From d7fb40be1ceba8180e2de15d2ab9e41074251ce1 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:48:15 -0700 Subject: [PATCH] feat(outdated): add --ignore to skip pinned dependencies Add a repeatable --ignore flag to the outdated command so packages you have pinned on purpose stop showing up as outdated and stop tripping --exit-code in CI. Names are matched case-insensitively and accept a comma-separated list or repeated flags. Ignored packages are dropped from the report and the total before the exit-code gate is evaluated. Closes #496 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- cli/src/cmd/app/commands/outdated.go | 48 +++++++++++++- cli/src/cmd/app/commands/outdated_test.go | 78 ++++++++++++++++++++++- 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/cli/src/cmd/app/commands/outdated.go b/cli/src/cmd/app/commands/outdated.go index da69a2799..8f4eed680 100644 --- a/cli/src/cmd/app/commands/outdated.go +++ b/cli/src/cmd/app/commands/outdated.go @@ -76,6 +76,7 @@ type outdatedOptions struct { services []string format string exitCode bool + ignore []string writer io.Writer } @@ -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 @@ -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() != "" { @@ -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 } @@ -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} @@ -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) @@ -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. diff --git a/cli/src/cmd/app/commands/outdated_test.go b/cli/src/cmd/app/commands/outdated_test.go index 241583c5c..2a5882149 100644 --- a/cli/src/cmd/app/commands/outdated_test.go +++ b/cli/src/cmd/app/commands/outdated_test.go @@ -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) } @@ -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)