Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
{
"cyclomatic": 10,
"cognitive": 15,
"enableExtraction": true
"enableExtraction": true,
"moduleComplexity": 80
}
],

Expand Down Expand Up @@ -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"]
}
77 changes: 68 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -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`

Expand All @@ -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"],
Expand All @@ -27,10 +28,11 @@ npm install oxlint-plugin-complexity --save-dev
"error",
{
"cyclomatic": 20,
"cognitive": 15
}
]
}
"cognitive": 15,
"moduleComplexity": 80,
},
],
},
}
```

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -10,6 +10,8 @@
"complexity",
"cyclomatic",
"cognitive",
"halstead",
"maintainability-index",
"code-quality"
],
"author": "itaymendel",
Expand All @@ -32,6 +34,10 @@
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./analyze": {
"import": "./dist/analyze.js",
"types": "./dist/analyze.d.ts"
}
},
"files": [
Expand Down Expand Up @@ -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
}
},
Comment on lines 69 to 81
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash

echo "=== Checking package.json peerDependencies ==="
sed -n '65,77p' package.json

echo ""
echo "=== Checking src/analyze.ts imports (first 15 lines) ==="
head -15 src/analyze.ts

echo ""
echo "=== Checking for all imports of oxc-parser ==="
rg "from ['\"]oxc-parser['\"]|import.*oxc-parser" src/ -A 2 -B 2

echo ""
echo "=== Checking for all imports of estree-walker ==="
rg "from ['\"]estree-walker['\"]|import.*estree-walker" src/ -A 2 -B 2

Repository: itaymendel/oxlint-plugin-complexity

Length of output: 1531


🏁 Script executed:

#!/bin/bash

echo "=== Checking package.json main/exports field ==="
rg '"main"|"exports"' package.json -A 5

echo ""
echo "=== Checking how analyzeModule is exported ==="
rg "export.*analyzeModule|function analyzeModule" src/ -B 2 -A 5

echo ""
echo "=== Checking where analyzeModule is defined ==="
grep -n "analyzeModule" src/analyze.ts | head -5

echo ""
echo "=== Checking if there are any try-catch or dynamic imports ==="
rg "try\s*\{|catch|dynamic.*import|import\(" src/analyze.ts

Repository: itaymendel/oxlint-plugin-complexity

Length of output: 1237


🏁 Script executed:

#!/bin/bash

echo "=== Checking module/visitor.js for oxc-parser/estree-walker deps ==="
rg "from ['\"]oxc-parser['\"]|import.*oxc-parser|from ['\"]estree-walker['\"]|import.*estree-walker" src/module/visitor.ts

echo ""
echo "=== Checking all imports in module/visitor.ts (first 20 lines) ==="
head -20 src/module/visitor.ts

echo ""
echo "=== Summary: Does main entry point require optional deps? ==="
echo "Main entry exports from: src/index.ts"
echo "Exports from src/index.ts:"
grep "export" src/index.ts

Repository: itaymendel/oxlint-plugin-complexity

Length of output: 1772


Optional peer dependencies will cause import-time failure if not installed.

oxc-parser and estree-walker are marked as optional peerDependencies, but src/analyze.ts imports them unconditionally at the top level. Since src/index.ts re-exports analyzeModule from src/analyze.ts, importing from the main package entry point will trigger a MODULE_NOT_FOUND error if these optional dependencies are not installed, even when accessing other exports.

Consider one of:

  1. Use dynamic import() in analyzeModule so the dependency is only required when the function is called
  2. Move analyzeModule to a separate entry point (e.g., oxlint-plugin-complexity/analyze) to avoid loading optional deps from the main entry
  3. Remove optional: true if these dependencies are required for core functionality
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` around lines 65 - 77, The package marks oxc-parser and
estree-walker as optional peer deps but src/analyze.ts imports them at module
top-level and src/index.ts re-exports analyzeModule, causing MODULE_NOT_FOUND on
any import; fix by changing analyzeModule in src/analyze.ts to lazily load these
optional libs via dynamic import() inside the function (or helper) so they are
only required when analyzeModule is invoked, update any type/return handling
accordingly, and keep src/index.ts re-exporting analyzeModule unchanged so other
imports no longer trigger missing-module errors.

"engines": {
"node": ">=20.0.0"
Expand Down
119 changes: 119 additions & 0 deletions src/analyze.ts
Original file line number Diff line number Diff line change
@@ -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<string, ((node: ESTreeNode) => 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;
}
21 changes: 14 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand Down
Loading