woods 2.0.0.beta1 → 2.0.0.beta2

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: 34d92f37cbc6ef7c357b1566fe22c316716f0723bf7fb87a89194a8851fbe347
4
- data.tar.gz: c9684e3f87be6cfe4ac1c199c96099478d8a22801a52fd6ac112b2ef4b70dab2
3
+ metadata.gz: afd0e7789aeee449784af8f4a3b49eb2dfae7bb522381a78dba2e1932c41057c
4
+ data.tar.gz: a1c7c26400a59fae7cce074e086c0928e2267399dba6cedef7c6f5e49697ed46
5
5
  SHA512:
6
- metadata.gz: f003ea2e4d4586b4da3e2970b27197993893d17c9e064af3a0fed513e54780799ee663fbbe2b2e1dcf5248ba9df7570ee94a43eaf3eacdab556ca46ebcf5d18a
7
- data.tar.gz: 1da574149c4fe985d700d30e3d6dfe9f80a6ce8f9c7606080158b8efcf1a453d8df8846695dd373cccec3e4a4a51ac3f61fe749e1b60f9c94c51b00d539feb4d
6
+ metadata.gz: d3f87143d3a9283f9ad53d6d8fb08c61929e162513c1b5ca55452c24b52f2e804d1ad9e664d754b664b6e76b312f0fd3b1afbe957b7f00d8701f8238ac4976fa
7
+ data.tar.gz: bece3ff52a93f3298ee3792769c61ae75b4d8913a5ea5ce92c1564a5d3c612f8073b15357fe0e9b25d81e983dcdaea0c7030dda5375df958a3d57805b0fd5457
data/CHANGELOG.md CHANGED
@@ -7,8 +7,139 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.0.0.beta2] - 2026-09-10
11
+
12
+ ### Added
13
+
14
+ - **`release:prepare` runs a live preflight before printing the tag and dispatch
15
+ commands.** The new `release:preflight` task (also runnable standalone) checks, via
16
+ `gh api`, that the live `release` environment still requires review and disallows admin
17
+ bypass; that `REQUIRED_CI_JOBS` in `script/validate-release-run` still matches
18
+ `ci.yml`'s job names; and that both `release.yml` download-artifact steps set
19
+ `merge-multiple: true`. Every check is advisory: a missing or failing `gh` skips with a
20
+ note rather than blocking prepare. CONTRIBUTING.md and the release-flow skill also gained
21
+ a "when a dispatch fails" guide (which failures a main merge alone fixes versus which need
22
+ the tag moved, and only before publication) and the documented final step for creating the
23
+ GitHub Release entry by hand, which the workflow deliberately never automates.
24
+ - **A release_v2 spec greps `spec/` for hard-coded current-version literals** (`'= 2.0.0'`,
25
+ `"2.0.0\n"`, `gem_version: '2.0.0'`-shaped strings), built from `Woods::VERSION`'s base, so
26
+ the next version bump cannot leave one behind the way the beta1 cut did.
27
+ - **`WOODS_PROFILE=1` logs a timing line per extraction phase.** The per-extractor
28
+ lines already reported extraction itself; everything after it (payload seed, previous
29
+ graph load, eager load, blast radius, re-extraction, type index, graph analysis, flows,
30
+ manifest and summary, publish) was unattributed, so a slow run could only be split by
31
+ guessing. One `[Woods] [profile] <phase> in N.NNs` line per phase, on the monotonic
32
+ clock. Off by default and free when off.
33
+ - **`durable_payload_writes` restores the per-file `fsync` on payload files.** Boolean,
34
+ default `false`. Off is not the weaker setting: readers resolve only through
35
+ `generation.json`, and every publish now flushes the whole payload before writing that
36
+ pointer, so the contract holds either way. Turning the key on buys exactly one thing, an
37
+ individual payload file being durable before the pointer exists, and pays two forced
38
+ flushes per file (about 8.9ms each on btrfs) for it. It cannot disable the publish
39
+ flush, which has no opt-out.
40
+
41
+ - **`incremental_blast_radius_depth` bounds how far an incremental run re-extracts.**
42
+ `extract_changed` walked the unbounded transitive dependent closure of every changed
43
+ file, so one edit to a widely referenced unit re-extracted most of the app. The new key
44
+ caps the walk at N reverse hops (`nil`, the default, keeps the unbounded closure). A
45
+ unit outside the cap keeps its content and still gets its `dependents` list refreshed by
46
+ the run's second pass, which the equivalence harness now covers under a cap of 1. The
47
+ default stays unbounded on purpose: an STI grandchild inherits its grandparent's
48
+ associations, validations and callback chain while sitting two hops away in the graph,
49
+ and nested `has_many :through` resolves the same way, so a host opts in for a tree it
50
+ knows has neither. On a 200-service chain in the dummy app, one leaf edit re-extracts
51
+ 200 units unbounded and 2 under a depth of 1.
52
+
53
+ ### Changed
54
+
55
+ - **Payload durability moved from every file to the generation pointer.** `AtomicFile.write`
56
+ fsynced the temp file and the containing directory for every file it wrote, so a full
57
+ extraction of a large application paid two forced flushes 8323 times: 71.3s of the write
58
+ phase for 8000 files on btrfs, against 1.0s for one filesystem flush. Payload writes (unit
59
+ files, type indexes, the dependency graph, the graph analysis, the manifest, the summary,
60
+ the flow documents) now skip both, and `publish_generation` calls the new
61
+ `AtomicFile.sync_directory_tree` on the payload directory immediately before writing
62
+ `generation.json`.
63
+
64
+ The guarantee that replaces the old one: **when `generation.json` is durable, every file
65
+ in the payload it names is durable.** What is given up is an individual payload file being
66
+ durable before the pointer exists, and nothing reads a payload file in that window, since
67
+ every reader resolves through the pointer and a crash there leaves an unreferenced partial
68
+ payload the next run prunes. `generation.json` itself, the watch daemon's status file, the
69
+ update check cache, the Obsidian and Unblocked exports, Notion sync state, temporal
70
+ snapshots, embedding checkpoints and MCP task records all keep the per-file fsync: their
71
+ readers do not go through the pointer. The gem mapper's self-map publishes through the
72
+ same pointer but keeps its per-file fsync for now; it is a small payload and adopts the
73
+ single flush in a follow-up.
74
+
75
+ `sync_directory_tree` tries `syncfs(2)` through Fiddle, then `sync -f <dir>`, then a bare
76
+ `sync`, then an `fsync` on every file in the tree, and returns which one ran. The last
77
+ resort is what keeps this honest: the chain never silently does nothing, it only gets
78
+ slower. Fiddle is required inside a rescue and stays out of the gemspec, since it is a
79
+ bundled gem from Ruby 3.5. `bench/atomic_write_bench.rb` measures all four modes.
80
+
81
+ - **Seeding a payload creates each directory once rather than once per file.**
82
+ `PayloadStore#clone` ran `FileUtils.mkdir_p` before every file it replicated.
83
+ `Pathname#find` visits a directory before its children, so the directory branch had
84
+ already created every parent a file could need. 8001 files across four type directories
85
+ on btrfs: 0.906s before, 0.847s after.
86
+
87
+ - **Cycle detection is capped, and says so.** `GraphAnalyzer#analyze` runs on every
88
+ extraction regardless of the change set, and enumerating every cycle was the largest
89
+ part of it: one cycle per DFS back-edge, uncapped in count and in length, each
90
+ canonicalized by joining a path that on a deep DFS is thousands of nodes long. Two new
91
+ config keys bound it, `graph_cycle_limit` (default 500) and `graph_cycle_max_length`
92
+ (default 50, in distinct nodes); either firing sets the new
93
+ `stats.cycle_limit_reached` in `graph_analysis.json`. Set either to `nil` to remove
94
+ its cap and restore exhaustive enumeration. Signatures are now keyed by a digest of
95
+ the rotated cycle rather than the joined path. On a synthetic 8201-node graph, cycle
96
+ detection went from 8.6s to 0.4s.
97
+ - **Bridge detection reuses its work.** `bfs_shortest_path` carries parent pointers
98
+ instead of enqueueing a copy of the path so far for every node it reaches, and forward
99
+ adjacency resolves through a per-analyzer memo instead of being re-derived on each of
100
+ the 200 sampled traversals. Output is unchanged. On the same graph, bridges went from
101
+ 2.2s to 0.6s and the whole report from 3.1s to 1.0s.
102
+ - **Flow assembly caches loaded units and parsed sources.** One `FlowAssembler` serves a
103
+ whole precompute run, but nothing was cached across it: a unit's JSON was re-globbed
104
+ and re-parsed on every expansion, and its source re-parsed once per action of every
105
+ controller that reached it. Three per-instance LRU memos (unit data, whole-source AST,
106
+ and the method index derived from it) bound at 1000 entries each. On 435 controllers x
107
+ 7 actions over 3000 services, precompute went from 20.5s to 3.8s.
108
+ - **The manifest and `SUMMARY.md` read each type index once.** Both derive their totals
109
+ from the per-type `_index.json` files and run back to back at the end of every
110
+ incremental run, so every index was globbed, read and parsed twice. An unreadable index
111
+ still drops that type from both, now with one warning instead of two.
112
+ - **The incremental flow refresh is scoped to the flow assembly radius.** Flows were
113
+ reassembled for every controller in the run's touched set, and the touched set is the
114
+ graph's whole reverse closure, so one leaf edit re-ran flow assembly for nearly every
115
+ controller in the app. A flow document reaches `FlowPrecomputer::DEFAULT_MAX_DEPTH`
116
+ units, so the run now walks the pre-change graph to that same depth and reassembles
117
+ only the controllers inside it. Controllers outside it carry their
118
+ `metadata[:flow_paths]` annotation forward out of the previous flow index rather than
119
+ losing it. A targeted `refresh`, a routes re-run, and a controller whose action set no
120
+ longer matches the index all still reassemble in full. On 50 controllers over a
121
+ 50-service chain in the dummy app, one edit at the far end went from 50 controllers
122
+ reassembled to 3 plus 47 carried.
123
+ - **`graph_sha` is digested from the bytes written.** `graph_analysis.json`'s digest of
124
+ `dependency_graph.json` came from reading the file back off disk, one whole-file read
125
+ per run of an artifact that on a large app is tens of megabytes, to digest bytes the
126
+ run had just serialized. The value is unchanged.
127
+
10
128
  ### Fixed
11
129
 
130
+ - `TraceEnricher.record` rejects calls without a block before creating a
131
+ TracePoint, preventing an enabled hook from leaking into subsequent execution (#308).
132
+
133
+ - **The Changed list only names the surface inventory when regenerating it actually moved
134
+ it.** `release:prepare` used to list `.Codex/release-v2/surface-inventory.json`
135
+ unconditionally, even on the ordinary run where nothing in the public surface changed.
136
+ - **The release banner no longer links an upgrade guide that does not exist yet.** A major
137
+ version bump past `docs/UPGRADING_TO_2.md`'s own major derived a
138
+ `docs/UPGRADING_TO_<major>.md` link without checking the file exists.
139
+ - **Changelog entries merged from a duplicate heading no longer carry a stray blank line.**
140
+ Two occurrences of the same `###` heading in one `## [Unreleased]` cycle folded into a
141
+ release section with a blank line between their bullets, splitting one list into two; they
142
+ now join tight.
12
143
  - **Release validation names the live-backends CI job as it is called.** The release
13
144
  validator required a CI job named `Live backends (pgvector + Qdrant + Solid Cache)`,
14
145
  but the job gained `+ Redis` in its name, so every release dispatch failed at
@@ -23,6 +154,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
23
154
  snapshot fixture, so the first prerelease failed them (Bundler never resolves a prerelease
24
155
  from an unpinned requirement). They now use `Woods::VERSION`. The candidate host also
25
156
  installs `webrick` so `woods-mcp-http` finds a Rack handler on Rubies that no longer ship one.
157
+ - **Inspector contract specs track the pinned `@modelcontextprotocol/inspector` version instead
158
+ of a literal.** Bumping the dev dependency to 2.6.0 fixed the SDK bug where Inspector sent a
159
+ legacy `logging/setLevel` call after negotiating the modern 2026-07-28 protocol, so the two
160
+ `pending` stdio/HTTP examples in `mcp_inspector_contract_spec.rb` asserted a stderr message
161
+ that no longer occurs. Those examples now assert a clean modern handshake, and every version
162
+ literal in that file reads from `package.json` instead. `sdk_dependency_spec.rb`'s deliberate
163
+ version-and-integrity trip wire is bumped to match the new pin.
26
164
 
27
165
  ## [2.0.0.beta1] - 2026-09-09
28
166
 
data/CONTRIBUTING.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Contributing to Woods
2
2
 
3
3
  <!-- release-state:contributing-intro -->
4
- Woods welcomes bug fixes, extractor coverage, storage and retrieval improvements, MCP compatibility work, documentation, and focused performance changes. This guide covers the shared contribution contract. Coding agents working from a source checkout should also read the repository's [AGENTS.md](https://github.com/lost-in-the/woods/blob/v2.0.0.beta1/AGENTS.md).
4
+ Woods welcomes bug fixes, extractor coverage, storage and retrieval improvements, MCP compatibility work, documentation, and focused performance changes. This guide covers the shared contribution contract. Coding agents working from a source checkout should also read the repository's [AGENTS.md](https://github.com/lost-in-the/woods/blob/v2.0.0.beta2/AGENTS.md).
5
5
  <!-- release-state:end -->
6
6
 
7
7
  ## Choose the right channel
@@ -25,6 +25,8 @@ bin/rake spec
25
25
  bin/rubocop
26
26
  ```
27
27
 
28
+ `Gemfile.lock` is gitignored, so a fresh worktree (as opposed to a clone) needs it copied in from an existing checkout before running any `bin/*` command.
29
+
28
30
  Create a branch from current `main`. Keep each pull request to one logical change and preserve unrelated formatting and refactors for separate work.
29
31
 
30
32
  `main` is the development branch: it holds work for the next release and can run ahead of the latest published gem. Releases are cut from version tags by the guarded workflow in the [release section below](#release-flow); documentation matching a published gem lives on that release's tag.
@@ -44,7 +46,7 @@ Create a branch from current `main`. Keep each pull request to one logical chang
44
46
  | `plugin/skills/` | Distributed Woods skills (setup/upgrade, MCP configuration, investigation, agent enablement, diagnosis) |
45
47
 
46
48
  <!-- release-state:contributing-architecture -->
47
- Read [CLAUDE.md](https://github.com/lost-in-the/woods/blob/v2.0.0.beta1/CLAUDE.md) for architecture and implementation gotchas before changing runtime behavior.
49
+ Read [CLAUDE.md](https://github.com/lost-in-the/woods/blob/v2.0.0.beta2/CLAUDE.md) for architecture and implementation gotchas before changing runtime behavior.
48
50
  <!-- release-state:end -->
49
51
 
50
52
  ### Agent orientation and static self-map
@@ -233,6 +235,55 @@ Nothing is published from a laptop: the workflow builds and pushes the gem from
233
235
 
234
236
  After a final release publishes, reopen development with `release:reopen` in a follow-up pull request.
235
237
 
238
+ ### When a dispatch fails
239
+
240
+ `release-context` and `publish` both check out `github.sha`, the default
241
+ branch's tip at dispatch time, not the tag: `release-context` re-validates the
242
+ named CI run, checks the live `release` environment, and runs
243
+ `script/validate-release`; `publish` runs `script/verify-release-tag` and
244
+ pushes the downloaded artifact. Only `package-test` checks out
245
+ `needs.release-context.outputs.release-sha`, the tag's own commit, because
246
+ that is what CI actually built and tested. The gem bytes `publish` pushes were
247
+ built by CI at `release-sha`; `publish` never rebuilds them.
248
+
249
+ That split decides the fix for a failed dispatch:
250
+
251
+ | What failed | Lives in | Fix |
252
+ |---|---|---|
253
+ | `script/validate-release-run`, `script/validate-release`, `script/verify-release-tag`, or the workflow files themselves | main, read at `github.sha` | merge the fix to main, then re-dispatch at the same tag; the tag never moves |
254
+ | Live `release` environment settings (protection rule, admin bypass) | GitHub environment configuration, not the tree | fix the setting directly; no commit or re-dispatch needed |
255
+ | Anything under `spec/` or `lib/` that `package-test` actually runs against the candidate | the tagged commit, read at `release-sha` | a main-only fix does not reach the candidate; merge it, then move the tag to the new main tip and get a fresh CI run on it |
256
+
257
+ Both failure classes happened in the beta1 dispatch: a `REQUIRED_CI_JOBS`
258
+ prefix left behind by a `ci.yml` job rename was a validator fix that needed
259
+ only a merge and a re-dispatch; two `packaged_gem_spec.rb` smoke failures
260
+ traced to hard-coded `2.0.0` literals needed the tag moved to the commit that
261
+ fixed them, because the candidate job runs the spec file at the tag.
262
+
263
+ **Moving a tag is acceptable only before publication.** Once `publish` has
264
+ pushed the gem to RubyGems, the tag is the permanent, immutable record of what
265
+ was published; move it before that point only, with
266
+ `git tag -f v<version> <new-sha> && git push --force origin v<version>` run by
267
+ the maintainer, followed by a fresh CI run on the new tag SHA before
268
+ re-dispatching.
269
+
270
+ ### Publishing the GitHub Release entry
271
+
272
+ The workflow deliberately creates no GitHub Release: the API cannot bind an
273
+ existing tag to an expected commit atomically, so automating it would race the
274
+ tag's own verification. Once `gem info woods --remote` (or `--remote
275
+ --prerelease`) confirms publication, create the entry by hand:
276
+
277
+ ```bash
278
+ gh release create v<version> --verify-tag --notes-file <file> # release
279
+ gh release create v<version> --verify-tag --prerelease --notes-file <file> # beta or rc
280
+ ```
281
+
282
+ `--verify-tag` refuses if the tag is missing or moved. Write `<file>` as a
283
+ short body that links `CHANGELOG.md` at the tag itself (not at `main`) and
284
+ anchors straight to that version's dated heading, so the note a reader lands
285
+ on always matches the bytes RubyGems published.
286
+
236
287
  ### Stable branches
237
288
 
238
289
  A stable branch is `N-M-stable`, cut from the release tag. Create one only when a released line needs a patch after a newer major has shipped on `main`; until then, `main` is the only branch. There is no stable branch today.
data/README.md CHANGED
@@ -15,15 +15,15 @@
15
15
  >
16
16
  > `main` is the development branch and can run ahead of the latest published gem. The gem badge above shows the latest published version; documentation for a published version lives on its tag.
17
17
  >
18
- > ### Version: 2.0.0.beta1 is published as a prerelease; `main` documents 2.0.0
18
+ > ### Version: 2.0.0.beta2 is published as a prerelease; `main` documents 2.0.0
19
19
  >
20
20
  > | Line | Version | Documentation |
21
21
  > |---|---|---|
22
22
  > | Documented here | **2.0.0**, unreleased | this README and the [documentation index](docs/README.md) |
23
- > | Latest prerelease | **2.0.0.beta1** | [the v2.0.0.beta1 tag](https://github.com/lost-in-the/woods/tree/v2.0.0.beta1) |
23
+ > | Latest prerelease | **2.0.0.beta2** | [the v2.0.0.beta2 tag](https://github.com/lost-in-the/woods/tree/v2.0.0.beta2) |
24
24
  > | Latest published gem | **1.6.1** | [the v1.6.1 tag](https://github.com/lost-in-the/woods/tree/v1.6.1) |
25
25
  >
26
- > RubyGems treats 2.0.0.beta1 as a prerelease, so `gem "woods", "~> 2.0"` does not resolve it. Install it explicitly with `gem "woods", "2.0.0.beta1"`. The released constraint stays `gem "woods", "~> 1.6"`.
26
+ > RubyGems treats 2.0.0.beta2 as a prerelease, so `gem "woods", "~> 2.0"` does not resolve it. Install it explicitly with `gem "woods", "2.0.0.beta2"`. The released constraint stays `gem "woods", "~> 1.6"`.
27
27
  <!-- release-state:end -->
28
28
 
29
29
  Woods boots your Rails app, extracts the behavior Rails assembles at runtime, and serves it to AI tools through the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/). Agents can inspect resolved routes, schema, associations, callbacks, included concerns, dependencies, and execution flows instead of guessing from source files alone.
@@ -358,6 +358,40 @@ end
358
358
  | `extract_navigation_edges` | Boolean | `true` | Extract `link_to`, `redirect_to`, and `form_action` navigation edges from views and controllers |
359
359
  | `enable_snapshots` | Boolean | `false` | Enable temporal snapshots. Woods automatically migrates its internal output-directory SQLite store; if SQLite is unavailable, it uses the JSON snapshot store. No Rails migration is required. |
360
360
  | `volatile_dependency_ratio` | Float | `3.0` | A dependency whose commit count (last 365 days) exceeds the dependent's by this ratio appears in the `volatile_dependencies` report (top 20, ranked by PageRank). Must be greater than 1. Report only, never a gate. |
361
+ | `graph_cycle_limit` | Integer or `nil` | `500` | How many distinct cycles `GraphAnalyzer` enumerates before it stops. Cycle detection finds one cycle per DFS back-edge, so a dense graph has tens of thousands of them and enumerating every one is the largest single cost of the analysis that runs on every extraction. Set to `nil` for exhaustive enumeration. |
362
+ | `graph_cycle_max_length` | Integer or `nil` | `50` | The longest cycle recorded, in distinct nodes. A back-edge deep in the DFS closes a cycle as long as the path, which on a large graph is thousands of nodes: unreadable as a report and expensive to canonicalize. Set to `nil` to record a cycle of any length. |
363
+
364
+ | `incremental_blast_radius_depth` | Integer or `nil` | `nil` | How many reverse hops an incremental run walks from a changed file before it stops re-extracting dependents. `nil` keeps the unbounded transitive closure. See the note below before setting it. |
365
+ | `durable_payload_writes` | Boolean | `false` | Force an `fsync` on every payload file as it is written, on top of the single flush every publish already performs. See the note below before setting it. |
366
+
367
+ `incremental_blast_radius_depth` is unbounded by default because a unit two hops
368
+ out really can have content that depends on the changed file. An STI grandchild
369
+ (`SportsCar < Car < Vehicle`) inherits its grandparent's associations,
370
+ validations and callback chain, and a nested `has_many :through` resolves
371
+ through the same kind of chain, while the graph only records the one-hop
372
+ superclass reference each source file mentions. Set the key on a tree you know
373
+ has neither shape; the units it stops re-extracting still get their `dependents`
374
+ list refreshed, and `spec/integration/incremental_equivalence_spec.rb` holds a
375
+ depth of 1 to full-extraction equivalence.
376
+
377
+ `durable_payload_writes` is off by default, and off is not the weaker
378
+ guarantee. Readers resolve only through `generation.json`, so a payload file
379
+ has no reader until that pointer names it. Every publish flushes the whole
380
+ payload directory once (`syncfs`, else `sync -f`, else `sync`, else a per-file
381
+ `fsync` pass) *before* writing the pointer, which makes the contract: when
382
+ `generation.json` is durable, every file in the payload it names is durable.
383
+ A crash before that leaves an unreferenced partial payload the next run prunes.
384
+
385
+ Turning the key on buys exactly one thing: an individual payload file being
386
+ durable before the pointer exists. It costs two forced flushes per file, about
387
+ 8.9ms each on btrfs, so 8000 units is roughly 71s of writing against 1s. It
388
+ cannot disable the publish flush, which has no opt-out.
389
+
390
+ When either cap fires, `graph_analysis.json` reports `stats.cycle_limit_reached: true`
391
+ alongside `stats.cycle_count`, so a reader of the `cycles` array can tell a truncated
392
+ view from a short one. Enumeration stops at the cap rather than counting past it, so
393
+ there is no full population count to publish. Both caps must be a positive Integer or
394
+ `nil`; anything else raises `Woods::ConfigurationError`.
361
395
 
362
396
  ## Session tracer options
363
397
 
@@ -573,6 +607,7 @@ These variables are read by the gem and its MCP servers at runtime. They complem
573
607
  | `CI_COMMIT_BEFORE_SHA`, `CI_COMMIT_SHA` | unset (GitLab) | Build the diff range `<before>..<after>` for `woods:incremental`. A zero before-SHA (new branch) makes the range unresolvable, which exits 1 unless a running daemon covers the index. |
574
608
  | `GITHUB_BASE_REF` | unset (GitHub Actions) | Build the diff range `origin/<ref>...HEAD` for `woods:incremental`; an unfetched ref makes the range unresolvable, same exit behavior. |
575
609
  | `RAILS_ENV` | `development` | Rails environment the rake tasks boot in. |
610
+ | `WOODS_PROFILE` | unset | Set to `"1"` to log one `[Woods] [profile] <phase> in N.NNs` line per run phase (payload seed, previous graph load, eager load, extraction or blast radius and re-extraction, type index, graph analysis, flows, manifest and summary, publish). Complements the per-extractor timing lines, which cover extraction only. Off by default and free when off. |
576
611
  | `WOODS_GIT_DIR` | unset | Absolute path to the canonical git directory. Wins over the repository Woods would otherwise find, at all three of its git call sites: per-unit `commit_count`/`change_frequency` (enrichment), `manifest.json`'s `git_branch`/`git_sha` (provenance), and the `woods:incremental` diff range. All three build their command line with `Woods::GitCommand.argv`. |
577
612
  | `GIT_BRANCH`, `GIT_SHA` | unset | Provenance for a checkout with no `.git` at all (a source tarball, a Docker `COPY` that excludes it). Ignored when a `.git` is present but unresolvable, so a stale build arg cannot mask a worktree. |
578
613
 
@@ -82,7 +82,8 @@ same rooting rule the manifest's git provenance follows.
82
82
  step before it.
83
83
 
84
84
  1. **Blast radius** from the *pre-change* graph, so dependents of a file that
85
- just disappeared still get re-extracted.
85
+ just disappeared still get re-extracted. Unbounded by default; see
86
+ [Bounding the blast radius](#bounding-the-blast-radius).
86
87
  2. **Reconcile changed paths.** Every changed path that still exists is handed
87
88
  to the file-based extractors that claim it (`PathDispatcher`), and units the
88
89
  path no longer produces are dropped. This is what indexes a file the index
@@ -130,6 +131,33 @@ A run that changed nothing **does not rewrite the manifest**. The manifest
130
131
  timestamp drives `woods_status.staleness_seconds`, and touching it after a no-op
131
132
  would report the index as freshly synced when nothing was re-read.
132
133
 
134
+ ## Bounding the blast radius
135
+
136
+ `incremental_blast_radius_depth` caps how many reverse hops step 1 walks.
137
+ `nil`, the default, keeps the unbounded transitive closure: every unit that
138
+ reaches the changed file, at any depth, is re-extracted.
139
+
140
+ The reason to cap it is cost. A unit's extracted content is mostly a function
141
+ of its own source and its own reflection, so on most graphs the deep half of
142
+ the closure re-derives bytes that do not change. On a 200-service chain in the
143
+ dummy app, one leaf edit re-extracts 200 units unbounded and 2 at a depth
144
+ of 1.
145
+
146
+ The reason the default is not capped is that "mostly" is not "always". An STI
147
+ grandchild reads its grandparent's reflection: `SportsCar < Car < Vehicle`
148
+ inherits `Vehicle`'s associations, validations and callback chain, and a
149
+ nested `has_many :through` resolves through the same kind of chain. The graph
150
+ records only the one-hop superclass reference each source file mentions, so
151
+ that grandchild sits two hops out while its content depends on hop zero. Set
152
+ the key on a tree you know has neither shape.
153
+
154
+ What the cap never affects is `dependents`. Edges change only when a unit is
155
+ re-extracted, and the run marks every target of a re-extracted unit's edges,
156
+ before and after registration, so a unit that gains or loses an inbound edge
157
+ is rewritten by the second pass whether or not the walk reached it.
158
+ `spec/integration/incremental_equivalence_spec.rb` holds a depth of 1 to
159
+ full-extraction equivalence, including that case.
160
+
133
161
  ## Dispatch inventory
134
162
 
135
163
  ### Per-file
@@ -274,6 +302,18 @@ them for its **delta**:
274
302
  generation, which payload seeding hardlinks into the run's payload
275
303
  directory.
276
304
 
305
+ Re-assembly is scoped further, because a flow document only reaches
306
+ `FlowPrecomputer::DEFAULT_MAX_DEPTH` units. The run walks the pre-change graph
307
+ to that same depth from its changed files; a re-extracted controller inside
308
+ that radius is re-assembled, and one outside it takes its
309
+ `metadata[:flow_paths]` back from the previous index without paying for the
310
+ assembly. Three cases opt out and re-assemble every re-extracted controller: a
311
+ targeted `Extractor#refresh`, which has no change set; a routes re-run, which
312
+ replaces every controller and moves the route a flow document carries without
313
+ touching any dependency edge; and a controller whose action set no longer
314
+ matches the previous index, which is how an action inherited from further up a
315
+ controller chain than the radius reaches still lands.
316
+
277
317
  After the index is rewritten, a **dedicated flow-artifact sweep** removes
278
318
  every `flows/` document no index entry references. It validates against
279
319
  `flow_index.json` and is deliberately separate from the unit sweep: flows/
data/docs/INTERNALS.md CHANGED
@@ -172,7 +172,7 @@ Scores feed into the retrieval ranker as one signal in the final ranking formula
172
172
  | **Orphans** | Units with no dependents, potential dead code or public entry points. Framework sources are excluded (they're naturally unreferenced in the reverse index). |
173
173
  | **Dead ends** | Units with no dependencies, self-contained leaf nodes (value objects, standalone utilities) |
174
174
  | **Hubs** | Units with many dependents, architectural bottlenecks; changes here have high blast radius |
175
- | **Cycles** | Circular dependencies, A→B→C→A. Detected via DFS. |
175
+ | **Cycles** | Circular dependencies, A→B→C→A. Detected via DFS, and capped: `graph_cycle_limit` (default 500) bounds how many are enumerated and `graph_cycle_max_length` (default 50) skips one longer than that. Either cap firing sets `stats.cycle_limit_reached`. Set both to `nil` for exhaustive enumeration. |
176
176
  | **Bridges** | Edges whose removal would disconnect the graph, high-risk structural connections |
177
177
  | **Cross-database edges** | Association or foreign-key edges whose two ends resolve to different databases. A `has_many :through` is reported as `join_through_across_databases` when `disable_joins` is false and `from_db`, `through_db` (the join model's database), or `to_db` disagree. A foreign key never resolves to an owner in the source database, even when another database also claims the table; when every owner sits elsewhere and they span more than one database, the entry comes back with `to: nil` and an `ambiguous_owners` list instead of guessing. Read from graph node and edge attributes, so full and incremental runs agree. Scoped to primary nodes (units registered in the graph), not variants. |
178
178
  | **Volatile dependencies** | Edges that point at a unit changing at least `volatile_dependency_ratio` times more often than the dependent (POODR: depend on things that change less often than you do). Dependencies with fewer than 5 commits or a `new` change frequency are skipped. Ranked by the dependency's PageRank; the persisted list keeps the top 20, while `stats.volatile_dependency_count` reports the full qualifying count and `stats.volatile_dependencies_limit` reports the cap. |
@@ -191,15 +191,18 @@ persisted rather than every hub in the graph.
191
191
  | `orphans` | `stats.orphan_count` | no | every orphan |
192
192
  | `dead_ends` | `stats.dead_end_count` | no | every dead end |
193
193
  | `hubs` | `stats.hub_count` | yes, top 20 by dependent count | the persisted array, so it stops at 20 |
194
- | `cycles` | `stats.cycle_count` | no | every cycle found |
194
+ | `cycles` | `stats.cycle_count`, `stats.cycle_limit_reached` | yes, `graph_cycle_limit` cycles of at most `graph_cycle_max_length` nodes | the count is the persisted array; the flag says whether either cap fired |
195
195
  | `bridges` | none | yes, top 10 by score | not counted |
196
196
  | `cross_database_edges` | `stats.cross_database_edge_count` | no | every crossing edge |
197
197
  | `volatile_dependencies` | `stats.volatile_dependency_count`, `stats.volatile_dependencies_limit` | yes, top 20 by the dependency's PageRank | the count is every qualifying edge; the limit is the cap |
198
198
  | `undeclared_package_edges` | `stats.undeclared_package_edge_count` | no | every undeclared crossing |
199
199
 
200
- A capped section means the array on disk is a page, not the population. Only
201
- `volatile_dependencies` publishes both numbers, so it is the only one where a
202
- reader can tell truncation from a short list without re-running the analysis.
200
+ A capped section means the array on disk is a page, not the population.
201
+ `volatile_dependencies` publishes both numbers and `cycles` publishes a
202
+ truncation flag, so those two are the ones where a reader can tell truncation
203
+ from a short list without re-running the analysis. Cycle enumeration stops at
204
+ the cap rather than counting past it, which is why `cycles` gets a flag and not
205
+ a population count.
203
206
  The `graph_analysis` MCP tool pages each section independently when given
204
207
  `limit` or `offset`, and its `Showing N of M (truncated)` line counts the array
205
208
  it read from disk, not the population the stat keys count.
@@ -65,6 +65,22 @@ A directory numbered above the pointer (a payload built but never bumped to) and
65
65
 
66
66
  A *missing* `generation.json` is not an error: it means a flat (pre-2.0) index, generation 0. A `generation.json` that **exists but will not parse** is different, a corrupt install, not an empty index, so `.available_generations` and `.new`/`.open` raise `Woods::PublishedIndex::CorruptPointerError` naming the file's path instead of silently reporting zero published generations.
67
67
 
68
+ ### Durability: the pointer is the commit point
69
+
70
+ The pointer is not only what makes a generation *visible*, it is what makes it *durable*.
71
+
72
+ Payload files are written through `Woods::AtomicFile.write` with `durable: false`: a temp file, a chmod to the final mode, and a rename, but no `fsync`. Immediately before `generation.json` is written, the writer flushes the whole payload directory once with `Woods::AtomicFile.sync_directory_tree`, and then writes the pointer durably.
73
+
74
+ So the contract a reader can rely on is:
75
+
76
+ > When `generation.json` is durable, every file in the payload it names is durable.
77
+
78
+ What that gives up is an individual payload file being durable *before* the pointer exists. Nothing reads a payload file in that window: every reader resolves through the pointer, and a crash there leaves an unreferenced partial payload that the next run prunes. The saving is large, since two forced flushes per file is about 8.9ms each on btrfs, or 71s for 8000 units against 1s for one flush.
79
+
80
+ `sync_directory_tree` tries `syncfs(2)` through Fiddle, then `sync -f <dir>`, then a bare `sync`, then an `fsync` on every file in the tree. The last resort is what keeps this honest: the chain never silently does nothing, it only gets slower.
81
+
82
+ A file whose readers do **not** resolve through the pointer keeps its own `fsync`: `generation.json` itself, the watch daemon's `watch_status.json`, the update check cache, the Obsidian and Unblocked exports, Notion sync state, temporal snapshots, `checkpoint.json`, and MCP task records. Set `durable_payload_writes = true` to pay the per-file `fsync` on payload files as well; it cannot disable the publish flush.
83
+
68
84
  ## Keying a RuboCop cache on the index
69
85
 
70
86
  RuboCop caches offenses per file and invalidates the cache when a cop's `external_dependency_checksum` changes. `rubocop-rails` uses this to re-run schema-aware cops when `db/schema.rb` changes. The same pattern works against Woods: the pinned payload's `manifest.json` is rewritten through `AtomicFile` on every publish, so its digest is the checksum.
@@ -5,8 +5,8 @@ Woods 2.0 changes observable index identifiers, publication layout, vector-store
5
5
  This guide assumes the last v1 release, 1.6.1, and targets 2.0.0.
6
6
 
7
7
  <!-- release-state:upgrade-availability -->
8
- > RubyGems lists 2.0.0.beta1 as a prerelease. Pin it explicitly with
9
- > `gem "woods", "2.0.0.beta1"`; `~> 2.0` resolves only once
8
+ > RubyGems lists 2.0.0.beta2 as a prerelease. Pin it explicitly with
9
+ > `gem "woods", "2.0.0.beta2"`; `~> 2.0` resolves only once
10
10
  > 2.0.0 is published.
11
11
  <!-- release-state:end -->
12
12
 
@@ -25,11 +25,30 @@ module Woods
25
25
  # one: the watch daemon's +watch_status.json+, read by host-side hooks
26
26
  # through a bind mount).
27
27
  #
28
+ # +durable:+ chooses when the bytes are forced to the disk, never whether
29
+ # the write is atomic. Both paths go tempfile, chmod, rename, so a reader
30
+ # sees the old content or the new content and never a torn partial.
31
+ # +durable: false+ drops the two forced flushes (the temp file's own
32
+ # +fsync+ and the containing directory's), which is the whole cost of the
33
+ # write on a journalling filesystem: roughly 8.9ms per file against
34
+ # 0.11ms without, measured on btrfs.
35
+ #
36
+ # It is only safe for a file with no reader until something else commits
37
+ # it. Woods' payload files qualify: every reader resolves through
38
+ # +generation.json+, and that pointer is written durably after
39
+ # {.sync_directory_tree} has flushed the payload it names. A file whose
40
+ # readers do not go through the pointer (the watch daemon's status, the
41
+ # update check's cache, an export, an embedding checkpoint) must stay
42
+ # durable.
43
+ #
28
44
  # @param path [String, Pathname] destination path
29
45
  # @param content [String] file content
30
46
  # @param mode [Integer] permissions for the written file (default 0600)
47
+ # @param durable [Boolean] force the bytes to disk before returning
48
+ # (default true). Pass false only for a file that something else makes
49
+ # durable before any reader can resolve it.
31
50
  # @return [void]
32
- def write(path, content, mode: 0o600)
51
+ def write(path, content, mode: 0o600, durable: true)
33
52
  path = path.to_s
34
53
  FileUtils.mkdir_p(File.dirname(path))
35
54
  tmp = Tempfile.new('.woods-', File.dirname(path))
@@ -38,19 +57,130 @@ module Woods
38
57
  tmp.binmode
39
58
  tmp.write(content)
40
59
  tmp.flush
41
- tmp.fsync
60
+ tmp.fsync if durable
42
61
  tmp.close
43
62
  # Chmod the temp file so the destination is born with its final
44
63
  # permissions — never observed more open or more closed in between.
45
64
  File.chmod(mode, tmp.path)
46
65
  File.rename(tmp.path, path)
47
- fsync_directory(File.dirname(path))
66
+ fsync_directory(File.dirname(path)) if durable
48
67
  rescue StandardError
49
68
  tmp&.close
50
69
  tmp&.unlink
51
70
  raise
52
71
  end
53
72
 
73
+ # Make every file under +directory+ durable with one filesystem flush.
74
+ #
75
+ # The counterpart to {.write}'s +durable: false+. Thousands of per-file
76
+ # +fsync+ calls and one +syncfs+ buy the same guarantee for a payload that
77
+ # is published all at once, and cost 71.3s against 1.0s for 8000 files on
78
+ # btrfs.
79
+ #
80
+ # Strategies, in order, first one that works wins:
81
+ #
82
+ # 1. +syncfs(2)+ on a descriptor for +directory+, through Fiddle. Linux
83
+ # only, and flushes exactly the one filesystem the payload is on.
84
+ # 2. +sync -f <dir>+ (GNU coreutils), the same call through a subprocess.
85
+ # 3. +sync+ with no arguments (BSD/macOS), which flushes everything
86
+ # mounted rather than one filesystem, but is still one call.
87
+ # 4. an +fsync+ on every file and directory in the tree.
88
+ #
89
+ # The last resort is what keeps the guarantee honest: the chain never
90
+ # silently does nothing, it only ever gets slower.
91
+ #
92
+ # Fiddle is a default gem through Ruby 3.4 and a bundled gem from 3.5, so
93
+ # the require lives inside a rescue and Fiddle is deliberately not in the
94
+ # gemspec. A host without it lands on +sync -f+.
95
+ #
96
+ # @param directory [String, Pathname] the tree to flush
97
+ # @return [Symbol, nil] the strategy that ran (+:syncfs+, +:sync_f+,
98
+ # +:sync+, +:fsync_pass+), or nil when the directory does not exist
99
+ def sync_directory_tree(directory)
100
+ directory = directory.to_s
101
+ return nil unless File.directory?(directory)
102
+
103
+ strategy = syncfs(directory) || sync_f(directory) || plain_sync || fsync_pass(directory)
104
+ log_sync_strategy(strategy, directory)
105
+ strategy
106
+ end
107
+
108
+ # @return [Symbol, nil] +:syncfs+ when the libc call succeeded
109
+ def syncfs(directory)
110
+ call = syncfs_function
111
+ return nil unless call
112
+
113
+ succeeded = File.open(directory, File::RDONLY) { |dir| call.call(dir.fileno).zero? }
114
+ succeeded ? :syncfs : nil
115
+ rescue StandardError
116
+ nil
117
+ end
118
+
119
+ # Resolved once per process, and nil for the whole process when it cannot
120
+ # be. `dlopen` is not free, and a failed `require 'fiddle'` prints a
121
+ # bundled-gem warning on every attempt from Ruby 3.5 onward, and a publish
122
+ # must not emit one line of noise per generation.
123
+ #
124
+ # @return [Fiddle::Function, nil]
125
+ def syncfs_function
126
+ return @syncfs_function if defined?(@syncfs_function)
127
+
128
+ @syncfs_function = begin
129
+ require 'fiddle'
130
+ Fiddle::Function.new(Fiddle.dlopen(nil)['syncfs'], [Fiddle::TYPE_INT], Fiddle::TYPE_INT)
131
+ rescue LoadError, StandardError
132
+ # No Fiddle in the bundle, no libc symbol (macOS has none), or the
133
+ # handle would not open. `sync -f` is next in the chain.
134
+ nil
135
+ end
136
+ end
137
+
138
+ # @return [Symbol, nil] +:sync_f+ when `sync -f` exited 0
139
+ def sync_f(directory)
140
+ :sync_f if system('sync', '-f', directory, out: File::NULL, err: File::NULL)
141
+ rescue StandardError
142
+ nil
143
+ end
144
+
145
+ # @return [Symbol, nil] +:sync+ when a bare `sync` exited 0
146
+ def plain_sync
147
+ :sync if system('sync', out: File::NULL, err: File::NULL)
148
+ rescue StandardError
149
+ nil
150
+ end
151
+
152
+ # @return [Symbol] always +:fsync_pass+; this is the floor of the chain
153
+ def fsync_pass(directory)
154
+ Dir.glob(File.join(directory, '**', '*'), File::FNM_DOTMATCH).each do |entry|
155
+ next if %w[. ..].include?(File.basename(entry))
156
+
157
+ fsync_path(entry)
158
+ end
159
+ fsync_path(directory)
160
+ # The directory entry that names this tree lives in the parent, so a
161
+ # freshly created (or renamed) payload directory is only durable once
162
+ # the parent is flushed too. The three faster strategies flush a whole
163
+ # filesystem and cover this for free; only the scoped pass has to say
164
+ # it.
165
+ fsync_path(File.dirname(directory))
166
+ :fsync_pass
167
+ end
168
+
169
+ # @param path [String] file or directory to flush
170
+ # @return [void]
171
+ def fsync_path(path)
172
+ File.open(path, File::RDONLY, &:fsync)
173
+ rescue Errno::EINVAL, Errno::ENOTSUP, Errno::EISDIR, Errno::ENOENT, Errno::EACCES
174
+ nil
175
+ end
176
+
177
+ # @return [void]
178
+ def log_sync_strategy(strategy, directory)
179
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
180
+
181
+ Rails.logger.debug { "[Woods] payload sync via #{strategy} on #{directory}" }
182
+ end
183
+
54
184
  def fsync_directory(directory)
55
185
  File.open(directory, File::RDONLY, &:fsync)
56
186
  rescue Errno::EINVAL, Errno::ENOTSUP, Errno::EISDIR