woods 1.5.0 → 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 +160 -0
- data/CONTRIBUTING.md +30 -1
- data/README.md +31 -0
- 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 +138 -26
- 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/index_artifact.rb +5 -1
- 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/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 +12 -2
|
@@ -256,7 +256,7 @@ module Woods
|
|
|
256
256
|
return 0.5 unless unit
|
|
257
257
|
return 0.5 unless classification.target_type
|
|
258
258
|
|
|
259
|
-
unit_type = dig_metadata(unit, :type)
|
|
259
|
+
unit_type = dig_metadata(unit, :type)
|
|
260
260
|
unit_type&.to_sym == classification.target_type ? 1.0 : 0.3
|
|
261
261
|
end
|
|
262
262
|
|
|
@@ -297,19 +297,57 @@ module Woods
|
|
|
297
297
|
penalty
|
|
298
298
|
end
|
|
299
299
|
|
|
300
|
-
# Dig
|
|
300
|
+
# Dig a value out of a unit's metadata, tolerating both symbol and
|
|
301
|
+
# string keys at every level.
|
|
302
|
+
#
|
|
303
|
+
# Units come from the metadata store, which returns STRING keys on
|
|
304
|
+
# every real backend (SQLite round-trips through JSON.parse;
|
|
305
|
+
# InMemory#store calls stringify_keys). The previous implementation
|
|
306
|
+
# probed only symbol keys, so recency/importance/type_match/diversity
|
|
307
|
+
# silently scored every unit at their neutral fallback in production —
|
|
308
|
+
# the specs passed only because they stub symbol-keyed units.
|
|
309
|
+
#
|
|
310
|
+
# Looks under the `metadata` wrapper first, then falls back to a
|
|
311
|
+
# top-level field for single-key lookups (namespace/type live on the
|
|
312
|
+
# unit itself, not under metadata).
|
|
301
313
|
#
|
|
302
314
|
# @param unit [Hash, Object] Unit data
|
|
303
|
-
# @param keys [Array<Symbol>] Key path
|
|
315
|
+
# @param keys [Array<Symbol>] Key path within metadata
|
|
304
316
|
# @return [Object, nil]
|
|
305
317
|
def dig_metadata(unit, *keys)
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
318
|
+
return nil unless unit.is_a?(Hash)
|
|
319
|
+
|
|
320
|
+
nested = dig_either(fetch_either(unit, :metadata), keys)
|
|
321
|
+
return nested unless nested.nil?
|
|
322
|
+
|
|
323
|
+
keys.size == 1 ? fetch_either(unit, keys.first) : nil
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Walk a key path through nested hashes, trying both key forms.
|
|
327
|
+
#
|
|
328
|
+
# @param hash [Object] Starting hash (nil-safe)
|
|
329
|
+
# @param keys [Array<Symbol>] Key path
|
|
330
|
+
# @return [Object, nil]
|
|
331
|
+
def dig_either(hash, keys)
|
|
332
|
+
keys.reduce(hash) do |acc, key|
|
|
333
|
+
break nil unless acc.is_a?(Hash)
|
|
334
|
+
|
|
335
|
+
fetch_either(acc, key)
|
|
310
336
|
end
|
|
311
337
|
end
|
|
312
338
|
|
|
339
|
+
# Fetch a key trying its symbol form, then its string form.
|
|
340
|
+
#
|
|
341
|
+
# @param hash [Object] Hash to read (nil-safe)
|
|
342
|
+
# @param key [Symbol, String] Key to fetch
|
|
343
|
+
# @return [Object, nil]
|
|
344
|
+
def fetch_either(hash, key)
|
|
345
|
+
return nil unless hash.is_a?(Hash)
|
|
346
|
+
|
|
347
|
+
value = hash[key]
|
|
348
|
+
value.nil? ? hash[key.to_s] : value
|
|
349
|
+
end
|
|
350
|
+
|
|
313
351
|
# Build a Candidate struct compatible with SearchExecutor::Candidate.
|
|
314
352
|
#
|
|
315
353
|
# @return [Candidate-like Struct]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time' # Time.httpdate
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
# Parse an HTTP +Retry-After+ header into a number of seconds to wait.
|
|
7
|
+
#
|
|
8
|
+
# Per RFC 9110 the header is either a non-negative integer count of seconds
|
|
9
|
+
# or an HTTP-date. Calling +.to_f+ on the header directly (the naive
|
|
10
|
+
# approach) turns the HTTP-date form into +0.0+, so a client honoring it
|
|
11
|
+
# would retry immediately and hammer a server that is actively asking it to
|
|
12
|
+
# back off. This helper handles both forms and falls back to a caller-supplied
|
|
13
|
+
# value when the header is absent or unparseable.
|
|
14
|
+
module RetryAfter
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# @param header [String, nil] the raw +Retry-After+ header value
|
|
18
|
+
# @param fallback [Numeric] seconds to use when the header is missing or unparseable
|
|
19
|
+
# @param now [Time] reference time for the HTTP-date form (injectable for tests)
|
|
20
|
+
# @return [Float] non-negative seconds to wait
|
|
21
|
+
def seconds(header, fallback:, now: Time.now)
|
|
22
|
+
value = header.to_s.strip
|
|
23
|
+
return fallback.to_f if value.empty?
|
|
24
|
+
|
|
25
|
+
# delta-seconds form: a bare non-negative integer.
|
|
26
|
+
return value.to_f if value.match?(/\A\d+\z/)
|
|
27
|
+
|
|
28
|
+
# HTTP-date form: wait until that instant, never negative.
|
|
29
|
+
begin
|
|
30
|
+
[Time.httpdate(value) - now, 0.0].max
|
|
31
|
+
rescue ArgumentError
|
|
32
|
+
fallback.to_f
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -4,6 +4,7 @@ require 'json'
|
|
|
4
4
|
require 'net/http'
|
|
5
5
|
require 'uri'
|
|
6
6
|
require 'woods'
|
|
7
|
+
require_relative '../retry_after'
|
|
7
8
|
require_relative 'rate_limiter'
|
|
8
9
|
|
|
9
10
|
module Woods
|
|
@@ -175,7 +176,9 @@ module Woods
|
|
|
175
176
|
|
|
176
177
|
if response.code == '429' && retries < MAX_RETRIES
|
|
177
178
|
retries += 1
|
|
178
|
-
|
|
179
|
+
# Retry-After may be an HTTP-date, which .to_f would collapse to
|
|
180
|
+
# 0.0 — honoring it as-is would hammer a throttling server.
|
|
181
|
+
wait_time = Woods::RetryAfter.seconds(response['Retry-After'], fallback: retries * 2)
|
|
179
182
|
sleep(wait_time)
|
|
180
183
|
next
|
|
181
184
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'woods/export/unit_facts'
|
|
4
|
+
|
|
3
5
|
module Woods
|
|
4
6
|
module Unblocked
|
|
5
7
|
# Converts extracted unit JSON into condensed Markdown documents
|
|
@@ -96,10 +98,10 @@ module Woods
|
|
|
96
98
|
sections = []
|
|
97
99
|
|
|
98
100
|
sections << model_header(unit, meta)
|
|
99
|
-
sections << model_associations(
|
|
101
|
+
sections << model_associations(unit)
|
|
100
102
|
sections << model_dependents(unit)
|
|
101
103
|
sections << model_entry_points(unit)
|
|
102
|
-
sections << model_schema_highlights(
|
|
104
|
+
sections << model_schema_highlights(unit)
|
|
103
105
|
sections << model_side_effects(unit)
|
|
104
106
|
|
|
105
107
|
sections.compact.join("\n\n")
|
|
@@ -116,22 +118,18 @@ module Woods
|
|
|
116
118
|
parts.join("\n")
|
|
117
119
|
end
|
|
118
120
|
|
|
119
|
-
def model_associations(
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
def model_associations(unit)
|
|
122
|
+
facts = Woods::Export::UnitFacts.new(unit)
|
|
123
|
+
by_type = facts.associations_by_type
|
|
124
|
+
return nil if by_type.empty?
|
|
122
125
|
|
|
123
|
-
|
|
124
|
-
lines = ["## Associations (#{assocs.size})"]
|
|
126
|
+
lines = ["## Associations (#{facts.association_count})"]
|
|
125
127
|
|
|
126
128
|
%w[belongs_to has_many has_one has_and_belongs_to_many].each do |type|
|
|
127
|
-
items =
|
|
129
|
+
items = by_type[type]
|
|
128
130
|
next unless items&.any?
|
|
129
131
|
|
|
130
|
-
targets = items.map
|
|
131
|
-
name = a['target'] || a['name']
|
|
132
|
-
dep = a.dig('options', 'dependent')
|
|
133
|
-
dep ? "#{name} (#{dep})" : name
|
|
134
|
-
end
|
|
132
|
+
targets = items.map { |a| a[:dependent] ? "#{a[:target]} (#{a[:dependent]})" : a[:target] }
|
|
135
133
|
# Sorted so the body is a function of association content, not order
|
|
136
134
|
# (the exporter hashes this body to detect changes).
|
|
137
135
|
lines << "**#{type}:** #{targets.sort.join(', ')}"
|
|
@@ -176,25 +174,21 @@ module Woods
|
|
|
176
174
|
lines.join("\n")
|
|
177
175
|
end
|
|
178
176
|
|
|
179
|
-
def model_schema_highlights(
|
|
177
|
+
def model_schema_highlights(unit)
|
|
178
|
+
highlights = Woods::Export::UnitFacts.new(unit).schema_highlights
|
|
180
179
|
parts = []
|
|
181
180
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
.map { |name, values| "#{name} (#{format_enum_values(values)})" }
|
|
181
|
+
if highlights[:enums].any?
|
|
182
|
+
enum_strs = highlights[:enums].sort_by { |name, _| name.to_s }
|
|
183
|
+
.map { |name, values| "#{name} (#{format_enum_values(values)})" }
|
|
186
184
|
parts << "**Enums:** #{enum_strs.join('; ')}"
|
|
187
185
|
end
|
|
188
186
|
|
|
189
|
-
scopes
|
|
190
|
-
parts << "**
|
|
191
|
-
|
|
192
|
-
concerns = meta['inlined_concerns']
|
|
193
|
-
parts << "**Concerns:** #{concerns.sort.join(', ')}" if concerns.is_a?(Array) && concerns.any?
|
|
187
|
+
parts << "**Scopes:** #{highlights[:scopes].sort.join(', ')}" if highlights[:scopes].any?
|
|
188
|
+
parts << "**Concerns:** #{highlights[:concerns].sort.join(', ')}" if highlights[:concerns].any?
|
|
194
189
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
parts << "**Callbacks (#{callbacks.size}):** #{format_callbacks(callbacks)}"
|
|
190
|
+
if highlights[:callbacks].any?
|
|
191
|
+
parts << "**Callbacks (#{highlights[:callbacks].size}):** #{format_callbacks(highlights[:callbacks])}"
|
|
198
192
|
end
|
|
199
193
|
|
|
200
194
|
return nil if parts.empty?
|
|
@@ -333,7 +327,7 @@ module Woods
|
|
|
333
327
|
def format_callbacks(callbacks)
|
|
334
328
|
# Sort before truncating so both the selection and order are stable
|
|
335
329
|
# regardless of input order (the body is hashed for change detection).
|
|
336
|
-
callbacks.map { |cb| "#{cb[
|
|
330
|
+
callbacks.map { |cb| "#{cb[:type]}: #{cb[:filter]}" }.sort.first(5).join(', ')
|
|
337
331
|
end
|
|
338
332
|
end
|
|
339
333
|
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
require_relative 'version'
|
|
7
|
+
require_relative 'atomic_file'
|
|
8
|
+
|
|
9
|
+
module Woods
|
|
10
|
+
# Best-effort "is a newer Woods release available?" check.
|
|
11
|
+
#
|
|
12
|
+
# Modeled on grove's background update notifier: it queries RubyGems for the
|
|
13
|
+
# latest published +woods+ version, caches the answer on disk for 24h, and is
|
|
14
|
+
# fully non-fatal — any network, parse, or filesystem failure degrades to
|
|
15
|
+
# "no update signal" rather than raising. It exists so the MCP server can tell
|
|
16
|
+
# an agent (and, through it, the user) that the installed gem is behind, which
|
|
17
|
+
# matters because the distributed guide skills operate against whatever version
|
|
18
|
+
# is installed.
|
|
19
|
+
#
|
|
20
|
+
# Two surfaces consume this:
|
|
21
|
+
# * {Woods::MCP::Server.build_status} embeds {status_hash} under
|
|
22
|
+
# +server.update+ so +woods_status+ reports update availability.
|
|
23
|
+
# * {Woods::MCP::VersionAwareToolDispatch} uses {tool_not_found_message} to
|
|
24
|
+
# turn a bare "Tool not found" into version-aware, self-healing guidance.
|
|
25
|
+
#
|
|
26
|
+
# Disable entirely with +WOODS_NO_UPDATE_CHECK=1+.
|
|
27
|
+
#
|
|
28
|
+
# @example
|
|
29
|
+
# Woods::UpdateCheck.status_hash
|
|
30
|
+
# # => { current_version: "1.5.0", latest_version: "1.6.0", update_available: true }
|
|
31
|
+
module UpdateCheck
|
|
32
|
+
# RubyGems endpoint returning +{ "version": "x.y.z" }+ for the latest release.
|
|
33
|
+
RUBYGEMS_LATEST_URL = 'https://rubygems.org/api/v1/versions/woods/latest.json'
|
|
34
|
+
# How long a successful result is trusted before a re-fetch is attempted.
|
|
35
|
+
CACHE_TTL = 24 * 60 * 60
|
|
36
|
+
# A *failed* probe is cached for a shorter window, so an unreachable or slow
|
|
37
|
+
# RubyGems throttles retries (rather than re-blocking on every call) without
|
|
38
|
+
# hiding a real update for a full day once connectivity returns.
|
|
39
|
+
FAILURE_TTL = 60 * 60
|
|
40
|
+
# Open/read timeout for the (best-effort) network probe, in seconds.
|
|
41
|
+
HTTP_TIMEOUT = 1.5
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
# Resolve update availability, using the on-disk cache when fresh and
|
|
46
|
+
# otherwise fetching once and caching the result.
|
|
47
|
+
#
|
|
48
|
+
# @param current [String] Installed version (defaults to {Woods::VERSION})
|
|
49
|
+
# @param cache_path [String] Path to the JSON cache file
|
|
50
|
+
# @param ttl [Integer] Cache lifetime in seconds
|
|
51
|
+
# @param now [Time] Injected clock (for deterministic specs)
|
|
52
|
+
# @param fetcher [#call] Callable taking the URL and returning a version
|
|
53
|
+
# string or nil; injectable for tests
|
|
54
|
+
# @return [Hash] +{ current:, latest:, update_available: }+ (latest may be nil)
|
|
55
|
+
def check(current: Woods::VERSION, cache_path: default_cache_path, ttl: CACHE_TTL,
|
|
56
|
+
now: Time.now, fetcher: method(:fetch_latest_version))
|
|
57
|
+
return result(current, nil) if disabled?
|
|
58
|
+
|
|
59
|
+
result(current, cached_or_refreshed_latest(cache_path, ttl, now, fetcher))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The +server.update+ sub-hash for +woods_status+. Keys are snake_case to
|
|
63
|
+
# match the rest of the status payload's JSON shape.
|
|
64
|
+
#
|
|
65
|
+
# @return [Hash] +{ current_version:, latest_version:, update_available: }+
|
|
66
|
+
def status_hash(current: Woods::VERSION, **opts)
|
|
67
|
+
r = check(current: current, **opts)
|
|
68
|
+
{
|
|
69
|
+
current_version: r[:current],
|
|
70
|
+
latest_version: r[:latest],
|
|
71
|
+
update_available: r[:update_available]
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Version-aware replacement for the MCP layer's bare "Tool not found"
|
|
76
|
+
# message. Cache-only — it never triggers a network fetch, since it runs on
|
|
77
|
+
# an error path that must stay cheap.
|
|
78
|
+
#
|
|
79
|
+
# @param tool_name [String] The unrecognized tool name
|
|
80
|
+
# @param current [String] Installed version
|
|
81
|
+
# @return [String] Guidance an agent can relay to the user
|
|
82
|
+
def tool_not_found_message(tool_name, current: Woods::VERSION, cache_path: default_cache_path, fetcher: nil)
|
|
83
|
+
_ = fetcher # accepted so callers/specs can prove it is never invoked on this path
|
|
84
|
+
latest = disabled? ? nil : read_cache(cache_path)&.fetch('latest', nil)
|
|
85
|
+
msg = "Tool not found: #{tool_name}. This tool is not available in the installed " \
|
|
86
|
+
"Woods v#{current}. It may require a newer release — advise the user to run " \
|
|
87
|
+
'`bundle update woods`, then reconnect the MCP server.'
|
|
88
|
+
msg << " (latest published: #{latest})" if latest && newer?(latest, current)
|
|
89
|
+
msg
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# --- internals -----------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def disabled?
|
|
95
|
+
ENV['WOODS_NO_UPDATE_CHECK'] == '1'
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def result(current, latest)
|
|
99
|
+
{ current: current, latest: latest, update_available: latest ? newer?(latest, current) : false }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def newer?(latest, current)
|
|
103
|
+
Gem::Version.new(latest) > Gem::Version.new(current)
|
|
104
|
+
rescue ArgumentError
|
|
105
|
+
false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Return the cached latest version when the cache entry is still fresh
|
|
109
|
+
# (a shorter window applies to a previously-failed probe), otherwise fetch
|
|
110
|
+
# once and cache the outcome — success *or* failure.
|
|
111
|
+
#
|
|
112
|
+
# @return [String, nil]
|
|
113
|
+
def cached_or_refreshed_latest(cache_path, ttl, now, fetcher)
|
|
114
|
+
entry = read_cache(cache_path)
|
|
115
|
+
return entry['latest'] if entry && fresh_entry?(entry, ttl, now)
|
|
116
|
+
|
|
117
|
+
refresh(cache_path, now, fetcher)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A success entry is trusted for +ttl+; a failure entry (nil latest) only
|
|
121
|
+
# for {FAILURE_TTL}.
|
|
122
|
+
def fresh_entry?(entry, ttl, now)
|
|
123
|
+
checked_at = entry['checked_at']
|
|
124
|
+
return false unless checked_at.is_a?(Numeric)
|
|
125
|
+
|
|
126
|
+
effective_ttl = entry['latest'] ? ttl : FAILURE_TTL
|
|
127
|
+
(now.to_i - checked_at) < effective_ttl
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Fetch and cache the outcome. A nil latest (unreachable RubyGems, non-2xx,
|
|
131
|
+
# unparseable body) is cached too, so repeated failures are throttled by
|
|
132
|
+
# {FAILURE_TTL} instead of re-probing on every call.
|
|
133
|
+
#
|
|
134
|
+
# @return [String, nil] the latest version, or nil on failure
|
|
135
|
+
def refresh(cache_path, now, fetcher)
|
|
136
|
+
latest = fetcher.call(RUBYGEMS_LATEST_URL)
|
|
137
|
+
write_cache(cache_path, latest, now)
|
|
138
|
+
latest
|
|
139
|
+
rescue StandardError
|
|
140
|
+
write_cache(cache_path, nil, now)
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @return [Hash, nil] the parsed cache entry, or nil if absent, unreadable,
|
|
145
|
+
# or not a JSON object (guards the "never raise" contract against a
|
|
146
|
+
# corrupt/tampered cache holding e.g. +[]+ or +42+)
|
|
147
|
+
def read_cache(cache_path)
|
|
148
|
+
return nil unless File.exist?(cache_path)
|
|
149
|
+
|
|
150
|
+
parsed = JSON.parse(File.read(cache_path))
|
|
151
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
152
|
+
rescue StandardError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def write_cache(cache_path, latest, now)
|
|
157
|
+
Woods::AtomicFile.write(cache_path, JSON.generate('latest' => latest, 'checked_at' => now.to_i))
|
|
158
|
+
rescue StandardError
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Default per-user cache location, honoring XDG, falling back to tmpdir.
|
|
163
|
+
def default_cache_path
|
|
164
|
+
base = ENV.fetch('XDG_CACHE_HOME', nil)
|
|
165
|
+
base = File.join(Dir.home, '.cache') if base.nil? || base.empty?
|
|
166
|
+
File.join(base, 'woods', 'update_check.json')
|
|
167
|
+
rescue StandardError
|
|
168
|
+
File.join(Dir.tmpdir, 'woods-update-check.json')
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Best-effort RubyGems probe. Kept tiny and self-contained so the module has
|
|
172
|
+
# no non-stdlib dependencies.
|
|
173
|
+
#
|
|
174
|
+
# @return [String, nil] latest version string, or nil on any failure
|
|
175
|
+
def fetch_latest_version(url)
|
|
176
|
+
require 'net/http'
|
|
177
|
+
uri = URI(url)
|
|
178
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
|
|
179
|
+
open_timeout: HTTP_TIMEOUT, read_timeout: HTTP_TIMEOUT) do |http|
|
|
180
|
+
http.get(uri.request_uri)
|
|
181
|
+
end
|
|
182
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
183
|
+
|
|
184
|
+
version = JSON.parse(response.body)['version']
|
|
185
|
+
version if version.is_a?(String) && !version.empty?
|
|
186
|
+
rescue StandardError
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
data/lib/woods/version.rb
CHANGED
data/lib/woods.rb
CHANGED
|
@@ -319,6 +319,30 @@ module Woods
|
|
|
319
319
|
end
|
|
320
320
|
end
|
|
321
321
|
|
|
322
|
+
# Resolve the effective Notion API token.
|
|
323
|
+
#
|
|
324
|
+
# A non-blank +NOTION_API_TOKEN+ env var takes precedence; otherwise the
|
|
325
|
+
# configured +notion_api_token+ is used. A set-but-blank env var — common
|
|
326
|
+
# with docker-compose +${NOTION_API_TOKEN}+ interpolation of an unset
|
|
327
|
+
# host variable — is treated as absent, so it never masks a valid
|
|
328
|
+
# configured token nor slips through as a blank bearer. This is the single
|
|
329
|
+
# resolution point shared by the Notion exporter, the MCP +notion_sync+
|
|
330
|
+
# tool + its registration gate, and the rake task, so all four agree on a
|
|
331
|
+
# given host.
|
|
332
|
+
#
|
|
333
|
+
# @param config [Configuration] configuration to read the fallback from
|
|
334
|
+
# @return [String, nil] the resolved token, or nil when neither source
|
|
335
|
+
# supplies a non-blank value
|
|
336
|
+
def resolve_notion_token(config = configuration)
|
|
337
|
+
env = ENV.fetch('NOTION_API_TOKEN', nil)
|
|
338
|
+
return env unless env.nil? || env.strip.empty?
|
|
339
|
+
|
|
340
|
+
configured = config.respond_to?(:notion_api_token) ? config.notion_api_token : nil
|
|
341
|
+
return nil if configured.nil? || configured.to_s.strip.empty?
|
|
342
|
+
|
|
343
|
+
configured
|
|
344
|
+
end
|
|
345
|
+
|
|
322
346
|
# Build a Retriever wired with adapters from the current configuration.
|
|
323
347
|
#
|
|
324
348
|
# @return [Retriever] A fully wired retriever instance
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: woods
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Leah Armstrong
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-07-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: mcp
|
|
@@ -112,6 +112,7 @@ files:
|
|
|
112
112
|
- lib/woods/ast/method_extractor.rb
|
|
113
113
|
- lib/woods/ast/node.rb
|
|
114
114
|
- lib/woods/ast/parser.rb
|
|
115
|
+
- lib/woods/atomic_file.rb
|
|
115
116
|
- lib/woods/builder.rb
|
|
116
117
|
- lib/woods/cache/cache_middleware.rb
|
|
117
118
|
- lib/woods/cache/cache_store.rb
|
|
@@ -176,6 +177,7 @@ files:
|
|
|
176
177
|
- lib/woods/evaluation/metrics.rb
|
|
177
178
|
- lib/woods/evaluation/query_set.rb
|
|
178
179
|
- lib/woods/evaluation/report_generator.rb
|
|
180
|
+
- lib/woods/export/unit_facts.rb
|
|
179
181
|
- lib/woods/extracted_unit.rb
|
|
180
182
|
- lib/woods/extractor.rb
|
|
181
183
|
- lib/woods/extractors/action_cable_extractor.rb
|
|
@@ -250,6 +252,7 @@ files:
|
|
|
250
252
|
- lib/woods/mcp/renderers/plain_renderer.rb
|
|
251
253
|
- lib/woods/mcp/server.rb
|
|
252
254
|
- lib/woods/mcp/tool_response_renderer.rb
|
|
255
|
+
- lib/woods/mcp/version_aware_tool_dispatch.rb
|
|
253
256
|
- lib/woods/model_name_cache.rb
|
|
254
257
|
- lib/woods/notion/client.rb
|
|
255
258
|
- lib/woods/notion/exporter.rb
|
|
@@ -262,6 +265,11 @@ files:
|
|
|
262
265
|
- lib/woods/observability/health_check.rb
|
|
263
266
|
- lib/woods/observability/instrumentation.rb
|
|
264
267
|
- lib/woods/observability/structured_logger.rb
|
|
268
|
+
- lib/woods/obsidian/errors.rb
|
|
269
|
+
- lib/woods/obsidian/name_mapper.rb
|
|
270
|
+
- lib/woods/obsidian/note_builder.rb
|
|
271
|
+
- lib/woods/obsidian/vault_assets.rb
|
|
272
|
+
- lib/woods/obsidian/vault_exporter.rb
|
|
265
273
|
- lib/woods/operator/error_escalator.rb
|
|
266
274
|
- lib/woods/operator/pipeline_guard.rb
|
|
267
275
|
- lib/woods/operator/status_reporter.rb
|
|
@@ -275,6 +283,7 @@ files:
|
|
|
275
283
|
- lib/woods/retrieval/ranker.rb
|
|
276
284
|
- lib/woods/retrieval/search_executor.rb
|
|
277
285
|
- lib/woods/retriever.rb
|
|
286
|
+
- lib/woods/retry_after.rb
|
|
278
287
|
- lib/woods/ruby_analyzer.rb
|
|
279
288
|
- lib/woods/ruby_analyzer/class_analyzer.rb
|
|
280
289
|
- lib/woods/ruby_analyzer/dataflow_analyzer.rb
|
|
@@ -307,6 +316,7 @@ files:
|
|
|
307
316
|
- lib/woods/unblocked/exporter.rb
|
|
308
317
|
- lib/woods/unblocked/rate_limiter.rb
|
|
309
318
|
- lib/woods/unblocked/sync_manifest.rb
|
|
319
|
+
- lib/woods/update_check.rb
|
|
310
320
|
- lib/woods/util/host_guard.rb
|
|
311
321
|
- lib/woods/version.rb
|
|
312
322
|
homepage: https://github.com/lost-in-the/woods
|