crap4ruby 0.1.0 → 0.1.1
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/CODE_OF_CONDUCT.md +90 -0
- data/CONTRIBUTING.md +50 -3
- data/README.md +21 -7
- data/SECURITY.md +15 -0
- data/VERSIONING.md +30 -0
- data/lib/crap4ruby/analyzer.rb +48 -3
- data/lib/crap4ruby/cli.rb +60 -4
- data/lib/crap4ruby/coverage.rb +28 -0
- data/lib/crap4ruby/discovery.rb +35 -2
- data/lib/crap4ruby/report.rb +26 -0
- data/lib/crap4ruby/score.rb +8 -1
- data/lib/crap4ruby/version.rb +2 -1
- data/lib/crap4ruby.rb +39 -1
- data/sig/crap4ruby.rbs +96 -0
- data/sig/manifest.yaml +8 -0
- data/sig/stdlib.rbs +10 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ef4ff5757bc98e35d4f9a59b34c2de273c8372b4b3019f0f1cd2234393900150
|
|
4
|
+
data.tar.gz: 7d05a4a6376b345e41ccaaba2311392e22608c4712ae61bae210a8ad392f540a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d0f0ae1b378c537b8eaac2972fa8bf866aeb3b0771fe0e66062b7e51309e2d08ae45c91a697ed948f5cb2e3837b2d0d7c21064c2fbf3542c9a764a1295bed790
|
|
7
|
+
data.tar.gz: b9454920d722a74389ae717afaa0cf01205b16ba08aeea24a814161ebf3b519ad2388d2b82a2a78bb9e8b244b723f5f8fcfd8c7198b9e0c55bb4e93564a7c5e3
|
data/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
All notable changes are recorded here using [Keep a Changelog 2.0.0](https://keepachangelog.com/en/2.0.0/).
|
|
4
|
+
Version numbers follow [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) as defined in the [versioning policy](VERSIONING.md).
|
|
5
|
+
|
|
3
6
|
## [Unreleased]
|
|
4
7
|
|
|
8
|
+
## [0.1.1] - 2026-10-28
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Document single-file analysis, contribution and private reporting policies, and the public compatibility policy.
|
|
13
|
+
- Add a YARD maintainer reference and link to gem documentation from RubyGems metadata.
|
|
14
|
+
|
|
5
15
|
## [0.1.0] - 2026-10-28
|
|
6
16
|
|
|
7
17
|
### Added
|
|
@@ -16,5 +26,6 @@
|
|
|
16
26
|
|
|
17
27
|
- Split CLI execution, AST discovery, and coverage loading into smaller methods without changing their behavior or scoring rules.
|
|
18
28
|
|
|
19
|
-
[Unreleased]: https://github.com/svyatov/crap4ruby/compare/v0.1.
|
|
29
|
+
[Unreleased]: https://github.com/svyatov/crap4ruby/compare/v0.1.1...HEAD
|
|
30
|
+
[0.1.1]: https://github.com/svyatov/crap4ruby/compare/v0.1.0...v0.1.1
|
|
20
31
|
[0.1.0]: https://github.com/svyatov/crap4ruby/releases/tag/v0.1.0
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Contributor Covenant 3.0 Code of Conduct
|
|
2
|
+
|
|
3
|
+
## Our Pledge
|
|
4
|
+
|
|
5
|
+
We pledge to make our community welcoming, safe, and equitable for all.
|
|
6
|
+
|
|
7
|
+
We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics, neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or religion, national or social origin, socio-economic position, level of education, or other status. The same privileges of participation are extended to everyone who participates in good faith and in accordance with this Covenant.
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## Encouraged Behaviors
|
|
11
|
+
|
|
12
|
+
While acknowledging differences in social norms, we all strive to meet our community's expectations for positive behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture, background, or native language.
|
|
13
|
+
|
|
14
|
+
With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared values, including:
|
|
15
|
+
|
|
16
|
+
1. Respecting the **purpose of our community**, our activities, and our ways of gathering.
|
|
17
|
+
2. Engaging **kindly and honestly** with others.
|
|
18
|
+
3. Respecting **different viewpoints** and experiences.
|
|
19
|
+
4. **Taking responsibility** for our actions and contributions.
|
|
20
|
+
5. Gracefully giving and accepting **constructive feedback**.
|
|
21
|
+
6. Committing to **repairing harm** when it occurs.
|
|
22
|
+
7. Behaving in other ways that promote and sustain the **well-being of our community**.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
## Restricted Behaviors
|
|
26
|
+
|
|
27
|
+
We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are violations of this Code of Conduct.
|
|
28
|
+
|
|
29
|
+
1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any clear request to stop.
|
|
30
|
+
2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of people.
|
|
31
|
+
3. **Stereotyping or discrimination.** Characterizing anyone’s personality or behavior on the basis of immutable identities or traits.
|
|
32
|
+
4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or purpose of the community.
|
|
33
|
+
5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their permission.
|
|
34
|
+
6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group.
|
|
35
|
+
7. Behaving in other ways that **threaten the well-being** of our community.
|
|
36
|
+
|
|
37
|
+
### Other Restrictions
|
|
38
|
+
|
|
39
|
+
1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade enforcement actions.
|
|
40
|
+
2. **Failing to credit sources.** Not properly crediting the sources of content you contribute.
|
|
41
|
+
3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the community.
|
|
42
|
+
4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other restricted behaviors.
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
## Reporting an Issue
|
|
46
|
+
|
|
47
|
+
Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help avoid conflicts and minimize harm.
|
|
48
|
+
|
|
49
|
+
When an incident does occur, it is important to report it promptly. To report a possible violation, email [leonid@svyatov.com](mailto:leonid@svyatov.com). Leonid Svyatov monitors this address and will respond within 14 days.
|
|
50
|
+
|
|
51
|
+
Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon resolution.
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
## Addressing and Repairing Harm
|
|
55
|
+
|
|
56
|
+
If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be skipped.
|
|
57
|
+
|
|
58
|
+
1) Warning
|
|
59
|
+
1) Event: A violation involving a single incident or series of incidents.
|
|
60
|
+
2) Consequence: A private, written warning from the Community Moderators.
|
|
61
|
+
3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking clarification on expectations.
|
|
62
|
+
2) Temporarily Limited Activities
|
|
63
|
+
1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a more serious violation.
|
|
64
|
+
2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the seriousness of the situation and give the community members involved time to process the incident. The cooldown period may be limited to particular communication channels or interactions with particular community members.
|
|
65
|
+
3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and impact, and being thoughtful about re-entering community spaces after the period is over.
|
|
66
|
+
3) Temporary Suspension
|
|
67
|
+
1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a single serious violation.
|
|
68
|
+
2) Consequence: A private written warning with conditions for return from suspension. In general, temporary suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions.
|
|
69
|
+
3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for return, and being thoughtful about how to reintegrate with the community when the suspension is lifted.
|
|
70
|
+
4) Permanent Ban
|
|
71
|
+
1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or a violation so serious that the Community Moderators determine there is no way to keep the community safe with this person as a member.
|
|
72
|
+
2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working through other remedies has failed to change the behavior.
|
|
73
|
+
3) Repair: There is no possible repair in cases of this severity.
|
|
74
|
+
|
|
75
|
+
This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their discretion and judgment, in keeping with the best interests of our community.
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
## Scope
|
|
79
|
+
|
|
80
|
+
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public or other spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
## Attribution
|
|
84
|
+
|
|
85
|
+
This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/).
|
|
86
|
+
|
|
87
|
+
Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy of this license, visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/)
|
|
88
|
+
|
|
89
|
+
For answers to common questions about Contributor Covenant, see the FAQ at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional enforcement and community guideline resources can be found at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement ladder was inspired by the work of [Mozilla’s code of conduct team](https://github.com/mozilla/inclusion).
|
|
90
|
+
|
data/CONTRIBUTING.md
CHANGED
|
@@ -1,15 +1,44 @@
|
|
|
1
1
|
# Contributing
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Outside contributions are welcome. This guide defines the project's [acceptance criteria](#acceptance-criteria).
|
|
4
|
+
|
|
5
|
+
## Development
|
|
6
|
+
|
|
7
|
+
Use Ruby 3.3 or newer. Install [Betterleaks](https://github.com/betterleaks/betterleaks) with the `betterleaks` command on your PATH.
|
|
8
|
+
Set up dependencies and Git hooks, then run the suite:
|
|
4
9
|
|
|
5
10
|
```sh
|
|
6
|
-
|
|
11
|
+
bin/setup
|
|
7
12
|
bundle exec rake
|
|
8
13
|
```
|
|
9
14
|
|
|
10
|
-
|
|
15
|
+
Before each commit, hooks scan staged changes for secrets and whitespace errors.
|
|
16
|
+
Commit messages must use Conventional Commits.
|
|
17
|
+
Before each push, hooks run `bundle exec rake`.
|
|
18
|
+
Put machine-specific hook settings in the ignored `lefthook-local.yml` file.
|
|
19
|
+
|
|
20
|
+
The default check runs Steep, requires 100% typed calls across every Ruby file, runs tests with fresh SimpleCov coverage, and validates YARD documentation.
|
|
21
|
+
Every library method's CRAP score must be at most 10.
|
|
11
22
|
Use `bundle exec rake test` for tests alone. Open `coverage/index.html` to inspect line coverage.
|
|
12
23
|
|
|
24
|
+
Run `bundle exec rake types` for type checks alone.
|
|
25
|
+
Keep library signatures in `sig/` and signatures for tests and development tools in `sig-development/`.
|
|
26
|
+
The type gate rejects unchecked Ruby files, untyped signatures, and any call that Steep cannot type.
|
|
27
|
+
Add new source locations to `Steepfile` when the gate reports an unchecked file.
|
|
28
|
+
|
|
29
|
+
Document public library methods and data readers with YARD summaries, parameter types, return types, and relevant exceptions.
|
|
30
|
+
Explain private methods when their constraints or side effects need context.
|
|
31
|
+
Keep types consistent with `sig/` and include examples when they clarify usage.
|
|
32
|
+
The Ruby reference is for maintainers and does not extend the [public compatibility policy](VERSIONING.md).
|
|
33
|
+
|
|
34
|
+
Run `bundle exec rake docs` to generate the reference, including private methods, then open `doc/index.html`.
|
|
35
|
+
Run `bundle exec rake docs:check` to reject YARD warnings, undocumented public methods, and readers without explicit documentation and return types.
|
|
36
|
+
Generated files in `doc/` and `.yardoc/` are ignored.
|
|
37
|
+
|
|
38
|
+
After changing dependencies, use Ruby 3.3 and the Bundler version recorded in `Gemfile.lock`.
|
|
39
|
+
Run `bundle exec rake lock` to refresh both lockfiles and their checksums.
|
|
40
|
+
CI installs the release tools from `gemfiles/release/Gemfile.lock` in frozen mode.
|
|
41
|
+
|
|
13
42
|
Add a regression test for a behavior change. Use temporary projects for filesystem, Git, coverage, and CLI tests.
|
|
14
43
|
Keep analyzed code out of the tool's process. Preserve the previous snapshot when analysis fails.
|
|
15
44
|
|
|
@@ -17,3 +46,21 @@ For defect reports, include the Ruby version, command, expected result, actual r
|
|
|
17
46
|
Coverage defects also need a matching excerpt from `.resultset.json`. Remove application secrets before sharing examples.
|
|
18
47
|
|
|
19
48
|
Changes use Conventional Commits. Keep code, tests, and documentation for one behavior in a focused commit.
|
|
49
|
+
|
|
50
|
+
## Acceptance criteria
|
|
51
|
+
|
|
52
|
+
- Keep each change focused on one behavior or correction.
|
|
53
|
+
- Include tests for new functionality and regression tests for behavior changes.
|
|
54
|
+
- Pass `bundle exec rake`, including the method CRAP limit.
|
|
55
|
+
- Update documentation when user-visible behavior changes.
|
|
56
|
+
- Follow the [code of conduct](CODE_OF_CONDUCT.md).
|
|
57
|
+
|
|
58
|
+
## Submit a change
|
|
59
|
+
|
|
60
|
+
Fork the repository and create a branch from `main`. Commit your change, push the branch to your fork, and open a pull request against `svyatov/crap4ruby:main`.
|
|
61
|
+
Describe the behavior being changed and any effect on existing users. Link the related issue when one exists.
|
|
62
|
+
|
|
63
|
+
## Decisions and succession
|
|
64
|
+
|
|
65
|
+
Leonid Svyatov (@svyatov) is the sole maintainer and decides which changes to merge and release.
|
|
66
|
+
No succession arrangement exists if he stops maintaining the project.
|
data/README.md
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
crap4ruby identifies complex, under-tested Ruby methods using the Change Risk Anti-Pattern (CRAP) metric.
|
|
4
4
|
|
|
5
|
-
- Analyzes Ruby 3.3+
|
|
6
|
-
-
|
|
7
|
-
-
|
|
5
|
+
- Analyzes Ruby 3.3+ methods without loading application code.
|
|
6
|
+
- Ranks methods using complexity and test coverage, rather than complexity alone.
|
|
7
|
+
- Reads existing SimpleCov reports and merges coverage across test suites.
|
|
8
|
+
- Checks one file, directories, or only files changed in Git.
|
|
8
9
|
|
|
9
10
|
## Install
|
|
10
11
|
|
|
@@ -14,12 +15,22 @@ Ruby 3.3 or newer is required.
|
|
|
14
15
|
gem install crap4ruby
|
|
15
16
|
```
|
|
16
17
|
|
|
17
|
-
In
|
|
18
|
+
In an empty directory, create a sample and analyze it:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
printf 'def eligible?(age) = age >= 18\n' > sample.rb
|
|
22
|
+
crap4ruby --no-coverage sample.rb
|
|
23
|
+
# Reports eligible? with CC 1; coverage and CRAP are N/A.
|
|
24
|
+
# Writes .metrics/crap.edn.
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
In an existing project, select the source you want to measure:
|
|
18
28
|
|
|
19
29
|
```sh
|
|
20
30
|
crap4ruby --no-coverage
|
|
21
31
|
crap4ruby
|
|
22
32
|
crap4ruby --threshold 30
|
|
33
|
+
crap4ruby app/models/user.rb
|
|
23
34
|
crap4ruby app/models lib/billing
|
|
24
35
|
```
|
|
25
36
|
|
|
@@ -35,7 +46,7 @@ Run these commands from this checkout with Ruby 3.3 or newer:
|
|
|
35
46
|
```sh
|
|
36
47
|
bundle install
|
|
37
48
|
gem build crap4ruby.gemspec
|
|
38
|
-
gem install ./crap4ruby-0.1.
|
|
49
|
+
gem install ./crap4ruby-0.1.1.gem
|
|
39
50
|
```
|
|
40
51
|
|
|
41
52
|
To use Bundler in the measured project, add this checkout to that project's `Gemfile`:
|
|
@@ -48,7 +59,7 @@ Then run `bundle install` and `bundle exec crap4ruby` in that project.
|
|
|
48
59
|
|
|
49
60
|
## Set up coverage
|
|
50
61
|
|
|
51
|
-
[SimpleCov](https://github.com/simplecov-ruby/simplecov) must start before the application loads. Add it to the measured project's
|
|
62
|
+
[SimpleCov](https://github.com/simplecov-ruby/simplecov) must start before the application loads. Add it to the measured project's `Gemfile`:
|
|
52
63
|
|
|
53
64
|
```ruby
|
|
54
65
|
gem "simplecov", require: false, group: :test
|
|
@@ -104,7 +115,7 @@ Every successful analysis replaces the snapshot, including filtered analyses and
|
|
|
104
115
|
|
|
105
116
|
## Complexity and coverage
|
|
106
117
|
|
|
107
|
-
The
|
|
118
|
+
The scoring engine uses this formula, with coverage expressed as a fraction between 0 and 1:
|
|
108
119
|
|
|
109
120
|
```text
|
|
110
121
|
CRAP = CC^2 * (1 - coverage)^3 + CC
|
|
@@ -178,11 +189,14 @@ The implementation is tested on CRuby 3.3, 3.4, and 4.0. Other Ruby implementati
|
|
|
178
189
|
|
|
179
190
|
## Releases
|
|
180
191
|
|
|
192
|
+
The [versioning policy](VERSIONING.md) defines the public interface, version changes, and deprecation rules.
|
|
193
|
+
|
|
181
194
|
Signed `v*` tags trigger the release workflow. The tag must match `Crap4Ruby::VERSION`.
|
|
182
195
|
Tests and the CRAP limit run before the gem is built. Publication requires approval in GitHub's `release` environment.
|
|
183
196
|
RubyGems trusted publishing authenticates the workflow. Sigstore signs the built gem, and RubyGems stores its attestation.
|
|
184
197
|
|
|
185
198
|
Report defects or ask questions through [GitHub issues](https://github.com/svyatov/crap4ruby/issues).
|
|
199
|
+
Leonid Svyatov maintains this project. Security fixes target the latest release; see the [security policy](SECURITY.md).
|
|
186
200
|
|
|
187
201
|
## License
|
|
188
202
|
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Report a vulnerability
|
|
4
|
+
|
|
5
|
+
Send private reports to [leonid@svyatov.com](mailto:leonid@svyatov.com). Do not disclose an unpatched vulnerability in a public issue.
|
|
6
|
+
|
|
7
|
+
Include the affected crap4ruby and Ruby versions, a minimal reproduction, and the expected security impact.
|
|
8
|
+
Remove credentials and private application data from examples.
|
|
9
|
+
|
|
10
|
+
Leonid Svyatov monitors this address and will respond within 14 days.
|
|
11
|
+
The response will confirm receipt and describe the next investigation step; it does not promise a fix within that period.
|
|
12
|
+
|
|
13
|
+
## Supported versions
|
|
14
|
+
|
|
15
|
+
Security fixes target the latest release. Reproduce the issue with that release when possible, but report suspected vulnerabilities in older releases too.
|
data/VERSIONING.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Versioning policy
|
|
2
|
+
|
|
3
|
+
crap4ruby uses [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) for its documented public interface.
|
|
4
|
+
|
|
5
|
+
## Public interface
|
|
6
|
+
|
|
7
|
+
The compatibility policy covers:
|
|
8
|
+
|
|
9
|
+
- Documented CLI options, positional file selection, and exit codes.
|
|
10
|
+
- Documented complexity, coverage, and CRAP scoring semantics.
|
|
11
|
+
- The `.metrics/crap.edn` schema, including field meanings and value types.
|
|
12
|
+
- The minimum supported Ruby version.
|
|
13
|
+
|
|
14
|
+
Terminal table spacing, diagnostic wording, and Ruby classes are implementation details.
|
|
15
|
+
YARD documents the Ruby interfaces for maintainers; those interfaces are outside this compatibility policy.
|
|
16
|
+
Use the CLI and documented snapshot format when integrating with other tools.
|
|
17
|
+
|
|
18
|
+
## Version changes
|
|
19
|
+
|
|
20
|
+
During initial development (`0.y.z`), incompatible public changes increase MINOR. Compatible additions, fixes, and deprecations increase PATCH.
|
|
21
|
+
Starting with `1.0.0`, incompatible public changes increase MAJOR, compatible additions and deprecations increase MINOR, and compatible fixes increase PATCH.
|
|
22
|
+
|
|
23
|
+
Each release tag, gem version, and changelog entry must identify the same version. Published versions are never replaced.
|
|
24
|
+
Documentation and CI maintenance alone do not require a release.
|
|
25
|
+
|
|
26
|
+
## Deprecation
|
|
27
|
+
|
|
28
|
+
Deprecate a public interface in a release before removing it in a later release.
|
|
29
|
+
The notice must name its replacement or migration path and the earliest removal version, using a CLI diagnostic where applicable.
|
|
30
|
+
Removal requires a later MINOR release during initial development or a later MAJOR release after `1.0.0`.
|
data/lib/crap4ruby/analyzer.rb
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Crap4Ruby
|
|
4
|
+
# Finds explicit Ruby method definitions without executing source code.
|
|
4
5
|
class Analyzer
|
|
6
|
+
# Analyzes method bodies and parameter defaults with Prism.
|
|
7
|
+
#
|
|
8
|
+
# @param source [String] Ruby source text
|
|
9
|
+
# @param path [String] source label used in results and parse errors
|
|
10
|
+
# @return [Array<MethodInfo>] methods in traversal order, including nested definitions
|
|
11
|
+
# @raise [Error] if source cannot be parsed
|
|
12
|
+
# @example Analyze a method without loading it
|
|
13
|
+
# Analyzer.new.call("def eligible?(age) = age >= 18", path: "person.rb")
|
|
5
14
|
def call(source, path:)
|
|
6
15
|
result = Prism.parse(source)
|
|
7
16
|
unless result.success?
|
|
@@ -13,6 +22,13 @@ module Crap4Ruby
|
|
|
13
22
|
|
|
14
23
|
private
|
|
15
24
|
|
|
25
|
+
# Tracks lexical owners; skips owners that cannot be resolved statically.
|
|
26
|
+
#
|
|
27
|
+
# @param node [Prism::Node, nil] subtree to visit
|
|
28
|
+
# @param scope [Array<String>] lexical namespace components
|
|
29
|
+
# @param singleton [Boolean] whether definitions belong to a singleton class
|
|
30
|
+
# @param path [String] source label
|
|
31
|
+
# @return [Array<MethodInfo>] discovered method definitions
|
|
16
32
|
def discover(node, scope, singleton, path)
|
|
17
33
|
return [] unless node
|
|
18
34
|
|
|
@@ -30,6 +46,13 @@ module Crap4Ruby
|
|
|
30
46
|
end
|
|
31
47
|
end
|
|
32
48
|
|
|
49
|
+
# Measures a definition independently from definitions nested within it.
|
|
50
|
+
#
|
|
51
|
+
# @param node [Prism::DefNode] method definition
|
|
52
|
+
# @param scope [Array<String>] lexical namespace components
|
|
53
|
+
# @param singleton [Boolean] whether the enclosing scope is a singleton class
|
|
54
|
+
# @param path [String] source label
|
|
55
|
+
# @return [Array<MethodInfo>] this method and its nested definitions
|
|
33
56
|
def discover_method(node, scope, singleton, path)
|
|
34
57
|
owner = node.receiver && !node.receiver.is_a?(Prism::SelfNode) ? qualify(node.receiver, scope) : scope
|
|
35
58
|
return [] unless owner
|
|
@@ -41,6 +64,13 @@ module Crap4Ruby
|
|
|
41
64
|
discover(node.body, scope, singleton, path)
|
|
42
65
|
end
|
|
43
66
|
|
|
67
|
+
# Resolves constant syntax without looking up runtime constants.
|
|
68
|
+
# Declarations append names; references can reuse a lexical ancestor.
|
|
69
|
+
#
|
|
70
|
+
# @param node [Prism::Node, nil] namespace expression
|
|
71
|
+
# @param scope [Array<String>] lexical namespace components
|
|
72
|
+
# @param declaration [Boolean] whether the expression declares a namespace
|
|
73
|
+
# @return [Array<String>, nil] qualified components, or nil for dynamic expressions
|
|
44
74
|
def qualify(node, scope, declaration: false)
|
|
45
75
|
case node
|
|
46
76
|
when Prism::ParenthesesNode
|
|
@@ -50,10 +80,16 @@ module Crap4Ruby
|
|
|
50
80
|
ancestor = scope.rindex(name) unless declaration
|
|
51
81
|
ancestor ? scope.take(ancestor + 1) : scope + [name]
|
|
52
82
|
when Prism::ConstantPathNode
|
|
53
|
-
|
|
83
|
+
# @type var parent: Array[String]?
|
|
84
|
+
parent = node.parent ? qualify(node.parent, scope) : []
|
|
85
|
+
parent&.+([node.name.to_s])
|
|
54
86
|
end
|
|
55
87
|
end
|
|
56
88
|
|
|
89
|
+
# Counts branch operations, including safe navigation, outside nested definitions.
|
|
90
|
+
#
|
|
91
|
+
# @param node [Prism::Node, nil] subtree to measure
|
|
92
|
+
# @return [Integer] number of decisions, excluding the method's base complexity
|
|
57
93
|
def decisions(node)
|
|
58
94
|
return 0 unless node
|
|
59
95
|
return 0 if boundary?(node)
|
|
@@ -62,7 +98,12 @@ module Crap4Ruby
|
|
|
62
98
|
:when_node, :in_node, :rescue_node, :rescue_modifier_node, :and_node, :or_node].include?(node.type) ||
|
|
63
99
|
node.type.to_s.match?(/_(?:and|or)_write_node\z/)
|
|
64
100
|
count = decision ? 1 : 0
|
|
65
|
-
|
|
101
|
+
case node
|
|
102
|
+
when Prism::CallNode, Prism::CallAndWriteNode, Prism::CallOrWriteNode,
|
|
103
|
+
Prism::CallOperatorWriteNode, Prism::CallTargetNode, Prism::IndexAndWriteNode,
|
|
104
|
+
Prism::IndexOrWriteNode, Prism::IndexOperatorWriteNode, Prism::IndexTargetNode
|
|
105
|
+
count += 1 if node.safe_navigation?
|
|
106
|
+
end
|
|
66
107
|
count + node.compact_child_nodes.sum { |child| decisions(child) }
|
|
67
108
|
end
|
|
68
109
|
|
|
@@ -70,11 +111,15 @@ module Crap4Ruby
|
|
|
70
111
|
[Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].any? { |type| node.is_a?(type) }
|
|
71
112
|
end
|
|
72
113
|
|
|
114
|
+
# Selects body lines without nested methods, classes, or modules.
|
|
115
|
+
#
|
|
116
|
+
# @param node [Prism::Node, nil] method body
|
|
117
|
+
# @return [Array<Integer>] one-based source lines; empty for an absent body
|
|
73
118
|
def body_lines(node)
|
|
74
119
|
return [] unless node
|
|
75
120
|
|
|
76
121
|
lines = (node.location.start_line..node.location.end_line).to_a
|
|
77
|
-
excluded = []
|
|
122
|
+
excluded = [] #: Array[Integer]
|
|
78
123
|
collect_boundaries(node, excluded)
|
|
79
124
|
lines - excluded
|
|
80
125
|
end
|
data/lib/crap4ruby/cli.rb
CHANGED
|
@@ -3,12 +3,25 @@
|
|
|
3
3
|
require "optparse"
|
|
4
4
|
|
|
5
5
|
module Crap4Ruby
|
|
6
|
+
# Coordinates file discovery, coverage, scoring, and CLI output.
|
|
6
7
|
class CLI
|
|
8
|
+
# Sets output destinations without running an analysis.
|
|
9
|
+
#
|
|
10
|
+
# @param stdout [#puts, #write] destination for reports, help, and version output
|
|
11
|
+
# @param stderr [#puts, #write] destination for diagnostics and coverage command output
|
|
7
12
|
def initialize(stdout: $stdout, stderr: $stderr)
|
|
8
13
|
@stdout = stdout
|
|
9
14
|
@stderr = stderr
|
|
10
15
|
end
|
|
11
16
|
|
|
17
|
+
# Runs the requested analysis and replaces the snapshot after successful scoring.
|
|
18
|
+
# Help and version requests produce output without writing a snapshot.
|
|
19
|
+
# Expected usage, input, filesystem, and command errors are printed to stderr.
|
|
20
|
+
#
|
|
21
|
+
# @param arguments [Array<String>] command-line tokens; option parsing mutates this array
|
|
22
|
+
# @return [Integer] 0 on success, 1 on a handled error, or 2 when a score exceeds the threshold
|
|
23
|
+
# @example Analyze complexity without coverage
|
|
24
|
+
# CLI.new.run(["--no-coverage", "--source-root", "lib"])
|
|
12
25
|
def run(arguments)
|
|
13
26
|
options, parser, paths = parse_options(arguments)
|
|
14
27
|
return @stdout.puts(parser) || 0 if options[:help]
|
|
@@ -35,18 +48,37 @@ module Crap4Ruby
|
|
|
35
48
|
|
|
36
49
|
private
|
|
37
50
|
|
|
51
|
+
# Removes parsed options from arguments and returns the remaining path selectors.
|
|
52
|
+
#
|
|
53
|
+
# @param arguments [Array<String>] mutable command-line tokens
|
|
54
|
+
# @return [Array(Hash, OptionParser, Array<String>)] options, parser, and remaining selectors
|
|
55
|
+
# @raise [OptionParser::ParseError] if an option or threshold is invalid
|
|
38
56
|
def parse_options(arguments)
|
|
57
|
+
# @type var options: options
|
|
39
58
|
options = {root: Dir.pwd, source_roots: [], changed: false, coverage: "coverage/.resultset.json"}
|
|
40
59
|
parser = OptionParser.new do |cli|
|
|
41
60
|
cli.banner = "Usage: crap4ruby [options] [file-or-directory-or-filter ...]"
|
|
42
|
-
cli.on("--root PATH", "Project root (default: current directory)")
|
|
43
|
-
|
|
61
|
+
cli.on("--root PATH", "Project root (default: current directory)") do |path|
|
|
62
|
+
# @type var path: String
|
|
63
|
+
options[:root] = path
|
|
64
|
+
end
|
|
65
|
+
cli.on("--source-root PATH", "Analyze this directory; repeat for multiple roots") do |path|
|
|
66
|
+
# @type var path: String
|
|
67
|
+
options[:source_roots] << path
|
|
68
|
+
end
|
|
44
69
|
cli.on("--changed", "Analyze Git additions and edits, including untracked files") { options[:changed] = true }
|
|
45
|
-
cli.on("--coverage PATH", "SimpleCov resultset (default: coverage/.resultset.json)")
|
|
70
|
+
cli.on("--coverage PATH", "SimpleCov resultset (default: coverage/.resultset.json)") do |path|
|
|
71
|
+
# @type var path: String
|
|
72
|
+
options[:coverage] = path
|
|
73
|
+
end
|
|
46
74
|
cli.on("--no-coverage", "Complexity only; coverage and CRAP are N/A") { options[:no_coverage] = true }
|
|
47
75
|
cli.on("--use-existing-coverage", "Read reports without running tests (the default)") { options[:existing] = true }
|
|
48
|
-
cli.on("--coverage-command COMMAND", "Run a shell command that refreshes the resultset")
|
|
76
|
+
cli.on("--coverage-command COMMAND", "Run a shell command that refreshes the resultset") do |command|
|
|
77
|
+
# @type var command: String
|
|
78
|
+
options[:command] = command
|
|
79
|
+
end
|
|
49
80
|
cli.on("--threshold NUMBER", "Exit 2 when CRAP exceeds this value") do |value|
|
|
81
|
+
# @type var value: String
|
|
50
82
|
number = Float(value)
|
|
51
83
|
raise OptionParser::InvalidArgument, "threshold must be finite and nonnegative" unless number.finite? && number >= 0
|
|
52
84
|
|
|
@@ -60,12 +92,22 @@ module Crap4Ruby
|
|
|
60
92
|
[options, parser, parser.parse(arguments)]
|
|
61
93
|
end
|
|
62
94
|
|
|
95
|
+
# Rejects a coverage command when coverage is disabled or explicitly read-only.
|
|
96
|
+
#
|
|
97
|
+
# @param options [Hash] parsed CLI options
|
|
98
|
+
# @return [void]
|
|
99
|
+
# @raise [Error] if coverage options conflict
|
|
63
100
|
def validate_options(options)
|
|
64
101
|
if options[:command] && (options[:no_coverage] || options[:existing])
|
|
65
102
|
raise Error, "--coverage-command cannot be combined with --no-coverage or --use-existing-coverage"
|
|
66
103
|
end
|
|
67
104
|
end
|
|
68
105
|
|
|
106
|
+
# Compares unrounded, known CRAP scores with a strict upper limit.
|
|
107
|
+
#
|
|
108
|
+
# @param entries [Array<Entry>] scored methods
|
|
109
|
+
# @param threshold [Float, nil] maximum allowed score, or nil to disable the check
|
|
110
|
+
# @return [Integer] 2 after reporting a violation to stderr, otherwise 0
|
|
69
111
|
def threshold_status(entries, threshold)
|
|
70
112
|
if threshold && entries.any? { |entry| entry.crap && entry.crap > threshold }
|
|
71
113
|
@stderr.puts("CRAP threshold exceeded: #{threshold}")
|
|
@@ -81,6 +123,15 @@ module Crap4Ruby
|
|
|
81
123
|
Coverage.load(report, root: root)
|
|
82
124
|
end
|
|
83
125
|
|
|
126
|
+
# Runs a shell command at root and forwards its combined output to stderr.
|
|
127
|
+
# Requires success and a changed report stamp before coverage can be consumed.
|
|
128
|
+
#
|
|
129
|
+
# @param command [String] command passed to `sh -c` with the caller's environment
|
|
130
|
+
# @param root [String] command working directory
|
|
131
|
+
# @param report [String] expected coverage report path
|
|
132
|
+
# @return [void]
|
|
133
|
+
# @raise [Error] if the command is empty, fails, or leaves the report stamp unchanged
|
|
134
|
+
# @raise [SystemCallError] if the command cannot start or report metadata cannot be read
|
|
84
135
|
def run_coverage(command, root, report)
|
|
85
136
|
raise Error, "--coverage-command must not be empty" if command.strip.empty?
|
|
86
137
|
|
|
@@ -94,6 +145,11 @@ module Crap4Ruby
|
|
|
94
145
|
raise Error, "coverage command did not refresh #{report}" if report_stamp(report) == before
|
|
95
146
|
end
|
|
96
147
|
|
|
148
|
+
# Captures metadata used to detect a newly generated or refreshed report.
|
|
149
|
+
#
|
|
150
|
+
# @param path [String] coverage report path
|
|
151
|
+
# @return [Array(Integer, Time, Time, Integer), nil] inode, modification time,
|
|
152
|
+
# change time, and size; nil if the path does not exist
|
|
97
153
|
def report_stamp(path)
|
|
98
154
|
stat = File.stat(path)
|
|
99
155
|
[stat.ino, stat.mtime, stat.ctime, stat.size]
|
data/lib/crap4ruby/coverage.rb
CHANGED
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
module Crap4Ruby
|
|
6
|
+
# Merges SimpleCov line coverage across named test suites.
|
|
6
7
|
class Coverage
|
|
8
|
+
# Reads a SimpleCov resultset, accepting legacy arrays and line-coverage objects.
|
|
9
|
+
#
|
|
10
|
+
# @param path [String] JSON resultset path, relative to the working directory or absolute
|
|
11
|
+
# @param root [String] base directory for source paths inside the report
|
|
12
|
+
# @return [Coverage] merged coverage
|
|
13
|
+
# @raise [Error] if the report cannot be read, parsed, or validated
|
|
7
14
|
def self.load(path, root:)
|
|
8
15
|
new(JSON.parse(File.read(path)), root: root)
|
|
9
16
|
rescue SystemCallError => error
|
|
@@ -12,6 +19,11 @@ module Crap4Ruby
|
|
|
12
19
|
raise Error, "#{path}: invalid SimpleCov report (#{error.message})"
|
|
13
20
|
end
|
|
14
21
|
|
|
22
|
+
# Validates and merges suite reports by the maximum hit count for each line.
|
|
23
|
+
#
|
|
24
|
+
# @param suites [Hash{String => Hash}] parsed resultset keyed by suite name
|
|
25
|
+
# @param root [String] base directory for relative source paths
|
|
26
|
+
# @raise [Error] if suites or line coverage are malformed
|
|
15
27
|
def initialize(suites, root:)
|
|
16
28
|
raise Error, "expected named test suites" unless suites.is_a?(Hash) && !suites.empty?
|
|
17
29
|
|
|
@@ -19,9 +31,16 @@ module Crap4Ruby
|
|
|
19
31
|
suites.each_value { |suite| add_suite(suite, root) }
|
|
20
32
|
end
|
|
21
33
|
|
|
34
|
+
# Measures executable body lines with positive hit counts.
|
|
35
|
+
# Null and out-of-range lines do not enter the denominator.
|
|
36
|
+
#
|
|
37
|
+
# @param method [MethodInfo] method whose path and body lines select coverage
|
|
38
|
+
# @return [Float, nil] fraction from 0 to 1; zero for an absent file,
|
|
39
|
+
# or nil when a reported file has no measurable body lines
|
|
22
40
|
def fraction(method)
|
|
23
41
|
path = File.expand_path(method.path)
|
|
24
42
|
path = File.realpath(path) if File.exist?(path)
|
|
43
|
+
# @type var lines: Array[Integer?]?
|
|
25
44
|
lines = @files[path]
|
|
26
45
|
return 0.0 unless lines
|
|
27
46
|
|
|
@@ -40,6 +59,13 @@ module Crap4Ruby
|
|
|
40
59
|
suite["coverage"].each { |path, data| add_file(path, data, root) }
|
|
41
60
|
end
|
|
42
61
|
|
|
62
|
+
# Normalizes a source path and merges line counts across suites.
|
|
63
|
+
#
|
|
64
|
+
# @param path [String] source path from a suite report
|
|
65
|
+
# @param data [Array<Integer, nil>, Hash] legacy counts or an object with a `lines` array
|
|
66
|
+
# @param root [String] base directory for relative source paths
|
|
67
|
+
# @return [void]
|
|
68
|
+
# @raise [Error] if line counts are invalid
|
|
43
69
|
def add_file(path, data, root)
|
|
44
70
|
key = File.expand_path(path, root)
|
|
45
71
|
key = File.realpath(key) if File.exist?(key)
|
|
@@ -47,6 +73,8 @@ module Crap4Ruby
|
|
|
47
73
|
unless lines.is_a?(Array) && lines.all? { |hit| hit.nil? || (hit.is_a?(Integer) && hit >= 0) }
|
|
48
74
|
raise Error, "#{path}: expected nonnegative line counts or null"
|
|
49
75
|
end
|
|
76
|
+
# Every element was validated above; Steep cannot narrow an array through all?.
|
|
77
|
+
lines = lines #: Array[Integer?]
|
|
50
78
|
previous = @files[key] || []
|
|
51
79
|
@files[key] = Array.new([previous.size, lines.size].max) do |index|
|
|
52
80
|
[previous[index], lines[index]].compact.max
|
data/lib/crap4ruby/discovery.rb
CHANGED
|
@@ -4,9 +4,22 @@ require "find"
|
|
|
4
4
|
require "open3"
|
|
5
5
|
|
|
6
6
|
module Crap4Ruby
|
|
7
|
+
# Selects Ruby source files from explicit paths, directories, and Git changes.
|
|
7
8
|
module Discovery
|
|
9
|
+
# Directory names excluded during recursive traversal.
|
|
8
10
|
EXCLUDED = %w[test tests spec features vendor node_modules target coverage tmp log pkg build dist].freeze
|
|
9
11
|
|
|
12
|
+
# Combines source roots and existing paths, then applies filters and Git selection.
|
|
13
|
+
# Explicit files bypass traversal exclusions but must have the `.rb` extension.
|
|
14
|
+
# Defaults to the project root when no source roots or existing paths are given.
|
|
15
|
+
#
|
|
16
|
+
# @param root [String] absolute project directory
|
|
17
|
+
# @param source_roots [Array<String>] source directories, absolute or relative to root
|
|
18
|
+
# @param paths [Array<String>] existing paths or substring filters relative to root
|
|
19
|
+
# @param changed [Boolean] restrict results to staged, unstaged, and untracked files
|
|
20
|
+
# @return [Array<String>] sorted, unique real paths; substring filters use OR
|
|
21
|
+
# @raise [Error] if a source root is not a directory or Git fails
|
|
22
|
+
# @raise [SystemCallError] if paths cannot be read or resolved
|
|
10
23
|
def self.call(root:, source_roots:, paths:, changed:)
|
|
11
24
|
existing, filters = paths.partition { |path| File.exist?(File.expand_path(path, root)) }
|
|
12
25
|
roots = source_roots.map do |path|
|
|
@@ -24,8 +37,14 @@ module Crap4Ruby
|
|
|
24
37
|
files
|
|
25
38
|
end
|
|
26
39
|
|
|
40
|
+
# Walks a directory without following symlinks or entering excluded directories.
|
|
41
|
+
# Hidden files and test/spec filenames are excluded; extensions are not filtered.
|
|
42
|
+
#
|
|
43
|
+
# @param root [String] directory to traverse, including an explicitly selected hidden directory
|
|
44
|
+
# @return [Array<String>] candidate file paths in traversal order
|
|
45
|
+
# @raise [SystemCallError] if traversal fails
|
|
27
46
|
def self.walk(root)
|
|
28
|
-
paths = []
|
|
47
|
+
paths = [] #: Array[String]
|
|
29
48
|
Find.find(root) do |path|
|
|
30
49
|
name = File.basename(path)
|
|
31
50
|
if File.directory?(path)
|
|
@@ -37,6 +56,13 @@ module Crap4Ruby
|
|
|
37
56
|
paths
|
|
38
57
|
end
|
|
39
58
|
|
|
59
|
+
# Runs Git in the project directory without a shell.
|
|
60
|
+
#
|
|
61
|
+
# @param root [String] working directory for Git
|
|
62
|
+
# @param arguments [Array<String>] command and arguments
|
|
63
|
+
# @return [String] standard output, preserving NUL separators
|
|
64
|
+
# @raise [Error] if Git exits unsuccessfully
|
|
65
|
+
# @raise [SystemCallError] if Git cannot be started
|
|
40
66
|
def self.git(root, *arguments)
|
|
41
67
|
output, error, status = Open3.capture3("git", "-C", root, *arguments)
|
|
42
68
|
raise Error, "git: #{error.strip}" unless status.success?
|
|
@@ -44,11 +70,18 @@ module Crap4Ruby
|
|
|
44
70
|
output
|
|
45
71
|
end
|
|
46
72
|
|
|
73
|
+
# Finds existing staged, unstaged, and untracked files under the project directory.
|
|
74
|
+
# Deleted files are omitted and renames are treated as deletion and addition.
|
|
75
|
+
#
|
|
76
|
+
# @param root [String] directory inside a Git working tree
|
|
77
|
+
# @return [Array<String>] real paths, without extension or traversal filtering
|
|
78
|
+
# @raise [Error] if Git cannot inspect the working tree
|
|
79
|
+
# @raise [SystemCallError] if Git cannot start or a path cannot be resolved
|
|
47
80
|
def self.changed_files(root)
|
|
48
81
|
top = git(root, "rev-parse", "--show-toplevel").chomp
|
|
49
82
|
git(root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all", "--", ".")
|
|
50
83
|
.split("\0").filter_map do |entry|
|
|
51
|
-
path = File.join(top, entry[3..])
|
|
84
|
+
path = File.join(top, entry[3..].to_s)
|
|
52
85
|
File.realpath(path) if File.file?(path)
|
|
53
86
|
end
|
|
54
87
|
end
|
data/lib/crap4ruby/report.rb
CHANGED
|
@@ -4,11 +4,21 @@ require "fileutils"
|
|
|
4
4
|
require "tempfile"
|
|
5
5
|
|
|
6
6
|
module Crap4Ruby
|
|
7
|
+
# Formats analysis results and atomically replaces the project snapshot.
|
|
7
8
|
module Report
|
|
9
|
+
# Ranks known scores first, from highest to lowest.
|
|
10
|
+
# Ties use namespace, method name, then source path in ascending order.
|
|
11
|
+
#
|
|
12
|
+
# @param entries [Array<Entry>] unsorted results
|
|
13
|
+
# @return [Array<Entry>] new sorted array, without modifying the input
|
|
8
14
|
def self.sorted(entries)
|
|
9
15
|
entries.sort_by { |entry| [entry.crap ? 0 : 1, -(entry.crap || 0), entry.namespace, entry.name, entry.path] }
|
|
10
16
|
end
|
|
11
17
|
|
|
18
|
+
# Formats a terminal table, preserving entry order and displaying unknown values as N/A.
|
|
19
|
+
#
|
|
20
|
+
# @param entries [Array<Entry>] results in display order
|
|
21
|
+
# @return [String] table with one decimal place for percentages and scores, ending with a newline
|
|
12
22
|
def self.table(entries)
|
|
13
23
|
rows = [["Function", "Namespace", "CC", "Cov%", "CRAP"]] + entries.map do |entry|
|
|
14
24
|
[entry.name, entry.namespace, entry.complexity.to_s,
|
|
@@ -23,6 +33,11 @@ module Crap4Ruby
|
|
|
23
33
|
(["CRAP Report", "==========="] + lines).join("\n") + "\n"
|
|
24
34
|
end
|
|
25
35
|
|
|
36
|
+
# Serializes the crapper-compatible snapshot, preserving entry order.
|
|
37
|
+
# Source paths are omitted; unknown numeric values become EDN nil.
|
|
38
|
+
#
|
|
39
|
+
# @param entries [Array<Entry>] results in snapshot order
|
|
40
|
+
# @return [String] EDN map containing entries, ending with a newline
|
|
26
41
|
def self.edn(entries)
|
|
27
42
|
rows = entries.map do |entry|
|
|
28
43
|
" {:name #{JSON.generate(entry.name)}, :namespace #{JSON.generate(entry.namespace)}, " \
|
|
@@ -31,6 +46,10 @@ module Crap4Ruby
|
|
|
31
46
|
rows.empty? ? "{:entries []}\n" : "{:entries [\n#{rows.join("\n")}\n]}\n"
|
|
32
47
|
end
|
|
33
48
|
|
|
49
|
+
# Formats an EDN number with at most four decimal places and at least one.
|
|
50
|
+
#
|
|
51
|
+
# @param value [Float, nil] percentage or CRAP score
|
|
52
|
+
# @return [String] decimal representation, or `nil` for an unknown value
|
|
34
53
|
def self.number(value)
|
|
35
54
|
return "nil" if value.nil?
|
|
36
55
|
|
|
@@ -38,6 +57,13 @@ module Crap4Ruby
|
|
|
38
57
|
text.end_with?(".") ? "#{text}0" : text
|
|
39
58
|
end
|
|
40
59
|
|
|
60
|
+
# Atomically replaces `.metrics/crap.edn`, creating the directory if necessary.
|
|
61
|
+
# An empty selection replaces the snapshot with an empty entries vector.
|
|
62
|
+
#
|
|
63
|
+
# @param entries [Array<Entry>] results in snapshot order
|
|
64
|
+
# @param root [String] project directory that receives the snapshot
|
|
65
|
+
# @return [String] snapshot path
|
|
66
|
+
# @raise [SystemCallError, IOError] if the snapshot cannot be written or replaced
|
|
41
67
|
def self.write(entries, root:)
|
|
42
68
|
directory = File.join(root, ".metrics")
|
|
43
69
|
FileUtils.mkdir_p(directory)
|
data/lib/crap4ruby/score.rb
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Crap4Ruby
|
|
4
|
+
# Combines static complexity and measured coverage into CRAP scores.
|
|
4
5
|
module Score
|
|
6
|
+
# Calculates `complexity**2 * (1 - fraction)**3 + complexity`.
|
|
7
|
+
#
|
|
8
|
+
# @param method [MethodInfo] static method analysis
|
|
9
|
+
# @param coverage [Coverage, nil] coverage reader, or nil to disable scoring
|
|
10
|
+
# @return [Entry] result with percentage coverage; coverage and CRAP are nil
|
|
11
|
+
# when coverage is disabled or the method has no measurable lines
|
|
5
12
|
def self.call(method, coverage)
|
|
6
13
|
fraction = coverage&.fraction(method)
|
|
7
|
-
crap = method.complexity
|
|
14
|
+
crap = method.complexity * method.complexity * (1 - fraction)**3 + method.complexity if fraction
|
|
8
15
|
Entry.new(method.name, method.namespace, method.complexity, fraction && fraction * 100, crap, method.path)
|
|
9
16
|
end
|
|
10
17
|
end
|
data/lib/crap4ruby/version.rb
CHANGED
data/lib/crap4ruby.rb
CHANGED
|
@@ -3,10 +3,48 @@
|
|
|
3
3
|
require "prism"
|
|
4
4
|
require_relative "crap4ruby/version"
|
|
5
5
|
|
|
6
|
+
# Static method analysis, coverage, and CRAP reporting for the CLI.
|
|
7
|
+
# These Ruby interfaces are maintainer APIs, outside the compatibility policy.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
6
10
|
module Crap4Ruby
|
|
7
|
-
|
|
11
|
+
# Invalid source, coverage, command options, or external command results.
|
|
12
|
+
class Error < StandardError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Immutable static analysis of one explicit method definition.
|
|
8
16
|
MethodInfo = Data.define(:name, :namespace, :complexity, :lines, :path)
|
|
17
|
+
|
|
18
|
+
# Immutable analysis result with coverage and a CRAP score.
|
|
9
19
|
Entry = Data.define(:name, :namespace, :complexity, :coverage, :crap, :path)
|
|
20
|
+
|
|
21
|
+
# @!parse
|
|
22
|
+
# class Crap4Ruby::MethodInfo
|
|
23
|
+
# # @return [String] method name, prefixed with `self.` for singleton methods
|
|
24
|
+
# attr_reader :name
|
|
25
|
+
# # @return [String] lexical owner, or `Object` for a top-level method
|
|
26
|
+
# attr_reader :namespace
|
|
27
|
+
# # @return [Integer] cyclomatic complexity, including parameter defaults
|
|
28
|
+
# attr_reader :complexity
|
|
29
|
+
# # @return [Array<Integer>] one-based body lines, excluding nested definitions
|
|
30
|
+
# attr_reader :lines
|
|
31
|
+
# # @return [String] source path supplied to the analyzer
|
|
32
|
+
# attr_reader :path
|
|
33
|
+
# end
|
|
34
|
+
# class Crap4Ruby::Entry
|
|
35
|
+
# # @return [String] method name, prefixed with `self.` for singleton methods
|
|
36
|
+
# attr_reader :name
|
|
37
|
+
# # @return [String] lexical owner, or `Object` for a top-level method
|
|
38
|
+
# attr_reader :namespace
|
|
39
|
+
# # @return [Integer] cyclomatic complexity
|
|
40
|
+
# attr_reader :complexity
|
|
41
|
+
# # @return [Float, nil] percentage from 0 to 100, or nil when unmeasurable or disabled
|
|
42
|
+
# attr_reader :coverage
|
|
43
|
+
# # @return [Float, nil] CRAP score, or nil when coverage is unknown
|
|
44
|
+
# attr_reader :crap
|
|
45
|
+
# # @return [String] source path supplied to the analyzer
|
|
46
|
+
# attr_reader :path
|
|
47
|
+
# end
|
|
10
48
|
end
|
|
11
49
|
|
|
12
50
|
require_relative "crap4ruby/analyzer"
|
data/sig/crap4ruby.rbs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
module Crap4Ruby
|
|
2
|
+
VERSION: String
|
|
3
|
+
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class MethodInfo < Data
|
|
8
|
+
attr_reader name: String
|
|
9
|
+
attr_reader namespace: String
|
|
10
|
+
attr_reader complexity: Integer
|
|
11
|
+
attr_reader lines: Array[Integer]
|
|
12
|
+
attr_reader path: String
|
|
13
|
+
def self.new: (String name, String namespace, Integer complexity, Array[Integer] lines, String path) -> instance
|
|
14
|
+
| (name: String, namespace: String, complexity: Integer, lines: Array[Integer], path: String) -> instance
|
|
15
|
+
alias self.[] self.new
|
|
16
|
+
def with: (?name: String, ?namespace: String, ?complexity: Integer, ?lines: Array[Integer], ?path: String) -> instance
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Entry < Data
|
|
20
|
+
attr_reader name: String
|
|
21
|
+
attr_reader namespace: String
|
|
22
|
+
attr_reader complexity: Integer
|
|
23
|
+
attr_reader coverage: Float?
|
|
24
|
+
attr_reader crap: Float?
|
|
25
|
+
attr_reader path: String
|
|
26
|
+
def self.new: (String name, String namespace, Integer complexity, Float? coverage, Float? crap, String path) -> instance
|
|
27
|
+
| (name: String, namespace: String, complexity: Integer, coverage: Float?, crap: Float?, path: String) -> instance
|
|
28
|
+
alias self.[] self.new
|
|
29
|
+
def with: (?name: String, ?namespace: String, ?complexity: Integer, ?coverage: Float?, ?crap: Float?, ?path: String) -> instance
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class Analyzer
|
|
33
|
+
def call: (String source, path: String) -> Array[MethodInfo]
|
|
34
|
+
private
|
|
35
|
+
def discover: (Prism::Node? node, Array[String] scope, bool singleton, String path) -> Array[MethodInfo]
|
|
36
|
+
def discover_method: (Prism::DefNode node, Array[String] scope, bool singleton, String path) -> Array[MethodInfo]
|
|
37
|
+
def qualify: (Prism::Node? node, Array[String] scope, ?declaration: bool) -> Array[String]?
|
|
38
|
+
def decisions: (Prism::Node? node) -> Integer
|
|
39
|
+
def boundary?: (Prism::Node node) -> bool
|
|
40
|
+
def body_lines: (Prism::Node? node) -> Array[Integer]
|
|
41
|
+
def collect_boundaries: (Prism::Node node, Array[Integer] excluded) -> void
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
type json = nil | bool | Integer | Float | String | Array[json] | Hash[String, json]
|
|
45
|
+
|
|
46
|
+
class Coverage
|
|
47
|
+
@files: Hash[String, Array[Integer?]]
|
|
48
|
+
def self.load: (String path, root: String) -> Coverage
|
|
49
|
+
def initialize: (json suites, root: String) -> void
|
|
50
|
+
def fraction: (MethodInfo method) -> Float?
|
|
51
|
+
private
|
|
52
|
+
def add_suite: (json suite, String root) -> void
|
|
53
|
+
def add_file: (String path, json data, String root) -> void
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
module Discovery
|
|
57
|
+
EXCLUDED: Array[String]
|
|
58
|
+
def self.call: (root: String, source_roots: Array[String], paths: Array[String], changed: bool) -> Array[String]
|
|
59
|
+
def self.walk: (String root) -> Array[String]
|
|
60
|
+
def self.git: (String root, *String arguments) -> String
|
|
61
|
+
def self.changed_files: (String root) -> Array[String]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
module Score
|
|
65
|
+
def self.call: (MethodInfo method, Coverage? coverage) -> Entry
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
module Report
|
|
69
|
+
def self.sorted: (Array[Entry] entries) -> Array[Entry]
|
|
70
|
+
def self.table: (Array[Entry] entries) -> String
|
|
71
|
+
def self.edn: (Array[Entry] entries) -> String
|
|
72
|
+
def self.number: (Float? value) -> String
|
|
73
|
+
def self.write: (Array[Entry] entries, root: String) -> String
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
interface _Output
|
|
77
|
+
def puts: (*String | OptionParser objects) -> nil
|
|
78
|
+
def write: (String text) -> Integer
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class CLI
|
|
82
|
+
type options = { root: String, source_roots: Array[String], changed: bool, coverage: String,
|
|
83
|
+
?help: bool, ?version: bool, ?no_coverage: bool, ?existing: bool, ?command: String, ?threshold: Float }
|
|
84
|
+
@stdout: _Output
|
|
85
|
+
@stderr: _Output
|
|
86
|
+
def initialize: (?stdout: _Output, ?stderr: _Output) -> void
|
|
87
|
+
def run: (Array[String] arguments) -> Integer
|
|
88
|
+
private
|
|
89
|
+
def parse_options: (Array[String] arguments) -> [options, OptionParser, Array[String]]
|
|
90
|
+
def validate_options: (options options) -> void
|
|
91
|
+
def threshold_status: (Array[Entry] entries, Float? threshold) -> Integer
|
|
92
|
+
def load_coverage: (options options, String root) -> Coverage
|
|
93
|
+
def run_coverage: (String command, String root, String report) -> void
|
|
94
|
+
def report_stamp: (String path) -> [Integer, Time, Time, Integer]?
|
|
95
|
+
end
|
|
96
|
+
end
|
data/sig/manifest.yaml
ADDED
data/sig/stdlib.rbs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Complete the stdlib signatures for the subprocess options used here.
|
|
2
|
+
module Open3
|
|
3
|
+
def self.popen2e: [T] (*String command, chdir: String) { (IO input, IO output, Process::Waiter process) -> T } -> T
|
|
4
|
+
def self.capture3: (*String command, chdir: String) -> [String, String, Process::Status]
|
|
5
|
+
| ...
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
class Process::Waiter
|
|
9
|
+
def value: () -> Process::Status
|
|
10
|
+
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.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Leonid Svyatov
|
|
@@ -37,9 +37,12 @@ extensions: []
|
|
|
37
37
|
extra_rdoc_files: []
|
|
38
38
|
files:
|
|
39
39
|
- CHANGELOG.md
|
|
40
|
+
- CODE_OF_CONDUCT.md
|
|
40
41
|
- CONTRIBUTING.md
|
|
41
42
|
- LICENSE
|
|
42
43
|
- README.md
|
|
44
|
+
- SECURITY.md
|
|
45
|
+
- VERSIONING.md
|
|
43
46
|
- exe/crap4ruby
|
|
44
47
|
- lib/crap4ruby.rb
|
|
45
48
|
- lib/crap4ruby/analyzer.rb
|
|
@@ -49,10 +52,14 @@ files:
|
|
|
49
52
|
- lib/crap4ruby/report.rb
|
|
50
53
|
- lib/crap4ruby/score.rb
|
|
51
54
|
- lib/crap4ruby/version.rb
|
|
55
|
+
- sig/crap4ruby.rbs
|
|
56
|
+
- sig/manifest.yaml
|
|
57
|
+
- sig/stdlib.rbs
|
|
52
58
|
homepage: https://github.com/svyatov/crap4ruby
|
|
53
59
|
licenses:
|
|
54
60
|
- MIT
|
|
55
61
|
metadata:
|
|
62
|
+
documentation_uri: https://www.rubydoc.info/gems/crap4ruby
|
|
56
63
|
source_code_uri: https://github.com/svyatov/crap4ruby
|
|
57
64
|
bug_tracker_uri: https://github.com/svyatov/crap4ruby/issues
|
|
58
65
|
changelog_uri: https://github.com/svyatov/crap4ruby/blob/main/CHANGELOG.md
|