foundries 0.1.3 → 0.1.4

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: c8245b09fe2227ef4a25d5751c30f4a94a1d2d80519ef5fb97b8ba2b9dc527ef
4
- data.tar.gz: 24162154115e9f413117fb88d71aa0cbc19374cbbf9b6fa059f23e36535511e5
3
+ metadata.gz: ee65151ab0ae7d4ef338d8dd8dec76fbf9f0746be24d99d814abdee8716c500a
4
+ data.tar.gz: 9a298a3ca012affe264f0467240f7a44b46decb2227f88f9eb2ca27c28f8bfad
5
5
  SHA512:
6
- metadata.gz: f0b22d20d6edc795ef1c7a2f3403b1a7e5cbbbc0d3ca4bd19fc82c25992bcea6d460175df202d8cd0465dd328a8bc189672171423ed283aa7879390450261318
7
- data.tar.gz: 1ece80e18cdc964013db346ec665e2919f146bda411ddaf4254abb4c714c712a4a692cb30241a051da98029c164fbbc77b1e4fc44c2dac0cadda396c0fbccadf
6
+ metadata.gz: 3b0eef54e2302dbda0dcc4ee7a9224c75e31f90b42498dabcdcac4a8e00318bc79bd34f343367cd2f03ab1cb969f72b01d4336c49ae4329eac5d7ebd136b64c1
7
+ data.tar.gz: 60f6748284f995381cc16f26a816f7f1ac9fac581948bbd0bc553c4839caf4cd9df6634982930d9017f896bdbb8d5f3edd3072aca7be19f5b9ba1620479c3a27
data/CHANGELOG.md CHANGED
@@ -5,22 +5,19 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](http://keepachangelog.com/)
6
6
  and this project adheres to [Semantic Versioning](http://semver.org/).
7
7
 
8
- ## [0.1.3] - 2026-03-13
8
+ ## [0.1.4] - 2026-03-16
9
9
 
10
10
  ### Added
11
11
 
12
- - ancestor DSL and ancestors_for for path-based hierarchy building (7027e06)
12
+ - Recording module to analyze FactoryBot usage and suggest preset candidates (a294324)
13
+ - parallel_tests support for Recording with per-worker output and merge rake task (8e59deb)
13
14
 
14
- ## [0.1.2] - 2026-03-12
15
+ ### Changed
15
16
 
16
- ### Added
17
+ - README now covers Recording feature and parallel_tests support (69fa45b)
17
18
 
18
- - aliases DSL on Base for shorthand method names (377632f)
19
- - lookup_order DSL on Blueprint for ancestor traversal (377632f)
20
- - find_or_create pattern on Blueprint (377632f)
21
- - Tests for inherited registries, parent-scoped find, collection_find_by kwargs, find_or_create, ascending_find, and parent_present? (2d92945)
19
+ ## [0.1.3] - 2026-03-13
22
20
 
23
- ### Changed
21
+ ### Added
24
22
 
25
- - Collections initialize before blueprints (377632f)
26
- - Fix find parent scoping and collection_find_by kwargs handling (3ebfc3e)
23
+ - ancestor DSL and ancestors_for for path-based hierarchy building (7027e06)
data/README.md CHANGED
@@ -224,6 +224,51 @@ When two presets share identical structure or one is structurally contained with
224
224
 
225
225
  Each unique pair is warned once per process. The detection normalizes trees by deduplicating sibling nodes (keeping the richest subtree), collapsing pass-through chains, and sorting alphabetically. This means presets that build the same *shape* of data are detected regardless of the specific names or attribute values used.
226
226
 
227
+ ## Factory Usage Recording
228
+
229
+ If you're migrating an existing test suite to Foundries, the recording feature can help you discover which factory call patterns appear most often — and would make good preset candidates.
230
+
231
+ Add to your `spec_helper.rb`:
232
+
233
+ ```ruby
234
+ require "foundries/recording"
235
+ ```
236
+
237
+ Then run your suite with the environment variable:
238
+
239
+ ```
240
+ FOUNDRIES_RECORD=1 bundle exec rspec
241
+ ```
242
+
243
+ After the suite finishes, a summary is printed to stdout:
244
+
245
+ ```
246
+ [Foundries] Recording complete. 482 tests, 3841 factory creates.
247
+ [Foundries] Top preset candidates:
248
+ 1. team > [project > [task], user] (34 tests, score: 102)
249
+ 2. team > [user] (28 tests, score: 56)
250
+ 3. team > [project > [task > [comment]], user] (12 tests, score: 60)
251
+ [Foundries] Full report: tmp/foundries/recording.json
252
+ ```
253
+
254
+ The full JSON report at `tmp/foundries/recording.json` includes per-test breakdowns and all candidates with the test names that use each pattern.
255
+
256
+ ### Parallel tests
257
+
258
+ Recording works with [parallel_tests](https://github.com/grosser/parallel_tests). Each worker writes its own file, then a rake task merges the results:
259
+
260
+ ```
261
+ FOUNDRIES_RECORD=1 bundle exec parallel_rspec
262
+ rake foundries:recording:merge
263
+ ```
264
+
265
+ To use the rake task, add to your `Rakefile`:
266
+
267
+ ```ruby
268
+ require "foundries/recording/rake_task"
269
+ Foundries::Recording::RakeTask.install
270
+ ```
271
+
227
272
  ## Requirements
228
273
 
229
274
  - Ruby >= 4.0
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "node"
4
+
5
+ module Foundries
6
+ module Recording
7
+ class Aggregator
8
+ def initialize(results)
9
+ @results = results
10
+ end
11
+
12
+ def candidates
13
+ return [] if @results.empty?
14
+
15
+ normalized = normalize_results
16
+ full_tree_candidates = group_by_full_tree(normalized)
17
+ subtree_candidates = find_common_subtrees(normalized)
18
+ merged = merge_candidates(full_tree_candidates, subtree_candidates)
19
+ filtered = merged.reject { |c| c[:tree_size] <= 1 }
20
+ filtered.sort_by { |c| -c[:score] }
21
+ end
22
+
23
+ private
24
+
25
+ def normalize_results
26
+ @results.transform_values(&:normalize)
27
+ end
28
+
29
+ def group_by_full_tree(normalized)
30
+ groups = Hash.new { |h, k| h[k] = [] }
31
+
32
+ normalized.each do |test_id, root|
33
+ structure = root_structure(root)
34
+ groups[structure] << test_id
35
+ end
36
+
37
+ groups.map do |structure, tests|
38
+ sample_root = normalized[tests.first]
39
+ tree_size = sample_root.children.sum(&:tree_size)
40
+ {
41
+ structure: structure,
42
+ frequency: tests.size,
43
+ tree_size: tree_size,
44
+ score: tests.size * tree_size,
45
+ tests: tests.map(&:to_s)
46
+ }
47
+ end
48
+ end
49
+
50
+ def root_structure(root)
51
+ root.children.sort_by(&:signature).map(&:signature).join(", ")
52
+ end
53
+
54
+ def find_common_subtrees(normalized)
55
+ subtree_tests = Hash.new { |h, k| h[k] = Set.new }
56
+ subtree_nodes = {}
57
+
58
+ normalized.each do |test_id, root|
59
+ root.children.each do |child|
60
+ extract_nontrivial_subtrees(child).each do |subtree|
61
+ sig = subtree.signature
62
+ subtree_tests[sig] << test_id
63
+ subtree_nodes[sig] ||= subtree
64
+ end
65
+ end
66
+ end
67
+
68
+ subtree_tests.map do |sig, tests|
69
+ subtree = subtree_nodes[sig]
70
+ {
71
+ structure: sig,
72
+ frequency: tests.size,
73
+ tree_size: subtree.tree_size,
74
+ score: tests.size * subtree.tree_size,
75
+ tests: tests.map(&:to_s)
76
+ }
77
+ end
78
+ end
79
+
80
+ def extract_nontrivial_subtrees(node)
81
+ subtrees = []
82
+ # A non-trivial subtree is a node that has children
83
+ subtrees << node unless node.children.empty?
84
+ node.children.each do |child|
85
+ subtrees.concat(extract_nontrivial_subtrees(child))
86
+ end
87
+ subtrees
88
+ end
89
+
90
+ def merge_candidates(full_tree, subtree)
91
+ by_structure = {}
92
+
93
+ full_tree.each do |candidate|
94
+ by_structure[candidate[:structure]] = candidate
95
+ end
96
+
97
+ subtree.each do |candidate|
98
+ if by_structure.key?(candidate[:structure])
99
+ existing = by_structure[candidate[:structure]]
100
+ merged_tests = (existing[:tests] + candidate[:tests]).uniq
101
+ frequency = merged_tests.size
102
+ tree_size = candidate[:tree_size]
103
+ by_structure[candidate[:structure]] = {
104
+ structure: candidate[:structure],
105
+ frequency: frequency,
106
+ tree_size: tree_size,
107
+ score: frequency * tree_size,
108
+ tests: merged_tests
109
+ }
110
+ else
111
+ by_structure[candidate[:structure]] = candidate
112
+ end
113
+ end
114
+
115
+ by_structure.values
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require_relative "node"
5
+
6
+ module Foundries
7
+ module Recording
8
+ class Collector
9
+ def initialize
10
+ @results = {}
11
+ @total_creates = 0
12
+ @subscriber = nil
13
+ @stack = nil
14
+ @current_root = nil
15
+ end
16
+
17
+ def start_test(test_id)
18
+ @current_root = MutableNode.new(:__root__, [])
19
+ @stack = [@current_root]
20
+
21
+ @subscriber = ActiveSupport::Notifications.subscribe(
22
+ "factory_bot.run_factory",
23
+ Subscriber.new(self)
24
+ )
25
+ end
26
+
27
+ def stop_test(test_id)
28
+ ActiveSupport::Notifications.unsubscribe(@subscriber)
29
+ @subscriber = nil
30
+
31
+ @results[test_id] = freeze_tree(@current_root)
32
+ @stack = nil
33
+ @current_root = nil
34
+ end
35
+
36
+ attr_reader :results
37
+
38
+ attr_reader :total_creates
39
+
40
+ # @api private — called by the Subscriber
41
+ def handle_start(payload)
42
+ return unless payload[:strategy] == :create
43
+
44
+ traits = payload[:traits] || []
45
+ mutable = MutableNode.new(payload[:name], traits)
46
+ @stack.last.children << mutable
47
+ @stack.push(mutable)
48
+ end
49
+
50
+ # @api private — called by the Subscriber
51
+ def handle_finish(payload)
52
+ return unless payload[:strategy] == :create
53
+
54
+ @stack.pop
55
+ @total_creates += 1
56
+ end
57
+
58
+ private
59
+
60
+ def freeze_tree(mutable)
61
+ frozen_children = mutable.children.map { |child| freeze_tree(child) }
62
+ Node.new(
63
+ factory: mutable.factory,
64
+ traits: mutable.traits,
65
+ children: frozen_children
66
+ )
67
+ end
68
+
69
+ MutableNode = Struct.new(:factory, :traits) do
70
+ def children
71
+ @children ||= []
72
+ end
73
+ end
74
+
75
+ class Subscriber
76
+ def initialize(collector)
77
+ @collector = collector
78
+ end
79
+
80
+ def start(_name, _id, payload)
81
+ @collector.handle_start(payload)
82
+ end
83
+
84
+ def finish(_name, _id, payload)
85
+ @collector.handle_finish(payload)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foundries
4
+ module Recording
5
+ class Node
6
+ attr_reader :factory, :traits, :children
7
+
8
+ def initialize(factory:, traits: [], children: [])
9
+ @factory = factory.to_sym
10
+ @traits = traits.map(&:to_sym).sort.freeze
11
+ @children = children.freeze
12
+ @self_signature = compute_self_signature
13
+ @signature = compute_signature
14
+ @tree_size = 1 + children.sum(&:tree_size)
15
+ freeze
16
+ end
17
+
18
+ attr_reader :signature, :self_signature, :tree_size
19
+
20
+ def normalize
21
+ normalized_children = children.map(&:normalize)
22
+ deduped = normalized_children
23
+ .group_by(&:self_signature)
24
+ .map { |_sig, group| group.max_by(&:tree_size) }
25
+ .sort_by(&:signature)
26
+ self.class.new(factory: factory, traits: traits, children: deduped)
27
+ end
28
+
29
+ def to_h
30
+ {
31
+ factory: factory.to_s,
32
+ traits: traits.map(&:to_s),
33
+ children: children.map(&:to_h)
34
+ }
35
+ end
36
+
37
+ def ==(other)
38
+ other.is_a?(self.class) && signature == other.signature
39
+ end
40
+
41
+ alias_method :eql?, :==
42
+
43
+ def hash
44
+ signature.hash
45
+ end
46
+
47
+ def to_s
48
+ signature
49
+ end
50
+
51
+ private
52
+
53
+ def compute_self_signature
54
+ base = factory.to_s
55
+ base = "#{base}[#{traits.map { |t| ":#{t}" }.join(", ")}]" unless traits.empty?
56
+ base
57
+ end
58
+
59
+ def compute_signature
60
+ base = @self_signature
61
+ unless children.empty?
62
+ sorted_children = children.sort_by(&:signature)
63
+ base = "#{base} > [#{sorted_children.map(&:signature).join(", ")}]"
64
+ end
65
+ base
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake"
4
+ require_relative "../recording"
5
+
6
+ module Foundries
7
+ module Recording
8
+ class RakeTask
9
+ include Rake::DSL
10
+
11
+ def self.install(output_path: Recording::DEFAULT_OUTPUT_PATH)
12
+ new.install_tasks(output_path: output_path)
13
+ end
14
+
15
+ def install_tasks(output_path:)
16
+ namespace :foundries do
17
+ namespace :recording do
18
+ desc "Merge per-worker recording files into a single report"
19
+ task :merge do
20
+ dir = File.dirname(output_path)
21
+ base = File.basename(output_path, File.extname(output_path))
22
+ ext = File.extname(output_path)
23
+ pattern = File.join(dir, "#{base}-*#{ext}")
24
+ files = Dir.glob(pattern).sort
25
+
26
+ if files.empty?
27
+ $stdout.puts "[Foundries] No worker recording files found matching #{pattern}"
28
+ next
29
+ end
30
+
31
+ Foundries::Recording::RakeTask.merge(files, output_path: output_path)
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ def self.merge(files, output_path:)
38
+ summary = Reporter.merge_files(files, output_path: output_path)
39
+ $stdout.puts summary
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "fileutils"
6
+ require_relative "aggregator"
7
+ require_relative "node"
8
+
9
+ module Foundries
10
+ module Recording
11
+ class Reporter
12
+ MAX_STDOUT_CANDIDATES = 10
13
+
14
+ def self.merge_files(paths, output_path:)
15
+ combined_per_test = {}
16
+ total_creates = 0
17
+
18
+ paths.each do |path|
19
+ data = JSON.parse(File.read(path))
20
+ total_creates += data["total_factory_creates"]
21
+ data["per_test"].each do |test_id, test_data|
22
+ combined_per_test[test_id] = test_data
23
+ end
24
+ end
25
+
26
+ total_tests = combined_per_test.size
27
+
28
+ results = combined_per_test.transform_values do |test_data|
29
+ children = (test_data["tree"] || []).map { |h| node_from_hash(h) }
30
+ Node.new(factory: :__root__, children: children)
31
+ end
32
+
33
+ reporter = new(results: results, total_creates: total_creates, total_tests: total_tests)
34
+ reporter.write_json(output_path)
35
+ reporter.summary(json_path: output_path)
36
+ end
37
+
38
+ def self.node_from_hash(hash)
39
+ children = (hash["children"] || []).map { |c| node_from_hash(c) }
40
+ Node.new(
41
+ factory: hash["factory"].to_sym,
42
+ traits: (hash["traits"] || []).map(&:to_sym),
43
+ children: children
44
+ )
45
+ end
46
+
47
+ private_class_method :node_from_hash
48
+
49
+ def initialize(results:, total_creates:, total_tests:)
50
+ @results = results
51
+ @total_creates = total_creates
52
+ @total_tests = total_tests
53
+ end
54
+
55
+ def candidates
56
+ @candidates ||= Aggregator.new(@results).candidates
57
+ end
58
+
59
+ def write_json(path)
60
+ FileUtils.mkdir_p(File.dirname(path))
61
+ File.write(path, JSON.pretty_generate(json_data))
62
+ end
63
+
64
+ def summary(json_path: nil)
65
+ lines = []
66
+ lines << "[Foundries] Recording complete. #{@total_tests} tests, #{@total_creates} factory creates."
67
+
68
+ if candidates.empty?
69
+ lines << "[Foundries] No preset candidates found."
70
+ else
71
+ lines << "[Foundries] Top preset candidates:"
72
+ candidates.first(MAX_STDOUT_CANDIDATES).each_with_index do |candidate, index|
73
+ lines << " #{index + 1}. #{candidate[:structure]} (#{candidate[:frequency]} tests, score: #{candidate[:score]})"
74
+ end
75
+ end
76
+
77
+ lines << "[Foundries] Full report: #{json_path}" if json_path
78
+
79
+ lines.join("\n")
80
+ end
81
+
82
+ private
83
+
84
+ def json_data
85
+ {
86
+ recorded_at: Time.now.utc.iso8601,
87
+ total_tests: @total_tests,
88
+ total_factory_creates: @total_creates,
89
+ candidates: candidates.map { |c| stringify_candidate(c) },
90
+ per_test: build_per_test
91
+ }
92
+ end
93
+
94
+ def stringify_candidate(candidate)
95
+ {
96
+ structure: candidate[:structure],
97
+ frequency: candidate[:frequency],
98
+ tree_size: candidate[:tree_size],
99
+ score: candidate[:score],
100
+ tests: candidate[:tests]
101
+ }
102
+ end
103
+
104
+ def build_per_test
105
+ @results.each_with_object({}) do |(test_id, root), hash|
106
+ hash[test_id.to_s] = {
107
+ factories_created: root.children.size,
108
+ tree: root.children.map(&:to_h)
109
+ }
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "recording/collector"
4
+ require_relative "recording/reporter"
5
+
6
+ module Foundries
7
+ module Recording
8
+ DEFAULT_OUTPUT_PATH = "tmp/foundries/recording.json"
9
+
10
+ class << self
11
+ attr_writer :enabled, :output_path
12
+
13
+ def enabled?
14
+ return @enabled unless @enabled.nil?
15
+ ENV["FOUNDRIES_RECORD"] == "1"
16
+ end
17
+
18
+ def collector
19
+ @collector ||= Collector.new
20
+ end
21
+
22
+ def output_path
23
+ @output_path || DEFAULT_OUTPUT_PATH
24
+ end
25
+
26
+ def worker_output_path
27
+ worker_id = ENV["TEST_ENV_NUMBER"]
28
+ return output_path if worker_id.nil?
29
+
30
+ suffix = worker_id.empty? ? Process.pid.to_s : worker_id
31
+ base = output_path
32
+ ext = File.extname(base)
33
+ "#{base.chomp(ext)}-#{suffix}#{ext}"
34
+ end
35
+
36
+ def reset!
37
+ @collector = nil
38
+ @enabled = nil
39
+ @output_path = nil
40
+ end
41
+
42
+ def report!
43
+ total_tests = collector.results.size
44
+ reporter = Reporter.new(
45
+ results: collector.results,
46
+ total_creates: collector.total_creates,
47
+ total_tests: total_tests
48
+ )
49
+ path = worker_output_path
50
+ reporter.write_json(path)
51
+ $stdout.puts reporter.summary(json_path: path)
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ if Foundries::Recording.enabled? && defined?(RSpec)
58
+ RSpec.configure do |config|
59
+ config.before(:each) do |example|
60
+ Foundries::Recording.collector.start_test(example.full_description)
61
+ end
62
+
63
+ config.after(:each) do |example|
64
+ Foundries::Recording.collector.stop_test(example.full_description)
65
+ end
66
+
67
+ config.after(:suite) do
68
+ Foundries::Recording.report!
69
+ end
70
+ end
71
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Foundries
4
- VERSION = "0.1.3"
5
- RELEASE_DATE = "2026-03-13"
4
+ VERSION = "0.1.4"
5
+ RELEASE_DATE = "2026-03-16"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foundries
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Dowd
@@ -54,6 +54,12 @@ files:
54
54
  - lib/foundries.rb
55
55
  - lib/foundries/base.rb
56
56
  - lib/foundries/blueprint.rb
57
+ - lib/foundries/recording.rb
58
+ - lib/foundries/recording/aggregator.rb
59
+ - lib/foundries/recording/collector.rb
60
+ - lib/foundries/recording/node.rb
61
+ - lib/foundries/recording/rake_task.rb
62
+ - lib/foundries/recording/reporter.rb
57
63
  - lib/foundries/similarity.rb
58
64
  - lib/foundries/similarity/comparator.rb
59
65
  - lib/foundries/similarity/recorder.rb