fiber_audit 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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/.fiber-audit.example.yml +33 -0
  3. data/CHANGELOG.md +32 -0
  4. data/README.md +150 -0
  5. data/bin/fiber-audit +5 -0
  6. data/lib/fiber_audit/audit.rb +288 -0
  7. data/lib/fiber_audit/cli.rb +245 -0
  8. data/lib/fiber_audit/configuration.rb +236 -0
  9. data/lib/fiber_audit/correlation/fingerprint.rb +26 -0
  10. data/lib/fiber_audit/errors.rb +8 -0
  11. data/lib/fiber_audit/execution_context.rb +47 -0
  12. data/lib/fiber_audit/findings/collection.rb +58 -0
  13. data/lib/fiber_audit/findings/confidence.rb +19 -0
  14. data/lib/fiber_audit/findings/evidence.rb +13 -0
  15. data/lib/fiber_audit/findings/finding.rb +76 -0
  16. data/lib/fiber_audit/findings/location.rb +9 -0
  17. data/lib/fiber_audit/findings/severity.rb +19 -0
  18. data/lib/fiber_audit/project.rb +96 -0
  19. data/lib/fiber_audit/reporters/base.rb +12 -0
  20. data/lib/fiber_audit/reporters/json.rb +34 -0
  21. data/lib/fiber_audit/reporters/schema.rb +574 -0
  22. data/lib/fiber_audit/reporters/text.rb +179 -0
  23. data/lib/fiber_audit/static/call_site.rb +71 -0
  24. data/lib/fiber_audit/static/call_site_extractor.rb +524 -0
  25. data/lib/fiber_audit/static/execution_context_resolver.rb +266 -0
  26. data/lib/fiber_audit/static/rules/base.rb +185 -0
  27. data/lib/fiber_audit/static/rules/blocking_subprocess.rb +94 -0
  28. data/lib/fiber_audit/static/rules/built_ins.rb +36 -0
  29. data/lib/fiber_audit/static/rules/direct_socket.rb +112 -0
  30. data/lib/fiber_audit/static/rules/io_select.rb +104 -0
  31. data/lib/fiber_audit/static/rules/net_http_in_request.rb +116 -0
  32. data/lib/fiber_audit/static/rules/registry.rb +123 -0
  33. data/lib/fiber_audit/static/rules/synchronization.rb +124 -0
  34. data/lib/fiber_audit/static/rules/thread_current_state.rb +113 -0
  35. data/lib/fiber_audit/static/rules/thread_join.rb +96 -0
  36. data/lib/fiber_audit/static/semantic_index.rb +300 -0
  37. data/lib/fiber_audit/suppressions/parser.rb +146 -0
  38. data/lib/fiber_audit/suppressions/store.rb +63 -0
  39. data/lib/fiber_audit/version.rb +5 -0
  40. data/lib/fiber_audit.rb +40 -0
  41. metadata +108 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 77fa4ea6790d065dbe69e5cbda2b0753dc2d1c1baa1983717fd00e3bab37b8c8
4
+ data.tar.gz: 5fd00521ad7ff862aede23b0f9abe9e9f126be7b967e2a3f2e364c9ffc5191c6
5
+ SHA512:
6
+ metadata.gz: 1849f2cf6a7abc30f3cf20c95689c2b5e8a32574908c472f5d685fa05267aabaaefdfc65a6e31f249ce03c7175d458c57eccd0bb945194dadd58cd89bff38fd9
7
+ data.tar.gz: fa902948a9e4d517b77cd4bf33dc50b1082f4ba69d8ea406262101e6baef5c583aa256208ba72eca0d9985b3668d868d7252bec4de24efd118fd0d9c4d9c3411
@@ -0,0 +1,33 @@
1
+ # FiberAudit v0.1 static-analysis configuration
2
+ static:
3
+ # Globs are evaluated from the detected project root.
4
+ include:
5
+ - app/**/*.rb
6
+ - lib/**/*.rb
7
+ - config/**/*.rb
8
+ exclude:
9
+ - vendor/**/*
10
+ - tmp/**/*
11
+ - node_modules/**/*
12
+ - db/schema.rb
13
+
14
+ # Optional YAML suppressions. Every entry must include a reason.
15
+ # suppressions_path: .fiber-audit-suppressions.yml
16
+
17
+ rules:
18
+ # Disable an individual rule.
19
+ FA1007:
20
+ enabled: false
21
+
22
+ # Override severity before the execution-context ceiling is applied.
23
+ FA1003:
24
+ severity: low
25
+
26
+ report:
27
+ # Reserved output formats supported by v0.1. The CLI emits one format per run.
28
+ formats:
29
+ - text
30
+ - json
31
+
32
+ # critical, high, medium, low, or info. Default low hides info findings.
33
+ min_severity: low
data/CHANGELOG.md ADDED
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-08-02)
4
+
5
+ First static-only release of FiberAudit. Requires Ruby 3.3 or newer.
6
+
7
+ ### Added
8
+
9
+ - Rubydex-backed semantic indexing behind a FiberAudit-owned adapter.
10
+ - Prism call-site extraction with conservative receiver inference and stable,
11
+ project-relative fingerprints.
12
+ - Rails-aware execution contexts for requests, middleware, callbacks, views,
13
+ jobs, WebSockets, boot, tests, and Rake tasks.
14
+ - Seven static rules:
15
+ - FA1001 blocking subprocess operations
16
+ - FA1002 thread waits
17
+ - FA1003 thread-oriented synchronization
18
+ - FA1004 thread-local state
19
+ - FA1005 explicit `IO.select`
20
+ - FA1006 direct sockets
21
+ - FA1007 synchronous request-path HTTP
22
+ - Deterministic text and JSON reports using schema version 1.0.
23
+ - Inline and YAML suppressions with mandatory reasons.
24
+ - Project detection, per-rule configuration, minimum-severity filtering, and
25
+ CI-friendly exit codes.
26
+ - `static`, `list-rules`, `explain`, and `version` CLI commands.
27
+
28
+ ### Scope
29
+
30
+ Version 0.1.0 does not perform runtime analysis and never emits unconditional
31
+ `PASS`. Every report states that PASS cannot be granted without runtime
32
+ coverage.
data/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # FiberAudit
2
+
3
+ FiberAudit audits Ruby and Rails code for operations that can block the thread
4
+ running a Fiber scheduler. Version 0.1.0 performs static analysis only.
5
+
6
+ > **Static-only disclaimer:** This is a static-only audit. PASS cannot be
7
+ > granted without runtime coverage.
8
+
9
+ ## Requirements and installation
10
+
11
+ FiberAudit v0.1.0 supports Ruby 3.3 and 3.4.
12
+
13
+ ```sh
14
+ gem install fiber_audit
15
+ ```
16
+
17
+ Or add it to a bundle and run `bundle install`:
18
+
19
+ ```ruby
20
+ gem "fiber_audit", require: false
21
+ ```
22
+
23
+ ## Quick start
24
+
25
+ Run from a project directory or any directory beneath it:
26
+
27
+ ```sh
28
+ fiber-audit static
29
+ ```
30
+
31
+ FiberAudit walks upward to find the nearest `Gemfile`, `gems.rb`, or
32
+ `config/application.rb`. It loads `.fiber-audit.yml` from that root when the
33
+ file exists.
34
+
35
+ ```text
36
+ fiber-audit static [--format text|json] [--config PATH] [--out PATH]
37
+ [--min-severity LEVEL] [--no-color]
38
+ fiber-audit list-rules
39
+ fiber-audit explain FA1001
40
+ fiber-audit version
41
+ ```
42
+
43
+ Output defaults to text on a TTY and JSON when piped. `--out PATH` defaults to
44
+ JSON, writes only the report to that file, and prints a one-line confirmation.
45
+ Explicit `--format` always wins.
46
+
47
+ ## Shipped rules
48
+
49
+ | ID | Detects | Default severity |
50
+ |---|---|---|
51
+ | FA1001 | Blocking subprocess operations | high |
52
+ | FA1002 | `Thread#join` and `Thread#value` | high |
53
+ | FA1003 | Thread-oriented synchronization | medium |
54
+ | FA1004 | Thread-local state access | high/medium |
55
+ | FA1005 | Explicit `IO.select`/`Kernel.select` | medium |
56
+ | FA1006 | Direct socket construction | medium |
57
+ | FA1007 | Synchronous HTTP in request-like contexts | high |
58
+
59
+ Use `fiber-audit explain <RULE_ID>` for exact targets and remediation.
60
+
61
+ ## Configuration
62
+
63
+ Copy `.fiber-audit.example.yml` to `.fiber-audit.yml` in the project root.
64
+ Paths and globs are rooted at the detected project. An explicit `--config`
65
+ path is resolved from the directory where the command was invoked.
66
+
67
+ ```yaml
68
+ static:
69
+ include:
70
+ - app/**/*.rb
71
+ - lib/**/*.rb
72
+ - config/**/*.rb
73
+ exclude:
74
+ - vendor/**/*
75
+ - tmp/**/*
76
+ - db/schema.rb
77
+ suppressions_path: .fiber-audit-suppressions.yml
78
+
79
+ rules:
80
+ FA1007:
81
+ enabled: false
82
+ FA1003:
83
+ severity: low
84
+
85
+ report:
86
+ formats: [text, json]
87
+ min_severity: low
88
+ ```
89
+
90
+ `--min-severity` overrides `report.min_severity` for one run. Severity ordering
91
+ is `critical`, `high`, `medium`, `low`, `info`; findings below the threshold
92
+ are omitted and do not affect the exit code. The default `low` threshold keeps
93
+ informational findings silent.
94
+
95
+ ## Suppressions
96
+
97
+ Every suppression requires a non-empty reason. Directive-looking text inside
98
+ strings, heredocs, or regular expressions is ignored.
99
+
100
+ Suppress one line:
101
+
102
+ ```ruby
103
+ system(command) # fiber-audit:disable FA1001 -- trusted maintenance command
104
+ ```
105
+
106
+ Suppress a block:
107
+
108
+ ```ruby
109
+ # fiber-audit:disable FA1003 -- protected legacy boundary
110
+ mutex.synchronize { update_record }
111
+ # fiber-audit:enable FA1003
112
+ ```
113
+
114
+ A separate YAML file can suppress by rule and optionally by symbol or
115
+ operation. Point `static.suppressions_path` at the file:
116
+
117
+ ```yaml
118
+ suppressions:
119
+ - rule: FA1001
120
+ symbol: Reports::Generator#call
121
+ operation: Open3.capture3
122
+ reason: isolated worker process with an external timeout
123
+ ```
124
+
125
+ Missing reasons and invalid configuration return exit code 2.
126
+
127
+ ## Static statuses
128
+
129
+ - `FAIL` — at least one critical or high finding.
130
+ - `REVIEW` — a medium finding, or a non-informational low/unknown-confidence
131
+ finding.
132
+ - `PASS_WITH_WARNINGS` — only low or informational findings.
133
+ - `NO_FINDINGS` — no findings at the configured threshold.
134
+
135
+ FiberAudit never emits unconditional `PASS` in v0.1.0.
136
+
137
+ ## Exit codes
138
+
139
+ | Code | Meaning |
140
+ |---|---|
141
+ | 0 | No active finding at or above the configured threshold |
142
+ | 1 | One or more active findings at or above the threshold |
143
+ | 2 | Invalid options, configuration, analysis, or report output |
144
+ | 3 | Reserved; never emitted by v0.1.0 |
145
+
146
+ Source parse errors are included in report data while analysis continues on
147
+ other files.
148
+
149
+ See [ARCHITECTURE.md](ARCHITECTURE.md) for implementation boundaries and the
150
+ future runtime-analysis architecture.
data/bin/fiber-audit ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'fiber_audit/cli'
5
+ exit FiberAudit::CLI.start(ARGV)
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative 'errors'
5
+ require_relative 'findings/severity'
6
+ require_relative 'findings/confidence'
7
+ require_relative 'findings/location'
8
+ require_relative 'findings/evidence'
9
+ require_relative 'findings/finding'
10
+ require_relative 'findings/collection'
11
+ require_relative 'suppressions/parser'
12
+ require_relative 'suppressions/store'
13
+ require_relative 'execution_context'
14
+ require_relative 'static/semantic_index'
15
+ require_relative 'static/call_site'
16
+ require_relative 'static/call_site_extractor'
17
+ require_relative 'static/execution_context_resolver'
18
+ require_relative 'static/rules/built_ins'
19
+
20
+ module FiberAudit
21
+ # Wave R4 WP-7: Audit coordinator.
22
+ #
23
+ # Orchestrates the complete static analysis workflow:
24
+ # 1. Expand/clean root
25
+ # 2. Resolve target files (include/exclude globs, Ruby only, sorted, deduped)
26
+ # 3. Build SemanticIndex(root:)
27
+ # 4. Extract call sites with absolute paths (unchanged)
28
+ # 5. Copy call sites and parse errors to root-relative paths (portable)
29
+ # 6. Resolve execution contexts on root-relative call sites
30
+ # 7. Parse inline suppressions with root-relative paths
31
+ # 8. Parse YAML suppressions (relative to root unless absolute)
32
+ # 9. Run enabled rules via Collection publication
33
+ # 10. Apply suppressions and min_severity filter
34
+ # 11. Determine status and build immutable Result
35
+ #
36
+ class Audit
37
+ Coverage = Data.define(:analysed_files, :total_call_sites, :rules_run)
38
+ Result = Data.define(:findings, :suppressed, :parse_errors, :coverage, :status)
39
+
40
+ # Status constants
41
+ STATUS_FAIL = 'FAIL'
42
+ STATUS_REVIEW = 'REVIEW'
43
+ STATUS_PASS_WITH_WARNINGS = 'PASS_WITH_WARNINGS'
44
+ STATUS_NO_FINDINGS = 'NO_FINDINGS'
45
+
46
+ def initialize(configuration:, root:)
47
+ @configuration = configuration
48
+ expanded_root = Pathname.new(root).expand_path.cleanpath
49
+ raise ArgumentError, "audit root is not a directory: #{expanded_root}" unless expanded_root.directory?
50
+
51
+ @root = expanded_root.realpath
52
+ freeze
53
+ end
54
+
55
+ attr_reader :root
56
+
57
+ def call
58
+ pipeline = run_pipeline
59
+ active, suppressed = pipeline.fetch(:store).apply(pipeline.fetch(:findings))
60
+ filtered_active = filter_by_severity(active)
61
+ filtered_suppressed = filter_by_severity(suppressed)
62
+
63
+ build_result(pipeline, filtered_active, filtered_suppressed)
64
+ end
65
+
66
+ private
67
+
68
+ # ── File resolution ──────────────────────────────────────────────
69
+
70
+ # Resolve files using include/exclude globs.
71
+ # Returns sorted, deduplicated array of absolute paths to regular .rb files.
72
+ def resolve_files
73
+ included = expand_include_globs
74
+ excluded = expand_exclude_globs
75
+
76
+ excluded_lookup = excluded.to_h { |file| [file, true] }
77
+
78
+ included
79
+ .reject { |file| excluded_lookup.key?(File.expand_path(file)) }
80
+ .select { |file| inside_root?(file) }
81
+ .map { |file| File.expand_path(file) }
82
+ .uniq
83
+ .sort
84
+ end
85
+
86
+ def expand_include_globs
87
+ @configuration.static_include.flat_map do |pattern|
88
+ Dir.glob(File.join(@root.to_s, pattern))
89
+ .select { |f| File.file?(f) && f.end_with?('.rb') }
90
+ end
91
+ end
92
+
93
+ def expand_exclude_globs
94
+ @configuration.static_exclude.flat_map do |pattern|
95
+ Dir.glob(File.join(@root.to_s, pattern)).map { |file| File.expand_path(file) }
96
+ end
97
+ end
98
+
99
+ def inside_root?(path)
100
+ expanded = File.expand_path(path)
101
+ expanded == @root.to_s || expanded.start_with?("#{@root}#{File::SEPARATOR}")
102
+ end
103
+
104
+ def run_pipeline
105
+ files = resolve_files
106
+ semantic_index = build_semantic_index
107
+ extraction = extract_call_sites(files, semantic_index)
108
+ call_sites = relativize_call_sites(extraction.call_sites)
109
+ context_resolver = build_context_resolver(semantic_index)
110
+ enabled_rules = build_enabled_rules(semantic_index, context_resolver)
111
+
112
+ {
113
+ files: files,
114
+ extraction: extraction,
115
+ parse_errors: relativize_parse_errors(extraction.parse_errors),
116
+ store: build_suppression_store(files),
117
+ enabled_rules: enabled_rules,
118
+ findings: run_rules(enabled_rules, context_resolver.resolve_all(call_sites: call_sites))
119
+ }
120
+ end
121
+
122
+ def build_result(pipeline, active, suppressed)
123
+ coverage = Coverage.new(
124
+ analysed_files: pipeline.fetch(:files).size,
125
+ total_call_sites: pipeline.fetch(:extraction).call_sites.size,
126
+ rules_run: pipeline.fetch(:enabled_rules).size
127
+ )
128
+
129
+ Result.new(
130
+ findings: active.dup.freeze,
131
+ suppressed: suppressed.dup.freeze,
132
+ parse_errors: pipeline.fetch(:parse_errors).dup.freeze,
133
+ coverage: coverage,
134
+ status: determine_status(active)
135
+ )
136
+ end
137
+
138
+ # ── Semantic index ───────────────────────────────────────────────
139
+
140
+ def build_semantic_index
141
+ Static::SemanticIndex.new(root: @root.to_s).build
142
+ end
143
+
144
+ # ── Call site extraction ─────────────────────────────────────────
145
+
146
+ # CallSiteExtractor receives absolute file paths exactly unchanged.
147
+ def extract_call_sites(files, semantic_index)
148
+ Static::CallSiteExtractor.new(
149
+ files: files,
150
+ semantic_index: semantic_index
151
+ ).call
152
+ end
153
+
154
+ # ── Path relativization ──────────────────────────────────────────
155
+
156
+ # Copy call sites to immutable root-relative paths (inside-root only).
157
+ def relativize_call_sites(call_sites)
158
+ call_sites.map do |cs|
159
+ rel_path = to_root_relative(cs.path)
160
+ cs.class.new(
161
+ path: rel_path,
162
+ line: cs.line,
163
+ column: cs.column,
164
+ receiver_source: cs.receiver_source,
165
+ receiver_constant: cs.receiver_constant,
166
+ method_name: cs.method_name,
167
+ arguments: cs.arguments,
168
+ enclosing_symbol: cs.enclosing_symbol,
169
+ nesting: cs.nesting,
170
+ execution_context: cs.execution_context,
171
+ resolution: cs.resolution,
172
+ confidence: cs.confidence
173
+ )
174
+ end
175
+ end
176
+
177
+ # Copy parse errors to immutable root-relative paths (inside-root only).
178
+ def relativize_parse_errors(parse_errors)
179
+ parse_errors.map do |pe|
180
+ rel_path = to_root_relative(pe.path)
181
+ Static::CallSiteExtractor::ParseError.new(
182
+ path: rel_path,
183
+ message: pe.message,
184
+ line: pe.line
185
+ )
186
+ end
187
+ end
188
+
189
+ # Convert absolute path to root-relative, inside-root only.
190
+ def to_root_relative(absolute_path)
191
+ return absolute_path unless absolute_path
192
+
193
+ root_str = @root.to_s
194
+ if absolute_path == root_str || absolute_path.start_with?("#{root_str}/")
195
+ Pathname.new(absolute_path).relative_path_from(@root).to_s
196
+ else
197
+ absolute_path
198
+ end
199
+ end
200
+
201
+ # ── Context resolution ───────────────────────────────────────────
202
+
203
+ def build_context_resolver(semantic_index)
204
+ Static::ExecutionContextResolver.new(workspace: semantic_index)
205
+ end
206
+
207
+ # ── Suppressions ─────────────────────────────────────────────────
208
+
209
+ def build_suppression_store(files)
210
+ Suppressions::Store.new(
211
+ inline_suppressions: parse_inline_suppressions(files),
212
+ yaml_suppressions: parse_yaml_suppressions
213
+ )
214
+ end
215
+
216
+ # Parse inline suppressions with root-relative path while reading absolute file.
217
+ def parse_inline_suppressions(files)
218
+ files.flat_map do |absolute_path|
219
+ content = File.read(absolute_path)
220
+ relative_path = to_root_relative(absolute_path)
221
+ Suppressions::Parser.parse_inline(relative_path, content)
222
+ end
223
+ end
224
+
225
+ # YAML suppressions path relative to root unless absolute.
226
+ def parse_yaml_suppressions
227
+ suppressions_path = @configuration.suppressions_path
228
+ return [] unless suppressions_path
229
+
230
+ yaml_path = if Pathname.new(suppressions_path).absolute?
231
+ suppressions_path
232
+ else
233
+ File.join(@root.to_s, suppressions_path)
234
+ end
235
+
236
+ Suppressions::Parser.parse_yaml(yaml_path)
237
+ end
238
+
239
+ # ── Rules ────────────────────────────────────────────────────────
240
+
241
+ def build_enabled_rules(semantic_index, context_resolver)
242
+ registry = Static::Rules::BuiltIns.registry(
243
+ workspace: semantic_index,
244
+ context_resolver: context_resolver
245
+ )
246
+ registry.enabled_for(@configuration)
247
+ end
248
+
249
+ # Run all enabled rules; collect findings via Collection publication.
250
+ def run_rules(enabled_rules, call_sites)
251
+ findings = enabled_rules.flat_map { |rule| rule.analyze(call_sites: call_sites) }
252
+ Collection.new(findings).to_a
253
+ end
254
+
255
+ # ── Severity filtering ───────────────────────────────────────────
256
+
257
+ def filter_by_severity(findings)
258
+ min_index = Severity.index(@configuration.min_severity)
259
+ findings.select { |f| Severity.index(f.severity) <= min_index }
260
+ end
261
+
262
+ # ── Status determination ─────────────────────────────────────────
263
+
264
+ # FAIL: any critical or high severity
265
+ # REVIEW: any medium, or low with unknown confidence
266
+ # PASS_WITH_WARNINGS: nonempty low/info remainder
267
+ # NO_FINDINGS: empty findings
268
+ # Never PASS.
269
+ def determine_status(findings)
270
+ return STATUS_NO_FINDINGS if findings.empty?
271
+
272
+ severities = findings.map(&:severity)
273
+
274
+ return STATUS_FAIL if severities.any? { |s| %i[critical high].include?(s) }
275
+ return STATUS_REVIEW if severities.include?(:medium)
276
+ return STATUS_REVIEW if findings.any? { |finding| review_confidence?(finding) }
277
+
278
+ has_low_or_info = findings.any? { |finding| %i[low info].include?(finding.severity) }
279
+ return STATUS_PASS_WITH_WARNINGS if has_low_or_info
280
+
281
+ STATUS_NO_FINDINGS
282
+ end
283
+
284
+ def review_confidence?(finding)
285
+ finding.severity != :info && %i[low unknown].include?(finding.confidence)
286
+ end
287
+ end
288
+ end