Skip to content
Open
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
48 changes: 47 additions & 1 deletion cli/src/cmd/app/commands/outdated.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ type outdatedOptions struct {
services []string
format string
exitCode bool
ignore []string
writer io.Writer
}

Expand All @@ -98,6 +99,10 @@ The package manager is detected per service:
A service whose package manager is not installed is skipped with a warning
rather than failing the whole run.

Use --ignore to drop packages you have intentionally pinned so they do not show
up as outdated or trip --exit-code. Names are matched case-insensitively; pass a
comma-separated list or repeat the flag.

Examples:
# Report outdated dependencies for every service
azd app outdated
Expand All @@ -109,7 +114,10 @@ Examples:
azd app outdated --format json

# Fail (non-zero exit) when anything is outdated, for CI gating
azd app outdated --exit-code`,
azd app outdated --exit-code

# Ignore packages you have pinned on purpose
azd app outdated --exit-code --ignore react,typescript`,
SilenceUsage: true,
PreRunE: func(cmd *cobra.Command, args []string) error {
if flag := cmd.InheritedFlags().Lookup("output"); flag != nil && flag.Value.String() != "" {
Expand All @@ -130,6 +138,7 @@ Examples:
cmd.Flags().StringSliceVarP(&opts.services, "service", "s", nil, "Limit to specific services (can be specified multiple times)")
cmd.Flags().StringVar(&opts.format, "format", "", "Output format: text (default) or json")
cmd.Flags().BoolVar(&opts.exitCode, "exit-code", false, "Return a non-zero exit code when any dependency is outdated")
cmd.Flags().StringSliceVar(&opts.ignore, "ignore", nil, "Package names to exclude from the report (comma-separated or repeated)")

return cmd
}
Expand All @@ -152,6 +161,8 @@ func runOutdated(opts *outdatedOptions) error {
return err
}

ignored := newIgnoreSet(opts.ignore)

result := outdatedResult{}
for _, t := range targets {
svc := serviceOutdated{Service: t.Service, Language: t.Language, Manager: t.Manager}
Expand All @@ -178,6 +189,7 @@ func runOutdated(opts *outdatedOptions) error {
result.Services = append(result.Services, svc)
continue
}
pkgs = filterIgnoredPackages(pkgs, ignored)
svc.Packages = pkgs
result.TotalOutdated += len(pkgs)
result.Services = append(result.Services, svc)
Expand Down Expand Up @@ -238,6 +250,40 @@ func resolveOutdatedTargets(searchRoot string, requested []string) ([]outdatedTa
return targets, nil
}

// newIgnoreSet builds a lookup of package names to exclude from the report. Each
// entry is trimmed and lowercased so matching is case-insensitive, and empty
// entries (from a trailing comma, say) are dropped.
func newIgnoreSet(entries []string) map[string]struct{} {
if len(entries) == 0 {
return nil
}
set := make(map[string]struct{}, len(entries))
for _, entry := range entries {
name := strings.ToLower(strings.TrimSpace(entry))
if name == "" {
continue
}
set[name] = struct{}{}
}
return set
}

// filterIgnoredPackages returns the packages whose names are not in the ignore
// set, preserving order. It returns the input unchanged when nothing is ignored.
func filterIgnoredPackages(pkgs []outdatedPackage, ignored map[string]struct{}) []outdatedPackage {
if len(ignored) == 0 || len(pkgs) == 0 {
return pkgs
}
kept := make([]outdatedPackage, 0, len(pkgs))
for _, pkg := range pkgs {
if _, skip := ignored[strings.ToLower(strings.TrimSpace(pkg.Name))]; skip {
continue
}
kept = append(kept, pkg)
}
return kept
}

// resolveManager determines the display language and package manager for a
// service directory, using the declared language when present and falling back
// to marker files on disk.
Expand Down
78 changes: 77 additions & 1 deletion cli/src/cmd/app/commands/outdated_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func TestNewOutdatedCommand(t *testing.T) {
if cmd.Use != "outdated" {
t.Fatalf("Use = %q, want outdated", cmd.Use)
}
for _, name := range []string{"service", "format", "exit-code"} {
for _, name := range []string{"service", "format", "exit-code", "ignore"} {
if cmd.Flags().Lookup(name) == nil {
t.Errorf("flag %q not found", name)
}
Expand Down Expand Up @@ -368,6 +368,82 @@ func TestRunOutdatedAggregation(t *testing.T) {
}
}

func TestRunOutdatedIgnore(t *testing.T) {
root := setupOutdatedProject(t)
t.Chdir(root)
forceTextFormat(t)

origLook, origRun := outdatedLookPath, outdatedRunner
t.Cleanup(func() { outdatedLookPath, outdatedRunner = origLook, origRun })

outdatedLookPath = func(string) (string, error) { return "npm", nil }
outdatedRunner = func(dir, bin string, args []string) ([]byte, error) {
return []byte(`{"chalk":{"current":"4.0.0","wanted":"5.0.0","latest":"5.3.0"},` +
`"react":{"current":"17.0.0","wanted":"18.0.0","latest":"18.2.0"}}`), nil
}

// Ignoring chalk drops it from the report but keeps react.
var buf bytes.Buffer
if err := runOutdated(&outdatedOptions{writer: &buf, ignore: []string{"chalk"}}); err != nil {
t.Fatalf("runOutdated: %v", err)
}
out := buf.String()
if strings.Contains(out, "chalk") {
t.Errorf("ignored package chalk should not appear:\n%s", out)
}
if !strings.Contains(out, "react") {
t.Errorf("expected react in output:\n%s", out)
}

// Ignoring every outdated package clears the --exit-code gate. Case and
// surrounding whitespace should not matter.
err := runOutdated(&outdatedOptions{
writer: &bytes.Buffer{},
exitCode: true,
ignore: []string{"Chalk", " react "},
})
if err != nil {
t.Errorf("expected clean exit when all outdated deps are ignored, got %v", err)
}
}

func TestNewIgnoreSet(t *testing.T) {
got := newIgnoreSet([]string{"React", " typescript ", "", " "})
if len(got) != 2 {
t.Fatalf("got %d entries, want 2: %v", len(got), got)
}
for _, want := range []string{"react", "typescript"} {
if _, ok := got[want]; !ok {
t.Errorf("missing normalized entry %q", want)
}
}
if newIgnoreSet(nil) != nil {
t.Error("empty input should return a nil set")
}
}

func TestFilterIgnoredPackages(t *testing.T) {
pkgs := []outdatedPackage{
{Name: "chalk"},
{Name: "react"},
{Name: "typescript"},
}

t.Run("removes matching names case-insensitively", func(t *testing.T) {
kept := filterIgnoredPackages(pkgs, newIgnoreSet([]string{"CHALK", "typescript"}))
if len(kept) != 1 || kept[0].Name != "react" {
t.Fatalf("unexpected result: %+v", kept)
}
})

t.Run("nil ignore set returns input unchanged", func(t *testing.T) {
kept := filterIgnoredPackages(pkgs, nil)
if len(kept) != len(pkgs) {
t.Fatalf("got %d packages, want %d", len(kept), len(pkgs))
}
})
}

func TestRunOutdatedSkipsMissingTool(t *testing.T) {
root := setupOutdatedProject(t)
t.Chdir(root)
Expand Down
Loading