kaskd 0.1.0
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +197 -0
- data/lib/kaskd/analyzer.rb +156 -0
- data/lib/kaskd/blast_radius.rb +90 -0
- data/lib/kaskd/configuration.rb +28 -0
- data/lib/kaskd/test_finder.rb +136 -0
- data/lib/kaskd/tree_renderer.rb +81 -0
- data/lib/kaskd/version.rb +5 -0
- data/lib/kaskd.rb +75 -0
- metadata +55 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f251ae1063d05fd9ad9988102464e31b5f50908bcd47562b4c0797ed999efda0
|
|
4
|
+
data.tar.gz: 715b5710244662141b0ffa90cf1056e87ce1556cfeb5153313acf731090b07dc
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7f07e0c30b74aefeab1e1ed5f777d6f746169c149a459e5b92be5e29214c926a809612260fdcaf85c5a909f575552f5666ccd597588af114b318fb0049b497b5
|
|
7
|
+
data.tar.gz: 67374e08547105c3f47fb72a30b1beb216eb845d9c7044695ce600877e1c2992992c5b78a347d340f44156b15f09f7cf649659c80fad85affa874856175124f1
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nildiert
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# kaskd
|
|
2
|
+
|
|
3
|
+
Static analyzer for Ruby service dependency graphs and blast radius calculation.
|
|
4
|
+
|
|
5
|
+
> **kaskd** = cascade — when a service changes, the ripple propagates.
|
|
6
|
+
|
|
7
|
+
Kaskd scans your `app/services` and `packs/**/app/services` directories via static analysis, builds a full dependency graph, and answers two questions:
|
|
8
|
+
|
|
9
|
+
1. **Which services are affected** if a given service changes? (blast radius)
|
|
10
|
+
2. **Which test files should be run** as a result?
|
|
11
|
+
|
|
12
|
+
Works with standard Rails layouts and [Packwerk](https://github.com/Shopify/packwerk)-based monorepos.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Add to your `Gemfile`:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
gem "kaskd", github: "nildiert/kaskd"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or for development/CI only:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
group :development, :test do
|
|
28
|
+
gem "kaskd", github: "nildiert/kaskd"
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bundle install
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
### Full dependency graph
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
result = Kaskd.analyze
|
|
46
|
+
result[:services] # Hash<class_name => metadata>
|
|
47
|
+
result[:total] # Integer
|
|
48
|
+
result[:generated_at] # ISO 8601 String
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Blast radius
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
radius = Kaskd.blast_radius("My::PayrollService")
|
|
55
|
+
|
|
56
|
+
# Control traversal depth (default: 8, nil = unlimited)
|
|
57
|
+
radius = Kaskd.blast_radius("My::PayrollService", max_depth: 3)
|
|
58
|
+
|
|
59
|
+
radius[:target] # => "My::PayrollService"
|
|
60
|
+
radius[:max_depth] # => 3
|
|
61
|
+
radius[:max_depth_reached] # => 2
|
|
62
|
+
|
|
63
|
+
# Results grouped by depth level
|
|
64
|
+
radius[:by_depth] # => {
|
|
65
|
+
# 1 => [
|
|
66
|
+
# { class_name: "My::InvoiceService", depth: 1, via: "My::PayrollService",
|
|
67
|
+
# file: "app/services/my/invoice_service.rb",
|
|
68
|
+
# dependencies: ["My::PayrollService", "My::TaxService"], parent: nil },
|
|
69
|
+
# ],
|
|
70
|
+
# 2 => [
|
|
71
|
+
# { class_name: "My::ReportService", depth: 2, via: "My::InvoiceService",
|
|
72
|
+
# file: "app/services/my/report_service.rb",
|
|
73
|
+
# dependencies: ["My::InvoiceService"], parent: nil },
|
|
74
|
+
# ],
|
|
75
|
+
# }
|
|
76
|
+
|
|
77
|
+
# Flat array sorted by depth then name
|
|
78
|
+
radius[:affected] # => [
|
|
79
|
+
# { class_name: "My::InvoiceService", depth: 1, via: "My::PayrollService",
|
|
80
|
+
# file: "...", dependencies: [...], parent: nil },
|
|
81
|
+
# { class_name: "My::ReportService", depth: 2, via: "My::InvoiceService",
|
|
82
|
+
# file: "...", dependencies: [...], parent: nil },
|
|
83
|
+
# ]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### ASCII tree view
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
# Convenience method — runs blast_radius and renders in one call
|
|
90
|
+
puts Kaskd.render_tree("My::PayrollService")
|
|
91
|
+
puts Kaskd.render_tree("My::PayrollService", max_depth: 3)
|
|
92
|
+
|
|
93
|
+
# Or render from an existing blast radius result
|
|
94
|
+
radius = Kaskd.blast_radius("My::PayrollService")
|
|
95
|
+
puts Kaskd::TreeRenderer.render(radius)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Example output:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
My::PayrollService
|
|
102
|
+
├── My::InvoiceService [depth 1] app/services/my/invoice_service.rb (deps: My::PayrollService, My::TaxService)
|
|
103
|
+
│ ├── My::ExportService [depth 2] app/services/my/export_service.rb
|
|
104
|
+
│ └── My::ReportService [depth 2] app/services/my/report_service.rb
|
|
105
|
+
└── My::NotifierService [depth 1] app/services/my/notifier_service.rb
|
|
106
|
+
└── My::AuditService [depth 2] app/services/my/audit_service.rb
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Each node shows: class name, depth (gray), file path (cyan), and direct dependencies (gray).
|
|
110
|
+
|
|
111
|
+
### Related tests
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
tests = Kaskd.related_tests("My::PayrollService")
|
|
115
|
+
|
|
116
|
+
# Also accepts max_depth to match the blast radius scope
|
|
117
|
+
tests = Kaskd.related_tests("My::PayrollService", max_depth: 3)
|
|
118
|
+
|
|
119
|
+
tests[:test_files] # => [
|
|
120
|
+
# { path: "test/services/my/payroll_service_test.rb", class_name: "My::PayrollService" },
|
|
121
|
+
# { path: "test/services/my/invoice_service_test.rb", class_name: "My::InvoiceService" },
|
|
122
|
+
# ]
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Lower-level API
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
# 1. Analyze
|
|
129
|
+
result = Kaskd::Analyzer.new(root: "/path/to/project").analyze
|
|
130
|
+
|
|
131
|
+
# 2. Blast radius
|
|
132
|
+
radius = Kaskd::BlastRadius.new(result[:services]).compute("My::PayrollService", max_depth: 4)
|
|
133
|
+
|
|
134
|
+
# 3. Tree
|
|
135
|
+
puts Kaskd::TreeRenderer.render(radius)
|
|
136
|
+
|
|
137
|
+
# 4. Tests
|
|
138
|
+
affected_classes = radius[:affected].map { |a| a[:class_name] } + ["My::PayrollService"]
|
|
139
|
+
tests = Kaskd::TestFinder.new(root: "/path/to/project").find_for(affected_classes, result[:services])
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Configuration
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
Kaskd.configure do |c|
|
|
148
|
+
# Override service glob patterns
|
|
149
|
+
c.service_globs = [
|
|
150
|
+
"app/services/**/*.rb",
|
|
151
|
+
"packs/**/app/services/**/*.rb",
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
# Override test glob patterns
|
|
155
|
+
c.test_globs = [
|
|
156
|
+
"test/**/*_test.rb",
|
|
157
|
+
"spec/**/*_spec.rb",
|
|
158
|
+
"packs/**/test/**/*_test.rb",
|
|
159
|
+
"packs/**/spec/**/*_spec.rb",
|
|
160
|
+
]
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## How it works
|
|
167
|
+
|
|
168
|
+
### Analyzer — two-pass static analysis
|
|
169
|
+
|
|
170
|
+
**Pass 1** — collect all class names and metadata (file path, pack, parent class, description from preceding comment block). Handles module-nested classes (`module Foo / class Bar` → `Foo::Bar`).
|
|
171
|
+
|
|
172
|
+
**Pass 2** — for each file, scan for references to known class names. The intersection of identifiers in the file with the set of known classes gives the dependency list.
|
|
173
|
+
|
|
174
|
+
### BlastRadius — BFS on the reverse graph
|
|
175
|
+
|
|
176
|
+
A reverse index maps each class to the services that depend on it. BFS traversal computes the full transitive blast radius with predecessor tracking (`via` field) for path reconstruction. Results are grouped by depth level in `by_depth` and also returned as a flat `affected` array.
|
|
177
|
+
|
|
178
|
+
### TreeRenderer — ASCII tree from the `via` chain
|
|
179
|
+
|
|
180
|
+
Reconstructs the parent-child relationships from the `via` field of each entry and renders them as an indented ASCII tree using `├──` / `└──` connectors.
|
|
181
|
+
|
|
182
|
+
### TestFinder — two-heuristic matching
|
|
183
|
+
|
|
184
|
+
1. **Naming convention** — strip `_test`/`_spec` suffix from the filename and match against the service name map.
|
|
185
|
+
2. **Content scan** — search the test file body for direct references to any of the target class names.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Used by
|
|
190
|
+
|
|
191
|
+
- [`service_graph_dev`](https://github.com/nildiert/service_graph_dev) — mountable Rails engine that visualizes the dependency graph interactively. Uses `kaskd` as its analysis core via git submodule.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
MIT
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaskd
|
|
4
|
+
# Analyzes dependencies between Ruby service classes via two-pass static analysis.
|
|
5
|
+
#
|
|
6
|
+
# Pass 1 — collect class names and metadata from all matched files.
|
|
7
|
+
# Pass 2 — detect cross-references between known classes.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# result = Kaskd::Analyzer.new.analyze
|
|
11
|
+
# result[:services] # => Hash<class_name => metadata>
|
|
12
|
+
# result[:total] # => Integer
|
|
13
|
+
# result[:generated_at] # => ISO 8601 String
|
|
14
|
+
class Analyzer
|
|
15
|
+
# @param root [String, nil] project root directory. Defaults to Dir.pwd.
|
|
16
|
+
# @param globs [Array<String>, nil] override service glob patterns.
|
|
17
|
+
def initialize(root: nil, globs: nil)
|
|
18
|
+
@root = root || Dir.pwd
|
|
19
|
+
@globs = globs || Kaskd.configuration.service_globs
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Run the full two-pass analysis.
|
|
23
|
+
# @return [Hash]
|
|
24
|
+
def analyze
|
|
25
|
+
all_files = resolve_files
|
|
26
|
+
services = {}
|
|
27
|
+
file_contents = {}
|
|
28
|
+
|
|
29
|
+
# Pass 1: collect class names and metadata
|
|
30
|
+
all_files.each do |path|
|
|
31
|
+
content = read_file(path)
|
|
32
|
+
file_contents[path] = content
|
|
33
|
+
class_name = extract_class_name(content)
|
|
34
|
+
next unless class_name
|
|
35
|
+
|
|
36
|
+
services[class_name] = {
|
|
37
|
+
class_name: class_name,
|
|
38
|
+
file: relative_path(path),
|
|
39
|
+
pack: extract_pack(path),
|
|
40
|
+
description: extract_description(content),
|
|
41
|
+
parent: extract_parent(content),
|
|
42
|
+
dependencies: [],
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
known_classes = services.keys.to_set
|
|
47
|
+
|
|
48
|
+
# Pass 2: detect dependencies by intersecting references with known classes
|
|
49
|
+
all_files.each do |path|
|
|
50
|
+
content = file_contents[path]
|
|
51
|
+
class_name = extract_class_name(content)
|
|
52
|
+
next unless class_name && services[class_name]
|
|
53
|
+
|
|
54
|
+
services[class_name][:dependencies] = extract_dependencies(content, known_classes, class_name)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
services: services,
|
|
59
|
+
generated_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
60
|
+
total: services.size,
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def resolve_files
|
|
67
|
+
@globs
|
|
68
|
+
.flat_map { |pattern| Dir.glob(File.join(@root, pattern)) }
|
|
69
|
+
.uniq
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def read_file(path)
|
|
73
|
+
File.read(path, encoding: "utf-8", invalid: :replace, undef: :replace)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def relative_path(path)
|
|
77
|
+
path.delete_prefix("#{@root}/")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Extracts the pack name from the file path.
|
|
81
|
+
# packs/talent/evaluation_process/... => "talent/evaluation_process"
|
|
82
|
+
# app/services/... => "app"
|
|
83
|
+
def extract_pack(path)
|
|
84
|
+
rel = relative_path(path)
|
|
85
|
+
return "app" unless rel.start_with?("packs/")
|
|
86
|
+
|
|
87
|
+
parts = rel.split("/")
|
|
88
|
+
parts[1..2].join("/")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Extracts the fully qualified class name, handling module wrappers.
|
|
92
|
+
# E.g.: module Foo / class Bar => "Foo::Bar"
|
|
93
|
+
def extract_class_name(content)
|
|
94
|
+
modules = []
|
|
95
|
+
content.each_line do |line|
|
|
96
|
+
stripped = line.strip
|
|
97
|
+
if (m = stripped.match(/\Amodule\s+([\w:]+)/))
|
|
98
|
+
modules << m[1]
|
|
99
|
+
elsif (m = stripped.match(/\Aclass\s+([\w:]+)/))
|
|
100
|
+
return build_qualified_name(modules, m[1])
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def extract_parent(content)
|
|
107
|
+
content.each_line do |line|
|
|
108
|
+
stripped = line.strip
|
|
109
|
+
if (m = stripped.match(/\Aclass\s+[\w:]+\s*<\s*([\w:]+(?:::\w+)*)/))
|
|
110
|
+
return m[1]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def build_qualified_name(modules, class_part)
|
|
117
|
+
return class_part if modules.empty?
|
|
118
|
+
|
|
119
|
+
full_module = modules.join("::")
|
|
120
|
+
class_part.start_with?("#{full_module}::") ? class_part : "#{full_module}::#{class_part}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Extracts description from the comment block preceding the class declaration.
|
|
124
|
+
# Ignores Sorbet/Rubocop directives and YARD tags like @param/@return.
|
|
125
|
+
def extract_description(content)
|
|
126
|
+
lines = content.lines
|
|
127
|
+
class_line_idx = lines.find_index { |l| l.strip.match?(/\Aclass\s/) }
|
|
128
|
+
return "" unless class_line_idx
|
|
129
|
+
|
|
130
|
+
comments = []
|
|
131
|
+
(class_line_idx - 1).downto(0) do |i|
|
|
132
|
+
line = lines[i].strip
|
|
133
|
+
break if line.empty? && comments.any?
|
|
134
|
+
next if line.empty?
|
|
135
|
+
|
|
136
|
+
if line.start_with?("#")
|
|
137
|
+
stripped = line.sub(/^#+\s?/, "").strip
|
|
138
|
+
next if stripped.start_with?("@")
|
|
139
|
+
next if stripped.match?(/\A(typed:|frozen_string_literal)/)
|
|
140
|
+
|
|
141
|
+
comments.unshift(stripped) unless stripped.empty?
|
|
142
|
+
else
|
|
143
|
+
break
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
comments.reject(&:empty?).join(" ").then { |s| s.empty? ? "" : s }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Detects dependencies by finding references to known classes in the file content.
|
|
151
|
+
def extract_dependencies(content, known_classes, self_class)
|
|
152
|
+
references = content.scan(/\b([A-Z][A-Za-z0-9]*(?:::[A-Z][A-Za-z0-9]*)*)/).flatten.to_set
|
|
153
|
+
(references & known_classes - Set[self_class]).to_a.sort
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaskd
|
|
4
|
+
# Computes the blast radius of a service change via BFS on the reverse dependency graph.
|
|
5
|
+
#
|
|
6
|
+
# Given a target service class name, it finds every other service that depends on it
|
|
7
|
+
# (directly or transitively) and groups results by depth level.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# result = Kaskd::Analyzer.new.analyze
|
|
11
|
+
# radius = Kaskd::BlastRadius.new(result[:services]).compute("My::ServiceClass", max_depth: 3)
|
|
12
|
+
#
|
|
13
|
+
# radius[:target] # => "My::ServiceClass"
|
|
14
|
+
# radius[:by_depth] # => {
|
|
15
|
+
# 1 => [{ class_name: "A", via: "My::ServiceClass", file: "...", dependencies: [...], parent: "..." }, ...],
|
|
16
|
+
# 2 => [{ class_name: "B", via: "A", file: "...", dependencies: [...], parent: nil }, ...],
|
|
17
|
+
# }
|
|
18
|
+
# radius[:affected] # => flat array of all entries, sorted by depth then name
|
|
19
|
+
# radius[:max_depth_reached] # => Integer — deepest level found (≤ max_depth)
|
|
20
|
+
class BlastRadius
|
|
21
|
+
DEFAULT_MAX_DEPTH = 8
|
|
22
|
+
|
|
23
|
+
# @param services [Hash] output of Kaskd::Analyzer#analyze — services keyed by class name.
|
|
24
|
+
def initialize(services)
|
|
25
|
+
@services = services
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Run BFS from the target service through the reverse dependency index.
|
|
29
|
+
#
|
|
30
|
+
# @param target [String] fully-qualified class name of the modified service.
|
|
31
|
+
# @param max_depth [Integer] maximum traversal depth (default: 6). nil = unlimited.
|
|
32
|
+
# @return [Hash]
|
|
33
|
+
def compute(target, max_depth: DEFAULT_MAX_DEPTH)
|
|
34
|
+
reverse_index = build_reverse_index
|
|
35
|
+
|
|
36
|
+
queue = [[target, 0]]
|
|
37
|
+
visited = { target => { depth: 0, via: nil } }
|
|
38
|
+
|
|
39
|
+
while queue.any?
|
|
40
|
+
svc, depth = queue.shift
|
|
41
|
+
next if max_depth && depth >= max_depth
|
|
42
|
+
|
|
43
|
+
(reverse_index[svc] || []).each do |caller_svc|
|
|
44
|
+
next if visited.key?(caller_svc)
|
|
45
|
+
|
|
46
|
+
visited[caller_svc] = { depth: depth + 1, via: svc }
|
|
47
|
+
queue << [caller_svc, depth + 1]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
entries = visited
|
|
52
|
+
.reject { |name, _| name == target }
|
|
53
|
+
.sort_by { |name, meta| [meta[:depth], name] }
|
|
54
|
+
.map do |name, meta|
|
|
55
|
+
{
|
|
56
|
+
class_name: name,
|
|
57
|
+
depth: meta[:depth],
|
|
58
|
+
via: meta[:via],
|
|
59
|
+
file: @services.dig(name, :file),
|
|
60
|
+
dependencies: @services.dig(name, :dependencies) || [],
|
|
61
|
+
parent: @services.dig(name, :parent),
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
by_depth = entries.group_by { |e| e[:depth] }
|
|
66
|
+
|
|
67
|
+
{
|
|
68
|
+
target: target,
|
|
69
|
+
max_depth: max_depth,
|
|
70
|
+
max_depth_reached: entries.map { |e| e[:depth] }.max || 0,
|
|
71
|
+
by_depth: by_depth,
|
|
72
|
+
affected: entries,
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Builds a reverse index: given a service X, who depends on X?
|
|
79
|
+
# reverse_index["X"] => ["A", "B", ...]
|
|
80
|
+
def build_reverse_index
|
|
81
|
+
index = Hash.new { |h, k| h[k] = [] }
|
|
82
|
+
@services.each do |name, meta|
|
|
83
|
+
meta[:dependencies].each do |dep|
|
|
84
|
+
index[dep] << name
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
index
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaskd
|
|
4
|
+
# Holds gem-wide configuration.
|
|
5
|
+
# Use Kaskd.configure { |c| c.service_globs = [...] } to override defaults.
|
|
6
|
+
class Configuration
|
|
7
|
+
# Glob patterns used to discover service files.
|
|
8
|
+
# Override if your project uses a non-standard layout.
|
|
9
|
+
attr_accessor :service_globs
|
|
10
|
+
|
|
11
|
+
# Glob patterns used to discover test files (Minitest and RSpec).
|
|
12
|
+
attr_accessor :test_globs
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@service_globs = [
|
|
16
|
+
"app/services/**/*.rb",
|
|
17
|
+
"packs/**/app/services/**/*.rb",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
@test_globs = [
|
|
21
|
+
"test/**/*_test.rb",
|
|
22
|
+
"spec/**/*_spec.rb",
|
|
23
|
+
"packs/**/test/**/*_test.rb",
|
|
24
|
+
"packs/**/spec/**/*_spec.rb",
|
|
25
|
+
]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaskd
|
|
4
|
+
# Finds test files related to a set of service class names.
|
|
5
|
+
#
|
|
6
|
+
# Strategy:
|
|
7
|
+
# 1. Scan all test files matched by the configured globs.
|
|
8
|
+
# 2. For each test file, extract the class name it likely tests using two heuristics:
|
|
9
|
+
# a. Naming convention: strip _test / _spec suffix from the filename and map it
|
|
10
|
+
# to a Ruby constant (e.g. my_service_test.rb => MyService).
|
|
11
|
+
# b. Content scan: look for references to any of the target class names inside
|
|
12
|
+
# the file body.
|
|
13
|
+
# 3. Return a deduplicated list of { path:, class_name: } for every match.
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# result = Kaskd::Analyzer.new.analyze
|
|
17
|
+
# radius = Kaskd::BlastRadius.new(result[:services]).compute("My::ServiceClass")
|
|
18
|
+
# targets = radius[:affected].map { |a| a[:class_name] } + ["My::ServiceClass"]
|
|
19
|
+
# tests = Kaskd::TestFinder.new.find_for(targets, result[:services])
|
|
20
|
+
# tests[:test_files] # => [{ path: "test/...", class_name: "My::ServiceClass" }, ...]
|
|
21
|
+
class TestFinder
|
|
22
|
+
# @param root [String, nil] project root. Defaults to Dir.pwd.
|
|
23
|
+
# @param globs [Array<String>, nil] override test glob patterns.
|
|
24
|
+
def initialize(root: nil, globs: nil)
|
|
25
|
+
@root = root || Dir.pwd
|
|
26
|
+
@globs = globs || Kaskd.configuration.test_globs
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param target_classes [Array<String>] fully-qualified class names to search for.
|
|
30
|
+
# @param services [Hash] services map from Kaskd::Analyzer#analyze (used for
|
|
31
|
+
# quick filename-to-class lookups).
|
|
32
|
+
# @return [Hash]
|
|
33
|
+
def find_for(target_classes, services = {})
|
|
34
|
+
target_set = target_classes.to_set
|
|
35
|
+
all_tests = resolve_test_files
|
|
36
|
+
found = {}
|
|
37
|
+
|
|
38
|
+
# Build a reverse map: snake_case base name => class name (from services)
|
|
39
|
+
# so naming-convention matching can cover namespaced classes too.
|
|
40
|
+
name_map = build_name_map(services)
|
|
41
|
+
|
|
42
|
+
all_tests.each do |path|
|
|
43
|
+
rel = relative_path(path)
|
|
44
|
+
matched = match_by_convention(rel, target_set, name_map)
|
|
45
|
+
matched ||= match_by_content(path, target_set)
|
|
46
|
+
next unless matched
|
|
47
|
+
|
|
48
|
+
# A test file may cover multiple targets — accumulate all
|
|
49
|
+
Array(matched).each do |class_name|
|
|
50
|
+
key = "#{rel}::#{class_name}"
|
|
51
|
+
found[key] = { path: rel, class_name: class_name }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
target_classes: target_classes,
|
|
57
|
+
test_files: found.values.sort_by { |t| [t[:path], t[:class_name]] },
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def resolve_test_files
|
|
64
|
+
@globs
|
|
65
|
+
.flat_map { |pattern| Dir.glob(File.join(@root, pattern)) }
|
|
66
|
+
.uniq
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def relative_path(path)
|
|
70
|
+
path.delete_prefix("#{@root}/")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Builds a map from underscore base name to fully-qualified class names.
|
|
74
|
+
# e.g. "my_service" => ["My::ServiceClass"] where the file is my_service.rb
|
|
75
|
+
def build_name_map(services)
|
|
76
|
+
map = Hash.new { |h, k| h[k] = [] }
|
|
77
|
+
services.each do |class_name, meta|
|
|
78
|
+
base = File.basename(meta[:file].to_s, ".rb")
|
|
79
|
+
map[base] << class_name
|
|
80
|
+
end
|
|
81
|
+
map
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Heuristic 1 — naming convention (Rails-style path guard).
|
|
85
|
+
#
|
|
86
|
+
# Rails convention: the test file path mirrors the class namespace.
|
|
87
|
+
# Talent::EmployeeEvaluations::Restore
|
|
88
|
+
# => expected path fragment: "talent/employee_evaluations/restore"
|
|
89
|
+
#
|
|
90
|
+
# A test file matches a candidate class only when the full underscored
|
|
91
|
+
# namespace path (e.g. "talent/employee_evaluations/restore") is a
|
|
92
|
+
# substring of the test's relative path (ignoring the _test/_spec suffix).
|
|
93
|
+
# This is the same guard Rails autoload uses and eliminates false positives
|
|
94
|
+
# from generic leaf names like "restore" or "create" appearing in unrelated
|
|
95
|
+
# packs.
|
|
96
|
+
def match_by_convention(rel_path, target_set, name_map)
|
|
97
|
+
base = File.basename(rel_path, ".rb")
|
|
98
|
+
.delete_suffix("_test")
|
|
99
|
+
.delete_suffix("_spec")
|
|
100
|
+
|
|
101
|
+
# Normalise the test path for substring matching (drop extension suffix).
|
|
102
|
+
normalised_path = File.join(File.dirname(rel_path), base)
|
|
103
|
+
|
|
104
|
+
candidates = name_map[base] || []
|
|
105
|
+
matched = candidates.select do |class_name|
|
|
106
|
+
next false unless target_set.include?(class_name)
|
|
107
|
+
|
|
108
|
+
# Build the expected path fragment from the fully-qualified class name.
|
|
109
|
+
expected_fragment = class_name.split("::").map { |p| underscore(p) }.join("/")
|
|
110
|
+
normalised_path.include?(expected_fragment)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
matched.empty? ? nil : matched
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Minimal underscore — converts CamelCase to snake_case.
|
|
117
|
+
# e.g. "EmployeeEvaluations" => "employee_evaluations"
|
|
118
|
+
def underscore(str)
|
|
119
|
+
str
|
|
120
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
121
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
122
|
+
.downcase
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Heuristic 2 — content scan.
|
|
126
|
+
# Read the file and look for direct references to any of the target class names.
|
|
127
|
+
# Returns matched class names or nil.
|
|
128
|
+
def match_by_content(path, target_set)
|
|
129
|
+
content = File.read(path, encoding: "utf-8", invalid: :replace, undef: :replace)
|
|
130
|
+
matched = target_set.select { |class_name| content.include?(class_name) }
|
|
131
|
+
matched.empty? ? nil : matched.to_a
|
|
132
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaskd
|
|
4
|
+
# Renders a blast radius result as an ASCII tree.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# radius = Kaskd.blast_radius("My::ServiceClass", root: Rails.root.to_s)
|
|
8
|
+
# puts Kaskd::TreeRenderer.render(radius)
|
|
9
|
+
#
|
|
10
|
+
# Output example:
|
|
11
|
+
# My::ServiceClass
|
|
12
|
+
# ├── My::InvoiceService [depth 1] app/services/my/invoice_service.rb
|
|
13
|
+
# │ ├── My::ReportService [depth 2] app/services/my/report_service.rb
|
|
14
|
+
# │ └── My::ExportService [depth 2] app/services/my/export_service.rb
|
|
15
|
+
# └── My::NotifierService [depth 1] app/services/my/notifier_service.rb
|
|
16
|
+
# └── My::AuditService [depth 2] app/services/my/audit_service.rb
|
|
17
|
+
#
|
|
18
|
+
# Also available as a convenience method:
|
|
19
|
+
# Kaskd.render_tree("My::ServiceClass", root: Rails.root.to_s)
|
|
20
|
+
class TreeRenderer
|
|
21
|
+
BRANCH = "├── "
|
|
22
|
+
LAST = "└── "
|
|
23
|
+
PIPE = "│ "
|
|
24
|
+
SPACE = " "
|
|
25
|
+
|
|
26
|
+
# @param radius [Hash] output of Kaskd::BlastRadius#compute or Kaskd.blast_radius
|
|
27
|
+
# @param io [IO] output target (default: returns String)
|
|
28
|
+
# @return [String]
|
|
29
|
+
def self.render(radius)
|
|
30
|
+
new(radius).render
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(radius)
|
|
34
|
+
@target = radius[:target]
|
|
35
|
+
@affected = radius[:affected]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def render
|
|
39
|
+
# Build adjacency: parent -> [children] using the :via field
|
|
40
|
+
children = Hash.new { |h, k| h[k] = [] }
|
|
41
|
+
@affected.each { |entry| children[entry[:via]] << entry }
|
|
42
|
+
|
|
43
|
+
# Sort each child list by class_name for deterministic output
|
|
44
|
+
children.each_value { |list| list.sort_by! { |e| e[:class_name] } }
|
|
45
|
+
|
|
46
|
+
lines = []
|
|
47
|
+
lines << @target
|
|
48
|
+
render_children(children[@target], children, "", lines)
|
|
49
|
+
lines.join("\n")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def render_children(nodes, children_map, prefix, lines)
|
|
55
|
+
return if nodes.nil? || nodes.empty?
|
|
56
|
+
|
|
57
|
+
nodes.each_with_index do |node, idx|
|
|
58
|
+
last = idx == nodes.size - 1
|
|
59
|
+
|
|
60
|
+
connector = last ? LAST : BRANCH
|
|
61
|
+
child_prefix = prefix + (last ? SPACE : PIPE)
|
|
62
|
+
|
|
63
|
+
label = format_node(node)
|
|
64
|
+
lines << "#{prefix}#{connector}#{label}"
|
|
65
|
+
|
|
66
|
+
render_children(children_map[node[:class_name]], children_map, child_prefix, lines)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def format_node(node)
|
|
71
|
+
parts = [node[:class_name]]
|
|
72
|
+
parts << "\e[2m[depth #{node[:depth]}]\e[0m"
|
|
73
|
+
parts << "\e[36m#{node[:file]}\e[0m" if node[:file]
|
|
74
|
+
unless node[:dependencies].nil? || node[:dependencies].empty?
|
|
75
|
+
deps = node[:dependencies].join(", ")
|
|
76
|
+
parts << "\e[2m(deps: #{deps})\e[0m"
|
|
77
|
+
end
|
|
78
|
+
parts.join(" ")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/kaskd.rb
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "kaskd/version"
|
|
4
|
+
require_relative "kaskd/analyzer"
|
|
5
|
+
require_relative "kaskd/blast_radius"
|
|
6
|
+
require_relative "kaskd/test_finder"
|
|
7
|
+
require_relative "kaskd/configuration"
|
|
8
|
+
require_relative "kaskd/tree_renderer"
|
|
9
|
+
|
|
10
|
+
module Kaskd
|
|
11
|
+
class << self
|
|
12
|
+
def configuration
|
|
13
|
+
@configuration ||= Configuration.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def configure
|
|
17
|
+
yield configuration
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Convenience entry point: analyze all services and return the full graph.
|
|
21
|
+
# Returns: { services: Hash, generated_at: String, total: Integer }
|
|
22
|
+
def analyze(root: nil)
|
|
23
|
+
Analyzer.new(root: root).analyze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Compute blast radius for a given service class name.
|
|
27
|
+
#
|
|
28
|
+
# @param class_name [String] fully-qualified class name of the modified service.
|
|
29
|
+
# @param root [String, nil] project root. Defaults to Dir.pwd.
|
|
30
|
+
# @param max_depth [Integer, nil] max BFS traversal depth (default: 6). nil = unlimited.
|
|
31
|
+
#
|
|
32
|
+
# Returns:
|
|
33
|
+
# {
|
|
34
|
+
# target: "My::ServiceClass",
|
|
35
|
+
# max_depth: 3,
|
|
36
|
+
# max_depth_reached: 2,
|
|
37
|
+
# by_depth: {
|
|
38
|
+
# 1 => [{ class_name:, via:, file: }, ...],
|
|
39
|
+
# 2 => [{ class_name:, via:, file: }, ...],
|
|
40
|
+
# },
|
|
41
|
+
# affected: [ flat array sorted by depth then name ],
|
|
42
|
+
# }
|
|
43
|
+
def blast_radius(class_name, root: nil, max_depth: BlastRadius::DEFAULT_MAX_DEPTH)
|
|
44
|
+
result = analyze(root: root)
|
|
45
|
+
BlastRadius.new(result[:services]).compute(class_name, max_depth: max_depth)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Find test files related to a service and its blast radius.
|
|
49
|
+
# Returns: { target_classes:, test_files: Array<{ path:, class_name: }> }
|
|
50
|
+
def related_tests(class_name, root: nil, max_depth: BlastRadius::DEFAULT_MAX_DEPTH)
|
|
51
|
+
result = analyze(root: root)
|
|
52
|
+
radius = BlastRadius.new(result[:services]).compute(class_name, max_depth: max_depth)
|
|
53
|
+
affected = radius[:affected].map { |a| a[:class_name] } + [class_name]
|
|
54
|
+
TestFinder.new(root: root).find_for(affected, result[:services])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Render the blast radius of a service as an ASCII tree.
|
|
58
|
+
# Combines blast_radius + TreeRenderer in one call.
|
|
59
|
+
#
|
|
60
|
+
# Example output:
|
|
61
|
+
# My::ServiceClass
|
|
62
|
+
# ├── My::InvoiceService [depth 1] app/services/my/invoice_service.rb
|
|
63
|
+
# │ └── My::ReportService [depth 2] app/services/my/report_service.rb
|
|
64
|
+
# └── My::NotifierService [depth 1] app/services/my/notifier_service.rb
|
|
65
|
+
#
|
|
66
|
+
# @param class_name [String]
|
|
67
|
+
# @param root [String, nil]
|
|
68
|
+
# @param max_depth [Integer, nil]
|
|
69
|
+
# @return [String]
|
|
70
|
+
def render_tree(class_name, root: nil, max_depth: BlastRadius::DEFAULT_MAX_DEPTH)
|
|
71
|
+
radius = blast_radius(class_name, root: root, max_depth: max_depth)
|
|
72
|
+
TreeRenderer.render(radius)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: kaskd
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- nildiert
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
Kaskd scans Ruby service files via static analysis, builds a dependency graph,
|
|
14
|
+
and answers two questions: (1) which services are affected if a given service changes
|
|
15
|
+
(blast radius), and (2) which test files should be run as a result.
|
|
16
|
+
Works with standard Rails layouts and Packwerk-based monorepos.
|
|
17
|
+
email: []
|
|
18
|
+
executables: []
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- LICENSE
|
|
23
|
+
- README.md
|
|
24
|
+
- lib/kaskd.rb
|
|
25
|
+
- lib/kaskd/analyzer.rb
|
|
26
|
+
- lib/kaskd/blast_radius.rb
|
|
27
|
+
- lib/kaskd/configuration.rb
|
|
28
|
+
- lib/kaskd/test_finder.rb
|
|
29
|
+
- lib/kaskd/tree_renderer.rb
|
|
30
|
+
- lib/kaskd/version.rb
|
|
31
|
+
homepage: https://github.com/nildiert/kaskd
|
|
32
|
+
licenses:
|
|
33
|
+
- MIT
|
|
34
|
+
metadata:
|
|
35
|
+
homepage_uri: https://github.com/nildiert/kaskd
|
|
36
|
+
source_code_uri: https://github.com/nildiert/kaskd
|
|
37
|
+
changelog_uri: https://github.com/nildiert/kaskd/releases
|
|
38
|
+
rdoc_options: []
|
|
39
|
+
require_paths:
|
|
40
|
+
- lib
|
|
41
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '2.7'
|
|
46
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
47
|
+
requirements:
|
|
48
|
+
- - ">="
|
|
49
|
+
- !ruby/object:Gem::Version
|
|
50
|
+
version: '0'
|
|
51
|
+
requirements: []
|
|
52
|
+
rubygems_version: 3.6.7
|
|
53
|
+
specification_version: 4
|
|
54
|
+
summary: Static analyzer for Ruby service dependency graphs and blast radius calculation.
|
|
55
|
+
test_files: []
|