v0.2.0: init wizard + audit findings-summary #4
No files matched your search
+10
-1
@@ -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$
|
||||
@@ -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
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:")
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user