aidp 0.5.0 → 0.8.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +128 -151
  3. data/bin/aidp +1 -1
  4. data/lib/aidp/analysis/kb_inspector.rb +471 -0
  5. data/lib/aidp/analysis/seams.rb +159 -0
  6. data/lib/aidp/analysis/tree_sitter_grammar_loader.rb +480 -0
  7. data/lib/aidp/analysis/tree_sitter_scan.rb +686 -0
  8. data/lib/aidp/analyze/error_handler.rb +2 -78
  9. data/lib/aidp/analyze/json_file_storage.rb +292 -0
  10. data/lib/aidp/analyze/progress.rb +12 -0
  11. data/lib/aidp/analyze/progress_visualizer.rb +12 -17
  12. data/lib/aidp/analyze/ruby_maat_integration.rb +13 -31
  13. data/lib/aidp/analyze/runner.rb +256 -87
  14. data/lib/aidp/analyze/steps.rb +6 -0
  15. data/lib/aidp/cli/jobs_command.rb +103 -435
  16. data/lib/aidp/cli.rb +317 -191
  17. data/lib/aidp/config.rb +298 -10
  18. data/lib/aidp/debug_logger.rb +195 -0
  19. data/lib/aidp/debug_mixin.rb +187 -0
  20. data/lib/aidp/execute/progress.rb +9 -0
  21. data/lib/aidp/execute/runner.rb +221 -40
  22. data/lib/aidp/execute/steps.rb +17 -7
  23. data/lib/aidp/execute/workflow_selector.rb +211 -0
  24. data/lib/aidp/harness/completion_checker.rb +268 -0
  25. data/lib/aidp/harness/condition_detector.rb +1526 -0
  26. data/lib/aidp/harness/config_loader.rb +373 -0
  27. data/lib/aidp/harness/config_manager.rb +382 -0
  28. data/lib/aidp/harness/config_schema.rb +1006 -0
  29. data/lib/aidp/harness/config_validator.rb +355 -0
  30. data/lib/aidp/harness/configuration.rb +477 -0
  31. data/lib/aidp/harness/enhanced_runner.rb +494 -0
  32. data/lib/aidp/harness/error_handler.rb +616 -0
  33. data/lib/aidp/harness/provider_config.rb +423 -0
  34. data/lib/aidp/harness/provider_factory.rb +306 -0
  35. data/lib/aidp/harness/provider_manager.rb +1269 -0
  36. data/lib/aidp/harness/provider_type_checker.rb +88 -0
  37. data/lib/aidp/harness/runner.rb +411 -0
  38. data/lib/aidp/harness/state/errors.rb +28 -0
  39. data/lib/aidp/harness/state/metrics.rb +219 -0
  40. data/lib/aidp/harness/state/persistence.rb +128 -0
  41. data/lib/aidp/harness/state/provider_state.rb +132 -0
  42. data/lib/aidp/harness/state/ui_state.rb +68 -0
  43. data/lib/aidp/harness/state/workflow_state.rb +123 -0
  44. data/lib/aidp/harness/state_manager.rb +586 -0
  45. data/lib/aidp/harness/status_display.rb +888 -0
  46. data/lib/aidp/harness/ui/base.rb +16 -0
  47. data/lib/aidp/harness/ui/enhanced_tui.rb +545 -0
  48. data/lib/aidp/harness/ui/enhanced_workflow_selector.rb +252 -0
  49. data/lib/aidp/harness/ui/error_handler.rb +132 -0
  50. data/lib/aidp/harness/ui/frame_manager.rb +361 -0
  51. data/lib/aidp/harness/ui/job_monitor.rb +500 -0
  52. data/lib/aidp/harness/ui/navigation/main_menu.rb +311 -0
  53. data/lib/aidp/harness/ui/navigation/menu_formatter.rb +120 -0
  54. data/lib/aidp/harness/ui/navigation/menu_item.rb +142 -0
  55. data/lib/aidp/harness/ui/navigation/menu_state.rb +139 -0
  56. data/lib/aidp/harness/ui/navigation/submenu.rb +202 -0
  57. data/lib/aidp/harness/ui/navigation/workflow_selector.rb +176 -0
  58. data/lib/aidp/harness/ui/progress_display.rb +280 -0
  59. data/lib/aidp/harness/ui/question_collector.rb +141 -0
  60. data/lib/aidp/harness/ui/spinner_group.rb +184 -0
  61. data/lib/aidp/harness/ui/spinner_helper.rb +152 -0
  62. data/lib/aidp/harness/ui/status_manager.rb +312 -0
  63. data/lib/aidp/harness/ui/status_widget.rb +280 -0
  64. data/lib/aidp/harness/ui/workflow_controller.rb +312 -0
  65. data/lib/aidp/harness/user_interface.rb +2381 -0
  66. data/lib/aidp/provider_manager.rb +131 -7
  67. data/lib/aidp/providers/anthropic.rb +28 -109
  68. data/lib/aidp/providers/base.rb +170 -0
  69. data/lib/aidp/providers/cursor.rb +52 -183
  70. data/lib/aidp/providers/gemini.rb +24 -109
  71. data/lib/aidp/providers/macos_ui.rb +99 -5
  72. data/lib/aidp/providers/opencode.rb +194 -0
  73. data/lib/aidp/storage/csv_storage.rb +172 -0
  74. data/lib/aidp/storage/file_manager.rb +214 -0
  75. data/lib/aidp/storage/json_storage.rb +140 -0
  76. data/lib/aidp/version.rb +1 -1
  77. data/lib/aidp.rb +56 -35
  78. data/templates/ANALYZE/06a_tree_sitter_scan.md +217 -0
  79. data/templates/COMMON/AGENT_BASE.md +11 -0
  80. data/templates/EXECUTE/00_PRD.md +4 -4
  81. data/templates/EXECUTE/02_ARCHITECTURE.md +5 -4
  82. data/templates/EXECUTE/07_TEST_PLAN.md +4 -1
  83. data/templates/EXECUTE/08_TASKS.md +4 -4
  84. data/templates/EXECUTE/10_IMPLEMENTATION_AGENT.md +4 -4
  85. data/templates/README.md +279 -0
  86. data/templates/aidp-development.yml.example +373 -0
  87. data/templates/aidp-minimal.yml.example +48 -0
  88. data/templates/aidp-production.yml.example +475 -0
  89. data/templates/aidp.yml.example +598 -0
  90. metadata +106 -64
  91. data/lib/aidp/analyze/agent_personas.rb +0 -71
  92. data/lib/aidp/analyze/agent_tool_executor.rb +0 -445
  93. data/lib/aidp/analyze/data_retention_manager.rb +0 -426
  94. data/lib/aidp/analyze/database.rb +0 -260
  95. data/lib/aidp/analyze/dependencies.rb +0 -335
  96. data/lib/aidp/analyze/export_manager.rb +0 -425
  97. data/lib/aidp/analyze/focus_guidance.rb +0 -517
  98. data/lib/aidp/analyze/incremental_analyzer.rb +0 -543
  99. data/lib/aidp/analyze/language_analysis_strategies.rb +0 -897
  100. data/lib/aidp/analyze/large_analysis_progress.rb +0 -504
  101. data/lib/aidp/analyze/memory_manager.rb +0 -365
  102. data/lib/aidp/analyze/metrics_storage.rb +0 -336
  103. data/lib/aidp/analyze/parallel_processor.rb +0 -460
  104. data/lib/aidp/analyze/performance_optimizer.rb +0 -694
  105. data/lib/aidp/analyze/repository_chunker.rb +0 -704
  106. data/lib/aidp/analyze/static_analysis_detector.rb +0 -577
  107. data/lib/aidp/analyze/storage.rb +0 -662
  108. data/lib/aidp/analyze/tool_configuration.rb +0 -456
  109. data/lib/aidp/analyze/tool_modernization.rb +0 -750
  110. data/lib/aidp/database/pg_adapter.rb +0 -148
  111. data/lib/aidp/database_config.rb +0 -69
  112. data/lib/aidp/database_connection.rb +0 -72
  113. data/lib/aidp/database_migration.rb +0 -158
  114. data/lib/aidp/job_manager.rb +0 -41
  115. data/lib/aidp/jobs/base_job.rb +0 -47
  116. data/lib/aidp/jobs/provider_execution_job.rb +0 -96
  117. data/lib/aidp/project_detector.rb +0 -117
  118. data/lib/aidp/providers/agent_supervisor.rb +0 -348
  119. data/lib/aidp/providers/supervised_base.rb +0 -317
  120. data/lib/aidp/providers/supervised_cursor.rb +0 -22
  121. data/lib/aidp/sync.rb +0 -13
  122. data/lib/aidp/workspace.rb +0 -19
@@ -0,0 +1,686 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "digest"
6
+ require "etc"
7
+
8
+ require_relative "tree_sitter_grammar_loader"
9
+ require_relative "seams"
10
+
11
+ module Aidp
12
+ module Analysis
13
+ class TreeSitterScan
14
+ def initialize(root: Dir.pwd, kb_dir: ".aidp/kb", langs: %w[ruby], threads: Etc.nprocessors)
15
+ @root = File.expand_path(root)
16
+ @kb_dir = File.expand_path(kb_dir, @root)
17
+ @langs = Array(langs)
18
+ @threads = threads
19
+ @grammar_loader = TreeSitterGrammarLoader.new(@root)
20
+
21
+ # Data structures to accumulate analysis results
22
+ @symbols = []
23
+ @imports = []
24
+ @calls = []
25
+ @metrics = []
26
+ @seams = []
27
+ @hotspots = []
28
+ @tests = []
29
+ @cycles = []
30
+
31
+ # Cache for parsed files
32
+ @cache = {}
33
+ @cache_file = File.join(@kb_dir, ".cache")
34
+ end
35
+
36
+ def run
37
+ puts "🔍 Starting Tree-sitter static analysis..."
38
+ puts "📁 Root: #{@root}"
39
+ puts "🗂️ KB Directory: #{@kb_dir}"
40
+ puts "🌐 Languages: #{@langs.join(", ")}"
41
+ puts "🧵 Threads: #{@threads}"
42
+
43
+ files = discover_files
44
+ puts "📄 Found #{files.length} files to analyze"
45
+
46
+ prepare_kb_dir
47
+ load_cache
48
+
49
+ parallel_parse(files)
50
+ write_kb_files
51
+
52
+ puts "✅ Tree-sitter analysis complete!"
53
+ puts "📊 Generated KB files in #{@kb_dir}"
54
+ end
55
+
56
+ private
57
+
58
+ def discover_files
59
+ files = []
60
+
61
+ @langs.each do |lang|
62
+ patterns = @grammar_loader.file_patterns_for_language(lang)
63
+ patterns.each do |pattern|
64
+ files.concat(Dir.glob(File.join(@root, pattern)))
65
+ end
66
+ end
67
+
68
+ # Filter out files that should be ignored
69
+ files = filter_ignored_files(files)
70
+
71
+ # Sort for consistent processing
72
+ files.sort
73
+ end
74
+
75
+ def filter_ignored_files(files)
76
+ # Respect .gitignore
77
+ gitignore_path = File.join(@root, ".gitignore")
78
+ ignored_patterns = []
79
+
80
+ if File.exist?(gitignore_path)
81
+ File.readlines(gitignore_path).each do |line|
82
+ line = line.strip
83
+ next if line.empty? || line.start_with?("#")
84
+
85
+ # Convert gitignore patterns to glob patterns
86
+ pattern = convert_gitignore_to_glob(line)
87
+ ignored_patterns << pattern
88
+ end
89
+ end
90
+
91
+ # Add common ignore patterns
92
+ ignored_patterns.concat([
93
+ "**/.git/**", "**/node_modules/**", "**/vendor/**",
94
+ "**/tmp/**", "tmp/**", "**/log/**", "log/**", "**/.aidp/**"
95
+ ])
96
+
97
+ files.reject do |file|
98
+ relative_path = file.sub(/^#{Regexp.escape(@root)}\/?/, "")
99
+ ignored_patterns.any? { |pattern| File.fnmatch?(pattern, relative_path) }
100
+ end
101
+ end
102
+
103
+ # Convert gitignore patterns to Ruby glob patterns
104
+ def convert_gitignore_to_glob(gitignore_pattern)
105
+ # Handle different gitignore pattern types
106
+ case gitignore_pattern
107
+ when /^\//
108
+ # Absolute path from root: /foo -> foo
109
+ gitignore_pattern[1..]
110
+ when /\/$/
111
+ # Directory only: foo/ -> **/foo/**
112
+ "**/" + gitignore_pattern.chomp("/") + "/**"
113
+ when /\*\*/
114
+ # Already contains **: leave as-is for glob
115
+ gitignore_pattern
116
+ else
117
+ # Regular pattern: foo -> **/foo
118
+ if gitignore_pattern.include?("/")
119
+ # Contains path separator: keep relative structure
120
+ gitignore_pattern
121
+ else
122
+ # Simple filename: match anywhere
123
+ "**/" + gitignore_pattern
124
+ end
125
+ end
126
+ end
127
+
128
+ def prepare_kb_dir
129
+ FileUtils.mkdir_p(@kb_dir)
130
+ end
131
+
132
+ def load_cache
133
+ return unless File.exist?(@cache_file)
134
+
135
+ begin
136
+ @cache = JSON.parse(File.read(@cache_file), symbolize_names: true)
137
+ rescue JSON::ParserError
138
+ @cache = {}
139
+ end
140
+ end
141
+
142
+ def save_cache
143
+ File.write(@cache_file, JSON.pretty_generate(@cache))
144
+ end
145
+
146
+ def parallel_parse(files)
147
+ puts "🔄 Parsing files in parallel..."
148
+
149
+ # Group files by language for efficient processing
150
+ files_by_lang = files.group_by { |file| detect_language(file) }
151
+
152
+ # Process each language group
153
+ files_by_lang.each do |lang, lang_files|
154
+ puts "📝 Processing #{lang_files.length} #{lang} files..."
155
+
156
+ # Load grammar for this language
157
+ grammar = @grammar_loader.load_grammar(lang)
158
+
159
+ # Process files synchronously (simplified)
160
+ lang_files.each do |file|
161
+ parse_file(file, grammar)
162
+ end
163
+ end
164
+
165
+ save_cache
166
+ end
167
+
168
+ def detect_language(file_path)
169
+ case File.extname(file_path)
170
+ when ".rb"
171
+ "ruby"
172
+ when ".js", ".jsx"
173
+ "javascript"
174
+ when ".ts", ".tsx"
175
+ "typescript"
176
+ when ".py"
177
+ "python"
178
+ else
179
+ "unknown"
180
+ end
181
+ end
182
+
183
+ def parse_file(file_path, grammar)
184
+ relative_path = file_path.sub(@root + File::SEPARATOR, "")
185
+
186
+ # Check cache first
187
+ cache_key = relative_path
188
+ file_mtime = File.mtime(file_path).to_i
189
+
190
+ if @cache[cache_key] && @cache[cache_key][:mtime] == file_mtime
191
+ # Use cached results
192
+ cached_data = @cache[cache_key][:data]
193
+ merge_cached_data(cached_data)
194
+ return
195
+ end
196
+
197
+ # Parse the file
198
+ # Set current file for context in helper methods
199
+ @current_file_path = file_path
200
+
201
+ source_code = File.read(file_path)
202
+ ast = grammar[:parser][:parse].call(source_code)
203
+
204
+ # Extract data from AST
205
+ file_data = extract_file_data(file_path, ast, source_code)
206
+
207
+ # Cache the results
208
+ @cache[cache_key] = {
209
+ mtime: file_mtime,
210
+ data: file_data
211
+ }
212
+
213
+ # Merge into global data structures
214
+ merge_file_data(file_data)
215
+ end
216
+
217
+ def extract_file_data(file_path, ast, source_code)
218
+ relative_path = file_path.sub(@root + File::SEPARATOR, "")
219
+
220
+ {
221
+ symbols: extract_symbols(ast, relative_path),
222
+ imports: extract_imports(ast, relative_path),
223
+ calls: extract_calls(ast, relative_path),
224
+ metrics: calculate_metrics(ast, source_code, relative_path),
225
+ seams: extract_seams(ast, relative_path)
226
+ }
227
+ end
228
+
229
+ def extract_symbols(ast, file_path)
230
+ symbols = []
231
+
232
+ children = ast[:children] || []
233
+ children = children.is_a?(Array) ? children : []
234
+
235
+ children.each do |node|
236
+ case node[:type].to_s
237
+ when "class"
238
+ symbols << {
239
+ id: "#{file_path}:#{node[:line]}:#{node[:name]}",
240
+ file: file_path,
241
+ line: node[:line],
242
+ kind: "class",
243
+ name: node[:name],
244
+ visibility: "public",
245
+ arity: 0,
246
+ loc: {
247
+ start_line: node[:line],
248
+ end_line: node[:line],
249
+ start_column: node[:start_column],
250
+ end_column: node[:end_column]
251
+ },
252
+ nesting_depth: calculate_nesting_depth(node)
253
+ }
254
+ when "module"
255
+ symbols << {
256
+ id: "#{file_path}:#{node[:line]}:#{node[:name]}",
257
+ file: file_path,
258
+ line: node[:line],
259
+ kind: "module",
260
+ name: node[:name],
261
+ visibility: "public",
262
+ arity: 0,
263
+ loc: {
264
+ start_line: node[:line],
265
+ end_line: node[:line],
266
+ start_column: node[:start_column],
267
+ end_column: node[:end_column]
268
+ },
269
+ nesting_depth: calculate_nesting_depth(node)
270
+ }
271
+ when "method"
272
+ symbols << {
273
+ id: "#{file_path}:#{node[:line]}:#{node[:name]}",
274
+ file: file_path,
275
+ line: node[:line],
276
+ kind: "method",
277
+ name: node[:name],
278
+ visibility: determine_method_visibility(node),
279
+ arity: calculate_method_arity(node),
280
+ loc: {
281
+ start_line: node[:line],
282
+ end_line: node[:line],
283
+ start_column: node[:start_column],
284
+ end_column: node[:end_column]
285
+ },
286
+ nesting_depth: calculate_nesting_depth(node)
287
+ }
288
+ end
289
+ end
290
+
291
+ symbols
292
+ end
293
+
294
+ def extract_imports(ast, file_path)
295
+ imports = []
296
+
297
+ children = ast[:children] || []
298
+ children = children.is_a?(Array) ? children : []
299
+
300
+ children.each do |node|
301
+ case node[:type].to_s
302
+ when "require"
303
+ imports << {
304
+ file: file_path,
305
+ kind: "require",
306
+ target: node[:target],
307
+ line: node[:line]
308
+ }
309
+ when "require_relative"
310
+ imports << {
311
+ file: file_path,
312
+ kind: "require_relative",
313
+ target: node[:target],
314
+ line: node[:line]
315
+ }
316
+ when "call"
317
+ # Handle require statements that are parsed as call nodes
318
+ # Check if this is a require call by looking at the first child (identifier)
319
+ # The children are nested in the structure
320
+ actual_children = (node[:children] && node[:children][:children]) ? node[:children][:children] : node[:children]
321
+ if actual_children&.is_a?(Array) && actual_children.first
322
+ first_child = actual_children.first
323
+ # Extract the actual identifier name from the source code
324
+ identifier_name = extract_identifier_name(first_child, file_path)
325
+ if identifier_name == "require"
326
+ # Extract the target from the argument list
327
+ target = extract_require_target(node)
328
+ if target
329
+ imports << {
330
+ file: file_path,
331
+ kind: "require",
332
+ target: target,
333
+ line: node[:line]
334
+ }
335
+ end
336
+ elsif identifier_name == "require_relative"
337
+ # Extract the target from the argument list
338
+ target = extract_require_target(node)
339
+ if target
340
+ imports << {
341
+ file: file_path,
342
+ kind: "require_relative",
343
+ target: target,
344
+ line: node[:line]
345
+ }
346
+ end
347
+ end
348
+ end
349
+ end
350
+ end
351
+
352
+ imports
353
+ end
354
+
355
+ def extract_require_target(node)
356
+ # Recursively search for string literals in the require call
357
+ find_string_in_node(node)
358
+ end
359
+
360
+ # Recursively find string content in a Tree-sitter node
361
+ def find_string_in_node(node)
362
+ return nil unless node.is_a?(Hash)
363
+
364
+ case node[:type].to_s
365
+ when "string"
366
+ # Found a string node - extract its content
367
+ return extract_string_literal_content(node)
368
+ when "string_content"
369
+ # Direct string content - extract text
370
+ return extract_node_text_from_source(node)
371
+ end
372
+
373
+ # Recursively search in children
374
+ children = get_node_children(node)
375
+ if children&.is_a?(Array)
376
+ children.each do |child|
377
+ result = find_string_in_node(child)
378
+ return result if result
379
+ end
380
+ end
381
+
382
+ nil
383
+ end
384
+
385
+ # Extract content from a string literal, handling quotes properly
386
+ def extract_string_literal_content(string_node)
387
+ # Get the full text of the string node including quotes
388
+ full_text = extract_node_text_from_source(string_node)
389
+ return nil unless full_text
390
+
391
+ # Remove quotes and handle escape sequences
392
+ case full_text[0]
393
+ when '"'
394
+ # Double-quoted string - handle escape sequences
395
+ content = full_text[1..-2] # Remove surrounding quotes
396
+ unescape_string(content)
397
+ when "'"
398
+ # Single-quoted string - minimal escaping
399
+ content = full_text[1..-2] # Remove surrounding quotes
400
+ content.gsub("\\'", "'").gsub("\\\\", "\\")
401
+ when "%"
402
+ # Percent notation strings (%q, %Q, %w, etc.)
403
+ handle_percent_string(full_text)
404
+ else
405
+ # Fallback: try to extract content between quotes
406
+ if (match = full_text.match(/^["'](.*)["']$/))
407
+ match[1]
408
+ else
409
+ full_text
410
+ end
411
+ end
412
+ end
413
+
414
+ # Handle Ruby's percent notation strings
415
+ def handle_percent_string(text)
416
+ return text unless text.start_with?("%")
417
+
418
+ case text[1]
419
+ when "q", "Q"
420
+ # %q{...} or %Q{...}
421
+ delimiter = text[2]
422
+ closing_delimiter = get_closing_delimiter(delimiter)
423
+ content = text[3..-(closing_delimiter.length + 1)]
424
+ (text[1] == "Q") ? unescape_string(content) : content
425
+ else
426
+ text
427
+ end
428
+ end
429
+
430
+ # Get closing delimiter for percent strings
431
+ def get_closing_delimiter(opening)
432
+ case opening
433
+ when "(" then ")"
434
+ when "[" then "]"
435
+ when "{" then "}"
436
+ when "<" then ">"
437
+ else; opening
438
+ end
439
+ end
440
+
441
+ # Unescape common Ruby escape sequences
442
+ def unescape_string(str)
443
+ str.gsub("\\n", "\n")
444
+ .gsub("\\t", "\t")
445
+ .gsub("\\r", "\r")
446
+ .gsub('\"', '"')
447
+ .gsub("\\\\", "\\")
448
+ end
449
+
450
+ # Safely extract children from a node, handling nested structures
451
+ def get_node_children(node)
452
+ if node[:children].is_a?(Hash) && node[:children][:children]
453
+ node[:children][:children]
454
+ else
455
+ node[:children]
456
+ end
457
+ end
458
+
459
+ def extract_identifier_name(identifier_node, file_path)
460
+ # Extract the actual identifier name from the source code using node position
461
+ extract_node_text_from_source(identifier_node, file_path)
462
+ end
463
+
464
+ # Generalized method to extract text from any Tree-sitter node using source position
465
+ def extract_node_text_from_source(node, file_path = nil)
466
+ return nil unless node&.dig(:start_line) && node.dig(:end_line)
467
+ return nil unless node&.dig(:start_column) && node.dig(:end_column)
468
+
469
+ # Get source file path - either from parameter or from current parsing context
470
+ source_file = file_path ? File.join(@root, file_path) : @current_file_path
471
+ return nil unless source_file && File.exist?(source_file)
472
+
473
+ # Read source lines
474
+ source_lines = File.readlines(source_file)
475
+
476
+ start_line = node[:start_line]
477
+ end_line = node[:end_line]
478
+ start_col = node[:start_column]
479
+ end_col = node[:end_column]
480
+
481
+ # Handle single line nodes
482
+ if start_line == end_line
483
+ line_content = source_lines[start_line - 1] || ""
484
+ return line_content[start_col...end_col]
485
+ end
486
+
487
+ # Handle multi-line nodes
488
+ result = ""
489
+ (start_line..end_line).each do |line_num|
490
+ line_content = source_lines[line_num - 1] || ""
491
+
492
+ result += if line_num == start_line
493
+ # First line: from start_col to end
494
+ line_content[start_col..] || ""
495
+ elsif line_num == end_line
496
+ # Last line: from start to end_col
497
+ line_content[0...end_col] || ""
498
+ else
499
+ # Middle lines: entire line
500
+ line_content
501
+ end
502
+ end
503
+
504
+ result
505
+ end
506
+
507
+ def extract_calls(_ast, _file_path)
508
+ []
509
+
510
+ # This would extract method calls from the AST
511
+ # For now, return empty array
512
+ end
513
+
514
+ def calculate_metrics(ast, source_code, file_path)
515
+ metrics = []
516
+
517
+ ast[:children]&.each do |node|
518
+ if node[:type] == "method"
519
+ method_metrics = {
520
+ symbol_id: "#{file_path}:#{node[:line]}:#{node[:name]}",
521
+ file: file_path,
522
+ method: node[:name],
523
+ cyclomatic_proxy: calculate_cyclomatic_complexity(node),
524
+ branch_count: count_branches(node),
525
+ max_nesting: calculate_max_nesting(node),
526
+ fan_out: calculate_fan_out(node),
527
+ lines: calculate_method_lines(node)
528
+ }
529
+ metrics << method_metrics
530
+ end
531
+ end
532
+
533
+ # Add file-level metrics
534
+ children = ast[:children] || []
535
+ children = children.is_a?(Array) ? children : []
536
+
537
+ file_metrics = {
538
+ file: file_path,
539
+ total_lines: source_code.lines.count,
540
+ total_methods: children.count { |n| n[:type].to_s == "method" },
541
+ total_classes: children.count { |n| n[:type].to_s == "class" },
542
+ total_modules: children.count { |n| n[:type].to_s == "module" }
543
+ }
544
+ metrics << file_metrics
545
+
546
+ metrics
547
+ end
548
+
549
+ def extract_seams(ast, file_path)
550
+ children = ast[:children] || []
551
+ children = children.is_a?(Array) ? children : []
552
+ Seams.detect_seams_in_ast(children, file_path)
553
+ end
554
+
555
+ def calculate_nesting_depth(_node)
556
+ # Simple nesting depth calculation
557
+ # In practice, this would analyze the actual AST structure
558
+ 0
559
+ end
560
+
561
+ def determine_method_visibility(_node)
562
+ # Determine method visibility based on context
563
+ # In practice, this would analyze the AST structure
564
+ "public"
565
+ end
566
+
567
+ def calculate_method_arity(_node)
568
+ # Calculate method arity from parameters
569
+ # In practice, this would analyze the method's parameter list
570
+ 0
571
+ end
572
+
573
+ def calculate_cyclomatic_complexity(node)
574
+ # Calculate cyclomatic complexity proxy
575
+ # Count control flow statements
576
+ count_branches(node) + 1
577
+ end
578
+
579
+ def count_branches(_node)
580
+ # Count branching statements in the method
581
+ # This would analyze the method's AST for if/elsif/else/case/when/while/until/rescue
582
+ 0
583
+ end
584
+
585
+ def calculate_max_nesting(_node)
586
+ # Calculate maximum nesting depth in the method
587
+ 0
588
+ end
589
+
590
+ def calculate_fan_out(_node)
591
+ # Calculate fan-out (number of distinct method calls)
592
+ 0
593
+ end
594
+
595
+ def calculate_method_lines(_node)
596
+ # Calculate lines of code in the method
597
+ 1
598
+ end
599
+
600
+ def merge_cached_data(cached_data)
601
+ @symbols.concat(cached_data[:symbols] || [])
602
+ @imports.concat(cached_data[:imports] || [])
603
+ @calls.concat(cached_data[:calls] || [])
604
+ @metrics.concat(cached_data[:metrics] || [])
605
+ @seams.concat(cached_data[:seams] || [])
606
+ end
607
+
608
+ def merge_file_data(file_data)
609
+ @symbols.concat(file_data[:symbols])
610
+ @imports.concat(file_data[:imports])
611
+ @calls.concat(file_data[:calls])
612
+ @metrics.concat(file_data[:metrics])
613
+ @seams.concat(file_data[:seams])
614
+ end
615
+
616
+ def write_kb_files
617
+ puts "💾 Writing knowledge base files..."
618
+
619
+ prepare_kb_dir
620
+
621
+ write_json_file("symbols.json", @symbols)
622
+ write_json_file("imports.json", @imports)
623
+ write_json_file("calls.json", @calls)
624
+ write_json_file("metrics.json", @metrics)
625
+ write_json_file("seams.json", @seams)
626
+
627
+ # Generate derived data
628
+ generate_hotspots
629
+ generate_tests
630
+ generate_cycles
631
+
632
+ write_json_file("hotspots.json", @hotspots)
633
+ write_json_file("tests.json", @tests)
634
+ write_json_file("cycles.json", @cycles)
635
+ end
636
+
637
+ def write_json_file(filename, data)
638
+ file_path = File.join(@kb_dir, filename)
639
+ File.write(file_path, JSON.pretty_generate(data))
640
+ puts "📄 Written #{filename} (#{data.length} entries)"
641
+ end
642
+
643
+ def generate_hotspots
644
+ # Merge structural metrics with git churn data
645
+ # Generate hotspots based on complexity and change frequency
646
+ @hotspots = @metrics.select { |m| m[:symbol_id] }
647
+ .map do |metric|
648
+ {
649
+ symbol_id: metric[:symbol_id],
650
+ score: (metric[:cyclomatic_proxy] || 1) * (metric[:fan_out] || 1),
651
+ complexity: metric[:cyclomatic_proxy] || 1,
652
+ touches: 1, # This would come from git log analysis
653
+ file: metric[:file],
654
+ method: metric[:method]
655
+ }
656
+ end
657
+ .sort_by { |h| -h[:score] }
658
+ .first(20)
659
+ end
660
+
661
+ def generate_tests
662
+ # Map public APIs to tests based on naming conventions
663
+ public_methods = @symbols.select { |s| s[:kind] == "method" && s[:visibility] == "public" }
664
+
665
+ @tests = public_methods.map do |method|
666
+ {
667
+ symbol_id: method[:id],
668
+ tests: find_tests_for_method(method)
669
+ }
670
+ end
671
+ end
672
+
673
+ def generate_cycles
674
+ # Detect import cycles
675
+ # For now, return empty array
676
+ @cycles = []
677
+ end
678
+
679
+ def find_tests_for_method(_method)
680
+ # Find test files that might test this method
681
+ # This would analyze test file naming and content
682
+ []
683
+ end
684
+ end
685
+ end
686
+ end