lex-mind-growth 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 (33) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +19 -0
  3. data/LICENSE +21 -0
  4. data/lex-mind-growth.gemspec +29 -0
  5. data/lib/legion/extensions/mind_growth/actors/growth_cycle.rb +55 -0
  6. data/lib/legion/extensions/mind_growth/client.rb +36 -0
  7. data/lib/legion/extensions/mind_growth/helpers/build_pipeline.rb +63 -0
  8. data/lib/legion/extensions/mind_growth/helpers/cognitive_models.rb +60 -0
  9. data/lib/legion/extensions/mind_growth/helpers/concept_proposal.rb +69 -0
  10. data/lib/legion/extensions/mind_growth/helpers/constants.rb +55 -0
  11. data/lib/legion/extensions/mind_growth/helpers/fitness_evaluator.rb +58 -0
  12. data/lib/legion/extensions/mind_growth/helpers/proposal_store.rb +71 -0
  13. data/lib/legion/extensions/mind_growth/runners/analyzer.rb +52 -0
  14. data/lib/legion/extensions/mind_growth/runners/builder.rb +254 -0
  15. data/lib/legion/extensions/mind_growth/runners/orchestrator.rb +178 -0
  16. data/lib/legion/extensions/mind_growth/runners/proposer.rb +269 -0
  17. data/lib/legion/extensions/mind_growth/runners/validator.rb +57 -0
  18. data/lib/legion/extensions/mind_growth/version.rb +9 -0
  19. data/lib/legion/extensions/mind_growth.rb +25 -0
  20. data/spec/legion/extensions/mind_growth/actors/growth_cycle_spec.rb +81 -0
  21. data/spec/legion/extensions/mind_growth/client_spec.rb +111 -0
  22. data/spec/legion/extensions/mind_growth/helpers/build_pipeline_spec.rb +190 -0
  23. data/spec/legion/extensions/mind_growth/helpers/cognitive_models_spec.rb +106 -0
  24. data/spec/legion/extensions/mind_growth/helpers/concept_proposal_spec.rb +209 -0
  25. data/spec/legion/extensions/mind_growth/helpers/fitness_evaluator_spec.rb +123 -0
  26. data/spec/legion/extensions/mind_growth/helpers/proposal_store_spec.rb +203 -0
  27. data/spec/legion/extensions/mind_growth/runners/analyzer_spec.rb +108 -0
  28. data/spec/legion/extensions/mind_growth/runners/builder_spec.rb +347 -0
  29. data/spec/legion/extensions/mind_growth/runners/orchestrator_spec.rb +185 -0
  30. data/spec/legion/extensions/mind_growth/runners/proposer_spec.rb +493 -0
  31. data/spec/legion/extensions/mind_growth/runners/validator_spec.rb +120 -0
  32. data/spec/spec_helper.rb +22 -0
  33. metadata +91 -0
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::MindGrowth::Helpers::ProposalStore do
4
+ subject(:store) { described_class.new }
5
+
6
+ let(:proposal) do
7
+ Legion::Extensions::MindGrowth::Helpers::ConceptProposal.new(
8
+ name: 'lex-focus',
9
+ module_name: 'Focus',
10
+ category: :cognition,
11
+ description: 'Focus management'
12
+ )
13
+ end
14
+
15
+ let(:approved_proposal) do
16
+ p = Legion::Extensions::MindGrowth::Helpers::ConceptProposal.new(
17
+ name: 'lex-approved',
18
+ module_name: 'Approved',
19
+ category: :memory,
20
+ description: 'Memory management'
21
+ )
22
+ scores = Legion::Extensions::MindGrowth::Helpers::Constants::EVALUATION_DIMENSIONS.to_h { |d| [d, 0.75] }
23
+ p.evaluate!(scores)
24
+ p
25
+ end
26
+
27
+ describe '#store and #get' do
28
+ it 'stores and retrieves a proposal by id' do
29
+ store.store(proposal)
30
+ expect(store.get(proposal.id)).to eq(proposal)
31
+ end
32
+
33
+ it 'returns nil for unknown id' do
34
+ expect(store.get('nonexistent')).to be_nil
35
+ end
36
+ end
37
+
38
+ describe '#all' do
39
+ it 'returns all proposals' do
40
+ store.store(proposal)
41
+ store.store(approved_proposal)
42
+ expect(store.all.size).to eq(2)
43
+ end
44
+
45
+ it 'returns empty array for empty store' do
46
+ expect(store.all).to eq([])
47
+ end
48
+
49
+ it 'returns a copy (not the internal array)' do
50
+ store.store(proposal)
51
+ all = store.all
52
+ all.clear
53
+ expect(store.all.size).to eq(1)
54
+ end
55
+ end
56
+
57
+ describe '#by_status' do
58
+ it 'returns proposals matching the status' do
59
+ store.store(proposal)
60
+ store.store(approved_proposal)
61
+ proposed = store.by_status(:proposed)
62
+ expect(proposed.map(&:id)).to include(proposal.id)
63
+ expect(proposed.map(&:id)).not_to include(approved_proposal.id)
64
+ end
65
+
66
+ it 'returns empty array when no matches' do
67
+ expect(store.by_status(:building)).to eq([])
68
+ end
69
+
70
+ it 'accepts string status and converts to symbol' do
71
+ store.store(proposal)
72
+ expect(store.by_status('proposed')).to include(proposal)
73
+ end
74
+ end
75
+
76
+ describe '#by_category' do
77
+ it 'returns proposals matching the category' do
78
+ store.store(proposal)
79
+ store.store(approved_proposal)
80
+ cognition = store.by_category(:cognition)
81
+ expect(cognition.map(&:id)).to include(proposal.id)
82
+ end
83
+
84
+ it 'accepts string category' do
85
+ store.store(proposal)
86
+ expect(store.by_category('cognition')).to include(proposal)
87
+ end
88
+ end
89
+
90
+ describe '#approved' do
91
+ it 'returns only approved proposals' do
92
+ store.store(proposal)
93
+ store.store(approved_proposal)
94
+ expect(store.approved.map(&:id)).to eq([approved_proposal.id])
95
+ end
96
+ end
97
+
98
+ describe '#build_queue' do
99
+ it 'returns approved proposals sorted by average score descending' do
100
+ p1 = Legion::Extensions::MindGrowth::Helpers::ConceptProposal.new(
101
+ name: 'lex-low', module_name: 'Low', category: :memory, description: 'low'
102
+ )
103
+ p2 = Legion::Extensions::MindGrowth::Helpers::ConceptProposal.new(
104
+ name: 'lex-high', module_name: 'High', category: :cognition, description: 'high'
105
+ )
106
+ low_scores = Legion::Extensions::MindGrowth::Helpers::Constants::EVALUATION_DIMENSIONS.to_h { |d| [d, 0.65] }
107
+ high_scores = Legion::Extensions::MindGrowth::Helpers::Constants::EVALUATION_DIMENSIONS.to_h { |d| [d, 0.85] }
108
+ p1.evaluate!(low_scores)
109
+ p2.evaluate!(high_scores)
110
+ store.store(p1)
111
+ store.store(p2)
112
+ queue = store.build_queue
113
+ expect(queue.first.id).to eq(p2.id)
114
+ end
115
+ end
116
+
117
+ describe '#recent' do
118
+ it 'returns proposals sorted by created_at descending' do
119
+ store.store(proposal)
120
+ store.store(approved_proposal)
121
+ recent = store.recent(limit: 5)
122
+ expect(recent.size).to eq(2)
123
+ end
124
+
125
+ it 'limits results' do
126
+ 5.times do |i|
127
+ p = Legion::Extensions::MindGrowth::Helpers::ConceptProposal.new(
128
+ name: "lex-#{i}", module_name: "P#{i}", category: :cognition, description: "desc #{i}"
129
+ )
130
+ store.store(p)
131
+ end
132
+ expect(store.recent(limit: 3).size).to eq(3)
133
+ end
134
+ end
135
+
136
+ describe '#stats' do
137
+ it 'returns total count' do
138
+ store.store(proposal)
139
+ expect(store.stats[:total]).to eq(1)
140
+ end
141
+
142
+ it 'returns by_status breakdown' do
143
+ store.store(proposal)
144
+ store.store(approved_proposal)
145
+ stats = store.stats
146
+ expect(stats[:by_status][:proposed]).to eq(1)
147
+ expect(stats[:by_status][:approved]).to eq(1)
148
+ end
149
+ end
150
+
151
+ describe '#clear' do
152
+ it 'removes all proposals' do
153
+ store.store(proposal)
154
+ store.clear
155
+ expect(store.stats[:total]).to eq(0)
156
+ end
157
+ end
158
+
159
+ describe 'MAX_PROPOSALS eviction' do
160
+ it 'evicts the oldest proposal when at capacity' do
161
+ stub_const("#{described_class}::MAX_PROPOSALS", 3)
162
+ small_store = described_class.new
163
+ proposals = 4.times.map do |i|
164
+ Legion::Extensions::MindGrowth::Helpers::ConceptProposal.new(
165
+ name: "lex-evict-#{i}", module_name: "E#{i}", category: :cognition, description: "evict #{i}"
166
+ )
167
+ end
168
+ proposals.each { |p| small_store.store(p) }
169
+ expect(small_store.stats[:total]).to eq(3)
170
+ expect(small_store.get(proposals[0].id)).to be_nil
171
+ expect(small_store.get(proposals[3].id)).not_to be_nil
172
+ end
173
+
174
+ it 'keeps the most recent proposals' do
175
+ stub_const("#{described_class}::MAX_PROPOSALS", 2)
176
+ small_store = described_class.new
177
+ 3.times do |i|
178
+ p = Legion::Extensions::MindGrowth::Helpers::ConceptProposal.new(
179
+ name: "lex-keep-#{i}", module_name: "K#{i}", category: :cognition, description: "keep #{i}"
180
+ )
181
+ small_store.store(p)
182
+ end
183
+ names = small_store.all.map(&:name)
184
+ expect(names).to include('lex-keep-1', 'lex-keep-2')
185
+ expect(names).not_to include('lex-keep-0')
186
+ end
187
+ end
188
+
189
+ describe 'thread safety' do
190
+ it 'handles concurrent stores without error' do
191
+ threads = 10.times.map do |i|
192
+ Thread.new do
193
+ p = Legion::Extensions::MindGrowth::Helpers::ConceptProposal.new(
194
+ name: "lex-thread-#{i}", module_name: "T#{i}", category: :cognition, description: "thread #{i}"
195
+ )
196
+ store.store(p)
197
+ end
198
+ end
199
+ threads.each(&:join)
200
+ expect(store.stats[:total]).to eq(10)
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::MindGrowth::Runners::Analyzer do
4
+ subject(:analyzer) { described_class }
5
+
6
+ describe '.cognitive_profile' do
7
+ it 'returns success: true' do
8
+ result = analyzer.cognitive_profile
9
+ expect(result[:success]).to be true
10
+ end
11
+
12
+ it 'returns total_extensions count' do
13
+ result = analyzer.cognitive_profile(existing_extensions: %i[attention memory])
14
+ expect(result[:total_extensions]).to eq(2)
15
+ end
16
+
17
+ it 'returns model_coverage array' do
18
+ result = analyzer.cognitive_profile(existing_extensions: [])
19
+ expect(result[:model_coverage]).to be_an(Array)
20
+ expect(result[:model_coverage].size).to eq(5)
21
+ end
22
+
23
+ it 'returns overall_coverage as a float' do
24
+ result = analyzer.cognitive_profile(existing_extensions: [])
25
+ expect(result[:overall_coverage]).to be_a(Float)
26
+ expect(result[:overall_coverage]).to be_between(0.0, 1.0)
27
+ end
28
+
29
+ it 'returns higher overall coverage with more extensions' do
30
+ empty_result = analyzer.cognitive_profile(existing_extensions: [])
31
+ full_required = Legion::Extensions::MindGrowth::Helpers::CognitiveModels::MODELS.values.flat_map { |m| m[:required] }.uniq
32
+ full_result = analyzer.cognitive_profile(existing_extensions: full_required)
33
+ expect(full_result[:overall_coverage]).to be > empty_result[:overall_coverage]
34
+ end
35
+
36
+ it 'uses empty list when no extensions provided and metacognition constants absent' do
37
+ result = analyzer.cognitive_profile
38
+ expect(result[:total_extensions]).to eq(0)
39
+ end
40
+
41
+ it 'ignores unknown keyword arguments' do
42
+ expect { analyzer.cognitive_profile(unknown: :value) }.not_to raise_error
43
+ end
44
+ end
45
+
46
+ describe '.identify_weak_links' do
47
+ let(:healthy) { { invocation_count: 500, impact_score: 0.9, health_score: 1.0, error_rate: 0.0, avg_latency_ms: 50 } }
48
+ let(:weak) { { invocation_count: 0, impact_score: 0.1, health_score: 0.2, error_rate: 0.9, avg_latency_ms: 3000 } }
49
+
50
+ it 'returns success: true' do
51
+ result = analyzer.identify_weak_links(extensions: [])
52
+ expect(result[:success]).to be true
53
+ end
54
+
55
+ it 'identifies weak links below IMPROVEMENT_THRESHOLD' do
56
+ result = analyzer.identify_weak_links(extensions: [healthy, weak])
57
+ # The healthy extension should not appear as a weak link
58
+ weak_fitnesses = result[:weak_links].map { |e| e[:fitness] }
59
+ weak_fitnesses.each do |f|
60
+ expect(f).to be < Legion::Extensions::MindGrowth::Helpers::Constants::IMPROVEMENT_THRESHOLD
61
+ end
62
+ end
63
+
64
+ it 'returns ranked weak links' do
65
+ result = analyzer.identify_weak_links(extensions: [weak])
66
+ expect(result[:weak_links]).to be_an(Array)
67
+ end
68
+
69
+ it 'returns count of weak links' do
70
+ result = analyzer.identify_weak_links(extensions: [healthy, weak])
71
+ expect(result[:count]).to be_a(Integer)
72
+ end
73
+
74
+ it 'defaults to empty extensions array' do
75
+ result = analyzer.identify_weak_links
76
+ expect(result[:count]).to eq(0)
77
+ end
78
+ end
79
+
80
+ describe '.recommend_priorities' do
81
+ it 'returns success: true' do
82
+ result = analyzer.recommend_priorities(existing_extensions: [])
83
+ expect(result[:success]).to be true
84
+ end
85
+
86
+ it 'returns priorities array' do
87
+ result = analyzer.recommend_priorities(existing_extensions: [])
88
+ expect(result[:priorities]).to be_an(Array)
89
+ end
90
+
91
+ it 'limits priorities to 10' do
92
+ result = analyzer.recommend_priorities(existing_extensions: [])
93
+ expect(result[:priorities].size).to be <= 10
94
+ end
95
+
96
+ it 'includes rationale' do
97
+ result = analyzer.recommend_priorities(existing_extensions: [])
98
+ expect(result[:rationale]).to be_a(String)
99
+ expect(result[:rationale]).not_to be_empty
100
+ end
101
+
102
+ it 'returns fewer priorities when extensions cover more requirements' do
103
+ all_required = Legion::Extensions::MindGrowth::Helpers::CognitiveModels::MODELS.values.flat_map { |m| m[:required] }.uniq
104
+ result = analyzer.recommend_priorities(existing_extensions: all_required)
105
+ expect(result[:priorities]).to be_empty
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require 'fileutils'
5
+ require 'securerandom'
6
+
7
+ RSpec.describe Legion::Extensions::MindGrowth::Runners::Builder do
8
+ subject(:builder) { described_class }
9
+
10
+ # Reset proposer store before each example
11
+ before { Legion::Extensions::MindGrowth::Runners::Proposer.instance_variable_set(:@proposal_store, nil) }
12
+
13
+ let(:proposal_id) do
14
+ result = Legion::Extensions::MindGrowth::Runners::Proposer.propose_concept(
15
+ name: 'lex-buildable', category: :cognition, description: 'a buildable proposal', enrich: false
16
+ )
17
+ result[:proposal][:id]
18
+ end
19
+
20
+ describe '.build_extension' do
21
+ it 'returns not_found for unknown proposal_id' do
22
+ result = builder.build_extension(proposal_id: 'nonexistent')
23
+ expect(result[:success]).to be false
24
+ expect(result[:error]).to eq(:not_found)
25
+ end
26
+
27
+ it 'returns success when proposal exists' do
28
+ result = builder.build_extension(proposal_id: proposal_id)
29
+ expect(result[:success]).to be true
30
+ end
31
+
32
+ it 'returns pipeline hash' do
33
+ result = builder.build_extension(proposal_id: proposal_id)
34
+ expect(result[:pipeline]).to be_a(Hash)
35
+ expect(result[:pipeline][:stage]).to eq(:complete)
36
+ end
37
+
38
+ it 'returns proposal hash' do
39
+ result = builder.build_extension(proposal_id: proposal_id)
40
+ expect(result[:proposal]).to be_a(Hash)
41
+ end
42
+
43
+ it 'transitions proposal to :passing on success' do
44
+ builder.build_extension(proposal_id: proposal_id)
45
+ list = Legion::Extensions::MindGrowth::Runners::Proposer.list_proposals
46
+ p = list[:proposals].find { |pr| pr[:id] == proposal_id }
47
+ expect(p[:status]).to eq(:passing)
48
+ end
49
+
50
+ it 'accepts optional base_path parameter' do
51
+ result = builder.build_extension(proposal_id: proposal_id, base_path: '/tmp')
52
+ expect(result[:success]).to be true
53
+ end
54
+
55
+ it 'ignores unknown keyword arguments' do
56
+ expect { builder.build_extension(proposal_id: proposal_id, unknown: :value) }.not_to raise_error
57
+ end
58
+ end
59
+
60
+ describe '.build_status' do
61
+ it 'returns not_found for unknown proposal_id' do
62
+ result = builder.build_status(proposal_id: 'nonexistent')
63
+ expect(result[:success]).to be false
64
+ expect(result[:error]).to eq(:not_found)
65
+ end
66
+
67
+ it 'returns status for existing proposal' do
68
+ result = builder.build_status(proposal_id: proposal_id)
69
+ expect(result[:success]).to be true
70
+ expect(result[:name]).to eq('lex-buildable')
71
+ end
72
+
73
+ it 'returns current status symbol' do
74
+ result = builder.build_status(proposal_id: proposal_id)
75
+ expect(result[:status]).to be_a(Symbol)
76
+ end
77
+ end
78
+
79
+ describe 'stage stubs (no dependencies loaded)' do
80
+ it 'progresses through all pipeline stages without error' do
81
+ result = builder.build_extension(proposal_id: proposal_id)
82
+ pipeline = result[:pipeline]
83
+ expect(pipeline[:errors]).to be_empty
84
+ expect(pipeline[:stage]).to eq(:complete)
85
+ end
86
+ end
87
+
88
+ describe 'wired stages' do
89
+ describe 'scaffold_stage with codegen' do
90
+ before do
91
+ stub_const('Legion::Extensions::Codegen::Runners::Generate', Module.new)
92
+ stub_const('Legion::Extensions::Codegen::Runners::Validate', Module.new)
93
+ allow(Legion::Extensions::Codegen::Runners::Generate).to receive(:scaffold_extension)
94
+ .and_return({ success: true, path: '/tmp/lex-buildable', files_created: 12, name: 'lex-buildable' })
95
+ allow(Legion::Extensions::Codegen::Runners::Validate).to receive(:validate_structure)
96
+ .and_return({ valid: true, missing: [], present: %w[Gemfile] })
97
+ allow(Legion::Extensions::Codegen::Runners::Validate).to receive(:validate_gemspec)
98
+ .and_return({ valid: true, issues: [] })
99
+ end
100
+
101
+ it 'delegates scaffold to codegen' do
102
+ builder.build_extension(proposal_id: proposal_id, base_path: '/tmp')
103
+ expect(Legion::Extensions::Codegen::Runners::Generate).to have_received(:scaffold_extension)
104
+ .with(hash_including(name: 'buildable', module_name: anything, base_path: '/tmp'))
105
+ end
106
+
107
+ it 'passes proposal fields to codegen' do
108
+ builder.build_extension(proposal_id: proposal_id, base_path: '/tmp')
109
+ expect(Legion::Extensions::Codegen::Runners::Generate).to have_received(:scaffold_extension)
110
+ .with(hash_including(description: 'a buildable proposal', category: :cognition))
111
+ end
112
+
113
+ it 'strips lex- prefix from name before passing to codegen' do
114
+ builder.build_extension(proposal_id: proposal_id, base_path: '/tmp')
115
+ expect(Legion::Extensions::Codegen::Runners::Generate).to have_received(:scaffold_extension)
116
+ .with(hash_including(name: 'buildable'))
117
+ end
118
+
119
+ it 'pipeline completes when codegen succeeds' do
120
+ result = builder.build_extension(proposal_id: proposal_id, base_path: '/tmp')
121
+ expect(result[:pipeline][:stage]).to eq(:complete)
122
+ end
123
+
124
+ it 'pipeline fails when scaffold fails' do
125
+ allow(Legion::Extensions::Codegen::Runners::Generate).to receive(:scaffold_extension)
126
+ .and_return({ success: false, error: 'disk full' })
127
+ result = builder.build_extension(proposal_id: proposal_id, base_path: '/tmp')
128
+ expect(result[:success]).to be false
129
+ expect(result[:pipeline][:errors]).not_to be_empty
130
+ end
131
+ end
132
+
133
+ describe 'test_stage with exec' do
134
+ before do
135
+ stub_const('Legion::Extensions::Exec::Runners::Bundler', Module.new)
136
+ allow(Legion::Extensions::Exec::Runners::Bundler).to receive(:install)
137
+ .and_return({ success: true, stdout: '', stderr: '', exit_code: 0 })
138
+ allow(Legion::Extensions::Exec::Runners::Bundler).to receive(:exec_rspec)
139
+ .and_return({ success: true, parsed: { examples: 10, failures: 0, pending: 0, passed: 10 } })
140
+ allow(Legion::Extensions::Exec::Runners::Bundler).to receive(:exec_rubocop)
141
+ .and_return({ success: true, parsed: { files: 5, offenses: 0, clean: true } })
142
+ end
143
+
144
+ it 'runs install, rspec, and rubocop' do
145
+ builder.build_extension(proposal_id: proposal_id, base_path: '/tmp')
146
+ expect(Legion::Extensions::Exec::Runners::Bundler).to have_received(:install)
147
+ expect(Legion::Extensions::Exec::Runners::Bundler).to have_received(:exec_rspec)
148
+ expect(Legion::Extensions::Exec::Runners::Bundler).to have_received(:exec_rubocop)
149
+ end
150
+
151
+ it 'pipeline fails when rspec has failures' do
152
+ allow(Legion::Extensions::Exec::Runners::Bundler).to receive(:exec_rspec)
153
+ .and_return({ success: true, parsed: { examples: 10, failures: 2, pending: 0, passed: 8 } })
154
+ result = builder.build_extension(proposal_id: proposal_id, base_path: '/tmp')
155
+ expect(result[:success]).to be false
156
+ end
157
+
158
+ it 'pipeline fails when bundle install fails' do
159
+ allow(Legion::Extensions::Exec::Runners::Bundler).to receive(:install)
160
+ .and_return({ success: false, stderr: 'gem not found', exit_code: 1 })
161
+ result = builder.build_extension(proposal_id: proposal_id, base_path: '/tmp')
162
+ expect(result[:success]).to be false
163
+ end
164
+
165
+ it 'does not run rspec if install fails' do
166
+ allow(Legion::Extensions::Exec::Runners::Bundler).to receive(:install)
167
+ .and_return({ success: false, stderr: 'error', exit_code: 1 })
168
+ builder.build_extension(proposal_id: proposal_id, base_path: '/tmp')
169
+ expect(Legion::Extensions::Exec::Runners::Bundler).not_to have_received(:exec_rspec)
170
+ end
171
+ end
172
+
173
+ describe 'validate_stage with codegen validators' do
174
+ before do
175
+ stub_const('Legion::Extensions::Codegen::Runners::Generate', Module.new)
176
+ stub_const('Legion::Extensions::Codegen::Runners::Validate', Module.new)
177
+ allow(Legion::Extensions::Codegen::Runners::Generate).to receive(:scaffold_extension)
178
+ .and_return({ success: true, path: '/tmp/lex-buildable', files_created: 12 })
179
+ allow(Legion::Extensions::Codegen::Runners::Validate).to receive(:validate_structure)
180
+ .and_return({ valid: true, missing: [], present: %w[Gemfile .rubocop.yml] })
181
+ allow(Legion::Extensions::Codegen::Runners::Validate).to receive(:validate_gemspec)
182
+ .and_return({ valid: true, issues: [] })
183
+ end
184
+
185
+ it 'calls structure and gemspec validators' do
186
+ builder.build_extension(proposal_id: proposal_id, base_path: '/tmp')
187
+ expect(Legion::Extensions::Codegen::Runners::Validate).to have_received(:validate_structure)
188
+ expect(Legion::Extensions::Codegen::Runners::Validate).to have_received(:validate_gemspec)
189
+ end
190
+
191
+ it 'fails when structure is invalid' do
192
+ allow(Legion::Extensions::Codegen::Runners::Validate).to receive(:validate_structure)
193
+ .and_return({ valid: false, missing: ['Gemfile'], present: [] })
194
+ result = builder.build_extension(proposal_id: proposal_id, base_path: '/tmp')
195
+ expect(result[:success]).to be false
196
+ end
197
+ end
198
+
199
+ describe 'register_stage with metacognition registry' do
200
+ before do
201
+ stub_const('Legion::Extensions::Metacognition::Runners::Registry', Module.new)
202
+ allow(Legion::Extensions::Metacognition::Runners::Registry).to receive(:register_extension)
203
+ .and_return({ success: true, name: 'lex-buildable', category: 'cognition' })
204
+ end
205
+
206
+ it 'registers the extension' do
207
+ builder.build_extension(proposal_id: proposal_id, base_path: '/tmp')
208
+ expect(Legion::Extensions::Metacognition::Runners::Registry).to have_received(:register_extension)
209
+ .with(hash_including(name: 'lex-buildable', category: 'cognition'))
210
+ end
211
+
212
+ it 'passes module_name and description' do
213
+ builder.build_extension(proposal_id: proposal_id, base_path: '/tmp')
214
+ expect(Legion::Extensions::Metacognition::Runners::Registry).to have_received(:register_extension)
215
+ .with(hash_including(module_name: anything, description: 'a buildable proposal'))
216
+ end
217
+ end
218
+
219
+ describe 'implement_stage with legion-llm' do
220
+ let(:mock_chat) { double('RubyLLM::Chat') }
221
+ let(:mock_response) { double('RubyLLM::Message', content: "# frozen_string_literal: true\n\n{ success: true }\n") }
222
+ let(:ext_dir) { File.join(Dir.tmpdir, "lex-mind-growth-llm-test-#{SecureRandom.hex(4)}") }
223
+
224
+ before do
225
+ llm_mod = Module.new do
226
+ def self.started? = true
227
+ def self.chat(**) = nil
228
+ end
229
+ stub_const('Legion::LLM', llm_mod)
230
+ allow(Legion::LLM).to receive(:chat).and_return(mock_chat)
231
+ allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
232
+ allow(mock_chat).to receive(:ask).and_return(mock_response)
233
+
234
+ # Create a minimal scaffolded extension directory
235
+ runner_dir = File.join(ext_dir, 'lex-buildable', 'lib', 'legion', 'extensions', 'buildable', 'runners')
236
+ helper_dir = File.join(ext_dir, 'lex-buildable', 'lib', 'legion', 'extensions', 'buildable', 'helpers')
237
+ FileUtils.mkdir_p(runner_dir)
238
+ FileUtils.mkdir_p(helper_dir)
239
+ File.write(File.join(runner_dir, 'example.rb'), "# frozen_string_literal: true\n\n{ success: true }\n")
240
+ File.write(File.join(helper_dir, 'store.rb'), "# frozen_string_literal: true\n\nclass Store; end\n")
241
+ end
242
+
243
+ after { FileUtils.rm_rf(ext_dir) }
244
+
245
+ it 'calls LLM for each target file' do
246
+ builder.build_extension(proposal_id: proposal_id, base_path: ext_dir)
247
+ expect(mock_chat).to have_received(:ask).twice
248
+ end
249
+
250
+ it 'passes system instructions to chat' do
251
+ builder.build_extension(proposal_id: proposal_id, base_path: ext_dir)
252
+ expect(mock_chat).to have_received(:with_instructions)
253
+ .with(a_string_including('Ruby code generator')).at_least(:once)
254
+ end
255
+
256
+ it 'includes proposal description in prompt' do
257
+ builder.build_extension(proposal_id: proposal_id, base_path: ext_dir)
258
+ expect(mock_chat).to have_received(:ask)
259
+ .with(a_string_including('a buildable proposal')).at_least(:once)
260
+ end
261
+
262
+ it 'writes LLM output back to files' do
263
+ allow(mock_response).to receive(:content).and_return("# frozen_string_literal: true\n\n# implemented\n")
264
+ builder.build_extension(proposal_id: proposal_id, base_path: ext_dir)
265
+ runner_path = File.join(ext_dir, 'lex-buildable', 'lib', 'legion', 'extensions', 'buildable', 'runners', 'example.rb')
266
+ expect(File.read(runner_path)).to include('# implemented')
267
+ end
268
+
269
+ it 'extracts code from markdown fences' do
270
+ fenced = "Here's the code:\n```ruby\n# frozen_string_literal: true\n\nreal_code\n```\n"
271
+ allow(mock_response).to receive(:content).and_return(fenced)
272
+ builder.build_extension(proposal_id: proposal_id, base_path: ext_dir)
273
+ runner_path = File.join(ext_dir, 'lex-buildable', 'lib', 'legion', 'extensions', 'buildable', 'runners', 'example.rb')
274
+ content = File.read(runner_path)
275
+ expect(content).to include('real_code')
276
+ expect(content).not_to include('```')
277
+ end
278
+
279
+ it 'skips version.rb and client.rb' do
280
+ version_dir = File.join(ext_dir, 'lex-buildable', 'lib', 'legion', 'extensions', 'buildable')
281
+ File.write(File.join(version_dir, 'version.rb'), "VERSION = '0.1.0'\n")
282
+ File.write(File.join(version_dir, 'client.rb'), "class Client; end\n")
283
+ builder.build_extension(proposal_id: proposal_id, base_path: ext_dir)
284
+ # Only runner/example.rb and helper/store.rb should be targets (2 calls)
285
+ expect(mock_chat).to have_received(:ask).twice
286
+ end
287
+
288
+ it 'handles LLM errors gracefully' do
289
+ allow(mock_chat).to receive(:ask).and_raise(StandardError, 'LLM timeout')
290
+ result = builder.build_extension(proposal_id: proposal_id, base_path: ext_dir)
291
+ expect(result[:pipeline][:errors]).not_to be_empty
292
+ end
293
+
294
+ it 'includes metaphor in prompt when present' do
295
+ metaphor_id = Legion::Extensions::MindGrowth::Runners::Proposer.propose_concept(
296
+ name: 'lex-metaphoric', category: :cognition, description: 'test metaphor', enrich: false
297
+ )[:proposal][:id]
298
+ proposal_obj = Legion::Extensions::MindGrowth::Runners::Proposer.get_proposal_object(metaphor_id)
299
+ proposal_obj.instance_variable_set(:@metaphor, 'like a garden')
300
+
301
+ runner_dir = File.join(ext_dir, 'lex-metaphoric', 'lib', 'legion', 'extensions', 'metaphoric', 'runners')
302
+ FileUtils.mkdir_p(runner_dir)
303
+ File.write(File.join(runner_dir, 'grow.rb'), "# stub\n")
304
+
305
+ builder.build_extension(proposal_id: metaphor_id, base_path: ext_dir)
306
+ expect(mock_chat).to have_received(:ask).with(a_string_including('like a garden'))
307
+ end
308
+ end
309
+
310
+ describe 'full wired pipeline' do
311
+ before do
312
+ stub_const('Legion::Extensions::Codegen::Runners::Generate', Module.new)
313
+ stub_const('Legion::Extensions::Codegen::Runners::Validate', Module.new)
314
+ stub_const('Legion::Extensions::Exec::Runners::Bundler', Module.new)
315
+ stub_const('Legion::Extensions::Metacognition::Runners::Registry', Module.new)
316
+
317
+ allow(Legion::Extensions::Codegen::Runners::Generate).to receive(:scaffold_extension)
318
+ .and_return({ success: true, path: '/tmp/lex-buildable', files_created: 12 })
319
+ allow(Legion::Extensions::Exec::Runners::Bundler).to receive(:install)
320
+ .and_return({ success: true })
321
+ allow(Legion::Extensions::Exec::Runners::Bundler).to receive(:exec_rspec)
322
+ .and_return({ success: true, parsed: { examples: 5, failures: 0, pending: 0, passed: 5 } })
323
+ allow(Legion::Extensions::Exec::Runners::Bundler).to receive(:exec_rubocop)
324
+ .and_return({ success: true, parsed: { files: 3, offenses: 0, clean: true } })
325
+ allow(Legion::Extensions::Codegen::Runners::Validate).to receive(:validate_structure)
326
+ .and_return({ valid: true, missing: [], present: %w[Gemfile] })
327
+ allow(Legion::Extensions::Codegen::Runners::Validate).to receive(:validate_gemspec)
328
+ .and_return({ valid: true, issues: [] })
329
+ allow(Legion::Extensions::Metacognition::Runners::Registry).to receive(:register_extension)
330
+ .and_return({ success: true, name: 'lex-buildable' })
331
+ end
332
+
333
+ it 'completes all stages successfully' do
334
+ result = builder.build_extension(proposal_id: proposal_id, base_path: '/tmp')
335
+ expect(result[:success]).to be true
336
+ expect(result[:pipeline][:stage]).to eq(:complete)
337
+ expect(result[:pipeline][:errors]).to be_empty
338
+ end
339
+
340
+ it 'transitions proposal to :passing' do
341
+ builder.build_extension(proposal_id: proposal_id, base_path: '/tmp')
342
+ proposal = Legion::Extensions::MindGrowth::Runners::Proposer.get_proposal_object(proposal_id)
343
+ expect(proposal.status).to eq(:passing)
344
+ end
345
+ end
346
+ end
347
+ end