From d5f34be0fd3ba81d6fd9678946f9efe59b8512b9 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 02:58:09 -0600 Subject: [PATCH 1/7] refactor: align worktree with domain-grouping branch --- CLAUDE.md | 25 +- src/ast-analysis/engine.js | 4 +- src/ast-analysis/shared.js | 4 +- src/cli.js | 2 +- src/cli/commands/ast.js | 2 +- src/cli/commands/batch.js | 2 +- src/cli/commands/check.js | 2 +- src/cli/commands/co-change.js | 2 +- src/cli/commands/info.js | 4 +- src/cli/commands/registry.js | 4 +- src/cli/commands/triage.js | 2 +- src/cli/index.js | 6 +- src/cli/shared/options.js | 2 +- src/commands/check.js | 2 +- src/db/connection.js | 4 +- src/db/migrations.js | 2 +- src/db/query-builder.js | 4 +- src/db/repository/graph-read.js | 2 +- src/db/repository/in-memory-repository.js | 4 +- src/db/repository/nodes.js | 4 +- src/domain/analysis/context.js | 2 +- src/domain/analysis/dependencies.js | 2 +- src/domain/analysis/exports.js | 2 +- src/domain/analysis/impact.js | 4 +- src/domain/analysis/module-map.js | 2 +- src/domain/analysis/roles.js | 2 +- src/domain/analysis/symbol-lookup.js | 4 +- src/domain/graph/builder/helpers.js | 4 +- src/domain/graph/builder/incremental.js | 6 +- src/domain/graph/builder/pipeline.js | 6 +- .../graph/builder/stages/build-edges.js | 2 +- .../graph/builder/stages/build-structure.js | 4 +- .../graph/builder/stages/collect-files.js | 4 +- .../graph/builder/stages/detect-changes.js | 8 +- src/domain/graph/builder/stages/finalize.js | 6 +- .../graph/builder/stages/parse-files.js | 4 +- .../graph/builder/stages/resolve-imports.js | 2 +- .../graph/builder/stages/run-analyses.js | 2 +- src/domain/graph/change-journal.js | 130 +++++ src/domain/graph/cycles.js | 2 +- src/domain/graph/journal.js | 109 ++++ src/domain/graph/resolve.js | 4 +- src/domain/graph/watcher.js | 12 +- src/domain/parser.js | 516 ++++++++++++++++++ src/domain/queries.js | 4 +- src/domain/search/generator.js | 4 +- src/domain/search/models.js | 4 +- src/domain/search/search/cli-formatter.js | 2 +- src/domain/search/search/semantic.js | 2 +- src/extractors/javascript.js | 2 +- src/features/ast.js | 4 +- src/features/audit.js | 2 +- src/features/batch.js | 2 +- src/features/boundaries.js | 2 +- src/features/cfg.js | 8 +- src/features/check.js | 2 +- src/features/cochange.js | 6 +- src/features/communities.js | 2 +- src/features/complexity.js | 10 +- src/features/dataflow.js | 8 +- src/features/export.js | 2 +- src/features/flow.js | 2 +- src/features/manifesto.js | 6 +- src/features/sequence.js | 2 +- src/features/snapshot.js | 4 +- src/features/structure.js | 6 +- src/features/triage.js | 4 +- src/index.js | 26 +- src/infrastructure/config.js | 139 +++++ src/infrastructure/logger.js | 24 + src/infrastructure/native.js | 112 ++++ src/infrastructure/registry.js | 173 ++++++ src/infrastructure/update-check.js | 160 ++++++ src/mcp/middleware.js | 2 +- src/mcp/server.js | 6 +- src/mcp/tools/list-repos.js | 2 +- src/presentation/result-formatter.js | 2 +- src/shared/constants.js | 39 ++ src/shared/errors.js | 78 +++ src/shared/file-utils.js | 4 +- src/shared/generators.js | 2 +- src/shared/kinds.js | 50 ++ src/shared/paginate.js | 105 ++++ tests/builder/detect-changes.test.js | 2 +- tests/engines/dataflow-parity.test.js | 6 +- tests/engines/parity.test.js | 6 +- tests/engines/query-walk-parity.test.js | 2 +- tests/graph/cycles.test.js | 2 +- tests/incremental/cache.test.js | 2 +- tests/incremental/watcher-incremental.test.js | 4 +- tests/integration/build-parity.test.js | 2 +- tests/integration/build.test.js | 2 +- tests/integration/complexity.test.js | 4 +- tests/integration/pagination.test.js | 2 +- tests/parsers/ast-all-langs.test.js | 4 +- tests/parsers/ast-nodes.test.js | 4 +- tests/parsers/cfg-all-langs.test.js | 4 +- tests/parsers/csharp.test.js | 2 +- tests/parsers/dataflow-csharp.test.js | 2 +- tests/parsers/dataflow-go.test.js | 2 +- tests/parsers/dataflow-java.test.js | 2 +- tests/parsers/dataflow-javascript.test.js | 2 +- tests/parsers/dataflow-php.test.js | 2 +- tests/parsers/dataflow-python.test.js | 2 +- tests/parsers/dataflow-ruby.test.js | 2 +- tests/parsers/dataflow-rust.test.js | 2 +- tests/parsers/extended-kinds.test.js | 2 +- tests/parsers/go.test.js | 2 +- tests/parsers/java.test.js | 2 +- tests/parsers/javascript.test.js | 2 +- tests/parsers/php.test.js | 2 +- tests/parsers/ruby.test.js | 2 +- tests/parsers/rust.test.js | 2 +- tests/parsers/unified.test.js | 2 +- tests/resolution/parity.test.js | 2 +- tests/unit/cfg.test.js | 2 +- tests/unit/change-journal.test.js | 2 +- tests/unit/complexity.test.js | 2 +- tests/unit/config.test.js | 2 +- tests/unit/constants.test.js | 2 +- tests/unit/errors.test.js | 2 +- tests/unit/journal.test.js | 2 +- tests/unit/logger.test.js | 9 +- tests/unit/mcp.test.js | 12 +- tests/unit/parser.test.js | 2 +- tests/unit/registry.test.js | 4 +- tests/unit/update-check.test.js | 6 +- tests/unit/visitor.test.js | 2 +- 128 files changed, 1860 insertions(+), 207 deletions(-) create mode 100644 src/domain/graph/change-journal.js create mode 100644 src/domain/graph/journal.js create mode 100644 src/domain/parser.js create mode 100644 src/infrastructure/config.js create mode 100644 src/infrastructure/logger.js create mode 100644 src/infrastructure/native.js create mode 100644 src/infrastructure/registry.js create mode 100644 src/infrastructure/update-check.js create mode 100644 src/shared/constants.js create mode 100644 src/shared/errors.js create mode 100644 src/shared/kinds.js create mode 100644 src/shared/paginate.js diff --git a/CLAUDE.md b/CLAUDE.md index efef122f..512162b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,21 +42,28 @@ JS source is plain JavaScript (ES modules) in `src/`. No transpilation step. The |------|------| | `cli.js` | Commander CLI entry point (`bin.codegraph`) | | `index.js` | Programmatic API exports | -| `parser.js` | tree-sitter WASM wrapper; `LANGUAGE_REGISTRY` + per-language extractors for functions, classes, methods, imports, exports, call sites | -| `config.js` | `.codegraphrc.json` loading, env overrides, `apiKeyCommand` secret resolution | -| `constants.js` | `EXTENSIONS` (derived from parser registry) and `IGNORE_DIRS` constants | -| `native.js` | Native napi-rs addon loader with WASM fallback | -| `registry.js` | Global repo registry (`~/.codegraph/registry.json`) for multi-repo MCP | -| `paginate.js` | Pagination helpers for bounded query results | -| `logger.js` | Structured logging (`warn`, `debug`, `info`, `error`) | +| **`shared/`** | **Cross-cutting constants and utilities** | +| `shared/constants.js` | `EXTENSIONS` (derived from parser registry) and `IGNORE_DIRS` constants | +| `shared/errors.js` | Domain error hierarchy (`CodegraphError`, `ConfigError`, `ParseError`, etc.) | +| `shared/kinds.js` | Symbol and edge kind constants (`CORE_SYMBOL_KINDS`, `EVERY_SYMBOL_KIND`, `VALID_ROLES`) | +| `shared/paginate.js` | Pagination helpers for bounded query results | +| **`infrastructure/`** | **Platform and I/O plumbing** | +| `infrastructure/config.js` | `.codegraphrc.json` loading, env overrides, `apiKeyCommand` secret resolution | +| `infrastructure/logger.js` | Structured logging (`warn`, `debug`, `info`, `error`) | +| `infrastructure/native.js` | Native napi-rs addon loader with WASM fallback | +| `infrastructure/registry.js` | Global repo registry (`~/.codegraph/registry.json`) for multi-repo MCP | +| `infrastructure/update-check.js` | npm update availability check | | **`db/`** | **Database layer** | | `db/index.js` | SQLite schema and operations (`better-sqlite3`) | | **`domain/`** | **Core domain logic** | -| `domain/queries.js` | Query functions: symbol search, file deps, impact analysis, diff-impact; `SYMBOL_KINDS` constant defines all node kinds | +| `domain/parser.js` | tree-sitter WASM wrapper; `LANGUAGE_REGISTRY` + per-language extractors for functions, classes, methods, imports, exports, call sites | +| `domain/queries.js` | Query functions: symbol search, file deps, impact analysis, diff-impact | | `domain/graph/builder.js` | Graph building: file collection, parsing, import resolution, incremental hashing | | `domain/graph/cycles.js` | Circular dependency detection (delegates to `graph/` subsystem) | | `domain/graph/resolve.js` | Import resolution (supports native batch mode) | | `domain/graph/watcher.js` | Watch mode for incremental rebuilds | +| `domain/graph/journal.js` | Change journal for incremental builds | +| `domain/graph/change-journal.js` | Change event tracking (NDJSON) | | `domain/analysis/` | Query-layer analysis: context, dependencies, exports, impact, module-map, roles, symbol-lookup | | `domain/search/` | Embedding subsystem: model management, vector generation, semantic/keyword/hybrid search, CLI formatting | | **`features/`** | **Composable feature modules** | @@ -87,7 +94,7 @@ JS source is plain JavaScript (ES modules) in `src/`. No transpilation step. The - **Dual-engine architecture:** Native Rust parsing via napi-rs (`crates/codegraph-core/`) with automatic fallback to WASM. Controlled by `--engine native|wasm|auto` (default: `auto`) - Platform-specific prebuilt binaries published as optional npm packages (`@optave/codegraph-{platform}-{arch}`) - WASM grammars are built from devDeps on `npm install` (via `prepare` script) and not committed to git — used as fallback when native addon is unavailable -- **Language parser registry:** `LANGUAGE_REGISTRY` in `parser.js` is the single source of truth for all supported languages — maps each language to `{ id, extensions, grammarFile, extractor, required }`. `EXTENSIONS` in `constants.js` is derived from the registry. Adding a new language requires one registry entry + extractor function +- **Language parser registry:** `LANGUAGE_REGISTRY` in `domain/parser.js` is the single source of truth for all supported languages — maps each language to `{ id, extensions, grammarFile, extractor, required }`. `EXTENSIONS` in `shared/constants.js` is derived from the registry. Adding a new language requires one registry entry + extractor function - **Node kinds:** `SYMBOL_KINDS` in `domain/queries.js` lists all valid kinds: `function`, `method`, `class`, `interface`, `type`, `struct`, `enum`, `trait`, `record`, `module`. Language-specific types use their native kind (e.g. Go structs → `struct`, Rust traits → `trait`, Ruby modules → `module`) rather than mapping everything to `class`/`interface` - `@huggingface/transformers` and `@modelcontextprotocol/sdk` are optional dependencies, lazy-loaded - Non-required parsers (all except JS/TS/TSX) fail gracefully if their WASM grammar is unavailable diff --git a/src/ast-analysis/engine.js b/src/ast-analysis/engine.js index 3998e313..6775a7f0 100644 --- a/src/ast-analysis/engine.js +++ b/src/ast-analysis/engine.js @@ -18,7 +18,7 @@ import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { bulkNodeIdsByFile } from '../db/index.js'; -import { debug } from '../logger.js'; +import { debug } from '../infrastructure/logger.js'; import { computeLOCMetrics, computeMaintainabilityIndex } from './metrics.js'; import { AST_TYPE_MAPS, @@ -45,7 +45,7 @@ const WALK_EXTENSIONS = buildExtensionSet(AST_TYPE_MAPS); let _parserModule = null; async function getParserModule() { - if (!_parserModule) _parserModule = await import('../parser.js'); + if (!_parserModule) _parserModule = await import('../domain/parser.js'); return _parserModule; } diff --git a/src/ast-analysis/shared.js b/src/ast-analysis/shared.js index f5d8e0be..964f9a06 100644 --- a/src/ast-analysis/shared.js +++ b/src/ast-analysis/shared.js @@ -2,8 +2,8 @@ * Shared utilities for AST analysis modules (complexity, CFG, dataflow, AST nodes). */ -import { ConfigError } from '../errors.js'; -import { LANGUAGE_REGISTRY } from '../parser.js'; +import { LANGUAGE_REGISTRY } from '../domain/parser.js'; +import { ConfigError } from '../shared/errors.js'; // ─── Generic Rule Factory ───────────────────────────────────────────────── diff --git a/src/cli.js b/src/cli.js index 6318f0e4..449bd25f 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import { run } from './cli/index.js'; -import { CodegraphError } from './errors.js'; +import { CodegraphError } from './shared/errors.js'; run().catch((err) => { if (err instanceof CodegraphError) { diff --git a/src/cli/commands/ast.js b/src/cli/commands/ast.js index cc9124b0..1588804a 100644 --- a/src/cli/commands/ast.js +++ b/src/cli/commands/ast.js @@ -1,4 +1,4 @@ -import { ConfigError } from '../../errors.js'; +import { ConfigError } from '../../shared/errors.js'; export const command = { name: 'ast [pattern]', diff --git a/src/cli/commands/batch.js b/src/cli/commands/batch.js index 8ba4f99c..5d740ad1 100644 --- a/src/cli/commands/batch.js +++ b/src/cli/commands/batch.js @@ -1,8 +1,8 @@ import fs from 'node:fs'; import { batch } from '../../commands/batch.js'; import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; -import { ConfigError } from '../../errors.js'; import { BATCH_COMMANDS, multiBatchData, splitTargets } from '../../features/batch.js'; +import { ConfigError } from '../../shared/errors.js'; export const command = { name: 'batch [targets...]', diff --git a/src/cli/commands/check.js b/src/cli/commands/check.js index 43d98a3a..8c5f29ca 100644 --- a/src/cli/commands/check.js +++ b/src/cli/commands/check.js @@ -1,5 +1,5 @@ import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; -import { ConfigError } from '../../errors.js'; +import { ConfigError } from '../../shared/errors.js'; export const command = { name: 'check [ref]', diff --git a/src/cli/commands/co-change.js b/src/cli/commands/co-change.js index ef4885b5..83b29e75 100644 --- a/src/cli/commands/co-change.js +++ b/src/cli/commands/co-change.js @@ -1,4 +1,4 @@ -import { AnalysisError } from '../../errors.js'; +import { AnalysisError } from '../../shared/errors.js'; export const command = { name: 'co-change [file]', diff --git a/src/cli/commands/info.js b/src/cli/commands/info.js index a0fd2d6c..b4c0a28a 100644 --- a/src/cli/commands/info.js +++ b/src/cli/commands/info.js @@ -3,9 +3,9 @@ export const command = { description: 'Show codegraph engine info and diagnostics', async execute(_args, _opts, ctx) { const { getNativePackageVersion, isNativeAvailable, loadNative } = await import( - '../../native.js' + '../../infrastructure/native.js' ); - const { getActiveEngine } = await import('../../parser.js'); + const { getActiveEngine } = await import('../../domain/parser.js'); const engine = ctx.program.opts().engine; const { name: activeName, version: activeVersion } = getActiveEngine({ engine }); diff --git a/src/cli/commands/registry.js b/src/cli/commands/registry.js index 9e516d9a..f2803116 100644 --- a/src/cli/commands/registry.js +++ b/src/cli/commands/registry.js @@ -1,13 +1,13 @@ import fs from 'node:fs'; import path from 'node:path'; -import { ConfigError } from '../../errors.js'; import { listRepos, pruneRegistry, REGISTRY_PATH, registerRepo, unregisterRepo, -} from '../../registry.js'; +} from '../../infrastructure/registry.js'; +import { ConfigError } from '../../shared/errors.js'; export const command = { name: 'registry', diff --git a/src/cli/commands/triage.js b/src/cli/commands/triage.js index 851df8e9..23e07183 100644 --- a/src/cli/commands/triage.js +++ b/src/cli/commands/triage.js @@ -1,5 +1,5 @@ import { EVERY_SYMBOL_KIND, VALID_ROLES } from '../../domain/queries.js'; -import { ConfigError } from '../../errors.js'; +import { ConfigError } from '../../shared/errors.js'; export const command = { name: 'triage', diff --git a/src/cli/index.js b/src/cli/index.js index 02d36a77..057bae31 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -2,9 +2,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { Command } from 'commander'; -import { ConfigError } from '../errors.js'; -import { setVerbose } from '../logger.js'; -import { checkForUpdates, printUpdateNotification } from '../update-check.js'; +import { setVerbose } from '../infrastructure/logger.js'; +import { checkForUpdates, printUpdateNotification } from '../infrastructure/update-check.js'; +import { ConfigError } from '../shared/errors.js'; import { applyQueryOpts, config, formatSize, resolveNoTests } from './shared/options.js'; import { outputResult } from './shared/output.js'; diff --git a/src/cli/shared/options.js b/src/cli/shared/options.js index 9f596076..7f7552c3 100644 --- a/src/cli/shared/options.js +++ b/src/cli/shared/options.js @@ -1,4 +1,4 @@ -import { loadConfig } from '../../config.js'; +import { loadConfig } from '../../infrastructure/config.js'; const config = loadConfig(process.cwd()); diff --git a/src/commands/check.js b/src/commands/check.js index eb592fe0..1dc0c4fc 100644 --- a/src/commands/check.js +++ b/src/commands/check.js @@ -1,6 +1,6 @@ -import { AnalysisError } from '../errors.js'; import { checkData } from '../features/check.js'; import { outputResult } from '../infrastructure/result-formatter.js'; +import { AnalysisError } from '../shared/errors.js'; /** * CLI formatter — prints check results and sets exitCode 1 on failure. diff --git a/src/db/connection.js b/src/db/connection.js index d8b34c21..acf87547 100644 --- a/src/db/connection.js +++ b/src/db/connection.js @@ -1,8 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; import Database from 'better-sqlite3'; -import { DbError } from '../errors.js'; -import { warn } from '../logger.js'; +import { warn } from '../infrastructure/logger.js'; +import { DbError } from '../shared/errors.js'; function isProcessAlive(pid) { try { diff --git a/src/db/migrations.js b/src/db/migrations.js index e3925cdf..3b38feff 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -1,4 +1,4 @@ -import { debug } from '../logger.js'; +import { debug } from '../infrastructure/logger.js'; // ─── Schema Migrations ───────────────────────────────────────────────── export const MIGRATIONS = [ diff --git a/src/db/query-builder.js b/src/db/query-builder.js index 12a15ecc..10dd1fca 100644 --- a/src/db/query-builder.js +++ b/src/db/query-builder.js @@ -1,5 +1,5 @@ -import { DbError } from '../errors.js'; -import { EVERY_EDGE_KIND } from '../kinds.js'; +import { DbError } from '../shared/errors.js'; +import { EVERY_EDGE_KIND } from '../shared/kinds.js'; // ─── Validation Helpers ───────────────────────────────────────────── diff --git a/src/db/repository/graph-read.js b/src/db/repository/graph-read.js index b514e9bc..8fd284ad 100644 --- a/src/db/repository/graph-read.js +++ b/src/db/repository/graph-read.js @@ -1,4 +1,4 @@ -import { CORE_SYMBOL_KINDS } from '../../kinds.js'; +import { CORE_SYMBOL_KINDS } from '../../shared/kinds.js'; import { cachedStmt } from './cached-stmt.js'; // ─── Statement caches (one prepared statement per db instance) ──────────── diff --git a/src/db/repository/in-memory-repository.js b/src/db/repository/in-memory-repository.js index 91205c3c..9d228ca1 100644 --- a/src/db/repository/in-memory-repository.js +++ b/src/db/repository/in-memory-repository.js @@ -1,5 +1,5 @@ -import { ConfigError } from '../../errors.js'; -import { CORE_SYMBOL_KINDS, EVERY_SYMBOL_KIND, VALID_ROLES } from '../../kinds.js'; +import { ConfigError } from '../../shared/errors.js'; +import { CORE_SYMBOL_KINDS, EVERY_SYMBOL_KIND, VALID_ROLES } from '../../shared/kinds.js'; import { escapeLike } from '../query-builder.js'; import { Repository } from './base.js'; diff --git a/src/db/repository/nodes.js b/src/db/repository/nodes.js index cabc2ce3..fbe2ddf0 100644 --- a/src/db/repository/nodes.js +++ b/src/db/repository/nodes.js @@ -1,5 +1,5 @@ -import { ConfigError } from '../../errors.js'; -import { EVERY_SYMBOL_KIND, VALID_ROLES } from '../../kinds.js'; +import { ConfigError } from '../../shared/errors.js'; +import { EVERY_SYMBOL_KIND, VALID_ROLES } from '../../shared/kinds.js'; import { escapeLike, NodeQuery } from '../query-builder.js'; import { cachedStmt } from './cached-stmt.js'; diff --git a/src/domain/analysis/context.js b/src/domain/analysis/context.js index 803fcc99..e3409208 100644 --- a/src/domain/analysis/context.js +++ b/src/domain/analysis/context.js @@ -14,7 +14,6 @@ import { openReadonlyOrFail, } from '../../db/index.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; -import { paginateResult } from '../../paginate.js'; import { createFileLinesReader, extractSignature, @@ -24,6 +23,7 @@ import { } from '../../shared/file-utils.js'; import { resolveMethodViaHierarchy } from '../../shared/hierarchy.js'; import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; import { findMatchingNodes } from './symbol-lookup.js'; function explainFileImpl(db, target, getFileLines) { diff --git a/src/domain/analysis/dependencies.js b/src/domain/analysis/dependencies.js index 63778733..e632470f 100644 --- a/src/domain/analysis/dependencies.js +++ b/src/domain/analysis/dependencies.js @@ -8,9 +8,9 @@ import { openReadonlyOrFail, } from '../../db/index.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; -import { paginateResult } from '../../paginate.js'; import { resolveMethodViaHierarchy } from '../../shared/hierarchy.js'; import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; import { findMatchingNodes } from './symbol-lookup.js'; export function fileDepsData(file, customDbPath, opts = {}) { diff --git a/src/domain/analysis/exports.js b/src/domain/analysis/exports.js index 6088656f..9af6b807 100644 --- a/src/domain/analysis/exports.js +++ b/src/domain/analysis/exports.js @@ -7,12 +7,12 @@ import { openReadonlyOrFail, } from '../../db/index.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; -import { paginateResult } from '../../paginate.js'; import { createFileLinesReader, extractSignature, extractSummary, } from '../../shared/file-utils.js'; +import { paginateResult } from '../../shared/paginate.js'; export function exportsData(file, customDbPath, opts = {}) { const db = openReadonlyOrFail(customDbPath); diff --git a/src/domain/analysis/impact.js b/src/domain/analysis/impact.js index 282e9c64..01b5cef6 100644 --- a/src/domain/analysis/impact.js +++ b/src/domain/analysis/impact.js @@ -1,7 +1,6 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import { loadConfig } from '../../config.js'; import { findDbPath, findDistinctCallers, @@ -13,9 +12,10 @@ import { import { evaluateBoundaries } from '../../features/boundaries.js'; import { coChangeForFiles } from '../../features/cochange.js'; import { ownersForFiles } from '../../features/owners.js'; +import { loadConfig } from '../../infrastructure/config.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; -import { paginateResult } from '../../paginate.js'; import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; import { findMatchingNodes } from './symbol-lookup.js'; export function impactAnalysisData(file, customDbPath, opts = {}) { diff --git a/src/domain/analysis/module-map.js b/src/domain/analysis/module-map.js index fedef93e..e6aa0936 100644 --- a/src/domain/analysis/module-map.js +++ b/src/domain/analysis/module-map.js @@ -1,8 +1,8 @@ import path from 'node:path'; import { openReadonlyOrFail, testFilterSQL } from '../../db/index.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; -import { LANGUAGE_REGISTRY } from '../../parser.js'; import { findCycles } from '../graph/cycles.js'; +import { LANGUAGE_REGISTRY } from '../parser.js'; export const FALSE_POSITIVE_NAMES = new Set([ 'run', diff --git a/src/domain/analysis/roles.js b/src/domain/analysis/roles.js index d295515f..a54362fd 100644 --- a/src/domain/analysis/roles.js +++ b/src/domain/analysis/roles.js @@ -1,7 +1,7 @@ import { openReadonlyOrFail } from '../../db/index.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; -import { paginateResult } from '../../paginate.js'; import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; export function rolesData(customDbPath, opts = {}) { const db = openReadonlyOrFail(customDbPath); diff --git a/src/domain/analysis/symbol-lookup.js b/src/domain/analysis/symbol-lookup.js index e269a42d..47a7d403 100644 --- a/src/domain/analysis/symbol-lookup.js +++ b/src/domain/analysis/symbol-lookup.js @@ -14,9 +14,9 @@ import { openReadonlyOrFail, } from '../../db/index.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; -import { ALL_SYMBOL_KINDS } from '../../kinds.js'; -import { paginateResult } from '../../paginate.js'; +import { ALL_SYMBOL_KINDS } from '../../shared/kinds.js'; import { getFileHash, normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; const FUNCTION_KINDS = ['function', 'method', 'class']; diff --git a/src/domain/graph/builder/helpers.js b/src/domain/graph/builder/helpers.js index a333f456..038de4c2 100644 --- a/src/domain/graph/builder/helpers.js +++ b/src/domain/graph/builder/helpers.js @@ -6,9 +6,9 @@ import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import { EXTENSIONS, IGNORE_DIRS } from '../../../constants.js'; import { purgeFilesData } from '../../../db/index.js'; -import { warn } from '../../../logger.js'; +import { warn } from '../../../infrastructure/logger.js'; +import { EXTENSIONS, IGNORE_DIRS } from '../../../shared/constants.js'; export const BUILTIN_RECEIVERS = new Set([ 'console', diff --git a/src/domain/graph/builder/incremental.js b/src/domain/graph/builder/incremental.js index 7183d4b7..f04a136e 100644 --- a/src/domain/graph/builder/incremental.js +++ b/src/domain/graph/builder/incremental.js @@ -6,9 +6,9 @@ */ import fs from 'node:fs'; import path from 'node:path'; -import { normalizePath } from '../../../constants.js'; -import { warn } from '../../../logger.js'; -import { parseFileIncremental } from '../../../parser.js'; +import { warn } from '../../../infrastructure/logger.js'; +import { normalizePath } from '../../../shared/constants.js'; +import { parseFileIncremental } from '../../parser.js'; import { computeConfidence, resolveImportPath } from '../resolve.js'; import { BUILTIN_RECEIVERS, readFileSafe } from './helpers.js'; diff --git a/src/domain/graph/builder/pipeline.js b/src/domain/graph/builder/pipeline.js index d1be7ebb..ea9848c5 100644 --- a/src/domain/graph/builder/pipeline.js +++ b/src/domain/graph/builder/pipeline.js @@ -6,10 +6,10 @@ */ import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { loadConfig } from '../../../config.js'; import { closeDb, getBuildMeta, initSchema, MIGRATIONS, openDb } from '../../../db/index.js'; -import { info } from '../../../logger.js'; -import { getActiveEngine } from '../../../parser.js'; +import { loadConfig } from '../../../infrastructure/config.js'; +import { info } from '../../../infrastructure/logger.js'; +import { getActiveEngine } from '../../parser.js'; import { PipelineContext } from './context.js'; import { loadPathAliases } from './helpers.js'; import { buildEdges } from './stages/build-edges.js'; diff --git a/src/domain/graph/builder/stages/build-edges.js b/src/domain/graph/builder/stages/build-edges.js index 33aa66ca..a8879b62 100644 --- a/src/domain/graph/builder/stages/build-edges.js +++ b/src/domain/graph/builder/stages/build-edges.js @@ -7,7 +7,7 @@ import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { getNodeId } from '../../../../db/index.js'; -import { loadNative } from '../../../../native.js'; +import { loadNative } from '../../../../infrastructure/native.js'; import { computeConfidence } from '../../resolve.js'; import { BUILTIN_RECEIVERS, batchInsertEdges } from '../helpers.js'; import { getResolved, isBarrelFile, resolveBarrelExport } from './resolve-imports.js'; diff --git a/src/domain/graph/builder/stages/build-structure.js b/src/domain/graph/builder/stages/build-structure.js index ec235be2..f4737df9 100644 --- a/src/domain/graph/builder/stages/build-structure.js +++ b/src/domain/graph/builder/stages/build-structure.js @@ -5,8 +5,8 @@ */ import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { normalizePath } from '../../../../constants.js'; -import { debug } from '../../../../logger.js'; +import { debug } from '../../../../infrastructure/logger.js'; +import { normalizePath } from '../../../../shared/constants.js'; import { readFileSafe } from '../helpers.js'; /** diff --git a/src/domain/graph/builder/stages/collect-files.js b/src/domain/graph/builder/stages/collect-files.js index 97d183ce..9f3eb636 100644 --- a/src/domain/graph/builder/stages/collect-files.js +++ b/src/domain/graph/builder/stages/collect-files.js @@ -5,8 +5,8 @@ */ import fs from 'node:fs'; import path from 'node:path'; -import { normalizePath } from '../../../../constants.js'; -import { info } from '../../../../logger.js'; +import { info } from '../../../../infrastructure/logger.js'; +import { normalizePath } from '../../../../shared/constants.js'; import { collectFiles as collectFilesUtil } from '../helpers.js'; /** diff --git a/src/domain/graph/builder/stages/detect-changes.js b/src/domain/graph/builder/stages/detect-changes.js index 29d25a8a..50ffbd1d 100644 --- a/src/domain/graph/builder/stages/detect-changes.js +++ b/src/domain/graph/builder/stages/detect-changes.js @@ -6,11 +6,11 @@ */ import fs from 'node:fs'; import path from 'node:path'; -import { normalizePath } from '../../../../constants.js'; import { closeDb } from '../../../../db/index.js'; -import { readJournal, writeJournalHeader } from '../../../../journal.js'; -import { debug, info } from '../../../../logger.js'; -import { parseFilesAuto } from '../../../../parser.js'; +import { debug, info } from '../../../../infrastructure/logger.js'; +import { normalizePath } from '../../../../shared/constants.js'; +import { parseFilesAuto } from '../../../parser.js'; +import { readJournal, writeJournalHeader } from '../../journal.js'; import { fileHash, fileStat, purgeFilesFromGraph, readFileSafe } from '../helpers.js'; /** diff --git a/src/domain/graph/builder/stages/finalize.js b/src/domain/graph/builder/stages/finalize.js index e82411d7..6b493785 100644 --- a/src/domain/graph/builder/stages/finalize.js +++ b/src/domain/graph/builder/stages/finalize.js @@ -7,8 +7,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { closeDb, getBuildMeta, setBuildMeta } from '../../../../db/index.js'; -import { writeJournalHeader } from '../../../../journal.js'; -import { debug, info, warn } from '../../../../logger.js'; +import { debug, info, warn } from '../../../../infrastructure/logger.js'; +import { writeJournalHeader } from '../../journal.js'; const __builderDir = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/i, '$1')); const CODEGRAPH_VERSION = JSON.parse( @@ -127,7 +127,7 @@ export async function finalize(ctx) { debug(`Skipping auto-registration for temp directory: ${resolvedRoot}`); } else { try { - const { registerRepo } = await import('../../../../registry.js'); + const { registerRepo } = await import('../../../../infrastructure/registry.js'); registerRepo(rootDir); } catch (err) { debug(`Auto-registration failed: ${err.message}`); diff --git a/src/domain/graph/builder/stages/parse-files.js b/src/domain/graph/builder/stages/parse-files.js index 0d3e1167..6690bb5f 100644 --- a/src/domain/graph/builder/stages/parse-files.js +++ b/src/domain/graph/builder/stages/parse-files.js @@ -5,8 +5,8 @@ * Populates ctx.allSymbols, ctx.fileSymbols, ctx.filesToParse. */ import { performance } from 'node:perf_hooks'; -import { info } from '../../../../logger.js'; -import { parseFilesAuto } from '../../../../parser.js'; +import { info } from '../../../../infrastructure/logger.js'; +import { parseFilesAuto } from '../../../parser.js'; /** * @param {import('../context.js').PipelineContext} ctx diff --git a/src/domain/graph/builder/stages/resolve-imports.js b/src/domain/graph/builder/stages/resolve-imports.js index d2a39861..7d9bbe40 100644 --- a/src/domain/graph/builder/stages/resolve-imports.js +++ b/src/domain/graph/builder/stages/resolve-imports.js @@ -6,7 +6,7 @@ */ import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { parseFilesAuto } from '../../../../parser.js'; +import { parseFilesAuto } from '../../../parser.js'; import { resolveImportPath, resolveImportsBatch } from '../../resolve.js'; /** diff --git a/src/domain/graph/builder/stages/run-analyses.js b/src/domain/graph/builder/stages/run-analyses.js index f6dbbdb9..53384613 100644 --- a/src/domain/graph/builder/stages/run-analyses.js +++ b/src/domain/graph/builder/stages/run-analyses.js @@ -4,7 +4,7 @@ * Dispatches to the unified AST analysis engine (AST nodes, complexity, CFG, dataflow). * Filters out reverse-dep files for incremental builds. */ -import { debug, warn } from '../../../../logger.js'; +import { debug, warn } from '../../../../infrastructure/logger.js'; /** * @param {import('../context.js').PipelineContext} ctx diff --git a/src/domain/graph/change-journal.js b/src/domain/graph/change-journal.js new file mode 100644 index 00000000..7589b5a6 --- /dev/null +++ b/src/domain/graph/change-journal.js @@ -0,0 +1,130 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { debug, warn } from '../../infrastructure/logger.js'; + +export const CHANGE_EVENTS_FILENAME = 'change-events.ndjson'; +export const DEFAULT_MAX_BYTES = 1024 * 1024; // 1 MB + +/** + * Returns the absolute path to the NDJSON change events file. + */ +export function changeEventsPath(rootDir) { + return path.join(rootDir, '.codegraph', CHANGE_EVENTS_FILENAME); +} + +/** + * Compare old and new symbol arrays, returning added/removed/modified sets. + * Symbols are keyed on `name\0kind`. A symbol is "modified" if the same + * name+kind exists in both but the line changed. + * + * @param {Array<{name:string, kind:string, line:number}>} oldSymbols + * @param {Array<{name:string, kind:string, line:number}>} newSymbols + * @returns {{ added: Array, removed: Array, modified: Array }} + */ +export function diffSymbols(oldSymbols, newSymbols) { + const oldMap = new Map(); + for (const s of oldSymbols) { + oldMap.set(`${s.name}\0${s.kind}`, s); + } + + const newMap = new Map(); + for (const s of newSymbols) { + newMap.set(`${s.name}\0${s.kind}`, s); + } + + const added = []; + const removed = []; + const modified = []; + + for (const [key, s] of newMap) { + const old = oldMap.get(key); + if (!old) { + added.push({ name: s.name, kind: s.kind, line: s.line }); + } else if (old.line !== s.line) { + modified.push({ name: s.name, kind: s.kind, line: s.line }); + } + } + + for (const [key, s] of oldMap) { + if (!newMap.has(key)) { + removed.push({ name: s.name, kind: s.kind }); + } + } + + return { added, removed, modified }; +} + +/** + * Assemble a single change event object. + */ +export function buildChangeEvent(file, event, symbolDiff, counts) { + return { + ts: new Date().toISOString(), + file, + event, + symbols: symbolDiff, + counts: { + nodes: { before: counts.nodesBefore ?? 0, after: counts.nodesAfter ?? 0 }, + edges: { added: counts.edgesAdded ?? 0 }, + }, + }; +} + +/** + * Append change events as NDJSON lines to the change events file. + * Creates the .codegraph directory if needed. Non-fatal on failure. + */ +export function appendChangeEvents(rootDir, events) { + const filePath = changeEventsPath(rootDir); + const dir = path.dirname(filePath); + + try { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const lines = `${events.map((e) => JSON.stringify(e)).join('\n')}\n`; + fs.appendFileSync(filePath, lines); + debug(`Appended ${events.length} change event(s) to ${filePath}`); + } catch (err) { + warn(`Failed to append change events: ${err.message}`); + return; + } + + try { + rotateIfNeeded(filePath, DEFAULT_MAX_BYTES); + } catch { + /* rotation failure is non-fatal */ + } +} + +/** + * If the file exceeds maxBytes, keep the last ~half by finding + * the first newline at or after the midpoint and rewriting from there. + */ +export function rotateIfNeeded(filePath, maxBytes = DEFAULT_MAX_BYTES) { + let stat; + try { + stat = fs.statSync(filePath); + } catch { + return; // file doesn't exist, nothing to rotate + } + + if (stat.size <= maxBytes) return; + + try { + const buf = fs.readFileSync(filePath); + const mid = Math.floor(buf.length / 2); + const newlineIdx = buf.indexOf(0x0a, mid); + if (newlineIdx === -1) { + warn( + `Change events file exceeds ${maxBytes} bytes but contains no line breaks; skipping rotation`, + ); + return; + } + const kept = buf.slice(newlineIdx + 1); + fs.writeFileSync(filePath, kept); + debug(`Rotated change events: ${stat.size} → ${kept.length} bytes`); + } catch (err) { + warn(`Failed to rotate change events: ${err.message}`); + } +} diff --git a/src/domain/graph/cycles.js b/src/domain/graph/cycles.js index bed9fc03..c7872a61 100644 --- a/src/domain/graph/cycles.js +++ b/src/domain/graph/cycles.js @@ -1,7 +1,7 @@ import { tarjan } from '../../graph/algorithms/tarjan.js'; import { buildDependencyGraph } from '../../graph/builders/dependency.js'; import { CodeGraph } from '../../graph/model.js'; -import { loadNative } from '../../native.js'; +import { loadNative } from '../../infrastructure/native.js'; /** * Detect circular dependencies in the codebase using Tarjan's SCC algorithm. diff --git a/src/domain/graph/journal.js b/src/domain/graph/journal.js new file mode 100644 index 00000000..714889f2 --- /dev/null +++ b/src/domain/graph/journal.js @@ -0,0 +1,109 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { debug, warn } from '../../infrastructure/logger.js'; + +export const JOURNAL_FILENAME = 'changes.journal'; +const HEADER_PREFIX = '# codegraph-journal v1 '; + +/** + * Read and validate the change journal. + * Returns { valid, timestamp, changed[], removed[] } or { valid: false }. + */ +export function readJournal(rootDir) { + const journalPath = path.join(rootDir, '.codegraph', JOURNAL_FILENAME); + let content; + try { + content = fs.readFileSync(journalPath, 'utf-8'); + } catch { + return { valid: false }; + } + + const lines = content.split('\n'); + if (lines.length === 0 || !lines[0].startsWith(HEADER_PREFIX)) { + debug('Journal has malformed or missing header'); + return { valid: false }; + } + + const timestamp = Number(lines[0].slice(HEADER_PREFIX.length).trim()); + if (!Number.isFinite(timestamp) || timestamp <= 0) { + debug('Journal has invalid timestamp'); + return { valid: false }; + } + + const changed = []; + const removed = []; + const seenChanged = new Set(); + const seenRemoved = new Set(); + + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line || line.startsWith('#')) continue; + + if (line.startsWith('DELETED ')) { + const filePath = line.slice(8); + if (filePath && !seenRemoved.has(filePath)) { + seenRemoved.add(filePath); + removed.push(filePath); + } + } else { + if (!seenChanged.has(line)) { + seenChanged.add(line); + changed.push(line); + } + } + } + + return { valid: true, timestamp, changed, removed }; +} + +/** + * Append changed/deleted paths to the journal. + * Creates the journal with a header if it doesn't exist. + */ +export function appendJournalEntries(rootDir, entries) { + const dir = path.join(rootDir, '.codegraph'); + const journalPath = path.join(dir, JOURNAL_FILENAME); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + // If journal doesn't exist, create with a placeholder header + if (!fs.existsSync(journalPath)) { + fs.writeFileSync(journalPath, `${HEADER_PREFIX}0\n`); + } + + const lines = entries.map((e) => { + if (e.deleted) return `DELETED ${e.file}`; + return e.file; + }); + + fs.appendFileSync(journalPath, `${lines.join('\n')}\n`); +} + +/** + * Write a fresh journal header after a successful build. + * Atomic: write to temp file then rename. + */ +export function writeJournalHeader(rootDir, timestamp) { + const dir = path.join(rootDir, '.codegraph'); + const journalPath = path.join(dir, JOURNAL_FILENAME); + const tmpPath = `${journalPath}.tmp`; + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + try { + fs.writeFileSync(tmpPath, `${HEADER_PREFIX}${timestamp}\n`); + fs.renameSync(tmpPath, journalPath); + } catch (err) { + warn(`Failed to write journal header: ${err.message}`); + // Clean up temp file if rename failed + try { + fs.unlinkSync(tmpPath); + } catch { + /* ignore */ + } + } +} diff --git a/src/domain/graph/resolve.js b/src/domain/graph/resolve.js index 73179b67..5e0ab1d3 100644 --- a/src/domain/graph/resolve.js +++ b/src/domain/graph/resolve.js @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { normalizePath } from '../../constants.js'; -import { loadNative } from '../../native.js'; +import { loadNative } from '../../infrastructure/native.js'; +import { normalizePath } from '../../shared/constants.js'; // ── Alias format conversion ───────────────────────────────────────── diff --git a/src/domain/graph/watcher.js b/src/domain/graph/watcher.js index 58b56eeb..15b4b4a6 100644 --- a/src/domain/graph/watcher.js +++ b/src/domain/graph/watcher.js @@ -1,13 +1,13 @@ import fs from 'node:fs'; import path from 'node:path'; -import { appendChangeEvents, buildChangeEvent, diffSymbols } from '../../change-journal.js'; -import { EXTENSIONS, IGNORE_DIRS, normalizePath } from '../../constants.js'; import { closeDb, getNodeId as getNodeIdQuery, initSchema, openDb } from '../../db/index.js'; -import { DbError } from '../../errors.js'; -import { appendJournalEntries } from '../../journal.js'; -import { info } from '../../logger.js'; -import { createParseTreeCache, getActiveEngine } from '../../parser.js'; +import { info } from '../../infrastructure/logger.js'; +import { EXTENSIONS, IGNORE_DIRS, normalizePath } from '../../shared/constants.js'; +import { DbError } from '../../shared/errors.js'; +import { createParseTreeCache, getActiveEngine } from '../parser.js'; import { rebuildFile } from './builder/incremental.js'; +import { appendChangeEvents, buildChangeEvent, diffSymbols } from './change-journal.js'; +import { appendJournalEntries } from './journal.js'; function shouldIgnore(filePath) { const parts = filePath.split(path.sep); diff --git a/src/domain/parser.js b/src/domain/parser.js new file mode 100644 index 00000000..fb41d473 --- /dev/null +++ b/src/domain/parser.js @@ -0,0 +1,516 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Language, Parser, Query } from 'web-tree-sitter'; +import { warn } from '../infrastructure/logger.js'; +import { getNative, getNativePackageVersion, loadNative } from '../infrastructure/native.js'; + +// Re-export all extractors for backward compatibility +export { + extractCSharpSymbols, + extractGoSymbols, + extractHCLSymbols, + extractJavaSymbols, + extractPHPSymbols, + extractPythonSymbols, + extractRubySymbols, + extractRustSymbols, + extractSymbols, +} from '../extractors/index.js'; + +import { + extractCSharpSymbols, + extractGoSymbols, + extractHCLSymbols, + extractJavaSymbols, + extractPHPSymbols, + extractPythonSymbols, + extractRubySymbols, + extractRustSymbols, + extractSymbols, +} from '../extractors/index.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function grammarPath(name) { + return path.join(__dirname, '..', '..', 'grammars', name); +} + +let _initialized = false; + +// Memoized parsers — avoids reloading WASM grammars on every createParsers() call +let _cachedParsers = null; + +// Cached Language objects — WASM-backed, must be .delete()'d explicitly +let _cachedLanguages = null; + +// Query cache for JS/TS/TSX extractors (populated during createParsers) +const _queryCache = new Map(); + +// Shared patterns for all JS/TS/TSX (class_declaration excluded — name type differs) +const COMMON_QUERY_PATTERNS = [ + '(function_declaration name: (identifier) @fn_name) @fn_node', + '(variable_declarator name: (identifier) @varfn_name value: (arrow_function) @varfn_value)', + '(variable_declarator name: (identifier) @varfn_name value: (function_expression) @varfn_value)', + '(method_definition name: (property_identifier) @meth_name) @meth_node', + '(import_statement source: (string) @imp_source) @imp_node', + '(export_statement) @exp_node', + '(call_expression function: (identifier) @callfn_name) @callfn_node', + '(call_expression function: (member_expression) @callmem_fn) @callmem_node', + '(call_expression function: (subscript_expression) @callsub_fn) @callsub_node', + '(expression_statement (assignment_expression left: (member_expression) @assign_left right: (_) @assign_right)) @assign_node', +]; + +// JS: class name is (identifier) +const JS_CLASS_PATTERN = '(class_declaration name: (identifier) @cls_name) @cls_node'; + +// TS/TSX: class name is (type_identifier), plus interface and type alias +const TS_EXTRA_PATTERNS = [ + '(class_declaration name: (type_identifier) @cls_name) @cls_node', + '(interface_declaration name: (type_identifier) @iface_name) @iface_node', + '(type_alias_declaration name: (type_identifier) @type_name) @type_node', +]; + +export async function createParsers() { + if (_cachedParsers) return _cachedParsers; + + if (!_initialized) { + await Parser.init(); + _initialized = true; + } + + const parsers = new Map(); + const languages = new Map(); + for (const entry of LANGUAGE_REGISTRY) { + try { + const lang = await Language.load(grammarPath(entry.grammarFile)); + const parser = new Parser(); + parser.setLanguage(lang); + parsers.set(entry.id, parser); + languages.set(entry.id, lang); + // Compile and cache tree-sitter Query for JS/TS/TSX extractors + if (entry.extractor === extractSymbols && !_queryCache.has(entry.id)) { + const isTS = entry.id === 'typescript' || entry.id === 'tsx'; + const patterns = isTS + ? [...COMMON_QUERY_PATTERNS, ...TS_EXTRA_PATTERNS] + : [...COMMON_QUERY_PATTERNS, JS_CLASS_PATTERN]; + _queryCache.set(entry.id, new Query(lang, patterns.join('\n'))); + } + } catch (e) { + if (entry.required) throw e; + warn( + `${entry.id} parser failed to initialize: ${e.message}. ${entry.id} files will be skipped.`, + ); + parsers.set(entry.id, null); + } + } + _cachedParsers = parsers; + _cachedLanguages = languages; + return parsers; +} + +/** + * Dispose all cached WASM parsers and queries to free WASM linear memory. + * Call this between repeated builds in the same process (e.g. benchmarks) + * to prevent memory accumulation that can cause segfaults. + */ +export function disposeParsers() { + if (_cachedParsers) { + for (const [, parser] of _cachedParsers) { + if (parser && typeof parser.delete === 'function') { + try { + parser.delete(); + } catch {} + } + } + _cachedParsers = null; + } + for (const [, query] of _queryCache) { + if (query && typeof query.delete === 'function') { + try { + query.delete(); + } catch {} + } + } + _queryCache.clear(); + if (_cachedLanguages) { + for (const [, lang] of _cachedLanguages) { + if (lang && typeof lang.delete === 'function') { + try { + lang.delete(); + } catch {} + } + } + _cachedLanguages = null; + } + _initialized = false; +} + +export function getParser(parsers, filePath) { + const ext = path.extname(filePath); + const entry = _extToLang.get(ext); + if (!entry) return null; + return parsers.get(entry.id) || null; +} + +/** + * Pre-parse files missing `_tree` via WASM so downstream phases (CFG, dataflow) + * don't each need to create parsers and re-parse independently. + * Only parses files whose extension is in SUPPORTED_EXTENSIONS. + * + * @param {Map} fileSymbols - Map + * @param {string} rootDir - absolute project root + */ +export async function ensureWasmTrees(fileSymbols, rootDir) { + // Check if any file needs a tree + let needsParse = false; + for (const [relPath, symbols] of fileSymbols) { + if (!symbols._tree) { + const ext = path.extname(relPath).toLowerCase(); + if (_extToLang.has(ext)) { + needsParse = true; + break; + } + } + } + if (!needsParse) return; + + const parsers = await createParsers(); + + for (const [relPath, symbols] of fileSymbols) { + if (symbols._tree) continue; + const ext = path.extname(relPath).toLowerCase(); + const entry = _extToLang.get(ext); + if (!entry) continue; + const parser = parsers.get(entry.id); + if (!parser) continue; + + const absPath = path.join(rootDir, relPath); + let code; + try { + code = fs.readFileSync(absPath, 'utf-8'); + } catch { + continue; + } + try { + symbols._tree = parser.parse(code); + symbols._langId = entry.id; + } catch { + // skip files that fail to parse + } + } +} + +/** + * Check whether the required WASM grammar files exist on disk. + */ +export function isWasmAvailable() { + return LANGUAGE_REGISTRY.filter((e) => e.required).every((e) => + fs.existsSync(grammarPath(e.grammarFile)), + ); +} + +// ── Unified API ────────────────────────────────────────────────────────────── + +function resolveEngine(opts = {}) { + const pref = opts.engine || 'auto'; + if (pref === 'wasm') return { name: 'wasm', native: null }; + if (pref === 'native' || pref === 'auto') { + const native = loadNative(); + if (native) return { name: 'native', native }; + if (pref === 'native') { + getNative(); // throws with detailed error + install instructions + } + } + return { name: 'wasm', native: null }; +} + +/** + * Patch native engine output in-place for the few remaining semantic transforms. + * With #[napi(js_name)] on Rust types, most fields already arrive as camelCase. + * This only handles: + * - _lineCount compat for builder.js + * - Backward compat for older native binaries missing js_name annotations + * - dataflow argFlows/mutations bindingType → binding wrapper + */ +function patchNativeResult(r) { + // lineCount: napi(js_name) emits "lineCount"; older binaries may emit "line_count" + r.lineCount = r.lineCount ?? r.line_count ?? null; + r._lineCount = r.lineCount; + + // Backward compat for older binaries missing js_name annotations + if (r.definitions) { + for (const d of r.definitions) { + if (d.endLine === undefined && d.end_line !== undefined) { + d.endLine = d.end_line; + } + } + } + if (r.imports) { + for (const i of r.imports) { + if (i.typeOnly === undefined) i.typeOnly = i.type_only; + if (i.wildcardReexport === undefined) i.wildcardReexport = i.wildcard_reexport; + if (i.pythonImport === undefined) i.pythonImport = i.python_import; + if (i.goImport === undefined) i.goImport = i.go_import; + if (i.rustUse === undefined) i.rustUse = i.rust_use; + if (i.javaImport === undefined) i.javaImport = i.java_import; + if (i.csharpUsing === undefined) i.csharpUsing = i.csharp_using; + if (i.rubyRequire === undefined) i.rubyRequire = i.ruby_require; + if (i.phpUse === undefined) i.phpUse = i.php_use; + if (i.dynamicImport === undefined) i.dynamicImport = i.dynamic_import; + } + } + + // dataflow: wrap bindingType into binding object for argFlows and mutations + if (r.dataflow) { + if (r.dataflow.argFlows) { + for (const f of r.dataflow.argFlows) { + f.binding = f.bindingType ? { type: f.bindingType } : null; + } + } + if (r.dataflow.mutations) { + for (const m of r.dataflow.mutations) { + m.binding = m.bindingType ? { type: m.bindingType } : null; + } + } + } + + return r; +} + +/** + * Declarative registry of all supported languages. + * Adding a new language requires only a new entry here + its extractor function. + */ +export const LANGUAGE_REGISTRY = [ + { + id: 'javascript', + extensions: ['.js', '.jsx', '.mjs', '.cjs'], + grammarFile: 'tree-sitter-javascript.wasm', + extractor: extractSymbols, + required: true, + }, + { + id: 'typescript', + extensions: ['.ts'], + grammarFile: 'tree-sitter-typescript.wasm', + extractor: extractSymbols, + required: true, + }, + { + id: 'tsx', + extensions: ['.tsx'], + grammarFile: 'tree-sitter-tsx.wasm', + extractor: extractSymbols, + required: true, + }, + { + id: 'hcl', + extensions: ['.tf', '.hcl'], + grammarFile: 'tree-sitter-hcl.wasm', + extractor: extractHCLSymbols, + required: false, + }, + { + id: 'python', + extensions: ['.py'], + grammarFile: 'tree-sitter-python.wasm', + extractor: extractPythonSymbols, + required: false, + }, + { + id: 'go', + extensions: ['.go'], + grammarFile: 'tree-sitter-go.wasm', + extractor: extractGoSymbols, + required: false, + }, + { + id: 'rust', + extensions: ['.rs'], + grammarFile: 'tree-sitter-rust.wasm', + extractor: extractRustSymbols, + required: false, + }, + { + id: 'java', + extensions: ['.java'], + grammarFile: 'tree-sitter-java.wasm', + extractor: extractJavaSymbols, + required: false, + }, + { + id: 'csharp', + extensions: ['.cs'], + grammarFile: 'tree-sitter-c_sharp.wasm', + extractor: extractCSharpSymbols, + required: false, + }, + { + id: 'ruby', + extensions: ['.rb'], + grammarFile: 'tree-sitter-ruby.wasm', + extractor: extractRubySymbols, + required: false, + }, + { + id: 'php', + extensions: ['.php'], + grammarFile: 'tree-sitter-php.wasm', + extractor: extractPHPSymbols, + required: false, + }, +]; + +const _extToLang = new Map(); +for (const entry of LANGUAGE_REGISTRY) { + for (const ext of entry.extensions) { + _extToLang.set(ext, entry); + } +} + +export const SUPPORTED_EXTENSIONS = new Set(_extToLang.keys()); + +/** + * WASM extraction helper: picks the right extractor based on file extension. + */ +function wasmExtractSymbols(parsers, filePath, code) { + const parser = getParser(parsers, filePath); + if (!parser) return null; + + let tree; + try { + tree = parser.parse(code); + } catch (e) { + warn(`Parse error in ${filePath}: ${e.message}`); + return null; + } + + const ext = path.extname(filePath); + const entry = _extToLang.get(ext); + if (!entry) return null; + const query = _queryCache.get(entry.id) || null; + const symbols = entry.extractor(tree, filePath, query); + return symbols ? { symbols, tree, langId: entry.id } : null; +} + +/** + * Parse a single file and return normalized symbols. + * + * @param {string} filePath Absolute path to the file. + * @param {string} source Source code string. + * @param {object} [opts] Options: { engine: 'native'|'wasm'|'auto' } + * @returns {Promise<{definitions, calls, imports, classes, exports}|null>} + */ +export async function parseFileAuto(filePath, source, opts = {}) { + const { native } = resolveEngine(opts); + + if (native) { + const result = native.parseFile(filePath, source, !!opts.dataflow, opts.ast !== false); + return result ? patchNativeResult(result) : null; + } + + // WASM path + const parsers = await createParsers(); + const extracted = wasmExtractSymbols(parsers, filePath, source); + return extracted ? extracted.symbols : null; +} + +/** + * Parse multiple files in bulk and return a Map. + * + * @param {string[]} filePaths Absolute paths to files. + * @param {string} rootDir Project root for computing relative paths. + * @param {object} [opts] Options: { engine: 'native'|'wasm'|'auto' } + * @returns {Promise>} + */ +export async function parseFilesAuto(filePaths, rootDir, opts = {}) { + const { native } = resolveEngine(opts); + const result = new Map(); + + if (native) { + const nativeResults = native.parseFiles( + filePaths, + rootDir, + !!opts.dataflow, + opts.ast !== false, + ); + for (const r of nativeResults) { + if (!r) continue; + const relPath = path.relative(rootDir, r.file).split(path.sep).join('/'); + result.set(relPath, patchNativeResult(r)); + } + return result; + } + + // WASM path + const parsers = await createParsers(); + for (const filePath of filePaths) { + let code; + try { + code = fs.readFileSync(filePath, 'utf-8'); + } catch (err) { + warn(`Skipping ${path.relative(rootDir, filePath)}: ${err.message}`); + continue; + } + const extracted = wasmExtractSymbols(parsers, filePath, code); + if (extracted) { + const relPath = path.relative(rootDir, filePath).split(path.sep).join('/'); + extracted.symbols._tree = extracted.tree; + extracted.symbols._langId = extracted.langId; + extracted.symbols._lineCount = code.split('\n').length; + result.set(relPath, extracted.symbols); + } + } + return result; +} + +/** + * Report which engine is active. + * + * @param {object} [opts] Options: { engine: 'native'|'wasm'|'auto' } + * @returns {{ name: 'native'|'wasm', version: string|null }} + */ +export function getActiveEngine(opts = {}) { + const { name, native } = resolveEngine(opts); + let version = native + ? typeof native.engineVersion === 'function' + ? native.engineVersion() + : null + : null; + // Prefer platform package.json version over binary-embedded version + // to handle stale binaries that weren't recompiled during a release + if (native) { + try { + version = getNativePackageVersion() ?? version; + } catch {} + } + return { name, version }; +} + +/** + * Create a native ParseTreeCache for incremental parsing. + * Returns null if the native engine is unavailable (WASM fallback). + */ +export function createParseTreeCache() { + const native = loadNative(); + if (!native || !native.ParseTreeCache) return null; + return new native.ParseTreeCache(); +} + +/** + * Parse a file incrementally using the cache, or fall back to full parse. + * + * @param {object|null} cache ParseTreeCache instance (or null for full parse) + * @param {string} filePath Absolute path to the file + * @param {string} source Source code string + * @param {object} [opts] Options forwarded to parseFileAuto on fallback + * @returns {Promise<{definitions, calls, imports, classes, exports}|null>} + */ +export async function parseFileIncremental(cache, filePath, source, opts = {}) { + if (cache) { + const result = cache.parseFile(filePath, source); + return result ? patchNativeResult(result) : null; + } + return parseFileAuto(filePath, source, opts); +} diff --git a/src/domain/queries.js b/src/domain/queries.js index 4b853d79..57a7cac7 100644 --- a/src/domain/queries.js +++ b/src/domain/queries.js @@ -8,6 +8,7 @@ // ── Re-export from dedicated module for backward compat ─────────────────── export { isTestFile, TEST_PATTERN } from '../infrastructure/test-filter.js'; +export { iterListFunctions, iterRoles, iterWhere } from '../shared/generators.js'; // ── Kind/edge constants (canonical source: kinds.js) ───────────────────── export { ALL_SYMBOL_KINDS, @@ -18,8 +19,7 @@ export { EXTENDED_SYMBOL_KINDS, STRUCTURAL_EDGE_KINDS, VALID_ROLES, -} from '../kinds.js'; -export { iterListFunctions, iterRoles, iterWhere } from '../shared/generators.js'; +} from '../shared/kinds.js'; // ── Shared utilities ───────────────────────────────────────────────────── export { kindIcon, normalizeSymbol } from '../shared/normalize.js'; export { contextData, explainData } from './analysis/context.js'; diff --git a/src/domain/search/generator.js b/src/domain/search/generator.js index 6936dd84..dcdfd36c 100644 --- a/src/domain/search/generator.js +++ b/src/domain/search/generator.js @@ -1,8 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { closeDb, findDbPath, openDb } from '../../db/index.js'; -import { DbError } from '../../errors.js'; -import { warn } from '../../logger.js'; +import { warn } from '../../infrastructure/logger.js'; +import { DbError } from '../../shared/errors.js'; import { embed, getModelConfig } from './models.js'; import { buildSourceText } from './strategies/source.js'; import { buildStructuredText } from './strategies/structured.js'; diff --git a/src/domain/search/models.js b/src/domain/search/models.js index 355dd381..404952cc 100644 --- a/src/domain/search/models.js +++ b/src/domain/search/models.js @@ -1,7 +1,7 @@ import { execFileSync } from 'node:child_process'; import { createInterface } from 'node:readline'; -import { ConfigError, EngineError } from '../../errors.js'; -import { info } from '../../logger.js'; +import { info } from '../../infrastructure/logger.js'; +import { ConfigError, EngineError } from '../../shared/errors.js'; // Lazy-load transformers (heavy, optional module) let pipeline = null; diff --git a/src/domain/search/search/cli-formatter.js b/src/domain/search/search/cli-formatter.js index 013333af..a0b45a80 100644 --- a/src/domain/search/search/cli-formatter.js +++ b/src/domain/search/search/cli-formatter.js @@ -1,4 +1,4 @@ -import { warn } from '../../../logger.js'; +import { warn } from '../../../infrastructure/logger.js'; import { hybridSearchData } from './hybrid.js'; import { ftsSearchData } from './keyword.js'; import { multiSearchData, searchData } from './semantic.js'; diff --git a/src/domain/search/search/semantic.js b/src/domain/search/search/semantic.js index aa624ab6..dc7b301a 100644 --- a/src/domain/search/search/semantic.js +++ b/src/domain/search/search/semantic.js @@ -1,4 +1,4 @@ -import { warn } from '../../../logger.js'; +import { warn } from '../../../infrastructure/logger.js'; import { normalizeSymbol } from '../../queries.js'; import { embed } from '../models.js'; import { cosineSim } from '../stores/sqlite-blob.js'; diff --git a/src/extractors/javascript.js b/src/extractors/javascript.js index 06f9468b..a2d9e7b1 100644 --- a/src/extractors/javascript.js +++ b/src/extractors/javascript.js @@ -1,4 +1,4 @@ -import { debug } from '../logger.js'; +import { debug } from '../infrastructure/logger.js'; import { findChild, nodeEndLine } from './helpers.js'; /** diff --git a/src/features/ast.js b/src/features/ast.js index 6935efe7..6bc3a371 100644 --- a/src/features/ast.js +++ b/src/features/ast.js @@ -12,9 +12,9 @@ import { buildExtensionSet } from '../ast-analysis/shared.js'; import { walkWithVisitors } from '../ast-analysis/visitor.js'; import { createAstStoreVisitor } from '../ast-analysis/visitors/ast-store-visitor.js'; import { bulkNodeIdsByFile, openReadonlyOrFail } from '../db/index.js'; +import { debug } from '../infrastructure/logger.js'; import { outputResult } from '../infrastructure/result-formatter.js'; -import { debug } from '../logger.js'; -import { paginateResult } from '../paginate.js'; +import { paginateResult } from '../shared/paginate.js'; // ─── Constants ──────────────────────────────────────────────────────── diff --git a/src/features/audit.js b/src/features/audit.js index c267b04a..ef71ca93 100644 --- a/src/features/audit.js +++ b/src/features/audit.js @@ -7,9 +7,9 @@ */ import path from 'node:path'; -import { loadConfig } from '../config.js'; import { openReadonlyOrFail } from '../db/index.js'; import { explainData } from '../domain/queries.js'; +import { loadConfig } from '../infrastructure/config.js'; import { isTestFile } from '../infrastructure/test-filter.js'; import { RULE_DEFS } from './manifesto.js'; diff --git a/src/features/batch.js b/src/features/batch.js index 4d1225bd..f5f386a7 100644 --- a/src/features/batch.js +++ b/src/features/batch.js @@ -15,7 +15,7 @@ import { impactAnalysisData, whereData, } from '../domain/queries.js'; -import { ConfigError } from '../errors.js'; +import { ConfigError } from '../shared/errors.js'; import { complexityData } from './complexity.js'; import { dataflowData } from './dataflow.js'; import { flowData } from './flow.js'; diff --git a/src/features/boundaries.js b/src/features/boundaries.js index 8da92b92..7a357ebd 100644 --- a/src/features/boundaries.js +++ b/src/features/boundaries.js @@ -1,5 +1,5 @@ +import { debug } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { debug } from '../logger.js'; // ─── Glob-to-Regex ─────────────────────────────────────────────────── diff --git a/src/features/cfg.js b/src/features/cfg.js index 6f1dcdfc..e8728cab 100644 --- a/src/features/cfg.js +++ b/src/features/cfg.js @@ -23,9 +23,9 @@ import { hasCfgTables, openReadonlyOrFail, } from '../db/index.js'; +import { info } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { info } from '../logger.js'; -import { paginateResult } from '../paginate.js'; +import { paginateResult } from '../shared/paginate.js'; // Re-export for backward compatibility export { _makeCfgRules as makeCfgRules, CFG_RULES }; @@ -104,13 +104,13 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) { } if (needsFallback) { - const { createParsers } = await import('./parser.js'); + const { createParsers } = await import('../domain/parser.js'); parsers = await createParsers(); } let getParserFn = null; if (parsers) { - const mod = await import('./parser.js'); + const mod = await import('../domain/parser.js'); getParserFn = mod.getParser; } diff --git a/src/features/check.js b/src/features/check.js index b8e3c75f..f3de3b78 100644 --- a/src/features/check.js +++ b/src/features/check.js @@ -1,9 +1,9 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import { loadConfig } from '../config.js'; import { findDbPath, openReadonlyOrFail } from '../db/index.js'; import { findCycles } from '../domain/graph/cycles.js'; +import { loadConfig } from '../infrastructure/config.js'; import { isTestFile } from '../infrastructure/test-filter.js'; import { matchOwners, parseCodeowners } from './owners.js'; diff --git a/src/features/cochange.js b/src/features/cochange.js index 134519ed..4c531dfe 100644 --- a/src/features/cochange.js +++ b/src/features/cochange.js @@ -8,11 +8,11 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import { normalizePath } from '../constants.js'; import { closeDb, findDbPath, initSchema, openDb, openReadonlyOrFail } from '../db/index.js'; +import { warn } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { warn } from '../logger.js'; -import { paginateResult } from '../paginate.js'; +import { normalizePath } from '../shared/constants.js'; +import { paginateResult } from '../shared/paginate.js'; /** * Scan git history and return parsed commit data. diff --git a/src/features/communities.js b/src/features/communities.js index 51970558..cf46fa39 100644 --- a/src/features/communities.js +++ b/src/features/communities.js @@ -2,7 +2,7 @@ import path from 'node:path'; import { openReadonlyOrFail } from '../db/index.js'; import { louvainCommunities } from '../graph/algorithms/louvain.js'; import { buildDependencyGraph } from '../graph/builders/dependency.js'; -import { paginateResult } from '../paginate.js'; +import { paginateResult } from '../shared/paginate.js'; // ─── Directory Helpers ──────────────────────────────────────────────── diff --git a/src/features/complexity.js b/src/features/complexity.js index b319c945..4e4cf35d 100644 --- a/src/features/complexity.js +++ b/src/features/complexity.js @@ -12,11 +12,11 @@ import { } from '../ast-analysis/shared.js'; import { walkWithVisitors } from '../ast-analysis/visitor.js'; import { createComplexityVisitor } from '../ast-analysis/visitors/complexity-visitor.js'; -import { loadConfig } from '../config.js'; import { getFunctionNodeId, openReadonlyOrFail } from '../db/index.js'; +import { loadConfig } from '../infrastructure/config.js'; +import { info } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { info } from '../logger.js'; -import { paginateResult } from '../paginate.js'; +import { paginateResult } from '../shared/paginate.js'; // Re-export rules for backward compatibility export { COMPLEXITY_RULES, HALSTEAD_RULES }; @@ -360,12 +360,12 @@ export async function buildComplexityMetrics(db, fileSymbols, rootDir, _engineOp } } if (needsFallback) { - const { createParsers } = await import('./parser.js'); + const { createParsers } = await import('../domain/parser.js'); parsers = await createParsers(); extToLang = buildExtToLangMap(); } - const { getParser } = await import('./parser.js'); + const { getParser } = await import('../domain/parser.js'); const upsert = db.prepare( `INSERT OR REPLACE INTO function_complexity diff --git a/src/features/dataflow.js b/src/features/dataflow.js index dbff4cda..9d0c8bcc 100644 --- a/src/features/dataflow.js +++ b/src/features/dataflow.js @@ -21,9 +21,9 @@ import { walkWithVisitors } from '../ast-analysis/visitor.js'; import { createDataflowVisitor } from '../ast-analysis/visitors/dataflow-visitor.js'; import { hasDataflowTable, openReadonlyOrFail } from '../db/index.js'; import { ALL_SYMBOL_KINDS, normalizeSymbol } from '../domain/queries.js'; +import { info } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { info } from '../logger.js'; -import { paginateResult } from '../paginate.js'; +import { paginateResult } from '../shared/paginate.js'; // Re-export for backward compatibility export { _makeDataflowRules as makeDataflowRules, DATAFLOW_RULES }; @@ -88,13 +88,13 @@ export async function buildDataflowEdges(db, fileSymbols, rootDir, _engineOpts) } if (needsFallback) { - const { createParsers } = await import('./parser.js'); + const { createParsers } = await import('../domain/parser.js'); parsers = await createParsers(); } let getParserFn = null; if (parsers) { - const mod = await import('./parser.js'); + const mod = await import('../domain/parser.js'); getParserFn = mod.getParser; } diff --git a/src/features/export.js b/src/features/export.js index 61ed15ba..6f93faae 100644 --- a/src/features/export.js +++ b/src/features/export.js @@ -1,6 +1,5 @@ import path from 'node:path'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { paginateResult } from '../paginate.js'; import { renderFileLevelDOT, renderFileLevelGraphML, @@ -11,6 +10,7 @@ import { renderFunctionLevelMermaid, renderFunctionLevelNeo4jCSV, } from '../presentation/export.js'; +import { paginateResult } from '../shared/paginate.js'; const DEFAULT_MIN_CONFIDENCE = 0.5; diff --git a/src/features/flow.js b/src/features/flow.js index 8f42af3a..e91e00b8 100644 --- a/src/features/flow.js +++ b/src/features/flow.js @@ -8,7 +8,7 @@ import { openReadonlyOrFail } from '../db/index.js'; import { CORE_SYMBOL_KINDS, findMatchingNodes } from '../domain/queries.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { paginateResult } from '../paginate.js'; +import { paginateResult } from '../shared/paginate.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; /** diff --git a/src/features/manifesto.js b/src/features/manifesto.js index c77ae21d..3113122a 100644 --- a/src/features/manifesto.js +++ b/src/features/manifesto.js @@ -1,8 +1,8 @@ -import { loadConfig } from '../config.js'; import { openReadonlyOrFail } from '../db/index.js'; import { findCycles } from '../domain/graph/cycles.js'; -import { debug } from '../logger.js'; -import { paginateResult } from '../paginate.js'; +import { loadConfig } from '../infrastructure/config.js'; +import { debug } from '../infrastructure/logger.js'; +import { paginateResult } from '../shared/paginate.js'; import { evaluateBoundaries } from './boundaries.js'; // ─── Rule Definitions ───────────────────────────────────────────────── diff --git a/src/features/sequence.js b/src/features/sequence.js index 78a3d68a..0edeba87 100644 --- a/src/features/sequence.js +++ b/src/features/sequence.js @@ -9,7 +9,7 @@ import { findCallees, openReadonlyOrFail } from '../db/index.js'; import { findMatchingNodes } from '../domain/queries.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { paginateResult } from '../paginate.js'; +import { paginateResult } from '../shared/paginate.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; // ─── Alias generation ──────────────────────────────────────────────── diff --git a/src/features/snapshot.js b/src/features/snapshot.js index 007549aa..71baf8d2 100644 --- a/src/features/snapshot.js +++ b/src/features/snapshot.js @@ -2,8 +2,8 @@ import fs from 'node:fs'; import path from 'node:path'; import Database from 'better-sqlite3'; import { findDbPath } from '../db/index.js'; -import { ConfigError, DbError } from '../errors.js'; -import { debug } from '../logger.js'; +import { debug } from '../infrastructure/logger.js'; +import { ConfigError, DbError } from '../shared/errors.js'; const NAME_RE = /^[a-zA-Z0-9_-]+$/; diff --git a/src/features/structure.js b/src/features/structure.js index 7fcd5628..4ba9ee0a 100644 --- a/src/features/structure.js +++ b/src/features/structure.js @@ -1,9 +1,9 @@ import path from 'node:path'; -import { normalizePath } from '../constants.js'; import { getNodeId, openReadonlyOrFail, testFilterSQL } from '../db/index.js'; +import { debug } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { debug } from '../logger.js'; -import { paginateResult } from '../paginate.js'; +import { normalizePath } from '../shared/constants.js'; +import { paginateResult } from '../shared/paginate.js'; // ─── Build-time: insert directory nodes, contains edges, and metrics ──── diff --git a/src/features/triage.js b/src/features/triage.js index 5cd9d7a1..32257f3f 100644 --- a/src/features/triage.js +++ b/src/features/triage.js @@ -1,8 +1,8 @@ import { findNodesForTriage, openReadonlyOrFail } from '../db/index.js'; import { DEFAULT_WEIGHTS, scoreRisk } from '../graph/classifiers/risk.js'; +import { warn } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { warn } from '../logger.js'; -import { paginateResult } from '../paginate.js'; +import { paginateResult } from '../shared/paginate.js'; // ─── Data Function ──────────────────────────────────────────────────── diff --git a/src/index.js b/src/index.js index a50e9017..448cbed0 100644 --- a/src/index.js +++ b/src/index.js @@ -9,8 +9,6 @@ * import { buildGraph, queryNameData, findCycles, exportDOT } from '@optave/codegraph'; */ -export { loadConfig } from './config.js'; -export { EXTENSIONS, IGNORE_DIRS } from './constants.js'; export { buildGraph } from './domain/graph/builder.js'; export { findCycles } from './domain/graph/cycles.js'; export { @@ -36,16 +34,6 @@ export { multiSearchData, searchData, } from './domain/search/index.js'; -export { - AnalysisError, - BoundaryError, - CodegraphError, - ConfigError, - DbError, - EngineError, - ParseError, - ResolutionError, -} from './errors.js'; export { astQueryData } from './features/ast.js'; export { auditData } from './features/audit.js'; export { batchData } from './features/batch.js'; @@ -63,4 +51,16 @@ export { ownersData } from './features/owners.js'; export { sequenceData } from './features/sequence.js'; export { hotspotsData, moduleBoundariesData, structureData } from './features/structure.js'; export { triageData } from './features/triage.js'; -export { EVERY_EDGE_KIND, EVERY_SYMBOL_KIND } from './kinds.js'; +export { loadConfig } from './infrastructure/config.js'; +export { EXTENSIONS, IGNORE_DIRS } from './shared/constants.js'; +export { + AnalysisError, + BoundaryError, + CodegraphError, + ConfigError, + DbError, + EngineError, + ParseError, + ResolutionError, +} from './shared/errors.js'; +export { EVERY_EDGE_KIND, EVERY_SYMBOL_KIND } from './shared/kinds.js'; diff --git a/src/infrastructure/config.js b/src/infrastructure/config.js new file mode 100644 index 00000000..1ab75e47 --- /dev/null +++ b/src/infrastructure/config.js @@ -0,0 +1,139 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { debug, warn } from './logger.js'; + +export const CONFIG_FILES = ['.codegraphrc.json', '.codegraphrc', 'codegraph.config.json']; + +export const DEFAULTS = { + include: [], + exclude: [], + ignoreDirs: [], + extensions: [], + aliases: {}, + build: { + incremental: true, + dbPath: '.codegraph/graph.db', + driftThreshold: 0.2, + }, + query: { + defaultDepth: 3, + defaultLimit: 20, + excludeTests: false, + }, + embeddings: { model: 'nomic-v1.5', llmProvider: null }, + llm: { provider: null, model: null, baseUrl: null, apiKey: null, apiKeyCommand: null }, + search: { defaultMinScore: 0.2, rrfK: 60, topK: 15 }, + ci: { failOnCycles: false, impactThreshold: null }, + manifesto: { + rules: { + cognitive: { warn: 15 }, + cyclomatic: { warn: 10 }, + maxNesting: { warn: 4 }, + maintainabilityIndex: { warn: 20, fail: null }, + importCount: { warn: null, fail: null }, + exportCount: { warn: null, fail: null }, + lineCount: { warn: null, fail: null }, + fanIn: { warn: null, fail: null }, + fanOut: { warn: null, fail: null }, + noCycles: { warn: null, fail: null }, + boundaries: { warn: null, fail: null }, + }, + boundaries: null, + }, + check: { + cycles: true, + blastRadius: null, + signatures: true, + boundaries: true, + depth: 3, + }, + coChange: { + since: '1 year ago', + minSupport: 3, + minJaccard: 0.3, + maxFilesPerCommit: 50, + }, +}; + +/** + * Load project configuration from a .codegraphrc.json or similar file. + * Returns merged config with defaults. + */ +export function loadConfig(cwd) { + cwd = cwd || process.cwd(); + for (const name of CONFIG_FILES) { + const filePath = path.join(cwd, name); + if (fs.existsSync(filePath)) { + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const config = JSON.parse(raw); + debug(`Loaded config from ${filePath}`); + const merged = mergeConfig(DEFAULTS, config); + if ('excludeTests' in config && !(config.query && 'excludeTests' in config.query)) { + merged.query.excludeTests = Boolean(config.excludeTests); + } + delete merged.excludeTests; + return resolveSecrets(applyEnvOverrides(merged)); + } catch (err) { + debug(`Failed to parse config ${filePath}: ${err.message}`); + } + } + } + return resolveSecrets(applyEnvOverrides({ ...DEFAULTS })); +} + +const ENV_LLM_MAP = { + CODEGRAPH_LLM_PROVIDER: 'provider', + CODEGRAPH_LLM_API_KEY: 'apiKey', + CODEGRAPH_LLM_MODEL: 'model', +}; + +export function applyEnvOverrides(config) { + for (const [envKey, field] of Object.entries(ENV_LLM_MAP)) { + if (process.env[envKey] !== undefined) { + config.llm[field] = process.env[envKey]; + } + } + return config; +} + +export function resolveSecrets(config) { + const cmd = config.llm.apiKeyCommand; + if (typeof cmd !== 'string' || cmd.trim() === '') return config; + + const parts = cmd.trim().split(/\s+/); + const [executable, ...args] = parts; + try { + const result = execFileSync(executable, args, { + encoding: 'utf-8', + timeout: 10_000, + maxBuffer: 64 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + if (result) { + config.llm.apiKey = result; + } + } catch (err) { + warn(`apiKeyCommand failed: ${err.message}`); + } + return config; +} + +function mergeConfig(defaults, overrides) { + const result = { ...defaults }; + for (const [key, value] of Object.entries(overrides)) { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + defaults[key] && + typeof defaults[key] === 'object' + ) { + result[key] = { ...defaults[key], ...value }; + } else { + result[key] = value; + } + } + return result; +} diff --git a/src/infrastructure/logger.js b/src/infrastructure/logger.js new file mode 100644 index 00000000..c6d356c0 --- /dev/null +++ b/src/infrastructure/logger.js @@ -0,0 +1,24 @@ +let verbose = false; + +export function setVerbose(v) { + verbose = v; +} +export function isVerbose() { + return verbose; +} + +export function warn(msg) { + process.stderr.write(`[codegraph WARN] ${msg}\n`); +} + +export function debug(msg) { + if (verbose) process.stderr.write(`[codegraph DEBUG] ${msg}\n`); +} + +export function info(msg) { + process.stderr.write(`[codegraph] ${msg}\n`); +} + +export function error(msg) { + process.stderr.write(`[codegraph ERROR] ${msg}\n`); +} diff --git a/src/infrastructure/native.js b/src/infrastructure/native.js new file mode 100644 index 00000000..a1481621 --- /dev/null +++ b/src/infrastructure/native.js @@ -0,0 +1,112 @@ +/** + * Native addon loader with graceful fallback to WASM. + * + * Tries to load the platform-specific napi-rs binary built from + * crates/codegraph-core. If unavailable the caller should fall back + * to the existing WASM pipeline. + */ + +import { createRequire } from 'node:module'; +import os from 'node:os'; +import { EngineError } from '../shared/errors.js'; + +let _cached; // undefined = not yet tried, null = failed, object = module +let _loadError = null; +const _require = createRequire(import.meta.url); + +/** + * Detect whether the current Linux environment uses glibc or musl. + * Returns 'gnu' for glibc, 'musl' for musl, 'gnu' as fallback. + */ +function detectLibc() { + try { + const { readdirSync } = _require('node:fs'); + const files = readdirSync('/lib'); + if (files.some((f) => f.startsWith('ld-musl-') && f.endsWith('.so.1'))) { + return 'musl'; + } + } catch {} + return 'gnu'; +} + +/** Map of (platform-arch[-libc]) → npm package name. */ +const PLATFORM_PACKAGES = { + 'linux-x64-gnu': '@optave/codegraph-linux-x64-gnu', + 'linux-x64-musl': '@optave/codegraph-linux-x64-musl', + 'linux-arm64-gnu': '@optave/codegraph-linux-arm64-gnu', + 'linux-arm64-musl': '@optave/codegraph-linux-arm64-musl', // not yet published — placeholder for future CI target + 'darwin-arm64': '@optave/codegraph-darwin-arm64', + 'darwin-x64': '@optave/codegraph-darwin-x64', + 'win32-x64': '@optave/codegraph-win32-x64-msvc', +}; + +/** + * Resolve the platform-specific npm package name for the native addon. + * Returns null if the current platform is not supported. + */ +function resolvePlatformPackage() { + const platform = os.platform(); + const arch = os.arch(); + const key = platform === 'linux' ? `${platform}-${arch}-${detectLibc()}` : `${platform}-${arch}`; + return PLATFORM_PACKAGES[key] || null; +} + +/** + * Try to load the native napi addon. + * Returns the module on success, null on failure. + */ +export function loadNative() { + if (_cached !== undefined) return _cached; + + const pkg = resolvePlatformPackage(); + if (pkg) { + try { + _cached = _require(pkg); + return _cached; + } catch (err) { + _loadError = err; + } + } else { + _loadError = new Error(`Unsupported platform: ${os.platform()}-${os.arch()}`); + } + + _cached = null; + return null; +} + +/** + * Check whether the native engine is available on this platform. + */ +export function isNativeAvailable() { + return loadNative() !== null; +} + +/** + * Read the version from the platform-specific npm package.json. + * Returns null if the package is not installed or has no version. + */ +export function getNativePackageVersion() { + const pkg = resolvePlatformPackage(); + if (!pkg) return null; + try { + const pkgJson = _require(`${pkg}/package.json`); + return pkgJson.version || null; + } catch { + return null; + } +} + +/** + * Return the native module or throw if not available. + */ +export function getNative() { + const mod = loadNative(); + if (!mod) { + throw new EngineError( + `Native codegraph-core not available: ${_loadError?.message || 'unknown error'}. ` + + 'Install the platform package or use --engine wasm.', + { cause: _loadError }, + ); + } + return mod; +} diff --git a/src/infrastructure/registry.js b/src/infrastructure/registry.js new file mode 100644 index 00000000..a7d2ea01 --- /dev/null +++ b/src/infrastructure/registry.js @@ -0,0 +1,173 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { debug, warn } from './logger.js'; + +export const REGISTRY_PATH = + process.env.CODEGRAPH_REGISTRY_PATH || path.join(os.homedir(), '.codegraph', 'registry.json'); + +/** Default TTL: entries not accessed within 30 days are pruned. */ +export const DEFAULT_TTL_DAYS = 30; + +/** + * Load the registry from disk. + * Returns `{ repos: {} }` on missing or corrupt file. + */ +export function loadRegistry(registryPath = REGISTRY_PATH) { + try { + const raw = fs.readFileSync(registryPath, 'utf-8'); + const data = JSON.parse(raw); + if (!data || typeof data.repos !== 'object') return { repos: {} }; + return data; + } catch { + return { repos: {} }; + } +} + +/** + * Persist the registry to disk (atomic write via temp + rename). + * Creates the parent directory if needed. + */ +export function saveRegistry(registry, registryPath = REGISTRY_PATH) { + const dir = path.dirname(registryPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const tmp = `${registryPath}.tmp.${process.pid}`; + fs.writeFileSync(tmp, JSON.stringify(registry, null, 2), 'utf-8'); + fs.renameSync(tmp, registryPath); +} + +/** + * Register a project directory. Idempotent. + * Name defaults to `path.basename(rootDir)`. + * + * When no explicit name is provided and the basename already exists + * pointing to a different path, auto-suffixes (`api` → `api-2`, `api-3`, …). + * Re-registering the same path updates in place. Explicit names always overwrite. + */ +export function registerRepo(rootDir, name, registryPath = REGISTRY_PATH) { + const absRoot = path.resolve(rootDir); + const baseName = name || path.basename(absRoot); + const registry = loadRegistry(registryPath); + + let repoName = baseName; + + // Auto-suffix only when no explicit name was provided + if (!name) { + const existing = registry.repos[baseName]; + if (existing && path.resolve(existing.path) !== absRoot) { + // Basename collision with a different path — find next available suffix + let suffix = 2; + while (registry.repos[`${baseName}-${suffix}`]) { + const entry = registry.repos[`${baseName}-${suffix}`]; + if (path.resolve(entry.path) === absRoot) { + // Already registered under this suffixed name — update in place + repoName = `${baseName}-${suffix}`; + break; + } + suffix++; + } + if (repoName === baseName) { + repoName = `${baseName}-${suffix}`; + } + } + } + + const now = new Date().toISOString(); + registry.repos[repoName] = { + path: absRoot, + dbPath: path.join(absRoot, '.codegraph', 'graph.db'), + addedAt: registry.repos[repoName]?.addedAt || now, + lastAccessedAt: now, + }; + + saveRegistry(registry, registryPath); + debug(`Registered repo "${repoName}" at ${absRoot}`); + return { name: repoName, entry: registry.repos[repoName] }; +} + +/** + * Remove a repo from the registry. Returns false if not found. + */ +export function unregisterRepo(name, registryPath = REGISTRY_PATH) { + const registry = loadRegistry(registryPath); + if (!registry.repos[name]) return false; + delete registry.repos[name]; + saveRegistry(registry, registryPath); + return true; +} + +/** + * List all registered repos, sorted by name. + */ +export function listRepos(registryPath = REGISTRY_PATH) { + const registry = loadRegistry(registryPath); + return Object.entries(registry.repos) + .map(([name, entry]) => ({ + name, + path: entry.path, + dbPath: entry.dbPath, + addedAt: entry.addedAt, + lastAccessedAt: entry.lastAccessedAt || entry.addedAt, + })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Resolve a repo name to its database path. + * Returns undefined if the repo is not found or its DB file is missing. + */ +export function resolveRepoDbPath(name, registryPath = REGISTRY_PATH) { + const registry = loadRegistry(registryPath); + const entry = registry.repos[name]; + if (!entry) return undefined; + if (!fs.existsSync(entry.dbPath)) { + warn(`Registry: database missing for "${name}" at ${entry.dbPath}`); + return undefined; + } + // Touch lastAccessedAt on successful resolution + entry.lastAccessedAt = new Date().toISOString(); + saveRegistry(registry, registryPath); + return entry.dbPath; +} + +/** + * Remove registry entries whose repo directory no longer exists on disk, + * or that haven't been accessed within `ttlDays` days. + * Returns an array of `{ name, path, reason }` for each pruned entry. + * + * When `dryRun` is true, entries are identified but not removed from disk. + */ +export function pruneRegistry( + registryPath = REGISTRY_PATH, + ttlDays = DEFAULT_TTL_DAYS, + excludeNames = [], + dryRun = false, +) { + const registry = loadRegistry(registryPath); + const pruned = []; + const cutoff = Date.now() - ttlDays * 24 * 60 * 60 * 1000; + const excludeSet = new Set( + excludeNames.filter((n) => typeof n === 'string' && n.trim().length > 0), + ); + + for (const [name, entry] of Object.entries(registry.repos)) { + if (excludeSet.has(name)) continue; + if (!fs.existsSync(entry.path)) { + pruned.push({ name, path: entry.path, reason: 'missing' }); + if (!dryRun) delete registry.repos[name]; + continue; + } + const lastAccess = Date.parse(entry.lastAccessedAt || entry.addedAt); + if (lastAccess < cutoff) { + pruned.push({ name, path: entry.path, reason: 'expired' }); + if (!dryRun) delete registry.repos[name]; + } + } + + if (!dryRun && pruned.length > 0) { + saveRegistry(registry, registryPath); + } + + return pruned; +} diff --git a/src/infrastructure/update-check.js b/src/infrastructure/update-check.js new file mode 100644 index 00000000..7956b198 --- /dev/null +++ b/src/infrastructure/update-check.js @@ -0,0 +1,160 @@ +import fs from 'node:fs'; +import https from 'node:https'; +import os from 'node:os'; +import path from 'node:path'; + +const CACHE_PATH = + process.env.CODEGRAPH_UPDATE_CACHE_PATH || + path.join(os.homedir(), '.codegraph', 'update-check.json'); + +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const FETCH_TIMEOUT_MS = 3000; +const REGISTRY_URL = 'https://registry.npmjs.org/@optave/codegraph/latest'; + +/** + * Minimal semver comparison. Returns -1, 0, or 1. + * Only handles numeric x.y.z (no pre-release tags). + */ +export function semverCompare(a, b) { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < 3; i++) { + const na = pa[i] || 0; + const nb = pb[i] || 0; + if (na < nb) return -1; + if (na > nb) return 1; + } + return 0; +} + +/** + * Load the cached update-check result from disk. + * Returns null on missing or corrupt file. + */ +function loadCache(cachePath = CACHE_PATH) { + try { + const raw = fs.readFileSync(cachePath, 'utf-8'); + const data = JSON.parse(raw); + if (!data || typeof data.lastCheckedAt !== 'number' || typeof data.latestVersion !== 'string') { + return null; + } + return data; + } catch { + return null; + } +} + +/** + * Persist the cache to disk (atomic write via temp + rename). + */ +function saveCache(cache, cachePath = CACHE_PATH) { + const dir = path.dirname(cachePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const tmp = `${cachePath}.tmp.${process.pid}`; + fs.writeFileSync(tmp, JSON.stringify(cache), 'utf-8'); + fs.renameSync(tmp, cachePath); +} + +/** + * Fetch the latest version string from the npm registry. + * Returns the version string or null on failure. + */ +function fetchLatestVersion() { + return new Promise((resolve) => { + const req = https.get( + REGISTRY_URL, + { timeout: FETCH_TIMEOUT_MS, headers: { Accept: 'application/json' } }, + (res) => { + if (res.statusCode !== 200) { + res.resume(); + resolve(null); + return; + } + let body = ''; + res.setEncoding('utf-8'); + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + try { + const data = JSON.parse(body); + resolve(typeof data.version === 'string' ? data.version : null); + } catch { + resolve(null); + } + }); + }, + ); + req.on('error', () => resolve(null)); + req.on('timeout', () => { + req.destroy(); + resolve(null); + }); + }); +} + +/** + * Check whether a newer version of codegraph is available. + * + * Returns `{ current, latest }` if an update is available, `null` otherwise. + * Silently returns null on any error — never affects CLI operation. + * + * Options: + * cachePath — override cache file location (for testing) + * _fetchLatest — override the fetch function (for testing) + */ +export async function checkForUpdates(currentVersion, options = {}) { + // Suppress in non-interactive / CI contexts + if (process.env.CI) return null; + if (process.env.NO_UPDATE_CHECK) return null; + if (!process.stderr.isTTY) return null; + if (currentVersion.includes('-')) return null; + + const cachePath = options.cachePath || CACHE_PATH; + const fetchFn = options._fetchLatest || fetchLatestVersion; + + try { + const cache = loadCache(cachePath); + + // Cache is fresh — use it + if (cache && Date.now() - cache.lastCheckedAt < CACHE_TTL_MS) { + if (semverCompare(currentVersion, cache.latestVersion) < 0) { + return { current: currentVersion, latest: cache.latestVersion }; + } + return null; + } + + // Cache is stale or missing — fetch + const latest = await fetchFn(); + if (!latest) return null; + + // Update cache regardless of result + saveCache({ lastCheckedAt: Date.now(), latestVersion: latest }, cachePath); + + if (semverCompare(currentVersion, latest) < 0) { + return { current: currentVersion, latest }; + } + return null; + } catch { + return null; + } +} + +/** + * Print a visible update notification box to stderr. + */ +export function printUpdateNotification(current, latest) { + const msg1 = `Update available: ${current} → ${latest}`; + const msg2 = 'Run `npm i -g @optave/codegraph` to update'; + const width = Math.max(msg1.length, msg2.length) + 4; + + const top = `┌${'─'.repeat(width)}┐`; + const bot = `└${'─'.repeat(width)}┘`; + const pad1 = ' '.repeat(width - msg1.length - 2); + const pad2 = ' '.repeat(width - msg2.length - 2); + const line1 = `│ ${msg1}${pad1}│`; + const line2 = `│ ${msg2}${pad2}│`; + + process.stderr.write(`\n${top}\n${line1}\n${line2}\n${bot}\n\n`); +} diff --git a/src/mcp/middleware.js b/src/mcp/middleware.js index 7f261cad..96dc26af 100644 --- a/src/mcp/middleware.js +++ b/src/mcp/middleware.js @@ -2,7 +2,7 @@ * MCP middleware helpers — pagination defaults and limits. */ -import { MCP_DEFAULTS, MCP_MAX_LIMIT } from '../paginate.js'; +import { MCP_DEFAULTS, MCP_MAX_LIMIT } from '../shared/paginate.js'; export { MCP_DEFAULTS, MCP_MAX_LIMIT }; diff --git a/src/mcp/server.js b/src/mcp/server.js index b5c1ea92..464fafaf 100644 --- a/src/mcp/server.js +++ b/src/mcp/server.js @@ -7,8 +7,8 @@ import { createRequire } from 'node:module'; import { findDbPath } from '../db/index.js'; -import { CodegraphError, ConfigError } from '../errors.js'; -import { MCP_MAX_LIMIT } from '../paginate.js'; +import { CodegraphError, ConfigError } from '../shared/errors.js'; +import { MCP_MAX_LIMIT } from '../shared/paginate.js'; import { buildToolList } from './tool-registry.js'; import { TOOL_HANDLERS } from './tools/index.js'; @@ -89,7 +89,7 @@ export async function startMCPServer(customDbPath, options = {}) { if (allowedRepos && !allowedRepos.includes(args.repo)) { throw new ConfigError(`Repository "${args.repo}" is not in the allowed repos list.`); } - const { resolveRepoDbPath } = await import('../registry.js'); + const { resolveRepoDbPath } = await import('../infrastructure/registry.js'); const resolved = resolveRepoDbPath(args.repo); if (!resolved) throw new ConfigError( diff --git a/src/mcp/tools/list-repos.js b/src/mcp/tools/list-repos.js index 743fa959..1cd18d3d 100644 --- a/src/mcp/tools/list-repos.js +++ b/src/mcp/tools/list-repos.js @@ -1,7 +1,7 @@ export const name = 'list_repos'; export async function handler(_args, ctx) { - const { listRepos, pruneRegistry } = await import('../../registry.js'); + const { listRepos, pruneRegistry } = await import('../../infrastructure/registry.js'); pruneRegistry(); let repos = listRepos(); if (ctx.allowedRepos) { diff --git a/src/presentation/result-formatter.js b/src/presentation/result-formatter.js index 98aa8ea1..389df681 100644 --- a/src/presentation/result-formatter.js +++ b/src/presentation/result-formatter.js @@ -1,4 +1,4 @@ -import { printNdjson } from '../paginate.js'; +import { printNdjson } from '../shared/paginate.js'; /** * Shared JSON / NDJSON output dispatch for CLI wrappers. diff --git a/src/shared/constants.js b/src/shared/constants.js new file mode 100644 index 00000000..db06b4c5 --- /dev/null +++ b/src/shared/constants.js @@ -0,0 +1,39 @@ +import path from 'node:path'; +import { SUPPORTED_EXTENSIONS } from '../domain/parser.js'; + +export const IGNORE_DIRS = new Set([ + 'node_modules', + '.git', + 'dist', + 'build', + '.next', + '.nuxt', + '.svelte-kit', + 'coverage', + '.codegraph', + '__pycache__', + '.tox', + 'vendor', + '.venv', + 'venv', + 'env', + '.env', +]); + +export { SUPPORTED_EXTENSIONS as EXTENSIONS }; + +export function shouldIgnore(dirName) { + return IGNORE_DIRS.has(dirName) || dirName.startsWith('.'); +} + +export function isSupportedFile(filePath) { + return SUPPORTED_EXTENSIONS.has(path.extname(filePath)); +} + +/** + * Normalize a file path to always use forward slashes. + * Ensures cross-platform consistency in the SQLite database. + */ +export function normalizePath(filePath) { + return filePath.split(path.sep).join('/'); +} diff --git a/src/shared/errors.js b/src/shared/errors.js new file mode 100644 index 00000000..0a398446 --- /dev/null +++ b/src/shared/errors.js @@ -0,0 +1,78 @@ +/** + * Domain error hierarchy for codegraph. + * + * Library code throws these instead of calling process.exit() or throwing + * bare Error instances. The CLI top-level catch formats them for humans; + * MCP returns structured { isError, code } responses. + */ + +export class CodegraphError extends Error { + /** @type {string} */ + code; + + /** @type {string|undefined} */ + file; + + /** + * @param {string} message + * @param {object} [opts] + * @param {string} [opts.code] + * @param {string} [opts.file] - Related file path, if applicable + * @param {Error} [opts.cause] - Original error that triggered this one + */ + constructor(message, { code = 'CODEGRAPH_ERROR', file, cause } = {}) { + super(message, { cause }); + this.name = 'CodegraphError'; + this.code = code; + this.file = file; + } +} + +export class ParseError extends CodegraphError { + constructor(message, opts = {}) { + super(message, { code: 'PARSE_FAILED', ...opts }); + this.name = 'ParseError'; + } +} + +export class DbError extends CodegraphError { + constructor(message, opts = {}) { + super(message, { code: 'DB_ERROR', ...opts }); + this.name = 'DbError'; + } +} + +export class ConfigError extends CodegraphError { + constructor(message, opts = {}) { + super(message, { code: 'CONFIG_INVALID', ...opts }); + this.name = 'ConfigError'; + } +} + +export class ResolutionError extends CodegraphError { + constructor(message, opts = {}) { + super(message, { code: 'RESOLUTION_FAILED', ...opts }); + this.name = 'ResolutionError'; + } +} + +export class EngineError extends CodegraphError { + constructor(message, opts = {}) { + super(message, { code: 'ENGINE_UNAVAILABLE', ...opts }); + this.name = 'EngineError'; + } +} + +export class AnalysisError extends CodegraphError { + constructor(message, opts = {}) { + super(message, { code: 'ANALYSIS_FAILED', ...opts }); + this.name = 'AnalysisError'; + } +} + +export class BoundaryError extends CodegraphError { + constructor(message, opts = {}) { + super(message, { code: 'BOUNDARY_VIOLATION', ...opts }); + this.name = 'BoundaryError'; + } +} diff --git a/src/shared/file-utils.js b/src/shared/file-utils.js index bd52719d..814f54de 100644 --- a/src/shared/file-utils.js +++ b/src/shared/file-utils.js @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { debug } from '../logger.js'; -import { LANGUAGE_REGISTRY } from '../parser.js'; +import { LANGUAGE_REGISTRY } from '../domain/parser.js'; +import { debug } from '../infrastructure/logger.js'; /** * Resolve a file path relative to repoRoot, rejecting traversal outside the repo. diff --git a/src/shared/generators.js b/src/shared/generators.js index 93753dbd..3d121f81 100644 --- a/src/shared/generators.js +++ b/src/shared/generators.js @@ -1,6 +1,6 @@ import { iterateFunctionNodes, openReadonlyOrFail } from '../db/index.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { ALL_SYMBOL_KINDS } from '../kinds.js'; +import { ALL_SYMBOL_KINDS } from './kinds.js'; /** * Generator: stream functions one-by-one using .iterate() for memory efficiency. diff --git a/src/shared/kinds.js b/src/shared/kinds.js new file mode 100644 index 00000000..3f469c43 --- /dev/null +++ b/src/shared/kinds.js @@ -0,0 +1,50 @@ +// ── Symbol kind constants ─────────────────────────────────────────── +// Original 10 kinds — used as default query scope +export const CORE_SYMBOL_KINDS = [ + 'function', + 'method', + 'class', + 'interface', + 'type', + 'struct', + 'enum', + 'trait', + 'record', + 'module', +]; + +// Sub-declaration kinds (Phase 1) +export const EXTENDED_SYMBOL_KINDS = [ + 'parameter', + 'property', + 'constant', + // Phase 2 (reserved, not yet extracted): + // 'constructor', 'namespace', 'decorator', 'getter', 'setter', +]; + +// Full set for --kind validation and MCP enum +export const EVERY_SYMBOL_KIND = [...CORE_SYMBOL_KINDS, ...EXTENDED_SYMBOL_KINDS]; + +// Backward compat: ALL_SYMBOL_KINDS stays as the core 10 +export const ALL_SYMBOL_KINDS = CORE_SYMBOL_KINDS; + +// ── Edge kind constants ───────────────────────────────────────────── +// Core edge kinds — coupling and dependency relationships +export const CORE_EDGE_KINDS = [ + 'imports', + 'imports-type', + 'dynamic-imports', + 'reexports', + 'calls', + 'extends', + 'implements', + 'contains', +]; + +// Structural edge kinds — parent/child and type relationships +export const STRUCTURAL_EDGE_KINDS = ['parameter_of', 'receiver']; + +// Full set for MCP enum and validation +export const EVERY_EDGE_KIND = [...CORE_EDGE_KINDS, ...STRUCTURAL_EDGE_KINDS]; + +export const VALID_ROLES = ['entry', 'core', 'utility', 'adapter', 'dead', 'leaf']; diff --git a/src/shared/paginate.js b/src/shared/paginate.js new file mode 100644 index 00000000..09cc03b7 --- /dev/null +++ b/src/shared/paginate.js @@ -0,0 +1,105 @@ +/** + * Pagination utilities for bounded, context-friendly query results. + * + * Offset/limit pagination — the DB is a read-only snapshot so data doesn't + * change between pages; offset/limit is simpler and maps directly to SQL. + */ + +/** Default limits applied by MCP tool handlers (not by the programmatic API). */ +export const MCP_DEFAULTS = { + // Existing + list_functions: 100, + query: 10, + where: 50, + node_roles: 100, + export_graph: 500, + // Smaller defaults for rich/nested results + fn_impact: 5, + context: 5, + explain: 10, + file_deps: 20, + file_exports: 20, + diff_impact: 30, + impact_analysis: 20, + semantic_search: 20, + execution_flow: 50, + hotspots: 20, + co_changes: 20, + complexity: 30, + manifesto: 50, + communities: 20, + structure: 30, + triage: 20, + ast_query: 50, +}; + +/** Hard cap to prevent abuse via MCP. */ +export const MCP_MAX_LIMIT = 1000; + +/** + * Paginate an array. + * + * When `limit` is undefined the input is returned unchanged (no-op). + * + * @param {any[]} items + * @param {{ limit?: number, offset?: number }} opts + * @returns {{ items: any[], pagination?: { total: number, offset: number, limit: number, hasMore: boolean, returned: number } }} + */ +export function paginate(items, { limit, offset } = {}) { + if (limit === undefined) { + return { items }; + } + const total = items.length; + const off = Math.max(0, Math.min(offset || 0, total)); + const lim = Math.max(0, limit); + const page = items.slice(off, off + lim); + return { + items: page, + pagination: { + total, + offset: off, + limit: lim, + hasMore: off + lim < total, + returned: page.length, + }, + }; +} + +/** + * Apply pagination to a named array field on a result object. + * + * When `limit` is undefined the result is returned unchanged (backward compat). + * When active, `_pagination` metadata is added to the result. + * + * @param {object} result - The result object (e.g. `{ count: 42, functions: [...] }`) + * @param {string} field - The array field name to paginate (e.g. `'functions'`) + * @param {{ limit?: number, offset?: number }} opts + * @returns {object} - Result with paginated field + `_pagination` (if active) + */ +export function paginateResult(result, field, { limit, offset } = {}) { + if (limit === undefined) { + return result; + } + const arr = result[field]; + if (!Array.isArray(arr)) return result; + + const { items, pagination } = paginate(arr, { limit, offset }); + return { ...result, [field]: items, _pagination: pagination }; +} + +/** + * Print data as newline-delimited JSON (NDJSON). + * + * Emits a `_meta` line with pagination info (if present), then one JSON + * line per item in the named array field. + * + * @param {object} data - Result object (may contain `_pagination`) + * @param {string} field - Array field name to stream (e.g. `'results'`) + */ +export function printNdjson(data, field) { + if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination })); + const items = data[field]; + if (Array.isArray(items)) { + for (const item of items) console.log(JSON.stringify(item)); + } +} diff --git a/tests/builder/detect-changes.test.js b/tests/builder/detect-changes.test.js index f6a671e3..2555b097 100644 --- a/tests/builder/detect-changes.test.js +++ b/tests/builder/detect-changes.test.js @@ -8,7 +8,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { closeDb, initSchema, openDb } from '../../src/db/index.js'; import { PipelineContext } from '../../src/domain/graph/builder/context.js'; import { detectChanges } from '../../src/domain/graph/builder/stages/detect-changes.js'; -import { writeJournalHeader } from '../../src/journal.js'; +import { writeJournalHeader } from '../../src/domain/graph/journal.js'; let tmpDir; diff --git a/tests/engines/dataflow-parity.test.js b/tests/engines/dataflow-parity.test.js index 1559243a..0ed2d996 100644 --- a/tests/engines/dataflow-parity.test.js +++ b/tests/engines/dataflow-parity.test.js @@ -12,9 +12,9 @@ */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers, getParser } from '../../src/domain/parser.js'; import { extractDataflow } from '../../src/features/dataflow.js'; -import { isNativeAvailable } from '../../src/native.js'; -import { createParsers, getParser } from '../../src/parser.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; let native; let parsers; @@ -136,7 +136,7 @@ const describeOrSkip = hasNative ? describe : describe.skip; describeOrSkip('Cross-engine dataflow parity', () => { beforeAll(async () => { if (!hasNative) return; - const { getNative } = await import('../../src/native.js'); + const { getNative } = await import('../../src/infrastructure/native.js'); native = getNative(); nativeHasDataflow = detectNativeDataflow(); parsers = await createParsers(); diff --git a/tests/engines/parity.test.js b/tests/engines/parity.test.js index 184d2e06..fc11c2e1 100644 --- a/tests/engines/parity.test.js +++ b/tests/engines/parity.test.js @@ -8,7 +8,6 @@ */ import { beforeAll, describe, expect, it } from 'vitest'; -import { isNativeAvailable } from '../../src/native.js'; import { createParsers, extractCSharpSymbols, @@ -21,7 +20,8 @@ import { extractRustSymbols, extractSymbols, getParser, -} from '../../src/parser.js'; +} from '../../src/domain/parser.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; let native; let parsers; @@ -110,7 +110,7 @@ const describeOrSkip = hasNative ? describe : describe.skip; describeOrSkip('Cross-engine parity', () => { beforeAll(async () => { if (!hasNative) return; - const { getNative } = await import('../../src/native.js'); + const { getNative } = await import('../../src/infrastructure/native.js'); native = getNative(); parsers = await createParsers(); }); diff --git a/tests/engines/query-walk-parity.test.js b/tests/engines/query-walk-parity.test.js index 2556af08..05335c5c 100644 --- a/tests/engines/query-walk-parity.test.js +++ b/tests/engines/query-walk-parity.test.js @@ -9,8 +9,8 @@ */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers, getParser, parseFileAuto } from '../../src/domain/parser.js'; import { extractSymbols } from '../../src/extractors/javascript.js'; -import { createParsers, getParser, parseFileAuto } from '../../src/parser.js'; let parsers; diff --git a/tests/graph/cycles.test.js b/tests/graph/cycles.test.js index 8f6946e3..2dd8a2ea 100644 --- a/tests/graph/cycles.test.js +++ b/tests/graph/cycles.test.js @@ -6,7 +6,7 @@ import Database from 'better-sqlite3'; import { describe, expect, it } from 'vitest'; import { initSchema } from '../../src/db/index.js'; import { findCycles, findCyclesJS } from '../../src/domain/graph/cycles.js'; -import { isNativeAvailable, loadNative } from '../../src/native.js'; +import { isNativeAvailable, loadNative } from '../../src/infrastructure/native.js'; const hasNative = isNativeAvailable(); diff --git a/tests/incremental/cache.test.js b/tests/incremental/cache.test.js index f930ce64..46ee6290 100644 --- a/tests/incremental/cache.test.js +++ b/tests/incremental/cache.test.js @@ -5,7 +5,7 @@ */ import { beforeEach, describe, expect, it } from 'vitest'; -import { isNativeAvailable, loadNative } from '../../src/native.js'; +import { isNativeAvailable, loadNative } from '../../src/infrastructure/native.js'; const hasNative = isNativeAvailable(); diff --git a/tests/incremental/watcher-incremental.test.js b/tests/incremental/watcher-incremental.test.js index d2619894..8c273203 100644 --- a/tests/incremental/watcher-incremental.test.js +++ b/tests/incremental/watcher-incremental.test.js @@ -11,8 +11,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { isNativeAvailable } from '../../src/native.js'; -import { createParseTreeCache, parseFileIncremental } from '../../src/parser.js'; +import { createParseTreeCache, parseFileIncremental } from '../../src/domain/parser.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; const hasNative = isNativeAvailable(); diff --git a/tests/integration/build-parity.test.js b/tests/integration/build-parity.test.js index 18c710e2..86ef5043 100644 --- a/tests/integration/build-parity.test.js +++ b/tests/integration/build-parity.test.js @@ -13,7 +13,7 @@ import path from 'node:path'; import Database from 'better-sqlite3'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { buildGraph } from '../../src/domain/graph/builder.js'; -import { isNativeAvailable } from '../../src/native.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; const FIXTURE_DIR = path.join(import.meta.dirname, '..', 'fixtures', 'sample-project'); diff --git a/tests/integration/build.test.js b/tests/integration/build.test.js index 8af7600d..d7bee6bc 100644 --- a/tests/integration/build.test.js +++ b/tests/integration/build.test.js @@ -10,7 +10,7 @@ import Database from 'better-sqlite3'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { closeDb, openDb, setBuildMeta } from '../../src/db/index.js'; import { buildGraph } from '../../src/domain/graph/builder.js'; -import { JOURNAL_FILENAME, writeJournalHeader } from '../../src/journal.js'; +import { JOURNAL_FILENAME, writeJournalHeader } from '../../src/domain/graph/journal.js'; // ES-module versions of the sample-project fixture so the parser // generates import edges (the originals use CommonJS require()). diff --git a/tests/integration/complexity.test.js b/tests/integration/complexity.test.js index 52454bd9..8d7ea175 100644 --- a/tests/integration/complexity.test.js +++ b/tests/integration/complexity.test.js @@ -10,11 +10,11 @@ import os from 'node:os'; import path from 'node:path'; import Database from 'better-sqlite3'; import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'; -import { loadConfig } from '../../src/config.js'; import { initSchema } from '../../src/db/index.js'; import { complexityData } from '../../src/features/complexity.js'; +import { loadConfig } from '../../src/infrastructure/config.js'; -vi.mock('../../src/config.js', () => ({ +vi.mock('../../src/infrastructure/config.js', () => ({ loadConfig: vi.fn(() => ({})), })); diff --git a/tests/integration/pagination.test.js b/tests/integration/pagination.test.js index e57f9c19..ecf31c5d 100644 --- a/tests/integration/pagination.test.js +++ b/tests/integration/pagination.test.js @@ -41,7 +41,7 @@ import { paginate, paginateResult, printNdjson, -} from '../../src/paginate.js'; +} from '../../src/shared/paginate.js'; // ─── Helpers ─────────────────────────────────────────────────────────── diff --git a/tests/parsers/ast-all-langs.test.js b/tests/parsers/ast-all-langs.test.js index 615c9304..b4130a18 100644 --- a/tests/parsers/ast-all-langs.test.js +++ b/tests/parsers/ast-all-langs.test.js @@ -13,9 +13,9 @@ import path from 'node:path'; import Database from 'better-sqlite3'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { initSchema } from '../../src/db/index.js'; +import { parseFilesAuto } from '../../src/domain/parser.js'; import { buildAstNodes } from '../../src/features/ast.js'; -import { loadNative } from '../../src/native.js'; -import { parseFilesAuto } from '../../src/parser.js'; +import { loadNative } from '../../src/infrastructure/native.js'; // ─── Helpers ────────────────────────────────────────────────────────── diff --git a/tests/parsers/ast-nodes.test.js b/tests/parsers/ast-nodes.test.js index 894d9d1a..3d3c3de7 100644 --- a/tests/parsers/ast-nodes.test.js +++ b/tests/parsers/ast-nodes.test.js @@ -11,9 +11,9 @@ import path from 'node:path'; import Database from 'better-sqlite3'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { initSchema } from '../../src/db/index.js'; +import { parseFilesAuto } from '../../src/domain/parser.js'; import { buildAstNodes } from '../../src/features/ast.js'; -import { loadNative } from '../../src/native.js'; -import { parseFilesAuto } from '../../src/parser.js'; +import { loadNative } from '../../src/infrastructure/native.js'; // ─── Fixture ────────────────────────────────────────────────────────── diff --git a/tests/parsers/cfg-all-langs.test.js b/tests/parsers/cfg-all-langs.test.js index f19ab8ec..f37c9a95 100644 --- a/tests/parsers/cfg-all-langs.test.js +++ b/tests/parsers/cfg-all-langs.test.js @@ -14,10 +14,10 @@ import path from 'node:path'; import Database from 'better-sqlite3'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { initSchema } from '../../src/db/index.js'; +import { createParsers, getParser, parseFilesAuto } from '../../src/domain/parser.js'; import { buildCFGData, buildFunctionCFG } from '../../src/features/cfg.js'; import { COMPLEXITY_RULES, findFunctionNode } from '../../src/features/complexity.js'; -import { loadNative } from '../../src/native.js'; -import { createParsers, getParser, parseFilesAuto } from '../../src/parser.js'; +import { loadNative } from '../../src/infrastructure/native.js'; // ─── Helpers ────────────────────────────────────────────────────────── diff --git a/tests/parsers/csharp.test.js b/tests/parsers/csharp.test.js index e8031262..01d3725a 100644 --- a/tests/parsers/csharp.test.js +++ b/tests/parsers/csharp.test.js @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from 'vitest'; -import { createParsers, extractCSharpSymbols } from '../../src/parser.js'; +import { createParsers, extractCSharpSymbols } from '../../src/domain/parser.js'; describe('C# parser', () => { let parsers; diff --git a/tests/parsers/dataflow-csharp.test.js b/tests/parsers/dataflow-csharp.test.js index 6744d1c9..30f91a2d 100644 --- a/tests/parsers/dataflow-csharp.test.js +++ b/tests/parsers/dataflow-csharp.test.js @@ -2,8 +2,8 @@ * Unit tests for extractDataflow() against parsed C# ASTs. */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers } from '../../src/domain/parser.js'; import { extractDataflow } from '../../src/features/dataflow.js'; -import { createParsers } from '../../src/parser.js'; describe('extractDataflow — C#', () => { let parsers; diff --git a/tests/parsers/dataflow-go.test.js b/tests/parsers/dataflow-go.test.js index 674b8b3c..2f73d294 100644 --- a/tests/parsers/dataflow-go.test.js +++ b/tests/parsers/dataflow-go.test.js @@ -2,8 +2,8 @@ * Unit tests for extractDataflow() against parsed Go ASTs. */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers } from '../../src/domain/parser.js'; import { extractDataflow } from '../../src/features/dataflow.js'; -import { createParsers } from '../../src/parser.js'; describe('extractDataflow — Go', () => { let parsers; diff --git a/tests/parsers/dataflow-java.test.js b/tests/parsers/dataflow-java.test.js index 24175ea0..44481a4e 100644 --- a/tests/parsers/dataflow-java.test.js +++ b/tests/parsers/dataflow-java.test.js @@ -2,8 +2,8 @@ * Unit tests for extractDataflow() against parsed Java ASTs. */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers } from '../../src/domain/parser.js'; import { extractDataflow } from '../../src/features/dataflow.js'; -import { createParsers } from '../../src/parser.js'; describe('extractDataflow — Java', () => { let parsers; diff --git a/tests/parsers/dataflow-javascript.test.js b/tests/parsers/dataflow-javascript.test.js index df9d1dfb..bcbdf0c9 100644 --- a/tests/parsers/dataflow-javascript.test.js +++ b/tests/parsers/dataflow-javascript.test.js @@ -2,8 +2,8 @@ * Unit tests for extractDataflow() against parsed JS/TS ASTs. */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers } from '../../src/domain/parser.js'; import { extractDataflow } from '../../src/features/dataflow.js'; -import { createParsers } from '../../src/parser.js'; describe('extractDataflow — JavaScript', () => { let parsers; diff --git a/tests/parsers/dataflow-php.test.js b/tests/parsers/dataflow-php.test.js index ae337942..ef5ddcc9 100644 --- a/tests/parsers/dataflow-php.test.js +++ b/tests/parsers/dataflow-php.test.js @@ -2,8 +2,8 @@ * Unit tests for extractDataflow() against parsed PHP ASTs. */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers } from '../../src/domain/parser.js'; import { extractDataflow } from '../../src/features/dataflow.js'; -import { createParsers } from '../../src/parser.js'; describe('extractDataflow — PHP', () => { let parsers; diff --git a/tests/parsers/dataflow-python.test.js b/tests/parsers/dataflow-python.test.js index 4a83874e..e97aeea9 100644 --- a/tests/parsers/dataflow-python.test.js +++ b/tests/parsers/dataflow-python.test.js @@ -2,8 +2,8 @@ * Unit tests for extractDataflow() against parsed Python ASTs. */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers } from '../../src/domain/parser.js'; import { extractDataflow } from '../../src/features/dataflow.js'; -import { createParsers } from '../../src/parser.js'; describe('extractDataflow — Python', () => { let parsers; diff --git a/tests/parsers/dataflow-ruby.test.js b/tests/parsers/dataflow-ruby.test.js index 69aa67db..973c6bee 100644 --- a/tests/parsers/dataflow-ruby.test.js +++ b/tests/parsers/dataflow-ruby.test.js @@ -2,8 +2,8 @@ * Unit tests for extractDataflow() against parsed Ruby ASTs. */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers } from '../../src/domain/parser.js'; import { extractDataflow } from '../../src/features/dataflow.js'; -import { createParsers } from '../../src/parser.js'; describe('extractDataflow — Ruby', () => { let parsers; diff --git a/tests/parsers/dataflow-rust.test.js b/tests/parsers/dataflow-rust.test.js index 5d771740..f7200487 100644 --- a/tests/parsers/dataflow-rust.test.js +++ b/tests/parsers/dataflow-rust.test.js @@ -2,8 +2,8 @@ * Unit tests for extractDataflow() against parsed Rust ASTs. */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers } from '../../src/domain/parser.js'; import { extractDataflow } from '../../src/features/dataflow.js'; -import { createParsers } from '../../src/parser.js'; describe('extractDataflow — Rust', () => { let parsers; diff --git a/tests/parsers/extended-kinds.test.js b/tests/parsers/extended-kinds.test.js index 266ac44a..ab1a8ccf 100644 --- a/tests/parsers/extended-kinds.test.js +++ b/tests/parsers/extended-kinds.test.js @@ -15,7 +15,7 @@ import { extractRubySymbols, extractRustSymbols, extractSymbols, -} from '../../src/parser.js'; +} from '../../src/domain/parser.js'; // ── JavaScript ────────────────────────────────────────────────────────────── diff --git a/tests/parsers/go.test.js b/tests/parsers/go.test.js index 6d6c23a0..e8c29581 100644 --- a/tests/parsers/go.test.js +++ b/tests/parsers/go.test.js @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from 'vitest'; -import { createParsers, extractGoSymbols } from '../../src/parser.js'; +import { createParsers, extractGoSymbols } from '../../src/domain/parser.js'; describe('Go parser', () => { let parsers; diff --git a/tests/parsers/java.test.js b/tests/parsers/java.test.js index cc458dbd..79486a04 100644 --- a/tests/parsers/java.test.js +++ b/tests/parsers/java.test.js @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from 'vitest'; -import { createParsers, extractJavaSymbols } from '../../src/parser.js'; +import { createParsers, extractJavaSymbols } from '../../src/domain/parser.js'; describe('Java parser', () => { let parsers; diff --git a/tests/parsers/javascript.test.js b/tests/parsers/javascript.test.js index 539929c0..63875fc8 100644 --- a/tests/parsers/javascript.test.js +++ b/tests/parsers/javascript.test.js @@ -6,7 +6,7 @@ * Then: npm test */ import { beforeAll, describe, expect, it } from 'vitest'; -import { createParsers, extractSymbols } from '../../src/parser.js'; +import { createParsers, extractSymbols } from '../../src/domain/parser.js'; describe('JavaScript parser', () => { let parsers; diff --git a/tests/parsers/php.test.js b/tests/parsers/php.test.js index 8f32dbad..106ba306 100644 --- a/tests/parsers/php.test.js +++ b/tests/parsers/php.test.js @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from 'vitest'; -import { createParsers, extractPHPSymbols } from '../../src/parser.js'; +import { createParsers, extractPHPSymbols } from '../../src/domain/parser.js'; describe('PHP parser', () => { let parsers; diff --git a/tests/parsers/ruby.test.js b/tests/parsers/ruby.test.js index 64d0d45a..eff4a403 100644 --- a/tests/parsers/ruby.test.js +++ b/tests/parsers/ruby.test.js @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from 'vitest'; -import { createParsers, extractRubySymbols } from '../../src/parser.js'; +import { createParsers, extractRubySymbols } from '../../src/domain/parser.js'; describe('Ruby parser', () => { let parsers; diff --git a/tests/parsers/rust.test.js b/tests/parsers/rust.test.js index e58ea256..e6ee7dae 100644 --- a/tests/parsers/rust.test.js +++ b/tests/parsers/rust.test.js @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from 'vitest'; -import { createParsers, extractRustSymbols } from '../../src/parser.js'; +import { createParsers, extractRustSymbols } from '../../src/domain/parser.js'; describe('Rust parser', () => { let parsers; diff --git a/tests/parsers/unified.test.js b/tests/parsers/unified.test.js index d5c59d66..b05c1919 100644 --- a/tests/parsers/unified.test.js +++ b/tests/parsers/unified.test.js @@ -6,7 +6,7 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { getActiveEngine, parseFileAuto, parseFilesAuto } from '../../src/parser.js'; +import { getActiveEngine, parseFileAuto, parseFilesAuto } from '../../src/domain/parser.js'; describe('Unified parser API', () => { describe('getActiveEngine', () => { diff --git a/tests/resolution/parity.test.js b/tests/resolution/parity.test.js index 444c4040..6c036ee0 100644 --- a/tests/resolution/parity.test.js +++ b/tests/resolution/parity.test.js @@ -14,7 +14,7 @@ import { resolveImportPathJS, resolveImportsBatch, } from '../../src/domain/graph/resolve.js'; -import { isNativeAvailable, loadNative } from '../../src/native.js'; +import { isNativeAvailable, loadNative } from '../../src/infrastructure/native.js'; const hasNative = isNativeAvailable(); diff --git a/tests/unit/cfg.test.js b/tests/unit/cfg.test.js index adf8aa49..50dc06dd 100644 --- a/tests/unit/cfg.test.js +++ b/tests/unit/cfg.test.js @@ -9,9 +9,9 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { CFG_RULES } from '../../src/ast-analysis/rules/index.js'; import { walkWithVisitors } from '../../src/ast-analysis/visitor.js'; import { createCfgVisitor } from '../../src/ast-analysis/visitors/cfg-visitor.js'; +import { createParsers } from '../../src/domain/parser.js'; import { buildFunctionCFG, makeCfgRules } from '../../src/features/cfg.js'; import { COMPLEXITY_RULES } from '../../src/features/complexity.js'; -import { createParsers } from '../../src/parser.js'; let jsParser; diff --git a/tests/unit/change-journal.test.js b/tests/unit/change-journal.test.js index 5fcc787b..3595c674 100644 --- a/tests/unit/change-journal.test.js +++ b/tests/unit/change-journal.test.js @@ -14,7 +14,7 @@ import { DEFAULT_MAX_BYTES, diffSymbols, rotateIfNeeded, -} from '../../src/change-journal.js'; +} from '../../src/domain/graph/change-journal.js'; let tmpDir; diff --git a/tests/unit/complexity.test.js b/tests/unit/complexity.test.js index b458ead1..776df84c 100644 --- a/tests/unit/complexity.test.js +++ b/tests/unit/complexity.test.js @@ -6,6 +6,7 @@ */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers } from '../../src/domain/parser.js'; import { COMPLEXITY_RULES, computeFunctionComplexity, @@ -14,7 +15,6 @@ import { computeMaintainabilityIndex, HALSTEAD_RULES, } from '../../src/features/complexity.js'; -import { createParsers } from '../../src/parser.js'; let jsParser; diff --git a/tests/unit/config.test.js b/tests/unit/config.test.js index 77af17aa..56685830 100644 --- a/tests/unit/config.test.js +++ b/tests/unit/config.test.js @@ -12,7 +12,7 @@ import { DEFAULTS, loadConfig, resolveSecrets, -} from '../../src/config.js'; +} from '../../src/infrastructure/config.js'; vi.mock('node:child_process', async (importOriginal) => { const actual = await importOriginal(); diff --git a/tests/unit/constants.test.js b/tests/unit/constants.test.js index e192c056..81ed4dba 100644 --- a/tests/unit/constants.test.js +++ b/tests/unit/constants.test.js @@ -10,7 +10,7 @@ import { isSupportedFile, normalizePath, shouldIgnore, -} from '../../src/constants.js'; +} from '../../src/shared/constants.js'; describe('EXTENSIONS', () => { it('contains known supported extensions', () => { diff --git a/tests/unit/errors.test.js b/tests/unit/errors.test.js index 3714df5b..db4736e0 100644 --- a/tests/unit/errors.test.js +++ b/tests/unit/errors.test.js @@ -12,7 +12,7 @@ import { EngineError, ParseError, ResolutionError, -} from '../../src/errors.js'; +} from '../../src/shared/errors.js'; describe('CodegraphError', () => { it('sets defaults', () => { diff --git a/tests/unit/journal.test.js b/tests/unit/journal.test.js index a4ef3dd2..27769f2a 100644 --- a/tests/unit/journal.test.js +++ b/tests/unit/journal.test.js @@ -11,7 +11,7 @@ import { JOURNAL_FILENAME, readJournal, writeJournalHeader, -} from '../../src/journal.js'; +} from '../../src/domain/graph/journal.js'; let tmpDir; diff --git a/tests/unit/logger.test.js b/tests/unit/logger.test.js index a95ee42f..fb54863d 100644 --- a/tests/unit/logger.test.js +++ b/tests/unit/logger.test.js @@ -3,7 +3,14 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { debug, error, info, isVerbose, setVerbose, warn } from '../../src/logger.js'; +import { + debug, + error, + info, + isVerbose, + setVerbose, + warn, +} from '../../src/infrastructure/logger.js'; describe('logger', () => { let stderrSpy; diff --git a/tests/unit/mcp.test.js b/tests/unit/mcp.test.js index cb9f490e..352ea092 100644 --- a/tests/unit/mcp.test.js +++ b/tests/unit/mcp.test.js @@ -587,7 +587,7 @@ describe('startMCPServer handler dispatch', () => { ListToolsRequestSchema: 'tools/list', CallToolRequestSchema: 'tools/call', })); - vi.doMock('../../src/registry.js', () => ({ + vi.doMock('../../src/infrastructure/registry.js', () => ({ resolveRepoDbPath: vi.fn((name) => name === 'my-project' ? '/resolved/path/.codegraph/graph.db' : undefined, ), @@ -650,7 +650,7 @@ describe('startMCPServer handler dispatch', () => { ListToolsRequestSchema: 'tools/list', CallToolRequestSchema: 'tools/call', })); - vi.doMock('../../src/registry.js', () => ({ + vi.doMock('../../src/infrastructure/registry.js', () => ({ resolveRepoDbPath: vi.fn(() => undefined), })); vi.doMock('../../src/domain/queries.js', () => ({ @@ -703,7 +703,7 @@ describe('startMCPServer handler dispatch', () => { ListToolsRequestSchema: 'tools/list', CallToolRequestSchema: 'tools/call', })); - vi.doMock('../../src/registry.js', () => ({ + vi.doMock('../../src/infrastructure/registry.js', () => ({ resolveRepoDbPath: vi.fn(() => '/some/path'), })); vi.doMock('../../src/domain/queries.js', () => ({ @@ -756,7 +756,7 @@ describe('startMCPServer handler dispatch', () => { ListToolsRequestSchema: 'tools/list', CallToolRequestSchema: 'tools/call', })); - vi.doMock('../../src/registry.js', () => ({ + vi.doMock('../../src/infrastructure/registry.js', () => ({ resolveRepoDbPath: vi.fn(() => '/resolved/db'), })); @@ -817,7 +817,7 @@ describe('startMCPServer handler dispatch', () => { ListToolsRequestSchema: 'tools/list', CallToolRequestSchema: 'tools/call', })); - vi.doMock('../../src/registry.js', () => ({ + vi.doMock('../../src/infrastructure/registry.js', () => ({ resolveRepoDbPath: vi.fn(), listRepos: vi.fn(() => [ { name: 'alpha', path: '/alpha' }, @@ -876,7 +876,7 @@ describe('startMCPServer handler dispatch', () => { ListToolsRequestSchema: 'tools/list', CallToolRequestSchema: 'tools/call', })); - vi.doMock('../../src/registry.js', () => ({ + vi.doMock('../../src/infrastructure/registry.js', () => ({ resolveRepoDbPath: vi.fn(), listRepos: vi.fn(() => [ { name: 'alpha', path: '/alpha' }, diff --git a/tests/unit/parser.test.js b/tests/unit/parser.test.js index 495b2a96..7995ba77 100644 --- a/tests/unit/parser.test.js +++ b/tests/unit/parser.test.js @@ -4,7 +4,7 @@ import fs from 'node:fs'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { isWasmAvailable, LANGUAGE_REGISTRY } from '../../src/parser.js'; +import { isWasmAvailable, LANGUAGE_REGISTRY } from '../../src/domain/parser.js'; describe('isWasmAvailable', () => { afterEach(() => { diff --git a/tests/unit/registry.test.js b/tests/unit/registry.test.js index 3d166d62..9cc0d1aa 100644 --- a/tests/unit/registry.test.js +++ b/tests/unit/registry.test.js @@ -13,7 +13,7 @@ import { resolveRepoDbPath, saveRegistry, unregisterRepo, -} from '../../src/registry.js'; +} from '../../src/infrastructure/registry.js'; let tmpDir; let registryPath; @@ -41,7 +41,7 @@ describe('REGISTRY_PATH', () => { [ '--input-type=module', '-e', - `import { REGISTRY_PATH } from './src/registry.js'; process.stdout.write(REGISTRY_PATH);`, + `import { REGISTRY_PATH } from './src/infrastructure/registry.js'; process.stdout.write(REGISTRY_PATH);`, ], { cwd: path.resolve(import.meta.dirname, '..', '..'), diff --git a/tests/unit/update-check.test.js b/tests/unit/update-check.test.js index 1bd41cff..78bc204f 100644 --- a/tests/unit/update-check.test.js +++ b/tests/unit/update-check.test.js @@ -2,7 +2,11 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { checkForUpdates, printUpdateNotification, semverCompare } from '../../src/update-check.js'; +import { + checkForUpdates, + printUpdateNotification, + semverCompare, +} from '../../src/infrastructure/update-check.js'; let tmpDir; let cachePath; diff --git a/tests/unit/visitor.test.js b/tests/unit/visitor.test.js index e8f4d437..e15571ec 100644 --- a/tests/unit/visitor.test.js +++ b/tests/unit/visitor.test.js @@ -8,7 +8,7 @@ let parse; async function ensureParser() { if (parse) return; - const { createParsers, getParser } = await import('../../src/parser.js'); + const { createParsers, getParser } = await import('../../src/domain/parser.js'); const parsers = await createParsers(); parse = (code) => { // getParser needs a path to determine language From f7def33cb6e52484cbd92cd7e017c7e632bb7d6e Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 03:09:49 -0600 Subject: [PATCH 2/7] refactor: move remaining flat src/ files into subdirectories (#458) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: reorganize src/ into domain/, features/, presentation/ layers Move source files into a domain-driven directory structure: - src/domain/ — core graph building, queries, analysis, search (embeddings) - src/features/ — composable feature modules (audit, check, complexity, etc.) - src/presentation/ — CLI formatting and output rendering - src/db/ — database layer (db.js → db/index.js) All 199 files updated with corrected import paths. 1858 tests pass, 0 cycles, all manifesto rules green. Impact: 22 functions changed, 5 affected * docs: update CLAUDE.md architecture table for new directory structure Update file paths in the architecture table to reflect the domain/, features/, presentation/, db/ reorganization. Addresses Greptile review. * refactor: move remaining flat src/ files into subdirectories Move 12 remaining files out of flat src/ so only cli.js and index.js remain as entry points: - config, logger, native, registry, update-check → infrastructure/ - constants, errors, kinds, paginate → shared/ - parser → domain/ - journal, change-journal → domain/graph/ Update all import paths across src/ and tests/. Update CLAUDE.md architecture table to match. Impact: 9 functions changed, 17 affected * style: fix import ordering after file moves --- CLAUDE.md | 25 +++++++++++------- src/ast-analysis/engine.js | 4 +-- src/ast-analysis/shared.js | 4 +-- src/cli.js | 2 +- src/cli/commands/ast.js | 2 +- src/cli/commands/batch.js | 2 +- src/cli/commands/check.js | 2 +- src/cli/commands/co-change.js | 2 +- src/cli/commands/info.js | 4 +-- src/cli/commands/registry.js | 4 +-- src/cli/commands/triage.js | 2 +- src/cli/index.js | 6 ++--- src/cli/shared/options.js | 2 +- src/commands/check.js | 2 +- src/db/connection.js | 4 +-- src/db/migrations.js | 2 +- src/db/query-builder.js | 4 +-- src/db/repository/graph-read.js | 2 +- src/db/repository/in-memory-repository.js | 4 +-- src/db/repository/nodes.js | 4 +-- src/domain/analysis/context.js | 2 +- src/domain/analysis/dependencies.js | 2 +- src/domain/analysis/exports.js | 2 +- src/domain/analysis/impact.js | 4 +-- src/domain/analysis/module-map.js | 2 +- src/domain/analysis/roles.js | 2 +- src/domain/analysis/symbol-lookup.js | 4 +-- src/domain/graph/builder/helpers.js | 4 +-- src/domain/graph/builder/incremental.js | 6 ++--- src/domain/graph/builder/pipeline.js | 6 ++--- .../graph/builder/stages/build-edges.js | 2 +- .../graph/builder/stages/build-structure.js | 4 +-- .../graph/builder/stages/collect-files.js | 4 +-- .../graph/builder/stages/detect-changes.js | 8 +++--- src/domain/graph/builder/stages/finalize.js | 6 ++--- .../graph/builder/stages/parse-files.js | 4 +-- .../graph/builder/stages/resolve-imports.js | 2 +- .../graph/builder/stages/run-analyses.js | 2 +- src/{ => domain/graph}/change-journal.js | 2 +- src/domain/graph/cycles.js | 2 +- src/{ => domain/graph}/journal.js | 2 +- src/domain/graph/resolve.js | 4 +-- src/domain/graph/watcher.js | 12 ++++----- src/{ => domain}/parser.js | 10 +++---- src/domain/queries.js | 4 +-- src/domain/search/generator.js | 4 +-- src/domain/search/models.js | 4 +-- src/domain/search/search/cli-formatter.js | 2 +- src/domain/search/search/semantic.js | 2 +- src/extractors/javascript.js | 2 +- src/features/ast.js | 4 +-- src/features/audit.js | 2 +- src/features/batch.js | 2 +- src/features/boundaries.js | 2 +- src/features/cfg.js | 8 +++--- src/features/check.js | 2 +- src/features/cochange.js | 6 ++--- src/features/communities.js | 2 +- src/features/complexity.js | 10 +++---- src/features/dataflow.js | 8 +++--- src/features/export.js | 2 +- src/features/flow.js | 2 +- src/features/manifesto.js | 6 ++--- src/features/sequence.js | 2 +- src/features/snapshot.js | 4 +-- src/features/structure.js | 6 ++--- src/features/triage.js | 4 +-- src/index.js | 26 +++++++++---------- src/{ => infrastructure}/config.js | 0 src/{ => infrastructure}/logger.js | 0 src/{ => infrastructure}/native.js | 2 +- src/{ => infrastructure}/registry.js | 0 src/{ => infrastructure}/update-check.js | 0 src/mcp/middleware.js | 2 +- src/mcp/server.js | 6 ++--- src/mcp/tools/list-repos.js | 2 +- src/presentation/result-formatter.js | 2 +- src/{ => shared}/constants.js | 2 +- src/{ => shared}/errors.js | 0 src/shared/file-utils.js | 4 +-- src/shared/generators.js | 2 +- src/{ => shared}/kinds.js | 0 src/{ => shared}/paginate.js | 0 tests/builder/detect-changes.test.js | 2 +- tests/engines/dataflow-parity.test.js | 6 ++--- tests/engines/parity.test.js | 6 ++--- tests/engines/query-walk-parity.test.js | 2 +- tests/graph/cycles.test.js | 2 +- tests/incremental/cache.test.js | 2 +- tests/incremental/watcher-incremental.test.js | 4 +-- tests/integration/build-parity.test.js | 2 +- tests/integration/build.test.js | 2 +- tests/integration/complexity.test.js | 4 +-- tests/integration/pagination.test.js | 2 +- tests/parsers/ast-all-langs.test.js | 4 +-- tests/parsers/ast-nodes.test.js | 4 +-- tests/parsers/cfg-all-langs.test.js | 4 +-- tests/parsers/csharp.test.js | 2 +- tests/parsers/dataflow-csharp.test.js | 2 +- tests/parsers/dataflow-go.test.js | 2 +- tests/parsers/dataflow-java.test.js | 2 +- tests/parsers/dataflow-javascript.test.js | 2 +- tests/parsers/dataflow-php.test.js | 2 +- tests/parsers/dataflow-python.test.js | 2 +- tests/parsers/dataflow-ruby.test.js | 2 +- tests/parsers/dataflow-rust.test.js | 2 +- tests/parsers/extended-kinds.test.js | 2 +- tests/parsers/go.test.js | 2 +- tests/parsers/java.test.js | 2 +- tests/parsers/javascript.test.js | 2 +- tests/parsers/php.test.js | 2 +- tests/parsers/ruby.test.js | 2 +- tests/parsers/rust.test.js | 2 +- tests/parsers/unified.test.js | 2 +- tests/resolution/parity.test.js | 2 +- tests/unit/cfg.test.js | 2 +- tests/unit/change-journal.test.js | 2 +- tests/unit/complexity.test.js | 2 +- tests/unit/config.test.js | 2 +- tests/unit/constants.test.js | 2 +- tests/unit/errors.test.js | 2 +- tests/unit/journal.test.js | 2 +- tests/unit/logger.test.js | 9 ++++++- tests/unit/mcp.test.js | 12 ++++----- tests/unit/parser.test.js | 2 +- tests/unit/registry.test.js | 4 +-- tests/unit/update-check.test.js | 6 ++++- tests/unit/visitor.test.js | 2 +- 128 files changed, 234 insertions(+), 216 deletions(-) rename src/{ => domain/graph}/change-journal.js (98%) rename src/{ => domain/graph}/journal.js (97%) rename src/{ => domain}/parser.js (98%) rename src/{ => infrastructure}/config.js (100%) rename src/{ => infrastructure}/logger.js (100%) rename src/{ => infrastructure}/native.js (98%) rename src/{ => infrastructure}/registry.js (100%) rename src/{ => infrastructure}/update-check.js (100%) rename src/{ => shared}/constants.js (92%) rename src/{ => shared}/errors.js (100%) rename src/{ => shared}/kinds.js (100%) rename src/{ => shared}/paginate.js (100%) diff --git a/CLAUDE.md b/CLAUDE.md index efef122f..512162b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,21 +42,28 @@ JS source is plain JavaScript (ES modules) in `src/`. No transpilation step. The |------|------| | `cli.js` | Commander CLI entry point (`bin.codegraph`) | | `index.js` | Programmatic API exports | -| `parser.js` | tree-sitter WASM wrapper; `LANGUAGE_REGISTRY` + per-language extractors for functions, classes, methods, imports, exports, call sites | -| `config.js` | `.codegraphrc.json` loading, env overrides, `apiKeyCommand` secret resolution | -| `constants.js` | `EXTENSIONS` (derived from parser registry) and `IGNORE_DIRS` constants | -| `native.js` | Native napi-rs addon loader with WASM fallback | -| `registry.js` | Global repo registry (`~/.codegraph/registry.json`) for multi-repo MCP | -| `paginate.js` | Pagination helpers for bounded query results | -| `logger.js` | Structured logging (`warn`, `debug`, `info`, `error`) | +| **`shared/`** | **Cross-cutting constants and utilities** | +| `shared/constants.js` | `EXTENSIONS` (derived from parser registry) and `IGNORE_DIRS` constants | +| `shared/errors.js` | Domain error hierarchy (`CodegraphError`, `ConfigError`, `ParseError`, etc.) | +| `shared/kinds.js` | Symbol and edge kind constants (`CORE_SYMBOL_KINDS`, `EVERY_SYMBOL_KIND`, `VALID_ROLES`) | +| `shared/paginate.js` | Pagination helpers for bounded query results | +| **`infrastructure/`** | **Platform and I/O plumbing** | +| `infrastructure/config.js` | `.codegraphrc.json` loading, env overrides, `apiKeyCommand` secret resolution | +| `infrastructure/logger.js` | Structured logging (`warn`, `debug`, `info`, `error`) | +| `infrastructure/native.js` | Native napi-rs addon loader with WASM fallback | +| `infrastructure/registry.js` | Global repo registry (`~/.codegraph/registry.json`) for multi-repo MCP | +| `infrastructure/update-check.js` | npm update availability check | | **`db/`** | **Database layer** | | `db/index.js` | SQLite schema and operations (`better-sqlite3`) | | **`domain/`** | **Core domain logic** | -| `domain/queries.js` | Query functions: symbol search, file deps, impact analysis, diff-impact; `SYMBOL_KINDS` constant defines all node kinds | +| `domain/parser.js` | tree-sitter WASM wrapper; `LANGUAGE_REGISTRY` + per-language extractors for functions, classes, methods, imports, exports, call sites | +| `domain/queries.js` | Query functions: symbol search, file deps, impact analysis, diff-impact | | `domain/graph/builder.js` | Graph building: file collection, parsing, import resolution, incremental hashing | | `domain/graph/cycles.js` | Circular dependency detection (delegates to `graph/` subsystem) | | `domain/graph/resolve.js` | Import resolution (supports native batch mode) | | `domain/graph/watcher.js` | Watch mode for incremental rebuilds | +| `domain/graph/journal.js` | Change journal for incremental builds | +| `domain/graph/change-journal.js` | Change event tracking (NDJSON) | | `domain/analysis/` | Query-layer analysis: context, dependencies, exports, impact, module-map, roles, symbol-lookup | | `domain/search/` | Embedding subsystem: model management, vector generation, semantic/keyword/hybrid search, CLI formatting | | **`features/`** | **Composable feature modules** | @@ -87,7 +94,7 @@ JS source is plain JavaScript (ES modules) in `src/`. No transpilation step. The - **Dual-engine architecture:** Native Rust parsing via napi-rs (`crates/codegraph-core/`) with automatic fallback to WASM. Controlled by `--engine native|wasm|auto` (default: `auto`) - Platform-specific prebuilt binaries published as optional npm packages (`@optave/codegraph-{platform}-{arch}`) - WASM grammars are built from devDeps on `npm install` (via `prepare` script) and not committed to git — used as fallback when native addon is unavailable -- **Language parser registry:** `LANGUAGE_REGISTRY` in `parser.js` is the single source of truth for all supported languages — maps each language to `{ id, extensions, grammarFile, extractor, required }`. `EXTENSIONS` in `constants.js` is derived from the registry. Adding a new language requires one registry entry + extractor function +- **Language parser registry:** `LANGUAGE_REGISTRY` in `domain/parser.js` is the single source of truth for all supported languages — maps each language to `{ id, extensions, grammarFile, extractor, required }`. `EXTENSIONS` in `shared/constants.js` is derived from the registry. Adding a new language requires one registry entry + extractor function - **Node kinds:** `SYMBOL_KINDS` in `domain/queries.js` lists all valid kinds: `function`, `method`, `class`, `interface`, `type`, `struct`, `enum`, `trait`, `record`, `module`. Language-specific types use their native kind (e.g. Go structs → `struct`, Rust traits → `trait`, Ruby modules → `module`) rather than mapping everything to `class`/`interface` - `@huggingface/transformers` and `@modelcontextprotocol/sdk` are optional dependencies, lazy-loaded - Non-required parsers (all except JS/TS/TSX) fail gracefully if their WASM grammar is unavailable diff --git a/src/ast-analysis/engine.js b/src/ast-analysis/engine.js index 3998e313..6775a7f0 100644 --- a/src/ast-analysis/engine.js +++ b/src/ast-analysis/engine.js @@ -18,7 +18,7 @@ import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { bulkNodeIdsByFile } from '../db/index.js'; -import { debug } from '../logger.js'; +import { debug } from '../infrastructure/logger.js'; import { computeLOCMetrics, computeMaintainabilityIndex } from './metrics.js'; import { AST_TYPE_MAPS, @@ -45,7 +45,7 @@ const WALK_EXTENSIONS = buildExtensionSet(AST_TYPE_MAPS); let _parserModule = null; async function getParserModule() { - if (!_parserModule) _parserModule = await import('../parser.js'); + if (!_parserModule) _parserModule = await import('../domain/parser.js'); return _parserModule; } diff --git a/src/ast-analysis/shared.js b/src/ast-analysis/shared.js index f5d8e0be..964f9a06 100644 --- a/src/ast-analysis/shared.js +++ b/src/ast-analysis/shared.js @@ -2,8 +2,8 @@ * Shared utilities for AST analysis modules (complexity, CFG, dataflow, AST nodes). */ -import { ConfigError } from '../errors.js'; -import { LANGUAGE_REGISTRY } from '../parser.js'; +import { LANGUAGE_REGISTRY } from '../domain/parser.js'; +import { ConfigError } from '../shared/errors.js'; // ─── Generic Rule Factory ───────────────────────────────────────────────── diff --git a/src/cli.js b/src/cli.js index 6318f0e4..449bd25f 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import { run } from './cli/index.js'; -import { CodegraphError } from './errors.js'; +import { CodegraphError } from './shared/errors.js'; run().catch((err) => { if (err instanceof CodegraphError) { diff --git a/src/cli/commands/ast.js b/src/cli/commands/ast.js index cc9124b0..1588804a 100644 --- a/src/cli/commands/ast.js +++ b/src/cli/commands/ast.js @@ -1,4 +1,4 @@ -import { ConfigError } from '../../errors.js'; +import { ConfigError } from '../../shared/errors.js'; export const command = { name: 'ast [pattern]', diff --git a/src/cli/commands/batch.js b/src/cli/commands/batch.js index 8ba4f99c..5d740ad1 100644 --- a/src/cli/commands/batch.js +++ b/src/cli/commands/batch.js @@ -1,8 +1,8 @@ import fs from 'node:fs'; import { batch } from '../../commands/batch.js'; import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; -import { ConfigError } from '../../errors.js'; import { BATCH_COMMANDS, multiBatchData, splitTargets } from '../../features/batch.js'; +import { ConfigError } from '../../shared/errors.js'; export const command = { name: 'batch [targets...]', diff --git a/src/cli/commands/check.js b/src/cli/commands/check.js index 43d98a3a..8c5f29ca 100644 --- a/src/cli/commands/check.js +++ b/src/cli/commands/check.js @@ -1,5 +1,5 @@ import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; -import { ConfigError } from '../../errors.js'; +import { ConfigError } from '../../shared/errors.js'; export const command = { name: 'check [ref]', diff --git a/src/cli/commands/co-change.js b/src/cli/commands/co-change.js index ef4885b5..83b29e75 100644 --- a/src/cli/commands/co-change.js +++ b/src/cli/commands/co-change.js @@ -1,4 +1,4 @@ -import { AnalysisError } from '../../errors.js'; +import { AnalysisError } from '../../shared/errors.js'; export const command = { name: 'co-change [file]', diff --git a/src/cli/commands/info.js b/src/cli/commands/info.js index a0fd2d6c..b4c0a28a 100644 --- a/src/cli/commands/info.js +++ b/src/cli/commands/info.js @@ -3,9 +3,9 @@ export const command = { description: 'Show codegraph engine info and diagnostics', async execute(_args, _opts, ctx) { const { getNativePackageVersion, isNativeAvailable, loadNative } = await import( - '../../native.js' + '../../infrastructure/native.js' ); - const { getActiveEngine } = await import('../../parser.js'); + const { getActiveEngine } = await import('../../domain/parser.js'); const engine = ctx.program.opts().engine; const { name: activeName, version: activeVersion } = getActiveEngine({ engine }); diff --git a/src/cli/commands/registry.js b/src/cli/commands/registry.js index 9e516d9a..f2803116 100644 --- a/src/cli/commands/registry.js +++ b/src/cli/commands/registry.js @@ -1,13 +1,13 @@ import fs from 'node:fs'; import path from 'node:path'; -import { ConfigError } from '../../errors.js'; import { listRepos, pruneRegistry, REGISTRY_PATH, registerRepo, unregisterRepo, -} from '../../registry.js'; +} from '../../infrastructure/registry.js'; +import { ConfigError } from '../../shared/errors.js'; export const command = { name: 'registry', diff --git a/src/cli/commands/triage.js b/src/cli/commands/triage.js index 851df8e9..23e07183 100644 --- a/src/cli/commands/triage.js +++ b/src/cli/commands/triage.js @@ -1,5 +1,5 @@ import { EVERY_SYMBOL_KIND, VALID_ROLES } from '../../domain/queries.js'; -import { ConfigError } from '../../errors.js'; +import { ConfigError } from '../../shared/errors.js'; export const command = { name: 'triage', diff --git a/src/cli/index.js b/src/cli/index.js index 02d36a77..057bae31 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -2,9 +2,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { Command } from 'commander'; -import { ConfigError } from '../errors.js'; -import { setVerbose } from '../logger.js'; -import { checkForUpdates, printUpdateNotification } from '../update-check.js'; +import { setVerbose } from '../infrastructure/logger.js'; +import { checkForUpdates, printUpdateNotification } from '../infrastructure/update-check.js'; +import { ConfigError } from '../shared/errors.js'; import { applyQueryOpts, config, formatSize, resolveNoTests } from './shared/options.js'; import { outputResult } from './shared/output.js'; diff --git a/src/cli/shared/options.js b/src/cli/shared/options.js index 9f596076..7f7552c3 100644 --- a/src/cli/shared/options.js +++ b/src/cli/shared/options.js @@ -1,4 +1,4 @@ -import { loadConfig } from '../../config.js'; +import { loadConfig } from '../../infrastructure/config.js'; const config = loadConfig(process.cwd()); diff --git a/src/commands/check.js b/src/commands/check.js index eb592fe0..1dc0c4fc 100644 --- a/src/commands/check.js +++ b/src/commands/check.js @@ -1,6 +1,6 @@ -import { AnalysisError } from '../errors.js'; import { checkData } from '../features/check.js'; import { outputResult } from '../infrastructure/result-formatter.js'; +import { AnalysisError } from '../shared/errors.js'; /** * CLI formatter — prints check results and sets exitCode 1 on failure. diff --git a/src/db/connection.js b/src/db/connection.js index d8b34c21..acf87547 100644 --- a/src/db/connection.js +++ b/src/db/connection.js @@ -1,8 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; import Database from 'better-sqlite3'; -import { DbError } from '../errors.js'; -import { warn } from '../logger.js'; +import { warn } from '../infrastructure/logger.js'; +import { DbError } from '../shared/errors.js'; function isProcessAlive(pid) { try { diff --git a/src/db/migrations.js b/src/db/migrations.js index e3925cdf..3b38feff 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -1,4 +1,4 @@ -import { debug } from '../logger.js'; +import { debug } from '../infrastructure/logger.js'; // ─── Schema Migrations ───────────────────────────────────────────────── export const MIGRATIONS = [ diff --git a/src/db/query-builder.js b/src/db/query-builder.js index 12a15ecc..10dd1fca 100644 --- a/src/db/query-builder.js +++ b/src/db/query-builder.js @@ -1,5 +1,5 @@ -import { DbError } from '../errors.js'; -import { EVERY_EDGE_KIND } from '../kinds.js'; +import { DbError } from '../shared/errors.js'; +import { EVERY_EDGE_KIND } from '../shared/kinds.js'; // ─── Validation Helpers ───────────────────────────────────────────── diff --git a/src/db/repository/graph-read.js b/src/db/repository/graph-read.js index b514e9bc..8fd284ad 100644 --- a/src/db/repository/graph-read.js +++ b/src/db/repository/graph-read.js @@ -1,4 +1,4 @@ -import { CORE_SYMBOL_KINDS } from '../../kinds.js'; +import { CORE_SYMBOL_KINDS } from '../../shared/kinds.js'; import { cachedStmt } from './cached-stmt.js'; // ─── Statement caches (one prepared statement per db instance) ──────────── diff --git a/src/db/repository/in-memory-repository.js b/src/db/repository/in-memory-repository.js index 91205c3c..9d228ca1 100644 --- a/src/db/repository/in-memory-repository.js +++ b/src/db/repository/in-memory-repository.js @@ -1,5 +1,5 @@ -import { ConfigError } from '../../errors.js'; -import { CORE_SYMBOL_KINDS, EVERY_SYMBOL_KIND, VALID_ROLES } from '../../kinds.js'; +import { ConfigError } from '../../shared/errors.js'; +import { CORE_SYMBOL_KINDS, EVERY_SYMBOL_KIND, VALID_ROLES } from '../../shared/kinds.js'; import { escapeLike } from '../query-builder.js'; import { Repository } from './base.js'; diff --git a/src/db/repository/nodes.js b/src/db/repository/nodes.js index cabc2ce3..fbe2ddf0 100644 --- a/src/db/repository/nodes.js +++ b/src/db/repository/nodes.js @@ -1,5 +1,5 @@ -import { ConfigError } from '../../errors.js'; -import { EVERY_SYMBOL_KIND, VALID_ROLES } from '../../kinds.js'; +import { ConfigError } from '../../shared/errors.js'; +import { EVERY_SYMBOL_KIND, VALID_ROLES } from '../../shared/kinds.js'; import { escapeLike, NodeQuery } from '../query-builder.js'; import { cachedStmt } from './cached-stmt.js'; diff --git a/src/domain/analysis/context.js b/src/domain/analysis/context.js index 803fcc99..e3409208 100644 --- a/src/domain/analysis/context.js +++ b/src/domain/analysis/context.js @@ -14,7 +14,6 @@ import { openReadonlyOrFail, } from '../../db/index.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; -import { paginateResult } from '../../paginate.js'; import { createFileLinesReader, extractSignature, @@ -24,6 +23,7 @@ import { } from '../../shared/file-utils.js'; import { resolveMethodViaHierarchy } from '../../shared/hierarchy.js'; import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; import { findMatchingNodes } from './symbol-lookup.js'; function explainFileImpl(db, target, getFileLines) { diff --git a/src/domain/analysis/dependencies.js b/src/domain/analysis/dependencies.js index 63778733..e632470f 100644 --- a/src/domain/analysis/dependencies.js +++ b/src/domain/analysis/dependencies.js @@ -8,9 +8,9 @@ import { openReadonlyOrFail, } from '../../db/index.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; -import { paginateResult } from '../../paginate.js'; import { resolveMethodViaHierarchy } from '../../shared/hierarchy.js'; import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; import { findMatchingNodes } from './symbol-lookup.js'; export function fileDepsData(file, customDbPath, opts = {}) { diff --git a/src/domain/analysis/exports.js b/src/domain/analysis/exports.js index 6088656f..9af6b807 100644 --- a/src/domain/analysis/exports.js +++ b/src/domain/analysis/exports.js @@ -7,12 +7,12 @@ import { openReadonlyOrFail, } from '../../db/index.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; -import { paginateResult } from '../../paginate.js'; import { createFileLinesReader, extractSignature, extractSummary, } from '../../shared/file-utils.js'; +import { paginateResult } from '../../shared/paginate.js'; export function exportsData(file, customDbPath, opts = {}) { const db = openReadonlyOrFail(customDbPath); diff --git a/src/domain/analysis/impact.js b/src/domain/analysis/impact.js index 282e9c64..01b5cef6 100644 --- a/src/domain/analysis/impact.js +++ b/src/domain/analysis/impact.js @@ -1,7 +1,6 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import { loadConfig } from '../../config.js'; import { findDbPath, findDistinctCallers, @@ -13,9 +12,10 @@ import { import { evaluateBoundaries } from '../../features/boundaries.js'; import { coChangeForFiles } from '../../features/cochange.js'; import { ownersForFiles } from '../../features/owners.js'; +import { loadConfig } from '../../infrastructure/config.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; -import { paginateResult } from '../../paginate.js'; import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; import { findMatchingNodes } from './symbol-lookup.js'; export function impactAnalysisData(file, customDbPath, opts = {}) { diff --git a/src/domain/analysis/module-map.js b/src/domain/analysis/module-map.js index fedef93e..e6aa0936 100644 --- a/src/domain/analysis/module-map.js +++ b/src/domain/analysis/module-map.js @@ -1,8 +1,8 @@ import path from 'node:path'; import { openReadonlyOrFail, testFilterSQL } from '../../db/index.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; -import { LANGUAGE_REGISTRY } from '../../parser.js'; import { findCycles } from '../graph/cycles.js'; +import { LANGUAGE_REGISTRY } from '../parser.js'; export const FALSE_POSITIVE_NAMES = new Set([ 'run', diff --git a/src/domain/analysis/roles.js b/src/domain/analysis/roles.js index d295515f..a54362fd 100644 --- a/src/domain/analysis/roles.js +++ b/src/domain/analysis/roles.js @@ -1,7 +1,7 @@ import { openReadonlyOrFail } from '../../db/index.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; -import { paginateResult } from '../../paginate.js'; import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; export function rolesData(customDbPath, opts = {}) { const db = openReadonlyOrFail(customDbPath); diff --git a/src/domain/analysis/symbol-lookup.js b/src/domain/analysis/symbol-lookup.js index e269a42d..47a7d403 100644 --- a/src/domain/analysis/symbol-lookup.js +++ b/src/domain/analysis/symbol-lookup.js @@ -14,9 +14,9 @@ import { openReadonlyOrFail, } from '../../db/index.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; -import { ALL_SYMBOL_KINDS } from '../../kinds.js'; -import { paginateResult } from '../../paginate.js'; +import { ALL_SYMBOL_KINDS } from '../../shared/kinds.js'; import { getFileHash, normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; const FUNCTION_KINDS = ['function', 'method', 'class']; diff --git a/src/domain/graph/builder/helpers.js b/src/domain/graph/builder/helpers.js index a333f456..038de4c2 100644 --- a/src/domain/graph/builder/helpers.js +++ b/src/domain/graph/builder/helpers.js @@ -6,9 +6,9 @@ import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import { EXTENSIONS, IGNORE_DIRS } from '../../../constants.js'; import { purgeFilesData } from '../../../db/index.js'; -import { warn } from '../../../logger.js'; +import { warn } from '../../../infrastructure/logger.js'; +import { EXTENSIONS, IGNORE_DIRS } from '../../../shared/constants.js'; export const BUILTIN_RECEIVERS = new Set([ 'console', diff --git a/src/domain/graph/builder/incremental.js b/src/domain/graph/builder/incremental.js index 7183d4b7..f04a136e 100644 --- a/src/domain/graph/builder/incremental.js +++ b/src/domain/graph/builder/incremental.js @@ -6,9 +6,9 @@ */ import fs from 'node:fs'; import path from 'node:path'; -import { normalizePath } from '../../../constants.js'; -import { warn } from '../../../logger.js'; -import { parseFileIncremental } from '../../../parser.js'; +import { warn } from '../../../infrastructure/logger.js'; +import { normalizePath } from '../../../shared/constants.js'; +import { parseFileIncremental } from '../../parser.js'; import { computeConfidence, resolveImportPath } from '../resolve.js'; import { BUILTIN_RECEIVERS, readFileSafe } from './helpers.js'; diff --git a/src/domain/graph/builder/pipeline.js b/src/domain/graph/builder/pipeline.js index d1be7ebb..ea9848c5 100644 --- a/src/domain/graph/builder/pipeline.js +++ b/src/domain/graph/builder/pipeline.js @@ -6,10 +6,10 @@ */ import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { loadConfig } from '../../../config.js'; import { closeDb, getBuildMeta, initSchema, MIGRATIONS, openDb } from '../../../db/index.js'; -import { info } from '../../../logger.js'; -import { getActiveEngine } from '../../../parser.js'; +import { loadConfig } from '../../../infrastructure/config.js'; +import { info } from '../../../infrastructure/logger.js'; +import { getActiveEngine } from '../../parser.js'; import { PipelineContext } from './context.js'; import { loadPathAliases } from './helpers.js'; import { buildEdges } from './stages/build-edges.js'; diff --git a/src/domain/graph/builder/stages/build-edges.js b/src/domain/graph/builder/stages/build-edges.js index 33aa66ca..a8879b62 100644 --- a/src/domain/graph/builder/stages/build-edges.js +++ b/src/domain/graph/builder/stages/build-edges.js @@ -7,7 +7,7 @@ import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { getNodeId } from '../../../../db/index.js'; -import { loadNative } from '../../../../native.js'; +import { loadNative } from '../../../../infrastructure/native.js'; import { computeConfidence } from '../../resolve.js'; import { BUILTIN_RECEIVERS, batchInsertEdges } from '../helpers.js'; import { getResolved, isBarrelFile, resolveBarrelExport } from './resolve-imports.js'; diff --git a/src/domain/graph/builder/stages/build-structure.js b/src/domain/graph/builder/stages/build-structure.js index ec235be2..f4737df9 100644 --- a/src/domain/graph/builder/stages/build-structure.js +++ b/src/domain/graph/builder/stages/build-structure.js @@ -5,8 +5,8 @@ */ import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { normalizePath } from '../../../../constants.js'; -import { debug } from '../../../../logger.js'; +import { debug } from '../../../../infrastructure/logger.js'; +import { normalizePath } from '../../../../shared/constants.js'; import { readFileSafe } from '../helpers.js'; /** diff --git a/src/domain/graph/builder/stages/collect-files.js b/src/domain/graph/builder/stages/collect-files.js index 97d183ce..9f3eb636 100644 --- a/src/domain/graph/builder/stages/collect-files.js +++ b/src/domain/graph/builder/stages/collect-files.js @@ -5,8 +5,8 @@ */ import fs from 'node:fs'; import path from 'node:path'; -import { normalizePath } from '../../../../constants.js'; -import { info } from '../../../../logger.js'; +import { info } from '../../../../infrastructure/logger.js'; +import { normalizePath } from '../../../../shared/constants.js'; import { collectFiles as collectFilesUtil } from '../helpers.js'; /** diff --git a/src/domain/graph/builder/stages/detect-changes.js b/src/domain/graph/builder/stages/detect-changes.js index 29d25a8a..50ffbd1d 100644 --- a/src/domain/graph/builder/stages/detect-changes.js +++ b/src/domain/graph/builder/stages/detect-changes.js @@ -6,11 +6,11 @@ */ import fs from 'node:fs'; import path from 'node:path'; -import { normalizePath } from '../../../../constants.js'; import { closeDb } from '../../../../db/index.js'; -import { readJournal, writeJournalHeader } from '../../../../journal.js'; -import { debug, info } from '../../../../logger.js'; -import { parseFilesAuto } from '../../../../parser.js'; +import { debug, info } from '../../../../infrastructure/logger.js'; +import { normalizePath } from '../../../../shared/constants.js'; +import { parseFilesAuto } from '../../../parser.js'; +import { readJournal, writeJournalHeader } from '../../journal.js'; import { fileHash, fileStat, purgeFilesFromGraph, readFileSafe } from '../helpers.js'; /** diff --git a/src/domain/graph/builder/stages/finalize.js b/src/domain/graph/builder/stages/finalize.js index e82411d7..6b493785 100644 --- a/src/domain/graph/builder/stages/finalize.js +++ b/src/domain/graph/builder/stages/finalize.js @@ -7,8 +7,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { closeDb, getBuildMeta, setBuildMeta } from '../../../../db/index.js'; -import { writeJournalHeader } from '../../../../journal.js'; -import { debug, info, warn } from '../../../../logger.js'; +import { debug, info, warn } from '../../../../infrastructure/logger.js'; +import { writeJournalHeader } from '../../journal.js'; const __builderDir = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/i, '$1')); const CODEGRAPH_VERSION = JSON.parse( @@ -127,7 +127,7 @@ export async function finalize(ctx) { debug(`Skipping auto-registration for temp directory: ${resolvedRoot}`); } else { try { - const { registerRepo } = await import('../../../../registry.js'); + const { registerRepo } = await import('../../../../infrastructure/registry.js'); registerRepo(rootDir); } catch (err) { debug(`Auto-registration failed: ${err.message}`); diff --git a/src/domain/graph/builder/stages/parse-files.js b/src/domain/graph/builder/stages/parse-files.js index 0d3e1167..6690bb5f 100644 --- a/src/domain/graph/builder/stages/parse-files.js +++ b/src/domain/graph/builder/stages/parse-files.js @@ -5,8 +5,8 @@ * Populates ctx.allSymbols, ctx.fileSymbols, ctx.filesToParse. */ import { performance } from 'node:perf_hooks'; -import { info } from '../../../../logger.js'; -import { parseFilesAuto } from '../../../../parser.js'; +import { info } from '../../../../infrastructure/logger.js'; +import { parseFilesAuto } from '../../../parser.js'; /** * @param {import('../context.js').PipelineContext} ctx diff --git a/src/domain/graph/builder/stages/resolve-imports.js b/src/domain/graph/builder/stages/resolve-imports.js index d2a39861..7d9bbe40 100644 --- a/src/domain/graph/builder/stages/resolve-imports.js +++ b/src/domain/graph/builder/stages/resolve-imports.js @@ -6,7 +6,7 @@ */ import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { parseFilesAuto } from '../../../../parser.js'; +import { parseFilesAuto } from '../../../parser.js'; import { resolveImportPath, resolveImportsBatch } from '../../resolve.js'; /** diff --git a/src/domain/graph/builder/stages/run-analyses.js b/src/domain/graph/builder/stages/run-analyses.js index f6dbbdb9..53384613 100644 --- a/src/domain/graph/builder/stages/run-analyses.js +++ b/src/domain/graph/builder/stages/run-analyses.js @@ -4,7 +4,7 @@ * Dispatches to the unified AST analysis engine (AST nodes, complexity, CFG, dataflow). * Filters out reverse-dep files for incremental builds. */ -import { debug, warn } from '../../../../logger.js'; +import { debug, warn } from '../../../../infrastructure/logger.js'; /** * @param {import('../context.js').PipelineContext} ctx diff --git a/src/change-journal.js b/src/domain/graph/change-journal.js similarity index 98% rename from src/change-journal.js rename to src/domain/graph/change-journal.js index bbba73ec..7589b5a6 100644 --- a/src/change-journal.js +++ b/src/domain/graph/change-journal.js @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { debug, warn } from './logger.js'; +import { debug, warn } from '../../infrastructure/logger.js'; export const CHANGE_EVENTS_FILENAME = 'change-events.ndjson'; export const DEFAULT_MAX_BYTES = 1024 * 1024; // 1 MB diff --git a/src/domain/graph/cycles.js b/src/domain/graph/cycles.js index bed9fc03..c7872a61 100644 --- a/src/domain/graph/cycles.js +++ b/src/domain/graph/cycles.js @@ -1,7 +1,7 @@ import { tarjan } from '../../graph/algorithms/tarjan.js'; import { buildDependencyGraph } from '../../graph/builders/dependency.js'; import { CodeGraph } from '../../graph/model.js'; -import { loadNative } from '../../native.js'; +import { loadNative } from '../../infrastructure/native.js'; /** * Detect circular dependencies in the codebase using Tarjan's SCC algorithm. diff --git a/src/journal.js b/src/domain/graph/journal.js similarity index 97% rename from src/journal.js rename to src/domain/graph/journal.js index a072df50..714889f2 100644 --- a/src/journal.js +++ b/src/domain/graph/journal.js @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { debug, warn } from './logger.js'; +import { debug, warn } from '../../infrastructure/logger.js'; export const JOURNAL_FILENAME = 'changes.journal'; const HEADER_PREFIX = '# codegraph-journal v1 '; diff --git a/src/domain/graph/resolve.js b/src/domain/graph/resolve.js index 73179b67..5e0ab1d3 100644 --- a/src/domain/graph/resolve.js +++ b/src/domain/graph/resolve.js @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { normalizePath } from '../../constants.js'; -import { loadNative } from '../../native.js'; +import { loadNative } from '../../infrastructure/native.js'; +import { normalizePath } from '../../shared/constants.js'; // ── Alias format conversion ───────────────────────────────────────── diff --git a/src/domain/graph/watcher.js b/src/domain/graph/watcher.js index 58b56eeb..15b4b4a6 100644 --- a/src/domain/graph/watcher.js +++ b/src/domain/graph/watcher.js @@ -1,13 +1,13 @@ import fs from 'node:fs'; import path from 'node:path'; -import { appendChangeEvents, buildChangeEvent, diffSymbols } from '../../change-journal.js'; -import { EXTENSIONS, IGNORE_DIRS, normalizePath } from '../../constants.js'; import { closeDb, getNodeId as getNodeIdQuery, initSchema, openDb } from '../../db/index.js'; -import { DbError } from '../../errors.js'; -import { appendJournalEntries } from '../../journal.js'; -import { info } from '../../logger.js'; -import { createParseTreeCache, getActiveEngine } from '../../parser.js'; +import { info } from '../../infrastructure/logger.js'; +import { EXTENSIONS, IGNORE_DIRS, normalizePath } from '../../shared/constants.js'; +import { DbError } from '../../shared/errors.js'; +import { createParseTreeCache, getActiveEngine } from '../parser.js'; import { rebuildFile } from './builder/incremental.js'; +import { appendChangeEvents, buildChangeEvent, diffSymbols } from './change-journal.js'; +import { appendJournalEntries } from './journal.js'; function shouldIgnore(filePath) { const parts = filePath.split(path.sep); diff --git a/src/parser.js b/src/domain/parser.js similarity index 98% rename from src/parser.js rename to src/domain/parser.js index d7393bb3..fb41d473 100644 --- a/src/parser.js +++ b/src/domain/parser.js @@ -2,8 +2,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Language, Parser, Query } from 'web-tree-sitter'; -import { warn } from './logger.js'; -import { getNative, getNativePackageVersion, loadNative } from './native.js'; +import { warn } from '../infrastructure/logger.js'; +import { getNative, getNativePackageVersion, loadNative } from '../infrastructure/native.js'; // Re-export all extractors for backward compatibility export { @@ -16,7 +16,7 @@ export { extractRubySymbols, extractRustSymbols, extractSymbols, -} from './extractors/index.js'; +} from '../extractors/index.js'; import { extractCSharpSymbols, @@ -28,12 +28,12 @@ import { extractRubySymbols, extractRustSymbols, extractSymbols, -} from './extractors/index.js'; +} from '../extractors/index.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); function grammarPath(name) { - return path.join(__dirname, '..', 'grammars', name); + return path.join(__dirname, '..', '..', 'grammars', name); } let _initialized = false; diff --git a/src/domain/queries.js b/src/domain/queries.js index 4b853d79..57a7cac7 100644 --- a/src/domain/queries.js +++ b/src/domain/queries.js @@ -8,6 +8,7 @@ // ── Re-export from dedicated module for backward compat ─────────────────── export { isTestFile, TEST_PATTERN } from '../infrastructure/test-filter.js'; +export { iterListFunctions, iterRoles, iterWhere } from '../shared/generators.js'; // ── Kind/edge constants (canonical source: kinds.js) ───────────────────── export { ALL_SYMBOL_KINDS, @@ -18,8 +19,7 @@ export { EXTENDED_SYMBOL_KINDS, STRUCTURAL_EDGE_KINDS, VALID_ROLES, -} from '../kinds.js'; -export { iterListFunctions, iterRoles, iterWhere } from '../shared/generators.js'; +} from '../shared/kinds.js'; // ── Shared utilities ───────────────────────────────────────────────────── export { kindIcon, normalizeSymbol } from '../shared/normalize.js'; export { contextData, explainData } from './analysis/context.js'; diff --git a/src/domain/search/generator.js b/src/domain/search/generator.js index 6936dd84..dcdfd36c 100644 --- a/src/domain/search/generator.js +++ b/src/domain/search/generator.js @@ -1,8 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { closeDb, findDbPath, openDb } from '../../db/index.js'; -import { DbError } from '../../errors.js'; -import { warn } from '../../logger.js'; +import { warn } from '../../infrastructure/logger.js'; +import { DbError } from '../../shared/errors.js'; import { embed, getModelConfig } from './models.js'; import { buildSourceText } from './strategies/source.js'; import { buildStructuredText } from './strategies/structured.js'; diff --git a/src/domain/search/models.js b/src/domain/search/models.js index 355dd381..404952cc 100644 --- a/src/domain/search/models.js +++ b/src/domain/search/models.js @@ -1,7 +1,7 @@ import { execFileSync } from 'node:child_process'; import { createInterface } from 'node:readline'; -import { ConfigError, EngineError } from '../../errors.js'; -import { info } from '../../logger.js'; +import { info } from '../../infrastructure/logger.js'; +import { ConfigError, EngineError } from '../../shared/errors.js'; // Lazy-load transformers (heavy, optional module) let pipeline = null; diff --git a/src/domain/search/search/cli-formatter.js b/src/domain/search/search/cli-formatter.js index 013333af..a0b45a80 100644 --- a/src/domain/search/search/cli-formatter.js +++ b/src/domain/search/search/cli-formatter.js @@ -1,4 +1,4 @@ -import { warn } from '../../../logger.js'; +import { warn } from '../../../infrastructure/logger.js'; import { hybridSearchData } from './hybrid.js'; import { ftsSearchData } from './keyword.js'; import { multiSearchData, searchData } from './semantic.js'; diff --git a/src/domain/search/search/semantic.js b/src/domain/search/search/semantic.js index aa624ab6..dc7b301a 100644 --- a/src/domain/search/search/semantic.js +++ b/src/domain/search/search/semantic.js @@ -1,4 +1,4 @@ -import { warn } from '../../../logger.js'; +import { warn } from '../../../infrastructure/logger.js'; import { normalizeSymbol } from '../../queries.js'; import { embed } from '../models.js'; import { cosineSim } from '../stores/sqlite-blob.js'; diff --git a/src/extractors/javascript.js b/src/extractors/javascript.js index 06f9468b..a2d9e7b1 100644 --- a/src/extractors/javascript.js +++ b/src/extractors/javascript.js @@ -1,4 +1,4 @@ -import { debug } from '../logger.js'; +import { debug } from '../infrastructure/logger.js'; import { findChild, nodeEndLine } from './helpers.js'; /** diff --git a/src/features/ast.js b/src/features/ast.js index 6935efe7..6bc3a371 100644 --- a/src/features/ast.js +++ b/src/features/ast.js @@ -12,9 +12,9 @@ import { buildExtensionSet } from '../ast-analysis/shared.js'; import { walkWithVisitors } from '../ast-analysis/visitor.js'; import { createAstStoreVisitor } from '../ast-analysis/visitors/ast-store-visitor.js'; import { bulkNodeIdsByFile, openReadonlyOrFail } from '../db/index.js'; +import { debug } from '../infrastructure/logger.js'; import { outputResult } from '../infrastructure/result-formatter.js'; -import { debug } from '../logger.js'; -import { paginateResult } from '../paginate.js'; +import { paginateResult } from '../shared/paginate.js'; // ─── Constants ──────────────────────────────────────────────────────── diff --git a/src/features/audit.js b/src/features/audit.js index c267b04a..ef71ca93 100644 --- a/src/features/audit.js +++ b/src/features/audit.js @@ -7,9 +7,9 @@ */ import path from 'node:path'; -import { loadConfig } from '../config.js'; import { openReadonlyOrFail } from '../db/index.js'; import { explainData } from '../domain/queries.js'; +import { loadConfig } from '../infrastructure/config.js'; import { isTestFile } from '../infrastructure/test-filter.js'; import { RULE_DEFS } from './manifesto.js'; diff --git a/src/features/batch.js b/src/features/batch.js index 4d1225bd..f5f386a7 100644 --- a/src/features/batch.js +++ b/src/features/batch.js @@ -15,7 +15,7 @@ import { impactAnalysisData, whereData, } from '../domain/queries.js'; -import { ConfigError } from '../errors.js'; +import { ConfigError } from '../shared/errors.js'; import { complexityData } from './complexity.js'; import { dataflowData } from './dataflow.js'; import { flowData } from './flow.js'; diff --git a/src/features/boundaries.js b/src/features/boundaries.js index 8da92b92..7a357ebd 100644 --- a/src/features/boundaries.js +++ b/src/features/boundaries.js @@ -1,5 +1,5 @@ +import { debug } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { debug } from '../logger.js'; // ─── Glob-to-Regex ─────────────────────────────────────────────────── diff --git a/src/features/cfg.js b/src/features/cfg.js index 6f1dcdfc..e8728cab 100644 --- a/src/features/cfg.js +++ b/src/features/cfg.js @@ -23,9 +23,9 @@ import { hasCfgTables, openReadonlyOrFail, } from '../db/index.js'; +import { info } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { info } from '../logger.js'; -import { paginateResult } from '../paginate.js'; +import { paginateResult } from '../shared/paginate.js'; // Re-export for backward compatibility export { _makeCfgRules as makeCfgRules, CFG_RULES }; @@ -104,13 +104,13 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) { } if (needsFallback) { - const { createParsers } = await import('./parser.js'); + const { createParsers } = await import('../domain/parser.js'); parsers = await createParsers(); } let getParserFn = null; if (parsers) { - const mod = await import('./parser.js'); + const mod = await import('../domain/parser.js'); getParserFn = mod.getParser; } diff --git a/src/features/check.js b/src/features/check.js index b8e3c75f..f3de3b78 100644 --- a/src/features/check.js +++ b/src/features/check.js @@ -1,9 +1,9 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import { loadConfig } from '../config.js'; import { findDbPath, openReadonlyOrFail } from '../db/index.js'; import { findCycles } from '../domain/graph/cycles.js'; +import { loadConfig } from '../infrastructure/config.js'; import { isTestFile } from '../infrastructure/test-filter.js'; import { matchOwners, parseCodeowners } from './owners.js'; diff --git a/src/features/cochange.js b/src/features/cochange.js index 134519ed..4c531dfe 100644 --- a/src/features/cochange.js +++ b/src/features/cochange.js @@ -8,11 +8,11 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import { normalizePath } from '../constants.js'; import { closeDb, findDbPath, initSchema, openDb, openReadonlyOrFail } from '../db/index.js'; +import { warn } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { warn } from '../logger.js'; -import { paginateResult } from '../paginate.js'; +import { normalizePath } from '../shared/constants.js'; +import { paginateResult } from '../shared/paginate.js'; /** * Scan git history and return parsed commit data. diff --git a/src/features/communities.js b/src/features/communities.js index 51970558..cf46fa39 100644 --- a/src/features/communities.js +++ b/src/features/communities.js @@ -2,7 +2,7 @@ import path from 'node:path'; import { openReadonlyOrFail } from '../db/index.js'; import { louvainCommunities } from '../graph/algorithms/louvain.js'; import { buildDependencyGraph } from '../graph/builders/dependency.js'; -import { paginateResult } from '../paginate.js'; +import { paginateResult } from '../shared/paginate.js'; // ─── Directory Helpers ──────────────────────────────────────────────── diff --git a/src/features/complexity.js b/src/features/complexity.js index b319c945..4e4cf35d 100644 --- a/src/features/complexity.js +++ b/src/features/complexity.js @@ -12,11 +12,11 @@ import { } from '../ast-analysis/shared.js'; import { walkWithVisitors } from '../ast-analysis/visitor.js'; import { createComplexityVisitor } from '../ast-analysis/visitors/complexity-visitor.js'; -import { loadConfig } from '../config.js'; import { getFunctionNodeId, openReadonlyOrFail } from '../db/index.js'; +import { loadConfig } from '../infrastructure/config.js'; +import { info } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { info } from '../logger.js'; -import { paginateResult } from '../paginate.js'; +import { paginateResult } from '../shared/paginate.js'; // Re-export rules for backward compatibility export { COMPLEXITY_RULES, HALSTEAD_RULES }; @@ -360,12 +360,12 @@ export async function buildComplexityMetrics(db, fileSymbols, rootDir, _engineOp } } if (needsFallback) { - const { createParsers } = await import('./parser.js'); + const { createParsers } = await import('../domain/parser.js'); parsers = await createParsers(); extToLang = buildExtToLangMap(); } - const { getParser } = await import('./parser.js'); + const { getParser } = await import('../domain/parser.js'); const upsert = db.prepare( `INSERT OR REPLACE INTO function_complexity diff --git a/src/features/dataflow.js b/src/features/dataflow.js index dbff4cda..9d0c8bcc 100644 --- a/src/features/dataflow.js +++ b/src/features/dataflow.js @@ -21,9 +21,9 @@ import { walkWithVisitors } from '../ast-analysis/visitor.js'; import { createDataflowVisitor } from '../ast-analysis/visitors/dataflow-visitor.js'; import { hasDataflowTable, openReadonlyOrFail } from '../db/index.js'; import { ALL_SYMBOL_KINDS, normalizeSymbol } from '../domain/queries.js'; +import { info } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { info } from '../logger.js'; -import { paginateResult } from '../paginate.js'; +import { paginateResult } from '../shared/paginate.js'; // Re-export for backward compatibility export { _makeDataflowRules as makeDataflowRules, DATAFLOW_RULES }; @@ -88,13 +88,13 @@ export async function buildDataflowEdges(db, fileSymbols, rootDir, _engineOpts) } if (needsFallback) { - const { createParsers } = await import('./parser.js'); + const { createParsers } = await import('../domain/parser.js'); parsers = await createParsers(); } let getParserFn = null; if (parsers) { - const mod = await import('./parser.js'); + const mod = await import('../domain/parser.js'); getParserFn = mod.getParser; } diff --git a/src/features/export.js b/src/features/export.js index 61ed15ba..6f93faae 100644 --- a/src/features/export.js +++ b/src/features/export.js @@ -1,6 +1,5 @@ import path from 'node:path'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { paginateResult } from '../paginate.js'; import { renderFileLevelDOT, renderFileLevelGraphML, @@ -11,6 +10,7 @@ import { renderFunctionLevelMermaid, renderFunctionLevelNeo4jCSV, } from '../presentation/export.js'; +import { paginateResult } from '../shared/paginate.js'; const DEFAULT_MIN_CONFIDENCE = 0.5; diff --git a/src/features/flow.js b/src/features/flow.js index 8f42af3a..e91e00b8 100644 --- a/src/features/flow.js +++ b/src/features/flow.js @@ -8,7 +8,7 @@ import { openReadonlyOrFail } from '../db/index.js'; import { CORE_SYMBOL_KINDS, findMatchingNodes } from '../domain/queries.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { paginateResult } from '../paginate.js'; +import { paginateResult } from '../shared/paginate.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; /** diff --git a/src/features/manifesto.js b/src/features/manifesto.js index c77ae21d..3113122a 100644 --- a/src/features/manifesto.js +++ b/src/features/manifesto.js @@ -1,8 +1,8 @@ -import { loadConfig } from '../config.js'; import { openReadonlyOrFail } from '../db/index.js'; import { findCycles } from '../domain/graph/cycles.js'; -import { debug } from '../logger.js'; -import { paginateResult } from '../paginate.js'; +import { loadConfig } from '../infrastructure/config.js'; +import { debug } from '../infrastructure/logger.js'; +import { paginateResult } from '../shared/paginate.js'; import { evaluateBoundaries } from './boundaries.js'; // ─── Rule Definitions ───────────────────────────────────────────────── diff --git a/src/features/sequence.js b/src/features/sequence.js index 78a3d68a..0edeba87 100644 --- a/src/features/sequence.js +++ b/src/features/sequence.js @@ -9,7 +9,7 @@ import { findCallees, openReadonlyOrFail } from '../db/index.js'; import { findMatchingNodes } from '../domain/queries.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { paginateResult } from '../paginate.js'; +import { paginateResult } from '../shared/paginate.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; // ─── Alias generation ──────────────────────────────────────────────── diff --git a/src/features/snapshot.js b/src/features/snapshot.js index 007549aa..71baf8d2 100644 --- a/src/features/snapshot.js +++ b/src/features/snapshot.js @@ -2,8 +2,8 @@ import fs from 'node:fs'; import path from 'node:path'; import Database from 'better-sqlite3'; import { findDbPath } from '../db/index.js'; -import { ConfigError, DbError } from '../errors.js'; -import { debug } from '../logger.js'; +import { debug } from '../infrastructure/logger.js'; +import { ConfigError, DbError } from '../shared/errors.js'; const NAME_RE = /^[a-zA-Z0-9_-]+$/; diff --git a/src/features/structure.js b/src/features/structure.js index 7fcd5628..4ba9ee0a 100644 --- a/src/features/structure.js +++ b/src/features/structure.js @@ -1,9 +1,9 @@ import path from 'node:path'; -import { normalizePath } from '../constants.js'; import { getNodeId, openReadonlyOrFail, testFilterSQL } from '../db/index.js'; +import { debug } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { debug } from '../logger.js'; -import { paginateResult } from '../paginate.js'; +import { normalizePath } from '../shared/constants.js'; +import { paginateResult } from '../shared/paginate.js'; // ─── Build-time: insert directory nodes, contains edges, and metrics ──── diff --git a/src/features/triage.js b/src/features/triage.js index 5cd9d7a1..32257f3f 100644 --- a/src/features/triage.js +++ b/src/features/triage.js @@ -1,8 +1,8 @@ import { findNodesForTriage, openReadonlyOrFail } from '../db/index.js'; import { DEFAULT_WEIGHTS, scoreRisk } from '../graph/classifiers/risk.js'; +import { warn } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { warn } from '../logger.js'; -import { paginateResult } from '../paginate.js'; +import { paginateResult } from '../shared/paginate.js'; // ─── Data Function ──────────────────────────────────────────────────── diff --git a/src/index.js b/src/index.js index a50e9017..448cbed0 100644 --- a/src/index.js +++ b/src/index.js @@ -9,8 +9,6 @@ * import { buildGraph, queryNameData, findCycles, exportDOT } from '@optave/codegraph'; */ -export { loadConfig } from './config.js'; -export { EXTENSIONS, IGNORE_DIRS } from './constants.js'; export { buildGraph } from './domain/graph/builder.js'; export { findCycles } from './domain/graph/cycles.js'; export { @@ -36,16 +34,6 @@ export { multiSearchData, searchData, } from './domain/search/index.js'; -export { - AnalysisError, - BoundaryError, - CodegraphError, - ConfigError, - DbError, - EngineError, - ParseError, - ResolutionError, -} from './errors.js'; export { astQueryData } from './features/ast.js'; export { auditData } from './features/audit.js'; export { batchData } from './features/batch.js'; @@ -63,4 +51,16 @@ export { ownersData } from './features/owners.js'; export { sequenceData } from './features/sequence.js'; export { hotspotsData, moduleBoundariesData, structureData } from './features/structure.js'; export { triageData } from './features/triage.js'; -export { EVERY_EDGE_KIND, EVERY_SYMBOL_KIND } from './kinds.js'; +export { loadConfig } from './infrastructure/config.js'; +export { EXTENSIONS, IGNORE_DIRS } from './shared/constants.js'; +export { + AnalysisError, + BoundaryError, + CodegraphError, + ConfigError, + DbError, + EngineError, + ParseError, + ResolutionError, +} from './shared/errors.js'; +export { EVERY_EDGE_KIND, EVERY_SYMBOL_KIND } from './shared/kinds.js'; diff --git a/src/config.js b/src/infrastructure/config.js similarity index 100% rename from src/config.js rename to src/infrastructure/config.js diff --git a/src/logger.js b/src/infrastructure/logger.js similarity index 100% rename from src/logger.js rename to src/infrastructure/logger.js diff --git a/src/native.js b/src/infrastructure/native.js similarity index 98% rename from src/native.js rename to src/infrastructure/native.js index 7de86d9a..a1481621 100644 --- a/src/native.js +++ b/src/infrastructure/native.js @@ -8,7 +8,7 @@ import { createRequire } from 'node:module'; import os from 'node:os'; -import { EngineError } from './errors.js'; +import { EngineError } from '../shared/errors.js'; let _cached; // undefined = not yet tried, null = failed, object = module let _loadError = null; diff --git a/src/registry.js b/src/infrastructure/registry.js similarity index 100% rename from src/registry.js rename to src/infrastructure/registry.js diff --git a/src/update-check.js b/src/infrastructure/update-check.js similarity index 100% rename from src/update-check.js rename to src/infrastructure/update-check.js diff --git a/src/mcp/middleware.js b/src/mcp/middleware.js index 7f261cad..96dc26af 100644 --- a/src/mcp/middleware.js +++ b/src/mcp/middleware.js @@ -2,7 +2,7 @@ * MCP middleware helpers — pagination defaults and limits. */ -import { MCP_DEFAULTS, MCP_MAX_LIMIT } from '../paginate.js'; +import { MCP_DEFAULTS, MCP_MAX_LIMIT } from '../shared/paginate.js'; export { MCP_DEFAULTS, MCP_MAX_LIMIT }; diff --git a/src/mcp/server.js b/src/mcp/server.js index b5c1ea92..464fafaf 100644 --- a/src/mcp/server.js +++ b/src/mcp/server.js @@ -7,8 +7,8 @@ import { createRequire } from 'node:module'; import { findDbPath } from '../db/index.js'; -import { CodegraphError, ConfigError } from '../errors.js'; -import { MCP_MAX_LIMIT } from '../paginate.js'; +import { CodegraphError, ConfigError } from '../shared/errors.js'; +import { MCP_MAX_LIMIT } from '../shared/paginate.js'; import { buildToolList } from './tool-registry.js'; import { TOOL_HANDLERS } from './tools/index.js'; @@ -89,7 +89,7 @@ export async function startMCPServer(customDbPath, options = {}) { if (allowedRepos && !allowedRepos.includes(args.repo)) { throw new ConfigError(`Repository "${args.repo}" is not in the allowed repos list.`); } - const { resolveRepoDbPath } = await import('../registry.js'); + const { resolveRepoDbPath } = await import('../infrastructure/registry.js'); const resolved = resolveRepoDbPath(args.repo); if (!resolved) throw new ConfigError( diff --git a/src/mcp/tools/list-repos.js b/src/mcp/tools/list-repos.js index 743fa959..1cd18d3d 100644 --- a/src/mcp/tools/list-repos.js +++ b/src/mcp/tools/list-repos.js @@ -1,7 +1,7 @@ export const name = 'list_repos'; export async function handler(_args, ctx) { - const { listRepos, pruneRegistry } = await import('../../registry.js'); + const { listRepos, pruneRegistry } = await import('../../infrastructure/registry.js'); pruneRegistry(); let repos = listRepos(); if (ctx.allowedRepos) { diff --git a/src/presentation/result-formatter.js b/src/presentation/result-formatter.js index 98aa8ea1..389df681 100644 --- a/src/presentation/result-formatter.js +++ b/src/presentation/result-formatter.js @@ -1,4 +1,4 @@ -import { printNdjson } from '../paginate.js'; +import { printNdjson } from '../shared/paginate.js'; /** * Shared JSON / NDJSON output dispatch for CLI wrappers. diff --git a/src/constants.js b/src/shared/constants.js similarity index 92% rename from src/constants.js rename to src/shared/constants.js index 2bcbb1af..db06b4c5 100644 --- a/src/constants.js +++ b/src/shared/constants.js @@ -1,5 +1,5 @@ import path from 'node:path'; -import { SUPPORTED_EXTENSIONS } from './parser.js'; +import { SUPPORTED_EXTENSIONS } from '../domain/parser.js'; export const IGNORE_DIRS = new Set([ 'node_modules', diff --git a/src/errors.js b/src/shared/errors.js similarity index 100% rename from src/errors.js rename to src/shared/errors.js diff --git a/src/shared/file-utils.js b/src/shared/file-utils.js index bd52719d..814f54de 100644 --- a/src/shared/file-utils.js +++ b/src/shared/file-utils.js @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { debug } from '../logger.js'; -import { LANGUAGE_REGISTRY } from '../parser.js'; +import { LANGUAGE_REGISTRY } from '../domain/parser.js'; +import { debug } from '../infrastructure/logger.js'; /** * Resolve a file path relative to repoRoot, rejecting traversal outside the repo. diff --git a/src/shared/generators.js b/src/shared/generators.js index 93753dbd..3d121f81 100644 --- a/src/shared/generators.js +++ b/src/shared/generators.js @@ -1,6 +1,6 @@ import { iterateFunctionNodes, openReadonlyOrFail } from '../db/index.js'; import { isTestFile } from '../infrastructure/test-filter.js'; -import { ALL_SYMBOL_KINDS } from '../kinds.js'; +import { ALL_SYMBOL_KINDS } from './kinds.js'; /** * Generator: stream functions one-by-one using .iterate() for memory efficiency. diff --git a/src/kinds.js b/src/shared/kinds.js similarity index 100% rename from src/kinds.js rename to src/shared/kinds.js diff --git a/src/paginate.js b/src/shared/paginate.js similarity index 100% rename from src/paginate.js rename to src/shared/paginate.js diff --git a/tests/builder/detect-changes.test.js b/tests/builder/detect-changes.test.js index f6a671e3..2555b097 100644 --- a/tests/builder/detect-changes.test.js +++ b/tests/builder/detect-changes.test.js @@ -8,7 +8,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { closeDb, initSchema, openDb } from '../../src/db/index.js'; import { PipelineContext } from '../../src/domain/graph/builder/context.js'; import { detectChanges } from '../../src/domain/graph/builder/stages/detect-changes.js'; -import { writeJournalHeader } from '../../src/journal.js'; +import { writeJournalHeader } from '../../src/domain/graph/journal.js'; let tmpDir; diff --git a/tests/engines/dataflow-parity.test.js b/tests/engines/dataflow-parity.test.js index 1559243a..0ed2d996 100644 --- a/tests/engines/dataflow-parity.test.js +++ b/tests/engines/dataflow-parity.test.js @@ -12,9 +12,9 @@ */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers, getParser } from '../../src/domain/parser.js'; import { extractDataflow } from '../../src/features/dataflow.js'; -import { isNativeAvailable } from '../../src/native.js'; -import { createParsers, getParser } from '../../src/parser.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; let native; let parsers; @@ -136,7 +136,7 @@ const describeOrSkip = hasNative ? describe : describe.skip; describeOrSkip('Cross-engine dataflow parity', () => { beforeAll(async () => { if (!hasNative) return; - const { getNative } = await import('../../src/native.js'); + const { getNative } = await import('../../src/infrastructure/native.js'); native = getNative(); nativeHasDataflow = detectNativeDataflow(); parsers = await createParsers(); diff --git a/tests/engines/parity.test.js b/tests/engines/parity.test.js index 184d2e06..fc11c2e1 100644 --- a/tests/engines/parity.test.js +++ b/tests/engines/parity.test.js @@ -8,7 +8,6 @@ */ import { beforeAll, describe, expect, it } from 'vitest'; -import { isNativeAvailable } from '../../src/native.js'; import { createParsers, extractCSharpSymbols, @@ -21,7 +20,8 @@ import { extractRustSymbols, extractSymbols, getParser, -} from '../../src/parser.js'; +} from '../../src/domain/parser.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; let native; let parsers; @@ -110,7 +110,7 @@ const describeOrSkip = hasNative ? describe : describe.skip; describeOrSkip('Cross-engine parity', () => { beforeAll(async () => { if (!hasNative) return; - const { getNative } = await import('../../src/native.js'); + const { getNative } = await import('../../src/infrastructure/native.js'); native = getNative(); parsers = await createParsers(); }); diff --git a/tests/engines/query-walk-parity.test.js b/tests/engines/query-walk-parity.test.js index 2556af08..05335c5c 100644 --- a/tests/engines/query-walk-parity.test.js +++ b/tests/engines/query-walk-parity.test.js @@ -9,8 +9,8 @@ */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers, getParser, parseFileAuto } from '../../src/domain/parser.js'; import { extractSymbols } from '../../src/extractors/javascript.js'; -import { createParsers, getParser, parseFileAuto } from '../../src/parser.js'; let parsers; diff --git a/tests/graph/cycles.test.js b/tests/graph/cycles.test.js index 8f6946e3..2dd8a2ea 100644 --- a/tests/graph/cycles.test.js +++ b/tests/graph/cycles.test.js @@ -6,7 +6,7 @@ import Database from 'better-sqlite3'; import { describe, expect, it } from 'vitest'; import { initSchema } from '../../src/db/index.js'; import { findCycles, findCyclesJS } from '../../src/domain/graph/cycles.js'; -import { isNativeAvailable, loadNative } from '../../src/native.js'; +import { isNativeAvailable, loadNative } from '../../src/infrastructure/native.js'; const hasNative = isNativeAvailable(); diff --git a/tests/incremental/cache.test.js b/tests/incremental/cache.test.js index f930ce64..46ee6290 100644 --- a/tests/incremental/cache.test.js +++ b/tests/incremental/cache.test.js @@ -5,7 +5,7 @@ */ import { beforeEach, describe, expect, it } from 'vitest'; -import { isNativeAvailable, loadNative } from '../../src/native.js'; +import { isNativeAvailable, loadNative } from '../../src/infrastructure/native.js'; const hasNative = isNativeAvailable(); diff --git a/tests/incremental/watcher-incremental.test.js b/tests/incremental/watcher-incremental.test.js index d2619894..8c273203 100644 --- a/tests/incremental/watcher-incremental.test.js +++ b/tests/incremental/watcher-incremental.test.js @@ -11,8 +11,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { isNativeAvailable } from '../../src/native.js'; -import { createParseTreeCache, parseFileIncremental } from '../../src/parser.js'; +import { createParseTreeCache, parseFileIncremental } from '../../src/domain/parser.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; const hasNative = isNativeAvailable(); diff --git a/tests/integration/build-parity.test.js b/tests/integration/build-parity.test.js index 18c710e2..86ef5043 100644 --- a/tests/integration/build-parity.test.js +++ b/tests/integration/build-parity.test.js @@ -13,7 +13,7 @@ import path from 'node:path'; import Database from 'better-sqlite3'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { buildGraph } from '../../src/domain/graph/builder.js'; -import { isNativeAvailable } from '../../src/native.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; const FIXTURE_DIR = path.join(import.meta.dirname, '..', 'fixtures', 'sample-project'); diff --git a/tests/integration/build.test.js b/tests/integration/build.test.js index 8af7600d..d7bee6bc 100644 --- a/tests/integration/build.test.js +++ b/tests/integration/build.test.js @@ -10,7 +10,7 @@ import Database from 'better-sqlite3'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { closeDb, openDb, setBuildMeta } from '../../src/db/index.js'; import { buildGraph } from '../../src/domain/graph/builder.js'; -import { JOURNAL_FILENAME, writeJournalHeader } from '../../src/journal.js'; +import { JOURNAL_FILENAME, writeJournalHeader } from '../../src/domain/graph/journal.js'; // ES-module versions of the sample-project fixture so the parser // generates import edges (the originals use CommonJS require()). diff --git a/tests/integration/complexity.test.js b/tests/integration/complexity.test.js index 52454bd9..8d7ea175 100644 --- a/tests/integration/complexity.test.js +++ b/tests/integration/complexity.test.js @@ -10,11 +10,11 @@ import os from 'node:os'; import path from 'node:path'; import Database from 'better-sqlite3'; import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'; -import { loadConfig } from '../../src/config.js'; import { initSchema } from '../../src/db/index.js'; import { complexityData } from '../../src/features/complexity.js'; +import { loadConfig } from '../../src/infrastructure/config.js'; -vi.mock('../../src/config.js', () => ({ +vi.mock('../../src/infrastructure/config.js', () => ({ loadConfig: vi.fn(() => ({})), })); diff --git a/tests/integration/pagination.test.js b/tests/integration/pagination.test.js index e57f9c19..ecf31c5d 100644 --- a/tests/integration/pagination.test.js +++ b/tests/integration/pagination.test.js @@ -41,7 +41,7 @@ import { paginate, paginateResult, printNdjson, -} from '../../src/paginate.js'; +} from '../../src/shared/paginate.js'; // ─── Helpers ─────────────────────────────────────────────────────────── diff --git a/tests/parsers/ast-all-langs.test.js b/tests/parsers/ast-all-langs.test.js index 615c9304..b4130a18 100644 --- a/tests/parsers/ast-all-langs.test.js +++ b/tests/parsers/ast-all-langs.test.js @@ -13,9 +13,9 @@ import path from 'node:path'; import Database from 'better-sqlite3'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { initSchema } from '../../src/db/index.js'; +import { parseFilesAuto } from '../../src/domain/parser.js'; import { buildAstNodes } from '../../src/features/ast.js'; -import { loadNative } from '../../src/native.js'; -import { parseFilesAuto } from '../../src/parser.js'; +import { loadNative } from '../../src/infrastructure/native.js'; // ─── Helpers ────────────────────────────────────────────────────────── diff --git a/tests/parsers/ast-nodes.test.js b/tests/parsers/ast-nodes.test.js index 894d9d1a..3d3c3de7 100644 --- a/tests/parsers/ast-nodes.test.js +++ b/tests/parsers/ast-nodes.test.js @@ -11,9 +11,9 @@ import path from 'node:path'; import Database from 'better-sqlite3'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { initSchema } from '../../src/db/index.js'; +import { parseFilesAuto } from '../../src/domain/parser.js'; import { buildAstNodes } from '../../src/features/ast.js'; -import { loadNative } from '../../src/native.js'; -import { parseFilesAuto } from '../../src/parser.js'; +import { loadNative } from '../../src/infrastructure/native.js'; // ─── Fixture ────────────────────────────────────────────────────────── diff --git a/tests/parsers/cfg-all-langs.test.js b/tests/parsers/cfg-all-langs.test.js index f19ab8ec..f37c9a95 100644 --- a/tests/parsers/cfg-all-langs.test.js +++ b/tests/parsers/cfg-all-langs.test.js @@ -14,10 +14,10 @@ import path from 'node:path'; import Database from 'better-sqlite3'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { initSchema } from '../../src/db/index.js'; +import { createParsers, getParser, parseFilesAuto } from '../../src/domain/parser.js'; import { buildCFGData, buildFunctionCFG } from '../../src/features/cfg.js'; import { COMPLEXITY_RULES, findFunctionNode } from '../../src/features/complexity.js'; -import { loadNative } from '../../src/native.js'; -import { createParsers, getParser, parseFilesAuto } from '../../src/parser.js'; +import { loadNative } from '../../src/infrastructure/native.js'; // ─── Helpers ────────────────────────────────────────────────────────── diff --git a/tests/parsers/csharp.test.js b/tests/parsers/csharp.test.js index e8031262..01d3725a 100644 --- a/tests/parsers/csharp.test.js +++ b/tests/parsers/csharp.test.js @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from 'vitest'; -import { createParsers, extractCSharpSymbols } from '../../src/parser.js'; +import { createParsers, extractCSharpSymbols } from '../../src/domain/parser.js'; describe('C# parser', () => { let parsers; diff --git a/tests/parsers/dataflow-csharp.test.js b/tests/parsers/dataflow-csharp.test.js index 6744d1c9..30f91a2d 100644 --- a/tests/parsers/dataflow-csharp.test.js +++ b/tests/parsers/dataflow-csharp.test.js @@ -2,8 +2,8 @@ * Unit tests for extractDataflow() against parsed C# ASTs. */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers } from '../../src/domain/parser.js'; import { extractDataflow } from '../../src/features/dataflow.js'; -import { createParsers } from '../../src/parser.js'; describe('extractDataflow — C#', () => { let parsers; diff --git a/tests/parsers/dataflow-go.test.js b/tests/parsers/dataflow-go.test.js index 674b8b3c..2f73d294 100644 --- a/tests/parsers/dataflow-go.test.js +++ b/tests/parsers/dataflow-go.test.js @@ -2,8 +2,8 @@ * Unit tests for extractDataflow() against parsed Go ASTs. */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers } from '../../src/domain/parser.js'; import { extractDataflow } from '../../src/features/dataflow.js'; -import { createParsers } from '../../src/parser.js'; describe('extractDataflow — Go', () => { let parsers; diff --git a/tests/parsers/dataflow-java.test.js b/tests/parsers/dataflow-java.test.js index 24175ea0..44481a4e 100644 --- a/tests/parsers/dataflow-java.test.js +++ b/tests/parsers/dataflow-java.test.js @@ -2,8 +2,8 @@ * Unit tests for extractDataflow() against parsed Java ASTs. */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers } from '../../src/domain/parser.js'; import { extractDataflow } from '../../src/features/dataflow.js'; -import { createParsers } from '../../src/parser.js'; describe('extractDataflow — Java', () => { let parsers; diff --git a/tests/parsers/dataflow-javascript.test.js b/tests/parsers/dataflow-javascript.test.js index df9d1dfb..bcbdf0c9 100644 --- a/tests/parsers/dataflow-javascript.test.js +++ b/tests/parsers/dataflow-javascript.test.js @@ -2,8 +2,8 @@ * Unit tests for extractDataflow() against parsed JS/TS ASTs. */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers } from '../../src/domain/parser.js'; import { extractDataflow } from '../../src/features/dataflow.js'; -import { createParsers } from '../../src/parser.js'; describe('extractDataflow — JavaScript', () => { let parsers; diff --git a/tests/parsers/dataflow-php.test.js b/tests/parsers/dataflow-php.test.js index ae337942..ef5ddcc9 100644 --- a/tests/parsers/dataflow-php.test.js +++ b/tests/parsers/dataflow-php.test.js @@ -2,8 +2,8 @@ * Unit tests for extractDataflow() against parsed PHP ASTs. */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers } from '../../src/domain/parser.js'; import { extractDataflow } from '../../src/features/dataflow.js'; -import { createParsers } from '../../src/parser.js'; describe('extractDataflow — PHP', () => { let parsers; diff --git a/tests/parsers/dataflow-python.test.js b/tests/parsers/dataflow-python.test.js index 4a83874e..e97aeea9 100644 --- a/tests/parsers/dataflow-python.test.js +++ b/tests/parsers/dataflow-python.test.js @@ -2,8 +2,8 @@ * Unit tests for extractDataflow() against parsed Python ASTs. */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers } from '../../src/domain/parser.js'; import { extractDataflow } from '../../src/features/dataflow.js'; -import { createParsers } from '../../src/parser.js'; describe('extractDataflow — Python', () => { let parsers; diff --git a/tests/parsers/dataflow-ruby.test.js b/tests/parsers/dataflow-ruby.test.js index 69aa67db..973c6bee 100644 --- a/tests/parsers/dataflow-ruby.test.js +++ b/tests/parsers/dataflow-ruby.test.js @@ -2,8 +2,8 @@ * Unit tests for extractDataflow() against parsed Ruby ASTs. */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers } from '../../src/domain/parser.js'; import { extractDataflow } from '../../src/features/dataflow.js'; -import { createParsers } from '../../src/parser.js'; describe('extractDataflow — Ruby', () => { let parsers; diff --git a/tests/parsers/dataflow-rust.test.js b/tests/parsers/dataflow-rust.test.js index 5d771740..f7200487 100644 --- a/tests/parsers/dataflow-rust.test.js +++ b/tests/parsers/dataflow-rust.test.js @@ -2,8 +2,8 @@ * Unit tests for extractDataflow() against parsed Rust ASTs. */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers } from '../../src/domain/parser.js'; import { extractDataflow } from '../../src/features/dataflow.js'; -import { createParsers } from '../../src/parser.js'; describe('extractDataflow — Rust', () => { let parsers; diff --git a/tests/parsers/extended-kinds.test.js b/tests/parsers/extended-kinds.test.js index 266ac44a..ab1a8ccf 100644 --- a/tests/parsers/extended-kinds.test.js +++ b/tests/parsers/extended-kinds.test.js @@ -15,7 +15,7 @@ import { extractRubySymbols, extractRustSymbols, extractSymbols, -} from '../../src/parser.js'; +} from '../../src/domain/parser.js'; // ── JavaScript ────────────────────────────────────────────────────────────── diff --git a/tests/parsers/go.test.js b/tests/parsers/go.test.js index 6d6c23a0..e8c29581 100644 --- a/tests/parsers/go.test.js +++ b/tests/parsers/go.test.js @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from 'vitest'; -import { createParsers, extractGoSymbols } from '../../src/parser.js'; +import { createParsers, extractGoSymbols } from '../../src/domain/parser.js'; describe('Go parser', () => { let parsers; diff --git a/tests/parsers/java.test.js b/tests/parsers/java.test.js index cc458dbd..79486a04 100644 --- a/tests/parsers/java.test.js +++ b/tests/parsers/java.test.js @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from 'vitest'; -import { createParsers, extractJavaSymbols } from '../../src/parser.js'; +import { createParsers, extractJavaSymbols } from '../../src/domain/parser.js'; describe('Java parser', () => { let parsers; diff --git a/tests/parsers/javascript.test.js b/tests/parsers/javascript.test.js index 539929c0..63875fc8 100644 --- a/tests/parsers/javascript.test.js +++ b/tests/parsers/javascript.test.js @@ -6,7 +6,7 @@ * Then: npm test */ import { beforeAll, describe, expect, it } from 'vitest'; -import { createParsers, extractSymbols } from '../../src/parser.js'; +import { createParsers, extractSymbols } from '../../src/domain/parser.js'; describe('JavaScript parser', () => { let parsers; diff --git a/tests/parsers/php.test.js b/tests/parsers/php.test.js index 8f32dbad..106ba306 100644 --- a/tests/parsers/php.test.js +++ b/tests/parsers/php.test.js @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from 'vitest'; -import { createParsers, extractPHPSymbols } from '../../src/parser.js'; +import { createParsers, extractPHPSymbols } from '../../src/domain/parser.js'; describe('PHP parser', () => { let parsers; diff --git a/tests/parsers/ruby.test.js b/tests/parsers/ruby.test.js index 64d0d45a..eff4a403 100644 --- a/tests/parsers/ruby.test.js +++ b/tests/parsers/ruby.test.js @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from 'vitest'; -import { createParsers, extractRubySymbols } from '../../src/parser.js'; +import { createParsers, extractRubySymbols } from '../../src/domain/parser.js'; describe('Ruby parser', () => { let parsers; diff --git a/tests/parsers/rust.test.js b/tests/parsers/rust.test.js index e58ea256..e6ee7dae 100644 --- a/tests/parsers/rust.test.js +++ b/tests/parsers/rust.test.js @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from 'vitest'; -import { createParsers, extractRustSymbols } from '../../src/parser.js'; +import { createParsers, extractRustSymbols } from '../../src/domain/parser.js'; describe('Rust parser', () => { let parsers; diff --git a/tests/parsers/unified.test.js b/tests/parsers/unified.test.js index d5c59d66..b05c1919 100644 --- a/tests/parsers/unified.test.js +++ b/tests/parsers/unified.test.js @@ -6,7 +6,7 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { getActiveEngine, parseFileAuto, parseFilesAuto } from '../../src/parser.js'; +import { getActiveEngine, parseFileAuto, parseFilesAuto } from '../../src/domain/parser.js'; describe('Unified parser API', () => { describe('getActiveEngine', () => { diff --git a/tests/resolution/parity.test.js b/tests/resolution/parity.test.js index 444c4040..6c036ee0 100644 --- a/tests/resolution/parity.test.js +++ b/tests/resolution/parity.test.js @@ -14,7 +14,7 @@ import { resolveImportPathJS, resolveImportsBatch, } from '../../src/domain/graph/resolve.js'; -import { isNativeAvailable, loadNative } from '../../src/native.js'; +import { isNativeAvailable, loadNative } from '../../src/infrastructure/native.js'; const hasNative = isNativeAvailable(); diff --git a/tests/unit/cfg.test.js b/tests/unit/cfg.test.js index adf8aa49..50dc06dd 100644 --- a/tests/unit/cfg.test.js +++ b/tests/unit/cfg.test.js @@ -9,9 +9,9 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { CFG_RULES } from '../../src/ast-analysis/rules/index.js'; import { walkWithVisitors } from '../../src/ast-analysis/visitor.js'; import { createCfgVisitor } from '../../src/ast-analysis/visitors/cfg-visitor.js'; +import { createParsers } from '../../src/domain/parser.js'; import { buildFunctionCFG, makeCfgRules } from '../../src/features/cfg.js'; import { COMPLEXITY_RULES } from '../../src/features/complexity.js'; -import { createParsers } from '../../src/parser.js'; let jsParser; diff --git a/tests/unit/change-journal.test.js b/tests/unit/change-journal.test.js index 5fcc787b..3595c674 100644 --- a/tests/unit/change-journal.test.js +++ b/tests/unit/change-journal.test.js @@ -14,7 +14,7 @@ import { DEFAULT_MAX_BYTES, diffSymbols, rotateIfNeeded, -} from '../../src/change-journal.js'; +} from '../../src/domain/graph/change-journal.js'; let tmpDir; diff --git a/tests/unit/complexity.test.js b/tests/unit/complexity.test.js index b458ead1..776df84c 100644 --- a/tests/unit/complexity.test.js +++ b/tests/unit/complexity.test.js @@ -6,6 +6,7 @@ */ import { beforeAll, describe, expect, it } from 'vitest'; +import { createParsers } from '../../src/domain/parser.js'; import { COMPLEXITY_RULES, computeFunctionComplexity, @@ -14,7 +15,6 @@ import { computeMaintainabilityIndex, HALSTEAD_RULES, } from '../../src/features/complexity.js'; -import { createParsers } from '../../src/parser.js'; let jsParser; diff --git a/tests/unit/config.test.js b/tests/unit/config.test.js index 77af17aa..56685830 100644 --- a/tests/unit/config.test.js +++ b/tests/unit/config.test.js @@ -12,7 +12,7 @@ import { DEFAULTS, loadConfig, resolveSecrets, -} from '../../src/config.js'; +} from '../../src/infrastructure/config.js'; vi.mock('node:child_process', async (importOriginal) => { const actual = await importOriginal(); diff --git a/tests/unit/constants.test.js b/tests/unit/constants.test.js index e192c056..81ed4dba 100644 --- a/tests/unit/constants.test.js +++ b/tests/unit/constants.test.js @@ -10,7 +10,7 @@ import { isSupportedFile, normalizePath, shouldIgnore, -} from '../../src/constants.js'; +} from '../../src/shared/constants.js'; describe('EXTENSIONS', () => { it('contains known supported extensions', () => { diff --git a/tests/unit/errors.test.js b/tests/unit/errors.test.js index 3714df5b..db4736e0 100644 --- a/tests/unit/errors.test.js +++ b/tests/unit/errors.test.js @@ -12,7 +12,7 @@ import { EngineError, ParseError, ResolutionError, -} from '../../src/errors.js'; +} from '../../src/shared/errors.js'; describe('CodegraphError', () => { it('sets defaults', () => { diff --git a/tests/unit/journal.test.js b/tests/unit/journal.test.js index a4ef3dd2..27769f2a 100644 --- a/tests/unit/journal.test.js +++ b/tests/unit/journal.test.js @@ -11,7 +11,7 @@ import { JOURNAL_FILENAME, readJournal, writeJournalHeader, -} from '../../src/journal.js'; +} from '../../src/domain/graph/journal.js'; let tmpDir; diff --git a/tests/unit/logger.test.js b/tests/unit/logger.test.js index a95ee42f..fb54863d 100644 --- a/tests/unit/logger.test.js +++ b/tests/unit/logger.test.js @@ -3,7 +3,14 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { debug, error, info, isVerbose, setVerbose, warn } from '../../src/logger.js'; +import { + debug, + error, + info, + isVerbose, + setVerbose, + warn, +} from '../../src/infrastructure/logger.js'; describe('logger', () => { let stderrSpy; diff --git a/tests/unit/mcp.test.js b/tests/unit/mcp.test.js index cb9f490e..352ea092 100644 --- a/tests/unit/mcp.test.js +++ b/tests/unit/mcp.test.js @@ -587,7 +587,7 @@ describe('startMCPServer handler dispatch', () => { ListToolsRequestSchema: 'tools/list', CallToolRequestSchema: 'tools/call', })); - vi.doMock('../../src/registry.js', () => ({ + vi.doMock('../../src/infrastructure/registry.js', () => ({ resolveRepoDbPath: vi.fn((name) => name === 'my-project' ? '/resolved/path/.codegraph/graph.db' : undefined, ), @@ -650,7 +650,7 @@ describe('startMCPServer handler dispatch', () => { ListToolsRequestSchema: 'tools/list', CallToolRequestSchema: 'tools/call', })); - vi.doMock('../../src/registry.js', () => ({ + vi.doMock('../../src/infrastructure/registry.js', () => ({ resolveRepoDbPath: vi.fn(() => undefined), })); vi.doMock('../../src/domain/queries.js', () => ({ @@ -703,7 +703,7 @@ describe('startMCPServer handler dispatch', () => { ListToolsRequestSchema: 'tools/list', CallToolRequestSchema: 'tools/call', })); - vi.doMock('../../src/registry.js', () => ({ + vi.doMock('../../src/infrastructure/registry.js', () => ({ resolveRepoDbPath: vi.fn(() => '/some/path'), })); vi.doMock('../../src/domain/queries.js', () => ({ @@ -756,7 +756,7 @@ describe('startMCPServer handler dispatch', () => { ListToolsRequestSchema: 'tools/list', CallToolRequestSchema: 'tools/call', })); - vi.doMock('../../src/registry.js', () => ({ + vi.doMock('../../src/infrastructure/registry.js', () => ({ resolveRepoDbPath: vi.fn(() => '/resolved/db'), })); @@ -817,7 +817,7 @@ describe('startMCPServer handler dispatch', () => { ListToolsRequestSchema: 'tools/list', CallToolRequestSchema: 'tools/call', })); - vi.doMock('../../src/registry.js', () => ({ + vi.doMock('../../src/infrastructure/registry.js', () => ({ resolveRepoDbPath: vi.fn(), listRepos: vi.fn(() => [ { name: 'alpha', path: '/alpha' }, @@ -876,7 +876,7 @@ describe('startMCPServer handler dispatch', () => { ListToolsRequestSchema: 'tools/list', CallToolRequestSchema: 'tools/call', })); - vi.doMock('../../src/registry.js', () => ({ + vi.doMock('../../src/infrastructure/registry.js', () => ({ resolveRepoDbPath: vi.fn(), listRepos: vi.fn(() => [ { name: 'alpha', path: '/alpha' }, diff --git a/tests/unit/parser.test.js b/tests/unit/parser.test.js index 495b2a96..7995ba77 100644 --- a/tests/unit/parser.test.js +++ b/tests/unit/parser.test.js @@ -4,7 +4,7 @@ import fs from 'node:fs'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { isWasmAvailable, LANGUAGE_REGISTRY } from '../../src/parser.js'; +import { isWasmAvailable, LANGUAGE_REGISTRY } from '../../src/domain/parser.js'; describe('isWasmAvailable', () => { afterEach(() => { diff --git a/tests/unit/registry.test.js b/tests/unit/registry.test.js index 3d166d62..9cc0d1aa 100644 --- a/tests/unit/registry.test.js +++ b/tests/unit/registry.test.js @@ -13,7 +13,7 @@ import { resolveRepoDbPath, saveRegistry, unregisterRepo, -} from '../../src/registry.js'; +} from '../../src/infrastructure/registry.js'; let tmpDir; let registryPath; @@ -41,7 +41,7 @@ describe('REGISTRY_PATH', () => { [ '--input-type=module', '-e', - `import { REGISTRY_PATH } from './src/registry.js'; process.stdout.write(REGISTRY_PATH);`, + `import { REGISTRY_PATH } from './src/infrastructure/registry.js'; process.stdout.write(REGISTRY_PATH);`, ], { cwd: path.resolve(import.meta.dirname, '..', '..'), diff --git a/tests/unit/update-check.test.js b/tests/unit/update-check.test.js index 1bd41cff..78bc204f 100644 --- a/tests/unit/update-check.test.js +++ b/tests/unit/update-check.test.js @@ -2,7 +2,11 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { checkForUpdates, printUpdateNotification, semverCompare } from '../../src/update-check.js'; +import { + checkForUpdates, + printUpdateNotification, + semverCompare, +} from '../../src/infrastructure/update-check.js'; let tmpDir; let cachePath; diff --git a/tests/unit/visitor.test.js b/tests/unit/visitor.test.js index e8f4d437..e15571ec 100644 --- a/tests/unit/visitor.test.js +++ b/tests/unit/visitor.test.js @@ -8,7 +8,7 @@ let parse; async function ensureParser() { if (parse) return; - const { createParsers, getParser } = await import('../../src/parser.js'); + const { createParsers, getParser } = await import('../../src/domain/parser.js'); const parsers = await createParsers(); parse = (code) => { // getParser needs a path to determine language From 222d503c9754288535e6704f48f3954075322706 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 03:10:04 -0600 Subject: [PATCH 3/7] refactor: resolve three architecture issues after #456 and #458 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename features/viewer.js → features/graph-enrichment.js to resolve naming collision with presentation/viewer.js (Issue 3) - Move 16 CLI formatter files from commands/ → presentation/ where they belong as presentation concerns, delete commands/ directory (Issue 1) - Split 866-LOC presentation/queries-cli.js into thematic modules: path.js, overview.js, inspect.js, impact.js, exports.js (Issue 2) - Fix broken import in cli/commands/plot.js (was referencing deleted src/viewer.js, now correctly points to features/graph-enrichment.js) - Fix wrong relative import in presentation/query.js - Update CLAUDE.md architecture table to reflect new structure Impact: 29 functions changed, 1 affected --- CLAUDE.md | 10 +- src/cli/commands/audit.js | 2 +- src/cli/commands/batch.js | 2 +- src/cli/commands/branch-compare.js | 2 +- src/cli/commands/cfg.js | 2 +- src/cli/commands/check.js | 6 +- src/cli/commands/co-change.js | 2 +- src/cli/commands/communities.js | 2 +- src/cli/commands/complexity.js | 2 +- src/cli/commands/dataflow.js | 2 +- src/cli/commands/flow.js | 2 +- src/cli/commands/owners.js | 2 +- src/cli/commands/plot.js | 2 +- src/cli/commands/sequence.js | 2 +- src/cli/commands/structure.js | 2 +- src/cli/commands/triage.js | 4 +- .../{viewer.js => graph-enrichment.js} | 0 src/{commands => presentation}/audit.js | 0 src/{commands => presentation}/batch.js | 0 .../branch-compare.js | 0 src/{commands => presentation}/cfg.js | 0 src/{commands => presentation}/check.js | 0 src/{commands => presentation}/cochange.js | 0 src/{commands => presentation}/communities.js | 0 src/{commands => presentation}/complexity.js | 0 src/{commands => presentation}/dataflow.js | 0 src/{commands => presentation}/flow.js | 0 src/{commands => presentation}/manifesto.js | 0 src/{commands => presentation}/owners.js | 0 src/presentation/queries-cli.js | 887 +----------------- src/presentation/queries-cli/exports.js | 46 + src/presentation/queries-cli/impact.js | 198 ++++ src/presentation/queries-cli/index.js | 5 + src/presentation/queries-cli/inspect.js | 334 +++++++ src/presentation/queries-cli/overview.js | 197 ++++ src/presentation/queries-cli/path.js | 58 ++ src/{commands => presentation}/query.js | 2 +- src/{commands => presentation}/sequence.js | 0 src/{commands => presentation}/structure.js | 0 src/{commands => presentation}/triage.js | 0 tests/graph/viewer.test.js | 6 +- 41 files changed, 894 insertions(+), 885 deletions(-) rename src/features/{viewer.js => graph-enrichment.js} (100%) rename src/{commands => presentation}/audit.js (100%) rename src/{commands => presentation}/batch.js (100%) rename src/{commands => presentation}/branch-compare.js (100%) rename src/{commands => presentation}/cfg.js (100%) rename src/{commands => presentation}/check.js (100%) rename src/{commands => presentation}/cochange.js (100%) rename src/{commands => presentation}/communities.js (100%) rename src/{commands => presentation}/complexity.js (100%) rename src/{commands => presentation}/dataflow.js (100%) rename src/{commands => presentation}/flow.js (100%) rename src/{commands => presentation}/manifesto.js (100%) rename src/{commands => presentation}/owners.js (100%) create mode 100644 src/presentation/queries-cli/exports.js create mode 100644 src/presentation/queries-cli/impact.js create mode 100644 src/presentation/queries-cli/index.js create mode 100644 src/presentation/queries-cli/inspect.js create mode 100644 src/presentation/queries-cli/overview.js create mode 100644 src/presentation/queries-cli/path.js rename src/{commands => presentation}/query.js (88%) rename src/{commands => presentation}/sequence.js (100%) rename src/{commands => presentation}/structure.js (100%) rename src/{commands => presentation}/triage.js (100%) diff --git a/CLAUDE.md b/CLAUDE.md index 512162b0..df3cdfe4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,8 +82,14 @@ JS source is plain JavaScript (ES modules) in `src/`. No transpilation step. The | `features/snapshot.js` | SQLite DB backup and restore | | `features/structure.js` | Codebase structure analysis | | `features/triage.js` | Risk-ranked audit priority queue (delegates scoring to `graph/classifiers/`) | -| **`presentation/`** | **Pure output formatting** | -| `presentation/` | `viewer.js` (HTML renderer), `export.js` (DOT/Mermaid/GraphML/Neo4j serializers), `sequence-renderer.js` (Mermaid sequence diagrams), `table.js` (CLI table formatting), `result-formatter.js` (JSON/NDJSON output) | +| `features/graph-enrichment.js` | Data enrichment for HTML viewer (complexity, communities, fan-in/out) | +| **`presentation/`** | **Pure output formatting + CLI command wrappers** | +| `presentation/viewer.js` | Interactive HTML renderer with vis-network | +| `presentation/queries-cli/` | CLI display wrappers for query functions, split by concern: `path.js`, `overview.js`, `inspect.js`, `impact.js`, `exports.js` | +| `presentation/*.js` | Command formatters (audit, batch, check, communities, complexity, etc.) — call `features/*.js`, format output, set exit codes | +| `presentation/export.js` | DOT/Mermaid/GraphML/Neo4j serializers | +| `presentation/sequence-renderer.js` | Mermaid sequence diagram rendering | +| `presentation/table.js`, `result-formatter.js`, `colors.js` | CLI table formatting, JSON/NDJSON output, color constants | | **`graph/`** | **Unified graph model** | | `graph/` | `CodeGraph` class (`model.js`), algorithms (Tarjan SCC, Louvain, BFS, shortest path, centrality), classifiers (role, risk), builders (dependency, structure, temporal) | | **`mcp/`** | **MCP server** | diff --git a/src/cli/commands/audit.js b/src/cli/commands/audit.js index caa0b747..bf542b7c 100644 --- a/src/cli/commands/audit.js +++ b/src/cli/commands/audit.js @@ -1,5 +1,5 @@ -import { audit } from '../../commands/audit.js'; import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; +import { audit } from '../../presentation/audit.js'; import { explain } from '../../presentation/queries-cli.js'; export const command = { diff --git a/src/cli/commands/batch.js b/src/cli/commands/batch.js index 5d740ad1..0beda9ce 100644 --- a/src/cli/commands/batch.js +++ b/src/cli/commands/batch.js @@ -1,7 +1,7 @@ import fs from 'node:fs'; -import { batch } from '../../commands/batch.js'; import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; import { BATCH_COMMANDS, multiBatchData, splitTargets } from '../../features/batch.js'; +import { batch } from '../../presentation/batch.js'; import { ConfigError } from '../../shared/errors.js'; export const command = { diff --git a/src/cli/commands/branch-compare.js b/src/cli/commands/branch-compare.js index e674d835..45699c04 100644 --- a/src/cli/commands/branch-compare.js +++ b/src/cli/commands/branch-compare.js @@ -9,7 +9,7 @@ export const command = { ['-f, --format ', 'Output format: text, mermaid, json', 'text'], ], async execute([base, target], opts, ctx) { - const { branchCompare } = await import('../../commands/branch-compare.js'); + const { branchCompare } = await import('../../presentation/branch-compare.js'); await branchCompare(base, target, { engine: ctx.program.opts().engine, depth: parseInt(opts.depth, 10), diff --git a/src/cli/commands/cfg.js b/src/cli/commands/cfg.js index 20ef05bb..0ab00544 100644 --- a/src/cli/commands/cfg.js +++ b/src/cli/commands/cfg.js @@ -15,7 +15,7 @@ export const command = { } }, async execute([name], opts, ctx) { - const { cfg } = await import('../../commands/cfg.js'); + const { cfg } = await import('../../presentation/cfg.js'); cfg(name, opts.db, { format: opts.format, file: opts.file, diff --git a/src/cli/commands/check.js b/src/cli/commands/check.js index 8c5f29ca..cb2e16d9 100644 --- a/src/cli/commands/check.js +++ b/src/cli/commands/check.js @@ -32,7 +32,7 @@ export const command = { `Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`, ); } - const { manifesto } = await import('../../commands/manifesto.js'); + const { manifesto } = await import('../../presentation/manifesto.js'); manifesto(opts.db, { file: opts.file, kind: opts.kind, @@ -45,7 +45,7 @@ export const command = { return; } - const { check } = await import('../../commands/check.js'); + const { check } = await import('../../presentation/check.js'); check(opts.db, { ref, staged: opts.staged, @@ -64,7 +64,7 @@ export const command = { `Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`, ); } - const { manifesto } = await import('../../commands/manifesto.js'); + const { manifesto } = await import('../../presentation/manifesto.js'); manifesto(opts.db, { file: opts.file, kind: opts.kind, diff --git a/src/cli/commands/co-change.js b/src/cli/commands/co-change.js index 83b29e75..55c6c99f 100644 --- a/src/cli/commands/co-change.js +++ b/src/cli/commands/co-change.js @@ -20,7 +20,7 @@ export const command = { ], async execute([file], opts, ctx) { const { analyzeCoChanges, coChangeData, coChangeTopData } = await import('../../cochange.js'); - const { formatCoChange, formatCoChangeTop } = await import('../../commands/cochange.js'); + const { formatCoChange, formatCoChangeTop } = await import('../../presentation/cochange.js'); if (opts.analyze) { const result = analyzeCoChanges(opts.db, { diff --git a/src/cli/commands/communities.js b/src/cli/commands/communities.js index 42312b06..0fa07d28 100644 --- a/src/cli/commands/communities.js +++ b/src/cli/commands/communities.js @@ -8,7 +8,7 @@ export const command = { ['--drift', 'Show only drift analysis'], ], async execute(_args, opts, ctx) { - const { communities } = await import('../../commands/communities.js'); + const { communities } = await import('../../presentation/communities.js'); communities(opts.db, { functions: opts.functions, resolution: parseFloat(opts.resolution), diff --git a/src/cli/commands/complexity.js b/src/cli/commands/complexity.js index 193271d3..cf9ab4c4 100644 --- a/src/cli/commands/complexity.js +++ b/src/cli/commands/complexity.js @@ -27,7 +27,7 @@ export const command = { } }, async execute([target], opts, ctx) { - const { complexity } = await import('../../commands/complexity.js'); + const { complexity } = await import('../../presentation/complexity.js'); complexity(opts.db, { target, limit: parseInt(opts.limit, 10), diff --git a/src/cli/commands/dataflow.js b/src/cli/commands/dataflow.js index 4118b5b3..c32dde63 100644 --- a/src/cli/commands/dataflow.js +++ b/src/cli/commands/dataflow.js @@ -16,7 +16,7 @@ export const command = { } }, async execute([name], opts, ctx) { - const { dataflow } = await import('../../commands/dataflow.js'); + const { dataflow } = await import('../../presentation/dataflow.js'); dataflow(name, opts.db, { file: opts.file, kind: opts.kind, diff --git a/src/cli/commands/flow.js b/src/cli/commands/flow.js index 20814a3c..90c2c424 100644 --- a/src/cli/commands/flow.js +++ b/src/cli/commands/flow.js @@ -20,7 +20,7 @@ export const command = { } }, async execute([name], opts, ctx) { - const { flow } = await import('../../commands/flow.js'); + const { flow } = await import('../../presentation/flow.js'); flow(name, opts.db, { list: opts.list, depth: parseInt(opts.depth, 10), diff --git a/src/cli/commands/owners.js b/src/cli/commands/owners.js index 8dbc23a6..fee107fc 100644 --- a/src/cli/commands/owners.js +++ b/src/cli/commands/owners.js @@ -12,7 +12,7 @@ export const command = { ['-j, --json', 'Output as JSON'], ], async execute([target], opts, ctx) { - const { owners } = await import('../../commands/owners.js'); + const { owners } = await import('../../presentation/owners.js'); owners(opts.db, { owner: opts.owner, boundary: opts.boundary, diff --git a/src/cli/commands/plot.js b/src/cli/commands/plot.js index 8dafe532..030d00c8 100644 --- a/src/cli/commands/plot.js +++ b/src/cli/commands/plot.js @@ -22,7 +22,7 @@ export const command = { ['--color-by ', 'Color nodes by: kind | role | community | complexity'], ], async execute(_args, opts, ctx) { - const { generatePlotHTML, loadPlotConfig } = await import('../../viewer.js'); + const { generatePlotHTML, loadPlotConfig } = await import('../../features/graph-enrichment.js'); const os = await import('node:os'); const db = openReadonlyOrFail(opts.db); diff --git a/src/cli/commands/sequence.js b/src/cli/commands/sequence.js index 343a9fac..6daa0a6c 100644 --- a/src/cli/commands/sequence.js +++ b/src/cli/commands/sequence.js @@ -16,7 +16,7 @@ export const command = { } }, async execute([name], opts, ctx) { - const { sequence } = await import('../../commands/sequence.js'); + const { sequence } = await import('../../presentation/sequence.js'); sequence(name, opts.db, { depth: parseInt(opts.depth, 10), file: opts.file, diff --git a/src/cli/commands/structure.js b/src/cli/commands/structure.js index 1e469f54..bc72aa07 100644 --- a/src/cli/commands/structure.js +++ b/src/cli/commands/structure.js @@ -15,7 +15,7 @@ export const command = { ['--ndjson', 'Newline-delimited JSON output'], ], async execute([dir], opts, ctx) { - const { structureData, formatStructure } = await import('../../commands/structure.js'); + const { structureData, formatStructure } = await import('../../presentation/structure.js'); const data = structureData(opts.db, { directory: dir, depth: opts.depth ? parseInt(opts.depth, 10) : undefined, diff --git a/src/cli/commands/triage.js b/src/cli/commands/triage.js index 23e07183..5a8a570f 100644 --- a/src/cli/commands/triage.js +++ b/src/cli/commands/triage.js @@ -31,7 +31,7 @@ export const command = { ], async execute(_args, opts, ctx) { if (opts.level === 'file' || opts.level === 'directory') { - const { hotspotsData, formatHotspots } = await import('../../commands/structure.js'); + const { hotspotsData, formatHotspots } = await import('../../presentation/structure.js'); const metric = opts.sort === 'risk' ? 'fan-in' : opts.sort; const data = hotspotsData(opts.db, { metric, @@ -60,7 +60,7 @@ export const command = { throw new ConfigError('Invalid --weights JSON', { cause: err }); } } - const { triage } = await import('../../commands/triage.js'); + const { triage } = await import('../../presentation/triage.js'); triage(opts.db, { limit: parseInt(opts.limit, 10), offset: opts.offset ? parseInt(opts.offset, 10) : undefined, diff --git a/src/features/viewer.js b/src/features/graph-enrichment.js similarity index 100% rename from src/features/viewer.js rename to src/features/graph-enrichment.js diff --git a/src/commands/audit.js b/src/presentation/audit.js similarity index 100% rename from src/commands/audit.js rename to src/presentation/audit.js diff --git a/src/commands/batch.js b/src/presentation/batch.js similarity index 100% rename from src/commands/batch.js rename to src/presentation/batch.js diff --git a/src/commands/branch-compare.js b/src/presentation/branch-compare.js similarity index 100% rename from src/commands/branch-compare.js rename to src/presentation/branch-compare.js diff --git a/src/commands/cfg.js b/src/presentation/cfg.js similarity index 100% rename from src/commands/cfg.js rename to src/presentation/cfg.js diff --git a/src/commands/check.js b/src/presentation/check.js similarity index 100% rename from src/commands/check.js rename to src/presentation/check.js diff --git a/src/commands/cochange.js b/src/presentation/cochange.js similarity index 100% rename from src/commands/cochange.js rename to src/presentation/cochange.js diff --git a/src/commands/communities.js b/src/presentation/communities.js similarity index 100% rename from src/commands/communities.js rename to src/presentation/communities.js diff --git a/src/commands/complexity.js b/src/presentation/complexity.js similarity index 100% rename from src/commands/complexity.js rename to src/presentation/complexity.js diff --git a/src/commands/dataflow.js b/src/presentation/dataflow.js similarity index 100% rename from src/commands/dataflow.js rename to src/presentation/dataflow.js diff --git a/src/commands/flow.js b/src/presentation/flow.js similarity index 100% rename from src/commands/flow.js rename to src/presentation/flow.js diff --git a/src/commands/manifesto.js b/src/presentation/manifesto.js similarity index 100% rename from src/commands/manifesto.js rename to src/presentation/manifesto.js diff --git a/src/commands/owners.js b/src/presentation/owners.js similarity index 100% rename from src/commands/owners.js rename to src/presentation/owners.js diff --git a/src/presentation/queries-cli.js b/src/presentation/queries-cli.js index 8eab363e..8e77abfd 100644 --- a/src/presentation/queries-cli.js +++ b/src/presentation/queries-cli.js @@ -1,866 +1,27 @@ /** - * queries-cli.js — CLI display wrappers for query data functions. + * queries-cli.js — barrel re-export for backward compatibility. * - * Each function calls its corresponding *Data() function from queries.js, - * handles JSON/NDJSON output via outputResult(), then formats human-readable - * output for the terminal. + * The actual implementations live in queries-cli/ split by concern: + * path.js — symbolPath + * overview.js — stats, moduleMap, roles + * inspect.js — where, queryName, context, children, explain + * impact.js — fileDeps, fnDeps, impactAnalysis, fnImpact, diffImpact + * exports.js — fileExports */ - -import path from 'node:path'; -import { - childrenData, - contextData, - diffImpactData, - diffImpactMermaid, - explainData, - exportsData, - fileDepsData, - fnDepsData, - fnImpactData, - impactAnalysisData, - kindIcon, - moduleMapData, - pathData, - queryNameData, - rolesData, - statsData, - whereData, -} from '../domain/queries.js'; -import { outputResult } from '../infrastructure/result-formatter.js'; - -// ─── symbolPath ───────────────────────────────────────────────────────── - -export function symbolPath(from, to, customDbPath, opts = {}) { - const data = pathData(from, to, customDbPath, opts); - if (outputResult(data, null, opts)) return; - - if (data.error) { - console.log(data.error); - return; - } - - if (!data.found) { - const dir = data.reverse ? 'reverse ' : ''; - console.log(`No ${dir}path from "${from}" to "${to}" within ${data.maxDepth} hops.`); - if (data.fromCandidates.length > 1) { - console.log( - `\n "${from}" matched ${data.fromCandidates.length} symbols — using top match: ${data.fromCandidates[0].name} (${data.fromCandidates[0].file}:${data.fromCandidates[0].line})`, - ); - } - if (data.toCandidates.length > 1) { - console.log( - ` "${to}" matched ${data.toCandidates.length} symbols — using top match: ${data.toCandidates[0].name} (${data.toCandidates[0].file}:${data.toCandidates[0].line})`, - ); - } - return; - } - - if (data.hops === 0) { - console.log(`\n"${from}" and "${to}" resolve to the same symbol (0 hops):`); - const n = data.path[0]; - console.log(` ${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}\n`); - return; - } - - const dir = data.reverse ? ' (reverse)' : ''; - console.log( - `\nPath from ${from} to ${to} (${data.hops} ${data.hops === 1 ? 'hop' : 'hops'})${dir}:\n`, - ); - for (let i = 0; i < data.path.length; i++) { - const n = data.path[i]; - const indent = ' '.repeat(i + 1); - if (i === 0) { - console.log(`${indent}${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}`); - } else { - console.log( - `${indent}--[${n.edgeKind}]--> ${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}`, - ); - } - } - - if (data.alternateCount > 0) { - console.log( - `\n (${data.alternateCount} alternate shortest ${data.alternateCount === 1 ? 'path' : 'paths'} at same depth)`, - ); - } - console.log(); -} - -// ─── stats ────────────────────────────────────────────────────────────── - -export async function stats(customDbPath, opts = {}) { - const data = statsData(customDbPath, { noTests: opts.noTests }); - - // Community detection summary (async import for lazy-loading) - try { - const { communitySummaryForStats } = await import('./communities.js'); - data.communities = communitySummaryForStats(customDbPath, { noTests: opts.noTests }); - } catch { - /* graphology may not be available */ - } - - if (outputResult(data, null, opts)) return; - - // Human-readable output - console.log('\n# Codegraph Stats\n'); - - // Nodes - console.log(`Nodes: ${data.nodes.total} total`); - const kindEntries = Object.entries(data.nodes.byKind).sort((a, b) => b[1] - a[1]); - const kindParts = kindEntries.map(([k, v]) => `${k} ${v}`); - for (let i = 0; i < kindParts.length; i += 3) { - const row = kindParts - .slice(i, i + 3) - .map((p) => p.padEnd(18)) - .join(''); - console.log(` ${row}`); - } - - // Edges - console.log(`\nEdges: ${data.edges.total} total`); - const edgeEntries = Object.entries(data.edges.byKind).sort((a, b) => b[1] - a[1]); - const edgeParts = edgeEntries.map(([k, v]) => `${k} ${v}`); - for (let i = 0; i < edgeParts.length; i += 3) { - const row = edgeParts - .slice(i, i + 3) - .map((p) => p.padEnd(18)) - .join(''); - console.log(` ${row}`); - } - - // Files - console.log(`\nFiles: ${data.files.total} (${data.files.languages} languages)`); - const langEntries = Object.entries(data.files.byLanguage).sort((a, b) => b[1] - a[1]); - const langParts = langEntries.map(([k, v]) => `${k} ${v}`); - for (let i = 0; i < langParts.length; i += 3) { - const row = langParts - .slice(i, i + 3) - .map((p) => p.padEnd(18)) - .join(''); - console.log(` ${row}`); - } - - // Cycles - console.log( - `\nCycles: ${data.cycles.fileLevel} file-level, ${data.cycles.functionLevel} function-level`, - ); - - // Hotspots - if (data.hotspots.length > 0) { - console.log(`\nTop ${data.hotspots.length} coupling hotspots:`); - for (let i = 0; i < data.hotspots.length; i++) { - const h = data.hotspots[i]; - console.log( - ` ${String(i + 1).padStart(2)}. ${h.file.padEnd(35)} fan-in: ${String(h.fanIn).padStart(3)} fan-out: ${String(h.fanOut).padStart(3)}`, - ); - } - } - - // Embeddings - if (data.embeddings) { - const e = data.embeddings; - console.log( - `\nEmbeddings: ${e.count} vectors (${e.model || 'unknown'}, ${e.dim || '?'}d) built ${e.builtAt || 'unknown'}`, - ); - } else { - console.log('\nEmbeddings: not built'); - } - - // Quality - if (data.quality) { - const q = data.quality; - const cc = q.callerCoverage; - const cf = q.callConfidence; - console.log(`\nGraph Quality: ${q.score}/100`); - console.log( - ` Caller coverage: ${(cc.ratio * 100).toFixed(1)}% (${cc.covered}/${cc.total} functions have >=1 caller)`, - ); - console.log( - ` Call confidence: ${(cf.ratio * 100).toFixed(1)}% (${cf.highConf}/${cf.total} call edges are high-confidence)`, - ); - if (q.falsePositiveWarnings.length > 0) { - console.log(' False-positive warnings:'); - for (const fp of q.falsePositiveWarnings) { - console.log(` ! ${fp.name} (${fp.callerCount} callers) -- ${fp.file}:${fp.line}`); - } - } - } - - // Roles - if (data.roles && Object.keys(data.roles).length > 0) { - const total = Object.values(data.roles).reduce((a, b) => a + b, 0); - console.log(`\nRoles: ${total} classified symbols`); - const roleParts = Object.entries(data.roles) - .sort((a, b) => b[1] - a[1]) - .map(([k, v]) => `${k} ${v}`); - for (let i = 0; i < roleParts.length; i += 3) { - const row = roleParts - .slice(i, i + 3) - .map((p) => p.padEnd(18)) - .join(''); - console.log(` ${row}`); - } - } - - // Complexity - if (data.complexity) { - const cx = data.complexity; - const miPart = cx.avgMI != null ? ` | avg MI: ${cx.avgMI} | min MI: ${cx.minMI}` : ''; - console.log( - `\nComplexity: ${cx.analyzed} functions | avg cognitive: ${cx.avgCognitive} | avg cyclomatic: ${cx.avgCyclomatic} | max cognitive: ${cx.maxCognitive}${miPart}`, - ); - } - - // Communities - if (data.communities) { - const cm = data.communities; - console.log( - `\nCommunities: ${cm.communityCount} detected | modularity: ${cm.modularity} | drift: ${cm.driftScore}%`, - ); - } - - console.log(); -} - -// ─── queryName ────────────────────────────────────────────────────────── - -export function queryName(name, customDbPath, opts = {}) { - const data = queryNameData(name, customDbPath, { - noTests: opts.noTests, - limit: opts.limit, - offset: opts.offset, - }); - if (outputResult(data, 'results', opts)) return; - - if (data.results.length === 0) { - console.log(`No results for "${name}"`); - return; - } - - console.log(`\nResults for "${name}":\n`); - for (const r of data.results) { - console.log(` ${kindIcon(r.kind)} ${r.name} (${r.kind}) -- ${r.file}:${r.line}`); - if (r.callees.length > 0) { - console.log(` -> calls/uses:`); - for (const c of r.callees.slice(0, 15)) - console.log(` -> ${c.name} (${c.edgeKind}) ${c.file}:${c.line}`); - if (r.callees.length > 15) console.log(` ... and ${r.callees.length - 15} more`); - } - if (r.callers.length > 0) { - console.log(` <- called by:`); - for (const c of r.callers.slice(0, 15)) - console.log(` <- ${c.name} (${c.edgeKind}) ${c.file}:${c.line}`); - if (r.callers.length > 15) console.log(` ... and ${r.callers.length - 15} more`); - } - console.log(); - } -} - -// ─── impactAnalysis ───────────────────────────────────────────────────── - -export function impactAnalysis(file, customDbPath, opts = {}) { - const data = impactAnalysisData(file, customDbPath, opts); - if (outputResult(data, 'sources', opts)) return; - - if (data.sources.length === 0) { - console.log(`No file matching "${file}" in graph`); - return; - } - - console.log(`\nImpact analysis for files matching "${file}":\n`); - for (const s of data.sources) console.log(` # ${s} (source)`); - - const levels = data.levels; - if (Object.keys(levels).length === 0) { - console.log(` No dependents found.`); - } else { - for (const level of Object.keys(levels).sort((a, b) => a - b)) { - const nodes = levels[level]; - console.log( - `\n ${'--'.repeat(parseInt(level, 10))} Level ${level} (${nodes.length} files):`, - ); - for (const n of nodes.slice(0, 30)) - console.log(` ${' '.repeat(parseInt(level, 10))}^ ${n.file}`); - if (nodes.length > 30) console.log(` ... and ${nodes.length - 30} more`); - } - } - console.log(`\n Total: ${data.totalDependents} files transitively depend on "${file}"\n`); -} - -// ─── moduleMap ────────────────────────────────────────────────────────── - -export function moduleMap(customDbPath, limit = 20, opts = {}) { - const data = moduleMapData(customDbPath, limit, { noTests: opts.noTests }); - if (outputResult(data, 'topNodes', opts)) return; - - console.log(`\nModule map (top ${limit} most-connected nodes):\n`); - const dirs = new Map(); - for (const n of data.topNodes) { - if (!dirs.has(n.dir)) dirs.set(n.dir, []); - dirs.get(n.dir).push(n); - } - for (const [dir, files] of [...dirs].sort()) { - console.log(` [${dir}/]`); - for (const f of files) { - const coupling = f.inEdges + f.outEdges; - const bar = '#'.repeat(Math.min(coupling, 40)); - console.log( - ` ${path.basename(f.file).padEnd(35)} <-${String(f.inEdges).padStart(3)} ->${String(f.outEdges).padStart(3)} =${String(coupling).padStart(3)} ${bar}`, - ); - } - } - console.log( - `\n Total: ${data.stats.totalFiles} files, ${data.stats.totalNodes} symbols, ${data.stats.totalEdges} edges\n`, - ); -} - -// ─── fileDeps ─────────────────────────────────────────────────────────── - -export function fileDeps(file, customDbPath, opts = {}) { - const data = fileDepsData(file, customDbPath, opts); - if (outputResult(data, 'results', opts)) return; - - if (data.results.length === 0) { - console.log(`No file matching "${file}" in graph`); - return; - } - - for (const r of data.results) { - console.log(`\n# ${r.file}\n`); - console.log(` -> Imports (${r.imports.length}):`); - for (const i of r.imports) { - const typeTag = i.typeOnly ? ' (type-only)' : ''; - console.log(` -> ${i.file}${typeTag}`); - } - console.log(`\n <- Imported by (${r.importedBy.length}):`); - for (const i of r.importedBy) console.log(` <- ${i.file}`); - if (r.definitions.length > 0) { - console.log(`\n Definitions (${r.definitions.length}):`); - for (const d of r.definitions.slice(0, 30)) - console.log(` ${kindIcon(d.kind)} ${d.name} :${d.line}`); - if (r.definitions.length > 30) console.log(` ... and ${r.definitions.length - 30} more`); - } - console.log(); - } -} - -// ─── fnDeps ───────────────────────────────────────────────────────────── - -export function fnDeps(name, customDbPath, opts = {}) { - const data = fnDepsData(name, customDbPath, opts); - if (outputResult(data, 'results', opts)) return; - - if (data.results.length === 0) { - console.log(`No function/method/class matching "${name}"`); - return; - } - - for (const r of data.results) { - console.log(`\n${kindIcon(r.kind)} ${r.name} (${r.kind}) -- ${r.file}:${r.line}\n`); - if (r.callees.length > 0) { - console.log(` -> Calls (${r.callees.length}):`); - for (const c of r.callees) - console.log(` -> ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); - } - if (r.callers.length > 0) { - console.log(`\n <- Called by (${r.callers.length}):`); - for (const c of r.callers) { - const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : ''; - console.log(` <- ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`); - } - } - for (const [d, fns] of Object.entries(r.transitiveCallers)) { - console.log( - `\n ${'<-'.repeat(parseInt(d, 10))} Transitive callers (depth ${d}, ${fns.length}):`, - ); - for (const n of fns.slice(0, 20)) - console.log( - ` ${' '.repeat(parseInt(d, 10) - 1)}<- ${kindIcon(n.kind)} ${n.name} ${n.file}:${n.line}`, - ); - if (fns.length > 20) console.log(` ... and ${fns.length - 20} more`); - } - if (r.callees.length === 0 && r.callers.length === 0) { - console.log(` (no call edges found -- may be invoked dynamically or via re-exports)`); - } - console.log(); - } -} - -// ─── context ──────────────────────────────────────────────────────────── - -export function context(name, customDbPath, opts = {}) { - const data = contextData(name, customDbPath, opts); - if (outputResult(data, 'results', opts)) return; - - if (data.results.length === 0) { - console.log(`No function/method/class matching "${name}"`); - return; - } - - for (const r of data.results) { - const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`; - const roleTag = r.role ? ` [${r.role}]` : ''; - console.log(`\n# ${r.name} (${r.kind})${roleTag} — ${r.file}:${lineRange}\n`); - - // Signature - if (r.signature) { - console.log('## Type/Shape Info'); - if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`); - if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`); - console.log(); - } - - // Children - if (r.children && r.children.length > 0) { - console.log(`## Children (${r.children.length})`); - for (const c of r.children) { - console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`); - } - console.log(); - } - - // Complexity - if (r.complexity) { - const cx = r.complexity; - const miPart = cx.maintainabilityIndex ? ` | MI: ${cx.maintainabilityIndex}` : ''; - console.log('## Complexity'); - console.log( - ` Cognitive: ${cx.cognitive} | Cyclomatic: ${cx.cyclomatic} | Max Nesting: ${cx.maxNesting}${miPart}`, - ); - console.log(); - } - - // Source - if (r.source) { - console.log('## Source'); - for (const line of r.source.split('\n')) { - console.log(` ${line}`); - } - console.log(); - } - - // Callees - if (r.callees.length > 0) { - console.log(`## Direct Dependencies (${r.callees.length})`); - for (const c of r.callees) { - const summary = c.summary ? ` — ${c.summary}` : ''; - console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${summary}`); - if (c.source) { - for (const line of c.source.split('\n').slice(0, 10)) { - console.log(` | ${line}`); - } - } - } - console.log(); - } - - // Callers - if (r.callers.length > 0) { - console.log(`## Callers (${r.callers.length})`); - for (const c of r.callers) { - const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : ''; - console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`); - } - console.log(); - } - - // Related tests - if (r.relatedTests.length > 0) { - console.log('## Related Tests'); - for (const t of r.relatedTests) { - console.log(` ${t.file} — ${t.testCount} tests`); - for (const tn of t.testNames) { - console.log(` - ${tn}`); - } - if (t.source) { - console.log(' Source:'); - for (const line of t.source.split('\n').slice(0, 20)) { - console.log(` | ${line}`); - } - } - } - console.log(); - } - - if (r.callees.length === 0 && r.callers.length === 0 && r.relatedTests.length === 0) { - console.log( - ' (no call edges or tests found — may be invoked dynamically or via re-exports)', - ); - console.log(); - } - } -} - -// ─── children ─────────────────────────────────────────────────────────── - -export function children(name, customDbPath, opts = {}) { - const data = childrenData(name, customDbPath, opts); - if (outputResult(data, 'results', opts)) return; - - if (data.results.length === 0) { - console.log(`No symbol matching "${name}"`); - return; - } - for (const r of data.results) { - console.log(`\n${kindIcon(r.kind)} ${r.name} ${r.file}:${r.line}`); - if (r.children.length === 0) { - console.log(' (no children)'); - } else { - for (const c of r.children) { - console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`); - } - } - } -} - -// ─── explain ──────────────────────────────────────────────────────────── - -export function explain(target, customDbPath, opts = {}) { - const data = explainData(target, customDbPath, opts); - if (outputResult(data, 'results', opts)) return; - - if (data.results.length === 0) { - console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`); - return; - } - - if (data.kind === 'file') { - for (const r of data.results) { - const publicCount = r.publicApi.length; - const internalCount = r.internal.length; - const lineInfo = r.lineCount ? `${r.lineCount} lines, ` : ''; - console.log(`\n# ${r.file}`); - console.log( - ` ${lineInfo}${r.symbolCount} symbols (${publicCount} exported, ${internalCount} internal)`, - ); - - if (r.imports.length > 0) { - console.log(` Imports: ${r.imports.map((i) => i.file).join(', ')}`); - } - if (r.importedBy.length > 0) { - console.log(` Imported by: ${r.importedBy.map((i) => i.file).join(', ')}`); - } - - if (r.publicApi.length > 0) { - console.log(`\n## Exported`); - for (const s of r.publicApi) { - const sig = s.signature?.params != null ? `(${s.signature.params})` : ''; - const roleTag = s.role ? ` [${s.role}]` : ''; - const summary = s.summary ? ` -- ${s.summary}` : ''; - console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`); - } - } - - if (r.internal.length > 0) { - console.log(`\n## Internal`); - for (const s of r.internal) { - const sig = s.signature?.params != null ? `(${s.signature.params})` : ''; - const roleTag = s.role ? ` [${s.role}]` : ''; - const summary = s.summary ? ` -- ${s.summary}` : ''; - console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`); - } - } - - if (r.dataFlow.length > 0) { - console.log(`\n## Data Flow`); - for (const df of r.dataFlow) { - console.log(` ${df.caller} -> ${df.callees.join(', ')}`); - } - } - console.log(); - } - } else { - function printFunctionExplain(r, indent = '') { - const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`; - const lineInfo = r.lineCount ? `${r.lineCount} lines` : ''; - const summaryPart = r.summary ? ` | ${r.summary}` : ''; - const roleTag = r.role ? ` [${r.role}]` : ''; - const depthLevel = r._depth || 0; - const heading = depthLevel === 0 ? '#' : '##'.padEnd(depthLevel + 2, '#'); - console.log(`\n${indent}${heading} ${r.name} (${r.kind})${roleTag} ${r.file}:${lineRange}`); - if (lineInfo || r.summary) { - console.log(`${indent} ${lineInfo}${summaryPart}`); - } - if (r.signature) { - if (r.signature.params != null) - console.log(`${indent} Parameters: (${r.signature.params})`); - if (r.signature.returnType) console.log(`${indent} Returns: ${r.signature.returnType}`); - } - - if (r.complexity) { - const cx = r.complexity; - const miPart = cx.maintainabilityIndex ? ` MI=${cx.maintainabilityIndex}` : ''; - console.log( - `${indent} Complexity: cognitive=${cx.cognitive} cyclomatic=${cx.cyclomatic} nesting=${cx.maxNesting}${miPart}`, - ); - } - - if (r.callees.length > 0) { - console.log(`\n${indent} Calls (${r.callees.length}):`); - for (const c of r.callees) { - console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); - } - } - - if (r.callers.length > 0) { - console.log(`\n${indent} Called by (${r.callers.length}):`); - for (const c of r.callers) { - console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); - } - } - - if (r.relatedTests.length > 0) { - const label = r.relatedTests.length === 1 ? 'file' : 'files'; - console.log(`\n${indent} Tests (${r.relatedTests.length} ${label}):`); - for (const t of r.relatedTests) { - console.log(`${indent} ${t.file}`); - } - } - - if (r.callees.length === 0 && r.callers.length === 0) { - console.log( - `${indent} (no call edges found -- may be invoked dynamically or via re-exports)`, - ); - } - - // Render recursive dependency details - if (r.depDetails && r.depDetails.length > 0) { - console.log(`\n${indent} --- Dependencies (depth ${depthLevel + 1}) ---`); - for (const dep of r.depDetails) { - printFunctionExplain(dep, `${indent} `); - } - } - console.log(); - } - - for (const r of data.results) { - printFunctionExplain(r); - } - } -} - -// ─── where ────────────────────────────────────────────────────────────── - -export function where(target, customDbPath, opts = {}) { - const data = whereData(target, customDbPath, opts); - if (outputResult(data, 'results', opts)) return; - - if (data.results.length === 0) { - console.log( - data.mode === 'file' - ? `No file matching "${target}" in graph` - : `No symbol matching "${target}" in graph`, - ); - return; - } - - if (data.mode === 'symbol') { - for (const r of data.results) { - const roleTag = r.role ? ` [${r.role}]` : ''; - const tag = r.exported ? ' (exported)' : ''; - console.log(`\n${kindIcon(r.kind)} ${r.name}${roleTag} ${r.file}:${r.line}${tag}`); - if (r.uses.length > 0) { - const useStrs = r.uses.map((u) => `${u.file}:${u.line}`); - console.log(` Used in: ${useStrs.join(', ')}`); - } else { - console.log(' No uses found'); - } - } - } else { - for (const r of data.results) { - console.log(`\n# ${r.file}`); - if (r.symbols.length > 0) { - const symStrs = r.symbols.map((s) => `${s.name}:${s.line}`); - console.log(` Symbols: ${symStrs.join(', ')}`); - } - if (r.imports.length > 0) { - console.log(` Imports: ${r.imports.join(', ')}`); - } - if (r.importedBy.length > 0) { - console.log(` Imported by: ${r.importedBy.join(', ')}`); - } - if (r.exported.length > 0) { - console.log(` Exported: ${r.exported.join(', ')}`); - } - } - } - console.log(); -} - -// ─── roles ────────────────────────────────────────────────────────────── - -export function roles(customDbPath, opts = {}) { - const data = rolesData(customDbPath, opts); - if (outputResult(data, 'symbols', opts)) return; - - if (data.count === 0) { - console.log('No classified symbols found. Run "codegraph build" first.'); - return; - } - - const total = data.count; - console.log(`\nNode roles (${total} symbols):\n`); - - const summaryParts = Object.entries(data.summary) - .sort((a, b) => b[1] - a[1]) - .map(([role, count]) => `${role}: ${count}`); - console.log(` ${summaryParts.join(' ')}\n`); - - const byRole = {}; - for (const s of data.symbols) { - if (!byRole[s.role]) byRole[s.role] = []; - byRole[s.role].push(s); - } - - for (const [role, symbols] of Object.entries(byRole)) { - console.log(`## ${role} (${symbols.length})`); - for (const s of symbols.slice(0, 30)) { - console.log(` ${kindIcon(s.kind)} ${s.name} ${s.file}:${s.line}`); - } - if (symbols.length > 30) { - console.log(` ... and ${symbols.length - 30} more`); - } - console.log(); - } -} - -// ─── fileExports ──────────────────────────────────────────────────────── - -export function fileExports(file, customDbPath, opts = {}) { - const data = exportsData(file, customDbPath, opts); - if (outputResult(data, 'results', opts)) return; - - if (data.results.length === 0) { - if (opts.unused) { - console.log(`No unused exports found for "${file}".`); - } else { - console.log(`No exported symbols found for "${file}". Run "codegraph build" first.`); - } - return; - } - - if (opts.unused) { - console.log( - `\n# ${data.file} — ${data.totalUnused} unused export${data.totalUnused !== 1 ? 's' : ''} (of ${data.totalExported} exported)\n`, - ); - } else { - const unusedNote = data.totalUnused > 0 ? ` (${data.totalUnused} unused)` : ''; - console.log( - `\n# ${data.file} — ${data.totalExported} exported${unusedNote}, ${data.totalInternal} internal\n`, - ); - } - - for (const sym of data.results) { - const icon = kindIcon(sym.kind); - const sig = sym.signature?.params ? `(${sym.signature.params})` : ''; - const role = sym.role ? ` [${sym.role}]` : ''; - console.log(` ${icon} ${sym.name}${sig}${role} :${sym.line}`); - if (sym.consumers.length === 0) { - console.log(' (no consumers)'); - } else { - for (const c of sym.consumers) { - console.log(` <- ${c.name} (${c.file}:${c.line})`); - } - } - } - - if (data.reexports.length > 0) { - console.log(`\n Re-exports: ${data.reexports.map((r) => r.file).join(', ')}`); - } - console.log(); -} - -// ─── fnImpact ─────────────────────────────────────────────────────────── - -export function fnImpact(name, customDbPath, opts = {}) { - const data = fnImpactData(name, customDbPath, opts); - if (outputResult(data, 'results', opts)) return; - - if (data.results.length === 0) { - console.log(`No function/method/class matching "${name}"`); - return; - } - - for (const r of data.results) { - console.log(`\nFunction impact: ${kindIcon(r.kind)} ${r.name} -- ${r.file}:${r.line}\n`); - if (Object.keys(r.levels).length === 0) { - console.log(` No callers found.`); - } else { - for (const [level, fns] of Object.entries(r.levels).sort((a, b) => a[0] - b[0])) { - const l = parseInt(level, 10); - console.log(` ${'--'.repeat(l)} Level ${level} (${fns.length} functions):`); - for (const f of fns.slice(0, 20)) - console.log(` ${' '.repeat(l)}^ ${kindIcon(f.kind)} ${f.name} ${f.file}:${f.line}`); - if (fns.length > 20) console.log(` ... and ${fns.length - 20} more`); - } - } - console.log(`\n Total: ${r.totalDependents} functions transitively depend on ${r.name}\n`); - } -} - -// ─── diffImpact ───────────────────────────────────────────────────────── - -export function diffImpact(customDbPath, opts = {}) { - if (opts.format === 'mermaid') { - console.log(diffImpactMermaid(customDbPath, opts)); - return; - } - const data = diffImpactData(customDbPath, opts); - if (opts.format === 'json') opts = { ...opts, json: true }; - if (outputResult(data, 'affectedFunctions', opts)) return; - - if (data.error) { - console.log(data.error); - return; - } - if (data.changedFiles === 0) { - console.log('No changes detected.'); - return; - } - if (data.affectedFunctions.length === 0) { - console.log( - ' No function-level changes detected (changes may be in imports, types, or config).', - ); - return; - } - - console.log(`\ndiff-impact: ${data.changedFiles} files changed\n`); - console.log(` ${data.affectedFunctions.length} functions changed:\n`); - for (const fn of data.affectedFunctions) { - console.log(` ${kindIcon(fn.kind)} ${fn.name} -- ${fn.file}:${fn.line}`); - if (fn.transitiveCallers > 0) console.log(` ^ ${fn.transitiveCallers} transitive callers`); - } - if (data.historicallyCoupled && data.historicallyCoupled.length > 0) { - console.log('\n Historically coupled (not in static graph):\n'); - for (const c of data.historicallyCoupled) { - const pct = `${(c.jaccard * 100).toFixed(0)}%`; - console.log( - ` ${c.file} <- coupled with ${c.coupledWith} (${pct}, ${c.commitCount} commits)`, - ); - } - } - if (data.ownership) { - console.log(`\n Affected owners: ${data.ownership.affectedOwners.join(', ')}`); - console.log(` Suggested reviewers: ${data.ownership.suggestedReviewers.join(', ')}`); - } - if (data.boundaryViolations && data.boundaryViolations.length > 0) { - console.log(`\n Boundary violations (${data.boundaryViolationCount}):\n`); - for (const v of data.boundaryViolations) { - console.log(` [${v.name}] ${v.file} -> ${v.targetFile}`); - if (v.message) console.log(` ${v.message}`); - } - } - if (data.summary) { - let summaryLine = `\n Summary: ${data.summary.functionsChanged} functions changed -> ${data.summary.callersAffected} callers affected across ${data.summary.filesAffected} files`; - if (data.summary.historicallyCoupledCount > 0) { - summaryLine += `, ${data.summary.historicallyCoupledCount} historically coupled`; - } - if (data.summary.ownersAffected > 0) { - summaryLine += `, ${data.summary.ownersAffected} owners affected`; - } - if (data.summary.boundaryViolationCount > 0) { - summaryLine += `, ${data.summary.boundaryViolationCount} boundary violations`; - } - console.log(`${summaryLine}\n`); - } -} +export { + children, + context, + diffImpact, + explain, + fileDeps, + fileExports, + fnDeps, + fnImpact, + impactAnalysis, + moduleMap, + queryName, + roles, + stats, + symbolPath, + where, +} from './queries-cli/index.js'; diff --git a/src/presentation/queries-cli/exports.js b/src/presentation/queries-cli/exports.js new file mode 100644 index 00000000..ea7dcade --- /dev/null +++ b/src/presentation/queries-cli/exports.js @@ -0,0 +1,46 @@ +import { exportsData, kindIcon } from '../../domain/queries.js'; +import { outputResult } from '../../infrastructure/result-formatter.js'; + +export function fileExports(file, customDbPath, opts = {}) { + const data = exportsData(file, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + if (opts.unused) { + console.log(`No unused exports found for "${file}".`); + } else { + console.log(`No exported symbols found for "${file}". Run "codegraph build" first.`); + } + return; + } + + if (opts.unused) { + console.log( + `\n# ${data.file} — ${data.totalUnused} unused export${data.totalUnused !== 1 ? 's' : ''} (of ${data.totalExported} exported)\n`, + ); + } else { + const unusedNote = data.totalUnused > 0 ? ` (${data.totalUnused} unused)` : ''; + console.log( + `\n# ${data.file} — ${data.totalExported} exported${unusedNote}, ${data.totalInternal} internal\n`, + ); + } + + for (const sym of data.results) { + const icon = kindIcon(sym.kind); + const sig = sym.signature?.params ? `(${sym.signature.params})` : ''; + const role = sym.role ? ` [${sym.role}]` : ''; + console.log(` ${icon} ${sym.name}${sig}${role} :${sym.line}`); + if (sym.consumers.length === 0) { + console.log(' (no consumers)'); + } else { + for (const c of sym.consumers) { + console.log(` <- ${c.name} (${c.file}:${c.line})`); + } + } + } + + if (data.reexports.length > 0) { + console.log(`\n Re-exports: ${data.reexports.map((r) => r.file).join(', ')}`); + } + console.log(); +} diff --git a/src/presentation/queries-cli/impact.js b/src/presentation/queries-cli/impact.js new file mode 100644 index 00000000..176172be --- /dev/null +++ b/src/presentation/queries-cli/impact.js @@ -0,0 +1,198 @@ +import { + diffImpactData, + diffImpactMermaid, + fileDepsData, + fnDepsData, + fnImpactData, + impactAnalysisData, + kindIcon, +} from '../../domain/queries.js'; +import { outputResult } from '../../infrastructure/result-formatter.js'; + +export function fileDeps(file, customDbPath, opts = {}) { + const data = fileDepsData(file, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No file matching "${file}" in graph`); + return; + } + + for (const r of data.results) { + console.log(`\n# ${r.file}\n`); + console.log(` -> Imports (${r.imports.length}):`); + for (const i of r.imports) { + const typeTag = i.typeOnly ? ' (type-only)' : ''; + console.log(` -> ${i.file}${typeTag}`); + } + console.log(`\n <- Imported by (${r.importedBy.length}):`); + for (const i of r.importedBy) console.log(` <- ${i.file}`); + if (r.definitions.length > 0) { + console.log(`\n Definitions (${r.definitions.length}):`); + for (const d of r.definitions.slice(0, 30)) + console.log(` ${kindIcon(d.kind)} ${d.name} :${d.line}`); + if (r.definitions.length > 30) console.log(` ... and ${r.definitions.length - 30} more`); + } + console.log(); + } +} + +export function fnDeps(name, customDbPath, opts = {}) { + const data = fnDepsData(name, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No function/method/class matching "${name}"`); + return; + } + + for (const r of data.results) { + console.log(`\n${kindIcon(r.kind)} ${r.name} (${r.kind}) -- ${r.file}:${r.line}\n`); + if (r.callees.length > 0) { + console.log(` -> Calls (${r.callees.length}):`); + for (const c of r.callees) + console.log(` -> ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); + } + if (r.callers.length > 0) { + console.log(`\n <- Called by (${r.callers.length}):`); + for (const c of r.callers) { + const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : ''; + console.log(` <- ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`); + } + } + for (const [d, fns] of Object.entries(r.transitiveCallers)) { + console.log( + `\n ${'<-'.repeat(parseInt(d, 10))} Transitive callers (depth ${d}, ${fns.length}):`, + ); + for (const n of fns.slice(0, 20)) + console.log( + ` ${' '.repeat(parseInt(d, 10) - 1)}<- ${kindIcon(n.kind)} ${n.name} ${n.file}:${n.line}`, + ); + if (fns.length > 20) console.log(` ... and ${fns.length - 20} more`); + } + if (r.callees.length === 0 && r.callers.length === 0) { + console.log(` (no call edges found -- may be invoked dynamically or via re-exports)`); + } + console.log(); + } +} + +export function impactAnalysis(file, customDbPath, opts = {}) { + const data = impactAnalysisData(file, customDbPath, opts); + if (outputResult(data, 'sources', opts)) return; + + if (data.sources.length === 0) { + console.log(`No file matching "${file}" in graph`); + return; + } + + console.log(`\nImpact analysis for files matching "${file}":\n`); + for (const s of data.sources) console.log(` # ${s} (source)`); + + const levels = data.levels; + if (Object.keys(levels).length === 0) { + console.log(` No dependents found.`); + } else { + for (const level of Object.keys(levels).sort((a, b) => a - b)) { + const nodes = levels[level]; + console.log( + `\n ${'--'.repeat(parseInt(level, 10))} Level ${level} (${nodes.length} files):`, + ); + for (const n of nodes.slice(0, 30)) + console.log(` ${' '.repeat(parseInt(level, 10))}^ ${n.file}`); + if (nodes.length > 30) console.log(` ... and ${nodes.length - 30} more`); + } + } + console.log(`\n Total: ${data.totalDependents} files transitively depend on "${file}"\n`); +} + +export function fnImpact(name, customDbPath, opts = {}) { + const data = fnImpactData(name, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No function/method/class matching "${name}"`); + return; + } + + for (const r of data.results) { + console.log(`\nFunction impact: ${kindIcon(r.kind)} ${r.name} -- ${r.file}:${r.line}\n`); + if (Object.keys(r.levels).length === 0) { + console.log(` No callers found.`); + } else { + for (const [level, fns] of Object.entries(r.levels).sort((a, b) => a[0] - b[0])) { + const l = parseInt(level, 10); + console.log(` ${'--'.repeat(l)} Level ${level} (${fns.length} functions):`); + for (const f of fns.slice(0, 20)) + console.log(` ${' '.repeat(l)}^ ${kindIcon(f.kind)} ${f.name} ${f.file}:${f.line}`); + if (fns.length > 20) console.log(` ... and ${fns.length - 20} more`); + } + } + console.log(`\n Total: ${r.totalDependents} functions transitively depend on ${r.name}\n`); + } +} + +export function diffImpact(customDbPath, opts = {}) { + if (opts.format === 'mermaid') { + console.log(diffImpactMermaid(customDbPath, opts)); + return; + } + const data = diffImpactData(customDbPath, opts); + if (opts.format === 'json') opts = { ...opts, json: true }; + if (outputResult(data, 'affectedFunctions', opts)) return; + + if (data.error) { + console.log(data.error); + return; + } + if (data.changedFiles === 0) { + console.log('No changes detected.'); + return; + } + if (data.affectedFunctions.length === 0) { + console.log( + ' No function-level changes detected (changes may be in imports, types, or config).', + ); + return; + } + + console.log(`\ndiff-impact: ${data.changedFiles} files changed\n`); + console.log(` ${data.affectedFunctions.length} functions changed:\n`); + for (const fn of data.affectedFunctions) { + console.log(` ${kindIcon(fn.kind)} ${fn.name} -- ${fn.file}:${fn.line}`); + if (fn.transitiveCallers > 0) console.log(` ^ ${fn.transitiveCallers} transitive callers`); + } + if (data.historicallyCoupled && data.historicallyCoupled.length > 0) { + console.log('\n Historically coupled (not in static graph):\n'); + for (const c of data.historicallyCoupled) { + const pct = `${(c.jaccard * 100).toFixed(0)}%`; + console.log( + ` ${c.file} <- coupled with ${c.coupledWith} (${pct}, ${c.commitCount} commits)`, + ); + } + } + if (data.ownership) { + console.log(`\n Affected owners: ${data.ownership.affectedOwners.join(', ')}`); + console.log(` Suggested reviewers: ${data.ownership.suggestedReviewers.join(', ')}`); + } + if (data.boundaryViolations && data.boundaryViolations.length > 0) { + console.log(`\n Boundary violations (${data.boundaryViolationCount}):\n`); + for (const v of data.boundaryViolations) { + console.log(` [${v.name}] ${v.file} -> ${v.targetFile}`); + if (v.message) console.log(` ${v.message}`); + } + } + if (data.summary) { + let summaryLine = `\n Summary: ${data.summary.functionsChanged} functions changed -> ${data.summary.callersAffected} callers affected across ${data.summary.filesAffected} files`; + if (data.summary.historicallyCoupledCount > 0) { + summaryLine += `, ${data.summary.historicallyCoupledCount} historically coupled`; + } + if (data.summary.ownersAffected > 0) { + summaryLine += `, ${data.summary.ownersAffected} owners affected`; + } + if (data.summary.boundaryViolationCount > 0) { + summaryLine += `, ${data.summary.boundaryViolationCount} boundary violations`; + } + console.log(`${summaryLine}\n`); + } +} diff --git a/src/presentation/queries-cli/index.js b/src/presentation/queries-cli/index.js new file mode 100644 index 00000000..40aae323 --- /dev/null +++ b/src/presentation/queries-cli/index.js @@ -0,0 +1,5 @@ +export { fileExports } from './exports.js'; +export { diffImpact, fileDeps, fnDeps, fnImpact, impactAnalysis } from './impact.js'; +export { children, context, explain, queryName, where } from './inspect.js'; +export { moduleMap, roles, stats } from './overview.js'; +export { symbolPath } from './path.js'; diff --git a/src/presentation/queries-cli/inspect.js b/src/presentation/queries-cli/inspect.js new file mode 100644 index 00000000..5a3ddcb7 --- /dev/null +++ b/src/presentation/queries-cli/inspect.js @@ -0,0 +1,334 @@ +import { + childrenData, + contextData, + explainData, + kindIcon, + queryNameData, + whereData, +} from '../../domain/queries.js'; +import { outputResult } from '../../infrastructure/result-formatter.js'; + +export function where(target, customDbPath, opts = {}) { + const data = whereData(target, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log( + data.mode === 'file' + ? `No file matching "${target}" in graph` + : `No symbol matching "${target}" in graph`, + ); + return; + } + + if (data.mode === 'symbol') { + for (const r of data.results) { + const roleTag = r.role ? ` [${r.role}]` : ''; + const tag = r.exported ? ' (exported)' : ''; + console.log(`\n${kindIcon(r.kind)} ${r.name}${roleTag} ${r.file}:${r.line}${tag}`); + if (r.uses.length > 0) { + const useStrs = r.uses.map((u) => `${u.file}:${u.line}`); + console.log(` Used in: ${useStrs.join(', ')}`); + } else { + console.log(' No uses found'); + } + } + } else { + for (const r of data.results) { + console.log(`\n# ${r.file}`); + if (r.symbols.length > 0) { + const symStrs = r.symbols.map((s) => `${s.name}:${s.line}`); + console.log(` Symbols: ${symStrs.join(', ')}`); + } + if (r.imports.length > 0) { + console.log(` Imports: ${r.imports.join(', ')}`); + } + if (r.importedBy.length > 0) { + console.log(` Imported by: ${r.importedBy.join(', ')}`); + } + if (r.exported.length > 0) { + console.log(` Exported: ${r.exported.join(', ')}`); + } + } + } + console.log(); +} + +export function queryName(name, customDbPath, opts = {}) { + const data = queryNameData(name, customDbPath, { + noTests: opts.noTests, + limit: opts.limit, + offset: opts.offset, + }); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No results for "${name}"`); + return; + } + + console.log(`\nResults for "${name}":\n`); + for (const r of data.results) { + console.log(` ${kindIcon(r.kind)} ${r.name} (${r.kind}) -- ${r.file}:${r.line}`); + if (r.callees.length > 0) { + console.log(` -> calls/uses:`); + for (const c of r.callees.slice(0, 15)) + console.log(` -> ${c.name} (${c.edgeKind}) ${c.file}:${c.line}`); + if (r.callees.length > 15) console.log(` ... and ${r.callees.length - 15} more`); + } + if (r.callers.length > 0) { + console.log(` <- called by:`); + for (const c of r.callers.slice(0, 15)) + console.log(` <- ${c.name} (${c.edgeKind}) ${c.file}:${c.line}`); + if (r.callers.length > 15) console.log(` ... and ${r.callers.length - 15} more`); + } + console.log(); + } +} + +export function context(name, customDbPath, opts = {}) { + const data = contextData(name, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No function/method/class matching "${name}"`); + return; + } + + for (const r of data.results) { + const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`; + const roleTag = r.role ? ` [${r.role}]` : ''; + console.log(`\n# ${r.name} (${r.kind})${roleTag} — ${r.file}:${lineRange}\n`); + + // Signature + if (r.signature) { + console.log('## Type/Shape Info'); + if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`); + if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`); + console.log(); + } + + // Children + if (r.children && r.children.length > 0) { + console.log(`## Children (${r.children.length})`); + for (const c of r.children) { + console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`); + } + console.log(); + } + + // Complexity + if (r.complexity) { + const cx = r.complexity; + const miPart = cx.maintainabilityIndex ? ` | MI: ${cx.maintainabilityIndex}` : ''; + console.log('## Complexity'); + console.log( + ` Cognitive: ${cx.cognitive} | Cyclomatic: ${cx.cyclomatic} | Max Nesting: ${cx.maxNesting}${miPart}`, + ); + console.log(); + } + + // Source + if (r.source) { + console.log('## Source'); + for (const line of r.source.split('\n')) { + console.log(` ${line}`); + } + console.log(); + } + + // Callees + if (r.callees.length > 0) { + console.log(`## Direct Dependencies (${r.callees.length})`); + for (const c of r.callees) { + const summary = c.summary ? ` — ${c.summary}` : ''; + console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${summary}`); + if (c.source) { + for (const line of c.source.split('\n').slice(0, 10)) { + console.log(` | ${line}`); + } + } + } + console.log(); + } + + // Callers + if (r.callers.length > 0) { + console.log(`## Callers (${r.callers.length})`); + for (const c of r.callers) { + const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : ''; + console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`); + } + console.log(); + } + + // Related tests + if (r.relatedTests.length > 0) { + console.log('## Related Tests'); + for (const t of r.relatedTests) { + console.log(` ${t.file} — ${t.testCount} tests`); + for (const tn of t.testNames) { + console.log(` - ${tn}`); + } + if (t.source) { + console.log(' Source:'); + for (const line of t.source.split('\n').slice(0, 20)) { + console.log(` | ${line}`); + } + } + } + console.log(); + } + + if (r.callees.length === 0 && r.callers.length === 0 && r.relatedTests.length === 0) { + console.log( + ' (no call edges or tests found — may be invoked dynamically or via re-exports)', + ); + console.log(); + } + } +} + +export function children(name, customDbPath, opts = {}) { + const data = childrenData(name, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No symbol matching "${name}"`); + return; + } + for (const r of data.results) { + console.log(`\n${kindIcon(r.kind)} ${r.name} ${r.file}:${r.line}`); + if (r.children.length === 0) { + console.log(' (no children)'); + } else { + for (const c of r.children) { + console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`); + } + } + } +} + +export function explain(target, customDbPath, opts = {}) { + const data = explainData(target, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`); + return; + } + + if (data.kind === 'file') { + for (const r of data.results) { + const publicCount = r.publicApi.length; + const internalCount = r.internal.length; + const lineInfo = r.lineCount ? `${r.lineCount} lines, ` : ''; + console.log(`\n# ${r.file}`); + console.log( + ` ${lineInfo}${r.symbolCount} symbols (${publicCount} exported, ${internalCount} internal)`, + ); + + if (r.imports.length > 0) { + console.log(` Imports: ${r.imports.map((i) => i.file).join(', ')}`); + } + if (r.importedBy.length > 0) { + console.log(` Imported by: ${r.importedBy.map((i) => i.file).join(', ')}`); + } + + if (r.publicApi.length > 0) { + console.log(`\n## Exported`); + for (const s of r.publicApi) { + const sig = s.signature?.params != null ? `(${s.signature.params})` : ''; + const roleTag = s.role ? ` [${s.role}]` : ''; + const summary = s.summary ? ` -- ${s.summary}` : ''; + console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`); + } + } + + if (r.internal.length > 0) { + console.log(`\n## Internal`); + for (const s of r.internal) { + const sig = s.signature?.params != null ? `(${s.signature.params})` : ''; + const roleTag = s.role ? ` [${s.role}]` : ''; + const summary = s.summary ? ` -- ${s.summary}` : ''; + console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`); + } + } + + if (r.dataFlow.length > 0) { + console.log(`\n## Data Flow`); + for (const df of r.dataFlow) { + console.log(` ${df.caller} -> ${df.callees.join(', ')}`); + } + } + console.log(); + } + } else { + function printFunctionExplain(r, indent = '') { + const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`; + const lineInfo = r.lineCount ? `${r.lineCount} lines` : ''; + const summaryPart = r.summary ? ` | ${r.summary}` : ''; + const roleTag = r.role ? ` [${r.role}]` : ''; + const depthLevel = r._depth || 0; + const heading = depthLevel === 0 ? '#' : '##'.padEnd(depthLevel + 2, '#'); + console.log(`\n${indent}${heading} ${r.name} (${r.kind})${roleTag} ${r.file}:${lineRange}`); + if (lineInfo || r.summary) { + console.log(`${indent} ${lineInfo}${summaryPart}`); + } + if (r.signature) { + if (r.signature.params != null) + console.log(`${indent} Parameters: (${r.signature.params})`); + if (r.signature.returnType) console.log(`${indent} Returns: ${r.signature.returnType}`); + } + + if (r.complexity) { + const cx = r.complexity; + const miPart = cx.maintainabilityIndex ? ` MI=${cx.maintainabilityIndex}` : ''; + console.log( + `${indent} Complexity: cognitive=${cx.cognitive} cyclomatic=${cx.cyclomatic} nesting=${cx.maxNesting}${miPart}`, + ); + } + + if (r.callees.length > 0) { + console.log(`\n${indent} Calls (${r.callees.length}):`); + for (const c of r.callees) { + console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); + } + } + + if (r.callers.length > 0) { + console.log(`\n${indent} Called by (${r.callers.length}):`); + for (const c of r.callers) { + console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); + } + } + + if (r.relatedTests.length > 0) { + const label = r.relatedTests.length === 1 ? 'file' : 'files'; + console.log(`\n${indent} Tests (${r.relatedTests.length} ${label}):`); + for (const t of r.relatedTests) { + console.log(`${indent} ${t.file}`); + } + } + + if (r.callees.length === 0 && r.callers.length === 0) { + console.log( + `${indent} (no call edges found -- may be invoked dynamically or via re-exports)`, + ); + } + + // Render recursive dependency details + if (r.depDetails && r.depDetails.length > 0) { + console.log(`\n${indent} --- Dependencies (depth ${depthLevel + 1}) ---`); + for (const dep of r.depDetails) { + printFunctionExplain(dep, `${indent} `); + } + } + console.log(); + } + + for (const r of data.results) { + printFunctionExplain(r); + } + } +} diff --git a/src/presentation/queries-cli/overview.js b/src/presentation/queries-cli/overview.js new file mode 100644 index 00000000..62593c68 --- /dev/null +++ b/src/presentation/queries-cli/overview.js @@ -0,0 +1,197 @@ +import path from 'node:path'; +import { kindIcon, moduleMapData, rolesData, statsData } from '../../domain/queries.js'; +import { outputResult } from '../../infrastructure/result-formatter.js'; + +export async function stats(customDbPath, opts = {}) { + const data = statsData(customDbPath, { noTests: opts.noTests }); + + // Community detection summary (async import for lazy-loading) + try { + const { communitySummaryForStats } = await import('../communities.js'); + data.communities = communitySummaryForStats(customDbPath, { noTests: opts.noTests }); + } catch { + /* graphology may not be available */ + } + + if (outputResult(data, null, opts)) return; + + // Human-readable output + console.log('\n# Codegraph Stats\n'); + + // Nodes + console.log(`Nodes: ${data.nodes.total} total`); + const kindEntries = Object.entries(data.nodes.byKind).sort((a, b) => b[1] - a[1]); + const kindParts = kindEntries.map(([k, v]) => `${k} ${v}`); + for (let i = 0; i < kindParts.length; i += 3) { + const row = kindParts + .slice(i, i + 3) + .map((p) => p.padEnd(18)) + .join(''); + console.log(` ${row}`); + } + + // Edges + console.log(`\nEdges: ${data.edges.total} total`); + const edgeEntries = Object.entries(data.edges.byKind).sort((a, b) => b[1] - a[1]); + const edgeParts = edgeEntries.map(([k, v]) => `${k} ${v}`); + for (let i = 0; i < edgeParts.length; i += 3) { + const row = edgeParts + .slice(i, i + 3) + .map((p) => p.padEnd(18)) + .join(''); + console.log(` ${row}`); + } + + // Files + console.log(`\nFiles: ${data.files.total} (${data.files.languages} languages)`); + const langEntries = Object.entries(data.files.byLanguage).sort((a, b) => b[1] - a[1]); + const langParts = langEntries.map(([k, v]) => `${k} ${v}`); + for (let i = 0; i < langParts.length; i += 3) { + const row = langParts + .slice(i, i + 3) + .map((p) => p.padEnd(18)) + .join(''); + console.log(` ${row}`); + } + + // Cycles + console.log( + `\nCycles: ${data.cycles.fileLevel} file-level, ${data.cycles.functionLevel} function-level`, + ); + + // Hotspots + if (data.hotspots.length > 0) { + console.log(`\nTop ${data.hotspots.length} coupling hotspots:`); + for (let i = 0; i < data.hotspots.length; i++) { + const h = data.hotspots[i]; + console.log( + ` ${String(i + 1).padStart(2)}. ${h.file.padEnd(35)} fan-in: ${String(h.fanIn).padStart(3)} fan-out: ${String(h.fanOut).padStart(3)}`, + ); + } + } + + // Embeddings + if (data.embeddings) { + const e = data.embeddings; + console.log( + `\nEmbeddings: ${e.count} vectors (${e.model || 'unknown'}, ${e.dim || '?'}d) built ${e.builtAt || 'unknown'}`, + ); + } else { + console.log('\nEmbeddings: not built'); + } + + // Quality + if (data.quality) { + const q = data.quality; + const cc = q.callerCoverage; + const cf = q.callConfidence; + console.log(`\nGraph Quality: ${q.score}/100`); + console.log( + ` Caller coverage: ${(cc.ratio * 100).toFixed(1)}% (${cc.covered}/${cc.total} functions have >=1 caller)`, + ); + console.log( + ` Call confidence: ${(cf.ratio * 100).toFixed(1)}% (${cf.highConf}/${cf.total} call edges are high-confidence)`, + ); + if (q.falsePositiveWarnings.length > 0) { + console.log(' False-positive warnings:'); + for (const fp of q.falsePositiveWarnings) { + console.log(` ! ${fp.name} (${fp.callerCount} callers) -- ${fp.file}:${fp.line}`); + } + } + } + + // Roles + if (data.roles && Object.keys(data.roles).length > 0) { + const total = Object.values(data.roles).reduce((a, b) => a + b, 0); + console.log(`\nRoles: ${total} classified symbols`); + const roleParts = Object.entries(data.roles) + .sort((a, b) => b[1] - a[1]) + .map(([k, v]) => `${k} ${v}`); + for (let i = 0; i < roleParts.length; i += 3) { + const row = roleParts + .slice(i, i + 3) + .map((p) => p.padEnd(18)) + .join(''); + console.log(` ${row}`); + } + } + + // Complexity + if (data.complexity) { + const cx = data.complexity; + const miPart = cx.avgMI != null ? ` | avg MI: ${cx.avgMI} | min MI: ${cx.minMI}` : ''; + console.log( + `\nComplexity: ${cx.analyzed} functions | avg cognitive: ${cx.avgCognitive} | avg cyclomatic: ${cx.avgCyclomatic} | max cognitive: ${cx.maxCognitive}${miPart}`, + ); + } + + // Communities + if (data.communities) { + const cm = data.communities; + console.log( + `\nCommunities: ${cm.communityCount} detected | modularity: ${cm.modularity} | drift: ${cm.driftScore}%`, + ); + } + + console.log(); +} + +export function moduleMap(customDbPath, limit = 20, opts = {}) { + const data = moduleMapData(customDbPath, limit, { noTests: opts.noTests }); + if (outputResult(data, 'topNodes', opts)) return; + + console.log(`\nModule map (top ${limit} most-connected nodes):\n`); + const dirs = new Map(); + for (const n of data.topNodes) { + if (!dirs.has(n.dir)) dirs.set(n.dir, []); + dirs.get(n.dir).push(n); + } + for (const [dir, files] of [...dirs].sort()) { + console.log(` [${dir}/]`); + for (const f of files) { + const coupling = f.inEdges + f.outEdges; + const bar = '#'.repeat(Math.min(coupling, 40)); + console.log( + ` ${path.basename(f.file).padEnd(35)} <-${String(f.inEdges).padStart(3)} ->${String(f.outEdges).padStart(3)} =${String(coupling).padStart(3)} ${bar}`, + ); + } + } + console.log( + `\n Total: ${data.stats.totalFiles} files, ${data.stats.totalNodes} symbols, ${data.stats.totalEdges} edges\n`, + ); +} + +export function roles(customDbPath, opts = {}) { + const data = rolesData(customDbPath, opts); + if (outputResult(data, 'symbols', opts)) return; + + if (data.count === 0) { + console.log('No classified symbols found. Run "codegraph build" first.'); + return; + } + + const total = data.count; + console.log(`\nNode roles (${total} symbols):\n`); + + const summaryParts = Object.entries(data.summary) + .sort((a, b) => b[1] - a[1]) + .map(([role, count]) => `${role}: ${count}`); + console.log(` ${summaryParts.join(' ')}\n`); + + const byRole = {}; + for (const s of data.symbols) { + if (!byRole[s.role]) byRole[s.role] = []; + byRole[s.role].push(s); + } + + for (const [role, symbols] of Object.entries(byRole)) { + console.log(`## ${role} (${symbols.length})`); + for (const s of symbols.slice(0, 30)) { + console.log(` ${kindIcon(s.kind)} ${s.name} ${s.file}:${s.line}`); + } + if (symbols.length > 30) { + console.log(` ... and ${symbols.length - 30} more`); + } + console.log(); + } +} diff --git a/src/presentation/queries-cli/path.js b/src/presentation/queries-cli/path.js new file mode 100644 index 00000000..fbdaafa5 --- /dev/null +++ b/src/presentation/queries-cli/path.js @@ -0,0 +1,58 @@ +import { kindIcon, pathData } from '../../domain/queries.js'; +import { outputResult } from '../../infrastructure/result-formatter.js'; + +export function symbolPath(from, to, customDbPath, opts = {}) { + const data = pathData(from, to, customDbPath, opts); + if (outputResult(data, null, opts)) return; + + if (data.error) { + console.log(data.error); + return; + } + + if (!data.found) { + const dir = data.reverse ? 'reverse ' : ''; + console.log(`No ${dir}path from "${from}" to "${to}" within ${data.maxDepth} hops.`); + if (data.fromCandidates.length > 1) { + console.log( + `\n "${from}" matched ${data.fromCandidates.length} symbols — using top match: ${data.fromCandidates[0].name} (${data.fromCandidates[0].file}:${data.fromCandidates[0].line})`, + ); + } + if (data.toCandidates.length > 1) { + console.log( + ` "${to}" matched ${data.toCandidates.length} symbols — using top match: ${data.toCandidates[0].name} (${data.toCandidates[0].file}:${data.toCandidates[0].line})`, + ); + } + return; + } + + if (data.hops === 0) { + console.log(`\n"${from}" and "${to}" resolve to the same symbol (0 hops):`); + const n = data.path[0]; + console.log(` ${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}\n`); + return; + } + + const dir = data.reverse ? ' (reverse)' : ''; + console.log( + `\nPath from ${from} to ${to} (${data.hops} ${data.hops === 1 ? 'hop' : 'hops'})${dir}:\n`, + ); + for (let i = 0; i < data.path.length; i++) { + const n = data.path[i]; + const indent = ' '.repeat(i + 1); + if (i === 0) { + console.log(`${indent}${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}`); + } else { + console.log( + `${indent}--[${n.edgeKind}]--> ${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}`, + ); + } + } + + if (data.alternateCount > 0) { + console.log( + `\n (${data.alternateCount} alternate shortest ${data.alternateCount === 1 ? 'path' : 'paths'} at same depth)`, + ); + } + console.log(); +} diff --git a/src/commands/query.js b/src/presentation/query.js similarity index 88% rename from src/commands/query.js rename to src/presentation/query.js index 471e45c0..72a5bef0 100644 --- a/src/commands/query.js +++ b/src/presentation/query.js @@ -18,4 +18,4 @@ export { stats, symbolPath, where, -} from '../presentation/queries-cli.js'; +} from './queries-cli/index.js'; diff --git a/src/commands/sequence.js b/src/presentation/sequence.js similarity index 100% rename from src/commands/sequence.js rename to src/presentation/sequence.js diff --git a/src/commands/structure.js b/src/presentation/structure.js similarity index 100% rename from src/commands/structure.js rename to src/presentation/structure.js diff --git a/src/commands/triage.js b/src/presentation/triage.js similarity index 100% rename from src/commands/triage.js rename to src/presentation/triage.js diff --git a/tests/graph/viewer.test.js b/tests/graph/viewer.test.js index 95a67190..9347833f 100644 --- a/tests/graph/viewer.test.js +++ b/tests/graph/viewer.test.js @@ -5,7 +5,11 @@ import Database from 'better-sqlite3'; import { describe, expect, it } from 'vitest'; import { initSchema } from '../../src/db/index.js'; -import { generatePlotHTML, loadPlotConfig, prepareGraphData } from '../../src/features/viewer.js'; +import { + generatePlotHTML, + loadPlotConfig, + prepareGraphData, +} from '../../src/features/graph-enrichment.js'; function createTestDb() { const db = new Database(':memory:'); From 175607f243d84449abbae94e44cb08952548887d Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 03:17:39 -0600 Subject: [PATCH 4/7] refactor: resolve three architecture issues after #456 and #458 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename features/viewer.js → features/graph-enrichment.js to resolve naming collision with presentation/viewer.js (Issue 3) - Move 16 CLI formatter files from commands/ → presentation/ where they belong as presentation concerns, delete commands/ directory (Issue 1) - Split 866-LOC presentation/queries-cli.js into thematic modules: path.js, overview.js, inspect.js, impact.js, exports.js (Issue 2) - Fix broken import in cli/commands/plot.js (was referencing deleted src/viewer.js, now correctly points to features/graph-enrichment.js) - Fix wrong relative import in presentation/query.js - Update CLAUDE.md architecture table to reflect new structure Impact: 54 functions changed, 19 affected --- CLAUDE.md | 10 +- src/cli/commands/audit.js | 2 +- src/cli/commands/batch.js | 2 +- src/cli/commands/branch-compare.js | 2 +- src/cli/commands/cfg.js | 2 +- src/cli/commands/check.js | 6 +- src/cli/commands/co-change.js | 2 +- src/cli/commands/communities.js | 2 +- src/cli/commands/complexity.js | 2 +- src/cli/commands/dataflow.js | 2 +- src/cli/commands/flow.js | 2 +- src/cli/commands/owners.js | 2 +- src/cli/commands/plot.js | 2 +- src/cli/commands/sequence.js | 2 +- src/cli/commands/structure.js | 2 +- src/cli/commands/triage.js | 4 +- src/features/graph-enrichment.js | 327 +++++++++ src/presentation/audit.js | 88 +++ src/presentation/batch.js | 26 + src/presentation/branch-compare.js | 97 +++ src/presentation/cfg.js | 55 ++ src/presentation/check.js | 82 +++ src/presentation/cochange.js | 37 + src/presentation/communities.js | 69 ++ src/presentation/complexity.js | 77 ++ src/presentation/dataflow.js | 110 +++ src/presentation/flow.js | 70 ++ src/presentation/manifesto.js | 77 ++ src/presentation/owners.js | 52 ++ src/presentation/queries-cli.js | 887 +---------------------- src/presentation/queries-cli/exports.js | 46 ++ src/presentation/queries-cli/impact.js | 198 +++++ src/presentation/queries-cli/index.js | 5 + src/presentation/queries-cli/inspect.js | 334 +++++++++ src/presentation/queries-cli/overview.js | 197 +++++ src/presentation/queries-cli/path.js | 58 ++ src/presentation/query.js | 21 + src/presentation/sequence.js | 33 + src/presentation/structure.js | 64 ++ src/presentation/triage.js | 49 ++ tests/graph/viewer.test.js | 6 +- 41 files changed, 2227 insertions(+), 884 deletions(-) create mode 100644 src/features/graph-enrichment.js create mode 100644 src/presentation/audit.js create mode 100644 src/presentation/batch.js create mode 100644 src/presentation/branch-compare.js create mode 100644 src/presentation/cfg.js create mode 100644 src/presentation/check.js create mode 100644 src/presentation/cochange.js create mode 100644 src/presentation/communities.js create mode 100644 src/presentation/complexity.js create mode 100644 src/presentation/dataflow.js create mode 100644 src/presentation/flow.js create mode 100644 src/presentation/manifesto.js create mode 100644 src/presentation/owners.js create mode 100644 src/presentation/queries-cli/exports.js create mode 100644 src/presentation/queries-cli/impact.js create mode 100644 src/presentation/queries-cli/index.js create mode 100644 src/presentation/queries-cli/inspect.js create mode 100644 src/presentation/queries-cli/overview.js create mode 100644 src/presentation/queries-cli/path.js create mode 100644 src/presentation/query.js create mode 100644 src/presentation/sequence.js create mode 100644 src/presentation/structure.js create mode 100644 src/presentation/triage.js diff --git a/CLAUDE.md b/CLAUDE.md index 512162b0..df3cdfe4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,8 +82,14 @@ JS source is plain JavaScript (ES modules) in `src/`. No transpilation step. The | `features/snapshot.js` | SQLite DB backup and restore | | `features/structure.js` | Codebase structure analysis | | `features/triage.js` | Risk-ranked audit priority queue (delegates scoring to `graph/classifiers/`) | -| **`presentation/`** | **Pure output formatting** | -| `presentation/` | `viewer.js` (HTML renderer), `export.js` (DOT/Mermaid/GraphML/Neo4j serializers), `sequence-renderer.js` (Mermaid sequence diagrams), `table.js` (CLI table formatting), `result-formatter.js` (JSON/NDJSON output) | +| `features/graph-enrichment.js` | Data enrichment for HTML viewer (complexity, communities, fan-in/out) | +| **`presentation/`** | **Pure output formatting + CLI command wrappers** | +| `presentation/viewer.js` | Interactive HTML renderer with vis-network | +| `presentation/queries-cli/` | CLI display wrappers for query functions, split by concern: `path.js`, `overview.js`, `inspect.js`, `impact.js`, `exports.js` | +| `presentation/*.js` | Command formatters (audit, batch, check, communities, complexity, etc.) — call `features/*.js`, format output, set exit codes | +| `presentation/export.js` | DOT/Mermaid/GraphML/Neo4j serializers | +| `presentation/sequence-renderer.js` | Mermaid sequence diagram rendering | +| `presentation/table.js`, `result-formatter.js`, `colors.js` | CLI table formatting, JSON/NDJSON output, color constants | | **`graph/`** | **Unified graph model** | | `graph/` | `CodeGraph` class (`model.js`), algorithms (Tarjan SCC, Louvain, BFS, shortest path, centrality), classifiers (role, risk), builders (dependency, structure, temporal) | | **`mcp/`** | **MCP server** | diff --git a/src/cli/commands/audit.js b/src/cli/commands/audit.js index caa0b747..bf542b7c 100644 --- a/src/cli/commands/audit.js +++ b/src/cli/commands/audit.js @@ -1,5 +1,5 @@ -import { audit } from '../../commands/audit.js'; import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; +import { audit } from '../../presentation/audit.js'; import { explain } from '../../presentation/queries-cli.js'; export const command = { diff --git a/src/cli/commands/batch.js b/src/cli/commands/batch.js index 5d740ad1..0beda9ce 100644 --- a/src/cli/commands/batch.js +++ b/src/cli/commands/batch.js @@ -1,7 +1,7 @@ import fs from 'node:fs'; -import { batch } from '../../commands/batch.js'; import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; import { BATCH_COMMANDS, multiBatchData, splitTargets } from '../../features/batch.js'; +import { batch } from '../../presentation/batch.js'; import { ConfigError } from '../../shared/errors.js'; export const command = { diff --git a/src/cli/commands/branch-compare.js b/src/cli/commands/branch-compare.js index e674d835..45699c04 100644 --- a/src/cli/commands/branch-compare.js +++ b/src/cli/commands/branch-compare.js @@ -9,7 +9,7 @@ export const command = { ['-f, --format ', 'Output format: text, mermaid, json', 'text'], ], async execute([base, target], opts, ctx) { - const { branchCompare } = await import('../../commands/branch-compare.js'); + const { branchCompare } = await import('../../presentation/branch-compare.js'); await branchCompare(base, target, { engine: ctx.program.opts().engine, depth: parseInt(opts.depth, 10), diff --git a/src/cli/commands/cfg.js b/src/cli/commands/cfg.js index 20ef05bb..0ab00544 100644 --- a/src/cli/commands/cfg.js +++ b/src/cli/commands/cfg.js @@ -15,7 +15,7 @@ export const command = { } }, async execute([name], opts, ctx) { - const { cfg } = await import('../../commands/cfg.js'); + const { cfg } = await import('../../presentation/cfg.js'); cfg(name, opts.db, { format: opts.format, file: opts.file, diff --git a/src/cli/commands/check.js b/src/cli/commands/check.js index 8c5f29ca..cb2e16d9 100644 --- a/src/cli/commands/check.js +++ b/src/cli/commands/check.js @@ -32,7 +32,7 @@ export const command = { `Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`, ); } - const { manifesto } = await import('../../commands/manifesto.js'); + const { manifesto } = await import('../../presentation/manifesto.js'); manifesto(opts.db, { file: opts.file, kind: opts.kind, @@ -45,7 +45,7 @@ export const command = { return; } - const { check } = await import('../../commands/check.js'); + const { check } = await import('../../presentation/check.js'); check(opts.db, { ref, staged: opts.staged, @@ -64,7 +64,7 @@ export const command = { `Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`, ); } - const { manifesto } = await import('../../commands/manifesto.js'); + const { manifesto } = await import('../../presentation/manifesto.js'); manifesto(opts.db, { file: opts.file, kind: opts.kind, diff --git a/src/cli/commands/co-change.js b/src/cli/commands/co-change.js index 83b29e75..55c6c99f 100644 --- a/src/cli/commands/co-change.js +++ b/src/cli/commands/co-change.js @@ -20,7 +20,7 @@ export const command = { ], async execute([file], opts, ctx) { const { analyzeCoChanges, coChangeData, coChangeTopData } = await import('../../cochange.js'); - const { formatCoChange, formatCoChangeTop } = await import('../../commands/cochange.js'); + const { formatCoChange, formatCoChangeTop } = await import('../../presentation/cochange.js'); if (opts.analyze) { const result = analyzeCoChanges(opts.db, { diff --git a/src/cli/commands/communities.js b/src/cli/commands/communities.js index 42312b06..0fa07d28 100644 --- a/src/cli/commands/communities.js +++ b/src/cli/commands/communities.js @@ -8,7 +8,7 @@ export const command = { ['--drift', 'Show only drift analysis'], ], async execute(_args, opts, ctx) { - const { communities } = await import('../../commands/communities.js'); + const { communities } = await import('../../presentation/communities.js'); communities(opts.db, { functions: opts.functions, resolution: parseFloat(opts.resolution), diff --git a/src/cli/commands/complexity.js b/src/cli/commands/complexity.js index 193271d3..cf9ab4c4 100644 --- a/src/cli/commands/complexity.js +++ b/src/cli/commands/complexity.js @@ -27,7 +27,7 @@ export const command = { } }, async execute([target], opts, ctx) { - const { complexity } = await import('../../commands/complexity.js'); + const { complexity } = await import('../../presentation/complexity.js'); complexity(opts.db, { target, limit: parseInt(opts.limit, 10), diff --git a/src/cli/commands/dataflow.js b/src/cli/commands/dataflow.js index 4118b5b3..c32dde63 100644 --- a/src/cli/commands/dataflow.js +++ b/src/cli/commands/dataflow.js @@ -16,7 +16,7 @@ export const command = { } }, async execute([name], opts, ctx) { - const { dataflow } = await import('../../commands/dataflow.js'); + const { dataflow } = await import('../../presentation/dataflow.js'); dataflow(name, opts.db, { file: opts.file, kind: opts.kind, diff --git a/src/cli/commands/flow.js b/src/cli/commands/flow.js index 20814a3c..90c2c424 100644 --- a/src/cli/commands/flow.js +++ b/src/cli/commands/flow.js @@ -20,7 +20,7 @@ export const command = { } }, async execute([name], opts, ctx) { - const { flow } = await import('../../commands/flow.js'); + const { flow } = await import('../../presentation/flow.js'); flow(name, opts.db, { list: opts.list, depth: parseInt(opts.depth, 10), diff --git a/src/cli/commands/owners.js b/src/cli/commands/owners.js index 8dbc23a6..fee107fc 100644 --- a/src/cli/commands/owners.js +++ b/src/cli/commands/owners.js @@ -12,7 +12,7 @@ export const command = { ['-j, --json', 'Output as JSON'], ], async execute([target], opts, ctx) { - const { owners } = await import('../../commands/owners.js'); + const { owners } = await import('../../presentation/owners.js'); owners(opts.db, { owner: opts.owner, boundary: opts.boundary, diff --git a/src/cli/commands/plot.js b/src/cli/commands/plot.js index 8dafe532..030d00c8 100644 --- a/src/cli/commands/plot.js +++ b/src/cli/commands/plot.js @@ -22,7 +22,7 @@ export const command = { ['--color-by ', 'Color nodes by: kind | role | community | complexity'], ], async execute(_args, opts, ctx) { - const { generatePlotHTML, loadPlotConfig } = await import('../../viewer.js'); + const { generatePlotHTML, loadPlotConfig } = await import('../../features/graph-enrichment.js'); const os = await import('node:os'); const db = openReadonlyOrFail(opts.db); diff --git a/src/cli/commands/sequence.js b/src/cli/commands/sequence.js index 343a9fac..6daa0a6c 100644 --- a/src/cli/commands/sequence.js +++ b/src/cli/commands/sequence.js @@ -16,7 +16,7 @@ export const command = { } }, async execute([name], opts, ctx) { - const { sequence } = await import('../../commands/sequence.js'); + const { sequence } = await import('../../presentation/sequence.js'); sequence(name, opts.db, { depth: parseInt(opts.depth, 10), file: opts.file, diff --git a/src/cli/commands/structure.js b/src/cli/commands/structure.js index 1e469f54..bc72aa07 100644 --- a/src/cli/commands/structure.js +++ b/src/cli/commands/structure.js @@ -15,7 +15,7 @@ export const command = { ['--ndjson', 'Newline-delimited JSON output'], ], async execute([dir], opts, ctx) { - const { structureData, formatStructure } = await import('../../commands/structure.js'); + const { structureData, formatStructure } = await import('../../presentation/structure.js'); const data = structureData(opts.db, { directory: dir, depth: opts.depth ? parseInt(opts.depth, 10) : undefined, diff --git a/src/cli/commands/triage.js b/src/cli/commands/triage.js index 23e07183..5a8a570f 100644 --- a/src/cli/commands/triage.js +++ b/src/cli/commands/triage.js @@ -31,7 +31,7 @@ export const command = { ], async execute(_args, opts, ctx) { if (opts.level === 'file' || opts.level === 'directory') { - const { hotspotsData, formatHotspots } = await import('../../commands/structure.js'); + const { hotspotsData, formatHotspots } = await import('../../presentation/structure.js'); const metric = opts.sort === 'risk' ? 'fan-in' : opts.sort; const data = hotspotsData(opts.db, { metric, @@ -60,7 +60,7 @@ export const command = { throw new ConfigError('Invalid --weights JSON', { cause: err }); } } - const { triage } = await import('../../commands/triage.js'); + const { triage } = await import('../../presentation/triage.js'); triage(opts.db, { limit: parseInt(opts.limit, 10), offset: opts.offset ? parseInt(opts.offset, 10) : undefined, diff --git a/src/features/graph-enrichment.js b/src/features/graph-enrichment.js new file mode 100644 index 00000000..96e47e2c --- /dev/null +++ b/src/features/graph-enrichment.js @@ -0,0 +1,327 @@ +import path from 'node:path'; +import { louvainCommunities } from '../graph/algorithms/louvain.js'; +import { CodeGraph } from '../graph/model.js'; +import { isTestFile } from '../infrastructure/test-filter.js'; +import { + COMMUNITY_COLORS, + DEFAULT_NODE_COLORS, + DEFAULT_ROLE_COLORS, +} from '../presentation/colors.js'; +import { DEFAULT_CONFIG, renderPlotHTML } from '../presentation/viewer.js'; + +// Re-export presentation utilities for backward compatibility +export { loadPlotConfig } from '../presentation/viewer.js'; + +const DEFAULT_MIN_CONFIDENCE = 0.5; + +// ─── Data Preparation ───────────────────────────────────────────────── + +/** + * Prepare enriched graph data for the HTML viewer. + */ +export function prepareGraphData(db, opts = {}) { + const fileLevel = opts.fileLevel !== false; + const noTests = opts.noTests || false; + const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE; + const cfg = opts.config || DEFAULT_CONFIG; + + return fileLevel + ? prepareFileLevelData(db, noTests, minConf, cfg) + : prepareFunctionLevelData(db, noTests, minConf, cfg); +} + +function prepareFunctionLevelData(db, noTests, minConf, cfg) { + let edges = db + .prepare( + ` + SELECT n1.id AS source_id, n1.name AS source_name, n1.kind AS source_kind, + n1.file AS source_file, n1.line AS source_line, n1.role AS source_role, + n2.id AS target_id, n2.name AS target_name, n2.kind AS target_kind, + n2.file AS target_file, n2.line AS target_line, n2.role AS target_role, + e.kind AS edge_kind + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module') + AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module') + AND e.kind = 'calls' + AND e.confidence >= ? + `, + ) + .all(minConf); + if (noTests) + edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file)); + + if (cfg.filter.kinds) { + const kinds = new Set(cfg.filter.kinds); + edges = edges.filter((e) => kinds.has(e.source_kind) && kinds.has(e.target_kind)); + } + if (cfg.filter.files) { + const patterns = cfg.filter.files; + edges = edges.filter( + (e) => + patterns.some((p) => e.source_file.includes(p)) && + patterns.some((p) => e.target_file.includes(p)), + ); + } + + const nodeMap = new Map(); + for (const e of edges) { + if (!nodeMap.has(e.source_id)) { + nodeMap.set(e.source_id, { + id: e.source_id, + name: e.source_name, + kind: e.source_kind, + file: e.source_file, + line: e.source_line, + role: e.source_role, + }); + } + if (!nodeMap.has(e.target_id)) { + nodeMap.set(e.target_id, { + id: e.target_id, + name: e.target_name, + kind: e.target_kind, + file: e.target_file, + line: e.target_line, + role: e.target_role, + }); + } + } + + if (cfg.filter.roles) { + const roles = new Set(cfg.filter.roles); + for (const [id, n] of nodeMap) { + if (!roles.has(n.role)) nodeMap.delete(id); + } + const nodeIds = new Set(nodeMap.keys()); + edges = edges.filter((e) => nodeIds.has(e.source_id) && nodeIds.has(e.target_id)); + } + + // Complexity data + const complexityMap = new Map(); + try { + const rows = db + .prepare( + 'SELECT node_id, cognitive, cyclomatic, max_nesting, maintainability_index FROM function_complexity', + ) + .all(); + for (const r of rows) { + complexityMap.set(r.node_id, { + cognitive: r.cognitive, + cyclomatic: r.cyclomatic, + maintainabilityIndex: r.maintainability_index, + }); + } + } catch { + // table may not exist in old DBs + } + + // Fan-in / fan-out via graph subsystem + const fnGraph = new CodeGraph(); + for (const [id] of nodeMap) fnGraph.addNode(String(id)); + for (const e of edges) { + const src = String(e.source_id); + const tgt = String(e.target_id); + if (src !== tgt && !fnGraph.hasEdge(src, tgt)) fnGraph.addEdge(src, tgt); + } + + // Use DB-level fan-in/fan-out (counts ALL call edges, not just visible) + const fanInMap = new Map(); + const fanOutMap = new Map(); + const fanInRows = db + .prepare( + "SELECT target_id AS node_id, COUNT(*) AS fan_in FROM edges WHERE kind = 'calls' GROUP BY target_id", + ) + .all(); + for (const r of fanInRows) fanInMap.set(r.node_id, r.fan_in); + + const fanOutRows = db + .prepare( + "SELECT source_id AS node_id, COUNT(*) AS fan_out FROM edges WHERE kind = 'calls' GROUP BY source_id", + ) + .all(); + for (const r of fanOutRows) fanOutMap.set(r.node_id, r.fan_out); + + // Communities (Louvain) via graph subsystem + const communityMap = new Map(); + if (nodeMap.size > 0) { + try { + const { assignments } = louvainCommunities(fnGraph); + for (const [nid, cid] of assignments) communityMap.set(Number(nid), cid); + } catch { + // louvain can fail on disconnected graphs + } + } + + // Build enriched nodes + const visNodes = [...nodeMap.values()].map((n) => { + const cx = complexityMap.get(n.id) || null; + const fanIn = fanInMap.get(n.id) || 0; + const fanOut = fanOutMap.get(n.id) || 0; + const community = communityMap.get(n.id) ?? null; + const directory = path.dirname(n.file); + const risk = []; + if (n.role === 'dead') risk.push('dead-code'); + if (fanIn >= (cfg.riskThresholds?.highBlastRadius ?? 10)) risk.push('high-blast-radius'); + if (cx && cx.maintainabilityIndex < (cfg.riskThresholds?.lowMI ?? 40)) risk.push('low-mi'); + + const color = + cfg.colorBy === 'role' && n.role + ? cfg.roleColors[n.role] || DEFAULT_ROLE_COLORS[n.role] || '#ccc' + : cfg.colorBy === 'community' && community !== null + ? COMMUNITY_COLORS[community % COMMUNITY_COLORS.length] + : cfg.nodeColors[n.kind] || DEFAULT_NODE_COLORS[n.kind] || '#ccc'; + + return { + id: n.id, + label: n.name, + title: `${n.file}:${n.line} (${n.kind}${n.role ? `, ${n.role}` : ''})`, + color, + kind: n.kind, + role: n.role || '', + file: n.file, + line: n.line, + community, + cognitive: cx?.cognitive ?? null, + cyclomatic: cx?.cyclomatic ?? null, + maintainabilityIndex: cx?.maintainabilityIndex ?? null, + fanIn, + fanOut, + directory, + risk, + }; + }); + + const visEdges = edges.map((e, i) => ({ + id: `e${i}`, + from: e.source_id, + to: e.target_id, + })); + + // Seed strategy + let seedNodeIds; + if (cfg.seedStrategy === 'top-fanin') { + const sorted = [...visNodes].sort((a, b) => b.fanIn - a.fanIn); + seedNodeIds = sorted.slice(0, cfg.seedCount || 30).map((n) => n.id); + } else if (cfg.seedStrategy === 'entry') { + seedNodeIds = visNodes.filter((n) => n.role === 'entry').map((n) => n.id); + } else { + seedNodeIds = visNodes.map((n) => n.id); + } + + return { nodes: visNodes, edges: visEdges, seedNodeIds }; +} + +function prepareFileLevelData(db, noTests, minConf, cfg) { + let edges = db + .prepare( + ` + SELECT DISTINCT n1.file AS source, n2.file AS target + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type', 'calls') + AND e.confidence >= ? + `, + ) + .all(minConf); + if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target)); + + const files = new Set(); + for (const { source, target } of edges) { + files.add(source); + files.add(target); + } + + const fileIds = new Map(); + let idx = 0; + for (const f of files) fileIds.set(f, idx++); + + // Fan-in/fan-out + const fanInCount = new Map(); + const fanOutCount = new Map(); + for (const { source, target } of edges) { + fanOutCount.set(source, (fanOutCount.get(source) || 0) + 1); + fanInCount.set(target, (fanInCount.get(target) || 0) + 1); + } + + // Communities via graph subsystem + const communityMap = new Map(); + if (files.size > 0) { + try { + const fileGraph = new CodeGraph(); + for (const f of files) fileGraph.addNode(f); + for (const { source, target } of edges) { + if (source !== target && !fileGraph.hasEdge(source, target)) + fileGraph.addEdge(source, target); + } + const { assignments } = louvainCommunities(fileGraph); + for (const [file, cid] of assignments) communityMap.set(file, cid); + } catch { + // ignore + } + } + + const visNodes = [...files].map((f) => { + const id = fileIds.get(f); + const community = communityMap.get(f) ?? null; + const fanIn = fanInCount.get(f) || 0; + const fanOut = fanOutCount.get(f) || 0; + const directory = path.dirname(f); + const color = + cfg.colorBy === 'community' && community !== null + ? COMMUNITY_COLORS[community % COMMUNITY_COLORS.length] + : cfg.nodeColors.file || DEFAULT_NODE_COLORS.file; + + return { + id, + label: path.basename(f), + title: f, + color, + kind: 'file', + role: '', + file: f, + line: 0, + community, + cognitive: null, + cyclomatic: null, + maintainabilityIndex: null, + fanIn, + fanOut, + directory, + risk: [], + }; + }); + + const visEdges = edges.map(({ source, target }, i) => ({ + id: `e${i}`, + from: fileIds.get(source), + to: fileIds.get(target), + })); + + let seedNodeIds; + if (cfg.seedStrategy === 'top-fanin') { + const sorted = [...visNodes].sort((a, b) => b.fanIn - a.fanIn); + seedNodeIds = sorted.slice(0, cfg.seedCount || 30).map((n) => n.id); + } else if (cfg.seedStrategy === 'entry') { + seedNodeIds = visNodes.map((n) => n.id); + } else { + seedNodeIds = visNodes.map((n) => n.id); + } + + return { nodes: visNodes, edges: visEdges, seedNodeIds }; +} + +// ─── HTML Generation (thin wrapper) ────────────────────────────────── + +/** + * Generate a self-contained interactive HTML file with vis-network. + * + * Loads graph data from the DB, then delegates to the presentation layer. + */ +export function generatePlotHTML(db, opts = {}) { + const cfg = opts.config || DEFAULT_CONFIG; + const data = prepareGraphData(db, opts); + return renderPlotHTML(data, cfg); +} diff --git a/src/presentation/audit.js b/src/presentation/audit.js new file mode 100644 index 00000000..13e4d488 --- /dev/null +++ b/src/presentation/audit.js @@ -0,0 +1,88 @@ +import { kindIcon } from '../domain/queries.js'; +import { auditData } from '../features/audit.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; + +/** + * CLI formatter for the audit command. + */ +export function audit(target, customDbPath, opts = {}) { + const data = auditData(target, customDbPath, opts); + + if (outputResult(data, null, opts)) return; + + if (data.functions.length === 0) { + console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`); + return; + } + + console.log(`\n# Audit: ${target} (${data.kind})`); + console.log(` ${data.functions.length} function(s) analyzed\n`); + + for (const fn of data.functions) { + const lineRange = fn.endLine ? `${fn.line}-${fn.endLine}` : `${fn.line}`; + const roleTag = fn.role ? ` [${fn.role}]` : ''; + console.log(`## ${kindIcon(fn.kind)} ${fn.name} (${fn.kind})${roleTag}`); + console.log(` ${fn.file}:${lineRange}${fn.lineCount ? ` (${fn.lineCount} lines)` : ''}`); + if (fn.summary) console.log(` ${fn.summary}`); + if (fn.signature) { + if (fn.signature.params != null) console.log(` Parameters: (${fn.signature.params})`); + if (fn.signature.returnType) console.log(` Returns: ${fn.signature.returnType}`); + } + + // Health metrics + if (fn.health.cognitive != null) { + console.log(`\n Health:`); + console.log( + ` Cognitive: ${fn.health.cognitive} Cyclomatic: ${fn.health.cyclomatic} Nesting: ${fn.health.maxNesting}`, + ); + console.log(` MI: ${fn.health.maintainabilityIndex}`); + if (fn.health.halstead.volume) { + console.log( + ` Halstead: vol=${fn.health.halstead.volume} diff=${fn.health.halstead.difficulty} effort=${fn.health.halstead.effort} bugs=${fn.health.halstead.bugs}`, + ); + } + if (fn.health.loc) { + console.log( + ` LOC: ${fn.health.loc} SLOC: ${fn.health.sloc} Comments: ${fn.health.commentLines}`, + ); + } + } + + // Threshold breaches + if (fn.health.thresholdBreaches.length > 0) { + console.log(`\n Threshold Breaches:`); + for (const b of fn.health.thresholdBreaches) { + const icon = b.level === 'fail' ? 'FAIL' : 'WARN'; + console.log(` [${icon}] ${b.metric}: ${b.value} >= ${b.threshold}`); + } + } + + // Impact + console.log(`\n Impact: ${fn.impact.totalDependents} transitive dependent(s)`); + for (const [level, nodes] of Object.entries(fn.impact.levels)) { + console.log(` Level ${level}: ${nodes.map((n) => n.name).join(', ')}`); + } + + // Call edges + if (fn.callees.length > 0) { + console.log(`\n Calls (${fn.callees.length}):`); + for (const c of fn.callees) { + console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); + } + } + if (fn.callers.length > 0) { + console.log(`\n Called by (${fn.callers.length}):`); + for (const c of fn.callers) { + console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); + } + } + if (fn.relatedTests.length > 0) { + console.log(`\n Tests (${fn.relatedTests.length}):`); + for (const t of fn.relatedTests) { + console.log(` ${t.file}`); + } + } + + console.log(); + } +} diff --git a/src/presentation/batch.js b/src/presentation/batch.js new file mode 100644 index 00000000..bec40cf9 --- /dev/null +++ b/src/presentation/batch.js @@ -0,0 +1,26 @@ +import { batchData, multiBatchData } from '../features/batch.js'; + +/** + * CLI wrapper — calls batchData and prints JSON to stdout. + */ +export function batch(command, targets, customDbPath, opts = {}) { + const data = batchData(command, targets, customDbPath, opts); + console.log(JSON.stringify(data, null, 2)); +} + +/** + * CLI wrapper for batch-query — detects multi-command mode (objects with .command) + * or falls back to single-command batchData (default: 'where'). + */ +export function batchQuery(targets, customDbPath, opts = {}) { + const { command: defaultCommand = 'where', ...rest } = opts; + const isMulti = targets.length > 0 && typeof targets[0] === 'object' && targets[0].command; + + let data; + if (isMulti) { + data = multiBatchData(targets, customDbPath, rest); + } else { + data = batchData(defaultCommand, targets, customDbPath, rest); + } + console.log(JSON.stringify(data, null, 2)); +} diff --git a/src/presentation/branch-compare.js b/src/presentation/branch-compare.js new file mode 100644 index 00000000..c90394fb --- /dev/null +++ b/src/presentation/branch-compare.js @@ -0,0 +1,97 @@ +import { kindIcon } from '../domain/queries.js'; +import { branchCompareData, branchCompareMermaid } from '../features/branch-compare.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; + +// ─── Text Formatting ──────────────────────────────────────────────────── + +function formatText(data) { + if (data.error) return `Error: ${data.error}`; + + const lines = []; + const shortBase = data.baseSha.slice(0, 7); + const shortTarget = data.targetSha.slice(0, 7); + + lines.push(`branch-compare: ${data.baseRef}..${data.targetRef}`); + lines.push(` Base: ${data.baseRef} (${shortBase})`); + lines.push(` Target: ${data.targetRef} (${shortTarget})`); + lines.push(` Files changed: ${data.changedFiles.length}`); + + if (data.added.length > 0) { + lines.push(''); + lines.push(` + Added (${data.added.length} symbol${data.added.length !== 1 ? 's' : ''}):`); + for (const sym of data.added) { + lines.push(` [${kindIcon(sym.kind)}] ${sym.name} -- ${sym.file}:${sym.line}`); + } + } + + if (data.removed.length > 0) { + lines.push(''); + lines.push( + ` - Removed (${data.removed.length} symbol${data.removed.length !== 1 ? 's' : ''}):`, + ); + for (const sym of data.removed) { + lines.push(` [${kindIcon(sym.kind)}] ${sym.name} -- ${sym.file}:${sym.line}`); + if (sym.impact && sym.impact.length > 0) { + lines.push( + ` ^ ${sym.impact.length} transitive caller${sym.impact.length !== 1 ? 's' : ''} affected`, + ); + } + } + } + + if (data.changed.length > 0) { + lines.push(''); + lines.push( + ` ~ Changed (${data.changed.length} symbol${data.changed.length !== 1 ? 's' : ''}):`, + ); + for (const sym of data.changed) { + const parts = []; + if (sym.changes.lineCount !== 0) { + parts.push(`lines: ${sym.base.lineCount} -> ${sym.target.lineCount}`); + } + if (sym.changes.fanIn !== 0) { + parts.push(`fan_in: ${sym.base.fanIn} -> ${sym.target.fanIn}`); + } + if (sym.changes.fanOut !== 0) { + parts.push(`fan_out: ${sym.base.fanOut} -> ${sym.target.fanOut}`); + } + const detail = parts.length > 0 ? ` (${parts.join(', ')})` : ''; + lines.push( + ` [${kindIcon(sym.kind)}] ${sym.name} -- ${sym.file}:${sym.base.line}${detail}`, + ); + if (sym.impact && sym.impact.length > 0) { + lines.push( + ` ^ ${sym.impact.length} transitive caller${sym.impact.length !== 1 ? 's' : ''} affected`, + ); + } + } + } + + const s = data.summary; + lines.push(''); + lines.push( + ` Summary: +${s.added} added, -${s.removed} removed, ~${s.changed} changed` + + ` -> ${s.totalImpacted} caller${s.totalImpacted !== 1 ? 's' : ''} impacted` + + (s.filesAffected > 0 + ? ` across ${s.filesAffected} file${s.filesAffected !== 1 ? 's' : ''}` + : ''), + ); + + return lines.join('\n'); +} + +// ─── CLI Display Function ─────────────────────────────────────────────── + +export async function branchCompare(baseRef, targetRef, opts = {}) { + const data = await branchCompareData(baseRef, targetRef, opts); + + if (opts.format === 'json') opts = { ...opts, json: true }; + if (outputResult(data, null, opts)) return; + + if (opts.format === 'mermaid') { + console.log(branchCompareMermaid(data)); + return; + } + + console.log(formatText(data)); +} diff --git a/src/presentation/cfg.js b/src/presentation/cfg.js new file mode 100644 index 00000000..97b1e414 --- /dev/null +++ b/src/presentation/cfg.js @@ -0,0 +1,55 @@ +import { cfgData, cfgToDOT, cfgToMermaid } from '../features/cfg.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; + +/** + * CLI display for cfg command. + */ +export function cfg(name, customDbPath, opts = {}) { + const data = cfgData(name, customDbPath, opts); + + if (outputResult(data, 'results', opts)) return; + + if (data.warning) { + console.log(`\u26A0 ${data.warning}`); + return; + } + if (data.results.length === 0) { + console.log(`No symbols matching "${name}".`); + return; + } + + const format = opts.format || 'text'; + if (format === 'dot') { + console.log(cfgToDOT(data)); + return; + } + if (format === 'mermaid') { + console.log(cfgToMermaid(data)); + return; + } + + // Text format + for (const r of data.results) { + console.log(`\n${r.kind} ${r.name} (${r.file}:${r.line})`); + console.log('\u2500'.repeat(60)); + console.log(` Blocks: ${r.summary.blockCount} Edges: ${r.summary.edgeCount}`); + + if (r.blocks.length > 0) { + console.log('\n Blocks:'); + for (const b of r.blocks) { + const loc = b.startLine + ? ` L${b.startLine}${b.endLine && b.endLine !== b.startLine ? `-${b.endLine}` : ''}` + : ''; + const label = b.label ? ` (${b.label})` : ''; + console.log(` [${b.index}] ${b.type}${label}${loc}`); + } + } + + if (r.edges.length > 0) { + console.log('\n Edges:'); + for (const e of r.edges) { + console.log(` B${e.source} \u2192 B${e.target} [${e.kind}]`); + } + } + } +} diff --git a/src/presentation/check.js b/src/presentation/check.js new file mode 100644 index 00000000..1dc0c4fc --- /dev/null +++ b/src/presentation/check.js @@ -0,0 +1,82 @@ +import { checkData } from '../features/check.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; +import { AnalysisError } from '../shared/errors.js'; + +/** + * CLI formatter — prints check results and sets exitCode 1 on failure. + */ +export function check(customDbPath, opts = {}) { + const data = checkData(customDbPath, opts); + + if (data.error) { + throw new AnalysisError(data.error); + } + + if (outputResult(data, null, opts)) { + if (!data.passed) process.exitCode = 1; + return; + } + + console.log('\n# Check Results\n'); + + if (data.predicates.length === 0) { + console.log(' No changes detected.\n'); + return; + } + + console.log( + ` Changed files: ${data.summary.changedFiles} New files: ${data.summary.newFiles}\n`, + ); + + for (const pred of data.predicates) { + const icon = pred.passed ? 'PASS' : 'FAIL'; + console.log(` [${icon}] ${pred.name}`); + + if (!pred.passed) { + if (pred.name === 'cycles' && pred.cycles) { + for (const cycle of pred.cycles.slice(0, 10)) { + console.log(` ${cycle.join(' -> ')}`); + } + if (pred.cycles.length > 10) { + console.log(` ... and ${pred.cycles.length - 10} more`); + } + } + if (pred.name === 'blast-radius' && pred.violations) { + for (const v of pred.violations.slice(0, 10)) { + console.log( + ` ${v.name} (${v.kind}) at ${v.file}:${v.line} — ${v.transitiveCallers} callers (max: ${pred.threshold})`, + ); + } + if (pred.violations.length > 10) { + console.log(` ... and ${pred.violations.length - 10} more`); + } + } + if (pred.name === 'signatures' && pred.violations) { + for (const v of pred.violations.slice(0, 10)) { + console.log(` ${v.name} (${v.kind}) at ${v.file}:${v.line}`); + } + if (pred.violations.length > 10) { + console.log(` ... and ${pred.violations.length - 10} more`); + } + } + if (pred.name === 'boundaries' && pred.violations) { + for (const v of pred.violations.slice(0, 10)) { + console.log(` ${v.from} -> ${v.to} (${v.edgeKind})`); + } + if (pred.violations.length > 10) { + console.log(` ... and ${pred.violations.length - 10} more`); + } + } + } + if (pred.note) { + console.log(` ${pred.note}`); + } + } + + const s = data.summary; + console.log(`\n ${s.total} predicates | ${s.passed} passed | ${s.failed} failed\n`); + + if (!data.passed) { + process.exitCode = 1; + } +} diff --git a/src/presentation/cochange.js b/src/presentation/cochange.js new file mode 100644 index 00000000..21802cc2 --- /dev/null +++ b/src/presentation/cochange.js @@ -0,0 +1,37 @@ +/** + * Format co-change data for CLI output (single file). + */ +export function formatCoChange(data) { + if (data.error) return data.error; + if (data.partners.length === 0) return `No co-change partners found for ${data.file}`; + + const lines = [`\nCo-change partners for ${data.file}:\n`]; + for (const p of data.partners) { + const pct = `${(p.jaccard * 100).toFixed(0)}%`.padStart(4); + const commits = `${p.commitCount} commits`.padStart(12); + lines.push(` ${pct} ${commits} ${p.file}`); + } + if (data.meta?.analyzedAt) { + lines.push(`\n Analyzed: ${data.meta.analyzedAt} | Window: ${data.meta.since || 'all'}`); + } + return lines.join('\n'); +} + +/** + * Format top co-change pairs for CLI output (global view). + */ +export function formatCoChangeTop(data) { + if (data.error) return data.error; + if (data.pairs.length === 0) return 'No co-change pairs found.'; + + const lines = ['\nTop co-change pairs:\n']; + for (const p of data.pairs) { + const pct = `${(p.jaccard * 100).toFixed(0)}%`.padStart(4); + const commits = `${p.commitCount} commits`.padStart(12); + lines.push(` ${pct} ${commits} ${p.fileA} <-> ${p.fileB}`); + } + if (data.meta?.analyzedAt) { + lines.push(`\n Analyzed: ${data.meta.analyzedAt} | Window: ${data.meta.since || 'all'}`); + } + return lines.join('\n'); +} diff --git a/src/presentation/communities.js b/src/presentation/communities.js new file mode 100644 index 00000000..ae1e8fb7 --- /dev/null +++ b/src/presentation/communities.js @@ -0,0 +1,69 @@ +import { communitiesData } from '../features/communities.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; + +/** + * CLI entry point: run community detection and print results. + */ +export function communities(customDbPath, opts = {}) { + const data = communitiesData(customDbPath, opts); + + if (outputResult(data, 'communities', opts)) return; + + if (data.summary.communityCount === 0) { + console.log( + '\nNo communities detected. The graph may be too small or disconnected.\n' + + 'Run "codegraph build" first to populate the graph.\n', + ); + return; + } + + const mode = opts.functions ? 'Function' : 'File'; + console.log(`\n# ${mode}-Level Communities\n`); + console.log( + ` ${data.summary.communityCount} communities | ${data.summary.nodeCount} nodes | modularity: ${data.summary.modularity} | drift: ${data.summary.driftScore}%\n`, + ); + + if (!opts.drift) { + for (const c of data.communities) { + const dirs = Object.entries(c.directories) + .sort((a, b) => b[1] - a[1]) + .map(([d, n]) => `${d} (${n})`) + .join(', '); + console.log(` Community ${c.id} (${c.size} members): ${dirs}`); + if (c.members) { + const shown = c.members.slice(0, 8); + for (const m of shown) { + const kind = m.kind ? ` [${m.kind}]` : ''; + console.log(` - ${m.name}${kind} ${m.file}`); + } + if (c.members.length > 8) { + console.log(` ... and ${c.members.length - 8} more`); + } + } + } + } + + // Drift analysis + const d = data.drift; + if (d.splitCandidates.length > 0 || d.mergeCandidates.length > 0) { + console.log(`\n# Drift Analysis (score: ${data.summary.driftScore}%)\n`); + + if (d.splitCandidates.length > 0) { + console.log(' Split candidates (directories spanning multiple communities):'); + for (const s of d.splitCandidates.slice(0, 10)) { + console.log(` - ${s.directory} → ${s.communityCount} communities`); + } + } + + if (d.mergeCandidates.length > 0) { + console.log(' Merge candidates (communities spanning multiple directories):'); + for (const m of d.mergeCandidates.slice(0, 10)) { + console.log( + ` - Community ${m.communityId} (${m.size} members) → ${m.directoryCount} dirs: ${m.directories.join(', ')}`, + ); + } + } + } + + console.log(); +} diff --git a/src/presentation/complexity.js b/src/presentation/complexity.js new file mode 100644 index 00000000..f2de7f6a --- /dev/null +++ b/src/presentation/complexity.js @@ -0,0 +1,77 @@ +import { complexityData } from '../features/complexity.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; + +/** + * Format complexity output for CLI display. + */ +export function complexity(customDbPath, opts = {}) { + const data = complexityData(customDbPath, opts); + + if (outputResult(data, 'functions', opts)) return; + + if (data.functions.length === 0) { + if (data.summary === null) { + if (data.hasGraph) { + console.log( + '\nNo complexity data found, but a graph exists. Run "codegraph build --no-incremental" to populate complexity metrics.\n', + ); + } else { + console.log( + '\nNo complexity data found. Run "codegraph build" first to analyze your codebase.\n', + ); + } + } else { + console.log('\nNo functions match the given filters.\n'); + } + return; + } + + const header = opts.aboveThreshold ? 'Functions Above Threshold' : 'Function Complexity'; + console.log(`\n# ${header}\n`); + + if (opts.health) { + // Health-focused view with Halstead + MI columns + console.log( + ` ${'Function'.padEnd(35)} ${'File'.padEnd(25)} ${'MI'.padStart(5)} ${'Vol'.padStart(7)} ${'Diff'.padStart(6)} ${'Effort'.padStart(9)} ${'Bugs'.padStart(6)} ${'LOC'.padStart(5)} ${'SLOC'.padStart(5)}`, + ); + console.log( + ` ${'─'.repeat(35)} ${'─'.repeat(25)} ${'─'.repeat(5)} ${'─'.repeat(7)} ${'─'.repeat(6)} ${'─'.repeat(9)} ${'─'.repeat(6)} ${'─'.repeat(5)} ${'─'.repeat(5)}`, + ); + + for (const fn of data.functions) { + const name = fn.name.length > 33 ? `${fn.name.slice(0, 32)}…` : fn.name; + const file = fn.file.length > 23 ? `…${fn.file.slice(-22)}` : fn.file; + const miWarn = fn.exceeds?.includes('maintainabilityIndex') ? '!' : ' '; + console.log( + ` ${name.padEnd(35)} ${file.padEnd(25)} ${String(fn.maintainabilityIndex).padStart(5)}${miWarn}${String(fn.halstead.volume).padStart(7)} ${String(fn.halstead.difficulty).padStart(6)} ${String(fn.halstead.effort).padStart(9)} ${String(fn.halstead.bugs).padStart(6)} ${String(fn.loc).padStart(5)} ${String(fn.sloc).padStart(5)}`, + ); + } + } else { + // Default view with MI column appended + console.log( + ` ${'Function'.padEnd(40)} ${'File'.padEnd(30)} ${'Cog'.padStart(4)} ${'Cyc'.padStart(4)} ${'Nest'.padStart(5)} ${'MI'.padStart(5)}`, + ); + console.log( + ` ${'─'.repeat(40)} ${'─'.repeat(30)} ${'─'.repeat(4)} ${'─'.repeat(4)} ${'─'.repeat(5)} ${'─'.repeat(5)}`, + ); + + for (const fn of data.functions) { + const name = fn.name.length > 38 ? `${fn.name.slice(0, 37)}…` : fn.name; + const file = fn.file.length > 28 ? `…${fn.file.slice(-27)}` : fn.file; + const warn = fn.exceeds ? ' !' : ''; + const mi = fn.maintainabilityIndex > 0 ? String(fn.maintainabilityIndex) : '-'; + console.log( + ` ${name.padEnd(40)} ${file.padEnd(30)} ${String(fn.cognitive).padStart(4)} ${String(fn.cyclomatic).padStart(4)} ${String(fn.maxNesting).padStart(5)} ${mi.padStart(5)}${warn}`, + ); + } + } + + if (data.summary) { + const s = data.summary; + const miPart = s.avgMI != null ? ` | avg MI: ${s.avgMI}` : ''; + console.log( + `\n ${s.analyzed} functions analyzed | avg cognitive: ${s.avgCognitive} | avg cyclomatic: ${s.avgCyclomatic}${miPart} | ${s.aboveWarn} above threshold`, + ); + } + console.log(); +} diff --git a/src/presentation/dataflow.js b/src/presentation/dataflow.js new file mode 100644 index 00000000..fafcfb21 --- /dev/null +++ b/src/presentation/dataflow.js @@ -0,0 +1,110 @@ +import { dataflowData, dataflowImpactData } from '../features/dataflow.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; + +/** + * CLI display for dataflow command. + */ +export function dataflow(name, customDbPath, opts = {}) { + if (opts.impact) { + return dataflowImpact(name, customDbPath, opts); + } + + const data = dataflowData(name, customDbPath, opts); + + if (outputResult(data, 'results', opts)) return; + + if (data.warning) { + console.log(`⚠ ${data.warning}`); + return; + } + if (data.results.length === 0) { + console.log(`No symbols matching "${name}".`); + return; + } + + for (const r of data.results) { + console.log(`\n${r.kind} ${r.name} (${r.file}:${r.line})`); + console.log('─'.repeat(60)); + + if (r.flowsTo.length > 0) { + console.log('\n Data flows TO:'); + for (const f of r.flowsTo) { + const conf = f.confidence < 1.0 ? ` [${(f.confidence * 100).toFixed(0)}%]` : ''; + console.log(` → ${f.target} (${f.file}:${f.line}) arg[${f.paramIndex}]${conf}`); + } + } + + if (r.flowsFrom.length > 0) { + console.log('\n Data flows FROM:'); + for (const f of r.flowsFrom) { + const conf = f.confidence < 1.0 ? ` [${(f.confidence * 100).toFixed(0)}%]` : ''; + console.log(` ← ${f.source} (${f.file}:${f.line}) arg[${f.paramIndex}]${conf}`); + } + } + + if (r.returns.length > 0) { + console.log('\n Return value consumed by:'); + for (const c of r.returns) { + console.log(` → ${c.consumer} (${c.file}:${c.line}) ${c.expression}`); + } + } + + if (r.returnedBy.length > 0) { + console.log('\n Uses return value of:'); + for (const p of r.returnedBy) { + console.log(` ← ${p.producer} (${p.file}:${p.line}) ${p.expression}`); + } + } + + if (r.mutates.length > 0) { + console.log('\n Mutates:'); + for (const m of r.mutates) { + console.log(` ✎ ${m.expression} (line ${m.line})`); + } + } + + if (r.mutatedBy.length > 0) { + console.log('\n Mutated by:'); + for (const m of r.mutatedBy) { + console.log(` ✎ ${m.source} — ${m.expression} (line ${m.line})`); + } + } + } +} + +/** + * CLI display for dataflow --impact. + */ +function dataflowImpact(name, customDbPath, opts = {}) { + const data = dataflowImpactData(name, customDbPath, { + noTests: opts.noTests, + depth: opts.depth ? Number(opts.depth) : 5, + file: opts.file, + kind: opts.kind, + limit: opts.limit, + offset: opts.offset, + }); + + if (outputResult(data, 'results', opts)) return; + + if (data.warning) { + console.log(`⚠ ${data.warning}`); + return; + } + if (data.results.length === 0) { + console.log(`No symbols matching "${name}".`); + return; + } + + for (const r of data.results) { + console.log( + `\n${r.kind} ${r.name} (${r.file}:${r.line}) — ${r.totalAffected} data-dependent consumer${r.totalAffected !== 1 ? 's' : ''}`, + ); + for (const [level, items] of Object.entries(r.levels)) { + console.log(` Level ${level}:`); + for (const item of items) { + console.log(` ${item.name} (${item.file}:${item.line})`); + } + } + } +} diff --git a/src/presentation/flow.js b/src/presentation/flow.js new file mode 100644 index 00000000..e86f2de9 --- /dev/null +++ b/src/presentation/flow.js @@ -0,0 +1,70 @@ +import { kindIcon } from '../domain/queries.js'; +import { flowData, listEntryPointsData } from '../features/flow.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; + +/** + * CLI formatter — text or JSON output. + */ +export function flow(name, dbPath, opts = {}) { + if (opts.list) { + const data = listEntryPointsData(dbPath, { + noTests: opts.noTests, + limit: opts.limit, + offset: opts.offset, + }); + if (outputResult(data, 'entries', opts)) return; + if (data.count === 0) { + console.log('No entry points found. Run "codegraph build" first.'); + return; + } + console.log(`\nEntry points (${data.count} total):\n`); + for (const [type, entries] of Object.entries(data.byType)) { + console.log(` ${type} (${entries.length}):`); + for (const e of entries) { + console.log(` [${kindIcon(e.kind)}] ${e.name} ${e.file}:${e.line}`); + } + console.log(); + } + return; + } + + const data = flowData(name, dbPath, opts); + if (outputResult(data, 'steps', opts)) return; + + if (!data.entry) { + console.log(`No matching entry point or function found for "${name}".`); + return; + } + + const e = data.entry; + const typeTag = e.type !== 'exported' ? ` (${e.type})` : ''; + console.log(`\nFlow from: [${kindIcon(e.kind)}] ${e.name}${typeTag} ${e.file}:${e.line}`); + console.log( + `Depth: ${data.depth} Reached: ${data.totalReached} nodes Leaves: ${data.leaves.length}`, + ); + if (data.truncated) { + console.log(` (truncated at depth ${data.depth})`); + } + console.log(); + + if (data.steps.length === 0) { + console.log(' (leaf node — no callees)'); + return; + } + + for (const step of data.steps) { + console.log(` depth ${step.depth}:`); + for (const n of step.nodes) { + const isLeaf = data.leaves.some((l) => l.name === n.name && l.file === n.file); + const leafTag = isLeaf ? ' [leaf]' : ''; + console.log(` [${kindIcon(n.kind)}] ${n.name} ${n.file}:${n.line}${leafTag}`); + } + } + + if (data.cycles.length > 0) { + console.log('\n Cycles detected:'); + for (const c of data.cycles) { + console.log(` ${c.from} -> ${c.to} (at depth ${c.depth})`); + } + } +} diff --git a/src/presentation/manifesto.js b/src/presentation/manifesto.js new file mode 100644 index 00000000..f491e0bf --- /dev/null +++ b/src/presentation/manifesto.js @@ -0,0 +1,77 @@ +import { manifestoData } from '../features/manifesto.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; + +/** + * CLI formatter — prints manifesto results and sets exitCode 1 on failure. + */ +export function manifesto(customDbPath, opts = {}) { + const data = manifestoData(customDbPath, opts); + + if (outputResult(data, 'violations', opts)) { + if (!data.passed) process.exitCode = 1; + return; + } + + console.log('\n# Manifesto Rules\n'); + + // Rules table + console.log( + ` ${'Rule'.padEnd(20)} ${'Level'.padEnd(10)} ${'Status'.padEnd(8)} ${'Warn'.padStart(6)} ${'Fail'.padStart(6)} ${'Violations'.padStart(11)}`, + ); + console.log( + ` ${'─'.repeat(20)} ${'─'.repeat(10)} ${'─'.repeat(8)} ${'─'.repeat(6)} ${'─'.repeat(6)} ${'─'.repeat(11)}`, + ); + + for (const rule of data.rules) { + const warn = rule.thresholds.warn != null ? String(rule.thresholds.warn) : '—'; + const fail = rule.thresholds.fail != null ? String(rule.thresholds.fail) : '—'; + const statusIcon = rule.status === 'pass' ? 'pass' : rule.status === 'warn' ? 'WARN' : 'FAIL'; + console.log( + ` ${rule.name.padEnd(20)} ${rule.level.padEnd(10)} ${statusIcon.padEnd(8)} ${warn.padStart(6)} ${fail.padStart(6)} ${String(rule.violationCount).padStart(11)}`, + ); + } + + // Summary + const s = data.summary; + console.log( + `\n ${s.total} rules | ${s.passed} passed | ${s.warned} warned | ${s.failed} failed | ${s.violationCount} violations`, + ); + + // Violations detail + if (data.violations.length > 0) { + const failViolations = data.violations.filter((v) => v.level === 'fail'); + const warnViolations = data.violations.filter((v) => v.level === 'warn'); + + if (failViolations.length > 0) { + console.log(`\n## Failures (${failViolations.length})\n`); + for (const v of failViolations.slice(0, 20)) { + const loc = v.line ? `${v.file}:${v.line}` : v.file; + console.log( + ` [FAIL] ${v.rule}: ${v.name} (${v.value}) at ${loc} — threshold ${v.threshold}`, + ); + } + if (failViolations.length > 20) { + console.log(` ... and ${failViolations.length - 20} more`); + } + } + + if (warnViolations.length > 0) { + console.log(`\n## Warnings (${warnViolations.length})\n`); + for (const v of warnViolations.slice(0, 20)) { + const loc = v.line ? `${v.file}:${v.line}` : v.file; + console.log( + ` [WARN] ${v.rule}: ${v.name} (${v.value}) at ${loc} — threshold ${v.threshold}`, + ); + } + if (warnViolations.length > 20) { + console.log(` ... and ${warnViolations.length - 20} more`); + } + } + } + + console.log(); + + if (!data.passed) { + process.exitCode = 1; + } +} diff --git a/src/presentation/owners.js b/src/presentation/owners.js new file mode 100644 index 00000000..2ec0f5c3 --- /dev/null +++ b/src/presentation/owners.js @@ -0,0 +1,52 @@ +import { ownersData } from '../features/owners.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; + +/** + * CLI display function for the `owners` command. + */ +export function owners(customDbPath, opts = {}) { + const data = ownersData(customDbPath, opts); + if (outputResult(data, null, opts)) return; + + if (!data.codeownersFile) { + console.log('No CODEOWNERS file found.'); + return; + } + + console.log(`\nCODEOWNERS: ${data.codeownersFile}\n`); + + const s = data.summary; + console.log( + ` Coverage: ${s.coveragePercent}% (${s.ownedFiles}/${s.totalFiles} files owned, ${s.ownerCount} owners)\n`, + ); + + if (s.byOwner.length > 0) { + console.log(' Owners:\n'); + for (const o of s.byOwner) { + console.log(` ${o.owner} ${o.fileCount} files`); + } + console.log(); + } + + if (data.files.length > 0 && opts.owner) { + console.log(` Files owned by ${opts.owner}:\n`); + for (const f of data.files) { + console.log(` ${f.file}`); + } + console.log(); + } + + if (data.boundaries.length > 0) { + console.log(` Cross-owner boundaries: ${data.boundaries.length} edges\n`); + const shown = data.boundaries.slice(0, 30); + for (const b of shown) { + const srcOwner = b.from.owners.join(', ') || '(unowned)'; + const tgtOwner = b.to.owners.join(', ') || '(unowned)'; + console.log(` ${b.from.name} [${srcOwner}] -> ${b.to.name} [${tgtOwner}]`); + } + if (data.boundaries.length > 30) { + console.log(` ... and ${data.boundaries.length - 30} more`); + } + console.log(); + } +} diff --git a/src/presentation/queries-cli.js b/src/presentation/queries-cli.js index 8eab363e..8e77abfd 100644 --- a/src/presentation/queries-cli.js +++ b/src/presentation/queries-cli.js @@ -1,866 +1,27 @@ /** - * queries-cli.js — CLI display wrappers for query data functions. + * queries-cli.js — barrel re-export for backward compatibility. * - * Each function calls its corresponding *Data() function from queries.js, - * handles JSON/NDJSON output via outputResult(), then formats human-readable - * output for the terminal. + * The actual implementations live in queries-cli/ split by concern: + * path.js — symbolPath + * overview.js — stats, moduleMap, roles + * inspect.js — where, queryName, context, children, explain + * impact.js — fileDeps, fnDeps, impactAnalysis, fnImpact, diffImpact + * exports.js — fileExports */ - -import path from 'node:path'; -import { - childrenData, - contextData, - diffImpactData, - diffImpactMermaid, - explainData, - exportsData, - fileDepsData, - fnDepsData, - fnImpactData, - impactAnalysisData, - kindIcon, - moduleMapData, - pathData, - queryNameData, - rolesData, - statsData, - whereData, -} from '../domain/queries.js'; -import { outputResult } from '../infrastructure/result-formatter.js'; - -// ─── symbolPath ───────────────────────────────────────────────────────── - -export function symbolPath(from, to, customDbPath, opts = {}) { - const data = pathData(from, to, customDbPath, opts); - if (outputResult(data, null, opts)) return; - - if (data.error) { - console.log(data.error); - return; - } - - if (!data.found) { - const dir = data.reverse ? 'reverse ' : ''; - console.log(`No ${dir}path from "${from}" to "${to}" within ${data.maxDepth} hops.`); - if (data.fromCandidates.length > 1) { - console.log( - `\n "${from}" matched ${data.fromCandidates.length} symbols — using top match: ${data.fromCandidates[0].name} (${data.fromCandidates[0].file}:${data.fromCandidates[0].line})`, - ); - } - if (data.toCandidates.length > 1) { - console.log( - ` "${to}" matched ${data.toCandidates.length} symbols — using top match: ${data.toCandidates[0].name} (${data.toCandidates[0].file}:${data.toCandidates[0].line})`, - ); - } - return; - } - - if (data.hops === 0) { - console.log(`\n"${from}" and "${to}" resolve to the same symbol (0 hops):`); - const n = data.path[0]; - console.log(` ${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}\n`); - return; - } - - const dir = data.reverse ? ' (reverse)' : ''; - console.log( - `\nPath from ${from} to ${to} (${data.hops} ${data.hops === 1 ? 'hop' : 'hops'})${dir}:\n`, - ); - for (let i = 0; i < data.path.length; i++) { - const n = data.path[i]; - const indent = ' '.repeat(i + 1); - if (i === 0) { - console.log(`${indent}${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}`); - } else { - console.log( - `${indent}--[${n.edgeKind}]--> ${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}`, - ); - } - } - - if (data.alternateCount > 0) { - console.log( - `\n (${data.alternateCount} alternate shortest ${data.alternateCount === 1 ? 'path' : 'paths'} at same depth)`, - ); - } - console.log(); -} - -// ─── stats ────────────────────────────────────────────────────────────── - -export async function stats(customDbPath, opts = {}) { - const data = statsData(customDbPath, { noTests: opts.noTests }); - - // Community detection summary (async import for lazy-loading) - try { - const { communitySummaryForStats } = await import('./communities.js'); - data.communities = communitySummaryForStats(customDbPath, { noTests: opts.noTests }); - } catch { - /* graphology may not be available */ - } - - if (outputResult(data, null, opts)) return; - - // Human-readable output - console.log('\n# Codegraph Stats\n'); - - // Nodes - console.log(`Nodes: ${data.nodes.total} total`); - const kindEntries = Object.entries(data.nodes.byKind).sort((a, b) => b[1] - a[1]); - const kindParts = kindEntries.map(([k, v]) => `${k} ${v}`); - for (let i = 0; i < kindParts.length; i += 3) { - const row = kindParts - .slice(i, i + 3) - .map((p) => p.padEnd(18)) - .join(''); - console.log(` ${row}`); - } - - // Edges - console.log(`\nEdges: ${data.edges.total} total`); - const edgeEntries = Object.entries(data.edges.byKind).sort((a, b) => b[1] - a[1]); - const edgeParts = edgeEntries.map(([k, v]) => `${k} ${v}`); - for (let i = 0; i < edgeParts.length; i += 3) { - const row = edgeParts - .slice(i, i + 3) - .map((p) => p.padEnd(18)) - .join(''); - console.log(` ${row}`); - } - - // Files - console.log(`\nFiles: ${data.files.total} (${data.files.languages} languages)`); - const langEntries = Object.entries(data.files.byLanguage).sort((a, b) => b[1] - a[1]); - const langParts = langEntries.map(([k, v]) => `${k} ${v}`); - for (let i = 0; i < langParts.length; i += 3) { - const row = langParts - .slice(i, i + 3) - .map((p) => p.padEnd(18)) - .join(''); - console.log(` ${row}`); - } - - // Cycles - console.log( - `\nCycles: ${data.cycles.fileLevel} file-level, ${data.cycles.functionLevel} function-level`, - ); - - // Hotspots - if (data.hotspots.length > 0) { - console.log(`\nTop ${data.hotspots.length} coupling hotspots:`); - for (let i = 0; i < data.hotspots.length; i++) { - const h = data.hotspots[i]; - console.log( - ` ${String(i + 1).padStart(2)}. ${h.file.padEnd(35)} fan-in: ${String(h.fanIn).padStart(3)} fan-out: ${String(h.fanOut).padStart(3)}`, - ); - } - } - - // Embeddings - if (data.embeddings) { - const e = data.embeddings; - console.log( - `\nEmbeddings: ${e.count} vectors (${e.model || 'unknown'}, ${e.dim || '?'}d) built ${e.builtAt || 'unknown'}`, - ); - } else { - console.log('\nEmbeddings: not built'); - } - - // Quality - if (data.quality) { - const q = data.quality; - const cc = q.callerCoverage; - const cf = q.callConfidence; - console.log(`\nGraph Quality: ${q.score}/100`); - console.log( - ` Caller coverage: ${(cc.ratio * 100).toFixed(1)}% (${cc.covered}/${cc.total} functions have >=1 caller)`, - ); - console.log( - ` Call confidence: ${(cf.ratio * 100).toFixed(1)}% (${cf.highConf}/${cf.total} call edges are high-confidence)`, - ); - if (q.falsePositiveWarnings.length > 0) { - console.log(' False-positive warnings:'); - for (const fp of q.falsePositiveWarnings) { - console.log(` ! ${fp.name} (${fp.callerCount} callers) -- ${fp.file}:${fp.line}`); - } - } - } - - // Roles - if (data.roles && Object.keys(data.roles).length > 0) { - const total = Object.values(data.roles).reduce((a, b) => a + b, 0); - console.log(`\nRoles: ${total} classified symbols`); - const roleParts = Object.entries(data.roles) - .sort((a, b) => b[1] - a[1]) - .map(([k, v]) => `${k} ${v}`); - for (let i = 0; i < roleParts.length; i += 3) { - const row = roleParts - .slice(i, i + 3) - .map((p) => p.padEnd(18)) - .join(''); - console.log(` ${row}`); - } - } - - // Complexity - if (data.complexity) { - const cx = data.complexity; - const miPart = cx.avgMI != null ? ` | avg MI: ${cx.avgMI} | min MI: ${cx.minMI}` : ''; - console.log( - `\nComplexity: ${cx.analyzed} functions | avg cognitive: ${cx.avgCognitive} | avg cyclomatic: ${cx.avgCyclomatic} | max cognitive: ${cx.maxCognitive}${miPart}`, - ); - } - - // Communities - if (data.communities) { - const cm = data.communities; - console.log( - `\nCommunities: ${cm.communityCount} detected | modularity: ${cm.modularity} | drift: ${cm.driftScore}%`, - ); - } - - console.log(); -} - -// ─── queryName ────────────────────────────────────────────────────────── - -export function queryName(name, customDbPath, opts = {}) { - const data = queryNameData(name, customDbPath, { - noTests: opts.noTests, - limit: opts.limit, - offset: opts.offset, - }); - if (outputResult(data, 'results', opts)) return; - - if (data.results.length === 0) { - console.log(`No results for "${name}"`); - return; - } - - console.log(`\nResults for "${name}":\n`); - for (const r of data.results) { - console.log(` ${kindIcon(r.kind)} ${r.name} (${r.kind}) -- ${r.file}:${r.line}`); - if (r.callees.length > 0) { - console.log(` -> calls/uses:`); - for (const c of r.callees.slice(0, 15)) - console.log(` -> ${c.name} (${c.edgeKind}) ${c.file}:${c.line}`); - if (r.callees.length > 15) console.log(` ... and ${r.callees.length - 15} more`); - } - if (r.callers.length > 0) { - console.log(` <- called by:`); - for (const c of r.callers.slice(0, 15)) - console.log(` <- ${c.name} (${c.edgeKind}) ${c.file}:${c.line}`); - if (r.callers.length > 15) console.log(` ... and ${r.callers.length - 15} more`); - } - console.log(); - } -} - -// ─── impactAnalysis ───────────────────────────────────────────────────── - -export function impactAnalysis(file, customDbPath, opts = {}) { - const data = impactAnalysisData(file, customDbPath, opts); - if (outputResult(data, 'sources', opts)) return; - - if (data.sources.length === 0) { - console.log(`No file matching "${file}" in graph`); - return; - } - - console.log(`\nImpact analysis for files matching "${file}":\n`); - for (const s of data.sources) console.log(` # ${s} (source)`); - - const levels = data.levels; - if (Object.keys(levels).length === 0) { - console.log(` No dependents found.`); - } else { - for (const level of Object.keys(levels).sort((a, b) => a - b)) { - const nodes = levels[level]; - console.log( - `\n ${'--'.repeat(parseInt(level, 10))} Level ${level} (${nodes.length} files):`, - ); - for (const n of nodes.slice(0, 30)) - console.log(` ${' '.repeat(parseInt(level, 10))}^ ${n.file}`); - if (nodes.length > 30) console.log(` ... and ${nodes.length - 30} more`); - } - } - console.log(`\n Total: ${data.totalDependents} files transitively depend on "${file}"\n`); -} - -// ─── moduleMap ────────────────────────────────────────────────────────── - -export function moduleMap(customDbPath, limit = 20, opts = {}) { - const data = moduleMapData(customDbPath, limit, { noTests: opts.noTests }); - if (outputResult(data, 'topNodes', opts)) return; - - console.log(`\nModule map (top ${limit} most-connected nodes):\n`); - const dirs = new Map(); - for (const n of data.topNodes) { - if (!dirs.has(n.dir)) dirs.set(n.dir, []); - dirs.get(n.dir).push(n); - } - for (const [dir, files] of [...dirs].sort()) { - console.log(` [${dir}/]`); - for (const f of files) { - const coupling = f.inEdges + f.outEdges; - const bar = '#'.repeat(Math.min(coupling, 40)); - console.log( - ` ${path.basename(f.file).padEnd(35)} <-${String(f.inEdges).padStart(3)} ->${String(f.outEdges).padStart(3)} =${String(coupling).padStart(3)} ${bar}`, - ); - } - } - console.log( - `\n Total: ${data.stats.totalFiles} files, ${data.stats.totalNodes} symbols, ${data.stats.totalEdges} edges\n`, - ); -} - -// ─── fileDeps ─────────────────────────────────────────────────────────── - -export function fileDeps(file, customDbPath, opts = {}) { - const data = fileDepsData(file, customDbPath, opts); - if (outputResult(data, 'results', opts)) return; - - if (data.results.length === 0) { - console.log(`No file matching "${file}" in graph`); - return; - } - - for (const r of data.results) { - console.log(`\n# ${r.file}\n`); - console.log(` -> Imports (${r.imports.length}):`); - for (const i of r.imports) { - const typeTag = i.typeOnly ? ' (type-only)' : ''; - console.log(` -> ${i.file}${typeTag}`); - } - console.log(`\n <- Imported by (${r.importedBy.length}):`); - for (const i of r.importedBy) console.log(` <- ${i.file}`); - if (r.definitions.length > 0) { - console.log(`\n Definitions (${r.definitions.length}):`); - for (const d of r.definitions.slice(0, 30)) - console.log(` ${kindIcon(d.kind)} ${d.name} :${d.line}`); - if (r.definitions.length > 30) console.log(` ... and ${r.definitions.length - 30} more`); - } - console.log(); - } -} - -// ─── fnDeps ───────────────────────────────────────────────────────────── - -export function fnDeps(name, customDbPath, opts = {}) { - const data = fnDepsData(name, customDbPath, opts); - if (outputResult(data, 'results', opts)) return; - - if (data.results.length === 0) { - console.log(`No function/method/class matching "${name}"`); - return; - } - - for (const r of data.results) { - console.log(`\n${kindIcon(r.kind)} ${r.name} (${r.kind}) -- ${r.file}:${r.line}\n`); - if (r.callees.length > 0) { - console.log(` -> Calls (${r.callees.length}):`); - for (const c of r.callees) - console.log(` -> ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); - } - if (r.callers.length > 0) { - console.log(`\n <- Called by (${r.callers.length}):`); - for (const c of r.callers) { - const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : ''; - console.log(` <- ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`); - } - } - for (const [d, fns] of Object.entries(r.transitiveCallers)) { - console.log( - `\n ${'<-'.repeat(parseInt(d, 10))} Transitive callers (depth ${d}, ${fns.length}):`, - ); - for (const n of fns.slice(0, 20)) - console.log( - ` ${' '.repeat(parseInt(d, 10) - 1)}<- ${kindIcon(n.kind)} ${n.name} ${n.file}:${n.line}`, - ); - if (fns.length > 20) console.log(` ... and ${fns.length - 20} more`); - } - if (r.callees.length === 0 && r.callers.length === 0) { - console.log(` (no call edges found -- may be invoked dynamically or via re-exports)`); - } - console.log(); - } -} - -// ─── context ──────────────────────────────────────────────────────────── - -export function context(name, customDbPath, opts = {}) { - const data = contextData(name, customDbPath, opts); - if (outputResult(data, 'results', opts)) return; - - if (data.results.length === 0) { - console.log(`No function/method/class matching "${name}"`); - return; - } - - for (const r of data.results) { - const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`; - const roleTag = r.role ? ` [${r.role}]` : ''; - console.log(`\n# ${r.name} (${r.kind})${roleTag} — ${r.file}:${lineRange}\n`); - - // Signature - if (r.signature) { - console.log('## Type/Shape Info'); - if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`); - if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`); - console.log(); - } - - // Children - if (r.children && r.children.length > 0) { - console.log(`## Children (${r.children.length})`); - for (const c of r.children) { - console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`); - } - console.log(); - } - - // Complexity - if (r.complexity) { - const cx = r.complexity; - const miPart = cx.maintainabilityIndex ? ` | MI: ${cx.maintainabilityIndex}` : ''; - console.log('## Complexity'); - console.log( - ` Cognitive: ${cx.cognitive} | Cyclomatic: ${cx.cyclomatic} | Max Nesting: ${cx.maxNesting}${miPart}`, - ); - console.log(); - } - - // Source - if (r.source) { - console.log('## Source'); - for (const line of r.source.split('\n')) { - console.log(` ${line}`); - } - console.log(); - } - - // Callees - if (r.callees.length > 0) { - console.log(`## Direct Dependencies (${r.callees.length})`); - for (const c of r.callees) { - const summary = c.summary ? ` — ${c.summary}` : ''; - console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${summary}`); - if (c.source) { - for (const line of c.source.split('\n').slice(0, 10)) { - console.log(` | ${line}`); - } - } - } - console.log(); - } - - // Callers - if (r.callers.length > 0) { - console.log(`## Callers (${r.callers.length})`); - for (const c of r.callers) { - const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : ''; - console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`); - } - console.log(); - } - - // Related tests - if (r.relatedTests.length > 0) { - console.log('## Related Tests'); - for (const t of r.relatedTests) { - console.log(` ${t.file} — ${t.testCount} tests`); - for (const tn of t.testNames) { - console.log(` - ${tn}`); - } - if (t.source) { - console.log(' Source:'); - for (const line of t.source.split('\n').slice(0, 20)) { - console.log(` | ${line}`); - } - } - } - console.log(); - } - - if (r.callees.length === 0 && r.callers.length === 0 && r.relatedTests.length === 0) { - console.log( - ' (no call edges or tests found — may be invoked dynamically or via re-exports)', - ); - console.log(); - } - } -} - -// ─── children ─────────────────────────────────────────────────────────── - -export function children(name, customDbPath, opts = {}) { - const data = childrenData(name, customDbPath, opts); - if (outputResult(data, 'results', opts)) return; - - if (data.results.length === 0) { - console.log(`No symbol matching "${name}"`); - return; - } - for (const r of data.results) { - console.log(`\n${kindIcon(r.kind)} ${r.name} ${r.file}:${r.line}`); - if (r.children.length === 0) { - console.log(' (no children)'); - } else { - for (const c of r.children) { - console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`); - } - } - } -} - -// ─── explain ──────────────────────────────────────────────────────────── - -export function explain(target, customDbPath, opts = {}) { - const data = explainData(target, customDbPath, opts); - if (outputResult(data, 'results', opts)) return; - - if (data.results.length === 0) { - console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`); - return; - } - - if (data.kind === 'file') { - for (const r of data.results) { - const publicCount = r.publicApi.length; - const internalCount = r.internal.length; - const lineInfo = r.lineCount ? `${r.lineCount} lines, ` : ''; - console.log(`\n# ${r.file}`); - console.log( - ` ${lineInfo}${r.symbolCount} symbols (${publicCount} exported, ${internalCount} internal)`, - ); - - if (r.imports.length > 0) { - console.log(` Imports: ${r.imports.map((i) => i.file).join(', ')}`); - } - if (r.importedBy.length > 0) { - console.log(` Imported by: ${r.importedBy.map((i) => i.file).join(', ')}`); - } - - if (r.publicApi.length > 0) { - console.log(`\n## Exported`); - for (const s of r.publicApi) { - const sig = s.signature?.params != null ? `(${s.signature.params})` : ''; - const roleTag = s.role ? ` [${s.role}]` : ''; - const summary = s.summary ? ` -- ${s.summary}` : ''; - console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`); - } - } - - if (r.internal.length > 0) { - console.log(`\n## Internal`); - for (const s of r.internal) { - const sig = s.signature?.params != null ? `(${s.signature.params})` : ''; - const roleTag = s.role ? ` [${s.role}]` : ''; - const summary = s.summary ? ` -- ${s.summary}` : ''; - console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`); - } - } - - if (r.dataFlow.length > 0) { - console.log(`\n## Data Flow`); - for (const df of r.dataFlow) { - console.log(` ${df.caller} -> ${df.callees.join(', ')}`); - } - } - console.log(); - } - } else { - function printFunctionExplain(r, indent = '') { - const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`; - const lineInfo = r.lineCount ? `${r.lineCount} lines` : ''; - const summaryPart = r.summary ? ` | ${r.summary}` : ''; - const roleTag = r.role ? ` [${r.role}]` : ''; - const depthLevel = r._depth || 0; - const heading = depthLevel === 0 ? '#' : '##'.padEnd(depthLevel + 2, '#'); - console.log(`\n${indent}${heading} ${r.name} (${r.kind})${roleTag} ${r.file}:${lineRange}`); - if (lineInfo || r.summary) { - console.log(`${indent} ${lineInfo}${summaryPart}`); - } - if (r.signature) { - if (r.signature.params != null) - console.log(`${indent} Parameters: (${r.signature.params})`); - if (r.signature.returnType) console.log(`${indent} Returns: ${r.signature.returnType}`); - } - - if (r.complexity) { - const cx = r.complexity; - const miPart = cx.maintainabilityIndex ? ` MI=${cx.maintainabilityIndex}` : ''; - console.log( - `${indent} Complexity: cognitive=${cx.cognitive} cyclomatic=${cx.cyclomatic} nesting=${cx.maxNesting}${miPart}`, - ); - } - - if (r.callees.length > 0) { - console.log(`\n${indent} Calls (${r.callees.length}):`); - for (const c of r.callees) { - console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); - } - } - - if (r.callers.length > 0) { - console.log(`\n${indent} Called by (${r.callers.length}):`); - for (const c of r.callers) { - console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); - } - } - - if (r.relatedTests.length > 0) { - const label = r.relatedTests.length === 1 ? 'file' : 'files'; - console.log(`\n${indent} Tests (${r.relatedTests.length} ${label}):`); - for (const t of r.relatedTests) { - console.log(`${indent} ${t.file}`); - } - } - - if (r.callees.length === 0 && r.callers.length === 0) { - console.log( - `${indent} (no call edges found -- may be invoked dynamically or via re-exports)`, - ); - } - - // Render recursive dependency details - if (r.depDetails && r.depDetails.length > 0) { - console.log(`\n${indent} --- Dependencies (depth ${depthLevel + 1}) ---`); - for (const dep of r.depDetails) { - printFunctionExplain(dep, `${indent} `); - } - } - console.log(); - } - - for (const r of data.results) { - printFunctionExplain(r); - } - } -} - -// ─── where ────────────────────────────────────────────────────────────── - -export function where(target, customDbPath, opts = {}) { - const data = whereData(target, customDbPath, opts); - if (outputResult(data, 'results', opts)) return; - - if (data.results.length === 0) { - console.log( - data.mode === 'file' - ? `No file matching "${target}" in graph` - : `No symbol matching "${target}" in graph`, - ); - return; - } - - if (data.mode === 'symbol') { - for (const r of data.results) { - const roleTag = r.role ? ` [${r.role}]` : ''; - const tag = r.exported ? ' (exported)' : ''; - console.log(`\n${kindIcon(r.kind)} ${r.name}${roleTag} ${r.file}:${r.line}${tag}`); - if (r.uses.length > 0) { - const useStrs = r.uses.map((u) => `${u.file}:${u.line}`); - console.log(` Used in: ${useStrs.join(', ')}`); - } else { - console.log(' No uses found'); - } - } - } else { - for (const r of data.results) { - console.log(`\n# ${r.file}`); - if (r.symbols.length > 0) { - const symStrs = r.symbols.map((s) => `${s.name}:${s.line}`); - console.log(` Symbols: ${symStrs.join(', ')}`); - } - if (r.imports.length > 0) { - console.log(` Imports: ${r.imports.join(', ')}`); - } - if (r.importedBy.length > 0) { - console.log(` Imported by: ${r.importedBy.join(', ')}`); - } - if (r.exported.length > 0) { - console.log(` Exported: ${r.exported.join(', ')}`); - } - } - } - console.log(); -} - -// ─── roles ────────────────────────────────────────────────────────────── - -export function roles(customDbPath, opts = {}) { - const data = rolesData(customDbPath, opts); - if (outputResult(data, 'symbols', opts)) return; - - if (data.count === 0) { - console.log('No classified symbols found. Run "codegraph build" first.'); - return; - } - - const total = data.count; - console.log(`\nNode roles (${total} symbols):\n`); - - const summaryParts = Object.entries(data.summary) - .sort((a, b) => b[1] - a[1]) - .map(([role, count]) => `${role}: ${count}`); - console.log(` ${summaryParts.join(' ')}\n`); - - const byRole = {}; - for (const s of data.symbols) { - if (!byRole[s.role]) byRole[s.role] = []; - byRole[s.role].push(s); - } - - for (const [role, symbols] of Object.entries(byRole)) { - console.log(`## ${role} (${symbols.length})`); - for (const s of symbols.slice(0, 30)) { - console.log(` ${kindIcon(s.kind)} ${s.name} ${s.file}:${s.line}`); - } - if (symbols.length > 30) { - console.log(` ... and ${symbols.length - 30} more`); - } - console.log(); - } -} - -// ─── fileExports ──────────────────────────────────────────────────────── - -export function fileExports(file, customDbPath, opts = {}) { - const data = exportsData(file, customDbPath, opts); - if (outputResult(data, 'results', opts)) return; - - if (data.results.length === 0) { - if (opts.unused) { - console.log(`No unused exports found for "${file}".`); - } else { - console.log(`No exported symbols found for "${file}". Run "codegraph build" first.`); - } - return; - } - - if (opts.unused) { - console.log( - `\n# ${data.file} — ${data.totalUnused} unused export${data.totalUnused !== 1 ? 's' : ''} (of ${data.totalExported} exported)\n`, - ); - } else { - const unusedNote = data.totalUnused > 0 ? ` (${data.totalUnused} unused)` : ''; - console.log( - `\n# ${data.file} — ${data.totalExported} exported${unusedNote}, ${data.totalInternal} internal\n`, - ); - } - - for (const sym of data.results) { - const icon = kindIcon(sym.kind); - const sig = sym.signature?.params ? `(${sym.signature.params})` : ''; - const role = sym.role ? ` [${sym.role}]` : ''; - console.log(` ${icon} ${sym.name}${sig}${role} :${sym.line}`); - if (sym.consumers.length === 0) { - console.log(' (no consumers)'); - } else { - for (const c of sym.consumers) { - console.log(` <- ${c.name} (${c.file}:${c.line})`); - } - } - } - - if (data.reexports.length > 0) { - console.log(`\n Re-exports: ${data.reexports.map((r) => r.file).join(', ')}`); - } - console.log(); -} - -// ─── fnImpact ─────────────────────────────────────────────────────────── - -export function fnImpact(name, customDbPath, opts = {}) { - const data = fnImpactData(name, customDbPath, opts); - if (outputResult(data, 'results', opts)) return; - - if (data.results.length === 0) { - console.log(`No function/method/class matching "${name}"`); - return; - } - - for (const r of data.results) { - console.log(`\nFunction impact: ${kindIcon(r.kind)} ${r.name} -- ${r.file}:${r.line}\n`); - if (Object.keys(r.levels).length === 0) { - console.log(` No callers found.`); - } else { - for (const [level, fns] of Object.entries(r.levels).sort((a, b) => a[0] - b[0])) { - const l = parseInt(level, 10); - console.log(` ${'--'.repeat(l)} Level ${level} (${fns.length} functions):`); - for (const f of fns.slice(0, 20)) - console.log(` ${' '.repeat(l)}^ ${kindIcon(f.kind)} ${f.name} ${f.file}:${f.line}`); - if (fns.length > 20) console.log(` ... and ${fns.length - 20} more`); - } - } - console.log(`\n Total: ${r.totalDependents} functions transitively depend on ${r.name}\n`); - } -} - -// ─── diffImpact ───────────────────────────────────────────────────────── - -export function diffImpact(customDbPath, opts = {}) { - if (opts.format === 'mermaid') { - console.log(diffImpactMermaid(customDbPath, opts)); - return; - } - const data = diffImpactData(customDbPath, opts); - if (opts.format === 'json') opts = { ...opts, json: true }; - if (outputResult(data, 'affectedFunctions', opts)) return; - - if (data.error) { - console.log(data.error); - return; - } - if (data.changedFiles === 0) { - console.log('No changes detected.'); - return; - } - if (data.affectedFunctions.length === 0) { - console.log( - ' No function-level changes detected (changes may be in imports, types, or config).', - ); - return; - } - - console.log(`\ndiff-impact: ${data.changedFiles} files changed\n`); - console.log(` ${data.affectedFunctions.length} functions changed:\n`); - for (const fn of data.affectedFunctions) { - console.log(` ${kindIcon(fn.kind)} ${fn.name} -- ${fn.file}:${fn.line}`); - if (fn.transitiveCallers > 0) console.log(` ^ ${fn.transitiveCallers} transitive callers`); - } - if (data.historicallyCoupled && data.historicallyCoupled.length > 0) { - console.log('\n Historically coupled (not in static graph):\n'); - for (const c of data.historicallyCoupled) { - const pct = `${(c.jaccard * 100).toFixed(0)}%`; - console.log( - ` ${c.file} <- coupled with ${c.coupledWith} (${pct}, ${c.commitCount} commits)`, - ); - } - } - if (data.ownership) { - console.log(`\n Affected owners: ${data.ownership.affectedOwners.join(', ')}`); - console.log(` Suggested reviewers: ${data.ownership.suggestedReviewers.join(', ')}`); - } - if (data.boundaryViolations && data.boundaryViolations.length > 0) { - console.log(`\n Boundary violations (${data.boundaryViolationCount}):\n`); - for (const v of data.boundaryViolations) { - console.log(` [${v.name}] ${v.file} -> ${v.targetFile}`); - if (v.message) console.log(` ${v.message}`); - } - } - if (data.summary) { - let summaryLine = `\n Summary: ${data.summary.functionsChanged} functions changed -> ${data.summary.callersAffected} callers affected across ${data.summary.filesAffected} files`; - if (data.summary.historicallyCoupledCount > 0) { - summaryLine += `, ${data.summary.historicallyCoupledCount} historically coupled`; - } - if (data.summary.ownersAffected > 0) { - summaryLine += `, ${data.summary.ownersAffected} owners affected`; - } - if (data.summary.boundaryViolationCount > 0) { - summaryLine += `, ${data.summary.boundaryViolationCount} boundary violations`; - } - console.log(`${summaryLine}\n`); - } -} +export { + children, + context, + diffImpact, + explain, + fileDeps, + fileExports, + fnDeps, + fnImpact, + impactAnalysis, + moduleMap, + queryName, + roles, + stats, + symbolPath, + where, +} from './queries-cli/index.js'; diff --git a/src/presentation/queries-cli/exports.js b/src/presentation/queries-cli/exports.js new file mode 100644 index 00000000..ea7dcade --- /dev/null +++ b/src/presentation/queries-cli/exports.js @@ -0,0 +1,46 @@ +import { exportsData, kindIcon } from '../../domain/queries.js'; +import { outputResult } from '../../infrastructure/result-formatter.js'; + +export function fileExports(file, customDbPath, opts = {}) { + const data = exportsData(file, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + if (opts.unused) { + console.log(`No unused exports found for "${file}".`); + } else { + console.log(`No exported symbols found for "${file}". Run "codegraph build" first.`); + } + return; + } + + if (opts.unused) { + console.log( + `\n# ${data.file} — ${data.totalUnused} unused export${data.totalUnused !== 1 ? 's' : ''} (of ${data.totalExported} exported)\n`, + ); + } else { + const unusedNote = data.totalUnused > 0 ? ` (${data.totalUnused} unused)` : ''; + console.log( + `\n# ${data.file} — ${data.totalExported} exported${unusedNote}, ${data.totalInternal} internal\n`, + ); + } + + for (const sym of data.results) { + const icon = kindIcon(sym.kind); + const sig = sym.signature?.params ? `(${sym.signature.params})` : ''; + const role = sym.role ? ` [${sym.role}]` : ''; + console.log(` ${icon} ${sym.name}${sig}${role} :${sym.line}`); + if (sym.consumers.length === 0) { + console.log(' (no consumers)'); + } else { + for (const c of sym.consumers) { + console.log(` <- ${c.name} (${c.file}:${c.line})`); + } + } + } + + if (data.reexports.length > 0) { + console.log(`\n Re-exports: ${data.reexports.map((r) => r.file).join(', ')}`); + } + console.log(); +} diff --git a/src/presentation/queries-cli/impact.js b/src/presentation/queries-cli/impact.js new file mode 100644 index 00000000..176172be --- /dev/null +++ b/src/presentation/queries-cli/impact.js @@ -0,0 +1,198 @@ +import { + diffImpactData, + diffImpactMermaid, + fileDepsData, + fnDepsData, + fnImpactData, + impactAnalysisData, + kindIcon, +} from '../../domain/queries.js'; +import { outputResult } from '../../infrastructure/result-formatter.js'; + +export function fileDeps(file, customDbPath, opts = {}) { + const data = fileDepsData(file, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No file matching "${file}" in graph`); + return; + } + + for (const r of data.results) { + console.log(`\n# ${r.file}\n`); + console.log(` -> Imports (${r.imports.length}):`); + for (const i of r.imports) { + const typeTag = i.typeOnly ? ' (type-only)' : ''; + console.log(` -> ${i.file}${typeTag}`); + } + console.log(`\n <- Imported by (${r.importedBy.length}):`); + for (const i of r.importedBy) console.log(` <- ${i.file}`); + if (r.definitions.length > 0) { + console.log(`\n Definitions (${r.definitions.length}):`); + for (const d of r.definitions.slice(0, 30)) + console.log(` ${kindIcon(d.kind)} ${d.name} :${d.line}`); + if (r.definitions.length > 30) console.log(` ... and ${r.definitions.length - 30} more`); + } + console.log(); + } +} + +export function fnDeps(name, customDbPath, opts = {}) { + const data = fnDepsData(name, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No function/method/class matching "${name}"`); + return; + } + + for (const r of data.results) { + console.log(`\n${kindIcon(r.kind)} ${r.name} (${r.kind}) -- ${r.file}:${r.line}\n`); + if (r.callees.length > 0) { + console.log(` -> Calls (${r.callees.length}):`); + for (const c of r.callees) + console.log(` -> ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); + } + if (r.callers.length > 0) { + console.log(`\n <- Called by (${r.callers.length}):`); + for (const c of r.callers) { + const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : ''; + console.log(` <- ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`); + } + } + for (const [d, fns] of Object.entries(r.transitiveCallers)) { + console.log( + `\n ${'<-'.repeat(parseInt(d, 10))} Transitive callers (depth ${d}, ${fns.length}):`, + ); + for (const n of fns.slice(0, 20)) + console.log( + ` ${' '.repeat(parseInt(d, 10) - 1)}<- ${kindIcon(n.kind)} ${n.name} ${n.file}:${n.line}`, + ); + if (fns.length > 20) console.log(` ... and ${fns.length - 20} more`); + } + if (r.callees.length === 0 && r.callers.length === 0) { + console.log(` (no call edges found -- may be invoked dynamically or via re-exports)`); + } + console.log(); + } +} + +export function impactAnalysis(file, customDbPath, opts = {}) { + const data = impactAnalysisData(file, customDbPath, opts); + if (outputResult(data, 'sources', opts)) return; + + if (data.sources.length === 0) { + console.log(`No file matching "${file}" in graph`); + return; + } + + console.log(`\nImpact analysis for files matching "${file}":\n`); + for (const s of data.sources) console.log(` # ${s} (source)`); + + const levels = data.levels; + if (Object.keys(levels).length === 0) { + console.log(` No dependents found.`); + } else { + for (const level of Object.keys(levels).sort((a, b) => a - b)) { + const nodes = levels[level]; + console.log( + `\n ${'--'.repeat(parseInt(level, 10))} Level ${level} (${nodes.length} files):`, + ); + for (const n of nodes.slice(0, 30)) + console.log(` ${' '.repeat(parseInt(level, 10))}^ ${n.file}`); + if (nodes.length > 30) console.log(` ... and ${nodes.length - 30} more`); + } + } + console.log(`\n Total: ${data.totalDependents} files transitively depend on "${file}"\n`); +} + +export function fnImpact(name, customDbPath, opts = {}) { + const data = fnImpactData(name, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No function/method/class matching "${name}"`); + return; + } + + for (const r of data.results) { + console.log(`\nFunction impact: ${kindIcon(r.kind)} ${r.name} -- ${r.file}:${r.line}\n`); + if (Object.keys(r.levels).length === 0) { + console.log(` No callers found.`); + } else { + for (const [level, fns] of Object.entries(r.levels).sort((a, b) => a[0] - b[0])) { + const l = parseInt(level, 10); + console.log(` ${'--'.repeat(l)} Level ${level} (${fns.length} functions):`); + for (const f of fns.slice(0, 20)) + console.log(` ${' '.repeat(l)}^ ${kindIcon(f.kind)} ${f.name} ${f.file}:${f.line}`); + if (fns.length > 20) console.log(` ... and ${fns.length - 20} more`); + } + } + console.log(`\n Total: ${r.totalDependents} functions transitively depend on ${r.name}\n`); + } +} + +export function diffImpact(customDbPath, opts = {}) { + if (opts.format === 'mermaid') { + console.log(diffImpactMermaid(customDbPath, opts)); + return; + } + const data = diffImpactData(customDbPath, opts); + if (opts.format === 'json') opts = { ...opts, json: true }; + if (outputResult(data, 'affectedFunctions', opts)) return; + + if (data.error) { + console.log(data.error); + return; + } + if (data.changedFiles === 0) { + console.log('No changes detected.'); + return; + } + if (data.affectedFunctions.length === 0) { + console.log( + ' No function-level changes detected (changes may be in imports, types, or config).', + ); + return; + } + + console.log(`\ndiff-impact: ${data.changedFiles} files changed\n`); + console.log(` ${data.affectedFunctions.length} functions changed:\n`); + for (const fn of data.affectedFunctions) { + console.log(` ${kindIcon(fn.kind)} ${fn.name} -- ${fn.file}:${fn.line}`); + if (fn.transitiveCallers > 0) console.log(` ^ ${fn.transitiveCallers} transitive callers`); + } + if (data.historicallyCoupled && data.historicallyCoupled.length > 0) { + console.log('\n Historically coupled (not in static graph):\n'); + for (const c of data.historicallyCoupled) { + const pct = `${(c.jaccard * 100).toFixed(0)}%`; + console.log( + ` ${c.file} <- coupled with ${c.coupledWith} (${pct}, ${c.commitCount} commits)`, + ); + } + } + if (data.ownership) { + console.log(`\n Affected owners: ${data.ownership.affectedOwners.join(', ')}`); + console.log(` Suggested reviewers: ${data.ownership.suggestedReviewers.join(', ')}`); + } + if (data.boundaryViolations && data.boundaryViolations.length > 0) { + console.log(`\n Boundary violations (${data.boundaryViolationCount}):\n`); + for (const v of data.boundaryViolations) { + console.log(` [${v.name}] ${v.file} -> ${v.targetFile}`); + if (v.message) console.log(` ${v.message}`); + } + } + if (data.summary) { + let summaryLine = `\n Summary: ${data.summary.functionsChanged} functions changed -> ${data.summary.callersAffected} callers affected across ${data.summary.filesAffected} files`; + if (data.summary.historicallyCoupledCount > 0) { + summaryLine += `, ${data.summary.historicallyCoupledCount} historically coupled`; + } + if (data.summary.ownersAffected > 0) { + summaryLine += `, ${data.summary.ownersAffected} owners affected`; + } + if (data.summary.boundaryViolationCount > 0) { + summaryLine += `, ${data.summary.boundaryViolationCount} boundary violations`; + } + console.log(`${summaryLine}\n`); + } +} diff --git a/src/presentation/queries-cli/index.js b/src/presentation/queries-cli/index.js new file mode 100644 index 00000000..40aae323 --- /dev/null +++ b/src/presentation/queries-cli/index.js @@ -0,0 +1,5 @@ +export { fileExports } from './exports.js'; +export { diffImpact, fileDeps, fnDeps, fnImpact, impactAnalysis } from './impact.js'; +export { children, context, explain, queryName, where } from './inspect.js'; +export { moduleMap, roles, stats } from './overview.js'; +export { symbolPath } from './path.js'; diff --git a/src/presentation/queries-cli/inspect.js b/src/presentation/queries-cli/inspect.js new file mode 100644 index 00000000..5a3ddcb7 --- /dev/null +++ b/src/presentation/queries-cli/inspect.js @@ -0,0 +1,334 @@ +import { + childrenData, + contextData, + explainData, + kindIcon, + queryNameData, + whereData, +} from '../../domain/queries.js'; +import { outputResult } from '../../infrastructure/result-formatter.js'; + +export function where(target, customDbPath, opts = {}) { + const data = whereData(target, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log( + data.mode === 'file' + ? `No file matching "${target}" in graph` + : `No symbol matching "${target}" in graph`, + ); + return; + } + + if (data.mode === 'symbol') { + for (const r of data.results) { + const roleTag = r.role ? ` [${r.role}]` : ''; + const tag = r.exported ? ' (exported)' : ''; + console.log(`\n${kindIcon(r.kind)} ${r.name}${roleTag} ${r.file}:${r.line}${tag}`); + if (r.uses.length > 0) { + const useStrs = r.uses.map((u) => `${u.file}:${u.line}`); + console.log(` Used in: ${useStrs.join(', ')}`); + } else { + console.log(' No uses found'); + } + } + } else { + for (const r of data.results) { + console.log(`\n# ${r.file}`); + if (r.symbols.length > 0) { + const symStrs = r.symbols.map((s) => `${s.name}:${s.line}`); + console.log(` Symbols: ${symStrs.join(', ')}`); + } + if (r.imports.length > 0) { + console.log(` Imports: ${r.imports.join(', ')}`); + } + if (r.importedBy.length > 0) { + console.log(` Imported by: ${r.importedBy.join(', ')}`); + } + if (r.exported.length > 0) { + console.log(` Exported: ${r.exported.join(', ')}`); + } + } + } + console.log(); +} + +export function queryName(name, customDbPath, opts = {}) { + const data = queryNameData(name, customDbPath, { + noTests: opts.noTests, + limit: opts.limit, + offset: opts.offset, + }); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No results for "${name}"`); + return; + } + + console.log(`\nResults for "${name}":\n`); + for (const r of data.results) { + console.log(` ${kindIcon(r.kind)} ${r.name} (${r.kind}) -- ${r.file}:${r.line}`); + if (r.callees.length > 0) { + console.log(` -> calls/uses:`); + for (const c of r.callees.slice(0, 15)) + console.log(` -> ${c.name} (${c.edgeKind}) ${c.file}:${c.line}`); + if (r.callees.length > 15) console.log(` ... and ${r.callees.length - 15} more`); + } + if (r.callers.length > 0) { + console.log(` <- called by:`); + for (const c of r.callers.slice(0, 15)) + console.log(` <- ${c.name} (${c.edgeKind}) ${c.file}:${c.line}`); + if (r.callers.length > 15) console.log(` ... and ${r.callers.length - 15} more`); + } + console.log(); + } +} + +export function context(name, customDbPath, opts = {}) { + const data = contextData(name, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No function/method/class matching "${name}"`); + return; + } + + for (const r of data.results) { + const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`; + const roleTag = r.role ? ` [${r.role}]` : ''; + console.log(`\n# ${r.name} (${r.kind})${roleTag} — ${r.file}:${lineRange}\n`); + + // Signature + if (r.signature) { + console.log('## Type/Shape Info'); + if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`); + if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`); + console.log(); + } + + // Children + if (r.children && r.children.length > 0) { + console.log(`## Children (${r.children.length})`); + for (const c of r.children) { + console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`); + } + console.log(); + } + + // Complexity + if (r.complexity) { + const cx = r.complexity; + const miPart = cx.maintainabilityIndex ? ` | MI: ${cx.maintainabilityIndex}` : ''; + console.log('## Complexity'); + console.log( + ` Cognitive: ${cx.cognitive} | Cyclomatic: ${cx.cyclomatic} | Max Nesting: ${cx.maxNesting}${miPart}`, + ); + console.log(); + } + + // Source + if (r.source) { + console.log('## Source'); + for (const line of r.source.split('\n')) { + console.log(` ${line}`); + } + console.log(); + } + + // Callees + if (r.callees.length > 0) { + console.log(`## Direct Dependencies (${r.callees.length})`); + for (const c of r.callees) { + const summary = c.summary ? ` — ${c.summary}` : ''; + console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${summary}`); + if (c.source) { + for (const line of c.source.split('\n').slice(0, 10)) { + console.log(` | ${line}`); + } + } + } + console.log(); + } + + // Callers + if (r.callers.length > 0) { + console.log(`## Callers (${r.callers.length})`); + for (const c of r.callers) { + const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : ''; + console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`); + } + console.log(); + } + + // Related tests + if (r.relatedTests.length > 0) { + console.log('## Related Tests'); + for (const t of r.relatedTests) { + console.log(` ${t.file} — ${t.testCount} tests`); + for (const tn of t.testNames) { + console.log(` - ${tn}`); + } + if (t.source) { + console.log(' Source:'); + for (const line of t.source.split('\n').slice(0, 20)) { + console.log(` | ${line}`); + } + } + } + console.log(); + } + + if (r.callees.length === 0 && r.callers.length === 0 && r.relatedTests.length === 0) { + console.log( + ' (no call edges or tests found — may be invoked dynamically or via re-exports)', + ); + console.log(); + } + } +} + +export function children(name, customDbPath, opts = {}) { + const data = childrenData(name, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No symbol matching "${name}"`); + return; + } + for (const r of data.results) { + console.log(`\n${kindIcon(r.kind)} ${r.name} ${r.file}:${r.line}`); + if (r.children.length === 0) { + console.log(' (no children)'); + } else { + for (const c of r.children) { + console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`); + } + } + } +} + +export function explain(target, customDbPath, opts = {}) { + const data = explainData(target, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`); + return; + } + + if (data.kind === 'file') { + for (const r of data.results) { + const publicCount = r.publicApi.length; + const internalCount = r.internal.length; + const lineInfo = r.lineCount ? `${r.lineCount} lines, ` : ''; + console.log(`\n# ${r.file}`); + console.log( + ` ${lineInfo}${r.symbolCount} symbols (${publicCount} exported, ${internalCount} internal)`, + ); + + if (r.imports.length > 0) { + console.log(` Imports: ${r.imports.map((i) => i.file).join(', ')}`); + } + if (r.importedBy.length > 0) { + console.log(` Imported by: ${r.importedBy.map((i) => i.file).join(', ')}`); + } + + if (r.publicApi.length > 0) { + console.log(`\n## Exported`); + for (const s of r.publicApi) { + const sig = s.signature?.params != null ? `(${s.signature.params})` : ''; + const roleTag = s.role ? ` [${s.role}]` : ''; + const summary = s.summary ? ` -- ${s.summary}` : ''; + console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`); + } + } + + if (r.internal.length > 0) { + console.log(`\n## Internal`); + for (const s of r.internal) { + const sig = s.signature?.params != null ? `(${s.signature.params})` : ''; + const roleTag = s.role ? ` [${s.role}]` : ''; + const summary = s.summary ? ` -- ${s.summary}` : ''; + console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`); + } + } + + if (r.dataFlow.length > 0) { + console.log(`\n## Data Flow`); + for (const df of r.dataFlow) { + console.log(` ${df.caller} -> ${df.callees.join(', ')}`); + } + } + console.log(); + } + } else { + function printFunctionExplain(r, indent = '') { + const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`; + const lineInfo = r.lineCount ? `${r.lineCount} lines` : ''; + const summaryPart = r.summary ? ` | ${r.summary}` : ''; + const roleTag = r.role ? ` [${r.role}]` : ''; + const depthLevel = r._depth || 0; + const heading = depthLevel === 0 ? '#' : '##'.padEnd(depthLevel + 2, '#'); + console.log(`\n${indent}${heading} ${r.name} (${r.kind})${roleTag} ${r.file}:${lineRange}`); + if (lineInfo || r.summary) { + console.log(`${indent} ${lineInfo}${summaryPart}`); + } + if (r.signature) { + if (r.signature.params != null) + console.log(`${indent} Parameters: (${r.signature.params})`); + if (r.signature.returnType) console.log(`${indent} Returns: ${r.signature.returnType}`); + } + + if (r.complexity) { + const cx = r.complexity; + const miPart = cx.maintainabilityIndex ? ` MI=${cx.maintainabilityIndex}` : ''; + console.log( + `${indent} Complexity: cognitive=${cx.cognitive} cyclomatic=${cx.cyclomatic} nesting=${cx.maxNesting}${miPart}`, + ); + } + + if (r.callees.length > 0) { + console.log(`\n${indent} Calls (${r.callees.length}):`); + for (const c of r.callees) { + console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); + } + } + + if (r.callers.length > 0) { + console.log(`\n${indent} Called by (${r.callers.length}):`); + for (const c of r.callers) { + console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); + } + } + + if (r.relatedTests.length > 0) { + const label = r.relatedTests.length === 1 ? 'file' : 'files'; + console.log(`\n${indent} Tests (${r.relatedTests.length} ${label}):`); + for (const t of r.relatedTests) { + console.log(`${indent} ${t.file}`); + } + } + + if (r.callees.length === 0 && r.callers.length === 0) { + console.log( + `${indent} (no call edges found -- may be invoked dynamically or via re-exports)`, + ); + } + + // Render recursive dependency details + if (r.depDetails && r.depDetails.length > 0) { + console.log(`\n${indent} --- Dependencies (depth ${depthLevel + 1}) ---`); + for (const dep of r.depDetails) { + printFunctionExplain(dep, `${indent} `); + } + } + console.log(); + } + + for (const r of data.results) { + printFunctionExplain(r); + } + } +} diff --git a/src/presentation/queries-cli/overview.js b/src/presentation/queries-cli/overview.js new file mode 100644 index 00000000..62593c68 --- /dev/null +++ b/src/presentation/queries-cli/overview.js @@ -0,0 +1,197 @@ +import path from 'node:path'; +import { kindIcon, moduleMapData, rolesData, statsData } from '../../domain/queries.js'; +import { outputResult } from '../../infrastructure/result-formatter.js'; + +export async function stats(customDbPath, opts = {}) { + const data = statsData(customDbPath, { noTests: opts.noTests }); + + // Community detection summary (async import for lazy-loading) + try { + const { communitySummaryForStats } = await import('../communities.js'); + data.communities = communitySummaryForStats(customDbPath, { noTests: opts.noTests }); + } catch { + /* graphology may not be available */ + } + + if (outputResult(data, null, opts)) return; + + // Human-readable output + console.log('\n# Codegraph Stats\n'); + + // Nodes + console.log(`Nodes: ${data.nodes.total} total`); + const kindEntries = Object.entries(data.nodes.byKind).sort((a, b) => b[1] - a[1]); + const kindParts = kindEntries.map(([k, v]) => `${k} ${v}`); + for (let i = 0; i < kindParts.length; i += 3) { + const row = kindParts + .slice(i, i + 3) + .map((p) => p.padEnd(18)) + .join(''); + console.log(` ${row}`); + } + + // Edges + console.log(`\nEdges: ${data.edges.total} total`); + const edgeEntries = Object.entries(data.edges.byKind).sort((a, b) => b[1] - a[1]); + const edgeParts = edgeEntries.map(([k, v]) => `${k} ${v}`); + for (let i = 0; i < edgeParts.length; i += 3) { + const row = edgeParts + .slice(i, i + 3) + .map((p) => p.padEnd(18)) + .join(''); + console.log(` ${row}`); + } + + // Files + console.log(`\nFiles: ${data.files.total} (${data.files.languages} languages)`); + const langEntries = Object.entries(data.files.byLanguage).sort((a, b) => b[1] - a[1]); + const langParts = langEntries.map(([k, v]) => `${k} ${v}`); + for (let i = 0; i < langParts.length; i += 3) { + const row = langParts + .slice(i, i + 3) + .map((p) => p.padEnd(18)) + .join(''); + console.log(` ${row}`); + } + + // Cycles + console.log( + `\nCycles: ${data.cycles.fileLevel} file-level, ${data.cycles.functionLevel} function-level`, + ); + + // Hotspots + if (data.hotspots.length > 0) { + console.log(`\nTop ${data.hotspots.length} coupling hotspots:`); + for (let i = 0; i < data.hotspots.length; i++) { + const h = data.hotspots[i]; + console.log( + ` ${String(i + 1).padStart(2)}. ${h.file.padEnd(35)} fan-in: ${String(h.fanIn).padStart(3)} fan-out: ${String(h.fanOut).padStart(3)}`, + ); + } + } + + // Embeddings + if (data.embeddings) { + const e = data.embeddings; + console.log( + `\nEmbeddings: ${e.count} vectors (${e.model || 'unknown'}, ${e.dim || '?'}d) built ${e.builtAt || 'unknown'}`, + ); + } else { + console.log('\nEmbeddings: not built'); + } + + // Quality + if (data.quality) { + const q = data.quality; + const cc = q.callerCoverage; + const cf = q.callConfidence; + console.log(`\nGraph Quality: ${q.score}/100`); + console.log( + ` Caller coverage: ${(cc.ratio * 100).toFixed(1)}% (${cc.covered}/${cc.total} functions have >=1 caller)`, + ); + console.log( + ` Call confidence: ${(cf.ratio * 100).toFixed(1)}% (${cf.highConf}/${cf.total} call edges are high-confidence)`, + ); + if (q.falsePositiveWarnings.length > 0) { + console.log(' False-positive warnings:'); + for (const fp of q.falsePositiveWarnings) { + console.log(` ! ${fp.name} (${fp.callerCount} callers) -- ${fp.file}:${fp.line}`); + } + } + } + + // Roles + if (data.roles && Object.keys(data.roles).length > 0) { + const total = Object.values(data.roles).reduce((a, b) => a + b, 0); + console.log(`\nRoles: ${total} classified symbols`); + const roleParts = Object.entries(data.roles) + .sort((a, b) => b[1] - a[1]) + .map(([k, v]) => `${k} ${v}`); + for (let i = 0; i < roleParts.length; i += 3) { + const row = roleParts + .slice(i, i + 3) + .map((p) => p.padEnd(18)) + .join(''); + console.log(` ${row}`); + } + } + + // Complexity + if (data.complexity) { + const cx = data.complexity; + const miPart = cx.avgMI != null ? ` | avg MI: ${cx.avgMI} | min MI: ${cx.minMI}` : ''; + console.log( + `\nComplexity: ${cx.analyzed} functions | avg cognitive: ${cx.avgCognitive} | avg cyclomatic: ${cx.avgCyclomatic} | max cognitive: ${cx.maxCognitive}${miPart}`, + ); + } + + // Communities + if (data.communities) { + const cm = data.communities; + console.log( + `\nCommunities: ${cm.communityCount} detected | modularity: ${cm.modularity} | drift: ${cm.driftScore}%`, + ); + } + + console.log(); +} + +export function moduleMap(customDbPath, limit = 20, opts = {}) { + const data = moduleMapData(customDbPath, limit, { noTests: opts.noTests }); + if (outputResult(data, 'topNodes', opts)) return; + + console.log(`\nModule map (top ${limit} most-connected nodes):\n`); + const dirs = new Map(); + for (const n of data.topNodes) { + if (!dirs.has(n.dir)) dirs.set(n.dir, []); + dirs.get(n.dir).push(n); + } + for (const [dir, files] of [...dirs].sort()) { + console.log(` [${dir}/]`); + for (const f of files) { + const coupling = f.inEdges + f.outEdges; + const bar = '#'.repeat(Math.min(coupling, 40)); + console.log( + ` ${path.basename(f.file).padEnd(35)} <-${String(f.inEdges).padStart(3)} ->${String(f.outEdges).padStart(3)} =${String(coupling).padStart(3)} ${bar}`, + ); + } + } + console.log( + `\n Total: ${data.stats.totalFiles} files, ${data.stats.totalNodes} symbols, ${data.stats.totalEdges} edges\n`, + ); +} + +export function roles(customDbPath, opts = {}) { + const data = rolesData(customDbPath, opts); + if (outputResult(data, 'symbols', opts)) return; + + if (data.count === 0) { + console.log('No classified symbols found. Run "codegraph build" first.'); + return; + } + + const total = data.count; + console.log(`\nNode roles (${total} symbols):\n`); + + const summaryParts = Object.entries(data.summary) + .sort((a, b) => b[1] - a[1]) + .map(([role, count]) => `${role}: ${count}`); + console.log(` ${summaryParts.join(' ')}\n`); + + const byRole = {}; + for (const s of data.symbols) { + if (!byRole[s.role]) byRole[s.role] = []; + byRole[s.role].push(s); + } + + for (const [role, symbols] of Object.entries(byRole)) { + console.log(`## ${role} (${symbols.length})`); + for (const s of symbols.slice(0, 30)) { + console.log(` ${kindIcon(s.kind)} ${s.name} ${s.file}:${s.line}`); + } + if (symbols.length > 30) { + console.log(` ... and ${symbols.length - 30} more`); + } + console.log(); + } +} diff --git a/src/presentation/queries-cli/path.js b/src/presentation/queries-cli/path.js new file mode 100644 index 00000000..fbdaafa5 --- /dev/null +++ b/src/presentation/queries-cli/path.js @@ -0,0 +1,58 @@ +import { kindIcon, pathData } from '../../domain/queries.js'; +import { outputResult } from '../../infrastructure/result-formatter.js'; + +export function symbolPath(from, to, customDbPath, opts = {}) { + const data = pathData(from, to, customDbPath, opts); + if (outputResult(data, null, opts)) return; + + if (data.error) { + console.log(data.error); + return; + } + + if (!data.found) { + const dir = data.reverse ? 'reverse ' : ''; + console.log(`No ${dir}path from "${from}" to "${to}" within ${data.maxDepth} hops.`); + if (data.fromCandidates.length > 1) { + console.log( + `\n "${from}" matched ${data.fromCandidates.length} symbols — using top match: ${data.fromCandidates[0].name} (${data.fromCandidates[0].file}:${data.fromCandidates[0].line})`, + ); + } + if (data.toCandidates.length > 1) { + console.log( + ` "${to}" matched ${data.toCandidates.length} symbols — using top match: ${data.toCandidates[0].name} (${data.toCandidates[0].file}:${data.toCandidates[0].line})`, + ); + } + return; + } + + if (data.hops === 0) { + console.log(`\n"${from}" and "${to}" resolve to the same symbol (0 hops):`); + const n = data.path[0]; + console.log(` ${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}\n`); + return; + } + + const dir = data.reverse ? ' (reverse)' : ''; + console.log( + `\nPath from ${from} to ${to} (${data.hops} ${data.hops === 1 ? 'hop' : 'hops'})${dir}:\n`, + ); + for (let i = 0; i < data.path.length; i++) { + const n = data.path[i]; + const indent = ' '.repeat(i + 1); + if (i === 0) { + console.log(`${indent}${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}`); + } else { + console.log( + `${indent}--[${n.edgeKind}]--> ${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}`, + ); + } + } + + if (data.alternateCount > 0) { + console.log( + `\n (${data.alternateCount} alternate shortest ${data.alternateCount === 1 ? 'path' : 'paths'} at same depth)`, + ); + } + console.log(); +} diff --git a/src/presentation/query.js b/src/presentation/query.js new file mode 100644 index 00000000..72a5bef0 --- /dev/null +++ b/src/presentation/query.js @@ -0,0 +1,21 @@ +/** + * Re-export all query CLI wrappers from queries-cli.js. + * This barrel file provides the standard src/commands/ import path. + */ +export { + children, + context, + diffImpact, + explain, + fileDeps, + fileExports, + fnDeps, + fnImpact, + impactAnalysis, + moduleMap, + queryName, + roles, + stats, + symbolPath, + where, +} from './queries-cli/index.js'; diff --git a/src/presentation/sequence.js b/src/presentation/sequence.js new file mode 100644 index 00000000..ff8946c0 --- /dev/null +++ b/src/presentation/sequence.js @@ -0,0 +1,33 @@ +import { kindIcon } from '../domain/queries.js'; +import { sequenceData, sequenceToMermaid } from '../features/sequence.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; + +/** + * CLI entry point — format sequence data as mermaid, JSON, or ndjson. + */ +export function sequence(name, dbPath, opts = {}) { + const data = sequenceData(name, dbPath, opts); + + if (outputResult(data, 'messages', opts)) return; + + // Default: mermaid format + if (!data.entry) { + console.log(`No matching function found for "${name}".`); + return; + } + + const e = data.entry; + console.log(`\nSequence from: [${kindIcon(e.kind)}] ${e.name} ${e.file}:${e.line}`); + console.log(`Participants: ${data.participants.length} Messages: ${data.totalMessages}`); + if (data.truncated) { + console.log(` (truncated at depth ${data.depth})`); + } + console.log(); + + if (data.messages.length === 0) { + console.log(' (leaf node — no callees)'); + return; + } + + console.log(sequenceToMermaid(data)); +} diff --git a/src/presentation/structure.js b/src/presentation/structure.js new file mode 100644 index 00000000..a2679a4f --- /dev/null +++ b/src/presentation/structure.js @@ -0,0 +1,64 @@ +import path from 'node:path'; +import { hotspotsData, moduleBoundariesData, structureData } from '../features/structure.js'; + +export { hotspotsData, moduleBoundariesData, structureData }; + +export function formatStructure(data) { + if (data.count === 0) return 'No directory structure found. Run "codegraph build" first.'; + + const lines = [`\nProject structure (${data.count} directories):\n`]; + for (const d of data.directories) { + const cohStr = d.cohesion !== null ? ` cohesion=${d.cohesion.toFixed(2)}` : ''; + const depth = d.directory.split('/').length - 1; + const indent = ' '.repeat(depth); + lines.push( + `${indent}${d.directory}/ (${d.fileCount} files, ${d.symbolCount} symbols, <-${d.fanIn} ->${d.fanOut}${cohStr})`, + ); + for (const f of d.files) { + lines.push( + `${indent} ${path.basename(f.file)} ${f.lineCount}L ${f.symbolCount}sym <-${f.fanIn} ->${f.fanOut}`, + ); + } + } + if (data.warning) { + lines.push(''); + lines.push(`⚠ ${data.warning}`); + } + return lines.join('\n'); +} + +export function formatHotspots(data) { + if (data.hotspots.length === 0) return 'No hotspots found. Run "codegraph build" first.'; + + const lines = [`\nHotspots by ${data.metric} (${data.level}-level, top ${data.limit}):\n`]; + let rank = 1; + for (const h of data.hotspots) { + const extra = + h.kind === 'directory' + ? `${h.fileCount} files, cohesion=${h.cohesion !== null ? h.cohesion.toFixed(2) : 'n/a'}` + : `${h.lineCount || 0}L, ${h.symbolCount || 0} symbols`; + lines.push( + ` ${String(rank++).padStart(2)}. ${h.name} <-${h.fanIn || 0} ->${h.fanOut || 0} (${extra})`, + ); + } + return lines.join('\n'); +} + +export function formatModuleBoundaries(data) { + if (data.count === 0) return `No modules found with cohesion >= ${data.threshold}.`; + + const lines = [`\nModule boundaries (cohesion >= ${data.threshold}, ${data.count} modules):\n`]; + for (const m of data.modules) { + lines.push( + ` ${m.directory}/ cohesion=${m.cohesion.toFixed(2)} (${m.fileCount} files, ${m.symbolCount} symbols)`, + ); + lines.push(` Incoming: ${m.fanIn} edges Outgoing: ${m.fanOut} edges`); + if (m.files.length > 0) { + lines.push( + ` Files: ${m.files.slice(0, 5).join(', ')}${m.files.length > 5 ? ` ... +${m.files.length - 5}` : ''}`, + ); + } + lines.push(''); + } + return lines.join('\n'); +} diff --git a/src/presentation/triage.js b/src/presentation/triage.js new file mode 100644 index 00000000..8c10cb03 --- /dev/null +++ b/src/presentation/triage.js @@ -0,0 +1,49 @@ +import { triageData } from '../features/triage.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; + +/** + * Print triage results to console. + */ +export function triage(customDbPath, opts = {}) { + const data = triageData(customDbPath, opts); + + if (outputResult(data, 'items', opts)) return; + + if (data.items.length === 0) { + if (data.summary.total === 0) { + console.log('\nNo symbols found. Run "codegraph build" first.\n'); + } else { + console.log('\nNo symbols match the given filters.\n'); + } + return; + } + + console.log('\n# Risk Audit Queue\n'); + + console.log( + ` ${'Symbol'.padEnd(35)} ${'File'.padEnd(28)} ${'Role'.padEnd(8)} ${'Score'.padStart(6)} ${'Fan-In'.padStart(7)} ${'Cog'.padStart(4)} ${'Churn'.padStart(6)} ${'MI'.padStart(5)}`, + ); + console.log( + ` ${'─'.repeat(35)} ${'─'.repeat(28)} ${'─'.repeat(8)} ${'─'.repeat(6)} ${'─'.repeat(7)} ${'─'.repeat(4)} ${'─'.repeat(6)} ${'─'.repeat(5)}`, + ); + + for (const it of data.items) { + const name = it.name.length > 33 ? `${it.name.slice(0, 32)}…` : it.name; + const file = it.file.length > 26 ? `…${it.file.slice(-25)}` : it.file; + const role = (it.role || '-').padEnd(8); + const score = it.riskScore.toFixed(2).padStart(6); + const fanIn = String(it.fanIn).padStart(7); + const cog = String(it.cognitive).padStart(4); + const churn = String(it.churn).padStart(6); + const mi = it.maintainabilityIndex > 0 ? String(it.maintainabilityIndex).padStart(5) : ' -'; + console.log( + ` ${name.padEnd(35)} ${file.padEnd(28)} ${role} ${score} ${fanIn} ${cog} ${churn} ${mi}`, + ); + } + + const s = data.summary; + console.log( + `\n ${s.analyzed} symbols scored (of ${s.total} total) | avg: ${s.avgScore.toFixed(2)} | max: ${s.maxScore.toFixed(2)} | sort: ${opts.sort || 'risk'}`, + ); + console.log(); +} diff --git a/tests/graph/viewer.test.js b/tests/graph/viewer.test.js index 95a67190..9347833f 100644 --- a/tests/graph/viewer.test.js +++ b/tests/graph/viewer.test.js @@ -5,7 +5,11 @@ import Database from 'better-sqlite3'; import { describe, expect, it } from 'vitest'; import { initSchema } from '../../src/db/index.js'; -import { generatePlotHTML, loadPlotConfig, prepareGraphData } from '../../src/features/viewer.js'; +import { + generatePlotHTML, + loadPlotConfig, + prepareGraphData, +} from '../../src/features/graph-enrichment.js'; function createTestDb() { const db = new Database(':memory:'); From 8d775964d55192f645a966b4331d66a609b6c862 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 03:26:02 -0600 Subject: [PATCH 5/7] refactor: remove dead presentation/query.js barrel file This file was a duplicate of queries-cli.js with zero importers. Removes the maintenance burden of keeping two identical barrels in sync. Addresses Greptile review feedback on #459. docs check acknowledged --- src/presentation/query.js | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 src/presentation/query.js diff --git a/src/presentation/query.js b/src/presentation/query.js deleted file mode 100644 index 72a5bef0..00000000 --- a/src/presentation/query.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Re-export all query CLI wrappers from queries-cli.js. - * This barrel file provides the standard src/commands/ import path. - */ -export { - children, - context, - diffImpact, - explain, - fileDeps, - fileExports, - fnDeps, - fnImpact, - impactAnalysis, - moduleMap, - queryName, - roles, - stats, - symbolPath, - where, -} from './queries-cli/index.js'; From 43ff32e479d3c46ebcd1d4a6e0cd810c15034ec3 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 04:06:07 -0600 Subject: [PATCH 6/7] fix: correct communitySummaryForStats import path to features layer Impact: 1 functions changed, 3 affected --- src/presentation/queries-cli/overview.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/presentation/queries-cli/overview.js b/src/presentation/queries-cli/overview.js index 62593c68..88409da2 100644 --- a/src/presentation/queries-cli/overview.js +++ b/src/presentation/queries-cli/overview.js @@ -7,7 +7,7 @@ export async function stats(customDbPath, opts = {}) { // Community detection summary (async import for lazy-loading) try { - const { communitySummaryForStats } = await import('../communities.js'); + const { communitySummaryForStats } = await import('../../features/communities.js'); data.communities = communitySummaryForStats(customDbPath, { noTests: opts.noTests }); } catch { /* graphology may not be available */ From 506c30fc8883c1200cc532b70757125cab91e9d9 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 04:24:57 -0600 Subject: [PATCH 7/7] fix: correct co-change dynamic import path to features layer Impact: 1 functions changed, 0 affected --- src/cli/commands/co-change.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/co-change.js b/src/cli/commands/co-change.js index 55c6c99f..d9d4e8ec 100644 --- a/src/cli/commands/co-change.js +++ b/src/cli/commands/co-change.js @@ -19,7 +19,9 @@ export const command = { ['--ndjson', 'Newline-delimited JSON output'], ], async execute([file], opts, ctx) { - const { analyzeCoChanges, coChangeData, coChangeTopData } = await import('../../cochange.js'); + const { analyzeCoChanges, coChangeData, coChangeTopData } = await import( + '../../features/cochange.js' + ); const { formatCoChange, formatCoChangeTop } = await import('../../presentation/cochange.js'); if (opts.analyze) {