woods 1.5.0 → 1.6.0

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 +160 -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 +138 -26
  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: f001f56232acc78a70a3b8584e1bb7dc7d94d9d0f6fa3d34a749bc963d2a361f
4
+ data.tar.gz: d9f5e7b0ff143d8d71e4368ce961e946cfc0a5b380793e6f4e190fedae968375
5
5
  SHA512:
6
- metadata.gz: 0273f5abb61b0cdf7ba2ee95bdc8632a7e31f717d2eb039ddea18f21085bd83dd5c158497c693ccdc59e5d480be1162629fcd3324b7a9457e607d91183c4a5dc
7
- data.tar.gz: fa24e32b452eb7c9f3a64267f7c1cd45149e9211611ef87432ec6ce82ca9f2955813bfe778f76cf1003daf2e0cdbf76e3b6590067f1c5fa7cf8a93ff482a0aed
6
+ metadata.gz: 5486c78c71a80e6c6445e149ff791dce250cf08503309ac0099e517ea680de3759da359a6e5a819963013f80d4ad3fe83b3f46b43e8d81d560cfaa4034a47552
7
+ data.tar.gz: 2af74e73f867a605a4ad5ca2770ea148583bd02c1e520ff87fb34bee0ca09be9dad2888875a37c97f3a620e877320dc32ee676e081c911f0c0ad6253bfe8604e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,166 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.6.0] - 2026-07-16
11
+
12
+ ### Added
13
+
14
+ - **MCP update awareness.** A new `Woods::UpdateCheck` module performs a best-effort,
15
+ 24h-cached RubyGems lookup for a newer `woods` release (disable with
16
+ `WOODS_NO_UPDATE_CHECK=1`). The Index Server's `woods_status` tool now reports
17
+ `server.update` (`current_version` / `latest_version` / `update_available`), and a call to a
18
+ tool the installed gem doesn't define now returns version-aware guidance ("not available in
19
+ the installed Woods vX — run `bundle update woods`") instead of a bare "Tool not found". This
20
+ keeps agents that follow a newer guide skill from silently failing against an older gem.
21
+ - **Claude Code plugin.** The three user-facing guide skills (`woods-setup`,
22
+ `woods-mcp-config`, `woods-diagnose`) are now packaged as the `woods-plugin`, distributed
23
+ from the [`lost-in-the/plugins`](https://github.com/lost-in-the/plugins) marketplace suite
24
+ via a `git-subdir` source (installs fetch only the `plugin/` subtree, not the whole gem).
25
+ Install with `/plugin marketplace add lost-in-the/plugins` then
26
+ `/plugin install woods-plugin@lost-in-the-plugins`. Each skill gained a **Version Preflight**
27
+ step so agents operate only against the installed Woods version (≥ 1.5.0) instead of
28
+ suggesting tools or tasks an older gem lacks.
29
+
30
+ ### Changed
31
+
32
+ - The user-facing guide skills moved from `docs/skills/` to `plugin/skills/` to form a valid
33
+ Claude Code plugin root (`plugin/.claude-plugin/plugin.json`). In-repo dev-workflow skills
34
+ under `.claude/skills/` are unaffected and remain undistributed.
35
+
36
+ ### Security
37
+
38
+ - **Console `sample`/`recent` tools now validate `columns` against the model's
39
+ real columns.** They passed the raw array to ActiveRecord's `.select`, which
40
+ treats string args as SQL fragments — a crafted column could smuggle a
41
+ subquery into the SELECT list, bypassing TableGate and column redaction
42
+ (`pluck` already validated; `sample`/`recent` didn't).
43
+ - **`trace_flow` sanitizes the entry point before building the flow file path.**
44
+ Only `::`→`__` was applied, so `/` and `..` in a client-supplied entry point
45
+ could traverse outside the `flows/` directory (a file-read oracle over the
46
+ HTTP transport). Both path segments now go through the `FilenameUtils`
47
+ allow-list.
48
+ - **TableGate's SQL table detector no longer misses tables on PostgreSQL.** It
49
+ hardcoded MySQL literal-stripping, so on PostgreSQL (`standard_conforming_strings`)
50
+ the MySQL `\'` rule could over-strip and swallow a real `FROM <blocked>`
51
+ clause. It now strips under both dialects and unions the identifiers.
52
+ - **TableGate no longer misses a table hidden behind a comment marker inside a
53
+ string literal.** `SqlNoiseStripper` stripped comments *before* literals, so
54
+ `SELECT '-- ' FROM blocked` had its real `FROM blocked` swallowed as a line
55
+ comment and slipped past the gate. Comment- and literal-stripping now run in a
56
+ single combined left-to-right pass (`SqlNoiseStripper.strip_noise`) — a
57
+ comment marker inside a literal, and a quote inside a comment, are each
58
+ protected by whichever opens first. `SqlValidator` and the scope-template
59
+ guard route through the same pass.
60
+ - **`EvalGuard` rejects `%x` shell literals with any delimiter — including
61
+ whitespace.** The delimiter check excluded `\s`, but a newline is a valid `%x`
62
+ delimiter (`%x\ncmd\n` compiles and executes), so it slipped through to
63
+ `instance_eval`. The check now matches any non-word delimiter (`[^\w]`).
64
+ - **`PipelineLock` no longer allows two processes to hold the lock.** Both the
65
+ stale-lock takeover and the release path had TOCTOU windows: a process that
66
+ passed the staleness check could rename away — or a plain read-then-unlink
67
+ release could delete — a competitor's *fresh* lock, so both would "hold" it.
68
+ Takeover now re-verifies the retired file is still stale (restoring it and
69
+ backing off if a competitor already took over), and release renames the file
70
+ aside and re-checks its ownership token before deleting.
71
+
72
+ ### Fixed
73
+
74
+ - **Incremental extraction no longer corrupts the index.** `rake woods:incremental`
75
+ overwrote `manifest.json` with zero counts, captured an empty temporal snapshot
76
+ (making the diff report every unit as deleted), wrote absolute file paths into
77
+ the index, and re-indexed affected units with a null `estimated_tokens`.
78
+ - **Retrieval ranking signals work on real backends again.** Recency, importance,
79
+ type-match, and diversity read metadata with symbol keys, but every store
80
+ returns string keys — so all four scored every unit at their neutral fallback
81
+ in production. The ranker now reads both key forms.
82
+ - **The embedding cache is now scoped to the provider model.** The cache key was
83
+ `SHA256(text)` with no model, so a persistent shared backend served the
84
+ previous model's vector after a model switch. `model_name` is now part of the
85
+ key — a plain attribute rather than `dimensions`, whose Ollama implementation
86
+ is a live `embed('test')` probe (keying on it made every cache lookup, hits
87
+ included, depend on the provider being reachable, and defeated the cache while
88
+ the backend was down).
89
+ - **`callback_count` reports real counts.** It called the nonexistent
90
+ `CallbackChain#size`, which raised and left the count silently at 0 for every
91
+ model; switched to `#count`.
92
+ - **`ResolvedConfig` is now deeply immutable.** Nested strings in
93
+ `embedding_provider`/`stores` stayed mutable, so the frozen snapshot could be
94
+ corrupted in place.
95
+ - **Model dependency edges are correct for nested and re-registered units.** The
96
+ `ModelNameCache` alternation matched a prefixed parent (`Library::Book` for
97
+ `Library::Book::Chapter`); `DependencyGraph#register` left stale reverse edges
98
+ on re-registration, inflating incremental blast radius.
99
+ - **Precomputed request flows are no longer empty/stale.** `FlowPrecomputer` ran
100
+ before unit JSON was written to disk; it now runs after `write_results`.
101
+ - **`trace_flow` resolves precomputed flows again.** The reader allow-listed the
102
+ entry point but the writer left the action name raw, so a flow for an action
103
+ outside `[A-Za-z0-9_-]` was written under one filename and looked up under
104
+ another (silently falling back to live assembly). Both sides now derive the
105
+ filename from the shared `FilenameUtils.flow_filename`.
106
+ - **Flow annotation no longer resurrects deleted units.** On a full extraction
107
+ with `precompute_flows`, refreshing a type's `_index.json` rebuilt it from a
108
+ disk glob of the never-wiped output dir, re-adding unit files for classes
109
+ deleted from the app since the last run. The index is now rebuilt from the
110
+ in-memory `@results` (the incremental path, which only holds changed units,
111
+ still rebuilds from disk).
112
+ - **`FlowAssembler` no longer reports DAG diamonds as cycles**, and
113
+ `IndexArtifact#promote` no longer accepts sibling directories that merely
114
+ share a name prefix with `dumps/`.
115
+ - **`implicit_belongs_to` metadata is accurate.** It was flagged on every
116
+ ActiveRecord presence validator; it now keys on `belongs_to` reflections.
117
+ - **Dependency scanning ignores commented references consistently** across all
118
+ three passes (a commented `Library::Book` still produced a ghost edge), while
119
+ keeping references that follow a `#` *inside a string literal* (`link_to "Tag
120
+ #ruby", Article.recent`). Comment-stripping is string-literal-aware — a plain
121
+ `#…` regex ate the rest of such lines and dropped the real edge.
122
+ - **`pipeline_extract(incremental: true)` requires `changed_files`** instead of
123
+ silently re-extracting nothing while reporting success.
124
+ - **`pipeline_diagnose` classifies by the supplied error class** (it built a bare
125
+ `StandardError`, so every `Timeout`/`Net::`/`Errno` error came back
126
+ `:unknown`).
127
+ - **`notion_sync` honors the `NOTION_API_TOKEN` env var**, matching the gate that
128
+ registers the tool (ENV-only hosts previously saw the tool but every call
129
+ failed). A *blank* env var (docker-compose `${NOTION_API_TOKEN}` interpolation
130
+ of an unset host variable) is now treated as absent rather than masking a
131
+ configured token or passing through as a blank bearer. All four resolution
132
+ sites (exporter, `notion_wired?` gate, tool handler, rake task) share
133
+ `Woods.resolve_notion_token`.
134
+ - **Embedded documents include the `dependencies:` line again** — the indexer
135
+ leaves dependency hashes string-keyed and the text preparer only read symbol
136
+ keys.
137
+ - The MCP CLI integration spec no longer fails under a POSIX/C locale (UTF-8
138
+ subprocess output is normalized before regex matching).
139
+ - **`CircuitBreaker` admits only a single probe in `half_open`.** It let every
140
+ concurrent call through while half-open (a thundering herd against a
141
+ recovering service), and a slow probe's success could wipe failures recorded
142
+ by an overlapping probe. Concurrent probes are now rejected with
143
+ `CircuitOpenError`, and an optional `success_threshold` requires N consecutive
144
+ successful probes to close (default 1). Recovery accounting is also keyed to
145
+ the per-call probe flag, not the shared state: a stale call admitted while the
146
+ circuit was *closed* that completes after the transition to `half_open` no
147
+ longer counts as the probe (it could otherwise close the circuit — or clear a
148
+ concurrent probe's slot — while the service was still down).
149
+ - **`Retry-After` honors the HTTP-date form.** The Notion and Unblocked clients
150
+ parsed the header with `.to_f`, turning an HTTP-date into `0.0` and retrying
151
+ immediately against a throttling server. A shared `Woods::RetryAfter` helper
152
+ now handles both the delta-seconds and HTTP-date forms.
153
+ - **The MCP pipeline lock is no longer racy under the HTTP transport.** The
154
+ `@pipeline_mutex ||= Mutex.new` lazy init let two concurrent handlers create
155
+ separate mutexes and run two pipelines of the same kind; the mutex is now
156
+ eagerly initialized.
157
+
158
+ ### Changed
159
+
160
+ - Directory globbing and dependency deduplication across the extractor fleet are
161
+ centralized in `SharedUtilityMethods#find_files_in_directories` and
162
+ `SharedDependencyScanner#consolidate_dependencies` (behavior-preserving).
163
+
164
+ ### Documentation
165
+
166
+ - Added an in-container Index Server section to `DOCKER_SETUP.md` (#139),
167
+ corrected stale tool counts, corrected backend config examples that referenced
168
+ nonexistent adapters (#83), and listed the previously-undocumented rake tasks.
169
+
10
170
  ## [1.5.0] - 2026-06-23
11
171
 
12
172
  ### 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