Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 65 additions & 10 deletions discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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))
}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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) && !patternCovers(excludePatterns, 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))
Expand All @@ -175,6 +195,29 @@ 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, "*?[{")
}

// 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 {
Expand Down Expand Up @@ -225,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)
Expand All @@ -235,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, "/")
Expand Down
100 changes: 99 additions & 1 deletion discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]`,
Expand Down Expand Up @@ -174,6 +174,104 @@ 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 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",
"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",
Expand Down