package modules
import (
"testing"
"github.com/zzet/gortex/internal/graph"
)
func TestParseGoMod_Variants(t *testing.T) {
src := []byte(`module github.com/example/x
go 1.22
require github.com/spf13/cobra v1.10.0
require (
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/stretchr/testify v1.11.1
go.uber.org/zap v1.27.1 // indirect
)
replace github.com/foo/bar => ./local/bar
`)
specs := ParseGoMod(src)
if len(specs) != 4 {
t.Fatalf("expected 4 specs, got %d: %+v", len(specs), specs)
}
got := map[string]Spec{}
for _, s := range specs {
got[s.Path] = s
}
if got["github.com/spf13/cobra"].Version != "v1.10.0" {
t.Errorf("cobra version = %q", got["github.com/spf13/cobra"].Version)
}
if !got["go.uber.org/zap"].Indirect {
t.Errorf("zap should be indirect")
}
if got["go.uber.org/zap"].Indirect != true {
t.Errorf("zap indirect flag wrong")
}
if got["github.com/sabhiram/go-gitignore"].Indirect {
t.Errorf("go-gitignore should not be indirect")
}
}
func TestParseGoMod_ReplaceDirective(t *testing.T) {
src := []byte(`module x
require github.com/foo/bar v1.0.0
replace github.com/foo/bar => ./local/bar
`)
specs := ParseGoMod(src)
if len(specs) != 1 {
t.Fatalf("expected 1 spec, got %d", len(specs))
}
if specs[0].Replace != "./local/bar" {
t.Errorf("replace = %q", specs[0].Replace)
}
}
func TestParseGoMod_Empty(t *testing.T) {
if got := ParseGoMod(nil); got != nil {
t.Errorf("nil input should yield nil specs")
}
if got := ParseGoMod([]byte("module x\n")); len(got) != 0 {
t.Errorf("module-only manifest should have no deps")
}
}
func TestModuleNodeID(t *testing.T) {
cases := []struct {
ecosystem, path, version, want string
}{
{"go", "github.com/foo/bar", "v1.0.0", "module::go:github.com/foo/bar@v1.0.0"},
{"go", "github.com/foo/bar", "", "module::go:github.com/foo/bar"},
{"npm", "lodash", "4.17.0", "module::npm:lodash@4.17.0"},
}
for _, c := range cases {
if got := ModuleNodeID(c.ecosystem, c.path, c.version); got != c.want {
t.Errorf("ModuleNodeID(%q,%q,%q) = %q, want %q",
c.ecosystem, c.path, c.version, got, c.want)
}
}
}
func TestBuildGraphArtifacts(t *testing.T) {
specs := []Spec{
{Ecosystem: "go", Path: "github.com/foo/bar", Version: "v1.0.0", Line: 5},
{Ecosystem: "go", Path: "github.com/foo/bar", Version: "v1.0.0", Line: 6}, // dup
{Ecosystem: "go", Path: "go.uber.org/zap", Version: "v1.27.1", Indirect: true, Line: 7},
}
nodes, edges := BuildGraphArtifacts("go.mod", specs)
if len(nodes) != 2 {
t.Errorf("expected 2 unique nodes, got %d", len(nodes))
}
if len(edges) != 3 {
t.Errorf("expected 3 edges (one per spec, dups produce dup edges), got %d", len(edges))
}
for _, e := range edges {
if e.From != "go.mod" {
t.Errorf("edge from = %q", e.From)
}
if e.Kind != graph.EdgeDependsOnModule {
t.Errorf("edge kind = %q", e.Kind)
}
}
for _, n := range nodes {
if n.Kind != graph.KindModule {
t.Errorf("node kind = %q", n.Kind)
}
if n.Meta["ecosystem"] != "go" {
t.Errorf("ecosystem meta = %v", n.Meta["ecosystem"])
}
}
// Verify the indirect flag on the zap node.
for _, n := range nodes {
if n.Meta["path"] == "go.uber.org/zap" {
if v, _ := n.Meta["indirect"].(bool); !v {
t.Errorf("zap indirect flag missing")
}
}
}
}
func TestParsePackageJSON_AllBlocks(t *testing.T) {
src := []byte(`{
"name": "my-app",
"version": "1.0.0",
"dependencies": {
"react": "^18.2.0",
"lodash": "4.17.21"
},
"devDependencies": {
"vitest": "^1.0.0"
},
"peerDependencies": {
"next": ">=13.0.0"
},
"optionalDependencies": {
"fsevents": "^2.3.0"
}
}`)
specs := ParsePackageJSON(src)
if len(specs) != 5 {
t.Fatalf("expected 5 specs, got %d: %+v", len(specs), specs)
}
got := map[string]Spec{}
for _, s := range specs {
got[s.Path] = s
if s.Ecosystem != "npm" {
t.Errorf("ecosystem = %q for %q", s.Ecosystem, s.Path)
}
}
if got["react"].Version != "^18.2.0" {
t.Errorf("react version = %q", got["react"].Version)
}
if got["react"].Indirect {
t.Errorf("react should NOT be indirect (production dep)")
}
if !got["vitest"].Indirect || got["vitest"].Replace != "dev" {
t.Errorf("vitest should be dev-indirect: %+v", got["vitest"])
}
if got["next"].Replace != "peer" {
t.Errorf("next.Replace = %q", got["next"].Replace)
}
if got["fsevents"].Replace != "optional" {
t.Errorf("fsevents.Replace = %q", got["fsevents"].Replace)
}
}
func TestParsePackageJSON_Empty(t *testing.T) {
if got := ParsePackageJSON(nil); got != nil {
t.Errorf("nil input → nil specs")
}
if got := ParsePackageJSON([]byte("{}")); len(got) != 0 {
t.Errorf("empty manifest → empty specs")
}
}
func TestParsePackageJSON_Malformed(t *testing.T) {
if got := ParsePackageJSON([]byte("not json")); got != nil {
t.Errorf("malformed input → nil")
}
}
func TestParseNpmAlias(t *testing.T) {
cases := []struct {
in string
wantName string
wantOK bool
}{
{"npm:@acme/shared-lib@1.4.0", "@acme/shared-lib", true},
{"npm:lodash@4.17.21", "lodash", true},
{"npm:left-pad", "left-pad", true}, // no version
{"npm:@scope/pkg", "@scope/pkg", true}, // scoped, no version
{"npm:@scope/pkg@^2.0.0", "@scope/pkg", true},
{"^18.2.0", "", false}, // ordinary semver range
{"4.17.21", "", false}, // bare version
{"github:user/repo", "", false}, // git shorthand, not npm:
{"npm:", "", false}, // prefix only
{"", "", false},
}
for _, c := range cases {
name, ok := ParseNpmAlias(c.in)
if name != c.wantName || ok != c.wantOK {
t.Errorf("ParseNpmAlias(%q) = (%q, %t), want (%q, %t)",
c.in, name, ok, c.wantName, c.wantOK)
}
}
}
func TestParsePackageJSON_NpmAlias(t *testing.T) {
src := []byte(`{
"name": "my-app",
"dependencies": {
"shared": "npm:@acme/shared-lib@1.4.0",
"lodash4": "npm:lodash@4.17.21",
"react": "^18.2.0"
},
"devDependencies": {
"test-utils": "npm:@acme/test-utils"
}
}`)
specs := ParsePackageJSON(src)
got := map[string]Spec{}
for _, s := range specs {
got[s.Path] = s
}
// Aliased production dep: Path is the alias key, Alias the real name.
if got["shared"].Alias != "@acme/shared-lib" {
t.Errorf("shared.Alias = %q, want @acme/shared-lib", got["shared"].Alias)
}
if got["shared"].Version != "npm:@acme/shared-lib@1.4.0" {
t.Errorf("shared.Version should keep the verbatim npm: string, got %q", got["shared"].Version)
}
if got["lodash4"].Alias != "lodash" {
t.Errorf("lodash4.Alias = %q, want lodash", got["lodash4"].Alias)
}
// Aliased dev dep — alias is captured in devDependencies too.
if got["test-utils"].Alias != "@acme/test-utils" {
t.Errorf("test-utils.Alias = %q, want @acme/test-utils", got["test-utils"].Alias)
}
if got["test-utils"].Replace != "dev" {
t.Errorf("test-utils should be a dev dep, got Replace=%q", got["test-utils"].Replace)
}
// Ordinary dep — no alias.
if got["react"].Alias != "" {
t.Errorf("react.Alias should be empty, got %q", got["react"].Alias)
}
}
func TestParsePackageJSON_StableOrder(t *testing.T) {
// JSON map iteration is randomised — our packageJSONBlock
// helper sorts within each block to keep tests deterministic.
src := []byte(`{"dependencies": {"zoo": "1.0", "alpha": "2.0", "beta": "3.0"}}`)
specs := ParsePackageJSON(src)
if len(specs) != 3 {
t.Fatalf("expected 3, got %d", len(specs))
}
if specs[0].Path != "alpha" || specs[1].Path != "beta" || specs[2].Path != "zoo" {
t.Errorf("not alphabetically sorted: %+v", specs)
}
}
func TestParsePackageLockJSON_v3(t *testing.T) {
src := []byte(`{
"name": "myapp",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "myapp",
"version": "1.0.0"
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-..."
},
"node_modules/react": {
"version": "18.2.0"
},
"node_modules/vitest": {
"version": "1.0.4",
"dev": true
},
"node_modules/@scope/util": {
"version": "2.5.0"
},
"node_modules/foo/node_modules/bar": {
"version": "1.0.0"
}
}
}`)
specs := ParsePackageLockJSON(src)
if len(specs) != 5 {
t.Fatalf("expected 5 specs (root entry skipped), got %d: %+v", len(specs), specs)
}
got := map[string]Spec{}
for _, s := range specs {
got[s.Path] = s
if s.Ecosystem != "npm" {
t.Errorf("ecosystem = %q for %q", s.Ecosystem, s.Path)
}
}
if got["lodash"].Version != "4.17.21" {
t.Errorf("lodash version = %q (lockfile resolved value, not semver range)", got["lodash"].Version)
}
if got["react"].Version != "18.2.0" {
t.Errorf("react version = %q", got["react"].Version)
}
if !got["vitest"].Indirect || got["vitest"].Replace != "dev" {
t.Errorf("vitest dev classification wrong: %+v", got["vitest"])
}
if got["@scope/util"].Version != "2.5.0" {
t.Errorf("scoped package version = %q", got["@scope/util"].Version)
}
if got["foo/node_modules/bar"].Version != "1.0.0" {
t.Errorf("nested-transitive path %q should preserve full chain (multi-version differentiator)",
"foo/node_modules/bar")
}
}
func TestParsePackageLockJSON_RejectsV1(t *testing.T) {
src := []byte(`{
"name": "myapp",
"lockfileVersion": 1,
"dependencies": {
"lodash": {"version": "4.17.21"}
}
}`)
if got := ParsePackageLockJSON(src); got != nil {
t.Errorf("v1 lockfile should yield nil specs (unsupported shape), got %+v", got)
}
}
func TestParsePackageLockJSON_Empty(t *testing.T) {
if got := ParsePackageLockJSON(nil); got != nil {
t.Errorf("nil input should yield nil")
}
if got := ParsePackageLockJSON([]byte(`{"lockfileVersion": 3, "packages": {}}`)); len(got) != 0 {
t.Errorf("empty packages map should yield empty specs")
}
}
func TestParseYarnLock_v1(t *testing.T) {
src := []byte(`# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
lodash@^4.17.0:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#..."
integrity sha512-...
react@^18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#..."
"@types/node@*", "@types/node@^20.0.0":
version "20.10.5"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.10.5.tgz#..."
`)
specs := ParseYarnLock(src)
if len(specs) != 3 {
t.Fatalf("expected 3 specs, got %d: %+v", len(specs), specs)
}
got := map[string]string{}
for _, s := range specs {
got[s.Path] = s.Version
if s.Ecosystem != "npm" {
t.Errorf("ecosystem = %q for %q", s.Ecosystem, s.Path)
}
}
if got["lodash"] != "4.17.21" {
t.Errorf("lodash = %q", got["lodash"])
}
if got["react"] != "18.2.0" {
t.Errorf("react = %q", got["react"])
}
if got["@types/node"] != "20.10.5" {
t.Errorf("@types/node = %q (scoped name should be preserved as is)", got["@types/node"])
}
}
func TestParseYarnLock_DedupesMultiRangeBlocks(t *testing.T) {
// `lodash@^4.0.0, lodash@^4.17.0:` is one block declaring two
// ranges that resolve to the same version. Should yield one
// Spec, not two.
src := []byte(`lodash@^4.0.0, lodash@^4.17.0:
version "4.17.21"
`)
specs := ParseYarnLock(src)
if len(specs) != 1 {
t.Errorf("expected 1 deduped spec, got %d", len(specs))
}
}
func TestParseYarnLock_Empty(t *testing.T) {
if got := ParseYarnLock(nil); got != nil {
t.Errorf("nil input should yield nil")
}
}
func TestParsePnpmLock_v6(t *testing.T) {
src := []byte(`lockfileVersion: '6.0'
settings:
autoInstallPeers: true
importers:
.:
dependencies:
react:
specifier: ^18.2.0
version: 18.2.0
packages:
/react@18.2.0:
resolution: {integrity: sha512-...}
dependencies:
loose-envify: 1.4.0
/lodash@4.17.21:
resolution: {integrity: sha512-...}
/@types/node@20.10.5:
resolution: {integrity: sha512-...}
`)
specs := ParsePnpmLock(src)
if len(specs) != 3 {
t.Fatalf("expected 3 specs, got %d: %+v", len(specs), specs)
}
got := map[string]string{}
for _, s := range specs {
got[s.Path] = s.Version
if s.Ecosystem != "npm" {
t.Errorf("ecosystem = %q for %q", s.Ecosystem, s.Path)
}
}
if got["react"] != "18.2.0" || got["lodash"] != "4.17.21" {
t.Errorf("got %+v", got)
}
if got["@types/node"] != "20.10.5" {
t.Errorf("scoped pkg @types/node = %q", got["@types/node"])
}
}
func TestParsePnpmLock_StripsPeerSuffix(t *testing.T) {
// pnpm encodes peer-dep resolutions in the key:
// `/some-pkg@1.0.0_react@18.2.0` — the `_react@...` suffix
// is the peer combination, not part of the version. We strip
// it so the canonical version stays clean.
src := []byte(`packages:
/some-pkg@1.0.0_react@18.2.0:
resolution: {integrity: sha512-...}
`)
specs := ParsePnpmLock(src)
if len(specs) != 1 || specs[0].Version != "1.0.0" {
t.Errorf("expected version 1.0.0, got %+v", specs)
}
}
func TestParsePnpmLock_Empty(t *testing.T) {
if got := ParsePnpmLock(nil); got != nil {
t.Errorf("nil input should yield nil")
}
if got := ParsePnpmLock([]byte("lockfileVersion: '6.0'\n")); len(got) != 0 {
t.Errorf("manifest with no packages should yield empty specs")
}
}
func TestParsePyProject_PEP621(t *testing.T) {
src := []byte(`[project]
name = "myproj"
version = "0.1.0"
dependencies = [
"litellm>=1.50.0",
"docker>=7.0.0",
"pyyaml",
"flask[async]==2.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"ruff",
]
docs = [
"mkdocs>=1.0",
]
`)
specs := ParsePyProject(src)
if len(specs) != 7 {
t.Fatalf("expected 7 specs (4 prod + 2 dev + 1 docs), got %d: %+v", len(specs), specs)
}
got := map[string]Spec{}
for _, s := range specs {
got[s.Path] = s
}
if got["litellm"].Version != ">=1.50.0" {
t.Errorf("litellm version = %q", got["litellm"].Version)
}
if got["pyyaml"].Version != "" {
t.Errorf("unconstrained pkg should have empty version, got %q", got["pyyaml"].Version)
}
if got["flask"].Version != "==2.0.0" {
t.Errorf("flask extras suffix should be stripped: %q", got["flask"].Version)
}
if got["pytest"].Replace != "dev" || !got["pytest"].Indirect {
t.Errorf("pytest should be dev-indirect: %+v", got["pytest"])
}
if got["mkdocs"].Replace != "docs" {
t.Errorf("mkdocs.Replace = %q", got["mkdocs"].Replace)
}
}
func TestParsePyProject_Poetry(t *testing.T) {
src := []byte(`[tool.poetry]
name = "myproj"
[tool.poetry.dependencies]
python = "^3.10"
requests = "^2.0"
django = { version = "^4.2", extras = ["bcrypt"] }
[tool.poetry.dev-dependencies]
pytest = "^8.0"
`)
specs := ParsePyProject(src)
got := map[string]Spec{}
for _, s := range specs {
got[s.Path] = s
}
if _, ok := got["python"]; ok {
t.Errorf("python interpreter constraint must not produce a Spec")
}
if got["requests"].Version != "^2.0" {
t.Errorf("requests version = %q", got["requests"].Version)
}
if got["django"].Version != "^4.2" {
t.Errorf("django version (from table) = %q", got["django"].Version)
}
if got["pytest"].Replace != "dev" {
t.Errorf("pytest should be dev: %+v", got["pytest"])
}
}
func TestParseRequirementsTxt(t *testing.T) {
src := []byte(`# top-level comment
flask>=2.0.0
django==4.2.7 # inline comment
requests
-r other.txt
-e .
--index-url https://pypi.org/simple
# blank line above
git+https://github.com/x/y.git ; sys_platform == "darwin"
`)
specs := ParseRequirementsTxt(src)
got := map[string]Spec{}
for _, s := range specs {
got[s.Path] = s
}
if got["flask"].Version != ">=2.0.0" {
t.Errorf("flask version = %q", got["flask"].Version)
}
if got["django"].Version != "==4.2.7" {
t.Errorf("django version (with inline comment stripped) = %q", got["django"].Version)
}
if _, ok := got["requests"]; !ok {
t.Errorf("unconstrained 'requests' should still produce a spec")
}
if _, ok := got["other.txt"]; ok {
t.Errorf("-r include must not be treated as a dep")
}
// `git+https://...` becomes `git` after splitPEP508 stripping;
// it's a degraded recovery rather than ideal, but at least it
// doesn't blow up. The git URL test pin documents current
// behaviour.
if _, ok := got["git"]; !ok {
t.Logf("note: git+url shape recovers as 'git' (acknowledged degraded form)")
}
}
func TestSplitPEP508(t *testing.T) {
cases := []struct {
in, name, version string
}{
{"requests>=2.0", "requests", ">=2.0"},
{"flask[async]==2.0.0", "flask", "==2.0.0"},
{"numpy", "numpy", ""},
{"pkg ; python_version<'3.9'", "pkg", ""},
{"foo @ https://example.com/foo.tar.gz", "foo", ""},
}
for _, c := range cases {
name, version := splitPEP508(c.in)
if name != c.name || version != c.version {
t.Errorf("splitPEP508(%q) = (%q, %q), want (%q, %q)",
c.in, name, version, c.name, c.version)
}
}
}
func TestParseCargoToml_AllBlocks(t *testing.T) {
src := []byte(`[package]
name = "myapp"
version = "0.1.0"
[dependencies]
serde = "1.0"
tokio = { version = "1.30", features = ["full"] }
local-only = { path = "../local" }
git-only = { git = "https://example.com/git/repo.git" }
[dev-dependencies]
assert_cmd = "2"
[build-dependencies]
cc = "1"
`)
specs := ParseCargoToml(src)
got := map[string]Spec{}
for _, s := range specs {
got[s.Path] = s
if s.Ecosystem != "cargo" {
t.Errorf("ecosystem = %q for %q", s.Ecosystem, s.Path)
}
}
if len(specs) != 4 {
t.Errorf("expected 4 specs (serde + tokio + assert_cmd + cc; local/git skipped), got %d: %+v",
len(specs), specs)
}
if got["serde"].Version != "1.0" {
t.Errorf("serde version = %q", got["serde"].Version)
}
if got["tokio"].Version != "1.30" {
t.Errorf("tokio version (from table) = %q", got["tokio"].Version)
}
if _, ok := got["local-only"]; ok {
t.Errorf("path-only dep must not produce a Spec")
}
if _, ok := got["git-only"]; ok {
t.Errorf("git-only dep without version must not produce a Spec")
}
if !got["assert_cmd"].Indirect || got["assert_cmd"].Replace != "dev" {
t.Errorf("assert_cmd should be dev-indirect: %+v", got["assert_cmd"])
}
if got["cc"].Replace != "build" {
t.Errorf("cc.Replace = %q (want build)", got["cc"].Replace)
}
}
func TestParseCargoToml_UnderscoreDevDeps(t *testing.T) {
// Older Cargo manifests sometimes use the underscore form.
src := []byte(`[package]
name = "p"
[dev_dependencies]
mockall = "0.11"
`)
specs := ParseCargoToml(src)
if len(specs) != 1 {
t.Fatalf("expected 1 spec, got %d", len(specs))
}
if specs[0].Replace != "dev" {
t.Errorf("Replace = %q (underscore form should still be dev)", specs[0].Replace)
}
}
func TestParseCargoToml_Empty(t *testing.T) {
if got := ParseCargoToml(nil); got != nil {
t.Errorf("nil input should yield nil")
}
if got := ParseCargoToml([]byte("[package]\nname = \"x\"\n")); len(got) != 0 {
t.Errorf("manifest with no deps should yield empty specs")
}
}
func TestParsePomXML_AllScopes(t *testing.T) {
src := []byte(`
com.example
myapp
1.0.0
org.springframework
spring-core
5.3.0
com.fasterxml.jackson.core
jackson-databind
2.15.0
compile
junit
junit
4.13.2
test
javax.servlet
servlet-api
2.5
provided
`)
specs := ParsePomXML(src)
if len(specs) != 4 {
t.Fatalf("expected 4 specs, got %d: %+v", len(specs), specs)
}
got := map[string]Spec{}
for _, s := range specs {
got[s.Path] = s
if s.Ecosystem != "maven" {
t.Errorf("ecosystem = %q for %q", s.Ecosystem, s.Path)
}
}
if got["org.springframework:spring-core"].Version != "5.3.0" {
t.Errorf("spring-core version = %q", got["org.springframework:spring-core"].Version)
}
// No-scope and explicit `compile` both treated as production.
if got["org.springframework:spring-core"].Indirect {
t.Errorf("missing scope should be production (Indirect=false)")
}
if got["com.fasterxml.jackson.core:jackson-databind"].Indirect {
t.Errorf("explicit compile should be production")
}
if got["junit:junit"].Replace != "test" || !got["junit:junit"].Indirect {
t.Errorf("junit/test scope wrong: %+v", got["junit:junit"])
}
if got["javax.servlet:servlet-api"].Replace != "provided" {
t.Errorf("servlet-api Replace = %q", got["javax.servlet:servlet-api"].Replace)
}
}
func TestParsePomXML_SkipsIncompleteCoordinate(t *testing.T) {
src := []byte(`
missing-group
1.0
com.example
2.0
com.example
real
3.0
`)
specs := ParsePomXML(src)
if len(specs) != 1 {
t.Errorf("expected 1 spec (the only complete coordinate), got %d", len(specs))
}
}
func TestParsePomXML_PropertyVersionVerbatim(t *testing.T) {
// Property substitution is left verbatim; resolving it from
// is a future enhancement noted inline.
src := []byte(`
org.springframework
spring-core
${spring.version}
`)
specs := ParsePomXML(src)
if len(specs) != 1 || specs[0].Version != "${spring.version}" {
t.Errorf("property reference should be kept verbatim, got %+v", specs)
}
}
func TestLinkImports_LongestPrefix(t *testing.T) {
g := graph.New()
// Two import nodes — one for an exact match, one for a sub-package.
g.AddNode(&graph.Node{
ID: "pkg/a.go::import::github.com/spf13/cobra",
Kind: graph.KindImport,
FilePath: "pkg/a.go",
Meta: map[string]any{"path": "github.com/spf13/cobra"},
})
g.AddNode(&graph.Node{
ID: "pkg/b.go::import::github.com/spf13/cobra/doc",
Kind: graph.KindImport,
FilePath: "pkg/b.go",
Meta: map[string]any{"path": "github.com/spf13/cobra/doc"},
})
g.AddNode(&graph.Node{
ID: "pkg/c.go::import::own/internal/foo",
Kind: graph.KindImport,
FilePath: "pkg/c.go",
Meta: map[string]any{"path": "own/internal/foo"},
})
specs := []Spec{
{Ecosystem: "go", Path: "github.com/spf13/cobra", Version: "v1.0.0"},
{Ecosystem: "go", Path: "go.uber.org/zap", Version: "v1.27.1"},
}
emitted := LinkImports(g, specs, "own")
if emitted != 2 {
t.Errorf("expected 2 edges (cobra exact + cobra/doc prefix; own/internal skipped), got %d", emitted)
}
wantTo := "module::go:github.com/spf13/cobra@v1.0.0"
hits := 0
for _, e := range g.AllEdges() {
if e.Kind == graph.EdgeDependsOnModule && e.To == wantTo {
hits++
}
}
if hits != 2 {
t.Errorf("expected 2 edges to %q, got %d", wantTo, hits)
}
}
func TestLinkImports_PrefersLongerSpecForVersionedImports(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{
ID: "f::import::github.com/foo/bar/v2/sub",
Kind: graph.KindImport,
FilePath: "f.go",
Meta: map[string]any{"path": "github.com/foo/bar/v2/sub"},
})
// Both v1 and v2 exist — the longest match wins.
specs := []Spec{
{Ecosystem: "go", Path: "github.com/foo/bar", Version: "v1.0.0"},
{Ecosystem: "go", Path: "github.com/foo/bar/v2", Version: "v2.1.0"},
}
if got := LinkImports(g, specs, ""); got != 1 {
t.Fatalf("expected 1 edge, got %d", got)
}
for _, e := range g.AllEdges() {
if e.Kind == graph.EdgeDependsOnModule {
if e.To != "module::go:github.com/foo/bar/v2@v2.1.0" {
t.Errorf("wrong module target: %q (longest spec should win)", e.To)
}
}
}
}
func TestLinkImports_SkipsWhenNoMatch(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{
ID: "f::import::stdlib",
Kind: graph.KindImport,
FilePath: "f.go",
Meta: map[string]any{"path": "fmt"},
})
specs := []Spec{
{Ecosystem: "go", Path: "github.com/foo/bar"},
}
if got := LinkImports(g, specs, ""); got != 0 {
t.Errorf("stdlib import shouldn't match external module, got %d edges", got)
}
}
func TestShortName(t *testing.T) {
cases := []struct {
in, want string
}{
{"github.com/foo/bar", "bar"},
{"github.com/foo/bar/v2", "bar"},
{"github.com/foo/bar/v10", "bar"},
{"foo", "foo"},
{"", ""},
}
for _, c := range cases {
if got := shortName(c.in); got != c.want {
t.Errorf("shortName(%q) = %q, want %q", c.in, got, c.want)
}
}
}