Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7dc0751
feat(core): read Package.resolved v2 and v3
justin13888 Sep 1, 2026
8351c58
feat(core): add the Swift ecosystem
justin13888 Sep 1, 2026
859c95c
feat(fetch): scan an ecosystem that has no registry
justin13888 Sep 1, 2026
e844737
feat(fetch): let a caller decline an ecosystem that has no registry
justin13888 Sep 1, 2026
30912ab
feat(swift): report that currency is unknown for every Swift dependency
justin13888 Sep 1, 2026
4dbf399
fix(cli): never tell a Swift project it is already up to date
justin13888 Sep 1, 2026
cdb5c57
docs(fetch): say what a registry-less check does not emit
justin13888 Sep 1, 2026
9acf7c1
fix(core): stop a truncated JSON document crashing the scanner
justin13888 Sep 1, 2026
cc77be0
fix(swift): stop Package.resolved answering questions it cannot
justin13888 Sep 1, 2026
e35946c
fix(fetch): ask OSV every spelling of a name, and warn only about a r…
justin13888 Sep 1, 2026
d889a8a
fix(fetch): keep one project from adopting another's dependency list
justin13888 Sep 1, 2026
44d4494
docs(readme): say what a Swift check leaves unestablished
justin13888 Sep 1, 2026
27e199b
Merge remote-tracking branch 'origin/feat/84-pom-xml-parser' into fea…
justin13888 Sep 1, 2026
5358685
fix(core): strip a port from a Swift package host
justin13888 Sep 1, 2026
c0f8c86
fix(cli): let --no-lock-file suppress annotations, not the list itself
justin13888 Sep 1, 2026
06bcd6d
feat(report): say when a project's dependency list went unread
justin13888 Sep 1, 2026
24a264f
test(swift): cover a Package.resolved project end to end
justin13888 Sep 1, 2026
0ab4e6a
Merge remote-tracking branch 'origin/feat/84-pom-xml-parser' into fea…
justin13888 Sep 1, 2026
21b9132
fix(fetch): let --no-lock-file suppress annotations for check too
justin13888 Sep 1, 2026
1193ba3
fix(core): read an SCP-shorthand colon as a path separator, never a port
justin13888 Sep 1, 2026
f0f0209
fix(report): unwrap DEP003's strings and stop overloading its status key
justin13888 Sep 1, 2026
e060ef4
fix(report): carry an unread dependency list into the report itself
justin13888 Sep 1, 2026
58579a4
fix(core): a port is digits, so a non-numeric segment is not one
justin13888 Sep 1, 2026
5616ebc
fix(report): head an unread dependency list as unread, not as zero
justin13888 Sep 1, 2026
2c68e69
fix(cli): carry the unread and undetermined caveats into the GitHub s…
justin13888 Sep 6, 2026
27840d3
fix(cli): stop fix calling an unread project up to date
justin13888 Sep 6, 2026
e0e186b
chore(fetch): merge feat/84-pom-xml-parser into feat/85-swift-package…
justin13888 Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 43 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Or download a prebuilt binary for your platform from the
| C# / .NET | `*.csproj`, `Directory.Packages.props` | NuGet | — | 🧪 Experimental |
| Elixir | `mix.exs` | Hex | `mix.lock` | 🧪 Experimental |
| Kotlin / Java | `gradle/libs.versions.toml`, `pom.xml` | Maven Central | — | 🧪 Experimental |
| Swift | `Package.swift` (never read) | — none exists | `Package.resolved` | 🧪 Experimental |

Kotlin / Java coverage is the **declarative** half of a JVM build. For Gradle that is
the version catalog: a build script (`build.gradle`, `build.gradle.kts`) is a program,
Expand All @@ -54,6 +55,45 @@ is a resolution engine rather than a parser. Those dependencies are still listed
no version and nothing claimed about them, so a POM that inherits some of its versions
is never presented as depending on only the rest.

**Swift is vulnerability-only, and says so.** SwiftPM identifies a package by its git
URL and discovers versions by enumerating git tags. SE-0292 defines a registry API, but
no dominant public instance operates one — so there is nothing to ask "is a newer
version available?", and `dependable` never pretends otherwise. A Swift dependency is
reported `undetermined`: scanned against OSV's `SwiftURL` advisories, and **never**
compared for currency. Every Swift manifest carries a warning saying so, because a
Swift run that turns up no advisories otherwise reads exactly like a clean, up-to-date
one. `--fix` cannot apply to a Swift project.

`Package.swift` is executable Swift, and unlike `mix.exs` it cannot be read as text
honestly — dependencies are routinely assembled in loops, behind conditionals, and from
variables, so a text-level reader returns a *wrong* list rather than a short one. It is
never read. `Package.resolved` is plain JSON carrying the full flattened pin set, and it
is where every Swift dependency comes from: the one lockfile here that is the dependency
list rather than an annotation on one. SwiftPM records the *flattened* resolution there
and does not mark which pins are direct, so a Swift project lists its transitive
dependencies alongside its direct ones — which is more than every other ecosystem shows,
not less. Because the file cannot say which is which, no pin is reported as a direct
dependency: `list` marks every one `(indirect)`, and `--format json` gives it
`"direct": false`.

Being the dependency list rather than an annotation on one has two more consequences.
A `Package.resolved` counts only in the manifest's own directory — a nested package in
a monorepo never adopts the root's pins, which would report the root's dependencies,
and the root's advisories, against a package that has neither. And a `Package.swift`
with no readable `Package.resolved` beside it — a missing one (Apple advises library
packages not to commit theirs) or a malformed one — is reported as *unknown*, never as
zero dependencies: a warning names the cause, and `--fail-on any` exits non-zero,
because nothing was established about that project at all.

One limitation remains, and it is worth stating. OSV matches its `SwiftURL` keys
case-sensitively while a git forge does not, and real keys are mixed-case
(`github.com/weichsel/ZIPFoundation`). `dependable` lowercases the host, keeps the
repository path exactly as `Package.resolved` wrote it, and additionally queries the
all-lowercase spelling. That covers every direction but one: a `Package.resolved`
recording a lowercase spelling of a repository whose advisory is keyed under mixed case
matches nothing, and the package reports clean. Recovering the canonical casing needs
the forge, not the file.

### Lockfiles

A lockfile is what turns "the manifest allows `^19.0.0`" into "you are actually
Expand All @@ -70,6 +110,7 @@ dependencies.
| `composer.lock` | ✅ | ✅ |
| `mix.lock` | ✅ | ✅ |
| `pubspec.lock` | ✅ | ✕ — records versions but not which package required which |
| `Package.resolved` | ✅ | ✕ — records pins but not which package required which |

Not read: `yarn.lock`, `pnpm-lock.yaml`, `deno.lock`, `go.sum`, `uv.lock`,
`poetry.lock`, `Pipfile.lock`, `packages.lock.json`.
Expand All @@ -92,17 +133,14 @@ V2 reporting features and other deferred work are tracked as GitHub issues; see

### Not yet supported

Three languages come up often enough to answer here. Each is absent for a different
reason, and one of them is closer than it looks:
Two languages come up often enough to answer here. Each is absent for a different
reason:

- **Gradle build scripts and `pom.xml`** — the JVM's declarative half ships (see the
table above); the rest does not. A `build.gradle.kts` is a program, and its ground
truth needs `./gradlew dependencies` — a JVM daemon executing your build. `pom.xml` is
data and is readable in principle, but its versions are frequently `${properties}`
inherited through a parent chain, which is a resolution step of its own.
- **Swift** — SwiftPM has no canonical registry: packages are git URLs and versions are
git tags, and `Package.swift` is executable Swift. `Package.resolved` is readable, so
locked versions and vulnerability scanning are feasible, but "outdated" is not.
- **C / C++** — there is no canonical registry (vcpkg is a git repository of ports whose
versions are pinned by one baseline commit), vcpkg's `version-string` scheme is
unordered by design, and OSV publishes no advisory data for vcpkg or ConanCenter. This
Expand Down
97 changes: 93 additions & 4 deletions crates/dependable-core/src/ecosystem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ use serde::{Deserialize, Serialize};

/// A package ecosystem.
///
/// Every variant is wired end-to-end: a parser, a registry fetcher, and an OSV
/// mapping. Which languages that adds up to is a wider question than this enum —
/// Most variants are wired end-to-end: a parser, a registry fetcher, and an OSV
/// mapping. [`Ecosystem::has_registry`] names the exception — an ecosystem that
/// publishes no registry has an OSV mapping and nothing to compare a version
/// against. Which languages that adds up to is a wider question than this enum —
/// `deno.json` and `pnpm-workspace.yaml` are both [`Ecosystem::Npm`] — so the
/// **Supported languages** table in `README.md` is authoritative for status, and
/// `docs/ECOSYSTEM-CANDIDATES.md` records what a new variant has to clear.
Expand All @@ -21,6 +23,15 @@ pub enum Ecosystem {
CSharp,
Elixir,
Jvm,
/// Swift packages, identified by their git URL.
///
/// The one ecosystem here with no registry: SwiftPM discovers versions by
/// enumerating a repository's git tags, and while SE-0292 defines a registry
/// API, no dominant public instance operates one. [`Ecosystem::has_registry`]
/// is `false`, and a check reports currency as
/// [`Undetermined`](crate::result::DependencyStatus::Undetermined) rather than
/// guessing.
Swift,
}

impl Ecosystem {
Expand All @@ -37,9 +48,32 @@ impl Ecosystem {
Ecosystem::CSharp => "NuGet",
Ecosystem::Elixir => "Hex",
Ecosystem::Jvm => "Maven",
// OSV keys its Swift advisories by repository URL, not by a package
// name any registry issued — which is why the name we send is the URL
// with its scheme stripped (`dependable_core::swift_package_name`).
Ecosystem::Swift => "SwiftURL",
}
}

/// Whether this ecosystem publishes a registry a version can be compared
/// against.
///
/// `false` for exactly one ecosystem, [`Swift`](Self::Swift), and it is a fact
/// about the ecosystem rather than about this tool's configuration — which is
/// the whole reason it is a method here and not the absence of a fetcher. A
/// caller with no fetcher registered for an ecosystem cannot otherwise tell "the
/// user turned this off" from "there is nothing to turn on", and the two want
/// opposite behaviour: the first should skip the manifest, the second should
/// carry on and scan it for vulnerabilities.
///
/// A `false` here means [`default_registry`](Self::default_registry) is empty and
/// nothing will ever be fetched, so currency is unknowable rather than merely
/// unread.
#[must_use]
pub fn has_registry(self) -> bool {
!matches!(self, Ecosystem::Swift)
}

/// A human-readable name for display.
#[must_use]
pub fn display_name(self) -> &'static str {
Expand All @@ -53,10 +87,17 @@ impl Ecosystem {
Ecosystem::CSharp => "C#",
Ecosystem::Elixir => "Elixir",
Ecosystem::Jvm => "JVM",
Ecosystem::Swift => "Swift",
}
}

/// The default registry base URL for the ecosystem.
/// The default registry base URL for the ecosystem, or `""` for an ecosystem
/// that has none.
///
/// Empty is the honest answer for Swift and the only one: inventing a URL here
/// would hand a fetcher somewhere to send requests that cannot be answered.
/// [`has_registry`](Self::has_registry) is the predicate to branch on; this is
/// the value to configure a fetcher with once it says `true`.
#[must_use]
pub fn default_registry(self) -> &'static str {
match self {
Expand All @@ -69,6 +110,7 @@ impl Ecosystem {
Ecosystem::CSharp => "https://api.nuget.org",
Ecosystem::Elixir => "https://hex.pm",
Ecosystem::Jvm => "https://repo1.maven.org/maven2",
Ecosystem::Swift => "",
}
}

Expand Down Expand Up @@ -103,6 +145,11 @@ impl Ecosystem {
"https://central.sonatype.com/artifact/{}",
name.replace(':', "/")
),
// A Swift package name *is* its repository URL with the scheme taken
// off, so the page is that URL put back together. There is no registry
// page to link to instead, and inventing one would send the reader to a
// site that has never heard of this package.
Ecosystem::Swift => format!("https://{name}"),
}
}

Expand Down Expand Up @@ -130,6 +177,10 @@ impl Ecosystem {
),
// Packagist renders every version on the package page itself.
Ecosystem::Php => self.package_url(name),
// A Swift version is a git tag, and the tag's spelling is not derivable
// from the version: `2.65.0` and `v2.65.0` are both common, and a link
// to the wrong one 404s. The repository is what we can name truthfully.
Ecosystem::Swift => self.package_url(name),
}
}

Expand Down Expand Up @@ -160,7 +211,7 @@ mod tests {

/// Every variant, so a new ecosystem cannot be added without being given
/// its pages.
const ALL: [Ecosystem; 9] = [
const ALL: [Ecosystem; 10] = [
Ecosystem::Rust,
Ecosystem::Go,
Ecosystem::Npm,
Expand All @@ -170,8 +221,46 @@ mod tests {
Ecosystem::CSharp,
Ecosystem::Elixir,
Ecosystem::Jvm,
Ecosystem::Swift,
];

/// Exactly one ecosystem has no registry, and the rest must not drift into
/// claiming they have none — a `false` here routes a manifest past the
/// registry entirely.
#[test]
fn swift_is_the_only_ecosystem_without_a_registry() {
for ecosystem in ALL {
let expected = ecosystem != Ecosystem::Swift;
assert_eq!(ecosystem.has_registry(), expected, "{ecosystem:?}");
assert_eq!(
!ecosystem.default_registry().is_empty(),
expected,
"{ecosystem:?}: a registry URL and `has_registry` must agree"
);
}
}

/// The OSV ecosystem strings are what a query is keyed on; a wrong one matches
/// nothing and reports a vulnerable package as clean.
#[test]
fn swift_advisories_are_keyed_by_repository_url() {
assert_eq!(Ecosystem::Swift.osv_name(), "SwiftURL");
assert_eq!(
Ecosystem::Swift.package_url("github.com/vapor/vapor"),
"https://github.com/vapor/vapor"
);
// No per-version page: a git tag's spelling is not derivable from the
// version, so the repository is all that can be named truthfully.
assert_eq!(
Ecosystem::Swift.version_url("github.com/vapor/vapor", "4.92.1"),
Ecosystem::Swift.package_url("github.com/vapor/vapor")
);
assert_eq!(
Ecosystem::Swift.docs_url("github.com/vapor/vapor", "4.92.1"),
None
);
}

#[test]
fn every_ecosystem_can_name_a_page_for_a_package() {
for ecosystem in ALL {
Expand Down
7 changes: 5 additions & 2 deletions crates/dependable-core/src/item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,11 @@ pub enum DependencyKind {
/// Cargo's `[workspace.dependencies]`, pnpm catalogs, NuGet `PackageVersion`.
/// Members opt in by name, so the declaration alone means nothing is depended on.
Workspace,
/// A transitive dependency the manifest records explicitly (`go.mod`'s
/// `// indirect`). Not a direct dependency of the module.
/// A dependency that is not known to be a direct one: either recorded as
/// transitive (`go.mod`'s `// indirect`), or drawn from a flattened
/// resolution that marks direct and transitive pins alike (SwiftPM's
/// `Package.resolved`). Either way, calling it direct would be a claim the
/// file does not support.
Indirect,
}

Expand Down
12 changes: 7 additions & 5 deletions crates/dependable-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ pub use graph::{
};
pub use item::{DependencyKind, Item, PackageSource};
pub use lockfiles::{
LockedPackage, LockfileData, ResolvedLockfile, apply_lockfile, parse_bun_lock,
LockedPackage, LockfileData, ResolvedLockfile, apply_lockfile, lockfile_items, parse_bun_lock,
parse_bun_lock_graph, parse_cargo_lock, parse_cargo_lock_graph, parse_composer_lock,
parse_composer_lock_graph, parse_dart_pubspec_lock, parse_lockfile, parse_lockfile_kind,
parse_mix_lock, parse_mix_lock_graph, parse_package_lock, parse_package_lock_graph,
parse_swift_package_resolved, swift_package_name, swift_package_name_variants,
swift_package_resolved_items,
};
pub use manifest::{
AlternateRegistryDecl, LockfileKind, ManifestKind, ParsedManifest, UNREADABLE_MANIFESTS,
Expand All @@ -36,10 +38,10 @@ pub use npmrc::{NpmrcConfig, parse_npmrc};
pub use parsers::{
AutoTargets, CargoPackageManifest, CargoTarget, CargoTargetKind, CargoTomlParser,
CfgDependencyTable, ComposerJsonParser, CsprojParser, DenoJsonParser, DependencySection,
GoModParser, GradleCatalogParser, MixExsParser, PackageField, PackageJsonParser, Parser,
PnpmWorkspaceParser, PomXmlParser, ProjectMeta, ProjectRole, PubspecYamlParser,
PyprojectTomlParser, RequirementsTxtParser, WorkspaceDecl, parse, parse_cargo_config,
parse_package_manifest, parse_package_name, parse_project, parse_workspace,
GoModParser, GradleCatalogParser, MixExsParser, PackageField, PackageJsonParser,
PackageSwiftParser, Parser, PnpmWorkspaceParser, PomXmlParser, ProjectMeta, ProjectRole,
PubspecYamlParser, PyprojectTomlParser, RequirementsTxtParser, WorkspaceDecl, parse,
parse_cargo_config, parse_package_manifest, parse_package_name, parse_project, parse_workspace,
resolve_workspace_inheritance,
};
pub use result::{CheckResult, DependencyStatus};
Expand Down
34 changes: 34 additions & 0 deletions crates/dependable-core/src/lockfiles/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Lockfile parsers and per-kind dispatch.

use crate::error::ParseError;
use crate::item::Item;
use crate::manifest::{LockfileKind, ManifestKind};

pub mod bun_lock;
Expand All @@ -14,6 +15,7 @@ pub mod mix_lock;
pub mod mix_lock_graph;
pub mod package_lock_graph;
pub mod package_lock_json;
pub mod swift_package_resolved;

pub use bun_lock::parse_bun_lock;
pub use bun_lock_graph::parse_bun_lock_graph;
Expand All @@ -26,6 +28,10 @@ pub use mix_lock::parse_mix_lock;
pub use mix_lock_graph::parse_mix_lock_graph;
pub use package_lock_graph::parse_package_lock_graph;
pub use package_lock_json::parse_package_lock;
pub use swift_package_resolved::{
parse_swift_package_resolved, swift_package_name, swift_package_name_variants,
swift_package_resolved_items,
};

/// Parse lockfile `content` with the parser for the file that was found.
///
Expand All @@ -44,6 +50,34 @@ pub fn parse_lockfile_kind(kind: LockfileKind, content: &str) -> Result<Lockfile
LockfileKind::ComposerLock => parse_composer_lock(content),
LockfileKind::PubspecLock => parse_dart_pubspec_lock(content),
LockfileKind::MixLock => parse_mix_lock(content),
LockfileKind::PackageResolved => parse_swift_package_resolved(content),
}
}

/// The dependency list a lockfile *is*, for the formats that are the only record
/// of one.
///
/// [`apply_lockfile`] annotates items a manifest already produced and never
/// inserts, which is the right contract wherever the manifest is readable data.
/// Swift's is not — `Package.swift` is executable Swift — so `Package.resolved`
/// is the only honest source of the dependency list, and a caller has to be able
/// to take items *from* a lockfile rather than only apply one *to* them.
///
/// `None` for every other kind, whose contract is unchanged: ask
/// [`parse_lockfile_kind`] for their versions and apply them.
/// [`LockfileKind::is_dependency_source`] answers the same question without
/// parsing.
///
/// `None` **also** when the kind is a dependency source whose file did not read.
/// Both answers mean the same thing to a caller — "no dependency list came from
/// this file" — and both leave it to fall through to [`parse_lockfile_kind`],
/// which reports the failure. Handing back a prefix of a truncated
/// `Package.resolved` would instead present a short list as a complete one.
#[must_use]
pub fn lockfile_items(kind: LockfileKind, content: &str) -> Option<Vec<Item>> {
match kind {
LockfileKind::PackageResolved => swift_package_resolved_items(content),
_ => None,
}
}

Expand Down
Loading
Loading