diff --git a/CHANGELOG.md b/CHANGELOG.md index de8abbd..3770c71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,6 @@ All notable changes to this project will be documented in this file. See [commit * **native:** fix function-scoped `const` declarations being incorrectly extracted as top-level constants ([#344](https://github.com/optave/codegraph/pull/344)) - ## [3.0.2](https://github.com/optave/codegraph/compare/v3.0.1...v3.0.2) (2026-03-04) **Dataflow goes multi-language, build performance recovery, and native engine parity fixes.** This patch extends dataflow analysis from JS/TS-only to all 11 supported languages, recovers build performance lost after CFG/dataflow became default-on, fixes language-aware identifier collection in dataflow, and closes a native engine scoping bug for constants. diff --git a/CLAUDE.md b/CLAUDE.md index dd1739a..fb22fc2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,7 @@ JS source is plain JavaScript (ES modules) in `src/`. No transpilation step. The | `boundaries.js` | Architecture boundary rules with onion architecture preset | | `owners.js` | CODEOWNERS integration for ownership queries | | `snapshot.js` | SQLite DB backup and restore | +| `sequence.js` | Mermaid sequence diagram generation from call graph edges | | `paginate.js` | Pagination helpers for bounded query results | | `logger.js` | Structured logging (`warn`, `debug`, `info`, `error`) | @@ -132,6 +133,7 @@ node src/cli.js deps src/.js # File-level imports and importers node src/cli.js diff-impact main # Impact of current branch vs main node src/cli.js complexity -T # Per-function complexity metrics node src/cli.js communities -T # Community detection & drift analysis +node src/cli.js sequence -T # Mermaid sequence diagram from call edges node src/cli.js check -T # Rule engine pass/fail check node src/cli.js audit -T # Combined structural summary + impact + health report node src/cli.js triage -T # Ranked audit priority queue diff --git a/src/cli.js b/src/cli.js index c799ef1..41c94a6 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1137,6 +1137,39 @@ program }); }); +program + .command('sequence ') + .description('Generate a Mermaid sequence diagram from call graph edges (participants = files)') + .option('--depth ', 'Max forward traversal depth', '10') + .option('--dataflow', 'Annotate with parameter names and return arrows from dataflow table') + .option('-d, --db ', 'Path to graph.db') + .option('-f, --file ', 'Scope to a specific file (partial match)') + .option('-k, --kind ', 'Filter by symbol kind') + .option('-T, --no-tests', 'Exclude test/spec files from results') + .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') + .option('-j, --json', 'Output as JSON') + .option('--limit ', 'Max results to return') + .option('--offset ', 'Skip N results (default: 0)') + .option('--ndjson', 'Newline-delimited JSON output') + .action(async (name, opts) => { + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); + process.exit(1); + } + const { sequence } = await import('./sequence.js'); + sequence(name, opts.db, { + depth: parseInt(opts.depth, 10), + file: opts.file, + kind: opts.kind, + noTests: resolveNoTests(opts), + json: opts.json, + dataflow: opts.dataflow, + limit: opts.limit ? parseInt(opts.limit, 10) : undefined, + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + ndjson: opts.ndjson, + }); + }); + program .command('dataflow ') .description('Show data flow for a function: parameters, return consumers, mutations') diff --git a/src/flow.js b/src/flow.js index 23224bf..61f3950 100644 --- a/src/flow.js +++ b/src/flow.js @@ -7,7 +7,7 @@ import { openReadonlyOrFail } from './db.js'; import { paginateResult, printNdjson } from './paginate.js'; -import { isTestFile, kindIcon } from './queries.js'; +import { findMatchingNodes, isTestFile, kindIcon } from './queries.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; /** @@ -95,12 +95,12 @@ export function flowData(name, dbPath, opts = {}) { const noTests = opts.noTests || false; // Phase 1: Direct LIKE match on full name - let matchNode = findBestMatch(db, name, opts); + let matchNode = findMatchingNodes(db, name, opts)[0] ?? null; // Phase 2: Prefix-stripped matching — try adding framework prefixes if (!matchNode) { for (const prefix of FRAMEWORK_ENTRY_PREFIXES) { - matchNode = findBestMatch(db, `${prefix}${name}`, opts); + matchNode = findMatchingNodes(db, `${prefix}${name}`, opts)[0] ?? null; if (matchNode) break; } } @@ -219,73 +219,6 @@ export function flowData(name, dbPath, opts = {}) { return paginateResult(base, 'steps', { limit: opts.limit, offset: opts.offset }); } -/** - * Find the best matching node using the same relevance scoring as queries.js findMatchingNodes. - */ -function findBestMatch(db, name, opts = {}) { - const kinds = opts.kind - ? [opts.kind] - : [ - 'function', - 'method', - 'class', - 'interface', - 'type', - 'struct', - 'enum', - 'trait', - 'record', - 'module', - ]; - const placeholders = kinds.map(() => '?').join(', '); - const params = [`%${name}%`, ...kinds]; - - let fileCondition = ''; - if (opts.file) { - fileCondition = ' AND n.file LIKE ?'; - params.push(`%${opts.file}%`); - } - - const rows = db - .prepare( - `SELECT n.*, COALESCE(fi.cnt, 0) AS fan_in - FROM nodes n - LEFT JOIN ( - SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id - ) fi ON fi.target_id = n.id - WHERE n.name LIKE ? AND n.kind IN (${placeholders})${fileCondition}`, - ) - .all(...params); - - const noTests = opts.noTests || false; - const nodes = noTests ? rows.filter((n) => !isTestFile(n.file)) : rows; - - if (nodes.length === 0) return null; - - const lowerQuery = name.toLowerCase(); - for (const node of nodes) { - const lowerName = node.name.toLowerCase(); - const bareName = lowerName.includes('.') ? lowerName.split('.').pop() : lowerName; - - let matchScore; - if (lowerName === lowerQuery || bareName === lowerQuery) { - matchScore = 100; - } else if (lowerName.startsWith(lowerQuery) || bareName.startsWith(lowerQuery)) { - matchScore = 60; - } else if (lowerName.includes(`.${lowerQuery}`) || lowerName.includes(`${lowerQuery}.`)) { - matchScore = 40; - } else { - matchScore = 10; - } - - const fanInBonus = Math.min(Math.log2(node.fan_in + 1) * 5, 25); - node._relevance = matchScore + fanInBonus; - } - - nodes.sort((a, b) => b._relevance - a._relevance); - return nodes[0]; -} - /** * CLI formatter — text or JSON output. */ diff --git a/src/index.js b/src/index.js index 0cf6549..9f51e6c 100644 --- a/src/index.js +++ b/src/index.js @@ -121,7 +121,6 @@ export { isNativeAvailable } from './native.js'; export { matchOwners, owners, ownersData, ownersForFiles, parseCodeowners } from './owners.js'; // Pagination utilities export { MCP_DEFAULTS, MCP_MAX_LIMIT, paginate, paginateResult, printNdjson } from './paginate.js'; - // Unified parser API export { getActiveEngine, isWasmAvailable, parseFileAuto, parseFilesAuto } from './parser.js'; // Query functions (data-returning) @@ -170,6 +169,8 @@ export { saveRegistry, unregisterRepo, } from './registry.js'; +// Sequence diagram generation +export { sequence, sequenceData, sequenceToMermaid } from './sequence.js'; // Snapshot management export { snapshotDelete, diff --git a/src/mcp.js b/src/mcp.js index a63ff87..1b51d75 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -418,6 +418,43 @@ const BASE_TOOLS = [ }, }, }, + { + name: 'sequence', + description: + 'Generate a Mermaid sequence diagram from call graph edges. Participants are files, messages are function calls between them.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Entry point or function name to trace from (partial match)', + }, + depth: { type: 'number', description: 'Max forward traversal depth', default: 10 }, + format: { + type: 'string', + enum: ['mermaid', 'json'], + description: 'Output format (default: mermaid)', + }, + dataflow: { + type: 'boolean', + description: 'Annotate with parameter names and return arrows', + default: false, + }, + file: { + type: 'string', + description: 'Scope search to functions in this file (partial match)', + }, + kind: { + type: 'string', + enum: EVERY_SYMBOL_KIND, + description: 'Filter to a specific symbol kind', + }, + no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, + }, + required: ['name'], + }, + }, { name: 'complexity', description: @@ -1165,6 +1202,23 @@ export async function startMCPServer(customDbPath, options = {}) { } break; } + case 'sequence': { + const { sequenceData, sequenceToMermaid } = await import('./sequence.js'); + const seqResult = sequenceData(args.name, dbPath, { + depth: args.depth, + file: args.file, + kind: args.kind, + dataflow: args.dataflow, + noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.execution_flow, MCP_MAX_LIMIT), + offset: args.offset ?? 0, + }); + result = + args.format === 'json' + ? seqResult + : { text: sequenceToMermaid(seqResult), ...seqResult }; + break; + } case 'complexity': { const { complexityData } = await import('./complexity.js'); result = complexityData(dbPath, { diff --git a/src/queries.js b/src/queries.js index a35f4f4..2791509 100644 --- a/src/queries.js +++ b/src/queries.js @@ -163,7 +163,7 @@ function resolveMethodViaHierarchy(db, methodName) { * Find nodes matching a name query, ranked by relevance. * Scoring: exact=100, prefix=60, word-boundary=40, substring=10, plus fan-in tiebreaker. */ -function findMatchingNodes(db, name, opts = {}) { +export function findMatchingNodes(db, name, opts = {}) { const kinds = opts.kind ? [opts.kind] : FUNCTION_KINDS; const placeholders = kinds.map(() => '?').join(', '); const params = [`%${name}%`, ...kinds]; diff --git a/src/sequence.js b/src/sequence.js new file mode 100644 index 0000000..cb9c254 --- /dev/null +++ b/src/sequence.js @@ -0,0 +1,369 @@ +/** + * Sequence diagram generation – Mermaid sequenceDiagram from call graph edges. + * + * Participants are files (not individual functions). Calls within the same file + * become self-messages. This keeps diagrams readable and matches typical + * sequence-diagram conventions. + */ + +import { openReadonlyOrFail } from './db.js'; +import { paginateResult, printNdjson } from './paginate.js'; +import { findMatchingNodes, isTestFile, kindIcon } from './queries.js'; +import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; + +// ─── Alias generation ──────────────────────────────────────────────── + +/** + * Build short participant aliases from file paths with collision handling. + * e.g. "src/builder.js" → "builder", but if two files share basename, + * progressively add parent dirs: "src/builder" vs "lib/builder". + */ +function buildAliases(files) { + const aliases = new Map(); + const basenames = new Map(); + + // Group by basename + for (const file of files) { + const base = file + .split('/') + .pop() + .replace(/\.[^.]+$/, ''); + if (!basenames.has(base)) basenames.set(base, []); + basenames.get(base).push(file); + } + + for (const [base, paths] of basenames) { + if (paths.length === 1) { + aliases.set(paths[0], base); + } else { + // Collision — progressively add parent dirs until aliases are unique + for (let depth = 2; depth <= 10; depth++) { + const trial = new Map(); + let allUnique = true; + const seen = new Set(); + + for (const p of paths) { + const parts = p.replace(/\.[^.]+$/, '').split('/'); + const alias = parts + .slice(-depth) + .join('_') + .replace(/[^a-zA-Z0-9_-]/g, '_'); + trial.set(p, alias); + if (seen.has(alias)) allUnique = false; + seen.add(alias); + } + + if (allUnique || depth === 10) { + for (const [p, alias] of trial) { + aliases.set(p, alias); + } + break; + } + } + } + } + + return aliases; +} + +// ─── Core data function ────────────────────────────────────────────── + +/** + * Build sequence diagram data by BFS-forward from an entry point. + * + * @param {string} name - Symbol name to trace from + * @param {string} [dbPath] + * @param {object} [opts] + * @param {number} [opts.depth=10] + * @param {boolean} [opts.noTests] + * @param {string} [opts.file] + * @param {string} [opts.kind] + * @param {boolean} [opts.dataflow] + * @param {number} [opts.limit] + * @param {number} [opts.offset] + * @returns {{ entry, participants, messages, depth, totalMessages, truncated }} + */ +export function sequenceData(name, dbPath, opts = {}) { + const db = openReadonlyOrFail(dbPath); + const maxDepth = opts.depth || 10; + const noTests = opts.noTests || false; + const withDataflow = opts.dataflow || false; + + // Phase 1: Direct LIKE match + let matchNode = findMatchingNodes(db, name, opts)[0] ?? null; + + // Phase 2: Prefix-stripped matching + if (!matchNode) { + for (const prefix of FRAMEWORK_ENTRY_PREFIXES) { + matchNode = findMatchingNodes(db, `${prefix}${name}`, opts)[0] ?? null; + if (matchNode) break; + } + } + + if (!matchNode) { + db.close(); + return { + entry: null, + participants: [], + messages: [], + depth: maxDepth, + totalMessages: 0, + truncated: false, + }; + } + + const entry = { + name: matchNode.name, + file: matchNode.file, + kind: matchNode.kind, + line: matchNode.line, + }; + + // BFS forward — track edges, not just nodes + const visited = new Set([matchNode.id]); + let frontier = [matchNode.id]; + const messages = []; + const fileSet = new Set([matchNode.file]); + const idToNode = new Map(); + idToNode.set(matchNode.id, matchNode); + let truncated = false; + + const getCallees = db.prepare( + `SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line + FROM edges e JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind = 'calls'`, + ); + + for (let d = 1; d <= maxDepth; d++) { + const nextFrontier = []; + + for (const fid of frontier) { + const callees = getCallees.all(fid); + + const caller = idToNode.get(fid); + + for (const c of callees) { + if (noTests && isTestFile(c.file)) continue; + + // Always record the message (even for visited nodes — different caller path) + fileSet.add(c.file); + messages.push({ + from: caller.file, + to: c.file, + label: c.name, + type: 'call', + depth: d, + }); + + if (visited.has(c.id)) continue; + + visited.add(c.id); + nextFrontier.push(c.id); + idToNode.set(c.id, c); + } + } + + frontier = nextFrontier; + if (frontier.length === 0) break; + + if (d === maxDepth && frontier.length > 0) { + // Only mark truncated if at least one frontier node has further callees + const hasMoreCalls = frontier.some((fid) => getCallees.all(fid).length > 0); + if (hasMoreCalls) truncated = true; + } + } + + // Dataflow annotations: add return arrows + if (withDataflow && messages.length > 0) { + const hasTable = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='dataflow'") + .get(); + + if (hasTable) { + // Build name|file lookup for O(1) target node access + const nodeByNameFile = new Map(); + for (const n of idToNode.values()) { + nodeByNameFile.set(`${n.name}|${n.file}`, n); + } + + const getReturns = db.prepare( + `SELECT d.expression FROM dataflow d + WHERE d.source_id = ? AND d.kind = 'returns'`, + ); + const getFlowsTo = db.prepare( + `SELECT d.expression FROM dataflow d + WHERE d.target_id = ? AND d.kind = 'flows_to' + ORDER BY d.param_index`, + ); + + // For each called function, check if it has return edges + const seenReturns = new Set(); + for (const msg of [...messages]) { + if (msg.type !== 'call') continue; + const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`); + if (!targetNode) continue; + + const returnKey = `${msg.to}->${msg.from}:${msg.label}`; + if (seenReturns.has(returnKey)) continue; + + const returns = getReturns.all(targetNode.id); + + if (returns.length > 0) { + seenReturns.add(returnKey); + const expr = returns[0].expression || 'result'; + messages.push({ + from: msg.to, + to: msg.from, + label: expr, + type: 'return', + depth: msg.depth, + }); + } + } + + // Annotate call messages with parameter names + for (const msg of messages) { + if (msg.type !== 'call') continue; + const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`); + if (!targetNode) continue; + + const params = getFlowsTo.all(targetNode.id); + + if (params.length > 0) { + const paramNames = params + .map((p) => p.expression) + .filter(Boolean) + .slice(0, 3); + if (paramNames.length > 0) { + msg.label = `${msg.label}(${paramNames.join(', ')})`; + } + } + } + } + } + + // Sort messages by depth, then call before return + messages.sort((a, b) => { + if (a.depth !== b.depth) return a.depth - b.depth; + if (a.type === 'call' && b.type === 'return') return -1; + if (a.type === 'return' && b.type === 'call') return 1; + return 0; + }); + + // Build participant list from files + const aliases = buildAliases([...fileSet]); + const participants = [...fileSet].map((file) => ({ + id: aliases.get(file), + label: file.split('/').pop(), + file, + })); + + // Sort participants: entry file first, then alphabetically + participants.sort((a, b) => { + if (a.file === entry.file) return -1; + if (b.file === entry.file) return 1; + return a.file.localeCompare(b.file); + }); + + // Replace file paths with alias IDs in messages + for (const msg of messages) { + msg.from = aliases.get(msg.from); + msg.to = aliases.get(msg.to); + } + + db.close(); + + const base = { + entry, + participants, + messages, + depth: maxDepth, + totalMessages: messages.length, + truncated, + }; + const result = paginateResult(base, 'messages', { limit: opts.limit, offset: opts.offset }); + if (opts.limit !== undefined || opts.offset !== undefined) { + const activeFiles = new Set(result.messages.flatMap((m) => [m.from, m.to])); + result.participants = result.participants.filter((p) => activeFiles.has(p.id)); + } + return result; +} + +// ─── Mermaid formatter ─────────────────────────────────────────────── + +/** + * Escape special Mermaid characters in labels. + */ +function escapeMermaid(str) { + return str + .replace(//g, '>') + .replace(/:/g, '#colon;') + .replace(/"/g, '#quot;'); +} + +/** + * Convert sequenceData result to Mermaid sequenceDiagram syntax. + * @param {{ participants, messages, truncated }} seqResult + * @returns {string} + */ +export function sequenceToMermaid(seqResult) { + const lines = ['sequenceDiagram']; + + for (const p of seqResult.participants) { + lines.push(` participant ${p.id} as ${escapeMermaid(p.label)}`); + } + + for (const msg of seqResult.messages) { + const arrow = msg.type === 'return' ? '-->>' : '->>'; + lines.push(` ${msg.from}${arrow}${msg.to}: ${escapeMermaid(msg.label)}`); + } + + if (seqResult.truncated && seqResult.participants.length > 0) { + lines.push( + ` note right of ${seqResult.participants[0].id}: Truncated at depth ${seqResult.depth}`, + ); + } + + return lines.join('\n'); +} + +// ─── CLI formatter ─────────────────────────────────────────────────── + +/** + * CLI entry point — format sequence data as mermaid, JSON, or ndjson. + */ +export function sequence(name, dbPath, opts = {}) { + const data = sequenceData(name, dbPath, opts); + + if (opts.ndjson) { + printNdjson(data, 'messages'); + return; + } + + if (opts.json) { + console.log(JSON.stringify(data, null, 2)); + 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/tests/integration/sequence.test.js b/tests/integration/sequence.test.js new file mode 100644 index 0000000..6d3367e --- /dev/null +++ b/tests/integration/sequence.test.js @@ -0,0 +1,279 @@ +/** + * Integration tests for sequence diagram generation. + * + * Uses a hand-crafted in-memory DB with known graph topology: + * + * buildGraph() → parseFiles() [src/builder.js → src/parser.js] + * → resolveImports() [src/builder.js → src/resolve.js] + * parseFiles() → extractSymbols() [src/parser.js → src/parser.js, same-file] + * extractSymbols() [leaf] + * resolveImports() [leaf] + * + * For alias collision test: + * helperA() in src/utils/helper.js + * helperB() in lib/utils/helper.js + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { initSchema } from '../../src/db.js'; +import { sequenceData, sequenceToMermaid } from '../../src/sequence.js'; + +// ─── Helpers ─────────────────────────────────────────────────────────── + +function insertNode(db, name, kind, file, line) { + return db + .prepare('INSERT INTO nodes (name, kind, file, line) VALUES (?, ?, ?, ?)') + .run(name, kind, file, line).lastInsertRowid; +} + +function insertEdge(db, sourceId, targetId, kind, confidence = 1.0) { + db.prepare( + 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, 0)', + ).run(sourceId, targetId, kind, confidence); +} + +// ─── Fixture DB ──────────────────────────────────────────────────────── + +let tmpDir, dbPath; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-sequence-')); + fs.mkdirSync(path.join(tmpDir, '.codegraph')); + dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + initSchema(db); + + // Core nodes + const buildGraph = insertNode(db, 'buildGraph', 'function', 'src/builder.js', 10); + const parseFiles = insertNode(db, 'parseFiles', 'function', 'src/parser.js', 5); + const extractSymbols = insertNode(db, 'extractSymbols', 'function', 'src/parser.js', 20); + const resolveImports = insertNode(db, 'resolveImports', 'function', 'src/resolve.js', 1); + + // Call edges + insertEdge(db, buildGraph, parseFiles, 'calls'); + insertEdge(db, buildGraph, resolveImports, 'calls'); + insertEdge(db, parseFiles, extractSymbols, 'calls'); + + // Alias collision nodes (two different helper.js files) + const helperA = insertNode(db, 'helperA', 'function', 'src/utils/helper.js', 1); + const helperB = insertNode(db, 'helperB', 'function', 'lib/utils/helper.js', 1); + insertEdge(db, buildGraph, helperA, 'calls'); + insertEdge(db, helperA, helperB, 'calls'); + + // Test file node (for noTests filtering) + const testFn = insertNode(db, 'testBuild', 'function', 'tests/builder.test.js', 1); + insertEdge(db, buildGraph, testFn, 'calls'); + + db.close(); +}); + +afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ─── sequenceData ────────────────────────────────────────────────────── + +describe('sequenceData', () => { + test('basic sequence — correct participants and messages in BFS order', () => { + const data = sequenceData('buildGraph', dbPath, { noTests: true }); + expect(data.entry).not.toBeNull(); + expect(data.entry.name).toBe('buildGraph'); + + // Should have 5 files as participants (builder, parser, resolve, src/utils/helper, lib/utils/helper) + // (test file excluded by noTests) + expect(data.participants.length).toBe(5); + + // Messages should be in BFS depth order + expect(data.messages.length).toBeGreaterThanOrEqual(4); + const depths = data.messages.map((m) => m.depth); + for (let i = 1; i < depths.length; i++) { + expect(depths[i]).toBeGreaterThanOrEqual(depths[i - 1]); + } + }); + + test('self-call — same-file call appears as self-message', () => { + const data = sequenceData('parseFiles', dbPath, { noTests: true }); + expect(data.entry).not.toBeNull(); + + // parseFiles → extractSymbols are both in src/parser.js + const selfMessages = data.messages.filter((m) => m.from === m.to); + expect(selfMessages.length).toBe(1); + expect(selfMessages[0].label).toBe('extractSymbols'); + }); + + test('depth limiting — depth:1 truncates', () => { + const data = sequenceData('buildGraph', dbPath, { depth: 1, noTests: true }); + expect(data.truncated).toBe(true); + expect(data.depth).toBe(1); + + // At depth 1, only direct callees of buildGraph + const msgDepths = data.messages.map((m) => m.depth); + expect(Math.max(...msgDepths)).toBe(1); + }); + + test('unknown name — entry is null', () => { + const data = sequenceData('nonExistentFunction', dbPath); + expect(data.entry).toBeNull(); + expect(data.participants).toHaveLength(0); + expect(data.messages).toHaveLength(0); + }); + + test('leaf entry — entry exists, zero messages', () => { + const data = sequenceData('extractSymbols', dbPath); + expect(data.entry).not.toBeNull(); + expect(data.entry.name).toBe('extractSymbols'); + expect(data.messages).toHaveLength(0); + // Only the entry file as participant + expect(data.participants).toHaveLength(1); + }); + + test('participant alias collision — two helper.js files get distinct IDs', () => { + const data = sequenceData('buildGraph', dbPath, { noTests: true }); + const helperParticipants = data.participants.filter((p) => p.label === 'helper.js'); + expect(helperParticipants.length).toBe(2); + + // IDs should be distinct + const ids = helperParticipants.map((p) => p.id); + expect(ids[0]).not.toBe(ids[1]); + + // IDs must be valid Mermaid participant identifiers (no slashes, etc.) + for (const id of ids) { + expect(id).toMatch(/^[a-zA-Z0-9_-]+$/); + } + }); + + test('noTests filtering — test file nodes excluded', () => { + const withTests = sequenceData('buildGraph', dbPath, { noTests: false }); + const withoutTests = sequenceData('buildGraph', dbPath, { noTests: true }); + + // With tests should have more messages (includes testBuild) + expect(withTests.totalMessages).toBeGreaterThan(withoutTests.totalMessages); + + // testBuild should not appear when filtering + const testMsgs = withoutTests.messages.filter((m) => m.label === 'testBuild'); + expect(testMsgs).toHaveLength(0); + }); +}); + +// ─── sequenceToMermaid ────────────────────────────────────────────────── + +describe('sequenceToMermaid', () => { + test('starts with sequenceDiagram and has participant lines', () => { + const data = sequenceData('buildGraph', dbPath, { noTests: true }); + const mermaid = sequenceToMermaid(data); + + expect(mermaid).toMatch(/^sequenceDiagram/); + expect(mermaid).toContain('participant'); + }); + + test('has ->> arrows for calls', () => { + const data = sequenceData('buildGraph', dbPath, { noTests: true }); + const mermaid = sequenceToMermaid(data); + expect(mermaid).toContain('->>'); + }); + + test('truncation note when truncated', () => { + const data = sequenceData('buildGraph', dbPath, { depth: 1, noTests: true }); + const mermaid = sequenceToMermaid(data); + expect(mermaid).toContain('Truncated at depth'); + }); + + test('no truncation note when participants empty (offset past all messages)', () => { + const mockData = { + participants: [], + messages: [], + truncated: true, + depth: 5, + }; + const mermaid = sequenceToMermaid(mockData); + expect(mermaid).not.toContain('note right of'); + expect(mermaid).not.toContain('undefined'); + }); + + test('escapes colons in labels', () => { + const mockData = { + participants: [{ id: 'a', label: 'a.js' }], + messages: [{ from: 'a', to: 'a', label: 'route:GET /users', type: 'call' }], + truncated: false, + }; + const mermaid = sequenceToMermaid(mockData); + expect(mermaid).toContain('#colon;'); + expect(mermaid).not.toContain('route:'); + }); +}); + +// ─── Dataflow annotations ─────────────────────────────────────────────── + +describe('dataflow annotations', () => { + let dfTmpDir, dfDbPath; + + beforeAll(() => { + dfTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-seq-df-')); + fs.mkdirSync(path.join(dfTmpDir, '.codegraph')); + dfDbPath = path.join(dfTmpDir, '.codegraph', 'graph.db'); + + const db = new Database(dfDbPath); + db.pragma('journal_mode = WAL'); + initSchema(db); + + // caller → callee (cross-file) + const caller = insertNode(db, 'handleRequest', 'function', 'src/handler.js', 1); + const callee = insertNode(db, 'fetchData', 'function', 'src/service.js', 10); + insertEdge(db, caller, callee, 'calls'); + + // dataflow: fetchData returns 'Promise' and receives param 'userId' + db.prepare( + 'INSERT INTO dataflow (source_id, target_id, kind, param_index, expression) VALUES (?, ?, ?, ?, ?)', + ).run(callee, caller, 'returns', null, 'Promise'); + db.prepare( + 'INSERT INTO dataflow (source_id, target_id, kind, param_index, expression) VALUES (?, ?, ?, ?, ?)', + ).run(caller, callee, 'flows_to', 0, 'userId'); + + db.close(); + }); + + afterAll(() => { + if (dfTmpDir) fs.rmSync(dfTmpDir, { recursive: true, force: true }); + }); + + test('return arrows appear with dataflow enabled', () => { + const data = sequenceData('handleRequest', dfDbPath, { noTests: true, dataflow: true }); + expect(data.entry).not.toBeNull(); + + const returnMsgs = data.messages.filter((m) => m.type === 'return'); + expect(returnMsgs.length).toBe(1); + expect(returnMsgs[0].label).toBe('Promise'); + // Return goes from callee file back to caller file + expect(returnMsgs[0].from).not.toBe(returnMsgs[0].to); + }); + + test('call labels annotated with parameter names', () => { + const data = sequenceData('handleRequest', dfDbPath, { noTests: true, dataflow: true }); + + const callMsgs = data.messages.filter((m) => m.type === 'call'); + expect(callMsgs.length).toBe(1); + expect(callMsgs[0].label).toBe('fetchData(userId)'); + }); + + test('without dataflow flag, no return arrows or param annotations', () => { + const data = sequenceData('handleRequest', dfDbPath, { noTests: true, dataflow: false }); + + const returnMsgs = data.messages.filter((m) => m.type === 'return'); + expect(returnMsgs).toHaveLength(0); + + const callMsgs = data.messages.filter((m) => m.type === 'call'); + expect(callMsgs[0].label).toBe('fetchData'); + }); + + test('mermaid output has dashed return arrow', () => { + const data = sequenceData('handleRequest', dfDbPath, { noTests: true, dataflow: true }); + const mermaid = sequenceToMermaid(data); + expect(mermaid).toContain('-->>'); + }); +}); diff --git a/tests/unit/mcp.test.js b/tests/unit/mcp.test.js index 4e5bc3c..70889bf 100644 --- a/tests/unit/mcp.test.js +++ b/tests/unit/mcp.test.js @@ -28,6 +28,7 @@ const ALL_TOOL_NAMES = [ 'co_changes', 'node_roles', 'execution_flow', + 'sequence', 'complexity', 'communities', 'code_owners',