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