woods 1.5.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +171 -0
  3. data/CONTRIBUTING.md +30 -1
  4. data/README.md +31 -0
  5. data/lib/tasks/woods.rake +52 -2
  6. data/lib/woods/atomic_file.rb +43 -0
  7. data/lib/woods/cache/cache_middleware.rb +18 -1
  8. data/lib/woods/console/embedded_executor.rb +20 -2
  9. data/lib/woods/console/eval_guard.rb +8 -2
  10. data/lib/woods/console/sql_noise_stripper.rb +113 -0
  11. data/lib/woods/console/sql_table_scanner.rb +22 -9
  12. data/lib/woods/console/sql_validator.rb +1 -2
  13. data/lib/woods/coordination/pipeline_lock.rb +112 -7
  14. data/lib/woods/dependency_graph.rb +38 -1
  15. data/lib/woods/embedding/text_preparer.rb +6 -1
  16. data/lib/woods/export/unit_facts.rb +80 -0
  17. data/lib/woods/extractor.rb +153 -32
  18. data/lib/woods/extractors/concern_extractor.rb +3 -5
  19. data/lib/woods/extractors/configuration_extractor.rb +3 -5
  20. data/lib/woods/extractors/controller_extractor.rb +1 -1
  21. data/lib/woods/extractors/database_view_extractor.rb +1 -1
  22. data/lib/woods/extractors/decorator_extractor.rb +3 -5
  23. data/lib/woods/extractors/event_extractor.rb +2 -2
  24. data/lib/woods/extractors/factory_extractor.rb +2 -4
  25. data/lib/woods/extractors/graphql_extractor.rb +1 -1
  26. data/lib/woods/extractors/i18n_extractor.rb +6 -4
  27. data/lib/woods/extractors/job_extractor.rb +4 -6
  28. data/lib/woods/extractors/lib_extractor.rb +1 -1
  29. data/lib/woods/extractors/mailer_extractor.rb +1 -1
  30. data/lib/woods/extractors/manager_extractor.rb +3 -5
  31. data/lib/woods/extractors/migration_extractor.rb +1 -1
  32. data/lib/woods/extractors/model_extractor.rb +26 -10
  33. data/lib/woods/extractors/phlex_extractor.rb +1 -1
  34. data/lib/woods/extractors/policy_extractor.rb +3 -5
  35. data/lib/woods/extractors/poro_extractor.rb +1 -1
  36. data/lib/woods/extractors/pundit_extractor.rb +3 -5
  37. data/lib/woods/extractors/rake_task_extractor.rb +2 -4
  38. data/lib/woods/extractors/serializer_extractor.rb +4 -6
  39. data/lib/woods/extractors/service_extractor.rb +3 -5
  40. data/lib/woods/extractors/shared_dependency_scanner.rb +88 -16
  41. data/lib/woods/extractors/shared_utility_methods.rb +14 -0
  42. data/lib/woods/extractors/state_machine_extractor.rb +2 -4
  43. data/lib/woods/extractors/validator_extractor.rb +3 -5
  44. data/lib/woods/extractors/view_component_extractor.rb +1 -1
  45. data/lib/woods/filename_utils.rb +31 -2
  46. data/lib/woods/flow_assembler.rb +45 -28
  47. data/lib/woods/flow_precomputer.rb +2 -1
  48. data/lib/woods/index_artifact.rb +5 -1
  49. data/lib/woods/mcp/server.rb +68 -15
  50. data/lib/woods/mcp/version_aware_tool_dispatch.rb +47 -0
  51. data/lib/woods/model_name_cache.rb +6 -1
  52. data/lib/woods/notion/client.rb +4 -1
  53. data/lib/woods/notion/exporter.rb +7 -1
  54. data/lib/woods/obsidian/errors.rb +11 -0
  55. data/lib/woods/obsidian/name_mapper.rb +132 -0
  56. data/lib/woods/obsidian/note_builder.rb +260 -0
  57. data/lib/woods/obsidian/vault_assets.rb +104 -0
  58. data/lib/woods/obsidian/vault_exporter.rb +412 -0
  59. data/lib/woods/operator/error_escalator.rb +15 -4
  60. data/lib/woods/resilience/circuit_breaker.rb +98 -24
  61. data/lib/woods/resolved_config.rb +4 -1
  62. data/lib/woods/retrieval/ranker.rb +45 -7
  63. data/lib/woods/retry_after.rb +36 -0
  64. data/lib/woods/unblocked/client.rb +4 -1
  65. data/lib/woods/unblocked/document_builder.rb +21 -27
  66. data/lib/woods/update_check.rb +190 -0
  67. data/lib/woods/version.rb +1 -1
  68. data/lib/woods.rb +24 -0
  69. metadata +12 -2
@@ -60,19 +60,26 @@ module Woods
60
60
 
61
61
  # Recursively expand a unit into flow steps.
62
62
  #
63
+ # A cycle is a re-encounter on the CURRENT recursion path (tracked in
64
+ # +path+, popped on exit — the gray set of a DFS). A unit already
65
+ # expanded via a sibling branch (a DAG diamond: two services calling
66
+ # the same model) is plain dedup, not a cycle — it is skipped silently
67
+ # rather than mislabeled with a cycle marker.
68
+ #
63
69
  # @param identifier [String] Unit identifier (may include #method)
64
70
  # @param steps [Array<Hash>] Accumulator for step hashes
65
- # @param visited [Set<String>] Visited unit identifiers for cycle detection
71
+ # @param visited [Set<String>] Already-expanded unit identifiers (dedup)
66
72
  # @param depth [Integer] Current recursion depth
67
73
  # @param max_depth [Integer] Maximum recursion depth
68
- def expand(identifier, steps, visited, depth:, max_depth:)
74
+ # @param path [Set<String>] Identifiers on the current recursion path
75
+ def expand(identifier, steps, visited, depth:, max_depth:, path: Set.new)
69
76
  return if depth > max_depth
70
77
 
71
78
  # Parse identifier into unit name and optional method
72
79
  unit_id, method_name = parse_identifier(identifier)
73
80
 
74
- if visited.include?(unit_id)
75
- # Cycle detected - emit a marker step
81
+ if path.include?(unit_id)
82
+ # Genuine cycle the unit is an ancestor of itself on this path.
76
83
  steps << {
77
84
  unit: unit_id,
78
85
  type: 'cycle',
@@ -81,34 +88,44 @@ module Woods
81
88
  return
82
89
  end
83
90
 
91
+ # Already expanded through another branch — dedup, not a cycle.
92
+ return if visited.include?(unit_id)
93
+
84
94
  visited.add(unit_id)
95
+ path.add(unit_id)
85
96
 
86
- # Load the unit data from disk
87
- unit_data = load_unit(unit_id)
88
- return unless unit_data
97
+ begin
98
+ # Load the unit data from disk
99
+ unit_data = load_unit(unit_id)
100
+ return unless unit_data
89
101
 
90
- source_code = unit_data[:source_code]
91
- return unless source_code && !source_code.empty?
102
+ source_code = unit_data[:source_code]
103
+ return unless source_code && !source_code.empty?
92
104
 
93
- metadata = unit_data[:metadata] || {}
94
- unit_type = unit_data[:type]&.to_s
95
- file_path = unit_data[:file_path]
105
+ metadata = unit_data[:metadata] || {}
106
+ unit_type = unit_data[:type]&.to_s
107
+ file_path = unit_data[:file_path]
96
108
 
97
- # Extract operations from the relevant method
98
- operations = extract_operations(source_code, method_name, metadata, unit_type)
109
+ # Extract operations from the relevant method
110
+ operations = extract_operations(source_code, method_name, metadata, unit_type)
99
111
 
100
- step = {
101
- unit: identifier,
102
- type: unit_type,
103
- file_path: file_path,
104
- operations: operations
105
- }
112
+ step = {
113
+ unit: identifier,
114
+ type: unit_type,
115
+ file_path: file_path,
116
+ operations: operations
117
+ }
106
118
 
107
- steps << step
119
+ steps << step
108
120
 
109
- # Recursively expand targets that resolve to known units
110
- operations.each do |op|
111
- expand_operation(op, identifier, steps, visited, depth: depth, max_depth: max_depth)
121
+ # Recursively expand targets that resolve to known units
122
+ operations.each do |op|
123
+ expand_operation(op, identifier, steps, visited, depth: depth, max_depth: max_depth, path: path)
124
+ end
125
+ ensure
126
+ # Pop on every exit (including the early returns above) — a unit
127
+ # left on the path would make sibling branches report false cycles.
128
+ path.delete(unit_id)
112
129
  end
113
130
  end
114
131
 
@@ -178,7 +195,7 @@ module Woods
178
195
  # @param visited [Set<String>] Visited unit identifiers for cycle detection
179
196
  # @param depth [Integer] Current recursion depth
180
197
  # @param max_depth [Integer] Maximum recursion depth
181
- def expand_operation(op, current_unit, steps, visited, depth:, max_depth:)
198
+ def expand_operation(op, current_unit, steps, visited, depth:, max_depth:, path: Set.new)
182
199
  case op[:type]
183
200
  when :call, :async
184
201
  target = op[:target]
@@ -187,14 +204,14 @@ module Woods
187
204
  candidate = resolve_target(target)
188
205
  return unless candidate
189
206
 
190
- expand(candidate, steps, visited, depth: depth + 1, max_depth: max_depth)
207
+ expand(candidate, steps, visited, depth: depth + 1, max_depth: max_depth, path: path)
191
208
  when :transaction
192
209
  (op[:nested] || []).each do |nested_op|
193
- expand_operation(nested_op, current_unit, steps, visited, depth: depth, max_depth: max_depth)
210
+ expand_operation(nested_op, current_unit, steps, visited, depth: depth, max_depth: max_depth, path: path)
194
211
  end
195
212
  when :conditional
196
213
  ((op[:then_ops] || []) + (op[:else_ops] || [])).each do |branch_op|
197
- expand_operation(branch_op, current_unit, steps, visited, depth: depth, max_depth: max_depth)
214
+ expand_operation(branch_op, current_unit, steps, visited, depth: depth, max_depth: max_depth, path: path)
198
215
  end
199
216
  end
200
217
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'fileutils'
5
+ require_relative 'filename_utils'
5
6
  require_relative 'flow_assembler'
6
7
 
7
8
  module Woods
@@ -80,7 +81,7 @@ module Woods
80
81
  def assemble_and_write(assembler, entry_point, controller_id, action)
81
82
  flow = assembler.assemble(entry_point, max_depth: @max_depth)
82
83
 
83
- filename = "#{controller_id.gsub('::', '__')}_#{action}.json"
84
+ filename = Woods::FilenameUtils.flow_filename(controller_id, action)
84
85
  flow_path = File.join(@flows_dir, filename)
85
86
 
86
87
  File.write(flow_path, canonical_json(flow.to_h))
@@ -127,7 +127,11 @@ module Woods
127
127
  target_real = target.exist? ? target.realpath.to_s : ''
128
128
  # Resolve symlinks on both sides before comparing (handles macOS /tmp → /private/var)
129
129
  root_resolved = Pathname.new(root_real).exist? ? Pathname.new(root_real).realpath.to_s : root_real
130
- unless target.exist? && target_real.start_with?(root_resolved)
130
+ # Prefix must end at a path boundary — a bare start_with? would accept
131
+ # sibling directories like "dumps-backup" or "dumpsevil", and the
132
+ # basename-only pointer written below would then name a directory that
133
+ # doesn't exist under dumps_root.
134
+ unless target.exist? && target_real.start_with?("#{root_resolved}#{File::SEPARATOR}")
131
135
  raise ArgumentError,
132
136
  'dump_dir must exist inside dumps_root. ' \
133
137
  "Got: #{dump_dir.inspect}, dumps_root: #{dumps_root}"
@@ -7,8 +7,11 @@ require 'open3'
7
7
  require 'time'
8
8
  require 'set'
9
9
  require_relative '../tasks'
10
+ require_relative '../filename_utils'
11
+ require_relative '../update_check'
10
12
  require_relative 'index_reader'
11
13
  require_relative 'tool_response_renderer'
14
+ require_relative 'version_aware_tool_dispatch'
12
15
 
13
16
  module Woods
14
17
  module MCP
@@ -27,6 +30,15 @@ module Woods
27
30
  # transport.open
28
31
  #
29
32
  module Server
33
+ # Module-level pipeline serialization state, eagerly initialized at load
34
+ # time (self == Server here). The HTTP transport dispatches tool handlers
35
+ # concurrently, so a lazy `@pipeline_mutex ||= Mutex.new` inside
36
+ # pipeline_start would let two handlers each create a DIFFERENT mutex and
37
+ # synchronize on separate locks — defeating the exclusion and allowing two
38
+ # concurrent pipelines of the same kind.
39
+ @pipeline_mutex = Mutex.new
40
+ @pipeline_in_flight = {}
41
+
30
42
  class << self
31
43
  # Build a configured MCP::Server with all tools and resources.
32
44
  #
@@ -87,6 +99,9 @@ module Woods
87
99
  resources: resources,
88
100
  resource_templates: resource_templates
89
101
  )
102
+ # Rewrite "Tool not found" into version-aware update guidance for agents
103
+ # running against an older gem than the skill they're following assumes.
104
+ server.singleton_class.prepend(VersionAwareToolDispatch)
90
105
 
91
106
  define_lookup_tool(server, reader, respond, respond_err, renderer)
92
107
  define_search_tool(server, reader, respond, respond_err, renderer)
@@ -152,15 +167,18 @@ module Woods
152
167
  end
153
168
 
154
169
  # Notion export needs both an API token and at least one database ID.
155
- # NOTION_API_TOKEN env var overrides the config token (see
156
- # docs/NOTION_EXPORT.md).
170
+ # A non-blank NOTION_API_TOKEN env var overrides the config token (see
171
+ # docs/NOTION_EXPORT.md). Resolution goes through
172
+ # Woods.resolve_notion_token so a blank env var is treated as absent
173
+ # (rather than masking a valid configured token) — matching the
174
+ # exporter and the notion_sync handler.
157
175
  def notion_wired?
158
176
  config = Woods.configuration
159
177
  return false unless config
160
178
 
161
- token = ENV['NOTION_API_TOKEN'] || (config.respond_to?(:notion_api_token) ? config.notion_api_token : nil)
179
+ token = Woods.resolve_notion_token(config)
162
180
  ids = config.respond_to?(:notion_database_ids) ? config.notion_database_ids : nil
163
- token && !token.empty? && ids && !ids.empty?
181
+ !token.nil? && ids && !ids.empty?
164
182
  end
165
183
 
166
184
  def text_response(text)
@@ -255,7 +273,11 @@ module Woods
255
273
  controller, action = entry_point.split('#', 2)
256
274
  return nil if controller.empty? || action.empty?
257
275
 
258
- filename = "#{controller.gsub('::', '__')}_#{action}.json"
276
+ # entry_point is client input — FilenameUtils.flow_filename
277
+ # allow-lists both parts (so `/` and `..` can't traverse outside
278
+ # flows/) using the SAME transform FlowPrecomputer writes with, so a
279
+ # legitimately precomputed flow always resolves to the file on disk.
280
+ filename = Woods::FilenameUtils.flow_filename(controller, action)
259
281
  path = File.join(index_dir, 'flows', filename)
260
282
  return nil unless File.exist?(path)
261
283
 
@@ -875,12 +897,34 @@ module Woods
875
897
  description: 'Trigger a codebase extraction pipeline run. Checks rate limits before proceeding.',
876
898
  input_schema: {
877
899
  properties: {
878
- incremental: { type: 'boolean', description: 'Run incremental extraction (default: false)' }
900
+ incremental: { type: 'boolean', description: 'Run incremental extraction (default: false)' },
901
+ changed_files: {
902
+ type: 'array',
903
+ items: { type: 'string' },
904
+ description: 'Required when incremental is true: repo-relative paths of changed files ' \
905
+ '(the MCP server has no git context to compute them itself).'
906
+ }
879
907
  }
880
908
  }
881
- ) do |server_context:, incremental: nil|
909
+ ) do |server_context:, incremental: nil, changed_files: nil|
882
910
  next op_missing.call('pipeline_extract') unless operator
883
911
 
912
+ # Incremental extraction re-extracts only the units affected by
913
+ # the given files. The MCP server has no git-diff context (unlike
914
+ # the rake task, which derives them from CHANGED_FILES / CI env),
915
+ # so an empty list would re-extract nothing while still bumping
916
+ # the manifest timestamp — a silent no-op reporting success.
917
+ # Require the caller to supply the changed files explicitly.
918
+ files = Array(changed_files).map(&:to_s).reject(&:empty?)
919
+ if incremental && files.empty?
920
+ next respond_err.call(
921
+ 'Incremental extraction requires a non-empty changed_files list. ' \
922
+ 'Pass the changed paths, or run `rake woods:incremental` which derives them from git.',
923
+ code: :invalid_params,
924
+ tool: 'pipeline_extract'
925
+ )
926
+ end
927
+
884
928
  guard = operator[:pipeline_guard]
885
929
  if guard && !guard.allow?(:extraction)
886
930
  next respond_err.call(
@@ -910,7 +954,7 @@ module Woods
910
954
  extractor = Woods::Extractor.new(
911
955
  output_dir: Woods.configuration.output_dir
912
956
  )
913
- incremental ? extractor.extract_changed([]) : extractor.extract_all
957
+ incremental ? extractor.extract_changed(files) : extractor.extract_all
914
958
  rescue StandardError => e
915
959
  logger = defined?(Rails) ? Rails.logger : Logger.new($stderr)
916
960
  logger.error("[Woods] Pipeline extract failed: #{e.message}")
@@ -987,8 +1031,9 @@ module Woods
987
1031
  # pipeline). Module-level state — a single MCP server process
988
1032
  # serializes its own pipelines.
989
1033
  def pipeline_start(kind)
990
- @pipeline_mutex ||= Mutex.new
991
- @pipeline_in_flight ||= {}
1034
+ # @pipeline_mutex / @pipeline_in_flight are initialized eagerly in
1035
+ # the module body — no lazy `||=` here, which would race under the
1036
+ # concurrent HTTP transport.
992
1037
  @pipeline_mutex.synchronize do
993
1038
  return false if @pipeline_in_flight[kind]
994
1039
 
@@ -998,7 +1043,7 @@ module Woods
998
1043
  end
999
1044
 
1000
1045
  def pipeline_finish(kind)
1001
- @pipeline_mutex&.synchronize { @pipeline_in_flight&.delete(kind) }
1046
+ @pipeline_mutex.synchronize { @pipeline_in_flight.delete(kind) }
1002
1047
  end
1003
1048
 
1004
1049
  def define_pipeline_status_tool(server, operator, respond, respond_err, op_missing)
@@ -1048,9 +1093,11 @@ module Woods
1048
1093
  )
1049
1094
  end
1050
1095
 
1096
+ # Pass the client-supplied class name so class-keyed patterns
1097
+ # (Timeout, Net::, Errno::ENOENT, …) match — a bare
1098
+ # StandardError would classify everything as :unknown.
1051
1099
  error = StandardError.new(error_message)
1052
- # Set the class name in the error string for pattern matching
1053
- result = escalator.classify(error)
1100
+ result = escalator.classify(error, class_name: error_class)
1054
1101
  result[:original_class] = error_class
1055
1102
  respond.call(JSON.pretty_generate(result))
1056
1103
  end
@@ -1302,7 +1349,12 @@ module Woods
1302
1349
  }
1303
1350
  ) do |server_context:|
1304
1351
  config = Woods.configuration
1305
- unless config.notion_api_token
1352
+ # Mirror notion_wired? (which gates registration) and the
1353
+ # Exporter via the shared resolver: a non-blank NOTION_API_TOKEN
1354
+ # env var satisfies the token requirement (reading config alone
1355
+ # rejected ENV-only hosts even though the tool was registered for
1356
+ # them), while a blank env var is treated as absent.
1357
+ if Woods.resolve_notion_token(config).nil?
1306
1358
  next respond_err.call(
1307
1359
  'notion_api_token is not configured. Set it in Woods.configure or via the NOTION_API_TOKEN env var.',
1308
1360
  code: :not_configured,
@@ -1421,7 +1473,8 @@ module Woods
1421
1473
  server: {
1422
1474
  name: 'woods',
1423
1475
  version: Woods::VERSION,
1424
- index_dir: index_dir.to_s
1476
+ index_dir: index_dir.to_s,
1477
+ update: Woods::UpdateCheck.status_hash
1425
1478
  },
1426
1479
  index: index_section(manifest, extracted_at, staleness, index_dir),
1427
1480
  retriever: {
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../update_check'
4
+
5
+ module Woods
6
+ module MCP
7
+ # Prepended onto a built +MCP::Server+ instance so that a call to a tool the
8
+ # installed gem does not define produces version-aware, self-healing
9
+ # guidance instead of a bare "Tool not found".
10
+ #
11
+ # An agent following a newer version of the distributed guide skills may try
12
+ # to call a tool that only exists in a later Woods release. The upstream
13
+ # +mcp+ gem raises +RequestHandlerError("Tool not found: X")+ with no seam to
14
+ # customize the message, so we intercept the built server's +call_tool+
15
+ # (dispatched directly by +handle_request+, so a prepend on the instance is
16
+ # honored on both the stdio and HTTP transports) and re-raise with the
17
+ # installed version plus an update hint the agent can relay to the user.
18
+ #
19
+ # Only the genuine tool-not-found case is rewritten; every other
20
+ # +RequestHandlerError+ (missing/invalid arguments, internal errors) is
21
+ # re-raised untouched.
22
+ module VersionAwareToolDispatch
23
+ # @param request [Hash] the tools/call params (carries +:name+)
24
+ # @return [MCP::Tool::Response] the tool result
25
+ # @raise [MCP::Server::RequestHandlerError] rewritten for unknown tools
26
+ def call_tool(request, **)
27
+ super
28
+ rescue ::MCP::Server::RequestHandlerError => e
29
+ raise unless tool_not_found_error?(e)
30
+
31
+ raise ::MCP::Server::RequestHandlerError.new(
32
+ Woods::UpdateCheck.tool_not_found_message(request[:name]),
33
+ request,
34
+ error_type: :invalid_params,
35
+ original_error: e
36
+ )
37
+ end
38
+
39
+ private
40
+
41
+ # The gem raises exactly this shape for an unregistered tool name.
42
+ def tool_not_found_error?(error)
43
+ error.error_type == :invalid_params && error.message.to_s.start_with?('Tool not found:')
44
+ end
45
+ end
46
+ end
47
+ end
@@ -87,7 +87,12 @@ module Woods
87
87
  names = model_names
88
88
  return /(?!)/ if names.empty? # never-matching regex
89
89
 
90
- /\b(?:#{names.map { |n| Regexp.escape(n) }.join('|')})\b/
90
+ # Longest-first: regex alternation is ordered, not longest-match.
91
+ # Without the sort, a reference to Library::Book::Chapter can match
92
+ # the shorter Library::Book alternative (\b is satisfied at the
93
+ # following ":"), emitting an edge to the wrong model.
94
+ sorted = names.sort_by { |n| -n.length }
95
+ /\b(?:#{sorted.map { |n| Regexp.escape(n) }.join('|')})\b/
91
96
  end
92
97
 
93
98
  # Build short-name → full-name mapping. A short name that appears on
@@ -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
@@ -135,7 +136,9 @@ module Woods
135
136
 
136
137
  if response.code == '429' && retries < MAX_RETRIES
137
138
  retries += 1
138
- wait_time = (response['Retry-After'] || retries).to_f
139
+ # Retry-After may be an HTTP-date, which .to_f would collapse to
140
+ # 0.0 — honoring it as-is would hammer a throttling server.
141
+ wait_time = Woods::RetryAfter.seconds(response['Retry-After'], fallback: retries)
139
142
  sleep(wait_time)
140
143
  next
141
144
  end
@@ -25,7 +25,13 @@ module Woods
25
25
  # @param reader [Object, nil] IndexReader instance (auto-created from index_dir if nil)
26
26
  # @raise [ConfigurationError] if notion_api_token is not configured
27
27
  def initialize(index_dir:, config: Woods.configuration, client: nil, reader: nil)
28
- api_token = config.notion_api_token
28
+ # A non-blank NOTION_API_TOKEN overrides the configured token
29
+ # (documented contract; also what the MCP notion_wired? gate keys on).
30
+ # Resolve via the shared Woods.resolve_notion_token so the exporter,
31
+ # the MCP tool, and the rake task treat a blank env var identically —
32
+ # a set-but-empty NOTION_API_TOKEN must not mask a configured token or
33
+ # pass through as a blank bearer.
34
+ api_token = Woods.resolve_notion_token(config)
29
35
  raise ConfigurationError, 'notion_api_token is required for Notion export' unless api_token
30
36
 
31
37
  @database_ids = config.notion_database_ids || {}
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'woods'
4
+
5
+ module Woods
6
+ module Obsidian
7
+ # Raised for recoverable Obsidian export failures (e.g. a missing extraction
8
+ # output dir). Inherits Woods::Error so callers can rescue the gem's hierarchy.
9
+ class ExportError < Woods::Error; end
10
+ end
11
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'digest'
5
+
6
+ module Woods
7
+ module Obsidian
8
+ # Centralizes the identifier -> note-filename mapping for an Obsidian vault.
9
+ #
10
+ # This is the single source of truth for three things that must always
11
+ # agree: the filename a note is written to, the wikilink target that points
12
+ # at it, and the inverse +path -> id+ map shipped in the machine sidecar.
13
+ # Building them in one place means a link can never point at a filename the
14
+ # writer didn't produce.
15
+ #
16
+ # Rules:
17
+ # - Sanitize +::+ to +__+ and every other non +[A-Za-z0-9_-]+ char to +_+,
18
+ # so the basename is always safe both as a filename and as a wikilink
19
+ # target (Obsidian's +# | ^ : %%+ link-breaking chars are all removed).
20
+ # - Collisions are resolved **per folder**, case-insensitively (macOS/Windows
21
+ # fold case): the colliding note gets a short content hash appended. Two
22
+ # identical basenames in *different* type folders are distinct paths and
23
+ # need no hash.
24
+ # - The MOC basename +_index+ is reserved in every folder so a (pathological)
25
+ # unit named +_index+ can never overwrite a folder's index note.
26
+ # - Filenames are capped to 255 bytes, truncated on a UTF-8 char boundary.
27
+ # - The alias (the human-readable display half of a wikilink) keeps the
28
+ # original identifier, with only the link-structural chars +[ ] |+ replaced.
29
+ #
30
+ # Determinism: ids are processed in sorted order, so the same input always
31
+ # produces the same collision-hash assignments.
32
+ class NameMapper
33
+ MAX_FILENAME_BYTES = 255
34
+ EXTENSION = '.md'
35
+ HASH_SUFFIX_BYTES = 9 # "-" + 8 hex chars
36
+ RESERVED_BASENAMES = %w[_index].freeze
37
+
38
+ # @param id_to_dir [Hash{String=>String}] map of unit identifier -> type
39
+ # folder name (e.g. "User" => "models")
40
+ def initialize(id_to_dir)
41
+ @map = {}
42
+ @paths = {}
43
+ build(id_to_dir)
44
+ end
45
+
46
+ # @return [Array<String>] exported identifiers, sorted
47
+ def ids
48
+ @map.keys
49
+ end
50
+
51
+ # @param id [String]
52
+ # @return [Boolean] whether this id has an emitted note
53
+ def known?(id)
54
+ @map.key?(id)
55
+ end
56
+
57
+ # @param id [String]
58
+ # @return [String, nil] vault-relative note path ("models/User.md")
59
+ def path_for(id)
60
+ @map[id]&.fetch(:path)
61
+ end
62
+
63
+ # @param id [String]
64
+ # @return [String, nil] wikilink target with no extension ("models/User")
65
+ def target_for(id)
66
+ @map[id]&.fetch(:target)
67
+ end
68
+
69
+ # @param id [String]
70
+ # @return [String, nil] full wikilink ("[[models/User|User]]") or nil if unknown
71
+ def wikilink(id)
72
+ entry = @map[id]
73
+ return nil unless entry
74
+
75
+ "[[#{entry[:target]}|#{entry[:alias]}]]"
76
+ end
77
+
78
+ # @return [Hash{String=>String}] inverse path -> id map (sorted)
79
+ def paths_to_ids
80
+ @paths
81
+ end
82
+
83
+ private
84
+
85
+ def build(id_to_dir)
86
+ taken = Hash.new { |h, dir| h[dir] = Set.new(RESERVED_BASENAMES) }
87
+ id_to_dir.keys.sort.each do |id|
88
+ dir = id_to_dir[id]
89
+ basename = assign_basename(id, taken[dir])
90
+ path = "#{dir}/#{basename}#{EXTENSION}"
91
+ @map[id] = { dir: dir, basename: basename, path: path, target: "#{dir}/#{basename}", alias: alias_for(id) }
92
+ @paths[path] = id
93
+ end
94
+ end
95
+
96
+ def assign_basename(id, used)
97
+ base = fit(sanitize(id), EXTENSION.bytesize)
98
+ if used.include?(base.downcase)
99
+ base = "#{fit(sanitize(id), EXTENSION.bytesize + HASH_SUFFIX_BYTES)}-#{Digest::SHA256.hexdigest(id)[0, 8]}"
100
+ end
101
+ used << base.downcase
102
+ base
103
+ end
104
+
105
+ def sanitize(id)
106
+ out = id.to_s.gsub('::', '__').gsub(/[^a-zA-Z0-9_-]/, '_')
107
+ out.empty? ? 'unit' : out
108
+ end
109
+
110
+ # Replace only the chars that are structural inside a wikilink alias.
111
+ # `#` and `:` are safe in alias display text and are kept for readability.
112
+ def alias_for(id)
113
+ id.to_s.gsub('[', '(').gsub(']', ')').gsub('|', '/')
114
+ end
115
+
116
+ # Truncate +str+ so it fits within MAX_FILENAME_BYTES minus +reserve+
117
+ # bytes, never splitting a multibyte character.
118
+ def fit(str, reserve)
119
+ limit = MAX_FILENAME_BYTES - reserve
120
+ return str if str.bytesize <= limit
121
+
122
+ out = +''
123
+ str.each_char do |ch|
124
+ break if out.bytesize + ch.bytesize > limit
125
+
126
+ out << ch
127
+ end
128
+ out
129
+ end
130
+ end
131
+ end
132
+ end