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 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaskd
4
+ VERSION = "0.1.0"
5
+ 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: []