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
|
@@ -68,6 +68,7 @@ module Woods
|
|
|
68
68
|
MISSING_KEY_ERRORS = %w[
|
|
69
69
|
ActiveSupport::EncryptedConfiguration::MissingKeyError
|
|
70
70
|
ActiveSupport::EncryptedFile::MissingKeyError
|
|
71
|
+
ActiveSupport::EncryptedFile::MissingContentError
|
|
71
72
|
ActiveSupport::MessageEncryptor::InvalidMessage
|
|
72
73
|
].freeze
|
|
73
74
|
|
|
@@ -90,13 +91,19 @@ module Woods
|
|
|
90
91
|
# @return [CredentialIndex] populated index, or an empty index when
|
|
91
92
|
# the credentials store can't be opened.
|
|
92
93
|
def build(rails_app:)
|
|
93
|
-
|
|
94
|
+
refresh(rails_app: rails_app)
|
|
94
95
|
rescue StandardError => e
|
|
95
96
|
raise unless missing_key_error?(e)
|
|
96
97
|
|
|
97
98
|
new(secrets: [])
|
|
98
99
|
end
|
|
99
100
|
|
|
101
|
+
# Strict refresh: construct the replacement completely before a caller
|
|
102
|
+
# installs it. Unlike boot-time .build, errors must preserve the old index.
|
|
103
|
+
def refresh(rails_app:)
|
|
104
|
+
new(secrets: collect_secrets(rails_app))
|
|
105
|
+
end
|
|
106
|
+
|
|
100
107
|
# Emit a structured log warning when any of the given credentials
|
|
101
108
|
# files has a modification time newer than `process_start`. This
|
|
102
109
|
# catches the common "rotated credentials but forgot to restart"
|
|
@@ -136,12 +143,23 @@ module Woods
|
|
|
136
143
|
private
|
|
137
144
|
|
|
138
145
|
def collect_secrets(rails_app)
|
|
139
|
-
config = rails_app.credentials.config
|
|
146
|
+
config = fresh_credentials(rails_app.credentials).config
|
|
140
147
|
collected = []
|
|
141
148
|
walk(config, collected)
|
|
142
149
|
collected
|
|
143
150
|
end
|
|
144
151
|
|
|
152
|
+
def fresh_credentials(credentials)
|
|
153
|
+
return credentials unless defined?(ActiveSupport::EncryptedConfiguration) &&
|
|
154
|
+
credentials.is_a?(ActiveSupport::EncryptedConfiguration)
|
|
155
|
+
|
|
156
|
+
require_relative 'encrypted_credential_snapshot'
|
|
157
|
+
EncryptedCredentialSnapshot.new(
|
|
158
|
+
config_path: credentials.content_path, key_path: credentials.key_path,
|
|
159
|
+
env_key: credentials.env_key, raise_if_missing_key: true
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
145
163
|
def walk(node, collected)
|
|
146
164
|
case node
|
|
147
165
|
when Hash then node.each_value { |v| walk(v, collected) }
|
|
@@ -155,17 +155,17 @@ module Woods
|
|
|
155
155
|
# for patterns that fired — callers should treat a missing key as zero.
|
|
156
156
|
def scan(value)
|
|
157
157
|
counts = {}
|
|
158
|
-
scanned = walk(value, counts)
|
|
158
|
+
scanned = walk(value, counts, @secret_index)
|
|
159
159
|
[scanned, counts]
|
|
160
160
|
end
|
|
161
161
|
|
|
162
162
|
private
|
|
163
163
|
|
|
164
|
-
def walk(value, counts)
|
|
164
|
+
def walk(value, counts, index)
|
|
165
165
|
case value
|
|
166
|
-
when String then scan_string(value, counts)
|
|
167
|
-
when Hash then walk_hash(value, counts)
|
|
168
|
-
when Array then value.map { |item| walk(item, counts) }
|
|
166
|
+
when String then scan_string(value, counts, index)
|
|
167
|
+
when Hash then walk_hash(value, counts, index)
|
|
168
|
+
when Array then value.map { |item| walk(item, counts, index) }
|
|
169
169
|
else value
|
|
170
170
|
end
|
|
171
171
|
end
|
|
@@ -177,19 +177,19 @@ module Woods
|
|
|
177
177
|
# credential-shaped string is emitted as a Symbol after redaction
|
|
178
178
|
# (e.g. `:"[REDACTED]"`). String keys stay Strings. Non-String,
|
|
179
179
|
# non-Symbol keys (Integer, etc.) pass through unchanged.
|
|
180
|
-
def walk_hash(hash, counts)
|
|
180
|
+
def walk_hash(hash, counts, index)
|
|
181
181
|
hash.each_with_object({}) do |(key, val), out|
|
|
182
182
|
scanned_key = case key
|
|
183
|
-
when String then scan_string(key, counts)
|
|
184
|
-
when Symbol then scan_string(key.to_s, counts).to_sym
|
|
183
|
+
when String then scan_string(key, counts, index)
|
|
184
|
+
when Symbol then scan_string(key.to_s, counts, index).to_sym
|
|
185
185
|
else key
|
|
186
186
|
end
|
|
187
|
-
out[scanned_key] = walk(val, counts)
|
|
187
|
+
out[scanned_key] = walk(val, counts, index)
|
|
188
188
|
end
|
|
189
189
|
end
|
|
190
190
|
|
|
191
|
-
def scan_string(str, counts)
|
|
192
|
-
result = redact_indexed_secrets(str, counts)
|
|
191
|
+
def scan_string(str, counts, index)
|
|
192
|
+
result = redact_indexed_secrets(str, counts, index)
|
|
193
193
|
result = scan_encoded_forms(result, counts)
|
|
194
194
|
@active_patterns.inject(result) do |acc, (name, pattern)|
|
|
195
195
|
acc.gsub(pattern) do
|
|
@@ -290,10 +290,10 @@ module Woods
|
|
|
290
290
|
printable.to_f / str.length >= PRINTABLE_RATIO_THRESHOLD
|
|
291
291
|
end
|
|
292
292
|
|
|
293
|
-
def redact_indexed_secrets(str, counts)
|
|
294
|
-
return str unless
|
|
293
|
+
def redact_indexed_secrets(str, counts, index)
|
|
294
|
+
return str unless index&.match?(str)
|
|
295
295
|
|
|
296
|
-
redacted =
|
|
296
|
+
redacted = index.redact(str)
|
|
297
297
|
counts[INDEX_HIT] = (counts[INDEX_HIT] || 0) + redacted.scan(CredentialIndex::REDACTED).size
|
|
298
298
|
redacted
|
|
299
299
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module Console
|
|
5
|
+
# Coordinates scanner construction and rotation without retaining servers
|
|
6
|
+
# after their transports release them. Requests keep their own index snapshot.
|
|
7
|
+
class CredentialScannerRegistry
|
|
8
|
+
def initialize
|
|
9
|
+
@scanners = ObjectSpace::WeakMap.new
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def register
|
|
14
|
+
@mutex.synchronize do
|
|
15
|
+
scanner = yield
|
|
16
|
+
# Older Ruby WeakMap implementations validate the value's liveness
|
|
17
|
+
# when enumerating. Keep the scanner in both weak positions so an
|
|
18
|
+
# immortal sentinel cannot make an abandoned scanner look live.
|
|
19
|
+
@scanners[scanner] = scanner
|
|
20
|
+
scanner
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def rebuild
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
scanners = @scanners.values
|
|
27
|
+
return nil if scanners.empty?
|
|
28
|
+
|
|
29
|
+
index = yield
|
|
30
|
+
scanners.each { |scanner| scanner.replace_index!(index) }
|
|
31
|
+
index
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -1491,7 +1491,7 @@ module Woods
|
|
|
1491
1491
|
# `SqlNoiseStripper` is the same module SqlValidator uses. The
|
|
1492
1492
|
# combined single-pass strip_noise resolves comments and literals
|
|
1493
1493
|
# together so a comment marker inside a literal can't hide a keyword.
|
|
1494
|
-
stripped = SqlNoiseStripper.strip_noise(template)
|
|
1494
|
+
stripped = SqlNoiseStripper.strip_noise(template, dialect: sql_dialect || :postgres, **mysql_quote_modes)
|
|
1495
1495
|
if SCOPE_TEMPLATE_FORBIDDEN.match?(stripped)
|
|
1496
1496
|
raise ValidationError,
|
|
1497
1497
|
'scope template contains forbidden SQL keywords ' \
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/encrypted_configuration'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Console
|
|
7
|
+
# A private, uncached Rails configuration for one credential-index build.
|
|
8
|
+
# Unlike Rails' permissive configuration reader, a missing encrypted file
|
|
9
|
+
# must raise during an explicit refresh rather than erase the last index.
|
|
10
|
+
class EncryptedCredentialSnapshot < ActiveSupport::EncryptedConfiguration
|
|
11
|
+
def read
|
|
12
|
+
ActiveSupport::EncryptedFile.instance_method(:read).bind(self).call
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -81,17 +81,12 @@ module Woods
|
|
|
81
81
|
# for compatibility. Passing it fails closed because console_eval is unavailable.
|
|
82
82
|
# @param unsafe_eval_audit_log_path [String, Pathname, nil] Legacy option retained
|
|
83
83
|
# for compatibility. Passing it fails closed because console_eval is unavailable.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
@unsafe_eval_confirmation = unsafe_eval_confirmation
|
|
91
|
-
@unsafe_eval_audit_log_path = unsafe_eval_audit_log_path
|
|
92
|
-
@stateless = stateless
|
|
93
|
-
@mutex = Mutex.new
|
|
94
|
-
@transport = nil
|
|
84
|
+
# Rails 6.0 forwards middleware options as a positional hash on Ruby 3.
|
|
85
|
+
# Delegate to explicit keywords to preserve required/unknown option checks.
|
|
86
|
+
def initialize(app, options = {}, **keywords)
|
|
87
|
+
raise TypeError, 'middleware options must be a Hash' unless options.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
initialize_options(app, **options, **keywords)
|
|
95
90
|
end
|
|
96
91
|
|
|
97
92
|
# Rack interface — intercepts requests at the configured path.
|
|
@@ -99,7 +94,8 @@ module Woods
|
|
|
99
94
|
# The enabled flag is read from Woods.configuration on every request,
|
|
100
95
|
# never captured at construction: the railtie inserts this middleware
|
|
101
96
|
# before config/initializers have run (#183). While
|
|
102
|
-
# `console_mcp_enabled` is false (the default),
|
|
97
|
+
# `console_mcp_enabled` is false (the default), or HTTP is explicitly
|
|
98
|
+
# disabled with `console_mcp_http_enabled = false`, requests — even at the
|
|
103
99
|
# mounted path — pass through to the wrapped app unchanged, so a host
|
|
104
100
|
# that never opted in is completely unaffected. Requests at
|
|
105
101
|
# non-matching paths always pass through.
|
|
@@ -118,11 +114,24 @@ module Woods
|
|
|
118
114
|
|
|
119
115
|
private
|
|
120
116
|
|
|
117
|
+
def initialize_options(app, path: '/mcp/console', embedded_read_tools: false, # rubocop:disable Metrics/ParameterLists
|
|
118
|
+
unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil,
|
|
119
|
+
stateless: true)
|
|
120
|
+
@app = app
|
|
121
|
+
@path = path
|
|
122
|
+
@embedded_read_tools = embedded_read_tools
|
|
123
|
+
@unsafe_eval_confirmation = unsafe_eval_confirmation
|
|
124
|
+
@unsafe_eval_audit_log_path = unsafe_eval_audit_log_path
|
|
125
|
+
@stateless = stateless
|
|
126
|
+
@mutex = Mutex.new
|
|
127
|
+
@transport = nil
|
|
128
|
+
end
|
|
129
|
+
|
|
121
130
|
# Whether the console is enabled, read from the live configuration on
|
|
122
131
|
# every request. Nil-safe: the railtie mounts this middleware in every
|
|
123
132
|
# app (#183), including hosts that never called Woods.configure.
|
|
124
133
|
def enabled?
|
|
125
|
-
Woods.configuration&.console_mcp_enabled
|
|
134
|
+
Woods.configuration&.console_mcp_enabled && Woods.configuration.console_mcp_http_enabled
|
|
126
135
|
end
|
|
127
136
|
|
|
128
137
|
# Thread-safe lazy initialization of the MCP server and transport.
|
data/lib/woods/console/server.rb
CHANGED
|
@@ -14,6 +14,7 @@ require_relative 'audit_logger'
|
|
|
14
14
|
require_relative 'confirmation'
|
|
15
15
|
require_relative 'console_response_renderer'
|
|
16
16
|
require_relative 'credential_scanner'
|
|
17
|
+
require_relative 'credential_scanner_registry'
|
|
17
18
|
require_relative 'eval_guard'
|
|
18
19
|
require_relative 'table_gate'
|
|
19
20
|
require_relative 'redactor'
|
|
@@ -44,10 +45,14 @@ module Woods
|
|
|
44
45
|
# transport.open
|
|
45
46
|
#
|
|
46
47
|
module Server # rubocop:disable Metrics/ModuleLength
|
|
48
|
+
SCANNERS = CredentialScannerRegistry.new
|
|
49
|
+
private_constant :SCANNERS
|
|
50
|
+
|
|
47
51
|
class << self # rubocop:disable Metrics/ClassLength
|
|
48
52
|
# Rebuild the boot-time credential index from fresh Rails credentials
|
|
49
|
-
# and hot-swap it into
|
|
53
|
+
# and hot-swap it into every live scanner without restarting the process.
|
|
50
54
|
#
|
|
55
|
+
# Refresh failures propagate without replacing any live index.
|
|
51
56
|
# Host rotation jobs should call this immediately after `rails credentials:edit`
|
|
52
57
|
# changes are deployed. The swap is atomic on MRI (GVL) — in-flight scans see
|
|
53
58
|
# either the old or the new index, never a partial one.
|
|
@@ -62,14 +67,11 @@ module Woods
|
|
|
62
67
|
# the rebuild was skipped.
|
|
63
68
|
def rebuild_credential_index(rails_app: nil)
|
|
64
69
|
return nil unless credential_defense_enabled?
|
|
65
|
-
return nil unless @active_scanner
|
|
66
70
|
|
|
67
71
|
target_app = rails_app || default_rails_app
|
|
68
72
|
return nil unless target_app
|
|
69
73
|
|
|
70
|
-
|
|
71
|
-
@active_scanner.replace_index!(new_index)
|
|
72
|
-
new_index
|
|
74
|
+
SCANNERS.rebuild { CredentialIndex.refresh(rails_app: target_app) }
|
|
73
75
|
end
|
|
74
76
|
|
|
75
77
|
# True when Woods is configured and credential defense is on.
|
|
@@ -288,21 +290,21 @@ module Woods
|
|
|
288
290
|
model_reflections: model_reflections)
|
|
289
291
|
end
|
|
290
292
|
|
|
291
|
-
|
|
292
|
-
disabled_patterns = Array(config&.console_disabled_scanner_patterns)
|
|
293
|
-
scanner = if disabled_patterns.include?(:all)
|
|
294
|
-
nil
|
|
295
|
-
else
|
|
296
|
-
CredentialScanner.new(
|
|
297
|
-
disabled_patterns: disabled_patterns,
|
|
298
|
-
secret_index: secret_index
|
|
299
|
-
)
|
|
300
|
-
end
|
|
301
|
-
@active_scanner = scanner
|
|
293
|
+
scanner = build_scanner(config)
|
|
302
294
|
|
|
303
295
|
ResponseContext.build(safe_ctx: safe_ctx, table_gate: table_gate, credential_scanner: scanner)
|
|
304
296
|
end
|
|
305
297
|
|
|
298
|
+
def build_scanner(config)
|
|
299
|
+
disabled_patterns = Array(config&.console_disabled_scanner_patterns)
|
|
300
|
+
return nil if disabled_patterns.include?(:all)
|
|
301
|
+
|
|
302
|
+
SCANNERS.register do
|
|
303
|
+
CredentialScanner.new(disabled_patterns: disabled_patterns,
|
|
304
|
+
secret_index: build_credential_index(config))
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
306
308
|
# Build the boot-time credential index from Rails.application's encrypted
|
|
307
309
|
# credentials. Returns nil when credential defense is disabled or when no
|
|
308
310
|
# Rails application is reachable (specs, non-Rails hosts) — the scanner
|
|
@@ -32,9 +32,14 @@ module Woods
|
|
|
32
32
|
# enforce_dependencies: package units (Task 7)
|
|
33
33
|
# commit_count, change_frequency: git enrichment (Task 5)
|
|
34
34
|
NODE_ATTRIBUTE_KEYS = %i[
|
|
35
|
-
database table foreign_key_tables package enforce_dependencies commit_count change_frequency
|
|
35
|
+
database table foreign_key_tables package enforce_dependencies commit_count change_frequency kind
|
|
36
36
|
].freeze
|
|
37
37
|
|
|
38
|
+
# These extractors describe a whole source file rather than one constant.
|
|
39
|
+
# Other types remain unclassified: a path-like identifier is not evidence
|
|
40
|
+
# that a unit is a profile, and an unmarked unit need not name a constant.
|
|
41
|
+
FILE_PROFILE_TYPES = %i[caching configuration test_mapping rails_source gem_source].freeze
|
|
42
|
+
|
|
38
43
|
# Optional edge keys beyond target and via. `through` is the through
|
|
39
44
|
# association name; `through_db` is the through model's resolved
|
|
40
45
|
# database, emitted only when present; `disable_joins` is emitted only
|
|
@@ -80,14 +85,14 @@ module Woods
|
|
|
80
85
|
}.merge(node_attributes_from(unit))
|
|
81
86
|
|
|
82
87
|
(@edges[unit.identifier] ||= {})[unit.type] =
|
|
83
|
-
unit.dependencies
|
|
88
|
+
self.class.normalize_edges(unit.dependencies, strict: true)
|
|
84
89
|
(@file_map[unit.file_path] ||= Set.new).add(unit.identifier) if unit.file_path
|
|
85
90
|
|
|
86
91
|
# Type index for filtering (Set-based for O(1) insert)
|
|
87
92
|
(@type_index[unit.type] ||= Set.new).add(unit.identifier)
|
|
88
93
|
|
|
89
94
|
# Build reverse edges (Set-based for O(1) insert)
|
|
90
|
-
unit.
|
|
95
|
+
@edges[unit.identifier][unit.type].each do |dep|
|
|
91
96
|
(@reverse[dep[:target]] ||= Set.new).add(unit.identifier)
|
|
92
97
|
(@reverse_via[[dep[:target], dep[:via]]] ||= Set.new).add(unit.identifier)
|
|
93
98
|
end
|
|
@@ -645,7 +650,7 @@ module Woods
|
|
|
645
650
|
# @return [Hash{Symbol => Object}] normalized, nil-free
|
|
646
651
|
def node_attributes_from(unit)
|
|
647
652
|
metadata = unit.respond_to?(:metadata) && unit.metadata.is_a?(Hash) ? unit.metadata : {}
|
|
648
|
-
attrs =
|
|
653
|
+
attrs = self.class.file_profile_attributes(unit.type)
|
|
649
654
|
attrs[:database] = metadata[:database] unless metadata[:database].nil?
|
|
650
655
|
attrs[:table] = metadata[:table_name] if unit.type == :model && !metadata[:table_name].nil?
|
|
651
656
|
tables = Array(metadata[:foreign_keys]).filter_map { |fk| fk[:to_table] || fk['to_table'] if fk.is_a?(Hash) }
|
|
@@ -729,14 +734,15 @@ module Woods
|
|
|
729
734
|
# `nodes` and `edges` stay keyed on the bare identifier, holding the
|
|
730
735
|
# **primary** node — the type that sorts first. Identifiers registered
|
|
731
736
|
# under more than one type put their remaining nodes in `variants`, a flat
|
|
732
|
-
# array that is **omitted entirely when empty**.
|
|
733
|
-
#
|
|
734
|
-
# a `dependency_graph.json` written before this change loads through
|
|
737
|
+
# array that is **omitted entirely when empty**. A
|
|
738
|
+
# `dependency_graph.json` written before this change loads through
|
|
735
739
|
# {.from_h} unmodified: it simply carries no `variants`.
|
|
736
740
|
#
|
|
737
741
|
# `reverse`, `file_map` and `type_index` need no new shape. They are
|
|
738
742
|
# already identifier-valued sets, so a Scenic view `reports` and a factory
|
|
739
743
|
# `reports` each contribute to their own type bucket and their own path.
|
|
744
|
+
# The additive `reverse_via` index preserves typed source identities and
|
|
745
|
+
# complete relationship records without changing the legacy `reverse` map.
|
|
740
746
|
#
|
|
741
747
|
# @return [Hash] Complete graph data
|
|
742
748
|
def to_h
|
|
@@ -747,6 +753,7 @@ module Woods
|
|
|
747
753
|
nodes: key_sorted(self.class.relativize_nodes(primary_nodes, root)),
|
|
748
754
|
edges: key_sorted(primary_edges),
|
|
749
755
|
reverse: key_sorted(@reverse.transform_values { |ids| ids.to_a.sort }),
|
|
756
|
+
reverse_via: published_reverse_via,
|
|
750
757
|
file_map: key_sorted(self.class.relativize_file_map(@file_map, root)),
|
|
751
758
|
type_index: key_sorted(@type_index.transform_values { |ids| ids.to_a.sort }),
|
|
752
759
|
stats: {
|
|
@@ -770,6 +777,23 @@ module Woods
|
|
|
770
777
|
end
|
|
771
778
|
private :key_sorted
|
|
772
779
|
|
|
780
|
+
# Reverse buckets are derived from every typed forward registration, rather
|
|
781
|
+
# than the bare-name in-memory filter index, which cannot preserve variants
|
|
782
|
+
# or distinguish association attributes. Legacy nil relationships stay nil.
|
|
783
|
+
def published_reverse_via
|
|
784
|
+
buckets = {}
|
|
785
|
+
@edges.each do |source, by_type|
|
|
786
|
+
by_type.each do |type, edges|
|
|
787
|
+
edges.each do |edge|
|
|
788
|
+
record = { source: source, source_type: type }.merge(edge.except(:target))
|
|
789
|
+
(buckets[edge[:target]] ||= []) << record
|
|
790
|
+
end
|
|
791
|
+
end
|
|
792
|
+
end
|
|
793
|
+
key_sorted(buckets.transform_values { |records| records.sort_by { |record| JSON.generate(record) } })
|
|
794
|
+
end
|
|
795
|
+
private :published_reverse_via
|
|
796
|
+
|
|
773
797
|
# A copy whose edge containers are the caller's alone.
|
|
774
798
|
#
|
|
775
799
|
# `dup` copies only the top level, so `to_h[:edges][id]` used to be the
|
|
@@ -783,6 +807,9 @@ module Woods
|
|
|
783
807
|
def detached_snapshot(memo)
|
|
784
808
|
snapshot = memo.dup
|
|
785
809
|
snapshot[:edges] = memo[:edges].transform_values { |list| list.map(&:dup) }
|
|
810
|
+
snapshot[:reverse_via] = memo[:reverse_via].transform_values do |records|
|
|
811
|
+
records.map { |record| record.transform_values { |value| value.is_a?(String) ? value.dup : value } }
|
|
812
|
+
end
|
|
786
813
|
if memo.key?(:variants)
|
|
787
814
|
snapshot[:variants] = memo[:variants].map do |record|
|
|
788
815
|
record.merge(edges: record[:edges].map(&:dup))
|
|
@@ -794,7 +821,12 @@ module Woods
|
|
|
794
821
|
|
|
795
822
|
# @return [Hash{String => Hash}] identifier => primary node
|
|
796
823
|
def primary_nodes
|
|
797
|
-
|
|
824
|
+
# Late enrichment and JSON loading can insert optional fields in a
|
|
825
|
+
# different order. Canonicalize like variant_records so a graph's JSON
|
|
826
|
+
# fingerprint is stable across full and incremental publication.
|
|
827
|
+
@nodes.transform_values do |nodes|
|
|
828
|
+
primary_of(nodes).slice(:type, :file_path, :namespace, *NODE_ATTRIBUTE_KEYS)
|
|
829
|
+
end
|
|
798
830
|
end
|
|
799
831
|
private :primary_nodes
|
|
800
832
|
|
|
@@ -969,10 +1001,17 @@ module Woods
|
|
|
969
1001
|
# @param node [Hash]
|
|
970
1002
|
# @return [Hash{Symbol => Object}]
|
|
971
1003
|
def self.persisted_node_attributes(node)
|
|
972
|
-
NODE_ATTRIBUTE_KEYS.each_with_object({}) do |key, attrs|
|
|
1004
|
+
attributes = NODE_ATTRIBUTE_KEYS.each_with_object({}) do |key, attrs|
|
|
973
1005
|
value = node.key?(key) ? node[key] : node[key.to_s]
|
|
974
1006
|
attrs[key] = normalize_node_attribute(key, value) unless value.nil?
|
|
975
1007
|
end
|
|
1008
|
+
attributes.merge(file_profile_attributes(node[:type] || node['type']))
|
|
1009
|
+
end
|
|
1010
|
+
|
|
1011
|
+
# Backfill the additive marker when an older graph is republished, so
|
|
1012
|
+
# unchanged nodes in an incremental run agree with a fresh full graph.
|
|
1013
|
+
def self.file_profile_attributes(type)
|
|
1014
|
+
FILE_PROFILE_TYPES.include?(type&.to_sym) ? { kind: 'file_profile' } : {}
|
|
976
1015
|
end
|
|
977
1016
|
|
|
978
1017
|
# Edge attributes present in a dependency or persisted edge hash.
|
|
@@ -1032,7 +1071,10 @@ module Woods
|
|
|
1032
1071
|
graph.instance_variable_set(:@edges, edges)
|
|
1033
1072
|
|
|
1034
1073
|
raw_reverse = data[:reverse] || data['reverse'] || {}
|
|
1035
|
-
|
|
1074
|
+
reverse = raw_reverse.each_with_object({}) do |(target, sources), index|
|
|
1075
|
+
(index[target.to_s] ||= Set.new).merge(sources)
|
|
1076
|
+
end
|
|
1077
|
+
graph.instance_variable_set(:@reverse, reverse)
|
|
1036
1078
|
|
|
1037
1079
|
raw_file_map = data[:file_map] || data['file_map'] || {}
|
|
1038
1080
|
graph.instance_variable_set(:@file_map, absolutize_file_map(normalize_file_map(raw_file_map), root))
|
|
@@ -1126,7 +1168,12 @@ module Woods
|
|
|
1126
1168
|
}.merge(persisted_node_attributes(node))
|
|
1127
1169
|
end
|
|
1128
1170
|
|
|
1129
|
-
# Normalize
|
|
1171
|
+
# Normalize fresh dependencies and persisted edges to the same identity:
|
|
1172
|
+
# string targets and symbol relationship labels. Extractors may emit
|
|
1173
|
+
# symbolic external targets (such as :http_api); retaining those symbols
|
|
1174
|
+
# after a JSON-loaded baseline splits reverse indexes on re-registration.
|
|
1175
|
+
# Unit dependency metadata stays unchanged; only graph records normalize.
|
|
1176
|
+
# Also accepts the old bare-string edge format.
|
|
1130
1177
|
#
|
|
1131
1178
|
# ROUND-TRIP INVARIANT (do not break when refactoring):
|
|
1132
1179
|
# DependencyGraph#to_h -> JSON.generate -> JSON.parse -> DependencyGraph.from_h
|
|
@@ -1147,15 +1194,20 @@ module Woods
|
|
|
1147
1194
|
# explicit data conversion.
|
|
1148
1195
|
#
|
|
1149
1196
|
# @param edges [Array] Edge entries — either strings or hashes
|
|
1197
|
+
# @param strict [Boolean] require hashes for fresh units; legacy loads stay tolerant
|
|
1150
1198
|
# @return [Array<Hash>] Normalized edges with :target and :via keys
|
|
1151
|
-
def self.normalize_edges(edges)
|
|
1199
|
+
def self.normalize_edges(edges, strict: false)
|
|
1200
|
+
raise ArgumentError, 'Fresh graph dependencies must be an array' if strict && !edges.is_a?(Array)
|
|
1201
|
+
|
|
1152
1202
|
return [] unless edges.is_a?(Array)
|
|
1153
1203
|
|
|
1154
1204
|
edges.map do |edge|
|
|
1205
|
+
raise ArgumentError, 'Fresh graph dependencies must be hashes' if strict && !edge.is_a?(Hash)
|
|
1206
|
+
|
|
1155
1207
|
if edge.is_a?(String)
|
|
1156
1208
|
{ target: edge, via: nil }
|
|
1157
1209
|
elsif edge.is_a?(Hash)
|
|
1158
|
-
{ target: edge[:target] || edge['target'], via: (edge[:via] || edge['via'])&.to_sym }
|
|
1210
|
+
{ target: (edge[:target] || edge['target'])&.to_s, via: (edge[:via] || edge['via'])&.to_sym }
|
|
1159
1211
|
.merge(edge_attributes(edge))
|
|
1160
1212
|
else
|
|
1161
1213
|
{ target: edge.to_s, via: nil }
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../atomic_file'
|
|
5
|
+
require_relative '../generation'
|
|
6
|
+
|
|
7
|
+
module Woods
|
|
8
|
+
class Error < StandardError; end unless defined?(Woods::Error)
|
|
9
|
+
|
|
10
|
+
module Embedding
|
|
11
|
+
# Collect the complete extraction input before an embedding run can mutate
|
|
12
|
+
# stores. Native publications use their authoritative listings, never a glob
|
|
13
|
+
# that could mistake a missing/corrupt payload for intentional deletion.
|
|
14
|
+
class Corpus
|
|
15
|
+
class Incomplete < Woods::Error; end
|
|
16
|
+
|
|
17
|
+
def initialize(output_dir)
|
|
18
|
+
@output_dir = output_dir.to_s
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def load
|
|
22
|
+
native? ? published_units : legacy_units
|
|
23
|
+
rescue IOError, SystemCallError, JSON::ParserError, EncodingError, ArgumentError => e
|
|
24
|
+
raise Incomplete, "Embedding input incomplete: #{e.message}. Repair or rebuild extraction before embedding."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def native?
|
|
30
|
+
path = File.join(@output_dir, Generation::FILENAME)
|
|
31
|
+
if File.exist?(path)
|
|
32
|
+
marker = JSON.parse(AtomicFile.read(path))
|
|
33
|
+
raise IOError, 'invalid generation marker' unless marker.is_a?(Hash)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if !marker || marker['payload'].nil?
|
|
37
|
+
if File.directory?(File.join(@output_dir, 'payloads'))
|
|
38
|
+
raise IOError, 'missing publication pointer beside payloads'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
return false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
validate_marker(marker)
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_marker(marker)
|
|
49
|
+
strings_valid = %w[payload token].all? { |key| marker[key].is_a?(String) && !marker[key].empty? }
|
|
50
|
+
return if strings_valid && marker['number'].is_a?(Integer) && marker['number'].positive?
|
|
51
|
+
|
|
52
|
+
raise IOError, 'invalid published generation marker'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def published_units
|
|
56
|
+
require_relative '../mcp/index_reader'
|
|
57
|
+
|
|
58
|
+
reader = MCP::IndexReader.new(@output_dir)
|
|
59
|
+
reader.with_pinned_generation do
|
|
60
|
+
# Generation's compatibility fallback to the root is useful for
|
|
61
|
+
# readers, but cannot prove completeness for destructive reconciliation.
|
|
62
|
+
if reader.payload_dir.expand_path == Pathname.new(@output_dir).expand_path
|
|
63
|
+
raise IOError,
|
|
64
|
+
'published payload missing or invalid'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
validate_counts(reader.manifest)
|
|
68
|
+
reader.each_unit.to_a
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate_counts(manifest)
|
|
73
|
+
counts = manifest.is_a?(Hash) && manifest['counts']
|
|
74
|
+
valid = counts.is_a?(Hash) && counts.all? do |dir, count|
|
|
75
|
+
MCP::IndexReader::TYPE_DIRS.include?(dir) && count.is_a?(Integer) && count >= 0
|
|
76
|
+
end
|
|
77
|
+
raise IOError, 'invalid published manifest counts' unless valid
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Pre-pointer indexes may use arbitrary filenames and have no manifest
|
|
81
|
+
# or authoritative listing. Keep that input contract, but never silently
|
|
82
|
+
# omit unreadable JSON before reconciling persisted identities.
|
|
83
|
+
def legacy_units
|
|
84
|
+
Dir.glob(File.join(@output_dir, '**', '*.json')).filter_map do |path|
|
|
85
|
+
relative = path.delete_prefix("#{@output_dir}/")
|
|
86
|
+
next if relative.start_with?('dumps/', 'payloads/') || File.basename(path) == 'checkpoint.json'
|
|
87
|
+
|
|
88
|
+
data = JSON.parse(AtomicFile.read(path))
|
|
89
|
+
data if data.is_a?(Hash) && data.key?('type') && data.key?('identifier')
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|