From 549e45d0b42e2308ca84b6b8a951cb80151c62f4 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 3 Jun 2026 18:39:49 -0500 Subject: [PATCH 1/2] feat(init): interactive huh wizard + .license-tool.yaml scaffolding --- .testcoverage.yml | 11 +- cmd/license-tool/commands.go | 74 +++++++++- cmd/license-tool/commands_test.go | 128 +++++++++++++++-- cmd/license-tool/wizard.go | 114 +++++++++++++++ go.mod | 27 ++++ go.sum | 75 ++++++++++ internal/config/config.go | 82 +++++++++++ internal/config/config_test.go | 213 ++++++++++++++++++++++++++++ internal/spdx/spdx.go | 52 +++++++ internal/spdx/spdx_internal_test.go | 2 + internal/spdx/spdx_test.go | 32 +++++ 11 files changed, 793 insertions(+), 17 deletions(-) create mode 100644 cmd/license-tool/wizard.go diff --git a/.testcoverage.yml b/.testcoverage.yml index 54b9734..0a73db0 100644 --- a/.testcoverage.yml +++ b/.testcoverage.yml @@ -15,7 +15,7 @@ threshold: package: 0 total: 100 -# Documented exclusions. These two files are the ONLY code excluded from the 100% +# Documented exclusions. These files are the ONLY code excluded from the 100% # coverage requirement, and each is excluded for a concrete, defensible reason: # # 1. cmd/license-tool/main.go @@ -32,7 +32,16 @@ threshold: # vendored data/ artifacts. The ignore tag already removes it from the normal # build and from coverage instrumentation; it is listed here for documentation # and defense-in-depth. It is not part of the shipped binary. +# +# 3. cmd/license-tool/wizard.go +# The interactive init wizard is a thin huh-backed TUI shell: it builds the form, +# binds answers, and runs the terminal event loop (form.Run). Like the main() +# shim, it has no business logic of its own -- all validation and parsing live in +# answersToConfig (in commands.go) and the config/spdx packages, which ARE +# covered. Driving a real huh form requires an interactive PTY that an in-process +# unit test cannot provide, so it is excluded for the same reason as main.go. exclude: paths: - ^cmd/license-tool/main\.go$ - ^scripts/gen_spdx\.go$ + - ^cmd/license-tool/wizard\.go$ diff --git a/cmd/license-tool/commands.go b/cmd/license-tool/commands.go index 283d20c..7add36f 100644 --- a/cmd/license-tool/commands.go +++ b/cmd/license-tool/commands.go @@ -251,19 +251,83 @@ func bindApplyFlags(cmd *cobra.Command, f *applyFlags) { cmd.Flags().StringVar(&f.commitMessage, "commit-message", "", "commit message template (with --commit)") } +// interactiveCollect is a package-level seam over collectInteractive so tests can +// drive the init command's non-interactive flow (and inject answers) without a real +// terminal. Production always points at the huh-backed collectInteractive. +var interactiveCollect = collectInteractive + +// answersToConfig validates and converts collected init answers into a model.Config, +// starting from the built-in Defaults so unset answers carry the documented default +// behavior. WHY validation lives here, not in the wizard: the wizard is the +// interactive shell (excluded from coverage); answersToConfig is the single tested +// gate that both the TTY and flag-only paths funnel through, so an invalid license +// or empty holder is rejected identically regardless of how the answers arrived. +func answersToConfig(a initAnswers) (model.Config, error) { + if !spdx.Validate(a.License) { + return model.Config{}, fmt.Errorf("init: %q is not a recognized SPDX license identifier", a.License) + } + if a.Holder == "" { + return model.Config{}, fmt.Errorf("init: copyright holder is required") + } + cfg := config.Defaults() + cfg.License = a.License + cfg.Holder = a.Holder + if a.Year != "" { + ys, err := config.ParseYearSpec(a.Year) + if err != nil { + return model.Config{}, err + } + cfg.Year = ys + } + if a.Style != "" { + st, err := config.ParseStyle(a.Style) + if err != nil { + return model.Config{}, err + } + cfg.Style = st + } + cfg.ManageLicenseFile = a.ManageLicenseFile + cfg.Excludes = a.Excludes + return cfg, nil +} + func newInitCmd(shared *sharedFlags) *cobra.Command { - return &cobra.Command{ + f := &applyFlags{} + cmd := &cobra.Command{ Use: "init [path]", Short: "Scaffold a .license-tool.yaml (interactive on a TTY)", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { path := argPath(args) - // init scaffolds config; the config feature agent owns the body. We - // surface a clear not-yet-implemented error rather than silently no-op. - _, err := config.Resolve(path, sharedToFlags(shared), config.Options{Interactive: isTTY(), RequireApply: true}) - return err + out := cmd.OutOrStdout() + a := initAnswers{ + License: f.license, + Holder: f.holder, + Year: f.year, + Style: f.style, + ManageLicenseFile: true, + Excludes: shared.exclude, + } + if err := interactiveCollect(&a, isTTY()); err != nil { + return err + } + cfg, err := answersToConfig(a) + if err != nil { + return err + } + target, err := config.WriteFile(path, cfg, f.force) + if err != nil { + return err + } + fmt.Fprintf(out, "wrote %s\n", target) + return nil }, } + // bindApplyFlags already registers --force (bound to f.force); init reuses that + // same flag to mean "overwrite an existing .license-tool.yaml", so we do not + // re-register it here (cobra panics on a duplicate flag name). + bindApplyFlags(cmd, f) + return cmd } func newVersionCmd(info buildInfo) *cobra.Command { diff --git a/cmd/license-tool/commands_test.go b/cmd/license-tool/commands_test.go index 1358c33..9ced979 100644 --- a/cmd/license-tool/commands_test.go +++ b/cmd/license-tool/commands_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "errors" "os" "os/exec" "path/filepath" @@ -12,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "github.com/KofTwentyTwo/license-tool/internal/config" + "github.com/KofTwentyTwo/license-tool/internal/model" "github.com/KofTwentyTwo/license-tool/internal/spdx" ) @@ -442,22 +444,126 @@ func TestLicenseCommand(t *testing.T) { func TestInitCommand(t *testing.T) { isolateEnv(t) - t.Run("complete config succeeds", func(t *testing.T) { - // A repo whose .license-tool.yaml already supplies license+holder satisfies - // the required fields without prompting, so init returns nil. - dir := fixtureDir(t) - out, err := runRoot(t, "init", dir) + t.Run("flags scaffold config file", func(t *testing.T) { + // Non-TTY stdin (forced by isolateEnv) skips the wizard, so init scaffolds a + // .license-tool.yaml purely from the --license/--holder flags. + dir := t.TempDir() + out, err := runRoot(t, "init", dir, "--license", "MIT", "--holder", "Acme") require.NoError(t, err) - assert.Empty(t, out) + target := filepath.Join(dir, ".license-tool.yaml") + assert.Contains(t, out, "wrote "+target) + // The scaffold must round-trip back through the config loader. + cfg, lerr := config.LoadFile(target) + require.NoError(t, lerr) + assert.Equal(t, "MIT", cfg.License) + assert.Equal(t, "Acme", cfg.Holder) }) - t.Run("missing required fields errors", func(t *testing.T) { - // No config and a non-TTY stdin: fillRequired hard-errors rather than hanging. + t.Run("missing license errors", func(t *testing.T) { + // No --license flag and a non-TTY stdin: answersToConfig rejects the empty + // license rather than writing a header with no identity. dir := t.TempDir() - writeFile(t, dir, "main.go", "package main\n") - _, err := runRoot(t, "init", dir) - require.ErrorIs(t, err, config.ErrMissingRequired) + _, err := runRoot(t, "init", dir, "--holder", "Acme") + require.Error(t, err) + assert.Contains(t, err.Error(), "SPDX license identifier") }) + + t.Run("existing file needs force", func(t *testing.T) { + dir := t.TempDir() + _, err := runRoot(t, "init", dir, "--license", "MIT", "--holder", "Acme") + require.NoError(t, err) + // A second run without --force must refuse to clobber the committed config. + _, err = runRoot(t, "init", dir, "--license", "Apache-2.0", "--holder", "Other") + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + // With --force it overwrites in place. + _, err = runRoot(t, "init", dir, "--license", "Apache-2.0", "--holder", "Other", "--force") + require.NoError(t, err) + cfg, lerr := config.LoadFile(filepath.Join(dir, ".license-tool.yaml")) + require.NoError(t, lerr) + assert.Equal(t, "Apache-2.0", cfg.License) + }) +} + +// TestAnswersToConfig drives the single tested gate that both the TTY wizard and +// the flag-only path funnel through: the valid case plus each rejection (unknown +// license, empty holder, bad year, bad style). WHY exhaustive here: the wizard shell +// is excluded from coverage, so this is the only place the validation/parse arms are +// exercised, and they must reject identically regardless of how answers arrived. +func TestAnswersToConfig(t *testing.T) { + t.Run("valid answers build a config from defaults", func(t *testing.T) { + cfg, err := answersToConfig(initAnswers{ + License: "MIT", + Holder: "Acme, LLC", + Year: "2021-2026", + Style: "reuse", + ManageLicenseFile: false, + Excludes: []string{"**/vendor/**"}, + }) + require.NoError(t, err) + assert.Equal(t, "MIT", cfg.License) + assert.Equal(t, "Acme, LLC", cfg.Holder) + assert.Equal(t, model.YearRange, cfg.Year.Kind) + assert.Equal(t, 2021, cfg.Year.Start) + assert.Equal(t, 2026, cfg.Year.End) + assert.Equal(t, model.StyleReuse, cfg.Style) + assert.False(t, cfg.ManageLicenseFile) + assert.Equal(t, []string{"**/vendor/**"}, cfg.Excludes) + }) + + t.Run("empty year and style keep the built-in defaults", func(t *testing.T) { + // Unset year/style answers must leave the Defaults() values untouched, so the + // year/style parse branches are skipped (not defaulted to a parse of ""). + cfg, err := answersToConfig(initAnswers{License: "MIT", Holder: "Acme"}) + require.NoError(t, err) + assert.Equal(t, model.YearGit, cfg.Year.Kind) + assert.Equal(t, model.StyleReusePlusNotice, cfg.Style) + }) + + t.Run("unknown license rejected", func(t *testing.T) { + _, err := answersToConfig(initAnswers{License: "NOT-A-LICENSE", Holder: "Acme"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a recognized SPDX license identifier") + }) + + t.Run("empty holder rejected", func(t *testing.T) { + _, err := answersToConfig(initAnswers{License: "MIT", Holder: ""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "copyright holder is required") + }) + + t.Run("bad year rejected", func(t *testing.T) { + _, err := answersToConfig(initAnswers{License: "MIT", Holder: "Acme", Year: "not-a-year"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "year") + }) + + t.Run("bad style rejected", func(t *testing.T) { + _, err := answersToConfig(initAnswers{License: "MIT", Holder: "Acme", Style: "fancy"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown style") + }) +} + +// TestInitCommandInteractiveCollectError covers the init RunE branch where the +// interactiveCollect seam returns an error: the command must propagate it verbatim +// and write nothing. The seam is overridden so the failure is deterministic without +// a real terminal (the huh wizard itself is excluded from coverage). +func TestInitCommandInteractiveCollectError(t *testing.T) { + isolateEnv(t) + + orig := interactiveCollect + wantErr := errors.New("wizard aborted") + interactiveCollect = func(_ *initAnswers, _ bool) error { return wantErr } + t.Cleanup(func() { interactiveCollect = orig }) + + dir := t.TempDir() + _, err := runRoot(t, "init", dir, "--license", "MIT", "--holder", "Acme") + require.ErrorIs(t, err, wantErr, "init must propagate the interactiveCollect error") + + // The collect failure must abort before any file is written. + _, statErr := os.Stat(filepath.Join(dir, ".license-tool.yaml")) + assert.True(t, os.IsNotExist(statErr), "no config should be written when collect fails") } func TestSharedToFlags(t *testing.T) { diff --git a/cmd/license-tool/wizard.go b/cmd/license-tool/wizard.go new file mode 100644 index 0000000..4955cda --- /dev/null +++ b/cmd/license-tool/wizard.go @@ -0,0 +1,114 @@ +package main + +import ( + "errors" + + "github.com/charmbracelet/huh" + + "github.com/KofTwentyTwo/license-tool/internal/config" + "github.com/KofTwentyTwo/license-tool/internal/spdx" +) + +// errEmptyHolder is the in-form validation error shown when the holder is blank. A +// non-empty holder is required to render a meaningful copyright line, so the wizard +// refuses to advance until one is entered. +var errEmptyHolder = errors.New("copyright holder is required") + +// initAnswers holds the raw string answers collected by the interactive init +// wizard, before they are validated and parsed into a model.Config. WHY raw strings +// (not a model.Config): the form binds directly to these fields, and answersToConfig +// owns the single validation/parse pass, so the wizard stays a thin I/O shell with +// no business logic (this file is excluded from coverage for exactly that reason). +type initAnswers struct { + License string + Holder string + Year string + Style string + ManageLicenseFile bool + Excludes []string +} + +// licenseSelectOptions builds the filterable license picker options: the curated +// CommonIDs first (each labeled " (common)" so a user knows they are the +// recommended set), then the remaining non-deprecated SPDX ids in sorted order with +// no duplicates. WHY common-first: the operator sees sensible defaults at the top +// before the long alphabetical tail they can still filter into. +func licenseSelectOptions() []huh.Option[string] { + common := spdx.CommonIDs() + seen := make(map[string]bool, len(common)) + opts := make([]huh.Option[string], 0, len(common)) + for _, id := range common { + seen[id] = true + opts = append(opts, huh.NewOption(id+" (common)", id)) + } + for _, id := range spdx.IDs() { + if seen[id] { + continue + } + opts = append(opts, huh.NewOption(id, id)) + } + return opts +} + +// runInitWizard drives the interactive init form, binding each answer into a. WHY +// the Year/License validators reuse the config parsers: the wizard must reject the +// same inputs answersToConfig would later reject, so the user fixes a bad value +// in-place rather than getting a post-submit error. +func runInitWizard(a *initAnswers) error { + // Pre-populate the year field with the safe "git" default so an operator who just + // presses enter accepts the same default the config layer would have applied. + if a.Year == "" { + a.Year = "git" + } + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Target SPDX license"). + Options(licenseSelectOptions()...). + Filtering(true). + Value(&a.License), + huh.NewInput(). + Title("Copyright holder"). + Value(&a.Holder). + Validate(func(s string) error { + if s == "" { + return errEmptyHolder + } + return nil + }), + huh.NewInput(). + Title("Year policy (current|YYYY|YYYY-YYYY|git)"). + Value(&a.Year). + Validate(func(s string) error { + if s == "" { + return nil + } + _, err := config.ParseYearSpec(s) + return err + }), + huh.NewSelect[string](). + Title("Header style"). + Options( + huh.NewOption("reuse", "reuse"), + huh.NewOption("notice", "notice"), + huh.NewOption("reuse+notice", "reuse+notice"), + ). + Value(&a.Style), + huh.NewConfirm(). + Title("Manage top-level LICENSE file?"). + Value(&a.ManageLicenseFile), + ), + ) + return form.Run() +} + +// collectInteractive runs the wizard to populate a when interactive is true, +// otherwise it is a no-op (the non-TTY path relies entirely on flags). WHY a seam: +// commands.go swaps this out in tests so the init command's non-interactive flow can +// be exercised without a terminal. +func collectInteractive(a *initAnswers, interactive bool) error { + if !interactive { + return nil + } + return runInitWizard(a) +} diff --git a/go.mod b/go.mod index 387ebec..d4ca7f5 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/KofTwentyTwo/license-tool go 1.24 require ( + github.com/charmbracelet/huh v1.0.0 github.com/github/go-spdx/v2 v2.7.0 github.com/google/licensecheck v0.3.1 github.com/hexops/gotextdiff v1.0.3 @@ -13,8 +14,34 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.23.0 // indirect ) diff --git a/go.sum b/go.sum index ae74724..6245a84 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,51 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/github/go-spdx/v2 v2.7.0 h1:GzfXx4wFdlilARxmFRXW/mgUy3A4vSqZocCMFV6XFdQ= github.com/github/go-spdx/v2 v2.7.0/go.mod h1:Ftc45YYG1WzpzwEPKRVm9Jv8vDqOrN4gWoCkK+bHer0= github.com/google/licensecheck v0.3.1 h1:QoxgoDkaeC4nFrtGN1jV7IPmDCHFNIVh54e5hSt6sPs= @@ -10,8 +54,27 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= @@ -23,7 +86,19 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index 1b2561c..c2d17c2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -188,6 +188,88 @@ func LoadFile(filename string) (model.Config, error) { return cfg, nil } +// RenderFile serializes cfg into the on-disk .license-tool.yaml byte form. It is +// pure (no I/O) so callers can preview the scaffold or test the exact bytes without +// touching the filesystem. WHY it mirrors fileSchema rather than marshaling the +// model directly: the YAML keys, the *bool nullability of manage_license_file, and +// the string tokenization of enums (style, year, fail_on) are the documented file +// contract, and round-tripping through fileSchema keeps init's output identical to +// what mergeSchema reads back. +func RenderFile(cfg model.Config) ([]byte, error) { + fs := fileSchema{ + License: cfg.License, + Holder: cfg.Holder, + Year: yearSpecRaw(cfg.Year), + Style: cfg.Style.String(), + ManageLicenseFile: &cfg.ManageLicenseFile, + Exclude: cfg.Excludes, + } + // Only emit a policy block when the config actually carries policy intent, so a + // freshly scaffolded file is not cluttered with an empty policy the user did not set. + if cfg.Policy.Required != "" || len(cfg.Policy.Allow) > 0 || len(cfg.Policy.Deny) > 0 || len(cfg.Policy.FailOn) > 0 { + fs.Policy = policySchema{ + Required: cfg.Policy.Required, + Allow: cfg.Policy.Allow, + Deny: cfg.Policy.Deny, + FailOn: failConditionsToTokens(cfg.Policy.FailOn), + } + } + return yaml.Marshal(fs) +} + +// renderFile is a package-level seam over RenderFile so WriteFile's render-error +// branch is reachable in tests. RenderFile cannot fail for any real model.Config +// (yaml.Marshal of the fixed fileSchema is total), so the only way to exercise the +// error path is to inject a failing renderer here. Production always points at the +// real RenderFile, leaving runtime behavior unchanged. +var renderFile = RenderFile + +// WriteFile renders cfg and writes it to /.license-tool.yaml, returning the +// written target path. WHY the force guard: init must never silently clobber a +// committed config, so an existing target is a hard error unless the caller opted in +// with force (the --force flag). The 0o644 mode matches a normal committed text file. +func WriteFile(path string, cfg model.Config, force bool) (string, error) { + target := filepath.Join(path, repoConfigName) + if !force && fileExists(target) { + return "", fmt.Errorf("config: %s already exists (use --force)", target) + } + data, err := renderFile(cfg) + if err != nil { + return "", err + } + if err := os.WriteFile(target, data, 0o644); err != nil { + return "", fmt.Errorf("config: write %s: %w", target, err) + } + return target, nil +} + +// yearSpecRaw renders a YearSpec back to its config token, the inverse of +// ParseYearSpec. WHY YearGit is the default arm: it is the built-in default Kind, so +// any unrecognized/zero-value spec serializes to the safe "git" token rather than an +// empty string that would round-trip back to a parse error. +func yearSpecRaw(y model.YearSpec) string { + switch y.Kind { + case model.YearCurrent: + return "current" + case model.YearExplicit: + return strconv.Itoa(y.Start) + case model.YearRange: + return strconv.Itoa(y.Start) + "-" + strconv.Itoa(y.End) + default: // model.YearGit and any zero value. + return "git" + } +} + +// failConditionsToTokens renders fail conditions back to their config tokens, the +// inverse of parseFailConditions, so a scaffolded fail_on list round-trips exactly. +func failConditionsToTokens(fcs []model.FailCondition) []string { + out := make([]string, 0, len(fcs)) + for _, fc := range fcs { + out = append(out, fc.String()) + } + return out +} + // loadSchema reads and decodes a YAML config file into the on-disk schema. func loadSchema(filename string) (fileSchema, error) { data, err := os.ReadFile(filename) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 51faf0f..088d525 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -901,6 +901,219 @@ func TestPromptEOFWithPartialLine(t *testing.T) { } } +// TestYearSpecRaw drives every arm of yearSpecRaw, the inverse of ParseYearSpec, so +// a scaffolded year token round-trips back to the same YearSpec. The zero-value case +// proves the default arm folds any unrecognized/zero spec to the safe "git" token +// rather than an empty string that would round-trip into a parse error. +func TestYearSpecRaw(t *testing.T) { + tests := []struct { + name string + spec model.YearSpec + want string + }{ + {"current", model.YearSpec{Kind: model.YearCurrent}, "current"}, + {"explicit", model.YearSpec{Kind: model.YearExplicit, Start: 2026}, "2026"}, + {"range", model.YearSpec{Kind: model.YearRange, Start: 2021, End: 2026}, "2021-2026"}, + {"git (the default arm)", model.YearSpec{Kind: model.YearGit}, "git"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := yearSpecRaw(tt.spec) + if got != tt.want { + t.Errorf("yearSpecRaw(%+v) = %q, want %q", tt.spec, got, tt.want) + } + // Each token must round-trip back through ParseYearSpec to the same spec, + // proving render/parse are true inverses for the picker's tokens. + rt, err := ParseYearSpec(got) + if err != nil { + t.Fatalf("ParseYearSpec(%q) round-trip error: %v", got, err) + } + if rt != tt.spec { + t.Errorf("round-trip %q = %+v, want %+v", got, rt, tt.spec) + } + }) + } +} + +// TestRenderFile confirms RenderFile produces bytes that decode back through LoadFile +// to the same effective fields, and that the policy block is emitted only when the +// config actually carries policy intent (so a freshly scaffolded file is not +// cluttered with an empty policy the user never set). +func TestRenderFile(t *testing.T) { + t.Run("round-trips through LoadFile with policy", func(t *testing.T) { + cfg := Defaults() + cfg.License = "AGPL-3.0-or-later" + cfg.Holder = "Kingsrook, LLC" + cfg.Year = model.YearSpec{Kind: model.YearRange, Start: 2021, End: 2026} + cfg.Style = model.StyleNotice + cfg.ManageLicenseFile = false + cfg.Excludes = []string{"**/generated/**"} + cfg.Policy = model.Policy{ + Required: "AGPL-3.0-or-later", + Allow: []string{"AGPL-3.0-or-later", "MIT"}, + Deny: []string{"GPL-2.0-only"}, + FailOn: []model.FailCondition{model.FailOnMissingHeader, model.FailOnUnknownLicense}, + } + + data, err := RenderFile(cfg) + if err != nil { + t.Fatalf("RenderFile error: %v", err) + } + + // Decode the rendered bytes back via the real loader and assert the fields. + dir := t.TempDir() + p := filepath.Join(dir, repoConfigName) + writeFile(t, p, string(data)) + got, err := LoadFile(p) + if err != nil { + t.Fatalf("LoadFile of rendered bytes: %v", err) + } + if got.License != cfg.License || got.Holder != cfg.Holder { + t.Errorf("identity not round-tripped: %+v", got) + } + if got.Year != cfg.Year { + t.Errorf("year = %+v, want %+v", got.Year, cfg.Year) + } + if got.Style != cfg.Style { + t.Errorf("style = %v, want %v", got.Style, cfg.Style) + } + if got.ManageLicenseFile { + t.Errorf("manage_license_file should round-trip as false") + } + if len(got.Excludes) != 1 || got.Excludes[0] != "**/generated/**" { + t.Errorf("excludes = %v, want [**/generated/**]", got.Excludes) + } + if got.Policy.Required != "AGPL-3.0-or-later" || len(got.Policy.Allow) != 2 || + len(got.Policy.Deny) != 1 || len(got.Policy.FailOn) != 2 { + t.Errorf("policy not round-tripped: %+v", got.Policy) + } + }) + + t.Run("policy block stays empty when no policy intent", func(t *testing.T) { + // A config with an empty policy must not have the policySchema populated, so the + // scaffold carries no required/allow/deny/fail_on values. (yaml still renders the + // value-typed policy key, but every sub-field is its zero value.) The guard in + // RenderFile is what keeps failConditionsToTokens from emitting tokens here. + cfg := Defaults() + cfg.License = "MIT" + cfg.Holder = "Acme" + cfg.Policy = model.Policy{} // explicitly empty: no required/allow/deny/fail_on + + data, err := RenderFile(cfg) + if err != nil { + t.Fatalf("RenderFile error: %v", err) + } + // Decode the rendered bytes through the raw file schema (not LoadFile, which + // re-seeds Defaults' fail_on): with the guard skipping the policySchema + // assignment, the rendered policy block carries no values the user never set. + fs, derr := decodeYAML(data) + if derr != nil { + t.Fatalf("decodeYAML of rendered bytes: %v", derr) + } + if fs.Policy.Required != "" || len(fs.Policy.Allow) != 0 || + len(fs.Policy.Deny) != 0 || len(fs.Policy.FailOn) != 0 { + t.Errorf("empty-policy config rendered policy intent it never had: %+v", fs.Policy) + } + }) +} + +// TestWriteFile covers WriteFile end to end at the config layer: it writes the +// scaffold, refuses to clobber without force, overwrites with force, and surfaces a +// write error when the target directory does not exist. +func TestWriteFile(t *testing.T) { + cfg := Defaults() + cfg.License = "MIT" + cfg.Holder = "Acme, LLC" + + t.Run("writes scaffold to a fresh dir", func(t *testing.T) { + dir := t.TempDir() + target, err := WriteFile(dir, cfg, false) + if err != nil { + t.Fatalf("WriteFile error: %v", err) + } + if target != filepath.Join(dir, repoConfigName) { + t.Errorf("target = %q, want %q", target, filepath.Join(dir, repoConfigName)) + } + // The written file must load back to the same identity fields. + got, lerr := LoadFile(target) + if lerr != nil { + t.Fatalf("LoadFile error: %v", lerr) + } + if got.License != "MIT" || got.Holder != "Acme, LLC" { + t.Errorf("written scaffold round-trips wrong: %+v", got) + } + }) + + t.Run("refuses to overwrite without force", func(t *testing.T) { + dir := t.TempDir() + if _, err := WriteFile(dir, cfg, false); err != nil { + t.Fatalf("first WriteFile error: %v", err) + } + _, err := WriteFile(dir, cfg, false) + if err == nil { + t.Fatal("second WriteFile without force should refuse to clobber") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("error should mention the existing file: %v", err) + } + }) + + t.Run("overwrites with force", func(t *testing.T) { + dir := t.TempDir() + if _, err := WriteFile(dir, cfg, false); err != nil { + t.Fatalf("first WriteFile error: %v", err) + } + other := cfg + other.License = "Apache-2.0" + other.Holder = "Other, Inc" + target, err := WriteFile(dir, other, true) + if err != nil { + t.Fatalf("forced WriteFile error: %v", err) + } + got, lerr := LoadFile(target) + if lerr != nil { + t.Fatalf("LoadFile error: %v", lerr) + } + if got.License != "Apache-2.0" || got.Holder != "Other, Inc" { + t.Errorf("force did not overwrite in place: %+v", got) + } + }) + + t.Run("render error surfaces from WriteFile", func(t *testing.T) { + // RenderFile is total for any real config (yaml.Marshal of the fixed schema + // cannot fail), so the render-error branch is reachable only by injecting a + // failing renderer through the package seam. Restore it after the test. + orig := renderFile + renderErr := errors.New("synthetic render failure") + renderFile = func(model.Config) ([]byte, error) { return nil, renderErr } + t.Cleanup(func() { renderFile = orig }) + + dir := t.TempDir() + _, err := WriteFile(dir, cfg, false) + if !errors.Is(err, renderErr) { + t.Fatalf("WriteFile should surface the render error, got %v", err) + } + // A render failure must abort before any file is written. + if _, statErr := os.Stat(filepath.Join(dir, repoConfigName)); !os.IsNotExist(statErr) { + t.Errorf("no file should be written when render fails") + } + }) + + t.Run("write error when target dir is missing", func(t *testing.T) { + // Joining onto a path whose parent does not exist makes os.WriteFile fail, + // exercising the write-error wrap. force=true skips the exists guard so we land + // directly on the write attempt. + missing := filepath.Join(t.TempDir(), "no-such-dir") + _, err := WriteFile(missing, cfg, true) + if err == nil { + t.Fatal("WriteFile into a missing directory should error") + } + if !strings.Contains(err.Error(), "write") { + t.Errorf("error should be the write-failure wrap: %v", err) + } + }) +} + // writeFile writes content to path, creating parent dirs, failing the test on error. func writeFile(t *testing.T, path, content string) { t.Helper() diff --git a/internal/spdx/spdx.go b/internal/spdx/spdx.go index 3a76b48..4b0de9e 100644 --- a/internal/spdx/spdx.go +++ b/internal/spdx/spdx.go @@ -25,6 +25,7 @@ import ( "encoding/json" "fmt" "io/fs" + "sort" "strings" "sync" @@ -193,6 +194,57 @@ func listVersionFrom(s *store, err error) string { return s.version } +// IDs returns every non-deprecated SPDX identifier in the vendored index, sorted. +// WHY non-deprecated only: deprecated ids (e.g. "GPL-3.0", "AGPL-3.0") are legal to +// validate for existing headers but must not be offered as new choices, so the +// interactive picker that consumes this list never steers a user onto a deprecated +// id. Returns nil on a snapshot load error so callers degrade to an empty picker +// rather than panicking; the embedded snapshot always loads in production. +func IDs() []string { + return idsFrom(get()) +} + +// idsFrom builds the sorted, deprecation-free id list from a get() result, so tests +// can drive the load-error branch with an injected error. Production always loads the +// embedded snapshot, so the error arm is otherwise unreachable; on error it returns +// nil so the picker degrades to empty rather than panicking. +func idsFrom(s *store, err error) []string { + if err != nil { + return nil + } + out := make([]string, 0, len(s.ids)) + for id, e := range s.ids { + if e.IsDeprecatedLicenseID { + continue + } + out = append(out, id) + } + sort.Strings(out) + return out +} + +// CommonIDs returns a curated, display-ordered shortlist of the licenses users pick +// most often. WHY a hand-ordered slice rather than a sorted subset of IDs: the order +// is editorial (most-recommended first, permissive before copyleft), so the picker +// can surface sensible defaults at the top before the full alphabetical long tail. +func CommonIDs() []string { + return []string{ + "MIT", + "Apache-2.0", + "BSD-3-Clause", + "BSD-2-Clause", + "ISC", + "MPL-2.0", + "GPL-3.0-or-later", + "GPL-2.0-only", + "LGPL-3.0-or-later", + "AGPL-3.0-or-later", + "AGPL-3.0-only", + "Unlicense", + "CC0-1.0", + } +} + // classify maps a curated SPDX id to its model.Category. AGPL is both strong and // network copyleft; we report it as network-copyleft since that is its defining, // most-restrictive obligation, and the policy layer treats network-copyleft as a diff --git a/internal/spdx/spdx_internal_test.go b/internal/spdx/spdx_internal_test.go index 7fe6244..160a4d7 100644 --- a/internal/spdx/spdx_internal_test.go +++ b/internal/spdx/spdx_internal_test.go @@ -161,6 +161,8 @@ func TestAccessorsWithLoadError(t *testing.T) { assert.Equal(t, model.License{}, lic, "Lookup must return the zero License on load error") assert.Equal(t, "", listVersionFrom(nil, errStore), "ListVersion must be empty on load error") + + assert.Nil(t, idsFrom(nil, errStore), "IDs must be nil on load error so the picker degrades to empty") } // TestAccessorsFromHelpersHappyPath confirms the extracted helpers preserve the diff --git a/internal/spdx/spdx_test.go b/internal/spdx/spdx_test.go index 7d6dfa4..ef1a71b 100644 --- a/internal/spdx/spdx_test.go +++ b/internal/spdx/spdx_test.go @@ -1,6 +1,7 @@ package spdx import ( + "sort" "strings" "testing" @@ -98,3 +99,34 @@ func TestAGPLCanonicalStandardHeader(t *testing.T) { func TestListVersion(t *testing.T) { assert.NotEmpty(t, ListVersion(), "embedded snapshot should record its list version") } + +// TestIDs confirms IDs() returns a non-empty, sorted, deprecation-free id list. The +// picker that consumes it must never steer a user onto a deprecated id (e.g. the +// bare "GPL-3.0"/"AGPL-3.0" forms), and must present a stable alphabetical order. +func TestIDs(t *testing.T) { + ids := IDs() + require.NotEmpty(t, ids, "the vendored snapshot must yield some non-deprecated ids") + + // Sorted, ascending. + assert.True(t, sort.StringsAreSorted(ids), "IDs() must be sorted") + + // No deprecated ids leak through. The bare GPL/AGPL forms are deprecated in SPDX + // and must be filtered out so they are never offered as new choices. + for _, dep := range []string{"GPL-3.0", "AGPL-3.0", "GPL-2.0", "LGPL-3.0"} { + assert.NotContains(t, ids, dep, "deprecated id %q must be excluded from IDs()", dep) + } + + // A representative non-deprecated id is present. + assert.Contains(t, ids, "MIT", "the canonical MIT id should be offered") +} + +// TestCommonIDs confirms every curated shortlist id validates against the full +// vendored index, so the picker can never present a "common" option that the rest +// of the tool would later reject as an unknown SPDX id. +func TestCommonIDs(t *testing.T) { + common := CommonIDs() + require.NotEmpty(t, common, "the common shortlist must not be empty") + for _, id := range common { + assert.Truef(t, Validate(id), "CommonIDs entry %q must be a valid SPDX id", id) + } +} -- 2.54.0 From 8b02f7b5b28c6abc5f7a94502ceb991e1a6e18aa Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 3 Jun 2026 18:50:00 -0500 Subject: [PATCH 2/2] feat: audit findings-summary + isTTY x/term fix; changelog v0.2.0 --- CHANGELOG.md | 9 ++ cmd/license-tool/commands.go | 13 ++- cmd/license-tool/commands_test.go | 14 ++- go.mod | 5 +- go.sum | 6 +- internal/report/findings.go | 185 ++++++++++++++++++++++++++++++ internal/report/findings_test.go | 133 +++++++++++++++++++++ internal/report/report.go | 5 + 8 files changed, 355 insertions(+), 15 deletions(-) create mode 100644 internal/report/findings.go create mode 100644 internal/report/findings_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c580b50..8f77f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2026-06-03 + +### Added +- `init`: interactive wizard (charmbracelet/huh) to scaffold `.license-tool.yaml` — filterable SPDX license picker (common licenses first), holder/year/style prompts with validation, and an overwrite guard; non-TTY path uses flags +- `audit`: findings-summary block at the top of the text report (source files headered/missing, license types, unknown, copyleft flags, dependencies resolved/unresolved, policy PASS/FAIL) + +### Fixed +- `isTTY` now uses `golang.org/x/term` so `/dev/null` and other char devices are not mistaken for a terminal (correct non-interactive behavior in CI/scripts) + ## [0.1.1] - 2026-06-03 ### Fixed diff --git a/cmd/license-tool/commands.go b/cmd/license-tool/commands.go index 7add36f..18315e5 100644 --- a/cmd/license-tool/commands.go +++ b/cmd/license-tool/commands.go @@ -5,6 +5,7 @@ import ( "os" "github.com/spf13/cobra" + "golang.org/x/term" "github.com/KofTwentyTwo/license-tool/internal/applier" "github.com/KofTwentyTwo/license-tool/internal/config" @@ -425,11 +426,11 @@ func applyToFlags(s *sharedFlags, f *applyFlags) config.Flags { } // isTTY reports whether stdin is an interactive terminal, gating interactive -// prompts (off in CI). It is a conservative check on the character-device bit. +// prompts (off in CI). WHY term.IsTerminal over an os.ModeCharDevice bit check: +// the mode-bit test treats every character device as a terminal, so redirecting +// from /dev/null (or any other char device, common in CI and shell pipelines) +// wrongly enters the interactive wizard. term.IsTerminal issues the actual +// terminal ioctl, so 'init 0 { + f.DepsScanned = true + f.DepsTotal = len(r.Dependencies) + for _, dep := range r.Dependencies { + if dep.Resolution == model.ResolutionResolved { + f.DepsResolved++ + } else { + f.DepsUnresolved++ + } + } + } + + // Violations are already sorted+deduped on the Report, but copy defensively so a + // caller mutating the slice cannot perturb the source report. + f.Violations = append([]string(nil), r.Violations...) + + return f +} + +// licenseSummary renders the per-id license mix as " " tokens joined by +// commas, appending "none " when any coverable source file is missing a header, +// and "(none)" when there are no coverable source files at all. Ids are sorted so +// the line is deterministic. +func (f Findings) licenseSummary() string { + if f.SourceTotal == 0 { + return "(none)" + } + parts := make([]string, 0, len(f.LicenseCounts)+1) + for _, id := range sortedKeys(f.LicenseCounts) { + parts = append(parts, fmt.Sprintf("%s %d", id, f.LicenseCounts[id])) + } + if f.SourceMissing > 0 { + parts = append(parts, fmt.Sprintf("none %d", f.SourceMissing)) + } + if len(parts) == 0 { + return "(none)" + } + return strings.Join(parts, ", ") +} + +// copyleftSummary renders the distinct copyleft ids present, or "none". +func (f Findings) copyleftSummary() string { + if len(f.Copyleft) == 0 { + return "none" + } + return strings.Join(f.Copyleft, ", ") +} + +// policySummary renders "PASS" or, on failure, "FAIL" followed by the violation +// tokens and a count, so the reader sees the verdict and its cause in one line. +func (f Findings) policySummary() string { + if f.Passed { + return "PASS" + } + if len(f.Violations) == 0 { + return "FAIL" + } + return fmt.Sprintf("FAIL (%d: %s)", len(f.Violations), strings.Join(f.Violations, ", ")) +} + +// renderFindings writes the Findings block. It is the only formatter that knows the +// block's layout; buildFindings owns the arithmetic. The dependencies line is +// omitted entirely when dependency resolution did not run. +func renderFindings(bw *errWriter, f Findings) { + bw.printf("findings:\n") + bw.printf(" source files: %d (headered %d, missing %d)\n", f.SourceTotal, f.SourceHeadered, f.SourceMissing) + bw.printf(" license types: %s\n", f.licenseSummary()) + bw.printf(" unknown/unrecognized: %d\n", f.UnknownCount) + bw.printf(" copyleft: %s\n", f.copyleftSummary()) + if f.DepsScanned { + bw.printf(" dependencies: %d (resolved %d, unresolved %d)\n", f.DepsTotal, f.DepsResolved, f.DepsUnresolved) + } + bw.printf(" policy: %s\n", f.policySummary()) + bw.printf("\n") +} + +// sortedKeys returns the keys of a string-keyed map in ascending order, the canonical +// ordering used throughout the renderers for determinism. +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/report/findings_test.go b/internal/report/findings_test.go new file mode 100644 index 0000000..2e15bf8 --- /dev/null +++ b/internal/report/findings_test.go @@ -0,0 +1,133 @@ +package report + +import ( + "bytes" + "testing" + + "github.com/KofTwentyTwo/license-tool/internal/model" + "github.com/stretchr/testify/assert" +) + +// fileWith builds a non-skipped FileResult carrying the given detected id (empty id +// means "no managed header"). +func fileWith(path, id string) model.FileResult { + fr := model.FileResult{Path: path} + if id != "" { + fr.Detected = model.DetectedHeader{Present: true, SPDXID: id} + } + return fr +} + +func TestBuildFindingsSourceCoverageAndLicenseMix(t *testing.T) { + r := model.Report{ + Files: []model.FileResult{ + fileWith("a.go", "MIT"), + fileWith("b.go", "MIT"), + fileWith("c.go", "GPL-3.0-or-later"), + fileWith("d.go", ""), // missing header + {Path: "skip.json", Skipped: true, SkipReason: "uncommentable"}, + }, + } + f := buildFindings(r) + + assert.Equal(t, 4, f.SourceTotal) // skipped file excluded + assert.Equal(t, 3, f.SourceHeadered) + assert.Equal(t, 1, f.SourceMissing) + assert.Equal(t, 2, f.LicenseCounts["MIT"]) + assert.Equal(t, 1, f.LicenseCounts["GPL-3.0-or-later"]) + + // License mix is sorted by id, with the missing bucket last. + assert.Equal(t, "GPL-3.0-or-later 1, MIT 2, none 1", f.licenseSummary()) +} + +func TestBuildFindingsUnknownAndCopyleft(t *testing.T) { + r := model.Report{ + Files: []model.FileResult{ + fileWith("a.go", "MIT"), // permissive, known + fileWith("b.go", "GPL-3.0-or-later"), // strong copyleft + fileWith("c.go", "Not-A-Real-License"), // unrecognized + }, + } + f := buildFindings(r) + + assert.Equal(t, 1, f.UnknownCount) // only the bogus id + assert.Equal(t, []string{"GPL-3.0-or-later"}, f.Copyleft) // strong copyleft present + assert.Equal(t, "GPL-3.0-or-later", f.copyleftSummary()) +} + +func TestBuildFindingsNoSourceFiles(t *testing.T) { + f := buildFindings(model.Report{}) + assert.Equal(t, 0, f.SourceTotal) + assert.Equal(t, "(none)", f.licenseSummary()) + assert.Equal(t, "none", f.copyleftSummary()) +} + +func TestBuildFindingsDependencies(t *testing.T) { + // No deps scanned -> the dependencies line is suppressed. + none := buildFindings(model.Report{}) + assert.False(t, none.DepsScanned) + + r := model.Report{ + Dependencies: []model.DependencyLicense{ + {Ecosystem: "maven", Name: "a", SPDXID: "MIT", Resolution: model.ResolutionResolved}, + {Ecosystem: "npm", Name: "b", Resolution: model.ResolutionUnresolved}, + }, + } + f := buildFindings(r) + assert.True(t, f.DepsScanned) + assert.Equal(t, 2, f.DepsTotal) + assert.Equal(t, 1, f.DepsResolved) + assert.Equal(t, 1, f.DepsUnresolved) +} + +// licenseSummary has a defensive "no parts" guard: when there are coverable source +// files (SourceTotal > 0) but neither any headered ids nor any missing files, it +// returns "(none)". buildFindings cannot produce that shape (every coverable file is +// either headered or counted as missing, so SourceTotal > 0 implies at least one +// part), so we exercise the guard directly on a hand-built Findings to keep it from +// being dead-but-untested code. +func TestFindingsLicenseSummaryNoPartsGuard(t *testing.T) { + f := Findings{SourceTotal: 1, LicenseCounts: map[string]int{}, SourceMissing: 0} + assert.Equal(t, "(none)", f.licenseSummary()) +} + +func TestFindingsPolicySummary(t *testing.T) { + assert.Equal(t, "PASS", Findings{Passed: true}.policySummary()) + assert.Equal(t, "FAIL", Findings{Passed: false}.policySummary()) + assert.Equal(t, + "FAIL (2: deny:GPL-3.0-or-later, missing-header)", + Findings{Passed: false, Violations: []string{"deny:GPL-3.0-or-later", "missing-header"}}.policySummary(), + ) +} + +func TestRenderFindingsBlock(t *testing.T) { + r := model.Report{ + Files: []model.FileResult{ + fileWith("a.go", "MIT"), + fileWith("d.go", ""), + }, + Dependencies: []model.DependencyLicense{ + {Ecosystem: "maven", Name: "a", SPDXID: "MIT", Resolution: model.ResolutionResolved}, + }, + Passed: true, + } + var buf bytes.Buffer + bw := &errWriter{w: &buf} + renderFindings(bw, buildFindings(r)) + out := buf.String() + + assert.Contains(t, out, "findings:") + assert.Contains(t, out, "source files: 2 (headered 1, missing 1)") + assert.Contains(t, out, "license types: MIT 1, none 1") + assert.Contains(t, out, "unknown/unrecognized: 0") + assert.Contains(t, out, "copyleft: none") + assert.Contains(t, out, "dependencies: 1 (resolved 1, unresolved 0)") + assert.Contains(t, out, "policy: PASS") +} + +func TestRenderFindingsOmitsDepsWhenNotScanned(t *testing.T) { + var buf bytes.Buffer + bw := &errWriter{w: &buf} + renderFindings(bw, buildFindings(model.Report{Passed: true})) + assert.NotContains(t, buf.String(), "dependencies:") +} diff --git a/internal/report/report.go b/internal/report/report.go index c598285..a2afbbf 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -417,6 +417,11 @@ func renderText(w io.Writer, r model.Report) error { bw.printf("root: %s\n", r.Root) bw.printf("license: %s holder: %s style: %s\n\n", orNone(r.Config.License), orNone(r.Config.Holder), r.Config.Style) + // Findings summary first: a concise by-finding overview (coverage, license mix, + // unknowns, copyleft, deps, policy) so a reader sees the verdict before the + // detailed by-SPDX / by-category / by-file-type breakdowns below. + renderFindings(bw, buildFindings(r)) + bw.printf("result: %s\n\n", passLabel(r.Passed)) bw.printf("by SPDX id:\n") -- 2.54.0