From 17e66a143c18fee3187e7dce263b1817818bc9d9 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 15 Sep 2026 20:25:03 +0100 Subject: [PATCH 1/2] Warn when a literal workspace member has no manifest A go.work use entry (or Cargo/npm/pnpm workspace member) that names a directory with no manifest was dropped silently, so callers building a release order from the discovered set got an incomplete graph with no signal. Emit a warning naming the workspace file, the member pattern as written, and the manifest it lacked. Wildcard patterns that expand to nothing stay silent. Fixes #97 --- discovery.go | 45 +++++++++++++++++++++++++++------- discovery_test.go | 62 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 9 deletions(-) diff --git a/discovery.go b/discovery.go index 70fbe74..8cbfcaa 100644 --- a/discovery.go +++ b/discovery.go @@ -61,8 +61,13 @@ type DiscoveredManifest struct { } type manifestDiscovery struct { - reader RepositoryReader - items map[discoveredManifestKey]DiscoveredManifest + reader RepositoryReader + items map[discoveredManifestKey]DiscoveredManifest + warnings []error +} + +func (d *manifestDiscovery) warn(err error) { + d.warnings = append(d.warnings, err) } type discoveredManifestKey struct { @@ -84,10 +89,9 @@ func DiscoverManifests(reader RepositoryReader) ([]DiscoveredManifest, []error) reader: reader, items: make(map[discoveredManifestKey]DiscoveredManifest), } - var warnings []error for _, pattern := range []string{"*", ".github/workflows/*.yml", ".github/workflows/*.yaml"} { if err := discovery.addMatches(pattern, ""); err != nil { - warnings = append(warnings, fmt.Errorf("discovering manifests matching %q: %w", pattern, err)) + discovery.warn(fmt.Errorf("discovering manifests matching %q: %w", pattern, err)) } } @@ -99,11 +103,11 @@ func DiscoverManifests(reader RepositoryReader) ([]DiscoveredManifest, []error) } for _, discover := range workspaceDiscoveries { if err := discover(); err != nil { - warnings = append(warnings, err) + discovery.warn(err) } } - return discovery.sorted(), warnings + return discovery.sorted(), discovery.warnings } func (d *manifestDiscovery) addMatches(pattern, parentPath string) error { @@ -157,9 +161,25 @@ func (d *manifestDiscovery) addWorkspaceManifests( if err != nil { return err } - included, err := d.workspaceManifestPaths(includePatterns, manifestName) - if err != nil { - return err + + included := make(map[string]struct{}) + for _, pattern := range includePatterns { + normalized, ok := normalizeRepositoryPattern(pattern) + if !ok { + continue + } + matches, err := d.reader.Glob(path.Join(normalized, manifestName)) + if err != nil { + return fmt.Errorf("expanding workspace pattern %q: %w", pattern, err) + } + if len(matches) == 0 && isLiteralPattern(normalized) { + d.warn(fmt.Errorf("%s: workspace member %q has no %s", parentPath, pattern, manifestName)) + } + for _, match := range matches { + if p, valid := normalizeRepositoryPath(match); valid { + included[p] = struct{}{} + } + } } paths := make([]string, 0, len(included)) @@ -175,6 +195,13 @@ func (d *manifestDiscovery) addWorkspaceManifests( return nil } +// isLiteralPattern reports whether a workspace member pattern names a +// single directory rather than a glob. Wildcard entries that expand to +// nothing are not treated as missing. +func isLiteralPattern(pattern string) bool { + return !strings.ContainsAny(pattern, "*?[{") +} + func (d *manifestDiscovery) workspaceManifestPaths(patterns []string, manifestName string) (map[string]struct{}, error) { result := make(map[string]struct{}) for _, pattern := range patterns { diff --git a/discovery_test.go b/discovery_test.go index 0db0da3..a7dd2bd 100644 --- a/discovery_test.go +++ b/discovery_test.go @@ -174,6 +174,68 @@ func TestDiscoverManifestsReaderWarnings(t *testing.T) { } } +func TestDiscoverManifestsWarnsOnMissingLiteralWorkspaceMember(t *testing.T) { + tests := []struct { + name string + files map[string]string + want string + }{ + { + name: "go.work", + files: map[string]string{ + "go.work": "go 1.26\nuse (\n\t./svc-a\n\t./svc-b\n\t./missing\n)\n", + "svc-a/go.mod": "module example.com/a", + "svc-b/go.mod": "module example.com/b", + }, + want: `go.work: workspace member "./missing" has no go.mod`, + }, + { + name: "cargo", + files: map[string]string{ + "Cargo.toml": "[workspace]\nmembers = [\"crates/core\", \"crates/gone\"]\n", + "crates/core/Cargo.toml": "[package]", + }, + want: `Cargo.toml: workspace member "crates/gone" has no Cargo.toml`, + }, + { + name: "npm literal", + files: map[string]string{ + "package.json": `{"workspaces":["apps/web","apps/gone"]}`, + "apps/web/package.json": `{"name":"web"}`, + }, + want: `package.json: workspace member "apps/gone" has no package.json`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, warnings := DiscoverManifests(mapFSReader(test.files)) + if len(warnings) != 1 || warnings[0].Error() != test.want { + t.Fatalf("warnings = %v, want [%q]", warnings, test.want) + } + if len(got) < 2 { + t.Errorf("valid members should still be returned, got %+v", got) + } + }) + } +} + +func TestDiscoverManifestsNoWarningForEmptyWildcardMember(t *testing.T) { + reader := mapFSReader(map[string]string{ + "go.work": "go 1.26\nuse (\n\t./svc-a\n\t./extras/*\n)\n", + "svc-a/go.mod": "module example.com/a", + }) + got, warnings := DiscoverManifests(reader) + if len(warnings) != 0 { + t.Fatalf("wildcard with zero matches should not warn: %v", warnings) + } + want := []DiscoveredManifest{ + {Path: "svc-a/go.mod", Ecosystem: "golang", Kind: Manifest, ParentPath: "go.work"}, + } + if !slices.Equal(got, want) { + t.Errorf("got %+v, want %+v", got, want) + } +} + func TestDiscoverManifestsGoWorkspaceRootModule(t *testing.T) { reader := mapFSReader(map[string]string{ "go.mod": "module example.com/root\n", From c50667db0f8d0203dc12c354ba504e16cf30f79b Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 15 Sep 2026 21:45:58 +0100 Subject: [PATCH 2/2] Suppress false workspace-member warnings A literal include also covered by an exclude pattern warned even though the workspace configuration removed it; match the literal against the exclude patterns before warning. normalizeRepositoryPath rejected /-prefixed and ../ paths but let a Windows drive-absolute entry (C:\src\svc) through, so it globbed to nothing under the reader root and produced a "has no go.mod" warning for a path outside the repository. Reject drive-letter volumes the same way as other absolute paths. --- discovery.go | 32 ++++++++++++++++++++++++++++++-- discovery_test.go | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/discovery.go b/discovery.go index 8cbfcaa..df3202a 100644 --- a/discovery.go +++ b/discovery.go @@ -172,7 +172,7 @@ func (d *manifestDiscovery) addWorkspaceManifests( if err != nil { return fmt.Errorf("expanding workspace pattern %q: %w", pattern, err) } - if len(matches) == 0 && isLiteralPattern(normalized) { + if len(matches) == 0 && isLiteralPattern(normalized) && !patternCovers(excludePatterns, normalized) { d.warn(fmt.Errorf("%s: workspace member %q has no %s", parentPath, pattern, manifestName)) } for _, match := range matches { @@ -202,6 +202,22 @@ func isLiteralPattern(pattern string) bool { return !strings.ContainsAny(pattern, "*?[{") } +// patternCovers reports whether name matches any of the given patterns. +// Used to suppress a missing-member warning when the same entry is also +// excluded. +func patternCovers(patterns []string, name string) bool { + for _, p := range patterns { + p, ok := normalizeRepositoryPattern(p) + if !ok { + continue + } + if ok, _ := doublestar.PathMatch(p, name); ok { + return true + } + } + return false +} + func (d *manifestDiscovery) workspaceManifestPaths(patterns []string, manifestName string) (map[string]struct{}, error) { result := make(map[string]struct{}) for _, pattern := range patterns { @@ -252,7 +268,7 @@ func (d *manifestDiscovery) sorted() []DiscoveredManifest { func normalizeRepositoryPath(value string) (string, bool) { value = strings.TrimSpace(strings.ReplaceAll(value, `\`, "/")) - if value == "" || strings.HasPrefix(value, "/") { + if value == "" || strings.HasPrefix(value, "/") || hasWindowsVolume(value) { return "", false } value = path.Clean(value) @@ -262,6 +278,18 @@ func normalizeRepositoryPath(value string) (string, bool) { return value, true } +// hasWindowsVolume reports whether s begins with a Windows drive +// letter (e.g. "C:/"). Such paths are absolute on Windows and outside +// the repository reader's root, so they are rejected alongside +// "/"-prefixed and "../" paths. +func hasWindowsVolume(s string) bool { + if len(s) < 2 || s[1] != ':' { + return false + } + c := s[0] + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') +} + func normalizeRepositoryPattern(value string) (string, bool) { value = strings.TrimSpace(strings.ReplaceAll(value, `\`, "/")) value = strings.TrimSuffix(value, "/") diff --git a/discovery_test.go b/discovery_test.go index a7dd2bd..bca04ab 100644 --- a/discovery_test.go +++ b/discovery_test.go @@ -118,7 +118,7 @@ func TestDiscoverManifestsNPMWorkspaceForms(t *testing.T) { func TestDiscoverManifestsRejectsOutsideWorkspaceMembers(t *testing.T) { reader := mapFSReader(map[string]string{ - "Cargo.toml": `[workspace]` + "\n" + `members = ["../outside", "/absolute"]`, + "Cargo.toml": `[workspace]` + "\n" + `members = ["../outside", "/absolute", "C:/drive"]`, "outside/Cargo.toml": `[package]`, "absolute/Cargo.toml": `[package]`, "nested/other/Cargo.toml": `[package]`, @@ -219,6 +219,42 @@ func TestDiscoverManifestsWarnsOnMissingLiteralWorkspaceMember(t *testing.T) { } } +func TestDiscoverManifestsNoWarningForExcludedLiteralMember(t *testing.T) { + // A literal include that is also covered by an exclude pattern + // should not warn even when it has no manifest. + reader := mapFSReader(map[string]string{ + "pnpm-workspace.yaml": "packages:\n - apps/web\n - apps/gone\n - \"!apps/gone\"\n", + "apps/web/package.json": `{"name":"web"}`, + }) + got, warnings := DiscoverManifests(reader) + if len(warnings) != 0 { + t.Fatalf("excluded literal should not warn: %v", warnings) + } + want := []DiscoveredManifest{ + {Path: "apps/web/package.json", Ecosystem: "npm", Kind: Manifest, ParentPath: "pnpm-workspace.yaml"}, + } + if !slices.Equal(got, want) { + t.Errorf("got %+v, want %+v", got, want) + } +} + +func TestDiscoverManifestsRejectsDriveAbsoluteMember(t *testing.T) { + reader := mapFSReader(map[string]string{ + "go.work": "go 1.26\nuse (\n\t./svc-a\n\tC:\\src\\svc\n)\n", + "svc-a/go.mod": "module example.com/a", + }) + got, warnings := DiscoverManifests(reader) + if len(warnings) != 0 { + t.Fatalf("drive-absolute member should be rejected silently, not warned: %v", warnings) + } + want := []DiscoveredManifest{ + {Path: "svc-a/go.mod", Ecosystem: "golang", Kind: Manifest, ParentPath: "go.work"}, + } + if !slices.Equal(got, want) { + t.Errorf("got %+v, want %+v", got, want) + } +} + func TestDiscoverManifestsNoWarningForEmptyWildcardMember(t *testing.T) { reader := mapFSReader(map[string]string{ "go.work": "go 1.26\nuse (\n\t./svc-a\n\t./extras/*\n)\n",