woods 1.6.1 → 2.0.0.beta2

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 (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +655 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +455 -0
  19. data/docs/INTERNALS.md +418 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +213 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +80 -11
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +171 -2
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -12,34 +12,63 @@ module Woods
12
12
  #
13
13
  # @example
14
14
  # mapper = ColumnMapper.new
15
- # properties = mapper.map(column, model_identifier: "User", validations: [...], parent_page_id: "page-123")
15
+ # properties = mapper.map(column, model_identifier: "User", validations: [...], parent_page_ids: ["page-123"])
16
16
  #
17
17
  class ColumnMapper
18
18
  include Shared
19
19
 
20
20
  # Map a single column to Notion Columns page properties.
21
21
  #
22
+ # The page title is qualified as "<table>.<column>" (e.g. "users.id")
23
+ # when a table name is given. Every Rails model shares id/created_at/
24
+ # updated_at, and the Columns database is upserted by title — a bare
25
+ # column-name title made every model overwrite every other model's
26
+ # shared-name column pages (#149). The qualifier must come from
27
+ # {ModelMapper.table_name_for} so it agrees with the Data Models page
28
+ # the Table relation points at.
29
+ #
30
+ # A physical table can be owned by several models (STI, or a shared
31
+ # +self.table_name=+), and the column page is one page per physical
32
+ # column — so the Table relation lists *every* owner's Data Models
33
+ # page. A single-owner relation made the two models fight over the
34
+ # page's parent claim on every run (EXP-1); Notion relations are lists,
35
+ # so naming them all is both accurate and stable.
36
+ #
22
37
  # @param column [Hash] Column hash from metadata["columns"] (name, type, null, default)
23
38
  # @param model_identifier [String] Parent model name (for context)
39
+ # @param table_name [String, nil] Owning table name used to qualify the page title
24
40
  # @param validations [Array<Hash>] Model-level validations to match against this column
25
- # @param parent_page_id [String, nil] Notion page ID of the Data Models parent page
41
+ # @param parent_page_ids [Array<String>] Notion page IDs of the owning
42
+ # Data Models pages, in a caller-fixed (deterministic) order
26
43
  # @return [Hash] Notion page properties hash
27
- def map(column, model_identifier: nil, validations: [], parent_page_id: nil) # rubocop:disable Lint/UnusedMethodArgument
44
+ def map(column, model_identifier: nil, table_name: nil, validations: [], parent_page_ids: []) # rubocop:disable Lint/UnusedMethodArgument
28
45
  properties = {
29
- 'Column Name' => { title: [{ text: { content: column['name'] } }] },
46
+ 'Column Name' => { title: [{ text: { content: qualified_title(column['name'], table_name) } }] },
30
47
  'Data Type' => { select: { name: column['type'] } },
31
48
  'Nullable' => { checkbox: column['null'] == true },
32
49
  'Default Value' => rich_text_property(column['default'].to_s),
33
50
  'Validation Rules' => rich_text_property(format_validation_rules(column['name'], validations))
34
51
  }
35
52
 
36
- properties['Table'] = { relation: [{ id: parent_page_id }] } if parent_page_id
53
+ related = Array(parent_page_ids).compact
54
+ properties['Table'] = { relation: related.map { |id| { id: id } } } if related.any?
37
55
 
38
56
  properties
39
57
  end
40
58
 
41
59
  private
42
60
 
61
+ # Build the column page title, qualified by table when known.
62
+ #
63
+ # @param column_name [String]
64
+ # @param table_name [String, nil]
65
+ # @return [String] "<table>.<column>", or the bare name without a table
66
+ def qualified_title(column_name, table_name)
67
+ return column_name.to_s if table_name.nil? || table_name.to_s.empty?
68
+
69
+ "#{table_name}.#{column_name}"
70
+ end
71
+
43
72
  # Find and format validations matching this column name.
44
73
  #
45
74
  # @param column_name [String]
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
3
5
  module Woods
4
6
  module Notion
5
7
  module Mappers
@@ -13,25 +15,47 @@ module Woods
13
15
  # # => { "users" => "2026-02-20T10:00:00Z", "posts" => "2026-01-15T09:00:00Z" }
14
16
  #
15
17
  class MigrationMapper
16
- # Compute the latest migration date for each affected table.
18
+ # A migration filename stamp: 20260220100000.
19
+ VERSION_STAMP = /\A(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\z/
20
+
21
+ # Compute the latest schema-change date for each affected table.
22
+ #
23
+ # The date is the **migration's own** version stamp, not the unit's
24
+ # +extracted_at+ (EXP-3). `extracted_at` records when Woods ran, so a
25
+ # full extraction re-stamped every unit and made every table read
26
+ # "changed today" — and rewrote every Data Models page to say so.
27
+ # Units with no parseable version fall back to `extracted_at`.
17
28
  #
18
29
  # @param migration_units [Array<Hash>] Parsed migration ExtractedUnit JSONs
19
- # @return [Hash<String, String>] Table name to latest extracted_at timestamp
30
+ # @return [Hash<String, String>] Table name to latest ISO8601 timestamp
20
31
  def latest_changes(migration_units)
21
32
  migration_units.each_with_object({}) do |unit, changes|
22
- extracted_at = unit['extracted_at']
23
- next unless extracted_at
33
+ metadata = unit['metadata'] || {}
34
+ changed_at = version_timestamp(metadata['migration_version']) || unit['extracted_at']
35
+ next unless changed_at
24
36
 
25
- tables = (unit['metadata'] || {})['tables_affected'] || []
26
- tables.each { |table| update_latest(changes, table, extracted_at) }
37
+ tables = metadata['tables_affected'] || []
38
+ tables.each { |table| update_latest(changes, table, changed_at) }
27
39
  end
28
40
  end
29
41
 
30
42
  private
31
43
 
44
+ # @param version [String, nil] a `%Y%m%d%H%M%S` migration stamp
45
+ # @return [String, nil] ISO8601 UTC, or nil when absent/unparseable
46
+ def version_timestamp(version)
47
+ match = VERSION_STAMP.match(version.to_s)
48
+ return nil unless match
49
+
50
+ Time.utc(*match.captures.map(&:to_i)).iso8601
51
+ rescue ArgumentError
52
+ # A well-shaped stamp naming an impossible date (month 13).
53
+ nil
54
+ end
55
+
32
56
  # @return [void]
33
- def update_latest(changes, table, extracted_at)
34
- changes[table] = extracted_at if changes[table].nil? || extracted_at > changes[table]
57
+ def update_latest(changes, table, changed_at)
58
+ changes[table] = changed_at if changes[table].nil? || changed_at > changes[table]
35
59
  end
36
60
  end
37
61
  end
@@ -18,6 +18,24 @@ module Woods
18
18
  class ModelMapper # rubocop:disable Metrics/ClassLength
19
19
  include Shared
20
20
 
21
+ # Derive the table name for a model unit — the exact value the Data
22
+ # Models page title is built from. Shared with the Columns sync so the
23
+ # column title qualifier ("<table>.<column>") and the Table relation
24
+ # always agree on which table a column belongs to (#149).
25
+ #
26
+ # Falls back to a naive tableized identifier when extraction produced
27
+ # no table_name (close enough to ActiveRecord convention for a title).
28
+ #
29
+ # @param unit_data [Hash] Parsed model ExtractedUnit JSON
30
+ # @return [String]
31
+ def self.table_name_for(unit_data)
32
+ metadata = unit_data['metadata'] || {}
33
+ return metadata['table_name'] if metadata['table_name']
34
+
35
+ identifier = unit_data['identifier'] || ''
36
+ "#{identifier.split('::').last.to_s.gsub(/([a-z])([A-Z])/, '\1_\2').downcase}s"
37
+ end
38
+
21
39
  # Map a model unit to Notion Data Models page properties.
22
40
  #
23
41
  # @param unit_data [Hash] Parsed model ExtractedUnit JSON
@@ -35,7 +53,7 @@ module Woods
35
53
  # @return [Hash] Text-based Notion properties
36
54
  def build_text_properties(unit_data, metadata)
37
55
  {
38
- 'Table Name' => title_property(table_name(unit_data, metadata)),
56
+ 'Table Name' => title_property(table_name(unit_data)),
39
57
  'Model Name' => rich_text_property(unit_data['identifier']),
40
58
  'Description' => rich_text_property(extract_description(unit_data['source_code'])),
41
59
  'Associations' => rich_text_property(format_associations(metadata['associations'])),
@@ -54,11 +72,8 @@ module Woods
54
72
  end
55
73
 
56
74
  # @return [String]
57
- def table_name(unit_data, metadata)
58
- return metadata['table_name'] if metadata['table_name']
59
-
60
- identifier = unit_data['identifier'] || ''
61
- "#{identifier.split('::').last.to_s.gsub(/([a-z])([A-Z])/, '\1_\2').downcase}s"
75
+ def table_name(unit_data)
76
+ self.class.table_name_for(unit_data)
62
77
  end
63
78
 
64
79
  # @return [Integer]
@@ -5,16 +5,58 @@ module Woods
5
5
  module Mappers
6
6
  # Shared helpers for Notion mapper classes.
7
7
  module Shared
8
+ # Notion's rich_text content limit, counted in **UTF-16 code units**
9
+ # — not Ruby characters. A character outside the BMP (an emoji, most
10
+ # CJK extension blocks) costs two units, so a char-count check let
11
+ # payloads up to twice the limit through and the API rejected them
12
+ # with a 400 on every run (EXP-2).
8
13
  MAX_RICH_TEXT_LENGTH = 2000
9
14
 
15
+ # Marks a truncated value; costs 3 UTF-16 units.
16
+ TRUNCATION_SUFFIX = '...'
17
+
10
18
  # Build a Notion rich_text property, truncating to API limits.
11
19
  #
12
20
  # @param text [String]
13
21
  # @return [Hash]
14
22
  def rich_text_property(text)
15
- content = text.to_s
16
- content = "#{content[0...1997]}..." if content.length > MAX_RICH_TEXT_LENGTH
17
- { rich_text: [{ text: { content: content } }] }
23
+ { rich_text: [{ text: { content: truncate_to_utf16_limit(text.to_s) } }] }
24
+ end
25
+
26
+ private
27
+
28
+ # Truncate to {MAX_RICH_TEXT_LENGTH} UTF-16 code units, reserving room
29
+ # for {TRUNCATION_SUFFIX}.
30
+ #
31
+ # Walks whole characters, so a surrogate pair is never split — a lone
32
+ # surrogate is not valid UTF-8 and would fail the request differently.
33
+ #
34
+ # @param content [String]
35
+ # @return [String] the input unchanged when it already fits
36
+ def truncate_to_utf16_limit(content)
37
+ # UTF-8 never spends fewer bytes than UTF-16 spends units (1/2/3
38
+ # bytes for one unit, 4 for two), so this settles the common case
39
+ # without walking the string.
40
+ return content if content.bytesize <= MAX_RICH_TEXT_LENGTH
41
+ return content if utf16_length(content) <= MAX_RICH_TEXT_LENGTH
42
+
43
+ budget = MAX_RICH_TEXT_LENGTH - TRUNCATION_SUFFIX.length
44
+ used = 0
45
+ kept = +''
46
+ content.each_char do |char|
47
+ cost = char.ord >= 0x10000 ? 2 : 1
48
+ break if used + cost > budget
49
+
50
+ kept << char
51
+ used += cost
52
+ end
53
+ kept << TRUNCATION_SUFFIX
54
+ end
55
+
56
+ # @param content [String]
57
+ # @return [Integer] length in UTF-16 code units
58
+ def utf16_length(content)
59
+ content.each_char.sum { |char| char.ord >= 0x10000 ? 2 : 1 }
18
60
  end
19
61
  end
20
62
  end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'digest'
5
+
6
+ require_relative '../atomic_file'
7
+
8
+ module Woods
9
+ module Notion
10
+ # Tracks what was last written to the Notion databases so a sync can
11
+ # skip unchanged pages entirely (zero API calls) and PATCH changed ones
12
+ # by cached page id (one call, no find-by-title query). Modeled on
13
+ # {Woods::Unblocked::SyncManifest} (#207 / B-095).
14
+ #
15
+ # Entries are grouped by scope — one scope per Notion database
16
+ # ("data_models", "columns") — and keyed by the page's logical identity:
17
+ # the qualified title from #149 ("users.id" for columns, the table name
18
+ # or "<table> (<Model>)" for data models). Each entry records the Notion
19
+ # page_id plus a deterministic content hash of the mapped properties.
20
+ #
21
+ # The manifest is a local cache, never the source of truth: a missing,
22
+ # corrupt, or unreadable file degrades to "everything is new" — a
23
+ # correct (if expensive) full title-lookup sync that rebuilds it. Reads
24
+ # and writes both go through {Woods::AtomicFile} from day one — a bare
25
+ # +File.read+ tags bytes with the process's default external encoding
26
+ # (US-ASCII under +LANG=C+), which broke the Unblocked manifest's
27
+ # degrade contract on non-ASCII content (B-077 / #189).
28
+ #
29
+ # A stored scope is discarded when its recorded database id no longer
30
+ # matches the configured one (the analog of Unblocked's collection
31
+ # guard): cached page_ids point into the *old* database, and a PATCH by
32
+ # page id would silently write pages the configured database never sees.
33
+ #
34
+ # @example
35
+ # manifest = SyncManifest.new(path: "tmp/woods/notion_sync_manifest.json",
36
+ # database_ids: { data_models: "db-uuid" })
37
+ # hash = SyncManifest.content_hash(properties)
38
+ # manifest.unchanged?("data_models", "users", hash) # => false on first run
39
+ # manifest.record(scope: "data_models", key: "users", hash: hash, page_id: "page-1")
40
+ # manifest.save
41
+ #
42
+ class SyncManifest
43
+ VERSION = 1
44
+
45
+ # Deterministic fingerprint of a mapped Notion properties payload.
46
+ #
47
+ # Hash keys are sorted recursively (after +to_s+, so a symbol-keyed and
48
+ # a string-keyed payload that serialize identically hash identically),
49
+ # so the digest never depends on insertion order. Array order is
50
+ # preserved — it is meaningful in Notion payloads (rich text runs,
51
+ # relation lists).
52
+ #
53
+ # @param properties [Hash] Mapper output (Notion page properties)
54
+ # @return [String] SHA-256 hex digest
55
+ def self.content_hash(properties)
56
+ Digest::SHA256.hexdigest(JSON.generate(canonicalize(properties)))
57
+ end
58
+
59
+ # Recursively sort hash keys so serialization is order-independent.
60
+ #
61
+ # @api private
62
+ # @param value [Object]
63
+ # @return [Object]
64
+ def self.canonicalize(value)
65
+ case value
66
+ when Hash
67
+ value.map { |k, v| [k.to_s, canonicalize(v)] }.sort_by(&:first).to_h
68
+ when Array
69
+ value.map { |element| canonicalize(element) }
70
+ else
71
+ value
72
+ end
73
+ end
74
+
75
+ # @param path [String] JSON file path for the manifest
76
+ # @param database_ids [Hash{Symbol,String=>String}, nil] Scope name =>
77
+ # Notion database UUID currently configured. A stored scope whose
78
+ # recorded database id differs is discarded on load.
79
+ def initialize(path:, database_ids:)
80
+ @path = path
81
+ @database_ids = normalize_database_ids(database_ids)
82
+ @pages = load
83
+ end
84
+
85
+ # @return [Boolean] true when no pages are recorded in any scope
86
+ def empty?
87
+ @pages.values.all?(&:empty?)
88
+ end
89
+
90
+ # @return [Integer] number of recorded pages across all scopes
91
+ def size
92
+ @pages.values.sum(&:size)
93
+ end
94
+
95
+ # @param scope [String] Scope name (e.g. "data_models")
96
+ # @param key [String] Logical page key (qualified title)
97
+ # @param hash [String] Content hash of the properties we would write now
98
+ # @return [Boolean] true when the recorded hash matches *and* a page_id
99
+ # is on record (without one, a skip would leave dependent pages —
100
+ # column Table relations — unable to reference the page)
101
+ def unchanged?(scope, key, hash)
102
+ entry = @pages.dig(scope, key)
103
+ !entry.nil? && entry['hash'] == hash && !entry['page_id'].nil?
104
+ end
105
+
106
+ # @param scope [String] Scope name
107
+ # @param key [String] Logical page key
108
+ # @return [String, nil] Stored Notion page_id, if known
109
+ def page_id_for(scope, key)
110
+ @pages.dig(scope, key, 'page_id')
111
+ end
112
+
113
+ # Record (or update) what we wrote for a page.
114
+ #
115
+ # @param scope [String] Scope name
116
+ # @param key [String] Logical page key (qualified title)
117
+ # @param hash [String] Content hash written
118
+ # @param page_id [String] Notion page UUID
119
+ # @return [void]
120
+ def record(scope:, key:, hash:, page_id:)
121
+ (@pages[scope] ||= {})[key] = { 'hash' => hash, 'page_id' => page_id }
122
+ end
123
+
124
+ # Drop one key from a scope (e.g. after a cached page turned out to be
125
+ # deleted in Notion, so the next attempt goes through find-by-title).
126
+ #
127
+ # @param scope [String] Scope name
128
+ # @param key [String] Logical page key
129
+ # @return [void]
130
+ def forget(scope, key)
131
+ @pages[scope]&.delete(key)
132
+ end
133
+
134
+ # Drop every key in +scope+ absent from +current_keys+ — pages whose
135
+ # logical identity vanished from the sync set. Only the manifest entry
136
+ # is dropped: the Notion page itself is deliberately left alone (there
137
+ # is no deletion path), pruning just keeps the manifest from growing
138
+ # forever. A key that later reappears goes through the find-by-title
139
+ # path and re-adopts the surviving page.
140
+ #
141
+ # @param scope [String] Scope name
142
+ # @param current_keys [Array<String>, Set] Keys that still exist this run
143
+ # @return [Array<String>] the pruned keys
144
+ def prune(scope, current_keys)
145
+ scoped = @pages[scope]
146
+ return [] unless scoped
147
+
148
+ stale = scoped.keys - current_keys.to_a
149
+ stale.each { |key| scoped.delete(key) }
150
+ stale
151
+ end
152
+
153
+ # Persist the manifest crash-safely ({Woods::AtomicFile} — temp file,
154
+ # fsync, rename) so an interrupted write never leaves a torn file.
155
+ #
156
+ # @return [void]
157
+ def save
158
+ payload = JSON.generate(
159
+ 'version' => VERSION,
160
+ 'databases' => @database_ids,
161
+ 'pages' => @pages
162
+ )
163
+ AtomicFile.write(@path, payload)
164
+ end
165
+
166
+ private
167
+
168
+ # @param database_ids [Hash, nil]
169
+ # @return [Hash{String=>String}] string keys/values, nil values dropped
170
+ def normalize_database_ids(database_ids)
171
+ (database_ids || {}).each_with_object({}) do |(scope, id), normalized|
172
+ normalized[scope.to_s] = id.to_s unless id.nil?
173
+ end
174
+ end
175
+
176
+ # Load the persisted pages, discarding data from a different schema
177
+ # version or an unparseable/unreadable file, and discarding any scope
178
+ # recorded against a different database id. Every discard warns to
179
+ # stderr — the consequence (a full title-lookup re-check) is expensive
180
+ # enough that operators need to know why it happened.
181
+ #
182
+ # {Woods::AtomicFile.read}, not +File.read+ — see the class docs and
183
+ # B-077. Read failures (e.g. +Errno::EACCES+) take the same discard
184
+ # path: the manifest is a cache, and "everything is new" is always a
185
+ # correct answer.
186
+ #
187
+ # @return [Hash{String=>Hash}] scope => { key => { 'hash' =>, 'page_id' => } }
188
+ def load
189
+ return {} unless File.exist?(@path)
190
+
191
+ parsed = JSON.parse(AtomicFile.read(@path))
192
+ return discard('not a JSON object') unless parsed.is_a?(Hash)
193
+ return discard("schema version #{parsed['version'].inspect}, expected #{VERSION}") unless
194
+ parsed['version'] == VERSION
195
+
196
+ select_current_scopes(parsed)
197
+ rescue JSON::ParserError
198
+ discard('unparseable JSON')
199
+ rescue EncodingError, SystemCallError => e
200
+ discard("unreadable file: #{e.class}: #{e.message}")
201
+ end
202
+
203
+ # Keep only scopes whose recorded database id matches the configured
204
+ # one — cached page_ids from a different database must not be PATCHed.
205
+ #
206
+ # @param parsed [Hash] the persisted manifest document
207
+ # @return [Hash{String=>Hash}]
208
+ def select_current_scopes(parsed)
209
+ pages = parsed['pages']
210
+ return discard('malformed pages section') unless pages.is_a?(Hash)
211
+
212
+ stored_dbs = parsed['databases'].is_a?(Hash) ? parsed['databases'] : {}
213
+ pages.each_with_object({}) do |(scope, entries), kept|
214
+ scoped = load_scope(scope, entries, stored_dbs)
215
+ kept[scope] = scoped if scoped
216
+ end
217
+ end
218
+
219
+ # One stored scope: kept (minus individually torn entries) when it was
220
+ # recorded against the configured database id, dropped with a warning
221
+ # otherwise.
222
+ #
223
+ # @param scope [String] Scope name from the persisted manifest
224
+ # @param entries [Object] Persisted entries for the scope
225
+ # @param stored_dbs [Hash] Persisted scope => database id map
226
+ # @return [Hash, nil] entries to keep, or nil to drop the scope
227
+ def load_scope(scope, entries, stored_dbs)
228
+ return nil unless entries.is_a?(Hash)
229
+ return entries.select { |_key, entry| entry.is_a?(Hash) } if current_scope?(scope, stored_dbs)
230
+
231
+ warn_scope_discard(scope, stored_dbs[scope]) unless entries.empty?
232
+ nil
233
+ end
234
+
235
+ # @return [Boolean] true when the stored scope was recorded against the
236
+ # database id configured now (and one is configured at all)
237
+ def current_scope?(scope, stored_dbs)
238
+ !@database_ids[scope].nil? && stored_dbs[scope] == @database_ids[scope]
239
+ end
240
+
241
+ # @param scope [String] Scope being discarded
242
+ # @param stored_id [String, nil] Database id the scope was recorded against
243
+ # @return [void]
244
+ def warn_scope_discard(scope, stored_id)
245
+ warn "WARNING: discarding notion sync manifest scope #{scope.inspect} at #{@path} (written for " \
246
+ "database #{stored_id.inspect}, expected #{@database_ids[scope].inspect}) — " \
247
+ 'next sync re-checks those pages by title'
248
+ end
249
+
250
+ # @param reason [String] Why the persisted manifest is unusable
251
+ # @return [Hash] empty pages hash (degrades to a full title-lookup sync)
252
+ def discard(reason)
253
+ warn "WARNING: discarding notion sync manifest at #{@path} (#{reason}) — next sync re-checks every page"
254
+ {}
255
+ end
256
+ end
257
+ end
258
+ end
@@ -7,5 +7,11 @@ module Woods
7
7
  # Raised for recoverable Obsidian export failures (e.g. a missing extraction
8
8
  # output dir). Inherits Woods::Error so callers can rescue the gem's hierarchy.
9
9
  class ExportError < Woods::Error; end
10
+
11
+ # Raised when a computed write target resolves outside the vault root.
12
+ # The fail-closed counterpart to the dir_for/NameMapper sanitizers: those
13
+ # are expected to prevent this from ever firing, but a future regression
14
+ # in either one must refuse the write, not silently escape the vault.
15
+ class PathTraversalError < ExportError; end
10
16
  end
11
17
  end
@@ -32,9 +32,14 @@ module Woods
32
32
  class NameMapper
33
33
  MAX_FILENAME_BYTES = 255
34
34
  EXTENSION = '.md'
35
- HASH_SUFFIX_BYTES = 9 # "-" + 8 hex chars
35
+ HASH_SUFFIX_HEX = 8 # hex chars per disambiguation step
36
36
  RESERVED_BASENAMES = %w[_index].freeze
37
37
 
38
+ # Basenames Windows refuses to create, with or without an extension.
39
+ # A Ruby class named +Aux+ produced "models/Aux.md", which breaks any
40
+ # vault synced through Windows Obsidian or OneDrive (EXP-9).
41
+ WINDOWS_RESERVED_BASENAMES = /\A(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])\z/i
42
+
38
43
  # @param id_to_dir [Hash{String=>String}] map of unit identifier -> type
39
44
  # folder name (e.g. "User" => "models")
40
45
  def initialize(id_to_dir)
@@ -43,29 +48,12 @@ module Woods
43
48
  build(id_to_dir)
44
49
  end
45
50
 
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
51
  # @param id [String]
58
52
  # @return [String, nil] vault-relative note path ("models/User.md")
59
53
  def path_for(id)
60
54
  @map[id]&.fetch(:path)
61
55
  end
62
56
 
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
57
  # @param id [String]
70
58
  # @return [String, nil] full wikilink ("[[models/User|User]]") or nil if unknown
71
59
  def wikilink(id)
@@ -93,18 +81,46 @@ module Woods
93
81
  end
94
82
  end
95
83
 
84
+ # Claim a free basename in this folder, case-insensitively.
85
+ #
86
+ # The hashed candidate used to be inserted without being re-checked, so
87
+ # a hash prefix that happened to equal an existing literal basename put
88
+ # two notes at one path — last writer wins, and one id silently vanished
89
+ # from the inverse map (EXP-9). Now the digest slice widens until the
90
+ # candidate is free.
96
91
  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]}"
92
+ candidate = fit(sanitize(id), EXTENSION.bytesize)
93
+ digest = Digest::SHA256.hexdigest(id)
94
+ attempt = 0
95
+
96
+ while used.include?(candidate.downcase)
97
+ attempt += 1
98
+ candidate = hashed_basename(id, hash_suffix(digest, attempt))
100
99
  end
101
- used << base.downcase
102
- base
100
+
101
+ used << candidate.downcase
102
+ candidate
103
+ end
104
+
105
+ # Widening slices of the digest (8, 16, ... 64 hex chars), then the whole
106
+ # digest plus a counter — so {#assign_basename} terminates even in the
107
+ # (unreachable, ids are unique) case that every slice is taken.
108
+ def hash_suffix(digest, attempt)
109
+ width = attempt * HASH_SUFFIX_HEX
110
+ return digest[0, width] if width <= digest.length
111
+
112
+ "#{digest}-#{attempt - (digest.length / HASH_SUFFIX_HEX)}"
113
+ end
114
+
115
+ def hashed_basename(id, suffix)
116
+ "#{fit(sanitize(id), EXTENSION.bytesize + 1 + suffix.bytesize)}-#{suffix}"
103
117
  end
104
118
 
105
119
  def sanitize(id)
106
120
  out = id.to_s.gsub('::', '__').gsub(/[^a-zA-Z0-9_-]/, '_')
107
- out.empty? ? 'unit' : out
121
+ return 'unit' if out.empty?
122
+
123
+ WINDOWS_RESERVED_BASENAMES.match?(out) ? "_#{out}" : out
108
124
  end
109
125
 
110
126
  # Replace only the chars that are structural inside a wikilink alias.