rubyn-code 0.7.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -1
  3. data/lib/rubyn_code/agent/conversation.rb +11 -1
  4. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
  5. data/lib/rubyn_code/agent/llm_caller.rb +5 -1
  6. data/lib/rubyn_code/agent/loop.rb +57 -3
  7. data/lib/rubyn_code/agent/response_parser.rb +8 -0
  8. data/lib/rubyn_code/agent/system_prompt_builder.rb +3 -0
  9. data/lib/rubyn_code/agent/tool_processor.rb +10 -0
  10. data/lib/rubyn_code/autonomous/daemon.rb +1 -1
  11. data/lib/rubyn_code/cli/commands/context.rb +27 -0
  12. data/lib/rubyn_code/cli/commands/custom_command.rb +44 -2
  13. data/lib/rubyn_code/cli/commands/custom_loader.rb +36 -5
  14. data/lib/rubyn_code/cli/commands/effort.rb +47 -0
  15. data/lib/rubyn_code/cli/commands/export.rb +174 -0
  16. data/lib/rubyn_code/cli/commands/mcp.rb +32 -8
  17. data/lib/rubyn_code/cli/commands/resume.rb +97 -26
  18. data/lib/rubyn_code/cli/commands/think.rb +47 -0
  19. data/lib/rubyn_code/cli/first_run.rb +1 -1
  20. data/lib/rubyn_code/cli/mention_expander.rb +19 -0
  21. data/lib/rubyn_code/cli/repl.rb +31 -1
  22. data/lib/rubyn_code/cli/repl_commands.rb +1 -1
  23. data/lib/rubyn_code/cli/repl_setup.rb +8 -6
  24. data/lib/rubyn_code/config/defaults.rb +2 -1
  25. data/lib/rubyn_code/config/schema.json +5 -0
  26. data/lib/rubyn_code/config/settings.rb +4 -2
  27. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  28. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  29. data/lib/rubyn_code/index/codebase_index.rb +64 -3
  30. data/lib/rubyn_code/index/prism_extractor.rb +82 -0
  31. data/lib/rubyn_code/learning/injector.rb +1 -2
  32. data/lib/rubyn_code/llm/adapters/anthropic.rb +107 -17
  33. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
  34. data/lib/rubyn_code/llm/adapters/base.rb +2 -1
  35. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  36. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
  37. data/lib/rubyn_code/llm/client.rb +16 -3
  38. data/lib/rubyn_code/llm/image_reader.rb +60 -0
  39. data/lib/rubyn_code/llm/message_builder.rb +21 -1
  40. data/lib/rubyn_code/llm/model_router.rb +4 -4
  41. data/lib/rubyn_code/mcp/discovery.rb +93 -0
  42. data/lib/rubyn_code/memory/session_persistence.rb +1 -1
  43. data/lib/rubyn_code/observability/cost_calculator.rb +6 -3
  44. data/lib/rubyn_code/protocols/RUBYN.md +0 -3
  45. data/lib/rubyn_code/tasks/models.rb +0 -16
  46. data/lib/rubyn_code/teams/teammate.rb +0 -15
  47. data/lib/rubyn_code/tools/RUBYN.md +2 -2
  48. data/lib/rubyn_code/tools/bash.rb +3 -3
  49. data/lib/rubyn_code/tools/code_graph.rb +134 -0
  50. data/lib/rubyn_code/tools/executor.rb +4 -1
  51. data/lib/rubyn_code/tools/todo_store.rb +55 -0
  52. data/lib/rubyn_code/tools/todo_write.rb +88 -0
  53. data/lib/rubyn_code/version.rb +1 -1
  54. data/lib/rubyn_code.rb +13 -8
  55. data/skills/rubyn_self_test.md +140 -0
  56. metadata +10 -7
  57. data/lib/rubyn_code/context/context_budget.rb +0 -183
  58. data/lib/rubyn_code/context/schema_filter.rb +0 -64
  59. data/lib/rubyn_code/learning/shortcut.rb +0 -95
  60. data/lib/rubyn_code/llm/adapters/token_caching.rb +0 -54
  61. data/lib/rubyn_code/llm/streaming.rb +0 -10
  62. data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
@@ -8,37 +8,61 @@ module RubynCode
8
8
  def self.description = 'MCP server status'
9
9
 
10
10
  def execute(_args, ctx)
11
- configs = load_configs(ctx.project_root)
11
+ entries = load_entries(ctx.project_root)
12
12
 
13
- if configs.empty?
13
+ if entries.empty?
14
14
  ctx.renderer.info('No MCP servers configured.')
15
15
  puts ' Add servers to .rubyn-code/mcp.json — see docs/MCP.md for details.'
16
16
  return
17
17
  end
18
18
 
19
- ctx.renderer.info("MCP servers (#{configs.size}):")
19
+ ctx.renderer.info("MCP servers (#{entries.size}):")
20
20
  puts
21
21
 
22
- configs.each { |cfg| render_server(cfg) }
22
+ entries.each { |entry| render_server(entry) }
23
23
  end
24
24
 
25
25
  private
26
26
 
27
- def load_configs(project_root)
28
- MCP::Config.load(project_root)
27
+ # Load merged user + project entries via MCP::Discovery so the
28
+ # output can show which servers came from `~/.rubyn-code/mcp.json`
29
+ # vs the project-root `.mcp.json`.
30
+ def load_entries(project_root)
31
+ MCP::Discovery.discover(project_root)
29
32
  end
30
33
 
31
- def render_server(cfg)
34
+ def source_label(source)
35
+ case source
36
+ when :project then '[project]'
37
+ when :user then '[user]'
38
+ else '[-]'
39
+ end
40
+ end
41
+
42
+ def render_server(entry)
43
+ cfg = entry_to_config(entry)
32
44
  client = build_client(cfg)
33
45
  status, counts = probe_server(client)
34
46
  icon = status_icon(status)
47
+ label = source_label(entry.respond_to?(:source) ? entry.source : :user)
35
48
 
36
- puts " #{icon} #{cfg[:name]} [#{status}]#{capability_label(counts)}"
49
+ puts " #{icon} #{entry.name} #{label} [#{status}]#{capability_label(counts)}"
37
50
  render_transport_info(cfg)
38
51
  ensure
39
52
  client&.disconnect! if client&.connected?
40
53
  end
41
54
 
55
+ # Discovery::Entry → Config hash shape so existing renderers work.
56
+ def entry_to_config(entry)
57
+ {
58
+ name: entry.name,
59
+ command: entry.command,
60
+ args: entry.args,
61
+ env: entry.env,
62
+ url: entry.url
63
+ }
64
+ end
65
+
42
66
  def capability_label(counts)
43
67
  return '' unless counts
44
68
 
@@ -1,48 +1,119 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
3
5
  module RubynCode
4
6
  module CLI
5
7
  module Commands
8
+ # List saved sessions or restore one into the current conversation.
9
+ #
10
+ # /resume # list sessions, recent first
11
+ # /resume <id> # load session <id> (prompts before clobber)
12
+ # /resume <id> --force # skip the confirm prompt
6
13
  class Resume < Base
7
14
  def self.command_name = '/resume'
8
- def self.description = 'Resume a session or list recent sessions'
15
+ def self.description = 'List saved sessions or restore one into the current conversation'
16
+
17
+ MAX_LIST_ENTRIES = 10
9
18
 
10
19
  def execute(args, ctx)
11
- session_id = args.first
20
+ persistence = ctx.session_persistence
21
+ if persistence.nil?
22
+ ctx.renderer.error('No session persistence wired in. Run a prompt first.')
23
+ return
24
+ end
12
25
 
13
- if session_id
14
- resume_session(session_id, ctx)
15
- else
16
- list_sessions(ctx)
26
+ opts = parse_args(args)
27
+
28
+ if opts[:id].nil?
29
+ list_sessions(persistence, ctx)
30
+ return
17
31
  end
32
+
33
+ restore_session(persistence, opts, ctx)
18
34
  end
19
35
 
20
36
  private
21
37
 
22
- def resume_session(session_id, ctx)
23
- data = ctx.session_persistence.load_session(session_id)
38
+ def parse_args(args)
39
+ args.delete('--force')
40
+ force = args.delete('-y')
41
+ { id: args.first, force: force }
42
+ end
43
+
44
+ def list_sessions(persistence, ctx)
45
+ rows = persistence.list_sessions
46
+ if rows.empty?
47
+ ctx.renderer.info('No saved sessions yet.')
48
+ return
49
+ end
50
+
51
+ ctx.renderer.info("Saved sessions (most recent first, up to #{MAX_LIST_ENTRIES}):")
52
+ rows.first(MAX_LIST_ENTRIES).each do |row|
53
+ count = row[:message_count].to_i
54
+ time = row[:last_activity]
55
+ title = row[:title].to_s.empty? ? '(untitled)' : row[:title]
56
+ sid = row[:session_id]
57
+ ctx.renderer.info(" #{sid} #{count} msg · #{format_time(time)} · #{title}")
58
+ end
59
+ end
60
+
61
+ def restore_session(persistence, opts, ctx)
62
+ loaded = persistence.load_session(opts[:id])
63
+ if loaded.nil?
64
+ ctx.renderer.error("Session not found: #{opts[:id]}")
65
+ return
66
+ end
67
+
68
+ messages = loaded[:messages] || []
69
+ if messages.empty?
70
+ ctx.renderer.warning("Session #{opts[:id]} has no messages — nothing to restore.")
71
+ return
72
+ end
24
73
 
25
- if data
26
- ctx.conversation.replace!(data[:messages])
27
- ctx.renderer.info("Resumed session #{session_id[0..7]}")
28
- { action: :set_session_id, session_id: session_id }
29
- else
30
- ctx.renderer.error("Session not found: #{session_id}")
74
+ unless confirm_replace(ctx, opts, loaded, messages)
75
+ ctx.renderer.info('Resume cancelled.')
76
+ return
31
77
  end
78
+
79
+ ctx.conversation.replace!(messages)
80
+ title = loaded[:title].to_s.empty? ? '(untitled)' : loaded[:title]
81
+ ctx.renderer.info("✓ Resumed session #{opts[:id]} — #{messages.size} messages from '#{title}'")
82
+ end
83
+
84
+ def confirm_replace(ctx, opts, loaded, messages)
85
+ return true if opts[:force]
86
+ return true if ctx.conversation.nil?
87
+ return true if ctx.conversation.messages.empty?
88
+
89
+ current = ctx.conversation.messages.size
90
+ target = messages.size
91
+ title = loaded[:title].to_s.empty? ? '(untitled)' : loaded[:title]
92
+ prompt = "Replace current conversation (#{current} messages) " \
93
+ "with session #{opts[:id]} '#{title}' (#{target} messages)? [y/N]"
94
+
95
+ return false unless ctx.renderer.respond_to?(:ask)
96
+
97
+ ctx.renderer.ask(prompt, default: false)
32
98
  end
33
99
 
34
- def list_sessions(ctx)
35
- sessions = ctx.session_persistence.list_sessions(
36
- project_path: ctx.project_root,
37
- limit: 10
38
- )
39
-
40
- if sessions.empty?
41
- ctx.renderer.info('No previous sessions.')
42
- else
43
- sessions.each do |s|
44
- puts " #{s[:id][0..7]} | #{s[:title] || 'untitled'} | #{s[:created_at]}"
45
- end
100
+ def format_time(time)
101
+ return 'never' if time.nil?
102
+
103
+ t = begin
104
+ time.is_a?(Time) ? time : Time.parse(time.to_s)
105
+ rescue StandardError
106
+ nil
107
+ end
108
+ return 'never' if t.nil?
109
+
110
+ delta = Time.now - t
111
+ case delta
112
+ when 0...60 then 'just now'
113
+ when 60...3600 then "#{delta.to_i / 60} min ago"
114
+ when 3600...86_400 then "#{delta.to_i / 3600} hr ago"
115
+ when 86_400...604_800 then "#{delta.to_i / 86_400} d ago"
116
+ else t.strftime('%Y-%m-%d')
46
117
  end
47
118
  end
48
119
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # Toggle extended thinking on/off and optionally set the budget.
7
+ #
8
+ # /think # show current budget
9
+ # /think off # disable
10
+ # /think <budget> # enable with <budget> tokens (e.g. /think 8192)
11
+ class Think < Base
12
+ DEFAULT_BUDGET = 8_192
13
+
14
+ def self.command_name = '/think'
15
+ def self.description = 'Toggle or set extended thinking budget (tokens)'
16
+
17
+ def execute(args, ctx)
18
+ arg = args.first
19
+
20
+ current = ctx.llm_client.respond_to?(:thinking_budget_tokens) ? ctx.llm_client.thinking_budget_tokens : 0
21
+
22
+ case arg
23
+ when nil
24
+ show_status(current, ctx)
25
+ when 'off', '0'
26
+ ctx.llm_client.thinking_budget_tokens = 0
27
+ ctx.renderer.info('Extended thinking OFF 🧠✋')
28
+ when /\A\d+\z/
29
+ budget = arg.to_i
30
+ ctx.llm_client.thinking_budget_tokens = budget
31
+ ctx.renderer.info("Extended thinking ON — budget: #{budget} tokens 🧠")
32
+ else
33
+ ctx.renderer.warning("Usage: /think [off|<budget>]. Got: #{arg}")
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def show_status(current, ctx)
40
+ state = current.to_i.positive? ? "ON (#{current} tokens)" : 'OFF'
41
+ ctx.renderer.info("Extended thinking: #{state}")
42
+ ctx.renderer.info('Usage: /think <budget> e.g. /think 8192 (or /think off)')
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -136,7 +136,7 @@ module RubynCode
136
136
  def default_model(provider)
137
137
  return 'gpt-5.4' if provider == 'openai'
138
138
 
139
- 'claude-opus-4-8'
139
+ 'claude-opus-5'
140
140
  end
141
141
 
142
142
  def display_summary
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'rubyn_code/llm/image_reader'
4
+
3
5
  module RubynCode
4
6
  module CLI
5
7
  # Expands `@path/to/file` mentions in user input into inline file content,
@@ -35,6 +37,23 @@ module RubynCode
35
37
  ["#{input}\n\n#{blocks.join("\n\n")}", resolved.map(&:first)]
36
38
  end
37
39
 
40
+ # Extract image references from input and return them as a list of
41
+ # LLM::ImageBlock instances. Image files (.png/.jpg/.jpeg/.gif/.webp)
42
+ # are NOT included in `expand` because they're sent as image content
43
+ # blocks, not as text fances. Non-image files are returned as nil.
44
+ #
45
+ # @param input [String]
46
+ # @return [Array<LLM::ImageBlock>] resolved image blocks (in order)
47
+ def expand_images(input)
48
+ return [] unless input.is_a?(String) && input.include?('@')
49
+
50
+ scan(input).filter_map do |rel, abs|
51
+ next unless LLM::ImageReader.image_extension?(rel)
52
+
53
+ LLM::ImageReader.for_path(abs)
54
+ end
55
+ end
56
+
38
57
  private
39
58
 
40
59
  # @return [Array<Array(String, String)>] unique [rel, abs] pairs, in order
@@ -97,9 +97,22 @@ module RubynCode
97
97
 
98
98
  def handle_on_tool_result(name, result)
99
99
  @renderer.tool_result(name, result)
100
+ render_todo_checklist
100
101
  @spinner.start
101
102
  end
102
103
 
104
+ # Refresh the in-turn checklist above the spinner after every tool call.
105
+ # Only printed if the TodoWrite tool has been used in this turn.
106
+ def render_todo_checklist
107
+ return unless @agent_loop.respond_to?(:todo_store)
108
+
109
+ store = @agent_loop.todo_store
110
+ return if store.nil? || store.empty?
111
+ return if @spinner.done
112
+
113
+ @renderer.info("☐ Checklist:\n#{store.render}")
114
+ end
115
+
103
116
  def handle_on_text(text)
104
117
  @spinner.stop
105
118
  if @streaming_first_chunk
@@ -115,11 +128,12 @@ module RubynCode
115
128
  # Checkpoint before the turn (raw input as label), then expand
116
129
  # @-mentions so the agent sees referenced file contents.
117
130
  @checkpoint_manager&.checkpoint!(label: input, conversation: @conversation)
131
+ image_blocks = expand_image_mentions(input)
118
132
  input = expand_mentions(input)
119
133
  @spinner.start
120
134
  @streaming_first_chunk = true
121
135
 
122
- response = @agent_loop.send_message(input)
136
+ response = @agent_loop.send_message(input, blocks: image_blocks.empty? ? nil : image_blocks)
123
137
 
124
138
  @spinner.stop
125
139
  render_response(response)
@@ -160,6 +174,22 @@ module RubynCode
160
174
  input
161
175
  end
162
176
 
177
+ # Collect image references from @path/to/image.png style mentions and
178
+ # return them as a list of LLM::ImageBlock hashes ready for the
179
+ # Agent::Loop. Falls back to [] when no images are attached.
180
+ def expand_image_mentions(input)
181
+ blocks = @mention_expander.expand_images(input)
182
+ if blocks.empty?
183
+ []
184
+ else
185
+ @renderer.info("🖼 Attached images: #{blocks.size}")
186
+ # Translate via MessageBuilder for adapter-aware shape
187
+ RubynCode::LLM::MessageBuilder.new.format_content_blocks(blocks)
188
+ end
189
+ rescue StandardError
190
+ []
191
+ end
192
+
163
193
  def setup_readline!
164
194
  completions = @command_registry.completions
165
195
 
@@ -27,7 +27,7 @@ module RubynCode
27
27
  Commands::Megaplan, Commands::Goal, Commands::Loop,
28
28
  Commands::Agents, Commands::Learning, Commands::Rewind,
29
29
  Commands::Chisel, Commands::ChiselReview, Commands::ChiselAudit,
30
- Commands::ChiselDebt, Commands::ChiselGain
30
+ Commands::ChiselDebt, Commands::ChiselGain, Commands::Think, Commands::Effort, Commands::Export
31
31
  ].each { |cmd| @command_registry.register(cmd) }
32
32
  register_custom_commands!
33
33
  end
@@ -29,6 +29,7 @@ module RubynCode
29
29
 
30
30
  def setup_core_services!
31
31
  @llm_client = LLM::Client.new
32
+ @llm_client.thinking_budget_tokens = Config::Settings.new.get('thinking_budget_tokens', 0).to_i
32
33
  @conversation = Agent::Conversation.new
33
34
  @tool_executor = Tools::Executor.new(project_root: @project_root)
34
35
  @context_manager = Context::Manager.new(llm_client: @llm_client)
@@ -191,11 +192,11 @@ module RubynCode
191
192
 
192
193
  def setup_mcp_servers!
193
194
  @mcp_clients = []
194
- server_configs = MCP::Config.load(@project_root)
195
- return if server_configs.empty?
195
+ entries = MCP::Discovery.discover(@project_root)
196
+ return if entries.empty?
196
197
 
197
- server_configs.each do |config|
198
- connect_mcp_server(config)
198
+ entries.each do |entry|
199
+ connect_mcp_server(entry)
199
200
  end
200
201
 
201
202
  at_exit { disconnect_mcp_clients! unless defined?(RSpec) }
@@ -206,9 +207,10 @@ module RubynCode
206
207
  client.connect!
207
208
  MCP::ToolBridge.bridge(client)
208
209
  @mcp_clients << client
209
- @renderer.info("MCP server '#{config[:name]}' connected (#{client.tools.size} tools)")
210
+ source_tag = config.respond_to?(:source) && config.source == :project ? ' [project]' : ' [user]'
211
+ @renderer.info("MCP server '#{config.name}' connected#{source_tag} (#{client.tools.size} tools)")
210
212
  rescue StandardError => e
211
- warn "[MCP] Failed to connect '#{config[:name]}': #{e.message}"
213
+ warn "[MCP] Failed to connect '#{config.name}': #{e.message}"
212
214
  end
213
215
 
214
216
  def disconnect_mcp_clients!
@@ -11,7 +11,7 @@ module RubynCode
11
11
  MEMORIES_DIR = File.join(HOME_DIR, 'memories')
12
12
 
13
13
  DEFAULT_PROVIDER = 'anthropic'
14
- DEFAULT_MODEL = 'claude-opus-4-8'
14
+ DEFAULT_MODEL = 'claude-opus-5'
15
15
  MODEL_MODE = 'auto' # 'auto' or 'manual'
16
16
  MAX_ITERATIONS = 200
17
17
  # Hard ceiling when a Stop hook (e.g. an active /goal) keeps the agent
@@ -41,6 +41,7 @@ module RubynCode
41
41
  # Chisel: opt-in "write the minimum that works" enforcement. Off by
42
42
  # default — only changes agent behavior once the user turns it on.
43
43
  CHISEL_MODE = 'off'
44
+ THINKING_BUDGET_TOKENS = 0
44
45
 
45
46
  SESSION_BUDGET_USD = 5.00
46
47
  DAILY_BUDGET_USD = 10.00
@@ -47,6 +47,11 @@
47
47
  "chisel_mode": {
48
48
  "type": "string",
49
49
  "enum": ["off", "lite", "full", "ultra"]
50
+ },
51
+ "thinking_budget_tokens": {
52
+ "type": "integer",
53
+ "minimum": 0,
54
+ "maximum": 200000
50
55
  }
51
56
  },
52
57
  "additionalProperties": true
@@ -19,6 +19,7 @@ module RubynCode
19
19
  oauth_token_url oauth_scopes
20
20
  skills_autoload
21
21
  chisel_mode
22
+ thinking_budget_tokens
22
23
  ].freeze
23
24
 
24
25
  DEFAULT_MAP = {
@@ -40,7 +41,8 @@ module RubynCode
40
41
  oauth_token_url: Defaults::OAUTH_TOKEN_URL,
41
42
  oauth_scopes: Defaults::OAUTH_SCOPES,
42
43
  skills_autoload: Defaults::SKILLS_AUTOLOAD,
43
- chisel_mode: Defaults::CHISEL_MODE
44
+ chisel_mode: Defaults::CHISEL_MODE,
45
+ thinking_budget_tokens: Defaults::THINKING_BUDGET_TOKENS
44
46
  }.freeze
45
47
 
46
48
  attr_reader :config_path, :data
@@ -156,7 +158,7 @@ module RubynCode
156
158
  DEFAULT_PROVIDER_MODELS = {
157
159
  'anthropic' => {
158
160
  'env_key' => 'ANTHROPIC_API_KEY',
159
- 'models' => { 'cheap' => 'claude-haiku-4-5', 'mid' => 'claude-sonnet-4-6', 'top' => 'claude-opus-4-8' }
161
+ 'models' => { 'cheap' => 'claude-haiku-4-5', 'mid' => 'claude-sonnet-5', 'top' => 'claude-opus-5' }
160
162
  },
161
163
  'openai' => {
162
164
  'env_key' => 'OPENAI_API_KEY',
@@ -63,7 +63,7 @@ module RubynCode
63
63
  ]
64
64
 
65
65
  options = {}
66
- options[:model] = 'claude-sonnet-4-6' if llm_client.respond_to?(:chat)
66
+ options[:model] = 'claude-sonnet-5' if llm_client.respond_to?(:chat)
67
67
 
68
68
  response = llm_client.chat(messages: summary_messages, **options)
69
69
 
@@ -68,7 +68,7 @@ module RubynCode
68
68
  ]
69
69
 
70
70
  options = {}
71
- options[:model] = 'claude-sonnet-4-6' if llm_client.respond_to?(:chat)
71
+ options[:model] = 'claude-sonnet-5' if llm_client.respond_to?(:chat)
72
72
 
73
73
  response = llm_client.chat(messages: summary_messages, **options)
74
74
 
@@ -14,6 +14,9 @@ module RubynCode
14
14
  INDEX_DIR = '.rubyn-code'
15
15
  INDEX_FILE = 'codebase_index.json'
16
16
  CHARS_PER_TOKEN = 4
17
+ # Bump when the stored shape changes so stale indexes rebuild instead
18
+ # of silently serving degraded data. 2 = Prism spans + call edges.
19
+ FORMAT_VERSION = 2
17
20
 
18
21
  attr_reader :nodes, :edges, :index_path
19
22
 
@@ -33,6 +36,7 @@ module RubynCode
33
36
 
34
37
  ruby_files.each { |file| index_file(file) }
35
38
  extract_rails_edges
39
+ prune_call_edges!
36
40
  save!
37
41
  self
38
42
  end
@@ -42,6 +46,8 @@ module RubynCode
42
46
  return nil unless File.exist?(@index_path)
43
47
 
44
48
  data = JSON.parse(File.read(@index_path))
49
+ return nil unless data['format_version'] == FORMAT_VERSION
50
+
45
51
  @nodes = data['nodes'] || []
46
52
  # uniq drops duplicate edges accumulated by older versions, which
47
53
  # appended tests edges on every update! without dedup.
@@ -68,6 +74,7 @@ module RubynCode
68
74
  end
69
75
 
70
76
  extract_rails_edges
77
+ prune_call_edges!
71
78
  save!
72
79
  self
73
80
  end
@@ -81,6 +88,7 @@ module RubynCode
81
88
  remove_nodes_for(absolute)
82
89
  index_file(absolute) if File.exist?(absolute)
83
90
  extract_rails_edges
91
+ prune_call_edges!
84
92
  save!
85
93
  self
86
94
  end
@@ -237,14 +245,66 @@ module RubynCode
237
245
  content = File.read(file)
238
246
  @file_mtimes[relative] = File.mtime(file).to_i
239
247
 
240
- extract_classes(content, relative)
241
- extract_methods(content, relative)
248
+ extract_symbols(content, relative)
242
249
  extract_associations(content, relative)
243
250
  extract_rails_patterns(content, relative)
244
251
  rescue StandardError => e
245
252
  RubynCode::Debug.warn("Index: failed to parse #{file}: #{e.message}")
246
253
  end
247
254
 
255
+ # Prism gives real line spans (needed for verbatim source in code_graph)
256
+ # and call edges. Files that don't parse fall back to the regex pass,
257
+ # which produces the same node shapes minus end_line/owner/calls.
258
+ def extract_symbols(content, file)
259
+ extracted = PrismExtractor.extract(content)
260
+ unless extracted
261
+ extract_classes(content, file)
262
+ extract_methods(content, file)
263
+ return
264
+ end
265
+
266
+ add_class_nodes(extracted.classes, file)
267
+ add_method_nodes(extracted.defs, file)
268
+ add_call_edges(extracted.calls, file)
269
+ end
270
+
271
+ def add_class_nodes(classes, file)
272
+ classes.each do |c|
273
+ @nodes << {
274
+ 'type' => classify_node(file, c[:kind]), 'name' => c[:name],
275
+ 'file' => file, 'line' => c[:line], 'end_line' => c[:end_line]
276
+ }
277
+ end
278
+ end
279
+
280
+ def add_method_nodes(methods, file)
281
+ methods.each do |m|
282
+ @nodes << {
283
+ 'type' => 'method', 'name' => m[:name], 'file' => file,
284
+ 'line' => m[:line], 'end_line' => m[:end_line],
285
+ 'owner' => m[:owner], 'params' => m[:params], 'visibility' => 'public'
286
+ }
287
+ end
288
+ end
289
+
290
+ # `from` stays the file path so remove_nodes_for's from-based cleanup
291
+ # applies to call edges too; the calling method rides in from_method.
292
+ def add_call_edges(calls, file)
293
+ calls.each do |c|
294
+ @edges << {
295
+ 'from' => file, 'from_method' => c[:from], 'to' => c[:to],
296
+ 'relationship' => 'calls', 'line' => c[:line]
297
+ }
298
+ end
299
+ end
300
+
301
+ # Drop call edges whose target isn't defined in the project — filters
302
+ # out stdlib/gem calls (puts, map, ...) that would swamp the graph.
303
+ def prune_call_edges!
304
+ defined_methods = @nodes.filter_map { |n| n['name'] if n['type'] == 'method' }.to_set
305
+ @edges.reject! { |e| e['relationship'] == 'calls' && !defined_methods.include?(e['to']) }
306
+ end
307
+
248
308
  def extract_classes(content, file)
249
309
  content.scan(/^\s*(class|module)\s+(\S+)/).each do |type, name|
250
310
  node_type = classify_node(file, type)
@@ -342,7 +402,8 @@ module RubynCode
342
402
 
343
403
  def save!
344
404
  FileUtils.mkdir_p(File.dirname(@index_path))
345
- data = { 'nodes' => @nodes, 'edges' => @edges, 'file_mtimes' => @file_mtimes }
405
+ data = { 'format_version' => FORMAT_VERSION, 'nodes' => @nodes, 'edges' => @edges,
406
+ 'file_mtimes' => @file_mtimes }
346
407
  File.write(@index_path, JSON.generate(data))
347
408
  end
348
409
  end