bundler-codegraph 0.1.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 +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +19 -0
- data/README.md +278 -0
- data/lib/bundler/codegraph/backfill.rb +45 -0
- data/lib/bundler/codegraph/command.rb +93 -0
- data/lib/bundler/codegraph/config.rb +71 -0
- data/lib/bundler/codegraph/error_log.rb +119 -0
- data/lib/bundler/codegraph/indexer.rb +185 -0
- data/lib/bundler/codegraph/lock.rb +50 -0
- data/lib/bundler/codegraph/runtime_dir.rb +52 -0
- data/lib/bundler/codegraph/subprocess.rb +69 -0
- data/lib/bundler/codegraph/version.rb +19 -0
- data/lib/bundler/codegraph.rb +133 -0
- data/plugins.rb +16 -0
- metadata +54 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 010d01ceb078418222249056f60188101bde16ea45c8717f45759d6f80602402
|
|
4
|
+
data.tar.gz: bcc8de402d3b5826cfddf3fa8e7eadcc52d17caea29b3c60de5d0e6855f022a3
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ebf29e721f8fff41d7c6e9a7cc95f90b048c47d04c1ce8b16f6f79cfde6016aaee73b4060ccc56427a6b725d15fed5325db393cf4d355ac0f3171e7a3f67d23d
|
|
7
|
+
data.tar.gz: a65f3f6497ed2ae73ea39469ac327b24dda381d9047b93621812cff3619d332a4a60139305effdd13a9ed63494b33c31a0116cca88c6f22562e710d828be2e86
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# CHANGELOG
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-09-25)
|
|
4
|
+
|
|
5
|
+
First release!
|
|
6
|
+
|
|
7
|
+
* Index every gem of the bundle at the end of `bundle install`: `after-install` queues each gem, `after-install-all` indexes the queue, so indexing never holds up an install worker; each gem indexed prints a line
|
|
8
|
+
* Add the `bundle codegraph-index [--force]` command to catch up on an already installed bundle; `--help` prints its usage; it exits non-zero on an unknown argument, a missing `codegraph` binary, or any gem failing to index
|
|
9
|
+
* Serialize indexing through an advisory file lock, so concurrent `bundle install` runs do not run several indexers at once; the lock lives in a private per-user directory, so another user of a shared `/tmp` cannot hold it, and both the hook and `bundle codegraph-index` warn when that directory cannot be trusted
|
|
10
|
+
* Stop `codegraph` (TERM, then KILL) before cleaning up when `bundle install` is interrupted, even when the signal reaches Ruby alone
|
|
11
|
+
* Skip gems carrying no Ruby source, and have the hook skip gems already holding an index (`.codegraph/codegraph.db`)
|
|
12
|
+
* Sync existing indexes from `bundle codegraph-index`, which completes one a killed run left partial
|
|
13
|
+
* Remove the `.codegraph/` directory of a failed or interrupted run, and restore the previous index when a `--force` rebuild fails or is killed
|
|
14
|
+
* Turn the plugin into a no-op when the `codegraph` binary is missing, rather than failing the install
|
|
15
|
+
* Run `codegraph` with its stdin on `/dev/null`, so a prompt it would show can never hang `bundle install`
|
|
16
|
+
* Find Ruby sources under a path holding glob metacharacters (`[`, `{`) instead of skipping every gem as `no Ruby source`
|
|
17
|
+
* Leave `bundler` and the project's own gemspec out of both the hook and `bundle codegraph-index`
|
|
18
|
+
* Warn about each gem `codegraph` fails on, and keep its error output in a per-gem log file; the hook says it skips that gem, and does not retry it until a new version of it is installed, `bundle codegraph-index` runs, or the temporary directory is purged; an interrupted run, or a `codegraph` killed by a signal, is not recorded as a failure
|
|
19
|
+
* Add the `BUNDLER_CODEGRAPH`, `BUNDLER_CODEGRAPH_EXCLUDE` and `BUNDLER_CODEGRAPH_BIN` environment variables
|
data/LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in
|
|
11
|
+
all copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
19
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# bundler-codegraph
|
|
2
|
+
|
|
3
|
+
[](https://github.com/jbox-web/bundler-codegraph/blob/master/LICENSE)
|
|
4
|
+
[](https://github.com/jbox-web/bundler-codegraph/actions)
|
|
5
|
+
[](https://qlty.sh/gh/jbox-web/projects/bundler-codegraph)
|
|
6
|
+
[](https://qlty.sh/gh/jbox-web/projects/bundler-codegraph)
|
|
7
|
+
|
|
8
|
+
A Bundler plugin that builds a [CodeGraph][codegraph] index inside every gem it
|
|
9
|
+
installs.
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
Answering *"what does this gem actually do here?"* normally means a grep/read
|
|
14
|
+
loop through `.bundle/ruby/*/gems/<gem>/`: guess a filename, read it, follow a
|
|
15
|
+
constant into another file, read that one too. Every hop is a round-trip, the
|
|
16
|
+
whole file lands in the context window when three methods were needed, and
|
|
17
|
+
nothing in that loop can follow a dynamically dispatched call.
|
|
18
|
+
|
|
19
|
+
[CodeGraph][codegraph] turns a directory into a local SQLite knowledge graph of
|
|
20
|
+
symbols, edges and files. Querying it returns the relevant symbols' verbatim
|
|
21
|
+
source *and* the call paths between them, in a single round-trip:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
codegraph explore --path .bundle/ruby/4.0.0/gems/rack-3.2.6 "Rack::Session::Cookie"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The MCP tool takes the same value as `projectPath`, so an agent gets the same
|
|
28
|
+
answer without shelling out.
|
|
29
|
+
|
|
30
|
+
The catch: the index has to already exist when the question comes up. Running
|
|
31
|
+
`codegraph init` by hand on a few hundred gem directories, and again after every
|
|
32
|
+
`bundle update`, is not a plan. That is the whole point of this plugin — the
|
|
33
|
+
index is a side effect of `bundle install`, and nobody has to think about it.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
### 1. The `codegraph` binary
|
|
38
|
+
|
|
39
|
+
It must be on `PATH`. Through [mise][mise] and the [jbox-web aqua
|
|
40
|
+
registry][aqua-registry]:
|
|
41
|
+
|
|
42
|
+
```toml
|
|
43
|
+
# mise.toml
|
|
44
|
+
[settings]
|
|
45
|
+
aqua.registries = ["https://github.com/jbox-web/aqua-registry"]
|
|
46
|
+
|
|
47
|
+
[tools]
|
|
48
|
+
"aqua:colbymchenry/codegraph" = "1.5.0"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or straight from npm:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm install -g @colbymchenry/codegraph
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
If the binary is missing the plugin turns itself into a no-op rather than
|
|
58
|
+
failing the install, so this step can be skipped on machines that do not need
|
|
59
|
+
the indexes.
|
|
60
|
+
|
|
61
|
+
### 2. The plugin
|
|
62
|
+
|
|
63
|
+
Add it to the `Gemfile` of the project whose dependencies you want indexed:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
plugin 'bundler-codegraph'
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
That pulls the released gem from RubyGems.org. To follow unreleased changes,
|
|
70
|
+
point it at the repository instead:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
plugin 'bundler-codegraph', git: 'https://github.com/jbox-web/bundler-codegraph.git', branch: 'master'
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
A `plugin` declaration takes the same options as a `gem` one — Bundler's plugin
|
|
77
|
+
DSL delegates straight to it — so `github:`, `tag:`, `ref:` and `path:` all
|
|
78
|
+
work. Use `path:` when hacking on the plugin itself, since it picks changes up
|
|
79
|
+
without a commit:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
plugin 'bundler-codegraph', path: '/path/to/bundler-codegraph'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Bundler installs it into `.bundle/plugin/` on the next `bundle install`.
|
|
86
|
+
|
|
87
|
+
**Every update of the plugin goes through an uninstall.** Bundler reinstalls a
|
|
88
|
+
plugin whenever its path changes — a new release, a new commit fetched by
|
|
89
|
+
`bundle update` on a `git:` source, a moved `path:` checkout, a switch from one
|
|
90
|
+
source to another — but registers the new copy without removing the old one,
|
|
91
|
+
and refuses the `codegraph-index` command the old copy still holds:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
Failed to install plugin `bundler-codegraph`, due to Bundler::Plugin::Index::CommandConflict (Command(s) `codegraph-index` declared by bundler-codegraph are already registered.)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
That is Bundler's doing (checked on 4.0.17), and it hits any plugin that
|
|
98
|
+
declares a command. Uninstall first, then install again:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
bundle plugin uninstall bundler-codegraph && bundle install
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
There is no `bundle plugin update`: this is also how a `git:` clone, pinned to
|
|
105
|
+
the commit resolved at install time, moves forward.
|
|
106
|
+
|
|
107
|
+
## Usage
|
|
108
|
+
|
|
109
|
+
Nothing to run: every gem of the bundle is indexed at the end of each
|
|
110
|
+
`bundle install`.
|
|
111
|
+
|
|
112
|
+
A bundle that is already in place needs one catch-up pass:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
bundle codegraph-index # index every gem that has none, sync the others
|
|
116
|
+
bundle codegraph-index --force # rebuild all of them
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Without `--force`, an index already in place goes through `codegraph sync`,
|
|
120
|
+
which also completes an index a killed run left partial.
|
|
121
|
+
|
|
122
|
+
The command exits non-zero on an unknown argument, when the `codegraph` binary
|
|
123
|
+
cannot be found (checked before walking the bundle), and — after trying every
|
|
124
|
+
gem — when any of them failed to index, so a script or a CI job can tell.
|
|
125
|
+
`bundle codegraph-index --help` (or `bundle help codegraph-index`) prints its
|
|
126
|
+
usage.
|
|
127
|
+
|
|
128
|
+
Both walk the *resolved* bundle (`Bundler.definition.specs`, minus `bundler`
|
|
129
|
+
itself and minus the project's own gemspec — a gem project declaring `gemspec`
|
|
130
|
+
is not one of its dependencies), not the contents of `.bundle/ruby/*/gems/`.
|
|
131
|
+
Stale checkouts and older versions of a gem sitting next to the one in
|
|
132
|
+
`Gemfile.lock` are left alone, so an unindexed directory down there is expected
|
|
133
|
+
rather than a missed gem. Gems installed from a Git source live in
|
|
134
|
+
`.bundle/ruby/*/bundler/gems/`, and `path:` sources are indexed where they sit.
|
|
135
|
+
codegraph drops a `.gitignore` inside `.codegraph/` that ignores everything but
|
|
136
|
+
itself, so the database never shows up in Git; that `.gitignore` itself still
|
|
137
|
+
does, as an untracked file — add `.codegraph` to the `.gitignore` of any `path:`
|
|
138
|
+
source tracked by Git to keep its status clean.
|
|
139
|
+
|
|
140
|
+
## Using the index from an agent
|
|
141
|
+
|
|
142
|
+
Indexing every gem is only worth it if the agent actually reaches for those
|
|
143
|
+
indexes. Two things are needed.
|
|
144
|
+
|
|
145
|
+
**Wire up the MCP server.** `codegraph install` configures it for Claude Code,
|
|
146
|
+
Cursor, Codex CLI, opencode and Hermes Agent (`--target`, `--location`, or
|
|
147
|
+
`--print-config <agent>` to just see the snippet). It runs over stdio as
|
|
148
|
+
`codegraph serve --mcp` and exposes `codegraph_explore` — relevant symbols'
|
|
149
|
+
verbatim source plus the call paths between them — and `codegraph_node`, for one
|
|
150
|
+
symbol's caller/callee trail or a line-numbered file read.
|
|
151
|
+
|
|
152
|
+
Both take a `projectPath`, and the server resolves the nearest `.codegraph/` at
|
|
153
|
+
or above it. **That is what makes this plugin useful**: an index dropped inside a
|
|
154
|
+
gem directory is queryable from any project, without registering each gem as a
|
|
155
|
+
project of its own.
|
|
156
|
+
|
|
157
|
+
**Tell the agent to use them.** Nothing here changes an agent's default habit of
|
|
158
|
+
grepping through vendored sources, so the rule has to be written down. Something
|
|
159
|
+
along these lines, in the consuming project's `CLAUDE.md` / `AGENTS.md`:
|
|
160
|
+
|
|
161
|
+
```markdown
|
|
162
|
+
## Dependencies
|
|
163
|
+
|
|
164
|
+
Every gem of the bundle carries its own CodeGraph index. For a question about a
|
|
165
|
+
dependency's code, query that gem's index rather than reading its files:
|
|
166
|
+
|
|
167
|
+
- `codegraph_explore` with `projectPath` set to the gem directory, e.g.
|
|
168
|
+
`.bundle/ruby/4.0.0/gems/rack-3.2.6`
|
|
169
|
+
- or `codegraph explore -p <gem directory> "<symbol or question>"`
|
|
170
|
+
|
|
171
|
+
Take the version from `Gemfile.lock`: several versions of a gem can sit side by
|
|
172
|
+
side on disk, and only the resolved one is indexed. If a gem has no
|
|
173
|
+
`.codegraph/` directory, run `bundle codegraph-index` at the project root and
|
|
174
|
+
retry.
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## How it works
|
|
178
|
+
|
|
179
|
+
`plugins.rb` registers two hooks and a command with Bundler, and is the only
|
|
180
|
+
file in the repository that touches Bundler's plugin API.
|
|
181
|
+
|
|
182
|
+
**The install hooks.** Bundler emits `GEM_AFTER_INSTALL` once per gem, from the
|
|
183
|
+
install worker that handled it — on Bundler 4 (checked on 4.0.17) for every gem
|
|
184
|
+
of the bundle on every `bundle install`, whether it was just installed or
|
|
185
|
+
already there. The plugin checks the install succeeded and only queues the gem:
|
|
186
|
+
indexing from the worker would hold it for as long as `codegraph` runs and stall
|
|
187
|
+
the rest of the install. Once everything is installed Bundler emits
|
|
188
|
+
`GEM_AFTER_INSTALL_ALL`, and the plugin runs `codegraph init <gem path>` on each
|
|
189
|
+
queued gem, one at a time, which drops a `.codegraph/` directory next to the
|
|
190
|
+
gem's sources. A `bundle install` that fails never emits that second hook, so
|
|
191
|
+
the gems it did install stay unindexed until `bundle codegraph-index` runs.
|
|
192
|
+
Each gem it indexes prints a `bundler-codegraph: indexed <gem>` line, so a cold
|
|
193
|
+
install that spends minutes in `codegraph` after Bundler is done does not look
|
|
194
|
+
hung. Three properties of that indexing pass are deliberate:
|
|
195
|
+
|
|
196
|
+
- *It never raises.* Every outcome — binary missing, no Ruby source, index
|
|
197
|
+
already there, `codegraph` exiting non-zero — comes back as a status symbol
|
|
198
|
+
and is swallowed. An exception raised from a Bundler hook aborts the entire
|
|
199
|
+
`bundle install`, and a failed index is never a good enough reason to break
|
|
200
|
+
someone's install. A gem `codegraph` fails on still gets a one-line warning,
|
|
201
|
+
pointing at its error output, kept in
|
|
202
|
+
`bundler-codegraph-<uid>/<gem directory>-<digest>.log` (`rack-3.2.6-…log`) under
|
|
203
|
+
`Dir.tmpdir` until a later run succeeds. The hook does not retry that gem on
|
|
204
|
+
the next `bundle install` — it would pay the same failure every time — and
|
|
205
|
+
says so in a `skipped` line, until a new version lands in a new directory,
|
|
206
|
+
`bundle codegraph-index` retries it, or the system purges its temporary
|
|
207
|
+
directory (macOS does on its own, Linux usually at boot). A `path:` source
|
|
208
|
+
keeps its directory: after fixing it, run `bundle codegraph-index`. An interrupted run
|
|
209
|
+
(codegraph is stopped before the cleanup, killed if it ignores TERM), a `codegraph` killed by a signal
|
|
210
|
+
(the OOM killer) or one that could not even start is not a failure: no log is
|
|
211
|
+
kept, and the gem is tried again next time.
|
|
212
|
+
- *It never leaves a partial index.* An index is a `.codegraph/codegraph.db`,
|
|
213
|
+
and `codegraph init` creates `.codegraph/` before it starts indexing, so a run
|
|
214
|
+
that fails or is interrupted (Ctrl-C included) has its `.codegraph/` removed
|
|
215
|
+
rather than left for the next run to mistake for a complete index; a bare
|
|
216
|
+
`.codegraph/` without its database is such a leftover, and is rebuilt.
|
|
217
|
+
`--force` sets the previous index aside and puts it back when the rebuild
|
|
218
|
+
fails — or on the next run, when the rebuild was killed outright.
|
|
219
|
+
- *It serializes.* `codegraph` is itself multi-threaded and already saturates
|
|
220
|
+
several cores, so the queue is indexed one gem at a time, and an advisory
|
|
221
|
+
`flock` keeps two `bundle install` (or a `bundle install` and a `bundle
|
|
222
|
+
codegraph-index`) running side by side from indexing at once. The lock file
|
|
223
|
+
sits in a private per-user directory, `bundler-codegraph-<uid>` under
|
|
224
|
+
`Dir.tmpdir`, created `0700` (and closed again if it is yours but was left
|
|
225
|
+
open); when that path is not a directory owned by you — someone planted it
|
|
226
|
+
in a shared `/tmp` — indexing runs unserialized and without error logs
|
|
227
|
+
rather than trusting it, and both the hook and `bundle codegraph-index` say
|
|
228
|
+
so.
|
|
229
|
+
|
|
230
|
+
**The `codegraph-index` command.** The catch-up pass for whatever the hooks
|
|
231
|
+
missed — a failed `bundle install`, an index deleted by hand — and it reports a
|
|
232
|
+
per-gem status so it is obvious what was skipped and why. It is also the repair
|
|
233
|
+
pass: it runs `codegraph sync` on every index already there, which completes
|
|
234
|
+
one a killed `bundle install` left partial — something the hook cannot see,
|
|
235
|
+
since it skips existing indexes to keep `bundle install` fast — and it retries
|
|
236
|
+
the gems the hook stopped retrying after a failure.
|
|
237
|
+
|
|
238
|
+
## Configuration
|
|
239
|
+
|
|
240
|
+
| Variable | Effect |
|
|
241
|
+
| --- | --- |
|
|
242
|
+
| `BUNDLER_CODEGRAPH` | `0`, `off`, `false` or `no` disables the plugin entirely |
|
|
243
|
+
| `BUNDLER_CODEGRAPH_EXCLUDE` | Comma-separated glob patterns matched against gem names, e.g. `tzinfo-data,rails-*` |
|
|
244
|
+
| `BUNDLER_CODEGRAPH_BIN` | Absolute path to the `codegraph` binary when it is not on `PATH` |
|
|
245
|
+
|
|
246
|
+
There is no *include* list on purpose: restricting a run to a handful of gems is
|
|
247
|
+
rare enough that negative globs do the job, and an include list would silently
|
|
248
|
+
shrink the coverage a later `bundle install` is expected to produce.
|
|
249
|
+
|
|
250
|
+
## Cost
|
|
251
|
+
|
|
252
|
+
Expect an index to weigh roughly **four times the gem's source**: rack 3.2.6 is
|
|
253
|
+
528 kB on disk and produces a 2.1 MB index, built in under a second. Scaled to a
|
|
254
|
+
real application — a ~400-gem bundle — that is a few minutes for the initial
|
|
255
|
+
catch-up pass and somewhere around 600 MB next to the gems. Gems holding no Ruby
|
|
256
|
+
source at all are skipped, and the hook skips gems that already carry an index
|
|
257
|
+
(`.codegraph/codegraph.db`), so the steady-state cost after the first pass is
|
|
258
|
+
only whatever `bundle update` brings in. `bundle codegraph-index` syncs those
|
|
259
|
+
existing indexes instead, at a fraction of a second each on a healthy one.
|
|
260
|
+
|
|
261
|
+
An index lands next to its gem, wherever Bundler installed it. With a
|
|
262
|
+
project-local `BUNDLE_PATH` (`bundle config set --local path .bundle`, as the
|
|
263
|
+
examples above assume) that is the project's `.bundle/`. Without one, gems —
|
|
264
|
+
and so their indexes — live in the Ruby installation's shared `GEM_HOME`, used
|
|
265
|
+
by every project on that Ruby: the 600 MB land there, an index built for one
|
|
266
|
+
project serves the others, and `bundle codegraph-index --force` in one project
|
|
267
|
+
rebuilds them for all. Set `BUNDLE_PATH` to keep the indexes per project.
|
|
268
|
+
|
|
269
|
+
`.bundle/` is usually already git-ignored; `path:` sources are not, hence the
|
|
270
|
+
`.gitignore` note above.
|
|
271
|
+
|
|
272
|
+
## License
|
|
273
|
+
|
|
274
|
+
MIT — see [LICENSE](LICENSE).
|
|
275
|
+
|
|
276
|
+
[codegraph]: https://github.com/colbymchenry/codegraph
|
|
277
|
+
[mise]: https://mise.jdx.dev
|
|
278
|
+
[aqua-registry]: https://github.com/jbox-web/aqua-registry
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bundler
|
|
4
|
+
module Codegraph
|
|
5
|
+
|
|
6
|
+
# Indexes a whole set of gem specs, one at a time: the queue
|
|
7
|
+
# `after_install_all` drains at the end of `bundle install`, or the whole
|
|
8
|
+
# resolved bundle for `bundle codegraph-index`.
|
|
9
|
+
class Backfill
|
|
10
|
+
|
|
11
|
+
attr_reader :specs, :reporter, :indexer_options
|
|
12
|
+
|
|
13
|
+
# @param specs [Enumerable] objects responding to `name` and `full_gem_path`
|
|
14
|
+
# @param reporter [#call, nil] called after each gem with its name, its
|
|
15
|
+
# status and `Indexer#log_hint`
|
|
16
|
+
# @param indexer_options [Hash] handed to every `Indexer` as is (`config:`,
|
|
17
|
+
# `force:`, `sync:`, `skip_failed:`), with a single `Config` for them all
|
|
18
|
+
def initialize(specs, reporter: nil, **indexer_options)
|
|
19
|
+
@specs = specs
|
|
20
|
+
@reporter = reporter
|
|
21
|
+
@indexer_options = { config: Config.new }.merge(indexer_options)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Hash{Symbol => Integer}] number of gems per resulting status
|
|
25
|
+
def call
|
|
26
|
+
specs.each_with_object(Hash.new(0)) do |spec, results|
|
|
27
|
+
name, status, log_hint = index(spec)
|
|
28
|
+
results[status] += 1
|
|
29
|
+
reporter&.call(name, status, log_hint)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# A spec that cannot even be read counts as a failure of its own, rather
|
|
36
|
+
# than cutting the pass short for every gem after it.
|
|
37
|
+
def index(spec)
|
|
38
|
+
indexer = Indexer.new(spec.full_gem_path, name: spec.name, **indexer_options)
|
|
39
|
+
[spec.name, indexer.call, indexer.log_hint]
|
|
40
|
+
rescue StandardError
|
|
41
|
+
[spec.respond_to?(:name) ? spec.name : spec.to_s, :failed, '']
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bundler
|
|
4
|
+
module Codegraph
|
|
5
|
+
|
|
6
|
+
# `bundle codegraph-index [--force]`
|
|
7
|
+
#
|
|
8
|
+
# Indexes every gem of the current bundle, and runs `codegraph sync` on the
|
|
9
|
+
# indexes already there — which also completes one a killed run left
|
|
10
|
+
# partial. Loaded only from `plugins.rb`, so the rest of the gem stays
|
|
11
|
+
# testable without Bundler's plugin API.
|
|
12
|
+
class Command < Bundler::Plugin::API
|
|
13
|
+
|
|
14
|
+
FORCE_FLAG = '--force'
|
|
15
|
+
|
|
16
|
+
# Labels for the statuses worth reporting; anything else is silent noise.
|
|
17
|
+
STATUS_LABELS = {
|
|
18
|
+
indexed: 'indexed',
|
|
19
|
+
synced: 'synchronized',
|
|
20
|
+
already_indexed: 'already indexed',
|
|
21
|
+
excluded: 'excluded',
|
|
22
|
+
no_ruby: 'no Ruby source',
|
|
23
|
+
missing: 'not installed',
|
|
24
|
+
unavailable: 'codegraph not found',
|
|
25
|
+
disabled: 'disabled',
|
|
26
|
+
failed: 'failed',
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
HELP_FLAGS = %w[--help -h].freeze
|
|
30
|
+
|
|
31
|
+
USAGE = <<~USAGE
|
|
32
|
+
Usage: bundle codegraph-index [--force]
|
|
33
|
+
|
|
34
|
+
Indexes every gem of the bundle with codegraph, and syncs the indexes already there.
|
|
35
|
+
|
|
36
|
+
--force rebuild every index from scratch
|
|
37
|
+
USAGE
|
|
38
|
+
|
|
39
|
+
# Exits non-zero (through a `Bundler::BundlerError`) on an unknown
|
|
40
|
+
# argument, when the binary is missing — before walking the bundle, rather
|
|
41
|
+
# than reporting every gem as `codegraph not found` — and once every gem
|
|
42
|
+
# was tried when any of them failed, so a script can tell. `--help` is
|
|
43
|
+
# what `bundle help codegraph-index` passes.
|
|
44
|
+
def exec(_command_name, args)
|
|
45
|
+
return Bundler.ui.info(USAGE) if args.intersect?(HELP_FLAGS)
|
|
46
|
+
|
|
47
|
+
force = force?(args)
|
|
48
|
+
config = Config.new
|
|
49
|
+
ensure_executable(config)
|
|
50
|
+
Codegraph.warn_untrusted_runtime_dir(Bundler.ui, config)
|
|
51
|
+
|
|
52
|
+
results = Backfill.new(gem_specs, config: config, force: force, sync: true, reporter: method(:report)).call
|
|
53
|
+
Bundler.ui.info("\n#{summary(results)}")
|
|
54
|
+
ensure_no_failure(results)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def force?(args)
|
|
60
|
+
unknown = args - [FORCE_FLAG]
|
|
61
|
+
raise Bundler::InvalidOption, "Unknown option: #{unknown.join(' ')}" unless unknown.empty?
|
|
62
|
+
|
|
63
|
+
args.include?(FORCE_FLAG)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ensure_executable(config)
|
|
67
|
+
return if config.disabled? || config.executable
|
|
68
|
+
|
|
69
|
+
raise Bundler::PluginError, "codegraph not found (#{config.binary})"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def ensure_no_failure(results)
|
|
73
|
+
failed = results[:failed]
|
|
74
|
+
return if failed.zero?
|
|
75
|
+
|
|
76
|
+
raise Bundler::PluginError, "#{failed} gem#{'s' if failed > 1} failed to index"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def gem_specs
|
|
80
|
+
root = Bundler.root
|
|
81
|
+
Bundler.definition.specs.select { |spec| Codegraph.dependency?(spec, root) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def report(name, status, log_hint)
|
|
85
|
+
Bundler.ui.info("#{name}: #{STATUS_LABELS.fetch(status, status)}#{log_hint}")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def summary(results)
|
|
89
|
+
results.map { |status, count| "#{count} #{STATUS_LABELS.fetch(status, status)}" }.join(', ')
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bundler
|
|
4
|
+
module Codegraph
|
|
5
|
+
|
|
6
|
+
# Runtime configuration, driven entirely by environment variables so that a
|
|
7
|
+
# single `bundle install` can be run with indexing off without editing a file.
|
|
8
|
+
class Config
|
|
9
|
+
|
|
10
|
+
ENV_ENABLED = 'BUNDLER_CODEGRAPH'
|
|
11
|
+
ENV_EXCLUDE = 'BUNDLER_CODEGRAPH_EXCLUDE'
|
|
12
|
+
ENV_BINARY = 'BUNDLER_CODEGRAPH_BIN'
|
|
13
|
+
|
|
14
|
+
DEFAULT_BINARY = 'codegraph'
|
|
15
|
+
DISABLED_VALUES = %w[0 off false no].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :env, :exclude_patterns, :binary
|
|
18
|
+
|
|
19
|
+
# @param env [Hash] environment to read the configuration from
|
|
20
|
+
def initialize(env: ENV)
|
|
21
|
+
@env = env
|
|
22
|
+
@disabled = DISABLED_VALUES.include?(env[ENV_ENABLED].to_s.strip.downcase)
|
|
23
|
+
@exclude_patterns = env[ENV_EXCLUDE].to_s.split(',').map(&:strip).reject(&:empty?)
|
|
24
|
+
@binary = env[ENV_BINARY].to_s.empty? ? DEFAULT_BINARY : env[ENV_BINARY]
|
|
25
|
+
@executable = nil
|
|
26
|
+
@executable_resolved = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Resolved lazily, once: the `PATH` lookup only runs when something
|
|
30
|
+
# actually needs the binary.
|
|
31
|
+
#
|
|
32
|
+
# @return [String, nil] path of the codegraph executable, nil when not found
|
|
33
|
+
def executable
|
|
34
|
+
return @executable if @executable_resolved
|
|
35
|
+
|
|
36
|
+
@executable_resolved = true
|
|
37
|
+
@executable = which(binary)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def disabled?
|
|
41
|
+
@disabled
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param gem_name [String] name of the gem about to be indexed
|
|
45
|
+
# @return [Boolean] true when the gem matches one of the exclusion globs
|
|
46
|
+
def excluded?(gem_name)
|
|
47
|
+
exclude_patterns.any? { |pattern| File.fnmatch?(pattern, gem_name.to_s) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# A name holding a separator is a path: used as is, never looked up on
|
|
53
|
+
# PATH. An empty PATH entry stands for the current directory, as in POSIX
|
|
54
|
+
# — `File.join('', name)` would make it the filesystem root.
|
|
55
|
+
def which(name)
|
|
56
|
+
return (runnable?(name) ? name : nil) if name.include?(File::SEPARATOR)
|
|
57
|
+
|
|
58
|
+
env.fetch('PATH', '').split(File::PATH_SEPARATOR, -1).each do |dir|
|
|
59
|
+
candidate = File.join(dir.empty? ? '.' : dir, name)
|
|
60
|
+
return candidate if runnable?(candidate)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def runnable?(candidate)
|
|
67
|
+
File.file?(candidate) && File.executable?(candidate)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Bundler
|
|
7
|
+
module Codegraph
|
|
8
|
+
|
|
9
|
+
# codegraph's error output for one gem, kept in the runtime directory for
|
|
10
|
+
# as long as the last run on that gem failed.
|
|
11
|
+
#
|
|
12
|
+
# The log starts with the directory it is about, is written under a
|
|
13
|
+
# temporary name while codegraph runs, and only takes its own name once the
|
|
14
|
+
# run failed: a log naming a directory means codegraph failed on it and
|
|
15
|
+
# nothing retried it since — never that a run is still going. A new version
|
|
16
|
+
# of the gem is a new directory.
|
|
17
|
+
class ErrorLog
|
|
18
|
+
|
|
19
|
+
# Named after the gem directory and a digest of its full path
|
|
20
|
+
# (`rack-3.2.6-<digest>.log`): two directories of the same name — the
|
|
21
|
+
# same version in two projects' `.bundle/`, two `path:` sources called
|
|
22
|
+
# `admin` — keep a log each, instead of erasing each other's on every run.
|
|
23
|
+
#
|
|
24
|
+
# @param gem_path [String] directory codegraph runs on
|
|
25
|
+
# @return [String, nil] nil when the runtime directory cannot be trusted
|
|
26
|
+
def self.path(gem_path)
|
|
27
|
+
dir = RuntimeDir.path
|
|
28
|
+
dir && File.join(dir, "#{File.basename(gem_path)}-#{::Digest::SHA256.hexdigest(gem_path)[0, 12]}.log")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :path, :gem_path
|
|
32
|
+
|
|
33
|
+
# @param gem_path [String] directory codegraph runs on
|
|
34
|
+
def initialize(gem_path)
|
|
35
|
+
@path = self.class.path(gem_path)
|
|
36
|
+
@gem_path = gem_path
|
|
37
|
+
@kept = false
|
|
38
|
+
@logged = false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Whether the last `capture` kept a log — the only log a failure message
|
|
42
|
+
# may point at: one found on disk could predate this attempt, or belong
|
|
43
|
+
# to another process working on another version of the gem.
|
|
44
|
+
def kept?
|
|
45
|
+
@kept
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Where to find codegraph's error output, as a suffix for a failure
|
|
49
|
+
# message; empty unless this attempt kept a log.
|
|
50
|
+
#
|
|
51
|
+
# @return [String]
|
|
52
|
+
def hint
|
|
53
|
+
kept? ? ", see #{path}" : ''
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def failed_before?
|
|
57
|
+
return false unless path && File.exist?(path)
|
|
58
|
+
|
|
59
|
+
File.foreach(path).first&.chomp == gem_path
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def discard
|
|
63
|
+
FileUtils.rm_f(path) if path
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Yields the stream codegraph's stderr should go to, and keeps the log
|
|
67
|
+
# only when the block returns a failure. An exception — `Interrupt`
|
|
68
|
+
# included — drops it too: codegraph did not fail, it was stopped, and a
|
|
69
|
+
# kept log would stop the hook from ever retrying the gem.
|
|
70
|
+
#
|
|
71
|
+
# Only an explicit `false` is a codegraph failure: `nil` means it could
|
|
72
|
+
# not even be started. A log that cannot be opened (full disk) must not
|
|
73
|
+
# cost the index either, so codegraph then runs without one.
|
|
74
|
+
#
|
|
75
|
+
# @return the block's value
|
|
76
|
+
def capture(&)
|
|
77
|
+
@kept = false
|
|
78
|
+
return yield(File::NULL) unless path
|
|
79
|
+
|
|
80
|
+
succeeded = write(&)
|
|
81
|
+
@kept = succeeded == false && @logged && publish
|
|
82
|
+
succeeded
|
|
83
|
+
ensure
|
|
84
|
+
FileUtils.rm_f(partial_path) if path
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def partial_path
|
|
90
|
+
"#{path}.part"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The header goes in first. A log that cannot be opened or written (full
|
|
94
|
+
# disk) falls back to running codegraph without one — and is never
|
|
95
|
+
# published, empty, as the log of a failure; an error raised by the block
|
|
96
|
+
# itself propagates, rather than running codegraph twice.
|
|
97
|
+
def write
|
|
98
|
+
@logged = false
|
|
99
|
+
File.open(partial_path, 'w', 0o600) do |file|
|
|
100
|
+
file.puts(gem_path)
|
|
101
|
+
file.flush
|
|
102
|
+
@logged = true
|
|
103
|
+
return yield file
|
|
104
|
+
end
|
|
105
|
+
rescue SystemCallError
|
|
106
|
+
raise if @logged
|
|
107
|
+
|
|
108
|
+
yield File::NULL
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def publish
|
|
112
|
+
File.rename(partial_path, path)
|
|
113
|
+
true
|
|
114
|
+
rescue SystemCallError
|
|
115
|
+
false
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module Bundler
|
|
6
|
+
module Codegraph
|
|
7
|
+
|
|
8
|
+
# Builds a CodeGraph index for a single gem directory.
|
|
9
|
+
#
|
|
10
|
+
# Never raises: every failure mode is reported as a status symbol, because
|
|
11
|
+
# this runs from a Bundler hook where an exception would abort the whole
|
|
12
|
+
# `bundle install`.
|
|
13
|
+
class Indexer
|
|
14
|
+
|
|
15
|
+
INDEX_DIRNAME = '.codegraph'
|
|
16
|
+
DATABASE_NAME = 'codegraph.db'
|
|
17
|
+
BACKUP_DIRNAME = '.codegraph.bundler-codegraph-backup'
|
|
18
|
+
|
|
19
|
+
attr_reader :path, :name, :config, :force, :sync, :skip_failed
|
|
20
|
+
|
|
21
|
+
# @param path [String] absolute path of the directory to index
|
|
22
|
+
# @param name [String] gem name, used for the exclusion patterns
|
|
23
|
+
# @param config [Config]
|
|
24
|
+
# @param force [Boolean] drop and rebuild an existing index
|
|
25
|
+
# @param sync [Boolean] run `codegraph sync` on an existing index instead
|
|
26
|
+
# of skipping it, which also completes an index a killed run left behind
|
|
27
|
+
# @param skip_failed [Boolean] skip a directory codegraph already failed
|
|
28
|
+
# on, as long as its error log is there
|
|
29
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
30
|
+
def initialize(path, name:, config: Config.new, force: false, sync: false, skip_failed: false)
|
|
31
|
+
@path = path.to_s
|
|
32
|
+
@name = name.to_s
|
|
33
|
+
@config = config
|
|
34
|
+
@force = force
|
|
35
|
+
@sync = sync
|
|
36
|
+
@skip_failed = skip_failed
|
|
37
|
+
@error_log = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Symbol] :indexed, :synced, :failed, :disabled, :excluded,
|
|
41
|
+
# :missing, :failed_before, :no_ruby, :already_indexed or :unavailable
|
|
42
|
+
def call
|
|
43
|
+
skipped = skip_reason
|
|
44
|
+
return skipped if skipped
|
|
45
|
+
|
|
46
|
+
Lock.synchronize do
|
|
47
|
+
error_log.discard
|
|
48
|
+
run
|
|
49
|
+
end
|
|
50
|
+
rescue StandardError
|
|
51
|
+
:failed
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Where to find codegraph's error output after `call`, as a suffix for a
|
|
55
|
+
# failure message (see `ErrorLog#hint`).
|
|
56
|
+
#
|
|
57
|
+
# @return [String]
|
|
58
|
+
def log_hint
|
|
59
|
+
@error_log ? @error_log.hint : ''
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Same criterion as codegraph itself: a `.codegraph/` directory without
|
|
63
|
+
# its database is a leftover, not an index.
|
|
64
|
+
def indexed?
|
|
65
|
+
File.exist?(File.join(index_path, DATABASE_NAME))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# Ordered cheapest-first: configuration, then single stats, then the PATH
|
|
71
|
+
# lookup (done once per Config), and the walk of the gem's tree last.
|
|
72
|
+
def skip_reason
|
|
73
|
+
config_skip_reason || filesystem_skip_reason
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def config_skip_reason
|
|
77
|
+
return :disabled if config.disabled?
|
|
78
|
+
return :excluded if config.excluded?(name)
|
|
79
|
+
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def filesystem_skip_reason
|
|
84
|
+
return :missing unless Dir.exist?(path)
|
|
85
|
+
return :already_indexed if keep_index?
|
|
86
|
+
return :unavailable unless executable
|
|
87
|
+
return :failed_before if skip_failed && error_log.failed_before?
|
|
88
|
+
return :no_ruby unless ruby_sources?
|
|
89
|
+
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# An index this run leaves alone: present, neither forced nor synced, and
|
|
94
|
+
# not sitting next to the backup a killed `--force` rebuild left behind.
|
|
95
|
+
def keep_index?
|
|
96
|
+
indexed? && !force && !sync && !Dir.exist?(backup_path)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Stops at the first hit instead of materializing the whole file list.
|
|
100
|
+
# The path goes through `base:`, never into the pattern: a checkout under
|
|
101
|
+
# a directory named `client [2026]` would otherwise match nothing.
|
|
102
|
+
def ruby_sources?
|
|
103
|
+
Dir.glob('**/*.rb', base: path) { |_file| return true }
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def executable
|
|
108
|
+
config.executable
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Checks for an index again: another process may have built it while this
|
|
112
|
+
# one waited for the lock.
|
|
113
|
+
#
|
|
114
|
+
# A backup only outlives a `--force` rebuild that never reached its
|
|
115
|
+
# `ensure` (SIGKILL, OOM): whatever `.codegraph/` holds then is that
|
|
116
|
+
# unfinished rebuild, and the backup is the index to keep.
|
|
117
|
+
def run
|
|
118
|
+
restore_backup
|
|
119
|
+
return build unless indexed?
|
|
120
|
+
return rebuild if force
|
|
121
|
+
|
|
122
|
+
sync ? refresh : :already_indexed
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# `codegraph init` creates `.codegraph/` before it starts indexing, so a
|
|
126
|
+
# failed or interrupted run leaves a partial index behind that the next
|
|
127
|
+
# run would take for a complete one. Whatever is there when the build does
|
|
128
|
+
# not succeed is therefore removed, a stale leftover included.
|
|
129
|
+
def build
|
|
130
|
+
FileUtils.rm_rf(index_path)
|
|
131
|
+
succeeded = codegraph('init')
|
|
132
|
+
succeeded ? :indexed : :failed
|
|
133
|
+
ensure
|
|
134
|
+
FileUtils.rm_rf(index_path) unless succeeded
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The previous index is set aside rather than deleted, and put back when
|
|
138
|
+
# the rebuild does not succeed.
|
|
139
|
+
def rebuild
|
|
140
|
+
FileUtils.rm_rf(backup_path)
|
|
141
|
+
File.rename(index_path, backup_path)
|
|
142
|
+
status = build
|
|
143
|
+
ensure
|
|
144
|
+
status == :indexed ? FileUtils.rm_rf(backup_path) : restore_backup
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def restore_backup
|
|
148
|
+
return unless Dir.exist?(backup_path)
|
|
149
|
+
|
|
150
|
+
FileUtils.rm_rf(index_path)
|
|
151
|
+
File.rename(backup_path, index_path)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def refresh
|
|
155
|
+
codegraph('sync') ? :synced : :failed
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# stdin is closed off too: codegraph prompts on it in some setups (live
|
|
159
|
+
# watching disabled, a Git checkout), and with its output going to
|
|
160
|
+
# /dev/null the question would be invisible and `bundle install` would
|
|
161
|
+
# hang on it. At EOF the prompt is cancelled and codegraph moves on.
|
|
162
|
+
# stderr goes to the gem's `ErrorLog`, kept only when the run fails.
|
|
163
|
+
def codegraph(command)
|
|
164
|
+
error_log.capture { |stderr| Subprocess.run([executable, command, path], stderr) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The log of an earlier run is discarded once this one holds the lock
|
|
168
|
+
# (see `call`) — never earlier, where it could be another process's log
|
|
169
|
+
# still being written — so a log that exists afterwards always holds codegraph's
|
|
170
|
+
# output from this run — never a stale one, when this run failed before
|
|
171
|
+
# codegraph even ran.
|
|
172
|
+
def error_log
|
|
173
|
+
@error_log ||= ErrorLog.new(path)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def index_path
|
|
177
|
+
File.join(path, INDEX_DIRNAME)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def backup_path
|
|
181
|
+
File.join(path, BACKUP_DIRNAME)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
|
|
5
|
+
module Bundler
|
|
6
|
+
module Codegraph
|
|
7
|
+
|
|
8
|
+
# Serializes indexing across processes.
|
|
9
|
+
#
|
|
10
|
+
# `codegraph` already saturates several cores on its own. Within one
|
|
11
|
+
# `bundle install` the queue is drained one gem at a time; this advisory
|
|
12
|
+
# file lock covers several `bundle install` (or a `bundle codegraph-index`)
|
|
13
|
+
# running side by side.
|
|
14
|
+
module Lock
|
|
15
|
+
|
|
16
|
+
LOCK_FILENAME = 'bundler-codegraph.lock'
|
|
17
|
+
|
|
18
|
+
# @return [String, nil] nil when the runtime directory cannot be trusted
|
|
19
|
+
def self.path
|
|
20
|
+
dir = RuntimeDir.path
|
|
21
|
+
dir && File.join(dir, LOCK_FILENAME)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Runs the block while holding the exclusive lock. An untrusted runtime
|
|
25
|
+
# directory, or a filesystem without working advisory locks, degrades to
|
|
26
|
+
# running unserialized rather than failing.
|
|
27
|
+
#
|
|
28
|
+
# Only opening and locking may fall back: an error raised by the block
|
|
29
|
+
# itself propagates, rather than running the block a second time, unlocked.
|
|
30
|
+
def self.synchronize(&)
|
|
31
|
+
lock_path = path
|
|
32
|
+
lock_path ? with_lock(lock_path, &) : yield
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.with_lock(lock_path)
|
|
36
|
+
locked = false
|
|
37
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |file|
|
|
38
|
+
file.flock(File::LOCK_EX)
|
|
39
|
+
locked = true
|
|
40
|
+
return yield
|
|
41
|
+
end
|
|
42
|
+
rescue SystemCallError, NotImplementedError
|
|
43
|
+
raise if locked
|
|
44
|
+
|
|
45
|
+
yield
|
|
46
|
+
end
|
|
47
|
+
private_class_method :with_lock
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
|
|
5
|
+
module Bundler
|
|
6
|
+
module Codegraph
|
|
7
|
+
|
|
8
|
+
# A private directory for the plugin's runtime files, one per user under
|
|
9
|
+
# `Dir.tmpdir`.
|
|
10
|
+
#
|
|
11
|
+
# On Linux `Dir.tmpdir` is the shared `/tmp`, where a fixed file name lets
|
|
12
|
+
# another user create it first — and then either hold the lock forever or
|
|
13
|
+
# leave it unopenable. The directory is created 0700 and only trusted when
|
|
14
|
+
# it is a real directory (not a symlink), owned by the current user and
|
|
15
|
+
# closed to everyone else; callers degrade gracefully when it is not.
|
|
16
|
+
module RuntimeDir
|
|
17
|
+
|
|
18
|
+
# @return [String] where the directory lives, trusted or not
|
|
19
|
+
def self.candidate
|
|
20
|
+
File.join(Dir.tmpdir, "bundler-codegraph-#{Process.uid}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [String, nil] the directory, nil when it cannot be trusted
|
|
24
|
+
def self.path
|
|
25
|
+
dir = candidate
|
|
26
|
+
create(dir)
|
|
27
|
+
trusted?(dir) ? dir : nil
|
|
28
|
+
rescue SystemCallError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A directory already there and ours — a real directory, not a symlink
|
|
33
|
+
# — is closed to others if it was left open (a umask, another tool):
|
|
34
|
+
# otherwise it would stay untrusted, and the lock and logs off, for good.
|
|
35
|
+
def self.create(dir)
|
|
36
|
+
Dir.mkdir(dir, 0o700)
|
|
37
|
+
rescue Errno::EEXIST
|
|
38
|
+
stat = File.lstat(dir)
|
|
39
|
+
File.chmod(0o700, dir) if stat.directory? && stat.uid == Process.uid
|
|
40
|
+
end
|
|
41
|
+
private_class_method :create
|
|
42
|
+
|
|
43
|
+
# Owned by `Process.uid`, the same id the directory is named after —
|
|
44
|
+
# `File::Stat#owned?` compares with the effective uid instead.
|
|
45
|
+
def self.trusted?(dir)
|
|
46
|
+
stat = File.lstat(dir)
|
|
47
|
+
stat.directory? && stat.uid == Process.uid && stat.mode.nobits?(0o077)
|
|
48
|
+
end
|
|
49
|
+
private_class_method :trusted?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bundler
|
|
4
|
+
module Codegraph
|
|
5
|
+
|
|
6
|
+
# Runs codegraph as a child process, stdin and stdout closed off.
|
|
7
|
+
#
|
|
8
|
+
# `Kernel#system` would leave the child running when this process alone
|
|
9
|
+
# is interrupted (SIGTERM to Ruby, not its process group): the cleanup that
|
|
10
|
+
# follows — removing a partial index, releasing the lock — would then happen
|
|
11
|
+
# under a live codegraph. The child is stopped and reaped first.
|
|
12
|
+
module Subprocess
|
|
13
|
+
|
|
14
|
+
# How long a child gets to exit on TERM before it is killed.
|
|
15
|
+
STOP_TIMEOUT = 5
|
|
16
|
+
|
|
17
|
+
# @param argv [Array<String>] command line
|
|
18
|
+
# @param stderr [IO, String] where the child's error output goes
|
|
19
|
+
# @return [Boolean, nil] like `system`, except that a child killed by a
|
|
20
|
+
# signal (the OOM killer) gives nil, as one that cannot be started: it
|
|
21
|
+
# did not fail, it was stopped
|
|
22
|
+
def self.run(argv, stderr)
|
|
23
|
+
pid = Process.spawn(*argv, in: File::NULL, out: File::NULL, err: stderr)
|
|
24
|
+
status = Process.wait2(pid).last
|
|
25
|
+
pid = nil
|
|
26
|
+
status.success? || (status.signaled? ? nil : false)
|
|
27
|
+
rescue SystemCallError
|
|
28
|
+
nil
|
|
29
|
+
ensure
|
|
30
|
+
stop(pid) if pid
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# TERM first, KILL once STOP_TIMEOUT is over: a child that ignores TERM
|
|
34
|
+
# must not hang `bundle install` in an `ensure`. A child already reaped
|
|
35
|
+
# (the interrupt landed right after `wait2`) is never signalled, as its
|
|
36
|
+
# pid may have been reused.
|
|
37
|
+
def self.stop(pid)
|
|
38
|
+
return if reaped?(pid)
|
|
39
|
+
|
|
40
|
+
Process.kill('TERM', pid)
|
|
41
|
+
return if exited_within?(pid, STOP_TIMEOUT)
|
|
42
|
+
|
|
43
|
+
Process.kill('KILL', pid)
|
|
44
|
+
Process.wait(pid)
|
|
45
|
+
rescue SystemCallError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
private_class_method :stop
|
|
49
|
+
|
|
50
|
+
def self.reaped?(pid)
|
|
51
|
+
!Process.wait(pid, Process::WNOHANG).nil?
|
|
52
|
+
rescue Errno::ECHILD
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
private_class_method :reaped?
|
|
56
|
+
|
|
57
|
+
def self.exited_within?(pid, timeout)
|
|
58
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
59
|
+
until Process.wait(pid, Process::WNOHANG)
|
|
60
|
+
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
61
|
+
|
|
62
|
+
sleep 0.05
|
|
63
|
+
end
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
private_class_method :exited_within?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bundler
|
|
4
|
+
module Codegraph
|
|
5
|
+
|
|
6
|
+
def self.gem_version
|
|
7
|
+
Gem::Version.new VERSION::STRING
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module VERSION
|
|
11
|
+
MAJOR = 0
|
|
12
|
+
MINOR = 1
|
|
13
|
+
TINY = 0
|
|
14
|
+
PRE = nil
|
|
15
|
+
|
|
16
|
+
STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.')
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'codegraph/version'
|
|
4
|
+
require_relative 'codegraph/config'
|
|
5
|
+
require_relative 'codegraph/runtime_dir'
|
|
6
|
+
require_relative 'codegraph/error_log'
|
|
7
|
+
require_relative 'codegraph/lock'
|
|
8
|
+
require_relative 'codegraph/subprocess'
|
|
9
|
+
require_relative 'codegraph/indexer'
|
|
10
|
+
require_relative 'codegraph/backfill'
|
|
11
|
+
|
|
12
|
+
module Bundler
|
|
13
|
+
|
|
14
|
+
# Bundler plugin building a CodeGraph index inside every gem it installs, so
|
|
15
|
+
# that an agent can query a dependency's source through `codegraph explore
|
|
16
|
+
# --path <gem>` instead of reading its files by hand.
|
|
17
|
+
module Codegraph
|
|
18
|
+
|
|
19
|
+
# Specs queued by `after_install`, drained by `after_install_all`. A
|
|
20
|
+
# `Thread::Queue` because Bundler fires `after-install` from each of its
|
|
21
|
+
# parallel install workers.
|
|
22
|
+
@pending = Queue.new
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
attr_reader :pending
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Entry point wired to Bundler's `after-install` hook, which fires from the
|
|
29
|
+
# install workers themselves. Indexing there would hold a worker for as long
|
|
30
|
+
# as `codegraph` runs, and stall the rest of the install behind it: the gem
|
|
31
|
+
# is only queued, and `after_install_all` indexes the queue once installing
|
|
32
|
+
# is over.
|
|
33
|
+
#
|
|
34
|
+
# Swallows everything: the hook also fires for failed installs, and a broken
|
|
35
|
+
# index must never be the reason a `bundle install` aborts.
|
|
36
|
+
#
|
|
37
|
+
# @param spec_install [Bundler::ParallelInstaller::SpecInstallation]
|
|
38
|
+
# @param root [Pathname, String, nil] root of the project being installed,
|
|
39
|
+
# `Bundler.root` by default — resolved in the body, where the rescue
|
|
40
|
+
# covers it, never as a keyword default, which is evaluated outside it
|
|
41
|
+
# @return [nil]
|
|
42
|
+
def self.after_install(spec_install, root: nil)
|
|
43
|
+
return unless spec_install.respond_to?(:installed?) && spec_install.installed?
|
|
44
|
+
|
|
45
|
+
spec = spec_install.spec
|
|
46
|
+
pending << spec if dependency?(spec, root || Bundler.root)
|
|
47
|
+
nil
|
|
48
|
+
rescue StandardError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Entry point wired to Bundler's `after-install-all` hook, fired once every
|
|
53
|
+
# gem is installed — and not at all when the install fails, in which case
|
|
54
|
+
# `bundle codegraph-index` picks up whatever was left unindexed.
|
|
55
|
+
#
|
|
56
|
+
# Swallows everything, for the same reason as `after_install`, but warns
|
|
57
|
+
# about each gem that failed to index, pointing at codegraph's error output.
|
|
58
|
+
#
|
|
59
|
+
# @param config [Config, nil] read from the environment by default
|
|
60
|
+
# @param shell [#warn, nil] Bundler's UI by default; both are resolved in
|
|
61
|
+
# the body, where the rescue covers them
|
|
62
|
+
# @return [Hash{Symbol => Integer}, nil] number of gems per resulting status
|
|
63
|
+
def self.after_install_all(config: nil, shell: nil)
|
|
64
|
+
config ||= Config.new
|
|
65
|
+
shell ||= Bundler.ui
|
|
66
|
+
specs = []
|
|
67
|
+
specs << pending.pop until pending.empty?
|
|
68
|
+
warn_untrusted_runtime_dir(shell, config) unless specs.empty?
|
|
69
|
+
# Bundler fires `after-install` for every gem on every `bundle install`:
|
|
70
|
+
# a gem codegraph fails on is not retried each time, only once it changes
|
|
71
|
+
# or when `bundle codegraph-index` runs.
|
|
72
|
+
Backfill.new(specs, config: config, skip_failed: true, reporter: reporter_for(shell)).call
|
|
73
|
+
rescue StandardError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# One line per gem actually indexed — a cold install can spend minutes in
|
|
78
|
+
# codegraph after Bundler is done — one per gem skipped after an earlier
|
|
79
|
+
# failure, so it is never left unindexed without a word, and a warning
|
|
80
|
+
# per new failure. Printing may fail (`bundle install | head` closes the
|
|
81
|
+
# pipe); that must not cost the rest of the queue.
|
|
82
|
+
def self.reporter_for(shell)
|
|
83
|
+
lambda do |name, status, log_hint|
|
|
84
|
+
report(shell, name, status, log_hint)
|
|
85
|
+
rescue StandardError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
private_class_method :reporter_for
|
|
90
|
+
|
|
91
|
+
def self.report(shell, name, status, log_hint)
|
|
92
|
+
case status
|
|
93
|
+
when :indexed then shell.info("bundler-codegraph: indexed #{name}")
|
|
94
|
+
when :failed_before
|
|
95
|
+
shell.info("bundler-codegraph: skipped #{name}, codegraph failed on it before; " \
|
|
96
|
+
'`bundle codegraph-index` retries it')
|
|
97
|
+
when :failed then shell.warn("bundler-codegraph: indexing #{name} failed#{log_hint}")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
private_class_method :report
|
|
101
|
+
|
|
102
|
+
# Without a trusted runtime directory the lock and the error logs are both
|
|
103
|
+
# off; say so once rather than let it pass unnoticed — as long as there is
|
|
104
|
+
# any indexing to run at all.
|
|
105
|
+
def self.warn_untrusted_runtime_dir(shell, config)
|
|
106
|
+
return if config.disabled? || !config.executable || RuntimeDir.path
|
|
107
|
+
|
|
108
|
+
shell.warn("bundler-codegraph: #{RuntimeDir.candidate} is not a private directory, " \
|
|
109
|
+
'so indexing runs unserialized and without error logs')
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Whether a spec of the bundle is a dependency worth indexing. `bundler`
|
|
113
|
+
# itself lives in the Ruby installation, outside the bundle. A project
|
|
114
|
+
# declaring `gemspec` gets its own spec back as a `path:` source rooted at
|
|
115
|
+
# the project directory, which is the project, not a dependency: it is
|
|
116
|
+
# told by its `Bundler::Source::Gemspec` source, since the Gemfile need not
|
|
117
|
+
# sit at the root (Appraisal's `gemfiles/*.gemfile` use `gemspec path:
|
|
118
|
+
# '../'`), and by its path for specs that carry no source.
|
|
119
|
+
#
|
|
120
|
+
# @param spec [#name, #full_gem_path]
|
|
121
|
+
# @param root [Pathname, String] root of the project
|
|
122
|
+
def self.dependency?(spec, root)
|
|
123
|
+
return false if spec.name == 'bundler' || own_gemspec?(spec)
|
|
124
|
+
|
|
125
|
+
File.expand_path(spec.full_gem_path) != File.expand_path(root.to_s)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def self.own_gemspec?(spec)
|
|
129
|
+
defined?(Bundler::Source::Gemspec) && spec.respond_to?(:source) && spec.source.is_a?(Bundler::Source::Gemspec)
|
|
130
|
+
end
|
|
131
|
+
private_class_method :own_gemspec?
|
|
132
|
+
end
|
|
133
|
+
end
|
data/plugins.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/bundler/codegraph'
|
|
4
|
+
require_relative 'lib/bundler/codegraph/command'
|
|
5
|
+
|
|
6
|
+
# Fired once per gem, from each parallel install worker: only queues the gem.
|
|
7
|
+
Bundler::Plugin::API.hook(Bundler::Plugin::Events::GEM_AFTER_INSTALL) do |spec_install|
|
|
8
|
+
Bundler::Codegraph.after_install(spec_install)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Fired once, after every gem is installed: indexes the queue.
|
|
12
|
+
Bundler::Plugin::API.hook(Bundler::Plugin::Events::GEM_AFTER_INSTALL_ALL) do |_dependencies|
|
|
13
|
+
Bundler::Codegraph.after_install_all
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
Bundler::Plugin::API.command('codegraph-index', Bundler::Codegraph::Command)
|
metadata
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: bundler-codegraph
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Nicolas Rodriguez
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: A Bundler plugin running `codegraph init` on each installed gem.
|
|
13
|
+
email:
|
|
14
|
+
- nico@nicoladmin.fr
|
|
15
|
+
executables: []
|
|
16
|
+
extensions: []
|
|
17
|
+
extra_rdoc_files: []
|
|
18
|
+
files:
|
|
19
|
+
- CHANGELOG.md
|
|
20
|
+
- LICENSE
|
|
21
|
+
- README.md
|
|
22
|
+
- lib/bundler/codegraph.rb
|
|
23
|
+
- lib/bundler/codegraph/backfill.rb
|
|
24
|
+
- lib/bundler/codegraph/command.rb
|
|
25
|
+
- lib/bundler/codegraph/config.rb
|
|
26
|
+
- lib/bundler/codegraph/error_log.rb
|
|
27
|
+
- lib/bundler/codegraph/indexer.rb
|
|
28
|
+
- lib/bundler/codegraph/lock.rb
|
|
29
|
+
- lib/bundler/codegraph/runtime_dir.rb
|
|
30
|
+
- lib/bundler/codegraph/subprocess.rb
|
|
31
|
+
- lib/bundler/codegraph/version.rb
|
|
32
|
+
- plugins.rb
|
|
33
|
+
homepage: https://github.com/jbox-web/bundler-codegraph
|
|
34
|
+
licenses:
|
|
35
|
+
- MIT
|
|
36
|
+
metadata: {}
|
|
37
|
+
rdoc_options: []
|
|
38
|
+
require_paths:
|
|
39
|
+
- lib
|
|
40
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
41
|
+
requirements:
|
|
42
|
+
- - ">="
|
|
43
|
+
- !ruby/object:Gem::Version
|
|
44
|
+
version: 3.2.0
|
|
45
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '0'
|
|
50
|
+
requirements: []
|
|
51
|
+
rubygems_version: 4.0.20
|
|
52
|
+
specification_version: 4
|
|
53
|
+
summary: Build a CodeGraph index for every gem Bundler installs.
|
|
54
|
+
test_files: []
|