diff --git a/README.md b/README.md
index 66d142f..1fffecd 100644
--- a/README.md
+++ b/README.md
@@ -223,6 +223,29 @@ convert the value with knowledge of the source format.
When a dependency comes from a non-default registry, the PURL includes a `repository_url` qualifier (e.g., `pkg:npm/foo@1.0.0?repository_url=https://npm.mycompany.com/`). Default registries like registry.npmjs.org, pypi.org, and rubygems.org are not included in the PURL.
+### Declaration
+
+```go
+type Declaration struct {
+ Name string // Package name
+ Version string // Version requirement as written in the manifest
+ Scope Scope // runtime, development, test, build, optional
+ PURL string // Versionless Package URL
+ Location string // Opaque parser-defined identity within the manifest
+}
+```
+
+Declarations preserve source-level references without applying inheritance,
+merging, interpolation, or other effective-model resolution. Consumers can use
+`Location` to match the same logical entry across edits, but should not parse
+its ecosystem-specific value. A declaration PURL omits the version because the
+raw requirement may be a range or property expression.
+
+Parsers that do not preserve source locations leave `Declarations` empty.
+The `pom.xml` parser populates parents, dependencies, dependency management,
+plugins, plugin dependencies, plugin management, build extensions, and their
+profile-scoped forms.
+
### ParseResult
```go
@@ -234,6 +257,7 @@ type ParseResult struct {
Licenses []string // raw declared license values
LicenseFile string // manifest-relative path to a declared license file
Dependencies []Dependency
+ Declarations []Declaration
}
```
diff --git a/go.mod b/go.mod
index fe89f38..a25eba3 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
github.com/BurntSushi/toml v1.6.0
github.com/bazelbuild/buildtools v0.0.0-20260716142318-04cf7de1434f
github.com/bmatcuk/doublestar/v4 v4.10.0
- github.com/git-pkgs/pom v0.1.6
+ github.com/git-pkgs/pom v0.1.7
github.com/git-pkgs/purl v0.1.16
gopkg.in/yaml.v3 v3.0.1
)
diff --git a/go.sum b/go.sum
index 3de40c4..608475f 100644
--- a/go.sum
+++ b/go.sum
@@ -4,8 +4,8 @@ github.com/bazelbuild/buildtools v0.0.0-20260716142318-04cf7de1434f h1:2mT6QcXmM
github.com/bazelbuild/buildtools v0.0.0-20260716142318-04cf7de1434f/go.mod h1:PLNUetjLa77TCCziPsz0EI8a6CUxgC+1jgmWv0H25tg=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
-github.com/git-pkgs/pom v0.1.6 h1:OecrZgRChYQybf35YVF5yKfPIh6zsJ5gGgE3FKghJoc=
-github.com/git-pkgs/pom v0.1.6/go.mod h1:ufdMBe1lKzqOeP9IUb9NPZ458xKV8E8NvuyBMxOfwIk=
+github.com/git-pkgs/pom v0.1.7 h1:4yKdtw6eyShtjul6bcZdyz7yLQ+jdrYeYkKbskDGi4c=
+github.com/git-pkgs/pom v0.1.7/go.mod h1:ufdMBe1lKzqOeP9IUb9NPZ458xKV8E8NvuyBMxOfwIk=
github.com/git-pkgs/purl v0.1.16 h1:VAX6tv0hhdTENbkrGMoPZbOAl1Y8U1/ZnzoCsYuNBYM=
github.com/git-pkgs/purl v0.1.16/go.mod h1:7u7ora8tQdrkS7Auclr5v8dCJdjN4ej6AbrvYZi2b7k=
github.com/git-pkgs/vers v0.3.1 h1:jy/ht2wIRJI5zQrccm6GTeYr+hGFwe2z8LV1HOr4Wco=
diff --git a/internal/core/types.go b/internal/core/types.go
index 8a095c3..075ab79 100644
--- a/internal/core/types.go
+++ b/internal/core/types.go
@@ -35,6 +35,18 @@ type Dependency struct {
RegistryURL string
}
+// Declaration is a dependency-like reference at a stable logical location
+// in a manifest. Version is the requirement as written in that location,
+// before effective-model resolution or inheritance. Location is
+// ecosystem-specific and should be treated as an opaque identity.
+type Declaration struct {
+ Name string
+ Version string
+ Scope Scope
+ PURL string
+ Location string
+}
+
// Result is the output of a single parser.
type Result struct {
// Name is the package's own name as declared in the manifest, when
@@ -49,6 +61,7 @@ type Result struct {
// LicenseFile is a manifest-relative path to a declared license file.
LicenseFile string
Dependencies []Dependency
+ Declarations []Declaration
}
// Parser is the interface implemented by all manifest parsers.
diff --git a/internal/maven/declarations.go b/internal/maven/declarations.go
new file mode 100644
index 0000000..2d93578
--- /dev/null
+++ b/internal/maven/declarations.go
@@ -0,0 +1,145 @@
+package maven
+
+import (
+ "net/url"
+ "strings"
+
+ "github.com/git-pkgs/manifests/internal/core"
+ "github.com/git-pkgs/pom"
+)
+
+const defaultMavenPluginGroup = "org.apache.maven.plugins"
+
+func parsePOMDeclarations(project *pom.POM) []core.Declaration {
+ var declarations []core.Declaration
+ if project.Parent != nil {
+ parent := project.Parent
+ appendMavenDeclaration(
+ &declarations,
+ "parent",
+ parent.GroupID,
+ parent.ArtifactID,
+ parent.Version,
+ core.Build,
+ "",
+ )
+ }
+ collectPOMDependencies(&declarations, "dependencies", project.Dependencies)
+ collectPOMDependencies(&declarations, "dependencyManagement/dependencies", project.DependencyManagement.Dependencies)
+ collectMavenPlugins(&declarations, "build/plugins", project.Build.Plugins)
+ collectMavenPlugins(&declarations, "build/pluginManagement/plugins", project.Build.PluginManagement.Plugins)
+ collectMavenExtensions(&declarations, "build/extensions", project.Build.Extensions)
+
+ for _, profile := range project.Profiles {
+ profileID := strings.TrimSpace(profile.ID)
+ if profileID == "" {
+ profileID = "default"
+ }
+ prefix := "profiles/" + url.PathEscape(profileID)
+ collectPOMDependencies(&declarations, prefix+"/dependencies", profile.Dependencies)
+ collectPOMDependencies(&declarations, prefix+"/dependencyManagement/dependencies", profile.DependencyManagement.Dependencies)
+ collectMavenPlugins(&declarations, prefix+"/build/plugins", profile.Build.Plugins)
+ collectMavenPlugins(&declarations, prefix+"/build/pluginManagement/plugins", profile.Build.PluginManagement.Plugins)
+ collectMavenExtensions(&declarations, prefix+"/build/extensions", profile.Build.Extensions)
+ }
+
+ return declarations
+}
+
+func collectPOMDependencies(declarations *[]core.Declaration, location string, dependencies []pom.Dep) {
+ for _, dependency := range dependencies {
+ optional := strings.EqualFold(strings.TrimSpace(dependency.Optional), "true")
+ appendMavenDeclaration(
+ declarations,
+ location,
+ dependency.GroupID,
+ dependency.ArtifactID,
+ dependency.Version,
+ mapScope(dependency.Scope, optional),
+ "",
+ dependency.Type,
+ dependency.Classifier,
+ )
+ }
+}
+
+func collectMavenPlugins(declarations *[]core.Declaration, location string, plugins []pom.Plugin) {
+ for _, plugin := range plugins {
+ pluginName := appendMavenDeclaration(
+ declarations,
+ location,
+ plugin.GroupID,
+ plugin.ArtifactID,
+ plugin.Version,
+ core.Build,
+ defaultMavenPluginGroup,
+ )
+ if pluginName == "" {
+ continue
+ }
+ dependencyLocation := location + "/" + url.PathEscape(pluginName) + "/dependencies"
+ for _, dependency := range plugin.Dependencies {
+ appendMavenDeclaration(
+ declarations,
+ dependencyLocation,
+ dependency.GroupID,
+ dependency.ArtifactID,
+ dependency.Version,
+ core.Build,
+ "",
+ )
+ }
+ }
+}
+
+func collectMavenExtensions(declarations *[]core.Declaration, location string, extensions []pom.Extension) {
+ for _, extension := range extensions {
+ appendMavenDeclaration(
+ declarations,
+ location,
+ extension.GroupID,
+ extension.ArtifactID,
+ extension.Version,
+ core.Build,
+ "",
+ )
+ }
+}
+
+func appendMavenDeclaration(
+ declarations *[]core.Declaration,
+ location string,
+ groupID string,
+ artifactID string,
+ version string,
+ scope core.Scope,
+ defaultGroup string,
+ qualifiers ...string,
+) string {
+ groupID = strings.TrimSpace(groupID)
+ artifactID = strings.TrimSpace(artifactID)
+ if artifactID == "" {
+ return ""
+ }
+ if groupID == "" {
+ groupID = defaultGroup
+ }
+ if groupID == "" {
+ return ""
+ }
+
+ name := groupID + ":" + artifactID
+ key := name
+ for _, qualifier := range qualifiers {
+ if qualifier = strings.TrimSpace(qualifier); qualifier != "" {
+ key += ":" + qualifier
+ }
+ }
+ *declarations = append(*declarations, core.Declaration{
+ Name: name,
+ Version: strings.TrimSpace(version),
+ Scope: scope,
+ Location: location + "/" + url.PathEscape(key),
+ })
+ return name
+}
diff --git a/internal/maven/declarations_test.go b/internal/maven/declarations_test.go
new file mode 100644
index 0000000..b793e52
--- /dev/null
+++ b/internal/maven/declarations_test.go
@@ -0,0 +1,243 @@
+package maven
+
+import (
+ "testing"
+
+ "github.com/git-pkgs/manifests/internal/core"
+ "github.com/git-pkgs/pom"
+)
+
+func TestPOMDeclarations(t *testing.T) {
+ content := []byte(`
+ 4.0.0
+
+ org.example
+ parent
+ 1.0.0
+
+ org.example
+ app
+ 1.0.0
+
+
+ org.example
+ runtime
+ 2.0.0
+
+
+ org.example
+ managed
+
+
+
+
+
+ org.example
+ managed
+ 3.0.0
+
+
+
+
+
+
+ maven-compiler-plugin
+ 4.0.0
+
+
+ org.example
+ plugin-runtime
+ 5.0.0
+
+
+
+
+
+
+
+ org.example
+ managed-plugin
+ 6.0.0
+
+
+ org.example
+ managed-plugin-runtime
+ 6.1.0
+
+
+
+
+
+
+
+ org.example
+ extension
+ 7.0.0
+
+
+
+
+
+ release
+
+
+ org.example
+ profile-runtime
+ 8.0.0
+ test
+
+
+
+
+
+ org.example
+ profile-managed
+ 9.0.0
+
+
+
+
+
+
+ org.example
+ profile-plugin
+ 10.0.0
+
+
+ org.example
+ profile-plugin-runtime
+ 10.1.0
+
+
+
+
+
+
+
+ org.example
+ profile-managed-plugin
+ 10.2.0
+
+
+ org.example
+ profile-managed-plugin-runtime
+ 10.3.0
+
+
+
+
+
+
+
+ org.example
+ profile-extension
+ 11.0.0
+
+
+
+
+
+`)
+
+ result, err := (&pomXMLParser{}).Parse("pom.xml", content)
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ if len(result.Dependencies) != 2 {
+ t.Fatalf("Dependencies has %d entries, want 2: %+v", len(result.Dependencies), result.Dependencies)
+ }
+
+ want := map[string]struct {
+ name string
+ version string
+ scope core.Scope
+ }{
+ "parent/org.example:parent": {"org.example:parent", "1.0.0", core.Build},
+ "dependencies/org.example:runtime": {"org.example:runtime", "2.0.0", core.Runtime},
+ "dependencies/org.example:managed": {"org.example:managed", "", core.Runtime},
+ "dependencyManagement/dependencies/org.example:managed": {"org.example:managed", "3.0.0", core.Runtime},
+ "build/plugins/org.apache.maven.plugins:maven-compiler-plugin": {"org.apache.maven.plugins:maven-compiler-plugin", "4.0.0", core.Build},
+ "build/plugins/org.apache.maven.plugins:maven-compiler-plugin/dependencies/org.example:plugin-runtime": {"org.example:plugin-runtime", "5.0.0", core.Build},
+ "build/pluginManagement/plugins/org.example:managed-plugin": {"org.example:managed-plugin", "6.0.0", core.Build},
+ "build/pluginManagement/plugins/org.example:managed-plugin/dependencies/org.example:managed-plugin-runtime": {"org.example:managed-plugin-runtime", "6.1.0", core.Build},
+ "build/extensions/org.example:extension": {"org.example:extension", "7.0.0", core.Build},
+ "profiles/release/dependencies/org.example:profile-runtime": {"org.example:profile-runtime", "8.0.0", core.Test},
+ "profiles/release/dependencyManagement/dependencies/org.example:profile-managed": {"org.example:profile-managed", "9.0.0", core.Runtime},
+ "profiles/release/build/plugins/org.example:profile-plugin": {"org.example:profile-plugin", "10.0.0", core.Build},
+ "profiles/release/build/plugins/org.example:profile-plugin/dependencies/org.example:profile-plugin-runtime": {"org.example:profile-plugin-runtime", "10.1.0", core.Build},
+ "profiles/release/build/pluginManagement/plugins/org.example:profile-managed-plugin": {"org.example:profile-managed-plugin", "10.2.0", core.Build},
+ "profiles/release/build/pluginManagement/plugins/org.example:profile-managed-plugin/dependencies/org.example:profile-managed-plugin-runtime": {"org.example:profile-managed-plugin-runtime", "10.3.0", core.Build},
+ "profiles/release/build/extensions/org.example:profile-extension": {"org.example:profile-extension", "11.0.0", core.Build},
+ }
+
+ if len(result.Declarations) != len(want) {
+ t.Fatalf("Declarations has %d entries, want %d: %+v", len(result.Declarations), len(want), result.Declarations)
+ }
+ for _, declaration := range result.Declarations {
+ expected, ok := want[declaration.Location]
+ if !ok {
+ t.Errorf("unexpected declaration at %q: %+v", declaration.Location, declaration)
+ continue
+ }
+ if declaration.Name != expected.name || declaration.Version != expected.version || declaration.Scope != expected.scope {
+ t.Errorf("declaration at %q = %+v, want name %q, version %q, scope %q", declaration.Location, declaration, expected.name, expected.version, expected.scope)
+ }
+ }
+}
+
+func TestPOMDeclarationProfileDefaultsToDefaultID(t *testing.T) {
+ content := []byte(`
+ org.exampleprofile-runtime1.0.0
+`)
+ project, err := pom.ParsePOM(content)
+ if err != nil {
+ t.Fatalf("ParsePOM: %v", err)
+ }
+ declarations := parsePOMDeclarations(project)
+ if len(declarations) != 1 {
+ t.Fatalf("declarations has %d entries, want 1: %+v", len(declarations), declarations)
+ }
+ if declarations[0].Location != "profiles/default/dependencies/org.example:profile-runtime" {
+ t.Errorf("Location = %q, want default profile location", declarations[0].Location)
+ }
+}
+
+func TestPOMDeclarationLocationIncludesTypeAndClassifier(t *testing.T) {
+ content := []byte(`
+ org.examplelib1.0.0
+ org.examplelib1.0.0test-jar
+ org.examplelib1.0.0sources
+`)
+ project, err := pom.ParsePOM(content)
+ if err != nil {
+ t.Fatalf("ParsePOM: %v", err)
+ }
+ declarations := parsePOMDeclarations(project)
+ want := map[string]bool{
+ "dependencyManagement/dependencies/org.example:lib": true,
+ "dependencyManagement/dependencies/org.example:lib:test-jar": true,
+ "dependencyManagement/dependencies/org.example:lib:sources": true,
+ }
+ if len(declarations) != len(want) {
+ t.Fatalf("declarations has %d entries, want %d: %+v", len(declarations), len(want), declarations)
+ }
+ for _, declaration := range declarations {
+ if !want[declaration.Location] {
+ t.Errorf("unexpected declaration location %q", declaration.Location)
+ }
+ if declaration.Name != "org.example:lib" {
+ t.Errorf("Name = %q, want org.example:lib", declaration.Name)
+ }
+ }
+}
+
+func TestAppendMavenDeclarationSkipsIncompleteCoordinates(t *testing.T) {
+ var declarations []core.Declaration
+ appendMavenDeclaration(&declarations, "dependencies", "org.example", "", "1.0.0", core.Runtime, "")
+ appendMavenDeclaration(&declarations, "dependencies", "", "dependency", "1.0.0", core.Runtime, "")
+ appendMavenDeclaration(&declarations, "build/plugins", "", "", "1.0.0", core.Build, defaultMavenPluginGroup)
+
+ if len(declarations) != 0 {
+ t.Errorf("declarations = %+v, want no incomplete coordinates", declarations)
+ }
+}
diff --git a/internal/maven/maven.go b/internal/maven/maven.go
index 68350e1..26029a3 100644
--- a/internal/maven/maven.go
+++ b/internal/maven/maven.go
@@ -44,6 +44,7 @@ func (p *pomXMLParser) ParseInRoot(filename string, content []byte, fsRoot strin
if err != nil {
return nil, &core.ParseError{Filename: filename, Err: err}
}
+ declarations := parsePOMDeclarations(root)
fetcher := pom.NewLocalFetcherFrom(root, filepath.Dir(filename), fsRoot)
ep, err := pom.NewResolver(fetcher).ResolvePOM(context.Background(), root, pom.Options{})
@@ -84,6 +85,7 @@ func (p *pomXMLParser) ParseInRoot(filename string, content []byte, fsRoot strin
Licenses: licenses,
LicenseFile: licenseFile,
Dependencies: deps,
+ Declarations: declarations,
}, nil
}
diff --git a/manifests.go b/manifests.go
index 0357934..44ac56c 100644
--- a/manifests.go
+++ b/manifests.go
@@ -30,6 +30,10 @@ type (
// verification value whose digest encoding depends on the source format.
type Dependency = core.Dependency
+// Declaration represents a dependency-like reference at a stable logical
+// location in a manifest. Location is ecosystem-specific and opaque.
+type Declaration = core.Declaration
+
// Re-export constants.
const (
Manifest Kind = core.Manifest
@@ -63,6 +67,10 @@ type ParseResult struct {
// format declares one instead of, or as well as, an expression.
LicenseFile string
Dependencies []Dependency
+ // Declarations holds source-level references when the parser preserves
+ // their logical locations. Unlike Dependencies, these entries are not
+ // merged, inherited, or otherwise resolved into an effective model.
+ Declarations []Declaration
}
// Options configures Parse.
@@ -110,6 +118,9 @@ func Parse(filename string, content []byte, opts ...Options) (*ParseResult, erro
}
res.Dependencies[i].PURL = makePURL(eco, res.Dependencies[i].Name, version, res.Dependencies[i].RegistryURL)
}
+ for i := range res.Declarations {
+ res.Declarations[i].PURL = makePURL(eco, res.Declarations[i].Name, "", "")
+ }
return &ParseResult{
Ecosystem: eco,
@@ -119,6 +130,7 @@ func Parse(filename string, content []byte, opts ...Options) (*ParseResult, erro
Licenses: res.Licenses,
LicenseFile: res.LicenseFile,
Dependencies: res.Dependencies,
+ Declarations: res.Declarations,
}, nil
}
diff --git a/manifests_test.go b/manifests_test.go
index 374f404..4df0919 100644
--- a/manifests_test.go
+++ b/manifests_test.go
@@ -80,6 +80,47 @@ func TestEcosystems(t *testing.T) {
}
}
+func TestMavenDeclarationPURLs(t *testing.T) {
+ content := []byte(`
+
+ org.example
+ parent
+ 1.0.0
+
+ example
+
+
+
+ maven-compiler-plugin
+ 4.0.0
+
+
+
+`)
+
+ result, err := Parse("pom.xml", content)
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ want := map[string]string{
+ "parent/org.example:parent": "pkg:maven/org.example/parent",
+ "build/plugins/org.apache.maven.plugins:maven-compiler-plugin": "pkg:maven/org.apache.maven.plugins/maven-compiler-plugin",
+ }
+ if len(result.Declarations) != len(want) {
+ t.Fatalf("Declarations has %d entries, want %d: %+v", len(result.Declarations), len(want), result.Declarations)
+ }
+ for _, declaration := range result.Declarations {
+ wantPURL, ok := want[declaration.Location]
+ if !ok {
+ t.Errorf("unexpected declaration at %q: %+v", declaration.Location, declaration)
+ continue
+ }
+ if declaration.PURL != wantPURL {
+ t.Errorf("declaration at %q has PURL %q, want %q", declaration.Location, declaration.PURL, wantPURL)
+ }
+ }
+}
+
func TestParseDeclaredLicenses(t *testing.T) {
testCases := []struct {
name string