pinspec 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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +133 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +183 -0
  5. data/exe/pinspec +8 -0
  6. data/lib/pinspec/analyzer/app_profile_reader.rb +348 -0
  7. data/lib/pinspec/analyzer/factory_registry.rb +288 -0
  8. data/lib/pinspec/analyzer/inflector.rb +85 -0
  9. data/lib/pinspec/analyzer/schema_reader.rb +444 -0
  10. data/lib/pinspec/analyzer/source.rb +56 -0
  11. data/lib/pinspec/analyzer/target_parser.rb +710 -0
  12. data/lib/pinspec/cli.rb +585 -0
  13. data/lib/pinspec/emit/namer.rb +103 -0
  14. data/lib/pinspec/emit/spec_writer.rb +504 -0
  15. data/lib/pinspec/emit/stability_filter.rb +183 -0
  16. data/lib/pinspec/errors.rb +85 -0
  17. data/lib/pinspec/inputs/boundary.rb +112 -0
  18. data/lib/pinspec/inputs/corpus.rb +148 -0
  19. data/lib/pinspec/inputs/hydrator.rb +197 -0
  20. data/lib/pinspec/inputs/redactor.rb +138 -0
  21. data/lib/pinspec/inputs/sample_runner.rb +98 -0
  22. data/lib/pinspec/inputs/sampler.rb +187 -0
  23. data/lib/pinspec/report/summary.rb +348 -0
  24. data/lib/pinspec/runner/capture.rb +127 -0
  25. data/lib/pinspec/runner/probe_generator.rb +662 -0
  26. data/lib/pinspec/runner/sandbox.rb +121 -0
  27. data/lib/pinspec/setup/context_builder.rb +471 -0
  28. data/lib/pinspec/setup/dependency_resolver.rb +236 -0
  29. data/lib/pinspec/tags.rb +103 -0
  30. data/lib/pinspec/types.rb +497 -0
  31. data/lib/pinspec/validate/mutation_adapter.rb +108 -0
  32. data/lib/pinspec/validate/pin_scorer.rb +164 -0
  33. data/lib/pinspec/verify/verifier.rb +149 -0
  34. data/lib/pinspec/version.rb +8 -0
  35. data/lib/pinspec.rb +17 -0
  36. data/templates/factory_build.rb +54 -0
  37. data/templates/serializer.rb +243 -0
  38. data/templates/spec_support.rb +83 -0
  39. metadata +134 -0
@@ -0,0 +1,585 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Pinspec
6
+ class CLI < Thor
7
+ def self.exit_on_failure?
8
+ true
9
+ end
10
+
11
+ desc "version", "Print the pinspec version"
12
+ def version
13
+ puts "pinspec #{Pinspec::VERSION} " \
14
+ "(probe v#{Pinspec::PROBE_VERSION}, serializer v#{Pinspec::SERIALIZER_VERSION})"
15
+ end
16
+ map %w[--version -v] => :version
17
+
18
+ desc "plan FILE#METHOD", "Resolve a target and print the SetupPlan that would build its world"
19
+ method_option :app, type: :string, default: ".", desc: "target app root"
20
+ method_option :cases, type: :numeric, default: Inputs::Corpus::DEFAULT_MAX_CASES,
21
+ desc: "max input cases per method"
22
+ def plan(target)
23
+ guarded do
24
+ file, method = Analyzer::TargetParser.split_target(target)
25
+ target_profile = Analyzer::TargetParser.parse(file, method)
26
+
27
+ print_profile(target_profile)
28
+ puts
29
+
30
+ app_profile = Analyzer::AppProfileReader.read(options[:app])
31
+ setup_plan = Setup::ContextBuilder.build(target: target_profile, profile: app_profile)
32
+ corpus = Inputs::Corpus.build(
33
+ target: target_profile,
34
+ plan: setup_plan,
35
+ schema: app_profile.schema,
36
+ max_cases: options[:cases]
37
+ )
38
+
39
+ print_plan(setup_plan)
40
+ puts
41
+ print_corpus(corpus)
42
+ end
43
+ end
44
+
45
+ desc "analyze [APP_PATH]", "App profile: schema, factories, auth, tenancy, hazards"
46
+ def analyze(app_path = ".")
47
+ guarded do
48
+ profile = Analyzer::AppProfileReader.read(app_path)
49
+
50
+ print_app(profile)
51
+ puts
52
+ print_schema(profile.schema)
53
+ puts
54
+ print_factories(profile.factories)
55
+ print_warnings(profile)
56
+ end
57
+ end
58
+
59
+ desc "capture FILE#METHOD", "Run the probe, write observations.json"
60
+ method_option :app, type: :string, default: ".", desc: "target app root"
61
+ method_option :cases, type: :numeric, default: Inputs::Corpus::DEFAULT_MAX_CASES
62
+ method_option :boots, type: :numeric, default: 2,
63
+ desc: "probe boots; 2 is the default because one process shares warm caches"
64
+ method_option :"compare-sql", type: :boolean, default: false,
65
+ desc: "include SQL fingerprints in the stability decision"
66
+ method_option :"app-env", type: :array, default: [], banner: "KEY=VALUE",
67
+ desc: "environment for the app's own runtime (when it is not this shell's Ruby)"
68
+ method_option :sample, type: :boolean, default: false,
69
+ desc: "read real rows through a generated read-only script in the app"
70
+ method_option :"no-redact", type: :boolean, default: false,
71
+ desc: "do NOT rewrite personal data in sampled rows (they land in a committed file)"
72
+ def capture(target)
73
+ guarded do
74
+ file, method = Analyzer::TargetParser.split_target(target)
75
+
76
+ result = Runner::Capture.new(
77
+ app_root: options[:app],
78
+ target: file,
79
+ method: method,
80
+ max_cases: options[:cases],
81
+ boots: options[:boots],
82
+ compare_sql: options[:"compare-sql"],
83
+ sandbox_env: app_env,
84
+ sample: options[:sample],
85
+ redact: !options[:"no-redact"]
86
+ ).run
87
+
88
+ print_capture(result)
89
+
90
+ raise NothingStableToPin, nothing_stable_message(result.stability) if result.stability.nothing_to_pin?
91
+ end
92
+ end
93
+
94
+ desc "pin FILE#METHOD", "Plan + capture + emit + verify"
95
+ method_option :app, type: :string, default: ".", desc: "target app root"
96
+ method_option :cases, type: :numeric, default: Inputs::Corpus::DEFAULT_MAX_CASES
97
+ method_option :boots, type: :numeric, default: 2
98
+ method_option :"verify-level", type: :string, default: "full", enum: %w[full isolated]
99
+ method_option :"skip-verify", type: :boolean, default: false
100
+ method_option :force, type: :boolean, default: false,
101
+ desc: "overwrite a pin file that has been hand-edited"
102
+ method_option :snapshot, type: :string, default: "inline", enum: %w[inline insta approvals],
103
+ desc: "snapshot backend"
104
+ method_option :"app-env", type: :array, default: [], banner: "KEY=VALUE",
105
+ desc: "environment for the app's own runtime (when it is not this shell's Ruby)"
106
+ method_option :sample, type: :boolean, default: false,
107
+ desc: "read real rows through a generated read-only script in the app"
108
+ method_option :"no-redact", type: :boolean, default: false,
109
+ desc: "do NOT rewrite personal data in sampled rows (they land in a committed file)"
110
+ def pin(target)
111
+ guarded do
112
+ refuse_unbuilt_backend!
113
+ warn_about_redaction!
114
+ file, method = Analyzer::TargetParser.split_target(target)
115
+
116
+ capture = Runner::Capture.new(
117
+ app_root: options[:app], target: file, method: method,
118
+ max_cases: options[:cases], boots: options[:boots], sandbox_env: app_env,
119
+ sample: options[:sample], redact: !options[:"no-redact"]
120
+ ).run
121
+
122
+ print_capture(capture)
123
+ raise NothingStableToPin, nothing_stable_message(capture.stability) if capture.stability.nothing_to_pin?
124
+
125
+ written = Emit::SpecWriter.new(
126
+ app_root: options[:app], target: capture.target, plan: capture.plan,
127
+ corpus: capture.corpus, stability: capture.stability,
128
+ fk_map: Analyzer::AppProfileReader.read(options[:app]).schema.fk_map,
129
+ force: options[:force]
130
+ ).write!
131
+
132
+ puts
133
+ print_written(written)
134
+
135
+ return if options[:"skip-verify"]
136
+
137
+ outcomes = Verify::Verifier.new(
138
+ app_root: options[:app], spec_path: written.spec_path,
139
+ level: options[:"verify-level"].to_sym, env: app_env,
140
+ captured_tz: capture.plan.env_fingerprint[:tz]
141
+ ).verify
142
+
143
+ puts
144
+ print_verification(outcomes)
145
+
146
+ report_path = Report::Summary.new(
147
+ app_root: options[:app], profile: Analyzer::AppProfileReader.read(options[:app]),
148
+ target: capture.target, plan: capture.plan, corpus: capture.corpus,
149
+ stability: capture.stability, written: written, outcomes: outcomes
150
+ ).write!
151
+
152
+ puts
153
+ row "report", report_path
154
+
155
+ raise VerifyFailed, verify_failed_message(outcomes) unless outcomes.all?(&:green?)
156
+ end
157
+ end
158
+
159
+ desc "validate FILE#METHOD", "Mutation-score a pin, one aspect at a time"
160
+ method_option :app, type: :string, default: ".", desc: "target app root"
161
+ method_option :cases, type: :numeric, default: Inputs::Corpus::DEFAULT_MAX_CASES
162
+ method_option :"test-command", type: :string,
163
+ desc: "run the app's suite in its own runtime (for apps on Ruby < 3.4)"
164
+ method_option :"app-env", type: :array, default: [], banner: "KEY=VALUE",
165
+ desc: "environment for the app's own runtime"
166
+ def validate(target)
167
+ guarded do
168
+ file, method = Analyzer::TargetParser.split_target(target)
169
+
170
+ capture = Runner::Capture.new(
171
+ app_root: options[:app], target: file, method: method,
172
+ max_cases: options[:cases], sandbox_env: app_env
173
+ ).run
174
+
175
+ raise NothingStableToPin, nothing_stable_message(capture.stability) if capture.stability.nothing_to_pin?
176
+
177
+ profile = Analyzer::AppProfileReader.read(options[:app])
178
+
179
+ report = Validate::PinScorer.new(
180
+ app_root: options[:app], target: capture.target, plan: capture.plan,
181
+ corpus: capture.corpus, stability: capture.stability,
182
+ fk_map: profile.schema.fk_map,
183
+ env: app_env, test_command: options[:"test-command"]
184
+ ).run
185
+
186
+ print_scores(report)
187
+
188
+ summary = Report::Summary.new(
189
+ app_root: options[:app], profile: profile, target: capture.target,
190
+ plan: capture.plan, corpus: capture.corpus, stability: capture.stability,
191
+ scores: report
192
+ )
193
+
194
+ puts
195
+ puts " report #{summary.write!}"
196
+ end
197
+ end
198
+
199
+ desc "report", "Print the last run's markdown report"
200
+ method_option :app, type: :string, default: ".", desc: "target app root"
201
+ def report
202
+ guarded do
203
+ path = File.join(options[:app], Report::Summary::OUTPUT)
204
+
205
+ unless File.file?(path)
206
+ raise TargetNotFound,
207
+ "no report at #{path}. Run `pinspec pin` first (or `pinspec validate`, " \
208
+ "which adds the mutation scores); either writes one every time."
209
+ end
210
+
211
+ puts Analyzer::Source.read(path)
212
+ end
213
+ end
214
+
215
+ private
216
+
217
+ def app_env
218
+ Array(options[:"app-env"]).each_with_object({}) do |pair, out|
219
+ key, value = pair.split("=", 2)
220
+ out[key] = value.to_s
221
+ end
222
+ end
223
+
224
+ def refuse_unbuilt_backend!
225
+ backend = options[:snapshot]
226
+ return if backend.nil? || backend == "inline"
227
+
228
+ raise VerifyFailed,
229
+ "the #{backend} snapshot backend is not built yet; only `inline` is. " \
230
+ "Inline snapshots keep the pinned value in the spec file, where a reviewer " \
231
+ "can read it - which is why it is the default. Re-run without --snapshot."
232
+ end
233
+
234
+ def warn_about_redaction!
235
+ return unless options[:"no-redact"]
236
+
237
+ warn "pinspec: --no-redact means real personal data will be written into a spec " \
238
+ "file that gets committed. Every sampled value is reproduced verbatim."
239
+ end
240
+
241
+ def guarded
242
+ yield
243
+ rescue Pinspec::Error => e
244
+ warn "pinspec: #{e.message}"
245
+ warn " reason: #{e.reason}" if e.respond_to?(:reason)
246
+ exit e.exit_code
247
+ end
248
+
249
+ def print_schema(graph)
250
+ heuristic = graph.foreign_keys.select(&:heuristic?)
251
+
252
+ puts "schema"
253
+ row "tables", graph.tables.size
254
+ row "columns", graph.tables.sum { |t| t.columns.size }
255
+ row "foreign keys", foreign_key_summary(graph, heuristic)
256
+ row "hazards", graph.skipped_statements.empty? ? "none" : graph.skipped_statements.size
257
+
258
+ unless heuristic.empty?
259
+ puts
260
+ puts " inferred from a column name (no constraint declares these):"
261
+ heuristic.each { |fk| puts " #{fk.key} -> #{fk.to_table}" }
262
+ end
263
+
264
+ return if graph.skipped_statements.empty?
265
+
266
+ puts
267
+ puts " hazards (relevance is decided once a plan exists, in M-05):"
268
+ graph.skipped_statements.each { |statement| puts " #{statement}" }
269
+ end
270
+
271
+ def foreign_key_summary(graph, heuristic)
272
+ return "none" if graph.foreign_keys.empty?
273
+ return graph.foreign_keys.size.to_s if heuristic.empty?
274
+
275
+ "#{graph.foreign_keys.size} (#{heuristic.size} inferred)"
276
+ end
277
+
278
+ def print_plan(plan)
279
+ puts "setup plan #{plan.plan_id} (generation #{plan.generation}, isolation #{plan.isolation})"
280
+
281
+ plan.steps.each_with_index do |step, index|
282
+ puts format(" %2d. %s", index + 1, step)
283
+ end
284
+
285
+ unless plan.bindings.empty?
286
+ puts
287
+ puts " parameter bindings:"
288
+ plan.bindings.each { |param, ref| puts " #{param} -> #{ref}" }
289
+ end
290
+
291
+ unless plan.notes.empty?
292
+ puts
293
+ puts " plan notes:"
294
+ plan.notes.each { |note| puts " #{note[:kind]}: #{note[:detail]}" }
295
+ end
296
+
297
+ end
298
+
299
+ def print_capture(result)
300
+ stability = result.stability
301
+
302
+ puts "capture #{result.target.qualified_name}"
303
+ row "plan", "#{result.plan.plan_id} (isolation #{result.plan.isolation})"
304
+ row "runs", "#{stability.runs} boots"
305
+ row "cases", result.corpus.size
306
+ row "stable", "#{stability.stable.size} of #{result.corpus.size}"
307
+ row "compared", stability.compared_fields.join(", ")
308
+ row "observations", result.output_path
309
+
310
+ unless stability.stable.empty?
311
+ puts
312
+ puts " stable, and therefore pinnable:"
313
+ stability.stable.each { |verdict| puts " #{verdict.case_id} #{summarize(verdict.observation)}" }
314
+ end
315
+
316
+ return if stability.unstable.empty?
317
+
318
+ puts
319
+ puts " unstable (each with the first field that differed):"
320
+ stability.unstable.each do |verdict|
321
+ puts " #{verdict.case_id} #{verdict.cause}"
322
+ verdict.diff.to_s.lines.each { |line| puts " #{line.chomp}" } unless verdict.diff.to_s.empty?
323
+ end
324
+ end
325
+
326
+ def summarize(observation)
327
+ case observation["status"]
328
+ when "raised" then "raised #{observation.dig('error', 'class')}"
329
+ when "returned"
330
+ parts = ["returned #{observation.dig('return_value', 't')}"]
331
+ jobs = observation["enqueued_jobs"].to_a.size
332
+ mail = observation["mail_deliveries"].to_a.size
333
+ parts << "#{jobs} job(s)" if jobs.positive?
334
+ parts << "#{mail} mail" if mail.positive?
335
+ parts.join(", ")
336
+ else observation["status"]
337
+ end
338
+ end
339
+
340
+ def nothing_stable_message(stability)
341
+ histogram = stability.causes.map { |cause, count| "#{count} #{cause}" }.join(", ")
342
+
343
+ "no case was stable across #{stability.runs} boots (#{histogram}). " \
344
+ "pinspec will not emit a spec it cannot stand behind."
345
+ end
346
+
347
+ def print_written(written)
348
+ puts "emitted #{written.spec_path}"
349
+ written.support_paths.each { |path| row "support", path }
350
+ row "pinned", written.pinned_cases.join(", ")
351
+ row "aspects", written.aspects.reject { |_, count| count.zero? }
352
+ .map { |aspect, count| "#{count} #{aspect}" }.join(", ")
353
+ end
354
+
355
+ def print_verification(outcomes)
356
+ puts "verify"
357
+
358
+ outcomes.each do |outcome|
359
+ row outcome.config.to_s, outcome.green? ? "green (#{outcome.examples} examples)" : "#{outcome.status} - #{outcome.diagnosis}"
360
+ next if outcome.green?
361
+
362
+ outcome.detail.to_s.lines.first(12).each { |line| puts " #{line.chomp}" }
363
+ end
364
+ end
365
+
366
+ def verify_failed_message(outcomes)
367
+ failed = outcomes.reject(&:green?)
368
+
369
+ "the emitted spec did not run green in #{failed.map { |o| o.config }.join(', ')} " \
370
+ "(#{failed.map { |o| o.diagnosis }.uniq.join(', ')}). The pin was written, but " \
371
+ "pinspec will not claim a spec is green when it is not."
372
+ end
373
+
374
+ def print_scores(report)
375
+ puts "mutation score #{report.subject}"
376
+ row "aspects scored", report.scores.map(&:aspect).join(", ")
377
+ row "strong", "#{report.strong_ratio}% of scored aspects"
378
+
379
+ puts
380
+ report.scores.each do |score|
381
+ if score.score.nil?
382
+ row score.aspect.to_s, "not scored - #{score.note.to_s.lines.first.to_s.strip}"
383
+ next
384
+ end
385
+
386
+ row score.aspect.to_s, "#{score.score}% #{score.verdict} " \
387
+ "(#{score.killed} killed, #{score.survived} survived)"
388
+ puts " caveat: #{score.note}" if score.note
389
+ score.survivors.first(3).each do |survivor|
390
+ puts " survived: #{survivor['operator']} at line #{survivor['line']} - #{survivor['token']}"
391
+ end
392
+ end
393
+
394
+ print_cross_aspect(report)
395
+
396
+ return if report.skipped.empty?
397
+
398
+ puts
399
+ puts " not asserted by this pin, so not scored: #{report.skipped.join(', ')}"
400
+ end
401
+
402
+ def print_cross_aspect(report)
403
+ return if report.scored.size < 2
404
+
405
+ gaps = report.surviving_all_aspects
406
+ covered = report.covered_by_another_aspect
407
+
408
+ puts
409
+ unless covered.empty?
410
+ puts " #{covered.size} mutant(s) survived one aspect but were killed by another,"
411
+ puts " which is the aspects dividing the work rather than a gap:"
412
+ covered.first(4).each { |m| puts " #{m['operator']} at line #{m['line']} - #{m['token']}" }
413
+ end
414
+
415
+ if gaps.empty?
416
+ puts " nothing survived every aspect: together, the pins cover this target."
417
+ else
418
+ puts " #{gaps.size} mutant(s) survived EVERY aspect - the real gap:"
419
+ gaps.each { |m| puts " #{m['operator']} at line #{m['line']} - #{m['token']}" }
420
+ end
421
+ end
422
+
423
+ def print_corpus(corpus)
424
+ puts "input cases #{corpus.size} (#{corpus.origins.map { |o, n| "#{n} #{o}" }.join(', ')})"
425
+
426
+ corpus.cases.each { |input_case| puts " #{input_case}" }
427
+
428
+ puts
429
+ puts "Sampled rows and import clusters need a database. `plan` never opens one;"
430
+ puts "pass --sample to `capture` or `pin` to read real rows through a generated"
431
+ puts "read-only script in the app's own runtime."
432
+ end
433
+
434
+ def print_app(profile)
435
+ stack = profile.test_stack
436
+
437
+ puts "app"
438
+ row "rails", profile.rails_version || "unknown (no Gemfile.lock)"
439
+ row "ruby", profile.ruby_version || "unknown"
440
+ row "isolation", "#{profile.isolation} (#{profile.isolation_source})"
441
+ row "locale / zone", "#{profile.default_locale.inspect} / #{profile.default_zone.inspect}"
442
+ row "auth / authz", "#{profile.auth} / #{profile.authz}"
443
+ row "tenancy", profile.tenancy
444
+ row "soft delete", profile.soft_delete
445
+ row "versioning", profile.versioning
446
+ row "feature flags", profile.flags
447
+ row "attachments", profile.attachments.empty? ? "none" : profile.attachments.join(", ")
448
+ row "multi-database", profile.multi_db
449
+ row "test stack", test_stack_summary(stack)
450
+ row "queue adapter", profile.queue_adapter_in_tests&.inspect || "unset (Rails default)"
451
+
452
+ print_model_findings(profile)
453
+ print_notes(profile)
454
+ end
455
+
456
+ def test_stack_summary(stack)
457
+ parts = [stack.framework.to_s]
458
+ parts << "webmock" if stack.webmock
459
+ parts << "vcr" if stack.vcr
460
+ parts << "database_cleaner" if stack.database_cleaner_gem
461
+ parts.concat(stack.snapshot_backends.map(&:to_s))
462
+ parts.join(" + ")
463
+ end
464
+
465
+ def print_model_findings(profile)
466
+ return if profile.model_findings.empty?
467
+
468
+ puts
469
+ puts " model hazards:"
470
+ profile.model_findings.group_by(&:kind).each do |kind, findings|
471
+ puts " #{kind}: #{findings.map { |f| "#{f.model} (#{f.file}:#{f.line})" }.join(', ')}"
472
+ end
473
+ end
474
+
475
+ def print_notes(profile)
476
+ return if profile.notes.empty?
477
+
478
+ puts
479
+ puts " could not read:"
480
+ profile.notes.each { |note| puts " #{note[:kind]}: #{note[:detail]}" }
481
+ end
482
+
483
+ def print_warnings(profile)
484
+ warnings = profile.warnings
485
+ return if warnings.empty?
486
+
487
+ puts
488
+ puts "warnings"
489
+ warnings.each_with_index do |warning, index|
490
+ puts " #{index + 1}. #{wrap(warning)}"
491
+ end
492
+ end
493
+
494
+ def wrap(text, width: 76, indent: " ")
495
+ words = text.split(/\s+/)
496
+ lines = words.each_with_object([[]]) do |word, acc|
497
+ if (acc.last + [word]).join(" ").length > width
498
+ acc << [word]
499
+ else
500
+ acc.last << word
501
+ end
502
+ end
503
+
504
+ lines.map { |line| line.join(" ") }.join("\n#{indent}")
505
+ end
506
+
507
+ def print_factories(index)
508
+ with_callbacks = index.factories.select(&:fires_callbacks?)
509
+ non_persisting = index.factories.reject(&:persists?)
510
+
511
+ puts "factories (#{index.dsl_module})"
512
+ row "factories", index.factories.empty? ? "none found" : index.factories.size
513
+ row "traits", index.factories.sum { |f| f.traits.size }
514
+ row "unreadable", index.skipped.empty? ? "none" : index.skipped.size
515
+
516
+ unless with_callbacks.empty?
517
+ puts
518
+ puts " callbacks fire while the plan builds records, so the probe attributes"
519
+ puts " them to setup rather than to the target:"
520
+ with_callbacks.each do |factory|
521
+ puts " #{factory.name}: #{factory.callbacks.map(&:to_s).join(', ')} " \
522
+ "(#{factory.file}:#{factory.callbacks.first.line})"
523
+ end
524
+ end
525
+
526
+ unless non_persisting.empty?
527
+ puts
528
+ puts " these factories never persist a row, so a plan cannot build on them:"
529
+ non_persisting.each do |factory|
530
+ puts " #{factory.name}: #{factory.hazards.map(&:first).join(', ')}"
531
+ end
532
+ end
533
+
534
+ return if index.skipped.empty?
535
+
536
+ puts
537
+ puts " unreadable factory files (pinspec will act as if these factories do not exist):"
538
+ index.skipped.each { |entry| puts " #{entry[:file]}: #{entry[:kind]} - #{entry[:detail]}" }
539
+ end
540
+
541
+ def print_profile(profile)
542
+ start_line, end_line = profile.source_range
543
+
544
+ puts profile.qualified_name
545
+ row "file", "#{profile.file_path}:#{start_line}-#{end_line}"
546
+ row "construction", construction_summary(profile)
547
+ row "ctor params", params_summary(profile.initializer_params)
548
+ row "method params", params_summary(profile.params)
549
+ row "visibility", profile.visibility
550
+ row "constants", list(profile.referenced_constants)
551
+ row "clock sites", clock_summary(profile)
552
+ end
553
+
554
+ def construction_summary(profile)
555
+ return "#{profile.construction_kind} (no subject needed)" unless profile.needs_subject?
556
+
557
+ args = profile.initializer_params.map(&:to_s).join(", ")
558
+ "#{profile.construction_kind}: #{profile.class_name}.new(#{args})"
559
+ end
560
+
561
+ def params_summary(params)
562
+ return "(none)" if params.empty?
563
+
564
+ params.map do |param|
565
+ hint = param.type_hint ? " [#{param.type_hint}]" : ""
566
+ "#{param}#{hint}"
567
+ end.join(", ")
568
+ end
569
+
570
+ def clock_summary(profile)
571
+ return "(none)" unless profile.clock_dependent?
572
+
573
+ sites = profile.clock_sites.map { |s| "#{s.call} (line #{s.line})" }.join(", ")
574
+ "#{sites}; reads the process clock, not Time.zone, so pins will be TZ-dependent"
575
+ end
576
+
577
+ def list(values)
578
+ values.empty? ? "(none)" : values.join(", ")
579
+ end
580
+
581
+ def row(label, value)
582
+ puts format(" %-14s %s", label, value)
583
+ end
584
+ end
585
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pinspec
6
+ module Emit
7
+ class Namer
8
+ MAX_DESCRIPTION = 120
9
+
10
+ Facts = Data.define(:case_id, :origin, :outcome, :method_name, :class_name, :parameters)
11
+
12
+ def initialize(target:, enabled: false, client: nil)
13
+ @target = target
14
+ @enabled = enabled
15
+ @client = client
16
+ end
17
+
18
+ def enabled?
19
+ @enabled && !@client.nil?
20
+ end
21
+
22
+ def describe(cases)
23
+ facts = cases.map { |input_case, observation| facts_for(input_case, observation) }
24
+ fallback = facts.to_h { |fact| [fact.case_id, deterministic(fact)] }
25
+
26
+ return fallback unless enabled?
27
+
28
+ improved = request(facts)
29
+ fallback.merge(sanitize(improved, fallback))
30
+ rescue StandardError
31
+ fallback
32
+ end
33
+
34
+ def payload(facts)
35
+ {
36
+ "task" => "Describe each characterization test case in one short phrase.",
37
+ "constraints" => [
38
+ "Describe only the INPUTS and the kind of outcome.",
39
+ "Never invent or state a concrete expected value.",
40
+ "One phrase per case, at most #{MAX_DESCRIPTION} characters."
41
+ ],
42
+ "cases" => facts.map do |fact|
43
+ {
44
+ "id" => fact.case_id,
45
+ "origin" => fact.origin.to_s,
46
+ "outcome_kind" => fact.outcome.to_s,
47
+ "method" => fact.method_name.to_s,
48
+ "class" => fact.class_name.to_s,
49
+ "parameter_names" => fact.parameters.map(&:to_s)
50
+ }
51
+ end
52
+ }
53
+ end
54
+
55
+ private
56
+
57
+ def facts_for(input_case, observation)
58
+ Facts.new(
59
+ case_id: input_case.id,
60
+ origin: input_case.origin,
61
+ outcome: observation["status"] == "raised" ? :raises : :returns,
62
+ method_name: @target.method_name,
63
+ class_name: @target.class_name,
64
+ parameters: @target.input_params.map(&:name)
65
+ )
66
+ end
67
+
68
+ def deterministic(fact)
69
+ verb = fact.outcome == :raises ? "raises" : "returns the pinned value"
70
+
71
+ "#{fact.method_name} #{verb} (#{fact.case_id}, #{fact.origin})"
72
+ end
73
+
74
+ def request(facts)
75
+ response = @client.call(payload(facts))
76
+
77
+ parsed = response.is_a?(String) ? JSON.parse(response) : response
78
+ Array(parsed["cases"]).to_h { |entry| [entry["id"], entry["description"]] }
79
+ end
80
+
81
+ def sanitize(improved, fallback)
82
+ improved.each_with_object({}) do |(case_id, description), out|
83
+ next unless fallback.key?(case_id)
84
+ next unless description.is_a?(String)
85
+
86
+ text = description.strip
87
+ next if text.empty? || text.length > MAX_DESCRIPTION
88
+ next if suspicious?(text)
89
+
90
+ out[case_id] = "#{text} (#{case_id})"
91
+ end
92
+ end
93
+
94
+ def suspicious?(text)
95
+ return true if text.match?(/=>|\{"t"|\bexpect\b|\beq\(/)
96
+ return true if text.match?(/\d{3,}/)
97
+ return true if text.count('"') > 2
98
+
99
+ false
100
+ end
101
+ end
102
+ end
103
+ end