Skip to content

Commit 295f2bd

Browse files
authored
Add deno lockfile support (#15153)
* Add Deno helpers module for invoking the CLI * Add Deno LockfileUpdater for regenerating deno.lock * Emit updated deno.lock from Deno file updater * Refresh Deno README implementation status * Refactor Deno lockfile updater: extract ManifestUpdater and tighten error handling * Truncate large deno install error output * Drift-proof Deno lockfile specs and document test requirements * Fix Sorbet strict-mode complaints in Deno helpers and manifest updater * CI feedback: SharedHelpers subprocess and stronger Sorbet sigils
1 parent 5deda55 commit 295f2bd

17 files changed

Lines changed: 662 additions & 33 deletions

File tree

deno/README.md

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,45 @@ Deno support for [`dependabot-core`][core-repo].
1515
[dependabot-core-dev] ~ $ cd deno && rspec
1616
```
1717

18+
The lockfile-regeneration specs (`spec/dependabot/deno/file_updater/lockfile_updater_spec.rb`) shell out
19+
to a real `deno install` and hit the JSR/npm registries. They expect the `deno` binary on `PATH` and
20+
network access — both are provided by the `bin/docker-dev-shell deno` image, but local runs outside
21+
the container need them too.
22+
1823
[core-repo]: https://github.com/dependabot/dependabot-core
1924

2025
### Implementation Status
2126

22-
This ecosystem is currently under development. See [NEW_ECOSYSTEMS.md](../NEW_ECOSYSTEMS.md) for implementation guidelines.
23-
2427
#### Required Classes
25-
- [ ] FileFetcher
26-
- [ ] FileParser
27-
- [ ] UpdateChecker
28-
- [ ] FileUpdater
28+
- [x] FileFetcher
29+
- [x] FileParser
30+
- [x] UpdateChecker
31+
- [x] FileUpdater (manifest + `deno.lock` regeneration)
2932

3033
#### Optional Classes
31-
- [ ] MetadataFinder
32-
- [ ] Version
33-
- [ ] Requirement
34+
- [x] MetadataFinder (npm sources; jsr returns nil)
35+
- [x] Version
36+
- [x] Requirement
3437

3538
#### Supporting Infrastructure
36-
- [ ] Comprehensive unit tests
37-
- [ ] CI/CD integration
38-
- [ ] Documentation
39+
- [x] Comprehensive unit tests
40+
- [x] CI/CD integration
41+
- [x] Documentation
42+
43+
### Supported
44+
45+
- `deno.json` and `deno.jsonc` import maps
46+
- `jsr:` and `npm:` specifiers (scoped, unscoped, versionless, sub-path)
47+
- `deno.lock` regeneration when the manifest changes
48+
- Cooldown for direct dependencies
49+
50+
### Not yet supported (planned)
51+
52+
- HTTPS imports (`https://deno.land/x/...`)
53+
- `scopes` field overrides
54+
- `vendor/` directory regeneration
55+
- Workspaces (nested `deno.json`)
56+
- `links` field (local package overrides)
57+
- `DENO_AUTH_TOKENS` / private registries
58+
- Frozen-lockfile UX (we pass `--frozen=false` and may overwrite a frozen lockfile)
59+
- Custom lockfile path (`"lock": { "path": "..." }`)

deno/lib/dependabot/deno.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
require "dependabot/deno/file_updater"
1010
require "dependabot/deno/metadata_finder"
1111
require "dependabot/deno/package/package_details_fetcher"
12+
require "dependabot/deno/helpers"
1213
require "dependabot/deno/version"
1314
require "dependabot/deno/requirement"
1415

deno/lib/dependabot/deno/file_updater.rb

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# typed: strict
1+
# typed: strong
22
# frozen_string_literal: true
33

44
require "dependabot/file_updaters"
@@ -9,6 +9,9 @@ module Deno
99
class FileUpdater < Dependabot::FileUpdaters::Base
1010
extend T::Sig
1111

12+
require_relative "file_updater/manifest_updater"
13+
require_relative "file_updater/lockfile_updater"
14+
1215
MANIFEST_FILENAMES = T.let(%w(deno.json deno.jsonc).freeze, T::Array[String])
1316

1417
sig { override.returns(T::Array[Dependabot::DependencyFile]) }
@@ -24,6 +27,13 @@ def updated_dependency_files
2427
updated_files << updated_file(file: file, content: new_content)
2528
end
2629

30+
if lockfile
31+
updated_files << updated_file(
32+
file: T.must(lockfile),
33+
content: lockfile_updater.updated_lockfile_content
34+
)
35+
end
36+
2737
updated_files
2838
end
2939

@@ -36,28 +46,29 @@ def check_required_files
3646
raise "No deno.json or deno.jsonc found!"
3747
end
3848

39-
sig { params(file: Dependabot::DependencyFile).returns(String) }
40-
def update_manifest_content(file)
41-
content = T.must(file.content)
42-
43-
dependencies.each do |dep|
44-
prev_reqs = dep.previous_requirements&.select { |r| r[:file] == file.name } || []
45-
new_reqs = dep.requirements.select { |r| r[:file] == file.name }
46-
47-
prev_reqs.zip(new_reqs).each do |prev_req, new_req|
48-
source_type = prev_req[:source][:type]
49-
prev_req_str = prev_req[:requirement]
50-
new_req_str = T.must(new_req)[:requirement]
51-
52-
base = "#{source_type}:#{dep.name}"
53-
old_specifier = prev_req_str ? "#{base}@#{prev_req_str}" : base
54-
new_specifier = "#{base}@#{new_req_str}"
49+
sig { returns(T.nilable(Dependabot::DependencyFile)) }
50+
def lockfile
51+
@lockfile ||= T.let(
52+
dependency_files.find { |f| f.name == "deno.lock" },
53+
T.nilable(Dependabot::DependencyFile)
54+
)
55+
end
5556

56-
content = content.gsub(%r{#{Regexp.escape(old_specifier)}(?=["/])}, new_specifier)
57-
end
58-
end
57+
sig { returns(LockfileUpdater) }
58+
def lockfile_updater
59+
@lockfile_updater ||= T.let(
60+
LockfileUpdater.new(
61+
dependencies: dependencies,
62+
dependency_files: dependency_files,
63+
credentials: credentials
64+
),
65+
T.nilable(LockfileUpdater)
66+
)
67+
end
5968

60-
content
69+
sig { params(file: Dependabot::DependencyFile).returns(String) }
70+
def update_manifest_content(file)
71+
ManifestUpdater.new(dependencies: dependencies, manifest: file).updated_manifest_content
6172
end
6273
end
6374
end
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# typed: strong
2+
# frozen_string_literal: true
3+
4+
require "json"
5+
require "sorbet-runtime"
6+
7+
require "dependabot/dependency"
8+
require "dependabot/dependency_file"
9+
require "dependabot/errors"
10+
require "dependabot/shared_helpers"
11+
require "dependabot/deno/file_updater"
12+
require "dependabot/deno/file_updater/manifest_updater"
13+
require "dependabot/deno/helpers"
14+
15+
module Dependabot
16+
module Deno
17+
class FileUpdater < Dependabot::FileUpdaters::Base
18+
class LockfileUpdater
19+
extend T::Sig
20+
21+
LOCKFILE_FILENAME = T.let("deno.lock", String)
22+
23+
sig do
24+
params(
25+
dependencies: T::Array[Dependabot::Dependency],
26+
dependency_files: T::Array[Dependabot::DependencyFile],
27+
credentials: T::Array[Dependabot::Credential]
28+
).void
29+
end
30+
def initialize(dependencies:, dependency_files:, credentials:)
31+
@dependencies = dependencies
32+
@dependency_files = dependency_files
33+
# Reserved for DENO_AUTH_TOKENS / private registry support — accepted now
34+
# so callers don't need a signature change when that lands.
35+
@credentials = credentials
36+
end
37+
38+
sig { returns(String) }
39+
def updated_lockfile_content
40+
@updated_lockfile_content ||= T.let(
41+
regenerate_lockfile,
42+
T.nilable(String)
43+
)
44+
end
45+
46+
private
47+
48+
sig { returns(T::Array[Dependabot::Dependency]) }
49+
attr_reader :dependencies
50+
51+
sig { returns(T::Array[Dependabot::DependencyFile]) }
52+
attr_reader :dependency_files
53+
54+
sig { returns(T::Array[Dependabot::Credential]) }
55+
attr_reader :credentials
56+
57+
sig { returns(String) }
58+
def regenerate_lockfile
59+
# Deno rewrites `deno.lock` holistically (not surgically) when its
60+
# input manifest references newer constraints. Don't try to
61+
# preserve unrelated entries here — that's deno install's job.
62+
#
63+
# Note on error detection: `deno install` exits 0 even when a
64+
# specifier can't be resolved (missing package, unsatisfiable
65+
# constraint) — it just silently leaves the lockfile unchanged.
66+
# The byte-equal check below is the primary defense; the rescue
67+
# wraps the rare-but-real cases where deno does exit non-zero
68+
# (malformed config, binary missing, filesystem errors).
69+
original_lockfile_content = T.must(lockfile.content)
70+
71+
new_content =
72+
begin
73+
SharedHelpers.in_a_temporary_directory do |dir|
74+
write_temporary_files(dir.to_s)
75+
Helpers.run_deno_command("install", "--frozen=false", dir: dir.to_s)
76+
File.read(File.join(dir.to_s, LOCKFILE_FILENAME))
77+
end
78+
rescue SharedHelpers::HelperSubprocessFailed, Errno::ENOENT => e
79+
raise Dependabot::DependencyFileNotResolvable, e.message
80+
end
81+
82+
if new_content == original_lockfile_content
83+
raise Dependabot::DependencyFileNotResolvable,
84+
"deno install did not change #{LOCKFILE_FILENAME}; manifest bump did not take effect"
85+
end
86+
87+
new_content
88+
end
89+
90+
sig { params(dir: String).void }
91+
def write_temporary_files(dir)
92+
File.write(File.join(dir, manifest.name), updated_manifest_content)
93+
File.write(File.join(dir, LOCKFILE_FILENAME), T.must(lockfile.content))
94+
end
95+
96+
sig { returns(String) }
97+
def updated_manifest_content
98+
ManifestUpdater.new(dependencies: dependencies, manifest: manifest).updated_manifest_content
99+
end
100+
101+
sig { returns(Dependabot::DependencyFile) }
102+
def manifest
103+
@manifest ||= T.let(
104+
T.must(dependency_files.find { |f| FileUpdater::MANIFEST_FILENAMES.include?(f.name) }),
105+
T.nilable(Dependabot::DependencyFile)
106+
)
107+
end
108+
109+
sig { returns(Dependabot::DependencyFile) }
110+
def lockfile
111+
@lockfile ||= T.let(
112+
T.must(dependency_files.find { |f| f.name == LOCKFILE_FILENAME }),
113+
T.nilable(Dependabot::DependencyFile)
114+
)
115+
end
116+
end
117+
end
118+
end
119+
end
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "sorbet-runtime"
5+
6+
require "dependabot/dependency"
7+
require "dependabot/dependency_file"
8+
require "dependabot/deno/file_updater"
9+
10+
module Dependabot
11+
module Deno
12+
class FileUpdater
13+
class ManifestUpdater
14+
extend T::Sig
15+
16+
sig do
17+
params(
18+
dependencies: T::Array[Dependabot::Dependency],
19+
manifest: Dependabot::DependencyFile
20+
).void
21+
end
22+
def initialize(dependencies:, manifest:)
23+
@dependencies = dependencies
24+
@manifest = manifest
25+
end
26+
27+
sig { returns(String) }
28+
def updated_manifest_content
29+
content = T.must(manifest.content).dup
30+
31+
dependencies.each do |dep|
32+
prev_reqs = (dep.previous_requirements || []).select { |r| r[:file] == manifest.name }
33+
new_reqs = dep.requirements.select { |r| r[:file] == manifest.name }
34+
35+
prev_reqs.zip(new_reqs).each do |prev_req, new_req|
36+
content = apply_substitution(content, dep, prev_req, T.must(new_req))
37+
end
38+
end
39+
40+
content
41+
end
42+
43+
private
44+
45+
sig { returns(T::Array[Dependabot::Dependency]) }
46+
attr_reader :dependencies
47+
48+
sig { returns(Dependabot::DependencyFile) }
49+
attr_reader :manifest
50+
51+
sig do
52+
params(
53+
content: String,
54+
dep: Dependabot::Dependency,
55+
prev_req: T::Hash[Symbol, T.untyped],
56+
new_req: T::Hash[Symbol, T.untyped]
57+
).returns(String)
58+
end
59+
def apply_substitution(content, dep, prev_req, new_req)
60+
source_type = prev_req[:source][:type]
61+
prev_req_str = prev_req[:requirement]
62+
new_req_str = new_req[:requirement]
63+
64+
base = "#{source_type}:#{dep.name}"
65+
old_specifier = prev_req_str ? "#{base}@#{prev_req_str}" : base
66+
new_specifier = "#{base}@#{new_req_str}"
67+
68+
content.gsub(%r{#{Regexp.escape(old_specifier)}(?=["/])}, new_specifier)
69+
end
70+
end
71+
end
72+
end
73+
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# typed: strong
2+
# frozen_string_literal: true
3+
4+
require "sorbet-runtime"
5+
6+
require "dependabot/shared_helpers"
7+
8+
module Dependabot
9+
module Deno
10+
module Helpers
11+
extend T::Sig
12+
13+
# Wraps `deno <args>` via Dependabot's standard subprocess helper, so
14+
# failures surface as Dependabot::SharedHelpers::HelperSubprocessFailed
15+
# (consistent with cargo / bun / npm_and_yarn). DENO_DIR is scoped to
16+
# the working directory so concurrent jobs don't trample each other's
17+
# module cache.
18+
sig do
19+
params(
20+
args: String,
21+
dir: String
22+
).returns(String)
23+
end
24+
def self.run_deno_command(*args, dir:)
25+
Dependabot::SharedHelpers.run_shell_command(
26+
"deno #{args.join(' ')}",
27+
cwd: dir,
28+
env: { "DENO_DIR" => File.join(dir, ".deno_cache") }
29+
)
30+
end
31+
end
32+
end
33+
end

0 commit comments

Comments
 (0)