woods 1.6.1 → 2.0.0.beta2
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 +2035 -0
- data/CONTRIBUTING.md +253 -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 +655 -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 +455 -0
- data/docs/INTERNALS.md +418 -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 +213 -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 +171 -2
- 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 +2783 -238
- 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 +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -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 +564 -87
- 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 +236 -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/trace_enricher.rb +3 -0
- 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 +138 -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
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,2041 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.0.0.beta2] - 2026-09-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`release:prepare` runs a live preflight before printing the tag and dispatch
|
|
15
|
+
commands.** The new `release:preflight` task (also runnable standalone) checks, via
|
|
16
|
+
`gh api`, that the live `release` environment still requires review and disallows admin
|
|
17
|
+
bypass; that `REQUIRED_CI_JOBS` in `script/validate-release-run` still matches
|
|
18
|
+
`ci.yml`'s job names; and that both `release.yml` download-artifact steps set
|
|
19
|
+
`merge-multiple: true`. Every check is advisory: a missing or failing `gh` skips with a
|
|
20
|
+
note rather than blocking prepare. CONTRIBUTING.md and the release-flow skill also gained
|
|
21
|
+
a "when a dispatch fails" guide (which failures a main merge alone fixes versus which need
|
|
22
|
+
the tag moved, and only before publication) and the documented final step for creating the
|
|
23
|
+
GitHub Release entry by hand, which the workflow deliberately never automates.
|
|
24
|
+
- **A release_v2 spec greps `spec/` for hard-coded current-version literals** (`'= 2.0.0'`,
|
|
25
|
+
`"2.0.0\n"`, `gem_version: '2.0.0'`-shaped strings), built from `Woods::VERSION`'s base, so
|
|
26
|
+
the next version bump cannot leave one behind the way the beta1 cut did.
|
|
27
|
+
- **`WOODS_PROFILE=1` logs a timing line per extraction phase.** The per-extractor
|
|
28
|
+
lines already reported extraction itself; everything after it (payload seed, previous
|
|
29
|
+
graph load, eager load, blast radius, re-extraction, type index, graph analysis, flows,
|
|
30
|
+
manifest and summary, publish) was unattributed, so a slow run could only be split by
|
|
31
|
+
guessing. One `[Woods] [profile] <phase> in N.NNs` line per phase, on the monotonic
|
|
32
|
+
clock. Off by default and free when off.
|
|
33
|
+
- **`durable_payload_writes` restores the per-file `fsync` on payload files.** Boolean,
|
|
34
|
+
default `false`. Off is not the weaker setting: readers resolve only through
|
|
35
|
+
`generation.json`, and every publish now flushes the whole payload before writing that
|
|
36
|
+
pointer, so the contract holds either way. Turning the key on buys exactly one thing, an
|
|
37
|
+
individual payload file being durable before the pointer exists, and pays two forced
|
|
38
|
+
flushes per file (about 8.9ms each on btrfs) for it. It cannot disable the publish
|
|
39
|
+
flush, which has no opt-out.
|
|
40
|
+
|
|
41
|
+
- **`incremental_blast_radius_depth` bounds how far an incremental run re-extracts.**
|
|
42
|
+
`extract_changed` walked the unbounded transitive dependent closure of every changed
|
|
43
|
+
file, so one edit to a widely referenced unit re-extracted most of the app. The new key
|
|
44
|
+
caps the walk at N reverse hops (`nil`, the default, keeps the unbounded closure). A
|
|
45
|
+
unit outside the cap keeps its content and still gets its `dependents` list refreshed by
|
|
46
|
+
the run's second pass, which the equivalence harness now covers under a cap of 1. The
|
|
47
|
+
default stays unbounded on purpose: an STI grandchild inherits its grandparent's
|
|
48
|
+
associations, validations and callback chain while sitting two hops away in the graph,
|
|
49
|
+
and nested `has_many :through` resolves the same way, so a host opts in for a tree it
|
|
50
|
+
knows has neither. On a 200-service chain in the dummy app, one leaf edit re-extracts
|
|
51
|
+
200 units unbounded and 2 under a depth of 1.
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- **Payload durability moved from every file to the generation pointer.** `AtomicFile.write`
|
|
56
|
+
fsynced the temp file and the containing directory for every file it wrote, so a full
|
|
57
|
+
extraction of a large application paid two forced flushes 8323 times: 71.3s of the write
|
|
58
|
+
phase for 8000 files on btrfs, against 1.0s for one filesystem flush. Payload writes (unit
|
|
59
|
+
files, type indexes, the dependency graph, the graph analysis, the manifest, the summary,
|
|
60
|
+
the flow documents) now skip both, and `publish_generation` calls the new
|
|
61
|
+
`AtomicFile.sync_directory_tree` on the payload directory immediately before writing
|
|
62
|
+
`generation.json`.
|
|
63
|
+
|
|
64
|
+
The guarantee that replaces the old one: **when `generation.json` is durable, every file
|
|
65
|
+
in the payload it names is durable.** What is given up is an individual payload file being
|
|
66
|
+
durable before the pointer exists, and nothing reads a payload file in that window, since
|
|
67
|
+
every reader resolves through the pointer and a crash there leaves an unreferenced partial
|
|
68
|
+
payload the next run prunes. `generation.json` itself, the watch daemon's status file, the
|
|
69
|
+
update check cache, the Obsidian and Unblocked exports, Notion sync state, temporal
|
|
70
|
+
snapshots, embedding checkpoints and MCP task records all keep the per-file fsync: their
|
|
71
|
+
readers do not go through the pointer. The gem mapper's self-map publishes through the
|
|
72
|
+
same pointer but keeps its per-file fsync for now; it is a small payload and adopts the
|
|
73
|
+
single flush in a follow-up.
|
|
74
|
+
|
|
75
|
+
`sync_directory_tree` tries `syncfs(2)` through Fiddle, then `sync -f <dir>`, then a bare
|
|
76
|
+
`sync`, then an `fsync` on every file in the tree, and returns which one ran. The last
|
|
77
|
+
resort is what keeps this honest: the chain never silently does nothing, it only gets
|
|
78
|
+
slower. Fiddle is required inside a rescue and stays out of the gemspec, since it is a
|
|
79
|
+
bundled gem from Ruby 3.5. `bench/atomic_write_bench.rb` measures all four modes.
|
|
80
|
+
|
|
81
|
+
- **Seeding a payload creates each directory once rather than once per file.**
|
|
82
|
+
`PayloadStore#clone` ran `FileUtils.mkdir_p` before every file it replicated.
|
|
83
|
+
`Pathname#find` visits a directory before its children, so the directory branch had
|
|
84
|
+
already created every parent a file could need. 8001 files across four type directories
|
|
85
|
+
on btrfs: 0.906s before, 0.847s after.
|
|
86
|
+
|
|
87
|
+
- **Cycle detection is capped, and says so.** `GraphAnalyzer#analyze` runs on every
|
|
88
|
+
extraction regardless of the change set, and enumerating every cycle was the largest
|
|
89
|
+
part of it: one cycle per DFS back-edge, uncapped in count and in length, each
|
|
90
|
+
canonicalized by joining a path that on a deep DFS is thousands of nodes long. Two new
|
|
91
|
+
config keys bound it, `graph_cycle_limit` (default 500) and `graph_cycle_max_length`
|
|
92
|
+
(default 50, in distinct nodes); either firing sets the new
|
|
93
|
+
`stats.cycle_limit_reached` in `graph_analysis.json`. Set either to `nil` to remove
|
|
94
|
+
its cap and restore exhaustive enumeration. Signatures are now keyed by a digest of
|
|
95
|
+
the rotated cycle rather than the joined path. On a synthetic 8201-node graph, cycle
|
|
96
|
+
detection went from 8.6s to 0.4s.
|
|
97
|
+
- **Bridge detection reuses its work.** `bfs_shortest_path` carries parent pointers
|
|
98
|
+
instead of enqueueing a copy of the path so far for every node it reaches, and forward
|
|
99
|
+
adjacency resolves through a per-analyzer memo instead of being re-derived on each of
|
|
100
|
+
the 200 sampled traversals. Output is unchanged. On the same graph, bridges went from
|
|
101
|
+
2.2s to 0.6s and the whole report from 3.1s to 1.0s.
|
|
102
|
+
- **Flow assembly caches loaded units and parsed sources.** One `FlowAssembler` serves a
|
|
103
|
+
whole precompute run, but nothing was cached across it: a unit's JSON was re-globbed
|
|
104
|
+
and re-parsed on every expansion, and its source re-parsed once per action of every
|
|
105
|
+
controller that reached it. Three per-instance LRU memos (unit data, whole-source AST,
|
|
106
|
+
and the method index derived from it) bound at 1000 entries each. On 435 controllers x
|
|
107
|
+
7 actions over 3000 services, precompute went from 20.5s to 3.8s.
|
|
108
|
+
- **The manifest and `SUMMARY.md` read each type index once.** Both derive their totals
|
|
109
|
+
from the per-type `_index.json` files and run back to back at the end of every
|
|
110
|
+
incremental run, so every index was globbed, read and parsed twice. An unreadable index
|
|
111
|
+
still drops that type from both, now with one warning instead of two.
|
|
112
|
+
- **The incremental flow refresh is scoped to the flow assembly radius.** Flows were
|
|
113
|
+
reassembled for every controller in the run's touched set, and the touched set is the
|
|
114
|
+
graph's whole reverse closure, so one leaf edit re-ran flow assembly for nearly every
|
|
115
|
+
controller in the app. A flow document reaches `FlowPrecomputer::DEFAULT_MAX_DEPTH`
|
|
116
|
+
units, so the run now walks the pre-change graph to that same depth and reassembles
|
|
117
|
+
only the controllers inside it. Controllers outside it carry their
|
|
118
|
+
`metadata[:flow_paths]` annotation forward out of the previous flow index rather than
|
|
119
|
+
losing it. A targeted `refresh`, a routes re-run, and a controller whose action set no
|
|
120
|
+
longer matches the index all still reassemble in full. On 50 controllers over a
|
|
121
|
+
50-service chain in the dummy app, one edit at the far end went from 50 controllers
|
|
122
|
+
reassembled to 3 plus 47 carried.
|
|
123
|
+
- **`graph_sha` is digested from the bytes written.** `graph_analysis.json`'s digest of
|
|
124
|
+
`dependency_graph.json` came from reading the file back off disk, one whole-file read
|
|
125
|
+
per run of an artifact that on a large app is tens of megabytes, to digest bytes the
|
|
126
|
+
run had just serialized. The value is unchanged.
|
|
127
|
+
|
|
128
|
+
### Fixed
|
|
129
|
+
|
|
130
|
+
- `TraceEnricher.record` rejects calls without a block before creating a
|
|
131
|
+
TracePoint, preventing an enabled hook from leaking into subsequent execution (#308).
|
|
132
|
+
|
|
133
|
+
- **The Changed list only names the surface inventory when regenerating it actually moved
|
|
134
|
+
it.** `release:prepare` used to list `.Codex/release-v2/surface-inventory.json`
|
|
135
|
+
unconditionally, even on the ordinary run where nothing in the public surface changed.
|
|
136
|
+
- **The release banner no longer links an upgrade guide that does not exist yet.** A major
|
|
137
|
+
version bump past `docs/UPGRADING_TO_2.md`'s own major derived a
|
|
138
|
+
`docs/UPGRADING_TO_<major>.md` link without checking the file exists.
|
|
139
|
+
- **Changelog entries merged from a duplicate heading no longer carry a stray blank line.**
|
|
140
|
+
Two occurrences of the same `###` heading in one `## [Unreleased]` cycle folded into a
|
|
141
|
+
release section with a blank line between their bullets, splitting one list into two; they
|
|
142
|
+
now join tight.
|
|
143
|
+
- **Release validation names the live-backends CI job as it is called.** The release
|
|
144
|
+
validator required a CI job named `Live backends (pgvector + Qdrant + Solid Cache)`,
|
|
145
|
+
but the job gained `+ Redis` in its name, so every release dispatch failed at
|
|
146
|
+
release-context with nothing published. The prefix now matches, and a spec checks every
|
|
147
|
+
required contract job against the names in `ci.yml` so a rename cannot drift again.
|
|
148
|
+
- **Release candidate jobs find the downloaded artifact.** `actions/download-artifact` with
|
|
149
|
+
`artifact-ids` extracts into `dist/<artifact-name>/`, so the digest check and the install
|
|
150
|
+
in `dist/` failed with a missing file on every dispatch since the switch to artifact ids.
|
|
151
|
+
Both downloads now set `merge-multiple: true`; the workflow spec requires it.
|
|
152
|
+
- **Release candidate tests accept a prerelease version.** The clean-install smoke specs
|
|
153
|
+
pinned `2.0.0` as a literal in the dummy app's Gemfile, the loaded-version check, and a
|
|
154
|
+
snapshot fixture, so the first prerelease failed them (Bundler never resolves a prerelease
|
|
155
|
+
from an unpinned requirement). They now use `Woods::VERSION`. The candidate host also
|
|
156
|
+
installs `webrick` so `woods-mcp-http` finds a Rack handler on Rubies that no longer ship one.
|
|
157
|
+
- **Inspector contract specs track the pinned `@modelcontextprotocol/inspector` version instead
|
|
158
|
+
of a literal.** Bumping the dev dependency to 2.6.0 fixed the SDK bug where Inspector sent a
|
|
159
|
+
legacy `logging/setLevel` call after negotiating the modern 2026-07-28 protocol, so the two
|
|
160
|
+
`pending` stdio/HTTP examples in `mcp_inspector_contract_spec.rb` asserted a stderr message
|
|
161
|
+
that no longer occurs. Those examples now assert a clean modern handshake, and every version
|
|
162
|
+
literal in that file reads from `package.json` instead. `sdk_dependency_spec.rb`'s deliberate
|
|
163
|
+
version-and-integrity trip wire is bumped to match the new pin.
|
|
164
|
+
|
|
165
|
+
## [2.0.0.beta1] - 2026-09-09
|
|
166
|
+
|
|
167
|
+
### Added
|
|
168
|
+
|
|
169
|
+
- **`WOODS_GIT_DIR` names the canonical git directory outright.** It wins over
|
|
170
|
+
whatever repository Woods would otherwise find, at all three of Woods's git
|
|
171
|
+
call sites: per-unit enrichment, `manifest.json` provenance, and the
|
|
172
|
+
`woods:incremental` diff range. All three build their command line with the
|
|
173
|
+
new `Woods::GitCommand.argv`. This is the escape hatch for a container that
|
|
174
|
+
can mount the canonical git directory but not the host path a linked
|
|
175
|
+
worktree's `gitdir:` pointer names.
|
|
176
|
+
- **Database-partition layer for multi-database apps (#280).** Model units record
|
|
177
|
+
`metadata[:database]` from `connection_db_config` (Rails 6.1+, `nil` on 6.0), so a model
|
|
178
|
+
that inherits `connects_to` from an abstract class reports the inherited database.
|
|
179
|
+
Association entries carry `from_db`, `to_db`, `through_db` (the has_many :through join
|
|
180
|
+
model's database, nil for a plain association), and `disable_joins`; `metadata[:foreign_keys]`
|
|
181
|
+
lists each foreign key's `from_table`, `to_table`, and `column` (the target table's owning
|
|
182
|
+
database is a graph-level lookup, not stored per model). The dependency graph gains additive
|
|
183
|
+
node keys (`database`, `table`, `foreign_key_tables`) and edge keys (`through`, `through_db`,
|
|
184
|
+
`disable_joins`); a graph with none of these serializes exactly as before.
|
|
185
|
+
- **`cross_database_edges` report.** `GraphAnalyzer#analyze` lists association and
|
|
186
|
+
foreign-key edges that cross databases, with `kind` set to `join_through_across_databases`
|
|
187
|
+
when `disable_joins` is false and `from_db`, `through_db`, or `to_db` disagree (a nil
|
|
188
|
+
`through_db` falls back to comparing the two ends). A foreign key never resolves to an
|
|
189
|
+
owner living in its own source database, even when another database also claims the
|
|
190
|
+
table; only when every owner sits elsewhere, across more than one database, does the
|
|
191
|
+
entry come back with `to: nil` and an `ambiguous_owners` list instead of guessing.
|
|
192
|
+
Written to `graph_analysis.json`; exposed through `graph_analysis` in Task 10.
|
|
193
|
+
- **A phase breakdown of one full extraction of the fixture app**, in
|
|
194
|
+
`docs/WATCH_DAEMON.md`. Graph analysis, PageRank included, is 3.2% of the run;
|
|
195
|
+
git enrichment is 11.6%; the one phase over 15% is `RailsSourceExtractor`,
|
|
196
|
+
which is 52.6% only because 119 of the fixture's 147 units are framework
|
|
197
|
+
sources.
|
|
198
|
+
- **Positioning against Rubydex, rails-mcp-server, and ruby-lsp-rails.** `docs/WHY_WOODS.md`
|
|
199
|
+
gains a comparison table with versions checked on 2026-09-08, and frames Rubydex as
|
|
200
|
+
complementary on symbol references.
|
|
201
|
+
- **Git commit facts land on graph nodes before analysis (#280).** Both extraction
|
|
202
|
+
paths copy `commit_count` and `change_frequency` from a unit's git metadata onto its
|
|
203
|
+
graph node: full extraction after git enrichment and before graph analysis
|
|
204
|
+
(`Extractor#annotate_graph_with_git_data`), incremental extraction as part of the
|
|
205
|
+
per-unit JSON patch (`Extractor#annotate_node_from_git`). A patch missing one of the
|
|
206
|
+
two keys leaves the node's existing value for that key alone rather than clearing it.
|
|
207
|
+
|
|
208
|
+
This gives `GraphAnalyzer` access to git facts without holding every unit in memory,
|
|
209
|
+
which an incremental run never does.
|
|
210
|
+
- **`volatile_dependencies` report (#280).** Git churn and graph edges finally meet:
|
|
211
|
+
`GraphAnalyzer#analyze` lists edges whose dependency has at least
|
|
212
|
+
`config.volatile_dependency_ratio` (default `3.0`) times the dependent's commit count,
|
|
213
|
+
ranked by the dependency's PageRank. Young classes (fewer than 5 commits, or `new`)
|
|
214
|
+
are skipped, and the report is informational only. The top 20 are persisted in
|
|
215
|
+
`graph_analysis.json` (`stats.volatile_dependency_count` reports the full qualifying
|
|
216
|
+
total); the top 5 appear in `SUMMARY.md`. Each entry carries both ends' node types
|
|
217
|
+
(`from_type`/`to_type`). PageRank now runs inside `analyze`.
|
|
218
|
+
- **`Woods::PublishedIndex` (#280).** A small read-only Ruby API over one published index
|
|
219
|
+
generation for RuboCop cops and gate scripts: look up units by identifier (optionally
|
|
220
|
+
scoped to a type, to avoid a same-identifier collision across type directories), iterate
|
|
221
|
+
edges with their attributes, build a table-to-database map, pin a retained `payloads/gen-N`
|
|
222
|
+
under the same retention lock protocol as `PayloadStore#prune`, and get a checksum keyed to
|
|
223
|
+
the pinned payload for `external_dependency_checksum`. Raises
|
|
224
|
+
`Woods::PublishedIndex::CorruptPointerError` for a `generation.json` that exists but will
|
|
225
|
+
not parse, rather than reporting zero published generations. `docs/PUBLISHED_INDEX.md`
|
|
226
|
+
includes a worked `Multidb/ForeignKeyAcrossDatabases` cop.
|
|
227
|
+
- **Agent-level ablation harness (#280).** `woods:evaluate:ablation[task_set]` runs a task
|
|
228
|
+
set twice per task, with the index on and off, through any agent command that prints
|
|
229
|
+
`claude -p --output-format json` style output. Each trial runs in a disposable git
|
|
230
|
+
worktree checked out from a fixed baseline SHA, verifies Woods availability before
|
|
231
|
+
running, and enforces a per-trial timeout. Reports resolution rate, tokens, cost, turns,
|
|
232
|
+
and errors per condition with the delta, plus provenance (agent command, model, MCP
|
|
233
|
+
config, Woods generation, baseline SHA) per result. No Rails boot. This is a harness for
|
|
234
|
+
collecting paired runs, not causal evidence. `docs/EVALUATION.md` now documents
|
|
235
|
+
`woods:evaluate`, the baseline, and the ablation in one place.
|
|
236
|
+
- **Plugin hooks for index freshness (#280).** The Claude Code plugin (2.3.0) ships two
|
|
237
|
+
opt-in hooks under `plugin/hooks/`: a `PostToolUse` hook that runs `woods:incremental`
|
|
238
|
+
in the background when an edit touches models, routes, migrations, schema, or a
|
|
239
|
+
`package.yml`, reading `cwd` from the hook payload so linked worktrees refresh their own
|
|
240
|
+
index; and a `SessionStart` hook that warns when the published generation predates the
|
|
241
|
+
last commit (scoped to commit timestamps, so it does not cover uncommitted edits or an
|
|
242
|
+
older checkout). Both do nothing until `WOODS_HOOKS_ENABLED=1` is set, honor
|
|
243
|
+
`WOODS_HOOKS_DISABLED=1`, and resolve the index directory the same no-boot way
|
|
244
|
+
`woods:watch_status` does (`WOODS_OUTPUT`, default `tmp/woods`). Lock contention batches
|
|
245
|
+
concurrent edits instead of dropping them: a busy hook appends its path to
|
|
246
|
+
`hook-pending.txt` and returns, and the lock holder drains it in a loop until empty
|
|
247
|
+
(mkdir-based lock on hosts without `flock`, reclaimed after `WOODS_HOOK_LOCK_STALE_SECONDS`,
|
|
248
|
+
default 1800, when a crashed run leaves it behind). Docs gain the Rails 8.1 `config/ci.rb` step
|
|
249
|
+
`step "Woods: refresh", "bin/rails woods:incremental"`.
|
|
250
|
+
- **Packwerk package layer (#280).** A new `package` unit type reads every `package.yml`
|
|
251
|
+
`enforce_dependencies`, `layer`, `public_path`, `owner`, with a `package_dependency`
|
|
252
|
+
edge per declared dependency. Any `package.yml` change re-runs the extractor wholesale
|
|
253
|
+
during incremental extraction. Woods reports boundaries; `pks check` and
|
|
254
|
+
`packwerk check` keep enforcement. A pack-resident file-based unit (a service under
|
|
255
|
+
`packs/billing/app/services/`) is still not discovered by `PathDispatcher`, so it never
|
|
256
|
+
becomes a unit at all (follow-up B-175).
|
|
257
|
+
- **Package membership on units (#280).** Every discovered app-owned unit under a package
|
|
258
|
+
root carries `metadata[:package]` and its graph node carries `package`, on full and
|
|
259
|
+
incremental runs alike; a `package.yml` change re-annotates the affected units in the
|
|
260
|
+
same incremental run.
|
|
261
|
+
- **`undeclared_package_edges` report.** `GraphAnalyzer#analyze` lists every edge that
|
|
262
|
+
crosses a package boundary the source package never declared, read from the `package`
|
|
263
|
+
node attribute and each package unit's own `package_dependency` edges. `dependents`,
|
|
264
|
+
`lookup`, and `domain_clusters` already show the units; this shows the boundary an
|
|
265
|
+
agent is about to cross. Enforcement stays with `packwerk check` / `pks check`.
|
|
266
|
+
- **`graph_analysis` exposes the cross-database and volatile-dependency reports.**
|
|
267
|
+
`analysis` accepts `cross_database_edges`, `volatile_dependencies`, and
|
|
268
|
+
`undeclared_package_edges`; `all` paginates them like every other section, and the
|
|
269
|
+
markdown and plain renderers print edge-shaped items on one line each.
|
|
270
|
+
- **`woods:check:moved_messages` (#280).** Compares two retained payload generations through
|
|
271
|
+
`Woods::PublishedIndex` and lists every public method name that looks like it moved between
|
|
272
|
+
units while its `test_coverage` edge did not follow. Each row is a candidate move into a unit
|
|
273
|
+
without mapped tests, not a proven coverage loss. `WOODS_CHECK_STRICT=1` exits 1 for CI. No
|
|
274
|
+
Rails boot.
|
|
275
|
+
|
|
276
|
+
- **MCP 2026-07-28 support** (B-111–B-114). The gemspec now requires `mcp >= 1.2, < 2.0`,
|
|
277
|
+
the release that added the 2026-07-28 protocol revision.
|
|
278
|
+
- **Stateless Streamable HTTP by default.** `woods-mcp-http` no longer mints
|
|
279
|
+
`Mcp-Session-Id`, so restarting it (gem upgrade, machine sleep, worktree rebuild) is
|
|
280
|
+
invisible to connected clients instead of invalidating every session, and several
|
|
281
|
+
instances can serve one volume-mounted index without sticky routing. Set
|
|
282
|
+
`WOODS_MCP_HTTP_STATELESS=0` for a client that still needs sessions, the GET SSE
|
|
283
|
+
stream, or DELETE teardown — a transitional escape hatch, since all three are gone
|
|
284
|
+
from the specification.
|
|
285
|
+
- **Tasks extension** (`io.modelcontextprotocol/tasks`). `pipeline_extract` and
|
|
286
|
+
`pipeline_embed` return a durable task handle to clients that declare the extension:
|
|
287
|
+
poll with `tasks/get`. Cancellation is not advertised: `tasks/cancel` returns
|
|
288
|
+
`Method not found` because Woods cannot safely stop work already holding the
|
|
289
|
+
pipeline lock or prevent it from publishing. Records live on disk under
|
|
290
|
+
`<index_dir>/tasks/`, so a run reports real success or failure, a client that drops
|
|
291
|
+
mid-run can reconnect — even to a restarted server — and collect the result, and a
|
|
292
|
+
task whose owning process died resolves to `failed` instead of leaving an agent
|
|
293
|
+
polling forever. Clients that do not declare the extension get the previous
|
|
294
|
+
fire-and-forget behaviour, unchanged.
|
|
295
|
+
- **Cache hints and deterministic tool ordering.** List and read results carry
|
|
296
|
+
`ttlMs` (default 10s, `WOODS_MCP_CACHE_TTL_MS`) and `cacheScope: "private"`, and tools
|
|
297
|
+
are advertised in sorted order so a host with optional integrations wired presents the
|
|
298
|
+
same tool block as one without.
|
|
299
|
+
- **`server/discover`** is answered, advertising supported versions, capabilities and
|
|
300
|
+
the Tasks extension.
|
|
301
|
+
|
|
302
|
+
- **`embedding_provider = :fake`** — the deterministic bag-of-words provider is now a
|
|
303
|
+
first-class citizen (promoted from spec support), and `Builder` also accepts an injected
|
|
304
|
+
provider object responding to `#embed`/`#embed_batch`. `woods:embed` → `woods:retrieve`
|
|
305
|
+
now runs fully offline; `woods:retrieve` resolves all four backends through the
|
|
306
|
+
configuration instead of hardcoding Ollama + in-memory stores (#178).
|
|
307
|
+
- **Notion sync manifest** — unchanged pages cost zero API calls on re-sync; changed pages
|
|
308
|
+
update by cached page id without a title query; `WOODS_NOTION_FORCE=1` bypasses for one
|
|
309
|
+
run (#207).
|
|
310
|
+
- **`woods:validate`** warns for units whose `file_path` resolves neither as written nor
|
|
311
|
+
under `Rails.root` (#169).
|
|
312
|
+
|
|
313
|
+
- **`rake woods:watch` — a resident extraction daemon** (#164, phase 2). Watches the app and
|
|
314
|
+
keeps the index current as files change, instead of as-fresh-as-the-last-explicit-run. One
|
|
315
|
+
cycle is watch → debounce → classify → reload if needed → extract → publish. Freshness was
|
|
316
|
+
pull-based because every sync from a cold process pays a full Rails boot; a process that
|
|
317
|
+
stays booted removes that tax without giving up runtime-true extraction. Development only —
|
|
318
|
+
it adds no network listener. See `docs/WATCH_DAEMON.md`.
|
|
319
|
+
- **Restart triggers, Spring-style.** A change to `Gemfile.lock`, `config/**`, or the schema
|
|
320
|
+
stops the daemon with a degraded status and exit `75` for a supervisor, because Rails'
|
|
321
|
+
reloader re-runs none of it.
|
|
322
|
+
- **Failure posture.** A failed reload (the mid-edit syntax error) publishes a degraded
|
|
323
|
+
status naming the reason and leaves the index intact at its last good generation. The
|
|
324
|
+
daemon never crash-loops, never publishes a partial write, and never advances the
|
|
325
|
+
generation over work that didn't land.
|
|
326
|
+
- **Storm handling.** Above a changed-file threshold (default 50) a branch switch falls back
|
|
327
|
+
to one full extraction rather than N incremental steps.
|
|
328
|
+
- **Two watcher backends.** The `listen` gem when the host has it; a dependency-free polling
|
|
329
|
+
scan otherwise — which is also the right choice inside a container, where native FS events
|
|
330
|
+
don't cross bind mounts reliably.
|
|
331
|
+
- **Multi-instance operation across worktrees** (#164, phase 4). Worktrees stay disjoint by
|
|
332
|
+
construction (own `Rails.root`, own `tmp/woods`), so the work is within one worktree: the
|
|
333
|
+
daemon, a manual `woods:extract`, and a hook-triggered `woods:incremental` now share the
|
|
334
|
+
existing `PipelineLock`. The daemon yields to another writer and carries its paths into the
|
|
335
|
+
next cycle rather than losing them; manual runs wait up to 30 s and then proceed with a
|
|
336
|
+
warning rather than hanging a terminal; and `woods:incremental` skips entirely when a live
|
|
337
|
+
daemon is already watching the tree (`WOODS_IGNORE_WATCH=1` overrides). New
|
|
338
|
+
`rake woods:watch_status` exits 0 when a daemon is alive, so a worktree hook can revive one
|
|
339
|
+
without parsing anything. `Woods::Watch::Daemon`'s `idle_timeout` (off by default) stops a
|
|
340
|
+
daemon in a dormant slot so it stops holding a booted app's memory.
|
|
341
|
+
- **An MCP freshness contract** (#164, phase 3). `woods_status` now reports the index
|
|
342
|
+
`generation` (number, when it moved, what moved it), whether the **working tree** is dirty
|
|
343
|
+
plus a fingerprint of it, and the watch daemon's state (`running` / `degraded` + reason /
|
|
344
|
+
`stopped` / `absent`). `git_sha_matches_head` only ever saw *committed* HEAD, so an agent
|
|
345
|
+
forty uncommitted edits deep was told the index matched while every answer described the
|
|
346
|
+
tree before those edits.
|
|
347
|
+
- **`IndexReader` self-refreshes on a generation change**, making the MCP `reload` tool an
|
|
348
|
+
optimization rather than a correctness requirement. A long-lived server used to hold
|
|
349
|
+
whatever it read at boot, so an agent working alongside a running extraction silently got
|
|
350
|
+
answers describing the tree as of the last server start. The check costs one `File.stat` of
|
|
351
|
+
a ~100-byte file per read. `IndexReader#with_pinned_generation` extends it across a sequence
|
|
352
|
+
of reads. Indexes with no generation file behave exactly as before.
|
|
353
|
+
- **`Woods::Generation`** — a monotonic marker for "which version of the index is on disk",
|
|
354
|
+
written atomically as the last step of a successful run by *every* extraction mode (full,
|
|
355
|
+
incremental, targeted refresh, daemon cycle). Never advanced by a run that failed or changed
|
|
356
|
+
nothing, so staleness stays honest.
|
|
357
|
+
- **`Extractor#refresh(*keys)` and `rake "woods:refresh[routes]"`** (#164, phase 1). Re-runs
|
|
358
|
+
named extractors wholesale against an already-booted app, replacing every unit of the types
|
|
359
|
+
they own. The unit types with no per-file entry point — routes, middleware, engines,
|
|
360
|
+
scheduled jobs, state machines, factories, events, database views — were only reachable by
|
|
361
|
+
full extraction from a cold boot, which was an artifact of the boot cost rather than
|
|
362
|
+
anything inherent: in a booted process re-running one extractor takes seconds. A routes
|
|
363
|
+
refresh cascades to the extractors that embed the route table. Any extractor key is
|
|
364
|
+
accepted, so `refresh(:models)` is a legitimate way to re-derive models after a schema
|
|
365
|
+
change.
|
|
366
|
+
- **`Woods::ReloadPolicy`** — the reload-trigger inventory (#164). Classifies a changed path
|
|
367
|
+
as `:ignore`, `:reextract` (Woods reads bytes; no Rails involvement), `:reload` (an
|
|
368
|
+
autoloaded constant changed) or `:restart` (boot-captured state changed — initializers,
|
|
369
|
+
`config/**`, `Gemfile.lock`, schema). Consumed by `Watch::Daemon` on every cycle, and
|
|
370
|
+
tested against the `railties >= 6.0` support matrix. `spec/reload_policy_spec.rb` derives
|
|
371
|
+
its samples from the `PathDispatcher` rules themselves, so the two cannot drift apart
|
|
372
|
+
silently.
|
|
373
|
+
- **Differential test harness for incremental extraction**
|
|
374
|
+
(`spec/integration/incremental_equivalence_spec.rb`, tagged `:booted_app`). Applies
|
|
375
|
+
randomized create/modify/delete/rename sequences to a booted fixture app and asserts, at
|
|
376
|
+
every step, that the incrementally-maintained index matches a cold full extraction: same
|
|
377
|
+
units, same unit content, same graph, PageRank recomputed. Tune with `WOODS_DIFF_OPS` and
|
|
378
|
+
`WOODS_DIFF_SEEDS`; runs in CI on every Rails-matrix row.
|
|
379
|
+
- `Woods::ChangeSet` — one normalization of "what changed" (absolutize, de-duplicate, split
|
|
380
|
+
present from vanished) shared by every entry point, so the git-diff caller and the watch
|
|
381
|
+
daemon can't drift apart.
|
|
382
|
+
|
|
383
|
+
### Performance
|
|
384
|
+
|
|
385
|
+
- **Controller and mailer chunk extraction parses each file once, not once per
|
|
386
|
+
action (P1).** `AstSourceExtraction#extract_action_source` re-read and re-parsed
|
|
387
|
+
the defining file for every action, so a 30-action controller cost 30 reads and
|
|
388
|
+
30 parses of one source file. The defining file is now read and parsed once per
|
|
389
|
+
extractor instance and every action is answered from that parse; per-action
|
|
390
|
+
chunk output is byte-identical.
|
|
391
|
+
|
|
392
|
+
- **Event extraction reads each file once per run (P2).** `EventExtractor`
|
|
393
|
+
re-read every publisher/subscriber file for every event that referenced it
|
|
394
|
+
(once per event in pass 2, on top of the pass-1 scan), so a widely-shared file
|
|
395
|
+
was read once per event. Both passes now share one read per path per run.
|
|
396
|
+
|
|
397
|
+
- **Rake task extraction reads and parses each `.rake` file once per run
|
|
398
|
+
(P9a).** `all_definitions` — the sibling-definition index built on the first
|
|
399
|
+
`sibling_definitions` call — re-read and re-parsed every `.rake` file the main
|
|
400
|
+
extraction path had just handled. Both paths now share a memoized read+parse.
|
|
401
|
+
|
|
402
|
+
- **GraphQL model-reference scanning makes one pass per type file (P9c).** The
|
|
403
|
+
constant follow-up check ran one full-source scan per unique capitalized
|
|
404
|
+
constant; one combined scan now collects the constants followed by a model
|
|
405
|
+
call. Emitted dependencies are unchanged.
|
|
406
|
+
|
|
407
|
+
- **Git metadata batching sends each pathspec once (P9d).** Multi-unit files
|
|
408
|
+
repeated their path in every 500-path git log batch; `batch_git_data` now
|
|
409
|
+
deduplicates before slicing. Output is keyed by relative path and unchanged.
|
|
410
|
+
|
|
411
|
+
- **User search regexes are time-bounded (P5).** `search` compiled the query as
|
|
412
|
+
a raw Ruby regex with no time limit, so a pattern with catastrophic
|
|
413
|
+
backtracking could stall the dispatch thread indefinitely. On Ruby 3.2+ the
|
|
414
|
+
compiled pattern carries a 1s per-match limit; an overrun aborts the scan
|
|
415
|
+
into a partial response with a note instead of hanging. Invalid patterns
|
|
416
|
+
still fall back to an escaped literal match.
|
|
417
|
+
|
|
418
|
+
- **`dependency_graph.json` parses once per generation (P6).** The Index
|
|
419
|
+
Server's `dependency_graph` and `raw_graph_data` each parsed the same file,
|
|
420
|
+
holding two copies of a large graph per generation; the typed graph now
|
|
421
|
+
builds from the single raw parse. Because `raw_graph_data` exposes that
|
|
422
|
+
shared parsed object, Woods recursively freezes it before publication so a
|
|
423
|
+
caller cannot mutate nested nodes or edges and corrupt later graph reads.
|
|
424
|
+
|
|
425
|
+
- **JSON temporal snapshots are pruned by retention (P8).** `snapshots/`
|
|
426
|
+
wrote one file per SHA and never pruned, growing unboundedly on long-lived
|
|
427
|
+
repos. Capture now keeps the newest `WOODS_PAYLOAD_RETENTION` snapshots
|
|
428
|
+
(default 3, same variable and default as payload retention), and the bound
|
|
429
|
+
holds even when the just-captured snapshot's timestamp ties or precedes
|
|
430
|
+
older entries; `diff` and `unit_history` beyond the retention window
|
|
431
|
+
return empty.
|
|
432
|
+
|
|
433
|
+
- **Redis session-tracer eviction uses a recency ZSET (P4).** `prune_sessions`
|
|
434
|
+
read every candidate session's history on every record once `max_sessions`
|
|
435
|
+
was reached (up to 1000 session reads per request). The `woods:sessions`
|
|
436
|
+
index is now a ZSET scored by each request's timestamp, so eviction touches
|
|
437
|
+
a bounded window and never reads session histories. A legacy SET index from
|
|
438
|
+
a previous version migrates automatically on the first record through a
|
|
439
|
+
single atomic server-side script, so concurrent writers racing the legacy
|
|
440
|
+
index cannot erase each other's members or fail mid-migration; eviction
|
|
441
|
+
order (oldest last request) is unchanged. Adds a live-Redis contract spec
|
|
442
|
+
(`spec/session_tracer/redis_store_live_spec.rb`, `WOODS_RUN_LIVE_BACKENDS=1`).
|
|
443
|
+
|
|
444
|
+
### Documentation
|
|
445
|
+
|
|
446
|
+
- **The documentation set is restructured for a cold reader** (from the
|
|
447
|
+
2026-09-03 documentation review). The README opens with the pitch and
|
|
448
|
+
reaches setup before any migration content, links Why Woods, and points
|
|
449
|
+
1.x users at the upgrade guide instead of restating it;
|
|
450
|
+
`docs/ARCHITECTURE.md` is renamed `docs/INTERNALS.md` (ending the name
|
|
451
|
+
collision with generated self-analysis output); Troubleshooting's quick
|
|
452
|
+
reference sits under its intro; the FAQ, tool cookbook, and configuration
|
|
453
|
+
reference open with question/scenario/options indexes; reference-doc
|
|
454
|
+
headings use sentence case; the release-cutting runbook lives in
|
|
455
|
+
CONTRIBUTING.md; and implementation plans moved under `docs/design/`.
|
|
456
|
+
`woods:self_analyze` no longer emits `call-graph.md`, `dependency-map.md`,
|
|
457
|
+
and `dataflow.md` — byte-identical duplicates of sections
|
|
458
|
+
`architecture.md` already embeds (13k generated lines removed).
|
|
459
|
+
|
|
460
|
+
- **The distributed plugin grows from three skills to five, with upgrade
|
|
461
|
+
coverage and reworked triggering** (plugin 2.2.0). `woods-investigate`
|
|
462
|
+
triggers on audits, code reviews, investigations, debugging, onboarding,
|
|
463
|
+
and change-impact assessment, distilling AGENT_GUIDE.md's tool-selection
|
|
464
|
+
workflow so index consumption — not just setup — has a skill.
|
|
465
|
+
`woods-agent-enable` wires Woods into a repository's own agent tooling:
|
|
466
|
+
project MCP configuration, index-first guidance in CLAUDE.md/AGENTS.md,
|
|
467
|
+
and an optional project skill seeded from real cluster/pagerank output.
|
|
468
|
+
`woods-setup` now covers the 1.x→2.0 upgrade path (clean re-index, no
|
|
469
|
+
in-place durable-index upgrade, full re-embed) and triggers on upgrades.
|
|
470
|
+
Every skill description states what the skill does as well as when to use
|
|
471
|
+
it, and skill prose no longer hardcodes a patch-precise version floor or a
|
|
472
|
+
pinned MCP protocol date: the marketplace entry is the authoritative
|
|
473
|
+
minimum, and skills operate against the preflight-recorded installed
|
|
474
|
+
version.
|
|
475
|
+
|
|
476
|
+
- **`woods-mcp-start` is described as what it is: a preflight wrapper (MCP-10).**
|
|
477
|
+
It validates the index directory and published manifest, then `exec`s
|
|
478
|
+
`woods-mcp`; there is no supervision and no restart loop. The "self-healing
|
|
479
|
+
MCP wrapper" description in the header comment and the CLAUDE.md architecture
|
|
480
|
+
table is gone. (`docs/MCP_SERVERS.md` and `docs/DOCKER_SETUP.md` already
|
|
481
|
+
described it accurately.)
|
|
482
|
+
- **Reload documentation drift (MCP-7).** `Bootstrapper.populate_vector_metadata`
|
|
483
|
+
named `populate_reloaded_vector_metadata`, a method the M7 transaction removed;
|
|
484
|
+
`Ranker#invalidate_pagerank_cache!` claimed a bootstrapper caller that does not
|
|
485
|
+
exist (the transaction installs a fresh Ranker instead, deliberately). Both
|
|
486
|
+
comments now describe the current design; `invalidate_pagerank_cache!` is
|
|
487
|
+
re-documented as an API for direct embedders that repoint a Ranker's graph
|
|
488
|
+
store without rebuilding it.
|
|
489
|
+
- **The unreleased-2.0 story is legible from the repository root.** `README.md`
|
|
490
|
+
gains a version banner naming both the documented 2.0.0 line and the published
|
|
491
|
+
1.6.1 gem, a "What's new in 2.0" comparison table, and a compact upgrade
|
|
492
|
+
checklist; `docs/UPGRADING_TO_2.md` covers the Notion physical-column re-sync,
|
|
493
|
+
the new task exit codes, the incremental baseline guard, and `reload`'s
|
|
494
|
+
write-access requirement; every claim that expires at tag time is wrapped in a
|
|
495
|
+
`release-state` fence, listed in the new release note in `docs/README.md`.
|
|
496
|
+
|
|
497
|
+
### Upgrade Notes
|
|
498
|
+
|
|
499
|
+
This is a major release: the full-gem review (#210) corrected how several extractors
|
|
500
|
+
derive unit identifiers, which changes the index format's observable contract.
|
|
501
|
+
|
|
502
|
+
- **Unit identifiers have changed shape.** Namespaces are now derived correctly by a
|
|
503
|
+
position-aware nesting parser (#174), abstract-model and mixin-module artifacts no
|
|
504
|
+
longer leak into names, and GraphQL inner classes are no longer folded into
|
|
505
|
+
identifiers (#194). Concretely: a state machine that indexed as `Payment::aasm` is now
|
|
506
|
+
`Billing::Payment::aasm`; a service that indexed as bare `IssueInvoice` is now
|
|
507
|
+
`Billing::IssueInvoice`; concern units are no longer misnamed `ClassMethods`.
|
|
508
|
+
**Anything that cached the old identifiers will miss**: saved retrieval queries,
|
|
509
|
+
external notes, exported Notion pages and Unblocked documents, MCP clients holding
|
|
510
|
+
identifier lists.
|
|
511
|
+
- **The remedy is one clean re-index**: `woods:clean` followed by `woods:extract`
|
|
512
|
+
(then `woods:embed` if you embed, and a re-export if you sync Notion/Obsidian/
|
|
513
|
+
Unblocked — the exporters reconcile renamed units as delete-plus-add).
|
|
514
|
+
- **Durable vector stores are now reconciled against extraction output** (#211). The
|
|
515
|
+
first `woods:embed` / `woods:embed_incremental` after upgrading will **delete** vectors
|
|
516
|
+
for units the extraction no longer produces — including every unit the identifier
|
|
517
|
+
renames moved. Deleting more than 30% of the store (or purging into an empty load) is
|
|
518
|
+
refused with an explanation; `WOODS_ALLOW_PURGE=1` overrides after you've confirmed the
|
|
519
|
+
deletion is intentional. On a rename-heavy index, expect to need it once.
|
|
520
|
+
- **An embedding-dimension mismatch now refuses to embed up front** (#214).
|
|
521
|
+
`woods:embed` compares the provider's dimension against what the store actually holds
|
|
522
|
+
(pgvector's column type, Qdrant's collection config) before embedding anything, raising
|
|
523
|
+
`Woods::MCP::DimensionMismatch` with both widths — instead of embedding everything and
|
|
524
|
+
failing per-row on insert. If you changed `embedding_model` at some point and it
|
|
525
|
+
"worked", this check may now surface the latent mismatch; the remedy is a full re-embed
|
|
526
|
+
into a store created at the new width.
|
|
527
|
+
- **New environment variables**: `WOODS_ALLOW_PURGE` (override the vector purge guard,
|
|
528
|
+
above) and `WOODS_NOTION_FORCE` (bypass the Notion sync manifest for one run, forcing
|
|
529
|
+
a full re-push).
|
|
530
|
+
|
|
531
|
+
### Changed
|
|
532
|
+
|
|
533
|
+
- **Release flow: `main` carries an alpha development marker.** `Woods::VERSION` is
|
|
534
|
+
`X.Y.Z.alpha` between releases, so `main` never claims a released version and the
|
|
535
|
+
gemspec points its source, changelog, and documentation URIs at the branch rather
|
|
536
|
+
than at a tag that does not exist. A release is an explicit commit plus a tag:
|
|
537
|
+
`bin/rake "release:prepare[<version>]"` bumps VERSION, folds `## [Unreleased]` into
|
|
538
|
+
`## [<version>] - <date>` with one block per `###` heading, restates the documentation
|
|
539
|
+
fences (renamed from `v2-unreleased-note` to `release-state`, now driven by VERSION
|
|
540
|
+
rather than by hand), regenerates the surface inventory, and prints the tag and
|
|
541
|
+
dispatch commands. `bin/rake "release:reopen[<next>.alpha]"` reopens development after
|
|
542
|
+
a release. `spec/release_v2/version_state_spec.rb` enforces the state on every commit,
|
|
543
|
+
both release validators refuse an alpha tag, and the `release` task `bundler/gem_tasks`
|
|
544
|
+
installs is blocked: nothing is published from a laptop. Betas and release candidates
|
|
545
|
+
are supported states, and RubyGems treats them as prereleases, so a `~> 1.6` or
|
|
546
|
+
`~> 2.0` constraint never resolves one. See the release flow section of
|
|
547
|
+
`CONTRIBUTING.md`.
|
|
548
|
+
|
|
549
|
+
- **Dead code removed.** The unwired formatting adapters (Claude, GPT, Generic),
|
|
550
|
+
console job/cache adapters, `StubBridge`, `HealthCheck`, `Instrumentation`,
|
|
551
|
+
`Notion::Mapper`, and a dozen spec-only methods are gone. `config.add_gem`
|
|
552
|
+
now warns like `config.extractors`: accepted, not implemented.
|
|
553
|
+
- **Docs rewritten for readability**: shorter sentences, tables for
|
|
554
|
+
comparisons, no em-dashes, one owner per fact. The MCP 2026 handoff document
|
|
555
|
+
was folded into the strategy ADR.
|
|
556
|
+
|
|
557
|
+
- **The packaged gem ships only user-facing files.** Internal release machinery
|
|
558
|
+
(`lib/tasks/release_v2.rake`, `lib/woods/release_v2/`) and non-user-facing
|
|
559
|
+
documentation subdirectories are excluded from the package; the repo keeps
|
|
560
|
+
them for CI. Historical build-phase design documents were removed from
|
|
561
|
+
`docs/` for the release and remain in git history.
|
|
562
|
+
- **The Claude plugin releases with the gem.** `plugin.json` is 2.0.2.
|
|
563
|
+
- **`config.extractors` warns when set.** The knob is accepted for forward
|
|
564
|
+
compatibility but extractor selection is not implemented; all extractors run.
|
|
565
|
+
Docs no longer teach it as a live setting. The unused `log_level` accessor
|
|
566
|
+
is removed.
|
|
567
|
+
- **`woods-mcp-start` no longer pins the MCP protocol version.** It defaulted
|
|
568
|
+
`MCP_PROTOCOL_VERSION` to `2024-11-05` — the oldest revision there is — which silently
|
|
569
|
+
opted every user out of four protocol revisions. The SDK server is dual-era, answering
|
|
570
|
+
`initialize` for legacy clients while serving `server/discover` and per-request metadata
|
|
571
|
+
for modern ones, so the unpinned server is the *more* compatible one. The variable
|
|
572
|
+
remains as an escape hatch and now announces itself on stderr when set.
|
|
573
|
+
**No action required:** legacy clients keep working, and no re-extraction or re-embedding
|
|
574
|
+
is implied — no on-disk artifact format changed.
|
|
575
|
+
|
|
576
|
+
- `woods:watch_status` no longer depends on `:environment`. It reads one small JSON file, and
|
|
577
|
+
the point is that a worktree hook can call it *before* deciding whether to do real work —
|
|
578
|
+
paying a full Rails boot to find out cost more than the sync it exists to avoid.
|
|
579
|
+
- `WOODS_WATCH_POLL=1` forces the polling backend. `docs/WATCH_DAEMON.md` told container hosts
|
|
580
|
+
watching a bind mount to do this; nothing exposed it. `WOODS_WATCH_IDLE_TIMEOUT` and
|
|
581
|
+
`WOODS_WATCH_CATCH_UP` are exposed on the rake task for the same reason.
|
|
582
|
+
- The debounce window now genuinely coalesces. The watcher callback merges into a pending set
|
|
583
|
+
and returns, so a save, the formatter's rewrite and the linter's touch become one cycle
|
|
584
|
+
rather than three — previously `settle` only delayed the first of the three.
|
|
585
|
+
- `spec/reload_policy_spec.rb` derives its samples from the `PathDispatcher` rules rather than
|
|
586
|
+
a hand-written list, so a rule added for a new extractor is covered without editing the
|
|
587
|
+
spec. `spec/support/index_comparison.rb` compares PageRank *values* (to 6 dp) rather than
|
|
588
|
+
just keys — comparing keys alone said nothing about the modify-only operations most likely
|
|
589
|
+
to leave scores stale.
|
|
590
|
+
- `GraphAnalyzer` output is now a pure function of graph content. `hubs` breaks ties on
|
|
591
|
+
identifier instead of graph-insertion order, and `bridges` samples from a sorted node list,
|
|
592
|
+
so two extractions of the same tree publish the same analysis.
|
|
593
|
+
|
|
594
|
+
- New `Woods::Extractors::LineNeutralizer`: the quote-aware comment stripper (formerly private to
|
|
595
|
+
`SharedDependencyScanner`) plus string- and heredoc-blanking variants, shared by SourceNesting,
|
|
596
|
+
the rake and factory parsers, and CallbackAnalyzer.
|
|
597
|
+
- New `Woods::Extractors::ReferencePatterns`: the namespace-capable service, mailer, and job-enqueue
|
|
598
|
+
regexes, shared by `SharedDependencyScanner`, `JobExtractor`, and `CallbackAnalyzer`.
|
|
599
|
+
|
|
600
|
+
- **`FlowPrecomputer`'s `fail_closed:` switch is gone (EXTB-14).** Both callers
|
|
601
|
+
passed `true` and CLAUDE.md documents both paths as fail-closed, but the YARD
|
|
602
|
+
still promised the full path a "log-and-skip contract" and the
|
|
603
|
+
`Rails.logger.error` branch it described was dead code. Flow assembly now always
|
|
604
|
+
raises `Woods::ExtractionError` on a per-action failure.
|
|
605
|
+
|
|
606
|
+
- **An unlisted integer tool parameter fails the server build with a named error
|
|
607
|
+
(MCP-8).** `ToolContract::INTEGER_BOUNDS.fetch` raised a bare
|
|
608
|
+
`KeyError: key not found: "count"` at `Server.build`, with no pointer to the
|
|
609
|
+
table that needs the entry. It now raises an `ArgumentError` naming the tool,
|
|
610
|
+
the property, and `INTEGER_BOUNDS`.
|
|
611
|
+
|
|
612
|
+
- CI gains a `c-locale` job running the artifact-reader specs (`spec/mcp`,
|
|
613
|
+
`spec/feedback`, `spec/session_tracer`, `spec/evaluation`) under `LC_ALL=C`. The
|
|
614
|
+
runners default to a UTF-8 locale, so the whole encoding family could regress
|
|
615
|
+
with the suite green.
|
|
616
|
+
- Three specs that simulate an unreadable/read-only path with `chmod` now skip as
|
|
617
|
+
root, where `chmod` is ineffective (`spec/operator/pipeline_guard_spec.rb` x2,
|
|
618
|
+
`spec/mcp/tasks/pipeline_tasks_spec.rb`).
|
|
619
|
+
- `docs/backlog.json` gains B-140..B-163: the seven prior-audit deferred lows that
|
|
620
|
+
never received IDs (R2-2 — L3, L7, L12, L13, L14, L15, L21) plus seventeen
|
|
621
|
+
deferrals from this audit (CORE-3, CORE-5, EXTA-7, EXTA-9, EXTA-11, EXTA-13,
|
|
622
|
+
EXTA-15, CON-4, STO-7, STO-10, STO-14, INF-5, INF-6, INF-13, R2-4, R2-5, R2-6).
|
|
623
|
+
|
|
624
|
+
- **STO-12**: Corrected the `Storage::Snapshotter` doc comment: `Snapshotter::Metadata.validate_store!` can use plain `respond_to?` only because `MetadataStore::Interface` defines neither `#each_entry` nor `#bulk_load` — adding either stub would silently convert the check into the B-108 bug. `spec/storage/snapshotter/vector_spec.rb`'s float-truncation tolerance block (which passed whether or not the load raised, stale since the M10 fix) now asserts the raise.
|
|
625
|
+
|
|
626
|
+
### Fixed
|
|
627
|
+
|
|
628
|
+
- **The release contract specs pass in every version state.** `spec/release_v2`
|
|
629
|
+
copied this checkout's `CHANGELOG.md`, `README.md`, `CONTRIBUTING.md`, and
|
|
630
|
+
`docs/UPGRADING_TO_2.md` into its temporary repository, so eleven examples
|
|
631
|
+
assumed the checked-in tree was an alpha with a non-empty Unreleased section.
|
|
632
|
+
A release commit produced by `release:prepare` therefore could not pass its
|
|
633
|
+
own specs, which defeats the flow. The transition specs now run against a
|
|
634
|
+
self-contained fixture (`spec/fixtures/release_repository`), each of them
|
|
635
|
+
keeps one example that reads this checkout and asserts only the invariants of
|
|
636
|
+
the state it finds, and `spec/release_v2/release_state_matrix_spec.rb`
|
|
637
|
+
generates the alpha, prerelease, and final states from the fixture in one run
|
|
638
|
+
and checks all three plus the running checkout against the same invariants.
|
|
639
|
+
The gemspec metadata example asserts the ref rule (`tree/main` for an alpha,
|
|
640
|
+
`tree/v<VERSION>` otherwise) instead of a fixed state.
|
|
641
|
+
|
|
642
|
+
- **Components outside the eager-load paths are indexed.** `PhlexExtractor` and
|
|
643
|
+
`ViewComponentExtractor` discovered units from `component_base.descendants`
|
|
644
|
+
after `eager_load!`, so a component under an autoloaded but not eager-loaded
|
|
645
|
+
subtree of `app/views` was never a descendant and never indexed. Both now ask
|
|
646
|
+
the autoloader for every Ruby file under the component directories first, and
|
|
647
|
+
the directory list is configurable through `config.component_paths` (default
|
|
648
|
+
`app/components`, `app/views/components`, `app/views`; an empty array walks
|
|
649
|
+
nothing). Nested directories collapse into their ancestor, and one debug line
|
|
650
|
+
per run reports files whose directory is not on an autoload path. The
|
|
651
|
+
ViewComponent path is skipped entirely when `ViewComponent::Base` is
|
|
652
|
+
undefined.
|
|
653
|
+
- **Git enrichment refuses to invent history it cannot read.** Over a
|
|
654
|
+
containerized linked worktree, `git rev-parse --git-dir` succeeds while no ref
|
|
655
|
+
resolves (the private git directory reaches the shared one through a relative
|
|
656
|
+
`commondir` pointer), `git log` exits 0 with no output, and every unit was
|
|
657
|
+
written with `commit_count: 0` and `change_frequency: "new"`,
|
|
658
|
+
indistinguishable from a file that was never committed. Enrichment now
|
|
659
|
+
requires `git rev-parse HEAD` to succeed: when it does not, the git keys are
|
|
660
|
+
omitted from every unit, provenance records `"unknown"`, and one warning names
|
|
661
|
+
git's own reason. `docs/TROUBLESHOOTING.md` and
|
|
662
|
+
`docs/CONFIGURATION_REFERENCE.md` state that `GIT_DIR` alone is not enough for
|
|
663
|
+
a linked worktree.
|
|
664
|
+
- **`dependents` and `dependencies` are bounded and say when they truncate.**
|
|
665
|
+
Both tools now return at most 50 traversal nodes by default, accept `limit`
|
|
666
|
+
and `offset` like `graph_analysis`, and print the same
|
|
667
|
+
`Showing N of M (truncated)` line; their descriptions name `depth`, `types`
|
|
668
|
+
and `via` as the controls that shrink an answer rather than page it. Every
|
|
669
|
+
partial answer carries `nodes_total`, the last page of a walk included, so a
|
|
670
|
+
page is never mistaken for a complete result. Rows carry the unit's database,
|
|
671
|
+
but only in a graph that spans more than one, so a single-database index
|
|
672
|
+
renders exactly as before.
|
|
673
|
+
- **`graph_analysis.json` states its own volatile-dependency cap.**
|
|
674
|
+
`stats.volatile_dependencies_limit` (20) now sits beside
|
|
675
|
+
`stats.volatile_dependency_count`, so a reader of the truncated
|
|
676
|
+
`volatile_dependencies` array can tell a page from the whole population.
|
|
677
|
+
`docs/INTERNALS.md` gains a table mapping every analysis section to its stat
|
|
678
|
+
key and naming which sections the artifact caps.
|
|
679
|
+
- **`graph_sha` is now a function of graph content, not of registration order.**
|
|
680
|
+
`DependencyGraph#to_h` sorted neither the members of `reverse`, `file_map` and
|
|
681
|
+
`type_index` nor the keys of any section, and the extractor wrote PageRank in
|
|
682
|
+
node-registration order, so a no-op incremental run republished an identical
|
|
683
|
+
graph under a new digest and every consumer caching on `graph_sha` redid its
|
|
684
|
+
work. The incremental equivalence oracle now compares `graph_sha` as well.
|
|
685
|
+
- Respect MySQL session quote modes throughout Console SQL checks, including
|
|
686
|
+
`ANSI_QUOTES` and `NO_BACKSLASH_ESCAPES`, without rejecting ordinary escaped literals.
|
|
687
|
+
- Preserve absolute Ruby constants through both AST backends and static-map resolution.
|
|
688
|
+
- Compare evaluation baselines against public names after typed storage transitions.
|
|
689
|
+
|
|
690
|
+
- Preserve coexisting typed units through embedding, checkpoints, retrieval and
|
|
691
|
+
snapshots. SQLite migration 007 retains old snapshot rows; older JSON snapshots
|
|
692
|
+
remain readable. Remove metadata when the corresponding vanished vectors are pruned.
|
|
693
|
+
- Keep precomputed flows for namespaced and underscore-named controllers separately
|
|
694
|
+
addressable, and resolve known lexical module dependencies in the static self-map.
|
|
695
|
+
- Remove machine-specific session and host instructions from shared contributor context.
|
|
696
|
+
- Keep release-inventory drift tests from temporarily modifying real source and guide files.
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
- **Wrapper-nested classes no longer collide on one identifier.** A file under
|
|
700
|
+
a managed autoload path is now named for the constant its path spells
|
|
701
|
+
(Zeitwerk-governed naming): `app/services/domain/container/parser.rb`
|
|
702
|
+
declaring `module Domain; class Container; class Parser` indexes as
|
|
703
|
+
`Domain::Container::Parser` instead of the wrapper `Domain::Container`.
|
|
704
|
+
Previously every sibling under the same wrapper indexed as the wrapper and
|
|
705
|
+
same-type dedup silently dropped all but one. The source parser remains the
|
|
706
|
+
fallback for unmanaged or unconventional paths, and extraction now aborts
|
|
707
|
+
naming both file paths when one type+identifier is still derived from two
|
|
708
|
+
different files. **Re-extract after upgrading** — embeddings, exports, and
|
|
709
|
+
saved queries keyed by the old identifiers need regeneration.
|
|
710
|
+
|
|
711
|
+
- **Console stdio setup now explains production token validation.** Stdio clients do
|
|
712
|
+
not send the HTTP bearer token, but Rails still requires a 32-character-or-longer
|
|
713
|
+
`console_mcp_token` at production boot whenever Console MCP is enabled. The upgrade,
|
|
714
|
+
direct, Docker, FAQ, and agent setup paths now state that boundary explicitly.
|
|
715
|
+
|
|
716
|
+
- **Console SQL gate no longer has MySQL comment and dollar-quote blind spots.**
|
|
717
|
+
`SqlNoiseStripper` did not know `#` line comments or `/*! ... */` executable
|
|
718
|
+
comments (both live SQL on MySQL), and treated a `$` inside a PostgreSQL
|
|
719
|
+
identifier as a dollar-quote opener, so a blocked table could be hidden from
|
|
720
|
+
`TableGate`. The `TABLE name` statement form was never scanned at all. All
|
|
721
|
+
four are closed, with the `#` rule gated on the MySQL dialect.
|
|
722
|
+
- **Redacted columns are refused as query inputs, not only masked on output.**
|
|
723
|
+
`console_aggregate(column:)`, scope keys (including `_matches`), `find(by:)`,
|
|
724
|
+
and `recent(order_by:)` accepted `console_redacted_columns`, which gave a
|
|
725
|
+
plaintext aggregate or a comparison oracle over a secret.
|
|
726
|
+
- **The MySQL console timeout no longer leaks into the host's connection pool.**
|
|
727
|
+
`SET max_execution_time` is session-scoped and survives rollback; the prior
|
|
728
|
+
value is now read and restored in `ensure`.
|
|
729
|
+
- **Console SQL validation stops rejecting columns named `do`, `start`,
|
|
730
|
+
`lock`, `release`, or `handler`.** Forbidden statement keywords now match only
|
|
731
|
+
at a statement-leader position. `EXPLAIN ANALYSE` (the PostgreSQL spelling) is
|
|
732
|
+
rejected like `ANALYZE`. `console_association_count` gates the rendered SQL, so
|
|
733
|
+
a blocked `through` table is refused.
|
|
734
|
+
- **Long-running MCP tasks no longer expire the moment they complete.** Terminal
|
|
735
|
+
task ttl is measured from the terminal transition, not from creation. A task
|
|
736
|
+
minted under a different boot identity (a container) is left alone by a host
|
|
737
|
+
reader instead of being marked failed.
|
|
738
|
+
- **Extractor accuracy batch.** `permitted_params` no longer leaks across method
|
|
739
|
+
bodies (and reads Rails 8 `params.expect`); GraphQL complexity is read from a
|
|
740
|
+
real match; per-file GraphQL classification agrees with the runtime pass;
|
|
741
|
+
`form_action` edges stop at `do`/`end`; `SourceNesting` pops on `end.freeze`;
|
|
742
|
+
`render :partial => 'x'` resolves the real partial; a rake task whose name
|
|
743
|
+
contains `do` no longer swallows its neighbours; inline-namespaced migrations
|
|
744
|
+
are extracted; mounted engines are unwrapped from `Mapper::Constraints` so
|
|
745
|
+
`mounted_path` is populated, and `Rails::Application` is no longer reported as
|
|
746
|
+
an engine.
|
|
747
|
+
- **An empty vector dump no longer refuses to boot.** `woods:embed` over an empty
|
|
748
|
+
payload wrote `dimension = 0` and every later boot raised `DimensionMismatch`.
|
|
749
|
+
- **Storage hardening.** Interface stubs are no longer probed with `respond_to?`
|
|
750
|
+
(B-108) in the retryable provider, builder, indexer, and ranker; Notion
|
|
751
|
+
read-only POSTs retry on a network failure; the watch daemon's stale-claim
|
|
752
|
+
reclaim checks the claim inode before removing it and falls back to an
|
|
753
|
+
exclusive create where `File.link` is unsupported; `InMemory#delete_by_filter`
|
|
754
|
+
honours array filters like `#search`.
|
|
755
|
+
- **Task orphan detection compares pid namespaces, not only boot ids.** Docker
|
|
756
|
+
on Linux shares the host kernel boot id, so a host reader could judge a
|
|
757
|
+
container's task by an unrelated host pid. The producer identity now carries
|
|
758
|
+
`/proc/<pid>/ns/pid`, and a task from another namespace is left alone.
|
|
759
|
+
- **Daemon claim reclaim runs under an `flock`.** A byte comparison before the
|
|
760
|
+
delete still left a read-then-unlink window where two starters could both
|
|
761
|
+
end up as claim owners; the whole reclaim-and-create loop is now one
|
|
762
|
+
critical section on a sidecar lock file, released by the kernel on death.
|
|
763
|
+
- **Snapshot capture retries a locked SQLite database.** SQLite skips the busy
|
|
764
|
+
handler in its deadlock-avoidance case, so two concurrent captures could
|
|
765
|
+
fail at `BEGIN IMMEDIATE` despite `busy_timeout`. Three bounded attempts.
|
|
766
|
+
- **`woods:clean` and `woods:validate` no longer raise `NameError` in a host
|
|
767
|
+
app.** `woods.rake` reached `Woods::Generation` through the extractor, which
|
|
768
|
+
those tasks never load. Caught by every woods-testbed variant.
|
|
769
|
+
- **The daemon's stale-claim race guard compares bytes, not the inode.** Linux
|
|
770
|
+
reuses a freed inode for the next file in the directory, so the inode check
|
|
771
|
+
let a just-replaced live claim be deleted. Failed on CI, passed on macOS.
|
|
772
|
+
- **Routes that differ only by constraint are all indexed.** The identifier is
|
|
773
|
+
`VERB /path`, qualified by request constraints when the route has any:
|
|
774
|
+
`GET /users [subdomain=api]`, `GET /users [format=json]`, `constraint=proc`
|
|
775
|
+
for a callable. Routes that still collide are numbered in route order
|
|
776
|
+
(`GET /users #2`) instead of being dropped. Unconstrained routes keep their
|
|
777
|
+
old identifier; a constrained one changes, so the clean re-index above
|
|
778
|
+
covers it.
|
|
779
|
+
- **A rake task reopened in two `.rake` files is one unit**, the way Rake sees
|
|
780
|
+
it: its source carries every definition, `metadata.defined_in` lists the
|
|
781
|
+
files, and a per-file incremental run produces the same merged unit as a
|
|
782
|
+
full run. Previously the second file overwrote the first.
|
|
783
|
+
- **`woods:validate` and `Resilience::IndexValidator` are one implementation.**
|
|
784
|
+
The task now runs the class, which gained the task's manifest-count,
|
|
785
|
+
unit-file, file-path, and dependency-graph checks (`app_root:` opts into
|
|
786
|
+
the file-path check).
|
|
787
|
+
- **`EXPLAIN (FORMAT JSON) SELECT` is accepted.** The option list was read as
|
|
788
|
+
a call to a function named `EXPLAIN`. `EXPLAIN ANALYZE` in any form is
|
|
789
|
+
still refused.
|
|
790
|
+
- **Class names are position-aware in every file-scanning extractor.** Jobs,
|
|
791
|
+
serializers, decorators, policies, Pundit policies, managers, and validators
|
|
792
|
+
took the first `class` token in the file, so `module Billing; class ChargeJob`
|
|
793
|
+
indexed as bare `ChargeJob` (and the class-based second pass then added a
|
|
794
|
+
duplicate `Billing::ChargeJob`), while the decorator scanner joined every
|
|
795
|
+
`module` token, including helpers nested inside the class. All of them now
|
|
796
|
+
go through `SourceNesting#qualified_first_class_name` (#174).
|
|
797
|
+
- **Policy `evaluated_models` no longer invents models from parameter syntax.**
|
|
798
|
+
`def initialize(order, user = nil, strict: false)` produced `Nil`, `Strict`,
|
|
799
|
+
and `False` model edges; only bare positional parameters are read now.
|
|
800
|
+
- **The release gate requires the booted-extraction matrix.**
|
|
801
|
+
`script/validate-release-run` did not list the `rails-matrix` CI job, so a
|
|
802
|
+
red Rails 6.0 to 8.1 row could not block a release.
|
|
803
|
+
- **Spec order no longer leaks a nil `Woods.configuration`.** Four spec files
|
|
804
|
+
nil it out in `after` hooks; `spec_helper` now restores whatever each example
|
|
805
|
+
started with, which fixes two seed-dependent failures in `extractor_spec`.
|
|
806
|
+
|
|
807
|
+
- **Every MCP entry point boots a payload-layout index.** The #226 layout moved
|
|
808
|
+
`manifest.json` into `payloads/gen-<N>/`, but `woods-mcp`, `woods-mcp-http`,
|
|
809
|
+
`woods-mcp-start`, the retriever's graph hydration, and the operator status
|
|
810
|
+
reporter still looked for root-level artifacts: a fresh 2.0 extract was refused
|
|
811
|
+
at boot with "Run `rake woods:extract` first", the retriever's graph store
|
|
812
|
+
hydrated empty (silent loss of PageRank and graph expansion), and status read
|
|
813
|
+
`:not_extracted`. All five now resolve through the generation pointer, with the
|
|
814
|
+
legacy flat layout still accepted.
|
|
815
|
+
- **MCP `reload` no longer crashes the stdio server on pgvector/Qdrant.** The
|
|
816
|
+
reload metadata backfill guarded on `respond_to?(:each_entry)`, which the
|
|
817
|
+
vector-store interface answers true for while raising `NotImplementedError`
|
|
818
|
+
(the B-108 anti-pattern); on stdio that unwound the transport loop and killed
|
|
819
|
+
the process. The guard is now an ownership check.
|
|
820
|
+
- **Wholesale replacement prunes by `(identifier, type)`.** The #225 typed-graph
|
|
821
|
+
work missed one call site: a factories (or any wholesale) re-run removing a
|
|
822
|
+
vanished unit deleted every type sharing the identifier, so a same-named
|
|
823
|
+
Scenic view's node and JSON file vanished from the index until a full run.
|
|
824
|
+
Colliding identifiers also now serialize dependents on every sharing unit and
|
|
825
|
+
carry each type's own git metadata instead of one type's history.
|
|
826
|
+
- **Incremental runs abort instead of publishing a collapsed index.** When
|
|
827
|
+
payload creation failed over a payload-born index, the degrade path published
|
|
828
|
+
a near-empty flat root and redirected readers to it. Incremental and refresh
|
|
829
|
+
runs now raise without bumping the generation; full runs keep the flat
|
|
830
|
+
fallback (their write set is complete). Payload seeding also gained the
|
|
831
|
+
cross-device copy fallback, and payload pruning survives a restarted
|
|
832
|
+
generation counter.
|
|
833
|
+
- **Woods artifacts are read encoding-safely everywhere.** Twelve remaining bare
|
|
834
|
+
`File.read` sites (extractor incremental path, rake validate/stats/flow, the
|
|
835
|
+
index validator) crashed with `Encoding::InvalidByteSequenceError` under
|
|
836
|
+
`LANG=C` (the documented daemon container environment) on any multibyte byte.
|
|
837
|
+
All now use `AtomicFile.read`.
|
|
838
|
+
- **Extraction-family rake tasks honor `config.output_dir`.** They hardcoded
|
|
839
|
+
`tmp/woods` while the embed and export families used the configured
|
|
840
|
+
directory, so a host that set `output_dir` split its index in two silently.
|
|
841
|
+
- **The watch daemon captures files changed during startup catch-up.** The
|
|
842
|
+
watcher started only after catch-up finished, so a save during a long
|
|
843
|
+
catch-up extraction was lost until the next edit. The watcher now starts
|
|
844
|
+
first. A dead container daemon's startup claim also no longer blocks a
|
|
845
|
+
host-side daemon forever (host identity is compared before trusting pid
|
|
846
|
+
liveness), and a lock-release failure after a successful cycle no longer
|
|
847
|
+
relabels the cycle as a lock failure.
|
|
848
|
+
- **Retrieval ranking and budgeting defects.** RRF source merging demoted
|
|
849
|
+
strong cross-source hits into the supporting section; the framework partition
|
|
850
|
+
never fired for real graph-expansion candidates; empty metadata could shadow
|
|
851
|
+
real metadata; `:within_type_fallback` was reported for types the fallback
|
|
852
|
+
never returned; an empty supporting section stranded ~35% of the token budget;
|
|
853
|
+
keyword scores encoded arbitrary database row order as the dominant ranking
|
|
854
|
+
signal. All fixed; keyword scores now derive from matched-field counts.
|
|
855
|
+
- **Console SQL validation stops rejecting English.** Body keyword scans ran
|
|
856
|
+
over string literals, so `WHERE body = 'please update the record'` was refused
|
|
857
|
+
as an UPDATE; scans now run over noise-stripped SQL while comment-hidden
|
|
858
|
+
injections stay caught. `MERGE` joined the forbidden set, recursive writable
|
|
859
|
+
CTEs get the specific error, and the stdio transport passes the table map so
|
|
860
|
+
qualified `table.column` references validate like they do over HTTP.
|
|
861
|
+
- **Exporter clients treat ambiguous 503s honestly.** A 503 for a
|
|
862
|
+
non-idempotent create (Notion `create_page`, Unblocked `create_collection`)
|
|
863
|
+
can arrive from an intermediary after the origin committed, so those now
|
|
864
|
+
raise the ambiguous-outcome error instead of retrying into a duplicate; 429
|
|
865
|
+
and idempotent requests retry as before. Notion's `query_all` gained the
|
|
866
|
+
nil-cursor loop guard, and the two clients' retry budgets now agree.
|
|
867
|
+
- **Session tracer fairness and hygiene.** The Redis store evicted arbitrary
|
|
868
|
+
sessions at the cap (now oldest-first, matching the file store), and
|
|
869
|
+
client-controlled header values are escaped before landing in the
|
|
870
|
+
session-context document served to agents.
|
|
871
|
+
- **Storage edge paths.** Dump-capability detection uses ownership checks
|
|
872
|
+
(typed `InapplicableBackend` instead of a bare `NotImplementedError`), the
|
|
873
|
+
in-memory metadata search no longer matches on injected timestamps, dump
|
|
874
|
+
pruning can never delete the just-promoted dump after a backward clock step,
|
|
875
|
+
and the tasks store sweeps corrupt records older than the TTL.
|
|
876
|
+
- **A generation's payload is published atomically (#226).** `generation.json` was
|
|
877
|
+
bumped atomically and last, but it named the output root — a directory of
|
|
878
|
+
independently-written files — so a reader refreshing mid-publish could load a
|
|
879
|
+
unit from generation N+1 beside a manifest from N. Extraction now publishes
|
|
880
|
+
into `payloads/gen-<N>/` and names that directory from `generation.json`, so
|
|
881
|
+
the single atomic write of that file commits the whole payload; a reader sees
|
|
882
|
+
one generation whole, including artifacts it had not read yet. Incremental
|
|
883
|
+
runs seed their directory from the published one with hardlinks, so an
|
|
884
|
+
unchanged artifact costs a directory entry rather than a copy. Three
|
|
885
|
+
generations are retained by default (`WOODS_PAYLOAD_RETENTION`).
|
|
886
|
+
**This changes the on-disk layout.** No re-index is required — the first run
|
|
887
|
+
after upgrading publishes a payload and reading is unaffected, since every
|
|
888
|
+
Woods reader resolves the pointer — but anything outside Woods that reads
|
|
889
|
+
`tmp/woods/manifest.json`, `tmp/woods/dependency_graph.json` or
|
|
890
|
+
`tmp/woods/<type>/*.json` directly must now read `generation.json`, take its
|
|
891
|
+
`payload` value, and resolve relative to the index directory. Files left at
|
|
892
|
+
the root by a pre-2.0 run are stale from the first payload publish onward;
|
|
893
|
+
`woods:clean` removes them.
|
|
894
|
+
- **Units of different types no longer collapse onto one graph node (#225).** A Scenic
|
|
895
|
+
view `reports` and a factory `reports` are two units and the index has always written
|
|
896
|
+
them to two files, but `DependencyGraph` keyed nodes on the bare identifier, so
|
|
897
|
+
registering the second destroyed the first's reverse edges, `file_map` entry and
|
|
898
|
+
`type_index` entry. Both now coexist as typed nodes. Deleting one type's source file
|
|
899
|
+
removes that type's node and its JSON only; incremental re-extraction, git enrichment
|
|
900
|
+
and the `dependents` rewrite fan out over every type an identifier names; and the MCP
|
|
901
|
+
traversal tools follow both units' edges and report `types` when an identifier is
|
|
902
|
+
ambiguous instead of picking one silently. **No re-index is required** — the persisted
|
|
903
|
+
graph is unchanged for any index with no shared identifier, and identifiers that are
|
|
904
|
+
shared add a `variants` array that older graphs simply do not carry.
|
|
905
|
+
- **Release-hardening batch (2026-08-07, #211–#218, #220):**
|
|
906
|
+
- *Embedding durability:* every pgvector/Qdrant embed run crashed at the very end and
|
|
907
|
+
discarded its work — `Indexer#persistable?` asked `respond_to?(:each_entry)`, which
|
|
908
|
+
the storage interface answers true for by definition; it now asks which module owns
|
|
909
|
+
the method (B-108, #220). Durable stores are reconciled against extraction output on
|
|
910
|
+
full and incremental runs, with the purge guard shared with the dump path (#211).
|
|
911
|
+
Checkpoint hits on durable backends are verified against the store, so switching
|
|
912
|
+
`vector_store` from `:local` to pgvector/Qdrant no longer strands unchanged units
|
|
913
|
+
(#211). Qdrant mutating point operations pass `wait=true`, so a delete is readable
|
|
914
|
+
as deleted (#220). Dimension mismatches are detected before embedding, not per-row
|
|
915
|
+
after (#214).
|
|
916
|
+
- *Extraction fidelity:* blockless factories (`factory :admin, parent: :user`) are
|
|
917
|
+
extracted; abstract models no longer enter the model-name scan (which inflated
|
|
918
|
+
`ApplicationRecord`'s PageRank); `private def` methods are no longer reported
|
|
919
|
+
public; `EventExtractor` no longer mints phantom events from non-Wisper `.on(:sym)`
|
|
920
|
+
calls; `ManagerExtractor` resolves multi-word models (`order_item` → `OrderItem`,
|
|
921
|
+
not `Order_item`) (#215).
|
|
922
|
+
- *Flow/graph determinism:* `find_node_by_suffix` is memoized (was a full-graph scan
|
|
923
|
+
per call) and resolves ambiguous short names deterministically; `case` predicates
|
|
924
|
+
are no longer misattributed as branch operations; `domain_clusters` output no longer
|
|
925
|
+
depends on registration order; vector dumps record the embedding model in the WVF1
|
|
926
|
+
header (#216).
|
|
927
|
+
- *Export hardening:* the Unblocked client redacts its bearer token in error paths and
|
|
928
|
+
describes its per-run (not "daily") budget honestly; `Retry-After` is capped at 120s
|
|
929
|
+
in both export clients; Unblocked citations use the ref recorded in the manifest
|
|
930
|
+
rather than hardcoded `blob/main`, with path segments percent-encoded; Notion aborts
|
|
931
|
+
fast on 401/403 instead of spending the whole cold sync failing per-unit (#217).
|
|
932
|
+
- *Retrieval/observability residuals:* ranking signals no longer go neutral on chunked
|
|
933
|
+
corpora (the ranker now strips chunk suffixes like every other consumer); metadata
|
|
934
|
+
keyword search is case-insensitive on both adapters with the contract pinned by
|
|
935
|
+
shared examples; `IndexReader`'s LRU is thread-safe under the HTTP transport;
|
|
936
|
+
`GapDetector` counts queries, not keyword occurrences; `RedisStore#sessions` returns
|
|
937
|
+
recent sessions rather than arbitrary ones (#218).
|
|
938
|
+
- *MCP:* the `pipeline_extract` tool loads the extractor lazily, so it works in a
|
|
939
|
+
standalone `woods-mcp` process instead of dying in the background with
|
|
940
|
+
`NameError: uninitialized constant Woods::Extractor` (B-110).
|
|
941
|
+
- **The evaluation harness is runnable** (#212). `woods:evaluate` existed on no host (its
|
|
942
|
+
rake file was never loaded by the railtie), called accessors that had never existed, and
|
|
943
|
+
no adapter implemented `all_identifiers` for the baselines. It now loads, builds stores
|
|
944
|
+
through the MCP bootstrapper so evaluation reads the same persisted index that semantic
|
|
945
|
+
search serves, and ships an offline end-to-end smoke on the `:fake` provider. The
|
|
946
|
+
ground-truth taxonomy now *is*
|
|
947
|
+
`QueryClassifier::INTENTS`/`SCOPES`, so annotations compare against what the pipeline
|
|
948
|
+
actually classified (#218's open item, closed here).
|
|
949
|
+
|
|
950
|
+
- **Full-gem review batch (2026-07-30): 30 defects fixed** (#183–#209 and pre-existing
|
|
951
|
+
#149, #150, #169, #170, #174–#178; see each issue for the full analysis). Highlights,
|
|
952
|
+
grouped by blast radius:
|
|
953
|
+
- *Host-app safety:* enabling the documented console-MCP mode no longer 401s the entire
|
|
954
|
+
host application (guards are path-scoped, enablement is decided at request time), and
|
|
955
|
+
the enable flags now work from `config/initializers/woods.rb` (#183).
|
|
956
|
+
- *Retrieval correctness:* type-filtered `codebase_retrieve` no longer returns empty on
|
|
957
|
+
every booted server (symbol-keyed vector metadata on boot and reload, #150); the
|
|
958
|
+
weighted ranking layer actually ranks (live keyword signal, normalized RRF, assembler
|
|
959
|
+
honors ranked order, PageRank memo invalidated on reload, #185); classifier-derived
|
|
960
|
+
target types no longer hard-filter vector search on common English words (#184);
|
|
961
|
+
framework units are no longer duplicated across context sections (#186).
|
|
962
|
+
- *Extraction fidelity:* concern inlining works for compact-style class declarations and
|
|
963
|
+
concern-defined callbacks now yield side effects, for models and (new) controllers
|
|
964
|
+
(#193, #175); a shared position-aware nesting parser fixes namespace derivation in five
|
|
965
|
+
source-parsing extractors (#174); polymorphic associations, ApplicationController
|
|
966
|
+
discovery, cache-call attribution, GraphQL inner classes, YAML anchors, Whenever
|
|
967
|
+
blocks, label-form rake tasks, and `RSpec.describe Klass, type:` test mapping all
|
|
968
|
+
parse correctly (#194, #199–#204, #176); full extraction sweeps orphaned unit files
|
|
969
|
+
so reused output dirs stop over-reporting (#177).
|
|
970
|
+
- *Pipeline integrity:* `rails_source`/`gem_source` route through the real write pipeline
|
|
971
|
+
as a Gemfile.lock-keyed whole-app extractor and `include_framework_sources` genuinely
|
|
972
|
+
gates it (#169); every index writer — `woods:clean`, the embed tasks, MCP
|
|
973
|
+
`pipeline_extract` — now takes the pipeline lock (#170); incremental extraction cannot
|
|
974
|
+
prune units on a failed extractor construction or a degraded eager-load boot (#198);
|
|
975
|
+
the write-skip optimization actually fires (#208).
|
|
976
|
+
- *Embedding durability:* a mis-pointed `woods:embed_incremental` can no longer wipe the
|
|
977
|
+
vector index (30% purge guard + empty-load refusal, #191); non-ASCII identifiers stop
|
|
978
|
+
re-embedding forever (WVF1 ids hydrate as UTF-8, #192); a single 429 no longer aborts
|
|
979
|
+
an embed run — providers are wrapped in the previously-unwired resilience layer with
|
|
980
|
+
Retry-After honored (#188); pgvector works via the documented setup path and dedupes
|
|
981
|
+
in-batch ids (#187, #181); PageRank keeps rank mass for duplicate/unresolvable edges
|
|
982
|
+
(#205); temporal snapshots stop leaking a unit set per same-SHA re-capture (#206).
|
|
983
|
+
- *Robustness:* the embed pipeline, Unblocked manifest, StatusReporter, and flow layer
|
|
984
|
+
survive `LANG=C` and torn files (`AtomicFile` everywhere, #189, #190); flow artifacts
|
|
985
|
+
are portable (relative paths, #190); a standing-down watch daemon no longer clobbers
|
|
986
|
+
the live daemon's status, and the lock heartbeat cannot resurrect a released lock
|
|
987
|
+
(#196, #197); Notion/Unblocked clients no longer retry non-idempotent POSTs on read
|
|
988
|
+
timeout (#150); Notion multi-model sync no longer corrupts the Columns database
|
|
989
|
+
(qualified `table.column` titles with legacy-page adoption, #149); metadata search
|
|
990
|
+
validates field names and escapes LIKE metacharacters (#209).
|
|
991
|
+
|
|
992
|
+
- **`woods:embed_incremental` no longer discards the vectors it embeds** (B-059, #148). On the
|
|
993
|
+
`:local` and `:shared_filesystem` presets the vector store is in-memory and the dump under
|
|
994
|
+
`dumps/` is the *only* durable copy — but `Indexer#index_incremental` never called
|
|
995
|
+
`persist_snapshot`, while `process_units` advanced `checkpoint.json` regardless. Each
|
|
996
|
+
incremental run therefore embedded changed units into a store that died with the process,
|
|
997
|
+
wrote nothing to `dumps/`, and left a checkpoint claiming the work was done, so no later
|
|
998
|
+
incremental run would ever produce those vectors again: unrecoverable without a full
|
|
999
|
+
re-embed, and silent — stats reported `processed: 1`. An incremental run now hydrates the
|
|
1000
|
+
store from `dumps/latest` before embedding and dumps afterwards, so the dump it writes is
|
|
1001
|
+
cumulative. The invariant now enforced is that **`checkpoint.json` never advances over a
|
|
1002
|
+
unit whose vector was not durably stored**: on the dump-backed path the checkpoint is
|
|
1003
|
+
written only after the dump is on disk and the `latest` pointer is flipped (the interval
|
|
1004
|
+
checkpoints are suppressed there — a dump is a whole-store snapshot, so there is no partial
|
|
1005
|
+
durability for them to record), and a checkpoint hit is honoured only when the hydrated
|
|
1006
|
+
artifact actually holds a vector for that unit. A checkpoint that ran ahead of its dump —
|
|
1007
|
+
an older gem with this bug, an interrupted promote, a store swap — self-heals into a
|
|
1008
|
+
re-embed and says so on stderr. A dump that cannot be read (corrupt file, dimension
|
|
1009
|
+
mismatch after a model switch) warns and falls back to re-embedding everything, which is
|
|
1010
|
+
the documented remedy for both. Durable backends (pgvector, Qdrant) are unaffected: their
|
|
1011
|
+
`store_batch` *is* the durable write, so they keep the interval checkpoints and never
|
|
1012
|
+
hydrate.
|
|
1013
|
+
|
|
1014
|
+
- **Woods' own JSON artifacts are read as UTF-8, not as the process locale** (#164 review,
|
|
1015
|
+
round 4). `AtomicFile.write` uses `binmode` so bytes land verbatim, but a plain `File.read`
|
|
1016
|
+
tags the result with the default *external* encoding — US-ASCII in a container with no
|
|
1017
|
+
locale set, which is a plain Docker image and exactly where the watch daemon is documented
|
|
1018
|
+
to run. The daemon writes status reasons containing em dashes, so one ordinary lock
|
|
1019
|
+
contention under `LANG=C` raised `Encoding::InvalidByteSequenceError` out of
|
|
1020
|
+
`Watch::Status#read` (which rescued `JSON::ParserError` and `SystemCallError`, neither of
|
|
1021
|
+
which that is), taking `woods:watch_status`, the hook sync's daemon-deference check and the
|
|
1022
|
+
`woods_status` tool down with it until something rewrote the file with an ASCII-only reason.
|
|
1023
|
+
New `AtomicFile.read` is the counterpart to `.write`; `Status`, `Generation`, the daemon's
|
|
1024
|
+
pending/graph reads and `woods_status` all go through it, and `Status#read` now also rescues
|
|
1025
|
+
`EncodingError`.
|
|
1026
|
+
- **A cycle that writes an index without publishing a generation now reports degraded**
|
|
1027
|
+
(#164 review, round 4). `Extractor#publish_generation` rescues its own failures so a good
|
|
1028
|
+
index is not discarded over an unwritable marker — right, but the marker *is* the freshness
|
|
1029
|
+
contract, so readers kept serving the previous index while the daemon reported `running`,
|
|
1030
|
+
and the next incremental could be a no-op that bumped nothing either. The daemon now
|
|
1031
|
+
cross-checks that the number moved when units were written, carries the paths forward, and
|
|
1032
|
+
logs at error rather than warn.
|
|
1033
|
+
- **`graph_analysis.json` no longer depends on registration order** (#164 review, round 4).
|
|
1034
|
+
`orphans` and `dead_ends` were emitted in graph-registration order and `cycles` started its
|
|
1035
|
+
DFS from the same, so a full and an incremental extraction of one tree published different
|
|
1036
|
+
analysis — the opposite of what the docs claimed. The differential harness could not see it:
|
|
1037
|
+
its oracle `deep_sort`ed both sides of that file before comparing. Sorting there and
|
|
1038
|
+
asserting determinism here cannot both be load-bearing; the analyzer is now genuinely
|
|
1039
|
+
order-independent and the oracle compares the file exactly. Guarded by a registration-order
|
|
1040
|
+
rotation in `spec/graph_analyzer_spec.rb`.
|
|
1041
|
+
- **The harness oracle keys units by filename, not by their own contents** (#164 review,
|
|
1042
|
+
round 4). `unit_snapshot` keyed on the identifier *inside* each document, so a stale file
|
|
1043
|
+
whose identifier a newly-written file also carried collapsed onto one entry with
|
|
1044
|
+
last-write-wins — a leftover unit read as no difference at all — and content written under
|
|
1045
|
+
the wrong name compared equal while the directories plainly were not.
|
|
1046
|
+
- **A class removed from a file that still exists is now pruned** (#164 review, round 4).
|
|
1047
|
+
Deletion keyed on the source file being gone, which cannot see this: two models in one `.rb`
|
|
1048
|
+
with one deleted leaves no missing path, and class-based units register a *convention* path
|
|
1049
|
+
derived from the constant name, so the second class was never attributed to the file it
|
|
1050
|
+
actually lived in. Nothing in the run removed it, so it outlived every subsequent
|
|
1051
|
+
incremental — a permanent divergence from a full extraction. Class-based reconciliation now
|
|
1052
|
+
runs in both directions, with removal gated on the eager load having completed: on the
|
|
1053
|
+
documented NameError fallback the discovery sets are known-partial, and deleting by the type
|
|
1054
|
+
is a far worse failure than a stale unit. The booted harness cannot cover this — Zeitwerk
|
|
1055
|
+
unloads only a file's expected constant, so the side-effect class survives the reload and
|
|
1056
|
+
the in-process full extraction the oracle compares against emits it too.
|
|
1057
|
+
- **`RailsReloader#reload!` no longer carries an unreachable interlock wrapper** (#164 review,
|
|
1058
|
+
round 4). The call was guarded by `interlock.respond_to?(:done)`, and
|
|
1059
|
+
`ActiveSupport::Dependencies::Interlock` has never had a `#done` — so the guard was false on
|
|
1060
|
+
every Rails version, the wrapper never ran, and the comment above it described locking that
|
|
1061
|
+
was not happening. It is also not needed: `reload!` takes the unload lock itself via
|
|
1062
|
+
`class_unload!` → `require_unload_lock!`. Found by writing the first test that drives the
|
|
1063
|
+
real reloader instead of a double; it was stubbed in every spec and so ran on zero of the
|
|
1064
|
+
seven matrix rows.
|
|
1065
|
+
- **New GraphQL files are indexed incrementally** (#164 review, round 3). `app/graphql` had no
|
|
1066
|
+
`PathDispatcher` rule and GraphQL types are not class-discoverable, so a created type,
|
|
1067
|
+
mutation or resolver routed nowhere and never entered the index, and a rename lost the unit
|
|
1068
|
+
entirely — #164 gap 1 verbatim, in the one corner the gap-1 fix missed. The coverage guard
|
|
1069
|
+
missed it too: `GRAPHQL_TYPES` is its own constant, so deriving the expectation from
|
|
1070
|
+
`FILE_BASED` left a hole exactly the size of the bug. The guard now works by subtraction —
|
|
1071
|
+
every unit type must be reachable per file, wholesale, or by class discovery, with
|
|
1072
|
+
`rails_source` the one stated exception.
|
|
1073
|
+
- **`resolve_head_sha` no longer folds git's stderr into the SHA** (#164 review, round 3). The
|
|
1074
|
+
same `capture2e` hazard as the working-tree probe one method over: a warning on an otherwise
|
|
1075
|
+
successful `rev-parse` was concatenated into the value and then compared against the manifest
|
|
1076
|
+
as if it were a SHA. The status spec's git stub had also gone dead when the working-tree
|
|
1077
|
+
probe moved to `capture3`, so real git was running against `/tmp` in those examples.
|
|
1078
|
+
- **Startup catch-up now notices deletion-only downtime** (#164 review, round 2). The
|
|
1079
|
+
reconciliation scanned mtimes of files that exist, so a file deleted while no daemon was
|
|
1080
|
+
running left no trace: the daemon logged "index is current at startup" and the ghost units
|
|
1081
|
+
survived until the next unrelated event. Catch-up now also checks the graph's registered
|
|
1082
|
+
paths for files gone from disk and, if any, runs one cycle with an *empty* change set — the
|
|
1083
|
+
extractor's bounded sweep reaches the ghosts, with the bounds that keep nominal paths
|
|
1084
|
+
(Rails < 7.1 `SchemaMigration`) safe from authoritative deletion.
|
|
1085
|
+
- **The drain guard is an atomic test-and-set** (#164 review, round 2). The re-entrancy guard
|
|
1086
|
+
was a check-then-act boolean — the exact race it guarded against: two `listen` callback
|
|
1087
|
+
threads could both read `false` before either wrote `true` and run two overlapping drain
|
|
1088
|
+
loops. It is now `Mutex#try_lock`; the refused caller's paths are already in the pending set,
|
|
1089
|
+
so the winning loop picks them up and nothing is lost.
|
|
1090
|
+
- **`IndexReader` freshness bookkeeping is safe under a threaded transport** (#164 review,
|
|
1091
|
+
round 2). The generation check-and-reload was unguarded check-then-act, and the pin was a
|
|
1092
|
+
boolean — under `woods-mcp-http`, whose tool handlers run on the Rack server's request
|
|
1093
|
+
threads, two concurrent reads could double-reload or drop each other's caches mid-sequence,
|
|
1094
|
+
and the first of two overlapping `with_pinned_generation` blocks to finish unpinned the
|
|
1095
|
+
reader for both. The check-and-reload now runs under a per-reader mutex and pins are
|
|
1096
|
+
refcounted: invalidation resumes when the *last* pin releases.
|
|
1097
|
+
- **A cycle that can't land its work no longer loses it** (#164 review). Lock contention
|
|
1098
|
+
already carried its paths into the next cycle; a *failed reload* did not. Saving a valid
|
|
1099
|
+
`post.rb` while `user.rb` sat half-typed produced one app-wide reload failure covering both,
|
|
1100
|
+
and when `user.rb` was fixed the event named only `user.rb` — so `post.rb`'s change never
|
|
1101
|
+
reached the index at all. Failed reloads and raising extractions now carry forward too, and
|
|
1102
|
+
the drain lives inside `Daemon#process`, so an embedded host gets the same retry behaviour
|
|
1103
|
+
`#run` does.
|
|
1104
|
+
- **A quiet daemon is no longer declared dead** (#164 review). `Status#alive?` disbelieves a
|
|
1105
|
+
record older than 15 minutes and only cycle boundaries wrote one, so a healthy daemon
|
|
1106
|
+
watching a worktree nobody was typing in read as stopped — and every caller that stands down
|
|
1107
|
+
for a live daemon started contending for its lock instead. A heartbeat now re-stamps the
|
|
1108
|
+
last published record every 5 minutes, republishing `degraded` as `degraded` rather than
|
|
1109
|
+
claiming recovery.
|
|
1110
|
+
- **The daemon reconciles changes that predate it** (#164 review). It only ever reacted to
|
|
1111
|
+
events it personally witnessed, so the documented hook pattern — start a daemon, then sync —
|
|
1112
|
+
stood the sync down over changes the fresh daemon had never seen. `Daemon#run` now
|
|
1113
|
+
reconciles against the index's own watermark (`generation.json`'s mtime) before waiting for
|
|
1114
|
+
its first event. `woods:incremental` also no longer stands down for a *degraded* daemon:
|
|
1115
|
+
alive but not updating is not coverage.
|
|
1116
|
+
- **`woods:refresh` serializes with the other writers** (#164 review). It rewrites the whole
|
|
1117
|
+
dependency graph and took no lock, so a refresh racing a daemon cycle silently discarded the
|
|
1118
|
+
other's work and then bumped the generation over it — atomic writes don't help, because each
|
|
1119
|
+
write is individually intact and the *set* is not. It now runs under `PipelineLock` like
|
|
1120
|
+
`woods:extract` and `woods:incremental`, and records its own generation reason instead of
|
|
1121
|
+
masquerading as an incremental run.
|
|
1122
|
+
- **The polling watcher no longer loses a same-second write** (#164 review). Snapshots
|
|
1123
|
+
truncated mtime with `to_i`, so a second write inside the same second was invisible
|
|
1124
|
+
permanently — there is no later event to catch it — and save-then-formatter at the default
|
|
1125
|
+
1s interval is entirely ordinary. Snapshots now carry full-resolution mtime plus size.
|
|
1126
|
+
- **`IndexReader` no longer misses a same-size generation bump** (#164 review). The freshness
|
|
1127
|
+
signature was `[mtime, size]`, and equal size is the daemon's steady state (reason
|
|
1128
|
+
`"incremental"` every cycle). On a coarse-mtime filesystem — including the volume-mounted
|
|
1129
|
+
Docker deployment the Index Server is documented for — two bumps in one tick were
|
|
1130
|
+
indistinguishable and the reader served a stale index indefinitely. The inode is now part of
|
|
1131
|
+
the signature, which `AtomicFile`'s rename-per-write guarantees moves.
|
|
1132
|
+
- **A clean working tree no longer reports dirty** (#164 review). `resolve_working_tree_status`
|
|
1133
|
+
used `capture2e`, folding git's stderr into the porcelain output — so any warning on an
|
|
1134
|
+
otherwise successful run (a stale `index.lock` notice, `core.fsmonitor` chatter) read as
|
|
1135
|
+
uncommitted changes, and the fingerprint tracked the warning rather than the code.
|
|
1136
|
+
- **Index artifacts are written atomically.** `dependency_graph.json`, `manifest.json`,
|
|
1137
|
+
`_index.json` and every per-unit file went through plain `File.write`. With a resident daemon
|
|
1138
|
+
writing while resident MCP readers read, a reader could catch a truncated file mid-write;
|
|
1139
|
+
all of them now route through `Woods::AtomicFile`.
|
|
1140
|
+
- **A class-based file moved between autoload directories is no longer dropped for a run**
|
|
1141
|
+
(#164 review). Reconciliation ran before pruning, so a file moved with its constant unchanged
|
|
1142
|
+
still looked "known" and was not re-extracted, then was pruned for its vanished path.
|
|
1143
|
+
`extract_changed` now reconciles once more after pruning.
|
|
1144
|
+
- The `listen` backend degrades instead of dying: only its setup is wrapped in the
|
|
1145
|
+
`WatcherError` rescue, so a failure raised once it is merely parked (including from the
|
|
1146
|
+
extraction inside a callback) is no longer relabelled "failed to start", and inotify
|
|
1147
|
+
exhaustion falls back to polling. Both watchers also honour a `stop` that races startup.
|
|
1148
|
+
- The storm threshold counts only paths the reload policy considers actionable — sixty edited
|
|
1149
|
+
markdown files plus one model is a one-model change, not a storm.
|
|
1150
|
+
|
|
1151
|
+
- **Token estimates now describe the file that is written.** `ExtractedUnit#estimated_tokens`
|
|
1152
|
+
measured `metadata.to_json`, which with ActiveSupport loaded applies HTML-safe escaping (`>`
|
|
1153
|
+
becomes `\u003e`), while the unit file is written with `JSON.generate`. Any unit whose
|
|
1154
|
+
metadata contained a lambda scope was therefore indexed with a token count that described a
|
|
1155
|
+
document that was never written — and differed depending on whether a full or an incremental
|
|
1156
|
+
run last touched it. Both sides now measure `JSON.generate`.
|
|
1157
|
+
|
|
1158
|
+
- **Incremental extraction is now equivalent to a full extraction** (#164, phase 0). Five
|
|
1159
|
+
confirmed correctness gaps in `woods:incremental` are closed. They mattered most in an
|
|
1160
|
+
incremental CI chain, where the previous graph is restored and `woods:incremental` runs per
|
|
1161
|
+
merge: a missed unit propagated forward run over run instead of being erased by the next
|
|
1162
|
+
full rebuild.
|
|
1163
|
+
- **New files are indexed.** Changes routed only through `DependencyGraph#affected_by`,
|
|
1164
|
+
which resolves a path via the graph's file map — populated only from already-registered
|
|
1165
|
+
units — so a file that did not exist at the last extraction routed nowhere and was
|
|
1166
|
+
silently ignored. A new `Woods::PathDispatcher` supplies the missing direction, path →
|
|
1167
|
+
extractor, for file-based types; class-based types (models, controllers, mailers,
|
|
1168
|
+
components, channels) are reconciled against each extractor's own runtime discovery set,
|
|
1169
|
+
now exposed as `#discoverable_classes`.
|
|
1170
|
+
- **Deleted files no longer leave ghosts.** Units whose source file has vanished are pruned
|
|
1171
|
+
— unit JSON removed, graph node unregistered, reverse edges withdrawn, type index
|
|
1172
|
+
regenerated. Deletions named in the change set are authoritative; a sweep over registered
|
|
1173
|
+
paths catches callers whose change set omits them. A rename resolves to delete-plus-add.
|
|
1174
|
+
- **Files defining several units reconcile as a whole.** `DependencyGraph`'s file map is now
|
|
1175
|
+
multi-valued (`path => Set<identifier>`), so a task removed from a multi-task `.rake` file
|
|
1176
|
+
is dropped rather than left behind. Graphs written before this load unchanged.
|
|
1177
|
+
- **Whole-app unit types refresh.** `route`, `middleware`, `engine`, `scheduled_job`,
|
|
1178
|
+
`state_machine`, `factory`, `event`, and `database_view` are re-run wholesale when their
|
|
1179
|
+
trigger paths change, instead of being skipped while the run still rewrote the manifest
|
|
1180
|
+
and zeroed `staleness_seconds`. A routes change also re-extracts the types that embed the
|
|
1181
|
+
route table (controllers, mailers, components, view templates).
|
|
1182
|
+
- **Derived data no longer drifts.** Incremental runs recompute `graph_analysis.json`, and
|
|
1183
|
+
refresh each affected unit's `dependents` list and `metadata.git` — all previously
|
|
1184
|
+
full-extraction-only. A run that extracts nothing now leaves the manifest timestamp alone
|
|
1185
|
+
rather than reporting the index as freshly synced.
|
|
1186
|
+
|
|
1187
|
+
- **Assignment-form multi-line conditionals no longer unbalance depth
|
|
1188
|
+
tracking.** `block_opener?` counted `if`/`unless` only in line-leading
|
|
1189
|
+
position while `case`/`begin`/`do` matched anywhere, so
|
|
1190
|
+
`value = if cond … end` popped a frame that was never pushed and closed
|
|
1191
|
+
the enclosing construct one `end` early. In a `.rake` file every task
|
|
1192
|
+
after the conditional lost its namespace prefix (wrong identifiers, a
|
|
1193
|
+
public contract); in `SourceNesting` a sibling class after the
|
|
1194
|
+
conditional lost its qualification (absorbed by governed naming for
|
|
1195
|
+
conventional paths); in the factory parser the enclosing factory
|
|
1196
|
+
completed early and attributes after the conditional were dropped.
|
|
1197
|
+
`if`/`unless` now also count directly after an assignment operator
|
|
1198
|
+
(`value = if x`, `@memo ||= unless y`), still excluding trailing
|
|
1199
|
+
modifiers and self-balancing one-liners, in all three copies of the
|
|
1200
|
+
rule (`RakeTaskExtractor`, `SourceNesting` — which
|
|
1201
|
+
`StateMachineExtractor` shares — and `FactoryExtractor`);
|
|
1202
|
+
`SemanticChunker` already counted assignment position. Surfaced by the
|
|
1203
|
+
v2 downstream validation after the comment/string neutralization fix
|
|
1204
|
+
removed a compensating miscount.
|
|
1205
|
+
|
|
1206
|
+
- **An alias can no longer name a protected output header (CON-1).**
|
|
1207
|
+
`console_query` accepted `select: ["key", "value", "id AS value"]`; the
|
|
1208
|
+
duplicate header made the positional redactor mask the aliased cell and
|
|
1209
|
+
return the real EAV secret in cleartext (`id AS key` disarmed masking
|
|
1210
|
+
entirely). A fourth select refusal now rejects any `AS` alias whose name
|
|
1211
|
+
collides with a `console_redacted_columns` or `console_redacted_key_values`
|
|
1212
|
+
column, case-insensitively. Defense-in-depth: the positional redactor now
|
|
1213
|
+
treats a duplicated key or value header as ambiguous and masks every cell
|
|
1214
|
+
under a value-named header unconditionally instead of letting a
|
|
1215
|
+
last-index-wins lookup pick the shadow. The alias/aggregate refusals also
|
|
1216
|
+
compare configured column names case-insensitively (CON-3/R1-6), matching
|
|
1217
|
+
the predicate-side refusals. `console_sql` was already immune (its
|
|
1218
|
+
reference scan sees the alias token) and is unchanged.
|
|
1219
|
+
- **`woods:incremental` refuses to run without a baseline index (CORE-2).**
|
|
1220
|
+
Against an output directory with no published generation and no dependency
|
|
1221
|
+
graph — a failed CI cache restore, a typo'd `WOODS_OUTPUT`, a first run on
|
|
1222
|
+
a fresh runner — `extract_changed` computed an empty blast radius over the
|
|
1223
|
+
empty graph, dispatched only the diffed paths, and silently published a
|
|
1224
|
+
near-empty index as generation 1 that nothing self-heals until a full
|
|
1225
|
+
extraction. `prepare_incremental_run` now raises a typed
|
|
1226
|
+
`Woods::ExtractionError` naming `woods:extract` (the task exits non-zero);
|
|
1227
|
+
the watch daemon keeps its existing missing-generation → full-extraction
|
|
1228
|
+
posture, and an embedding caller that already holds a populated in-memory
|
|
1229
|
+
graph still runs. `woods:refresh` gets the same guard.
|
|
1230
|
+
- **Per-path prune skips by (identifier, type), not bare identifier
|
|
1231
|
+
(CORE-1).** Two dispatch rules can claim one path (`app/policies` is
|
|
1232
|
+
claimed by the policies and pundit_policies rules) and mint the same
|
|
1233
|
+
identifier for different unit types; when an edit made one of them stop
|
|
1234
|
+
producing, the surviving identifier shielded the stale sibling-type node
|
|
1235
|
+
from the prune — a permanent full/incremental divergence. The #225 typed
|
|
1236
|
+
discipline now covers `prune_path_leftovers` too.
|
|
1237
|
+
|
|
1238
|
+
- Trailing `#` comments no longer steer `SourceNesting`'s depth tracking. A comment ending in the
|
|
1239
|
+
word "end" (`module Api # rename at the end`) made the wrapper look self-terminated and dropped
|
|
1240
|
+
the namespace from the identifier; a comment containing `do`/`for` opened a phantom frame that
|
|
1241
|
+
swallowed a real `end` and leaked a closed wrapper onto a later top-level class (EXTA-1).
|
|
1242
|
+
- `SourceNesting` keeps non-ASCII constant names whole (`class Café` was captured as `Caf`) and
|
|
1243
|
+
skips declarations inside `=begin`/`=end` block comments (EXTA-14).
|
|
1244
|
+
- `RakeTaskExtractor` no longer counts block keywords found in comments, string literals, or
|
|
1245
|
+
heredoc bodies. A `# do not touch production` inside a task inflated depth with no matching
|
|
1246
|
+
`end`, so later namespaces inherited a stale prefix (`other:third` indexed as
|
|
1247
|
+
`cleanup:other:third`) and a task swallowed the next one's lines; a heredoc line reading
|
|
1248
|
+
`end of the road` truncated a task body and lost its dependency edges (EXTB-1).
|
|
1249
|
+
- `FactoryExtractor` no longer reads block keywords inside attribute strings. Two of them in one
|
|
1250
|
+
factory (`title { "things to do" }`, `note { "walk for a while" }`) left the factory unclosed at
|
|
1251
|
+
EOF and the unit was dropped from the index entirely (EXTB-2).
|
|
1252
|
+
- `SemanticChunker`'s line-depth heuristic no longer treats `=begin`/`=end` as a block opener. Each
|
|
1253
|
+
block comment added a permanent +1 to the depth count, so the enclosing method never closed and
|
|
1254
|
+
every later method was appended to its chunk (STO-3).
|
|
1255
|
+
- A blockless AASM/`state_machines` `event :noop` is emitted immediately instead of being replaced
|
|
1256
|
+
unseen by the next event, and event depth is clamped at zero so an unbalanced `end` can no longer
|
|
1257
|
+
disable parsing for every event that follows (EXTB-17).
|
|
1258
|
+
- Service, job/worker, and mailer dependency edges keep their namespace. Since G-1 a namespaced
|
|
1259
|
+
unit's identifier is fully qualified, so `Billing::ChargeService.call` recorded an edge to
|
|
1260
|
+
`ChargeService` — matching no node, invisible to `dependents`, PageRank, and the incremental
|
|
1261
|
+
blast radius (EXTA-2).
|
|
1262
|
+
- One shared enqueue pattern is now used by the dependency scanner, `JobExtractor`, and
|
|
1263
|
+
`CallbackAnalyzer`, so all three agree: Sidekiq `*Worker` classes and
|
|
1264
|
+
`SyncJob.set(wait: …).perform_later` chains produce `:job` edges everywhere (EXTA-4).
|
|
1265
|
+
- `CallbackAnalyzer` detects `self.col ||=`, `self.col +=` and the other operator-assignment forms
|
|
1266
|
+
as column writes — `self.token ||= SecureRandom.uuid` reported none — and no longer reads
|
|
1267
|
+
`self.col =~ /re/` as a write (EXTA-6).
|
|
1268
|
+
- `CallbackAnalyzer` neutralizes comments and string bodies before its regex scans, so
|
|
1269
|
+
`logger.info "self.status = pending"` and a commented-out assignment no longer report a column
|
|
1270
|
+
write (EXTA-8).
|
|
1271
|
+
- `EventExtractor` recognizes Wisper's canonical paren form `broadcast(:event, …)`; the
|
|
1272
|
+
whitespace-only regex registered no publisher, and with no subscriber naming the event the event
|
|
1273
|
+
unit did not exist at all (EXTB-3).
|
|
1274
|
+
- Quoted namespaced rake dependencies keep their namespace: `task deploy: 'assets:precompile'`
|
|
1275
|
+
recorded `precompile` and pointed the edge at a task that does not exist (EXTB-9).
|
|
1276
|
+
- Whenever commands in single quotes are recognized: `runner 'CleanupJob.perform_later'` yielded
|
|
1277
|
+
command type `:unknown`, no job class, and no `:job` edge (EXTB-12).
|
|
1278
|
+
- Environment-nested schedule YAML unwraps the section for the current `Rails.env` (falling back to
|
|
1279
|
+
the first) instead of whichever environment happens to be listed first (EXTB-19).
|
|
1280
|
+
- `retry_config[:retry_on]` keeps namespaced error classes: `retry_on Net::OpenTimeout, wait: …,
|
|
1281
|
+
attempts: …` recorded `Net` and lost both `wait` and `attempts` (EXTA-10).
|
|
1282
|
+
- A class-discovered job whose source cannot be resolved records a nil `file_path` instead of a
|
|
1283
|
+
fabricated `app/jobs/<name>.rb`. The fabricated path entered the graph's `file_map` and the next
|
|
1284
|
+
incremental run's safety-net sweep deleted a unit every full extraction emits — the B-070/#171
|
|
1285
|
+
GraphQL shape, reproduced for jobs (EXTA-3).
|
|
1286
|
+
- GraphQL per-field complexity is attributed to the field that declares it; the match crossed
|
|
1287
|
+
declarations, so a field with no complexity absorbed the next field's and the real owner lost it
|
|
1288
|
+
(EXTB-8).
|
|
1289
|
+
- Strong-params capture accepts the fluent chain style — `params.require(:post)` with `.permit(…)`
|
|
1290
|
+
on the following line, and the fully fluent `params` / `.require` / `.permit` form — which the
|
|
1291
|
+
multi-line (M2) fix did not reach (EXTA-5 / R1-2).
|
|
1292
|
+
- `permitted_params` now lists every top-level key, hash-form keys included, and excludes nested
|
|
1293
|
+
leaves: `permit(:title, tags: [], meta: {seo: [:keyword]})` yields `title, tags, meta` where it
|
|
1294
|
+
used to yield `title, keyword` (EXTA-12 / R1-3).
|
|
1295
|
+
|
|
1296
|
+
- **`unless` no longer inverts every flow document (EXTB-4).** `Prism::UnlessNode`
|
|
1297
|
+
shared `convert_prism_if` and landed its body in the *then* slot, so
|
|
1298
|
+
`woods:flow`, the precomputed `flows/*.json`, and the MCP flow tools stated the
|
|
1299
|
+
opposite of what the code does for every `unless` — including the modifier form.
|
|
1300
|
+
The parser now swaps the then/else slots for `unless`, which makes the emitted
|
|
1301
|
+
`if <predicate>` reading exact; the parser-gem branch already normalized it that
|
|
1302
|
+
way.
|
|
1303
|
+
- **Call arguments are walkable AST children (EXTB-5).** Arguments were flattened
|
|
1304
|
+
to source text, so anything nested in an argument list was invisible to every
|
|
1305
|
+
tree walker: `private def hidden; …; end` lost the whole method (no
|
|
1306
|
+
`ruby_method` unit, no `extract_method_source`, no dataflow), and `foo(Bar.baz)`
|
|
1307
|
+
never produced a `Bar.baz` call site. Argument nodes now become children,
|
|
1308
|
+
appended after the receiver; the `arguments` text field is unchanged.
|
|
1309
|
+
- **Call sites in a block call's receiver chain are recorded (EXTB-16).**
|
|
1310
|
+
`User.where(active: true).each { … }` recorded `each` but not `where`, while the
|
|
1311
|
+
same chain without a block recorded both. `CallSiteExtractor`'s `:block` branch
|
|
1312
|
+
now recurses into the send's own children, matching the non-block path.
|
|
1313
|
+
- **A predicate-less `case` keeps its first `when` branch (EXTB-15).** The
|
|
1314
|
+
predicate slot is now positional (a nil hole when absent), mirroring the L2 fix
|
|
1315
|
+
for `:if`. `handle_case`'s `drop(1)` used to consume the first branch and render
|
|
1316
|
+
its condition as the literal word "when".
|
|
1317
|
+
- **`MermaidRenderer#render_dependency_map` renders edges again (EXTB-6).**
|
|
1318
|
+
`DependencyGraph#to_h` has emitted `[{target:, via:}]` hashes since the via
|
|
1319
|
+
migration; the renderer iterated them as bare targets, so `nodes.key?(hash)` was
|
|
1320
|
+
always false and every edge was silently dropped — the committed
|
|
1321
|
+
`docs/self-analysis/dependency-map.md` shipped ~2,400 nodes and zero
|
|
1322
|
+
dependencies. Edges are normalized through `DependencyGraph.normalize_edges`
|
|
1323
|
+
(legacy bare strings still load) and now carry `|via|` labels. The
|
|
1324
|
+
`docs/self-analysis/` artifacts are regenerated.
|
|
1325
|
+
- **`GraphAnalyzer#domain_clusters` is order-free (EXTB-7).** Unnamespaced units
|
|
1326
|
+
were assigned to clusters one at a time, each assignment mutating the target
|
|
1327
|
+
before the next unit was scored, so a unit whose only connection was *another*
|
|
1328
|
+
unnamespaced unit joined a cluster only when that other unit was registered
|
|
1329
|
+
first — full and incremental runs register in different orders, so the MCP
|
|
1330
|
+
`domain_clusters` tool could answer differently for identical trees. Assignment
|
|
1331
|
+
now scores every pending unit against one pre-round membership snapshot and
|
|
1332
|
+
applies the round together, iterating to a fixed point within
|
|
1333
|
+
`ORPHAN_ASSIGNMENT_ROUNDS`. Closes the residual hole in the #216/B-103
|
|
1334
|
+
determinism claim.
|
|
1335
|
+
- **`pagerank` survives a graph carrying edges for a node-less source
|
|
1336
|
+
(EXTB-10).** `from_h` accepts (and `registered_types` relies on) that shape, but
|
|
1337
|
+
`pagerank_step` read `scores[src]` for every reverse source and raised
|
|
1338
|
+
`NoMethodError: undefined method '*' for nil` out of the incremental PageRank
|
|
1339
|
+
refresh. A phantom source now contributes 0.0.
|
|
1340
|
+
- **`DependencyGraph#to_h` no longer hands out the live edge arrays (EXTB-11).**
|
|
1341
|
+
The documented "returns a dup so callers can't pollute the cached hash" was
|
|
1342
|
+
shallow: `to_h[:edges][id]` was the very Array `@edges` holds, so appending to it
|
|
1343
|
+
changed what `dependencies_of` answered. Edge arrays are copied element-wise,
|
|
1344
|
+
and each snapshot is detached from the memo.
|
|
1345
|
+
- **`change_table` columns are extracted from migrations (EXTB-18).**
|
|
1346
|
+
`change_table :orders do |t| t.string :notes end` produced no `columns_added`
|
|
1347
|
+
entry and left `orders` out of `tables_affected` (so no `:table_name` model
|
|
1348
|
+
edge). `change_table` joins `TABLE_OPERATIONS` and the three block scanners
|
|
1349
|
+
accept both openers.
|
|
1350
|
+
- **STI models sharing a table no longer churn the Notion Columns database
|
|
1351
|
+
(EXP-1).** Column pages are titled by *table*, so two models emitted the same
|
|
1352
|
+
`users.id` title with a different `Table` relation and a different content hash,
|
|
1353
|
+
each run PATCHing the page back — two API calls per shared column, forever, with
|
|
1354
|
+
the relation pointing at whichever model synced last. Columns are now grouped by
|
|
1355
|
+
physical table before syncing: one page per physical column per run, validations
|
|
1356
|
+
unioned, and the `Table` relation lists every owning model's page in a
|
|
1357
|
+
deterministic order. The #149 distinct-table behavior is unchanged.
|
|
1358
|
+
- **Notion rich_text is truncated in UTF-16 code units (EXP-2).** Notion's 2000
|
|
1359
|
+
limit counts UTF-16 units, not Ruby characters, so text containing non-BMP
|
|
1360
|
+
characters (emoji in a header comment is enough) shipped payloads up to twice
|
|
1361
|
+
the limit and the unit failed with `Notion API error 400` on *every* run.
|
|
1362
|
+
Truncation now walks whole characters accumulating 1 or 2 units, never splitting
|
|
1363
|
+
a surrogate pair.
|
|
1364
|
+
- **Notion "Last Schema Change" is the migration's date, not the extraction's
|
|
1365
|
+
(EXP-3).** `latest_changes` keyed on `extracted_at`, which a full extraction
|
|
1366
|
+
re-stamps for every unit — so every table read "changed today" and every Data
|
|
1367
|
+
Models page was rewritten to say so. The migration's own `migration_version`
|
|
1368
|
+
stamp (`%Y%m%d%H%M%S`, already in the same metadata hash) is preferred;
|
|
1369
|
+
`extracted_at` remains the fallback when it is absent or unparseable.
|
|
1370
|
+
- **Exporters pin the index generation (EXP-5).** `IndexReader` self-refreshes on
|
|
1371
|
+
every public accessor when the published generation moves and assigns pinning
|
|
1372
|
+
responsibility to direct callers; none of the three exporters pinned, so an
|
|
1373
|
+
extraction publishing mid-export produced a mixed-generation export (silently:
|
|
1374
|
+
Notion column pages created with no `Table` relation, Obsidian's
|
|
1375
|
+
"byte-identical across runs" contract broken, sweep and purge sets computed
|
|
1376
|
+
against a mixture). `Notion::Exporter#sync_all`, `Unblocked::Exporter#sync_all`
|
|
1377
|
+
and `Obsidian::VaultExporter#export_all` now run inside
|
|
1378
|
+
`reader.with_pinned_generation` when the injected reader supports it.
|
|
1379
|
+
- **The Obsidian stale-note sweep survives glob metacharacters in the vault path
|
|
1380
|
+
(EXP-6).** The vault path was interpolated into the glob pattern, so `[`, `]`,
|
|
1381
|
+
`{`, `}`, `*` or `?` in a folder name (`my [work] vault`) made `managed_notes`
|
|
1382
|
+
match nothing — the sweep saw zero managed notes and deleted nothing, forever.
|
|
1383
|
+
The glob now runs with `base:` so the path is never pattern syntax.
|
|
1384
|
+
- **NameMapper re-checks its hashed candidate and sanitizes Windows device names
|
|
1385
|
+
(EXP-9).** The collision-hash suffix was inserted into the taken set without
|
|
1386
|
+
being re-checked, so a hash prefix colliding with an existing literal basename
|
|
1387
|
+
put two notes at one path (last writer wins, one id lost from the inverse map);
|
|
1388
|
+
the digest slice now widens until the basename is free. `CON`, `PRN`, `AUX`,
|
|
1389
|
+
`NUL`, `COM1-9` and `LPT1-9` (any case) are prefixed, so a class named `Aux` no
|
|
1390
|
+
longer produces a note Windows cannot create.
|
|
1391
|
+
- **A columns-only Notion configuration syncs its columns (EXP-11).** `sync_all`
|
|
1392
|
+
required *both* database ids before running the column sync, so a columns-only
|
|
1393
|
+
configuration returned all-zero stats in silence — indistinguishable from
|
|
1394
|
+
breakage, and contradicting the documented "the other sync is skipped
|
|
1395
|
+
gracefully". The sync now runs with no parent pages (`ColumnMapper` already
|
|
1396
|
+
tolerates that) and says on stderr why the `Table` relation is missing.
|
|
1397
|
+
- **`context_completeness` is no longer recall under a second name (EXP-10).**
|
|
1398
|
+
The evaluator passed `expected_units` as both the relevant and the required set,
|
|
1399
|
+
so `mean_context_completeness == mean_recall` always and a threshold keyed on it
|
|
1400
|
+
silently gated on recall. `QuerySet::Query` gains an optional `required_units`
|
|
1401
|
+
annotation (loaded, saved, and validated as a subset of `expected_units`);
|
|
1402
|
+
queries without it keep the previous value.
|
|
1403
|
+
- **`flow_document.rb` and `evaluation/report_generator.rb` load standalone
|
|
1404
|
+
(EXTB-13, EXP-8).** Both named `Time#iso8601` (and `FileUtils` in the report
|
|
1405
|
+
generator) without requiring them; only a transitive `require "woods"` masked it.
|
|
1406
|
+
Missing requires added, with a subprocess smoke spec.
|
|
1407
|
+
|
|
1408
|
+
- **`reload` no longer degrades an index that has never run `woods:embed` (MCP-1).**
|
|
1409
|
+
A retriever-wired server with no promoted dump used `required: true` against a
|
|
1410
|
+
nil dump, so `reload` aborted the transaction, answered `degraded_index`, and
|
|
1411
|
+
stamped `bootstrap.reload_failure` into `woods_status` — clearable only by a
|
|
1412
|
+
successful reload, which was impossible until an embed ran. Boot hydrates that
|
|
1413
|
+
shape with `load_or_empty`; reload now agrees and answers a zero-count success.
|
|
1414
|
+
A dump that IS promoted but incomplete still fails closed.
|
|
1415
|
+
- **`reload` refreshes the reader on every zero-count no-op (MCP-2).**
|
|
1416
|
+
`Bootstrapper.reload_stores!`'s early returns (no retriever, no swap target, no
|
|
1417
|
+
artifact, durable stores, no promoted dump) skipped `reader.reload!`. On a flat
|
|
1418
|
+
(pre-2.0) index the reader never self-refreshes, so `reload` is its only
|
|
1419
|
+
freshness path — and precisely there the tool reported `reloaded: true` with the
|
|
1420
|
+
retired manifest. Every early return now runs the reader's exclusive reload
|
|
1421
|
+
first. Both packaged executables always wire a reloader, so the tool's
|
|
1422
|
+
non-reloader fallback was never the path in a shipped process; the spec that
|
|
1423
|
+
covered it now exercises the real wiring.
|
|
1424
|
+
- **A raising pipeline-lock acquire no longer wedges `pipeline_extract` /
|
|
1425
|
+
`pipeline_embed` for the life of the process (MCP-3).** On an index directory
|
|
1426
|
+
the server cannot write (a read-only Docker mount), `PipelineLock#acquire`
|
|
1427
|
+
raises `SystemCallError` instead of returning false. The raise escaped between
|
|
1428
|
+
`pipeline_start` and the background hand-off, leaking the in-process in-flight
|
|
1429
|
+
flag — every later call answered `already_running` — and reached `ToolContract`
|
|
1430
|
+
as a nested `SystemCallError`, which relabeled it `corrupt_artifact` ("An Index
|
|
1431
|
+
artifact is unavailable or malformed"), the wrong diagnosis for a permissions
|
|
1432
|
+
failure. The window is now owned by one guarded region that answers a typed
|
|
1433
|
+
`lock_unwritable` error and releases both the on-disk lock and the in-process
|
|
1434
|
+
slot unless the run was handed off.
|
|
1435
|
+
- **An orphaned task from a producer this host cannot judge now expires (MCP-4).**
|
|
1436
|
+
A `working` record whose `producer_identity` names a foreign boot id or pid
|
|
1437
|
+
namespace was left alone forever — correct for a cross-machine producer over a
|
|
1438
|
+
shared filesystem, wrong for the far more common reading of a boot-id mismatch
|
|
1439
|
+
on the same store: this machine rebooted mid-run, so the producer is dead by
|
|
1440
|
+
construction and the client polled `working` with no TTL backstop. Foreign
|
|
1441
|
+
producers are now believed on age alone, up to
|
|
1442
|
+
`Tasks::Store::FOREIGN_PRODUCER_GRACE_SECONDS` (24h from `updated_at`), then
|
|
1443
|
+
resolved to `failed`. Younger foreign records are untouched.
|
|
1444
|
+
- **Bounded staleness under sustained overlapping requests (MCP-5).**
|
|
1445
|
+
`IndexReader` only attempted a refresh when a pin arrived at depth 0, so on a
|
|
1446
|
+
threaded transport with continuous traffic — several agents against one
|
|
1447
|
+
`woods-mcp-http`, the deployment stateless mode exists for — the depth never
|
|
1448
|
+
reached zero and a retired generation was served indefinitely, silently. A pin
|
|
1449
|
+
arriving after the generation moved while pins are held now registers as a
|
|
1450
|
+
refresh waiter: it gates new pin entries (the same way an exclusive reload
|
|
1451
|
+
does), waits for the held pins to drain, refreshes, and admits the queue.
|
|
1452
|
+
Nested pins and the reads of an already-held pin are exempt, so the drain
|
|
1453
|
+
always completes.
|
|
1454
|
+
- **Store failures inside the retrieval pipeline surface as the typed store
|
|
1455
|
+
error (MCP-6).** M8 wrapped the three lookups the Retriever performs itself,
|
|
1456
|
+
but the store reads carrying most query traffic happen inside the pipeline
|
|
1457
|
+
components (the ranker's and assembler's `find_batch`, every executor store
|
|
1458
|
+
call). Those raised raw, so the SDK reported "Internal error calling tool
|
|
1459
|
+
codebase_retrieve" or `ToolContract` relabeled an IO-flavored cause as
|
|
1460
|
+
`corrupt_artifact`. The pipeline components now read through facades that
|
|
1461
|
+
translate any store failure into `Woods::Retriever::StoreError` naming the
|
|
1462
|
+
failing store, which `codebase_retrieve` already maps to
|
|
1463
|
+
`degraded_index (phase: 'query')`. `build_structural_context` no longer
|
|
1464
|
+
swallows a store failure to `nil` — the last swallow-to-empty on the query
|
|
1465
|
+
path.
|
|
1466
|
+
- **A payload directory without its `manifest.json` degrades instead of hanging
|
|
1467
|
+
(CORE-4).** The reader's retention-pin loop retried on `ENOENT` by reloading
|
|
1468
|
+
the generation; with the pointer unchanged, every iteration took the identical
|
|
1469
|
+
path with no sleep and no cap, spinning the request thread at 100% CPU. A
|
|
1470
|
+
second consecutive `ENOENT` for the same expected directory now proceeds
|
|
1471
|
+
unpinned, matching the flat-index branch.
|
|
1472
|
+
- **`search` rejects an unknown `fields` value (MCP-9).** `fields: ["sourcecode"]`
|
|
1473
|
+
used to return a clean empty result with nothing saying the selector was
|
|
1474
|
+
meaningless. The schema now enumerates `identifier`, `metadata`, `source_code`
|
|
1475
|
+
and `ToolContract` enforces it.
|
|
1476
|
+
- **A whitespace-only `OPENAI_API_KEY` is treated as absent on the `woods.json`
|
|
1477
|
+
path (R1-5).** The resolver-default path already stripped before deciding; the
|
|
1478
|
+
stored-config path did not, so `OPENAI_API_KEY=" "` wired an OpenAI provider
|
|
1479
|
+
with a blank key and failed per request instead of raising the one-line
|
|
1480
|
+
`MissingCredential` message.
|
|
1481
|
+
|
|
1482
|
+
- **Console MCP `console_sql` is validated once, with the host adapter's dialect.**
|
|
1483
|
+
The registered tool handler pre-validated with a dialect-blind `SqlValidator.new`
|
|
1484
|
+
(the conservative MySQL+PostgreSQL union) before the executor ever ran, so on a
|
|
1485
|
+
MySQL host a statement like `WHERE body = 'customer\'s request for update'` was
|
|
1486
|
+
rejected as a row-lock clause and the PR-248 dialect-aware acceptance path was
|
|
1487
|
+
dead on both real transports. The handler now only builds the request; the
|
|
1488
|
+
executor still raises `SqlValidationError` for anything it refuses, so no gate is
|
|
1489
|
+
lost. (CON-2)
|
|
1490
|
+
- **Console audit log redacts before it truncates.** `AuditLogger` cut a >16 KiB
|
|
1491
|
+
field at `MAX_FIELD_CHARS` and *then* ran the credential scanner, so a secret
|
|
1492
|
+
straddling that boundary was split, no longer matched the scanner's
|
|
1493
|
+
word-boundary-anchored patterns, and its cleartext prefix landed in the JSONL.
|
|
1494
|
+
Redaction now runs over the whole value first. `#entries` also reads UTF-8
|
|
1495
|
+
explicitly (the truncation notice itself carries a multibyte ellipsis). (CON-5)
|
|
1496
|
+
- **Unexpected exceptions inside the Console dispatch pipeline render as sanitized
|
|
1497
|
+
tool errors.** `DispatchPipeline#call` rescued five known error classes; anything
|
|
1498
|
+
else (a renderer `NoMethodError`, an encoding oddity, a handler defect) escaped
|
|
1499
|
+
into the `mcp` gem's handling of a raising tool block, which can echo
|
|
1500
|
+
`Class: message` to the client. Such failures now answer with the executor's
|
|
1501
|
+
sanitized shape — the tool name only, details logged server-side via
|
|
1502
|
+
`console.dispatch.unexpected_error` — still routed through the credential scan.
|
|
1503
|
+
(prior-audit L9)
|
|
1504
|
+
- **`exe/woods-console` leases a database connection per request.** The stdio
|
|
1505
|
+
server passed `SafeContext.new(connection: ActiveRecord::Base.connection)`,
|
|
1506
|
+
pinning one connection for the process lifetime, so every tool call failed after
|
|
1507
|
+
a failover or a `wait_timeout` recycle until the client restarted. It now passes
|
|
1508
|
+
`pool:`, matching the HTTP path. (prior-audit L10)
|
|
1509
|
+
- **Console executor error text is credential-scanned before the `Rails.logger`
|
|
1510
|
+
write.** The client response for an unexpected execution error was already
|
|
1511
|
+
sanitized to the class name, but the log line carried the adapter's own message —
|
|
1512
|
+
and PG/Mysql2 errors embed the rejected SQL and constraint literals, so a secret
|
|
1513
|
+
in a WHERE clause reached the server log unscanned. (prior-audit L11)
|
|
1514
|
+
- **The missing-Console-token boot warning names the transport it applies to.**
|
|
1515
|
+
"Console MCP requests will be refused (401) until one is set" has no transport
|
|
1516
|
+
qualifier, but the 401 belongs to the HTTP stack; the stdio transport neither
|
|
1517
|
+
sends nor consumes a bearer token. The warning now says so, and that a stdio-only
|
|
1518
|
+
setup still works. The production raise is unchanged. (prior-audit G-3)
|
|
1519
|
+
- **Four more Woods JSONL/JSON readers no longer depend on the process locale.**
|
|
1520
|
+
`Feedback::Store#all_entries`, `SessionTracer::FileStore` (history append, read,
|
|
1521
|
+
and the legacy-file merge), and `Evaluation::QuerySet.load`/`#save` used bare
|
|
1522
|
+
reads, so under `LANG=C` the first non-ASCII entry raised
|
|
1523
|
+
`Encoding::CompatibilityError` and — because the poison line stays on disk while
|
|
1524
|
+
writes keep succeeding — permanently took down `retrieval_explain`,
|
|
1525
|
+
`retrieval_suggest`, GapDetector, and the `session_trace` MCP tool. All read
|
|
1526
|
+
UTF-8 explicitly now, per the `AtomicFile.read` contract. (INF-3, R1-1, EXP-7)
|
|
1527
|
+
- **`woods:embed`, `woods:embed_incremental` and `woods:notion_sync` exit 1 when
|
|
1528
|
+
they report errors.** They printed `Errors: N` and exited 0, so a revoked API
|
|
1529
|
+
key, a full vector store, or a Notion 401 on every page left CI green while the
|
|
1530
|
+
embedding index or the Notion database went stale. They now fail like their
|
|
1531
|
+
siblings `woods:unblocked_sync` and `woods:obsidian`. (INF-4 / EXP-4)
|
|
1532
|
+
- **A missing `git` binary is a decision, not a crash, in `woods:incremental`.**
|
|
1533
|
+
`Errno::ENOENT` out of `Open3.capture3` killed the task with a backtrace before
|
|
1534
|
+
the tail-M1 decision matrix ran, so a daemon-covered tree that should have stood
|
|
1535
|
+
down failed its hook instead. The helper now returns the same `[nil, failure]`
|
|
1536
|
+
shape with `git unavailable: …`. (INF-12)
|
|
1537
|
+
- **`woods/session_tracer/redis_store` loaded standalone raises the documented
|
|
1538
|
+
error.** Without `lib/woods.rb` loaded first, the missing-redis-gem guard raised
|
|
1539
|
+
`NameError: uninitialized constant …SessionTracerError` instead of the actionable
|
|
1540
|
+
"add `gem \"redis\"`" message. (INF-8)
|
|
1541
|
+
- **`woods/feedback/store` requires `time`.** `Time#iso8601` only exists after
|
|
1542
|
+
`require 'time'`; in the MCP server process unrelated requires masked it, so a
|
|
1543
|
+
narrow entry point got a `NoMethodError` from `record_rating`. (INF-9)
|
|
1544
|
+
|
|
1545
|
+
- **STO-1**: A genuine pre-rename `codebase_index` database no longer wedges `Db::Migrator#migrate!` permanently. The legacy gem recorded applied versions in `codebase_index_schema_migrations`, which nothing renamed, so `ensure_table!` created an empty ledger, 001-005 re-ran against a live legacy database and 006's `ALTER TABLE codebase_units RENAME TO woods_units` then failed against the table 001 had just created — and failed identically on every later run. `SchemaVersion#ensure_table!` now adopts the legacy ledger first (guarded, idempotent; when both tables exist the `woods_` one wins and the legacy table is left in place with a warning). The spec fixture that recorded legacy versions in a table the legacy gem never had is corrected.
|
|
1546
|
+
- **STO-2**: Durable-store reconciliation no longer deletes non-Woods Qdrant points. A collection shared with another writer had every foreign point read as "vanished" on each `woods:embed`, and silently deleted whenever the vanished fraction stayed under the 30% purge guard. `Qdrant#each_id` now skips points with no `woods_identifier` payload (they are unattributable to Woods), and `Indexer#vanished_durable_identifiers` additionally ignores ids shaped like a canonical UUID or a native integer point id — shapes Woods never mints as an identifier.
|
|
1547
|
+
- **STO-4**: `Snapshotter::Metadata` raises the typed `Woods::MCP::UnsupportedArtifact` for a truncated or malformed `metadata.msgpack` and for a header missing or mistyping `schema_version`/`record_count`, instead of a raw `EOFError`/`NoMethodError`. Mirrors the M3/M10 guards the Vector twin already carried.
|
|
1548
|
+
- **STO-5**: `woods/storage/qdrant`, `woods/resilience/circuit_breaker` and `woods/cache/cache_middleware` load in isolation again. The first two were missing the repo's `class Error < StandardError; end unless defined?` shim; the third included `Embedding::Provider::Interface` without requiring it. `spec/load_order_spec.rb` grew a require-in-isolation sweep over the storage/cache/embedding entry files.
|
|
1549
|
+
- **STO-6**: `Builder` no longer crashes with `NoMethodError` when an injected embedding provider implements only `#embed`/`#embed_batch` and a durable vector store is configured with an explicit `vector_store_options[:dimensions]`. `vector_dimensions` is now probed like `safe_max_input_tokens` (`respond_to?` plus `rescue NotImplementedError`), which makes the documented "built without an embedding provider" fallback reachable.
|
|
1550
|
+
- **STO-8**: `MetadataStore::InMemory` and `MetadataStore::SQLite` agree on non-string values. InMemory now normalises stored metadata through the same JSON round-trip SQLite performs (symbol keys *and* values become strings, all the way down) and builds field-scoped search haystacks the way `json_extract` does, instead of leaking Ruby `Hash#to_s` syntax such as `=>` into the haystack. The shared "hardened search" examples grew hash/array-valued field queries and value round-trips; the spec that pinned InMemory's divergent symbol round-trip under a parity title is flipped.
|
|
1551
|
+
- **STO-9**: `MetadataStore::SQLite#store` rejects a blank `type` (`''` or whitespace) as well as a missing one — it fabricated exactly the empty type column L22 was fixed to prevent.
|
|
1552
|
+
- **STO-11**: The Indexer's prune guards use the `implements_own?` ownership check rather than `respond_to?(:delete)`, which was always true because `VectorStore::Interface` defines `#delete` as a raising stub (B-108). `reconcilable?` now requires an own `#delete` too, so an adapter that can be enumerated but not deleted from completes the run instead of raising `NotImplementedError` mid-sweep.
|
|
1553
|
+
- **STO-13**: `Util::UUID5.name_bytes` hashes an `ASCII-8BIT`-tagged name by its bytes instead of raising `Encoding::UndefinedConversionError` on high bytes. BINARY is "already bytes", not an encoding to transcode from — the method's own contract.
|
|
1554
|
+
- **STO-15**: The OpenAI provider's in-adapter retry rescues `Net::ReadTimeout`, matching Ollama. `Net::ReadTimeout` descends from `Timeout::Error`, not `IOError`, so a read timeout escaped both the retry and the `RequestError` typing.
|
|
1555
|
+
- **INF-1**: The watch daemon's polling fallback rebuilds the watcher with `ignored: ignored_directories`. Dropping it re-armed the output-directory feedback loop for any `WOODS_OUTPUT` under the root but outside the default ignore set — a daemon that never idles and re-extracts forever, on precisely the path a large tree reaches when listen cannot start.
|
|
1556
|
+
- **INF-2**: Carried-forward work is retried on its own thread rather than inline on the heartbeat thread. A retried storm used to starve the `PipelineLock` touch and the status re-stamp for its whole duration: past `LOCK_STALE_TIMEOUT` (600 s) a waiting writer retires the live lock (the two-writer clobber the lock exists to prevent), and past `Status::STALE_AFTER` (900 s) `woods:incremental` stops standing down. `drain`'s `try_lock` still bounds it to one drain at a time.
|
|
1557
|
+
- **INF-7**: `PipelineLock#acquire` unlinks the lock file it just created if the write fails (a full or read-only disk). The 0-byte remnant was fresh, unparseable (`:unknown` ownership for everyone) and never released, blocking every writer for the whole stale window — the artifact `#touch` was already fixed never to create.
|
|
1558
|
+
- **INF-10**: The daemon's startup watermark treats a generation whose payload directory no longer resolves as no index at all, so the existing no-watermark → storm → full-extraction path recovers a gutted index. Previously the surviving marker read as "index is current at startup" while every reader resolved to a rootward fallback holding nothing.
|
|
1559
|
+
- **INF-11**: `Daemon#release_claim` verifies ownership before deleting the startup claim, mirroring `reclaim_if_stale`'s snapshot-compare and `PipelineLock#release`. A daemon whose claim had been replaced deleted its *successor's* live claim at shutdown, letting a third starter in while the successor ran.
|
|
1560
|
+
- **CORE-6**: `Generation#payload_dir` bounds the payload pointer with a `realpath` comparison against the index root, mirroring `IndexArtifact#validate_dump_dir!` (B-134). `expand_path` is textual, so a symlink planted inside `payloads/` and pointing outside the index passed the check and every payload reader followed it. Flat-index, missing-directory and textual-escape fallbacks are unchanged.
|
|
1561
|
+
- **Gem-owned classes keep their real source path instead of a synthesized
|
|
1562
|
+
app path.** `resolve_source_location` accepted only app-owned locations and
|
|
1563
|
+
otherwise returned the caller's convention fallback, so an engine model such
|
|
1564
|
+
as `ActiveStorage::Blob` indexed at `app/models/active_storage/blob.rb`, a
|
|
1565
|
+
file that does not exist, with no class body in its source and a
|
|
1566
|
+
`woods:validate` warning telling the operator to re-run extraction for a
|
|
1567
|
+
path no extraction could produce. When nothing in the app defines the class
|
|
1568
|
+
and its definition site exists on disk, that site is used. It stays absolute
|
|
1569
|
+
through path normalization, `woods:validate` reports it as gem-owned, and git
|
|
1570
|
+
enrichment skips paths outside `Rails.root` (git rejects a whole `log`
|
|
1571
|
+
invocation when any pathspec is outside the repository) as well as vendored
|
|
1572
|
+
`vendor/` and `node_modules/` paths under it (a bundle vendored inside the
|
|
1573
|
+
app root is gitignored, so asking git about it is wasted work). Applies to every
|
|
1574
|
+
class-based extractor sharing the helper: models, controllers, mailers, jobs,
|
|
1575
|
+
serializers.
|
|
1576
|
+
|
|
1577
|
+
- **Incremental runs no longer duplicate a class-discovered job under its
|
|
1578
|
+
enclosing class's identifier.** A job nested inside a non-job file (`class
|
|
1579
|
+
Billing::Invoicing::Reconciler; class RefreshJob < ApplicationJob`) is found
|
|
1580
|
+
on the full path by the `ApplicationJob` descendant walk, with the model
|
|
1581
|
+
file as its `file_path`. Blast-radius re-extraction only knew the unit's
|
|
1582
|
+
type, took the file-based entry point, and Zeitwerk-governed naming then
|
|
1583
|
+
correctly named the *file* for its outer constant — registering a second
|
|
1584
|
+
`job` unit under the PORO's identifier that no full extraction emits. The
|
|
1585
|
+
duplicate flipped the graph node's type, added a false `variants` entry,
|
|
1586
|
+
and made `woods:incremental` and `woods:extract` disagree about the same
|
|
1587
|
+
tree until the next full run. `Extractor#re_extracted_units` now falls back
|
|
1588
|
+
to the class-based entry point when the file does not reproduce the unit,
|
|
1589
|
+
and only for a class the extractor's own discovery would return
|
|
1590
|
+
(`JobExtractor#discoverable_classes`). The booted equivalence lane pins the
|
|
1591
|
+
shape with a nested-job fixture in `spec/dummy`.
|
|
1592
|
+
|
|
1593
|
+
- **Concerns join the Zeitwerk-governed naming contract.** `ConcernExtractor`
|
|
1594
|
+
was the one extractor still naming from the outer-module chain alone, which
|
|
1595
|
+
stops at the first non-module line. A concern under a mid-path `concerns/`
|
|
1596
|
+
segment (a real namespace, not an autoload root) with a one-line `class
|
|
1597
|
+
SomeError < StandardError; end` declared above it indexed as the wrapper
|
|
1598
|
+
(`Outer::Mid::Inner`) instead of `Outer::Mid::Inner::Concerns::Leaf`. The
|
|
1599
|
+
governed name is tried first; the module-chain scan remains the fallback.
|
|
1600
|
+
|
|
1601
|
+
- **The missing-token console warning names the transport it applies to.**
|
|
1602
|
+
It claimed every Console MCP request would be refused with 401, but only
|
|
1603
|
+
the HTTP transport carries the bearer check; an stdio console server lists
|
|
1604
|
+
and executes every Tier 1 tool without a token. The warning now says HTTP
|
|
1605
|
+
requests will be refused, that stdio does not check the token, and that
|
|
1606
|
+
production boot will raise.
|
|
1607
|
+
|
|
1608
|
+
- **`woods:validate` no longer tells you to re-run extraction for gem-owned
|
|
1609
|
+
paths.** Engine models and framework sources carry absolute paths outside
|
|
1610
|
+
the application tree, so under a different install prefix they resolve
|
|
1611
|
+
nowhere by design and re-extraction cannot change that. They now get their
|
|
1612
|
+
own warning without the no-op remedy; app-tree paths keep the original one.
|
|
1613
|
+
|
|
1614
|
+
- **Metadata searches with `fields: []` now return an empty result on every
|
|
1615
|
+
backend (B-133).** The SQLite adapter previously emitted an incomplete
|
|
1616
|
+
`WHERE` clause and exposed a raw `SQLite3::SQLException`; adapters now stop
|
|
1617
|
+
before touching their backing store when no fields are searchable.
|
|
1618
|
+
|
|
1619
|
+
- **Snapshot dump writers reject symlink escapes before writing (B-134).**
|
|
1620
|
+
Vector dumps, metadata dumps, and promotion now share the same realpath-aware
|
|
1621
|
+
`dumps_root` boundary check. A legitimate symlinked alias of the artifact
|
|
1622
|
+
root remains supported, while a child symlink targeting another directory
|
|
1623
|
+
cannot receive snapshot files.
|
|
1624
|
+
|
|
1625
|
+
- **Corrupt JSON temporal snapshots are consistently treated as absent
|
|
1626
|
+
(B-135).** Direct `find` now follows the existing list/history posture by
|
|
1627
|
+
warning and returning `nil`; `diff` warns and returns an empty result when a
|
|
1628
|
+
requested snapshot is truncated, rather than leaking `JSON::ParserError`.
|
|
1629
|
+
|
|
1630
|
+
- **Payload retention preserves generations pinned by readers in other
|
|
1631
|
+
processes.** `IndexReader#with_pinned_generation` previously coordinated
|
|
1632
|
+
only threads sharing one reader object. A long MCP request could remain on
|
|
1633
|
+
generation N while three quick extraction publishes advanced retention far
|
|
1634
|
+
enough to delete `payloads/gen-N/`; an artifact first opened later in the
|
|
1635
|
+
request then failed with `ENOENT`. Pinned readers now hold a shared advisory
|
|
1636
|
+
lock on that generation's manifest, and retention skips any payload whose
|
|
1637
|
+
manifest cannot immediately take the exclusive lock. The operating system
|
|
1638
|
+
releases the lock on normal exit or a crash, and a later publish reclaims
|
|
1639
|
+
the skipped payload.
|
|
1640
|
+
|
|
1641
|
+
- **One-shot extraction tasks fail when their generation marker cannot be
|
|
1642
|
+
published.** `woods:extract`, `woods:incremental`, `woods:refresh`, and the
|
|
1643
|
+
`woods:extract_framework` compatibility task no longer print success and
|
|
1644
|
+
exit 0 after writing a payload that readers cannot reach. They now raise a
|
|
1645
|
+
typed `Woods::ExtractionError` while the previous generation stays active.
|
|
1646
|
+
The resident watch daemon keeps its existing recoverable behavior: it
|
|
1647
|
+
reports degraded and carries the paths into a later cycle.
|
|
1648
|
+
|
|
1649
|
+
- **`woods:watch_status` resolves its conventional index beside the active
|
|
1650
|
+
Rakefile, not the caller's current directory.** Cheap hook checks still do
|
|
1651
|
+
not boot Rails, but `rake -f /app/Rakefile woods:watch_status` now reads
|
|
1652
|
+
`/app/tmp/woods/watch_status.json` even when a worktree manager launches it
|
|
1653
|
+
elsewhere. `WOODS_OUTPUT` continues to override the conventional path.
|
|
1654
|
+
|
|
1655
|
+
- **`console_sql` rejects `INSERT`, `UPDATE`, and `DELETE` written as bare
|
|
1656
|
+
keywords mid-statement.** The forbidden-body keyword scan anchored every
|
|
1657
|
+
keyword to statement-leader positions only (start of the SQL, or after
|
|
1658
|
+
`;`/a comment boundary), so a statement like `SELECT 1 UPDATE posts SET
|
|
1659
|
+
status = 10` passed validation and failed as an adapter-level syntax error
|
|
1660
|
+
instead of a typed refusal. Those three keywords are reserved words on every
|
|
1661
|
+
supported backend and can never be bare identifiers, so the validator now
|
|
1662
|
+
also rejects them as bare tokens anywhere in the noise-stripped statement
|
|
1663
|
+
body. `MERGE` is deliberately excluded from that scan: SQLite permits an
|
|
1664
|
+
unquoted `merge` column, so body-level MERGE scanning would reject ordinary
|
|
1665
|
+
selects like `SELECT merge FROM posts`; MERGE statements remain covered by
|
|
1666
|
+
the allowed-prefix rule and the WITH-attached-DML check. Literal content
|
|
1667
|
+
never triggers (`SELECT 'update' AS word`, `WHERE title = 'UPDATE me'` stay
|
|
1668
|
+
accepted), identifier-shaped column names stay accepted (`updated_at`,
|
|
1669
|
+
`last_update`, `merge`), and row-lock clauses keep their dedicated earlier
|
|
1670
|
+
check, so `SELECT 1 FOR UPDATE` still reports the lock-clause message.
|
|
1671
|
+
Non-DML keywords that are plausible column names (`do`, `lock`, `release`)
|
|
1672
|
+
keep the leader-anchored rule unchanged.
|
|
1673
|
+
|
|
1674
|
+
- **`console_query` placeholder scopes resolve table-qualified columns
|
|
1675
|
+
case-insensitively, exactly like the public path.** A `["posts.status = ?",
|
|
1676
|
+
10]` scope passed the public schema but was refused at execution with
|
|
1677
|
+
`Unknown table 'posts'. Cannot validate qualified column 'posts.status'.`
|
|
1678
|
+
whenever the executor's ModelValidator had no model-to-table mapping to
|
|
1679
|
+
resolve the qualifier, and a case variant (`["Posts.status = ?", 10]`) was
|
|
1680
|
+
refused even with the mapping. The query scope path now resolves a
|
|
1681
|
+
`table.column` reference whose table matches the queried model's own table
|
|
1682
|
+
(case-insensitively, matching unquoted SQL identifier semantics) against
|
|
1683
|
+
that model's own columns, so own-table qualification behaves exactly like
|
|
1684
|
+
the bare-column form. Redaction stays strict: a redacted column referenced
|
|
1685
|
+
through any case variant (`Users.Password_Digest = ?`, `Orders.Amount = ?`,
|
|
1686
|
+
bare `AMOUNT`) refuses with the typed redaction message — the refusal now
|
|
1687
|
+
runs before column resolution and matches column names case-insensitively —
|
|
1688
|
+
and any other qualified table still resolves through the fail-closed
|
|
1689
|
+
table-column check.
|
|
1690
|
+
|
|
1691
|
+
- **The Index MCP `reload` tool no longer reports an empty success when the promoted dump's
|
|
1692
|
+
store configuration diverges from the live server (M2).** The live retriever was in-memory
|
|
1693
|
+
and the captured dump was complete and valid, but when the dump's embedded `woods.json`
|
|
1694
|
+
named a store type the live target cannot refresh (a re-embed ran with pgvector or Qdrant
|
|
1695
|
+
configured and promoted over the dump the server hydrated from), the reload-time resolver
|
|
1696
|
+
adopted the dump's store types, every candidate builder returned nil, and the tool answered
|
|
1697
|
+
`reloaded: true` with zero counts while nothing was swapped and no degraded condition was
|
|
1698
|
+
recorded. That divergence is now a degraded reload: the `reload` tool responds with the
|
|
1699
|
+
reload-phase `degraded_index` error naming both store types and the honest state (nothing
|
|
1700
|
+
was swapped, the previous generation is still served), and the condition surfaces additively
|
|
1701
|
+
through `woods_status` (`bootstrap.reload_failure`). A genuine empty dump still reloads
|
|
1702
|
+
successfully with zero counts.
|
|
1703
|
+
|
|
1704
|
+
- **Vector dump hydration fails closed on a truncated or mismatched `vectors.idx` (M3).** The
|
|
1705
|
+
idx parser read each record's length, id, and offset with no end-of-file guard: a dump
|
|
1706
|
+
truncated mid-record hydrated a garbage short id silently, an idx holding more records than
|
|
1707
|
+
the float blob crashed hydration with a bare `NoMethodError`, and an idx holding fewer
|
|
1708
|
+
silently hydrated fewer vectors than the dump header claims. Parsing now raises the same
|
|
1709
|
+
typed `UnsupportedArtifact` the bin side raises for a truncated float payload when a record
|
|
1710
|
+
would read past EOF, and the idx record count is cross-checked against the header's
|
|
1711
|
+
`vector_count` after parsing, naming both counts and prompting a re-run of `woods:embed` on
|
|
1712
|
+
mismatch.
|
|
1713
|
+
|
|
1714
|
+
- **Best-effort Git provenance and file-history probes are now quiet and rooted
|
|
1715
|
+
at the extracted application.** Expected failures in source copies without a
|
|
1716
|
+
`.git` directory no longer emit `fatal: not a git repository` on stderr, and
|
|
1717
|
+
extraction launched from another checkout can no longer attach that
|
|
1718
|
+
checkout's branch or file history to the Rails application.
|
|
1719
|
+
|
|
1720
|
+
- **Reloading the Index MCP server no longer opens an empty-store window, and a
|
|
1721
|
+
failed reload no longer leaves a misaligned index (M7).** The `reload` tool
|
|
1722
|
+
refreshed the live in-memory vector and metadata stores with `clear!` followed by
|
|
1723
|
+
`bulk_load`, so a concurrent `codebase_retrieve` could search an empty or
|
|
1724
|
+
half-loaded store (and the reader's caches were reloaded even when store
|
|
1725
|
+
hydration failed, pairing one generation's JSON index with another's vectors).
|
|
1726
|
+
The reload is now a transaction: candidate stores are built off-side against one
|
|
1727
|
+
captured generation marker and one captured promoted-dump identity, reading
|
|
1728
|
+
exclusively from those captured locations (config from the captured dump's
|
|
1729
|
+
embedded snapshot, vector/metadata from the captured dump directory, the graph
|
|
1730
|
+
from the captured payload), so a concurrent promotion can never mix vector and
|
|
1731
|
+
metadata halves from two dumps. Any candidate failure leaves the previous fully
|
|
1732
|
+
aligned generation untouched — the old retriever keeps answering and a distinct
|
|
1733
|
+
reload-phase `degraded_index` condition (with `phase: 'reload'` naming the
|
|
1734
|
+
generation still being served) is reported on the `reload` tool response and
|
|
1735
|
+
additively through `woods_status` (`bootstrap.reload_failure`), without flipping
|
|
1736
|
+
the boot degraded state. The commit acquires the same on-disk extraction
|
|
1737
|
+
PipelineLock every writer uses before rechecking both identities, so a writer
|
|
1738
|
+
cannot publish between the recheck and the one-assignment store-bundle swap; a
|
|
1739
|
+
promoted dump missing any required vector or metadata component also fails
|
|
1740
|
+
closed without replacing the healthy live bundle. Because the reload transaction
|
|
1741
|
+
takes the shared on-disk writer lock, the MCP process needs write access to the
|
|
1742
|
+
index directory when using `reload`. A
|
|
1743
|
+
generation movement fails the attempt with `ReloadGenerationMoved` and a
|
|
1744
|
+
promoted-dump movement (an embed promotes without bumping the generation file)
|
|
1745
|
+
with `ReloadDumpMoved` — the next `reload` is the recovery path. A successful
|
|
1746
|
+
reload clears the condition.
|
|
1747
|
+
|
|
1748
|
+
- **Incremental extraction no longer misses a class-based unit whose file
|
|
1749
|
+
moved with its constant unchanged (M1).** Moving `app/models/tag.rb` to
|
|
1750
|
+
`app/services/tag.rb` without renaming `Tag` pruned the model for the
|
|
1751
|
+
vanished old path, and the second reconciliation pass refused to re-add
|
|
1752
|
+
it, so one generation served an index with the unit missing until the
|
|
1753
|
+
next run. The pass now re-adds pruned class-based identifiers the active
|
|
1754
|
+
Zeitwerk loader still governs a changed file for — the constant path
|
|
1755
|
+
`cpath_expected_at` derives (its inflector, ignores, and root namespaces
|
|
1756
|
+
decide), gated on the file declaring it; a loader non-claim is
|
|
1757
|
+
authoritative, so an unmanaged path re-adds nothing. Another namespace's
|
|
1758
|
+
same-named file and a file that only mentions the class in a comment or
|
|
1759
|
+
string literal resurrect nothing; deletions (including deletions batched
|
|
1760
|
+
with unrelated additions) stay pruned exactly as before.
|
|
1761
|
+
- **Incremental runs now refresh the flow artifact family, and both paths
|
|
1762
|
+
fail closed (M3).** With `precompute_flows` enabled, a controller
|
|
1763
|
+
re-extracted incrementally lost `metadata[:flow_paths]`,
|
|
1764
|
+
`flow_index.json` kept describing pre-change routes, and `flows/`
|
|
1765
|
+
documents for deleted or renamed controllers persisted across every
|
|
1766
|
+
generation. Incremental runs now recompute the run's controller delta,
|
|
1767
|
+
carry untouched controllers' entries forward, and sweep `flows/`
|
|
1768
|
+
documents nothing references through a dedicated flow-artifact sweep
|
|
1769
|
+
(separate from the unit sweep). Full and incremental extractions of the
|
|
1770
|
+
same tree produce equivalent flow artifacts. A failure anywhere in the
|
|
1771
|
+
family on either path — assembly, index write, annotation rewrite, or
|
|
1772
|
+
sweep — now aborts before the generation publish, so a partial flow
|
|
1773
|
+
index, stale prior flow artifacts alongside a new graph, or
|
|
1774
|
+
half-rewritten annotations can never be published; the preceding
|
|
1775
|
+
generation stays resolved and readable.
|
|
1776
|
+
- **`woods:validate` no longer fails every flow-enabled index (G-2).** The
|
|
1777
|
+
validator treated `flows/` as a unit-type directory and demanded
|
|
1778
|
+
`_index.json` from it, so any index published with flow precomputation
|
|
1779
|
+
on reported "Missing _index.json in flows/". Type directories are now
|
|
1780
|
+
bounded by a shared allowlist derived from `Extractor::EXTRACTORS`, and
|
|
1781
|
+
the flow family is validated by its own rule: `flow_index.json` must
|
|
1782
|
+
parse and every entry must point at a flow document that exists and
|
|
1783
|
+
parses, with missing or malformed artifacts reported accurately.
|
|
1784
|
+
- **Multi-line `strong params` declarations are captured (M2).** The
|
|
1785
|
+
`permit(...)`/`expect(...)` capture regexes could not cross newlines, so
|
|
1786
|
+
the common multi-line style produced an empty `metadata[:permitted_params]`.
|
|
1787
|
+
- **A half-loaded model no longer aborts the models phase (L1).**
|
|
1788
|
+
`ModelExtractor.discoverable_classes` called `abstract_class?` unguarded;
|
|
1789
|
+
a descendant that raises on it (possible under the NameError fallback)
|
|
1790
|
+
escaped the scan and failed the whole extraction. The call is guarded the
|
|
1791
|
+
same way `ModelNameCache` already guarded its twin, keeping the class and
|
|
1792
|
+
letting per-class extraction handle failures.
|
|
1793
|
+
- **`if nil` no longer misattributes conditional branches in flow analysis
|
|
1794
|
+
(L2).** The AST normalized `if` children with a `compact` that dropped a
|
|
1795
|
+
literal `nil` condition, so the else body landed in `then_ops`. `if`
|
|
1796
|
+
children are positional now; missing slots stay nil.
|
|
1797
|
+
- **A hydration failure at boot no longer reports `:hydrated` over empty stores (M6).**
|
|
1798
|
+
A corrupt or unreadable dump left the in-memory vector/metadata stores empty behind
|
|
1799
|
+
only a stderr warning while `woods_status` reported a healthy `:hydrated` — a server
|
|
1800
|
+
that answered everything with nothing. The boot status is now derived from store
|
|
1801
|
+
health (`:degraded` plus a per-store `hydration_failures` report), and
|
|
1802
|
+
`codebase_retrieve` answers with a typed `degraded_index` error naming the affected
|
|
1803
|
+
stores instead of a clean empty result. Graph hydration reads through the
|
|
1804
|
+
encoding-safe atomic-file path, so a non-ASCII index stays healthy under `LANG=C`.
|
|
1805
|
+
- **A metadata-store failure no longer produces misleading retrieval answers (M8).**
|
|
1806
|
+
Store errors were swallowed at three call sites: `types:` queries reported `:absent`
|
|
1807
|
+
for types that exist, the rank-within-type fallback short-circuited to empty, and
|
|
1808
|
+
exclusion filtering silently no-op'd. All store accesses now raise the shared
|
|
1809
|
+
`Woods::Retriever::StoreError`, which `codebase_retrieve` maps to the same typed
|
|
1810
|
+
degraded metadata instead of raising through the tool boundary.
|
|
1811
|
+
- **A set-but-empty `OPENAI_API_KEY` behaves as absent (M9).** The truthiness check
|
|
1812
|
+
wired the OpenAI provider with a blank key, skipped the Ollama fallback a missing key
|
|
1813
|
+
gets, and then crashed boot with a raw backtrace. Blank keys now fall through to the
|
|
1814
|
+
Ollama probe (pattern-only when nothing is usable), and both executables catch
|
|
1815
|
+
`Woods::ConfigurationError` in their bootstrap rescue so an unusable embedding
|
|
1816
|
+
configuration prints the one-line operator message.
|
|
1817
|
+
- **A non-`SystemCallError` guard failure no longer leaks the pipeline lock (L6).**
|
|
1818
|
+
`pipeline_extract`/`pipeline_embed` released the on-disk lock only for
|
|
1819
|
+
`SystemCallError`/`IOError` from the task-durability guard; any other raise blocked
|
|
1820
|
+
every later writer until the stale window expired. The release is now `ensure`-based
|
|
1821
|
+
for every pre-handoff exit path.
|
|
1822
|
+
- **"find who calls X" routes to graph tracing (L8).** The query classifier's
|
|
1823
|
+
first-match ordering sent mixed locate/trace queries to keyword location handling;
|
|
1824
|
+
the `:trace` intent pattern now runs before `:locate`.
|
|
1825
|
+
|
|
1826
|
+
- **`woods_status` no longer reports a stale registry version alongside a newer
|
|
1827
|
+
install.** When the installed gem is ahead of RubyGems (for example while
|
|
1828
|
+
testing an unreleased release), `server.update.latest_version` now reports the
|
|
1829
|
+
newest known version — the installed one — instead of the raw published
|
|
1830
|
+
version, so the payload no longer pairs `current_version: 2.0.0` with
|
|
1831
|
+
`latest_version: 1.6.0` and `update_available: false`. `update_available`
|
|
1832
|
+
semantics and all key names are unchanged.
|
|
1833
|
+
- A wrong-dimension query vector now raises the typed `Woods::Error` before the
|
|
1834
|
+
request leaves the process on both the pgvector and Qdrant search paths
|
|
1835
|
+
(previously a server-side `PG::DataException` or Qdrant 400).
|
|
1836
|
+
- An OpenAI embedding request whose one retry also fails now raises the typed
|
|
1837
|
+
`RequestError` (as Ollama already did) instead of leaking a raw
|
|
1838
|
+
`Errno::ECONNRESET`. Persistent HTTP connections dropped on transport errors
|
|
1839
|
+
are closed promptly instead of waiting for GC.
|
|
1840
|
+
|
|
1841
|
+
- **A truncated vector dump now refuses to load instead of corrupting search (M10).**
|
|
1842
|
+
`vectors.bin` with a valid header but a short float payload used to unpack with nil
|
|
1843
|
+
padding: the nil-floated vectors loaded into the live store, crashed search with
|
|
1844
|
+
`TypeError`, and re-published as zeros on the next dump. Loading now raises
|
|
1845
|
+
`Woods::MCP::UnsupportedArtifact` pointing at the file; the remedy is a re-run of
|
|
1846
|
+
`woods:embed`.
|
|
1847
|
+
- **Every atomic dump write now fsyncs its directory (M11).** The vector and metadata
|
|
1848
|
+
snapshotters and the index artifact writer skipped the containing-directory fsync
|
|
1849
|
+
`AtomicFile` performs, so a crash after the rename could leave a directory entry that
|
|
1850
|
+
a reboot drops — a "complete" generation that vanishes. The class contract that the
|
|
1851
|
+
dump directory is fully fsynced before the `latest` pointer flips now holds on every
|
|
1852
|
+
write path.
|
|
1853
|
+
- **File permissions are explicit per artifact (O1).** `AtomicFile.write` takes a `mode:`
|
|
1854
|
+
parameter defaulting to the restrictive 0600 Tempfile already used. The one artifact
|
|
1855
|
+
with a cross-boundary consumer — the watch daemon's `watch_status.json`, read by
|
|
1856
|
+
host-side hooks through a bind mount — is written 0644 by design.
|
|
1857
|
+
- **A second writer on the metadata SQLite database no longer raises
|
|
1858
|
+
`SQLite3::BusyException` immediately (O2).** The connection now sets a busy timeout at
|
|
1859
|
+
open and retries a contended write a bounded number of times, mirroring the temporal
|
|
1860
|
+
snapshot store.
|
|
1861
|
+
- **Re-capturing an unchanged HEAD computes diff stats against real history (L20).**
|
|
1862
|
+
Both temporal stores resolved "previous" to the snapshot being captured, so the
|
|
1863
|
+
re-capture diffed against itself and zeroed every stat. Previous now excludes the SHA
|
|
1864
|
+
being captured.
|
|
1865
|
+
- **Storing metadata without a type key raises instead of writing an empty type (L22).**
|
|
1866
|
+
The SQLite metadata adapter coerced an absent key to `""` in the column that backs
|
|
1867
|
+
`find_by_type`; it now raises `ArgumentError`.
|
|
1868
|
+
|
|
1869
|
+
- **Select aliasing no longer defeats console redaction.** `console_query` accepted
|
|
1870
|
+
`select: ["password_digest AS note"]`; the positional redactor masks by output
|
|
1871
|
+
header name, so the aliased column returned plaintext. Three select shapes are
|
|
1872
|
+
now refused: an alias over a `console_redacted_columns` column, an aggregate over
|
|
1873
|
+
one (aliased or bare), and an alias over either column of a
|
|
1874
|
+
`console_redacted_key_values` pair. Direct, unaliased selection of a redacted
|
|
1875
|
+
column is unchanged and stays masked. Aggregates over either column of a
|
|
1876
|
+
`console_redacted_key_values` pair are also refused: an aggregate such as
|
|
1877
|
+
`MAX(amount)` over the rows a sensitive key selects reads the redacted EAV value
|
|
1878
|
+
itself. Selecting an EAV value column without its paired key column is refused
|
|
1879
|
+
as well — the positional rule needs both headers, so a lone value column
|
|
1880
|
+
returned plaintext.
|
|
1881
|
+
- **`console_query`'s having no longer leaks protected values.** `having` accepted
|
|
1882
|
+
aggregates over redacted or EAV-protected columns (`MAX(amount) > ?`) and bare
|
|
1883
|
+
predicates on redacted columns or EAV value columns; repeated guesses revealed
|
|
1884
|
+
the protected value from whether a row was returned. The same protected-column
|
|
1885
|
+
refusal used for `select` aggregates now runs on the having template and hash
|
|
1886
|
+
keys before any query executes. Structured scope predicates now apply the same
|
|
1887
|
+
rule to redacted columns and EAV value columns while preserving EAV key-column
|
|
1888
|
+
predicates.
|
|
1889
|
+
- **Tier 1 and raw-SQL redaction shapes now fail closed.** `console_sample`,
|
|
1890
|
+
`console_find`, `console_pluck`, and `console_recent` refuse an EAV value column
|
|
1891
|
+
unless its paired key column is selected too; `console_aggregate` refuses either
|
|
1892
|
+
EAV pair column. Structured order/group inputs and legacy multi-bind scope arrays
|
|
1893
|
+
now apply the protected-predicate guard. `console_sql` accepts a protected
|
|
1894
|
+
identifier only as a direct, unaliased outer select column; aliases, aggregates,
|
|
1895
|
+
predicates, CTE shapes, and unpaired EAV values are rejected before execution.
|
|
1896
|
+
- **A writable CTE past the first WITH entry no longer validates.** The writable-CTE
|
|
1897
|
+
check anchored its match to the statement leader, so
|
|
1898
|
+
`WITH a AS (SELECT 1), b AS (DELETE FROM users RETURNING *) SELECT * FROM b`
|
|
1899
|
+
passed validation and PostgreSQL executed the DELETE. Every `AS (...)` body in
|
|
1900
|
+
the statement is now inspected. A CTE list attached to top-level DML
|
|
1901
|
+
(`WITH a AS (SELECT 1) DELETE FROM users RETURNING *`) is also rejected; DELETE
|
|
1902
|
+
and UPDATE previously validated because the statement prefix is WITH and neither
|
|
1903
|
+
keyword is a body keyword (only the INSERT variant tripped a check, incidentally
|
|
1904
|
+
via INTO).
|
|
1905
|
+
- **Row-lock clauses are rejected.** `SELECT ... FOR UPDATE`, `FOR NO KEY UPDATE`,
|
|
1906
|
+
`FOR SHARE`, `FOR KEY SHARE` (with `NOWAIT`/`SKIP LOCKED`), and MySQL
|
|
1907
|
+
`LOCK IN SHARE MODE` validated as reads but took live row locks for the duration
|
|
1908
|
+
of the rolled-back transaction. The check is adapter-aware: `console_sql`
|
|
1909
|
+
validates with the active adapter's dialect (MySQL and PostgreSQL quote/comment
|
|
1910
|
+
grammars differ; MySQL double-quoted strings/backtick identifiers and PostgreSQL
|
|
1911
|
+
quoted identifiers/E-strings are tracked faithfully), while scanning
|
|
1912
|
+
both normalizations when the adapter is unknown, and every view is checked under
|
|
1913
|
+
both MySQL executable-comment (`/*!...*/`) semantics — `#` comments and
|
|
1914
|
+
version-guarded comments can no longer split a lock clause apart.
|
|
1915
|
+
|
|
1916
|
+
- **Index MCP reads no longer break under a C/US-ASCII host locale.** The
|
|
1917
|
+
Index Server read manifest.json, per-type `_index.json` files, and
|
|
1918
|
+
SUMMARY.md with bare `Pathname#read`, which tags the bytes with the host's
|
|
1919
|
+
default external encoding. Under a C locale that tag is US-ASCII, so any
|
|
1920
|
+
non-ASCII content in an index artifact (a branch like `feature/café`, a
|
|
1921
|
+
unit identifier, summary prose) made `JSON.parse` raise
|
|
1922
|
+
`Encoding::InvalidByteSequenceError`, surfacing search, lookup,
|
|
1923
|
+
dependencies, dependents, framework, and recent_changes results as
|
|
1924
|
+
misleading `corrupt_artifact` errors and degrading structure and
|
|
1925
|
+
`woods_status`. All `IndexReader` artifact reads now go through one
|
|
1926
|
+
UTF-8-forcing binary read (the mode unit loading already used), so an
|
|
1927
|
+
index is read correctly regardless of host locale. No re-index needed.
|
|
1928
|
+
|
|
1929
|
+
- **Console redaction-oracle refusals now fire on the real transports.**
|
|
1930
|
+
`Server.build_embedded` handed the executor the transport-provided
|
|
1931
|
+
SafeContext (connection/pool, statement timeout, rolled-back transaction)
|
|
1932
|
+
while building a separate, render-only SafeContext for the configured
|
|
1933
|
+
`console_redacted_columns`/`console_redacted_key_values`. The executor-side
|
|
1934
|
+
refusals (redacted scope/filter keys, find locators, order keys, aggregates
|
|
1935
|
+
and aliases over protected columns, unpaired EAV value selects, protected
|
|
1936
|
+
raw-SQL usage) all read the executor's context, so on the
|
|
1937
|
+
`exe/woods-console` and RackMiddleware wiring every one of them was dead: a
|
|
1938
|
+
comparison, aggregate, sort, or unpaired-EAV read executed against the
|
|
1939
|
+
database and returned plaintext before render-side redaction ever ran.
|
|
1940
|
+
`build_embedded` now derives a single policy-complete SafeContext from the
|
|
1941
|
+
transport context (`SafeContext#with_redaction_policy`), preserving its
|
|
1942
|
+
pool, timeout, and rolled-back transaction while applying the configured
|
|
1943
|
+
policy, and passes that one context to both the executor and the response
|
|
1944
|
+
renderer. The policy comes from the kwargs when supplied and otherwise from
|
|
1945
|
+
the lists the supplied context itself carries — a context that carries its
|
|
1946
|
+
own redaction lists now renders through the same policy-complete context
|
|
1947
|
+
instead of losing its renderer. When redaction is effectively configured
|
|
1948
|
+
but the supplied context cannot derive a policy-complete context,
|
|
1949
|
+
construction fails closed with a `ConfigurationError` rather than leaving
|
|
1950
|
+
the renderer disabled. Render-side masking behavior is unchanged.
|
|
1951
|
+
|
|
1952
|
+
- **TableGate catches blocked tables hidden in MySQL executable comments at
|
|
1953
|
+
FROM, JOIN, and subquery lead position.** The noise stripper deliberately
|
|
1954
|
+
preserves `/*! ... */` forms (MySQL executes their body), but the scanner's
|
|
1955
|
+
FROM/JOIN lead grammars cannot start on a comment marker, so
|
|
1956
|
+
`SELECT * FROM /*!authorizations*/`, `SELECT * FROM /*!99999*/
|
|
1957
|
+
authorizations`, `users JOIN /*!authorizations*/ a ...`, and
|
|
1958
|
+
`FROM (SELECT * FROM /*!authorizations*/) t` surfaced no identifier and the
|
|
1959
|
+
blocked table executed. The scanner now scans two additional views of each
|
|
1960
|
+
dialect's stripped text — every executable comment replaced by its body,
|
|
1961
|
+
and the whole form dropped — mirroring SqlValidator's dual
|
|
1962
|
+
executable-comment semantics for lock clauses. The preserved form is still
|
|
1963
|
+
scanned, so the post-comma shape keeps working; on PostgreSQL the `/*!`
|
|
1964
|
+
form is a syntax error, so extra detections there are over-detection by
|
|
1965
|
+
design.
|
|
1966
|
+
|
|
1967
|
+
- **A failed wholesale re-run can no longer publish a graph with phantom
|
|
1968
|
+
units (M8).** `replace_type_wholesale`'s rescue swallowed every failure.
|
|
1969
|
+
A unit's graph node is registered before its JSON is written, the removal
|
|
1970
|
+
half deletes the JSON before dropping the graph node, and registration
|
|
1971
|
+
itself mutates the graph before it can fail (a malformed dependency raises
|
|
1972
|
+
after the node is already inserted) — so a raise in any of those windows
|
|
1973
|
+
(a full disk, a serialization error, a malformed unit) left the in-memory
|
|
1974
|
+
graph and the payload directory disagreeing, and the run went on to
|
|
1975
|
+
publish a generation whose `dependency_graph.json` held nodes with no unit
|
|
1976
|
+
file, so `dependencies`/`dependents` reported `found: true` while lookup
|
|
1977
|
+
returned nothing. The rescue now re-raises (as `Woods::ExtractionError`)
|
|
1978
|
+
once the replacement has begun to register, write, or remove anything —
|
|
1979
|
+
the marker is placed before each mutation, so a failure inside one cannot
|
|
1980
|
+
slip past it — and the run aborts before publication, leaving the
|
|
1981
|
+
previous generation resolved. A failure that landed nothing is still
|
|
1982
|
+
swallowed, as before.
|
|
1983
|
+
|
|
1984
|
+
- **Incremental runs no longer ship the previous generation's SUMMARY.md
|
|
1985
|
+
(M4).** `write_structural_summary` returned early because an incremental
|
|
1986
|
+
run holds no units in memory, so the hardlinked summary of the last full
|
|
1987
|
+
extraction was served unchanged — its `Units:`/`Chunks:` totals went stale
|
|
1988
|
+
the first time a run added or removed a unit. The summary is now derived on
|
|
1989
|
+
the incremental path from the same persisted per-type `_index.json` files
|
|
1990
|
+
the manifest counts, so the two artifacts agree; the `Generated:` stamp
|
|
1991
|
+
still names the moment the summary was written. The equivalence oracle now
|
|
1992
|
+
also compares SUMMARY.md's totals against the manifest of the same index,
|
|
1993
|
+
so this drift can no longer hide.
|
|
1994
|
+
|
|
1995
|
+
- **`woods:incremental` no longer exits 0 over a git range it cannot resolve
|
|
1996
|
+
(M1).** The diff helper discarded git's exit status, so an unresolvable
|
|
1997
|
+
range — a GitLab zero-SHA, an unfetched GitHub base ref, garbage — read as
|
|
1998
|
+
"no relevant files changed" and the task exited 0 while the sync never ran;
|
|
1999
|
+
the degraded-daemon extract-anyway branch was unreachable. The helper now
|
|
2000
|
+
carries the failure out and the task decides in order: a resolvable range
|
|
2001
|
+
behaves as before; a failed range stands down with a printed reason (exit
|
|
2002
|
+
0) only when a running watch daemon maintains the index, and otherwise
|
|
2003
|
+
fails with an actionable error naming the range (exit 1, like the
|
|
2004
|
+
lock-timeout abort). The diff is also rooted at the extracted application
|
|
2005
|
+
(`git -C Rails.root`), consistent with the provenance rooting, so it can no
|
|
2006
|
+
longer diff whatever checkout the process happened to start in.
|
|
2007
|
+
|
|
2008
|
+
### Testing
|
|
2009
|
+
|
|
2010
|
+
- The verifying-double sweep: 102 string-named `instance_double`s — which verify nothing
|
|
2011
|
+
when the constant isn't loaded — now either reference the real constant (26) or are
|
|
2012
|
+
honest plain doubles (76) (#219). `PhlexExtractor` went from 3 examples/~38% line
|
|
2013
|
+
coverage to 39 examples/99% (#219). Every spec directory now passes standalone; nine
|
|
2014
|
+
spec files only passed in the company of the full suite because earlier files loaded
|
|
2015
|
+
their constants first (B-109). Two order-dependent flakes fixed: `wait_for_threads`
|
|
2016
|
+
now fails loudly on a hung thread, and `tasks_spec` no longer leaks a mutated global
|
|
2017
|
+
configuration (#215, #216).
|
|
2018
|
+
- **CI** (#220): workflows run on pushes to `main` as well as PRs (a bad merge previously
|
|
2019
|
+
went green by absence of a run); the unit axis adds Ruby 3.4 and the booted matrix adds
|
|
2020
|
+
Rails 8.1 rows; a new `live-backends` lane runs storage-adapter contract specs against
|
|
2021
|
+
real PostgreSQL+pgvector and Qdrant service containers plus an offline embed→retrieve
|
|
2022
|
+
round trip — every other storage spec drives doubles, which is how #181 shipped. The
|
|
2023
|
+
lane found B-108 on its first run.
|
|
2024
|
+
|
|
2025
|
+
- **The live-Redis session-tracer contract spec now runs in CI (M6).** The
|
|
2026
|
+
`spec/session_tracer/redis_store_live_spec.rb` suite from the P4 eviction
|
|
2027
|
+
rework was gated on `WOODS_RUN_LIVE_BACKENDS=1` but appeared in no CI
|
|
2028
|
+
job's rspec run, so its six examples (including the two-client Lua
|
|
2029
|
+
migration race) never executed anywhere. The `live-backends` job now
|
|
2030
|
+
lists it; that job already provides the ephemeral `redis` client install,
|
|
2031
|
+
the redis service, and `WOODS_REDIS_URL`.
|
|
2032
|
+
|
|
2033
|
+
### Security
|
|
2034
|
+
|
|
2035
|
+
- Preserve Console blocked-table boundaries for MySQL adjacent subtraction and
|
|
2036
|
+
whitespace/comment-separated qualified table names. Validate every SQL safety
|
|
2037
|
+
check with the active dialect and exercise PostgreSQL/MySQL Console requests in CI.
|
|
2038
|
+
- Require MessagePack 1.8.2 or newer for the patched runtime buffer implementation.
|
|
2039
|
+
- Require patched JSON 2.x (>=2.19.9, <3) so older supported Rails encoders
|
|
2040
|
+
retain their `quirks_mode` compatibility. Activate installed Index executables
|
|
2041
|
+
before optional libraries can select conflicting dependency versions.
|
|
2042
|
+
- Update Inspector transitive dependencies `fast-uri` and `qs` to patched versions
|
|
2043
|
+
and audit the pinned Node dependency tree in CI.
|
|
2044
|
+
|
|
10
2045
|
## [1.6.1] - 2026-07-22
|
|
11
2046
|
|
|
12
2047
|
### Fixed
|