From 793c517096e7a3a3be954bb4a60c89d395c03033 Mon Sep 17 00:00:00 2001 From: "Robert C. Martin" Date: Thu, 13 Aug 2026 10:35:15 -0400 Subject: [PATCH] Rewrite CRAP analyzer for TypeScript --- .gitignore | 5 +- README.md | 97 ++--- bin/crap4ts.js | 3 + package.json | 27 ++ pom.xml | 69 ---- spec.md | 288 ++------------ src/analyzer.ts | 27 ++ src/cli-application.ts | 96 +++++ src/cli-arguments.ts | 48 +++ src/command-executor.ts | 27 ++ src/coverage-runner.ts | 73 ++++ src/crap-score.ts | 13 + src/crap4java/ChangedFileDetector.java | 120 ------ src/crap4java/CliApplication.java | 253 ------------ src/crap4java/CliArguments.java | 31 -- src/crap4java/CliArgumentsParser.java | 89 ----- src/crap4java/CliMode.java | 43 -- src/crap4java/CommandExecutor.java | 23 -- src/crap4java/CoverageData.java | 42 -- src/crap4java/CoverageRunner.java | 81 ---- src/crap4java/CrapAnalyzer.java | 167 -------- src/crap4java/CrapScore.java | 36 -- src/crap4java/JacocoCoverageParser.java | 139 ------- src/crap4java/JavaMethodParser.java | 366 ------------------ src/crap4java/Main.java | 87 ----- src/crap4java/MethodDescriptor.java | 44 --- src/crap4java/MethodMetrics.java | 50 --- src/crap4java/ProcessCommandExecutor.java | 36 -- src/crap4java/ReportFormatter.java | 81 ---- src/crap4java/SourceFileFinder.java | 47 --- src/istanbul-coverage.ts | 126 ++++++ src/main.ts | 14 + src/package-grouper.ts | 63 +++ src/package-runtime.ts | 127 ++++++ src/report-formatter.ts | 36 ++ src/source-file-finder.ts | 150 +++++++ src/types.ts | 25 ++ src/typescript-method-parser.ts | 144 +++++++ test/analyzer.test.ts | 44 +++ test/cli-application.test.ts | 114 ++++++ test/cli-arguments.test.ts | 18 + test/command-executor.test.ts | 19 + test/coverage-runner.test.ts | 75 ++++ test/crap-score.test.ts | 19 + test/crap4java/ChangedFileDetectorTest.java | 104 ----- test/crap4java/CliApplicationTest.java | 155 -------- test/crap4java/CliArgumentsParserTest.java | 58 --- test/crap4java/CoverageRunnerTest.java | 72 ---- test/crap4java/CrapAnalyzerTest.java | 185 --------- test/crap4java/CrapScoreTest.java | 29 -- test/crap4java/JacocoCoverageParserTest.java | 97 ----- test/crap4java/JavaMethodParserTest.java | 227 ----------- test/crap4java/MainTest.java | 131 ------- .../crap4java/ProcessCommandExecutorTest.java | 22 -- test/crap4java/ReportFormatterTest.java | 56 --- test/crap4java/SourceFileFinderTest.java | 32 -- test/istanbul-coverage.test.ts | 67 ++++ test/package-grouper.test.ts | 41 ++ test/package-runtime.test.ts | 52 +++ test/report-formatter.test.ts | 20 + test/source-file-finder.test.ts | 63 +++ test/typescript-method-parser.test.ts | 92 +++++ tsconfig.json | 17 + 63 files changed, 1734 insertions(+), 3268 deletions(-) create mode 100755 bin/crap4ts.js create mode 100644 package.json delete mode 100644 pom.xml create mode 100644 src/analyzer.ts create mode 100644 src/cli-application.ts create mode 100644 src/cli-arguments.ts create mode 100644 src/command-executor.ts create mode 100644 src/coverage-runner.ts create mode 100644 src/crap-score.ts delete mode 100644 src/crap4java/ChangedFileDetector.java delete mode 100644 src/crap4java/CliApplication.java delete mode 100644 src/crap4java/CliArguments.java delete mode 100644 src/crap4java/CliArgumentsParser.java delete mode 100644 src/crap4java/CliMode.java delete mode 100644 src/crap4java/CommandExecutor.java delete mode 100644 src/crap4java/CoverageData.java delete mode 100644 src/crap4java/CoverageRunner.java delete mode 100644 src/crap4java/CrapAnalyzer.java delete mode 100644 src/crap4java/CrapScore.java delete mode 100644 src/crap4java/JacocoCoverageParser.java delete mode 100644 src/crap4java/JavaMethodParser.java delete mode 100644 src/crap4java/Main.java delete mode 100644 src/crap4java/MethodDescriptor.java delete mode 100644 src/crap4java/MethodMetrics.java delete mode 100644 src/crap4java/ProcessCommandExecutor.java delete mode 100644 src/crap4java/ReportFormatter.java delete mode 100644 src/crap4java/SourceFileFinder.java create mode 100644 src/istanbul-coverage.ts create mode 100644 src/main.ts create mode 100644 src/package-grouper.ts create mode 100644 src/package-runtime.ts create mode 100644 src/report-formatter.ts create mode 100644 src/source-file-finder.ts create mode 100644 src/types.ts create mode 100644 src/typescript-method-parser.ts create mode 100644 test/analyzer.test.ts create mode 100644 test/cli-application.test.ts create mode 100644 test/cli-arguments.test.ts create mode 100644 test/command-executor.test.ts create mode 100644 test/coverage-runner.test.ts create mode 100644 test/crap-score.test.ts delete mode 100644 test/crap4java/ChangedFileDetectorTest.java delete mode 100644 test/crap4java/CliApplicationTest.java delete mode 100644 test/crap4java/CliArgumentsParserTest.java delete mode 100644 test/crap4java/CoverageRunnerTest.java delete mode 100644 test/crap4java/CrapAnalyzerTest.java delete mode 100644 test/crap4java/CrapScoreTest.java delete mode 100644 test/crap4java/JacocoCoverageParserTest.java delete mode 100644 test/crap4java/JavaMethodParserTest.java delete mode 100644 test/crap4java/MainTest.java delete mode 100644 test/crap4java/ProcessCommandExecutorTest.java delete mode 100644 test/crap4java/ReportFormatterTest.java delete mode 100644 test/crap4java/SourceFileFinderTest.java create mode 100644 test/istanbul-coverage.test.ts create mode 100644 test/package-grouper.test.ts create mode 100644 test/package-runtime.test.ts create mode 100644 test/report-formatter.test.ts create mode 100644 test/source-file-finder.test.ts create mode 100644 test/typescript-method-parser.test.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index 2f7896d..c19bb02 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -target/ +node_modules/ +dist/ +coverage/ +*.tsbuildinfo diff --git a/README.md b/README.md index 90d5e35..e155e0b 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,84 @@ -# crap4java +# crap4ts -`crap4java` is a standalone CRAP metric tool for Java projects, modeled after `crap4clj`. +`crap4ts` is a standalone CRAP metric analyzer for TypeScript projects. -It combines method cyclomatic complexity with JaCoCo method coverage and reports CRAP scores. -On each run it deletes stale coverage artifacts, runs coverage, then analyzes the selected files. +It combines per-function cyclomatic complexity with Istanbul statement coverage and reports the riskiest functions first. ## Formula -`CRAP = CC^2 * (1 - coverage)^3 + CC` +```text +CRAP = CC^2 * (1 - coverage)^3 + CC +``` + +- `CC` is cyclomatic complexity calculated from the TypeScript AST. +- `coverage` is the fraction of covered Istanbul statements inside a function. + +## Supported code + +The parser uses the TypeScript Compiler API and supports `.ts` and `.tsx` files. It reports: + +- function declarations +- class methods and accessors +- named function expressions +- arrow functions assigned to named variables or properties + +It excludes declarations without bodies, constructors, anonymous callbacks, declaration files, and test/spec files. -- `CC` is cyclomatic complexity. -- `coverage` is method coverage fraction from JaCoCo `INSTRUCTION` counters. +## Coverage pipeline -## Coverage Pipeline +Like the original `crap4java`, `crap4ts` generates fresh coverage on every invocation: -For each invocation: +1. Group selected files by their nearest `package.json`. +2. Detect npm, pnpm, or Yarn from `packageManager` and lockfiles. +3. Detect Vitest or Jest from the package test script and dependencies. +4. Delete the stale package-local coverage output. +5. Run the detected test framework with Istanbul JSON reporting. +6. Read `coverage/coverage-final.json` and analyze that package. -1. Delete stale coverage artifacts: - - `target/site/jacoco/` - - `target/jacoco.exec` -2. Run `mvn -q org.jacoco:jacoco-maven-plugin:0.8.12:prepare-agent test org.jacoco:jacoco-maven-plugin:0.8.12:report` -3. Read `target/site/jacoco/jacoco.xml` -4. Analyze selected Java files +Workspace packages inherit package-manager and test-framework configuration from the project root when it is not declared locally. Package groups run sequentially. -## Build and Test +The generated commands are equivalent to: ```bash -mvn test +npm exec -- vitest run --root=. --coverage --coverage.reporter=json --coverage.reportsDirectory=coverage +npm exec -- jest --rootDir=. --coverage --coverageReporters=json --coverageDirectory=coverage ``` -## Run +The executable changes to `pnpm exec` or `yarn exec` when detected. -Build the jar: +Use another package-relative output location with: ```bash -mvn -DskipTests package +crap4ts --coverage artifacts/coverage-final.json ``` -From the project root you want to analyze: +For the default destination, the package's complete `coverage/` directory is removed before testing. For a custom destination, only the exact JSON file is removed. Paths outside a package are rejected. + +If the tests succeed but coverage JSON is absent, analysis continues with coverage and CRAP reported as `N/A`. A failed test command stops the analysis. + +## Install and develop ```bash -java -jar target/crap4java-0.1.0-SNAPSHOT.jar +npm install +npm test +npm run check +npm run build ``` ## CLI ```text ---help Print usage to stdout -(no args) Analyze all Java files under src/ ---changed Analyze changed Java files under src/ - Analyze only these files - Analyze all Java files under each directory's src/ subtree +crap4ts Analyze TypeScript files under src/ +crap4ts --changed Analyze changed TypeScript files under src/ +crap4ts Analyze explicit files or directories +crap4ts --coverage [...] Set the package-relative Istanbul JSON destination +crap4ts --help Print help ``` -Examples: - -```bash -java -jar target/crap4java-0.1.0-SNAPSHOT.jar --help -java -jar target/crap4java-0.1.0-SNAPSHOT.jar -java -jar target/crap4java-0.1.0-SNAPSHOT.jar --changed -java -jar target/crap4java-0.1.0-SNAPSHOT.jar src/main/java/demo/Sample.java -java -jar target/crap4java-0.1.0-SNAPSHOT.jar module-a module-b -``` +Default discovery finds every `src/` tree in the project, including workspace packages. Explicit directories are searched recursively. Generated directories, dependency directories, declaration files, and common test/spec files are excluded. ## Exit codes -- `0` success, threshold respected -- `1` invalid CLI usage -- `2` CRAP threshold exceeded (`> 8.0`) - -## Notes - -- If JaCoCo XML is missing, coverage is reported as `N/A`. -- Report output is sorted by CRAP descending, with `N/A` at the bottom. +- `0`: analysis succeeded and the threshold was respected +- `1`: invalid CLI usage or an analysis error +- `2`: maximum CRAP score exceeded `8.0` diff --git a/bin/crap4ts.js b/bin/crap4ts.js new file mode 100755 index 0000000..d809541 --- /dev/null +++ b/bin/crap4ts.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +import "../dist/src/main.js"; diff --git a/package.json b/package.json new file mode 100644 index 0000000..ad6cb15 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "crap4ts", + "version": "0.1.0", + "description": "CRAP metric analyzer for TypeScript projects", + "type": "module", + "bin": { + "crap4ts": "bin/crap4ts.js" + }, + "files": [ + "bin", + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "node --test --experimental-strip-types test/*.test.ts", + "check": "tsc -p tsconfig.json --noEmit" + }, + "engines": { + "node": ">=22.18" + }, + "dependencies": { + "typescript": "^5.9.3" + }, + "devDependencies": { + "@types/node": "^24.10.13" + } +} diff --git a/pom.xml b/pom.xml deleted file mode 100644 index e91061f..0000000 --- a/pom.xml +++ /dev/null @@ -1,69 +0,0 @@ - - 4.0.0 - - com.unclebob - crap4java - 0.1.0-SNAPSHOT - - - 17 - 17 - UTF-8 - 5.10.2 - 0.8.12 - - - - - org.junit.jupiter - junit-jupiter - ${junit.version} - test - - - - - ${project.basedir}/src/crap4java - ${project.basedir}/test/crap4java - - - org.apache.maven.plugins - maven-surefire-plugin - 3.2.5 - - - org.jacoco - jacoco-maven-plugin - ${jacoco.version} - - - - prepare-agent - - - - report - test - - report - - - - - - org.apache.maven.plugins - maven-jar-plugin - 3.4.1 - - - - crap4java.Main - - - - - - - diff --git a/spec.md b/spec.md index 2234226..8b0a08e 100644 --- a/spec.md +++ b/spec.md @@ -1,273 +1,59 @@ -# crap4java Specification +# crap4ts Specification -## 1. Purpose +## Purpose -`crap4java` is a CRAP metric analyzer for Java projects. +`crap4ts` is a quality-gate CLI for TypeScript projects. It discovers TypeScript source files, generates package-local Istanbul JSON coverage, parses concrete functions with the TypeScript Compiler API, calculates CRAP scores, and prints the highest-risk functions first. -It shall: +## File selection -- locate Java source files to analyze -- generate JaCoCo coverage for the owning Maven module of each analyzed file set -- parse Java methods and estimate cyclomatic complexity -- combine complexity and coverage into CRAP scores -- print a tabular report sorted by worst score first -- fail when the maximum CRAP score exceeds the configured threshold +- With no paths, find every directory named `src` beneath the project root and analyze its `.ts` and `.tsx` files recursively. +- With `--changed`, parse `git status --porcelain` and retain existing changed `.ts` and `.tsx` files beneath any `src/` tree. +- With explicit files or directories, analyze matching files and recursively expand directories. +- Deduplicate and sort all selected paths. +- Exclude `.d.ts`, `.test.ts[x]`, `.spec.ts[x]`, `node_modules`, `dist`, `build`, `coverage`, `.git`, and `__tests__`. +- An empty selection prints `No TypeScript files to analyze.` and succeeds. -`crap4java` is intended as a project-quality gate rather than a mutation tool. +## Function parsing -## 2. Scope +Report concrete function declarations, class methods, getters, setters, named function expressions, and arrows assigned to named variables or properties. -This specification defines: +Ignore overload/ambient/interface/abstract declarations without bodies, constructors, and anonymous callbacks. -- the command-line contract -- source file selection rules -- coverage generation behavior -- method parsing behavior -- CRAP score computation -- report ordering and exit codes +Each function starts at cyclomatic complexity 1. Add one for each `if`, loop, `catch`, switch `case` (including `default`), ternary expression, and short-circuit `&&`, `||`, or `??`. Branches in nested functions belong only to the nested function. -This specification does not define: +## Coverage -- non-Maven execution -- support for non-Java source files -- a machine-readable report format -- configurable thresholds through the CLI +Group selected files by the nearest ancestor `package.json`, falling back to the project root. Process package groups sequentially and generate coverage once per group. -## 3. Terminology +For every group: -- `project root` - The working root from which `crap4java` is invoked. +1. Detect npm, pnpm, or Yarn using the nearest `packageManager` field, then the nearest `pnpm-lock.yaml`, `yarn.lock`, or `package-lock.json`, and finally npm as the default. +2. Detect Vitest or Jest using the nearest package test script or dependency declaration. +3. Inherit detection configuration from ancestor packages up to the project root when necessary. +4. Delete stale package-local coverage output. +5. Execute the framework directly through the detected package manager with JSON coverage enabled. +6. Read `coverage/coverage-final.json` by default, or the package-relative destination given by `--coverage`. -- `module root` - The nearest ancestor directory of an analyzed file that contains `pom.xml`. If none exists below the project root, the project root is the module root. +Reject unsupported explicitly configured package managers and coverage destinations outside the package. Fail fast when the test command fails. If the command succeeds without producing JSON, warn and continue with `N/A` coverage. -- `method metric` - A single report row consisting of method identity, cyclomatic complexity, coverage, and CRAP score. +For each function, coverage is the fraction of covered Istanbul statements fully contained in its source line range. If it has no mapped statements, use the nearest matching Istanbul function counter. If neither is available, coverage is `N/A`. -- `coverage N/A` - The state where no JaCoCo XML was found for the module and therefore coverage could not be assigned to a method. +Vitest and Jest are supported. Node's built-in test coverage is not accepted because it does not produce Istanbul `coverage-final.json`. -## 4. Command-Line Interface +## Score and report -### 4.1 Supported Forms +For known coverage: -The tool shall support these forms: +```text +CRAP = CC^2 * (1 - coverage)^3 + CC +``` -- `crap4java` -- `crap4java --changed` -- `crap4java ` -- `crap4java --help` +Sort numeric scores descending and put `N/A` scores last. The report includes method name, file, complexity, coverage, and CRAP score. -### 4.2 Mode Semantics +## Threshold and exits -- no arguments - Analyze all Java source files under `src/`. +The fixed threshold is `8.0`. -- `--changed` - Analyze changed Java source files under `src/`. - -- `` - For each explicit path: - - if it is a file, analyze that file - - if it is a directory, analyze all Java files under that directory's `src/` subtree - -- `--help` - Print usage text and exit successfully. - -### 4.3 Invalid Usage - -The tool shall exit with usage error when argument parsing fails. - -The tool shall print usage text on CLI usage failure. - -## 5. File Selection Rules - -### 5.1 Default Source Discovery - -In default mode, the tool shall analyze all `.java` files under: - -- `/src/**` - -### 5.2 Changed-File Discovery - -In `--changed` mode, the tool shall: - -- invoke `git status --porcelain` -- interpret modified, added, and untracked Java files -- retain only `.java` files under `/src/` -- sort the resulting file list in path order - -### 5.3 Explicit Paths - -When explicit paths are supplied: - -- file paths shall be analyzed directly -- directory paths shall be expanded to `.java` files under `/src/**` -- duplicates shall be removed -- the final list shall be sorted in path order - -### 5.4 Empty Selection - -If no Java files are selected after expansion and filtering: - -- the tool shall print `No Java files to analyze.` -- the tool shall exit successfully - -## 6. Module Grouping - -The tool shall group selected files by module root before coverage generation. - -The tool shall determine the module root for a file by walking upward from the file's directory until: - -- a `pom.xml` file is found, or -- the walk leaves the project root - -If no nearer `pom.xml` is found, the project root shall be used as the module root. - -Coverage generation and JaCoCo XML lookup shall occur once per module group. - -## 7. Coverage Pipeline - -For each module group, the tool shall: - -1. delete stale coverage artifacts -2. run Maven tests with JaCoCo report generation -3. read the resulting JaCoCo XML report -4. analyze the selected Java files in that module - -### 7.1 Stale Artifact Cleanup - -Before coverage generation, the tool shall delete stale module-local coverage artifacts, including: - -- `target/site/jacoco/` -- `target/jacoco.exec` - -### 7.2 Maven Coverage Command - -Coverage generation shall invoke Maven against the module root and generate JaCoCo XML for that module. - -### 7.3 Missing Coverage XML - -If the expected JaCoCo XML file does not exist after coverage generation: - -- the tool shall print a warning to stderr -- coverage for methods in that module shall be reported as `N/A` - -## 8. Java Method Parsing - -The tool shall parse Java source using the JDK compiler tree APIs. - -The parser shall identify concrete method declarations and their basic attributes, including: - -- class name -- method name -- source location -- cyclomatic complexity - -The parser shall not require full semantic resolution of sibling or external symbols in order to extract methods. - -### 8.1 Exclusions - -The method parser shall ignore: - -- constructors -- abstract methods -- anonymous-class methods - -### 8.2 Complexity Counting - -Cyclomatic complexity shall be computed from method bodies using Java syntax structure rather than regex-only parsing. - -The resulting complexity shall be an integer `CC >= 1` for concrete methods. - -## 9. Coverage Attribution - -Coverage shall be attributed to methods by matching parsed methods to JaCoCo coverage data. - -If an exact coverage entry for a method cannot be found, the tool may use the nearest appropriate available coverage entry according to its implemented lookup rules. - -If no usable coverage data is available for a method: - -- coverage shall be reported as `N/A` -- CRAP score shall be reported as `N/A` - -## 10. CRAP Formula - -For methods with known coverage, CRAP shall be computed as: - -`CRAP = CC^2 * (1 - coverage)^3 + CC` - -Where: - -- `CC` is cyclomatic complexity -- `coverage` is the method coverage fraction in the range `0.0..1.0` - -Coverage shall be derived from JaCoCo `INSTRUCTION` counters. - -## 11. Report - -The tool shall print a tabular report containing, at minimum: - -- method name -- class name -- cyclomatic complexity -- coverage percentage or `N/A` -- CRAP score or `N/A` - -The report shall be sorted by CRAP descending. - -Methods with `N/A` CRAP shall appear after methods with numeric CRAP. - -## 12. Threshold - -The CRAP threshold shall be `8.0`. - -The tool shall determine the maximum numeric CRAP value in the result set. - -If the maximum numeric CRAP value is greater than `8.0`: - -- the tool shall print `CRAP threshold exceeded: > 8.0` to stderr -- the tool shall exit with threshold-failure status - -If no numeric CRAP values exist: - -- the maximum shall be treated as `0.0` -- the threshold shall not be considered exceeded - -## 13. Exit Codes - -The tool shall use these exit codes: - -- `0` - Successful analysis, including empty selection or all scores at or below threshold. - -- `1` - CLI usage error. - -- `2` - CRAP threshold exceeded. - -## 14. Error Handling - -The tool shall fail fast on: - -- invalid command-line usage -- coverage command failure -- unreadable source files -- parser failures that prevent analysis - -Warnings about missing JaCoCo XML shall not by themselves fail the run. - -## 15. Non-Goals - -The current implementation is not required to support: - -- configurable thresholds via CLI -- non-Maven builds -- directory recursion outside `src/` discovery rules -- mutation analysis -- machine-readable output formats - -## 16. Conformance - -An implementation conforms to this specification if it satisfies the CLI, file selection, module grouping, coverage generation, method analysis, CRAP computation, reporting, and exit-code rules above for Java projects built with Maven. +- Exit `0` for help, an empty selection, or a maximum score at or below the threshold. +- Exit `1` for invalid arguments or fatal analysis errors. +- Exit `2` and print a threshold error when the maximum numeric score is greater than `8.0`. diff --git a/src/analyzer.ts b/src/analyzer.ts new file mode 100644 index 0000000..0124369 --- /dev/null +++ b/src/analyzer.ts @@ -0,0 +1,27 @@ +import { readFile } from "node:fs/promises"; + +import { calculateCrap } from "./crap-score.ts"; +import { coverageForMethod, parseIstanbulCoverage } from "./istanbul-coverage.ts"; +import { parseTypeScriptMethods } from "./typescript-method-parser.ts"; +import type { MethodMetrics } from "./types.ts"; + +export async function analyzeFiles(files: string[], coverageFile: string): Promise { + const coverage = await parseIstanbulCoverage(coverageFile); + const metrics: MethodMetrics[] = []; + + for (const filePath of files) { + const source = await readFile(filePath, "utf8"); + for (const method of parseTypeScriptMethods(filePath, source)) { + const methodCoverage = coverageForMethod(coverage, method); + metrics.push({ + name: method.name, + className: method.className, + filePath, + complexity: method.complexity, + coverage: methodCoverage, + crapScore: calculateCrap(method.complexity, methodCoverage), + }); + } + } + return metrics; +} diff --git a/src/cli-application.ts b/src/cli-application.ts new file mode 100644 index 0000000..05789ef --- /dev/null +++ b/src/cli-application.ts @@ -0,0 +1,96 @@ +import { access } from "node:fs/promises"; +import path from "node:path"; + +import { analyzeFiles } from "./analyzer.ts"; +import { parseCliArguments } from "./cli-arguments.ts"; +import { ProcessCommandExecutor } from "./command-executor.ts"; +import { CoverageRunner } from "./coverage-runner.ts"; +import type { CoverageGenerator } from "./coverage-runner.ts"; +import { groupFilesByPackage } from "./package-grouper.ts"; +import { formatReport } from "./report-formatter.ts"; +import { detectChangedFiles, expandExplicitPaths, findDefaultSourceFiles } from "./source-file-finder.ts"; +import type { MethodMetrics } from "./types.ts"; + +const CRAP_THRESHOLD = 8; + +export interface CliContext { + projectRoot: string; + stdout: (text: string) => void; + stderr: (text: string) => void; + coverageRunner?: CoverageGenerator; +} + +export async function runCli(args: string[], context: CliContext): Promise { + let parsed; + try { + parsed = parseCliArguments(args); + } catch (error) { + context.stderr(`${errorMessage(error)}\n`); + context.stdout(usage()); + return 1; + } + + if (parsed.mode === "help") { + context.stdout(usage()); + return 0; + } + + const files = parsed.mode === "all" + ? await findDefaultSourceFiles(context.projectRoot) + : parsed.mode === "changed" + ? await detectChangedFiles(context.projectRoot) + : await expandExplicitPaths(context.projectRoot, parsed.paths); + + if (files.length === 0) { + context.stdout("No TypeScript files to analyze.\n"); + return 0; + } + + const coverageRunner = context.coverageRunner ?? new CoverageRunner(new ProcessCommandExecutor()); + const groups = await groupFilesByPackage(context.projectRoot, files); + const metrics: MethodMetrics[] = []; + for (const group of groups) { + const packageName = path.relative(context.projectRoot, group.packageRoot) || "."; + context.stdout(`Generating coverage for ${packageName}...\n`); + const coverageFile = await coverageRunner.generate(group.packageRoot, parsed.coveragePath, context.projectRoot); + if (!await exists(coverageFile)) { + context.stderr(`Warning: coverage file not found: ${coverageFile}; coverage will be N/A.\n`); + } + metrics.push(...await analyzeFiles(group.files, coverageFile)); + } + context.stdout(formatReport(metrics, context.projectRoot)); + + const maximum = maxCrap(metrics); + if (maximum > CRAP_THRESHOLD) { + context.stderr(`CRAP threshold exceeded: ${maximum.toFixed(2)} > ${CRAP_THRESHOLD.toFixed(2)}\n`); + return 2; + } + return 0; +} + +export function usage(): string { + return `Usage: + crap4ts Analyze TypeScript files under src/ + crap4ts --changed Analyze changed TypeScript files under src/ + crap4ts Analyze explicit files or directories + crap4ts --coverage [...] Set the package-relative Istanbul JSON destination + crap4ts --help Print this help message +`; +} + +function maxCrap(metrics: MethodMetrics[]): number { + return metrics.reduce((maximum, metric) => metric.crapScore === null ? maximum : Math.max(maximum, metric.crapScore), 0); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function exists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} diff --git a/src/cli-arguments.ts b/src/cli-arguments.ts new file mode 100644 index 0000000..affe1a2 --- /dev/null +++ b/src/cli-arguments.ts @@ -0,0 +1,48 @@ +import type { CliArguments } from "./types.ts"; + +const DEFAULT_COVERAGE_PATH = "coverage/coverage-final.json"; + +export function parseCliArguments(args: string[]): CliArguments { + const paths: string[] = []; + let changed = false; + let help = false; + let coveragePath = DEFAULT_COVERAGE_PATH; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--help" || argument === "-h") { + help = true; + } else if (argument === "--changed") { + changed = true; + } else if (argument === "--coverage") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("-")) { + throw new Error("--coverage requires a path"); + } + coveragePath = value; + index += 1; + } else if (argument.startsWith("-")) { + throw new Error(`Unknown option: ${argument}`); + } else { + paths.push(argument); + } + } + + if (help && (changed || paths.length > 0)) { + throw new Error("--help cannot be combined with other modes"); + } + if (changed && paths.length > 0) { + throw new Error("--changed cannot be combined with explicit paths"); + } + + if (help) { + return { mode: "help", paths: [], coveragePath }; + } + if (changed) { + return { mode: "changed", paths: [], coveragePath }; + } + if (paths.length > 0) { + return { mode: "paths", paths, coveragePath }; + } + return { mode: "all", paths: [], coveragePath }; +} diff --git a/src/command-executor.ts b/src/command-executor.ts new file mode 100644 index 0000000..5d7d250 --- /dev/null +++ b/src/command-executor.ts @@ -0,0 +1,27 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface CommandExecutor { + execute(command: string, args: string[], cwd: string): Promise; +} + +export class ProcessCommandExecutor implements CommandExecutor { + async execute(command: string, args: string[], cwd: string): Promise { + try { + await execFileAsync(command, args, { cwd, maxBuffer: 10 * 1024 * 1024 }); + } catch (error) { + throw new Error(commandFailure(command, args, error), { cause: error }); + } + } +} + +function commandFailure(command: string, args: string[], error: unknown): string { + const detail = error instanceof Error && "stderr" in error && typeof error.stderr === "string" + ? error.stderr.trim() + : error instanceof Error + ? error.message + : String(error); + return `Coverage command failed: ${[command, ...args].join(" ")}${detail.length > 0 ? `\n${detail}` : ""}`; +} diff --git a/src/coverage-runner.ts b/src/coverage-runner.ts new file mode 100644 index 0000000..eae4c3f --- /dev/null +++ b/src/coverage-runner.ts @@ -0,0 +1,73 @@ +import { rm } from "node:fs/promises"; +import path from "node:path"; + +import type { CommandExecutor } from "./command-executor.ts"; +import { detectPackageManager, detectTestFramework } from "./package-runtime.ts"; +import type { PackageManager, TestFramework } from "./package-runtime.ts"; + +export interface CoverageGenerator { + generate(packageRoot: string, coveragePath: string, projectRoot?: string): Promise; +} + +export class CoverageRunner implements CoverageGenerator { + private readonly executor: CommandExecutor; + + constructor(executor: CommandExecutor) { + this.executor = executor; + } + + async generate(packageRoot: string, coveragePath: string, projectRoot = packageRoot): Promise { + const normalizedRoot = path.resolve(packageRoot); + const reportPath = resolveReportPath(normalizedRoot, coveragePath); + const reportDirectory = path.dirname(reportPath); + await cleanStaleCoverage(normalizedRoot, reportPath); + + const packageManager = await detectPackageManager(normalizedRoot, projectRoot); + const testFramework = await detectTestFramework(normalizedRoot, projectRoot); + const relativeDirectory = toPortablePath(path.relative(normalizedRoot, reportDirectory) || "."); + const { command, args } = coverageCommand(packageManager, testFramework, relativeDirectory); + await this.executor.execute(command, args, normalizedRoot); + + return reportPath; + } +} + +function resolveReportPath(packageRoot: string, coveragePath: string): string { + if (path.isAbsolute(coveragePath)) { + throw new Error("Coverage path must be relative and inside the package"); + } + const reportPath = path.resolve(packageRoot, coveragePath); + const relative = path.relative(packageRoot, reportPath); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error("Coverage path must be inside the package"); + } + return reportPath; +} + +async function cleanStaleCoverage(packageRoot: string, reportPath: string): Promise { + const defaultReport = path.join(packageRoot, "coverage", "coverage-final.json"); + if (reportPath === defaultReport) { + await rm(path.dirname(reportPath), { recursive: true, force: true }); + } else { + await rm(reportPath, { force: true }); + } +} + +function coverageCommand( + packageManager: PackageManager, + testFramework: TestFramework, + reportDirectory: string, +): { command: string; args: string[] } { + const executableArgs = testFramework === "vitest" + ? ["vitest", "run", "--root=.", "--coverage", "--coverage.reporter=json", `--coverage.reportsDirectory=${reportDirectory}`] + : ["jest", "--rootDir=.", "--coverage", "--coverageReporters=json", `--coverageDirectory=${reportDirectory}`]; + + if (packageManager === "npm") { + return { command: "npm", args: ["exec", "--", ...executableArgs] }; + } + return { command: packageManager, args: ["exec", ...executableArgs] }; +} + +function toPortablePath(filePath: string): string { + return filePath.split(path.sep).join("/"); +} diff --git a/src/crap-score.ts b/src/crap-score.ts new file mode 100644 index 0000000..c56fce1 --- /dev/null +++ b/src/crap-score.ts @@ -0,0 +1,13 @@ +export function calculateCrap(complexity: number, coverage: number | null): number | null { + if (!Number.isInteger(complexity) || complexity < 1) { + throw new RangeError("Complexity must be an integer greater than or equal to 1"); + } + if (coverage === null) { + return null; + } + if (!Number.isFinite(coverage) || coverage < 0 || coverage > 1) { + throw new RangeError("Coverage must be between 0 and 1"); + } + + return complexity ** 2 * (1 - coverage) ** 3 + complexity; +} diff --git a/src/crap4java/ChangedFileDetector.java b/src/crap4java/ChangedFileDetector.java deleted file mode 100644 index 1094d21..0000000 --- a/src/crap4java/ChangedFileDetector.java +++ /dev/null @@ -1,120 +0,0 @@ -package crap4java; - -import java.io.IOException; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; - -final class ChangedFileDetector { - - private ChangedFileDetector() { - } - - static List changedJavaFiles(Path projectRoot) throws IOException, InterruptedException { - Process process = new ProcessBuilder("git", "-C", projectRoot.toString(), "status", "--porcelain") - .redirectErrorStream(true) - .start(); - - int exit = process.waitFor(); - if (exit != 0) { - String output = new String(process.getInputStream().readAllBytes()); - throw new IllegalStateException("git status failed: " + output); - } - - String output = new String(process.getInputStream().readAllBytes()); - List files = new ArrayList<>(); - for (String line : output.split("\\R")) { - Path file = parseStatusLine(projectRoot, line); - if (file != null) { - files.add(file); - } - } - files.sort(Path::compareTo); - return files; - } - - static List changedJavaFilesUnderSrc(Path projectRoot) throws IOException, InterruptedException { - return changedJavaFiles(projectRoot).stream() - .filter(path -> path.normalize().startsWith(projectRoot.resolve("src").normalize())) - .toList(); - } - - private static Path parseStatusLine(Path root, String line) { - if (!isCandidateLine(line)) { - return null; - } - String pathPart = line.substring(3).trim(); - String finalPath = renameTarget(pathPart); - if (!isJavaPath(finalPath)) { - return null; - } - return root.resolve(finalPath).normalize(); - } - - static boolean isCandidateLine(String line) { - if (line == null) { - return false; - } - if (line.isBlank()) { - return false; - } - return line.length() >= 4; - } - - static String renameTarget(String pathPart) { - int index = pathPart.indexOf(" -> "); - if (index < 0) { - return pathPart; - } - return pathPart.substring(index + 4); - } - - private static boolean isJavaPath(String path) { - return path.endsWith(".java"); - } -} - -/* mutate4java-manifest -version=1 -moduleHash=d6a200db9e9478a6b5be1b8ae4ba417ef0bb3feb428bf334a5029462e76fce49 -scope.0.id=Y2xhc3M6Q2hhbmdlZEZpbGVEZXRlY3RvciNDaGFuZ2VkRmlsZURldGVjdG9yOjg -scope.0.kind=class -scope.0.startLine=8 -scope.0.endLine=75 -scope.0.semanticHash=314b1b7d383824f09865a4814f458ca28b3bff55ca405f8d6a14db73bce3d80e -scope.1.id=bWV0aG9kOkNoYW5nZWRGaWxlRGV0ZWN0b3IjY2hhbmdlZEphdmFGaWxlcygxKToxMw -scope.1.kind=method -scope.1.startLine=13 -scope.1.endLine=34 -scope.1.semanticHash=8e8554ef51ed29e8fbc41e16c0b2279540d453c92ca270f25f1bf1ea7adf058b -scope.2.id=bWV0aG9kOkNoYW5nZWRGaWxlRGV0ZWN0b3IjY2hhbmdlZEphdmFGaWxlc1VuZGVyU3JjKDEpOjM2 -scope.2.kind=method -scope.2.startLine=36 -scope.2.endLine=40 -scope.2.semanticHash=964da5ec5598ea16203adfa61e65258cb353959cca24ffbf3e7016a015ef84c4 -scope.3.id=bWV0aG9kOkNoYW5nZWRGaWxlRGV0ZWN0b3IjY3RvcigwKToxMA -scope.3.kind=method -scope.3.startLine=10 -scope.3.endLine=11 -scope.3.semanticHash=8fe3abf26e99f2bd7186b30f9fe28659467f583d27b11adf97718417babfbefb -scope.4.id=bWV0aG9kOkNoYW5nZWRGaWxlRGV0ZWN0b3IjaXNDYW5kaWRhdGVMaW5lKDEpOjU0 -scope.4.kind=method -scope.4.startLine=54 -scope.4.endLine=62 -scope.4.semanticHash=b63e199ff7b921628fb65cc3b934e3938d92ce5d153227ef5e3e061968408b06 -scope.5.id=bWV0aG9kOkNoYW5nZWRGaWxlRGV0ZWN0b3IjaXNKYXZhUGF0aCgxKTo3Mg -scope.5.kind=method -scope.5.startLine=72 -scope.5.endLine=74 -scope.5.semanticHash=d6a306fcbb03b875c827caa8ff9b65330919721e0247ffc8f67de36907d6b621 -scope.6.id=bWV0aG9kOkNoYW5nZWRGaWxlRGV0ZWN0b3IjcGFyc2VTdGF0dXNMaW5lKDIpOjQy -scope.6.kind=method -scope.6.startLine=42 -scope.6.endLine=52 -scope.6.semanticHash=23c700724f3dd1a6161c3357c22492ad34072b011e35c655633737ff4af4434c -scope.7.id=bWV0aG9kOkNoYW5nZWRGaWxlRGV0ZWN0b3IjcmVuYW1lVGFyZ2V0KDEpOjY0 -scope.7.kind=method -scope.7.startLine=64 -scope.7.endLine=70 -scope.7.semanticHash=29da8c6dd6483a1004615c3364a5b239b115ea8298fe813a0bd5c64f88f8bd2b -*/ diff --git a/src/crap4java/CliApplication.java b/src/crap4java/CliApplication.java deleted file mode 100644 index c8f5ab7..0000000 --- a/src/crap4java/CliApplication.java +++ /dev/null @@ -1,253 +0,0 @@ -package crap4java; - -import java.io.PrintStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -final class CliApplication { - - private final Path projectRoot; - private final PrintStream out; - private final PrintStream err; - private final CoverageRunner coverageRunner; - - CliApplication(Path projectRoot, PrintStream out, PrintStream err, CoverageRunner coverageRunner) { - this.projectRoot = projectRoot; - this.out = out; - this.err = err; - this.coverageRunner = coverageRunner; - } - - int execute(String[] args) throws Exception { - ParseOutcome parse = parseArguments(args); - if (parse.exitCode >= 0) { - return parse.exitCode; - } - CliArguments parsed = parse.arguments; - List filesToAnalyze = filesForMode(parsed); - if (filesToAnalyze.isEmpty()) { - out.println("No Java files to analyze."); - return 0; - } - - List metrics = analyzeByModule(filesToAnalyze); - metrics.sort(Comparator.comparing(MethodMetrics::crapScore, - Comparator.nullsLast(Comparator.reverseOrder()))); - out.print(ReportFormatter.format(metrics)); - - double max = Main.maxCrap(metrics); - if (thresholdExceeded(max)) { - err.printf("CRAP threshold exceeded: %.1f > 8.0%n", max); - return 2; - } - return 0; - } - - private List analyzeByModule(List filesToAnalyze) throws Exception { - List metrics = new ArrayList<>(); - for (Map.Entry> entry : groupByModuleRoot(filesToAnalyze).entrySet()) { - Path moduleRoot = entry.getKey(); - Path jacocoXml = moduleRoot.resolve("target/site/jacoco/jacoco.xml"); - coverageRunner.generateCoverage(moduleRoot); - if (!Files.exists(jacocoXml)) { - err.println("Warning: JaCoCo XML not found at " + jacocoXml + ". Coverage will be N/A."); - } - metrics.addAll(CrapAnalyzer.analyze(moduleRoot, entry.getValue(), jacocoXml)); - } - return metrics; - } - - static boolean thresholdExceeded(double max) { - return Double.compare(max, 8.0) > 0; - } - - private ParseOutcome parseArguments(String[] args) { - try { - CliArguments parsed = CliArgumentsParser.parse(args); - if (parsed.mode() == CliMode.HELP) { - out.println(Main.usage()); - return ParseOutcome.exit(0); - } - return ParseOutcome.ok(parsed); - } catch (IllegalArgumentException ex) { - err.println(ex.getMessage()); - out.println(Main.usage()); - return ParseOutcome.exit(1); - } - } - - private List filesForMode(CliArguments parsed) throws Exception { - return switch (parsed.mode()) { - case ALL_SRC -> SourceFileFinder.findAllJavaFilesUnderSrc(projectRoot); - case CHANGED_SRC -> ChangedFileDetector.changedJavaFilesUnderSrc(projectRoot); - case EXPLICIT_FILES -> explicitFiles(parsed.fileArgs()); - case HELP -> List.of(); - }; - } - - private List explicitFiles(List args) throws Exception { - Set files = new LinkedHashSet<>(); - for (String arg : args) { - Path path = projectRoot.resolve(arg).normalize(); - if (Files.isDirectory(path)) { - files.addAll(SourceFileFinder.findAllJavaFilesUnderSrc(path)); - } else { - files.add(path); - } - } - List sorted = new ArrayList<>(files); - sorted.sort(Comparator.naturalOrder()); - return sorted; - } - - static Path moduleRootFor(Path workspaceRoot, Path file) { - Path normalizedWorkspaceRoot = workspaceRoot.normalize(); - Path current = Files.isDirectory(file) ? file.normalize() : file.normalize().getParent(); - while (current != null && current.startsWith(normalizedWorkspaceRoot)) { - if (Files.exists(current.resolve("pom.xml"))) { - return current; - } - current = current.getParent(); - } - return normalizedWorkspaceRoot; - } - - private Map> groupByModuleRoot(List filesToAnalyze) { - Map> grouped = new LinkedHashMap<>(); - for (Path file : filesToAnalyze) { - Path moduleRoot = moduleRootFor(projectRoot, file); - grouped.computeIfAbsent(moduleRoot, ignored -> new ArrayList<>()).add(file); - } - return grouped; - } - - private static final class ParseOutcome { - private final CliArguments arguments; - private final int exitCode; - - private ParseOutcome(CliArguments arguments, int exitCode) { - this.arguments = arguments; - this.exitCode = exitCode; - } - - private static ParseOutcome ok(CliArguments arguments) { - return new ParseOutcome(arguments, -1); - } - - private static ParseOutcome exit(int code) { - return new ParseOutcome(null, code); - } - } -} - -/* mutate4java-manifest -version=1 -moduleHash=5e28beaff6bbfa47827aba63f26186be35ab0a7211b3d96c9cffa37b93f2f64f -scope.0.id=Y2xhc3M6Q2xpQXBwbGljYXRpb24jQ2xpQXBwbGljYXRpb246MTQ -scope.0.kind=class -scope.0.startLine=14 -scope.0.endLine=148 -scope.0.semanticHash=72e3edb9c173f66282605ffd23efb26af90afc903be0c61f28b0d1bed8fdde20 -scope.1.id=Y2xhc3M6Q2xpQXBwbGljYXRpb24uUGFyc2VPdXRjb21lI1BhcnNlT3V0Y29tZToxMzE -scope.1.kind=class -scope.1.startLine=131 -scope.1.endLine=147 -scope.1.semanticHash=45ed2bfb66d7822af10a2384e04bae542509fb8fb1a46052b2bf49795e404530 -scope.2.id=ZmllbGQ6Q2xpQXBwbGljYXRpb24jY292ZXJhZ2VSdW5uZXI6MTk -scope.2.kind=field -scope.2.startLine=19 -scope.2.endLine=19 -scope.2.semanticHash=d92a1ba1476f655cf1babf2fbbc9b36f71fd57e400859cf09e46a1253a04c184 -scope.3.id=ZmllbGQ6Q2xpQXBwbGljYXRpb24jZXJyOjE4 -scope.3.kind=field -scope.3.startLine=18 -scope.3.endLine=18 -scope.3.semanticHash=0f12a462a677e93faaa05787bc46db2cace278490b3a801f721c167aedea712a -scope.4.id=ZmllbGQ6Q2xpQXBwbGljYXRpb24jb3V0OjE3 -scope.4.kind=field -scope.4.startLine=17 -scope.4.endLine=17 -scope.4.semanticHash=b98df4fbf291f7cd01ba32f6b30c169fc64c08011a73e48a271561a4fcdd0a52 -scope.5.id=ZmllbGQ6Q2xpQXBwbGljYXRpb24jcHJvamVjdFJvb3Q6MTY -scope.5.kind=field -scope.5.startLine=16 -scope.5.endLine=16 -scope.5.semanticHash=967df8631e20dcf5fe7b1534d2b220568e0e1c2f48d3990f2701b87b204eaad0 -scope.6.id=ZmllbGQ6Q2xpQXBwbGljYXRpb24uUGFyc2VPdXRjb21lI2FyZ3VtZW50czoxMzI -scope.6.kind=field -scope.6.startLine=132 -scope.6.endLine=132 -scope.6.semanticHash=661f16ad226990eadabf31eb84854855268c8a11339b14e3255dd5bba3147187 -scope.7.id=ZmllbGQ6Q2xpQXBwbGljYXRpb24uUGFyc2VPdXRjb21lI2V4aXRDb2RlOjEzMw -scope.7.kind=field -scope.7.startLine=133 -scope.7.endLine=133 -scope.7.semanticHash=9b365df939989346da53099623aa9608e776c5c7fa71f7e9a96132e7df1bbedf -scope.8.id=bWV0aG9kOkNsaUFwcGxpY2F0aW9uI2FuYWx5emVCeU1vZHVsZSgxKTo1Mw -scope.8.kind=method -scope.8.startLine=53 -scope.8.endLine=65 -scope.8.semanticHash=9e87960a5cb9616913a7fbe79fbb8ae27e79b3ca100d365c1fba9c9e51d549bc -scope.9.id=bWV0aG9kOkNsaUFwcGxpY2F0aW9uI2N0b3IoNCk6MjE -scope.9.kind=method -scope.9.startLine=21 -scope.9.endLine=26 -scope.9.semanticHash=f0fc877ed56783fd79a8d2c8590b731e35c0b16a80be0be16d30494d186d0b05 -scope.10.id=bWV0aG9kOkNsaUFwcGxpY2F0aW9uI2V4ZWN1dGUoMSk6Mjg -scope.10.kind=method -scope.10.startLine=28 -scope.10.endLine=51 -scope.10.semanticHash=e834a9410d6c93bdcfb4ccd07064562674c2afb34c8fb73d853070615253cd50 -scope.11.id=bWV0aG9kOkNsaUFwcGxpY2F0aW9uI2V4cGxpY2l0RmlsZXMoMSk6OTU -scope.11.kind=method -scope.11.startLine=95 -scope.11.endLine=108 -scope.11.semanticHash=e371db323f2ca72e6f886e53697121802f1d5f29e878d1fcec396241d98c0cd9 -scope.12.id=bWV0aG9kOkNsaUFwcGxpY2F0aW9uI2ZpbGVzRm9yTW9kZSgxKTo4Ng -scope.12.kind=method -scope.12.startLine=86 -scope.12.endLine=93 -scope.12.semanticHash=dcf1caca27fab7b7478c3f57fd0a2ab6036931b44af0a8b39691615b3f76d831 -scope.13.id=bWV0aG9kOkNsaUFwcGxpY2F0aW9uI2dyb3VwQnlNb2R1bGVSb290KDEpOjEyMg -scope.13.kind=method -scope.13.startLine=122 -scope.13.endLine=129 -scope.13.semanticHash=b162fe08460eea66f1c87678860bc8be1e90d225b5ae07016a4f0aa3103b5b20 -scope.14.id=bWV0aG9kOkNsaUFwcGxpY2F0aW9uI21vZHVsZVJvb3RGb3IoMik6MTEw -scope.14.kind=method -scope.14.startLine=110 -scope.14.endLine=120 -scope.14.semanticHash=a180b2afd49b317ec0ef05c2dbfaf083e513097e268f36ca534a50a27a886e8c -scope.15.id=bWV0aG9kOkNsaUFwcGxpY2F0aW9uI3BhcnNlQXJndW1lbnRzKDEpOjcx -scope.15.kind=method -scope.15.startLine=71 -scope.15.endLine=84 -scope.15.semanticHash=899bc3c351cfb3d2ad575d8fe4ba812948e282104bb60fb1412ba2c3861305f2 -scope.16.id=bWV0aG9kOkNsaUFwcGxpY2F0aW9uI3RocmVzaG9sZEV4Y2VlZGVkKDEpOjY3 -scope.16.kind=method -scope.16.startLine=67 -scope.16.endLine=69 -scope.16.semanticHash=00b761a57b4a4a66733929fb784fbe41d9760841f7ed75fb61ce54edf4f20ed7 -scope.17.id=bWV0aG9kOkNsaUFwcGxpY2F0aW9uLlBhcnNlT3V0Y29tZSNjdG9yKDIpOjEzNQ -scope.17.kind=method -scope.17.startLine=135 -scope.17.endLine=138 -scope.17.semanticHash=bac767bb702fe8b4a04ddc028c794d85ecb0968a4357fe90c0c152d76966a315 -scope.18.id=bWV0aG9kOkNsaUFwcGxpY2F0aW9uLlBhcnNlT3V0Y29tZSNleGl0KDEpOjE0NA -scope.18.kind=method -scope.18.startLine=144 -scope.18.endLine=146 -scope.18.semanticHash=10b36de9f002c3f16736f379c7754defec224dccb1cb16006d03a5e72aadcfab -scope.19.id=bWV0aG9kOkNsaUFwcGxpY2F0aW9uLlBhcnNlT3V0Y29tZSNvaygxKToxNDA -scope.19.kind=method -scope.19.startLine=140 -scope.19.endLine=142 -scope.19.semanticHash=a557a67f31a114feacb3bd934fb922508d7e77088f8afc84a424948584fa0c4f -*/ diff --git a/src/crap4java/CliArguments.java b/src/crap4java/CliArguments.java deleted file mode 100644 index e710ee6..0000000 --- a/src/crap4java/CliArguments.java +++ /dev/null @@ -1,31 +0,0 @@ -package crap4java; - -import java.util.List; - -record CliArguments(CliMode mode, List fileArgs) { -} - -/* mutate4java-manifest -version=1 -moduleHash=d1a406bd369d2573ff065574f27152f4a361ea4dc00c227f3bb7abfd75d2eaa9 -scope.0.id=Y2xhc3M6Q2xpQXJndW1lbnRzI0NsaUFyZ3VtZW50czo1 -scope.0.kind=class -scope.0.startLine=5 -scope.0.endLine=6 -scope.0.semanticHash=46a0476b8b3c65283f40ecaa583840e5677af56f04391655096ae2994a20dae9 -scope.1.id=ZmllbGQ6Q2xpQXJndW1lbnRzI2ZpbGVBcmdzOjU -scope.1.kind=field -scope.1.startLine=5 -scope.1.endLine=5 -scope.1.semanticHash=f80baf7e7953a4656e562aaf16e4f07371e3273850635d8563b47631b1dad17c -scope.2.id=ZmllbGQ6Q2xpQXJndW1lbnRzI21vZGU6NQ -scope.2.kind=field -scope.2.startLine=5 -scope.2.endLine=5 -scope.2.semanticHash=b09c0d200fb0d0f48cf18d6f308f4a995a0bdc16401be97c6b5ac3be74abcc23 -scope.3.id=bWV0aG9kOkNsaUFyZ3VtZW50cyNjdG9yKDIpOjU -scope.3.kind=method -scope.3.startLine=1 -scope.3.endLine=6 -scope.3.semanticHash=3c385dea36d6d24964b2feb1340e96cbf5c204aa5618b4b6aad43b65176dcab2 -*/ diff --git a/src/crap4java/CliArgumentsParser.java b/src/crap4java/CliArgumentsParser.java deleted file mode 100644 index 30d640d..0000000 --- a/src/crap4java/CliArgumentsParser.java +++ /dev/null @@ -1,89 +0,0 @@ -package crap4java; - -import java.util.ArrayList; -import java.util.List; - -final class CliArgumentsParser { - - private CliArgumentsParser() { - } - - static CliArguments parse(String[] args) { - if (args.length == 0) { - return new CliArguments(CliMode.ALL_SRC, List.of()); - } - - if (containsFlag(args, "--help")) { - return new CliArguments(CliMode.HELP, List.of()); - } - - boolean changed = containsFlag(args, "--changed"); - List values = nonFlagArgs(args); - ensureChangedIsNotCombined(changed, values); - if (changed) { - return new CliArguments(CliMode.CHANGED_SRC, List.of()); - } - return new CliArguments(CliMode.EXPLICIT_FILES, List.copyOf(values)); - } - - private static boolean containsFlag(String[] args, String flag) { - for (String arg : args) { - if (flag.equals(arg)) { - return true; - } - } - return false; - } - - private static List nonFlagArgs(String[] args) { - List values = new ArrayList<>(); - for (String arg : args) { - if (arg.startsWith("--")) { - continue; - } - values.add(arg); - } - return values; - } - - private static void ensureChangedIsNotCombined(boolean changed, List values) { - if (changed && !values.isEmpty()) { - throw new IllegalArgumentException("--changed cannot be combined with file arguments"); - } - } -} - -/* mutate4java-manifest -version=1 -moduleHash=c601a3269ac203989827cc53ee454fc10939fa53e57696dd51933588e9ffdd3c -scope.0.id=Y2xhc3M6Q2xpQXJndW1lbnRzUGFyc2VyI0NsaUFyZ3VtZW50c1BhcnNlcjo2 -scope.0.kind=class -scope.0.startLine=6 -scope.0.endLine=54 -scope.0.semanticHash=e1a985a18c271d3f33ed005b3954e9933a6b6a51a06728ba9f8015b2ed148726 -scope.1.id=bWV0aG9kOkNsaUFyZ3VtZW50c1BhcnNlciNjb250YWluc0ZsYWcoMik6Mjk -scope.1.kind=method -scope.1.startLine=29 -scope.1.endLine=36 -scope.1.semanticHash=e222c83f83779c94d458d47525f0e6d676f4c6be6b74c198d8b91c8bf3b95544 -scope.2.id=bWV0aG9kOkNsaUFyZ3VtZW50c1BhcnNlciNjdG9yKDApOjg -scope.2.kind=method -scope.2.startLine=8 -scope.2.endLine=9 -scope.2.semanticHash=c9502e5b2d38c24ae05d67ebe8ddde01d9ecd2bf449a91ac80aa7ba16421ee7d -scope.3.id=bWV0aG9kOkNsaUFyZ3VtZW50c1BhcnNlciNlbnN1cmVDaGFuZ2VkSXNOb3RDb21iaW5lZCgyKTo0OQ -scope.3.kind=method -scope.3.startLine=49 -scope.3.endLine=53 -scope.3.semanticHash=5cb2beb93b0a2fcc0eea65e584c43ac03abac646a4a1eb153dd697193f33f5c4 -scope.4.id=bWV0aG9kOkNsaUFyZ3VtZW50c1BhcnNlciNub25GbGFnQXJncygxKTozOA -scope.4.kind=method -scope.4.startLine=38 -scope.4.endLine=47 -scope.4.semanticHash=c552c2037213247b8fc707727d39c9091f857462f0e836d19bd293eca2ce05da -scope.5.id=bWV0aG9kOkNsaUFyZ3VtZW50c1BhcnNlciNwYXJzZSgxKToxMQ -scope.5.kind=method -scope.5.startLine=11 -scope.5.endLine=27 -scope.5.semanticHash=77f58a4ff446f7980fab2ce25b35a52a9dc246e814ae59e19e19a883f3d79d50 -*/ diff --git a/src/crap4java/CliMode.java b/src/crap4java/CliMode.java deleted file mode 100644 index 1b27888..0000000 --- a/src/crap4java/CliMode.java +++ /dev/null @@ -1,43 +0,0 @@ -package crap4java; - -enum CliMode { - HELP, - ALL_SRC, - CHANGED_SRC, - EXPLICIT_FILES -} - -/* mutate4java-manifest -version=1 -moduleHash=b99f62577d8971c5f14487baa101f1055ebb77d1bf600c910b6382793a94f0b2 -scope.0.id=Y2xhc3M6Q2xpTW9kZSNDbGlNb2RlOjM -scope.0.kind=class -scope.0.startLine=3 -scope.0.endLine=8 -scope.0.semanticHash=6ea13b62eb71d266847d00414775b63922d669609e1d513235ae4883b75bc85d -scope.1.id=ZmllbGQ6Q2xpTW9kZSNBTExfU1JDOjU -scope.1.kind=field -scope.1.startLine=5 -scope.1.endLine=5 -scope.1.semanticHash=f65b7c02e635b7067e16b735c656bb1ed0352b751b66d6ab78d207f211f40f40 -scope.2.id=ZmllbGQ6Q2xpTW9kZSNDSEFOR0VEX1NSQzo2 -scope.2.kind=field -scope.2.startLine=6 -scope.2.endLine=6 -scope.2.semanticHash=0835329e3f6b9aee73a82c21ce07e00a053319dd37455e85c01b195d057b5b42 -scope.3.id=ZmllbGQ6Q2xpTW9kZSNFWFBMSUNJVF9GSUxFUzo3 -scope.3.kind=field -scope.3.startLine=7 -scope.3.endLine=7 -scope.3.semanticHash=260255c67ac8d3c9d51ff26649dc988c3c50bfffea55c98aaf1882661dbc344b -scope.4.id=ZmllbGQ6Q2xpTW9kZSNIRUxQOjQ -scope.4.kind=field -scope.4.startLine=4 -scope.4.endLine=4 -scope.4.semanticHash=37a6760fa43caf5b1ea02f22251b1456c39e060a4918ba3e963dbde336f16148 -scope.5.id=bWV0aG9kOkNsaU1vZGUjY3RvcigwKToz -scope.5.kind=method -scope.5.startLine=1 -scope.5.endLine=8 -scope.5.semanticHash=609fa4706e485b81998ebaed94107e9749a41072ed14608e94667d31b6e8958f -*/ diff --git a/src/crap4java/CommandExecutor.java b/src/crap4java/CommandExecutor.java deleted file mode 100644 index b499869..0000000 --- a/src/crap4java/CommandExecutor.java +++ /dev/null @@ -1,23 +0,0 @@ -package crap4java; - -import java.nio.file.Path; -import java.util.List; - -interface CommandExecutor { - int run(List command, Path directory) throws Exception; -} - -/* mutate4java-manifest -version=1 -moduleHash=7c36597a09c1f3185368298c7e39f28e1b1d2d36b255bdea5b92cf7f234f7c4b -scope.0.id=Y2xhc3M6Q29tbWFuZEV4ZWN1dG9yI0NvbW1hbmRFeGVjdXRvcjo2 -scope.0.kind=class -scope.0.startLine=6 -scope.0.endLine=8 -scope.0.semanticHash=35250ada8d3a2b89a52b4e23b4d3a9fabb1673059a4f17d1a501036158ac87ca -scope.1.id=bWV0aG9kOkNvbW1hbmRFeGVjdXRvciNydW4oMik6Nw -scope.1.kind=method -scope.1.startLine=7 -scope.1.endLine=7 -scope.1.semanticHash=68f8449c642b534bef8b87213aee84fd1cd5fd880d70f000b3f8e80ae1c0fffc -*/ diff --git a/src/crap4java/CoverageData.java b/src/crap4java/CoverageData.java deleted file mode 100644 index 2900d83..0000000 --- a/src/crap4java/CoverageData.java +++ /dev/null @@ -1,42 +0,0 @@ -package crap4java; - -record CoverageData(int missedInstructions, int coveredInstructions) { - - double coveragePercent() { - int total = missedInstructions + coveredInstructions; - if (total == 0) { - return 0.0; - } - return (coveredInstructions * 100.0) / total; - } -} - -/* mutate4java-manifest -version=1 -moduleHash=433ba4b2abba61f792fc0181ff3b2a1f66756f0d6ef168cf5ec6e7f240f15494 -scope.0.id=Y2xhc3M6Q292ZXJhZ2VEYXRhI0NvdmVyYWdlRGF0YToz -scope.0.kind=class -scope.0.startLine=3 -scope.0.endLine=12 -scope.0.semanticHash=87aa606976f15abf19cea4b108d5f2609ee953e9d42b6e812fdb7e05d07d10f9 -scope.1.id=ZmllbGQ6Q292ZXJhZ2VEYXRhI2NvdmVyZWRJbnN0cnVjdGlvbnM6Mw -scope.1.kind=field -scope.1.startLine=3 -scope.1.endLine=3 -scope.1.semanticHash=4fcacc05e512f723247247f449ace2e375b58e82d3ca705adf551fa64c300594 -scope.2.id=ZmllbGQ6Q292ZXJhZ2VEYXRhI21pc3NlZEluc3RydWN0aW9uczoz -scope.2.kind=field -scope.2.startLine=3 -scope.2.endLine=3 -scope.2.semanticHash=11d1f0843b5d83d3d4a8a1cb697dac34ecc0bcd0a4df4534587d17759a9161b3 -scope.3.id=bWV0aG9kOkNvdmVyYWdlRGF0YSNjb3ZlcmFnZVBlcmNlbnQoMCk6NQ -scope.3.kind=method -scope.3.startLine=5 -scope.3.endLine=11 -scope.3.semanticHash=b5ca27864e09a536dd6af320d78e641fa3ce1fa6a384e2f2da4c94000bbe56df -scope.4.id=bWV0aG9kOkNvdmVyYWdlRGF0YSNjdG9yKDIpOjM -scope.4.kind=method -scope.4.startLine=1 -scope.4.endLine=12 -scope.4.semanticHash=8050ad331327c2af2636cf68c945923707350210aa3d77f632cb38acb645885a -*/ diff --git a/src/crap4java/CoverageRunner.java b/src/crap4java/CoverageRunner.java deleted file mode 100644 index 6a21041..0000000 --- a/src/crap4java/CoverageRunner.java +++ /dev/null @@ -1,81 +0,0 @@ -package crap4java; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Comparator; -import java.util.List; - -final class CoverageRunner { - - private final CommandExecutor executor; - - CoverageRunner(CommandExecutor executor) { - this.executor = executor; - } - - void generateCoverage(Path projectRoot) throws Exception { - deleteIfExists(projectRoot.resolve("target/site/jacoco")); - deleteIfExists(projectRoot.resolve("target/jacoco.exec")); - - int exit = executor.run(List.of( - "mvn", "-q", - "org.jacoco:jacoco-maven-plugin:0.8.12:prepare-agent", - "test", - "org.jacoco:jacoco-maven-plugin:0.8.12:report" - ), projectRoot); - if (exit != 0) { - throw new IllegalStateException("Coverage command failed with exit " + exit); - } - } - - private void deleteIfExists(Path path) throws IOException { - if (!Files.exists(path)) { - return; - } - if (Files.isDirectory(path)) { - try (var walk = Files.walk(path)) { - walk.sorted(Comparator.reverseOrder()) - .forEach(p -> { - try { - Files.deleteIfExists(p); - } catch (IOException ex) { - throw new IllegalStateException("Failed deleting stale coverage: " + p, ex); - } - }); - } - return; - } - Files.deleteIfExists(path); - } -} - -/* mutate4java-manifest -version=1 -moduleHash=080f89894e26acfa200b3c2dca10a0349ee9996687565dbfc43572b1d90a8eb1 -scope.0.id=Y2xhc3M6Q292ZXJhZ2VSdW5uZXIjQ292ZXJhZ2VSdW5uZXI6OQ -scope.0.kind=class -scope.0.startLine=9 -scope.0.endLine=51 -scope.0.semanticHash=222115fc5fd871d8ad974cf4c94cd0e52e88ee06fe1e89a8e2c6416610e667ec -scope.1.id=ZmllbGQ6Q292ZXJhZ2VSdW5uZXIjZXhlY3V0b3I6MTE -scope.1.kind=field -scope.1.startLine=11 -scope.1.endLine=11 -scope.1.semanticHash=c4eedf9e7c0e6dffb225b9db0a0ca739c26698121741ae20349559e2bc48602a -scope.2.id=bWV0aG9kOkNvdmVyYWdlUnVubmVyI2N0b3IoMSk6MTM -scope.2.kind=method -scope.2.startLine=13 -scope.2.endLine=15 -scope.2.semanticHash=24bd1d89a71e4c966e1f894918fbada2a751dbd894339d3832764a5b9ddf2608 -scope.3.id=bWV0aG9kOkNvdmVyYWdlUnVubmVyI2RlbGV0ZUlmRXhpc3RzKDEpOjMy -scope.3.kind=method -scope.3.startLine=32 -scope.3.endLine=50 -scope.3.semanticHash=3e9c1df528853970581858951a8747028bd49974850a38b37d4b10b3527140c5 -scope.4.id=bWV0aG9kOkNvdmVyYWdlUnVubmVyI2dlbmVyYXRlQ292ZXJhZ2UoMSk6MTc -scope.4.kind=method -scope.4.startLine=17 -scope.4.endLine=30 -scope.4.semanticHash=2370d9f69b2da165bd3ec40585384649f6d75eb3193c5a09baff7d25762fa826 -*/ diff --git a/src/crap4java/CrapAnalyzer.java b/src/crap4java/CrapAnalyzer.java deleted file mode 100644 index 1caa7d7..0000000 --- a/src/crap4java/CrapAnalyzer.java +++ /dev/null @@ -1,167 +0,0 @@ -package crap4java; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -final class CrapAnalyzer { - - private static final Pattern PACKAGE_PATTERN = Pattern.compile("(?m)^\\s*package\\s+([a-zA-Z_][\\w.]*)\\s*;"); - - private CrapAnalyzer() { - } - - static List analyze(Path projectRoot, List changedFiles, Path jacocoXml) throws IOException { - Map coverageMap = JacocoCoverageParser.parse(jacocoXml); - List metrics = new ArrayList<>(); - - for (Path file : changedFiles) { - if (!Files.exists(file)) { - continue; - } - String source = Files.readString(file); - String className = classNameFromSource(file, source); - List methods = JavaMethodParser.parse(className, source); - for (MethodDescriptor method : methods) { - Double coverage = lookupCoverage(coverageMap, className, method.name(), method.startLine()); - Double crap = CrapScore.calculate(method.complexity(), coverage); - metrics.add(new MethodMetrics(method.name(), className, method.complexity(), coverage, crap)); - } - } - - metrics.sort(Comparator.comparing(MethodMetrics::crapScore, - Comparator.nullsLast(Comparator.reverseOrder()))); - return metrics; - } - - static String classNameFromSource(Path file, String source) { - String simpleName = file.getFileName().toString().replaceFirst("\\.java$", ""); - Matcher matcher = PACKAGE_PATTERN.matcher(source); - if (!matcher.find()) { - return simpleName; - } - return matcher.group(1) + "." + simpleName; - } - - static Double lookupCoverage(Map coverageMap, - String className, - String methodName, - int line) { - Double exactCoverage = exactCoverage(coverageMap, className, methodName, line); - if (exactCoverage != null) { - return exactCoverage; - } - - CoverageData nearest = nearestCoverage(coverageMap, className, methodName, line); - if (nearest == null) { - return null; - } - return nearest.coveragePercent(); - } - - static Double exactCoverage(Map coverageMap, - String className, - String methodName, - int line) { - String exactKey = className + "#" + methodName + ":" + line; - CoverageData exact = coverageMap.get(exactKey); - if (exact == null) { - return null; - } - return exact.coveragePercent(); - } - - static CoverageData nearestCoverage(Map coverageMap, - String className, - String methodName, - int line) { - String prefix = className + "#" + methodName + ":"; - CoverageData nearest = null; - int nearestDistance = Integer.MAX_VALUE; - for (Map.Entry entry : coverageMap.entrySet()) { - String key = entry.getKey(); - if (!key.startsWith(prefix)) { - continue; - } - int jacocoLine = parseTrailingLine(key); - int distance = Math.abs(jacocoLine - line); - if (distance < nearestDistance) { - nearestDistance = distance; - nearest = entry.getValue(); - } - } - return nearest; - } - - static int parseTrailingLine(String key) { - int separator = key.lastIndexOf(':'); - if (separator < 0) { - return Integer.MAX_VALUE; - } - String lineText = key.substring(separator + 1); - if (lineText.isEmpty()) { - return Integer.MAX_VALUE; - } - try { - return Integer.parseInt(lineText); - } catch (NumberFormatException ex) { - return Integer.MAX_VALUE; - } - } -} - -/* mutate4java-manifest -version=1 -moduleHash=46c52126077b440bdeb3a9d7e9e96d62ba5ba39f6d5a8e52c37a9611fb4e5472 -scope.0.id=Y2xhc3M6Q3JhcEFuYWx5emVyI0NyYXBBbmFseXplcjoxMw -scope.0.kind=class -scope.0.startLine=13 -scope.0.endLine=117 -scope.0.semanticHash=6250b01e658e7385c7cd736192b5b7750c307fef9bf76a5b82f2e68f40e351cc -scope.1.id=ZmllbGQ6Q3JhcEFuYWx5emVyI1BBQ0tBR0VfUEFUVEVSTjoxNQ -scope.1.kind=field -scope.1.startLine=15 -scope.1.endLine=15 -scope.1.semanticHash=f68e81784bda450afae9841592611ed5ab70438167019556d7b43228d096ff2e -scope.2.id=bWV0aG9kOkNyYXBBbmFseXplciNhbmFseXplKDMpOjIw -scope.2.kind=method -scope.2.startLine=20 -scope.2.endLine=41 -scope.2.semanticHash=9817894754035fbaa337b9d73b70bee319da584e850b8efa44c672c8d27a25c0 -scope.3.id=bWV0aG9kOkNyYXBBbmFseXplciNjbGFzc05hbWVGcm9tU291cmNlKDIpOjQz -scope.3.kind=method -scope.3.startLine=43 -scope.3.endLine=50 -scope.3.semanticHash=815b935247b214866078b33c912be7c1d85f580bae95101447b397e5a162ab57 -scope.4.id=bWV0aG9kOkNyYXBBbmFseXplciNjdG9yKDApOjE3 -scope.4.kind=method -scope.4.startLine=17 -scope.4.endLine=18 -scope.4.semanticHash=4239165039ff734e78d5721158cba4fb480bd63519b0c9f393841dc6d397f477 -scope.5.id=bWV0aG9kOkNyYXBBbmFseXplciNleGFjdENvdmVyYWdlKDQpOjY4 -scope.5.kind=method -scope.5.startLine=68 -scope.5.endLine=78 -scope.5.semanticHash=14eb33c9a59b00104d7d7437435c2543411b16e5d6b0543501547f1df6d2ac17 -scope.6.id=bWV0aG9kOkNyYXBBbmFseXplciNsb29rdXBDb3ZlcmFnZSg0KTo1Mg -scope.6.kind=method -scope.6.startLine=52 -scope.6.endLine=66 -scope.6.semanticHash=e76e3b4434ed4eb1dcd67ed1abe792a6aeac52d1e2bee7f3ce9fb474860c9f33 -scope.7.id=bWV0aG9kOkNyYXBBbmFseXplciNuZWFyZXN0Q292ZXJhZ2UoNCk6ODA -scope.7.kind=method -scope.7.startLine=80 -scope.7.endLine=100 -scope.7.semanticHash=5605b5c1db815b66f699088064572a6d3d612461b0e8f12f43daaf60e0ed77e6 -scope.8.id=bWV0aG9kOkNyYXBBbmFseXplciNwYXJzZVRyYWlsaW5nTGluZSgxKToxMDI -scope.8.kind=method -scope.8.startLine=102 -scope.8.endLine=116 -scope.8.semanticHash=8b3365a8721f95688d6e1b9e66ee9b2bfe2a00905ebb8acc307492ca26e19588 -*/ diff --git a/src/crap4java/CrapScore.java b/src/crap4java/CrapScore.java deleted file mode 100644 index b199567..0000000 --- a/src/crap4java/CrapScore.java +++ /dev/null @@ -1,36 +0,0 @@ -package crap4java; - -final class CrapScore { - - private CrapScore() { - } - - static Double calculate(int complexity, Double coveragePercent) { - if (coveragePercent == null) { - return null; - } - double cc = complexity; - double uncovered = 1.0 - (coveragePercent / 100.0); - return (cc * cc * uncovered * uncovered * uncovered) + cc; - } -} - -/* mutate4java-manifest -version=1 -moduleHash=7b5549b918766b6d093d959777868af63c8e42636963918872d84b62ff8a2e4b -scope.0.id=Y2xhc3M6Q3JhcFNjb3JlI0NyYXBTY29yZToz -scope.0.kind=class -scope.0.startLine=3 -scope.0.endLine=16 -scope.0.semanticHash=5d7026673c6b59315c82ac037754aa1a75c53e1ed261bc9d3bf4d2079340a643 -scope.1.id=bWV0aG9kOkNyYXBTY29yZSNjYWxjdWxhdGUoMik6OA -scope.1.kind=method -scope.1.startLine=8 -scope.1.endLine=15 -scope.1.semanticHash=b6899c56720a2489fa82a88edbea39a4ed55addc0ea15883a701a8544f355568 -scope.2.id=bWV0aG9kOkNyYXBTY29yZSNjdG9yKDApOjU -scope.2.kind=method -scope.2.startLine=5 -scope.2.endLine=6 -scope.2.semanticHash=cdb5a31015beb2e9ff75ebdc8edb49a3c51443ab9d8442f5e7f80000d6426725 -*/ diff --git a/src/crap4java/JacocoCoverageParser.java b/src/crap4java/JacocoCoverageParser.java deleted file mode 100644 index ded44d6..0000000 --- a/src/crap4java/JacocoCoverageParser.java +++ /dev/null @@ -1,139 +0,0 @@ -package crap4java; - -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.Node; -import org.w3c.dom.NodeList; -import org.xml.sax.InputSource; - -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.XMLConstants; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.HashMap; -import java.util.Map; -import java.io.StringReader; - -final class JacocoCoverageParser { - - private JacocoCoverageParser() { - } - - static Map parse(Path jacocoXmlPath) { - if (jacocoXmlPath == null || !Files.exists(jacocoXmlPath)) { - return Map.of(); - } - - try { - DocumentBuilderFactory factory = newSecureFactory(); - - var builder = factory.newDocumentBuilder(); - builder.setEntityResolver((publicId, systemId) -> new InputSource(new StringReader(""))); - - Document document = builder.parse(jacocoXmlPath.toFile()); - NodeList classes = document.getElementsByTagName("class"); - Map coverage = new HashMap<>(); - - for (int i = 0; i < classes.getLength(); i++) { - Element classNode = (Element) classes.item(i); - String className = classNode.getAttribute("name").replace('/', '.'); - readClassMethods(classNode, className, coverage); - } - - return coverage; - } catch (Exception ex) { - throw new IllegalStateException("Unable to parse JaCoCo XML: " + jacocoXmlPath, ex); - } - } - - static DocumentBuilderFactory newSecureFactory() throws Exception { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", false); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", true); - factory.setXIncludeAware(false); - factory.setExpandEntityReferences(false); - return factory; - } - - private static void readClassMethods(Element classNode, String className, Map coverage) { - for (Node node = classNode.getFirstChild(); node != null; node = node.getNextSibling()) { - if (!(node instanceof Element method) || !"method".equals(method.getTagName())) { - continue; - } - CoverageData data = readInstructionCoverage(method); - if (data == null) { - continue; - } - String methodName = method.getAttribute("name"); - int line = parseInt(method.getAttribute("line")); - String key = className + "#" + methodName + ":" + line; - coverage.put(key, data); - } - } - - private static CoverageData readInstructionCoverage(Element method) { - for (Node node = method.getFirstChild(); node != null; node = node.getNextSibling()) { - if (!(node instanceof Element counter) || !"counter".equals(counter.getTagName())) { - continue; - } - if (!"INSTRUCTION".equals(counter.getAttribute("type"))) { - continue; - } - int missed = parseInt(counter.getAttribute("missed")); - int covered = parseInt(counter.getAttribute("covered")); - return new CoverageData(missed, covered); - } - return null; - } - - private static int parseInt(String value) { - try { - return Integer.parseInt(value); - } catch (NumberFormatException ex) { - return 0; - } - } -} - -/* mutate4java-manifest -version=1 -moduleHash=89c8255a0561b87fdf14286d330be4817d8fb08a347ee2a574ac82c65607fe49 -scope.0.id=Y2xhc3M6SmFjb2NvQ292ZXJhZ2VQYXJzZXIjSmFjb2NvQ292ZXJhZ2VQYXJzZXI6MTc -scope.0.kind=class -scope.0.startLine=17 -scope.0.endLine=99 -scope.0.semanticHash=7a041ae74f905520042a21029844c38d2e98b358cb11a7984966f3d275e08f6e -scope.1.id=bWV0aG9kOkphY29jb0NvdmVyYWdlUGFyc2VyI2N0b3IoMCk6MTk -scope.1.kind=method -scope.1.startLine=19 -scope.1.endLine=20 -scope.1.semanticHash=61be911b081ef883e381fd6346be6a373947794f858c51ca4541b93bc3741a6d -scope.2.id=bWV0aG9kOkphY29jb0NvdmVyYWdlUGFyc2VyI25ld1NlY3VyZUZhY3RvcnkoMCk6NDk -scope.2.kind=method -scope.2.startLine=49 -scope.2.endLine=59 -scope.2.semanticHash=c9dc905126becb0e2b7db2c2c6eafc9ea5ddcef64b11ed9fbe0b3531a9ae948e -scope.3.id=bWV0aG9kOkphY29jb0NvdmVyYWdlUGFyc2VyI3BhcnNlKDEpOjIy -scope.3.kind=method -scope.3.startLine=22 -scope.3.endLine=47 -scope.3.semanticHash=b20f6f004048d7d1a50f968e14d702c8384dd41e1ccbb634774217993407f650 -scope.4.id=bWV0aG9kOkphY29jb0NvdmVyYWdlUGFyc2VyI3BhcnNlSW50KDEpOjky -scope.4.kind=method -scope.4.startLine=92 -scope.4.endLine=98 -scope.4.semanticHash=4fb4b91404f9947d53aba51c47969c26e995c14a9f5bac278d8fff8e64566c4b -scope.5.id=bWV0aG9kOkphY29jb0NvdmVyYWdlUGFyc2VyI3JlYWRDbGFzc01ldGhvZHMoMyk6NjE -scope.5.kind=method -scope.5.startLine=61 -scope.5.endLine=75 -scope.5.semanticHash=7f3eae1e1b53a1b95e7055fb10d760c7435722007057cc9bd2732afe218a1060 -scope.6.id=bWV0aG9kOkphY29jb0NvdmVyYWdlUGFyc2VyI3JlYWRJbnN0cnVjdGlvbkNvdmVyYWdlKDEpOjc3 -scope.6.kind=method -scope.6.startLine=77 -scope.6.endLine=90 -scope.6.semanticHash=5203c9e5151a091f31321e790016a36e0df58cfffe959b853983686f37aa1a86 -*/ diff --git a/src/crap4java/JavaMethodParser.java b/src/crap4java/JavaMethodParser.java deleted file mode 100644 index a4d6204..0000000 --- a/src/crap4java/JavaMethodParser.java +++ /dev/null @@ -1,366 +0,0 @@ -package crap4java; - -import com.sun.source.tree.BinaryTree; -import com.sun.source.tree.CaseTree; -import com.sun.source.tree.CatchTree; -import com.sun.source.tree.ClassTree; -import com.sun.source.tree.CompilationUnitTree; -import com.sun.source.tree.ConditionalExpressionTree; -import com.sun.source.tree.DoWhileLoopTree; -import com.sun.source.tree.EnhancedForLoopTree; -import com.sun.source.tree.ForLoopTree; -import com.sun.source.tree.IfTree; -import com.sun.source.tree.MethodTree; -import com.sun.source.tree.Tree; -import com.sun.source.tree.WhileLoopTree; -import com.sun.source.util.JavacTask; -import com.sun.source.util.SourcePositions; -import com.sun.source.util.TreeScanner; -import com.sun.source.util.TreePathScanner; -import com.sun.source.util.Trees; - -import javax.tools.JavaCompiler; -import javax.tools.SimpleJavaFileObject; -import javax.tools.ToolProvider; -import java.io.IOException; -import java.io.UncheckedIOException; -import java.net.URI; -import java.util.ArrayList; -import java.util.List; - -final class JavaMethodParser { - - private JavaMethodParser() { - } - - static List parse(String className, String source) { - JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); - if (compiler == null) { - throw new IllegalStateException("No system Java compiler is available"); - } - - try { - JavacTask task = (JavacTask) compiler.getTask( - null, - null, - null, - List.of("-proc:none"), - null, - List.of(new SourceFileObject(className, source)) - ); - Iterable units = task.parse(); - return collectMethods(task, units); - } catch (IOException ex) { - throw new UncheckedIOException(ex); - } - } - - static String sourcePath(String className) { - String normalized = className.endsWith(".java") - ? className.substring(0, className.length() - ".java".length()) - : className; - return normalized.replace('.', '/') + ".java"; - } - - static URI sourceUri(String className) { - return URI.create("string:///" + sourcePath(className)); - } - - private static List collectMethods(JavacTask task, - Iterable units) { - Trees trees = Trees.instance(task); - List methods = new ArrayList<>(); - for (CompilationUnitTree unit : units) { - SourcePositions positions = trees.getSourcePositions(); - new MethodScanner(unit, positions, methods).scan(unit, null); - } - return methods; - } - - private static final class MethodScanner extends TreePathScanner { - private final CompilationUnitTree unit; - private final SourcePositions positions; - private final List methods; - - private MethodScanner(CompilationUnitTree unit, - SourcePositions positions, - List methods) { - this.unit = unit; - this.positions = positions; - this.methods = methods; - } - - @Override - public Void visitMethod(MethodTree node, Void unused) { - if (node.getBody() == null || node.getReturnType() == null) { - return null; - } - - long start = positions.getStartPosition(unit, node); - long bodyEndExclusive = positions.getEndPosition(unit, node.getBody()); - int startLine = lineNumber(start); - int endLine = lineNumber(Math.decrementExact((int) bodyEndExclusive)); - int complexity = ComplexityCounter.count(node); - methods.add(new MethodDescriptor(node.getName().toString(), startLine, endLine, complexity)); - return null; - } - - private int lineNumber(long position) { - return (int) unit.getLineMap().getLineNumber(position); - } - } - - private static final class ComplexityCounter extends TreeScanner { - private int complexity = 1; - - static int count(MethodTree method) { - ComplexityCounter counter = new ComplexityCounter(); - counter.scan(method.getBody(), null); - return counter.complexity; - } - - @Override - public Void visitClass(ClassTree node, Void unused) { - return null; - } - - @Override - public Void visitIf(IfTree node, Void unused) { - complexity++; - return super.visitIf(node, unused); - } - - @Override - public Void visitForLoop(ForLoopTree node, Void unused) { - complexity++; - return super.visitForLoop(node, unused); - } - - @Override - public Void visitEnhancedForLoop(EnhancedForLoopTree node, Void unused) { - complexity++; - return super.visitEnhancedForLoop(node, unused); - } - - @Override - public Void visitWhileLoop(WhileLoopTree node, Void unused) { - complexity++; - return super.visitWhileLoop(node, unused); - } - - @Override - public Void visitDoWhileLoop(DoWhileLoopTree node, Void unused) { - complexity++; - return super.visitDoWhileLoop(node, unused); - } - - @Override - public Void visitCatch(CatchTree node, Void unused) { - complexity++; - return super.visitCatch(node, unused); - } - - @Override - public Void visitConditionalExpression(ConditionalExpressionTree node, Void unused) { - complexity++; - return super.visitConditionalExpression(node, unused); - } - - @Override - public Void visitCase(CaseTree node, Void unused) { - complexity++; - return super.visitCase(node, unused); - } - - @Override - public Void visitBinary(BinaryTree node, Void unused) { - if (node.getKind() == Tree.Kind.CONDITIONAL_AND || node.getKind() == Tree.Kind.CONDITIONAL_OR) { - complexity++; - } - return super.visitBinary(node, unused); - } - } - - private static final class SourceFileObject extends SimpleJavaFileObject { - private final String source; - - private SourceFileObject(String className, String source) { - super(uriFor(className), Kind.SOURCE); - this.source = source; - } - - @Override - public CharSequence getCharContent(boolean ignoreEncodingErrors) { - return source; - } - - private static URI uriFor(String className) { - return sourceUri(className); - } - } -} - -/* mutate4java-manifest -version=1 -moduleHash=d914c00de7c89d6f6fdc7231333611dae24dbb66a5341bf5bbd9053801057b78 -scope.0.id=Y2xhc3M6SmF2YU1ldGhvZFBhcnNlciNKYXZhTWV0aG9kUGFyc2VyOjMx -scope.0.kind=class -scope.0.startLine=31 -scope.0.endLine=201 -scope.0.semanticHash=aab451eb5965cb96028bcea6cc71e6c9596b2534dc4a28d6f63bca30b525b57e -scope.1.id=Y2xhc3M6SmF2YU1ldGhvZFBhcnNlci5Db21wbGV4aXR5Q291bnRlciNDb21wbGV4aXR5Q291bnRlcjoxMTM -scope.1.kind=class -scope.1.startLine=113 -scope.1.endLine=182 -scope.1.semanticHash=b94e9cb6b24f6756c9a69b23de6ce9ba22b3c24398fc8ae71814f85082c05d4b -scope.2.id=Y2xhc3M6SmF2YU1ldGhvZFBhcnNlci5NZXRob2RTY2FubmVyI01ldGhvZFNjYW5uZXI6ODA -scope.2.kind=class -scope.2.startLine=80 -scope.2.endLine=111 -scope.2.semanticHash=c7d07a66d1c0c3df561c1dcf379f2aaad37363ac8d50597bc9540f545428efc4 -scope.3.id=Y2xhc3M6SmF2YU1ldGhvZFBhcnNlci5Tb3VyY2VGaWxlT2JqZWN0I1NvdXJjZUZpbGVPYmplY3Q6MTg0 -scope.3.kind=class -scope.3.startLine=184 -scope.3.endLine=200 -scope.3.semanticHash=dadb510103c340be278fe60e5c0d4f7e8e209054e008db13c17016156c268a5d -scope.4.id=ZmllbGQ6SmF2YU1ldGhvZFBhcnNlci5Db21wbGV4aXR5Q291bnRlciNjb21wbGV4aXR5OjExNA -scope.4.kind=field -scope.4.startLine=114 -scope.4.endLine=114 -scope.4.semanticHash=18ca06eabf338ade3ab97a617c98059ad90e5386133273b1581d6b783e62b7ec -scope.5.id=ZmllbGQ6SmF2YU1ldGhvZFBhcnNlci5NZXRob2RTY2FubmVyI21ldGhvZHM6ODM -scope.5.kind=field -scope.5.startLine=83 -scope.5.endLine=83 -scope.5.semanticHash=a8852d9d0a2c75bc2767eea7bff8aa5314f9ea024d0126bb78f6366c0ab881e6 -scope.6.id=ZmllbGQ6SmF2YU1ldGhvZFBhcnNlci5NZXRob2RTY2FubmVyI3Bvc2l0aW9uczo4Mg -scope.6.kind=field -scope.6.startLine=82 -scope.6.endLine=82 -scope.6.semanticHash=11d24a035a0249c15f1eba3cc289abe3b810f802f070c94bbbf22647f2926958 -scope.7.id=ZmllbGQ6SmF2YU1ldGhvZFBhcnNlci5NZXRob2RTY2FubmVyI3VuaXQ6ODE -scope.7.kind=field -scope.7.startLine=81 -scope.7.endLine=81 -scope.7.semanticHash=cdd8f46db86b41140edba9a893fe20cf64ab033feea2435ffd078df74d8abc74 -scope.8.id=ZmllbGQ6SmF2YU1ldGhvZFBhcnNlci5Tb3VyY2VGaWxlT2JqZWN0I3NvdXJjZToxODU -scope.8.kind=field -scope.8.startLine=185 -scope.8.endLine=185 -scope.8.semanticHash=97d0b5d76eb96c0b49baebabd2d8302b3f93dd5e9b34c3bf876e0156e47dafb5 -scope.9.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIjY29sbGVjdE1ldGhvZHMoMik6Njk -scope.9.kind=method -scope.9.startLine=69 -scope.9.endLine=78 -scope.9.semanticHash=b64ff25353d8b2c5581c7104a09dae1b94d580e5996449e4536e5864b4eb7b94 -scope.10.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIjY3RvcigwKTozMw -scope.10.kind=method -scope.10.startLine=33 -scope.10.endLine=34 -scope.10.semanticHash=0658575a3eeef68782d16a62516566aaa3fe3c90b33f73e13d5f86a3ffc0bbf9 -scope.11.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIjcGFyc2UoMik6MzY -scope.11.kind=method -scope.11.startLine=36 -scope.11.endLine=56 -scope.11.semanticHash=3e8cff6d9e86583140b4e9fafa5dc3dfea882c61926047e0d3304ac1060bfa4e -scope.12.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIjc291cmNlUGF0aCgxKTo1OA -scope.12.kind=method -scope.12.startLine=58 -scope.12.endLine=63 -scope.12.semanticHash=f5db6a2f4bc5d497203bb6f49102cf10f92e7c950f5ab57c094e41dd18a2cc63 -scope.13.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIjc291cmNlVXJpKDEpOjY1 -scope.13.kind=method -scope.13.startLine=65 -scope.13.endLine=67 -scope.13.semanticHash=d7be384ad2cce9c59973c1a0bea112d18230fa5bb6d7fffd1f4afd99ff68cbd0 -scope.14.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIuQ29tcGxleGl0eUNvdW50ZXIjY291bnQoMSk6MTE2 -scope.14.kind=method -scope.14.startLine=116 -scope.14.endLine=120 -scope.14.semanticHash=82dbabb06f257ec9984e442129571183b9bbe4dbfb902688e5d12e9a2fd9f848 -scope.15.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIuQ29tcGxleGl0eUNvdW50ZXIjY3RvcigwKToxMTM -scope.15.kind=method -scope.15.startLine=1 -scope.15.endLine=201 -scope.15.semanticHash=7a7890b718377b500410628980221377ef77c74aacdddd4967b80a395c8dc5ea -scope.16.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIuQ29tcGxleGl0eUNvdW50ZXIjdmlzaXRCaW5hcnkoMik6MTc1 -scope.16.kind=method -scope.16.startLine=175 -scope.16.endLine=181 -scope.16.semanticHash=3aa334a63397ffc2b4180dc69672f8c290da006aebcfc4fae250887e1a245e01 -scope.17.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIuQ29tcGxleGl0eUNvdW50ZXIjdmlzaXRDYXNlKDIpOjE2OQ -scope.17.kind=method -scope.17.startLine=169 -scope.17.endLine=173 -scope.17.semanticHash=80a0ce054be49df2164d08fa6b401cac5396c5b8d43048243da313bc3841db6a -scope.18.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIuQ29tcGxleGl0eUNvdW50ZXIjdmlzaXRDYXRjaCgyKToxNTc -scope.18.kind=method -scope.18.startLine=157 -scope.18.endLine=161 -scope.18.semanticHash=d4ce681a7e334a2579c066458ee3670ff733e9a9793b1faff063cb83e9f06999 -scope.19.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIuQ29tcGxleGl0eUNvdW50ZXIjdmlzaXRDbGFzcygyKToxMjI -scope.19.kind=method -scope.19.startLine=122 -scope.19.endLine=125 -scope.19.semanticHash=1cb70adaf6b3db790116f4b7f70ac84b972ca1771cb616ff4dcc64c0d05cd62c -scope.20.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIuQ29tcGxleGl0eUNvdW50ZXIjdmlzaXRDb25kaXRpb25hbEV4cHJlc3Npb24oMik6MTYz -scope.20.kind=method -scope.20.startLine=163 -scope.20.endLine=167 -scope.20.semanticHash=b66e0e50dd8edb682d855aadbafe227d28fd2b8f68b518649767366a1fbba6e1 -scope.21.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIuQ29tcGxleGl0eUNvdW50ZXIjdmlzaXREb1doaWxlTG9vcCgyKToxNTE -scope.21.kind=method -scope.21.startLine=151 -scope.21.endLine=155 -scope.21.semanticHash=4bd673c7f67707366449b4d6051f42a660a36b662f092105fca5436c0b81ccfc -scope.22.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIuQ29tcGxleGl0eUNvdW50ZXIjdmlzaXRFbmhhbmNlZEZvckxvb3AoMik6MTM5 -scope.22.kind=method -scope.22.startLine=139 -scope.22.endLine=143 -scope.22.semanticHash=f2dc7fc12e39c4b263dfe692754bcd0f3c036845635d39996342b57d168cf37b -scope.23.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIuQ29tcGxleGl0eUNvdW50ZXIjdmlzaXRGb3JMb29wKDIpOjEzMw -scope.23.kind=method -scope.23.startLine=133 -scope.23.endLine=137 -scope.23.semanticHash=c7779d692a4137fc764022e4d537434b6bd8893a34a52a4a5a07324655689ccc -scope.24.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIuQ29tcGxleGl0eUNvdW50ZXIjdmlzaXRJZigyKToxMjc -scope.24.kind=method -scope.24.startLine=127 -scope.24.endLine=131 -scope.24.semanticHash=3254fbf20e8e5d2e41f473321ab49459d4fab4a74e392f3d30f787632d65db73 -scope.25.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIuQ29tcGxleGl0eUNvdW50ZXIjdmlzaXRXaGlsZUxvb3AoMik6MTQ1 -scope.25.kind=method -scope.25.startLine=145 -scope.25.endLine=149 -scope.25.semanticHash=07c7422d7f0c9967680800641b836698b74c397f62c6eae08b009ba074d6d764 -scope.26.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIuTWV0aG9kU2Nhbm5lciNjdG9yKDMpOjg1 -scope.26.kind=method -scope.26.startLine=85 -scope.26.endLine=91 -scope.26.semanticHash=db5b8cdacc76177a9ae583fdf6036ba254a258d6a79ce33179d6cfe48afc5906 -scope.27.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIuTWV0aG9kU2Nhbm5lciNsaW5lTnVtYmVyKDEpOjEwOA -scope.27.kind=method -scope.27.startLine=108 -scope.27.endLine=110 -scope.27.semanticHash=f30ccd8ff3ff335fad27d2f85d07fb0bf1ee5ede4ee4164d2c0e8f7231249ee0 -scope.28.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIuTWV0aG9kU2Nhbm5lciN2aXNpdE1ldGhvZCgyKTo5Mw -scope.28.kind=method -scope.28.startLine=93 -scope.28.endLine=106 -scope.28.semanticHash=bbe49d37a7c8c06d62b347168fb6ad979edc2ca4a5c4816ec1721a4f8e3f7c7b -scope.29.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIuU291cmNlRmlsZU9iamVjdCNjdG9yKDIpOjE4Nw -scope.29.kind=method -scope.29.startLine=187 -scope.29.endLine=190 -scope.29.semanticHash=c8f775e9f1bb24f841231efef7e215250d9337f8d4e5985a682fedb3830d6f50 -scope.30.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIuU291cmNlRmlsZU9iamVjdCNnZXRDaGFyQ29udGVudCgxKToxOTI -scope.30.kind=method -scope.30.startLine=192 -scope.30.endLine=195 -scope.30.semanticHash=d568c345f5828ec3f1f3bbb9b0dec63e452559441e187fac047170903e271f69 -scope.31.id=bWV0aG9kOkphdmFNZXRob2RQYXJzZXIuU291cmNlRmlsZU9iamVjdCN1cmlGb3IoMSk6MTk3 -scope.31.kind=method -scope.31.startLine=197 -scope.31.endLine=199 -scope.31.semanticHash=db07fb454026dcad04b66420dd4817c60bf6b74e63f2b77521d7be08281c128d -*/ diff --git a/src/crap4java/Main.java b/src/crap4java/Main.java deleted file mode 100644 index 132c61a..0000000 --- a/src/crap4java/Main.java +++ /dev/null @@ -1,87 +0,0 @@ -package crap4java; - -import java.io.PrintStream; -import java.nio.file.Path; -import java.util.List; - -public final class Main { - - private Main() { - } - - public static void main(String[] args) throws Exception { - System.exit(run(args, Path.of(".").toAbsolutePath().normalize(), System.out, System.err)); - } - - static int run(String[] args, Path projectRoot, PrintStream out, PrintStream err) throws Exception { - return run(args, projectRoot, out, err, new CoverageRunner(new ProcessCommandExecutor())); - } - - static int run(String[] args, - Path projectRoot, - PrintStream out, - PrintStream err, - CoverageRunner coverageRunner) throws Exception { - return new CliApplication(projectRoot, out, err, coverageRunner).execute(args); - } - - static String usage() { - return """ - Usage: - crap4java Analyze all Java files under src/ - crap4java --changed Analyze changed Java files under src/ - crap4java Analyze files, or for directory args analyze /src/**/*.java - crap4java --help Print this help message - """; - } - - static double maxCrap(List metrics) { - double max = 0.0; - for (MethodMetrics metric : metrics) { - if (metric.crapScore() != null) { - max = Math.max(max, metric.crapScore()); - } - } - return max; - } -} - -/* mutate4java-manifest -version=1 -moduleHash=e33f577606041952eb9ddde846aa921b7b03fa24e487e2a834540f64b992914c -scope.0.id=Y2xhc3M6TWFpbiNNYWluOjc -scope.0.kind=class -scope.0.startLine=7 -scope.0.endLine=47 -scope.0.semanticHash=4958ec2d89a67c8cf8abbdba7cf1f27c2ed0a96f892b959933533c0dae70c14c -scope.1.id=bWV0aG9kOk1haW4jY3RvcigwKTo5 -scope.1.kind=method -scope.1.startLine=9 -scope.1.endLine=10 -scope.1.semanticHash=2a7894b82bf05c8917e82420e502ab2cc96fef2366eeef066911514202ec3bd1 -scope.2.id=bWV0aG9kOk1haW4jbWFpbigxKToxMg -scope.2.kind=method -scope.2.startLine=12 -scope.2.endLine=14 -scope.2.semanticHash=ad0b8e92af29f222e8dd39931244c11c92fec3df537a67a48b20b5f66ff38418 -scope.3.id=bWV0aG9kOk1haW4jbWF4Q3JhcCgxKTozOA -scope.3.kind=method -scope.3.startLine=38 -scope.3.endLine=46 -scope.3.semanticHash=9dc86a46e1bbd3c811f8c5fac1df658311b061ea8bb0d5d0eb6988e64d2b48a1 -scope.4.id=bWV0aG9kOk1haW4jcnVuKDQpOjE2 -scope.4.kind=method -scope.4.startLine=16 -scope.4.endLine=18 -scope.4.semanticHash=cc7423c4434171101ff7f748e37f11409b415f102f126ddf5a1f9df77c2b278f -scope.5.id=bWV0aG9kOk1haW4jcnVuKDUpOjIw -scope.5.kind=method -scope.5.startLine=20 -scope.5.endLine=26 -scope.5.semanticHash=026c69ed083265062059d1580ebc97d39024947bd7dc82141053916115a169ef -scope.6.id=bWV0aG9kOk1haW4jdXNhZ2UoMCk6Mjg -scope.6.kind=method -scope.6.startLine=28 -scope.6.endLine=36 -scope.6.semanticHash=e96b9f53b2599d50ee02f75be6e027bc903b4bd00a18a0ee334a1f9a36b56e0f -*/ diff --git a/src/crap4java/MethodDescriptor.java b/src/crap4java/MethodDescriptor.java deleted file mode 100644 index 5ac40d8..0000000 --- a/src/crap4java/MethodDescriptor.java +++ /dev/null @@ -1,44 +0,0 @@ -package crap4java; - -record MethodDescriptor( - String name, - int startLine, - int endLine, - int complexity -) { -} - -/* mutate4java-manifest -version=1 -moduleHash=7ac8848772b2c1a22da3d64cf5e472e0a81a43ca6872e8727a8416adc0356d69 -scope.0.id=Y2xhc3M6TWV0aG9kRGVzY3JpcHRvciNNZXRob2REZXNjcmlwdG9yOjM -scope.0.kind=class -scope.0.startLine=3 -scope.0.endLine=9 -scope.0.semanticHash=4b109993762858ca2ef9665ccc4f992bc025b7e4f93ea3dc759dba589e7a2fc8 -scope.1.id=ZmllbGQ6TWV0aG9kRGVzY3JpcHRvciNjb21wbGV4aXR5Ojc -scope.1.kind=field -scope.1.startLine=7 -scope.1.endLine=7 -scope.1.semanticHash=525e126091077815ac3ca3d9daf9dcd2873354cc1204c1bec369d5ea3c1d61d8 -scope.2.id=ZmllbGQ6TWV0aG9kRGVzY3JpcHRvciNlbmRMaW5lOjY -scope.2.kind=field -scope.2.startLine=6 -scope.2.endLine=6 -scope.2.semanticHash=6a317d712b10858871edee8b33ee58b8a682069bbab781a96d93cf4abe131abe -scope.3.id=ZmllbGQ6TWV0aG9kRGVzY3JpcHRvciNuYW1lOjQ -scope.3.kind=field -scope.3.startLine=4 -scope.3.endLine=4 -scope.3.semanticHash=28e8b9d0b6d83cf0ec13b6130883495dc7fce33f007e60550987b5da71347153 -scope.4.id=ZmllbGQ6TWV0aG9kRGVzY3JpcHRvciNzdGFydExpbmU6NQ -scope.4.kind=field -scope.4.startLine=5 -scope.4.endLine=5 -scope.4.semanticHash=1c3439a97a0bcc4e6560a484ca45e0482b4b1010943d82fa235aa4a63d21ffb1 -scope.5.id=bWV0aG9kOk1ldGhvZERlc2NyaXB0b3IjY3Rvcig0KToz -scope.5.kind=method -scope.5.startLine=1 -scope.5.endLine=9 -scope.5.semanticHash=d404da9a29f78b0d65959a81fcf6fb03d19f86bd65962488df0884a18ce69050 -*/ diff --git a/src/crap4java/MethodMetrics.java b/src/crap4java/MethodMetrics.java deleted file mode 100644 index 440cc89..0000000 --- a/src/crap4java/MethodMetrics.java +++ /dev/null @@ -1,50 +0,0 @@ -package crap4java; - -record MethodMetrics( - String methodName, - String className, - int complexity, - Double coveragePercent, - Double crapScore -) { -} - -/* mutate4java-manifest -version=1 -moduleHash=c14c616559fc225dfb95013ef98f43b109edb2e857dddcf59a696e2913d45ddf -scope.0.id=Y2xhc3M6TWV0aG9kTWV0cmljcyNNZXRob2RNZXRyaWNzOjM -scope.0.kind=class -scope.0.startLine=3 -scope.0.endLine=10 -scope.0.semanticHash=2eab30ca44c535e25c50c68c88abb446d3e4d9052b239530ef10b931b7226641 -scope.1.id=ZmllbGQ6TWV0aG9kTWV0cmljcyNjbGFzc05hbWU6NQ -scope.1.kind=field -scope.1.startLine=5 -scope.1.endLine=5 -scope.1.semanticHash=f443a976f0a493ec25f6b5bed022df2129866874b971248fe85fdcfa673a2d24 -scope.2.id=ZmllbGQ6TWV0aG9kTWV0cmljcyNjb21wbGV4aXR5OjY -scope.2.kind=field -scope.2.startLine=6 -scope.2.endLine=6 -scope.2.semanticHash=525e126091077815ac3ca3d9daf9dcd2873354cc1204c1bec369d5ea3c1d61d8 -scope.3.id=ZmllbGQ6TWV0aG9kTWV0cmljcyNjb3ZlcmFnZVBlcmNlbnQ6Nw -scope.3.kind=field -scope.3.startLine=7 -scope.3.endLine=7 -scope.3.semanticHash=6fe17226f74508c76f9b743c1a6bddd144acaf4ede3520d84fc04db2216ec6e4 -scope.4.id=ZmllbGQ6TWV0aG9kTWV0cmljcyNjcmFwU2NvcmU6OA -scope.4.kind=field -scope.4.startLine=8 -scope.4.endLine=8 -scope.4.semanticHash=9d5b6e046cc82ebfadf65a303be821e5e42fb4a4e76edb31f40fddf770d598c2 -scope.5.id=ZmllbGQ6TWV0aG9kTWV0cmljcyNtZXRob2ROYW1lOjQ -scope.5.kind=field -scope.5.startLine=4 -scope.5.endLine=4 -scope.5.semanticHash=af57a516a4dc672e52803affabb2baeab6f17bb9399d92de1a2445b6d00b5bc5 -scope.6.id=bWV0aG9kOk1ldGhvZE1ldHJpY3MjY3Rvcig1KToz -scope.6.kind=method -scope.6.startLine=1 -scope.6.endLine=10 -scope.6.semanticHash=d7b8e2a24a7ae4a8bec62381c9fb1a7d2f5f505fe539e0237c58c274a8e5a8c4 -*/ diff --git a/src/crap4java/ProcessCommandExecutor.java b/src/crap4java/ProcessCommandExecutor.java deleted file mode 100644 index 7fdf3b7..0000000 --- a/src/crap4java/ProcessCommandExecutor.java +++ /dev/null @@ -1,36 +0,0 @@ -package crap4java; - -import java.nio.file.Path; -import java.util.List; - -final class ProcessCommandExecutor implements CommandExecutor { - - @Override - public int run(List command, Path directory) throws Exception { - Process process = new ProcessBuilder(command) - .directory(directory.toFile()) - .inheritIO() - .start(); - return process.waitFor(); - } -} - -/* mutate4java-manifest -version=1 -moduleHash=6ea27d2229d3c3ec428dc6c5fff6f319242e088f19dd9369a4f1992ce2a80266 -scope.0.id=Y2xhc3M6UHJvY2Vzc0NvbW1hbmRFeGVjdXRvciNQcm9jZXNzQ29tbWFuZEV4ZWN1dG9yOjY -scope.0.kind=class -scope.0.startLine=6 -scope.0.endLine=16 -scope.0.semanticHash=d49ac5bf424aba3412a65e6fa49929921a017705f4c243d18d5828d46ca74e6e -scope.1.id=bWV0aG9kOlByb2Nlc3NDb21tYW5kRXhlY3V0b3IjY3RvcigwKTo2 -scope.1.kind=method -scope.1.startLine=1 -scope.1.endLine=16 -scope.1.semanticHash=d1a3877e6063504f6423a8dc876b39079a1832a2c7bc3f3145646bd3e99dd29c -scope.2.id=bWV0aG9kOlByb2Nlc3NDb21tYW5kRXhlY3V0b3IjcnVuKDIpOjg -scope.2.kind=method -scope.2.startLine=8 -scope.2.endLine=15 -scope.2.semanticHash=7be95a2db36c31ff28eb73e5aad6fcc4dbdd9b03cc3ece380c4a6aa13599060c -*/ diff --git a/src/crap4java/ReportFormatter.java b/src/crap4java/ReportFormatter.java deleted file mode 100644 index 53c5613..0000000 --- a/src/crap4java/ReportFormatter.java +++ /dev/null @@ -1,81 +0,0 @@ -package crap4java; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; - -final class ReportFormatter { - - private ReportFormatter() { - } - - static String format(List entries) { - List sorted = new ArrayList<>(entries); - sorted.sort(Comparator - .comparing((MethodMetrics e) -> e.crapScore() == null) - .thenComparing(e -> e.crapScore() == null ? 0.0 : -e.crapScore())); - - String header = String.format("%-30s %-35s %4s %7s %8s", "Method", "Class", "CC", "Cov%", "CRAP"); - String separator = "-".repeat(header.length()); - StringBuilder builder = new StringBuilder(); - builder.append("CRAP Report\n"); - builder.append("===========\n"); - builder.append(header).append('\n'); - builder.append(separator).append('\n'); - - for (MethodMetrics entry : sorted) { - builder.append(String.format("%-30s %-35s %4d %7s %8s%n", - entry.methodName(), - entry.className(), - entry.complexity(), - formatCoverage(entry.coveragePercent()), - formatCrap(entry.crapScore()))); - } - - return builder.toString(); - } - - private static String formatCoverage(Double coverage) { - if (coverage == null) { - return " N/A "; - } - return String.format("%5.1f%%", coverage); - } - - private static String formatCrap(Double score) { - if (score == null) { - return " N/A"; - } - return String.format("%8.1f", score); - } -} - -/* mutate4java-manifest -version=1 -moduleHash=234cae2941192c749c70d48c3471a374171a410c6b742f86d5d4421740a57d87 -scope.0.id=Y2xhc3M6UmVwb3J0Rm9ybWF0dGVyI1JlcG9ydEZvcm1hdHRlcjo3 -scope.0.kind=class -scope.0.startLine=7 -scope.0.endLine=51 -scope.0.semanticHash=8c1c8f1d2f2db26e60e42029c46d35b4a57c10aad221fb0a0356ff61cd3ac220 -scope.1.id=bWV0aG9kOlJlcG9ydEZvcm1hdHRlciNjdG9yKDApOjk -scope.1.kind=method -scope.1.startLine=9 -scope.1.endLine=10 -scope.1.semanticHash=c56635cb154ff589ba6ac24da4e4c4a2db58eca3647b903e9b2ab77eba09d75a -scope.2.id=bWV0aG9kOlJlcG9ydEZvcm1hdHRlciNmb3JtYXQoMSk6MTI -scope.2.kind=method -scope.2.startLine=12 -scope.2.endLine=36 -scope.2.semanticHash=e3a5b649082a7c17a62ad89f11eb9a55b8c5036a76da5dcd9826e87a8cd29f3c -scope.3.id=bWV0aG9kOlJlcG9ydEZvcm1hdHRlciNmb3JtYXRDb3ZlcmFnZSgxKTozOA -scope.3.kind=method -scope.3.startLine=38 -scope.3.endLine=43 -scope.3.semanticHash=652fd86d3d77e080ecc589ecc894f712d33d3d4da5305491a168c4a85ff416b1 -scope.4.id=bWV0aG9kOlJlcG9ydEZvcm1hdHRlciNmb3JtYXRDcmFwKDEpOjQ1 -scope.4.kind=method -scope.4.startLine=45 -scope.4.endLine=50 -scope.4.semanticHash=813ed619e148f5a4b581e58e800b15e24f5f3b15f06804f22cb7df9fe7d32309 -*/ diff --git a/src/crap4java/SourceFileFinder.java b/src/crap4java/SourceFileFinder.java deleted file mode 100644 index 8e57b41..0000000 --- a/src/crap4java/SourceFileFinder.java +++ /dev/null @@ -1,47 +0,0 @@ -package crap4java; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Comparator; -import java.util.List; - -final class SourceFileFinder { - - private SourceFileFinder() { - } - - static List findAllJavaFilesUnderSrc(Path projectRoot) throws IOException { - Path src = projectRoot.resolve("src"); - if (!Files.exists(src)) { - return List.of(); - } - - try (var stream = Files.walk(src)) { - return stream - .filter(path -> path.toString().endsWith(".java")) - .sorted(Comparator.naturalOrder()) - .toList(); - } - } -} - -/* mutate4java-manifest -version=1 -moduleHash=21199b1e7988ac2b433d1228947d962a5a2af272adf722ee5f55cd2eeb385784 -scope.0.id=Y2xhc3M6U291cmNlRmlsZUZpbmRlciNTb3VyY2VGaWxlRmluZGVyOjk -scope.0.kind=class -scope.0.startLine=9 -scope.0.endLine=27 -scope.0.semanticHash=e44d77790f24780d1574e94b00ec8055d64dda3940dee87617bd65fdc1033cbb -scope.1.id=bWV0aG9kOlNvdXJjZUZpbGVGaW5kZXIjY3RvcigwKToxMQ -scope.1.kind=method -scope.1.startLine=11 -scope.1.endLine=12 -scope.1.semanticHash=952989561249035658f6719d569bc70bd2b9d10da263124c47f965e77064bb82 -scope.2.id=bWV0aG9kOlNvdXJjZUZpbGVGaW5kZXIjZmluZEFsbEphdmFGaWxlc1VuZGVyU3JjKDEpOjE0 -scope.2.kind=method -scope.2.startLine=14 -scope.2.endLine=26 -scope.2.semanticHash=a11a3345bb541c4571b681b1447f0496b1f16c9b46d9f0449ca2162039b2e078 -*/ diff --git a/src/istanbul-coverage.ts b/src/istanbul-coverage.ts new file mode 100644 index 0000000..f1f8612 --- /dev/null +++ b/src/istanbul-coverage.ts @@ -0,0 +1,126 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import type { MethodDescriptor } from "./types.ts"; + +interface Position { + line: number; + column: number; +} + +interface Location { + start: Position; + end: Position; +} + +interface FunctionLocation { + name: string; + decl: { start: Position }; + loc: Location; +} + +interface IstanbulFileCoverage { + path: string; + statementMap: Record; + s: Record; + fnMap: Record; + f: Record; +} + +export type CoverageIndex = Map; + +export async function parseIstanbulCoverage(coverageFile: string): Promise { + let text: string; + try { + text = await readFile(coverageFile, "utf8"); + } catch (error) { + if (isMissingFile(error)) { + return new Map(); + } + throw error; + } + + const parsed: unknown = JSON.parse(text); + if (!isRecord(parsed)) { + throw new Error(`Invalid Istanbul coverage file: ${coverageFile}`); + } + + const result: CoverageIndex = new Map(); + for (const [key, value] of Object.entries(parsed)) { + if (!isRecord(value)) { + continue; + } + const fileCoverage = value as unknown as IstanbulFileCoverage; + const coveredPath = typeof fileCoverage.path === "string" ? fileCoverage.path : key; + result.set(path.resolve(coveredPath), { + path: coveredPath, + statementMap: fileCoverage.statementMap ?? {}, + s: fileCoverage.s ?? {}, + fnMap: fileCoverage.fnMap ?? {}, + f: fileCoverage.f ?? {}, + }); + } + return result; +} + +export function coverageForMethod(index: CoverageIndex, method: MethodDescriptor): number | null { + const fileCoverage = findFileCoverage(index, method.filePath); + if (fileCoverage === undefined) { + return null; + } + + const statementIds = Object.entries(fileCoverage.statementMap) + .filter(([, location]) => location.start.line >= method.startLine && location.end.line <= method.endLine) + .map(([id]) => id); + if (statementIds.length > 0) { + const covered = statementIds.filter((id) => (fileCoverage.s[id] ?? 0) > 0).length; + return covered / statementIds.length; + } + + const functionId = nearestFunctionId(fileCoverage, method); + if (functionId === null) { + return null; + } + return (fileCoverage.f[functionId] ?? 0) > 0 ? 1 : 0; +} + +function findFileCoverage(index: CoverageIndex, filePath: string): IstanbulFileCoverage | undefined { + const absolute = path.resolve(filePath); + const exact = index.get(absolute); + if (exact !== undefined) { + return exact; + } + + const normalized = absolute.split(path.sep).join("/"); + const suffixMatches = [...index.entries()].filter(([candidate]) => { + const normalizedCandidate = candidate.split(path.sep).join("/"); + return normalized.endsWith(`/${normalizedCandidate}`) || normalizedCandidate.endsWith(`/${normalized}`); + }); + return suffixMatches.length === 1 ? suffixMatches[0][1] : undefined; +} + +function nearestFunctionId(fileCoverage: IstanbulFileCoverage, method: MethodDescriptor): string | null { + let bestId: string | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (const [id, entry] of Object.entries(fileCoverage.fnMap)) { + const sameName = entry.name === method.name; + const containsStart = entry.loc.start.line <= method.startLine && entry.loc.end.line >= method.startLine; + if (!sameName && !containsStart) { + continue; + } + const distance = Math.abs(entry.decl.start.line - method.startLine); + if (distance < bestDistance) { + bestId = id; + bestDistance = distance; + } + } + return bestId; +} + +function isMissingFile(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..0e8deb0 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +import { runCli } from "./cli-application.ts"; + +try { + process.exitCode = await runCli(process.argv.slice(2), { + projectRoot: process.cwd(), + stdout: (text) => process.stdout.write(text), + stderr: (text) => process.stderr.write(text), + }); +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} diff --git a/src/package-grouper.ts b/src/package-grouper.ts new file mode 100644 index 0000000..49ee96a --- /dev/null +++ b/src/package-grouper.ts @@ -0,0 +1,63 @@ +import { access } from "node:fs/promises"; +import path from "node:path"; + +export interface PackageGroup { + packageRoot: string; + files: string[]; +} + +export async function groupFilesByPackage(projectRoot: string, files: string[]): Promise { + const normalizedRoot = path.resolve(projectRoot); + const grouped = new Map(); + + for (const file of files) { + const absoluteFile = path.resolve(file); + const packageRoot = await nearestPackageRoot(normalizedRoot, path.dirname(absoluteFile)); + const packageFiles = grouped.get(packageRoot) ?? []; + packageFiles.push(absoluteFile); + grouped.set(packageRoot, packageFiles); + } + + return [...grouped.entries()] + .sort(([left], [right]) => comparePaths(left, right)) + .map(([packageRoot, packageFiles]) => ({ + packageRoot, + files: [...new Set(packageFiles)].sort(comparePaths), + })); +} + +async function nearestPackageRoot(projectRoot: string, start: string): Promise { + let current = path.resolve(start); + while (isWithin(projectRoot, current)) { + if (await exists(path.join(current, "package.json"))) { + return current; + } + if (current === projectRoot) { + break; + } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + return projectRoot; +} + +function isWithin(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); +} + +async function exists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +function comparePaths(left: string, right: string): number { + return left.localeCompare(right, "en"); +} diff --git a/src/package-runtime.ts b/src/package-runtime.ts new file mode 100644 index 0000000..23b4357 --- /dev/null +++ b/src/package-runtime.ts @@ -0,0 +1,127 @@ +import { access, readFile } from "node:fs/promises"; +import path from "node:path"; + +export type PackageManager = "npm" | "pnpm" | "yarn"; +export type TestFramework = "vitest" | "jest"; + +interface PackageJson { + packageManager?: unknown; + scripts?: Record; + dependencies?: Record; + devDependencies?: Record; +} + +export async function detectPackageManager(packageRoot: string, projectRoot = packageRoot): Promise { + const roots = ancestorPackageRoots(packageRoot, projectRoot); + for (const root of roots) { + const packageJson = await readPackageJsonIfPresent(root); + if (typeof packageJson?.packageManager === "string") { + const name = packageJson.packageManager.split("@", 1)[0]; + if (name === "npm" || name === "pnpm" || name === "yarn") { + return name; + } + throw new Error(`Unsupported package manager configured in ${root}: ${name}`); + } + } + + for (const root of roots) { + if (await exists(path.join(root, "pnpm-lock.yaml"))) { + return "pnpm"; + } + if (await exists(path.join(root, "yarn.lock"))) { + return "yarn"; + } + if (await exists(path.join(root, "package-lock.json"))) { + return "npm"; + } + } + return "npm"; +} + +export async function detectTestFramework(packageRoot: string, projectRoot = packageRoot): Promise { + for (const root of ancestorPackageRoots(packageRoot, projectRoot)) { + const packageJson = await readPackageJsonIfPresent(root); + if (packageJson === null) { + continue; + } + const testScript = typeof packageJson.scripts?.test === "string" ? packageJson.scripts.test : ""; + if (/\bvitest\b/u.test(testScript)) { + return "vitest"; + } + if (/\bjest(?:\.js)?\b/u.test(testScript)) { + return "jest"; + } + + const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies }; + if ("vitest" in dependencies) { + return "vitest"; + } + if ("jest" in dependencies) { + return "jest"; + } + } + throw new Error(`No supported test framework found in ${packageRoot}; install or configure Vitest or Jest`); +} + +function ancestorPackageRoots(packageRoot: string, projectRoot: string): string[] { + const start = path.resolve(packageRoot); + const boundary = path.resolve(projectRoot); + const relative = path.relative(boundary, start); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + return [start]; + } + + const roots: string[] = []; + let current = start; + while (true) { + roots.push(current); + if (current === boundary) { + break; + } + current = path.dirname(current); + } + return roots; +} + +async function readPackageJsonIfPresent(packageRoot: string): Promise { + try { + return await readPackageJson(packageRoot); + } catch (error) { + if (error instanceof Error && error.message.startsWith("package.json not found")) { + return null; + } + throw error; + } +} + +async function readPackageJson(packageRoot: string): Promise { + const filePath = path.join(packageRoot, "package.json"); + let text: string; + try { + text = await readFile(filePath, "utf8"); + } catch (error) { + if (isMissingFile(error)) { + throw new Error(`package.json not found in ${packageRoot}`, { cause: error }); + } + throw error; + } + + const value: unknown = JSON.parse(text); + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`Invalid package.json in ${packageRoot}`); + } + return value as PackageJson; +} + +async function exists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +function isMissingFile(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} diff --git a/src/report-formatter.ts b/src/report-formatter.ts new file mode 100644 index 0000000..f3fdf55 --- /dev/null +++ b/src/report-formatter.ts @@ -0,0 +1,36 @@ +import path from "node:path"; + +import type { MethodMetrics } from "./types.ts"; + +export function sortMetrics(metrics: MethodMetrics[]): MethodMetrics[] { + return [...metrics].sort((left, right) => { + if (left.crapScore === null && right.crapScore === null) { + return methodLabel(left).localeCompare(methodLabel(right)); + } + if (left.crapScore === null) { + return 1; + } + if (right.crapScore === null) { + return -1; + } + return right.crapScore - left.crapScore || methodLabel(left).localeCompare(methodLabel(right)); + }); +} + +export function formatReport(metrics: MethodMetrics[], projectRoot: string): string { + const rows = sortMetrics(metrics).map((metric) => [ + methodLabel(metric), + path.relative(projectRoot, metric.filePath) || path.basename(metric.filePath), + String(metric.complexity), + metric.coverage === null ? "N/A" : `${(metric.coverage * 100).toFixed(1)}%`, + metric.crapScore === null ? "N/A" : metric.crapScore.toFixed(2), + ]); + const headers = ["Method", "File", "CC", "Coverage", "CRAP"]; + const widths = headers.map((header, column) => Math.max(header.length, ...rows.map((row) => row[column].length))); + const render = (row: string[]): string => row.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd(); + return `${render(headers)}\n${widths.map((width) => "-".repeat(width)).join(" ")}\n${rows.map(render).join("\n")}\n`; +} + +function methodLabel(metric: MethodMetrics): string { + return metric.className === null ? metric.name : `${metric.className}.${metric.name}`; +} diff --git a/src/source-file-finder.ts b/src/source-file-finder.ts new file mode 100644 index 0000000..ea5f6cf --- /dev/null +++ b/src/source-file-finder.ts @@ -0,0 +1,150 @@ +import { execFile } from "node:child_process"; +import { readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const EXCLUDED_DIRECTORIES = new Set(["node_modules", "dist", "build", "coverage", ".git", "test", "tests", "__tests__"]); + +export async function findDefaultSourceFiles(projectRoot: string): Promise { + return collectWorkspaceSourceFiles(path.resolve(projectRoot)); +} + +export async function expandExplicitPaths(projectRoot: string, entries: string[]): Promise { + const files = new Set(); + for (const entry of entries) { + const absolute = path.resolve(projectRoot, entry); + let details; + try { + details = await stat(absolute); + } catch (error) { + if (isMissingFile(error)) { + continue; + } + throw error; + } + if (details.isFile() && isTypeScriptSource(absolute)) { + files.add(absolute); + } else if (details.isDirectory()) { + for (const file of await collectSourceFiles(absolute)) { + files.add(file); + } + } + } + return [...files].sort(comparePaths); +} + +export async function detectChangedFiles(projectRoot: string): Promise { + const { stdout } = await execFileAsync("git", ["status", "--porcelain"], { cwd: projectRoot }); + return parseChangedFiles(projectRoot, stdout); +} + +export function parseChangedFiles(projectRoot: string, statusOutput: string): string[] { + const files = new Set(); + for (const line of statusOutput.split(/\r?\n/u)) { + if (line.length < 4) { + continue; + } + const status = line.slice(0, 2); + if (status.includes("D")) { + continue; + } + let fileName = line.slice(3).trim(); + const renameSeparator = fileName.lastIndexOf(" -> "); + if (renameSeparator >= 0) { + fileName = fileName.slice(renameSeparator + 4); + } + fileName = unquoteGitPath(fileName); + const normalized = fileName.split("\\").join("/"); + if (!isUnderSourceTree(normalized) || !isTypeScriptSource(normalized)) { + continue; + } + files.add(path.resolve(projectRoot, fileName)); + } + return [...files].sort(comparePaths); +} + +function isUnderSourceTree(fileName: string): boolean { + const segments = fileName.split("/"); + return segments.length > 1 && segments.slice(0, -1).includes("src"); +} + +async function collectSourceFiles(root: string): Promise { + let entries; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch (error) { + if (isMissingFile(error)) { + return []; + } + throw error; + } + + const files: string[] = []; + for (const entry of entries) { + const absolute = path.join(root, entry.name); + if (entry.isDirectory()) { + if (!shouldExcludeDirectory(entry.name)) { + files.push(...await collectSourceFiles(absolute)); + } + } else if (entry.isFile() && isTypeScriptSource(entry.name)) { + files.push(absolute); + } + } + return files.sort(comparePaths); +} + +async function collectWorkspaceSourceFiles(root: string): Promise { + let entries; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch (error) { + if (isMissingFile(error)) { + return []; + } + throw error; + } + + const files: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory() || shouldExcludeDirectory(entry.name)) { + continue; + } + const absolute = path.join(root, entry.name); + if (entry.name === "src") { + files.push(...await collectSourceFiles(absolute)); + } else { + files.push(...await collectWorkspaceSourceFiles(absolute)); + } + } + return files.sort(comparePaths); +} + +function shouldExcludeDirectory(directoryName: string): boolean { + return EXCLUDED_DIRECTORIES.has(directoryName) || directoryName.startsWith("."); +} + +function isTypeScriptSource(fileName: string): boolean { + const lower = fileName.toLowerCase(); + return (lower.endsWith(".ts") || lower.endsWith(".tsx")) + && !lower.endsWith(".d.ts") + && !lower.endsWith(".test.ts") + && !lower.endsWith(".test.tsx") + && !lower.endsWith(".spec.ts") + && !lower.endsWith(".spec.tsx"); +} + +function unquoteGitPath(fileName: string): string { + if (fileName.startsWith('"') && fileName.endsWith('"')) { + return fileName.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\"); + } + return fileName; +} + +function comparePaths(left: string, right: string): number { + return left.localeCompare(right, "en"); +} + +function isMissingFile(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..480b1ee --- /dev/null +++ b/src/types.ts @@ -0,0 +1,25 @@ +export interface MethodDescriptor { + name: string; + className: string | null; + filePath: string; + startLine: number; + endLine: number; + complexity: number; +} + +export interface MethodMetrics { + name: string; + className: string | null; + filePath: string; + complexity: number; + coverage: number | null; + crapScore: number | null; +} + +export type CliMode = "all" | "changed" | "paths" | "help"; + +export interface CliArguments { + mode: CliMode; + paths: string[]; + coveragePath: string; +} diff --git a/src/typescript-method-parser.ts b/src/typescript-method-parser.ts new file mode 100644 index 0000000..ae82a92 --- /dev/null +++ b/src/typescript-method-parser.ts @@ -0,0 +1,144 @@ +import * as ts from "typescript"; + +import type { MethodDescriptor } from "./types.ts"; + +type ConcreteFunction = + | ts.FunctionDeclaration + | ts.FunctionExpression + | ts.ArrowFunction + | ts.MethodDeclaration + | ts.GetAccessorDeclaration + | ts.SetAccessorDeclaration; + +export function parseTypeScriptMethods(filePath: string, source: string): MethodDescriptor[] { + const scriptKind = filePath.toLowerCase().endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, scriptKind); + const methods: MethodDescriptor[] = []; + + function collect(node: ts.Node, className: string | null): void { + const nextClassName = classNameForNode(node, className); + if (isConcreteFunction(node)) { + const name = functionName(node); + if (name !== null) { + const startLine = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; + const endLine = sourceFile.getLineAndCharacterOfPosition(node.end).line + 1; + methods.push({ + name, + className: isClassMember(node) || ts.isPropertyDeclaration(node.parent) ? nextClassName : null, + filePath, + startLine, + endLine, + complexity: cyclomaticComplexity(node), + }); + } + } + ts.forEachChild(node, (child) => collect(child, nextClassName)); + } + + collect(sourceFile, null); + return methods; +} + +function classNameForNode(node: ts.Node, current: string | null): string | null { + if ((ts.isClassDeclaration(node) || ts.isClassExpression(node)) && node.name !== undefined) { + return node.name.text; + } + return current; +} + +function isConcreteFunction(node: ts.Node): node is ConcreteFunction { + if (ts.isConstructorDeclaration(node)) { + return false; + } + if ( + ts.isFunctionDeclaration(node) + || ts.isFunctionExpression(node) + || ts.isArrowFunction(node) + || ts.isMethodDeclaration(node) + || ts.isGetAccessorDeclaration(node) + || ts.isSetAccessorDeclaration(node) + ) { + return node.body !== undefined; + } + return false; +} + +function isClassMember(node: ConcreteFunction): boolean { + return ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node); +} + +function functionName(node: ConcreteFunction): string | null { + if (ts.isFunctionDeclaration(node)) { + return node.name?.text ?? null; + } + if (ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node)) { + return propertyName(node.name); + } + if (ts.isFunctionExpression(node) && node.name !== undefined) { + return node.name.text; + } + + const parent = node.parent; + if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) { + return parent.name.text; + } + if (ts.isPropertyDeclaration(parent) || ts.isPropertyAssignment(parent)) { + return propertyName(parent.name); + } + return null; +} + +function propertyName(name: ts.PropertyName): string | null { + if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + return null; +} + +function cyclomaticComplexity(root: ConcreteFunction): number { + let complexity = 1; + + function visit(node: ts.Node): void { + if (node !== root && isAnyFunctionLike(node)) { + return; + } + + if ( + ts.isIfStatement(node) + || ts.isForStatement(node) + || ts.isForInStatement(node) + || ts.isForOfStatement(node) + || ts.isWhileStatement(node) + || ts.isDoStatement(node) + || ts.isCatchClause(node) + || ts.isConditionalExpression(node) + || ts.isCaseClause(node) + || ts.isDefaultClause(node) + ) { + complexity += 1; + } else if (ts.isBinaryExpression(node) && isShortCircuitOperator(node.operatorToken.kind)) { + complexity += 1; + } + + ts.forEachChild(node, visit); + } + + visit(root); + return complexity; +} + +function isAnyFunctionLike(node: ts.Node): boolean { + return ts.isFunctionDeclaration(node) + || ts.isFunctionExpression(node) + || ts.isArrowFunction(node) + || ts.isMethodDeclaration(node) + || ts.isGetAccessorDeclaration(node) + || ts.isSetAccessorDeclaration(node) + || ts.isConstructorDeclaration(node); +} + +function isShortCircuitOperator(kind: ts.SyntaxKind): boolean { + return kind === ts.SyntaxKind.AmpersandAmpersandToken + || kind === ts.SyntaxKind.BarBarToken + || kind === ts.SyntaxKind.QuestionQuestionToken; +} diff --git a/test/analyzer.test.ts b/test/analyzer.test.ts new file mode 100644 index 0000000..04ba266 --- /dev/null +++ b/test/analyzer.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { analyzeFiles } from "../src/analyzer.ts"; + +test("combines parsed complexity with Istanbul coverage", async () => { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-analyzer-")); + const sourceFile = path.join(root, "src", "sample.ts"); + const coverageFile = path.join(root, "coverage", "coverage-final.json"); + await mkdir(path.dirname(sourceFile), { recursive: true }); + await mkdir(path.dirname(coverageFile), { recursive: true }); + await writeFile(sourceFile, "export function sample(value: boolean) {\n if (value) return 1;\n return 0;\n}\n"); + await writeFile(coverageFile, JSON.stringify({ + [sourceFile]: { + path: sourceFile, + statementMap: { + "0": { start: { line: 2, column: 2 }, end: { line: 2, column: 21 } }, + "1": { start: { line: 3, column: 2 }, end: { line: 3, column: 11 } } + }, + s: { "0": 1, "1": 0 }, + fnMap: {}, + f: {} + } + })); + + const [metric] = await analyzeFiles([sourceFile], coverageFile); + assert.equal(metric.name, "sample"); + assert.equal(metric.complexity, 2); + assert.equal(metric.coverage, 0.5); + assert.equal(metric.crapScore, 2.5); +}); + +test("reports null coverage and score when coverage JSON is missing", async () => { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-analyzer-")); + const sourceFile = path.join(root, "sample.ts"); + await writeFile(sourceFile, "export function sample() { return 1; }"); + + const [metric] = await analyzeFiles([sourceFile], path.join(root, "missing.json")); + assert.equal(metric.coverage, null); + assert.equal(metric.crapScore, null); +}); diff --git a/test/cli-application.test.ts b/test/cli-application.test.ts new file mode 100644 index 0000000..5f7edd9 --- /dev/null +++ b/test/cli-application.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { runCli, usage } from "../src/cli-application.ts"; +import type { CoverageGenerator } from "../src/coverage-runner.ts"; + +test("help prints usage and succeeds", async () => { + const stdout: string[] = []; + const stderr: string[] = []; + const code = await runCli(["--help"], { projectRoot: "/project", stdout: (text) => stdout.push(text), stderr: (text) => stderr.push(text) }); + assert.equal(code, 0); + assert.equal(stderr.length, 0); + assert.equal(stdout.join(""), usage()); +}); + +test("empty selection succeeds without requiring coverage", async () => { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-cli-")); + await mkdir(path.join(root, "src")); + const stdout: string[] = []; + const code = await runCli([], { projectRoot: root, stdout: (text) => stdout.push(text), stderr: () => {} }); + assert.equal(code, 0); + assert.match(stdout.join(""), /No TypeScript files to analyze/); +}); + +test("returns 2 when the maximum CRAP score exceeds 8", async () => { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-cli-")); + const sourceFile = path.join(root, "src", "risky.ts"); + const coverageFile = path.join(root, "coverage", "coverage-final.json"); + await mkdir(path.dirname(sourceFile), { recursive: true }); + await mkdir(path.dirname(coverageFile), { recursive: true }); + await writeFile(sourceFile, `export function risky(a: boolean, b: boolean) { + if (a) return 1; + if (b) return 2; + return 0; + }`); + await writeFile(coverageFile, JSON.stringify({ + [sourceFile]: { + path: sourceFile, + statementMap: { + "0": { start: { line: 2, column: 4 }, end: { line: 2, column: 20 } }, + "1": { start: { line: 3, column: 4 }, end: { line: 3, column: 20 } }, + "2": { start: { line: 4, column: 4 }, end: { line: 4, column: 13 } } + }, + s: { "0": 0, "1": 0, "2": 0 }, fnMap: {}, f: {} + } + })); + const stdout: string[] = []; + const stderr: string[] = []; + + const coverageRunner: CoverageGenerator = { async generate() { return coverageFile; } }; + const code = await runCli([], { projectRoot: root, stdout: (text) => stdout.push(text), stderr: (text) => stderr.push(text), coverageRunner }); + assert.equal(code, 2); + assert.match(stdout.join(""), /risky/); + assert.match(stderr.join(""), /CRAP threshold exceeded: 12\.00 > 8\.00/); +}); + +test("generates coverage once for each package before analyzing it", async () => { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-cli-")); + const packages = [path.join(root, "packages", "a"), path.join(root, "packages", "b")]; + for (const packageRoot of packages) { + await mkdir(path.join(packageRoot, "src"), { recursive: true }); + await mkdir(path.join(packageRoot, "coverage"), { recursive: true }); + await writeFile(path.join(packageRoot, "package.json"), "{}"); + await writeFile(path.join(packageRoot, "src", "index.ts"), "export function value() { return 1; }"); + await writeFile(path.join(packageRoot, "coverage", "coverage-final.json"), "{}"); + } + const generated: string[] = []; + const coverageRunner: CoverageGenerator = { + async generate(packageRoot, coveragePath) { + generated.push(packageRoot); + return path.join(packageRoot, coveragePath); + }, + }; + + const code = await runCli([], { projectRoot: root, stdout: () => {}, stderr: () => {}, coverageRunner }); + + assert.equal(code, 0); + assert.deepEqual(generated, packages); +}); + +test("warns and reports N/A when a successful test run produces no coverage JSON", async () => { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-cli-")); + const sourceFile = path.join(root, "src", "sample.ts"); + await mkdir(path.dirname(sourceFile), { recursive: true }); + await writeFile(path.join(root, "package.json"), "{}"); + await writeFile(sourceFile, "export function sample() { return 1; }"); + const missingCoverage = path.join(root, "coverage", "coverage-final.json"); + const coverageRunner: CoverageGenerator = { async generate() { return missingCoverage; } }; + const stdout: string[] = []; + const stderr: string[] = []; + + const code = await runCli([], { + projectRoot: root, + stdout: (text) => stdout.push(text), + stderr: (text) => stderr.push(text), + coverageRunner, + }); + + assert.equal(code, 0); + assert.match(stdout.join(""), /sample.*N\/A/s); + assert.match(stderr.join(""), /coverage file not found/i); +}); + +test("invalid CLI usage returns 1 and prints help", async () => { + const stdout: string[] = []; + const stderr: string[] = []; + const code = await runCli(["--unknown"], { projectRoot: "/project", stdout: (text) => stdout.push(text), stderr: (text) => stderr.push(text) }); + assert.equal(code, 1); + assert.match(stderr.join(""), /Unknown option/); + assert.equal(stdout.join(""), usage()); +}); diff --git a/test/cli-arguments.test.ts b/test/cli-arguments.test.ts new file mode 100644 index 0000000..b1a9aa5 --- /dev/null +++ b/test/cli-arguments.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseCliArguments } from "../src/cli-arguments.ts"; + +test("parses all supported CLI forms", () => { + assert.deepEqual(parseCliArguments([]), { mode: "all", paths: [], coveragePath: "coverage/coverage-final.json" }); + assert.deepEqual(parseCliArguments(["--changed"]), { mode: "changed", paths: [], coveragePath: "coverage/coverage-final.json" }); + assert.deepEqual(parseCliArguments(["src/a.ts", "packages/app"]), { mode: "paths", paths: ["src/a.ts", "packages/app"], coveragePath: "coverage/coverage-final.json" }); + assert.deepEqual(parseCliArguments(["--coverage", "artifacts/coverage.json", "src"]), { mode: "paths", paths: ["src"], coveragePath: "artifacts/coverage.json" }); + assert.deepEqual(parseCliArguments(["--help"]), { mode: "help", paths: [], coveragePath: "coverage/coverage-final.json" }); +}); + +test("rejects conflicting or incomplete options", () => { + assert.throws(() => parseCliArguments(["--changed", "src/a.ts"]), /cannot be combined/i); + assert.throws(() => parseCliArguments(["--coverage"]), /requires a path/i); + assert.throws(() => parseCliArguments(["--wat"]), /unknown option/i); +}); diff --git a/test/command-executor.test.ts b/test/command-executor.test.ts new file mode 100644 index 0000000..3aa5545 --- /dev/null +++ b/test/command-executor.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ProcessCommandExecutor } from "../src/command-executor.ts"; + +test("process command executor waits for successful commands", async () => { + await new ProcessCommandExecutor().execute(process.execPath, ["-e", "process.exit(0)"], process.cwd()); +}); + +test("process command executor includes stderr when a command fails", async () => { + await assert.rejects( + () => new ProcessCommandExecutor().execute( + process.execPath, + ["-e", "process.stderr.write('coverage broke'); process.exit(3)"], + process.cwd(), + ), + /Coverage command failed:.*coverage broke/s, + ); +}); diff --git a/test/coverage-runner.test.ts b/test/coverage-runner.test.ts new file mode 100644 index 0000000..b1093b1 --- /dev/null +++ b/test/coverage-runner.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { access, mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { CoverageRunner } from "../src/coverage-runner.ts"; +import type { CommandExecutor } from "../src/command-executor.ts"; + +test("cleans stale coverage and runs Vitest through npm", async () => { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-runner-")); + const report = path.join(root, "coverage", "coverage-final.json"); + await mkdir(path.dirname(report)); + await writeFile(report, "stale"); + await writeFile(path.join(root, "package.json"), JSON.stringify({ devDependencies: { vitest: "latest" } })); + await writeFile(path.join(root, "package-lock.json"), "{}"); + const calls: Array<{ command: string; args: string[]; cwd: string }> = []; + const executor: CommandExecutor = { + async execute(command, args, cwd) { + await assert.rejects(() => access(report)); + calls.push({ command, args, cwd }); + await mkdir(path.dirname(report), { recursive: true }); + await writeFile(report, "{}"); + }, + }; + + const result = await new CoverageRunner(executor).generate(root, "coverage/coverage-final.json"); + + assert.equal(result, report); + assert.deepEqual(calls, [{ + command: "npm", + args: ["exec", "--", "vitest", "run", "--root=.", "--coverage", "--coverage.reporter=json", "--coverage.reportsDirectory=coverage"], + cwd: root, + }]); +}); + +test("runs Jest through pnpm with an isolated report directory", async () => { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-runner-")); + const report = path.join(root, "artifacts", "coverage-final.json"); + await writeFile(path.join(root, "package.json"), JSON.stringify({ packageManager: "pnpm@10", devDependencies: { jest: "latest" } })); + const calls: Array<{ command: string; args: string[]; cwd: string }> = []; + const executor: CommandExecutor = { + async execute(command, args, cwd) { + calls.push({ command, args, cwd }); + await mkdir(path.dirname(report), { recursive: true }); + await writeFile(report, "{}"); + }, + }; + + await new CoverageRunner(executor).generate(root, "artifacts/coverage-final.json"); + + assert.deepEqual(calls[0], { + command: "pnpm", + args: ["exec", "jest", "--rootDir=.", "--coverage", "--coverageReporters=json", "--coverageDirectory=artifacts"], + cwd: root, + }); +}); + +test("rejects coverage destinations outside the package", async () => { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-runner-")); + await writeFile(path.join(root, "package.json"), JSON.stringify({ devDependencies: { vitest: "latest" } })); + const executor: CommandExecutor = { async execute() {} }; + + await assert.rejects(() => new CoverageRunner(executor).generate(root, "../coverage-final.json"), /inside the package/i); +}); + +test("returns the expected path when the test command produces no Istanbul JSON", async () => { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-runner-")); + await writeFile(path.join(root, "package.json"), JSON.stringify({ devDependencies: { vitest: "latest" } })); + const executor: CommandExecutor = { async execute() {} }; + + const report = await new CoverageRunner(executor).generate(root, "coverage/coverage-final.json"); + assert.equal(report, path.join(root, "coverage", "coverage-final.json")); + await assert.rejects(() => access(report)); +}); diff --git a/test/crap-score.test.ts b/test/crap-score.test.ts new file mode 100644 index 0000000..a726b08 --- /dev/null +++ b/test/crap-score.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { calculateCrap } from "../src/crap-score.ts"; + +test("calculates CRAP from complexity and fractional coverage", () => { + assert.equal(calculateCrap(4, 0.5), 6); + assert.equal(calculateCrap(1, 1), 1); + assert.equal(calculateCrap(3, 0), 12); +}); + +test("returns null when coverage is unavailable", () => { + assert.equal(calculateCrap(4, null), null); +}); + +test("rejects invalid inputs", () => { + assert.throws(() => calculateCrap(0, 0.5), /complexity/i); + assert.throws(() => calculateCrap(2, 1.1), /coverage/i); +}); diff --git a/test/crap4java/ChangedFileDetectorTest.java b/test/crap4java/ChangedFileDetectorTest.java deleted file mode 100644 index 545e075..0000000 --- a/test/crap4java/ChangedFileDetectorTest.java +++ /dev/null @@ -1,104 +0,0 @@ -package crap4java; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class ChangedFileDetectorTest { - - @TempDir - Path tempDir; - - @Test - void findsModifiedAndUntrackedJavaFiles() throws Exception { - run("git init", tempDir); - run("git config user.email test@example.com", tempDir); - run("git config user.name test", tempDir); - - Path src = tempDir.resolve("src/main/java/demo"); - Files.createDirectories(src); - Path tracked = src.resolve("Tracked.java"); - Files.writeString(tracked, "class Tracked {}\n"); - - run("git add .", tempDir); - run("git commit -m init", tempDir); - - Files.writeString(tracked, "class Tracked { int x = 1; }\n"); - Path untracked = src.resolve("NewFile.java"); - Files.writeString(untracked, "class NewFile {}\n"); - Files.writeString(tempDir.resolve("README.md"), "ignore me\n"); - - List changed = ChangedFileDetector.changedJavaFiles(tempDir); - - assertEquals(List.of( - tempDir.resolve("src/main/java/demo/NewFile.java"), - tempDir.resolve("src/main/java/demo/Tracked.java") - ), changed); - } - - @Test - void includesGitErrorOutputWhenStatusFails() { - IllegalStateException error = assertThrows(IllegalStateException.class, - () -> ChangedFileDetector.changedJavaFiles(tempDir)); - - assertTrue(error.getMessage().contains("not a git repository")); - } - - @Test - void filtersChangedFilesToSrcTreeOnly() throws Exception { - run("git init", tempDir); - run("git config user.email test@example.com", tempDir); - run("git config user.name test", tempDir); - - Path mainSrc = tempDir.resolve("src/main/java/demo"); - Path testSrc = tempDir.resolve("test/crap4java"); - Files.createDirectories(mainSrc); - Files.createDirectories(testSrc); - - Path tracked = mainSrc.resolve("Tracked.java"); - Files.writeString(tracked, "class Tracked {}\n"); - run("git add .", tempDir); - run("git commit -m init", tempDir); - - Files.writeString(tracked, "class Tracked { int x = 1; }\n"); - Files.writeString(testSrc.resolve("ChangedFileDetectorTest.java"), "class ChangedFileDetectorTest {}\n"); - - List changed = ChangedFileDetector.changedJavaFilesUnderSrc(tempDir); - - assertEquals(List.of(tempDir.resolve("src/main/java/demo/Tracked.java")), changed); - } - - @Test - void candidateLineRequiresAtLeastFourCharacters() { - assertEquals(false, ChangedFileDetector.isCandidateLine(null)); - assertEquals(false, ChangedFileDetector.isCandidateLine("")); - assertEquals(false, ChangedFileDetector.isCandidateLine("abc")); - assertEquals(true, ChangedFileDetector.isCandidateLine("abcd")); - } - - @Test - void renameTargetUsesReplacementSideEvenAtStartOfString() { - assertEquals("src/New.java", ChangedFileDetector.renameTarget("src/Old.java -> src/New.java")); - assertEquals("New.java", ChangedFileDetector.renameTarget(" -> New.java")); - assertEquals("Plain.java", ChangedFileDetector.renameTarget("Plain.java")); - } - - private static void run(String command, Path dir) throws IOException, InterruptedException { - Process process = new ProcessBuilder("sh", "-c", command) - .directory(dir.toFile()) - .redirectErrorStream(true) - .start(); - if (process.waitFor() != 0) { - String output = new String(process.getInputStream().readAllBytes()); - throw new IllegalStateException(output); - } - } -} diff --git a/test/crap4java/CliApplicationTest.java b/test/crap4java/CliApplicationTest.java deleted file mode 100644 index 37e8865..0000000 --- a/test/crap4java/CliApplicationTest.java +++ /dev/null @@ -1,155 +0,0 @@ -package crap4java; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.io.ByteArrayOutputStream; -import java.io.PrintStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class CliApplicationTest { - - @TempDir - Path tempDir; - - private static final CoverageRunner NOOP_COVERAGE = - new CoverageRunner((command, directory) -> 0); - - @Test - void parseErrorsReturnUsageAndExitOne() throws Exception { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - ByteArrayOutputStream err = new ByteArrayOutputStream(); - - int exit = new CliApplication(tempDir, new PrintStream(out), new PrintStream(err), NOOP_COVERAGE) - .execute(new String[]{"--changed", "src/main/java/demo/Sample.java"}); - - assertEquals(1, exit); - assertTrue(out.toString().contains("Usage:")); - assertTrue(err.toString().contains("--changed cannot be combined with file arguments")); - } - - @Test - void returnsZeroWhenNoFilesAreFound() throws Exception { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - - int exit = new CliApplication(tempDir, new PrintStream(out), new PrintStream(new ByteArrayOutputStream()), NOOP_COVERAGE) - .execute(new String[0]); - - assertEquals(0, exit); - assertTrue(out.toString().contains("No Java files to analyze.")); - } - - @Test - void doesNotWarnWhenJacocoXmlExists() throws Exception { - Path sourceRoot = tempDir.resolve("src/main/java/demo"); - Files.createDirectories(sourceRoot); - Path source = sourceRoot.resolve("Sample.java"); - Files.writeString(source, """ - package demo; - - class Sample { - int alpha() { - return 1; - } - } - """); - Path jacocoXml = tempDir.resolve("target/site/jacoco/jacoco.xml"); - Files.createDirectories(jacocoXml.getParent()); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - ByteArrayOutputStream err = new ByteArrayOutputStream(); - CoverageRunner coverageRunner = new CoverageRunner((command, directory) -> { - Files.createDirectories(jacocoXml.getParent()); - Files.writeString(jacocoXml, """ - - - - - - - - - - """); - return 0; - }); - - int exit = new CliApplication(tempDir, new PrintStream(out), new PrintStream(err), coverageRunner) - .execute(new String[]{"src/main/java/demo/Sample.java"}); - - assertEquals(0, exit); - assertTrue(out.toString().contains("Sample")); - assertFalse(err.toString().contains("Warning: JaCoCo XML not found")); - } - - @Test - void explicitFileUsesOwningModuleForCoverageAndJacocoXml() throws Exception { - Path moduleRoot = tempDir.resolve("tools/mutate4java"); - Path sourceRoot = moduleRoot.resolve("src/mutate4java"); - Files.createDirectories(sourceRoot); - Files.writeString(moduleRoot.resolve("pom.xml"), ""); - Path source = sourceRoot.resolve("Sample.java"); - Files.writeString(source, """ - package mutate4java; - - class Sample { - int alpha() { - return 1; - } - } - """); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - ByteArrayOutputStream err = new ByteArrayOutputStream(); - List directories = new ArrayList<>(); - Path jacocoXml = moduleRoot.resolve("target/site/jacoco/jacoco.xml"); - CoverageRunner coverageRunner = new CoverageRunner((command, directory) -> { - directories.add(directory); - Files.createDirectories(jacocoXml.getParent()); - Files.writeString(jacocoXml, """ - - - - - - - - - - """); - return 0; - }); - - int exit = new CliApplication(tempDir, new PrintStream(out), new PrintStream(err), coverageRunner) - .execute(new String[]{"tools/mutate4java/src/mutate4java/Sample.java"}); - - assertEquals(0, exit); - assertEquals(List.of(moduleRoot), directories); - assertTrue(out.toString().contains("mutate4java.Sample")); - assertFalse(err.toString().contains("Warning: JaCoCo XML not found")); - } - - @Test - void thresholdExceededUsesStrictlyGreaterThanEight() { - assertFalse(CliApplication.thresholdExceeded(8.0)); - assertTrue(CliApplication.thresholdExceeded(8.1)); - } - - @Test - void moduleRootForFindsNearestAncestorWithPom() throws Exception { - Path moduleRoot = tempDir.resolve("tools/mutate4java"); - Path source = moduleRoot.resolve("src/mutate4java/Sample.java"); - Files.createDirectories(source.getParent()); - Files.writeString(moduleRoot.resolve("pom.xml"), ""); - Files.writeString(source, "class Sample {}"); - - Path module = CliApplication.moduleRootFor(tempDir, source); - - assertEquals(moduleRoot, module); - } -} diff --git a/test/crap4java/CliArgumentsParserTest.java b/test/crap4java/CliArgumentsParserTest.java deleted file mode 100644 index a8fe96d..0000000 --- a/test/crap4java/CliArgumentsParserTest.java +++ /dev/null @@ -1,58 +0,0 @@ -package crap4java; - -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class CliArgumentsParserTest { - - @Test - void noArgsMeansAllSrcFiles() { - CliArguments args = CliArgumentsParser.parse(new String[]{}); - assertEquals(CliMode.ALL_SRC, args.mode()); - } - - @Test - void changedFlagMeansChangedSrcFiles() { - CliArguments args = CliArgumentsParser.parse(new String[]{"--changed"}); - assertEquals(CliMode.CHANGED_SRC, args.mode()); - } - - @Test - void fileNamesMeanExplicitFiles() { - CliArguments args = CliArgumentsParser.parse(new String[]{"src/main/java/demo/A.java", "src/main/java/demo/B.java"}); - assertEquals(CliMode.EXPLICIT_FILES, args.mode()); - assertEquals(List.of("src/main/java/demo/A.java", "src/main/java/demo/B.java"), args.fileArgs()); - } - - @Test - void unknownFlagsAreIgnoredWhenCollectingExplicitFiles() { - CliArguments args = CliArgumentsParser.parse(new String[]{"src/main/java/demo/A.java", "--bogus", "src/main/java/demo/B.java"}); - - assertEquals(CliMode.EXPLICIT_FILES, args.mode()); - assertEquals(List.of("src/main/java/demo/A.java", "src/main/java/demo/B.java"), args.fileArgs()); - } - - @Test - void helpPrintsUsageMode() { - CliArguments args = CliArgumentsParser.parse(new String[]{"--help"}); - assertEquals(CliMode.HELP, args.mode()); - } - - @Test - void changedCannotBeCombinedWithFiles() { - assertThrows(IllegalArgumentException.class, - () -> CliArgumentsParser.parse(new String[]{"--changed", "src/main/java/demo/A.java"})); - } - - @Test - void plainFilesDoNotTriggerChangedMode() { - CliArguments args = CliArgumentsParser.parse(new String[]{"src/main/java/demo/A.java"}); - - assertEquals(CliMode.EXPLICIT_FILES, args.mode()); - assertEquals(List.of("src/main/java/demo/A.java"), args.fileArgs()); - } -} diff --git a/test/crap4java/CoverageRunnerTest.java b/test/crap4java/CoverageRunnerTest.java deleted file mode 100644 index ef9c7ae..0000000 --- a/test/crap4java/CoverageRunnerTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package crap4java; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class CoverageRunnerTest { - - @TempDir - Path tempDir; - - @Test - void deletesStaleCoverageAndRunsMavenCoverageCommand() throws Exception { - Path jacocoDir = tempDir.resolve("target/site/jacoco"); - Files.createDirectories(jacocoDir); - Files.writeString(jacocoDir.resolve("old.xml"), "stale"); - Path exec = tempDir.resolve("target/jacoco.exec"); - Files.createDirectories(exec.getParent()); - Files.writeString(exec, "stale"); - - RecordingExecutor executor = new RecordingExecutor(0); - CoverageRunner runner = new CoverageRunner(executor); - - runner.generateCoverage(tempDir); - - assertFalse(Files.exists(jacocoDir)); - assertFalse(Files.exists(exec)); - assertEquals(List.of( - "mvn", "-q", - "org.jacoco:jacoco-maven-plugin:0.8.12:prepare-agent", - "test", - "org.jacoco:jacoco-maven-plugin:0.8.12:report" - ), executor.commands.get(0)); - assertEquals(tempDir, executor.directories.get(0)); - } - - @Test - void failsWhenCoverageCommandFails() { - RecordingExecutor executor = new RecordingExecutor(2); - CoverageRunner runner = new CoverageRunner(executor); - - IllegalStateException ex = assertThrows(IllegalStateException.class, - () -> runner.generateCoverage(tempDir)); - - assertEquals("Coverage command failed with exit 2", ex.getMessage()); - } - - private static final class RecordingExecutor implements CommandExecutor { - private final int exitCode; - private final List> commands = new ArrayList<>(); - private final List directories = new ArrayList<>(); - - private RecordingExecutor(int exitCode) { - this.exitCode = exitCode; - } - - @Override - public int run(List command, Path directory) { - commands.add(command); - directories.add(directory); - return exitCode; - } - } -} diff --git a/test/crap4java/CrapAnalyzerTest.java b/test/crap4java/CrapAnalyzerTest.java deleted file mode 100644 index 90ebfab..0000000 --- a/test/crap4java/CrapAnalyzerTest.java +++ /dev/null @@ -1,185 +0,0 @@ -package crap4java; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; - -class CrapAnalyzerTest { - - @TempDir - Path tempDir; - - @Test - void computesScoresForChangedFiles() throws IOException { - Path sourceRoot = tempDir.resolve("src/main/java/demo"); - Files.createDirectories(sourceRoot); - Path source = sourceRoot.resolve("Sample.java"); - Files.writeString(source, """ - package demo; - class Sample { - int alpha(boolean a) { - if (a) { - return 1; - } - return 0; - } - } - """); - - Path jacoco = tempDir.resolve("jacoco.xml"); - Files.writeString(jacoco, """ - - - - - - - - - - """); - - List result = CrapAnalyzer.analyze( - tempDir, - List.of(source), - jacoco - ); - - assertEquals(1, result.size()); - MethodMetrics metric = result.get(0); - assertEquals("alpha", metric.methodName()); - assertEquals("demo.Sample", metric.className()); - assertEquals(2, metric.complexity()); - assertEquals(75.0, metric.coveragePercent(), 0.001); - assertEquals(2.0625, metric.crapScore(), 0.00001); - } - - @Test - void usesSimpleClassNameWhenSourceHasNoPackage() { - String className = CrapAnalyzer.classNameFromSource( - Path.of("src/main/java/Sample.java"), - """ - class Sample { - } - """ - ); - - assertEquals("Sample", className); - } - - @Test - void lookupCoveragePrefersExactLineBeforeNearestMatch() { - Map coverageMap = Map.of( - "demo.Sample#alpha:10", new CoverageData(1, 3), - "demo.Sample#alpha:12", new CoverageData(0, 8) - ); - - Double coverage = CrapAnalyzer.lookupCoverage(coverageMap, "demo.Sample", "alpha", 10); - - assertEquals(75.0, coverage, 0.001); - } - - @Test - void lookupCoverageFallsBackToNearestLineWithinMethod() { - Map coverageMap = Map.of( - "demo.Sample#alpha:10", new CoverageData(1, 3), - "demo.Sample#alpha:15", new CoverageData(0, 8) - ); - - Double coverage = CrapAnalyzer.lookupCoverage(coverageMap, "demo.Sample", "alpha", 13); - - assertEquals(100.0, coverage, 0.001); - } - - @Test - void nearestCoverageKeepsFirstEntryWhenDistancesTie() { - Map coverageMap = new LinkedHashMap<>(); - coverageMap.put("demo.Sample#alpha:10", new CoverageData(1, 3)); - coverageMap.put("demo.Sample#alpha:14", new CoverageData(0, 8)); - - CoverageData nearest = CrapAnalyzer.nearestCoverage(coverageMap, "demo.Sample", "alpha", 12); - - assertEquals(75.0, nearest.coveragePercent(), 0.001); - } - - @Test - void lookupCoverageReturnsNullWhenMethodHasNoCoverageEntries() { - Double coverage = CrapAnalyzer.lookupCoverage(Map.of(), "demo.Sample", "alpha", 10); - - assertNull(coverage); - } - - @Test - void parseTrailingLineReturnsMaxValueForMalformedKeys() { - assertEquals(Integer.MAX_VALUE, CrapAnalyzer.parseTrailingLine("demo.Sample#alpha")); - assertEquals(Integer.MAX_VALUE, CrapAnalyzer.parseTrailingLine("demo.Sample#alpha:")); - assertEquals(Integer.MAX_VALUE, CrapAnalyzer.parseTrailingLine("demo.Sample#alpha:oops")); - } - - @Test - void parseTrailingLineReturnsParsedLineNumberForValidKey() { - assertEquals(10, CrapAnalyzer.parseTrailingLine("demo.Sample#alpha:10")); - } - - @Test - void parseTrailingLineAcceptsLeadingSeparator() { - assertEquals(10, CrapAnalyzer.parseTrailingLine(":10")); - } - - @Test - void sortsMetricsByScoreDescendingWithNullsLast() throws IOException { - Path sourceRoot = tempDir.resolve("src/main/java/demo"); - Files.createDirectories(sourceRoot); - Path source = sourceRoot.resolve("Sample.java"); - Files.writeString(source, """ - package demo; - - class Sample { - int alpha(boolean a) { - if (a) { - return 1; - } - return 0; - } - - int beta() { - return 1; - } - - int gamma() { - return 2; - } - } - """); - - Path jacoco = tempDir.resolve("jacoco.xml"); - Files.writeString(jacoco, """ - - - - - - - - - - - - - """); - - List result = CrapAnalyzer.analyze(tempDir, List.of(source), jacoco); - - assertEquals(List.of("alpha", "beta", "gamma"), - result.stream().map(MethodMetrics::methodName).toList()); - } -} diff --git a/test/crap4java/CrapScoreTest.java b/test/crap4java/CrapScoreTest.java deleted file mode 100644 index bdef6d9..0000000 --- a/test/crap4java/CrapScoreTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package crap4java; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; - -class CrapScoreTest { - - @Test - void returnsComplexityWhenFullyCovered() { - assertEquals(5.0, CrapScore.calculate(5, 100.0), 0.0001); - } - - @Test - void returnsCcSquaredPlusCcWhenUncovered() { - assertEquals(30.0, CrapScore.calculate(5, 0.0), 0.0001); - } - - @Test - void computesPartialCoverage() { - assertEquals(18.648, CrapScore.calculate(8, 45.0), 0.01); - } - - @Test - void returnsNullForUnknownCoverage() { - assertNull(CrapScore.calculate(3, null)); - } -} diff --git a/test/crap4java/JacocoCoverageParserTest.java b/test/crap4java/JacocoCoverageParserTest.java deleted file mode 100644 index c7371ee..0000000 --- a/test/crap4java/JacocoCoverageParserTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package crap4java; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class JacocoCoverageParserTest { - - @TempDir - Path tempDir; - - @Test - void parsesCoverageByClassAndMethod() throws IOException { - Path xml = tempDir.resolve("jacoco.xml"); - Files.writeString(xml, """ - - - - - - - - - - - - - """); - - Map result = JacocoCoverageParser.parse(xml); - - assertEquals(90.0, result.get("demo.Sample#alpha:10").coveragePercent(), 0.001); - assertEquals(0.0, result.get("demo.Sample#beta:20").coveragePercent(), 0.001); - } - - @Test - void parsesXmlWithDoctypeWithoutRequiringLocalDtdFile() throws IOException { - Path xml = tempDir.resolve("jacoco-with-doctype.xml"); - Files.writeString(xml, """ - - - - - - - - - - - """); - - Map result = JacocoCoverageParser.parse(xml); - - assertEquals(90.0, result.get("demo.Sample#alpha:10").coveragePercent(), 0.001); - } - - @Test - void parsesInvalidLineNumbersAsZero() throws IOException { - Path xml = tempDir.resolve("jacoco-invalid-line.xml"); - Files.writeString(xml, """ - - - - - - - - - - """); - - Map result = JacocoCoverageParser.parse(xml); - - assertEquals(90.0, result.get("demo.Sample#alpha:0").coveragePercent(), 0.001); - } - - @Test - void configuresSecureFactoryFeatures() throws Exception { - var factory = JacocoCoverageParser.newSecureFactory(); - - assertTrue(factory.getFeature(javax.xml.XMLConstants.FEATURE_SECURE_PROCESSING)); - assertFalse(factory.getFeature("http://apache.org/xml/features/disallow-doctype-decl")); - assertFalse(factory.getFeature("http://xml.org/sax/features/external-general-entities")); - assertFalse(factory.getFeature("http://xml.org/sax/features/external-parameter-entities")); - assertTrue(factory.getFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd")); - assertFalse(factory.isXIncludeAware()); - assertFalse(factory.isExpandEntityReferences()); - } -} diff --git a/test/crap4java/JavaMethodParserTest.java b/test/crap4java/JavaMethodParserTest.java deleted file mode 100644 index d9fab95..0000000 --- a/test/crap4java/JavaMethodParserTest.java +++ /dev/null @@ -1,227 +0,0 @@ -package crap4java; - -import org.junit.jupiter.api.Test; - -import java.net.URI; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class JavaMethodParserTest { - - @Test - void extractsConcreteMethodsWithLinesAndComplexity() { - String source = """ - package demo; - class Sample { - int alpha(boolean a, boolean b) { - if (a && b) { - return 1; - } - return 0; - } - - int beta(int x) { - switch (x) { - case 1: return 1; - case 2: return 2; - default: return 0; - } - } - } - """; - - List methods = JavaMethodParser.parse("demo.Sample", source); - - assertEquals(List.of( - new MethodDescriptor("alpha", 3, 8, 3), - new MethodDescriptor("beta", 10, 16, 4) - ), methods); - } - - @Test - void ignoresConstructorsAndAbstractMethods() { - String source = """ - abstract class Sample { - Sample() { - } - - abstract int missing(); - - int present() { - return 1; - } - } - """; - - List methods = JavaMethodParser.parse("Sample", source); - - assertEquals(List.of(new MethodDescriptor("present", 7, 9, 1)), methods); - } - - @Test - void ignoresMethodsDeclaredInsideAnonymousClasses() { - String source = """ - class Sample { - int outer() { - Runnable runnable = new Runnable() { - @Override - public void run() { - if (true) { - } - } - }; - return 1; - } - } - """; - - List methods = JavaMethodParser.parse("Sample", source); - - assertEquals(List.of(new MethodDescriptor("outer", 2, 11, 1)), methods); - } - - @Test - void parsesMethodsWithoutResolvingSiblingTypes() { - String source = """ - package demo; - - class Sample { - Helper helper() { - return new Helper(); - } - } - """; - - List methods = JavaMethodParser.parse("demo.Sample", source); - - assertEquals(List.of(new MethodDescriptor("helper", 4, 6, 1)), methods); - } - - @Test - void ignoresKeywordsInsideCommentsAndStrings() { - String source = """ - class Sample { - int stable() { - String text = "if && || ? case default catch"; - // if && || ? case default catch - /* if && || ? case default catch */ - return 1; - } - } - """; - - List methods = JavaMethodParser.parse("Sample", source); - - assertEquals(List.of(new MethodDescriptor("stable", 2, 7, 1)), methods); - } - - @Test - void countsDecisionNodesFromTheAst() { - String source = """ - class Sample { - int score(boolean a, boolean b, int[] values) { - for (int i = 0; i < values.length; i++) { - } - for (int value : values) { - } - while (a) { - a = false; - } - do { - b = false; - } while (b); - if (a && b || values.length > 0) { - } - try { - return a ? 1 : 0; - } catch (RuntimeException ex) { - return 2; - } - } - } - """; - - List methods = JavaMethodParser.parse("Sample", source); - - assertEquals(List.of(new MethodDescriptor("score", 2, 20, 10)), methods); - } - - @Test - void visitsNestedDecisionNodesInsideOtherDecisionNodes() { - String source = """ - class Sample { - int nested(boolean a, boolean b, int[] values) { - for (int i = 0; i < values.length; i++) { - if (a) { - } - } - for (int value : values) { - if (b) { - } - } - while (a) { - if (b) { - } - a = false; - } - do { - if (a) { - } - b = false; - } while (b); - try { - return a ? (b ? 1 : 0) : 2; - } catch (RuntimeException ex) { - if (values.length > 0) { - return values[0]; - } - return 3; - } - } - - int switched(int value) { - switch (value) { - case 1: - if (value > 0) { - return 1; - } - return 0; - default: - return 2; - } - } - } - """; - - List methods = JavaMethodParser.parse("Sample", source); - - assertEquals(List.of( - new MethodDescriptor("nested", 2, 29, 13), - new MethodDescriptor("switched", 31, 41, 4) - ), methods); - } - - @Test - void acceptsClassNamesWithJavaSuffix() { - String source = """ - class Sample { - int value() { - return 1; - } - } - """; - - List methods = JavaMethodParser.parse("demo.Sample.java", source); - - assertEquals(List.of(new MethodDescriptor("value", 2, 4, 1)), methods); - } - - @Test - void buildsSourcePathAndUriFromClassNames() { - assertEquals("demo/Sample.java", JavaMethodParser.sourcePath("demo.Sample")); - assertEquals("demo/Sample.java", JavaMethodParser.sourcePath("demo.Sample.java")); - assertEquals(URI.create("string:///demo/Sample.java"), JavaMethodParser.sourceUri("demo.Sample")); - assertEquals(URI.create("string:///demo/Sample.java"), JavaMethodParser.sourceUri("demo.Sample.java")); - } -} diff --git a/test/crap4java/MainTest.java b/test/crap4java/MainTest.java deleted file mode 100644 index 5776677..0000000 --- a/test/crap4java/MainTest.java +++ /dev/null @@ -1,131 +0,0 @@ -package crap4java; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.io.ByteArrayOutputStream; -import java.io.PrintStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class MainTest { - - @TempDir - Path tempDir; - - private static final CoverageRunner NOOP_COVERAGE = - new CoverageRunner((command, directory) -> 0); - - @Test - void helpWritesUsageToStdout() throws Exception { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - ByteArrayOutputStream err = new ByteArrayOutputStream(); - - int exit = Main.run(new String[]{"--help"}, tempDir, new PrintStream(out), new PrintStream(err), NOOP_COVERAGE); - - assertEquals(0, exit); - assertTrue(out.toString().contains("Usage:")); - } - - @Test - void mainProcessExitsZeroForHelp() throws Exception { - Process process = new ProcessBuilder( - "java", - "-cp", - System.getProperty("java.class.path"), - "crap4java.Main", - "--help" - ).directory(tempDir.toFile()).start(); - - assertEquals(0, process.waitFor()); - } - - @Test - void mainProcessExitsNonZeroForUnknownOption() throws Exception { - Process process = new ProcessBuilder( - "java", - "-cp", - System.getProperty("java.class.path"), - "crap4java.Main", - "--changed", - "src/main/java/demo/Sample.java" - ).directory(tempDir.toFile()).start(); - - assertEquals(1, process.waitFor()); - } - - @Test - void explicitFileArgsAreAnalyzed() throws Exception { - Path sourceRoot = tempDir.resolve("src/main/java/demo"); - Files.createDirectories(sourceRoot); - Path source = sourceRoot.resolve("Sample.java"); - Files.writeString(source, """ - package demo; - class Sample { - int alpha(boolean a) { - if (a) { - return 1; - } - return 0; - } - } - """); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - ByteArrayOutputStream err = new ByteArrayOutputStream(); - - int exit = Main.run( - new String[]{"src/main/java/demo/Sample.java"}, - tempDir, - new PrintStream(out), - new PrintStream(err), - NOOP_COVERAGE - ); - - assertEquals(0, exit); - assertTrue(out.toString().contains("Sample")); - assertTrue(out.toString().contains("alpha")); - } - - @Test - void directoryArgAnalyzesJavaFilesUnderThatDirectorySrc() throws Exception { - Path moduleRoot = tempDir.resolve("module-a"); - Path sourceRoot = moduleRoot.resolve("src/main/java/demo"); - Files.createDirectories(sourceRoot); - Files.writeString(sourceRoot.resolve("Sample.java"), """ - package demo; - class Sample { - int alpha(boolean a) { - if (a) { - return 1; - } - return 0; - } - } - """); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - ByteArrayOutputStream err = new ByteArrayOutputStream(); - - int exit = Main.run(new String[]{"module-a"}, tempDir, new PrintStream(out), new PrintStream(err), NOOP_COVERAGE); - - assertEquals(0, exit); - assertTrue(out.toString().contains("Sample")); - assertTrue(out.toString().contains("alpha")); - } - - @Test - void maxCrapReturnsLargestNonNullScore() { - List metrics = List.of( - new MethodMetrics("alpha", "demo.Sample", 1, null, null), - new MethodMetrics("beta", "demo.Sample", 1, 75.0, 4.5), - new MethodMetrics("gamma", "demo.Sample", 1, 85.0, 7.0) - ); - - assertEquals(7.0, Main.maxCrap(metrics)); - } -} diff --git a/test/crap4java/ProcessCommandExecutorTest.java b/test/crap4java/ProcessCommandExecutorTest.java deleted file mode 100644 index 136f2dd..0000000 --- a/test/crap4java/ProcessCommandExecutorTest.java +++ /dev/null @@ -1,22 +0,0 @@ -package crap4java; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.nio.file.Path; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class ProcessCommandExecutorTest { - - @TempDir - Path tempDir; - - @Test - void returnsExitCodeFromLaunchedProcess() throws Exception { - int exit = new ProcessCommandExecutor().run(List.of("/bin/sh", "-c", "exit 7"), tempDir); - - assertEquals(7, exit); - } -} diff --git a/test/crap4java/ReportFormatterTest.java b/test/crap4java/ReportFormatterTest.java deleted file mode 100644 index 98aef8f..0000000 --- a/test/crap4java/ReportFormatterTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package crap4java; - -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class ReportFormatterTest { - - @Test - void formatsExactReportWithScoresAndNaValues() { - MethodMetrics scored = new MethodMetrics("foo", "demo.Sample", 3, 85.0, 4.5); - MethodMetrics unknown = new MethodMetrics("bar", "demo.Sample", 2, null, null); - - String report = ReportFormatter.format(List.of(scored, unknown)); - - String header = String.format("%-30s %-35s %4s %7s %8s", "Method", "Class", "CC", "Cov%", "CRAP"); - String separator = "-".repeat(header.length()); - String expected = """ - CRAP Report - =========== - %s - %s - %-30s %-35s %4d %7s %8s - %-30s %-35s %4d %7s %8s - """.formatted( - header, - separator, - "foo", - "demo.Sample", - 3, - "85.0%", - "4.5", - "bar", - "demo.Sample", - 2, - " N/A ", - " N/A"); - - assertEquals(expected, report); - } - - @Test - void sortsScoredEntriesAheadOfNaEntriesAndHigherScoresFirst() { - MethodMetrics lowerScore = new MethodMetrics("low", "demo.Sample", 2, 100.0, 2.0); - MethodMetrics unknown = new MethodMetrics("unknown", "demo.Sample", 2, null, null); - MethodMetrics higherScore = new MethodMetrics("high", "demo.Sample", 5, 10.0, 9.0); - - String report = ReportFormatter.format(List.of(lowerScore, unknown, higherScore)); - - assertTrue(report.indexOf("high") < report.indexOf("low")); - assertTrue(report.indexOf("low") < report.indexOf("unknown")); - } -} diff --git a/test/crap4java/SourceFileFinderTest.java b/test/crap4java/SourceFileFinderTest.java deleted file mode 100644 index f991714..0000000 --- a/test/crap4java/SourceFileFinderTest.java +++ /dev/null @@ -1,32 +0,0 @@ -package crap4java; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class SourceFileFinderTest { - - @TempDir - Path tempDir; - - @Test - void findsAllJavaFilesUnderSrcOnly() throws Exception { - Path src = tempDir.resolve("src/main/java/demo"); - Files.createDirectories(src); - Path inSrc = src.resolve("Sample.java"); - Files.writeString(inSrc, "class Sample {}\n"); - - Path outOfSrc = tempDir.resolve("other/Elsewhere.java"); - Files.createDirectories(outOfSrc.getParent()); - Files.writeString(outOfSrc, "class Elsewhere {}\n"); - - List files = SourceFileFinder.findAllJavaFilesUnderSrc(tempDir); - - assertEquals(List.of(inSrc), files); - } -} diff --git a/test/istanbul-coverage.test.ts b/test/istanbul-coverage.test.ts new file mode 100644 index 0000000..b860617 --- /dev/null +++ b/test/istanbul-coverage.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { coverageForMethod, parseIstanbulCoverage } from "../src/istanbul-coverage.ts"; +import type { MethodDescriptor } from "../src/types.ts"; + +test("derives fractional method coverage from statements inside the method", async () => { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-coverage-")); + const sourceFile = path.join(root, "src", "sample.ts"); + const coverageFile = path.join(root, "coverage-final.json"); + await writeFile(coverageFile, JSON.stringify({ + [sourceFile]: { + path: sourceFile, + statementMap: { + "0": { start: { line: 2, column: 2 }, end: { line: 2, column: 12 } }, + "1": { start: { line: 3, column: 2 }, end: { line: 3, column: 12 } }, + "2": { start: { line: 9, column: 2 }, end: { line: 9, column: 12 } } + }, + s: { "0": 1, "1": 0, "2": 1 }, + fnMap: {}, + f: {} + } + })); + const method: MethodDescriptor = { + name: "sample", + className: null, + filePath: sourceFile, + startLine: 1, + endLine: 4, + complexity: 1, + }; + + const coverage = await parseIstanbulCoverage(coverageFile); + assert.equal(coverageForMethod(coverage, method), 0.5); +}); + +test("falls back to Istanbul function counters and returns null for unknown files", async () => { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-coverage-")); + const sourceFile = path.join(root, "src", "sample.ts"); + const coverageFile = path.join(root, "coverage-final.json"); + await writeFile(coverageFile, JSON.stringify({ + [sourceFile]: { + path: sourceFile, + statementMap: {}, + s: {}, + fnMap: { + "0": { name: "sample", decl: { start: { line: 4, column: 0 } }, loc: { start: { line: 4, column: 0 }, end: { line: 6, column: 1 } } } + }, + f: { "0": 3 } + } + })); + const method: MethodDescriptor = { + name: "sample", + className: null, + filePath: sourceFile, + startLine: 4, + endLine: 6, + complexity: 1, + }; + + const coverage = await parseIstanbulCoverage(coverageFile); + assert.equal(coverageForMethod(coverage, method), 1); + assert.equal(coverageForMethod(coverage, { ...method, filePath: path.join(root, "missing.ts") }), null); +}); diff --git a/test/package-grouper.test.ts b/test/package-grouper.test.ts new file mode 100644 index 0000000..20e74ce --- /dev/null +++ b/test/package-grouper.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { groupFilesByPackage } from "../src/package-grouper.ts"; + +test("groups files by their nearest package.json and preserves sorted order", async () => { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-packages-")); + const packageA = path.join(root, "packages", "a"); + const packageB = path.join(root, "packages", "b"); + await mkdir(path.join(root, "src"), { recursive: true }); + await mkdir(path.join(packageA, "src"), { recursive: true }); + await mkdir(path.join(packageB, "src"), { recursive: true }); + await writeFile(path.join(root, "package.json"), "{}"); + await writeFile(path.join(packageA, "package.json"), "{}"); + await writeFile(path.join(packageB, "package.json"), "{}"); + const files = [ + path.join(packageB, "src", "b.ts"), + path.join(root, "src", "root.ts"), + path.join(packageA, "src", "z.ts"), + path.join(packageA, "src", "a.ts"), + ]; + + const groups = await groupFilesByPackage(root, files); + + assert.deepEqual(groups, [ + { packageRoot: root, files: [path.join(root, "src", "root.ts")] }, + { packageRoot: packageA, files: [path.join(packageA, "src", "a.ts"), path.join(packageA, "src", "z.ts")] }, + { packageRoot: packageB, files: [path.join(packageB, "src", "b.ts")] }, + ]); +}); + +test("falls back to project root when no package.json exists", async () => { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-packages-")); + const file = path.join(root, "src", "sample.ts"); + await mkdir(path.dirname(file), { recursive: true }); + + assert.deepEqual(await groupFilesByPackage(root, [file]), [{ packageRoot: root, files: [file] }]); +}); diff --git a/test/package-runtime.test.ts b/test/package-runtime.test.ts new file mode 100644 index 0000000..753410a --- /dev/null +++ b/test/package-runtime.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { detectPackageManager, detectTestFramework } from "../src/package-runtime.ts"; + +test("packageManager field takes precedence over lockfiles", async () => { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-runtime-")); + await writeFile(path.join(root, "package.json"), JSON.stringify({ packageManager: "pnpm@10.0.0", devDependencies: { vitest: "latest" } })); + await writeFile(path.join(root, "package-lock.json"), "{}"); + + assert.equal(await detectPackageManager(root), "pnpm"); + assert.equal(await detectTestFramework(root), "vitest"); +}); + +test("detects Yarn and Jest from lockfile and test script", async () => { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-runtime-")); + await writeFile(path.join(root, "package.json"), JSON.stringify({ scripts: { test: "node --conditions=test ./node_modules/jest/bin/jest.js" } })); + await writeFile(path.join(root, "yarn.lock"), ""); + + assert.equal(await detectPackageManager(root), "yarn"); + assert.equal(await detectTestFramework(root), "jest"); +}); + +test("defaults to npm and reports an unsupported test setup", async () => { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-runtime-")); + await writeFile(path.join(root, "package.json"), JSON.stringify({ scripts: { test: "node --test" } })); + + assert.equal(await detectPackageManager(root), "npm"); + await assert.rejects(() => detectTestFramework(root), /Vitest or Jest/i); +}); + +test("workspace packages inherit package manager and test framework from the project root", async () => { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-runtime-")); + const packageRoot = path.join(root, "packages", "web"); + await mkdir(packageRoot, { recursive: true }); + await writeFile(path.join(root, "package.json"), JSON.stringify({ packageManager: "pnpm@10", devDependencies: { vitest: "latest" } })); + await writeFile(path.join(root, "pnpm-lock.yaml"), ""); + await writeFile(path.join(packageRoot, "package.json"), JSON.stringify({ name: "web" })); + + assert.equal(await detectPackageManager(packageRoot, root), "pnpm"); + assert.equal(await detectTestFramework(packageRoot, root), "vitest"); +}); + +test("rejects an explicitly configured unsupported package manager", async () => { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-runtime-")); + await writeFile(path.join(root, "package.json"), JSON.stringify({ packageManager: "bun@1.2.0", devDependencies: { vitest: "latest" } })); + + await assert.rejects(() => detectPackageManager(root), /Unsupported package manager.*bun/i); +}); diff --git a/test/report-formatter.test.ts b/test/report-formatter.test.ts new file mode 100644 index 0000000..a293b72 --- /dev/null +++ b/test/report-formatter.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { formatReport, sortMetrics } from "../src/report-formatter.ts"; +import type { MethodMetrics } from "../src/types.ts"; + +test("sorts worst CRAP first with unavailable coverage last", () => { + const metrics: MethodMetrics[] = [ + { name: "safe", className: null, filePath: "src/a.ts", complexity: 1, coverage: 1, crapScore: 1 }, + { name: "unknown", className: null, filePath: "src/c.ts", complexity: 2, coverage: null, crapScore: null }, + { name: "risky", className: "Job", filePath: "src/b.ts", complexity: 3, coverage: 0, crapScore: 12 }, + ]; + + assert.deepEqual(sortMetrics(metrics).map((metric) => metric.name), ["risky", "safe", "unknown"]); + const report = formatReport(metrics, "/project"); + assert.match(report, /Job\.risky/); + assert.match(report, /100\.0%/); + assert.match(report, /N\/A/); + assert.ok(report.indexOf("risky") < report.indexOf("safe")); +}); diff --git a/test/source-file-finder.test.ts b/test/source-file-finder.test.ts new file mode 100644 index 0000000..64b27a2 --- /dev/null +++ b/test/source-file-finder.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { findDefaultSourceFiles, expandExplicitPaths, parseChangedFiles } from "../src/source-file-finder.ts"; + +async function fixture(): Promise { + const root = await mkdtemp(path.join(tmpdir(), "crap4ts-sources-")); + await mkdir(path.join(root, "src", "nested"), { recursive: true }); + await mkdir(path.join(root, "dist"), { recursive: true }); + await writeFile(path.join(root, "src", "a.ts"), "export const a = 1;"); + await writeFile(path.join(root, "src", "b.tsx"), "export const b = ;"); + await writeFile(path.join(root, "src", "types.d.ts"), "declare const value: string;"); + await writeFile(path.join(root, "src", "nested", "c.js"), "export const c = 1;"); + await writeFile(path.join(root, "dist", "generated.ts"), "export const generated = 1;"); + return root; +} + +test("default discovery includes ts and tsx under src and excludes declarations", async () => { + const root = await fixture(); + const files = await findDefaultSourceFiles(root); + assert.deepEqual(files.map((file) => path.relative(root, file)), ["src/a.ts", "src/b.tsx"]); +}); + +test("default discovery includes src trees in workspace packages", async () => { + const root = await fixture(); + const packageSource = path.join(root, "packages", "web", "src"); + await mkdir(packageSource, { recursive: true }); + await writeFile(path.join(packageSource, "app.ts"), "export const app = 1;"); + await mkdir(path.join(root, "examples", "not-source"), { recursive: true }); + await writeFile(path.join(root, "examples", "not-source", "ignored.ts"), "export const ignored = 1;"); + + const files = await findDefaultSourceFiles(root); + + assert.deepEqual(files.map((file) => path.relative(root, file)), ["packages/web/src/app.ts", "src/a.ts", "src/b.tsx"]); +}); + +test("explicit directories are searched directly and results are deduplicated", async () => { + const root = await fixture(); + const files = await expandExplicitPaths(root, ["src", "src/a.ts"]); + assert.deepEqual(files.map((file) => path.relative(root, file)), ["src/a.ts", "src/b.tsx"]); +}); + +test("changed-file parsing handles rename records and filters outside src", () => { + const status = [ + " M src/a.ts", + "?? src/new.tsx", + "R src/old.ts -> src/renamed.ts", + " M README.md", + " M dist/generated.ts", + " M packages/web/src/view.tsx", + " D src/deleted.ts", + ].join("\n"); + + assert.deepEqual(parseChangedFiles("/project", status), [ + "/project/packages/web/src/view.tsx", + "/project/src/a.ts", + "/project/src/new.tsx", + "/project/src/renamed.ts", + ]); +}); diff --git a/test/typescript-method-parser.test.ts b/test/typescript-method-parser.test.ts new file mode 100644 index 0000000..e3c091d --- /dev/null +++ b/test/typescript-method-parser.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseTypeScriptMethods } from "../src/typescript-method-parser.ts"; + +test("finds functions, methods, and named arrow functions", () => { + const source = ` +export function topLevel(value: number) { + if (value > 0 && value < 10) return value; + return 0; +} + +export const choose = (ready: boolean) => ready ? 1 : 2; + +class Greeter { + constructor(private prefix: string) {} + + select = (ready: boolean) => ready ? 1 : 2; + + greet(name: string) { + return this.prefix + name; + } + + get label() { + return this.prefix; + } +} +`; + + const methods = parseTypeScriptMethods("src/sample.ts", source); + + assert.deepEqual( + methods.map(({ name, className, complexity }) => ({ name, className, complexity })), + [ + { name: "topLevel", className: null, complexity: 3 }, + { name: "choose", className: null, complexity: 2 }, + { name: "select", className: "Greeter", complexity: 2 }, + { name: "greet", className: "Greeter", complexity: 1 }, + { name: "label", className: "Greeter", complexity: 1 }, + ], + ); +}); + +test("counts TypeScript control-flow constructs but not nested function branches", () => { + const source = ` +function complicated(values?: number[]) { + const safe = values ?? []; + for (const value of safe) { + if (value > 10 || value < 0) continue; + switch (value) { + case 1: break; + case 2: break; + default: break; + } + } + try { return safe.length ? 1 : 0; } catch { return 0; } + function nested(flag: boolean) { return flag ? 1 : 0; } +} +`; + + const [outer, nested] = parseTypeScriptMethods("src/sample.ts", source); + + assert.equal(outer.name, "complicated"); + assert.equal(outer.complexity, 10); + assert.equal(nested.name, "nested"); + assert.equal(nested.complexity, 2); +}); + +test("ignores declarations, constructors, and anonymous callbacks", () => { + const source = ` +declare function ambient(value: string): void; +interface Worker { run(): void } +abstract class Base { abstract work(): void } +class Job { constructor() {} } +[1, 2].map((value) => value + 1); +`; + + assert.deepEqual(parseTypeScriptMethods("src/sample.ts", source), []); +}); + +test("parses TSX without treating JSX expressions as methods", () => { + const source = ` +export function Badge({ active }: { active: boolean }) { + return {active ? "on" : "off"}; +} +`; + + const methods = parseTypeScriptMethods("src/badge.tsx", source); + assert.equal(methods.length, 1); + assert.equal(methods[0].name, "Badge"); + assert.equal(methods[0].complexity, 2); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..50c8ddf --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": ".", + "outDir": "dist", + "strict": true, + "declaration": true, + "sourceMap": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}