woods 1.5.0 → 1.6.1

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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +171 -0
  3. data/CONTRIBUTING.md +30 -1
  4. data/README.md +31 -0
  5. data/lib/tasks/woods.rake +52 -2
  6. data/lib/woods/atomic_file.rb +43 -0
  7. data/lib/woods/cache/cache_middleware.rb +18 -1
  8. data/lib/woods/console/embedded_executor.rb +20 -2
  9. data/lib/woods/console/eval_guard.rb +8 -2
  10. data/lib/woods/console/sql_noise_stripper.rb +113 -0
  11. data/lib/woods/console/sql_table_scanner.rb +22 -9
  12. data/lib/woods/console/sql_validator.rb +1 -2
  13. data/lib/woods/coordination/pipeline_lock.rb +112 -7
  14. data/lib/woods/dependency_graph.rb +38 -1
  15. data/lib/woods/embedding/text_preparer.rb +6 -1
  16. data/lib/woods/export/unit_facts.rb +80 -0
  17. data/lib/woods/extractor.rb +153 -32
  18. data/lib/woods/extractors/concern_extractor.rb +3 -5
  19. data/lib/woods/extractors/configuration_extractor.rb +3 -5
  20. data/lib/woods/extractors/controller_extractor.rb +1 -1
  21. data/lib/woods/extractors/database_view_extractor.rb +1 -1
  22. data/lib/woods/extractors/decorator_extractor.rb +3 -5
  23. data/lib/woods/extractors/event_extractor.rb +2 -2
  24. data/lib/woods/extractors/factory_extractor.rb +2 -4
  25. data/lib/woods/extractors/graphql_extractor.rb +1 -1
  26. data/lib/woods/extractors/i18n_extractor.rb +6 -4
  27. data/lib/woods/extractors/job_extractor.rb +4 -6
  28. data/lib/woods/extractors/lib_extractor.rb +1 -1
  29. data/lib/woods/extractors/mailer_extractor.rb +1 -1
  30. data/lib/woods/extractors/manager_extractor.rb +3 -5
  31. data/lib/woods/extractors/migration_extractor.rb +1 -1
  32. data/lib/woods/extractors/model_extractor.rb +26 -10
  33. data/lib/woods/extractors/phlex_extractor.rb +1 -1
  34. data/lib/woods/extractors/policy_extractor.rb +3 -5
  35. data/lib/woods/extractors/poro_extractor.rb +1 -1
  36. data/lib/woods/extractors/pundit_extractor.rb +3 -5
  37. data/lib/woods/extractors/rake_task_extractor.rb +2 -4
  38. data/lib/woods/extractors/serializer_extractor.rb +4 -6
  39. data/lib/woods/extractors/service_extractor.rb +3 -5
  40. data/lib/woods/extractors/shared_dependency_scanner.rb +88 -16
  41. data/lib/woods/extractors/shared_utility_methods.rb +14 -0
  42. data/lib/woods/extractors/state_machine_extractor.rb +2 -4
  43. data/lib/woods/extractors/validator_extractor.rb +3 -5
  44. data/lib/woods/extractors/view_component_extractor.rb +1 -1
  45. data/lib/woods/filename_utils.rb +31 -2
  46. data/lib/woods/flow_assembler.rb +45 -28
  47. data/lib/woods/flow_precomputer.rb +2 -1
  48. data/lib/woods/index_artifact.rb +5 -1
  49. data/lib/woods/mcp/server.rb +68 -15
  50. data/lib/woods/mcp/version_aware_tool_dispatch.rb +47 -0
  51. data/lib/woods/model_name_cache.rb +6 -1
  52. data/lib/woods/notion/client.rb +4 -1
  53. data/lib/woods/notion/exporter.rb +7 -1
  54. data/lib/woods/obsidian/errors.rb +11 -0
  55. data/lib/woods/obsidian/name_mapper.rb +132 -0
  56. data/lib/woods/obsidian/note_builder.rb +260 -0
  57. data/lib/woods/obsidian/vault_assets.rb +104 -0
  58. data/lib/woods/obsidian/vault_exporter.rb +412 -0
  59. data/lib/woods/operator/error_escalator.rb +15 -4
  60. data/lib/woods/resilience/circuit_breaker.rb +98 -24
  61. data/lib/woods/resolved_config.rb +4 -1
  62. data/lib/woods/retrieval/ranker.rb +45 -7
  63. data/lib/woods/retry_after.rb +36 -0
  64. data/lib/woods/unblocked/client.rb +4 -1
  65. data/lib/woods/unblocked/document_builder.rb +21 -27
  66. data/lib/woods/update_check.rb +190 -0
  67. data/lib/woods/version.rb +1 -1
  68. data/lib/woods.rb +24 -0
  69. metadata +12 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6be4722cd45cc31537b12abfe0dfc48e211aeffc4cc5ff9d0a378b3c2130eb1d
4
- data.tar.gz: ba720873764980d05cb85a8598218e7061fbad8df5a9f29fb091cface39dd500
3
+ metadata.gz: 4a3bf50a7ad421cc52a336375446c4e08c2583dbf2ce96e4a3038bd8dca7a0ae
4
+ data.tar.gz: fe9a6512787372c3f3d0cbe4038720f123349b9961c548b1584d21b8b4cbd0fb
5
5
  SHA512:
6
- metadata.gz: 0273f5abb61b0cdf7ba2ee95bdc8632a7e31f717d2eb039ddea18f21085bd83dd5c158497c693ccdc59e5d480be1162629fcd3324b7a9457e607d91183c4a5dc
7
- data.tar.gz: fa24e32b452eb7c9f3a64267f7c1cd45149e9211611ef87432ec6ce82ca9f2955813bfe778f76cf1003daf2e0cdbf76e3b6590067f1c5fa7cf8a93ff482a0aed
6
+ metadata.gz: 7070f985d4a9bab191593599e4f4f8c807d0378c8345a9aa8a73c2ac4de0200b7959ffd6bb6a823bb12fd67a4afe90ad6d3475eb16bbb60ac320ead1de114b77
7
+ data.tar.gz: 3c0cd27167baa9cd7e0245894527760e12bf2d038e57819ffa78468e6f0e6e4122dd144129bcfd769bd89965caad7c997299dfc5de9c1c38dfddb88bab8f2f78
data/CHANGELOG.md CHANGED
@@ -7,6 +7,177 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.6.1] - 2026-07-22
11
+
12
+ ### Fixed
13
+
14
+ - **Incremental extraction crash on multi-unit files.** `woods:incremental` raised
15
+ `NoMethodError: undefined method 'identifier' for an instance of Array` when re-extracting a
16
+ unit whose extractor returns several units from one file (a `.rake` file defining multiple
17
+ tasks; i18n, migration, and lib files are shaped the same way). `Extractor#re_extract_unit`
18
+ now normalizes the extractor result to an array and registers and writes each unit, matching
19
+ how full extraction already handles per-type results. Full extraction was unaffected.
20
+
21
+ ## [1.6.0] - 2026-07-16
22
+
23
+ ### Added
24
+
25
+ - **MCP update awareness.** A new `Woods::UpdateCheck` module performs a best-effort,
26
+ 24h-cached RubyGems lookup for a newer `woods` release (disable with
27
+ `WOODS_NO_UPDATE_CHECK=1`). The Index Server's `woods_status` tool now reports
28
+ `server.update` (`current_version` / `latest_version` / `update_available`), and a call to a
29
+ tool the installed gem doesn't define now returns version-aware guidance ("not available in
30
+ the installed Woods vX — run `bundle update woods`") instead of a bare "Tool not found". This
31
+ keeps agents that follow a newer guide skill from silently failing against an older gem.
32
+ - **Claude Code plugin.** The three user-facing guide skills (`woods-setup`,
33
+ `woods-mcp-config`, `woods-diagnose`) are now packaged as the `woods-plugin`, distributed
34
+ from the [`lost-in-the/plugins`](https://github.com/lost-in-the/plugins) marketplace suite
35
+ via a `git-subdir` source (installs fetch only the `plugin/` subtree, not the whole gem).
36
+ Install with `/plugin marketplace add lost-in-the/plugins` then
37
+ `/plugin install woods-plugin@lost-in-the-plugins`. Each skill gained a **Version Preflight**
38
+ step so agents operate only against the installed Woods version (≥ 1.5.0) instead of
39
+ suggesting tools or tasks an older gem lacks.
40
+
41
+ ### Changed
42
+
43
+ - The user-facing guide skills moved from `docs/skills/` to `plugin/skills/` to form a valid
44
+ Claude Code plugin root (`plugin/.claude-plugin/plugin.json`). In-repo dev-workflow skills
45
+ under `.claude/skills/` are unaffected and remain undistributed.
46
+
47
+ ### Security
48
+
49
+ - **Console `sample`/`recent` tools now validate `columns` against the model's
50
+ real columns.** They passed the raw array to ActiveRecord's `.select`, which
51
+ treats string args as SQL fragments — a crafted column could smuggle a
52
+ subquery into the SELECT list, bypassing TableGate and column redaction
53
+ (`pluck` already validated; `sample`/`recent` didn't).
54
+ - **`trace_flow` sanitizes the entry point before building the flow file path.**
55
+ Only `::`→`__` was applied, so `/` and `..` in a client-supplied entry point
56
+ could traverse outside the `flows/` directory (a file-read oracle over the
57
+ HTTP transport). Both path segments now go through the `FilenameUtils`
58
+ allow-list.
59
+ - **TableGate's SQL table detector no longer misses tables on PostgreSQL.** It
60
+ hardcoded MySQL literal-stripping, so on PostgreSQL (`standard_conforming_strings`)
61
+ the MySQL `\'` rule could over-strip and swallow a real `FROM <blocked>`
62
+ clause. It now strips under both dialects and unions the identifiers.
63
+ - **TableGate no longer misses a table hidden behind a comment marker inside a
64
+ string literal.** `SqlNoiseStripper` stripped comments *before* literals, so
65
+ `SELECT '-- ' FROM blocked` had its real `FROM blocked` swallowed as a line
66
+ comment and slipped past the gate. Comment- and literal-stripping now run in a
67
+ single combined left-to-right pass (`SqlNoiseStripper.strip_noise`) — a
68
+ comment marker inside a literal, and a quote inside a comment, are each
69
+ protected by whichever opens first. `SqlValidator` and the scope-template
70
+ guard route through the same pass.
71
+ - **`EvalGuard` rejects `%x` shell literals with any delimiter — including
72
+ whitespace.** The delimiter check excluded `\s`, but a newline is a valid `%x`
73
+ delimiter (`%x\ncmd\n` compiles and executes), so it slipped through to
74
+ `instance_eval`. The check now matches any non-word delimiter (`[^\w]`).
75
+ - **`PipelineLock` no longer allows two processes to hold the lock.** Both the
76
+ stale-lock takeover and the release path had TOCTOU windows: a process that
77
+ passed the staleness check could rename away — or a plain read-then-unlink
78
+ release could delete — a competitor's *fresh* lock, so both would "hold" it.
79
+ Takeover now re-verifies the retired file is still stale (restoring it and
80
+ backing off if a competitor already took over), and release renames the file
81
+ aside and re-checks its ownership token before deleting.
82
+
83
+ ### Fixed
84
+
85
+ - **Incremental extraction no longer corrupts the index.** `rake woods:incremental`
86
+ overwrote `manifest.json` with zero counts, captured an empty temporal snapshot
87
+ (making the diff report every unit as deleted), wrote absolute file paths into
88
+ the index, and re-indexed affected units with a null `estimated_tokens`.
89
+ - **Retrieval ranking signals work on real backends again.** Recency, importance,
90
+ type-match, and diversity read metadata with symbol keys, but every store
91
+ returns string keys — so all four scored every unit at their neutral fallback
92
+ in production. The ranker now reads both key forms.
93
+ - **The embedding cache is now scoped to the provider model.** The cache key was
94
+ `SHA256(text)` with no model, so a persistent shared backend served the
95
+ previous model's vector after a model switch. `model_name` is now part of the
96
+ key — a plain attribute rather than `dimensions`, whose Ollama implementation
97
+ is a live `embed('test')` probe (keying on it made every cache lookup, hits
98
+ included, depend on the provider being reachable, and defeated the cache while
99
+ the backend was down).
100
+ - **`callback_count` reports real counts.** It called the nonexistent
101
+ `CallbackChain#size`, which raised and left the count silently at 0 for every
102
+ model; switched to `#count`.
103
+ - **`ResolvedConfig` is now deeply immutable.** Nested strings in
104
+ `embedding_provider`/`stores` stayed mutable, so the frozen snapshot could be
105
+ corrupted in place.
106
+ - **Model dependency edges are correct for nested and re-registered units.** The
107
+ `ModelNameCache` alternation matched a prefixed parent (`Library::Book` for
108
+ `Library::Book::Chapter`); `DependencyGraph#register` left stale reverse edges
109
+ on re-registration, inflating incremental blast radius.
110
+ - **Precomputed request flows are no longer empty/stale.** `FlowPrecomputer` ran
111
+ before unit JSON was written to disk; it now runs after `write_results`.
112
+ - **`trace_flow` resolves precomputed flows again.** The reader allow-listed the
113
+ entry point but the writer left the action name raw, so a flow for an action
114
+ outside `[A-Za-z0-9_-]` was written under one filename and looked up under
115
+ another (silently falling back to live assembly). Both sides now derive the
116
+ filename from the shared `FilenameUtils.flow_filename`.
117
+ - **Flow annotation no longer resurrects deleted units.** On a full extraction
118
+ with `precompute_flows`, refreshing a type's `_index.json` rebuilt it from a
119
+ disk glob of the never-wiped output dir, re-adding unit files for classes
120
+ deleted from the app since the last run. The index is now rebuilt from the
121
+ in-memory `@results` (the incremental path, which only holds changed units,
122
+ still rebuilds from disk).
123
+ - **`FlowAssembler` no longer reports DAG diamonds as cycles**, and
124
+ `IndexArtifact#promote` no longer accepts sibling directories that merely
125
+ share a name prefix with `dumps/`.
126
+ - **`implicit_belongs_to` metadata is accurate.** It was flagged on every
127
+ ActiveRecord presence validator; it now keys on `belongs_to` reflections.
128
+ - **Dependency scanning ignores commented references consistently** across all
129
+ three passes (a commented `Library::Book` still produced a ghost edge), while
130
+ keeping references that follow a `#` *inside a string literal* (`link_to "Tag
131
+ #ruby", Article.recent`). Comment-stripping is string-literal-aware — a plain
132
+ `#…` regex ate the rest of such lines and dropped the real edge.
133
+ - **`pipeline_extract(incremental: true)` requires `changed_files`** instead of
134
+ silently re-extracting nothing while reporting success.
135
+ - **`pipeline_diagnose` classifies by the supplied error class** (it built a bare
136
+ `StandardError`, so every `Timeout`/`Net::`/`Errno` error came back
137
+ `:unknown`).
138
+ - **`notion_sync` honors the `NOTION_API_TOKEN` env var**, matching the gate that
139
+ registers the tool (ENV-only hosts previously saw the tool but every call
140
+ failed). A *blank* env var (docker-compose `${NOTION_API_TOKEN}` interpolation
141
+ of an unset host variable) is now treated as absent rather than masking a
142
+ configured token or passing through as a blank bearer. All four resolution
143
+ sites (exporter, `notion_wired?` gate, tool handler, rake task) share
144
+ `Woods.resolve_notion_token`.
145
+ - **Embedded documents include the `dependencies:` line again** — the indexer
146
+ leaves dependency hashes string-keyed and the text preparer only read symbol
147
+ keys.
148
+ - The MCP CLI integration spec no longer fails under a POSIX/C locale (UTF-8
149
+ subprocess output is normalized before regex matching).
150
+ - **`CircuitBreaker` admits only a single probe in `half_open`.** It let every
151
+ concurrent call through while half-open (a thundering herd against a
152
+ recovering service), and a slow probe's success could wipe failures recorded
153
+ by an overlapping probe. Concurrent probes are now rejected with
154
+ `CircuitOpenError`, and an optional `success_threshold` requires N consecutive
155
+ successful probes to close (default 1). Recovery accounting is also keyed to
156
+ the per-call probe flag, not the shared state: a stale call admitted while the
157
+ circuit was *closed* that completes after the transition to `half_open` no
158
+ longer counts as the probe (it could otherwise close the circuit — or clear a
159
+ concurrent probe's slot — while the service was still down).
160
+ - **`Retry-After` honors the HTTP-date form.** The Notion and Unblocked clients
161
+ parsed the header with `.to_f`, turning an HTTP-date into `0.0` and retrying
162
+ immediately against a throttling server. A shared `Woods::RetryAfter` helper
163
+ now handles both the delta-seconds and HTTP-date forms.
164
+ - **The MCP pipeline lock is no longer racy under the HTTP transport.** The
165
+ `@pipeline_mutex ||= Mutex.new` lazy init let two concurrent handlers create
166
+ separate mutexes and run two pipelines of the same kind; the mutex is now
167
+ eagerly initialized.
168
+
169
+ ### Changed
170
+
171
+ - Directory globbing and dependency deduplication across the extractor fleet are
172
+ centralized in `SharedUtilityMethods#find_files_in_directories` and
173
+ `SharedDependencyScanner#consolidate_dependencies` (behavior-preserving).
174
+
175
+ ### Documentation
176
+
177
+ - Added an in-container Index Server section to `DOCKER_SETUP.md` (#139),
178
+ corrected stale tool counts, corrected backend config examples that referenced
179
+ nonexistent adapters (#83), and listed the previously-undocumented rake tasks.
180
+
10
181
  ## [1.5.0] - 2026-06-23
11
182
 
12
183
  ### Added
data/CONTRIBUTING.md CHANGED
@@ -28,7 +28,36 @@ Open an issue describing:
28
28
  5. Ensure the test suite passes: `bundle exec rake spec`
29
29
  6. Ensure code style passes: `bundle exec rubocop`
30
30
  7. Update CHANGELOG.md with your changes
31
- 8. Open a pull request
31
+ 8. Complete the **Pre-PR requirements** below
32
+ 9. Open a pull request
33
+
34
+ ### Pre-PR requirements
35
+
36
+ These are hard gates — a PR that fails either is incomplete:
37
+
38
+ 1. **Documentation must be current.** Any doc affected by the change — README, `docs/`, the
39
+ `plugin/skills/` user guides, `CHANGELOG.md` — must be updated in the *same* PR. Don't ship
40
+ behavior the docs still describe the old way.
41
+ 2. **Investigate plugin-functionality impact.** If the change touches anything the distributed
42
+ user skills rely on — a rake task, MCP tool or its arguments, an executable (`woods-mcp`,
43
+ `woods-mcp-start`, `woods-console-mcp`, `woods-mcp-http`), a config key, or setup steps —
44
+ investigate whether `plugin/skills/{woods-setup,woods-mcp-config,woods-diagnose}` need to
45
+ change.
46
+
47
+ ### Claude Code plugin changes
48
+
49
+ `plugin/` is distributed as the `woods-plugin` via the
50
+ [`lost-in-the/plugins`](https://github.com/lost-in-the/plugins) marketplace (a `git-subdir`
51
+ reference to this subtree). Installed users may run an **older** gem than `main`, so:
52
+
53
+ - If a change adds/removes/renames a tool, task, executable, or config key that a skill
54
+ documents, **update the skill in the same PR**.
55
+ - The skills carry a Version Preflight (operate only against the installed version). **Land the
56
+ skill change with the release that ships the capability** — never document a feature in a
57
+ skill before the version that provides it is released. Bump `plugin/.claude-plugin/plugin.json`
58
+ `version` when the skill content changes.
59
+ - If the change requires a new marketplace entry, `ref` pin, or metadata edit, open a **paired
60
+ PR against `lost-in-the/plugins`** and link it from this PR.
32
61
 
33
62
  ## Development Setup
34
63
 
data/README.md CHANGED
@@ -249,6 +249,31 @@ Static tools miss `status_active?`, `status_pending?`, `build_line_item`, `creat
249
249
 
250
250
  ---
251
251
 
252
+ ## Claude Code plugin
253
+
254
+ Woods publishes three **user-facing guide skills** as a Claude Code plugin so agents can walk
255
+ you through setup, MCP configuration, and troubleshooting without leaving your editor:
256
+
257
+ - `woods-setup` — install, configure, extract, verify, connect MCP servers
258
+ - `woods-mcp-config` — generate a correct `.mcp.json` for your environment
259
+ - `woods-diagnose` — systematic troubleshooting for extraction/MCP/embedding/storage
260
+
261
+ The plugin ships from the [`lost-in-the/plugins`](https://github.com/lost-in-the/plugins)
262
+ marketplace suite, which references this repo's `plugin/` subtree via a `git-subdir` source —
263
+ so installing fetches only the skill files, not the whole gem:
264
+
265
+ ```bash
266
+ # In Claude Code:
267
+ /plugin marketplace add lost-in-the/plugins
268
+ /plugin install woods-plugin@lost-in-the-plugins
269
+ ```
270
+
271
+ The skill files live in this repo under [`plugin/skills/`](plugin/skills/) (the plugin root is
272
+ [`plugin/`](plugin/), with its manifest at `plugin/.claude-plugin/plugin.json`). Each skill
273
+ opens with a Version Preflight so agents operate only against your installed Woods version. To
274
+ test a local checkout: `claude --plugin-dir /path/to/woods/plugin`. Requires **Woods ≥ 1.5.0**.
275
+ The internal dev-workflow skills under `.claude/skills/` are **not** part of this plugin.
276
+
252
277
  ## Connect to Your AI Tool
253
278
 
254
279
  Woods ships two MCP servers. Most users only need the **Index Server**.
@@ -448,8 +473,14 @@ rake woods:clean # Remove index output (alias: woods:clear)
448
473
  rake woods:embed # Embed units for semantic search (alias: woods:nest)
449
474
  rake woods:embed_incremental # Embed changed units only (alias: woods:hone)
450
475
  rake woods:notion_sync # Sync models/columns to Notion (alias: woods:send)
476
+ rake woods:obsidian # Export to an Obsidian vault — graph view + Bases (alias: woods:vault)
451
477
  ```
452
478
 
479
+ > **Visualize connections in Obsidian.** `woods:obsidian` renders the codebase as a self-contained
480
+ > [Obsidian](https://obsidian.md) vault: one interlinked note per unit (explore the dependency graph
481
+ > in graph view), a filterable [Bases](https://help.obsidian.md/bases) table, and a `_woods/` machine
482
+ > sidecar so agents can load the whole topology in one read. See [Obsidian Integration](docs/OBSIDIAN_INTEGRATION.md).
483
+
453
484
  ---
454
485
 
455
486
  ## How It Works Under the Hood
data/lib/tasks/woods.rake CHANGED
@@ -534,8 +534,10 @@ namespace :woods do
534
534
  require 'woods/notion/exporter'
535
535
 
536
536
  config = Woods.configuration
537
- # Env var takes precedence over configured value
538
- config.notion_api_token = ENV.fetch('NOTION_API_TOKEN', nil) || config.notion_api_token
537
+ # A non-blank env var takes precedence over the configured value; a blank
538
+ # NOTION_API_TOKEN is treated as absent. Shared resolution keeps the rake
539
+ # task, the exporter, and the MCP tool consistent.
540
+ config.notion_api_token = Woods.resolve_notion_token(config)
539
541
 
540
542
  unless config.notion_api_token
541
543
  puts 'ERROR: Notion API token not configured.'
@@ -650,6 +652,54 @@ namespace :woods do
650
652
  desc 'Relay findings to Unblocked (alias for unblocked_sync)'
651
653
  task relay: :unblocked_sync
652
654
 
655
+ desc 'Export extraction data to a self-contained Obsidian vault'
656
+ task obsidian: :environment do
657
+ require 'woods/obsidian/vault_exporter'
658
+
659
+ config = Woods.configuration
660
+ output_dir = ENV.fetch('WOODS_OUTPUT', config.output_dir)
661
+ vault_path = ENV.fetch('WOODS_OBSIDIAN_VAULT', File.join(output_dir.to_s, 'obsidian_vault'))
662
+ # Truthy set, so FLAG=false / FLAG=0 disables rather than silently enabling.
663
+ env_flag = ->(name) { %w[1 true yes].include?(ENV.fetch(name, '').strip.downcase) }
664
+
665
+ puts 'Exporting extraction data to an Obsidian vault...'
666
+ puts " Output dir: #{output_dir}"
667
+ puts " Vault: #{vault_path}"
668
+ puts
669
+
670
+ begin
671
+ exporter = Woods::Obsidian::VaultExporter.new(
672
+ index_dir: output_dir,
673
+ vault_path: vault_path,
674
+ include_source: env_flag.call('WOODS_OBSIDIAN_INCLUDE_SOURCE'),
675
+ include_framework: env_flag.call('WOODS_OBSIDIAN_INCLUDE_FRAMEWORK'),
676
+ force_purge: env_flag.call('WOODS_OBSIDIAN_FORCE_PURGE')
677
+ )
678
+ stats = exporter.export_all
679
+ rescue Woods::Obsidian::ExportError => e
680
+ puts "ERROR: #{e.message}"
681
+ exit 1
682
+ end
683
+
684
+ puts 'Export complete!'
685
+ puts " Notes: #{stats[:exported]}"
686
+ puts " Indexes: #{stats[:indexes]}"
687
+ puts " Swept: #{stats[:swept]}"
688
+ puts " Skipped: #{stats[:skipped]}"
689
+ puts
690
+ puts "Open it in Obsidian: 'Open folder as vault' -> #{vault_path}"
691
+
692
+ if stats[:errors].any?
693
+ puts " Errors: #{stats[:errors].size}"
694
+ stats[:errors].first(5).each { |e| puts " - #{e}" }
695
+ puts " ... and #{stats[:errors].size - 5} more" if stats[:errors].size > 5
696
+ exit 1
697
+ end
698
+ end
699
+
700
+ desc 'Render the codebase as an Obsidian vault (alias for obsidian)'
701
+ task vault: :obsidian
702
+
653
703
  desc 'Generate a random bearer token for woods-mcp-http (WOODS_MCP_HTTP_TOKEN)'
654
704
  task :generate_token do
655
705
  require 'securerandom'
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tempfile'
5
+
6
+ module Woods
7
+ # Crash-safe file writes via a temp file + atomic rename.
8
+ #
9
+ # Extracted from the pattern in {Woods::IndexArtifact} (whose own
10
+ # +atomic_write+ is private and instance-level) so any component that writes
11
+ # many files — e.g. the Obsidian exporter writing a vault of notes — can reuse
12
+ # it. If the process dies mid-write, the destination file is either the old
13
+ # content or the new content, never a torn partial.
14
+ #
15
+ # @example
16
+ # Woods::AtomicFile.write("tmp/woods/obsidian_vault/models/User.md", note_body)
17
+ module AtomicFile
18
+ module_function
19
+
20
+ # Atomically write +content+ to +path+, creating parent directories.
21
+ #
22
+ # @param path [String, Pathname] destination path
23
+ # @param content [String] file content
24
+ # @return [void]
25
+ def write(path, content)
26
+ path = path.to_s
27
+ FileUtils.mkdir_p(File.dirname(path))
28
+ tmp = Tempfile.new('.woods-', File.dirname(path))
29
+ # Binary mode so the content's bytes (e.g. UTF-8) are written verbatim
30
+ # regardless of the process's default external encoding.
31
+ tmp.binmode
32
+ tmp.write(content)
33
+ tmp.flush
34
+ tmp.fsync
35
+ tmp.close
36
+ File.rename(tmp.path, path)
37
+ rescue StandardError
38
+ tmp&.close
39
+ tmp&.unlink
40
+ raise
41
+ end
42
+ end
43
+ end
@@ -359,10 +359,27 @@ module Woods
359
359
 
360
360
  # Build a cache key for an embedding text.
361
361
  #
362
+ # The provider's model_name is folded into the key: a cached embedding
363
+ # is only valid for the exact model that produced it. Without this, a
364
+ # persistent shared backend (Redis/SolidCache) returns the previous
365
+ # model's vector after a model switch or upgrade — different dimensions
366
+ # error mid-batch, same dimensions silently corrupt similarity scores
367
+ # (which IndexValidator can't detect, since it checks provider-vs-store
368
+ # dims, not cache contents).
369
+ #
370
+ # model_name (a plain attribute) is used rather than dimensions on
371
+ # purpose: for every supported provider the model uniquely determines
372
+ # the vector dimensionality, and `Provider#dimensions` can force a live
373
+ # network probe (Ollama memoizes `embed('test').length`; OpenAI probes
374
+ # for unknown models). Keying on dimensions made every cache lookup —
375
+ # including hits — depend on the provider being reachable, defeating the
376
+ # cache exactly when the backend is down. model_name distinguishes
377
+ # models without any I/O.
378
+ #
362
379
  # @param text [String]
363
380
  # @return [String]
364
381
  def embedding_key(text)
365
- Cache.cache_key(:embeddings, Digest::SHA256.hexdigest(text))
382
+ Cache.cache_key(:embeddings, @provider.model_name.to_s, Digest::SHA256.hexdigest(text))
366
383
  end
367
384
  end
368
385
 
@@ -458,6 +458,7 @@ module Woods
458
458
  end
459
459
 
460
460
  def handle_sample(params)
461
+ validate_select_columns!(params)
461
462
  model = resolve_model(params['model'])
462
463
  limit = [params.fetch('limit', 5).to_i, 25].min
463
464
  scope = apply_scope(model, params['scope'], model_name: params['model'])
@@ -563,6 +564,7 @@ module Woods
563
564
  end
564
565
 
565
566
  def handle_recent(params)
567
+ validate_select_columns!(params)
566
568
  model = resolve_model(params['model'])
567
569
  order_by = params.fetch('order_by', 'created_at')
568
570
  direction = params.fetch('direction', 'desc')
@@ -903,8 +905,10 @@ module Woods
903
905
  # instead of a confusing adapter-level syntax failure. It also
904
906
  # neutralises `--` line comments and PostgreSQL dollar-quoted
905
907
  # strings that could carry forbidden keywords past a naive scan.
906
- # `SqlNoiseStripper` is the same module SqlValidator uses.
907
- stripped = SqlNoiseStripper.strip_literals(SqlNoiseStripper.strip_comments(template))
908
+ # `SqlNoiseStripper` is the same module SqlValidator uses. The
909
+ # combined single-pass strip_noise resolves comments and literals
910
+ # together so a comment marker inside a literal can't hide a keyword.
911
+ stripped = SqlNoiseStripper.strip_noise(template)
908
912
  if SCOPE_TEMPLATE_FORBIDDEN.match?(stripped)
909
913
  raise ValidationError,
910
914
  'scope template contains forbidden SQL keywords ' \
@@ -928,6 +932,20 @@ module Woods
928
932
  scope.any? { |k, _| ScopePredicateParser::SUFFIX_PATTERN.match?(k.to_s) }
929
933
  end
930
934
 
935
+ # Validate that any requested +columns+ are real model columns before
936
+ # they reach +apply_columns+. ActiveRecord treats string args to
937
+ # +.select+ as raw SQL fragments, so an unvalidated column smuggles a
938
+ # subquery into the SELECT list (the sample/recent tools skip
939
+ # +check_sql!+, so this is their only gate). Mirrors +handle_pluck+.
940
+ #
941
+ # @param params [Hash] Tool params (uses 'model' and 'columns')
942
+ # @raise [ArgumentError] if any column is not declared on the model
943
+ def validate_select_columns!(params)
944
+ return unless params['columns']
945
+
946
+ @model_validator.validate_columns!(params['model'], params['columns'])
947
+ end
948
+
931
949
  # Apply column selection to a relation.
932
950
  #
933
951
  # @param relation [ActiveRecord::Relation] The relation
@@ -194,8 +194,14 @@ module Woods
194
194
  # `%x{cmd}`) — the AST flavor of these is `:xstr`/`:xstr_heredoc`,
195
195
  # which {Woods::Ast::Parser} may normalize differently across
196
196
  # Prism/parser-gem backends. A source-level refusal is both cheap
197
- # and impossible to evade via AST normalization.
198
- if code.include?('`') || code =~ /%x[{<|!@#(\[]/
197
+ # and impossible to evade via AST normalization. Ruby accepts ANY
198
+ # non-word character as the %x delimiter — including whitespace:
199
+ # `%x\ncmd\n` (newline-delimited) is valid and executes. Match
200
+ # every non-word char (`[^\w]`, which spans newline/space/tab, not
201
+ # `[^\w\s]` which excluded them and let `%x\n…` slip through). As a
202
+ # fail-safe, over-refusing odd-but-harmless source beats letting an
203
+ # unlisted delimiter through.
204
+ if code.include?('`') || code =~ /%x[^\w]/
199
205
  raise ForbiddenExpressionError, 'payload contains a shell-execution literal (backtick or %x)'
200
206
  end
201
207
 
@@ -82,6 +82,119 @@ module Woods
82
82
  pattern = dialect == :mysql ? SINGLE_QUOTED_MYSQL : SINGLE_QUOTED_POSTGRES
83
83
  out.gsub(pattern, "''")
84
84
  end
85
+
86
+ # Strip BOTH comments and string literals in a single left-to-right
87
+ # pass, so a comment marker inside a literal and a quote inside a
88
+ # comment are each protected by whichever construct opens first.
89
+ #
90
+ # Running {.strip_comments} then {.strip_literals} (or vice versa) is
91
+ # unsafe: `SELECT '-- ' FROM blocked` has its real `FROM blocked`
92
+ # swallowed as a line comment (the `--` sits inside a string literal),
93
+ # letting a blocked table slip past {TableGate}; the reverse order
94
+ # mis-handles an apostrophe inside a `--` comment. Only a combined scan
95
+ # that tracks literal/comment state correctly resolves both. This
96
+ # scanner backs security checks (SqlValidator, TableGate) so it must
97
+ # never under-detect: an unterminated literal is treated as an ordinary
98
+ # character rather than swallowing the rest of the statement.
99
+ #
100
+ # @param sql [String] the SQL string to process
101
+ # @param dialect [Symbol] `:postgres` (default) or `:mysql` — controls
102
+ # single-quote escape rules (see {.strip_literals}).
103
+ # @return [String] a new string with comments removed and every string
104
+ # literal replaced by `''`
105
+ # @raise [ArgumentError] if an unsupported dialect is provided
106
+ def self.strip_noise(sql, dialect: :postgres) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/AbcSize
107
+ unless SUPPORTED_DIALECTS.include?(dialect)
108
+ raise ArgumentError, "Unknown dialect #{dialect.inspect}. Supported: #{SUPPORTED_DIALECTS.inspect}"
109
+ end
110
+
111
+ mysql = dialect == :mysql
112
+ out = +''
113
+ i = 0
114
+ len = sql.length
115
+
116
+ while i < len
117
+ ch = sql[i]
118
+
119
+ if ch == "'"
120
+ close = single_quote_end(sql, i, mysql: mysql)
121
+ if close
122
+ out << "''"
123
+ i = close
124
+ else
125
+ # Unterminated literal — never under-detect; keep the char.
126
+ out << ch
127
+ i += 1
128
+ end
129
+ elsif ch == '$' && (tag = dollar_tag_at(sql, i))
130
+ close = sql.index(tag, i + tag.length)
131
+ if close
132
+ out << "''"
133
+ i = close + tag.length
134
+ else
135
+ out << ch
136
+ i += 1
137
+ end
138
+ elsif ch == '-' && sql[i + 1] == '-'
139
+ nl = sql.index("\n", i)
140
+ i = nl || len
141
+ elsif ch == '/' && sql[i + 1] == '*'
142
+ close = sql.index('*/', i + 2)
143
+ if close
144
+ i = close + 2
145
+ else
146
+ # Unterminated block comment: never under-detect. Leave it in
147
+ # place (over-detection is safe; the old regex also required a
148
+ # closing */ and left an unterminated /* untouched).
149
+ out << ch
150
+ i += 1
151
+ end
152
+ else
153
+ out << ch
154
+ i += 1
155
+ end
156
+ end
157
+
158
+ out
159
+ end
160
+
161
+ # Regexp matching a PostgreSQL dollar-quote opening tag (`$$` or
162
+ # `$tag$`) at the start of the given slice.
163
+ DOLLAR_TAG = /\A\$\w*\$/
164
+ private_constant :DOLLAR_TAG
165
+
166
+ # Return the dollar-quote tag opening at +index+, or nil.
167
+ #
168
+ # @api private
169
+ def self.dollar_tag_at(sql, index)
170
+ m = DOLLAR_TAG.match(sql[index..])
171
+ m && m[0]
172
+ end
173
+ private_class_method :dollar_tag_at
174
+
175
+ # Return the index just past the closing quote of the single-quoted
176
+ # literal that opens at +start+, honoring `''` (both dialects) and `\'`
177
+ # (MySQL only) escapes. Returns nil when the literal is unterminated.
178
+ #
179
+ # @api private
180
+ def self.single_quote_end(sql, start, mysql:)
181
+ i = start + 1
182
+ len = sql.length
183
+ while i < len
184
+ c = sql[i]
185
+ if mysql && c == '\\'
186
+ i += 2
187
+ elsif c == "'"
188
+ return i + 1 unless sql[i + 1] == "'" # closing quote
189
+
190
+ i += 2 # doubled-quote escape — literal continues
191
+ else
192
+ i += 1
193
+ end
194
+ end
195
+ nil
196
+ end
197
+ private_class_method :single_quote_end
85
198
  end
86
199
  end
87
200
  end
@@ -109,23 +109,36 @@ module Woods
109
109
  # stripped before scanning. Both JOIN-style and ANSI-89 comma-join syntax
110
110
  # are handled.
111
111
  #
112
+ # Literals are stripped under BOTH supported dialects and the scans
113
+ # unioned. This scanner backs TableGate, so it may over-detect but must
114
+ # never under-detect: stripping with the wrong dialect's escape rules
115
+ # can swallow a real FROM clause — e.g. MySQL's `\'` escape applied on
116
+ # a PostgreSQL host (where backslash is literal under
117
+ # standard_conforming_strings) folds `'x\' FROM blocked WHERE y = '`
118
+ # into one literal, hiding `blocked` from the gate while PostgreSQL
119
+ # genuinely reads that table.
120
+ #
112
121
  # @param sql [String, nil] the SQL string to scan
113
- # @return [Array<String>] identifiers in the order they were encountered;
114
- # may contain duplicates if the same table is referenced multiple times
122
+ # @return [Array<String>] identifiers in first-encounter order, deduplicated
115
123
  def self.identifiers_in(sql)
116
124
  return [] if sql.nil? || sql.empty?
117
125
 
118
- stripped = strip_noise(sql)
119
126
  results = []
120
- collect_join_identifiers(stripped, results)
121
- collect_from_identifiers(stripped, results)
122
- results
127
+ %i[postgres mysql].each do |dialect|
128
+ stripped = strip_noise(sql, dialect: dialect)
129
+ collect_join_identifiers(stripped, results)
130
+ collect_from_identifiers(stripped, results)
131
+ end
132
+ results.uniq
123
133
  end
124
134
 
125
135
  # @api private
126
- def self.strip_noise(sql)
127
- out = SqlNoiseStripper.strip_comments(sql)
128
- SqlNoiseStripper.strip_literals(out, dialect: :mysql)
136
+ # Comments and literals must be stripped in a single combined pass —
137
+ # stripping them separately lets a comment marker inside a literal
138
+ # (`'-- '`) hide a real FROM clause from the gate. See
139
+ # {SqlNoiseStripper.strip_noise}.
140
+ def self.strip_noise(sql, dialect:)
141
+ SqlNoiseStripper.strip_noise(sql, dialect: dialect)
129
142
  end
130
143
  private_class_method :strip_noise
131
144
 
@@ -152,8 +152,7 @@ module Woods
152
152
  # @param sql [String]
153
153
  # @return [Boolean]
154
154
  def contains_multiple_statements?(sql)
155
- stripped = SqlNoiseStripper.strip_comments(sql)
156
- stripped = SqlNoiseStripper.strip_literals(stripped)
155
+ stripped = SqlNoiseStripper.strip_noise(sql)
157
156
  stripped.include?(';')
158
157
  end
159
158