SPIKE: feat(bundler): add native Gemfile.lock parser and resolver#209
Draft
JamesPatrickGill wants to merge 3 commits into
Draft
SPIKE: feat(bundler): add native Gemfile.lock parser and resolver#209JamesPatrickGill wants to merge 3 commits into
JamesPatrickGill wants to merge 3 commits into
Conversation
Adds pkg/ecosystems/ruby/bundler/ with a native Go parser for Bundler's Gemfile.lock format and a SCAPlugin that turns it into a Snyk dep graph. Approach: NATIVE — forced exception to the "delegate to the package manager's CLI" principle. Every bundler subcommand that emits a dep graph requires `bundle install` first (mutates the project dir); there is no install-free CLI path. The lockfile is well-spec'd plain text, so native parsing in ~150 LoC (ported from @snyk/gemfile) is the only offline + install-free option. Coverage: - All top-level sections: GEM, GIT, PATH, PLATFORMS, DEPENDENCIES, BUNDLED WITH, RUBY VERSION. - Per-spec source metadata (remote/ref/branch/tag/glob). - DEPENDENCIES `!` marker stripped (e.g. `rspec!` → `rspec`). - `bundler` itself omitted from the dep tree (matches legacy silent drop driven by `if (gemspec)` guard). - Cycle detection via DFS ancestor chain → CycleError (matches legacy 422 semantics). - Group separation hook (BuildOptions.IncludeDev) — wired but no-op today because Gemfile.lock doesn't encode groups; the hook is in place for a future Gemfile-aware enhancement noted in the migration plan as deliberate intent. Honors --target-file (Gemfile or Gemfile.lock), --all-projects, --project-name, --exclude-paths. No orchestrator wiring (deliberately deferred per PR 204 convention). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
33 test cases across parser, dep-graph, and plugin layers: Parser (parser_test.go, 12 cases): - Simple GEM block with PLATFORMS/DEPENDENCIES/BUNDLED WITH - Nested children (indent depth 6) with version constraints stripped - GIT block + `!` marker stripping on DEPENDENCIES entries - PATH block (gemspec project) - Multiple PLATFORMS entries - RUBY VERSION line - Empty input / nil reader guard - DEPENDENCIES variants: bare, pinned, versioned, versioned-and-pinned - parseSpecLine direct table cases - parseDependencyLine direct table cases DepGraph (depgraph_test.go, 9 cases): - Simple two-dep graph with rubygems pkg-manager metadata - Transitive resolution with shared ancestry - Bundler exclusion as both DEPENDENCIES entry and spec child - Missing-spec graceful skip (matches legacy) - Cycle detection via DFS ancestor chain → CycleError - Diamond pattern (shared transitive) — guards against false-cycle bug - IncludeDev option plumbing - Nil-lockfile / empty-root guards Plugin (plugin_test.go, 12 cases): - Plugin name + identity contract (rootPkg.name = basename(dir)) - No-lockfile returns empty (not an error) - --target-file accepting Gemfile (resolves to sibling .lock) - --target-file accepting Gemfile.lock directly - --target-file rejecting non-gemfile targets - Custom gemfile names (e.g. rails.2.4.5.gemfile.lock) - --all-projects discovers nested lockfiles with distinct rootPkg names - --project-name overrides basename default - --exclude-paths honored at discovery layer - Unreadable lockfile handled gracefully - --dev flag plumbed from options to BuildDepGraphWithOptions - Nil-options entry tolerated Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds plugin_integration_test.go (build-tag-gated:
//go:build integration && bundler) plus three smoke fixtures and the
Makefile target to run them.
Fixtures (testdata/acceptance/):
- simple/ — GEM-only lockfile (json + lynx)
- with-git-source/ — two GIT blocks; tests `!` marker stripping for
rspec! and nokogiri (= 1.0.0)!
- with-dev-group/ — Gemfile groups :development/:test (json/pry/rspec
+ transitive); proves the dev-group hook is
wired even while group filtering is a no-op
Each fixture commits an expected.json golden. The suite re-runs with
`-update` to refresh: see comment block at the top of the test file.
Critically, the acceptance harness asserts that no `vendor/` or
`.bundle/` directory ever appears in the fixture directory before or
after the run — the principle-#2 ("install free") gate for this
ecosystem. Bundler's install path would create those; the native
parser must never.
Makefile: adds `test-bundler-integration` (mirrors the
test-python-integration / test-gradle-integration convention).
Run: `make test-bundler-integration`
or `go test -v -tags="integration,bundler" \
./pkg/ecosystems/ruby/bundler/...`
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
Adds a new SCA plugin at
pkg/ecosystems/ruby/bundler/that resolves Bundler dep graphs by natively parsingGemfile.lock— nobundle install, nobundleCLI invocation, novendor/or.bundle/directories created.The package is self-contained and not wired into the orchestrator in this PR; that lands in a follow-up. The legacy in-CLI plugin at
cli/src/lib/plugins/rubygems/is untouched and continues to serve production scans.Why
The legacy plugin is bundled inside the
clirepo itself (the only ecosystem laid out that way) and uses the@snyk/gemfilenpm package to parseGemfile.locknatively. The principled choice here was a quick audit: every first-party bundler subcommand that emits dep info requiresbundle installfirst — which would mutate the project dir, violate the offline principle, and ship gems intovendor/bundleor the system gemset. The communitybundler-graphplugin has the same install requirement.So native parsing is forced — and
Gemfile.lockis plain text with a well-spec'd structure (GEM / GIT / PATH / PLATFORMS / DEPENDENCIES / BUNDLED WITH / RUBY VERSION blocks). The@snyk/gemfileparser is ~150 LoC of straightforward state-machine parsing; porting to Go is low risk.See the Plugin Audit for the broader ecosystem migration context.
What's in this PR
3 commits, scoped narrowly to the new package:
feat(bundler): add native Gemfile.lock parser and resolvertypes.go(SpecSource, SourceMeta, Spec, Dependency, Lockfile),parser.go(indent-driven state machine, ~295 LoC),depgraph.go(DFS resolver, bundler exclusion,CycleError,BuildOptionshook),plugin.go(SCAPlugin impl, discovery).test(bundler): unit tests for parser, depgraph, plugin!stripping), bundler exclusion, diamond/cycle distinction, group plumbing.test(bundler): integration test scaffold with smoke fixtures//go:build integration && bundler) acceptance harness +make test-bundler-integrationtarget. 3 fixtures (simple,with-git-source,with-dev-group) withexpected.jsongoldens. 85.5% integration coverage.Nothing under
pkg/ecosystems/orchestrator/is touched, so this won't conflict with other in-flight resolver work.How it works
Native parse of
Gemfile.lockusing an indent-driven state machine: top-level keywords (GEM,GIT,PATH,PLATFORMS,DEPENDENCIES,BUNDLED WITH,RUBY VERSION) switch the parser mode; indentedspecs:entries with their transitive dependencies are accumulated per source.DepGraph construction is a DFS resolver. Two-set cycle handling:
visitedtracks the ancestor stack (popped on the way back up),addedtracks nodes already in the graph (for the diamond / shared-transitive case). A repeat visit while still on the ancestor stack raises*CycleError{Gem, Chain}— matching legacy 422 semantics.TestBuildDepGraph_DiamondNoFalseCycleguards against the classic visited-set bug where shared transitives get miscounted as cycles.Git
!markers on dep names (e.g.rspec!) are stripped to match legacy. Bundler itself is silently omitted from the dep graph (legacy behavior).Divergences from the in-CLI legacy plugin
cli/src/lib/plugins/rubygems/)@snyk/gemfile.!marker*CycleError{Gem, Chain}.--devBuildOptions.IncludeDevis wired fromoptions.Global.IncludeDev, butGemfile.lockdoesn't encode group membership. Real group filtering would require parsingGemfile(not the lockfile) to learn which gems belong to:test/:developmentgroups. Hook left in place for the follow-up.basename(scanRoot); orpath.join(basePackageName, dir)whenallSubProjects=true.--all-projectswe suffix with the relative dir for distinct root names.CLI options honored
--dev/-doptions.go:GlobalOptions.IncludeDevBuildOptions.IncludeDev; no-op until Gemfile parsing lands--all-projectsoptions.go:GlobalOptions.AllProjectsallSubProjects→ affects naming--target-fileoptions.go:GlobalOptions.TargetFile--exclude-pathsWhat's NOT in this PR
cli/src/lib/plugins/rubygems/— that stays put until orchestrator GA.Gemfileparsing for true group separation.A follow-up PR will land the orchestrator registration and (separately) the Gemfile parser for group filtering.
How to verify
Caveats for reviewers
--devgroup separation: plumbed but no-op. The plan called this out as an intentional enhancement over legacy, but the agent correctly noted you'd need Gemfile parsing (not just Gemfile.lock) to actually filter groups. Hook is in place; full implementation deferred.allSubProjectsnaming convention should be cross-checked before merge.cli-extension-dep-graph; the existingcli/src/lib/plugins/rubygems/keeps running for production scans until the orchestrator FF flips.Risk
The new package is not registered with the orchestrator, so it has no effect on production behaviour. The in-CLI legacy plugin keeps serving scans. Reviewers can focus on the plugin code in isolation.
🔬 SPIKE: exploratory work for the multi-ecosystem migration shipped as a draft for early review.