woods 2.0.0.beta1 → 2.0.0.beta3
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 +400 -1
- data/CONTRIBUTING.md +224 -9
- data/README.md +7 -3
- data/SECURITY.md +9 -6
- data/docs/AGENT_GUIDE.md +83 -4
- data/docs/AGENT_SETUP.md +82 -1
- data/docs/BACKEND_MATRIX.md +20 -0
- data/docs/CLIENT_HOOKS.md +111 -0
- data/docs/CONFIGURATION_REFERENCE.md +233 -13
- data/docs/CONSOLE_MCP_SETUP.md +35 -5
- data/docs/DOCKER_SETUP.md +21 -2
- data/docs/EVALUATION.md +464 -1
- data/docs/EXTRACTOR_REFERENCE.md +36 -5
- data/docs/FAQ.md +11 -12
- data/docs/GETTING_STARTED.md +17 -5
- data/docs/INCREMENTAL_EXTRACTION.md +158 -2
- data/docs/INDEX_LAYOUT.md +382 -0
- data/docs/INTERNALS.md +15 -7
- data/docs/MCP_SERVERS.md +221 -5
- data/docs/MCP_TOOL_COOKBOOK.md +33 -18
- data/docs/NOTION_INTEGRATION.md +13 -0
- data/docs/OBSIDIAN_INTEGRATION.md +57 -9
- data/docs/PUBLISHED_INDEX.md +71 -0
- data/docs/README.md +7 -0
- data/docs/RETRIEVAL_GUIDE.md +253 -11
- data/docs/RUNTIME_TRACING.md +71 -0
- data/docs/SOURCE_FRESHNESS.md +143 -0
- data/docs/TROUBLESHOOTING.md +117 -5
- data/docs/UNBLOCKED_INTEGRATION.md +25 -0
- data/docs/UPGRADING_TO_2.md +44 -22
- data/docs/WATCH_DAEMON.md +259 -59
- data/exe/woods-agent-config +6 -0
- data/exe/woods-extract +5 -0
- data/exe/woods-hook-context +6 -0
- data/lib/generators/woods/templates/woods.rb.tt +1 -3
- data/lib/tasks/woods.rake +47 -397
- data/lib/woods/agent_configuration/applier.rb +133 -0
- data/lib/woods/agent_configuration/cli.rb +101 -0
- data/lib/woods/agent_configuration/cli_options.rb +29 -0
- data/lib/woods/agent_configuration/document.rb +105 -0
- data/lib/woods/agent_configuration/error.rb +7 -0
- data/lib/woods/agent_configuration/launcher.rb +75 -0
- data/lib/woods/agent_configuration/layout.rb +59 -0
- data/lib/woods/agent_configuration/managed_section.rb +62 -0
- data/lib/woods/agent_configuration/plan.rb +98 -0
- data/lib/woods/agent_configuration/plan_diff.rb +38 -0
- data/lib/woods/agent_configuration/planned_files.rb +61 -0
- data/lib/woods/agent_configuration/planner.rb +63 -0
- data/lib/woods/agent_configuration/planner_validation.rb +77 -0
- data/lib/woods/agent_configuration/preflight.rb +100 -0
- data/lib/woods/agent_configuration/recovery.rb +49 -0
- data/lib/woods/ast/node.rb +2 -0
- data/lib/woods/ast/parser.rb +38 -5
- data/lib/woods/atomic_file.rb +133 -3
- data/lib/woods/builder.rb +21 -5
- data/lib/woods/cache/cache_middleware.rb +28 -7
- data/lib/woods/cache/cache_store.rb +4 -5
- data/lib/woods/change_set.rb +5 -4
- data/lib/woods/console/credential_index.rb +20 -2
- data/lib/woods/console/credential_scanner.rb +14 -14
- data/lib/woods/console/credential_scanner_registry.rb +36 -0
- data/lib/woods/console/embedded_executor.rb +1 -1
- data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
- data/lib/woods/console/rack_middleware.rb +22 -13
- data/lib/woods/console/server.rb +18 -16
- data/lib/woods/dependency_graph.rb +65 -13
- data/lib/woods/embedding/corpus.rb +94 -0
- data/lib/woods/embedding/indexer.rb +90 -46
- data/lib/woods/embedding/openai.rb +17 -6
- data/lib/woods/evaluation/ablation_executor.rb +6 -1
- data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
- data/lib/woods/export/typed_reader.rb +56 -0
- data/lib/woods/extractor.rb +557 -228
- data/lib/woods/extractors/action_cable_extractor.rb +3 -1
- data/lib/woods/extractors/behavioral_profile.rb +9 -7
- data/lib/woods/extractors/caching_extractor.rb +3 -1
- data/lib/woods/extractors/concern_extractor.rb +64 -6
- data/lib/woods/extractors/configuration_extractor.rb +7 -3
- data/lib/woods/extractors/controller_extractor.rb +13 -4
- data/lib/woods/extractors/database_view_extractor.rb +3 -1
- data/lib/woods/extractors/decorator_extractor.rb +3 -1
- data/lib/woods/extractors/engine_extractor.rb +3 -1
- data/lib/woods/extractors/event_extractor.rb +4 -2
- data/lib/woods/extractors/factory_extractor.rb +3 -1
- data/lib/woods/extractors/graphql_extractor.rb +8 -2
- data/lib/woods/extractors/i18n_extractor.rb +3 -1
- data/lib/woods/extractors/job_extractor.rb +6 -19
- data/lib/woods/extractors/lib_extractor.rb +3 -1
- data/lib/woods/extractors/mailer_extractor.rb +20 -5
- data/lib/woods/extractors/manager_extractor.rb +3 -1
- data/lib/woods/extractors/method_parameters.rb +53 -0
- data/lib/woods/extractors/middleware_argument.rb +65 -0
- data/lib/woods/extractors/middleware_extractor.rb +9 -3
- data/lib/woods/extractors/migration_extractor.rb +3 -1
- data/lib/woods/extractors/model_extractor.rb +39 -33
- data/lib/woods/extractors/package_extractor.rb +24 -4
- data/lib/woods/extractors/phlex_extractor.rb +3 -1
- data/lib/woods/extractors/policy_extractor.rb +3 -1
- data/lib/woods/extractors/poro_extractor.rb +3 -1
- data/lib/woods/extractors/pundit_extractor.rb +3 -1
- data/lib/woods/extractors/rails_source_extractor.rb +4 -2
- data/lib/woods/extractors/rake_task_extractor.rb +4 -2
- data/lib/woods/extractors/route_extractor.rb +3 -1
- data/lib/woods/extractors/route_helper_resolver.rb +10 -33
- data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
- data/lib/woods/extractors/serializer_extractor.rb +4 -2
- data/lib/woods/extractors/service_extractor.rb +3 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
- data/lib/woods/extractors/shared_utility_methods.rb +27 -15
- data/lib/woods/extractors/source_nesting.rb +1 -1
- data/lib/woods/extractors/state_machine_extractor.rb +3 -1
- data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
- data/lib/woods/extractors/validator_extractor.rb +3 -1
- data/lib/woods/extractors/view_component_extractor.rb +3 -1
- data/lib/woods/extractors/view_template_extractor.rb +3 -1
- data/lib/woods/flow_assembler.rb +87 -8
- data/lib/woods/flow_precomputer.rb +44 -7
- data/lib/woods/gem_mapper.rb +2 -0
- data/lib/woods/git_history.rb +116 -0
- data/lib/woods/graph_analyzer.rb +195 -63
- data/lib/woods/hooks/context_cli.rb +54 -0
- data/lib/woods/hooks/context_event.rb +88 -0
- data/lib/woods/hooks/context_hint.rb +73 -0
- data/lib/woods/hooks/context_impact.rb +77 -0
- data/lib/woods/hooks/context_output.rb +47 -0
- data/lib/woods/hooks/context_state.rb +102 -0
- data/lib/woods/hooks/refresh.rb +79 -0
- data/lib/woods/hooks/rule_projection.rb +78 -0
- data/lib/woods/input_rules.rb +19 -0
- data/lib/woods/mcp/bearer_auth.rb +20 -12
- data/lib/woods/mcp/bootstrapper.rb +62 -0
- data/lib/woods/mcp/index_reader.rb +323 -160
- data/lib/woods/mcp/initialization_guidance.rb +27 -0
- data/lib/woods/mcp/origin_guard.rb +17 -9
- data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
- data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
- data/lib/woods/mcp/search_results.rb +74 -0
- data/lib/woods/mcp/server.rb +158 -37
- data/lib/woods/mcp/tool_contract.rb +2 -0
- data/lib/woods/mcp/tool_response_renderer.rb +25 -0
- data/lib/woods/mcp/traversal_evidence.rb +113 -0
- data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
- data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
- data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
- data/lib/woods/notion/exporter.rb +56 -17
- data/lib/woods/obsidian/destination_plan.rb +98 -0
- data/lib/woods/obsidian/name_mapper.rb +19 -3
- data/lib/woods/obsidian/note_builder.rb +19 -10
- data/lib/woods/obsidian/vault_exporter.rb +88 -32
- data/lib/woods/operator/pipeline_guard.rb +18 -13
- data/lib/woods/path_dispatcher.rb +7 -1
- data/lib/woods/payload_store.rb +29 -15
- data/lib/woods/railtie.rb +3 -3
- data/lib/woods/railtie_support.rb +12 -12
- data/lib/woods/rake_helpers.rb +392 -0
- data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
- data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
- data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
- data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
- data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
- data/lib/woods/resilience/index_validator.rb +112 -23
- data/lib/woods/retrieval/context_assembler.rb +50 -15
- data/lib/woods/retrieval/lexical_assembler.rb +73 -0
- data/lib/woods/retrieval/lexical_index.rb +119 -0
- data/lib/woods/retrieval/ranker.rb +4 -2
- data/lib/woods/retrieval/scope.rb +108 -0
- data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
- data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
- data/lib/woods/retrieval/search_executor.rb +86 -27
- data/lib/woods/retrieval/source_evidence.rb +200 -0
- data/lib/woods/retriever.rb +98 -22
- data/lib/woods/ruby_analyzer/trace_enricher.rb +80 -38
- data/lib/woods/session_tracer/middleware.rb +10 -12
- data/lib/woods/session_tracer/redis_store.rb +22 -6
- data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
- data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
- data/lib/woods/session_tracer/unit_resolver.rb +63 -0
- data/lib/woods/source_inputs/consumer_errors.rb +27 -0
- data/lib/woods/source_inputs/handoff.rb +102 -0
- data/lib/woods/source_inputs/launcher.rb +157 -0
- data/lib/woods/source_inputs/manifest.rb +124 -0
- data/lib/woods/source_inputs/private_key.rb +55 -0
- data/lib/woods/source_inputs/scanner.rb +171 -0
- data/lib/woods/source_inputs/scopes.rb +71 -0
- data/lib/woods/source_inputs/session.rb +214 -0
- data/lib/woods/source_inputs/status.rb +84 -0
- data/lib/woods/source_inputs/verifier.rb +107 -0
- data/lib/woods/storage/metadata_store.rb +25 -25
- data/lib/woods/storage/pgvector.rb +29 -8
- data/lib/woods/storage/qdrant.rb +17 -7
- data/lib/woods/storage/vector_store.rb +18 -6
- data/lib/woods/tasks.rb +3 -2
- data/lib/woods/temporal/json_snapshot_store.rb +29 -8
- data/lib/woods/unblocked/exporter.rb +59 -70
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/boot_snapshot.rb +52 -0
- data/lib/woods/watch/daemon.rb +136 -28
- data/lib/woods/watch/listen_watcher.rb +4 -0
- data/lib/woods/watch/polling_watcher.rb +5 -1
- data/lib/woods/watch/status.rb +20 -15
- data/lib/woods/watch/tree_scan.rb +21 -13
- data/lib/woods/watch/watcher.rb +4 -1
- data/lib/woods.rb +135 -11
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.jq +15 -0
- data/plugin/hooks/adapters/normalize.rb +63 -0
- data/plugin/hooks/hooks.json +20 -0
- data/plugin/hooks/woods-context.sh +50 -0
- data/plugin/hooks/woods-input-rules.sh +159 -0
- data/plugin/hooks/woods-opencode.mjs +65 -0
- data/plugin/hooks/woods-post-edit.sh +2 -225
- data/plugin/hooks/woods-refresh.sh +260 -0
- data/plugin/hooks/woods-session-start.sh +47 -55
- data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
- data/plugin/skills/woods-diagnose/SKILL.md +288 -1
- data/plugin/skills/woods-investigate/SKILL.md +106 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
- data/plugin/skills/woods-setup/SKILL.md +107 -6
- metadata +84 -5
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../source_inputs/consumer_errors'
|
|
4
|
+
|
|
3
5
|
require_relative 'shared_utility_methods'
|
|
4
6
|
require_relative 'shared_dependency_scanner'
|
|
5
7
|
require_relative 'source_nesting'
|
|
@@ -83,7 +85,7 @@ module Woods
|
|
|
83
85
|
|
|
84
86
|
unit
|
|
85
87
|
rescue StandardError => e
|
|
86
|
-
|
|
88
|
+
SourceInputs::ConsumerErrors.log(self, "Failed to extract service #{file_path}: #{e.message}")
|
|
87
89
|
nil
|
|
88
90
|
end
|
|
89
91
|
|
|
@@ -204,8 +204,8 @@ module Woods
|
|
|
204
204
|
# Match _path/_url route helpers anywhere in source.
|
|
205
205
|
# This intentionally matches all usages (assignments, string interpolation, etc.)
|
|
206
206
|
# not just link_to/redirect_to calls — any reference to a route helper indicates
|
|
207
|
-
# a dependency on that controller.
|
|
208
|
-
#
|
|
207
|
+
# a dependency on that controller. RouteHelperResolver accepts only names
|
|
208
|
+
# present in the live controller/action route map, excluding unknown helpers.
|
|
209
209
|
# Requires the including class to also include RouteHelperResolver
|
|
210
210
|
# and call build_route_helper_map in its initializer.
|
|
211
211
|
ROUTE_HELPER_PATTERN = /\b(\w+)_(path|url)\b/
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'source_nesting'
|
|
4
|
+
require_relative 'method_parameters'
|
|
4
5
|
|
|
5
6
|
module Woods
|
|
6
7
|
module Extractors
|
|
@@ -221,6 +222,29 @@ module Woods
|
|
|
221
222
|
actions.to_a
|
|
222
223
|
end
|
|
223
224
|
|
|
225
|
+
# Rails 6 exposes a numeric Proc identity through #filter, and the
|
|
226
|
+
# callable through #raw_filter. Newer Rails exposes the callable directly.
|
|
227
|
+
def callback_filter(callback)
|
|
228
|
+
stable_filter(callback.respond_to?(:raw_filter) ? callback.raw_filter : callback.filter)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Proc#to_s embeds a process-local address. Model and controller filters
|
|
232
|
+
# share source-site labels, so metadata and derived chunks agree across
|
|
233
|
+
# boots. Preserve symbols and other application-defined callback values.
|
|
234
|
+
def stable_filter(filter)
|
|
235
|
+
return filter unless filter.is_a?(Proc)
|
|
236
|
+
|
|
237
|
+
location = filter.source_location
|
|
238
|
+
site = if location
|
|
239
|
+
path, line = location
|
|
240
|
+
"#{path.delete_prefix("#{Rails.root}/")}:#{line}"
|
|
241
|
+
else
|
|
242
|
+
'native'
|
|
243
|
+
end
|
|
244
|
+
"#<#{filter.lambda? ? 'lambda' : 'Proc'} #{site}>"
|
|
245
|
+
end
|
|
246
|
+
private :callback_filter, :stable_filter
|
|
247
|
+
|
|
224
248
|
# Human-readable label for a non-ActionFilter condition.
|
|
225
249
|
#
|
|
226
250
|
# @param condition [Object] A proc, symbol, or other condition
|
|
@@ -292,7 +316,7 @@ module Woods
|
|
|
292
316
|
source.scan(/def\s+self\.(\w+[?!=]?)/).flatten
|
|
293
317
|
end
|
|
294
318
|
|
|
295
|
-
# Extract initialize parameters from source
|
|
319
|
+
# Extract initialize parameters from source syntax without evaluating defaults.
|
|
296
320
|
#
|
|
297
321
|
# Parses the parameter list of the initialize method to determine
|
|
298
322
|
# parameter names, defaults, and whether they are keyword arguments.
|
|
@@ -304,21 +328,9 @@ module Woods
|
|
|
304
328
|
# @param source [String] Ruby source code
|
|
305
329
|
# @return [Array<Hash>] Parameter info hashes with :name, :has_default, :keyword
|
|
306
330
|
def extract_initialize_params(source)
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
params_str = init_match[1]
|
|
311
|
-
params = []
|
|
312
|
-
|
|
313
|
-
params_str.scan(/(\w+)(?::\s*([^,\n]+))?/) do |name, default|
|
|
314
|
-
params << {
|
|
315
|
-
name: name,
|
|
316
|
-
has_default: !default.nil?,
|
|
317
|
-
keyword: params_str.include?("#{name}:")
|
|
318
|
-
}
|
|
331
|
+
MethodParameters.extract(source, :initialize).map do |parameter|
|
|
332
|
+
parameter.slice(:name, :has_default, :keyword)
|
|
319
333
|
end
|
|
320
|
-
|
|
321
|
-
params
|
|
322
334
|
end
|
|
323
335
|
end
|
|
324
336
|
end
|
|
@@ -245,7 +245,7 @@ module Woods
|
|
|
245
245
|
# The constant path the active main loader expects for +file_path+,
|
|
246
246
|
# asked through the loader's own API so its inflector, namespace
|
|
247
247
|
# collapse, and ignore rules decide (a custom `api => API` inflection
|
|
248
|
-
# must win over the local camelizer). Available on Zeitwerk 2.6.
|
|
248
|
+
# must win over the local camelizer). Available on Zeitwerk 2.6.9+;
|
|
249
249
|
# nil — unmanaged — when the loader's API is older, declines the
|
|
250
250
|
# file, or cannot derive a name. A decline is authoritative: the
|
|
251
251
|
# local camelizer must not rescue it.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../source_inputs/consumer_errors'
|
|
4
|
+
|
|
3
5
|
require_relative 'shared_utility_methods'
|
|
4
6
|
require_relative 'shared_dependency_scanner'
|
|
5
7
|
require_relative 'source_nesting'
|
|
@@ -58,7 +60,7 @@ module Woods
|
|
|
58
60
|
units.concat(extract_state_machines_units(source, class_name, file_path))
|
|
59
61
|
units
|
|
60
62
|
rescue StandardError => e
|
|
61
|
-
|
|
63
|
+
SourceInputs::ConsumerErrors.log(self, "Failed to extract state machines from #{file_path}: #{e.message}")
|
|
62
64
|
[]
|
|
63
65
|
end
|
|
64
66
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../source_inputs/consumer_errors'
|
|
4
|
+
|
|
3
5
|
require_relative 'shared_utility_methods'
|
|
4
6
|
require_relative 'shared_dependency_scanner'
|
|
5
7
|
|
|
@@ -70,7 +72,7 @@ module Woods
|
|
|
70
72
|
|
|
71
73
|
unit
|
|
72
74
|
rescue StandardError => e
|
|
73
|
-
|
|
75
|
+
SourceInputs::ConsumerErrors.log(self, "Failed to extract test mapping from #{file_path}: #{e.message}")
|
|
74
76
|
nil
|
|
75
77
|
end
|
|
76
78
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../source_inputs/consumer_errors'
|
|
4
|
+
|
|
3
5
|
require_relative 'shared_utility_methods'
|
|
4
6
|
require_relative 'shared_dependency_scanner'
|
|
5
7
|
|
|
@@ -70,7 +72,7 @@ module Woods
|
|
|
70
72
|
|
|
71
73
|
unit
|
|
72
74
|
rescue StandardError => e
|
|
73
|
-
|
|
75
|
+
SourceInputs::ConsumerErrors.log(self, "Failed to extract validator #{file_path}: #{e.message}")
|
|
74
76
|
nil
|
|
75
77
|
end
|
|
76
78
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../source_inputs/consumer_errors'
|
|
4
|
+
|
|
3
5
|
require_relative 'component_discovery'
|
|
4
6
|
require_relative 'shared_utility_methods'
|
|
5
7
|
require_relative 'shared_dependency_scanner'
|
|
@@ -95,7 +97,7 @@ module Woods
|
|
|
95
97
|
|
|
96
98
|
unit
|
|
97
99
|
rescue StandardError => e
|
|
98
|
-
|
|
100
|
+
SourceInputs::ConsumerErrors.log(self, "Failed to extract view component #{component.name}: #{e.message}")
|
|
99
101
|
nil
|
|
100
102
|
end
|
|
101
103
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../source_inputs/consumer_errors'
|
|
4
|
+
|
|
3
5
|
require 'set'
|
|
4
6
|
require_relative 'route_helper_resolver'
|
|
5
7
|
require_relative 'view_engines/base'
|
|
@@ -95,7 +97,7 @@ module Woods
|
|
|
95
97
|
|
|
96
98
|
unit
|
|
97
99
|
rescue StandardError => e
|
|
98
|
-
|
|
100
|
+
SourceInputs::ConsumerErrors.log(self, "Failed to extract view template #{file_path}: #{e.message}")
|
|
99
101
|
nil
|
|
100
102
|
end
|
|
101
103
|
|
data/lib/woods/flow_assembler.rb
CHANGED
|
@@ -4,7 +4,6 @@ require 'digest'
|
|
|
4
4
|
require 'json'
|
|
5
5
|
require 'set'
|
|
6
6
|
require_relative 'ast/parser'
|
|
7
|
-
require_relative 'ast/method_extractor'
|
|
8
7
|
require_relative 'flow_analysis/operation_extractor'
|
|
9
8
|
require_relative 'flow_document'
|
|
10
9
|
|
|
@@ -25,15 +24,25 @@ module Woods
|
|
|
25
24
|
# puts flow.to_markdown
|
|
26
25
|
#
|
|
27
26
|
class FlowAssembler
|
|
27
|
+
# How many entries each per-instance memo holds before the oldest is
|
|
28
|
+
# dropped. One assembler serves a whole precompute run (every controller,
|
|
29
|
+
# every action), so an unbounded memo would hold the source and the parsed
|
|
30
|
+
# AST of every unit the run ever reached. A thousand covers the shared
|
|
31
|
+
# services a large app's flows keep landing on without pinning the whole
|
|
32
|
+
# index in memory.
|
|
33
|
+
MEMO_LIMIT = 1_000
|
|
34
|
+
|
|
28
35
|
# @param graph [DependencyGraph] The dependency graph for resolving targets
|
|
29
36
|
# @param extracted_dir [String] Directory containing extracted unit JSON files
|
|
30
37
|
def initialize(graph:, extracted_dir:)
|
|
31
38
|
@graph = graph
|
|
32
39
|
@extracted_dir = extracted_dir
|
|
33
40
|
@parser = Ast::Parser.new
|
|
34
|
-
@method_extractor = Ast::MethodExtractor.new(parser: @parser)
|
|
35
41
|
@operation_extractor = FlowAnalysis::OperationExtractor.new
|
|
36
42
|
@resolved_targets = {}
|
|
43
|
+
@unit_cache = {}
|
|
44
|
+
@ast_cache = {}
|
|
45
|
+
@method_node_cache = {}
|
|
37
46
|
end
|
|
38
47
|
|
|
39
48
|
# Assemble an execution flow from the given entry point.
|
|
@@ -114,7 +123,7 @@ module Woods
|
|
|
114
123
|
file_path = unit_data[:file_path]
|
|
115
124
|
|
|
116
125
|
# Extract operations from the relevant method
|
|
117
|
-
operations = extract_operations(source_code, method_name, metadata, unit_type)
|
|
126
|
+
operations = extract_operations(unit_id, source_code, method_name, metadata, unit_type)
|
|
118
127
|
|
|
119
128
|
step = {
|
|
120
129
|
unit: identifier,
|
|
@@ -146,19 +155,75 @@ module Woods
|
|
|
146
155
|
# one the callee actually defines locally (inherited, dynamically
|
|
147
156
|
# defined, or metaprogrammed) — losing the trace entirely would be worse
|
|
148
157
|
# than over-including it.
|
|
149
|
-
def extract_operations(source_code, method_name, metadata, unit_type)
|
|
158
|
+
def extract_operations(unit_id, source_code, method_name, metadata, unit_type)
|
|
150
159
|
operations = []
|
|
151
160
|
|
|
152
161
|
# For controllers, prepend before_action callbacks
|
|
153
162
|
prepend_callbacks(operations, metadata, method_name) if unit_type == 'controller'
|
|
154
163
|
|
|
155
|
-
scope_node =
|
|
156
|
-
scope_node ||=
|
|
164
|
+
scope_node = method_node(unit_id, source_code, method_name)
|
|
165
|
+
scope_node ||= parsed_source(unit_id, source_code)
|
|
157
166
|
operations.concat(@operation_extractor.extract(scope_node))
|
|
158
167
|
|
|
159
168
|
operations
|
|
160
169
|
end
|
|
161
170
|
|
|
171
|
+
# The unit's whole parsed source, parsed once per assembler instance.
|
|
172
|
+
#
|
|
173
|
+
# A unit reached from several controllers used to be re-parsed once per
|
|
174
|
+
# action of every controller that reached it, and Prism dominates the
|
|
175
|
+
# flow run's cost on a large app.
|
|
176
|
+
#
|
|
177
|
+
# @param unit_id [String] cache key; a unit's source is fixed for a run
|
|
178
|
+
# @param source_code [String] the unit's source
|
|
179
|
+
# @return [Ast::Node] root node
|
|
180
|
+
def parsed_source(unit_id, source_code)
|
|
181
|
+
memoize(@ast_cache, unit_id) { @parser.parse(source_code) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The `def` node for +method_name+, from a per-unit index built off the
|
|
185
|
+
# memoized parse.
|
|
186
|
+
#
|
|
187
|
+
# First definition wins for a name defined more than once, matching
|
|
188
|
+
# {Ast::MethodExtractor#extract_method}'s first-match lookup.
|
|
189
|
+
#
|
|
190
|
+
# @param unit_id [String] cache key
|
|
191
|
+
# @param source_code [String] the unit's source
|
|
192
|
+
# @param method_name [String, nil] the method the caller invoked
|
|
193
|
+
# @return [Ast::Node, nil] nil when no method was named, or the unit does
|
|
194
|
+
# not define one by that name
|
|
195
|
+
def method_node(unit_id, source_code, method_name)
|
|
196
|
+
return nil unless method_name
|
|
197
|
+
|
|
198
|
+
nodes = memoize(@method_node_cache, unit_id) do
|
|
199
|
+
parsed_source(unit_id, source_code).find_all(:def).each_with_object({}) do |node, index|
|
|
200
|
+
index[node.method_name] ||= node
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
nodes[method_name.to_s]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Read +key+ from +cache+, computing and storing it on a miss.
|
|
208
|
+
#
|
|
209
|
+
# Least-recently-used, using the fact that a Ruby Hash iterates in
|
|
210
|
+
# insertion order: a hit re-inserts the key at the young end, and
|
|
211
|
+
# +Hash#shift+ drops the old end once the cache is over {MEMO_LIMIT}.
|
|
212
|
+
# A nil value is a real answer (most call targets are not units) and is
|
|
213
|
+
# cached like any other.
|
|
214
|
+
#
|
|
215
|
+
# @param cache [Hash]
|
|
216
|
+
# @param key [Object]
|
|
217
|
+
# @return [Object] the cached or freshly computed value
|
|
218
|
+
def memoize(cache, key)
|
|
219
|
+
return cache[key] = cache.delete(key) if cache.key?(key)
|
|
220
|
+
|
|
221
|
+
value = yield
|
|
222
|
+
cache[key] = value
|
|
223
|
+
cache.shift while cache.size > MEMO_LIMIT
|
|
224
|
+
value
|
|
225
|
+
end
|
|
226
|
+
|
|
162
227
|
# Prepend before_action callbacks from controller metadata.
|
|
163
228
|
#
|
|
164
229
|
# Handles two metadata formats:
|
|
@@ -296,13 +361,27 @@ module Woods
|
|
|
296
361
|
end
|
|
297
362
|
end
|
|
298
363
|
|
|
299
|
-
# Load an ExtractedUnit's data from its JSON file on disk
|
|
364
|
+
# Load an ExtractedUnit's data from its JSON file on disk, memoized per
|
|
365
|
+
# assembler instance.
|
|
366
|
+
#
|
|
367
|
+
# The glob and the JSON parse used to run once per expansion, so a unit
|
|
368
|
+
# reached from many controllers paid for both once per action of every one
|
|
369
|
+
# of them. The memo also makes {#extract_route} free: the entry unit it
|
|
370
|
+
# asks for is the one {#expand} just loaded.
|
|
371
|
+
#
|
|
372
|
+
# @param unit_id [String] Unit identifier
|
|
373
|
+
# @return [Hash, nil] symbolized unit data, or nil when no file matched
|
|
374
|
+
def load_unit(unit_id)
|
|
375
|
+
memoize(@unit_cache, unit_id) { read_unit(unit_id) }
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# The disk half of {#load_unit}.
|
|
300
379
|
#
|
|
301
380
|
# Uses {Extractor#collision_safe_filename} convention (with SHA256 digest suffix).
|
|
302
381
|
# Falls back to legacy {Extractor#safe_filename} for older indexes.
|
|
303
382
|
# Searches across type subdirectories since the extractor writes to
|
|
304
383
|
# `<output_dir>/<type>/<filename>.json`.
|
|
305
|
-
def
|
|
384
|
+
def read_unit(unit_id)
|
|
306
385
|
base = unit_id.gsub('::', '__').gsub(/[^a-zA-Z0-9_-]/, '_')
|
|
307
386
|
digest = Digest::SHA256.hexdigest(unit_id)[0, 8]
|
|
308
387
|
filenames = [
|
|
@@ -89,11 +89,17 @@ module Woods
|
|
|
89
89
|
# controller units, rehydrated from the payload this run seeded
|
|
90
90
|
# @param removed_identifiers [Array<String>] controller identifiers
|
|
91
91
|
# whose unit the run pruned
|
|
92
|
+
# @param carried_identifiers [Array<String>] controllers the run
|
|
93
|
+
# re-extracted whose flows it established cannot have changed. Their
|
|
94
|
+
# index entries stay as they are and their annotation is read back out
|
|
95
|
+
# of those entries, because a re-extracted unit lost the annotation
|
|
96
|
+
# whether or not its flows moved. Skipping the assembly is the whole
|
|
97
|
+
# saving; skipping the annotation would diverge from a full run.
|
|
92
98
|
# @return [Hash{String => Hash{String => String}}] per-controller
|
|
93
99
|
# annotation (action => relative flow path) to write into the units'
|
|
94
100
|
# metadata; an empty hash means "no flows", which clears any
|
|
95
101
|
# annotation a previous run had written
|
|
96
|
-
def recompute_delta(touched_units:, removed_identifiers: [])
|
|
102
|
+
def recompute_delta(touched_units:, removed_identifiers: [], carried_identifiers: [])
|
|
97
103
|
FileUtils.mkdir_p(@flows_dir)
|
|
98
104
|
|
|
99
105
|
assembler = FlowAssembler.new(graph: @graph, extracted_dir: @output_dir)
|
|
@@ -110,8 +116,13 @@ module Woods
|
|
|
110
116
|
delta.merge!(entries)
|
|
111
117
|
end
|
|
112
118
|
|
|
119
|
+
previous = previous_flow_index
|
|
120
|
+
Array(carried_identifiers).each do |identifier|
|
|
121
|
+
annotations[identifier] = carried_annotation(previous, identifier)
|
|
122
|
+
end
|
|
123
|
+
|
|
113
124
|
replaced = touched_units.map(&:identifier) + Array(removed_identifiers)
|
|
114
|
-
carried =
|
|
125
|
+
carried = previous.reject { |entry_point, _path| replaced.include?(controller_of(entry_point)) }
|
|
115
126
|
|
|
116
127
|
write_flow_index(carried.merge(delta))
|
|
117
128
|
|
|
@@ -156,6 +167,18 @@ module Woods
|
|
|
156
167
|
[entries, unit_flow_paths]
|
|
157
168
|
end
|
|
158
169
|
|
|
170
|
+
# One controller's annotation, rebuilt from the index entries it keeps.
|
|
171
|
+
#
|
|
172
|
+
# @param previous [Hash{String => String}] the previous flow index
|
|
173
|
+
# @param identifier [String] controller identifier
|
|
174
|
+
# @return [Hash{String => String}] action to relative flow path
|
|
175
|
+
def carried_annotation(previous, identifier)
|
|
176
|
+
previous.each_with_object({}) do |(entry_point, path), annotation|
|
|
177
|
+
controller, action = entry_point.to_s.split('#', 2)
|
|
178
|
+
annotation[action] = path if action && controller == identifier
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
159
182
|
# The controller part of a flow index entry point.
|
|
160
183
|
#
|
|
161
184
|
# @param entry_point [String]
|
|
@@ -183,9 +206,10 @@ module Woods
|
|
|
183
206
|
|
|
184
207
|
# Assemble a flow for one entry point and write the JSON file.
|
|
185
208
|
#
|
|
186
|
-
# Written via {Woods::AtomicFile} (temp +
|
|
187
|
-
#
|
|
188
|
-
#
|
|
209
|
+
# Written via {Woods::AtomicFile} (temp + rename) like every other index
|
|
210
|
+
# artifact. A plain +File.write+ interrupted mid-write left a torn
|
|
211
|
+
# partial for the MCP read side to trip over. Not durable by default: see
|
|
212
|
+
# {#durable_writes?}.
|
|
189
213
|
#
|
|
190
214
|
# @param assembler [FlowAssembler]
|
|
191
215
|
# @param entry_point [String]
|
|
@@ -199,7 +223,7 @@ module Woods
|
|
|
199
223
|
|
|
200
224
|
filename = Woods::FilenameUtils.flow_filename(controller_id, action)
|
|
201
225
|
|
|
202
|
-
Woods::AtomicFile.write(File.join(@flows_dir, filename), canonical_json(flow.to_h))
|
|
226
|
+
Woods::AtomicFile.write(File.join(@flows_dir, filename), canonical_json(flow.to_h), durable: durable_writes?)
|
|
203
227
|
|
|
204
228
|
# Relative — this value is persisted (flow_index.json and the unit's
|
|
205
229
|
# metadata[:flow_paths]), so it must not carry this machine's root.
|
|
@@ -213,7 +237,20 @@ module Woods
|
|
|
213
237
|
# @param flow_map [Hash{String => String}]
|
|
214
238
|
def write_flow_index(flow_map)
|
|
215
239
|
index_path = File.join(@flows_dir, 'flow_index.json')
|
|
216
|
-
Woods::AtomicFile.write(index_path, canonical_json(flow_map))
|
|
240
|
+
Woods::AtomicFile.write(index_path, canonical_json(flow_map), durable: durable_writes?)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Whether a flow document pays for its own fsync as it is written.
|
|
244
|
+
#
|
|
245
|
+
# False by default. A flow document is a payload file: nothing reads it
|
|
246
|
+
# until +generation.json+ names the payload, and
|
|
247
|
+
# {Woods::AtomicFile.sync_directory_tree} flushes the whole payload before
|
|
248
|
+
# that pointer is written. `durable_payload_writes = true` buys the
|
|
249
|
+
# per-file fsync back for a host that wants it.
|
|
250
|
+
#
|
|
251
|
+
# @return [Boolean]
|
|
252
|
+
def durable_writes?
|
|
253
|
+
Woods.configuration&.durable_payload_writes ? true : false
|
|
217
254
|
end
|
|
218
255
|
|
|
219
256
|
# Emit deterministic pretty JSON — keys recursively sorted so two runs
|
data/lib/woods/gem_mapper.rb
CHANGED
|
@@ -7,6 +7,7 @@ require 'pathname'
|
|
|
7
7
|
require 'time'
|
|
8
8
|
|
|
9
9
|
require_relative 'atomic_file'
|
|
10
|
+
require_relative 'version'
|
|
10
11
|
require_relative 'coordination/pipeline_lock'
|
|
11
12
|
require_relative 'dependency_graph'
|
|
12
13
|
require_relative 'extracted_unit'
|
|
@@ -267,6 +268,7 @@ module Woods
|
|
|
267
268
|
end
|
|
268
269
|
manifest = {
|
|
269
270
|
extracted_at: Time.now.utc.iso8601, rails_version: nil, ruby_version: RUBY_VERSION,
|
|
271
|
+
woods_version: Woods::VERSION,
|
|
270
272
|
counts: counts, total_units: @units.size, total_chunks: 0, git_sha: nil, git_branch: nil,
|
|
271
273
|
provenance: GemMapper::PROVENANCE.merge(source_checksum: @checksum),
|
|
272
274
|
graph_nodes: @graph.to_h.dig(:stats, :node_count), graph_edges: @graph.to_h.dig(:stats, :edge_count)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'time'
|
|
5
|
+
require_relative 'git_command'
|
|
6
|
+
|
|
7
|
+
module Woods
|
|
8
|
+
# Stream HEAD-reachable touched-path events without pathspec simplification.
|
|
9
|
+
# Raw NUL records keep filenames separate from commit fields; only requested
|
|
10
|
+
# paths are retained. Merge diffs compare against the first parent, while
|
|
11
|
+
# traversal still visits every parent. Requires Git 2.31 or newer.
|
|
12
|
+
class GitHistory
|
|
13
|
+
class ReadError < StandardError; end
|
|
14
|
+
|
|
15
|
+
FORMAT = '%x00%H%x00%an%x00%cI%x00%s%x00'
|
|
16
|
+
private_constant :FORMAT
|
|
17
|
+
|
|
18
|
+
def initialize(root:, logger:)
|
|
19
|
+
@root = root
|
|
20
|
+
@logger = logger
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Hash, nil] raw per-path aggregates, or nil on any incomplete read
|
|
24
|
+
def read(paths, recent_after:)
|
|
25
|
+
requested = paths.to_h { |path| [repository_prefix + path.b, path] }
|
|
26
|
+
result = paths.to_h { |path| [path, {}] }
|
|
27
|
+
stream { |stdout| parse(stdout, requested, result, recent_after) }
|
|
28
|
+
result
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
@logger.warn('[Woods] Git enrichment omitted: history could not be read completely. ' \
|
|
31
|
+
'Git 2.31 or newer is required; check the application repository and git installation. ' \
|
|
32
|
+
"(#{e.class})")
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def repository_prefix
|
|
39
|
+
return @repository_prefix if defined?(@repository_prefix)
|
|
40
|
+
|
|
41
|
+
prefix, _error, status = Open3.capture3(*GitCommand.argv(@root, 'rev-parse', '--show-prefix'))
|
|
42
|
+
raise ReadError, 'cannot resolve application path in repository' unless status.success?
|
|
43
|
+
|
|
44
|
+
@repository_prefix = prefix.delete_suffix("\n").b
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def stream
|
|
48
|
+
argv = GitCommand.argv(@root, '-c', 'log.showSignature=false', 'log', 'HEAD',
|
|
49
|
+
'--since=365 days ago', '--root', '--raw', '-z', '--no-renames',
|
|
50
|
+
'--no-relative', '--no-ext-diff', '--encoding=UTF-8',
|
|
51
|
+
'--diff-merges=first-parent', "--format=#{FORMAT}")
|
|
52
|
+
Open3.popen3(*argv) do |stdin, stdout, stderr, wait|
|
|
53
|
+
stdin.close
|
|
54
|
+
stdout.binmode
|
|
55
|
+
# Drain stderr without retaining a potentially unbounded diagnostic.
|
|
56
|
+
drain = Thread.new { stderr.read(16_384) until stderr.eof? }
|
|
57
|
+
begin
|
|
58
|
+
yield stdout
|
|
59
|
+
raise ReadError, "history command exited #{wait.value.exitstatus}" unless wait.value.success?
|
|
60
|
+
ensure
|
|
61
|
+
stdout.close
|
|
62
|
+
drain.join
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def token(io)
|
|
68
|
+
value = io.gets("\0")
|
|
69
|
+
return nil unless value
|
|
70
|
+
raise ReadError, 'unterminated history record' unless value.end_with?("\0")
|
|
71
|
+
|
|
72
|
+
value.delete_suffix("\0")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def parse(io, requested, result, recent_after)
|
|
76
|
+
commit = nil
|
|
77
|
+
while (field = token(io))
|
|
78
|
+
next if field.empty?
|
|
79
|
+
|
|
80
|
+
if field.match?(/\A[0-9a-f]{40}(?:[0-9a-f]{24})?\z/)
|
|
81
|
+
commit = read_commit(io, field, recent_after)
|
|
82
|
+
elsif field.match?(/\A\n?:[0-7]{6} [0-7]{6} [0-9a-f]+ [0-9a-f]+ [A-Z]\z/)
|
|
83
|
+
record_path(io, requested, result, commit)
|
|
84
|
+
else
|
|
85
|
+
raise ReadError, 'unexpected history record'
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def read_commit(io, sha, recent_after)
|
|
91
|
+
author, date, message = Array.new(3) { token(io) }
|
|
92
|
+
raise ReadError, 'incomplete commit header' unless author && date && message
|
|
93
|
+
|
|
94
|
+
{ sha: sha, author: author.force_encoding(Encoding::UTF_8),
|
|
95
|
+
date: date, message: message.force_encoding(Encoding::UTF_8), recent: Time.iso8601(date) > recent_after }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def record_path(io, requested, result, commit)
|
|
99
|
+
path = token(io)
|
|
100
|
+
raise ReadError, 'incomplete path record' unless commit && path
|
|
101
|
+
|
|
102
|
+
relative = requested[path]
|
|
103
|
+
record(result[relative], commit) if relative
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def record(data, commit)
|
|
107
|
+
data[:last_modified] ||= commit[:date]
|
|
108
|
+
data[:last_author] ||= commit[:author]
|
|
109
|
+
data[:commit_count] = data.fetch(:commit_count, 0) + 1
|
|
110
|
+
data[:recent_count] = data.fetch(:recent_count, 0) + (commit[:recent] ? 1 : 0)
|
|
111
|
+
commits = (data[:commits] ||= [])
|
|
112
|
+
commits << commit if commits.size < 5
|
|
113
|
+
(data[:contributors] ||= Hash.new(0))[commit[:author]] += 1
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|