diff --git a/.oxlintrc.json b/.oxlintrc.json index 59e709a..b2a1fc2 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -22,7 +22,8 @@ { "cyclomatic": 10, "cognitive": 15, - "enableExtraction": true + "enableExtraction": true, + "moduleComplexity": 80 } ], @@ -56,5 +57,5 @@ "promise/valid-params": "error" }, - "ignorePatterns": ["dist/", "node_modules/", "coverage/", "*.config.js", "*.config.ts"] + "ignorePatterns": ["dist/", "node_modules/", "coverage/", "tests/", "*.config.js", "*.config.ts"] } diff --git a/README.md b/README.md index c2a4a54..a3856b5 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # oxlint-plugin-complexity -Cyclomatic and cognitive complexity rules for [oxlint](https://oxc.rs/docs/guide/usage/linter.html) with **actionable error messages**. Also available as a standalone library for programmatic complexity analysis. +Cyclomatic and cognitive complexity rules for [oxlint](https://oxc.rs/docs/guide/usage/linter.html) with **actionable error messages**, **module-level analysis**, and a standalone library API. **Features:** -- Cyclomatic and cognitive complexity analysis. -- Actionable error messages with complexity breakdown. -- [Programmatic API](./src/index.ts) for custom tooling +- Cyclomatic and cognitive complexity analysis +- Module-level analysis: Halstead metrics, module complexity score, aggregate complexity +- Actionable error messages with complexity breakdown +- [Programmatic API](#programmatic-api) for custom tooling - **Framework support:** React, Vue, Angular, Svelte, Astro, Solid, Qwik - **File types:** `.js` `.mjs` `.cjs` `.ts` `.tsx` `.jsx` `.vue` `.svelte` `.astro` @@ -18,7 +19,7 @@ Cyclomatic and cognitive complexity rules for [oxlint](https://oxc.rs/docs/guide npm install oxlint-plugin-complexity --save-dev ``` -```json +```jsonc // .oxlintrc.json { "jsPlugins": ["oxlint-plugin-complexity"], @@ -27,10 +28,11 @@ npm install oxlint-plugin-complexity --save-dev "error", { "cyclomatic": 20, - "cognitive": 15 - } - ] - } + "cognitive": 15, + "moduleComplexity": 80, + }, + ], + }, } ``` @@ -85,6 +87,10 @@ function processData(items, mode, config) { // Performance optimization (optional) "minLines": 10, // Default: 10 (skip functions <10 lines like getters; 0 = analyze all; counts comments/blanks) + // Module-level analysis (omit "moduleComplexity" to disable) + "moduleComplexity": 80, // Maximum module complexity score (0-100). Enables module analysis. + "maxCyclomaticSum": 0, // Default: 0 (disabled; max total cyclomatic across all functions) + "maxCognitiveSum": 0, // Default: 0 (disabled; max total cognitive across all functions) // Extraction suggestions (optional) "enableExtraction": true, // Default: true "extractionMultiplier": 1.5, // Default: 1.5 (triggers at 1.5× cognitive threshold) @@ -157,6 +163,59 @@ Extraction suggestions use static analysis heuristics and may miss: Always review suggestions before applying, even when marked "high confidence". +### Module-Level Analysis + +When `moduleComplexity` is set, the rule analyzes the entire file and reports actionable, plain-language insights. + +**What is "module complexity"?** It's the inverted [Maintainability Index](https://en.wikipedia.org/wiki/Maintainability#Software_engineering) on a 0-100 scale: `moduleComplexity = 100 - scaledMI`. Under the hood it combines Halstead effort, cyclomatic complexity, and lines of code. + +**Config options:** + +- **`moduleComplexity`** — Maximum module complexity score (0-100). Enables module analysis. When violated, the report includes estimated bug risk, reading time, and identifies the main contributor. +- **`maxCyclomaticSum`** — Maximum total cyclomatic complexity across all functions. Default: 0 (disabled). +- **`maxCognitiveSum`** — Maximum total cognitive complexity across all functions. Default: 0 (disabled). + **Example config:** + +```jsonc +"moduleComplexity": 80, +"maxCyclomaticSum": 30, +"maxCognitiveSum": 40 +``` + +**Example output:** + +```text +Module is too complex (score: 81.5/100, maximum: 80). +Estimated bug risk: ~2.3 defects. Estimated reading time: ~42 min. +Main contributor: complex expressions increase bug risk. +Module has too many decision paths (total: 45, maximum: 30). +Module is too hard to read (cognitive total: 52, maximum: 40). +``` + +The main contributor tells you _why_ the score is high: + +- "complex expressions increase bug risk" — Halstead effort dominates +- "too many decision branches" — cyclomatic complexity dominates +- "functions are too long" — lines of code dominate + +### Programmatic API + +Use `analyzeModule` for complexity analysis outside of linting (CI scripts, custom tools, etc.): + +```typescript +import { analyzeModule } from 'oxlint-plugin-complexity/analyze'; + +const result = analyzeModule(code, 'module.ts'); + +console.log(result.moduleComplexity); // 0-100 (higher = more complex) +console.log(result.complexityDecomposition); // { effortTerm, cyclomaticTerm, locTerm, mainContributor } +console.log(result.functions); // per-function metrics +console.log(result.cyclomatic.sum); // aggregate cyclomatic +console.log(result.halstead.effort); // module-wide Halstead effort +``` + +Returns `ModuleAnalysisResult` with per-function cyclomatic, cognitive, and Halstead metrics, module-wide aggregates, module complexity score, and complexity decomposition. + --- ## Migration from v0.x diff --git a/package.json b/package.json index b7ea98f..edff665 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "oxlint-plugin-complexity", "version": "2.0.1", - "description": "Cyclomatic and cognitive complexity rules for oxlint", + "description": "Cyclomatic, cognitive, and Halstead complexity rules for oxlint with module-level analysis and Maintainability Index", "keywords": [ "oxlint", "oxc", @@ -10,6 +10,8 @@ "complexity", "cyclomatic", "cognitive", + "halstead", + "maintainability-index", "code-quality" ], "author": "itaymendel", @@ -32,6 +34,10 @@ ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" + }, + "./analyze": { + "import": "./dist/analyze.js", + "types": "./dist/analyze.d.ts" } }, "files": [ @@ -61,7 +67,17 @@ "vitest": "^4.0.17" }, "peerDependencies": { - "@oxlint/plugins": ">=1.43.0" + "@oxlint/plugins": ">=1.43.0", + "oxc-parser": ">=0.60.0", + "estree-walker": ">=3.0.0" + }, + "peerDependenciesMeta": { + "oxc-parser": { + "optional": true + }, + "estree-walker": { + "optional": true + } }, "engines": { "node": ">=20.0.0" diff --git a/src/analyze.ts b/src/analyze.ts new file mode 100644 index 0000000..d22f666 --- /dev/null +++ b/src/analyze.ts @@ -0,0 +1,119 @@ +import { parseSync } from 'oxc-parser'; +import { walk } from 'estree-walker'; +import type { Node as EstreeWalkerNode } from 'estree-walker'; +import type { Context, ESTreeNode } from './types.js'; +import { + createModuleAnalysisVisitor, + type ModuleAnalysisResult, + type ModuleComplexityOptions, +} from './module/visitor.js'; + +function createLineOffsetTable(code: string): number[] { + const lineOffsets: number[] = [0]; + for (let i = 0; i < code.length; i++) { + if (code[i] === '\n') { + lineOffsets.push(i + 1); + } + } + return lineOffsets; +} + +function offsetToLineCol(offset: number, lineOffsets: number[]): { line: number; column: number } { + let lo = 0; + let hi = lineOffsets.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >>> 1; + if (lineOffsets[mid] <= offset) lo = mid; + else hi = mid - 1; + } + return { line: lo + 1, column: offset - lineOffsets[lo] }; +} + +type VisitorHandlerMap = Record void) | undefined>; + +/** Single-pass AST walk: adds parent/loc references and dispatches visitor handlers. */ +function walkAndDispatch(ast: ESTreeNode, code: string, visitor: VisitorHandlerMap): void { + const lineOffsets = createLineOffsetTable(code); + + walk(ast as EstreeWalkerNode, { + enter(node, parent) { + const esNode = node as unknown as ESTreeNode; + const raw = node as unknown as { start?: number; end?: number }; + + if (typeof raw.start === 'number' && typeof raw.end === 'number') { + Object.defineProperty(esNode, 'loc', { + value: { + start: offsetToLineCol(raw.start, lineOffsets), + end: offsetToLineCol(raw.end, lineOffsets), + }, + writable: true, + enumerable: false, + configurable: true, + }); + } + + Object.defineProperty(esNode, 'parent', { + value: parent as unknown as ESTreeNode, + writable: true, + enumerable: false, + configurable: true, + }); + + visitor[esNode.type]?.(esNode); + visitor['*']?.(esNode); + }, + leave(node) { + const esNode = node as unknown as ESTreeNode; + visitor[`${esNode.type}:exit`]?.(esNode); + visitor['*:exit']?.(esNode); + }, + }); +} + +function createLibraryContext(code: string): Context { + return { + sourceCode: { + text: code, + getText: () => code, + scopeManager: null, + getScope: () => null, + }, + options: [], + report: () => {}, + } as unknown as Context; +} + +/** Standalone module complexity analysis (no linting context required). */ +export function analyzeModule( + code: string, + filename: string = 'module.js', + options?: ModuleComplexityOptions +): ModuleAnalysisResult { + const { program, errors } = parseSync(filename, code); + + if (errors.length > 0) { + throw new Error( + `Parse errors in "${filename}": ${errors.map((e: { message: string }) => e.message).join(', ')}` + ); + } + + const ast = program as unknown as ESTreeNode; + let result: ModuleAnalysisResult | undefined; + + const visitor = createModuleAnalysisVisitor( + createLibraryContext(code), + (r) => { + result = r; + }, + undefined, + options + ); + + walkAndDispatch(ast, code, visitor as VisitorHandlerMap); + + if (!result) { + throw new Error('Module analysis did not produce a result'); + } + + return result; +} diff --git a/src/index.ts b/src/index.ts index acb2c03..0f158da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,6 @@ import { definePlugin } from '@oxlint/plugins'; import { complexity } from './rules/complexity.js'; -// Re-export types for library users export type { Plugin, Rule, @@ -14,22 +13,17 @@ export type { MaxCognitiveOptions, } from './types.js'; -// Re-export visitor factory for advanced usage export { createComplexityVisitor } from './visitor.js'; export type { VisitorContext } from './visitor.js'; -// Re-export calculators for programmatic use export { createCyclomaticVisitor } from './cyclomatic.js'; export { createCognitiveVisitor } from './cognitive/visitor.js'; -// Re-export combined visitor for advanced usage export { createCombinedComplexityVisitor } from './combined-visitor.js'; export type { CombinedComplexityResult } from './combined-visitor.js'; -// Re-export utilities export { getFunctionName, createComplexityPoint, summarizeComplexity } from './utils.js'; -// Re-export extraction analysis export type { ExtractionSuggestion, ExtractionOptions, @@ -46,10 +40,23 @@ export { formatExtractionSuggestions, } from './extraction/index.js'; +export type { HalsteadMetrics, HalsteadCounts } from './module/halstead.js'; +export { calculateHalsteadMetrics, createHalsteadCounts } from './module/halstead.js'; + +export type { + FunctionMetrics, + AggregateComplexity, + MIDecomposition, + ModuleAnalysisResult, + ModuleComplexityOptions, +} from './module/visitor.js'; +export { calculateModuleComplexity, createModuleAnalysisVisitor } from './module/visitor.js'; + /** * oxlint-plugin-complexity * - * Provides cyclomatic and cognitive complexity rules for oxlint. + * Provides cyclomatic and cognitive complexity rules for oxlint, + * plus module-level analysis with Halstead metrics and module complexity scoring. * * Rules: * - complexity/complexity: Enforce both metrics in one pass diff --git a/src/module/halstead-visitor.ts b/src/module/halstead-visitor.ts new file mode 100644 index 0000000..cdabbf8 --- /dev/null +++ b/src/module/halstead-visitor.ts @@ -0,0 +1,227 @@ +/** + * Halstead operator/operand classification for JavaScript/TypeScript AST nodes. + * Based on escomplex's es5.js (MIT), extended for modern JS/TS. + */ +import type { ESTreeNode } from '../types.js'; +import type { HalsteadCounts } from './halstead.js'; +import { createHalsteadCounts, incrementCount } from './halstead.js'; + +export interface HalsteadVisitorCallbacks { + onFunctionEnter?: () => void; + onFunctionExit?: (counts: HalsteadCounts) => void; +} + +function getNodeOperator(node: ESTreeNode): string { + return (node as unknown as { operator: string }).operator; +} + +/** Caller must pre-check that parent type is MemberExpression, Property, or MethodDefinition. */ +function isNonComputedKeyOf(node: ESTreeNode): boolean { + const parent = node.parent; + if (!parent) return false; + const keyed = parent as unknown as { key?: ESTreeNode; property?: ESTreeNode; computed: boolean }; + if (keyed.computed) return false; + return keyed.key === node || keyed.property === node; +} + +const DECLARATION_PARENTS = new Map([ + ['VariableDeclarator', 'id'], + ['ImportSpecifier', 'local'], + ['ImportDefaultSpecifier', 'local'], + ['ImportNamespaceSpecifier', 'local'], + ['CatchClause', 'param'], + ['LabeledStatement', 'label'], +]); + +/** Skip identifiers at declaration sites (they are not operands). */ +// eslint-disable-next-line complexity/complexity -- Type-dispatch across many AST parent types +function isDeclarationIdentifier(node: ESTreeNode): boolean { + const parent = node.parent; + if (!parent) return false; + + const n = node as unknown as { name: string }; + + if (parent.type === 'FunctionDeclaration' || parent.type === 'FunctionExpression') { + const fn = parent as unknown as { id?: { name: string }; params: ESTreeNode[] }; + return fn.id?.name === n.name || (fn.params?.includes(node) ?? false); + } + + if (parent.type === 'ArrowFunctionExpression') { + const arrow = parent as unknown as { params: ESTreeNode[] }; + return arrow.params?.includes(node) ?? false; + } + + if (parent.type === 'ClassDeclaration' || parent.type === 'ClassExpression') { + const cls = parent as unknown as { id?: { name: string } }; + return cls.id?.name === n.name; + } + + const prop = DECLARATION_PARENTS.get(parent.type); + if (prop) { + return (parent as unknown as Record)[prop] === node; + } + + return false; +} + +// eslint-disable-next-line complexity/complexity -- Visitor factory pattern requires many nested handlers +export function createHalsteadVisitorHandlers(callbacks?: HalsteadVisitorCallbacks): { + handlers: Record void>; + moduleCounts: HalsteadCounts; +} { + const moduleCounts = createHalsteadCounts(); + const scopeStack: HalsteadCounts[] = []; + + function currentScope(): HalsteadCounts | undefined { + return scopeStack[scopeStack.length - 1]; + } + + function addCount(kind: 'operators' | 'operands', name: string): void { + incrementCount(moduleCounts[kind], name); + const scope = currentScope(); + if (scope) { + incrementCount(scope[kind], name); + } + } + + function addOperator(name: string): void { + addCount('operators', name); + } + + function addOperand(name: string): void { + addCount('operands', name); + } + + function enterFunction(): void { + scopeStack.push(createHalsteadCounts()); + callbacks?.onFunctionEnter?.(); + } + + function exitFunction(): void { + const scope = scopeStack.pop(); + if (scope) { + callbacks?.onFunctionExit?.(scope); + } + } + + function operatorHandler(name: string): () => void { + return () => addOperator(name); + } + + function nodeOperatorHandler(): (node: ESTreeNode) => void { + return (node: ESTreeNode) => addOperator(getNodeOperator(node)); + } + + function handleNamedFunction(node: ESTreeNode): void { + enterFunction(); + addOperator('function'); + const fn = node as unknown as { id?: { name: string } }; + if (fn.id?.name) addOperand(fn.id.name); + } + + const handlers: Record void> = { + FunctionDeclaration: handleNamedFunction, + 'FunctionDeclaration:exit': () => exitFunction(), + FunctionExpression: handleNamedFunction, + 'FunctionExpression:exit': () => exitFunction(), + ArrowFunctionExpression() { + enterFunction(); + addOperator('=>'); + }, + 'ArrowFunctionExpression:exit': () => exitFunction(), + + IfStatement: operatorHandler('if'), + 'IfStatement:exit'(node: ESTreeNode) { + const ifNode = node as unknown as { alternate?: ESTreeNode }; + if (ifNode.alternate) addOperator('else'); + }, + ForStatement: operatorHandler('for'), + ForInStatement: operatorHandler('forin'), + ForOfStatement: operatorHandler('forof'), + WhileStatement: operatorHandler('while'), + DoWhileStatement: operatorHandler('dowhile'), + SwitchStatement: operatorHandler('switch'), + SwitchCase(node: ESTreeNode) { + const sc = node as unknown as { test: unknown }; + addOperator(sc.test !== null ? 'case' : 'default'); + }, + CatchClause: operatorHandler('catch'), + BreakStatement: operatorHandler('break'), + ContinueStatement: operatorHandler('continue'), + ReturnStatement: operatorHandler('return'), + ThrowStatement: operatorHandler('throw'), + + NewExpression: operatorHandler('new'), + ClassDeclaration: operatorHandler('class'), + ClassExpression: operatorHandler('class'), + ImportDeclaration: operatorHandler('import'), + ExportNamedDeclaration: operatorHandler('export'), + ExportDefaultDeclaration: operatorHandler('export'), + AwaitExpression: operatorHandler('await'), + YieldExpression: operatorHandler('yield'), + + BinaryExpression: nodeOperatorHandler(), + LogicalExpression: nodeOperatorHandler(), + AssignmentExpression: nodeOperatorHandler(), + UnaryExpression: nodeOperatorHandler(), + UpdateExpression: nodeOperatorHandler(), + + CallExpression: operatorHandler('()'), + ArrayExpression: operatorHandler('[]'), + ObjectExpression: operatorHandler('{}'), + + MemberExpression(node: ESTreeNode) { + const member = node as unknown as { optional?: boolean; computed: boolean }; + addOperator(member.computed ? '[]' : member.optional ? '?.' : '.'); + }, + + Property: operatorHandler(':'), + ConditionalExpression: operatorHandler('?:'), + + VariableDeclaration(node: ESTreeNode) { + const decl = node as unknown as { kind: string }; + addOperator(decl.kind); + }, + VariableDeclarator(node: ESTreeNode) { + const declarator = node as unknown as { init: unknown; id: ESTreeNode }; + if (declarator.init !== null && declarator.init !== undefined) { + addOperator('='); + } + const id = declarator.id as unknown as { name?: string; type: string }; + if (id.type === 'Identifier' && id.name) { + addOperand(id.name); + } + }, + + TemplateLiteral: operatorHandler('`'), + SpreadElement: operatorHandler('...'), + RestElement: operatorHandler('...'), + + Identifier(node: ESTreeNode) { + if (isDeclarationIdentifier(node)) return; + const pt = node.parent?.type; + if (pt === 'MemberExpression' || pt === 'Property' || pt === 'MethodDefinition') { + if (isNonComputedKeyOf(node)) return; + } + addOperand((node as unknown as { name: string }).name); + }, + + Literal(node: ESTreeNode) { + const lit = node as unknown as { value: unknown; raw?: string }; + addOperand(lit.raw ?? String(lit.value)); + }, + + ThisExpression() { + addOperand('this'); + }, + + TemplateElement(node: ESTreeNode) { + const elem = node as unknown as { value: { raw: string } }; + if (elem.value.raw) { + addOperand(elem.value.raw); + } + }, + }; + + return { handlers, moduleCounts }; +} diff --git a/src/module/halstead.ts b/src/module/halstead.ts new file mode 100644 index 0000000..b11fc06 --- /dev/null +++ b/src/module/halstead.ts @@ -0,0 +1,72 @@ +export interface HalsteadCounts { + operators: Map; + operands: Map; +} + +/** + * Halstead metrics derived from operator/operand counts. + * + * Formulas (Halstead, 1977): + * length = N1 + N2 + * vocabulary = n1 + n2 + * volume = length * log2(vocabulary) + * difficulty = (n1 / 2) * (N2 / n2) + * effort = difficulty * volume + * bugs = volume / 3000 + * time = effort / 18 (seconds) + */ +export interface HalsteadMetrics { + n1: number; + n2: number; + N1: number; + N2: number; + length: number; + vocabulary: number; + volume: number; + difficulty: number; + effort: number; + bugs: number; + time: number; +} + +export function incrementCount(map: Map, key: string, amount: number = 1): void { + map.set(key, (map.get(key) ?? 0) + amount); +} + +export function calculateHalsteadMetrics(counts: HalsteadCounts): HalsteadMetrics { + const n1 = counts.operators.size; + const n2 = counts.operands.size; + const N1 = sumValues(counts.operators); + const N2 = sumValues(counts.operands); + + const length = N1 + N2; + const vocabulary = n1 + n2; + const volume = vocabulary > 0 ? length * Math.log2(vocabulary) : 0; + const difficulty = n2 > 0 ? (n1 / 2) * (N2 / n2) : 0; + const effort = difficulty * volume; + const bugs = volume / 3000; + const time = effort / 18; + + return { n1, n2, N1, N2, length, vocabulary, volume, difficulty, effort, bugs, time }; +} + +export function createHalsteadCounts(): HalsteadCounts { + return { operators: new Map(), operands: new Map() }; +} + +export function mergeHalsteadCounts(target: HalsteadCounts, source: HalsteadCounts): void { + for (const [key, count] of source.operators) { + incrementCount(target.operators, key, count); + } + for (const [key, count] of source.operands) { + incrementCount(target.operands, key, count); + } +} + +function sumValues(map: Map): number { + let total = 0; + for (const count of map.values()) { + total += count; + } + return total; +} diff --git a/src/module/visitor.ts b/src/module/visitor.ts new file mode 100644 index 0000000..45b6cc8 --- /dev/null +++ b/src/module/visitor.ts @@ -0,0 +1,276 @@ +import type { Context, ESTreeNode, FunctionNode, Visitor, ComplexityPoint } from '../types.js'; +import { getFunctionName } from '../utils.js'; +import { + createCombinedComplexityVisitor, + type CombinedComplexityResult, +} from '../combined-visitor.js'; +import { createHalsteadVisitorHandlers } from './halstead-visitor.js'; +import { + calculateHalsteadMetrics, + createHalsteadCounts, + type HalsteadCounts, + type HalsteadMetrics, +} from './halstead.js'; + +export interface FunctionMetrics { + name: string; + lineStart: number; + lineEnd: number; + loc: number; + cyclomatic: number; + cognitive: number; + halstead: HalsteadMetrics; + cyclomaticPoints: ComplexityPoint[]; + cognitivePoints: ComplexityPoint[]; +} + +export interface AggregateComplexity { + sum: number; + max: number; + average: number; + countAboveThreshold: number; +} + +export interface MIDecomposition { + /** 3.42 * ln(avgEffort) — how much expression complexity lowers MI */ + effortTerm: number; + /** 0.23 * ln(avgCyclomatic) — how much branching lowers MI */ + cyclomaticTerm: number; + /** 16.2 * ln(avgLOC) — how much code length lowers MI */ + locTerm: number; + /** Which factor contributes the most to lowering MI */ + mainContributor: 'effort' | 'cyclomatic' | 'loc'; +} + +export interface ModuleAnalysisResult { + functions: FunctionMetrics[]; + halstead: HalsteadMetrics; + cyclomatic: AggregateComplexity; + cognitive: AggregateComplexity; + /** Module complexity score (0-100, higher = more complex). Inverted Maintainability Index. */ + moduleComplexity: number; + complexityDecomposition: MIDecomposition; + totalLOC: number; + functionCount: number; +} + +export interface ModuleComplexityOptions { + moduleComplexity?: number; + maxCyclomaticSum?: number; + maxCognitiveSum?: number; + cyclomaticThreshold?: number; + cognitiveThreshold?: number; +} + +/** + * Module complexity = 100 - scaled Maintainability Index. + * + * MI = 171 - 3.42 * ln(avgEffort) - 0.23 * ln(avgCyclomatic) - 16.2 * ln(avgLOC) + * Scaled MI = max(0, MI * 100 / 171) + * Module complexity = 100 - Scaled MI (0 = trivial, 100 = maximally complex) + */ +export function calculateModuleComplexity( + avgEffort: number, + avgCyclomatic: number, + avgLOC: number +): { score: number; decomposition: MIDecomposition } { + const lnEffort = Math.log(Math.max(avgEffort, 1)); + const lnCyclomatic = Math.log(Math.max(avgCyclomatic, 1)); + const lnLOC = Math.log(Math.max(avgLOC, 1)); + + const effortTerm = 3.42 * lnEffort; + const cyclomaticTerm = 0.23 * lnCyclomatic; + const locTerm = 16.2 * lnLOC; + + const rawMI = 171 - effortTerm - cyclomaticTerm - locTerm; + const scaledMI = Math.max(0, (rawMI * 100) / 171); + const score = Math.min(100, 100 - scaledMI); + + let mainContributor: MIDecomposition['mainContributor'] = 'effort'; + if (locTerm >= effortTerm && locTerm >= cyclomaticTerm) { + mainContributor = 'loc'; + } else if (cyclomaticTerm >= effortTerm) { + mainContributor = 'cyclomatic'; + } + + return { + score, + decomposition: { effortTerm, cyclomaticTerm, locTerm, mainContributor }, + }; +} + +/** If no functions, treats the whole module as one function. */ +export function computeMIInputs( + functionCount: number, + totalCyclomatic: number, + totalLOC: number, + moduleHalstead: HalsteadMetrics, + functionEffortSum: number +): { avgEffort: number; avgCyclomatic: number; avgLOC: number } { + if (functionCount === 0) { + return { + avgEffort: moduleHalstead.effort, + avgCyclomatic: 1, + avgLOC: Math.max(totalLOC, 1), + }; + } + + return { + avgEffort: functionEffortSum / functionCount, + avgCyclomatic: totalCyclomatic / functionCount, + avgLOC: totalLOC / functionCount, + }; +} + +interface PendingFunction { + node: ESTreeNode; + name: string; + result: CombinedComplexityResult; + halsteadCounts: HalsteadCounts; +} + +/** + * Creates a visitor that computes cyclomatic + cognitive + Halstead per function, + * then aggregates into module-level metrics with Maintainability Index on Program:exit. + */ +export function createModuleAnalysisVisitor( + context: Context, + onModuleAnalyzed: (result: ModuleAnalysisResult) => void, + onFunctionAnalyzed?: (result: CombinedComplexityResult, node: ESTreeNode) => void, + options?: ModuleComplexityOptions +): Visitor { + const opts = options ?? {}; + const cyclomaticThreshold = opts.cyclomaticThreshold ?? 10; + const cognitiveThreshold = opts.cognitiveThreshold ?? 10; + + const pendingFunctions: PendingFunction[] = []; + + const combinedVisitor = createCombinedComplexityVisitor( + context, + (result: CombinedComplexityResult, node: ESTreeNode) => { + const funcNode = node as FunctionNode; + const name = getFunctionName(funcNode, funcNode.parent); + pendingFunctions.push({ node, name, result, halsteadCounts: createHalsteadCounts() }); + onFunctionAnalyzed?.(result, node); + } + ); + + const { handlers: halsteadHandlers, moduleCounts } = createHalsteadVisitorHandlers({ + onFunctionExit(counts) { + const last = pendingFunctions[pendingFunctions.length - 1]; + if (last) last.halsteadCounts = counts; + }, + }); + + // Merge both visitors: for overlapping keys, call both handlers + const combinedHandlerMap = combinedVisitor as Record< + string, + ((node: ESTreeNode) => void) | undefined + >; + const allKeys = new Set([...Object.keys(combinedVisitor), ...Object.keys(halsteadHandlers)]); + const mergedVisitor: Record void> = {}; + + for (const key of allKeys) { + const a = combinedHandlerMap[key]; + const b = halsteadHandlers[key]; + const handler = + a && b + ? (node: ESTreeNode) => { + a(node); + b(node); + } + : (a ?? b); + if (handler) mergedVisitor[key] = handler; + } + + // Reset per-file state when entering a new Program (prevents accumulation across files) + const originalProgramEnter = mergedVisitor['Program']; + mergedVisitor['Program'] = (node: ESTreeNode) => { + pendingFunctions.length = 0; + moduleCounts.operators.clear(); + moduleCounts.operands.clear(); + originalProgramEnter?.(node); + }; + + const originalProgramExit = mergedVisitor['Program:exit']; + mergedVisitor['Program:exit'] = (node: ESTreeNode) => { + originalProgramExit?.(node); + + const totalLOC = node.loc ? node.loc.end.line : 0; + + const functions: FunctionMetrics[] = pendingFunctions.map((pf) => { + const halstead = calculateHalsteadMetrics(pf.halsteadCounts); + + const lineStart = pf.node.loc?.start.line ?? 0; + const lineEnd = pf.node.loc?.end.line ?? 0; + const loc = Math.max(lineEnd - lineStart + 1, 1); + + return { + name: pf.name, + lineStart, + lineEnd, + loc, + cyclomatic: pf.result.cyclomatic, + cognitive: pf.result.cognitive, + halstead, + cyclomaticPoints: pf.result.cyclomaticPoints, + cognitivePoints: pf.result.cognitivePoints, + }; + }); + + const moduleHalstead = calculateHalsteadMetrics(moduleCounts); + + const cyclomatic = aggregateComplexity( + functions.map((f) => f.cyclomatic), + cyclomaticThreshold + ); + const cognitive = aggregateComplexity( + functions.map((f) => f.cognitive), + cognitiveThreshold + ); + + const functionEffortSum = functions.reduce((sum, f) => sum + f.halstead.effort, 0); + const miInputs = computeMIInputs( + functions.length, + cyclomatic.sum, + totalLOC, + moduleHalstead, + functionEffortSum + ); + const mc = calculateModuleComplexity( + miInputs.avgEffort, + miInputs.avgCyclomatic, + miInputs.avgLOC + ); + + onModuleAnalyzed({ + functions, + halstead: moduleHalstead, + cyclomatic, + cognitive, + moduleComplexity: mc.score, + complexityDecomposition: mc.decomposition, + totalLOC, + functionCount: functions.length, + }); + }; + + return mergedVisitor as Visitor; +} + +function aggregateComplexity(values: number[], threshold: number): AggregateComplexity { + if (values.length === 0) { + return { sum: 0, max: 0, average: 0, countAboveThreshold: 0 }; + } + + let sum = 0; + let max = 0; + let countAboveThreshold = 0; + for (const v of values) { + sum += v; + if (v > max) max = v; + if (v > threshold) countAboveThreshold++; + } + + return { sum, max, average: sum / values.length, countAboveThreshold }; +} diff --git a/src/rules/complexity.ts b/src/rules/complexity.ts index f5f649c..8f6706a 100644 --- a/src/rules/complexity.ts +++ b/src/rules/complexity.ts @@ -8,27 +8,78 @@ import type { ESTreeNode, } from '../types.js'; import { getFunctionName, summarizeComplexity, formatBreakdown } from '../utils.js'; -import { - createCombinedComplexityVisitor, - type CombinedComplexityResult, -} from '../combined-visitor.js'; +import type { CombinedComplexityResult } from '../combined-visitor.js'; +import { createModuleAnalysisVisitor, type ModuleAnalysisResult } from '../module/visitor.js'; import { normalizeCognitiveCategory, parseExtractionOptions, getExtractionOutput, EXTRACTION_SCHEMA_PROPERTIES, + MODULE_SCHEMA_PROPERTIES, + parseModuleOptions, + type ParsedModuleOptions, + type ParsedExtractionOptions, + type ModuleSchemaOptions, } from './shared.js'; const DEFAULT_CYCLOMATIC = 20; const DEFAULT_COGNITIVE = 15; const DEFAULT_MIN_LINES = 10; -interface CombinedComplexityOptions extends Omit { +interface CombinedComplexityOptions extends Omit, ModuleSchemaOptions { cyclomatic?: number; cognitive?: number; minLines?: number; } +const CONTRIBUTOR_MESSAGES: Record = { + effort: 'Main contributor: complex expressions increase bug risk.', + cyclomatic: 'Main contributor: too many decision branches.', + loc: 'Main contributor: functions are too long.', +}; + +function buildComplexityScoreMessages( + result: ModuleAnalysisResult, + opts: ParsedModuleOptions +): string[] { + if (opts.moduleComplexity <= 0 || result.moduleComplexity <= opts.moduleComplexity) return []; + + const messages = [ + `Module is too complex (score: ${result.moduleComplexity.toFixed(1)}/100, maximum: ${opts.moduleComplexity}).`, + ]; + + if (result.halstead.bugs >= 0.1) { + messages.push(`Estimated bug risk: ~${result.halstead.bugs.toFixed(1)} defects.`); + } + + const readingMinutes = result.halstead.time / 60; + if (readingMinutes >= 1) { + messages.push(`Estimated reading time: ~${Math.round(readingMinutes)} min.`); + } + + messages.push(CONTRIBUTOR_MESSAGES[result.complexityDecomposition.mainContributor]); + + return messages; +} + +function buildAggregateMessages(result: ModuleAnalysisResult, opts: ParsedModuleOptions): string[] { + const messages: string[] = []; + + if (opts.maxCyclomaticSum > 0 && result.cyclomatic.sum > opts.maxCyclomaticSum) { + messages.push( + `Module has too many decision paths (total: ${result.cyclomatic.sum}, maximum: ${opts.maxCyclomaticSum}).` + ); + } + + if (opts.maxCognitiveSum > 0 && result.cognitive.sum > opts.maxCognitiveSum) { + messages.push( + `Module is too hard to read (cognitive total: ${result.cognitive.sum}, maximum: ${opts.maxCognitiveSum}).` + ); + } + + return messages; +} + /** * Enforce maximum cyclomatic and cognitive complexity (RECOMMENDED). * @@ -39,6 +90,11 @@ interface CombinedComplexityOptions extends Omit { * - Cyclomatic: 20 * - Cognitive: 15 * - minLines: 10 (skip functions with fewer lines for better performance) + * + * When `moduleComplexity` is present, also performs module-level analysis: + * - Halstead metrics + * - Module complexity score (inverted Maintainability Index) + * - Aggregate complexity scores */ export const complexity: Rule = defineRule({ meta: { @@ -67,6 +123,7 @@ export const complexity: Rule = defineRule({ minimum: 0, description: 'Minimum lines to analyze (default: 10, 0 = analyze all)', }, + ...MODULE_SCHEMA_PROPERTIES, ...EXTRACTION_SCHEMA_PROPERTIES, }, additionalProperties: false, @@ -75,10 +132,12 @@ export const complexity: Rule = defineRule({ }, createOnce(context: Context) { + // Options are read in before() — these are mutable defaults let maxCyclomatic = DEFAULT_CYCLOMATIC; let maxCognitive = DEFAULT_COGNITIVE; let minLines = DEFAULT_MIN_LINES; - let parsed = parseExtractionOptions({}); + let parsed: ParsedExtractionOptions = parseExtractionOptions({}); + let moduleOpts: ParsedModuleOptions = parseModuleOptions(); function isBelowMinLines(node: ESTreeNode): boolean { if (minLines <= 0 || !node.loc) return false; @@ -136,6 +195,22 @@ export const complexity: Rule = defineRule({ reportCognitive(node, functionName, result); } + function reportModule(result: ModuleAnalysisResult): void { + if (!moduleOpts.enabled) return; + + const messages = [ + ...buildComplexityScoreMessages(result, moduleOpts), + ...buildAggregateMessages(result, moduleOpts), + ]; + + if (messages.length === 0) return; + + context.report({ + loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 0 } }, + message: messages.join(' '), + }); + } + return { before() { const options = (context.options[0] ?? {}) as CombinedComplexityOptions; @@ -143,9 +218,13 @@ export const complexity: Rule = defineRule({ maxCognitive = options.cognitive ?? DEFAULT_COGNITIVE; minLines = options.minLines ?? DEFAULT_MIN_LINES; parsed = parseExtractionOptions(options); + moduleOpts = parseModuleOptions(options); }, - ...createCombinedComplexityVisitor(context, handleComplexityResult), + // Always use the module analysis visitor — it's a superset of the combined visitor. + // When module analysis is disabled, reportModule() is a no-op, so the only overhead + // is Halstead counting. This avoids the need to conditionally create visitors. + ...createModuleAnalysisVisitor(context, reportModule, handleComplexityResult), } as VisitorWithHooks; }, }); diff --git a/src/rules/shared.ts b/src/rules/shared.ts index 7a19d91..53c0e7d 100644 --- a/src/rules/shared.ts +++ b/src/rules/shared.ts @@ -50,6 +50,58 @@ export const EXTRACTION_SCHEMA_PROPERTIES = { }, } as const; +/** JSON Schema properties for module-level analysis (flat, top-level). */ +export const MODULE_SCHEMA_PROPERTIES = { + moduleComplexity: { + type: 'integer', + minimum: 0, + maximum: 100, + description: + 'Enable module analysis and set maximum module complexity score (0-100). Omit to disable.', + }, + maxCyclomaticSum: { + type: 'integer', + minimum: 0, + description: 'Maximum aggregate cyclomatic complexity for the module. Default: 0 (disabled)', + }, + maxCognitiveSum: { + type: 'integer', + minimum: 0, + description: 'Maximum aggregate cognitive complexity for the module. Default: 0 (disabled)', + }, +} as const; + +export interface ParsedModuleOptions { + enabled: boolean; + moduleComplexity: number; + maxCyclomaticSum: number; + maxCognitiveSum: number; +} + +export interface ModuleSchemaOptions { + moduleComplexity?: number; + maxCyclomaticSum?: number; + maxCognitiveSum?: number; +} + +export function parseModuleOptions(options?: ModuleSchemaOptions): ParsedModuleOptions { + if (options?.moduleComplexity === undefined) { + return { + enabled: false, + moduleComplexity: 80, + maxCyclomaticSum: 0, + maxCognitiveSum: 0, + }; + } + + return { + enabled: true, + moduleComplexity: options.moduleComplexity, + maxCyclomaticSum: options.maxCyclomaticSum ?? 0, + maxCognitiveSum: options.maxCognitiveSum ?? 0, + }; +} + type ExtractionSchemaOptions = Omit; export interface ParsedExtractionOptions { diff --git a/tests/analyze.test.ts b/tests/analyze.test.ts new file mode 100644 index 0000000..d04c9b0 --- /dev/null +++ b/tests/analyze.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect } from 'vitest'; +import { analyzeModule } from '#src/analyze.js'; + +describe('analyzeModule', () => { + it('should analyze a simple module', () => { + const code = ` +function add(a, b) { + return a + b; +} +`; + const result = analyzeModule(code); + + expect(result.functionCount).toBe(1); + expect(result.functions[0].name).toBe('add'); + expect(result.functions[0].cyclomatic).toBe(1); + expect(result.functions[0].cognitive).toBe(0); + expect(result.halstead.volume).toBeGreaterThan(0); + expect(result.moduleComplexity).toBeGreaterThanOrEqual(0); + expect(result.moduleComplexity).toBeLessThan(100); + }); + + it('should analyze TypeScript code', () => { + const code = ` +interface Config { + threshold: number; +} + +function validate(config: Config): boolean { + if (config.threshold < 0) { + throw new Error("Invalid threshold"); + } + return config.threshold > 0; +} +`; + const result = analyzeModule(code, 'module.ts'); + + expect(result.functionCount).toBe(1); + expect(result.functions[0].name).toBe('validate'); + expect(result.functions[0].cyclomatic).toBe(2); // 1 base + 1 if + }); + + it('should throw on parse errors', () => { + expect(() => analyzeModule('function { invalid', 'test.js')).toThrow('Parse errors'); + }); + + it('should handle empty module', () => { + const result = analyzeModule('// nothing here\nconst x = 1;\n'); + + expect(result.functionCount).toBe(0); + expect(result.cyclomatic.sum).toBe(0); + expect(result.cognitive.sum).toBe(0); + }); + + it('should handle multiple functions', () => { + const code = ` +function foo() { return 1; } +function bar(x) { + if (x > 0) return x; + return -x; +} +function baz(a, b) { + return a && b ? a + b : 0; +} +`; + const result = analyzeModule(code); + + expect(result.functionCount).toBe(3); + expect(result.functions.map((f) => f.name)).toEqual(['foo', 'bar', 'baz']); + expect(result.cyclomatic.sum).toBeGreaterThan(3); // each has at least 1 + expect(result.halstead.n1).toBeGreaterThan(0); + expect(result.halstead.n2).toBeGreaterThan(0); + }); + + it('should provide per-function Halstead metrics', () => { + const code = ` +function simple() { return 1; } +function complex(x, y, z) { + if (x > 0 && y < 10) { + for (let i = 0; i < z; i++) { + if (i % 2 === 0) { + console.log(i); + } + } + } + return x + y + z; +} +`; + const result = analyzeModule(code); + + expect(result.functions[0].halstead.volume).toBeGreaterThan(0); + expect(result.functions[1].halstead.volume).toBeGreaterThan( + result.functions[0].halstead.volume + ); + expect(result.functions[1].halstead.effort).toBeGreaterThan( + result.functions[0].halstead.effort + ); + }); + + it('should compute aggregate complexity', () => { + const code = ` +function a(x) { if (x) { if (x > 1) {} } return x; } +function b(x) { if (x) {} return x; } +function c() { return 1; } +`; + const result = analyzeModule(code, 'test.js', { cyclomaticThreshold: 2 }); + + expect(result.cyclomatic.max).toBe(3); // a has 1+2=3 + expect(result.cyclomatic.average).toBeCloseTo(result.cyclomatic.sum / 3); + // a has cyclomatic 3, above threshold 2 + expect(result.cyclomatic.countAboveThreshold).toBe(1); + }); + + it('should compute MI that distinguishes simple vs complex', () => { + const simple = analyzeModule('function id(x) { return x; }'); + const complex = analyzeModule(` +function process(data) { + if (data.type === 'a') { + for (let i = 0; i < data.items.length; i++) { + if (data.items[i].valid) { + switch (data.items[i].category) { + case 'x': return 1; + case 'y': return 2; + case 'z': return 3; + default: return 0; + } + } + } + } else if (data.type === 'b') { + while (data.hasNext()) { + try { + return transform(data.current()); + } catch (e) { + return fallback(e); + } + } + } + return null; +} +`); + + expect(simple.moduleComplexity).toBeLessThan(complex.moduleComplexity); + }); + + it('should accept custom options', () => { + const code = ` +function a(x) { if (x > 5) return true; return false; } +function b(x) { return x; } +`; + const result = analyzeModule(code, 'test.js', { cyclomaticThreshold: 1 }); + + // Both functions have cyclomatic >= 1 + expect(result.cyclomatic.countAboveThreshold).toBe(1); // a has 2, b has 1; only a is > 1 + }); + + it('should track LOC information', () => { + const code = `function foo() {\n return 1;\n}\n`; + const result = analyzeModule(code); + + expect(result.totalLOC).toBeGreaterThan(0); + expect(result.functions[0].loc).toBeGreaterThan(0); + expect(result.functions[0].lineStart).toBeGreaterThan(0); + expect(result.functions[0].lineEnd).toBeGreaterThanOrEqual(result.functions[0].lineStart); + }); + + it('should export result matching ModuleAnalysisResult interface', () => { + const result = analyzeModule('function f() {}'); + + // Verify all expected fields exist + expect(result).toHaveProperty('functions'); + expect(result).toHaveProperty('halstead'); + expect(result).toHaveProperty('cyclomatic'); + expect(result).toHaveProperty('cognitive'); + expect(result).toHaveProperty('moduleComplexity'); + expect(result).toHaveProperty('totalLOC'); + expect(result).toHaveProperty('functionCount'); + + // Verify Halstead fields + expect(result.halstead).toHaveProperty('n1'); + expect(result.halstead).toHaveProperty('n2'); + expect(result.halstead).toHaveProperty('volume'); + expect(result.halstead).toHaveProperty('difficulty'); + expect(result.halstead).toHaveProperty('effort'); + expect(result.halstead).toHaveProperty('bugs'); + expect(result.halstead).toHaveProperty('time'); + }); +}); diff --git a/tests/fixtures/js/module-level.js b/tests/fixtures/js/module-level.js new file mode 100644 index 0000000..e4bdae3 --- /dev/null +++ b/tests/fixtures/js/module-level.js @@ -0,0 +1,38 @@ +// @complexity processData:cyclomatic=8,cognitive=16 validateInput:cyclomatic=4,cognitive=3 transform:cyclomatic=2,cognitive=1 + +function processData(data) { + if (data === null) { + return null; + } + + if (data.type === 'array') { + for (let i = 0; i < data.items.length; i++) { + if (data.items[i].active) { + if (data.items[i].value > 0) { + switch (data.items[i].priority) { + case 'high': + return data.items[i].value * 2; + case 'low': + return data.items[i].value; + } + } + } + } + } + + return 0; +} + +function validateInput(input) { + if (!input || typeof input !== 'object') { + return false; + } + if (!input.type) { + return false; + } + return true; +} + +function transform(value) { + return value > 0 ? value * 2 : value; +} diff --git a/tests/halstead.test.ts b/tests/halstead.test.ts new file mode 100644 index 0000000..68f4879 --- /dev/null +++ b/tests/halstead.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect } from 'vitest'; +import { parseSync } from 'oxc-parser'; +import { walkWithVisitor } from './utils/test-helpers.js'; +import type { ESTreeNode } from '#src/types.js'; +import { + calculateHalsteadMetrics, + createHalsteadCounts, + mergeHalsteadCounts, + type HalsteadCounts, +} from '#src/module/halstead.js'; +import { createHalsteadVisitorHandlers } from '#src/module/halstead-visitor.js'; + +function analyzeHalstead(code: string, filename = 'test.js') { + const { program } = parseSync(filename, code); + const { handlers, moduleCounts } = createHalsteadVisitorHandlers(); + const functionResults: HalsteadCounts[] = []; + + // Wrap handlers to capture function exits + const wrappedHandlers = { ...handlers }; + const origFuncDeclExit = handlers['FunctionDeclaration:exit']; + const origFuncExprExit = handlers['FunctionExpression:exit']; + const origArrowExit = handlers['ArrowFunctionExpression:exit']; + + // We need to collect function-level counts via callbacks + const { handlers: handlers2, moduleCounts: moduleCounts2 } = createHalsteadVisitorHandlers({ + onFunctionExit(counts) { + functionResults.push(counts); + }, + }); + + walkWithVisitor(program as unknown as ESTreeNode, handlers2, code); + + return { + moduleCounts: moduleCounts2, + moduleMetrics: calculateHalsteadMetrics(moduleCounts2), + functionResults, + }; +} + +describe('calculateHalsteadMetrics', () => { + it('should return zeroes for empty counts', () => { + const counts = createHalsteadCounts(); + const metrics = calculateHalsteadMetrics(counts); + + expect(metrics.n1).toBe(0); + expect(metrics.n2).toBe(0); + expect(metrics.N1).toBe(0); + expect(metrics.N2).toBe(0); + expect(metrics.length).toBe(0); + expect(metrics.vocabulary).toBe(0); + expect(metrics.volume).toBe(0); + expect(metrics.difficulty).toBe(0); + expect(metrics.effort).toBe(0); + expect(metrics.bugs).toBe(0); + expect(metrics.time).toBe(0); + }); + + it('should compute correct metrics for known counts', () => { + const counts = createHalsteadCounts(); + // 2 unique operators, 3 unique operands + counts.operators.set('+', 3); + counts.operators.set('=', 2); + counts.operands.set('a', 4); + counts.operands.set('b', 2); + counts.operands.set('1', 1); + + const m = calculateHalsteadMetrics(counts); + + expect(m.n1).toBe(2); + expect(m.n2).toBe(3); + expect(m.N1).toBe(5); // 3 + 2 + expect(m.N2).toBe(7); // 4 + 2 + 1 + expect(m.length).toBe(12); + expect(m.vocabulary).toBe(5); + expect(m.volume).toBeCloseTo(12 * Math.log2(5), 5); + expect(m.difficulty).toBeCloseTo((2 / 2) * (7 / 3), 5); + expect(m.effort).toBeCloseTo(m.difficulty * m.volume, 5); + expect(m.bugs).toBeCloseTo(m.volume / 3000, 5); + expect(m.time).toBeCloseTo(m.effort / 18, 5); + }); +}); + +describe('mergeHalsteadCounts', () => { + it('should merge source into target', () => { + const target = createHalsteadCounts(); + target.operators.set('+', 2); + target.operands.set('a', 1); + + const source = createHalsteadCounts(); + source.operators.set('+', 3); + source.operators.set('-', 1); + source.operands.set('b', 2); + + mergeHalsteadCounts(target, source); + + expect(target.operators.get('+')).toBe(5); + expect(target.operators.get('-')).toBe(1); + expect(target.operands.get('a')).toBe(1); + expect(target.operands.get('b')).toBe(2); + }); +}); + +describe('Halstead visitor', () => { + it('should classify a simple assignment', () => { + const { moduleMetrics, moduleCounts } = analyzeHalstead('var x = 1;'); + + // Operators: var, = + expect(moduleCounts.operators.has('var')).toBe(true); + expect(moduleCounts.operators.has('=')).toBe(true); + // Operands: x, 1 + expect(moduleCounts.operands.has('x')).toBe(true); + expect(moduleCounts.operands.has('1')).toBe(true); + + expect(moduleMetrics.n1).toBeGreaterThanOrEqual(2); + expect(moduleMetrics.n2).toBeGreaterThanOrEqual(2); + }); + + it('should classify binary expressions', () => { + const { moduleCounts } = analyzeHalstead('var z = a + b * c;'); + + expect(moduleCounts.operators.has('+')).toBe(true); + expect(moduleCounts.operators.has('*')).toBe(true); + expect(moduleCounts.operands.has('a')).toBe(true); + expect(moduleCounts.operands.has('b')).toBe(true); + expect(moduleCounts.operands.has('c')).toBe(true); + }); + + it('should classify control flow', () => { + const code = ` +function test(x) { + if (x > 0) { + return x; + } else { + return -x; + } +}`; + const { moduleCounts } = analyzeHalstead(code); + + expect(moduleCounts.operators.has('function')).toBe(true); + expect(moduleCounts.operators.has('if')).toBe(true); + expect(moduleCounts.operators.has('else')).toBe(true); + expect(moduleCounts.operators.has('return')).toBe(true); + expect(moduleCounts.operators.has('>')).toBe(true); + expect(moduleCounts.operators.has('-')).toBe(true); + }); + + it('should classify loops', () => { + const code = ` +function test() { + for (var i = 0; i < 10; i++) { + while (true) { break; } + } +}`; + const { moduleCounts } = analyzeHalstead(code); + + expect(moduleCounts.operators.has('for')).toBe(true); + expect(moduleCounts.operators.has('while')).toBe(true); + expect(moduleCounts.operators.has('break')).toBe(true); + expect(moduleCounts.operators.has('++')).toBe(true); + expect(moduleCounts.operators.has('<')).toBe(true); + }); + + it('should classify arrow functions', () => { + const { moduleCounts } = analyzeHalstead('const add = (a, b) => a + b;'); + + expect(moduleCounts.operators.has('=>')).toBe(true); + expect(moduleCounts.operators.has('+')).toBe(true); + expect(moduleCounts.operators.has('const')).toBe(true); + }); + + it('should classify member expressions and calls', () => { + const code = ` +function test() { + console.log("hello"); + obj?.method(); +}`; + const { moduleCounts } = analyzeHalstead(code); + + expect(moduleCounts.operators.has('.')).toBe(true); + expect(moduleCounts.operators.has('()')).toBe(true); + expect(moduleCounts.operands.has('console')).toBe(true); + }); + + it('should classify template literals', () => { + const code = 'const msg = `hello ${name}`;'; + const { moduleCounts } = analyzeHalstead(code); + + expect(moduleCounts.operators.has('`')).toBe(true); + }); + + it('should classify spread and rest', () => { + const code = 'const arr = [...items]; function f(...args) {}'; + const { moduleCounts } = analyzeHalstead(code); + + expect(moduleCounts.operators.has('...')).toBe(true); + }); + + it('should classify ternary', () => { + const code = 'const r = x ? 1 : 0;'; + const { moduleCounts } = analyzeHalstead(code); + + expect(moduleCounts.operators.has('?:')).toBe(true); + }); + + it('should classify switch/case/default', () => { + const code = ` +function test(x) { + switch(x) { + case 1: break; + default: break; + } +}`; + const { moduleCounts } = analyzeHalstead(code); + + expect(moduleCounts.operators.has('switch')).toBe(true); + expect(moduleCounts.operators.has('case')).toBe(true); + expect(moduleCounts.operators.has('default')).toBe(true); + }); + + it('should classify new, class, import/export', () => { + const code = ` +class Foo {} +export default Foo; +`; + const { moduleCounts } = analyzeHalstead(code); + + expect(moduleCounts.operators.has('class')).toBe(true); + expect(moduleCounts.operators.has('export')).toBe(true); + }); + + it('should handle async/await and yield', () => { + const code = ` +async function test() { + await fetch("url"); +}`; + const { moduleCounts } = analyzeHalstead(code); + + expect(moduleCounts.operators.has('await')).toBe(true); + }); + + it('should track per-function Halstead counts', () => { + const code = ` +function foo() { return 1 + 2; } +function bar() { return 3 * 4; } +`; + const { functionResults } = analyzeHalstead(code); + + expect(functionResults.length).toBe(2); + // Each function should have its own counts + expect(functionResults[0].operators.has('return')).toBe(true); + expect(functionResults[1].operators.has('return')).toBe(true); + }); + + it('should classify this as operand', () => { + const code = ` +function test() { + return this.value; +}`; + const { moduleCounts } = analyzeHalstead(code); + + expect(moduleCounts.operands.has('this')).toBe(true); + }); + + it('should classify object and array literals', () => { + const code = 'const obj = { a: 1 }; const arr = [1, 2];'; + const { moduleCounts } = analyzeHalstead(code); + + expect(moduleCounts.operators.has('{}')).toBe(true); + expect(moduleCounts.operators.has('[]')).toBe(true); + expect(moduleCounts.operators.has(':')).toBe(true); + }); +}); diff --git a/tests/module-analysis.test.ts b/tests/module-analysis.test.ts new file mode 100644 index 0000000..c714d2a --- /dev/null +++ b/tests/module-analysis.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect } from 'vitest'; +import { parseAndPrepareAst, walkWithVisitor, createMockContext } from './utils/test-helpers.js'; +import type { ESTreeNode } from '#src/types.js'; +import { createModuleAnalysisVisitor } from '#src/module/visitor.js'; +import { + calculateModuleComplexity, + computeMIInputs, + type ModuleAnalysisResult, +} from '#src/module/visitor.js'; + +function analyzeModule(code: string, filename = 'test.js'): ModuleAnalysisResult { + const { program } = parseAndPrepareAst(code, filename); + const context = createMockContext(program); + let result: ModuleAnalysisResult | undefined; + + const visitor = createModuleAnalysisVisitor( + context, + (r) => { + result = r; + }, + undefined, + {} + ); + + walkWithVisitor(program, visitor, code); + + if (!result) { + throw new Error('Module analysis did not produce a result (Program:exit not called?)'); + } + return result; +} + +describe('calculateModuleComplexity', () => { + it('should return 0 complexity for trivial code', () => { + const { score } = calculateModuleComplexity(1, 1, 1); + // With all ln(1)=0 inputs, MI = 171, complexity = 0 + expect(score).toBeCloseTo(0, 0); + }); + + it('should increase complexity with higher effort', () => { + const easy = calculateModuleComplexity(100, 1, 10); + const hard = calculateModuleComplexity(10000, 1, 10); + expect(hard.score).toBeGreaterThan(easy.score); + }); + + it('should increase complexity with higher cyclomatic', () => { + const simple = calculateModuleComplexity(100, 5, 10); + const complex = calculateModuleComplexity(100, 50, 10); + expect(complex.score).toBeGreaterThan(simple.score); + }); + + it('should increase complexity with more LOC', () => { + const small = calculateModuleComplexity(100, 5, 10); + const large = calculateModuleComplexity(100, 5, 1000); + expect(large.score).toBeGreaterThan(small.score); + }); + + it('should clamp score to 100 maximum', () => { + const { score } = calculateModuleComplexity(1e30, 1e10, 1e10); + expect(score).toBe(100); + }); +}); + +describe('computeMIInputs', () => { + it('should return module-level values when no functions', () => { + const moduleHalstead = { effort: 500 } as any; + const inputs = computeMIInputs(0, 0, 100, moduleHalstead, 0); + expect(inputs.avgEffort).toBe(500); + expect(inputs.avgCyclomatic).toBe(1); + expect(inputs.avgLOC).toBe(100); + }); + + it('should compute averages when functions exist', () => { + const moduleHalstead = { effort: 1000 } as any; + const inputs = computeMIInputs(4, 20, 200, moduleHalstead, 800); + expect(inputs.avgEffort).toBe(200); + expect(inputs.avgCyclomatic).toBe(5); + expect(inputs.avgLOC).toBe(50); + }); +}); + +describe('Module analysis visitor', () => { + it('should analyze a simple module with one function', () => { + const code = ` +function greet(name) { + if (name) { + return "Hello " + name; + } + return "Hello stranger"; +} +`; + const result = analyzeModule(code); + + expect(result.functionCount).toBe(1); + expect(result.functions[0].name).toBe('greet'); + expect(result.functions[0].cyclomatic).toBe(2); // 1 base + 1 if + expect(result.functions[0].cognitive).toBe(1); // 1 if + expect(result.cyclomatic.sum).toBe(2); + expect(result.cognitive.sum).toBe(1); + expect(result.moduleComplexity).toBeGreaterThanOrEqual(0); + expect(result.moduleComplexity).toBeLessThan(100); + }); + + it('should analyze module with multiple functions', () => { + const code = ` +function add(a, b) { return a + b; } +function subtract(a, b) { return a - b; } +function multiply(a, b) { return a * b; } +`; + const result = analyzeModule(code); + + expect(result.functionCount).toBe(3); + expect(result.functions.map((f) => f.name)).toEqual(['add', 'subtract', 'multiply']); + // Each function has cyclomatic 1 (just base) + expect(result.cyclomatic.sum).toBe(3); + expect(result.cyclomatic.max).toBe(1); + expect(result.cyclomatic.average).toBeCloseTo(1, 5); + }); + + it('should track Halstead metrics at module level', () => { + const code = ` +function calc(x, y) { + return x + y * 2; +} +`; + const result = analyzeModule(code); + + expect(result.halstead.n1).toBeGreaterThan(0); + expect(result.halstead.n2).toBeGreaterThan(0); + expect(result.halstead.volume).toBeGreaterThan(0); + expect(result.halstead.effort).toBeGreaterThan(0); + }); + + it('should track per-function Halstead metrics', () => { + const code = ` +function foo() { return 1 + 2; } +function bar() { return 3 * 4 * 5; } +`; + const result = analyzeModule(code); + + expect(result.functions[0].halstead.volume).toBeGreaterThan(0); + expect(result.functions[1].halstead.volume).toBeGreaterThan(0); + // bar has more operators, so likely higher effort + expect(result.functions[1].halstead.N1).toBeGreaterThanOrEqual(result.functions[0].halstead.N1); + }); + + it('should compute MI that makes sense', () => { + // Simple code → high MI + const simple = analyzeModule(`function id(x) { return x; }`); + // Complex code → lower MI + const complex = analyzeModule(` +function process(data) { + if (data.type === 'a') { + for (let i = 0; i < data.items.length; i++) { + if (data.items[i].valid) { + switch (data.items[i].category) { + case 'x': return processX(data.items[i]); + case 'y': return processY(data.items[i]); + case 'z': return processZ(data.items[i]); + default: return null; + } + } + } + } else if (data.type === 'b') { + while (data.hasNext()) { + if (data.current() && data.current().active) { + try { + return transform(data.current()); + } catch (e) { + return fallback(e); + } + } + } + } + return null; +} +`); + + expect(simple.moduleComplexity).toBeLessThan(complex.moduleComplexity); + }); + + it('should count functions above threshold', () => { + const code = ` +function simple() { return 1; } +function medium(x) { + if (x > 0) { + if (x > 10) { + for (let i = 0; i < x; i++) { + if (i % 2 === 0) { + while (i > 0) { + if (i === 5) break; + switch(i) { + case 1: break; + case 2: break; + case 3: break; + case 4: break; + } + } + } + } + } + } + return x; +} +`; + const result = analyzeModule(code, 'test.js'); + + // simple has cyclomatic 1, medium has cyclomatic > 10 + expect(result.cyclomatic.countAboveThreshold).toBe(1); // medium is above 10 + }); + + it('should include complexityDecomposition in result', () => { + const code = ` +function foo(x) { + if (x) return x + 1; + return 0; +} +`; + const result = analyzeModule(code); + + expect(result.complexityDecomposition).toBeDefined(); + expect(result.complexityDecomposition.effortTerm).toBeGreaterThanOrEqual(0); + expect(result.complexityDecomposition.cyclomaticTerm).toBeGreaterThanOrEqual(0); + expect(result.complexityDecomposition.locTerm).toBeGreaterThanOrEqual(0); + expect(['effort', 'cyclomatic', 'loc']).toContain( + result.complexityDecomposition.mainContributor + ); + }); + + it('should handle empty module', () => { + const result = analyzeModule('// empty module\nvar x = 1;\n'); + + expect(result.functionCount).toBe(0); + expect(result.cyclomatic.sum).toBe(0); + expect(result.cognitive.sum).toBe(0); + expect(result.moduleComplexity).toBeLessThan(100); + }); + + it('should compute totalLOC from program', () => { + const code = 'function a() {}\nfunction b() {}\nfunction c() {}\n'; + const result = analyzeModule(code); + + expect(result.totalLOC).toBeGreaterThan(0); + }); + + it('should forward per-function results to callback', () => { + const code = ` +function foo() { if (true) {} } +function bar() { for (;;) {} } +`; + const { program } = parseAndPrepareAst(code, 'test.js'); + const context = createMockContext(program); + const functionCalls: Array<{ cyclomatic: number; cognitive: number }> = []; + let moduleResult: ModuleAnalysisResult | undefined; + + const visitor = createModuleAnalysisVisitor( + context, + (r) => { + moduleResult = r; + }, + (result) => { + functionCalls.push({ cyclomatic: result.cyclomatic, cognitive: result.cognitive }); + }, + {} + ); + + walkWithVisitor(program, visitor, code); + + expect(functionCalls.length).toBe(2); + expect(moduleResult).toBeDefined(); + expect(moduleResult!.functionCount).toBe(2); + }); +}); diff --git a/tests/module-complexity.test.ts b/tests/module-complexity.test.ts new file mode 100644 index 0000000..1cf99ef --- /dev/null +++ b/tests/module-complexity.test.ts @@ -0,0 +1,346 @@ +import { describe, it, expect } from 'vitest'; +import { parseAndPrepareAst, walkWithVisitor, createMockContext } from './utils/test-helpers.js'; +import type { ESTreeNode, VisitorWithHooks } from '#src/types.js'; +import { createModuleAnalysisVisitor } from '#src/module/visitor.js'; +import type { ModuleAnalysisResult } from '#src/module/visitor.js'; +import type { CombinedComplexityResult } from '#src/combined-visitor.js'; +import { parseModuleOptions } from '#src/rules/shared.js'; +import { complexity } from '#src/rules/complexity.js'; + +function analyzeWithModuleRule( + code: string, + moduleOptions: Record, + filename = 'test.js' +): { + moduleResult: ModuleAnalysisResult; + functionResults: Array<{ name: string; cyclomatic: number; cognitive: number }>; + reports: string[]; +} { + const { program } = parseAndPrepareAst(code, filename); + const context = createMockContext(program); + + const reports: string[] = []; + (context as any).report = ({ message }: { message: string }) => { + reports.push(message); + }; + (context as any).options = [{ ...moduleOptions, cyclomatic: 20, cognitive: 15, minLines: 0 }]; + + let moduleResult: ModuleAnalysisResult | undefined; + const functionResults: Array<{ name: string; cyclomatic: number; cognitive: number }> = []; + + const visitor = createModuleAnalysisVisitor( + context, + (r) => { + moduleResult = r; + }, + (result: CombinedComplexityResult, node: ESTreeNode) => { + const name = (node as any).id?.name ?? ''; + functionResults.push({ + name, + cyclomatic: result.cyclomatic, + cognitive: result.cognitive, + }); + } + ); + + walkWithVisitor(program, visitor, code); + + if (!moduleResult) { + throw new Error('Module analysis did not produce a result'); + } + + return { moduleResult, functionResults, reports }; +} + +describe('parseModuleOptions', () => { + it('should return disabled when moduleComplexity is absent', () => { + const opts = parseModuleOptions(); + expect(opts.enabled).toBe(false); + }); + + it('should return disabled when moduleComplexity is undefined', () => { + const opts = parseModuleOptions({}); + expect(opts.enabled).toBe(false); + }); + + it('should return enabled with defaults when moduleComplexity is 0', () => { + const opts = parseModuleOptions({ moduleComplexity: 0 }); + expect(opts.enabled).toBe(true); + expect(opts.moduleComplexity).toBe(0); + expect(opts.maxCyclomaticSum).toBe(0); + expect(opts.maxCognitiveSum).toBe(0); + }); + + it('should use moduleComplexity value', () => { + const opts = parseModuleOptions({ moduleComplexity: 30 }); + expect(opts.enabled).toBe(true); + expect(opts.moduleComplexity).toBe(30); + }); + + it('should use provided values', () => { + const opts = parseModuleOptions({ + moduleComplexity: 30, + maxCyclomaticSum: 50, + maxCognitiveSum: 40, + }); + expect(opts.enabled).toBe(true); + expect(opts.moduleComplexity).toBe(30); + expect(opts.maxCyclomaticSum).toBe(50); + expect(opts.maxCognitiveSum).toBe(40); + }); +}); + +/** + * Run code through the actual complexity rule (createOnce + before + walk) + * to capture the real report messages including module-level insights. + */ +function runComplexityRule( + code: string, + options: Record, + filename = 'test.js' +): string[] { + const { program } = parseAndPrepareAst(code, filename); + const context = createMockContext(program); + + const reports: string[] = []; + (context as any).report = ({ message }: { message: string }) => { + reports.push(message); + }; + (context as any).options = [options]; + + const visitor = (complexity as any).createOnce(context) as VisitorWithHooks; + visitor.before?.(); + walkWithVisitor(program, visitor as any, code); + + return reports; +} + +describe('Module complexity rule integration', () => { + it('should report when cyclomatic sum exceeds threshold', () => { + const code = ` +function a(x) { if (x) { if (x > 1) { if (x > 2) {} } } return x; } +function b(x) { if (x) { if (x > 1) { if (x > 2) {} } } return x; } +function c(x) { if (x) { if (x > 1) { if (x > 2) {} } } return x; } +`; + const { moduleResult } = analyzeWithModuleRule(code, { + moduleComplexity: 0, + maxCyclomaticSum: 5, + }); + + // Each function has cyclomatic 4 (1 base + 3 ifs), sum = 12 + expect(moduleResult.cyclomatic.sum).toBe(12); + expect(moduleResult.cyclomatic.sum).toBeGreaterThan(5); + }); + + it('should not report when below thresholds', () => { + const code = `function simple() { return 1; }`; + const { moduleResult } = analyzeWithModuleRule(code, { + moduleComplexity: 0, + maxCyclomaticSum: 100, + maxCognitiveSum: 100, + }); + + expect(moduleResult.cyclomatic.sum).toBe(1); + expect(moduleResult.cognitive.sum).toBe(0); + }); + + it('should track function-level results alongside module', () => { + const code = ` +function foo(x) { if (x) return x; return 0; } +function bar(x, y) { return x && y ? x : y; } +`; + const { moduleResult, functionResults } = analyzeWithModuleRule(code, { moduleComplexity: 0 }); + + expect(functionResults.length).toBe(2); + expect(moduleResult.functionCount).toBe(2); + expect(moduleResult.halstead.volume).toBeGreaterThan(0); + }); + + it('should compute module complexity score', () => { + const code = ` +function complexFunction(data) { + if (data.type === 'a') { + for (let i = 0; i < data.items.length; i++) { + if (data.items[i].valid) { + switch (data.items[i].category) { + case 'x': return 1; + case 'y': return 2; + default: return 0; + } + } + } + } + return null; +} +`; + const { moduleResult } = analyzeWithModuleRule(code, { moduleComplexity: 20 }); + + expect(moduleResult.moduleComplexity).toBeGreaterThan(0); + expect(moduleResult.moduleComplexity).toBeLessThanOrEqual(100); + }); + + it('should report cognitive sum threshold', () => { + const code = ` +function deep(x) { + if (x) { + if (x > 1) { + if (x > 2) { + if (x > 3) { + return x; + } + } + } + } + return 0; +} +`; + const { moduleResult } = analyzeWithModuleRule(code, { + moduleComplexity: 0, + maxCognitiveSum: 5, + }); + + // Deeply nested ifs generate high cognitive complexity + expect(moduleResult.cognitive.sum).toBeGreaterThan(5); + }); + + it('should count functions above cyclomatic threshold', () => { + const code = ` +function simple() { return 1; } +function complex(x) { + if (x > 0) { + if (x > 10) { + for (let i = 0; i < x; i++) { + if (i % 2 === 0) { + while (i > 0) { + if (i === 5) break; + if (i === 6) break; + if (i === 7) break; + if (i === 8) break; + if (i === 9) break; + } + } + } + } + } + return x; +} +`; + const { moduleResult } = analyzeWithModuleRule(code, { moduleComplexity: 0 }); + + // complex function has cyclomatic > 10 (default threshold) + expect(moduleResult.cyclomatic.countAboveThreshold).toBe(1); + }); +}); + +describe('Complexity decomposition', () => { + it('should include complexityDecomposition on the analysis result', () => { + const code = ` +function foo(x) { + if (x > 0) { + for (let i = 0; i < x; i++) { + if (i % 2 === 0) return i; + } + } + return x; +} +`; + const { moduleResult } = analyzeWithModuleRule(code, { moduleComplexity: 0 }); + + expect(moduleResult.complexityDecomposition).toBeDefined(); + expect(moduleResult.complexityDecomposition.effortTerm).toBeGreaterThan(0); + expect(moduleResult.complexityDecomposition.locTerm).toBeGreaterThan(0); + expect(['effort', 'cyclomatic', 'loc']).toContain( + moduleResult.complexityDecomposition.mainContributor + ); + }); +}); + +describe('Module report message format', () => { + const complexCode = ` +function processOrder(data) { + if (data.type === 'a') { + for (let i = 0; i < data.items.length; i++) { + if (data.items[i].valid) { + switch (data.items[i].category) { + case 'x': return 1; + case 'y': return 2; + case 'z': return 3; + default: return 0; + } + } + } + } else if (data.type === 'b') { + while (data.hasNext()) { + if (data.current() && data.current().active) { + try { return transform(data.current()); } + catch (e) { return fallback(e); } + } + } + } + return null; +} +function helper(x) { + if (x > 0) { if (x > 10) { return x * 2; } } + return x; +} +function simple() { return 1; } +`; + + it('should use plain-language complexity message instead of jargon', () => { + const reports = runComplexityRule(complexCode, { + moduleComplexity: 10, + cyclomatic: 100, + cognitive: 100, + minLines: 0, + }); + + const moduleReport = reports.find((r) => r.includes('too complex')); + expect(moduleReport).toBeDefined(); + expect(moduleReport).toContain('score:'); + expect(moduleReport).toContain('/100'); + expect(moduleReport).not.toContain('Maintainability Index'); + }); + + it('should include estimated bug risk and reading time when applicable', () => { + const reports = runComplexityRule(complexCode, { + moduleComplexity: 10, + cyclomatic: 100, + cognitive: 100, + minLines: 0, + }); + + const moduleReport = reports.find((r) => r.includes('too complex')); + expect(moduleReport).toBeDefined(); + // Bug risk shown when >= 0.1 + expect(moduleReport).toContain('Main contributor:'); + }); + + it('should use plain-language cyclomatic sum message', () => { + const reports = runComplexityRule(complexCode, { + moduleComplexity: 0, + maxCyclomaticSum: 1, + cyclomatic: 100, + cognitive: 100, + minLines: 0, + }); + + const moduleReport = reports.find((r) => r.includes('decision paths')); + expect(moduleReport).toBeDefined(); + expect(moduleReport).toContain('total:'); + expect(moduleReport).toContain('maximum:'); + }); + + it('should use plain-language cognitive sum message', () => { + const reports = runComplexityRule(complexCode, { + moduleComplexity: 0, + maxCognitiveSum: 1, + cyclomatic: 100, + cognitive: 100, + minLines: 0, + }); + + const moduleReport = reports.find((r) => r.includes('too hard to read')); + expect(moduleReport).toBeDefined(); + expect(moduleReport).toContain('cognitive total:'); + }); +});