lex-coldstart 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.
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Coldstart
6
+ module Runners
7
+ module Ingest
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ # Ingest a single Claude memory or CLAUDE.md file into lex-memory traces.
12
+ # If lex-memory is not loaded, returns the parsed traces without storing.
13
+ #
14
+ # @param file_path [String] absolute path to the markdown file
15
+ # @param store_traces [Boolean] whether to store into lex-memory (default: true)
16
+ # @return [Hash] { file:, file_type:, traces_parsed:, traces_stored:, traces: }
17
+ def ingest_file(file_path:, store_traces: true, **)
18
+ unless File.exist?(file_path)
19
+ Legion::Logging.warn "[coldstart:ingest] file not found: #{file_path}"
20
+ return { file: file_path, error: 'file not found' }
21
+ end
22
+
23
+ candidates = Helpers::ClaudeParser.parse_file(file_path)
24
+ file_type = Helpers::ClaudeParser.detect_file_type(file_path)
25
+ Legion::Logging.info "[coldstart:ingest] parsed #{candidates.size} traces from #{file_path} (#{file_type})"
26
+
27
+ stored = store_traces ? store_candidates(candidates) : []
28
+
29
+ {
30
+ file: file_path,
31
+ file_type: file_type,
32
+ traces_parsed: candidates.size,
33
+ traces_stored: stored.size,
34
+ traces: stored.empty? ? candidates : stored
35
+ }
36
+ end
37
+
38
+ # Ingest all CLAUDE.md and MEMORY.md files under a directory.
39
+ #
40
+ # @param dir_path [String] absolute path to the directory
41
+ # @param pattern [String] glob pattern (default: '**/{CLAUDE,MEMORY}.md')
42
+ # @param store_traces [Boolean] whether to store into lex-memory (default: true)
43
+ # @return [Hash] { directory:, files_found:, total_parsed:, total_stored:, files: }
44
+ def ingest_directory(dir_path:, pattern: '**/{CLAUDE,MEMORY}.md', store_traces: true, **)
45
+ unless Dir.exist?(dir_path)
46
+ Legion::Logging.warn "[coldstart:ingest] directory not found: #{dir_path}"
47
+ return { directory: dir_path, error: 'directory not found' }
48
+ end
49
+
50
+ candidates = Helpers::ClaudeParser.parse_directory(dir_path, pattern: pattern)
51
+ files = candidates.map { |c| c[:source_file] }.uniq
52
+ Legion::Logging.info "[coldstart:ingest] parsed #{candidates.size} traces from #{files.size} files in #{dir_path}"
53
+
54
+ stored = store_traces ? store_candidates(candidates) : []
55
+
56
+ {
57
+ directory: dir_path,
58
+ files_found: files.size,
59
+ total_parsed: candidates.size,
60
+ total_stored: stored.size,
61
+ files: files
62
+ }
63
+ end
64
+
65
+ # Preview what traces would be created from a file without storing them.
66
+ #
67
+ # @param file_path [String] absolute path to the markdown file
68
+ # @return [Hash] { file:, file_type:, traces: }
69
+ def preview_ingest(file_path:, **)
70
+ return { file: file_path, error: 'file not found' } unless File.exist?(file_path)
71
+
72
+ candidates = Helpers::ClaudeParser.parse_file(file_path)
73
+ file_type = Helpers::ClaudeParser.detect_file_type(file_path)
74
+
75
+ {
76
+ file: file_path,
77
+ file_type: file_type,
78
+ traces: candidates
79
+ }
80
+ end
81
+
82
+ private
83
+
84
+ def store_candidates(candidates)
85
+ return [] unless memory_available?
86
+
87
+ imprint = imprint_active_now?
88
+ runner = memory_runner
89
+ stored = []
90
+
91
+ candidates.each do |candidate|
92
+ result = runner.store_trace(
93
+ type: candidate[:trace_type],
94
+ content_payload: candidate[:content_payload],
95
+ domain_tags: candidate[:domain_tags],
96
+ origin: candidate[:origin],
97
+ confidence: candidate[:confidence],
98
+ imprint_active: imprint,
99
+ emotional_valence: candidate[:emotional_valence] || 0.0,
100
+ emotional_intensity: candidate[:emotional_intensity] || (candidate[:trace_type] == :firmware ? 0.8 : 0.3)
101
+ )
102
+ stored << result if result
103
+ rescue StandardError => e
104
+ Legion::Logging.warn "[coldstart:ingest] failed to store trace: #{e.message}"
105
+ end
106
+
107
+ # Flush the cache-backed store if it supports it
108
+ store = runner.send(:default_store)
109
+ store.flush if store.respond_to?(:flush)
110
+
111
+ Legion::Logging.info "[coldstart:ingest] stored #{stored.size} traces (imprint_active=#{imprint})"
112
+
113
+ # Co-activate traces from the same section to form Hebbian links
114
+ coactivate_section_traces(stored, candidates, runner)
115
+
116
+ stored
117
+ end
118
+
119
+ def memory_available?
120
+ Legion::Extensions.const_defined?(:Memory) &&
121
+ Legion::Extensions::Memory.const_defined?(:Runners) &&
122
+ Legion::Extensions::Memory::Runners.const_defined?(:Traces)
123
+ end
124
+
125
+ def memory_runner
126
+ @memory_runner ||= Object.new.extend(Legion::Extensions::Memory::Runners::Traces)
127
+ end
128
+
129
+ def imprint_active_now?
130
+ bootstrap.imprint_active?
131
+ rescue StandardError
132
+ false
133
+ end
134
+
135
+ def bootstrap
136
+ @bootstrap ||= Helpers::Bootstrap.new
137
+ end
138
+
139
+ def coactivate_section_traces(stored, candidates, runner)
140
+ return if stored.size < 2
141
+
142
+ store = runner.send(:default_store)
143
+
144
+ # Group stored trace IDs by their heading_slug domain tag
145
+ groups = {}
146
+ stored.each_with_index do |result, idx|
147
+ candidate = candidates[idx]
148
+ next unless candidate && result
149
+
150
+ slug = candidate[:domain_tags]&.find { |t| t.is_a?(String) && !%w[memory claude_md markdown].include?(t) && !t.include?('/') }
151
+ next unless slug
152
+
153
+ groups[slug] ||= []
154
+ groups[slug] << result[:trace_id]
155
+ end
156
+
157
+ coactivations = 0
158
+ groups.each_value do |trace_ids|
159
+ # Limit to first 10 traces per section to avoid O(n^2) explosion
160
+ trace_ids.first(10).combination(2).each do |id_a, id_b|
161
+ store.record_coactivation(id_a, id_b)
162
+ coactivations += 1
163
+ end
164
+ end
165
+
166
+ Legion::Logging.debug "[coldstart:ingest] co-activated #{coactivations} trace pairs across #{groups.size} sections"
167
+ rescue StandardError => e
168
+ Legion::Logging.warn "[coldstart:ingest] co-activation failed: #{e.message}"
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Coldstart
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/coldstart/version'
4
+ require 'legion/extensions/coldstart/helpers/imprint'
5
+ require 'legion/extensions/coldstart/helpers/bootstrap'
6
+ require 'legion/extensions/coldstart/helpers/claude_parser'
7
+ require 'legion/extensions/coldstart/runners/coldstart'
8
+ require 'legion/extensions/coldstart/runners/ingest'
9
+
10
+ module Legion
11
+ module Extensions
12
+ module Coldstart
13
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
14
+ end
15
+ end
16
+ end
17
+
18
+ if defined?(Legion::Data::Local)
19
+ Legion::Data::Local.register_migrations(
20
+ name: :coldstart,
21
+ path: File.join(__dir__, 'coldstart', 'local_migrations')
22
+ )
23
+ end
@@ -0,0 +1,25 @@
1
+ # lex-example: Test Extension
2
+
3
+ ## Purpose
4
+
5
+ A test extension for validating the Claude parser.
6
+
7
+ ## What is This?
8
+
9
+ An async job processing engine built on RabbitMQ.
10
+
11
+ ## Development
12
+
13
+ ```bash
14
+ bundle install
15
+ bundle exec rspec
16
+ ```
17
+
18
+ - Use `bundle exec` for all commands
19
+ - Run rubocop before committing
20
+
21
+ ## Key Concepts
22
+
23
+ - **Runner**: A function that processes a task
24
+ - **Actor**: An execution mode (subscription, polling, interval)
25
+ - **Extension**: A gem that plugs into the framework
@@ -0,0 +1,23 @@
1
+ # Test Project Memory
2
+
3
+ ## Hard Rules
4
+ - Never delete production data without confirmation
5
+ - Always use snake_case for Ruby methods
6
+
7
+ ## Key Architecture Facts
8
+ - Ruby gem ecosystem with auto-discovered extensions
9
+ - RabbitMQ for task distribution via `legion-transport`
10
+ - MySQL via `Sequel` ORM for persistence
11
+
12
+ ## CLI Gotchas
13
+ - Thor reserves `run` as a method name
14
+ - `::Process` must be explicit inside `Legion::` namespace
15
+ - `::JSON` must be explicit inside `Legion::` namespace
16
+
17
+ ## Identity Auth Pattern
18
+ - Digital Worker = Entra ID **Application** (service principal)
19
+ - Dual-layer: OIDC client credentials + behavioral entropy
20
+
21
+ ## Project Structure
22
+ - Workspace: `/tmp/test`
23
+ - 10 repos total
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Stub the framework actor base class since legionio gem is not available in test
4
+ module Legion
5
+ module Extensions
6
+ module Actors
7
+ class Once # rubocop:disable Lint/EmptyClass
8
+ end
9
+ end
10
+ end
11
+ end
12
+
13
+ # Intercept the require in the actor file so it doesn't fail
14
+ $LOADED_FEATURES << 'legion/extensions/actors/once'
15
+
16
+ require 'legion/extensions/coldstart/actors/imprint'
17
+
18
+ RSpec.describe Legion::Extensions::Coldstart::Actor::Imprint do
19
+ subject(:actor) { described_class.new }
20
+
21
+ describe '#runner_class' do
22
+ it 'returns the Coldstart runner module' do
23
+ expect(actor.runner_class).to eq(Legion::Extensions::Coldstart::Runners::Coldstart)
24
+ end
25
+ end
26
+
27
+ describe '#runner_function' do
28
+ it 'returns begin_imprint' do
29
+ expect(actor.runner_function).to eq('begin_imprint')
30
+ end
31
+ end
32
+
33
+ describe '#use_runner?' do
34
+ it 'returns false' do
35
+ expect(actor.use_runner?).to be false
36
+ end
37
+ end
38
+
39
+ describe '#check_subtask?' do
40
+ it 'returns false' do
41
+ expect(actor.check_subtask?).to be false
42
+ end
43
+ end
44
+
45
+ describe '#generate_task?' do
46
+ it 'returns false' do
47
+ expect(actor.generate_task?).to be false
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/coldstart/client'
4
+
5
+ RSpec.describe Legion::Extensions::Coldstart::Client do
6
+ it 'responds to coldstart runner methods' do
7
+ client = described_class.new
8
+ expect(client).to respond_to(:begin_imprint)
9
+ expect(client).to respond_to(:record_observation)
10
+ expect(client).to respond_to(:coldstart_progress)
11
+ expect(client).to respond_to(:imprint_active?)
12
+ expect(client).to respond_to(:current_multiplier)
13
+ end
14
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Coldstart::Helpers::ClaudeParser do
6
+ let(:fixture_dir) { File.expand_path('../../../../fixtures', __dir__) }
7
+ let(:memory_path) { File.join(fixture_dir, 'sample_memory.md') }
8
+ let(:claude_path) { File.join(fixture_dir, 'sample_claude.md') }
9
+
10
+ describe '.detect_file_type' do
11
+ it 'detects MEMORY.md files' do
12
+ expect(described_class.detect_file_type('/some/path/MEMORY.md')).to eq(:memory)
13
+ end
14
+
15
+ it 'detects CLAUDE.md files' do
16
+ expect(described_class.detect_file_type('/some/path/CLAUDE.md')).to eq(:claude_md)
17
+ end
18
+
19
+ it 'returns :markdown for other files' do
20
+ expect(described_class.detect_file_type('/some/path/README.md')).to eq(:markdown)
21
+ end
22
+ end
23
+
24
+ describe '.classify_section' do
25
+ it 'maps Hard Rules to firmware' do
26
+ expect(described_class.classify_section('Hard Rules')).to eq(:firmware)
27
+ end
28
+
29
+ it 'maps architecture sections to semantic' do
30
+ expect(described_class.classify_section('Key Architecture Facts')).to eq(:semantic)
31
+ end
32
+
33
+ it 'maps gotchas to procedural' do
34
+ expect(described_class.classify_section('CLI Gotchas')).to eq(:procedural)
35
+ end
36
+
37
+ it 'maps identity sections to identity' do
38
+ expect(described_class.classify_section('Identity Auth Pattern')).to eq(:identity)
39
+ end
40
+
41
+ it 'defaults to semantic for unknown sections' do
42
+ expect(described_class.classify_section('Random Stuff')).to eq(:semantic)
43
+ end
44
+
45
+ it 'maps development sections to procedural' do
46
+ expect(described_class.classify_section('Development')).to eq(:procedural)
47
+ end
48
+
49
+ it 'maps API sections to procedural' do
50
+ expect(described_class.classify_section('REST API Routes')).to eq(:procedural)
51
+ end
52
+ end
53
+
54
+ describe '.split_sections' do
55
+ it 'splits on ## headers' do
56
+ content = "## First\nline1\n## Second\nline2\n"
57
+ sections = described_class.split_sections(content)
58
+ expect(sections.size).to eq(2)
59
+ expect(sections[0][:heading]).to eq('First')
60
+ expect(sections[1][:heading]).to eq('Second')
61
+ end
62
+
63
+ it 'creates slugs from headings' do
64
+ content = "## Key Architecture Facts\nstuff\n"
65
+ sections = described_class.split_sections(content)
66
+ expect(sections[0][:heading_slug]).to eq('key-architecture-facts')
67
+ end
68
+
69
+ it 'skips empty sections' do
70
+ content = "## Empty\n\n## HasContent\nreal stuff\n"
71
+ sections = described_class.split_sections(content)
72
+ expect(sections.size).to eq(1)
73
+ expect(sections[0][:heading]).to eq('HasContent')
74
+ end
75
+ end
76
+
77
+ describe '.extract_items' do
78
+ it 'extracts bullet points as individual items' do
79
+ body = "- first item\n- second item\n- third item\n"
80
+ items = described_class.extract_items(body)
81
+ expect(items).to eq(['first item', 'second item', 'third item'])
82
+ end
83
+
84
+ it 'merges continuation lines into bullets' do
85
+ body = "- first item\n continued here\n- second item\n"
86
+ items = described_class.extract_items(body)
87
+ expect(items.size).to eq(2)
88
+ expect(items[0]).to include('continued here')
89
+ end
90
+
91
+ it 'keeps code blocks as single items' do
92
+ body = "- before\n```ruby\ncode here\nmore code\n```\n- after\n"
93
+ items = described_class.extract_items(body)
94
+ expect(items.any? { |i| i.include?('code here') }).to be true
95
+ end
96
+
97
+ it 'handles paragraphs' do
98
+ body = "This is a paragraph\nwith continuation.\n\nAnother paragraph.\n"
99
+ items = described_class.extract_items(body)
100
+ expect(items.size).to eq(2)
101
+ end
102
+ end
103
+
104
+ describe '.extract_inline_tags' do
105
+ it 'extracts backtick terms' do
106
+ tags = described_class.extract_inline_tags('Uses `legion-transport` for messaging')
107
+ expect(tags).to include('legion-transport')
108
+ end
109
+
110
+ it 'extracts bold terms' do
111
+ tags = described_class.extract_inline_tags('The **Runner** processes tasks')
112
+ expect(tags).to include('runner')
113
+ end
114
+
115
+ it 'rejects very short or very long tags' do
116
+ tags = described_class.extract_inline_tags("`x` and `a_very_long_tag_#{'x' * 60}`")
117
+ expect(tags).to be_empty
118
+ end
119
+ end
120
+
121
+ describe '.parse_file' do
122
+ it 'parses a MEMORY.md file into traces' do
123
+ traces = described_class.parse_file(memory_path)
124
+ expect(traces).to be_an(Array)
125
+ expect(traces).not_to be_empty
126
+ end
127
+
128
+ it 'assigns firmware type to Hard Rules' do
129
+ traces = described_class.parse_file(memory_path)
130
+ firmware = traces.select { |t| t[:trace_type] == :firmware }
131
+ expect(firmware).not_to be_empty
132
+ expect(firmware.first[:content_payload]).to include('production data')
133
+ end
134
+
135
+ it 'assigns procedural type to gotchas' do
136
+ traces = described_class.parse_file(memory_path)
137
+ procedural = traces.select { |t| t[:trace_type] == :procedural }
138
+ expect(procedural).not_to be_empty
139
+ expect(procedural.any? { |t| t[:content_payload].include?('Thor') }).to be true
140
+ end
141
+
142
+ it 'assigns identity type to identity sections' do
143
+ traces = described_class.parse_file(memory_path)
144
+ identity = traces.select { |t| t[:trace_type] == :identity }
145
+ expect(identity).not_to be_empty
146
+ end
147
+
148
+ it 'sets origin to :firmware for MEMORY.md files' do
149
+ traces = described_class.parse_file(memory_path)
150
+ # All traces from MEMORY.md get :firmware origin (imprint source)
151
+ expect(traces.all? { |t| t[:origin] == :firmware }).to be true
152
+ end
153
+
154
+ it 'sets origin to :direct_experience for CLAUDE.md files' do
155
+ traces = described_class.parse_file(claude_path)
156
+ expect(traces.all? { |t| t[:origin] == :direct_experience }).to be true
157
+ end
158
+
159
+ it 'sets confidence 1.0 for firmware traces' do
160
+ traces = described_class.parse_file(memory_path)
161
+ firmware = traces.select { |t| t[:trace_type] == :firmware }
162
+ expect(firmware.all? { |t| t[:confidence] == 1.0 }).to be true # rubocop:disable Lint/FloatComparison
163
+ end
164
+
165
+ it 'includes source_file in each trace' do
166
+ traces = described_class.parse_file(memory_path)
167
+ expect(traces.all? { |t| t[:source_file] == memory_path }).to be true
168
+ end
169
+
170
+ it 'includes domain tags' do
171
+ traces = described_class.parse_file(memory_path)
172
+ expect(traces.all? { |t| t[:domain_tags].is_a?(Array) && !t[:domain_tags].empty? }).to be true
173
+ end
174
+
175
+ it 'parses a CLAUDE.md file into traces' do
176
+ traces = described_class.parse_file(claude_path)
177
+ expect(traces).not_to be_empty
178
+ semantic = traces.select { |t| t[:trace_type] == :semantic }
179
+ expect(semantic).not_to be_empty
180
+ end
181
+ end
182
+
183
+ describe '.parse_directory' do
184
+ it 'finds and parses all matching files' do
185
+ traces = described_class.parse_directory(fixture_dir, pattern: '*.md')
186
+ expect(traces).not_to be_empty
187
+ source_files = traces.map { |t| t[:source_file] }.uniq
188
+ expect(source_files.size).to be >= 2
189
+ end
190
+ end
191
+
192
+ describe '.skip_path?' do
193
+ it 'skips _deprecated paths' do
194
+ expect(described_class.skip_path?('/foo/_deprecated/CLAUDE.md')).to be true
195
+ end
196
+
197
+ it 'skips references paths' do
198
+ expect(described_class.skip_path?('/foo/references/CLAUDE.md')).to be true
199
+ end
200
+
201
+ it 'allows normal paths' do
202
+ expect(described_class.skip_path?('/foo/lex-memory/CLAUDE.md')).to be false
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/coldstart/client'
4
+
5
+ RSpec.describe Legion::Extensions::Coldstart::Runners::Coldstart do
6
+ let(:client) { Legion::Extensions::Coldstart::Client.new }
7
+
8
+ describe '#begin_imprint' do
9
+ it 'starts the imprint window' do
10
+ result = client.begin_imprint
11
+ expect(result[:started]).to be true
12
+ expect(result[:multiplier]).to eq(3.0)
13
+ end
14
+ end
15
+
16
+ describe '#record_observation' do
17
+ it 'increments observation count' do
18
+ client.begin_imprint
19
+ result = client.record_observation
20
+ expect(result[:observation_count]).to eq(1)
21
+ end
22
+
23
+ it 'transitions calibration state at baseline threshold' do
24
+ client.begin_imprint
25
+ 50.times { client.record_observation }
26
+ result = client.record_observation
27
+ expect(result[:calibration_state]).to eq(:baseline_established)
28
+ end
29
+ end
30
+
31
+ describe '#coldstart_progress' do
32
+ it 'reports progress' do
33
+ client.begin_imprint
34
+ result = client.coldstart_progress
35
+ expect(result[:firmware_loaded]).to be true
36
+ expect(result[:imprint_active]).to be true
37
+ expect(result[:current_layer]).to eq(:imprint_window)
38
+ end
39
+ end
40
+
41
+ describe '#imprint_active?' do
42
+ it 'returns false before begin' do
43
+ result = client.imprint_active?
44
+ expect(result[:active]).to be false
45
+ end
46
+
47
+ it 'returns true after begin' do
48
+ client.begin_imprint
49
+ result = client.imprint_active?
50
+ expect(result[:active]).to be true
51
+ end
52
+ end
53
+
54
+ describe '#current_multiplier' do
55
+ it 'returns 1.0 when not imprinting' do
56
+ result = client.current_multiplier
57
+ expect(result[:multiplier]).to eq(1.0)
58
+ end
59
+
60
+ it 'returns 3.0 during imprint' do
61
+ client.begin_imprint
62
+ result = client.current_multiplier
63
+ expect(result[:multiplier]).to eq(3.0)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Coldstart::Runners::Ingest do
6
+ let(:runner) { Object.new.extend(described_class) }
7
+ let(:fixture_dir) { File.expand_path('../../../../fixtures', __dir__) }
8
+ let(:memory_path) { File.join(fixture_dir, 'sample_memory.md') }
9
+ let(:claude_path) { File.join(fixture_dir, 'sample_claude.md') }
10
+
11
+ describe '#ingest_file' do
12
+ it 'parses a MEMORY.md file' do
13
+ result = runner.ingest_file(file_path: memory_path, store_traces: false)
14
+ expect(result[:file]).to eq(memory_path)
15
+ expect(result[:file_type]).to eq(:memory)
16
+ expect(result[:traces_parsed]).to be > 0
17
+ end
18
+
19
+ it 'parses a CLAUDE.md file' do
20
+ result = runner.ingest_file(file_path: claude_path, store_traces: false)
21
+ expect(result[:file]).to eq(claude_path)
22
+ expect(result[:file_type]).to eq(:claude_md)
23
+ expect(result[:traces_parsed]).to be > 0
24
+ end
25
+
26
+ it 'returns error for missing file' do
27
+ result = runner.ingest_file(file_path: '/nonexistent/file.md')
28
+ expect(result[:error]).to eq('file not found')
29
+ end
30
+
31
+ it 'returns traces when store_traces is false' do
32
+ result = runner.ingest_file(file_path: memory_path, store_traces: false)
33
+ expect(result[:traces]).to be_an(Array)
34
+ expect(result[:traces]).not_to be_empty
35
+ expect(result[:traces].first).to have_key(:trace_type)
36
+ end
37
+
38
+ it 'includes firmware traces from Hard Rules' do
39
+ result = runner.ingest_file(file_path: memory_path, store_traces: false)
40
+ firmware = result[:traces].select { |t| t[:trace_type] == :firmware }
41
+ expect(firmware.size).to eq(2)
42
+ end
43
+ end
44
+
45
+ describe '#ingest_directory' do
46
+ it 'finds and parses files in a directory' do
47
+ result = runner.ingest_directory(dir_path: fixture_dir, pattern: '*.md', store_traces: false)
48
+ expect(result[:files_found]).to be >= 2
49
+ expect(result[:total_parsed]).to be > 0
50
+ end
51
+
52
+ it 'returns error for missing directory' do
53
+ result = runner.ingest_directory(dir_path: '/nonexistent/dir')
54
+ expect(result[:error]).to eq('directory not found')
55
+ end
56
+
57
+ it 'lists processed files' do
58
+ result = runner.ingest_directory(dir_path: fixture_dir, pattern: '*.md', store_traces: false)
59
+ expect(result[:files]).to be_an(Array)
60
+ expect(result[:files].any? { |f| f.include?('sample_memory') }).to be true
61
+ end
62
+ end
63
+
64
+ describe '#preview_ingest' do
65
+ it 'returns traces without storing' do
66
+ result = runner.preview_ingest(file_path: memory_path)
67
+ expect(result[:traces]).to be_an(Array)
68
+ expect(result[:traces]).not_to be_empty
69
+ expect(result[:file_type]).to eq(:memory)
70
+ end
71
+
72
+ it 'returns error for missing file' do
73
+ result = runner.preview_ingest(file_path: '/nonexistent/file.md')
74
+ expect(result[:error]).to eq('file not found')
75
+ end
76
+ end
77
+ end