Documentation
¶
Overview ¶
Package scanner reads dependency manifests (package.json, go.mod, Cargo.toml, Dockerfiles and compose files among twenty-odd others) into one ecosystem-neutral shape: the package's declared identity (name, version) and its declared dependencies with their ranges and manifest fields. It only reads; rewriting manifests is the writer package's job.
The scanner is deliberately lightweight: a handful of file-name probes and thin per-format parsers, no SBOM machinery. The recognised manifest names are fixed at build time; supporting a new ecosystem means adding a parser to this package.
Example ¶
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/yohimik/dispat/pkg/scanner"
)
func main() {
dir, _ := os.MkdirTemp("", "scanner-example-")
defer os.RemoveAll(dir)
manifest := []byte(`{
"name": "@acme/web",
"version": "1.2.0",
"dependencies": {"@acme/core": "workspace:*"},
"devDependencies": {"typescript": "^5.4.0"}
}`)
_ = os.WriteFile(filepath.Join(dir, "package.json"), manifest, 0o644)
mans, err := scanner.New().Scan(context.Background(), dir)
if err != nil {
fmt.Println("partial scan:", err)
}
for _, m := range mans {
fmt.Printf("%s %s@%s\n", m.Ecosystem, m.Name, m.Version)
for _, d := range m.Deps {
fmt.Printf(" %s %s %q\n", d.Kind, d.Name, d.Range)
}
}
}
Output: npm @acme/web@1.2.0 dependencies @acme/core "workspace:*" devDependencies typescript "^5.4.0"
Example (Android) ¶
Example_android reads an Android module's build script and the version catalog it resolves its dependency versions through. Both name libraries by Maven coordinate, so a catalog entry and a pom.xml dependency describe the same package.
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/yohimik/dispat/pkg/scanner"
)
func main() {
dir, _ := os.MkdirTemp("", "scanner-android-")
defer os.RemoveAll(dir)
_ = os.WriteFile(filepath.Join(dir, "build.gradle"), []byte(`android {
defaultConfig {
applicationId "com.acme.app"
versionCode 42
versionName "1.2.3"
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation project(':core')
testImplementation 'junit:junit:4.13.2'
}`), 0o644)
_ = os.MkdirAll(filepath.Join(dir, "gradle"), 0o755)
_ = os.WriteFile(filepath.Join(dir, "gradle", "libs.versions.toml"), []byte(`[versions]
retrofit = "2.9.0"
[libraries]
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }`), 0o644)
mans, err := scanner.New().Scan(context.Background(), dir)
if err != nil {
fmt.Println("partial scan:", err)
}
for _, m := range mans {
fmt.Printf("%s (%s)\n", m.Path, m.Ecosystem)
if m.Name != "" {
fmt.Printf(" %s version=%s build=%s\n", m.Name, m.Version, m.BuildNumber)
}
for _, d := range m.Deps {
fmt.Printf(" %s %s %q\n", d.Kind, d.Name, d.Range)
}
}
}
Output: build.gradle (gradle) com.acme.app version=1.2.3 build=42 dependencies androidx.core:core-ktx "1.12.0" dependencies core "" devDependencies junit:junit "4.13.2" gradle/libs.versions.toml (gradle) dependencies com.squareup.retrofit2:retrofit "2.9.0"
Example (IOS) ¶
Example_iOS reads an iOS application's manifests: the bundle metadata that carries its identity and version, and the Podfile that carries its dependencies.
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/yohimik/dispat/pkg/scanner"
)
func main() {
dir, _ := os.MkdirTemp("", "scanner-ios-")
defer os.RemoveAll(dir)
_ = os.WriteFile(filepath.Join(dir, "Info.plist"), []byte(`<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>com.acme.app</string>
<key>CFBundleShortVersionString</key>
<string>1.2.3</string>
<key>CFBundleVersion</key>
<string>42</string>
</dict>
</plist>`), 0o644)
_ = os.WriteFile(filepath.Join(dir, "Podfile"), []byte(`target 'Acme' do
pod 'Alamofire', '~> 5.6'
pod 'Core', :path => '../Core'
target 'AcmeTests' do
pod 'Quick', '~> 7.0'
end
end`), 0o644)
mans, err := scanner.New().Scan(context.Background(), dir)
if err != nil {
fmt.Println("partial scan:", err)
}
for _, m := range mans {
fmt.Printf("%s (%s)\n", m.Path, m.Ecosystem)
if m.Name != "" {
fmt.Printf(" %s version=%s build=%s\n", m.Name, m.Version, m.BuildNumber)
}
for _, d := range m.Deps {
fmt.Printf(" %s %s %q", d.Kind, d.Name, d.Range)
if d.LocalPath != "" {
fmt.Printf(" -> %s", d.LocalPath)
}
fmt.Println()
}
}
}
Output: Info.plist (plist) com.acme.app version=1.2.3 build=42 Podfile (cocoapods) dependencies Alamofire "~> 5.6" dependencies Core "" -> ../Core devDependencies Quick "~> 7.0"
Example (Ruby) ¶
Example_ruby reads a Ruby project's manifests: the gemspec that declares the library's own identity and the Gemfile that declares what an application installs. A gemspec assigning its version from a constant reports no version: the number lives in a Ruby source file this package does not evaluate.
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/yohimik/dispat/pkg/scanner"
)
func main() {
dir, _ := os.MkdirTemp("", "scanner-ruby-")
defer os.RemoveAll(dir)
_ = os.WriteFile(filepath.Join(dir, "Gemfile"), []byte(`source 'https://rubygems.org'
gem 'rails', '~> 7.0.4'
gem 'local', path: '../local'
group :development, :test do
gem 'rspec-rails', '~> 6.0'
end`), 0o644)
_ = os.WriteFile(filepath.Join(dir, "acme.gemspec"), []byte(`Gem::Specification.new do |spec|
spec.name = "acme"
spec.version = "1.2.3"
spec.add_dependency "rails", "~> 7.0"
spec.add_development_dependency "rspec", "~> 3.0"
end`), 0o644)
mans, err := scanner.New().Scan(context.Background(), dir)
if err != nil {
fmt.Println("partial scan:", err)
}
for _, m := range mans {
fmt.Printf("%s (%s)\n", m.Path, m.Ecosystem)
if m.Name != "" {
fmt.Printf(" %s@%s\n", m.Name, m.Version)
}
for _, d := range m.Deps {
fmt.Printf(" %s %s %q", d.Kind, d.Name, d.Range)
if d.LocalPath != "" {
fmt.Printf(" -> %s", d.LocalPath)
}
fmt.Println()
}
}
}
Output: Gemfile (rubygems) dependencies local "" -> ../local dependencies rails "~> 7.0.4" devDependencies rspec-rails "~> 6.0" acme.gemspec (rubygems) acme@1.2.3 dependencies rails "~> 7.0" devDependencies rspec "~> 3.0"
Index ¶
- Constants
- Variables
- func NameIndex(owners []Owner) (names map[string]string, ambiguous []string)
- func ResolveLocalDir(dirs map[string]string, pkgDir, manifestRel, local string) string
- func SkipDir(name string) bool
- func SkipWorkspaceDir(name string) bool
- type DeclaredDep
- type Ecosystem
- type Kind
- type Manifest
- type Owner
- type Scanner
Examples ¶
Constants ¶
const ( KindDependencies = manifest.KindDependencies KindDevDependencies = manifest.KindDevDependencies KindPeerDependencies = manifest.KindPeerDependencies KindOptionalDependencies = manifest.KindOptionalDependencies )
Dependency kinds, re-exported from pkg/manifest.
Variables ¶
var ErrManifestTooLarge = errors.New("scanner: manifest exceeds 16 MiB")
ErrManifestTooLarge marks a manifest skipped for exceeding the read cap; joined into the scan error like any parse failure.
Functions ¶
func NameIndex ¶
NameIndex maps every manifest name onto the package it belongs to, under one rule shared by every consumer of the mapping: a stated name binds before a declared one, and a root manifest before a nested one (a package's own identity beats a vendored or example manifest deeper inside another package). A name two packages claim at the same rank is ambiguous, returned in ambiguous (sorted) instead of mapped, because deriving relations from it would be guessing.
func ResolveLocalDir ¶
ResolveLocalDir maps a declared local path (an npm "file:" range, a go.mod relative replace, a Cargo `path` key) onto the package whose folder it points into: dirs indexes cleaned package folders by name, pkgDir is the consuming package's folder, manifestRel the declaring manifest's slash-relative path inside it. The lookup ascends from the exact target, so a path into a package's sub-folder still finds the package. Empty when the path leaves every known package.
func SkipDir ¶
SkipDir reports a folder name a workspace walk must not enter: the dependency trees, virtual environments and build output listed above, plus every dot-folder. It is exported so a caller walking a package folder for some other reason stays out of exactly the same places rather than keeping a second list that drifts from this one.
It is not the rule Scan follows; SkipWorkspaceDir is. The two differ by the engine output folders, which hold generated copies of real manifests but may still hold a file a caller means to read. A tool replacing literal text uses this one, because a version string under Build/ is still a version string.
func SkipWorkspaceDir ¶ added in v1.1.0
SkipWorkspaceDir reports a folder no search for manifests should enter: everything SkipDir names, plus the folders a game engine generates. It is the rule Scan follows.
Types ¶
type DeclaredDep ¶
type DeclaredDep struct {
// Name as the manifest declares it: "@acme/core", "github.com/acme/x",
// a crate name. For a renamed Cargo dependency this is the real package
// name (the `package` key), not the alias.
Name string
// Range is the declared version text, verbatim: "^1.2.0", "workspace:*",
// "v1.2.3". Empty when the manifest declares no version (e.g. a Cargo
// path-only dependency).
Range string
// Kind is the manifest field the declaration sits in.
Kind Kind
// LocalPath is the declared filesystem path when the dependency points into
// the same repository (an npm "file:"/"link:" range, a go.mod replace to a
// relative path, a Cargo `path` key) relative to the manifest's folder. Empty
// otherwise. It is the strongest workspace-edge signal: it survives name
// mismatches between folder and manifest.
LocalPath string
}
DeclaredDep is one dependency declaration inside a manifest.
type Ecosystem ¶
type Ecosystem string
Ecosystem names the package manager or platform family a manifest belongs to. Several formats can share one: every NuGet list is "nuget", and a Podfile and a podspec are both "cocoapods".
const ( EcosystemNpm Ecosystem = "npm" // package.json EcosystemGoMod Ecosystem = "gomod" // go.mod EcosystemCargo Ecosystem = "cargo" // Cargo.toml EcosystemPython Ecosystem = "python" // pyproject.toml (PEP 621 and Poetry) EcosystemComposer Ecosystem = "composer" // composer.json EcosystemMaven Ecosystem = "maven" // pom.xml EcosystemNuGet Ecosystem = "nuget" // *.csproj, *.nuspec, packages.config EcosystemPub Ecosystem = "pub" // pubspec.yaml EcosystemPlist Ecosystem = "plist" // Info.plist EcosystemCocoaPods Ecosystem = "cocoapods" // Podfile, *.podspec EcosystemXcode Ecosystem = "xcode" // project.pbxproj EcosystemAndroid Ecosystem = "android" // AndroidManifest.xml EcosystemGradle Ecosystem = "gradle" // libs.versions.toml, build.gradle(.kts) EcosystemRubyGems Ecosystem = "rubygems" // Gemfile, *.gemspec EcosystemDocker Ecosystem = "docker" // Dockerfile, compose.yaml // The game engines, each named after the engine rather than a package // manager, because that is what resolves their manifests. EcosystemUnity Ecosystem = "unity" // Packages/manifest.json, ProjectSettings.asset EcosystemGodot Ecosystem = "godot" // project.godot, plugin.cfg, export_presets.cfg EcosystemUnreal Ecosystem = "unreal" // *.uproject, *.uplugin, Config/Default*.ini EcosystemDefold Ecosystem = "defold" // game.project EcosystemO3DE Ecosystem = "o3de" // project.json, gem.json )
Ecosystems the built-in parsers recognise. The names are spelled after the manifest format rather than the platform that ships it: Info.plist is Apple bundle metadata on macOS and tvOS as much as on iOS, and a Gradle build script is not exclusively Android.
func EcosystemOf ¶
EcosystemOf reports the ecosystem a format's manifests belong to.
type Kind ¶
Kind is the manifest dependency field a declaration came from: the shared pkg/manifest vocabulary, aliased so the reader and the writer can never disagree on what a kind is called.
type Manifest ¶
type Manifest struct {
// Path of the manifest file relative to the scanned folder, using slashes.
Path string
// Ecosystem the manifest belongs to: one of the Ecosystem* constants.
Ecosystem Ecosystem
// Name is the package's declared name; empty when the ecosystem has no
// name field or the manifest omits it.
Name string
// Version is the package's declared own version; empty when absent
// (go.mod has none by design).
Version string
// BuildNumber is the monotonic build counter the mobile formats carry beside
// their marketing version, CFBundleVersion, android:versionCode,
// CURRENT_PROJECT_VERSION. It is not a semantic version, so no version
// write ever moves it; the writer's SetBuild is the dedicated write.
// Empty for every format without one.
BuildNumber string
// Deps are the manifest's declared dependencies, sorted by field then
// name for deterministic output.
Deps []DeclaredDep
// Indirect are the requirements the manifest records as transitive
// bookkeeping rather than as its own declarations, sorted the same way.
// Only go.mod has the distinction, and only its parser fills this; every
// other format leaves it nil.
//
// They are kept apart from Deps because they are not something the package
// asked for, so reconciling their ranges would rewrite a number the
// toolchain owns. What they are good for is redirection: only a main
// module's replace directives govern a Go build, so a module reached
// transitively still has to be pointed at a local folder from here. A
// requirement listed in Deps never appears here as well.
Indirect []DeclaredDep
// Dropped are the entries the manifest declared but the parser could not
// coerce into a dependency, one line each ("service db: not a mapping"),
// sorted for deterministic output. They are not errors: the manifest
// parsed, and the caller decides whether the drops are worth reporting.
// The shapes a format reads selectively by design (a Gradle line built by
// code, a platform-specific Poetry constraint list) are not dropped
// entries; those live in each reader's documented limits.
Dropped []string
// Root reports that the manifest sits directly in the scanned folder
// rather than in a sub-folder.
Root bool
}
Manifest is one parsed manifest file.
func (Manifest) AtPackageRoot ¶ added in v1.1.1
AtPackageRoot reports that the manifest is the scanned folder's own rather than one belonging to something nested inside it.
Root answers that for every format whose location its author chose. The path-qualified formats are the exception: their folder is part of the format's name, so a Unity project keeps its settings at ProjectSettings/ProjectSettings.asset and an Unreal project keeps its version under Config/ because the engine says so, not because somebody filed them away there. Such a manifest is nested and still the scanned folder's own. A copy deeper in the tree is not, and stays excluded.
type Owner ¶
type Owner struct {
Package string
// Names are the manifest names the package is known by regardless of what
// its files say. They exist for the packages whose manifests declare no
// name a workspace can learn: a Gradle module, a bare Makefile project, a
// folder whose manifest this package cannot parse. A stated name outranks
// a declared one, since it is the operator saying so.
Names []string
// Manifests are the package's parsed manifests.
Manifests []Manifest
}
Owner is one package's identity as NameIndex sees it: the names its configuration states outright, and the names its manifests declare.
type Scanner ¶
type Scanner interface {
// Scan returns every recognised manifest under dir in deterministic
// (path-sorted) order, descending into sub-folders but skipping
// dependency and build-output folders (node_modules, vendor, dist, ...,
// and every dot-folder).
Scan(ctx context.Context, dir string) ([]Manifest, error)
// ScanRoot parses only the manifests sitting directly in dir (the files that
// declare the folder's own identity) without descending anywhere.
ScanRoot(ctx context.Context, dir string) ([]Manifest, error)
}
Scanner turns a folder into its parsed manifests. Both methods share one error contract: a manifest that fails to parse is skipped, its error joined into the returned error, and the successfully parsed manifests are returned either way, so callers may report the error and keep the partial result. A folder Scan cannot read is stepped over on the same terms, so one unreadable sub-tree costs its own manifests and no others.
Source Files
¶
- androidmanifest.go
- cargo.go
- compose.go
- composer.go
- csproj.go
- defold.go
- dockerfile.go
- gemfile.go
- gemspec.go
- godot.go
- gomod.go
- gradlebuild.go
- gradlecatalog.go
- inicfg.go
- maven.go
- npm.go
- nugetlists.go
- nuspec.go
- o3de.go
- plist.go
- podfile.go
- podspec.go
- pubspec.go
- python.go
- requirements.go
- ruby.go
- scanner.go
- unity.go
- unreal.go
- unrealini.go
- xcodeproj.go