rubyn-code 0.5.1 → 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.
- checksums.yaml +4 -4
- data/README.md +137 -4
- data/db/migrations/014_multi_agent_upgrade.rb +79 -0
- data/lib/rubyn_code/agent/conversation.rb +100 -4
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
- data/lib/rubyn_code/agent/llm_caller.rb +7 -3
- data/lib/rubyn_code/agent/loop.rb +106 -12
- data/lib/rubyn_code/agent/response_parser.rb +8 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +40 -2
- data/lib/rubyn_code/agent/tool_processor.rb +13 -1
- data/lib/rubyn_code/auth/oauth.rb +1 -1
- data/lib/rubyn_code/auth/token_store.rb +49 -4
- data/lib/rubyn_code/autonomous/daemon.rb +1 -1
- data/lib/rubyn_code/checkpoint/hook.rb +26 -0
- data/lib/rubyn_code/checkpoint/manager.rb +109 -0
- data/lib/rubyn_code/chisel/debt.rb +65 -0
- data/lib/rubyn_code/chisel/inspection.rb +93 -0
- data/lib/rubyn_code/chisel.rb +127 -0
- data/lib/rubyn_code/cli/commands/agents.rb +31 -0
- data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
- data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
- data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
- data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
- data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
- data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
- data/lib/rubyn_code/cli/commands/context.rb +30 -1
- data/lib/rubyn_code/cli/commands/custom_command.rb +84 -0
- data/lib/rubyn_code/cli/commands/custom_loader.rb +100 -0
- data/lib/rubyn_code/cli/commands/effort.rb +47 -0
- data/lib/rubyn_code/cli/commands/export.rb +174 -0
- data/lib/rubyn_code/cli/commands/goal.rb +87 -0
- data/lib/rubyn_code/cli/commands/learning.rb +62 -0
- data/lib/rubyn_code/cli/commands/loop.rb +58 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +49 -12
- data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
- data/lib/rubyn_code/cli/commands/registry.rb +14 -9
- data/lib/rubyn_code/cli/commands/resume.rb +97 -26
- data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
- data/lib/rubyn_code/cli/commands/think.rb +47 -0
- data/lib/rubyn_code/cli/first_run.rb +1 -1
- data/lib/rubyn_code/cli/loop_runner.rb +98 -0
- data/lib/rubyn_code/cli/mention_expander.rb +111 -0
- data/lib/rubyn_code/cli/renderer.rb +3 -2
- data/lib/rubyn_code/cli/repl.rb +68 -15
- data/lib/rubyn_code/cli/repl_commands.rb +76 -2
- data/lib/rubyn_code/cli/repl_setup.rb +17 -7
- data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
- data/lib/rubyn_code/cli/version_check.rb +10 -3
- data/lib/rubyn_code/config/defaults.rb +14 -1
- data/lib/rubyn_code/config/schema.json +9 -0
- data/lib/rubyn_code/config/settings.rb +19 -2
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/manager.rb +29 -12
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/debug.rb +11 -5
- data/lib/rubyn_code/goal/evaluator.rb +95 -0
- data/lib/rubyn_code/hooks/event_map.rb +56 -0
- data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
- data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
- data/lib/rubyn_code/hooks/response.rb +83 -0
- data/lib/rubyn_code/hooks/runner.rb +61 -3
- data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
- data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
- data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
- data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
- data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
- data/lib/rubyn_code/index/codebase_index.rb +103 -4
- data/lib/rubyn_code/index/prism_extractor.rb +82 -0
- data/lib/rubyn_code/learning/injector.rb +1 -2
- data/lib/rubyn_code/learning/porter.rb +129 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +168 -29
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
- data/lib/rubyn_code/llm/adapters/base.rb +2 -1
- data/lib/rubyn_code/llm/adapters/openai.rb +2 -2
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
- data/lib/rubyn_code/llm/client.rb +16 -3
- data/lib/rubyn_code/llm/image_reader.rb +60 -0
- data/lib/rubyn_code/llm/message_builder.rb +21 -1
- data/lib/rubyn_code/llm/model_router.rb +4 -4
- data/lib/rubyn_code/mcp/client.rb +59 -0
- data/lib/rubyn_code/mcp/discovery.rb +93 -0
- data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
- data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
- data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
- data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
- data/lib/rubyn_code/memory/search.rb +9 -5
- data/lib/rubyn_code/memory/session_persistence.rb +159 -21
- data/lib/rubyn_code/observability/cost_calculator.rb +8 -3
- data/lib/rubyn_code/output/diff_renderer.rb +62 -7
- data/lib/rubyn_code/protocols/RUBYN.md +0 -3
- data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
- data/lib/rubyn_code/skills/registry_client.rb +4 -3
- data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
- data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
- data/lib/rubyn_code/tasks/models.rb +0 -16
- data/lib/rubyn_code/teams/agent_registry.rb +120 -0
- data/lib/rubyn_code/teams/mailbox.rb +99 -10
- data/lib/rubyn_code/teams/manager.rb +83 -5
- data/lib/rubyn_code/teams/teammate.rb +3 -14
- data/lib/rubyn_code/tools/RUBYN.md +2 -2
- data/lib/rubyn_code/tools/ask_user.rb +15 -1
- data/lib/rubyn_code/tools/bash.rb +3 -3
- data/lib/rubyn_code/tools/code_graph.rb +134 -0
- data/lib/rubyn_code/tools/executor.rb +9 -4
- data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
- data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
- data/lib/rubyn_code/tools/todo_store.rb +55 -0
- data/lib/rubyn_code/tools/todo_write.rb +88 -0
- data/lib/rubyn_code/tools/web_fetch.rb +1 -1
- data/lib/rubyn_code/tools/web_search.rb +4 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +57 -9
- data/skills/rubyn_self_test.md +462 -14
- data/skills/self_test/chisel_smoke.rb +84 -0
- data/skills/self_test/fixtures/chisel_sample.rb +64 -0
- metadata +45 -6
- data/lib/rubyn_code/context/context_budget.rb +0 -183
- data/lib/rubyn_code/context/schema_filter.rb +0 -64
- data/lib/rubyn_code/learning/shortcut.rb +0 -95
- data/lib/rubyn_code/llm/streaming.rb +0 -10
- data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RubynCode
|
|
4
4
|
module IDE
|
|
@@ -22,7 +22,7 @@ module RubynCode
|
|
|
22
22
|
session = @server.lookup_interview_session(session_id)
|
|
23
23
|
unless session
|
|
24
24
|
raise Protocol::JsonRpcError.new(SESSION_NOT_FOUND_CODE,
|
|
25
|
-
|
|
25
|
+
"Unknown interview session: #{session_id}")
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
outcome = session.answer(question_id, answer)
|
|
@@ -34,9 +34,9 @@ module RubynCode
|
|
|
34
34
|
Megaplan::PlanProposer::InvalidProposalError => e
|
|
35
35
|
warn "[PlanInterviewAnswerHandler] interview failed: #{e.message}"
|
|
36
36
|
@server.notify('plan/interview/error', {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
'sessionId' => params['sessionId'],
|
|
38
|
+
'message' => e.message
|
|
39
|
+
})
|
|
40
40
|
@server.drop_interview_session(params['sessionId'].to_s)
|
|
41
41
|
raise Protocol::JsonRpcError.new(INVALID_INTERVIEW_CODE, e.message)
|
|
42
42
|
end
|
|
@@ -46,16 +46,16 @@ module RubynCode
|
|
|
46
46
|
def emit_outcome(session, outcome)
|
|
47
47
|
if outcome.is_a?(Megaplan::InterviewSession::Question)
|
|
48
48
|
@server.notify('plan/interview/question', {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
'sessionId' => session.session_id,
|
|
50
|
+
'questionId' => outcome.id,
|
|
51
|
+
'text' => outcome.text,
|
|
52
|
+
'options' => outcome.options
|
|
53
|
+
})
|
|
54
54
|
else
|
|
55
55
|
@server.notify('plan/interview/done', {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
'sessionId' => session.session_id,
|
|
57
|
+
'plan' => outcome
|
|
58
|
+
})
|
|
59
59
|
@server.drop_interview_session(session.session_id)
|
|
60
60
|
end
|
|
61
61
|
end
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RubynCode
|
|
4
4
|
module IDE
|
|
@@ -12,7 +12,7 @@ module RubynCode
|
|
|
12
12
|
|
|
13
13
|
def initialize(server, factory: nil)
|
|
14
14
|
@server = server
|
|
15
|
-
@factory = factory ||
|
|
15
|
+
@factory = factory || lambda { |workspace_path:|
|
|
16
16
|
Megaplan::InterviewSession.new(workspace_path: workspace_path)
|
|
17
17
|
}
|
|
18
18
|
end
|
|
@@ -34,16 +34,16 @@ module RubynCode
|
|
|
34
34
|
def emit_outcome(session, outcome)
|
|
35
35
|
if outcome.is_a?(Megaplan::InterviewSession::Question)
|
|
36
36
|
@server.notify('plan/interview/question', {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
'sessionId' => session.session_id,
|
|
38
|
+
'questionId' => outcome.id,
|
|
39
|
+
'text' => outcome.text,
|
|
40
|
+
'options' => outcome.options
|
|
41
|
+
})
|
|
42
42
|
else
|
|
43
43
|
@server.notify('plan/interview/done', {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
'sessionId' => session.session_id,
|
|
45
|
+
'plan' => outcome
|
|
46
|
+
})
|
|
47
47
|
@server.drop_interview_session(session.session_id)
|
|
48
48
|
end
|
|
49
49
|
end
|
|
@@ -57,7 +57,15 @@ module RubynCode
|
|
|
57
57
|
return unless thread&.alive?
|
|
58
58
|
|
|
59
59
|
thread.raise(Interrupt)
|
|
60
|
-
|
|
60
|
+
begin
|
|
61
|
+
thread.join(2) # give it a moment to clean up
|
|
62
|
+
rescue Interrupt
|
|
63
|
+
# We asked the thread to stop, so its Interrupt is expected. If it
|
|
64
|
+
# lands before the agent installs its own rescue, Thread#join would
|
|
65
|
+
# otherwise re-raise it into the caller (the cancel path) and, in
|
|
66
|
+
# tests, escape the example and abort the whole run.
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
61
69
|
end
|
|
62
70
|
|
|
63
71
|
private
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'securerandom'
|
|
4
4
|
|
|
@@ -36,18 +36,34 @@ module RubynCode
|
|
|
36
36
|
private
|
|
37
37
|
|
|
38
38
|
def run_recovery(session_id, context)
|
|
39
|
+
notify_recovering(session_id, context)
|
|
40
|
+
|
|
41
|
+
recovery = @recovery || Megaplan::CiRecovery.new(
|
|
42
|
+
agent_invoker: build_invoker(session_id)
|
|
43
|
+
)
|
|
44
|
+
outcome = recovery.recover(context)
|
|
45
|
+
|
|
46
|
+
notify_outcome(session_id, context, outcome)
|
|
47
|
+
@server.notify('agent/status', {
|
|
48
|
+
'sessionId' => session_id,
|
|
49
|
+
'status' => 'done',
|
|
50
|
+
'summary' => outcome['summary']
|
|
51
|
+
})
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
warn "[RecoverCiHandler] error: #{e.message}"
|
|
54
|
+
notify_error(session_id, context, e)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def notify_recovering(session_id, context)
|
|
39
58
|
@server.notify('agent/status', {
|
|
40
59
|
'sessionId' => session_id,
|
|
41
60
|
'status' => 'recovering',
|
|
42
61
|
'phaseNumber' => context['phase_number'],
|
|
43
62
|
'attemptNumber' => context['attempt_number']
|
|
44
63
|
})
|
|
64
|
+
end
|
|
45
65
|
|
|
46
|
-
|
|
47
|
-
agent_invoker: build_invoker(session_id)
|
|
48
|
-
)
|
|
49
|
-
outcome = recovery.recover(context)
|
|
50
|
-
|
|
66
|
+
def notify_outcome(session_id, context, outcome)
|
|
51
67
|
@server.notify('recovery/outcome', {
|
|
52
68
|
'sessionId' => session_id,
|
|
53
69
|
'planId' => context['plan_id'],
|
|
@@ -56,25 +72,20 @@ module RubynCode
|
|
|
56
72
|
'commitSha' => outcome['commit_sha'],
|
|
57
73
|
'summary' => outcome['summary']
|
|
58
74
|
})
|
|
75
|
+
end
|
|
59
76
|
|
|
60
|
-
|
|
61
|
-
'sessionId' => session_id,
|
|
62
|
-
'status' => 'done',
|
|
63
|
-
'summary' => outcome['summary']
|
|
64
|
-
})
|
|
65
|
-
rescue StandardError => e
|
|
66
|
-
warn "[RecoverCiHandler] error: #{e.message}"
|
|
77
|
+
def notify_error(session_id, context, error)
|
|
67
78
|
@server.notify('recovery/outcome', {
|
|
68
79
|
'sessionId' => session_id,
|
|
69
80
|
'planId' => context['plan_id'],
|
|
70
81
|
'phaseNumber' => context['phase_number'],
|
|
71
82
|
'kind' => 'errored',
|
|
72
|
-
'summary' =>
|
|
83
|
+
'summary' => error.message
|
|
73
84
|
})
|
|
74
85
|
@server.notify('agent/status', {
|
|
75
86
|
'sessionId' => session_id,
|
|
76
87
|
'status' => 'error',
|
|
77
|
-
'error' =>
|
|
88
|
+
'error' => error.message
|
|
78
89
|
})
|
|
79
90
|
end
|
|
80
91
|
|
|
@@ -87,7 +98,7 @@ module RubynCode
|
|
|
87
98
|
llm_client = LLM::Client.new
|
|
88
99
|
response = llm_client.chat(
|
|
89
100
|
messages: [{ role: 'user', content: prompt }],
|
|
90
|
-
on_text:
|
|
101
|
+
on_text: lambda { |text|
|
|
91
102
|
@server.notify('stream/text', {
|
|
92
103
|
'sessionId' => session_id,
|
|
93
104
|
'text' => text,
|
|
@@ -30,7 +30,7 @@ module RubynCode
|
|
|
30
30
|
prompt = @server.handler_instance(:prompt)
|
|
31
31
|
if prompt
|
|
32
32
|
conversation = Agent::Conversation.new
|
|
33
|
-
|
|
33
|
+
conversation.replace!(messages.dup)
|
|
34
34
|
prompt.inject_conversation(session_id, conversation)
|
|
35
35
|
end
|
|
36
36
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'fileutils'
|
|
5
|
+
require 'open3'
|
|
5
6
|
|
|
6
7
|
module RubynCode
|
|
7
8
|
module Index
|
|
@@ -13,6 +14,9 @@ module RubynCode
|
|
|
13
14
|
INDEX_DIR = '.rubyn-code'
|
|
14
15
|
INDEX_FILE = 'codebase_index.json'
|
|
15
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
|
|
16
20
|
|
|
17
21
|
attr_reader :nodes, :edges, :index_path
|
|
18
22
|
|
|
@@ -32,6 +36,7 @@ module RubynCode
|
|
|
32
36
|
|
|
33
37
|
ruby_files.each { |file| index_file(file) }
|
|
34
38
|
extract_rails_edges
|
|
39
|
+
prune_call_edges!
|
|
35
40
|
save!
|
|
36
41
|
self
|
|
37
42
|
end
|
|
@@ -41,8 +46,12 @@ module RubynCode
|
|
|
41
46
|
return nil unless File.exist?(@index_path)
|
|
42
47
|
|
|
43
48
|
data = JSON.parse(File.read(@index_path))
|
|
49
|
+
return nil unless data['format_version'] == FORMAT_VERSION
|
|
50
|
+
|
|
44
51
|
@nodes = data['nodes'] || []
|
|
45
|
-
|
|
52
|
+
# uniq drops duplicate edges accumulated by older versions, which
|
|
53
|
+
# appended tests edges on every update! without dedup.
|
|
54
|
+
@edges = (data['edges'] || []).uniq
|
|
46
55
|
@file_mtimes = data['file_mtimes'] || {}
|
|
47
56
|
self
|
|
48
57
|
rescue StandardError
|
|
@@ -65,6 +74,21 @@ module RubynCode
|
|
|
65
74
|
end
|
|
66
75
|
|
|
67
76
|
extract_rails_edges
|
|
77
|
+
prune_call_edges!
|
|
78
|
+
save!
|
|
79
|
+
self
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Incremental update for a single known-changed file (e.g. after a
|
|
83
|
+
# write_file/edit_file tool call). Avoids the full-tree scan in update!.
|
|
84
|
+
def update_file!(path)
|
|
85
|
+
absolute = File.expand_path(path, @project_root)
|
|
86
|
+
return self unless absolute.start_with?("#{@project_root}/")
|
|
87
|
+
|
|
88
|
+
remove_nodes_for(absolute)
|
|
89
|
+
index_file(absolute) if File.exist?(absolute)
|
|
90
|
+
extract_rails_edges
|
|
91
|
+
prune_call_edges!
|
|
68
92
|
save!
|
|
69
93
|
self
|
|
70
94
|
end
|
|
@@ -192,6 +216,26 @@ module RubynCode
|
|
|
192
216
|
end
|
|
193
217
|
|
|
194
218
|
def ruby_files
|
|
219
|
+
git_ruby_files || glob_ruby_files
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Prefer git's file list when available: it skips ignored dirs
|
|
223
|
+
# (tmp/, log/, coverage/, db/) the glob would index. --others picks up
|
|
224
|
+
# untracked-but-not-ignored files so freshly created ones still appear.
|
|
225
|
+
def git_ruby_files
|
|
226
|
+
return nil unless File.exist?(File.join(@project_root, '.git'))
|
|
227
|
+
|
|
228
|
+
stdout, status = Open3.capture2(
|
|
229
|
+
'git', '-C', @project_root, 'ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '*.rb'
|
|
230
|
+
)
|
|
231
|
+
return nil unless status.success?
|
|
232
|
+
|
|
233
|
+
stdout.split("\0").map { |f| File.join(@project_root, f) }.select { |f| File.file?(f) }
|
|
234
|
+
rescue StandardError
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def glob_ruby_files
|
|
195
239
|
Dir.glob(File.join(@project_root, '**', '*.rb'))
|
|
196
240
|
.reject { |f| f.include?('/vendor/') || f.include?('/node_modules/') }
|
|
197
241
|
end
|
|
@@ -201,14 +245,66 @@ module RubynCode
|
|
|
201
245
|
content = File.read(file)
|
|
202
246
|
@file_mtimes[relative] = File.mtime(file).to_i
|
|
203
247
|
|
|
204
|
-
|
|
205
|
-
extract_methods(content, relative)
|
|
248
|
+
extract_symbols(content, relative)
|
|
206
249
|
extract_associations(content, relative)
|
|
207
250
|
extract_rails_patterns(content, relative)
|
|
208
251
|
rescue StandardError => e
|
|
209
252
|
RubynCode::Debug.warn("Index: failed to parse #{file}: #{e.message}")
|
|
210
253
|
end
|
|
211
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
|
+
|
|
212
308
|
def extract_classes(content, file)
|
|
213
309
|
content.scan(/^\s*(class|module)\s+(\S+)/).each do |type, name|
|
|
214
310
|
node_type = classify_node(file, type)
|
|
@@ -253,6 +349,8 @@ module RubynCode
|
|
|
253
349
|
end
|
|
254
350
|
|
|
255
351
|
def extract_rails_edges
|
|
352
|
+
# Rebuild tests edges from scratch so repeated updates stay idempotent.
|
|
353
|
+
@edges.reject! { |e| e['relationship'] == 'tests' }
|
|
256
354
|
spec_files = @file_mtimes.keys.select { |f| f.include?('spec/') || f.include?('test/') }
|
|
257
355
|
spec_files.each do |spec_file|
|
|
258
356
|
source = spec_file.sub(%r{spec/}, 'app/').sub(/_spec\.rb$/, '.rb')
|
|
@@ -304,7 +402,8 @@ module RubynCode
|
|
|
304
402
|
|
|
305
403
|
def save!
|
|
306
404
|
FileUtils.mkdir_p(File.dirname(@index_path))
|
|
307
|
-
data = { '
|
|
405
|
+
data = { 'format_version' => FORMAT_VERSION, 'nodes' => @nodes, 'edges' => @edges,
|
|
406
|
+
'file_mtimes' => @file_mtimes }
|
|
308
407
|
File.write(@index_path, JSON.generate(data))
|
|
309
408
|
end
|
|
310
409
|
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module Index
|
|
7
|
+
# Prism-based symbol and call extractor for a single Ruby file.
|
|
8
|
+
# Returns classes/modules and methods with real line spans, plus
|
|
9
|
+
# method-to-method call sites the regex pass can't see. Used by
|
|
10
|
+
# CodebaseIndex, with the regex extractors as a parse-error fallback.
|
|
11
|
+
class PrismExtractor < Prism::Visitor
|
|
12
|
+
Result = Struct.new(:classes, :defs, :calls)
|
|
13
|
+
|
|
14
|
+
# @return [Result, nil] nil when the file doesn't parse
|
|
15
|
+
def self.extract(content)
|
|
16
|
+
parsed = Prism.parse(content)
|
|
17
|
+
return nil unless parsed.success?
|
|
18
|
+
|
|
19
|
+
visitor = new
|
|
20
|
+
visitor.visit(parsed.value)
|
|
21
|
+
Result.new(visitor.classes, visitor.defs, visitor.calls)
|
|
22
|
+
rescue StandardError
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :classes, :defs, :calls
|
|
27
|
+
|
|
28
|
+
def initialize
|
|
29
|
+
super
|
|
30
|
+
@namespace = []
|
|
31
|
+
@current_def = nil
|
|
32
|
+
@classes = [] # { name:, kind:, line:, end_line: }
|
|
33
|
+
@defs = [] # { name:, owner:, line:, end_line:, params: }
|
|
34
|
+
@calls = [] # { from:, to:, line: }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def visit_class_node(node)
|
|
38
|
+
record_namespace(node, 'class') { super }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def visit_module_node(node)
|
|
42
|
+
record_namespace(node, 'module') { super }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def visit_def_node(node)
|
|
46
|
+
@defs << {
|
|
47
|
+
name: node.name.to_s,
|
|
48
|
+
owner: @namespace.join('::'),
|
|
49
|
+
line: node.location.start_line,
|
|
50
|
+
end_line: node.location.end_line,
|
|
51
|
+
params: node.parameters ? "(#{node.parameters.slice})" : nil
|
|
52
|
+
}
|
|
53
|
+
previous = @current_def
|
|
54
|
+
@current_def = node.name.to_s
|
|
55
|
+
super
|
|
56
|
+
@current_def = previous
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# ponytail: resolution is by method name, not receiver type — a call to
|
|
60
|
+
# `user.save` links to any `save` defined in the project. Real receiver
|
|
61
|
+
# inference needs type analysis; name matching covers the common case.
|
|
62
|
+
def visit_call_node(node)
|
|
63
|
+
@calls << { from: @current_def, to: node.name.to_s, line: node.location.start_line } if @current_def
|
|
64
|
+
super
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# The block continues the walk into child nodes (the caller's `super`).
|
|
70
|
+
def record_namespace(node, kind)
|
|
71
|
+
name = node.constant_path.slice
|
|
72
|
+
@classes << {
|
|
73
|
+
name: name, kind: kind,
|
|
74
|
+
line: node.location.start_line, end_line: node.location.end_line
|
|
75
|
+
}
|
|
76
|
+
@namespace.push(name)
|
|
77
|
+
yield
|
|
78
|
+
@namespace.pop
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -112,8 +112,7 @@ module RubynCode
|
|
|
112
112
|
tag_set = tags.to_set(&:downcase)
|
|
113
113
|
|
|
114
114
|
instincts.select do |inst|
|
|
115
|
-
|
|
116
|
-
inst_tags.any? { |t| tag_set.include?(t) }
|
|
115
|
+
inst.context_tags.any? { |t| tag_set.include?(t.downcase) }
|
|
117
116
|
end
|
|
118
117
|
end
|
|
119
118
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Learning
|
|
8
|
+
# Exports and imports learned instincts so a user can carry their
|
|
9
|
+
# accumulated learnings to another machine. Instincts live in SQLite under
|
|
10
|
+
# ~/.rubyn-code; this serializes them to a portable JSON file and loads
|
|
11
|
+
# them back, regenerating ids and de-duplicating by (project_path, pattern).
|
|
12
|
+
module Porter
|
|
13
|
+
FORMAT_VERSION = 1
|
|
14
|
+
# Columns carried across machines (id is regenerated on import).
|
|
15
|
+
COLUMNS = %w[
|
|
16
|
+
project_path pattern context_tags confidence decay_rate
|
|
17
|
+
times_applied times_helpful created_at updated_at
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
class Error < RubynCode::Error; end
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# Export instincts to a JSON file.
|
|
24
|
+
#
|
|
25
|
+
# @param db [DB::Connection]
|
|
26
|
+
# @param path [String] destination file
|
|
27
|
+
# @param project_path [String, nil] limit to one project, or nil for all
|
|
28
|
+
# @return [Integer] number of instincts exported
|
|
29
|
+
def export(db:, path:, project_path: nil)
|
|
30
|
+
rows = fetch(db, project_path)
|
|
31
|
+
payload = { 'version' => FORMAT_VERSION, 'instincts' => rows }
|
|
32
|
+
File.write(path, "#{JSON.pretty_generate(payload)}\n")
|
|
33
|
+
rows.size
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Import instincts from a JSON file.
|
|
37
|
+
#
|
|
38
|
+
# @param db [DB::Connection]
|
|
39
|
+
# @param path [String] source file
|
|
40
|
+
# @param remap_project [String, nil] override every row's project_path
|
|
41
|
+
# (use the current project so imported learnings apply here)
|
|
42
|
+
# @return [Hash] { imported:, skipped:, total: }
|
|
43
|
+
def import(db:, path:, remap_project: nil)
|
|
44
|
+
raise Error, "File not found: #{path}" unless File.file?(path)
|
|
45
|
+
|
|
46
|
+
payload = parse(path)
|
|
47
|
+
instincts = Array(payload['instincts'])
|
|
48
|
+
imported = instincts.count { |row| import_row(db, row, remap_project) }
|
|
49
|
+
|
|
50
|
+
{ imported: imported, skipped: instincts.size - imported, total: instincts.size }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Hash] { count:, projects: } summary for display
|
|
54
|
+
def stats(db, project_path: nil)
|
|
55
|
+
rows = fetch(db, project_path)
|
|
56
|
+
{ count: rows.size, projects: rows.map { |r| r['project_path'] }.uniq.size }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def fetch(db, project_path)
|
|
62
|
+
select = "SELECT #{COLUMNS.join(', ')} FROM instincts"
|
|
63
|
+
if project_path
|
|
64
|
+
db.query("#{select} WHERE project_path = ?", [project_path]).to_a
|
|
65
|
+
else
|
|
66
|
+
db.query(select).to_a
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def parse(path)
|
|
71
|
+
payload = JSON.parse(File.read(path))
|
|
72
|
+
raise Error, 'Not a Rubyn learnings file' unless payload.is_a?(Hash) && payload.key?('instincts')
|
|
73
|
+
|
|
74
|
+
version = payload['version'].to_i
|
|
75
|
+
raise Error, "Unsupported export version: #{version}" if version > FORMAT_VERSION
|
|
76
|
+
|
|
77
|
+
payload
|
|
78
|
+
rescue JSON::ParserError => e
|
|
79
|
+
raise Error, "Invalid JSON: #{e.message}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Boolean] true if inserted, false if skipped (duplicate)
|
|
83
|
+
def import_row(db, row, remap_project)
|
|
84
|
+
project = remap_project || row['project_path']
|
|
85
|
+
return false if project.to_s.empty? || row['pattern'].to_s.empty?
|
|
86
|
+
return false if exists?(db, project, row['pattern'])
|
|
87
|
+
|
|
88
|
+
insert(db, row, project)
|
|
89
|
+
true
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
RubynCode::Debug.warn("Skipping instinct import: #{e.message}")
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def exists?(db, project, pattern)
|
|
96
|
+
db.query(
|
|
97
|
+
'SELECT 1 FROM instincts WHERE project_path = ? AND pattern = ? LIMIT 1',
|
|
98
|
+
[project, pattern]
|
|
99
|
+
).to_a.any?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def insert(db, row, project)
|
|
103
|
+
now = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
104
|
+
db.execute(
|
|
105
|
+
<<~SQL.tr("\n", ' ').strip,
|
|
106
|
+
INSERT INTO instincts (id, project_path, pattern, context_tags,
|
|
107
|
+
confidence, decay_rate, times_applied, times_helpful, created_at, updated_at)
|
|
108
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
109
|
+
SQL
|
|
110
|
+
[
|
|
111
|
+
SecureRandom.uuid, project, row['pattern'], normalize_tags(row['context_tags']),
|
|
112
|
+
(row['confidence'] || 0.5).to_f, (row['decay_rate'] || 0.05).to_f,
|
|
113
|
+
(row['times_applied'] || 0).to_i, (row['times_helpful'] || 0).to_i,
|
|
114
|
+
row['created_at'] || now, row['updated_at'] || now
|
|
115
|
+
]
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# context_tags is stored as a JSON string column; accept either a JSON
|
|
120
|
+
# string or an array from the export file.
|
|
121
|
+
def normalize_tags(tags)
|
|
122
|
+
return tags if tags.is_a?(String)
|
|
123
|
+
|
|
124
|
+
JSON.generate(Array(tags))
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|