ripple_effect 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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/.ripple-effect.yml.example +56 -0
  3. data/ARCHITECTURE.md +222 -0
  4. data/CHANGELOG.md +115 -0
  5. data/CODE_OF_CONDUCT.md +64 -0
  6. data/CONTRIBUTING.md +112 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +305 -0
  9. data/SECURITY.md +73 -0
  10. data/docs/ANALYSIS_MODEL.md +275 -0
  11. data/docs/CLI.md +276 -0
  12. data/docs/CONFIGURATION.md +178 -0
  13. data/docs/DECISIONS.md +210 -0
  14. data/docs/PUBLIC_LAUNCH_CHECKLIST.md +105 -0
  15. data/docs/RELEASING.md +94 -0
  16. data/docs/TESTING.md +179 -0
  17. data/exe/ripple-effect +7 -0
  18. data/lib/ripple_effect/analyzer.rb +379 -0
  19. data/lib/ripple_effect/cache_store.rb +207 -0
  20. data/lib/ripple_effect/cli/application.rb +126 -0
  21. data/lib/ripple_effect/cli/command.rb +165 -0
  22. data/lib/ripple_effect/cli/diff_command.rb +76 -0
  23. data/lib/ripple_effect/cli/doctor_command.rb +106 -0
  24. data/lib/ripple_effect/cli/graph_command.rb +61 -0
  25. data/lib/ripple_effect/cli/inspect_command.rb +66 -0
  26. data/lib/ripple_effect/cli/tests_command.rb +109 -0
  27. data/lib/ripple_effect/cli/version_command.rb +46 -0
  28. data/lib/ripple_effect/confidence.rb +61 -0
  29. data/lib/ripple_effect/configuration.rb +264 -0
  30. data/lib/ripple_effect/diagnostic.rb +90 -0
  31. data/lib/ripple_effect/diff/changed_symbol_resolver.rb +292 -0
  32. data/lib/ripple_effect/diff/git.rb +175 -0
  33. data/lib/ripple_effect/diff/hunk.rb +80 -0
  34. data/lib/ripple_effect/edge.rb +114 -0
  35. data/lib/ripple_effect/error.rb +23 -0
  36. data/lib/ripple_effect/extractors/base.rb +292 -0
  37. data/lib/ripple_effect/extractors/rails_associations.rb +102 -0
  38. data/lib/ripple_effect/extractors/rails_callbacks.rb +144 -0
  39. data/lib/ripple_effect/extractors/rails_delegation.rb +121 -0
  40. data/lib/ripple_effect/extractors/rails_jobs.rb +131 -0
  41. data/lib/ripple_effect/extractors/rails_mailers.rb +120 -0
  42. data/lib/ripple_effect/extractors/rails_routes.rb +256 -0
  43. data/lib/ripple_effect/extractors/rails_views.rb +299 -0
  44. data/lib/ripple_effect/extractors/ruby_structure.rb +221 -0
  45. data/lib/ripple_effect/extractors/test_conventions.rb +135 -0
  46. data/lib/ripple_effect/formatters/dot.rb +69 -0
  47. data/lib/ripple_effect/formatters/json.rb +43 -0
  48. data/lib/ripple_effect/formatters/text.rb +197 -0
  49. data/lib/ripple_effect/graph.rb +199 -0
  50. data/lib/ripple_effect/node.rb +153 -0
  51. data/lib/ripple_effect/project.rb +264 -0
  52. data/lib/ripple_effect/result.rb +147 -0
  53. data/lib/ripple_effect/risk.rb +167 -0
  54. data/lib/ripple_effect/static_index/adapter.rb +84 -0
  55. data/lib/ripple_effect/static_index/rubydex_adapter.rb +356 -0
  56. data/lib/ripple_effect/traversal/impact_walker.rb +153 -0
  57. data/lib/ripple_effect/version.rb +11 -0
  58. data/lib/ripple_effect.rb +89 -0
  59. metadata +155 -0
data/docs/CLI.md ADDED
@@ -0,0 +1,276 @@
1
+ # CLI reference
2
+
3
+ ```
4
+ ripple-effect COMMAND [options]
5
+ ```
6
+
7
+ ## Exit codes
8
+
9
+ These are part of the interface and are the same for every command.
10
+
11
+ | Code | Meaning |
12
+ | --- | --- |
13
+ | `0` | Success |
14
+ | `1` | A threshold you asked us to enforce was met (`--fail-on-risk`) |
15
+ | `2` | Your input: a bad query, bad configuration, bad usage, or a refused unsafe focus |
16
+ | `3` | Our problem: the analyzer or index failed |
17
+
18
+ Finding impact is never, by itself, a failure.
19
+
20
+ ## Global options
21
+
22
+ Accepted **before or after** the subcommand.
23
+
24
+ | Option | Meaning |
25
+ | --- | --- |
26
+ | `--root PATH` | Project root (default: current directory) |
27
+ | `--config PATH` | Path to a `.ripple-effect.yml`; must exist |
28
+ | `--format text\|json` | Output format (default `text`; `tests` defaults to `paths`) |
29
+ | `--no-cache` | Ignore and do not write the graph cache |
30
+ | `--verbose` | Include stats and every diagnostic |
31
+ | `--quiet` | Suppress non-essential output |
32
+ | `-v`, `--version` | Print the version |
33
+ | `-h`, `--help` | Show usage |
34
+
35
+ ## `inspect`
36
+
37
+ ```
38
+ ripple-effect inspect SYMBOL [options]
39
+ ```
40
+
41
+ What may be affected by changing `SYMBOL`, and why.
42
+
43
+ **Symbol forms**
44
+
45
+ | Form | Example |
46
+ | --- | --- |
47
+ | Class or module | `User`, `User::Profile` |
48
+ | Instance method | `User#activate!` |
49
+ | Singleton method | `User.find` (`User::find` is also accepted) |
50
+ | File | `app/models/user.rb` |
51
+
52
+ | Option | Meaning |
53
+ | --- | --- |
54
+ | `--depth N\|all` | Maximum hops to follow (default: unlimited) |
55
+ | `--path PATH` | Disambiguate a symbol declared in several files |
56
+ | `--min-confidence high\|medium\|low` | Only follow edges at least this confident |
57
+ | `--include-low-confidence` | Also follow low-confidence edges |
58
+ | `--direction dependents\|dependencies\|both` | Default `dependents` |
59
+
60
+ ```console
61
+ $ ripple-effect inspect 'BillingService#charge'
62
+ $ ripple-effect inspect User.find --depth 2 --format json
63
+ $ ripple-effect inspect 'User#save' --path lib/legacy/user.rb
64
+ ```
65
+
66
+ An ambiguous symbol is an error, never a guess:
67
+
68
+ ```console
69
+ $ ripple-effect inspect 'User#save'
70
+ Error: `User#save` matched 2 declarations
71
+ 1. app/models/user.rb:42
72
+ 2. lib/legacy/user.rb:18
73
+ Use --path to disambiguate.
74
+ ```
75
+
76
+ ## `diff`
77
+
78
+ ```
79
+ ripple-effect diff BASE [HEAD] [options]
80
+ ```
81
+
82
+ The blast radius of everything changed between two revisions.
83
+
84
+ **Revision semantics**
85
+
86
+ | Invocation | Compares |
87
+ | --- | --- |
88
+ | `diff main` | `main` against the **working tree**, committed, staged, unstaged and untracked |
89
+ | `diff main HEAD` | `main` against `HEAD`, committed changes only |
90
+ | `diff v1.2.0 HEAD` | Any two refs |
91
+
92
+ | Option | Meaning |
93
+ | --- | --- |
94
+ | `--depth N\|all` | Maximum hops to follow |
95
+ | `--min-confidence LEVEL` | Only follow edges at least this confident |
96
+ | `--include-low-confidence` | Also follow low-confidence edges |
97
+ | `--fail-on-risk low\|medium\|high\|critical` | Exit `1` when risk reaches this level |
98
+
99
+ ```console
100
+ $ ripple-effect diff main
101
+ $ ripple-effect diff origin/main --fail-on-risk critical
102
+ ```
103
+
104
+ ## `tests`
105
+
106
+ ```
107
+ ripple-effect tests BASE [HEAD] [options]
108
+ ```
109
+
110
+ The test files most likely relevant to a change.
111
+
112
+ The default `paths` format prints **only** test paths on stdout: every warning
113
+ goes to stderr: so it composes:
114
+
115
+ ```console
116
+ $ bundle exec rspec $(ripple-effect tests main)
117
+ ```
118
+
119
+ | Option | Meaning |
120
+ | --- | --- |
121
+ | `--runner rspec\|minitest\|auto` | Test framework (default `auto`) |
122
+ | `--allow-unsafe-focus` | Print a narrowed list even when a global file changed |
123
+ | `--min-confidence LEVEL` | Only follow edges at least this confident |
124
+ | `--format paths\|text\|json` | Default `paths` |
125
+
126
+ ### The unsafe-focus refusal
127
+
128
+ When a boot-impact file changes. `Gemfile`, `config/initializers/**`,
129
+ `config/routes.rb`, `spec/rails_helper.rb`, and the rest of
130
+ `tests.unsafe_global_files`, the graph cannot bound what it reaches. Printing a
131
+ short list would be an implicit claim that the other tests are unnecessary, which
132
+ RippleEffect does not make.
133
+
134
+ So the command prints nothing to stdout and exits `2`:
135
+
136
+ ```console
137
+ $ ripple-effect tests main
138
+ WARNING: focused tests may be incomplete because config/initializers/payments.rb changed.
139
+ These files affect application boot or configuration, so the impact graph cannot bound what they reach.
140
+ Run your full suite, or pass --allow-unsafe-focus to print a narrowed list anyway.
141
+ ```
142
+
143
+ ## `graph`
144
+
145
+ ```
146
+ ripple-effect graph SYMBOL [options]
147
+ ```
148
+
149
+ The impacted subgraph: not the whole project.
150
+
151
+ | Option | Meaning |
152
+ | --- | --- |
153
+ | `--format dot\|json` | Default `dot` |
154
+ | `--depth N\|all` | Maximum hops to follow |
155
+ | `--path PATH` | Disambiguate a symbol declared in several files |
156
+
157
+ ```console
158
+ $ ripple-effect graph 'BillingService#charge' --format dot | dot -Tsvg -o impact.svg
159
+ ```
160
+
161
+ Node shapes distinguish routes, jobs, callbacks and mailers; edge line styles show
162
+ confidence (solid = high, dashed = medium, dotted = low).
163
+
164
+ ## `doctor`
165
+
166
+ ```
167
+ ripple-effect doctor
168
+ ```
169
+
170
+ Checks the project root, Git availability, Ruby version support, Rails-like
171
+ layout, the static index backend, config validity, how many files were indexed,
172
+ diagnostic count, and cache writability: then restates the privacy guarantees.
173
+
174
+ Exits `2` if the Ruby version is unsupported. Makes no network calls.
175
+
176
+ ## `version`
177
+
178
+ ```
179
+ ripple-effect version
180
+ ```
181
+
182
+ Prints the bare semantic version, so it can be captured directly:
183
+
184
+ ```console
185
+ $ VERSION=$(ripple-effect version)
186
+ ```
187
+
188
+ `--verbose` adds the schema version, Ruby version and index backend; `--format
189
+ json` emits the same as JSON.
190
+
191
+ ## JSON output
192
+
193
+ Every command accepts `--format json`. The document is versioned and
194
+ deterministic: identical input produces byte-identical output.
195
+
196
+ ```json
197
+ {
198
+ "schema_version": 1,
199
+ "tool": { "name": "ripple_effect", "version": "0.1.0" },
200
+ "query": { "type": "symbol", "value": "BillingService#charge" },
201
+ "changed_nodes": [],
202
+ "impacted_nodes": [
203
+ {
204
+ "id": "method:app/services/checkout_service.rb:CheckoutService#call",
205
+ "kind": "instance_method",
206
+ "name": "CheckoutService#call",
207
+ "qualified_name": "CheckoutService#call",
208
+ "path": "app/services/checkout_service.rb",
209
+ "start_line": 2,
210
+ "end_line": 5,
211
+ "metadata": {},
212
+ "depth": 1,
213
+ "confidence": "medium",
214
+ "source_id": "method:app/services/billing_service.rb:BillingService#charge",
215
+ "evidence_path": [
216
+ {
217
+ "type": "method_call",
218
+ "evidence": "inference.unique_method_name",
219
+ "confidence": "medium",
220
+ "location": "app/services/checkout_service.rb:3"
221
+ }
222
+ ]
223
+ }
224
+ ],
225
+ "tests": [
226
+ {
227
+ "path": "spec/services/billing_service_spec.rb",
228
+ "reason": "reference",
229
+ "evidence": "inference.unique_method_name",
230
+ "confidence": "medium",
231
+ "depth": 1
232
+ }
233
+ ],
234
+ "risk": { "level": "high", "score": 15.0, "reasons": ["4 direct dependents"] },
235
+ "diagnostics": [
236
+ {
237
+ "severity": "info",
238
+ "code": "unresolved_route_controller",
239
+ "path": "config/routes.rb",
240
+ "line": 4,
241
+ "message": "GET /health routes to HealthController#show, which is not indexed"
242
+ }
243
+ ],
244
+ "stats": { "files_indexed": 23, "nodes": 75, "edges": 160, "cache": "hit" }
245
+ }
246
+ ```
247
+
248
+ Guarantees:
249
+
250
+ - `schema_version` is always present and increments on any breaking change.
251
+ - All file paths are project-relative. (Route URLs also begin with `/`, those are
252
+ URLs, in `metadata`, not paths.)
253
+ - No Ruby symbols; every value is JSON-native.
254
+ - Key order is fixed.
255
+
256
+ Unlike text output, JSON includes structural nodes (files, and the class that owns
257
+ a changed method). Text hides them as noise; machine consumers may want them.
258
+
259
+ ### Errors in JSON mode
260
+
261
+ Errors are JSON too, on **stderr**, with a non-zero exit. stdout stays empty.
262
+
263
+ ```json
264
+ {
265
+ "schema_version": 1,
266
+ "tool": { "name": "ripple_effect", "version": "0.1.0" },
267
+ "error": {
268
+ "code": "query_error",
269
+ "type": "RippleEffect::QueryError",
270
+ "message": "no indexed symbol matches `Nope#nope`"
271
+ }
272
+ }
273
+ ```
274
+
275
+ Error codes: `usage`, `query_error`, `configuration_error`, `project_error`,
276
+ `git_error`, `index_error`, `unsafe_focus`.
@@ -0,0 +1,178 @@
1
+ # Configuration
2
+
3
+ RippleEffect works with no configuration. To override anything, add
4
+ `.ripple-effect.yml` to your project root.
5
+
6
+ The `version` key is **required** whenever the file exists, so that a future schema
7
+ change can be detected rather than silently misread.
8
+
9
+ ## Full schema
10
+
11
+ Every value shown is the default.
12
+
13
+ ```yaml
14
+ version: 1
15
+
16
+ paths:
17
+ include:
18
+ - app/**/*.rb
19
+ - lib/**/*.rb
20
+ - config/routes.rb
21
+ - config/routes/**/*.rb
22
+ - spec/**/*.rb
23
+ - test/**/*.rb
24
+ views:
25
+ - app/views/**/*.erb
26
+ discover_engines: true
27
+ exclude:
28
+ - vendor/**
29
+ - node_modules/**
30
+ - tmp/**
31
+ - log/**
32
+ - coverage/**
33
+ - public/assets/**
34
+ - storage/**
35
+ - .bundle/**
36
+ - .git/**
37
+
38
+ analysis:
39
+ min_confidence: medium
40
+ default_depth: all
41
+ include_low_confidence: false
42
+
43
+ rails:
44
+ routes: true
45
+ associations: true
46
+ callbacks: true
47
+ jobs: true
48
+ mailers: true
49
+ delegation: true
50
+ views: true
51
+
52
+ tests:
53
+ framework: auto
54
+ unsafe_global_files:
55
+ - Gemfile
56
+ - Gemfile.lock
57
+ - config/application.rb
58
+ - config/environment.rb
59
+ - config/boot.rb
60
+ - config/environments/**
61
+ - config/initializers/**
62
+ - config/routes.rb
63
+ - spec/spec_helper.rb
64
+ - spec/rails_helper.rb
65
+ - test/test_helper.rb
66
+
67
+ cache:
68
+ enabled: true
69
+ directory: tmp/ripple_effect
70
+ ```
71
+
72
+ ## Keys
73
+
74
+ ### `paths`
75
+
76
+ Which files are indexed. Globs are project-relative; a leading `./` is stripped.
77
+
78
+ Test directories are included deliberately: indexing your specs is what lets
79
+ RippleEffect rank a test that *references* the changed code above one that merely
80
+ sits at the conventional path.
81
+
82
+ ### `paths.views`
83
+
84
+ ERB templates. These are not Ruby files, so they need their own patterns.
85
+
86
+ In a classic server-rendered Rails application the view layer is a large part of
87
+ the real dependency graph: helpers and models are used almost entirely from
88
+ templates. Omitting views does not just lose detail, it systematically
89
+ *understates* every helper's blast radius while the answer still looks complete.
90
+
91
+ Set to `[]` to skip templates entirely (or use `rails.views: false`).
92
+
93
+ ### `paths.discover_engines`
94
+
95
+ Default `true`. Also index directories that are Rails engines or gems in their
96
+ own right: a directory with both an `app/` and either a `*.gemspec` or a
97
+ `lib/**/engine.rb`.
98
+
99
+ Engine monorepos are common: Solidus and Spree are built this way, and plenty of
100
+ applications keep an `engines/` directory. Such repos often have no top-level
101
+ `app/` at all, so without this the default patterns would match almost nothing.
102
+ The gemspec-or-engine requirement is what keeps ordinary subdirectories out.
103
+
104
+ `ripple-effect doctor` lists the engines it found.
105
+
106
+ ### `analysis.min_confidence`
107
+
108
+ `high`, `medium` (default) or `low`. Edges weaker than this are not traversed.
109
+
110
+ - `high`, only explicit static references and literal Rails DSL relationships.
111
+ - `medium`, also convention-inferred targets. The default.
112
+ - `low`, also naming and path heuristics. Noisy.
113
+
114
+ ### `analysis.default_depth`
115
+
116
+ A positive integer, or `all` (default) for unlimited, cycle-protected traversal.
117
+ `--depth` overrides it per invocation.
118
+
119
+ ### `analysis.include_low_confidence`
120
+
121
+ Lowers the floor to `low`. Equivalent to always passing
122
+ `--include-low-confidence`.
123
+
124
+ ### `rails.*`
125
+
126
+ Switches individual Rails extractors off. Useful when a DSL in your codebase is
127
+ producing noise, or to speed up analysis of a non-Rails project.
128
+
129
+ The Ruby structure extractor cannot be disabled: every other extractor builds on
130
+ the nodes it creates.
131
+
132
+ ### `tests.framework`
133
+
134
+ `auto` (default), `rspec` or `minitest`. `auto` looks for `spec/`, then `test/`.
135
+
136
+ ### `tests.unsafe_global_files`
137
+
138
+ Files whose change can affect almost anything. When one of these changes, `diff`
139
+ records a `global_file_changed` diagnostic, risk scoring adds points, and `tests`
140
+ refuses to print a narrowed list without `--allow-unsafe-focus`.
141
+
142
+ Add anything in your project with the same property: a central initializer, a
143
+ generated schema, a shared factory file.
144
+
145
+ ### `cache`
146
+
147
+ `enabled` (default `true`) and `directory` (default `tmp/ripple_effect`).
148
+
149
+ The cache is invalidated by content, so you rarely need to clear it by hand.
150
+ `--no-cache` skips it for one run.
151
+
152
+ ## Validation
153
+
154
+ RippleEffect distinguishes two kinds of mistake:
155
+
156
+ - **An unknown top-level key is a warning**, surfaced by `doctor`. A typo in a key
157
+ name is usually harmless.
158
+ - **An invalid value for a known key is a hard error.** A bad value would silently
159
+ change what gets analysed, and a silently wrong answer is worse than no answer.
160
+
161
+ ```console
162
+ $ ripple-effect doctor
163
+ Configuration warnings
164
+ unknown configuration key(s): analisys
165
+
166
+ $ ripple-effect inspect User
167
+ Error: `min_confidence` must be one of high, medium, low, got "certain"
168
+ ```
169
+
170
+ ## Safety
171
+
172
+ The file is read with `YAML.safe_load_file`: no aliases, no permitted classes, no
173
+ object deserialisation. A config file cannot execute anything.
174
+
175
+ ## Example
176
+
177
+ Copy [`.ripple-effect.yml.example`](../.ripple-effect.yml.example) to
178
+ `.ripple-effect.yml` and edit.
data/docs/DECISIONS.md ADDED
@@ -0,0 +1,210 @@
1
+ # Architecture decisions
2
+
3
+ A record of the choices that shaped RippleEffect, and what they cost.
4
+
5
+ ---
6
+
7
+ ## 1. Rubydex for Ruby indexing, behind an adapter
8
+
9
+ **Decision.** Use [Rubydex](https://github.com/Shopify/rubydex) for Ruby
10
+ declarations and references, and seal it behind `StaticIndex::Adapter`.
11
+
12
+ **Why.** Writing a correct Ruby indexer: constant resolution, lexical scope,
13
+ singleton classes, mixin ancestry: is a multi-year project and not what makes
14
+ RippleEffect valuable. Rubydex does it well and is MIT-licensed.
15
+
16
+ But Rubydex is young; version 0.4.1 is what this was built against, and its API is
17
+ still moving. Depending on it directly throughout the codebase would make every
18
+ future Rubydex release a potential rewrite.
19
+
20
+ **Cost.** A translation layer, and normalisation work at the boundary: Rubydex
21
+ reports zero-indexed lines and names singleton methods `Order::<Order>#recent`.
22
+ The adapter converts both. A spec asserts no Rubydex object escapes.
23
+
24
+ **Result.** Replacing the indexer means rewriting one file.
25
+
26
+ ---
27
+
28
+ ## 2. Prism for the Rails DSL, in addition to the index
29
+
30
+ **Decision.** Parse Rails DSL calls with Prism rather than extending the index.
31
+
32
+ **Why.** A declaration index answers "what is defined and what is referenced". It
33
+ tells us *that* `belongs_to` was called; it does not tell us it was called with
34
+ `:account, class_name: "User"`. Rails semantics live entirely in those arguments.
35
+
36
+ **Cost.** A second dependency, and a second pass over the source. Mitigated by a
37
+ shared `SourceCache`, so each file is parsed at most once regardless of how many
38
+ extractors need it.
39
+
40
+ **Note.** Prism is a default gem from Ruby 3.3, but not on 3.2, so it is an
41
+ explicit runtime dependency.
42
+
43
+ ---
44
+
45
+ ## 3. Confidence bands, never percentages
46
+
47
+ **Decision.** Three bands. `high`, `medium`, `low`. No numeric probability in any
48
+ human-facing output.
49
+
50
+ **Why.** A number like "91% likely to break" implies a calibrated model. There is
51
+ no such model, and there could not be one without a large corpus of real changes
52
+ and their outcomes. A fabricated number is worse than no number, because people
53
+ act on it.
54
+
55
+ **Cost.** Less impressive output. Coarser ranking.
56
+
57
+ **Consequence.** A numeric weight exists internally for traversal and ranking, but
58
+ never surfaces.
59
+
60
+ ---
61
+
62
+ ## 4. Ambiguity produces a diagnostic, never a guess
63
+
64
+ **Decision.** When a call's receiver is unresolved and several methods share the
65
+ name, add **no edge** and record `unresolved_method_receiver`. Only a unique name
66
+ match earns a `medium` edge.
67
+
68
+ **Why.** This is the decision the whole product rests on. A user who finds one
69
+ confidently-asserted, obviously-wrong edge will stop trusting every other edge —
70
+ and correctly so. False negatives are disappointing; confident false positives are
71
+ disqualifying.
72
+
73
+ **Cost.** RippleEffect misses real relationships in codebases that use common
74
+ method names heavily.
75
+
76
+ **Mitigation.** The diagnostic is visible with `--verbose`, so a user can see
77
+ exactly what was skipped and why.
78
+
79
+ ---
80
+
81
+ ## 5. Edges point from dependent to dependency
82
+
83
+ **Decision.** `from_id` depends on `into_id`, always, with no exceptions.
84
+
85
+ **Why.** A graph that mixes directions is unusable: every traversal has to know
86
+ which convention each edge type follows. With one rule, "what breaks if I change
87
+ this?" is simply a reverse walk.
88
+
89
+ **Cost.** Some edges read backwards at first (a route "depends on" its controller
90
+ action; a class "depends on" the methods it defines). The consistency is worth it.
91
+
92
+ ---
93
+
94
+ ## 6. Breadth-first traversal
95
+
96
+ **Decision.** Breadth-first, keeping the shortest path, breaking ties on
97
+ confidence.
98
+
99
+ **Why.** The evidence path is the product. The shortest chain of reasoning is the
100
+ one a human can verify fastest, and verifiability is what makes the output
101
+ trustworthy. Depth-first would produce arbitrary, sometimes very long,
102
+ explanations for the same conclusion.
103
+
104
+ **Cost.** Slightly more bookkeeping: an equally-short alternative path must still
105
+ reach the tie-break even though it is not re-queued.
106
+
107
+ ---
108
+
109
+ ## 7. Diff maps lines to the smallest enclosing declaration
110
+
111
+ **Decision.** Map changed line ranges to the innermost declaration, rather than
112
+ marking every declaration in a touched file as changed.
113
+
114
+ **Why.** File-level granularity makes a one-line fix look like a rewrite of the
115
+ class, which makes the entire blast radius useless in exactly the case people care
116
+ about most.
117
+
118
+ **Cost.** Real complexity. Deleted code exists only in the base revision, so it
119
+ must be fetched with `git show` and parsed separately. When a deleted symbol is
120
+ gone entirely, the change is attributed to its former owner: which is what its
121
+ dependents actually referenced.
122
+
123
+ ---
124
+
125
+ ## 8. `tests` refuses to narrow after a global-file change
126
+
127
+ **Decision.** When a boot-impact file changes, print nothing to stdout and exit
128
+ `2` unless `--allow-unsafe-focus` is given.
129
+
130
+ **Why.** Printing a short list is an implicit claim that the omitted tests are
131
+ unnecessary. For a change to `config/initializers/` or the `Gemfile`, the graph
132
+ cannot support that claim. Since the output is designed to be piped straight into
133
+ `rspec`, a quiet under-approximation would silently skip tests in CI.
134
+
135
+ **Cost.** An extra flag for users who know what they are doing.
136
+
137
+ **Principle.** RippleEffect can show a test is probably relevant. It cannot show
138
+ one is irrelevant.
139
+
140
+ ---
141
+
142
+ ## 9. Structural edges exist, but are hidden from text output
143
+
144
+ **Decision.** Keep `file -> class` and `class -> method` edges in the graph; hide
145
+ them in text output; keep them in JSON.
146
+
147
+ **Why.** They are load-bearing: a file-level diff needs to reach the code it
148
+ declares, and a method change needs to reach dependents of its class. But
149
+ reporting "app/models/order.rb depends on Order#total" as a *finding* buries the
150
+ real answer under noise.
151
+
152
+ **Cost.** Text and JSON differ in content, which must be documented. Risk scoring
153
+ also has to exclude file nodes, or every score inflates with the number of files
154
+ touched.
155
+
156
+ ---
157
+
158
+ ## 10. JSON cache, never Marshal
159
+
160
+ **Decision.** Serialise the graph cache as JSON.
161
+
162
+ **Why.** A cache file is ordinary data in `tmp/` that any process can write.
163
+ `Marshal.load` on untrusted data constructs arbitrary objects. The performance
164
+ difference does not come close to justifying that.
165
+
166
+ **Cost.** Slower serialisation, and value objects must be reconstructed by hand on
167
+ read.
168
+
169
+ ---
170
+
171
+ ## 11. Cache keyed on full content digests
172
+
173
+ **Decision.** SHA-256 every indexed file into the cache key, rather than relying on
174
+ mtime and size.
175
+
176
+ **Why.** A stale graph produces a *wrong answer that looks right*, which is the
177
+ worst failure mode this tool has. Branch switching, `git stash` and checkouts all
178
+ move mtimes in ways that make timestamp invalidation unreliable.
179
+
180
+ **Cost.** Hashing every file on every run.
181
+
182
+ **Escape hatches.** A corrupt cache is discarded and rebuilt rather than being
183
+ fatal; `--no-cache` skips it entirely.
184
+
185
+ ---
186
+
187
+ ## 12. OptionParser rather than Thor
188
+
189
+ **Decision.** Use the standard library.
190
+
191
+ **Why.** Adding a runtime dependency to a developer tool means adding it to every
192
+ project that installs the tool. OptionParser is adequate for six subcommands.
193
+
194
+ **Cost.** Subcommand dispatch and shared-flag handling written by hand: about
195
+ sixty lines in `CLI::Command`.
196
+
197
+ ---
198
+
199
+ ## 13. Rails is analysed, never depended on
200
+
201
+ **Decision.** No Rails dependency, at any version.
202
+
203
+ **Why.** Loading Rails to analyse Rails would mean booting the target
204
+ application: the exact thing that makes this kind of tool slow, fragile, and
205
+ unsafe on an unfamiliar codebase. Static analysis also works on a broken app,
206
+ which is often when you most want it.
207
+
208
+ **Cost.** Rails' inflections and conventions are reimplemented in miniature. The
209
+ inflector is small and conservative, and an unresolved target is
210
+ reported rather than guessed.