stud-finder 0.5.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e14a94cc20570cd7eb6b592b62b0cbf7268d8be02f546a3f1f343146f6b3c6f
4
- data.tar.gz: a49e0535ae4dc3f97958e2758773a04ceb7b486e7f7582a3048562dafbcfeaf4
3
+ metadata.gz: d5c4ae36f3971b4c2aa32bc157c76eea4223b401764e2f895f541d4532f5ed8d
4
+ data.tar.gz: '0180283bc3f6c389e7c4b68ce42819e8d5bd5ec08684031249b4deb7915e7b62'
5
5
  SHA512:
6
- metadata.gz: d036d77feaec9b455cf6abe8d5f2578f25a91f7948bb91d7a97150d94b007c38fd4daa4b4ea0bce21c41168b31f3f70cb03779657af4c156a7c4225d17fa6020
7
- data.tar.gz: ff082727ae14f9dbd27a0a11528a4f3223df541218ab58f4ac7420adae66b405eef05ae541c20da3ad3a97cd738c2104bc5b916ad1846ac6066f361576e2f3a2
6
+ metadata.gz: 9bc2be78efee09c43411250904494503d23b9080e70bbd962cc043dc92073fc94c2903eef4cbe8dd828cb35fd4a5ce70758a815c5ed110a8bfb7250dcf37fc32
7
+ data.tar.gz: 6a808490d99422741999eb3d559c813b1c7e7646a8195bb4d6cc87541fdf21d4420f914e345acb68522d79ee72510101f1f4fb96549d01080b4f105e0318f347
data/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ 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
+
15
+ ## [0.6.0] - Unreleased
16
+
17
+ ### Changed
18
+
19
+ - BREAKING: JSON output now reports `meta.schema_version: 1`, an integer schema marker that will bump on future breaking JSON changes.
20
+ - BREAKING: JSON `warnings` and `meta.warnings` are now normalized to objects shaped as `{code, message}`. Bare warning strings are no longer emitted.
21
+
8
22
  ## [0.5.1] - Unreleased
9
23
 
10
24
  ### Added
data/README.md CHANGED
@@ -165,7 +165,7 @@ In shallow clones where auto-unshallow fails (or `--no-auto-unshallow` is set),
165
165
 
166
166
  ## Warnings
167
167
 
168
- `analysis.warnings` (available in JSON output) surfaces conditions the run detected that a consumer should know about:
168
+ `analysis.warnings` (available in JSON output) surfaces conditions the run detected that a consumer should know about. Starting in schema version `1`, every warning is an object with `code` and human-readable `message` fields; bare warning strings are no longer emitted.
169
169
 
170
170
  - **`shallow_clone_newness_disabled`** — shallow git clone detected; newness rules auto-disabled (auto-unshallow also failed, or `--no-auto-unshallow` was passed).
171
171
  - **`shallow_clone_unshallow_failed`** — `git fetch --unshallow` was attempted but failed (network error or timeout); evidence is unavailable. Use `fetch-depth: 0` in CI or pass `--no-auto-unshallow` to suppress the attempt.
@@ -245,7 +245,7 @@ Each language gets its own ranking section in the output — Ruby and JS are not
245
245
 
246
246
  - `table` — human-readable, aligned columns
247
247
  - `csv` — spreadsheet-friendly, pipe to a file
248
- - `json` — machine-readable with `meta`, `warnings`, `ruby`, `javascript` sections. `meta.formula` labels the active mode (`5-factor + coupling`, `5-factor`, `4-factor + coupling`, `4-factor`). `meta.weights` reports the normalized weights actually used (with `null` for signals that were unavailable).
248
+ - `json` — machine-readable with `meta`, `warnings`, `ruby`, `javascript` sections. `meta.schema_version` is the integer JSON schema version (`1` as of Stud Finder 0.6.0). `meta.formula` labels the active mode (`5-factor + coupling`, `5-factor`, `4-factor + coupling`, `4-factor`). `meta.weights` reports the normalized weights actually used (with `null` for signals that were unavailable).
249
249
  - `markdown` — drop directly into a PR comment or issue
250
250
 
251
251
  ---
@@ -9,6 +9,7 @@ require 'set'
9
9
  require 'time'
10
10
  require_relative 'churn'
11
11
  require_relative 'temporal_coupling'
12
+ require_relative 'warnings'
12
13
  require_relative 'complexity'
13
14
  require_relative 'diff'
14
15
  require_relative 'coverage/detector'
@@ -17,6 +18,7 @@ require_relative 'fan_in'
17
18
  require_relative 'js_fan_in'
18
19
  require_relative 'js_complexity'
19
20
  require_relative 'file_collector'
21
+ require_relative 'gate'
20
22
  require_relative 'loc_counter'
21
23
  require_relative 'newness'
22
24
  require_relative 'scorer'
@@ -73,32 +75,40 @@ module StudFinder
73
75
 
74
76
  class ValidationError < StandardError; end
75
77
 
76
- def initialize(argv, stdout: $stdout, stderr: $stderr)
78
+ def initialize(argv, stdout: $stdout, stderr: $stderr, stdin: $stdin)
77
79
  @argv = argv.dup
78
80
  @stdout = stdout
79
81
  @stderr = stderr
82
+ @stdin = stdin
80
83
  @options = Marshal.load(Marshal.dump(DEFAULT_OPTIONS))
81
84
  end
82
85
 
83
- def self.start(argv = ARGV, stdout: $stdout, stderr: $stderr)
84
- 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
85
88
  end
86
89
 
87
90
  def run
88
91
  parser = option_parser
92
+ return run_gate if shift_subcommand?('gate')
93
+ return run_edges_subcommand(parser) if shift_subcommand?('edges')
89
94
 
90
- if @argv[0] == 'edges'
91
- @argv.shift
92
- parser.parse!(@argv)
93
- target = @argv.shift
94
- path = @argv.shift || '.'
95
- raise ValidationError, "Error: unexpected arguments: #{@argv.join(' ')}" unless @argv.empty?
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
96
103
 
97
- @repo_path = File.expand_path(path)
98
- validate_options!
99
- return run_edges(target, path)
100
- end
104
+ def shift_subcommand?(name)
105
+ return false unless @argv[0] == name
106
+
107
+ @argv.shift
108
+ true
109
+ end
101
110
 
111
+ def run_scan(parser)
102
112
  parser.parse!(@argv)
103
113
  path = @argv.shift || '.'
104
114
  raise ValidationError, "Error: unexpected arguments: #{@argv.join(' ')}" unless @argv.empty?
@@ -121,11 +131,41 @@ module StudFinder
121
131
  analysis = warn_if_no_scored_files(analysis)
122
132
  emit_results(@repo_path, result, analysis)
123
133
  0
124
- rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::InvalidArgument, ValidationError,
125
- FileCollector::Error, Churn::Error, Complexity::Error, Coverage::Cobertura::Error, Coverage::Detector::Error,
126
- Coverage::Lcov::Error, Coverage::Resultset::Error, Diff::Error, Newness::Error, Scorer::ValidationError => e
127
- @stderr.puts e.message
128
- 1
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}"
129
169
  end
130
170
 
131
171
  def run_edges(target, path)
@@ -722,7 +762,7 @@ module StudFinder
722
762
  def emit_json(path, analysis, ruby_rows, javascript_rows)
723
763
  @stdout.puts JSON.generate(
724
764
  meta: json_meta(path, analysis),
725
- warnings: analysis.warnings,
765
+ warnings: Warnings.normalize(analysis.warnings),
726
766
  ruby: ruby_rows.map { |row| json_file(row) },
727
767
  javascript: javascript_rows.map { |row| json_file(row) }
728
768
  )
@@ -737,7 +777,8 @@ module StudFinder
737
777
  files_skipped: analysis.ruby.skipped_files.length + analysis.javascript.skipped_files.length,
738
778
  formula: json_formula(analysis),
739
779
  weights: json_weights(analysis.ruby.weights || analysis.javascript.weights),
740
- warnings: analysis.warnings
780
+ schema_version: 1,
781
+ warnings: Warnings.normalize(analysis.warnings)
741
782
  }
742
783
  meta[:filtered] = true if @options[:filter_set]
743
784
  meta[:diff_base] = @options[:diff_base] if @options[:diff_base]
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StudFinder
4
- VERSION = '0.5.1'
4
+ VERSION = '0.7.0'
5
5
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StudFinder
4
+ module Warnings
5
+ MESSAGES = {
6
+ 'coverage_flag_deprecated' => '--coverage is deprecated; use --ruby-coverage instead',
7
+ 'coverage_unavailable' => 'coverage data was not provided; coverage and interaction signals are unavailable',
8
+ 'coverage_partial' => 'coverage data did not include every scored file; missing files use uncovered risk',
9
+ 'diff_filter_empty' => 'diff filter matched no changed files; output rows are empty',
10
+ 'diff_no_scored_files' => 'diff matched no scored files; the PR may only touch unscorable files',
11
+ 'fan_in_rails_inference_failed' => 'Rails inference failed; inferred Rails references may be incomplete',
12
+ 'fan_in_reference_resolution_failed' => 'constant reference resolution failed; fan-in may be incomplete',
13
+ 'files_skipped' => 'one or more files were skipped during analysis',
14
+ 'git_error' => 'git command failed while computing temporal coupling; coupling is unavailable',
15
+ 'git_not_found' => 'git was not found in PATH while computing temporal coupling; coupling is unavailable',
16
+ 'js_depcruise_failed' => 'dependency-cruiser failed; JavaScript/TypeScript fan-in is unavailable',
17
+ 'js_depcruise_no_config' => 'dependency-cruiser config failed; retried with --no-config, so ' \
18
+ 'JavaScript/TypeScript fan-in may be undercounted',
19
+ 'js_depcruise_timeout' => 'dependency-cruiser timed out; JavaScript/TypeScript fan-in is unavailable',
20
+ 'js_eslint_failed' => 'ESLint failed for at least one batch; JavaScript/TypeScript complexity may be incomplete',
21
+ 'js_eslint_malformed' => 'ESLint produced malformed JSON for at least one batch; JavaScript/TypeScript ' \
22
+ 'complexity may be incomplete',
23
+ 'js_eslint_missing' => 'ESLint was not found; JavaScript/TypeScript complexity is unavailable',
24
+ 'js_eslint_timeout' => 'ESLint timed out for at least one batch; JavaScript/TypeScript complexity may be ' \
25
+ 'incomplete',
26
+ 'js_tools_missing' => 'Node.js or dependency-cruiser was not found; JavaScript/TypeScript fan-in is unavailable',
27
+ 'js_ts_parser_missing' => 'TypeScript files were present but @typescript-eslint/parser was not found; ' \
28
+ 'TypeScript complexity may be incomplete',
29
+ 'shallow_clone_newness_disabled' => 'shallow git clone detected; newness rules disabled (use fetch-depth: 0)',
30
+ 'shallow_clone_unshallow_failed' => 'auto-unshallow failed; evidence unavailable (use full git history)',
31
+ 'small_repo' => 'repo has fewer files than --min-files; results may be noisy',
32
+ 'temporal_coupling_bulk_commits_skipped' => 'one or more bulk commits were skipped while computing temporal ' \
33
+ 'coupling',
34
+ 'zero_churn_majority' => 'most files have zero churn in the selected window; churn may be less informative'
35
+ }.freeze
36
+
37
+ INSUFFICIENT_DISPERSION_MESSAGE = lambda do |signal|
38
+ "every file has the same non-zero raw #{signal} value, so its percentile-ranked contribution collapsed to 0.0"
39
+ end
40
+ private_constant :INSUFFICIENT_DISPERSION_MESSAGE
41
+
42
+ module_function
43
+
44
+ def normalize(items)
45
+ Array(items).map { |item| normalize_one(item) }.uniq { |warning| warning.fetch(:code) }
46
+ end
47
+
48
+ def normalize_one(item)
49
+ code, explicit_message = warning_parts(item)
50
+ { code: code, message: message_for(code, explicit_message, item) }
51
+ end
52
+
53
+ def message_for(code, explicit_message = nil, item = nil)
54
+ return explicit_message.to_s unless explicit_message.to_s.empty?
55
+
56
+ if code.start_with?('insufficient_dispersion_')
57
+ signal = code.delete_prefix('insufficient_dispersion_')
58
+ return INSUFFICIENT_DISPERSION_MESSAGE.call(signal)
59
+ end
60
+
61
+ if code == 'temporal_coupling_bulk_commits_skipped' && item.respond_to?(:fetch)
62
+ count = item.fetch(:count, nil) || item.fetch('count', nil)
63
+ max = item.fetch(:max_commit_files, nil) || item.fetch('max_commit_files', nil)
64
+ return "#{MESSAGES.fetch(code)} (#{count} skipped; max_commit_files=#{max})" if count && max
65
+ end
66
+
67
+ MESSAGES.fetch(code) { code.tr('_', ' ') }
68
+ end
69
+
70
+ def warning_parts(item)
71
+ if item.respond_to?(:fetch) && (item.key?(:code) || item.key?('code'))
72
+ code = item.fetch(:code, nil) || item.fetch('code')
73
+ message = item.fetch(:message, nil) || item.fetch('message', nil)
74
+ [code.to_s, message]
75
+ else
76
+ [item.to_s, nil]
77
+ end
78
+ end
79
+ private_class_method :warning_parts
80
+ end
81
+ end
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.5.1
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-13 00:00:00.000000000 Z
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
@@ -171,6 +172,7 @@ files:
171
172
  - lib/stud_finder/scorer.rb
172
173
  - lib/stud_finder/temporal_coupling.rb
173
174
  - lib/stud_finder/version.rb
175
+ - lib/stud_finder/warnings.rb
174
176
  homepage: https://github.com/bazfer/stud-finder
175
177
  licenses:
176
178
  - MIT