msnav 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +187 -0
- data/exe/msnav +11 -0
- data/lib/msnav/cli.rb +388 -0
- data/lib/msnav/config.rb +79 -0
- data/lib/msnav/ctl.rb +203 -0
- data/lib/msnav/datadir.rb +121 -0
- data/lib/msnav/errors.rb +11 -0
- data/lib/msnav/graph.rb +151 -0
- data/lib/msnav/navigator.rb +326 -0
- data/lib/msnav/server.rb +341 -0
- data/lib/msnav/store.rb +97 -0
- data/lib/msnav/version.rb +5 -0
- data/lib/msnav/windows.rb +221 -0
- data/lib/msnav.rb +23 -0
- metadata +101 -0
data/lib/msnav/config.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Msnav
|
|
7
|
+
# The slice of coderag.yml msnav needs: where the workspace is and where the
|
|
8
|
+
# index lives. Resolution mirrors coderag's load_config so both daemons
|
|
9
|
+
# agree on the db path for any given hub:
|
|
10
|
+
#
|
|
11
|
+
# workspace_root: explicit arg > coderag.yml's key > "." (the cwd — the
|
|
12
|
+
# lifecycle commands always run the daemon with cwd=hub)
|
|
13
|
+
# storage_dir: declared (workspace-relative unless absolute)
|
|
14
|
+
# > legacy <workspace>/.coderag when it already holds a db
|
|
15
|
+
# > the package data dir (~/.local/share/coderag/hubs/<hub>)
|
|
16
|
+
#
|
|
17
|
+
# All other coderag.yml keys (models, profiles, …) belong to the indexer and
|
|
18
|
+
# are ignored here — msnav never indexes.
|
|
19
|
+
class Config
|
|
20
|
+
attr_reader :workspace_root, :storage_dir, :config_source
|
|
21
|
+
|
|
22
|
+
def self.load(path = nil, workspace_root: nil)
|
|
23
|
+
candidates = []
|
|
24
|
+
candidates << Pathname.new(path) if path
|
|
25
|
+
env = ENV["CODERAG_CONFIG"]
|
|
26
|
+
candidates << Pathname.new(env) if env && !env.empty?
|
|
27
|
+
candidates << (Pathname.new(workspace_root) + "coderag.yml") if workspace_root
|
|
28
|
+
candidates << Pathname.new("coderag.yml")
|
|
29
|
+
|
|
30
|
+
data = {}
|
|
31
|
+
source = nil
|
|
32
|
+
candidates.each do |cand|
|
|
33
|
+
next unless cand.file?
|
|
34
|
+
begin
|
|
35
|
+
data = YAML.safe_load(cand.read) || {}
|
|
36
|
+
rescue Psych::SyntaxError => e
|
|
37
|
+
raise ConfigError, "#{cand}: invalid YAML — #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
raise ConfigError, "#{cand}: top level must be a mapping" unless data.is_a?(Hash)
|
|
40
|
+
source = cand.expand_path
|
|
41
|
+
break
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
new(data, workspace_root: workspace_root, source: source)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize(data, workspace_root: nil, source: nil)
|
|
48
|
+
@config_source = source
|
|
49
|
+
root = workspace_root || data["workspace_root"] || "."
|
|
50
|
+
@workspace_root = resolve(Pathname.new(File.expand_path(root.to_s)))
|
|
51
|
+
|
|
52
|
+
declared = data["storage_dir"]
|
|
53
|
+
if declared
|
|
54
|
+
dir = Pathname.new(declared.to_s.sub(/\A~/) { Dir.home })
|
|
55
|
+
dir = @workspace_root + dir unless dir.absolute?
|
|
56
|
+
@storage_dir = resolve(dir)
|
|
57
|
+
else
|
|
58
|
+
legacy = @workspace_root + ".coderag"
|
|
59
|
+
@storage_dir = if (legacy + "coderag.db").exist?
|
|
60
|
+
legacy
|
|
61
|
+
else
|
|
62
|
+
Datadir.hub_dir(@workspace_root)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def db_path
|
|
68
|
+
@storage_dir + "coderag.db"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Pathname#realpath for what exists (symlink-stable, like Python's
|
|
74
|
+
# Path.resolve()); already-expanded path otherwise.
|
|
75
|
+
def resolve(pathname)
|
|
76
|
+
pathname.exist? ? pathname.realpath : pathname
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/msnav/ctl.rb
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "yaml"
|
|
8
|
+
|
|
9
|
+
module Msnav
|
|
10
|
+
# Daemon lifecycle control: the logic behind `msnav up/status/down` — a
|
|
11
|
+
# port of coderag/daemon/ctl.py with identical semantics and exit codes,
|
|
12
|
+
# so the editor extension can branch on them the same way.
|
|
13
|
+
module Ctl
|
|
14
|
+
PROBE_TIMEOUT = 2.0 # seconds per health request
|
|
15
|
+
SPAWN_GRACE = 3.0 # keep probing this long after our child dies (bind race)
|
|
16
|
+
|
|
17
|
+
# `up` exit codes (the extension and scripts branch on these)
|
|
18
|
+
EXIT_OK = 0
|
|
19
|
+
EXIT_FAIL = 1 # spawn failed / port held by a foreign process
|
|
20
|
+
EXIT_NO_HUB = 2 # no coderag.yml found — refuse to spawn
|
|
21
|
+
EXIT_WRONG_ROOT = 3 # healthy daemon on the port, but for another hub
|
|
22
|
+
|
|
23
|
+
# A leaked Bundler context (bundle exec / RUBYOPT=-rbundler/setup) makes
|
|
24
|
+
# rubygems binstubs refuse any gem not in the active Gemfile — the daemon
|
|
25
|
+
# never needs Bundler, so these are scrubbed from the child's environment.
|
|
26
|
+
BUNDLER_ENV = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH
|
|
27
|
+
BUNDLER_VERSION BUNDLER_SETUP].freeze
|
|
28
|
+
|
|
29
|
+
Probe = Struct.new(:state, :health, :detail, keyword_init: true) do
|
|
30
|
+
def root
|
|
31
|
+
health && health["root"]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def pid
|
|
35
|
+
health && health["pid"]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# Nearest ancestor of START (inclusive) containing coderag.yml — the hub
|
|
42
|
+
# root a daemon may be spawned at. nil is the guard against rooting a
|
|
43
|
+
# daemon inside a single service directory.
|
|
44
|
+
def find_hub_root(start)
|
|
45
|
+
cur = Pathname.new(File.expand_path(start))
|
|
46
|
+
cur = cur.realpath if cur.exist?
|
|
47
|
+
loop do
|
|
48
|
+
return cur if (cur + "coderag.yml").file?
|
|
49
|
+
parent = cur.parent
|
|
50
|
+
return nil if parent == cur
|
|
51
|
+
cur = parent
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The workspace root a daemon started at HUB will report: coderag.yml's
|
|
56
|
+
# `workspace_root` key resolved against the hub, else the hub itself.
|
|
57
|
+
def expected_workspace_root(hub)
|
|
58
|
+
hub = Pathname.new(hub)
|
|
59
|
+
begin
|
|
60
|
+
data = YAML.safe_load((hub + "coderag.yml").read) || {}
|
|
61
|
+
declared = data.is_a?(Hash) ? data["workspace_root"] : nil
|
|
62
|
+
if declared
|
|
63
|
+
p = Pathname.new(declared.to_s.sub(/\A~/) { Dir.home })
|
|
64
|
+
candidate = p.absolute? ? p : hub + p
|
|
65
|
+
return candidate.exist? ? candidate.realpath : candidate.cleanpath
|
|
66
|
+
end
|
|
67
|
+
rescue StandardError
|
|
68
|
+
# a broken yml fails properly at daemon startup, not here
|
|
69
|
+
end
|
|
70
|
+
hub.exist? ? hub.realpath : hub
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# One /api/health probe: healthy daemon, some foreign process, or nothing
|
|
74
|
+
# listening.
|
|
75
|
+
def probe_health(url, timeout = PROBE_TIMEOUT)
|
|
76
|
+
uri = URI.parse("#{url}/api/health")
|
|
77
|
+
body = nil
|
|
78
|
+
begin
|
|
79
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
80
|
+
http.open_timeout = timeout
|
|
81
|
+
http.read_timeout = timeout
|
|
82
|
+
res = http.get(uri.request_uri)
|
|
83
|
+
unless res.is_a?(Net::HTTPSuccess)
|
|
84
|
+
return Probe.new(state: "foreign", health: {},
|
|
85
|
+
detail: "HTTP #{res.code} from #{url}")
|
|
86
|
+
end
|
|
87
|
+
body = res.body
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
return Probe.new(state: "down", health: {}, detail: e.message)
|
|
90
|
+
end
|
|
91
|
+
health = begin
|
|
92
|
+
JSON.parse(body)
|
|
93
|
+
rescue JSON::ParserError
|
|
94
|
+
return Probe.new(state: "foreign", health: {},
|
|
95
|
+
detail: "non-JSON response from #{url}")
|
|
96
|
+
end
|
|
97
|
+
unless health.is_a?(Hash) && health.key?("ok") && health.key?("root")
|
|
98
|
+
return Probe.new(state: "foreign", health: {},
|
|
99
|
+
detail: "not a coderag daemon at #{url}")
|
|
100
|
+
end
|
|
101
|
+
Probe.new(state: "healthy", health: health, detail: "")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def same_root(a, b)
|
|
105
|
+
real(a.to_s) == real(b.to_s)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def real(path)
|
|
109
|
+
File.realpath(path)
|
|
110
|
+
rescue SystemCallError
|
|
111
|
+
File.expand_path(path)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Where this hub's index/logs live — mirrors config resolution so the
|
|
115
|
+
# lifecycle commands know the log location before any daemon runs.
|
|
116
|
+
def resolved_storage(hub)
|
|
117
|
+
hub = Pathname.new(hub)
|
|
118
|
+
declared = nil
|
|
119
|
+
begin
|
|
120
|
+
data = YAML.safe_load((hub + "coderag.yml").read) || {}
|
|
121
|
+
declared = data.is_a?(Hash) ? data["storage_dir"] : nil
|
|
122
|
+
rescue StandardError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
root = expected_workspace_root(hub)
|
|
126
|
+
if declared
|
|
127
|
+
p = Pathname.new(declared.to_s.sub(/\A~/) { Dir.home })
|
|
128
|
+
return p.absolute? ? p : (root + p).cleanpath
|
|
129
|
+
end
|
|
130
|
+
legacy = root + ".coderag"
|
|
131
|
+
return legacy if (legacy + "coderag.db").exist?
|
|
132
|
+
Datadir.hub_dir(root)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Raw stdout/stderr of a spawned daemon. Separate from coderag.log.
|
|
136
|
+
def daemon_log_path(hub)
|
|
137
|
+
resolved_storage(hub) + "daemon.log"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The command that reruns THIS msnav — the gem binstub when we were
|
|
141
|
+
# started through one, else ruby + the packaged exe.
|
|
142
|
+
def self_command
|
|
143
|
+
exe = File.expand_path($PROGRAM_NAME)
|
|
144
|
+
return [exe] if File.file?(exe) && File.executable?(exe)
|
|
145
|
+
require "rbconfig"
|
|
146
|
+
ruby = File.join(RbConfig::CONFIG["bindir"],
|
|
147
|
+
RbConfig::CONFIG["ruby_install_name"])
|
|
148
|
+
[ruby, File.expand_path("../../exe/msnav", __dir__)]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Launch `msnav daemon` detached: new process group (outlives the
|
|
152
|
+
# caller), cwd = hub (so a relative workspace_root resolves there),
|
|
153
|
+
# output appended to daemon.log. Returns [pid, waiter_thread].
|
|
154
|
+
def spawn_daemon(hub, config_path, host, port,
|
|
155
|
+
service_root: nil, service_name: nil, log_path: nil)
|
|
156
|
+
log_path = log_path || daemon_log_path(hub)
|
|
157
|
+
log_path.parent.mkpath
|
|
158
|
+
argv = self_command + ["daemon", "--config", config_path.to_s,
|
|
159
|
+
"--host", host, "--port", port.to_s]
|
|
160
|
+
argv += ["--service-root", service_root.to_s] if service_root
|
|
161
|
+
argv += ["--service", service_name.to_s] if service_name
|
|
162
|
+
log_file = File.open(log_path.to_s, "ab")
|
|
163
|
+
begin
|
|
164
|
+
scrub = BUNDLER_ENV.each_with_object({}) { |k, h| h[k] = nil }
|
|
165
|
+
pid = Process.spawn(scrub, *argv, chdir: hub.to_s, pgroup: true,
|
|
166
|
+
in: File::NULL, out: log_file, err: log_file)
|
|
167
|
+
ensure
|
|
168
|
+
log_file.close # the child holds its own fd
|
|
169
|
+
end
|
|
170
|
+
[pid, Process.detach(pid)]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Poll until something answers health or the deadline passes. A dead
|
|
174
|
+
# child is NOT failure by itself: losing the port-bind race to another
|
|
175
|
+
# window's daemon is success as long as a healthy daemon appears — the
|
|
176
|
+
# caller compares roots. After the child dies we keep probing briefly.
|
|
177
|
+
def wait_healthy(url, waiter, timeout, poll = 0.25)
|
|
178
|
+
deadline = monotonic + timeout
|
|
179
|
+
died_at = nil
|
|
180
|
+
loop do
|
|
181
|
+
probe = probe_health(url, [PROBE_TIMEOUT, poll * 4].min)
|
|
182
|
+
return probe unless probe.state == "down"
|
|
183
|
+
now = monotonic
|
|
184
|
+
died_at = now if waiter && !waiter.alive? && died_at.nil?
|
|
185
|
+
if now >= deadline || (!died_at.nil? && now - died_at > SPAWN_GRACE)
|
|
186
|
+
return probe
|
|
187
|
+
end
|
|
188
|
+
sleep poll
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def tail_log(log_path, lines = 20)
|
|
193
|
+
content = File.read(log_path.to_s).split("\n")
|
|
194
|
+
content.last(lines).join("\n")
|
|
195
|
+
rescue SystemCallError
|
|
196
|
+
""
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def monotonic
|
|
200
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "sqlite3"
|
|
6
|
+
|
|
7
|
+
module Msnav
|
|
8
|
+
# Package-owned data directory — mirrors coderag/datadir.py exactly, so an
|
|
9
|
+
# index built by `coderag index` on the host is found at the same place
|
|
10
|
+
# inside the container (the one uniform bind mount:
|
|
11
|
+
# ~/.local/share/coderag -> /root/.local/share/coderag).
|
|
12
|
+
module Datadir
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def data_dir
|
|
16
|
+
override = ENV["CODERAG_DATA_DIR"]
|
|
17
|
+
return Pathname.new(File.expand_path(override)) if override && !override.empty?
|
|
18
|
+
xdg = ENV["XDG_DATA_HOME"]
|
|
19
|
+
base = xdg && !xdg.empty? ? Pathname.new(File.expand_path(xdg)) : Pathname.new(Dir.home) + ".local" + "share"
|
|
20
|
+
base + "coderag"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The hub's slice of the data dir: readable name + path-hash suffix.
|
|
24
|
+
# Must match coderag's sha256(str(resolved_root))[:10].
|
|
25
|
+
def hub_dir(workspace_root)
|
|
26
|
+
root = Pathname.new(File.expand_path(workspace_root))
|
|
27
|
+
root = root.realpath if root.exist?
|
|
28
|
+
key = Digest::SHA256.hexdigest(root.to_s)[0, 10]
|
|
29
|
+
data_dir + "hubs" + "#{root.basename}-#{key}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# content matching: enough overlap between the index's cached file paths
|
|
33
|
+
# and the files actually on disk to be sure this folder IS that service
|
|
34
|
+
MATCH_MIN_FILES = 3
|
|
35
|
+
MATCH_MIN_RATIO = 0.6
|
|
36
|
+
SAMPLE_LIMIT = 2000
|
|
37
|
+
SKIP_DIRS = %w[.git vendor node_modules tmp log spec test].freeze
|
|
38
|
+
|
|
39
|
+
# Relative .rb paths under SERVICE_ROOT (bounded walk).
|
|
40
|
+
def disk_ruby_files(service_root)
|
|
41
|
+
found = {}
|
|
42
|
+
queue = [Pathname.new(service_root)]
|
|
43
|
+
root = Pathname.new(service_root)
|
|
44
|
+
until queue.empty?
|
|
45
|
+
dir = queue.shift
|
|
46
|
+
children = begin
|
|
47
|
+
dir.children
|
|
48
|
+
rescue SystemCallError
|
|
49
|
+
next
|
|
50
|
+
end
|
|
51
|
+
children.each do |entry|
|
|
52
|
+
if entry.directory?
|
|
53
|
+
queue << entry unless SKIP_DIRS.include?(entry.basename.to_s)
|
|
54
|
+
elsif entry.extname == ".rb"
|
|
55
|
+
found[entry.relative_path_from(root).to_s.tr(File::SEPARATOR, "/")] = true
|
|
56
|
+
return found if found.length >= SAMPLE_LIMIT
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
found
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Which indexed service (dirname) does SERVICE_ROOT hold? Directory-name
|
|
64
|
+
# match first; otherwise by content — the service whose cached file paths
|
|
65
|
+
# are actually present on disk (mounts like `/app` don't carry the
|
|
66
|
+
# service name). Mirrors coderag's datadir.identify_service.
|
|
67
|
+
def identify_service(db_path, service_root)
|
|
68
|
+
service_root = Pathname.new(service_root)
|
|
69
|
+
con = begin
|
|
70
|
+
SQLite3::Database.new(db_path.to_s, readonly: true)
|
|
71
|
+
rescue SQLite3::Exception
|
|
72
|
+
return nil
|
|
73
|
+
end
|
|
74
|
+
begin
|
|
75
|
+
dirnames = begin
|
|
76
|
+
con.execute("SELECT dirname FROM services").map { |r| r[0] }
|
|
77
|
+
rescue SQLite3::Exception
|
|
78
|
+
return nil
|
|
79
|
+
end
|
|
80
|
+
base = service_root.basename.to_s
|
|
81
|
+
return base if dirnames.include?(base)
|
|
82
|
+
disk = disk_ruby_files(service_root)
|
|
83
|
+
return nil if disk.empty?
|
|
84
|
+
best = nil
|
|
85
|
+
dirnames.each do |dirname|
|
|
86
|
+
paths = begin
|
|
87
|
+
con.execute("SELECT path FROM facts WHERE service = ?", [dirname])
|
|
88
|
+
.map { |r| r[0] }
|
|
89
|
+
rescue SQLite3::Exception
|
|
90
|
+
return nil
|
|
91
|
+
end
|
|
92
|
+
present = paths.count { |p| disk.key?(p) }
|
|
93
|
+
next if present < MATCH_MIN_FILES || paths.empty?
|
|
94
|
+
ratio = present.to_f / paths.length
|
|
95
|
+
if ratio >= MATCH_MIN_RATIO && (best.nil? || ratio > best[0])
|
|
96
|
+
best = [ratio, dirname]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
best && best[1]
|
|
100
|
+
ensure
|
|
101
|
+
con.close
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# [hub dir, service dirname] candidates for the service at SERVICE_ROOT —
|
|
106
|
+
# how a service-only container identifies its hub with nothing but the
|
|
107
|
+
# data-dir mount.
|
|
108
|
+
def find_hubs_for_service_root(service_root)
|
|
109
|
+
hubs = data_dir + "hubs"
|
|
110
|
+
return [] unless hubs.directory?
|
|
111
|
+
matches = []
|
|
112
|
+
hubs.children.sort.each do |hub|
|
|
113
|
+
db = hub + "coderag.db"
|
|
114
|
+
next unless db.file?
|
|
115
|
+
dirname = identify_service(db, service_root)
|
|
116
|
+
matches << [hub, dirname] if dirname
|
|
117
|
+
end
|
|
118
|
+
matches
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
data/lib/msnav/errors.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Msnav
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# The index is missing, foreign, or from an incompatible coderag schema.
|
|
7
|
+
class IndexStaleError < Error; end
|
|
8
|
+
|
|
9
|
+
# coderag.yml could not be parsed into the keys msnav needs.
|
|
10
|
+
class ConfigError < Error; end
|
|
11
|
+
end
|
data/lib/msnav/graph.rb
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Msnav
|
|
6
|
+
# In-memory view of the coderag code graph (nodes/edges tables), with the
|
|
7
|
+
# same secondary indexes coderag builds: by-kind, per-(service, file) node
|
|
8
|
+
# lists, and per-file line-interval lists for covering-symbol lookup.
|
|
9
|
+
#
|
|
10
|
+
# Node data and edge data are the JSON hashes coderag persisted (string
|
|
11
|
+
# keys); an edge's "kind" column is merged back into its data hash, exactly
|
|
12
|
+
# like CodeGraph.load does in Python.
|
|
13
|
+
class Graph
|
|
14
|
+
attr_reader :nodes
|
|
15
|
+
|
|
16
|
+
# Load the graph and re-anchor service roots when the same workspace is
|
|
17
|
+
# mounted at a different path than at index time (host <-> container).
|
|
18
|
+
# Each root is rebased only if the rebased directory actually exists — a
|
|
19
|
+
# container that mounts only the data dir keeps the stored host paths and
|
|
20
|
+
# serves them verbatim (the service-scoped devcontainer flow).
|
|
21
|
+
def self.load(store, workspace_root = nil)
|
|
22
|
+
nodes = {}
|
|
23
|
+
out_edges = Hash.new { |h, k| h[k] = [] }
|
|
24
|
+
in_edges = Hash.new { |h, k| h[k] = [] }
|
|
25
|
+
stored_ws = nil
|
|
26
|
+
store.with_conn do |db|
|
|
27
|
+
db.execute("SELECT id, data FROM nodes") do |id, data|
|
|
28
|
+
nodes[id] = JSON.parse(data)
|
|
29
|
+
end
|
|
30
|
+
db.execute("SELECT src, dst, kind, data FROM edges") do |src, dst, kind, data|
|
|
31
|
+
edge = JSON.parse(data)
|
|
32
|
+
edge["kind"] = kind
|
|
33
|
+
nodes[src] ||= {} # edges may reference nodes with no attributes
|
|
34
|
+
nodes[dst] ||= {}
|
|
35
|
+
out_edges[src] << [dst, edge]
|
|
36
|
+
in_edges[dst] << [src, edge]
|
|
37
|
+
end
|
|
38
|
+
row = db.get_first_row("SELECT value FROM meta WHERE key='workspace_root'")
|
|
39
|
+
stored_ws = row && row[0]
|
|
40
|
+
end
|
|
41
|
+
graph = new(nodes, out_edges, in_edges)
|
|
42
|
+
graph.rebase_roots(stored_ws, workspace_root.to_s) if workspace_root
|
|
43
|
+
graph
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize(nodes, out_edges, in_edges)
|
|
47
|
+
@nodes = nodes
|
|
48
|
+
@out_edges = out_edges
|
|
49
|
+
@in_edges = in_edges
|
|
50
|
+
build_indexes
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def node(node_id)
|
|
54
|
+
@nodes[node_id]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def has_node?(node_id)
|
|
58
|
+
@nodes.key?(node_id)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Outgoing [dst, edge_data] pairs, optionally filtered to edge kinds.
|
|
62
|
+
def out(node_id, kinds = nil)
|
|
63
|
+
edges = @out_edges[node_id] || []
|
|
64
|
+
return edges if kinds.nil?
|
|
65
|
+
edges.select { |_dst, data| kinds.include?(data["kind"]) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Incoming [src, edge_data] pairs (cross-service references).
|
|
69
|
+
def in_edges(node_id)
|
|
70
|
+
@in_edges[node_id] || []
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def nodes_of_kind(kind)
|
|
74
|
+
(@by_kind[kind] || []).map { |id| [id, @nodes[id]] }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def nodes_in_file(service, file)
|
|
78
|
+
(@by_service_file[[service, file]] || []).map { |id| [id, @nodes[id]] }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def services
|
|
82
|
+
nodes_of_kind("service")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def endpoints(service = nil)
|
|
86
|
+
nodes_of_kind("endpoint").select do |_id, data|
|
|
87
|
+
service.nil? || data["service"] == service
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Smallest node whose line span covers `line` (1-based) in the file's
|
|
92
|
+
# interval list. Ties break toward the later start line, matching the
|
|
93
|
+
# Python bisect walk.
|
|
94
|
+
def covering_symbol(service, file, line)
|
|
95
|
+
spans = @intervals[[service, file]] || []
|
|
96
|
+
best = nil
|
|
97
|
+
spans.reverse_each do |start_line, end_line, node_id|
|
|
98
|
+
next if start_line > line
|
|
99
|
+
next if end_line < line
|
|
100
|
+
size = end_line - start_line
|
|
101
|
+
best = [size, node_id] if best.nil? || size < best[0]
|
|
102
|
+
end
|
|
103
|
+
best && best[1]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Re-anchor service roots from the workspace root stored at index time to
|
|
107
|
+
# WORKSPACE_ROOT. No-op when they match, the meta key is missing, or the
|
|
108
|
+
# rebased directory does not exist.
|
|
109
|
+
def rebase_roots(stored_ws, workspace_root)
|
|
110
|
+
ws = File.expand_path(workspace_root)
|
|
111
|
+
return if stored_ws.nil? || stored_ws.empty?
|
|
112
|
+
return if File.expand_path(stored_ws) == ws
|
|
113
|
+
stored = Pathname.new(File.expand_path(stored_ws))
|
|
114
|
+
services.each do |_id, data|
|
|
115
|
+
root = data["root"]
|
|
116
|
+
next if root.nil? || root.empty?
|
|
117
|
+
begin
|
|
118
|
+
rel = Pathname.new(File.expand_path(root)).relative_path_from(stored).to_s
|
|
119
|
+
rescue ArgumentError
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
next if rel.start_with?("..")
|
|
123
|
+
candidate = rel == "." ? ws : File.join(ws, rel)
|
|
124
|
+
data["root"] = candidate if File.directory?(candidate)
|
|
125
|
+
end
|
|
126
|
+
build_indexes
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def build_indexes
|
|
132
|
+
@by_kind = {}
|
|
133
|
+
@by_service_file = {}
|
|
134
|
+
@intervals = {}
|
|
135
|
+
@nodes.each do |id, data|
|
|
136
|
+
kind = data["kind"]
|
|
137
|
+
(@by_kind[kind] ||= []) << id if kind
|
|
138
|
+
service = data["service"]
|
|
139
|
+
file = data["file"]
|
|
140
|
+
next unless service && file
|
|
141
|
+
key = [service, file]
|
|
142
|
+
(@by_service_file[key] ||= []) << id
|
|
143
|
+
start_line = data["start_line"] || data["line"]
|
|
144
|
+
next if start_line.nil?
|
|
145
|
+
end_line = data["end_line"] || start_line
|
|
146
|
+
(@intervals[key] ||= []) << [start_line, end_line, id]
|
|
147
|
+
end
|
|
148
|
+
@intervals.each_value(&:sort!)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|