chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+906
View File
@@ -0,0 +1,906 @@
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(`<?xml version="1.0" encoding="UTF-8"?>
<project>
<groupId>com.example</groupId>
<artifactId>myapp</artifactId>
<version>1.0.0</version>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>5.3.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>servlet-api</artifactId>
<version>2.5</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>`)
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(`<?xml version="1.0"?>
<project>
<dependencies>
<dependency>
<artifactId>missing-group</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>com.example</groupId>
<version>2.0</version>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>real</artifactId>
<version>3.0</version>
</dependency>
</dependencies>
</project>`)
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
// <properties> is a future enhancement noted inline.
src := []byte(`<?xml version="1.0"?>
<project>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>${spring.version}</version>
</dependency>
</dependencies>
</project>`)
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)
}
}
}
+493
View File
@@ -0,0 +1,493 @@
package modules
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"github.com/pelletier/go-toml/v2"
"github.com/zzet/gortex/internal/graph"
)
// A package-manager workspace is a root manifest that declares a set of
// member packages — distinct from Gortex's repository-grouping
// WorkspaceID and from cross-repo edges. Three ecosystems express it:
//
// - npm / yarn: a root package.json with a "workspaces" array (or the
// `{ "workspaces": { "packages": [...] } }` object form yarn also
// accepts). Entries are glob patterns or plain directory paths.
// - pnpm: a pnpm-workspace.yaml with a top-level `packages:` list.
// - Cargo: a root Cargo.toml with `[workspace] members` (and an
// optional `exclude` list). Entries may be globs.
//
// Detection produces a WorkspaceManifest; ResolveWorkspaceMembers then
// expands the glob patterns against the filesystem to the concrete
// member manifest files. BuildWorkspaceArtifacts turns the resolved
// members into a root node plus one EdgePackageWorkspaceMember edge per
// member.
// WorkspaceEcosystem identifies which package manager owns a workspace
// root. The string value also namespaces the synthetic root node ID.
type WorkspaceEcosystem string
const (
// WorkspaceNPM covers both npm and yarn — they share the
// package.json "workspaces" field and the same glob semantics, so
// a single ecosystem tag is enough.
WorkspaceNPM WorkspaceEcosystem = "npm"
WorkspacePnpm WorkspaceEcosystem = "pnpm"
WorkspaceCargo WorkspaceEcosystem = "cargo"
)
// WorkspaceManifest is a detected package-manager workspace root: which
// ecosystem owns it, the repo-relative path of the root manifest file,
// and the verbatim member patterns it declares. Patterns are not yet
// resolved — ResolveWorkspaceMembers expands them.
type WorkspaceManifest struct {
Ecosystem WorkspaceEcosystem
// ManifestPath is the repo-relative path of the root manifest
// (e.g. "package.json", "pnpm-workspace.yaml", "Cargo.toml").
ManifestPath string
// Patterns are the declared member entries — glob patterns
// (`packages/*`) or plain directory paths (`apps/web`).
Patterns []string
// Exclude holds patterns explicitly excluded from membership.
// Only Cargo's `[workspace] exclude` populates this; npm/pnpm have
// no exclude list, so it stays nil for them.
Exclude []string
}
// WorkspaceMember is one resolved member package of a workspace: the
// repo-relative path of its own manifest file (the file node the
// membership edge targets) and the repo-relative directory it lives in.
type WorkspaceMember struct {
// ManifestPath is the repo-relative path of the member's manifest
// (package.json for npm/pnpm, Cargo.toml for cargo).
ManifestPath string
// Dir is the repo-relative directory of the member package.
Dir string
}
// memberManifestName returns the manifest filename a member package of
// the given ecosystem carries at its directory root.
func memberManifestName(eco WorkspaceEcosystem) string {
if eco == WorkspaceCargo {
return "Cargo.toml"
}
return "package.json"
}
// ParsePackageJSONWorkspaces inspects an npm/yarn root package.json and
// returns the declared workspace member patterns. Both the array form
// (`"workspaces": ["packages/*"]`) and the object form yarn also
// accepts (`"workspaces": { "packages": ["packages/*"] }`) are handled.
// Returns nil when the manifest declares no workspaces — that repo is
// simply not a workspace root.
func ParsePackageJSONWorkspaces(source []byte) []string {
if len(source) == 0 {
return nil
}
// The "workspaces" field is polymorphic: a JSON array of strings,
// or an object with a "packages" array (and an optional "nohoist"
// the graph doesn't care about). Decode into json.RawMessage first
// so we can branch on the concrete shape.
var manifest struct {
Workspaces json.RawMessage `json:"workspaces"`
}
if err := json.Unmarshal(source, &manifest); err != nil {
return nil
}
if len(manifest.Workspaces) == 0 {
return nil
}
// Array form.
var asArray []string
if err := json.Unmarshal(manifest.Workspaces, &asArray); err == nil {
return cleanPatterns(asArray)
}
// Object form.
var asObject struct {
Packages []string `json:"packages"`
}
if err := json.Unmarshal(manifest.Workspaces, &asObject); err == nil {
return cleanPatterns(asObject.Packages)
}
return nil
}
// ParsePnpmWorkspaceYAML inspects a pnpm-workspace.yaml file and returns
// the declared member patterns from its top-level `packages:` list. The
// file is parsed with a line-oriented scan rather than a YAML library:
// pnpm-workspace.yaml is tiny and flat, and the scan keeps the parser
// dependency-free and independent of YAML-library list-style quirks
// (block sequences vs. flow sequences). Returns nil when no `packages:`
// list is present.
func ParsePnpmWorkspaceYAML(source []byte) []string {
if len(source) == 0 {
return nil
}
var (
patterns []string
inPackages bool
)
for _, raw := range strings.Split(string(source), "\n") {
line := strings.TrimRight(raw, "\r")
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
// A non-indented line starts a new top-level key. `packages:`
// opens the section we want; any other top-level key closes it.
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
// The key is the text before the first ':'. Taking the
// substring (rather than trimming a trailing ':') keeps
// `packages: [...]` — an inline flow sequence on the same
// line — recognised as the `packages` key.
key := trimmed
if i := strings.IndexByte(trimmed, ':'); i >= 0 {
key = trimmed[:i]
}
key = strings.TrimSpace(key)
// `packages:` may inline a flow sequence on the same line
// (`packages: ["a/*", "b/*"]`) — handle that before
// switching into block-sequence mode.
if key == "packages" {
if idx := strings.Index(trimmed, "["); idx >= 0 {
patterns = append(patterns, parseYAMLFlowSequence(trimmed[idx:])...)
inPackages = false
continue
}
inPackages = true
continue
}
inPackages = false
continue
}
if !inPackages {
continue
}
// Inside `packages:` — block-sequence entries look like
// ` - 'packages/*'`.
if !strings.HasPrefix(trimmed, "-") {
continue
}
item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
item = unquoteYAMLScalar(item)
if item != "" {
patterns = append(patterns, item)
}
}
return cleanPatterns(patterns)
}
// ParseCargoWorkspace inspects a Cargo.toml and returns the member and
// exclude patterns from its `[workspace]` table. A Cargo manifest can
// be a workspace root, a regular crate, or both at once (a root crate);
// only the `[workspace]` table matters here. Returns (nil, nil) when
// the manifest has no `[workspace]` table — that crate is not a
// workspace root.
func ParseCargoWorkspace(source []byte) (members, exclude []string) {
if len(source) == 0 {
return nil, nil
}
var manifest struct {
Workspace *struct {
Members []string `toml:"members"`
Exclude []string `toml:"exclude"`
} `toml:"workspace"`
}
if err := toml.Unmarshal(source, &manifest); err != nil {
return nil, nil
}
if manifest.Workspace == nil {
return nil, nil
}
return cleanPatterns(manifest.Workspace.Members), cleanPatterns(manifest.Workspace.Exclude)
}
// DetectWorkspace reads the candidate root manifests at repoRoot and
// returns the first package-manager workspace it finds, or nil when the
// repo is not a workspace root. Detection order is npm/yarn → pnpm →
// Cargo; a repo realistically uses one package manager, so the first
// hit is the answer. repoRoot is an absolute filesystem path.
func DetectWorkspace(repoRoot string) *WorkspaceManifest {
if repoRoot == "" {
return nil
}
// npm / yarn — root package.json with a "workspaces" field.
if src, err := os.ReadFile(filepath.Join(repoRoot, "package.json")); err == nil {
if pats := ParsePackageJSONWorkspaces(src); len(pats) > 0 {
return &WorkspaceManifest{
Ecosystem: WorkspaceNPM,
ManifestPath: "package.json",
Patterns: pats,
}
}
}
// pnpm — pnpm-workspace.yaml with a `packages:` list.
if src, err := os.ReadFile(filepath.Join(repoRoot, "pnpm-workspace.yaml")); err == nil {
if pats := ParsePnpmWorkspaceYAML(src); len(pats) > 0 {
return &WorkspaceManifest{
Ecosystem: WorkspacePnpm,
ManifestPath: "pnpm-workspace.yaml",
Patterns: pats,
}
}
}
// Cargo — root Cargo.toml with a `[workspace]` table.
if src, err := os.ReadFile(filepath.Join(repoRoot, "Cargo.toml")); err == nil {
if members, exclude := ParseCargoWorkspace(src); len(members) > 0 {
return &WorkspaceManifest{
Ecosystem: WorkspaceCargo,
ManifestPath: "Cargo.toml",
Patterns: members,
Exclude: exclude,
}
}
}
return nil
}
// ResolveWorkspaceMembers expands a WorkspaceManifest's member patterns
// against the filesystem under repoRoot (an absolute path) and returns
// one WorkspaceMember per directory that both matches a member pattern
// and actually carries the ecosystem's manifest file. Glob patterns
// (`packages/*`, `crates/*`) and plain directory paths are both
// supported; a directory matched by an exclude pattern is dropped.
//
// The workspace root's own directory is never returned as its own
// member — a root crate that also lists itself resolves to the root,
// which BuildWorkspaceArtifacts skips. Results are sorted by manifest
// path for deterministic edge emission.
func ResolveWorkspaceMembers(repoRoot string, m *WorkspaceManifest) []WorkspaceMember {
if repoRoot == "" || m == nil {
return nil
}
manifestName := memberManifestName(m.Ecosystem)
excluded := make(map[string]struct{})
for _, pat := range m.Exclude {
for _, dir := range expandMemberPattern(repoRoot, pat) {
excluded[dir] = struct{}{}
}
}
seen := make(map[string]struct{})
var members []WorkspaceMember
for _, pat := range m.Patterns {
for _, dir := range expandMemberPattern(repoRoot, pat) {
if _, skip := excluded[dir]; skip {
continue
}
// The root manifest's own directory (".") is the workspace
// root, not a member of itself.
if dir == "." {
continue
}
if _, dup := seen[dir]; dup {
continue
}
manifestRel := filepath.ToSlash(filepath.Join(dir, manifestName))
// A member directory only counts when it carries the
// ecosystem's manifest — a `packages/*` glob can match
// non-package directories (docs, fixtures) that should
// not become graph members.
if !fileExists(filepath.Join(repoRoot, filepath.FromSlash(manifestRel))) {
continue
}
seen[dir] = struct{}{}
members = append(members, WorkspaceMember{
ManifestPath: manifestRel,
Dir: dir,
})
}
}
sort.Slice(members, func(i, j int) bool {
return members[i].ManifestPath < members[j].ManifestPath
})
return members
}
// expandMemberPattern resolves one member pattern to the set of
// repo-relative directories it matches. A pattern with no glob
// metacharacter is treated as a literal directory path; a pattern with
// `*` / `?` / `[` is expanded with filepath.Glob. Only directories are
// returned. The returned paths are slash-separated and repo-relative;
// the root itself is returned as ".".
func expandMemberPattern(repoRoot, pattern string) []string {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
return nil
}
// Normalise to forward slashes and strip a trailing slash so
// `packages/` and `packages` behave identically.
pattern = strings.TrimSuffix(filepath.ToSlash(pattern), "/")
if pattern == "" || pattern == "." {
return []string{"."}
}
if !strings.ContainsAny(pattern, "*?[") {
// Literal directory path.
abs := filepath.Join(repoRoot, filepath.FromSlash(pattern))
if dirExists(abs) {
return []string{pattern}
}
return nil
}
// Glob pattern — expand against the filesystem.
matches, err := filepath.Glob(filepath.Join(repoRoot, filepath.FromSlash(pattern)))
if err != nil {
return nil
}
var dirs []string
for _, abs := range matches {
if !dirExists(abs) {
continue
}
rel, err := filepath.Rel(repoRoot, abs)
if err != nil {
continue
}
dirs = append(dirs, filepath.ToSlash(rel))
}
sort.Strings(dirs)
return dirs
}
// WorkspaceRootNodeID returns the canonical ID for a workspace-root
// node. The `pkgws::` prefix is a synthetic namespace (matching the
// `module::` / `external::` convention the exporter already
// recognises); ecosystem plus the repo-relative root directory keeps
// two workspace roots in the same repo — vanishingly rare, but a
// nested Cargo workspace inside an npm one is legal — distinct.
func WorkspaceRootNodeID(eco WorkspaceEcosystem, rootDir string) string {
rootDir = filepath.ToSlash(strings.TrimSpace(rootDir))
if rootDir == "" {
rootDir = "."
}
return "pkgws::" + string(eco) + ":" + rootDir
}
// BuildWorkspaceArtifacts turns a detected workspace and its resolved
// members into a (root node, edges) pair: one synthetic
// KindPackage root node and one EdgePackageWorkspaceMember edge from
// that root to each member's manifest file node. Returns (nil, nil)
// when there are no members — a workspace root with zero resolved
// members carries no graph signal.
//
// The root node's FilePath is the root manifest so navigation lands on
// the file declaring the workspace; Meta records the ecosystem and
// member count. Edge endpoints are unprefixed repo-relative paths —
// applyRepoPrefix downstream handles multi-repo namespacing.
func BuildWorkspaceArtifacts(m *WorkspaceManifest, members []WorkspaceMember) (*graph.Node, []*graph.Edge) {
if m == nil || len(members) == 0 {
return nil, nil
}
rootDir := filepath.ToSlash(filepath.Dir(m.ManifestPath))
rootID := WorkspaceRootNodeID(m.Ecosystem, rootDir)
root := &graph.Node{
ID: rootID,
Kind: graph.KindPackage,
Name: "workspace:" + string(m.Ecosystem),
FilePath: filepath.ToSlash(m.ManifestPath),
Language: manifestLanguage(m.ManifestPath),
Meta: map[string]any{
"package_workspace": true,
"ecosystem": string(m.Ecosystem),
"manifest": filepath.ToSlash(m.ManifestPath),
"member_count": len(members),
},
}
edges := make([]*graph.Edge, 0, len(members))
for _, mem := range members {
edges = append(edges, &graph.Edge{
From: rootID,
To: mem.ManifestPath,
Kind: graph.EdgePackageWorkspaceMember,
FilePath: filepath.ToSlash(m.ManifestPath),
Origin: graph.OriginASTResolved,
Meta: map[string]any{
"ecosystem": string(m.Ecosystem),
"member_dir": mem.Dir,
},
})
}
return root, edges
}
// cleanPatterns trims whitespace from each pattern and drops empties,
// preserving order.
func cleanPatterns(in []string) []string {
if len(in) == 0 {
return nil
}
out := make([]string, 0, len(in))
for _, p := range in {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
if len(out) == 0 {
return nil
}
return out
}
// parseYAMLFlowSequence parses a single-line YAML flow sequence
// (`["a/*", 'b/*']`) into its scalar entries. Only the flat
// string-list shape pnpm-workspace.yaml uses is supported.
func parseYAMLFlowSequence(s string) []string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "[")
s = strings.TrimSuffix(s, "]")
if s == "" {
return nil
}
var out []string
for _, part := range strings.Split(s, ",") {
item := unquoteYAMLScalar(strings.TrimSpace(part))
if item != "" {
out = append(out, item)
}
}
return out
}
// unquoteYAMLScalar strips a single matching pair of single or double
// quotes from a YAML scalar. Unquoted scalars are returned unchanged.
func unquoteYAMLScalar(s string) string {
if len(s) >= 2 {
if (s[0] == '\'' && s[len(s)-1] == '\'') || (s[0] == '"' && s[len(s)-1] == '"') {
return s[1 : len(s)-1]
}
}
return s
}
// dirExists reports whether absPath exists and is a directory.
func dirExists(absPath string) bool {
info, err := os.Stat(absPath)
return err == nil && info.IsDir()
}
// fileExists reports whether absPath exists and is a regular file.
func fileExists(absPath string) bool {
info, err := os.Stat(absPath)
return err == nil && !info.IsDir()
}
// manifestLanguage maps a workspace manifest path to the language tag
// stamped on its synthetic node. Kept local to the modules package so
// it does not depend on the indexer's identically-named helper.
func manifestLanguage(manifestPath string) string {
switch filepath.Base(manifestPath) {
case "package.json":
return "json"
case "pnpm-workspace.yaml":
return "yaml"
case "Cargo.toml":
return "toml"
}
return ""
}
+365
View File
@@ -0,0 +1,365 @@
package modules
import (
"os"
"path/filepath"
"sort"
"testing"
"github.com/zzet/gortex/internal/graph"
)
func TestParsePackageJSONWorkspaces(t *testing.T) {
cases := []struct {
name string
src string
want []string
}{
{
name: "array form",
src: `{"name":"root","workspaces":["packages/*","apps/web"]}`,
want: []string{"packages/*", "apps/web"},
},
{
name: "object form with packages",
src: `{"name":"root","workspaces":{"packages":["libs/*"],"nohoist":["**/react"]}}`,
want: []string{"libs/*"},
},
{
name: "no workspaces field",
src: `{"name":"plain","version":"1.0.0","dependencies":{"react":"^18"}}`,
want: nil,
},
{
name: "empty array",
src: `{"name":"root","workspaces":[]}`,
want: nil,
},
{
name: "whitespace-only entries dropped",
src: `{"workspaces":["packages/*"," "]}`,
want: []string{"packages/*"},
},
{
name: "malformed json",
src: `not json`,
want: nil,
},
{
name: "empty input",
src: ``,
want: nil,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := ParsePackageJSONWorkspaces([]byte(c.src))
if !equalStrings(got, c.want) {
t.Errorf("ParsePackageJSONWorkspaces() = %v, want %v", got, c.want)
}
})
}
}
func TestParsePnpmWorkspaceYAML(t *testing.T) {
cases := []struct {
name string
src string
want []string
}{
{
name: "block sequence",
src: "packages:\n - 'packages/*'\n - \"apps/*\"\n - tools/cli\n",
want: []string{"packages/*", "apps/*", "tools/cli"},
},
{
name: "flow sequence inline",
src: "packages: ['packages/*', \"libs/*\"]\n",
want: []string{"packages/*", "libs/*"},
},
{
name: "comments and blank lines skipped",
src: "# pnpm workspace\n\npackages:\n # members\n - 'packages/*'\n",
want: []string{"packages/*"},
},
{
name: "section ends at next top-level key",
src: "packages:\n - 'packages/*'\ncatalog:\n react: ^18\n",
want: []string{"packages/*"},
},
{
name: "no packages key",
src: "catalog:\n react: ^18\n",
want: nil,
},
{
name: "empty input",
src: ``,
want: nil,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := ParsePnpmWorkspaceYAML([]byte(c.src))
if !equalStrings(got, c.want) {
t.Errorf("ParsePnpmWorkspaceYAML() = %v, want %v", got, c.want)
}
})
}
}
func TestParseCargoWorkspace(t *testing.T) {
cases := []struct {
name string
src string
wantMembers []string
wantExclude []string
}{
{
name: "members and exclude",
src: "[workspace]\nmembers = [\"crates/*\", \"tools/gen\"]\nexclude = [\"crates/legacy\"]\n",
wantMembers: []string{"crates/*", "tools/gen"},
wantExclude: []string{"crates/legacy"},
},
{
name: "members only",
src: "[workspace]\nmembers = [\"crates/*\"]\n",
wantMembers: []string{"crates/*"},
wantExclude: nil,
},
{
name: "root crate that is also a workspace",
src: "[package]\nname = \"root\"\nversion = \"0.1.0\"\n\n[workspace]\nmembers = [\"crates/a\"]\n",
wantMembers: []string{"crates/a"},
wantExclude: nil,
},
{
name: "plain crate, no workspace table",
src: "[package]\nname = \"solo\"\n\n[dependencies]\nserde = \"1.0\"\n",
wantMembers: nil,
wantExclude: nil,
},
{
name: "empty input",
src: ``,
wantMembers: nil,
wantExclude: nil,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
members, exclude := ParseCargoWorkspace([]byte(c.src))
if !equalStrings(members, c.wantMembers) {
t.Errorf("members = %v, want %v", members, c.wantMembers)
}
if !equalStrings(exclude, c.wantExclude) {
t.Errorf("exclude = %v, want %v", exclude, c.wantExclude)
}
})
}
}
func TestWorkspaceRootNodeID(t *testing.T) {
cases := []struct {
eco WorkspaceEcosystem
rootDir string
want string
}{
{WorkspaceNPM, ".", "pkgws::npm:."},
{WorkspaceCargo, "", "pkgws::cargo:."},
{WorkspacePnpm, "sub/dir", "pkgws::pnpm:sub/dir"},
}
for _, c := range cases {
if got := WorkspaceRootNodeID(c.eco, c.rootDir); got != c.want {
t.Errorf("WorkspaceRootNodeID(%q,%q) = %q, want %q", c.eco, c.rootDir, got, c.want)
}
}
}
// writeFiles materialises a map of repo-relative path → content under
// dir, creating parent directories as needed.
func writeFiles(t *testing.T, dir string, files map[string]string) {
t.Helper()
for rel, content := range files {
abs := filepath.Join(dir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
t.Fatalf("mkdir for %s: %v", rel, err)
}
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
}
func TestDetectAndResolveWorkspace(t *testing.T) {
cases := []struct {
name string
files map[string]string
wantEco WorkspaceEcosystem
wantMembers []string // sorted manifest paths
}{
{
name: "npm workspace, glob plus explicit member",
files: map[string]string{
"package.json": `{"name":"root","workspaces":["packages/*","apps/web"]}`,
"packages/ui/package.json": `{"name":"ui"}`,
"packages/core/package.json": `{"name":"core"}`,
"packages/notes.md": "not a package",
"apps/web/package.json": `{"name":"web"}`,
"apps/admin/package.json": `{"name":"admin"}`, // not matched — not under a pattern
},
wantEco: WorkspaceNPM,
wantMembers: []string{
"apps/web/package.json",
"packages/core/package.json",
"packages/ui/package.json",
},
},
{
name: "pnpm workspace, glob members",
files: map[string]string{
"pnpm-workspace.yaml": "packages:\n - 'packages/*'\n",
"packages/a/package.json": `{"name":"a"}`,
"packages/b/package.json": `{"name":"b"}`,
"package.json": `{"name":"root"}`,
},
wantEco: WorkspacePnpm,
wantMembers: []string{
"packages/a/package.json",
"packages/b/package.json",
},
},
{
name: "cargo workspace, glob plus explicit, exclude honoured",
files: map[string]string{
"Cargo.toml": "[workspace]\nmembers = [\"crates/*\", \"tools/gen\"]\nexclude = [\"crates/legacy\"]\n",
"crates/engine/Cargo.toml": "[package]\nname = \"engine\"\n",
"crates/cli/Cargo.toml": "[package]\nname = \"cli\"\n",
"crates/legacy/Cargo.toml": "[package]\nname = \"legacy\"\n", // excluded
"tools/gen/Cargo.toml": "[package]\nname = \"gen\"\n",
},
wantEco: WorkspaceCargo,
wantMembers: []string{
"crates/cli/Cargo.toml",
"crates/engine/Cargo.toml",
"tools/gen/Cargo.toml",
},
},
{
name: "non-workspace repo yields no workspace",
files: map[string]string{
"package.json": `{"name":"solo","version":"1.0.0","dependencies":{"react":"^18"}}`,
},
wantEco: "",
wantMembers: nil,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
root := t.TempDir()
writeFiles(t, root, c.files)
manifest := DetectWorkspace(root)
if c.wantEco == "" {
if manifest != nil {
t.Fatalf("expected no workspace, got %+v", manifest)
}
return
}
if manifest == nil {
t.Fatalf("expected a %s workspace, got nil", c.wantEco)
}
if manifest.Ecosystem != c.wantEco {
t.Errorf("ecosystem = %q, want %q", manifest.Ecosystem, c.wantEco)
}
members := ResolveWorkspaceMembers(root, manifest)
gotPaths := make([]string, 0, len(members))
for _, m := range members {
gotPaths = append(gotPaths, m.ManifestPath)
}
sort.Strings(gotPaths)
if !equalStrings(gotPaths, c.wantMembers) {
t.Errorf("members = %v, want %v", gotPaths, c.wantMembers)
}
})
}
}
func TestBuildWorkspaceArtifacts(t *testing.T) {
root := t.TempDir()
writeFiles(t, root, map[string]string{
"package.json": `{"name":"root","workspaces":["packages/*"]}`,
"packages/a/package.json": `{"name":"a"}`,
"packages/b/package.json": `{"name":"b"}`,
})
manifest := DetectWorkspace(root)
if manifest == nil {
t.Fatal("expected an npm workspace")
}
members := ResolveWorkspaceMembers(root, manifest)
if len(members) != 2 {
t.Fatalf("expected 2 members, got %d", len(members))
}
rootNode, edges := BuildWorkspaceArtifacts(manifest, members)
if rootNode == nil {
t.Fatal("expected a workspace root node")
}
if rootNode.Kind != graph.KindPackage {
t.Errorf("root node kind = %q, want %q", rootNode.Kind, graph.KindPackage)
}
if rootNode.ID != "pkgws::npm:." {
t.Errorf("root node ID = %q, want pkgws::npm:.", rootNode.ID)
}
if v, _ := rootNode.Meta["package_workspace"].(bool); !v {
t.Errorf("root node should carry package_workspace=true meta")
}
if v, _ := rootNode.Meta["member_count"].(int); v != 2 {
t.Errorf("member_count meta = %v, want 2", rootNode.Meta["member_count"])
}
if len(edges) != 2 {
t.Fatalf("expected 2 root→member edges, got %d", len(edges))
}
gotTargets := make([]string, 0, len(edges))
for _, e := range edges {
if e.Kind != graph.EdgePackageWorkspaceMember {
t.Errorf("edge kind = %q, want %q", e.Kind, graph.EdgePackageWorkspaceMember)
}
if e.From != rootNode.ID {
t.Errorf("edge From = %q, want %q", e.From, rootNode.ID)
}
if e.Origin != graph.OriginASTResolved {
t.Errorf("edge origin = %q, want %q", e.Origin, graph.OriginASTResolved)
}
gotTargets = append(gotTargets, e.To)
}
sort.Strings(gotTargets)
want := []string{"packages/a/package.json", "packages/b/package.json"}
if !equalStrings(gotTargets, want) {
t.Errorf("edge targets = %v, want %v", gotTargets, want)
}
}
func TestBuildWorkspaceArtifacts_NoMembers(t *testing.T) {
m := &WorkspaceManifest{Ecosystem: WorkspaceNPM, ManifestPath: "package.json"}
node, edges := BuildWorkspaceArtifacts(m, nil)
if node != nil || edges != nil {
t.Errorf("a workspace with no members should yield no artifacts, got node=%v edges=%v", node, edges)
}
}
// equalStrings compares two string slices for order-sensitive equality,
// treating nil and empty as equal.
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}