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 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
+ [![GitHub license](https://img.shields.io/github/license/jbox-web/bundler-codegraph.svg)](https://github.com/jbox-web/bundler-codegraph/blob/master/LICENSE)
4
+ [![CI](https://github.com/jbox-web/bundler-codegraph/workflows/CI/badge.svg)](https://github.com/jbox-web/bundler-codegraph/actions)
5
+ [![Maintainability](https://qlty.sh/gh/jbox-web/projects/bundler-codegraph/maintainability.svg)](https://qlty.sh/gh/jbox-web/projects/bundler-codegraph)
6
+ [![Code Coverage](https://qlty.sh/gh/jbox-web/projects/bundler-codegraph/coverage.svg)](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: []