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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8991ef048ac039aafe41403a11c320333107ece6ad89da376765107820d27b9
4
- data.tar.gz: 3fdc6630de8946e1d2ba074ca0b115588934ff965712f1d18d6d3abeb8c5c50b
3
+ metadata.gz: 6be4722cd45cc31537b12abfe0dfc48e211aeffc4cc5ff9d0a378b3c2130eb1d
4
+ data.tar.gz: ba720873764980d05cb85a8598218e7061fbad8df5a9f29fb091cface39dd500
5
5
  SHA512:
6
- metadata.gz: fed6f9a4a26f68dcd5304bc5ab6ba8a99ead3e5ca872a4c466c8ceb9faa23345e16a8405c14397bccb5fb0290970503b6b5ccb62ffe4d041d583593a64bd619a
7
- data.tar.gz: 10451e14fce82dd983d43b60875fac444dbb3eb08092c85f2085c08de79ebeeca7be80c3278de8145b88c841dae3f2cd909035899460dffd243cc85711523975
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.1
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
 
@@ -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 info fall back to env vars for Docker/worktree environments
761
- # where the git repo may not be directly accessible
762
- git_sha: run_git('rev-parse', 'HEAD').presence || ENV['GIT_SHA'].presence,
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
- return unless manifest['git_sha']
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
@@ -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; the
61
- # Bootstrapper uses this to decide whether to raise {Woods::MCP::MissingArtifact}
62
- # or proceed with loading.
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
- # either raise MissingArtifact or, if WOODS_ALLOW_AUTODETECT=1,
89
- # fall back to env-var auto-detect (deprecated path).
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 with autodetect off).
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 (deprecated, opt-in via
27
- # +WOODS_ALLOW_AUTODETECT=1+). Mutates +config+ to set provider and stores.
28
- # 4. If none of the above applies, raises {MissingArtifact}.
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
- # - If +WOODS_ALLOW_AUTODETECT+ is +1+, falls through to env-var
48
- # auto-detect (deprecated, emits a structured warning).
49
- # - Otherwise raises {MissingArtifact}.
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 +WOODS_ALLOW_AUTODETECT+ is unset.
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
- # Raises {MissingArtifact} unless +WOODS_ALLOW_AUTODETECT=1+ opts in to
205
- # the deprecated env-var auto-detect path.
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['WOODS_ALLOW_AUTODETECT'] != '1'
228
+ if env['WOODS_REQUIRE_INDEX'] == '1'
215
229
  raise MissingArtifact.new(
216
- 'No woods.json found and WOODS_ALLOW_AUTODETECT is unset. ' \
217
- 'Run `bundle exec rake woods:extract` in your host app, or set ' \
218
- 'WOODS_ALLOW_AUTODETECT=1 to probe env vars (deprecated).',
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
- warn '[woods-mcp] deprecated_autodetect: falling back to env-var auto-detect (no woods.json found)'
224
- [autodetect_from_env(config, env: env, ollama_probe: ollama_probe), :autodetect]
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
- # Only reachable when +WOODS_ALLOW_AUTODETECT=1+ and no +woods.json+
232
- # is present. Mutates +config+ to set provider and stores when a
233
- # credential or reachable Ollama instance is found.
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]
@@ -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 env flag
76
- # +WOODS_ALLOW_AUTODETECT+ is not set. Hosts that have never run an embed
77
- # see a clear failure message rather than silent degradation.
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
- return nil unless git_sha
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
- return nil unless git_sha
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?(doc[:uri], hash)
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: doc[:uri]
260
+ uri: uri
251
261
  )
252
- document_id = (response['id'] if response.is_a?(Hash)) || @manifest.document_id_for(doc[:uri])
253
- @manifest.record(uri: doc[:uri], hash: hash, document_id: document_id)
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
- @current_uris << @builder.uri_for(unit_data)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Woods
4
- VERSION = '1.4.0'
4
+ VERSION = '1.5.0'
5
5
  end
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.0
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-10 00:00:00.000000000 Z
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.1'
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.1'
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