Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

Note: API documentation generated from the Rust source (cargo doc) is published separately — see API Documentation.

Supported Ecosystems

EcosystemLanguageManifest File(s)Lock File(s)Highlights
CargoRustCargo.tomlCargo.lockHover, inlay hints, completion, code actions, diagnostics, code lens, feature flag completion, alternate/private registry resolution via .cargo/config.toml
npmJavaScript/TypeScriptpackage.jsonpackage-lock.json, pnpm-lock.yamlHover, 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
PyPIPythonpyproject.toml, requirements.txt, constraints.txt (also recognized under a requirements/ directory, e.g. requirements/base.txt)poetry.lock, uv.lockHover 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]
GoGogo.modgo.sumHover, inlay hints, completion, code actions, diagnostics, code lens, pseudo-version support, $GOENV GOPROXY/GOPRIVATE proxy-chain resolution
BundlerRubyGemfileGemfile.lockHover, 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)
DartDartpubspec.yamlpubspec.lockHover 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
MavenJavapom.xmlmaven-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)
GradleKotlin/Groovybuild.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)
ComposerPHPcomposer.jsoncomposer.lockHover, inlay hints, completion, code actions, diagnostics, code lens (requirement matching and “latest version” selection both use corrected stability-qualifier ordering)
SwiftSwiftPackage.swiftPackage.resolvedHover, 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.configpackages.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
DenoJavaScript/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 ActionsYAML.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/CDYAML.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 own initialization_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 }) if deps-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: eglot manages one server per buffer by default, so running deps-lsp alongside a primary language server for the same buffer (e.g. rust-analyzer on Cargo.toml) needs eglot’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

SectionOptionDefaultDescription
inlay_hintsenabledtrueShow inline version annotations next to each dependency
inlay_hintsup_to_date_text"✅"Text shown when the dependency is up to date
inlay_hintsneeds_update_text"❌ {}"Text shown when an update exists; {} is replaced with the latest version
cold_startenabledtrueLoad previously opened files from disk at startup so features work before the editor sends didOpen
cold_startrate_limit_ms100Minimum delay in milliseconds between cold-start registry fetches for the same URI
cacheenabledtrueWhether 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
cachefetch_timeout_secs5Per-package fetch timeout (1-300 seconds)
cachemax_concurrent_fetches20Concurrent registry requests (1-100)
loading_indicatorenabledtrueShow loading feedback during fetches
loading_indicatorfallback_to_hintstrueShow loading in inlay hints if LSP progress unsupported
loading_indicatorloading_text"..."Text shown during loading (max 100 chars)
code_lensenabledtrueShow 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
diagnosticsoutdated_severity"hint"Severity for the outdated-version diagnostic
diagnosticsunknown_severity"warning"Severity for an unresolvable/unknown package or version
diagnosticsyanked_severity"warning"Severity for the yanked-version diagnostic
diagnosticsunsatisfiable_severity"warning"Severity for the unsatisfiable-requirement diagnostic
diagnosticsdeprecated_severity"warning"Severity for the package-deprecation diagnostic
diagnosticsmutable_ref_pin_severity"hint"Severity for the mutable-ref-pin diagnostic (GitHub Actions/GitLab CI)
diagnosticssha_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)
diagnosticsunknown_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
diagnosticsmutable_ref_pin_enabledtrueTurns the mutable-ref-pin diagnostic and its bulk code lens off entirely — unlike the other diagnostics, severity alone cannot silence it
diagnosticsvulnerabilities_enabledtrueWhether OSV.dev-backed vulnerability diagnostics run at all
freshnessenabledtrueFlag a “latest” version still inside its cooldown window
freshnesscooldown_secs259200Cooldown window in seconds (3 days), clamped to 0-30 days
registriesworkspace_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.
registriesnuget_user_profile_sourcesfalseWhether 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
registriesswift_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
registriesgitlab_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
networkofflinefalseBlock every outbound registry/OSV/GitHub request; already-cached data still serves, uncached dependencies show an offline marker
supply_chainenabledtrueShow the OpenSSF Scorecard/build-provenance hover line, backed by deps.dev requests; false disables the requests and the section entirely
license_policyallow[]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_policydeny[]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
typosquatenabledfalseWhether the typosquat-similarity diagnostic runs at all — opt-in, backed by deps.dev’s v3alpha GetSimilarlyNamedPackages/GetDependents endpoints
gossipenabledfalseWhether 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.

SettingWhat a repository can do with it
registries.gitlab_instance_hostRedirect 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_registriesSet "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_sourcesAdd user-profile NuGet sources as routing hops; credentials stay bound to the URL declared in your own user-level NuGet.Config
registries.swift_keychain_credentialsTrigger a macOS Keychain access prompt; the credential goes only to registries declared in your user-level registries.json
diagnostics.vulnerabilities_enabled, network.offlineHide 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 /16 minimum, a unique-local (fc00::/7) range must be listed as a /48 or longer prefix (for example fd12:3456:789a::/48), not as fc00::/7.
  • A CIDR cannot name a host by its DNS name. A declared *.internal, *.local or 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, 0 and false mean 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, and diagnostics.yanked_severity are 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 as requirements.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_severity flags 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’s deprecated field and Composer’s abandoned field 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_severity flags a GitHub Actions or GitLab CI dependency pinned to a mutable ref (a tag, e.g. actions/checkout@v4, or a GitLab component: 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>). Set diagnostics.mutable_ref_pin_enabled to false to 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.offline blocks 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 via workspace/didChangeConfiguration takes effect immediately, with no editor restart. A change to diagnostics.* (for example vulnerabilities_enabled or a *_severity) republishes diagnostics for every open document, so clients that only receive publishDiagnostics (no workspace/diagnostic/refresh support) 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_policy diagnostics 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 a license: 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/deny take exact, case-insensitive SPDX identifiers only — no AND/OR/WITH expression parsing. See License Policy Diagnostic for matching rules and precedence.

Tip: Increase fetch_timeout_secs for 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-cli implements no classification logic of its own — every verdict comes from the same function deps-lsp calls for its LSP diagnostics, so a deps-cli check result 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-line Summary: 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_version is bumped, and the bump documented as Breaking in CHANGELOG.md, whenever a field is renamed, removed, or its wire type/nullability changes (e.g. a sentinel value like "" becoming null); 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 its https://osv.dev/vulnerability/{id} link — only sarif output does (see below). If your tooling needs the advisory id/URL for a vulnerability finding, parse sarif output instead of json.

  • 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 a helpUri to the advisory page, a fullDescription built from the finding’s own message, and a security-severity score 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 its shortDescription. Each result carries a partialFingerprints entry 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.id disambiguates 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 codeMeaning
0Clean — no finding matched the --fail-on policy
1Policy violation — at least one finding matched --fail-on
2Execution 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.

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 its registries, network, and diagnostics.*_enabled sections (and cache/freshness/license_policy/supply_chain) reset to their safe defaults before use; only the seven *_severity display values are kept (they’re cosmetic and can never suppress a --fail-on match). This is deliberate: the repository a CI job is checking is not a trusted source for the policy that judges it. A checked-in deps.toml on an attacker-controlled branch must not be able to disable the vulnerability scan, force network.offline to hide every registry/OSV-derived finding, or redirect GitLab host resolution via registries.gitlab_instance_host. (GITLAB_TOKEN is never sent to that host: it is bound to gitlab.com or the GITLAB_TOKEN_HOST environment variable, so an explicit --config does 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 the DEPS_LSP_PRIVATE_REGISTRY_HOSTS environment 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’s env:. Without the variable, "all" behaves like "public_only" and deps-cli prints a warning on stderr.

Note: registries.swift_keychain_credentials is not supported in deps-cli, which cannot answer a macOS Keychain access prompt: an explicit --config with 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 check would report outdated, rewriting its declared requirement to the latest matching version — unless that version was published within freshness.cooldown_secs of now (default: 3 days, Dependabot’s own default), in which case update targets 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/--config given 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 — otherwise update falls back to a full skip, reported as a skipped outcome whose reason names the freshness cooldown window (issue #1544). When the fallback candidate is itself OSV-flagged or unverified, update refuses to write it and exits non-zero naming that version, the same way an unsafe latest is refused — it never silently falls back to the plain cooldown skip. Disable this filter entirely with a --config file setting [freshness] enabled = false, or narrow the window with --cooldown (see below). This filter is deps-cli update-specific — check’s own Outdated diagnostic and deps-lsp’s “update to latest” code action still only annotate a fresh version’s age, never exclude it or substitute a fallback.

  • --security-only targets 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. update does 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 GitLab component: 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 JSON target field 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 applied even 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 --config also sets [freshness] enabled = false — deps-cli warns 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 codeMeaning
0Every 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)
1At 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
2Execution 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

InputDescriptionDefault
pathsSpace-separated paths to walkdeps-cli’s own default (repository root)
fail-onComma-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, othervulnerable,yanked,unsatisfiable
cooldownOverrides freshness.cooldown_secs for this run only (e.g. 3d)unset
configPath to a fully-trusted deps.toml — see the warning belowunset (falls back to deps-cli’s own hardened auto-discovery)

Warning: deps-cli treats an explicit --config path (which config here maps to) as fully trusted — unlike an auto-discovered deps.toml, whose registries/network/ diagnostics.*_enabled sections 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)). Passing config here 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 a pull_request_target workflow scanning a fork.

Outputs

OutputDescription
sarif-filePath 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-codedeps-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:

InputEnvironment variabledeps-cli flag
pathsDEPS_CLI_PATHSpositional paths
fail-onDEPS_CLI_FAIL_ON--fail-on
cooldownDEPS_CLI_COOLDOWN--cooldown
configDEPS_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.toml configuration 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 with deps-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:

IconMeaningShown when
✅Up to dateThe declared version already matches the latest available version.
❌ <version>Update availableA newer version exists; <version> is the latest one, substituted into the hint text.
⏳LoadingVersion/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.
📴OfflineNetwork 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.

ConventionExampleMeaning
**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.

EcosystemSourceRenders as
Dartpub.dev /score best-effort license detector tag, per-package (not per-version)**License (detected)**
SwiftGitHub’s licensee-detected license.spdx_id on the repository’s default branch (not the resolved version’s tag)**License (detected)**
GradleMaven 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**
DenoJSR’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 allow configured, 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-empty allow (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 vulnerability instead 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 INFORMATION severity (not WARNING) 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. via aliases) always takes precedence over an informational classification, even if the same record also carries database_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.

EcosystemYanked diagnosticRegistry signal
CargoYescrates.io sparse-index yanked
npmYesnpm deprecated
PyPIYesPEP 592 per-file yank status
BundlerYesRubyGems yanked
DartYespub.dev retracted
GoNomodule proxy reports no retraction data
MavenNoMaven Central has no retraction concept
GradleNodelegates to the same Maven Central registry as Maven
SwiftPartialSE-0292 registry releases carrying a problem are yanked; GitHub-tag dependencies have no yank signal
NuGetNounlisted versions are not distinguishable from listed ones today
ComposerNoPackagist’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
DenoYesJSR 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: the request package has 126/126 versions marked deprecated), Composer’s from abandoned — 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’s npm: 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’s jsr: specifiers are unrestricted (any requirement shape): JSR’s meta.json yanked flag 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:

EcosystemWorks today?Source
CargoYessparse index yanked field
npmNodeprecated exists but the check is unconditionally off (see restriction above)
PyPIYesPEP 592 yanked
ComposerYes, exact pins onlyabandoned (see restriction above)
DartYespub.dev retracted
BundlerNoRubyGems’ 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
GoNoGoVersion.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)
MavenNoMavenVersion::is_yanked is a hardcoded false constant — Maven Central does not support version retraction
GradleNoreuses Maven Central’s registry client, same hardcoded false
NuGetNoNuGetVersion::is_yanked is a hardcoded false constant
SwiftPartialSwiftVersion.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
Denojsr: yes, any requirement shape; npm: noJSR meta.json per-version yanked for jsr: specifiers; npm deprecated (unconditionally off, see restriction above) for npm: specifiers
GitHub ActionsNoGitHub’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 report Latest version <v> is flagged by OSV (<ids>) — do not upgrade, at Error severity for a malicious record or Warning otherwise (escalated above the configured outdated severity). Inlay hints show 🚫/⚠️ <v> flagged instead 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 report Newer version available: <v> (not yet verified against OSV) at the ordinary outdated severity — visually distinct from both Verified (no such caveat) and Flagged (no escalated severity), but treated identically to Flagged for 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 — latest or otherwise (issue #1524) — unless its own verdict is Verified or NotApplicable; a Flagged or Unverified item 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 a Flagged or Unverified latest into the manifest at all — the dependency is reported as Unplannable with reason LatestFlaggedByOsv or LatestUnverified rather than silently dropped or silently applied. There is currently no override flag: a dependency in this state cannot be updated to latest via deps-cli update until OSV affirmatively clears it. If any in-scope dependency’s latest comes back Unverified (rather than Flagged), 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 Flagged latest 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 one slsaProvenances[]/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”), or none found (no provenance data at all for this version). Labeled plainly as “Provenance”, not “SLSA provenance”: the two arrays are unioned, and an attestations[] 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 dependentCount is at least 50× the declared package’s own dependentCount (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 dependentCount is 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::Hint diagnostic (code typosquat-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-lsp has 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-lsp falls 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 GetFindings call 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-cli has 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 version field, if present, does not refer to something resolvable against the ecosystem’s package registry at all (e.g. this project’s own deps-core = { path = ..., version = "0.10.1" }, or Dart’s { sdk: flutter, version: "^3.24.0" }, which resolves against pub.dev’s unrelated package literally named flutter).
  • Suppressed for an unresolved requirement — a dangling Gradle version-catalog version.ref alias 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/@dev Composer 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.json omits 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" when foo tops out at 2.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: semver for Cargo/Swift, node-semver for npm, pep440_rs for 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.999 reads as “up to date” against a latest of 1.0.214 under the loose same-major-minor heuristic, despite patch 999 never 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.0 reads as fully unsatisfiable even when a 2.0.0-rc.1 has been published. The WARNING then appends a clause naming it:
    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)
    
    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.

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.

EcosystemWorks today?Source
npmYesdeprecated free-text message (no structured replacement — see above)
ComposerYes, with replace actionabandoned (bare true, or a string naming a successor package)
Cargo, Go, PyPI, Bundler, Dart, Maven, Gradle, Swift, NuGet, DenoNot yetNo 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.xml dependencies 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 from gradle.properties) or a libs.versions.toml version-catalog alias (version.ref = "spring") are skipped for the same reason.
  • Package.swift dependencies 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 on lowerBound > 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.patch release the registry indexes (a moving major-version ref like v4 itself is frequently in this category — GitHub’s tags API lists v4.3.1, not a synthetic v4 tag 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-deny could 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 — a uses: 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 ~latest or a partial-semver version (e.g. 1.2 for 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::Tag step is pinned; a registry-confirmed literal-named tag (the PinStyle::Branch case 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 TagIndex entry 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 fileCargo.toml
Lock file (in-use version)Cargo.lock (versions 3 and 4)
Registrycrates.io — sparse index (index.crates.io) for version lookups, REST API (crates.io/api/v1) for package-name search
Version syntaxCargo’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:

ValueBehavior
"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.toml does not take effect until the affected Cargo.toml is 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 filepackage.json
Lock file (in-use version)package-lock.json (versions 2 and 3) or pnpm-lock.yaml
Registrynpm registry — packument API (registry.npmjs.org/{package}) for versions, search API (registry.npmjs.org/-/v1/search) for package-name search
Version syntaxnode-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 .npmrc present in the workspace is still honored either way. pnpm’s own pnpm-workspace.yaml catalog 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 (an npm: alias whose version is itself a catalog reference) is not detected — the base npm: alias form (no catalog combination) is resolved, see npm: 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.lock and bun.lock are not read for in-use/resolved-version detection — only package-lock.json and pnpm-lock.yaml are supported lock file formats (tracked as follow-ups on issue #709).
  • pnpm-lock.yaml’s importers map is aggregated flatly across every workspace member into one shared version pool per package name, without correlating an importer entry back to the specific package.json being 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’s package.json can 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 any package.json under 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 like node_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 filespyproject.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
RegistryPyPI — PEP 691 Simple API JSON (pypi.org/simple/{package}/) for version lookups, JSON API (pypi.org/pypi/{package}/json) for hover metadata
Version syntaxPEP 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 implicit pypi.org hop is appended — --index-url replaces the default index (matching pip’s own semantics), so a file that wants pypi.org reachable 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.org fallback, 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 to pypi.org before 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-url and --extra-index-url and 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 against pypi.org directly, exactly as if the file declared nothing at all.
  • An explicit --index-url primary: an explicit primary replaces the default index rather than adding to it (FR-005(a)), so there is no implicit pypi.org hop to fall back to. If off blocks that primary, it fails closed (CustomRegistry, FR-006) and every dependency in the file loses version data — off does 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.ini and PIP_INDEX_URL/PIP_EXTRA_INDEX_URL environment variables are not read at all — a project relying solely on those (rather than in-file --index-url flags) sees no improvement from this feature.
  • -r/-c include propagation is not implemented: a file included via -r base.txt does not inherit the includer’s index declarations, and vice versa.
  • A [tool.uv.sources] binding is only recognized for the index = "<name>" shape — git =, path =, and workspace = true bindings 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.org fallback, its hover heading still omits the pypi.org project 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 — Django is offered (and inserted) as django, Zope.Interface as zope-interface. This matches what pip install and both poetry.lock/uv.lock already normalize to.
  • Matching is prefix-only against the package name, not a project’s import name or description — typing yaml will not surface pyyaml, sklearn will not surface scikit-learn, and bs4 will not surface beautifulsoup4. 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 filego.mod
Lock file (in-use version)go.sum (module content-checksum lines, skipping the /go.mod-suffixed checksum-only lines)
RegistryGo module proxy — proxy.golang.org by default (/{module}/@v/list, /{module}/@v/{version}.info, /{module}/@latest)
Version syntaxGo’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 $GOENV does not take effect until the affected go.mod is next reparsed (edited, or the document reopened) — there is no dedicated file watcher for it yet.
  • Live GOPROXY/GOPRIVATE/GONOSUMCHECK/GOFLAGS process environment variables (as opposed to the $GOENV file) are not read.
  • GOSUMDB/GONOSUMCHECK checksum-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 GOPROXY chain or a GOPRIVATE-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 fileGemfile
Lock file (in-use version)Gemfile.lock
RegistryRubyGems (rubygems.org/api/v1)
Version syntaxRubyGems’ 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 filepubspec.yaml
Lock file (in-use version)pubspec.lock
Registrypub.dev (pub.dev/api/packages/{name})
Version syntaxpub_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 (previously M2 > 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.integration selectors, 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_requirement returns false rather 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 always None and 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

TierPath
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.

TrustedWorkspace-declared
Reachabilityexempt from registries.workspace_registries (like Cargo’s $CARGO_HOME), except loopback, link-local, cloud-metadata, unspecified and reserved hosts, which are never fetchedgated by registries.workspace_registries
Redirectsconfined to the registry’s base URLconfined to the registry’s base URL
Credentialattachednever 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:

OutcomeKept
Item founduntil exit; disabling the setting drops it immediately and aborts a pending lookup
No item5 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, security returns 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 didChangeConfiguration payload without a registries section resets every registries setting, 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 security as 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-level registries.json, never to hosts the repository declares.
  • deps-cli does 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_DATA or ~/.netrc there.

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 publishedAt from 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-time tracing::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 .csproj files’ <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; its version="..." attribute is an exact pin (unlike a bare PackageReference Version’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/RegistrationsBaseUrl to a different host is stopped, matching the guarantee api.nuget.org itself 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.org never forces a source closed) is not a claim that querying api.nuget.org by 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 because deps-lsp already 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.Config does 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). Flipping registries.nuget_user_profile_sources via workspace/didChangeConfiguration, in contrast, re-parses already-open manifests immediately and purges an already-registered AlternateRegistry chain (issue #592) — only a direct NuGet.Config file 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.Config can 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.org package-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 real api.nuget.org source, identified by URL, never by a source’s key) lose the signals. Without a mapping, adding one internal feed suppresses these signals for every dependency in the project, including ones still resolving from api.nuget.org via the implicit fallback hop.
  • complete_package_names stays source-blind and always queries api.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 filedeno.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
Registryjsr: specifiers resolve against JSR (jsr.io/api.jsr.io); npm: specifiers resolve against the same npm registry client deps-npm uses
Version syntaxnode-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/.yaml extension), not an exact filename.
  • action.yml/action.yaml — a composite/reusable Action’s own metadata file, whose runs.steps can 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.patch or v\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 plain major.minor.patch core, or one with a recognized pre-release label such as rc or beta) cannot plausibly be a branch. It is unresolved instead of up to date at any position, and the unknown-ref diagnostic reports `4.3.1` is not a published tag of actions/checkout. Severity defaults to warning and is set with diagnostics.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-node20 and codecov/codecov-action@v5.x are documented branch pins. They keep the rule above (unresolved only when ahead of the latest release) and are never reported by unknown-ref, so a ref such as @v40 gets 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.2 for @v4) is the version that is checked, and hover shows it as Resolved. 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 only v4, or an unrelated v5.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.0 and v4.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), and deps-cli update refuses a latest it 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 introduced event and no fixed event is treated as open-ended unless the advisory’s database_specific.last_known_affected_version_range gives a parsable upper bound (< X or <= 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 null inside a merge chain (e.g. - {ref: v9, <<: *b} where *b is {<<: *a, ref: ~}) resolves to the entry’s earlier literal value instead of Psych’s nil, since this crate’s field representation cannot distinguish “key absent” from “key present but null” the way Ruby’s Hash can.
  • A scalar-anchor alias resolved through a merge (e.g. ref: *pin where *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 (severity diagnostics.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: tag ref: that is a full release no tag of the complete Tags list matches (ref: 1.117.0 beside tag v1.117.0) is unresolved instead of up to date and raises unknown-ref (severity diagnostics.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; a component: include is never reported either, since a version without a release is not a missing tag. An exact tag ref: that a truncated Tags list does not reach is unresolved as well, never up to date. A partial project: 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. The Change ref to published tag <tag> quickfix rewrites the ref: 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.yml that 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/partial component: 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 the ecosystem_ids! macro (which also derives EcosystemId::ALL, EcosystemId::id(), and its FromStr impl, 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 exhaustive match on EcosystemId across the workspace — including EcosystemId::osv_ecosystem()’s OSV.dev name mapping and deps-core::deps_dev’s deps.dev system mapping — to be updated at compile time.

  • ParseResult / Dependency — trait-object interfaces a parser returns; ecosystem-specific dependency types are exposed generically but remain downcastable via as_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 an INFORMATION-severity diagnostic instead.

  • Manifest routing is checked by EcosystemRegistry in a fixed four-stage order, each stage only consulted once the previous one misses:

    1. manifest_filenames() — exact basename match (Cargo.toml, package.json).
    2. manifest_patterns() — single-*-wildcard basename globs (requirements*.txt), case-sensitive.
    3. manifest_extensions() — file extension only, for basenames that vary (.csproj/.fsproj/.vbproj for NuGet’s MSBuild project files).
    4. manifest_directory_patterns() — (directory_path, suffix) pairs matched against the tail of the file’s directory path on segment boundaries (.github/workflows/*.yml for GitHub Actions, requirements/*.txt for PyPI’s split-file layout) — the only stage that needs the full path, so it’s reachable only from EcosystemRegistry::for_uri, never for_filename.

    A separate, single-purpose lookup, EcosystemRegistry::for_lockfile, matches an ecosystem’s lockfile_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_config does the same for watched_configs() (path-suffix WatchedConfig entries, each tagged routing-changing or requirement-rewriting) — non-lockfile config an ecosystem resolves during parsing (npm’s pnpm-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 parsed ParseResult rather 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 on deps_core::completion::complete_package_names_generic.
  • complete_version — usually built on deps_core::completion::complete_versions_at_position, which also threads through freshness.enabled so 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:

MethodPurpose
get_versionsFetch all versions.
get_versions_withFetch all versions plus extra data (e.g. publish dates for freshness).
get_latest_matchingFetch the single version matching a requirement.
searchFetch search results for a query (package-name completion).
register_alternateRegister an alternate/private registry source.
package_urlBuild the registry’s web-display URL for a package.
with_baseConstruct 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-traitContract
PackageNamingNormalizes/validates a manifest-declared package name into a stable lookup key.
PackageRenderingFormats a version into manifest-safe text edits and builds the registry package URL.
RequirementResolutionPure (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.
DiagnosticMessagesStatic, 'static wording for yanked/deprecated diagnostics and hover — display copy only, cacheable across a whole diagnostics pass.
DiagnosticPolicyPer-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).
SourcePolicyWhether a DependencySource (registry/git/path) can be resolved, and whether it counts as public-registry content for vulnerability scanning and cache-key trust.
OsvNamingBridges 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, returning None if it doesn’t exist or the workspace-root search fails.
  • parse_lockfile(path) — reads and parses it into ResolvedPackages.

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:

CapabilityDetail
Text syncFull-document sync (TextDocumentSyncKind::FULL)
CompletionTrigger characters ", =, .; label details supported; no resolve step
HoverSimple (always available)
Inlay hintsEnabled
Code actionsRefactor and QuickFix kinds
Code lensEnabled, no resolve step
Document linksEnabled, no resolve step
DiagnosticsPull model (textDocument/diagnostic), identifier "deps", no inter-file dependencies, no workspace-wide pull
Execute commanddeps-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:

  1. Phase A (OsvClient::scan) — the primary scan over every dependency’s current version, chunked into /v1/querybatch requests (FR-009 chunk size) and, for any chunk OSV itself truncates, recovered via bounded, concurrent individual /v1/query calls (MAX_TRUNCATED_REQUERY_BUDGET) rather than accepting a silently incomplete result.
  2. 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:

OperationLatencyNotes
Document open (50 deps)~150msParallel registry fetching
Inlay hints<100msCached version lookups
Hover<50msPre-fetched metadata
Code actions<50msNo network calls
Code lens<50msNo 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 binary
  • deps-core — shared abstractions (the Ecosystem trait, registry client contracts, OSV.dev/deps.dev clients, LSP response helpers)
  • deps-cli — the CLI
  • deps-engine — the shared classification pipeline behind both deps-lsp and deps-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:

  1. Create the Crate
  2. Handle Errors
  3. Define Types
  4. Implement the Parser
  5. Implement the Registry Client
  6. Implement the Ecosystem Trait
  7. Implement the Lock File Provider
  8. Implement the Formatter
  9. Create lib.rs
  10. Register the Ecosystem
  11. 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 (LSP CompletionItem/Range/Position construction, generate_completions, etc.) needs this feature enabled to compile and to be exercised by tests — see the lsp-responses feature 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-server types (Range, CompletionItem, the generate_completions/complete_version methods) belongs behind this crate’s lsp-responses feature (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; see crates/deps-maven/src/ecosystem.rs for 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 in deps-lsp itself — see The deps-engine crate for why. deps-lsp and deps-cli both call the same deps_engine::setup::register_ecosystems function 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, not Dependency) — see Step 3
  • Types implementing Dependency and Version traits (with source() method)
  • A new variant added to deps_core::EcosystemId (crates/deps-core/src/ecosystem.rs’s ecosystem_ids! invocation) — Ecosystem::ecosystem_id() returns this variant, and every existing exhaustive match on EcosystemId across the workspace must be updated to handle it
  • Parser with accurate position tracking for names AND versions
  • Lock file parser implementing LockFileProvider trait (locate_lockfile + parse_lockfile)
  • Formatter implementing PackageRendering (format_version_for_text_edit + package_url) plus the other six EcosystemFormatter concern traits — never impl EcosystemFormatter directly, it is a blanket impl
  • Registry client implementing deps_core::Registry trait with BoxFuture signatures
  • Ecosystem impl with impl deps_core::ecosystem::private::Sealed block (the workspace’s documented-contract sealing convention, not a compiler-enforced restriction)
  • completion_insert_text implemented (required, no default — issue #722); override fallback_completion_prefix too if this manifest format has a raw-text dependencies-section boundary to detect, reusing deps_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’s members is the crates/* glob — see Step 1); do add an lsp-responses feature forwarding to deps-core/lsp-responses, gating this crate’s own tower-lsp-server dependency
  • [lints] workspace = true in the new crate’s Cargo.toml (otherwise it silently gets none of the indexing_slicing/unwrap_used/expect_used/string_slice restriction lints consolidated into [workspace.lints.clippy] by #689, and CI stays green)
  • Feature flag added in crates/deps-engine/Cargo.toml, forwarded from crates/deps-lsp/Cargo.toml and (if the CLI should support it too) crates/deps-cli/Cargo.toml
  • Re-exports via ecosystem!() macro in crates/deps-engine/src/setup.rs
  • Registration via register!() macro inside register_ecosystems() in crates/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 index
  • crates/deps-npm/ - JavaScript/package.json with npm registry
  • crates/deps-pypi/ - Python/pyproject.toml/poetry/requirements.txt with PyPI API and PEP 508 marker support
  • crates/deps-go/ - Go/go.mod with proxy.golang.org
  • crates/deps-bundler/ - Ruby/Gemfile with RubyGems API
  • crates/deps-dart/ - Dart/pubspec.yaml with pub.dev API
  • crates/deps-maven/ - Java/pom.xml with Maven Central (CDN metadata + Solr search)
  • crates/deps-gradle/ - Kotlin/Groovy with version catalogs and property resolution
  • crates/deps-composer/ - PHP/composer.json with Packagist V2 API
  • crates/deps-swift/ - Swift/Package.swift with GitHub API support
  • crates/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, delegating npm: specifiers to deps-npm’s registry client — the reference implementation for an ecosystem that dispatches across two registries from one manifest
  • crates/deps-github-actions/ - GitHub Actions/.github/workflows/*.yml+action.yml with 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 manager
  • crates/deps-gitlab-ci/ - GitLab CI/CD/.gitlab-ci.yml with 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, not tower_lsp_server::ls_types::Uri — deps-lsp converts an LSP Uri to a url::Url once at the document boundary, so every Ecosystem/ParseResult/LockFileProvider method below works with url::Url throughout.

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:

OperationCanonical name
Fetch all versionsget_versions
Fetch all versions plus extra data (e.g. publish dates)get_versions_with
Fetch the version matching a requirementget_latest_matching
Search by querysearch
Register an alternate/private registry sourceregister_alternate
Build the registry’s web-display URL for a packagepackage_url (re-export from lib.rs)
Construct a client pointed at a non-default base URLwith_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 (every package_url-named builder living in lib.rs) was in scope.
  • The metadata-fetch return type stays per-ecosystem (GemInfo/PackageInfo/ ArtifactInfo/CrateInfo/DenoMetadata/GoMetadata, …) — only the method name converges on get_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.