woods 2.0.0.beta2 → 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 +262 -1
- data/CONTRIBUTING.md +173 -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 +199 -14
- 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 +117 -1
- data/docs/INDEX_LAYOUT.md +382 -0
- data/docs/INTERNALS.md +7 -2
- 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 +55 -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/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 +232 -137
- 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/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 +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 +27 -26
- 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 +77 -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 +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 +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/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.
|
|
@@ -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
|