From ba8dcc75e047bab3273f1b7ddf422f1f1efdea4d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Feb 2026 17:05:20 -0700 Subject: [PATCH 1/3] feat: manifesto-driven pass/fail rule engine Generic rule engine that evaluates function-level, file-level, and graph-level rules against the DB and returns pass/fail verdicts. - 9 rules: cognitive, cyclomatic, maxNesting (function), importCount, exportCount, lineCount, fanIn, fanOut (file), noCycles (graph) - Exits with code 1 on any fail-level breach (CI gate) - Exposed via CLI (codegraph manifesto), programmatic API, and MCP tool Impact: 9 functions changed, 7 affected --- src/cli.js | 23 ++ src/config.js | 6 + src/index.js | 2 + src/manifesto.js | 440 ++++++++++++++++++++++++++++ src/mcp.js | 25 ++ tests/integration/manifesto.test.js | 325 ++++++++++++++++++++ 6 files changed, 821 insertions(+) create mode 100644 src/manifesto.js create mode 100644 tests/integration/manifesto.test.js diff --git a/src/cli.js b/src/cli.js index 21a23aae..b7c13d17 100644 --- a/src/cli.js +++ b/src/cli.js @@ -742,6 +742,29 @@ program }); }); +program + .command('manifesto') + .description('Evaluate manifesto rules (pass/fail verdicts for code health)') + .option('-d, --db ', 'Path to graph.db') + .option('-T, --no-tests', 'Exclude test/spec files from results') + .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') + .option('-f, --file ', 'Scope to file (partial match)') + .option('-k, --kind ', 'Filter by symbol kind') + .option('-j, --json', 'Output as JSON') + .action(async (opts) => { + if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); + process.exit(1); + } + const { manifesto } = await import('./manifesto.js'); + manifesto(opts.db, { + file: opts.file, + kind: opts.kind, + noTests: resolveNoTests(opts), + json: opts.json, + }); + }); + program .command('communities') .description('Detect natural module boundaries using Louvain community detection') diff --git a/src/config.js b/src/config.js index 29dec215..c51b6a26 100644 --- a/src/config.js +++ b/src/config.js @@ -29,6 +29,12 @@ export const DEFAULTS = { cognitive: { warn: 15, fail: null }, cyclomatic: { warn: 10, fail: null }, maxNesting: { warn: 4, 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 }, }, }, coChange: { diff --git a/src/index.js b/src/index.js index 1bc45a7f..11a15a61 100644 --- a/src/index.js +++ b/src/index.js @@ -62,6 +62,8 @@ export { exportDOT, exportJSON, exportMermaid } from './export.js'; export { entryPointType, flowData, listEntryPointsData } from './flow.js'; // Logger export { setVerbose } from './logger.js'; +// Manifesto rule engine +export { manifesto, manifestoData, RULE_DEFS } from './manifesto.js'; // Native engine export { isNativeAvailable } from './native.js'; diff --git a/src/manifesto.js b/src/manifesto.js new file mode 100644 index 00000000..2669dc8a --- /dev/null +++ b/src/manifesto.js @@ -0,0 +1,440 @@ +import { loadConfig } from './config.js'; +import { findCycles } from './cycles.js'; +import { openReadonlyOrFail } from './db.js'; +import { isTestFile } from './queries.js'; + +// ─── Rule Definitions ───────────────────────────────────────────────── + +/** + * All supported manifesto rules. + * level: 'function' | 'file' | 'graph' + * metric: DB column or special key + * defaults: { warn, fail } — null means disabled + */ +export const RULE_DEFS = [ + { name: 'cognitive', level: 'function', metric: 'cognitive', defaults: { warn: 15, fail: null } }, + { + name: 'cyclomatic', + level: 'function', + metric: 'cyclomatic', + defaults: { warn: 10, fail: null }, + }, + { + name: 'maxNesting', + level: 'function', + metric: 'max_nesting', + defaults: { warn: 4, fail: null }, + }, + { + name: 'importCount', + level: 'file', + metric: 'import_count', + defaults: { warn: null, fail: null }, + }, + { + name: 'exportCount', + level: 'file', + metric: 'export_count', + defaults: { warn: null, fail: null }, + }, + { + name: 'lineCount', + level: 'file', + metric: 'line_count', + defaults: { warn: null, fail: null }, + }, + { name: 'fanIn', level: 'file', metric: 'fan_in', defaults: { warn: null, fail: null } }, + { name: 'fanOut', level: 'file', metric: 'fan_out', defaults: { warn: null, fail: null } }, + { name: 'noCycles', level: 'graph', metric: 'noCycles', defaults: { warn: null, fail: null } }, +]; + +// ─── Helpers ────────────────────────────────────────────────────────── + +const NO_TEST_SQL = ` + AND n.file NOT LIKE '%.test.%' + AND n.file NOT LIKE '%.spec.%' + AND n.file NOT LIKE '%__test__%' + AND n.file NOT LIKE '%__tests__%' + AND n.file NOT LIKE '%.stories.%'`; + +/** + * Deep-merge user config with RULE_DEFS defaults per rule. + * mergeConfig in config.js is shallow for nested objects, so we do per-rule merging here. + */ +function resolveRules(userRules) { + const resolved = {}; + for (const def of RULE_DEFS) { + const user = userRules?.[def.name]; + resolved[def.name] = { + warn: user?.warn !== undefined ? user.warn : def.defaults.warn, + fail: user?.fail !== undefined ? user.fail : def.defaults.fail, + }; + } + return resolved; +} + +/** + * Check if a rule is enabled (has at least one non-null threshold). + */ +function isEnabled(thresholds) { + return thresholds.warn != null || thresholds.fail != null; +} + +/** + * Check a numeric value against warn/fail thresholds, push violations. + */ +function checkThreshold(rule, thresholds, value, meta, violations) { + if (thresholds.fail != null && value >= thresholds.fail) { + violations.push({ + rule, + level: 'fail', + value, + threshold: thresholds.fail, + ...meta, + }); + return 'fail'; + } + if (thresholds.warn != null && value >= thresholds.warn) { + violations.push({ + rule, + level: 'warn', + value, + threshold: thresholds.warn, + ...meta, + }); + return 'warn'; + } + return 'pass'; +} + +// ─── Evaluators ─────────────────────────────────────────────────────── + +function evaluateFunctionRules(db, rules, opts, violations, ruleResults) { + const functionDefs = RULE_DEFS.filter((d) => d.level === 'function'); + const activeDefs = functionDefs.filter((d) => isEnabled(rules[d.name])); + if (activeDefs.length === 0) { + for (const def of functionDefs) { + ruleResults.push({ + name: def.name, + level: def.level, + status: 'pass', + thresholds: rules[def.name], + violationCount: 0, + }); + } + return; + } + + let where = "WHERE n.kind IN ('function','method')"; + const params = []; + if (opts.noTests) where += NO_TEST_SQL; + if (opts.file) { + where += ' AND n.file LIKE ?'; + params.push(`%${opts.file}%`); + } + if (opts.kind) { + where += ' AND n.kind = ?'; + params.push(opts.kind); + } + + let rows; + try { + rows = db + .prepare( + `SELECT n.name, n.kind, n.file, n.line, + fc.cognitive, fc.cyclomatic, fc.max_nesting + FROM function_complexity fc + JOIN nodes n ON fc.node_id = n.id + ${where}`, + ) + .all(...params); + } catch { + rows = []; + } + + if (opts.noTests) { + rows = rows.filter((r) => !isTestFile(r.file)); + } + + // Track worst status per rule + const worst = {}; + const counts = {}; + for (const def of functionDefs) { + worst[def.name] = 'pass'; + counts[def.name] = 0; + } + + for (const row of rows) { + for (const def of activeDefs) { + const value = row[def.metric]; + if (value == null) continue; + const meta = { name: row.name, file: row.file, line: row.line }; + const status = checkThreshold(def.name, rules[def.name], value, meta, violations); + if (status !== 'pass') { + counts[def.name]++; + if (status === 'fail') worst[def.name] = 'fail'; + else if (worst[def.name] !== 'fail') worst[def.name] = 'warn'; + } + } + } + + for (const def of functionDefs) { + ruleResults.push({ + name: def.name, + level: def.level, + status: worst[def.name], + thresholds: rules[def.name], + violationCount: counts[def.name], + }); + } +} + +function evaluateFileRules(db, rules, opts, violations, ruleResults) { + const fileDefs = RULE_DEFS.filter((d) => d.level === 'file'); + const activeDefs = fileDefs.filter((d) => isEnabled(rules[d.name])); + if (activeDefs.length === 0) { + for (const def of fileDefs) { + ruleResults.push({ + name: def.name, + level: def.level, + status: 'pass', + thresholds: rules[def.name], + violationCount: 0, + }); + } + return; + } + + let where = "WHERE n.kind = 'file'"; + const params = []; + if (opts.noTests) where += NO_TEST_SQL; + if (opts.file) { + where += ' AND n.file LIKE ?'; + params.push(`%${opts.file}%`); + } + + let rows; + try { + rows = db + .prepare( + `SELECT n.name, n.file, n.line, + nm.import_count, nm.export_count, nm.line_count, + nm.fan_in, nm.fan_out + FROM node_metrics nm + JOIN nodes n ON nm.node_id = n.id + ${where}`, + ) + .all(...params); + } catch { + rows = []; + } + + if (opts.noTests) { + rows = rows.filter((r) => !isTestFile(r.file)); + } + + const worst = {}; + const counts = {}; + for (const def of fileDefs) { + worst[def.name] = 'pass'; + counts[def.name] = 0; + } + + for (const row of rows) { + for (const def of activeDefs) { + const value = row[def.metric]; + if (value == null) continue; + const meta = { name: row.name, file: row.file, line: row.line }; + const status = checkThreshold(def.name, rules[def.name], value, meta, violations); + if (status !== 'pass') { + counts[def.name]++; + if (status === 'fail') worst[def.name] = 'fail'; + else if (worst[def.name] !== 'fail') worst[def.name] = 'warn'; + } + } + } + + for (const def of fileDefs) { + ruleResults.push({ + name: def.name, + level: def.level, + status: worst[def.name], + thresholds: rules[def.name], + violationCount: counts[def.name], + }); + } +} + +function evaluateGraphRules(db, rules, opts, violations, ruleResults) { + const thresholds = rules.noCycles; + if (!isEnabled(thresholds)) { + ruleResults.push({ + name: 'noCycles', + level: 'graph', + status: 'pass', + thresholds, + violationCount: 0, + }); + return; + } + + const cycles = findCycles(db, { fileLevel: true, noTests: opts.noTests || false }); + const hasCycles = cycles.length > 0; + + if (!hasCycles) { + ruleResults.push({ + name: 'noCycles', + level: 'graph', + status: 'pass', + thresholds, + violationCount: 0, + }); + return; + } + + // Determine level: fail takes precedence over warn + const level = thresholds.fail != null ? 'fail' : 'warn'; + + for (const cycle of cycles) { + violations.push({ + rule: 'noCycles', + level, + name: `cycle(${cycle.length} files)`, + file: cycle.join(' → '), + line: null, + value: cycle.length, + threshold: 0, + }); + } + + ruleResults.push({ + name: 'noCycles', + level: 'graph', + status: level, + thresholds, + violationCount: cycles.length, + }); +} + +// ─── Public API ─────────────────────────────────────────────────────── + +/** + * Evaluate all manifesto rules and return structured results. + * + * @param {string} [customDbPath] - Path to graph.db + * @param {object} [opts] - Options + * @param {boolean} [opts.noTests] - Exclude test files + * @param {string} [opts.file] - Filter by file (partial match) + * @param {string} [opts.kind] - Filter by symbol kind + * @returns {{ rules: object[], violations: object[], summary: object, passed: boolean }} + */ +export function manifestoData(customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + + try { + const config = loadConfig(process.cwd()); + const rules = resolveRules(config.manifesto?.rules); + + const violations = []; + const ruleResults = []; + + evaluateFunctionRules(db, rules, opts, violations, ruleResults); + evaluateFileRules(db, rules, opts, violations, ruleResults); + evaluateGraphRules(db, rules, opts, violations, ruleResults); + + const failViolations = violations.filter((v) => v.level === 'fail'); + + const summary = { + total: ruleResults.length, + passed: ruleResults.filter((r) => r.status === 'pass').length, + warned: ruleResults.filter((r) => r.status === 'warn').length, + failed: ruleResults.filter((r) => r.status === 'fail').length, + violationCount: violations.length, + }; + + return { + rules: ruleResults, + violations, + summary, + passed: failViolations.length === 0, + }; + } finally { + db.close(); + } +} + +/** + * CLI formatter — prints manifesto results and exits with code 1 on failure. + */ +export function manifesto(customDbPath, opts = {}) { + const data = manifestoData(customDbPath, opts); + + if (opts.json) { + console.log(JSON.stringify(data, null, 2)); + if (!data.passed) process.exit(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.exit(1); + } +} diff --git a/src/mcp.js b/src/mcp.js index eb05054e..c9b5e542 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -432,6 +432,22 @@ const BASE_TOOLS = [ }, }, }, + { + name: 'manifesto', + description: + 'Evaluate manifesto rules and return pass/fail verdicts for code health. Checks function complexity, file metrics, and cycle rules against configured thresholds.', + inputSchema: { + type: 'object', + properties: { + file: { type: 'string', description: 'Scope to file (partial match)' }, + no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + kind: { + type: 'string', + description: 'Filter by symbol kind (function, method, class, etc.)', + }, + }, + }, + }, { name: 'communities', description: @@ -788,6 +804,15 @@ export async function startMCPServer(customDbPath, options = {}) { }); break; } + case 'manifesto': { + const { manifestoData } = await import('./manifesto.js'); + result = manifestoData(dbPath, { + file: args.file, + noTests: args.no_tests, + kind: args.kind, + }); + break; + } case 'communities': { const { communitiesData } = await import('./communities.js'); result = communitiesData(dbPath, { diff --git a/tests/integration/manifesto.test.js b/tests/integration/manifesto.test.js new file mode 100644 index 00000000..bc876012 --- /dev/null +++ b/tests/integration/manifesto.test.js @@ -0,0 +1,325 @@ +/** + * Integration tests for manifesto rule engine. + * + * Creates a temp DB with fixture data, then verifies manifestoData() + * returns correct pass/fail verdicts and violation details. + */ + +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 { manifestoData, RULE_DEFS } from '../../src/manifesto.js'; + +// ─── Helpers ─────────────────────────────────────────────────────────── + +function insertNode(db, name, kind, file, line, endLine = null) { + return db + .prepare('INSERT INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)') + .run(name, kind, file, line, endLine).lastInsertRowid; +} + +function insertComplexity(db, nodeId, cognitive, cyclomatic, maxNesting) { + db.prepare( + 'INSERT INTO function_complexity (node_id, cognitive, cyclomatic, max_nesting) VALUES (?, ?, ?, ?)', + ).run(nodeId, cognitive, cyclomatic, maxNesting); +} + +function insertFileMetrics(db, nodeId, opts = {}) { + db.prepare( + 'INSERT INTO node_metrics (node_id, line_count, symbol_count, import_count, export_count, fan_in, fan_out) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run( + nodeId, + opts.lineCount ?? 100, + opts.symbolCount ?? 5, + opts.importCount ?? 3, + opts.exportCount ?? 2, + opts.fanIn ?? 1, + opts.fanOut ?? 2, + ); +} + +function insertEdge(db, sourceId, targetId, kind, confidence = 1.0) { + db.prepare('INSERT INTO edges (source_id, target_id, kind, confidence) VALUES (?, ?, ?, ?)').run( + sourceId, + targetId, + kind, + confidence, + ); +} + +// ─── Fixture DB ──────────────────────────────────────────────────────── + +let tmpDir, dbPath; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-manifesto-')); + 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); + + // Function nodes with varying complexity + const fn1 = insertNode(db, 'simpleAdd', 'function', 'src/math.js', 1, 3); + const fn2 = insertNode(db, 'processItems', 'function', 'src/processor.js', 5, 40); + const fn3 = insertNode(db, 'validateInput', 'function', 'src/validator.js', 1, 20); + const fn4 = insertNode(db, 'handleRequest', 'method', 'src/handler.js', 10, 50); + const fn5 = insertNode(db, 'testHelper', 'function', 'tests/helper.test.js', 1, 10); + + insertComplexity(db, fn1, 0, 1, 0); // trivial + insertComplexity(db, fn2, 18, 8, 4); // above cognitive warn (15), at maxNesting warn (4) + insertComplexity(db, fn3, 12, 11, 3); // above cyclomatic warn (10) + insertComplexity(db, fn4, 25, 15, 5); // above all thresholds + insertComplexity(db, fn5, 5, 3, 2); // test file + + // File nodes with metrics + const file1 = insertNode(db, 'src/math.js', 'file', 'src/math.js', 1); + const file2 = insertNode(db, 'src/processor.js', 'file', 'src/processor.js', 1); + const file3 = insertNode(db, 'src/handler.js', 'file', 'src/handler.js', 1); + const testFile = insertNode(db, 'tests/helper.test.js', 'file', 'tests/helper.test.js', 1); + + insertFileMetrics(db, file1, { + importCount: 2, + exportCount: 1, + lineCount: 50, + fanIn: 3, + fanOut: 1, + }); + insertFileMetrics(db, file2, { + importCount: 5, + exportCount: 3, + lineCount: 200, + fanIn: 2, + fanOut: 4, + }); + insertFileMetrics(db, file3, { + importCount: 8, + exportCount: 2, + lineCount: 300, + fanIn: 1, + fanOut: 6, + }); + insertFileMetrics(db, testFile, { + importCount: 3, + exportCount: 0, + lineCount: 80, + fanIn: 0, + fanOut: 2, + }); + + // Create a file-level cycle: math.js → processor.js → math.js + insertEdge(db, file1, file2, 'imports'); + insertEdge(db, file2, file1, 'imports'); + + db.close(); +}); + +afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ─── Tests ───────────────────────────────────────────────────────────── + +describe('manifestoData', () => { + test('returns all rules with default thresholds', () => { + const data = manifestoData(dbPath); + expect(data.rules).toBeDefined(); + expect(data.rules.length).toBe(RULE_DEFS.length); + + const names = data.rules.map((r) => r.name); + for (const def of RULE_DEFS) { + expect(names).toContain(def.name); + } + }); + + test('default thresholds: cognitive/cyclomatic/maxNesting active, file rules disabled', () => { + const data = manifestoData(dbPath); + + // Function rules should have defaults + const cognitive = data.rules.find((r) => r.name === 'cognitive'); + expect(cognitive.thresholds.warn).toBe(15); + + const cyclomatic = data.rules.find((r) => r.name === 'cyclomatic'); + expect(cyclomatic.thresholds.warn).toBe(10); + + const maxNesting = data.rules.find((r) => r.name === 'maxNesting'); + expect(maxNesting.thresholds.warn).toBe(4); + + // File rules should be disabled (null thresholds) + const importCount = data.rules.find((r) => r.name === 'importCount'); + expect(importCount.thresholds.warn).toBeNull(); + expect(importCount.thresholds.fail).toBeNull(); + expect(importCount.status).toBe('pass'); + expect(importCount.violationCount).toBe(0); + }); + + test('detects function-level warn violations (cognitive > 15)', () => { + const data = manifestoData(dbPath); + const cogWarn = data.violations.filter((v) => v.rule === 'cognitive' && v.level === 'warn'); + // processItems (18) and handleRequest (25) exceed cognitive warn=15 + expect(cogWarn.length).toBeGreaterThanOrEqual(2); + const names = cogWarn.map((v) => v.name); + expect(names).toContain('processItems'); + expect(names).toContain('handleRequest'); + }); + + test('detects function-level warn violations (cyclomatic > 10)', () => { + const data = manifestoData(dbPath); + const cycWarn = data.violations.filter((v) => v.rule === 'cyclomatic' && v.level === 'warn'); + // validateInput (11) and handleRequest (15) exceed cyclomatic warn=10 + expect(cycWarn.length).toBeGreaterThanOrEqual(2); + const names = cycWarn.map((v) => v.name); + expect(names).toContain('validateInput'); + expect(names).toContain('handleRequest'); + }); + + test('file-level rules disabled by default produce zero violations', () => { + const data = manifestoData(dbPath); + const fileRules = data.rules.filter((r) => r.level === 'file'); + for (const rule of fileRules) { + expect(rule.status).toBe('pass'); + expect(rule.violationCount).toBe(0); + } + }); + + test('noTests filter excludes test files from violations', () => { + const data = manifestoData(dbPath, { noTests: true }); + for (const v of data.violations) { + if (v.file) { + expect(v.file).not.toMatch(/\.test\./); + } + } + }); + + test('file filter scopes to matching files', () => { + const data = manifestoData(dbPath, { file: 'handler' }); + // Only handleRequest should appear + const funcViolations = data.violations.filter((v) => v.rule !== 'noCycles' && v.file); + for (const v of funcViolations) { + expect(v.file).toContain('handler'); + } + }); + + test('kind filter scopes to matching symbol kinds', () => { + const data = manifestoData(dbPath, { kind: 'method' }); + const funcViolations = data.violations.filter( + (v) => v.rule === 'cognitive' || v.rule === 'cyclomatic' || v.rule === 'maxNesting', + ); + // Only handleRequest is a method + for (const v of funcViolations) { + expect(v.name).toBe('handleRequest'); + } + }); + + test('summary counts are accurate', () => { + const data = manifestoData(dbPath); + const s = data.summary; + expect(s.total).toBe(RULE_DEFS.length); + expect(s.passed + s.warned + s.failed).toBe(s.total); + expect(s.violationCount).toBe(data.violations.length); + }); + + test('passed is true when no fail-level violations', () => { + // Default config has no fail thresholds, so passed should be true + const data = manifestoData(dbPath); + expect(data.passed).toBe(true); + expect(data.violations.every((v) => v.level === 'warn')).toBe(true); + }); + + test('empty DB — graceful handling, all rules pass', () => { + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-manifesto-empty-')); + fs.mkdirSync(path.join(emptyDir, '.codegraph')); + const emptyDbPath = path.join(emptyDir, '.codegraph', 'graph.db'); + + const db = new Database(emptyDbPath); + db.pragma('journal_mode = WAL'); + initSchema(db); + db.close(); + + try { + const data = manifestoData(emptyDbPath); + expect(data.passed).toBe(true); + expect(data.violations.length).toBe(0); + for (const rule of data.rules) { + expect(rule.status).toBe('pass'); + } + } finally { + fs.rmSync(emptyDir, { recursive: true, force: true }); + } + }); + + test('noCycles rule detects cycles when enabled via config', () => { + // Create a temp config that enables noCycles + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-manifesto-cycles-')); + fs.mkdirSync(path.join(configDir, '.codegraph')); + const cycleDbPath = path.join(configDir, '.codegraph', 'graph.db'); + + // Copy the fixture DB + fs.copyFileSync(dbPath, cycleDbPath); + + // Write a config that enables noCycles with warn + fs.writeFileSync( + path.join(configDir, '.codegraphrc.json'), + JSON.stringify({ + manifesto: { + rules: { + noCycles: { warn: true, fail: null }, + }, + }, + }), + ); + + // Temporarily change cwd so loadConfig picks up the config + const origCwd = process.cwd(); + try { + process.chdir(configDir); + const data = manifestoData(cycleDbPath); + const noCyclesRule = data.rules.find((r) => r.name === 'noCycles'); + expect(noCyclesRule.status).toBe('warn'); + expect(noCyclesRule.violationCount).toBeGreaterThan(0); + + const cycleViolations = data.violations.filter((v) => v.rule === 'noCycles'); + expect(cycleViolations.length).toBeGreaterThan(0); + expect(cycleViolations[0].level).toBe('warn'); + } finally { + process.chdir(origCwd); + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + + test('noCycles rule with fail threshold sets passed=false', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-manifesto-fail-')); + fs.mkdirSync(path.join(configDir, '.codegraph')); + const failDbPath = path.join(configDir, '.codegraph', 'graph.db'); + + fs.copyFileSync(dbPath, failDbPath); + + fs.writeFileSync( + path.join(configDir, '.codegraphrc.json'), + JSON.stringify({ + manifesto: { + rules: { + noCycles: { warn: null, fail: true }, + }, + }, + }), + ); + + const origCwd = process.cwd(); + try { + process.chdir(configDir); + const data = manifestoData(failDbPath); + expect(data.passed).toBe(false); + + const noCyclesRule = data.rules.find((r) => r.name === 'noCycles'); + expect(noCyclesRule.status).toBe('fail'); + } finally { + process.chdir(origCwd); + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); +}); From fc99ec847b1a21c56df1566ed7968091b2cef99b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Feb 2026 17:46:09 -0700 Subject: [PATCH 2/3] perf: eliminate WASM re-parse for native complexity + build optimizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port complexity algorithm to Rust (complexity.rs) so the native engine computes cognitive/cyclomatic/maxNesting during extraction, eliminating the expensive WASM re-parse fallback that caused the 1.9→4.8 ms/file regression. Additional optimizations: - Cache line counts during parse (avoids re-reading every file from disk) - Use pre-loaded nodesByNameAndFile maps for extends/implements edges (replaces inline DB queries in the edge-building loop) - Optimize structure cohesion from O(dirs×edges) to O(edges+dirs) via reverse file→dirs index and single-pass edge aggregation Impact: 44 functions changed, 46 affected --- crates/codegraph-core/src/complexity.rs | 456 ++++++++++++++++++ .../codegraph-core/src/extractors/csharp.rs | 8 + crates/codegraph-core/src/extractors/go.rs | 2 + crates/codegraph-core/src/extractors/hcl.rs | 1 + crates/codegraph-core/src/extractors/java.rs | 5 + .../src/extractors/javascript.rs | 11 + crates/codegraph-core/src/extractors/php.rs | 6 + .../codegraph-core/src/extractors/python.rs | 2 + crates/codegraph-core/src/extractors/ruby.rs | 4 + .../src/extractors/rust_lang.rs | 4 + crates/codegraph-core/src/lib.rs | 1 + crates/codegraph-core/src/parallel.rs | 9 +- crates/codegraph-core/src/types.rs | 11 + src/builder.js | 34 +- src/complexity.js | 42 +- src/parser.js | 9 + src/structure.js | 70 ++- 17 files changed, 629 insertions(+), 46 deletions(-) create mode 100644 crates/codegraph-core/src/complexity.rs diff --git a/crates/codegraph-core/src/complexity.rs b/crates/codegraph-core/src/complexity.rs new file mode 100644 index 00000000..c391f8e0 --- /dev/null +++ b/crates/codegraph-core/src/complexity.rs @@ -0,0 +1,456 @@ +use tree_sitter::Node; + +use crate::types::ComplexityMetrics; + +/// Language kind for complexity analysis (only JS/TS/TSX supported). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ComplexityLang { + JavaScript, + TypeScript, + Tsx, +} + +impl ComplexityLang { + /// Derive from file extension. Returns None for unsupported languages. + pub fn from_extension(path: &str) -> Option { + let ext = path.rsplit('.').next().unwrap_or(""); + match ext { + "js" | "jsx" | "mjs" | "cjs" => Some(Self::JavaScript), + "ts" => Some(Self::TypeScript), + "tsx" => Some(Self::Tsx), + _ => None, + } + } +} + +// ─── Node type sets (JS/TS/TSX share the same tree-sitter grammar structure) ── + +fn is_branch_node(kind: &str) -> bool { + matches!( + kind, + "if_statement" + | "else_clause" + | "switch_statement" + | "for_statement" + | "for_in_statement" + | "while_statement" + | "do_statement" + | "catch_clause" + | "ternary_expression" + ) +} + +fn is_nesting_node(kind: &str) -> bool { + matches!( + kind, + "if_statement" + | "switch_statement" + | "for_statement" + | "for_in_statement" + | "while_statement" + | "do_statement" + | "catch_clause" + | "ternary_expression" + ) +} + +fn is_function_node(kind: &str) -> bool { + matches!( + kind, + "function_declaration" + | "function_expression" + | "arrow_function" + | "method_definition" + | "generator_function" + | "generator_function_declaration" + ) +} + +fn is_logical_operator(kind: &str) -> bool { + matches!(kind, "&&" | "||" | "??") +} + +fn is_case_node(kind: &str) -> bool { + kind == "switch_case" +} + +// ─── Single-traversal DFS complexity computation ────────────────────────── + +/// Compute cognitive complexity, cyclomatic complexity, and max nesting depth +/// for a function's AST subtree in a single DFS walk. +/// +/// This is a faithful port of `computeFunctionComplexity()` from `src/complexity.js`. +pub fn compute_function_complexity(function_node: &Node) -> ComplexityMetrics { + let mut cognitive: u32 = 0; + let mut cyclomatic: u32 = 1; // McCabe starts at 1 + let mut max_nesting: u32 = 0; + + walk( + function_node, + 0, + true, + &mut cognitive, + &mut cyclomatic, + &mut max_nesting, + ); + + ComplexityMetrics { + cognitive, + cyclomatic, + max_nesting, + } +} + +fn walk( + node: &Node, + nesting_level: u32, + is_top_function: bool, + cognitive: &mut u32, + cyclomatic: &mut u32, + max_nesting: &mut u32, +) { + let kind = node.kind(); + + // Track nesting depth + if nesting_level > *max_nesting { + *max_nesting = nesting_level; + } + + // Handle logical operators in binary expressions + if kind == "binary_expression" { + if let Some(op_node) = node.child(1) { + let op = op_node.kind(); + if is_logical_operator(op) { + // Cyclomatic: +1 for every logical operator + *cyclomatic += 1; + + // Cognitive: +1 only when operator changes from the previous sibling sequence + let mut same_sequence = false; + if let Some(parent) = node.parent() { + if parent.kind() == "binary_expression" { + if let Some(parent_op) = parent.child(1) { + if parent_op.kind() == op { + same_sequence = true; + } + } + } + } + if !same_sequence { + *cognitive += 1; + } + + // Walk children manually to avoid double-counting + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + walk(&child, nesting_level, false, cognitive, cyclomatic, max_nesting); + } + } + return; + } + } + } + + // Handle optional chaining (cyclomatic only) + if kind == "optional_chain_expression" { + *cyclomatic += 1; + } + + // Handle branch/control flow nodes + if is_branch_node(kind) { + let is_else_if = kind == "if_statement" + && node + .parent() + .map_or(false, |p| p.kind() == "else_clause"); + + if kind == "else_clause" { + // else: +1 cognitive structural, no nesting increment, no cyclomatic + // But only if it's a plain else (not else-if) + let first_child = node.named_child(0); + if first_child.map_or(false, |c| c.kind() == "if_statement") { + // This is else-if: the if_statement child handles its own increment + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + walk(&child, nesting_level, false, cognitive, cyclomatic, max_nesting); + } + } + return; + } + // Plain else + *cognitive += 1; + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + walk(&child, nesting_level, false, cognitive, cyclomatic, max_nesting); + } + } + return; + } + + if is_else_if { + // else-if: +1 structural cognitive, +1 cyclomatic, NO nesting increment + *cognitive += 1; + *cyclomatic += 1; + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + walk(&child, nesting_level, false, cognitive, cyclomatic, max_nesting); + } + } + return; + } + + // Regular branch node + *cognitive += 1 + nesting_level; // structural + nesting + *cyclomatic += 1; + + // switch_statement doesn't add cyclomatic itself (cases do), but adds cognitive + if kind == "switch_statement" { + *cyclomatic -= 1; // Undo the ++ above; cases handle cyclomatic + } + + if is_nesting_node(kind) { + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + walk( + &child, + nesting_level + 1, + false, + cognitive, + cyclomatic, + max_nesting, + ); + } + } + return; + } + } + + // Handle case nodes (cyclomatic only) + if is_case_node(kind) { + *cyclomatic += 1; + } + + // Handle nested function definitions (increase nesting) + if !is_top_function && is_function_node(kind) { + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + walk( + &child, + nesting_level + 1, + false, + cognitive, + cyclomatic, + max_nesting, + ); + } + } + return; + } + + // Walk children + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + walk(&child, nesting_level, false, cognitive, cyclomatic, max_nesting); + } + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use tree_sitter::Parser; + + fn compute_js(code: &str) -> ComplexityMetrics { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_javascript::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(code.as_bytes(), None).unwrap(); + // Find the first function node + let root = tree.root_node(); + let func = find_first_function(&root).expect("no function found in test code"); + compute_function_complexity(&func) + } + + fn find_first_function<'a>(node: &Node<'a>) -> Option> { + if is_function_node(node.kind()) { + return Some(*node); + } + // For variable declarations with arrow functions + if node.kind() == "variable_declarator" { + if let Some(value) = node.child_by_field_name("value") { + if is_function_node(value.kind()) { + return Some(value); + } + } + } + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + if let Some(found) = find_first_function(&child) { + return Some(found); + } + } + } + None + } + + #[test] + fn empty_function() { + let m = compute_js("function f() {}"); + assert_eq!(m.cognitive, 0); + assert_eq!(m.cyclomatic, 1); + assert_eq!(m.max_nesting, 0); + } + + #[test] + fn single_if() { + let m = compute_js("function f(x) { if (x) { return 1; } }"); + assert_eq!(m.cognitive, 1); // +1 structural + assert_eq!(m.cyclomatic, 2); // 1 base + 1 if + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn if_else() { + let m = compute_js("function f(x) { if (x) { return 1; } else { return 0; } }"); + assert_eq!(m.cognitive, 2); // +1 if, +1 else + assert_eq!(m.cyclomatic, 2); // 1 base + 1 if + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn if_else_if_else() { + let m = compute_js( + "function f(x) { if (x > 0) { return 1; } else if (x < 0) { return -1; } else { return 0; } }", + ); + assert_eq!(m.cognitive, 4); // +1 if, +1 else-if, +1 else (from else-if's else_clause), +1 else-if cognitive + // Wait, let me recalculate: + // if: cognitive +1 (nesting 0), cyclomatic +1 + // else-if: cognitive +1, cyclomatic +1 (no nesting) + // else: cognitive +1 + // Total cognitive = 3, cyclomatic = 1 + 1 + 1 = 3 + // Hmm, the else clause wrapping the else-if doesn't add anything (it's detected as else-if wrapper) + // So: if (+1 cog, +1 cyc), else-if (+1 cog, +1 cyc), plain else (+1 cog) + // cognitive = 3, cyclomatic = 3 + assert_eq!(m.cognitive, 3); + assert_eq!(m.cyclomatic, 3); + } + + #[test] + fn nested_if() { + let m = compute_js( + "function f(x, y) { if (x) { if (y) { return 1; } } }", + ); + // Outer if: cognitive +1 (nesting 0), cyclomatic +1 + // Inner if: cognitive +1+1 (nesting 1), cyclomatic +1 + assert_eq!(m.cognitive, 3); + assert_eq!(m.cyclomatic, 3); + assert_eq!(m.max_nesting, 2); + } + + #[test] + fn for_loop() { + let m = compute_js("function f(arr) { for (let i = 0; i < arr.length; i++) { process(arr[i]); } }"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn logical_operators_same() { + let m = compute_js("function f(a, b, c) { if (a && b && c) { return 1; } }"); + // if: cognitive +1, cyclomatic +1 + // &&: cyclomatic +1 each (2 operators), cognitive +1 for first && (sequence start) + // second && is same sequence, no cognitive + assert_eq!(m.cognitive, 2); // 1 (if) + 1 (&&) + assert_eq!(m.cyclomatic, 4); // 1 base + 1 if + 2 && + } + + #[test] + fn logical_operators_mixed() { + let m = compute_js("function f(a, b, c) { if (a && b || c) { return 1; } }"); + // if: cognitive +1, cyclomatic +1 + // The AST is: (a && b) || c + // || at top: cyclomatic +1, cognitive +1 (new sequence) + // && nested: cyclomatic +1, cognitive +1 (different from parent ||) + assert_eq!(m.cognitive, 3); // 1 (if) + 1 (&&) + 1 (||) + assert_eq!(m.cyclomatic, 4); // 1 base + 1 if + 1 && + 1 || + } + + #[test] + fn switch_case() { + let m = compute_js( + "function f(x) { switch(x) { case 1: return 'a'; case 2: return 'b'; default: return 'c'; } }", + ); + // switch: cognitive +1, cyclomatic undone + // case 1: cyclomatic +1 + // case 2: cyclomatic +1 + // default is not switch_case, so no cyclomatic + assert_eq!(m.cognitive, 1); // switch structural + assert_eq!(m.cyclomatic, 3); // 1 base + 2 cases + } + + #[test] + fn ternary() { + let m = compute_js("function f(x) { return x ? 1 : 0; }"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn nested_function() { + let m = compute_js( + "function f(x) { const inner = () => { if (x) { return 1; } }; }", + ); + // Nested arrow function increases nesting + // if inside nested: cognitive +1+1 (nesting=1 from nested fn), cyclomatic +1 + assert_eq!(m.cognitive, 2); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 2); + } + + #[test] + fn catch_clause() { + let m = compute_js( + "function f() { try { doSomething(); } catch(e) { handleError(e); } }", + ); + // catch: cognitive +1 (nesting 0), cyclomatic +1 + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + } + + #[test] + fn while_loop() { + let m = compute_js("function f() { while (true) { doSomething(); } }"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn do_while_loop() { + let m = compute_js("function f() { do { doSomething(); } while (true); }"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn complexity_lang_from_extension() { + assert_eq!( + ComplexityLang::from_extension("foo.js"), + Some(ComplexityLang::JavaScript) + ); + assert_eq!( + ComplexityLang::from_extension("foo.ts"), + Some(ComplexityLang::TypeScript) + ); + assert_eq!( + ComplexityLang::from_extension("foo.tsx"), + Some(ComplexityLang::Tsx) + ); + assert_eq!(ComplexityLang::from_extension("foo.py"), None); + assert_eq!(ComplexityLang::from_extension("foo.go"), None); + } +} diff --git a/crates/codegraph-core/src/extractors/csharp.rs b/crates/codegraph-core/src/extractors/csharp.rs index 08ad7045..d899b04d 100644 --- a/crates/codegraph-core/src/extractors/csharp.rs +++ b/crates/codegraph-core/src/extractors/csharp.rs @@ -41,6 +41,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); extract_csharp_base_types(node, &class_name, source, symbols); } @@ -55,6 +56,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); extract_csharp_base_types(node, &name, source, symbols); } @@ -69,6 +71,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); extract_csharp_base_types(node, &name, source, symbols); } @@ -83,6 +86,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); if let Some(body) = node.child_by_field_name("body") { for i in 0..body.child_count() { @@ -116,6 +120,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); } } @@ -134,6 +139,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); } } @@ -152,6 +158,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); } } @@ -170,6 +177,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); } } diff --git a/crates/codegraph-core/src/extractors/go.rs b/crates/codegraph-core/src/extractors/go.rs index 63eea911..e416d68d 100644 --- a/crates/codegraph-core/src/extractors/go.rs +++ b/crates/codegraph-core/src/extractors/go.rs @@ -23,6 +23,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); } } @@ -58,6 +59,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); } } diff --git a/crates/codegraph-core/src/extractors/hcl.rs b/crates/codegraph-core/src/extractors/hcl.rs index 776c9de8..1cbb539d 100644 --- a/crates/codegraph-core/src/extractors/hcl.rs +++ b/crates/codegraph-core/src/extractors/hcl.rs @@ -66,6 +66,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); // Module source imports diff --git a/crates/codegraph-core/src/extractors/java.rs b/crates/codegraph-core/src/extractors/java.rs index ba547060..e0ff0b0c 100644 --- a/crates/codegraph-core/src/extractors/java.rs +++ b/crates/codegraph-core/src/extractors/java.rs @@ -40,6 +40,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); // Superclass @@ -91,6 +92,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); if let Some(body) = node.child_by_field_name("body") { for i in 0..body.child_count() { @@ -124,6 +126,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); } } @@ -142,6 +145,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); } } @@ -160,6 +164,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); } } diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index fb254842..61ada78e 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -1,4 +1,5 @@ use tree_sitter::{Node, Tree}; +use crate::complexity::compute_function_complexity; use crate::types::*; use super::helpers::*; use super::SymbolExtractor; @@ -23,6 +24,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: Some(compute_function_complexity(node)), }); } } @@ -36,6 +38,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); // Heritage: extends + implements @@ -77,6 +80,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: Some(compute_function_complexity(node)), }); } } @@ -90,6 +94,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); // Extract interface methods let body = node @@ -110,6 +115,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); } } @@ -132,6 +138,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(&value_n)), decorators: None, + complexity: Some(compute_function_complexity(&value_n)), }); } } @@ -340,6 +347,7 @@ fn extract_interface_methods( line: start_line(&child), end_line: Some(end_line(&child)), decorators: None, + complexity: None, }); } } @@ -554,6 +562,7 @@ fn extract_callback_definition(call_node: &Node, source: &[u8]) -> Option Option Option Vec Vec Option { .ok()?; let tree = parser.parse(source_bytes, None)?; - Some(extract_symbols(lang, &tree, source_bytes, file_path)) + let line_count = source_bytes.iter().filter(|&&b| b == b'\n').count() as u32 + 1; + let mut symbols = extract_symbols(lang, &tree, source_bytes, file_path); + symbols.line_count = Some(line_count); + Some(symbols) } diff --git a/crates/codegraph-core/src/types.rs b/crates/codegraph-core/src/types.rs index a255120b..9fe5bad7 100644 --- a/crates/codegraph-core/src/types.rs +++ b/crates/codegraph-core/src/types.rs @@ -1,6 +1,14 @@ use napi_derive::napi; use serde::{Deserialize, Serialize}; +#[napi(object)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComplexityMetrics { + pub cognitive: u32, + pub cyclomatic: u32, + pub max_nesting: u32, +} + #[napi(object)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Definition { @@ -10,6 +18,7 @@ pub struct Definition { pub end_line: Option, #[napi(ts_type = "string[] | undefined")] pub decorators: Option>, + pub complexity: Option, } #[napi(object)] @@ -86,6 +95,7 @@ pub struct FileSymbols { pub imports: Vec, pub classes: Vec, pub exports: Vec, + pub line_count: Option, } impl FileSymbols { @@ -97,6 +107,7 @@ impl FileSymbols { imports: Vec::new(), classes: Vec::new(), exports: Vec::new(), + line_count: None, } } } diff --git a/src/builder.js b/src/builder.js index 708d6a76..f7a36e6b 100644 --- a/src/builder.js +++ b/src/builder.js @@ -880,12 +880,12 @@ export async function buildGraph(rootDir, opts = {}) { } } - // Class extends edges + // Class extends edges (use pre-loaded maps instead of inline DB queries) for (const cls of symbols.classes) { if (cls.extends) { - const sourceRow = db - .prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ?') - .get(cls.name, 'class', relPath); + const sourceRow = (nodesByNameAndFile.get(`${cls.name}|${relPath}`) || []).find( + (n) => n.kind === 'class', + ); const targetCandidates = nodesByName.get(cls.extends) || []; const targetRows = targetCandidates.filter((n) => n.kind === 'class'); if (sourceRow) { @@ -897,9 +897,9 @@ export async function buildGraph(rootDir, opts = {}) { } if (cls.implements) { - const sourceRow = db - .prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ?') - .get(cls.name, 'class', relPath); + const sourceRow = (nodesByNameAndFile.get(`${cls.name}|${relPath}`) || []).find( + (n) => n.kind === 'class', + ); const targetCandidates = nodesByName.get(cls.implements) || []; const targetRows = targetCandidates.filter( (n) => n.kind === 'interface' || n.kind === 'class', @@ -916,15 +916,19 @@ export async function buildGraph(rootDir, opts = {}) { }); buildEdges(); - // Build line count map for structure metrics + // Build line count map for structure metrics (prefer cached _lineCount from parser) const lineCountMap = new Map(); - for (const [relPath] of fileSymbols) { - const absPath = path.join(rootDir, relPath); - try { - const content = fs.readFileSync(absPath, 'utf-8'); - lineCountMap.set(relPath, content.split('\n').length); - } catch { - lineCountMap.set(relPath, 0); + for (const [relPath, symbols] of fileSymbols) { + if (symbols._lineCount) { + lineCountMap.set(relPath, symbols._lineCount); + } else { + const absPath = path.join(rootDir, relPath); + try { + const content = fs.readFileSync(absPath, 'utf-8'); + lineCountMap.set(relPath, content.split('\n').length); + } catch { + lineCountMap.set(relPath, 0); + } } } diff --git a/src/complexity.js b/src/complexity.js index 5a3e5459..d3686361 100644 --- a/src/complexity.js +++ b/src/complexity.js @@ -234,14 +234,20 @@ function findFunctionNode(rootNode, startLine, _endLine, rules) { * @param {object} [engineOpts] - engine options (unused; always uses WASM for AST) */ export async function buildComplexityMetrics(db, fileSymbols, rootDir, _engineOpts) { - // Only initialize WASM parsers if some files lack a cached tree (native engine path) + // Only initialize WASM parsers if some files lack both a cached tree AND pre-computed complexity let parsers = null; let extToLang = null; let needsFallback = false; for (const [, symbols] of fileSymbols) { if (!symbols._tree) { - needsFallback = true; - break; + // Check if all function/method defs have pre-computed complexity (native engine) + const hasPrecomputed = symbols.definitions.every( + (d) => (d.kind !== 'function' && d.kind !== 'method') || d.complexity, + ); + if (!hasPrecomputed) { + needsFallback = true; + break; + } } } if (needsFallback) { @@ -268,11 +274,17 @@ export async function buildComplexityMetrics(db, fileSymbols, rootDir, _engineOp const tx = db.transaction(() => { for (const [relPath, symbols] of fileSymbols) { + // Check if all function/method defs have pre-computed complexity + const allPrecomputed = symbols.definitions.every( + (d) => (d.kind !== 'function' && d.kind !== 'method') || d.complexity, + ); + let tree = symbols._tree; let langId = symbols._langId; - // Fallback: re-read and re-parse when no cached tree (native engine) - if (!tree) { + // Only attempt WASM fallback if we actually need AST-based computation + if (!allPrecomputed && !tree) { + if (!extToLang) continue; // No WASM parsers available const ext = path.extname(relPath).toLowerCase(); langId = extToLang.get(ext); if (!langId) continue; @@ -295,13 +307,29 @@ export async function buildComplexityMetrics(db, fileSymbols, rootDir, _engineOp } } - const rules = COMPLEXITY_RULES.get(langId); - if (!rules) continue; + const rules = langId ? COMPLEXITY_RULES.get(langId) : null; for (const def of symbols.definitions) { if (def.kind !== 'function' && def.kind !== 'method') continue; if (!def.line) continue; + // Use pre-computed complexity from native engine if available + if (def.complexity) { + const row = getNodeId.get(def.name, relPath, def.line); + if (!row) continue; + upsert.run( + row.id, + def.complexity.cognitive, + def.complexity.cyclomatic, + def.complexity.maxNesting ?? def.complexity.max_nesting ?? 0, + ); + analyzed++; + continue; + } + + // Fallback: compute from AST tree + if (!tree || !rules) continue; + const funcNode = findFunctionNode(tree.rootNode, def.line, def.endLine, rules); if (!funcNode) continue; diff --git a/src/parser.js b/src/parser.js index 9bd9814c..46ad4312 100644 --- a/src/parser.js +++ b/src/parser.js @@ -125,12 +125,20 @@ function resolveEngine(opts = {}) { */ function normalizeNativeSymbols(result) { return { + _lineCount: result.lineCount ?? result.line_count ?? null, definitions: (result.definitions || []).map((d) => ({ name: d.name, kind: d.kind, line: d.line, endLine: d.endLine ?? d.end_line ?? null, decorators: d.decorators, + complexity: d.complexity + ? { + cognitive: d.complexity.cognitive, + cyclomatic: d.complexity.cyclomatic, + maxNesting: d.complexity.maxNesting ?? d.complexity.max_nesting, + } + : null, })), calls: (result.calls || []).map((c) => ({ name: c.name, @@ -342,6 +350,7 @@ export async function parseFilesAuto(filePaths, rootDir, opts = {}) { 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); } } diff --git a/src/structure.js b/src/structure.js index ee491e8d..ca92ed51 100644 --- a/src/structure.js +++ b/src/structure.js @@ -162,6 +162,48 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director } } + // Build reverse index: file → set of ancestor directories (O(files × depth)) + const fileToAncestorDirs = new Map(); + for (const [dir, files] of dirFiles) { + for (const f of files) { + if (!fileToAncestorDirs.has(f)) fileToAncestorDirs.set(f, new Set()); + fileToAncestorDirs.get(f).add(dir); + } + } + + // Single O(E) pass: pre-aggregate edge counts per directory + const dirEdgeCounts = new Map(); + for (const dir of allDirs) { + dirEdgeCounts.set(dir, { intra: 0, fanIn: 0, fanOut: 0 }); + } + for (const { source_file, target_file } of importEdges) { + const srcDirs = fileToAncestorDirs.get(source_file); + const tgtDirs = fileToAncestorDirs.get(target_file); + if (!srcDirs && !tgtDirs) continue; + + // For each directory that contains the source file + if (srcDirs) { + for (const dir of srcDirs) { + const counts = dirEdgeCounts.get(dir); + if (!counts) continue; + if (tgtDirs?.has(dir)) { + counts.intra++; + } else { + counts.fanOut++; + } + } + } + // For each directory that contains the target but NOT the source + if (tgtDirs) { + for (const dir of tgtDirs) { + if (srcDirs?.has(dir)) continue; // already counted as intra + const counts = dirEdgeCounts.get(dir); + if (!counts) continue; + counts.fanIn++; + } + } + } + const computeDirMetrics = db.transaction(() => { for (const [dir, files] of dirFiles) { const dirRow = getNodeId.get(dir, 'directory', dir, 0); @@ -169,9 +211,6 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director const fileCount = files.length; let symbolCount = 0; - let totalFanIn = 0; - let totalFanOut = 0; - const filesInDir = new Set(files); for (const f of files) { const sym = fileSymbols.get(f); @@ -187,23 +226,10 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director } } - // Compute cross-boundary fan-in/fan-out and cohesion - let intraEdges = 0; - let crossEdges = 0; - for (const { source_file, target_file } of importEdges) { - const srcInside = filesInDir.has(source_file); - const tgtInside = filesInDir.has(target_file); - if (srcInside && tgtInside) { - intraEdges++; - } else if (srcInside || tgtInside) { - crossEdges++; - if (!srcInside && tgtInside) totalFanIn++; - if (srcInside && !tgtInside) totalFanOut++; - } - } - - const totalEdges = intraEdges + crossEdges; - const cohesion = totalEdges > 0 ? intraEdges / totalEdges : null; + // O(1) lookup from pre-aggregated edge counts + const counts = dirEdgeCounts.get(dir) || { intra: 0, fanIn: 0, fanOut: 0 }; + const totalEdges = counts.intra + counts.fanIn + counts.fanOut; + const cohesion = totalEdges > 0 ? counts.intra / totalEdges : null; upsertMetric.run( dirRow.id, @@ -211,8 +237,8 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director symbolCount, null, null, - totalFanIn, - totalFanOut, + counts.fanIn, + counts.fanOut, cohesion, fileCount, ); From b2e1fc170f43f82de5ec07d4e36927c0e4c1af9d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Feb 2026 18:02:32 -0700 Subject: [PATCH 3/3] fix: add missing complexity field to native extractors and manifesto tool to MCP tests - Add complexity: None to Definition initializers in go, rust, java, csharp, and php extractors (fixes Rust compile errors) - Add 'manifesto' to MCP test ALL_TOOL_NAMES (fixes tool count mismatch) - Log errors in manifesto query catch blocks instead of silencing them - Remove redundant isTestFile filtering already handled by SQL WHERE Impact: 7 functions changed, 7 affected --- crates/codegraph-core/src/extractors/csharp.rs | 1 + crates/codegraph-core/src/extractors/go.rs | 4 ++++ crates/codegraph-core/src/extractors/java.rs | 1 + crates/codegraph-core/src/extractors/php.rs | 1 + .../codegraph-core/src/extractors/rust_lang.rs | 1 + src/manifesto.js | 16 +++++----------- tests/unit/mcp.test.js | 1 + 7 files changed, 14 insertions(+), 11 deletions(-) diff --git a/crates/codegraph-core/src/extractors/csharp.rs b/crates/codegraph-core/src/extractors/csharp.rs index d899b04d..5d9ac600 100644 --- a/crates/codegraph-core/src/extractors/csharp.rs +++ b/crates/codegraph-core/src/extractors/csharp.rs @@ -103,6 +103,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(&child), end_line: Some(end_line(&child)), decorators: None, + complexity: None, }); } } diff --git a/crates/codegraph-core/src/extractors/go.rs b/crates/codegraph-core/src/extractors/go.rs index e416d68d..4f3bd62f 100644 --- a/crates/codegraph-core/src/extractors/go.rs +++ b/crates/codegraph-core/src/extractors/go.rs @@ -82,6 +82,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); } "interface_type" => { @@ -91,6 +92,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); // Extract interface methods for j in 0..type_node.child_count() { @@ -109,6 +111,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(&member), end_line: Some(end_line(&member)), decorators: None, + complexity: None, }); } } @@ -122,6 +125,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(node), end_line: Some(end_line(node)), decorators: None, + complexity: None, }); } } diff --git a/crates/codegraph-core/src/extractors/java.rs b/crates/codegraph-core/src/extractors/java.rs index e0ff0b0c..0f6c5679 100644 --- a/crates/codegraph-core/src/extractors/java.rs +++ b/crates/codegraph-core/src/extractors/java.rs @@ -109,6 +109,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(&child), end_line: Some(end_line(&child)), decorators: None, + complexity: None, }); } } diff --git a/crates/codegraph-core/src/extractors/php.rs b/crates/codegraph-core/src/extractors/php.rs index 17b002c7..bae6d52b 100644 --- a/crates/codegraph-core/src/extractors/php.rs +++ b/crates/codegraph-core/src/extractors/php.rs @@ -121,6 +121,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(&child), end_line: Some(end_line(&child)), decorators: None, + complexity: None, }); } } diff --git a/crates/codegraph-core/src/extractors/rust_lang.rs b/crates/codegraph-core/src/extractors/rust_lang.rs index 4299e143..840539ed 100644 --- a/crates/codegraph-core/src/extractors/rust_lang.rs +++ b/crates/codegraph-core/src/extractors/rust_lang.rs @@ -101,6 +101,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { line: start_line(&child), end_line: Some(end_line(&child)), decorators: None, + complexity: None, }); } } diff --git a/src/manifesto.js b/src/manifesto.js index 2669dc8a..54638a5d 100644 --- a/src/manifesto.js +++ b/src/manifesto.js @@ -1,7 +1,7 @@ import { loadConfig } from './config.js'; import { findCycles } from './cycles.js'; import { openReadonlyOrFail } from './db.js'; -import { isTestFile } from './queries.js'; +import { debug } from './logger.js'; // ─── Rule Definitions ───────────────────────────────────────────────── @@ -148,14 +148,11 @@ function evaluateFunctionRules(db, rules, opts, violations, ruleResults) { ${where}`, ) .all(...params); - } catch { + } catch (err) { + debug('manifesto function query failed: %s', err.message); rows = []; } - if (opts.noTests) { - rows = rows.filter((r) => !isTestFile(r.file)); - } - // Track worst status per rule const worst = {}; const counts = {}; @@ -225,14 +222,11 @@ function evaluateFileRules(db, rules, opts, violations, ruleResults) { ${where}`, ) .all(...params); - } catch { + } catch (err) { + debug('manifesto file query failed: %s', err.message); rows = []; } - if (opts.noTests) { - rows = rows.filter((r) => !isTestFile(r.file)); - } - const worst = {}; const counts = {}; for (const def of fileDefs) { diff --git a/tests/unit/mcp.test.js b/tests/unit/mcp.test.js index 2712b520..fd6427d5 100644 --- a/tests/unit/mcp.test.js +++ b/tests/unit/mcp.test.js @@ -31,6 +31,7 @@ const ALL_TOOL_NAMES = [ 'execution_flow', 'list_entry_points', 'complexity', + 'manifesto', 'communities', 'list_repos', ];