@gabadi/crap4js
v0.1.0
Published
CRAP (Change Risk Anti-Pattern) metric analyzer for JavaScript/TypeScript projects
Maintainers
Readme
crap4js
CRAP (Change Risk Anti-Pattern) metric analyzer for JavaScript/TypeScript projects.
Combines cyclomatic complexity with test coverage to identify functions that are both complex and under-tested — the riskiest code to change. This is a JS/TS-native port in the spirit of Robert C. "Uncle Bob" Martin's family of CRAP analyzers — including crap4clj (Clojure) and crap4go (Go) — adapted for JavaScript and TypeScript source analysis using native tooling. The report format and metric semantics most closely follow crap4clj (see Compatibility with crap4clj below).
Quick Start
Install from npm:
npm install --save-dev @gabadi/crap4js
# or
pnpm add -D @gabadi/crap4jsAlternatively, pin to a release tag directly from GitHub (see Releases) — note the source repo is private, so this route requires access:
npm install --save-dev github:gabadi/crap4js#v0.1.0
# or
pnpm add -D github:gabadi/crap4js#v0.1.0Run against your project (requires coverage/lcov.info):
npx crap4js
# or
pnpm exec crap4jsGenerate coverage and analyze in one step:
npx crap4js --cov-cmd "npx vitest run --coverage"
# or
pnpm exec crap4js --cov-cmd "pnpm exec vitest run --coverage"Output
CRAP Report
===========
Function Source/Module CC Cov% CRAP
----------------------------------------------------------------------------------------
complexFunction src/logic.ts 12 45.0% 130.2
simpleHelper src/utils.ts 1 100.0% 1.0CLI Usage
Usage: crap4js [options] [module-filter ...]
Runs coverage, computes CRAP scores, and prints a report sorted worst first.
Options:
-h, --help Print this help message and exit.
-V, --version Print version and exit.
--format <type> Output format: table (default) or json.
--cov-cmd <cmd> Coverage command to run in analyzed project root.
--root <path> Root directory to analyze (default: current directory).
Arguments:
module-filter Optional source path fragment. When present, only
matching source files are analyzed.Examples
Analyze with existing coverage:
pnpm exec crap4jsGenerate coverage and analyze:
pnpm exec crap4js --cov-cmd "pnpm exec vitest run --coverage"JSON output for tooling integration:
pnpm exec crap4js --format jsonFilter to specific modules:
pnpm exec crap4js auth user
pnpm exec crap4js src/core/utilsAnalyze a different project root:
pnpm exec crap4js --root ./packages/apiLibrary API
crap4js can also be used as a library:
import {
analyzeProject,
crapScore,
sortByCrap,
formatReport,
formatJson,
parseLcov,
extractFunctions,
} from "@gabadi/crap4js";
// Analyze a full project
const result = await analyzeProject({
rootDir: "/path/to/project",
moduleFilters: [],
coverageCommand: null,
format: "json",
});
console.log(formatReport(result.entries));
// Use individual functions
const score = crapScore(5, 0.0); // 30.0 (CC=5, 0% coverage)
const entries = extractFunctions(sourceText, "module.ts");Exported Functions
| Function | Description |
|----------|-------------|
| analyzeProject(options) | Full project analysis with coverage, sorting, and diagnostics |
| analyzeFile(sourcePath, sourceText, lcovData) | Single-file CRAP analysis |
| loadLcov(lcovPath) | Parse an LCOV file from disk |
| crapScore(complexity, coveragePercent) | Compute CRAP score (null for indeterminate) |
| sortByCrap(entries) | Sort entries worst-first, N/A last |
| extractFunctions(sourceText, sourcePath) | Extract callable functions with complexity |
| parseLcov(text) | Parse LCOV text into line coverage data |
| coverageForRange(lineCov, startLine, endLine) | Coverage for a function's line range |
| lcovCoverageForSource(lcovData, sourcePath) | Find LCOV data for a source file |
| lcovDiagnostics(lcovData, sourcePath) | Diagnostics for unmatched source paths |
| formatReport(entries) | Format entries as text table |
| formatJson(result) | Format analysis result as JSON |
| findSourceFiles(rootDir, filters) | Discover supported JS/TS source files |
Exported Types
CrapEntry— Analyzed function entry with name, source, complexity, coverage, CRAP scoreAnalysisResult— Full result including entries, diagnostics, and summaryAnalysisOptions— Configuration for project analysisAnalysisSummary— Aggregate statistics (totals, averages, worst score)ExtractedFunction— Parsed function with name, line range, complexityDiagnostic— Union type for coverage, parse, and missing-path diagnosticsLcovData— Parsed LCOV coverage data structure
Coverage Requirements
crap4js requires LCOV coverage data at coverage/lcov.info in the analyzed project root. This is the standard output format for:
- Vitest with
@vitest/coverage-v8or@vitest/coverage-istanbul - Jest with
jest --coverage - c8 / nyc / Istanbul
- Any tool that emits LCOV format
The --cov-cmd option runs the coverage command in the analyzed project root, deleting stale coverage first. If no coverage command is provided, crap4js uses any existing coverage/lcov.info.
CRAP Formula
CRAP(fn) = CC² × (1 - coverageFraction)³ + CC- CC = cyclomatic complexity (decision points + 1)
- coverageFraction = covered lines / total executable lines
| Score | Risk | |-------|------| | 1-5 | Low — clean code | | 5-30 | Moderate — refactor or add tests | | 30+ | High — complex and under-tested |
What It Counts
Decision points that increase cyclomatic complexity in JS/TS:
if,else if- Conditional expressions (
?:) - Logical operators:
&&,||,?? - Loops:
for,for...of,for...in,while,do...while catchclauses- Each
caseanddefaultinswitch
Optional chaining (?.) is not a decision point — it is a null-guard absent from Uncle Bob's
original Go/Java/Clojure spec. Counting it would inflate CC on idiomatic TypeScript that uses ?.
for safe property access.
Type annotations, interfaces, generics, imports, and type-only syntax do not increase complexity.
Supported Callable Forms
crap4js extracts and scores these JS/TS function forms with stable names:
- Function declarations:
function foo() {} - Exported functions:
export function foo() {} - Arrow functions:
const foo = () => {} - Function expressions:
let foo = function() {} - Class methods (including static, private
#, getters, setters) - Object literal methods:
{ method() {} } - Exports assignments:
exports.foo = () => {},module.exports = function() {} - Default exports:
export default function() {}
Anonymous functions without stable names (IIFEs, bare callbacks) are excluded.
Indeterminate Coverage
When a function's coverage cannot be matched to LCOV data (for example, the source file is not in the LCOV output), coverage and CRAP are reported as N/A rather than 0.0%. This prevents false high-risk scores from missing coverage data rather than actual zero coverage.
JSON Output Schema
With --format json, crap4js outputs:
{
"rootDir": "/path/to/project",
"entries": [
{
"name": "functionName",
"source": "src/module.ts",
"startLine": 10,
"endLine": 25,
"complexity": 5,
"coveragePercent": 85.0,
"crap": 5.1
}
],
"diagnostics": [],
"summary": {
"totalFunctions": 42,
"coveredFunctions": 38,
"indeterminateFunctions": 4,
"averageComplexity": 3.2,
"averageCrap": 7.5,
"worstCrap": 130.2
}
}Null values in coveragePercent and crap indicate indeterminate coverage.
Source Discovery
crap4js discovers source files under the project root:
- Includes:
.js,.jsx,.ts,.tsximplementation files undersrc/(or the project root if nosrc/directory exists) - Excludes:
.d.tsdeclaration files, test/spec files (*.test.*,*.spec.*),node_modules,dist,coverage, build output, and hidden/cache directories
Self-Analysis
crap4js can analyze its own codebase:
pnpm run analyze:selfThis generates coverage for the crap4js project and then runs the analyzer on its own source, producing a CRAP report for the tool itself.
Quality Commands
| Command | Description |
|---------|-------------|
| pnpm run lint | ESLint check on source and test |
| pnpm run typecheck | TypeScript strict type check |
| pnpm run test | Vitest test suite |
| pnpm run coverage | Coverage with LCOV output |
| pnpm run build | Build dist artifacts (ESM + CJS + declarations) |
| pnpm run arch | Dependency-cruiser architecture boundary check |
| pnpm run analyze:self | Self-analysis of crap4js source |
| pnpm run mutate | Stryker mutation testing (concurrency 2) |
| pnpm run check | Full aggregate quality gate |
Mutation threshold rationale: The break threshold of 50% reflects structural limits of Stryker's perTest coverage analysis against CLI subprocess execution paths and AST-based parsing where guard logic is inherent to the parser. Key survivor categories: (1) CLI subprocess tests invoke built dist/cli.js (not instrumented source), producing no-coverage mutants in cli/main.ts; (2) lcovDiagnostics scoring mutants are informational-only and don't affect CRAP scores; (3) complexity.ts has ~197 equivalent survivors because @typescript-eslint/typescript-estree handles comment/string/template guards at the AST level, making explicit guard clauses in our code unnecessary and their removal undetectable by mutation; (4) core.ts adapter code has filesystem/subprocess paths untestable at source level. Notable achievement: crap.ts and reporting.ts both achieve 100% mutation score. The sortByCrap null-handling survivors were resolved by extracting the comparator into a named crapComparator function and testing it via direct calls (bypassing Array.sort), enabling Stryker's perTest analysis to correctly attribute branch kills. See the mission library mutation-survivors.md for detailed per-category justification.
Architecture Boundaries
The codebase enforces these module boundaries via dependency-cruiser:
- CRAP metric (
crap.ts): Pure domain — no imports from CLI, filesystem, process, or presentation - Complexity (
complexity.ts): Parser-only — no imports from CLI, core, or reporting - Coverage (
coverage.ts): LCOV-only — no imports from CLI, core, or reporting - Reporting (
reporting.ts): Presentation-only — no imports from CLI, core, or config - Types (
types.ts): Pure definitions — no imports from implementation modules - Public API (
index.ts): Re-exports only — no CLI internals
An ESLint custom rule also prevents string-concatenated filesystem path separators (e.g., + "/" +) in source modules, enforcing path.join/path.resolve usage for cross-platform path construction.
Compatibility with crap4clj
crap4js preserves the core CRAP formula and report semantics from crap4clj with these JS/TS-native adaptations:
| Aspect | crap4clj | crap4js |
|--------|----------|---------|
| Coverage source | Cloverage HTML/LCOV | LCOV from Vitest/Jest/c8/nyc |
| Callable extraction | Clojure defn/defn- | JS/TS function/method/expression forms |
| Decision points | Clojure if/when/and/or/case | JS/TS if/?:/&&/||/??/switch (no ?.) |
| Indeterminate coverage | N/A for unmatched HTML | N/A for unmatched LCOV |
| Report columns | Function, Namespace, CC, Cov%, CRAP | Function, Source/Module, CC, Cov%, CRAP |
| Sorting | CRAP descending, N/A last | CRAP descending, N/A last |
Intentional differences:
- crap4js uses LCOV as the sole coverage source (no HTML parsing) since JS/TS tools natively produce LCOV
- crap4js does not count optional chaining (
?.) as a decision point — it is a null-guard with no equivalent in Uncle Bob's original spec - crap4js does not count
when/when-not(Clojure-only) ortry/finally(neither adds a decision point)
GitHub Actions Integration
Add crap4js to any project's CI pipeline:
- name: Install crap4js
run: npm install --save-dev @gabadi/crap4js
- name: Run CRAP analysis
run: npx crap4js --cov-cmd "npx vitest run --coverage"Or with JSON output for downstream processing:
- name: CRAP report
run: npx crap4js --format json > crap-report.jsonDevelopment
pnpm install --frozen-lockfile # install dependencies
pnpm run check # run all quality gatesReleasing a New Version
Releases are manual. The release workflow runs on version tags — it does not trigger on
merges to main.
Steps:
Run the full quality gate locally and confirm it passes:
pnpm run checkBump the version in
package.json:# edit package.json: "version": "X.Y.Z"Rebuild
dist/and commit everything:pnpm run build git add dist/ package.json git commit -m "chore: release vX.Y.Z"Tag and push the tag:
git tag vX.Y.Z git push origin main git push origin vX.Y.ZThe
releaseworkflow triggers on the tag push. It re-runs the full quality gate and creates a GitHub Release athttps://github.com/gabadi/crap4js/releases/tag/vX.Y.Z.Publish to npm:
npm publish --access public
Consumers install the new version with:
npm install --save-dev @gabadi/[email protected]License
MIT
