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) } } }