woods 1.6.0 → 2.0.0.beta1
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 +1908 -0
- data/CONTRIBUTING.md +202 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +620 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +415 -0
- data/docs/INTERNALS.md +415 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +197 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +40 -1
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2509 -189
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +63 -21
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +138 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +408 -34
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +223 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +53 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- data/lib/woods/observability/instrumentation.rb +0 -34
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Checks
|
|
7
|
+
# Public methods that look like they moved from one unit to another
|
|
8
|
+
# between two generations while their test coverage did not follow
|
|
9
|
+
# (#280, 2026-09-09 ruling).
|
|
10
|
+
#
|
|
11
|
+
# A refactor that pulls `total` out of `Checkout` into `Pricing` keeps
|
|
12
|
+
# `spec/checkout_spec.rb` green only until the old call site goes; if
|
|
13
|
+
# nothing covers `Pricing`, the moved message is now untested. The check
|
|
14
|
+
# is a set difference over method lists in two {Woods::PublishedIndex}
|
|
15
|
+
# generations, joined with `:test_coverage` edges from the same
|
|
16
|
+
# generations.
|
|
17
|
+
#
|
|
18
|
+
# Each row is a **candidate** move into a unit without mapped tests, not
|
|
19
|
+
# a proven coverage loss: matching is by name and kind alone, so two
|
|
20
|
+
# unrelated methods that happen to share both are indistinguishable from
|
|
21
|
+
# a real move. This is a heuristic by design (see `docs/PUBLISHED_INDEX.md`
|
|
22
|
+
# and the rake task's `desc`), and is reported only when the source unit
|
|
23
|
+
# was covered before and the destination unit is not covered after; a
|
|
24
|
+
# method that was never covered is not a regression this check owns.
|
|
25
|
+
#
|
|
26
|
+
# Consumes anything shaped like {Woods::PublishedIndex}: `#units`,
|
|
27
|
+
# `#unit(identifier)`, and `#edges(via:)`. It never opens an index
|
|
28
|
+
# itself, so it needs no Rails and takes no retention lock of its own.
|
|
29
|
+
#
|
|
30
|
+
# @example
|
|
31
|
+
# before = Woods::PublishedIndex.new(dir, generation: 41)
|
|
32
|
+
# after = Woods::PublishedIndex.new(dir, generation: 42)
|
|
33
|
+
# Woods::Checks::MovedMessages.new(before: before, after: after).run
|
|
34
|
+
# # => [#<Finding method="total" kind=:instance from_unit="Checkout" ...>]
|
|
35
|
+
#
|
|
36
|
+
class MovedMessages
|
|
37
|
+
# Metadata keys extractors use for method lists (see
|
|
38
|
+
# `docs/PUBLISHED_INDEX.md`'s "Moved-message check" section for which
|
|
39
|
+
# extractor writes which key).
|
|
40
|
+
METHOD_KEYS = %w[public_methods instance_methods class_methods].freeze
|
|
41
|
+
|
|
42
|
+
COVERAGE_VIA = 'test_coverage'
|
|
43
|
+
|
|
44
|
+
# @!attribute method
|
|
45
|
+
# @return [String] the method name, `self.`-prefix stripped
|
|
46
|
+
# @!attribute kind
|
|
47
|
+
# @return [Symbol] `:instance` or `:class`
|
|
48
|
+
# @!attribute from_unit
|
|
49
|
+
# @return [String] identifier that lost the method
|
|
50
|
+
# @!attribute to_unit
|
|
51
|
+
# @return [String] identifier that gained the method
|
|
52
|
+
# @!attribute covered_before
|
|
53
|
+
# @return [Boolean] whether `from_unit` had a `:test_coverage` edge before
|
|
54
|
+
# @!attribute covered_after
|
|
55
|
+
# @return [Boolean] whether `to_unit` has a `:test_coverage` edge after
|
|
56
|
+
# rubocop:disable Lint/StructNewOverride -- :method is the field name docs and callers already use
|
|
57
|
+
Finding = Struct.new(:method, :kind, :from_unit, :to_unit, :covered_before, :covered_after, keyword_init: true) do
|
|
58
|
+
# rubocop:enable Lint/StructNewOverride
|
|
59
|
+
# @return [Hash] symbol-keyed, ready for JSON
|
|
60
|
+
def to_h
|
|
61
|
+
{ method: method, kind: kind, from_unit: from_unit, to_unit: to_unit,
|
|
62
|
+
covered_before: covered_before, covered_after: covered_after }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @param before [Woods::PublishedIndex] the older generation
|
|
67
|
+
# @param after [Woods::PublishedIndex] the newer generation
|
|
68
|
+
def initialize(before:, after:)
|
|
69
|
+
@before = before
|
|
70
|
+
@after = after
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Array<Finding>] sorted by method, then kind, then source, then destination
|
|
74
|
+
def run
|
|
75
|
+
removed, added = method_moves
|
|
76
|
+
covered_before = covered_units(@before)
|
|
77
|
+
covered_after = covered_units(@after)
|
|
78
|
+
|
|
79
|
+
removed.keys.sort_by { |name, kind| [name, kind.to_s] }.flat_map do |signature|
|
|
80
|
+
destinations = added[signature]
|
|
81
|
+
next [] unless destinations
|
|
82
|
+
|
|
83
|
+
build_findings(signature, removed[signature], destinations, covered_before, covered_after)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# @param signature [Array(String, Symbol)] `[method_name, kind]`
|
|
90
|
+
# @param sources [Array<Array(String, String)>] `[identifier, type]` units that lost it
|
|
91
|
+
# @param destinations [Array<Array(String, String)>] `[identifier, type]` units that gained it
|
|
92
|
+
# @param covered_before [Set<String>]
|
|
93
|
+
# @param covered_after [Set<String>]
|
|
94
|
+
# @return [Array<Finding>]
|
|
95
|
+
def build_findings(signature, sources, destinations, covered_before, covered_after)
|
|
96
|
+
name, kind = signature
|
|
97
|
+
|
|
98
|
+
sources.sort.product(destinations.sort).filter_map do |(from_unit, _from_type), (to_unit, _to_type)|
|
|
99
|
+
next if from_unit == to_unit
|
|
100
|
+
|
|
101
|
+
finding = Finding.new(method: name, kind: kind, from_unit: from_unit, to_unit: to_unit,
|
|
102
|
+
covered_before: covered_before.include?(from_unit),
|
|
103
|
+
covered_after: covered_after.include?(to_unit))
|
|
104
|
+
finding if finding.covered_before && !finding.covered_after
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Array(Hash, Hash)] `[method_kind, unit] => units that lost/gained it`
|
|
109
|
+
def method_moves
|
|
110
|
+
before_methods = method_index(@before)
|
|
111
|
+
after_methods = method_index(@after)
|
|
112
|
+
[signature_losses(before_methods, after_methods), signature_losses(after_methods, before_methods)]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Signatures a `[identifier, type]` unit has in +from+ but not in +to+,
|
|
116
|
+
# grouped by signature. "Removed" and "added" are the same computation
|
|
117
|
+
# with the two method-index hashes swapped, so both come from this one
|
|
118
|
+
# method.
|
|
119
|
+
#
|
|
120
|
+
# @param from [Hash{Array(String, String) => Set}]
|
|
121
|
+
# @param to [Hash{Array(String, String) => Set}]
|
|
122
|
+
# @return [Hash{Array(String, Symbol) => Array<Array(String, String)>}]
|
|
123
|
+
def signature_losses(from, to)
|
|
124
|
+
losses = Hash.new { |hash, key| hash[key] = [] }
|
|
125
|
+
(from.keys | to.keys).each do |unit_key|
|
|
126
|
+
lost = (from[unit_key] || Set.new) - (to[unit_key] || Set.new)
|
|
127
|
+
lost.each { |signature| losses[signature] << unit_key }
|
|
128
|
+
end
|
|
129
|
+
losses
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# `[identifier, type]` => Set of `[name, kind]` signatures, for units
|
|
133
|
+
# that list any.
|
|
134
|
+
#
|
|
135
|
+
# Keyed on `[identifier, type]`, not identifier alone: an identifier a
|
|
136
|
+
# model and a service both use would otherwise resolve through
|
|
137
|
+
# `reader.unit(identifier)`'s untyped, collision-prone lookup, and the
|
|
138
|
+
# second entry processed would overwrite the first's slot in this hash.
|
|
139
|
+
# Typed lookup and a composite key keep each unit's own method list
|
|
140
|
+
# under its own type.
|
|
141
|
+
#
|
|
142
|
+
# @param reader [Woods::PublishedIndex]
|
|
143
|
+
# @return [Hash{Array(String, String) => Set<Array(String, Symbol)>}]
|
|
144
|
+
def method_index(reader)
|
|
145
|
+
reader.units.each_with_object({}) do |entry, index|
|
|
146
|
+
data = reader.unit(entry['identifier'], type: entry['type'])
|
|
147
|
+
next unless data
|
|
148
|
+
|
|
149
|
+
signatures = METHOD_KEYS.flat_map { |key| normalized_signatures(key, data.dig('metadata', key)) }
|
|
150
|
+
index[[entry['identifier'], entry['type']]] = signatures.to_set if signatures.any?
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Normalizes a raw metadata method name into a `[name, kind]` signature.
|
|
155
|
+
#
|
|
156
|
+
# `public_methods` (regex-extracted) mixes instance and class methods in
|
|
157
|
+
# one list, marking a class method with a literal `self.` prefix.
|
|
158
|
+
# `class_methods` and `instance_methods` are already split by key, with
|
|
159
|
+
# no prefix on the name. Normalizing both shapes to the same
|
|
160
|
+
# `[name, kind]` pair is what lets a `self.build` in one unit's
|
|
161
|
+
# `public_methods` match a bare `build` in another unit's
|
|
162
|
+
# `class_methods`, and, just as importantly, keeps an instance method
|
|
163
|
+
# from matching a class method of the same bare name.
|
|
164
|
+
#
|
|
165
|
+
# @param key [String] the metadata key the names came from
|
|
166
|
+
# @param names [Array<String>, nil]
|
|
167
|
+
# @return [Array<Array(String, Symbol)>]
|
|
168
|
+
def normalized_signatures(key, names)
|
|
169
|
+
Array(names).map do |raw|
|
|
170
|
+
name = raw.to_s
|
|
171
|
+
if name.start_with?('self.')
|
|
172
|
+
[name.delete_prefix('self.'), :class]
|
|
173
|
+
else
|
|
174
|
+
[name, key == 'class_methods' ? :class : :instance]
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# @param reader [Woods::PublishedIndex]
|
|
180
|
+
# @return [Set<String>] identifiers some test_mapping unit covers
|
|
181
|
+
def covered_units(reader)
|
|
182
|
+
reader.edges(via: COVERAGE_VIA).to_set { |edge| edge[:to] }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -29,6 +29,123 @@ module Woods
|
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Shared line-level Ruby block accounting for the chunkers in this
|
|
33
|
+
# file (B-083 / #195). Mirrors the proven heuristic in
|
|
34
|
+
# Woods::Extractors::RakeTaskExtractor#block_opener? — credit to that
|
|
35
|
+
# implementation for the "modifier keywords only count in statement
|
|
36
|
+
# position" rule (`return if x` must not open a block). Copied rather
|
|
37
|
+
# than shared so the extractor and the chunkers stay independent.
|
|
38
|
+
#
|
|
39
|
+
# Known limitations (deliberate — this is a line classifier, not a
|
|
40
|
+
# parser): heredoc and multi-line string bodies whose lines look like
|
|
41
|
+
# keywords (`<<~SQL` containing a bare `end` or a leading `case`)
|
|
42
|
+
# will skew the depth count, multi-line brace blocks (`{ ... }`) are
|
|
43
|
+
# not tracked, and a trailing comment containing `do`/`end` on the
|
|
44
|
+
# same line as real code can miscount. The AST layer
|
|
45
|
+
# (Woods::Ast) is the right tool when exactness matters.
|
|
46
|
+
module LineDepthTracking
|
|
47
|
+
# Multi-line `do` block opener — `items.each do |item|`. Anchored
|
|
48
|
+
# to end-of-line (optionally through block args and a trailing
|
|
49
|
+
# comment) so prose containing "do" mid-line doesn't count.
|
|
50
|
+
DO_BLOCK_PATTERN = /\bdo\s*(?:\|[^|]*\|)?\s*(?:#.*)?\z/
|
|
51
|
+
|
|
52
|
+
# Keywords that open an `end`-terminated body only in statement
|
|
53
|
+
# position: at line start, or directly after an assignment
|
|
54
|
+
# (`status = if urgent?`, `@memo ||= begin`). Trailing modifier
|
|
55
|
+
# forms (`return if x`, `retry while busy?`) must not count, and
|
|
56
|
+
# ternaries / `case ... in` clauses never involve these keywords
|
|
57
|
+
# in statement position, so they add no depth.
|
|
58
|
+
STATEMENT_KEYWORD_PATTERN =
|
|
59
|
+
/(?:\A|=\s*)(?:if|unless|case|begin|while|until|for|def|class|module)\b/
|
|
60
|
+
|
|
61
|
+
# A line that begins with `end` closes one body — including the
|
|
62
|
+
# chained (`end.compact`) and modifier (`end while retry?`) forms.
|
|
63
|
+
# `(?!:)` excludes `end:` (a symbol hash key, legal despite the
|
|
64
|
+
# reserved word).
|
|
65
|
+
END_LINE_PATTERN = /\Aend\b(?!:)/
|
|
66
|
+
|
|
67
|
+
# `=begin` / `=end` block-comment markers. `=begin` matched the
|
|
68
|
+
# assignment-position keyword branch (its `=` satisfying `=\s*`) and
|
|
69
|
+
# counted as an opener, while `=end` matched neither opener nor
|
|
70
|
+
# {END_LINE_PATTERN} — a net +1 depth per block comment, after which
|
|
71
|
+
# the enclosing method never closed and every later method was
|
|
72
|
+
# appended to its chunk (STO-3).
|
|
73
|
+
BLOCK_COMMENT_MARKER_PATTERN = /\A=(?:begin|end)\b/
|
|
74
|
+
|
|
75
|
+
# Operator method names, longest-first so `<=>` wins over `<=`.
|
|
76
|
+
OPERATOR_METHOD_NAMES =
|
|
77
|
+
'<=>|===|==|=~|!~|!=|\[\]=|\[\]|\*\*|<<|>>|<=|>=|[+-]@|[+\-*\/%<>&|^~!]'
|
|
78
|
+
|
|
79
|
+
# Name of an operator method definition (`def ==(other)`).
|
|
80
|
+
OPERATOR_DEF_NAME_PATTERN = /\A\s*def\s+(?:self\s*\.\s*)?(#{OPERATOR_METHOD_NAMES})/
|
|
81
|
+
|
|
82
|
+
# Endless (Ruby 3.0+) method definition: `def show = head :ok`.
|
|
83
|
+
# The endless marker is an `=` AFTER the method name and optional
|
|
84
|
+
# parameter list — `def ==(other)` and `def x=(v)` are ordinary
|
|
85
|
+
# defs whose *name* contains `=`, and both open a body. The
|
|
86
|
+
# marker's `=` must follow whitespace or a closing paren, which
|
|
87
|
+
# also keeps the rare space-form setter (`def x= v`) classified
|
|
88
|
+
# as a normal def. Limitation: a parameter default containing
|
|
89
|
+
# `)` (`def f(a = (1)) = a`) is misread as a normal def.
|
|
90
|
+
ENDLESS_DEF_PATTERN = /
|
|
91
|
+
\A \s* def \s+
|
|
92
|
+
(?: self \s* \. \s* )?
|
|
93
|
+
(?: #{OPERATOR_METHOD_NAMES}
|
|
94
|
+
| \w+ = (?= \s* \( ) # setter name (`def x=(v)`) — `=` is part of the name
|
|
95
|
+
| \w+ [?!]? # plain name, may end with ? or !
|
|
96
|
+
)
|
|
97
|
+
(?: \s* \( [^)]* \) \s* | \s+ )
|
|
98
|
+
= \s* \S
|
|
99
|
+
/x
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Does this line open a block that a later `end` will close?
|
|
104
|
+
#
|
|
105
|
+
# @param line [String] raw source line
|
|
106
|
+
# @return [Boolean]
|
|
107
|
+
def block_opener?(line)
|
|
108
|
+
stripped = line.strip
|
|
109
|
+
return false if stripped.empty? || stripped.start_with?('#')
|
|
110
|
+
return false if stripped.match?(BLOCK_COMMENT_MARKER_PATTERN)
|
|
111
|
+
return false if endless_def?(stripped)
|
|
112
|
+
# An opener whose `end` sits on the same line is self-balancing
|
|
113
|
+
# (`if x then y end`) — count neither side.
|
|
114
|
+
return false if stripped.match?(/\bend\s*\z/)
|
|
115
|
+
|
|
116
|
+
stripped.match?(STATEMENT_KEYWORD_PATTERN) || stripped.match?(DO_BLOCK_PATTERN)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Does this line close a block (`end`, `end.compact`, `end while x`)?
|
|
120
|
+
#
|
|
121
|
+
# @param line [String] raw source line
|
|
122
|
+
# @return [Boolean]
|
|
123
|
+
def block_terminator?(line)
|
|
124
|
+
line.strip.match?(END_LINE_PATTERN)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Is this line an endless method definition (`def show = head :ok`)?
|
|
128
|
+
# Endless defs have no body and no closing `end`, so they must
|
|
129
|
+
# never enter method-tracking state or contribute depth.
|
|
130
|
+
#
|
|
131
|
+
# @param line [String] raw source line
|
|
132
|
+
# @return [Boolean]
|
|
133
|
+
def endless_def?(line)
|
|
134
|
+
line.match?(ENDLESS_DEF_PATTERN)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Extract an operator method name (`==`, `<=>`, `[]`) from a def
|
|
138
|
+
# line. Fallback for the chunkers' `\w+`-based name extraction —
|
|
139
|
+
# without it `current_method` is set to nil (falsy), tracking never
|
|
140
|
+
# engages, and the operator method's body leaks out of its chunk.
|
|
141
|
+
#
|
|
142
|
+
# @param line [String] raw source line
|
|
143
|
+
# @return [String, nil] the operator name, or nil for non-operator defs
|
|
144
|
+
def operator_def_name(line)
|
|
145
|
+
line[OPERATOR_DEF_NAME_PATTERN, 1]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
32
149
|
# Class-like unit types that MethodChunker handles — anything
|
|
33
150
|
# shaped as "class or module with public methods, maybe privates,
|
|
34
151
|
# maybe callbacks/filters." Extend this list when new extractors
|
|
@@ -298,6 +415,7 @@ module Woods
|
|
|
298
415
|
# @api private
|
|
299
416
|
class ModelChunker
|
|
300
417
|
include ChunkBuilder
|
|
418
|
+
include LineDepthTracking
|
|
301
419
|
|
|
302
420
|
ASSOCIATION_PATTERN = /^\s*(has_many|has_one|belongs_to|has_and_belongs_to_many)\b/
|
|
303
421
|
VALIDATION_PATTERN = /^\s*validates?\b/
|
|
@@ -370,12 +488,15 @@ module Woods
|
|
|
370
488
|
def track_method_line(state, line)
|
|
371
489
|
state[:sections][:methods] << line
|
|
372
490
|
update_method_depth(state, line)
|
|
373
|
-
state[:in_method] = false if state[:depth] <= 0 &&
|
|
491
|
+
state[:in_method] = false if state[:depth] <= 0 && block_terminator?(line)
|
|
374
492
|
end
|
|
375
493
|
|
|
494
|
+
# Balance nested blocks (`do`, multi-line `if`/`unless`/`case`/
|
|
495
|
+
# `begin`/`while`/`until`, nested `def`) so a nested `end` doesn't
|
|
496
|
+
# close the method early (B-083 / #195).
|
|
376
497
|
def update_method_depth(state, line)
|
|
377
|
-
state[:depth] += 1 if
|
|
378
|
-
state[:depth] -= 1 if
|
|
498
|
+
state[:depth] += 1 if block_opener?(line)
|
|
499
|
+
state[:depth] -= 1 if block_terminator?(line)
|
|
379
500
|
end
|
|
380
501
|
|
|
381
502
|
# Classify a single non-method line.
|
|
@@ -404,9 +525,14 @@ module Woods
|
|
|
404
525
|
end
|
|
405
526
|
|
|
406
527
|
def start_method(state, line)
|
|
528
|
+
state[:sections][:methods] << line
|
|
529
|
+
# An endless def (`def title = super&.strip`) has no body and no
|
|
530
|
+
# closing `end` — entering tracking state for it would swallow
|
|
531
|
+
# every subsequent line into :methods.
|
|
532
|
+
return if endless_def?(line)
|
|
533
|
+
|
|
407
534
|
state[:in_method] = true
|
|
408
535
|
state[:depth] = 1
|
|
409
|
-
state[:sections][:methods] << line
|
|
410
536
|
end
|
|
411
537
|
|
|
412
538
|
def assign_fallback(state, line)
|
|
@@ -424,8 +550,7 @@ module Woods
|
|
|
424
550
|
# @api private
|
|
425
551
|
class ControllerChunker
|
|
426
552
|
include ChunkBuilder
|
|
427
|
-
|
|
428
|
-
FILTER_PATTERN = /^\s*(before_action|after_action|around_action|skip_before_action)\b/
|
|
553
|
+
include LineDepthTracking
|
|
429
554
|
|
|
430
555
|
# @param unit [ExtractedUnit]
|
|
431
556
|
def initialize(unit)
|
|
@@ -458,10 +583,13 @@ module Woods
|
|
|
458
583
|
state
|
|
459
584
|
end
|
|
460
585
|
|
|
586
|
+
# While inside an action body, collect every line and balance
|
|
587
|
+
# nested blocks (`do`, multi-line `if`/`case`/…) so a nested `end`
|
|
588
|
+
# doesn't close the action early (B-083 / #195).
|
|
461
589
|
def track_action_line(state, line)
|
|
462
590
|
state[:actions][state[:current_action]] << line
|
|
463
|
-
state[:depth] += 1 if
|
|
464
|
-
return unless
|
|
591
|
+
state[:depth] += 1 if block_opener?(line)
|
|
592
|
+
return unless block_terminator?(line)
|
|
465
593
|
|
|
466
594
|
state[:depth] -= 1
|
|
467
595
|
return unless state[:depth] <= 0
|
|
@@ -482,10 +610,18 @@ module Woods
|
|
|
482
610
|
end
|
|
483
611
|
|
|
484
612
|
def start_action(state, line)
|
|
485
|
-
|
|
613
|
+
# Capture an optional `self.` so a class method chunks under
|
|
614
|
+
# `self.<name>` instead of every one landing on the key "self"
|
|
615
|
+
# and clobbering the previous class method's collected lines.
|
|
616
|
+
action_name = line[/def\s+((?:self\.)?\w+)/, 1]
|
|
617
|
+
state[:actions][action_name] = [line]
|
|
618
|
+
# An endless def (`def show = head :ok`) is a complete action on
|
|
619
|
+
# its own line — no body follows, so entering tracking state
|
|
620
|
+
# would swallow every subsequent action into this chunk.
|
|
621
|
+
return if endless_def?(line)
|
|
622
|
+
|
|
486
623
|
state[:current_action] = action_name
|
|
487
624
|
state[:depth] = 1
|
|
488
|
-
state[:actions][action_name] = [line]
|
|
489
625
|
end
|
|
490
626
|
|
|
491
627
|
# @param state [Hash]
|
|
@@ -520,6 +656,7 @@ module Woods
|
|
|
520
656
|
# @api private
|
|
521
657
|
class MethodChunker
|
|
522
658
|
include ChunkBuilder
|
|
659
|
+
include LineDepthTracking
|
|
523
660
|
|
|
524
661
|
# @param unit [ExtractedUnit]
|
|
525
662
|
def initialize(unit)
|
|
@@ -555,15 +692,15 @@ module Woods
|
|
|
555
692
|
end
|
|
556
693
|
|
|
557
694
|
# While inside a method body, collect every line and track depth so
|
|
558
|
-
# we know when the method closes. Blocks (`do...end`,
|
|
559
|
-
# nest inside methods and must be
|
|
560
|
-
# `end` line counts.
|
|
695
|
+
# we know when the method closes. Blocks (`do...end`, multi-line
|
|
696
|
+
# `if...end`, `case...end`, …) nest inside methods and must be
|
|
697
|
+
# balanced before the method's own `end` line counts (B-083 / #195).
|
|
561
698
|
def track_method_line(state, line)
|
|
562
699
|
target = state[:in_private] ? state[:private_methods] : state[:methods][state[:current_method]]
|
|
563
700
|
target << line
|
|
564
701
|
|
|
565
|
-
state[:depth] += 1 if
|
|
566
|
-
return unless
|
|
702
|
+
state[:depth] += 1 if block_opener?(line)
|
|
703
|
+
return unless block_terminator?(line)
|
|
567
704
|
|
|
568
705
|
state[:depth] -= 1
|
|
569
706
|
return unless state[:depth] <= 0
|
|
@@ -588,9 +725,7 @@ module Woods
|
|
|
588
725
|
end
|
|
589
726
|
|
|
590
727
|
def start_method(state, line)
|
|
591
|
-
method_name = line[/def\s+(?:self\.)?(\w+)/, 1]
|
|
592
|
-
state[:current_method] = method_name
|
|
593
|
-
state[:depth] = 1
|
|
728
|
+
method_name = line[/def\s+(?:self\.)?(\w+)/, 1] || operator_def_name(line)
|
|
594
729
|
|
|
595
730
|
if state[:in_private]
|
|
596
731
|
state[:private_methods] << line
|
|
@@ -600,6 +735,13 @@ module Woods
|
|
|
600
735
|
# methods in source order.
|
|
601
736
|
state[:methods][method_name] = [line]
|
|
602
737
|
end
|
|
738
|
+
|
|
739
|
+
# An endless def (`def ping = :pong`) is complete on its own line
|
|
740
|
+
# — entering tracking state would swallow every later method.
|
|
741
|
+
return if endless_def?(line)
|
|
742
|
+
|
|
743
|
+
state[:current_method] = method_name
|
|
744
|
+
state[:depth] = 1
|
|
603
745
|
end
|
|
604
746
|
|
|
605
747
|
# Build the final chunk array from the parse state.
|
|
@@ -44,11 +44,17 @@ module Woods
|
|
|
44
44
|
def log(tool:, params:, confirmed:, result_summary:)
|
|
45
45
|
ensure_directory!
|
|
46
46
|
|
|
47
|
+
# Redact BEFORE truncating (CON-5). Truncation cuts at
|
|
48
|
+
# MAX_FIELD_CHARS, and a credential straddling that boundary is split
|
|
49
|
+
# in two: the scanner's word-boundary/length-anchored patterns no
|
|
50
|
+
# longer match the surviving prefix, so cleartext `sk_live_…` reached
|
|
51
|
+
# the JSONL. Scanning the whole value first means the boundary can
|
|
52
|
+
# only ever fall inside `[REDACTED]`.
|
|
47
53
|
entry = {
|
|
48
54
|
tool: tool,
|
|
49
|
-
params: redact(
|
|
55
|
+
params: truncate_deep(redact(params)),
|
|
50
56
|
confirmed: confirmed,
|
|
51
|
-
result_summary: redact(
|
|
57
|
+
result_summary: truncate_value(redact(result_summary)),
|
|
52
58
|
timestamp: Time.now.utc.iso8601
|
|
53
59
|
}
|
|
54
60
|
|
|
@@ -121,7 +127,10 @@ module Woods
|
|
|
121
127
|
def entries
|
|
122
128
|
return [] unless File.exist?(@path)
|
|
123
129
|
|
|
124
|
-
|
|
130
|
+
# Explicit UTF-8: entries are written as UTF-8 (the truncation notice
|
|
131
|
+
# alone carries a multibyte ellipsis), so a bare read would tag them
|
|
132
|
+
# with the process default external encoding and raise under LANG=C.
|
|
133
|
+
File.readlines(@path, encoding: Encoding::UTF_8).filter_map do |line|
|
|
125
134
|
JSON.parse(line.strip) unless line.strip.empty?
|
|
126
135
|
end
|
|
127
136
|
end
|
|
@@ -2,27 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module Woods
|
|
4
4
|
module Console
|
|
5
|
-
# Canonical
|
|
6
|
-
#
|
|
7
|
-
# executor that ships today). The eventual real bridge-process
|
|
8
|
-
# implementation (Option D — see `docs/design/CONSOLE_SERVER.md`)
|
|
9
|
-
# will also reference this module so every executor that speaks the
|
|
10
|
-
# protocol agrees on the tool vocabulary.
|
|
5
|
+
# Canonical Tier 1 vocabulary used by {EmbeddedExecutor}. The historical
|
|
6
|
+
# name remains for compatibility after removal of the JSON-lines bridge.
|
|
11
7
|
#
|
|
12
|
-
#
|
|
8
|
+
# Two constants live here:
|
|
13
9
|
#
|
|
14
10
|
# - {SUPPORTED_TOOLS} — the canonical Tier 1 tool list.
|
|
15
11
|
# - {TIER1_TOOLS} — alias, kept as a distinct name for call
|
|
16
12
|
# sites that reason about tier semantics rather than the whole
|
|
17
13
|
# supported set.
|
|
18
|
-
# - {TOOL_HANDLERS} — tool → `handle_<tool>` method-symbol map.
|
|
19
14
|
#
|
|
20
|
-
# Previously these lived on {StubBridge} and {EmbeddedExecutor}
|
|
21
|
-
# borrowed them with `TIER1_TOOLS = StubBridge::TIER1_TOOLS`, which
|
|
22
|
-
# reads as "the real executor borrows constants from the stub" —
|
|
23
|
-
# backwards. Extracting the protocol here lets the real executor
|
|
24
|
-
# (and a future non-stub `Bridge` class) claim the contract without
|
|
25
|
-
# importing the scaffold.
|
|
26
15
|
module BridgeProtocol
|
|
27
16
|
SUPPORTED_TOOLS = %w[
|
|
28
17
|
count
|
|
@@ -37,8 +26,6 @@ module Woods
|
|
|
37
26
|
].freeze
|
|
38
27
|
|
|
39
28
|
TIER1_TOOLS = SUPPORTED_TOOLS
|
|
40
|
-
|
|
41
|
-
TOOL_HANDLERS = SUPPORTED_TOOLS.to_h { |t| [t, :"handle_#{t}"] }.freeze
|
|
42
29
|
end
|
|
43
30
|
end
|
|
44
31
|
end
|