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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +171 -0
- data/CONTRIBUTING.md +30 -1
- data/README.md +31 -0
- data/lib/tasks/woods.rake +52 -2
- data/lib/woods/atomic_file.rb +43 -0
- data/lib/woods/cache/cache_middleware.rb +18 -1
- data/lib/woods/console/embedded_executor.rb +20 -2
- data/lib/woods/console/eval_guard.rb +8 -2
- data/lib/woods/console/sql_noise_stripper.rb +113 -0
- data/lib/woods/console/sql_table_scanner.rb +22 -9
- data/lib/woods/console/sql_validator.rb +1 -2
- data/lib/woods/coordination/pipeline_lock.rb +112 -7
- data/lib/woods/dependency_graph.rb +38 -1
- data/lib/woods/embedding/text_preparer.rb +6 -1
- data/lib/woods/export/unit_facts.rb +80 -0
- data/lib/woods/extractor.rb +153 -32
- data/lib/woods/extractors/concern_extractor.rb +3 -5
- data/lib/woods/extractors/configuration_extractor.rb +3 -5
- data/lib/woods/extractors/controller_extractor.rb +1 -1
- data/lib/woods/extractors/database_view_extractor.rb +1 -1
- data/lib/woods/extractors/decorator_extractor.rb +3 -5
- data/lib/woods/extractors/event_extractor.rb +2 -2
- data/lib/woods/extractors/factory_extractor.rb +2 -4
- data/lib/woods/extractors/graphql_extractor.rb +1 -1
- data/lib/woods/extractors/i18n_extractor.rb +6 -4
- data/lib/woods/extractors/job_extractor.rb +4 -6
- data/lib/woods/extractors/lib_extractor.rb +1 -1
- data/lib/woods/extractors/mailer_extractor.rb +1 -1
- data/lib/woods/extractors/manager_extractor.rb +3 -5
- data/lib/woods/extractors/migration_extractor.rb +1 -1
- data/lib/woods/extractors/model_extractor.rb +26 -10
- data/lib/woods/extractors/phlex_extractor.rb +1 -1
- data/lib/woods/extractors/policy_extractor.rb +3 -5
- data/lib/woods/extractors/poro_extractor.rb +1 -1
- data/lib/woods/extractors/pundit_extractor.rb +3 -5
- data/lib/woods/extractors/rake_task_extractor.rb +2 -4
- data/lib/woods/extractors/serializer_extractor.rb +4 -6
- data/lib/woods/extractors/service_extractor.rb +3 -5
- data/lib/woods/extractors/shared_dependency_scanner.rb +88 -16
- data/lib/woods/extractors/shared_utility_methods.rb +14 -0
- data/lib/woods/extractors/state_machine_extractor.rb +2 -4
- data/lib/woods/extractors/validator_extractor.rb +3 -5
- data/lib/woods/extractors/view_component_extractor.rb +1 -1
- data/lib/woods/filename_utils.rb +31 -2
- data/lib/woods/flow_assembler.rb +45 -28
- data/lib/woods/flow_precomputer.rb +2 -1
- data/lib/woods/index_artifact.rb +5 -1
- data/lib/woods/mcp/server.rb +68 -15
- data/lib/woods/mcp/version_aware_tool_dispatch.rb +47 -0
- data/lib/woods/model_name_cache.rb +6 -1
- data/lib/woods/notion/client.rb +4 -1
- data/lib/woods/notion/exporter.rb +7 -1
- data/lib/woods/obsidian/errors.rb +11 -0
- data/lib/woods/obsidian/name_mapper.rb +132 -0
- data/lib/woods/obsidian/note_builder.rb +260 -0
- data/lib/woods/obsidian/vault_assets.rb +104 -0
- data/lib/woods/obsidian/vault_exporter.rb +412 -0
- data/lib/woods/operator/error_escalator.rb +15 -4
- data/lib/woods/resilience/circuit_breaker.rb +98 -24
- data/lib/woods/resolved_config.rb +4 -1
- data/lib/woods/retrieval/ranker.rb +45 -7
- data/lib/woods/retry_after.rb +36 -0
- data/lib/woods/unblocked/client.rb +4 -1
- data/lib/woods/unblocked/document_builder.rb +21 -27
- data/lib/woods/update_check.rb +190 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods.rb +24 -0
- metadata +12 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a3bf50a7ad421cc52a336375446c4e08c2583dbf2ce96e4a3038bd8dca7a0ae
|
|
4
|
+
data.tar.gz: fe9a6512787372c3f3d0cbe4038720f123349b9961c548b1584d21b8b4cbd0fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
#
|
|
538
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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.
|
|
156
|
-
stripped = SqlNoiseStripper.strip_literals(stripped)
|
|
155
|
+
stripped = SqlNoiseStripper.strip_noise(sql)
|
|
157
156
|
stripped.include?(';')
|
|
158
157
|
end
|
|
159
158
|
|