crap4ruby 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -1
- data/PERFORMANCE.md +166 -0
- data/README.md +4 -1
- data/lib/crap4ruby/analyzer.rb +73 -44
- data/lib/crap4ruby/batch.rb +172 -0
- data/lib/crap4ruby/cli.rb +7 -7
- data/lib/crap4ruby/coverage.rb +4 -1
- data/lib/crap4ruby/discovery.rb +6 -2
- data/lib/crap4ruby/report.rb +4 -6
- data/lib/crap4ruby/score.rb +6 -2
- data/lib/crap4ruby/version.rb +1 -1
- data/lib/crap4ruby.rb +2 -1
- data/sig/crap4ruby.rbs +27 -9
- data/sig/process.rbs +12 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 28f7ade7b1a7064708117a46abf1e555d777493d5be570fc28cf6958b196e253
|
|
4
|
+
data.tar.gz: 3ae6bf461495ff0e5fa2edc3ecfa773f7c20f920fb3d6f7e92f9ce42bfec3ddd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: da1e756e8824c99bd46cb34d5f994e95689866a760870bd13f5eb3530573c5eed5c4d6903f4e60a35c16f9f8f2bcb153af2db6712d9fa4d373d8a0c27f9b7a9d
|
|
7
|
+
data.tar.gz: 9fc11d38f195dea8de1a7b52079e8fd1b32979bc28d12149d7ded251d887577fa26ea0ffc28b6839aa4c41fc76b906f161b7b73bb9f7f53fed4fd1b7873f5e4f
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ Version numbers follow [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.2.2] - 2026-10-29
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Automatically analyze large selections with bounded worker processes. Add `--jobs N` to set the worker count or use `--jobs 1` for serial analysis, with serial fallback on unsupported or multithreaded hosts.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Reduce analyzer, coverage-loading, discovery, scoring, and table-formatting overhead while preserving analysis results and report output. See [PERFORMANCE.md](PERFORMANCE.md) for real-project measurements.
|
|
17
|
+
|
|
8
18
|
## [0.2.1] - 2026-10-29
|
|
9
19
|
|
|
10
20
|
### Added
|
|
@@ -48,7 +58,8 @@ Version numbers follow [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.
|
|
|
48
58
|
|
|
49
59
|
- Split CLI execution, AST discovery, and coverage loading into smaller methods without changing their behavior or scoring rules.
|
|
50
60
|
|
|
51
|
-
[Unreleased]: https://github.com/svyatov/crap4ruby/compare/v0.2.
|
|
61
|
+
[Unreleased]: https://github.com/svyatov/crap4ruby/compare/v0.2.2...HEAD
|
|
62
|
+
[0.2.2]: https://github.com/svyatov/crap4ruby/compare/v0.2.1...v0.2.2
|
|
52
63
|
[0.2.1]: https://github.com/svyatov/crap4ruby/compare/v0.2.0...v0.2.1
|
|
53
64
|
[0.2.0]: https://github.com/svyatov/crap4ruby/compare/v0.1.1...v0.2.0
|
|
54
65
|
[0.1.1]: https://github.com/svyatov/crap4ruby/compare/v0.1.0...v0.1.1
|
data/PERFORMANCE.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Performance
|
|
2
|
+
|
|
3
|
+
The benchmark compares complete CLI runs on local project checkouts. It checks report equality and rejects changed inputs.
|
|
4
|
+
|
|
5
|
+
## Measured results (2026-10-29)
|
|
6
|
+
|
|
7
|
+
Baseline: `19942c2` (0.2.1). CLI candidate: `2f23c80`. Final EDN allocation follow-up: `3dd909d`, measured separately below. Runtime: Ruby 4.0.9, Prism 1.9.0, arm64 macOS.
|
|
8
|
+
|
|
9
|
+
Each variant has seven measured subprocess runs after one excluded warmup. Variant order alternates, and runs execute serially with warm filesystem caches.
|
|
10
|
+
|
|
11
|
+
| Project | Mode | Ruby files | Baseline median | Candidate median | Time reduction |
|
|
12
|
+
| --- | --- | ---: | ---: | ---: | ---: |
|
|
13
|
+
| Les | Complexity only | 196 | 0.2530 s | 0.1485 s | 41.3% |
|
|
14
|
+
| Les | SimpleCov coverage | 196 | 0.2840 s | 0.1788 s | 37.1% |
|
|
15
|
+
| Forem | Complexity only | 1,307 | 0.9631 s | 0.3761 s | 60.9% |
|
|
16
|
+
| Steep | Complexity only | 146 | 0.5330 s | 0.2185 s | 59.0% |
|
|
17
|
+
| YARD | Complexity only | 207 | 0.3699 s | 0.1768 s | 52.2% |
|
|
18
|
+
|
|
19
|
+
[Raw samples and input/output hashes](benchmark/results/2026-10-29-batch.jsonl) retain every measured duration. Each line is a JSON object. Campaign records distinguish the original baseline, the optimized serial baseline, current-code worker settings, and small inputs. Grouped samples factor identical metadata without dropping values. Phase, resource, and independent QA records are separate.
|
|
20
|
+
|
|
21
|
+
The earlier campaigns measured [candidate `80484fd`](benchmark/results/2026-10-29.jsonl), [candidate `2f35bdd`](benchmark/results/2026-10-29-follow-up.jsonl), and [candidate `6a1701b`](benchmark/results/2026-10-29-report-score.jsonl) against the same baseline. Project input and report hashes match across campaigns. Timing differences between campaigns do not isolate individual changes.
|
|
22
|
+
|
|
23
|
+
Timing includes Ruby startup, Prism activation, discovery, source reads, analysis, coverage loading, sorting, formatting, output capture, and process completion. Both variants start without inherited Bundler setup and activate the same Prism version. Runs use `--no-save`, so timing excludes snapshot serialization and writes. Test-suite execution is excluded.
|
|
24
|
+
|
|
25
|
+
Every measured report matches its baseline byte for byte. Separate differential checks preserve all fields and traversal order across 10,568 method definitions in these four projects. Independent fixture checks cover nested definitions, heredocs, encoding, assignment syntax, errors, and coverage on Ruby 3.3.11 and 4.0.9. The batch implementation also passed 75 independent CLI runs and 21 controlled lifecycle probes on Ruby 3.3.11, 3.4.9, and 4.0.9. All used Prism 1.9.0. Prism 1.0 and 2.x were not executed.
|
|
26
|
+
|
|
27
|
+
These results describe local checkouts and warm filesystem caches. They do not establish cold-disk latency, unique physical memory use, or performance on other machines. Separately sampled summed RSS is reported below.
|
|
28
|
+
|
|
29
|
+
## Repeat the comparison
|
|
30
|
+
|
|
31
|
+
1. Create a scratch directory with `mktemp -d`. Replace `BASELINE_DIR` below with its printed path.
|
|
32
|
+
2. Export the baseline library:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
git archive --output=BASELINE_DIR/baseline.tar 19942c2 lib
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
3. Extract the archive:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
tar -xf BASELINE_DIR/baseline.tar -C BASELINE_DIR
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
4. Compare against a project checkout:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
bundle exec ruby script/benchmark.rb BASELINE_DIR/lib /path/to/project
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The command prints JSON to stdout and median/range summaries to stderr. Pass multiple project paths to measure them serially. Set `SAMPLES` to an odd integer of at least three to change the sample count.
|
|
51
|
+
|
|
52
|
+
For a comparison of current-code serial execution against its automatic default, use `BASELINE_JOBS=1` and pass `lib` as the baseline directory. `CANDIDATE_JOBS` can override the candidate worker count. Both optional settings are recorded in the output; use them only with library versions that support `--jobs`.
|
|
53
|
+
|
|
54
|
+
The runner selects existing `app/` and `lib/` directories. It measures complexity-only mode and adds coverage-enabled mode when `coverage/.resultset.json` exists. It hashes selected paths and contents before and after each subprocess. Coverage-enabled runs also hash the coverage report. Library hashes must remain unchanged throughout the campaign.
|
|
55
|
+
|
|
56
|
+
## Optimization evidence
|
|
57
|
+
|
|
58
|
+
The analyzer shares one ordered result array across discovery, uses symbol dispatch for decision types, and resolves coverage endpoints by byte offset. Complexity-only CLI runs omit coverage line collection. Default library calls retain body lines.
|
|
59
|
+
|
|
60
|
+
Analysis-only comparisons against the baseline preserved complete method records and reports. They reduced median analysis time by 18% to 25% before omitting coverage collection. The omitted walk then reduced complete complexity-only CLI latency by another 16% to 23% against that intermediate implementation.
|
|
61
|
+
|
|
62
|
+
Preliminary CLI timings inherited Bundler's candidate version preload and were replaced by the clean-boot measurements above. The newer Prism child iterator was excluded because the minimum supported Prism version lacks it.
|
|
63
|
+
|
|
64
|
+
### Follow-up experiments
|
|
65
|
+
|
|
66
|
+
Seven trials per variant alternated execution order. Each trial started a separate Ruby process and ran one excluded warmup batch. Phase timing excludes library loading and result hashing. Allocation counts use `GC.stat(:total_allocated_objects)` deltas.
|
|
67
|
+
|
|
68
|
+
| Experiment | Input and batch | Baseline median | Candidate median | Baseline allocated objects | Candidate allocated objects |
|
|
69
|
+
| --- | --- | ---: | ---: | ---: | ---: |
|
|
70
|
+
| Iteration membership hash | Forem, one complexity-only analysis | 0.4041 s | 0.3911 s | 944,945 | 945,015 |
|
|
71
|
+
| Direct definition-boundary checks | Forem, one analysis with body lines | 0.5592 s | 0.5284 s | 1,374,461 | 1,186,622 |
|
|
72
|
+
| Copy initial coverage counts | Les, 50 report loads | 0.3416 s | 0.2707 s | 1,434,813 | 87,463 |
|
|
73
|
+
| Skip repeated file-type checks | Forem, 20 discovery calls | 1.8376 s | 1.6761 s | 1,061,220 | 993,820 |
|
|
74
|
+
|
|
75
|
+
Each phase isolates one change against its preceding implementation. Analysis includes source reads and parsing. Coverage loading includes JSON parsing, validation, and normalization. Its input is the real Les report with 209 files and 13,399 line entries. The loader experiment screened an equivalent conditional form before the committed guard clause.
|
|
76
|
+
|
|
77
|
+
Complete method records, merged coverage data, and selected file paths match within every phase. Independent checks also matched 382 Analyzer fixtures, 176 coverage cases, and 40 discovery scenarios on Ruby 3.3.11 and 4.0.9. Coverage checks include caller-array mutation, merged suites, unequal arrays, and symlink aliases.
|
|
78
|
+
|
|
79
|
+
Moving the iteration block check before membership lookup showed no clear gain and was reverted.
|
|
80
|
+
|
|
81
|
+
### Formatting and scoring
|
|
82
|
+
|
|
83
|
+
The table formatter now uses one format template per report. Scoring combines integer terms before constructing the exact Rational result. Both changes preserve output and reduce allocated objects.
|
|
84
|
+
|
|
85
|
+
| Experiment | Input and batch | Baseline median | Candidate median | Baseline allocated objects | Candidate allocated objects |
|
|
86
|
+
| --- | --- | ---: | ---: | ---: | ---: |
|
|
87
|
+
| Table format template | Forem, 20 tables of 5,765 entries | 0.1991 s | 0.1206 s | 1,384,240 | 461,740 |
|
|
88
|
+
| Integer score terms | Les, 20 scoring passes over 1,045 methods | 0.3267 s | 0.3192 s | 397,060 | 250,020 |
|
|
89
|
+
|
|
90
|
+
Each experiment has seven alternating trials per variant, with one excluded warmup batch per process. The table experiment compares `562581a` with the formatter in `c0f10f6`. The scoring experiment compares `c0f10f6` with `6a1701b`. Allocation counts cover the measured batch only.
|
|
91
|
+
|
|
92
|
+
The formatter reduced phase time by 39% and allocated objects by 67%. Scoring reduced allocated objects by 37%. Its elapsed time changed by 2.3%, and CPU time changed from 0.3256 s to 0.3175 s. These small timing changes do not establish a reliable speed gain. Median process peak RSS was 47,398,912 versus 47,431,680 bytes. Median GC count was 17 for both variants; candidate batches triggered 16 to 17 collections. Median GC time was 12 versus 10 ms. Fewer allocations are a retained improvement even without a demonstrated reduction in peak memory or median collection count.
|
|
93
|
+
|
|
94
|
+
Independent differential checks matched 146 formatter cases and 74 scoring cases on Ruby 3.3.11 and 4.0.9. These include encoding and control characters, exact Rational values, partial coverage, and large integers.
|
|
95
|
+
|
|
96
|
+
#### EDN allocation follow-up
|
|
97
|
+
|
|
98
|
+
Commit `3dd909d` trims trailing zeros in the newly formatted number string in place. This avoids copying that string. Each variant runs 100 EDN formatting passes after 100 excluded warmup passes, in seven fresh subprocesses with alternating order. Timing excludes library loading, analysis, sorting, and snapshot writes. The baseline is `af2f770`.
|
|
99
|
+
|
|
100
|
+
| Input | Baseline median | Candidate median | Baseline allocated objects | Candidate allocated objects |
|
|
101
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
102
|
+
| Les, 1,045 methods with coverage | 0.1696 s | 0.1699 s | 1,701,309 | 1,517,509 |
|
|
103
|
+
| Forem, 5,765 methods without coverage | 0.3711 s | 0.3687 s | 2,894,109 | 2,894,109 |
|
|
104
|
+
|
|
105
|
+
Les report allocations decreased by 10.8%. Forem has no numeric coverage or scores to trim, so its allocation count is unchanged. Timing ranges overlap for both inputs; these results establish no elapsed-time improvement. All EDN hashes match, and all selected source and coverage hashes remain unchanged. [Raw samples and QA evidence](benchmark/results/2026-10-29-edn.jsonl) retain every duration and allocation count. Differential checks preserve 4,012 numeric cases on each of Ruby 3.3.11, 3.4.9, and 4.0.9. Additional EDN checks cover escaping, empty reports, exact Rational values, negative zero, and nonfinite floats on Ruby 3.3.11 and 3.4.9.
|
|
106
|
+
|
|
107
|
+
### Automatic batch analysis
|
|
108
|
+
|
|
109
|
+
Selections of at least 64 files use up to four worker processes, bounded by available processors. Smaller selections, unsupported fork platforms, and hosts with other live Ruby threads remain serial. `--jobs 1` forces serial execution. Result order and all report writes remain controlled by the parent.
|
|
110
|
+
|
|
111
|
+
The following campaign compares the same `2f23c80` library with `--jobs 1` against its automatic default. It isolates worker selection without changing the loaded library. Each mode has seven alternating measured subprocess runs after one excluded warmup.
|
|
112
|
+
|
|
113
|
+
| Project | Mode | Serial median | Automatic median | Time reduction |
|
|
114
|
+
| --- | --- | ---: | ---: | ---: |
|
|
115
|
+
| Les | Complexity only | 0.1960 s | 0.1579 s | 19.4% |
|
|
116
|
+
| Les | SimpleCov coverage | 0.2438 s | 0.1800 s | 26.1% |
|
|
117
|
+
| Forem | Complexity only | 0.6207 s | 0.3822 s | 38.4% |
|
|
118
|
+
| Steep | Complexity only | 0.3215 s | 0.2160 s | 32.8% |
|
|
119
|
+
| YARD | Complexity only | 0.2507 s | 0.1732 s | 30.9% |
|
|
120
|
+
|
|
121
|
+
A separate paired campaign against the earlier optimized serial implementation, `6a1701b`, measured reductions of 19.6% to 40.0%. All reports matched. The raw data keeps this comparison separate from the original-baseline and worker-override campaigns.
|
|
122
|
+
|
|
123
|
+
Real-project subsets support the 64-file automatic threshold. The phase experiment includes source reads, parsing, scoring, fork startup, cancellation-thread startup, and result transfer. It excludes discovery, coverage loading, sorting, formatting, and process startup. Each trial has one excluded warmup batch; worker order rotates across seven fresh subprocess trials.
|
|
124
|
+
|
|
125
|
+
| Project | Selected files | Serial phase median | Four-worker phase median |
|
|
126
|
+
| --- | ---: | ---: | ---: |
|
|
127
|
+
| Forem | 16 | 0.0063 s | 0.0087 s |
|
|
128
|
+
| Forem | 32 | 0.0124 s | 0.0114 s |
|
|
129
|
+
| Forem | 64 | 0.0272 s | 0.0198 s |
|
|
130
|
+
| Forem | 128 | 0.0439 s | 0.0241 s |
|
|
131
|
+
| Les, coverage | 16 | 0.0068 s | 0.0116 s |
|
|
132
|
+
| Les, coverage | 32 | 0.0174 s | 0.0157 s |
|
|
133
|
+
| Les, coverage | 64 | 0.0301 s | 0.0197 s |
|
|
134
|
+
| Les, coverage | 128 | 0.0648 s | 0.0307 s |
|
|
135
|
+
|
|
136
|
+
Four workers lose at 16 files, show smaller gains at 32, and reduce phase time by 27% to 35% at 64. On the full selections, automatic analysis-and-scoring time was 0.1599 s versus 0.4082 s for Forem, and 0.0575 s versus 0.1186 s for Les. Median total parent-plus-child CPU time increased from 0.4073 s to 0.4615 s on Forem, and from 0.1180 s to 0.1608 s on Les: increases of 13.3% and 36.2%.
|
|
137
|
+
|
|
138
|
+
Seven alternating memory probes per configuration sampled the sum of CLI parent and worker RSS every 10 ms. This includes startup and reporting; fork telemetry and polling perturb timing, so these probes support no latency claim. Shared pages are counted once per process, and sampling can miss short peaks. These are not measurements of unique physical memory.
|
|
139
|
+
|
|
140
|
+
| Project | Serial median sampled peak sum RSS | Automatic median sampled peak sum RSS |
|
|
141
|
+
| --- | ---: | ---: |
|
|
142
|
+
| Forem | 73,678,848 bytes | 194,854,912 bytes |
|
|
143
|
+
| Les, coverage | 47,005,696 bytes | 112,443,392 bytes |
|
|
144
|
+
|
|
145
|
+
Independent QA compared serial baseline `6a1701b` with automatic mode and `--jobs 1/2/4` through actual CLI subprocesses on all four projects. It preserved stdout, stderr, exit statuses, source and coverage inputs, and existing snapshots across three Ruby versions. Controlled probes exercised ordering, exact Rational scores, Unicode, payloads larger than a pipe buffer, malformed results, abnormal exits, first-error ordering, multithreaded fallback, cancellation, and custom SIGCHLD reapers. All probes finished without remaining children. Cleanup made no PID signals and did not replace host signal handlers. The main suite additionally covers unavailable fork, partial startup failure, and SIGINT cancellation.
|
|
146
|
+
|
|
147
|
+
Review found a stale-PID signaling race when a host reaped worker statuses. The repaired implementation cancels through private pipes and uses a child monitor thread, then validates complete result payloads even if the host has consumed the exit status. The two reproducing regression tests and the full 86-test suite pass. The measurements above include this repair.
|
|
148
|
+
|
|
149
|
+
A separate [process-group SIGINT probe](benchmark/results/2026-10-29-batch-sigint.json) delivered SIGINT after all four workers started. The CLI terminated on signal 2, preserved its existing snapshot, emitted no report, and left no processes in its group.
|
|
150
|
+
|
|
151
|
+
Small-input CLI comparisons against `6a1701b` remained serial. Medians for 16 and 32 files ranged from effectively unchanged to 4.0% slower, with overlapping timing ranges. The largest median difference was 5.1 ms. A follow-up rejected deferred loading of `Etc`: `Tempfile` loads it transitively, and repeated load and CLI measurements showed no clear benefit. [The rejected experiment](benchmark/results/2026-10-29-startup-screen.json) retains all samples and its prototype description. EDN serialization was screened separately above. These measurements do not establish a performance maximum.
|
|
152
|
+
|
|
153
|
+
### Batch screening prototype
|
|
154
|
+
|
|
155
|
+
An earlier throwaway fork-based prototype screened analysis and scoring across files before the production worker implementation. These historical phase measurements exclude CLI startup, discovery, sorting, formatting, and saving. Seven measured rounds followed one excluded warmup round; worker order rotated. Selected file contents and all returned entries matched across worker counts. The prototype samples are retained in the earlier report-and-score campaign.
|
|
156
|
+
|
|
157
|
+
| Project and mode | Worker processes | Median elapsed time | Median total CPU time |
|
|
158
|
+
| --- | ---: | ---: | ---: |
|
|
159
|
+
| Forem, complexity only | 1 | 0.4104 s | 0.4059 s |
|
|
160
|
+
| Forem, complexity only | 2 | 0.2656 s | 0.4365 s |
|
|
161
|
+
| Forem, complexity only | 4 | 0.1504 s | 0.4507 s |
|
|
162
|
+
| Les, coverage | 1 | 0.1220 s | 0.1211 s |
|
|
163
|
+
| Les, coverage | 2 | 0.0776 s | 0.1377 s |
|
|
164
|
+
| Les, coverage | 4 | 0.0566 s | 0.1532 s |
|
|
165
|
+
|
|
166
|
+
Four workers reduced prototype phase latency by 63% on Forem and 54% on Les, while increasing total CPU time by 11% and 26%. Total CPU includes parent and child processes. The production implementation and its resource costs are measured separately above.
|
data/README.md
CHANGED
|
@@ -46,7 +46,7 @@ Run these commands from this checkout with Ruby 3.3 or newer:
|
|
|
46
46
|
```sh
|
|
47
47
|
bundle install
|
|
48
48
|
gem build crap4ruby.gemspec
|
|
49
|
-
gem install ./crap4ruby-0.2.
|
|
49
|
+
gem install ./crap4ruby-0.2.2.gem
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
To use Bundler in the measured project, add this checkout to that project's `Gemfile`:
|
|
@@ -97,6 +97,7 @@ crap4ruby does not configure SimpleCov, delete reports, install dependencies, or
|
|
|
97
97
|
| `--coverage-command COMMAND` | Run a shell command that refreshes the selected report. |
|
|
98
98
|
| `--no-coverage` | Report complexity with unknown coverage and CRAP. |
|
|
99
99
|
| `--no-save` | Print results without creating or replacing `.metrics/crap.edn`. |
|
|
100
|
+
| `--jobs N` | Set a positive worker count; `1` forces serial analysis. The default selects workers automatically. |
|
|
100
101
|
| `--threshold NUMBER` | Check this finite, nonnegative limit; exit 2 for a measured violation, or 1 if an unknown score could exceed it. |
|
|
101
102
|
| `-h`, `--help` | Print usage. |
|
|
102
103
|
| `-v`, `--version` | Print the gem version. |
|
|
@@ -126,6 +127,8 @@ bundle exec crap4ruby --no-coverage --no-save
|
|
|
126
127
|
`--no-save` preserves the terminal report and threshold exit codes while leaving the snapshot unchanged.
|
|
127
128
|
It also works with coverage enabled.
|
|
128
129
|
|
|
130
|
+
Selections of at least 64 files automatically use up to four worker processes, limited by the available processors. Workers reduce analysis latency by using more CPU and memory. Use `--jobs 1` to keep analysis in one process, or set a larger count explicitly. The count never exceeds the selected file count. Platforms without fork support and hosts with other live Ruby threads use serial analysis. Worker startup resource failures also fall back to serial analysis. Reports, scores, and snapshot behavior are the same in both modes.
|
|
131
|
+
|
|
129
132
|
## Complexity and coverage
|
|
130
133
|
|
|
131
134
|
The scoring engine uses this formula, with coverage expressed as a fraction between 0 and 1:
|
data/lib/crap4ruby/analyzer.rb
CHANGED
|
@@ -17,7 +17,14 @@ module Crap4Ruby
|
|
|
17
17
|
repeated_combination select! sort! each_key each_pair each_value
|
|
18
18
|
fetch fetch_values has_key? merge merge! transform_keys transform_keys!
|
|
19
19
|
transform_values transform_values!
|
|
20
|
-
].freeze
|
|
20
|
+
].to_h { |name| [name, true] }.freeze
|
|
21
|
+
|
|
22
|
+
# Configures whether analysis collects body lines for coverage scoring.
|
|
23
|
+
#
|
|
24
|
+
# @param coverage [Boolean] collect body lines; false leaves MethodInfo#lines nil
|
|
25
|
+
def initialize(coverage: true)
|
|
26
|
+
@coverage = coverage
|
|
27
|
+
end
|
|
21
28
|
|
|
22
29
|
# Analyzes method bodies with Prism and the RuboCop 1.92.1 counting rules.
|
|
23
30
|
#
|
|
@@ -44,52 +51,57 @@ module Crap4Ruby
|
|
|
44
51
|
# @param scope [Array<String>] lexical namespace components
|
|
45
52
|
# @param singleton [Boolean] whether definitions belong to a singleton class
|
|
46
53
|
# @param path [String] source label
|
|
54
|
+
# @param methods [Array<MethodInfo>] ordered output shared by the traversal
|
|
47
55
|
# @return [Array<MethodInfo>] discovered method definitions
|
|
48
|
-
def discover(node, scope, singleton, path)
|
|
49
|
-
return
|
|
56
|
+
def discover(node, scope, singleton, path, methods = [])
|
|
57
|
+
return methods unless node
|
|
50
58
|
|
|
51
59
|
case node
|
|
52
60
|
when Prism::ClassNode, Prism::ModuleNode
|
|
53
|
-
discover_class(node, scope, singleton, path)
|
|
61
|
+
discover_class(node, scope, singleton, path, methods)
|
|
54
62
|
when Prism::SingletonClassNode
|
|
55
63
|
owner = node.expression.is_a?(Prism::SelfNode) ? scope : qualify(node.expression, scope)
|
|
56
|
-
|
|
64
|
+
discover(node.body, owner, true, path, methods) if owner
|
|
57
65
|
when Prism::DefNode
|
|
58
|
-
discover_method(node, scope, singleton, path)
|
|
66
|
+
discover_method(node, scope, singleton, path, methods)
|
|
59
67
|
when Prism::CallNode
|
|
60
|
-
discover_call(node, scope, singleton, path)
|
|
68
|
+
discover_call(node, scope, singleton, path, methods)
|
|
61
69
|
else
|
|
62
|
-
node.compact_child_nodes.
|
|
70
|
+
node.compact_child_nodes.each { |child| discover(child, scope, singleton, path, methods) }
|
|
63
71
|
end
|
|
72
|
+
methods
|
|
64
73
|
end
|
|
65
74
|
|
|
66
|
-
def discover_class(node, scope, singleton, path)
|
|
67
|
-
|
|
68
|
-
methods = node.is_a?(Prism::ClassNode) ? discover(node.superclass, scope, singleton, path) : []
|
|
75
|
+
def discover_class(node, scope, singleton, path, methods)
|
|
76
|
+
discover(node.superclass, scope, singleton, path, methods) if node.is_a?(Prism::ClassNode)
|
|
69
77
|
owner = qualify(node.constant_path, scope, declaration: true)
|
|
70
|
-
|
|
78
|
+
discover(node.body, owner, false, path, methods) if owner
|
|
71
79
|
end
|
|
72
80
|
|
|
73
|
-
def discover_call(node, scope, singleton, path)
|
|
81
|
+
def discover_call(node, scope, singleton, path, methods)
|
|
74
82
|
name = defined_name(node)
|
|
75
83
|
block = node.block
|
|
76
84
|
if name && block.is_a?(Prism::BlockNode)
|
|
77
|
-
discover_block(block, name, scope, singleton, path)
|
|
85
|
+
discover_block(block, name, scope, singleton, path, methods)
|
|
78
86
|
else
|
|
79
|
-
node.compact_child_nodes.
|
|
87
|
+
node.compact_child_nodes.each { |child| discover(child, scope, singleton, path, methods) }
|
|
80
88
|
end
|
|
81
89
|
end
|
|
82
90
|
|
|
83
|
-
def discover_block(block, name, scope, singleton, path)
|
|
91
|
+
def discover_block(block, name, scope, singleton, path, methods)
|
|
84
92
|
raise EncodingError unless name.valid_encoding?
|
|
85
93
|
|
|
86
94
|
name = name.encode(Encoding::UTF_8)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
95
|
+
if @coverage
|
|
96
|
+
parameters = block.parameters
|
|
97
|
+
header = parameters.is_a?(Prism::BlockParametersNode) ? parameters.location : block.opening_loc
|
|
98
|
+
lines = body_lines(block.body, after: header.end_line, ending: block.location)
|
|
99
|
+
end
|
|
90
100
|
name = "self.#{name}" if singleton
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
methods << MethodInfo.new(name, scope.empty? ? "Object" : scope.join("::"),
|
|
102
|
+
1 + decisions(block.body), lines, path)
|
|
103
|
+
discover(block.parameters, scope, singleton, path, methods)
|
|
104
|
+
discover(block.body, scope, singleton, path, methods)
|
|
93
105
|
rescue EncodingError
|
|
94
106
|
raise Error, "#{path}:#{block.location.start_line}: define_method name must be representable in UTF-8"
|
|
95
107
|
end
|
|
@@ -111,18 +123,21 @@ module Crap4Ruby
|
|
|
111
123
|
# @param scope [Array<String>] lexical namespace components
|
|
112
124
|
# @param singleton [Boolean] whether the enclosing scope is a singleton class
|
|
113
125
|
# @param path [String] source label
|
|
114
|
-
# @
|
|
115
|
-
|
|
126
|
+
# @param methods [Array<MethodInfo>] ordered output shared by the traversal
|
|
127
|
+
# @return [void]
|
|
128
|
+
def discover_method(node, scope, singleton, path, methods)
|
|
116
129
|
owner = method_owner(node, scope)
|
|
117
|
-
return
|
|
130
|
+
return unless owner
|
|
118
131
|
|
|
119
132
|
name = "#{"self." if singleton || node.receiver}#{node.name}"
|
|
120
|
-
|
|
121
|
-
|
|
133
|
+
if @coverage
|
|
134
|
+
header = node.equal_loc || node.rparen_loc || node.parameters&.location || node.name_loc
|
|
135
|
+
lines = body_lines(node.body, after: header.end_line, ending: node.location)
|
|
136
|
+
end
|
|
122
137
|
complexity = 1 + decisions(node.body)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
138
|
+
methods << MethodInfo.new(name, owner.empty? ? "Object" : owner.join("::"), complexity, lines, path)
|
|
139
|
+
discover(node.parameters, scope, singleton, path, methods)
|
|
140
|
+
discover(node.body, scope, singleton, path, methods)
|
|
126
141
|
end
|
|
127
142
|
|
|
128
143
|
def method_owner(node, scope)
|
|
@@ -201,10 +216,21 @@ module Crap4Ruby
|
|
|
201
216
|
def decision?(node)
|
|
202
217
|
return !node.begin_modifier? if node.is_a?(Prism::WhileNode) || node.is_a?(Prism::UntilNode)
|
|
203
218
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
219
|
+
case node.type
|
|
220
|
+
when :if_node, :unless_node, :for_node, :when_node, :in_node,
|
|
221
|
+
:rescue_modifier_node, :and_node, :or_node,
|
|
222
|
+
:call_and_write_node, :call_or_write_node,
|
|
223
|
+
:class_variable_and_write_node, :class_variable_or_write_node,
|
|
224
|
+
:constant_and_write_node, :constant_or_write_node,
|
|
225
|
+
:constant_path_and_write_node, :constant_path_or_write_node,
|
|
226
|
+
:global_variable_and_write_node, :global_variable_or_write_node,
|
|
227
|
+
:index_and_write_node, :index_or_write_node,
|
|
228
|
+
:instance_variable_and_write_node, :instance_variable_or_write_node,
|
|
229
|
+
:local_variable_and_write_node, :local_variable_or_write_node
|
|
230
|
+
true
|
|
231
|
+
else
|
|
232
|
+
node.is_a?(Prism::BeginNode) && !node.rescue_clause.nil?
|
|
233
|
+
end
|
|
208
234
|
end
|
|
209
235
|
|
|
210
236
|
def safe_navigation?(node, seen)
|
|
@@ -225,7 +251,7 @@ module Crap4Ruby
|
|
|
225
251
|
end
|
|
226
252
|
|
|
227
253
|
def iteration?(node)
|
|
228
|
-
return false unless node.is_a?(Prism::CallNode) && ITERATING_METHODS.
|
|
254
|
+
return false unless node.is_a?(Prism::CallNode) && ITERATING_METHODS.key?(node.name)
|
|
229
255
|
|
|
230
256
|
block = node.block
|
|
231
257
|
return false unless block
|
|
@@ -236,10 +262,12 @@ module Crap4Ruby
|
|
|
236
262
|
end
|
|
237
263
|
|
|
238
264
|
def boundary?(node)
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
265
|
+
case node
|
|
266
|
+
when Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode
|
|
267
|
+
true
|
|
268
|
+
else
|
|
242
269
|
!defined_name(node).nil?
|
|
270
|
+
end
|
|
243
271
|
end
|
|
244
272
|
|
|
245
273
|
# Selects body lines without nested methods, classes, or modules.
|
|
@@ -257,7 +285,7 @@ module Crap4Ruby
|
|
|
257
285
|
return nil if start.location.start_line <= after
|
|
258
286
|
|
|
259
287
|
excluded = [] #: Array[Integer]
|
|
260
|
-
last_line =
|
|
288
|
+
last_line = body_end_node(node, excluded).location.end_line
|
|
261
289
|
lines = (start.location.start_line..last_line).to_a - excluded
|
|
262
290
|
return nil if lines.include?(ending.end_line) && trailing_code?(ending)
|
|
263
291
|
|
|
@@ -270,14 +298,15 @@ module Crap4Ruby
|
|
|
270
298
|
end
|
|
271
299
|
|
|
272
300
|
# Heredoc interpolations can extend beyond their parent node's location.
|
|
273
|
-
|
|
274
|
-
|
|
301
|
+
# Compare byte offsets to defer line resolution until a range is needed.
|
|
302
|
+
def body_end_node(node, excluded)
|
|
303
|
+
last = node
|
|
275
304
|
node.compact_child_nodes.each do |child|
|
|
276
|
-
|
|
277
|
-
|
|
305
|
+
child_end = body_end_node(child, excluded)
|
|
306
|
+
last = child_end if child_end.end_offset > last.end_offset
|
|
278
307
|
end
|
|
279
|
-
excluded.concat((node.location.start_line..
|
|
280
|
-
|
|
308
|
+
excluded.concat((node.location.start_line..last.location.end_line).to_a) if boundary?(node)
|
|
309
|
+
last
|
|
281
310
|
end
|
|
282
311
|
end
|
|
283
312
|
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module Crap4Ruby
|
|
6
|
+
# Analyzes selected files and scores their methods in input order.
|
|
7
|
+
module Batch
|
|
8
|
+
# Reads Ruby source without executing it and preserves method order per file.
|
|
9
|
+
#
|
|
10
|
+
# @param files [Array<String>] source paths in analysis order
|
|
11
|
+
# @param coverage [Coverage, nil] measured coverage, or nil for complexity only
|
|
12
|
+
# @param jobs [Integer, nil] worker count, or nil for automatic selection
|
|
13
|
+
# @return [Array<Entry>] scored methods in file and traversal order
|
|
14
|
+
# @raise [ArgumentError] if the requested worker count is not positive
|
|
15
|
+
def self.call(files, coverage, jobs: nil)
|
|
16
|
+
raise ArgumentError, "jobs must be positive" if jobs && jobs < 1
|
|
17
|
+
|
|
18
|
+
count = worker_count(files.size, jobs)
|
|
19
|
+
count > 1 ? parallel(files, coverage, count) : serial(files, coverage)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def worker_count(size, jobs)
|
|
26
|
+
return 1 if size < 2 || jobs == 1 || (!jobs && size < 64)
|
|
27
|
+
return 1 unless Process.respond_to?(:fork) && Thread.list.one?
|
|
28
|
+
|
|
29
|
+
[size, jobs || [4, Etc.nprocessors].min].min
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def serial(files, coverage)
|
|
33
|
+
analyzer = Analyzer.new(coverage: !coverage.nil?)
|
|
34
|
+
files.flat_map do |path|
|
|
35
|
+
analyzer.call(File.binread(path), path: path).map { |method| Score.call(method, coverage) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def parallel(files, coverage, count)
|
|
40
|
+
# @type var workers: Array[worker]
|
|
41
|
+
workers = [] #: Array[worker]
|
|
42
|
+
begin
|
|
43
|
+
files.each_slice((files.size + count - 1) / count) do |chunk|
|
|
44
|
+
Thread.handle_interrupt(Exception => :never) { start_worker(chunk, coverage, workers) }
|
|
45
|
+
end
|
|
46
|
+
rescue NotImplementedError, Errno::EAGAIN, Errno::ENOMEM, Errno::EMFILE, Errno::ENFILE
|
|
47
|
+
cleanup(workers)
|
|
48
|
+
return serial(files, coverage)
|
|
49
|
+
end
|
|
50
|
+
results = workers.map { |worker| receive(worker) }
|
|
51
|
+
results.flat_map do |result|
|
|
52
|
+
raise result if result.is_a?(StandardError)
|
|
53
|
+
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
ensure
|
|
57
|
+
cleanup(workers)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def start_worker(files, coverage, workers)
|
|
61
|
+
# @type var pipes: Array[IO]
|
|
62
|
+
pipes = []
|
|
63
|
+
pid = nil #: Integer?
|
|
64
|
+
begin
|
|
65
|
+
2.times { pipes.concat(IO.pipe) }
|
|
66
|
+
pid = Process.fork do
|
|
67
|
+
run_child(pipes, files, coverage, workers)
|
|
68
|
+
end
|
|
69
|
+
workers << [pipes.fetch(0), pid, pipes.fetch(3)]
|
|
70
|
+
ensure
|
|
71
|
+
close_pipe(pipes[1])
|
|
72
|
+
close_pipe(pipes[2])
|
|
73
|
+
unless pid
|
|
74
|
+
close_pipe(pipes[0])
|
|
75
|
+
close_pipe(pipes[3])
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def run_child(pipes, files, coverage, workers)
|
|
81
|
+
# @type var status: Integer
|
|
82
|
+
status = 1
|
|
83
|
+
workers.each do |worker|
|
|
84
|
+
close_pipe(worker[0])
|
|
85
|
+
close_pipe(worker[2])
|
|
86
|
+
end
|
|
87
|
+
close_pipe(pipes.fetch(0))
|
|
88
|
+
close_pipe(pipes.fetch(3))
|
|
89
|
+
cancellation = pipes.fetch(2)
|
|
90
|
+
Thread.new do
|
|
91
|
+
cancellation.read
|
|
92
|
+
Process.exit!(1)
|
|
93
|
+
end
|
|
94
|
+
result = child_result(files, coverage)
|
|
95
|
+
Marshal.dump(result, pipes.fetch(1))
|
|
96
|
+
status = 0
|
|
97
|
+
ensure
|
|
98
|
+
begin
|
|
99
|
+
close_pipe(pipes[1])
|
|
100
|
+
ensure
|
|
101
|
+
Process.exit!(status)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def child_result(files, coverage)
|
|
106
|
+
serial(files, coverage)
|
|
107
|
+
rescue StandardError => error
|
|
108
|
+
error
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def receive(worker)
|
|
112
|
+
# @type var reader: IO
|
|
113
|
+
reader = worker[0]
|
|
114
|
+
data = reader.read
|
|
115
|
+
status = reap(worker)
|
|
116
|
+
if status && !status.success?
|
|
117
|
+
raise Error,
|
|
118
|
+
"analysis worker failed (exit #{status.exitstatus || "signal #{status.termsig}"})"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
payload = decode(data)
|
|
122
|
+
return payload if payload.is_a?(StandardError)
|
|
123
|
+
return payload if payload.is_a?(Array) && payload.all?(Entry)
|
|
124
|
+
|
|
125
|
+
raise Error, "analysis worker returned invalid results"
|
|
126
|
+
rescue EOFError, TypeError, ArgumentError => error
|
|
127
|
+
raise Error, "analysis worker returned an invalid payload: #{error.message}"
|
|
128
|
+
ensure
|
|
129
|
+
reader.close unless reader.closed?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def decode(data)
|
|
133
|
+
# Only this call's child processes supply Marshal data; receive validates its shape.
|
|
134
|
+
# rubocop:disable-next Security/MarshalLoad
|
|
135
|
+
Marshal.load(data)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def cleanup(workers)
|
|
139
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
140
|
+
workers.each do |worker|
|
|
141
|
+
close_pipe(worker[2])
|
|
142
|
+
close_pipe(worker[0])
|
|
143
|
+
end
|
|
144
|
+
# Close every cancellation channel before waiting for any worker.
|
|
145
|
+
# rubocop:disable-next Style/CombinableLoops
|
|
146
|
+
workers.each { |worker| reap(worker) }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def reap(worker)
|
|
151
|
+
pid = worker[1]
|
|
152
|
+
return unless pid
|
|
153
|
+
|
|
154
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
155
|
+
# @type var status: Process::Status?
|
|
156
|
+
begin
|
|
157
|
+
_, status = Process.wait2(pid)
|
|
158
|
+
rescue Errno::ECHILD
|
|
159
|
+
# A host's SIGCHLD handler may have consumed the exit status.
|
|
160
|
+
status = nil
|
|
161
|
+
end
|
|
162
|
+
worker[1] = nil
|
|
163
|
+
status
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def close_pipe(pipe)
|
|
168
|
+
pipe.close if pipe && !pipe.closed?
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
data/lib/crap4ruby/cli.rb
CHANGED
|
@@ -33,7 +33,7 @@ module Crap4Ruby
|
|
|
33
33
|
|
|
34
34
|
files = Discovery.call(root: root, source_roots: options[:source_roots], paths: paths, changed: options[:changed])
|
|
35
35
|
coverage = load_coverage(options, root) unless options[:no_coverage] || files.empty?
|
|
36
|
-
entries = Report.sorted(
|
|
36
|
+
entries = Report.sorted(Batch.call(files, coverage, jobs: options[:jobs]))
|
|
37
37
|
unless options[:no_save]
|
|
38
38
|
path = Report.write(entries, root: root)
|
|
39
39
|
@stderr.puts("Wrote #{path}")
|
|
@@ -47,12 +47,6 @@ module Crap4Ruby
|
|
|
47
47
|
|
|
48
48
|
private
|
|
49
49
|
|
|
50
|
-
def analyze(files, coverage)
|
|
51
|
-
files.flat_map do |path|
|
|
52
|
-
Analyzer.new.call(File.binread(path), path: path).map { |method| Score.call(method, coverage) }
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
|
|
56
50
|
# Removes parsed options from arguments and returns the remaining path selectors.
|
|
57
51
|
#
|
|
58
52
|
# @param arguments [Array<String>] mutable command-line tokens
|
|
@@ -78,6 +72,12 @@ module Crap4Ruby
|
|
|
78
72
|
end
|
|
79
73
|
cli.on("--no-coverage", "Complexity only; coverage and CRAP are N/A") { options[:no_coverage] = true }
|
|
80
74
|
cli.on("--no-save", "Print results without writing .metrics/crap.edn") { options[:no_save] = true }
|
|
75
|
+
cli.on("--jobs N", Integer, "Worker processes (default: automatic; 1 disables workers)") do |jobs|
|
|
76
|
+
# @type var jobs: Integer
|
|
77
|
+
raise OptionParser::InvalidArgument, "jobs must be positive" if jobs < 1
|
|
78
|
+
|
|
79
|
+
options[:jobs] = jobs
|
|
80
|
+
end
|
|
81
81
|
cli.on("--use-existing-coverage", "Read reports without running tests (the default)") do
|
|
82
82
|
options[:existing] = true
|
|
83
83
|
end
|
data/lib/crap4ruby/coverage.rb
CHANGED
|
@@ -79,7 +79,10 @@ module Crap4Ruby
|
|
|
79
79
|
|
|
80
80
|
# Every element was validated above; Steep cannot narrow an array through all?.
|
|
81
81
|
lines = lines #: Array[Integer?]
|
|
82
|
-
|
|
82
|
+
# @type var previous: Array[Integer?]?
|
|
83
|
+
previous = @files[key]
|
|
84
|
+
return @files[key] = lines.dup unless previous
|
|
85
|
+
|
|
83
86
|
@files[key] = Array.new([previous.size, lines.size].max) do |index|
|
|
84
87
|
[previous[index], lines[index]].compact.max
|
|
85
88
|
end
|
data/lib/crap4ruby/discovery.rb
CHANGED
|
@@ -66,8 +66,12 @@ module Crap4Ruby
|
|
|
66
66
|
name = File.basename(path)
|
|
67
67
|
if File.directory?(path)
|
|
68
68
|
Find.prune if path != root && (name.start_with?(".") || EXCLUDED.include?(name) || File.symlink?(path))
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
else
|
|
70
|
+
unless File.symlink?(path) || name.start_with?(".") || name.match?(/\A(?:test|spec)_|_(?:test|spec)\.rb\z/)
|
|
71
|
+
paths << path
|
|
72
|
+
end
|
|
73
|
+
# The file type is known; skip Find's repeated lstat.
|
|
74
|
+
Find.prune
|
|
71
75
|
end
|
|
72
76
|
end
|
|
73
77
|
paths
|
data/lib/crap4ruby/report.rb
CHANGED
|
@@ -26,11 +26,8 @@ module Crap4Ruby
|
|
|
26
26
|
entry.crap ? format("%.1f", entry.crap) : "N/A"]
|
|
27
27
|
end
|
|
28
28
|
widths = rows.transpose.map { |column| column.map(&:length).max }
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
index < 2 ? value.ljust(widths[index]) : value.rjust(widths[index])
|
|
32
|
-
end.join(" ")
|
|
33
|
-
end
|
|
29
|
+
template = "%-#{widths[0]}s %-#{widths[1]}s %#{widths[2]}s %#{widths[3]}s %#{widths[4]}s"
|
|
30
|
+
lines = rows.map { |row| format(template, *row) }
|
|
34
31
|
lines.insert(1, "-" * lines.first.length)
|
|
35
32
|
"#{(["CRAP Report", "==========="] + lines).join("\n")}\n"
|
|
36
33
|
end
|
|
@@ -63,7 +60,8 @@ module Crap4Ruby
|
|
|
63
60
|
def self.number(value)
|
|
64
61
|
return "nil" if value.nil?
|
|
65
62
|
|
|
66
|
-
text = format("%.4f", value)
|
|
63
|
+
text = format("%.4f", value)
|
|
64
|
+
text.sub!(/0+\z/, "")
|
|
67
65
|
text.end_with?(".") ? "#{text}0" : text
|
|
68
66
|
end
|
|
69
67
|
|
data/lib/crap4ruby/score.rb
CHANGED
|
@@ -12,8 +12,12 @@ module Crap4Ruby
|
|
|
12
12
|
def self.call(method, coverage)
|
|
13
13
|
fraction = coverage&.fraction(method)
|
|
14
14
|
if fraction
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
# Combine integer terms before constructing the exact score.
|
|
16
|
+
denominator = fraction.denominator
|
|
17
|
+
uncovered = denominator - fraction.numerator
|
|
18
|
+
cube = denominator * denominator * denominator
|
|
19
|
+
complexity = method.complexity
|
|
20
|
+
crap = Rational((complexity * complexity * uncovered * uncovered * uncovered) + (complexity * cube), cube)
|
|
17
21
|
end
|
|
18
22
|
Entry.new(method.name, method.namespace, method.complexity, fraction && (fraction * 100).to_f, crap, method.path)
|
|
19
23
|
end
|
data/lib/crap4ruby/version.rb
CHANGED
data/lib/crap4ruby.rb
CHANGED
|
@@ -26,7 +26,7 @@ module Crap4Ruby
|
|
|
26
26
|
# attr_reader :namespace
|
|
27
27
|
# # @return [Integer] cyclomatic complexity of the body
|
|
28
28
|
# attr_reader :complexity
|
|
29
|
-
# # @return [Array<Integer>, nil] one-based body lines, or nil when
|
|
29
|
+
# # @return [Array<Integer>, nil] one-based body lines, or nil when disabled or ambiguous
|
|
30
30
|
# attr_reader :lines
|
|
31
31
|
# # @return [String] source path supplied to the analyzer
|
|
32
32
|
# attr_reader :path
|
|
@@ -50,6 +50,7 @@ end
|
|
|
50
50
|
require_relative "crap4ruby/analyzer"
|
|
51
51
|
require_relative "crap4ruby/coverage"
|
|
52
52
|
require_relative "crap4ruby/score"
|
|
53
|
+
require_relative "crap4ruby/batch"
|
|
53
54
|
require_relative "crap4ruby/report"
|
|
54
55
|
require_relative "crap4ruby/discovery"
|
|
55
56
|
require_relative "crap4ruby/cli"
|
data/sig/crap4ruby.rbs
CHANGED
|
@@ -31,14 +31,16 @@ module Crap4Ruby
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
class Analyzer
|
|
34
|
-
|
|
34
|
+
@coverage: bool
|
|
35
|
+
ITERATING_METHODS: Hash[Symbol, bool]
|
|
36
|
+
def initialize: (?coverage: bool) -> void
|
|
35
37
|
def call: (String source, path: String) -> Array[MethodInfo]
|
|
36
38
|
private
|
|
37
|
-
def discover: (Prism::Node? node, Array[String] scope, bool singleton, String path) -> Array[MethodInfo]
|
|
38
|
-
def discover_class: (Prism::ClassNode | Prism::ModuleNode node, Array[String] scope, bool singleton, String path
|
|
39
|
-
def discover_call: (Prism::CallNode node, Array[String] scope, bool singleton, String path
|
|
40
|
-
def discover_method: (Prism::DefNode node, Array[String] scope, bool singleton, String path
|
|
41
|
-
def discover_block: (Prism::BlockNode block, String name, Array[String] scope, bool singleton, String path
|
|
39
|
+
def discover: (Prism::Node? node, Array[String] scope, bool singleton, String path, ?Array[MethodInfo] methods) -> Array[MethodInfo]
|
|
40
|
+
def discover_class: (Prism::ClassNode | Prism::ModuleNode node, Array[String] scope, bool singleton, String path, Array[MethodInfo] methods) -> void
|
|
41
|
+
def discover_call: (Prism::CallNode node, Array[String] scope, bool singleton, String path, Array[MethodInfo] methods) -> void
|
|
42
|
+
def discover_method: (Prism::DefNode node, Array[String] scope, bool singleton, String path, Array[MethodInfo] methods) -> void
|
|
43
|
+
def discover_block: (Prism::BlockNode block, String name, Array[String] scope, bool singleton, String path, Array[MethodInfo] methods) -> void
|
|
42
44
|
def defined_name: (Prism::Node node) -> String?
|
|
43
45
|
def method_owner: (Prism::DefNode node, Array[String] scope) -> Array[String]?
|
|
44
46
|
def qualify: (Prism::Node? node, Array[String] scope, ?declaration: bool) -> Array[String]?
|
|
@@ -51,7 +53,7 @@ module Crap4Ruby
|
|
|
51
53
|
def boundary?: (Prism::Node node) -> bool
|
|
52
54
|
def body_lines: (Prism::Node? node, after: Integer, ending: Prism::Location) -> Array[Integer]?
|
|
53
55
|
def trailing_code?: (Prism::Location location) -> bool
|
|
54
|
-
def
|
|
56
|
+
def body_end_node: (Prism::Node node, Array[Integer] excluded) -> Prism::Node
|
|
55
57
|
end
|
|
56
58
|
|
|
57
59
|
type json = nil | bool | Integer | Float | String | Array[json] | Hash[String, json]
|
|
@@ -79,6 +81,22 @@ module Crap4Ruby
|
|
|
79
81
|
def self.call: (MethodInfo method, Coverage? coverage) -> Entry
|
|
80
82
|
end
|
|
81
83
|
|
|
84
|
+
module Batch
|
|
85
|
+
type worker = [IO, Integer?, IO]
|
|
86
|
+
def self.call: (Array[String] files, Coverage? coverage, ?jobs: Integer?) -> Array[Entry]
|
|
87
|
+
def self.worker_count: (Integer size, Integer? jobs) -> Integer
|
|
88
|
+
def self.serial: (Array[String] files, Coverage? coverage) -> Array[Entry]
|
|
89
|
+
def self.parallel: (Array[String] files, Coverage? coverage, Integer count) -> Array[Entry]
|
|
90
|
+
def self.start_worker: (Array[String] files, Coverage? coverage, Array[worker] workers) -> void
|
|
91
|
+
def self.run_child: (Array[IO] pipes, Array[String] files, Coverage? coverage, Array[worker] workers) -> void
|
|
92
|
+
def self.child_result: (Array[String] files, Coverage? coverage) -> (Array[Entry] | StandardError)
|
|
93
|
+
def self.receive: (worker worker) -> (Array[Entry] | StandardError)
|
|
94
|
+
def self.decode: (String data) -> Object
|
|
95
|
+
def self.cleanup: (Array[worker] workers) -> void
|
|
96
|
+
def self.reap: (worker worker) -> Process::Status?
|
|
97
|
+
def self.close_pipe: (IO? pipe) -> void
|
|
98
|
+
end
|
|
99
|
+
|
|
82
100
|
module Report
|
|
83
101
|
def self.sorted: (Array[Entry] entries) -> Array[Entry]
|
|
84
102
|
def self.table: (Array[Entry] entries) -> String
|
|
@@ -95,13 +113,13 @@ module Crap4Ruby
|
|
|
95
113
|
|
|
96
114
|
class CLI
|
|
97
115
|
type options = { root: String, source_roots: Array[String], changed: bool, coverage: String,
|
|
98
|
-
?help: bool, ?version: bool, ?no_coverage: bool, ?no_save: bool, ?existing: bool, ?command: String,
|
|
116
|
+
?help: bool, ?version: bool, ?no_coverage: bool, ?no_save: bool, ?existing: bool, ?command: String,
|
|
117
|
+
?threshold: Rational, ?jobs: Integer }
|
|
99
118
|
@stdout: _Output
|
|
100
119
|
@stderr: _Output
|
|
101
120
|
def initialize: (?stdout: _Output, ?stderr: _Output) -> void
|
|
102
121
|
def run: (Array[String] arguments) -> Integer
|
|
103
122
|
private
|
|
104
|
-
def analyze: (Array[String] files, Coverage? coverage) -> Array[Entry]
|
|
105
123
|
def parse_options: (Array[String] arguments) -> [options, OptionParser, Array[String]]
|
|
106
124
|
def validate_options: (options options) -> void
|
|
107
125
|
def threshold_status: (Array[Entry] entries, Rational? threshold) -> Integer
|
data/sig/process.rbs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Fork-related APIs missing or incomplete in the bundled core signatures.
|
|
2
|
+
module Process
|
|
3
|
+
def self.fork: () { () -> void } -> Integer
|
|
4
|
+
def self.exit!: (?Integer status) -> bot
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class Thread
|
|
8
|
+
def self.list: () -> Array[Thread]
|
|
9
|
+
| ...
|
|
10
|
+
def self.handle_interrupt: [T] (Hash[Class, Symbol] mask) { () -> T } -> T
|
|
11
|
+
| ...
|
|
12
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: crap4ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Leonid Svyatov
|
|
@@ -40,12 +40,14 @@ files:
|
|
|
40
40
|
- CODE_OF_CONDUCT.md
|
|
41
41
|
- CONTRIBUTING.md
|
|
42
42
|
- LICENSE
|
|
43
|
+
- PERFORMANCE.md
|
|
43
44
|
- README.md
|
|
44
45
|
- SECURITY.md
|
|
45
46
|
- VERSIONING.md
|
|
46
47
|
- exe/crap4ruby
|
|
47
48
|
- lib/crap4ruby.rb
|
|
48
49
|
- lib/crap4ruby/analyzer.rb
|
|
50
|
+
- lib/crap4ruby/batch.rb
|
|
49
51
|
- lib/crap4ruby/cli.rb
|
|
50
52
|
- lib/crap4ruby/coverage.rb
|
|
51
53
|
- lib/crap4ruby/discovery.rb
|
|
@@ -57,6 +59,7 @@ files:
|
|
|
57
59
|
- scoring-audit.md
|
|
58
60
|
- sig/crap4ruby.rbs
|
|
59
61
|
- sig/manifest.yaml
|
|
62
|
+
- sig/process.rbs
|
|
60
63
|
- sig/stdlib.rbs
|
|
61
64
|
homepage: https://github.com/svyatov/crap4ruby
|
|
62
65
|
licenses:
|