v0.2.0: init wizard + audit findings-summary #4

Merged
KofTwentyTwo merged 2 commits from feature/init-wizard into develop 2026-06-03 23:50:09 +00:00
15 changed files with 1145 additions and 29 deletions

No files matched your search

+10 -1
View File
@@ -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$
+9
View File
@@ -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
+76 -11
View File
@@ -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"
@@ -251,19 +252,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 {
@@ -361,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 </dev/null' is correctly seen as non-interactive.
func isTTY() bool {
fi, err := os.Stdin.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
return term.IsTerminal(int(os.Stdin.Fd()))
}
+126 -16
View File
@@ -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) {
@@ -505,13 +611,16 @@ func TestIsTTY(t *testing.T) {
orig := os.Stdin
t.Cleanup(func() { os.Stdin = orig })
t.Run("character device is a TTY", func(t *testing.T) {
// /dev/null is a character device, exercising the ModeCharDevice branch.
// WHY /dev/null must NOT be a TTY: the previous os.ModeCharDevice check treated
// every character device (including /dev/null) as a terminal, so 'init </dev/null'
// and CI wrongly entered the interactive wizard. term.IsTerminal issues the real
// terminal ioctl, so a char device that is not a terminal is correctly rejected.
t.Run("character device is not a TTY", func(t *testing.T) {
f, err := os.Open(os.DevNull)
require.NoError(t, err)
t.Cleanup(func() { _ = f.Close() })
os.Stdin = f
assert.True(t, isTTY())
assert.False(t, isTTY())
})
t.Run("regular file is not a TTY", func(t *testing.T) {
@@ -523,8 +632,9 @@ func TestIsTTY(t *testing.T) {
assert.False(t, isTTY())
})
t.Run("stat error is not a TTY", func(t *testing.T) {
// A closed descriptor makes Stat fail, exercising the err != nil branch.
t.Run("closed descriptor is not a TTY", func(t *testing.T) {
// A closed descriptor cannot be a terminal; the ioctl fails and IsTerminal
// reports false.
f, err := os.Open(os.DevNull)
require.NoError(t, err)
require.NoError(t, f.Close())
+114
View File
@@ -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 "<id> (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)
}
+29 -1
View File
@@ -1,20 +1,48 @@
module github.com/KofTwentyTwo/license-tool
go 1.24
go 1.25.0
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
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
golang.org/x/term v0.43.0
gopkg.in/yaml.v3 v3.0.1
)
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.44.0 // indirect
golang.org/x/text v0.23.0 // indirect
)
+77
View File
@@ -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,21 @@ 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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
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=
+82
View File
@@ -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 <path>/.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)
+213
View File
@@ -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()
+185
View File
@@ -0,0 +1,185 @@
package report
import (
"fmt"
"sort"
"strings"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/KofTwentyTwo/license-tool/internal/spdx"
)
// Findings is the at-a-glance summary printed near the top of the text report. It
// is derived purely from a model.Report so it can be unit-tested without rendering,
// and so the text renderer stays a thin formatter over already-computed numbers.
//
// WHY a dedicated struct rather than formatting inline in renderText: the summary
// answers "what did we find" by category (source coverage, license mix, unknowns,
// copyleft exposure, dependency resolution, policy), each of which is a small bit
// of aggregation over the report's existing fields. Pulling that aggregation into
// one pure function makes the arithmetic testable in isolation and keeps the
// renderer deterministic.
type Findings struct {
// SourceTotal is the count of coverable (non-skipped) source files.
SourceTotal int
// SourceHeadered is the count of coverable source files carrying a managed header.
SourceHeadered int
// SourceMissing is the count of coverable source files with no managed header.
SourceMissing int
// LicenseCounts maps each detected SPDX id to its source-file count (managed,
// headered files only). It excludes the synthetic (none) bucket; missing files
// are reported via SourceMissing.
LicenseCounts map[string]int
// UnknownCount is the number of headered source files whose detected id is not a
// recognized SPDX identifier (or classifies as the unknown category).
UnknownCount int
// Copyleft lists the distinct copyleft SPDX ids present in source (weak, strong,
// or network copyleft categories), sorted.
Copyleft []string
// DepsScanned is true when dependency resolution ran (Dependencies is non-empty);
// the dependencies line is omitted from the summary when false.
DepsScanned bool
// DepsTotal/Resolved/Unresolved summarize dependency resolution.
DepsTotal int
DepsResolved int
DepsUnresolved int
// Passed mirrors Report.Passed (the policy verdict).
Passed bool
// Violations are the repo-level violation tokens, sorted, surfaced when failing.
Violations []string
}
// buildFindings folds a finished model.Report into a Findings summary. It is pure
// (no I/O, no globals beyond the read-only vendored SPDX index) and order-stable, so
// identical reports yield identical Findings.
func buildFindings(r model.Report) Findings {
f := Findings{
LicenseCounts: map[string]int{},
Passed: r.Passed,
}
copyleftSeen := map[string]bool{}
for _, fr := range r.Files {
// Coverable means a file that participates in license management. Skipped
// files (binary, uncommentable, unknown type) carry no managed header and are
// excluded from the source coverage tally, matching Build's by-license logic.
if fr.Skipped {
continue
}
f.SourceTotal++
id := fr.Detected.SPDXID
if fr.Detected.Present && id != "" {
f.SourceHeadered++
f.LicenseCounts[id]++
// Unknown = a detected id we cannot vouch for: not a real SPDX id, or one
// we have no curated category for. Either way it is unrecognized exposure.
if !spdx.Validate(id) || categoryToken(id) == model.CategoryUnknown.String() {
f.UnknownCount++
}
switch categoryToken(id) {
case model.CategoryWeakCopyleft.String(),
model.CategoryStrongCopyleft.String(),
model.CategoryNetworkCopyleft.String():
copyleftSeen[id] = true
}
} else {
f.SourceMissing++
}
}
f.Copyleft = sortedKeys(copyleftSeen)
if len(r.Dependencies) > 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 "<id> <count>" tokens joined by
// commas, appending "none <M>" 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
}
+133
View File
@@ -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:")
}
+5
View File
@@ -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")
+52
View File
@@ -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
+2
View File
@@ -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
+32
View File
@@ -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)
}
}