sonicop 26.8.101 → 26.8.102

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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CONFORMANCE.md +112 -86
  3. data/Cargo.lock +12 -9
  4. data/Cargo.toml +3 -3
  5. data/README.ja.md +37 -2
  6. data/README.md +61 -7
  7. data/lib/sonicop/version.rb +1 -1
  8. data/src/config/mod.rs +39 -0
  9. data/src/config/paths.rs +67 -10
  10. data/src/diagnostic.rs +26 -8
  11. data/src/directives.rs +59 -7
  12. data/src/engine.rs +263 -17
  13. data/src/lib.rs +1 -0
  14. data/src/magic_comment.rs +222 -0
  15. data/src/rules/layout/empty_line_after_magic_comment.rs +40 -23
  16. data/src/rules/layout/end_of_line.rs +19 -11
  17. data/src/rules/layout/line_length.rs +910 -106
  18. data/src/rules/layout/space_after_comma.rs +29 -11
  19. data/src/rules/layout/space_around_operators.rs +880 -69
  20. data/src/rules/layout/space_inside_parens.rs +10 -4
  21. data/src/rules/layout/trailing_empty_lines.rs +27 -15
  22. data/src/rules/layout/trailing_whitespace.rs +117 -13
  23. data/src/rules/lint/duplicate_methods.rs +766 -40
  24. data/src/rules/lint/mod.rs +3 -0
  25. data/src/rules/lint/syntax.rs +742 -89
  26. data/src/rules/lint/unused_block_argument.rs +117 -60
  27. data/src/rules/lint/useless_assignment.rs +533 -78
  28. data/src/rules/lint/variable_force.rs +1680 -0
  29. data/src/rules/metrics/block_length.rs +57 -19
  30. data/src/rules/metrics/class_length.rs +43 -15
  31. data/src/rules/metrics/method_length.rs +61 -2
  32. data/src/rules/metrics/module_length.rs +48 -8
  33. data/src/rules/metrics/parameter_lists.rs +162 -13
  34. data/src/rules/metrics/support.rs +206 -39
  35. data/src/rules/mod.rs +24 -1
  36. data/src/rules/naming/ascii_identifiers.rs +45 -9
  37. data/src/rules/naming/constant_name.rs +251 -18
  38. data/src/rules/naming/method_name.rs +340 -44
  39. data/src/rules/naming/support.rs +370 -9
  40. data/src/rules/naming/variable_name.rs +39 -35
  41. data/src/rules/security/eval.rs +5 -0
  42. data/src/rules/style/frozen_string_literal_comment.rs +164 -41
  43. data/src/rules/style/hash_syntax.rs +93 -24
  44. data/src/rules/style/numeric_literals.rs +157 -21
  45. data/src/rules/style/redundant_return.rs +163 -17
  46. data/src/rules/style/semicolon.rs +245 -33
  47. data/src/rules/style/string_literals.rs +502 -29
  48. data/src/rules/support.rs +57 -7
  49. data/src/source.rs +18 -0
  50. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c2383add6601b741d516e8efb4116d217555568a60b08517aa294e42a47fac4
4
- data.tar.gz: 65fcd7157dbd75afe11b9f89d069c5c89a34a907517b1c83b022716477d6e373
3
+ metadata.gz: ea2dfe101378c37802dfe7e5cf3a4e3c3595942c75c91c9f25ade094ab11e632
4
+ data.tar.gz: 01e5d99dcd5989fee69f66403712918e792d0c268ea3b82096ec260dcfd3f176
5
5
  SHA512:
6
- metadata.gz: 410780fd39347da7d2b707a0573ec6ad26d3ed852929abc37d88d492fd499eaf4286790ad09137fb737b173358c8073804aa8d4afd7e6b631630f5a740d6e0c2
7
- data.tar.gz: c0905dee79e639abd4cb472b7d7a18c60c198b34f20cef3f84d6ac6a878ce95a2c6fc7e65c7b9e6f90b6e4c32b7da4f0ca7d325ca0f7f4a49cd94c3260434417
6
+ metadata.gz: 726107889fe04bf60cf2f056c8ca7d34c5c825650426120281e9afa88d2c819d38d7342d99cdc45dd0b81a942011e5d130f6745fd7ee6cc6c5249663526d159d
7
+ data.tar.gz: 51b76434f05a2b35cecc62eda0e3636592f7058ff123ab936872f577082502301a5c609828fc5d0659abd94f3d67291aa3dbc71ddd6ffc0be2fc344a13e0bf51
data/CONFORMANCE.md CHANGED
@@ -1,109 +1,135 @@
1
1
  # RuboCop conformance
2
2
 
3
- Snapshot date: 2026-08-10
4
- Reference: RuboCop 1.89.0
5
- Corpus: the complete 1,759-file RuboCop source tree
6
- Configuration: RuboCop 1.89.0 built-in defaults (`--force-default-config`)
3
+ Snapshot date: 2026-08-12
4
+ Reference: RuboCop 1.89.0 on Ruby 4.0.6
5
+ Configuration: RuboCop 1.89.0 built-in defaults (`--force-default-config`) on both sides
7
6
 
8
7
  The bundled `config/default.yml` records the upstream version it was vendored from in its header.
9
8
  Re-fetch it with `scripts/sync_default_yml.sh <rubocop-version>`.
10
9
 
11
- The comparison covers every cop Sonicop implemented at the snapshot date, listed verbatim in the
12
- `cops` variable of the command below. Each JSON offense is normalized by path, cop, line, column,
13
- length, severity, correctability, and message. Run `sonicop --show-cops` for the current set.
14
-
15
- | Result | Count |
16
- |---|---:|
17
- | Reference offense locations | 4,052 |
18
- | Sonicop offense locations | 4,052 |
19
- | Matching locations | 4,052 |
20
- | False positives | **0** |
21
- | False negatives | **0** |
22
- | Location recall | **100%** |
23
- | Message/severity/correctability differences at matching locations | **0** |
24
-
25
- All offenses from the implemented cops match RuboCop by location, message, severity, and
26
- correctability. This includes RuboCop's source-line indexing for metric cops, line-length
27
- correctability and exemptions, directive lexing around heredocs, and target-version syntax errors.
28
-
29
- Run the harness against a local RuboCop and corpus. It defaults to `rubocop` on `PATH`
30
- (`gem install rubocop`) as the reference and `./target/release/sonicop` (`make release`) as the
31
- candidate, so both only need `--reference` / `--candidate` when they live elsewhere for example
32
- `--reference "bundle exec rubocop"` inside a bundled project:
10
+ ## What is measured
11
+
12
+ Five Ruby projects, 18,244 target files between them, are linted by both tools and compared offense
13
+ by offense. An offense is keyed by cop name, path, line and column; at each shared key the last
14
+ line, last column, length, message, severity and correctability are compared as well.
15
+
16
+ | Corpus | Commit | Target files | Reference offenses |
17
+ |---|---|---:|---:|
18
+ | rubocop/rubocop | `e82df38` | 1,765 | 4,142 |
19
+ | rails/rails | `729d2e9` | 3,550 | 117,541 |
20
+ | ruby/ruby | `52975b7` | 7,465 | 603,331 |
21
+ | Homebrew/brew | `5d49126` | 2,175 | 38,742 |
22
+ | mastodon/mastodon | `e5db3aa` | 3,289 | 7,610 |
23
+
24
+ The **target file lists match exactly** on all five not just the counts but the paths. That is
25
+ worth stating separately because file discovery is where a port silently diverges first: RuboCop
26
+ never reads `.gitignore`, applies a shebang test only to extensionless files, descends through
27
+ directory symlinks, and treats a hidden path by its *first* component rather than by any dot in it.
28
+
29
+ Only the cops Sonicop implements are compared; the reference process is restricted to them with
30
+ `--only`. RuboCop's extension plugins are not installed, so this measures the range that can be
31
+ checked without them.
33
32
 
34
33
  ```bash
35
- cops="Layout/EmptyLineAfterMagicComment,Layout/EndOfLine,Layout/LineLength,Layout/SpaceAfterComma,Layout/SpaceAroundOperators,Layout/SpaceInsideParens,Layout/TrailingEmptyLines,Layout/TrailingWhitespace,Lint/DuplicateMethods,Lint/Syntax,Lint/UnusedBlockArgument,Lint/UselessAssignment,Metrics/BlockLength,Metrics/ClassLength,Metrics/MethodLength,Metrics/ModuleLength,Metrics/ParameterLists,Naming/AsciiIdentifiers,Naming/ConstantName,Naming/MethodName,Naming/VariableName,Security/Eval,Style/FrozenStringLiteralComment,Style/HashSyntax,Style/NumericLiterals,Style/RedundantReturn,Style/Semicolon,Style/StringLiterals"
34
+ cops=$(sonicop --show-cops --force-default-config \
35
+ | grep -B3 'Implemented: true' | grep '^[A-Z].*:$' | tr -d ':' | paste -sd, -)
36
36
 
37
- scripts/conformance_diff.sh \
38
- --force-default-config \
39
- --cop "${cops}" \
40
- -- /path/to/rubocop
37
+ rubocop --force-default-config --cache false --only "$cops" -f json
38
+ sonicop --force-default-config --format json
41
39
  ```
42
40
 
43
- `Lint/Syntax` is also run independently over the corpus. The latest owayo tree-sitter grammar
44
- accepts the modern syntax in all 1,759 files, while Sonicop resolves Ruby 2.6 from the corpus
45
- gemspec and applies a post-parse syntax feature gate. Its four fatal offenses, including the legacy
46
- parser recovery diagnostics, exactly match RuboCop.
41
+ ## Results
47
42
 
48
- ## Limits of this measurement
43
+ | Corpus | Excess | Missing | Field differences |
44
+ |---|---:|---:|---|
45
+ | rubocop/rubocop | 0 | 0 | none |
46
+ | rails/rails | 0 | 0 | none |
47
+ | mastodon/mastodon | 0 | 0 | none |
48
+ | Homebrew/brew | 253 | 996 | none |
49
+ | ruby/ruby | 89 | 346 | 1 |
49
50
 
50
- The 100% above is a property of *this corpus*, not a general accuracy claim. RuboCop's own source
51
- tree is RuboCop-clean and stylistically uniform, so entire classes of input never occur in it and
52
- the comparison cannot exercise them. A cop that is never triggered contributes nothing to the
53
- match count, and its absence is indistinguishable from agreement.
51
+ Homebrew's remaining difference is entirely `Lint/Syntax`; every other cop matches exactly. Most of
52
+ ruby/ruby's is too 117 of the 346 missing and 70 of the 89 excess are `Lint/Syntax` itself. The
53
+ rest follows from it: a file the two disagree about is inspected by one tool and skipped by the
54
+ other, so every offense in it lands on one side of the ledger. Both are explained under
55
+ *Known divergences*.
54
56
 
55
- Two implemented cops are confirmed to produce **zero offenses on both sides** over this corpus, so
56
- this run says nothing about them:
57
+ Autocorrect is compared the same way, byte for byte over the whole tree: run `-a` (and separately
58
+ `-A`) with both tools from a clean checkout and diff the results. On all four corpora measured this way —
59
+ rubocop/rubocop, rails/rails, Homebrew/brew and mastodon/mastodon — the corrected trees are
60
+ **identical**.
57
61
 
58
- | Cop | Offenses in this corpus | Verified elsewhere |
59
- |---|---:|---|
60
- | `Layout/TrailingEmptyLines` | 0 | Diverges on line, column, length, and message for a file ending in a blank line |
61
- | `Naming/AsciiIdentifiers` | 0 | Rails contains exactly two occurrences; both diverge on message casing |
62
+ Run the comparison on a copy of the corpus. Autocorrect rewrites the tree in place, so a run that
63
+ shares a checkout with anything else — another comparison, a lint measurement — has both tools
64
+ reading different files and reports differences that are not there.
62
65
 
63
- Running the same harness over application code rather than linter source surfaces divergences this
64
- corpus does not. Treat a clean run on any single corpus as evidence about that corpus.
66
+ ## Reading a measurement
65
67
 
66
- Two properties of the harness itself also bound what a run can tell you:
68
+ Two traps make a run look better or worse than it is, and both have produced wrong conclusions here.
67
69
 
68
- - Only the cops passed to `--cop` are compared. Cops that RuboCop reports and Sonicop has not
69
- implemented show up as false negatives, which is expected rather than a defect.
70
- - RuboCop emits **only** `Lint/Syntax` for a file it considers fatally unparseable and suppresses
71
- every other cop for that file, while Sonicop keeps inspecting it. Whenever the two disagree
72
- about syntax validity — most easily triggered by a `TargetRubyVersion` the source does not
73
- satisfy — the false-positive count is dominated by that single behaviour rather than by cop
74
- logic. Check the reference output for `"severity": "fatal"` before reading a large false-positive
75
- count as a cop problem.
70
+ **Counts cancel out.** Comparing offense totals hides an excess and a shortfall of the same size.
71
+ Compare the *set* of locations, then compare fields at the locations both tools produced.
76
72
 
77
- Closing these gaps properly means migrating RuboCop's own spec suite, which exercises each cop
78
- against inputs written to break it. Until that lands, this document records what was measured, not
79
- an upper bound on what could differ.
73
+ **RuboCop can stop early and still emit valid JSON.** On ruby/ruby its parser raises
74
+ `invalid byte sequence in UTF-8` on `test/ruby/test_regexp.rb` and the whole run unwinds; the JSON
75
+ formatter still writes a well-formed document from an `ensure` block. The result reports 6,924 of
76
+ 7,465 files inspected, and the 541 files after that one in sort order were never looked at — so
77
+ every offense Sonicop found in them counts as "excess". Check `summary.inspected_file_count`
78
+ against `summary.target_file_count` on every run. To get a complete reference for that corpus, split
79
+ the file list into chunks and re-run each chunk past the file that killed it.
80
80
 
81
- ## Rails configuration and scale
81
+ ## Known divergences
82
82
 
83
- Rails source snapshot: `adf307b03b4241cbc0ed3821faf3b153ca6cd5cd` (Rails 8.2.0.alpha).
84
- The reference process uses RuboCop 1.89.0 with the five plugins declared by Rails:
85
- rubocop-minitest 0.40.0, rubocop-packaging 0.6.0, rubocop-performance 1.26.1,
86
- rubocop-rails 2.36.0, and rubocop-md 2.0.4.
83
+ `tests/conformance/known_divergences.yml` is the machine-checked record: a divergence that is listed
84
+ but no longer reproduces fails the test, and so does one that appears without being listed.
87
85
 
88
- This corpus exercises `AllCops/DisabledByDefault`, external plugin cops, recursive excludes,
89
- hidden paths, and the inherited `guides/.rubocop.yml` configuration.
86
+ ### Error recovery after a syntax error
90
87
 
91
- | Compatibility check | RuboCop | Sonicop | Result |
92
- |---|---:|---:|---|
93
- | Ruby target files from repository root | 3,453 | 3,453 | Exact paths and order |
94
- | Enabled cops for `activerecord/lib/active_record.rb` | 107 | 107 | Exact names and order |
95
- | Enabled cops for a file under `guides/` | 102 | 102 | Exact names and order |
96
- | Full-tree false positives | — | 0 | Pass |
97
-
98
- RuboCop also discovers 75 Markdown targets through rubocop-md. Markdown extraction is outside
99
- Sonicop's current parser scope, so those files are removed before comparing `-L` output.
100
-
101
- The full 3,453-Ruby-file offense comparison reports 10 reference locations and no Sonicop
102
- locations: zero false positives and 10 visible false negatives. The missing locations are three
103
- `Style/RedundantPercentQ`, two `Lint/RedundantCopDisableDirective`, two
104
- `Layout/IndentationWidth`, two unsupported `Lint/DuplicateMethods` cases, and one
105
- `Lint/Debugger`. Artifacts: `/tmp/sonicop-conformance-rails-full-2`.
106
-
107
- On the 402-file `activerecord/lib` subset, a warmed single run with cache and color disabled took
108
- 4.37 seconds for RuboCop and 0.35 seconds for Sonicop (`/usr/bin/time -p`): approximately
109
- **12.5x faster** on the validation machine.
88
+ RuboCop parses with `parser`, an LALR parser that recovers from an error and keeps going, emitting
89
+ further diagnostics from the recovered state. Sonicop parses with tree-sitter, which does not model
90
+ that recovery, so the follow-on diagnostics cannot be reproduced. On Homebrew this accounts for the
91
+ 996 missing `Lint/Syntax` offenses: `class definition in method body`, `dynamic constant assignment`,
92
+ `cannot assign to a keyword`, and repeated `unexpected token` inside one multi-line hash.
93
+
94
+ The first diagnostic in each file does match, which is what decides whether the file is inspected at
95
+ all: RuboCop runs no cop other than `Lint/Syntax` on a file that does not parse, and Sonicop does the
96
+ same.
97
+
98
+ ### Grammar gaps
99
+
100
+ Where tree-sitter rejects code that Ruby accepts, Sonicop reports a syntax error and — following the
101
+ rule above reports nothing else for that file. One such gap costs every offense in the file at
102
+ once, which makes them worth hunting: fixing eight lexer rules in the grammar fork took ruby/ruby
103
+ from 24 affected files to 5, and the offenses Sonicop was missing there from 2,457 to 346.
104
+
105
+ What remains are constructs whose ambiguity Ruby resolves with information a grammar does not have.
106
+ `$a?0:1` is the clearest: whether `?0` is a character literal or the start of a ternary depends on
107
+ whether the token before it completed an expression, which in turn depends on knowing that `a` is a
108
+ local variable rather than a method call.
109
+
110
+ ### Encoding — the one deliberate difference
111
+
112
+ RuboCop's autocorrect ends in a plain `File.write`, so a corrected Shift_JIS file is written back as
113
+ UTF-8 while its magic comment still claims Shift_JIS. The result no longer loads: read it back as
114
+ cp932 and you get mojibake, not the source you had.
115
+
116
+ **Sonicop writes the correction back in the encoding the file declares**, and refuses to write at all
117
+ when the correction holds a character that encoding cannot represent, leaving the file untouched
118
+ rather than substituting something else. Drop-in compatibility reaches a long way, but not as far as
119
+ reproducing data loss on purpose. This is the only place Sonicop knowingly differs, and it only
120
+ shows up on files that declare a non-UTF-8 encoding — 8 of the 18,244 files measured here.
121
+
122
+ A source declaring `ASCII-8BIT` or `binary` needs no such treatment: Ruby measures it one byte at a
123
+ time, so Sonicop reads it that way too and the bytes go back out unchanged.
124
+
125
+ ## Limits
126
+
127
+ A clean run is a property of the corpora, not a general claim. `known_divergences.yml` carries the
128
+ current list of what these corpora never exercise; the main ones are non-default configuration
129
+ values, extension plugins, and Windows line endings. Cops that never fire contribute nothing to a
130
+ match count, and their silence is indistinguishable from agreement.
131
+
132
+ Closing that gap properly means porting RuboCop's own spec suite, which exercises each cop against
133
+ inputs written to break it. Until that lands, this document records what was measured.
134
+
135
+ Timings live in the README's Performance section.
data/Cargo.lock CHANGED
@@ -243,6 +243,15 @@ version = "1.17.0"
243
243
  source = "registry+https://github.com/rust-lang/crates.io-index"
244
244
  checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
245
245
 
246
+ [[package]]
247
+ name = "encoding_rs"
248
+ version = "0.8.35"
249
+ source = "registry+https://github.com/rust-lang/crates.io-index"
250
+ checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
251
+ dependencies = [
252
+ "cfg-if",
253
+ ]
254
+
246
255
  [[package]]
247
256
  name = "equivalent"
248
257
  version = "1.0.2"
@@ -733,11 +742,12 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
733
742
 
734
743
  [[package]]
735
744
  name = "sonicop"
736
- version = "26.8.101"
745
+ version = "26.8.102"
737
746
  dependencies = [
738
747
  "anyhow",
739
748
  "assert_cmd",
740
749
  "clap",
750
+ "encoding_rs",
741
751
  "globset",
742
752
  "ignore",
743
753
  "rayon",
@@ -749,7 +759,6 @@ dependencies = [
749
759
  "tempfile",
750
760
  "tree-sitter",
751
761
  "tree-sitter-ruby",
752
- "unicode-width",
753
762
  "ureq",
754
763
  ]
755
764
 
@@ -839,7 +848,7 @@ checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
839
848
  [[package]]
840
849
  name = "tree-sitter-ruby"
841
850
  version = "26.8.100"
842
- source = "git+https://github.com/owayo/tree-sitter-ruby.git?rev=88a64c6c37c4c23d8d4d804c4ef9c4eca3c88b96#88a64c6c37c4c23d8d4d804c4ef9c4eca3c88b96"
851
+ source = "git+https://github.com/owayo/tree-sitter-ruby.git?rev=6408d8b7adff44f0b346517b56071460efd323b6#6408d8b7adff44f0b346517b56071460efd323b6"
843
852
  dependencies = [
844
853
  "cc",
845
854
  "tree-sitter-language",
@@ -851,12 +860,6 @@ version = "1.0.24"
851
860
  source = "registry+https://github.com/rust-lang/crates.io-index"
852
861
  checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
853
862
 
854
- [[package]]
855
- name = "unicode-width"
856
- version = "0.2.2"
857
- source = "registry+https://github.com/rust-lang/crates.io-index"
858
- checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
859
-
860
863
  [[package]]
861
864
  name = "unsafe-libyaml"
862
865
  version = "0.2.11"
data/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "sonicop"
3
- version = "26.8.101"
3
+ version = "26.8.102"
4
4
  edition = "2024"
5
5
  rust-version = "1.85"
6
6
  description = "A fast, native RuboCop-compatible Ruby linter and formatter"
@@ -17,6 +17,7 @@ path = "src/main.rs"
17
17
  [dependencies]
18
18
  anyhow = "1.0.104"
19
19
  clap = { version = "4.6.4", features = ["derive", "wrap_help"] }
20
+ encoding_rs = "0.8.35"
20
21
  globset = "0.4.19"
21
22
  ignore = "0.4.31"
22
23
  rayon = "1.12.0"
@@ -27,8 +28,7 @@ serde_yaml_ng = "0.10"
27
28
  shell-words = "1.1.1"
28
29
  tempfile = "3.27.0"
29
30
  tree-sitter = "0.26.11"
30
- tree-sitter-ruby = { git = "https://github.com/owayo/tree-sitter-ruby.git", rev = "88a64c6c37c4c23d8d4d804c4ef9c4eca3c88b96" }
31
- unicode-width = "0.2.2"
31
+ tree-sitter-ruby = { git = "https://github.com/owayo/tree-sitter-ruby.git", rev = "6408d8b7adff44f0b346517b56071460efd323b6" }
32
32
  ureq = { version = "3.4", default-features = false, features = ["native-tls"] }
33
33
 
34
34
  [dev-dependencies]
data/README.ja.md CHANGED
@@ -111,8 +111,43 @@ Ruby プラグイン実行、キャッシュ再利用、カスタム Cop、実
111
111
  本家の全 1,759 ファイルを本家既定設定で検査した現在のスナップショットは、参照 4,052 件中
112
112
  4,052 件すべての位置が一致(**recall 100%**)し、**誤検出とメタデータ差もともに 0 件**です。コマンド、比較範囲、測定結果は
113
113
  [CONFORMANCE.md](CONFORMANCE.md) にまとめています。Rails では Ruby 対象 3,453 ファイルと
114
- ルート/`guides/` の有効 Cop 一覧が完全一致し、全体の誤検出は 0 件です。Active Record
115
- 402 ファイルの無キャッシュ実行は本家比で約 12.5 倍高速でした。
114
+ ルート/`guides/` の有効 Cop 一覧が完全一致し、全体の誤検出は 0 件です。
115
+
116
+ ### 性能
117
+
118
+ Rails 8.2.0.alpha のソースツリーで測定しました。両者とも同梱の既定設定
119
+ (`--force-default-config`)で走らせているため Rails の `.rubocop.yml` は読まず、
120
+ **対象ファイルは 3,550 件で完全に一致**しています。
121
+
122
+ | 実行条件 | RuboCop 1.89.0 | Sonicop 26.8.101 | 比 |
123
+ |---|---:|---:|---:|
124
+ | 同一の 28 Cop・並列 | 10.44 秒 | **2.78 秒** | 3.8 倍 |
125
+ | 同一の 28 Cop・単一プロセス | 33.82 秒 | **10.17 秒** | 3.3 倍 |
126
+ | 各ツールが既定で有効にする全 Cop | 22.52 秒 *(394 Cop・並列)* | **2.78 秒** *(28 Cop・並列)* | — |
127
+
128
+ 最後の行は素で叩いたときの実測ですが、**対等な比較ではありません**。Sonicop は RuboCop の
129
+ 609 Cop のうち 28 個しか実装しておらず、答えている問いがそもそも小さいためです。
130
+ 上 2 行は RuboCop を同じ 28 Cop に絞ったもので、エンジンの速度差はこちらが実態を表します。
131
+ この 28 Cop での検出数は 117,541 件中 243 件(0.2%)しか違わないため、
132
+ 仕事を省いて速いわけではありません。
133
+
134
+ 再現時に注意が必要な点が 2 つあります。RuboCop は **`--cache false` と併用すると
135
+ `--parallel` を黙って無効化します**。そのためここでの並列実行はキャッシュを有効にしたうえで
136
+ 実行ごとにキャッシュディレクトリを消しており、`--cache false --parallel` で計測すると
137
+ 単一プロセスを測ることになり差が過大に出ます。また RuboCop の既定は単一プロセス、
138
+ Sonicop は `--no-parallel` を渡さない限り並列です。
139
+
140
+ ```bash
141
+ # RuboCop(並列・キャッシュは毎回空・Sonicop の実装済み Cop に限定)
142
+ rubocop --force-default-config --cache true --cache-root "$(mktemp -d)" \
143
+ --no-color --parallel --only "$COPS" -f quiet
144
+
145
+ # Sonicop
146
+ sonicop --force-default-config --format quiet
147
+ ```
148
+
149
+ 測定機は Apple M2(8 コア)、Ruby 4.0.6(YJIT 利用可)、RubyGems 導入の RuboCop 1.89.0。
150
+ 各値は暖機後 2〜3 回の最速値です。
116
151
 
117
152
  ## 開発
118
153
 
data/README.md CHANGED
@@ -116,13 +116,67 @@ reuse, custom Ruby cops, or cops outside the implemented set.
116
116
 
117
117
  ### Conformance
118
118
 
119
- The implemented cops are verified against RuboCop 1.89 using normalized JSON offenses.
120
- On RuboCop's complete 1,759-file source tree with the upstream default configuration, the current
121
- snapshot matches all 4,052 reference offense locations (**100% recall**) with **zero false
122
- positives and zero metadata differences**. On Rails, all 3,453 Ruby target paths and the root/nested enabled-cop lists match
123
- exactly, the full tree has zero false positives, and the 402-file Active Record subset runs about
124
- 12.5x faster without cache. See [CONFORMANCE.md](CONFORMANCE.md) for the commands, scope, and
125
- measurements.
119
+ The implemented cops are verified against RuboCop 1.89.0 over five Ruby projects — RuboCop itself,
120
+ Rails, Ruby, Homebrew and Mastodon — totalling 18,244 files, with the upstream default
121
+ configuration on both sides. Every offense is compared by cop, path, line, column, last line, last
122
+ column, length, message, severity and correctability.
123
+
124
+ Three of the five match **exactly**: RuboCop's own tree (4,142 offenses), Rails (117,541) and
125
+ Mastodon (7,610), with no excess, no shortfall and no metadata differences. The target file lists
126
+ match exactly on all five. What remains is concentrated in `Lint/Syntax`, where RuboCop's LALR
127
+ parser recovers from an error and emits diagnostics a tree-sitter parse cannot reconstruct.
128
+ Autocorrect is byte-identical on every corpus it was run against.
129
+
130
+ See [CONFORMANCE.md](CONFORMANCE.md) for the commands, the per-corpus numbers, and the two ways a
131
+ measurement of this kind can mislead you.
132
+
133
+ ### Performance
134
+
135
+ Measured over all five conformance corpora. Both tools were given their own bundled default
136
+ configuration (`--force-default-config`), so neither reads the project's `.rubocop.yml`, and on every
137
+ corpus the two resolve **the same file list**.
138
+
139
+ RuboCop is restricted to the 28 cops Sonicop implements, which is the only like-for-like comparison
140
+ available. Times are the fastest of two warmed runs.
141
+
142
+ | Corpus | Files | RuboCop parallel | Sonicop parallel | RuboCop single | Sonicop single |
143
+ |---|---:|---:|---:|---:|---:|
144
+ | rubocop/rubocop | 1,765 | 5.30 s | **1.41 s** | 20.88 s | **6.22 s** |
145
+ | mastodon/mastodon | 3,289 | 3.77 s | **1.54 s** | 11.18 s | **5.67 s** |
146
+ | Homebrew/brew | 2,175 | 8.52 s | **1.47 s** | 19.26 s | **5.20 s** |
147
+ | rails/rails | 3,550 | 10.62 s | **5.07 s** | 32.89 s | **15.58 s** |
148
+ | ruby/ruby | 7,465 | 27.31 s | **11.19 s** | 96.19 s | **34.85 s** |
149
+
150
+ The gap is 2.1x to 5.8x in parallel and 2.0x to 3.7x single-process, so no single corpus summarizes
151
+ it. The single-process column is the steadier of the two — it measures the engines rather than how
152
+ well each one's parallelism happens to fit the tree it was pointed at.
153
+
154
+ Out of the box the two commands do different amounts of work — RuboCop enables 394 cops by default
155
+ against Sonicop's 28 — so that comparison is **not** like-for-like. For the record, RuboCop's default
156
+ parallel run takes 10.46 s on its own tree, 9.91 s on Mastodon, 10.83 s on Homebrew, 20.73 s on
157
+ Rails and 75.05 s on Ruby.
158
+
159
+ Over the 28 shared cops the two agree on **every offense** on RuboCop's own tree, on Rails and on
160
+ Mastodon, so the speed is not bought by skipping work.
161
+
162
+ Two details matter for reproducing this. RuboCop **silently turns `--parallel` off when combined
163
+ with `--cache false`**, so its parallel runs here use a cache directory that is deleted before each
164
+ run rather than disabled; timing it with `--cache false --parallel` measures a single process and
165
+ overstates the difference. RuboCop's default is a single process, while Sonicop is parallel unless
166
+ `--no-parallel` is passed.
167
+
168
+ ```bash
169
+ # RuboCop, parallel, cold cache, restricted to the cops Sonicop implements
170
+ rubocop --force-default-config --cache true --cache-root "$(mktemp -d)" \
171
+ --no-color --parallel --only "$COPS" -f quiet
172
+
173
+ # Sonicop
174
+ sonicop --force-default-config --format quiet
175
+ ```
176
+
177
+ Machine: Apple M2 (8 cores), Ruby 4.0.6 with YJIT available, RubyGems-installed RuboCop 1.89.0.
178
+ Measure on an otherwise idle machine: anything else competing for cores inflates both sides
179
+ unevenly, and a contended run here reported RuboCop's Rails time as 21 s rather than 10.6 s.
126
180
 
127
181
  ## Development
128
182
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  # Generated from Cargo.toml by `rake version:sync`. Do not edit by hand.
4
4
  module Sonicop
5
- VERSION = '26.8.101'
5
+ VERSION = '26.8.102'
6
6
  end
data/src/config/mod.rs CHANGED
@@ -31,6 +31,9 @@ pub struct Config {
31
31
  raw: Value,
32
32
  user: Value,
33
33
  project_root: PathBuf,
34
+ /// Where the run was launched from. RuboCop shortens paths it prints inside offense messages
35
+ /// against `Dir.pwd`, not against the project root, so the two cannot be collapsed.
36
+ cwd: PathBuf,
34
37
  config_path: Option<PathBuf>,
35
38
  target_ruby: ResolvedTargetRuby,
36
39
  known_cops: HashSet<String>,
@@ -108,6 +111,7 @@ impl Config {
108
111
  raw,
109
112
  user,
110
113
  project_root,
114
+ cwd: cwd.to_path_buf(),
111
115
  config_path,
112
116
  target_ruby,
113
117
  known_cops,
@@ -121,6 +125,10 @@ impl Config {
121
125
  &self.project_root
122
126
  }
123
127
 
128
+ pub fn cwd(&self) -> &Path {
129
+ &self.cwd
130
+ }
131
+
124
132
  pub fn config_path(&self) -> Option<&Path> {
125
133
  self.config_path.as_deref()
126
134
  }
@@ -205,9 +213,40 @@ impl Config {
205
213
  }
206
214
 
207
215
  pub fn path_included(&self, path: &Path) -> bool {
216
+ // RuboCop shortcuts a path whose *first* component is hidden: no `Include` pattern can reach
217
+ // it unless one of them mentions a dot. Only the leading component counts, so a dot
218
+ // directory nested under a visible one -- `docs/.mdl_style.rb` -- is still matched normally.
219
+ if self.top_level_hidden(path) && !self.possibly_include_hidden() {
220
+ return false;
221
+ }
208
222
  self.includes.is_empty() || self.includes.matches_includes(path, &self.project_root)
209
223
  }
210
224
 
225
+ fn top_level_hidden(&self, path: &Path) -> bool {
226
+ paths::project_relative(path, &self.project_root).is_some_and(|relative| {
227
+ relative
228
+ .components()
229
+ .next()
230
+ .and_then(|component| component.as_os_str().to_str())
231
+ .is_some_and(|first| first.starts_with('.') && first != "..")
232
+ })
233
+ }
234
+
235
+ /// Whether a *directory* is one the configuration excludes wholesale, which is how RuboCop
236
+ /// prunes `.git` and `node_modules` before descending rather than filtering their contents one
237
+ /// file at a time.
238
+ pub fn directory_excluded(&self, path: &Path) -> bool {
239
+ let patterns: Vec<String> = self.all_cops_value("Exclude").unwrap_or_default();
240
+ let Some(relative) = paths::project_relative(path, &self.project_root) else {
241
+ return false;
242
+ };
243
+ patterns.iter().any(|pattern| {
244
+ pattern
245
+ .strip_suffix("/**/*")
246
+ .is_some_and(|directory| relative == Path::new(directory))
247
+ })
248
+ }
249
+
211
250
  pub fn path_excluded(&self, path: &Path) -> bool {
212
251
  self.excluded_by("AllCops", path)
213
252
  }