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
data/lib/woods/graph_analyzer.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest'
|
|
3
4
|
require 'set'
|
|
4
5
|
|
|
5
6
|
module Woods
|
|
@@ -55,11 +56,32 @@ module Woods
|
|
|
55
56
|
# can tell whether it was truncated (B-182).
|
|
56
57
|
DEFAULT_VOLATILE_LIMIT = 20
|
|
57
58
|
|
|
59
|
+
# How many distinct cycles {#detect_cycles} enumerates before it stops.
|
|
60
|
+
# The DFS finds one cycle per back-edge, and a dense graph has tens of
|
|
61
|
+
# thousands of them; nobody reads past the first few hundred, and the
|
|
62
|
+
# per-cycle signature work is what made analysis the fixed floor of every
|
|
63
|
+
# incremental run. `nil` means no cap.
|
|
64
|
+
DEFAULT_CYCLE_LIMIT = 500
|
|
65
|
+
|
|
66
|
+
# The longest cycle {#detect_cycles} will record, counted in distinct
|
|
67
|
+
# nodes. A back-edge deep in a DFS closes a cycle as long as the path,
|
|
68
|
+
# which on a large graph is thousands of nodes: unreadable as a report and
|
|
69
|
+
# expensive to canonicalize. `nil` means no cap.
|
|
70
|
+
DEFAULT_CYCLE_MAX_LENGTH = 50
|
|
71
|
+
|
|
58
72
|
# @param dependency_graph [DependencyGraph] The graph to analyze
|
|
59
73
|
# @param volatile_ratio [Numeric] see {#volatile_dependencies}
|
|
60
|
-
|
|
74
|
+
# @param volatile_limit_per_target [Integer, nil] maximum edges per typed dependency, before the global limit
|
|
75
|
+
# @param cycle_limit [Integer, nil] see {DEFAULT_CYCLE_LIMIT}
|
|
76
|
+
# @param cycle_max_length [Integer, nil] see {DEFAULT_CYCLE_MAX_LENGTH}
|
|
77
|
+
def initialize(dependency_graph, volatile_ratio: DEFAULT_VOLATILE_RATIO, volatile_limit_per_target: nil,
|
|
78
|
+
cycle_limit: DEFAULT_CYCLE_LIMIT, cycle_max_length: DEFAULT_CYCLE_MAX_LENGTH)
|
|
61
79
|
@graph = dependency_graph
|
|
62
80
|
@volatile_ratio = volatile_ratio.to_f
|
|
81
|
+
@volatile_limit_per_target = volatile_limit_per_target
|
|
82
|
+
@cycle_limit = cycle_limit
|
|
83
|
+
@cycle_max_length = cycle_max_length
|
|
84
|
+
@cycle_limit_reached = false
|
|
63
85
|
end
|
|
64
86
|
|
|
65
87
|
# ══════════════════════════════════════════════════════════════════════
|
|
@@ -147,6 +169,19 @@ module Woods
|
|
|
147
169
|
@cycles ||= detect_cycles
|
|
148
170
|
end
|
|
149
171
|
|
|
172
|
+
# Whether {#cycles} is a truncated view of the graph's cycles.
|
|
173
|
+
#
|
|
174
|
+
# True when either cap fired: the count cap stopped enumeration, or at
|
|
175
|
+
# least one cycle was longer than the length cap and was skipped. A reader
|
|
176
|
+
# of the array alone cannot tell, so this is published as
|
|
177
|
+
# `stats[:cycle_limit_reached]` alongside it.
|
|
178
|
+
#
|
|
179
|
+
# @return [Boolean]
|
|
180
|
+
def cycle_limit_reached?
|
|
181
|
+
cycles
|
|
182
|
+
@cycle_limit_reached
|
|
183
|
+
end
|
|
184
|
+
|
|
150
185
|
# Units that bridge different types in the graph.
|
|
151
186
|
#
|
|
152
187
|
# Computes a simplified betweenness centrality metric — for each unit, we
|
|
@@ -179,6 +214,7 @@ module Woods
|
|
|
179
214
|
|
|
180
215
|
pairs.each do |source, target|
|
|
181
216
|
path = bfs_shortest_path(source, target)
|
|
217
|
+
|
|
182
218
|
next unless path && path.size > 2
|
|
183
219
|
|
|
184
220
|
# Credit intermediate nodes (exclude source and target)
|
|
@@ -262,15 +298,16 @@ module Woods
|
|
|
262
298
|
# dependencies with fewer than {VOLATILE_MIN_COMMITS} commits or a
|
|
263
299
|
# `new` change frequency are skipped. Ranked by the dependency's
|
|
264
300
|
# PageRank so the most-depended-on volatile unit comes first. `limit`
|
|
265
|
-
# caps what this method hands back; {#analyze}'s
|
|
301
|
+
# caps what this method hands back after the optional per-target cap; {#analyze}'s
|
|
266
302
|
# `stats[:volatile_dependency_count]` reports the full qualifying count
|
|
267
303
|
# regardless of `limit`, and `stats[:volatile_dependencies_limit]` reports
|
|
268
|
-
# 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.
|
|
269
306
|
#
|
|
270
307
|
# @param limit [Integer] maximum entries
|
|
271
308
|
# @return [Array<Hash>] `{ from:, from_type:, to:, to_type:, via:, from_commits:, to_commits:, ratio:, pagerank: }`
|
|
272
309
|
def volatile_dependencies(limit: DEFAULT_VOLATILE_LIMIT)
|
|
273
|
-
|
|
310
|
+
capped_volatile_dependencies.first(limit)
|
|
274
311
|
end
|
|
275
312
|
|
|
276
313
|
# Edges that cross a Packwerk package boundary the source package never
|
|
@@ -361,9 +398,9 @@ module Woods
|
|
|
361
398
|
dead_end_count: computed_dead_ends.size,
|
|
362
399
|
hub_count: computed_hubs.size,
|
|
363
400
|
cycle_count: computed_cycles.size,
|
|
401
|
+
cycle_limit_reached: cycle_limit_reached?,
|
|
364
402
|
cross_database_edge_count: computed_cross_database.size,
|
|
365
|
-
|
|
366
|
-
volatile_dependencies_limit: DEFAULT_VOLATILE_LIMIT,
|
|
403
|
+
**volatile_dependency_stats(computed_volatile),
|
|
367
404
|
undeclared_package_edge_count: computed_undeclared.size
|
|
368
405
|
}
|
|
369
406
|
}
|
|
@@ -687,6 +724,33 @@ module Woods
|
|
|
687
724
|
@all_volatile_dependencies ||= compute_volatile_dependencies
|
|
688
725
|
end
|
|
689
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
|
+
|
|
690
754
|
# Short-circuits to `[]`, skipping the PageRank computation entirely,
|
|
691
755
|
# when no node carries an Integer `commit_count` (git enrichment never
|
|
692
756
|
# ran): there is nothing to rank.
|
|
@@ -802,6 +866,7 @@ module Woods
|
|
|
802
866
|
# @return [Array<Array<String>>] Detected cycles
|
|
803
867
|
def detect_cycles
|
|
804
868
|
nodes = graph_nodes
|
|
869
|
+
@cycle_limit_reached = false
|
|
805
870
|
return [] if nodes.empty?
|
|
806
871
|
|
|
807
872
|
white = 0
|
|
@@ -813,53 +878,48 @@ module Woods
|
|
|
813
878
|
found_cycles = []
|
|
814
879
|
seen_cycle_signatures = Set.new
|
|
815
880
|
|
|
816
|
-
|
|
817
|
-
|
|
881
|
+
catch(:cycle_limit) do
|
|
882
|
+
nodes.keys.sort.each do |start_node|
|
|
883
|
+
next unless color[start_node] == white
|
|
818
884
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
885
|
+
# Iterative DFS using an explicit stack.
|
|
886
|
+
# Each entry is [node, :enter] or [node, :exit].
|
|
887
|
+
stack = [[start_node, :enter]]
|
|
822
888
|
|
|
823
|
-
|
|
824
|
-
|
|
889
|
+
# Track the current DFS path for cycle extraction.
|
|
890
|
+
path = []
|
|
825
891
|
|
|
826
|
-
|
|
827
|
-
|
|
892
|
+
while stack.any?
|
|
893
|
+
node, action = stack.pop
|
|
828
894
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
895
|
+
if action == :exit
|
|
896
|
+
color[node] = black
|
|
897
|
+
path.pop
|
|
898
|
+
next
|
|
899
|
+
end
|
|
834
900
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
if cycle
|
|
855
|
-
sig = normalize_cycle_signature(cycle)
|
|
856
|
-
unless seen_cycle_signatures.include?(sig)
|
|
857
|
-
seen_cycle_signatures.add(sig)
|
|
858
|
-
found_cycles << cycle
|
|
859
|
-
end
|
|
901
|
+
# :enter action
|
|
902
|
+
next unless color[node] == white
|
|
903
|
+
|
|
904
|
+
color[node] = gray
|
|
905
|
+
path.push(node)
|
|
906
|
+
stack.push([node, :exit])
|
|
907
|
+
|
|
908
|
+
# Not sorted, deliberately: this list is the unit's own declared
|
|
909
|
+
# dependency order, which is identical in a full and an incremental
|
|
910
|
+
# run, so sorting it would change nothing any test can observe.
|
|
911
|
+
neighbors = @graph.dependencies_of(node)
|
|
912
|
+
neighbors.each do |neighbor|
|
|
913
|
+
case color[neighbor]
|
|
914
|
+
when white
|
|
915
|
+
parent[neighbor] = node
|
|
916
|
+
stack.push([neighbor, :enter])
|
|
917
|
+
when gray
|
|
918
|
+
# Found a cycle: extract it from the path
|
|
919
|
+
collect_cycle(path, neighbor, found_cycles, seen_cycle_signatures)
|
|
860
920
|
end
|
|
921
|
+
# black nodes are fully explored, skip them
|
|
861
922
|
end
|
|
862
|
-
# black nodes are fully explored, skip them
|
|
863
923
|
end
|
|
864
924
|
end
|
|
865
925
|
end
|
|
@@ -869,16 +929,52 @@ module Woods
|
|
|
869
929
|
found_cycles
|
|
870
930
|
end
|
|
871
931
|
|
|
932
|
+
# Record the cycle closed by a back-edge to +cycle_start+.
|
|
933
|
+
#
|
|
934
|
+
# Skips a cycle longer than the length cap and one already recorded under
|
|
935
|
+
# another rotation. Throws +:cycle_limit+ once the count cap is full,
|
|
936
|
+
# which ends the whole scan in {#detect_cycles}.
|
|
937
|
+
#
|
|
938
|
+
# @param path [Array<String>] Current DFS path
|
|
939
|
+
# @param cycle_start [String] The node that closes the cycle
|
|
940
|
+
# @param found_cycles [Array<Array<String>>] Accumulator
|
|
941
|
+
# @param seen [Set<String>] Signatures already recorded
|
|
942
|
+
# @return [void]
|
|
943
|
+
def collect_cycle(path, cycle_start, found_cycles, seen)
|
|
944
|
+
cycle = extract_cycle_from_path(path, cycle_start, max_length: @cycle_max_length)
|
|
945
|
+
if cycle == :too_long
|
|
946
|
+
@cycle_limit_reached = true
|
|
947
|
+
return
|
|
948
|
+
end
|
|
949
|
+
return unless cycle
|
|
950
|
+
|
|
951
|
+
signature = normalize_cycle_signature(cycle)
|
|
952
|
+
return if seen.include?(signature)
|
|
953
|
+
|
|
954
|
+
seen.add(signature)
|
|
955
|
+
found_cycles << cycle
|
|
956
|
+
return unless @cycle_limit && found_cycles.size >= @cycle_limit
|
|
957
|
+
|
|
958
|
+
@cycle_limit_reached = true
|
|
959
|
+
throw :cycle_limit
|
|
960
|
+
end
|
|
961
|
+
|
|
872
962
|
# Extracts a cycle from the current DFS path when a back-edge to
|
|
873
963
|
# +cycle_start+ is found.
|
|
874
964
|
#
|
|
965
|
+
# The length check runs on indexes, before the slice: an over-long cycle
|
|
966
|
+
# costs nothing beyond the +index+ lookup that found it.
|
|
967
|
+
#
|
|
875
968
|
# @param path [Array<String>] Current DFS path
|
|
876
969
|
# @param cycle_start [String] The node that closes the cycle
|
|
877
|
-
# @
|
|
878
|
-
#
|
|
879
|
-
|
|
970
|
+
# @param max_length [Integer, nil] Longest cycle to build, in distinct nodes
|
|
971
|
+
# @return [Array<String>, Symbol, nil] The cycle path ending with cycle_start
|
|
972
|
+
# repeated; +:too_long+ when it exceeds +max_length+; nil when cycle_start
|
|
973
|
+
# is not in the path
|
|
974
|
+
def extract_cycle_from_path(path, cycle_start, max_length: nil)
|
|
880
975
|
start_index = path.index(cycle_start)
|
|
881
976
|
return nil unless start_index
|
|
977
|
+
return :too_long if max_length && (path.size - start_index) > max_length
|
|
882
978
|
|
|
883
979
|
path[start_index..] + [cycle_start]
|
|
884
980
|
end
|
|
@@ -886,17 +982,20 @@ module Woods
|
|
|
886
982
|
# Normalize a cycle so that duplicate rotations are treated as the same cycle.
|
|
887
983
|
# For example, [A, B, C, A] and [B, C, A, B] are the same cycle.
|
|
888
984
|
#
|
|
985
|
+
# Keyed by digest rather than by the joined path: the set holds one
|
|
986
|
+
# 64-byte key per cycle instead of a string as long as the cycle, so
|
|
987
|
+
# membership stays constant-cost however deep the DFS went.
|
|
988
|
+
#
|
|
889
989
|
# @param cycle [Array<String>] Cycle path with repeated last element
|
|
890
|
-
# @return [String] Canonical
|
|
990
|
+
# @return [String] Canonical hex digest of the rotated loop
|
|
891
991
|
def normalize_cycle_signature(cycle)
|
|
892
992
|
# Remove the trailing repeated element to get the raw loop
|
|
893
993
|
loop_nodes = cycle[0..-2]
|
|
894
|
-
return
|
|
994
|
+
return Digest::SHA256.hexdigest('') if loop_nodes.empty?
|
|
895
995
|
|
|
896
996
|
# Rotate so the lexicographically smallest element is first
|
|
897
997
|
min_index = loop_nodes.each_with_index.min_by { |node, _i| node }.last
|
|
898
|
-
|
|
899
|
-
rotated.join('->')
|
|
998
|
+
Digest::SHA256.hexdigest(loop_nodes.rotate(min_index).join('->'))
|
|
900
999
|
end
|
|
901
1000
|
|
|
902
1001
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -927,32 +1026,65 @@ module Woods
|
|
|
927
1026
|
pairs.to_a
|
|
928
1027
|
end
|
|
929
1028
|
|
|
1029
|
+
# Forward adjacency, resolved once per node per analyzer instance.
|
|
1030
|
+
#
|
|
1031
|
+
# {#bridges} runs `sample_size` whole-graph traversals, and
|
|
1032
|
+
# `DependencyGraph#dependencies_of` sorts and flattens the node's edge
|
|
1033
|
+
# buckets on every call, so an uncached BFS re-derived the same adjacency
|
|
1034
|
+
# list up to 200 times per node. Populated on demand rather than up front:
|
|
1035
|
+
# a traversal that never reaches a node should not pay for it.
|
|
1036
|
+
#
|
|
1037
|
+
# @return [Hash{String => Array<String>}]
|
|
1038
|
+
def adjacency
|
|
1039
|
+
@adjacency ||= Hash.new { |cache, identifier| cache[identifier] = @graph.dependencies_of(identifier) }
|
|
1040
|
+
end
|
|
1041
|
+
|
|
930
1042
|
# BFS shortest path between two nodes, following forward edges.
|
|
931
1043
|
#
|
|
1044
|
+
# Carries parent pointers rather than a path per queue entry. The old form
|
|
1045
|
+
# allocated a copy of the path so far for every node it enqueued, which on
|
|
1046
|
+
# a large graph is a full array per node per traversal; the path is now
|
|
1047
|
+
# built once, for the one node that matched.
|
|
1048
|
+
#
|
|
932
1049
|
# @param source [String] Starting node identifier
|
|
933
1050
|
# @param target [String] Target node identifier
|
|
934
1051
|
# @return [Array<String>, nil] Shortest path or nil if unreachable
|
|
935
1052
|
def bfs_shortest_path(source, target)
|
|
936
1053
|
return [source] if source == target
|
|
937
1054
|
|
|
938
|
-
|
|
939
|
-
queue = [
|
|
1055
|
+
parents = { source => nil }
|
|
1056
|
+
queue = [source]
|
|
1057
|
+
head = 0
|
|
940
1058
|
|
|
941
|
-
while queue.
|
|
942
|
-
current
|
|
1059
|
+
while head < queue.size
|
|
1060
|
+
current = queue[head]
|
|
1061
|
+
head += 1
|
|
943
1062
|
|
|
944
|
-
|
|
945
|
-
next if
|
|
1063
|
+
adjacency[current].each do |neighbor|
|
|
1064
|
+
next if parents.key?(neighbor)
|
|
946
1065
|
|
|
947
|
-
|
|
948
|
-
return
|
|
1066
|
+
parents[neighbor] = current
|
|
1067
|
+
return path_to(parents, neighbor) if neighbor == target
|
|
949
1068
|
|
|
950
|
-
|
|
951
|
-
queue.push([neighbor, new_path])
|
|
1069
|
+
queue.push(neighbor)
|
|
952
1070
|
end
|
|
953
1071
|
end
|
|
954
1072
|
|
|
955
1073
|
nil
|
|
956
1074
|
end
|
|
1075
|
+
|
|
1076
|
+
# Walk parent pointers back to the source and reverse.
|
|
1077
|
+
#
|
|
1078
|
+
# @param parents [Hash{String => String, nil}] node => the node it was reached from
|
|
1079
|
+
# @param node [String] the end of the path
|
|
1080
|
+
# @return [Array<String>] source-first path ending at +node+
|
|
1081
|
+
def path_to(parents, node)
|
|
1082
|
+
path = []
|
|
1083
|
+
while node
|
|
1084
|
+
path << node
|
|
1085
|
+
node = parents[node]
|
|
1086
|
+
end
|
|
1087
|
+
path.reverse
|
|
1088
|
+
end
|
|
957
1089
|
end
|
|
958
1090
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module Hooks
|
|
8
|
+
# The plugin's independent process-group deadline also includes bundle/Ruby
|
|
9
|
+
# startup. This inner deadline keeps ordinary direct calls short as well.
|
|
10
|
+
class ContextCLI
|
|
11
|
+
MAX_INPUT_BYTES = 1_048_576
|
|
12
|
+
HELP = 'Usage: woods-hook-context SessionStart|PostToolUse < claude-event.json; ' \
|
|
13
|
+
'opt in with WOODS_HOOK_CONTEXT_ENABLED=1. Prefer the bounded plugin hook entry point.'
|
|
14
|
+
|
|
15
|
+
def self.run(kind, input: $stdin, output: $stdout)
|
|
16
|
+
return output.puts(HELP) || 0 if kind == '--help'
|
|
17
|
+
|
|
18
|
+
return 0 if ENV['WOODS_HOOKS_DISABLED'] == '1' || ENV['WOODS_HOOK_CONTEXT_ENABLED'] != '1'
|
|
19
|
+
return 0 unless %w[SessionStart PostToolUse].include?(kind)
|
|
20
|
+
|
|
21
|
+
Timeout.timeout(0.65) do
|
|
22
|
+
require 'woods'
|
|
23
|
+
require 'woods/hooks/context_hint'
|
|
24
|
+
require 'woods/hooks/context_state'
|
|
25
|
+
emit(kind, input, output)
|
|
26
|
+
end
|
|
27
|
+
0
|
|
28
|
+
rescue Timeout::Error, JSON::ParserError, ArgumentError, KeyError, TypeError, SystemCallError, IOError
|
|
29
|
+
# Missing/degraded/slow inputs are optional context, not permission to
|
|
30
|
+
# run extraction, read application data, or acknowledge queued edits.
|
|
31
|
+
0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.emit(kind, input, output)
|
|
35
|
+
bytes = input.read(MAX_INPUT_BYTES + 1)
|
|
36
|
+
return if bytes.bytesize > MAX_INPUT_BYTES
|
|
37
|
+
|
|
38
|
+
event = JSON.parse(bytes)
|
|
39
|
+
return unless event.is_a?(Hash) && event['hook_event_name'] == kind
|
|
40
|
+
|
|
41
|
+
root = ENV['WOODS_HOOK_CONTEXT_ROOT'] || event.fetch('cwd')
|
|
42
|
+
directory = File.expand_path(ENV.fetch('WOODS_OUTPUT', 'tmp/woods'), root)
|
|
43
|
+
result = ContextHint.new(event: event, output_dir: directory, root: root).call
|
|
44
|
+
return unless result
|
|
45
|
+
|
|
46
|
+
ContextState.new(directory).emit(result) do
|
|
47
|
+
output.write(ContextOutput.new(kind).encode(result.fetch(:context)))
|
|
48
|
+
output.flush
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
private_class_method :emit
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'woods/input_rules'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module Hooks
|
|
8
|
+
# Claude context events are deliberately separate from refresh queue records.
|
|
9
|
+
class ContextEvent
|
|
10
|
+
MAX_FILE_BYTES = 1_048_576
|
|
11
|
+
attr_reader :root, :path, :kind, :session
|
|
12
|
+
|
|
13
|
+
def initialize(input, root: nil)
|
|
14
|
+
validate_root!(input)
|
|
15
|
+
|
|
16
|
+
@logical_root = File.expand_path(input['cwd'])
|
|
17
|
+
@root = File.realpath(root || @logical_root)
|
|
18
|
+
@kind = input['hook_event_name']
|
|
19
|
+
@session = input['session_id'] if valid_string?(input['session_id'], 256)
|
|
20
|
+
return if kind == 'SessionStart'
|
|
21
|
+
|
|
22
|
+
raise ArgumentError unless kind == 'PostToolUse' && %w[Edit Write MultiEdit].include?(input['tool_name'])
|
|
23
|
+
|
|
24
|
+
@path = relative_path(input.fetch('tool_input').fetch('file_path'))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def eligible?
|
|
28
|
+
return true if kind == 'SessionStart'
|
|
29
|
+
|
|
30
|
+
require 'woods/extractor' unless defined?(Woods::Extractor::EXTRACTORS)
|
|
31
|
+
InputRules.new.action(path) != :ignore
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def fingerprint
|
|
35
|
+
return 'orientation' if kind == 'SessionStart'
|
|
36
|
+
|
|
37
|
+
flags = File::RDONLY | File::NONBLOCK
|
|
38
|
+
flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
|
|
39
|
+
File.open(File.join(root, path), flags) do |file|
|
|
40
|
+
return nil unless file.stat.file? && file.stat.size <= MAX_FILE_BYTES
|
|
41
|
+
|
|
42
|
+
bytes = file.read(MAX_FILE_BYTES + 1)
|
|
43
|
+
return nil if bytes.bytesize > MAX_FILE_BYTES
|
|
44
|
+
|
|
45
|
+
Digest::SHA256.hexdigest(bytes)
|
|
46
|
+
end
|
|
47
|
+
rescue Errno::ENOENT
|
|
48
|
+
'missing'
|
|
49
|
+
rescue SystemCallError, IOError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def validate_root!(input)
|
|
56
|
+
raise ArgumentError unless input.is_a?(Hash) && valid_string?(input['cwd']) && input['cwd'].start_with?('/')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def valid_string?(value, limit = 4096)
|
|
60
|
+
value.is_a?(String) && !value.empty? && value.bytesize <= limit &&
|
|
61
|
+
value.valid_encoding? && !value.include?("\0")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def relative_path(value)
|
|
65
|
+
raise ArgumentError unless valid_string?(value)
|
|
66
|
+
|
|
67
|
+
prefix = [@logical_root, root].find { |base| value.start_with?("#{base}/") }
|
|
68
|
+
relative = prefix ? value.delete_prefix("#{prefix}/") : value
|
|
69
|
+
validate_parts!(relative)
|
|
70
|
+
validate_containment!(relative)
|
|
71
|
+
relative
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_parts!(relative)
|
|
75
|
+
parts = relative.split('/', -1)
|
|
76
|
+
raise ArgumentError if relative.start_with?('/') || parts.size > 64
|
|
77
|
+
raise ArgumentError if parts.any? { |part| ['', '.', '..'].include?(part) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_containment!(relative)
|
|
81
|
+
candidate = File.join(root, relative)
|
|
82
|
+
candidate = File.dirname(candidate) until File.exist?(candidate) || File.symlink?(candidate)
|
|
83
|
+
resolved = File.realpath(candidate)
|
|
84
|
+
raise ArgumentError unless resolved == root || resolved.start_with?("#{root}/")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'woods/mcp/index_reader'
|
|
4
|
+
require_relative 'context_event'
|
|
5
|
+
require_relative 'context_output'
|
|
6
|
+
require_relative 'context_impact'
|
|
7
|
+
|
|
8
|
+
module Woods
|
|
9
|
+
module Hooks
|
|
10
|
+
# A bounded snapshot hint. The outer hook process owns the end-to-end deadline.
|
|
11
|
+
class ContextHint
|
|
12
|
+
MAX_ARTIFACT_BYTES = 16 * 1024 * 1024
|
|
13
|
+
|
|
14
|
+
def initialize(event:, output_dir:, root: nil)
|
|
15
|
+
@event = ContextEvent.new(event, root: root)
|
|
16
|
+
@output = File.expand_path(output_dir, @event.root)
|
|
17
|
+
@renderer = ContextOutput.new(@event.kind)
|
|
18
|
+
rescue ArgumentError, KeyError, TypeError, SystemCallError
|
|
19
|
+
@event = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
return nil unless @event&.eligible?
|
|
24
|
+
|
|
25
|
+
@reader = MCP::IndexReader.new(@output)
|
|
26
|
+
@reader.with_pinned_generation do
|
|
27
|
+
@generation = @reader.generation_identity
|
|
28
|
+
raise ArgumentError if @reader.payload_dir.to_s == @output
|
|
29
|
+
|
|
30
|
+
check_artifacts!
|
|
31
|
+
freshness = source_freshness
|
|
32
|
+
text = if @event.kind == 'SessionStart'
|
|
33
|
+
orientation(freshness)
|
|
34
|
+
else
|
|
35
|
+
ContextImpact.new(@reader, @renderer).call(@event.path, @generation.first, freshness)
|
|
36
|
+
end
|
|
37
|
+
result(text)
|
|
38
|
+
end
|
|
39
|
+
rescue ArgumentError, KeyError, TypeError, NoMethodError, JSON::ParserError, SystemCallError, IOError
|
|
40
|
+
{ context: ContextOutput::UNAVAILABLE, identity: nil, session: nil }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def check_artifacts!
|
|
46
|
+
%w[manifest.json dependency_graph.json source_inputs.json].each do |name|
|
|
47
|
+
file = @reader.payload_dir.join(name)
|
|
48
|
+
next if name == 'source_inputs.json' && !file.exist?
|
|
49
|
+
|
|
50
|
+
raise ArgumentError unless file.file? && file.size <= MAX_ARTIFACT_BYTES
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def source_freshness
|
|
55
|
+
SourceInputs::Status.new(output_dir: @output, root: @event.root, payload_dir: @reader.payload_dir,
|
|
56
|
+
generation: @reader.loaded_generation).call.fetch('state')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def orientation(freshness)
|
|
60
|
+
@renderer.context("Woods index generation #{@generation.first}; source freshness: #{freshness}. " \
|
|
61
|
+
'Use woods_status, search and typed lookup; dependents explain:true suggests checks.')
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def result(text)
|
|
65
|
+
fingerprint = @event.fingerprint
|
|
66
|
+
identity = if fingerprint && @event.session
|
|
67
|
+
Digest::SHA256.hexdigest(JSON.generate([@event.root, @event.path, @generation, fingerprint, text]))
|
|
68
|
+
end
|
|
69
|
+
{ context: text, identity: identity, session: @event.session, root: @event.root }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module Hooks
|
|
5
|
+
# Resolve one unambiguous edited identity, then reuse the bounded graph walk.
|
|
6
|
+
class ContextImpact
|
|
7
|
+
MAX_NODES = 50_000
|
|
8
|
+
|
|
9
|
+
def initialize(reader, renderer)
|
|
10
|
+
@reader = reader
|
|
11
|
+
@renderer = renderer
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(path, generation, freshness)
|
|
15
|
+
graph = @reader.raw_graph_data
|
|
16
|
+
roots = roots_for(graph, path)
|
|
17
|
+
header = "Woods candidates from generation #{generation} (pre-refresh snapshot; " \
|
|
18
|
+
"source freshness: #{freshness}). Changed path: #{JSON.generate(path)}."
|
|
19
|
+
return unresolved(header, roots) unless roots.size == 1
|
|
20
|
+
|
|
21
|
+
root = roots.first
|
|
22
|
+
traversal = @reader.traverse_dependents(root.fetch('identifier'), depth: 2, explain: true, max_nodes: 10,
|
|
23
|
+
max_edges: 100)
|
|
24
|
+
@renderer.context(header, rows_for(traversal), partial: !!traversal[:partial])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def rows_for(traversal)
|
|
30
|
+
rows = traversal.fetch(:explanation).fetch(:witnesses).filter_map do |_identifier, witness|
|
|
31
|
+
next if witness[:impact] == 'root'
|
|
32
|
+
|
|
33
|
+
edge = traversal[:explanation][:edges].fetch(witness.fetch(:edge_id))
|
|
34
|
+
evidence_row(edge, witness)
|
|
35
|
+
end
|
|
36
|
+
rows << 'No candidate found within this bounded snapshot; verify manually with dependents.' if rows.empty?
|
|
37
|
+
rows
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def roots_for(graph, path)
|
|
41
|
+
records = records_for(graph)
|
|
42
|
+
matching = records.select { |node| node['file_path'] == path }
|
|
43
|
+
identities = matching.map { |node| node.slice('identifier', 'type') }.uniq
|
|
44
|
+
return identities if identities.size != 1
|
|
45
|
+
|
|
46
|
+
# A recorded edge target does not identify which same-named type it means.
|
|
47
|
+
records.select { |node| node['identifier'] == identities.first['identifier'] }
|
|
48
|
+
.map { |node| node.slice('identifier', 'type') }.uniq
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def unresolved(header, roots)
|
|
52
|
+
reason = roots.empty? ? 'unresolved changed path' : 'ambiguous changed identity'
|
|
53
|
+
rows = roots.first(10).map { |root| "#{root['identifier']} (#{root['type']})" }
|
|
54
|
+
@renderer.context("#{header} #{reason}; verify with search and typed lookup.", rows, partial: true)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def evidence_row(edge, witness)
|
|
58
|
+
source = edge.fetch(:source)
|
|
59
|
+
target = edge.fetch(:target)
|
|
60
|
+
target_type = target[:type] || "ambiguous/unknown #{Array(target[:candidate_types]).join(',')}"
|
|
61
|
+
test_hint = source[:type] == 'test_mapping' ? '; test suggestion' : ''
|
|
62
|
+
uncertain = witness[:typed_path_complete] ? '' : '; typed path incomplete'
|
|
63
|
+
"#{witness[:impact]} candidate: #{source[:identifier]} (#{source[:type]}) " \
|
|
64
|
+
"--via=#{edge[:via] || 'unknown'}--> #{target[:identifier]} (#{target_type})#{test_hint}#{uncertain}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def records_for(graph)
|
|
68
|
+
nodes = graph.fetch('nodes')
|
|
69
|
+
variants = graph.fetch('variants', [])
|
|
70
|
+
raise ArgumentError unless nodes.is_a?(Hash) && variants.is_a?(Array)
|
|
71
|
+
raise ArgumentError if nodes.size + variants.size > MAX_NODES
|
|
72
|
+
|
|
73
|
+
nodes.map { |identifier, node| node.merge('identifier' => identifier) } + variants
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|