woods 2.0.0.beta2 → 2.0.0.beta4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +339 -1
- data/CONTRIBUTING.md +188 -12
- data/README.md +93 -174
- data/SECURITY.md +9 -6
- data/docs/AGENT_GUIDE.md +109 -8
- data/docs/AGENT_SETUP.md +98 -7
- data/docs/BACKEND_MATRIX.md +25 -0
- data/docs/CLIENT_HOOKS.md +111 -0
- data/docs/CONFIGURATION_REFERENCE.md +267 -16
- data/docs/CONSOLE_MCP_SETUP.md +80 -7
- data/docs/DOCKER_SETUP.md +22 -3
- data/docs/EVALUATION.md +464 -1
- data/docs/EXTRACTOR_REFERENCE.md +45 -6
- data/docs/FAQ.md +11 -12
- data/docs/GETTING_STARTED.md +17 -5
- data/docs/INCREMENTAL_EXTRACTION.md +147 -7
- data/docs/INDEX_LAYOUT.md +382 -0
- data/docs/INTERNALS.md +7 -2
- data/docs/MCP_SERVERS.md +276 -5
- data/docs/MCP_TOOL_COOKBOOK.md +37 -22
- data/docs/MCP_WORKTREE_SETUP.md +43 -83
- data/docs/NOTION_INTEGRATION.md +13 -0
- data/docs/OBSIDIAN_INTEGRATION.md +57 -9
- data/docs/PUBLISHED_INDEX.md +72 -0
- data/docs/README.md +7 -0
- data/docs/RETRIEVAL_GUIDE.md +273 -12
- data/docs/RUNTIME_TRACING.md +71 -0
- data/docs/SOURCE_FRESHNESS.md +143 -0
- data/docs/TROUBLESHOOTING.md +129 -18
- data/docs/UNBLOCKED_INTEGRATION.md +25 -0
- data/docs/UPGRADING_TO_2.md +48 -22
- data/docs/WATCH_DAEMON.md +277 -67
- data/exe/woods-agent-config +6 -0
- data/exe/woods-extract +5 -0
- data/exe/woods-hook-context +6 -0
- data/exe/woods-mcp-start +14 -9
- data/lib/generators/woods/pgvector_generator.rb +8 -2
- data/lib/generators/woods/templates/woods.rb.tt +1 -3
- data/lib/tasks/woods.rake +47 -397
- data/lib/woods/agent_configuration/applier.rb +135 -0
- data/lib/woods/agent_configuration/cli.rb +101 -0
- data/lib/woods/agent_configuration/cli_options.rb +29 -0
- data/lib/woods/agent_configuration/document.rb +105 -0
- data/lib/woods/agent_configuration/error.rb +7 -0
- data/lib/woods/agent_configuration/launcher.rb +75 -0
- data/lib/woods/agent_configuration/layout.rb +72 -0
- data/lib/woods/agent_configuration/managed_section.rb +62 -0
- data/lib/woods/agent_configuration/plan.rb +98 -0
- data/lib/woods/agent_configuration/plan_diff.rb +38 -0
- data/lib/woods/agent_configuration/planned_files.rb +61 -0
- data/lib/woods/agent_configuration/planner.rb +63 -0
- data/lib/woods/agent_configuration/planner_validation.rb +77 -0
- data/lib/woods/agent_configuration/preflight.rb +100 -0
- data/lib/woods/agent_configuration/recovery.rb +49 -0
- data/lib/woods/ast/node.rb +2 -0
- data/lib/woods/ast/parser.rb +38 -5
- data/lib/woods/builder.rb +21 -5
- data/lib/woods/cache/cache_middleware.rb +28 -7
- data/lib/woods/cache/cache_store.rb +4 -5
- data/lib/woods/change_set.rb +5 -4
- data/lib/woods/console/credential_index.rb +20 -2
- data/lib/woods/console/credential_scanner.rb +18 -17
- data/lib/woods/console/credential_scanner_registry.rb +36 -0
- data/lib/woods/console/dispatch_pipeline.rb +7 -0
- data/lib/woods/console/embedded_executor.rb +32 -10
- data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
- data/lib/woods/console/rack_middleware.rb +22 -13
- data/lib/woods/console/server.rb +18 -16
- data/lib/woods/console/sql_noise_stripper.rb +9 -7
- data/lib/woods/console/sql_table_scanner.rb +47 -7
- data/lib/woods/console/sql_validator.rb +49 -9
- data/lib/woods/console/sqlite_read_guard.rb +46 -0
- data/lib/woods/coordination/pipeline_lock.rb +3 -2
- data/lib/woods/dependency_graph.rb +65 -13
- data/lib/woods/embedding/corpus.rb +94 -0
- data/lib/woods/embedding/indexer.rb +114 -60
- data/lib/woods/embedding/openai.rb +17 -6
- data/lib/woods/evaluation/ablation_executor.rb +6 -1
- data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
- data/lib/woods/export/typed_reader.rb +56 -0
- data/lib/woods/extractor.rb +277 -149
- data/lib/woods/extractors/action_cable_extractor.rb +3 -1
- data/lib/woods/extractors/behavioral_profile.rb +9 -7
- data/lib/woods/extractors/caching_extractor.rb +3 -1
- data/lib/woods/extractors/concern_extractor.rb +64 -6
- data/lib/woods/extractors/configuration_extractor.rb +7 -3
- data/lib/woods/extractors/controller_extractor.rb +13 -4
- data/lib/woods/extractors/database_view_extractor.rb +3 -1
- data/lib/woods/extractors/declared_parent.rb +55 -0
- data/lib/woods/extractors/decorator_extractor.rb +3 -1
- data/lib/woods/extractors/engine_extractor.rb +3 -1
- data/lib/woods/extractors/event_extractor.rb +4 -2
- data/lib/woods/extractors/factory_extractor.rb +3 -1
- data/lib/woods/extractors/graphql_extractor.rb +10 -13
- data/lib/woods/extractors/i18n_extractor.rb +3 -1
- data/lib/woods/extractors/job_extractor.rb +6 -19
- data/lib/woods/extractors/lib_extractor.rb +13 -9
- data/lib/woods/extractors/mailer_extractor.rb +26 -15
- data/lib/woods/extractors/manager_extractor.rb +3 -1
- data/lib/woods/extractors/method_parameters.rb +53 -0
- data/lib/woods/extractors/middleware_argument.rb +65 -0
- data/lib/woods/extractors/middleware_extractor.rb +9 -3
- data/lib/woods/extractors/migration_extractor.rb +3 -1
- data/lib/woods/extractors/model_extractor.rb +26 -34
- data/lib/woods/extractors/package_extractor.rb +24 -4
- data/lib/woods/extractors/phlex_extractor.rb +3 -1
- data/lib/woods/extractors/policy_extractor.rb +3 -1
- data/lib/woods/extractors/poro_extractor.rb +13 -9
- data/lib/woods/extractors/pundit_extractor.rb +3 -1
- data/lib/woods/extractors/rails_source_extractor.rb +4 -2
- data/lib/woods/extractors/rake_task_extractor.rb +4 -2
- data/lib/woods/extractors/route_extractor.rb +3 -1
- data/lib/woods/extractors/route_helper_resolver.rb +10 -33
- data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
- data/lib/woods/extractors/serializer_extractor.rb +4 -2
- data/lib/woods/extractors/service_extractor.rb +3 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
- data/lib/woods/extractors/shared_utility_methods.rb +48 -19
- data/lib/woods/extractors/source_nesting.rb +1 -1
- data/lib/woods/extractors/state_machine_extractor.rb +3 -1
- data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
- data/lib/woods/extractors/validator_extractor.rb +3 -1
- data/lib/woods/extractors/view_component_extractor.rb +3 -1
- data/lib/woods/extractors/view_template_extractor.rb +3 -1
- data/lib/woods/gem_mapper.rb +2 -0
- data/lib/woods/git_history.rb +116 -0
- data/lib/woods/graph_analyzer.rb +35 -6
- data/lib/woods/hooks/context_cli.rb +54 -0
- data/lib/woods/hooks/context_event.rb +88 -0
- data/lib/woods/hooks/context_hint.rb +73 -0
- data/lib/woods/hooks/context_impact.rb +77 -0
- data/lib/woods/hooks/context_output.rb +47 -0
- data/lib/woods/hooks/context_state.rb +102 -0
- data/lib/woods/hooks/refresh.rb +79 -0
- data/lib/woods/hooks/rule_projection.rb +78 -0
- data/lib/woods/input_rules.rb +19 -0
- data/lib/woods/mcp/bearer_auth.rb +22 -13
- data/lib/woods/mcp/bootstrapper.rb +79 -4
- data/lib/woods/mcp/config_resolver.rb +2 -1
- data/lib/woods/mcp/index_reader.rb +334 -162
- data/lib/woods/mcp/initialization_guidance.rb +27 -0
- data/lib/woods/mcp/origin_guard.rb +17 -9
- data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +22 -9
- data/lib/woods/mcp/renderers/plain_renderer.rb +18 -8
- data/lib/woods/mcp/search_results.rb +74 -0
- data/lib/woods/mcp/server.rb +178 -63
- data/lib/woods/mcp/tool_contract.rb +3 -1
- data/lib/woods/mcp/tool_response_renderer.rb +41 -0
- data/lib/woods/mcp/traversal_evidence.rb +113 -0
- data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
- data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
- data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
- data/lib/woods/mcp/traversal_response.rb +22 -0
- data/lib/woods/notion/exporter.rb +56 -17
- data/lib/woods/obsidian/destination_plan.rb +98 -0
- data/lib/woods/obsidian/name_mapper.rb +19 -3
- data/lib/woods/obsidian/note_builder.rb +19 -10
- data/lib/woods/obsidian/vault_exporter.rb +88 -32
- data/lib/woods/operator/pipeline_guard.rb +18 -13
- data/lib/woods/path_dispatcher.rb +13 -6
- data/lib/woods/payload_store.rb +27 -26
- data/lib/woods/published_index/typed_unit_reader.rb +40 -3
- data/lib/woods/published_index.rb +2 -2
- data/lib/woods/railtie.rb +3 -3
- data/lib/woods/railtie_support.rb +12 -12
- data/lib/woods/rake_helpers.rb +382 -0
- data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
- data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
- data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
- data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
- data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
- data/lib/woods/resilience/index_validator.rb +112 -23
- data/lib/woods/retrieval/context_assembler.rb +50 -15
- data/lib/woods/retrieval/lexical_assembler.rb +84 -0
- data/lib/woods/retrieval/lexical_index.rb +120 -0
- data/lib/woods/retrieval/ranker.rb +4 -2
- data/lib/woods/retrieval/scope.rb +108 -0
- data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
- data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
- data/lib/woods/retrieval/search_executor.rb +86 -27
- data/lib/woods/retrieval/source_evidence.rb +200 -0
- data/lib/woods/retriever.rb +98 -22
- data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
- data/lib/woods/session_tracer/file_store.rb +6 -1
- data/lib/woods/session_tracer/middleware.rb +10 -12
- data/lib/woods/session_tracer/redis_store.rb +22 -6
- data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
- data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
- data/lib/woods/session_tracer/unit_resolver.rb +63 -0
- data/lib/woods/source_inputs/consumer_errors.rb +31 -0
- data/lib/woods/source_inputs/handoff.rb +102 -0
- data/lib/woods/source_inputs/launcher.rb +157 -0
- data/lib/woods/source_inputs/manifest.rb +124 -0
- data/lib/woods/source_inputs/private_key.rb +55 -0
- data/lib/woods/source_inputs/scanner.rb +171 -0
- data/lib/woods/source_inputs/scopes.rb +71 -0
- data/lib/woods/source_inputs/session.rb +214 -0
- data/lib/woods/source_inputs/status.rb +84 -0
- data/lib/woods/source_inputs/verifier.rb +107 -0
- data/lib/woods/storage/metadata_store.rb +25 -25
- data/lib/woods/storage/pgvector.rb +35 -10
- data/lib/woods/storage/qdrant.rb +17 -7
- data/lib/woods/storage/vector_store.rb +18 -6
- data/lib/woods/tasks.rb +3 -2
- data/lib/woods/temporal/json_snapshot_store.rb +58 -9
- data/lib/woods/unblocked/exporter.rb +59 -70
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/boot_snapshot.rb +52 -0
- data/lib/woods/watch/daemon.rb +154 -32
- data/lib/woods/watch/listen_watcher.rb +4 -0
- data/lib/woods/watch/polling_watcher.rb +5 -1
- data/lib/woods/watch/status.rb +20 -15
- data/lib/woods/watch/tree_scan.rb +21 -13
- data/lib/woods/watch/watcher.rb +4 -1
- data/lib/woods.rb +50 -11
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.jq +15 -0
- data/plugin/hooks/adapters/normalize.rb +63 -0
- data/plugin/hooks/hooks.json +20 -0
- data/plugin/hooks/woods-context.sh +50 -0
- data/plugin/hooks/woods-input-rules.sh +159 -0
- data/plugin/hooks/woods-opencode.mjs +65 -0
- data/plugin/hooks/woods-post-edit.sh +2 -225
- data/plugin/hooks/woods-refresh.sh +260 -0
- data/plugin/hooks/woods-session-start.sh +47 -55
- data/plugin/skills/woods-agent-enable/SKILL.md +19 -0
- data/plugin/skills/woods-diagnose/SKILL.md +319 -1
- data/plugin/skills/woods-investigate/SKILL.md +145 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +90 -2
- data/plugin/skills/woods-setup/SKILL.md +110 -6
- metadata +87 -5
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module SourceInputs
|
|
7
|
+
# Compact wire format: consumers refer to a shared identity table. Internal
|
|
8
|
+
# mutation uses expanded digests so table indices never leak across runs.
|
|
9
|
+
class Manifest
|
|
10
|
+
FILE_NAME = 'source_inputs.json'
|
|
11
|
+
VERSION = 1
|
|
12
|
+
MAX_BYTES = 16 * 1024 * 1024
|
|
13
|
+
class Invalid < StandardError; end
|
|
14
|
+
|
|
15
|
+
attr_reader :data
|
|
16
|
+
|
|
17
|
+
def self.parse(bytes)
|
|
18
|
+
raise Invalid, 'source_manifest_too_large' if bytes.bytesize > MAX_BYTES
|
|
19
|
+
|
|
20
|
+
new(JSON.parse(bytes))
|
|
21
|
+
rescue JSON::ParserError
|
|
22
|
+
raise Invalid, 'invalid_source_manifest'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(data)
|
|
26
|
+
@data = data
|
|
27
|
+
validate!
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def expanded
|
|
31
|
+
identities = @data.fetch('identities')
|
|
32
|
+
@data.fetch('scopes').transform_values do |paths|
|
|
33
|
+
paths.transform_values { |index| identities.fetch(index) }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Explicit wire fields keep incomplete coverage separate from boot provenance.
|
|
38
|
+
# rubocop:disable Metrics/AbcSize, Metrics/ParameterLists
|
|
39
|
+
def self.build(snapshot:, scopes:, boot_verified:, generation:, errors: [], unverified_scopes: [])
|
|
40
|
+
identities = scopes.values.flat_map(&:values).uniq.sort
|
|
41
|
+
indices = identities.each_with_index.to_h
|
|
42
|
+
new('version' => VERSION, 'generation' => generation, 'root' => snapshot.fetch('root'),
|
|
43
|
+
'key_id' => snapshot.fetch('key_id'), 'rules' => snapshot.fetch('rules'),
|
|
44
|
+
'extra_roots' => snapshot.fetch('extra_roots'), 'boot_verified' => boot_verified,
|
|
45
|
+
'complete' => snapshot.fetch('complete'), 'errors' => (snapshot.fetch('errors') + errors).uniq,
|
|
46
|
+
'unverified_scopes' => unverified_scopes.uniq.sort,
|
|
47
|
+
'identities' => identities, 'metrics' => snapshot.fetch('metrics'),
|
|
48
|
+
'scopes' => scopes.sort.to_h.transform_values do |paths|
|
|
49
|
+
paths.sort.to_h.transform_values { |digest| indices.fetch(digest) }
|
|
50
|
+
end)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# rubocop:enable Metrics/AbcSize, Metrics/ParameterLists
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def validate!
|
|
58
|
+
raise Invalid, 'unsupported_source_manifest' unless supported_version?
|
|
59
|
+
raise Invalid, 'invalid_source_manifest_header' unless valid_header?
|
|
60
|
+
|
|
61
|
+
validate_tables!
|
|
62
|
+
return if @data['extra_roots'].all? { |path| valid_path?(path) }
|
|
63
|
+
|
|
64
|
+
raise Invalid, 'invalid_source_roots'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_tables!
|
|
68
|
+
identities = @data['identities']
|
|
69
|
+
scopes = @data['scopes']
|
|
70
|
+
unless identities.is_a?(Array) && identities.all? { |digest| digest?(digest) } && scopes.is_a?(Hash)
|
|
71
|
+
raise Invalid, 'invalid_source_identity_table'
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
scopes.each { |scope, paths| validate_scope!(scope, paths, identities) }
|
|
75
|
+
raise Invalid, 'invalid_source_manifest_coverage' unless valid_coverage?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate_scope!(scope, paths, identities)
|
|
79
|
+
return if scope.is_a?(String) && paths.is_a?(Hash) &&
|
|
80
|
+
paths.all? { |path, index| valid_entry?(path, index, identities) }
|
|
81
|
+
|
|
82
|
+
raise Invalid, 'invalid_source_scope'
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def supported_version?
|
|
86
|
+
@data.is_a?(Hash) && @data['version'] == VERSION &&
|
|
87
|
+
@data['generation'].is_a?(Integer) && @data['generation'].positive?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def valid_header?
|
|
91
|
+
valid_root? && %w[key_id rules].all? { |key| digest?(@data[key]) } &&
|
|
92
|
+
%w[boot_verified complete].all? { |key| [true, false].include?(@data[key]) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def valid_root?
|
|
96
|
+
root = @data['root']
|
|
97
|
+
root.is_a?(String) && root.start_with?('/') && !root.include?("\0")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def valid_coverage?
|
|
101
|
+
@data['errors'].is_a?(Array) && @data['errors'].all? { |error| valid_error?(error) } &&
|
|
102
|
+
%w[extra_roots unverified_scopes].all? { |key| @data[key].is_a?(Array) && @data[key].all?(String) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def valid_error?(error)
|
|
106
|
+
error.is_a?(Hash) && error['reason'].is_a?(String) &&
|
|
107
|
+
(error['path'].nil? || error['path'].is_a?(String))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def valid_entry?(path, index, identities)
|
|
111
|
+
valid_path?(path) && index.is_a?(Integer) && index >= 0 && index < identities.size
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def valid_path?(path)
|
|
115
|
+
path.is_a?(String) && !path.empty? && !path.start_with?('/') && !path.include?("\0") &&
|
|
116
|
+
path.split('/', -1).none? { |part| ['', '.', '..'].include?(part) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def digest?(value)
|
|
120
|
+
value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
|
|
7
|
+
module Woods
|
|
8
|
+
module SourceInputs
|
|
9
|
+
# Never copied into a published generation. Public identities are keyed even
|
|
10
|
+
# for ordinary source, so an unrecognized secret-bearing file stays protected.
|
|
11
|
+
class PrivateKey
|
|
12
|
+
FILE_NAME = '.source-inputs.key'
|
|
13
|
+
class Unavailable < StandardError; end
|
|
14
|
+
|
|
15
|
+
attr_reader :bytes, :identifier
|
|
16
|
+
|
|
17
|
+
def initialize(output_dir:, create: false)
|
|
18
|
+
path = File.join(output_dir.to_s, FILE_NAME)
|
|
19
|
+
create_key(path) if create
|
|
20
|
+
flags = File::RDONLY | File::NONBLOCK
|
|
21
|
+
flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
|
|
22
|
+
File.open(path, flags) do |file|
|
|
23
|
+
stat = file.stat
|
|
24
|
+
raise Unavailable, 'insecure_identity_key' unless private_regular?(stat)
|
|
25
|
+
|
|
26
|
+
@bytes = file.read(33)
|
|
27
|
+
end
|
|
28
|
+
raise Unavailable, 'invalid_identity_key' unless @bytes&.bytesize == 32
|
|
29
|
+
|
|
30
|
+
@identifier = Digest::SHA256.hexdigest(@bytes)
|
|
31
|
+
rescue SystemCallError, IOError
|
|
32
|
+
raise Unavailable, 'identity_key_unavailable'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def private_regular?(stat)
|
|
38
|
+
stat.file? && stat.uid == Process.uid && stat.mode.nobits?(0o077)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def create_key(path)
|
|
42
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
43
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
44
|
+
file.binmode
|
|
45
|
+
file.write(SecureRandom.random_bytes(32))
|
|
46
|
+
file.flush
|
|
47
|
+
file.fsync
|
|
48
|
+
end
|
|
49
|
+
rescue Errno::EEXIST
|
|
50
|
+
# Existing permissions/content are validated, never repaired implicitly.
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -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
|