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.
- checksums.yaml +7 -0
- data/.fiber-audit.example.yml +33 -0
- data/CHANGELOG.md +32 -0
- data/README.md +150 -0
- data/bin/fiber-audit +5 -0
- data/lib/fiber_audit/audit.rb +288 -0
- data/lib/fiber_audit/cli.rb +245 -0
- data/lib/fiber_audit/configuration.rb +236 -0
- data/lib/fiber_audit/correlation/fingerprint.rb +26 -0
- data/lib/fiber_audit/errors.rb +8 -0
- data/lib/fiber_audit/execution_context.rb +47 -0
- data/lib/fiber_audit/findings/collection.rb +58 -0
- data/lib/fiber_audit/findings/confidence.rb +19 -0
- data/lib/fiber_audit/findings/evidence.rb +13 -0
- data/lib/fiber_audit/findings/finding.rb +76 -0
- data/lib/fiber_audit/findings/location.rb +9 -0
- data/lib/fiber_audit/findings/severity.rb +19 -0
- data/lib/fiber_audit/project.rb +96 -0
- data/lib/fiber_audit/reporters/base.rb +12 -0
- data/lib/fiber_audit/reporters/json.rb +34 -0
- data/lib/fiber_audit/reporters/schema.rb +574 -0
- data/lib/fiber_audit/reporters/text.rb +179 -0
- data/lib/fiber_audit/static/call_site.rb +71 -0
- data/lib/fiber_audit/static/call_site_extractor.rb +524 -0
- data/lib/fiber_audit/static/execution_context_resolver.rb +266 -0
- data/lib/fiber_audit/static/rules/base.rb +185 -0
- data/lib/fiber_audit/static/rules/blocking_subprocess.rb +94 -0
- data/lib/fiber_audit/static/rules/built_ins.rb +36 -0
- data/lib/fiber_audit/static/rules/direct_socket.rb +112 -0
- data/lib/fiber_audit/static/rules/io_select.rb +104 -0
- data/lib/fiber_audit/static/rules/net_http_in_request.rb +116 -0
- data/lib/fiber_audit/static/rules/registry.rb +123 -0
- data/lib/fiber_audit/static/rules/synchronization.rb +124 -0
- data/lib/fiber_audit/static/rules/thread_current_state.rb +113 -0
- data/lib/fiber_audit/static/rules/thread_join.rb +96 -0
- data/lib/fiber_audit/static/semantic_index.rb +300 -0
- data/lib/fiber_audit/suppressions/parser.rb +146 -0
- data/lib/fiber_audit/suppressions/store.rb +63 -0
- data/lib/fiber_audit/version.rb +5 -0
- data/lib/fiber_audit.rb +40 -0
- 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,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
|