Introduction
deps-lsp is a universal Language Server Protocol server for dependency management. A single
binary provides hover, completion, diagnostics, code actions, code lens, and inlay hints for
outdated, unknown, yanked, vulnerable, and unsatisfiable dependencies across 14 package
ecosystems — Cargo, npm, Deno, PyPI, Go, Bundler, Dart, Maven, Gradle, Swift, Composer, NuGet,
GitHub Actions, and GitLab CI/CD — instead of requiring a separate extension per language.
A companion binary, deps-cli, runs the same checks from the command line —
routing every manifest through the identical classification pipeline — for CI pipelines,
pre-commit hooks, and shell scripts where an editor isn’t involved. A ready-made
GitHub Action wraps it for CI with zero setup beyond a workflow file.
For installation, editor setup, and LSP configuration options, see the root
README.md. This book does not duplicate that
material; it covers instead:
- What each ecosystem supports today — the Ecosystem Reference chapters, and the cross-cutting behaviors (Cross-Ecosystem Features) that apply the same way across most or all of them.
deps-cliand CI integration — the command-line reference for CI/pre-commit/shell use, and the ready-made GitHub Action that wraps it.- How it’s built — Architecture & Internals, for the
Ecosystemtrait, registry clients, caching, and the LSP handler dispatch underneath the behaviors above. - How to add support for a new ecosystem — the
Adding a New Ecosystem contributor track, for anyone extending
deps-lspitself.
Note: API documentation generated from the Rust source (
cargo doc) is published separately — see API Documentation.
Supported Ecosystems
| Ecosystem | Language | Manifest File(s) | Lock File(s) | Highlights |
|---|---|---|---|---|
| Cargo | Rust | Cargo.toml | Cargo.lock | Hover, inlay hints, completion, code actions, diagnostics, code lens, feature flag completion, alternate/private registry resolution via .cargo/config.toml |
| npm | JavaScript/TypeScript | package.json | package-lock.json, pnpm-lock.yaml | Hover, inlay hints, completion, code actions, diagnostics, code lens, custom/private registry resolution via .npmrc, pnpm workspace catalog (catalog:/catalog:<name>) resolution via pnpm-workspace.yaml |
| PyPI | Python | pyproject.toml, requirements.txt, constraints.txt (also recognized under a requirements/ directory, e.g. requirements/base.txt) | poetry.lock, uv.lock | Hover with PEP 508 environment marker display (“Active when: <marker>”), inlay hints, completion, code actions, diagnostics, code lens, document links for -r/-c/--requirement/--constraint file references, private/custom index resolution via --index-url/--extra-index-url, Poetry [[tool.poetry.source]], and uv [tool.uv.index]/[tool.uv.sources] |
| Go | Go | go.mod | go.sum | Hover, inlay hints, completion, code actions, diagnostics, code lens, pseudo-version support, $GOENV GOPROXY/GOPRIVATE proxy-chain resolution |
| Bundler | Ruby | Gemfile | Gemfile.lock | Hover, inlay hints, completion, code actions, diagnostics, code lens, custom-source classification (source/git/path blocks and per-gem options, modern and legacy hash-rocket syntax) |
| Dart | Dart | pubspec.yaml | pubspec.lock | Hover with corrected version ordering (prereleases sort below base release), inlay hints, completion, code actions, diagnostics, code lens, YAML anchor/alias resolution for whole dependency sections and environment:, hosted: custom-registry classification |
| Maven | Java | pom.xml | maven-metadata.xml (CDN) | Hover with corrected version ordering (numeric segments outrank qualifiers, prereleases sort below base release), inlay hints, completion, code actions, diagnostics, code lens (property-versioned dependencies not covered) |
| Gradle | Kotlin/Groovy | build.gradle, build.gradle.kts, gradle/libs.versions.toml | — | Hover with corrected version ordering (same as Maven), inlay hints, completion, code actions, diagnostics, code lens (variable/catalog-versioned dependencies not covered), variable resolution (gradle.properties) |
| Composer | PHP | composer.json | composer.lock | Hover, inlay hints, completion, code actions, diagnostics, code lens (requirement matching and “latest version” selection both use corrected stability-qualifier ordering) |
| Swift | Swift | Package.swift | Package.resolved | Hover, inlay hints, completion, code actions, diagnostics, code lens (range-form dependencies not covered), GitHub API support |
| NuGet | .NET | .csproj, .fsproj, .vbproj, Directory.Packages.props, packages.config | packages.lock.json, packages.<project>.lock.json (multi-project) | Hover, inlay hints, completion, code actions, diagnostics, code lens, central package management support, SemVer2 prerelease handling, hover-only unlisted-version marker, private/custom feed resolution via NuGet.Config |
| Deno | JavaScript/TypeScript (Deno runtime) | deno.json, deno.jsonc | — (no deno.lock support yet) | Hover, inlay hints, completion, code actions, diagnostics, code lens — jsr: specifiers via the keyless JSR API, npm: specifiers delegate to the same registry client npm uses; imports map only, scopes/importMap not covered |
| GitHub Actions | YAML | .github/workflows/*.yml, *.yaml; action.yml, action.yaml (composite/Docker/JS actions — a repository root or .github/actions/<name>/, issue #706) | — (no lock file) | Hover, inlay hints, code actions, diagnostics, code lens (package-name completion not covered); tag/commit-SHA/branch uses: pins via the GitHub tags API; reusable-workflow calls recognized but not version-resolved; release-age hint and cooldown diagnostic require GITHUB_TOKEN |
| GitLab CI/CD | YAML | .gitlab-ci.yml, .gitlab/ci/*.yml, *.yaml | — (no lock file) | Hover, inlay hints, code actions, diagnostics, code lens (package-name completion not covered); project:+ref: pins via the GitLab repository-tags API, component: CI/CD Catalog pins via the GitLab project-releases API (SHA/exact-release/~latest/partial-semver priority ladder); self-hosted instances via registries.gitlab_instance_host; scalar YAML anchor/alias resolution within include: |
Many of the behaviors above are shared across several ecosystems rather than reimplemented per crate — see Cross-Ecosystem Features for the conventions and diagnostics that apply the same way everywhere they’re listed.
Editor Setup
deps-lsp speaks standard LSP over stdio, so any LSP-capable editor can use it. The
README covers the quickest way to get it running in
Zed; this page has the full per-editor reference, including editors with no first-party
extension.
Note: Inlay hints, code lens, and (in some editors) inline diagnostics are off by default at the editor level, independent of
deps-lsp’s owninitialization_options. The server always advertises support for all three — each section below covers the editor-side toggle needed to actually see them.
Zed
Install the Deps extension from the Zed Extensions marketplace. Ruby support is enabled for
Gemfile files.
Enable inlay hints, code lens, and (optionally) inline diagnostics in Zed settings:
{
"inlay_hints": {
"enabled": true
},
"code_lens": "on",
"diagnostics": {
"inline": {
"enabled": true
}
}
}
code_lens accepts "on", "off" (default), or "menu", and is required for the “Update N
outdated dependencies” lens to appear. diagnostics.inline is optional — diagnostics already show
in the gutter and Problems panel without it; this additionally renders deps-lsp’s short one-line
messages inline next to each dependency.
Neovim
require('lspconfig').deps_lsp.setup({
cmd = { "deps-lsp", "--stdio" },
filetypes = { "toml", "json", "gomod", "ruby", "yaml", "xml", "swift", "php", "requirements" },
})
-- Enable inlay hints (Neovim 0.10+)
vim.lsp.inlay_hint.enable(true)
For older Neovim versions, use nvim-lsp-inlayhints.
Code lens is not refreshed or rendered automatically by Neovim’s built-in client — wire it up
via an LspAttach autocommand:
vim.api.nvim_create_autocmd("LspAttach", {
callback = function(args)
local client = vim.lsp.get_client_by_id(args.data.client_id)
if client and client:supports_method("textDocument/codeLens") then
vim.lsp.codelens.refresh({ bufnr = args.buf })
vim.api.nvim_create_autocmd({ "BufEnter", "CursorHold", "InsertLeave" }, {
buffer = args.buf,
callback = function() vim.lsp.codelens.refresh({ bufnr = args.buf }) end,
})
end
end,
})
vim.keymap.set("n", "<leader>cl", vim.lsp.codelens.run, { desc = "Run code lens" })
Warning: Neovim 0.11 changed diagnostic virtual text (inline diagnostics) from opt-out to opt-in. On 0.11+, run
vim.diagnostic.config({ virtual_text = true })ifdeps-lsp’s warnings aren’t appearing inline — on 0.10 and earlier this was already the default.
Helix
# ~/.config/helix/languages.toml
[[language]]
name = "toml"
language-servers = ["deps-lsp"]
[[language]]
name = "json"
language-servers = ["deps-lsp"]
[language-server.deps-lsp]
command = "deps-lsp"
args = ["--stdio"]
Enable inlay hints in Helix config:
# ~/.config/helix/config.toml
[editor.lsp]
display-inlay-hints = true
Diagnostics render inline by default with no configuration needed.
Note: Helix does not implement
textDocument/codeLens— the “Update N outdated dependencies” batch action is unavailable there; use the per-dependency code action (Cmd+./Ctrl+.equivalent) instead.
VS Code
Install an LSP client extension and configure deps-lsp. Enable inlay hints:
{
"editor.inlayHints.enabled": "on"
}
editor.codeLens is true by default in VS Code itself, so deps-lsp’s code lens should appear
automatically — provided your chosen generic LSP client extension forwards the codeLens
capability (most do; check its documentation if the lens doesn’t show up). Diagnostics render as
squiggles plus entries in the Problems panel by default; for an always-visible inline message next
to each dependency, install the third-party
Error Lens extension.
Emacs (eglot)
(with-eval-after-load 'eglot
(add-to-list 'eglot-server-programs
'((conf-toml-mode yaml-mode json-mode) . ("deps-lsp" "--stdio"))))
Note:
eglotmanages one server per buffer by default, so runningdeps-lspalongside a primary language server for the same buffer (e.g.rust-analyzeronCargo.toml) needseglot’s multi-server support rather than this snippet alone.
Emacs (lsp-mode)
A first-party lsp-mode client is tracked in
#712; until it ships, register deps-lsp
manually as an add-on server:
(with-eval-after-load 'lsp-mode
(lsp-register-client
(make-lsp-client
:new-connection (lsp-stdio-connection '("deps-lsp" "--stdio"))
:activation-fn (lsp-activate-on 'toml-mode 'json-mode 'yaml-mode)
:add-on? t
:server-id 'deps-lsp)))
:add-on? t is required so deps-lsp runs in addition to, not instead of, the buffer’s primary
server.
Sublime Text (LSP package)
{
"clients": {
"deps-lsp": {
"enabled": true,
"command": ["deps-lsp", "--stdio"],
"selector": "source.toml | source.json | source.yaml"
}
}
}
Add to LSP.sublime-settings. The sublimelsp/LSP package runs multiple clients per view, so
this coexists with any primary language server already configured for the same selector.
Kate
{
"servers": {
"deps-lsp": {
"command": ["deps-lsp", "--stdio"],
"highlightingModeRegex": "^(TOML|JSON|YAML)$"
}
}
}
Add to Kate’s built-in LSP Client plugin settings (Settings → Configure Kate → LSP Client → User Server Settings). Kate supports multiple LSP servers per document, so this runs alongside any primary language server already registered for the same syntax.
coc.nvim
{
"languageserver": {
"deps-lsp": {
"command": "deps-lsp",
"args": ["--stdio"],
"filetypes": ["toml", "json", "yaml", "gomod", "ruby", "xml", "swift", "php", "requirements"]
}
}
}
Add to coc-settings.json (:CocConfig). coc.nvim attaches every configured languageserver
entry whose filetypes match, so this coexists with a primary language server for the same
filetype.
Configuration
deps-lsp is configured entirely through LSP initializationOptions (and updated live via
workspace/didChangeConfiguration) — there is no separate config file. The
README shows the most commonly changed options
inline; this page is the exhaustive reference for every section and option.
For the inlay hint icons and the hover/diagnostic/code lens text conventions those options control, see Conventions.
Configuration Reference
| Section | Option | Default | Description |
|---|---|---|---|
inlay_hints | enabled | true | Show inline version annotations next to each dependency |
inlay_hints | up_to_date_text | "✅" | Text shown when the dependency is up to date |
inlay_hints | needs_update_text | "❌ {}" | Text shown when an update exists; {} is replaced with the latest version |
cold_start | enabled | true | Load previously opened files from disk at startup so features work before the editor sends didOpen |
cold_start | rate_limit_ms | 100 | Minimum delay in milliseconds between cold-start registry fetches for the same URI |
cache | enabled | true | Whether the HTTP entry-map cache is used at all; false fetches fresh on every request and never stores. Overridden to behave as true while network.offline is set |
cache | fetch_timeout_secs | 5 | Per-package fetch timeout (1-300 seconds) |
cache | max_concurrent_fetches | 20 | Concurrent registry requests (1-100) |
loading_indicator | enabled | true | Show loading feedback during fetches |
loading_indicator | fallback_to_hints | true | Show loading in inlay hints if LSP progress unsupported |
loading_indicator | loading_text | "..." | Text shown during loading (max 100 chars) |
code_lens | enabled | true | Show the “Update N outdated dependencies” code lens, and (GitHub Actions/GitLab CI, gated additionally by diagnostics.mutable_ref_pin_enabled) the bulk “Pin N {noun} to commit SHA” code lens |
diagnostics | outdated_severity | "hint" | Severity for the outdated-version diagnostic |
diagnostics | unknown_severity | "warning" | Severity for an unresolvable/unknown package or version |
diagnostics | yanked_severity | "warning" | Severity for the yanked-version diagnostic |
diagnostics | unsatisfiable_severity | "warning" | Severity for the unsatisfiable-requirement diagnostic |
diagnostics | deprecated_severity | "warning" | Severity for the package-deprecation diagnostic |
diagnostics | mutable_ref_pin_severity | "hint" | Severity for the mutable-ref-pin diagnostic (GitHub Actions/GitLab CI) |
diagnostics | sha_comment_mismatch_severity | "warning" | Severity for the SHA-comment-mismatch diagnostic (GitHub Actions or GitLab CI/CD SHA pin whose # tag comment is not that commit’s tag) |
diagnostics | unknown_ref_severity | "warning" | Severity for the unknown-ref diagnostic (GitHub Actions or GitLab CI/CD tag pin whose ref is a full release no published tag matches); no on/off toggle |
diagnostics | mutable_ref_pin_enabled | true | Turns the mutable-ref-pin diagnostic and its bulk code lens off entirely — unlike the other diagnostics, severity alone cannot silence it |
diagnostics | vulnerabilities_enabled | true | Whether OSV.dev-backed vulnerability diagnostics run at all |
freshness | enabled | true | Flag a “latest” version still inside its cooldown window |
freshness | cooldown_secs | 259200 | Cooldown window in seconds (3 days), clamped to 0-30 days |
registries | workspace_registries | "public_only" | Which workspace-declared registry index hosts are ever fetched, across every ecosystem (Cargo’s .cargo/config.toml/[source], npm’s .npmrc, PyPI’s --index-url/Poetry/uv sources, Go’s $GOENV GOPROXY, NuGet’s NuGet.Config, Swift’s registries.json) — "public_only", "off", or "all"; see Cargo Custom/Private Registries, npm Custom/Private Registries, PyPI Custom/Private Indexes, Go GOPROXY/GOPRIVATE Support, NuGet Private/Custom Feeds, and Swift Package Registries. |
registries | nuget_user_profile_sources | false | Whether a NuGet user-profile-tier NuGet.Config source with no repo-declared counterpart becomes a routing hop (AlternateRegistry-sourced — OSV/deps.dev/hover-trust suppressed for it), instead of only ever supplying credentials for a matching repo-declared source; see NuGet Private/Custom Feeds |
registries | swift_keychain_credentials | "disabled" | "enabled" also reads macOS Keychain credentials for user-declared Swift SE-0292 registries (may show a macOS access prompt; ignored by deps-cli); see Swift macOS Keychain credentials |
registries | gitlab_instance_host | "" | The self-hosted GitLab instance host that a project: include and a $CI_SERVER_FQDN-relative component: include resolve against, which never receives GITLAB_TOKEN (the token goes only to gitlab.com or the host in the GITLAB_TOKEN_HOST environment variable). Unset ("") means neither form is version-resolved; see GitLab CI/CD Self-Hosted Instances |
network | offline | false | Block every outbound registry/OSV/GitHub request; already-cached data still serves, uncached dependencies show an offline marker |
supply_chain | enabled | true | Show the OpenSSF Scorecard/build-provenance hover line, backed by deps.dev requests; false disables the requests and the section entirely |
license_policy | allow | [] | SPDX identifiers a dependency’s license must include at least one of, when non-empty; produces a WARNING diagnostic otherwise. Invalid entries are dropped with a logged warning, not rejected. See License Policy Diagnostic |
license_policy | deny | [] | SPDX identifiers a dependency’s license must not include any of; produces an ERROR diagnostic when matched (wins over allow). Invalid entries are dropped with a logged warning, not rejected. See License Policy Diagnostic |
typosquat | enabled | false | Whether the typosquat-similarity diagnostic runs at all — opt-in, backed by deps.dev’s v3alpha GetSimilarlyNamedPackages/GetDependents endpoints |
gossip | enabled | false | Whether deps.dev GOSSIP signals (Dynamic Cooldown, low-usage) are fetched at all — opt-in, backed by deps.dev’s v3alpha GetFindingsBatch/GetFindings endpoints |
Editor workspace settings and trust
Editors merge project-level settings into the initializationOptions and
workspace/didChangeConfiguration payloads the server receives: Zed’s .zed/settings.json,
VS Code’s .vscode/settings.json, Helix’s .helix/languages.toml, and coc.nvim’s
.vim/coc-settings.json. The server cannot tell a repository-written value from a user-written
one, so any registries.* or diagnostics.* field can be set by a repository you open. Credentials
are therefore never bound to a settings value; they come from your process environment.
| Setting | What a repository can do with it |
|---|---|
registries.gitlab_instance_host | Redirect GitLab host resolution (unauthenticated requests only). GITLAB_TOKEN is not sent to it unless you also export the same host as GITLAB_TOKEN_HOST |
registries.workspace_registries | Set "all". Alone this reaches no private host: the private hosts and CIDR ranges in your DEPS_LSP_PRIVATE_REGISTRY_HOSTS environment variable are the effective control. Once you export that variable, any repository you open can send unauthenticated, blind GET requests to the listed hosts, on any port. Loopback, link-local, cloud-metadata, unspecified and reserved hosts are never reachable |
registries.nuget_user_profile_sources | Add user-profile NuGet sources as routing hops; credentials stay bound to the URL declared in your own user-level NuGet.Config |
registries.swift_keychain_credentials | Trigger a macOS Keychain access prompt; the credential goes only to registries declared in your user-level registries.json |
diagnostics.vulnerabilities_enabled, network.offline | Hide vulnerability findings or all registry data in the editor |
Warning: If your editor lets a repository’s settings change the server’s environment or binary (for example Zed’s
lsp.<id>.binary), that is already code execution under the editor’s own trust prompt, which this server cannot guard against.
Private registry allowlist
registries.workspace_registries = "all" allows public hosts plus the hosts listed in the
DEPS_LSP_PRIVATE_REGISTRY_HOSTS environment variable (read by both deps-lsp and deps-cli;
there is no settings or config-file field for it). It is a comma-separated list of CIDR ranges
(10.0.0.0/8), bare IPs and exact lowercase host names (registry.corp.internal, punycode for
non-ASCII), for example DEPS_LSP_PRIVATE_REGISTRY_HOSTS=10.20.0.0/16,registry.corp.internal.
- The setting is repository-controllable, so the variable, not the setting, is the effective control. List registry hosts or narrow CIDRs only; never a broad range.
- An allowlisted host is reachable on any port.
- Prefixes shorter than
/8(IPv4) or/16(IPv6), ports, paths, wildcards, brackets, zone ids and userinfo are rejected. Because of the/16minimum, a unique-local (fc00::/7) range must be listed as a/48or longer prefix (for examplefd12:3456:789a::/48), not asfc00::/7. - A CIDR cannot name a host by its DNS name. A declared
*.internal,*.localor single-label host (https://nexus/) is accepted when the list contains at least one CIDR, and then only connects if it resolves into a listed CIDR; list the host name itself to vouch for it regardless of the address it resolves to. One bad entry invalidates the whole variable (nothing is allowed) and the server warns once without echoing the value. Unset, empty,0andfalsemean no private host. - With
"all"but no valid variable the server behaves like"public_only"and shows a warning once per change. - The GitHub Action needs the variable in the step’s
env:.
When a registry host is blocked by the access policy, the hint that suggests
workspace_registries = "all" and DEPS_LSP_PRIVATE_REGISTRY_HOSTS is not shown at "off" or for a host refused under every policy (loopback, link-local,
cloud-metadata), where the allowlist cannot help.
Proxies and guarded registry traffic
Registry requests that the access policy guards (hosts declared by the workspace, pinned
registries, and operator-owned sources such as the $GOENV GOPROXY, your user ~/.npmrc and
your user-profile NuGet.Config) connect directly by default and bypass the system and
HTTP(S)_PROXY proxy. The connect-time SSRF guard checks the address a host actually resolves to,
and a proxy would hide that address from it. Public default registries (crates.io, npm, PyPI and
so on) keep using your proxy.
If a system proxy is detected, the server says so once at startup: deps-lsp logs a warning and
shows a window/showMessage warning, and deps-cli prints one line on stderr. To route guarded
traffic through the proxy as well, set:
DEPS_LSP_WORKSPACE_REGISTRY_PROXY=proxy
Any other value, including unset, keeps the direct default. The variable is read from the
environment only, never from settings, so a repository cannot change it. With it set, a proxy
whose own host is localhost or a private name works on every tier.
Routing through a proxy is weaker than the direct default:
- Weaker check. The server cannot see the address the proxy connects to. It resolves the target host itself and checks that answer against the policy first, but DNS can rebind between this local check and the proxy’s own lookup.
- Local DNS is required. On a network whose local DNS cannot resolve external names, every guarded request fails at that check (fail closed), even though the proxy could have reached the host.
- Same-host redirects only. Cross-host redirects are stopped, so a registry that redirects to a CDN host fails to resolve versions.
Blocked or unreachable guarded hosts fail after a 10 second connect timeout. In the GitHub
Action, set the variable in the step’s env:.
GITLAB_TOKEN is sent only to gitlab.com, or to the single host named by the
GITLAB_TOKEN_HOST environment variable when set (for example GITLAB_TOKEN_HOST=gitlab.mycorp.dev).
An invalid GITLAB_TOKEN_HOST disables the token entirely. See
GitLab CI/CD Self-Hosted Instances.
Full Example
{
"inlay_hints": {
"enabled": true,
"up_to_date_text": "✅",
"needs_update_text": "❌ {}"
},
"diagnostics": {
"outdated_severity": "hint",
"unknown_severity": "warning",
"yanked_severity": "warning",
"unsatisfiable_severity": "warning",
"deprecated_severity": "warning",
"mutable_ref_pin_severity": "hint",
"sha_comment_mismatch_severity": "warning",
"unknown_ref_severity": "warning",
"mutable_ref_pin_enabled": true,
"vulnerabilities_enabled": true
},
"freshness": {
"enabled": true,
"cooldown_secs": 259200
},
"cache": {
"enabled": true,
"fetch_timeout_secs": 5,
"max_concurrent_fetches": 20
},
"loading_indicator": {
"enabled": true,
"fallback_to_hints": true,
"loading_text": "..."
},
"cold_start": {
"enabled": true,
"rate_limit_ms": 100
},
"code_lens": {
"enabled": true
},
"registries": {
"workspace_registries": "public_only",
"nuget_user_profile_sources": false,
"gitlab_instance_host": "",
"swift_keychain_credentials": "disabled"
},
"network": {
"offline": false
},
"supply_chain": {
"enabled": true
},
"license_policy": {
"allow": [],
"deny": []
},
"typosquat": {
"enabled": false
},
"gossip": {
"enabled": false
}
}
Notes and Caveats
Note:
diagnostics.outdated_severity,diagnostics.unknown_severity,diagnostics.unsatisfiable_severity, anddiagnostics.yanked_severityare all honored end-to-end. The yanked diagnostic fires in two independent cases (never both at once for the same dependency): (1) the dependency’s in-use version — lock-file-resolved, or an exact pin such asrequirements.txt’s==1.2.3— is itself reported as yanked/deprecated/retracted, supported for Cargo, npm, PyPI, Bundler, and Dart; or (2) the dependency’s declared version requirement (a range) is currently satisfiable only by yanked versions, even with no lock file at all. See Yanked Version Diagnostic for exact semantics and per-ecosystem coverage of each case (RubyGems cannot be detected by either mechanism, since its registry omits yanked versions from the list entirely rather than flagging them).
Note:
diagnostics.deprecated_severityflags a dependency whose package — not a specific version — is reported as deprecated/abandoned (This package is deprecated: <reason>), with a matching hover section and, for Composer packages naming a successor, a “Replace with X” quick fix. Currently sourced from npm’sdeprecatedfield and Composer’sabandonedfield only. See Package Deprecation Diagnostics for the full ecosystem coverage table and how this differs from the yanked diagnostic above.
Note:
diagnostics.mutable_ref_pin_severityflags a GitHub Actions or GitLab CI dependency pinned to a mutable ref (a tag, e.g.actions/checkout@v4, or a GitLabcomponent:pinned via~latest/a partial version) instead of a full commit SHA — a supply-chain hardening recommendation independent of the outdated-version check above (a dependency can be both up to date and mutable). Comes with a “Pin<name>to commit SHA” quick fix, and a bulk “Pin N {noun} to commit SHA” code lens batching every resolvable one in the document, when the commit SHA is already known (GitHub Actions rewrites the ref to<sha> # <tag>; GitLab CI rewrites to a bare<sha>). Setdiagnostics.mutable_ref_pin_enabledtofalseto turn both the diagnostic and the bulk lens off entirely — unlike the other diagnostics above, severity alone cannot silence it. See Mutable-Ref-Pin Diagnostic and Bulk “Pin All to SHA” Code Lens for full details.
Note: The release-freshness signal applies uniformly across all ecosystems — there is no per-ecosystem override. Coverage depth varies with what each registry exposes (e.g. Deno’s
jsr:specifiers get full coverage at no extra request cost; Swift, GitHub Actions, and Maven/Gradle have partial coverage since their APIs don’t expose per-version publish dates directly). See Swift/GitHub Actions Release-Freshness Coverage and Maven/Gradle Release-Freshness Coverage for per-ecosystem details.
Note:
network.offlineblocks every outbound request the server makes (registry, OSV vulnerability, and GitHub tags), across every ecosystem. Already-cached data keeps serving; an uncached dependency shows an offline marker in inlay hints, and hover appends a footer stating that version and vulnerability data were not checked. Toggling it viaworkspace/didChangeConfigurationtakes effect immediately, with no editor restart. A change todiagnostics.*(for examplevulnerabilities_enabledor a*_severity) republishes diagnostics for every open document, so clients that only receivepublishDiagnostics(noworkspace/diagnostic/refreshsupport) do not keep stale diagnostics.
Note: The supply-chain trust signal only appears for npm, Cargo, Go, Maven, PyPI, Bundler, and NuGet (Composer, Dart, and Swift have no deps.dev coverage) and only for a dependency with a concrete in-use version — a lock-file-resolved version, or an exact requirement pin. It shows the linked source repository’s OpenSSF Scorecard score and the resolved version’s SLSA/attestation provenance status; a Scorecard fetched via a package-self-reported (rather than attested) repository link is marked
*(self-reported repo)*. Informational only — a low score never becomes a diagnostic. See Supply-Chain Trust Signal for the full details.
Note:
license_policydiagnostics only fire for dependencies this feature already has license data for — Composer, Dart, Swift, Deno, and Gradle. This list is a snapshot, not a designed-in limit: any ecosystem whose registry client gains alicense:field on its version type joins the diagnostic set automatically, with no further code changes required. Gradle’s Maven Central POM licenses are free text (e.g."The Apache Software License, Version 2.0"), not SPDX identifiers, so they are normalized against a known-variant table before evaluation — the table covers the common Apache/MIT/BSD/GPL/LGPL/AGPL/EPL/MPL/CDDL/ISC families but is not exhaustive. A free-text license the table doesn’t recognize is never falsely flagged, but it is also not enforced — it is silently excluded from evaluation rather than guessed at, the same as a dependency with no license data at all. A dependency with no known license is never treated as a violation.allow/denytake exact, case-insensitive SPDX identifiers only — noAND/OR/WITHexpression parsing. See License Policy Diagnostic for matching rules and precedence.
Tip: Increase
fetch_timeout_secsfor slower networks. The per-dependency timeout prevents slow packages from blocking others. Cold start support ensures LSP features work immediately when your IDE restores previously opened files.
deps-cli
deps-cli runs deps-lsp’s dependency-health checks from the command line — no editor
required. It walks a workspace, routes every manifest it finds through the exact same
14-ecosystem classification pipeline (deps-engine) that powers deps-lsp’s hover and
diagnostics, and reports outdated/yanked/vulnerable/unsatisfiable/deprecated/license/
mutable-ref-pin/sha-comment-mismatch findings as a table, as JSON, or as SARIF 2.1.0, with a
CI-friendly exit code.
Note:
deps-cliimplements no classification logic of its own — every verdict comes from the same functiondeps-lspcalls for its LSP diagnostics, so adeps-cli checkresult and an editor’s diagnostics for the same manifest never disagree. See The deps-engine crate for how that sharing works.
Installation
cargo install deps-cli
Or, without a Rust toolchain, the install script detects your OS/architecture, downloads
the matching release archive, verifies its SHA256 checksum, and installs to
${CARGO_HOME:-~/.cargo}/bin (falling back to ~/.local/bin):
curl -fsSL https://raw.githubusercontent.com/bug-ops/deps-lsp/main/scripts/install-deps-cli.sh | sh
Pin a release with --tag <version> (or DEPS_CLI_VERSION); override the install directory
with --install-dir <dir> (or DEPS_CLI_INSTALL_DIR). The script does not support Windows —
download the .zip asset from GitHub Releases
instead. Pre-built binaries are published for 8 targets: Linux x86_64/aarch64 (glibc and
musl), macOS x86_64/Apple Silicon, and Windows x86_64/ARM64.
Docker
The image published for the GitHub Action
(ghcr.io/bug-ops/deps-lsp-github-action) also bundles a prebuilt deps-cli binary, fetched
from the matching GitHub release and SHA256-verified at build time — no Rust toolchain to
install, and nothing to trust beyond the image itself. Its default ENTRYPOINT is hardcoded to
the GitHub Action’s own contract (deps-cli check --format sarif, driven by DEPS_CLI_* env
vars — see GitHub Action), so running deps-cli directly means overriding it:
docker run --rm -v "$PWD:/workspace" -w /workspace \
--entrypoint deps-cli ghcr.io/bug-ops/deps-lsp-github-action:1 check
Mount the directory you want to scan at /workspace, then pass any normal deps-cli
subcommand and flags after check — table output by default, or --format json/
--format sarif as described under Output formats. The image is
Alpine-based, built for linux/amd64 and linux/arm64, and tagged latest, major (X),
minor (X.Y), and exact (X.Y.Z) — ghcr.io/bug-ops/deps-lsp-github-action:1 tracks the
latest 1.x.y release, the same tag action.yml pins for the GitHub Action itself.
Usage
# Check the current directory, human-readable table output (default)
deps-cli check
# Check specific paths
deps-cli check Cargo.toml package.json services/api/
# Fail the run only on real vulnerabilities and unsatisfiable requirements
deps-cli check --fail-on vulnerable,unsatisfiable
# Machine-readable output for a CI step that parses results
deps-cli check --format json
# SARIF 2.1.0 output for github/codeql-action/upload-sarif
deps-cli check --format sarif > results.sarif
# CI-friendly: never touch the network, use only what's already cached
deps-cli check --offline
# Loosen the freshness window for this run only
deps-cli check --cooldown 3d
# Use an explicit, fully-trusted config file
deps-cli check --config ./ci/deps-strict.toml
check and update (see update usage below) are the two deps-cli
subcommands. Paths default to the current directory when none are given to check.
Output formats
-
table(default) — human-readable, grouped by manifest path, then by severity (error > warning > information > hint) within each file, ending in a one-lineSummary: outdated=2 vulnerable=1 ...count by category. -
json— a versioned document:{ "schema_version": 1, "findings": [ { "ecosystem": "cargo", "manifest_path": "Cargo.toml", "dependency_name": "serde", "requirement": "1.0", "category": "outdated", "severity": "hint", "range": { "start": { "line": 4, "character": 0 }, "end": { "line": 4, "character": 10 } }, "message": "Newer version available: 1.1.0" } ], "summary": { "outdated": 1 } }schema_versionis bumped, and the bump documented asBreakinginCHANGELOG.md, whenever a field is renamed, removed, or its wire type/nullability changes (e.g. a sentinel value like""becomingnull); adding a new optional field is not itself a bump. Note that the JSON schema does not carry a finding’s OSV advisory id or itshttps://osv.dev/vulnerability/{id}link — onlysarifoutput does (see below). If your tooling needs the advisory id/URL for a vulnerability finding, parsesarifoutput instead ofjson. -
sarif— a SARIF 2.1.0 document. A vulnerability finding becomes its own SARIF rule (keyed by its OSV advisory id, e.g.RUSTSEC-.../GHSA-...) with ahelpUrito the advisory page, afullDescriptionbuilt from the finding’s own message, and asecurity-severityscore when the OSV scan itself graded that advisory; every other category collapses to one rule per category token, using each category’s own description as itsshortDescription. Each result carries apartialFingerprintsentry derived from manifest path, dependency identity, rule id, and an occurrence ordinal — not the line range — so an unrelated line shift elsewhere in the file doesn’t make GitHub treat an existing alert as new.run.automationDetails.iddisambiguates repeated uploads for the same commit.
--fail-on categories and exit codes
--fail-on takes a comma-separated list of: outdated, yanked, vulnerable,
unsatisfiable, mutable-ref, sha-comment-mismatch, unknown-ref, license, deprecated,
other. It defaults to vulnerable,yanked,unsatisfiable when omitted; an explicit --fail-on
replaces that default list rather than extending it. sha-comment-mismatch selects SHA pins
(GitHub Actions/GitLab CI) whose trailing version comment is not confirmed by the repository’s
tag index; it matches regardless of the finding’s severity and is never part of the default
policy. unknown-ref selects tag pins whose ref is a full release that no published tag of the
repository matches (see GitHub Actions);
it is likewise severity-agnostic and never part of the default policy. other covers every
finding that matches none of the nine specific categories and is never part of the default
policy.
Warning: other also matches informational notices, not only real problems: the offline
notice, the skipped-lookup notice for any manifest without a lock file, an unresolved
self-hosted GitLab host, the dependency-ceiling notice, and collapsed registry-fetch failures.
It therefore fails the run on any manifest without a lock file and on every --offline run,
and cannot target one of these findings alone.
| Exit code | Meaning |
|---|---|
0 | Clean — no finding matched the --fail-on policy |
1 | Policy violation — at least one finding matched --fail-on |
2 | Execution error — a registry was unreachable, a deps.toml/manifest failed to parse, or a walked path was unreadable |
An unreachable registry (DNS failure, connect timeout after 10 seconds, refused connection) is an
execution error: deps-cli check exits 2 and reports “Registry lookup failed”. A registry that the
access policy blocks, for example a private address without an allowlist entry, is different: it is a
policy decision, reported as an informational other finding naming the policy and the
allowlist hint, and the run exits 0 unless you add
--fail-on other. Guarded registry hosts bypass the system proxy unless
DEPS_LSP_WORKSPACE_REGISTRY_PROXY=proxy is set (see
Proxies and guarded registry traffic);
deps-cli prints one stderr line at startup when it detects a system proxy.
A real policy violation (1) always takes precedence over an unrelated execution error
elsewhere in the run — one malformed manifest in a large workspace never hides a genuine
--fail-on hit behind a less specific 2.
Workspace walk and symlink handling
check does not honor .gitignore/.ignore by default. In a CI gate
(git checkout && deps-cli check . against an untrusted fork PR), both files are
attacker-controlled input — a one-line addition to either would otherwise silently drop a
manifest from the scan with no warning and exit code 0. A compiled-in denylist
(node_modules, target, vendor, .venv, and other common dependency/build/VCS
directories) still keeps the scan fast without depending on either file. Pass
--respect-gitignore to restore standard .gitignore/.ignore awareness when scanning a
target you trust as much as your own deps.toml.
A manifest reachable only through a symlink is detected and reported (a warning, non-zero
exit code) regardless of this flag; it is not resolved and scanned unless
--follow-symlinks is also passed. A symlink whose resolved, canonicalized target falls
outside the walked root is never followed, and the walk is bounded so a symlink loop can’t
run unbounded, regardless of the flag.
A single check invocation inspects at most 50,000 files across every walked root; beyond
that the walk stops and the report is marked truncated rather than silently
under-reporting. A single manifest file larger than 10 MB is skipped with a warning rather
than read in full (the same cap deps-lsp applies via fs_probe::read_to_string_capped — see
Architecture).
Configuration (deps.toml)
deps-cli reuses deps-lsp’s own PolicyConfig schema — the same diagnostics, cache,
freshness, supply_chain, registries, network, license_policy, typosquat, and
gossip sections documented in Configuration — loaded from a deps.toml file instead of
LSP initializationOptions:
[diagnostics]
vulnerabilities_enabled = true
mutable_ref_pin_enabled = true
[freshness]
cooldown_secs = 259200 # 3 days, Dependabot's default
[network]
offline = false
[license_policy]
allow = ["MIT", "Apache-2.0", "BSD-3-Clause"]
deps-cli looks for deps.toml relative to the walked root when --config is not given: if
check was given exactly one path, that path’s own directory (or its parent, if the path is a
file); if it was given several paths, or none (the implicit .), the lookup falls back to the
current working directory instead, since there is no single “the walked root” to prefer among
several. deps.toml itself is capped at 1 MB and must be valid TOML matching this schema
exactly (deny_unknown_fields at the top level — an unrecognized top-level key rejects the
whole file; an unrecognized key nested inside a known section like [cache] is tolerated for
forward compatibility). --offline and --cooldown override the loaded config for that run
only.
[gossip] enabled = true is honored by check and update when loaded from an explicit
--config (see deps.dev GOSSIP signals); an auto-discovered
deps.toml has it reset to false. [typosquat] has no effect in deps-cli at all: the
typosquat diagnostic is deps-lsp only, and a non-default [typosquat] section prints a
“has no effect” warning even for an explicit --config.
Warning: An auto-discovered
deps.toml— found by the default lookup, not passed explicitly via--config— has itsregistries,network, anddiagnostics.*_enabledsections (and cache/freshness/license_policy/supply_chain) reset to their safe defaults before use; only the seven*_severitydisplay values are kept (they’re cosmetic and can never suppress a--fail-onmatch). This is deliberate: the repository a CI job is checking is not a trusted source for the policy that judges it. A checked-indeps.tomlon an attacker-controlled branch must not be able to disable the vulnerability scan, forcenetwork.offlineto hide every registry/OSV-derived finding, or redirect GitLab host resolution viaregistries.gitlab_instance_host. (GITLAB_TOKENis never sent to that host: it is bound togitlab.comor theGITLAB_TOKEN_HOSTenvironment variable, so an explicit--configdoes not authenticate a self-hosted host by itself.) Any section ignored this way is named in a warning on stderr. Only a config path given explicitly via--config— the operator’s own choice, not the scanned repository’s — is trusted in full.
Note:
registries.workspace_registries = "all"reaches private hosts only if they are listed in theDEPS_LSP_PRIVATE_REGISTRY_HOSTSenvironment variable (see Configuration), also with an explicit--config. An allowlisted host is reachable on any port, so list registry hosts or narrow CIDRs only. In the GitHub Action, set it in the step’senv:. Without the variable,"all"behaves like"public_only"anddeps-cliprints a warning on stderr.
Note:
registries.swift_keychain_credentialsis not supported indeps-cli, which cannot answer a macOS Keychain access prompt: an explicit--configwith it enabled prints a warning and runs with it disabled. See Swift macOS Keychain credentials.
update usage
deps-cli update <MANIFEST> reads exactly one manifest, plans a set of version-requirement
edits, and writes them back atomically — the non-interactive counterpart to deps-lsp’s
“update all outdated” code lens and per-dependency vulnerability-fix quick action.
# Update every outdated dependency in Cargo.toml
deps-cli update Cargo.toml
# Only serde, even if other dependencies are also outdated
deps-cli update --package serde Cargo.toml
# Plan without writing, and inspect the machine-readable plan
deps-cli update --dry-run --format json Cargo.toml
# Only OSV-Vulnerable dependencies, via their recommended fix (never plain "latest")
deps-cli update --security-only Cargo.toml
# Ignore rules only take effect from an explicit --config — see below
deps-cli update --config deps.toml Cargo.toml
<MANIFEST> must resolve to exactly one manifest a registered ecosystem recognizes — a
directory, a shell-glob expansion to more than one path, or an unrecognized file is an
execution error (exit 2). update never walks a tree the way check does, so it has no
--respect-gitignore/--follow-symlinks flags: an explicitly named path is already an
explicit choice.
Default mode vs. --security-only
-
Default mode targets every dependency
checkwould reportoutdated, rewriting its declared requirement to the latest matching version — unless that version was published withinfreshness.cooldown_secsof now (default: 3 days, Dependabot’s own default), in which caseupdatetargets the newest already-cooled-down, independently OSV-verified, floor-protected fallback candidate instead, when one exists (issue #1528). This is on by default and applies even with no--cooldown/--configgiven at all: a version that just came out is not yet a real recommendation. A fallback candidate is computed whether or not a lock-file-resolved in-use version exists: with a lock file, the search floors at the in-use version; with no lock file and a range requirement (the majority case for a fresh install), it floors at the declared requirement itself, and is only written when doing so provably doesn’t leave the requirement still admitting a newer, still-cooling version on the next unlocked resolve — otherwiseupdatefalls back to a full skip, reported as askippedoutcome whose reason names the freshness cooldown window (issue #1544). When the fallback candidate is itself OSV-flagged or unverified,updaterefuses to write it and exits non-zero naming that version, the same way an unsafelatestis refused — it never silently falls back to the plain cooldown skip. Disable this filter entirely with a--configfile setting[freshness] enabled = false, or narrow the window with--cooldown(see below). This filter isdeps-cli update-specific —check’s ownOutdateddiagnostic anddeps-lsp’s “update to latest” code action still only annotate a fresh version’s age, never exclude it or substitute a fallback. -
--security-onlytargets only dependencies OSV reports vulnerable, rewriting to the advisory’s own recommended fix (never a plain “latest” pick), and independently re-verifies that fix against OSV before writing it. Every vulnerable dependency is classified into exactly one of three outcomes:applied— the fix was written.requires-lockfile-update— the declared requirement already admits the fix, so there is nothing to rewrite at the manifest level; a lock-file regeneration step (tracked by #1116, not yet implemented) is needed to actually pull the fixed version in.updatedoes not regenerate lock files itself.unfixable— no independently-verified fix target exists, the registry fetch for that dependency failed, the fix target is itself yanked, the declared requirement’s syntax has no safe single-value rewrite and is confirmed not to already admit the fix target, or the declared requirement is too large to safely evaluate, or a commit (SHA) pin’s fix version has no matching release in the repository’s complete tag list (GitHub Actions, GitLab CI; for a GitLabcomponent:include, its release list), so the pin cannot be rewritten and still points at the vulnerable commit. A missing, cold or truncated tag list cannot prove that, and is reported as no verified fix instead. For the last four causes (a yanked, confirmed-unsupported-requirement-shape, oversized-requirement or no-release-tag fix target), the table’s target column and JSONtargetfield report that rejected version instead of staying empty, so the operator can see what was ruled out even though nothing was written.
For a registry that does not report yank status at all, the yank check is inert for that ecosystem (a documented limitation, not a bug) — such a dependency can still be classified
appliedeven though its yanked status was never actually checked.--cooldown(and a[freshness]cooldown sourced from--config) has no effect under--security-only: the fix target comes from the advisory, never the freshness-filtered registry pick. It is also a no-op in default mode when an explicit--configalso sets[freshness] enabled = false—deps-cliwarns in both cases rather than silently ignoring the flag.
[update].ignore (only via --config)
[update]
ignore = [
{ name = "tokio", update_types = ["major"] }, # skip only major bumps
{ name = "legacy-thing" }, # skip every update, including unclassifiable ones
]
update_types is one or more of major/minor/patch; omitting it skips every kind for
that dependency, including one update cannot classify at all (a GitHub Actions SHA pin, a
Go pseudo-version, a Maven/NuGet range, */latest/workspace:*, …) — an unclassifiable
update is always treated as if it met a scoped rule’s threshold too, never silently let
through. Names are matched exactly (after normalization), not by wildcard.
[update].ignore is honored only when loaded from an explicit --config <path> —
update never auto-discovers a default-location deps.toml at all (unlike check), so
without --config no ignore rule is ever loaded. --security-only overrides every ignore
rule outright (never silently applies one) — a security fix is never held back by a routine
maintenance preference, and the plan reports when a rule was overridden rather than applying
it quietly.
Unlike Dependabot’s ignore (which can retarget a blocked update to the highest version its
own rule still allows), a matching [update].ignore rule here skips the dependency entirely
for this run — it is reported skipped (ignore-rule) with no edit written at all, never
rewritten to some lesser version.
Write safety and exit codes
Writes are atomic: a temp file is created in the manifest’s own directory
(O_CREAT|O_EXCL), permissions are copied from the original before any content is written
(Unix only), the content is fsynced, then renamed over the original. A manifest path whose
final component is itself a symlink is refused before any temp file is created. The manifest
is re-read and byte-compared against the content the plan was computed against immediately
before writing; a mismatch (something else modified the file in the meantime) aborts without
writing.
| Exit code | Meaning |
|---|---|
0 | Every selected update was applied, or nothing was eligible, or every non-applied item was a deliberate exclusion — an [update].ignore match, a --package exclusion, or a version held back by the default-mode freshness cooldown (reported as a skipped outcome, see above) |
1 | At least one item the run wanted to fix but could not — an unsafe/unrecognized span, requires-lockfile-update, unfixable, or a cooldown-fallback candidate that was itself OSV-flagged/unverified |
2 | Execution error — not a single recognized manifest, a registry required to classify the manifest was unreachable, a write/read failure, a symlinked manifest path (refused before any read, not just before the write), stale content detected before write, or --security-only combined with network.offline/vulnerability scanning disabled |
An ignore rule, a --package exclusion, or a freshness-cooldown pause is something the run
deliberately chose to leave alone, not a failure — none of the three ever turn an otherwise-clean
run non-zero on their own. The cooldown pause is automatic (policy-driven), not
operator-requested the way the other two are, but the exit-code treatment is identical: a CI
pipeline must not start failing merely because a dependency’s latest release is a few hours
old. An OSV-blocked cooldown-fallback candidate is different: it is a safety refusal, not a
deliberate pause, so it exits 1 the same way a flagged/unverified latest does.
A single unreachable dependency aborts the whole run. Unlike check, which still reports
its other findings alongside exit 2, update treats any one dependency’s registry fetch
failure as reason to abort the entire plan before writing anything — a deliberate, stricter
fail-closed choice, not a bug, since a plan built from partial registry data could otherwise
recommend a version that isn’t actually the latest.
A non-zero exit never implies the working tree is unmodified. A mixed run (some items
applied, others not) exits 1 with the applied edits already written to disk. A wrapper
script deciding what to do with the result (e.g. whether to commit a diff) must inspect each
item’s outcome field in --format json output, never branch on the process exit code
alone.
Pre-commit hook
This repository ships a .pre-commit-hooks.yaml
at its root defining a deps-lsp-check hook (language: system, entry: deps-cli check).
language: system means pre-commit installs nothing for this hook — deps-cli must
already be on PATH (the install script or cargo install deps-cli both
work).
# .pre-commit-config.yaml
repos:
- repo: https://github.com/bug-ops/deps-lsp
rev: <tag>
hooks:
- id: deps-lsp-check
Using deps-cli in CI
For GitHub Actions specifically, crates/github-action ships a ready-made Docker-based action
wrapping deps-cli check --format sarif, with SARIF output wired for
github/codeql-action/upload-sarif — see GitHub Action for inputs,
outputs, exit-code-to-job-failure mapping, and a full workflow example. For any other CI
system, install deps-cli as described above and run deps-cli check --format sarif (or
json/table) as an ordinary step, using its exit code
to gate the build.
GitHub Action
crates/github-action ships a ready-made, Docker-based GitHub Action that runs
deps-cli check --format sarif against your repository and writes the result to a
SARIF 2.1.0 file — the same dependency-health checks deps-lsp surfaces in your editor,
running as a CI gate with zero Rust toolchain setup.
Quick start
name: Dependency check
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
security-events: write
jobs:
deps-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: bug-ops/deps-lsp/crates/github-action@v1.2.0
id: deps-check
with:
fail-on: vulnerable,yanked,unsatisfiable
- uses: github/codeql-action/upload-sarif@v3
if: steps.deps-check.outputs.sarif-file != ''
with:
sarif_file: ${{ steps.deps-check.outputs.sarif-file }}
- name: Fail the build on a policy violation
if: steps.deps-check.outputs.exit-code == '1'
run: exit 1
The action itself does not fail the job on a policy violation (exit-code: 1) — only on
an execution error (anything other than exit code 0 or 1, e.g. a deps-cli panic or a
missing binary). Whether a policy violation should fail your build is your own workflow’s
decision, made explicit by the last step above. Drop that step if you only want the SARIF
findings uploaded to GitHub’s code scanning UI, without failing the build.
This action requires a Linux runner (runs-on: ubuntu-latest or similar) — it is a Docker
action, which GitHub Actions only runs on Linux.
Inputs
| Input | Description | Default |
|---|---|---|
paths | Space-separated paths to walk | deps-cli’s own default (repository root) |
fail-on | Comma-separated categories that make deps-cli exit 1 — outdated, yanked, vulnerable, unsatisfiable, mutable-ref, sha-comment-mismatch and unknown-ref (both require the next release of the action image), license, deprecated, other | vulnerable,yanked,unsatisfiable |
cooldown | Overrides freshness.cooldown_secs for this run only (e.g. 3d) | unset |
config | Path to a fully-trusted deps.toml — see the warning below | unset (falls back to deps-cli’s own hardened auto-discovery) |
Warning:
deps-clitreats an explicit--configpath (whichconfighere maps to) as fully trusted — unlike an auto-discovereddeps.toml, whoseregistries/network/diagnostics.*_enabledsections are reset to safe defaults specifically because the scanned repository is not a trusted source for the policy that judges it (see Configuration (deps.toml)). Passingconfighere re-establishes that full trust, so only point it at a file outside the scanned checkout and under your own control — never at a path inside the repository you are scanning, especially in apull_request_targetworkflow scanning a fork.
Outputs
| Output | Description |
|---|---|
sarif-file | Path to the produced SARIF file (deps-lsp-results.sarif). Set only when a non-empty, regular SARIF file was produced — may be unset even for exit code 0/1 (e.g. an unwritable output path), and is always unset for an execution error |
exit-code | deps-cli check’s own exit code (0 clean, 1 a --fail-on category matched, 2 execution error), or the raw exit code from an abnormal termination. Set whenever deps-cli ran; unset only if the action refused to start before running it at all |
How it maps to deps-cli
The action is a thin wrapper: action.yml declares using: docker, pointing at
ghcr.io/bug-ops/deps-lsp-github-action:1 — an image that bundles a prebuilt deps-cli
binary, fetched from the matching GitHub release and SHA256-verified at build time. Each
input becomes a DEPS_CLI_* environment variable the image’s entrypoint script reads and
translates into the equivalent deps-cli check flag:
| Input | Environment variable | deps-cli flag |
|---|---|---|
paths | DEPS_CLI_PATHS | positional paths |
fail-on | DEPS_CLI_FAIL_ON | --fail-on |
cooldown | DEPS_CLI_COOLDOWN | --cooldown |
config | DEPS_CLI_CONFIG | --config |
The entrypoint always runs deps-cli check --format sarif, redirecting the output to
deps-lsp-results.sarif in the scanned checkout. That output path is removed before every
run — including a pre-existing regular file left by a prior step or a symlink placed there by
the scanned checkout itself — so a stale or hostile file never leaks into the result; a
directory at that path cannot be removed this way, so the action refuses to start before
deps-cli even runs, leaving both outputs unset.
You can run the exact same image directly with plain docker run (useful for reproducing a
CI failure locally) — see deps-cli’s Docker section for the command.
Image tags and supply-chain hardening
Pin the uses: ref itself to a release tag (@v1.2.0, as in the examples above), not @main —
per the CI/CD Pinning guidance deps-lsp itself gives for your
other GitHub Actions dependencies, a branch ref can start running different code with no change
to your workflow file. This is separate from the Docker image tag discussed next, which
action.yml pins on your behalf.
The image is rebuilt whenever a new deps-lsp/deps-cli release is tagged. Tags published:
latest, a rolling major (1), and exact per-release tags (X.Y, X.Y.Z). action.yml
pins the rolling major tag (:1) so this action tracks the latest 1.x.y release without a
manual bump; pin an exact tag yourself in your own workflow if you need full reproducibility.
Every image is scanned with Trivy for CRITICAL/HIGH vulnerabilities before publish — a finding
blocks the publish and is reported to the repository’s Security tab.
See also
- deps-cli — the CLI this action wraps, including its own SARIF output format,
exit-code contract, and
deps.tomlconfiguration and its auto-discovery hardening. - The deps-engine crate — the shared classification layer
deps-cli(and thus this action) uses, guaranteeing its findings never disagree withdeps-lsp’s editor diagnostics for the same manifest.
Cross-Ecosystem Features
The behaviors in this chapter are implemented once in deps-core (see
Architecture Overview) and apply, with the coverage noted in each section,
across most or all of the 14 supported ecosystems — rather than being reimplemented per crate.
- Conventions — the inlay hint icons and hover/diagnostic/code lens text conventions every ecosystem shares.
- Licensing — license hover and the license allow/deny policy diagnostic.
- Yanked Versions & Vulnerabilities — the two independent yanked-version diagnostics, the vulnerability-fix code action, and the supply-chain trust signal.
- Version Diagnostics — unsatisfiable requirements, package deprecation, the dependency-count ceiling, and the bulk “update outdated” code lens.
- CI/CD Pinning — the mutable-ref-pin diagnostic and bulk “pin to SHA” code lens shared by GitHub Actions and GitLab CI/CD.
Ecosystem-specific behavior — parsing, registry resolution, custom/private registries — lives in the Ecosystem Reference chapters instead.
Conventions
Inlay Hint Icons at a Glance
Every ecosystem’s “inlay hints” (the inline text shown right next to a dependency’s
version in the manifest) come from the same four icons, all defined in deps-core’s
Ecosystem trait defaults and deps-lsp’s config:
| Icon | Meaning | Shown when |
|---|---|---|
| ✅ | Up to date | The declared version already matches the latest available version. |
❌ <version> | Update available | A newer version exists; <version> is the latest one, substituted into the hint text. |
| ⏳ | Loading | Version/vulnerability data is still being fetched from the registry. Only appears for editors that don’t support LSP work-done progress reporting — most editors show a native progress indicator instead. |
| 📴 | Offline | Network access is disabled, so version and vulnerability data were not checked. Also appears as an “Offline: version and vulnerability data not checked” note in hover text. |
All four are user-configurable — an editor/client can override the up-to-date and
needs-update text via the inlay_hints LSP config block, and the loading text via
loading_indicator. The examples above are the defaults.
✅/❌ compares against the lock file, not the manifest range, when a lock file
exists. When a lock file (Cargo.lock, package-lock.json, etc.) is present, the
icon is decided by comparing the lock-resolved version against the latest
release — the manifest’s version range is not consulted in that case. So a manifest
range that already covers the latest release (e.g. Cargo.toml declares ^2.0 and
the latest published version is 2.1.1) can still show ❌ if the lock file hasn’t
been regenerated and still resolves to an older version (e.g. 2.0.5). The fix is to
update/regenerate the lock file, not necessarily the manifest range. The manifest
range is used directly only as a fallback, when no resolved lock version is
available. Go is the exception: it reads the resolved version from go.mod’s own
directive rather than from go.sum, because go.sum isn’t a reliable source for the
in-use version — so for Go, the manifest drives the icon directly.
Not every status gets an icon. Yanked, deprecated, and unsatisfiable-requirement
dependencies, and OSV vulnerabilities, are surfaced as plain text in hover content (e.g.
(yanked), (deprecated), a CVE severity label) and as standard LSP diagnostics — the
squiggly underlines and problem-panel entries your editor already renders for warnings and
hints — rather than as an inlay-hint icon. If you don’t see an icon for one of these, check
hover and the diagnostics panel instead.
Hover, Diagnostic & Code Lens Text Conventions
Hover content is Markdown, built from the same handful of building blocks across every ecosystem; diagnostics and code lens titles are plain text with no icon or Markdown convention of their own.
| Convention | Example | Meaning |
|---|---|---|
**Label**: \value`` | **Current**: \1.2.0`, Latest: `1.3.0`` | Bold label plus a code span for a version/fact. The package name itself is an H1 heading, linked to the registry page when one is available. |
*(status)* | `1.2.0` *(yanked)* | Yanked/deprecated status, always italicized in parentheses, shown next to the version in the “Recent versions” list. Most ecosystems say *(deprecated)*; Composer uses Packagist’s own term, *(abandoned)*. |
> callout | > ⏳ **Recently published** — ... | Markdown blockquote shown when a version is still inside the release-cooldown window. |
### Security advisories | - **[CVE-XXXX-YYYY](advisory link)** — critical then a summary line and Fixed in: \1.2.4`` | One bullet per advisory: linked CVE/GHSA id, plain-text severity (critical/high/medium/low/unknown severity/confirmed malicious package/maintenance-status notice, not a vulnerability), a summary line, and the fixed-in version. When a package has been scanned and has no advisories, hover shows **No known vulnerabilities** (OSV.dev) instead — this line only appears after a scan actually ran, never for an unscanned package. |
| unheaded line | 🔐 **Supply chain**: OpenSSF Scorecard \7.5`/10 · Provenance: verified` | OpenSSF Scorecard score and/or SLSA-provenance verdict (via deps.dev), on its own line with no heading. Omitted entirely when neither signal is available. |
--- + footer | ⌨️ **Press \Cmd+.` to update version**, 📴 Offline: version and vulnerability data not checked` | Each footer is preceded by a Markdown horizontal rule. The first appears when a newer version is available and the dependency’s requirement isn’t an unresolved template placeholder (codeAction would return zero actions for one, so the footer stays suppressed); the second while network.offline is active. |
--- + footer | *Vulnerability data was not checked: no resolved or exact version was available to query* | Shown per dependency, while online, whenever the OSV scan was skipped for that dependency instead of run — same purpose as the offline footer above but for a per-dependency reason (no resolvable version, a resolved tag that is not a full version, an OSV.dev query failure, a truncated result set, a package name or ecosystem OSV.dev doesn’t support). Never appears together with the offline footer, and never for a dependency that OSV.dev genuinely scanned. |
A dependency skipped for one of these reasons also gets a single, file-level
Information diagnostic aggregating every skipped dependency in the document (one
RelatedInformation entry per dependency, capped at 9 plus an “N more” tail), so the
signal is visible in the Problems panel too, not just on hover. Dependencies skipped
because the package name or ecosystem itself cannot be mapped to OSV.dev (e.g. a
jsr:-pinned Deno dependency) are excluded from this diagnostic — that state is
permanent for as long as the dependency is declared that way, so a standing
Problems-panel entry would be unactionable noise — but still show the per-dependency
hover footer above.
Diagnostics and code lens are plain text, not Markdown, and have no icon
convention. Diagnostic message wording is deliberately not unified across rule
types even for similar situations (for example, “yanked and currently in use” and
“yanked but only reachable through the declared range” use different phrasing) —
their visual severity (squiggly underline color, problem-panel icon) comes
entirely from the editor, driven by the diagnostics config block (HINT/WARNING
per rule), not from any icon deps-lsp draws itself. Code lens
titles follow the same plain-text rule: “Update 1 outdated dependency” / “Update
{n} outdated dependencies”, and for GitHub Actions/GitLab CI, “Pin 1 {kind} to
commit SHA” / “Pin {n} {kind} to commit SHA” — no emoji, positioned on the
manifest’s first line.
Licensing
License Hover
Hover shows the SPDX license identifier(s) for the resolved version, and flags a “License changed” warning when the latest version’s license differs (issue #204). Covered for Cargo, npm, PyPI, Go, Maven, Bundler, NuGet (via the deps.dev supply-chain call — resolved version only, the “latest” license degrades to “unavailable” since no second network call is made) and Composer (via Packagist’s own version list, which carries license for both the resolved and latest version, enabling the “License changed” comparison). When license data is unavailable for a dependency, the section is omitted rather than shown as “unknown”.
Dart, Swift, Gradle, and Deno (issue #660) are covered via a per-ecosystem
background pre-fetch (mirroring the OSV vulnerability-scan pattern — never
blocking hover latency) instead of the deps.dev/Packagist hot-path call above.
This pre-fetch has its own 10-second timeout floor, independent of a lower
configured fetch_timeout_secs
(which can be set as low as 1s): Gradle’s <parent> POM traversal (below) may need up
to a few sequential HTTPS round trips for one dependency, so clamping the
pre-fetch’s timeout down to a very low fetch_timeout_secs would silently
starve exactly the parent-chained licenses this feature exists to resolve
(issue #692 critic M2). A user tuning fetch_timeout_secs down for fast
feedback on the hot registry-fetch path is unaffected there — only this
background license pre-fetch keeps a higher floor.
| Ecosystem | Source | Renders as |
|---|---|---|
| Dart | pub.dev /score best-effort license detector tag, per-package (not per-version) | **License (detected)** |
| Swift | GitHub’s licensee-detected license.spdx_id on the repository’s default branch (not the resolved version’s tag) | **License (detected)** |
| Gradle | Maven Central POM <license><name>, fetched for the resolved version — following the POM’s <parent> coordinate (bounded to a few hops) when that POM declares no <licenses> block of its own (issue #692, e.g. Guava’s license is declared only on guava-parent’s POM) | **License** |
| Deno | JSR’s per-version license field (jsr: specifiers only) | **License** |
Which of these four “sources” a dependency’s license is depends on the ecosystem
crate’s Ecosystem::license_source() (issue #688/#697): RegistryDeclaredSpdx
(author-declared, arriving for free in the hot-path registry response — the
default, every ecosystem above except Dart/Swift/Gradle/Deno), FetchedDeclaredSpdx
(Deno only — author-declared, but via JSR’s dedicated per-version fetch rather than
the hot-path response), DetectedSpdx (Dart/Swift — a best-effort detector, not
author-declared metadata, hence the (detected) qualifier), or PomFreeText
(Gradle only — Maven POM <license><name> free text, e.g. "The Apache Software License, Version 2.0", never an SPDX identifier). LicenseSource::requires_dedicated_fetch()
is true for every variant except RegistryDeclaredSpdx — this is also the single
gate deps-lsp’s tier-3 license pre-fetch uses to decide whether to call an
ecosystem’s fetch_license at all. Gradle’s free text is normalized once,
at the shared pre-fetch data boundary (issue #687), but hover and
License Policy Diagnostic below use two
different views of that normalization
(deps_core::licenses::resolve_license_entries_for_display vs
resolve_license_entries), not identical output: hover renders a single
canonical id (Apache-2.0), while policy evaluation gets the full
SPDX-convention-ambiguous synonym slice a deny/allow list needs to match
against (the GPL family normalizes to three ids — see below) — printing all
three in hover would read as three licenses for what is genuinely one. A POM
name the normalization table doesn’t recognize falls back to the raw text in
hover (never silently vanishes) but is dropped, not guessed at, for policy
evaluation. Dart/Swift have no license data at all without a resolved version
(pubspec.lock/Package.resolved — both endpoints require an in-use version
to look up, even though the data itself isn’t version-specific).
License Policy Diagnostic (issue #661)
Configuring license_policy.allow/license_policy.deny (see the
Configuration reference)
produces a diagnostic for a dependency whose known license violates the policy, anchored at the
same manifest line as the outdated/vulnerability diagnostics. Both lists take exact,
case-insensitive SPDX identifiers only — no AND/OR/WITH expression-operator
parsing (spec 010 plan.md’s explicit v1 scope decision); an invalid entry is
dropped with a logged warning at config-load time rather than rejecting the whole
license_policy payload.
- Deny wins over allow when a license matches both — mirrors
cargo deny licenses’ own precedence convention. Renders as an ERROR diagnostic. - Not on the allow-list (a non-empty
allowconfigured, and none of the dependency’s declared licenses matches) renders as a WARNING diagnostic. - No known license (the dependency isn’t one of the ecosystems below, or its license hasn’t been fetched yet) never violates the policy — there is nothing to check, not a hidden deny.
- Multi-licensed dependencies (more than one declared license): denied if
any entry matches
deny(errs toward flagging for manual review); allowed if any entry matches a non-emptyallow(the permissive reading — the consumer can pick whichever license they comply with).
Coverage is Composer, plus exactly the ecosystems License Hover’s
background pre-fetch covers — Dart, Swift, Deno, and Gradle. Composer’s license
arrives for free in its hot-path registry response (RegistryDeclaredSpdx, via
Packagist’s own version list — see License Hover above), so it
needs no dedicated pre-fetch to populate the diagnostic’s synchronous license map;
any other ecosystem whose registry client starts returning a license: field on
its version type joins this set the same way, with no further code changes. Gradle’s
Maven Central POM licenses are free text (e.g. "The Apache Software License, Version 2.0"), never SPDX identifiers, so directly matching them against an SPDX
allow/deny list would produce both false positives (a compliant Apache-2.0
dependency reported “not on the allowed license list”) and false negatives (a
GPL-3.0 deny-list entry never matching "GNU General Public License v3").
deps-core::licenses::normalize_pom_license_names (issue #679) maps known Maven
Central POM free-text variants (Apache/MIT/BSD/GPL/LGPL/AGPL/EPL/MPL/CDDL/ISC)
to their canonical SPDX identifier(s) before evaluation — free text that never
disambiguates the deprecated bare id from the current -only/-or-later split
(e.g. "GNU General Public License v3") normalizes to all three forms, so a
deny/allow list written in either convention still matches. If any of a
dependency’s declared license entries fails to normalize, only a NotAllowed
conclusion is suppressed for it (the surviving evidence is incomplete, so
“nothing matched” can’t be trusted); a Denied match on a normalized entry
still fires regardless. A Gradle license this table doesn’t recognize is never
falsely flagged, but it is also not enforced — it is excluded from
evaluation rather than guessed at, the same as a dependency with no license
data. The table is not exhaustive; an unrecognized license on a deny list
silently escapes enforcement until that variant is added to
KNOWN_POM_LICENSE_NAMES. Hover and this diagnostic normalize through the same
data boundary but read two different views of it (see License Hover
above, issue #687): a recognized entry’s diagnostic message shows the same
canonical id hover does (e.g. Apache-2.0), but an SPDX-convention-ambiguous
entry’s message may list more ids than hover’s single canonical one (the GPL
family’s GPL-3.0, GPL-3.0-only, GPL-3.0-or-later) — that expansion is
policy-matching evidence, not something hover should also print. Either way,
neither surface ever shows the original free text for a recognized entry.
This diagnostic is evaluated identically whether it was triggered by a
textDocument/diagnostic pull request or a background push refresh (a
fetch-completion, watched-config, or lock-file-change reparse) — the currently
configured policy is cached server-side and kept live-updated by
workspace/didChangeConfiguration, so it never depends on which code path
happened to generate a given diagnostics response.
Yanked Versions & Vulnerabilities
Informational Advisories (issue #1043)
Not every OSV record is a graded vulnerability. deps-lsp classifies an OSV advisory as
Informational — instead of Critical/High/Medium/Low/Unknown — when its
database_specific.informational field is "unmaintained" on an entry that genuinely
describes the queried package (not a stranger sharing the same advisory id). This is an
allowlist of exactly one value: RUSTSEC’s "unsound" (a real memory-safety/UB finding)
and "notice" (which can still describe a real defect) are deliberately not treated as
informational — an unrecognized or missing value falls through to the existing Unknown/
WARNING treatment rather than being silently downgraded to the less-visible informational
bucket.
An informational advisory renders distinctly rather than being hidden or conflated with a real vulnerability:
- Hover labels it
maintenance-status notice, not a vulnerabilityinstead of a severity word, and a candidate version’s “still vulnerable” line is suppressed only when every remaining advisory for it is known-informational — a mix of one informational and one graded advisory still shows the warning line. - Diagnostics render at
INFORMATIONseverity (notWARNING) and the message is prefixed[INFORMATIONAL], so it is visually and severity-distinct from a graded advisory’s diagnostic in the editor’s Problems panel. - A confirmed-malicious-package record (OSV’s
MAL-id prefix, e.g. viaaliases) always takes precedence over aninformationalclassification, even if the same record also carriesdatabase_specific.informational: "unmaintained".
deps-cli check’s vulnerable category maps 1:1 to the same OSV scan deps-lsp runs, so
an informational-only advisory is reported the same way there — visible in the output, but
distinguishable from a graded finding via its severity field.
Yanked-Version Diagnostics
diagnostics.yanked_severity flags a dependency pinned to a version the registry
reports as yanked/deprecated/retracted, covering either the lock-file-resolved
version or an exact manifest pin (e.g. requirements.txt’s ==1.2.3) when no lock
file exists. Checked for every dependency with a known in-use version — not only
one that differs from the registry’s reported latest — since it is a free
in-memory lookup against the version list deps-lsp already fetched to compute
“latest”, and only against a registry that exposes real per-version yank data.
This is one of two independent yanked-related diagnostics; see
Yanked Version Diagnostic below for the other, which
flags a requirement (a range, not necessarily an in-use version) satisfiable only
by yanked versions. deps-lsp never emits both for the same dependency — see that
section for how the two are deduplicated.
| Ecosystem | Yanked diagnostic | Registry signal |
|---|---|---|
| Cargo | Yes | crates.io sparse-index yanked |
| npm | Yes | npm deprecated |
| PyPI | Yes | PEP 592 per-file yank status |
| Bundler | Yes | RubyGems yanked |
| Dart | Yes | pub.dev retracted |
| Go | No | module proxy reports no retraction data |
| Maven | No | Maven Central has no retraction concept |
| Gradle | No | delegates to the same Maven Central registry as Maven |
| Swift | Partial | SE-0292 registry releases carrying a problem are yanked; GitHub-tag dependencies have no yank signal |
| NuGet | No | unlisted versions are not distinguishable from listed ones today |
| Composer | No | Packagist’s abandoned flag is package-level, not per-version — enabling it would fire on nearly every dependency of an abandoned package rather than the specific withdrawn release |
| Deno | Yes | JSR meta.json per-version yanked (genuine) for jsr: specifiers; npm deprecated (same package-level caveat as the npm row above) for npm: specifiers |
Yanked Version Diagnostic
The other of the two independent yanked-related diagnostics — see
Yanked-Version Diagnostics above for the in-use-version
check. When a dependency’s declared version requirement is satisfiable, but every
version that satisfies it has been yanked/deprecated by the registry, deps-lsp shows a
WARNING diagnostic (configurable via diagnostics.yanked_severity):
This version has been yanked
This only fires when at least one matching version exists and all matching versions are
yanked — the same scan Unsatisfiable Version Requirement (see
Version Diagnostics) uses (via
EcosystemFormatter::compile_bounded_requirement), cross-referenced against the registry’s yanked
flags. It is mutually exclusive with both the unsatisfiable WARNING (a yanked-only match is
a satisfied match, not zero matches) and the outdated/up-to-date check. If a non-yanked
version also satisfies the requirement (e.g. ^1.0 matching both a yanked 1.0.0 and a
non-yanked 1.0.1), this diagnostic does not fire — the dependency is not actually stuck on
a yanked version, and the ordinary outdated/up-to-date check applies instead.
It is also mutually exclusive with the in-use-version Yanked-Version
Diagnostics check above: for a dependency pinned to the one
version that also happens to be the only version satisfying its own requirement, both checks
would independently find a yanked verdict, but generate_diagnostics_from_cache skips this
check once the in-use-version check has already emitted a diagnostic for the same
dependency, so only one yanked diagnostic is ever shown per dependency.
- npm is disabled entirely; Composer is restricted to exact-pin requirements. Both source
their yanked flag from a package-wide signal — npm’s from
deprecated(live-verified: therequestpackage has 126/126 versions marked deprecated), Composer’s fromabandoned— not a true per-version yank, which is why this is a distinct diagnostic from Package Deprecation Diagnostics below rather than the same one. For npm, evaluating any requirement shape against that package-wide signal — including a bare exact pin — would too often just duplicate the package-level deprecation diagnostic, so this check is unconditionally off for npm (resolves #436); this also covers Deno’snpm:specifiers, which delegate to npm’s own registry data (resolves #448). For Composer, evaluating a range requirement against it would flag every dependency on an abandoned package, so a bare exact pin ("1.2.3", not"^1.2.3") is unaffected by that ambiguity and the check still applies there. Deno’sjsr:specifiers are unrestricted (any requirement shape): JSR’smeta.jsonyankedflag is a true per-version signal with no package-level deprecation payload to conflate with, unlike npm/Composer above (resolves #454).
Ecosystem coverage, live-verified per registry rather than assumed from code:
| Ecosystem | Works today? | Source |
|---|---|---|
| Cargo | Yes | sparse index yanked field |
| npm | No | deprecated exists but the check is unconditionally off (see restriction above) |
| PyPI | Yes | PEP 592 yanked |
| Composer | Yes, exact pins only | abandoned (see restriction above) |
| Dart | Yes | pub.dev retracted |
| Bundler | No | RubyGems’ versions.json never includes a yanked field on any entry (live-verified against the API directly) — indistinguishable from a version that never existed, same limitation the Unsatisfiable Version Requirement check documents |
| Go | No | GoVersion.retracted is hardcoded false at both construction sites in deps-go’s registry client — the field exists but is never populated from real data (tracked separately) |
| Maven | No | MavenVersion::is_yanked is a hardcoded false constant — Maven Central does not support version retraction |
| Gradle | No | reuses Maven Central’s registry client, same hardcoded false |
| NuGet | No | NuGetVersion::is_yanked is a hardcoded false constant |
| Swift | Partial | SwiftVersion.yanked is true for an SE-0292 registry release with a problem, always false for GitHub tags (no such concept in the source); SwiftRegistry::reports_yanked is true |
| Deno | jsr: yes, any requirement shape; npm: no | JSR meta.json per-version yanked for jsr: specifiers; npm deprecated (unconditionally off, see restriction above) for npm: specifiers |
| GitHub Actions | No | GitHub’s tags API exposes no yank/deprecation signal for actions — GithubActionsRegistry::reports_yanked is hardcoded false, same architectural gap as Swift |
5 of 13 ecosystems can produce this diagnostic today; npm is disabled by design rather than
lacking a real signal (see restriction above), and that also covers Deno’s npm: specifiers;
the remaining 7 have no real yanked signal to source it from (four are architecturally
impossible — no such registry concept exists — and Go’s is a fixable but separate gap).
Latest-Version Safety Check (issue #1517)
deps-lsp and deps-cli update never recommend or apply a dependency’s latest version
without an independent OSV.dev check on that exact version — not just on the version
currently pinned in the manifest. Before this fix, OSV’s second-phase (“phase B”) check on an
upgrade candidate only ran for a dependency already flagged vulnerable at its pinned
version, so a cleanly-pinned dependency’s latest was never itself checked: every renderer
that recommends latest as an upgrade, and deps-cli update’s default mode, could recommend
or silently apply a version OSV.dev flags as malicious or critical. Phase B now runs for
every dependency whose registry-reported latest differs from its pinned/in-use version,
regardless of whether the pinned version itself has any advisories.
This is entirely ecosystem-agnostic: the check lives in deps-core (osv::LatestStatusMap,
lsp_helpers::latest_verdict), not in any one ecosystem crate, so it applies uniformly to
every ecosystem OSV.dev has an advisory feed for — there is no npm-specific or Cargo-specific
variant of this logic.
The four verdicts
Every renderer computes one of four verdicts for the version it is about to show as latest,
via the shared latest_verdict gate:
- Verified — OSV.dev checked this exact version and found it clean (or affected only by an informational advisory). Shown normally as an ordinary upgrade recommendation.
- Flagged — OSV.dev checked this exact version and found a real advisory against it.
Hover shows
🚫 Latest version is confirmed malicious by OSV.dev — do not upgrade to this version(a confirmed-malicious-package record) or⚠️ Latest version is flagged by OSV.dev — do not upgrade to this version(any other non-informational advisory), and the advisory ids are listed. Diagnostics reportLatest version <v> is flagged by OSV (<ids>) — do not upgrade, atErrorseverity for a malicious record orWarningotherwise (escalated above the configuredoutdatedseverity). Inlay hints show🚫/⚠️ <v> flaggedinstead of the plain outdated icon. - Unverified — the version was never definitively checked: OSV hasn’t completed phase B
yet, a transient failure or timeout occurred, or the checked version has since diverged from
what’s now cached as
latest. Diagnostics reportNewer version available: <v> (not yet verified against OSV)at the ordinaryoutdatedseverity — visually distinct from bothVerified(no such caveat) andFlagged(no escalated severity), but treated identically toFlaggedfor whether the upgrade is recommended. - NotApplicable — OSV checking does not apply here at all: OSV is disabled or offline for
this scan, or the dependency’s source is structurally never checked against OSV (e.g. a git
dependency, or an ecosystem OSV.dev does not cover). Renders exactly like
Verified— no caveat, since there was never a check to distrust.
The safety principle is fail-closed: only Verified and NotApplicable are ever treated
as “safe to recommend or apply.” Anything not affirmatively verified clean — including a
timeout, incomplete advisory data, or a deliberate offline/disabled skip — is treated the same
as an actively flagged version. This applies before the very first phase B run completes, too:
an empty status map (nothing checked yet) yields Unverified, never a silent pass-through.
Where this applies
Every renderer that can surface latest as an upgrade consults this gate: hover, diagnostics,
code actions, code lens, inlay hints, and completion.
- The “Fix Vulnerability” / “update to version X” code actions (see below) and completion’s
version items never offer an item —
latestor otherwise (issue #1524) — unless its own verdict isVerifiedorNotApplicable; aFlaggedorUnverifieditem is simply omitted (code actions) or demoted/tagged (completion) rather than offered with no warning, so it can never be applied with one click. deps-cli update’s default mode refuses to write aFlaggedorUnverifiedlatestinto the manifest at all — the dependency is reported asUnplannablewith reasonLatestFlaggedByOsvorLatestUnverifiedrather than silently dropped or silently applied. There is currently no override flag: a dependency in this state cannot be updated tolatestviadeps-cli updateuntil OSV affirmatively clears it. If any in-scope dependency’slatestcomes backUnverified(rather thanFlagged), the whole run aborts early with a clear message instead of silently omitting just that dependency, the same way a registry-unreachable condition does.- As a side effect, a
Flaggedlatest also suppresses the release-freshness/cooldown callout (hover’s “recently published” notice and the equivalent GOSSIP-sourced diagnostic wording) for that version — a confirmed-unsafe version must never also read as a benign “just released, wait it out” notice.
Non-latest candidate versions (issue #1524)
Phase B’s candidate check also runs a bounded set of “candidate-check rounds” (up to 6, one per
rank among each dependency’s newest non-yanked registry versions), independent of the single
latest check above, and stores each version’s own verdict in osv::CandidateStatusMap —
looked up via lsp_helpers::candidate_verdict, the sibling of latest_verdict for a version
that isn’t necessarily latest. Code actions’ REFACTOR “update to X” list and completion’s
version items both consult it for every item except the one identified as latest (which still
goes through latest_verdict/LatestStatusMap), so a non-latest intermediate version that was
never independently checked is demoted/excluded exactly like an unsafe latest already is.
The candidate-check rounds select the newest non-yanked entries from the registry’s plain
version list (not the richer, dyn Version-based selection code actions/completion use to
choose what to display), so a display item outside that bounded set reads as Unverified and
is excluded/demoted; this is a deliberate over-conservative gap, never an under-conservative
one. In practice this is rare for code actions (they display the same unfiltered top
non-yanked set the rounds check), but not rare for completion: typing an
older-line prefix (e.g. serde = "0.9.) filters the display list down to versions the rounds
never cover at all, so every one of them reads as Unverified rather than transiently so.
Completion’s rendering accounts for this — an Unverified item is demoted (sort order,
explanatory detail text) but not tagged DEPRECATED (no strikethrough), since that would
otherwise read as “OSV actively flagged this” rather than “not independently checked.”
Completion’s own gate for whether to run this check at all is CompletionOrigin::Version —
the typed signal Ecosystem::generate_completions already resolved — not a position-based
heuristic. PyPI’s and Composer’s is_position_on_dependency overrides widen the span they
consider “on this dependency” to include the package-name position too (PyPI extras
completion, Composer’s alias forms), so using it as the sole gate would also reach
package-name completion on those two ecosystems and demote every item on every keystroke while
vulnerabilities checking is enabled.
Once a version-completion context is confirmed, locating which dependency is being completed
uses completion::version_dependency_at_position — the same two-pass lookup
(version_range-based, falling back to a same-line candidate when version_range is absent or
the cursor sits just outside it) an ecosystem’s own version-completion dispatch already applies
to produce the completion items in the first place — not is_position_on_dependency, whose
default has no such fallback and live-verified misses Maven’s self-closing <version/> tag (no
version text to have a version_range over). A lookup miss past this point — which shouldn’t
happen once the context is confirmed, but the document can still have changed between the
request and this check — fails closed: every item is marked unverified rather than left
untouched.
Requirement admits a flagged latest (issue #1526)
A permissive semver range can admit the registry’s latest without the dependency ever reading
as Outdated (e.g. ^1.0.4 with no lock file, where 1.0.4 is itself the flagged version) —
requirement_status_for returns RequirementStatus::UpToDate in this case, not Outdated. The
outdated-diagnostic rule and the up-to-date inlay-hint rendering both now consult
latest_verdict in this branch too, surfacing a diagnostic/warning icon when it resolves to
Flagged, instead of silently returning as if there were nothing to report. Deliberately scoped
to Flagged only, not Unverified: Unverified is the common, transient state for every
dependency before phase B first completes, and introducing a brand-new diagnostic on every
up-to-date dependency during that window would be a broad noise regression neither surface had
before.
Code Action: Fix Vulnerability
A dependency flagged by the OSV vulnerability scan (see the security-advisories hover
section and diagnostics) gets an extra code action alongside the plain “update to
version X” list: a quickfix titled Update to <version> (fixes <ADVISORY-ID>[ +N more]),
naming only the worst-severity advisory id and summarizing the rest so the title stays
readable in an editor’s code-action menu (the full id list still travels with the action
so editors can bind it to the matching diagnostics — see below). The target version is the
lowest version that resolves every advisory the action claims to fix: an advisory OSV
reports as still applying at the checked candidate (from the scan’s second-phase check) is
excluded from the claim, and — crucially — excluded before the target version is picked,
so that advisory’s own fix version (which may be much higher) can never inflate the
recommendation past what the claimed advisories actually need.
The action is independent of the registry fetch that produces the plain update list, so a
registry outage never hides it. When the registry fetch does succeed, a fix version the
registry reports as yanked is dropped instead of offered (no action, rather than silently
retargeting to some other version), and a fix version whose formatted manifest text
already matches the dependency’s declared requirement is skipped as a no-op edit — the
comparison uses the actual text the edit would write, not the bare version, since several
ecosystems format it differently (Dart wraps it in a ^ constraint, PyPI expands it into a
>=,< range). If the scanned version came from the lock file rather than the declared
requirement, the title gets an ; update lockfile to apply suffix, since editing the
manifest alone will not clear the diagnostic until the lock file is regenerated.
Editors that support diagnostic-bound quickfixes (surfacing the action from the advisory’s
own lightbulb rather than only the generic code-action menu) get this automatically: the
action carries its resolved advisory ids internally, and deps-lsp binds it to any
matching diagnostic the client already reported for the same range. Filtering code actions
by kind (e.g. an editor’s “quick fix only” view) is also honored.
Go note. The formatter hook this action relies on to convert an OSV-reported version
into go.mod’s v-prefixed form is in place, but Go’s vulnerability scan currently sends
the v-prefixed module version to OSV, which expects it unprefixed, and gets no matches
back (tracked separately) — so no Go dependency can trigger this action yet.
Supply-Chain Trust Signal (issue #543)
Hover can show an additional, informational line for a dependency’s upstream supply-chain health, sourced from deps.dev API v3 — a free, keyless, cross-ecosystem metadata API. Two independent pieces of data are shown together on one line:
🔐 Supply chain: OpenSSF Scorecard `8.5`/10 · Provenance: verified
- OpenSSF Scorecard — the linked source repository’s aggregate score (0-10), from checks like Code-Review, Dangerous-Workflow, Maintained, and Branch-Protection.
- Build provenance — whether the specific resolved version has a verified build
provenance or attestation:
verified(at least oneslsaProvenances[]/attestations[]entry is verified),attested but unverified(an entry exists but none is verified — shown rather than silently omitted, so “we checked and found nothing” is never confused with “we didn’t check”), ornone found(no provenance data at all for this version). Labeled plainly as “Provenance”, not “SLSA provenance”: the two arrays are unioned, and anattestations[]entry is not necessarily SLSA-specific.
Both halves render independently — a package with a Scorecard but no provenance data (or vice versa) shows only the half that resolved. The section is omitted entirely, with no error or warning, whenever deps.dev has nothing to offer: an unsupported ecosystem, no linked source repository, a deps.dev outage, or no concrete in-use version for the dependency (a lock-file- resolved version, or an exact requirement pin — the provenance claim is version-specific, so no in-use version means nothing safe to query).
Self-reported repository disclosure. A package can carry several SOURCE_REPO links to
deps.dev, distinguished by whether the link is SLSA_ATTESTATION-backed (cryptographically
tied to the published artifact) or merely UNVERIFIED_METADATA (taken from the package’s own,
unverified manifest metadata). deps-lsp prefers the attested link; when only a self-reported
one exists, the score is still shown but marked *(self-reported repo)* — a package could
otherwise point its metadata at an unrelated, reputable repository and borrow its Scorecard.
Coverage. deps.dev covers npm, Cargo, Go, Maven, PyPI, Bundler, and NuGet — Composer,
Dart, and Swift have no deps.dev coverage and issue zero requests for this signal. Gradle and
Deno’s npm: specifiers are not covered by this iteration either, though deps.dev’s maven
system would cover Gradle coordinates without further mapping work.
Performance. The fetch runs as a detached background task alongside the dependency’s normal
registry fetch, bounded by a short wait budget on the hover response itself — a slow or
first-ever (cold-cache) deps.dev lookup never delays or blocks any other hover content, and an
over-budget fetch still finishes into an in-process cache so the next hover on that dependency
is instant. Set supply_chain.enabled to false to turn the signal (and every deps.dev
request) off entirely — see the
Configuration reference.
Informational only, permanently: a low Scorecard score never becomes a diagnostic, warning, or blocking behavior, matching the release-freshness signal’s precedent for a supply-chain-risk-adjacent hover addition.
Typosquat Detection (issue #1437)
Flags a declared direct dependency whose name deps.dev reports as asymmetrically
similar to a much more popular package — a possible typo or a deliberate
typosquat (crossenv vs cross-env, expres vs express).
How It Works
For a declared dependency in one of the seven deps.dev-covered
ecosystems (Cargo, npm, PyPI, Go, Bundler, Maven, NuGet), deps-lsp queries deps.dev’s
v3alpha GetSimilarlyNamedPackages endpoint. That endpoint returns identity only — no
popularity data — so popularity is resolved separately via GetPackage (the package’s
default version) and GetDependents (that version’s dependent-package count), for both
the declared package and up to five similarity candidates.
A candidate fires the diagnostic only when both hold:
- its
dependentCountis at least 50× the declared package’s owndependentCount(a threshold validated against real deps.dev data: confirmed historical typosquats score 300×-3000×, the closest known legitimate similarly-named pair,coffee-script/coffeescript, scores ~6.9×) - its
dependentCountis at least 50 in absolute terms, so two obscure packages can’t trip the ratio on noise
Composer, GitHub Actions, GitLab CI/CD, Dart, Swift, Gradle, and Deno have no deps.dev coverage and are never queried — not a deferred gap, deps.dev does not track those package systems at all. Only manifest-declared direct dependencies are checked, never transitive/lockfile-resolved ones.
Behavior
- Renders as a single
Severity::Hintdiagnostic (codetyposquat-suspect) naming the suspected-intended package — deliberately the weakest severity this project uses, since the similarity algorithm is an undocumented deps.dev black box, not a structured, confirmed finding like an OSV advisory or a license violation. - Resolved via a background, per-document pre-fetch (mirroring the tier-3 license pre-fetch’s design) — never blocks a hover or diagnostics response; a result that arrives after the initial diagnostics publish triggers its own follow-up publish.
- A dependency’s name is only ever sent to deps.dev when its declared source is a public registry — a private-registry, git, or path dependency’s name is never sent, and a dependency that switches to a non-public source after a signal was resolved stops rendering it immediately (re-checked at render time, no network call).
- Never wired into any rename/quickfix code action — the similarity signal is materially weaker evidence than the structured-registry-field bar package-rename quickfixes require.
Configuration
Ships disabled by default — enable via typosquat.enabled (see the
Configuration reference). Unlike
supply_chain/diagnostics.vulnerabilities_enabled (both opt-out), this signal is built on
an undocumented, v3alpha (no stability guarantee) similarity algorithm, so it stays
opt-in-only at launch; default-on is deferred to a separate future issue once the endpoint
has shown stability across releases.
Known Limitations
- A signal that fires once persists on that dependency until it’s edited/removed or the
document closes, even if a later re-check would no longer find the same candidate
qualifying (e.g. the candidate’s own popularity dropped). This is a deliberate trade-off
for a
Hint-severity, best-effort signal, not a bug. - The similarity algorithm’s exact matching logic is deps.dev’s own, undocumented
implementation detail —
deps-lsphas no visibility into false negatives (a real typosquat deps.dev’s algorithm simply doesn’t surface as “similar”).
deps.dev GOSSIP Signals (issue #1456)
Sources hover’s and diagnostics’ outdated-release-cooldown callout from deps.dev’s authoritative GOSSIP (Google Open Source Security Intelligence Platform) Dynamic Cooldown signal, and adds a live hover low-usage/slopsquatting-risk callout — on top of, never replacing, the existing local cooldown heuristic.
How It Works
For a declared dependency in one of the seven deps.dev-covered
ecosystems (Cargo, npm, PyPI, Go, Bundler, Maven, NuGet), deps-lsp batch-fetches
GOSSIP findings for every dependency in a document via deps.dev’s v3alpha
GetFindingsBatch endpoint (one POST per document, not one call per dependency), through a
background, per-document pre-fetch mirroring the typosquat pre-fetch’s design.
- Cooldown: hover’s “Latest” callout and diagnostics’ outdated-dependency message read
the prefetched result synchronously — no live network wait. The GOSSIP-sourced answer is
used only when its own recorded version exactly matches the version being displayed;
otherwise
deps-lspfalls back to the existing local heuristic (freshness.cooldown_secs) unchanged. A GOSSIP-sourced cooldown callout is worded distinctly (“deps.dev/GOSSIP reports…”) so its source is never ambiguous with the local heuristic’s own wording. - Low usage: hover additionally makes one live, version-scoped
GetFindingscall for the pinned/resolved version (not necessarily the package’s default version, so the document-level batch can’t cover it), flagging a version deps.dev considers suspiciously low-usage — a slopsquatting/LLM-hallucinated-name risk signal — as a non-blocking, low-severity invitation to double-check the package identity. Skipped entirely when no concrete in-use version can be resolved (a range-only requirement with no lock file). - Completion gets its own, GOSSIP-free local cooldown baseline: every version candidate’s relative-age label is badged (⏳) when it falls within the configured cooldown window, using the same locally-known publish timestamps completion already collects. This works for all 14 ecosystems, including the seven GOSSIP doesn’t cover, and needs no network call.
Composer, GitHub Actions, GitLab CI/CD, Dart, Swift, Gradle, and Deno have no deps.dev coverage — completion’s local baseline still covers them, hover/diagnostics fall back to the local heuristic exactly as they did before this feature.
Configuration
Ships disabled by default — enable via gossip.enabled (see the
Configuration reference). Opt-in for the
same reason the typosquat diagnostic is: the per-document batch
prefetch discloses every declared dependency’s name to deps.dev. Completion’s local
cooldown baseline is unaffected by this flag — it never reads GOSSIP data and is always on
(subject to the existing freshness.enabled/freshness.cooldown_secs settings).
deps-cli Parity (issue #1474)
deps-cli check/update also honor [gossip].enabled (spec 074). Unlike deps-lsp’s
hover/diagnostics-only integration above (which only ever rewords a message — the local
freshness.cooldown_secs heuristic never excludes a version from being “latest” either),
GOSSIP is the only mechanism in deps-cli that can change which version counts as
“latest” at all, for both check’s Outdated classification (indirect only — no new
Category/--fail-on token) and update’s fix-target selection, since both share one
classification pipeline.
This is floor-protected, and requires a concretely resolved in-use version to do
anything at all: GOSSIP may only exclude a version strictly newer than the dependency’s
already-declared/in-use version, and only when one can actually be resolved (lockfile-backed,
or an unambiguous exact pin). The floor is a hard lower bound not just on what GOSSIP may
exclude but on the final pick itself — even when the floor version survives filtering, it
can still turn out to be unselectable by the ecosystem’s own rules (e.g. an in-use prerelease
or yanked version); if that happens, deps-cli falls back to the pre-GOSSIP pick rather than
ever accepting an even-older release below the floor. So latest can never regress below
what’s already declared — a manifest already pinned to the flagged version is left untouched,
update can never be pointed at a downgrade, and an Outdated finding is attributed to
GOSSIP in its message only when the exclusion actually changed the pick. When no in-use
version can be resolved at all (a fresh dependency add, or any range requirement with no
lock file — the common case for a bare Cargo requirement, which is a range, not an exact pin,
or an unlocked npm/PyPI range), GOSSIP deliberately excludes nothing for that dependency this
run, rather than risk a downgrade with no floor to protect it.
update --security-only’s fix target comes from the advisory instead, so GOSSIP (like the
local cooldown heuristic) has no effect there — deps-cli warns about this the same way it
already does for --cooldown.
A [gossip]/[typosquat] section that differs from the default now prints a
“has no effect” warning for an auto-discovered deps.toml
([gossip] — reset back to default there, spec 062’s untrusted-input hardening) or,
for [typosquat] specifically (deps-cli has no typosquat integration at all yet), for an
explicit --config file too.
Known Limitations
- Findings are cached per-package for up to an hour; an idle, unedited document can show a GOSSIP answer that is up to an hour stale before the next natural prefetch trigger (an edit, a reopen, or a config change) refreshes it.
deps-clihas no Low-Usage Packages parity — no existing user demand signal for the CLI’s equivalent of hover’s “invite to double-check” callout (deferred, spec 074 §1).- The GOSSIP API is still
v3alpha(no GA designation) — same provisional-integration posture as the typosquat diagnostic.
Version Diagnostics
Unsatisfiable Version Requirement
When a dependency’s declared version requirement matches zero published versions — of any kind, stable, prerelease, or yanked — deps-lsp shows a WARNING diagnostic:
No published version satisfies requirement '99'; latest is 1.0.214
This is distinct from Unknown package (the package itself was not found) and from the
“Newer version available” HINT (a satisfiable requirement that simply isn’t pinned to the
latest release). The two are mutually exclusive on the same dependency — a requirement is
either up to date, outdated-but-satisfiable, or unsatisfiable, never more than one at once.
The check is always on (no configuration flag) across 12 of the 13 ecosystems — GitHub Actions does not opt in (a pin is not a range, so there is no “requirement satisfies zero versions” question to ask) — and is deliberately conservative:
- Suppressed while versions are still loading, or if the registry fetch failed — an empty/unknown version list means “don’t know yet”, not “nothing published”.
- Suppressed for path/git/URL/SDK/workspace dependencies — their
versionfield, if present, does not refer to something resolvable against the ecosystem’s package registry at all (e.g. this project’s owndeps-core = { path = ..., version = "0.10.1" }, or Dart’s{ sdk: flutter, version: "^3.24.0" }, which resolves against pub.dev’s unrelated package literally namedflutter). - Suppressed for an unresolved requirement — a dangling Gradle version-catalog
version.refalias or an unexpanded Maven${property}was never actually checked against anything. - A prerelease-only or yanked-only match still counts as satisfied — neither triggers
this WARNING.
foo = "2.0.0-beta.1"is a deliberate opt-in, and a yanked version is still installable when pinned (Cargo resolves yanked versions present in the lock file); flagging either as unsatisfiable would be a false positive. A yanked-only match is not silent, though — it surfaces instead as the separate Yanked Version Diagnostic. - Suppressed for requirement forms naming a version outside the fetched candidate list by
construction, not just failing to match one present in it — Go pseudo-versions and
dev-*/*-dev/@devComposer branches (never enumerable from the registry list at all), and Maven/Gradle-SNAPSHOT/LATEST/RELEASE(resolved via a different repository/side channel this registry never queries). - RubyGems exact pins are suppressed only when the pin does not exceed the highest
published version. RubyGems’
versions.jsonomits yanked versions from the list with no flag to detect them, so a pin that could plausibly name a hidden yanked version is not flagged. A pin above every published version — a mistyped or genuinely unpublished version, e.g.gem "foo", "99.0.0"whenfootops out at2.0— is still flagged as unsatisfiable. - Each ecosystem opts in by implementing a precise per-version-format comparator (the same
crate its registry client already depends on:
semverfor Cargo/Swift,node-semverfor npm,pep440_rsfor PyPI, bracket-interval range parsers for Maven/Gradle/NuGet, and exact/pattern comparators for Go/Bundler/Dart/Composer) — not the same loose heuristic used for the “up to date” hint, which is intentionally permissive and would produce false positives if reused here (e.g. Cargo’s~1.0.999reads as “up to date” against a latest of1.0.214under the loose same-major-minor heuristic, despite patch999never having been published). - Cargo, npm, and Swift additionally name a matching pre-release, if one exists. These
three ecosystems use a strict SemVer-style matcher that excludes prereleases unless the
requirement itself names one — so a requirement like
^2.0.0reads as fully unsatisfiable even when a2.0.0-rc.1has been published. The WARNING then appends a clause naming it:
The hint is skipped when the requirement itself already names a pre-release (the real blocker there is version ordering, not pre-release exclusion) and when the only matching pre-release has been yanked. Maven/NuGet/Composer/Gradle’s range-parsing model already admits prerelease qualifiers within a range and is unaffected.No published version satisfies requirement '^2.0.0'; latest is 1.5.0 (a pre-release, 2.0.0-rc.1, is excluded by SemVer's default pre-release-matching rules; require it explicitly to use it)
Not yet implemented: a separate informational diagnostic for a requirement that only matches prerelease versions.
Code Action: Fix Unsatisfiable Requirement
A dependency flagged by the diagnostic above gets a QUICKFIX titled Fix unsatisfiable requirement: update to <version>, targeting the same cached latest value the diagnostic
message names, so the action’s title and the diagnostic text always agree on what “the
latest” is. The action is gated by the identical unsatisfiability check the diagnostic uses,
so the action never appears without the diagnostic — though several further guards (a
yanked target, a no-op or still-unsatisfiable rewrite, a text collision with the
vulnerability fix) can independently suppress the action while the diagnostic itself stays up.
Like the vulnerability fix above, this action is computed before the registry fetch that produces the plain update list, so a registry outage never hides it. When the fetch does succeed, a target the registry reports as yanked is dropped rather than offered. The rewritten text is re-checked against the same unsatisfiability predicate before the action is returned, since an ecosystem that preserves operator style when rewriting a requirement (PyPI, Gradle) can otherwise produce another still-unsatisfiable range; this re-check cannot prove a rewrite it cannot evaluate is correct, so it holds for every rewrite the ecosystem’s own comparator can judge, not unconditionally.
If both this action and the vulnerability fix apply to the same dependency and would write byte-identical text, the vulnerability fix (the more informative title) is kept and this one is dropped. At most one action across the whole response is ever marked as the editor’s preferred quickfix, in priority order: vulnerability fix, then unsatisfiable-requirement fix, then the REFACTOR item pointing at the newest available version.
Editors that support diagnostic-bound quickfixes get this automatically, the same way as the vulnerability fix (Code Action: Fix Vulnerability): the action binds to any matching diagnostic the client already reported for an overlapping range.
Package Deprecation Diagnostics (issue #205)
The two yanked diagnostics above (see Yanked Versions &
Vulnerabilities) answer “is this version installable”; this
one answers a different, package-level question — “is the project itself still maintained” —
regardless of which version is declared or resolved. When the registry reports the package’s
latest version as deprecated/abandoned, deps-lsp shows a diagnostic (configurable via
diagnostics.deprecated_severity, default WARNING):
This package is deprecated: use String.prototype.padStart() instead
The hover popup gets a matching ### Deprecated section with the same reason text and, when
the registry names one, a suggested replacement package. Derived entirely from data the
regular version fetch already retrieves — no extra registry request.
Suppression against the yanked diagnostics above. npm’s yanked signal is itself sourced
from the same deprecated field this diagnostic reads, so a dependency pinned to an exact,
deprecated version would otherwise show two near-duplicate diagnostics. When this diagnostic
fires, it suppresses both yanked checks above for the same dependency — the in-use-version
check and the range-requirement-only-satisfiable-by-a-flagged-version check — but only when
the matched yanked finding’s underlying signal is an advisory (AdvisoryDeprecated), never a
genuine hard yank/retraction. A package that is both deprecated and has a specific version
really withdrawn from resolution still shows both diagnostics; “the exact version you have was
pulled” is strictly more actionable than “the project is archived,” and one must never hide
the other.
Each of the two yanked checks decides this independently from its own matched version’s
RemovalStatus (issue #437) — the range check does not defer to, or require, the
in-use-version check’s own finding. This matters once an ecosystem’s per-version status can be
AdvisoryDeprecated for one version and Yanked for another within the same package (not
possible for npm/Composer today, since npm never reports a real yank and Composer’s range
check never runs, but expected once PyPI’s PEP 592 yanks and PEP 792 project-status coexist):
a range requirement satisfiable only by a genuinely yanked version still fires even when the
package’s separately-tracked in-use/latest version is merely deprecated and its own diagnostic
was suppressed.
Composer-only “Replace with X” code action. When Packagist’s abandoned field names a
successor package, a QUICKFIX titled Replace with <package> rewrites the dependency’s name
in place. Not offered for npm: its only successor signal is free-text prose inside the
deprecated message, and regex-extracting a package name from registry-controlled text to
rewrite a manifest is a typosquatting vector — npm still gets the diagnostic and hover (the
message is shown verbatim, which is the useful part), just not an automated rename.
| Ecosystem | Works today? | Source |
|---|---|---|
| npm | Yes | deprecated free-text message (no structured replacement — see above) |
| Composer | Yes, with replace action | abandoned (bare true, or a string naming a successor package) |
| Cargo, Go, PyPI, Bundler, Dart, Maven, Gradle, Swift, NuGet, Deno | Not yet | No registry-native package-level deprecation signal wired up yet (tracked as fast-follows; Dart’s isDiscontinued/replacedBy and PyPI’s PEP 792 project-status already exist on the wire and are the best next targets) |
Dependency-Count Ceiling Diagnostic (issue #796)
All 14 ecosystems. A manifest may declare far more dependency entries than any real
project has — an adversarial or malformed file with hundreds of thousands of declarations
would otherwise drive both server memory and outbound registry request volume linearly with
a number the manifest’s author controls. Each ecosystem’s own parser threads a shared
deps_core::DependencyBudget through its dependency-collecting loop(s), so the concrete
per-document dependency list it retains — the thing that stays resident in memory for the
life of the open document — never grows past MAX_DEPENDENCIES_PER_DOCUMENT (5000) in the
first place, rather than being truncated after an oversized list was already built and kept
around. deps_core::ecosystem::parse_manifest_blocking (the single chokepoint every
ecosystem’s parse result flows through before reaching deps-lsp) applies
deps_core::dependency_cap’s view-level cap as a belt-and-braces backstop on top, so hover,
completion, diagnostics, inlay hints, code lens, and the registry fetch fan-out all only
ever see the capped subset even if some ecosystem parser were ever added without wiring in
the budget itself. The largest real-world manifests sit in the low hundreds of dependencies,
so this leaves generous headroom for any legitimate project.
When a manifest exceeds the ceiling, only the first 5000 declared dependencies are tracked
and checked against the registry; the rest are silently untracked (parsing itself is not
rejected, unlike the unrelated 10MB file-size limit). An INFORMATION-severity diagnostic
is published at the top of the file naming the limit and the manifest’s true dependency
count:
manifest declares 12000 dependencies, exceeding deps-lsp's per-document limit of 5000; only the first 5000 are tracked, fetched, and checked against the registry
This limit is hardcoded, not configurable — the same “security limit, not a user preference” reasoning as the 10MB file-size cap.
CodeLens: “Update N Outdated Dependencies”
An open manifest with at least one outdated, safely-editable dependency shows a code lens at
the top of the document, titled Update N outdated dependencies. Clicking it applies a
single batch edit that rewrites every such dependency’s version to the latest known
version, sharing the same “is this outdated” definition as diagnostics (a requirement
already satisfied by the latest version — e.g. Cargo’s ^1.2 accepting 1.9 — is left
alone; that lag is the lock file’s, not the manifest’s, to fix).
Coverage caveat. Before rewriting a dependency’s declared version text, the feature verifies the manifest span it is about to edit actually is that version literal. Some ecosystems point the tracked span at something else instead:
pom.xmldependencies versioned through a<properties>placeholder (<version>${my.version}</version>) are skipped — the span covers the placeholder, not a literal.- Gradle dependencies versioned through a DSL variable (
"...:$myVersion", resolved fromgradle.properties) or alibs.versions.tomlversion-catalog alias (version.ref = "spring") are skipped for the same reason. Package.swiftdependencies declared with a two-literal range ("1.0.0"..<"2.0.0"or"1.0.0"..."1.9.9") are skipped — the tracked span covers only the range’s lower-bound literal, and rewriting that literal alone would invert the range (SwiftPM traps onlowerBound > upperBound, corrupting the whole manifest) rather than leave a merely-stale-but-valid declaration.
For these, no lens appears even when the dependency is genuinely outdated — this is the correct, conservative behavior (silently declining to edit is far better than corrupting a build file), not a bug. The per-line “Update to latest version” code action shares the exact same guard, so it declines the same way rather than corrupting these declarations.
Package.swift’s other declaration forms (from:, .upToNextMajor, .upToNextMinor,
.exact) were affected by this same guard through #367: each synthesizes a comparator
requirement (e.g. .exact("4.50.0") -> =4.50.0) that never textually matched the bare
version literal the tracked span actually points at, so the guard rejected every one of
them and both the lens and the code action silently did nothing. Fixed by having
deps-swift additionally report the bare literal (Dependency::version_literal()) the
guard should compare against; these four forms now get a lens and code actions like any
other registry-form dependency (.branch/.revision/.package(path:) dependencies still
have no version to update, same as any other ecosystem’s git/path dependency, and the two
range forms above remain guard-skipped by design).
Only the six documented .package(...) spellings above are parsed at all — a handful of
other valid SwiftPM argument-label combinations (.package(url:exact:), .package(url: branch:)/(url:revision:), .package(id:...), the legacy .package(name:url:...), or any
of the above with a trailing comma) currently parse to zero dependencies rather than a
skipped one; extending parser coverage to them is tracked separately, out of scope here.
Known divergence from inlay hints (accepted, documented). Inlay hints use a
lock-file-aware “outdated” check (resolved version vs. latest), while the lens and
diagnostics use the manifest-requirement check described above. With a lagging lock file
and a requirement permissive enough to already accept the latest version, inlay hints can
render ❌ <version> on a dependency with no matching diagnostic and no lens — the fix
in that case is regenerating the lock file, which only the package manager can do, so
there is nothing for the lens to edit. Unifying the two definitions is tracked as a
follow-up.
CI/CD Pinning
These two features are specific to GitHub Actions and GitLab CI/CD — the two YAML-based CI/CD ecosystems where a dependency reference is commonly a mutable tag or branch rather than a version range.
Mutable-Ref-Pin Diagnostic (issue #473, #634)
A dependency or include pinned to a mutable ref can silently
start running different code than the workflow/manifest file shows: a compromised or republished tag
changes what CI executes without a single line of the file changing. This is a distinct, additive
signal from the outdated-version diagnostic (configurable via
diagnostics.mutable_ref_pin_severity, default HINT) — a pin can be up to date on its tag and
still vulnerable to tag mutation, so both diagnostics can fire independently on the same target
with distinct codes:
GitHub Actions: A uses: step pinned to a mutable ref — a tag (actions/checkout@v4)
or, in a future iteration, a branch — is flagged via this diagnostic:
actions/checkout is pinned to the mutable tag ref `v4`; pin to a full commit SHA to guard against tag mutation
GitHub Actions: The diagnostic fires for two kinds of tags:
- Semantic-version-shaped tags (
v1.2.3,v4.0, etc.) — detected by text pattern and confirmed via the registry. - Literal-named tags (non-version-like names such as
cargo-deny,latest-stable) — these do not look tag-shaped to the parser, but when the registry confirms they are real tags, they become eligible for this diagnostic too (issue #551).
“Pin <name> to commit SHA” quick fix (GitHub Actions). When offered, rewrites the step’s ref to
<sha> # <tag> — the exact same {sha} # {tag} shape the outdated-SHA-update quick fix already
produces for a SHA-pinned step, reusing the tag/SHA cross-reference already populated by the
existing outdated-version check (zero new network calls). The quick fix is withheld, not offered
with a wrong or destructive edit, in three cases:
- The tag’s commit SHA is not yet known — e.g. the document was opened before the registry fetch
completed, or the tag was moved/deleted/is not a full
major.minor.patchrelease the registry indexes (a moving major-version ref likev4itself is frequently in this category — GitHub’s tags API listsv4.3.1, not a syntheticv4tag object). - For literal-named tags, even if the SHA is known, the quick fix is deliberately withheld (safety boundary FR-005) — a literal tag name like
cargo-denycould in principle be a typo for a branch name, and silently replacing it with an auto-rewritten SHA would lock workflow behavior in place without the author’s explicit intent. The diagnostic still fires so you know the tag is mutable, but the fix requires manual intervention to avoid accidental branch/tag confusion. - The
uses:value is a quoted YAML scalar (uses: "actions/checkout@v4"). The ref text sits inside the quotes there, so appending# <tag>would place a#inside the string rather than starting a YAML comment — auses:value GitHub Actions rejects. Re-pin a quoted step by hand, or remove the quotes first.
Out of scope for GitHub Actions this iteration: branch pins (@main) get no diagnostic yet — no
tag-to-SHA-style index exists for branches, and adding one would require a new network call per
branch; reusable-workflow calls (owner/repo/.github/workflows/x.yml@ref) and ./local/
docker:// references are not resolvable refs and get no diagnostic either.
GitLab CI/CD: The diagnostic fires for:
include: - project:pins to a mutable tag or branch ref — the ref is resolved against the GitLab repository-tags API. The diagnostic fires for tag-shaped refs (semantically-versioned or literal-named tags confirmed as real).include: - component:pins to~latestor a partial-semver version (e.g.1.2for a component published via releases) — while not a branch-vs-tag confusion risk like GitHub Actions, these are still mutable in the sense that the pin can resolve to a different release if the GitLab project publishes a new one (resolved against the project-releases API).
“Pin to commit SHA” quick fix (GitLab CI/CD). For project: includes, rewrites the tag/branch ref to
its resolved commit SHA; for component: includes with ~latest/partial pins that have already been
resolved against the releases API, shows the fix was available (resolves #643). The quick fix is withheld in
the same cases as GitHub Actions: SHA not yet known (still loading), or for literal-named tags (same
branch/tag ambiguity safety concern).
Unlike every other diagnostic in this project, severity alone cannot silence this one —
DiagnosticSeverity has no suppression value. Set diagnostics.mutable_ref_pin_enabled to
false in initialization options to turn it off entirely for teams that intentionally accept
mutable pins.
Bulk “Pin All to SHA” Code Lens (issue #633, generalized cross-ecosystem in #640)
A document with at least one mutable-ref pin resolvable to a commit SHA shows a second
code lens alongside Update N outdated dependencies, titled Pin N {noun} to commit SHA (GitHub Actions: Pin N actions to commit SHA; GitLab CI: Pin N refs to commit SHA). Clicking it rewrites every such pin in one batch edit — the bulk counterpart of
the per-position “Pin <name> to commit SHA” quickfix each ecosystem already offers.
The lens/command itself is a shared deps-lsp/deps-core mechanism
(Ecosystem::collect_pin_all_to_sha_edits); an ecosystem with no mutable-ref pin concept
simply never shows it.
GitHub Actions: reuses the exact same TagIndex lookup the per-step quickfix uses
(zero additional network calls: bulk-pinning N actions costs N hashmap lookups, not N
registry fetches).
A step is included only when the per-step quickfix would also offer it — the bulk lens is never laxer than the single-step fix:
- Only a statically-classified
PinStyle::Tagstep is pinned; a registry-confirmed literal-named tag (thePinStyle::Branchcase from the diagnostic section above) has no automated fix here either, for the same branch/tag name-collision safety reason. - A step whose tag has no resolvable
TagIndexentry yet (cache miss — e.g. the document was opened before the registry fetch completed) is silently skipped, not blocked on or turned into an extra fetch. - A quoted
uses:scalar (uses: "actions/checkout@v4") is skipped, matching the per-step quickfix’s own guard against corrupting the quoted value. - A
uses:step written in YAML flow style ({uses: actions/checkout@v4, with: {node: 20}}) is skipped: appending# <tag>right after the ref would comment out the rest of the flow collection, producing invalid (unterminated) YAML instead of merely leaving the step unpinned. Ordinary block-style steps — the overwhelming majority of real workflows — are unaffected.
The lens itself is omitted entirely — no permanent line-0 annotation — when nothing in the
document is eligible: an all-SHA-pinned workflow, an empty workflow, or one where every tag
is still an unresolved cache miss. It is also omitted whenever
diagnostics.mutable_ref_pin_enabled is false — the same on/off switch that silences the
mutable-ref-pin diagnostic above, since a lens is permanently rendered (unlike the pull-based
per-step quickfix) and must respect the same opt-out.
Known limitations. The lens counts only steps resolvable via the live TagIndex, while
the mutable-ref-pin diagnostic flags every PinStyle::Tag step regardless of resolvability —
so a workflow can show more mutable-ref-pin squiggles than the lens’s own count, and clicking
the lens can leave some squiggles in place (the steps it genuinely could not resolve). The
lens’s displayed count can also drift from the number of edits actually applied if a
background fetch for another open workflow evicts a TagIndex entry between render and
click (the shared index is bounded to 256 repositories).
GitLab CI: covers a project:/component: include pinned via PinStyle::Tag
(resolved synchronously against the shared TagIndex, same as GitHub Actions) and a
component: include pinned via ~latest/a partial version (1.2), resolved against the
project’s already-fetched release list — the lens never itself performs a network fetch,
so a ~latest/partial pin is only counted (and edited) once that data has already been
loaded for some other reason (e.g. hover, diagnostics). A SHA pin, an unconfirmed branch
ref, and a ref-less project: include are never eligible, matching the per-position
quickfix’s own restrictions.
See also GitHub Actions and GitLab CI/CD for the rest of each ecosystem’s own features.
Ecosystem Reference
Each chapter here documents the ecosystem-specific behavior for one of the 14 supported package ecosystems — custom/private registry resolution, parser quirks, and any feature that is not shared broadly enough to belong in Cross-Ecosystem Features.
A feature shared by exactly two or three ecosystems (e.g. Bundler/Dart custom registries, Dart/Composer version comparison, NuGet/npm freshness, Swift/GitHub Actions freshness) is documented once, in whichever ecosystem it is most central to, with a cross-link from the other(s) — rather than duplicated.
See the Introduction for the at-a-glance table of manifest/lock file names and top-level feature coverage per ecosystem.
Cargo
deps-cargo provides full LSP support for Rust’s package manager.
Basics
| Manifest file | Cargo.toml |
| Lock file (in-use version) | Cargo.lock (versions 3 and 4) |
| Registry | crates.io — sparse index (index.crates.io) for version lookups, REST API (crates.io/api/v1) for package-name search |
| Version syntax | Cargo’s own requirement grammar (semver::VersionReq): ^, ~, =, <, >, *, and bare comparison operators |
deps-cargo parses every dependency-like table — [dependencies], [dev-dependencies],
[build-dependencies], their [target.<cfg-expr-or-triple>] variants, and a workspace
root’s [workspace.dependencies] — with byte-accurate position tracking, so hover,
diagnostics, completion, and code actions all anchor on the exact name/version/features span
in the file, not just the dependency’s line.
[dependencies]
serde = "1.0"
tokio = { version = "1", features = ["full"] }
Hovering serde’s version shows the latest crates.io release, whether the declared
requirement is satisfied, and a link to the crate’s crates.io page; an outdated requirement
gets an inlay hint (❌ 1.0.219) and a matching diagnostic with a “Update to latest version”
code action. Typing inside the features array offers completion sourced from the latest
stable version’s real feature list. workspace = true inheritance, path =/git =
dependencies, and a package = "..." rename (aliasing the TOML key to a different registry
lookup name) are all recognized and routed correctly — a renamed dependency’s diagnostics and
completions key off the real crate name, not the local alias.
Custom/Private Registries
A Cargo dependency declared as registry = "<alias>" or registry-index = "<url>"
resolves against that registry’s own sparse index — hover, diagnostics, completion,
and code actions all work against it exactly as they do for a plain crates.io
dependency, instead of showing no version data at all.
Resolution: registry = "<alias>" is resolved by reading [registries.<alias>]
from the same .cargo/config.toml hierarchy Cargo itself consults — every
ancestor directory’s .cargo/config.toml between the opened manifest and the
filesystem root, closest directory winning — plus $CARGO_HOME/config.toml as the
lowest-precedence tier. registry-index = "<url>" needs no config lookup: it is
already a concrete index URL. Only sparse+https:// (or a bare https://) index
URLs are supported; http:// and any URL carrying user:pass@ are rejected.
Cargo’s CARGO_REGISTRIES_<NAME>_INDEX/_TOKEN environment variable overrides are
also honored.
Authentication: a registry token is attached to requests against a registry
resolved from $CARGO_HOME/config.toml (or its own CARGO_REGISTRIES_<NAME>_TOKEN
environment variable) only. A registry alias resolved from a workspace
.cargo/config.toml — a file a cloned, untrusted repository fully controls — never
gets a credential attached, even if an identically-named alias is configured with
one in $CARGO_HOME. This is deliberate: it prevents a hostile repository from
redirecting a familiar alias name (e.g. "github") to an attacker-controlled host
and harvesting whatever token the user’s real, differently-scoped registry of that
name would have used.
The token is sent verbatim in the Authorization header, with no Bearer prefix, exactly as
Cargo itself sends it.
Mirroring crates.io ([source] replace-with): a workspace’s
[source.crates-io] replace-with = "<name>" chain, terminating at a
[source.<name>] registry = "sparse+https://…" entry, reroutes every plain
(un-aliased) dependency to that mirror — hover/diagnostics/completion reflect the
mirror’s data, the crates.io hover link stays intact (Cargo verifies per-version
checksum equality against crates.io for a mirror, so its content is exactly as
trustworthy as crates.io’s own), and OSV vulnerability scanning still runs against
it. A chain terminating at a directory (vendored), local-registry, or
git-index (non-sparse) source instead leaves plain dependencies resolving against
crates.io unchanged — vendoring/mirroring through those mechanisms doesn’t
guarantee the same version set as crates.io, so degrading to no data would be
worse than the pre-existing crates.io answer.
Reachability policy (registries.workspace_registries, security): a
workspace-declared registry index (the registry/registry-index alias path, or a
[source] mirror) is checked against this setting before it is ever fetched — a
hostile cloned repository can write both, and this LSP parses on file open, before
any build runs. This setting is shared with npm’s .npmrc resolution, PyPI’s
custom-index resolution, NuGet’s NuGet.Config resolution, Go’s GOPROXY
chain, and GitLab CI/CD’s self-hosted-instance resolution — one process-wide
HttpCache policy governs every ecosystem’s workspace-declared registry fetches;
see npm,
PyPI,
NuGet,
Go, and
GitLab CI/CD for what
that sharing means in practice. Three values:
| Value | Behavior |
|---|---|
"public_only" (default) | Only a publicly-routable host is fetched — blocks loopback, link-local, RFC1918/CGNAT, unique-local-v6, and cloud-metadata-range hosts (e.g. 169.254.169.254) declared by a workspace file. A corporate https://index.mycorp.dev-style registry still works, since a DNS name cannot be classified as internal without resolving it — see the residual-risk note below. |
"off" | No workspace-declared index is ever fetched — the only complete boundary. Applies to the alias path as well as [source]. |
"all" | Public hosts plus the private hosts and CIDR ranges listed in the DEPS_LSP_PRIVATE_REGISTRY_HOSTS environment variable (see Configuration) — the escape hatch for a workspace that legitimately points at an RFC1918 registry. Without that variable it behaves like "public_only": the setting is repository-controllable, so the variable is the effective control, and a listed host is reachable on any port. It never allows loopback, link-local, cloud-metadata, unspecified or reserved addresses, which are refused under every value. |
Proxies. A workspace-declared registry connects directly and bypasses the system proxy by default, so the connect-time check sees the real address; see Proxies and guarded registry traffic for the opt-in variable.
Hosts that resolve to a blocked address. The check above looks at the declared host name. A
name that resolves, when the connection is made, to a blocked address class (for example a public
name that points at 10.0.0.5) is refused too, in every ecosystem, and the dependency shows a
policy-specific message instead of a generic fetch failure. The message names the address class
only, never the address. For the classes "all" does not allow, it says the host “is never a
registry under any policy”; for the others it says the host is “blocked by
registries.workspace_registries policy” and adds that private hosts need "all" and
DEPS_LSP_PRIVATE_REGISTRY_HOSTS. Cached data for a registry is still served while
revalidation is blocked, since no connection is made.
$CARGO_HOME/config.toml-configured registries are never policy-checked, under
any of the three values — that file is the user’s own trusted configuration, not
something a cloned repository controls. A blocked index is never silent: it is
logged, and a registry/registry-index dependency line additionally gets an
informational diagnostic naming the blocked host class (a [source]-chain block is
log-only, since it is a property of .cargo/config.toml, not of any one
dependency line). This diagnostic is not Cargo-specific (issue #925): npm’s
.npmrc registry=/@scope:registry= resolution, PyPI’s --index-url/Poetry
source =/uv index = resolution, and NuGet’s NuGet.Config
<packageSources>/<packageSourceMapping> resolution all surface the same
informational diagnostic on the affected dependency’s own line when a declared
registry is blocked, instead of degrading silently to the public registry. Go’s
GOPROXY chain and GitLab CI/CD’s registries.gitlab_instance_host/component:
host resolution were the last two ecosystems to close this gap
(issues #967, #968) — every ecosystem with a workspace-declared, policy-gated
registry host now surfaces the same kind of diagnostic instead of leaving the
block visible only in the server log.
Beyond that initial URL-string check, public_only (and off/all) is also
enforced at connect time: the address a workspace-declared index’s hostname
actually resolves to, and the target of any redirect hop the fetch follows, are
both checked against the setting too — not just the declared URL string at parse
time. This closes a DNS-rebinding gap (issue #455) where a workspace file declares
a host that classifies as public at parse time (https://evil.example/) but
resolves to a blocked address (an RFC1918/CGNAT range, or one rebound after parse
time) at actual fetch time.
Tightening the setting (e.g. all -> public_only/off) now takes effect on
already-open documents immediately: workspace/didChangeConfiguration re-parses
every open document whose ecosystem consults this policy and forces a full
version refetch, purging any cached version obtained through the now-untrusted
registry rather than requiring the document to be edited or reopened first
(issue #592).
Known limitations:
- Editing
.cargo/config.tomldoes not take effect until the affectedCargo.tomlis next reparsed (edited, or the document reopened) — there is no dedicated file watcher for it yet. - The sparse index protocol has no search endpoint, so package-name completion (typing a brand-new dependency) always searches crates.io, even inside a workspace whose default registry is mirrored elsewhere.
- Git-index (non-sparse) private registries remain unsupported, matching prior behavior.
npm
deps-npm provides full LSP support for Node.js/JavaScript projects using npm, pnpm, or
Yarn — all three read the same package.json format.
Basics
| Manifest file | package.json |
| Lock file (in-use version) | package-lock.json (versions 2 and 3) or pnpm-lock.yaml |
| Registry | npm registry — packument API (registry.npmjs.org/{package}) for versions, search API (registry.npmjs.org/-/v1/search) for package-name search |
| Version syntax | node-semver ranges: ^, ~, =, <, >, * |
deps-npm parses dependencies, devDependencies, peerDependencies, and
optionalDependencies, tracking each entry’s exact key/value byte span for accurate
hover/diagnostic/completion positioning.
{
"dependencies": {
"express": "^4.18.2"
}
}
Hovering the version string shows the latest npm release and whether ^4.18.2 is satisfied;
an outdated dependency gets an inlay hint and a diagnostic with an “Update to latest version”
code action. Typing a version prefix completes from the real, live version list; typing a new
dependency name searches the npm registry. watched_configs covers .npmrc and
pnpm-workspace.yaml — editing either one externally (e.g. git checkout) triggers a reparse
of every open package.json so pushed diagnostics stay current without waiting for the next
in-editor edit.
Custom/Private Registries
An npm dependency whose scope (via @scope:registry=) or whose workspace (via a
top-level registry= override) resolves to a private/custom registry through
.npmrc gets the same hover/diagnostic/completion value a registry.npmjs.org
dependency gets — instead of showing no version data, or (before this feature)
silently checking the wrong (public) registry.
Resolution: .npmrc is read from a two-tier hierarchy — the project tier
(walked from the opened package.json’s directory up to the filesystem root,
closest directory winning; a deliberate superset of npm’s own project-root-only
read, chosen for monorepo ergonomics and mirroring Cargo’s .cargo/config.toml
discovery — see Cargo) and the user tier
(~/.npmrc). The global tier ($PREFIX/etc/npmrc) is not read. A
@scope:registry= entry always takes precedence over a top-level registry=
override for a dependency in that scope. Scope keys are matched byte-exact, with
no case folding, matching npm’s own lookup. A ${VAR}-style placeholder in
either key’s value is expanded from this LSP server’s own process environment;
an undefined variable makes the whole entry invalid (same outcome as an invalid
URL, below). deps-lsp watches .npmrc for external changes (e.g. a git checkout) and reparses every open package.json to refresh pushed
diagnostics, rather than waiting for that document’s own next edit.
Authentication: phase 1 carries no authentication at all. _authToken,
_auth, _password, _authIdent, always-auth, and every //<host>/:_*
scoped-credential key are never parsed, held in memory, logged, or transmitted —
every alternate-registry request is unauthenticated. A follow-up spec is required
before any credential is wired up.
Fail-closed on misconfiguration: a registry=/@scope:registry= value that
is not a well-formed https:// URL, carries userinfo, or is blocked by the
reachability policy below shows no version data for the affected dependency —
never a silent fallback to registry.npmjs.org, matching Cargo’s equivalent
guarantee for a misconfigured registry alias.
Proxies. Guarded traffic, including your user ~/.npmrc registries, connects directly and bypasses the
system proxy by default; set DEPS_LSP_WORKSPACE_REGISTRY_PROXY=proxy to route it through
the proxy (see Proxies and guarded registry traffic).
Reachability policy: governed by the same registries.workspace_registries
setting documented in Cargo, including its
connect-time message for a host that resolves to a blocked
address — unlike
Cargo’s $CARGO_HOME-is-trusted split, npm’s project and user .npmrc tiers
are policy-symmetric: phase 1 has no credential provenance to protect, so
there is no tier that is “the user’s own configuration” in the way
$CARGO_HOME is for Cargo. Setting registries.workspace_registries to
"all" for npm’s benefit also widens it for Cargo, and vice versa; either way only the hosts in
DEPS_LSP_PRIVATE_REGISTRY_HOSTS become reachable (see
Configuration).
A change to a .npmrc or pnpm-workspace.yaml reparses only the open manifests in that file’s
directory and below, the ones the ancestor walk can reach. A change to the user-level ~/.npmrc
(a .npmrc directly in your home directory) reparses every open npm and Deno document, wherever
it lives.
Known limitations:
- Package-name completion (typing a brand-new dependency) always searches
registry.npmjs.org, even for a scope resolved to a private registry — the string being searched is a prefix the user typed into the name field, not a resolved private dependency name, so this is safe but not registry-aware. - A dependency resolved to a private registry drops out of OSV vulnerability scanning and loses its npmjs.com hover link (an advisory or link keyed to the public package name does not apply to a same-named private package) and its relative-age (“published N days ago”) hover suffix.
.yarnrc/.yarnrc.yml(Yarn Berry’s own config format) are not read; a standard.npmrcpresent in the workspace is still honored either way. pnpm’s ownpnpm-workspace.yamlcatalog extension is read — see below.
Non-Registry Dependency Sources
A dependency declared against a git repository, a local filesystem path, or a workspace
sibling is classified as such instead of defaulting to registry.npmjs.org — this covers
git+ssh:///git+https:///git:// URLs, a bare HTTPS URL to a known git host
(GitHub/GitLab/Bitbucket) ending in .git, github:/gitlab:/bitbucket:/gist:
shorthand (owner/repo[#ref]), a direct tarball URL, file:/link:/portal:-prefixed and
bare local paths (./, ../, ~/, or a Windows drive letter), and workspace: protocol
dependencies. A dependency resolved this way is never sent to the registry, drops its
public-registry hover link, and is excluded from OSV vulnerability scanning against the
public package name — matching the treatment Cargo already gives a git =/path =
dependency (resolves #1202).
npm: Alias Resolution
npm/pnpm/yarn let a package.json dependency install under a different local
import name than its registry package, via the npm: protocol prefix:
"my-react": "npm:react@^18.0.0" — my-react is the local key, react is the
real package to resolve, ^18.0.0 is its version requirement. Hover,
completion, diagnostics, code lens, inlay hints, .npmrc scoped-registry
routing, and OSV vulnerability lookups all resolve against the real package
name (react), while the local alias key stays the position anchor for
hover/diagnostic ranges in the editor (resolves #654). A scoped real package
name ("my-pkg": "npm:@scope/pkg@^1.0.0") is supported the same way. A
dist-tag alias ("npm:react@beta") or one with no version at all
("npm:react") resolves as an existence check (equivalent to a bare "*"
requirement) rather than a specific version comparison.
Code actions/lens are not offered for an npm:-aliased dependency: the
manifest text at the version position is the whole npm:pkg@range literal,
not just the range, so an automated rewrite would need to preserve the alias
prefix — not yet implemented. This is a deliberate, safe degradation (no
version data is lost, only the one-click fix), not a bug.
Known limitation: the pnpm-catalog combination form
(npm:<pkg>@catalog:<name>) is not detected — see pnpm Catalogs below.
pnpm Catalogs
A package.json dependency declared as "catalog:" (the default catalog) or
"catalog:<name>" (a named catalog) resolves against the nearest-ancestor
pnpm-workspace.yaml’s catalog:/catalogs.<name>: map — the same
hover/diagnostic/completion/inlay-hint experience a literal semver range gets,
instead of an unresolvable raw string.
Resolution: the nearest pnpm-workspace.yaml found walking up from the
package.json’s directory wins (matching pnpm’s own find-workspace-dir
single-root-per-tree behavior — nested/multiple workspace roots are not
searched further). catalog: and catalog:default are equivalent references
to the default catalog, which may be defined either as a top-level catalog:
block or a catalogs.default: section (but never both — see below). deps-lsp
watches pnpm-workspace.yaml for external changes and reparses every open
package.json to refresh pushed diagnostics, rather than waiting for that
document’s own next edit.
Fail-closed, never destructive: whenever a catalog: specifier does not
resolve to a parseable semver range — no pnpm-workspace.yaml found, the file
is malformed, the referenced catalog or entry doesn’t exist, or the entry’s
value isn’t a semver range (workspace:*, a git URL) — the dependency’s
version requirement is left unset while hover still renders an explanatory
message. This is a deliberate correctness guarantee, not a missing feature: an
unresolved catalog specifier must never be treated as a valid semver range,
since that would let the “Update all outdated dependencies” quick-fix silently
overwrite "react": "catalog:" with a literal version, destroying the catalog
reference.
A catalog entry whose value exceeds a length cap (256 bytes) is rejected
before it is even parsed as a semver range, closing a resource-exhaustion
vector in an unbounded pnpm-workspace.yaml entry — unlike the other
fail-closed cases above, this one does surface a diagnostic, since no real
catalog entry approaches this length.
Both a top-level catalog: block and a catalogs.default: section present
is treated as unresolvable for every catalog: specifier in the workspace
(not only default-catalog references) — matching pnpm’s own
checkDefaultCatalogIsDefinedOnce, which rejects the whole workspace manifest
in this situation before returning any catalog map at all; this deliberately
never per-key-merges the two sections.
Known limitations:
- The
npm:<pkg>@catalog:<name>combination form (annpm:alias whose version is itself a catalog reference) is not detected — the basenpm:alias form (no catalog combination) is resolved, seenpm:Alias Resolution above. - A catalog entry whose value isn’t a scalar string (e.g. a nested mapping) gets its own distinct “not a version string” message rather than being validated against pnpm’s own schema further.
yarn.lockandbun.lockare not read for in-use/resolved-version detection — onlypackage-lock.jsonandpnpm-lock.yamlare supported lock file formats (tracked as follow-ups on issue #709).pnpm-lock.yaml’simportersmap is aggregated flatly across every workspace member into one shared version pool per package name, without correlating an importer entry back to the specificpackage.jsonbeing queried (spec 052’s deliberate scoping). When two importers have overlapping semver ranges that pnpm resolved to different concrete versions, hover/OSV scanning for one importer’spackage.jsoncan show the version resolved for a different importer instead of its own — a false-negative risk for vulnerability scanning, not just an imprecision. The same applies to anypackage.jsonunder the workspace root that isn’t itself a registered importer: it inherits whichever importer’s version the ancestor lock file search happens to attach to.
Package Name Validation
When a dependency in package.json fails to resolve against the npm registry, the diagnostic distinguishes between two cases instead of always reporting “Unknown package”:
Invalid package name '<name>': <reason>— the name itself violates npm’s own naming rules (e.g. it starts with./_, exceeds 214 characters, contains a character outside npm’s URL-friendly set, or is a reserved name likenode_modules).Unknown package '<name>'— the name is syntactically valid but was not found in the registry (typo, private/unpublished package, etc.).
The check is deliberately permissive: uppercase names are still accepted (npm only warns on those for legacy packages, never rejects), and it accepts every character npm’s own encodeURIComponent(segment) === segment predicate accepts, including ! ' ( ) * - . _ ~ — not just alphanumerics and hyphens.
Release-Freshness Coverage
npm’s freshness signal (gated by freshness.enabled, default true) issues an entire
additional full-packument fetch per package, not a marginal delta — the abbreviated
packument get_versions already fetches carries no publish dates, so freshness attaches a
separately-fetched, TTL’d (1 hour) {version: date} map derived from the full packument’s
time field instead. npm is the one ecosystem in this project where freshness.enabled: false genuinely removes a whole request per hover/completion round, not just a conditional
revalidation. See NuGet for the other half of this
shared signal.
PyPI
deps-pypi provides LSP support for Python projects, covering both TOML-based manifests and
pip’s line-oriented requirements-file format from one crate.
Basics
| Manifest files | pyproject.toml (PEP 621, PEP 735, Poetry, PEP 517/518 build-system.requires); requirements*.txt, *-requirements.txt, *.requirements.txt, constraints*.txt; any .txt directly under a requirements/ directory |
| Lock file (in-use version) | poetry.lock or uv.lock |
| Registry | PyPI — PEP 691 Simple API JSON (pypi.org/simple/{package}/) for version lookups, JSON API (pypi.org/pypi/{package}/json) for hover metadata |
| Version syntax | PEP 440 version specifiers, parsed via pep440_rs; full PEP 508 requirement strings (extras, environment markers) via pep508_rs |
[project]
dependencies = [
"requests>=2.31,<3",
"numpy>=1.24; python_version>='3.9'",
]
Hover shows the requirement’s PEP 440 satisfaction against the latest PyPI release, extras
(requests[socks]), and — when present — the environment marker in a readable “Active when:”
form (see Environment Markers below). The same PEP 508 parsing
machinery renders requirements.txt entries identically, so switching between pyproject.toml
and a requirements file changes nothing about hover/diagnostic/completion behavior for an
otherwise-identical requirement string.
Custom/Private Indexes
A PyPI/pip dependency whose applicable index is overridden via requirements.txt
--index-url/--extra-index-url, Poetry’s [[tool.poetry.source]], or uv’s
[tool.uv.index]/[tool.uv.sources] gets the same hover/diagnostic/completion
value a plain pypi.org dependency gets — instead of showing no version data, or
(before this feature) silently checking the wrong (public) index.
Resolution order — the security-relevant rule: whether an explicit
--index-url (or Poetry primary/default-priority source, or a uv index with
default = true) is present in the file determines the order every plain
dependency is checked in:
- An explicit primary is declared: that index is checked first, then every
--extra-index-url/supplemental source, in declaration order. No implicitpypi.orghop is appended —--index-urlreplaces the default index (matching pip’s own semantics), so a file that wantspypi.orgreachable alongside an explicit primary must list it as an extra itself. - No explicit primary, but extras exist: declared extras are checked before
the implicit
pypi.orgfallback, which is always checked last. This is deliberately the reverse of what might seem intuitive, and is the whole point of this feature’s design: it stops a private-only package’s name from ever being sent topypi.orgbefore the user’s own declared index has had a chance, and stops a same-named public package from silently shadowing a private one the user explicitly configured (the “dependency confusion” attack shape). This diverges from pip’s own resolver, which pip’s docs describe as having no defined precedence between--index-urland--extra-index-urland explicitly warn is unsafe for private packages for exactly this reason.
uv’s default = true index follows this same “no explicit primary” shape: it is
uv’s own lowest-priority, last-resort index — checked after every other declared
uv index, replacing the implicit pypi.org slot, never checked first the way an
explicit --index-url primary is.
Poetry named sources: a dependency declaring source = "<name>" (Poetry) or a
[tool.uv.sources] <dep> = { index = "<name>" } binding (uv) resolves directly
against that one named source, with no fallback to any other index — a deliberate,
single-hop route. A Poetry source with no priority key is treated as primary
(matching current Poetry documentation); explicit-priority Poetry sources and
explicit = true uv indexes are reachable only by name, never auto-included in the
extras chain.
Authentication: phase 1 carries no authentication at all — the same
Cargo/npm precedent. Any index URL with embedded userinfo (https://user:pass@…)
is rejected outright rather than stripped-and-used; keyring/.netrc are not
detected or acknowledged. This means an auth-gated private feed (e.g. Azure
Artifacts) is not reachable end-to-end until a follow-up auth spec ships — the
routing/fallback mechanism itself still works correctly for any unauthenticated
private index (an internal mirror behind network-level access control, or a devpi
instance with anonymous read).
Fail-closed on misconfiguration: an explicit --index-url, Poetry
primary/named source, or uv default/named index that fails validation (not
https, malformed, or blocked by the reachability policy below) shows no version
data for every affected dependency — never a silent fallback to pypi.org. An
invalid --extra-index-url/supplemental/non-default entry, by contrast, is simply
dropped from the fallback chain (with a logged warning) rather than failing the
whole dependency closed, since an extra is additive/optional by definition — the
remaining valid hops (including the implicit pypi.org fallback, if applicable)
still serve the dependency.
Availability trade-off of the security fix above: a genuine transport error
(timeout, 5xx, connection refused) on any hop — including a declared extra that
happens to be hop 0 in the no-explicit-primary case — halts resolution for that
dependency rather than silently falling through to the next hop. Applied to a file
with only --extra-index-url entries, an unreachable extra (a developer off the
corporate VPN, say) means every dependency in that file loses its version data,
including ordinary public ones with no relation to the private index. This is
intentional, not a bug: falling through on a transport failure would send every
affected package’s name to pypi.org precisely when the private index is merely
unreachable — the same disclosure the resolution-order rule above exists to
prevent. A distinguishable log message (“extra index unreachable — resolution
halted, not falling back to pypi.org”) accompanies this case so it can be told
apart from a genuinely missing package.
Reachability policy: governed by the same registries.workspace_registries
setting documented in Cargo, including its
connect-time message for a host that resolves to a blocked
address (a blocked index halts the chain with that message) — the same
"public_only"/"off"/"all" values, the same shared process-wide HttpCache
policy. Only explicitly-declared indexes (a primary, every extra, every named
source) are gated; the implicit pypi.org fallback used by the no-explicit-primary
case is never itself subject to this setting, since it is the same public-tier
client every plain dependency already uses. What this means in practice depends on
whether the file declares an explicit --index-url primary:
- No explicit primary, extras only:
workspace_registries = "off"blocks every declared extra, and — since there is no implicit-fallback slot to lose — every plain dependency in the file degrades gracefully to resolving againstpypi.orgdirectly, exactly as if the file declared nothing at all. - An explicit
--index-urlprimary: an explicit primary replaces the default index rather than adding to it (FR-005(a)), so there is no implicitpypi.orghop to fall back to. Ifoffblocks that primary, it fails closed (CustomRegistry, FR-006) and every dependency in the file loses version data —offdoes not silently degrade to public resolution here, unlike the extras-only case above.
Known limitations:
- Editing a file’s index declarations does not take effect until it is next reparsed (edited, or the document reopened) — there is no dedicated file watcher for it yet.
pip.conf/pip.iniandPIP_INDEX_URL/PIP_EXTRA_INDEX_URLenvironment variables are not read at all — a project relying solely on those (rather than in-file--index-urlflags) sees no improvement from this feature.-r/-cinclude propagation is not implemented: a file included via-r base.txtdoes not inherit the includer’s index declarations, and vice versa.- A
[tool.uv.sources]binding is only recognized for theindex = "<name>"shape —git =,path =, andworkspace = truebindings are a distinct concept (dependency provenance, not registry routing) and are not read. - Cosmetic limitation: a plain dependency in an extras-only file is classified
as resolved via the alternate-index chain at parse time, before the winning hop
is actually known — if it ends up resolving via the implicit
pypi.orgfallback, its hover heading still omits thepypi.orgproject link (the same suppression a genuinely private dependency gets). No data-correctness impact.
Environment Markers (PEP 508)
When a Python dependency is gated by an environment marker (e.g., numpy>=1.24; python_version>='3.9'), the hover popup displays:
Active when: python_version >= '3.9'
This helps you understand when conditional dependencies apply. Markers are shown for dependencies in pyproject.toml (PEP 621), Poetry [tool.poetry.dependencies] tables, and both PEP 621 requirement strings and Poetry string-form suffixes.
requirements.txt / constraints.txt
Files matching requirements*.txt, *-requirements.txt, *.requirements.txt, or constraints*.txt — or any .txt file directly inside a directory literally named requirements/ (e.g. requirements/base.txt, requirements/dev.txt) — are routed to the PyPI ecosystem and parsed line-by-line (pip’s requirements file format), reusing the same PEP 508 machinery as pyproject.toml — hover, diagnostics, markers and extras render identically across both. Comments, blank lines, \-continuations, per-requirement options (--hash=...), and recognized pip options (-r, -c, -e, --index-url, --pre, etc.) are handled; a -r/-c/--requirement/--constraint target is surfaced as a clickable documentLink resolved relative to the containing file’s directory (ctrl/cmd-click to open it — its own dependencies are still checked only once it’s open, not transitively from the referencing file). A pinned dependency (django==5.0.1) keeps its == pin on “update version” instead of widening to a range. Because neither the filename-pattern routing nor the requirements/ directory convention is a fixed name, a non-manifest file that happens to match (e.g. a product-requirements.txt prose document, or a requirements-engineering docs file under an unrelated requirements/ folder) is detected via a content heuristic and produces no hover/diagnostics/network requests — a file matched only via the requirements/ directory convention requires a stronger signal (a recognized pip option, or a dependency with a version/URL) than a basename match does, since directory-name routing alone is weaker evidence that the file is really a Python manifest.
Package-Name Completion
Package-name completion (issue #419) serves unranked, alphabetically-sorted prefix matches from an in-memory index of PyPI’s full Simple API project list (~882k names, no popularity ranking) — the same approach PyCharm’s PyPI completion uses, since PyPI removed its XML-RPC search API and offers no first-party ranked search. The index is built lazily (on the first completion request in a Python manifest) and once per process, not refreshed on a timer.
Two behaviors worth knowing:
- Completions insert the PEP 503 normalized spelling, not the project’s display
spelling —
Djangois offered (and inserted) asdjango,Zope.Interfaceaszope-interface. This matches whatpip installand bothpoetry.lock/uv.lockalready normalize to. - Matching is prefix-only against the package name, not a project’s import name
or description — typing
yamlwill not surfacepyyaml,sklearnwill not surfacescikit-learn, andbs4will not surfacebeautifulsoup4. This is a known, common PyPI-specific expectation gap; a substring/import-name-aware search is tracked as a possible follow-up, not implemented here.
Because the index is capped and alphabetically sorted rather than ranked, the LSP
response for a package-name completion request sets isIncomplete: true so editors
re-query as the user keeps typing (resolves #427). This is scoped to the
package-name search itself — other completions in a Python manifest (versions,
comments, [build-system] positions) report isIncomplete: false like every other
ecosystem, since their result sets are already exhaustive.
Go
deps-go provides LSP support for Go modules.
Basics
| Manifest file | go.mod |
| Lock file (in-use version) | go.sum (module content-checksum lines, skipping the /go.mod-suffixed checksum-only lines) |
| Registry | Go module proxy — proxy.golang.org by default (/{module}/@v/list, /{module}/@v/{version}.info, /{module}/@latest) |
| Version syntax | Go’s own module versioning (semver-based, including pseudo-versions like v0.0.0-20230101000000-abcdef123456) |
require (
github.com/gin-gonic/gin v1.9.1
)
Hovering a require line’s version shows the latest module version from the proxy and a link
to its pkg.go.dev page; an outdated requirement gets an inlay hint and a diagnostic with an
“Update to latest version” code action. require/replace/exclude directives, both the
single-line and grouped (...) block forms, are all parsed with position tracking.
replace Directives to a Filesystem Path
A module replaced to a local filesystem path (replace acme.com/mod => ./local/mod) is
classified as a non-registry dependency rather than defaulting to proxy.golang.org — the
classification applies to the module’s require entry too, not only the replace line
itself, so the same module is not fetched under its original required version while its
replacement is correctly skipped. A dependency resolved this way is never sent to the
proxy, drops its pkg.go.dev hover link, and is excluded from OSV vulnerability scanning
against the public module path (resolves #1202). A replace to a remote module at a
pinned version (not a filesystem path) is unaffected and remains registry-resolvable.
Known limitation: two replace directives for the same module path (a malformed or
mid-edit go.mod) collapse to whichever is parsed last, with no diagnostic.
GOPROXY/GOPRIVATE Support
A Go module dependency whose applicable proxy is overridden via a $GOENV
GOPROXY= entry, or whose module path matches a $GOENV GOPRIVATE= glob
pattern, gets the same hover/diagnostic/completion value a
proxy.golang.org-resolved dependency gets — instead of showing no version
data, or (before this feature) silently checking the wrong (public) proxy.
Resolution: $GOENV is read once per process — the GOENV environment
variable if set and non-empty, else the platform default
os.UserConfigDir()/go/env (~/.config/go/env on Linux/macOS,
%AppData%\go\env on Windows), matching go env -w’s own file. GOPROXY is
parsed as a comma-or-pipe-separated ordered chain of hops (go help goproxy
semantics), recognizing the direct and off sentinels; when absent, the
existing hardcoded https://proxy.golang.org default applies unchanged.
GOPRIVATE is a comma-separated list of path.Match-style glob patterns
(go help goprivate) matched against a module’s full path — a matching
module bypasses the entire GOPROXY chain and routes straight to the
direct terminal hop, regardless of what GOPROXY is configured to.
direct/off show no data (phase 1 limitation): deps-go has no
direct-VCS resolution mechanism (no go-import meta-tag discovery, no
arbitrary-VCS client), so both the direct sentinel and off are
implemented as fail-closed terminal hops — the chain-fallback mechanics are
correct (a proxy hop’s explicit not-found response falls through to the
next hop, including direct/off), but neither sentinel itself produces
version data. This preserves GOPRIVATE’s confidentiality guarantee (a
private module path is never sent to any proxy hop) even though no
replacement data is shown yet.
Authentication: phase 1 carries no authentication at all — the same
Cargo/npm/PyPI precedent. A GOPROXY hop URL with embedded userinfo
(https://user:pass@…) is rejected outright rather than stripped-and-used;
.netrc and a bare local-filesystem-path hop are not detected or
acknowledged.
Fail-closed on misconfiguration: a GOPROXY hop that fails validation
(not https, malformed, or blocked by the reachability policy below) is
dropped from the chain (with a logged warning) when other valid hops
remain; if every hop turns out invalid, the whole chain fails closed
(no version data for any affected dependency) — never a silent fallback to
proxy.golang.org. A transport failure (timeout, 5xx, connection refused)
on any hop halts resolution for that dependency rather than silently
falling through to the next hop, mirroring PyPI’s
identical trade-off for the same reason: falling through would risk resolving a
private module through a fallback the reachability state does not actually
support.
, vs | separator semantics: the two GOPROXY separators are not
interchangeable — each governs a different fallback trigger for the hop
transition it precedes, matching go help goproxy/modfetch/proxy.go:
,falls through to the next hop only on an explicit not-found response (404/410) — a transport failure (timeout, 5xx, connection refused) on that hop halts resolution for the dependency instead (see above).|falls through to the next hop on any error from that hop, including a transport failure.
A single GOPROXY value may mix both (e.g.
GOPROXY=https://a.example|https://b.example,direct); each transition
between two consecutive, valid hops keeps the separator that preceded
it, so a chain can combine “skip on any failure” and “skip only when
genuinely absent” hop-to-hop as needed.
When an invalid hop is dropped (per the fail-closed rule above) between
two surviving hops, the separators on either side of the dropped entry
are merged, with the more permissive one (|) winning: for example,
GOPROXY=https://a.example|not-a-valid-url,https://c.example records |
for the a -> c fallback, not the , that happened to follow the
dropped entry — a “skip on any failure” the user wrote is never silently
narrowed to “skip only when not found” just because the hop in between
turned out invalid.
Proxies. Guarded traffic, including the $GOENV GOPROXY chain, connects directly and bypasses the
system proxy by default; set DEPS_LSP_WORKSPACE_REGISTRY_PROXY=proxy to route it through
the proxy (see Proxies and guarded registry traffic).
Reachability policy: governed by the same registries.workspace_registries
setting documented in Cargo, including its
connect-time message for a host that resolves to a blocked
address. A hop blocked this way halts a , chain with that message; in a | chain a later
not-found or error does not hide it. The default
public chain (https://proxy.golang.org,direct) used when $GOENV declares no
GOPROXY override is never subject to this gate — it is the same
ungated public-tier client deps-go already uses today. A hop blocked by the
policy surfaces the same informational diagnostic every other ecosystem’s
blocked registry does (issue #958), naming the blocked host class independently
of any other invalid hop earlier in the chain — one diagnostic per document,
since GOPROXY is a single config-global declaration rather than a
per-dependency one.
Known limitations:
- Editing
$GOENVdoes not take effect until the affectedgo.modis next reparsed (edited, or the document reopened) — there is no dedicated file watcher for it yet. - Live
GOPROXY/GOPRIVATE/GONOSUMCHECK/GOFLAGSprocess environment variables (as opposed to the$GOENVfile) are not read. GOSUMDB/GONOSUMCHECKchecksum-database verification is out of scope entirely — no ecosystem crate in this project performs integrity verification today.- Package-name completion is unconditionally a no-op for a dependency
resolved to a non-default
GOPROXYchain or aGOPRIVATE-routed module — Go has no package-name search endpoint in its module-proxy protocol at all.
Bundler
deps-bundler provides LSP support for Ruby projects using Bundler.
Basics
| Manifest file | Gemfile |
| Lock file (in-use version) | Gemfile.lock |
| Registry | RubyGems (rubygems.org/api/v1) |
| Version syntax | RubyGems’ own Gem::Version/Gem::Requirement semantics, ported directly from RubyGems’ source so ordering and ~> (pessimistic operator) matching are exact, including prerelease tie-breaking |
gem "rails", "~> 7.1.0"
gem "pg"
Hovering a gem’s version string shows the latest RubyGems release and a link to its
rubygems.org page; an outdated requirement gets an inlay hint and a diagnostic with an
“Update to latest version” code action. Both modern key: value and legacy hash-rocket
:key => value option syntax (group:, require:, platforms:, source:, git:,
path:) are recognized identically.
Custom/Private Registries (issue #980)
Unlike Cargo/npm/PyPI/Go/NuGet, deps-bundler
and deps-dart do not fetch version data from a declared custom registry — they only classify
a dependency as DependencySource::CustomRegistry instead of Registry so it stops being queried
against the public registry under its real name and stops rendering a misleading public-registry
hover link.
Bundler (Gemfile): a source "<url>" do ... end block (a comment after
do is tolerated), and the per-gem inline source:/git:/path: options
(modern key: and legacy hash-rocket :key => forms both recognized — see
below), classify the gems they cover as CustomRegistry/Git/Path instead of
falling through to Registry and leaking the gem’s name to rubygems.org. A gem
with no declared source still resolves against rubygems.org (Registry)
unchanged.
Dart (pubspec.yaml): see Dart for the
equivalent hosted: classification.
Known limitation: neither ecosystem fetches version data from the declared
custom source — a CustomRegistry dependency gets no hover version list,
diagnostics, completion, or code lens, the same degraded-but-honest behavior a
declared-but-unqueried registry has always had, just now applied to the correct
dependencies instead of silently querying the wrong ones. suppress_package_url
(Bundler’s PackageRendering impl) hides the rubygems.org hover link for any
non-Registry source, since rendering it would falsely imply the gem is
published there.
Legacy Hash-Rocket Option Syntax (issues #987, #988, #990)
Ruby’s older :key => value option syntax is now recognized everywhere the
modern key: value form already was — per-gem source:/git:/path:/github:
options (classification above), group:, require:, and platforms:. A gem
declared with hash-rocket syntax previously fell through to Registry (for the
routing options) or was silently dropped (for group:/require:/platforms:)
instead of being recognized. A left word boundary on the key match also stops
subgroup:/autorequire:-style keys from being mistaken for group:/require:.
VERSION_PATTERN additionally tolerates a trailing comment after the closing
quote (gem "rails", "~> 7.0" # pinned), so a version requirement is no longer
silently dropped just because the line ends with a comment.
Yanked-Version Diagnostics
Bundler participates in the cross-ecosystem
yanked-version diagnostics for the
in-use-version check (sourced from RubyGems’ yanked field), but RubyGems’ versions.json
never includes a yanked field on any entry for the range-requirement check — see
Yanked Version Diagnostic
for the full per-ecosystem coverage table.
Dart
deps-dart provides LSP support for Dart/Flutter projects using the Pub package manager.
Basics
| Manifest file | pubspec.yaml |
| Lock file (in-use version) | pubspec.lock |
| Registry | pub.dev (pub.dev/api/packages/{name}) |
| Version syntax | pub_semver: SemVer 2.0.0 §11 prerelease precedence (numeric identifiers compare numerically, alphanumeric compare lexically, a prerelease sorts below its base release), plus significant +N build revisions |
dependencies:
http: ^1.2.0
provider: ^6.1.1
Hovering a package’s version constraint shows the latest pub.dev release and whether the
constraint is satisfied; an outdated dependency gets an inlay hint and a diagnostic with an
“Update to latest version” code action. dependencies, dev_dependencies, and
dependency_overrides are all parsed, along with YAML anchor/alias-based section reuse (see
below).
Custom/Private Registries (issue #980)
Unlike Cargo/npm/PyPI/Go/NuGet, deps-dart
does not fetch version data from a declared custom registry — it only classifies a dependency
as DependencySource::CustomRegistry instead of Registry so it stops being queried against the
public registry under its real name and stops rendering a misleading public-registry hover link.
See Bundler for the equivalent source "..."
classification, which shares this same design.
A dependency’s hosted: value — either the hosted: <url> shorthand or the
hosted: {name, url} map form — classifies it as CustomRegistry, mirroring Bundler’s
source "..." and Cargo’s registry = "..." handling (#248). An explicit hosted: https://pub.dev (or its legacy pub.dartlang.org alias) is recognized as the default
registry and stays Registry rather than being misclassified as custom just because
hosted: was written out explicitly.
Known limitation: no version data is fetched from the declared custom source — a
CustomRegistry dependency gets no hover version list, diagnostics, completion, or code
lens, the same degraded-but-honest behavior a declared-but-unqueried registry has always
had, just now applied to the correct dependencies instead of silently querying the wrong
ones.
Version Comparison
compare_versions previously discarded everything after the first non-digit character in a
dot-separated segment, so a prerelease/qualifier version compared as equal to its stable
counterpart (2.0.0-beta1 tied with 2.0.0). Dart now applies proper prerelease-aware
ordering: SemVer 2.0.0 §11 precedence — numeric identifiers compare numerically, alphanumeric
identifiers compare lexically (ASCII), and a version with a prerelease sorts below its base
release. Applied both to “latest version” selection (pub.dev’s response order) and to
constraint matching, so hover/completion sort order and outdated diagnostics are both
corrected.
Unlike SemVer 2.0.0 (and the other ecosystems), pub treats +N build revisions as distinct,
ordered releases: 0.8.13 < 0.8.13+1 < 0.8.13+23, with build identifiers ordered like
prerelease identifiers and applied after prerelease. A pubspec.lock pin at 0.8.13+1 is
therefore reported outdated when pub.dev’s latest is 0.8.13+23, and the newest build revision
is selected as latest. See Composer for the equivalent fix in that
ecosystem, which shares the same underlying bug class.
YAML Anchor/Alias Resolution
pubspec.yaml supports YAML anchors (&name) and aliases (*name) for sharing structure
between sections, e.g. a shared dependencies: block reused via
dev_dependencies: *shared_deps. Aliasing an entire dependencies:/dev_dependencies:/
dependency_overrides: section, or an entire environment: mapping (not just its sdk:
value), resolves correctly — the aliased dependencies/sdk: constraint appear as if written
out in full, including when the same section is aliased more than once.
Known limitation: resolved-via-alias dependencies show no hover, diagnostics, completion,
inlay hints, or code lens, since no position in the aliasing occurrence’s own text
corresponds to them (only the anchor’s original definition does, which would be misleading
and — for a section aliased more than once — ambiguous). They still count toward the
document’s dependency total (including the truncation cap), and are visible to anything
reading the parsed dependency list. A single dependency’s own value aliasing a whole mapping
(pkg: *shared_entry, as opposed to the section or environment: key itself) is not
resolved at all — the dependency appears with a real name but no version/source info.
Maven & Gradle
Gradle resolves coordinates against the same Maven Central registry client Maven uses (falling back to the Gradle Plugin Portal for a group ID not found there), so most of Maven’s behavior — version comparison, range matching, freshness — applies identically to both ecosystems. This chapter documents them together; ecosystem-specific notes are called out where they diverge.
Basics
Maven manifests are pom.xml files. deps-lsp reads <dependency> entries under
<dependencies> and <dependencyManagement>, plus <plugin> entries under <build><plugins>:
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.14.0</version>
</dependency>
</dependencies>
Hovering over commons-lang3 or 3.14.0 queries Maven Central
(repo1.maven.org/maven2) for the artifact’s maven-metadata.xml, showing the latest
release and recent version history. A groupId under androidx.*, com.google.firebase.*,
com.google.android.*, com.google.gms.*, or com.android.* resolves against Google
Maven (dl.google.com/dl/android/maven2) instead — Google does not mirror these artifacts
to Maven Central. Any group ID not found on Maven Central also falls back to the Gradle
Plugin Portal (plugins.gradle.org/m2), which is how a coordinate that is really a Gradle
plugin (declared as a plain dependency, not a plugins {} block) still resolves. Completion
for <groupId>/<artifactId>/<version> uses Maven Central’s Solr search API
(search.maven.org/solrsearch). Maven has no lock file — the version written in pom.xml (or
resolved through a ${property} reference, see below) is always the “in-use” version.
Gradle manifests are build.gradle (Groovy DSL), build.gradle.kts (Kotlin DSL),
settings.gradle/settings.gradle.kts (for pluginManagement {} dependencies), and
gradle/libs.versions.toml (the Gradle version catalog format). A dependency declared in
any Gradle configuration — implementation, api, testImplementation,
androidTestImplementation, compileOnly, classpath, the legacy compile/testCompile/
provided, and their variant-prefixed forms — is recognized:
dependencies {
implementation("com.google.guava:guava:33.0.0-jre")
testImplementation("junit:junit:4.13.2")
}
# gradle/libs.versions.toml
[versions]
guava = "33.0.0-jre"
[libraries]
guava = { module = "com.google.guava:guava", version.ref = "guava" }
deps-gradle’s own parser dispatches by file name/extension into a dedicated Groovy, Kotlin,
.properties, settings.gradle(.kts), or version-catalog sub-parser — but all of them resolve
coordinates through the same Maven Central registry client Maven uses (see the top of this
page), so hover/completion/diagnostics behavior described for Maven below applies to Gradle too
unless a section says otherwise. Gradle has no lock file either.
Non-Registry Dependency Sources (Maven)
A Maven <dependency> with <scope>system</scope> and a <systemPath> — an explicit
locally-provided JAR, never resolved from Maven Central — is classified as a non-registry
dependency instead of defaulting to Maven Central. A dependency resolved this way is never
sent to Central, drops its public-registry hover link, and is excluded from OSV
vulnerability scanning against the public artifact coordinates (resolves #1202). A
system-scope dependency with a missing or empty <systemPath> still falls back to
Central-resolvable, since scope alone isn’t a locally-provided binding without a path.
A <repositories>/<repository><url>file://...</url> declaration is a second, independent
non-registry source: since Maven’s <repository> element has no per-package binding (every
declared repository is tried, in order, for any dependency), each dependency in that
pom.xml is individually probed against the standard Maven repository layout
(<group-path>/<artifact>/<version>/<artifact>-<version>.jar) under every declared file://
repository directory, and only classified as non-registry when its jar is actually found
there (resolves #1503). A dependency with no matching jar under any file:// repository — an
implicit Maven Central dependency declared in the same file, for example — keeps its normal
Central-resolvable classification; a version-range requirement (e.g. [1.0,2.0)) and a
<plugin> entry (which resolves through the separate <pluginRepositories> config) are
never probed this way. ${project.basedir} (the pom.xml’s own containing directory) resolves
correctly in the repository URL, but a file:// URL that carries a host — a potential
Windows UNC path — is rejected outright rather than probed, since resolving it could make
Windows silently attempt SMB authentication against that host.
Known limitation: a file:// repository that happens to mirror or cache Maven Central
(e.g. a local ~/.m2/repository cache or an offline CI mirror declared as a <repository>)
causes every dependency whose jar is actually cached there to lose OSV/outdated coverage,
even though it is really a Central-sourced dependency — the probe has no way to distinguish
“genuinely local-only” from “a local mirror of a public registry.” The probing pass also
spends a bounded total budget of filesystem checks across the whole document; once
exhausted, every remaining dependency keeps its current (usually Registry) classification,
so a genuinely local dependency declared very late in an unusually large pom.xml may be
missed.
Gradle: Gradle has no per-dependency local-source syntax analogous to Maven’s
systemPath (project(":core")/files()/fileTree() dependencies are deliberately not
surfaced as version-checkable dependencies at all, so no Gradle dependency ever carries a
local source through this pipeline that way). Instead, a repositories { <repo> { content { includeGroup(...)/includeGroupByRegex(...)/includeModule(...) } } } restriction (Gradle 6+,
Groovy and Kotlin DSL, including maven("url") { }/url.set(uri("...")) call-site
spellings) is read as a per-dependency non-registry classification signal: a dependency
whose group matches a content {} restriction scoped to a repository with an explicit URL
is classified as a custom-registry source the same way Maven’s systemPath is (resolves
#1212). An explicit repository URL is required — the common shorthand repos
google()/mavenCentral()/gradlePluginPortal()/mavenLocal() (which frequently carry
their own content {} filter, e.g. Android’s canonical google { content { includeGroupByRegex("androidx.*") } }) are never reclassified this way, since they are
themselves registry-shaped. A content {} restriction declared inside a buildscript {}
block (plugin resolution) never affects the project’s own dependencies {} classification.
Known limitation: exclusiveContent {} and includeGroupAndSubgroups(...) are not yet
parsed — only includeGroup/includeGroupByRegex/includeModule inside an ordinary
content {} block.
Version Completion (Maven)
Version completion is offered for a self-closing <version/> tag, not just <version>X</version>
or an empty <version></version>: accepting a completion item there replaces the whole
<version/> span with <version>X</version> via an explicit text edit, rather than inserting text
at the cursor (which would otherwise land just after /> and corrupt the surrounding XML).
Version Comparison
Versions are now ranked with correct Maven semantics:
- Numeric segments outrank non-numeric qualifiers:
33>r09(previously the reverse) - Prerelease qualifiers sort below their base release:
1.0-RC1<1.0(previously the reverse) - Qualifier precedence:
alpha<beta<milestone<rc/cr<snapshot<release<sp(case-insensitive) - Numeric suffixes within qualifiers are compared numerically:
M10>M2(previouslyM2>M10)
These fixes ensure hover’s “Recent versions” list and completion sort order match Maven’s actual version ordering.
Version Range Matching
version_satisfies_requirement recognizes bracket-interval range syntax instead of only exact string equality, so a dependency pinned to a range no longer always renders as “outdated”:
- Maven (
pom.xml): interval notation —[1.0,2.0),[1.0](exact pin),[1.5,),(,2.0]— and top-level comma unions, e.g.(,1.0),(1.2,). Bounds are compared with Maven’s qualifier-aware ordering, so[1.0-beta,2.0-rc)orders correctly. A bare, non-bracketed requirement (1.0) is still Maven’s “soft” recommended version and compared for plain equality, not as a range. - Gradle (
build.gradle,build.gradle.kts,gradle/libs.versions.toml): the same bracket-interval syntax as Maven (no comma unions — Gradle’s grammar doesn’t have them), plus Gradle-specific forms: dynamic versions (1.0+,2.10.+),latest.release/latest.integrationselectors, and Gradle’s reversed-bracket exclusive notation (]1.2,1.5]for an exclusive lower bound,[1.1,2.0[for an exclusive upper bound). - Malformed input fails closed: an unparseable range (unbalanced/stray brackets, an extra comma-separated component, a mismatched no-comma pin like
[1.0), or any unparseable member of a Maven union) is rejected as a whole —version_satisfies_requirementreturnsfalserather than matching on a corrupted or partial parse.
Unresolved Requirements
A requirement that couldn’t be resolved to a concrete version (Maven’s ${property} missing from <properties>, Gradle’s $var/${var} variable reference, or a Gradle version-catalog version.ref alias missing from [versions]) is treated as RequirementStatus::Unresolved, distinct from UpToDate/Outdated:
- Diagnostics: no “Newer version available” hint is shown — same as before, since the server can’t verify either way.
- Inlay hints: no badge is shown at all, neither “up to date” nor “needs update” — showing “up to date” for a requirement that was never actually checked against the latest version would be misleading.
- CodeLens “Update N outdated dependencies” (see Version Diagnostics): an unresolved requirement is also never counted or edited — it already fails the literal-span guard (the tracked span covers a placeholder/variable, not a version literal), so the two mechanisms agree independently rather than one depending on the other.
Release-Freshness Coverage
Hover’s “Recent versions” age suffix and completion’s age label_details (gated by
freshness.enabled, default true) depend on Maven Central’s repo1.maven.org HTML
directory listing, which is not available for every artifact source Maven/Gradle resolve
through:
- Maven Central (
repo1.maven.org) — the directory listing is fetched and parsed; ages render normally. - Google Maven (
dl.google.com,androidx.*/com.google.firebase.*/com.google.android.*/com.google.gms.*/com.android.*group IDs) — the listing 404s for every artifact, so no extra request is even attempted;published_at()is alwaysNoneand the version list itself is unaffected. - Gradle Plugin Portal (
plugins.gradle.org, the fallback for a group ID not found on Maven Central) — the listing has no date column; same result as above.
This is intentional graceful degradation (US-003), the same shape as Go’s documented
partial freshness coverage (/@v/list carries no per-version dates either) — not a bug.
License Hover (Gradle)
Gradle’s license hover follows the Maven Central POM’s <parent> coordinate chain when a
dependency’s own POM declares no <licenses> block — see License
Hover for the full cross-ecosystem picture and
the license policy diagnostic that consumes the same normalized data.
Composer
Basics
Composer manifests are composer.json, with dependencies under require (production) and
require-dev (development):
{
"require": {
"symfony/console": "^7.0",
"monolog/monolog": "~3.5"
}
}
Platform packages (php, ext-*, lib-*) are filtered out — they name a PHP runtime or
extension, not a Packagist package, and have no registry entry to resolve. Every remaining
entry is resolved against Packagist’s metadata API
(repo.packagist.org/p2/{vendor}/{package}.json), with hover showing the latest version,
license, and (if the maintainer flagged the package) an “abandoned” notice. Completion queries
Packagist’s packagist.org/search.json endpoint. Version constraints use Composer’s own
syntax — caret (^7.0, compatible up to the next major), tilde (~3.5, compatible up to the
next minor), exact pins, wildcards, hyphenated ranges (1.0 - 2.0, equivalent to
>=1.0.0 <2.1.0), and OR-separated alternatives (1.0 || 2.0, or the equivalent single-pipe
1.0 | 2.0) — compared with the stability-aware ordering described below. When a
composer.lock exists alongside the manifest, it is read to resolve each dependency’s actual
in-use (installed) version, shown alongside the declared constraint.
Version Comparison
compare_versions previously discarded everything after the first non-digit character in a
dot-separated segment, so a prerelease/qualifier version compared as equal to its stable
counterpart. Composer now applies proper stability-aware ordering: stability precedence dev
< alpha < beta < RC < stable, applied both to requirement-satisfaction comparison and
to “latest version” selection. select_latest_matching/get_latest_matching exclude
alpha/beta/RC releases by default (mirroring Composer’s minimum-stability: stable default)
unless overridden; a wildcard/existence-check requirement still resolves a prerelease-only
package instead of reporting no version found. The effective stability floor is now manifest-
and dependency-aware: a per-dependency @stability flag (^1.0@beta) or a directly-pinned
prerelease version overrides the manifest’s own composer.json minimum-stability field,
which in turn overrides the stable default — reflected consistently across diagnostics,
hover, completion, and code actions, via a shared SelectionContext rather than a
diagnostics-only path. Editing minimum-stability in an already-open document forces a full
re-fetch, so the other surfaces don’t keep showing a stale “latest” behind the new stability
floor. Separator-less and dot/underscore-separated prerelease suffixes (1.0.0RC1,
2.6.3.alpha) classify consistently regardless of v/V prefix. A minimum-stability value
that isn’t one of dev/alpha/beta/RC/stable (or isn’t a string at all) now surfaces a
warning diagnostic pointing at the offending value; selection still falls back to stable.
Composer’s update code actions and completion also preserve the requirement’s own v-prefix
style instead of forcing the raw Packagist tag’s prefix onto an unprefixed requirement (or vice
versa).
See Dart for the equivalent fix in that ecosystem, which shares
the same underlying compare_versions bug class and was corrected in the same change.
Non-Registry Dependency Sources
A require entry bound to a non-vcs-heuristic repositories entry is classified as
non-registry instead of defaulting to Packagist: a package-type repository (matched by
its embedded package.name), an artifact-type repository, and an only/exclude
wildcard-filtered vcs/path entry (Composer’s * glob syntax, not just exact names). A
top-level {"packagist.org": false} entry disables the default registry outright. A
dependency resolved this way is never sent to Packagist, drops its public-registry hover
link, and is excluded from OSV vulnerability scanning against the public package name
(resolves #1202).
A bare vcs/path/artifact repository entry with no only filter has no static
per-package name binding in composer.json itself (Composer tries every declared
repository, in order, for any required package) — an earlier vendor-substring URL heuristic
covered this case but produced false positives that silently disabled OSV scanning for
unrelated public packages sharing a GitHub org with a private repository’s URL (e.g. one
vcs entry for github.com/acme/internal incorrectly reclassifying an unrelated public
acme/-scoped package too). The heuristic was removed rather than fixed. Instead, when a
bare repository of this kind is declared, an ancestor composer.lock (once composer install has run) is cross-checked for the affected dependency’s own recorded
source.type, and only "path" is trusted as a non-registry signal — never "git", since
composer.lock records a "git" source for essentially every ordinary Packagist-resolved
package too (Packagist itself mirrors GitHub/GitLab/Bitbucket-hosted packages), so it cannot
distinguish a genuinely private package from an ordinary public one (resolves #1212).
Known limitation: the vcs-repository case from #1202’s original report (a private
git-hosted package, no lockfile equivalent to Path’s unambiguous signal) remains an accepted
gap, as does a lockless manifest (no composer.lock present) and the artifact repository
type (Composer’s lock records an artifact-sourced package under dist, not source, so
this repository kind can never trigger the override).
Deprecation & Abandoned Packages
Composer’s abandoned field powers two cross-ecosystem features rather than a
Composer-specific one — see Package Deprecation
Diagnostics
for the diagnostic and the Composer-only “Replace with X” code action, and Yanked Version
Diagnostic for how
abandoned also feeds the yanked-version check (restricted to exact-pin requirements).
Licensing
Composer’s license arrives for free in its hot-path Packagist registry response, so it is covered by both License Hover and the License Policy Diagnostic with no dedicated pre-fetch.
Swift
Basics
Swift Package Manager has no manifest data format — Package.swift is literal, executable
Swift source code, not TOML/JSON/YAML. deps-swift does not run a Swift compiler; it extracts
.package(url:, ...) calls with a set of targeted patterns covering every requirement form SPM
supports:
dependencies: [
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"), // upToNextMajor
.package(url: "https://github.com/apple/swift-collections.git", .upToNextMinor(from: "1.1.0")),
.package(url: "https://github.com/apple/swift-nio.git", .exact("2.65.0")),
.package(url: "https://github.com/apple/swift-atomics.git", branch: "main"),
.package(url: "https://github.com/apple/swift-algorithms.git", revision: "abc123..."),
]
Since Swift Package Manager has no registry of its own for GitHub-hosted packages, versions
are resolved from the same host the url: points at: GitHub’s tags API, via
deps_core::github’s shared client (also used by GitHub Actions — see below). A .branch/
.revision dependency is not version-resolvable at all and is shown as a non-registry Git
source with no hover version data. When a Package.resolved lock file is present alongside
the manifest, it is read to show each dependency’s actual pinned (in-use) revision/version.
A traits: argument (SwiftPM 6.1) of any shape is accepted after the requirement; its value is
never parsed. A .package(id: "scope.name", from: "1.0.0") registry dependency (SE-0292) is
resolved through the registry its scope maps to; see
Package Registries (SE-0292).
Package Registries (SE-0292)
SwiftPM has no public default registry, so an id: dependency is only version-resolved when its
scope (or a [default] entry) maps to a registry in a registries.json file, the same file
swift package-registry set writes:
{
"registries": {
"[default]": { "url": "https://tuist.dev/api/registry/swift" },
"acme": { "url": "https://swift.acme.dev/api" }
},
"authentication": { "swift.acme.dev": { "type": "token" } },
"version": 1
}
GET {url}/{scope}/{name} is sent with Accept: application/vnd.swift.registry.v1+json using the
lowercase identity (Apple.Swift-NIO is requested as apple/swift-nio; scopes match
case-insensitively). Releases whose key is not semver are skipped; a release with a problem is
marked yanked. With no matching entry the dependency is shown but never fetched, and an id: name
is never sent to GitHub or any other registry.
Configuration tiers
| Tier | Path |
|---|---|
| Project | <directory of Package.swift>/.swiftpm/configuration/registries.json (no ancestor walk) |
| User (macOS) | ~/Library/org.swift.swiftpm/configuration/registries.json |
| User (other) | $XDG_CONFIG_HOME/swiftpm/configuration/registries.json if XDG_CONFIG_HOME is set, else ~/.swiftpm/configuration/registries.json |
The project tier overrides the user tier per scope and for [default]; a scoped entry in either
tier wins over [default]. Exactly one user-tier path is read, with no existence fallback; an
empty or relative XDG_CONFIG_HOME makes the user tier unusable. The file is decoded with
SwiftPM’s strictness (version must be 1, scope keys must follow the scope grammar,
authentication.type must be basic or token, security must be an object when present, unknown
keys are ignored). A tier that exists but is unusable (unreadable, not a regular file, over 8 MiB,
or failing that decode) leaves every id: dependency unresolved with a warning; it never falls
back to the other tier’s [default].
On Unix, a .swiftpm that is a regular file (or any other stat failure besides “not found”) also
makes the project tier unusable, which fails closed; Windows reports “not found” for that path, so
the project tier is simply absent there. The user-level file is watched only where its path ends in .swiftpm/configuration/registries.json
(the ~/.swiftpm form on Linux): a change there reparses every open Swift document. The macOS
(~/Library/org.swift.swiftpm/...) and $XDG_CONFIG_HOME forms are not watched, and edits to
them (and to SWIFTPM_REGISTRY_* variables, read once at startup) take effect on the next manifest
parse. A change to a project’s .swiftpm/configuration/registries.json reparses only the open
Package.swift in the same directory; an unrelated registries.json elsewhere in the workspace,
or a nested package’s file, does not.
Trust and credentials
A registry URL is trusted exactly when it equals (after normalization: lowercase host, default
port and trailing / dropped, path case kept) a URL declared in the user-level file, whichever
tier declared it in the current workspace. Any other URL is workspace-declared. Trust never
depends on the workspace, so a hostile repository cannot redirect a credential.
| Trusted | Workspace-declared | |
|---|---|---|
| Reachability | exempt from registries.workspace_registries (like Cargo’s $CARGO_HOME), except loopback, link-local, cloud-metadata, unspecified and reserved hosts, which are never fetched | gated by registries.workspace_registries |
| Redirects | confined to the registry’s base URL | confined to the registry’s base URL |
| Credential | attached | never attached |
Credentials are read once at startup from SWIFTPM_REGISTRY_TOKEN, or from
SWIFTPM_REGISTRY_LOGIN together with SWIFTPM_REGISTRY_PASSWORD (the token wins; one variable of
the pair alone is ignored with a warning naming it). The header format comes from the user-level
authentication map only: token (or no entry) sends Bearer, basic sends Basic; a login of
token with no entry is also sent as Bearer, as SwiftPM does. authentication is keyed by host and
non-default port, like SwiftPM (swift.acme.dev:8443 does not match a portless key); unlike
SwiftPM, an explicit default port (:443) is treated as absent.
When the variables are unset, credentials come from the content of SWIFTPM_NETRC_DATA, else from
~/.netrc (the first source that exists wins, as in SwiftPM). On macOS, the opt-in
Keychain source sits between SWIFTPM_NETRC_DATA and ~/.netrc. A netrc
is matched by the registry’s host, ignoring the port, and is sent as Basic unless the
user-level authentication entry says token (or the login is token). SWIFTPM_NETRC_DATA
is parsed once at startup, first matching machine wins and host names compare case-sensitively;
an unusable value logs a warning naming only the variable and is skipped. ~/.netrc is re-read
whenever it changes, the last matching machine wins and host names compare case-insensitively; an
absent or invalid file yields no credential. The grammar follows SwiftPM’s Netrc.swift
(quoted values, # comments after whitespace, account between login and password, entries
missing a login or password dropped).
A single credential is sent to every trusted registry URL, including a public [default]
listed in the user-level file next to a private scoped registry. If you do not want a token
sent to a public registry, do not list it in the user-level registries.json while the variable
is exported (declare it in the project file instead). With netrc the credential depends on the
host, but a default entry applies to every trusted host without a machine entry, public
registries included. On Linux, and from SWIFTPM_NETRC_DATA on every platform, a default entry
is honored; on macOS the default entry of ~/.netrc is ignored.
Deviation from SwiftPM
Released SwiftPM 6.4.x sends environment credentials to every host; SwiftPM main (since
swiftlang/swift-package-manager#10507, unreleased) binds them to the origins of every configured
registry across both tiers, project tier included. deps-swift is stricter: user-tier provenance
plus an exact full-URL match, so path-tenanted shared hosts (host/api/swift/<repo>) never receive
another tenant’s credential. A project-only registry on a host you trust therefore gets 401
here where SwiftPM main would authenticate; add that exact URL to your user-level file. The
authentication type is also taken from the user tier only, and workspace-declared URLs are
policy-gated, which SwiftPM does not do.
On macOS SwiftPM reads credentials from the Keychain and not from ~/.netrc (unless forced).
deps-swift reads ~/.netrc there by default, ignoring its default entry; set
registries.swift_keychain_credentials to also read the Keychain, as described next.
macOS Keychain credentials
Set registries.swift_keychain_credentials to "enabled" (default "disabled") to let
deps-swift look up a registry credential in the macOS login Keychain, the way SwiftPM does:
{ "registries": { "swift_keychain_credentials": "enabled" } }
Credential sources have the order SWIFTPM_REGISTRY_* environment variables,
SWIFTPM_NETRC_DATA, Keychain, ~/.netrc, and, like SwiftPM, only the first source that applies
is used. With the setting enabled on macOS, ~/.netrc is therefore never read, and a Keychain
miss for a host does not fall back to the netrc: a registry whose credential lives only in
~/.netrc loses it until you disable the setting. The setting has no effect on other platforms (a warning
is logged and ~/.netrc is used), and an enabled setting never sends a credential to a
workspace-declared registry: the Keychain is consulted only for user-declared (trusted) registry
URLs, with the same header format rules as the other sources.
What happens at the first request. The lookup runs /usr/bin/security find-internet-password
for the registry’s host (and port, only when the URL names one). Reading the secret can make macOS
show an access prompt, and the lookup waits up to 10 minutes for you to answer it; lookups are
serialized, so only one prompt is open at a time, and a server queued behind another’s prompt does
not spend its own 10 minutes while it waits. Offline mode (network.offline) never runs
security. The registry
request is not sent until the lookup finishes, but the surrounding version fetch has its own,
much shorter timeout, so the dependency shows a fetch failure while the prompt is open. Once you
approve and the answer arrives after that fetch gave up, open Swift documents are refreshed
automatically and the credential is used; no editor action is needed.
Answer the prompt with “Allow”, not “Always Allow”. The server remembers the secret for the life
of the process, so “Allow” prompts once per server start. “Always Allow” adds /usr/bin/security
(not deps-lsp) to the item’s access list, after which any process running as your user can read
the secret silently with security find-internet-password -w. Because a cloned repository’s
workspace settings can enable this setting (see the caveats below), expect the prompt only for
registries you declared yourself.
What is remembered. Per registry host, for the life of the process unless noted:
| Outcome | Kept |
|---|---|
| Item found | until exit; disabling the setting drops it immediately and aborts a pending lookup |
| No item | 5 minutes, then looked up again |
Access refused (any security failure other than not found and interaction-not-allowed) | until the setting is toggled off and on, or the server restarts |
| Timeout, or exit 36 (interaction not allowed, for example a locked keychain without UI) | not remembered; retried on the next fetch |
A 401 never triggers a new lookup, and without a found item no credential is sent.
Caveats.
- With several Keychain items for one host,
securityreturns the first match, while SwiftPM picks the most recently modified item; the two tools can choose different credentials. - When a registry switches between sending a Keychain credential and none (lookup resolving to found, or any change of the setting), its cached release lists are dropped, so an anonymous response is not served afterwards; this is skipped offline, so the warm offline cache survives.
- A
didChangeConfigurationpayload without aregistriessection resets everyregistriessetting, including this one, to its default ("disabled"), as for the other settings. - The secret is read with
security ... -g, so non-ASCII secrets are decoded correctly. - Without a URL port, the lookup omits
-P, so an item stored for any port of that host matches. - The item’s account name is passed to
securityas an argument and is visible to other processes of your user in the process list; the secret is never passed as an argument and never logged. - Editor workspace settings (
.zed/settings.json,.vscode/settings.json; see Editor workspace settings and trust) in a cloned repository can enable this setting and therefore trigger the access prompt. The credential still goes only to registries declared in your user-levelregistries.json, never to hosts the repository declares. deps-clidoes not support the setting: it exits before a prompt can be answered, so it ignores the setting with a warning on stderr. Use the environment variables,SWIFTPM_NETRC_DATAor~/.netrcthere.
Limitations
- Paginated release lists.
Link: <...>; rel="next"pages are followed (at most 10 pages, 10,000 releases, 32 MiB of response bodies and 15 seconds in total; same origin and path as the registry URL, same credential rules) and merged. A missing, ambiguous, repeated or foreign next link, or a list over a limit, discards everything fetched and the dependency shows “registry returned an unusable next-page link”, “registry release list exceeds the page limit” or “registry release list took too long to fetch”; no latest version or up-to-date mark is derived from a partial list. The failure is remembered for 90 seconds. Every page is revalidated on every request, and a page whose revalidation fails fails the whole round instead of being served from cache, so a list never mixes page generations. The last complete list of this process is kept per package and served on a page failure, a timeout or offline mode (and during the 90-second window); with none yet, or when it exceeds the release limit, the error above is shown and cached pages are never assembled into a list. After a single registry blip the package is served from the last complete list for up to 90 seconds, so recovery becomes visible with that delay. The one remaining limit is a registry that changes between the page fetches of a single round. The kept list resets when the client is rebuilt (a trust or credential change). - Publication dates. With freshness enabled, the newest 8 non-yanked releases get their
publishedAtfrom one metadata request each (GET {base}/{scope}/{name}/{version}), at most 4 at a time and within one 2-second budget per lookup. Dates are remembered for the life of the process (a registry’s answer for a release does not change); a failed request is retried after 90 seconds. A missing date never fails the version list. - A hostname that resolves to a blocked address class shows the policy-specific message described under Cargo.
- No
id:name completion (SE-0292 has no search endpoint).
Package.resolved registry pins are shown as registry sources, and a pin of an unrecognized
kind is skipped instead of being treated as a source-control pin.
Non-GitHub Package Hosts (issues #979, #983, #924)
A registry-form .package(url: "...") dependency is only ever resolved against
GitHub’s API (deps-swift has no other registry client), so url_to_identity
now parses the URL’s host and produces a GitHub owner/repo identity only
when the host is github.com/www.github.com. A dependency declared against
any other host — GitLab, a self-hosted git server, or any private host,
including git@host:path SSH-form URLs — falls back to a visible
DependencySource::Git with the raw URL (matching the existing
.branch/.revision behavior) instead of vanishing from parse results or,
worse, being silently resolved against an unrelated, attacker-nameable GitHub
repository with the user’s GITHUB_TOKEN attached to the request.
Because this source is a non-resolvable Git dependency, no diagnostic fires
for it at all — validate_package_name accepts non-GitHub URLs (mirroring
deps-github-actions’s precedent for non-resolvable sources) instead of showing
a misleading “name must be a GitHub owner/repo identifier” error that implied
the manifest itself was wrong.
Release-Freshness Coverage (shared with GitHub Actions)
Unlike the ecosystems whose registry already carries a publish timestamp, Swift and GitHub
Actions both source package
versions from GitHub’s tags API, which has no date field. deps-swift and
deps-github-actions each augment their tag-derived version list with publish times from
GitHub’s releases API (one extra request per package, memoized behind a TTL) via the same
shared deps_core::github::ReleaseDatesCache (#486) — but this makes both ecosystems’
freshness signal partial, in four distinct ways:
- Requires
GITHUB_TOKEN. Without it, hover and completion render versions exactly as they did before this feature — no publish age shown, no error. A one-timetracing::info!notes the skip on first use (export GITHUB_TOKEN=$(gh auth token)to enable it). - Covers only versions with a matching GitHub Release. A tag with no corresponding Release
shows no date. Coverage of the versions actually rendered is high but not universal even among
recent versions — one real-world counterexample (
SwiftyJSON/SwiftyJSON) is missing dates for two of its eight most recent tags. - Reports Release publish time, not tag-creation time. If a maintainer tags a commit and only publishes the GitHub Release for it later, the date reflects the Release, which can read as more recent than when the code was actually written. There is no cheap way to distinguish this from a genuinely fresh release.
- Covers roughly the 100 newest releases only (one unpaginated API page). Browsing completions filtered to an older major version line can show no dates at all, even though the same package’s newest versions do — this is a known, accepted inconsistency within a single session.
A miss in any of the above degrades to no date shown, never a wrong one. GitHub Actions inherits
this coverage verbatim (same shared cache, same /releases endpoint) — the one difference is the
join key: a GHA uses: step’s tag keeps its v prefix as published in the version list, so the
join normalizes it before matching against the releases map, while Swift’s tag-derived versions
are already normalized at parse time.
Licensing
Swift’s license comes from a per-ecosystem background pre-fetch (GitHub’s licensee-detected
license.spdx_id) rather than the hot-path registry response — see License
Hover for the full cross-ecosystem picture.
NuGet
Basics
NuGet is the ecosystem with the most manifest formats deps-lsp supports in one place —
deps-nuget recognizes all of them:
.csproj/.fsproj/.vbproj(SDK-style project files) — modern<PackageReference Include="..." Version="..." />entries, in either attribute form or nested-element form (<PackageReference Include="..."><Version>...</Version></PackageReference>).Directory.Packages.props— .NET’s Central Package Management file: a single<PackageVersion Include="..." Version="..." />list shared across every project in a repository, with individual.csprojfiles’<PackageReference>entries then referencing packages by name only (no version).packages.config— the legacy (pre-PackageReference) manifest format, still found in older .NET Framework projects; itsversion="..."attribute is an exact pin (unlike a barePackageReferenceVersion’s floor semantics), normalized internally to a bracketed[1.0.0]range so the same interval parser handles both forms.
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
Every dependency resolves against nuget.org’s V3 API (api.nuget.org/v3/index.json, a
service-index indirection that then points at further per-capability resource URLs — see
below) by default, unless a NuGet.Config redirects it to a private feed (see “Private/Custom
Feeds”). When a packages.lock.json (or per-project packages.<name>.lock.json) file is
present, it is read to resolve each dependency’s in-use version. NuGet versions follow
Major.Minor.Patch[.Revision] (1–4 numeric components) with SemVer2 prerelease precedence,
compared case-insensitively — no maintained Rust crate implements this scheme, so deps-nuget
hand-rolls its own comparator (the same pattern deps-maven uses for Maven’s own scheme).
Private/Custom Feeds
A NuGet dependency whose applicable feed is overridden via a repository’s
NuGet.Config <packageSources> (Azure Artifacts, GitHub Packages, an internal
Artifactory/BaGet/ProGet instance) gets the same hover/diagnostic/completion
value a plain api.nuget.org dependency gets, instead of always querying the
public feed regardless of what the project actually configures.
Discovery — every in-repo ancestor file, merged root-to-leaf: deps-nuget
walks upward from the manifest’s directory toward the filesystem root (capped at
64 directories), checking NuGet.Config/nuget.config/NuGet.config at each
level, and merges every file it finds — not just the nearest one — applying
them in root-to-leaf order. A <clear/> anywhere in that chain is sticky for
every file below it: a repo-root NuGet.Config with <clear/> plus a private
feed stays cleared even for a subproject whose own NuGet.Config adds a second
feed without repeating <clear/>. User-profile and machine-wide config
(%APPDATA%\NuGet\NuGet.Config, ~/.nuget/NuGet/NuGet.Config) are not read —
deliberately: that is exactly where <packageSourceCredentials> most commonly
lives, and a global <clear/> there would silently re-route every project on
the machine.
Additive by default: a NuGet.Config with no <clear/> adds its declared
sources alongside the implicit api.nuget.org source, matching nuget.exe’s
own default-source-preservation behavior — a package present only on the new
feed and a package present only on api.nuget.org both keep resolving
correctly.
<packageSourceMapping> (dependency-confusion defense) takes priority when
present: NuGet 6.0+’s recommended <packageSourceMapping> element
(<packageSource key="..."><package pattern="..." /></packageSource>) is
honored when declared with at least one pattern — every dependency is then
routed by pattern match (bare *, a trailing-* prefix glob, or an exact id;
longest/most-specific match wins, exact beats prefix, ties make every tied
source eligible) instead of the additive chain above. A package matching no
pattern shows no version data (real NuGet fails restore with NU1100 in this
case) rather than falling through to an unmapped feed — this is what actually
closes the dependency-confusion attack <packageSourceMapping> exists for:
without honoring it, an internal package name could still be looked up against
api.nuget.org on a cache miss. <packageSourceMapping> rules are merged
across the same root-to-leaf ancestor chain as <packageSources> — a broader
ancestor mapping rule is never silently dropped by a narrower leaf file’s own
mapping (a leaf-level <clear/> inside <packageSourceMapping> itself is not
honored — see Known Limitations below). A mapping key that is the literal
nuget.org and names no declared <packageSources> entry resolves to the
real public feed rather than failing closed — the common real-world shape,
since nuget.org itself typically lives in the machine/user-profile config
this feature does not read.
<disabledPackageSources>/<packageSourceCredentials>/<remove> are
respected: a source disabled via <disabledPackageSources><add key="..." value="true" />, removed via <packageSources><remove key="..."/>, or with an
associated <packageSourceCredentials> block is excluded from resolution
entirely. Excluding a source does not by itself mean the affected
dependency shows no data: with no <clear/> in the chain, the exclusion just
falls back to the implicit api.nuget.org default — the same additive-source
model FR-003 already documents, since the excluded source is simply treated as
if it had never been declared. It becomes a hard failure only when the chain
also has a <clear/> (or an explicit <remove key="nuget.org"/>) in effect,
leaving nothing for the exclusion to fall back to. Key matching is
case-insensitive and additionally compares against NuGet’s _xHHHH_-encoded
child-element-name form (a source named Corp Feed appears as
<Corp_x0020_Feed> under <packageSourceCredentials>).
Authentication (issue #561): a user-profile-tier NuGet.Config
(Windows %APPDATA%\NuGet\NuGet.Config; Unix $XDG_CONFIG_HOME/NuGet/NuGet.Config
if set, else ~/.config/NuGet/NuGet.Config, else ~/.nuget/NuGet/NuGet.Config —
the first-existing candidate, never merged) is now discovered once at server
start and its <packageSourceCredentials> ClearTextPassword/Username
values are parsed and expanded (%ENV_VAR% syntax, re-evaluated on every
resolve so rotating the variable’s value takes effect without a file edit). A
credential declared there under key K attaches as a Basic Authorization
header to a repo-declared source only when all of: the repo entry’s own
key overlaps exactly one user-profile credential; that credential’s key
overlaps exactly one user-profile <add>; and the repo entry’s URL is
byte-identical to that <add>’s URL (origin-level matching is
deliberately not enough — see below). Any partial match — same key, different
URL; an ambiguous double-match; a %ENV_VAR% that is unset; a DPAPI-encrypted
<Password> — fails the source closed exactly like an unauthenticated
credentialed source always has, never queried anonymously. A repo-tier
<packageSourceCredentials> block still forces the same unconditional
fail-closed behavior as before this feature, regardless of any user-profile
match.
Why full-URL equality, not origin equality: pkgs.dev.azure.com and
nuget.pkg.github.com are shared by every tenant/organization on that host. A
hostile repository could otherwise declare its own project’s URL under the
same key your user profile trusts and receive your PAT on a same-origin,
different-project request. Requiring the exact URL closes that; it also means
the credential is never sent anywhere off the declared feed’s origin, even
via a redirect a compromised or misconfigured service index tries to induce.
A credential named for the real api.nuget.org (e.g. an Azure-Artifacts
upstreaming setup) never forces a source closed and never attaches — that
lookup was already unauthenticated before this feature and stays that way,
since it is not the leak this feature closes (see “Corrections” below).
registries.nuget_user_profile_sources (default false): with the
setting off, a user-profile file contributes credentials only — its own
<clear/>/<remove>/<disabledPackageSources>/<packageSourceMapping>
reach no project, and a user-profile-only <add> (nothing in the repo names
it) is inert. Turning it on additionally makes such an <add> a routing hop
— covering the common dotnet nuget add source workflow with no
repo-committed NuGet.Config — at the cost of downgrading every dependency
resolved through it to AlternateRegistry (OSV/deps.dev/hover-trust
suppressed, same tradeoff any private feed already carries). A
<disabledPackageSources> entry in your own profile still withholds your
credential from a matching repo-declared source even with the setting off —
it only ever suppresses the credential, never machine-wide-disables that
source for other projects.
Fail-closed on misconfiguration: an invalid feed URL (non-https, userinfo,
malformed, a local/UNC filesystem path, or protocolVersion="2") shows no
version data if it is the only remaining viable source, or is dropped (with a
logged warning) if other valid sources remain. A <clear/> that removes every
source down to zero — with or without an invalid entry left to name — is an
explicit fail-closed state, never a silent fallback to api.nuget.org (the
same issue #248/#502/#513 regression class Cargo/npm/PyPI already closed).
Proxies. Guarded traffic, including user-profile NuGet.Config feeds, connects directly and bypasses the
system proxy by default; set DEPS_LSP_WORKSPACE_REGISTRY_PROXY=proxy to route it through
the proxy (see Proxies and guarded registry traffic).
Reachability policy: governed by the same registries.workspace_registries
setting documented in Cargo, including its
connect-time message for a host that resolves to a blocked
address (a blocked feed halts the chain with that message). Additionally,
a workspace-declared feed’s own service-index resource URLs
(PackageBaseAddress/SearchQueryService/RegistrationsBaseUrl) are
re-validated against this same policy before being trusted — NuGet’s service
index is a two-hop indirection (a top-level feed URL resolves to a JSON
document naming further per-capability resource URLs) with no equivalent in
Cargo’s/npm’s/PyPI’s single-URL registry model, so a validated top-level host
could otherwise redirect resolution to an internal host via its own service
index.
Corrections (issue #561/#562): two limitations previously listed here are now closed, not accepted risk:
- A workspace-declared/alternate feed’s flat-container, service-index, and
registration-hive fetches now go through an origin-pinned,
connect-address-guarded transport — a redirect off the resolved
PackageBaseAddress/RegistrationsBaseUrlto a different host is stopped, matching the guaranteeapi.nuget.orgitself already had. Registration-hive enrichment (publish-time freshness, the hover-only*(unlisted)*marker) is no longer skipped for these feeds. - Wording fix, not a new claim: the FR-008 public-index carve-out (a
user-profile credential named for
nuget.orgnever forces a source closed) is not a claim that queryingapi.nuget.orgby package name is leak-free — it already leaks the name to Microsoft, which is the exact leak class #561 exists to close for a genuinely private feed. The carve-out exists only becausedeps-lspalready performs this unauthenticated public-index lookup today, and this feature must not regress that already-shipped behavior. A user who wants the public index itself treated as private should not declare it in<packageSources>.
Known limitations:
- Editing
NuGet.Configdoes not take effect until the affected manifest is next reparsed — no dedicated file watcher. A user-profile config created after server start is picked up only on restart (discovered once, not re-walked per parse). Flippingregistries.nuget_user_profile_sourcesviaworkspace/didChangeConfiguration, in contrast, re-parses already-open manifests immediately and purges an already-registeredAlternateRegistrychain (issue #592) — only a directNuGet.Configfile edit still needs a reparse to be picked up. <packageSourceMapping><clear/>is not honored — mapping rules only ever accumulate across the ancestor chain, never reset, even by a leaf file’s own<clear/>inside that element. Deliberate: undoing the merge-not-nearest-wins fix for this one element needs its own empirical verification against real NuGet first.- Authentication is user-profile-tier only (see above) — a repo-tier
NuGet.Configcan never carry a credential, even opt-in: a cloned repository controls both the credential-shaped value and the destination URL it would be sent to, an arbitrary-secret-exfiltration primitive no settings key can safely gate. - DPAPI-encrypted
<Password>values are permanently out of scope (Windows-only, not portably decryptable) — rejected at parse time, never silently dropped. - The machine-wide config tier (
/etc/opt/NuGet/Config,%ProgramFiles(x86)%\NuGet\Config) is not read — explicit non-goal, same cut as the user-profile-vs-repo boundary above. - A dependency resolved to a private feed drops out of OSV vulnerability
scanning, the deps.dev supply-chain signal, and the hover trust badge, and
its hover heading omits the
nuget.orgpackage-page link (it would be misleading next to live private-feed data). Declaring<packageSourceMapping>narrows this considerably: only genuinely-private ids (ones that don’t resolve to the realapi.nuget.orgsource, identified by URL, never by a source’skey) lose the signals. Without a mapping, adding one internal feed suppresses these signals for every dependency in the project, including ones still resolving fromapi.nuget.orgvia the implicit fallback hop. complete_package_namesstays source-blind and always queriesapi.nuget.org— the typed string is a prefix, not a resolved private package name, so this is safe but not feed-aware (mirrors npm’s/PyPI’s identical choice).
Release-Freshness Coverage
NuGet’s freshness signal (gated by freshness.enabled, default true) works, but only for
the newest ~8 versions of a package — for any feed that exposes a RegistrationsBaseUrl
resource in its service index (nuget.org always does), ages come from walking the registration
hive’s pages backwards from the newest, stopping once enough recent versions are covered. This
is a deliberate MVP trade-off, not an oversight: completion filters by the typed prefix
before truncating to the versions it renders, so a prefix that selects only older versions
(e.g. typing 6. against a package whose newest release is 9.x) renders those versions with
no age at all, even though hover on the same package shows ages normally (hover only ever
renders the newest ~8 anyway). A private V3 feed (Azure Artifacts, BaGet, GitHub Packages) that
omits RegistrationsBaseUrl entirely degrades to published_at: None for every version, with
the version list itself unaffected. Unlisted versions (the registration hive’s 1900-01-01
sentinel date) never render a bogus age. Added cost is typically zero extra round trips (the
version list and the registration index are fetched concurrently), one extra round trip when a
package’s registration hive externalizes its last page.
See npm for the other half of this shared signal — the two ecosystems pay for freshness in very different ways.
Deno
deps-deno provides LSP support for Deno projects, which can depend on packages from two
different registries in the same imports map.
Basics
| Manifest file | deno.json or deno.jsonc |
| Lock file (in-use version) | not yet supported — deno.lock parsing is a documented gap; no LockFileProvider is registered for this ecosystem |
| Registry | jsr: specifiers resolve against JSR (jsr.io/api.jsr.io); npm: specifiers resolve against the same npm registry client deps-npm uses |
| Version syntax | node-semver ranges, same as npm |
{
"imports": {
"@std/fs": "jsr:@std/fs@^1.0.0",
"lodash": "npm:lodash@^4.17.21"
}
}
A dependency’s scheme prefix (jsr: or npm:) determines which registry client serves its
hover, completion, and diagnostics — both schemes get the identical LSP experience version
data otherwise gets (outdated/unsatisfiable diagnostics, inlay hints, code actions), just
sourced from different upstream registries.
Non-Registry Dependency Sources
A deno.json/deno.jsonc imports entry resolved to a private npm scope via .npmrc is
classified as a non-registry dependency instead of defaulting to registry.npmjs.org
(resolves #1202). This .npmrc resolution now shares npm’s cached ancestor-config lookup
and live RegistryAccessPolicy instead of re-reading .npmrc from disk with a
hardcoded policy on every parse, and participates in the same live-config reparse scope as
npm — a registries.workspace_registries change reaches an already-open deno.json the
same way it reaches package.json (resolves #1212).
A scope-overridden npm: import is fully fetchable, not just correctly classified: hover,
diagnostics, completion, and inlay hints query the resolved alternate registry — the same
deps-npm registry client and fail-closed routing package.json uses for the identical
.npmrc entry — instead of silently behaving as an unresolved dependency (resolves #1227).
Release-Freshness Coverage
jsr: specifiers get full freshness coverage at zero extra request cost — better than
both NuGet and npm.
JSR’s meta.json (the same response JsrRegistry::get_versions already fetches for the
version list) carries a per-version createdAt timestamp, so published_at is populated
unconditionally with no separate fetch and no TTL to tune. npm: specifiers in deno.json
inherit npm’s own freshness behavior exactly, since DenoRegistry delegates them to the same
deps-npm registry client package.json uses.
Yanked Versions
jsr: specifiers get a genuine per-version yanked signal from JSR’s meta.json; npm:
specifiers delegate to npm’s own deprecated-sourced signal, including its restrictions — see
Yanked Versions & Vulnerabilities for the
full cross-ecosystem picture.
Licensing
Deno’s license comes from a per-ecosystem background pre-fetch (JSR’s per-version license
field, jsr: specifiers only) rather than the hot-path registry response — see License
Hover for the full cross-ecosystem picture.
GitHub Actions
Basics
Unlike the language-package ecosystems above, GitHub Actions is a CI/CD supply-chain pinning
ecosystem: deps-lsp does not track library dependencies, it tracks which commit of a
third-party Action each workflow step trusts. Two manifest shapes are recognized:
.github/workflows/*.yml/*.yaml— ordinary workflow files, matched via a directory-pattern rule (any file in that directory with a.yml/.yamlextension), not an exact filename.action.yml/action.yaml— a composite/reusable Action’s own metadata file, whoseruns.stepscan itself reference other Actions.
Every uses: step is a dependency:
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v4.1.1
A tag reference (@v4) resolves against GitHub’s own tags API for that owner/repo (no
separate package registry exists for Actions) and is flagged by the mutable-ref-pin diagnostic,
since a tag can be force-moved by the repository owner to point at different code without the
version string in your workflow ever changing — see CI/CD
Pinning for why this matters and how the SHA-pinning code
action/code lens works. A full 40-character commit SHA is the only pin GitHub itself cannot
silently repoint.
Non-Semver Tag Handling (issue #550)
When a GitHub Action repository has only tags that don’t parse as full semantic versions — such as
dtolnay/rust-toolchain with its sole tag v1, or literal-named tags like cargo-deny — the
hover and diagnostics handle these gracefully instead of showing a false “Unknown package”
diagnostic.
- Hover: shows the package as resolvable (not unknown), but with an empty “Recent versions” list since no tag matches the standard semver filter. The mutable-ref-pin diagnostic still fires for the tag ref even though no update-to-latest is available.
- Diagnostics: the package is recognized as resolvable, not reported as “Unknown package” — this is a real action, just not one with a conventional semver release train.
- Literal-named tags (non-version-like names): are now recognized as actual tags (when confirmed by the registry) and qualify for the mutable-ref-pin diagnostic, even though they don’t follow the
major.minor.patchorv\d+patterns the parser heuristic would normally detect.
SHA-Pin Comment Tag Freshness (issue #907)
A SHA-pinned step commonly carries a human-readable trailing comment naming the
tag it was pinned from (uses: actions/checkout@<sha> # v4). This comment is
accepted at partial precision too — # v6, # v2.9 — not just a full
major.minor.patch tag; is_partial_semver_shaped (deps-core’s git_ref
module) is the acceptance gate, stricter than the git-ref-oriented
is_tag_shaped so free-text comments (a date, an issue number) aren’t mistaken
for a tag.
The comment is treated as a hint, not ground truth: hover, diagnostics, inlay
hints, and the bulk “update outdated” code lens all resolve the pin’s actual
freshness against TagIndex.sha_to_tag (which SHA the tag really points at
today), so a comment that has drifted from the pinned SHA no longer produces a
false “up to date” result just because the comment text looked current.
A full-SHA pin that no release tag points at (with no comment, or a non-version one like
# cargo-deny) is reported as outdated once the repository’s tags are loaded; a pin to a
non-release commit is therefore offered the latest release. This includes a commentless pin on a
non-release commit newer than the latest release: it is reported outdated and update-all re-pins
it to the latest release (effectively a downgrade).
A pin whose SHA is absent from the loaded tag index is reported outdated even when it carries a
version comment, since the comment cannot make a non-release commit the latest release. The
“absent” verdict is only drawn from a complete tag list: a repository with more tags than the
fetch cap (30 pages of 100) yields a truncated index, and a SHA missing from it is never called
outdated nor proven current: the comment may still stand in for its status, but only short of up to
date (an up-to-date comment, whatever its shape, reads unresolved), and no mismatch diagnostic is
raised. The one exception is a comment naming a full version (# v4.2.0) that the truncated index
maps to a different commit: that comment is provably wrong, so it is not trusted, the status is
unresolved and the sha-comment-mismatch diagnostic fires. Tags are matched by version, so
# 4.2.0, # V4.2.0 and # v4.2.0+build are contradicted by the tag v4.2.0 as well. A comment
naming a version the truncated index does not list stays unverifiable, with the same cap on its
status. An exact tag pin (@v4.8.0) missing from a truncated index is capped the same way,
and so is a floating partial pin (@v1) the list does not contain: a truncated list proves a tag
is present, never that it is absent, so such a pin reads unresolved where a complete index would
read it by the usual moving-line rule.
A SHA pin whose commit is tagged only by a floating tag below the latest release (v1 or 1.1
while the latest is v1.1.0 on another commit) is reported outdated (issue #1730). A tag at or
above the latest release, including v3.0.0-rc1, counts as up to date.
Trade-off. A non-release commit newer than the latest release that carries only a floating tag is also reported outdated, and update-all re-pins it to the latest release. Making that case distinguishable is tracked in #1725.
When the update target is not version-shaped (a release named stable), the SHA is rewritten
without a # tag comment; any words after the old tag stay in the comment
(# v1.0.0 pinned for CVE becomes # pinned for CVE).
Peer-document rescan (issue #1716)
When a tags fetch adds or changes a repository’s tag index, every other open workflow that uses that repository is rescanned: its vulnerability check and diagnostics are refreshed without an edit or reopen. Events are coalesced over 250 ms and the rescan runs for at most 4 documents at a time. The vulnerability rescan applies only while vulnerability checking is enabled and the server is online, but diagnostics are republished (and inlay hints and code lenses refreshed once per batch when a document did not already request its own) regardless, since the tag index also drives the status and comment checks. A document that is still loading is skipped and publishes after its own load. GitLab CI/CD does not emit refresh events.
Comment mismatch diagnostic (issue #1722)
When the trailing comment names a tag that is provably not the pinned commit’s tag, the
sha-comment-mismatch diagnostic flags the step (an imposter-commit or stale-comment signal) and
hover adds a **Warning** line, either comment says v2.87.20, but SHA is v2.87.22 or
SHA is not the commit of any release tag. A partial-precision comment (# v4) agrees with a
SHA whose most specific tag extends it. Nothing is reported while the tag index is cold or when
the SHA is absent from a truncated index, unless the comment names a full version that index maps
to another commit. Severity defaults to warning and is set with
diagnostics.sha_comment_mismatch_severity; there is no on/off toggle.
The Correct version comment to <tag> quickfix rewrites only the comment’s tag token to the
tag the SHA actually carries, leaving the SHA, its casing and any closing quote or } untouched.
It is withheld when the registry tag is not a plain version, contains control or bidirectional
characters, is overly long, or when the comment token ends in punctuation.
Tag pins and release ordering
A tag pin is compared with the latest release by SemVer precedence. A pre-release pin
(@v2-beta, @v3.0.0-rc.1) is reported outdated once a newer release exists, while a partial pin
(@v4) stays current as long as the latest release extends it. A pin that is ahead of the latest
release is reported up to date, unless the tag index is complete and has no such tag (@v40, a
typo or a deleted tag): that pin is unresolved, never outdated, so no downgrade is offered. A
branch named like a version and ahead of the latest release reads the same way. Refs that are not on a version line (@v1.x, @v3-node20) are
never reported outdated. SHA pins are matched case-insensitively and shown in lowercase.
Unpublished refs and the unknown-ref diagnostic (issue #1766)
Tag refs are exact, so @4.3.1 names nothing in a repository that only tags v4.3.1. When the
repository’s tag list is complete, a tag pin whose exact text matches no tag is an unpublished
ref, and what it means depends on its shape:
- A full release (
@4.3.1,@v4.3.10,@v5.0.0-rc.1: a plainmajor.minor.patchcore, or one with a recognized pre-release label such asrcorbeta) cannot plausibly be a branch. It is unresolved instead of up to date at any position, and theunknown-refdiagnostic reports`4.3.1` is not a published tag of actions/checkout. Severity defaults to warning and is set withdiagnostics.unknown_ref_severity; there is no on/off toggle. An outdated full release stays outdated, since the update target is valid either way. - Any other tag-shaped ref (
@v1,@v5.x,@v3-node20,@v40,@v3.4.0-working) may be a branch:ruby/setup-ruby@v1,pnpm/action-setup@v3,aws-actions/configure-aws-credentials@v3-node20andcodecov/codecov-action@v5.xare documented branch pins. They keep the rule above (unresolved only when ahead of the latest release) and are never reported byunknown-ref, so a ref such as@v40gets an unresolved status but no diagnostic.
Nothing is reported while the tag index is cold, empty or truncated. deps-cli check --fail-on unknown-ref fails on the diagnostic regardless of its severity; it is not part of the
default --fail-on set.
When exactly one published tag matches the written ref after normalization (4.3.1 beside tag
v4.3.1), the Change ref to published tag <tag> quickfix rewrites the ref to that spelling
(issue #1781). It is withheld when no tag or several differently-targeted tags match.
In the editor the verdict is only as fresh as the last tags fetch of that repository. Every
document open or edit that uses it re-requests the tags (a conditional request, so an unchanged
list is cheap) and refreshes the diagnostics, so a pin bumped to a tag published after the last
fetch can show the warning until the next open or edit of a workflow using that repository.
deps-cli always fetches fresh.
Updating quoted and flow-style pins (issue #1724)
Update-all and the update quickfix rewrite a plain scalar SHA pin to <new sha> # <tag>. For a
quoted or flow-style pin (uses: 'owner/repo@<sha>', {uses: owner/repo@<sha>, with: {...}})
without a comment only the 40-hex SHA is replaced, so the quoting and flow structure stay intact.
The same holds for a tag that does not read as a version (@stable): the pin is converted
and rewritten to the bare SHA with no # <tag> comment, since a comment the next parse could not
read back would pile up on every update.
Comments after quotes and flow mappings (issue #1732)
The trailing # vX comment is also read, mismatch-checked and rewritten when only the closing
quote and/or the flow mapping’s } sit between the SHA and the comment: uses: "owner/repo@<sha>" # v4,
uses: 'owner/repo@<sha>' # v4, {uses: owner/repo@<sha>} # v4, {uses: "owner/repo@<sha>"} # v4.
Blanks before the } ({ uses: owner/repo@<sha> } # v4) are accepted too. The rewrite keeps the
delimiters (<new sha>" # <new tag>). A comment after a flow mapping that
has sibling keys after uses ({uses: owner/repo@<sha>, name: x} # v4) is ambiguous and not
attributed to the pin.
Mutable-Ref Pinning
GitHub Actions shares its mutable-ref-pin diagnostic and bulk “Pin All to SHA” code lens with GitLab CI/CD — see CI/CD Pinning for the full detail.
Release-Freshness Coverage (shared with Swift)
GitHub Actions sources release dates from the same deps_core::github::ReleaseDatesCache Swift
uses — see Swift for the full
detail, including the four ways this coverage is partial and the GITHUB_TOKEN requirement.
Vulnerability Scanning
OSV.dev does not version-match the GitHub Actions ecosystem server-side (a versioned query
returns nothing even for an affected version), so deps-lsp fetches a package’s advisories
without a version and matches their affected ranges locally against the pinned version.
- A full SemVer pin (
@v4.1.2, or a SHA pin whose tag resolves to one) is checked; an advisory whose range contains it produces the usual vulnerability hover and diagnostic. - A floating tag (
@v4,@v4.1) is resolved through the commit the tag currently points at, using the same tags fetch as SHA pins: the most specific release tag on that commit that extends the written tag (v4.2.2for@v4) is the version that is checked, and hover shows it asResolved. This is a snapshot taken at scan time: if the tag later moves to another commit, the result is refreshed only when the tags are next fetched. If the commit carries no such release (for example onlyv4, or an unrelatedv5.0.0), the tag index is not loaded yet, or the pin is a bare major (@4), a SHA pin with no resolvable tag, or any other non-SemVer pin, it is shown as “not checked”, never as clean. - A commit often carries several release tags (
v4.8.0andv4.9.0). Every one of them is checked, so an advisory that affects only a sibling tag is still reported, and hover and the diagnostic name it (matched release tag v4.9.0). For a SHA pin all release tags on the commit count; for an exact tag pin (@v4.8.0) only the releases of the same major version do, and for a floating tag (@v4) only the releases that extend the written tag. Pre-release tags are never checked as siblings. When a tag list was truncated (more than 3000 tags), or has not been fetched yet (cold cache) or failed to fetch, the sibling list may be incomplete: a clean answer for such a pin is shown as not fully checked in hover rather than as clean (no diagnostic is raised), anddeps-cli updaterefuses alatestit cannot verify the same way. A sibling-only advisory whose fix is not newer than the pinned version is not offered as a fix. The same sibling check applies to the latest version, to upgrade candidates and to a recommended fix target; while a candidate’s sibling tags are unknown (tag index not loaded yet) it is shown as “not checked”, never as clean. - An affected range that has only an
introducedevent and nofixedevent is treated as open-ended unless the advisory’sdatabase_specific.last_known_affected_version_rangegives a parsable upper bound (< Xor<= X); versions above that bound are not reported as affected, whether the range or an explicit versions list matched them. An unparsable bound makes the range unevaluable rather than affecting every later version. - An advisory exists for the package but its affected range cannot be evaluated: a diagnostic
notes that vulnerability data was not checked (
UnevaluableAdvisoryRange). - A package with more than 50 advisories is reported as truncated rather than partially matched.
OSV.dev package names are case-sensitive, so the queried name is the repository’s canonical
owner/repo casing taken from the GitHub tags response (a lowercase uses: value still
matches). Vulnerability checking therefore depends on the GitHub tags fetch and its API quota
(60 requests/hour unauthenticated; set GITHUB_TOKEN to raise it).
Until the casing is confirmed (or when the tags fetch fails or the repository is private), the
name as written in the manifest is queried instead, and only a positive result is trusted: an
advisory found under the written name is reported, while an empty or non-matching answer stays
“not checked” (CanonicalNameUnconfirmed), never clean. Such a result is re-checked under the
canonical casing once the tags arrive, and a recommended fix is not offered as verified for it.
Because of this fallback, the written owner/repo of a private repository is sent to osv.dev
whenever its canonical name cannot be confirmed, as it was before canonical-name resolution.
Known limitations: a renamed or transferred repository is queried only under its current GitHub name once confirmed, so an advisory OSV.dev still files under the old name is not matched.
GitLab CI/CD
Basics
Like GitHub Actions, GitLab CI/CD is a CI/CD supply-chain pinning ecosystem, not a language
package manager — deps-lsp tracks the include: directive, which pulls in job definitions
from another project or a published CI/CD Catalog component. Manifests are .gitlab-ci.yml at
the repository root, or any .yml/.yaml file under .gitlab/ci/ (directory-pattern match,
same mechanism GitHub Actions uses for .github/workflows/).
Two pinnable include: forms are recognized:
include:
- project: 'my-group/my-project'
ref: v1.2.0
file: '/templates/build.yml'
- component: gitlab.com/my-group/my-component/my-module@1.0
A project:/ref: include resolves against that project’s git tags (GitLab’s
GET /projects/:id/repository/tags API); a component: include resolves against the
project’s published releases (GET /projects/:id/releases) — a CI/CD Catalog component
version is a release, not a bare tag. .gitlab-ci.yml also commonly uses YAML anchors/aliases
to reuse job templates, which deps-gitlab-ci resolves faithfully (see below) so a pinned
version hidden behind an alias is still tracked correctly. Like GitHub Actions, there is no
package registry involved — only the mutable-ref-vs-SHA distinction described in CI/CD
Pinning.
YAML Anchor/Alias Resolution
.gitlab-ci.yml supports YAML anchors (&name) and aliases (*name) for reuse — GitLab’s
own docs recommend anchor-based templates as the standard way to reduce duplication across
jobs. Within the include: subtree, both scalar anchors (a ref:, project:, or
component: value aliased elsewhere) and mapping-shaped anchors (a whole include:
entry reused via - *tpl, include: *tpl, - <<: *tpl, or - <<: [*a, *b]) resolve
correctly, with hover/diagnostics/inlay hints positioned at the alias token (not the
anchor’s definition site).
Merge-key (<<:) precedence matches what GitLab’s own YAML loader (Ruby Psych) actually
resolves, not the abstract YAML 1.1 merge-key spec’s “explicit keys always win” reading —
the two disagree in several cases: <<: is applied positionally, like any other key, so an
own key written before <<: loses to the merged value, while one written after wins;
<<: [*a, *b] is first-wins when both templates define the same key; two separate <<: keys
in one mapping are last-wins (a different result from the sequence form); and a template
reached through a chain of merges (.c: &c {<<: *b} where .b itself merges *a) resolves
transitively with the same rules at each level, no special-casing.
SHA-pin quickfix and version completion are withheld at the alias site itself, scoped to
whichever field actually backs version_range (ref: for a project: include, component:’s
own field) — resolving an alias (scalar or container) produces a value with no editable
literal span at that position, since rewriting it would need to edit the anchor definition
instead. This is tracked per-field, not per-entry: a merged/aliased project: next to a
literal ref: is unaffected — only the field that is itself alias-derived loses its
quickfix/completion.
Two documented, deliberate divergences from Psych (both accepted rather than fixed — see the linked spec for the full rationale):
- A doubly-nested explicit
nullinside a merge chain (e.g.- {ref: v9, <<: *b}where*bis{<<: *a, ref: ~}) resolves to the entry’s earlier literal value instead of Psych’snil, since this crate’s field representation cannot distinguish “key absent” from “key present but null” the way Ruby’sHashcan. - A scalar-anchor alias resolved through a merge (e.g.
ref: *pinwhere*pin’s own anchor text is null-like) is not re-checked for null-ness the way a directly-typed null scalar is, so it is captured as literal text rather than treated as absent.
Remaining known limitation. include: *incs — aliasing a whole sequence of N
entries from one alias token — is a structural won’t-fix (#917): one alias token cannot back
N distinct entries’ name_range/version_range, so this shape is deliberately never
detected (0 records, matching today’s silent-drop behavior rather than a misleading partial
one).
Self-Hosted Instances
.gitlab-ci.yml’s include: directive supports two version-pinnable forms:
include: - project: org/proj+ref: <tag|branch|sha>— a plain git ref, resolved against the GitLab repository-tags API (GET /projects/:id/repository/tags).include: - component: host/org/proj/name@<version>— a CI/CD Catalog component pin, resolved against the project’s published releases (GET /projects/:id/releases) — a component version is a Release; a tag with no release is never a resolvable component version.
Component pin priority. A component: pin is resolved in GitLab’s own documented
order: commit SHA (exact) > exact release name > branch (honest-unknown — GitLab CI
never fetches /repository/branches for this, since it would double the request cost to
distinguish two cases that render identically) > ~latest (highest published
non-prerelease release) > partial semver (1.2, 1, via semver::VersionReq range
matching — ~1.2 matches >=1.2.0, <1.3.0).
SHA pins. A full 40-character SHA pin is classified against the route’s tag index once its tags (or releases) are loaded, the same way as in GitHub Actions: a SHA on the latest release’s commit is up to date; a SHA at an older tag, or that no tag points at, is reported outdated and update-all re-pins it to the latest release’s full SHA (never to a bare tag). Before the index is populated the pin stays unresolved. This includes a pin on a non-release commit newer than the latest release, which is reported outdated (effectively a downgrade).
SHA-pin trailing comments (issue #1743)
A literal SHA ref: (or component @<sha>) may carry a trailing tag comment, as in GitHub
Actions:
include:
- project: 'my-group/my-project'
ref: 44790937c6a1e4f0b1b1f1a0d0f6c2e3f4a5b6c7 # v1.117.0
file: '/templates/build.yml'
- Update-all and the update quickfix rewrite the SHA and the comment together
(
<new sha> # v1.120.0). A plain or quoted pin without a comment gains one; quotes are kept. A comment is never deleted when the tag index has no answer. - A comment naming a tag that is not the pinned commit’s tag raises
sha-comment-mismatch(severitydiagnostics.sha_comment_mismatch_severity) and a hover warning. Nothing is reported while the tag index is cold, or while a truncated tag list lacks the SHA; a SHA the truncated list lacks never reads as up to date from its comment, whatever the comment’s shape (unresolved instead). The exception is a comment naming a full version (# v1.117.0) that the truncated list maps to another commit: that comment is provably wrong, so it is not trusted, the status is unresolved and the mismatch is reported. - A
project:tagref:that is a full release no tag of the complete Tags list matches (ref: 1.117.0beside tagv1.117.0) is unresolved instead of up to date and raisesunknown-ref(severitydiagnostics.unknown_ref_severity). A partial or suffixed ref (v1,v1.x,v3-node20) may be a branch, so it keeps the ahead-of-latest rule and is never reported; acomponent:include is never reported either, since a version without a release is not a missing tag. An exact tagref:that a truncated Tags list does not reach is unresolved as well, never up to date. A partialproject:ref (1.2) is read as a branch, so the truncated-list rule for floating partial pins described there does not apply. See GitHub Actions for the shape rules. TheChange ref to published tag <tag>quickfix rewrites theref:to the single published spelling that matches (issue #1781). - A tags fetch that first populates or changes a project’s tag index rescans every other open
.gitlab-ci.ymlthat uses it, as for GitHub Actions (issue #1765). - The
Correct version comment to <tag>quickfix rewrites only the comment’s tag to the tag the pinned commit carries. It is offered only when the comment names another tag of that commit, not for an unknown SHA, a confirmed comment or a pin without one. - A non-version update target (a release named
stable) writes no# tag; trailing words in the old comment are kept. - Version completion is withheld inside the comment.
- A comment on an alias site (
ref: *pin # v1.0.0) is never read: the comment can go stale after the anchor is updated, with no mismatch diagnostic and no rewrite. - The “Pin to commit SHA” quickfix, the bulk “Pin All to SHA” lens and the
~latest/partialcomponent:pin quickfix write<sha> # <tag>for a plain, last-on-line literal ref (ref: v1.0.0,component: .../comp@1.0.0). A quoted ref (ref: "v1.0.0"), a flow-style entry ({project: org/proj, ref: v1.0.0, file: ci.yml}) and a ref with more content after it get the bare SHA, since a comment cannot follow them; neighbouring keys are kept. An aliased ref is not edited at all.
A SHA pin tagged only by a floating tag below the latest release (v1.1 while the latest is
v1.1.0 on another commit) is reported outdated. SHA pins and tag pins are compared by the same
tag order, so a pre-release above the latest release (v2.0.0-rc1 against latest 1.9.0) is up
to date either way. As in GitHub Actions, a non-release commit newer than the latest release that
carries only a floating tag is reported outdated (tracked in #1725).
A project: tag pin resolves through the project’s Tags list exactly like a GitHub Actions tag
pin (an exact release, a floating partial version, or unresolved), so hover and sibling-tag checks
see the same version in both ecosystems. A component: version stays unresolved here.
An exact project: tag pin that is ahead of the latest tag is reported up to date, unless the
loaded tag list is complete and has no such tag (ref: v40.0.0, a typo or a deleted tag): that
pin is unresolved, never outdated, so no downgrade is offered. A branch named like a version
reads the same way. This applies to project: includes only: a component: version names a
release, and a tag may exist without one, so the releases list never proves a version absent.
Self-hosted instances. include: - project: carries no host segment in GitLab’s
own syntax at all — the instance is always implicit. Set
registries.gitlab_instance_host to the host such an include (and a
$CI_SERVER_FQDN-relative component: include) should resolve against:
{
"registries": {
"gitlab_instance_host": "gitlab.mycorp.dev"
}
}
Left unset, both forms are parsed (the include reference is still shown in hover) but not version-resolved — an informational diagnostic explains why and names this setting as the remedy. No default host is ever guessed and no git-remote inference is performed: an incorrect guess would show version data from the wrong GitLab instance.
Blocked by policy is a distinct diagnostic from unset. When the configured
instance host (or an inline component: host) is rejected by
registries.workspace_registries (see Cargo, issue
#967), the diagnostic names the blocked host class instead of the generic “set
registries.gitlab_instance_host” message — the setting is already correct in that case,
so telling the user to set it would be wrong advice. Two component: hosts differing only
in letter case are grouped under one diagnostic, since hostnames are case-insensitive.
The one host GITLAB_TOKEN is ever sent to. The token’s destination comes from the
process environment, never from registries.gitlab_instance_host: editors merge a cloned
repository’s own settings into that value (see
Editor workspace settings and trust),
and a GitLab Personal/Project Access Token is only ever valid for the instance that issued it.
With GITLAB_TOKEN_HOST unset, GITLAB_TOKEN is sent only to gitlab.com; with
GITLAB_TOKEN_HOST=gitlab.mycorp.dev exported next to GITLAB_TOKEN, it is sent only there —
a component: include naming gitlab.com in that same file is fetched unauthenticated. An
invalid GITLAB_TOKEN_HOST (a port, scheme, path, a trailing dot, or a non-punycode
internationalized name) disables the token entirely, with a warning in the log, rather than
falling back to gitlab.com. An empty GITLAB_TOKEN_HOST is treated as unset, so the token is
bound to gitlab.com. A 401/403 from a host the token is not bound to shows a hint to set
GITLAB_TOKEN_HOST if the instance is self-hosted.
gitlab.com and the GITLAB_TOKEN_HOST host are operator-trusted and use the baseline policy
tier: a public-looking name that resolves to a private address (split-horizon DNS, common for a
self-hosted instance) is reachable there, and the system proxy applies. A private IP-literal host
stays blocked.
registries.gitlab_instance_host still drives host resolution, unauthenticated unless it names
the same host as GITLAB_TOKEN_HOST. Every other literal host a component: include names is
always fetched unauthenticated, subject to the same
registries.workspace_registries HostClass policy gate every other ecosystem’s
workspace-declared host goes through. A host that resolves to a blocked address class at
connect time shows the policy-specific message described under
Cargo.
A workspace/didChangeConfiguration that changes registries.gitlab_instance_host
re-parses every already-open GitLab CI document immediately, the same as
registries.workspace_registries and registries.nuget_user_profile_sources
(issue #592) — no edit or reopen needed for the new host to take effect.
Per-document host cap. At most 8 distinct literal component: hosts are resolved
per document; a further distinct host is logged once and left unresolved — this bounds
the per-didOpen connection fan-out a single file’s content could otherwise drive
unbounded.
Mutable-Ref Pinning
GitLab CI/CD shares its mutable-ref-pin diagnostic and bulk “Pin All to SHA” code lens with GitHub Actions — see CI/CD Pinning for the full detail.
Architecture Overview
This page is the deep-technical reference for how deps-lsp is put together — read
Editor Setup, Configuration, and the
Ecosystem Reference first if you’re new here. Everything below assumes
you already know what the server does; this explains how.
Every ecosystem is a thin, independent implementation of a small set of shared abstractions
defined in crates/deps-core. The binary, crates/deps-lsp, wires ecosystem crates into a
running LSP server via tower-lsp-server and does not itself know anything ecosystem-specific:
parsing, registry access, and LSP response formatting are all delegated through the traits
described below, so deps-lsp itself is only a request-dispatch and document-lifecycle layer.
The Ecosystem trait
Ecosystem (crates/deps-core/src/ecosystem.rs) is the extension point every ecosystem crate
implements — see crates/deps-cargo/src/ecosystem.rs for a concrete example. It is a sealed
trait (requires Self: ecosystem::private::Sealed), so it can only be implemented from inside
this workspace: private::Sealed lives in a pub (but #[doc(hidden)]) module, because Rust
has no visibility level meaning “this workspace’s crates, but no others” — sealing here is a
documented contract enforced by code review, not a compiler-enforced wall.
Identity and routing
-
EcosystemId— an exhaustive, not#[non_exhaustive]enum of every supported ecosystem, generated from one variant list by theecosystem_ids!macro (which also derivesEcosystemId::ALL,EcosystemId::id(), and itsFromStrimpl, so the three can never drift apart). Any code that needs to branch on ecosystem identity should match on this enum instead of re-deriving a partial string match: an unhandled variant here is a compile error, while an unhandled string is a silent runtime bug (the class of bug issue #118 fixed, where two call sites silently mishandled ecosystems missing from an incomplete string match). Adding a 15th ecosystem therefore forces every exhaustivematchonEcosystemIdacross the workspace — includingEcosystemId::osv_ecosystem()’s OSV.dev name mapping anddeps-core::deps_dev’s deps.devsystemmapping — to be updated at compile time. -
ParseResult/Dependency— trait-object interfaces a parser returns; ecosystem-specific dependency types are exposed generically but remain downcastable viaas_any().ParseResult::blocked_registries()additionally reports any dependency line whose registry-index resolution was blocked by the workspace-registry reachability policy (see Network security below), so a blocked custom registry never degrades silently — it renders anINFORMATION-severity diagnostic instead. -
Manifest routing is checked by
EcosystemRegistryin a fixed four-stage order, each stage only consulted once the previous one misses:manifest_filenames()— exact basename match (Cargo.toml,package.json).manifest_patterns()— single-*-wildcard basename globs (requirements*.txt), case-sensitive.manifest_extensions()— file extension only, for basenames that vary (.csproj/.fsproj/.vbprojfor NuGet’s MSBuild project files).manifest_directory_patterns()—(directory_path, suffix)pairs matched against the tail of the file’s directory path on segment boundaries (.github/workflows/*.ymlfor GitHub Actions,requirements/*.txtfor PyPI’s split-file layout) — the only stage that needs the full path, so it’s reachable only fromEcosystemRegistry::for_uri, neverfor_filename.
A separate, single-purpose lookup,
EcosystemRegistry::for_lockfile, matches an ecosystem’slockfile_filenames()(Cargo.lock,package-lock.json, …) so file-watcher events on a lock file route to the right ecosystem for a resolved-version refresh without a full reparse.for_watched_configdoes the same forwatched_configs()(path-suffixWatchedConfigentries, each tagged routing-changing or requirement-rewriting) — non-lockfile config an ecosystem resolves during parsing (npm’spnpm-workspace.yaml,.npmrc) — except a change there triggers a full document reparse, not just a cache refresh, since the config’s value is baked into the parsedParseResultrather than looked up separately.
LSP response generation
generate_hover / generate_diagnostics / generate_code_actions / generate_code_lenses /
generate_inlay_hints all have default implementations delegating to shared logic in
deps-core::lsp_helpers, driven by the ecosystem’s own formatter() (an EcosystemFormatter
implementation, see below) and registry() (a Registry implementation) — most ecosystem
crates only need to supply parsing plus a formatter and a registry client, not reimplement LSP
response generation. Override a generate_* method only for genuine ecosystem-specific
behavior.
generate_completions has no default that every ecosystem inherits for free in the way the
other generate_* methods do — the trait provides a default dispatch, but every ecosystem is
expected to reason about it explicitly. That default detects the
deps_core::completion::CompletionContext at the cursor position (PackageName, Version, or
Feature) and dispatches to one of three hooks:
complete_package_name— usually built ondeps_core::completion::complete_package_names_generic.complete_version— usually built ondeps_core::completion::complete_versions_at_position, which also threads throughfreshness.enabledso version completion items can carry a relative-age label.version_operator_chars()tells this default which leading operators (npm’s^/~, PyPI’s>=/!=, NuGet’s bracket intervals) to strip from a completion prefix before matching registry versions.complete_feature— feature-flag array entries; only Cargo and Go override this (most ecosystems have no feature-flag concept).
deps-maven and deps-gradle override generate_completions wholesale instead, because they
route on their own XML/Groovy-DSL cursor context rather than CompletionContext. When the
manifest fails to parse entirely (typically mid-edit), deps-lsp falls back to
fallback_completion_prefix/completion_insert_text/fallback_bare_insert_text — a raw-text,
parse-free completion path most ecosystems implement via the shared scanners in
deps_core::fallback_completion.
EcosystemConfig and LicenseSource
EcosystemConfig (inlay-hint text/behavior — up-to-date/needs-update/loading text, offline
marker) and LicenseSource (RegistryDeclaredSpdx / FetchedDeclaredSpdx / DetectedSpdx /
PomFreeText, driving hover’s “(detected)” qualifier and whether fetch_license needs a
dedicated call — see Licensing) are both #[non_exhaustive]
structs/enums built via a new() + with_* builder chain rather than a struct literal, so
adding a field or variant never breaks an out-of-crate construction site.
Registry and EcosystemFormatter
Registry (deps-core::registry) is the trait every ecosystem’s registry client implements
for version lookup and search, type-erased behind Arc<dyn Registry> so deps-core’s generic
LSP-response code never needs to know the concrete registry type. Each ecosystem’s own registry
struct (CratesIoRegistry, NpmRegistry, …) additionally exposes the same operations as
concrete, unboxed inherent async methods, which ecosystem-internal code and conformance
tests call directly. The trait method of a given name is expected to delegate to its inherent
counterpart. The canonical method vocabulary (issue #834, enforced at compile time for 9 of 14
ecosystem crates via registry_conformance!) is:
| Method | Purpose |
|---|---|
get_versions | Fetch all versions. |
get_versions_with | Fetch all versions plus extra data (e.g. publish dates for freshness). |
get_latest_matching | Fetch the single version matching a requirement. |
search | Fetch search results for a query (package-name completion). |
register_alternate | Register an alternate/private registry source. |
package_url | Build the registry’s web-display URL for a package. |
with_base | Construct a client pointed at a non-default base URL (tests, alternate-registry hops). |
Four crates are documented exceptions rather than silent drift: deps-gitlab-ci has no
name+version registry concept at all (route-based); deps-deno’s DenoRegistry is a
scheme-dispatching facade over a JsrRegistry and an NpmRegistry, not one struct; deps-go
and deps-github-actions both lack search because neither ecosystem has a package-name search
concept a user would complete against.
EcosystemFormatter (deps-core::lsp_helpers::formatter) governs everything about how a
resolved package/version becomes LSP-response text, split into seven focused sub-traits an
ecosystem composes rather than one large interface:
| Sub-trait | Contract |
|---|---|
PackageNaming | Normalizes/validates a manifest-declared package name into a stable lookup key. |
PackageRendering | Formats a version into manifest-safe text edits and builds the registry package URL. |
RequirementResolution | Pure (no I/O) requirement parsing/matching and up-to-date status — the hot hover/diagnostic path. requirement_is_unresolved’s default delegates to requirement_is_placeholder, so an ecosystem with an unexpanded-placeholder syntax (Maven, Gradle, NuGet, Cargo, npm, …) overrides only the latter to distinguish “not yet decidable” from “decided outdated.” GitHub Actions and GitLab CI override requirement_is_unresolved directly instead, since their SHA/branch pins are undecidable-but-not-a-placeholder — a case the default doesn’t cover. |
DiagnosticMessages | Static, 'static wording for yanked/deprecated diagnostics and hover — display copy only, cacheable across a whole diagnostics pass. |
DiagnosticPolicy | Per-ecosystem opt-outs narrowing or disabling a diagnostic a shared pass would otherwise emit (e.g. npm disables the yanked-requirement diagnostic to avoid duplicating its package-deprecation diagnostic). |
SourcePolicy | Whether a DependencySource (registry/git/path) can be resolved, and whether it counts as public-registry content for vulnerability scanning and cache-key trust. |
OsvNaming | Bridges native package-name/version-string conventions to OSV.dev’s own naming when they diverge. |
LockFileProvider
An ecosystem that supports resolved (lock-file) versions implements LockFileProvider
(deps-core::lockfile) alongside Ecosystem, returned from Ecosystem::lockfile_provider()
(None for an ecosystem with no lock-file concept):
locate_lockfile(manifest_uri)— searches ancestor directories (bounded depth) for the lock file, returningNoneif it doesn’t exist or the workspace-root search fails.parse_lockfile(path)— reads and parses it intoResolvedPackages.
Every implementation shares read_lockfile_content/read_and_parse_lockfile, which bound the
read to MAX_LOCKFILE_BYTES (32 MiB — deliberately larger than the 10 MB manifest cap and the
small-config-file cap in mtime_cache, since a large npm monorepo package-lock.json can
legitimately exceed 8 MiB) via fs_probe::read_to_string_capped, and run the whole
stat-then-read-then-parse sequence on the blocking-thread pool (tokio::task::spawn_blocking)
rather than the calling tokio worker — every parse_lockfile call sits on the live LSP request
path, and a lock file is discovered by an unauthenticated ancestor walk over a possibly hostile
cloned repository, so nothing may assume it’s small or well-formed before reading it in full.
The deps-lsp document state machine
crates/deps-lsp/src/document/ implements one state machine per open document, independent of
which ecosystem it belongs to.
DocumentState holds the raw content, the parsed ParseResult, and every piece of fetched
data layered on top of it: cached_versions, vulnerabilities (OSV scan outcomes), outcomes
(per-dependency classification), licenses, and resolved_version_candidates. Each dependency’s
fetch progress is tracked by LoadingState: Idle (default) → Loading (fetch in flight) →
Loaded (data cached) or Failed (fetch failed, but a stale cached value may still be shown).
ServerState is the global, Arc-shared server state: every open document (keyed by URI),
the shared HttpCache, the lock-file cache, and background-task bookkeeping. Two independent
concurrency limits bound outbound registry traffic:
cache.max_concurrent_fetches(config, default 20) bounds fetches within one document.FETCH_PERMITS(4, hardcoded) bounds how many documents fetch concurrently at once — an axis the config knob doesn’t cover, shared by both the document-open and document-change paths so neither alone can fan out unbounded registry traffic across many simultaneously edited files.
Lifecycle (document/lifecycle.rs) provides unified didOpen/didChange/didClose
handlers built on the Ecosystem trait, eliminating what used to be per-ecosystem duplication.
A didChange is debounced by DID_CHANGE_DEBOUNCE (100ms) before triggering a re-fetch, so a
burst of keystrokes coalesces into one registry round-trip rather than one per keystroke.
ColdStartLimiter separately rate-limits disk-load cold starts (a document an editor restores
without an explicit didOpen, see cold_start.rate_limit_ms in
Configuration) per URI, to prevent a client that thrashes file loads from
overwhelming the server.
Reparse (document/reparse.rs) is the shared driver behind two distinct triggers that must
re-evaluate every currently open document, not just one: a watched config file change
(.npmrc, gradle.properties, …) and a live workspace/didChangeConfiguration settings
reload. Both reuse the same version-guarded, sequential-await machinery didChange uses, and
both debounce a burst of rapid-fire notifications — RECONFIGURE_DEBOUNCE (250ms) coalesces a
settings-file save’s several notifications into one reparse round, capped by MAX_DEBOUNCE_WAIT
(2s) so a continuously chattering client can never defer the reparse indefinitely while the new
policy has already taken effect elsewhere.
deps-core::policy_config::PolicyConfig::diff computes exactly which parts of a config reload
require a reparse at all (ReparseScope): most policy sections (diagnostic severities, license
policy) are read fresh on every diagnostics pull and need no reparse, but registries’s three
fields each scope a different set of ecosystems and do force one. This distinction is enforced
at compile time — diff’s destructuring of every section is exhaustive (no ..), so a field
added to any policy section without an explicit reparse-impact decision fails to compile
(rustc E0027), and a CI grep step asserts no .. ever creeps back in.
LSP handler dispatch and declared capabilities
crates/deps-lsp/src/handlers/ is a thin dispatch layer, one file per LSP capability
(hover.rs, completion.rs, diagnostics.rs, code_actions.rs, code_lens.rs,
inlay_hints.rs, document_link.rs) — each resolves the request’s Ecosystem via
EcosystemRegistry, then calls that ecosystem’s generate_* method against already-cached
DocumentState. Every handler method is non-blocking by design: heavy work (registry fetches)
is always spawned via tokio::spawn ahead of time and cached, never awaited inline inside a
handler — a large minified manifest is even parsed on the blocking-thread pool
(ecosystem::parse_manifest_blocking) rather than synchronously on the tokio worker handling
the request, so one slow parse can never stall every other in-flight LSP request sharing that
worker.
server.rs’s server_capabilities() declares exactly what the client should expect:
| Capability | Detail |
|---|---|
| Text sync | Full-document sync (TextDocumentSyncKind::FULL) |
| Completion | Trigger characters ", =, .; label details supported; no resolve step |
| Hover | Simple (always available) |
| Inlay hints | Enabled |
| Code actions | Refactor and QuickFix kinds |
| Code lens | Enabled, no resolve step |
| Document links | Enabled, no resolve step |
| Diagnostics | Pull model (textDocument/diagnostic), identifier "deps", no inter-file dependencies, no workspace-wide pull |
| Execute command | deps-lsp.updateAllOutdated, deps-lsp.pinAllToSha |
Caching architecture
HttpCache (deps-core::cache), shared server-lifetime by every ecosystem’s registry
client, wraps outbound registry requests with RFC 7232 conditional-request validation
(ETag/If-None-Match, Last-Modified/If-Modified-Since) so unchanged registry data is
served from a bounded in-memory cache instead of re-fetched. Two independent caps bound memory
use under a long-running session: MAX_CACHE_ENTRIES (1000 entries) and a 64 MiB total-bytes
budget across every cached response body — a single response can be as large as 32 MiB
(MAX_RESPONSE_BYTES), so the entry cap alone doesn’t bound worst-case memory. Eviction
(cache_policy::evict_expired_then_oldest) always tries TTL-expired entries first, falling back
to the single oldest entry by fetch time only if nothing has expired; a full cache evicts
CACHE_EVICTION_PERCENTAGE (10%) of its capacity at once rather than one entry at a time.
dependency_cap bounds how many dependencies one open document tracks at all:
MAX_DEPENDENCIES_PER_DOCUMENT (5000) — hardcoded, not a config option, mirroring the 10 MB
manifest-size cap’s “security limit, not a preference” reasoning. It exists specifically against
a pathological manifest (issue #796’s repro: a 6.92 MB package.json with 330,000 unique
dependencies drove peak RSS to 850 MB and would have fanned out roughly 480,000 registry
requests). When truncation happens, ParseResult::dependency_cap_info() reports Some((kept, total)) so deps-lsp can surface a truncation notice rather than silently dropping
dependencies.
pagination drives the paged-fetch loop shared by every registry with a per_page=100-style
REST API (GitHub tags for GitHub Actions/Swift, GitLab tags/releases): page 1 is always fetched
alone first (to learn whether more pages likely exist, via page_has_more), then any further
pages are fetched concurrently in batches rather than sequentially. Hitting the safety
ceiling before pagination naturally ends logs a warning naming the API and the resource being
paginated, so truncation is diagnosable rather than looking identical to “no more matches.”
OSV.dev vulnerability scanning
OsvClient (deps-core::osv) batches every open document’s dependency versions against
OSV.dev and resolves matching advisories, layered with its own semantic
cache (separate from HttpCache’s transport-level cache) on ServerState so every document
benefits from the same query/record cache across the whole session. A scan never surfaces an
error to the caller — every failure mode degrades to a Skipped outcome (QueryFailed or
Truncated) rather than an absent one, so a transient OSV outage never silently hides real
findings from a previous, still-valid scan.
A scan runs in two phases:
- Phase A (
OsvClient::scan) — the primary scan over every dependency’s current version, chunked into/v1/querybatchrequests (FR-009 chunk size) and, for any chunk OSV itself truncates, recovered via bounded, concurrent individual/v1/querycalls (MAX_TRUNCATED_REQUERY_BUDGET) rather than accepting a silently incomplete result. - Phase B (
OsvClient::check_candidates) — a follow-up check on the specific version(s) about to be recommended as an update (only for dependencies phase A already flagged), so a hover/code-action suggestion never recommends a version that’s itself vulnerable.
Both phases share resolution logic bounded by an overall wall-clock timeout, checked between
chunks rather than wrapping the whole scan in a single cancellation — so already-completed work
is never discarded on timeout, only whatever hadn’t started yet degrades to Skipped. A record’s
full detail (summary, fixed-in version, severity) is fetched via bounded-concurrency GET /v1/vulns/{id} calls, capped to MAX_ADVISORY_RECORDS (50) full records fetched per dependency —
the input DependencyVulnerabilities::recommended_fix/fix-target verification compute over, so a
fix recommendation is never computed from only a handful of the advisories OSV reported. Rendering
(hover, diagnostics, deps-cli) truncates that further to ADVISORY_DISPLAY_CAP (5) via
DependencyVulnerabilities::advisories_for_display, worst-severity-first, plus a trailing “+N more
advisories” entry — the fetch and render bounds are deliberately independent constants, not one
shared cap, so widening the render cap can never silently affect which fix version gets
recommended (or vice versa). Severity classification (osv::severity) checks, in
order: a confirmed-malicious MAL-* id/alias (always wins, regardless of any graded signal on
the same record); database_specific.severity; ecosystem_specific.severity; an allowlisted
informational value ("unmaintained" only — see
Informational Advisories);
else Unknown.
Supply-chain trust signal (deps.dev)
DepsDevClient (deps-core::deps_dev) assembles the hover Scorecard/provenance line (see
Supply-Chain Trust Signal)
from deps.dev API v3 via a two-call sequence: a version-level
call (provenance/attestation data, keyed by (base, ecosystem, name, version) to prevent a test
mock server’s response ever serving a real-API cache hit) and a project-level call (the
Scorecard, keyed separately since it’s a property of the linked repository, not any one
package version — several packages sharing one upstream project share one cached call). Each
call has its own short per-call timeout, deliberately shorter than the hover-side overall wait
budget, so one hung call can never by itself starve the other of any chance to return. A
successfully assembled signal (or a definitive 404, treated as an authoritative “no record,” not
a transient fault) caches for 1 hour, matching deps.dev’s own cache-control header; a network
error, timeout, 5xx, or malformed response caches for a much shorter error TTL so a transient
outage self-heals within a couple of minutes of hovering.
Network security: SSRF host classification
net_policy (deps-core::net_policy) is the shared gate every ecosystem with a
user/workspace-configurable registry host (Cargo custom registries, npm .npmrc, PyPI custom
indexes, GitLab self-hosted instances, NuGet feeds) routes through before fetching from it.
HostClass classifies a URL’s host from the URL string alone (no DNS resolution — see
below for why): Loopback, LinkLocal, CloudMetadata (the 169.254.169.254/
fd00:ec2::254/100.100.100.200 instance-metadata addresses — AWS/GCP/Azure and Alibaba Cloud
respectively — and provider-documented metadata hostnames), PrivateV4 (RFC 1918), Cgnat
(100.64.0.0/10), UniqueLocalV6 (fc00::/7), Unspecified (0.0.0.0/8, widened from just
the exact 0.0.0.0, and ::), InternalName (a .internal/.local/.home.arpa-suffixed or
single-label hostname), Reserved (an IETF special-purpose range that is neither globally
routable nor a plausible internal network: 192.0.0.0/24 IETF Protocol Assignments and
fec0::/10 deprecated IPv6 site-local), or Global (everything else). Classification also
unwraps IPv4-mapped (::ffff:a.b.c.d) and NAT64 addresses to their embedded IPv4 form first, so
a bypass can’t be written by re-encoding the same address in another form — both the well-known
prefix (64:ff9b::/96, RFC 6052) and the entire local-use prefix (64:ff9b:1::/48, RFC 8215,
every /96 subnet within it, not only the zero subnet).
WorkspaceRegistryAccess — the user-facing policy (registries.workspace_registries in
Configuration) — decides which classes a workspace-declared registry URL
(one found inside the opened workspace’s own manifest/config, never a user’s own
$CARGO_HOME-tier config) may resolve to: Off (block every workspace-declared index
outright — the only complete boundary), PublicOnly (default — allow only HostClass::Global,
blocking an IP literal in a metadata/RFC1918 range while still allowing a legitimate corporate
https://index.mycorp.dev, since a DNS name can’t be classified as internal without resolving
it), or All (allow Global plus the hosts and CIDR ranges in the process-wide
PrivateRegistryAllowlist, read only from the DEPS_LSP_PRIVATE_REGISTRY_HOSTS environment
variable — never from settings, so a repository cannot widen it; with an empty allowlist All
behaves like PublicOnly, and the never_a_registry classes stay blocked under every value).
One permits decision, over a level-plus-allowlist snapshot, serves parse time, redirect hops and
connect time. This string-based host classification is deliberately
DNS-resolution-free (an attacker-controlled hostname that merely resolves to a blocked range
isn’t caught by classify_host itself); the DNS-rebinding TOCTOU that would otherwise open is
closed separately, at actual connect time, by a BlockedAddrResolver wired into every HTTP
client, which classifies the address a hostname actually resolved to and fails closed on any
lookup error.
is_trusted_prefix additionally hardens HttpCache’s redirect-hop confinement: a registry
response redirecting to another URL is only followed when the target shares the origin (not
just a textually similar hostname — artifacts.corp.evil.com must never pass as a redirect
target for artifacts.corp) and lies at or under the original URL’s path at a proper
path-segment boundary.
Secret handling
Any credential that must never reach a log line or a panic message — GITHUB_TOKEN,
GITLAB_TOKEN — is wrapped in Redacted<T> (deps-core::secret): its Debug/Display output
is always ***, and its backing memory is zeroized on drop. Exposing the real value requires
calling expose_secret() explicitly at the one call site that needs it (building the HTTP
Authorization header), so a credential can never leak through an incidentally-derived
#[derive(Debug)] on a struct that embeds it. When a credential needs to participate in an
HttpCache cache key (so an authenticated response is never served back for an unauthenticated
or differently-authenticated request), auth_digest computes a salted hash of the origin and
secret — salted with a per-process random value, so the digest can’t be reconstructed offline
even if it were to appear in a log line.
Credential request headers (Authorization, PRIVATE-TOKEN) are built only through
HttpCache’s typed RequestHeader/CredentialHeader parameters, which mark the header value
sensitive, so it is redacted from the HTTP stack’s own debug output as well.
Release-freshness signal
freshness (deps-core::freshness) implements the release-cooldown window described in
Configuration and applied uniformly across every
ecosystem: PublishTime (a Copy Unix-epoch-seconds timestamp), is_within_cooldown (an
exclusive bound — a version published exactly cooldown_secs ago is no longer “recent”), and
format_relative_age (coarse bucketing into "X minutes/hours/days/weeks/months/years ago", no
calendar-aware date arithmetic needed since it operates on a plain duration).
Cross-ecosystem consistency is a first-class design rule
A feature implemented for one ecosystem but not shared through deps-core — instead of
reimplemented per-crate — is treated as a bug class in this project. Concretely: JSON
position/AST parsing (deps-core::json_ast), non-string dependency-value guards
(deps-core::json_helpers), file-size-capped reads (deps-core::fs_probe::read_to_string_capped
— the single TOCTOU-safe read path), and ancestor-config-search depth
(MAX_CONFIG_ANCESTOR_DEPTH) are all centralized in deps-core specifically because the same
fix was independently needed in two or more ecosystem crates at some point. When adding logic
that touches more than one ecosystem crate, check deps-core::json_ast, json_helpers,
fs_probe, pagination, git_ref, and lsp_helpers first for an existing shared helper before
writing ecosystem-local code.
Crate layout
Each ecosystem is implemented as a separate crate under crates/deps-{ecosystem}/ with the
following structure:
crates/deps-{ecosystem}/
├── Cargo.toml
└── src/
├── lib.rs # Re-exports and module declarations
├── ecosystem.rs # Ecosystem trait implementation
├── error.rs # Ecosystem-specific error types
├── formatter.rs # Version display formatting
├── lockfile.rs # Lock file parsing
├── parser.rs # Manifest file parsing with position tracking
├── registry.rs # Package registry API client
└── types.rs # Dependency, Version, and other types
The Adding a New Ecosystem chapters walk through building one of these crates from scratch, step by step.
Project structure
deps-lsp/
├── crates/
│ ├── deps-core/ # Shared traits, cache, generic handlers
│ ├── deps-cargo/ # Cargo.toml parser + crates.io registry
│ ├── deps-npm/ # package.json parser + npm registry
│ ├── deps-pypi/ # pyproject.toml/requirements.txt parser + PyPI registry
│ ├── deps-go/ # go.mod parser + proxy.golang.org
│ ├── deps-bundler/ # Gemfile parser + rubygems.org registry
│ ├── deps-dart/ # pubspec.yaml parser + pub.dev registry
│ ├── deps-maven/ # pom.xml parser + Maven Central registry
│ ├── deps-gradle/ # Gradle parser (Version Catalog, Kotlin/Groovy DSL)
│ ├── deps-swift/ # Package.swift parser + GitHub API registry
│ ├── deps-composer/ # composer.json parser + Packagist registry
│ ├── deps-nuget/ # .csproj/packages.config parser + NuGet V3 registry
│ ├── deps-deno/ # deno.json parser + JSR registry (npm: delegates to deps-npm)
│ ├── deps-github-actions/ # workflow YAML parser + GitHub tags API registry
│ ├── deps-gitlab-ci/ # .gitlab-ci.yml parser + GitLab tags/releases API registry
│ ├── deps-engine/ # Shared classification pipeline (see engine.md) used by deps-lsp/deps-cli
│ ├── deps-lsp/ # Main LSP server
│ ├── deps-cli/ # `deps-cli check` — CLI for CI/pre-commit/shell workflows
│ ├── github-action/ # Docker-based GitHub Action wrapping `deps-cli check --format sarif`
│ └── deps-zed/ # Zed extension (WASM, separate git submodule)
├── .config/ # nextest configuration
└── .github/ # CI/CD workflows
See deps-engine for why the classification pipeline is its own crate rather
than living inside deps-lsp, and deps-cli / GitHub Action for the
non-editor ways to run these checks.
Performance
deps-lsp is optimized for responsiveness — parallel per-dependency fetching, aggressive
caching, and non-blocking handlers keep the interactive paths fast even on a manifest with
hundreds of dependencies:
| Operation | Latency | Notes |
|---|---|---|
| Document open (50 deps) | ~150ms | Parallel registry fetching |
| Inlay hints | <100ms | Cached version lookups |
| Hover | <50ms | Pre-fetched metadata |
| Code actions | <50ms | No network calls |
| Code lens | <50ms | No network calls; in-memory only |
Lock file support provides instant resolved versions without network requests.
Run performance benchmarks with criterion:
cargo bench --workspace
View the HTML report at target/criterion/report/index.html.
Versioning policy
deps-core’s public trait signatures (Ecosystem, Dependency, ParseResult,
EcosystemFormatter) — and its public lsp_helpers / completion helper functions — are typed
directly against tower_lsp_server::ls_types types. tower-lsp-server is pinned pre-1.0, so a
tower-lsp-server minor bump (e.g. 0.23 → 0.24) is not an implementation detail deps-core can
absorb silently — it forces a breaking release of deps-core: a minor version bump while
deps-core itself remains pre-1.0, a major version bump once deps-core reaches 1.0.
If you implement Ecosystem outside this workspace, depend on the exact matching
tower-lsp-server version via deps_core::tower_lsp_server rather than adding your own separate
direct dependency on tower-lsp-server, to avoid it drifting out of sync with the version
deps-core was built against.
The deps-engine crate
deps-engine is the workspace’s composition root: the one place that wires all 14
deps-<ecosystem> crates into a running EcosystemRegistry, and the one place that decides
what a dependency’s status actually is — outdated, yanked, vulnerable, unsatisfiable,
deprecated, or fetch-failed. deps-lsp and deps-cli both depend on it instead of
reimplementing either piece themselves.
Why this crate exists
Cargo’s dependency graph forbids a cycle: deps-core cannot depend on any deps-<ecosystem>
crate, since all 14 already depend on deps-core. Something still has to know about all 14
crates at once to register them — before deps-engine existed, that something was
deps-lsp itself, which meant a second driving adapter (deps-cli, added to check
dependencies from the command line and in CI) would have had to either depend on the LSP
binary crate or duplicate its registration and classification code. Neither is acceptable:
depending on a binary crate is not idiomatic Rust, and a duplicated copy drifts.
deps-engine was carved out of deps-lsp (issues #1058/#1059) specifically to be the shared
answer. Both deps-lsp’s LSP handlers and deps-cli’s check command call into the exact
same functions here, so an editor’s inline diagnostics and a CI job’s SARIF findings for the
same manifest can never disagree — there is only one place either could compute a different
answer, and both call it.
Three modules
setup — the composition root
EcosystemRuntime
bundles the live-updatable settings a running instance threads into every ecosystem that
needs them: the registries.workspace_registries reachability policy, NuGet’s
registries.nuget_user_profile_sources flag, GitLab’s registries.gitlab_instance_host,
Swift’s registries.swift_keychain_credentials opt-in (EcosystemRuntime::keychain_credentials,
set with with_keychain_credentials; a change reparses open Swift documents, and a credential
that resolves after its request gave up triggers one more reparse), and a shared lock-file
memoization cache. register_ecosystems takes one EcosystemRuntime and
returns a fully populated deps_core::EcosystemRegistry — every feature-enabled
deps-<ecosystem> crate’s Ecosystem implementation constructed with its registry client and
formatter, ready to route manifests to. This is the ~110-line function (moved verbatim from
deps-lsp/src/lib.rs) that both deps-lsp’s initialize handler and deps-cli’s startup
path call.
classify — pure verdict logic
The classify module (submodules diff, fetch, license, osv, resolved) answers “what
is true about this dependency” from data already in hand: in-use-version/lockfile resolution,
which dependencies need an OSV scan and which OSV findings still apply after a fix, registry
fetch fan-out, and outcome-merging. It deliberately knows nothing about when to ask a
registry, how to report progress to a caller, or what to do if input changes mid-flight —
those are each driving adapter’s own orchestration concern. This split (moved from
deps-lsp’s document/ module, issue #1059) is what lets deps-cli reach identical verdicts
to deps-lsp without reimplementing any classification of its own; see
specs/062-cli-check-mode/architecture-decision.md
in the repository for the full design rationale.
progress — a driving-adapter-agnostic port
Fetch tasks report progress through this module’s types rather than calling an LSP-specific
$/progress notification or a CLI-specific stderr line directly — each adapter supplies its
own implementation of the port. deps-lsp reports through LSP work-done progress; deps-cli
reports differently (or not at all, in a non-interactive CI run).
How this fits the rest of the architecture
deps-engine sits between deps-core (the trait definitions) and the two driving adapters:
deps-core — Ecosystem trait, Registry trait, shared LSP-response generation
↑
deps-cargo, deps-npm, ... — 14 ecosystem crates, each implementing deps-core's traits
↑
deps-engine — registers all 14, classifies verdicts, reports progress
↑ ↑
deps-lsp deps-cli — driving adapters: LSP server, CLI
See Architecture Overview for the Ecosystem trait and registry routing
this crate wires up, and deps-cli for the other consumer of this shared
classification layer.
API Documentation
This book covers ecosystem behavior and how to extend deps-lsp. Generated Rust API
documentation (rustdoc) for every crate in the workspace is published on
docs.rs, since every crate is published to crates.io independently. There is
no single combined API-docs page — each crate has its own docs.rs entry, following the pattern
https://docs.rs/<crate-name>:
deps-lsp— the LSP binarydeps-core— shared abstractions (theEcosystemtrait, registry client contracts, OSV.dev/deps.dev clients, LSP response helpers)deps-cli— the CLIdeps-engine— the shared classification pipeline behind bothdeps-lspanddeps-cli- Each ecosystem crate:
https://docs.rs/deps-<ecosystem>, e.g.deps-cargo,deps-npm,deps-pypi— substitute the ecosystem name from the Ecosystem Reference for any of the 14 supported ecosystems
Use the API docs when you need exact type signatures, trait bounds, or module-level rustdoc; use this book when you need to understand ecosystem-level behavior or the reasoning behind a design decision.
Adding a New Ecosystem
This chapter walks through adding support for a new package ecosystem (e.g., a language or
build tool not yet covered) to deps-lsp, from an empty crate to a registered, tested
implementation. It assumes familiarity with the Architecture Overview —
in particular the Ecosystem trait, Registry, and EcosystemFormatter.
Read the steps in order; each builds on artifacts (types, error variants) created in an earlier one:
- Create the Crate
- Handle Errors
- Define Types
- Implement the Parser
- Implement the Registry Client
- Implement the Ecosystem Trait
- Implement the Lock File Provider
- Implement the Formatter
- Create
lib.rs - Register the Ecosystem
- Add Tests
Once implemented, work through the Checklist before opening a PR. See
Reference Implementations for existing crates to study, and
Templates for a scaffold to start from. Key API Contracts
documents conventions (no async_trait, position tracking, LockFileProvider signatures,
registry client method naming) that apply across every step above.
Step 1: Create the Crate
Create a new crate with workspace dependencies:
# crates/deps-{ecosystem}/Cargo.toml
[package]
name = "deps-{ecosystem}"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "{Ecosystem} support for deps-lsp"
publish = true
[lints]
workspace = true
[features]
# Gates this crate's own `tower-lsp-server` dependency and LSP-response-shaped code
# (issue #1083, spec 064) — NOT `default`, since Cargo does not allow a `workspace = true`
# dependency edge to turn off a default feature, which would make this inescapable for
# `deps-engine`. `deps-lsp` requests it (directly or via `deps-engine`'s own
# `lsp-responses` feature); `deps-cli` never does, keeping `tower-lsp-server` out of its
# dependency tree entirely. Forward it to `deps-core` too: `lsp-responses = ["dep:tower-lsp-server", "deps-core/lsp-responses"]`.
lsp-responses = ["dep:tower-lsp-server", "deps-core/lsp-responses"]
[dependencies]
deps-core = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tower-lsp-server = { workspace = true, optional = true }
tracing = { workspace = true }
url = { workspace = true }
[dev-dependencies]
deps-core = { workspace = true, features = ["test-util"] }
tokio-test = { workspace = true }
url = { workspace = true }
Note: every
#[cfg(feature = "lsp-responses")]-gated item in this crate (LSPCompletionItem/Range/Positionconstruction,generate_completions, etc.) needs this feature enabled to compile and to be exercised by tests — see thelsp-responsesfeature comment above and Step 6 for what it gates in practice.
No workspace-membership edit is needed: root Cargo.toml’s [workspace] members is the glob
crates/*, so a new crates/deps-{ecosystem} directory with a Cargo.toml is picked up
automatically. It only needs an explicit mention in root Cargo.toml if it should be
excluded from the workspace instead (like crates/deps-zed, a separate git submodule, or
crates/github-action, a plain Docker action with no Cargo.toml at all) — see that file’s
exclude list.
Step 2: Handle Errors
Construct deps_core::DepsError directly at call sites instead of using a local error wrapper. Use deps_core::Result<T> for function signatures:
#![allow(unused)]
fn main() {
use deps_core::{DepsError, InvalidPackageName};
/// Example: validation function — use `InvalidPackageName` for a malformed package/module
/// identifier, not `InvalidVersionReq` (reserved for a malformed version-requirement string).
fn validate_module_path(path: &str) -> Result<(), InvalidPackageName> {
if path.is_empty() {
return Err(InvalidPackageName::new("module path is empty"));
}
if path.contains("..") {
return Err(InvalidPackageName::new(format!("invalid module path: {path}")));
}
Ok(())
}
/// Example: parsing function
fn parse_manifest(content: &str, uri: &url::Url) -> deps_core::Result<ParseResult> {
// Parse logic...
// On error: return Err(DepsError::ParseError { ... })
// On success: return Ok(ParseResult { ... })
}
/// Example: registry function handling 404
const REGISTRY: &str = "example-registry";
fn fetch_versions(package: &str) -> deps_core::Result<Vec<Version>> {
let response = http_client.get(&url).send()
.map_err(|e| DepsError::CacheError(e.to_string()))?;
if response.status() == 404 {
return Err(DepsError::PackageNotFound {
package: package.into(),
registry: REGISTRY,
});
}
let data: Vec<Version> = response.json()
.map_err(|e| DepsError::ApiResponse {
package: package.into(),
registry: REGISTRY,
source: e,
})?;
Ok(data)
}
}
Step 3: Define Types
Create ecosystem-specific types in types.rs. Every public type must be prefixed with
<Ecosystem> (e.g. NpmDependency, NpmParseResult, NpmDependencySection — not bare
Dependency, ParseResult, DependencySection), matching the convention every ecosystem
crate now follows (deps-cargo was the sole historical exception, fixed in #760).
#![allow(unused)]
fn main() {
//! Types for {Ecosystem} dependency management.
use std::any::Any;
use tower_lsp_server::ls_types::Range;
pub use deps_core::parser::DependencySource;
/// A dependency from the manifest file.
#[derive(Debug, Clone)]
pub struct {Ecosystem}Dependency {
/// Package name
pub name: deps_core::PackageName,
/// LSP range of the name in source
pub name_range: Range,
/// Version requirement (e.g., "^1.0", ">=2.0")
pub version_req: Option<deps_core::VersionReq>,
/// LSP range of version in source
pub version_range: Option<Range>,
/// Dependency source (registry, git, path)
pub source: DependencySource,
/// Dependency section (dependencies, dev, etc.)
pub section: {Ecosystem}DependencySection,
}
/// Dependency section types.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum {Ecosystem}DependencySection {
Dependencies,
DevDependencies,
// Add ecosystem-specific sections
}
/// Version information from the registry.
#[derive(Debug, Clone)]
pub struct {Ecosystem}Version {
pub version: deps_core::ConcreteVersion,
pub yanked: bool,
// Add ecosystem-specific fields
}
// Implement deps_core traits
impl deps_core::Dependency for {Ecosystem}Dependency {
fn name(&self) -> &deps_core::PackageName {
&self.name
}
fn name_range(&self) -> Range {
self.name_range
}
fn version_requirement(&self) -> Option<&deps_core::VersionReq> {
self.version_req.as_ref()
}
fn version_range(&self) -> Option<Range> {
self.version_range
}
fn source(&self) -> DependencySource {
self.source
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
impl deps_core::Version for {Ecosystem}Version {
fn version_string(&self) -> &deps_core::ConcreteVersion {
&self.version
}
fn is_yanked(&self) -> bool {
self.yanked
}
fn is_prerelease(&self) -> bool {
// Implement based on ecosystem's prerelease conventions
let version = self.version.as_str();
version.contains('-') || version.contains("alpha") || version.contains("beta")
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
}
Step 4: Implement the Parser
Create manifest parser in parser.rs with position tracking:
#![allow(unused)]
fn main() {
//! {Manifest} parser with position tracking.
use crate::error::Result;
use crate::types::{Ecosystem}Dependency;
use std::any::Any;
use url::Url;
use deps_core::lsp_helpers::LineOffsetTable;
/// Parse result containing dependencies and metadata.
#[derive(Debug)]
pub struct {Ecosystem}ParseResult {
pub dependencies: Vec<{Ecosystem}Dependency>,
pub uri: Url,
}
impl deps_core::ParseResult for {Ecosystem}ParseResult {
fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
self.dependencies
.iter()
.map(|d| d as &dyn deps_core::Dependency)
.collect()
}
fn workspace_root(&self) -> Option<&std::path::Path> {
None // Override if ecosystem supports workspaces
}
fn uri(&self) -> &Url {
&self.uri
}
fn as_any(&self) -> &dyn Any {
self
}
}
/// Parse manifest file and extract dependencies with positions.
pub fn parse_{manifest}(content: &str, uri: &Url) -> Result<{Ecosystem}ParseResult> {
let line_table = LineOffsetTable::new(content);
// TODO: Implement actual parsing logic
// Key requirements:
// 1. Track byte offsets for every dependency name and version
// 2. Convert offsets to LSP Position using line_table.byte_offset_to_position(content, offset)
// 3. Handle all dependency sections
Ok({Ecosystem}ParseResult {
dependencies: vec![],
uri: uri.clone(),
})
}
}
Step 5: Implement the Registry Client
Create registry client in registry.rs:
#![allow(unused)]
fn main() {
//! {Registry} API client with HTTP caching.
use crate::types::{Ecosystem}Version;
use deps_core::{DepsError, HttpCache, Result, ecosystem::BoxFuture};
use std::any::Any;
use std::sync::Arc;
const REGISTRY_URL: &str = "https://registry.example.com";
/// {Registry} API client.
pub struct {Ecosystem}Registry {
cache: Arc<HttpCache>,
}
impl {Ecosystem}Registry {
pub fn new(cache: Arc<HttpCache>) -> Self {
Self { cache }
}
/// Fetches all versions for a package.
pub async fn get_versions(&self, name: &str) -> Result<Vec<{Ecosystem}Version>> {
let url = format!("{}/{}", REGISTRY_URL, urlencoding::encode(name));
let data = self.cache
.get_cached(&url)
.await
.map_err(|e| DepsError::CacheError(e.to_string()))?;
// TODO: Parse response and return versions
Ok(vec![])
}
/// Gets the latest version matching a requirement.
pub async fn get_latest_matching(
&self,
name: &str,
version_req: &str,
) -> Result<Option<{Ecosystem}Version>> {
let versions = self.get_versions(name).await?;
// TODO: Implement version matching logic
Ok(versions.into_iter().find(|v| !v.yanked))
}
}
// Implement deps_core::Registry trait using BoxFuture (no async_trait).
// The trait takes PackageName/VersionReq; the inherent methods above stay
// &str, so each forward converts with .as_str().
impl deps_core::Registry for {Ecosystem}Registry {
fn get_versions<'a>(
&'a self,
name: &'a deps_core::PackageName,
) -> BoxFuture<'a, deps_core::error::Result<Vec<Box<dyn deps_core::Version>>>> {
Box::pin(async move {
let versions = self.get_versions(name.as_str()).await?;
Ok(versions
.into_iter()
.map(|v| Box::new(v) as Box<dyn deps_core::Version>)
.collect())
})
}
fn get_latest_matching<'a>(
&'a self,
name: &'a deps_core::PackageName,
req: &'a deps_core::VersionReq,
) -> BoxFuture<'a, deps_core::error::Result<Option<Box<dyn deps_core::Version>>>> {
Box::pin(async move {
let version = self.get_latest_matching(name.as_str(), req.as_str()).await?;
Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
})
}
// Named `search_raw`, not `search`: the inherent `Registry::search` wraps this with a
// credential-bearing-query gate that a trait-level default could be silently overridden past.
fn search_raw<'a>(
&'a self,
_query: &'a str,
_limit: usize,
) -> BoxFuture<'a, deps_core::error::Result<Vec<Box<dyn deps_core::Metadata>>>> {
Box::pin(async move { Ok(vec![]) })
}
fn as_any(&self) -> &dyn Any {
self
}
}
}
Step 6: Implement the Ecosystem Trait
Note: everything below that touches
tower-lsp-servertypes (Range,CompletionItem, thegenerate_completions/complete_versionmethods) belongs behind this crate’slsp-responsesfeature (see Step 1) in the real implementation — e.g.#[cfg(feature = "lsp-responses")] use tower_lsp_server::ls_types::Range;. Omitted from the snippet below for readability; seecrates/deps-maven/src/ecosystem.rsfor the full#[cfg(feature = "lsp-responses")]-gated shape.
Create the main ecosystem implementation in ecosystem.rs:
#![allow(unused)]
fn main() {
//! {Ecosystem} implementation for deps-lsp.
use std::any::Any;
use std::sync::Arc;
use tower_lsp_server::ls_types::Range;
use url::Url;
use deps_core::{
Ecosystem, HttpCache, PackageName, ParseResult as ParseResultTrait, Registry, Result,
completion::{Completions, CompletionRequest, complete_package_names_generic, complete_versions_at_position},
ecosystem::BoxFuture,
lockfile::LockFileProvider,
lsp_helpers::EcosystemFormatter,
};
use crate::formatter::{Ecosystem}Formatter;
use crate::lockfile::{Ecosystem}LockfileParser;
use crate::parser::parse_{manifest};
use crate::registry::{Ecosystem}Registry;
/// {Ecosystem} ecosystem implementation.
pub struct {Ecosystem}Ecosystem {
registry: Arc<{Ecosystem}Registry>,
formatter: {Ecosystem}Formatter,
}
impl {Ecosystem}Ecosystem {
pub fn new(cache: Arc<HttpCache>) -> Self {
Self {
registry: Arc::new({Ecosystem}Registry::new(cache)),
formatter: {Ecosystem}Formatter,
}
}
}
// Required sealed trait impl — a documented contract (code review, not the compiler)
// restricting `Ecosystem` implementations to crates in this workspace; see
// `deps_core::ecosystem::private`'s doc for why Rust cannot enforce this any harder.
impl deps_core::ecosystem::private::Sealed for {Ecosystem}Ecosystem {}
impl Ecosystem for {Ecosystem}Ecosystem {
fn ecosystem_id(&self) -> deps_core::EcosystemId {
deps_core::EcosystemId::{Ecosystem}
}
fn display_name(&self) -> &'static str {
"{Ecosystem Name}"
}
fn manifest_filenames(&self) -> &[&'static str] {
&["{manifest_filename}"]
}
fn lockfile_filenames(&self) -> &[&'static str] {
&["{lockfile_filename}"]
}
fn parse_manifest<'a>(
&'a self,
content: &'a str,
uri: &'a Url,
) -> BoxFuture<'a, Result<Box<dyn ParseResultTrait>>> {
Box::pin(async move {
let result = parse_{manifest}(content, uri)?;
Ok(Box::new(result) as Box<dyn ParseResultTrait>)
})
}
fn registry(&self) -> Arc<dyn Registry> {
self.registry.clone() as Arc<dyn Registry>
}
fn lockfile_provider(&self) -> Option<Arc<dyn LockFileProvider>> {
Some(Arc::new({Ecosystem}LockfileParser))
}
fn formatter(&self) -> &dyn EcosystemFormatter {
&self.formatter
}
// generate_inlay_hints, generate_hover, generate_code_actions, generate_diagnostics,
// generate_completions all have default implementations in the Ecosystem trait that
// delegate to lsp_helpers / dispatch to the complete_* hooks below. Override only if
// custom behavior is needed (issue #793: generate_completions's default is the
// exhaustive match over CompletionContext — do NOT hand-write that match again in a
// new ecosystem crate; implement the three hooks it dispatches to instead).
// Required — every ecosystem serves version completion, and this is the one hook with
// no default. Resolved by cursor position, not by name (issue #593):
// complete_versions_at_position re-derives the dependency at `request.position` from
// `request.parse_result` and gates the lookup on that dependency's own
// `Dependency::source()` via the formatter's `SourcePolicy::can_resolve_source` — do NOT
// call a registry directly from `package_name`/`prefix` alone (issue #1136: doing so
// skips that gate and can leak a private/non-registry dependency's name to the public
// registry on every keystroke).
fn complete_version<'a>(
&'a self,
request: CompletionRequest<'a>,
_package_name: PackageName,
prefix: String,
) -> BoxFuture<'a, Completions> {
Box::pin(async move {
complete_versions_at_position(
self.registry.as_ref(),
&self.formatter,
request.parse_result,
request.position,
&prefix,
&[],
request.freshness,
)
.await
.into()
})
}
// Optional — default is `Completions::default()` (no package-name search), correct
// for an ecosystem with no package-name index. Override to search a registry:
//
// fn complete_package_name<'a>(
// &'a self,
// _request: CompletionRequest<'a>,
// prefix: String,
// range: Range,
// ) -> BoxFuture<'a, Completions> {
// Box::pin(async move {
// complete_package_names_generic(self.registry.as_ref(), &prefix, 20, range)
// .await
// .into()
// })
// }
//
// `is_incomplete` should stay `false` unless this ecosystem serves completions from a
// capped/unranked index (see PyPI's `complete_package_name` override, which returns
// `Completions::new(items).with_incomplete(true)`).
// Optional — default is `Completions::default()` (no feature-flag concept). Override
// only for a manifest format with a feature/flag array (e.g. Cargo, Go).
// Raw-text fallback completion (parse-failure path, issue #722): override
// `fallback_completion_prefix` only if this manifest format has a cheap raw-text
// section boundary to detect (most do — see e.g. `deps_core::fallback_completion`'s
// shared TOML/JSON/XML-tag scanners). Default `None` disables fallback completion
// for this ecosystem, which is correct if there is none (e.g. a manifest format
// with no delimited dependencies section).
//
// `completion_insert_text` is REQUIRED — no default — since a missing override
// would silently insert another ecosystem's manifest syntax (issue #118's failure
// mode). Called only from the raw-text fallback path above — the primary (parsed)
// completion path builds its own insert text via
// `build_package_completion`/`complete_package_names_generic` and never calls this.
fn completion_insert_text(&self, metadata: &dyn deps_core::Metadata) -> Option<String> {
Some(format!("\"{}\" = \"{}\"", metadata.name(), metadata.latest_version()))
}
fn as_any(&self) -> &dyn Any {
self
}
}
}
Step 7: Implement the Lock File Provider
Create lock file parser in lockfile.rs:
#![allow(unused)]
fn main() {
//! Lock file parsing for {Ecosystem}.
use std::path::{Path, PathBuf};
use deps_core::lockfile::{
LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource,
locate_lockfile_for_manifest,
};
use url::Url;
/// Lock file parser for {Ecosystem}.
pub struct {Ecosystem}LockfileParser;
impl LockFileProvider for {Ecosystem}LockfileParser {
fn locate_lockfile(&self, manifest_uri: &Url) -> Option<PathBuf> {
locate_lockfile_for_manifest(manifest_uri, &["{lockfile_name}"])
}
fn parse_lockfile<'a>(
&'a self,
lockfile_path: &'a Path,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = deps_core::error::Result<ResolvedPackages>> + Send + 'a>> {
Box::pin(async move {
let content = tokio::fs::read_to_string(lockfile_path)
.await
.map_err(deps_core::DepsError::Io)?;
parse_lock_content(&content)
})
}
}
fn parse_lock_content(content: &str) -> deps_core::error::Result<ResolvedPackages> {
let mut packages = ResolvedPackages::new();
// TODO: Parse lock file and call packages.insert(ResolvedPackage { ... })
Ok(packages)
}
}
Step 8: Implement the Formatter
Create the formatter in formatter.rs:
#![allow(unused)]
fn main() {
use deps_core::lsp_helpers::{
DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
RequirementResolution, SourcePolicy,
};
use deps_core::{ConcreteVersion, PackageName};
// `EcosystemFormatter` is a blanket impl over these seven concern traits — never write
// `impl EcosystemFormatter for {Ecosystem}Formatter` directly. Implement only the trait
// that owns the behavior you need to customize; an empty body relies entirely on that
// trait's own default.
pub struct {Ecosystem}Formatter;
impl PackageNaming for {Ecosystem}Formatter {
// Optional: lint manifest-declared names against this ecosystem's naming
// rules. Default is always `Ok(())` — only override to warn on names the
// ecosystem's own tooling would never accept (see `deps-npm`'s
// `NpmFormatter` for a full example). Never used as a construction gate:
// `PackageName::new` stays infallible regardless of this check.
fn validate_package_name(&self, _name: &str) -> Result<(), deps_core::InvalidPackageName> {
Ok(())
}
}
impl PackageRendering for {Ecosystem}Formatter {
fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
// Format version string for use in code action text edits
format!("\"{}\"", version)
}
fn package_url(&self, name: &PackageName) -> String {
// `encode` guards the hostile-input-safety check `formatter_conformance!` generates
// unconditionally for every invocation — an unencoded name fails that test.
format!(
"https://registry.example.com/packages/{}",
urlencoding::encode(name.as_str())
)
}
}
impl RequirementResolution for {Ecosystem}Formatter {}
impl DiagnosticMessages for {Ecosystem}Formatter {}
impl DiagnosticPolicy for {Ecosystem}Formatter {}
impl SourcePolicy for {Ecosystem}Formatter {}
impl OsvNaming for {Ecosystem}Formatter {}
}
Replace hand-written test_format_version/test_package_url tests with one
deps_core::formatter_conformance! invocation (see
templates/deps-ecosystem/src/formatter.rs.template or deps-github-actions/src/formatter.rs
for a full example).
Step 9: Create lib.rs
Expose public API in lib.rs:
#![allow(unused)]
fn main() {
//! {Ecosystem} support for deps-lsp.
pub mod ecosystem;
pub mod error;
pub mod formatter;
pub mod lockfile;
pub mod parser;
pub mod registry;
pub mod types;
pub use ecosystem::{Ecosystem}Ecosystem;
pub use parser::parse_{manifest};
pub use registry::{Ecosystem}Registry;
pub use types::{{Ecosystem}Dependency, {Ecosystem}Version};
}
Step 10: Register the Ecosystem
Note: this composition root lives in
crates/deps-engine/src/setup.rs, not indeps-lspitself — see The deps-engine crate for why.deps-lspanddeps-cliboth call the samedeps_engine::setup::register_ecosystemsfunction instead of each maintaining their own registration list, so a new ecosystem crate only needs to be wired in once for both adapters to pick it up.
In crates/deps-engine/src/setup.rs, add your ecosystem using the macros:
#![allow(unused)]
fn main() {
// 1. Add a re-export block using the ecosystem! macro
ecosystem!(
"{ecosystem_id}", // Feature flag name (crates/deps-engine/Cargo.toml and
// crates/deps-lsp/Cargo.toml/crates/deps-cli/Cargo.toml must
// all declare/forward it)
deps_{ecosystem}, // Crate name
{Ecosystem}Ecosystem, // Main ecosystem type
[
{Ecosystem}Dependency,
{Ecosystem}Version,
{Ecosystem}Registry,
// ... every other public type this crate exports that a caller of
// deps_engine might need, e.g. its ParseResult/Formatter/LockParser types
]
);
// 2. Add a registration line inside register_ecosystems()
pub fn register_ecosystems(
registry: &EcosystemRegistry,
cache: Arc<HttpCache>,
runtime: &EcosystemRuntime,
) -> Vec<&'static str> {
// ... existing #[cfg(feature = "...")] blocks for cargo/npm/pypi/go/... ...
// Add your ecosystem here. Most ecosystems use the plain register! macro:
#[cfg(feature = "{ecosystem_id}")]
register!("{ecosystem_id}", {Ecosystem}Ecosystem, registry, &cache);
// ... existing workspace_registry_ecosystems tracking, if your ecosystem consumes
// RegistryAccessPolicy (see the EcosystemRuntime note below) ...
}
}
The ecosystem!/register! macros handle feature-gating automatically — when the feature is
disabled, both the re-export and the registration are compiled out. Most ecosystems register
with the plain register!("{ecosystem_id}", {Ecosystem}Ecosystem, registry, &cache) call
shown above; a handful (Cargo, npm, Deno) are special-cased directly in
register_ecosystems’s body instead, because they need extra construction-time context
(EcosystemRuntime’s RegistryAccessPolicy, an npm/deno shared registry client) that
register!’s generic Ecosystem::new(cache) call can’t thread through — only follow one of
those special-cased patterns if your ecosystem genuinely needs live-updatable policy or a
shared client; otherwise the plain macro call is correct.
If your ecosystem needs to consult registries.workspace_registries (custom/private registry
host reachability — see Cargo), push its id
onto the Vec<&'static str> this function returns, and thread EcosystemRuntime’s policy
field into your parser the same way Cargo’s #[cfg(feature = "cargo")] block does.
Step 11: Add Tests
Create comprehensive tests co-located with each module:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
fn test_uri() -> url::Url {
// `deps_core::test_util::test_uri("/test/{manifest_file}")` builds the same thing
// (and handles the Windows `C:` prefix) — prefer it over hand-rolling this helper.
url::Url::parse("file:///test/{manifest_file}").unwrap()
}
#[test]
fn test_parse_simple_dependencies() {
let content = r#"..."#;
let result = parse_{manifest}(content, &test_uri()).unwrap();
assert!(!result.dependencies.is_empty());
}
#[test]
fn test_position_tracking() {
let content = r#"..."#;
let result = parse_{manifest}(content, &test_uri()).unwrap();
let dep = &result.dependencies[0];
// Verify positions are correct
assert!(dep.name_range.start.line > 0);
assert!(dep.version_range.is_some());
}
#[tokio::test]
async fn test_ecosystem_trait() {
let cache = Arc::new(HttpCache::new());
let ecosystem = {Ecosystem}Ecosystem::new(cache);
assert_eq!(ecosystem.id(), "{ecosystem_id}");
assert!(ecosystem.manifest_filenames().contains(&"{manifest_file}"));
}
}
}
Checklist
Before submitting a PR for a new ecosystem:
- Error types with conversions to
deps_core::DepsError - Every public type prefixed with
<Ecosystem>(NpmDependency, notDependency) — see Step 3 - Types implementing
DependencyandVersiontraits (withsource()method) - A new variant added to
deps_core::EcosystemId(crates/deps-core/src/ecosystem.rs’secosystem_ids!invocation) —Ecosystem::ecosystem_id()returns this variant, and every existing exhaustivematchonEcosystemIdacross the workspace must be updated to handle it - Parser with accurate position tracking for names AND versions
- Lock file parser implementing
LockFileProvidertrait (locate_lockfile+parse_lockfile) - Formatter implementing
PackageRendering(format_version_for_text_edit+package_url) plus the other sixEcosystemFormatterconcern traits — neverimpl EcosystemFormatterdirectly, it is a blanket impl - Registry client implementing
deps_core::Registrytrait with BoxFuture signatures - Ecosystem impl with
impl deps_core::ecosystem::private::Sealedblock (the workspace’s documented-contract sealing convention, not a compiler-enforced restriction) -
completion_insert_textimplemented (required, no default — issue #722); overridefallback_completion_prefixtoo if this manifest format has a raw-text dependencies-section boundary to detect, reusingdeps_core::fallback_completion’s shared TOML/JSON/XML-tag scanners where the syntax shape matches an existing one - Unit tests for parser edge cases
- Integration tests for registry (can be
#[ignore]) - Documentation in lib.rs with examples
- No workspace-members edit needed (root
Cargo.toml’smembersis thecrates/*glob — see Step 1); do add anlsp-responsesfeature forwarding todeps-core/lsp-responses, gating this crate’s owntower-lsp-serverdependency -
[lints] workspace = truein the new crate’s Cargo.toml (otherwise it silently gets none of theindexing_slicing/unwrap_used/expect_used/string_slicerestriction lints consolidated into[workspace.lints.clippy]by #689, and CI stays green) - Feature flag added in
crates/deps-engine/Cargo.toml, forwarded fromcrates/deps-lsp/Cargo.tomland (if the CLI should support it too)crates/deps-cli/Cargo.toml - Re-exports via
ecosystem!()macro incrates/deps-engine/src/setup.rs - Registration via
register!()macro insideregister_ecosystems()incrates/deps-engine/src/setup.rs— see Step 10
Reference Implementations
See existing implementations for reference:
crates/deps-cargo/- Rust/Cargo.toml with crates.io sparse indexcrates/deps-npm/- JavaScript/package.json with npm registrycrates/deps-pypi/- Python/pyproject.toml/poetry/requirements.txt with PyPI API and PEP 508 marker supportcrates/deps-go/- Go/go.mod with proxy.golang.orgcrates/deps-bundler/- Ruby/Gemfile with RubyGems APIcrates/deps-dart/- Dart/pubspec.yaml with pub.dev APIcrates/deps-maven/- Java/pom.xml with Maven Central (CDN metadata + Solr search)crates/deps-gradle/- Kotlin/Groovy with version catalogs and property resolutioncrates/deps-composer/- PHP/composer.json with Packagist V2 APIcrates/deps-swift/- Swift/Package.swift with GitHub API supportcrates/deps-nuget/- C#/.NET/.csproj/packages.config with NuGet V3 registry (SemVer2 prerelease, central package management)crates/deps-deno/- Deno/deno.json with the JSR API, delegatingnpm:specifiers todeps-npm’s registry client — the reference implementation for an ecosystem that dispatches across two registries from one manifestcrates/deps-github-actions/- GitHub Actions/.github/workflows/*.yml+action.ymlwith the GitHub tags API — the reference implementation for a CI/CD pinning ecosystem (no package registry,manifest_directory_patterns()instead of exact filenames, mutable-ref-vs-SHA pinning diagnostics) rather than a language package managercrates/deps-gitlab-ci/- GitLab CI/CD/.gitlab-ci.ymlwith the GitLab tags/releases API — the second CI/CD pinning reference implementation, plus YAML anchor/alias resolution and self-hosted-instance host configuration
Key API Contracts
No async_trait
All trait methods use BoxFuture instead of #[async_trait]:
#![allow(unused)]
fn main() {
// Correct
fn parse_manifest<'a>(
&'a self,
content: &'a str,
uri: &'a url::Url,
) -> deps_core::ecosystem::BoxFuture<'a, Result<Box<dyn ParseResult>>> {
Box::pin(async move { ... })
}
// Wrong — do not use
#[async_trait]
async fn parse_manifest(&self, content: &str, uri: &url::Url) -> Result<Box<dyn ParseResult>> { ... }
}
Note: manifest/lock-file URIs are plain
url::Url, nottower_lsp_server::ls_types::Uri—deps-lspconverts an LSPUrito aurl::Urlonce at the document boundary, so everyEcosystem/ParseResult/LockFileProvidermethod below works withurl::Urlthroughout.
Position Tracking
Use deps_core::lsp_helpers::LineOffsetTable for byte offset to LSP position conversion:
#![allow(unused)]
fn main() {
use deps_core::lsp_helpers::LineOffsetTable;
let table = LineOffsetTable::new(content);
let position = table.byte_offset_to_position(content, byte_offset);
}
LockFileProvider Signatures
#![allow(unused)]
fn main() {
impl LockFileProvider for MyLockParser {
fn locate_lockfile(&self, manifest_uri: &url::Url) -> Option<PathBuf> { ... }
fn parse_lockfile<'a>(&'a self, lockfile_path: &'a Path)
-> Pin<Box<dyn Future<Output = Result<ResolvedPackages>> + Send + 'a>> { ... }
}
}
Registry Client Method Naming (issue #834)
A registry crate’s own struct ({Ecosystem}Registry) exposes concrete, non-boxed inherent
methods alongside its deps_core::Registry trait impl — the trait’s own method of the same
name should delegate to the inherent one (see Step 5: Implement the Registry
Client). A new ecosystem crate must use this exact vocabulary for a new
operation; a different name for one of these same operations is the #760/#834 bug class:
| Operation | Canonical name |
|---|---|
| Fetch all versions | get_versions |
| Fetch all versions plus extra data (e.g. publish dates) | get_versions_with |
| Fetch the version matching a requirement | get_latest_matching |
| Search by query | search |
| Register an alternate/private registry source | register_alternate |
| Build the registry’s web-display URL for a package | package_url (re-export from lib.rs) |
| Construct a client pointed at a non-default base URL | with_base |
| Pure in-memory getter (no I/O) | no get_ prefix (Rust API Guidelines C-GETTER) |
Sanctioned, named exceptions (a deliberate #834 scope decision, not drift to fix):
gem_url(deps-bundler),crate_url(deps-cargo),jsr_package_url(deps-deno) — ecosystem-specific display-URL builder names are fine to keep; only the re-export consistency (everypackage_url-named builder living inlib.rs) was in scope.- The metadata-fetch return type stays per-ecosystem (
GemInfo/PackageInfo/ArtifactInfo/CrateInfo/DenoMetadata/GoMetadata, …) — only the method name converges onget_package_metadata; the six type names are unrelated data shapes with no shared field set, and unifying them is a separate, much larger design exercise this issue did not take on.
See deps_core::Registry’s own “Registry client API contract” doc section for the full
rationale, and deps_core::registry_conformance!’s ty: form to add a compile-time check
that your crate’s registry struct exposes the first four names as true inherent methods
(not merely reachable through a trait — see deps_core::conformance::NotInherent’s doc).
Wired into 9 of the 14 ecosystem crates directly (deps-maven’s check transitively covers
deps-gradle, which reuses deps-maven’s MavenCentralRegistry unchanged); deps-go/
deps-github-actions (no search endpoint to call at all — an inherent search would be a
fabricated stub existing only to pass this check) and deps-deno/deps-gitlab-ci (no
single struct the check’s assumptions fit) are exempt, with the exact reasoning in
deps_core::Registry’s own doc.
Templates
Use the templates in templates/deps-ecosystem/ as a starting point for new ecosystems.