woods 1.4.0 → 1.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -0
- data/CONTRIBUTING.md +33 -0
- data/README.md +4 -2
- data/lib/woods/extractor.rb +14 -5
- data/lib/woods/git_provenance.rb +121 -0
- data/lib/woods/index_artifact.rb +3 -3
- data/lib/woods/mcp/bootstrapper.rb +6 -5
- data/lib/woods/mcp/config_resolver.rb +42 -21
- data/lib/woods/mcp/errors.rb +5 -3
- data/lib/woods/temporal/json_snapshot_store.rb +4 -1
- data/lib/woods/temporal/snapshot_store.rb +4 -1
- data/lib/woods/unblocked/exporter.rb +55 -5
- data/lib/woods/version.rb +1 -1
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6be4722cd45cc31537b12abfe0dfc48e211aeffc4cc5ff9d0a378b3c2130eb1d
|
|
4
|
+
data.tar.gz: ba720873764980d05cb85a8598218e7061fbad8df5a9f29fb091cface39dd500
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0273f5abb61b0cdf7ba2ee95bdc8632a7e31f717d2eb039ddea18f21085bd83dd5c158497c693ccdc59e5d480be1162629fcd3324b7a9457e607d91183c4a5dc
|
|
7
|
+
data.tar.gz: fa24e32b452eb7c9f3a64267f7c1cd45149e9211611ef87432ec6ce82ca9f2955813bfe778f76cf1003daf2e0cdbf76e3b6590067f1c5fa7cf8a93ff482a0aed
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.5.0] - 2026-06-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Rails 6.0 support: the `railties` floor is lowered from `>= 6.1` to `>= 6.0`**
|
|
15
|
+
(#135). Woods runs cleanly on the Rails 6.0 series — extraction and index-MCP
|
|
16
|
+
serving need no 6.1-only API. The only 6.1-introduced calls touched
|
|
17
|
+
(`connection_db_config`, `has_many_inversing`) are `respond_to?`-guarded and
|
|
18
|
+
degrade on 6.0; a regression spec locks that in.
|
|
19
|
+
- **CI now runs a Rails version matrix plus a booted-app extraction test** (#136).
|
|
20
|
+
`Appraisals` + `gemfiles/rails_*.gemfile` exercise Rails 6.0, 6.1, 7.0, 7.1,
|
|
21
|
+
7.2, and 8.0; the matrix excludes invalid Ruby×Rails pairs (e.g. Rails 6.0 only
|
|
22
|
+
on Ruby 3.0) and adds a **Ruby 4.0** lane. A new booted-app test
|
|
23
|
+
(`spec/integration/booted_extraction_spec.rb`, against the minimal `spec/dummy`
|
|
24
|
+
app) boots Rails in-process and runs a real end-to-end extraction, asserting a
|
|
25
|
+
non-zero unit count and the expected models/associations — gating the
|
|
26
|
+
version-sensitive introspection path the unit suite (which stubs Rails) can't.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- **The Index Server now boots in pattern-only mode by default when no embedding
|
|
31
|
+
index is present** (#138). Since 1.3.0, `woods-mcp` raised `MissingArtifact` at
|
|
32
|
+
boot unless `woods.json` existed or `WOODS_ALLOW_AUTODETECT=1` was set — which
|
|
33
|
+
surprised the most common setup: `rake woods:extract` with no embedding
|
|
34
|
+
provider, where pattern/regex/structural search works perfectly. The server now
|
|
35
|
+
auto-detects by default: it serves all always-on tools (`lookup`, `search`,
|
|
36
|
+
`dependencies`, `structure`, `graph_analysis`, `pagerank`, …) with no env var,
|
|
37
|
+
and `codebase_retrieve` (semantic search) activates automatically once an
|
|
38
|
+
embedding provider is configured. Set `WOODS_REQUIRE_INDEX=1` to restore
|
|
39
|
+
fail-closed behavior (raise `MissingArtifact` when `woods.json` is absent).
|
|
40
|
+
`WOODS_ALLOW_AUTODETECT` is retained as a no-op for backward compatibility.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- **`manifest.json` no longer reports a stale `git_branch`/`git_sha` in a git
|
|
45
|
+
worktree** (#137). In a linked worktree, `.git` is a file pointing at the real
|
|
46
|
+
git directory (often an absolute host path). When that directory couldn't be
|
|
47
|
+
resolved — e.g. inside a container where the host path isn't mounted — git
|
|
48
|
+
enrichment failed silently and the branch/SHA fell back to a baked
|
|
49
|
+
`GIT_BRANCH`/`GIT_SHA` build arg, reporting an unrelated branch. A new
|
|
50
|
+
`Woods::GitProvenance` resolves provenance with worktree-aware plumbing
|
|
51
|
+
(`git -C <root> rev-parse`), and emits `"unknown"` when a `.git` is present but
|
|
52
|
+
the ref can't be resolved (the worktree case) rather than a misleading value.
|
|
53
|
+
The `GIT_BRANCH`/`GIT_SHA` env vars are honored only when there is **no** `.git`
|
|
54
|
+
at the root at all (a non-repo checkout — e.g. a Docker `COPY` that excludes
|
|
55
|
+
`.git` — with build args supplying the SHA) or git is unavailable. Temporal
|
|
56
|
+
snapshots skip an `"unknown"` SHA so it can't key or collide a snapshot.
|
|
57
|
+
|
|
58
|
+
## [1.4.1] - 2026-06-10
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- **Unblocked sync: multiple units sharing one file no longer collide on a single
|
|
63
|
+
URI** (#130). A document's URI derives from `file_path`, so a file defining
|
|
64
|
+
several extracted units (nested/namespaced classes, STI subclasses, multiple
|
|
65
|
+
classes in one `.rb`) mapped every unit to the same URI — the remote document
|
|
66
|
+
was overwritten per unit (only the last survived) and, under the content-hash
|
|
67
|
+
manifest, those units re-pushed on every run. The exporter now detects files
|
|
68
|
+
shared by more than one synced unit and disambiguates: the lexically-first
|
|
69
|
+
identifier keeps the bare blob URL, siblings get a `?unit=<identifier>` suffix.
|
|
70
|
+
Solo files (the overwhelming majority) are untouched. Sibling of the
|
|
71
|
+
no-`file_path` guard shipped in 1.4.0.
|
|
72
|
+
|
|
10
73
|
## [1.4.0] - 2026-06-10
|
|
11
74
|
|
|
12
75
|
### Added — Incremental Unblocked sync (PR #128)
|
data/CONTRIBUTING.md
CHANGED
|
@@ -49,6 +49,39 @@ Woods has two test suites:
|
|
|
49
49
|
|
|
50
50
|
All new features need tests. Bug fixes should include a regression test.
|
|
51
51
|
|
|
52
|
+
### Rails version matrix
|
|
53
|
+
|
|
54
|
+
The gem supports `railties >= 6.0`. Coverage is split across two CI jobs:
|
|
55
|
+
|
|
56
|
+
- The base unit `test` job runs `rake spec` across **Ruby 3.0–4.0** on the
|
|
57
|
+
default (newest) Rails. The unit specs stub Rails, so they run once on the base
|
|
58
|
+
Gemfile rather than per Rails version.
|
|
59
|
+
- The `rails-matrix` job runs the **booted-app extraction test**
|
|
60
|
+
(`spec/integration/booted_extraction_spec.rb` against `spec/dummy`) under each
|
|
61
|
+
supported Rails — 6.0, 6.1, 7.0, 7.1, 7.2, 8.0 — using per-version gemfiles
|
|
62
|
+
under `gemfiles/`. This is the version-sensitive gate: it boots a real Rails
|
|
63
|
+
app in-process and runs an extraction. (The booted spec is tagged `:booted_app`
|
|
64
|
+
and excluded from the default `rake spec`; `WOODS_RUN_BOOTED_APP=1` opts it in,
|
|
65
|
+
and it must run in its own process — it can't share one with the unit suite.)
|
|
66
|
+
|
|
67
|
+
The Rails pins live in `Appraisals`; the gemfiles are hand-maintained
|
|
68
|
+
(`eval_gemfile`-ing the base `Gemfile` and pinning Rails) because Appraisal can't
|
|
69
|
+
generate from the conditional base Gemfile. To run a single Rails row locally:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle install
|
|
73
|
+
WOODS_RUN_BOOTED_APP=1 BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile \
|
|
74
|
+
bundle exec rspec spec/integration/booted_extraction_spec.rb
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
When adding a Rails line: add it to both `Appraisals` and `gemfiles/`, and add a
|
|
78
|
+
valid Ruby×Rails pair to the `rails-matrix` job in `.github/workflows/ci.yml`.
|
|
79
|
+
**For a row below Rails 7.1**, the gemfile must set `ENV['WOODS_SQLITE3_REQ'] =
|
|
80
|
+
'~> 1.4'` *before* `eval_gemfile` (those Rails versions pin `sqlite3 ~> 1.4` in
|
|
81
|
+
their adapter at load time) and pin `concurrent-ruby '< 1.3.5'` (1.3.5 dropped
|
|
82
|
+
the implicit `require "logger"` those releases rely on under Ruby 3.x) — copy an
|
|
83
|
+
existing `gemfiles/rails_6.0.gemfile` as the template.
|
|
84
|
+
|
|
52
85
|
## Code Style
|
|
53
86
|
|
|
54
87
|
- `frozen_string_literal: true` on every file
|
data/README.md
CHANGED
|
@@ -573,8 +573,10 @@ See [Architecture](docs/ARCHITECTURE.md) for the deep dive — extraction phases
|
|
|
573
573
|
|
|
574
574
|
## Requirements
|
|
575
575
|
|
|
576
|
-
- Ruby >= 3.0
|
|
577
|
-
- Rails >= 6.
|
|
576
|
+
- Ruby >= 3.0 (through Ruby 4.0)
|
|
577
|
+
- Rails >= 6.0
|
|
578
|
+
|
|
579
|
+
CI exercises Rails 6.0, 6.1, 7.0, 7.1, 7.2, and 8.0 across Ruby 3.0–4.0 — see the supported-version table in [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md).
|
|
578
580
|
|
|
579
581
|
Works with MySQL, PostgreSQL, and SQLite. No additional infrastructure required for basic extraction — embedding and vector search are optional add-ons.
|
|
580
582
|
|
data/lib/woods/extractor.rb
CHANGED
|
@@ -10,6 +10,7 @@ require 'set'
|
|
|
10
10
|
require_relative 'filename_utils'
|
|
11
11
|
require_relative 'extracted_unit'
|
|
12
12
|
require_relative 'dependency_graph'
|
|
13
|
+
require_relative 'git_provenance'
|
|
13
14
|
require_relative 'extractors/model_extractor'
|
|
14
15
|
require_relative 'extractors/controller_extractor'
|
|
15
16
|
require_relative 'extractors/phlex_extractor'
|
|
@@ -745,6 +746,12 @@ module Woods
|
|
|
745
746
|
end
|
|
746
747
|
|
|
747
748
|
def write_manifest
|
|
749
|
+
# Worktree-aware git provenance. In a linked worktree +.git+ is a file
|
|
750
|
+
# pointing at the real git dir; when that dir is unreachable (e.g. an
|
|
751
|
+
# unmounted host path inside a container) this resolves to "unknown"
|
|
752
|
+
# rather than a stale GIT_BRANCH/GIT_SHA build arg. See GitProvenance (#137).
|
|
753
|
+
provenance = GitProvenance.new(root: Rails.root).to_h
|
|
754
|
+
|
|
748
755
|
manifest = {
|
|
749
756
|
extracted_at: Time.current.iso8601,
|
|
750
757
|
rails_version: Rails.version,
|
|
@@ -757,10 +764,9 @@ module Woods
|
|
|
757
764
|
total_units: @results.values.sum(&:size),
|
|
758
765
|
total_chunks: @results.sum { |_, units| units.sum { |u| u.chunks.size } },
|
|
759
766
|
|
|
760
|
-
# Git
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
git_branch: run_git('rev-parse', '--abbrev-ref', 'HEAD').presence || ENV['GIT_BRANCH'].presence,
|
|
767
|
+
# Git provenance (branch/sha), or "unknown" when unresolvable
|
|
768
|
+
git_sha: provenance[:git_sha],
|
|
769
|
+
git_branch: provenance[:git_branch],
|
|
764
770
|
|
|
765
771
|
# For change detection
|
|
766
772
|
gemfile_lock_sha: gemfile_lock_sha,
|
|
@@ -787,7 +793,10 @@ module Woods
|
|
|
787
793
|
return unless manifest_path.exist?
|
|
788
794
|
|
|
789
795
|
manifest = JSON.parse(File.read(manifest_path))
|
|
790
|
-
|
|
796
|
+
# Snapshots are keyed on the commit SHA — an unresolvable provenance
|
|
797
|
+
# ("unknown", see GitProvenance/#137) must not key or collide a snapshot.
|
|
798
|
+
git_sha = manifest['git_sha']
|
|
799
|
+
return if git_sha.nil? || git_sha == Woods::GitProvenance::UNKNOWN
|
|
791
800
|
|
|
792
801
|
store = build_snapshot_store
|
|
793
802
|
return unless store
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
# Resolves git provenance (branch + commit SHA) for the extracted codebase,
|
|
7
|
+
# correctly handling linked git worktrees where +.git+ is a *file* containing a
|
|
8
|
+
# +gitdir:+ pointer rather than a directory.
|
|
9
|
+
#
|
|
10
|
+
# The reported failure (#137): in a worktree, +.git+ points at a real git
|
|
11
|
+
# directory that may be an absolute host path. When extraction runs somewhere
|
|
12
|
+
# that path cannot be resolved — e.g. inside a container where the host path is
|
|
13
|
+
# not mounted — plain +git rev-parse+ fails silently and the manifest used to
|
|
14
|
+
# fall back to a baked-in +GIT_BRANCH+/+GIT_SHA+ build arg, reporting a stale,
|
|
15
|
+
# misleading branch.
|
|
16
|
+
#
|
|
17
|
+
# Resolution precedence:
|
|
18
|
+
#
|
|
19
|
+
# 1. +git -C <root> rev-parse+ — independent of the process working directory,
|
|
20
|
+
# and resolves a worktree's +.git+-file pointer when the target git directory
|
|
21
|
+
# is reachable. This covers normal checkouts and reachable worktrees.
|
|
22
|
+
# 2. If that fails, {fallback} decides:
|
|
23
|
+
# - A +.git+ entry exists at the root but git couldn't resolve the ref (an
|
|
24
|
+
# unmounted worktree gitdir, a corrupted repo): emit {UNKNOWN}. A working
|
|
25
|
+
# tree whose ref can't be determined must not be papered over with a
|
|
26
|
+
# possibly-stale build arg — that is the #137 bug.
|
|
27
|
+
# - No +.git+ at the root at all (a source tarball, or a Docker +COPY+ that
|
|
28
|
+
# excludes +.git+), or no +git+ binary: the +GIT_BRANCH+/+GIT_SHA+ env vars
|
|
29
|
+
# are the only provenance signal, so honor them if set, else {UNKNOWN}.
|
|
30
|
+
#
|
|
31
|
+
# @example
|
|
32
|
+
# provenance = Woods::GitProvenance.new(root: Rails.root)
|
|
33
|
+
# provenance.branch # => "main" (or "unknown")
|
|
34
|
+
# provenance.sha # => "a1b2c3..." (or "unknown")
|
|
35
|
+
class GitProvenance
|
|
36
|
+
# Sentinel emitted when provenance cannot be determined, in preference to a
|
|
37
|
+
# stale or misleading fallback value.
|
|
38
|
+
UNKNOWN = 'unknown'
|
|
39
|
+
|
|
40
|
+
# @param root [String, Pathname] repository root (typically +Rails.root+)
|
|
41
|
+
# @param env [Hash] environment source (default +ENV+; overridable in specs)
|
|
42
|
+
def initialize(root:, env: ENV)
|
|
43
|
+
@root = root.to_s
|
|
44
|
+
@env = env
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [String] current branch name, or {UNKNOWN}
|
|
48
|
+
def branch
|
|
49
|
+
from_git = rev_parse('--abbrev-ref', 'HEAD')
|
|
50
|
+
# 'HEAD' means detached — not a branch name.
|
|
51
|
+
return from_git if present?(from_git) && from_git != 'HEAD'
|
|
52
|
+
|
|
53
|
+
fallback('GIT_BRANCH')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [String] current commit SHA, or {UNKNOWN}
|
|
57
|
+
def sha
|
|
58
|
+
from_git = rev_parse('HEAD')
|
|
59
|
+
return from_git if present?(from_git)
|
|
60
|
+
|
|
61
|
+
fallback('GIT_SHA')
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Hash{Symbol => String}] +{ git_branch:, git_sha: }+ for the manifest
|
|
65
|
+
def to_h
|
|
66
|
+
{ git_branch: branch, git_sha: sha }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def present?(value)
|
|
72
|
+
value && !value.empty?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Run git rooted at +@root+ so the result is independent of the process
|
|
76
|
+
# working directory and resolves a worktree's +.git+-file pointer when the
|
|
77
|
+
# target git directory is reachable.
|
|
78
|
+
#
|
|
79
|
+
# @return [String] stripped output, or empty string on any failure
|
|
80
|
+
def rev_parse(*args)
|
|
81
|
+
out, status = Open3.capture2('git', '-C', @root, 'rev-parse', *args)
|
|
82
|
+
status.success? ? out.strip : ''
|
|
83
|
+
rescue StandardError
|
|
84
|
+
''
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Decide what to emit when git resolution produced nothing. A baked +GIT_*+
|
|
88
|
+
# env var is honored only when this checkout is not a git working tree (no
|
|
89
|
+
# +.git+ at the root) or git is unavailable. When git is present AND a +.git+
|
|
90
|
+
# exists but the ref couldn't be resolved (unmounted worktree gitdir), emit
|
|
91
|
+
# {UNKNOWN} rather than a possibly-stale value (#137).
|
|
92
|
+
def fallback(env_key)
|
|
93
|
+
return UNKNOWN if git_available? && git_working_tree?
|
|
94
|
+
|
|
95
|
+
value = @env[env_key]
|
|
96
|
+
present?(value) ? value : UNKNOWN
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# True when the +git+ command itself can run, independent of any repository.
|
|
100
|
+
# Memoized — probed at most once per instance even though both +branch+ and
|
|
101
|
+
# +sha+ may reach {fallback}.
|
|
102
|
+
def git_available?
|
|
103
|
+
return @git_available if defined?(@git_available)
|
|
104
|
+
|
|
105
|
+
@git_available = begin
|
|
106
|
+
_out, status = Open3.capture2('git', '--version')
|
|
107
|
+
status.success?
|
|
108
|
+
rescue StandardError
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# True when +<root>/.git+ exists — a directory for a normal checkout, or a
|
|
114
|
+
# file for a linked worktree. Distinguishes "git couldn't resolve this
|
|
115
|
+
# working tree" (emit UNKNOWN) from "there is no working tree here" (the
|
|
116
|
+
# env-var build-arg path is legitimate).
|
|
117
|
+
def git_working_tree?
|
|
118
|
+
File.exist?(File.join(@root, '.git'))
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
data/lib/woods/index_artifact.rb
CHANGED
|
@@ -57,9 +57,9 @@ module Woods
|
|
|
57
57
|
# Returns true when the artifact has never been populated — +woods.json+
|
|
58
58
|
# is absent AND the +dumps/latest+ pointer does not exist.
|
|
59
59
|
#
|
|
60
|
-
# Once either file is present the artifact is considered non-fresh
|
|
61
|
-
# Bootstrapper
|
|
62
|
-
#
|
|
60
|
+
# Once either file is present the artifact is considered non-fresh and the
|
|
61
|
+
# Bootstrapper loads from it. A fresh artifact boots in pattern-only mode by
|
|
62
|
+
# default, raising {Woods::MCP::MissingArtifact} only under WOODS_REQUIRE_INDEX=1.
|
|
63
63
|
#
|
|
64
64
|
# @return [Boolean]
|
|
65
65
|
def fresh?
|
|
@@ -84,9 +84,10 @@ module Woods
|
|
|
84
84
|
#
|
|
85
85
|
# Flow:
|
|
86
86
|
# 1. Wrap output_dir in an IndexArtifact (owns path semantics).
|
|
87
|
-
# 2. If woods.json is present, resolve config from it; otherwise
|
|
88
|
-
#
|
|
89
|
-
#
|
|
87
|
+
# 2. If woods.json is present, resolve config from it; otherwise fall
|
|
88
|
+
# back to env-var auto-detect by default (pattern/structural mode when
|
|
89
|
+
# nothing is found). Set WOODS_REQUIRE_INDEX=1 to fail closed instead
|
|
90
|
+
# (raise MissingArtifact). See #138.
|
|
90
91
|
# 3. Build provider + stores from config (no mutation of
|
|
91
92
|
# Woods.configuration — the host's initializer stays intact).
|
|
92
93
|
# 4. Hydrate in-memory stores from dumps (stubs in PR 2; real in PR 3).
|
|
@@ -103,8 +104,8 @@ module Woods
|
|
|
103
104
|
# When nil, uses Woods.configuration.output_dir.
|
|
104
105
|
# @return [Array(Woods::Retriever, Woods::MCP::BootstrapState)]
|
|
105
106
|
# @raise [Woods::MCP::BootstrapError] on config-invalid (missing
|
|
106
|
-
# credentials, dimension mismatch, unsupported artifact, missing
|
|
107
|
-
# artifact
|
|
107
|
+
# credentials, dimension mismatch, unsupported artifact, or a missing
|
|
108
|
+
# artifact under WOODS_REQUIRE_INDEX=1).
|
|
108
109
|
def self.build_retriever(index_dir: nil)
|
|
109
110
|
state = BootstrapState.new
|
|
110
111
|
state.mark(:hydrating)
|
|
@@ -23,9 +23,12 @@ module Woods
|
|
|
23
23
|
# compatible via {ResolvedConfig#assert_compatible!}.
|
|
24
24
|
# 2. +woods.json+ snapshot alone (MCP server running without a host initializer).
|
|
25
25
|
# The stored config is used to populate +config+ in place.
|
|
26
|
-
# 3. Environment-variable auto-detect (
|
|
27
|
-
#
|
|
28
|
-
#
|
|
26
|
+
# 3. Environment-variable auto-detect (the default when +woods.json+ is
|
|
27
|
+
# absent and no host provider is set). Wires semantic search if
|
|
28
|
+
# +OPENAI_API_KEY+ or a reachable Ollama is found; otherwise leaves the
|
|
29
|
+
# provider nil so the server boots in pattern/structural-only mode.
|
|
30
|
+
# 4. +WOODS_REQUIRE_INDEX=1+ overrides (3) to fail closed, raising
|
|
31
|
+
# {MissingArtifact} when +woods.json+ is absent.
|
|
29
32
|
#
|
|
30
33
|
# @example Typical Bootstrapper usage
|
|
31
34
|
# config, source = ConfigResolver.resolve(Woods.configuration, artifact: artifact)
|
|
@@ -43,10 +46,11 @@ module Woods
|
|
|
43
46
|
# When +woods.json+ is absent and the host already has a provider
|
|
44
47
|
# configured, the host config is trusted and used as-is.
|
|
45
48
|
#
|
|
46
|
-
# When +woods.json+ is absent and the host has no provider configured
|
|
47
|
-
# -
|
|
48
|
-
#
|
|
49
|
-
#
|
|
49
|
+
# When +woods.json+ is absent and the host has no provider configured,
|
|
50
|
+
# auto-detect runs by default: pattern/structural tools always work, and
|
|
51
|
+
# semantic search wires up only if +OPENAI_API_KEY+ or a reachable Ollama
|
|
52
|
+
# is found. Set +WOODS_REQUIRE_INDEX=1+ to fail closed and raise
|
|
53
|
+
# {MissingArtifact} instead.
|
|
50
54
|
#
|
|
51
55
|
# @param config [Woods::Configuration] the live host configuration object.
|
|
52
56
|
# May be mutated in the auto-detect path.
|
|
@@ -61,7 +65,7 @@ module Woods
|
|
|
61
65
|
# where +source+ is one of +:snapshot+, +:host_config+, +:autodetect+,
|
|
62
66
|
# or +:none+. The config is the same object passed in, possibly mutated.
|
|
63
67
|
# @raise [Woods::MCP::MissingArtifact] when +woods.json+ is absent, the
|
|
64
|
-
# host has no provider configured, and +
|
|
68
|
+
# host has no provider configured, and +WOODS_REQUIRE_INDEX=1+ is set.
|
|
65
69
|
# @raise [Woods::MCP::UnsupportedArtifact] when +woods.json+ has an
|
|
66
70
|
# unsupported +schema_version+.
|
|
67
71
|
# @raise [Woods::MCP::DimensionMismatch] when stored and live provider
|
|
@@ -201,36 +205,53 @@ module Woods
|
|
|
201
205
|
|
|
202
206
|
# Handle the no-artifact, no-host-provider case.
|
|
203
207
|
#
|
|
204
|
-
#
|
|
205
|
-
#
|
|
208
|
+
# Extract-only hosts — those that ran +woods:extract+ but never
|
|
209
|
+
# +woods:embed+ and configured no embedding provider — boot in
|
|
210
|
+
# pattern/structural mode by default. The env-var auto-detect path runs,
|
|
211
|
+
# wiring semantic search only if +OPENAI_API_KEY+ or a reachable Ollama
|
|
212
|
+
# instance is found. With neither, +config+ comes back with a nil provider
|
|
213
|
+
# and {Bootstrapper.build_retriever} returns a nil retriever, leaving the
|
|
214
|
+
# always-on pattern/regex/structural tools fully functional.
|
|
215
|
+
#
|
|
216
|
+
# Set +WOODS_REQUIRE_INDEX=1+ to fail closed instead: demand a real
|
|
217
|
+
# +woods.json+ and raise {MissingArtifact} when it is absent.
|
|
218
|
+
# +WOODS_ALLOW_AUTODETECT+ is retained for backward compatibility but no
|
|
219
|
+
# longer changes behaviour — auto-detect is now the default.
|
|
206
220
|
#
|
|
207
221
|
# @param config [Woods::Configuration]
|
|
208
222
|
# @param artifact [Woods::IndexArtifact, nil]
|
|
209
223
|
# @param env [Hash]
|
|
210
224
|
# @param ollama_probe [#call, nil]
|
|
211
|
-
# @return [Woods::Configuration]
|
|
212
|
-
# @raise [Woods::MCP::MissingArtifact]
|
|
225
|
+
# @return [Array(Woods::Configuration, Symbol)]
|
|
226
|
+
# @raise [Woods::MCP::MissingArtifact] only when +WOODS_REQUIRE_INDEX=1+.
|
|
213
227
|
def self.resolve_without_artifact(config, artifact:, env:, ollama_probe:)
|
|
214
|
-
if env['
|
|
228
|
+
if env['WOODS_REQUIRE_INDEX'] == '1'
|
|
215
229
|
raise MissingArtifact.new(
|
|
216
|
-
'No woods.json found and
|
|
217
|
-
'Run `bundle exec rake woods:extract`
|
|
218
|
-
'
|
|
230
|
+
'No woods.json found and WOODS_REQUIRE_INDEX=1 demands a real index. ' \
|
|
231
|
+
'Run `bundle exec rake woods:extract` (then `woods:embed` for semantic ' \
|
|
232
|
+
'search) in your host app, or unset WOODS_REQUIRE_INDEX to boot in ' \
|
|
233
|
+
'pattern-only mode.',
|
|
219
234
|
details: { output_dir: artifact&.output_dir&.to_s }
|
|
220
235
|
)
|
|
221
236
|
end
|
|
222
237
|
|
|
223
|
-
|
|
224
|
-
|
|
238
|
+
resolved = autodetect_from_env(config, env: env, ollama_probe: ollama_probe)
|
|
239
|
+
unless resolved.embedding_provider
|
|
240
|
+
warn '[woods-mcp] no woods.json and no embedding provider — serving ' \
|
|
241
|
+
'pattern/structural tools only. Run `rake woods:embed`, or set ' \
|
|
242
|
+
'OPENAI_API_KEY / run Ollama, to enable semantic search.'
|
|
243
|
+
end
|
|
244
|
+
[resolved, :autodetect]
|
|
225
245
|
end
|
|
226
246
|
private_class_method :resolve_without_artifact
|
|
227
247
|
|
|
228
248
|
# Probe environment variables for provider credentials and configure
|
|
229
249
|
# +config+ accordingly.
|
|
230
250
|
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
251
|
+
# Reached by default when no +woods.json+ is present and the host
|
|
252
|
+
# configured no provider (unless +WOODS_REQUIRE_INDEX=1+). Mutates
|
|
253
|
+
# +config+ to set provider and stores when a credential or reachable
|
|
254
|
+
# Ollama instance is found; leaves the provider nil otherwise.
|
|
234
255
|
#
|
|
235
256
|
# @param config [Woods::Configuration]
|
|
236
257
|
# @param env [Hash]
|
data/lib/woods/mcp/errors.rb
CHANGED
|
@@ -72,9 +72,11 @@ module Woods
|
|
|
72
72
|
# )
|
|
73
73
|
class UnsupportedArtifact < BootstrapError; end
|
|
74
74
|
|
|
75
|
-
# Raised when +woods.json+ is absent from +output_dir+ and the
|
|
76
|
-
#
|
|
77
|
-
#
|
|
75
|
+
# Raised when +woods.json+ is absent from +output_dir+ and the operator has
|
|
76
|
+
# opted into strict mode with +WOODS_REQUIRE_INDEX=1+. By default an absent
|
|
77
|
+
# artifact is *not* an error — the server boots in pattern/structural-only
|
|
78
|
+
# mode (see {ConfigResolver.resolve_without_artifact}). Strict mode exists
|
|
79
|
+
# for deployments that want to fail closed when an index is missing.
|
|
78
80
|
#
|
|
79
81
|
# @example
|
|
80
82
|
# raise Woods::MCP::MissingArtifact.new(
|
|
@@ -29,7 +29,10 @@ module Woods
|
|
|
29
29
|
|
|
30
30
|
def capture(manifest, unit_hashes)
|
|
31
31
|
git_sha = mget(manifest, 'git_sha')
|
|
32
|
-
|
|
32
|
+
# Snapshots are keyed by commit SHA — skip a missing or non-SHA value
|
|
33
|
+
# (e.g. the "unknown" provenance sentinel, #137) rather than letting it
|
|
34
|
+
# reach snapshot_path, which raises ArgumentError on a non-hex SHA.
|
|
35
|
+
return nil unless git_sha.is_a?(String) && git_sha.match?(/\A[0-9a-f]+\z/i)
|
|
33
36
|
|
|
34
37
|
previous = find_latest
|
|
35
38
|
snapshot = build_snapshot(manifest, git_sha, unit_hashes)
|
|
@@ -85,7 +85,10 @@ module Woods
|
|
|
85
85
|
# @return [Hash] Snapshot record with diff stats
|
|
86
86
|
def capture(manifest, unit_hashes)
|
|
87
87
|
git_sha = mget(manifest, 'git_sha')
|
|
88
|
-
|
|
88
|
+
# Snapshots are keyed by commit SHA — skip a missing or non-SHA value
|
|
89
|
+
# (e.g. the "unknown" provenance sentinel, #137) so it can't key or
|
|
90
|
+
# collide a snapshot row. Mirrors JsonSnapshotStore's path validation.
|
|
91
|
+
return nil unless git_sha.is_a?(String) && git_sha.match?(/\A[0-9a-f]+\z/i)
|
|
89
92
|
|
|
90
93
|
previous = find_latest
|
|
91
94
|
upsert_snapshot(manifest, git_sha, unit_hashes.size)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'set'
|
|
4
4
|
require 'digest'
|
|
5
|
+
require 'uri'
|
|
5
6
|
require 'woods'
|
|
6
7
|
require_relative 'client'
|
|
7
8
|
require_relative 'rate_limiter'
|
|
@@ -83,6 +84,9 @@ module Woods
|
|
|
83
84
|
# sync_type_partial methods work standalone (track_uri needs them).
|
|
84
85
|
@current_uris = Set.new
|
|
85
86
|
@budget_exhausted = false
|
|
87
|
+
# base URI => identifier that keeps the bare URI (only populated for
|
|
88
|
+
# URIs shared by >1 unit). Rebuilt per sync_all run.
|
|
89
|
+
@uri_primary = {}
|
|
86
90
|
end
|
|
87
91
|
|
|
88
92
|
# Sync all configured unit types to the Unblocked collection.
|
|
@@ -91,6 +95,7 @@ module Woods
|
|
|
91
95
|
def sync_all
|
|
92
96
|
@current_uris = Set.new
|
|
93
97
|
@budget_exhausted = false
|
|
98
|
+
build_uri_index
|
|
94
99
|
reconcile_from_remote if @manifest.empty?
|
|
95
100
|
|
|
96
101
|
synced = 0
|
|
@@ -232,6 +237,11 @@ module Woods
|
|
|
232
237
|
# ping-pong the manifest hash forever. Skip them.
|
|
233
238
|
return :skipped unless unit_data['file_path']
|
|
234
239
|
|
|
240
|
+
# When several units share one file they share one base URI; only one
|
|
241
|
+
# keeps it, the rest get a `?unit=` suffix so each is a distinct remote
|
|
242
|
+
# document (and a distinct manifest key).
|
|
243
|
+
uri = effective_uri(unit_data)
|
|
244
|
+
|
|
235
245
|
doc = @builder.build(unit_data)
|
|
236
246
|
# An empty body means the credential scrub failed closed (the builders
|
|
237
247
|
# always emit at least a header). Upserting it would overwrite a good
|
|
@@ -241,16 +251,16 @@ module Woods
|
|
|
241
251
|
end
|
|
242
252
|
|
|
243
253
|
hash = fingerprint(doc)
|
|
244
|
-
return :skipped if !@force_full && @manifest.unchanged?(
|
|
254
|
+
return :skipped if !@force_full && @manifest.unchanged?(uri, hash)
|
|
245
255
|
|
|
246
256
|
response = @client.put_document(
|
|
247
257
|
collection_id: @collection_id,
|
|
248
258
|
title: doc[:title],
|
|
249
259
|
body: doc[:body],
|
|
250
|
-
uri:
|
|
260
|
+
uri: uri
|
|
251
261
|
)
|
|
252
|
-
document_id = (response['id'] if response.is_a?(Hash)) || @manifest.document_id_for(
|
|
253
|
-
@manifest.record(uri:
|
|
262
|
+
document_id = (response['id'] if response.is_a?(Hash)) || @manifest.document_id_for(uri)
|
|
263
|
+
@manifest.record(uri: uri, hash: hash, document_id: document_id)
|
|
254
264
|
:synced
|
|
255
265
|
end
|
|
256
266
|
|
|
@@ -360,7 +370,47 @@ module Woods
|
|
|
360
370
|
# stale repo-root document from before this guard should purge.
|
|
361
371
|
return unless unit_data['file_path']
|
|
362
372
|
|
|
363
|
-
|
|
373
|
+
# Must match the URI push_document actually uses, or a colliding unit's
|
|
374
|
+
# disambiguated document would look stale and be purged.
|
|
375
|
+
@current_uris << effective_uri(unit_data)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# The URI a unit's document is stored under. Normally the file's blob URL;
|
|
379
|
+
# when several units share that file, all but the lexically-first
|
|
380
|
+
# identifier get a `?unit=` suffix so each keeps a distinct document
|
|
381
|
+
# rather than overwriting the others (see #build_uri_index).
|
|
382
|
+
def effective_uri(unit_data)
|
|
383
|
+
base = @builder.uri_for(unit_data)
|
|
384
|
+
primary = @uri_primary[base]
|
|
385
|
+
return base if primary.nil? || primary == unit_data['identifier']
|
|
386
|
+
|
|
387
|
+
"#{base}?unit=#{URI.encode_www_form_component(unit_data['identifier'])}"
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# One cheap pass over the type indexes (entries already carry file_path,
|
|
391
|
+
# and read_index is cached) to find files that define more than one synced
|
|
392
|
+
# unit. For each such base URI, the lexically-smallest identifier — the
|
|
393
|
+
# outer/top-level class — keeps the bare URI; siblings are suffixed. Solo
|
|
394
|
+
# files (the overwhelming majority) are absent from the map and unchanged,
|
|
395
|
+
# so this introduces no churn for them.
|
|
396
|
+
def build_uri_index
|
|
397
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
|
398
|
+
synced_types.each do |type|
|
|
399
|
+
@reader.list_units(type: type).each do |entry|
|
|
400
|
+
next unless entry['file_path']
|
|
401
|
+
|
|
402
|
+
groups[@builder.uri_for(entry)] << entry['identifier']
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
@uri_primary = groups.each_with_object({}) do |(uri, identifiers), primary|
|
|
407
|
+
unique = identifiers.uniq
|
|
408
|
+
primary[uri] = unique.min if unique.size > 1
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def synced_types
|
|
413
|
+
FULL_SYNC_TYPES + PARTIAL_SYNC_TYPES.map(&:first)
|
|
364
414
|
end
|
|
365
415
|
|
|
366
416
|
def fingerprint(doc)
|
data/lib/woods/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: woods
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Leah Armstrong
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: mcp
|
|
@@ -64,14 +64,14 @@ dependencies:
|
|
|
64
64
|
requirements:
|
|
65
65
|
- - ">="
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: '6.
|
|
67
|
+
version: '6.0'
|
|
68
68
|
type: :runtime
|
|
69
69
|
prerelease: false
|
|
70
70
|
version_requirements: !ruby/object:Gem::Requirement
|
|
71
71
|
requirements:
|
|
72
72
|
- - ">="
|
|
73
73
|
- !ruby/object:Gem::Version
|
|
74
|
-
version: '6.
|
|
74
|
+
version: '6.0'
|
|
75
75
|
description: |
|
|
76
76
|
Woods extracts structured data from Rails applications for use in
|
|
77
77
|
AI-assisted development tooling. It provides version-specific context by
|
|
@@ -233,6 +233,7 @@ files:
|
|
|
233
233
|
- lib/woods/formatting/generic_adapter.rb
|
|
234
234
|
- lib/woods/formatting/gpt_adapter.rb
|
|
235
235
|
- lib/woods/formatting/human_adapter.rb
|
|
236
|
+
- lib/woods/git_provenance.rb
|
|
236
237
|
- lib/woods/graph_analyzer.rb
|
|
237
238
|
- lib/woods/index_artifact.rb
|
|
238
239
|
- lib/woods/mcp/bearer_auth.rb
|