rubocop-metz 0.5.2

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.

Potentially problematic release.


This version of rubocop-metz might be problematic. Click here for more details.

Files changed (98) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/lib/metz_scan/analyzers/contextual_node_walker.rb +82 -0
  4. data/lib/metz_scan/analyzers/cross_package_reference_metadata.rb +36 -0
  5. data/lib/metz_scan/analyzers/cross_package_reference_set.rb +33 -0
  6. data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_collector.rb +54 -0
  7. data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_reference.rb +37 -0
  8. data/lib/metz_scan/analyzers/implicit_context_pressure/current_attribute_collector.rb +73 -0
  9. data/lib/metz_scan/analyzers/implicit_context_pressure/reference_set.rb +37 -0
  10. data/lib/metz_scan/analyzers/implicit_context_pressure/thread_current_collector.rb +76 -0
  11. data/lib/metz_scan/analyzers/implicit_context_pressure/triage.rb +109 -0
  12. data/lib/metz_scan/analyzers/implicit_context_pressure.rb +139 -0
  13. data/lib/metz_scan/analyzers/inheritance_descendants/finding.rb +18 -0
  14. data/lib/metz_scan/analyzers/inheritance_descendants/metadata.rb +23 -0
  15. data/lib/metz_scan/analyzers/inheritance_descendants/root_kind.rb +58 -0
  16. data/lib/metz_scan/analyzers/inheritance_descendants/triage.rb +30 -0
  17. data/lib/metz_scan/analyzers/inheritance_descendants.rb +137 -0
  18. data/lib/metz_scan/analyzers/namespace_leak_pressure/finding.rb +20 -0
  19. data/lib/metz_scan/analyzers/namespace_leak_pressure/metadata.rb +40 -0
  20. data/lib/metz_scan/analyzers/namespace_leak_pressure/namespace.rb +41 -0
  21. data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_collector.rb +50 -0
  22. data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_set.rb +12 -0
  23. data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage/exception_declaration.rb +117 -0
  24. data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage.rb +69 -0
  25. data/lib/metz_scan/analyzers/namespace_leak_pressure.rb +139 -0
  26. data/lib/metz_scan/analyzers/occurrence.rb +28 -0
  27. data/lib/metz_scan/analyzers/package_dependency_pressure/finding.rb +20 -0
  28. data/lib/metz_scan/analyzers/package_dependency_pressure/metadata.rb +27 -0
  29. data/lib/metz_scan/analyzers/package_dependency_pressure/package_map.rb +11 -0
  30. data/lib/metz_scan/analyzers/package_dependency_pressure/reference_collector.rb +38 -0
  31. data/lib/metz_scan/analyzers/package_dependency_pressure/reference_set.rb +12 -0
  32. data/lib/metz_scan/analyzers/package_dependency_pressure/shared_dependency_triage.rb +124 -0
  33. data/lib/metz_scan/analyzers/package_dependency_pressure.rb +139 -0
  34. data/lib/metz_scan/analyzers/package_map.rb +81 -0
  35. data/lib/metz_scan/analyzers/project_analyzer_triage.rb +20 -0
  36. data/lib/metz_scan/analyzers/repeated_branching/branch_site_collector.rb +138 -0
  37. data/lib/metz_scan/analyzers/repeated_branching/branch_value.rb +27 -0
  38. data/lib/metz_scan/analyzers/repeated_branching/contextual_node_walker.rb +11 -0
  39. data/lib/metz_scan/analyzers/repeated_branching/decision_subject.rb +46 -0
  40. data/lib/metz_scan/analyzers/repeated_branching/predicate_chain.rb +58 -0
  41. data/lib/metz_scan/analyzers/repeated_branching/triage.rb +28 -0
  42. data/lib/metz_scan/analyzers/repeated_branching.rb +135 -0
  43. data/lib/metz_scan/analyzers/repeated_query_criteria/triage.rb +103 -0
  44. data/lib/metz_scan/analyzers/repeated_query_criteria.rb +285 -0
  45. data/lib/metz_scan/analyzers/ruby_file_enumerator.rb +37 -0
  46. data/lib/metz_scan/analyzers/service_soup/service_call_pattern.rb +53 -0
  47. data/lib/metz_scan/analyzers/service_soup/setup_workflow_triage.rb +48 -0
  48. data/lib/metz_scan/analyzers/service_soup/workflow_collector.rb +130 -0
  49. data/lib/metz_scan/analyzers/service_soup.rb +128 -0
  50. data/lib/metz_scan/analyzers/subclass_override_pressure/family_builder.rb +69 -0
  51. data/lib/metz_scan/analyzers/subclass_override_pressure/finding.rb +30 -0
  52. data/lib/metz_scan/analyzers/subclass_override_pressure/metadata.rb +74 -0
  53. data/lib/metz_scan/analyzers/subclass_override_pressure/method_body_facts.rb +100 -0
  54. data/lib/metz_scan/analyzers/subclass_override_pressure/triage.rb +106 -0
  55. data/lib/metz_scan/analyzers/subclass_override_pressure.rb +133 -0
  56. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/artifact_writer.rb +51 -0
  57. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/baseline_delta.rb +231 -0
  58. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/collaborators.rb +76 -0
  59. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/markdown_renderer.rb +353 -0
  60. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/readiness_catalog.rb +123 -0
  61. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/summary.rb +333 -0
  62. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/target_set.rb +139 -0
  63. data/lib/metz_scan/calibration/project_analyzer_evidence_runner.rb +64 -0
  64. data/lib/metz_scan/calibration/rubydex_drift_formatter.rb +91 -0
  65. data/lib/metz_scan/cli.rb +120 -0
  66. data/lib/metz_scan/commands/explain.rb +128 -0
  67. data/lib/metz_scan/commands/project_analyzers.rb +105 -0
  68. data/lib/metz_scan/commands/report.rb +115 -0
  69. data/lib/metz_scan/commands/rules.rb +92 -0
  70. data/lib/metz_scan/commands/scan/auto_fix.rb +138 -0
  71. data/lib/metz_scan/commands/scan/compliance_scorecard.rb +124 -0
  72. data/lib/metz_scan/commands/scan/github_annotations_renderer.rb +55 -0
  73. data/lib/metz_scan/commands/scan/offense_extractor.rb +42 -0
  74. data/lib/metz_scan/commands/scan/project_analyzer_breakdown.rb +55 -0
  75. data/lib/metz_scan/commands/scan/project_analyzer_metadata.rb +78 -0
  76. data/lib/metz_scan/commands/scan/project_analyzer_offenses.rb +85 -0
  77. data/lib/metz_scan/commands/scan/project_analyzer_runner.rb +148 -0
  78. data/lib/metz_scan/commands/scan/project_analyzer_summary_aggregate_formatter.rb +74 -0
  79. data/lib/metz_scan/commands/scan/project_analyzer_summary_breakdown_formatter.rb +46 -0
  80. data/lib/metz_scan/commands/scan/project_analyzer_triage_formatter.rb +40 -0
  81. data/lib/metz_scan/commands/scan/project_analyzer_triage_priority.rb +38 -0
  82. data/lib/metz_scan/commands/scan/project_config_scope.rb +231 -0
  83. data/lib/metz_scan/commands/scan/runner.rb +199 -0
  84. data/lib/metz_scan/commands/scan/sarif_renderer.rb +103 -0
  85. data/lib/metz_scan/commands/scan/sarif_rule_descriptors.rb +82 -0
  86. data/lib/metz_scan/commands/scan/sarif_severity.rb +27 -0
  87. data/lib/metz_scan/commands/scan/target_ruby_version.rb +56 -0
  88. data/lib/metz_scan/commands/scan/text_renderer.rb +145 -0
  89. data/lib/metz_scan/commands/scan.rb +201 -0
  90. data/lib/metz_scan/project_index/null_backend.rb +35 -0
  91. data/lib/metz_scan/project_index/rubydex_backend/file_discovery.rb +33 -0
  92. data/lib/metz_scan/project_index/rubydex_backend/location_formatting.rb +36 -0
  93. data/lib/metz_scan/project_index/rubydex_backend/method_declarations.rb +118 -0
  94. data/lib/metz_scan/project_index/rubydex_backend.rb +133 -0
  95. data/lib/metz_scan/project_index.rb +109 -0
  96. data/lib/metz_scan/version.rb +5 -0
  97. data/lib/metz_scan.rb +5 -0
  98. metadata +185 -0
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../commands/scan/project_analyzer_runner"
4
+
5
+ module MetzScan
6
+ module Calibration
7
+ module ProjectAnalyzerEvidenceRunner
8
+ class ReadinessCatalog
9
+ NOTES = {
10
+ "MetzProject/ServiceSoup" => {
11
+ "evidence" => "Medium design-pressure findings are calibrated; setup orchestration is downranked.",
12
+ "next" => "Monitor future calibration for setup noise or new service-call shapes.",
13
+ "not_next" => "Do not reopen promotion unless default-output evidence regresses."
14
+ },
15
+ "MetzProject/RepeatedBranching" => {
16
+ "evidence" => "Repeated branch tables are calibrated; generic subjects are lower-confidence.",
17
+ "next" => "Monitor generic-subject calibration and branch-subject readability.",
18
+ "not_next" => "Do not expand branch parsing without sparse fixture-backed evidence."
19
+ },
20
+ "MetzProject/DeepInheritanceTree" => {
21
+ "evidence" => "Useful inheritance spread evidence remains dependent on the optional project index.",
22
+ "next" => "Use opt-in calibration to watch grouped output and broad-root downranking.",
23
+ "not_next" => "Do not make default-output eligible while findings depend on optional indexing."
24
+ },
25
+ "MetzProject/PackageDependencyPressure" => {
26
+ "evidence" => "Expanded active fixtures show 47 findings: 5 medium package boundaries and " \
27
+ "42 low shared dependencies; consolidated medium review splits into " \
28
+ "3 useful prompts and 2 needs-context framework or extension surfaces.",
29
+ "next" => "Keep parked unless future generic work can separate cross-cutting logging pressure " \
30
+ "from framework roots and public extension registries.",
31
+ "not_next" => "Do not implement classifier behavior, promote, retune thresholds, downrank " \
32
+ "framework-root prompts, or add app-specific suppressions from this sample."
33
+ },
34
+ "MetzProject/NamespaceLeakPressure" => {
35
+ "evidence" => "Expanded active fixtures show 49 findings: 12 medium namespace-boundary prompts " \
36
+ "and 37 low shared namespaces; consolidated medium review mixes useful domain " \
37
+ "and security prompts with public extension or facade surfaces.",
38
+ "next" => "Keep parked unless future generic work separates namespace-boundary prompts from " \
39
+ "public extension, facade, payment, reporting, and renderer APIs.",
40
+ "not_next" => "Do not implement classifier behavior, promote, add app-specific suppressions, " \
41
+ "downrank payment namespaces, or retune thresholds from this narrow sample."
42
+ },
43
+ "MetzProject/ImplicitContextPressure" => {
44
+ "evidence" => "Expanded active fixtures show 12 findings dominated by mechanical framework state: " \
45
+ "9 mechanical, 1 useful execution-identity prompt, and 2 needs-context identity prompts.",
46
+ "next" => "Keep candidate-only and use broader samples to separate boundary setup from ambient identity " \
47
+ "design pressure.",
48
+ "not_next" => "Do not add suppressions, global-access forms, promotion, or default-output eligibility " \
49
+ "from this sample."
50
+ },
51
+ "MetzProject/RepeatedQueryCriteria" => {
52
+ "evidence" => "Expanded active fixtures show 16 findings: 13 useful prompts and 3 mechanical lookups.",
53
+ "next" => "Keep separating membership-table lookups from business-named and lifecycle lookup concepts.",
54
+ "not_next" => "Do not add more query forms, dynamic receivers, association receivers, SQL strings, " \
55
+ "joins, merges, Arel, single-key finders, bang finders, exists?, or take."
56
+ },
57
+ "MetzProject/SubclassOverridePressure" => {
58
+ "evidence" => "Expanded active fixtures show 148 findings: 93 low broad-root and " \
59
+ "55 medium manual-review findings across abstract hook, cooperative, and " \
60
+ "replacement override families.",
61
+ "next" => "Keep parked unless future generic work separates design-pressure hook contracts " \
62
+ "from deliberate extension protocols and setup conventions.",
63
+ "not_next" => "Do not implement classifier behavior, promote, make default-output eligible, " \
64
+ "suppress medium categories, or retune thresholds."
65
+ }
66
+ }.freeze
67
+
68
+ def initialize(analyzer_names: [], analyzers: Commands::Scan::ProjectAnalyzerRunner::ANALYZERS)
69
+ @analyzer_names = Array(analyzer_names)
70
+ @analyzers = analyzers
71
+ end
72
+
73
+ def to_a
74
+ selected_analyzers.filter_map { |analyzer| readiness_entry(analyzer) }
75
+ end
76
+
77
+ private
78
+
79
+ attr_reader :analyzer_names, :analyzers
80
+
81
+ def selected_analyzers
82
+ return analyzers if analyzer_names.empty?
83
+
84
+ analyzers.select { |analyzer| analyzer_names.include?(rule_id_for(analyzer)) }
85
+ end
86
+
87
+ def readiness_entry(analyzer)
88
+ note = NOTES[rule_id_for(analyzer)]
89
+ return unless note
90
+
91
+ analyzer_fields(analyzer).merge(note)
92
+ end
93
+
94
+ def analyzer_fields(analyzer)
95
+ status = analyzer::PROJECT_ANALYZER_STATUS
96
+ default_output = Commands::Scan::ProjectAnalyzerRunner.default_output_analyzer?(analyzer)
97
+
98
+ { "rule_id" => rule_id_for(analyzer), "status" => status, "default_output" => default_output,
99
+ "disposition" => disposition_for(status, default_output) }
100
+ end
101
+
102
+ def rule_id_for(analyzer)
103
+ analyzer::RULE_ID
104
+ end
105
+
106
+ def disposition_for(status, default_output)
107
+ "#{status_label(status)}; #{default_output_label(default_output)}."
108
+ end
109
+
110
+ def status_label(status)
111
+ return "Candidate-only" if status == "candidate"
112
+ return "Validated opt-in" if status == "validated"
113
+
114
+ status.to_s
115
+ end
116
+
117
+ def default_output_label(default_output)
118
+ default_output ? "default-output eligible" : "not default-output eligible"
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "time"
5
+
6
+ require_relative "../../project_index"
7
+ require_relative "../../commands/scan/project_analyzer_metadata"
8
+ require_relative "../../commands/scan/project_analyzer_offenses"
9
+ require_relative "../../commands/scan/project_analyzer_runner"
10
+ require_relative "readiness_catalog"
11
+
12
+ module MetzScan
13
+ module Calibration
14
+ module ProjectAnalyzerEvidenceRunner
15
+ class Summary
16
+ def initialize(options)
17
+ @options = options
18
+ end
19
+
20
+ def to_h
21
+ target_runs = targets.map { |target| target_run_for(target).call }
22
+ SummaryPayload.new(summary_payload_options(target_runs)).to_h
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :options
28
+
29
+ def target_run_for(target)
30
+ TargetRun.new(target_run_options(target))
31
+ end
32
+
33
+ def target_run_options(target)
34
+ options.slice(:target_set, :default_output, :index_builder, :finding_runner).merge(target: target)
35
+ end
36
+
37
+ def summary_payload_options(target_runs)
38
+ { target_runs: target_runs, default_output: default_output, fixture_root: fixture_root,
39
+ analyzer_names: analyzer_names, targets_file: targets_file, baseline_file: baseline_file }
40
+ end
41
+
42
+ def targets = options.fetch(:targets)
43
+
44
+ def default_output = options.fetch(:default_output)
45
+
46
+ def fixture_root = options.fetch(:fixture_root)
47
+
48
+ def analyzer_names = options.fetch(:analyzer_names)
49
+
50
+ def targets_file = options.fetch(:targets_file)
51
+
52
+ def baseline_file = options[:baseline_file]
53
+ end
54
+
55
+ class TargetRun
56
+ def initialize(options)
57
+ @options = options
58
+ end
59
+
60
+ attr_reader :findings, :offenses
61
+
62
+ def call
63
+ @findings = project_findings
64
+ @offenses = Commands::Scan::ProjectAnalyzerOffenses.build(findings).offenses
65
+ self
66
+ end
67
+
68
+ def metadata
69
+ identity_metadata.merge(result_metadata)
70
+ end
71
+
72
+ private
73
+
74
+ attr_reader :options
75
+
76
+ def target = options.fetch(:target)
77
+
78
+ def target_set = options.fetch(:target_set)
79
+
80
+ def default_output = options.fetch(:default_output)
81
+
82
+ def finding_runner = options.fetch(:finding_runner)
83
+
84
+ def scan_paths
85
+ @scan_paths ||= target_set.scan_paths_for(target)
86
+ end
87
+
88
+ def index
89
+ @index ||= index_for(options.fetch(:index_builder))
90
+ end
91
+
92
+ def index_for(index_builder)
93
+ return NoScanIndex.new if scan_paths.empty?
94
+
95
+ index_builder.call(scan_paths)
96
+ end
97
+
98
+ def identity_metadata
99
+ { "name" => File.basename(target), "root" => target, "scan_paths" => scan_paths,
100
+ "git" => GitMetadata.new(target).to_h, "index" => IndexMetadata.new(index).to_h }
101
+ end
102
+
103
+ def result_metadata
104
+ { "project_analyzers" => project_analyzer_summary, "finding_count" => findings.size,
105
+ "offense_count" => offenses.size, "breakdowns" => breakdowns,
106
+ "notable_findings" => notable_findings }
107
+ end
108
+
109
+ def project_findings
110
+ return [] if scan_paths.empty?
111
+
112
+ finding_runner.call(scan_paths, index: index, default_output: default_output)
113
+ end
114
+
115
+ def project_analyzer_summary
116
+ Commands::Scan::ProjectAnalyzerMetadata.summary(findings, offenses)
117
+ end
118
+
119
+ def breakdowns
120
+ Commands::Scan::ProjectAnalyzerBreakdown.new(findings).to_h
121
+ end
122
+
123
+ def notable_findings
124
+ NotableFindings.new(findings, target_name: File.basename(target)).to_a
125
+ end
126
+ end
127
+
128
+ class SummaryPayload
129
+ def initialize(options)
130
+ @options = options
131
+ end
132
+
133
+ def to_h
134
+ summary = identity_fields.merge(target_fields).merge(project_fields)
135
+ baseline_file ? summary.merge("baseline_delta" => baseline_delta_for(summary)) : summary
136
+ end
137
+
138
+ private
139
+
140
+ attr_reader :options
141
+
142
+ def target_runs = options.fetch(:target_runs)
143
+
144
+ def default_output = options.fetch(:default_output)
145
+
146
+ def fixture_root = options.fetch(:fixture_root)
147
+
148
+ def analyzer_names = options.fetch(:analyzer_names)
149
+
150
+ def targets_file = options.fetch(:targets_file)
151
+
152
+ def baseline_file = options[:baseline_file]
153
+
154
+ def identity_fields
155
+ { "generated_at" => Time.now.utc.iso8601, "default_output" => default_output,
156
+ "fixture_root" => fixture_root, "analyzer_filter" => analyzer_names,
157
+ "targets_file" => targets_file }.compact
158
+ end
159
+
160
+ def target_fields
161
+ { "targets" => target_runs.map(&:metadata) }
162
+ end
163
+
164
+ def project_fields
165
+ { "project_analyzers" => project_analyzer_summary_with_readiness, "finding_count" => findings.size,
166
+ "offense_count" => offenses.size, "breakdowns" => breakdowns,
167
+ "notable_findings" => notable_findings }
168
+ end
169
+
170
+ def findings = target_runs.flat_map(&:findings)
171
+
172
+ def offenses = target_runs.flat_map(&:offenses)
173
+
174
+ def notable_findings = target_runs.flat_map { |target_run| target_run.metadata.fetch("notable_findings") }
175
+
176
+ def project_analyzer_summary
177
+ Commands::Scan::ProjectAnalyzerMetadata.summary(findings, offenses)
178
+ end
179
+
180
+ def project_analyzer_summary_with_readiness
181
+ project_analyzer_summary.merge("readiness" => readiness_entries)
182
+ end
183
+
184
+ def readiness_entries
185
+ ReadinessCatalog.new(analyzer_names: analyzer_names).to_a
186
+ end
187
+
188
+ def breakdowns
189
+ Commands::Scan::ProjectAnalyzerBreakdown.new(findings).to_h
190
+ end
191
+
192
+ def baseline_delta_for(summary)
193
+ BaselineDelta.new(summary: summary, baseline_file: baseline_file).to_h
194
+ end
195
+ end
196
+
197
+ class NotableFindings
198
+ INCLUDED_CONFIDENCES = %w[high medium].freeze
199
+ LIMIT = 20
200
+ private_constant :INCLUDED_CONFIDENCES, :LIMIT
201
+
202
+ def initialize(findings, target_name:)
203
+ @findings = findings
204
+ @target_name = target_name
205
+ end
206
+
207
+ def to_a
208
+ findings.select { |finding| notable?(finding) }
209
+ .sort_by { |finding| sort_key(finding) }
210
+ .first(LIMIT)
211
+ .map { |finding| finding_metadata(finding) }
212
+ end
213
+
214
+ private
215
+
216
+ attr_reader :findings, :target_name
217
+
218
+ def notable?(finding)
219
+ INCLUDED_CONFIDENCES.include?(triage_metadata(finding)["confidence"])
220
+ end
221
+
222
+ def sort_key(finding)
223
+ Commands::Scan::ProjectAnalyzerTriagePriority.sort_key(triage_metadata(finding)) +
224
+ [finding.rule_id.to_s, finding.message.to_s]
225
+ end
226
+
227
+ def finding_metadata(finding)
228
+ triage_metadata(finding).merge(identity_metadata(finding), detail_metadata(finding)).compact
229
+ end
230
+
231
+ def identity_metadata(finding)
232
+ { "target" => target_name, "rule_id" => finding.rule_id, "message" => finding.message }
233
+ end
234
+
235
+ def detail_metadata(finding)
236
+ { "category" => category_for(finding), "metadata" => analyzer_metadata(finding),
237
+ "occurrence" => occurrence_metadata(finding) }
238
+ end
239
+
240
+ def triage_metadata(finding)
241
+ Commands::Scan::ProjectAnalyzerMetadata.triage_metadata(finding)
242
+ end
243
+
244
+ def analyzer_metadata(finding)
245
+ Commands::Scan::ProjectAnalyzerMetadata.analyzer_metadata(finding)
246
+ end
247
+
248
+ def category_for(finding)
249
+ Commands::Scan::ProjectAnalyzerMetadata.category_for(finding)
250
+ end
251
+
252
+ def occurrence_metadata(finding)
253
+ occurrence = Array(finding.report_occurrences).compact.first
254
+ return unless occurrence
255
+
256
+ { "path" => occurrence.path, "line" => occurrence_line(occurrence),
257
+ "context" => occurrence_context(occurrence) }.compact
258
+ end
259
+
260
+ def occurrence_line(occurrence)
261
+ return occurrence.report_line if occurrence.respond_to?(:report_line)
262
+ return occurrence.line if occurrence.respond_to?(:line)
263
+
264
+ nil
265
+ end
266
+
267
+ def occurrence_context(occurrence)
268
+ occurrence.context if occurrence.respond_to?(:context)
269
+ end
270
+ end
271
+
272
+ class NoScanIndex
273
+ def backend_name = :none
274
+
275
+ def available? = false
276
+
277
+ def reason = "no top-level app/ or lib/ scan paths under calibration target"
278
+
279
+ def indexed_files = []
280
+
281
+ def index_errors = []
282
+
283
+ def diagnostics = []
284
+ end
285
+
286
+ class IndexMetadata
287
+ def initialize(index)
288
+ @index = index
289
+ end
290
+
291
+ def to_h
292
+ availability_fields.merge(count_fields).compact
293
+ end
294
+
295
+ private
296
+
297
+ attr_reader :index
298
+
299
+ def availability_fields
300
+ { "backend" => index.backend_name.to_s, "available" => index.available?,
301
+ "reason" => index.reason }
302
+ end
303
+
304
+ def count_fields
305
+ { "indexed_file_count" => index.indexed_files.size, "index_error_count" => index.index_errors.size,
306
+ "diagnostic_count" => index.diagnostics.size }
307
+ end
308
+ end
309
+
310
+ class GitMetadata
311
+ def initialize(target)
312
+ @target = target
313
+ end
314
+
315
+ def to_h
316
+ { "branch" => git_capture("branch", "--show-current"),
317
+ "revision" => git_capture("rev-parse", "HEAD") }.compact
318
+ end
319
+
320
+ private
321
+
322
+ attr_reader :target
323
+
324
+ def git_capture(*)
325
+ stdout, _stderr, status = Open3.capture3("git", "-C", target, *)
326
+ return unless status.success?
327
+
328
+ stdout.strip.then { |value| value unless value.empty? }
329
+ end
330
+ end
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module MetzScan
6
+ module Calibration
7
+ module ProjectAnalyzerEvidenceRunner
8
+ class TargetSet
9
+ DEFAULT_SCAN_CHILDREN = %w[app lib].freeze
10
+
11
+ def initialize(paths:, default_apps_path:, targets_file: nil)
12
+ @paths = Array(paths)
13
+ @default_apps_path = default_apps_path
14
+ @targets_file = targets_file && File.expand_path(targets_file)
15
+ reject_mixed_target_inputs!
16
+ end
17
+
18
+ attr_reader :targets_file
19
+
20
+ def paths = raw_targets.map { |path| File.expand_path(path) }.uniq.sort
21
+
22
+ def ensure_present!
23
+ missing = paths.reject { |path| File.exist?(path) }
24
+ raise Error, "calibration target missing: #{missing.first}" unless missing.empty?
25
+
26
+ missing_scan_path = manifest_scan_paths.flatten.find { |path| !File.directory?(path) }
27
+ raise Error, "calibration scan path missing: #{missing_scan_path}" if missing_scan_path
28
+ end
29
+
30
+ def scan_paths_for(target)
31
+ manifest_paths = manifest_scan_paths_by_target[File.expand_path(target)]
32
+ return manifest_paths if manifest_paths
33
+
34
+ scan_paths = scan_children_for(target)
35
+ scan_paths = [target] if scan_paths.empty? && explicit_targets?
36
+ scan_paths.map { |path| File.expand_path(path) }
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :default_apps_path
42
+
43
+ def explicit_targets?
44
+ !@paths.empty?
45
+ end
46
+
47
+ def raw_targets
48
+ return manifest_target_roots if targets_file
49
+
50
+ @paths.empty? ? discovered_default_targets : @paths
51
+ end
52
+
53
+ def discovered_default_targets
54
+ return [default_apps_path] unless File.directory?(default_apps_path)
55
+
56
+ default_target_children.empty? ? [default_apps_path] : default_target_children
57
+ end
58
+
59
+ def default_target_children
60
+ Dir.children(default_apps_path)
61
+ .map { |child| File.join(default_apps_path, child) }
62
+ .select { |path| File.directory?(path) }
63
+ end
64
+
65
+ def scan_children_for(target)
66
+ DEFAULT_SCAN_CHILDREN.map { |child| File.join(target, child) }
67
+ .select { |path| File.directory?(path) }
68
+ end
69
+
70
+ def reject_mixed_target_inputs!
71
+ return unless targets_file && explicit_targets?
72
+
73
+ raise Error, "cannot combine --targets-file with explicit PATH targets"
74
+ end
75
+
76
+ def manifest_target_roots
77
+ manifest_entries.map { |entry| entry.fetch("root") }
78
+ end
79
+
80
+ def manifest_scan_paths_by_target
81
+ return {} unless targets_file
82
+
83
+ @manifest_scan_paths_by_target ||= manifest_entries.to_h do |entry|
84
+ root = File.expand_path(entry.fetch("root"))
85
+ [root, Array(entry.fetch("scan_paths")).map { |path| File.expand_path(path, root) }]
86
+ end
87
+ end
88
+
89
+ def manifest_scan_paths
90
+ return [] unless targets_file
91
+
92
+ manifest_scan_paths_by_target.values
93
+ end
94
+
95
+ def manifest_entries
96
+ @manifest_entries ||= parse_manifest_entries
97
+ end
98
+
99
+ def parse_manifest_entries
100
+ target_entries.map { |entry| normalize_manifest_entry(entry) }
101
+ rescue Psych::Exception => e
102
+ raise Error, "invalid calibration targets file: #{e.message}"
103
+ end
104
+
105
+ def target_entries
106
+ manifest_document.fetch("targets").tap do |entries|
107
+ raise Error, "calibration targets file must contain a targets list" unless entries.is_a?(Array)
108
+ end
109
+ rescue KeyError
110
+ raise Error, "calibration targets file must contain a targets list"
111
+ end
112
+
113
+ def manifest_document
114
+ raise Error, "calibration targets file missing: #{targets_file}" unless File.file?(targets_file)
115
+
116
+ YAML.safe_load_file(targets_file, aliases: false) || {}
117
+ end
118
+
119
+ def normalize_manifest_entry(entry)
120
+ raise Error, "calibration target entry must be a mapping" unless entry.is_a?(Hash)
121
+
122
+ { "root" => manifest_entry_root(entry), "scan_paths" => manifest_entry_scan_paths(entry) }
123
+ end
124
+
125
+ def manifest_entry_root(entry)
126
+ entry.fetch("root") { raise Error, "calibration target entry missing root" }.to_s
127
+ end
128
+
129
+ def manifest_entry_scan_paths(entry)
130
+ scan_paths = entry.fetch("scan_paths") { raise Error, "calibration target entry missing scan_paths" }
131
+ raise Error, "calibration target scan_paths must be a list" unless scan_paths.is_a?(Array)
132
+ raise Error, "calibration target scan_paths must not be empty" if scan_paths.empty?
133
+
134
+ scan_paths.map(&:to_s)
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "project_analyzer_evidence_runner/artifact_writer"
4
+ require_relative "project_analyzer_evidence_runner/baseline_delta"
5
+ require_relative "project_analyzer_evidence_runner/collaborators"
6
+ require_relative "project_analyzer_evidence_runner/markdown_renderer"
7
+ require_relative "project_analyzer_evidence_runner/summary"
8
+ require_relative "project_analyzer_evidence_runner/target_set"
9
+
10
+ module MetzScan
11
+ module Calibration
12
+ module ProjectAnalyzerEvidenceRunner
13
+ class Error < StandardError; end
14
+
15
+ DEFAULT_APPS_PATH = File.join("tmp", "project-analyzer-calibration", "apps").freeze
16
+ DEFAULT_RESULTS_PATH = File.join("tmp", "project-analyzer-calibration", "results").freeze
17
+
18
+ def self.default_apps_path = File.expand_path(DEFAULT_APPS_PATH)
19
+
20
+ def self.default_results_path = File.expand_path(DEFAULT_RESULTS_PATH)
21
+
22
+ def self.summarize(**options)
23
+ options = summarize_options(options)
24
+ targets = target_set(options[:paths], options[:targets_file])
25
+ resolved_summary_options = summary_options(targets, options)
26
+ targets.ensure_present!
27
+ Summary.new(resolved_summary_options).to_h
28
+ end
29
+
30
+ def self.write_artifacts(summary, output_dir: default_results_path, run_id: nil)
31
+ ArtifactWriter.new(summary: summary, output_dir: output_dir, run_id: run_id).call
32
+ end
33
+
34
+ def self.target_set(paths, targets_file)
35
+ TargetSet.new(paths: paths, default_apps_path: default_apps_path, targets_file: targets_file)
36
+ end
37
+
38
+ def self.summarize_options(options)
39
+ { paths: nil, default_output: false, analyzer_names: nil, targets_file: nil,
40
+ baseline_file: nil, index_builder: nil, finding_runner: nil }.merge(options)
41
+ end
42
+
43
+ def self.summary_options(targets, options)
44
+ analyzer_names = Array(options[:analyzer_names]).compact
45
+ analyzers = AnalyzerSelection.new(analyzer_names).call
46
+ base_summary_options(targets, options[:default_output], analyzer_names, options[:baseline_file])
47
+ .merge(collaborator_options(options, analyzers))
48
+ end
49
+
50
+ def self.collaborator_options(options, analyzers)
51
+ { index_builder: options[:index_builder] || ProjectIndex.method(:build),
52
+ finding_runner: options[:finding_runner] || FindingRunner.new(analyzers: analyzers) }
53
+ end
54
+
55
+ def self.base_summary_options(targets, default_output, analyzer_names, baseline_file)
56
+ { targets: targets.paths, target_set: targets,
57
+ default_output: default_output, fixture_root: default_apps_path,
58
+ analyzer_names: analyzer_names, targets_file: targets.targets_file, baseline_file: baseline_file }
59
+ end
60
+ private_class_method :target_set, :summary_options
61
+ private_class_method :base_summary_options
62
+ end
63
+ end
64
+ end