diff --git a/README.md b/README.md index 23c8021..ab2fa95 100644 --- a/README.md +++ b/README.md @@ -242,9 +242,11 @@ 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. +Declarations are available for `package.json`, Python requirements files, +`pyproject.toml`, GitHub Actions workflows, and `pom.xml`. The Maven parser +includes parents, dependencies, dependency management, plugins, plugin +dependencies, plugin management, build extensions, and their profile-scoped +forms. ### ParseResult diff --git a/go.mod b/go.mod index a25eba3..9f5fea1 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/bazelbuild/buildtools v0.0.0-20260716142318-04cf7de1434f github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/git-pkgs/pom v0.1.7 - github.com/git-pkgs/purl v0.1.16 + github.com/git-pkgs/purl v0.1.17 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 608475f..4c6ee71 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6 github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 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/purl v0.1.17 h1:oRSd8tqllTLl74Wa4WnuqU500hXd9OdUnImOEswQUVE= +github.com/git-pkgs/purl v0.1.17/go.mod h1:7u7ora8tQdrkS7Auclr5v8dCJdjN4ej6AbrvYZi2b7k= github.com/git-pkgs/vers v0.3.1 h1:jy/ht2wIRJI5zQrccm6GTeYr+hGFwe2z8LV1HOr4Wco= github.com/git-pkgs/vers v0.3.1/go.mod h1:biTbSQK1qdbrsxDEKnqe3Jzclxz8vW6uDcwKjfUGcOo= github.com/package-url/packageurl-go v0.1.6 h1:YO3p6u1XmCUliivUg/qWphaY8vI6hxSnnPv7Bfg3m5M= diff --git a/internal/github_actions/github_actions.go b/internal/github_actions/github_actions.go index 07199af..9a3df27 100644 --- a/internal/github_actions/github_actions.go +++ b/internal/github_actions/github_actions.go @@ -1,10 +1,13 @@ package github_actions import ( - "github.com/git-pkgs/manifests/internal/core" + "net/url" "path/filepath" + "strconv" "strings" + "github.com/git-pkgs/manifests/internal/core" + "gopkg.in/yaml.v3" ) @@ -59,19 +62,29 @@ func (p *githubWorkflowParser) Parse(filename string, content []byte) (*core.Res } var deps []core.Dependency + var declarations []core.Declaration seen := make(map[string]bool) + locations := make(map[string]int) - for _, job := range workflow.Jobs { - deps = collectStepActions(job.Steps, deps, seen) + for jobName, job := range workflow.Jobs { + deps = collectStepActions(jobName, job.Steps, deps, &declarations, seen, locations) deps = collectContainerImage(job.Container, deps, seen) deps = collectServiceImages(job.Services, deps, seen) } - return &core.Result{Dependencies: deps}, nil + return &core.Result{Dependencies: deps, Declarations: declarations}, nil } -// collectStepActions extracts action dependencies from job steps. -func collectStepActions(steps []githubStep, deps []core.Dependency, seen map[string]bool) []core.Dependency { +// collectStepActions extracts action dependencies and source declarations +// from job steps. +func collectStepActions( + jobName string, + steps []githubStep, + deps []core.Dependency, + declarations *[]core.Declaration, + seen map[string]bool, + locations map[string]int, +) []core.Dependency { for _, step := range steps { if step.Uses == "" { continue @@ -81,6 +94,20 @@ func collectStepActions(steps []githubStep, deps []core.Dependency, seen map[str if name == "" { continue } + if !strings.HasPrefix(name, "docker://") { + base := "jobs/" + url.PathEscape(jobName) + "/steps/" + url.PathEscape(name) + locations[base]++ + location := base + if locations[base] > 1 { + location += "/" + strconv.Itoa(locations[base]) + } + *declarations = append(*declarations, core.Declaration{ + Name: name, + Version: version, + Scope: core.Runtime, + Location: location, + }) + } key := name + "@" + version if seen[key] { diff --git a/internal/github_actions/github_actions_test.go b/internal/github_actions/github_actions_test.go index 01ba080..e1e61ec 100644 --- a/internal/github_actions/github_actions_test.go +++ b/internal/github_actions/github_actions_test.go @@ -56,3 +56,47 @@ func TestGitHubWorkflow(t *testing.T) { t.Error("expected docker://node to have version") } } + +func TestGitHubWorkflowDeclarations(t *testing.T) { + content := []byte(`jobs: + build: + steps: + - uses: actions/checkout@v4 + - uses: actions/cache/restore@v3 + - uses: actions/checkout@v3 + - uses: docker://alpine:3.20 + - uses: ./local-action + test: + steps: + - uses: actions/checkout@main +`) + + result, err := (&githubWorkflowParser{}).Parse("workflow.yml", content) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + want := map[string]struct { + name string + version string + }{ + "jobs/build/steps/actions%2Fcheckout": {"actions/checkout", "v4"}, + "jobs/build/steps/actions%2Fcache%2Frestore": {"actions/cache/restore", "v3"}, + "jobs/build/steps/actions%2Fcheckout/2": {"actions/checkout", "v3"}, + "jobs/test/steps/actions%2Fcheckout": {"actions/checkout", "main"}, + } + 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 != core.Runtime { + t.Errorf("declaration at %q = %+v, want name %q and version %q", + declaration.Location, declaration, expected.name, expected.version) + } + } +} diff --git a/internal/npm/npm.go b/internal/npm/npm.go index 96f6da3..0a46c6f 100644 --- a/internal/npm/npm.go +++ b/internal/npm/npm.go @@ -3,6 +3,7 @@ package npm import ( "bytes" "encoding/json" + "net/url" "strings" "github.com/git-pkgs/manifests/internal/core" @@ -44,42 +45,31 @@ func (p *npmPackageJSONParser) Parse(filename string, content []byte) (*core.Res } var deps []core.Dependency + var declarations []core.Declaration + collectNpmDeclarations(&deps, &declarations, "dependencies", pkg.Dependencies, core.Runtime) + collectNpmDeclarations(&deps, &declarations, "devDependencies", pkg.DevDependencies, core.Development) + collectNpmDeclarations(&deps, &declarations, "optionalDependencies", pkg.OptionalDependencies, core.Optional) + collectNpmDeclarations(&deps, &declarations, "peerDependencies", pkg.PeerDependencies, core.Runtime) - for name, value := range pkg.Dependencies { - if isNpmComment(name) { - continue - } - version, ok := value.(string) - if !ok { - continue - } - realName, realVersion := parseNpmAlias(name, version) - deps = append(deps, core.Dependency{ - Name: realName, - Version: realVersion, - Scope: core.Runtime, - Direct: true, - }) - } - - for name, value := range pkg.DevDependencies { - if isNpmComment(name) { - continue - } - version, ok := value.(string) - if !ok { - continue - } - realName, realVersion := parseNpmAlias(name, version) - deps = append(deps, core.Dependency{ - Name: realName, - Version: realVersion, - Scope: core.Development, - Direct: true, - }) - } + return &core.Result{ + Name: pkg.Name, + Version: pkg.Version, + Licenses: npmLicenses(pkg.License, pkg.Licenses), + Dependencies: deps, + Declarations: declarations, + }, nil +} - for name, value := range pkg.OptionalDependencies { +// collectNpmDeclarations appends dependencies and source declarations from +// one package.json dependency block. +func collectNpmDeclarations( + dependencies *[]core.Dependency, + declarations *[]core.Declaration, + location string, + values map[string]any, + scope core.Scope, +) { + for name, value := range values { if isNpmComment(name) { continue } @@ -88,37 +78,19 @@ func (p *npmPackageJSONParser) Parse(filename string, content []byte) (*core.Res continue } realName, realVersion := parseNpmAlias(name, version) - deps = append(deps, core.Dependency{ + *dependencies = append(*dependencies, core.Dependency{ Name: realName, Version: realVersion, - Scope: core.Optional, + Scope: scope, Direct: true, }) - } - - for name, value := range pkg.PeerDependencies { - if isNpmComment(name) { - continue - } - version, ok := value.(string) - if !ok { - continue - } - realName, realVersion := parseNpmAlias(name, version) - deps = append(deps, core.Dependency{ - Name: realName, - Version: realVersion, - Scope: core.Runtime, // peer dependencies are runtime requirements - Direct: true, + *declarations = append(*declarations, core.Declaration{ + Name: realName, + Version: realVersion, + Scope: scope, + Location: location + "/" + url.PathEscape(name), }) } - - return &core.Result{ - Name: pkg.Name, - Version: pkg.Version, - Licenses: npmLicenses(pkg.License, pkg.Licenses), - Dependencies: deps, - }, nil } func npmLicenses(license any, legacy []npmLicense) []string { diff --git a/internal/npm/npm_test.go b/internal/npm/npm_test.go index 884d758..b2a99b6 100644 --- a/internal/npm/npm_test.go +++ b/internal/npm/npm_test.go @@ -63,6 +63,52 @@ func TestNpmPackageJSON(t *testing.T) { } } +func TestNpmPackageJSONDeclarations(t *testing.T) { + content := []byte(`{ + "dependencies": { + "plain": "1.2.3", + "alias": "npm:@scope/actual-package@^2.0.0", + "@scope/package": "~3.0.0", + "// note": "ignored" + }, + "devDependencies": {"plain": "2.0.0"}, + "optionalDependencies": {"optional": "4.0.0"}, + "peerDependencies": {"peer": ">=5.0.0"} +}`) + + result, err := (&npmPackageJSONParser{}).Parse("package.json", content) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + want := map[string]struct { + name string + version string + scope core.Scope + }{ + "dependencies/plain": {"plain", "1.2.3", core.Runtime}, + "dependencies/alias": {"@scope/actual-package", "^2.0.0", core.Runtime}, + "dependencies/@scope%2Fpackage": {"@scope/package", "~3.0.0", core.Runtime}, + "devDependencies/plain": {"plain", "2.0.0", core.Development}, + "optionalDependencies/optional": {"optional", "4.0.0", core.Optional}, + "peerDependencies/peer": {"peer", ">=5.0.0", core.Runtime}, + } + 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 TestNpmPackageLock(t *testing.T) { content, err := os.ReadFile("../../testdata/npm/package-lock.json") if err != nil { diff --git a/internal/pypi/pypi.go b/internal/pypi/pypi.go index d493001..177bb75 100644 --- a/internal/pypi/pypi.go +++ b/internal/pypi/pypi.go @@ -2,11 +2,16 @@ package pypi import ( "encoding/json" - "github.com/git-pkgs/manifests/internal/core" + "fmt" + "maps" + "net/url" "regexp" + "slices" "strings" "github.com/BurntSushi/toml" + + "github.com/git-pkgs/manifests/internal/core" ) const ( @@ -67,6 +72,8 @@ var ( func (p *requirementsTxtParser) Parse(filename string, content []byte) (*core.Result, error) { var deps []core.Dependency + var declarations []core.Declaration + locations := make(map[string]int) lines := strings.Split(string(content), "\n") for _, line := range lines { @@ -92,6 +99,7 @@ func (p *requirementsTxtParser) Parse(filename string, content []byte) (*core.Re if match[2] != "" && match[3] != "" { version = match[2] + match[3] } + version = pep508Version(version) deps = append(deps, core.Dependency{ Name: name, @@ -99,10 +107,11 @@ func (p *requirementsTxtParser) Parse(filename string, content []byte) (*core.Re Scope: core.Runtime, Direct: true, }) + appendPyPIDeclaration(&declarations, locations, "requirements", name, version, core.Runtime) } } - return &core.Result{Dependencies: deps}, nil + return &core.Result{Dependencies: deps, Declarations: declarations}, nil } // pipfileParser parses Pipfile (TOML format). @@ -288,9 +297,12 @@ func (p *pyprojectParser) Parse(filename string, content []byte) (*core.Result, } var deps []core.Dependency + var declarations []core.Declaration + locations := make(map[string]int) // Poetry format - for name, value := range pyproject.Tool.Poetry.Dependencies { + for _, name := range sortedStringKeys(pyproject.Tool.Poetry.Dependencies) { + value := pyproject.Tool.Poetry.Dependencies[name] if name == "python" { continue } @@ -301,9 +313,11 @@ func (p *pyprojectParser) Parse(filename string, content []byte) (*core.Result, Scope: core.Runtime, Direct: true, }) + appendPyPIDeclaration(&declarations, locations, "tool/poetry/dependencies", name, version, core.Runtime) } - for name, value := range pyproject.Tool.Poetry.DevDependencies { + for _, name := range sortedStringKeys(pyproject.Tool.Poetry.DevDependencies) { + value := pyproject.Tool.Poetry.DevDependencies[name] version := extractPoetryVersion(value) deps = append(deps, core.Dependency{ Name: name, @@ -311,10 +325,12 @@ func (p *pyprojectParser) Parse(filename string, content []byte) (*core.Result, Scope: core.Development, Direct: true, }) + appendPyPIDeclaration(&declarations, locations, "tool/poetry/dev-dependencies", name, version, core.Development) } // Poetry group dependencies - for groupName, group := range pyproject.Tool.Poetry.Group { + for _, groupName := range sortedStringKeys(pyproject.Tool.Poetry.Group) { + group := pyproject.Tool.Poetry.Group[groupName] var scope core.Scope switch groupName { case groupDev, groupDevelopment: @@ -325,7 +341,8 @@ func (p *pyprojectParser) Parse(filename string, content []byte) (*core.Result, scope = core.Runtime } - for name, value := range group.Dependencies { + for _, name := range sortedStringKeys(group.Dependencies) { + value := group.Dependencies[name] version := extractPoetryVersion(value) deps = append(deps, core.Dependency{ Name: name, @@ -333,6 +350,8 @@ func (p *pyprojectParser) Parse(filename string, content []byte) (*core.Result, Scope: scope, Direct: true, }) + location := "tool/poetry/group/" + url.PathEscape(groupName) + "/dependencies" + appendPyPIDeclaration(&declarations, locations, location, name, version, scope) } } @@ -345,10 +364,12 @@ func (p *pyprojectParser) Parse(filename string, content []byte) (*core.Result, Scope: core.Runtime, Direct: true, }) + appendPyPIDeclaration(&declarations, locations, "project/dependencies", name, version, core.Runtime) } // PEP 621 optional dependencies - for groupName, groupDeps := range pyproject.Project.OptionalDependencies { + for _, groupName := range sortedStringKeys(pyproject.Project.OptionalDependencies) { + groupDeps := pyproject.Project.OptionalDependencies[groupName] scope := optionalGroupScope(groupName) for _, dep := range groupDeps { name, version := parsePEP508(dep) @@ -358,6 +379,8 @@ func (p *pyprojectParser) Parse(filename string, content []byte) (*core.Result, Scope: scope, Direct: true, }) + location := "project/optional-dependencies/" + url.PathEscape(groupName) + appendPyPIDeclaration(&declarations, locations, location, name, version, scope) } } @@ -384,9 +407,52 @@ func (p *pyprojectParser) Parse(filename string, content []byte) (*core.Result, Licenses: licenses, LicenseFile: licenseFile, Dependencies: deps, + Declarations: declarations, }, nil } +var pypiNameSeparator = regexp.MustCompile(`[-_.]+`) + +// sortedStringKeys returns the keys of values in lexical order. +func sortedStringKeys[V any](values map[string]V) []string { + return slices.Sorted(maps.Keys(values)) +} + +// appendPyPIDeclaration records a declaration at a PEP 503-normalized logical +// location and adds a numeric suffix when that location repeats. +func appendPyPIDeclaration( + declarations *[]core.Declaration, + locations map[string]int, + prefix string, + name string, + version string, + scope core.Scope, +) { + if name == "" { + return + } + identity := pypiNameSeparator.ReplaceAllString(strings.ToLower(name), "-") + base := prefix + "/" + url.PathEscape(identity) + locations[base]++ + location := base + if locations[base] > 1 { + location += fmt.Sprintf("/%d", locations[base]) + } + *declarations = append(*declarations, core.Declaration{ + Name: name, + Version: strings.TrimSpace(version), + Scope: scope, + Location: location, + }) +} + +// pep508Version removes the environment marker from a PEP 508 version +// requirement. +func pep508Version(version string) string { + version, _, _ = strings.Cut(version, ";") + return strings.TrimSpace(version) +} + func pyprojectLicenses(license any, licenseFiles, classifiers []string) ([]string, string) { var licenses []string var licenseFile string diff --git a/internal/pypi/pypi_test.go b/internal/pypi/pypi_test.go index 6def2c5..9f63f58 100644 --- a/internal/pypi/pypi_test.go +++ b/internal/pypi/pypi_test.go @@ -66,6 +66,41 @@ func TestRequirementsTxt(t *testing.T) { } } +func TestRequirementsDeclarations(t *testing.T) { + content := []byte("Django_Rest.Framework[api]==1.0 ; python_version >= \"3.10\"\n" + + "django-rest-framework>=2.0\n") + + result, err := (&requirementsTxtParser{}).Parse("requirements.txt", content) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + want := map[string]struct { + name string + version string + }{ + "requirements/django-rest-framework": {"Django_Rest.Framework", "==1.0"}, + "requirements/django-rest-framework/2": {"django-rest-framework", ">=2.0"}, + } + 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 != core.Runtime { + t.Errorf("declaration at %q = %+v, want name %q and version %q", + declaration.Location, declaration, expected.name, expected.version) + } + } + if got := result.Dependencies[0].Version; got != "==1.0" { + t.Errorf("dependency version = %q, want marker-free version", got) + } +} + func TestPipfile(t *testing.T) { content, err := os.ReadFile("../../testdata/pypi/Pipfile") if err != nil { @@ -224,6 +259,103 @@ func TestPyprojectToml(t *testing.T) { } } +func TestPyprojectDeclarations(t *testing.T) { + content := []byte(`[tool.poetry.dependencies] +python = "^3.12" +Django = "5.0" +requests = {version = "^2.32"} + +[tool.poetry.dev-dependencies] +pytest = "8.0" + +[tool.poetry.group.qa.dependencies] +Ruff = "0.12" + +[project] +dependencies = [ + "Flask==3.0 ; python_version >= '3.10'", + "flask==3.1", +] + +[project.optional-dependencies] +docs = ["Sphinx>=8"] +`) + + result, err := (&pyprojectParser{}).Parse("pyproject.toml", content) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + want := map[string]struct { + name string + version string + scope core.Scope + }{ + "tool/poetry/dependencies/django": {"Django", "5.0", core.Runtime}, + "tool/poetry/dependencies/requests": {"requests", "^2.32", core.Runtime}, + "tool/poetry/dev-dependencies/pytest": {"pytest", "8.0", core.Development}, + "tool/poetry/group/qa/dependencies/ruff": {"Ruff", "0.12", core.Runtime}, + "project/dependencies/flask": {"Flask", "==3.0", core.Runtime}, + "project/dependencies/flask/2": {"flask", "==3.1", core.Runtime}, + "project/optional-dependencies/docs/sphinx": {"Sphinx", ">=8", core.Optional}, + } + 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 TestPyprojectDeclarationLocationsAreDeterministic(t *testing.T) { + content := []byte(`[tool.poetry.dependencies] +"Example_Pkg" = "1.0" +example-pkg = "2.0" + +[tool.poetry.dev-dependencies] +"Dev_Pkg" = "1.0" +dev-pkg = "2.0" + +[tool.poetry.group.qa.dependencies] +"QA_Pkg" = "1.0" +qa-pkg = "2.0" +`) + + result, err := (&pyprojectParser{}).Parse("pyproject.toml", content) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + want := []struct { + location string + name string + }{ + {"tool/poetry/dependencies/example-pkg", "Example_Pkg"}, + {"tool/poetry/dependencies/example-pkg/2", "example-pkg"}, + {"tool/poetry/dev-dependencies/dev-pkg", "Dev_Pkg"}, + {"tool/poetry/dev-dependencies/dev-pkg/2", "dev-pkg"}, + {"tool/poetry/group/qa/dependencies/qa-pkg", "QA_Pkg"}, + {"tool/poetry/group/qa/dependencies/qa-pkg/2", "qa-pkg"}, + } + if len(result.Declarations) != len(want) { + t.Fatalf("Declarations has %d entries, want %d: %+v", len(result.Declarations), len(want), result.Declarations) + } + for i, expected := range want { + declaration := result.Declarations[i] + if declaration.Location != expected.location || declaration.Name != expected.name { + t.Errorf("Declaration[%d] = %+v, want location %q and name %q", i, declaration, expected.location, expected.name) + } + } +} + func TestPyprojectPoetryLicenseFallback(t *testing.T) { content := []byte(`[project] name = "hybrid-project" diff --git a/manifests_test.go b/manifests_test.go index 4df0919..a5bfdf9 100644 --- a/manifests_test.go +++ b/manifests_test.go @@ -121,6 +121,64 @@ func TestMavenDeclarationPURLs(t *testing.T) { } } +func TestDeclarationPURLs(t *testing.T) { + tests := []struct { + name string + filename string + content string + location string + wantName string + wantVersion string + wantPURL string + }{ + { + name: "npm alias", + filename: "package.json", + content: `{"dependencies":{"alias":"npm:@scope/actual-package@1.2.3"}}`, + location: "dependencies/alias", + wantName: "@scope/actual-package", + wantVersion: "1.2.3", + wantPURL: "pkg:npm/%40scope/actual-package", + }, + { + name: "canonical pypi name", + filename: "requirements.txt", + content: "Django_Rest.Framework==1.0\n", + location: "requirements/django-rest-framework", + wantName: "Django_Rest.Framework", + wantVersion: "==1.0", + wantPURL: "pkg:pypi/django-rest.framework", + }, + { + name: "github action subpath", + filename: ".github/workflows/ci.yml", + content: "jobs:\n build:\n steps:\n - uses: actions/cache/restore@v4\n", + location: "jobs/build/steps/actions%2Fcache%2Frestore", + wantName: "actions/cache/restore", + wantVersion: "v4", + wantPURL: "pkg:githubactions/actions/cache", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := Parse(test.filename, []byte(test.content)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(result.Declarations) != 1 { + t.Fatalf("Declarations has %d entries, want 1: %+v", len(result.Declarations), result.Declarations) + } + declaration := result.Declarations[0] + if declaration.Location != test.location || declaration.Name != test.wantName || + declaration.Version != test.wantVersion || declaration.PURL != test.wantPURL { + t.Errorf("Declaration = %+v, want location %q, name %q, version %q, PURL %q", + declaration, test.location, test.wantName, test.wantVersion, test.wantPURL) + } + }) + } +} + func TestParseDeclaredLicenses(t *testing.T) { testCases := []struct { name string