woods 1.6.4 → 2.0.0.beta1

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 (282) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1879 -37
  3. data/CONTRIBUTING.md +195 -137
  4. data/README.md +162 -520
  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 +620 -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 +415 -0
  19. data/docs/INTERNALS.md +415 -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 +197 -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 +39 -3
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +78 -24
  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 +40 -1
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +18 -13
  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/credential_index.rb +5 -53
  64. data/lib/woods/console/credential_scanner.rb +15 -16
  65. data/lib/woods/console/dispatch_pipeline.rb +46 -34
  66. data/lib/woods/console/embedded_executor.rb +806 -257
  67. data/lib/woods/console/eval_guard.rb +27 -20
  68. data/lib/woods/console/input_contract.rb +78 -0
  69. data/lib/woods/console/model_validator.rb +24 -6
  70. data/lib/woods/console/rack_middleware.rb +62 -63
  71. data/lib/woods/console/redactor.rb +10 -24
  72. data/lib/woods/console/safe_context.rb +45 -45
  73. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  74. data/lib/woods/console/server.rb +136 -267
  75. data/lib/woods/console/sql_noise_stripper.rb +20 -51
  76. data/lib/woods/console/sql_table_scanner.rb +39 -90
  77. data/lib/woods/console/sql_validator.rb +455 -85
  78. data/lib/woods/console/table_gate.rb +2 -2
  79. data/lib/woods/console/tool_specs.rb +462 -88
  80. data/lib/woods/console/tools/tier1.rb +0 -3
  81. data/lib/woods/console/tools/tier4.rb +17 -7
  82. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  83. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  84. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  85. data/lib/woods/db/migrator.rb +3 -9
  86. data/lib/woods/db/schema_version.rb +47 -2
  87. data/lib/woods/dependency_graph.rb +898 -64
  88. data/lib/woods/embedding/fake.rb +138 -0
  89. data/lib/woods/embedding/indexer.rb +832 -40
  90. data/lib/woods/embedding/openai.rb +77 -19
  91. data/lib/woods/embedding/provider.rb +189 -11
  92. data/lib/woods/embedding/text_preparer.rb +1 -1
  93. data/lib/woods/embedding/token_counter.rb +0 -7
  94. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  95. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  96. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  97. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  98. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  99. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  100. data/lib/woods/evaluation/ablation_task.rb +66 -0
  101. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  102. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  103. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  104. data/lib/woods/evaluation/baseline.rb +60 -0
  105. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  106. data/lib/woods/evaluation/evaluator.rb +41 -8
  107. data/lib/woods/evaluation/query_set.rb +79 -13
  108. data/lib/woods/evaluation/report_generator.rb +20 -1
  109. data/lib/woods/export/unit_facts.rb +0 -11
  110. data/lib/woods/extracted_unit.rb +22 -63
  111. data/lib/woods/extractor.rb +2503 -192
  112. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  113. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  114. data/lib/woods/extractors/caching_extractor.rb +46 -12
  115. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  116. data/lib/woods/extractors/component_discovery.rb +123 -0
  117. data/lib/woods/extractors/concern_extractor.rb +17 -3
  118. data/lib/woods/extractors/controller_extractor.rb +389 -29
  119. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  120. data/lib/woods/extractors/engine_extractor.rb +53 -8
  121. data/lib/woods/extractors/event_extractor.rb +55 -4
  122. data/lib/woods/extractors/factory_extractor.rb +49 -11
  123. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  124. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  125. data/lib/woods/extractors/job_extractor.rb +51 -21
  126. data/lib/woods/extractors/lib_extractor.rb +23 -17
  127. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  128. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  129. data/lib/woods/extractors/manager_extractor.rb +19 -2
  130. data/lib/woods/extractors/migration_extractor.rb +22 -11
  131. data/lib/woods/extractors/model_extractor.rb +292 -57
  132. data/lib/woods/extractors/package_extractor.rb +154 -0
  133. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  134. data/lib/woods/extractors/policy_extractor.rb +6 -5
  135. data/lib/woods/extractors/poro_extractor.rb +13 -14
  136. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  137. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  138. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  139. data/lib/woods/extractors/reference_patterns.rb +38 -0
  140. data/lib/woods/extractors/route_extractor.rb +58 -2
  141. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  142. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  143. data/lib/woods/extractors/service_extractor.rb +11 -1
  144. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  145. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  146. data/lib/woods/extractors/source_nesting.rb +560 -0
  147. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  148. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  149. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  150. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  151. data/lib/woods/feedback/gap_detector.rb +9 -3
  152. data/lib/woods/feedback/store.rb +7 -1
  153. data/lib/woods/filename_utils.rb +29 -1
  154. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  155. data/lib/woods/flow_assembler.rb +63 -21
  156. data/lib/woods/flow_document.rb +1 -0
  157. data/lib/woods/flow_precomputer.rb +138 -22
  158. data/lib/woods/gem_mapper.rb +285 -0
  159. data/lib/woods/generation.rb +185 -0
  160. data/lib/woods/git_command.rb +38 -0
  161. data/lib/woods/git_provenance.rb +16 -2
  162. data/lib/woods/graph_analyzer.rb +408 -34
  163. data/lib/woods/index_artifact.rb +93 -23
  164. data/lib/woods/mcp/bearer_auth.rb +92 -22
  165. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  166. data/lib/woods/mcp/bootstrapper.rb +582 -77
  167. data/lib/woods/mcp/config_resolver.rb +66 -6
  168. data/lib/woods/mcp/errors.rb +60 -0
  169. data/lib/woods/mcp/index_reader.rb +836 -117
  170. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  171. data/lib/woods/mcp/origin_guard.rb +108 -23
  172. data/lib/woods/mcp/protocol_policy.rb +98 -0
  173. data/lib/woods/mcp/provider_probe.rb +45 -6
  174. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  175. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  176. data/lib/woods/mcp/server.rb +907 -154
  177. data/lib/woods/mcp/tasks/extension.rb +196 -0
  178. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  179. data/lib/woods/mcp/tasks/store.rb +518 -0
  180. data/lib/woods/mcp/tool_contract.rb +171 -0
  181. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  182. data/lib/woods/mcp/version_aware_tool_dispatch.rb +3 -9
  183. data/lib/woods/model_name_cache.rb +19 -1
  184. data/lib/woods/notion/client.rb +132 -36
  185. data/lib/woods/notion/exporter.rb +456 -61
  186. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  187. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  188. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  189. data/lib/woods/notion/mappers/shared.rb +45 -3
  190. data/lib/woods/notion/sync_manifest.rb +258 -0
  191. data/lib/woods/obsidian/errors.rb +6 -0
  192. data/lib/woods/obsidian/name_mapper.rb +40 -24
  193. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  194. data/lib/woods/operator/pipeline_guard.rb +118 -21
  195. data/lib/woods/operator/status_reporter.rb +20 -3
  196. data/lib/woods/path_dispatcher.rb +276 -0
  197. data/lib/woods/payload_store.rb +223 -0
  198. data/lib/woods/published_index/edge_shaper.rb +61 -0
  199. data/lib/woods/published_index/generation_catalog.rb +72 -0
  200. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  201. data/lib/woods/published_index.rb +287 -0
  202. data/lib/woods/railtie.rb +70 -38
  203. data/lib/woods/railtie_support.rb +167 -0
  204. data/lib/woods/release.rb +12 -0
  205. data/lib/woods/reload_policy.rb +206 -0
  206. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  207. data/lib/woods/resilience/index_validator.rb +296 -10
  208. data/lib/woods/resilience/retryable_provider.rb +71 -6
  209. data/lib/woods/resolved_config.rb +55 -11
  210. data/lib/woods/retrieval/context_assembler.rb +132 -40
  211. data/lib/woods/retrieval/query_classifier.rb +25 -6
  212. data/lib/woods/retrieval/ranker.rb +193 -28
  213. data/lib/woods/retrieval/search_executor.rb +206 -39
  214. data/lib/woods/retriever.rb +317 -71
  215. data/lib/woods/retry_after.rb +22 -2
  216. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  217. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  218. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  219. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  220. data/lib/woods/ruby_analyzer.rb +21 -5
  221. data/lib/woods/session_tracer/file_store.rb +138 -19
  222. data/lib/woods/session_tracer/redis_store.rb +122 -12
  223. data/lib/woods/session_tracer/session_flow_assembler.rb +54 -11
  224. data/lib/woods/session_tracer/session_flow_document.rb +52 -6
  225. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  226. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  227. data/lib/woods/session_tracer/store.rb +14 -1
  228. data/lib/woods/storage/metadata_store.rb +230 -26
  229. data/lib/woods/storage/pgvector.rb +180 -22
  230. data/lib/woods/storage/qdrant.rb +367 -41
  231. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  232. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  233. data/lib/woods/storage/snapshotter.rb +23 -5
  234. data/lib/woods/storage/vector_store.rb +49 -8
  235. data/lib/woods/storage_identity.rb +28 -0
  236. data/lib/woods/tasks.rb +53 -2
  237. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  238. data/lib/woods/temporal/snapshot_store.rb +139 -42
  239. data/lib/woods/unblocked/client.rb +119 -17
  240. data/lib/woods/unblocked/document_builder.rb +34 -2
  241. data/lib/woods/unblocked/exporter.rb +63 -27
  242. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  243. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  244. data/lib/woods/update_check.rb +24 -1
  245. data/lib/woods/util/uuid5.rb +124 -0
  246. data/lib/woods/version.rb +1 -1
  247. data/lib/woods/watch/daemon.rb +1345 -0
  248. data/lib/woods/watch/listen_watcher.rb +81 -0
  249. data/lib/woods/watch/polling_watcher.rb +137 -0
  250. data/lib/woods/watch/status.rb +169 -0
  251. data/lib/woods/watch/tree_scan.rb +163 -0
  252. data/lib/woods/watch/watcher.rb +100 -0
  253. data/lib/woods.rb +53 -9
  254. data/plugin/.claude-plugin/plugin.json +18 -0
  255. data/plugin/hooks/hooks.json +29 -0
  256. data/plugin/hooks/woods-post-edit.sh +226 -0
  257. data/plugin/hooks/woods-session-start.sh +77 -0
  258. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  259. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  260. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  261. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  262. data/plugin/skills/woods-setup/SKILL.md +99 -0
  263. metadata +102 -30
  264. data/lib/woods/console/adapter_family.rb +0 -39
  265. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  266. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  268. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  269. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  270. data/lib/woods/console/bridge.rb +0 -210
  271. data/lib/woods/console/credential_scanner_registry.rb +0 -36
  272. data/lib/woods/console/encrypted_credential_snapshot.rb +0 -16
  273. data/lib/woods/console/sql_output_policy.rb +0 -535
  274. data/lib/woods/console/sqlite_read_guard.rb +0 -46
  275. data/lib/woods/formatting/claude_adapter.rb +0 -98
  276. data/lib/woods/formatting/generic_adapter.rb +0 -56
  277. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  278. data/lib/woods/mcp/http_transport_options.rb +0 -15
  279. data/lib/woods/mcp/origin_policy.rb +0 -113
  280. data/lib/woods/notion/mapper.rb +0 -40
  281. data/lib/woods/observability/health_check.rb +0 -79
  282. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -0,0 +1,560 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'line_neutralizer'
4
+
5
+ module Woods
6
+ module Extractors
7
+ # Position-aware module/class nesting scanner for Ruby source text.
8
+ #
9
+ # Several extractors name their units by parsing declarations out of raw
10
+ # source. The naive approaches — "first +class+ regex match" or "prepend
11
+ # every +module+ in the file" — get nesting wrong (#174): they drop the
12
+ # enclosing namespace (`module Billing; class Payment` indexed as bare
13
+ # +Payment+), absorb sibling modules that closed before the class opened,
14
+ # and let helper modules nested *inside* a class pollute the identifier.
15
+ # Wrong identifiers collapse cross-namespace same-named classes onto one
16
+ # graph node and leave edges targeting the real constant dangling.
17
+ #
18
+ # This module scans source line by line tracking nesting *by position*:
19
+ # a stack of open +module+/+class+ declarations that pops on the matching
20
+ # +end+, using the same depth-tracking discipline as
21
+ # RakeTaskExtractor#block_opener? (the house pattern for what counts as a
22
+ # block opener). Compact declarations (`class Billing::Payment`) keep
23
+ # their qualified segments.
24
+ #
25
+ # The heuristic is regex-based, not an AST. Pathological constructs
26
+ # (heredocs containing keywords, keywords inside string literals) can
27
+ # unbalance the depth count — the accepted house tradeoff, shared with
28
+ # RakeTaskExtractor: declarations and their +end+s pair up for
29
+ # conventionally formatted files. Assignment-form conditionals
30
+ # (`x = if ...`) are counted; see {#block_opener?}.
31
+ #
32
+ # @example Qualifying the first class declaration
33
+ # qualified_first_class_name("module Billing\n class Payment\n end\nend")
34
+ # # => "Billing::Payment"
35
+ #
36
+ # @example Naming a concern by its outer module, not an inner mixin module
37
+ # qualified_outer_module_name("module Trackable\n module ClassMethods\n end\nend")
38
+ # # => "Trackable"
39
+ #
40
+ module SourceNesting
41
+ # Matches a line-leading module/class declaration and captures the
42
+ # keyword and the (possibly compact, `A::B`-style) constant name.
43
+ # The constant must start uppercase, which also keeps `class << self`
44
+ # from matching as a named declaration (it is still depth-tracked as an
45
+ # anonymous block via {#block_opener?}). The POSIX classes (rather than
46
+ # `[A-Z]\w*`) keep non-ASCII constants whole: `class Café` captured as
47
+ # +Caf+ is a wrong identifier, worse than declining and falling back to
48
+ # the path convention.
49
+ DECLARATION_PATTERN = /\A(module|class)\s+([[:upper:]][[:word:]]*(?:::[[:upper:]][[:word:]]*)*)/
50
+
51
+ # The offline stand-in for a Zeitwerk autoload root: the last `app/`
52
+ # directory segment in a path. When no Rails autoloader is up there is
53
+ # no loader to ask, so file-path governance falls back to the same
54
+ # convention the runtime loader follows — everything under
55
+ # `app/<kind>/` maps to constants, e.g.
56
+ # `app/services/domain/container/parser.rb` → +Domain::Container::Parser+.
57
+ MANAGED_PATH_PATTERN = %r{\A(.*/app/[^/]+/)}
58
+
59
+ # Inner module names that hold mixin plumbing (ActiveSupport::Concern's
60
+ # ClassMethods, the classic included-hook InstanceMethods) — never the
61
+ # unit a file is named for.
62
+ MIXIN_INNER_MODULES = %w[ClassMethods InstanceMethods].freeze
63
+
64
+ # Matches a line-leading `end` that closes a block, whether it stands
65
+ # alone or is followed by a method chain (`end.freeze`, `end.join(x)`)
66
+ # or a closing delimiter (`end)`, `end]`, `end,`). An exact `== 'end'`
67
+ # check misses all of those, so a line like `LIST = %w[a b].map do |x|
68
+ # x end.freeze` pushed a stack frame on `do` that its own `end` never
69
+ # popped.
70
+ END_LINE = /\Aend\b/
71
+
72
+ # Fully-qualified name of the first +class+ declaration in the source.
73
+ #
74
+ # Enclosing modules still open at that position are joined with the
75
+ # class's own name; compact-form segments are preserved. Sibling modules
76
+ # that closed before the class opened, and modules nested after the
77
+ # class, do not contribute — that positional accuracy is the point.
78
+ #
79
+ # @param source [String] Ruby source code
80
+ # @return [String, nil] Qualified class name, or nil when the source
81
+ # contains no class declaration
82
+ def qualified_first_class_name(source)
83
+ each_declaration(source) do |kind, _name, qualified|
84
+ return qualified if kind == 'class'
85
+ end
86
+
87
+ nil
88
+ end
89
+
90
+ # Zeitwerk-governed name for a file's primary constant (finding G-1).
91
+ #
92
+ # A file under a managed autoload path is expected by Zeitwerk to
93
+ # define the constant its path spells: `app/services/domain/container/
94
+ # parser.rb` must define +Domain::Container::Parser+. The
95
+ # source-derived parser ({#qualified_first_class_name}) returns at the
96
+ # FIRST class declaration, so a file whose namespaces are written as
97
+ # classes (`module Domain; class Container; class Parser`) named the
98
+ # unit after the wrapper (+Domain::Container+). Every sibling under the
99
+ # same wrapper then collided on one identifier, and same-type dedup
100
+ # silently dropped all but one.
101
+ #
102
+ # This method computes the expected constant path — from
103
+ # +Rails.autoloaders.main+ when a Rails autoloader is up, from the
104
+ # same path-to-constant convention offline otherwise — and returns the
105
+ # first declaration (class or module) whose qualified name equals it.
106
+ # When the path is not managed, or no declaration matches (an
107
+ # unconventional file), it returns nil and the caller falls back to
108
+ # {#qualified_first_class_name}: pre-G-1 behavior for everything the
109
+ # convention cannot vouch for.
110
+ #
111
+ # @param file_path [String, Pathname] Path to the source file
112
+ # @param source [String] Ruby source code
113
+ # @return [String, nil] The Zeitwerk-expected constant name, or nil
114
+ # when the path is unmanaged or the source does not declare it
115
+ def governed_class_name(file_path, source)
116
+ expected = managed_constant_path(file_path.to_s)
117
+ return nil unless expected
118
+
119
+ first_declaration_named(expected, source)
120
+ end
121
+
122
+ # Fully-qualified name of the file's primary module.
123
+ #
124
+ # Follows the initial chain of module declarations — each opening
125
+ # directly inside the previous one, with only blank/comment lines
126
+ # between — and returns the chain joined with `::`. Prelude lines
127
+ # before the first module (requires, magic comments, guards) are
128
+ # skipped. The chain stops at the first body content (methods,
129
+ # `extend`, a class, a self-terminated inner module) and never descends
130
+ # into mixin plumbing modules ({MIXIN_INNER_MODULES}), so `module
131
+ # Trackable; module ClassMethods` names +Trackable+ while `module
132
+ # Gateway; module Stripe; module Refundable` names
133
+ # +Gateway::Stripe::Refundable+. This preserves the file-per-unit
134
+ # assumption: one primary module per file, possibly wrapped in
135
+ # namespace modules.
136
+ #
137
+ # @param source [String] Ruby source code
138
+ # @return [String, nil] Qualified module name, or nil when the source
139
+ # opens no module
140
+ def qualified_outer_module_name(source)
141
+ chain = []
142
+
143
+ each_significant_line(source) do |stripped|
144
+ decl = DECLARATION_PATTERN.match(stripped)
145
+
146
+ unless decl
147
+ next if chain.empty? # prelude before any module (requires, guards)
148
+
149
+ break # body content — the chain is complete
150
+ end
151
+
152
+ break unless decl[1] == 'module'
153
+ break if decl[2].split('::').any? { |seg| MIXIN_INNER_MODULES.include?(seg) }
154
+
155
+ unless block_opener?(stripped)
156
+ # Self-terminated one-liner (`module Foo; end`): encloses nothing.
157
+ # Before the chain starts it is a sibling prelude (skip it);
158
+ # afterwards it is body content (chain complete).
159
+ break unless chain.empty?
160
+
161
+ next
162
+ end
163
+
164
+ chain << decl[2]
165
+ end
166
+
167
+ chain.empty? ? nil : chain.join('::')
168
+ end
169
+
170
+ # Check if a line opens a new block (do...end, def...end, etc.).
171
+ #
172
+ # Mirrors RakeTaskExtractor#block_opener? — the house pattern:
173
+ # +if+/+unless+ count in statement position (leading the line, or
174
+ # directly after an assignment operator: `value = if x`), not as
175
+ # trailing modifiers (e.g., `return if x`, `value = 1 if x`), and a
176
+ # line ending in +end+ closed whatever it opened.
177
+ #
178
+ # @param stripped [String] Stripped line content
179
+ # @return [Boolean]
180
+ def block_opener?(stripped)
181
+ return true if stripped.match?(/\b(do|def|case|begin|class|module|while|until|for)\b.*(?<!\bend)\s*$/)
182
+
183
+ stripped.match?(/(?:\A|=\s*)(?:if|unless)\b.*(?<!\bend)\s*$/)
184
+ end
185
+
186
+ private
187
+
188
+ # Yield every module/class declaration with its position-aware
189
+ # qualified name. The stack discipline is the one documented on the
190
+ # module: declarations open frames, {#block_opener?} opens anonymous
191
+ # (depth-only) frames, and {END_LINE}-matching lines pop them.
192
+ #
193
+ # @param source [String] Ruby source code
194
+ # @yieldparam kind [String] 'module' or 'class'
195
+ # @yieldparam name [String] The declared (possibly compact) name
196
+ # @yieldparam qualified [String] Enclosing stack joined with the name
197
+ # @return [void]
198
+ def each_declaration(source)
199
+ stack = []
200
+
201
+ each_significant_line(source) do |stripped|
202
+ if (decl = DECLARATION_PATTERN.match(stripped))
203
+ yield decl[1], decl[2], (stack.compact << decl[2]).join('::')
204
+
205
+ # A self-terminated declaration (`module Foo; end`) is not an
206
+ # opener under the house pattern and encloses nothing.
207
+ stack << decl[2] if block_opener?(stripped)
208
+ elsif stripped.match?(END_LINE)
209
+ stack.pop
210
+ elsif block_opener?(stripped)
211
+ stack << nil # anonymous block (do/def/if/...): depth only, no name
212
+ end
213
+ end
214
+ end
215
+
216
+ # First declaration whose qualified name equals +expected+.
217
+ #
218
+ # @param expected [String] The Zeitwerk-expected constant path
219
+ # @param source [String] Ruby source code
220
+ # @return [String, nil] The matching qualified declaration name
221
+ def first_declaration_named(expected, source)
222
+ each_declaration(source) do |_kind, _name, qualified|
223
+ return qualified if qualified == expected
224
+ end
225
+
226
+ nil
227
+ end
228
+
229
+ # Constant path the file's position under a managed root expects.
230
+ #
231
+ # @param file_path [String] Absolute path to the source file
232
+ # @return [String, nil] Camelized constant path, or nil when the file
233
+ # is not under a managed root
234
+ def managed_constant_path(file_path)
235
+ file_path = File.expand_path(file_path.to_s)
236
+ root, owner, foreign_root = managed_root_for(file_path)
237
+ return nil unless root
238
+
239
+ return loader_expected_cpath(file_path) if owner == :loader
240
+ return foreign_loader_constant_path(file_path, root, foreign_root) if owner == :foreign_loader
241
+
242
+ local_managed_constant_path(file_path, root)
243
+ end
244
+
245
+ # The constant path the active main loader expects for +file_path+,
246
+ # asked through the loader's own API so its inflector, namespace
247
+ # collapse, and ignore rules decide (a custom `api => API` inflection
248
+ # must win over the local camelizer). Available on Zeitwerk 2.6.4+;
249
+ # nil — unmanaged — when the loader's API is older, declines the
250
+ # file, or cannot derive a name. A decline is authoritative: the
251
+ # local camelizer must not rescue it.
252
+ #
253
+ # @param file_path [String] Expanded absolute path to the file
254
+ # @return [String, nil]
255
+ def loader_expected_cpath(file_path)
256
+ loader = main_loader
257
+ return nil unless loader.respond_to?(:cpath_expected_at)
258
+
259
+ loader.cpath_expected_at(file_path)
260
+ rescue StandardError
261
+ nil
262
+ end
263
+
264
+ # The local path-to-constant convention. Reserved for roots with no
265
+ # loader to ask: the offline stand-in ({MANAGED_PATH_PATTERN}).
266
+ #
267
+ # @param file_path [String] Expanded absolute path to the file
268
+ # @param root [String] The managed root the path is relative to
269
+ # @return [String]
270
+ def local_managed_constant_path(file_path, root)
271
+ relative = file_path[root.length..].to_s.sub(/\.rb\z/, '')
272
+ relative.split('/').reject(&:empty?).map { |segment| camelize_segment(segment) }.join('::')
273
+ end
274
+
275
+ # Constant path for a copied/multi-worktree file whose active root is
276
+ # governed by the boot loader's root shape. Prefer the loader API by
277
+ # mapping the active file onto the corresponding boot-loader root; when
278
+ # the file exists only in the copy, keep the loader's inflector instead
279
+ # of falling back to Woods' default camelizer.
280
+ #
281
+ # @param file_path [String] Expanded absolute path to the file
282
+ # @param root [String] Active managed root the path is relative to
283
+ # @param foreign_root [String, nil] Matching managed root from loader.dirs
284
+ # @return [String, nil]
285
+ def foreign_loader_constant_path(file_path, root, foreign_root)
286
+ mapped = foreign_root && mapped_foreign_path(file_path, root, foreign_root)
287
+ return nil unless mapped
288
+
289
+ # If the corresponding boot-root path exists, the real loader can
290
+ # answer with full semantics: ignores, root namespaces, collapses,
291
+ # and custom inflections. A nil answer is an authoritative non-claim.
292
+ return loader_expected_cpath(mapped) if File.exist?(mapped)
293
+
294
+ return nil unless copied_only_loader_fallback_allowed?(file_path, root, foreign_root)
295
+
296
+ inflected_managed_constant_path(file_path, root)
297
+ end
298
+
299
+ # @param file_path [String] Expanded absolute path under the active root
300
+ # @param root [String] Active managed root
301
+ # @param foreign_root [String] Matching boot-loader managed root
302
+ # @return [String]
303
+ def mapped_foreign_path(file_path, root, foreign_root)
304
+ relative = file_path[root.length..].to_s
305
+ File.join(foreign_root, relative)
306
+ end
307
+
308
+ # The path-to-constant convention using the active loader's inflector
309
+ # for copied-only foreign-root files. This path deliberately handles
310
+ # only the common Object-root, non-collapsed shape; root namespaces and
311
+ # collapsed directories require +cpath_expected_at+ over a real mapped
312
+ # path, so copied-only files with those rules stay unmanaged instead
313
+ # of receiving a guessed identifier.
314
+ #
315
+ # @param file_path [String] Expanded absolute path to the file
316
+ # @param root [String] The managed root the path is relative to
317
+ # @return [String]
318
+ def inflected_managed_constant_path(file_path, root)
319
+ relative = file_path[root.length..].to_s.sub(/\.rb\z/, '')
320
+ segments = relative.split('/').reject(&:empty?)
321
+ current = root.chomp('/')
322
+ segments.map do |segment|
323
+ current = File.join(current, segment)
324
+ loader_camelize(segment, current)
325
+ end.join('::')
326
+ end
327
+
328
+ # @param segment [String] Path segment without extension
329
+ # @param abspath [String] Absolute path passed to Zeitwerk inflectors
330
+ # @return [String]
331
+ def loader_camelize(segment, abspath)
332
+ loader = main_loader
333
+ inflector = loader.respond_to?(:inflector) ? loader.inflector : nil
334
+ return camelize_segment(segment) unless inflector.respond_to?(:camelize)
335
+
336
+ begin
337
+ inflector.camelize(segment, abspath)
338
+ rescue ArgumentError
339
+ inflector.camelize(segment)
340
+ end
341
+ rescue StandardError
342
+ camelize_segment(segment)
343
+ end
344
+
345
+ # @param file_path [String] Expanded absolute path to the file
346
+ # @param root [String] Active managed root the path is relative to
347
+ # @param foreign_root [String] Matching boot-loader managed root
348
+ # @return [Boolean]
349
+ def copied_only_loader_fallback_allowed?(file_path, root, foreign_root)
350
+ loader_root_namespace(foreign_root) == Object &&
351
+ !mapped_path_uses_collapsed_directory?(file_path, root, foreign_root) &&
352
+ !mapped_path_crosses_loader_root?(file_path, root, foreign_root)
353
+ end
354
+
355
+ # A nested loader root resets Zeitwerk's constant-path origin. The
356
+ # copied-only fallback cannot ask +cpath_expected_at+ about a file that
357
+ # exists only in the active copy, so it must not camelize the nested
358
+ # root directory as an extra namespace segment.
359
+ def mapped_path_crosses_loader_root?(file_path, root, foreign_root)
360
+ mapped = mapped_foreign_path(file_path, root, foreign_root)
361
+ Array(main_loader.dirs).any? do |dir|
362
+ nested = File.expand_path(dir.to_s.chomp('/'))
363
+ nested != foreign_root && mapped.start_with?("#{nested}/")
364
+ end
365
+ rescue StandardError
366
+ true
367
+ end
368
+
369
+ # @param foreign_root [String] Matching boot-loader managed root
370
+ # @return [Module]
371
+ def loader_root_namespace(foreign_root)
372
+ loader = main_loader
373
+ namespaced_dirs = loader.dirs(namespaces: true) if loader.respond_to?(:dirs)
374
+ return Object unless namespaced_dirs.respond_to?(:[])
375
+
376
+ namespaced_dirs[foreign_root] || Object
377
+ rescue StandardError
378
+ Object
379
+ end
380
+
381
+ # @param file_path [String] Expanded absolute path to the file
382
+ # @param root [String] Active managed root
383
+ # @param foreign_root [String] Matching boot-loader managed root
384
+ # @return [Boolean]
385
+ def mapped_path_uses_collapsed_directory?(file_path, root, foreign_root)
386
+ loader = main_loader
387
+ return false unless loader.respond_to?(:__collapse?)
388
+
389
+ relative = file_path[root.length..].to_s.sub(%r{\A/}, '').sub(/\.rb\z/, '')
390
+ segments = relative.split('/').reject(&:empty?)
391
+ current = foreign_root.chomp('/')
392
+
393
+ segments[0...-1].any? do |segment|
394
+ current = File.join(current, segment)
395
+ loader.__collapse?(current)
396
+ end
397
+ rescue StandardError
398
+ true
399
+ end
400
+
401
+ # The managed root directory containing +file_path+, if any, with the
402
+ # authority that vouches for it.
403
+ #
404
+ # When a Rails autoloader is up, its directory list is the authority
405
+ # for the files it claims. For a file it does NOT claim, the
406
+ # non-claim stays authoritative whenever the loader demonstrably
407
+ # belongs to the active root (any loader directory under it) or
408
+ # reports an empty set (classic mode): unmanaged. Only when every
409
+ # loader root belongs to a DIFFERENT tree — copied-app and
410
+ # multi-worktree extractions, where the loader belongs to the boot
411
+ # root while Rails.root is repointed per slot — does the root-relative
412
+ # `app/<kind>/` shape under the active root govern, still using the
413
+ # foreign loader's inflector. No broad trust of paths that merely look
414
+ # like app trees. When no autoloader information exists at all (no Rails, a spec stub),
415
+ # {MANAGED_PATH_PATTERN} stands in offline.
416
+ #
417
+ # @param file_path [String] Absolute path to the source file
418
+ # @return [Array(String, Symbol), nil] `[root, owner]` with owner
419
+ # +:loader+, +:foreign_loader+, or +:convention+, or nil when the
420
+ # file is unmanaged
421
+ def managed_root_for(file_path)
422
+ file_path = File.expand_path(file_path)
423
+ loader = main_loader
424
+ unless loader
425
+ root = MANAGED_PATH_PATTERN.match(file_path)&.captures&.first
426
+ return root ? [root, :convention] : nil
427
+ end
428
+
429
+ dirs = Array(loader.dirs).map { |dir| File.expand_path(dir.to_s.chomp('/')) }
430
+
431
+ claimed = dirs.find { |dir| file_path.start_with?("#{dir}/") }
432
+ return [claimed, :loader] if claimed
433
+
434
+ active = active_root
435
+ return nil unless active
436
+ return nil if dirs.any? { |dir| dir.start_with?("#{active}/") }
437
+ return nil if dirs.empty?
438
+
439
+ active_root_managed_for(file_path, active, dirs)
440
+ end
441
+
442
+ # Root-relative managed root for a file the autoloader does not
443
+ # claim, when the loader's roots all belong to another tree: match
444
+ # the conventional `app/<kind>/` shape under the ACTIVE extraction
445
+ # root to the corresponding foreign loader roots. If exactly one
446
+ # mapped foreign file exists, that loader root is authoritative. If
447
+ # none exists, copied-only inflector fallback is allowed only for an
448
+ # unambiguous single foreign root. Ambiguous roots stay unmanaged
449
+ # rather than guessing between namespaces, collapses, or inflectors.
450
+ #
451
+ # @param file_path [String] Absolute path to the source file
452
+ # @param active [String] Expanded active extraction root
453
+ # @param dirs [Array<String>] Foreign loader roots
454
+ # @return [Array(String, Symbol, String), nil] `[root, :foreign_loader,
455
+ # foreign_root]`, or nil
456
+ def active_root_managed_for(file_path, active, dirs)
457
+ return nil unless file_path.start_with?("#{active}/")
458
+
459
+ shape = file_path[active.length..].to_s.match(%r{\A(/app/[^/]+/)})
460
+ return nil unless shape
461
+
462
+ root = "#{active}#{shape[1]}"
463
+ suffix = shape[1].chomp('/')
464
+ candidates = dirs.select { |dir| dir.end_with?(suffix) }
465
+ return nil if candidates.empty?
466
+
467
+ existing = candidates.select do |foreign_root|
468
+ File.exist?(mapped_foreign_path(file_path, root, foreign_root))
469
+ end
470
+
471
+ return [root, :foreign_loader, existing.first] if existing.one?
472
+ return nil if existing.any?
473
+
474
+ foreign_root = candidates.one? ? candidates.first : nil
475
+ return nil unless foreign_root
476
+
477
+ [root, :foreign_loader, foreign_root]
478
+ end
479
+
480
+ # The active extraction root, expanded: the boot root in a normal
481
+ # extraction, the repointed root in a copied or multi-worktree slot.
482
+ #
483
+ # @return [String, nil]
484
+ def active_root
485
+ return nil unless defined?(Rails) && Rails.respond_to?(:root)
486
+
487
+ root = Rails.root
488
+ root && File.expand_path(root.to_s)
489
+ rescue StandardError
490
+ nil
491
+ end
492
+
493
+ # The main Rails autoloader, or nil when there is none to consult
494
+ # (no Rails, a spec stub). In classic mode the loader exists with an
495
+ # empty directory list — an authoritative empty set.
496
+ #
497
+ # @return [Zeitwerk::Loader, nil]
498
+ def main_loader
499
+ return nil unless defined?(Rails) && Rails.respond_to?(:autoloaders)
500
+
501
+ autoloaders = Rails.autoloaders
502
+ return nil unless autoloaders.respond_to?(:main)
503
+
504
+ main = autoloaders.main
505
+ main.respond_to?(:dirs) ? main : nil
506
+ rescue StandardError
507
+ nil
508
+ end
509
+
510
+ # Camelize one path segment the way Zeitwerk's default inflector
511
+ # treats conventional names: `payment` → +Payment+, `fee_schedule` →
512
+ # +FeeSchedule+. Kept local so the scanner stays free of ActiveSupport.
513
+ # It diverges from Zeitwerk on all-caps segments and cannot honor a
514
+ # custom inflector (e.g. `api` configured as +API+): the mismatch only
515
+ # fails the governed match, which falls back to the source parser —
516
+ # governance is lost, an identifier is never wrong.
517
+ #
518
+ # @param segment [String]
519
+ # @return [String]
520
+ def camelize_segment(segment)
521
+ segment.gsub(/(?:\A|_)([a-z])/) { Regexp.last_match(1).upcase }
522
+ end
523
+
524
+ # Yield each non-blank, non-comment line of source, stripped.
525
+ #
526
+ # Trailing comments are removed too (quote-aware, via
527
+ # {LineNeutralizer}): a comment is prose, and prose containing "end",
528
+ # "do", or "for" made {#block_opener?} miscount — `module Api # rename
529
+ # at the end` looked self-terminated, `x.to_s # pad for display` opened
530
+ # a phantom frame that swallowed a real `end`. `=begin`/`=end` block
531
+ # comments are skipped whole for the same reason.
532
+ #
533
+ # @param source [String] Ruby source code
534
+ # @yieldparam stripped [String] Stripped significant line
535
+ # @return [void]
536
+ def each_significant_line(source)
537
+ in_block_comment = false
538
+
539
+ source.each_line do |line|
540
+ raw = line.strip
541
+
542
+ if in_block_comment
543
+ in_block_comment = false if raw.start_with?('=end')
544
+ next
545
+ elsif raw.start_with?('=begin')
546
+ in_block_comment = true
547
+ next
548
+ end
549
+
550
+ next if raw.empty? || raw.start_with?('#')
551
+
552
+ stripped = LineNeutralizer.strip_line_comment(line).strip
553
+ next if stripped.empty?
554
+
555
+ yield stripped
556
+ end
557
+ end
558
+ end
559
+ end
560
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'shared_utility_methods'
4
4
  require_relative 'shared_dependency_scanner'
5
+ require_relative 'source_nesting'
5
6
 
6
7
  module Woods
7
8
  module Extractors
@@ -25,6 +26,7 @@ module Woods
25
26
  class StateMachineExtractor
26
27
  include SharedUtilityMethods
27
28
  include SharedDependencyScanner
29
+ include SourceNesting
28
30
 
29
31
  MODEL_DIRECTORIES = %w[app/models].freeze
30
32
 
@@ -66,13 +68,19 @@ module Woods
66
68
  # Class Discovery
67
69
  # ──────────────────────────────────────────────────────────────────────
68
70
 
69
- # Detect class name from source or derive it from the file path.
71
+ # Detect the fully-qualified class name from source or derive it from
72
+ # the file path.
73
+ #
74
+ # Uses the position-aware nesting scanner (SourceNesting) so a model
75
+ # defined as `module Billing; class Payment` is named
76
+ # +Billing::Payment+, not bare +Payment+ (#174).
70
77
  #
71
78
  # @param source [String] Ruby source code
72
79
  # @param file_path [String] File path
73
- # @return [String] Class name
80
+ # @return [String] Fully-qualified class name
74
81
  def detect_class_name(source, file_path)
75
- return ::Regexp.last_match(1) if source =~ /^\s*class\s+([\w:]+)/
82
+ qualified = governed_class_name(file_path, source) || qualified_first_class_name(source)
83
+ return qualified if qualified
76
84
 
77
85
  relative = file_path.sub("#{Rails.root}/", '')
78
86
  relative.sub(%r{^app/models/}, '').sub('.rb', '').camelize
@@ -265,6 +273,14 @@ module Woods
265
273
  # Only processes lines after detecting +event :name do+, and closes the event
266
274
  # when the matching +end+ is found.
267
275
  #
276
+ # A blockless declaration (`event :noop`) is complete on its own line and
277
+ # is emitted immediately — the same discipline
278
+ # {FactoryExtractor#match_factory} follows. Leaving it "current" with
279
+ # depth 0 dropped it silently when the next `event` replaced it, and the
280
+ # enclosing block's `end` drove depth negative, after which every later
281
+ # event failed the `depth.zero?` guard (EXTB-17). Depth is clamped at
282
+ # zero so an unbalanced `end` cannot poison the rest of the scan.
283
+ #
268
284
  # @param source [String] Source code to parse
269
285
  # @param event_pattern [Regexp] Pattern to match event declaration (must capture event name in group 1)
270
286
  # @return [Array<Hash>] Events with :name and :transitions keys
@@ -278,8 +294,13 @@ module Woods
278
294
  next if stripped.start_with?('#')
279
295
 
280
296
  if depth.zero? && (m = stripped.match(event_pattern))
281
- current_event = { name: m[1], transitions: [] }
282
- depth = 1 if stripped.include?(' do') || stripped.end_with?('do')
297
+ event = { name: m[1], transitions: [] }
298
+ if stripped.include?(' do') || stripped.end_with?('do')
299
+ current_event = event
300
+ depth = 1
301
+ else
302
+ events << event
303
+ end
283
304
  next
284
305
  end
285
306
 
@@ -292,7 +313,7 @@ module Woods
292
313
  if stripped.match?(/\bdo\b/) && depth.positive?
293
314
  depth += 1
294
315
  elsif stripped == 'end'
295
- depth -= 1
316
+ depth -= 1 if depth.positive?
296
317
  if depth.zero?
297
318
  events << current_event
298
319
  current_event = nil
@@ -324,18 +345,9 @@ module Woods
324
345
  nil
325
346
  end
326
347
 
327
- # Check if a line opens a new block.
328
- #
329
- # Mirrors the implementation in RakeTaskExtractor to correctly handle
330
- # trailing +if+/+unless+ modifiers vs standalone block openers.
331
- #
332
- # @param stripped [String] Stripped line content
333
- # @return [Boolean]
334
- def block_opener?(stripped)
335
- return true if stripped.match?(/\b(do|def|case|begin|class|module|while|until|for)\b.*(?<!\bend)\s*$/)
336
-
337
- stripped.match?(/\A(if|unless)\b/)
338
- end
348
+ # NOTE: block_opener? (the RakeTaskExtractor-style depth-tracking
349
+ # discipline used by extract_block_for_state_machine above) is provided
350
+ # by the included SourceNesting module.
339
351
 
340
352
  # ──────────────────────────────────────────────────────────────────────
341
353
  # Unit Construction