woods 2.0.0.beta2 → 2.0.0.beta4
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 +339 -1
- data/CONTRIBUTING.md +188 -12
- data/README.md +93 -174
- data/SECURITY.md +9 -6
- data/docs/AGENT_GUIDE.md +109 -8
- data/docs/AGENT_SETUP.md +98 -7
- data/docs/BACKEND_MATRIX.md +25 -0
- data/docs/CLIENT_HOOKS.md +111 -0
- data/docs/CONFIGURATION_REFERENCE.md +267 -16
- data/docs/CONSOLE_MCP_SETUP.md +80 -7
- data/docs/DOCKER_SETUP.md +22 -3
- data/docs/EVALUATION.md +464 -1
- data/docs/EXTRACTOR_REFERENCE.md +45 -6
- data/docs/FAQ.md +11 -12
- data/docs/GETTING_STARTED.md +17 -5
- data/docs/INCREMENTAL_EXTRACTION.md +147 -7
- data/docs/INDEX_LAYOUT.md +382 -0
- data/docs/INTERNALS.md +7 -2
- data/docs/MCP_SERVERS.md +276 -5
- data/docs/MCP_TOOL_COOKBOOK.md +37 -22
- data/docs/MCP_WORKTREE_SETUP.md +43 -83
- data/docs/NOTION_INTEGRATION.md +13 -0
- data/docs/OBSIDIAN_INTEGRATION.md +57 -9
- data/docs/PUBLISHED_INDEX.md +72 -0
- data/docs/README.md +7 -0
- data/docs/RETRIEVAL_GUIDE.md +273 -12
- data/docs/RUNTIME_TRACING.md +71 -0
- data/docs/SOURCE_FRESHNESS.md +143 -0
- data/docs/TROUBLESHOOTING.md +129 -18
- data/docs/UNBLOCKED_INTEGRATION.md +25 -0
- data/docs/UPGRADING_TO_2.md +48 -22
- data/docs/WATCH_DAEMON.md +277 -67
- data/exe/woods-agent-config +6 -0
- data/exe/woods-extract +5 -0
- data/exe/woods-hook-context +6 -0
- data/exe/woods-mcp-start +14 -9
- data/lib/generators/woods/pgvector_generator.rb +8 -2
- data/lib/generators/woods/templates/woods.rb.tt +1 -3
- data/lib/tasks/woods.rake +47 -397
- data/lib/woods/agent_configuration/applier.rb +135 -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 +72 -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/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 +18 -17
- data/lib/woods/console/credential_scanner_registry.rb +36 -0
- data/lib/woods/console/dispatch_pipeline.rb +7 -0
- data/lib/woods/console/embedded_executor.rb +32 -10
- 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/console/sql_noise_stripper.rb +9 -7
- data/lib/woods/console/sql_table_scanner.rb +47 -7
- data/lib/woods/console/sql_validator.rb +49 -9
- data/lib/woods/console/sqlite_read_guard.rb +46 -0
- data/lib/woods/coordination/pipeline_lock.rb +3 -2
- data/lib/woods/dependency_graph.rb +65 -13
- data/lib/woods/embedding/corpus.rb +94 -0
- data/lib/woods/embedding/indexer.rb +114 -60
- 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 +277 -149
- 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/declared_parent.rb +55 -0
- 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 +10 -13
- 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 +13 -9
- data/lib/woods/extractors/mailer_extractor.rb +26 -15
- 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 +26 -34
- 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 +13 -9
- 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 +48 -19
- 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/gem_mapper.rb +2 -0
- data/lib/woods/git_history.rb +116 -0
- data/lib/woods/graph_analyzer.rb +35 -6
- 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 +22 -13
- data/lib/woods/mcp/bootstrapper.rb +79 -4
- data/lib/woods/mcp/config_resolver.rb +2 -1
- data/lib/woods/mcp/index_reader.rb +334 -162
- 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 +22 -9
- data/lib/woods/mcp/renderers/plain_renderer.rb +18 -8
- data/lib/woods/mcp/search_results.rb +74 -0
- data/lib/woods/mcp/server.rb +178 -63
- data/lib/woods/mcp/tool_contract.rb +3 -1
- data/lib/woods/mcp/tool_response_renderer.rb +41 -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/mcp/traversal_response.rb +22 -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 +13 -6
- data/lib/woods/payload_store.rb +27 -26
- data/lib/woods/published_index/typed_unit_reader.rb +40 -3
- data/lib/woods/published_index.rb +2 -2
- data/lib/woods/railtie.rb +3 -3
- data/lib/woods/railtie_support.rb +12 -12
- data/lib/woods/rake_helpers.rb +382 -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 +84 -0
- data/lib/woods/retrieval/lexical_index.rb +120 -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 +77 -38
- data/lib/woods/session_tracer/file_store.rb +6 -1
- 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 +31 -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 +35 -10
- 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 +58 -9
- 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 +154 -32
- 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 +50 -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 +19 -0
- data/plugin/skills/woods-diagnose/SKILL.md +319 -1
- data/plugin/skills/woods-investigate/SKILL.md +145 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +90 -2
- data/plugin/skills/woods-setup/SKILL.md +110 -6
- metadata +87 -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
|
|
|
@@ -76,7 +78,7 @@ module Woods
|
|
|
76
78
|
|
|
77
79
|
unit
|
|
78
80
|
rescue StandardError => e
|
|
79
|
-
|
|
81
|
+
SourceInputs::ConsumerErrors.log(self, "Failed to extract route: #{e.message}")
|
|
80
82
|
nil
|
|
81
83
|
end
|
|
82
84
|
|
|
@@ -23,41 +23,13 @@ module Woods
|
|
|
23
23
|
# end
|
|
24
24
|
#
|
|
25
25
|
module RouteHelperResolver
|
|
26
|
-
# Route helper prefixes that produce non-navigation dependencies.
|
|
27
|
-
# These generate asset URLs or are common false positives from
|
|
28
|
-
# non-route uses of _path/_url suffixes in Ruby code.
|
|
29
|
-
#
|
|
30
|
-
# NOTE: `root` is intentionally excluded — root_path is the most common
|
|
31
|
-
# Rails route helper, but it appears so frequently in non-navigation contexts
|
|
32
|
-
# (path construction, config, tests) that it generates excessive noise.
|
|
33
|
-
# The tradeoff: "what links to the home page?" won't appear in graph queries.
|
|
34
|
-
# Add new prefixes here when false positives are discovered in host apps.
|
|
35
|
-
IGNORED_HELPER_PREFIXES = %w[
|
|
36
|
-
asset
|
|
37
|
-
image
|
|
38
|
-
stylesheet
|
|
39
|
-
javascript
|
|
40
|
-
font
|
|
41
|
-
audio
|
|
42
|
-
video
|
|
43
|
-
turbo_stream
|
|
44
|
-
file
|
|
45
|
-
tmp
|
|
46
|
-
base
|
|
47
|
-
root
|
|
48
|
-
log
|
|
49
|
-
socket
|
|
50
|
-
download
|
|
51
|
-
].freeze
|
|
52
|
-
|
|
53
26
|
# Build the route helper lookup map from Rails named routes.
|
|
54
27
|
# Call this once in your extractor's initialize method.
|
|
55
28
|
#
|
|
56
29
|
# Resilient to partial test doubles: any exception raised while
|
|
57
30
|
# traversing Rails routes (unstubbed `application` on a double,
|
|
58
31
|
# missing `named_routes`, etc.) is swallowed and leaves the map
|
|
59
|
-
# empty — extractors
|
|
60
|
-
# literal as the dependency target.
|
|
32
|
+
# empty — extractors omit unresolved navigation dependencies.
|
|
61
33
|
def build_route_helper_map
|
|
62
34
|
@route_helper_map = {}
|
|
63
35
|
return unless defined?(Rails)
|
|
@@ -65,6 +37,11 @@ module Woods
|
|
|
65
37
|
routes = safe_rails_application_routes
|
|
66
38
|
return unless routes
|
|
67
39
|
|
|
40
|
+
# Rails lazy route sets do not load when named_routes is read. Use the
|
|
41
|
+
# runtime route collection reader before caching helpers, just as the
|
|
42
|
+
# route extractor does. Older route sets and partial doubles still work.
|
|
43
|
+
routes.routes if routes.respond_to?(:routes)
|
|
44
|
+
|
|
68
45
|
routes.named_routes.each do |name, route|
|
|
69
46
|
controller = route.defaults[:controller]
|
|
70
47
|
action = route.defaults[:action]
|
|
@@ -78,8 +55,8 @@ module Woods
|
|
|
78
55
|
}
|
|
79
56
|
end
|
|
80
57
|
rescue StandardError
|
|
81
|
-
# Leave @route_helper_map empty — navigation-edge extractors
|
|
82
|
-
#
|
|
58
|
+
# Leave @route_helper_map empty — navigation-edge extractors omit
|
|
59
|
+
# helpers whose controller/action cannot be resolved.
|
|
83
60
|
@route_helper_map = {}
|
|
84
61
|
end
|
|
85
62
|
|
|
@@ -105,8 +82,8 @@ module Woods
|
|
|
105
82
|
# @return [Hash, nil] { controller:, action:, path:, verb: } or nil if unresolvable
|
|
106
83
|
def resolve_route_helper(helper_name)
|
|
107
84
|
base = helper_name.sub(/_(path|url)\z/, '')
|
|
108
|
-
|
|
109
|
-
|
|
85
|
+
# A real named route is authoritative even when its name resembles
|
|
86
|
+
# a filesystem or asset helper. Unresolved names still produce no edge.
|
|
110
87
|
@route_helper_map&.[](base)
|
|
111
88
|
end
|
|
112
89
|
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../source_inputs/consumer_errors'
|
|
4
|
+
|
|
3
5
|
require 'yaml'
|
|
6
|
+
begin
|
|
7
|
+
require 'active_support/configuration_file'
|
|
8
|
+
rescue LoadError
|
|
9
|
+
# Rails 6.0 predates ConfigurationFile.
|
|
10
|
+
require 'erb'
|
|
11
|
+
end
|
|
4
12
|
|
|
5
13
|
module Woods
|
|
6
14
|
module Extractors
|
|
@@ -38,9 +46,6 @@ module Woods
|
|
|
38
46
|
'0 0 1 1 *' => 'yearly on January 1st'
|
|
39
47
|
}.freeze
|
|
40
48
|
|
|
41
|
-
# Environment keys to unwrap when nested in YAML
|
|
42
|
-
ENVIRONMENT_KEYS = %w[production development test staging].freeze
|
|
43
|
-
|
|
44
49
|
# A quoted Whenever command argument in either style. The body runs to
|
|
45
50
|
# the *matching* delimiter, so the inner quotes of `command "echo 'hi'"`
|
|
46
51
|
# stay part of the command.
|
|
@@ -79,8 +84,8 @@ module Woods
|
|
|
79
84
|
else
|
|
80
85
|
[]
|
|
81
86
|
end
|
|
82
|
-
rescue StandardError => e
|
|
83
|
-
|
|
87
|
+
rescue StandardError, SyntaxError, LoadError => e
|
|
88
|
+
SourceInputs::ConsumerErrors.log(self, "Failed to extract scheduled jobs from #{file_path}: #{e.message}")
|
|
84
89
|
[]
|
|
85
90
|
end
|
|
86
91
|
|
|
@@ -97,12 +102,15 @@ module Woods
|
|
|
97
102
|
# @return [Array<ExtractedUnit>]
|
|
98
103
|
def extract_yaml_schedule(file_path, format)
|
|
99
104
|
source = File.read(file_path)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
data = if format == :solid_queue
|
|
106
|
+
# Match Solid Queue's trusted Rails configuration loader: ERB
|
|
107
|
+
# executes with its real filename (including require_relative).
|
|
108
|
+
load_recurring_configuration(file_path, source)
|
|
109
|
+
else
|
|
110
|
+
# Sidekiq remains safe-loaded; aliases allow shared defaults
|
|
111
|
+
# without permitting additional deserialized classes (#203).
|
|
112
|
+
YAML.safe_load(source, permitted_classes: [Symbol], aliases: true)
|
|
113
|
+
end
|
|
106
114
|
|
|
107
115
|
return [] unless data.is_a?(Hash) && data.any?
|
|
108
116
|
|
|
@@ -116,10 +124,23 @@ module Woods
|
|
|
116
124
|
end
|
|
117
125
|
end
|
|
118
126
|
|
|
127
|
+
def load_recurring_configuration(file_path, source)
|
|
128
|
+
if defined?(ActiveSupport::ConfigurationFile)
|
|
129
|
+
ActiveSupport::ConfigurationFile.parse(file_path)
|
|
130
|
+
else
|
|
131
|
+
# Rails 6.0: preserve the same filename-aware ERB evaluation while
|
|
132
|
+
# retaining the existing scalar/hash YAML policy.
|
|
133
|
+
require 'erb'
|
|
134
|
+
erb = ERB.new(source)
|
|
135
|
+
erb.filename = file_path
|
|
136
|
+
YAML.safe_load(erb.result, permitted_classes: [Symbol], aliases: true)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
119
140
|
# Detect and unwrap environment-nested YAML.
|
|
120
141
|
#
|
|
121
|
-
# If the top
|
|
122
|
-
#
|
|
142
|
+
# If the top level contains maps of tasks, unwrap to the section for
|
|
143
|
+
# the environment being extracted (including custom environment names),
|
|
123
144
|
# falling back to the first section when the current environment has no
|
|
124
145
|
# entry. Taking `values.first` unconditionally meant a file listing
|
|
125
146
|
# `development:` before `production:` indexed the development schedule
|
|
@@ -128,9 +149,14 @@ module Woods
|
|
|
128
149
|
# @param data [Hash] Parsed YAML data
|
|
129
150
|
# @return [Hash] Unwrapped entries
|
|
130
151
|
def unwrap_environment_nesting(data)
|
|
131
|
-
|
|
152
|
+
# Environment sections contain task maps; task entries contain scalar
|
|
153
|
+
# configuration values. Shape matters even for a task called production.
|
|
154
|
+
nested = data.values.all? do |section|
|
|
155
|
+
section.nil? || (section.is_a?(Hash) && section.values.all?(Hash))
|
|
156
|
+
end
|
|
157
|
+
return data unless nested
|
|
132
158
|
|
|
133
|
-
data[current_environment] || data.values.first || {}
|
|
159
|
+
data.key?(current_environment) ? (data[current_environment] || {}) : (data.values.first || {})
|
|
134
160
|
end
|
|
135
161
|
|
|
136
162
|
# The environment name the extraction is running under, as a String.
|
|
@@ -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
|
|
|
@@ -106,7 +108,7 @@ module Woods
|
|
|
106
108
|
|
|
107
109
|
unit
|
|
108
110
|
rescue StandardError => e
|
|
109
|
-
|
|
111
|
+
SourceInputs::ConsumerErrors.log(self, "Failed to extract serializer #{file_path}: #{e.message}")
|
|
110
112
|
nil
|
|
111
113
|
end
|
|
112
114
|
|
|
@@ -136,7 +138,7 @@ module Woods
|
|
|
136
138
|
|
|
137
139
|
unit
|
|
138
140
|
rescue StandardError => e
|
|
139
|
-
|
|
141
|
+
SourceInputs::ConsumerErrors.log(self, "Failed to extract serializer #{klass.name}: #{e.message}")
|
|
140
142
|
nil
|
|
141
143
|
end
|
|
142
144
|
|
|
@@ -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,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'declared_parent'
|
|
4
|
+
|
|
3
5
|
require_relative 'source_nesting'
|
|
6
|
+
require_relative 'method_parameters'
|
|
4
7
|
|
|
5
8
|
module Woods
|
|
6
9
|
module Extractors
|
|
@@ -124,10 +127,10 @@ module Woods
|
|
|
124
127
|
# Extract the parent class name from a class definition.
|
|
125
128
|
#
|
|
126
129
|
# @param source [String] Ruby source code
|
|
127
|
-
# @
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
# @param class_name [String] Already selected extraction identity
|
|
131
|
+
# @return [String, nil] Explicit parent of that declaration, or nil
|
|
132
|
+
def extract_parent_class(source, class_name)
|
|
133
|
+
DeclaredParent.call(source, class_name)
|
|
131
134
|
end
|
|
132
135
|
|
|
133
136
|
# Count non-blank, non-comment lines of code.
|
|
@@ -221,6 +224,44 @@ module Woods
|
|
|
221
224
|
actions.to_a
|
|
222
225
|
end
|
|
223
226
|
|
|
227
|
+
# Rails 6 exposes a numeric Proc identity through #filter, and the
|
|
228
|
+
# callable through #raw_filter. Newer Rails exposes the callable directly.
|
|
229
|
+
def callback_filter(callback)
|
|
230
|
+
stable_filter(callback.respond_to?(:raw_filter) ? callback.raw_filter : callback.filter)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Proc#to_s embeds a process-local address. Model and controller filters
|
|
234
|
+
# share source-site labels, so metadata and derived chunks agree across
|
|
235
|
+
# boots. Preserve symbols and other application-defined callback values.
|
|
236
|
+
def stable_filter(filter)
|
|
237
|
+
return filter unless filter.is_a?(Proc)
|
|
238
|
+
|
|
239
|
+
location = filter.source_location
|
|
240
|
+
site = if location
|
|
241
|
+
path, line = location
|
|
242
|
+
"#{path.delete_prefix("#{Rails.root}/")}:#{line}"
|
|
243
|
+
else
|
|
244
|
+
'native'
|
|
245
|
+
end
|
|
246
|
+
"#<#{filter.lambda? ? 'lambda' : 'Proc'} #{site}>"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Preserve application labels; strip addresses only from Ruby's actual
|
|
250
|
+
# default representation. Nested identities occur for anonymous classes.
|
|
251
|
+
# These are descriptive labels, not serialization of callback object state.
|
|
252
|
+
def callback_filter_label(callback)
|
|
253
|
+
filter = callback_filter(callback)
|
|
254
|
+
label = filter.to_s
|
|
255
|
+
return label unless label.start_with?('#<')
|
|
256
|
+
|
|
257
|
+
owner = filter.is_a?(Module) ? Module : Kernel
|
|
258
|
+
return label unless label == owner.instance_method(:to_s).bind(filter).call
|
|
259
|
+
|
|
260
|
+
label.gsub(/:0x[0-9a-f]+(?=>)/i, '')
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
private :callback_filter, :stable_filter, :callback_filter_label
|
|
264
|
+
|
|
224
265
|
# Human-readable label for a non-ActionFilter condition.
|
|
225
266
|
#
|
|
226
267
|
# @param condition [Object] A proc, symbol, or other condition
|
|
@@ -292,7 +333,7 @@ module Woods
|
|
|
292
333
|
source.scan(/def\s+self\.(\w+[?!=]?)/).flatten
|
|
293
334
|
end
|
|
294
335
|
|
|
295
|
-
# Extract initialize parameters from source
|
|
336
|
+
# Extract initialize parameters from source syntax without evaluating defaults.
|
|
296
337
|
#
|
|
297
338
|
# Parses the parameter list of the initialize method to determine
|
|
298
339
|
# parameter names, defaults, and whether they are keyword arguments.
|
|
@@ -304,21 +345,9 @@ module Woods
|
|
|
304
345
|
# @param source [String] Ruby source code
|
|
305
346
|
# @return [Array<Hash>] Parameter info hashes with :name, :has_default, :keyword
|
|
306
347
|
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
|
-
}
|
|
348
|
+
MethodParameters.extract(source, :initialize).map do |parameter|
|
|
349
|
+
parameter.slice(:name, :has_default, :keyword)
|
|
319
350
|
end
|
|
320
|
-
|
|
321
|
-
params
|
|
322
351
|
end
|
|
323
352
|
end
|
|
324
353
|
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/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
|
data/lib/woods/graph_analyzer.rb
CHANGED
|
@@ -71,12 +71,14 @@ module Woods
|
|
|
71
71
|
|
|
72
72
|
# @param dependency_graph [DependencyGraph] The graph to analyze
|
|
73
73
|
# @param volatile_ratio [Numeric] see {#volatile_dependencies}
|
|
74
|
+
# @param volatile_limit_per_target [Integer, nil] maximum edges per typed dependency, before the global limit
|
|
74
75
|
# @param cycle_limit [Integer, nil] see {DEFAULT_CYCLE_LIMIT}
|
|
75
76
|
# @param cycle_max_length [Integer, nil] see {DEFAULT_CYCLE_MAX_LENGTH}
|
|
76
|
-
def initialize(dependency_graph, volatile_ratio: DEFAULT_VOLATILE_RATIO,
|
|
77
|
+
def initialize(dependency_graph, volatile_ratio: DEFAULT_VOLATILE_RATIO, volatile_limit_per_target: nil,
|
|
77
78
|
cycle_limit: DEFAULT_CYCLE_LIMIT, cycle_max_length: DEFAULT_CYCLE_MAX_LENGTH)
|
|
78
79
|
@graph = dependency_graph
|
|
79
80
|
@volatile_ratio = volatile_ratio.to_f
|
|
81
|
+
@volatile_limit_per_target = volatile_limit_per_target
|
|
80
82
|
@cycle_limit = cycle_limit
|
|
81
83
|
@cycle_max_length = cycle_max_length
|
|
82
84
|
@cycle_limit_reached = false
|
|
@@ -296,15 +298,16 @@ module Woods
|
|
|
296
298
|
# dependencies with fewer than {VOLATILE_MIN_COMMITS} commits or a
|
|
297
299
|
# `new` change frequency are skipped. Ranked by the dependency's
|
|
298
300
|
# PageRank so the most-depended-on volatile unit comes first. `limit`
|
|
299
|
-
# caps what this method hands back; {#analyze}'s
|
|
301
|
+
# caps what this method hands back after the optional per-target cap; {#analyze}'s
|
|
300
302
|
# `stats[:volatile_dependency_count]` reports the full qualifying count
|
|
301
303
|
# regardless of `limit`, and `stats[:volatile_dependencies_limit]` reports
|
|
302
|
-
# the cap
|
|
304
|
+
# the global cap. An enabled per-target cap also publishes its setting
|
|
305
|
+
# and the final reported count; the full qualifying count is unchanged.
|
|
303
306
|
#
|
|
304
307
|
# @param limit [Integer] maximum entries
|
|
305
308
|
# @return [Array<Hash>] `{ from:, from_type:, to:, to_type:, via:, from_commits:, to_commits:, ratio:, pagerank: }`
|
|
306
309
|
def volatile_dependencies(limit: DEFAULT_VOLATILE_LIMIT)
|
|
307
|
-
|
|
310
|
+
capped_volatile_dependencies.first(limit)
|
|
308
311
|
end
|
|
309
312
|
|
|
310
313
|
# Edges that cross a Packwerk package boundary the source package never
|
|
@@ -397,8 +400,7 @@ module Woods
|
|
|
397
400
|
cycle_count: computed_cycles.size,
|
|
398
401
|
cycle_limit_reached: cycle_limit_reached?,
|
|
399
402
|
cross_database_edge_count: computed_cross_database.size,
|
|
400
|
-
|
|
401
|
-
volatile_dependencies_limit: DEFAULT_VOLATILE_LIMIT,
|
|
403
|
+
**volatile_dependency_stats(computed_volatile),
|
|
402
404
|
undeclared_package_edge_count: computed_undeclared.size
|
|
403
405
|
}
|
|
404
406
|
}
|
|
@@ -722,6 +724,33 @@ module Woods
|
|
|
722
724
|
@all_volatile_dependencies ||= compute_volatile_dependencies
|
|
723
725
|
end
|
|
724
726
|
|
|
727
|
+
# Apply the optional hub cap after ranking, before the overall report
|
|
728
|
+
# limit. Keep individual relationship edges; two `via` values consume two
|
|
729
|
+
# slots. Type is part of dependency identity, never just the display name.
|
|
730
|
+
def capped_volatile_dependencies
|
|
731
|
+
return all_volatile_dependencies unless @volatile_limit_per_target
|
|
732
|
+
|
|
733
|
+
counts = Hash.new(0)
|
|
734
|
+
all_volatile_dependencies.select do |entry|
|
|
735
|
+
target = [entry[:to_type], entry[:to]]
|
|
736
|
+
counts[target] += 1
|
|
737
|
+
counts[target] <= @volatile_limit_per_target
|
|
738
|
+
end
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
# Preserve the default artifact exactly; expose extra selection metadata
|
|
742
|
+
# only when the per-target cap was requested. The full count always means
|
|
743
|
+
# all ratio-qualified edges, before either cap.
|
|
744
|
+
def volatile_dependency_stats(reported)
|
|
745
|
+
stats = { volatile_dependency_count: all_volatile_dependencies.size,
|
|
746
|
+
volatile_dependencies_limit: DEFAULT_VOLATILE_LIMIT }
|
|
747
|
+
if @volatile_limit_per_target
|
|
748
|
+
stats[:volatile_dependencies_limit_per_target] = @volatile_limit_per_target
|
|
749
|
+
stats[:volatile_dependency_reported_count] = reported.size
|
|
750
|
+
end
|
|
751
|
+
stats
|
|
752
|
+
end
|
|
753
|
+
|
|
725
754
|
# Short-circuits to `[]`, skipping the PageRank computation entirely,
|
|
726
755
|
# when no node carries an Integer `commit_count` (git enrichment never
|
|
727
756
|
# ran): there is nothing to rank.
|