woods 1.4.1 → 1.6.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/CHANGELOG.md +208 -0
- data/CONTRIBUTING.md +63 -1
- data/README.md +35 -2
- data/lib/tasks/woods.rake +52 -2
- data/lib/woods/atomic_file.rb +43 -0
- data/lib/woods/cache/cache_middleware.rb +18 -1
- data/lib/woods/console/embedded_executor.rb +20 -2
- data/lib/woods/console/eval_guard.rb +8 -2
- data/lib/woods/console/sql_noise_stripper.rb +113 -0
- data/lib/woods/console/sql_table_scanner.rb +22 -9
- data/lib/woods/console/sql_validator.rb +1 -2
- data/lib/woods/coordination/pipeline_lock.rb +112 -7
- data/lib/woods/dependency_graph.rb +38 -1
- data/lib/woods/embedding/text_preparer.rb +6 -1
- data/lib/woods/export/unit_facts.rb +80 -0
- data/lib/woods/extractor.rb +152 -31
- data/lib/woods/extractors/concern_extractor.rb +3 -5
- data/lib/woods/extractors/configuration_extractor.rb +3 -5
- data/lib/woods/extractors/controller_extractor.rb +1 -1
- data/lib/woods/extractors/database_view_extractor.rb +1 -1
- data/lib/woods/extractors/decorator_extractor.rb +3 -5
- data/lib/woods/extractors/event_extractor.rb +2 -2
- data/lib/woods/extractors/factory_extractor.rb +2 -4
- data/lib/woods/extractors/graphql_extractor.rb +1 -1
- data/lib/woods/extractors/i18n_extractor.rb +6 -4
- data/lib/woods/extractors/job_extractor.rb +4 -6
- data/lib/woods/extractors/lib_extractor.rb +1 -1
- data/lib/woods/extractors/mailer_extractor.rb +1 -1
- data/lib/woods/extractors/manager_extractor.rb +3 -5
- data/lib/woods/extractors/migration_extractor.rb +1 -1
- data/lib/woods/extractors/model_extractor.rb +26 -10
- data/lib/woods/extractors/phlex_extractor.rb +1 -1
- data/lib/woods/extractors/policy_extractor.rb +3 -5
- data/lib/woods/extractors/poro_extractor.rb +1 -1
- data/lib/woods/extractors/pundit_extractor.rb +3 -5
- data/lib/woods/extractors/rake_task_extractor.rb +2 -4
- data/lib/woods/extractors/serializer_extractor.rb +4 -6
- data/lib/woods/extractors/service_extractor.rb +3 -5
- data/lib/woods/extractors/shared_dependency_scanner.rb +88 -16
- data/lib/woods/extractors/shared_utility_methods.rb +14 -0
- data/lib/woods/extractors/state_machine_extractor.rb +2 -4
- data/lib/woods/extractors/validator_extractor.rb +3 -5
- data/lib/woods/extractors/view_component_extractor.rb +1 -1
- data/lib/woods/filename_utils.rb +31 -2
- data/lib/woods/flow_assembler.rb +45 -28
- data/lib/woods/flow_precomputer.rb +2 -1
- data/lib/woods/git_provenance.rb +121 -0
- data/lib/woods/index_artifact.rb +8 -4
- data/lib/woods/mcp/bootstrapper.rb +6 -5
- data/lib/woods/mcp/config_resolver.rb +42 -21
- data/lib/woods/mcp/errors.rb +5 -3
- data/lib/woods/mcp/server.rb +68 -15
- data/lib/woods/mcp/version_aware_tool_dispatch.rb +47 -0
- data/lib/woods/model_name_cache.rb +6 -1
- data/lib/woods/notion/client.rb +4 -1
- data/lib/woods/notion/exporter.rb +7 -1
- data/lib/woods/obsidian/errors.rb +11 -0
- data/lib/woods/obsidian/name_mapper.rb +132 -0
- data/lib/woods/obsidian/note_builder.rb +260 -0
- data/lib/woods/obsidian/vault_assets.rb +104 -0
- data/lib/woods/obsidian/vault_exporter.rb +412 -0
- data/lib/woods/operator/error_escalator.rb +15 -4
- data/lib/woods/resilience/circuit_breaker.rb +98 -24
- data/lib/woods/resolved_config.rb +4 -1
- data/lib/woods/retrieval/ranker.rb +45 -7
- data/lib/woods/retry_after.rb +36 -0
- data/lib/woods/temporal/json_snapshot_store.rb +4 -1
- data/lib/woods/temporal/snapshot_store.rb +4 -1
- data/lib/woods/unblocked/client.rb +4 -1
- data/lib/woods/unblocked/document_builder.rb +21 -27
- data/lib/woods/update_check.rb +190 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods.rb +24 -0
- metadata +15 -4
|
@@ -82,6 +82,119 @@ module Woods
|
|
|
82
82
|
pattern = dialect == :mysql ? SINGLE_QUOTED_MYSQL : SINGLE_QUOTED_POSTGRES
|
|
83
83
|
out.gsub(pattern, "''")
|
|
84
84
|
end
|
|
85
|
+
|
|
86
|
+
# Strip BOTH comments and string literals in a single left-to-right
|
|
87
|
+
# pass, so a comment marker inside a literal and a quote inside a
|
|
88
|
+
# comment are each protected by whichever construct opens first.
|
|
89
|
+
#
|
|
90
|
+
# Running {.strip_comments} then {.strip_literals} (or vice versa) is
|
|
91
|
+
# unsafe: `SELECT '-- ' FROM blocked` has its real `FROM blocked`
|
|
92
|
+
# swallowed as a line comment (the `--` sits inside a string literal),
|
|
93
|
+
# letting a blocked table slip past {TableGate}; the reverse order
|
|
94
|
+
# mis-handles an apostrophe inside a `--` comment. Only a combined scan
|
|
95
|
+
# that tracks literal/comment state correctly resolves both. This
|
|
96
|
+
# scanner backs security checks (SqlValidator, TableGate) so it must
|
|
97
|
+
# never under-detect: an unterminated literal is treated as an ordinary
|
|
98
|
+
# character rather than swallowing the rest of the statement.
|
|
99
|
+
#
|
|
100
|
+
# @param sql [String] the SQL string to process
|
|
101
|
+
# @param dialect [Symbol] `:postgres` (default) or `:mysql` — controls
|
|
102
|
+
# single-quote escape rules (see {.strip_literals}).
|
|
103
|
+
# @return [String] a new string with comments removed and every string
|
|
104
|
+
# literal replaced by `''`
|
|
105
|
+
# @raise [ArgumentError] if an unsupported dialect is provided
|
|
106
|
+
def self.strip_noise(sql, dialect: :postgres) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/AbcSize
|
|
107
|
+
unless SUPPORTED_DIALECTS.include?(dialect)
|
|
108
|
+
raise ArgumentError, "Unknown dialect #{dialect.inspect}. Supported: #{SUPPORTED_DIALECTS.inspect}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
mysql = dialect == :mysql
|
|
112
|
+
out = +''
|
|
113
|
+
i = 0
|
|
114
|
+
len = sql.length
|
|
115
|
+
|
|
116
|
+
while i < len
|
|
117
|
+
ch = sql[i]
|
|
118
|
+
|
|
119
|
+
if ch == "'"
|
|
120
|
+
close = single_quote_end(sql, i, mysql: mysql)
|
|
121
|
+
if close
|
|
122
|
+
out << "''"
|
|
123
|
+
i = close
|
|
124
|
+
else
|
|
125
|
+
# Unterminated literal — never under-detect; keep the char.
|
|
126
|
+
out << ch
|
|
127
|
+
i += 1
|
|
128
|
+
end
|
|
129
|
+
elsif ch == '$' && (tag = dollar_tag_at(sql, i))
|
|
130
|
+
close = sql.index(tag, i + tag.length)
|
|
131
|
+
if close
|
|
132
|
+
out << "''"
|
|
133
|
+
i = close + tag.length
|
|
134
|
+
else
|
|
135
|
+
out << ch
|
|
136
|
+
i += 1
|
|
137
|
+
end
|
|
138
|
+
elsif ch == '-' && sql[i + 1] == '-'
|
|
139
|
+
nl = sql.index("\n", i)
|
|
140
|
+
i = nl || len
|
|
141
|
+
elsif ch == '/' && sql[i + 1] == '*'
|
|
142
|
+
close = sql.index('*/', i + 2)
|
|
143
|
+
if close
|
|
144
|
+
i = close + 2
|
|
145
|
+
else
|
|
146
|
+
# Unterminated block comment: never under-detect. Leave it in
|
|
147
|
+
# place (over-detection is safe; the old regex also required a
|
|
148
|
+
# closing */ and left an unterminated /* untouched).
|
|
149
|
+
out << ch
|
|
150
|
+
i += 1
|
|
151
|
+
end
|
|
152
|
+
else
|
|
153
|
+
out << ch
|
|
154
|
+
i += 1
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
out
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Regexp matching a PostgreSQL dollar-quote opening tag (`$$` or
|
|
162
|
+
# `$tag$`) at the start of the given slice.
|
|
163
|
+
DOLLAR_TAG = /\A\$\w*\$/
|
|
164
|
+
private_constant :DOLLAR_TAG
|
|
165
|
+
|
|
166
|
+
# Return the dollar-quote tag opening at +index+, or nil.
|
|
167
|
+
#
|
|
168
|
+
# @api private
|
|
169
|
+
def self.dollar_tag_at(sql, index)
|
|
170
|
+
m = DOLLAR_TAG.match(sql[index..])
|
|
171
|
+
m && m[0]
|
|
172
|
+
end
|
|
173
|
+
private_class_method :dollar_tag_at
|
|
174
|
+
|
|
175
|
+
# Return the index just past the closing quote of the single-quoted
|
|
176
|
+
# literal that opens at +start+, honoring `''` (both dialects) and `\'`
|
|
177
|
+
# (MySQL only) escapes. Returns nil when the literal is unterminated.
|
|
178
|
+
#
|
|
179
|
+
# @api private
|
|
180
|
+
def self.single_quote_end(sql, start, mysql:)
|
|
181
|
+
i = start + 1
|
|
182
|
+
len = sql.length
|
|
183
|
+
while i < len
|
|
184
|
+
c = sql[i]
|
|
185
|
+
if mysql && c == '\\'
|
|
186
|
+
i += 2
|
|
187
|
+
elsif c == "'"
|
|
188
|
+
return i + 1 unless sql[i + 1] == "'" # closing quote
|
|
189
|
+
|
|
190
|
+
i += 2 # doubled-quote escape — literal continues
|
|
191
|
+
else
|
|
192
|
+
i += 1
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
197
|
+
private_class_method :single_quote_end
|
|
85
198
|
end
|
|
86
199
|
end
|
|
87
200
|
end
|
|
@@ -109,23 +109,36 @@ module Woods
|
|
|
109
109
|
# stripped before scanning. Both JOIN-style and ANSI-89 comma-join syntax
|
|
110
110
|
# are handled.
|
|
111
111
|
#
|
|
112
|
+
# Literals are stripped under BOTH supported dialects and the scans
|
|
113
|
+
# unioned. This scanner backs TableGate, so it may over-detect but must
|
|
114
|
+
# never under-detect: stripping with the wrong dialect's escape rules
|
|
115
|
+
# can swallow a real FROM clause — e.g. MySQL's `\'` escape applied on
|
|
116
|
+
# a PostgreSQL host (where backslash is literal under
|
|
117
|
+
# standard_conforming_strings) folds `'x\' FROM blocked WHERE y = '`
|
|
118
|
+
# into one literal, hiding `blocked` from the gate while PostgreSQL
|
|
119
|
+
# genuinely reads that table.
|
|
120
|
+
#
|
|
112
121
|
# @param sql [String, nil] the SQL string to scan
|
|
113
|
-
# @return [Array<String>] identifiers in
|
|
114
|
-
# may contain duplicates if the same table is referenced multiple times
|
|
122
|
+
# @return [Array<String>] identifiers in first-encounter order, deduplicated
|
|
115
123
|
def self.identifiers_in(sql)
|
|
116
124
|
return [] if sql.nil? || sql.empty?
|
|
117
125
|
|
|
118
|
-
stripped = strip_noise(sql)
|
|
119
126
|
results = []
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
127
|
+
%i[postgres mysql].each do |dialect|
|
|
128
|
+
stripped = strip_noise(sql, dialect: dialect)
|
|
129
|
+
collect_join_identifiers(stripped, results)
|
|
130
|
+
collect_from_identifiers(stripped, results)
|
|
131
|
+
end
|
|
132
|
+
results.uniq
|
|
123
133
|
end
|
|
124
134
|
|
|
125
135
|
# @api private
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
136
|
+
# Comments and literals must be stripped in a single combined pass —
|
|
137
|
+
# stripping them separately lets a comment marker inside a literal
|
|
138
|
+
# (`'-- '`) hide a real FROM clause from the gate. See
|
|
139
|
+
# {SqlNoiseStripper.strip_noise}.
|
|
140
|
+
def self.strip_noise(sql, dialect:)
|
|
141
|
+
SqlNoiseStripper.strip_noise(sql, dialect: dialect)
|
|
129
142
|
end
|
|
130
143
|
private_class_method :strip_noise
|
|
131
144
|
|
|
@@ -152,8 +152,7 @@ module Woods
|
|
|
152
152
|
# @param sql [String]
|
|
153
153
|
# @return [Boolean]
|
|
154
154
|
def contains_multiple_statements?(sql)
|
|
155
|
-
stripped = SqlNoiseStripper.
|
|
156
|
-
stripped = SqlNoiseStripper.strip_literals(stripped)
|
|
155
|
+
stripped = SqlNoiseStripper.strip_noise(sql)
|
|
157
156
|
stripped.include?(';')
|
|
158
157
|
end
|
|
159
158
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'json'
|
|
5
|
+
require 'securerandom'
|
|
5
6
|
|
|
6
7
|
module Woods
|
|
7
8
|
module Coordination
|
|
@@ -38,12 +39,13 @@ module Woods
|
|
|
38
39
|
def acquire
|
|
39
40
|
FileUtils.mkdir_p(@lock_dir)
|
|
40
41
|
|
|
41
|
-
# Check for stale lock first (separate from atomic creation)
|
|
42
42
|
if File.exist?(@lock_path)
|
|
43
43
|
return false unless stale?
|
|
44
|
-
|
|
45
|
-
#
|
|
46
|
-
|
|
44
|
+
# Retire the stale lock atomically. A bare rm_f + create here is
|
|
45
|
+
# a TOCTOU race: two processes passing the stale check together
|
|
46
|
+
# could each delete-and-create, the second deleting the first's
|
|
47
|
+
# FRESH lock — both would then "hold" it.
|
|
48
|
+
return false unless retire_stale_lock
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
# Atomic lock creation: File::EXCL ensures this fails if file already exists
|
|
@@ -58,10 +60,37 @@ module Woods
|
|
|
58
60
|
|
|
59
61
|
# Release the lock.
|
|
60
62
|
#
|
|
63
|
+
# Deletes the lock file only if it still carries this instance's
|
|
64
|
+
# token — a run that outlived stale_timeout may have been
|
|
65
|
+
# legitimately taken over, and deleting unconditionally would drop
|
|
66
|
+
# the new holder's lock.
|
|
67
|
+
#
|
|
61
68
|
# @return [void]
|
|
62
69
|
def release
|
|
63
|
-
|
|
70
|
+
return unless @held
|
|
71
|
+
|
|
72
|
+
# Clear @held up front so no later failure can leave this instance
|
|
73
|
+
# believing it still holds the lock.
|
|
64
74
|
@held = false
|
|
75
|
+
|
|
76
|
+
# Rename first, then inspect: a plain read-then-unlink is a TOCTOU —
|
|
77
|
+
# after we read our own token a takeover could replace the file, and
|
|
78
|
+
# our unlink would then delete the NEW holder's lock. Renaming
|
|
79
|
+
# atomically captures whatever is at the path.
|
|
80
|
+
graveyard = "#{@lock_path}.release.#{Process.pid}.#{SecureRandom.hex(4)}"
|
|
81
|
+
begin
|
|
82
|
+
File.rename(@lock_path, graveyard)
|
|
83
|
+
rescue Errno::ENOENT
|
|
84
|
+
return # already gone
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if own_lock?(graveyard)
|
|
88
|
+
FileUtils.rm_f(graveyard)
|
|
89
|
+
else
|
|
90
|
+
# We were legitimately taken over — put the successor's lock back
|
|
91
|
+
# without clobbering a still-newer holder (see {#restore_lock}).
|
|
92
|
+
restore_lock(graveyard)
|
|
93
|
+
end
|
|
65
94
|
end
|
|
66
95
|
|
|
67
96
|
# Execute a block while holding the lock.
|
|
@@ -100,9 +129,85 @@ module Woods
|
|
|
100
129
|
true
|
|
101
130
|
end
|
|
102
131
|
|
|
103
|
-
#
|
|
132
|
+
# Atomically retire a stale lock file via rename. Rename is atomic on
|
|
133
|
+
# POSIX: of any processes racing to take over the same stale lock,
|
|
134
|
+
# exactly one rename succeeds; the losers get ENOENT and back off.
|
|
135
|
+
# Winning the rename does NOT guarantee winning the lock — another
|
|
136
|
+
# process may O_EXCL-create between our rename and our create, which
|
|
137
|
+
# the caller's EEXIST rescue handles.
|
|
138
|
+
#
|
|
139
|
+
# Rename alone is not enough, though: a competitor that already passed
|
|
140
|
+
# `stale?` on the SAME original file may have retired it and created a
|
|
141
|
+
# FRESH lock before we run. Our rename would then move that fresh lock
|
|
142
|
+
# aside — and both processes would "hold" the lock. So after winning
|
|
143
|
+
# the rename we re-check the retired file's age; if it turns out to be
|
|
144
|
+
# fresh (someone beat us to the takeover), we put it back and lose the
|
|
145
|
+
# race instead of clobbering a live holder.
|
|
146
|
+
#
|
|
147
|
+
# @return [Boolean] true if this process retired a genuinely stale lock
|
|
148
|
+
def retire_stale_lock
|
|
149
|
+
graveyard = "#{@lock_path}.stale.#{Process.pid}.#{SecureRandom.hex(4)}"
|
|
150
|
+
File.rename(@lock_path, graveyard)
|
|
151
|
+
|
|
152
|
+
unless stale_file?(graveyard)
|
|
153
|
+
# We grabbed a lock that is no longer stale — a competitor already
|
|
154
|
+
# took over. Restore it (without clobbering a still-newer holder)
|
|
155
|
+
# and back off.
|
|
156
|
+
restore_lock(graveyard)
|
|
157
|
+
return false
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
FileUtils.rm_f(graveyard)
|
|
161
|
+
true
|
|
162
|
+
rescue Errno::ENOENT
|
|
163
|
+
false
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Whether the lock file at +path+ carries this instance's token.
|
|
167
|
+
#
|
|
168
|
+
# @param path [String]
|
|
169
|
+
# @return [Boolean] true when the token matches, or the file is corrupt
|
|
170
|
+
# (an unparseable lock we already renamed aside is treated as ours to
|
|
171
|
+
# discard rather than restore).
|
|
172
|
+
def own_lock?(path)
|
|
173
|
+
JSON.parse(File.read(path))['token'] == @token
|
|
174
|
+
rescue JSON::ParserError
|
|
175
|
+
true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Put a lock file we renamed aside back at @lock_path WITHOUT clobbering
|
|
179
|
+
# a lock another process may have O_EXCL-created in the meantime.
|
|
180
|
+
# `File.link` is atomic and fails with EEXIST if @lock_path already
|
|
181
|
+
# exists, so a newer holder always wins; the aside copy is discarded
|
|
182
|
+
# either way. A plain `File.rename` back would overwrite that newer
|
|
183
|
+
# holder's lock — reintroducing a double-hold.
|
|
184
|
+
#
|
|
185
|
+
# @param graveyard [String] path of the renamed-aside lock file
|
|
186
|
+
# @return [void]
|
|
187
|
+
def restore_lock(graveyard)
|
|
188
|
+
File.link(graveyard, @lock_path)
|
|
189
|
+
rescue Errno::EEXIST
|
|
190
|
+
# A newer holder already claimed the path — our copy is obsolete.
|
|
191
|
+
nil
|
|
192
|
+
ensure
|
|
193
|
+
FileUtils.rm_f(graveyard)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Whether the file at +path+ is older than the stale timeout.
|
|
197
|
+
#
|
|
198
|
+
# @param path [String]
|
|
199
|
+
# @return [Boolean]
|
|
200
|
+
def stale_file?(path)
|
|
201
|
+
Time.now - File.mtime(path) > @stale_timeout
|
|
202
|
+
rescue Errno::ENOENT
|
|
203
|
+
false
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# @return [String] Lock file content (JSON with PID, timestamp, and
|
|
207
|
+
# an ownership token release verifies before deleting)
|
|
104
208
|
def lock_content
|
|
105
|
-
|
|
209
|
+
@token = SecureRandom.hex(8)
|
|
210
|
+
JSON.generate(pid: Process.pid, locked_at: Time.now.iso8601, name: @name, token: @token)
|
|
106
211
|
end
|
|
107
212
|
end
|
|
108
213
|
end
|
|
@@ -31,12 +31,20 @@ module Woods
|
|
|
31
31
|
@to_h = nil
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
# Register a unit in the graph
|
|
34
|
+
# Register a unit in the graph.
|
|
35
|
+
#
|
|
36
|
+
# Re-registering an identifier (incremental extraction registers into a
|
|
37
|
+
# graph loaded from disk) first removes the previous registration's
|
|
38
|
+
# reverse edges, file-map entry, and type-index entry — otherwise stale
|
|
39
|
+
# dependents accumulate across incremental runs and get persisted back
|
|
40
|
+
# to dependency_graph.json.
|
|
35
41
|
#
|
|
36
42
|
# @param unit [ExtractedUnit] The unit to register
|
|
37
43
|
def register(unit)
|
|
38
44
|
@to_h = nil
|
|
39
45
|
|
|
46
|
+
unregister(unit.identifier) if @nodes.key?(unit.identifier)
|
|
47
|
+
|
|
40
48
|
@nodes[unit.identifier] = {
|
|
41
49
|
type: unit.type,
|
|
42
50
|
file_path: unit.file_path,
|
|
@@ -56,6 +64,35 @@ module Woods
|
|
|
56
64
|
end
|
|
57
65
|
end
|
|
58
66
|
|
|
67
|
+
# Remove an identifier's registration side effects: its contribution to
|
|
68
|
+
# the reverse indexes (derived from its recorded forward edges), its
|
|
69
|
+
# file-map entry, and its type-index entry. Forward node/edge data is
|
|
70
|
+
# overwritten by the caller (register), so it is not cleared here.
|
|
71
|
+
#
|
|
72
|
+
# @param identifier [String] Previously-registered unit identifier
|
|
73
|
+
# @return [void]
|
|
74
|
+
def unregister(identifier)
|
|
75
|
+
(@edges[identifier] || []).each do |edge|
|
|
76
|
+
if (set = @reverse[edge[:target]])
|
|
77
|
+
set.delete(identifier)
|
|
78
|
+
@reverse.delete(edge[:target]) if set.empty?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
via_key = [edge[:target], edge[:via]]
|
|
82
|
+
next unless (set = @reverse_via[via_key])
|
|
83
|
+
|
|
84
|
+
set.delete(identifier)
|
|
85
|
+
@reverse_via.delete(via_key) if set.empty?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
old_node = @nodes[identifier]
|
|
89
|
+
return unless old_node
|
|
90
|
+
|
|
91
|
+
old_path = old_node[:file_path]
|
|
92
|
+
@file_map.delete(old_path) if old_path && @file_map[old_path] == identifier
|
|
93
|
+
@type_index[old_node[:type]]&.delete(identifier)
|
|
94
|
+
end
|
|
95
|
+
|
|
59
96
|
# Find all units affected by changes to given files
|
|
60
97
|
# Uses BFS to find transitive dependents
|
|
61
98
|
#
|
|
@@ -95,7 +95,12 @@ module Woods
|
|
|
95
95
|
def append_dependency_line(lines, dependencies)
|
|
96
96
|
return unless dependencies&.any?
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
# Dependency hashes arrive symbol-keyed from the extractor's
|
|
99
|
+
# in-memory units but string-keyed from the indexer (Indexer#build_unit
|
|
100
|
+
# reads JSON and does not symbolize dependency keys, unlike chunks).
|
|
101
|
+
# Read both forms or the whole "dependencies:" prefix silently
|
|
102
|
+
# vanishes from every embedded document on the indexing path.
|
|
103
|
+
dep_names = dependencies.filter_map { |d| d[:target] || d['target'] }.first(10)
|
|
99
104
|
lines << "dependencies: #{dep_names.join(', ')}" if dep_names.any?
|
|
100
105
|
end
|
|
101
106
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
# Shared building blocks for the export formatters (Unblocked DocumentBuilder,
|
|
5
|
+
# Obsidian NoteBuilder).
|
|
6
|
+
module Export
|
|
7
|
+
# Format-agnostic extraction of facts from a unit's metadata.
|
|
8
|
+
#
|
|
9
|
+
# Both the Unblocked and Obsidian exporters need the same *facts* about a
|
|
10
|
+
# unit — its associations grouped by macro, its schema highlights — but
|
|
11
|
+
# render them differently (plain text + GitHub URIs vs. wikilinks +
|
|
12
|
+
# frontmatter). This value object owns the one definition of "what a unit's
|
|
13
|
+
# metadata says"; each exporter is a thin formatter over it. The returned
|
|
14
|
+
# structures are deliberately raw (no truncation, no ordering imposed beyond
|
|
15
|
+
# the source array): formatting decisions — sort order, top-N caps — belong
|
|
16
|
+
# to each formatter so neither exporter's output is constrained by the other.
|
|
17
|
+
class UnitFacts
|
|
18
|
+
# @param unit [Hash] parsed unit JSON
|
|
19
|
+
def initialize(unit)
|
|
20
|
+
@meta = (unit && unit['metadata']) || {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Associations grouped by macro, each as a structured entry. Group keys are
|
|
24
|
+
# whatever macros are present (insertion order from the source array); the
|
|
25
|
+
# formatter picks the display order.
|
|
26
|
+
#
|
|
27
|
+
# @return [Hash{String=>Array<Hash>}] macro => [{ target:, dependent: }]
|
|
28
|
+
def associations_by_type
|
|
29
|
+
associations.group_by { |assoc| assoc['type'] }.transform_values do |items|
|
|
30
|
+
items.map { |a| { target: a['target'] || a['name'], dependent: a.dig('options', 'dependent') } }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Integer] total association count across all macros
|
|
35
|
+
def association_count
|
|
36
|
+
associations.size
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Hash] { enums:, scopes:, concerns:, callbacks: } — raw, untruncated
|
|
40
|
+
def schema_highlights
|
|
41
|
+
{ enums: enums, scopes: scopes, concerns: concerns, callbacks: callbacks }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def enums
|
|
45
|
+
@meta['enums'].is_a?(Hash) ? @meta['enums'] : {}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def scopes
|
|
49
|
+
@meta['scopes'].is_a?(Array) ? @meta['scopes'].filter_map { |s| s['name'] } : []
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def concerns
|
|
53
|
+
@meta['inlined_concerns'].is_a?(Array) ? @meta['inlined_concerns'] : []
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def callbacks
|
|
57
|
+
return [] unless @meta['callbacks'].is_a?(Array)
|
|
58
|
+
|
|
59
|
+
@meta['callbacks'].map { |cb| { type: cb['type'], filter: cb['filter'] } }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Blast-radius bucket for a dependent count.
|
|
63
|
+
#
|
|
64
|
+
# @param dependent_count [Integer]
|
|
65
|
+
# @return [Symbol] :high (>50), :moderate (>20), or :none
|
|
66
|
+
def self.blast_radius(dependent_count)
|
|
67
|
+
return :high if dependent_count > 50
|
|
68
|
+
return :moderate if dependent_count > 20
|
|
69
|
+
|
|
70
|
+
:none
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def associations
|
|
76
|
+
@meta['associations'].is_a?(Array) ? @meta['associations'] : []
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|