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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1879 -37
- data/CONTRIBUTING.md +195 -137
- data/README.md +162 -520
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +620 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +415 -0
- data/docs/INTERNALS.md +415 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +197 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +39 -3
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +78 -24
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +40 -1
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +18 -13
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/credential_index.rb +5 -53
- data/lib/woods/console/credential_scanner.rb +15 -16
- data/lib/woods/console/dispatch_pipeline.rb +46 -34
- data/lib/woods/console/embedded_executor.rb +806 -257
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +24 -6
- data/lib/woods/console/rack_middleware.rb +62 -63
- data/lib/woods/console/redactor.rb +10 -24
- data/lib/woods/console/safe_context.rb +45 -45
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +136 -267
- data/lib/woods/console/sql_noise_stripper.rb +20 -51
- data/lib/woods/console/sql_table_scanner.rb +39 -90
- data/lib/woods/console/sql_validator.rb +455 -85
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +462 -88
- data/lib/woods/console/tools/tier1.rb +0 -3
- data/lib/woods/console/tools/tier4.rb +17 -7
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2503 -192
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +63 -21
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +138 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +408 -34
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +92 -22
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +108 -23
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +907 -154
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/mcp/version_aware_tool_dispatch.rb +3 -9
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +223 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +70 -38
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +25 -6
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +54 -11
- data/lib/woods/session_tracer/session_flow_document.rb +52 -6
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +53 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +102 -30
- data/lib/woods/console/adapter_family.rb +0 -39
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/console/credential_scanner_registry.rb +0 -36
- data/lib/woods/console/encrypted_credential_snapshot.rb +0 -16
- data/lib/woods/console/sql_output_policy.rb +0 -535
- data/lib/woods/console/sqlite_read_guard.rb +0 -46
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/mcp/http_transport_options.rb +0 -15
- data/lib/woods/mcp/origin_policy.rb +0 -113
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- 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
|
|
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]
|
|
80
|
+
# @return [String] Fully-qualified class name
|
|
74
81
|
def detect_class_name(source, file_path)
|
|
75
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
#
|
|
328
|
-
#
|
|
329
|
-
#
|
|
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
|