stud-finder 0.6.0 → 0.7.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 +4 -4
- data/CHANGELOG.md +7 -0
- data/lib/stud_finder/cli.rb +57 -18
- data/lib/stud_finder/gate.rb +163 -0
- data/lib/stud_finder/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d5c4ae36f3971b4c2aa32bc157c76eea4223b401764e2f895f541d4532f5ed8d
|
|
4
|
+
data.tar.gz: '0180283bc3f6c389e7c4b68ce42819e8d5bd5ec08684031249b4deb7915e7b62'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9bc2be78efee09c43411250904494503d23b9080e70bbd962cc043dc92073fc94c2903eef4cbe8dd828cb35fd4a5ce70758a815c5ed110a8bfb7250dcf37fc32
|
|
7
|
+
data.tar.gz: 6a808490d99422741999eb3d559c813b1c7e7646a8195bb4d6cc87541fdf21d4420f914e345acb68522d79ee72510101f1f4fb96549d01080b4f105e0318f347
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.7.0] - Unreleased
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Added `stud-finder gate`, an observation-first markdown gate subcommand for JSON scan output. v1 includes hardcoded checks for touched trunks, high-score files with low/missing evidence, and new files escalated by trunk adjacency.
|
|
13
|
+
- Added `--input FILE`/stdin input handling and an `--enforce` flag for gate failures. `--enforce` exists for CI experiments, but do not recommend it as a required merge gate until the Rec 3 rollout lands and teams have calibrated thresholds on real PRs.
|
|
14
|
+
|
|
8
15
|
## [0.6.0] - Unreleased
|
|
9
16
|
|
|
10
17
|
### Changed
|
data/lib/stud_finder/cli.rb
CHANGED
|
@@ -18,6 +18,7 @@ require_relative 'fan_in'
|
|
|
18
18
|
require_relative 'js_fan_in'
|
|
19
19
|
require_relative 'js_complexity'
|
|
20
20
|
require_relative 'file_collector'
|
|
21
|
+
require_relative 'gate'
|
|
21
22
|
require_relative 'loc_counter'
|
|
22
23
|
require_relative 'newness'
|
|
23
24
|
require_relative 'scorer'
|
|
@@ -74,32 +75,40 @@ module StudFinder
|
|
|
74
75
|
|
|
75
76
|
class ValidationError < StandardError; end
|
|
76
77
|
|
|
77
|
-
def initialize(argv, stdout: $stdout, stderr: $stderr)
|
|
78
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr, stdin: $stdin)
|
|
78
79
|
@argv = argv.dup
|
|
79
80
|
@stdout = stdout
|
|
80
81
|
@stderr = stderr
|
|
82
|
+
@stdin = stdin
|
|
81
83
|
@options = Marshal.load(Marshal.dump(DEFAULT_OPTIONS))
|
|
82
84
|
end
|
|
83
85
|
|
|
84
|
-
def self.start(argv = ARGV, stdout: $stdout, stderr: $stderr)
|
|
85
|
-
new(argv, stdout: stdout, stderr: stderr).run
|
|
86
|
+
def self.start(argv = ARGV, stdout: $stdout, stderr: $stderr, stdin: $stdin)
|
|
87
|
+
new(argv, stdout: stdout, stderr: stderr, stdin: stdin).run
|
|
86
88
|
end
|
|
87
89
|
|
|
88
90
|
def run
|
|
89
91
|
parser = option_parser
|
|
92
|
+
return run_gate if shift_subcommand?('gate')
|
|
93
|
+
return run_edges_subcommand(parser) if shift_subcommand?('edges')
|
|
90
94
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
run_scan(parser)
|
|
96
|
+
rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::InvalidArgument, ValidationError,
|
|
97
|
+
FileCollector::Error, Gate::Error, Churn::Error, Complexity::Error, Coverage::Cobertura::Error,
|
|
98
|
+
Coverage::Detector::Error, Coverage::Lcov::Error, Coverage::Resultset::Error, Diff::Error, Newness::Error,
|
|
99
|
+
Scorer::ValidationError => e
|
|
100
|
+
@stderr.puts e.message
|
|
101
|
+
1
|
|
102
|
+
end
|
|
97
103
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
104
|
+
def shift_subcommand?(name)
|
|
105
|
+
return false unless @argv[0] == name
|
|
106
|
+
|
|
107
|
+
@argv.shift
|
|
108
|
+
true
|
|
109
|
+
end
|
|
102
110
|
|
|
111
|
+
def run_scan(parser)
|
|
103
112
|
parser.parse!(@argv)
|
|
104
113
|
path = @argv.shift || '.'
|
|
105
114
|
raise ValidationError, "Error: unexpected arguments: #{@argv.join(' ')}" unless @argv.empty?
|
|
@@ -122,11 +131,41 @@ module StudFinder
|
|
|
122
131
|
analysis = warn_if_no_scored_files(analysis)
|
|
123
132
|
emit_results(@repo_path, result, analysis)
|
|
124
133
|
0
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
@
|
|
129
|
-
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def run_edges_subcommand(parser)
|
|
137
|
+
parser.parse!(@argv)
|
|
138
|
+
target = @argv.shift
|
|
139
|
+
path = @argv.shift || '.'
|
|
140
|
+
raise ValidationError, "Error: unexpected arguments: #{@argv.join(' ')}" unless @argv.empty?
|
|
141
|
+
|
|
142
|
+
@repo_path = File.expand_path(path)
|
|
143
|
+
validate_options!
|
|
144
|
+
run_edges(target, path)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def run_gate
|
|
148
|
+
gate_options = { input: nil, enforce: false }
|
|
149
|
+
OptionParser.new do |opts|
|
|
150
|
+
opts.banner = 'Usage: stud-finder gate [--input FILE] [--enforce]'
|
|
151
|
+
opts.on('--input FILE', 'Read stud-finder JSON output from FILE') { |value| gate_options[:input] = value }
|
|
152
|
+
opts.on('--enforce', 'Exit non-zero when gate findings are present') { gate_options[:enforce] = true }
|
|
153
|
+
end.parse!(@argv)
|
|
154
|
+
raise ValidationError, "Error: unexpected arguments: #{@argv.join(' ')}" unless @argv.empty?
|
|
155
|
+
|
|
156
|
+
json = gate_input(gate_options[:input])
|
|
157
|
+
result = Gate.call(json)
|
|
158
|
+
@stdout.puts Gate.markdown(result, enforce: gate_options[:enforce])
|
|
159
|
+
gate_options[:enforce] && result.findings? ? 1 : 0
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def gate_input(input_path)
|
|
163
|
+
return File.read(input_path) if input_path
|
|
164
|
+
return @stdin.read if !@stdin.respond_to?(:tty?) || !@stdin.tty?
|
|
165
|
+
|
|
166
|
+
raise ValidationError, 'Error: provide --input FILE or pipe JSON to stdin.'
|
|
167
|
+
rescue Errno::ENOENT
|
|
168
|
+
raise ValidationError, "Error: input file not found: #{input_path}"
|
|
130
169
|
end
|
|
131
170
|
|
|
132
171
|
def run_edges(target, path)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module StudFinder
|
|
6
|
+
class Gate
|
|
7
|
+
CHECKS = %w[trunk_touched low_evidence_high_score newness_trunk_adjacent].freeze
|
|
8
|
+
HIGH_SCORE_THRESHOLD = 0.75
|
|
9
|
+
LOW_EVIDENCE_THRESHOLD = 0.50
|
|
10
|
+
|
|
11
|
+
Finding = Struct.new(:path, :language, :score, :evidence, :classification, :reason, keyword_init: true)
|
|
12
|
+
Result = Struct.new(:checks, keyword_init: true) do
|
|
13
|
+
def finding_count
|
|
14
|
+
checks.values.sum(&:length)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def findings?
|
|
18
|
+
finding_count.positive?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class Error < StandardError; end
|
|
23
|
+
|
|
24
|
+
def self.call(json)
|
|
25
|
+
new(json).call
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(json)
|
|
29
|
+
@payload = JSON.parse(json)
|
|
30
|
+
rescue JSON::ParserError => e
|
|
31
|
+
raise Error, "Error: invalid JSON input: #{e.message}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call
|
|
35
|
+
rows = Array(@payload.fetch('ruby', [])) + Array(@payload.fetch('javascript', []))
|
|
36
|
+
Result.new(
|
|
37
|
+
checks: {
|
|
38
|
+
'trunk_touched' => trunk_touched(rows),
|
|
39
|
+
'low_evidence_high_score' => low_evidence_high_score(rows),
|
|
40
|
+
'newness_trunk_adjacent' => newness_trunk_adjacent(rows)
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.markdown(result, enforce: false)
|
|
46
|
+
(markdown_header(result, enforce: enforce) + markdown_summary(result) + markdown_details(result)).join("\n")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.markdown_header(result, enforce:)
|
|
50
|
+
[
|
|
51
|
+
'## Stud Finder gate',
|
|
52
|
+
'',
|
|
53
|
+
"**Mode:** #{enforce ? 'enforce' : 'observation'}",
|
|
54
|
+
"**Summary:** #{pluralize(result.finding_count, 'finding')} across #{CHECKS.length} checks.",
|
|
55
|
+
''
|
|
56
|
+
]
|
|
57
|
+
end
|
|
58
|
+
private_class_method :markdown_header
|
|
59
|
+
|
|
60
|
+
def self.markdown_summary(result)
|
|
61
|
+
CHECKS.map do |check|
|
|
62
|
+
findings = result.checks.fetch(check)
|
|
63
|
+
status = findings.empty? ? '✅' : '⚠️'
|
|
64
|
+
"- #{status} `#{check}` — #{pluralize(findings.length, 'finding')}"
|
|
65
|
+
end + ['']
|
|
66
|
+
end
|
|
67
|
+
private_class_method :markdown_summary
|
|
68
|
+
|
|
69
|
+
def self.markdown_details(result)
|
|
70
|
+
CHECKS.flat_map { |check| markdown_check_detail(check, result.checks.fetch(check)) }
|
|
71
|
+
end
|
|
72
|
+
private_class_method :markdown_details
|
|
73
|
+
|
|
74
|
+
def self.markdown_check_detail(check, findings)
|
|
75
|
+
[
|
|
76
|
+
"<details#{' open' if findings.any?}>",
|
|
77
|
+
"<summary><strong>#{check}</strong> — #{pluralize(findings.length, 'finding')}</summary>",
|
|
78
|
+
'',
|
|
79
|
+
*markdown_finding_rows(findings),
|
|
80
|
+
'',
|
|
81
|
+
'</details>',
|
|
82
|
+
''
|
|
83
|
+
]
|
|
84
|
+
end
|
|
85
|
+
private_class_method :markdown_check_detail
|
|
86
|
+
|
|
87
|
+
def self.markdown_finding_rows(findings)
|
|
88
|
+
return ['_No findings._'] if findings.empty?
|
|
89
|
+
|
|
90
|
+
['| file | class | score | evidence | reason |', '| --- | --- | ---: | ---: | --- |'] +
|
|
91
|
+
findings.map { |finding| markdown_finding_row(finding) }
|
|
92
|
+
end
|
|
93
|
+
private_class_method :markdown_finding_rows
|
|
94
|
+
|
|
95
|
+
def self.markdown_finding_row(finding)
|
|
96
|
+
"| `#{escape_md(finding.path)}` | #{escape_md(finding.classification)} | " \
|
|
97
|
+
"#{format_number(finding.score)} | #{format_evidence(finding.evidence)} | #{escape_md(finding.reason)} |"
|
|
98
|
+
end
|
|
99
|
+
private_class_method :markdown_finding_row
|
|
100
|
+
|
|
101
|
+
def self.pluralize(count, noun)
|
|
102
|
+
"#{count} #{noun}#{'s' unless count == 1}"
|
|
103
|
+
end
|
|
104
|
+
private_class_method :pluralize
|
|
105
|
+
|
|
106
|
+
def self.escape_md(value)
|
|
107
|
+
value.to_s.gsub('|', '\\|')
|
|
108
|
+
end
|
|
109
|
+
private_class_method :escape_md
|
|
110
|
+
|
|
111
|
+
def self.format_number(value)
|
|
112
|
+
value.nil? ? '—' : format('%.4f', value.to_f)
|
|
113
|
+
end
|
|
114
|
+
private_class_method :format_number
|
|
115
|
+
|
|
116
|
+
def self.format_evidence(value)
|
|
117
|
+
value.nil? ? 'nil' : format_number(value)
|
|
118
|
+
end
|
|
119
|
+
private_class_method :format_evidence
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def trunk_touched(rows)
|
|
124
|
+
rows.select { |row| row['class'] == 'trunk' }.map do |row|
|
|
125
|
+
finding(row, reason: 'Changed file is classified as trunk.')
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def low_evidence_high_score(rows)
|
|
130
|
+
risky_rows = rows.select do |row|
|
|
131
|
+
row.fetch('score', 0).to_f >= HIGH_SCORE_THRESHOLD && low_evidence?(row['evidence'])
|
|
132
|
+
end
|
|
133
|
+
risky_rows.map do |row|
|
|
134
|
+
finding(row, reason: low_evidence_high_score_reason)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def low_evidence_high_score_reason
|
|
139
|
+
"Score is >= #{HIGH_SCORE_THRESHOLD} while evidence is nil or < #{LOW_EVIDENCE_THRESHOLD}."
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def newness_trunk_adjacent(rows)
|
|
143
|
+
rows.select { |row| row['new_file'] && row['escalation'] == 'trunk_adjacent' }.map do |row|
|
|
144
|
+
finding(row, reason: 'New file is trunk-adjacent.')
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def low_evidence?(value)
|
|
149
|
+
value.nil? || value.to_f < LOW_EVIDENCE_THRESHOLD
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def finding(row, reason:)
|
|
153
|
+
Finding.new(
|
|
154
|
+
path: row.fetch('path'),
|
|
155
|
+
language: row['language'],
|
|
156
|
+
score: row['score'],
|
|
157
|
+
evidence: row['evidence'],
|
|
158
|
+
classification: row['class'],
|
|
159
|
+
reason: reason
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
data/lib/stud_finder/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: stud-finder
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- bazfer
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: csv
|
|
@@ -162,6 +162,7 @@ files:
|
|
|
162
162
|
- lib/stud_finder/edges.rb
|
|
163
163
|
- lib/stud_finder/fan_in.rb
|
|
164
164
|
- lib/stud_finder/file_collector.rb
|
|
165
|
+
- lib/stud_finder/gate.rb
|
|
165
166
|
- lib/stud_finder/js_complexity.rb
|
|
166
167
|
- lib/stud_finder/js_fan_in.rb
|
|
167
168
|
- lib/stud_finder/loc_counter.rb
|