From 60731261030fbb926c36ac950a0a5fa23f54c4d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 09:05:54 +0000 Subject: [PATCH 1/3] feat(cli)!: parse flags with pflag Replace the standard library's flag package with github.com/spf13/pflag, so decolint follows the GNU convention its users expect from other linters: --long flags plus shorthands for the ones typed most often (-c/--config, -f/--format, -p/--platform, -m/--merge, -v/--version, -h/--help). BREAKING CHANGE: single-dash long flags are gone. `decolint -format=github` must now be spelled `decolint --format=github`; the old spelling fails with exit code 2. pflag was already in the module graph, and cmd/dockerflagsgen already imported it, so this adds no dependency to the build. cmd/dockerflagsgen's -o moves along with it; cmd/docgen's -update stays on flag because `go test` owns that flag set. Unlike flag, pflag's ContinueOnError reports nothing on a parse error, so parseOptions prints the usage text itself to keep decolint's output as it was. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QFbEHHjGNp2bM4AKrXjwFa --- .github/workflows/ci.yml | 2 +- .github/workflows/sarif-upload-check.yml | 2 +- README.md | 4 +- cmd/decolint/color.go | 4 +- cmd/decolint/config.go | 16 +- cmd/decolint/init.go | 8 +- cmd/decolint/main.go | 14 +- cmd/decolint/main_test.go | 172 +++++++++--------- cmd/decolint/opts.go | 44 ++--- cmd/decolint/opts_test.go | 111 ++++++----- cmd/decolint/testdata/e2e/deny-warnings.jsonc | 2 +- cmd/decolint/testdata/e2e/format-sarif.jsonc | 2 +- cmd/decolint/testdata/e2e/format.jsonc | 2 +- cmd/decolint/testdata/e2e/merge-on.jsonc | 2 +- cmd/decolint/testdata/e2e/merge.jsonc | 2 +- cmd/decolint/testdata/e2e/platforms.jsonc | 2 +- cmd/decolint/testdata/e2e/security-warn.jsonc | 2 +- cmd/dockerflagsgen/main.go | 5 +- docs/content/getting-started.md | 20 +- docs/content/reference.md | 48 ++--- docs/content/rules/_index.md | 2 +- format/text.go | 2 +- format/text_test.go | 4 +- go.mod | 2 +- linter/rule.go | 2 +- 25 files changed, 250 insertions(+), 226 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b652f34..6d27cae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,7 +83,7 @@ jobs: with: go-version-file: go.mod - run: make build - - run: ./bin/decolint -format=github -deny-warnings -platform=vscode,codespaces . + - run: ./bin/decolint --format=github --deny-warnings --platform=vscode,codespaces . docker: runs-on: ubuntu-latest diff --git a/.github/workflows/sarif-upload-check.yml b/.github/workflows/sarif-upload-check.yml index eef8de5..798b692 100644 --- a/.github/workflows/sarif-upload-check.yml +++ b/.github/workflows/sarif-upload-check.yml @@ -42,7 +42,7 @@ jobs: # non-zero status is a real failure. It runs from the repository root so that the reported # paths are relative to it, which is how code scanning resolves them. Merging is off so the # run needs no registry access and cannot fail for reasons unrelated to the upload. - run: ./bin/decolint -format=sarif -merge=false "$FIXTURE" > decolint.sarif || [ "$?" -eq 1 ] + run: ./bin/decolint --format=sarif --merge=false "$FIXTURE" > decolint.sarif || [ "$?" -eq 1 ] - name: Upload the SARIF log id: upload diff --git a/README.md b/README.md index bb5cdca..4e2f8ae 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ privileges: Nothing in it is wrong. The problems are in what it pulls in: ```console -$ decolint -merge . +$ decolint --merge . Downloading image metadata(mcr.microsoft.com/devcontainers/go:1.24@sha256:8de3d5b3a3ce235671c7649f0b910414158a220d18cbd2714a4446cc0cc6acd3) Config: .decolint.jsonc Linted 1 file: @@ -115,7 +115,7 @@ installs two unpinned VS Code extensions, and that configuration reaches the container whether or not anyone reads it. decolint reports each finding at the property that pulled it in. -Turn it on with `-merge`, or `"merge": true` in your config; see [Lint what +Turn it on with `--merge`, or `"merge": true` in your config; see [Lint what actually runs](https://bare-devcontainer.github.io/decolint/getting-started/#4-lint-what-actually-runs) for what gets resolved and what does not. diff --git a/cmd/decolint/color.go b/cmd/decolint/color.go index 6d06b57..7c128af 100644 --- a/cmd/decolint/color.go +++ b/cmd/decolint/color.go @@ -9,7 +9,7 @@ import ( "golang.org/x/term" ) -// colorMode is when the text output should be colored, as given by the -color flag. +// colorMode is when the text output should be colored, as given by the --color flag. type colorMode int const ( @@ -41,7 +41,7 @@ func parseColorMode(name string) (colorMode, error) { // colorAuto, in which case the environment does, in this order: // // - NO_COLOR set to a non-empty value turns color off. It wins over FORCE_COLOR, so decolint never -// emits escape sequences where they were declared unwanted; -color=always still forces them. +// emits escape sequences where they were declared unwanted; --color=always still forces them. // - FORCE_COLOR decides on its own: "0" turns color off, any other non-empty value turns it on, // for a destination that renders escape sequences without being a terminal, e.g. a CI log. // - A "dumb" terminal, or a destination that is not a terminal at all, turns color off. diff --git a/cmd/decolint/config.go b/cmd/decolint/config.go index f08d988..faa1f31 100644 --- a/cmd/decolint/config.go +++ b/cmd/decolint/config.go @@ -15,16 +15,16 @@ import ( // Config is the on-disk shape of a decolint config file. type Config struct { // Platforms lists target platforms whose rules are linted in addition to platform-agnostic - // ones. The -platform flag, when given, takes precedence. + // ones. The --platform flag, when given, takes precedence. Platforms []linter.Platform `json:"platforms"` // Merge, when true, fetches the Features referenced in each devcontainer.json and - // lints the merged (effective) configuration. The -merge flag can enable it as well. + // lints the merged (effective) configuration. The --merge flag can enable it as well. Merge bool `json:"merge"` // DenyWarnings, when true, lowers the fail threshold to linter.SeverityWarn so that warnings - // also cause exit code 1. The -deny-warnings flag can enable it as well. + // also cause exit code 1. The --deny-warnings flag can enable it as well. DenyWarnings bool `json:"denyWarnings"` // Format selects how lint issues are written to stdout: "text" (the default when empty), "json", - // "github", or "sarif". The -format flag takes precedence. + // "github", or "sarif". The --format flag takes precedence. Format string `json:"format"` // LocalEnv maps names to the values "${localEnv:NAME}" (and "${env:NAME}") resolve to during // variable substitution, which runs with Merge; see [substitute.Context.LocalEnv]. It is also @@ -126,10 +126,10 @@ func writeSortedMap[V any](enc *jsontext.Encoder, m map[string]V) error { } // mergeConfig returns cfg with any CLI-provided opts fields applied as overrides. A non-empty -// -platform replaces the config file's Platforms (an empty -platform defers to the config file -// rather than clearing it). -merge and -deny-warnings, when explicitly given, override Merge and -// DenyWarnings in either direction (e.g. "-merge=false" disables merging even if the config file -// sets "merge": true). A non-empty -format replaces the config file's Format. LocalEnv, Categories, +// --platform replaces the config file's Platforms (an empty --platform defers to the config file +// rather than clearing it). --merge and --deny-warnings, when explicitly given, override Merge and +// DenyWarnings in either direction (e.g. "--merge=false" disables merging even if the config file +// sets "merge": true). A non-empty --format replaces the config file's Format. LocalEnv, Categories, // and Rules are config-file only. func mergeConfig(opts Options, cfg Config) Config { if len(opts.Platforms) > 0 { diff --git a/cmd/decolint/init.go b/cmd/decolint/init.go index f4a4c45..6043b21 100644 --- a/cmd/decolint/init.go +++ b/cmd/decolint/init.go @@ -14,7 +14,7 @@ import ( // initConfigFile writes a fresh .decolint.jsonc file to the current directory, listing every // built-in rule at its default severity. It writes a confirmation message to output. It is an -// error if the file already exists, so -init never silently overwrites a user's customized config. +// error if the file already exists, so --init never silently overwrites a user's customized config. func initConfigFile(output io.Writer) error { name := defaultConfigNames[0] // ".decolint.jsonc" @@ -35,16 +35,16 @@ func initConfigFile(output io.Writer) error { // under "categories"; per-rule entries take precedence, e.g.: // "categories": { "security": "error" } // "platforms" lists target platforms whose rules run in addition to -// platform-agnostic ones (the -platform flag takes precedence), e.g.: +// platform-agnostic ones (the ---platform flag takes precedence), e.g.: // "platforms": ["vscode", "codespaces"] // "merge", when true, fetches the Features referenced in each // devcontainer.json and lints the merged (effective) configuration, e.g.: // "merge": true // "denyWarnings", when true, treats warnings as failures (exit code 1); -// the -deny-warnings flag takes precedence, e.g.: +// the --deny-warnings flag takes precedence, e.g.: // "denyWarnings": true // "format" selects the output format ("text", "json", "github", or -// "sarif"); the -format flag takes precedence, e.g.: +// "sarif"); the --format flag takes precedence, e.g.: // "format": "github" // "localEnv" supplies the values ${localEnv:NAME} resolves to when merging, // and the environment Compose-file interpolation reads; environment diff --git a/cmd/decolint/main.go b/cmd/decolint/main.go index 2a5aed5..fcb5e14 100644 --- a/cmd/decolint/main.go +++ b/cmd/decolint/main.go @@ -4,7 +4,6 @@ package main import ( "context" "errors" - "flag" "fmt" "io" "io/fs" @@ -21,6 +20,7 @@ import ( "github.com/bare-devcontainer/decolint/linter" "github.com/bare-devcontainer/decolint/rules" "github.com/bare-devcontainer/decolint/substitute" + "github.com/spf13/pflag" ) // progName is the program name, used in the flag set, usage text, and error messages. @@ -61,7 +61,7 @@ func main() { func run(ctx context.Context, args []string, stdout, stderr io.Writer, getenv func(string) string) int { opts, err := parseOptions(args, stderr) if err != nil { - if errors.Is(err, flag.ErrHelp) { + if errors.Is(err, pflag.ErrHelp) { return exitCodeSuccess } _, _ = fmt.Fprintln(stderr, progName+":", err) @@ -113,14 +113,14 @@ func versionString() string { return fmt.Sprintf("%s %s (revision %s)", progName, version, revision) } -// severityEmoji renders a severity for the -rules table. +// severityEmoji renders a severity for the --rules table. var severityEmoji = map[linter.Severity]string{ linter.SeverityOff: "", linter.SeverityWarn: "🟡 WARN", linter.SeverityError: "🔴 ERROR", } -// rulesTableHeader is the header row of the -rules Markdown table. +// rulesTableHeader is the header row of the --rules Markdown table. var rulesTableHeader = []string{"Rule ID", "Category", "Platform", "Current"} // listRules writes a Markdown table of the built-in rules to output: each rule's ID, category, @@ -200,7 +200,7 @@ func writeTableRow(output io.Writer, cells []string, widths []int) error { } // displayWidth estimates s's width in terminal columns. Plain ASCII/Latin text is single-width; -// symbols and emoji (used for severities in the -rules table) render double-width in virtually +// symbols and emoji (used for severities in the --rules table) render double-width in virtually // every terminal, even though they're each a single rune, so utf8.RuneCountInString undercounts // them and throws off column padding. Variation selectors (e.g. U+FE0F, which requests the emoji // presentation of the preceding rune) contribute no width of their own. @@ -219,7 +219,7 @@ func displayWidth(s string) int { return w } -func usage(fs *flag.FlagSet) error { +func usage(fs *pflag.FlagSet) error { if _, err := io.WriteString(fs.Output(), fmt.Sprintf(`%s usage: %s [directory ...] @@ -259,7 +259,7 @@ func runLint(ctx context.Context, stdout, stderr io.Writer, opts Options, cfg Co return false, fmt.Errorf("register rules: %w", err) } // Variable substitution and Feature merging together compute the effective configuration, so - // both are enabled by -merge: without it, decolint lints the file as written. + // both are enabled by --merge: without it, decolint lints the file as written. var merge mergeFn var subst substituteFn if cfg.Merge { diff --git a/cmd/decolint/main_test.go b/cmd/decolint/main_test.go index b32d2ae..949a764 100644 --- a/cmd/decolint/main_test.go +++ b/cmd/decolint/main_test.go @@ -67,7 +67,7 @@ func TestRun(t *testing.T) { tests := []struct { name string - args []string // CLI args, excluding -format=json which is appended for all cases + args []string // CLI args, excluding --format=json which is appended for all cases want []firing wantExitCode int }{ @@ -78,7 +78,7 @@ func TestRun(t *testing.T) { // docker socket mount, unpinned image and feature) stays silent until those categories // are opted into. name: "violations", - args: []string{"-platform=vscode,codespaces", "testdata/e2e/violations"}, + args: []string{"--platform=vscode,codespaces", "testdata/e2e/violations"}, want: []firing{ {violationsFile, "no-bind-mount", linter.SeverityError}, {violationsFile, "no-host-port-format", linter.SeverityError}, @@ -99,7 +99,7 @@ func TestRun(t *testing.T) { // selection the platform-scoped correctness rules aren't registered, so deny-warnings is // the only thing standing between these warnings and a clean exit. name: "violations with deny-warnings", - args: []string{"-deny-warnings", "-config=testdata/e2e/security-warn.jsonc", "testdata/e2e/violations"}, + args: []string{"--deny-warnings", "--config=testdata/e2e/security-warn.jsonc", "testdata/e2e/violations"}, want: []firing{ {violationsFile, "no-docker-socket-mount", linter.SeverityWarn}, {violationsFile, "no-privileged-container", linter.SeverityWarn}, @@ -112,9 +112,9 @@ func TestRun(t *testing.T) { }, { // deny-warnings.jsonc sets "denyWarnings": true in the config file, so the same security - // warnings cross the fail threshold with no -deny-warnings flag. + // warnings cross the fail threshold with no --deny-warnings flag. name: "deny-warnings from config", - args: []string{"-config=testdata/e2e/deny-warnings.jsonc", "testdata/e2e/violations"}, + args: []string{"--config=testdata/e2e/deny-warnings.jsonc", "testdata/e2e/violations"}, want: []firing{ {violationsFile, "no-docker-socket-mount", linter.SeverityWarn}, {violationsFile, "no-privileged-container", linter.SeverityWarn}, @@ -126,10 +126,10 @@ func TestRun(t *testing.T) { wantExitCode: 1, }, { - // -deny-warnings=false, given explicitly, overrides deny-warnings.jsonc's + // --deny-warnings=false, given explicitly, overrides deny-warnings.jsonc's // "denyWarnings": true, so the warnings no longer fail the run. name: "deny-warnings disabled by CLI flag overrides config", - args: []string{"-deny-warnings=false", "-config=testdata/e2e/deny-warnings.jsonc", "testdata/e2e/violations"}, + args: []string{"--deny-warnings=false", "--config=testdata/e2e/deny-warnings.jsonc", "testdata/e2e/violations"}, want: []firing{ {violationsFile, "no-docker-socket-mount", linter.SeverityWarn}, {violationsFile, "no-privileged-container", linter.SeverityWarn}, @@ -147,8 +147,8 @@ func TestRun(t *testing.T) { // opted into. name: "violations with config overrides", args: []string{ - "-platform=vscode,codespaces", - "-config=testdata/e2e/override.jsonc", + "--platform=vscode,codespaces", + "--config=testdata/e2e/override.jsonc", "testdata/e2e/violations", }, want: []firing{ @@ -166,8 +166,8 @@ func TestRun(t *testing.T) { // Correctness rules are unaffected and stay at their enabled-by-default error severity. name: "violations with category overrides", args: []string{ - "-platform=vscode,codespaces", - "-config=testdata/e2e/categories.jsonc", + "--platform=vscode,codespaces", + "--config=testdata/e2e/categories.jsonc", "testdata/e2e/violations", }, want: []firing{ @@ -185,9 +185,9 @@ func TestRun(t *testing.T) { { // platforms.jsonc selects vscode and codespaces via the config file's "platforms" // member, so the same platform-scoped correctness rules fire as in the "violations" - // case, without any -platform flag. + // case, without any --platform flag. name: "violations with platforms from config", - args: []string{"-config=testdata/e2e/platforms.jsonc", "testdata/e2e/violations"}, + args: []string{"--config=testdata/e2e/platforms.jsonc", "testdata/e2e/violations"}, want: []firing{ {violationsFile, "no-bind-mount", linter.SeverityError}, {violationsFile, "no-host-port-format", linter.SeverityError}, @@ -195,13 +195,13 @@ func TestRun(t *testing.T) { wantExitCode: 1, }, { - // The -platform flag overrides the config's "platforms" member. The fixture's firing + // The --platform flag overrides the config's "platforms" member. The fixture's firing // rules are codespaces-scoped, so narrowing the selection to vscode via the flag // silences everything platforms.jsonc would otherwise enable. name: "platform flag overrides config platforms", args: []string{ - "-platform=vscode", - "-config=testdata/e2e/platforms.jsonc", + "--platform=vscode", + "--config=testdata/e2e/platforms.jsonc", "testdata/e2e/violations", }, want: nil, @@ -209,7 +209,7 @@ func TestRun(t *testing.T) { }, { name: "clean", - args: []string{"-platform=vscode,codespaces", "testdata/e2e/clean"}, + args: []string{"--platform=vscode,codespaces", "testdata/e2e/clean"}, want: nil, wantExitCode: 0, }, @@ -244,7 +244,7 @@ func TestRun(t *testing.T) { // Multiple directories are linted in one run and their issues aggregated; the clean // directory contributes nothing while the violations directory drives the exit code. name: "multiple directories aggregate issues", - args: []string{"-platform=vscode,codespaces", "testdata/e2e/clean", "testdata/e2e/violations"}, + args: []string{"--platform=vscode,codespaces", "testdata/e2e/clean", "testdata/e2e/violations"}, want: []firing{ {violationsFile, "no-bind-mount", linter.SeverityError}, {violationsFile, "no-host-port-format", linter.SeverityError}, @@ -256,7 +256,7 @@ func TestRun(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - args := append([]string{"-format=json"}, tt.args...) + args := append([]string{"--format=json"}, tt.args...) var stdout, stderr bytes.Buffer exitCode := run(t.Context(), args, &stdout, &stderr, emptyEnv) @@ -303,7 +303,7 @@ func TestRun_ReportedPathsAreWorkingDirectoryRelative(t *testing.T) { reportedPaths := func(t *testing.T, target string) []string { t.Helper() var stdout, stderr bytes.Buffer - run(t.Context(), []string{"-format=json", "-platform=codespaces", target}, &stdout, &stderr, emptyEnv) + run(t.Context(), []string{"--format=json", "--platform=codespaces", target}, &stdout, &stderr, emptyEnv) issues := decodeJSONOutput(t, stdout.Bytes()).Issues if len(issues) == 0 { t.Fatalf("no findings for %s; the fixture is expected to trip codespaces rules", target) @@ -341,11 +341,11 @@ func TestRun_ReportedPathsAreWorkingDirectoryRelative(t *testing.T) { func TestRun_Flags(t *testing.T) { t.Parallel() - t.Run("-version", func(t *testing.T) { + t.Run("--version", func(t *testing.T) { t.Parallel() var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-version"}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--version"}, &stdout, &stderr, emptyEnv) if exitCode != 0 { t.Errorf("exit code = %d, want 0", exitCode) } @@ -357,11 +357,11 @@ func TestRun_Flags(t *testing.T) { } }) - t.Run("-rules", func(t *testing.T) { + t.Run("--rules", func(t *testing.T) { t.Parallel() var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-rules"}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--rules"}, &stdout, &stderr, emptyEnv) if exitCode != 0 { t.Errorf("exit code = %d, want 0", exitCode) } @@ -388,11 +388,11 @@ func TestRun_Flags(t *testing.T) { } }) - t.Run("-rules with -config", func(t *testing.T) { + t.Run("--rules with --config", func(t *testing.T) { t.Parallel() var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-rules", "-config=testdata/e2e/override.jsonc"}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--rules", "--config=testdata/e2e/override.jsonc"}, &stdout, &stderr, emptyEnv) if exitCode != 0 { t.Errorf("exit code = %d, want 0", exitCode) } @@ -423,11 +423,11 @@ func TestRun_Flags(t *testing.T) { } }) - t.Run("-rules with category overrides in -config", func(t *testing.T) { + t.Run("--rules with category overrides in --config", func(t *testing.T) { t.Parallel() var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-rules", "-config=testdata/e2e/categories.jsonc"}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--rules", "--config=testdata/e2e/categories.jsonc"}, &stdout, &stderr, emptyEnv) if exitCode != 0 { t.Errorf("exit code = %d, want 0", exitCode) } @@ -452,11 +452,11 @@ func TestRun_Flags(t *testing.T) { } }) - t.Run("-rules ignores -format", func(t *testing.T) { + t.Run("--rules ignores --format", func(t *testing.T) { t.Parallel() var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-rules", "-format=sarif"}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--rules", "--format=sarif"}, &stdout, &stderr, emptyEnv) if exitCode != 0 { t.Errorf("exit code = %d, want 0", exitCode) } @@ -470,11 +470,11 @@ func TestRun_Flags(t *testing.T) { } }) - t.Run("-rules ignores the config file's format", func(t *testing.T) { + t.Run("--rules ignores the config file's format", func(t *testing.T) { t.Parallel() var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-rules", "-config=testdata/e2e/format-sarif.jsonc"}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--rules", "--config=testdata/e2e/format-sarif.jsonc"}, &stdout, &stderr, emptyEnv) if exitCode != 0 { t.Errorf("exit code = %d, want 0", exitCode) } @@ -488,11 +488,11 @@ func TestRun_Flags(t *testing.T) { } }) - t.Run("-help", func(t *testing.T) { + t.Run("--help", func(t *testing.T) { t.Parallel() var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-help"}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--help"}, &stdout, &stderr, emptyEnv) if exitCode != 0 { t.Errorf("exit code = %d, want 0", exitCode) } @@ -508,25 +508,25 @@ func TestRun_Flags(t *testing.T) { t.Parallel() var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-bogus"}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--bogus"}, &stdout, &stderr, emptyEnv) if exitCode != 2 { t.Errorf("exit code = %d, want 2", exitCode) } if stdout.String() != "" { t.Errorf("stdout = %q, want empty", stdout.String()) } - if !strings.Contains(stderr.String(), "-bogus") { + if !strings.Contains(stderr.String(), "--bogus") { t.Errorf("stderr = %q, want it to mention the unknown flag", stderr.String()) } }) - t.Run("-config path does not exist", func(t *testing.T) { + t.Run("--config path does not exist", func(t *testing.T) { t.Parallel() dir := writeDevcontainer(t, `{"image": "ubuntu:latest"}`) var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-config=nonexistent.jsonc", dir}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--config=nonexistent.jsonc", dir}, &stdout, &stderr, emptyEnv) if exitCode != 2 { t.Errorf("exit code = %d, want 2", exitCode) } @@ -538,11 +538,11 @@ func TestRun_Flags(t *testing.T) { } }) - t.Run("invalid -format value", func(t *testing.T) { + t.Run("invalid --format value", func(t *testing.T) { t.Parallel() var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-format=bogus", "testdata/e2e/clean"}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--format=bogus", "testdata/e2e/clean"}, &stdout, &stderr, emptyEnv) if exitCode != 2 { t.Errorf("exit code = %d, want 2", exitCode) } @@ -554,11 +554,11 @@ func TestRun_Flags(t *testing.T) { } }) - t.Run("invalid -platform value", func(t *testing.T) { + t.Run("invalid --platform value", func(t *testing.T) { t.Parallel() var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-platform=bogus", "testdata/e2e/clean"}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--platform=bogus", "testdata/e2e/clean"}, &stdout, &stderr, emptyEnv) if exitCode != 2 { t.Errorf("exit code = %d, want 2", exitCode) } @@ -654,7 +654,7 @@ func TestRun_OutputFormat(t *testing.T) { t.Parallel() var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-platform=vscode,codespaces", violationsDir}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--platform=vscode,codespaces", violationsDir}, &stdout, &stderr, emptyEnv) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) } @@ -679,7 +679,7 @@ func TestRun_OutputFormat(t *testing.T) { t.Parallel() var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-color=always", "-platform=vscode,codespaces", violationsDir}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--color=always", "--platform=vscode,codespaces", violationsDir}, &stdout, &stderr, emptyEnv) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) } @@ -698,7 +698,7 @@ func TestRun_OutputFormat(t *testing.T) { } return "" } - exitCode := run(t.Context(), []string{"-platform=vscode,codespaces", violationsDir}, &stdout, &stderr, env) + exitCode := run(t.Context(), []string{"--platform=vscode,codespaces", violationsDir}, &stdout, &stderr, env) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) } @@ -711,7 +711,7 @@ func TestRun_OutputFormat(t *testing.T) { t.Parallel() var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-format=github", "-platform=vscode,codespaces", violationsDir}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--format=github", "--platform=vscode,codespaces", violationsDir}, &stdout, &stderr, emptyEnv) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) } @@ -731,7 +731,7 @@ func TestRun_OutputFormat(t *testing.T) { t.Parallel() var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-format=sarif", "-platform=vscode,codespaces", violationsDir}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--format=sarif", "--platform=vscode,codespaces", violationsDir}, &stdout, &stderr, emptyEnv) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) } @@ -779,7 +779,7 @@ func TestRun_OutputFormat(t *testing.T) { dir := writeDevcontainer(t, `{}`) var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-format=sarif", dir}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--format=sarif", dir}, &stdout, &stderr, emptyEnv) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) } @@ -820,9 +820,9 @@ func TestRun_OutputFormat(t *testing.T) { t.Run("format selected by config file", func(t *testing.T) { t.Parallel() - // format.jsonc sets "format": "json", so output is a JSON report with no -format flag. + // format.jsonc sets "format": "json", so output is a JSON report with no --format flag. var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-config=testdata/e2e/format.jsonc", violationsDir}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--config=testdata/e2e/format.jsonc", violationsDir}, &stdout, &stderr, emptyEnv) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) } @@ -832,9 +832,9 @@ func TestRun_OutputFormat(t *testing.T) { t.Run("format flag overrides config file", func(t *testing.T) { t.Parallel() - // -format=text wins over format.jsonc's "format": "json", so output is the text format. + // --format=text wins over format.jsonc's "format": "json", so output is the text format. var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-format=text", "-config=testdata/e2e/format.jsonc", violationsDir}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--format=text", "--config=testdata/e2e/format.jsonc", violationsDir}, &stdout, &stderr, emptyEnv) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) } @@ -894,7 +894,7 @@ func TestRun_LintedFiles(t *testing.T) { t.Parallel() var stdout, stderr bytes.Buffer - run(t.Context(), append([]string{"-format=json"}, tt.args...), &stdout, &stderr, emptyEnv) + run(t.Context(), append([]string{"--format=json"}, tt.args...), &stdout, &stderr, emptyEnv) got := decodeJSONOutput(t, stdout.Bytes()).Files want := make([]format.File, len(tt.want)) @@ -909,13 +909,13 @@ func TestRun_LintedFiles(t *testing.T) { } // TestRun_ConfigSource checks that the text output names the config file the run's settings came -// from, and points at -init when there is none. +// from, and points at --init when there is none. func TestRun_ConfigSource(t *testing.T) { // Uses t.Chdir, which cannot be combined with t.Parallel. t.Run("config file given", func(t *testing.T) { var stdout, stderr bytes.Buffer - run(t.Context(), []string{"-config=testdata/e2e/security-warn.jsonc", "testdata/e2e/clean"}, &stdout, &stderr, emptyEnv) + run(t.Context(), []string{"--config=testdata/e2e/security-warn.jsonc", "testdata/e2e/clean"}, &stdout, &stderr, emptyEnv) want := "Config: testdata/e2e/security-warn.jsonc\n" if !strings.Contains(stdout.String(), want) { @@ -938,7 +938,7 @@ func TestRun_ConfigSource(t *testing.T) { var stdout, stderr bytes.Buffer run(t.Context(), []string{"."}, &stdout, &stderr, emptyEnv) - want := `Config: none (defaults; run "decolint -init" to create .decolint.jsonc)` + "\n" + want := `Config: none (defaults; run "decolint --init" to create .decolint.jsonc)` + "\n" if !strings.Contains(stdout.String(), want) { t.Errorf("stdout = %q, want it to contain %q", stdout.String(), want) } @@ -953,7 +953,7 @@ func TestRun_BrokenConfig(t *testing.T) { dir := writeDevcontainer(t, `{`) var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-format=json", dir}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--format=json", dir}, &stdout, &stderr, emptyEnv) if exitCode != 2 { t.Errorf("exit code = %d, want 2", exitCode) } @@ -982,7 +982,7 @@ func TestRun_DefaultDirectory(t *testing.T) { // No path argument: the current directory is linted. The config trips missing-container-def. var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-format=json"}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--format=json"}, &stdout, &stderr, emptyEnv) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) } @@ -1011,7 +1011,7 @@ func TestRun_ConfigDiscovery(t *testing.T) { return dir } - t.Run("discovers .decolint.jsonc without -config", func(t *testing.T) { + t.Run("discovers .decolint.jsonc without --config", func(t *testing.T) { project := writeProject(t) t.Chdir(t.TempDir()) if err := os.WriteFile(".decolint.jsonc", []byte(`{"rules": {"no-image-latest": "error"}}`), 0o644); err != nil { @@ -1019,7 +1019,7 @@ func TestRun_ConfigDiscovery(t *testing.T) { } var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-format=json", project}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--format=json", project}, &stdout, &stderr, emptyEnv) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) } @@ -1040,7 +1040,7 @@ func TestRun_ConfigDiscovery(t *testing.T) { } var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-format=json", project}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--format=json", project}, &stdout, &stderr, emptyEnv) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) } @@ -1057,7 +1057,7 @@ func TestRun_Init(t *testing.T) { t.Chdir(t.TempDir()) var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-init"}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--init"}, &stdout, &stderr, emptyEnv) if exitCode != 0 { t.Errorf("exit code = %d, want 0", exitCode) } @@ -1090,7 +1090,7 @@ func TestRun_Init(t *testing.T) { } var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-init"}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--init"}, &stdout, &stderr, emptyEnv) if exitCode != 2 { t.Errorf("exit code = %d, want 2", exitCode) } @@ -1271,7 +1271,7 @@ func TestRun_Merge(t *testing.T) { dir := copyFixture(t, "testdata/e2e/merge", map[string]string{"${BASE_IMAGE}": baseImageRef(t, host)}) var stdout, stderr bytes.Buffer - args := []string{"-format=json", "-merge", "-config=testdata/e2e/merge.jsonc", dir} + args := []string{"--format=json", "--merge", "--config=testdata/e2e/merge.jsonc", dir} exitCode := run(t.Context(), args, &stdout, &stderr, emptyEnv) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) @@ -1291,7 +1291,7 @@ func TestRun_Merge(t *testing.T) { t.Parallel() // Each case pairs the same merged config with a different flag/config combination; the two - // security rules fire only when merging is on. merge.jsonc enables them via the -merge flag, + // security rules fire only when merging is on. merge.jsonc enables them via the --merge flag, // merge-on.jsonc enables them and turns merging on through its own "merge" member. tests := []struct { name string @@ -1299,10 +1299,10 @@ func TestRun_Merge(t *testing.T) { wantExitCode int wantMerged bool }{ - {"flag enables merge", []string{"-merge", "-config=testdata/e2e/merge.jsonc"}, 1, true}, - {"no flag leaves merge off", []string{"-config=testdata/e2e/merge.jsonc"}, 0, false}, - {"config enables merge", []string{"-config=testdata/e2e/merge-on.jsonc"}, 1, true}, - {"flag overrides config merge", []string{"-merge=false", "-config=testdata/e2e/merge-on.jsonc"}, 0, false}, + {"flag enables merge", []string{"--merge", "--config=testdata/e2e/merge.jsonc"}, 1, true}, + {"no flag leaves merge off", []string{"--config=testdata/e2e/merge.jsonc"}, 0, false}, + {"config enables merge", []string{"--config=testdata/e2e/merge-on.jsonc"}, 1, true}, + {"flag overrides config merge", []string{"--merge=false", "--config=testdata/e2e/merge-on.jsonc"}, 0, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1312,7 +1312,7 @@ func TestRun_Merge(t *testing.T) { dir := copyFixture(t, "testdata/e2e/merge", map[string]string{"${BASE_IMAGE}": baseImageRef(t, host)}) var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), append([]string{"-format=json"}, append(tt.args, dir)...), &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), append([]string{"--format=json"}, append(tt.args, dir)...), &stdout, &stderr, emptyEnv) if exitCode != tt.wantExitCode { t.Errorf("exit code = %d, want %d; stdout: %s", exitCode, tt.wantExitCode, stdout.String()) } @@ -1344,7 +1344,7 @@ func TestRun_Merge(t *testing.T) { dir := writeDevcontainer(t, body) var stdout, stderr bytes.Buffer - args := []string{"-format=json", "-merge", "-config=testdata/e2e/merge.jsonc", dir} + args := []string{"--format=json", "--merge", "--config=testdata/e2e/merge.jsonc", dir} exitCode := run(t.Context(), args, &stdout, &stderr, emptyEnv) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) @@ -1366,7 +1366,7 @@ func TestRun_Merge(t *testing.T) { dir := writeDevcontainer(t, body) var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-merge", dir}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--merge", dir}, &stdout, &stderr, emptyEnv) if exitCode != 2 { t.Errorf("exit code = %d, want 2; stdout: %s", exitCode, stdout.String()) } @@ -1395,7 +1395,7 @@ func TestRun_Merge(t *testing.T) { } var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-merge", dir}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--merge", dir}, &stdout, &stderr, emptyEnv) if exitCode != 2 { t.Errorf("exit code = %d, want 2; stdout: %s", exitCode, stdout.String()) } @@ -1410,7 +1410,7 @@ func TestRun_Merge(t *testing.T) { dir := writeDevcontainer(t, fmt.Sprintf(`{"image": %q, "features": {"./missing": {}}}`, baseImageRef(t, host))) var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-merge", dir}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--merge", dir}, &stdout, &stderr, emptyEnv) if exitCode != 2 { t.Errorf("exit code = %d, want 2", exitCode) } @@ -1434,7 +1434,7 @@ func TestRun_Merge(t *testing.T) { } var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-merge", dir}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--merge", dir}, &stdout, &stderr, emptyEnv) if exitCode != 2 { t.Errorf("exit code = %d, want 2; stdout: %s", exitCode, stdout.String()) } @@ -1455,7 +1455,7 @@ func TestRun_Merge(t *testing.T) { dir := writeDevcontainer(t, body) var stdout, stderr bytes.Buffer - args := []string{"-format=json", "-merge", "-config=testdata/e2e/merge.jsonc", dir} + args := []string{"--format=json", "--merge", "--config=testdata/e2e/merge.jsonc", dir} exitCode := run(t.Context(), args, &stdout, &stderr, emptyEnv) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) @@ -1488,7 +1488,7 @@ func TestRun_Merge(t *testing.T) { } var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-merge", dir}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--merge", dir}, &stdout, &stderr, emptyEnv) if exitCode != 2 { t.Errorf("exit code = %d, want 2; stdout: %s", exitCode, stdout.String()) } @@ -1509,7 +1509,7 @@ LABEL devcontainer.metadata='[{"privileged": true, "mounts": ["source=/var/run/d `) var stdout, stderr bytes.Buffer - args := []string{"-format=json", "-merge", "-config=testdata/e2e/merge.jsonc", dir} + args := []string{"--format=json", "--merge", "--config=testdata/e2e/merge.jsonc", dir} exitCode := run(t.Context(), args, &stdout, &stderr, emptyEnv) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) @@ -1539,7 +1539,7 @@ LABEL devcontainer.metadata='[{"privileged": true, "mounts": ["source=/var/run/d writeDockerfile(t, dir, fmt.Sprintf("FROM %s/base:1\n", host)) var stdout, stderr bytes.Buffer - args := []string{"-format=json", "-merge", "-config=testdata/e2e/merge.jsonc", dir} + args := []string{"--format=json", "--merge", "--config=testdata/e2e/merge.jsonc", dir} exitCode := run(t.Context(), args, &stdout, &stderr, emptyEnv) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) @@ -1559,7 +1559,7 @@ LABEL devcontainer.metadata='[{"privileged": true, "mounts": ["source=/var/run/d writeDockerfile(t, dir, "FROM registry.invalid/base:1\n") var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-merge", dir}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--merge", dir}, &stdout, &stderr, emptyEnv) if exitCode != 2 { t.Errorf("exit code = %d, want 2; stdout: %s", exitCode, stdout.String()) } @@ -1580,7 +1580,7 @@ LABEL devcontainer.metadata='[{"privileged": true, "mounts": ["source=/var/run/d writeComposeFile(t, dir, fmt.Sprintf("services:\n app:\n image: %s/base:1\n", host)) var stdout, stderr bytes.Buffer - args := []string{"-format=json", "-merge", "-config=testdata/e2e/merge.jsonc", dir} + args := []string{"--format=json", "--merge", "--config=testdata/e2e/merge.jsonc", dir} exitCode := run(t.Context(), args, &stdout, &stderr, emptyEnv) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) @@ -1612,7 +1612,7 @@ LABEL devcontainer.metadata='[{"privileged": true, "mounts": ["source=/var/run/d } var stdout, stderr bytes.Buffer - args := []string{"-format=json", "-merge", "-config=" + config, dir} + args := []string{"--format=json", "--merge", "--config=" + config, dir} exitCode := run(t.Context(), args, &stdout, &stderr, emptyEnv) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) @@ -1635,7 +1635,7 @@ LABEL devcontainer.metadata='[{"privileged": true, "mounts": ["source=/var/run/d `) var stdout, stderr bytes.Buffer - args := []string{"-format=json", "-merge", "-config=testdata/e2e/merge.jsonc", dir} + args := []string{"--format=json", "--merge", "--config=testdata/e2e/merge.jsonc", dir} exitCode := run(t.Context(), args, &stdout, &stderr, emptyEnv) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) @@ -1663,7 +1663,7 @@ LABEL devcontainer.metadata='[{"privileged": true, "mounts": ["source=/var/run/d writeComposeFile(t, dir, "services:\n app:\n image: registry.invalid/base:1\n") var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-merge", dir}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--merge", dir}, &stdout, &stderr, emptyEnv) if exitCode != 2 { t.Errorf("exit code = %d, want 2; stdout: %s", exitCode, stdout.String()) } @@ -1963,7 +1963,7 @@ func TestAbsPathString_NoWorkingDirectory(t *testing.T) { } } -// TestRun_Substitution checks that ${...} variables resolve only under -merge, since substitution +// TestRun_Substitution checks that ${...} variables resolve only under --merge, since substitution // and merging together compute the effective configuration: with merging on, no-image-latest // reports the resolved image reference; with merging off, it reports the raw ${localEnv:...} text. func TestRun_Substitution(t *testing.T) { @@ -1985,7 +1985,7 @@ func TestRun_Substitution(t *testing.T) { t.Fatal(err) } var stdout, stderr bytes.Buffer - exitCode := run(t.Context(), []string{"-format=json", "-config=" + path, dir}, &stdout, &stderr, emptyEnv) + exitCode := run(t.Context(), []string{"--format=json", "--config=" + path, dir}, &stdout, &stderr, emptyEnv) if exitCode != 1 { t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String()) } diff --git a/cmd/decolint/opts.go b/cmd/decolint/opts.go index d01ddc0..fe5787d 100644 --- a/cmd/decolint/opts.go +++ b/cmd/decolint/opts.go @@ -1,12 +1,13 @@ package main import ( - "flag" + "errors" "fmt" "io" "strings" "github.com/bare-devcontainer/decolint/linter" + "github.com/spf13/pflag" ) // Options holds the parsed command-line arguments. It is purely the CLI's view of the world; see @@ -14,33 +15,33 @@ import ( type Options struct { // Paths are the directories to lint, as named on the command line; runLint resolves them. Paths []string - // DenyWarnings mirrors [Config.DenyWarnings]. When -deny-warnings is explicitly given it takes + // DenyWarnings mirrors [Config.DenyWarnings]. When --deny-warnings is explicitly given it takes // precedence over the config file's "denyWarnings" member, in either direction (see // denyWarningsSet and mergeConfig). DenyWarnings bool - // denyWarningsSet records whether -deny-warnings was explicitly passed, so mergeConfig can tell + // denyWarningsSet records whether --deny-warnings was explicitly passed, so mergeConfig can tell // "not given" (defer to the config file) apart from "explicitly given as false" (override the // config file's "denyWarnings": true). denyWarningsSet bool - // ConfigPath is the raw -config flag value (empty if not given), resolved into a Config by + // ConfigPath is the raw --config flag value (empty if not given), resolved into a Config by // loadConfig. ConfigPath string // Platforms restricts registered rules to those targeting one of these platforms, plus any rule // with no target platform. If empty, only rules with no target platform are registered, unless // overridden by the config file's "platforms" member (see mergeConfig). Platforms []linter.Platform - // Merge mirrors [Config.Merge]. When -merge is explicitly given it takes precedence over the + // Merge mirrors [Config.Merge]. When --merge is explicitly given it takes precedence over the // config file's "merge" member, in either direction (see mergeSet and mergeConfig). Merge bool - // mergeSet records whether -merge was explicitly passed, so mergeConfig can tell "not given" + // mergeSet records whether --merge was explicitly passed, so mergeConfig can tell "not given" // (defer to the config file) apart from "explicitly given as false" (override the config file's // "merge": true). mergeSet bool - // Format is the raw -format flag value ("" if not given), naming how lint issues are written to + // Format is the raw --format flag value ("" if not given), naming how lint issues are written to // stdout: "text", "json", "github", or "sarif". A non-empty value replaces the config file's // "format" member; it is resolved into a Format by parseFormat in runLint. Format string - // Color is when the text output should be colored, from the -color flag. It is CLI-only: whether + // Color is when the text output should be colored, from the --color flag. It is CLI-only: whether // escape sequences can be rendered depends on where decolint runs, not on the project it lints. Color colorMode // Version, when set, causes the program to print its version and exit. @@ -59,29 +60,28 @@ func parseOptions(args []string, output io.Writer) (Options, error) { var formatFlag string var colorFlag string - fs := flag.NewFlagSet(progName, flag.ContinueOnError) + fs := pflag.NewFlagSet(progName, pflag.ContinueOnError) fs.SetOutput(output) fs.BoolVar(&opts.DenyWarnings, "deny-warnings", false, "treat warnings as failures (exit code 1); overrides the config file's \"denyWarnings\" member") - fs.StringVar(&opts.ConfigPath, "config", "", "path to a config file (default: auto-discover .decolint.jsonc or .decolint.json in the current directory)") - fs.StringVar(&platformFlag, "platform", "", "comma-separated target platforms to include in addition to \"all\" (vscode, codespaces); overrides the config file's \"platforms\" member") - fs.StringVar(&formatFlag, "format", "", "output format: text (default), json, github, or sarif; overrides the config file's \"format\" member") + fs.StringVarP(&opts.ConfigPath, "config", "c", "", "path to a config file (default: auto-discover .decolint.jsonc or .decolint.json in the current directory)") + fs.StringVarP(&platformFlag, "platform", "p", "", "comma-separated target platforms to include in addition to \"all\" (vscode, codespaces); overrides the config file's \"platforms\" member") + fs.StringVarP(&formatFlag, "format", "f", "", "output format: text (default), json, github, or sarif; overrides the config file's \"format\" member") fs.StringVar(&colorFlag, "color", "", "when to color the text output: auto (default; only when writing to a terminal), always, or never") - fs.BoolVar(&opts.Merge, "merge", false, "lint the merged (effective) configuration, including referenced Features and base image metadata; overrides the config file's \"merge\" member") - fs.BoolVar(&opts.Version, "version", false, "print version information and exit") + fs.BoolVarP(&opts.Merge, "merge", "m", false, "lint the merged (effective) configuration, including referenced Features and base image metadata; overrides the config file's \"merge\" member") + fs.BoolVarP(&opts.Version, "version", "v", false, "print version information and exit") fs.BoolVar(&opts.ListRules, "rules", false, "print the built-in rules as a Markdown table (category, target platforms, current severity), then exit") fs.BoolVar(&opts.Init, "init", false, "write a new .decolint.jsonc config file listing every rule at its default severity, then exit") fs.Usage = func() { _ = usage(fs) } if err := fs.Parse(args); err != nil { + // pflag leaves it to the caller to report a parse error, unlike --help, which it reports + // itself before returning [pflag.ErrHelp]. + if !errors.Is(err, pflag.ErrHelp) { + _ = usage(fs) + } return Options{}, fmt.Errorf("parse flags: %w", err) } - fs.Visit(func(f *flag.Flag) { - switch f.Name { - case "merge": - opts.mergeSet = true - case "deny-warnings": - opts.denyWarningsSet = true - } - }) + opts.mergeSet = fs.Changed("merge") + opts.denyWarningsSet = fs.Changed("deny-warnings") platforms, err := parsePlatforms(platformFlag) if err != nil { diff --git a/cmd/decolint/opts_test.go b/cmd/decolint/opts_test.go index b2c07d8..08bf967 100644 --- a/cmd/decolint/opts_test.go +++ b/cmd/decolint/opts_test.go @@ -19,20 +19,20 @@ func TestParseOptions_Platform(t *testing.T) { wantErr bool }{ {"no flag", nil, nil, false}, - {"single platform", []string{"-platform=vscode"}, []linter.Platform{linter.PlatformVSCode}, false}, + {"single platform", []string{"--platform=vscode"}, []linter.Platform{linter.PlatformVSCode}, false}, { "multiple platforms", - []string{"-platform=vscode,codespaces"}, + []string{"--platform=vscode,codespaces"}, []linter.Platform{linter.PlatformVSCode, linter.PlatformCodespaces}, false, }, - {"mixed case", []string{"-platform=VSCode"}, []linter.Platform{linter.PlatformVSCode}, false}, + {"mixed case", []string{"--platform=VSCode"}, []linter.Platform{linter.PlatformVSCode}, false}, // Empty entries from stray commas or surrounding whitespace are skipped, not rejected. - {"empty entries skipped", []string{"-platform=vscode, ,,codespaces"}, []linter.Platform{linter.PlatformVSCode, linter.PlatformCodespaces}, false}, - {"unknown platform", []string{"-platform=bogus"}, nil, true}, + {"empty entries skipped", []string{"--platform=vscode, ,,codespaces"}, []linter.Platform{linter.PlatformVSCode, linter.PlatformCodespaces}, false}, + {"unknown platform", []string{"--platform=bogus"}, nil, true}, { "combined with other flags and paths", - []string{"-deny-warnings", "-platform=vscode", "."}, + []string{"--deny-warnings", "--platform=vscode", "."}, []linter.Platform{linter.PlatformVSCode}, false, }, @@ -57,19 +57,19 @@ func TestParseOptions_Platform(t *testing.T) { func TestParseOptions_Format(t *testing.T) { t.Parallel() - // parseOptions captures the raw -format value verbatim; validation and resolution into a Format + // parseOptions captures the raw --format value verbatim; validation and resolution into a Format // happen later, in runLint, so both the flag and the config file's "format" member go through - // one path (an invalid value is rejected there, see TestRun_Flags/"invalid -format value"). + // one path (an invalid value is rejected there, see TestRun_Flags/"invalid --format value"). tests := []struct { name string args []string want string }{ {"no flag", nil, ""}, - {"text", []string{"-format=text"}, "text"}, - {"json", []string{"-format=json"}, "json"}, - {"github", []string{"-format=github"}, "github"}, - {"unrecognized value captured verbatim", []string{"-format=bogus"}, "bogus"}, + {"text", []string{"--format=text"}, "text"}, + {"json", []string{"--format=json"}, "json"}, + {"github", []string{"--format=github"}, "github"}, + {"unrecognized value captured verbatim", []string{"--format=bogus"}, "bogus"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -95,10 +95,10 @@ func TestParseOptions_Color(t *testing.T) { wantErr bool }{ {name: "no flag", want: colorAuto}, - {name: "auto", args: []string{"-color=auto"}, want: colorAuto}, - {name: "always", args: []string{"-color=always"}, want: colorAlways}, - {name: "never", args: []string{"-color=never"}, want: colorNever}, - {name: "unknown value is rejected", args: []string{"-color=bogus"}, wantErr: true}, + {name: "auto", args: []string{"--color=auto"}, want: colorAuto}, + {name: "always", args: []string{"--color=always"}, want: colorAlways}, + {name: "never", args: []string{"--color=never"}, want: colorNever}, + {name: "unknown value is rejected", args: []string{"--color=bogus"}, wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -117,11 +117,6 @@ func TestParseOptions_Color(t *testing.T) { } } -// dashPrefixes are the two ways a boolean flag can be spelled on the command line; the standard -// flag package accepts either, so every bare boolean flag is tested with both automatically instead -// of listing each variant as a separate table row. -var dashPrefixes = []string{"-", "--"} - func TestParseOptions_BoolFlags(t *testing.T) { t.Parallel() @@ -138,24 +133,22 @@ func TestParseOptions_BoolFlags(t *testing.T) { for _, tt := range tests { t.Run(tt.flag, func(t *testing.T) { t.Parallel() - for _, prefix := range dashPrefixes { - t.Run(prefix, func(t *testing.T) { - t.Parallel() - args := []string{prefix + tt.flag} - opts, err := parseOptions(args, io.Discard) - if err != nil { - t.Fatalf("parseOptions(%v): %v", args, err) - } - if !tt.get(opts) { - t.Errorf("%s = false, want true", tt.flag) - } - }) - } + t.Run("bare", func(t *testing.T) { + t.Parallel() + args := []string{"--" + tt.flag} + opts, err := parseOptions(args, io.Discard) + if err != nil { + t.Fatalf("parseOptions(%v): %v", args, err) + } + if !tt.get(opts) { + t.Errorf("%s = false, want true", tt.flag) + } + }) for _, want := range []bool{true, false} { name := fmt.Sprintf("=%v", want) t.Run(name, func(t *testing.T) { t.Parallel() - args := []string{fmt.Sprintf("-%s=%v", tt.flag, want)} + args := []string{fmt.Sprintf("--%s=%v", tt.flag, want)} opts, err := parseOptions(args, io.Discard) if err != nil { t.Fatalf("parseOptions(%v): %v", args, err) @@ -169,6 +162,38 @@ func TestParseOptions_BoolFlags(t *testing.T) { } } +// TestParseOptions_Shorthands checks that each shorthand sets the same field as its long flag, and +// that a shorthand taking a value accepts it as the following argument. +func TestParseOptions_Shorthands(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want Options + }{ + {"config", []string{"-c", "custom.jsonc"}, Options{ConfigPath: "custom.jsonc"}}, + {"format", []string{"-f", "json"}, Options{Format: "json"}}, + {"platform", []string{"-p", "vscode"}, Options{Platforms: []linter.Platform{linter.PlatformVSCode}}}, + {"merge", []string{"-m"}, Options{Merge: true, mergeSet: true}}, + {"version", []string{"-v"}, Options{Version: true}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + opts, err := parseOptions(tt.args, io.Discard) + if err != nil { + t.Fatalf("parseOptions(%v): %v", tt.args, err) + } + want := tt.want + want.Paths = []string{"."} + if diff := cmp.Diff(want, opts, cmp.AllowUnexported(Options{})); diff != "" { + t.Errorf("parseOptions(%v) mismatch (-want +got):\n%s", tt.args, diff) + } + }) + } +} + func TestParseOptions_MergeSet(t *testing.T) { t.Parallel() @@ -180,9 +205,9 @@ func TestParseOptions_MergeSet(t *testing.T) { want bool }{ {"no flag", nil, false}, - {"bare flag", []string{"-merge"}, true}, - {"explicit true", []string{"-merge=true"}, true}, - {"explicit false", []string{"-merge=false"}, true}, + {"bare flag", []string{"--merge"}, true}, + {"explicit true", []string{"--merge=true"}, true}, + {"explicit false", []string{"--merge=false"}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -202,7 +227,7 @@ func TestParseOptions_DenyWarningsSet(t *testing.T) { t.Parallel() // The value of DenyWarnings itself is covered by TestParseOptions_BoolFlags; this exercises - // denyWarningsSet, the bookkeeping that lets an explicit -deny-warnings=false override the config + // denyWarningsSet, the bookkeeping that lets an explicit --deny-warnings=false override the config // file's "denyWarnings": true (see its doc comment in opts.go). tests := []struct { name string @@ -210,9 +235,9 @@ func TestParseOptions_DenyWarningsSet(t *testing.T) { want bool }{ {"no flag", nil, false}, - {"bare flag", []string{"-deny-warnings"}, true}, - {"explicit true", []string{"-deny-warnings=true"}, true}, - {"explicit false", []string{"-deny-warnings=false"}, true}, + {"bare flag", []string{"--deny-warnings"}, true}, + {"explicit true", []string{"--deny-warnings=true"}, true}, + {"explicit false", []string{"--deny-warnings=false"}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -237,7 +262,7 @@ func TestParseOptions_Config(t *testing.T) { want string }{ {"no flag", nil, ""}, - {"config flag", []string{"-config=path/to/config.jsonc"}, "path/to/config.jsonc"}, + {"config flag", []string{"--config=path/to/config.jsonc"}, "path/to/config.jsonc"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/cmd/decolint/testdata/e2e/deny-warnings.jsonc b/cmd/decolint/testdata/e2e/deny-warnings.jsonc index 5aaf881..de1fa66 100644 --- a/cmd/decolint/testdata/e2e/deny-warnings.jsonc +++ b/cmd/decolint/testdata/e2e/deny-warnings.jsonc @@ -1,6 +1,6 @@ // Config for the e2e test: opts the security category in at warn severity and sets // "denyWarnings" so warnings alone cross the fail threshold, exercising the config-file -// member without the -deny-warnings flag. +// member without the --deny-warnings flag. { "categories": { "security": "warn" diff --git a/cmd/decolint/testdata/e2e/format-sarif.jsonc b/cmd/decolint/testdata/e2e/format-sarif.jsonc index 6fec8e0..9d29ac4 100644 --- a/cmd/decolint/testdata/e2e/format-sarif.jsonc +++ b/cmd/decolint/testdata/e2e/format-sarif.jsonc @@ -1,4 +1,4 @@ -// Config for the e2e test: a project whose lint-findings format is "sarif". -rules lists the rule +// Config for the e2e test: a project whose lint-findings format is "sarif". --rules lists the rule // catalog, not lint findings, so this member must not affect it. { "format": "sarif" diff --git a/cmd/decolint/testdata/e2e/format.jsonc b/cmd/decolint/testdata/e2e/format.jsonc index 4eebb8b..499edbf 100644 --- a/cmd/decolint/testdata/e2e/format.jsonc +++ b/cmd/decolint/testdata/e2e/format.jsonc @@ -1,5 +1,5 @@ // Config for the e2e test: selects the JSON output format and both platforms from the config -// file, to exercise the "format" member and the -format flag's precedence over it. +// file, to exercise the "format" member and the --format flag's precedence over it. { "platforms": ["vscode", "codespaces"], "format": "json" diff --git a/cmd/decolint/testdata/e2e/merge-on.jsonc b/cmd/decolint/testdata/e2e/merge-on.jsonc index d6ed250..30cddb6 100644 --- a/cmd/decolint/testdata/e2e/merge-on.jsonc +++ b/cmd/decolint/testdata/e2e/merge-on.jsonc @@ -1,5 +1,5 @@ // Same as merge.jsonc, but enables Feature merging via the config file instead of the -// -merge flag. +// --merge flag. { "merge": true, "rules": { diff --git a/cmd/decolint/testdata/e2e/merge.jsonc b/cmd/decolint/testdata/e2e/merge.jsonc index f58c864..b7257aa 100644 --- a/cmd/decolint/testdata/e2e/merge.jsonc +++ b/cmd/decolint/testdata/e2e/merge.jsonc @@ -1,5 +1,5 @@ // Enables the two security rules the merge fixture's Feature trips, so the e2e tests can assert -// they fire only when -merge is given. +// they fire only when --merge is given. { "rules": { "no-privileged-container": "error", diff --git a/cmd/decolint/testdata/e2e/platforms.jsonc b/cmd/decolint/testdata/e2e/platforms.jsonc index adb342a..9546996 100644 --- a/cmd/decolint/testdata/e2e/platforms.jsonc +++ b/cmd/decolint/testdata/e2e/platforms.jsonc @@ -1,5 +1,5 @@ // Config for the e2e test: selects target platforms from the config file instead of the -// -platform flag, to exercise the "platforms" member. +// --platform flag, to exercise the "platforms" member. { "platforms": ["vscode", "codespaces"] } diff --git a/cmd/decolint/testdata/e2e/security-warn.jsonc b/cmd/decolint/testdata/e2e/security-warn.jsonc index f205ec1..e0adabd 100644 --- a/cmd/decolint/testdata/e2e/security-warn.jsonc +++ b/cmd/decolint/testdata/e2e/security-warn.jsonc @@ -1,5 +1,5 @@ // Config for the e2e test: opts the security category in at warn severity, to exercise -// -deny-warnings against category-derived (rather than per-rule) severities. +// --deny-warnings against category-derived (rather than per-rule) severities. { "categories": { "security": "warn" diff --git a/cmd/dockerflagsgen/main.go b/cmd/dockerflagsgen/main.go index 624bff4..1dae842 100644 --- a/cmd/dockerflagsgen/main.go +++ b/cmd/dockerflagsgen/main.go @@ -9,7 +9,6 @@ package main import ( - "flag" "fmt" "go/format" "io" @@ -27,8 +26,8 @@ import ( const dockerCLIModule = "github.com/docker/cli" func main() { - out := flag.String("o", "", "write the table to this file instead of standard output") - flag.Parse() + out := pflag.StringP("output", "o", "", "write the table to this file instead of standard output") + pflag.Parse() if err := run(*out); err != nil { fmt.Fprintln(os.Stderr, "dockerflagsgen:", err) diff --git a/docs/content/getting-started.md b/docs/content/getting-started.md index 2eafd4a..0aa64f3 100644 --- a/docs/content/getting-started.md +++ b/docs/content/getting-started.md @@ -59,7 +59,7 @@ back clean: ```console $ decolint -Config: none (defaults; run "decolint -init" to create .decolint.jsonc) +Config: none (defaults; run "decolint --init" to create .decolint.jsonc) Linted 1 file: .devcontainer/devcontainer.json (devcontainer) @@ -105,8 +105,8 @@ right: ``` Every severity is `error`, `warn`, or `off`, and a `rules` entry beats its -category. Run `decolint -rules` to see every rule with the severity your -config gives it, or `decolint -init` to generate a config that lists all of +category. Run `decolint --rules` to see every rule with the severity your +config gives it, or `decolint --init` to generate a config that lists all of them explicitly. The full list is under [Rules](reference.md#rules), and everything the file accepts is under [Config file](reference.md#config-file). @@ -150,7 +150,7 @@ Linted 1 file: .devcontainer/devcontainer.json:1:1: error: neither "remoteUser" nor "containerUser" is set, so the container defaults to running as root (require-non-root) Found 1 error and 0 warnings. -$ decolint -merge . +$ decolint --merge . Downloading image metadata(mcr.microsoft.com/devcontainers/go:1.24@sha256:8de3d5b3a3ce235671c7649f0b910414158a220d18cbd2714a4446cc0cc6acd3) Config: .decolint.jsonc Linted 1 file: @@ -169,7 +169,7 @@ in nothing you wrote — the image disables seccomp and installs two unpinned VS Code extensions. Findings that come from merged content are reported at the property that pulled it in, here the `image` line. -Turn it on for good with `"merge": true`, or pass `-merge` per run. It fetches +Turn it on for good with `"merge": true`, or pass `--merge` per run. It fetches every referenced Feature and resolves the base image, so it needs network access; see [Merging](reference.md#merging) for what gets resolved and what does not. @@ -191,7 +191,7 @@ directives. ## Add it to CI decolint exits `1` when it reports an `error`, which is all a CI job needs to -fail. Add `-format=github` and the findings also appear as annotations on the +fail. Add `--format=github` and the findings also appear as annotations on the pull request diff: ```yaml @@ -208,12 +208,12 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - run: docker run --rm -v "$PWD:/workspace" ghcr.io/bare-devcontainer/decolint -format=github . + - run: docker run --rm -v "$PWD:/workspace" ghcr.io/bare-devcontainer/decolint --format=github . ``` Only findings become annotations; the files that were linted go to a collapsed group in the run log, so a clean file does not annotate the diff. Warnings do -not fail the build on their own; add `-deny-warnings` to make them count. See +not fail the build on their own; add `--deny-warnings` to make them count. See [Exit codes](reference.md#exit-codes). To have findings tracked as alerts in the repository's Security tab instead, @@ -230,7 +230,7 @@ scanning, so this job adds `security-events: write` to the permissions above: - uses: actions/checkout@v7 with: persist-credentials: false - - run: docker run --rm -v "$PWD:/workspace" ghcr.io/bare-devcontainer/decolint -format=sarif . > decolint.sarif + - run: docker run --rm -v "$PWD:/workspace" ghcr.io/bare-devcontainer/decolint --format=sarif . > decolint.sarif continue-on-error: true # decolint exits 1 on findings; the upload must still run - uses: github/codeql-action/upload-sarif@v4 with: @@ -256,7 +256,7 @@ the directory — these are `correctness` rules, so they need no configuration: ```console $ decolint src/go-tools -Config: none (defaults; run "decolint -init" to create .decolint.jsonc) +Config: none (defaults; run "decolint --init" to create .decolint.jsonc) Linted 1 file: src/go-tools/devcontainer-feature.json (feature) diff --git a/docs/content/reference.md b/docs/content/reference.md index 257eec4..8853edf 100644 --- a/docs/content/reference.md +++ b/docs/content/reference.md @@ -31,34 +31,34 @@ the config file when given: | Setting | Config member | Flag | | --- | --- | --- | -| [Target platforms](#target-platforms) | `platforms` | `-platform` | -| [Merge features](#merging) | `merge` | `-merge` | -| [Deny warnings](#exit-codes) | `denyWarnings` | `-deny-warnings` | -| [Output format](#output-formats) | `format` | `-format` | +| [Target platforms](#target-platforms) | `platforms` | `-p`, `--platform` | +| [Merge features](#merging) | `merge` | `-m`, `--merge` | +| [Deny warnings](#exit-codes) | `denyWarnings` | `--deny-warnings` | +| [Output format](#output-formats) | `format` | `-f`, `--format` | | [Category severities](#rule-categories) | `categories` | — | | [Rule severities](#rules) | `rules` | — | -`-merge` and `-deny-warnings` override in either direction when given -explicitly — e.g. `-merge=false` disables merging even if the config file sets +`--merge` and `--deny-warnings` override in either direction when given +explicitly — e.g. `--merge=false` disables merging even if the config file sets `"merge": true`. Category and rule severities are config-file only, and -[color](#color) is set by the `-color` flag only. +[color](#color) is set by the `--color` flag only. -The remaining flags perform a one-off action and exit; run `decolint -help` +The remaining flags perform a one-off action and exit; run `decolint --help` for the full list: | Flag | Action | | --- | --- | -| `-config ` | use the config file at `` instead of [auto-discovery](#config-file) | -| `-init` | write a new `.decolint.jsonc` listing every rule at its default severity | -| `-rules` | print the built-in rules as a Markdown table | -| `-version` | print version information | -| `-help` | print usage | +| `-c`, `--config ` | use the config file at `` instead of [auto-discovery](#config-file) | +| `--init` | write a new `.decolint.jsonc` listing every rule at its default severity | +| `--rules` | print the built-in rules as a Markdown table | +| `-v`, `--version` | print version information | +| `-h`, `--help` | print usage | ## Config file decolint looks for `.decolint.jsonc`, then `.decolint.json`, in the current -directory; the first one found is used. Pass `-config ` to use a file at -a different location instead. It is an error (exit code 2) if `-config` points +directory; the first one found is used. Pass `--config ` to use a file at +a different location instead. It is an error (exit code 2) if `--config` points at a file that doesn't exist or fails to parse, or if the config references an unknown rule ID or category name. @@ -98,11 +98,11 @@ The remaining members mirror their flags: `platforms`, `merge`, Each rule optionally targets specific platforms (`vscode`, `codespaces`); a rule with no target platform applies to every platform and always runs. By -default, only those platform-agnostic rules run; pass `-platform` with a +default, only those platform-agnostic rules run; pass `--platform` with a comma-separated list to also run rules scoped to specific platforms: ```console -decolint -platform=vscode,codespaces +decolint --platform=vscode,codespaces ``` ## Merging @@ -119,7 +119,7 @@ bind-mounts the Docker socket) goes unnoticed. Enable merging to lint the merged configuration instead: ```console -decolint -merge +decolint --merge ``` This fetches every referenced Feature and resolves the base image, including @@ -218,14 +218,14 @@ terminal, and writes plain text otherwise — so a report piped into another command, or redirected to a file, stays free of escape sequences. The other formats are never colored. -Pass `-color` to decide instead of leaving it to the terminal: +Pass `--color` to decide instead of leaving it to the terminal: ```console -decolint -color=always | less -R # always color -decolint -color=never # never color +decolint --color=always | less -R # always color +decolint --color=never # never color ``` -Those two decide on their own. Under the default `-color=auto`, the `NO_COLOR` +Those two decide on their own. Under the default `--color=auto`, the `NO_COLOR` and `FORCE_COLOR` environment variables apply instead: set `NO_COLOR` to any non-empty value to turn color off, or `FORCE_COLOR` to color output that does not go to a terminal, such as a CI log — `FORCE_COLOR=0` turns color off @@ -237,7 +237,7 @@ instead. `NO_COLOR` wins over `FORCE_COLOR`. - `1` — at least one `error`-severity finding was reported - `2` — an error occurred (e.g. a file could not be parsed) -Enable deny-warnings (the `-deny-warnings` flag or `"denyWarnings": true`) to +Enable deny-warnings (the `--deny-warnings` flag or `"denyWarnings": true`) to also fail (exit code 1) on `warn`-severity findings. Exit codes are unaffected by the output format. @@ -252,7 +252,7 @@ platforms. The [rule reference](https://bare-devcontainer.github.io/decolint/rules/) lists every rule by category, and each rule's page covers why it exists, the configuration it accepts and rejects, and the specification it is based on. Run -`decolint -rules` to print the same list with the severity your configuration +`decolint --rules` to print the same list with the severity your configuration gives each rule. The [SARIF output](#output-formats) links every rule it reports to its page, so diff --git a/docs/content/rules/_index.md b/docs/content/rules/_index.md index 8c127d9..c211660 100644 --- a/docs/content/rules/_index.md +++ b/docs/content/rules/_index.md @@ -7,4 +7,4 @@ description: >- Each rule belongs to one category, which sets its severity unless a [config file](../getting-started.md#2-turn-on-the-checks-you-want) overrides it; only `correctness` is enabled out of the box. A rule that names a platform -runs only when that platform is selected with `-platform`. +runs only when that platform is selected with `--platform`. diff --git a/format/text.go b/format/text.go index 6a39336..905ec58 100644 --- a/format/text.go +++ b/format/text.go @@ -95,7 +95,7 @@ func writeSummary(w io.Writer, st styler, numErrors, numWarnings int) error { func writeConfigLine(w io.Writer, st styler, path string) error { line := st.bold("Config:") + " " + path if path == "" { - line = st.bold("Config:") + " " + st.dim(`none (defaults; run "decolint -init" to create .decolint.jsonc)`) + line = st.bold("Config:") + " " + st.dim(`none (defaults; run "decolint --init" to create .decolint.jsonc)`) } if _, err := fmt.Fprintln(w, line); err != nil { return fmt.Errorf("write config line: %w", err) diff --git a/format/text_test.go b/format/text_test.go index b25a5bb..a463910 100644 --- a/format/text_test.go +++ b/format/text_test.go @@ -115,7 +115,7 @@ Found 1 error and 1 warning. report: Report{ Files: []File{{Path: ".devcontainer.json", Type: linter.Devcontainer}}, }, - want: `Config: none (defaults; run "decolint -init" to create .decolint.jsonc) + want: `Config: none (defaults; run "decolint --init" to create .decolint.jsonc) Linted 1 file: .devcontainer.json (devcontainer) @@ -187,7 +187,7 @@ func TestTextWriteReport_Color(t *testing.T) { name: "a clean run is summarized in green", report: Report{Files: []File{{Path: ".devcontainer.json", Type: linter.Devcontainer}}}, want: []string{ - ansiBold + "Config:" + ansiOff + " " + ansiDim + `none (defaults; run "decolint -init" to create .decolint.jsonc)` + ansiOff, + ansiBold + "Config:" + ansiOff + " " + ansiDim + `none (defaults; run "decolint --init" to create .decolint.jsonc)` + ansiOff, ansiBold + "Linted 1 file:" + ansiOff, " .devcontainer.json " + ansiDim + "(devcontainer)" + ansiOff, "", diff --git a/go.mod b/go.mod index 1ce4b78..91d6c2b 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/olareg/olareg v0.2.2 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 + github.com/spf13/pflag v1.0.10 github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd golang.org/x/sys v0.47.0 golang.org/x/term v0.45.0 @@ -177,7 +178,6 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/fsync v0.10.1 // indirect - github.com/spf13/pflag v1.0.10 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/sudo-bmitch/oci-digest v0.1.2 // indirect github.com/tdewolff/minify/v2 v2.24.13 // indirect diff --git a/linter/rule.go b/linter/rule.go index c91fadb..4814b2a 100644 --- a/linter/rule.go +++ b/linter/rule.go @@ -32,7 +32,7 @@ const ( PlatformCodespaces ) -// String returns the platform's name, as used in the -platform flag and in output. +// String returns the platform's name, as used in the ---platform flag and in output. func (p Platform) String() string { switch p { case PlatformVSCode: From 61be293064a9970c3aab4751687a0461af4f3349 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 09:16:17 +0000 Subject: [PATCH 2/3] chore(cli): drop comments explaining pflag itself Also fix two "---platform" typos left by the flag renaming. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QFbEHHjGNp2bM4AKrXjwFa --- cmd/decolint/init.go | 2 +- cmd/decolint/opts.go | 2 -- cmd/decolint/opts_test.go | 2 -- linter/rule.go | 2 +- 4 files changed, 2 insertions(+), 6 deletions(-) diff --git a/cmd/decolint/init.go b/cmd/decolint/init.go index 6043b21..18cb09c 100644 --- a/cmd/decolint/init.go +++ b/cmd/decolint/init.go @@ -35,7 +35,7 @@ func initConfigFile(output io.Writer) error { // under "categories"; per-rule entries take precedence, e.g.: // "categories": { "security": "error" } // "platforms" lists target platforms whose rules run in addition to -// platform-agnostic ones (the ---platform flag takes precedence), e.g.: +// platform-agnostic ones (the --platform flag takes precedence), e.g.: // "platforms": ["vscode", "codespaces"] // "merge", when true, fetches the Features referenced in each // devcontainer.json and lints the merged (effective) configuration, e.g.: diff --git a/cmd/decolint/opts.go b/cmd/decolint/opts.go index fe5787d..dd0b54e 100644 --- a/cmd/decolint/opts.go +++ b/cmd/decolint/opts.go @@ -73,8 +73,6 @@ func parseOptions(args []string, output io.Writer) (Options, error) { fs.BoolVar(&opts.Init, "init", false, "write a new .decolint.jsonc config file listing every rule at its default severity, then exit") fs.Usage = func() { _ = usage(fs) } if err := fs.Parse(args); err != nil { - // pflag leaves it to the caller to report a parse error, unlike --help, which it reports - // itself before returning [pflag.ErrHelp]. if !errors.Is(err, pflag.ErrHelp) { _ = usage(fs) } diff --git a/cmd/decolint/opts_test.go b/cmd/decolint/opts_test.go index 08bf967..66bbede 100644 --- a/cmd/decolint/opts_test.go +++ b/cmd/decolint/opts_test.go @@ -162,8 +162,6 @@ func TestParseOptions_BoolFlags(t *testing.T) { } } -// TestParseOptions_Shorthands checks that each shorthand sets the same field as its long flag, and -// that a shorthand taking a value accepts it as the following argument. func TestParseOptions_Shorthands(t *testing.T) { t.Parallel() diff --git a/linter/rule.go b/linter/rule.go index 4814b2a..707ffa9 100644 --- a/linter/rule.go +++ b/linter/rule.go @@ -32,7 +32,7 @@ const ( PlatformCodespaces ) -// String returns the platform's name, as used in the ---platform flag and in output. +// String returns the platform's name, as used in the --platform flag and in output. func (p Platform) String() string { switch p { case PlatformVSCode: From 03844d65c639dd891737508bbc144ce7d56e47c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 09:19:46 +0000 Subject: [PATCH 3/3] docs: correct parseOptions' contract and the make run example pflag returns a parse error rather than writing it to the flag set's output, so parseOptions writes only the usage text there. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QFbEHHjGNp2bM4AKrXjwFa --- CONTRIBUTING.md | 2 +- cmd/decolint/opts.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6da3c05..ba780aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ needs `GOEXPERIMENT=jsonv2`. The [Makefile](Makefile) sets it for you: make build # build ./bin/decolint make test # go test ./... make lint # golangci-lint -make run ARGS="-format=json path/to/dir" +make run ARGS="--format=json path/to/dir" ``` The documentation site in [`docs/`](docs/) is built with diff --git a/cmd/decolint/opts.go b/cmd/decolint/opts.go index dd0b54e..b101f3b 100644 --- a/cmd/decolint/opts.go +++ b/cmd/decolint/opts.go @@ -53,7 +53,8 @@ type Options struct { Init bool } -// parseOptions parses args into Options. Flag errors and usage text are written to output. +// parseOptions parses args into Options. Usage text is written to output; an error is returned for +// the caller to report. func parseOptions(args []string, output io.Writer) (Options, error) { var opts Options var platformFlag string