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
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:
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user