Skip to content

Adding a New Ecosystem

Dependency discovery is 100% native Rust — there is no external scanner. Two pieces ship per ecosystem:

  1. A cataloger that reads the ecosystem’s lockfile / install tree / manifest and emits Dependency records (dependency discovery).
  2. A registry parser that reads the ecosystem’s registry config files and emits RegistryConfig records (registry-provenance validation).

Both are in-process Rust. The cataloger implements the Cataloger trait; the registry parser plugs into attach_ecosystem_registries().


flowchart TD
A[project_roots.rs finds project directory] --> B["catalogers.dispatch(root)\nnative catalogers → merged PackageMetadata"]
B --> D["attach_ecosystem_registries()\nRead .npmrc / NuGet.Config / pip.conf / etc."]
D --> E[vuln.rs enriches vulnerabilities\nvia OSV by PURL]
E --> F[policy.rs evaluates rules\nseverity, licenses, registries]
F --> G[Return PackageMetadata]

The cataloger you write plugs into step B; the registry parser plugs into step D. Everything downstream is handled by existing infrastructure.


A cataloger declares its tier via fn tier() -> CatalogerTier. Dispatch suppresses lower tiers when a higher one matches the same project root, so you can ship a Naked tier first and add a Lockfile tier later without coordination.

TierWhenEmit
LockfileA pinned lockfile exists (package-lock.json, Cargo.lock, composer.lock, Gemfile.lock, …). Most ecosystems land here.Resolution::Locked
InstallTreeOnlyNo lockfile but a populated install cache (~/.m2/repository/, ~/.gradle/caches/). Reserved tier — currently only Maven.Resolution::Locked / Resolution::Walked
NakedJust the manifest with version constraints (package.json without a lockfile, pom.xml, naked build.gradle).Resolution::Constraint

The ~20 shipped catalogers under app/core/src/discovery/catalogers/ are the reference patterns — pick the one with the closest format shape and copy its structure.


Replace <lang>/<pm> with your ecosystem / package manager (e.g., python / pip, rust / cargo).

Cataloger

  • Create app/core/src/discovery/catalogers/<ecosystem>/<pm>.rs implementing the Cataloger trait (name(), tier(), matches(root), parse(root, opts)).
  • Register it in default_registry() in app/core/src/discovery/catalogers.rs.
  • Add the ecosystem’s marker files to discovery::project_roots::PROJECT_MARKERS if not already listed.

Registry parser

  • Read the Registry Conventions page for the full spec of what config files exist at each level for your ecosystem.
  • Create app/core/src/languages/<lang>/registry.rs.
  • If app/core/src/languages/<lang>.rs doesn’t exist yet, create it with just pub mod registry;.
  • Wire pub mod <lang>; in app/core/src/languages.rs.
  • Implement the parser (see below).
  • Wire it into attach_ecosystem_registries() in app/core/src/lib.rs.

Tests

  • Add unit tests in #[cfg(test)] modules in both the cataloger and registry.rs.
  • Add a fixture directory under app/core/tests/fixtures/<lang>/.
  • Add an integration test in app/core/tests/integration.rs that asserts both the cataloger output AND the registry attachment.

File: app/core/src/languages/<lang>/registry.rs

Section titled “File: app/core/src/languages/<lang>/registry.rs”
use crate::languages::language_plugin::RegistryConfig;
use crate::utils::hash_secret;
use std::fs;
use std::path::Path;
/// Parse <lang> registry configuration from a project directory.
/// Returns one `RegistryConfig` per discovered registry URL.
pub fn parse_<lang>_registries(project_dir: &Path) -> Vec<RegistryConfig> {
let mut registries = Vec::new();
// 1. Project-level config
let config_path = project_dir.join("<config-file>");
if config_path.exists() {
// Parse it, push RegistryConfig entries...
}
// 2. User-level config
if let Some(home) = dirs::home_dir() {
let user_config = home.join("<user-level-path>");
if user_config.exists() {
// Parse it, push RegistryConfig entries...
}
}
registries
}

Every registry parser must:

  1. Return Vec<RegistryConfig> — one entry per registry URL found.
  2. Check both project and user levels (and machine level where it exists).
  3. Hash credentials via utils::hash_secret() before storing in password_hash. Never store plaintext.
  4. Short-circuit with an empty Vec when the config file is absent.
  5. Never panic — use Option/Result and return empty on failure.
  6. Set valid: true on every emitted record. The valid field is overwritten later by policy evaluation.

In app/core/src/lib.rs, find attach_ecosystem_registries and add your parser alongside the existing ones:

// ---- <Lang> (<config-file>) ----------------------------
registries.extend(languages::<lang>::registry::parse_<lang>_registries(
project_root,
));

The function is called for EVERY project root regardless of ecosystem, so your parser must short-circuit quickly when its config file is missing.


.NET is the one cataloger that wraps a legacy parser: the NuGet cataloger handles packages.lock.json first, then falls through to an in-process .csproj + packages.config parser when the lockfile is absent. New ecosystems should not model on this — write a primary cataloger at the Lockfile tier and (optionally) a Naked-tier fallback that emits Resolution::Constraint.


Several ecosystems share the same config file format. Reuse existing parsers where possible:

FormatCrateUsed by
INIline-by-line (custom)Node, Python, Java
TOMLtomlPython, Rust
YAMLserde_yamlNode (Berry), Dart, Ruby
XMLquick-xml.NET, Java (Maven)
netrcshared utilityGo, Swift, Python/uv
JSONserde_jsonPHP, Dart

The Node parser at app/core/src/languages/node/registry.rs is the most complete reference implementation. It handles:

  • Top-level registry=<url> (default)
  • @scope:registry=<url> (scoped)
  • //<host><path>/:_authToken=<token> (modern bearer auth)
  • <key>_auth=<base64> (legacy HTTP-basic)
  • .yarnrc.yml npmRegistryServer + npmScopes.<scope>.npmRegistryServer

Plus project, user, and XDG-level config sources, credential hashing, and comment handling.

Start there when implementing a new ecosystem’s parser.