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.
- checksums.yaml +7 -0
- data/Gemfile +19 -0
- data/LICENSE +21 -0
- data/lex-mind-growth.gemspec +29 -0
- data/lib/legion/extensions/mind_growth/actors/growth_cycle.rb +55 -0
- data/lib/legion/extensions/mind_growth/client.rb +36 -0
- data/lib/legion/extensions/mind_growth/helpers/build_pipeline.rb +63 -0
- data/lib/legion/extensions/mind_growth/helpers/cognitive_models.rb +60 -0
- data/lib/legion/extensions/mind_growth/helpers/concept_proposal.rb +69 -0
- data/lib/legion/extensions/mind_growth/helpers/constants.rb +55 -0
- data/lib/legion/extensions/mind_growth/helpers/fitness_evaluator.rb +58 -0
- data/lib/legion/extensions/mind_growth/helpers/proposal_store.rb +71 -0
- data/lib/legion/extensions/mind_growth/runners/analyzer.rb +52 -0
- data/lib/legion/extensions/mind_growth/runners/builder.rb +254 -0
- data/lib/legion/extensions/mind_growth/runners/orchestrator.rb +178 -0
- data/lib/legion/extensions/mind_growth/runners/proposer.rb +269 -0
- data/lib/legion/extensions/mind_growth/runners/validator.rb +57 -0
- data/lib/legion/extensions/mind_growth/version.rb +9 -0
- data/lib/legion/extensions/mind_growth.rb +25 -0
- data/spec/legion/extensions/mind_growth/actors/growth_cycle_spec.rb +81 -0
- data/spec/legion/extensions/mind_growth/client_spec.rb +111 -0
- data/spec/legion/extensions/mind_growth/helpers/build_pipeline_spec.rb +190 -0
- data/spec/legion/extensions/mind_growth/helpers/cognitive_models_spec.rb +106 -0
- data/spec/legion/extensions/mind_growth/helpers/concept_proposal_spec.rb +209 -0
- data/spec/legion/extensions/mind_growth/helpers/fitness_evaluator_spec.rb +123 -0
- data/spec/legion/extensions/mind_growth/helpers/proposal_store_spec.rb +203 -0
- data/spec/legion/extensions/mind_growth/runners/analyzer_spec.rb +108 -0
- data/spec/legion/extensions/mind_growth/runners/builder_spec.rb +347 -0
- data/spec/legion/extensions/mind_growth/runners/orchestrator_spec.rb +185 -0
- data/spec/legion/extensions/mind_growth/runners/proposer_spec.rb +493 -0
- data/spec/legion/extensions/mind_growth/runners/validator_spec.rb +120 -0
- data/spec/spec_helper.rb +22 -0
- 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
|