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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'find'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'woods/source_inputs/private_key'
|
|
6
|
+
require 'woods/source_inputs/scopes'
|
|
7
|
+
require 'woods/watch/watcher'
|
|
8
|
+
|
|
9
|
+
module Woods
|
|
10
|
+
module SourceInputs
|
|
11
|
+
# Unlike the watcher's best-effort walk, every incomplete read is evidence
|
|
12
|
+
# against claiming complete source coverage. No Rails application is booted.
|
|
13
|
+
class Scanner # rubocop:disable Metrics/ClassLength -- one bounded traversal owns its counters and read checks
|
|
14
|
+
DEFAULT_LIMITS = { max_files: 50_000, max_bytes: 128 * 1024 * 1024, max_seconds: 10.0 }.freeze
|
|
15
|
+
class BudgetExceeded < StandardError; end
|
|
16
|
+
|
|
17
|
+
attr_reader :root, :scopes
|
|
18
|
+
|
|
19
|
+
def initialize(root:, output_dir:, key:, scopes: Scopes.new, **limits)
|
|
20
|
+
@root = File.expand_path(root.to_s)
|
|
21
|
+
@output_dir = File.expand_path(output_dir.to_s)
|
|
22
|
+
@key = key
|
|
23
|
+
@scopes = scopes
|
|
24
|
+
@limits = DEFAULT_LIMITS.merge(limits)
|
|
25
|
+
return if @limits.values.all? { |value| value.is_a?(Numeric) && value.finite? && value.positive? }
|
|
26
|
+
|
|
27
|
+
raise ArgumentError, 'source scan limits must be finite and positive'
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call
|
|
31
|
+
@started = monotonic
|
|
32
|
+
@files = {}
|
|
33
|
+
@scope_paths = {}
|
|
34
|
+
@errors = []
|
|
35
|
+
@bytes = 0
|
|
36
|
+
@visited = 0
|
|
37
|
+
walk
|
|
38
|
+
result
|
|
39
|
+
rescue BudgetExceeded => e
|
|
40
|
+
error(e.message)
|
|
41
|
+
result
|
|
42
|
+
rescue SystemCallError, IOError
|
|
43
|
+
error('source_tree_unavailable')
|
|
44
|
+
result
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def walk
|
|
50
|
+
raise Errno::ENOENT, @root unless File.directory?(@root)
|
|
51
|
+
|
|
52
|
+
@resolved_root = File.realpath(@root)
|
|
53
|
+
Find.find(@root, ignore_error: false) do |path|
|
|
54
|
+
next if path == @root
|
|
55
|
+
|
|
56
|
+
check_budget!
|
|
57
|
+
relative = path.delete_prefix("#{@root}/")
|
|
58
|
+
if ignored_directory?(path, relative)
|
|
59
|
+
Find.prune if File.directory?(path)
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
stat = File.lstat(path)
|
|
63
|
+
next if stat.directory?
|
|
64
|
+
|
|
65
|
+
@visited += 1
|
|
66
|
+
check_budget!
|
|
67
|
+
visit(path, relative, stat)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def ignored_directory?(path, relative)
|
|
72
|
+
return true if path == @output_dir || path.start_with?("#{@output_dir}/")
|
|
73
|
+
return false if declared_path?(relative)
|
|
74
|
+
|
|
75
|
+
Watch::TreeScan.hidden?(relative) ||
|
|
76
|
+
Watch::TreeScan.ignored?(relative, Watch::Watcher::DEFAULT_IGNORED_DIRECTORIES)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def declared_path?(relative)
|
|
80
|
+
@scopes.extra_roots.any? do |root|
|
|
81
|
+
relative == root || relative.start_with?("#{root}/") || root.start_with?("#{relative}/")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
86
|
+
# Refuse opaque directories, external paths and unreadable inputs separately.
|
|
87
|
+
def visit(path, relative, stat)
|
|
88
|
+
if stat.symlink? && File.directory?(path)
|
|
89
|
+
error('unverified_symlink_directory', relative)
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
consumers = @scopes.for_path(relative)
|
|
93
|
+
return if consumers.empty?
|
|
94
|
+
|
|
95
|
+
real = File.realpath(path)
|
|
96
|
+
unless real.start_with?("#{@resolved_root}/")
|
|
97
|
+
error('external_source_path', relative)
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
identity = read_identity(path, real, stamp(File.stat(real)))
|
|
101
|
+
return unless identity
|
|
102
|
+
|
|
103
|
+
@files[relative] = identity
|
|
104
|
+
consumers.each { |scope| (@scope_paths[scope] ||= []) << relative }
|
|
105
|
+
rescue SystemCallError, IOError
|
|
106
|
+
error('source_file_unreadable', relative)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
110
|
+
|
|
111
|
+
def read_identity(path, resolved, expected) # rubocop:disable Metrics/CyclomaticComplexity -- stable descriptor and original resolution checks
|
|
112
|
+
flags = File::RDONLY | File::NONBLOCK
|
|
113
|
+
flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
|
|
114
|
+
File.open(resolved, flags) do |file|
|
|
115
|
+
before = stamp(file.stat)
|
|
116
|
+
unless before == expected && File.realpath(path) == resolved
|
|
117
|
+
error('source_changed_during_read', path.delete_prefix("#{@root}/"))
|
|
118
|
+
return nil
|
|
119
|
+
end
|
|
120
|
+
unless file.stat.file?
|
|
121
|
+
error('nonregular_source', path.delete_prefix("#{@root}/"))
|
|
122
|
+
return nil
|
|
123
|
+
end
|
|
124
|
+
identity = hash_stream(file)
|
|
125
|
+
unless before == stamp(file.stat) && before == stamp(File.stat(resolved)) && File.realpath(path) == resolved
|
|
126
|
+
error('source_changed_during_read', path.delete_prefix("#{@root}/"))
|
|
127
|
+
return nil
|
|
128
|
+
end
|
|
129
|
+
identity
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def hash_stream(file)
|
|
134
|
+
digest = OpenSSL::HMAC.new(@key.bytes, OpenSSL::Digest.new('SHA256'))
|
|
135
|
+
while (chunk = file.read(16_384))
|
|
136
|
+
@bytes += chunk.bytesize
|
|
137
|
+
check_budget!
|
|
138
|
+
digest.update(chunk)
|
|
139
|
+
end
|
|
140
|
+
digest.hexdigest
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def stamp(stat)
|
|
144
|
+
[stat.dev, stat.ino, stat.size, stat.mtime.to_r, stat.ctime.to_r]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def check_budget!
|
|
148
|
+
raise BudgetExceeded, 'scan_time_budget' if monotonic - @started > @limits[:max_seconds]
|
|
149
|
+
raise BudgetExceeded, 'scan_file_budget' if @visited > @limits[:max_files]
|
|
150
|
+
raise BudgetExceeded, 'scan_byte_budget' if @bytes > @limits[:max_bytes]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def error(reason, path = nil)
|
|
154
|
+
@errors << { 'reason' => reason, 'path' => path }.compact if @errors.size < 20
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def result
|
|
158
|
+
{ 'root' => @root, 'key_id' => @key.identifier, 'rules' => @scopes.fingerprint,
|
|
159
|
+
'extra_roots' => @scopes.extra_roots, 'files' => @files.sort.to_h,
|
|
160
|
+
'scope_paths' => @scope_paths.transform_values(&:sort).sort.to_h,
|
|
161
|
+
'errors' => @errors, 'complete' => @errors.empty?,
|
|
162
|
+
'metrics' => { 'visited_files' => @visited, 'hashed_files' => @files.size,
|
|
163
|
+
'bytes' => @bytes, 'elapsed_ms' => ((monotonic - @started) * 1000).round(3) } }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def monotonic
|
|
167
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'woods/input_rules'
|
|
6
|
+
require 'woods/watch/watcher'
|
|
7
|
+
require 'woods/watch/tree_scan'
|
|
8
|
+
|
|
9
|
+
module Woods
|
|
10
|
+
module SourceInputs
|
|
11
|
+
# Keep each consumer's provenance separate. A whole-app event scan must not
|
|
12
|
+
# bless retained service units that happen to share the same source file.
|
|
13
|
+
class Scopes
|
|
14
|
+
BOOT_FILES = %w[Rakefile config.ru].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :extra_roots
|
|
17
|
+
|
|
18
|
+
def initialize(extra_roots: [])
|
|
19
|
+
require 'woods/extractor' unless defined?(Woods::Extractor::EXTRACTORS)
|
|
20
|
+
@extra_roots = Array(extra_roots).map { |path| validate_root(path) }.uniq.sort
|
|
21
|
+
@file_rules = PathDispatcher.file_rules
|
|
22
|
+
@whole_rules = PathDispatcher.whole_app_rules
|
|
23
|
+
@policy = ReloadPolicy.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def for_path(path) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- each independent consumer contributes a scope
|
|
27
|
+
scopes = []
|
|
28
|
+
action = @policy.classify(path)
|
|
29
|
+
scopes << 'boot' if boot_path?(path, action)
|
|
30
|
+
scopes << 'runtime' if runtime_path?(path, action)
|
|
31
|
+
@file_rules.each { |rule| scopes << "file:#{rule.extractor_key}" if rule.matches?(path) }
|
|
32
|
+
@whole_rules.each { |rule| scopes << "whole:#{rule.extractor_key}" if rule.matches?(path) }
|
|
33
|
+
scopes << 'declared' if @extra_roots.any? { |dir| path.start_with?("#{dir}/") }
|
|
34
|
+
scopes.uniq.sort
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def boot_path?(path, action)
|
|
38
|
+
return true if action == :restart || BOOT_FILES.include?(path)
|
|
39
|
+
return false unless action == :ignore
|
|
40
|
+
|
|
41
|
+
path.match?(%r{\Aconfig/.+\.rb\z}) || path.match?(%r{\A[^/]+\.gemspec\z})
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def runtime_path?(path, action)
|
|
45
|
+
action == :reload && path.end_with?('.rb') && path.match?(%r{\A(?:app|lib)/})
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def fingerprint
|
|
49
|
+
data = [@file_rules.map(&:to_h), @whole_rules.map(&:to_h),
|
|
50
|
+
PathDispatcher.runtime_rules.map(&:to_h),
|
|
51
|
+
ReloadPolicy.constants(false).sort.to_h { |name| [name, ReloadPolicy.const_get(name)] },
|
|
52
|
+
Watch::Watcher::DEFAULT_IGNORED_DIRECTORIES, Watch::TreeScan::NOT_IGNORED_DOTFILES,
|
|
53
|
+
Watch::TreeScan::NOT_IGNORED_DOTFILE_PREFIXES, 'bounded_no_symlink_dirs_v1',
|
|
54
|
+
'config_ruby_and_root_gemspecs_v1', BOOT_FILES, @extra_roots]
|
|
55
|
+
Digest::SHA256.hexdigest(JSON.generate(data))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def validate_root(value)
|
|
61
|
+
path = value.to_s.delete_suffix('/')
|
|
62
|
+
if path.empty? || path.start_with?('/') || path.include?("\0") ||
|
|
63
|
+
path.split('/', -1).any? { |part| ['', '.', '..'].include?(part) }
|
|
64
|
+
raise ArgumentError, 'source roots must be relative directories under the application root'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
path
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
require 'woods/source_inputs/scanner'
|
|
5
|
+
require 'woods/source_inputs/manifest'
|
|
6
|
+
require 'woods/source_inputs/handoff'
|
|
7
|
+
require 'woods/source_inputs/consumer_errors'
|
|
8
|
+
|
|
9
|
+
module Woods
|
|
10
|
+
module SourceInputs
|
|
11
|
+
# Records successful consumers, never merely paths found by a final scan.
|
|
12
|
+
# An incremental run starts from the preceding generation's scope ledger.
|
|
13
|
+
class Session # rubocop:disable Metrics/ClassLength -- per-run consumer ledger and publication coverage
|
|
14
|
+
def initialize(root:, output_dir:, baseline_path:, operation:)
|
|
15
|
+
@root = File.expand_path(root.to_s)
|
|
16
|
+
@output = File.expand_path(output_dir.to_s)
|
|
17
|
+
@operation = operation.to_s
|
|
18
|
+
@errors = []
|
|
19
|
+
@unverified = []
|
|
20
|
+
@baseline = read_baseline(baseline_path)
|
|
21
|
+
@rules = Scopes.new(extra_roots: declared_roots)
|
|
22
|
+
@key = PrivateKey.new(output_dir: @output, create: true)
|
|
23
|
+
handoff = Handoff.read(root: @root, output_dir: @output, operation: @operation, rules: @rules.fingerprint,
|
|
24
|
+
key_id: @key.identifier)
|
|
25
|
+
@snapshot = handoff || scan
|
|
26
|
+
@verified_boot = !handoff.nil?
|
|
27
|
+
@identities = initial_identities
|
|
28
|
+
rescue PrivateKey::Unavailable => e
|
|
29
|
+
@snapshot = unavailable_snapshot(e.message)
|
|
30
|
+
@identities = {}
|
|
31
|
+
@verified_boot = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def consume_file(key, path)
|
|
35
|
+
consume_path("file:#{key}", path)
|
|
36
|
+
consume_path("unit:#{key}", path) if @identities.fetch("unit:#{key}", {}).key?(relative_path(path))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def consume_unit(key, path)
|
|
40
|
+
consume_path("unit:#{key}", path)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def consume_deleted(path) # rubocop:disable Metrics/CyclomaticComplexity -- deletion preserves boot provenance
|
|
44
|
+
relative = relative_path(path)
|
|
45
|
+
return if relative.nil? || @snapshot.fetch('files').key?(relative)
|
|
46
|
+
|
|
47
|
+
@rules.for_path(relative).each { |scope| @identities[scope]&.delete(relative) unless scope == 'boot' }
|
|
48
|
+
@identities.each { |scope, paths| paths.delete(relative) if scope.start_with?('unit:') }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def consume_extractor(key, units)
|
|
52
|
+
["file:#{key}", "whole:#{key}"].each { |scope| replace_scope(scope) }
|
|
53
|
+
@identities["unit:#{key}"] = {}
|
|
54
|
+
Array(units).each { |unit| consume_unit(key, unit.file_path) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def unverified(scope)
|
|
58
|
+
@unverified << scope.to_s
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def full_units(results, consumers: {})
|
|
62
|
+
results.each do |key, units|
|
|
63
|
+
if ConsumerErrors.failed?(consumers[key])
|
|
64
|
+
unverified("extractor:#{key}")
|
|
65
|
+
else
|
|
66
|
+
consume_extractor(key, units)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def finish(generation:, eager_load_complete:)
|
|
72
|
+
verify_stability
|
|
73
|
+
verify_loaded_source_coverage
|
|
74
|
+
if @operation != 'full'
|
|
75
|
+
preserve_baseline_coverage
|
|
76
|
+
# Fresh discovery is not proof that every retained runtime fact was
|
|
77
|
+
# serialized again. Keep this limitation explicit even when bytes match.
|
|
78
|
+
@unverified << 'runtime_consumption' if runtime_inputs_changed?
|
|
79
|
+
replace_scope('runtime') if @verified_boot && eager_load_complete
|
|
80
|
+
end
|
|
81
|
+
@unverified << 'incomplete_eager_load' unless eager_load_complete
|
|
82
|
+
Manifest.build(snapshot: @snapshot, scopes: @identities, boot_verified: verified_coverage?,
|
|
83
|
+
generation: generation, errors: @errors, unverified_scopes: @unverified)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def declared_roots
|
|
89
|
+
roots = Handoff.extra_roots
|
|
90
|
+
roots.empty? && @baseline ? @baseline.data.fetch('extra_roots') : roots
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def read_baseline(path)
|
|
94
|
+
return nil if path.nil? || !File.file?(path)
|
|
95
|
+
|
|
96
|
+
File.open(path, File::RDONLY | File::NONBLOCK) do |file|
|
|
97
|
+
raise Manifest::Invalid, 'source_manifest_too_large' if file.stat.size > Manifest::MAX_BYTES
|
|
98
|
+
|
|
99
|
+
Manifest.parse(file.read(Manifest::MAX_BYTES + 1))
|
|
100
|
+
end
|
|
101
|
+
rescue Manifest::Invalid, SystemCallError, IOError
|
|
102
|
+
@errors << { 'reason' => 'invalid_source_baseline' } unless @operation == 'full'
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def initial_identities
|
|
107
|
+
return snapshot_identities if @operation == 'full'
|
|
108
|
+
return @baseline.expanded if compatible_baseline?
|
|
109
|
+
|
|
110
|
+
@errors << { 'reason' => 'missing_or_incompatible_source_baseline' }
|
|
111
|
+
{}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def compatible_baseline?
|
|
115
|
+
@baseline && @baseline.data['key_id'] == @snapshot['key_id'] &&
|
|
116
|
+
@baseline.data['rules'] == @snapshot['rules'] && @baseline.data['root'] == @root
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def snapshot_identities
|
|
120
|
+
@snapshot.fetch('scope_paths').transform_values do |paths|
|
|
121
|
+
paths.to_h { |path| [path, @snapshot.fetch('files').fetch(path)] }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def consume_path(scope, path)
|
|
126
|
+
relative = relative_path(path)
|
|
127
|
+
return if relative.nil?
|
|
128
|
+
|
|
129
|
+
identity = @snapshot.fetch('files')[relative]
|
|
130
|
+
if identity
|
|
131
|
+
(@identities[scope] ||= {})[relative] = identity
|
|
132
|
+
elsif File.exist?(File.join(@root, relative))
|
|
133
|
+
@errors << { 'reason' => 'uncaptured_source_path', 'path' => relative }
|
|
134
|
+
else
|
|
135
|
+
@identities[scope]&.delete(relative)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def relative_path(path)
|
|
140
|
+
return nil if path.nil? || path.to_s.empty?
|
|
141
|
+
|
|
142
|
+
absolute = File.expand_path(path.to_s, @root)
|
|
143
|
+
# Installed gem/framework source is outside application-source coverage.
|
|
144
|
+
return nil unless absolute.start_with?("#{@root}/")
|
|
145
|
+
|
|
146
|
+
absolute.delete_prefix("#{@root}/")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def replace_scope(scope)
|
|
150
|
+
paths = @snapshot.fetch('scope_paths').fetch(scope, [])
|
|
151
|
+
@identities[scope] = paths.to_h { |path| [path, @snapshot.fetch('files').fetch(path)] }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def runtime_inputs_changed?
|
|
155
|
+
return true unless compatible_baseline?
|
|
156
|
+
|
|
157
|
+
before = @baseline.expanded.fetch('runtime', {})
|
|
158
|
+
now = snapshot_identities.fetch('runtime', {})
|
|
159
|
+
before != now
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def verified_coverage?
|
|
163
|
+
return @verified_boot if @operation == 'full'
|
|
164
|
+
|
|
165
|
+
!!(@verified_boot && compatible_baseline? && @baseline.data['boot_verified'])
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def preserve_baseline_coverage
|
|
169
|
+
return unless @baseline
|
|
170
|
+
|
|
171
|
+
@errors.concat(@baseline.data.fetch('errors'))
|
|
172
|
+
@unverified.concat(@baseline.data.fetch('unverified_scopes'))
|
|
173
|
+
@errors << { 'reason' => 'incomplete_source_baseline' } unless @baseline.data['complete']
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def verify_stability
|
|
177
|
+
return unless @key
|
|
178
|
+
|
|
179
|
+
after = scan
|
|
180
|
+
@errors.concat(after.fetch('errors'))
|
|
181
|
+
before_files = @snapshot.fetch('files')
|
|
182
|
+
after_files = after.fetch('files')
|
|
183
|
+
changed = (before_files.keys | after_files.keys).reject { |path| before_files[path] == after_files[path] }
|
|
184
|
+
changed.first(30).each { |path| @errors << { 'reason' => 'source_changed_during_extraction', 'path' => path } }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# A custom loader can consume Ruby outside the standard dispatch roots.
|
|
188
|
+
# Loaded-feature evidence can disprove coverage, never expand a capture
|
|
189
|
+
# after boot and retroactively claim those bytes were consumed.
|
|
190
|
+
def verify_loaded_source_coverage
|
|
191
|
+
paths = $LOADED_FEATURES.filter_map do |path|
|
|
192
|
+
# Ruby 3 also lists built-in pseudo features, such as thread.rb.
|
|
193
|
+
next unless path.start_with?('/') || File.file?(File.expand_path(path, @root))
|
|
194
|
+
|
|
195
|
+
relative_path(path)
|
|
196
|
+
end.uniq
|
|
197
|
+
missing = paths - @snapshot.fetch('files').keys
|
|
198
|
+
missing.first(30).each do |path|
|
|
199
|
+
@errors << { 'reason' => 'loaded_source_outside_coverage', 'path' => path }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def scan
|
|
204
|
+
Scanner.new(root: @root, output_dir: @output, key: @key, scopes: @rules).call
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def unavailable_snapshot(reason)
|
|
208
|
+
{ 'root' => @root, 'key_id' => '0' * 64, 'rules' => @rules.fingerprint,
|
|
209
|
+
'extra_roots' => @rules.extra_roots, 'files' => {}, 'scope_paths' => {}, 'complete' => false,
|
|
210
|
+
'errors' => [{ 'reason' => reason }], 'metrics' => {} }
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'woods/generation'
|
|
5
|
+
require 'woods/source_inputs/verifier'
|
|
6
|
+
|
|
7
|
+
module Woods
|
|
8
|
+
module SourceInputs
|
|
9
|
+
# Shared CLI/hook/MCP reader. A caller serving an older immutable payload
|
|
10
|
+
# passes its own directory and generation, never the newest marker's proof.
|
|
11
|
+
class Status
|
|
12
|
+
BUDGETS = { 'quick' => 0.25, 'deep' => 5.0 }.freeze
|
|
13
|
+
|
|
14
|
+
def self.from_transport(encoded)
|
|
15
|
+
raise ArgumentError, 'source status options are too large' if encoded.to_s.bytesize > 16_384
|
|
16
|
+
|
|
17
|
+
options = encoded.nil? ? {} : JSON.parse(Base64.strict_decode64(encoded))
|
|
18
|
+
unless options.is_a?(Hash) && (options.keys - %w[output root mode]).empty? && options.values.all?(String)
|
|
19
|
+
raise ArgumentError, 'source status options must contain only output, root and mode strings'
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
new(output_dir: options.fetch('output', ENV.fetch('WOODS_OUTPUT', 'tmp/woods')),
|
|
23
|
+
root: options['root'], mode: options.fetch('mode', 'quick')).call
|
|
24
|
+
rescue JSON::ParserError
|
|
25
|
+
raise ArgumentError, 'invalid source status options'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(output_dir:, payload_dir: nil, generation: nil, root: nil, mode: 'quick')
|
|
29
|
+
raise ArgumentError, 'source status mode must be quick or deep' unless BUDGETS.key?(mode)
|
|
30
|
+
|
|
31
|
+
@output = File.expand_path(output_dir.to_s)
|
|
32
|
+
@root = root
|
|
33
|
+
@mode = mode
|
|
34
|
+
@generation = generation
|
|
35
|
+
@payload = payload_dir
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call
|
|
39
|
+
return unknown(@resolution_error || 'atomic_source_manifest_unavailable') unless resolve_payload
|
|
40
|
+
|
|
41
|
+
flags = File::RDONLY | File::NONBLOCK
|
|
42
|
+
flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
|
|
43
|
+
manifest = File.open(File.join(@payload, Manifest::FILE_NAME), flags) do |file|
|
|
44
|
+
return unknown('invalid_source_manifest') unless file.stat.file?
|
|
45
|
+
return unknown('source_manifest_too_large') if file.stat.size > Manifest::MAX_BYTES
|
|
46
|
+
|
|
47
|
+
Manifest.parse(file.read(Manifest::MAX_BYTES + 1))
|
|
48
|
+
end
|
|
49
|
+
Verifier.new(manifest: manifest, output_dir: @output, root: @root,
|
|
50
|
+
generation: @generation, max_seconds: BUDGETS.fetch(@mode)).call.merge('check' => @mode)
|
|
51
|
+
rescue Errno::ENOENT
|
|
52
|
+
unknown('source_manifest_unavailable')
|
|
53
|
+
rescue Manifest::Invalid, SystemCallError, IOError
|
|
54
|
+
unknown('invalid_source_manifest')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def resolve_payload
|
|
60
|
+
generation = Generation.new(output_dir: @output)
|
|
61
|
+
marker = if @payload
|
|
62
|
+
Generation::Marker.new(payload: @payload.to_s)
|
|
63
|
+
else
|
|
64
|
+
generation.current.tap { |current| @generation = current.number }
|
|
65
|
+
end
|
|
66
|
+
return false unless marker.payload.is_a?(String)
|
|
67
|
+
|
|
68
|
+
resolved = generation.payload_dir(marker)
|
|
69
|
+
return false if resolved == generation.root
|
|
70
|
+
|
|
71
|
+
@payload = resolved
|
|
72
|
+
true
|
|
73
|
+
rescue TypeError, NoMethodError
|
|
74
|
+
@resolution_error = 'invalid_generation'
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def unknown(reason)
|
|
79
|
+
{ 'state' => 'unknown', 'mode' => 'content', 'check' => @mode, 'generation' => @generation,
|
|
80
|
+
'checked_at' => Time.now.utc.iso8601, 'complete' => false, 'reasons' => [reason] }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'woods/source_inputs/scanner'
|
|
5
|
+
require 'woods/source_inputs/manifest'
|
|
6
|
+
|
|
7
|
+
module Woods
|
|
8
|
+
module SourceInputs
|
|
9
|
+
# Bounded, byte-verifying source evidence. No stat-only result is called
|
|
10
|
+
# current: the same-size/same-mtime case is deliberately read and hashed.
|
|
11
|
+
class Verifier
|
|
12
|
+
DEFAULT_SECONDS = 0.25
|
|
13
|
+
SUMMARY_LIMIT = 30
|
|
14
|
+
|
|
15
|
+
# rubocop:disable Metrics/ParameterLists -- caller chooses identity, served generation and bounded scan
|
|
16
|
+
def initialize(manifest:, output_dir:, root: nil, generation: nil, max_seconds: DEFAULT_SECONDS, **limits)
|
|
17
|
+
@manifest = manifest.is_a?(Manifest) ? manifest : Manifest.new(manifest)
|
|
18
|
+
@output_dir = output_dir
|
|
19
|
+
@root = root || @manifest.data.fetch('root')
|
|
20
|
+
@generation = generation
|
|
21
|
+
@limits = limits.merge(max_seconds: max_seconds)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# rubocop:enable Metrics/ParameterLists
|
|
25
|
+
|
|
26
|
+
def call # rubocop:disable Metrics/AbcSize -- cheap refusal checks precede the only source scan
|
|
27
|
+
return unknown('generation_mismatch') if @generation && @generation != @manifest.data['generation']
|
|
28
|
+
return unknown('source_root_unavailable') unless File.directory?(@root)
|
|
29
|
+
|
|
30
|
+
key = PrivateKey.new(output_dir: @output_dir)
|
|
31
|
+
return unknown('identity_key_mismatch') unless key.identifier == @manifest.data.fetch('key_id')
|
|
32
|
+
|
|
33
|
+
scopes = Scopes.new(extra_roots: @manifest.data.fetch('extra_roots'))
|
|
34
|
+
return unknown('input_rules_changed') unless scopes.fingerprint == @manifest.data.fetch('rules')
|
|
35
|
+
|
|
36
|
+
current = Scanner.new(root: @root, output_dir: @output_dir, key: key, scopes: scopes, **@limits).call
|
|
37
|
+
compare(current)
|
|
38
|
+
rescue PrivateKey::Unavailable => e
|
|
39
|
+
unknown(e.message)
|
|
40
|
+
rescue SystemCallError, IOError
|
|
41
|
+
unknown('source_root_unavailable')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def compare(current)
|
|
47
|
+
previous = @manifest.expanded
|
|
48
|
+
changes = { 'added' => [], 'changed' => [], 'removed' => [] }
|
|
49
|
+
previous.each { |scope, paths| compare_scope(scope, paths, changes, current) }
|
|
50
|
+
add_new_scopes(previous, changes, current) if complete?(current)
|
|
51
|
+
reasons = coverage_reasons(current)
|
|
52
|
+
summarized(changes, reasons, current)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def complete?(current)
|
|
56
|
+
@manifest.data['complete'] && current['complete']
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def compare_scope(scope, paths, changes, current)
|
|
60
|
+
paths.each do |path, identity|
|
|
61
|
+
now = current.fetch('files')[path]
|
|
62
|
+
changes['changed'] << path if now && now != identity
|
|
63
|
+
changes['removed'] << path if now.nil? && current['complete']
|
|
64
|
+
end
|
|
65
|
+
return unless complete?(current)
|
|
66
|
+
|
|
67
|
+
changes['added'].concat(current.fetch('scope_paths').fetch(scope, []) - paths.keys)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def add_new_scopes(previous, changes, current)
|
|
71
|
+
new_scopes = current.fetch('scope_paths').keys - previous.keys
|
|
72
|
+
new_scopes.each { |scope| changes['added'].concat(current.fetch('scope_paths').fetch(scope)) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def coverage_reasons(current)
|
|
76
|
+
reasons = (@manifest.data.fetch('errors') + current.fetch('errors')).map { |error| error['reason'] }.compact
|
|
77
|
+
reasons << 'incomplete_capture' unless @manifest.data['complete']
|
|
78
|
+
reasons << 'incomplete_verification' unless current['complete']
|
|
79
|
+
reasons << 'unverified_boot_boundary' unless @manifest.data['boot_verified']
|
|
80
|
+
reasons << 'unverified_consumption_scopes' unless @manifest.data.fetch('unverified_scopes').empty?
|
|
81
|
+
reasons.uniq
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def summarized(changes, reasons, current)
|
|
85
|
+
changes.transform_values! { |paths| paths.uniq.sort }
|
|
86
|
+
counts = changes.transform_values(&:size)
|
|
87
|
+
{ 'state' => state_for(counts, reasons), 'mode' => 'content', 'generation' => @manifest.data['generation'],
|
|
88
|
+
'checked_at' => Time.now.utc.iso8601, 'reasons' => reasons,
|
|
89
|
+
'complete' => current['complete'] && @manifest.data['complete'],
|
|
90
|
+
'counts' => counts, 'truncated' => counts.values.any? { |count| count > SUMMARY_LIMIT },
|
|
91
|
+
'changes' => changes.transform_values { |paths| paths.first(SUMMARY_LIMIT) },
|
|
92
|
+
'metrics' => current.fetch('metrics') }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def state_for(counts, reasons)
|
|
96
|
+
return 'drifted' if counts.values.any?(&:positive?)
|
|
97
|
+
|
|
98
|
+
reasons.empty? ? 'current' : 'unknown'
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def unknown(reason)
|
|
102
|
+
{ 'state' => 'unknown', 'mode' => 'content', 'generation' => @manifest.data['generation'],
|
|
103
|
+
'checked_at' => Time.now.utc.iso8601, 'reasons' => [reason], 'complete' => false }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|