woods 1.6.0 → 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 +1908 -0
- data/CONTRIBUTING.md +202 -87
- data/README.md +161 -513
- 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 +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- 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 +7 -2
- 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/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- 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 +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- 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 +2509 -189
- 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 +102 -13
- 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 +66 -7
- 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 +898 -152
- 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/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 +69 -30
- 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 +26 -8
- 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/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- 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 +134 -23
- 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/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/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- data/lib/woods/observability/instrumentation.rb +0 -34
|
@@ -28,6 +28,16 @@ module Woods
|
|
|
28
28
|
RSPEC_GLOB = 'spec/**/*_spec.rb'
|
|
29
29
|
MINITEST_GLOB = 'test/**/*_test.rb'
|
|
30
30
|
|
|
31
|
+
# Constant-form describe: `describe User do`, `RSpec.describe User, type: :model do`.
|
|
32
|
+
# The constant must start uppercase (quoted strings can't sneak in) and may be
|
|
33
|
+
# followed by whitespace OR a comma — the rspec-rails generator default is
|
|
34
|
+
# `RSpec.describe User, type: :model do` (B-082 / #194).
|
|
35
|
+
RSPEC_CONSTANT_DESCRIBE = /^\s*(?:RSpec\.)?describe\s+([A-Z][\w:]*)(?=[\s,]|$)/
|
|
36
|
+
|
|
37
|
+
# String-form describe: `describe 'User' do`, `RSpec.describe 'GET /users', type: :request do`.
|
|
38
|
+
# The closing quote delimits the subject, so nothing is required after it.
|
|
39
|
+
RSPEC_STRING_DESCRIBE = /^\s*(?:RSpec\.)?describe\s+['"]([^'"]+)['"]/
|
|
40
|
+
|
|
31
41
|
def initialize
|
|
32
42
|
@rails_root = Rails.root
|
|
33
43
|
end
|
|
@@ -114,21 +124,28 @@ module Woods
|
|
|
114
124
|
framework == :rspec ? extract_rspec_subject(source) : extract_minitest_subject(source)
|
|
115
125
|
end
|
|
116
126
|
|
|
117
|
-
# Extract subject class from
|
|
127
|
+
# Extract subject class from the first describe in an RSpec file.
|
|
118
128
|
#
|
|
119
|
-
#
|
|
120
|
-
# form (describe
|
|
129
|
+
# Scans line by line and takes the file's FIRST describe, whatever its
|
|
130
|
+
# form — constant reference (describe User do, RSpec.describe User,
|
|
131
|
+
# type: :model do) or string (describe 'User' do). An inner
|
|
132
|
+
# `describe 'validations'` nested under a constant-form outer describe
|
|
133
|
+
# must never become the subject: it would mint a phantom graph node and
|
|
134
|
+
# lose the real coverage edge. Handles both RSpec.describe and bare
|
|
135
|
+
# describe.
|
|
121
136
|
#
|
|
122
137
|
# @param source [String] RSpec file source code
|
|
123
138
|
# @return [String, nil]
|
|
124
139
|
def extract_rspec_subject(source)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
140
|
+
source.each_line do |line|
|
|
141
|
+
constant = line.match(RSPEC_CONSTANT_DESCRIBE)
|
|
142
|
+
return constant[1] if constant
|
|
128
143
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
144
|
+
string = line.match(RSPEC_STRING_DESCRIBE)
|
|
145
|
+
return string[1] if string
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
nil
|
|
132
149
|
end
|
|
133
150
|
|
|
134
151
|
# Extract subject class from Minitest test class name.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'component_discovery'
|
|
3
4
|
require_relative 'shared_utility_methods'
|
|
4
5
|
require_relative 'shared_dependency_scanner'
|
|
5
6
|
require_relative 'route_helper_resolver'
|
|
@@ -25,6 +26,7 @@ module Woods
|
|
|
25
26
|
# card = units.find { |u| u.identifier == "CardComponent" }
|
|
26
27
|
#
|
|
27
28
|
class ViewComponentExtractor
|
|
29
|
+
include ComponentDiscovery
|
|
28
30
|
include SharedUtilityMethods
|
|
29
31
|
include SharedDependencyScanner
|
|
30
32
|
include RouteHelperResolver
|
|
@@ -38,13 +40,36 @@ module Woods
|
|
|
38
40
|
#
|
|
39
41
|
# @return [Array<ExtractedUnit>] List of view component units
|
|
40
42
|
def extract_all
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@component_base.descendants.map do |component|
|
|
43
|
+
discoverable_classes.map do |component|
|
|
44
44
|
extract_component(component)
|
|
45
45
|
end.compact
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
# The component classes this extractor would extract from the running
|
|
49
|
+
# app. Shared with the incremental path's class reconciliation (#164).
|
|
50
|
+
#
|
|
51
|
+
# @return [Array<Class>]
|
|
52
|
+
def discoverable_classes
|
|
53
|
+
return [] unless @component_base
|
|
54
|
+
|
|
55
|
+
# Same reason PhlexExtractor walks first: `descendants` cannot see a
|
|
56
|
+
# component the eager load never reached (B-184). Skipped entirely
|
|
57
|
+
# when ViewComponent::Base is undefined, since @component_base is then
|
|
58
|
+
# nil and this app has no view components to find.
|
|
59
|
+
load_component_files
|
|
60
|
+
|
|
61
|
+
# Previews are filtered here rather than only in {#extract_component},
|
|
62
|
+
# because this set is also the incremental path's reconciliation input.
|
|
63
|
+
# A preview class is never in the graph — `extract_component` rejects it
|
|
64
|
+
# — so leaving it in made every incremental run recompute the same
|
|
65
|
+
# "addition", extract it, get nil, and dirty the dependents pass for
|
|
66
|
+
# nothing. Anonymous classes go too: `extract_component` rejects a nil
|
|
67
|
+
# name for the same reason.
|
|
68
|
+
@component_base.descendants.reject do |component|
|
|
69
|
+
component.name.nil? || preview_class?(component)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
48
73
|
# Extract a single ViewComponent component
|
|
49
74
|
#
|
|
50
75
|
# @param component [Class] The component class
|
|
@@ -19,8 +19,9 @@ module Woods
|
|
|
19
19
|
ENGINE_NAME = :erb
|
|
20
20
|
|
|
21
21
|
# Matches named route helpers (e.g. `posts_path`, `user_url`) in
|
|
22
|
-
# template source.
|
|
23
|
-
#
|
|
22
|
+
# template source. Same shape as
|
|
23
|
+
# {SharedDependencyScanner::ROUTE_HELPER_PATTERN}, but this engine
|
|
24
|
+
# has no dependency on that module, so it keeps its own copy.
|
|
24
25
|
ROUTE_HELPER_PATTERN = /\b(\w+)_(path|url)\b/
|
|
25
26
|
|
|
26
27
|
# Matches form_with / form_for calls whose action is a named
|
|
@@ -72,8 +73,17 @@ module Woods
|
|
|
72
73
|
EXTENSIONS
|
|
73
74
|
end
|
|
74
75
|
|
|
76
|
+
# Render option keys that are never themselves a partial name. The
|
|
77
|
+
# bare `render :foo` shorthand pattern below cannot tell `render
|
|
78
|
+
# :partial => 'shared/header'` (the pre-Ruby-3.0 hash-rocket form of
|
|
79
|
+
# the `partial:` option) from an actual `render :partial_name` call,
|
|
80
|
+
# so it must exclude these explicitly rather than record the option
|
|
81
|
+
# key as a partial.
|
|
82
|
+
RESERVED_RENDER_OPTIONS = %w[partial template layout].freeze
|
|
83
|
+
|
|
75
84
|
# Matches:
|
|
76
85
|
# - `render partial: 'foo/bar'`
|
|
86
|
+
# - `render :partial => 'foo/bar'` (hash-rocket form)
|
|
77
87
|
# - `render 'foo/bar'`
|
|
78
88
|
# - `render :foo`
|
|
79
89
|
#
|
|
@@ -85,12 +95,16 @@ module Woods
|
|
|
85
95
|
partials << match[0]
|
|
86
96
|
end
|
|
87
97
|
|
|
98
|
+
source.scan(/render\s+:partial\s*=>\s*['"]([^'"]+)['"]/).each do |match|
|
|
99
|
+
partials << match[0]
|
|
100
|
+
end
|
|
101
|
+
|
|
88
102
|
source.scan(/render\s+['"]([^'"]+)['"]/).each do |match|
|
|
89
103
|
partials << match[0]
|
|
90
104
|
end
|
|
91
105
|
|
|
92
106
|
source.scan(/render\s+:(\w+)/).each do |match|
|
|
93
|
-
partials << match[0]
|
|
107
|
+
partials << match[0] unless RESERVED_RENDER_OPTIONS.include?(match[0])
|
|
94
108
|
end
|
|
95
109
|
|
|
96
110
|
partials.to_a
|
|
@@ -53,15 +53,21 @@ module Woods
|
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
# Count
|
|
56
|
+
# Count how many low-scoring *queries* mention each keyword.
|
|
57
|
+
#
|
|
58
|
+
# Deliberately one vote per query, not per occurrence: counting
|
|
59
|
+
# occurrences let a single query reading "user user user user" clear a
|
|
60
|
+
# threshold of four on its own, and made every reported count larger
|
|
61
|
+
# than the number of queries it summarized — which is what the
|
|
62
|
+
# description string claims to be reporting.
|
|
57
63
|
#
|
|
58
64
|
# @param ratings [Array<Hash>] Low-score rating entries
|
|
59
|
-
# @return [Hash<String, Integer>] Keyword =>
|
|
65
|
+
# @return [Hash<String, Integer>] Keyword => number of queries mentioning it
|
|
60
66
|
def count_keywords(ratings)
|
|
61
67
|
counts = Hash.new(0)
|
|
62
68
|
ratings.each do |rating|
|
|
63
69
|
words = rating['query'].to_s.downcase.split(/\W+/).reject { |w| w.length < 3 }
|
|
64
|
-
words.each { |w| counts[w] += 1 }
|
|
70
|
+
words.uniq.each { |w| counts[w] += 1 }
|
|
65
71
|
end
|
|
66
72
|
counts
|
|
67
73
|
end
|
data/lib/woods/feedback/store.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'fileutils'
|
|
5
|
+
require 'time'
|
|
5
6
|
|
|
6
7
|
module Woods
|
|
7
8
|
module Feedback
|
|
@@ -68,7 +69,12 @@ module Woods
|
|
|
68
69
|
return [] unless File.exist?(@path)
|
|
69
70
|
|
|
70
71
|
entries = []
|
|
71
|
-
|
|
72
|
+
# Explicit UTF-8: `record_*` appends raw UTF-8 bytes, so a bare read
|
|
73
|
+
# would tag every line with the process default external encoding and
|
|
74
|
+
# the first non-ASCII entry would raise under LANG=C — permanently,
|
|
75
|
+
# since the poison line stays in the file (INF-3). Same contract as
|
|
76
|
+
# AtomicFile.read.
|
|
77
|
+
File.foreach(@path, encoding: Encoding::UTF_8) do |line|
|
|
72
78
|
entry = JSON.parse(line.strip)
|
|
73
79
|
entries << entry
|
|
74
80
|
break if limit && entries.size >= limit
|
data/lib/woods/filename_utils.rb
CHANGED
|
@@ -30,12 +30,40 @@ module Woods
|
|
|
30
30
|
# (the writer previously left the action raw while the reader allow-listed
|
|
31
31
|
# it, so such flows were written under one name and never found).
|
|
32
32
|
#
|
|
33
|
+
# A SHA256 digest (the same disambiguation {#collision_safe_filename}
|
|
34
|
+
# already uses) is appended whenever either segment's transform was
|
|
35
|
+
# lossy — e.g. `bar.baz` and `bar_baz` both {.safe_segment} to `bar_baz`,
|
|
36
|
+
# so writing them under one name means the second write clobbers the
|
|
37
|
+
# first and `flow_index.json` points both entries at that one file. The
|
|
38
|
+
# namespace transform keeps its legacy spelling. Literal underscores in
|
|
39
|
+
# either input are digested, preventing both namespace and separator
|
|
40
|
+
# collisions, including an action that resembles another file's digest.
|
|
41
|
+
#
|
|
33
42
|
# @param controller_id [String] e.g. "Admin::UsersController"
|
|
34
43
|
# @param action [String] e.g. "index"
|
|
35
44
|
# @return [String] e.g. "Admin__UsersController_index.json"
|
|
36
45
|
def self.flow_filename(controller_id, action)
|
|
37
|
-
"#{safe_segment(controller_id)}_#{safe_segment(action)}
|
|
46
|
+
base = "#{safe_segment(controller_id)}_#{safe_segment(action)}"
|
|
47
|
+
ambiguous = controller_id.to_s.include?('_') || action.to_s.include?('_')
|
|
48
|
+
return "#{base}.json" unless ambiguous || lossy_segment?(controller_id) || lossy_segment?(action)
|
|
49
|
+
|
|
50
|
+
digest = Digest::SHA256.hexdigest("#{controller_id}##{action}")[0, 8]
|
|
51
|
+
"#{base}_#{digest}.json"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Whether {.safe_segment} discarded information for +value+ beyond the
|
|
55
|
+
# `::` → `__` namespace transform — i.e. whether some OTHER character
|
|
56
|
+
# got folded to `_`. That residual loss is what makes two different
|
|
57
|
+
# inputs land on the same segment (`bar.baz` and `bar_baz` both become
|
|
58
|
+
# `bar_baz`). Literal underscores are handled separately by flow_filename.
|
|
59
|
+
#
|
|
60
|
+
# @param value [String]
|
|
61
|
+
# @return [Boolean]
|
|
62
|
+
def self.lossy_segment?(value)
|
|
63
|
+
namespaced = value.to_s.gsub('::', '__')
|
|
64
|
+
namespaced.gsub(/[^a-zA-Z0-9_-]/, '_') != namespaced
|
|
38
65
|
end
|
|
66
|
+
private_class_method :lossy_segment?
|
|
39
67
|
|
|
40
68
|
# Convert an identifier to a safe filename (legacy format).
|
|
41
69
|
#
|
|
@@ -139,16 +139,10 @@ module Woods
|
|
|
139
139
|
|
|
140
140
|
return if then_ops.empty? && else_ops.empty?
|
|
141
141
|
|
|
142
|
-
condition_text = if children[0].is_a?(Ast::Node)
|
|
143
|
-
children[0].to_source
|
|
144
|
-
elsif children[0].is_a?(String)
|
|
145
|
-
children[0]
|
|
146
|
-
end
|
|
147
|
-
|
|
148
142
|
operations << {
|
|
149
143
|
type: :conditional,
|
|
150
144
|
kind: 'if',
|
|
151
|
-
condition:
|
|
145
|
+
condition: condition_source(children[0]) || node.source,
|
|
152
146
|
line: node.line,
|
|
153
147
|
then_ops: then_ops,
|
|
154
148
|
else_ops: else_ops
|
|
@@ -156,23 +150,41 @@ module Woods
|
|
|
156
150
|
end
|
|
157
151
|
|
|
158
152
|
# Handle :case nodes as a conditional variant.
|
|
153
|
+
#
|
|
154
|
+
# A case node is `(case predicate when… else)`, so children[0] is the
|
|
155
|
+
# predicate and the branches follow it. Walking *all* children counted
|
|
156
|
+
# any call in the predicate — `case user.role` — as an operation
|
|
157
|
+
# performed inside a branch, and reporting `node.source` as the condition
|
|
158
|
+
# meant the whole multi-line statement stood in for the one expression
|
|
159
|
+
# being switched on. Mirrors {#handle_conditional}.
|
|
159
160
|
def handle_case(node, operations)
|
|
160
|
-
|
|
161
|
+
children = node.children || []
|
|
161
162
|
branch_ops = []
|
|
162
|
-
|
|
163
|
+
children.drop(1).each { |child| walk(child, branch_ops) if child.is_a?(Ast::Node) }
|
|
163
164
|
|
|
164
165
|
return if branch_ops.empty?
|
|
165
166
|
|
|
166
167
|
operations << {
|
|
167
168
|
type: :conditional,
|
|
168
169
|
kind: 'case',
|
|
169
|
-
condition: node.source,
|
|
170
|
+
condition: condition_source(children[0]) || node.source,
|
|
170
171
|
line: node.line,
|
|
171
172
|
then_ops: branch_ops,
|
|
172
173
|
else_ops: []
|
|
173
174
|
}
|
|
174
175
|
end
|
|
175
176
|
|
|
177
|
+
# Source text for a conditional's predicate child, which the parser may
|
|
178
|
+
# hand back as a node or as an already-rendered string.
|
|
179
|
+
#
|
|
180
|
+
# @param child [Ast::Node, String, nil]
|
|
181
|
+
# @return [String, nil]
|
|
182
|
+
def condition_source(child)
|
|
183
|
+
return child.to_source if child.is_a?(Ast::Node)
|
|
184
|
+
|
|
185
|
+
child if child.is_a?(String)
|
|
186
|
+
end
|
|
187
|
+
|
|
176
188
|
# Detect transaction/with_lock calls.
|
|
177
189
|
def transaction_call?(node)
|
|
178
190
|
TRANSACTION_METHODS.include?(node.method_name)
|
data/lib/woods/flow_assembler.rb
CHANGED
|
@@ -33,6 +33,7 @@ module Woods
|
|
|
33
33
|
@parser = Ast::Parser.new
|
|
34
34
|
@method_extractor = Ast::MethodExtractor.new(parser: @parser)
|
|
35
35
|
@operation_extractor = FlowAnalysis::OperationExtractor.new
|
|
36
|
+
@resolved_targets = {}
|
|
36
37
|
end
|
|
37
38
|
|
|
38
39
|
# Assemble an execution flow from the given entry point.
|
|
@@ -72,11 +73,17 @@ module Woods
|
|
|
72
73
|
# @param depth [Integer] Current recursion depth
|
|
73
74
|
# @param max_depth [Integer] Maximum recursion depth
|
|
74
75
|
# @param path [Set<String>] Identifiers on the current recursion path
|
|
75
|
-
|
|
76
|
+
# @param method_name [String, nil] The method the caller actually invoked
|
|
77
|
+
# (from the triggering operation's `op[:method]`), for callers that
|
|
78
|
+
# resolve to a bare unit id and so cannot encode it in +identifier+
|
|
79
|
+
# itself. `identifier`'s own `#method` suffix (entry points) wins when
|
|
80
|
+
# both are present.
|
|
81
|
+
def expand(identifier, steps, visited, depth:, max_depth:, path: Set.new, method_name: nil)
|
|
76
82
|
return if depth > max_depth
|
|
77
83
|
|
|
78
84
|
# Parse identifier into unit name and optional method
|
|
79
|
-
unit_id,
|
|
85
|
+
unit_id, parsed_method = parse_identifier(identifier)
|
|
86
|
+
method_name = parsed_method || method_name
|
|
80
87
|
|
|
81
88
|
if path.include?(unit_id)
|
|
82
89
|
# Genuine cycle — the unit is an ancestor of itself on this path.
|
|
@@ -129,26 +136,25 @@ module Woods
|
|
|
129
136
|
end
|
|
130
137
|
end
|
|
131
138
|
|
|
132
|
-
# Extract operations from source code for a specific method
|
|
139
|
+
# Extract operations from source code for a specific method — or, when no
|
|
140
|
+
# method is named or the unit does not define one by that name, from the
|
|
141
|
+
# entire source.
|
|
142
|
+
#
|
|
143
|
+
# A named method that isn't found falls back to the whole unit rather
|
|
144
|
+
# than yielding no operations: {#expand_operation} now threads the
|
|
145
|
+
# calling operation's `op[:method]` through, and that method need not be
|
|
146
|
+
# one the callee actually defines locally (inherited, dynamically
|
|
147
|
+
# defined, or metaprogrammed) — losing the trace entirely would be worse
|
|
148
|
+
# than over-including it.
|
|
133
149
|
def extract_operations(source_code, method_name, metadata, unit_type)
|
|
134
150
|
operations = []
|
|
135
151
|
|
|
136
152
|
# For controllers, prepend before_action callbacks
|
|
137
153
|
prepend_callbacks(operations, metadata, method_name) if unit_type == 'controller'
|
|
138
154
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if method_node
|
|
143
|
-
ops = @operation_extractor.extract(method_node)
|
|
144
|
-
operations.concat(ops)
|
|
145
|
-
end
|
|
146
|
-
else
|
|
147
|
-
# No specific method - parse entire source
|
|
148
|
-
root = @parser.parse(source_code)
|
|
149
|
-
ops = @operation_extractor.extract(root)
|
|
150
|
-
operations.concat(ops)
|
|
151
|
-
end
|
|
155
|
+
scope_node = method_name && @method_extractor.extract_method(source_code, method_name)
|
|
156
|
+
scope_node ||= @parser.parse(source_code)
|
|
157
|
+
operations.concat(@operation_extractor.extract(scope_node))
|
|
152
158
|
|
|
153
159
|
operations
|
|
154
160
|
end
|
|
@@ -189,6 +195,15 @@ module Woods
|
|
|
189
195
|
|
|
190
196
|
# Recursively expand an operation's target if it resolves to a known unit.
|
|
191
197
|
#
|
|
198
|
+
# `op[:method]` is threaded into the recursive {#expand} call so the
|
|
199
|
+
# callee is scoped to the method actually invoked, not its whole source.
|
|
200
|
+
# When {#resolve_target} answers with an ambiguity marker (several
|
|
201
|
+
# namespaces share the target's short name) this does not recurse at
|
|
202
|
+
# all — it mutates +op+ in place with `:ambiguous_candidates` so the
|
|
203
|
+
# unresolved call is still visible in the flow output, naming every
|
|
204
|
+
# candidate instead of silently expanding whichever one the graph's
|
|
205
|
+
# suffix index happened to pick.
|
|
206
|
+
#
|
|
192
207
|
# @param op [Hash] The operation to potentially expand
|
|
193
208
|
# @param current_unit [String] The identifier of the unit containing this operation
|
|
194
209
|
# @param steps [Array<Hash>] Accumulator for step hashes
|
|
@@ -201,10 +216,21 @@ module Woods
|
|
|
201
216
|
target = op[:target]
|
|
202
217
|
return unless target
|
|
203
218
|
|
|
204
|
-
|
|
205
|
-
return unless
|
|
219
|
+
resolution = resolve_target(target)
|
|
220
|
+
return unless resolution
|
|
206
221
|
|
|
207
|
-
|
|
222
|
+
if resolution.is_a?(Hash)
|
|
223
|
+
# Several namespaces share this short name (Tier 1 suffix match) —
|
|
224
|
+
# record which ones rather than silently expanding whichever the
|
|
225
|
+
# graph's deterministic-but-arbitrary suffix index picked.
|
|
226
|
+
op[:ambiguous_candidates] = resolution[:candidates]
|
|
227
|
+
return
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
expand(
|
|
231
|
+
resolution, steps, visited,
|
|
232
|
+
depth: depth + 1, max_depth: max_depth, path: path, method_name: op[:method]
|
|
233
|
+
)
|
|
208
234
|
when :transaction
|
|
209
235
|
(op[:nested] || []).each do |nested_op|
|
|
210
236
|
expand_operation(nested_op, current_unit, steps, visited, depth: depth, max_depth: max_depth, path: path)
|
|
@@ -229,12 +255,28 @@ module Woods
|
|
|
229
255
|
#
|
|
230
256
|
# @param target [String] The call target name to resolve
|
|
231
257
|
# @return [String, nil] The resolved unit identifier, or nil if not found
|
|
258
|
+
# Memoized per assembler instance, and negative results are cached too
|
|
259
|
+
# (`nil` is a real answer — most call targets are not units). Resolution
|
|
260
|
+
# used to run a linear suffix scan over every graph node *and*, on a miss,
|
|
261
|
+
# a set of disk globs, once per operation encountered. A flow touching a
|
|
262
|
+
# few hundred operations on a host with tens of thousands of nodes paid
|
|
263
|
+
# that every time, including for the same target repeatedly.
|
|
232
264
|
def resolve_target(target)
|
|
265
|
+
return @resolved_targets[target] if @resolved_targets.key?(target)
|
|
266
|
+
|
|
267
|
+
@resolved_targets[target] = compute_resolved_target(target)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# @return [String, Hash, nil] a resolved unit identifier; a
|
|
271
|
+
# `{ ambiguous: true, candidates: }` marker when several namespaces
|
|
272
|
+
# share +target+'s short name; or nil when nothing resolves
|
|
273
|
+
def compute_resolved_target(target)
|
|
233
274
|
# Tier 1: Graph-wide lookup
|
|
234
275
|
return target if @graph.node_exists?(target)
|
|
235
276
|
|
|
236
|
-
|
|
237
|
-
return
|
|
277
|
+
suffix_matches = @graph.find_all_by_suffix(target)
|
|
278
|
+
return suffix_matches.first if suffix_matches.size == 1
|
|
279
|
+
return { ambiguous: true, candidates: suffix_matches } if suffix_matches.size > 1
|
|
238
280
|
|
|
239
281
|
# Tier 2: Disk fallback (unit JSON exists but isn't in the graph)
|
|
240
282
|
unit_data = load_unit(target)
|