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
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Woods
6
+ # Resolves a changed file path to the extraction work it implies.
7
+ #
8
+ # The incremental path used to route changes purely through
9
+ # {DependencyGraph#affected_by}, which resolves a path via the graph's
10
+ # file map — a map populated only from *already-registered* units. A file
11
+ # that did not exist at the last extraction therefore routed nowhere and
12
+ # was silently ignored (#164, gap 1). This class supplies the missing
13
+ # direction: path → extractor, for files the index has never seen.
14
+ #
15
+ # Two rule sets:
16
+ #
17
+ # * {.file_rules} — file-based extractors, whose per-file method can be
18
+ # pointed straight at the new path.
19
+ # * {.whole_app_rules} — extractors with no per-file entry point (routes,
20
+ # middleware, engines, scheduled jobs, state machines, factories,
21
+ # events, framework sources). Their trigger paths map to a wholesale
22
+ # re-run of that extractor, which is cheap in an already-booted process.
23
+ #
24
+ # Class-based extractors (models, controllers, mailers, components,
25
+ # channels) are deliberately *absent* here. They are reconciled against
26
+ # their own runtime discovery sets instead (see
27
+ # {Extractor#reconcile_class_based_types}), which is exact by construction
28
+ # and needs no path-to-constant guessing.
29
+ #
30
+ # Rules are built lazily and memoized: they reference the directory
31
+ # constants owned by each extractor, so a directory added to (say)
32
+ # +ServiceExtractor::SERVICE_DIRECTORIES+ flows into dispatch without a
33
+ # second edit here.
34
+ #
35
+ # @example
36
+ # Woods::PathDispatcher.new.file_rules_for('app/services/checkout.rb')
37
+ # # => [#<Rule extractor_key=:services method_name=:extract_service_file ...>]
38
+ #
39
+ class PathDispatcher # rubocop:disable Metrics/ClassLength
40
+ # A single path → extraction mapping.
41
+ #
42
+ # @!attribute extractor_key
43
+ # @return [Symbol] key into {Extractor::EXTRACTORS}
44
+ # @!attribute method_name
45
+ # @return [Symbol] per-file extraction method (file rules only)
46
+ # @!attribute dirs
47
+ # @return [Array<String>] Rails.root-relative directory prefixes
48
+ # @!attribute extensions
49
+ # @return [Array<String>, nil] required filename suffixes (nil = any)
50
+ # @!attribute exclude
51
+ # @return [Array<String>, nil] path substrings that disqualify a match
52
+ # @!attribute require_segment
53
+ # @return [String, nil] path substring that must be present
54
+ # @!attribute recursive
55
+ # @return [Boolean] false to match only files directly inside +dirs+
56
+ # @!attribute exact_paths
57
+ # @return [Array<String>, nil] exact relative paths that match
58
+ # @!attribute basenames
59
+ # @return [Array<String>, nil] file basenames that match anywhere under Rails.root (honors +exclude+)
60
+ Rule = Struct.new(
61
+ :extractor_key, :method_name, :dirs, :extensions, :exclude,
62
+ :require_segment, :recursive, :exact_paths, :basenames,
63
+ keyword_init: true
64
+ ) do
65
+ # @param relative_path [String] Rails.root-relative path
66
+ # @return [Boolean]
67
+ def matches?(relative_path)
68
+ return true if exact_paths&.include?(relative_path)
69
+ return basename_match?(relative_path) if basenames
70
+
71
+ filters_pass?(relative_path) && under_a_directory?(relative_path)
72
+ end
73
+
74
+ private
75
+
76
+ def basename_match?(relative_path)
77
+ basenames.include?(File.basename(relative_path)) &&
78
+ exclude.to_a.none? { |segment| relative_path.include?(segment) }
79
+ end
80
+
81
+ def filters_pass?(relative_path)
82
+ (extensions.nil? || extensions.any? { |ext| relative_path.end_with?(ext) }) &&
83
+ exclude.to_a.none? { |segment| relative_path.include?(segment) } &&
84
+ (require_segment.nil? || relative_path.include?(require_segment))
85
+ end
86
+
87
+ def under_a_directory?(relative_path)
88
+ dirs.to_a.any? do |dir|
89
+ next false unless relative_path.start_with?("#{dir}/")
90
+
91
+ recursive == false ? File.dirname(relative_path) == dir : true
92
+ end
93
+ end
94
+ end
95
+
96
+ class << self
97
+ # Rules for extractors with a per-file entry point.
98
+ #
99
+ # @return [Array<Rule>]
100
+ def file_rules
101
+ @file_rules ||= build_file_rules.freeze
102
+ end
103
+
104
+ # Rules for extractors that must be re-run wholesale.
105
+ #
106
+ # @return [Array<Rule>]
107
+ def whole_app_rules
108
+ @whole_app_rules ||= build_whole_app_rules.freeze
109
+ end
110
+
111
+ # Drop memoized rules. Used by specs that stub extractor constants.
112
+ #
113
+ # @return [void]
114
+ def reset!
115
+ @file_rules = nil
116
+ @whole_app_rules = nil
117
+ end
118
+
119
+ private
120
+
121
+ def build_file_rules
122
+ plain_ruby_rules + specialized_rules + caching_rules
123
+ end
124
+
125
+ # Extractors that glob `**/*.rb` under directories they own.
126
+ def plain_ruby_rules
127
+ ex = Woods::Extractors
128
+
129
+ [
130
+ [:services, :extract_service_file, ex::ServiceExtractor::SERVICE_DIRECTORIES],
131
+ [:jobs, :extract_job_file, ex::JobExtractor::JOB_DIRECTORIES],
132
+ [:serializers, :extract_serializer_file, ex::SerializerExtractor::SERIALIZER_DIRECTORIES],
133
+ [:managers, :extract_manager_file, ex::ManagerExtractor::MANAGER_DIRECTORIES],
134
+ [:policies, :extract_policy_file, ex::PolicyExtractor::POLICY_DIRECTORIES],
135
+ [:validators, :extract_validator_file, ex::ValidatorExtractor::VALIDATOR_DIRECTORIES],
136
+ [:pundit_policies, :extract_pundit_file, ex::PunditExtractor::PUNDIT_DIRECTORIES],
137
+ [:decorators, :extract_decorator_file, ex::DecoratorExtractor::DECORATOR_DIRECTORIES],
138
+ [:configurations, :extract_configuration_file, ex::ConfigurationExtractor::CONFIG_DIRECTORIES]
139
+ ].map { |key, method_name, dirs| file_rule(key, method_name, dirs) }
140
+ end
141
+
142
+ # Extractors with a narrower or wider surface than "*.rb under my dirs".
143
+ def specialized_rules
144
+ ex = Woods::Extractors
145
+
146
+ [
147
+ # ConcernExtractor globs app/**/concerns, not just the two canonical
148
+ # directories — match any .rb under app/ inside a concerns/ segment.
149
+ file_rule(:concerns, :extract_concern_file, %w[app], require_segment: '/concerns/'),
150
+ # GraphQL types sit outside FILE_BASED (they share one extractor
151
+ # method across four unit types via GRAPHQL_TYPES), which is exactly
152
+ # why the FILE_BASED-driven coverage guard never noticed they had no
153
+ # rule: a *new* type/mutation/resolver routed nowhere and never
154
+ # entered the index — the same failure #164 gap 1 exists to close.
155
+ file_rule(:graphql, :extract_graphql_file,
156
+ [ex::GraphQLExtractor::GRAPHQL_DIRECTORY], extensions: %w[.rb]),
157
+ file_rule(:i18n, :extract_i18n_file, ex::I18nExtractor::I18N_DIRECTORIES, extensions: %w[.yml]),
158
+ file_rule(:rake_tasks, :extract_rake_file, ex::RakeTaskExtractor::RAKE_DIRECTORIES, extensions: %w[.rake]),
159
+ file_rule(:view_templates, :extract_view_template_file,
160
+ ex::ViewTemplateExtractor::VIEW_DIRECTORIES, extensions: view_template_extensions),
161
+ file_rule(:migrations, :extract_migration_file, %w[db/migrate], recursive: false),
162
+ # POROs are app/models classes that are *not* ActiveRecord models;
163
+ # the extractor makes that call itself given ar_names.
164
+ file_rule(:poros, :extract_poro_file, %w[app/models], exclude: %w[/concerns/]),
165
+ file_rule(:libs, :extract_lib_file, %w[lib], exclude: ex::LibExtractor::EXCLUDED_SEGMENTS),
166
+ file_rule(:test_mappings, :extract_test_file, %w[spec], extensions: %w[_spec.rb]),
167
+ file_rule(:test_mappings, :extract_test_file, %w[test], extensions: %w[_test.rb])
168
+ ]
169
+ end
170
+
171
+ # CachingExtractor scans three separate globs with different extensions.
172
+ def caching_rules
173
+ Woods::Extractors::CachingExtractor::SCAN_PATTERNS.map do |_file_type, pattern|
174
+ dir, glob = pattern.split('/**/', 2)
175
+ file_rule(:caching, :extract_caching_file, [dir], extensions: [glob.delete_prefix('*')])
176
+ end
177
+ end
178
+
179
+ def view_template_extensions
180
+ Woods::Extractors::ViewTemplateExtractor::ENGINES.flat_map { |k| k.new.extensions }.uniq
181
+ end
182
+
183
+ def build_whole_app_rules
184
+ [
185
+ whole_app_rule(:routes, %w[config/routes], exact_paths: %w[config/routes.rb]),
186
+ whole_app_rule(:engines, %w[config/routes], exact_paths: %w[config/routes.rb Gemfile.lock]),
187
+ whole_app_rule(:middleware, %w[config/initializers config/environments],
188
+ exact_paths: %w[config/application.rb Gemfile.lock]),
189
+ whole_app_rule(:scheduled_jobs, [],
190
+ exact_paths: Woods::Extractors::ScheduledJobExtractor::SCHEDULE_FILES.keys),
191
+ whole_app_rule(:state_machines, Woods::Extractors::StateMachineExtractor::MODEL_DIRECTORIES,
192
+ extensions: %w[.rb]),
193
+ whole_app_rule(:factories, Woods::Extractors::FactoryExtractor::FACTORY_DIRECTORIES,
194
+ extensions: %w[.rb]),
195
+ whole_app_rule(:database_views, %w[db/views], extensions: %w[.sql]),
196
+ # EventExtractor is a two-pass scan over all of app/ — any Ruby
197
+ # change can add or remove a publish/subscribe site.
198
+ whole_app_rule(:events, Woods::Extractors::EventExtractor::APP_DIRECTORIES, extensions: %w[.rb]),
199
+ # Framework/gem sources are a function of the installed dependency
200
+ # set, so the lockfile is their one honest trigger (#169). The
201
+ # `include_framework_sources` gate lives in the extractor
202
+ # (Extractor#skip_by_configuration?), not here: rules are memoized
203
+ # per-process while configuration can change, and `relevant?` is
204
+ # correct either way because Gemfile.lock already triggers
205
+ # :engines and :middleware.
206
+ whole_app_rule(:rails_source, [], exact_paths: %w[Gemfile.lock]),
207
+ # Packwerk boundaries: a package.yml anywhere re-runs the package
208
+ # extractor wholesale (#280). Vendored and generated trees are the
209
+ # same ones packwerk excludes by default.
210
+ whole_app_rule(:packages, [],
211
+ basenames: [Woods::Extractors::PackageExtractor::PACKAGE_FILE,
212
+ Woods::Extractors::PackageExtractor::PACKWERK_CONFIG],
213
+ exclude: %w[node_modules/ vendor/ tmp/ bin/ script/])
214
+ ]
215
+ end
216
+
217
+ # File-based extractors glob `**/*.rb` unless they say otherwise, so
218
+ # that is the default extension filter here too.
219
+ def file_rule(key, method_name, dirs, **opts)
220
+ opts[:extensions] ||= %w[.rb]
221
+ Rule.new(extractor_key: key, method_name: method_name, dirs: Array(dirs), **opts)
222
+ end
223
+
224
+ def whole_app_rule(key, dirs, **opts)
225
+ Rule.new(extractor_key: key, method_name: nil, dirs: Array(dirs), **opts)
226
+ end
227
+ end
228
+
229
+ # File-based rules matching a path.
230
+ #
231
+ # @param relative_path [String] Rails.root-relative path
232
+ # @return [Array<Rule>]
233
+ def file_rules_for(relative_path)
234
+ self.class.file_rules.select { |rule| rule.matches?(relative_path) }
235
+ end
236
+
237
+ # Extractor keys whose whole-app re-run is triggered by a path.
238
+ #
239
+ # @param relative_path [String] Rails.root-relative path
240
+ # @return [Array<Symbol>]
241
+ def whole_app_keys_for(relative_path)
242
+ self.class.whole_app_rules
243
+ .select { |rule| rule.matches?(relative_path) }
244
+ .map(&:extractor_key).uniq
245
+ end
246
+
247
+ # Does this path imply any extraction work at all?
248
+ #
249
+ # Used to filter a raw git diff down to paths worth handing to
250
+ # {Extractor#extract_changed}. It is deliberately derived from the rules
251
+ # rather than from a second hand-maintained pattern list — the two would
252
+ # drift, and a path the filter drops is a path that never reaches the
253
+ # index no matter how good the dispatch behind it is.
254
+ #
255
+ # Paths under `app/` are relevant even without a rule match: class-based
256
+ # types are discovered from runtime descendants, not from the path.
257
+ #
258
+ # @param relative_path [String] Rails.root-relative path
259
+ # @return [Boolean]
260
+ def relevant?(relative_path)
261
+ return true if relative_path.start_with?('app/') && relative_path.end_with?('.rb')
262
+
263
+ file_rules_for(relative_path).any? || whole_app_keys_for(relative_path).any?
264
+ end
265
+
266
+ # Extractor keys whose whole-app re-run is triggered by any path in a set.
267
+ #
268
+ # @param relative_paths [Enumerable<String>]
269
+ # @return [Set<Symbol>]
270
+ def whole_app_keys_for_all(relative_paths)
271
+ relative_paths.each_with_object(Set.new) do |path, keys|
272
+ whole_app_keys_for(path).each { |key| keys.add(key) }
273
+ end
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'fileutils'
5
+ require 'find'
6
+
7
+ module Woods
8
+ # Writer-side owner of the per-generation payload directories.
9
+ #
10
+ # An index published flat is a directory of independently-written files, so a
11
+ # reader refreshing mid-publish can load a unit from generation N+1 beside a
12
+ # graph from N. Publishing each generation's payload into its own immutable
13
+ # directory and naming that directory from +generation.json+ makes the single
14
+ # atomic write of that one file the commit point for the whole payload.
15
+ #
16
+ # The reader half resolves the pointer ({Woods::MCP::IndexReader#payload_dir});
17
+ # this is the half that builds what it points at.
18
+ #
19
+ # @example Publishing a generation
20
+ # payloads = Woods::PayloadStore.new(output_dir)
21
+ # dir = payloads.create(next_generation)
22
+ # payloads.clone(payloads.path_for(current_generation), dir) # incremental
23
+ # # ... write the run's changes into `dir` ...
24
+ # generation.bump!(reason: 'incremental', payload: Woods::PayloadStore.name_for(next_generation))
25
+ # payloads.prune(keep: 3, protect: next_generation)
26
+ #
27
+ class PayloadStore
28
+ DIRNAME = 'payloads'
29
+
30
+ # How many payload generations to retain, overridable per host via
31
+ # WOODS_PAYLOAD_RETENTION (read by Extractor#payload_retention). Three is
32
+ # the current generation plus two a pinned reader might still be inside.
33
+ DEFAULT_RETENTION = 3
34
+
35
+ # @param output_dir [String, Pathname] index output directory
36
+ def initialize(output_dir)
37
+ @root = Pathname.new(output_dir.to_s)
38
+ end
39
+
40
+ # @return [Pathname] the directory holding every generation's payload
41
+ def root
42
+ @root.join(DIRNAME)
43
+ end
44
+
45
+ # The pointer a {Woods::Generation} carries for a generation's payload.
46
+ #
47
+ # Relative to the output directory by contract: the reader resolves it
48
+ # against its own index root, which is not the absolute path the writer
49
+ # used whenever the index is read through a volume mount.
50
+ #
51
+ # @param number [Integer] generation number
52
+ # @return [String]
53
+ def self.name_for(number)
54
+ "#{DIRNAME}/gen-#{number}"
55
+ end
56
+
57
+ # @param number [Integer] generation number
58
+ # @return [Pathname] where that generation's payload lives (may not exist)
59
+ def path_for(number)
60
+ @root.join(self.class.name_for(number))
61
+ end
62
+
63
+ # Create the payload directory for a generation and return it.
64
+ #
65
+ # An existing directory is **emptied** rather than reused. It can only be
66
+ # there because an earlier run built a payload and then failed before
67
+ # bumping the generation — nothing points at it, and merging this run's
68
+ # files into that run's leftovers publishes a mixture of the two.
69
+ #
70
+ # @param number [Integer] generation number this payload will be published as
71
+ # @return [Pathname]
72
+ def create(number)
73
+ dir = path_for(number)
74
+ FileUtils.rm_rf(dir.to_s)
75
+ FileUtils.mkdir_p(dir.to_s)
76
+ dir
77
+ end
78
+
79
+ # Populate +target+ with the contents of +source+ using hardlinks.
80
+ #
81
+ # This is what makes publishing a whole payload per generation affordable
82
+ # on an incremental run: an unchanged file costs a directory entry rather
83
+ # than a copy.
84
+ #
85
+ # It is safe **only because every writer against a payload goes through
86
+ # {Woods::AtomicFile}**, which renames a fresh tempfile over the path. A
87
+ # rename replaces the directory entry and leaves the old inode alone, so
88
+ # the previous generation keeps its bytes. A bare +File.write+ to a cloned
89
+ # path would edit the inode both generations share and corrupt the one a
90
+ # reader is still pinned to.
91
+ #
92
+ # A missing source is a no-op — the first payload has nothing to clone.
93
+ #
94
+ # @param source [Pathname, String] the previous generation's payload
95
+ # @param target [Pathname, String] a directory returned by {#create}
96
+ # @return [void]
97
+ def clone(source, target)
98
+ from = Pathname.new(source.to_s)
99
+ return unless from.directory?
100
+
101
+ to = Pathname.new(target.to_s)
102
+ # The one directory `find` never offers: it yields the root as `.`, and
103
+ # a caller seeding a subtree (Extractor#seed_payload_from_flat_root)
104
+ # passes a target that does not exist yet.
105
+ FileUtils.mkdir_p(to.to_s)
106
+ from.find do |entry|
107
+ relative = entry.relative_path_from(from)
108
+ next if relative.to_s == '.'
109
+
110
+ replicate(entry, to.join(relative))
111
+ end
112
+ end
113
+
114
+ # Remove superseded payload directories, newest-first.
115
+ #
116
+ # +protect+ is never removed however the count works out: it is the
117
+ # generation +generation.json+ names, and removing it would leave every
118
+ # reader resolving a stale pointer back to the flat root.
119
+ #
120
+ # A directory numbered ABOVE +protect+ cannot be a generation still
121
+ # ahead of us in a legitimate history — the counter only advances by
122
+ # bumping past the currently published number — so it can only be a
123
+ # leftover from before `generation.json` was lost and the counter
124
+ # restarted lower. `sort.last(keep)` would otherwise read those stale
125
+ # high numbers as "newest" and retain them forever while pruning the
126
+ # genuinely-previous generation, so they are always removed regardless
127
+ # of +keep+; ordinary retention then applies only to +protect+ and
128
+ # below.
129
+ #
130
+ # Directories that do not parse as a generation payload are left alone —
131
+ # retention only owns what it created.
132
+ #
133
+ # @param keep [Integer] how many payload generations to retain
134
+ # @param protect [Integer] the currently published generation number
135
+ # A reader pins a payload by holding a shared advisory lock on its
136
+ # +manifest.json+. Retention takes the same file's exclusive lock without
137
+ # waiting and skips the generation when another process is still reading
138
+ # it. The operating system releases the shared lock if a reader crashes,
139
+ # so there are no PID leases to expire and no stale marker to guess at.
140
+ # The next publish retries every skipped generation normally.
141
+ #
142
+ # The manifest is the lock target rather than a reader-created sidecar so
143
+ # the Index MCP remains a read-only consumer and payloads written before
144
+ # this protocol remain protectable. Each published generation has its own
145
+ # manifest inode: writers replace a cloned manifest through AtomicFile
146
+ # before publishing, preserving generation-local lock identity.
147
+ #
148
+ # @return [Array<Integer>] the generation numbers removed
149
+ def prune(keep:, protect:)
150
+ return [] unless root.directory?
151
+
152
+ eligible, superseded = generation_dirs.partition { |number, _| number <= protect }
153
+ retained = eligible.map(&:first).sort.last(keep)
154
+ prunable = eligible.reject { |number, _| number == protect || retained.include?(number) }
155
+
156
+ remove_generation_dirs(superseded + prunable)
157
+ end
158
+
159
+ # Hardlink where the filesystem allows it, copy where it does not — a
160
+ # payload spanning a device boundary (a bind mount inside the output
161
+ # directory) must still publish, just without the saving.
162
+ #
163
+ # Public so a caller seeding a payload from something other than
164
+ # {#clone} — {Woods::Extractor#seed_payload_from_flat_root}, which walks
165
+ # an allowlist of flat-root entries rather than a whole directory tree —
166
+ # gets the same cross-device fallback. A bare +FileUtils.ln+ there raised
167
+ # on every run on a filesystem that disallows hardlinks (e.g. some
168
+ # overlay/bind-mount setups), degrading every run to a flat publish and
169
+ # never once giving the fallback a chance to run.
170
+ #
171
+ # @param source [Pathname, String] file to link or copy
172
+ # @param destination [Pathname, String] where it should land
173
+ # @return [void]
174
+ def link_or_copy(source, destination)
175
+ FileUtils.ln(source.to_s, destination.to_s)
176
+ rescue Errno::EXDEV, Errno::EPERM, Errno::EMLINK, NotImplementedError
177
+ FileUtils.cp(source.to_s, destination.to_s)
178
+ end
179
+
180
+ private
181
+
182
+ # @param pairs [Array<Array(Integer, Pathname)>]
183
+ # @return [Array<Integer>] the generation numbers removed
184
+ def remove_generation_dirs(pairs)
185
+ pairs.filter_map { |number, dir| remove_generation_dir(number, dir) }
186
+ end
187
+
188
+ # @return [Integer, nil] the removed generation, or nil while pinned
189
+ def remove_generation_dir(number, dir)
190
+ manifest = dir.join('manifest.json')
191
+ return remove_unlocked_generation(number, dir) unless manifest.file?
192
+
193
+ File.open(manifest.to_s, File::RDONLY) do |file|
194
+ return nil unless file.flock(File::LOCK_EX | File::LOCK_NB)
195
+
196
+ remove_unlocked_generation(number, dir)
197
+ end
198
+ rescue Errno::ENOENT
199
+ # An incomplete/orphaned payload has no readable contract a reader can
200
+ # pin. Retention still owns and may remove that directory.
201
+ remove_unlocked_generation(number, dir)
202
+ end
203
+
204
+ # @return [Integer]
205
+ def remove_unlocked_generation(number, dir)
206
+ FileUtils.rm_rf(dir.to_s)
207
+ number
208
+ end
209
+
210
+ # @return [Array<Array(Integer, Pathname)>]
211
+ def generation_dirs
212
+ root.children.filter_map do |child|
213
+ match = child.directory? ? child.basename.to_s.match(/\Agen-(\d+)\z/) : nil
214
+ [match[1].to_i, child] if match
215
+ end
216
+ end
217
+
218
+ # `Pathname#find` is a pre-order walk: a directory is always visited
219
+ # before anything inside it. So the directory branch has already created
220
+ # every parent a file could need, and the `mkdir_p` the file branch used
221
+ # to run was one stat-heavy syscall chain per unit re-proving what the
222
+ # previous entry established. On a payload of 8000+ files that was the
223
+ # bulk of the incremental seed.
224
+ #
225
+ # @param entry [Pathname] source entry, as `find` yielded it
226
+ # @param destination [Pathname] where it belongs in the target
227
+ # @return [void]
228
+ def replicate(entry, destination)
229
+ if entry.directory?
230
+ FileUtils.mkdir_p(destination.to_s)
231
+ else
232
+ link_or_copy(entry, destination)
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ class PublishedIndex
5
+ # Turns raw `dependency_graph.json` data into the flat, uniform edge shape
6
+ # {PublishedIndex#edges} returns: `{ from:, to:, via:, through:,
7
+ # through_db:, disable_joins: }`.
8
+ #
9
+ # Pulled out of {PublishedIndex} itself because this walk is pure data
10
+ # shaping with no dependency on the pinned generation or its retention
11
+ # lock; keeping it separate is what lets the surrounding class stay under
12
+ # `Metrics/ClassLength` without an exclude entry.
13
+ module EdgeShaper
14
+ # Every forward edge in the graph: primary nodes and variants alike. An
15
+ # identifier shared by more than one type contributes one edge per
16
+ # owning type, two edges are never folded into one just because they
17
+ # look alike once reduced to this shape.
18
+ #
19
+ # @param graph [Hash] raw dependency graph data
20
+ # ({Woods::MCP::IndexReader#raw_graph_data})
21
+ # @return [Array<Hash>] `{ from:, to:, via:, through:, through_db:, disable_joins: }`
22
+ def self.call(graph)
23
+ primary = (graph['edges'] || {}).flat_map { |from, list| Array(list).map { |raw| edge_hash(from, raw) } }
24
+ primary + variant_edges(graph)
25
+ end
26
+
27
+ # Edges owned by a type recorded only in the graph's `variants` section:
28
+ # an identifier that names units of more than one type keeps its other
29
+ # types' out-edges there, since the primary `edges` map holds only one
30
+ # type's edges per identifier.
31
+ #
32
+ # @param graph [Hash] raw dependency graph data
33
+ # @return [Array<Hash>]
34
+ def self.variant_edges(graph)
35
+ variant_records(graph).flat_map do |record|
36
+ Array(record['edges']).map { |raw| edge_hash(record['identifier'], raw) }
37
+ end
38
+ end
39
+
40
+ # @param graph [Hash] raw dependency graph data
41
+ # @return [Array<Hash>] entries from `variants` that name an identifier
42
+ def self.variant_records(graph)
43
+ Array(graph['variants']).select { |record| record.is_a?(Hash) && record['identifier'] }
44
+ end
45
+
46
+ # @param from [String]
47
+ # @param raw [String, Hash] a bare target (pre-via graphs) or an edge hash
48
+ # @return [Hash]
49
+ def self.edge_hash(from, raw)
50
+ if raw.is_a?(Hash)
51
+ { from: from, to: raw['target'], via: raw['via'], through: raw['through'],
52
+ through_db: raw['through_db'], disable_joins: raw['disable_joins'] == true }
53
+ else
54
+ { from: from, to: raw.to_s, via: nil, through: nil, through_db: nil, disable_joins: false }
55
+ end
56
+ end
57
+
58
+ private_class_method :variant_edges, :variant_records, :edge_hash
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ class PublishedIndex
5
+ # Resolves `generation.json` and enumerates published `payloads/gen-N`
6
+ # directories.
7
+ #
8
+ # Split out of {PublishedIndex} because this is pure pointer and
9
+ # directory inspection: it never touches an open reader or its retention
10
+ # lock, and keeping it separate is what lets {PublishedIndex} itself stay
11
+ # under `Metrics/ClassLength` without an exclude entry.
12
+ module GenerationCatalog
13
+ # The published generation pointer, distinguishing "no pointer file"
14
+ # (a flat index, where {Woods::Generation::UNPUBLISHED} is the honest
15
+ # answer) from "a pointer file that will not parse" (a corrupt install,
16
+ # which {Woods::Generation#current} silently maps to that same
17
+ # UNPUBLISHED sentinel). Only this method's caller has already checked
18
+ # which one it is, so only here can the two be told apart.
19
+ #
20
+ # @param root [Pathname]
21
+ # @return [Woods::Generation::Marker]
22
+ # @raise [PublishedIndex::CorruptPointerError] when the file exists but will not parse
23
+ def self.pointer(root)
24
+ generation = Woods::Generation.new(output_dir: root)
25
+ return Woods::Generation::UNPUBLISHED unless File.exist?(generation.path)
26
+
27
+ marker = generation.current
28
+ return marker unless marker.equal?(Woods::Generation::UNPUBLISHED)
29
+
30
+ raise PublishedIndex::CorruptPointerError, "Unreadable generation pointer: #{generation.path}"
31
+ end
32
+
33
+ # Published generation numbers, ascending. See
34
+ # {PublishedIndex.available_generations} for what "published" means.
35
+ #
36
+ # @param root [Pathname]
37
+ # @return [Array<Integer>]
38
+ # @raise [PublishedIndex::CorruptPointerError] when `generation.json` exists but will not parse
39
+ def self.available(root)
40
+ marker = pointer(root)
41
+ return [] if marker.number.zero?
42
+
43
+ payloads = Woods::PayloadStore.new(root)
44
+ return [] unless payloads.root.directory?
45
+
46
+ payloads.root.children.filter_map { |child| published_number(child, marker.number) }.sort
47
+ end
48
+
49
+ # A directory's generation number, when it qualifies as published:
50
+ # named `gen-<N>` for N at or below +pointer+, holding a
51
+ # `manifest.json`.
52
+ #
53
+ # @param child [Pathname]
54
+ # @param pointer [Integer] the currently published generation number
55
+ # @return [Integer, nil]
56
+ def self.published_number(child, pointer)
57
+ return nil unless child.directory?
58
+
59
+ match = child.basename.to_s.match(/\Agen-(\d+)\z/)
60
+ return nil unless match
61
+
62
+ number = match[1].to_i
63
+ return nil if number > pointer
64
+ return nil unless child.join('manifest.json').file?
65
+
66
+ number
67
+ end
68
+
69
+ private_class_method :published_number
70
+ end
71
+ end
72
+ end