Compare commits

..
53 Commits
Author SHA1 Message Date
james.maes 43fa40c111 Merge pull request 'ci: build on Gitea with the shared Go workflow' (#53) from ci/gitea-go-workflow into develop 2026-11-08 15:01:43 +00:00
James MaesandClaude Opus 5.5 48bf47d4a8 ci: pin the shared workflow to v1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 09:00:29 -06:00
James MaesandClaude Opus 5.5 7da7f93fc7 ci: call the shared workflow from ci-workflows main
go / go (push) Successful in 2m0s
build / go (push) Successful in 2m0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:56:21 -06:00
James MaesandClaude Opus 5.5 147082206f ci: build on Gitea with the shared Go workflow
go / go (push) Successful in 3m16s
build / go (push) Successful in 3m16s
Port the GitHub CI gates (gofmt, go vet, golangci-lint, race tests with
the coverage profile, the 100% coverage gate, go build) to the shared
ci-workflows go.yaml. v* tags build the GoReleaser-equivalent archives
with scripts/build-release.sh and attach them with SHA256SUMS to the
Gitea release. The GitHub workflows ran on ubuntu-22.04/macos/windows
runners that Gitea does not serve; removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:27:29 -06:00
james.maes 8a8bc4b1e4 docs: session handoff after v0.4.0 release 2026-06-09 10:34:13 -05:00
james.maes c32a65621c docs: changelog for v0.4.0; add RELEASES.md release runbook 2026-06-09 10:17:32 -05:00
james.maes 9cf00681b7 style: gofmt commands_test.go after merge conflict resolution 2026-06-09 10:15:10 -05:00
james.maes a14dee227a Merge feature/GH-30-detect-overmatch (#30): confine header detection 2026-06-09 10:12:26 -05:00
james.maes e1e4563e03 Merge feature/GH-33-symlink-license (#33): refuse symlinked LICENSE 2026-06-09 10:12:19 -05:00
james.maes 5f11b5ad38 Merge feature/GH-34-quiet-verbose-flags (#34): remove no-op flags
# Conflicts:
#	CHANGELOG.md
2026-06-09 10:12:19 -05:00
james.maes d4c72311cf Merge feature/GH-31-spdxnorm-no-guess (#31): stop guessing SPDX ids 2026-06-09 10:11:35 -05:00
james.maes e116c60b88 Merge feature/GH-29-init-tui-wizard (#29): init full TUI wizard
# Conflicts:
#	CHANGELOG.md
#	cmd/license-tool/commands_test.go
#	docs/SESSION-STATE.md
2026-06-09 10:11:34 -05:00
james.maes c035b8be6b Merge feature/GH-35-audit-summary-reports (#35): audit summary + group-by reporting 2026-06-09 09:58:43 -05:00
james.maes 472d78b914 docs: track the six open PRs (#29/#30/#31/#33/#34/#35) and review notes 2026-06-09 09:49:55 -05:00
james.maes 6497f0be81 fix(#30): confine license-header detection to contiguous comment lines
Header detection over-matched on line-comment file types: lineCommentRegion
tolerated truly-blank lines between comment lines, so a layout of
"license header / blank line / unrelated file doc comment" collapsed into a
single detected span. render.replaceSpan then deleted that doc comment on
relicense or year-refresh. This is safety-critical -- a false positive in
detection means apply removes real source content.

Two fixes:

1. lineCommentRegion now stops at the first non-comment line, and a
   truly-blank line ends the contiguous run. Our own rendered headers never
   contain a truly-blank internal line: the renderer emits separator lines as
   a bare comment prefix (e.g. "#" or "//"), which is still a comment line and
   stays in-region. Confining the region to contiguous comment lines is
   therefore byte-idempotent for headers we write, while refusing to absorb a
   foreign comment that a blank line has separated from the header.

2. spdxIdentifierTag now requires the SPDX-License-Identifier tag to begin a
   comment line (leading whitespace allowed) rather than matching anywhere via
   strings.Index. A comment that merely mentions the tag mid-prose is
   documentation, not a managed header, and must not qualify a foreign comment
   for replacement.

When in doubt the detector now under-detects (leaves a foreign comment alone)
rather than over-detecting. Block-comment multi-block de-stacking (issue #30
LOW) is deferred: spanning only the first block is the safe, under-detecting
choice.

Idempotency and round-trip are preserved and verified by the existing
TestSpliceIdempotent / TestApplyFile* suites plus a new end-to-end applier
test that relicenses a line-comment file and asserts a doc comment below the
header's blank separator survives. The enshrining region test was updated to
require the corrected contiguous behavior with the safety rationale inline.
2026-06-09 08:38:50 -05:00
james.maes 05157ddf48 fix(#33): refuse to overwrite a symlinked license file
AtomicWrite used os.Stat + os.Rename with no symlink check, so writing a
managed LICENSE / LICENSES/<id>.txt that is a deliberate symlink (e.g.
LICENSE -> LICENSES/MIT.txt) would replace the link with a regular file,
silently breaking it.

AtomicWrite now lstats the target and refuses with errSymlinkTarget when it
is a symlink, leaving the link and its target untouched. The error message
contains "refusing to write" so the CLI's isWriteRefusal mapping classifies
it as a write refusal (exit code 3). writeManaged propagates this refusal as
a returned error (other write failures stay recorded per-file as before).

Closes #33
2026-06-09 08:00:58 -05:00
james.maes 20a0cd0f48 fix(#34): remove the no-op --quiet/--verbose flags
The persistent --quiet/-q and --verbose/-v flags were bound on the root
flag set but read nowhere -- pure no-op flags advertising behavior they
never delivered.

Decision: REMOVE (Option B), not WIRE. A review of every output site
showed there is no non-essential, human-facing output to gate behind
--quiet (the only stdout output is the machine report from audit/check/
apply and essential confirmations like "wrote X"), and no diagnostic or
tracing facility for --verbose to enable (the model's ResolveOptions.
Verbose field is itself dead -- no resolver reads it, nothing writes
diagnostics). Wiring would have required inventing new output purely to
justify the flags, i.e. hollow/cosmetic wiring. Removing the no-ops is
the honest, lower-risk choice for pre-1.0 (v0.3.0).

Passing --quiet/-q/--verbose/-v now yields an unknown-flag usage error
(exit 2). Updated TestNewRootCmd and added TestQuietVerboseFlagsRemoved;
updated README, the design doc, and the CHANGELOG.

The optional LOW item (typed-sentinel refactor of isWriteRefusal) is
deferred: the refusal strings span both internal/applier and
internal/config, so a sentinel refactor would touch the applier
write/refusal path that issue #33 is changing on a separate branch,
risking merge conflicts and cross-package sprawl. Kept this change
scoped to the CLI flag surface.

Closes #34
2026-06-09 08:00:09 -05:00
james.maes 194fb0e49f fix(#31): resolve ambiguous license aliases to unresolved instead of guessing
The spdxAliases table fabricated SPDX ids, violating the package's
"never guess" invariant:

- "bsd" / "bsd license" invented a clause count (could be 2/3/4-clause).
- "gnu lesser general public license" invented BOTH a version (2.1 vs 3.0)
  and the grant ("only" vs "or-later").

These wrong guesses flowed into deny/allow verdicts and the incompatibility
table, where -only vs -or-later and clause counts are decisive. Such ambiguous
spellings now leave normalizeSPDX returning ok=false, so the caller emits an
unresolved dependency with a reason rather than a fabricated id. Only
unambiguous, exact spellings remain (explicit 3-clause BSD, Apache, MIT, ISC,
MPL-2.0, Unlicense, the explicit AGPL "v3.0 or later" spelling).

Also fixes the related resolved-but-unclassifiable contradiction: alias
re-validation now uses spdx.Lookup (curated rendering set) instead of
spdx.Validate (full index). The Eclipse Public License aliases (EPL-2.0 /
EPL-1.0) passed Validate but are outside the curated set, so policy.Classify
returned Unknown while the dependency was still marked resolved. They are now
left unresolved. Any future alias whose target is valid-but-uncurated is
structurally rejected by the Lookup gate.

Closes #31
2026-06-09 07:59:28 -05:00
james.maes 7159bc1b48 docs(#35): close punch list; record review follow-ups and PR readiness 2026-06-09 07:45:58 -05:00
james.maes 3852c9122b fix(#35): harden group risk under --only; review-driven follow-ups
Address findings from a three-agent adversarial review of the GH-35
delta:

- Compute the repo-wide incompatibility set from the full report, not the
  --only-narrowed listing, so a filtered listing can no longer understate
  a group's policy-aware risk. Add groupFilesWith(r, spec, incompatIDs)
  and thread the full-report set through the text/markdown/JSON renderers.
- Use path.Base (not filepath.Base) on the slash-normalized enumerate
  path when matching the config file, for cross-platform correctness.
- Tests: directory-grouping escalation across a mixed bucket, incompat
  beating the headerless-unknown fallback, non-policy tokens not
  escalating, incompatibleIDs cardinality (0/1/3 ids), the filtered-
  listing risk contract, nested/--only config exclusion, no detected-
  license/violation leak on the skipped config, and a check exit-code
  regression guard for the config exclusion.
- Docs: correct the README grouped-output samples (risk markers,
  (no-header) label, JSON group shape) and note the check exit-code
  implication of the config exclusion in README and CHANGELOG.
2026-06-09 07:44:21 -05:00
james.maes 56c3e9410b docs(#35): document self-config exclusion, policy-aware risk, and decisions
README: policy-aware group risk and the .license-tool.yaml exclusion.
CHANGELOG: comprehensive Unreleased entry for the audit reporting overhaul.
DESIGN: post-implementation addenda recording the two new features and the
three audit-flagged items resolved by decision (dep-grouping non-goal,
JSON percentages declined, go.mod not a supported ecosystem).
2026-06-09 07:12:06 -05:00
james.maes 2822d862d8 feat(#35): policy-aware group risk
A group's [risk] marker was computed solely from its files' license
categories, so it was policy-blind: an Apache-2.0 group beside an
AGPL-3.0-or-later group read "low" even though the pair is a curated
hard incompatibility, and a deny-listed permissive license also read
"low".

Escalate a group's risk to "high" when it carries a policy concern: a
file whose license is party to a repo-level hard incompatibility
(computed once over the distinct source ids via policy.Incompatible),
or a file carrying a file-scoped policy-violation token. Category-only
risk is preserved when there is no policy concern, with no false
escalation (MIT beside AGPL, not an incompatibility, stays "low").
2026-06-09 07:10:16 -05:00
james.maes ce192cad54 feat(#35): exclude tool's own .license-tool.yaml from source coverage
The config file is YAML (commentable), so the audit counted it as a
coverable source file missing a managed header, inflating sourceTotal
and sourceMissing. It is tool metadata, not coverable source, and never
carries a header.

Mark any file whose basename is .license-tool.yaml as skipped (reason
"tool config") in the audit pipeline, so it still appears in the file
listing but is excluded from source/header tallies. Export
config.RepoConfigName as the single source of truth for the name.
2026-06-08 15:30:38 -05:00
james.maes bbba9d28bf docs(#35): document --only filter and JSON-always-full behavior 2026-06-08 15:12:54 -05:00
james.maes 366ffece45 feat(#35): --only filter to list problem files (missing|unknown|copyleft|violations) 2026-06-08 15:12:29 -05:00
james.maes b43e362a21 fix(#35): unify (no-header) token, unknown-risk for headerless groups, full JSON under --summary 2026-06-08 14:58:42 -05:00
james.maes 83df6402ab docs(#35): document audit risk/attribution/findings/sort/depth enhancements 2026-06-08 14:24:37 -05:00
james.maes 96389108a8 feat(#35): per-group license breakdown, --depth directory grouping 2026-06-08 14:21:52 -05:00
james.maes 29c7108c80 feat(#35): per-group risk markers, (no-header) rename, --sort key|count 2026-06-08 14:14:19 -05:00
james.maes daef9c396d feat(#35): findings + totals/percentages + riskLevel parity across text/markdown/json 2026-06-08 14:07:52 -05:00
james.maes 48fd159368 feat(#35): attributable policy violations (which license/rule/file) in all formats 2026-06-08 14:01:30 -05:00
james.maes b28d245c60 feat(#35): wire --summary and --group-by audit flags + document them
Closes #35
2026-06-08 13:29:35 -05:00
james.maes dd4b34ba93 feat(#35): RenderOptions for summary + grouped views across all formats 2026-06-08 13:19:45 -05:00
james.maes c3755f7baf feat(#35): add pure GroupFiles + ParseGroupBy for audit grouping 2026-06-08 13:12:04 -05:00
james.maes 6051a36198 docs(#35): spec audit summary + group-by report options 2026-06-08 13:04:49 -05:00
james.maes 32b44e5143 docs(#29): record init redesign completion in session state 2026-06-08 12:34:30 -05:00
james.maes 0031b00eb3 feat(#29): single-form init wizard with adaptive live preview
Replaces the 7-step 3-pane wizard with a single repo-seeded form and a
persistent adaptive preview. wizard.go is now a thin Bubble Tea adapter;
all logic lives in internal/initwizard at 100% coverage.

Closes #29
2026-06-08 12:33:49 -05:00
james.maes 09c8174841 feat(#29): pure adaptive view rendering for init form 2026-06-08 12:21:34 -05:00
james.maes a6cc331fea feat(#29): adaptive panel layout planner 2026-06-08 12:14:31 -05:00
james.maes 45dcfd90e1 feat(#29): pure form state machine with live validation 2026-06-08 12:13:25 -05:00
james.maes ddcc51d27f feat(#29): seed init answers from repo detection 2026-06-08 11:58:01 -05:00
james.maes bafa2810a6 refactor(#29): unify Answers->Config behind one translator 2026-06-08 11:50:28 -05:00
james.maes 4e3561ef29 fix(#32): include globs honor ** via shared gitignore matcher 2026-06-08 11:44:56 -05:00
james.maes 44d4986063 feat(init): add pane-based TUI wizard
Refs #29
2026-06-08 09:28:03 -05:00
james.maes 1bfddf89f9 merge: init tui shell slice 2026-06-04 16:37:21 -05:00
james.maes ca3479ef02 merge: init preview engine slice 2026-06-04 16:36:55 -05:00
james.maes f80ab905f2 feat(init): add source preview engine
Refs #29
2026-06-04 16:36:05 -05:00
james.maes ef70f64fff merge: init include config slice 2026-06-04 16:36:04 -05:00
james.maes 4773596e5d feat(init): wire tui collector
Refs #29.
2026-06-04 16:36:03 -05:00
james.maes fa1b86da03 feat(init): persist include patterns
Refs #29
2026-06-04 16:35:20 -05:00
james.maes 5a66996d73 docs: refine init tui plan 2026-06-04 16:31:31 -05:00
james.maes cff8435402 docs: plan init tui wizard 2026-06-04 16:27:18 -05:00
james.maes 7e177a914f docs: add v0.3.0 install note 2026-06-04 15:50:30 -05:00
64 changed files with 7406 additions and 873 deletions

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
name: build
on:
push:
branches: ['**']
tags: ['v*']
jobs:
go:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/go.yaml@v1
with:
go-version: '1.26'
golangci-lint: true
race: true
test-args: -coverpkg=./internal/...,./cmd/... -covermode=atomic -coverprofile=cover.out
# 100% coverage gate (.testcoverage.yml), then the plain build.
commands: |
go run github.com/vladopajic/go-test-coverage/v2@v2.18.8 --config=.testcoverage.yml
go build ./...
release-commands: scripts/build-release.sh "${GITHUB_REF_NAME#v}"
artifacts: dist/*
-121
View File
@@ -1,121 +0,0 @@
name: CI
on:
pull_request:
branches:
- develop
- main
- release/**
- rc/**
push:
branches:
- develop
- main
- release/**
- rc/**
permissions:
contents: read
jobs:
go-checks:
name: Go checks
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.26"
cache: true
- name: Check formatting
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "The following files are not gofmt-clean:"
echo "$unformatted"
exit 1
fi
- name: Run go vet
run: go vet ./...
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
# v8 installs golangci-lint v2.x, which understands the v2 .golangci.yml
# schema (version/formatters). latest pulls a build made with a current Go
# toolchain, satisfying the module's go directive.
version: latest
- name: Run tests with coverage profile
run: |
go test ./... -race \
-coverpkg=./internal/...,./cmd/... \
-covermode=atomic \
-coverprofile=cover.out
- name: Upload coverage profile
uses: actions/upload-artifact@v7
with:
name: coverage-profile-${{ github.run_id }}-${{ github.sha }}
path: cover.out
if-no-files-found: error
- name: Enforce 100% coverage gate
# go-test-coverage is a CI-only tool, run via `go run` so it never becomes a
# go.mod dependency. The gate FAILS the build when total coverage on
# ./internal/...,./cmd/... drops below the 100 threshold in .testcoverage.yml,
# which documents the only two exclusions (the main() shim and gen_spdx.go).
run: go run github.com/vladopajic/go-test-coverage/v2@v2.18.8 --config=.testcoverage.yml
- name: Build
run: go build ./...
macos-build:
name: macOS branch build
runs-on: macos-15
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.26"
cache: true
- name: Build binary
run: go build -o license-tool ./cmd/license-tool
- name: Upload branch binary
uses: actions/upload-artifact@v7
with:
name: license-tool-${{ github.run_id }}-${{ github.sha }}-macos
path: license-tool
if-no-files-found: error
windows-build:
name: Windows branch build
runs-on: windows-latest
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.26"
cache: true
- name: Build binary
run: go build -o license-tool.exe ./cmd/license-tool
- name: Upload branch binary
uses: actions/upload-artifact@v7
with:
name: license-tool-${{ github.run_id }}-${{ github.sha }}-windows
path: license-tool.exe
if-no-files-found: error
-39
View File
@@ -1,39 +0,0 @@
name: Release
# Run whenever a semver release tag is pushed (e.g. v1.0.0, v1.2.3-rc.1).
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+*"
permissions:
contents: write
jobs:
goreleaser:
name: GoReleaser
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# GoReleaser needs full history and all tags to compute the changelog
# and resolve the previous tag.
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.26.4"
cache: true
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
with:
version: v2.16.0
args: release --clean
env:
# GITHUB_TOKEN publishes the GitHub Release in this repo.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# HOMEBREW_TAP_TOKEN pushes the generated cask to KofTwentyTwo/homebrew-tap.
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
+7 -6
View File
@@ -34,12 +34,13 @@ threshold:
# 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.
# The interactive init wizard is a thin Bubble Tea adapter: it forwards window
# sizes and keystrokes to internal/initwizard FormState transitions and renders
# via initwizard.Render. It holds no logic of its own; the state machine,
# validation, layout, rendering, repo-seeding, and the single Answers->Config
# translation all live in internal/initwizard and are unit-tested to 100%.
# Driving a real TUI requires an interactive PTY that an in-process unit test
# cannot provide, so the adapter shell is excluded for the same reason as main.go.
exclude:
paths:
- ^cmd/license-tool/main\.go$
+38
View File
@@ -7,8 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.4.0] - 2026-06-09
### Install
```bash
brew install --cask KofTwentyTwo/tap/license-tool
```
### Added
- `init` now opens a full-screen Bubble Tea wizard with project-model, license, identity, header-style, license-file, coverage, and review steps (#29).
- Live example source previews for 15 language families, plus a `.license-tool.yaml` preview and a C fallback when no supported source language is detected (#29).
- Persisted `include` config support, wired through audit, apply, license, and init (#29).
- `audit --summary`: a counts-only report (findings plus the by-SPDX, by-category, and by-file-type rollups) that omits the per-file and per-dependency lists and any pending diffs (#35).
- `audit --group-by license|category|type|directory`: organizes the source-file listing under each value of the dimension; combined with `--summary` it shows per-group counts only (#35).
- `audit --sort key|count`, `--depth N` (widen directory keys to N path segments), and `--only missing,unknown,copyleft,violations` (narrow the file listing to problem files without distorting the rollups) (#35).
- Per-group **risk** markers and per-group license breakdowns, so a `directory` or `type` grouping is not license-blind (#35).
- Policy-aware group risk: a group's risk escalates to `high` when its license is party to a repo-level hard incompatibility (e.g. an Apache group beside an AGPL group) or a file in it carries a policy violation, instead of reporting the license category's risk alone (#35).
- Attributable policy violations in every format: each violation names the offending license, the rule, and the file (text/markdown) or a structured `violationDetails` array (JSON) (#35).
- `findings` summary, per-row percentages/totals, and `riskLevel`/`worstCategory` in the JSON and markdown reports (previously text-only) (#35).
### Changed
- The JSON report always emits the complete report; `--summary` only trims the human-readable (text/markdown) formats (#35).
- Renamed the headerless source-file bucket label to `(no-header)` (#35).
- Dependency resolution no longer guesses: ambiguous license aliases (a bare `bsd`, a bare LGPL name, EPL spellings) now resolve to *unresolved* with a reason instead of a fabricated SPDX id, and alias targets are held to the curated set so a "resolved" dependency is always classifiable (#31).
### Removed
- Removed the no-op `--quiet`/`-q` and `--verbose`/`-v` flags, which were bound but never read; they implied behavior the tool did not deliver (#34).
### Fixed
- Confined license-header detection to contiguous comment lines and required the `SPDX-License-Identifier:` tag to begin a comment line, so `apply` no longer risks deleting an adjacent doc comment separated from the header by a blank line (#30).
- Refuse to overwrite a symlinked `LICENSE` / `LICENSES/*.txt` instead of silently converting the symlink into a regular file via the atomic write (#33).
- Excluded the tool's own `.license-tool.yaml` from source-header coverage (it is listed as skipped with reason `tool config`), so it no longer inflates the source-file and missing-header counts and `check` no longer fails on it for lacking a header (#35).
- Made per-group risk reflect the full repo's incompatibilities under `--only`: the risk marker is now derived from the whole report, so narrowing the listing no longer understates a group's risk (#35).
## [0.3.0] - 2026-06-04
### Install
```bash
brew install --cask KofTwentyTwo/tap/license-tool
```
### Added
- Added first-class header support for Perl, PowerShell, R, Makefile, TOML, and Batch files.
- Added conservative shebang-based type detection for extensionless scripts.
+67
View File
@@ -0,0 +1,67 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
`license-tool` is a license-agnostic Go CLI that audits and standardizes license metadata across codebases. Target license, holder, year policy, and header style are all *inputs*, never assumptions (AGPL-3.0-or-later + Kingsrook is only the default profile). It has two halves: an **audit/check** half (read-only reporting + CI gate) and an **apply/license** half (writes canonical headers and top-level LICENSE files). License text is never invented — it comes from a vendored SPDX snapshot.
## Commands
The local quality gate mirrors CI; run all of it before considering work done:
```bash
gofmt -l . # must print nothing (tabs, not spaces); CI fails on any output
go vet ./...
golangci-lint run
go test ./... -race -cover
go build ./...
```
```bash
go test ./internal/render -run TestSplice -race # single package / single test
go run ./scripts/gen_spdx.go # regenerate the vendored SPDX snapshot
./scripts/verify-release.sh # the full 6-gate release preflight
```
**100% coverage is a hard gate.** CI runs `go-test-coverage` over `./internal/...` and `./cmd/...` and fails below 100% total. Only three files are excluded (see `.testcoverage.yml`): `cmd/license-tool/main.go`, `cmd/license-tool/wizard.go`, and `scripts/gen_spdx.go` — each because it is an untestable shell (process entry point, Bubble Tea TUI, or build-tagged generator). **All behavior must live in covered packages**, not in these three files. This is why `wizard.go` delegates to `answersToConfig` + `internal/initwizard` + `internal/config`, and why business logic never accretes in `main.go`.
## Architecture
### `internal/model` is the frozen contract
`internal/model/model.go` defines every shared type and interface (`Config`, `License`, `FileType`, `DetectedHeader`, `FileResult`, `Report`, `Resolver`, `YearResolver`, etc.). It is **stdlib-only and dependency-free** so every other package can import it without cycles. Changing a field or signature here ripples into all consumers — treat it as load-bearing and change it deliberately, not casually.
### The pipeline (what flows through model)
```
config.Resolve → enumerate → detect → render → applier (apply/license: the write half)
↓ ↓
detect → report (+ resolve for deps) (audit/check: the read half)
```
- **`internal/config`** — resolves effective config by layering, high→low: flags > repo `.license-tool.yaml` > user/global (`$XDG_CONFIG_HOME/license-tool/config.yaml`) > built-in defaults. `include` takes the highest non-empty layer; `exclude` accumulates across layers.
- **`internal/enumerate`** — produces the ordered file set. In a git repo, `git ls-files` (authoritative `.gitignore` honoring); otherwise a tree walk via go-gitignore. Skips symlinks/binaries, classifies each file via the file-type table.
- **`internal/filetype`** — the data-driven comment-syntax table mapping path → `FileType` (comment delimiters + ordered preserve-first rules). Data-driven so render/apply stays generic and users extend coverage via config, not code.
- **`internal/detect`** — *safety-critical.* Identifies an existing managed header by, in confidence order: own sentinel → `SPDX-License-Identifier` → known SPDX standard header → curated phrase fingerprint. A false positive means apply deletes a real doc comment, so detection demands a positive license signal inside the leading comment block and never scans below the header.
- **`internal/render`** — produces canonical header text *and* performs the byte-level splice (insert/replace). Rendering and mutation live together so producer and splicer can't drift on header shape, line endings, or the blank-line separator. Carries explicit SAFETY INVARIANTS in its package doc.
- **`internal/applier`** — Mode B orchestration: render per file, compute unified diff, atomically (temp-then-rename) write or replace. Also manages top-level `LICENSE` + `LICENSES/<id>.txt` (REUSE).
- **`internal/resolve`** — per-ecosystem dependency license resolution (maven/npm/gradle). Tiered: on-disk metadata by default, opt-in tool shell-out behind a flag. **Never guesses** — an unresolvable dep is reported `unresolved` with a reason.
- **`internal/report`** — folds per-file + dependency + policy results into one `Report`, renders text/JSON/Markdown deterministically (byte-identical for identical input regardless of order), and owns `Report.Passed` (the `check` exit decision) plus the "not legal advice" disclaimer.
- **`internal/policy`** — classifies licenses and enforces allow/deny/required + curated incompatibilities, emitting violation tokens that drive `check`'s `fail_on` exit. Routes classification through `internal/spdx` so there's no second drifting opinion.
- **`internal/spdx`** — exposes the vendored snapshot with zero runtime network. Two surfaces: `Validate(id)` accepts *any* real SPDX id (permissive — any valid id is a legal target); `Lookup(id)` returns full text/header/category only for the curated set the tool ships rendering for.
- **`internal/gitutil`** — shells out to `git` (clean-tree check, first-commit year, atomic commit) because `git ls-files` is the authoritative `.gitignore` honorer.
### Command layer (`cmd/license-tool`)
`commands.go` builds the cobra tree (`audit`, `check`, `apply`, `license`, `init`, `version`) and wires the concrete `internal` packages into a `report.Pipeline` indirection (`buildAuditPipeline`) so the report layer stays decoupled from enumeration/detection. `main.go` is a thin shim mapping errors to exit codes via `commandError`/`withExitCode`.
## Safety invariants (these are the product, not nice-to-haves)
- **`apply` and `license` are dry-run by default.** Writing without an explicit `--write` is a bug. They require a clean git tree (`--allow-dirty` to override), refuse non-git dirs without `--force`, write atomically, and are idempotent — a second apply is a no-op and headers never stack. Git is the undo.
- **Never invent license text.** New/updated text comes from re-running `gen_spdx.go` against a pinned upstream ref, never hand-edited. (The one documented override is the wrapped AGPL standard header — see `internal/spdx`.)
- **Exit codes are an API:** `0` ok · `1` policy/check failure · `2` usage · `3` write refused · `4` internal.
## Workflow
Feature branches → PR into `develop` → promote green commits to `main`; tags `vX.Y.Z` from `main` drive GoReleaser (GitHub Release + Homebrew cask). When changing header generation, detection, comment-syntax, or policy, add/adjust tests and update golden files (explain the golden diff in the PR). Release flow: `RELEASING.md`.
+2
View File
@@ -2,4 +2,6 @@
Use feature branches for normal work, merge through `develop`, and promote only green, release-ready commits to `main`. The required local quality gate is the same shape as CI: `gofmt -l .`, `go vet ./...`, `golangci-lint run`, race-enabled tests with `cover.out`, the configured 100% coverage checker, and `go build ./...`.
The interactive `init` wizard is a thin Bubble Tea adapter in `cmd/license-tool/wizard.go` (excluded from coverage). All behavior — the form state machine, live validation, adaptive layout, view rendering, repo detection (`Seed`), and the single `Translate` from answers to config — lives in `internal/initwizard` and is unit-tested to 100%. Keep the adapter logic-free: forward keystrokes to `FormState` transitions and render via `initwizard.Render`. There is exactly one `Answers`->`Config` translator (`initwizard.Translate`); do not reintroduce a second one for the preview.
Releases are documented in [RELEASING.md](RELEASING.md). In short: update [CHANGELOG.md](CHANGELOG.md), run `./scripts/verify-release.sh`, commit the release notes with a conventional commit, push `main`, wait for CI and CodeQL, then push a signed semver tag such as `v0.3.0`. The tag triggers GoReleaser, which creates the GitHub Release and publishes the Homebrew cask.
+85 -8
View File
@@ -54,8 +54,43 @@ license-tool audit --format json --output audit.json
license-tool audit --format markdown --output LICENSE-AUDIT.md
license-tool audit --deps=false
license-tool audit --resolve-deps tool
license-tool audit --summary # counts only; no per-file/dependency lists
license-tool audit --group-by license # group source files by license
license-tool audit --group-by directory # group by top-level directory
license-tool audit --summary --group-by type # per-group counts only
license-tool audit --group-by directory --depth 2 # group by two path segments
license-tool audit --group-by license --sort count # most common first
license-tool audit --only missing,copyleft # list only problem files
```
By default the report lists every source file. `--summary` keeps the `findings:`
overview and the by-SPDX / by-category / by-file-type rollups but omits the per-file
and per-dependency lists and any pending diffs. `--group-by license|category|type|directory`
organizes the source-file listing under each value of the dimension instead of a flat
list; combined with `--summary` it shows per-group counts only. Each group reports its
worst license **risk** (`high`/`medium`/`low`, or `unknown` for a headerless group)
and, for non-license groupings, its license breakdown, so a `directory` view is not
license-blind. Group risk is **policy-aware**: it escalates to `high` when the group's
license is party to a repo-level hard incompatibility (e.g. an Apache group beside an
AGPL group) or a file in it carries a policy violation, so a group is never reported
"low" while it is actually an audit liability. `--sort key|count`
orders rollups and groups; `--depth N` widens directory keys to N path segments;
`--only missing,unknown,copyleft,violations` narrows the file listing to problem files
without distorting the rollups. The flags apply to text, markdown, and JSON (which
always emits the complete report — `--summary` only trims the human formats). An
unknown `--group-by`/`--sort`/`--only` value is a usage error.
Every format now carries the `findings` summary (coverage, license mix, risk level,
copyleft, dependency resolution, policy) and **attributable policy violations** — each
violation names the offending license, the rule, and the file (text/markdown) or a
structured `violationDetails` array (JSON), so both engineers and tools can see *why*
a check failed, not just that it did. Rollups show per-row percentages and totals.
The tool's own `.license-tool.yaml` is treated as metadata, not coverable source: it
is listed as skipped (reason `tool config`) and never counted toward source-header
coverage, so it cannot inflate the missing-header tally — and `check` no longer fails
on the config for lacking a header.
Audit output is read-only. Audit always prints a "not legal advice" disclaimer.
Text output starts with a `findings:` summary that calls out source-file header
coverage, license types, unknown or unrecognized license ids, copyleft licenses,
@@ -71,6 +106,41 @@ findings:
policy: FAIL (1: policy-violation)
```
`--group-by` turns the flat file list into a grouped view. For example,
`audit --group-by license` shows exactly which files carry which license, and where
the gaps are:
```text
source files by license:
(no-header) (1) [risk: unknown]
src/legacy.go [no managed header]
AGPL-3.0-or-later (2) [risk: high]
src/a.go [AGPL-3.0-or-later]
src/b.go [AGPL-3.0-or-later]
MIT (2) [risk: low]
web/c.ts [MIT]
web/d.ts [MIT]
(skipped: 6)
```
For a tree-shaped view of coverage, `audit --summary --group-by directory` answers
"which parts of the repo are licensed" at a glance, with no per-file noise. Each
directory carries its worst risk and its license breakdown, so the grouping is not
license-blind:
```text
source files by directory:
src (3) [risk: high]
licenses: (no-header) 1, AGPL-3.0-or-later 2
web (2) [risk: low]
licenses: MIT 2
(skipped: 6)
```
And `audit --group-by license --format json` emits the same grouping as machine data
(a `groups` array of `{key, count, risk, licenses, files}`) for dashboards and CI
summaries.
Dependency audit discovers manifests in the root and in subdirectories, while
honoring git, `.gitignore`, configured excludes, and common vendor-heavy
directories such as `node_modules`, `vendor`, `build`, `dist`, and `.gradle`.
@@ -114,15 +184,22 @@ license-tool license --license AGPL-3.0-or-later --holder "Kingsrook, LLC" --wri
### init
Scaffold a `.license-tool.yaml` for the repo. On a TTY, `init` runs a wizard with
a filterable SPDX license picker that lists common licenses first, then prompts
for holder, year policy, header style, and whether to manage the top-level
`LICENSE` files. The holder prompt requires a non-empty value, and the license
and year prompts validate against the same parsers used by the flag path.
Scaffold a `.license-tool.yaml` for the repo. On a TTY, `init` opens a
single-screen form pre-filled from repo detection (existing headers, a top-level
`LICENSE`, and the git author seed the license, holder, and manage choices, each
badged `(detected)` and fully editable). Every field is visible and editable in
any order; complex fields (the searchable license picker and the include/exclude
glob lists) expand inline. A persistent live preview re-renders on every edit and
adapts to terminal width: the example source file with the generated header, the
YAML to be written, both side by side when wide, stacked when narrow. The example
source is chosen from supported language families detected in the repo, with C as
the fallback. Validation is live; the write action stays disabled until every
field is valid. Writing over an existing `.license-tool.yaml` asks first.
```bash
license-tool init
license-tool init --license MIT --holder "Example, Inc." --year git --style reuse+notice
license-tool init --include "src/**" --exclude "**/generated/**"
```
In a non-TTY environment, `init` skips the wizard, uses only the supplied flags,
@@ -137,8 +214,6 @@ overwrite it unless `--force` is passed.
--include <glob> restrict to matching paths (repeatable)
--exclude <glob> skip matching paths (repeatable)
--no-gitignore do not honor .gitignore during enumeration
--quiet, -q suppress non-essential output
--verbose, -v verbose output
```
### Exit codes
@@ -153,7 +228,7 @@ overwrite it unless `--force` is passed.
## Configuration
Configuration is layered. Precedence, high to low: flags, the per-repo `.license-tool.yaml`, the user/global config (`$XDG_CONFIG_HOME/license-tool/config.yaml`), then built-in defaults. The committed `.license-tool.yaml` declares the repo's license identity and doubles as the `check` expectation. For `init`, missing required fields prompt through the wizard on a TTY; non-TTY runs skip the wizard, use flags only, and report missing or invalid values as usage errors.
Configuration is layered. Precedence, high to low: flags, the per-repo `.license-tool.yaml`, the user/global config (`$XDG_CONFIG_HOME/license-tool/config.yaml`), then built-in defaults. The committed `.license-tool.yaml` declares the repo's license identity and doubles as the `check` expectation. `include` uses the highest non-empty layer, while `exclude` accumulates across layers. For `init`, missing required fields prompt through the wizard on a TTY; non-TTY runs skip the wizard, use flags only, and report missing or invalid values as usage errors.
```yaml
# .license-tool.yaml (committed per repo; doubles as the check expectation)
@@ -162,6 +237,8 @@ holder: "Kingsrook, LLC" # copyright holder
year: git # current | YYYY | YYYY-YYYY | git
style: reuse+notice # reuse | notice | reuse+notice
manage_license_file: true # write top-level LICENSE + LICENSES/<id>.txt
include: # optional globs limiting files considered
- "src/**"
exclude: # gitignore-style, in addition to .gitignore
- "**/generated/**"
- "**/*.pb.go"
+220
View File
@@ -0,0 +1,220 @@
# Releases
This is the full, end-to-end runbook for cutting a `license-tool` release. It expands
on [RELEASING.md](./RELEASING.md) (which is the short reference) with the complete
process: integrating feature work, choosing a version, running the gate, tagging, and
verifying the published artifacts.
Releases are built by `scripts/build-release.sh` and published to the Gitea release by
the tag build of `.gitea/workflows/build.yaml` (see RELEASING.md). There is **no version constant in source** — the
version is injected at build time via `-ldflags` (`main.version`, `main.commit`,
`main.date`) when the workflow runs against a pushed tag.
---
## 1. Branch model
Gitflow-style:
- `feature/*` — active work; each opens a PR into `develop`.
- `develop` — the integration branch and the repository default branch. Merged feature
work lands here. **Issues with `Closes #N` close when their PR merges into `develop`.**
- `main` — production. Only release-ready commits land here.
- Tags `vX.Y.Z` on `main` trigger the published release.
```
feature/GH-XX ──PR──▶ develop ──merge──▶ main ──tag vX.Y.Z──▶ Release workflow
```
---
## 2. Versioning (SemVer, pre-1.0 aware)
Pick the next version from the nature of the `[Unreleased]` changelog entries:
| Change set | Bump | Example |
|------------|------|---------|
| New features (backward-compatible) | minor | 0.3.0 → 0.4.0 |
| Only bug fixes | patch | 0.4.0 → 0.4.1 |
| Breaking changes | minor while < 1.0 (major once ≥ 1.0) | 0.4.0 → 0.5.0 |
While `0.x`, a minor bump may legitimately include a breaking change (e.g. removing a
flag); call it out under `### Removed`/`### Changed` and in the PR. Record the decision
in the changelog so the bump is self-documenting.
---
## 3. Integrate feature PRs into `develop`
Most releases bundle several feature PRs. When multiple PRs are open they will conflict
(at minimum on `CHANGELOG.md`, often in shared files like `cmd/.../commands.go`), so
integrate them locally in one pass and validate the **combined** result — a set of
individually-green PRs can still break when merged together.
```bash
git fetch origin
git checkout develop && git reset --hard origin/develop
# Merge each feature branch with a merge commit (so GitHub marks the PR "Merged"
# and the linked issue closes on the merge into develop).
for b in feature/GH-AAA feature/GH-BBB ... ; do
git merge --no-ff "$b" -m "Merge $b (#N): <summary>"
# Resolve conflicts:
# - CHANGELOG.md / SESSION-STATE.md: take one side now (git checkout --ours <f>);
# you will rewrite the changelog cleanly in step 4.
# - code/test files: keep the UNION of independent additions; never blindly --ours
# a file that also received auto-merged hunks from the other side, or you will
# drop them. Watch for cross-branch refactors (a symbol renamed on one branch and
# referenced on another) — the build/vet step below catches these.
git add -A && git commit --no-edit
done
```
Conflict-resolution tips learned in practice:
- A rename on one branch (`repoConfigName` → `RepoConfigName`) plus a new reference on
another branch surfaces as an `undefined:` build error, not a merge conflict — always
run `go build ./...` after each merge (do **not** pipe it to `tail`, or you mask the
exit code).
- A function moved between packages on one branch with a test left behind on another
yields an orphaned test referencing a now-undefined symbol — delete the orphan (its
coverage moved with the function).
---
## 4. Finalize the changelog
Rewrite the `[Unreleased]` section into a dated version section with every merged
change, grouped Added / Changed / Removed / Fixed, each line referencing its issue:
```markdown
## [Unreleased]
## [X.Y.Z] - YYYY-MM-DD
### Install
` ``bash
brew install --cask KofTwentyTwo/tap/license-tool
` ``
### Added
- ... (#NN)
### Changed
- ... (#NN)
### Removed
- ... (#NN)
### Fixed
- ... (#NN)
```
The date **must** be today's date in `YYYY-MM-DD` — `scripts/verify-release.sh` checks
it. Keep an empty `## [Unreleased]` header above the new section.
---
## 5. Run the pre-release gate
From the repo root, the full local gate (the same checks CI enforces, plus the
GoReleaser dry run):
```bash
./scripts/verify-release.sh
```
It runs, in order: `gofmt`, `go vet`, `golangci-lint`, race tests with the CI coverage
profile, the 100% coverage checker, `goreleaser check`, `goreleaser release --snapshot
--clean`, tap-repo/token existence, the CHANGELOG date check, the SECURITY/README
content checks, and a clean-tree check.
If `goreleaser` is not installed locally, run the non-GoReleaser gates directly and let
CI run GoReleaser on the tag:
```bash
gofmt -l . # expect no output
go vet ./...
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run
go test ./... -race -coverpkg=./internal/...,./cmd/... -covermode=atomic -coverprofile=cover.out
go run github.com/vladopajic/go-test-coverage/v2@v2.18.8 --config=.testcoverage.yml
```
The coverage gate is governed by `.testcoverage.yml` (the interactive TUI in
`cmd/.../main.go` and `wizard.go` is excluded as untestable). The checker prints
`Total coverage threshold (100%) satisfied: PASS` when the gate is met.
---
## 6. Push `develop`, then promote to `main`
```bash
git push origin develop
# wait for develop CI + CodeQL to go green:
gh run list --branch develop --limit 5
git checkout main && git reset --hard origin/main
git merge --no-ff develop -m "Release vX.Y.Z"
git push origin main
# wait for main CI + CodeQL to go green:
gh run list --branch main --limit 5
```
Branch protection currently enforces no required checks, so direct pushes succeed; still
wait for CI/CodeQL to pass on the release commit before tagging.
---
## 7. Tag and publish
From `main`, with CI green:
```bash
git tag -s vX.Y.Z -m "vX.Y.Z" # signed annotated tag preferred
git push origin vX.Y.Z
```
Pushing the tag triggers the `Release` workflow, which runs `goreleaser release --clean`
to: cross-compile macOS/Linux arm64+amd64 and Windows amd64, build archives +
`checksums.txt`, create the GitHub Release, and publish the Homebrew cask to
`KofTwentyTwo/homebrew-tap`.
A tag with a prerelease suffix (`vX.Y.Z-rc.N`) is published as a GitHub **prerelease**
(`release.prerelease: auto`); use these from a `release/*` or `rc/*` branch to stabilize.
---
## 8. Verify the published release
```bash
gh run watch $(gh run list --workflow Release --limit 1 --json databaseId -q '.[0].databaseId')
gh release view vX.Y.Z # assets + checksums present
git -C "$(brew --repository KofTwentyTwo/homebrew-tap)" log --oneline -3 # cask bumped
brew update && brew install --cask KofTwentyTwo/tap/license-tool && license-tool version
```
`license-tool version` should print the new `vX.Y.Z`, the release commit, and the build
date injected by the workflow.
---
## 9. Required GitHub setup (one-time)
- Public Homebrew tap repo: `KofTwentyTwo/homebrew-tap`.
- Actions secret on `KofTwentyTwo/license-tool` named `HOMEBREW_TAP_TOKEN` (a token that
can push to the tap).
- `gh auth status` must show write access before tagging.
---
## 10. If a release goes wrong
- **Workflow failed before publishing:** fix forward on `main`, then move the tag to the
new commit and re-push:
```bash
git tag -fas vX.Y.Z -m "vX.Y.Z"
git push --force origin vX.Y.Z
```
(Only safe before anyone has installed the release.)
- **Bad release already published:** do **not** reuse the version. Ship a `vX.Y.(Z+1)`
patch with the fix; optionally mark the bad GitHub Release as a prerelease or delete it
with `gh release delete vX.Y.Z`. The Homebrew cask should be rolled forward by the new
tag, not hand-edited.
- **Changelog date check failed in CI:** the `## [X.Y.Z] - YYYY-MM-DD` date must match the
release; correct it on `main` and re-tag.
+16 -40
View File
@@ -1,6 +1,13 @@
# Releasing
Releases are built with [GoReleaser](https://goreleaser.com) and published by GitHub Actions.
> For the complete end-to-end runbook (integrating multiple feature PRs, versioning,
> conflict resolution, tagging, and post-release verification) see [RELEASES.md](./RELEASES.md).
> This file is the short reference.
CI and releases run on Gitea Actions (`.gitea/workflows/build.yaml`, the shared
`KofTwentyTwo/ci-workflows` Go workflow). A `v*` tag runs every gate, then
`scripts/build-release.sh` builds the archives and CI attaches them with a `SHA256SUMS`
to the tag's Gitea release. `.goreleaser.yaml` remains for local snapshot checks.
## Changelog Management
@@ -68,8 +75,8 @@ git tag vX.Y.Z-rc.N
git push origin vX.Y.Z-rc.N
```
The `Release` workflow builds all configured target binaries and creates a GitHub prerelease
(the `release.prerelease: auto` setting marks any tag with a prerelease suffix as a prerelease).
The tag build creates a Gitea prerelease (any tag with a `-` suffix) with the archives and
`SHA256SUMS`.
## Production Release
@@ -82,7 +89,7 @@ From `main`:
git commit -am "docs: changelog for v$VERSION"
```
3. Push `main` and wait for CI and CodeQL to pass on the release commit.
3. Push `main` and wait for CI to pass on the release commit.
4. Tag and push. Prefer a signed annotated tag:
```bash
@@ -90,41 +97,10 @@ From `main`:
git push origin vX.Y.Z
```
The `Release` workflow runs `goreleaser release --clean`, which cross-compiles the
mac/linux arm64+amd64 and windows amd64 binaries, builds the archives and `checksums.txt`,
creates the GitHub Release, and publishes the Homebrew cask to:
The tag build runs every CI gate, then `scripts/build-release.sh <version>` cross-compiles the
mac/linux arm64+amd64 and windows amd64 binaries into `dist/` archives, and CI attaches them
and a `SHA256SUMS` to the tag's Gitea release.
```text
KofTwentyTwo/homebrew-tap
```
The Homebrew cask in `KofTwentyTwo/homebrew-tap` is not updated by CI; update it by hand from
the release's `SHA256SUMS` until the tap has its own workflow.
End users install with:
```bash
brew install --cask KofTwentyTwo/tap/license-tool
```
## Required GitHub Setup
Create or verify a public Homebrew tap repository:
```text
KofTwentyTwo/homebrew-tap
```
Add a repository Actions secret on `KofTwentyTwo/license-tool` named:
```text
HOMEBREW_TAP_TOKEN
```
The token needs contents write access to `KofTwentyTwo/homebrew-tap`, because the release
workflow checks out that repository and commits cask updates. Do not hardcode this token
in workflow files.
Before the first production release:
- Protect or manage the `main` and `develop` branches according to the branch flow above.
- Confirm `HOMEBREW_TAP_TOKEN` can write to `KofTwentyTwo/homebrew-tap`.
- Confirm the local push path works with `git ls-remote --heads origin`.
- Re-run `goreleaser check` and `goreleaser release --snapshot --clean`.
+86 -63
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"os"
"path"
"path/filepath"
"sort"
"strings"
@@ -16,6 +17,7 @@ import (
"github.com/KofTwentyTwo/license-tool/internal/config"
"github.com/KofTwentyTwo/license-tool/internal/detect"
"github.com/KofTwentyTwo/license-tool/internal/enumerate"
"github.com/KofTwentyTwo/license-tool/internal/initwizard"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/KofTwentyTwo/license-tool/internal/report"
"github.com/KofTwentyTwo/license-tool/internal/resolve"
@@ -30,8 +32,6 @@ type sharedFlags struct {
include []string
exclude []string
noGitignore bool
quiet bool
verbose bool
}
// newRootCmd assembles the full command tree. WHY a constructor that takes
@@ -53,8 +53,6 @@ func newRootCmd(info buildInfo) *cobra.Command {
pf.StringArrayVar(&shared.include, "include", nil, "glob of files to include (repeatable)")
pf.StringArrayVar(&shared.exclude, "exclude", nil, "glob of files to exclude (repeatable)")
pf.BoolVar(&shared.noGitignore, "no-gitignore", false, "do not inherit .gitignore on the non-git walk")
pf.BoolVarP(&shared.quiet, "quiet", "q", false, "suppress non-essential output")
pf.BoolVarP(&shared.verbose, "verbose", "v", false, "verbose diagnostic output")
root.AddCommand(
newAuditCmd(shared),
@@ -83,6 +81,42 @@ type auditFlags struct {
noDeps bool
resolveDeps string
failOn []string
summary bool
groupBy string
sort string
depth int
only string
}
// renderOptions parses the summary/group-by/sort flags into report.RenderOptions,
// surfacing an unknown --group-by or --sort value as a usage error.
func (f auditFlags) renderOptions() (report.RenderOptions, error) {
dim, err := report.ParseGroupBy(f.groupBy)
if err != nil {
return report.RenderOptions{}, err
}
byCount, err := parseSort(f.sort)
if err != nil {
return report.RenderOptions{}, err
}
only, err := report.ParseOnly(f.only)
if err != nil {
return report.RenderOptions{}, err
}
return report.RenderOptions{Summary: f.summary, GroupBy: dim, SortByCount: byCount, DirectoryDepth: f.depth, Only: only}, nil
}
// parseSort maps the --sort token to the by-count flag. "" and "key" sort
// alphabetically; "count" sorts by descending frequency.
func parseSort(raw string) (bool, error) {
switch raw {
case "", "key":
return false, nil
case "count":
return true, nil
default:
return false, fmt.Errorf("audit: unknown sort %q (expected key|count)", raw)
}
}
func (f auditFlags) includeDeps() bool {
@@ -108,6 +142,10 @@ func newAuditCmd(shared *sharedFlags) *cobra.Command {
if validateErr := validateResolveDeps(f.resolveDeps); validateErr != nil {
return usageError(validateErr)
}
renderOpts, err := f.renderOptions()
if err != nil {
return usageError(err)
}
r, err := report.Audit(path, cfg, report.Options{
Format: format,
IncludeDeps: f.includeDeps(),
@@ -117,7 +155,7 @@ func newAuditCmd(shared *sharedFlags) *cobra.Command {
if err != nil {
return internalError(err)
}
if err := renderCommandReport(cmd, f.output, r, format); err != nil {
if err := renderCommandReport(cmd, f.output, r, format, renderOpts); err != nil {
return err
}
return nil
@@ -153,6 +191,10 @@ func newCheckCmd(shared *sharedFlags) *cobra.Command {
if validateErr := validateResolveDeps(f.resolveDeps); validateErr != nil {
return usageError(validateErr)
}
renderOpts, err := f.renderOptions()
if err != nil {
return usageError(err)
}
r, err := report.Audit(path, cfg, report.Options{
Format: format,
IncludeDeps: f.includeDeps(),
@@ -162,7 +204,7 @@ func newCheckCmd(shared *sharedFlags) *cobra.Command {
if err != nil {
return internalError(err)
}
if err := renderCommandReport(cmd, f.output, r, format); err != nil {
if err := renderCommandReport(cmd, f.output, r, format, renderOpts); err != nil {
return err
}
if !r.Passed {
@@ -184,6 +226,11 @@ func bindAuditFlags(cmd *cobra.Command, f *auditFlags, isCheck bool) {
cmd.Flags().BoolVar(&f.deps, "deps", true, "resolve dependency licenses")
cmd.Flags().BoolVar(&f.noDeps, "no-deps", false, "skip dependency license resolution")
cmd.Flags().StringVar(&f.resolveDeps, "resolve-deps", "ondisk", "dependency resolution tier: ondisk|tool|off")
cmd.Flags().BoolVar(&f.summary, "summary", false, "counts only: omit the per-file and per-dependency lists")
cmd.Flags().StringVar(&f.groupBy, "group-by", "", "group source files by: license|category|type|directory")
cmd.Flags().StringVar(&f.sort, "sort", "key", "rollup/group order: key|count")
cmd.Flags().IntVar(&f.depth, "depth", 1, "directory grouping depth (with --group-by directory)")
cmd.Flags().StringVar(&f.only, "only", "", "list only problem files: missing|unknown|copyleft|violations (comma-separated)")
if isCheck {
cmd.Flags().StringArrayVar(&f.failOn, "fail-on", []string{"missing-header", "unknown-license", "policy-violation"}, "conditions that cause a non-zero exit")
}
@@ -223,7 +270,7 @@ func newApplyCmd(shared *sharedFlags) *cobra.Command {
// for write operations, so no second check is needed here.
r, err := applier.Apply(path, cfg, applier.Options{
Write: f.write,
Includes: shared.include,
Includes: cfg.Includes,
AllowDirty: f.allowDirty,
Force: f.force,
NoGitignore: shared.noGitignore,
@@ -261,7 +308,7 @@ func newLicenseCmd(shared *sharedFlags) *cobra.Command {
// for write operations, so no second check is needed here.
results, err := applier.License(path, cfg, applier.Options{
Write: f.write,
Includes: shared.include,
Includes: cfg.Includes,
AllowDirty: f.allowDirty,
Force: f.force,
NoGitignore: shared.noGitignore,
@@ -310,9 +357,9 @@ var createReportFile = func(name string) (io.WriteCloser, error) {
return os.Create(name)
}
func renderCommandReport(cmd *cobra.Command, output string, r model.Report, format report.Format) error {
func renderCommandReport(cmd *cobra.Command, output string, r model.Report, format report.Format, opts report.RenderOptions) error {
if output == "" {
if err := report.Render(cmd.OutOrStdout(), r, format); err != nil {
if err := report.RenderWithOptions(cmd.OutOrStdout(), r, format, opts); err != nil {
return internalError(err)
}
return nil
@@ -322,7 +369,7 @@ func renderCommandReport(cmd *cobra.Command, output string, r model.Report, form
if err != nil {
return internalError(fmt.Errorf("report: create output %s: %w", output, err))
}
renderErr := report.Render(f, r, format)
renderErr := report.RenderWithOptions(f, r, format, opts)
closeErr := f.Close()
if renderErr != nil {
return internalError(renderErr)
@@ -389,49 +436,10 @@ func bindApplyFlags(cmd *cobra.Command, f *applyFlags) {
}
// 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.
// inject richer wizard answers without a real terminal. Production points at the
// Bubble Tea collector, which no-ops when interactive is false.
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 or
// unrenderable license and an empty holder are 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 _, ok := spdx.Lookup(a.License); !ok {
return model.Config{}, fmt.Errorf("init: %q is a recognized SPDX license identifier, but license-tool cannot render it", 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 {
f := &applyFlags{}
cmd := &cobra.Command{
@@ -441,18 +449,21 @@ func newInitCmd(shared *sharedFlags) *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
path := argPath(args)
out := cmd.OutOrStdout()
a := initAnswers{
License: f.license,
Holder: f.holder,
Year: f.year,
Style: f.style,
ManageLicenseFile: true,
Excludes: shared.exclude,
a := initwizard.Answers{
License: initwizard.LicenseAnswer{SPDXID: f.license},
Identity: initwizard.IdentityAnswer{Holder: f.holder, Year: f.year},
HeaderStyle: initwizard.HeaderStyleAnswer{Style: f.style},
LicenseFiles: initwizard.LicenseFilesAnswer{Manage: true},
Coverage: initwizard.CoverageAnswer{
Include: shared.include,
Exclude: shared.exclude,
},
}
if err := interactiveCollect(&a, isTTY()); err != nil {
a, err := interactiveCollect(path, a, isTTY())
if err != nil {
return usageError(err)
}
cfg, err := answersToConfig(a)
cfg, err := initwizard.Translate(a, initwizard.TranslateOptions{})
if err != nil {
return usageError(err)
}
@@ -490,12 +501,16 @@ func newVersionCmd(info buildInfo) *cobra.Command {
// content (the report layer needs the bytes for detection); Detect is the detector
// directly; ResolveDeps iterates the ecosystem resolvers detected at the root and
// concatenates their findings (unresolved entries included, never guessed).
// reasonToolConfig is the skip reason recorded for the tool's own config file, so it
// is shown in the file listing but excluded from source/header coverage tallies.
const reasonToolConfig = "tool config"
func buildAuditPipeline(cfg model.Config, shared *sharedFlags) report.Pipeline {
classify := config.ContentLookupFunc(cfg)
return report.Pipeline{
Enumerate: func(root string, excludes []string) ([]report.SourceFile, error) {
entries, err := enumerate.WithContent(root, enumerate.Options{
Includes: shared.include,
Includes: cfg.Includes,
Excludes: excludes,
NoGitignore: shared.noGitignore,
}, classify)
@@ -510,7 +525,15 @@ func buildAuditPipeline(cfg model.Config, shared *sharedFlags) report.Pipeline {
Skip: e.Skip,
SkipReason: e.SkipReason,
}
if !e.Skip {
// The tool's own .license-tool.yaml is metadata, not coverable source: it
// never carries a managed header, so counting it would inflate
// sourceTotal/sourceMissing. Mark it skipped (still listed, with a reason)
// rather than counting it as a headerless source file.
if !sf.Skip && path.Base(e.Path) == config.RepoConfigName {
sf.Skip = true
sf.SkipReason = reasonToolConfig
}
if !sf.Skip {
content, rerr := os.ReadFile(e.AbsPath)
if rerr != nil {
sf.Skip = true
+298 -76
View File
@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -16,6 +17,7 @@ import (
"github.com/KofTwentyTwo/license-tool/internal/config"
"github.com/KofTwentyTwo/license-tool/internal/enumerate"
"github.com/KofTwentyTwo/license-tool/internal/initwizard"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/KofTwentyTwo/license-tool/internal/report"
"github.com/KofTwentyTwo/license-tool/internal/spdx"
@@ -159,9 +161,28 @@ func TestNewRootCmd(t *testing.T) {
}
// Persistent flags are bound on the root.
for _, f := range []string{"config", "include", "exclude", "no-gitignore", "quiet", "verbose"} {
for _, f := range []string{"config", "include", "exclude", "no-gitignore"} {
assert.NotNil(t, root.PersistentFlags().Lookup(f), "persistent flag %q", f)
}
// The no-op --quiet/--verbose flags (and their -q/-v shorthands) were removed
// in #34: the CLI had no non-essential output to gate and no diagnostic stream
// to emit, so the flags advertised behavior they never delivered.
for _, f := range []string{"quiet", "verbose"} {
assert.Nil(t, root.PersistentFlags().Lookup(f), "removed persistent flag %q must not be registered", f)
}
}
func TestQuietVerboseFlagsRemoved(t *testing.T) {
isolateEnv(t)
dir := fixtureDir(t)
for _, arg := range []string{"--quiet", "-q", "--verbose", "-v"} {
t.Run(arg, func(t *testing.T) {
_, stderr, code := executeRoot(t, "audit", arg, dir)
assert.Equal(t, exitUsage, code, "removed flag %q should yield a usage error", arg)
assert.Contains(t, stderr, "unknown")
})
}
}
func TestArgPath(t *testing.T) {
@@ -267,6 +288,64 @@ func TestAuditCommand(t *testing.T) {
assert.Contains(t, out, `"schema": "license-tool/report/v1"`)
})
t.Run("tool config file is excluded from source coverage", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, ".license-tool.yaml", configYAML)
writeFile(t, dir, "main.go", "package main\n\nfunc main() {}\n")
out, err := runRoot(t, "audit", dir, "--format", "json", "--deps=false")
require.NoError(t, err)
var got struct {
Findings struct {
SourceTotal int `json:"sourceTotal"`
SourceMissing int `json:"sourceMissing"`
} `json:"findings"`
}
require.NoError(t, json.Unmarshal([]byte(out), &got))
assert.Equal(t, 1, got.Findings.SourceTotal, "only main.go is coverable source; the tool's own config is not")
assert.Equal(t, 1, got.Findings.SourceMissing, "the headerless config file must not inflate sourceMissing")
entry := jsonFileEntry(t, out, ".license-tool.yaml")
assert.Equal(t, true, entry["skipped"], ".license-tool.yaml should be skipped, not counted as source")
assert.Equal(t, "tool config", entry["skipReason"])
// Detection and policy never run on the skipped config, so its declared license
// does not leak into the report as a detected header or a violation.
assert.Equal(t, false, entry["hasHeader"], "no header is detected on the skipped config")
assert.NotContains(t, entry, "spdxId", "the config's declared license must not surface as a detected id")
assert.NotContains(t, entry, "violations", "the skipped config carries no policy violations")
})
t.Run("tool config is excluded when nested and under --only", func(t *testing.T) {
// Basename match excludes the config at any depth, and a headerless config must
// never leak into the --only=missing problem list.
dir := t.TempDir()
writeFile(t, dir, ".license-tool.yaml", configYAML)
writeFile(t, dir, filepath.Join("sub", ".license-tool.yaml"), configYAML)
writeFile(t, dir, "main.go", "package main\n\nfunc main() {}\n")
out, err := runRoot(t, "audit", dir, "--format", "json", "--deps=false", "--only", "missing")
require.NoError(t, err)
var got struct {
Findings struct {
SourceTotal int `json:"sourceTotal"`
} `json:"findings"`
Files []map[string]any `json:"files"`
}
require.NoError(t, json.Unmarshal([]byte(out), &got))
assert.Equal(t, 1, got.Findings.SourceTotal, "both root and nested config are excluded; only main.go counts")
// The --only=missing listing lists the headerless main.go but neither config.
listed := map[string]bool{}
for _, f := range got.Files {
listed[f["path"].(string)] = true
}
assert.True(t, listed["main.go"], "the headerless main.go is a missing-header problem file")
assert.False(t, listed[".license-tool.yaml"], "the root config must not appear in --only=missing")
assert.False(t, listed[filepath.ToSlash(filepath.Join("sub", ".license-tool.yaml"))], "the nested config must not appear in --only=missing")
})
t.Run("output file", func(t *testing.T) {
dir := fixtureDir(t)
outPath := filepath.Join(dir, "audit.json")
@@ -295,6 +374,30 @@ func TestAuditCommand(t *testing.T) {
assert.Contains(t, out, "# license-tool audit report")
})
t.Run("config include scopes source enumeration", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, ".license-tool.yaml", configYAML+"include: [\"included.go\"]\n")
writeFile(t, dir, "included.go", "package included\n")
writeFile(t, dir, "excluded.go", "package excluded\n")
out, err := runRoot(t, "audit", dir, "--deps=false")
require.NoError(t, err)
assert.Contains(t, out, "included.go")
assert.NotContains(t, out, "excluded.go")
})
t.Run("flag include overrides config include", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, ".license-tool.yaml", configYAML+"include: [\"repo.go\"]\n")
writeFile(t, dir, "repo.go", "package repo\n")
writeFile(t, dir, "flag.go", "package flag\n")
out, err := runRoot(t, "audit", dir, "--include", "flag.go", "--deps=false")
require.NoError(t, err)
assert.Contains(t, out, "flag.go")
assert.NotContains(t, out, "repo.go")
})
t.Run("default path resolves to dot", func(t *testing.T) {
// No path arg: argPath returns ".". Run from an isolated empty dir so the
// audit sees only its own (absent) config and produces a clean report.
@@ -454,6 +557,18 @@ func TestCheckCommand(t *testing.T) {
require.NoError(t, err)
})
t.Run("tool config does not trigger a missing-header check failure", func(t *testing.T) {
// The config is headerless YAML; under the default fail_on (which includes
// missing-header) it would fail check if counted as source. Excluding it lets a
// repo whose only other file is properly headered pass. This guards the audit
// pipeline's config exclusion against regression on the check exit code.
dir := t.TempDir()
writeFile(t, dir, ".license-tool.yaml", configYAML)
writeFile(t, dir, "main.go", "/* SPDX-License-Identifier: AGPL-3.0-or-later */\n\npackage main\n")
_, err := runRoot(t, "check", dir, "--deps=false")
require.NoError(t, err, "the tool's own config must not cause a missing-header check failure")
})
t.Run("fail-on flag overrides check policy", func(t *testing.T) {
dir := fixtureDir(t)
out, err := runRoot(t, "check", dir, "--fail-on", "unresolved-dependency", "--deps=false")
@@ -734,6 +849,25 @@ func TestApplyCommand(t *testing.T) {
assert.NotContains(t, string(ignored), "SPDX-License-Identifier")
})
t.Run("write honors config include scope", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, ".license-tool.yaml", configNoManagedYAML+"include: [\"included.go\"]\n")
writeFile(t, dir, "included.go", "package included\n")
writeFile(t, dir, "ignored.go", "package ignored\n")
initGitRepo(t, dir)
_, err := runRoot(t, "apply", dir, "--write")
require.NoError(t, err)
included, rerr := os.ReadFile(filepath.Join(dir, "included.go"))
require.NoError(t, rerr)
assert.Contains(t, string(included), "SPDX-License-Identifier: AGPL-3.0-or-later")
ignored, rerr := os.ReadFile(filepath.Join(dir, "ignored.go"))
require.NoError(t, rerr)
assert.NotContains(t, string(ignored), "SPDX-License-Identifier")
})
t.Run("allow-dirty commit leaves unrelated changes uncommitted", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, ".license-tool.yaml", configNoManagedYAML)
@@ -828,6 +962,31 @@ func TestLicenseCommand(t *testing.T) {
assert.True(t, os.IsNotExist(statErr))
})
t.Run("symlinked LICENSE is refused with the write-refused exit code", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation is restricted on windows")
}
dir := fixtureDir(t)
initGitRepo(t, dir)
target := filepath.Join(dir, "LICENSES", "AGPL-3.0-or-later.txt")
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755))
require.NoError(t, os.WriteFile(target, []byte("real license text\n"), 0o644))
require.NoError(t, os.Symlink(target, filepath.Join(dir, "LICENSE")))
_, err := runRoot(t, "license", dir, "--write", "--allow-dirty")
require.Error(t, err)
assert.Contains(t, err.Error(), "refusing to write")
assert.Equal(t, exitWriteRefused, exitCode(err), "symlink refusal must map to the write-refused exit code")
// The link survives as a link and its target is untouched.
fi, lerr := os.Lstat(filepath.Join(dir, "LICENSE"))
require.NoError(t, lerr)
assert.NotZero(t, fi.Mode()&os.ModeSymlink)
got, rerr := os.ReadFile(target)
require.NoError(t, rerr)
assert.Equal(t, "real license text\n", string(got))
})
t.Run("commit flags create scoped license-file commit", func(t *testing.T) {
dir := fixtureDir(t)
initGitRepo(t, dir)
@@ -876,9 +1035,9 @@ func TestInitCommand(t *testing.T) {
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.
// .license-tool.yaml purely from flags.
dir := t.TempDir()
out, err := runRoot(t, "init", dir, "--license", "MIT", "--holder", "Acme")
out, err := runRoot(t, "init", dir, "--license", "MIT", "--holder", "Acme", "--include", "src/**", "--exclude", "vendor/**")
require.NoError(t, err)
target := filepath.Join(dir, ".license-tool.yaml")
assert.Contains(t, out, "wrote "+target)
@@ -887,6 +1046,8 @@ func TestInitCommand(t *testing.T) {
require.NoError(t, lerr)
assert.Equal(t, "MIT", cfg.License)
assert.Equal(t, "Acme", cfg.Holder)
assert.Equal(t, []string{"src/**"}, cfg.Includes)
assert.Equal(t, []string{"vendor/**"}, cfg.Excludes)
})
t.Run("missing license errors", func(t *testing.T) {
@@ -925,66 +1086,6 @@ func TestInitCommand(t *testing.T) {
})
}
// 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")
})
}
func TestParseFailOnFlags(t *testing.T) {
got, err := parseFailOnFlags([]string{"missing-header, unknown-license", "policy-violation", "unresolved-dependency"})
require.NoError(t, err)
@@ -1005,12 +1106,12 @@ func TestRenderCommandReportErrors(t *testing.T) {
var out bytes.Buffer
root.SetOut(&out)
err := renderCommandReport(root, "", model.Report{}, report.Format(99))
err := renderCommandReport(root, "", model.Report{}, report.Format(99), report.RenderOptions{})
require.Error(t, err)
assert.Equal(t, exitInternal, exitCode(err))
assert.Empty(t, out.String())
err = renderCommandReport(root, filepath.Join(t.TempDir(), "report.txt"), model.Report{}, report.Format(99))
err = renderCommandReport(root, filepath.Join(t.TempDir(), "report.txt"), model.Report{}, report.Format(99), report.RenderOptions{})
require.Error(t, err)
assert.Equal(t, exitInternal, exitCode(err))
@@ -1020,7 +1121,7 @@ func TestRenderCommandReportErrors(t *testing.T) {
}
t.Cleanup(func() { createReportFile = orig })
err = renderCommandReport(root, filepath.Join(t.TempDir(), "report.txt"), model.Report{}, report.FormatText)
err = renderCommandReport(root, filepath.Join(t.TempDir(), "report.txt"), model.Report{}, report.FormatText, report.RenderOptions{})
require.Error(t, err)
assert.Equal(t, exitInternal, exitCode(err))
assert.Contains(t, err.Error(), "close failed")
@@ -1032,28 +1133,80 @@ func TestWriteOrInternalErrorClassifiesUnexpectedErrors(t *testing.T) {
assert.Equal(t, exitInternal, exitCode(err))
}
func TestLicenseSelectOptions(t *testing.T) {
opts := licenseSelectOptions()
require.NotEmpty(t, opts, "license picker should offer renderable licenses")
func TestAuditSummaryOmitsFileList(t *testing.T) {
isolateEnv(t)
dir := fixtureDir(t)
out, err := runRoot(t, "audit", dir, "--deps=false", "--summary")
require.NoError(t, err)
assert.Contains(t, out, "by SPDX id:")
assert.NotContains(t, out, "\nsource files:")
}
values := make([]string, 0, len(opts))
for _, opt := range opts {
values = append(values, opt.Value)
func TestAuditGroupByLicense(t *testing.T) {
isolateEnv(t)
dir := fixtureDir(t)
out, err := runRoot(t, "audit", dir, "--deps=false", "--group-by", "license")
require.NoError(t, err)
assert.Contains(t, out, "source files by license:")
}
func TestAuditGroupByUnknownIsUsageError(t *testing.T) {
isolateEnv(t)
dir := fixtureDir(t)
stdout, stderr, code := executeRoot(t, "audit", dir, "--deps=false", "--group-by", "bogus")
assert.Equal(t, 2, code)
assert.Empty(t, stdout)
assert.Contains(t, stderr, "unknown group-by dimension")
}
func TestCheckGroupByUnknownIsUsageError(t *testing.T) {
isolateEnv(t)
dir := fixtureDir(t)
stdout, stderr, code := executeRoot(t, "check", dir, "--deps=false", "--group-by", "bogus")
assert.Equal(t, 2, code)
assert.Empty(t, stdout)
assert.Contains(t, stderr, "unknown group-by dimension")
}
func TestParseSort(t *testing.T) {
for _, tok := range []string{"", "key"} {
got, err := parseSort(tok)
require.NoError(t, err)
assert.False(t, got)
}
assert.Contains(t, values, "MIT")
assert.NotContains(t, values, "Zlib", "picker must not offer a license the tool cannot render")
got, err := parseSort("count")
require.NoError(t, err)
assert.True(t, got)
_, err = parseSort("bogus")
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown sort")
}
func TestAuditSortAndUnknownSort(t *testing.T) {
isolateEnv(t)
dir := fixtureDir(t)
_, err := runRoot(t, "audit", dir, "--deps=false", "--sort", "count")
require.NoError(t, err)
_, stderr, code := executeRoot(t, "audit", dir, "--deps=false", "--sort", "bogus")
assert.Equal(t, 2, code)
assert.Contains(t, stderr, "unknown sort")
}
// 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).
// a real terminal (the Bubble Tea 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 }
interactiveCollect = func(_ string, a initwizard.Answers, _ bool) (initwizard.Answers, error) {
return a, wantErr
}
t.Cleanup(func() { interactiveCollect = orig })
dir := t.TempDir()
@@ -1065,6 +1218,63 @@ func TestInitCommandInteractiveCollectError(t *testing.T) {
assert.True(t, os.IsNotExist(statErr), "no config should be written when collect fails")
}
func TestInitCommandConsumesCollectorAnswers(t *testing.T) {
isolateEnv(t)
orig := interactiveCollect
t.Cleanup(func() { interactiveCollect = orig })
dir := t.TempDir()
interactiveCollect = func(path string, initial initwizard.Answers, interactive bool) (initwizard.Answers, error) {
assert.Equal(t, dir, path)
assert.False(t, interactive)
assert.Equal(t, "MIT", initial.License.SPDXID)
assert.Equal(t, "Flag Holder", initial.Identity.Holder)
assert.Equal(t, "2026", initial.Identity.Year)
assert.Equal(t, "reuse", initial.HeaderStyle.Style)
assert.Equal(t, []string{"src/**"}, initial.Coverage.Include)
assert.Equal(t, []string{"vendor/**"}, initial.Coverage.Exclude)
return initwizard.Answers{
License: initwizard.LicenseAnswer{
SPDXID: "Apache-2.0",
},
Identity: initwizard.IdentityAnswer{
Holder: "Wizard Holder",
Year: "current",
},
HeaderStyle: initwizard.HeaderStyleAnswer{Style: "notice"},
LicenseFiles: initwizard.LicenseFilesAnswer{Manage: false},
Coverage: initwizard.CoverageAnswer{
Include: []string{"cmd/**"},
Exclude: []string{"generated/**"},
},
}, nil
}
out, err := runRoot(t, "init", dir,
"--license", "MIT",
"--holder", "Flag Holder",
"--year", "2026",
"--style", "reuse",
"--include", "src/**",
"--exclude", "vendor/**",
)
require.NoError(t, err)
target := filepath.Join(dir, ".license-tool.yaml")
assert.Contains(t, out, "wrote "+target)
cfg, err := config.LoadFile(target)
require.NoError(t, err)
assert.Equal(t, "Apache-2.0", cfg.License)
assert.Equal(t, "Wizard Holder", cfg.Holder)
assert.Equal(t, model.YearCurrent, cfg.Year.Kind)
assert.Equal(t, model.StyleNotice, cfg.Style)
assert.False(t, cfg.ManageLicenseFile)
assert.Equal(t, []string{"cmd/**"}, cfg.Includes)
assert.Equal(t, []string{"generated/**"}, cfg.Excludes)
}
func TestSharedToFlags(t *testing.T) {
s := &sharedFlags{
configPath: "/cfg.yaml",
@@ -1167,3 +1377,15 @@ func (w closeErrorWriter) Write(p []byte) (int, error) {
func (w closeErrorWriter) Close() error {
return w.closeErr
}
func TestAuditOnlyFilter(t *testing.T) {
isolateEnv(t)
dir := fixtureDir(t) // headerless main.go
out, err := runRoot(t, "audit", dir, "--deps=false", "--only", "missing")
require.NoError(t, err)
assert.Contains(t, out, "main.go")
_, stderr, code := executeRoot(t, "audit", dir, "--deps=false", "--only", "bogus")
assert.Equal(t, 2, code)
assert.Contains(t, stderr, "unknown --only filter")
}
+175 -94
View File
@@ -1,114 +1,195 @@
package main
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"time"
"github.com/charmbracelet/huh"
tea "github.com/charmbracelet/bubbletea"
"github.com/KofTwentyTwo/license-tool/internal/config"
"github.com/KofTwentyTwo/license-tool/internal/spdx"
"github.com/KofTwentyTwo/license-tool/internal/initwizard"
)
// 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
// initWizardModel is a thin Bubble Tea adapter. WHY it carries no logic: every
// decision (field state, validation, layout, rendering, the single Answers->Config
// translation) lives in the pure, fully-tested internal/initwizard package; this
// shell only forwards window sizes and keystrokes to FormState transitions and
// renders via initwizard.Render. It is excluded from the coverage gate precisely
// because it is this trivial.
type initWizardModel struct {
form initwizard.FormState
sample initwizard.Sample
nowYear int
width int
height int
confirmed bool
}
// 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 renderable SPDX ids in sorted order with no
// duplicates. WHY common-first: the operator sees sensible defaults at the top
// before the 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))
func newInitWizardModel(root string, a initwizard.Answers) initWizardModel {
seeded, detected := initwizard.Seed(root, a, initwizard.SeedDeps{})
return initWizardModel{
form: initwizard.NewForm(seeded, detected),
sample: initwizard.SelectSample(previewCandidatePaths(root)),
nowYear: time.Now().Year(),
}
for _, id := range spdx.RenderableIDs() {
if seen[id] {
continue
}
func (m initWizardModel) Init() tea.Cmd { return nil }
func (m initWizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m initWizardModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
case tea.KeyCtrlS:
if m.form.CanWrite() {
m.confirmed = true
return m, tea.Quit
}
opts = append(opts, huh.NewOption(id, id))
return m, nil
}
return opts
if m.form.Editing() {
return m.handleEditingKey(msg)
}
return m.handleNavKey(msg)
}
// 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"
func (m initWizardModel) handleEditingKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyEsc:
m.form.Cancel()
case tea.KeyEnter:
m.form.Commit()
case tea.KeyUp:
m.form.MoveCursor(-1)
case tea.KeyDown:
m.form.MoveCursor(1)
case tea.KeyDelete:
m.form.Remove()
case tea.KeyBackspace:
m.form.Backspace()
case tea.KeyRunes, tea.KeySpace:
for _, r := range msg.Runes {
m.form.Input(r)
}
}
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()
return m, nil
}
// 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 {
func (m initWizardModel) handleNavKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyEsc:
if m.form.Cancel() == initwizard.ActionQuit {
return m, tea.Quit
}
case tea.KeyEnter, tea.KeySpace:
if m.form.Activate() == initwizard.ActionWrite {
m.confirmed = true
return m, tea.Quit
}
case tea.KeyUp:
m.form.MoveFocus(-1)
case tea.KeyDown, tea.KeyTab:
m.form.MoveFocus(1)
case tea.KeyShiftTab:
m.form.MoveFocus(-1)
}
return m, nil
}
func (m initWizardModel) View() string {
width, height := m.width, m.height
if width <= 0 {
width = 120
}
if height <= 0 {
height = 34
}
// Reserve two rows for the footer that Render appends below the panels.
plan := initwizard.Layout(width, height-2)
return initwizard.Render(m.form, plan, m.sample, m.nowYear)
}
// previewCandidatePaths walks root for files whose extension maps to a preview
// sample, so SelectSample can pick a language family present in the repo.
func previewCandidatePaths(root string) []string {
if root == "" {
return nil
}
return runInitWizard(a)
var paths []string
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
if path != root && ignoredPreviewDir(d.Name()) {
return filepath.SkipDir
}
return nil
}
rel, relErr := filepath.Rel(root, path)
if relErr != nil {
rel = path
}
rel = filepath.ToSlash(rel)
if _, ok := initwizard.SampleForPath(rel); ok {
paths = append(paths, rel)
}
return nil
})
sort.Strings(paths)
return paths
}
func ignoredPreviewDir(name string) bool {
switch name {
case ".git", ".hg", ".svn", ".cache", ".next", "build", "dist", "node_modules", "target", "vendor":
return true
default:
return false
}
}
func runInitWizard(path string, a initwizard.Answers) (initwizard.Answers, error) {
program := tea.NewProgram(
newInitWizardModel(path, a),
tea.WithInput(os.Stdin),
tea.WithOutput(os.Stderr),
tea.WithAltScreen(),
)
final, err := program.Run()
if err != nil {
return a, err
}
m, ok := final.(initWizardModel)
if !ok {
return a, fmt.Errorf("init wizard returned unexpected model")
}
if !m.confirmed {
return a, initwizard.ErrAborted
}
return m.form.Answers(), nil
}
// collectInteractive runs the wizard when interactive is true, otherwise returning
// the flag-derived answers unchanged for deterministic non-TTY behavior.
func collectInteractive(path string, a initwizard.Answers, interactive bool) (initwizard.Answers, error) {
if !interactive {
return a, nil
}
return runInitWizard(path, a)
}
@@ -0,0 +1,90 @@
# DESIGN: GH-35 Audit Summary + Group-By Report Options
Status: **proposed** (brainstormed and approved; awaiting spec review). Issue: #35.
## Problem
The text and markdown audit renderers always emit a flat per-file list (every path), which buries the grouped rollups (`by SPDX id`, `by category`, `by file type`) on any non-trivial repo. There is no way to get a concise, human-readable, grouped view. Add opt-in summary and group-by options. The default output stays exactly as today (backward compatible).
## Approach
**Render-time grouping.** `model.Report` stays frozen. Grouping is derived from `r.Files` at render time by a pure, unit-tested function. A `RenderOptions` value is threaded into `report.Render` and honored by all three renderers. (Rejected: adding a `Groups` field to the frozen model; a separate `summarize` subcommand.)
## Flags (bound on both `audit` and `check` via the shared `bindAuditFlags`)
- `--summary` (bool, default false): counts only. Omits the per-file list, the per-dependency list, and pending diffs. Keeps findings, the count rollups, and policy violations.
- `--group-by <dim>` (string, default ""): one of `license` | `category` | `type` | `directory`. Organizes the source-file listing under each value of the dimension. Empty means the default flat list. An unknown value is a usage error (exit 2), validated like `--resolve-deps`.
### Orthogonal model
The three standalone count rollups (`by SPDX id`, `by category`, `by file type`) are **always** shown (unchanged). Only the **file listing** changes:
| flags | file listing |
|---|---|
| (none) | flat list of every source file — unchanged default |
| `--group-by X` | source files nested under each value of X: `KEY (n):` then the file lines |
| `--summary` | omitted entirely (also omits dep list + diffs) |
| `--summary --group-by X` | per-group **counts** for X only (`KEY: n`), no file paths |
### Scope
- `--group-by` groups **source files only**. The dependency section is unaffected (full list normally; replaced by the resolved/unresolved counts under `--summary`). Grouping dependencies by license/ecosystem is a deliberate non-goal.
- `GroupFiles` operates over the **non-skipped** source set (the licensable files), for every dimension. Skipped files (binary/uncommentable/unknown) are reported as a trailing `(skipped: N)` note in the grouped section, never grouped. The pre-existing count rollups keep their current populations.
### Group keys
- `license`: `fr.Detected.SPDXID` when a header is present and non-empty, else `(none)`.
- `category`: the policy category token for that id (`permissive`, `strong-copyleft`, ...), else `unknown`.
- `type`: `fr.FileType` (the matched type name).
- `directory`: the first path segment of `fr.Path`; files at the root group under `.`.
Groups are sorted by key; files within a group are sorted by path. Deterministic for identical input.
## Components
- **`internal/report/group.go`** (new):
- `type GroupDimension int` with `GroupNone, GroupLicense, GroupCategory, GroupType, GroupDirectory`.
- `func ParseGroupBy(string) (GroupDimension, error)` — `""`->`GroupNone` (no error); the four tokens; else error `report: unknown group-by dimension %q (expected license|category|type|directory)`.
- `type Group struct { Key string; Count int; Files []model.FileResult }`.
- `func GroupFiles(r model.Report, dim GroupDimension) (groups []Group, skipped int)` — pure, sorted, deterministic. `GroupNone` returns `(nil, skipped)`.
- **`internal/report/report.go`**:
- `type RenderOptions struct { Summary bool; GroupBy GroupDimension }`.
- `Render(w, r, format, opts)` — signature gains `opts`. `renderText` / `renderMarkdown` / `renderJSON` each honor `opts`:
- text/markdown: always print findings + the three count rollups; render the file listing per the table above; under `--summary` also drop the dependency list and pending diffs (keep `dependencies: N (resolved R, unresolved U)` from findings) and policy violations stay.
- JSON: when `GroupBy != GroupNone`, add a `groups` array (`[{key, count, files?}]`); under `Summary`, omit per-file `files` detail and the `dependencies` array, keeping the count maps and `violations`. Default JSON (no flags) is byte-identical to today. Additive schema, still `license-tool/report/v1`.
- **`cmd/license-tool/commands.go`**:
- `auditFlags` gains `summary bool` and `groupBy string`.
- `bindAuditFlags` registers `--summary` and `--group-by`.
- `newAuditCmd`/`newCheckCmd` parse+validate `--group-by` (usage error on unknown) and pass `report.RenderOptions` through `renderCommandReport` -> `report.Render`.
## Error handling
- Unknown `--group-by` value: usage error, exit 2, before any rendering.
- `--summary` and `--group-by` compose (no conflict); both may be combined per the table.
- All existing exit-code and rendering behavior for the no-flag case is preserved.
## Testing (hold the 100% gate)
- `ParseGroupBy`: every token + empty + unknown.
- `GroupFiles`: each dimension's keying (including `(none)` license, `unknown` category, root `.` directory), skipped-count exclusion, sort determinism, `GroupNone`.
- Renderers: for text, markdown, and JSON — default unchanged (golden), `--summary`, `--group-by` each dimension, and `--summary --group-by`.
- Command layer: flag wiring, unknown-dimension usage error (exit 2), `--summary`/`--group-by` reach `Render`.
## Docs
- README `audit` section: document `--summary` and `--group-by` with a short example of each.
## Out of scope
- Grouping dependencies; multiple simultaneous `--group-by` dimensions; a configurable directory depth (top-level only for now).
## Post-implementation addenda (2026-06-08)
The implementation extended past the original scope in two deliberate places, and three audit-flagged items were resolved by decision rather than code:
- **Implemented beyond original scope.** A configurable directory depth (`--depth N`), per-group risk markers and license breakdowns, `--sort key|count`, `--only` filtering, attributable `violationDetails`, and JSON/markdown findings parity were all added. The "top-level only" directory-depth non-goal above was superseded.
- **Self-config exclusion.** The tool's own `.license-tool.yaml` is YAML (commentable) and was being counted as a headerless source file, inflating `sourceTotal`/`sourceMissing`. It is now marked skipped (reason `tool config`) in the audit pipeline (`buildAuditPipeline`), keyed off the exported `config.RepoConfigName`. It stays in the file listing for transparency but is excluded from coverage tallies.
- **Policy-aware group risk.** A group's `[risk]` was computed only from its files' license categories, so it was policy-blind (an Apache group beside AGPL read `low`). Group risk now escalates to `high` when a group's license is party to a repo-level hard incompatibility (computed via `policy.Incompatible` over the distinct source ids) or a file in the group carries a file-scoped `policy-violation` token. Heterogeneity is intentionally not escalated (a whole-repo property; escalating every group would be noise).
- **`--group-by dependency` — confirmed non-goal.** Re-flagged by the clean-room audit but reaffirmed as out of scope: dependencies are not files, the semantics are ambiguous (by-license vs by-ecosystem), and the dependency section already has its own resolved/unresolved view.
- **JSON explicit percentages/totals — declined.** Per-row percentages are a presentation concern of the human formats; the JSON report stays normalized (counts + `findings`), from which any consumer can derive them. Adding redundant fields was judged low value.
- **`go.mod` dependency resolution — not a defect.** The audit flagged "dep resolver returned 0 for a go.mod require." Go modules are not a supported dependency ecosystem (only npm, Maven, and Gradle manifests are discovered), so resolving zero dependencies for a Go-module repo is correct behavior, not a bug.
+127
View File
@@ -0,0 +1,127 @@
# DESIGN: `init` TUI Clean-Room Redesign
Status: **proposed** (decisions confirmed in a grill-me session; not yet planned/implemented)
Supersedes the TUI shape from `docs/PLAN-GH-29-init-tui-wizard.md`.
Companion review findings: `docs/review/01-write-path-safety.md`, `02-audit-policy-resolve.md`, `03-cmd-tui-tests.md`.
## 1. Problem
The current `init` wizard (`cmd/license-tool/wizard.go`, ~990 lines, excluded from coverage) is functionally close but, per the user, "terrible and more or less not usable." Concrete causes, confirmed by code review:
- **3 panes fight for width** (Steps nav | controls | preview); collapses to a stacked mess below 100 cols.
- **Cram-fields:** Identity is one text input parsed as `holder | year`; Coverage is one input parsed as `include | exclude` with each side comma-split. Nobody guesses these grammars.
- **A dead step:** "Project model" is collected, previewed, and written to `Answers.Project`, but `answersToConfig` never reads it — the first screen has zero effect.
- **End-loaded validation:** an empty holder is accepted through Review and only rejected *after* the user confirms write, bouncing them out.
- **Two `Answers→Config` translators** (`previewConfig` vs `answersToConfig`) that can silently drift — so the preview can render something different from what gets written.
- **~400 lines of pure logic trapped** in the uncoverable `wizard.go` (selection math, filtering, parsing, preview assembly, layout sizing).
The *what* (collect config, teach via preview, write `.license-tool.yaml`) is right. The *how* is being redesigned clean-room.
## 2. North star & principles
**North star: a teaching tool driven by a live preview.** Every choice immediately re-renders an example source header (and the YAML) so the user understands the effect before writing. (Chosen over "fast correct config," "power config," and "minimal bootstrap.")
Principles, in priority order:
1. **See the effect, always.** The preview is persistent and never blank.
2. **Detect, don't interrogate.** Open pre-filled from the repo; the user confirms/tweaks.
3. **Can't reach a bad write.** Validate live; disable Write until valid.
4. **One source of truth.** A single `Answers→Config` translator feeds both preview and write.
5. **Honest coverage.** Logic is tested; the TUI shell is trivial enough that its exclusion is legitimate.
## 3. Resolved decisions
| # | Decision | Choice | Rejected alternatives |
|---|----------|--------|----------------------|
| 1 | North star | **Teaching tool via live preview** | fast-correct-config; power config; minimal bootstrap |
| 2 | Interaction model | **Single always-visible form**, all fields, edit in any order, persistent preview | linear stepper; grouped sections; preview-dominant overlay |
| 3 | Layout | **Adaptive panel count** (3 cols wide / 2 medium / stacked narrow); hard floor refuses the TUI | fixed responsive 2-pane; hard-min two-column; single-column peek |
| 4 | Defaults | **Detect & pre-fill, badged + editable** (retires the Project-model step) | light detection; static only; detection-on-keypress |
| 5 | Complex-field editing | **Inline-expanding rows** (filter-list / list editor / text expand in place) | modal overlay; full-pane focus; drill-in sub-screen |
| 6 | Validation | **Live inline; Write disabled until valid**, footer shows why, preview uses placeholder holder | validate-on-commit; validate-at-write; live + confirm-anyway |
| 7 | Code structure | **Thin shell + fat tested core**; single `Answers→Config` translator | eliminate exclusion via teatest; extract-only; spike-first |
| 8 | Write flow | **No review screen**; write disabled until valid; **confirm + old→new diff only when overwriting** | explicit final review; immediate no-confirm; always-diff |
| 9 | Private/internal path | **Cut now, track as a separate feature** | minimal "internal" marker; build LicenseRef-* now; leave dead |
## 4. Redesigned UX
### 4.1 The form (single screen, fields editable in any order)
Rows, top to bottom; each shows `label: <current value>` (with a `detected` badge where seeded), expands in place on Enter:
1. **License** — inline filter-as-you-type list over the *renderable* SPDX set (common ids first, then the rest). (Validation can accept any SPDX id, but `init` writes only renderable ids — so the picker is constrained to renderable, eliminating the "valid but unrenderable" error class.)
2. **Holder** — text input. Required.
3. **Year** — pick: `current` · `git` · `explicit` (then `YYYY` or `YYYY-YYYY`). Default `git`.
4. **Header style** — pick: `reuse` · `notice` · `reuse+notice`, each with a one-line plain-language implication. Default `reuse+notice`.
5. **Manage license files** — toggle (`LICENSE` + `LICENSES/<id>.txt`). Default true if no `LICENSE` exists yet; if one exists, default to standardize-with-confirm.
6. **Include globs** — inline list editor (add / select / remove). Empty ⇒ "all supported files."
7. **Exclude globs** — inline list editor; accumulates on top of `.gitignore`.
Bottom: a focusable **`Write .license-tool.yaml`** affordance, hard-disabled while any field is invalid; footer shows the blocking reason.
### 4.2 Layout breakpoints (starting values; validate during implementation)
- **Wide (≥ ~110 cols):** 3 columns — Form (~40) · Source preview (flex) · YAML preview (~40).
- **Medium (~80–109):** 2 columns — Form (~40) · Source preview (flex). YAML shown as a switchable tab on the preview pane (or stacked beneath source preview if height allows).
- **Narrow (≥ floor, < ~80):** single column — Form full width, Source preview stacked below (shorter, still persistent).
- **Below floor (< ~60 cols or < ~20 rows):** refuse the TUI; print a message pointing to the flag-only non-TTY path (`init --license ... --holder ...`) or to widen the terminal.
The dedicated "Steps" nav rail is **removed** (no steps to track in a single form), reclaiming ~24 cols. A compact validity/affordance line lives in the footer.
### 4.3 Preview content
- **Source preview** is the hero — the example file (language auto-selected from the repo; C fallback) with the generated header applied, re-rendered on every edit via the *same* `render` path apply uses.
- **YAML preview** shows the exact `.license-tool.yaml` to be written.
- **License-files** behavior and **coverage** summary become compact status lines (within/under the YAML panel), not full panels.
- On overwrite, the YAML panel switches to an **old→new diff**.
### 4.4 Keymap (replaces today's overloaded `backspace`)
- `Tab`/`Shift+Tab` or `↑`/`↓`: move field focus.
- `Enter`: expand focused row / commit an open editor.
- `Esc`: collapse an open editor without committing; if nothing is open, quit without writing.
- Filter-list editor: type to filter, `↑`/`↓` select, `Enter` commit, `Esc` cancel.
- Glob-list editor: type + `Enter` to add, `↑`/`↓` to select an entry, `d`/`Delete` to remove, `Esc` to finish.
- `Ctrl+S` (or focus the Write row + `Enter`): write — disabled while invalid; confirm if overwriting.
- `?`: toggle help. `Ctrl+C`: quit without writing.
### 4.5 Detection seeding (decision 4)
On launch, seed `Answers` (reusing tested code), badging each value and falling back to static defaults when detection is empty:
- **License / Holder:** run `internal/detect` over enumerated source files for existing managed/recognized headers → dominant SPDX id and holder. Cross-check a top-level `LICENSE` / `LICENSES/` for an id.
- **Holder fallback:** `git config user.name` (or repo org) when headers yield nothing.
- **Manage flag:** seeded from whether a top-level `LICENSE` already exists.
- **Preview sample:** `enumerate` language classification → `initwizard.SelectSample` (already implemented).
- Nothing is written unreviewed; seeds are editable form values, consistent with the tool's "never guess silently" ethos.
## 5. Architecture (decision 7)
**All logic in `internal/initwizard` (tested); `wizard.go` is a trivial Bubble Tea adapter.**
`internal/initwizard` gains:
- `Translate(Answers) (model.Config, error)` — the **single** translator. `cmd`'s `answersToConfig` and the preview's `previewConfig` both collapse into this. Kills the drift class (review 03).
- `Seed(root, env) Answers` — detection seeding (reuses `detect`/`enumerate`/`gitutil`).
- A pure form state machine: field focus, per-field expansion state, per-field validation results, derived "can write" flag — as plain types + transition functions.
- `Layout(width, height) PanelPlan` — pure breakpoint selection.
- View helpers returning strings (unit-testable; drive selected transitions with synthetic `tea.Msg` where cheap).
`cmd/license-tool/wizard.go`: an `Init/Update/View` adapter that routes `tea.KeyMsg` to `initwizard` transitions and renders via `initwizard` view helpers. Small enough that its coverage exclusion is honest. (Eliminating the exclusion via `teatest` was considered and deferred.)
## 6. Entangled review fixes
**Hard dependency (must fix to ship this design):**
- **[HIGH] `include` `**` globs match nothing** (`enumerate.go`, review 02 #1). Includes use `filepath.Match` (no `**`); excludes use go-gitignore (has `**`). Decision 5 validates globs against "the real matcher" and the Coverage rows teach glob authoring — so includes must move to the **same gitignore-style matcher** as excludes, and the test that enshrines the broken single-segment behavior must be removed. Without this, the wizard would actively teach broken globs.
**Independent but recommended to track alongside (not blockers):**
- **[HIGH]** `detect` absorbs an adjacent doc comment across a blank line and deletes it on replace (review 01 #1).
- **[HIGH]** `spdxnorm` alias table *guesses* licenses (`"bsd"`->BSD-2-Clause, LGPL->`-or-later`), violating "never guess" (review 02 #2).
- **[MED]** `detect` matches `SPDX-License-Identifier` mentioned anywhere in a leading comment (01); symlinked `LICENSE` clobber (01); `--quiet`/`--verbose` are no-op flags (03).
## 7. Out of scope
- **Proprietary / `LicenseRef-*` licenses** (decision 9): cut the dead `LicenseAnswer.Private` / private-model scaffolding; file proprietary support as its own issue spanning `render`/`policy`/license-file management. The "standard SPDX used internally" case is already served by picking that license.
## 8. Lower-confidence details to confirm during planning
- Exact breakpoint widths/heights and the medium-layout YAML treatment (tab vs stacked).
- Whether `Manage license files` defaulting to "standardize an existing LICENSE" needs its own confirm.
- Help affordance depth (`?` overlay vs persistent footer hints).
+80
View File
@@ -0,0 +1,80 @@
# PLAN: GH-29 Init TUI Redesign
> Execution-oriented plan. Design rationale lives in `docs/DESIGN-init-tui-redesign.md`; review findings in `docs/review/`. Supersedes the TUI shape in `PLAN-GH-29-init-tui-wizard.md`. Implemented on the existing `feature/GH-29-init-tui-wizard` branch (issue #29 is unmerged).
**Goal:** Replace the 7-step 3-pane wizard with a single always-visible, repo-seeded form + persistent adaptive live preview, with all logic in a tested `internal/initwizard` package and `wizard.go` reduced to a thin Bubble Tea adapter.
**Architecture:** Pure logic (translate, seed, form state machine, validation, layout, view rendering) lives in `internal/initwizard` and is unit-tested to 100%. `cmd/license-tool/wizard.go` becomes an `Init/Update/View` adapter that delegates to those pure functions. A single `Translate` feeds both the live preview and the write path, eliminating the `previewConfig`/`answersToConfig` drift.
**Tech Stack:** Go 1.25, Bubble Tea / Bubbles / Lip Gloss (already present), go-gitignore (already present), testify. No new dependencies.
---
## File map
| File | Responsibility | Status |
|------|----------------|--------|
| `internal/enumerate/glob.go` (+ existing enumerate.go) | gitignore-style matcher reused by includes, excludes, and wizard glob validation | new + modify (#32) |
| `internal/initwizard/model.go` | `Answers` (trimmed: drop Project/Review/Private dead types), `Field` enum | modify |
| `internal/initwizard/translate.go` | single `Translate(Answers, TranslateOptions) (model.Config, error)` | new |
| `internal/initwizard/seed.go` | `Seed(root, SeedDeps) (Answers, Detected)` repo detection | new |
| `internal/initwizard/validate.go` | per-field validation (`Validate`, `FieldError`) | new |
| `internal/initwizard/form.go` | `FormState` + transitions (focus, expand, commit, glob/license editors, `CanWrite`) | new |
| `internal/initwizard/layout.go` | `Layout(w,h) PanelPlan` adaptive breakpoints + too-small sentinel | new |
| `internal/initwizard/view.go` | pure string rendering from `FormState`+`PanelPlan` | new |
| `internal/initwizard/catalog.go` | sample catalog + preview builders | keep |
| `cmd/license-tool/wizard.go` | thin `tea.Model` adapter delegating to initwizard | rewrite (shrink) |
| `cmd/license-tool/commands.go` | `newInitCmd` seeds + runs form; `answersToConfig` -> `initwizard.Translate` | modify |
---
## Task sequence (each task: red -> green -> refactor -> commit; commit references `#29`/`#32`)
### Task 1 - Fix include `**` (issue #32, unblocks wizard glob teaching)
- Test: `internal/enumerate/glob_test.go` - `include: ["src/**"]` matches `src/sub/x.go`; `["**/generated/**"]` matches `a/generated/b.go`; single-segment still works.
- Impl: route includes through the same go-gitignore matcher as excludes (extract `internal/enumerate/glob.go` with `CompileMatcher(patterns) Matcher` + `Matcher.Match(rel) bool`). Replace `filepath.Match` include path.
- Remove `TestMatchesIncludes`'s assertion that enshrines single-segment-only behavior; replace with `**` cases.
- Gate: `go test ./internal/enumerate -race`. Commit: `fix(#32): include globs honor ** via gitignore matcher`.
### Task 2 - Single translator
- Test: `internal/initwizard/translate_test.go` - strict mode rejects empty holder / unrenderable license; placeholder mode substitutes `Example, Inc.` and never errors on holder; both map year/style/manage/include/exclude identically.
- Impl: `Translate(a Answers, opts TranslateOptions) (model.Config, error)` starting from `config.Defaults()`. `TranslateOptions{AllowPlaceholders bool}`.
- Rewire: `commands.go answersToConfig` -> `Translate(a, TranslateOptions{})`; `catalog.go`/preview path + `wizard.go previewConfig` -> `Translate(a, TranslateOptions{AllowPlaceholders:true})`. Delete `previewConfig`.
- Gate: `go test ./internal/initwizard ./cmd/... -race`. Commit: `refactor(#29): single Answers->Config translator`.
### Task 3 - Trim dead types + seed detection
- Test: `internal/initwizard/seed_test.go` - holder/license seeded from existing managed headers; holder falls back to git author then empty; manage flag false when LICENSE present; `Detected` flags set accordingly; empty repo -> static defaults.
- Impl: remove `ProjectModel*`, `Step*`, `ReviewAnswer`, `ProjectAnswer`, `LicenseAnswer.Private`. Add `Seed(root string, deps SeedDeps) (Answers, Detected)` reusing `detect`, `enumerate`, `spdx`, and a `GitAuthor` seam (default `gitutil`). `Detected struct{ License, Holder, Manage bool }`.
- Gate: `go test ./... -race` (catches removed-type fallout). Commit: `feat(#29): seed init answers from repo detection`.
### Task 4 - Form state machine + validation
- Test: `internal/initwizard/form_test.go`, `validate_test.go` - focus wraps across fields incl. Write row; Enter expands focused field and Esc collapses; license filter narrows choices and commit updates Answers; glob editor adds/removes entries; `Validate` flags empty holder, unparseable year, malformed glob; `CanWrite` false until valid.
- Impl: `Field` enum; `FormState{ Answers; Detected; Focus Field; Expanded bool; License editor state; Glob editor state }`; transitions `MoveFocus`, `Toggle/Expand/Collapse`, `Commit`, `SetFilter`, `AddGlob/RemoveGlob`, `Validate() []FieldError`, `CanWrite() bool`. License options from `licenseSelectOptions` (moved in from wizard.go).
- Gate: `go test ./internal/initwizard -race`. Commit: `feat(#29): pure form state machine and live validation`.
### Task 5 - Adaptive layout
- Test: `internal/initwizard/layout_test.go` - >=110 cols -> 3 panels; 80-109 -> 2; floor..79 -> stacked; <60 or <20 rows -> `TooSmall`; widths sum within bounds.
- Impl: `PanelPlan{ Panels []Panel; TooSmall bool; ... }`; `Layout(w,h int) PanelPlan`.
- Gate: `go test ./internal/initwizard -race`. Commit: `feat(#29): adaptive panel layout planner`.
### Task 6 - Pure view rendering
- Test: `internal/initwizard/view_test.go` - renders form rows with values + detected badges; focused/expanded row shows editor; footer shows blocking validation reason when `!CanWrite`; too-small plan renders the fallback message; preview panels present per plan.
- Impl: `Render(s FormState, plan PanelPlan) string` (+ row/preview/footer helpers) using lipgloss. Move `fitText`/`ellipsize`/`selectionList` in from wizard.go.
- Gate: `go test ./internal/initwizard -race`. Commit: `feat(#29): pure view rendering for init form`.
### Task 7 - Thin Bubble Tea adapter + wire-up + full gate
- Impl: rewrite `wizard.go` to hold `initwizard.FormState`+size; `Update` maps `tea.KeyMsg`/`WindowSizeMsg` to transitions, triggers write on the Write action via `config.WriteFile` (confirm + diff when overwriting), quits on Esc/ctrl+c; `View` calls `initwizard.Render`. Update `newInitCmd` to `Seed` then run. Update `commands_test.go` for the new collector contract.
- Update `.testcoverage.yml` comment if the wizard.go exclusion rationale changes (it stays excluded; now genuinely thin).
- Update `README.md`/`DEVELOPERS.md` init sections.
- Gate (full, per CONTRIBUTING): `gofmt -l .`, `go vet ./...`, `golangci-lint run`, `go test ./... -race -coverpkg=./internal/...,./cmd/... -covermode=atomic -coverprofile=cover.out`, `go run github.com/vladopajic/go-test-coverage/v2@v2.18.8 --config=.testcoverage.yml`, `go build ./...`.
- Commit: `feat(#29): single-form init wizard with adaptive live preview`.
---
## Out of scope (tracked separately)
- Proprietary / `LicenseRef-*` licenses (design decision 9).
- Issues #30, #31, #33, #34 (independent review bugs; not required for #29). #32 is in-scope as Task 1.
## Risks
- 100% coverage gate: every new exported/branch path needs a test before the gate passes. Form state transitions are the largest surface; budget most test effort there.
- View rendering tests can be brittle; assert on stable substrings/structure, not full styled output.
+72
View File
@@ -0,0 +1,72 @@
# PLAN: GH-29 Init TUI Wizard
## Goal
Redesign `license-tool init` into a full-screen configuration wizard that explains each choice through a live example source-file preview before writing `.license-tool.yaml`.
## Approach
Keep non-TTY `init` scriptable, but replace the interactive `huh` form with a Bubble Tea TUI. The wizard remains a configuration tool, not legal advice: it presents licensing models, common SPDX choices, private/internal paths, holder/year/style controls, include/exclude coverage, and a final review screen. The preview defaults to an example source file rendered with the current answers; detected repo language chooses the example, and C is the fallback.
## Files Affected
- `cmd/license-tool/wizard.go` - replace linear form shell with TUI entrypoint.
- `cmd/license-tool/commands.go` - wire persisted include settings and init-specific flags.
- `cmd/license-tool/commands_test.go` - CLI/init behavior tests.
- `internal/config/config.go` - add persisted `include` schema and merge/render support.
- `internal/config/config_test.go` - config schema and layering tests.
- `internal/initwizard/` - new wizard state, sample selection, preview rendering, and TUI model.
- `internal/render/` - reuse existing header rendering for previews; no direct changes expected.
- `README.md`, `DEVELOPERS.md` - document the interactive wizard and include/exclude config.
## User Flow
1. Choose project model: open source, private/internal, existing project detection, or advanced/manual.
2. Choose target license path: common SPDX, full searchable SPDX renderable list, or private/internal.
3. Enter copyright holder.
4. Choose year policy.
5. Choose header style with plain-language descriptions.
6. Choose whether to manage `LICENSE` and `LICENSES/<id>.txt`.
7. Configure file coverage: detected defaults, selected extensions, or advanced glob lists.
8. Review generated config, preview header on an example file, and confirm write.
## TUI Shape
- Left progress rail: Project, License, Identity, Header, Files, Coverage, Review.
- Center panel: the active controls and short implications for the current step.
- Right preview panel: source preview by default, with tabs for source, YAML, license files, and coverage summary.
- Footer: key hints, validation errors, and target path.
The terminal event loop should stay thin. Business logic belongs under `internal/initwizard` so the 100% coverage gate can test state transitions, sample selection, preview rendering, and validation without driving a real PTY.
## Preview Requirements
- Default panel is an example source file with the generated header applied.
- Secondary panels show `.license-tool.yaml`, license-file behavior, and a policy/coverage summary.
- Include examples for JavaScript/TypeScript, Python, Java, Go, C, C++, C#, PHP, Ruby, Swift, Kotlin, Rust, Shell, PowerShell, and R.
- If none of those languages are detected, default to C.
Samples should exercise preserve-first behavior when relevant: Go build tags, Python/Ruby coding pragmas, PHP open tags, shebang-bearing shell-like files, and Java/Kotlin package declarations.
## Config Semantics
- Add persisted `include` support to `.license-tool.yaml`.
- Empty include means all eligible files.
- Highest non-empty include layer wins: flags > repo config > user config > defaults.
- `exclude` keeps the existing accumulating behavior.
- Command paths should use resolved config includes; CLI `--include` remains highest precedence because it flows through config resolution.
```yaml
include:
- "src/**"
exclude:
- "**/generated/**"
```
## Private/Internal MVP
Private/internal is supported as a guided path, but the tool must not invent proprietary license terms. The MVP can allow a private/internal project to choose metadata-only behavior or a renderable SPDX license used internally. Deeper `LicenseRef-*` support requires explicit user-provided license text and can be deferred if it would spread across render, policy, and license-file management.
## Steps
1. [x] Add persisted config `include` support with TDD.
2. [x] Add init answers and preview model capable of rendering config and sample headers.
3. [x] Add language sample catalog and deterministic detection/fallback.
4. [x] Implement Bubble Tea wizard model with review flow.
5. [x] Wire interactive `init` to the new TUI while preserving non-TTY behavior.
6. [x] Update docs and issue status.
7. [x] Run full local quality and 100% coverage gates.
## Open Questions
- How complete should the private/internal license path be in this first slice if no proprietary license renderer exists yet?
+24 -22
View File
@@ -1,27 +1,29 @@
# Session State
## Current Branch
`feature/GH-12-27-audit-fixes`
**Last Updated:** 2026-06-09
## Current Objective
Resolve GitHub issues #6 through #27 using TDD, keep the 100% coverage gate passing, and merge the passing local feature branch back to local `develop`.
## Current Status
**v0.4.0 is RELEASED and verified.** Nothing in progress. Clean stopping point.
Resume on `develop` (default branch). Working tree clean.
## Status
- Branch created from `develop`.
- Audit issues filed and de-duplicated against existing open issues #6 through #11.
- Plan and TODO created.
- Worktrees created under `/private/tmp/license-tool-worktrees/` for write safety, CLI reporting, SPDX/policy, dependency/release/docs, and header placement slices.
- Worker agents assigned:
- Write safety: #12, #13, #14, #15 on `feature/GH-12-27-write-safety`.
- CLI/reporting: #16, #17, #18, #25 on `feature/GH-12-27-cli-reporting`.
- SPDX/policy: #19, #20 on `feature/GH-12-27-spdx-policy`.
- Dependency/release/docs: #11, #21, #22, #23, #26 on `feature/GH-12-27-deps-release-docs`.
- Header placement: #6, #7, #8, #24, #27 on `feature/GH-12-27-header-placement`.
- File-type coverage: #9 and #10 on `feature/GH-9-10-filetype-coverage`.
- GitHub issue comments reviewed for #6 through #27 and closed tracker #3.
- Worker branches integrated into `feature/GH-12-27-audit-fixes`.
- Local gates passed: `gofmt -l .`, `go vet ./...`, `golangci-lint run`, `go test ./... -race -cover`, 100% configured coverage gate, `go build ./...`, `goreleaser check`, and `gitleaks detect`.
- `feature/GH-12-27-audit-fixes` fast-forward merged into local `develop`.
## What Was Done (this session)
- Shipped six issues into v0.4.0: #35 (audit reporting overhaul + self-config exclusion + policy-aware group risk), #29 (init full TUI wizard), #31 (resolve: stop guessing SPDX ids), #34 (remove no-op --quiet/--verbose), #33 (refuse symlinked LICENSE), #30 (confine header detection).
- Bug fixes #30/#31/#33/#34 were built by parallel worktree-isolated agents (TDD + full gate + PR each).
- Integrated all six PRs into `develop` (six --no-ff merges, conflicts resolved), combined gate green (100% coverage), promoted `develop` -> `main`, tagged **v0.4.0**.
- Release workflow succeeded: GitHub Release v0.4.0 (6 assets: checksums + darwin/linux arm64+amd64 + windows amd64), Homebrew cask bumped on `KofTwentyTwo/homebrew-tap`.
- Authored `RELEASES.md` (full release runbook); added pointer from `RELEASING.md`.
## Next Step
Manual review and testing before any push to GitHub, promotion to `main`, tag, or release.
## Branch State
- `develop` and `main` both at v0.4.0 content; `main` tagged `v0.4.0`. `develop` is one doc commit (this handoff) ahead of `main` after it lands.
- Six merged local `feature/GH-*` branches remain and can be pruned (`git branch -d ...`); their remotes can be deleted too.
## Pending / Next Session
- [ ] Prune merged feature branches (local + remote) if desired.
- [ ] Verify install on a clean machine: `brew update && brew install --cask KofTwentyTwo/tap/license-tool && license-tool version` (should print v0.4.0).
- [ ] Deferred LOW follow-ups (own issues if pursued): block-comment de-stacking in detect (#30 scope); `isWriteRefusal` typed-sentinel refactor + remove dead `model.ResolveOptions.Verbose` (#34 scope).
- [ ] No open issues/PRs outstanding from this work.
## Key Reference
- Repo: GitHub `KofTwentyTwo/license-tool`. Default branch `develop`; production `main`; tag `vX.Y.Z` on `main` publishes via GoReleaser. Commits reference issues, no AI attribution.
- Release runbook: `RELEASES.md`. Gate: `gofmt -l .`; `go vet ./...`; `golangci-lint run`; `go test ./... -race -coverpkg=./internal/...,./cmd/... -covermode=atomic -coverprofile=cover.out`; `go run github.com/vladopajic/go-test-coverage/v2@v2.18.8 --config=.testcoverage.yml`.
- Gotcha: never pipe `go build`/`go test` to `tail` in a `&&` chain — it masks the exit code (bit me during merge integration). The Go filetype uses BLOCK comments (`/* */`), not `//`.
+13 -57
View File
@@ -1,59 +1,15 @@
# TODO: Audit Fixes
# TODO
## Tracking
- [x] Worker agents assigned for all implementation slices.
- [x] GitHub issue comments reviewed for #6 through #27.
- [x] Keep `docs/SESSION-STATE.md` current after each integrated slice.
- [x] Keep coverage at the repository gate: total coverage 100% for the gated set.
## Shipped in v0.4.0 (2026-06-09)
- [x] #35 audit reporting overhaul + self-config exclusion + policy-aware group risk
- [x] #29 init full TUI wizard + live previews + persisted `include`
- [x] #31 resolve: stop guessing SPDX ids for ambiguous aliases
- [x] #34 remove no-op `--quiet`/`--verbose` flags
- [x] #33 refuse to clobber a symlinked LICENSE
- [x] #30 confine header detection to contiguous comment lines
## Header Boundary Preservation
- [x] #6: Preserve Go `//go:build` and `// +build` constraints before inserted headers.
- [x] #7: Preserve CSS `@charset` before inserted headers.
- [x] #8: Preserve HTML/XML `<!DOCTYPE>` before inserted headers.
## File-Type Coverage
- [x] #9: Add Perl, PowerShell, R, Makefile, TOML, and Batch file-type support.
- [x] #10: Detect extensionless script type from shebang content.
## README Docs
- [x] #11: Document the init wizard and audit findings summary accurately.
## Write Safety
- [x] #12: Make `license` honor write gates and either implement or reject commit options.
- [x] #13: Prevent dirty-tree apply commits from staging unrelated work.
- [x] #14: Make apply honor include and gitignore scope controls.
- [x] #15: Emit unified diffs during dry-run apply and license runs.
## Header Placement
- [x] #24: Preserve Python `coding=` pragmas before inserted headers.
- [x] #27: Deduplicate shared sentinel and header placement behavior.
## CLI And Reports
- [x] #16: Print errors and map documented exit codes.
- [x] #17: Make check fail-condition CLI overrides affect behavior.
- [x] #18: Honor audit/check output controls and documented dependency skip behavior.
- [x] #25: Reject invalid dependency resolver tier values.
## Policy And SPDX
- [x] #19: Align accepted SPDX IDs with renderable license support.
- [x] #20: Validate policy required, allow, and deny IDs or expressions.
## Dependencies And Release
- [x] #21: Pin release workflow actions and release tooling.
- [x] #22: Make Gradle tool-tier behavior explicit or implemented.
- [x] #23: Discover nested dependency manifests.
## Public Docs
- [x] #26: Refresh public support, security-policy, and scaffolding docs.
## Final Gates
- [x] `gofmt -l .`
- [x] `go vet ./...`
- [x] `golangci-lint run`
- [x] `go test ./... -race -cover`
- [x] `go test ./... -race -coverpkg=./internal/...,./cmd/... -covermode=atomic -coverprofile=cover.out`
- [x] `go run github.com/vladopajic/go-test-coverage/v2@v2.18.8 --config=.testcoverage.yml`
- [x] `go build ./...`
- [x] `goreleaser check`
- [x] `gitleaks detect --source . --no-banner --redact --exit-code 1`
- [x] Merge passing feature branch to local `develop`.
## Follow-ups (not yet ticketed)
- [ ] Prune merged `feature/GH-*` branches (local + remote).
- [ ] Detect: de-stack multi-block `/* */` headers (deferred from #30; would need care to avoid over-detection).
- [ ] cmd/applier: replace `isWriteRefusal` substring matching with a typed sentinel error (deferred from #34 to avoid colliding with #33).
- [ ] model: remove the dead `ResolveOptions.Verbose` field (deferred from #34).
+1 -1
View File
@@ -20,7 +20,7 @@ license-tool init [path] # Scaffold a .license-tool.yaml (interactive on a
license-tool version # Version, commit, build date
```
Shared flags: `--config <file>`, `--include <glob>` (repeatable), `--exclude <glob>` (repeatable), `--no-gitignore`, `--quiet|-q`, `--verbose|-v`.
Shared flags: `--config <file>`, `--include <glob>` (repeatable), `--exclude <glob>` (repeatable), `--no-gitignore`.
`audit` / `check` flags: `--format text|json|markdown` (default `text`; `check` forces machine-friendly), `--output <file>`, `--deps|--no-deps` (default on), `--resolve-deps ondisk|tool|off` (default `ondisk`), `--fail-on <conditions>` (check only; default `missing-header,unknown-license,policy-violation`).
+287
View File
@@ -0,0 +1,287 @@
# Write-Path Safety Review — license-tool
Scope: the safety-critical header rewrite path.
`internal/detect`, `internal/render`, `internal/header`, `internal/filetype`,
`internal/applier`, `internal/gitutil` (plus their tests).
Reviewer focus: any path where `apply` could mutate or delete non-header
content; LF/CRLF and BOM handling; off-by-one in byte spans; detection
false positives/negatives; non-idempotent (stacking) paths; temp-then-rename
crash safety; symlink/encoding edge cases. Verdict on each stated SAFETY
INVARIANT in `render.go`.
## Overall assessment
The architecture is sound and unusually careful for this class of tool: a
single byte-splice entry point (`render.Splice`), span-exact replacement,
EOL/ BOM/trailing-newline preservation, atomic temp-then-rename, a dirty-tree
gate, and symlinks excluded upstream in `enumerate`. The stated render
invariants mostly hold. The real exposure is concentrated in **detection**,
where two behaviors can cause `apply` to absorb and delete a non-license
leading comment that merely sits next to (or mentions) a license signal. Those
are the findings to fix before trusting `--write` on arbitrary repos.
No crash-corruption or partial-write bug was found; the atomic write is
correct. No panic path was found (out-of-range spans are refused).
## Severity-sorted summary
| # | Severity | Area | Finding |
|---|----------|------|---------|
| 1 | High | detect | A leading non-license comment sitting one blank line below a managed header is absorbed into the header span and DELETED on replace (line-comment types). |
| 2 | Medium | detect | `SPDX-License-Identifier:` matched anywhere in a leading comment marks it managed — a doc comment that *mentions* the tag is a false positive and gets replaced. |
| 3 | Medium | applier | `AtomicWrite` on a symlinked managed file (`LICENSE`) follows it for mode but `os.Rename` replaces the symlink with a regular file. Source files are safe (excluded in enumerate); `LICENSE`/`LICENSES/*` are not. |
| 4 | Low | render | `normalizeEOL`'s "input is LF-only" invariant is asserted, not enforced; a CR inside a vendored `StandardHeader` would yield `\r\r\n` on CRLF files. |
| 5 | Low | detect | `extractYear` fallback scans the whole comment, so a year inside license-body prose can be reported as the copyright year for fingerprint-only matches. |
| 6 | Low | detect | Block-comment detection spans only the first `/* */`; a header authored as two adjacent blocks is partially replaced (cosmetic stacking, not data loss). |
| 7 | Info | gitutil | `ls-files` output is newline-split; paths with embedded newlines (core.quotepath off + literal newline) are mishandled. Extremely rare; documented for completeness. |
---
## Finding 1 — Adjacent leading comment absorbed and deleted on replace (High)
`internal/detect/detect.go:290` (`lineCommentRegion`) and `:238`
(`leadingCommentRegion` → `consumeTrailingBlankLines`).
`lineCommentRegion` tolerates blank lines *between* comment lines and folds
them into one region:
```go
case strings.TrimSpace(body) == "":
// Blank line: tolerate it only between comment lines...
pendingBlank++
...
case strings.HasPrefix(trimmed, prefix):
for i := 0; i < pendingBlank; i++ { lines = append(lines, "") }
pendingBlank = 0
...
end = lineEnd
```
This is intentional for a header split by a blank line (test at
`detect_test.go:554`, "blank line then comment is still one region"). But it
does not distinguish a *continuation of the header* from an *unrelated leading
doc comment*. Consider a Rust/Go-`//` or Python-`#` file:
```
// SPDX-License-Identifier: MIT
//
// Some hand-written file-level doc comment that is NOT part of the license.
func ...
```
or worse, two visually separate blocks:
```
// SPDX-License-Identifier: MIT
// Package foo does X. Authored by the team; please keep this.
package foo
```
Both comment blocks plus the blank line between them become a single detected
span (`StartByte..EndByte`). `render.replaceSpan` then swaps that **entire**
span for the new header, so on a relicense/refresh the second, non-license
comment is silently deleted. This is exactly the "false positive deletes a real
doc comment" hazard the package doc warns against — here triggered not by a
prose license-name but by adjacency to a genuine header.
Failure scenario: any repo whose convention is "license header, blank line,
file doc comment" loses the doc comment on the first `apply --write` that
performs a replace (e.g. a year refresh or relicense).
Fix options:
- Bound the header region to the *contiguous* comment run (stop at the first
blank line), and only fold an interior blank line when the following comment
also carries a license signal; or
- When replacing, recompute the minimal span that actually contains the
license signal (sentinel/tag/fingerprint lines) rather than the whole
tolerated run, so trailing non-license comment lines are preserved.
The block-comment path is not affected by the blank-line tolerance (it spans a
single `/* */`), so this is specific to line-comment types — which is most of
the table (Rust, Python, Shell, Ruby, SQL, YAML, etc.).
## Finding 2 — `SPDX-License-Identifier:` matched anywhere marks a comment managed (Medium)
`internal/detect/detect.go:130` and `:339` (`spdxIdentifierTag`).
```go
const tag = "spdx-license-identifier:"
lower := strings.ToLower(commentText)
idx := strings.Index(lower, tag)
```
Any leading comment whose text *contains* the tag substring — anywhere, not
only as a line-leading REUSE tag — sets `Present = true`. A file that documents
SPDX usage, a code comment explaining the convention, a template/example, or a
generated doc comment that quotes `SPDX-License-Identifier: <id>` is treated as
a managed header and its whole comment block is replaced on `apply`. This is
realistic inside a licensing tool's own repository, in docs-as-code, and in
test fixtures.
The package doc claims "mere mention of a license name in prose never
qualifies" — true for the fingerprint path, but the SPDX-tag path has no such
guard. Combined with Finding 1, a stray tag mention can also drag an adjacent
comment into the deleted span.
Fix: require the tag to be the start of a (decoration-stripped) comment line,
i.e. match `^\s*(\*\s*)?SPDX-License-Identifier:` per line rather than
`strings.Index` over the whole region. The detector already splits to lines for
`extractHolder`; do the same here.
Severity is Medium rather than High because a true REUSE tag in prose is
uncommon and the sentinel/standard-header paths are not affected.
## Finding 3 — AtomicWrite replaces a symlinked managed file (Medium)
`internal/applier/applier.go:283` (`AtomicWrite`).
```go
if fi, err := os.Stat(path); err == nil { // follows symlink -> target's mode
mode = fi.Mode().Perm()
}
tmp, _ := os.CreateTemp(dir, ".license-tool-*")
...
return os.Rename(tmpName, path) // replaces the link, not the target
```
`os.Stat` follows a symlink, so the mode is read from the *target*; `os.Rename`
then drops the new file at the *link path*, replacing the symlink with a regular
file. For source files this never happens because `enumerate` skips symlinks
(`enumerate.go:158`, `:207`, with tests), so the source-rewrite path is safe.
But `writeManaged` (`applier.go:241`) calls `AtomicWrite` on
`<root>/LICENSE` and `<root>/LICENSES/<id>.txt` with no symlink check. A repo
that symlinks `LICENSE` to a shared file would have the link clobbered (and the
shared target left stale), or worse, if the temp lands in a different dir than
the link target, the link is silently converted.
Severity Medium: it does not corrupt content (the intended bytes are written),
but it can break an intentional symlink layout and is a surprise mutation of a
non-regular file.
Fix: in `AtomicWrite` (or `writeManaged`), `os.Lstat` the path and refuse /
warn when it is a symlink, mirroring the source-file policy; or resolve and
write through to the link target deliberately if that is the desired semantics.
Document whichever is chosen.
## Finding 4 — `normalizeEOL` LF-only precondition is unenforced (Low)
`internal/render/render.go:320` (`normalizeEOL`) and `:72` (`Header`).
```go
// normalizeEOL ... s must not already contain CR characters; Header guarantees LF-only output.
func normalizeEOL(s, eol string) string {
if eol == "\n" { return s }
return strings.ReplaceAll(s, "\n", eol)
}
```
`Header` builds the body from `in.License.StandardHeader` via
`strings.TrimRight(notice, "\n")` and `strings.Split(notice, "\n")` — it never
strips `\r`. If any vendored `StandardHeader` (or a future user-supplied
notice) contained CRLF, the CRs survive into the LF "canonical" header, and on
a CRLF file `normalizeEOL` turns each `\n` into `\r\n`, producing `\r\r\n`.
That is a malformed line ending spliced into the user's file.
Today the SPDX snapshot is almost certainly LF-only, so this is latent, hence
Low. But the safety story rests on an asserted invariant rather than an
enforced one.
Fix: normalize the assembled body to LF in `Header`/`plaintextHeader`
(`strings.ReplaceAll(s, "\r\n", "\n")` then strip stray `\r`) before wrapping,
so the precondition `normalizeEOL` relies on is actually true regardless of
source data.
## Finding 5 — `extractYear` can report a license-body year as the copyright year (Low)
`internal/detect/detect.go:425` (`extractYear`).
```go
for _, line := range strings.Split(commentText, "\n") {
if !strings.Contains(lower, "copyright") { continue }
if y := findYearSpan(line); y != "" { return y }
}
// Fall back to any copyright-bearing fragment even if the keyword case differed.
if y := findYearSpan(commentText); y != "" { return y } // scans EVERYTHING
```
The fallback scans the entire comment for the first 4-digit run. For a
fingerprint-only legacy header (no `Copyright` line, e.g. a bare GNU notice
that contains "version 3" / a URL / "1989, 1991"), the first 4-digit token in
the license boilerplate is returned as the holder's copyright year. This only
feeds the report's holder/year fields and the re-rendered header's year when
the policy is "explicit/derived", so it is not destructive, but it can write an
incorrect year. Low severity; worth tightening the fallback to require a
copyright-context token near the digits.
## Finding 6 — Block detection spans only the first comment block (Low / cosmetic)
`internal/detect/detect.go:259` (`blockCommentRegion`): "WHY only one block: a
license header is one comment block; a second block below is file content."
Reasonable, but a header authored as two adjacent `/* */` blocks (e.g. a
copyright block then a separate license block, a real legacy pattern in
C/Java) is only partially detected; replace swaps the first and leaves the
second, producing a stacked/contradictory notice. Not data loss, but it
violates the "no stacked notice" intent for that input shape. Low.
## Finding 7 — `git ls-files` newline split (Info)
`internal/gitutil/gitutil.go:51`: `strings.Split(out, "\n")`. With default git
quoting this is safe, but a tracked path containing a literal newline (possible
with `core.quotepath=off` and an exotic filename) would split into bogus paths.
Vanishingly rare; noted only for completeness. If hardening is desired, use
`ls-files -z` and split on NUL.
---
## SAFETY INVARIANT verification (render.go:17-25)
1. "Bytes outside the header region are never altered (BOM, shebang, XML/PHP
prolog, pragma, EOL, trailing-newline survive)."
**Upheld in render.** `insertAt`/`replaceSpan` only touch `[start,end)` or
the computed insertion offset; BOM/CRLF/no-trailing-newline are preserved
(verified by code trace and by `TestSplicePreservesBOM`,
`TestSplicePreservesCRLF`, `TestSplicePreservesNoTrailingNewline`,
`TestSpliceNeverAltersBytesOutsideHeaderRegion`). The caveat is upstream: if
`detect` reports a span that *includes* a non-license comment (Findings 1/2),
render faithfully deletes exactly that wrongly-sized span. The invariant
holds for render given a correct span; the span can be wrong.
2. "Header placed after preserve-first / before package decl."
**Upheld.** `PreserveBoundary` (header.go) is well-tested across BOM,
shebang (universal), xml-decl, php-open, pragma, go:build, @charset,
doctype, and the Before (package) rule is never consumed.
3. "Replacement uses the detected header's exact byte span; no stacking."
**Upheld at the byte level** (single span swap, idempotent no-op when equal,
out-of-range refused — `TestReplaceRejectsOutOfRangeSpan`,
`TestSpliceIdempotent`). CRLF replace idempotency also holds: the detector
absorbs the CRLF terminator and trailing blank line so the re-detected span
equals the freshly normalized CRLF header (traced: `blockCommentRegion`
handles `\r\n` at `:280`, `consumeTrailingBlankLines` folds the blank). The
"no stacking" guarantee can still be defeated by Finding 6's two-block input.
## Test-quality notes
Tests are substantive, not coverage-padding: assertions check byte prefixes,
ordering (header-before-package, after-shebang), exact spans, idempotency
(byte-equality on second apply), and real `go/build` constraint evaluation
(`goFileMatches`) to prove a build tag is not voided. Error seams (`tmpWrite`,
`tmpClose`, `chmodFn`, `detectFn`, `preserveBoundaryFn`, `run`) are used to
drive otherwise-unreachable failure branches honestly. Atomic-write mode
preservation, dirty-tree gating, and per-file error isolation are all
exercised against real git repos.
Gaps that the 100%-line target does not catch (all behavioral, not line
coverage):
- No test inserts a license header *above an unrelated leading comment* and
then relicenses, which would surface Finding 1. The existing
"blank line then comment is still one region" test (`detect_test.go:554`)
actually documents the buggy behavior as intended.
- No test for an `SPDX-License-Identifier:` string appearing in a non-header
doc comment (Finding 2).
- No test for `AtomicWrite` onto a symlink (Finding 3).
- No CRLF in a vendored `StandardHeader` (Finding 4) — currently impossible to
trigger from data, so untested by construction.
+264
View File
@@ -0,0 +1,264 @@
# Code Review — Audit / Reporting Half (config, enumerate, spdx, policy, resolve, report)
Reviewer: automated deep review. Scope: correctness of policy verdicts, exit codes, config
layering, enumeration filtering, SPDX normalization, and deterministic output. Lead bias:
a *false pass* (something that should fail CI but does not) is the worst outcome here.
## Summary (severity-sorted)
| # | Severity | Area | File:Line | One-line |
|---|----------|------|-----------|----------|
| 1 | High | enumerate | enumerate.go:268-282 | `**` include globs silently never match (uses `filepath.Match`); excludes DO support `**`, so the two filters disagree |
| 2 | High | resolve | spdxnorm.go:89-98 | Alias table **guesses** a license version/clause it cannot know (`bsd`→BSD-2-Clause, `gnu lesser general public license`→LGPL-3.0-or-later), violating the documented "never guess" invariant |
| 3 | Medium | resolve | spdxnorm.go:95-97, 102 | Aliases resolve to EPL-2.0 / AGPL etc. that pass `spdx.Validate` but are NOT in the curated set, so `policy.Classify` returns Unknown — a resolved dep can carry a license the policy layer treats as unknown without anyone noticing |
| 4 | Medium | resolve | gradle.go:51, 94-109 | Gradle dep regex requires a literal `group:artifact:version`; versionless / BOM-managed coordinates are silently dropped (not reported unresolved), so a Gradle dependency can be invisible to the audit |
| 5 | Medium | report | report.go:378-387 | `Check` only ever returns 0/1/4; the unresolved-dependency gate works only if `unresolved-dependency` is in `fail_on`, and the default `fail_on` set omits it — unresolved deps are a default *pass* |
| 6 | Low | resolve | npm.go:56-84 | Only **direct** declared deps are resolved; transitive deps installed under nested `node_modules` are never audited (documented, but a real coverage gap for a CI gate) |
| 7 | Low | config | config.go:456-461 | Flag excludes append while flag includes replace — correct per spec, but there is no test that a flag exclude *adds to* (rather than replaces) repo/user excludes through the flag layer specifically |
| 8 | Low | enumerate | enumerate.go:328-334 | Non-git walk reads only the **root** `.gitignore`; nested/global ignores are not honored (documented), so the git and non-git paths can enumerate different sets |
| 9 | Info | policy | policy.go:277-286 | Incompatibility table omits AGPL/GPL-3 vs GPL-2-only-family pairs that the prose claims to cover; heterogeneity still flags them, so no false pass, but the curated table is narrower than its doc comment |
No critical (provably wrong hard-fail/hard-pass with default config) issue was found in the
policy verdict logic itself; the policy package is solid. The dangerous items are #2 (a guessed
license can launder a copyleft dep into a wrong-but-resolved verdict) and #1 (include scoping
silently mis-filters).
---
## Details
### 1. (High) `**` include globs silently match nothing — include/exclude filters disagree
`internal/enumerate/enumerate.go:268-282`:
```go
func matchesIncludes(rel string, includes []string) bool {
if len(includes) == 0 {
return true
}
base := filepath.Base(rel)
for _, pat := range includes {
if ok, _ := filepath.Match(pat, rel); ok {
return true
}
if ok, _ := filepath.Match(pat, base); ok {
return true
}
}
return false
}
```
Includes are matched with the standard library `filepath.Match`, which has **no `**`
support**: `**` collapses to a single `*`, and `*` never matches a path separator. So an
include pattern of `src/**` matches `src/x.go` but **not** `src/sub/x.go`, and `**/generated/**`
matches nothing at any depth. The config layer explicitly accepts and round-trips these
patterns — `internal/config/config_test.go:173-175,535-543` use `src/**`, `**/generated/**`,
`repo/**`, `flags/**` as first-class include values — so a user who writes the documented
recursive include will silently get an empty or wrong file set.
The asymmetry is the real trap: **excludes** are compiled with `go-gitignore`
(`enumerate.go:116-117`, `ignore.CompileIgnoreLines`), which *does* implement `**`. So
`exclude: ["**/vendor/**"]` works as written while `include: ["src/**"]` does not. Two filters
on the same config, one honoring `**` and one not.
The test `TestMatchesIncludes` (enumerate_test.go:51-71) only exercises `*.go` and `src/*.go`
and even *asserts* the non-spanning behavior (`"full-path glob does not span dirs" ... false`) —
so this is locked-in behavior, not an oversight in coverage. That makes it coverage-padding
relative to the actual user contract: 100% lines, zero `**` include cases.
Fix: match includes with the same `go-gitignore`/doublestar engine used for excludes (or
`github.com/bmatcuk/doublestar`), so include and exclude glob semantics are identical. At
minimum, document loudly that includes are shell-glob (single-segment) not gitignore-glob, and
reject/normalize `**` in include patterns at config load so the user gets an error instead of a
silent empty set.
---
### 2. (High) Alias table guesses a license it cannot actually determine — "never guess" violated
`internal/resolve/spdxnorm.go:81-103`:
```go
var spdxAliases = map[string]string{
...
"bsd": "BSD-2-Clause",
"bsd license": "BSD-2-Clause",
...
"gnu lesser general public license": "LGPL-3.0-or-later",
...
}
```
The package contract (resolve.go:1-15, spdxnorm.go:11-20) is emphatic that the tool **never
guesses** a license. These two entries do exactly that:
- `"bsd"` / `"bsd license"` → `BSD-2-Clause`. The bare string "BSD" does not state the clause
count. It could be 2-clause, 3-clause, or 4-clause. Picking BSD-2-Clause is a guess, and it
can be the *wrong* license for policy purposes (a 4-clause/advertising-clause BSD is a
materially different obligation).
- `"gnu lesser general public license"` → `LGPL-3.0-or-later`. The name carries **no version**.
SPDX has `LGPL-2.0-*`, `LGPL-2.1-*`, `LGPL-3.0-*` (both `-only` and `-or-later`). Mapping the
versionless name to `3.0-or-later` invents both a version and the "or-later" grant. That is a
guess with legal weight: it can turn an LGPL-2.1-only dependency into a reported
LGPL-3.0-or-later, and `-or-later` vs `-only` is exactly the distinction the incompatibility
table (policy.go:277-286) cares about.
Because `EvaluateDependency` checks the resolved id against deny/allow, a wrong guess here can
produce a *wrong policy verdict* (pass a denied license, or fail an allowed one). Contrast the
conservative entries in the same table (`apache 2.0`→Apache-2.0) which are genuinely
unambiguous and SPDX-documented equivalences — those are fine.
Fix: delete the two ambiguous aliases. A versionless/clauseless name must stay **unresolved**
with a reason ("declared license %q is ambiguous (no version/clause); not guessed"), which is
the honest answer the package promises everywhere else.
---
### 3. (Medium) Aliases can resolve to a valid-but-uncurated id that policy then can't classify
`spdxnorm.go:95-97,102` map to `EPL-2.0`, `EPL-1.0`, `AGPL-3.0-or-later`, etc.
`internal/spdx/data/licenses/` ships only 14 curated detail files (no `EPL-*.json`), while the
full index used by `Validate` contains EPL. So for an EPL dependency:
- `normalizeSPDX("Eclipse Public License 2.0")` → `EPL-2.0`, `spdx.Validate("EPL-2.0")` is true →
the dep is marked **Resolved** with id `EPL-2.0`.
- But `policy.Classify("EPL-2.0")` calls `spdx.Lookup`, which fails (uncurated) →
`CategoryUnknown`.
`EvaluateDependency` (policy.go:109-152) only consults deny/allow for resolved deps, and
unknown-category does not feed any dependency-level signal, so an EPL dep with no allow-list
slips through as a clean resolved pass even though the tool cannot actually categorize it.
This is internally consistent (deps aren't classified), but it means "resolved" can quietly mean
"valid SPDX string we can't reason about". Worth either (a) curating EPL so the alias targets are
all classifiable, or (b) noting in the report when a resolved id is outside the curated set.
---
### 4. (Medium) Gradle coordinates without a literal version are silently dropped
`internal/resolve/gradle.go:51`:
```go
var gradleDepRE = regexp.MustCompile(`["']([\w.\-]+:[\w.\-]+:[\w.\-${}]+)["']`)
```
The regex requires three colon-separated segments, i.e. `group:artifact:version`. Real Gradle
builds frequently declare dependencies **without** an inline version when the version comes from
a platform/BOM or version catalog: `implementation 'com.google.guava:guava'`,
`implementation libs.guava`, or `implementation(platform("..."))`. None of these match, so the
dependency is not even emitted as `Unresolved` — it is **invisible** to the audit.
The package's stated value (gradle.go:18-23) is to "surface that a Gradle project's dependency
licenses are unknown, not silently treat the project as license-free." A versionless dep is
silently dropped, partially defeating that. The comment at scanGradleManifest:99-101 ("always
yields at least three parts; no short-coordinate guard is needed") is true only because the regex
pre-filters to 3-part matches — but that pre-filter is itself the silent drop.
Fix: also match 2-segment coordinates (`group:artifact`) and emit them as Unresolved with
version empty and a reason. Not high severity because Gradle is detect-only anyway, but a
dropped dependency is a worse failure mode than a listed-unresolved one.
---
### 5. (Medium) Unresolved dependencies are a *pass* under the default policy
`internal/config/config.go:98-104` — `Defaults()` sets `FailOn` to
`{missing-header, unknown-license, policy-violation}` and deliberately omits
`unresolved-dependency`.
`report.Build` (report.go:158-162) correctly folds an `unresolved-dependency` condition for every
non-resolved dep, and `Check` (report.go:378-387) gates on `Report.Passed`. But `Passed`
(policy.go:315-326) only trips for conditions in `fail_on`. So with the default config, a repo
full of unresolved Gradle/Maven/npm dependencies **passes** `check` (exit 0).
This is arguably intentional (resolution is best-effort), and it is consistent across the code,
but it is a sharp edge for a tool teams "trust to block bad licenses": the most common real-world
gap (unresolvable third-party licenses) is off by default. No code bug, but flag it in docs and
consider whether `unresolved-dependency` belongs in the shipped default for `check`. There is no
test asserting that the default `fail_on` excludes it, so the default is also untested intent.
---
### 6. (Low) npm resolves only direct declared dependencies
`internal/resolve/npm.go:56-84` builds the dep set from the **root** package.json's
`dependencies`+`devDependencies` only (`declaredDependencyNames`). Transitive packages that exist
under `node_modules` (including nested `node_modules/<a>/node_modules/<b>`) are never enumerated.
For a license CI gate, transitive licenses are usually the *point*. This is documented
(npm.go:14-19 frames node_modules as authoritative for the *declared* set), but "authoritative
and complete" in the Resolve doc comment (lines 53-55) overstates it — it is complete for direct
deps only. Consider walking installed `node_modules/*/package.json` for full coverage, or soften
the comment.
---
### 7. (Low) Flag-exclude accumulation lacks a dedicated test
`config.go:456-461`: flag excludes append (`cfg.Excludes = append(cfg.Excludes, flags.Exclude...)`),
flag includes replace. This matches the documented contract (excludes accumulate, include is the
highest-precedence *replacing* layer). `TestResolveLayering` (config_test.go:514-522) does verify
the three-layer exclude *union* including the flag layer, so this is covered — downgrade to info.
The include replace-semantics are well tested (lines 534-561). No action required; listed for
completeness.
---
### 8. (Low) Non-git walk honors only the root `.gitignore`
`enumerate.go:328-334` compiles a single matcher from `<root>/.gitignore`. Nested `.gitignore`
files, the global excludes file, and `.git/info/exclude` are ignored on the walk path. This is
explicitly documented (enumerate.go:321-327) and the git path defers to `git ls-files` which gets
it right. The risk is only that auditing a non-git checkout yields a *different* (larger) file set
than the same tree in git. Acceptable for v1; documented.
---
### 9. (Info) Incompatibility table narrower than its doc comment
`policy.go:266-286`: the prose says strong/network copyleft "cannot be combined into a single
permissive redistribution" and that GPL-2.0-only has no upgrade path to the v3 family. The table
covers GPL-2.0-only × {Apache, GPL-3-or-later, AGPL-3, LGPL-3} and the copyleft×Apache pairs, but
omits e.g. AGPL-3 × GPL-3 or GPL-3 × LGPL-2.1. Because `EvaluateRepo` also runs the heterogeneity
check (distinct categories > 1), most such pairs still surface as a `policy-violation`, so there
is **no false pass**. This is only a doc/coverage mismatch in the curated table, not a verdict
bug. The function logic (symmetric, self-pair guard, empty guard) is correct.
---
## Things checked and found correct (no finding)
- **Config precedence** (flags > repo > user > defaults) is implemented correctly: lower layers
seed `cfg`, each higher layer overrides only non-empty fields; `manage_license_file` uses
`*bool` so explicit `false` is distinguishable from absent (config.go:370-372). `KnownFields(true)`
rejects typo'd keys. Empty-document handling returns defaults (decodeYAML:330-342).
- **Excludes accumulate, includes replace** across layers — matches spec and is tested with `**`
patterns at every layer.
- **Year/style parsing** is strict: 4-digit clamp rejects `21`/`20260`, range end<start rejected,
unknown style/year/fail_on tokens are hard errors (no silent CI weakening).
- **Explicit `--config` missing = error; discovered missing = silent** (config.go:137-151). Correct.
- **SPDX two-surface split** (`Validate` permissive over full index, `Lookup` curated) is sound;
`IDs()` correctly excludes deprecated ids from the picker; load is lazy/once/read-only so
concurrent callers are safe. AGPL header override is verbatim-body-preserving.
- **Policy `Passed`** is order-independent and correctly returns true for empty `fail_on`; per-file
and repo violations both feed the verdict via `report.Build`.
- **`EvaluateDependency`** correctly treats `resolved-but-no-id` as unresolved (policy.go:125-132)
rather than passing — a good defensive guard against a resolver lying.
- **Determinism**: every renderer sorts via `sortedCounts`/`sortedFiles`/`sortedDeps`; JSON relies
on encoding/json's sorted map keys plus pre-sorted slices; repo violation tokens are
sorted+deduped. `Build` preserves input file order but counts are order-independent. Verified no
map-iteration order leaks into output.
- **Enumerate skip ladder** (symlink → non-regular → unknown-type → uncommentable → binary) is in a
defensible order; `IsBinary` tolerates a truncated trailing rune at the 8 KiB boundary; staged
deletions (ls-files lists a path absent on disk) are skipped, not fatal.
- **`normalizeSPDX`** correctly rejects npm `SEE LICENSE`/`UNLICENSED` sentinels and compound
expressions (one-id-per-dep invariant).
## Test-quality note
The policy, spdxnorm, and config layering tests are **substantive** (real layering with `**`
globs, stacked-violation ordering assertions, category-sort assertions, alias branch coverage).
The one coverage-padding pattern that masks a real bug is `TestMatchesIncludes`
(enumerate_test.go:51-71): it asserts the broken single-segment behavior as if it were the
contract, so 100% line coverage coexists with finding #1. Recommend adding `**` include cases —
they will fail today and should drive the fix.
+235
View File
@@ -0,0 +1,235 @@
# Review 03 — Command layer, init TUI, and test quality
Scope: `cmd/license-tool/main.go`, `cmd/license-tool/commands.go` (+ `commands_test.go`),
`cmd/license-tool/wizard.go`, `internal/initwizard/{model.go,catalog.go}` (+ `catalog_test.go`).
Reviewed against the repo at branch `feature/GH-29-init-tui-wizard`.
## Summary table
| # | Area | Finding | Severity | Location |
|---|------|---------|----------|----------|
| 1 | Command layer | `--quiet` / `--verbose` persistent flags are bound but never read anywhere — dead UX surface | high | `commands.go:57-58` |
| 2 | Init TUI | Dead "Project model" step: collected, previewed, written to `Answers.Project`, but `answersToConfig` never reads `a.Project` — the whole first wizard screen has no effect on output | high | `commands.go:404-440`, `commands.go:452`, `wizard.go:353-354` |
| 3 | Init TUI | ~990-line `wizard.go` mixes pure logic (selection math, parsing, layout, preview assembly) with the Bubble Tea shell, so genuinely testable logic is parked in a coverage-excluded file | high | `wizard.go` (whole file) |
| 4 | Init TUI | Cram-two-values-into-one-textfield: Identity = `holder \| year`, Coverage = `include \| exclude`. Fragile `strings.Cut`/`splitCSV` parsing with no validation feedback in the field | medium | `wizard.go:728-768` |
| 5 | Model | `LicenseAnswer.Private` field is fully dead (never set, never read) | medium | `model.go:73-74` |
| 6 | Init correctness | `init` ignores `cfg.Year`/`cfg.Style`/etc. from a discovered config file: it scaffolds purely from `Defaults()` + flags, never calling `config.Resolve`. A second `init --force` silently discards prior non-flag settings | medium | `commands.go:419`, `commands.go:466-470` |
| 7 | Init correctness | `init` is the only write command that does NOT validate the target dir as a git tree / honor `--allow-dirty`; `--allow-dirty`/`--commit`/`--commit-message`/`--write` flags are bound on `init` (via `bindApplyFlags`) but meaningless there | medium | `commands.go:481` |
| 8 | Test quality | `internal/initwizard/model.go` has NO test file; the `Answers`/`Step` model and `ErrAborted` reach 100% only incidentally via `commands_test.go`/`wizard.go` usage — no behavioral assertions on the model itself | low | `internal/initwizard/` |
| 9 | Test quality | `TestInitCommandConsumesCollectorAnswers` asserts `initial.Project.Model` is passed in but never asserts the returned `Project`/`Review` affect output (consistent with finding #2 — there is nothing to assert, which masks the dead field) | low | `commands_test.go:1134-1192` |
| 10 | Command layer | `isWriteRefusal` classifies write-refusal vs internal errors by `strings.Contains` on the error message — brittle string coupling to `internal/applier` and `internal/config` wording | medium | `commands.go:290-293` |
| 11 | Command layer | `check --fail-on` only overrides policy when the flag was `Changed`; the bound default `[]string{...}` is therefore inert and exists only as help text. Mild redundancy, not a bug | low | `commands.go:143-149`, `commands.go:189` |
Overall: the command layer is correct on the load-bearing guarantees (dry-run-by-default,
exit-code mapping, `--fail-on` parsing, output-file handling, `isTTY` gating) and is
genuinely well-tested. The init TUI is the weak spot: it is large, carries a dead step and
a dead field, and parks testable logic inside the coverage-excluded shell. Test quality is
high and substantive almost everywhere — the few hollow spots are a direct consequence of
the dead wizard surface, not coverage-padding tricks.
---
## 1. Command layer correctness (focus area 1)
### Dry-run-by-default — CORRECT
`apply` and `license` default `--write=false` (`commands.go:385`) and pass `Write: f.write`
into `applier.Apply`/`applier.License`. `applier.go` guards every mutating branch behind
`opts.Write` (`applier.go:73,134,137,153,165,176`), so absent `--write` the run only
produces diffs. `TestApplyCommand/dry-run reports without writing` (`commands_test.go:646`)
asserts the source file is left untouched, and `dry-run json includes unified diff` /
`write json omits unified diff` pin the diff-presence contract. This guarantee is solid and
well covered.
### Exit-code mapping — CORRECT
`main.go` maps via `commandError`/`withExitCode`/`exitCode`:
- usage = 2 (also the fallback for untyped cobra errors via `exitCode`'s default arm),
- check failure = 1,
- write refusal = 3,
- internal = 4.
`commands_test.go:195-246` covers all four through `executeRoot`, and
`TestCheckFailingExits` (`commands_test.go:564`) uses the subprocess re-exec idiom to prove
the real `os.Exit(1)` path. `TestWithExitCodeNilError` covers the nil short-circuit. This is
thorough and correct.
### `--fail-on` parsing — CORRECT, one redundancy (finding #11)
`parseFailOnFlags` (`commands.go:337`) splits on commas AND across repeated flags via
`splitFlagTokens`, trims, drops empties, and rejects unknown tokens. `TestParseFailOnFlags`
covers the mixed `"a, b"` + repeated-flag + unknown cases.
Redundancy: the override only fires when `cmd.Flags().Changed("fail-on")` is true
(`commands.go:143`). The bound default list at `commands.go:189` is therefore never used to
*set* policy (the config/Defaults layer supplies the effective default); it serves only as
`--help` text. Harmless, but the dead default invites a future reader to assume it is
authoritative. Consider binding `--fail-on` with a nil default and documenting the real
default in the flag usage string.
### Output-file handling — CORRECT
`renderCommandReport` (`commands.go:314`) writes to stdout when `--output` is empty,
otherwise opens via the `createReportFile` seam, renders, and closes — correctly reporting
both render and close errors as internal (exit 4). `TestRenderCommandReportErrors` injects a
close-failing writer through the seam and asserts the close error is surfaced; the
`audit`/`check` "output file create error is internal error" subtests cover the create-fail
path. Good seam design and good coverage.
### `isTTY` gating — CORRECT
`isTTY` (`commands.go:652`) uses `term.IsTerminal` rather than an `os.ModeCharDevice` bit
test; the comment correctly explains that `/dev/null` is a char device but not a terminal.
`TestIsTTY` covers char-device, regular-file, and closed-fd cases. Note the gating reaches
TWO different interactive paths: the Bubble Tea wizard (`init`) and the line-prompt
`fillRequired` in `internal/config/config.go:617` (`apply`/`license`). Both are driven off
`isTTY()`; the test suite forces non-TTY via `forceNonTTYStdin`, so neither prompts in CI.
### Dead flags — finding #1 (high)
`--quiet`/`--verbose` are registered on the root persistent flag set (`commands.go:57-58`)
and stored on `sharedFlags`, but a grep across `cmd/` and `internal/` shows the fields are
read nowhere. They are pure no-ops. Either wire them into report verbosity / output
suppression or remove them; shipping flags that silently do nothing is a correctness/UX
trap. (They are not covered by any assertion either, so removing them is free.)
### `isWriteRefusal` string-matching — finding #10 (medium)
`isWriteRefusal` (`commands.go:290`) decides exit 3 vs exit 4 by
`strings.Contains(msg, "refusing to write")` / `"already exists (use --force)"`. This
couples the command layer to exact error wording in `internal/applier` and
`internal/config`. A reworded error in those packages would silently downgrade a write
refusal to an internal error (3→4) with no compile-time or test signal. Prefer a typed
sentinel (e.g. `errors.Is(err, applier.ErrWriteRefused)`) so the classification is
enforced by the type system.
---
## 2. The init TUI as code (focus area 2)
### Why it is 990 lines and uncoverable
`wizard.go` is excluded from the 100% gate (`.testcoverage.yml`) on the stated grounds that
it is "a Bubble Tea terminal shell ... no write-path validation of its own." That is only
half true. The file contains a large amount of pure, deterministic, trivially-testable
logic that has nothing to do with the terminal event loop:
- selection math: `wrapIndex`, `clampInt`, `windowStart`, `indexString`,
`indexProjectModel`, `moveSelection*` (`wizard.go:578-692,924-961`);
- license filtering: `filteredLicenseChoices`, `licenseChoicePosition`,
`alignLicenseSelection`, `isCommonLicense` (`wizard.go:639-682`);
- input parsing: `commitInput`, `coverageInputValue`, `splitCSV` (`wizard.go:728-768`);
- preview assembly: `previewConfig`, `previewResolvedYear`, `previewCandidatePaths`,
`ignoredPreviewDir`, `previewSummary` (`wizard.go:501-527,770-853`);
- text layout: `fitText`, `ellipsize`, `selectionList`, `licenseBody`, `reviewBody`,
`navigationBody`, `stepTitle` (`wizard.go:228-499,895-922`);
- formatting helpers: `yesNo`, `listOrAll`, `listOrNone`, `valueOrPlaceholder`.
None of these require a PTY. Only `Init`/`Update`/`View`/`runInitWizard`/`newInitWizardModel`
and the layout-width branches are genuinely shell-bound. The coverage exclusion is being
used to hide ~400+ lines of untested-but-testable logic behind the "it's a TUI" rationale.
Recommended extraction (does NOT change UX): move the pure pieces into
`internal/initwizard` (the package already exists and is fully tested) — e.g.
`initwizard.LicenseChoices(opts, filter, selectedIndex)`, `initwizard.PreviewConfig(...)`,
`initwizard.CoverageInput(...)`/`ParseCoverageInput(...)`, and the `wrapIndex`/`windowStart`
math. `wizard.go` then keeps only `tea.Model` glue and remains legitimately excluded. This
would meaningfully raise real (not gamed) coverage and let edge cases (empty license list,
filter-with-no-matches, window scrolling at boundaries) be asserted directly.
### Dead "Project model" step — finding #2 (high)
The wizard's first screen collects `Answers.Project.Model` (`wizard.go:353-354,580-582`),
the review screen displays it (`wizard.go:482`), and `newInitCmd` seeds it to
`ProjectModelAdvancedManual` (`commands.go:452`). But `answersToConfig` (`commands.go:404`)
never reads `a.Project` — it builds the config from License/Identity/HeaderStyle/
LicenseFiles/Coverage only. So the entire Project-model step is a no-op: whatever the user
picks, the generated `.license-tool.yaml` is identical. This is the single most misleading
thing in the init flow. Either (a) make the project model actually drive defaults
(open-source → manage license files + reuse+notice, private-internal → notice-only, etc.),
or (b) remove the step and the `ProjectAnswer` field until it has an effect. The
`projectModelOptions` slice and `ProjectModel*` constants are otherwise only self-referential.
### Cram-two-values-into-one-textfield — finding #4 (medium)
- Identity field packs `holder | year` into one text input (`wizard.go:716-719`), parsed by
`strings.Cut(value, "|")` in `commitInput` (`wizard.go:730-737`). If the holder legitimately
contains a `|`, parsing breaks. If the user omits the separator, the whole string becomes
the holder and year silently stays at its prior value.
- Coverage field packs `include globs | exclude globs` (`wizard.go:720-722,738-747`), split
on `|` then comma via `splitCSV`. Same separator-collision fragility; a glob containing a
literal `|` (rare but legal in some shells/patterns) would be mis-split.
There is no inline validation: a malformed entry is only rejected later in
`answersToConfig` (bad year/style) or accepted silently (bad globs). The instruction says
do not redesign UX — flagging purely as a code-fragility / maintainability concern. If kept,
the parse functions should at minimum be extracted and unit-tested with collision cases.
### Maintainability problems (concrete)
- `bodyView` (`wizard.go:271-332`) hard-codes a thicket of magic layout numbers
(`< 100`, `46`, `72`, `34`, `navWidth 22/18/24`, `topHeight` clamps) with no named
constants and no tests; any change risks silent visual regressions that the excluded-file
status guarantees won't be caught.
- Two parallel ordered lists of steps exist: the `Step` iota in `model.go:28-43` and the
literal slice in `navigationBody` (`wizard.go:389-397`). They must be kept in sync by hand;
adding a step requires editing both plus `stepTitle`'s switch (`wizard.go:232-248`).
- `previewConfig` (`wizard.go:770`) and `answersToConfig` (`commands.go:404`) independently
translate `Answers → model.Config` with subtly different rules (preview substitutes
`"Example, Inc."` for an empty holder and swallows parse errors; the real path rejects
them). Two translators for one concept is a drift hazard — the preview can show a config
the real writer would reject.
---
## 3. Test quality across the repo (focus area 3)
### Command layer tests — substantive, not padding
`commands_test.go` is strong: assertions check real output content (`+ SPDX-License-Identifier`,
unified-diff markers, JSON schema string, `result: PASS/FAIL`), file-system side effects
(header applied vs. not, LICENSE files created vs. absent), git commit scoping
(`--name-only` of HEAD excludes unrelated files), and exit codes via `executeRoot`. Negative
paths are covered with message assertions (bad format, bad fail-on token, unknown resolver
tier, missing `--config`, non-curated `Zlib`). The seams (`createReportFile`,
`interactiveCollect`) are injected to reach error arms deterministically. The subprocess
re-exec for `os.Exit` is the correct idiom. testify usage is idiomatic (`require` for
fatal preconditions, `assert` for soft checks); table tests in `TestArgPath`/`TestParseFailOnFlags`
are sound. This is meaningful coverage.
### `answersToConfig` gate — well-tested
`TestAnswersToConfig` exercises the valid case plus every rejection arm (unknown license,
empty holder, bad year, bad style) and the "empty year/style keep Defaults" branch. This is
exactly the right place to concentrate assertions given the wizard is excluded, and it does
the job.
### `internal/initwizard/catalog_test.go` — substantive
Asserts the catalog's exact membership and stable order (15 families), copy-semantics
(`TestCatalogReturnsACopy` mutates the returned slice and re-reads), extension→family
mapping including the C vs C++ split and C#, and `BuildSourcePreview` edge cases (empty
sample→C fallback, missing year, unknown license, no-file-type, uncommentable override).
`BuildYAMLPreview` is asserted to byte-match `config.RenderFile`. Good golden-style and
edge-case coverage. Minor: `indexOf` is a hand-rolled substring search where
`strings.Index` would do — harmless test-helper verbosity.
### Hollow / missing spots
- **`internal/initwizard/model.go` has no `model_test.go`** (finding #8, low). The
`Answers`/`Step`/`*Answer` types and `ErrAborted` are data/constants, so reaching them via
other packages satisfies the line gate, but there is no test that documents the model's
contract (e.g. step ordering, that `ErrAborted` is the sentinel `runInitWizard` returns).
This is benign for a pure data file, but it means the 100% number is partly incidental
here rather than earned by direct assertions.
- **`TestInitCommandConsumesCollectorAnswers`** (finding #9, low) passes `initial.Project.Model`
in and asserts it, and returns a `Project`/`Review` in the fake answers, but never asserts
those affect the written file — because they can't (finding #2). The test thus *looks* like
it validates the project model round-trips when in fact the field is inert. This is not
coverage-padding in the gaming sense, but it does give a false impression of behavior; a
reader should not trust that the Project field matters from this test.
### testify / table-test soundness — good
`require` vs `assert` discipline is correct throughout. `t.Setenv`/`t.TempDir`/`t.Cleanup`
are used to isolate (`isolateEnv` pins `XDG_CONFIG_HOME` and forces non-TTY stdin). No
obvious flaky-by-clock or order-dependent tests in the reviewed files. The one global-state
mutation (`os.Stdin`, `createReportFile`, `interactiveCollect`) is always paired with a
`t.Cleanup` restore.
---
## Recommended actions (priority order)
1. Resolve the dead Project step (#2): either wire `a.Project` into `answersToConfig`
defaults or remove the step + `ProjectAnswer` + `LicenseAnswer.Private` (#5).
2. Wire or remove `--quiet`/`--verbose` (#1).
3. Extract pure logic from `wizard.go` into `internal/initwizard` and add direct tests (#3);
collapse the two `Answers→Config` translators (preview vs real) into one.
4. Replace `isWriteRefusal` string matching with a typed sentinel error (#10).
5. Reconcile `init`'s flag surface: drop `--allow-dirty`/`--commit`/`--commit-message`
(or honor them) and decide whether `init` should layer an existing config via
`config.Resolve` rather than scaffolding only from Defaults+flags (#6, #7).
+308
View File
@@ -0,0 +1,308 @@
# Clean-room review: `audit --summary` and `--group-by`
Reviewer: independent, no prior context. Evaluated `feature/GH-35-audit-summary-reports`
built to `/tmp/cr-lt` from `cmd/license-tool`. Exercised against this repo, a hand-built
mixed-license repo (`/tmp/cr-mix`: MIT/Apache-2.0/AGPL/BSD + unlicensed `vendor/` + a
markdown file), and a 96-file tree (`/tmp/cr-big`). All output below is pasted from real
runs.
Lens: a compliance engineer scanning a repo, and an LLM/dashboard consuming the JSON.
---
## TL;DR verdict
The two features work and compose cleanly across all three formats, output is
deterministic, and the code is well-factored (`group.go` is pure, `findings.go` is pure).
But the views are **shallow**: they answer "how many files have license X" and stop. They
do not answer the questions a compliance engineer actually asks — *what's my coverage %,
where is my copyleft exposure, which group is the problem, why did policy fail*. The two
biggest gaps are:
1. **The grouped/summary views carry no risk signal.** A group is just `key (count)`.
There is no per-group license/category, no copyleft flag, no "this group is the
violation." You can group by `directory` but each group then hides which licenses live
in it — the one cross-tab a human wants (license × directory) is impossible.
2. **`policy-violation` is opaque everywhere.** The top-level verdict says
`FAIL (1: policy-violation)` and the JSON emits `"violations": ["policy-violation"]`
with zero detail about *which license* tripped *which rule*. For an LLM this is nearly
useless — it can see AGPL is present and that policy failed, but cannot state the causal
link with confidence.
Plus a pile of quick wins: lexical directory sort (`pkg10` before `pkg2`), no totals/
percentages anywhere, the rich `findings:` block exists **only** in text (Markdown and JSON
don't get it), no "problems only" filter, and `(none)` is overloaded as both "no header"
and "empty section."
---
## What works well (credit where due)
- **Composition is clean.** `--summary` × `--group-by` × `--format` all combine sensibly.
Summary+group gives per-group counts only; full+group nests files. No crashes, no weird
interactions.
- **Determinism.** Keys and files are sorted; JSON map keys come out stable; empty
aggregates render `{}`/`[]` not `null` (`nonNilCounts`/`nonNilStrings`). Good for diffing
reports in CI.
- **The `findings:` text block is genuinely good** — coverage, license mix, unknown count,
copyleft list, deps resolution, policy cause on one screen. The problem is only that it's
trapped in the text renderer.
- **Skipped files are handled honestly** — never grouped, surfaced as a trailing
`(skipped: N)` so they don't pollute the license buckets, and `(none)` is a deliberate
sentinel (documented in code) so missing headers stay visible.
- **JSON `--summary` trims correctly and keeps a separate DTO** (`jsonSummaryReport`) so the
full schema stays byte-stable. Schema is versioned (`license-tool/report/v1`).
---
## Data quality
### Right info present, but no totals or percentages — anywhere
`licenseCounts` sums to 9 but the tool never tells you that, nor "78% headered." A
compliance reader has to add the columns by hand.
```
$ /tmp/cr-lt audit . --deps=false --format json | jq 'has("totals"), (.licenseCounts|add)'
false
9
```
There is no `totals` / `coverage` object in JSON, and the count tables in text/markdown have
no total row and no percentage column.
### The verdict cause is invisible
This is the worst data-quality gap. The mix repo has AGPL files and fails policy:
```
$ /tmp/cr-lt audit . --deps=false
policy: FAIL (1: policy-violation)
...
policy violations:
policy-violation
```
```
$ /tmp/cr-lt audit . --deps=false --format json | jq '.violations'
[ "policy-violation" ]
```
`policy-violation` is a bare token. It does not name the offending license
(`AGPL-3.0-or-later`), the rule (deny? copyleft-not-allowed?), or the files. The *per-file*
`missing-header` tokens are present on file records, but the repo-level policy reason is a
black box. An LLM asked "why did this fail and what do I do" can only guess.
### Grouped views have no risk dimension
Every group is `key (count)`. When you group by `directory`, you lose all license
information for that group:
```
$ /tmp/cr-lt audit . --group-by directory --summary
source files by directory:
scripts (1)
src (4)
vendor (2)
web (2)
```
`src` holds both MIT (`src/core`) and Apache (`src/util`) — invisible here. `web` is all
AGPL (the risky one) — also invisible. The group that matters for compliance (where's the
copyleft?) is indistinguishable from the rest. There is no copyleft/risk flag per group, no
secondary breakdown.
### Directory grouping is top-segment only — collapses real structure
`groupKey`→`topDir` takes only the first path segment, so `src/core` and `src/util` both
become `src`. On a deep monorepo this collapses everything under a handful of top dirs and
the view is nearly content-free. There's no depth control (`--group-by directory:2`) and no
full-path option.
### No multi-dimension grouping, no filtering to problems
```
$ /tmp/cr-lt audit . --group-by license,directory
report: unknown group-by dimension "license,directory" ...
$ /tmp/cr-lt audit --help | grep -iE 'only|filter|problem'
(nothing)
```
You cannot cross-tab (license × directory is the killer compliance view), and you cannot say
"show me only the files that are missing headers / unknown / copyleft." On the 96-file tree
the full grouped view is 125 lines of mostly-fine files; the 24 problem files are buried.
### Dependencies are second-class
`--group-by` only ever touches **source files**. Dependencies — arguably the higher
compliance risk — cannot be grouped at all (by license, by ecosystem, by resolution).
`--summary` drops the dep list entirely, leaving only `dependencies: N (resolved R,
unresolved U)` in the text findings; the Markdown/JSON summary don't even carry that.
### `(none)` is overloaded
`(none)` is the missing-header license bucket **and** the empty-section placeholder
(`renderCountSection(..., "(none)")`). In `--group-by license` a real bucket `(none) (2)`
sits next to potential `(none)` placeholders elsewhere — ambiguous for both humans and
parsers. A distinct token like `(no-header)` for the bucket would disambiguate.
---
## Display / UX (text)
- **Lexical directory sort is wrong for humans.** On the big tree:
```
source files by directory:
pkg1 (8)
pkg10 (8)
pkg11 (8)
pkg12 (8)
pkg2 (8)
...
```
`pkg10`–`pkg12` jump ahead of `pkg2`. Natural sort, or sort-by-count-desc, would read far
better. There is **no sort option** at all — everything is alpha-by-key.
- **No sort-by-count.** The thing a human scans for is "which license/dir dominates" — that
wants count-descending. Alpha order buries it.
- **Counts columns use a fixed `%-24s`** which is fine for short SPDX ids but `network-
copyleft` and `JavaScript/TypeScript` nearly fill it; longer keys would misalign. Minor.
- **No color / no visual hierarchy.** Output is plain (no ANSI even to a TTY-ish pipe). For
a compliance tool, flagging copyleft/violations in red and headings in bold would make the
risk pop. At minimum the `(none)` and copyleft rows deserve a marker (`!`).
- **Large repos dump everything.** Full grouped view on 96 files = 125 lines. With no
`--summary`-by-default-over-N and no problems filter, the signal-to-noise is low. The
flat list under `--summary`-off is unbounded.
- **The findings block is the best part of the text output** and should be promoted/ported,
not buried below the rollups.
---
## Machine / LLM usefulness (JSON)
The schema is clean and stable, which is the right foundation. Gaps that specifically hurt
LLM/dashboard use:
- **No totals/derived fields.** Every consumer re-derives `total`, `headered`, `coverage`,
`copyleftCount`. The text renderer already computes all of this in `buildFindings` — it
just isn't in the JSON. Ship a `findings`/`summary` object in JSON.
- **`violations` is a flat string array of opaque tokens.** `["policy-violation"]` cannot be
reasoned over. An LLM can't tell the user *which* license or rule without inventing it.
Make each violation an object: `{rule, severity, license, files:[...], message}`.
- **Risk flags absent.** There is `categoryCounts` with `network-copyleft: 2`, but no
boolean/severity the consumer can branch on. A `riskLevel` per license or per group, or a
top-level `hasCopyleft`/`worstCategory`, would let a dashboard color a badge without a
hardcoded category list.
- **Groups carry no breakdown.** `groups[]` is `{key, count, files[]}`. For an LLM doing
"summarize compliance by directory" the group needs its own `licenseCounts`/`categoryCounts`
/`copyleft` so it doesn't have to re-aggregate `files[]` (and under `--summary`, `files`
is gone, so re-aggregation is *impossible*).
- **Summary-trimming the JSON is the wrong default for machines.** Stripping `files`/
`dependencies` under `--summary` is fine for text/markdown (human ergonomics), but a JSON
consumer usually wants *complete* data and will do its own projection. Recommend: JSON
ignores `--summary` for trimming (always complete), or gates trimming behind an explicit
`--json-compact`. At minimum, when groups are summary-trimmed, still emit per-group
aggregate counts so the data isn't lossy.
- Good: `omitempty` on optional file fields keeps records tight; `hasHeader` boolean is
clean; resolution is a string enum.
---
## Markdown
- **Paste-into-PR quality is decent** — proper tables, code-fenced paths, per-group `###`
headings, a `_Skipped (uneditable): N_` note. A reviewer can read it.
- **But Markdown is missing the findings block entirely.** The header jumps from
`**Result:** FAIL` straight to `## By SPDX id`. None of coverage / copyleft / unknown /
*why it failed* appears. The single most useful thing to paste into a PR ("this PR
introduces AGPL, here's the exposure") is absent.
- **No summary/TL;DR up top, no totals in the tables.** A `> 78% headered, 2 copyleft files,
policy FAIL` callout would make it a real report.
- Violations render as a bare bullet list of the same opaque tokens.
---
## Surprising / confusing / wrong
- **`policy: FAIL (1: policy-violation)` with no cause** — surprising for a compliance tool
whose whole job is to explain exposure. (The default policy fails on the AGPL present in
`web/js`, but you'd never know that from the output.)
- **`GPL-3.0-only` is a recognized SPDX id the tool refuses to render** a header for
(`apply` errors: "recognized ... but license-tool cannot render it"). Limited template set;
worth documenting which ids are renderable, since it surprised me mid-test.
- **`apply` refuses on a dirty git tree** (sensible) but this makes scripting a multi-license
scaffold awkward — every subtree apply needs an intervening commit. Not an audit issue, but
noted while building fixtures.
- **Directory grouping silently collapsing `src/core`+`src/util` into `src`** is easy to
misread as "all of src is one license."
---
## Prioritized improvements
### Quick wins (small, high leverage)
1. **Expand `policy-violation` into a structured cause.** *Rationale: the verdict is the
headline and it's currently unexplainable; biggest ROI for both humans and LLMs.*
```
policy violations:
copyleft-not-allowed: AGPL-3.0-or-later (2 files: web/js/app.js, web/js/lib.js)
```
JSON: `"violations":[{"rule":"copyleft-not-allowed","license":"AGPL-3.0-or-later","severity":"high","files":["web/js/app.js","web/js/lib.js"]}]`
2. **Put `findings` in JSON and Markdown, not just text.** *Rationale: the best content
already exists (`buildFindings`); it's a renderer gap, near-zero new logic.*
JSON: `"findings":{"sourceTotal":9,"headered":7,"missing":2,"coverage":0.78,"copyleft":["AGPL-3.0-or-later"],"unknown":0}`
3. **Add totals + percentages to every count table.** *Rationale: stops every consumer
re-summing; one line per table.*
```
by category:
network-copyleft 2 (22%)
permissive 5 (56%)
unknown 2 (22%)
total 9
```
4. **Natural-sort (or offer `--sort count|name`) for groups/counts.** *Rationale: `pkg10`
before `pkg2` is visibly wrong; count-desc surfaces the dominant license first.*
5. **Rename the missing-header bucket from `(none)` to `(no-header)`.** *Rationale: removes
the overload with the empty-section placeholder; trivial.*
6. **Mark risk in text output** (e.g. trailing ` !copyleft` or a leading `*` on copyleft/
none rows; respect `NO_COLOR`). *Rationale: a compliance reader should see risk without
reading every line.*
### Bigger bets (more design, more value)
7. **Per-group breakdown / risk flag.** Every `Group` carries its own `licenseCounts`,
`categoryCounts`, and a `copyleft`/`riskLevel`. *Rationale: makes `--group-by directory`
actually useful (which dirs hold copyleft) and lets JSON groups stand alone under
`--summary`.*
```
source files by directory:
web (2) AGPL-3.0-or-later 2 [COPYLEFT]
src (4) MIT 2, Apache-2.0 2
```
8. **Multi-dimension / cross-tab grouping** (`--group-by directory,license`). *Rationale:
"which directories introduce which licenses" is the central compliance question and is
currently unanswerable.*
9. **A `--only` / `--filter` problems mode** (`--only missing,unknown,copyleft,violations`).
*Rationale: on real repos the problem files are a tiny fraction; surfacing only them is
what a reviewer and an LLM both want.*
10. **Group dependencies too** (`--group-deps-by license|ecosystem|resolution`), and keep
a dep rollup in `--summary`. *Rationale: deps are often the bigger compliance risk and
are currently entirely ungroupable and dropped from summaries.*
11. **Directory grouping depth control** (`--group-by directory:2` or full-path mode).
*Rationale: top-segment-only collapses monorepos into meaningless buckets.*
12. **Decouple JSON from `--summary` trimming** (always-complete JSON, or `--json-compact`).
*Rationale: machines want full data + their own projection; lossy summary JSON forces
a second full run.*
### Specifically valuable for LLM consumption
- **Structured violations** (#1) — turns "it failed somehow" into an actionable,
attributable fact the model can repeat verbatim.
- **A top-level `findings`/`totals` object** (#2, #3) — lets the model state coverage and
risk without arithmetic it might get wrong.
- **Per-group aggregates that survive `--summary`** (#7, #12) — so the model can summarize a
grouped report without the (now-stripped) file list.
- **An explicit `riskLevel`/`worstCategory` field** — so the model branches on a value
instead of pattern-matching category strings against memorized copyleft knowledge.
@@ -0,0 +1,163 @@
# Clean-Room Review 2 — `audit` Grouping & Reporting
Reviewer: independent (engineer + LLM-consumer lens). Skeptical, evidence-based.
Date: 2026-06-08. Branch: `feature/GH-29-init-tui-wizard`.
Binary built to `/tmp/cr2-lt` from `cmd/license-tool` (clean build, no tracked files touched).
## Test rig
Throwaway git repo at `/tmp/cr2repo`. Real headers applied with the tool's own
`apply --write --force`, one license per subtree so groupings have something to chew on:
- `src/core/*.go` → MIT (one file deliberately stripped of its header → `(no-header)`)
- `src/util/{c.go,d.py}` → Apache-2.0
- `scripts/{f.sh,g.py}` → AGPL-3.0-only
- `vendor/lib/e.go` → MIT
- `.license-tool.yaml` committed with `policy: { allow: [MIT] }` so Apache/AGPL fire violations
- `go.mod` with a `require` to exercise the dependency path
File mix: Go, Python, Shell, YAML across 4 directory subtrees and 2 depths. Every flag
combination was run across `text`, `json`, and `markdown`.
---
## Prior-issue verdicts
### 1. Grouped/summary views had no risk signal; `--group-by directory` lost license info — ADDRESSED
Every group now carries a per-group `[risk: …]` and (for non-license groupings) a
`licenses:` breakdown. `--group-by directory` shows both, in all three formats. JSON
groups carry `key/count/risk/licenses{}/files[]`. Markdown renders per-group `risk:` and a
`Licenses:` line plus a file table. Solid.
### 2. `policy-violation` was opaque — ADDRESSED
File-attributable violations now name license + rule + file. Text/markdown:
`scripts/f.sh: license "AGPL-3.0-only" is not on the allow list`. Markdown gives a real
table with separate `Condition | License | Location | Detail` columns. JSON
`violationDetails[]` carries structured `{condition, spdxId, path, message}`. An LLM can
branch on `condition`/`spdxId` without string-parsing. This is the biggest improvement.
### 3. No totals or percentages — ADDRESSED (text/markdown), PARTIAL (JSON)
Text and markdown rollups now show per-row `%` and a `total` row. JSON deliberately does
**not** carry percentages or totals — it ships raw count maps (`licenseCounts{}` etc.) and
expects the consumer to compute. Defensible (machines can divide), but worth noting it is
not symmetric with the human formats. Counts-only in JSON is fine; the asymmetry is a
documentation footnote, not a bug.
### 4. `findings` block existed only in text — ADDRESSED
`findings` is present in all three. JSON `findings{}` is the richest: explicit
`riskLevel`, `worstCategory`, `sourceTotal/Headered/Missing`, `unknownCount`, `copyleft[]`,
and `depsScanned/Total/Resolved/Unresolved`. Markdown has a `## Findings` section. Good for
LLMs — a model can read one object and decide pass/fail/triage.
### 5. Lexical-only sort; no sort-by-count — ADDRESSED
`--sort count` works and reorders rollups and groups by descending count (ties fall back to
key order, so output stays byte-stable). `--sort key` remains the default.
### 6. Directory grouping was top-segment only — ADDRESSED
`--depth N` works. At `--depth 1`, `src/core` and `src/util` collapse to `src`; at
`--depth 2` they split correctly into `src/core` and `src/util` with independent risk and
license breakdowns. Behaves as documented.
### 7. No "problems only" filter (`--only missing,copyleft,violations`) — STILL OPEN
`--only` is not a flag (`unknown flag: --only`). There is no way to filter the report to
just the failing rows. For large trees an engineer still has to eyeball or post-filter, and
an LLM has to ingest the whole file list to find the 3 violations. This is the most
impactful remaining gap.
### 8. Dependencies couldn't be grouped; dropped from `--summary` — PARTIAL
- Dropped-from-summary: ADDRESSED. JSON `--summary` `findings{}` retains
`depsScanned/depsTotal/depsResolved/depsUnresolved`, so dependency posture survives the
trim.
- Grouped: STILL OPEN. `--group-by dependency` is rejected:
`unknown group-by dimension "dependency" (expected license|category|type|directory)`.
Dependencies cannot be grouped by license/ecosystem. (Note: in my rig the resolver
reported 0 deps despite a `go.mod require`, likely module-cache dependent; I could not
exercise a populated dependency grouping, but the dimension simply does not exist.)
### 9. `(none)` overloaded as both missing-header bucket and empty placeholder — PARTIAL
Mostly fixed: the missing-header bucket is consistently `(no-header)` in rollup tables and
in every group's `licenses:` map; the empty config placeholder is `(none)`. They are now
visually distinct. **Residual inconsistency:** the `findings:` line still labels the same
bucket `none` (`license types: … none 2`), a third spelling for the same concept. Pick one
token (`(no-header)`) everywhere.
### 10. JSON `--summary` trimmed files/deps (wrong for machines) — STILL OPEN (by design)
JSON `--summary` still omits the `files` and `dependencies` arrays (confirmed in
`internal/report/report.go:858` `jsonSummaryReport`, and at runtime: `files`/`dependencies`
keys absent). The code comment states this is intentional ("counts only … no per-file or
per-dependency detail"). The original objection stands as a philosophical disagreement: a
machine asking for `--format json` arguably always wants the full record, and `--summary`
is a human ergonomic. Today a consumer must choose between summary-shaped findings and the
file list — they cannot get findings + machine detail in one trimmed call. Verdict: OPEN,
but it is a deliberate stance rather than a regression.
**Tally:** ADDRESSED 6 (1,2,4,5,6 + the summary-deps half of 8) · PARTIAL 3 (3, 8, 9) ·
STILL OPEN 2 (7, 10).
---
## Fresh assessment
### Genuinely good
- **Markdown is PR-ready.** The violations table with discrete License/Location/Detail
columns drops straight into a PR comment and reads well. Findings + rollups + grouped
tables are all coherent.
- **JSON is LLM-friendly where it counts.** `findings.riskLevel`/`worstCategory` and
structured `violationDetails[]` mean a model branches on enums, not prose. `schema`
versioning (`license-tool/report/v1`) is present and a good signal.
- **Risk + license breakdown on every group**, in every format, with depth control. This
was the headline ask and it lands.
- **Byte-stable output** (sorted maps, pre-sorted slices) — important for diffing audit
runs in CI and for caching.
### Still weak / confusing / suspect data
- **No problems-only filter (item 7).** Biggest usability gap for both audiences.
- **Group risk is local-only and can mislead.** With `policy.allow: [MIT]` and an AGPL↔Apache
hard-incompatibility flagged at repo level, the `src/util` (Apache) group still prints
`[risk: low]`. A reader scanning groups sees "low" next to a license that is part of a
repo-level hard conflict. Group risk should reflect policy violations attributed to that
group, not just the intrinsic license category.
- **`unknown` / missing-header rendered as `[risk: none]`.** Files with no header are an
audit *liability* (unknown obligations), but the category group prints `risk: none`. "none"
reads as "nothing to worry about." `unknown` would be more honest than `none`.
- **`.license-tool.yaml` counted as a missing-header "source file."** The config file itself
lands in `sourceMissing` and generates a `missing-header` violation. That inflates
`sourceTotal`/`sourceMissing` (9/2 where a human would say 8/1) and produces a violation
for the tool's own config. The tool should exclude its own config (and arguably data files
like YAML/JSON) from header expectations by default.
- **`none` vs `(no-header)` token drift** in the findings line (item 9 residual).
- **`--summary` JSON can't also carry detail (item 10).** No way to get findings + machine
file records in one trimmed response.
- **Dependency resolution returned 0** on a repo with a `go.mod require` — could not verify
the dep path end-to-end; may be environment-specific, but worth a smoke test in CI with a
populated module cache.
### New problems / regressions
- No outright regression observed; the full (non-summary) JSON shape is preserved as
intended. The new concerns above (config-as-source-file, local-only group risk,
`risk: none` for unknowns) are pre-existing semantics now made more visible by the
richer output, not breakage introduced by the grouping work.
---
## Prioritized next steps
Quick wins
1. **Add `--only missing,copyleft,violations` (item 7).** Highest value/effort ratio; makes
the report actionable for large trees and cheap for LLMs to consume.
2. **Exclude the tool's own `.license-tool.yaml` (and ideally data files) from header
expectations.** Stops the tool flagging its own config and fixes inflated source counts.
3. **Unify the missing-header token to `(no-header)` in the `findings` line** (drop `none`).
One-line consistency fix.
4. **Render unknown/missing-header group risk as `unknown`, not `none`.** Avoids a false
"all clear" read.
Bigger bets
5. **Make group risk policy-aware.** Roll attributed policy violations (allow-list, hard
incompatibilities) into each group's risk so a group containing a conflicting license
doesn't read `low`. Most likely to change an engineer's triage decision.
6. **Add `--group-by dependency` (item 8) and verify the resolver in CI.** Dependencies are
half the license-risk surface; they should be groupable by license/ecosystem like source.
7. **Reconsider JSON `--summary` (item 10):** either always emit `files`/`dependencies` for
`--format json` (treat `--summary` as human-only), or add `--fields`/`--include-files` so a
machine can request findings + detail in one call.
+4 -8
View File
@@ -3,7 +3,9 @@ module github.com/KofTwentyTwo/license-tool
go 1.25.0
require (
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/lipgloss v1.1.0
github.com/github/go-spdx/v2 v2.7.0
github.com/google/licensecheck v0.3.1
github.com/hexops/gotextdiff v1.0.3
@@ -17,24 +19,17 @@ require (
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/bubbletea v1.3.6 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
@@ -42,6 +37,7 @@ require (
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/exp v0.0.0-20231006140011-7918f672742d // 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
-26
View File
@@ -1,49 +1,25 @@
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=
@@ -62,8 +38,6 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
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=
+33 -12
View File
@@ -229,23 +229,30 @@ func ManageLicenseFiles(path string, cfg model.Config, opts Options) ([]model.Fi
return nil, err
}
return []model.FileResult{
writeManaged(path, "LICENSE", []byte(body), opts),
writeManaged(path, filepath.Join("LICENSES", cfg.License+".txt"), []byte(entry), opts),
}, nil
licenseFR, lerr := writeManaged(path, "LICENSE", []byte(body), opts)
if lerr != nil {
return nil, lerr
}
entryFR, eerr := writeManaged(path, filepath.Join("LICENSES", cfg.License+".txt"), []byte(entry), opts)
if eerr != nil {
return nil, eerr
}
return []model.FileResult{licenseFR, entryFR}, nil
}
// writeManaged computes the action/diff for a managed whole-file (LICENSE or a
// LICENSES entry) and, on --write, atomically writes it. It never errors the run;
// a write failure is recorded on the result so one bad path does not abort apply.
func writeManaged(root, rel string, want []byte, opts Options) model.FileResult {
// LICENSES entry) and, on --write, atomically writes it. A symlinked target is a hard
// refusal returned as an error so the CLI reports a write refusal rather than
// clobbering the link into a regular file; every other write failure is recorded on
// the result (and not returned) so one bad path does not abort the whole run.
func writeManaged(root, rel string, want []byte, opts Options) (model.FileResult, error) {
abs := filepath.Join(root, rel)
fr := model.FileResult{Path: rel, FileType: "license", Action: "none"}
existing, rerr := os.ReadFile(abs)
switch {
case rerr == nil && string(existing) == string(want):
return fr
return fr, nil
case rerr == nil:
fr.Action = "replace"
default:
@@ -259,13 +266,16 @@ func writeManaged(root, rel string, want []byte, opts Options) model.FileResult
if opts.Write {
if mkerr := os.MkdirAll(filepath.Dir(abs), 0o755); mkerr != nil {
fr.Err = mkerr.Error()
return fr
return fr, nil
}
if werr := AtomicWrite(abs, want); werr != nil {
if errors.Is(werr, errSymlinkTarget) {
return fr, werr
}
fr.Err = werr.Error()
}
}
return fr
return fr, nil
}
// tmpWrite, tmpClose, and chmodFn are seams over the temp-file write/close and the
@@ -278,12 +288,23 @@ var (
chmodFn = os.Chmod
)
// errSymlinkTarget is returned when a write target is itself a symlink. Its message
// contains "refusing to write" so the CLI's isWriteRefusal mapping classifies it as a
// write refusal (exit code 3) rather than a generic internal error.
var errSymlinkTarget = errors.New("applier: refusing to write through a symlink")
// AtomicWrite writes content to path via a temp-file-then-rename so a crash never
// leaves a half-written source file. It preserves the original file mode.
// leaves a half-written source file. It preserves the original file mode. If path is
// itself a symlink it refuses rather than clobbering the link: os.Rename would replace
// the symlink with a regular file, silently breaking a deliberate link such as
// LICENSE -> LICENSES/MIT.txt.
func AtomicWrite(path string, content []byte) error {
dir := filepath.Dir(path)
mode := os.FileMode(0o644)
if fi, err := os.Stat(path); err == nil {
if fi, err := os.Lstat(path); err == nil {
if fi.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("%w: %s", errSymlinkTarget, path)
}
mode = fi.Mode().Perm()
}
+163 -2
View File
@@ -76,6 +76,22 @@ func phpFileType() model.FileType {
}
}
// pyFileType returns a "# " line-comment Python file type. It is used to exercise the
// issue #30 regression: a line-comment header followed by a blank line then a foreign
// doc comment must not absorb (and therefore must not delete) that doc comment.
func pyFileType() model.FileType {
return model.FileType{
Name: "Python",
Extensions: []string{".py"},
CommentStyle: model.CommentStyle{Block: false, LinePrefix: "# "},
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
{Kind: model.PreserveShebang, Before: false},
{Kind: model.PreserveCodingPragma, Before: false},
},
}
}
// agplConfig is the canonical apply config: AGPL, reuse+notice, an explicit year so
// the rendered header is deterministic and independent of the wall clock.
func agplConfig() model.Config {
@@ -328,6 +344,41 @@ func TestAtomicWriteChmodError(t *testing.T) {
require.ErrorIs(t, err, boom)
}
// TestAtomicWriteRefusesSymlink confirms AtomicWrite refuses to replace a target
// that is a symlink (e.g. LICENSE -> LICENSES/MIT.txt) rather than clobbering it
// into a regular file. The link must be left intact and its target untouched.
func TestAtomicWriteRefusesSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation is restricted on windows")
}
dir := t.TempDir()
target := filepath.Join(dir, "LICENSES", "MIT.txt")
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755))
require.NoError(t, os.WriteFile(target, []byte("original target\n"), 0o644))
link := filepath.Join(dir, "LICENSE")
require.NoError(t, os.Symlink(target, link))
err := AtomicWrite(link, []byte("clobbered\n"))
require.Error(t, err)
assert.Contains(t, err.Error(), "refusing to write", "refusal must match the write-refusal exit-code mapping")
assert.ErrorIs(t, err, errSymlinkTarget)
// The symlink itself must survive as a symlink.
fi, lerr := os.Lstat(link)
require.NoError(t, lerr)
assert.NotZero(t, fi.Mode()&os.ModeSymlink, "LICENSE must still be a symlink")
// The link target must be unchanged (not clobbered through the link).
got, rerr := os.ReadFile(target)
require.NoError(t, rerr)
assert.Equal(t, "original target\n", string(got))
dest, rlerr := os.Readlink(link)
require.NoError(t, rlerr)
assert.Equal(t, target, dest, "link destination must be unchanged")
}
// --- ApplyFile ------------------------------------------------------------
// agplHeaderFunc returns a HeaderRenderFunc that renders the canonical AGPL header
@@ -413,6 +464,67 @@ func TestApplyFile(t *testing.T) {
})
}
// lookupLicense fetches a curated license by id or fails the test. Local to these
// tests to avoid depending on a sibling package's test helper.
func lookupLicense(t *testing.T, id string) model.License {
t.Helper()
lic, ok := spdx.Lookup(id)
require.True(t, ok, "license %q must be in the curated set", id)
return lic
}
// TestApplyFilePreservesDocCommentBelowBlankLine is the issue #30 end-to-end safety
// guard. A line-comment file carrying a managed header, a blank line, then an unrelated
// file doc comment must keep that doc comment intact across a relicense: the detected
// span covers only the header, so the replace swaps only the header bytes.
func TestApplyFilePreservesDocCommentBelowBlankLine(t *testing.T) {
ft := pyFileType()
// Step 1: insert an MIT header into a file whose body opens with a doc comment.
mitFunc := func(f model.FileType) (string, error) {
return render.Header(render.HeaderInput{
License: lookupLicense(t, "MIT"),
Holder: "Acme Corp",
Year: "2024",
Style: model.StyleReuse,
FileType: f,
})
}
body := "# This module wires the dashboard widgets together.\n" +
"# It is intentionally documented at the top of the file.\n" +
"\n" +
"def main():\n pass\n"
withHeader, _, action, err := ApplyFile([]byte(body), ft, mitFunc)
require.NoError(t, err)
require.Equal(t, "insert", action)
require.Contains(t, string(withHeader), "SPDX-License-Identifier: MIT")
require.Contains(t, string(withHeader), "wires the dashboard widgets",
"the foreign doc comment must be present after insert")
// Step 2: relicense to Apache-2.0. This replaces the detected header span.
apacheFunc := func(f model.FileType) (string, error) {
return render.Header(render.HeaderInput{
License: lookupLicense(t, "Apache-2.0"),
Holder: "Acme Corp",
Year: "2026",
Style: model.StyleReuse,
FileType: f,
})
}
relicensed, _, action2, err := ApplyFile(withHeader, ft, apacheFunc)
require.NoError(t, err)
require.Equal(t, "replace", action2)
got := string(relicensed)
assert.Contains(t, got, "SPDX-License-Identifier: Apache-2.0", "new license written")
assert.NotContains(t, got, "SPDX-License-Identifier: MIT", "old license id removed")
// The safety-critical assertion: the foreign doc comment survives the relicense.
assert.Contains(t, got, "# This module wires the dashboard widgets together.",
"the doc comment below the blank line must survive a relicense")
assert.Contains(t, got, "# It is intentionally documented at the top of the file.")
assert.Contains(t, got, "def main():")
}
func TestApplyFileIdempotentAfterPreserveFirstConstructs(t *testing.T) {
cases := []struct {
name string
@@ -588,7 +700,8 @@ func TestWriteManagedMkdirError(t *testing.T) {
// "LICENSES" exists as a FILE, so MkdirAll(LICENSES) fails for the entry write.
require.NoError(t, os.WriteFile(filepath.Join(root, "LICENSES"), []byte("x"), 0o644))
fr := writeManaged(root, filepath.Join("LICENSES", "AGPL-3.0-or-later.txt"), []byte("body"), Options{Write: true})
fr, err := writeManaged(root, filepath.Join("LICENSES", "AGPL-3.0-or-later.txt"), []byte("body"), Options{Write: true})
require.NoError(t, err)
assert.NotEmpty(t, fr.Err)
}
@@ -609,10 +722,58 @@ func TestWriteManagedWriteError(t *testing.T) {
require.NoError(t, os.Mkdir(roDir, 0o555))
t.Cleanup(func() { _ = os.Chmod(roDir, 0o755) })
fr := writeManaged(roDir, "LICENSE", []byte("body"), Options{Write: true})
fr, err := writeManaged(roDir, "LICENSE", []byte("body"), Options{Write: true})
require.NoError(t, err)
assert.NotEmpty(t, fr.Err)
}
// TestWriteManagedRefusesSymlink confirms the managed license-file write path
// surfaces a symlinked target as a returned refusal (so the CLI exit-code mapping
// reports a write refusal) rather than silently clobbering the link.
func TestWriteManagedRefusesSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation is restricted on windows")
}
root := t.TempDir()
target := filepath.Join(root, "LICENSES", "AGPL-3.0-or-later.txt")
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755))
require.NoError(t, os.WriteFile(target, []byte("real license text\n"), 0o644))
require.NoError(t, os.Symlink(target, filepath.Join(root, "LICENSE")))
_, err := ManageLicenseFiles(root, agplConfig(), Options{Write: true})
require.Error(t, err)
assert.Contains(t, err.Error(), "refusing to write")
assert.ErrorIs(t, err, errSymlinkTarget)
// The symlink and its target must be untouched.
fi, lerr := os.Lstat(filepath.Join(root, "LICENSE"))
require.NoError(t, lerr)
assert.NotZero(t, fi.Mode()&os.ModeSymlink)
got, rerr := os.ReadFile(target)
require.NoError(t, rerr)
assert.Equal(t, "real license text\n", string(got))
}
// TestLicenseRefusesSymlinkTarget confirms the refusal propagates through the
// public License entry point, where the CLI maps it to the write-refused exit code.
func TestLicenseRefusesSymlinkTarget(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation is restricted on windows")
}
root := t.TempDir()
gitInit(t, root)
writeFile(t, root, "main.go", "package main\n")
gitAddCommit(t, root)
target := filepath.Join(root, "LICENSES", "AGPL-3.0-or-later.txt")
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755))
require.NoError(t, os.WriteFile(target, []byte("real license text\n"), 0o644))
require.NoError(t, os.Symlink(target, filepath.Join(root, "LICENSE")))
_, err := License(root, agplConfig(), Options{Write: true, AllowDirty: true})
require.Error(t, err)
assert.Contains(t, err.Error(), "refusing to write")
}
// --- Apply: validation and dry-run ----------------------------------------
// TestApplyUnknownLicense rejects an unknown SPDX id up front.
+16 -5
View File
@@ -23,9 +23,11 @@ import (
"github.com/KofTwentyTwo/license-tool/internal/spdx"
)
// repoConfigName is the committed, per-repo config file discovered at the scan
// root. It doubles as the apply input and the check expectation for a repo.
const repoConfigName = ".license-tool.yaml"
// RepoConfigName is the committed, per-repo config file discovered at the scan
// root. It doubles as the apply input and the check expectation for a repo, and is
// the basename the audit excludes from source/header coverage (tool metadata, not
// coverable source).
const RepoConfigName = ".license-tool.yaml"
// userConfigRel is the user/global config path relative to $XDG_CONFIG_HOME.
const userConfigRel = "license-tool/config.yaml"
@@ -93,6 +95,7 @@ func Defaults() model.Config {
Year: model.YearSpec{Kind: model.YearGit},
Style: model.StyleReusePlusNotice,
ManageLicenseFile: true,
Includes: nil,
Excludes: nil,
Policy: model.Policy{
FailOn: []model.FailCondition{
@@ -211,6 +214,7 @@ func RenderFile(cfg model.Config) ([]byte, error) {
Year: yearSpecRaw(cfg.Year),
Style: cfg.Style.String(),
ManageLicenseFile: &cfg.ManageLicenseFile,
Include: cfg.Includes,
Exclude: cfg.Excludes,
}
// Only emit a policy block when the config actually carries policy intent, so a
@@ -238,7 +242,7 @@ var renderFile = RenderFile
// 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)
target := filepath.Join(path, RepoConfigName)
if !force && fileExists(target) {
return "", fmt.Errorf("config: %s already exists (use --force)", target)
}
@@ -301,6 +305,7 @@ type fileSchema struct {
Year string `yaml:"year"`
Style string `yaml:"style"`
ManageLicenseFile *bool `yaml:"manage_license_file"`
Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"`
Policy policySchema `yaml:"policy"`
FileTypes map[string]fileTypeOverrideYAML `yaml:"file_types"`
@@ -367,6 +372,9 @@ func mergeSchema(cfg *model.Config, fs fileSchema) error {
if fs.ManageLicenseFile != nil {
cfg.ManageLicenseFile = *fs.ManageLicenseFile
}
if len(fs.Include) > 0 {
cfg.Includes = append([]string(nil), fs.Include...)
}
if len(fs.Exclude) > 0 {
// Excludes accumulate: a layer's excludes add to, rather than replace, the
// lower layers' so a repo cannot accidentally lose user/global exclusions.
@@ -450,6 +458,9 @@ func mergeFlags(cfg *model.Config, flags Flags) error {
if len(flags.Exclude) > 0 {
cfg.Excludes = append(cfg.Excludes, flags.Exclude...)
}
if len(flags.Include) > 0 {
cfg.Includes = append([]string(nil), flags.Include...)
}
return nil
}
@@ -674,7 +685,7 @@ func repoConfigPath(scanPath, configFlag string) (path string, explicit bool) {
if configFlag != "" {
return configFlag, true
}
return filepath.Join(scanPath, repoConfigName), false
return filepath.Join(scanPath, RepoConfigName), false
}
// fileExists reports whether path names an existing regular-readable file.
+65 -24
View File
@@ -169,6 +169,8 @@ holder: "Kingsrook, LLC"
year: git
style: reuse+notice
manage_license_file: true
include:
- "src/**"
exclude:
- "**/generated/**"
policy:
@@ -189,6 +191,9 @@ file_types:
if fs.ManageLicenseFile == nil || !*fs.ManageLicenseFile {
t.Errorf("manage_license_file not decoded as true: %+v", fs.ManageLicenseFile)
}
if len(fs.Include) != 1 || fs.Include[0] != "src/**" {
t.Errorf("include not decoded: %+v", fs.Include)
}
if len(fs.Policy.Allow) != 2 || len(fs.FileTypes) != 1 {
t.Errorf("nested keys not decoded: %+v", fs)
}
@@ -223,7 +228,7 @@ file_types:
func TestLoadFile(t *testing.T) {
t.Run("valid file seeds from defaults", func(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, repoConfigName)
p := filepath.Join(dir, RepoConfigName)
writeFile(t, p, `
license: MIT
holder: Acme
@@ -257,7 +262,7 @@ style: reuse
t.Run("invalid style errors", func(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, repoConfigName)
p := filepath.Join(dir, RepoConfigName)
writeFile(t, p, "style: bogus\n")
if _, err := LoadFile(p); err == nil {
t.Fatal("LoadFile with invalid style should error")
@@ -266,7 +271,7 @@ style: reuse
t.Run("explicit manage_license_file false", func(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, repoConfigName)
p := filepath.Join(dir, RepoConfigName)
writeFile(t, p, "manage_license_file: false\n")
cfg, err := LoadFile(p)
if err != nil {
@@ -344,7 +349,7 @@ func TestFileTypeOverrides(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, repoConfigName)
p := filepath.Join(dir, RepoConfigName)
writeFile(t, p, tt.yaml+"\n")
cfg, err := LoadFile(p)
if tt.wantErr {
@@ -454,7 +459,7 @@ year: current
// Repo layer overrides holder and style; inherits nothing else from user
// except where it stays silent (license, year here).
repo := t.TempDir()
writeFile(t, filepath.Join(repo, repoConfigName), `
writeFile(t, filepath.Join(repo, RepoConfigName), `
holder: RepoHolder
style: notice
`)
@@ -483,7 +488,7 @@ style: notice
isolateXDG(t)
repo := t.TempDir()
// Discovered repo file would set MIT; explicit --config sets Apache.
writeFile(t, filepath.Join(repo, repoConfigName), "license: MIT\nholder: A\n")
writeFile(t, filepath.Join(repo, RepoConfigName), "license: MIT\nholder: A\n")
explicit := filepath.Join(t.TempDir(), "custom.yaml")
writeFile(t, explicit, "license: Apache-2.0\nholder: B\n")
@@ -509,7 +514,7 @@ style: notice
xdg := isolateXDG(t)
writeFile(t, filepath.Join(xdg, "license-tool", "config.yaml"), "license: MIT\nholder: A\nexclude: [\"**/vendor/**\"]\n")
repo := t.TempDir()
writeFile(t, filepath.Join(repo, repoConfigName), "exclude: [\"**/generated/**\"]\n")
writeFile(t, filepath.Join(repo, RepoConfigName), "exclude: [\"**/generated/**\"]\n")
cfg, err := Resolve(repo, Flags{Exclude: []string{"**/*.pb.go"}}, Options{Interactive: false})
if err != nil {
t.Fatalf("Resolve error: %v", err)
@@ -524,6 +529,38 @@ style: notice
}
}
})
t.Run("highest non-empty include layer wins", func(t *testing.T) {
xdg := isolateXDG(t)
writeFile(t, filepath.Join(xdg, "license-tool", "config.yaml"), "license: MIT\nholder: A\ninclude: [\"global/**\"]\n")
repo := t.TempDir()
writeFile(t, filepath.Join(repo, RepoConfigName), "include: [\"repo/**\"]\n")
cfg, err := Resolve(repo, Flags{}, Options{Interactive: false})
if err != nil {
t.Fatalf("Resolve error: %v", err)
}
if len(cfg.Includes) != 1 || cfg.Includes[0] != "repo/**" {
t.Fatalf("includes = %v, want repo layer only", cfg.Includes)
}
cfg, err = Resolve(repo, Flags{Include: []string{"flags/**"}}, Options{Interactive: false})
if err != nil {
t.Fatalf("Resolve error: %v", err)
}
if len(cfg.Includes) != 1 || cfg.Includes[0] != "flags/**" {
t.Fatalf("includes = %v, want flags layer only", cfg.Includes)
}
repoWithoutInclude := t.TempDir()
cfg, err = Resolve(repoWithoutInclude, Flags{}, Options{Interactive: false})
if err != nil {
t.Fatalf("Resolve error: %v", err)
}
if len(cfg.Includes) != 1 || cfg.Includes[0] != "global/**" {
t.Fatalf("includes = %v, want user layer only", cfg.Includes)
}
})
}
func TestResolveRequiredFields(t *testing.T) {
@@ -652,7 +689,7 @@ func TestResolveBadConfigSurfaces(t *testing.T) {
t.Run("invalid year in repo config errors", func(t *testing.T) {
isolateXDG(t)
repo := t.TempDir()
writeFile(t, filepath.Join(repo, repoConfigName), "license: MIT\nholder: A\nyear: nope\n")
writeFile(t, filepath.Join(repo, RepoConfigName), "license: MIT\nholder: A\nyear: nope\n")
if _, err := Resolve(repo, Flags{}, Options{Interactive: false}); err == nil {
t.Fatal("invalid year in config should error")
}
@@ -661,7 +698,7 @@ func TestResolveBadConfigSurfaces(t *testing.T) {
t.Run("invalid fail_on in repo config errors", func(t *testing.T) {
isolateXDG(t)
repo := t.TempDir()
writeFile(t, filepath.Join(repo, repoConfigName), "license: MIT\nholder: A\npolicy:\n fail_on: [bogus]\n")
writeFile(t, filepath.Join(repo, RepoConfigName), "license: MIT\nholder: A\npolicy:\n fail_on: [bogus]\n")
if _, err := Resolve(repo, Flags{}, Options{Interactive: false}); err == nil {
t.Fatal("invalid fail_on in config should error")
}
@@ -670,7 +707,7 @@ func TestResolveBadConfigSurfaces(t *testing.T) {
t.Run("invalid policy required expression errors with field and value", func(t *testing.T) {
isolateXDG(t)
repo := t.TempDir()
writeFile(t, filepath.Join(repo, repoConfigName), "license: MIT\nholder: A\npolicy:\n required: NOT-A-LICENSE\n")
writeFile(t, filepath.Join(repo, RepoConfigName), "license: MIT\nholder: A\npolicy:\n required: NOT-A-LICENSE\n")
_, err := Resolve(repo, Flags{}, Options{Interactive: false})
if err == nil {
t.Fatal("invalid policy.required should error")
@@ -683,7 +720,7 @@ func TestResolveBadConfigSurfaces(t *testing.T) {
t.Run("policy required expression rejected because enforcement is exact id", func(t *testing.T) {
isolateXDG(t)
repo := t.TempDir()
writeFile(t, filepath.Join(repo, repoConfigName), "license: MIT\nholder: A\npolicy:\n required: MIT OR Apache-2.0\n")
writeFile(t, filepath.Join(repo, RepoConfigName), "license: MIT\nholder: A\npolicy:\n required: MIT OR Apache-2.0\n")
_, err := Resolve(repo, Flags{}, Options{Interactive: false})
if err == nil {
t.Fatal("policy.required expression should error")
@@ -696,7 +733,7 @@ func TestResolveBadConfigSurfaces(t *testing.T) {
t.Run("invalid policy allow expression errors with field and value", func(t *testing.T) {
isolateXDG(t)
repo := t.TempDir()
writeFile(t, filepath.Join(repo, repoConfigName), "license: MIT\nholder: A\npolicy:\n allow: [MIT AND]\n")
writeFile(t, filepath.Join(repo, RepoConfigName), "license: MIT\nholder: A\npolicy:\n allow: [MIT AND]\n")
_, err := Resolve(repo, Flags{}, Options{Interactive: false})
if err == nil {
t.Fatal("invalid policy.allow should error")
@@ -709,7 +746,7 @@ func TestResolveBadConfigSurfaces(t *testing.T) {
t.Run("policy allow expression rejected because enforcement is exact id", func(t *testing.T) {
isolateXDG(t)
repo := t.TempDir()
writeFile(t, filepath.Join(repo, repoConfigName), "license: MIT\nholder: A\npolicy:\n allow: [MIT OR Apache-2.0]\n")
writeFile(t, filepath.Join(repo, RepoConfigName), "license: MIT\nholder: A\npolicy:\n allow: [MIT OR Apache-2.0]\n")
_, err := Resolve(repo, Flags{}, Options{Interactive: false})
if err == nil {
t.Fatal("policy.allow expression should error")
@@ -722,7 +759,7 @@ func TestResolveBadConfigSurfaces(t *testing.T) {
t.Run("invalid policy deny expression errors with field and value", func(t *testing.T) {
isolateXDG(t)
repo := t.TempDir()
writeFile(t, filepath.Join(repo, repoConfigName), "license: MIT\nholder: A\npolicy:\n deny: [GPL-2.0-typo]\n")
writeFile(t, filepath.Join(repo, RepoConfigName), "license: MIT\nholder: A\npolicy:\n deny: [GPL-2.0-typo]\n")
_, err := Resolve(repo, Flags{}, Options{Interactive: false})
if err == nil {
t.Fatal("invalid policy.deny should error")
@@ -735,7 +772,7 @@ func TestResolveBadConfigSurfaces(t *testing.T) {
t.Run("policy deny expression rejected because enforcement is exact id", func(t *testing.T) {
isolateXDG(t)
repo := t.TempDir()
writeFile(t, filepath.Join(repo, repoConfigName), "license: MIT\nholder: A\npolicy:\n deny: [GPL-2.0-only AND Apache-2.0]\n")
writeFile(t, filepath.Join(repo, RepoConfigName), "license: MIT\nholder: A\npolicy:\n deny: [GPL-2.0-only AND Apache-2.0]\n")
_, err := Resolve(repo, Flags{}, Options{Interactive: false})
if err == nil {
t.Fatal("policy.deny expression should error")
@@ -748,7 +785,7 @@ func TestResolveBadConfigSurfaces(t *testing.T) {
t.Run("policy from config carried through", func(t *testing.T) {
isolateXDG(t)
repo := t.TempDir()
writeFile(t, filepath.Join(repo, repoConfigName), `
writeFile(t, filepath.Join(repo, RepoConfigName), `
license: AGPL-3.0-or-later
holder: Kingsrook, LLC
policy:
@@ -775,7 +812,7 @@ policy:
t.Run("valid policy ids including unrenderable ids are preserved", func(t *testing.T) {
isolateXDG(t)
repo := t.TempDir()
writeFile(t, filepath.Join(repo, repoConfigName), `
writeFile(t, filepath.Join(repo, RepoConfigName), `
license: MIT
holder: Acme
policy:
@@ -936,7 +973,7 @@ func TestResolveUserLayerErrors(t *testing.T) {
func TestResolveRepoLayerLoadError(t *testing.T) {
isolateXDG(t)
repo := t.TempDir()
writeFile(t, filepath.Join(repo, repoConfigName), "license: [unterminated\n")
writeFile(t, filepath.Join(repo, RepoConfigName), "license: [unterminated\n")
if _, err := Resolve(repo, Flags{}, Options{Interactive: false}); err == nil {
t.Fatal("malformed repo config should error from Resolve")
}
@@ -946,7 +983,7 @@ func TestResolveRepoLayerLoadError(t *testing.T) {
// file that reads fine but fails YAML decoding.
func TestLoadSchemaParseError(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, repoConfigName)
p := filepath.Join(dir, RepoConfigName)
writeFile(t, p, "license: [unterminated\n")
_, err := loadSchema(p)
if err == nil {
@@ -1030,7 +1067,7 @@ func TestResolveSkipsUserLayerWithoutHome(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("HOME", "")
repo := t.TempDir()
writeFile(t, filepath.Join(repo, repoConfigName), "license: MIT\nholder: Acme\n")
writeFile(t, filepath.Join(repo, RepoConfigName), "license: MIT\nholder: Acme\n")
cfg, err := Resolve(repo, Flags{}, Options{Interactive: false})
if err != nil {
t.Fatalf("Resolve error: %v", err)
@@ -1100,6 +1137,7 @@ func TestRenderFile(t *testing.T) {
cfg.Year = model.YearSpec{Kind: model.YearRange, Start: 2021, End: 2026}
cfg.Style = model.StyleNotice
cfg.ManageLicenseFile = false
cfg.Includes = []string{"src/**"}
cfg.Excludes = []string{"**/generated/**"}
cfg.Policy = model.Policy{
Required: "AGPL-3.0-or-later",
@@ -1115,7 +1153,7 @@ func TestRenderFile(t *testing.T) {
// Decode the rendered bytes back via the real loader and assert the fields.
dir := t.TempDir()
p := filepath.Join(dir, repoConfigName)
p := filepath.Join(dir, RepoConfigName)
writeFile(t, p, string(data))
got, err := LoadFile(p)
if err != nil {
@@ -1133,6 +1171,9 @@ func TestRenderFile(t *testing.T) {
if got.ManageLicenseFile {
t.Errorf("manage_license_file should round-trip as false")
}
if len(got.Includes) != 1 || got.Includes[0] != "src/**" {
t.Errorf("includes = %v, want [src/**]", got.Includes)
}
if len(got.Excludes) != 1 || got.Excludes[0] != "**/generated/**" {
t.Errorf("excludes = %v, want [**/generated/**]", got.Excludes)
}
@@ -1184,8 +1225,8 @@ func TestWriteFile(t *testing.T) {
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))
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)
@@ -1247,7 +1288,7 @@ func TestWriteFile(t *testing.T) {
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) {
if _, statErr := os.Stat(filepath.Join(dir, RepoConfigName)); !os.IsNotExist(statErr) {
t.Errorf("no file should be written when render fails")
}
})
+53 -46
View File
@@ -89,11 +89,12 @@ var curatedFingerprints = []struct {
// reports presence, the byte span, the matched SPDX id / holder / year if any, and
// whether the match came via the sentinel.
//
// The span (StartByte inclusive, EndByte exclusive) covers exactly the leading
// comment block(s) recognized as a license header, including any blank lines between
// adjacent comment blocks but excluding the preserve-first prefixes and the file
// content that follows. When no header is found, Present is false and the offsets
// are zero.
// The span (StartByte inclusive, EndByte exclusive) covers exactly the contiguous
// leading comment recognized as a license header, plus its trailing blank-line
// separator, but excluding the preserve-first prefixes and the file content that
// follows. A blank line inside the leading region ends it, so a foreign doc comment
// below a blank separator is never absorbed. When no header is found, Present is false
// and the offsets are zero.
func Detect(content []byte, ft model.FileType) (model.DetectedHeader, error) {
// Uncommentable types (JSON and friends) can never carry a header; report absence
// rather than risk matching incidental text.
@@ -200,8 +201,8 @@ func FingerprintLicense(headerText string) (string, bool) {
// leadingCommentRegion finds the contiguous comment block(s) at offset start and
// returns their byte span plus the extracted comment text (delimiters stripped,
// joined by newlines). For block-comment types it spans the first block comment; for
// line-comment types it spans the run of consecutive comment lines (blank lines
// between comment lines are included so a header split by a blank line is one region).
// line-comment types it spans the run of CONTIGUOUS comment lines (a truly-blank line
// ends the run -- see lineCommentRegion for the issue #30 safety rationale).
//
// The bool is false when there is no comment at start, in which case no header is
// possible. Leading blank lines before the comment are tolerated and included in the
@@ -283,10 +284,22 @@ func blockCommentRegion(content []byte, start int, cs model.CommentStyle) (int,
return openOffset, end, stripBlockInner(inner), true
}
// lineCommentRegion spans the run of consecutive line comments (each beginning with
// the LinePrefix, ignoring leading whitespace) starting at start, tolerating blank
// lines between comment lines, and returns the joined comment text with prefixes
// stripped. The run ends at the first non-blank, non-comment line.
// lineCommentRegion spans the run of CONTIGUOUS line comments (each beginning with
// the LinePrefix, ignoring leading whitespace) starting at start, and returns the
// joined comment text with prefixes stripped. The run ends at the first line that is
// neither a comment line nor empty -- and, critically, a truly-blank line ALSO ends
// the run.
//
// WHY a blank line ends the run (issue #30, safety-critical): a blank line between a
// license header and a following file doc comment is a hard boundary. Earlier this
// function tolerated blank lines between comment lines, so "header / blank / doc
// comment" collapsed into one detected span; apply then deleted the doc comment on
// relicense/year-refresh. Our own rendered headers never contain a truly-blank
// internal line -- the renderer emits separator lines as a bare prefix (e.g. "#" or
// "//"), which is still a comment line and stays in-region -- so confining the region
// to contiguous comment lines is byte-idempotent for headers we write while refusing
// to absorb a foreign comment that a blank line has separated from the header. When in
// doubt we under-detect (leave the foreign comment alone) rather than over-detect.
func lineCommentRegion(content []byte, start int, cs model.CommentStyle) (int, int, string, bool) {
prefix := strings.TrimRight(cs.LinePrefix, " \t")
if prefix == "" {
@@ -296,7 +309,6 @@ func lineCommentRegion(content []byte, start int, cs model.CommentStyle) (int, i
pos := start
end := start
var lines []string
pendingBlank := 0
sawComment := false
for pos < len(content) {
@@ -305,26 +317,16 @@ func lineCommentRegion(content []byte, start int, cs model.CommentStyle) (int, i
body := strings.TrimRight(raw, "\r\n")
trimmed := strings.TrimLeft(body, " \t")
switch {
case strings.HasPrefix(trimmed, prefix):
// A comment line: flush any tolerated blank lines into the region, then record.
for i := 0; i < pendingBlank; i++ {
lines = append(lines, "")
}
pendingBlank = 0
text := strings.TrimPrefix(trimmed, prefix)
text = strings.TrimPrefix(text, " ")
lines = append(lines, text)
end = lineEnd
sawComment = true
case strings.TrimSpace(body) == "":
// Blank line: tolerate it only between comment lines, do not extend the span yet.
pendingBlank++
default:
// Non-comment content ends the leading comment run.
pos = len(content)
continue
if !strings.HasPrefix(trimmed, prefix) {
// Anything that is not a comment line -- including a truly-blank line --
// ends the contiguous leading comment run.
break
}
text := strings.TrimPrefix(trimmed, prefix)
text = strings.TrimPrefix(text, " ")
lines = append(lines, text)
end = lineEnd
sawComment = true
pos = lineEnd
}
@@ -336,24 +338,29 @@ func lineCommentRegion(content []byte, start int, cs model.CommentStyle) (int, i
// spdxIdentifierTag extracts the SPDX id from an "SPDX-License-Identifier:" line, if
// present. This is the universal REUSE tag and a definitive license signal.
//
// SAFETY (issue #30): the tag must sit at the START of a comment line (leading
// whitespace allowed), not buried mid-prose. commentText is the leading comment with
// per-line prefixes already stripped and lines joined by "\n", so requiring the tag to
// begin a line means it is an actual REUSE tag rather than a sentence that merely
// mentions the tag (e.g. a doc comment explaining the convention). Matching the tag
// anywhere previously let such prose qualify a foreign comment as a managed header.
func spdxIdentifierTag(commentText string) (string, bool) {
const tag = "spdx-license-identifier:"
lower := strings.ToLower(commentText)
idx := strings.Index(lower, tag)
if idx < 0 {
return "", false
for _, line := range strings.Split(commentText, "\n") {
trimmed := strings.TrimLeft(line, " \t")
if !strings.HasPrefix(strings.ToLower(trimmed), tag) {
continue
}
id := strings.TrimSpace(trimmed[len(tag):])
if id == "" {
// The tag is on its own line but carries no value; not a usable signal.
continue
}
// A multi-license expression is reported as-is; the first token is the primary id.
return id, true
}
rest := commentText[idx+len(tag):]
if nl := strings.IndexAny(rest, "\r\n"); nl >= 0 {
rest = rest[:nl]
}
id := strings.TrimSpace(rest)
if id == "" {
return "", false
}
// A multi-license expression is reported as-is; the first token is the primary id.
// (id is already non-empty here, so it always has at least one field.)
return id, true
return "", false
}
// matchStandardHeader checks the comment text against every curated SPDX
+75 -10
View File
@@ -198,6 +198,18 @@ For more information, please refer to <http://unlicense.org/>`
ft: lineFT(),
wantPresent: false,
},
{
// SAFETY (issue #30): a comment that merely MENTIONS the SPDX tag mid-prose
// (not on its own comment line) is documentation about the tag, not a managed
// header. It must NOT be treated as a license header, or apply would rewrite a
// real doc comment.
name: "SPDX tag mentioned mid-prose is not a managed header",
content: "// To mark a file's license, add an SPDX-License-Identifier: MIT line\n" +
"// near the top of the file. This helper documents that convention.\n" +
"\npackage widget\n",
ft: lineFT(),
wantPresent: false,
},
}
for _, tc := range tests {
@@ -233,6 +245,30 @@ func TestDetectSpanCoversCommentRegion(t *testing.T) {
assert.Equal(t, "package main\n", string(content[got.EndByte:]))
}
// TestDetectExcludesDocCommentBelowBlankLine is the issue #30 regression guard at the
// Detect level: a managed header, a truly-blank line, then an unrelated file doc
// comment must yield a span that covers ONLY the header (through the blank separator),
// never the doc comment below. Absorbing the doc comment would make apply delete it on
// relicense/year-refresh.
func TestDetectExcludesDocCommentBelowBlankLine(t *testing.T) {
docComment := "// Package widget renders widgets for the dashboard.\n"
content := []byte("// " + Sentinel + "\n" +
"// SPDX-License-Identifier: MIT\n" +
"\n" +
docComment +
"// It exposes a single Render entry point.\n" +
"\npackage widget\n")
got, err := Detect(content, lineFT())
require.NoError(t, err)
require.True(t, got.Present)
assert.Equal(t, 0, got.StartByte)
// The span ends at the blank separator; the doc comment is wholly outside it.
rest := string(content[got.EndByte:])
assert.Equal(t, docComment+"// It exposes a single Render entry point.\n\npackage widget\n", rest)
assert.NotContains(t, string(content[got.StartByte:got.EndByte]), "renders widgets")
}
// TestDetectPreserveBoundaryError covers Detect's preserve-boundary error guard via
// the preserveBoundaryFn seam. The real PreserveBoundary never errors, so the guard
// is otherwise unreachable; injecting an error confirms Detect propagates it and
@@ -541,7 +577,29 @@ func TestLeadingCommentRegionLine(t *testing.T) {
cs := model.CommentStyle{Block: false, LinePrefix: "// "}
ft := model.FileType{CommentStyle: cs}
t.Run("run of comment lines with tolerated blank between", func(t *testing.T) {
// SAFETY (issue #30): a truly-blank line ENDS the leading comment run. WHY: a
// blank line between a license header and a following file doc comment is a hard
// boundary; absorbing the doc comment into the header span made apply delete it on
// relicense/year-refresh. Our own rendered headers never contain a truly-blank
// internal line (separator lines are emitted as a bare prefix, e.g. "#"/"//"), so
// stopping at the blank under-detects nothing we wrote and protects foreign
// comments below the header.
t.Run("truly blank line ends the region (doc comment below is excluded)", func(t *testing.T) {
content := []byte("// header part A\n\n// unrelated doc comment\ncode\n")
s, e, text, ok := leadingCommentRegion(content, 0, ft)
require.True(t, ok)
assert.Equal(t, 0, s)
// The blank separator is folded into the header span (it round-trips with our
// own rendered trailing blank), and the doc comment below it is wholly outside.
assert.Equal(t, "// unrelated doc comment\ncode\n", string(content[e:]))
assert.Contains(t, text, "part A")
assert.NotContains(t, text, "unrelated doc comment")
})
// A bare-prefix line ("//" with no body) is still a comment line, not a blank, so
// it does NOT end the run. This is exactly the shape our renderer emits for the
// separator between REUSE tags and the notice block, so it must stay in-region.
t.Run("bare prefix separator line stays in the region", func(t *testing.T) {
content := []byte("// line one\n//\n// line three\nbody\n")
s, e, text, ok := leadingCommentRegion(content, 0, ft)
require.True(t, ok)
@@ -551,15 +609,6 @@ func TestLeadingCommentRegionLine(t *testing.T) {
assert.Contains(t, text, "line three")
})
t.Run("blank line then comment is still one region", func(t *testing.T) {
content := []byte("// header part A\n\n// header part B\ncode\n")
_, e, text, ok := leadingCommentRegion(content, 0, ft)
require.True(t, ok)
assert.Equal(t, "code\n", string(content[e:]))
assert.Contains(t, text, "part A")
assert.Contains(t, text, "part B")
})
t.Run("non-comment line ends the run", func(t *testing.T) {
content := []byte("// header\ncode();\n// trailing comment\n")
_, e, _, ok := leadingCommentRegion(content, 0, ft)
@@ -620,6 +669,22 @@ func TestSpdxIdentifierTag(t *testing.T) {
wantID: "(MIT OR Apache-2.0)",
wantOK: true,
},
{
// SAFETY (issue #30): the tag must sit at the START of a comment line. A
// mention buried mid-prose is documentation about the tag, not a managed
// header, and must not qualify.
name: "tag mentioned mid-prose does not qualify",
text: "add an SPDX-License-Identifier: MIT line near the top",
wantOK: false,
},
{
// Leading whitespace before the tag on its own line is allowed (comment
// bodies may be indented); it still counts as own-line.
name: "tag on its own line with leading whitespace qualifies",
text: "Copyright 2026 Acme\n SPDX-License-Identifier: MIT\n",
wantID: "MIT",
wantOK: true,
},
{
name: "no tag present",
text: "just a normal comment",
+9 -31
View File
@@ -109,24 +109,22 @@ func enumerate(root string, opts Options, classify ContentClassifier, contentAwa
return nil, err
}
// Config/flag excludes apply uniformly on both paths. On the git path they layer
// on top of git's own .gitignore handling; on the walk path they layer on top of
// the .gitignore matcher already applied during the walk.
var excluder *ignore.GitIgnore
if len(opts.Excludes) > 0 {
excluder = ignore.CompileIgnoreLines(opts.Excludes...)
}
// Includes and excludes share one gitignore-style matcher so their glob semantics
// (** and slash-less "any depth") cannot diverge. Excludes apply uniformly on both
// discovery paths, layering on top of git's / the walk's own .gitignore handling.
includer := CompileMatcher(opts.Includes)
excluder := CompileMatcher(opts.Excludes)
entries := make([]Entry, 0, len(relPaths))
for _, rel := range relPaths {
// Normalize to forward slashes so glob/gitignore matching is OS-independent;
// MatchesPath itself also normalizes, but Includes use filepath.Match.
// Normalize to forward slashes so matching is OS-independent.
rel = filepath.ToSlash(rel)
if !matchesIncludes(rel, opts.Includes) {
// An empty include set matches everything (the common case).
if !includer.Empty() && !includer.Match(rel) {
continue
}
if excluder != nil && excluder.MatchesPath(rel) {
if excluder.Match(rel) {
continue
}
@@ -261,26 +259,6 @@ func readHead(abs string) ([]byte, error) {
return buf[:n], nil
}
// matchesIncludes reports whether rel satisfies the include globs. An empty include
// set matches everything (the common case); otherwise a path must match at least one
// glob, tested against both the full relative path and its base name so a bare
// "*.go" works regardless of directory depth.
func matchesIncludes(rel string, includes []string) bool {
if len(includes) == 0 {
return true
}
base := filepath.Base(rel)
for _, pat := range includes {
if ok, _ := filepath.Match(pat, rel); ok {
return true
}
if ok, _ := filepath.Match(pat, base); ok {
return true
}
}
return false
}
// isGitRepo reports whether path is inside a git working tree. WHY shell out rather
// than look for a .git directory: git rev-parse correctly resolves worktrees, nested
// repos, and the GIT_DIR/GIT_WORK_TREE environment, which a bare directory probe
+21 -24
View File
@@ -46,30 +46,6 @@ func TestIsBinary(t *testing.T) {
}
}
// TestMatchesIncludes verifies include-glob semantics: empty includes match all, and
// a non-empty set matches against both the full relative path and the base name.
func TestMatchesIncludes(t *testing.T) {
cases := []struct {
name string
rel string
includes []string
want bool
}{
{"empty includes match all", "src/Main.java", nil, true},
{"base-name glob matches at depth", "a/b/c/x.go", []string{"*.go"}, true},
{"full-path glob matches", "src/x.go", []string{"src/*.go"}, true},
{"full-path glob does not span dirs", "src/sub/x.go", []string{"src/*.go"}, false},
{"no glob matches", "x.ts", []string{"*.go", "*.py"}, false},
{"one of several matches", "x.py", []string{"*.go", "*.py"}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, matchesIncludes(tc.rel, tc.includes))
})
}
}
// indexEntries maps an enumeration result to relative-path -> Entry for assertions.
func indexEntries(entries []Entry) map[string]Entry {
m := make(map[string]Entry, len(entries))
@@ -238,6 +214,27 @@ func TestEnumerateGitRepoIncludes(t *testing.T) {
assert.NotContains(t, byPath, "b.py")
}
// TestEnumerateGitRepoIncludesDoublestar verifies that ** include globs span
// directories (issue #32): a src/** include must reach nested files, not just the
// top level. The previous filepath.Match include path failed this silently.
func TestEnumerateGitRepoIncludesDoublestar(t *testing.T) {
root := t.TempDir()
gitInit(t, root)
writeFile(t, root, "src/a.go", "package x\n")
writeFile(t, root, "src/deep/nested/b.go", "package y\n")
writeFile(t, root, "other/c.go", "package z\n")
gitAddCommit(t, root)
entries, err := Enumerate(root, Options{Includes: []string{"src/**"}}, filetype.Lookup)
require.NoError(t, err)
byPath := indexEntries(entries)
assert.Contains(t, byPath, "src/a.go")
assert.Contains(t, byPath, "src/deep/nested/b.go")
assert.NotContains(t, byPath, "other/c.go")
}
// TestEnumerateNonGitWalk covers the fallback walk: no git repo, .gitignore honored,
// the .git-style pruning is irrelevant, directories descended, classification applied.
func TestEnumerateNonGitWalk(t *testing.T) {
+40
View File
@@ -0,0 +1,40 @@
package enumerate
import (
ignore "github.com/sabhiram/go-gitignore"
)
// Matcher matches repo-relative, forward-slash paths against a set of
// gitignore-style glob patterns. WHY gitignore semantics rather than filepath.Match:
// gitignore supports ** (cross-directory) and treats a slash-less pattern as
// matching at any depth, which is what include/exclude globs are documented to do.
// filepath.Match has neither behavior, so "src/**" and "**/generated/**" silently
// matched nothing on the include path (issue #32). Includes and excludes now share
// this one matcher so their semantics cannot drift.
type Matcher struct {
ig *ignore.GitIgnore
}
// CompileMatcher builds a Matcher from glob patterns. An empty set yields an Empty
// matcher; callers decide whether empty means "match all" (includes) or "match
// nothing" (excludes).
func CompileMatcher(patterns []string) Matcher {
if len(patterns) == 0 {
return Matcher{}
}
return Matcher{ig: ignore.CompileIgnoreLines(patterns...)}
}
// Empty reports whether the matcher was built from no patterns.
func (m Matcher) Empty() bool {
return m.ig == nil
}
// Match reports whether rel (forward-slash, relative to the scan root) matches any
// pattern. An Empty matcher matches nothing.
func (m Matcher) Match(rel string) bool {
if m.ig == nil {
return false
}
return m.ig.MatchesPath(rel)
}
+45
View File
@@ -0,0 +1,45 @@
package enumerate
import (
"testing"
"github.com/stretchr/testify/assert"
)
// TestCompileMatcher verifies the shared gitignore-style glob matcher used by both
// includes and excludes. The load-bearing cases are the ** ones: the previous
// filepath.Match include path silently failed them (issue #32).
func TestCompileMatcher(t *testing.T) {
cases := []struct {
name string
patterns []string
rel string
want bool
}{
{"basename glob matches at depth", []string{"*.go"}, "a/b/c/x.go", true},
{"single-segment glob does not span dirs", []string{"src/*.go"}, "src/sub/x.go", false},
{"single-segment glob matches direct child", []string{"src/*.go"}, "src/x.go", true},
{"doublestar matches direct child", []string{"src/**"}, "src/x.go", true},
{"doublestar matches nested", []string{"src/**"}, "src/sub/deep/x.go", true},
{"leading doublestar matches any depth", []string{"**/generated/**"}, "a/b/generated/c.go", true},
{"leading doublestar non-match", []string{"**/generated/**"}, "a/b/src/c.go", false},
{"no pattern matches", []string{"*.go", "*.py"}, "x.ts", false},
{"one of several matches", []string{"*.go", "*.py"}, "x.py", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := CompileMatcher(tc.patterns)
assert.False(t, m.Empty())
assert.Equal(t, tc.want, m.Match(tc.rel))
})
}
}
// TestCompileMatcherEmpty verifies an empty pattern set is Empty and matches nothing
// (callers translate "empty includes" into "match all" themselves).
func TestCompileMatcherEmpty(t *testing.T) {
m := CompileMatcher(nil)
assert.True(t, m.Empty())
assert.False(t, m.Match("anything.go"))
}
+6
View File
@@ -74,6 +74,12 @@ var builtin = []model.FileType{
CommentStyle: block("/*", "*/"),
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
},
{
Name: "C#",
Extensions: []string{".cs"},
CommentStyle: block("/*", "*/"),
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
},
{
Name: "C/C++",
Extensions: []string{".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx", ".m", ".mm"},
+1
View File
@@ -21,6 +21,7 @@ func TestLookupTable(t *testing.T) {
{"build.gradle.kts", "Kotlin", true, true, false},
{"main.go", "Go", true, true, false},
{"App.swift", "Swift", true, true, false},
{"Program.cs", "C#", true, true, false},
{"lib.c", "C/C++", true, true, false},
{"lib.cpp", "C/C++", true, true, false},
{"lib.h", "C/C++", true, true, false},
+345
View File
@@ -0,0 +1,345 @@
// Package initwizard contains pure business logic for the interactive init flow.
package initwizard
import (
"fmt"
"path/filepath"
"strings"
"github.com/KofTwentyTwo/license-tool/internal/config"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/KofTwentyTwo/license-tool/internal/render"
"github.com/KofTwentyTwo/license-tool/internal/spdx"
)
// LanguageFamily is the stable label for a source preview sample.
type LanguageFamily string
// Language family labels supported by the source preview catalog.
const (
LanguageTypeScriptJavaScript LanguageFamily = "TypeScript/JavaScript"
LanguagePython LanguageFamily = "Python"
LanguageJava LanguageFamily = "Java"
LanguageGo LanguageFamily = "Go"
LanguageC LanguageFamily = "C"
LanguageCPlusPlus LanguageFamily = "C++"
LanguageCSharp LanguageFamily = "C#"
LanguagePHP LanguageFamily = "PHP"
LanguageRuby LanguageFamily = "Ruby"
LanguageSwift LanguageFamily = "Swift"
LanguageKotlin LanguageFamily = "Kotlin"
LanguageRust LanguageFamily = "Rust"
LanguageShell LanguageFamily = "Shell"
LanguagePowerShell LanguageFamily = "PowerShell"
LanguageR LanguageFamily = "R"
)
// Sample is one deterministic source file the init wizard can show in a preview.
type Sample struct {
Language LanguageFamily
Path string
Source string
}
// SourcePreviewInput is the complete pure input for rendering a sample source
// preview. ResolvedYear is passed in so callers own clocks and git history.
type SourcePreviewInput struct {
Config model.Config
Sample Sample
ResolvedYear string
}
// SourcePreview is a rendered example source file with the generated header applied.
type SourcePreview struct {
Language LanguageFamily
Path string
Content string
}
var catalog = []Sample{
{
Language: LanguageTypeScriptJavaScript,
Path: "example.ts",
Source: strings.TrimLeft(`
export function greet(name: string): string {
return "Hello, " + name;
}
`, "\n"),
},
{
Language: LanguagePython,
Path: "example.py",
Source: strings.TrimLeft(`
def greet(name: str) -> str:
return f"Hello, {name}"
`, "\n"),
},
{
Language: LanguageJava,
Path: "Example.java",
Source: strings.TrimLeft(`
package preview;
public class Example {
public String greet(String name) {
return "Hello, " + name;
}
}
`, "\n"),
},
{
Language: LanguageGo,
Path: "example.go",
Source: strings.TrimLeft(`
package main
func greet(name string) string {
return "Hello, " + name
}
`, "\n"),
},
{
Language: LanguageC,
Path: "example.c",
Source: strings.TrimLeft(`
#include <stdio.h>
void greet(const char *name) {
printf("Hello, %s\n", name);
}
`, "\n"),
},
{
Language: LanguageCPlusPlus,
Path: "example.cpp",
Source: strings.TrimLeft(`
#include <iostream>
void greet(const std::string& name) {
std::cout << "Hello, " << name << std::endl;
}
`, "\n"),
},
{
Language: LanguageCSharp,
Path: "Example.cs",
Source: strings.TrimLeft(`
public class Example
{
public string Greet(string name)
{
return "Hello, " + name;
}
}
`, "\n"),
},
{
Language: LanguagePHP,
Path: "example.php",
Source: strings.TrimLeft(`
<?php
function greet(string $name): string
{
return "Hello, " . $name;
}
`, "\n"),
},
{
Language: LanguageRuby,
Path: "example.rb",
Source: strings.TrimLeft(`
def greet(name)
"Hello, #{name}"
end
`, "\n"),
},
{
Language: LanguageSwift,
Path: "Example.swift",
Source: strings.TrimLeft(`
func greet(_ name: String) -> String {
"Hello, \(name)"
}
`, "\n"),
},
{
Language: LanguageKotlin,
Path: "Example.kt",
Source: strings.TrimLeft(`
package preview
fun greet(name: String): String {
return "Hello, $name"
}
`, "\n"),
},
{
Language: LanguageRust,
Path: "example.rs",
Source: strings.TrimLeft(`
fn greet(name: &str) -> String {
format!("Hello, {}", name)
}
`, "\n"),
},
{
Language: LanguageShell,
Path: "example.sh",
Source: strings.TrimLeft(`
#!/usr/bin/env bash
greet() {
printf 'Hello, %s\n' "$1"
}
`, "\n"),
},
{
Language: LanguagePowerShell,
Path: "example.ps1",
Source: strings.TrimLeft(`
function Get-Greeting {
param([string]$Name)
"Hello, $Name"
}
`, "\n"),
},
{
Language: LanguageR,
Path: "example.R",
Source: strings.TrimLeft(`
greet <- function(name) {
paste("Hello", name)
}
`, "\n"),
},
}
var extensionFamilies = map[string]LanguageFamily{
".ts": LanguageTypeScriptJavaScript,
".tsx": LanguageTypeScriptJavaScript,
".mts": LanguageTypeScriptJavaScript,
".cts": LanguageTypeScriptJavaScript,
".js": LanguageTypeScriptJavaScript,
".jsx": LanguageTypeScriptJavaScript,
".mjs": LanguageTypeScriptJavaScript,
".cjs": LanguageTypeScriptJavaScript,
".py": LanguagePython,
".pyi": LanguagePython,
".java": LanguageJava,
".go": LanguageGo,
".c": LanguageC,
".h": LanguageC,
".cc": LanguageCPlusPlus,
".cpp": LanguageCPlusPlus,
".cxx": LanguageCPlusPlus,
".hh": LanguageCPlusPlus,
".hpp": LanguageCPlusPlus,
".hxx": LanguageCPlusPlus,
".cs": LanguageCSharp,
".php": LanguagePHP,
".phtml": LanguagePHP,
".rb": LanguageRuby,
".swift": LanguageSwift,
".kt": LanguageKotlin,
".kts": LanguageKotlin,
".rs": LanguageRust,
".sh": LanguageShell,
".bash": LanguageShell,
".zsh": LanguageShell,
".ksh": LanguageShell,
".ps1": LanguagePowerShell,
".psm1": LanguagePowerShell,
".r": LanguageR,
}
// Catalog returns the full, stable source preview catalog.
func Catalog() []Sample {
return append([]Sample(nil), catalog...)
}
// SelectSample returns the catalog sample that best matches repo file paths. If no
// catalog language is detected, it returns the C fallback.
func SelectSample(paths []string) Sample {
detected := make(map[LanguageFamily]bool)
for _, path := range paths {
if sample, ok := SampleForPath(path); ok {
detected[sample.Language] = true
}
}
for _, sample := range catalog {
if detected[sample.Language] {
return sample
}
}
return fallbackSample()
}
// SampleForPath maps one repository path to its preview sample when the extension
// belongs to the supported catalog.
func SampleForPath(path string) (Sample, bool) {
ext := strings.ToLower(filepath.Ext(filepath.Base(path)))
if ext == "" {
return Sample{}, false
}
family, ok := extensionFamilies[ext]
if !ok {
return Sample{}, false
}
return sampleForFamily(family)
}
// BuildSourcePreview renders a generated header and inserts it into the selected
// sample source using the same SPDX, filetype, and render behavior as apply.
func BuildSourcePreview(in SourcePreviewInput) (SourcePreview, error) {
sample := in.Sample
if sample.Language == "" {
sample = fallbackSample()
}
if in.ResolvedYear == "" {
return SourcePreview{}, fmt.Errorf("initwizard: resolved year is required")
}
license, ok := spdx.Lookup(in.Config.License)
if !ok {
return SourcePreview{}, fmt.Errorf("initwizard: unknown license %q", in.Config.License)
}
ft, ok := config.LookupFunc(in.Config)(sample.Path)
if !ok {
return SourcePreview{}, fmt.Errorf("initwizard: no file type for sample %q", sample.Path)
}
header, err := render.Header(render.HeaderInput{
License: license,
Holder: in.Config.Holder,
Year: in.ResolvedYear,
Style: in.Config.Style,
FileType: ft,
})
if err != nil {
return SourcePreview{}, err
}
content, _ := render.Insert([]byte(sample.Source), ft, header)
return SourcePreview{
Language: sample.Language,
Path: sample.Path,
Content: string(content),
}, nil
}
// BuildYAMLPreview renders the .license-tool.yaml preview for cfg.
func BuildYAMLPreview(cfg model.Config) ([]byte, error) {
return config.RenderFile(cfg)
}
func fallbackSample() Sample {
sample, _ := sampleForFamily(LanguageC)
return sample
}
func sampleForFamily(family LanguageFamily) (Sample, bool) {
for _, sample := range catalog {
if sample.Language == family {
return sample, true
}
}
return Sample{}, false
}
+262
View File
@@ -0,0 +1,262 @@
package initwizard
import (
"testing"
"github.com/KofTwentyTwo/license-tool/internal/config"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCatalogContainsExactlySupportedLanguageFamiliesInStableOrder(t *testing.T) {
got := Catalog()
require.Len(t, got, 15)
want := []LanguageFamily{
LanguageTypeScriptJavaScript,
LanguagePython,
LanguageJava,
LanguageGo,
LanguageC,
LanguageCPlusPlus,
LanguageCSharp,
LanguagePHP,
LanguageRuby,
LanguageSwift,
LanguageKotlin,
LanguageRust,
LanguageShell,
LanguagePowerShell,
LanguageR,
}
for i, family := range want {
assert.Equal(t, family, got[i].Language)
assert.NotEmpty(t, got[i].Path)
assert.NotEmpty(t, got[i].Source)
}
}
func TestCatalogReturnsACopy(t *testing.T) {
first := Catalog()
require.NotEmpty(t, first)
first[0].Language = LanguagePython
second := Catalog()
assert.Equal(t, LanguageTypeScriptJavaScript, second[0].Language)
}
func TestSelectSamplePrefersDetectedCatalogFamiliesInCatalogOrder(t *testing.T) {
got := SelectSample([]string{
"cmd/license-tool/main.go",
"web/src/app.ts",
})
assert.Equal(t, LanguageTypeScriptJavaScript, got.Language)
assert.Equal(t, "example.ts", got.Path)
}
func TestSelectSampleFallsBackToCWhenNoCatalogLanguageIsDetected(t *testing.T) {
got := SelectSample([]string{
"README.md",
"package.json",
"styles/app.css",
})
assert.Equal(t, LanguageC, got.Language)
assert.Equal(t, "example.c", got.Path)
}
func TestSelectSampleSplitsCAndCPlusPlusExtensions(t *testing.T) {
cases := []struct {
name string
path string
want LanguageFamily
}{
{name: "c source", path: "src/main.c", want: LanguageC},
{name: "c header", path: "include/project.h", want: LanguageC},
{name: "cpp source", path: "src/main.cpp", want: LanguageCPlusPlus},
{name: "cpp header", path: "include/project.hpp", want: LanguageCPlusPlus},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := SelectSample([]string{tc.path})
assert.Equal(t, tc.want, got.Language)
})
}
}
func TestSelectSampleDetectsCSharp(t *testing.T) {
got := SelectSample([]string{"src/Program.cs"})
assert.Equal(t, LanguageCSharp, got.Language)
assert.Equal(t, "Example.cs", got.Path)
}
func TestBuildSourcePreviewAppliesGeneratedHeaderToSelectedSample(t *testing.T) {
sample := SelectSample([]string{"cmd/license-tool/main.go"})
preview, err := BuildSourcePreview(SourcePreviewInput{
Config: model.Config{
License: "MIT",
Holder: "Acme, Inc.",
Style: model.StyleReuse,
},
Sample: sample,
ResolvedYear: "2026",
})
require.NoError(t, err)
assert.Equal(t, LanguageGo, preview.Language)
assert.Equal(t, "example.go", preview.Path)
assert.Contains(t, preview.Content, "SPDX-FileCopyrightText: 2026 Acme, Inc.")
assert.Contains(t, preview.Content, "SPDX-License-Identifier: MIT")
assert.Contains(t, preview.Content, "package main")
assert.Less(t,
indexOf(t, preview.Content, "SPDX-License-Identifier: MIT"),
indexOf(t, preview.Content, "package main"),
)
}
func TestBuildSourcePreviewUsesCSharpFileType(t *testing.T) {
sample := SelectSample([]string{"src/Program.cs"})
preview, err := BuildSourcePreview(SourcePreviewInput{
Config: model.Config{
License: "MIT",
Holder: "Acme, Inc.",
Style: model.StyleReuse,
},
Sample: sample,
ResolvedYear: "2026",
})
require.NoError(t, err)
assert.Equal(t, LanguageCSharp, preview.Language)
assert.Contains(t, preview.Content, "/*")
assert.Contains(t, preview.Content, "*/")
assert.Contains(t, preview.Content, "public class Example")
}
func TestBuildSourcePreviewEdgeCases(t *testing.T) {
t.Run("path with no extension has no sample", func(t *testing.T) {
sample, ok := SampleForPath("LICENSE")
assert.False(t, ok)
assert.Equal(t, Sample{}, sample)
})
t.Run("unknown family has no sample", func(t *testing.T) {
sample, ok := sampleForFamily(LanguageFamily("Unknown"))
assert.False(t, ok)
assert.Equal(t, Sample{}, sample)
})
t.Run("empty sample falls back to C", func(t *testing.T) {
preview, err := BuildSourcePreview(SourcePreviewInput{
Config: model.Config{
License: "MIT",
Holder: "Acme, Inc.",
Style: model.StyleReuse,
},
ResolvedYear: "2026",
})
require.NoError(t, err)
assert.Equal(t, LanguageC, preview.Language)
assert.Equal(t, "example.c", preview.Path)
})
t.Run("resolved year is required", func(t *testing.T) {
_, err := BuildSourcePreview(SourcePreviewInput{
Config: model.Config{
License: "MIT",
Holder: "Acme, Inc.",
Style: model.StyleReuse,
},
Sample: SelectSample([]string{"main.go"}),
})
require.Error(t, err)
assert.Contains(t, err.Error(), "resolved year is required")
})
t.Run("unknown license is rejected", func(t *testing.T) {
_, err := BuildSourcePreview(SourcePreviewInput{
Config: model.Config{
License: "Nope",
Holder: "Acme, Inc.",
Style: model.StyleReuse,
},
Sample: SelectSample([]string{"main.go"}),
ResolvedYear: "2026",
})
require.Error(t, err)
assert.Contains(t, err.Error(), `unknown license "Nope"`)
})
t.Run("sample path must have a file type", func(t *testing.T) {
_, err := BuildSourcePreview(SourcePreviewInput{
Config: model.Config{
License: "MIT",
Holder: "Acme, Inc.",
Style: model.StyleReuse,
},
Sample: Sample{
Language: LanguageFamily("Unknown"),
Path: "example.unknown",
Source: "content\n",
},
ResolvedYear: "2026",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "no file type")
})
t.Run("uncommentable override rejects preview rendering", func(t *testing.T) {
_, err := BuildSourcePreview(SourcePreviewInput{
Config: model.Config{
License: "MIT",
Holder: "Acme, Inc.",
Style: model.StyleReuse,
FileTypeOverrides: map[string]model.FileType{
".c": {
Name: "custom-c",
Skip: true,
},
},
},
Sample: SelectSample([]string{"main.c"}),
ResolvedYear: "2026",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "uncommentable")
})
}
func TestBuildYAMLPreviewDelegatesToConfigRenderFile(t *testing.T) {
cfg := model.Config{
License: "MIT",
Holder: "Acme, Inc.",
Year: model.YearSpec{Kind: model.YearExplicit, Start: 2026},
Style: model.StyleReuse,
ManageLicenseFile: true,
Excludes: []string{"vendor/**"},
}
got, err := BuildYAMLPreview(cfg)
require.NoError(t, err)
want, err := config.RenderFile(cfg)
require.NoError(t, err)
assert.Equal(t, string(want), string(got))
}
func indexOf(t *testing.T, s, substr string) int {
t.Helper()
idx := -1
for i := range s {
if len(s[i:]) >= len(substr) && s[i:i+len(substr)] == substr {
idx = i
break
}
}
require.NotEqualf(t, -1, idx, "%q not found in %q", substr, s)
return idx
}
+443
View File
@@ -0,0 +1,443 @@
package initwizard
import (
"strings"
"github.com/KofTwentyTwo/license-tool/internal/config"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/KofTwentyTwo/license-tool/internal/spdx"
)
// Field identifies one editable row of the single-screen init form, in display
// order. FieldWrite is the trailing "write" affordance, not a value.
type Field int
const (
// FieldLicense is the target SPDX license row.
FieldLicense Field = iota
// FieldHolder is the copyright holder row.
FieldHolder
// FieldYear is the year-policy row.
FieldYear
// FieldStyle is the header-style row.
FieldStyle
// FieldManageFiles is the manage-license-files toggle row.
FieldManageFiles
// FieldInclude is the include-globs list row.
FieldInclude
// FieldExclude is the exclude-globs list row.
FieldExclude
// FieldWrite is the trailing write-config action row.
FieldWrite
)
// fieldCount is the number of focusable rows (values + the write action).
const fieldCount = int(FieldWrite) + 1
// Label is the human label for a field row.
func (f Field) Label() string {
switch f {
case FieldLicense:
return "License"
case FieldHolder:
return "Holder"
case FieldYear:
return "Year"
case FieldStyle:
return "Header style"
case FieldManageFiles:
return "Manage license files"
case FieldInclude:
return "Include globs"
case FieldExclude:
return "Exclude globs"
default:
return "Write .license-tool.yaml"
}
}
// Action is the outcome the adapter must act on after a transition: write the config
// or quit. Most transitions return ActionNone.
type Action int
const (
// ActionNone means the transition only changed in-form state.
ActionNone Action = iota
// ActionWrite means the user confirmed a valid write.
ActionWrite
// ActionQuit means the user asked to leave without writing.
ActionQuit
)
// FieldError is a single failed validation, addressed to the field that owns it.
type FieldError struct {
Field Field
Msg string
}
// styleOptions are the selectable header styles, in display order.
var styleOptions = []string{"reuse", "notice", "reuse+notice"}
// FormState is the pure, PTY-free state of the init form: the answers being edited,
// which fields were repo-detected, the focused row, and any open editor's transient
// cursor/buffer. The Bubble Tea adapter maps keystrokes to the transition methods
// and renders via Render; all behavior is unit-tested here so the adapter stays a
// trivial, coverage-excluded shell.
type FormState struct {
answers Answers
detected Detected
focus Field
editing bool
licenseOptions []string
licenseFilter string
licenseCursor int
styleCursor int
textBuffer string
globBuffer string
globCursor int
}
// NewForm builds a form from seeded answers, applying the documented defaults for any
// field the caller left unset (license = first option, year = git, style = reuse+notice).
func NewForm(a Answers, detected Detected) FormState {
s := FormState{
answers: a,
detected: detected,
focus: FieldLicense,
licenseOptions: licenseSelectOptions(),
}
if s.answers.License.SPDXID == "" && len(s.licenseOptions) > 0 {
s.answers.License.SPDXID = s.licenseOptions[0]
}
if s.answers.Identity.Year == "" {
s.answers.Identity.Year = "git"
}
if s.answers.HeaderStyle.Style == "" {
s.answers.HeaderStyle.Style = "reuse+notice"
}
return s
}
// licenseSelectOptions lists renderable SPDX ids with the common ones first, then the
// remaining renderable ids in sorted order, without duplicates.
func licenseSelectOptions() []string {
return selectOptions(spdx.CommonIDs(), spdx.RenderableIDs())
}
// selectOptions merges the editorial common list (kept first, in order) with the full
// renderable list (appended, deduped). Split out from licenseSelectOptions so the
// dedup/append behavior is unit-testable independent of the vendored snapshot, where
// the common set currently happens to cover every renderable id.
func selectOptions(common, renderable []string) []string {
seen := make(map[string]bool, len(common))
opts := make([]string, 0, len(common)+len(renderable))
for _, id := range common {
seen[id] = true
opts = append(opts, id)
}
for _, id := range renderable {
if !seen[id] {
opts = append(opts, id)
}
}
return opts
}
// Editing reports whether the focused field's inline editor is open.
func (s FormState) Editing() bool { return s.editing }
// Focused returns the focused field.
func (s FormState) Focused() Field { return s.focus }
// Answers returns the current answers (a snapshot for the adapter to translate).
func (s FormState) Answers() Answers { return s.answers }
// WriteConfig translates the current answers strictly, for the write path.
func (s FormState) WriteConfig() (model.Config, error) {
return Translate(s.answers, TranslateOptions{})
}
// MoveFocus moves the focused row by delta, wrapping. No-op while an editor is open.
func (s *FormState) MoveFocus(delta int) {
if s.editing {
return
}
s.focus = Field(((int(s.focus)+delta)%fieldCount + fieldCount) % fieldCount)
}
// Activate handles Enter (or space) on the focused row: toggles the manage flag,
// requests a write from the write row (only when valid), or opens an inline editor.
func (s *FormState) Activate() Action {
switch s.focus {
case FieldManageFiles:
s.answers.LicenseFiles.Manage = !s.answers.LicenseFiles.Manage
return ActionNone
case FieldWrite:
if s.CanWrite() {
return ActionWrite
}
return ActionNone
default:
s.openEditor()
return ActionNone
}
}
func (s *FormState) openEditor() {
s.editing = true
switch s.focus {
case FieldLicense:
s.licenseFilter = ""
s.licenseCursor = s.licenseIndexOf(s.answers.License.SPDXID)
case FieldHolder:
s.textBuffer = s.answers.Identity.Holder
case FieldYear:
s.textBuffer = s.answers.Identity.Year
case FieldStyle:
s.styleCursor = optionIndex(styleOptions, s.answers.HeaderStyle.Style)
case FieldInclude, FieldExclude:
s.globBuffer = ""
s.globCursor = 0
}
}
// Cancel handles Esc: close an open editor (discarding its buffer), or quit when no
// editor is open.
func (s *FormState) Cancel() Action {
if s.editing {
s.editing = false
return ActionNone
}
return ActionQuit
}
// Input feeds a typed rune into the active editor's buffer (license filter, text
// field, or glob entry). No-op outside an editor or for non-text editors.
func (s *FormState) Input(r rune) {
if !s.editing {
return
}
switch s.focus {
case FieldLicense:
s.licenseFilter += string(r)
s.clampLicenseCursor()
case FieldHolder, FieldYear:
s.textBuffer += string(r)
case FieldInclude, FieldExclude:
s.globBuffer += string(r)
}
}
// Backspace deletes the last rune of the active editor's buffer.
func (s *FormState) Backspace() {
if !s.editing {
return
}
switch s.focus {
case FieldLicense:
s.licenseFilter = dropLastRune(s.licenseFilter)
s.clampLicenseCursor()
case FieldHolder, FieldYear:
s.textBuffer = dropLastRune(s.textBuffer)
case FieldInclude, FieldExclude:
s.globBuffer = dropLastRune(s.globBuffer)
}
}
// MoveCursor moves the selection within an open list/choice editor (license choices,
// style options, or existing glob entries).
func (s *FormState) MoveCursor(delta int) {
if !s.editing {
return
}
switch s.focus {
case FieldLicense:
if n := len(s.filteredLicenses()); n > 0 {
s.licenseCursor = clamp(s.licenseCursor+delta, 0, n-1)
}
case FieldStyle:
s.styleCursor = clamp(s.styleCursor+delta, 0, len(styleOptions)-1)
case FieldInclude, FieldExclude:
if n := len(s.globEntries()); n > 0 {
s.globCursor = clamp(s.globCursor+delta, 0, n-1)
}
}
}
// Commit handles Enter inside an editor: choose the selected license/style, store the
// text field, or append the buffered glob (the glob editor stays open for more adds).
func (s *FormState) Commit() {
if !s.editing {
return
}
switch s.focus {
case FieldLicense:
if choices := s.filteredLicenses(); len(choices) > 0 {
s.answers.License.SPDXID = choices[clamp(s.licenseCursor, 0, len(choices)-1)]
}
s.editing = false
case FieldStyle:
s.answers.HeaderStyle.Style = styleOptions[clamp(s.styleCursor, 0, len(styleOptions)-1)]
s.editing = false
case FieldHolder:
s.answers.Identity.Holder = strings.TrimSpace(s.textBuffer)
s.editing = false
case FieldYear:
s.answers.Identity.Year = strings.TrimSpace(s.textBuffer)
s.editing = false
case FieldInclude:
if g := strings.TrimSpace(s.globBuffer); g != "" {
s.answers.Coverage.Include = append(s.answers.Coverage.Include, g)
s.globBuffer = ""
}
case FieldExclude:
if g := strings.TrimSpace(s.globBuffer); g != "" {
s.answers.Coverage.Exclude = append(s.answers.Coverage.Exclude, g)
s.globBuffer = ""
}
}
}
// Remove deletes the selected entry from the focused glob list editor.
func (s *FormState) Remove() {
if !s.editing {
return
}
switch s.focus {
case FieldInclude:
s.answers.Coverage.Include = removeAt(s.answers.Coverage.Include, s.globCursor)
s.clampGlobCursor()
case FieldExclude:
s.answers.Coverage.Exclude = removeAt(s.answers.Coverage.Exclude, s.globCursor)
s.clampGlobCursor()
}
}
// Validate returns every failing field-level rule. An empty result means the answers
// can be written.
func (s FormState) Validate() []FieldError {
var errs []FieldError
if _, ok := spdx.Lookup(strings.TrimSpace(s.answers.License.SPDXID)); !ok {
errs = append(errs, FieldError{FieldLicense, "choose a supported license"})
}
if strings.TrimSpace(s.answers.Identity.Holder) == "" {
errs = append(errs, FieldError{FieldHolder, "holder is required"})
}
if y := strings.TrimSpace(s.answers.Identity.Year); y != "" {
if _, err := config.ParseYearSpec(y); err != nil {
errs = append(errs, FieldError{FieldYear, "use current, git, YYYY, or YYYY-YYYY"})
}
}
for _, g := range s.answers.Coverage.Include {
if !validGlob(g) {
errs = append(errs, FieldError{FieldInclude, "glob entries cannot be blank"})
break
}
}
for _, g := range s.answers.Coverage.Exclude {
if !validGlob(g) {
errs = append(errs, FieldError{FieldExclude, "glob entries cannot be blank"})
break
}
}
return errs
}
// CanWrite reports whether the answers pass every validation rule.
func (s FormState) CanWrite() bool {
return len(s.Validate()) == 0
}
// BlockingReason returns the first validation message, or "" when writable. The
// footer shows this to explain why the write action is disabled.
func (s FormState) BlockingReason() string {
if errs := s.Validate(); len(errs) > 0 {
return errs[0].Msg
}
return ""
}
func (s FormState) filteredLicenses() []string {
f := strings.ToLower(strings.TrimSpace(s.licenseFilter))
if f == "" {
return s.licenseOptions
}
out := make([]string, 0, len(s.licenseOptions))
for _, id := range s.licenseOptions {
if strings.Contains(strings.ToLower(id), f) {
out = append(out, id)
}
}
return out
}
func (s FormState) licenseIndexOf(id string) int {
return optionIndex(s.filteredLicenses(), id)
}
func (s *FormState) clampLicenseCursor() {
n := len(s.filteredLicenses())
if n == 0 {
s.licenseCursor = 0
return
}
s.licenseCursor = clamp(s.licenseCursor, 0, n-1)
}
func (s FormState) globEntries() []string {
if s.focus == FieldExclude {
return s.answers.Coverage.Exclude
}
return s.answers.Coverage.Include
}
func (s *FormState) clampGlobCursor() {
n := len(s.globEntries())
if n == 0 {
s.globCursor = 0
return
}
s.globCursor = clamp(s.globCursor, 0, n-1)
}
func validGlob(g string) bool {
return strings.TrimSpace(g) != ""
}
func optionIndex(values []string, value string) int {
for i, v := range values {
if v == value {
return i
}
}
return 0
}
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func dropLastRune(s string) string {
if s == "" {
return s
}
r := []rune(s)
return string(r[:len(r)-1])
}
func removeAt(values []string, i int) []string {
if i < 0 || i >= len(values) {
return values
}
return append(values[:i], values[i+1:]...)
}
+307
View File
@@ -0,0 +1,307 @@
package initwizard
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validForm() FormState {
return NewForm(Answers{
License: LicenseAnswer{SPDXID: "MIT"},
Identity: IdentityAnswer{Holder: "Acme, LLC", Year: "git"},
}, Detected{})
}
func TestFieldLabel(t *testing.T) {
for f, want := range map[Field]string{
FieldLicense: "License",
FieldHolder: "Holder",
FieldYear: "Year",
FieldStyle: "Header style",
FieldManageFiles: "Manage license files",
FieldInclude: "Include globs",
FieldExclude: "Exclude globs",
FieldWrite: "Write .license-tool.yaml",
} {
assert.Equal(t, want, f.Label())
}
}
func TestNewFormDefaults(t *testing.T) {
s := NewForm(Answers{}, Detected{})
require.NotEmpty(t, s.licenseOptions)
assert.Equal(t, s.licenseOptions[0], s.answers.License.SPDXID)
assert.Equal(t, "git", s.answers.Identity.Year)
assert.Equal(t, "reuse+notice", s.answers.HeaderStyle.Style)
assert.Equal(t, FieldLicense, s.Focused())
assert.False(t, s.Editing())
}
func TestNewFormPreservesPresets(t *testing.T) {
s := NewForm(Answers{
License: LicenseAnswer{SPDXID: "Apache-2.0"},
Identity: IdentityAnswer{Year: "2024"},
HeaderStyle: HeaderStyleAnswer{Style: "reuse"},
}, Detected{License: true})
assert.Equal(t, "Apache-2.0", s.answers.License.SPDXID)
assert.Equal(t, "2024", s.answers.Identity.Year)
assert.Equal(t, "reuse", s.answers.HeaderStyle.Style)
}
func TestMoveFocusWraps(t *testing.T) {
s := validForm()
s.MoveFocus(-1)
assert.Equal(t, FieldWrite, s.Focused())
s.MoveFocus(1)
assert.Equal(t, FieldLicense, s.Focused())
s.editing = true
s.MoveFocus(1) // no-op while editing
assert.Equal(t, FieldLicense, s.Focused())
}
func TestActivateManageToggles(t *testing.T) {
s := validForm()
s.focus = FieldManageFiles
assert.False(t, s.answers.LicenseFiles.Manage)
assert.Equal(t, ActionNone, s.Activate())
assert.True(t, s.answers.LicenseFiles.Manage)
assert.False(t, s.Editing())
}
func TestActivateWrite(t *testing.T) {
t.Run("valid form requests write", func(t *testing.T) {
s := validForm()
s.focus = FieldWrite
assert.Equal(t, ActionWrite, s.Activate())
})
t.Run("invalid form does not write", func(t *testing.T) {
s := NewForm(Answers{License: LicenseAnswer{SPDXID: "MIT"}}, Detected{}) // empty holder
s.focus = FieldWrite
assert.Equal(t, ActionNone, s.Activate())
})
}
func TestActivateOpensEditors(t *testing.T) {
for _, f := range []Field{FieldLicense, FieldHolder, FieldYear, FieldStyle, FieldInclude, FieldExclude} {
s := validForm()
s.focus = f
assert.Equal(t, ActionNone, s.Activate())
assert.True(t, s.Editing(), "field %v should open an editor", f)
}
}
func TestCancel(t *testing.T) {
s := validForm()
s.focus = FieldHolder
s.Activate()
require.True(t, s.Editing())
assert.Equal(t, ActionNone, s.Cancel())
assert.False(t, s.Editing())
assert.Equal(t, ActionQuit, s.Cancel())
}
func TestLicenseFilterAndCommit(t *testing.T) {
s := validForm()
s.focus = FieldLicense
s.Activate()
for _, r := range "apache" {
s.Input(r)
}
choices := s.filteredLicenses()
require.NotEmpty(t, choices)
assert.Contains(t, choices[0], "Apache")
s.MoveCursor(1) // exercise the license-cursor move with a non-empty result set
s.Commit()
assert.False(t, s.Editing())
assert.Equal(t, "Apache-2.0", s.answers.License.SPDXID)
}
func TestLicenseBackspaceAndEmptyFilter(t *testing.T) {
s := validForm()
s.focus = FieldLicense
s.Activate()
for _, r := range "zzz" {
s.Input(r)
}
assert.Empty(t, s.filteredLicenses())
s.MoveCursor(1) // n==0 path
s.Commit() // choices==0 path: license unchanged, editor closes
assert.Equal(t, "MIT", s.answers.License.SPDXID)
assert.False(t, s.Editing())
s.Activate()
s.Input('m')
s.Backspace()
assert.Equal(t, s.licenseOptions, s.filteredLicenses())
}
func TestStyleEditor(t *testing.T) {
s := validForm()
s.focus = FieldStyle
s.Activate()
s.MoveCursor(-100) // clamps to first
s.Commit()
assert.Equal(t, "reuse", s.answers.HeaderStyle.Style)
s.Activate()
s.MoveCursor(100) // clamps to last
s.Commit()
assert.Equal(t, "reuse+notice", s.answers.HeaderStyle.Style)
}
func TestTextEditors(t *testing.T) {
s := validForm()
s.focus = FieldHolder
s.Activate()
s.Backspace() // trim a char from "Acme, LLC"
for _, r := range " Inc" {
s.Input(r)
}
s.Commit()
assert.Equal(t, "Acme, LL Inc", s.answers.Identity.Holder)
assert.False(t, s.Editing())
s.focus = FieldYear
s.Activate()
s.textBuffer = "2020-2025"
s.Commit()
assert.Equal(t, "2020-2025", s.answers.Identity.Year)
}
func TestGlobEditorAddRemove(t *testing.T) {
s := validForm()
s.focus = FieldInclude
s.Activate()
for _, r := range "src/**" {
s.Input(r)
}
s.Commit() // adds, stays open
assert.True(t, s.Editing())
assert.Equal(t, []string{"src/**"}, s.answers.Coverage.Include)
s.globBuffer = " " // blank add is a no-op
s.Commit()
assert.Equal(t, []string{"src/**"}, s.answers.Coverage.Include)
for _, r := range "docs/**" {
s.Input(r)
}
s.Commit()
assert.Equal(t, []string{"src/**", "docs/**"}, s.answers.Coverage.Include)
s.MoveCursor(1) // select second
s.Remove()
assert.Equal(t, []string{"src/**"}, s.answers.Coverage.Include)
// Exclude path mirrors include.
s.focus = FieldExclude
s.Activate()
for _, r := range "**/vendor/**" {
s.Input(r)
}
s.Commit()
assert.Equal(t, []string{"**/vendor/**"}, s.answers.Coverage.Exclude)
s.Remove()
assert.Empty(t, s.answers.Coverage.Exclude)
}
func TestTransitionsNoopWhenNotEditing(t *testing.T) {
s := validForm()
// None of these should panic or mutate when no editor is open.
s.Input('x')
s.Backspace()
s.MoveCursor(1)
s.Commit()
s.Remove()
assert.False(t, s.Editing())
assert.Equal(t, "MIT", s.answers.License.SPDXID)
}
func TestValidate(t *testing.T) {
t.Run("valid form has no errors", func(t *testing.T) {
assert.Empty(t, validForm().Validate())
assert.True(t, validForm().CanWrite())
assert.Empty(t, validForm().BlockingReason())
})
t.Run("each rule reports", func(t *testing.T) {
s := NewForm(Answers{
License: LicenseAnswer{SPDXID: "NOPE"},
Identity: IdentityAnswer{Year: "bad"},
Coverage: CoverageAnswer{Include: []string{" "}, Exclude: []string{"\t"}},
}, Detected{})
errs := s.Validate()
fields := map[Field]string{}
for _, e := range errs {
fields[e.Field] = e.Msg
}
assert.Contains(t, fields, FieldLicense)
assert.Contains(t, fields, FieldHolder)
assert.Contains(t, fields, FieldYear)
assert.Contains(t, fields, FieldInclude)
assert.Contains(t, fields, FieldExclude)
assert.False(t, s.CanWrite())
assert.NotEmpty(t, s.BlockingReason())
})
}
func TestWriteConfig(t *testing.T) {
cfg, err := validForm().WriteConfig()
require.NoError(t, err)
assert.Equal(t, "MIT", cfg.License)
_, err = NewForm(Answers{License: LicenseAnswer{SPDXID: "MIT"}}, Detected{}).WriteConfig()
assert.Error(t, err) // empty holder
}
func TestSmallHelpers(t *testing.T) {
assert.Equal(t, 0, optionIndex([]string{"a", "b"}, "missing"))
assert.Equal(t, 1, optionIndex([]string{"a", "b"}, "b"))
assert.Equal(t, 5, clamp(10, 0, 5))
assert.Equal(t, 0, clamp(-2, 0, 5))
assert.Equal(t, 3, clamp(3, 0, 5))
assert.Equal(t, "", dropLastRune(""))
assert.Equal(t, "ab", dropLastRune("abc"))
assert.False(t, validGlob(" "))
assert.True(t, validGlob("src/**"))
assert.Equal(t, []string{"a"}, removeAt([]string{"a"}, 5)) // out of range
assert.Equal(t, []string{"a"}, removeAt([]string{"a"}, -1))
}
func TestGlobCursorClampOnEmpty(t *testing.T) {
s := validForm()
s.focus = FieldInclude
s.Activate()
s.globCursor = 4
s.clampGlobCursor() // no entries -> resets to 0
assert.Equal(t, 0, s.globCursor)
}
func TestAnswersAccessor(t *testing.T) {
s := validForm()
assert.Equal(t, "MIT", s.Answers().License.SPDXID)
}
func TestGlobEditorBackspaceAndEmptyCursor(t *testing.T) {
s := validForm()
s.focus = FieldExclude
s.Activate()
for _, r := range "vend" {
s.Input(r)
}
s.Backspace() // glob-buffer backspace path
s.MoveCursor(1) // no entries yet -> n==0 path
s.Commit()
assert.Equal(t, []string{"ven"}, s.answers.Coverage.Exclude)
}
func TestSelectOptionsDedupAndAppend(t *testing.T) {
// renderable carries an id absent from common, exercising the append branch the
// vendored snapshot cannot (its common set covers every renderable id).
got := selectOptions([]string{"MIT", "Apache-2.0"}, []string{"Apache-2.0", "MIT", "ZZ-Extra"})
assert.Equal(t, []string{"MIT", "Apache-2.0", "ZZ-Extra"}, got)
}
+92
View File
@@ -0,0 +1,92 @@
package initwizard
// PanelKind identifies a renderable panel in the layout plan.
type PanelKind int
const (
// PanelForm is the editable field column.
PanelForm PanelKind = iota
// PanelSourcePreview is the rendered example source file.
PanelSourcePreview
// PanelYAMLPreview is the .license-tool.yaml preview.
PanelYAMLPreview
)
// Orientation is how the panels are arranged.
type Orientation int
const (
// LayoutHorizontal places panels side by side.
LayoutHorizontal Orientation = iota
// LayoutStacked places panels top to bottom.
LayoutStacked
)
// Panel is one panel's kind and its allotted interior dimensions.
type Panel struct {
Kind PanelKind
Width int
Height int
}
// PanelPlan is the adaptive layout decision for a given terminal body size. When
// TooSmall is set the caller should refuse the TUI and fall back to flag-only init.
type PanelPlan struct {
TooSmall bool
Orientation Orientation
Panels []Panel
}
// Layout breakpoints. WHY adaptive panel count: a teaching tool wants the source AND
// yaml previews beside the form when there is room, two panels at medium width, and a
// stacked single column when narrow; below the floor the previews cannot render
// usefully so the TUI is refused.
const (
layoutMinWidth = 60
layoutMinHeight = 20
layoutFormWidth = 46
layoutWideBreakpoint = 110
layoutMediumBreakpoint = 80
layoutGutter = 1
)
// Layout chooses the panel arrangement for the given body width and height.
func Layout(width, height int) PanelPlan {
if width < layoutMinWidth || height < layoutMinHeight {
return PanelPlan{TooSmall: true}
}
switch {
case width >= layoutWideBreakpoint:
rest := width - layoutFormWidth - 2*layoutGutter
src := rest / 2
yaml := rest - src
return PanelPlan{
Orientation: LayoutHorizontal,
Panels: []Panel{
{PanelForm, layoutFormWidth, height},
{PanelSourcePreview, src, height},
{PanelYAMLPreview, yaml, height},
},
}
case width >= layoutMediumBreakpoint:
src := width - layoutFormWidth - layoutGutter
return PanelPlan{
Orientation: LayoutHorizontal,
Panels: []Panel{
{PanelForm, layoutFormWidth, height},
{PanelSourcePreview, src, height},
},
}
default:
formHeight := height / 2
srcHeight := height - formHeight - layoutGutter
return PanelPlan{
Orientation: LayoutStacked,
Panels: []Panel{
{PanelForm, width, formHeight},
{PanelSourcePreview, width, srcHeight},
},
}
}
}
+64
View File
@@ -0,0 +1,64 @@
package initwizard
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func panelKinds(plan PanelPlan) []PanelKind {
kinds := make([]PanelKind, 0, len(plan.Panels))
for _, p := range plan.Panels {
kinds = append(kinds, p.Kind)
}
return kinds
}
func TestLayoutTooSmall(t *testing.T) {
for _, dim := range [][2]int{{50, 30}, {120, 10}, {59, 19}} {
plan := Layout(dim[0], dim[1])
assert.True(t, plan.TooSmall, "%dx%d should be too small", dim[0], dim[1])
assert.Empty(t, plan.Panels)
}
}
func TestLayoutWideHasThreePanels(t *testing.T) {
plan := Layout(120, 40)
require.False(t, plan.TooSmall)
assert.Equal(t, LayoutHorizontal, plan.Orientation)
assert.Equal(t, []PanelKind{PanelForm, PanelSourcePreview, PanelYAMLPreview}, panelKinds(plan))
total := 2 * layoutGutter
for _, p := range plan.Panels {
total += p.Width
assert.Equal(t, 40, p.Height)
}
assert.Equal(t, 120, total)
assert.Equal(t, layoutFormWidth, plan.Panels[0].Width)
}
func TestLayoutMediumHasTwoPanels(t *testing.T) {
plan := Layout(90, 30)
require.False(t, plan.TooSmall)
assert.Equal(t, LayoutHorizontal, plan.Orientation)
assert.Equal(t, []PanelKind{PanelForm, PanelSourcePreview}, panelKinds(plan))
assert.Equal(t, 90, plan.Panels[0].Width+plan.Panels[1].Width+layoutGutter)
}
func TestLayoutNarrowStacks(t *testing.T) {
plan := Layout(70, 24)
require.False(t, plan.TooSmall)
assert.Equal(t, LayoutStacked, plan.Orientation)
assert.Equal(t, []PanelKind{PanelForm, PanelSourcePreview}, panelKinds(plan))
for _, p := range plan.Panels {
assert.Equal(t, 70, p.Width)
}
assert.Equal(t, 24, plan.Panels[0].Height+plan.Panels[1].Height+layoutGutter)
}
func TestLayoutBreakpointBoundaries(t *testing.T) {
assert.Len(t, Layout(layoutWideBreakpoint, 40).Panels, 3)
assert.Len(t, Layout(layoutMediumBreakpoint, 40).Panels, 2)
assert.Equal(t, LayoutStacked, Layout(layoutMinWidth, layoutMinHeight).Orientation)
}
+60
View File
@@ -0,0 +1,60 @@
// Package initwizard defines the structured answer model shared by the init
// command and the interactive collector.
package initwizard
import "errors"
// ErrAborted is returned when the operator exits the interactive wizard without
// confirming the review step.
var ErrAborted = errors.New("init wizard aborted")
// Answers is the structured payload the init form edits and returns. WHY no project
// model or review step: the redesign detects defaults from the repo (see Seed) rather
// than asking a project-model question, and the single-screen form is itself the
// review, so the former Project/Review/Step machinery was removed.
type Answers struct {
// License records the target license choice.
License LicenseAnswer
// Identity records copyright holder and year policy.
Identity IdentityAnswer
// HeaderStyle records the source header format.
HeaderStyle HeaderStyleAnswer
// LicenseFiles records whether top-level license files should be managed.
LicenseFiles LicenseFilesAnswer
// Coverage records include and exclude file scope answers.
Coverage CoverageAnswer
}
// LicenseAnswer holds the target license selection.
type LicenseAnswer struct {
// SPDXID is the SPDX identifier selected for generated config.
SPDXID string
}
// IdentityAnswer holds copyright identity answers.
type IdentityAnswer struct {
// Holder is the copyright holder text.
Holder string
// Year is the raw year policy token.
Year string
}
// HeaderStyleAnswer holds the raw header style token.
type HeaderStyleAnswer struct {
// Style is reuse, notice, or reuse+notice.
Style string
}
// LicenseFilesAnswer records license file management intent.
type LicenseFilesAnswer struct {
// Manage enables top-level LICENSE and LICENSES/<id>.txt management.
Manage bool
}
// CoverageAnswer records source file scope selections.
type CoverageAnswer struct {
// Include is the selected include glob list.
Include []string
// Exclude is the selected exclude glob list.
Exclude []string
}
+148
View File
@@ -0,0 +1,148 @@
package initwizard
import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"github.com/KofTwentyTwo/license-tool/internal/config"
"github.com/KofTwentyTwo/license-tool/internal/detect"
"github.com/KofTwentyTwo/license-tool/internal/enumerate"
"github.com/KofTwentyTwo/license-tool/internal/spdx"
)
// Detected marks which seeded Answers fields came from repo detection, so the form
// can badge them and the user sees what was inferred versus defaulted.
type Detected struct {
License bool
Holder bool
Manage bool
}
// SeedDeps holds injectable seams for Seed. WHY only the git author is a seam: the
// header scan is deterministic against a temp directory (the project's test style),
// but the configured git author depends on machine/CI git config, so it is injected.
type SeedDeps struct {
// GitAuthor returns the configured author name for root, or "" when unknown.
GitAuthor func(root string) string
}
// seedReadFile is a seam over os.ReadFile so the per-file read-error path is testable.
var seedReadFile = os.ReadFile
// Seed pre-fills base from repository detection and reports which fields were
// inferred. It never overrides a non-empty base field (e.g. one set from a flag).
// Detection reuses the same enumerate + detect + spdx machinery as audit, so it
// agrees with the rest of the tool, and it writes nothing: these are editable
// defaults the user reviews against the live preview.
func Seed(root string, base Answers, deps SeedDeps) (Answers, Detected) {
if deps.GitAuthor == nil {
deps.GitAuthor = gitAuthor
}
var detected Detected
licenseID, holder := scanHeaders(root)
if base.License.SPDXID == "" && licenseID != "" {
// Only seed a license the tool can actually render; an existing header in an
// id we cannot emit is left for the user to choose explicitly.
if _, ok := spdx.Lookup(licenseID); ok {
base.License.SPDXID = licenseID
detected.License = true
}
}
if base.Identity.Holder == "" {
switch {
case holder != "":
base.Identity.Holder = holder
detected.Holder = true
default:
if name := deps.GitAuthor(root); name != "" {
base.Identity.Holder = name
detected.Holder = true
}
}
}
if licenseFileExists(root) {
detected.Manage = true
}
return base, detected
}
// scanHeaders enumerates source files and returns the most common detected license
// id and holder among recognized headers; empty strings when none are found.
func scanHeaders(root string) (licenseID, holder string) {
classify := config.ContentLookupFunc(config.Defaults())
entries, err := enumerate.WithContent(root, enumerate.Options{}, classify)
if err != nil {
return "", ""
}
licenseCounts := map[string]int{}
holderCounts := map[string]int{}
for _, e := range entries {
if e.Skip {
continue
}
content, rerr := seedReadFile(e.AbsPath)
if rerr != nil {
continue
}
dh, derr := detect.Detect(content, e.FileType)
if derr != nil || !dh.Present {
continue
}
if dh.SPDXID != "" {
licenseCounts[dh.SPDXID]++
}
if h := strings.TrimSpace(dh.Holder); h != "" {
holderCounts[h]++
}
}
return mostCommon(licenseCounts), mostCommon(holderCounts)
}
// mostCommon returns the highest-count key, breaking ties by lexical order for
// determinism; empty when the map is empty.
func mostCommon(counts map[string]int) string {
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
best, bestCount := "", 0
for _, k := range keys {
if counts[k] > bestCount {
best, bestCount = k, counts[k]
}
}
return best
}
// licenseFileExists reports whether a top-level LICENSE file or LICENSES/ directory
// already exists, which the form uses to badge the manage-license-files choice.
func licenseFileExists(root string) bool {
if _, err := os.Stat(filepath.Join(root, "LICENSE")); err == nil {
return true
}
if info, err := os.Stat(filepath.Join(root, "LICENSES")); err == nil && info.IsDir() {
return true
}
return false
}
// gitAuthor returns the configured git author name for root, or "" when git is
// absent or the value is unset.
func gitAuthor(root string) string {
out, err := exec.Command("git", "-C", root, "config", "user.name").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
+190
View File
@@ -0,0 +1,190 @@
package initwizard
import (
"errors"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/KofTwentyTwo/license-tool/internal/config"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/KofTwentyTwo/license-tool/internal/render"
"github.com/KofTwentyTwo/license-tool/internal/spdx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func seedWriteFile(t *testing.T, root, rel, content string) {
t.Helper()
abs := filepath.Join(root, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
require.NoError(t, os.WriteFile(abs, []byte(content), 0o644))
}
// seedWriteHeadered writes a Go file carrying a real managed header for licID/holder,
// built through the same render path apply uses so detect is guaranteed to recognize
// it. WHY round-trip through render: it ties the test to the tool's actual header
// shape rather than a hand-rolled approximation that could drift from detect.
func seedWriteHeadered(t *testing.T, root, rel, pkg, licID, holder string) {
t.Helper()
ft, ok := config.LookupFunc(config.Defaults())(rel)
require.True(t, ok)
lic, ok := spdx.Lookup(licID)
require.True(t, ok)
hdr, err := render.Header(render.HeaderInput{
License: lic,
Holder: holder,
Year: "2024",
Style: model.StyleReusePlusNotice,
FileType: ft,
})
require.NoError(t, err)
content, _ := render.Insert([]byte(pkg), ft, hdr)
seedWriteFile(t, root, rel, string(content))
}
func noAuthor(string) string { return "" }
func gitPerson(string) string { return "Git Person" }
func TestSeedFromHeaders(t *testing.T) {
root := t.TempDir()
seedWriteHeadered(t, root, "a.go", "package a\n", "MIT", "Acme, LLC")
seedWriteHeadered(t, root, "b.go", "package b\n", "MIT", "Acme, LLC")
got, detected := Seed(root, Answers{}, SeedDeps{GitAuthor: gitPerson})
assert.Equal(t, "MIT", got.License.SPDXID)
assert.Equal(t, "Acme, LLC", got.Identity.Holder)
assert.True(t, detected.License)
assert.True(t, detected.Holder)
}
func TestSeedTieBreaksLexically(t *testing.T) {
root := t.TempDir()
seedWriteHeadered(t, root, "a.go", "package a\n", "MIT", "Bravo Co")
seedWriteHeadered(t, root, "b.go", "package b\n", "Apache-2.0", "Alpha Co")
got, detected := Seed(root, Answers{}, SeedDeps{GitAuthor: noAuthor})
// 1-1 ties resolve to the lexically-first key for determinism.
assert.Equal(t, "Apache-2.0", got.License.SPDXID)
assert.Equal(t, "Alpha Co", got.Identity.Holder)
assert.True(t, detected.License)
assert.True(t, detected.Holder)
}
func TestSeedGitAuthorFallback(t *testing.T) {
root := t.TempDir()
// A plain source file with no header exercises detect's not-present path.
seedWriteFile(t, root, "main.go", "package main\n")
got, detected := Seed(root, Answers{}, SeedDeps{GitAuthor: gitPerson})
assert.Equal(t, "Git Person", got.Identity.Holder)
assert.True(t, detected.Holder)
assert.Empty(t, got.License.SPDXID)
assert.False(t, detected.License)
}
func TestSeedNoDetection(t *testing.T) {
root := t.TempDir()
seedWriteFile(t, root, "main.go", "package main\n")
got, detected := Seed(root, Answers{}, SeedDeps{GitAuthor: noAuthor})
assert.Empty(t, got.License.SPDXID)
assert.Empty(t, got.Identity.Holder)
assert.Equal(t, Detected{}, detected)
}
func TestSeedDoesNotOverridePreset(t *testing.T) {
root := t.TempDir()
seedWriteHeadered(t, root, "a.go", "package a\n", "MIT", "Acme, LLC")
base := Answers{
License: LicenseAnswer{SPDXID: "GPL-3.0-or-later"},
Identity: IdentityAnswer{Holder: "Preset Holder"},
}
got, detected := Seed(root, base, SeedDeps{GitAuthor: gitPerson})
assert.Equal(t, "GPL-3.0-or-later", got.License.SPDXID)
assert.Equal(t, "Preset Holder", got.Identity.Holder)
assert.False(t, detected.License)
assert.False(t, detected.Holder)
}
func TestSeedUnrenderableLicenseNotSeeded(t *testing.T) {
root := t.TempDir()
// A real SPDX id the tool does not ship rendering for; detect reads the tag, but
// Seed must not adopt a license it cannot render.
seedWriteFile(t, root, "x.go", "// SPDX-License-Identifier: Glide\n\npackage x\n")
got, detected := Seed(root, Answers{}, SeedDeps{GitAuthor: noAuthor})
assert.Empty(t, got.License.SPDXID)
assert.False(t, detected.License)
}
func TestSeedDetectsLicenseFile(t *testing.T) {
t.Run("LICENSE file present", func(t *testing.T) {
root := t.TempDir()
seedWriteFile(t, root, "LICENSE", "MIT License\n")
_, detected := Seed(root, Answers{}, SeedDeps{GitAuthor: noAuthor})
assert.True(t, detected.Manage)
})
t.Run("LICENSES directory present", func(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "LICENSES"), 0o755))
_, detected := Seed(root, Answers{}, SeedDeps{GitAuthor: noAuthor})
assert.True(t, detected.Manage)
})
}
func TestSeedReadErrorSkipsFile(t *testing.T) {
root := t.TempDir()
seedWriteHeadered(t, root, "a.go", "package a\n", "MIT", "Acme, LLC")
orig := seedReadFile
seedReadFile = func(string) ([]byte, error) { return nil, errors.New("boom") }
defer func() { seedReadFile = orig }()
got, detected := Seed(root, Answers{}, SeedDeps{GitAuthor: noAuthor})
assert.Empty(t, got.License.SPDXID)
assert.False(t, detected.License)
}
func TestSeedEnumerateErrorIsSafe(t *testing.T) {
got, detected := Seed(filepath.Join(t.TempDir(), "does-not-exist"), Answers{}, SeedDeps{GitAuthor: noAuthor})
assert.Empty(t, got.License.SPDXID)
assert.Empty(t, got.Identity.Holder)
assert.Equal(t, Detected{}, detected)
}
// TestGitAuthorErrorIsEmpty covers gitAuthor's failure path: git cannot chdir into a
// nonexistent directory, so it exits non-zero and the helper yields "".
func TestGitAuthorErrorIsEmpty(t *testing.T) {
assert.Empty(t, gitAuthor(filepath.Join(t.TempDir(), "missing", "deeper")))
}
// TestSeedDefaultGitAuthor exercises the default seam (deps.GitAuthor nil) against a
// real repo whose author is configured, covering the nil-default branch and the real
// gitAuthor helper.
func TestSeedDefaultGitAuthor(t *testing.T) {
root := t.TempDir()
for _, args := range [][]string{
{"init"},
{"config", "user.name", "Repo Author"},
{"config", "user.email", "repo@example.com"},
} {
cmd := exec.Command("git", append([]string{"-C", root}, args...)...)
require.NoError(t, cmd.Run())
}
seedWriteFile(t, root, "main.go", "package main\n")
got, detected := Seed(root, Answers{}, SeedDeps{})
assert.Equal(t, "Repo Author", got.Identity.Holder)
assert.True(t, detected.Holder)
}
+76
View File
@@ -0,0 +1,76 @@
package initwizard
import (
"fmt"
"strings"
"github.com/KofTwentyTwo/license-tool/internal/config"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/KofTwentyTwo/license-tool/internal/spdx"
)
// placeholderHolder stands in for an empty holder while previewing, so the live
// source/YAML preview is never blank before the user has typed a holder.
const placeholderHolder = "Example, Inc."
// TranslateOptions tunes Translate. AllowPlaceholders selects preview mode: an empty
// holder is filled with a placeholder and parse failures fall back to defaults
// instead of erroring, so the live preview always renders.
type TranslateOptions struct {
AllowPlaceholders bool
}
// Translate is the single conversion from wizard Answers to a model.Config, used by
// BOTH the live preview and the write path. WHY one function: a second translator
// (the old wizard previewConfig vs commands answersToConfig) could render a preview
// that disagreed with what got written. Strict mode (AllowPlaceholders false) is the
// write gate: it rejects an unrenderable license, an empty holder, and unparseable
// year/style. Preview mode tolerates all of those so the panel keeps rendering.
func Translate(a Answers, opts TranslateOptions) (model.Config, error) {
licenseID := strings.TrimSpace(a.License.SPDXID)
holder := strings.TrimSpace(a.Identity.Holder)
year := strings.TrimSpace(a.Identity.Year)
style := strings.TrimSpace(a.HeaderStyle.Style)
if !opts.AllowPlaceholders {
if !spdx.Validate(licenseID) {
return model.Config{}, fmt.Errorf("init: %q is not a recognized SPDX license identifier", licenseID)
}
if _, ok := spdx.Lookup(licenseID); !ok {
return model.Config{}, fmt.Errorf("init: %q is a recognized SPDX license identifier, but license-tool cannot render it", licenseID)
}
if holder == "" {
return model.Config{}, fmt.Errorf("init: copyright holder is required")
}
} else if holder == "" {
holder = placeholderHolder
}
cfg := config.Defaults()
cfg.License = licenseID
cfg.Holder = holder
if year != "" {
ys, err := config.ParseYearSpec(year)
switch {
case err == nil:
cfg.Year = ys
case !opts.AllowPlaceholders:
return model.Config{}, err
}
}
if style != "" {
st, err := config.ParseStyle(style)
switch {
case err == nil:
cfg.Style = st
case !opts.AllowPlaceholders:
return model.Config{}, err
}
}
cfg.ManageLicenseFile = a.LicenseFiles.Manage
cfg.Includes = append([]string(nil), a.Coverage.Include...)
cfg.Excludes = append([]string(nil), a.Coverage.Exclude...)
return cfg, nil
}
+126
View File
@@ -0,0 +1,126 @@
package initwizard
import (
"testing"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestTranslateStrict drives the write-path gate: a valid case plus each rejection.
// WHY exhaustive: this is the single gate both the wizard and the flag-only init path
// funnel through, so every validation/parse arm must reject identically.
func TestTranslateStrict(t *testing.T) {
t.Run("valid answers build a config from defaults", func(t *testing.T) {
cfg, err := Translate(Answers{
License: LicenseAnswer{SPDXID: "MIT"},
Identity: IdentityAnswer{Holder: "Acme, LLC", Year: "2021-2026"},
HeaderStyle: HeaderStyleAnswer{Style: "reuse"},
LicenseFiles: LicenseFilesAnswer{Manage: false},
Coverage: CoverageAnswer{Include: []string{"src/**"}, Exclude: []string{"**/vendor/**"}},
}, TranslateOptions{})
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{"src/**"}, cfg.Includes)
assert.Equal(t, []string{"**/vendor/**"}, cfg.Excludes)
})
t.Run("empty year and style keep the built-in defaults", func(t *testing.T) {
cfg, err := Translate(Answers{
License: LicenseAnswer{SPDXID: "MIT"},
Identity: IdentityAnswer{Holder: "Acme"},
LicenseFiles: LicenseFilesAnswer{Manage: true},
}, TranslateOptions{})
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 := Translate(Answers{
License: LicenseAnswer{SPDXID: "NOT-A-LICENSE"},
Identity: IdentityAnswer{Holder: "Acme"},
}, TranslateOptions{})
require.Error(t, err)
assert.Contains(t, err.Error(), "not a recognized SPDX license identifier")
})
t.Run("valid but unrenderable license rejected", func(t *testing.T) {
// 0BSD is a real SPDX id; if the tool does not ship rendering for it, strict
// mode must reject it with the cannot-render message rather than the
// not-recognized one. Pick an id that validates but is not curated.
_, err := Translate(Answers{
License: LicenseAnswer{SPDXID: "Glide"},
Identity: IdentityAnswer{Holder: "Acme"},
}, TranslateOptions{})
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot render")
})
t.Run("empty holder rejected", func(t *testing.T) {
_, err := Translate(Answers{License: LicenseAnswer{SPDXID: "MIT"}}, TranslateOptions{})
require.Error(t, err)
assert.Contains(t, err.Error(), "copyright holder is required")
})
t.Run("bad year rejected", func(t *testing.T) {
_, err := Translate(Answers{
License: LicenseAnswer{SPDXID: "MIT"},
Identity: IdentityAnswer{Holder: "Acme", Year: "not-a-year"},
}, TranslateOptions{})
require.Error(t, err)
assert.Contains(t, err.Error(), "year")
})
t.Run("bad style rejected", func(t *testing.T) {
_, err := Translate(Answers{
License: LicenseAnswer{SPDXID: "MIT"},
Identity: IdentityAnswer{Holder: "Acme"},
HeaderStyle: HeaderStyleAnswer{Style: "fancy"},
}, TranslateOptions{})
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown style")
})
}
// TestTranslatePlaceholders verifies preview mode never errors and substitutes a
// placeholder holder, so the live preview keeps rendering mid-edit.
func TestTranslatePlaceholders(t *testing.T) {
opts := TranslateOptions{AllowPlaceholders: true}
t.Run("empty holder becomes placeholder", func(t *testing.T) {
cfg, err := Translate(Answers{License: LicenseAnswer{SPDXID: "MIT"}}, opts)
require.NoError(t, err)
assert.Equal(t, placeholderHolder, cfg.Holder)
})
t.Run("unparseable year and style fall back to defaults without error", func(t *testing.T) {
cfg, err := Translate(Answers{
License: LicenseAnswer{SPDXID: "MIT"},
Identity: IdentityAnswer{Holder: "Acme", Year: "not-a-year"},
HeaderStyle: HeaderStyleAnswer{Style: "fancy"},
}, opts)
require.NoError(t, err)
assert.Equal(t, model.YearGit, cfg.Year.Kind)
assert.Equal(t, model.StyleReusePlusNotice, cfg.Style)
})
t.Run("valid placeholder values pass through", func(t *testing.T) {
cfg, err := Translate(Answers{
License: LicenseAnswer{SPDXID: "MIT"},
Identity: IdentityAnswer{Holder: "Acme", Year: "current"},
HeaderStyle: HeaderStyleAnswer{Style: "notice"},
}, opts)
require.NoError(t, err)
assert.Equal(t, "Acme", cfg.Holder)
assert.Equal(t, model.YearCurrent, cfg.Year.Kind)
assert.Equal(t, model.StyleNotice, cfg.Style)
})
}
+338
View File
@@ -0,0 +1,338 @@
package initwizard
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/KofTwentyTwo/license-tool/internal/model"
)
var (
paneStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240"))
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15"))
mutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
)
const footerHints = "tab move | enter edit/commit | esc cancel/back | ctrl+s write | ctrl+c quit"
// Render produces the full init view for the form, layout plan, chosen preview
// sample, and a now-year for preview year resolution. WHY a pure function: the
// Bubble Tea adapter only forwards window size and keys; everything visible is built
// here and unit-tested, so the adapter shell stays trivial.
func Render(s FormState, plan PanelPlan, sample Sample, nowYear int) string {
if plan.TooSmall {
return tooSmallView()
}
cfg, _ := Translate(s.answers, TranslateOptions{AllowPlaceholders: true})
boxes := make([]string, 0, len(plan.Panels))
for _, p := range plan.Panels {
var title, content string
switch p.Kind {
case PanelForm:
title, content = "license-tool init", renderForm(s)
case PanelSourcePreview:
title, content = "Source preview", renderSource(cfg, sample, nowYear)
case PanelYAMLPreview:
title, content = ".license-tool.yaml", renderYAML(cfg)
}
boxes = append(boxes, panelBox(p, title, content))
}
return joinPanels(plan.Orientation, boxes) + "\n" + footer(s)
}
func tooSmallView() string {
return "Terminal too small for the init wizard.\n" +
"Widen to at least 60x20, or run non-interactively:\n" +
" license-tool init --license <SPDX> --holder \"<name>\""
}
func renderForm(s FormState) string {
invalid := map[Field]bool{}
for _, e := range s.Validate() {
invalid[e.Field] = true
}
var b strings.Builder
for f := FieldLicense; f <= FieldWrite; f++ {
b.WriteString(renderRow(s, f, invalid[f]))
b.WriteByte('\n')
if s.editing && s.focus == f {
b.WriteString(indentBlock(renderEditor(s)))
b.WriteByte('\n')
}
}
return strings.TrimRight(b.String(), "\n")
}
func renderRow(s FormState, f Field, invalid bool) string {
marker := " "
if f == s.focus {
marker = "> "
}
if f == FieldWrite {
label := f.Label()
if !s.CanWrite() {
label += " (blocked)"
}
return marker + label
}
row := marker + f.Label() + ": " + rowValue(s, f)
if detectedBadge(s, f) {
row += " (detected)"
}
if invalid {
row += " !"
}
return row
}
func rowValue(s FormState, f Field) string {
switch f {
case FieldLicense:
return s.answers.License.SPDXID
case FieldHolder:
return orPlaceholder(s.answers.Identity.Holder, "(required)")
case FieldYear:
return s.answers.Identity.Year
case FieldStyle:
return s.answers.HeaderStyle.Style
case FieldManageFiles:
return yesNo(s.answers.LicenseFiles.Manage)
case FieldInclude:
return listOrAll(s.answers.Coverage.Include)
default:
return listOrNone(s.answers.Coverage.Exclude)
}
}
func detectedBadge(s FormState, f Field) bool {
switch f {
case FieldLicense:
return s.detected.License
case FieldHolder:
return s.detected.Holder
case FieldManageFiles:
return s.detected.Manage
default:
return false
}
}
func renderEditor(s FormState) string {
switch s.focus {
case FieldLicense:
return renderLicenseEditor(s)
case FieldStyle:
return renderChoiceEditor(styleOptions, s.styleCursor)
case FieldHolder, FieldYear:
return "> " + s.textBuffer + "_"
default: // FieldInclude, FieldExclude
return renderGlobEditor(s)
}
}
func renderLicenseEditor(s FormState) string {
choices := s.filteredLicenses()
var b strings.Builder
fmt.Fprintf(&b, "filter: %s\n", orPlaceholder(s.licenseFilter, "(type to filter)"))
if len(choices) == 0 {
b.WriteString("(no matching licenses)")
return b.String()
}
start, end := window(s.licenseCursor, len(choices), 6)
for i := start; i < end; i++ {
b.WriteString(cursorMark(i == s.licenseCursor) + choices[i] + "\n")
}
fmt.Fprintf(&b, "%d/%d", s.licenseCursor+1, len(choices))
return b.String()
}
func renderChoiceEditor(options []string, cursor int) string {
var b strings.Builder
for i, opt := range options {
b.WriteString(cursorMark(i == cursor) + opt + "\n")
}
return strings.TrimRight(b.String(), "\n")
}
func renderGlobEditor(s FormState) string {
entries := s.globEntries()
var b strings.Builder
if len(entries) == 0 {
b.WriteString("(no entries yet)\n")
}
for i, e := range entries {
b.WriteString(cursorMark(i == s.globCursor) + e + "\n")
}
fmt.Fprintf(&b, "add: %s_ (enter to add, del to remove)", s.globBuffer)
return b.String()
}
func renderSource(cfg model.Config, sample Sample, nowYear int) string {
sp, err := BuildSourcePreview(SourcePreviewInput{
Config: cfg,
Sample: sample,
ResolvedYear: previewYear(cfg.Year, nowYear),
})
if err != nil {
return "source preview unavailable: " + err.Error()
}
return fmt.Sprintf("%s %s\n\n%s", sp.Language, sp.Path, sp.Content)
}
func renderYAML(cfg model.Config) string {
// BuildYAMLPreview wraps config.RenderFile, which is documented total (yaml.Marshal
// of the fixed schema cannot fail for a real Config), so the error is unreachable
// for any cfg this view can produce and is intentionally not branched on.
y, _ := BuildYAMLPreview(cfg)
return strings.TrimRight(string(y), "\n")
}
func previewYear(y model.YearSpec, nowYear int) string {
switch y.Kind {
case model.YearCurrent:
return fmt.Sprintf("%d", nowYear)
case model.YearExplicit:
if y.Start > 0 {
return fmt.Sprintf("%d", y.Start)
}
case model.YearRange:
if y.Start > 0 && y.End > 0 {
return fmt.Sprintf("%d-%d", y.Start, y.End)
}
}
return fmt.Sprintf("2021-%d", nowYear)
}
func footer(s FormState) string {
status := "ready to write"
if r := s.BlockingReason(); r != "" {
status = "cannot write: " + r
}
return mutedStyle.Render(footerHints) + "\n" + mutedStyle.Render(status)
}
func panelBox(p Panel, title, content string) string {
// The rounded border adds one column/row on each side, so the text area is
// p.Width-2 by p.Height-2. Content is pre-fit to exactly that width so lipgloss
// never re-wraps an already-truncated line.
w := maxInt(1, p.Width-2)
h := maxInt(1, p.Height-2)
body := titleStyle.Render(title) + "\n" + fitBlock(content, w, maxInt(1, h-1))
return paneStyle.Width(w).Height(h).Render(body)
}
func joinPanels(orientation Orientation, boxes []string) string {
if orientation == LayoutStacked {
return lipgloss.JoinVertical(lipgloss.Left, boxes...)
}
spaced := make([]string, 0, len(boxes)*2)
for i, b := range boxes {
if i > 0 {
spaced = append(spaced, " ")
}
spaced = append(spaced, b)
}
return lipgloss.JoinHorizontal(lipgloss.Top, spaced...)
}
func cursorMark(selected bool) string {
if selected {
return "> "
}
return " "
}
func orPlaceholder(value, placeholder string) string {
if strings.TrimSpace(value) == "" {
return placeholder
}
return value
}
func yesNo(v bool) string {
if v {
return "yes"
}
return "no"
}
func listOrAll(values []string) string {
if len(values) == 0 {
return "all supported files"
}
return strings.Join(values, ", ")
}
func listOrNone(values []string) string {
if len(values) == 0 {
return "none"
}
return strings.Join(values, ", ")
}
func indentBlock(text string) string {
lines := strings.Split(text, "\n")
for i, l := range lines {
lines[i] = " " + l
}
return strings.Join(lines, "\n")
}
func window(cursor, total, rows int) (start, end int) {
if total <= rows {
return 0, total
}
start = cursor - rows/2
if start < 0 {
start = 0
}
if start+rows > total {
start = total - rows
}
return start, start + rows
}
func fitBlock(text string, width, height int) string {
if width <= 0 || height <= 0 {
return ""
}
raw := strings.Split(text, "\n")
lines := make([]string, 0, height)
for _, line := range raw {
lines = append(lines, truncateLine(line, width))
if len(lines) == height {
break
}
}
if len(raw) > height {
lines[height-1] = truncateLine("...", width)
}
return strings.Join(lines, "\n")
}
func truncateLine(line string, width int) string {
r := []rune(line)
if len(r) <= width {
return line
}
if width <= 3 {
return string(r[:width])
}
return string(r[:width-3]) + "..."
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
+151
View File
@@ -0,0 +1,151 @@
package initwizard
import (
"strings"
"testing"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/stretchr/testify/assert"
)
func cSample() Sample { return SelectSample(nil) }
func TestRenderTooSmall(t *testing.T) {
out := Render(validForm(), PanelPlan{TooSmall: true}, cSample(), 2026)
assert.Contains(t, out, "Terminal too small")
assert.Contains(t, out, "--license")
}
func TestRenderWideShowsAllPanels(t *testing.T) {
s := NewForm(Answers{
License: LicenseAnswer{SPDXID: "MIT"},
Identity: IdentityAnswer{Holder: "Acme, LLC", Year: "git"},
Coverage: CoverageAnswer{Include: []string{"src/**"}, Exclude: []string{"**/vendor/**"}},
}, Detected{})
out := Render(s, Layout(140, 40), cSample(), 2026)
assert.Contains(t, out, "license-tool init")
assert.Contains(t, out, "License")
assert.Contains(t, out, "MIT")
assert.Contains(t, out, "Acme, LLC")
assert.Contains(t, out, "src/**") // include row (listOrAll non-empty)
assert.Contains(t, out, "**/vendor/**") // exclude row (listOrNone non-empty)
assert.Contains(t, out, "Source preview")
assert.Contains(t, out, "stdio") // C sample body
assert.Contains(t, out, "license: MIT") // YAML preview
assert.Contains(t, out, "ready to write") // footer, valid form
}
func TestRenderListPlaceholders(t *testing.T) {
out := Render(validForm(), Layout(140, 40), cSample(), 2026)
assert.Contains(t, out, "all supported files") // empty include
assert.Contains(t, out, "Manage license files: no")
}
func TestRenderInvalidFormDisablesWrite(t *testing.T) {
s := NewForm(Answers{License: LicenseAnswer{SPDXID: "MIT"}}, Detected{}) // empty holder
out := Render(s, Layout(140, 40), cSample(), 2026)
assert.Contains(t, out, "(required)") // holder placeholder
assert.Contains(t, out, "(blocked)") // write row disabled
assert.Contains(t, out, "cannot write:") // footer reason
}
func TestRenderDetectedBadges(t *testing.T) {
s := NewForm(Answers{
License: LicenseAnswer{SPDXID: "MIT"},
Identity: IdentityAnswer{Holder: "Acme"},
}, Detected{License: true, Holder: true, Manage: true})
out := Render(s, Layout(140, 40), cSample(), 2026)
assert.GreaterOrEqual(t, strings.Count(out, "(detected)"), 3)
}
func TestRenderEditors(t *testing.T) {
open := func(f Field, mutate func(*FormState)) string {
s := validForm()
s.focus = f
s.Activate()
if mutate != nil {
mutate(&s)
}
return Render(s, Layout(140, 40), cSample(), 2026)
}
assert.Contains(t, open(FieldLicense, nil), "filter:")
assert.Contains(t, open(FieldStyle, nil), "reuse+notice")
assert.Contains(t, open(FieldHolder, nil), "Acme, LLC_")
assert.Contains(t, open(FieldInclude, nil), "(no entries yet)")
assert.Contains(t, open(FieldInclude, func(s *FormState) {
s.globBuffer = "src/**"
s.Commit()
}), "add:")
noMatch := open(FieldLicense, func(s *FormState) {
for _, r := range "zzzz" {
s.Input(r)
}
})
assert.Contains(t, noMatch, "no matching licenses")
}
func TestRenderSourceUnavailable(t *testing.T) {
s := NewForm(Answers{
License: LicenseAnswer{SPDXID: "Glide"}, // valid SPDX id, not renderable
Identity: IdentityAnswer{Holder: "Acme"},
}, Detected{})
out := Render(s, Layout(140, 40), cSample(), 2026)
assert.Contains(t, out, "source preview unavailable")
}
func TestRenderStacked(t *testing.T) {
out := Render(validForm(), Layout(70, 24), cSample(), 2026)
assert.Contains(t, out, "license-tool init")
assert.Contains(t, out, "Source preview")
}
func TestPreviewYear(t *testing.T) {
assert.Equal(t, "2026", previewYear(model.YearSpec{Kind: model.YearCurrent}, 2026))
assert.Equal(t, "2030", previewYear(model.YearSpec{Kind: model.YearExplicit, Start: 2030}, 2026))
assert.Equal(t, "2021-2026", previewYear(model.YearSpec{Kind: model.YearExplicit}, 2026)) // Start==0 fallback
assert.Equal(t, "2020-2025", previewYear(model.YearSpec{Kind: model.YearRange, Start: 2020, End: 2025}, 2026))
assert.Equal(t, "2021-2026", previewYear(model.YearSpec{Kind: model.YearRange}, 2026)) // incomplete fallback
assert.Equal(t, "2021-2026", previewYear(model.YearSpec{Kind: model.YearGit}, 2026)) // git/default
}
func TestSmallViewHelpers(t *testing.T) {
assert.Equal(t, "> ", cursorMark(true))
assert.Equal(t, " ", cursorMark(false))
assert.Equal(t, "fallback", orPlaceholder(" ", "fallback"))
assert.Equal(t, "value", orPlaceholder("value", "fallback"))
assert.Equal(t, "yes", yesNo(true))
assert.Equal(t, "no", yesNo(false))
assert.Equal(t, "all supported files", listOrAll(nil))
assert.Equal(t, "a, b", listOrAll([]string{"a", "b"}))
assert.Equal(t, "none", listOrNone(nil))
assert.Equal(t, "x", listOrNone([]string{"x"}))
assert.Equal(t, " a\n b", indentBlock("a\nb"))
assert.Equal(t, 5, maxInt(5, 1))
assert.Equal(t, 5, maxInt(1, 5))
}
func TestWindow(t *testing.T) {
s, e := window(0, 3, 6)
assert.Equal(t, [2]int{0, 3}, [2]int{s, e}) // total <= rows
s, e = window(0, 20, 6)
assert.Equal(t, [2]int{0, 6}, [2]int{s, e}) // start clamped to 0
s, e = window(19, 20, 6)
assert.Equal(t, [2]int{14, 20}, [2]int{s, e}) // start+rows > total
}
func TestFitBlockAndTruncate(t *testing.T) {
assert.Equal(t, "", fitBlock("x", 0, 5))
assert.Equal(t, "", fitBlock("x", 5, 0))
got := fitBlock("a\nb\nc\nd", 10, 2)
assert.Equal(t, "a\n...", got) // overflow replaces last visible line
assert.Equal(t, "ab", truncateLine("ab", 10)) // fits
assert.Equal(t, "ab", truncateLine("abcdef", 2)) // width <= 3
assert.Equal(t, "a...", truncateLine("abcdef", 4))
}
+38
View File
@@ -68,6 +68,23 @@ func (c Category) String() string {
}
}
// Risk renders the obligation severity of a category as a coarse, sortable token
// ("high"|"medium"|"low"|"unknown"), so reports and machine consumers can branch on a
// single value instead of pattern-matching category names. Strong/network copyleft and
// proprietary are high; weak copyleft is medium; permissive is low.
func (c Category) Risk() string {
switch c {
case CategoryStrongCopyleft, CategoryNetworkCopyleft, CategoryProprietary:
return "high"
case CategoryWeakCopyleft:
return "medium"
case CategoryPermissive:
return "low"
default:
return "unknown"
}
}
// HeaderStyle selects which parts of the canonical header are emitted into a file.
type HeaderStyle int
@@ -232,6 +249,8 @@ type Config struct {
Style HeaderStyle
// ManageLicenseFile enables writing top-level LICENSE + LICENSES/<id>.txt.
ManageLicenseFile bool
// Includes are glob patterns that restrict source-file processing when non-empty.
Includes []string
// Excludes are gitignore-style patterns added on top of .gitignore.
Excludes []string
// Policy drives audit classification and check exit codes.
@@ -383,6 +402,25 @@ type Report struct {
FileTypeCounts map[string]int
// Violations lists repo-level policy violation tokens.
Violations []string
// ViolationDetails carries every policy finding (file, dependency, and repo level)
// with its attribution: the condition, the offending SPDX id, the file/dependency
// path, and a human-readable message. WHY both this and Violations: Violations is
// the legacy repo-level token set; ViolationDetails is the full, attributable set
// reports and machine consumers need to explain WHICH license/rule/file failed.
ViolationDetails []ViolationDetail
// Passed is true when no fail_on condition tripped (drives check exit code).
Passed bool
}
// ViolationDetail is one policy finding with its attribution, so a reader (or an LLM)
// can see the cause, not just a token.
type ViolationDetail struct {
// Condition is the fail_on category this finding belongs to.
Condition FailCondition
// SPDXID is the offending license id, if applicable.
SPDXID string
// Path is the offending file or dependency label; empty for whole-repo findings.
Path string
// Message is the human-readable explanation.
Message string
}
+18
View File
@@ -28,6 +28,24 @@ func TestCategoryString(t *testing.T) {
}
}
func TestCategoryRisk(t *testing.T) {
tests := []struct {
category Category
want string
}{
{CategoryStrongCopyleft, "high"},
{CategoryNetworkCopyleft, "high"},
{CategoryProprietary, "high"},
{CategoryWeakCopyleft, "medium"},
{CategoryPermissive, "low"},
{CategoryUnknown, "unknown"},
{Category(99), "unknown"},
}
for _, tt := range tests {
assert.Equal(t, tt.want, tt.category.Risk())
}
}
func TestHeaderStyleString(t *testing.T) {
tests := []struct {
name string
+60 -5
View File
@@ -52,6 +52,12 @@ type Findings struct {
Passed bool
// Violations are the repo-level violation tokens, sorted, surfaced when failing.
Violations []string
// RiskLevel is the worst obligation severity among the headered source licenses
// ("high"|"medium"|"low"|"none"), so a reader or model can branch on one value.
RiskLevel string
// WorstCategory is the license category contributing RiskLevel (empty when none).
WorstCategory string
}
// buildFindings folds a finished model.Report into a Findings summary. It is pure
@@ -64,6 +70,7 @@ func buildFindings(r model.Report) Findings {
}
copyleftSeen := map[string]bool{}
worst := worstRisk{}
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
@@ -84,10 +91,12 @@ func buildFindings(r model.Report) Findings {
f.UnknownCount++
}
switch categoryToken(id) {
case model.CategoryWeakCopyleft.String(),
model.CategoryStrongCopyleft.String(),
model.CategoryNetworkCopyleft.String():
cat := classifyCategory(id)
worst.observe(cat)
switch cat {
case model.CategoryWeakCopyleft,
model.CategoryStrongCopyleft,
model.CategoryNetworkCopyleft:
copyleftSeen[id] = true
}
} else {
@@ -96,6 +105,7 @@ func buildFindings(r model.Report) Findings {
}
f.Copyleft = sortedKeys(copyleftSeen)
f.RiskLevel, f.WorstCategory = worst.result()
if len(r.Dependencies) > 0 {
f.DepsScanned = true
@@ -129,7 +139,7 @@ func (f Findings) licenseSummary() string {
parts = append(parts, fmt.Sprintf("%s %d", id, f.LicenseCounts[id]))
}
if f.SourceMissing > 0 {
parts = append(parts, fmt.Sprintf("none %d", f.SourceMissing))
parts = append(parts, fmt.Sprintf("%s %d", noLicenseKey, f.SourceMissing))
}
if len(parts) == 0 {
return "(none)"
@@ -166,6 +176,7 @@ func renderFindings(bw *errWriter, f Findings) {
bw.printf(" license types: %s\n", f.licenseSummary())
bw.printf(" unknown/unrecognized: %d\n", f.UnknownCount)
bw.printf(" copyleft: %s\n", f.copyleftSummary())
bw.printf(" risk: %s\n", f.riskSummary())
if f.DepsScanned {
bw.printf(" dependencies: %d (resolved %d, unresolved %d)\n", f.DepsTotal, f.DepsResolved, f.DepsUnresolved)
}
@@ -173,6 +184,50 @@ func renderFindings(bw *errWriter, f Findings) {
bw.printf("\n")
}
// worstRisk tracks the highest-severity license category observed across headered
// source files, for the findings RiskLevel. Ties on risk rank break toward the higher
// Category enum so the result is deterministic.
type worstRisk struct {
cat model.Category
have bool
}
func (w *worstRisk) observe(c model.Category) {
if !w.have || riskRank(c) > riskRank(w.cat) || (riskRank(c) == riskRank(w.cat) && c > w.cat) {
w.cat = c
w.have = true
}
}
func (w worstRisk) result() (level, category string) {
if !w.have {
return "none", ""
}
return w.cat.Risk(), w.cat.String()
}
// riskRank ranks a category's obligation severity for "worst" comparison.
func riskRank(c model.Category) int {
switch c.Risk() {
case "high":
return 3
case "medium":
return 2
case "low":
return 1
default:
return 0
}
}
// riskSummary renders the risk level and the worst category, or "none".
func (f Findings) riskSummary() string {
if f.WorstCategory == "" {
return "none"
}
return fmt.Sprintf("%s (%s)", f.RiskLevel, f.WorstCategory)
}
// 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 {
+2 -2
View File
@@ -37,7 +37,7 @@ func TestBuildFindingsSourceCoverageAndLicenseMix(t *testing.T) {
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())
assert.Equal(t, "GPL-3.0-or-later 1, MIT 2, (no-header) 1", f.licenseSummary())
}
func TestBuildFindingsUnknownAndCopyleft(t *testing.T) {
@@ -118,7 +118,7 @@ func TestRenderFindingsBlock(t *testing.T) {
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, "license types: MIT 1, (no-header) 1")
assert.Contains(t, out, "unknown/unrecognized: 0")
assert.Contains(t, out, "copyleft: none")
assert.Contains(t, out, "dependencies: 1 (resolved 1, unresolved 0)")
+343
View File
@@ -0,0 +1,343 @@
package report
import (
"fmt"
"path/filepath"
"sort"
"strings"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/KofTwentyTwo/license-tool/internal/policy"
)
// GroupDimension selects how the source-file listing is organized for the
// --group-by option. GroupNone is the default flat listing.
type GroupDimension int
const (
// GroupNone is the default: a flat, ungrouped file listing.
GroupNone GroupDimension = iota
// GroupLicense groups files by detected SPDX id.
GroupLicense
// GroupCategory groups files by license category (permissive, copyleft, ...).
GroupCategory
// GroupType groups files by matched file-type name.
GroupType
// GroupDirectory groups files by top-level path segment.
GroupDirectory
)
// String renders the dimension as its --group-by token (and the word used in
// rendered section headers). GroupNone renders as "none".
func (d GroupDimension) String() string {
switch d {
case GroupLicense:
return "license"
case GroupCategory:
return "category"
case GroupType:
return "type"
case GroupDirectory:
return "directory"
default:
return "none"
}
}
// ParseGroupBy parses a --group-by token. An empty token is GroupNone (no error);
// an unrecognized token is a usage error.
func ParseGroupBy(raw string) (GroupDimension, error) {
switch raw {
case "":
return GroupNone, nil
case "license":
return GroupLicense, nil
case "category":
return GroupCategory, nil
case "type":
return GroupType, nil
case "directory":
return GroupDirectory, nil
default:
return GroupNone, fmt.Errorf("report: unknown group-by dimension %q (expected license|category|type|directory)", raw)
}
}
// GroupSpec parameterizes GroupFiles: the dimension to group by and, for the
// directory dimension, how many leading path segments form the key (Depth, min 1).
type GroupSpec struct {
By GroupDimension
Depth int
}
// OnlyFilter restricts the source-file listing to "problem" files.
type OnlyFilter int
const (
// OnlyMissing keeps files with no managed header.
OnlyMissing OnlyFilter = iota
// OnlyUnknown keeps files whose detected license is unclassifiable.
OnlyUnknown
// OnlyCopyleft keeps files under a copyleft license (weak/strong/network).
OnlyCopyleft
// OnlyViolations keeps files carrying a policy violation.
OnlyViolations
)
// ParseOnly parses a comma-separated --only spec into filters. Empty yields none (no
// filtering); an unrecognized token is a usage error.
func ParseOnly(raw string) ([]OnlyFilter, error) {
var out []OnlyFilter
for _, tok := range strings.Split(raw, ",") {
tok = strings.TrimSpace(tok)
switch tok {
case "":
continue
case "missing":
out = append(out, OnlyMissing)
case "unknown":
out = append(out, OnlyUnknown)
case "copyleft":
out = append(out, OnlyCopyleft)
case "violations":
out = append(out, OnlyViolations)
default:
return nil, fmt.Errorf("report: unknown --only filter %q (expected missing|unknown|copyleft|violations)", tok)
}
}
return out, nil
}
// keepFile reports whether fr passes the --only filters (matches ANY). An empty
// filter set keeps everything; skipped files never match a problem filter.
func keepFile(fr model.FileResult, only []OnlyFilter) bool {
if len(only) == 0 {
return true
}
if fr.Skipped {
return false
}
for _, f := range only {
switch f {
case OnlyMissing:
if !fr.Detected.Present {
return true
}
case OnlyUnknown:
if fr.Detected.Present && fr.Detected.SPDXID != "" && classifyCategory(fr.Detected.SPDXID) == model.CategoryUnknown {
return true
}
case OnlyCopyleft:
switch classifyCategory(fr.Detected.SPDXID) {
case model.CategoryWeakCopyleft, model.CategoryStrongCopyleft, model.CategoryNetworkCopyleft:
return true
}
case OnlyViolations:
if len(fr.Violations) > 0 {
return true
}
}
}
return false
}
// Group is one bucket of the grouped source-file view: a key, the count of files in
// it, the worst obligation risk among its files, the license breakdown within it, and
// the files themselves (sorted by path).
type Group struct {
Key string
Count int
// Risk is the worst category risk among the group's files ("high"|"medium"|
// "low"|"none"), so directory/type groups carry a license-risk signal instead of
// being a license-blind count.
Risk string
// Licenses is the license breakdown within the group (SPDX id or "(no-header)" ->
// count), so a directory/type group shows WHICH licenses it mixes, not just a count.
Licenses map[string]int
Files []model.FileResult
}
// GroupFiles partitions the non-skipped source files of r per spec, returning the
// groups (sorted by key, files sorted by path) and the count of skipped files
// (binary/uncommentable/unknown) which are never grouped. GroupNone returns no
// groups. Pure and deterministic for identical input.
func GroupFiles(r model.Report, spec GroupSpec) (groups []Group, skipped int) {
// Repo-level hard incompatibilities are not attributed to any single file, so a
// group's risk would otherwise be blind to them (an Apache group beside AGPL would
// read "low"). Derive the incompatibility set from r's own files; callers that have
// narrowed r for the listing (e.g. --only) must use groupFilesWith with the full
// repo's set so the risk marker is not distorted by the filter.
return groupFilesWith(r, spec, incompatibleIDs(distinctSourceIDs(r.Files)))
}
// groupFilesWith is GroupFiles with an explicit repo-wide incompatibility set. The set
// must be computed over the full repo's distinct source ids (not a --only-narrowed
// listing), so a group's policy-aware risk reflects repo-level incompatibilities even
// when r.Files has been filtered down to the files actually being listed.
func groupFilesWith(r model.Report, spec GroupSpec, incompatIDs map[string]bool) (groups []Group, skipped int) {
buckets := map[string][]model.FileResult{}
for _, fr := range r.Files {
if fr.Skipped {
skipped++
continue
}
if spec.By == GroupNone {
continue
}
buckets[groupKeyDepth(fr, spec)] = append(buckets[groupKeyDepth(fr, spec)], fr)
}
keys := make([]string, 0, len(buckets))
for k := range buckets {
keys = append(keys, k)
}
sort.Strings(keys)
groups = make([]Group, 0, len(keys))
for _, k := range keys {
files := buckets[k]
sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
groups = append(groups, Group{
Key: k,
Count: len(files),
Risk: groupRisk(files, incompatIDs),
Licenses: licenseBreakdown(files),
Files: files,
})
}
return groups, skipped
}
// groupKeyDepth derives a file's bucket key, honoring the directory depth.
func groupKeyDepth(fr model.FileResult, spec GroupSpec) string {
if spec.By == GroupDirectory {
return topDirs(fr.Path, spec.Depth)
}
return groupKey(fr, spec.By)
}
// licenseBreakdown counts the license (or no-header) of each file in a group.
func licenseBreakdown(files []model.FileResult) map[string]int {
out := map[string]int{}
for _, fr := range files {
out[groupKey(fr, GroupLicense)]++
}
return out
}
// groupRisk returns the group's risk: the worst category risk among its files,
// escalated to "high" when the group carries a policy concern. A policy concern is
// a file whose license is party to a repo-level hard incompatibility (incompatIDs)
// or a file carrying a file-scoped policy violation (deny/allow/required breach).
// WHY escalate: a license-category-only risk is policy-blind -- an Apache group
// beside AGPL, or a deny-listed permissive license, both read "low" by category yet
// are real audit liabilities.
func groupRisk(files []model.FileResult, incompatIDs map[string]bool) string {
var w worstRisk
policyConcern := false
for _, fr := range files {
if fr.Detected.Present && fr.Detected.SPDXID != "" {
w.observe(classifyCategory(fr.Detected.SPDXID))
if incompatIDs[fr.Detected.SPDXID] {
policyConcern = true
}
}
if hasPolicyViolation(fr) {
policyConcern = true
}
}
if policyConcern {
return "high"
}
level, _ := w.result()
if level == "none" {
// A non-empty group with no classifiable license (all missing/headerless) is an
// audit liability, not "all clear" -- report it as unknown risk.
return "unknown"
}
return level
}
// incompatibleIDs returns the set of SPDX ids that are party to a curated hard
// incompatibility with another id in the same repo. ids is the distinct source-id
// set; the pairwise scan is over the (small) distinct set, not every file.
func incompatibleIDs(ids []string) map[string]bool {
out := map[string]bool{}
for i := 0; i < len(ids); i++ {
for j := i + 1; j < len(ids); j++ {
if policy.Incompatible(ids[i], ids[j]) {
out[ids[i]] = true
out[ids[j]] = true
}
}
}
return out
}
// hasPolicyViolation reports whether a file carries a file-scoped policy-violation
// token (allow/deny/required breach). Missing-header and unknown-license tokens are
// excluded: those are already reflected by the category-based risk (headerless ->
// unknown, unclassifiable id -> unknown) and are not policy escalations.
func hasPolicyViolation(fr model.FileResult) bool {
for _, tok := range fr.Violations {
if tok == model.FailOnPolicyViolation.String() {
return true
}
}
return false
}
// sortGroups orders groups by descending count (ties by key) when byCount is set,
// otherwise leaves the key-sorted order GroupFiles produced.
func sortGroups(groups []Group, byCount bool) {
if !byCount {
return
}
sort.SliceStable(groups, func(i, j int) bool {
if groups[i].Count != groups[j].Count {
return groups[i].Count > groups[j].Count
}
return groups[i].Key < groups[j].Key
})
}
// groupKey derives a file's bucket key for the given dimension.
func groupKey(fr model.FileResult, dim GroupDimension) string {
switch dim {
case GroupLicense:
if fr.Detected.Present && fr.Detected.SPDXID != "" {
return fr.Detected.SPDXID
}
return noLicenseKey
case GroupCategory:
if fr.Detected.Present && fr.Detected.SPDXID != "" {
return categoryToken(fr.Detected.SPDXID)
}
return model.CategoryUnknown.String()
default: // GroupType
if fr.FileType != "" {
return fr.FileType
}
return "(unknown type)"
}
}
// topDirs returns the first depth directory segments of a forward-slash path (the
// directory containing the file, capped at depth). A file with fewer leading
// directories than depth groups under its own directory; root-level files group
// under ".". Depth below 1 is treated as 1.
func topDirs(path string, depth int) string {
if depth < 1 {
depth = 1
}
p := filepath.ToSlash(path)
segments := strings.Split(p, "/")
if len(segments) <= 1 {
return "." // file at root, no directory
}
dirs := segments[:len(segments)-1] // drop the file name
if len(dirs) > depth {
dirs = dirs[:depth]
}
return strings.Join(dirs, "/")
}
+244
View File
@@ -0,0 +1,244 @@
package report
import (
"testing"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func headered(path, ft, id string) model.FileResult {
return model.FileResult{Path: path, FileType: ft, Detected: model.DetectedHeader{Present: true, SPDXID: id}}
}
func TestParseGroupBy(t *testing.T) {
cases := map[string]GroupDimension{
"": GroupNone,
"license": GroupLicense,
"category": GroupCategory,
"type": GroupType,
"directory": GroupDirectory,
}
for in, want := range cases {
got, err := ParseGroupBy(in)
require.NoError(t, err, "ParseGroupBy(%q)", in)
assert.Equal(t, want, got)
}
_, err := ParseGroupBy("nonsense")
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown group-by dimension")
}
func sampleReport() model.Report {
return model.Report{Files: []model.FileResult{
headered("src/a.go", "Go", "MIT"),
headered("src/b.go", "Go", "MIT"),
headered("web/c.ts", "TypeScript/JavaScript", "AGPL-3.0-or-later"),
{Path: "src/headerless.go", FileType: "Go"}, // non-skipped, no header
{Path: "img/logo.png", FileType: "", Skipped: true, SkipReason: "binary"}, // skipped
}}
}
func TestGroupFilesNone(t *testing.T) {
groups, skipped := GroupFiles(sampleReport(), GroupSpec{By: GroupNone})
assert.Empty(t, groups)
assert.Equal(t, 1, skipped)
}
func TestGroupFilesByLicense(t *testing.T) {
groups, skipped := GroupFiles(sampleReport(), GroupSpec{By: GroupLicense})
assert.Equal(t, 1, skipped)
// Keys sorted: "(no-header)" < "AGPL-3.0-or-later" < "MIT".
require.Len(t, groups, 3)
assert.Equal(t, "(no-header)", groups[0].Key)
assert.Equal(t, 1, groups[0].Count)
assert.Equal(t, "src/headerless.go", groups[0].Files[0].Path)
assert.Equal(t, "AGPL-3.0-or-later", groups[1].Key)
assert.Equal(t, "MIT", groups[2].Key)
assert.Equal(t, 2, groups[2].Count)
// Files within a group are path-sorted.
assert.Equal(t, "src/a.go", groups[2].Files[0].Path)
assert.Equal(t, "src/b.go", groups[2].Files[1].Path)
}
func TestGroupFilesByCategory(t *testing.T) {
groups, _ := GroupFiles(sampleReport(), GroupSpec{By: GroupCategory})
keys := map[string]int{}
for _, g := range groups {
keys[g.Key] = g.Count
}
assert.Equal(t, 2, keys["permissive"]) // MIT x2
assert.Equal(t, 1, keys["network-copyleft"]) // AGPL
assert.Equal(t, 1, keys["unknown"]) // headerless
}
func TestGroupFilesByType(t *testing.T) {
r := model.Report{Files: []model.FileResult{
headered("a.go", "Go", "MIT"),
{Path: "weird", FileType: ""}, // non-skipped, no file type
}}
groups, _ := GroupFiles(r, GroupSpec{By: GroupType})
keys := map[string]int{}
for _, g := range groups {
keys[g.Key] = g.Count
}
assert.Equal(t, 1, keys["Go"])
assert.Equal(t, 1, keys["(unknown type)"])
}
func TestGroupFilesByDirectory(t *testing.T) {
groups, _ := GroupFiles(sampleReport(), GroupSpec{By: GroupDirectory})
keys := map[string]int{}
for _, g := range groups {
keys[g.Key] = g.Count
}
assert.Equal(t, 3, keys["src"]) // a.go, b.go, headerless.go
assert.Equal(t, 1, keys["web"])
// img/logo.png is skipped, so "img" never appears.
_, hasImg := keys["img"]
assert.False(t, hasImg)
}
func TestGroupFilesDirectoryRoot(t *testing.T) {
r := model.Report{Files: []model.FileResult{headered("main.go", "Go", "MIT")}}
groups, _ := GroupFiles(r, GroupSpec{By: GroupDirectory})
require.Len(t, groups, 1)
assert.Equal(t, ".", groups[0].Key)
}
func TestTopDirs(t *testing.T) {
assert.Equal(t, ".", topDirs("main.go", 1))
assert.Equal(t, "src", topDirs("src/a.go", 1))
assert.Equal(t, "src", topDirs("src/sub/deep/a.go", 1)) // capped at depth 1
assert.Equal(t, "src/sub", topDirs("src/sub/deep/a.go", 2)) // depth 2
assert.Equal(t, "src", topDirs("src/a.go", 0)) // depth < 1 -> 1
}
// groupByKey returns the group with the given key, failing the test if absent.
func groupByKey(t *testing.T, groups []Group, key string) Group {
t.Helper()
for _, g := range groups {
if g.Key == key {
return g
}
}
t.Fatalf("no group with key %q in %v", key, groups)
return Group{}
}
func TestGroupRiskCategoryOnlyWhenNoPolicyConcern(t *testing.T) {
// sampleReport mixes MIT (permissive) and AGPL (network-copyleft). MIT and AGPL
// are NOT a curated hard incompatibility, so the MIT group must stay "low" (no
// false escalation), AGPL stays "high" by its category, and the headerless group
// is "unknown".
groups, _ := GroupFiles(sampleReport(), GroupSpec{By: GroupLicense})
assert.Equal(t, "low", groupByKey(t, groups, "MIT").Risk)
assert.Equal(t, "high", groupByKey(t, groups, "AGPL-3.0-or-later").Risk)
assert.Equal(t, "unknown", groupByKey(t, groups, noLicenseKey).Risk)
}
func TestGroupRiskEscalatesOnRepoIncompatibility(t *testing.T) {
// Apache-2.0 is permissive (category risk "low"), but beside AGPL-3.0-or-later it
// is party to a curated hard incompatibility. The Apache group must read "high",
// not "low" -- a group is not "all clear" when its license clashes repo-wide.
r := model.Report{Files: []model.FileResult{
headered("a/x.go", "Go", "Apache-2.0"),
headered("b/y.go", "Go", "AGPL-3.0-or-later"),
}}
groups, _ := GroupFiles(r, GroupSpec{By: GroupLicense})
assert.Equal(t, "high", groupByKey(t, groups, "Apache-2.0").Risk)
assert.Equal(t, "high", groupByKey(t, groups, "AGPL-3.0-or-later").Risk)
}
func TestGroupFilesWithRepoWideIncompatibility(t *testing.T) {
// The listing has been narrowed to just the Apache file (its AGPL partner filtered
// out of Files, e.g. by --only), but the repo-wide incompatibility set still names
// both. The Apache group must still escalate to "high": a narrowed listing must not
// distort the policy-aware risk marker (the rollup-distortion contract of --only).
r := model.Report{Files: []model.FileResult{headered("a/x.go", "Go", "Apache-2.0")}}
full := map[string]bool{"Apache-2.0": true, "AGPL-3.0-or-later": true}
groups, _ := groupFilesWith(r, GroupSpec{By: GroupLicense}, full)
assert.Equal(t, "high", groupByKey(t, groups, "Apache-2.0").Risk)
// Control: with an empty repo-wide set (what the filtered listing alone would
// yield), Apache reads its category risk "low" -- proving the passed-in set is what
// drives escalation, not the listing's own ids.
groups2, _ := groupFilesWith(r, GroupSpec{By: GroupLicense}, map[string]bool{})
assert.Equal(t, "low", groupByKey(t, groups2, "Apache-2.0").Risk)
}
func TestGroupRiskEscalatesOnFileScopedPolicyViolation(t *testing.T) {
// A permissive license carrying a file-scoped policy violation (e.g. deny-listed)
// must escalate its group to "high" rather than reading its category risk "low".
denied := headered("a/x.go", "Go", "MIT")
denied.Violations = []string{model.FailOnPolicyViolation.String()}
r := model.Report{Files: []model.FileResult{denied}}
groups, _ := GroupFiles(r, GroupSpec{By: GroupLicense})
assert.Equal(t, "high", groupByKey(t, groups, "MIT").Risk)
}
func TestGroupRiskDirectoryEscalatesMixedGroup(t *testing.T) {
// The headline case for policy-aware risk: a non-license grouping where one bad
// file taints the bucket. pkg/ mixes an Apache file (party to a repo incompatibility
// with the AGPL in lib/) with an innocent MIT file; both are category-low, yet pkg/
// must read "high". A sibling directory of only MIT stays "low".
r := model.Report{Files: []model.FileResult{
headered("pkg/a.go", "Go", "Apache-2.0"),
headered("pkg/b.go", "Go", "MIT"),
headered("other/c.go", "Go", "MIT"),
headered("lib/d.go", "Go", "AGPL-3.0-or-later"),
}}
groups, _ := GroupFiles(r, GroupSpec{By: GroupDirectory})
assert.Equal(t, "high", groupByKey(t, groups, "pkg").Risk)
assert.Equal(t, "low", groupByKey(t, groups, "other").Risk)
assert.Equal(t, "high", groupByKey(t, groups, "lib").Risk) // AGPL: category high and incompatible
}
func TestGroupRiskIncompatBeatsHeaderlessUnknown(t *testing.T) {
// A group with an incompatible-license file AND a headerless file escalates to
// "high" (policy concern), not the headerless "unknown" fallback.
r := model.Report{Files: []model.FileResult{
headered("pkg/a.go", "Go", "Apache-2.0"),
{Path: "pkg/b.go", FileType: "Go"}, // headerless
headered("lib/c.go", "Go", "AGPL-3.0-or-later"),
}}
groups, _ := GroupFiles(r, GroupSpec{By: GroupDirectory})
assert.Equal(t, "high", groupByKey(t, groups, "pkg").Risk)
}
func TestGroupRiskNonPolicyViolationTokenDoesNotEscalate(t *testing.T) {
// missing-header / unknown-license tokens are not policy escalations: a permissive
// group carrying only such a token keeps its category risk ("low"), not "high".
fr := headered("a/x.go", "Go", "MIT")
fr.Violations = []string{model.FailOnUnknownLicense.String()}
r := model.Report{Files: []model.FileResult{fr}}
groups, _ := GroupFiles(r, GroupSpec{By: GroupLicense})
assert.Equal(t, "low", groupByKey(t, groups, "MIT").Risk)
}
func TestIncompatibleIDs(t *testing.T) {
// Three ids where exactly one pair (AGPL x Apache) is a curated hard incompatibility:
// both are flagged, the innocent MIT is not.
got := incompatibleIDs([]string{"AGPL-3.0-or-later", "Apache-2.0", "MIT"})
assert.True(t, got["AGPL-3.0-or-later"])
assert.True(t, got["Apache-2.0"])
assert.False(t, got["MIT"])
// No incompatible pair, a single id, and the empty set all yield an empty result.
assert.Empty(t, incompatibleIDs([]string{"MIT", "Apache-2.0"}))
assert.Empty(t, incompatibleIDs([]string{"Apache-2.0"}))
assert.Empty(t, incompatibleIDs(nil))
}
func TestLicenseBreakdown(t *testing.T) {
files := []model.FileResult{
headered("a.go", "Go", "MIT"),
headered("b.go", "Go", "MIT"),
{Path: "c.go", FileType: "Go"}, // no header
}
got := licenseBreakdown(files)
assert.Equal(t, 2, got["MIT"])
assert.Equal(t, 1, got["(no-header)"])
}
+293
View File
@@ -0,0 +1,293 @@
package report
import (
"bytes"
"encoding/json"
"testing"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/KofTwentyTwo/license-tool/internal/policy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func reportWithViolations() model.Report {
r := optionsFixture()
r.ViolationDetails = []model.ViolationDetail{
{Condition: model.FailOnMissingHeader, Path: "src/legacy.go", Message: "src/legacy.go: no managed license header"},
{Condition: model.FailOnPolicyViolation, SPDXID: "AGPL-3.0-or-later", Path: "web/c.ts", Message: `web/c.ts: license "AGPL-3.0-or-later" is not on the allow list`},
}
return r
}
func TestPercent(t *testing.T) {
assert.Equal(t, "0.0%", percent(5, 0))
assert.Equal(t, "50.0%", percent(1, 2))
assert.Equal(t, "100.0%", percent(4, 4))
}
func TestSortByCountHelpers(t *testing.T) {
counts := map[string]int{"a": 1, "b": 3, "c": 2}
byKey := sortedCountsBy(counts, false)
assert.Equal(t, []string{"a", "b", "c"}, []string{byKey[0].key, byKey[1].key, byKey[2].key})
byCount := sortedCountsBy(counts, true)
assert.Equal(t, []string{"b", "c", "a"}, []string{byCount[0].key, byCount[1].key, byCount[2].key})
groups := []Group{{Key: "a", Count: 1}, {Key: "b", Count: 3}, {Key: "c", Count: 2}}
sortGroups(groups, false) // no-op leaves key order
assert.Equal(t, "a", groups[0].Key)
sortGroups(groups, true)
assert.Equal(t, []string{"b", "c", "a"}, []string{groups[0].Key, groups[1].Key, groups[2].Key})
tied := []Group{{Key: "z", Count: 2}, {Key: "a", Count: 2}}
sortGroups(tied, true) // equal counts -> alphabetical key
assert.Equal(t, "a", tied[0].Key)
}
func TestWorstRiskAndSummary(t *testing.T) {
var w worstRisk
lvl, cat := w.result()
assert.Equal(t, "none", lvl)
assert.Empty(t, cat)
w.observe(model.CategoryPermissive) // low
w.observe(model.CategoryStrongCopyleft) // high beats low
w.observe(model.CategoryWeakCopyleft) // medium does not beat high
w.observe(model.CategoryNetworkCopyleft) // tie on high, higher enum wins
w.observe(model.CategoryUnknown) // unknown (rank 0) loses
lvl, cat = w.result()
assert.Equal(t, "high", lvl)
assert.Equal(t, "network-copyleft", cat)
assert.Equal(t, "none", Findings{}.riskSummary())
assert.Equal(t, "high (network-copyleft)", Findings{RiskLevel: "high", WorstCategory: "network-copyleft"}.riskSummary())
}
func TestToViolationDetailsSortsDedupesAndHandlesEmpty(t *testing.T) {
assert.Empty(t, toViolationDetails(nil))
got := toViolationDetails([]policy.Violation{
{Condition: model.FailOnPolicyViolation, SPDXID: "MIT", Path: "b", Message: "m"},
{Condition: model.FailOnMissingHeader, Path: "a", Message: "m2"},
{Condition: model.FailOnMissingHeader, Path: "a", Message: "m1"}, // same cond+path, diff msg
{Condition: model.FailOnMissingHeader, Path: "a", Message: "m1"}, // exact duplicate
})
require.Len(t, got, 3) // duplicate collapsed
// missing-header (0) sorts before policy-violation (2); within, by path then message.
assert.Equal(t, model.FailOnMissingHeader, got[0].Condition)
assert.Equal(t, "m1", got[0].Message)
assert.Equal(t, "m2", got[1].Message)
assert.Equal(t, model.FailOnPolicyViolation, got[2].Condition)
}
func TestRenderTextViolationDetails(t *testing.T) {
out := renderToString(t, reportWithViolations(), FormatText, RenderOptions{})
assert.Contains(t, out, "policy violations:")
assert.Contains(t, out, "[missing-header] src/legacy.go: no managed license header")
assert.Contains(t, out, `[policy-violation] web/c.ts: license "AGPL-3.0-or-later" is not on the allow list`)
}
func TestRenderMarkdownViolationDetails(t *testing.T) {
out := renderToString(t, reportWithViolations(), FormatMarkdown, RenderOptions{})
assert.Contains(t, out, "## Policy violations")
assert.Contains(t, out, "| Condition | License | Location | Detail |")
assert.Contains(t, out, "| policy-violation | `AGPL-3.0-or-later` | web/c.ts |")
}
func TestRenderJSONViolationDetails(t *testing.T) {
out := renderToString(t, reportWithViolations(), FormatJSON, RenderOptions{})
var raw map[string]any
require.NoError(t, json.Unmarshal([]byte(out), &raw))
vd, ok := raw["violationDetails"].([]any)
require.True(t, ok)
require.Len(t, vd, 2)
first := vd[0].(map[string]any)
assert.Equal(t, "missing-header", first["condition"])
assert.Contains(t, first, "message")
}
// optionsFixture builds a realistic report (counts populated by Build) with headered,
// headerless, and skipped source files plus resolved/unresolved dependencies.
func optionsFixture() model.Report {
files := []model.FileResult{
headered("src/a.go", "Go", "MIT"),
{Path: "src/b.go", FileType: "Go"}, // headerless
{Path: "img/logo.png", FileType: "", Skipped: true, SkipReason: "binary"}, // skipped
}
deps := []model.DependencyLicense{
{Ecosystem: "npm", Name: "left-pad", Version: "1.0.0", SPDXID: "MIT", Resolution: model.ResolutionResolved},
{Ecosystem: "maven", Name: "foo", Resolution: model.ResolutionUnresolved, Reason: "no metadata"},
}
return Build("/root", model.Config{License: "MIT", Holder: "Acme", Style: model.StyleReuse}, files, deps, nil)
}
func renderToString(t *testing.T, r model.Report, format Format, opts RenderOptions) string {
t.Helper()
var b bytes.Buffer
require.NoError(t, RenderWithOptions(&b, r, format, opts))
return b.String()
}
func TestGroupDimensionString(t *testing.T) {
assert.Equal(t, "none", GroupNone.String())
assert.Equal(t, "license", GroupLicense.String())
assert.Equal(t, "category", GroupCategory.String())
assert.Equal(t, "type", GroupType.String())
assert.Equal(t, "directory", GroupDirectory.String())
}
func TestTitleCase(t *testing.T) {
assert.Equal(t, "", titleCase(""))
assert.Equal(t, "License", titleCase("license"))
}
func TestRenderTextSummaryOmitsLists(t *testing.T) {
out := renderToString(t, optionsFixture(), FormatText, RenderOptions{Summary: true})
assert.Contains(t, out, "by SPDX id:") // rollups stay
assert.Contains(t, out, "findings:") // findings stays
// The flat list header sits at the line start; the findings line is indented.
assert.NotContains(t, out, "\nsource files:")
assert.NotContains(t, out, "left-pad") // dependency list omitted
}
func TestRenderTextGroupBy(t *testing.T) {
out := renderToString(t, optionsFixture(), FormatText, RenderOptions{GroupBy: GroupLicense})
assert.Contains(t, out, "source files by license:")
assert.Contains(t, out, "MIT (1)")
assert.Contains(t, out, " src/a.go") // nested file line
assert.Contains(t, out, "(no-header) (1)") // headerless bucket
assert.Contains(t, out, "(skipped: 1)") // skipped note
assert.Contains(t, out, "left-pad") // deps list still shown (not summary)
}
func TestRenderTextGroupBySummaryCountsOnly(t *testing.T) {
out := renderToString(t, optionsFixture(), FormatText, RenderOptions{Summary: true, GroupBy: GroupDirectory})
assert.Contains(t, out, "source files by directory:")
assert.Contains(t, out, "src (2)") // src/a.go + src/b.go
assert.NotContains(t, out, " src/a.go") // no file paths under summary
assert.NotContains(t, out, "left-pad") // deps omitted
}
func TestRenderTextGroupByEmpty(t *testing.T) {
// A report with only skipped files yields no groups but still a skipped note.
r := model.Report{Files: []model.FileResult{{Path: "a.bin", Skipped: true, SkipReason: "binary"}}}
out := renderToString(t, r, FormatText, RenderOptions{GroupBy: GroupType})
assert.Contains(t, out, "(no managed source files)")
assert.Contains(t, out, "(skipped: 1)")
}
func TestRenderMarkdownGroupBy(t *testing.T) {
out := renderToString(t, optionsFixture(), FormatMarkdown, RenderOptions{GroupBy: GroupLicense})
assert.Contains(t, out, "## Source files by license")
assert.Contains(t, out, "### `MIT` (1)")
assert.Contains(t, out, "| `src/a.go` |")
assert.Contains(t, out, "_Skipped (uneditable): 1_")
}
func TestRenderMarkdownSummaryGroupBy(t *testing.T) {
out := renderToString(t, optionsFixture(), FormatMarkdown, RenderOptions{Summary: true, GroupBy: GroupLicense})
assert.Contains(t, out, "| License | Files |")
assert.Contains(t, out, "| `MIT` | 1 |")
assert.NotContains(t, out, "| `src/a.go` |")
}
func TestRenderMarkdownSummaryOmitsTables(t *testing.T) {
out := renderToString(t, optionsFixture(), FormatMarkdown, RenderOptions{Summary: true})
assert.Contains(t, out, "## By SPDX id")
assert.NotContains(t, out, "## Source files")
assert.NotContains(t, out, "## Dependencies")
}
func TestRenderJSONGroupBy(t *testing.T) {
out := renderToString(t, optionsFixture(), FormatJSON, RenderOptions{GroupBy: GroupLicense})
var raw map[string]any
require.NoError(t, json.Unmarshal([]byte(out), &raw))
groups, ok := raw["groups"].([]any)
require.True(t, ok, "groups array present")
require.NotEmpty(t, groups)
first := groups[0].(map[string]any)
assert.Contains(t, first, "key")
assert.Contains(t, first, "count")
assert.Contains(t, first, "files") // detail present in non-summary group-by
// Full report still carries the top-level files/dependencies.
assert.Contains(t, raw, "files")
assert.Contains(t, raw, "dependencies")
}
func TestRenderJSONIgnoresSummaryTrim(t *testing.T) {
// JSON is always the complete report: --summary only trims human formats, so a
// machine consumer still gets files, dependencies, and findings in one call.
out := renderToString(t, optionsFixture(), FormatJSON, RenderOptions{Summary: true})
var raw map[string]any
require.NoError(t, json.Unmarshal([]byte(out), &raw))
assert.Equal(t, "license-tool/report/v1", raw["schema"])
assert.Contains(t, raw, "files")
assert.Contains(t, raw, "dependencies")
assert.Contains(t, raw, "findings")
}
func TestRenderJSONSummaryGroupKeepsFiles(t *testing.T) {
out := renderToString(t, optionsFixture(), FormatJSON, RenderOptions{Summary: true, GroupBy: GroupCategory})
var raw map[string]any
require.NoError(t, json.Unmarshal([]byte(out), &raw))
first := raw["groups"].([]any)[0].(map[string]any)
assert.Contains(t, first, "count")
assert.Contains(t, first, "files") // JSON groups always carry detail
}
func TestRenderDirectoryBreakdown(t *testing.T) {
r := optionsFixture() // src/a.go (MIT), src/b.go (no header)
textOut := renderToString(t, r, FormatText, RenderOptions{GroupBy: GroupDirectory})
assert.Contains(t, textOut, "source files by directory:")
assert.Contains(t, textOut, "licenses: ") // per-group breakdown line (non-license dim)
mdOut := renderToString(t, r, FormatMarkdown, RenderOptions{GroupBy: GroupDirectory})
assert.Contains(t, mdOut, "Licenses: ")
jsonOut := renderToString(t, r, FormatJSON, RenderOptions{GroupBy: GroupDirectory})
var raw map[string]any
require.NoError(t, json.Unmarshal([]byte(jsonOut), &raw))
g := raw["groups"].([]any)[0].(map[string]any)
assert.Contains(t, g, "licenses")
assert.Contains(t, g, "risk")
}
func TestParseOnly(t *testing.T) {
got, err := ParseOnly("missing, copyleft,violations,unknown")
require.NoError(t, err)
assert.Equal(t, []OnlyFilter{OnlyMissing, OnlyCopyleft, OnlyViolations, OnlyUnknown}, got)
empty, err := ParseOnly("")
require.NoError(t, err)
assert.Empty(t, empty)
_, err = ParseOnly("bogus")
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown --only filter")
}
func TestKeepFile(t *testing.T) {
missing := model.FileResult{Path: "a.go"}
mit := headered("b.go", "Go", "MIT")
agpl := headered("c.go", "Go", "AGPL-3.0-or-later")
unknownLic := headered("d.go", "Go", "Frobnicate-9000")
violating := model.FileResult{Path: "e.go", Detected: model.DetectedHeader{Present: true, SPDXID: "MIT"}, Violations: []string{"policy-violation"}}
skipped := model.FileResult{Path: "x.bin", Skipped: true}
assert.True(t, keepFile(missing, nil)) // empty -> keep all
assert.False(t, keepFile(skipped, []OnlyFilter{OnlyMissing})) // skipped never matches
assert.True(t, keepFile(missing, []OnlyFilter{OnlyMissing}))
assert.False(t, keepFile(mit, []OnlyFilter{OnlyMissing}))
assert.True(t, keepFile(unknownLic, []OnlyFilter{OnlyUnknown}))
assert.True(t, keepFile(agpl, []OnlyFilter{OnlyCopyleft}))
assert.False(t, keepFile(mit, []OnlyFilter{OnlyCopyleft}))
assert.True(t, keepFile(violating, []OnlyFilter{OnlyViolations}))
}
func TestRenderTextOnlyFilter(t *testing.T) {
out := renderToString(t, optionsFixture(), FormatText, RenderOptions{Only: []OnlyFilter{OnlyMissing}})
assert.Contains(t, out, "src/b.go") // headerless kept
assert.NotContains(t, out, "src/a.go") // MIT-headered filtered out
}
+509 -138
View File
@@ -31,7 +31,7 @@ const Disclaimer = "This tool reports and enforces license metadata; it is not l
// empty string: an empty map key is invisible in rendered output and silently
// indistinguishable from "absent", so missing headers would never surface in the
// by-license breakdown. A named bucket makes the gap explicit and sortable.
const noLicenseKey = "(none)"
const noLicenseKey = "(no-header)"
// Format selects the output rendering.
type Format int
@@ -186,10 +186,16 @@ func Build(root string, cfg model.Config, files []model.FileResult, deps []model
// vendored curated set. Ids outside the curated rendering set classify as unknown,
// which is the honest answer: we have no vendored metadata to categorize them.
func categoryToken(id string) string {
return classifyCategory(id).String()
}
// classifyCategory maps an SPDX id to its model.Category via the vendored snapshot;
// ids outside the curated set are CategoryUnknown.
func classifyCategory(id string) model.Category {
if lic, ok := spdx.Lookup(id); ok {
return lic.Category.String()
return lic.Category
}
return model.CategoryUnknown.String()
return model.CategoryUnknown
}
// conditionFromToken maps a stable fail_on token back to its FailCondition. It is
@@ -285,6 +291,7 @@ func Audit(path string, cfg model.Config, opts Options, pipe Pipeline) (model.Re
}
files := make([]model.FileResult, 0, len(sources))
var allViolations []policy.Violation
for _, s := range sources {
fr := model.FileResult{
Path: s.Path,
@@ -309,12 +316,15 @@ func Audit(path string, cfg model.Config, opts Options, pipe Pipeline) (model.Re
}
// Per-file policy verdicts attach their stable tokens to the file so the
// by-file view and the fail-on evaluation both see them.
for _, v := range policy.EvaluateFile(cfg.Policy, fr) {
// by-file view and the fail-on evaluation both see them; the full structs are
// retained for the attributable ViolationDetails.
fileViolations := policy.EvaluateFile(cfg.Policy, fr)
for _, v := range fileViolations {
fr.Violations = append(fr.Violations, v.Token())
}
sort.Strings(fr.Violations)
fr.Violations = dedupeStrings(fr.Violations)
allViolations = append(allViolations, fileViolations...)
files = append(files, fr)
}
@@ -335,8 +345,53 @@ func Audit(path string, cfg model.Config, opts Options, pipe Pipeline) (model.Re
// Repo-level checks run over the distinct SPDX ids actually found in source.
repoViolations = append(repoViolations, policy.EvaluateRepo(cfg.Policy, distinctSourceIDs(files))...)
allViolations = append(allViolations, repoViolations...)
return Build(path, cfg, files, deps, repoViolations), nil
r := Build(path, cfg, files, deps, repoViolations)
r.ViolationDetails = toViolationDetails(allViolations)
return r, nil
}
// toViolationDetails maps the full policy.Violation set (file + dependency + repo) to
// attributable model.ViolationDetail records, sorted and de-duplicated for stable
// output. WHY the report layer owns this mapping: model stays free of a policy import,
// and the rich attribution policy already produces is preserved instead of being
// flattened to bare tokens.
func toViolationDetails(vs []policy.Violation) []model.ViolationDetail {
out := make([]model.ViolationDetail, 0, len(vs))
for _, v := range vs {
out = append(out, model.ViolationDetail{
Condition: v.Condition,
SPDXID: v.SPDXID,
Path: v.Path,
Message: v.Message,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Condition != out[j].Condition {
return out[i].Condition < out[j].Condition
}
if out[i].Path != out[j].Path {
return out[i].Path < out[j].Path
}
return out[i].Message < out[j].Message
})
return dedupeViolationDetails(out)
}
// dedupeViolationDetails removes adjacent duplicate findings; the input is pre-sorted,
// so a single linear pass suffices.
func dedupeViolationDetails(in []model.ViolationDetail) []model.ViolationDetail {
if len(in) == 0 {
return nil
}
out := in[:1]
for _, v := range in[1:] {
if v != out[len(out)-1] {
out = append(out, v)
}
}
return out
}
// distinctSourceIDs returns the sorted, unique set of SPDX ids detected across the
@@ -356,17 +411,65 @@ func distinctSourceIDs(files []model.FileResult) []string {
return ids
}
// Render writes the report in the requested format to w, prefixing the disclaimer
// where appropriate. All three renderers walk the model through a sorted view, so
// output is deterministic for identical inputs regardless of input ordering.
// RenderOptions tunes the human/JSON rendering of a report. The zero value is the
// default full report (every renderer's historical behavior).
type RenderOptions struct {
// Summary suppresses the per-file list, the per-dependency list, and pending
// diffs, leaving findings, the count rollups, and policy violations.
Summary bool
// GroupBy organizes the source-file listing under each value of a dimension
// instead of a flat list. GroupNone is the flat default.
GroupBy GroupDimension
// SortByCount orders the count rollups and groups by descending count (ties by
// key) instead of the default alphabetical key order.
SortByCount bool
// DirectoryDepth is how many leading path segments form a directory group key
// (min 1); only meaningful with GroupBy == GroupDirectory.
DirectoryDepth int
// Only restricts the source-file listing to problem files; empty lists all.
Only []OnlyFilter
}
// spec builds the GroupSpec for the current options.
func (o RenderOptions) spec() GroupSpec {
return GroupSpec{By: o.GroupBy, Depth: o.DirectoryDepth}
}
// listingReport returns r with its Files filtered by the --only set, for the
// (flat or grouped) file listing. The count rollups and findings stay derived from
// the full report, so --only narrows what is listed without distorting the totals.
func (o RenderOptions) listingReport(r model.Report) model.Report {
if len(o.Only) == 0 {
return r
}
filtered := make([]model.FileResult, 0, len(r.Files))
for _, fr := range r.Files {
if keepFile(fr, o.Only) {
filtered = append(filtered, fr)
}
}
r.Files = filtered
return r
}
// Render writes the report in the requested format to w with default options. It is
// the backward-compatible entry point; callers needing summary/group-by use
// RenderWithOptions.
func Render(w io.Writer, r model.Report, format Format) error {
return RenderWithOptions(w, r, format, RenderOptions{})
}
// RenderWithOptions writes the report honoring opts. All three renderers walk the
// model through a sorted view, so output is deterministic for identical inputs
// regardless of input ordering.
func RenderWithOptions(w io.Writer, r model.Report, format Format, opts RenderOptions) error {
switch format {
case FormatText:
return renderText(w, r)
return renderText(w, r, opts)
case FormatJSON:
return renderJSON(w, r)
return renderJSON(w, r, opts)
case FormatMarkdown:
return renderMarkdown(w, r)
return renderMarkdown(w, r, opts)
default:
return fmt.Errorf("report: cannot render unknown format %d", format)
}
@@ -410,7 +513,7 @@ func classifier(cfg model.Config) func(string) (model.FileType, bool) {
// ----- text renderer -----
func renderText(w io.Writer, r model.Report) error {
func renderText(w io.Writer, r model.Report, opts RenderOptions) error {
bw := &errWriter{w: w}
bw.printf("license-tool audit report\n")
@@ -425,50 +528,56 @@ func renderText(w io.Writer, r model.Report) error {
bw.printf("result: %s\n\n", passLabel(r.Passed))
bw.printf("by SPDX id:\n")
if len(r.LicenseCounts) == 0 {
bw.printf(" (no managed source files)\n")
}
for _, kv := range sortedCounts(r.LicenseCounts) {
bw.printf(" %-24s %d\n", kv.key, kv.count)
}
bw.printf("\n")
// The count rollups are always shown (cheap, and the core summary).
renderCountSection(bw, "by SPDX id", r.LicenseCounts, "(no managed source files)", opts.SortByCount)
renderCountSection(bw, "by category", r.CategoryCounts, "(none)", opts.SortByCount)
renderCountSection(bw, "by file type", r.FileTypeCounts, "(none)", opts.SortByCount)
bw.printf("by category:\n")
if len(r.CategoryCounts) == 0 {
bw.printf(" (none)\n")
// Source-file listing: flat by default, grouped under --group-by, omitted under
// --summary (a bare --summary with no group-by shows only the rollups above).
// Incompatibility risk is repo-wide, so derive it from the full report before
// --only narrows the listing; otherwise a filtered listing would understate a
// group's policy-aware risk.
incompatIDs := incompatibleIDs(distinctSourceIDs(r.Files))
lr := opts.listingReport(r)
switch {
case opts.GroupBy != GroupNone:
renderGroupedFiles(bw, lr, opts, incompatIDs)
case !opts.Summary:
bw.printf("source files: %d\n", len(lr.Files))
for _, fr := range sortedFiles(lr.Files) {
bw.printf(" %s\n", fileLine(fr))
}
bw.printf("\n")
}
for _, kv := range sortedCounts(r.CategoryCounts) {
bw.printf(" %-24s %d\n", kv.key, kv.count)
}
bw.printf("\n")
bw.printf("by file type:\n")
if len(r.FileTypeCounts) == 0 {
bw.printf(" (none)\n")
// Diffs and the per-dependency list are detail, suppressed in summary mode; the
// findings block already reports dependency resolution counts.
if !opts.Summary {
renderTextDiffs(bw, r.Files)
bw.printf("dependencies: %d\n", len(r.Dependencies))
for _, dep := range sortedDeps(r.Dependencies) {
bw.printf(" %s\n", depLine(dep))
}
bw.printf("\n")
}
for _, kv := range sortedCounts(r.FileTypeCounts) {
bw.printf(" %-24s %d\n", kv.key, kv.count)
renderTextViolations(bw, r)
return bw.err
}
// renderTextViolations prints the attributable findings (condition + message) when
// available, falling back to the legacy repo-level tokens otherwise.
func renderTextViolations(bw *errWriter, r model.Report) {
if len(r.ViolationDetails) > 0 {
bw.printf("policy violations:\n")
for _, v := range r.ViolationDetails {
bw.printf(" [%s] %s\n", v.Condition, v.Message)
}
bw.printf("\n")
return
}
bw.printf("\n")
// Source-vs-dependency split: source files come from Files, dependencies from
// Dependencies. Both are surfaced so the reader sees the two halves the
// requirements call out.
bw.printf("source files: %d\n", len(r.Files))
for _, fr := range sortedFiles(r.Files) {
bw.printf(" %s\n", fileLine(fr))
}
bw.printf("\n")
renderTextDiffs(bw, r.Files)
bw.printf("dependencies: %d\n", len(r.Dependencies))
for _, dep := range sortedDeps(r.Dependencies) {
bw.printf(" %s\n", depLine(dep))
}
bw.printf("\n")
if len(r.Violations) > 0 {
bw.printf("policy violations:\n")
for _, v := range r.Violations {
@@ -476,8 +585,76 @@ func renderText(w io.Writer, r model.Report) error {
}
bw.printf("\n")
}
}
return bw.err
// renderCountSection prints a "<title>:" header followed by the sorted counts, or
// emptyLabel when the map is empty.
func renderCountSection(bw *errWriter, title string, counts map[string]int, emptyLabel string, byCount bool) {
bw.printf("%s:\n", title)
if len(counts) == 0 {
bw.printf(" %s\n", emptyLabel)
bw.printf("\n")
return
}
total := sumCounts(counts)
for _, kv := range sortedCountsBy(counts, byCount) {
bw.printf(" %-24s %4d (%s)\n", kv.key, kv.count, percent(kv.count, total))
}
bw.printf(" %-24s %4d\n", "total", total)
bw.printf("\n")
}
// breakdownSummary renders a license breakdown map as "id n, id n" in key order.
func breakdownSummary(counts map[string]int) string {
parts := make([]string, 0, len(counts))
for _, kv := range sortedCounts(counts) {
parts = append(parts, fmt.Sprintf("%s %d", kv.key, kv.count))
}
return strings.Join(parts, ", ")
}
// sumCounts totals a count map.
func sumCounts(counts map[string]int) int {
total := 0
for _, v := range counts {
total += v
}
return total
}
// percent formats n as a one-decimal percentage of total ("55.6%"); 0/0 is "0.0%".
func percent(n, total int) string {
if total == 0 {
return "0.0%"
}
return fmt.Sprintf("%.1f%%", float64(n)*100/float64(total))
}
// renderGroupedFiles prints the source files grouped under opts.GroupBy. Under
// --summary it prints per-group counts only; otherwise it nests the file lines. A
// trailing note reports skipped (uneditable) files, which are never grouped.
func renderGroupedFiles(bw *errWriter, r model.Report, opts RenderOptions, incompatIDs map[string]bool) {
groups, skipped := groupFilesWith(r, opts.spec(), incompatIDs)
sortGroups(groups, opts.SortByCount)
bw.printf("source files by %s:\n", opts.GroupBy)
if len(groups) == 0 {
bw.printf(" (no managed source files)\n")
}
for _, g := range groups {
bw.printf(" %s (%d) [risk: %s]\n", g.Key, g.Count, g.Risk)
if opts.GroupBy != GroupLicense {
bw.printf(" licenses: %s\n", breakdownSummary(g.Licenses))
}
if !opts.Summary {
for _, fr := range g.Files {
bw.printf(" %s\n", fileLine(fr))
}
}
}
if skipped > 0 {
bw.printf(" (skipped: %d)\n", skipped)
}
bw.printf("\n")
}
func renderTextDiffs(bw *errWriter, files []model.FileResult) {
@@ -524,7 +701,7 @@ func depLine(dep model.DependencyLicense) string {
// ----- markdown renderer -----
func renderMarkdown(w io.Writer, r model.Report) error {
func renderMarkdown(w io.Writer, r model.Report, opts RenderOptions) error {
bw := &errWriter{w: w}
bw.printf("# license-tool audit report\n\n")
@@ -535,42 +712,53 @@ func renderMarkdown(w io.Writer, r model.Report) error {
bw.printf("- **Style:** `%s`\n", r.Config.Style)
bw.printf("- **Result:** %s\n\n", passLabel(r.Passed))
bw.printf("## By SPDX id\n\n")
bw.printf("| SPDX id | Files |\n| --- | --- |\n")
for _, kv := range sortedCounts(r.LicenseCounts) {
bw.printf("| `%s` | %d |\n", kv.key, kv.count)
}
bw.printf("\n")
renderMarkdownFindings(bw, buildFindings(r))
bw.printf("## By category\n\n")
bw.printf("| Category | Files |\n| --- | --- |\n")
for _, kv := range sortedCounts(r.CategoryCounts) {
bw.printf("| %s | %d |\n", kv.key, kv.count)
}
bw.printf("\n")
renderMarkdownCountTable(bw, "By SPDX id", "SPDX id", r.LicenseCounts, opts.SortByCount)
renderMarkdownCountTable(bw, "By category", "Category", r.CategoryCounts, opts.SortByCount)
renderMarkdownCountTable(bw, "By file type", "File type", r.FileTypeCounts, opts.SortByCount)
bw.printf("## By file type\n\n")
bw.printf("| File type | Files |\n| --- | --- |\n")
for _, kv := range sortedCounts(r.FileTypeCounts) {
bw.printf("| %s | %d |\n", kv.key, kv.count)
incompatIDs := incompatibleIDs(distinctSourceIDs(r.Files))
lr := opts.listingReport(r)
switch {
case opts.GroupBy != GroupNone:
renderMarkdownGroups(bw, lr, opts, incompatIDs)
case !opts.Summary:
bw.printf("## Source files (%d)\n\n", len(lr.Files))
bw.printf("| Path | File type | Status |\n| --- | --- | --- |\n")
for _, fr := range sortedFiles(lr.Files) {
bw.printf("| `%s` | %s | %s |\n", fr.Path, orNone(fr.FileType), mdFileStatus(fr))
}
bw.printf("\n")
}
bw.printf("\n")
bw.printf("## Source files (%d)\n\n", len(r.Files))
bw.printf("| Path | File type | Status |\n| --- | --- | --- |\n")
for _, fr := range sortedFiles(r.Files) {
bw.printf("| `%s` | %s | %s |\n", fr.Path, orNone(fr.FileType), mdFileStatus(fr))
if !opts.Summary {
bw.printf("## Dependencies (%d)\n\n", len(r.Dependencies))
bw.printf("| Ecosystem | Name | Version | License | Resolution |\n| --- | --- | --- | --- | --- |\n")
for _, dep := range sortedDeps(r.Dependencies) {
bw.printf("| %s | `%s` | %s | `%s` | %s |\n",
dep.Ecosystem, orNone(dep.Name), orNone(dep.Version), orNone(dep.SPDXID), dep.Resolution)
}
bw.printf("\n")
}
bw.printf("\n")
bw.printf("## Dependencies (%d)\n\n", len(r.Dependencies))
bw.printf("| Ecosystem | Name | Version | License | Resolution |\n| --- | --- | --- | --- | --- |\n")
for _, dep := range sortedDeps(r.Dependencies) {
bw.printf("| %s | `%s` | %s | `%s` | %s |\n",
dep.Ecosystem, orNone(dep.Name), orNone(dep.Version), orNone(dep.SPDXID), dep.Resolution)
renderMarkdownViolations(bw, r)
return bw.err
}
// renderMarkdownViolations renders an attributable findings table (condition, license,
// location, detail) when details are present, falling back to the legacy token list.
func renderMarkdownViolations(bw *errWriter, r model.Report) {
if len(r.ViolationDetails) > 0 {
bw.printf("## Policy violations\n\n")
bw.printf("| Condition | License | Location | Detail |\n| --- | --- | --- | --- |\n")
for _, v := range r.ViolationDetails {
bw.printf("| %s | `%s` | %s | %s |\n", v.Condition, orNone(v.SPDXID), orNone(v.Path), v.Message)
}
bw.printf("\n")
return
}
bw.printf("\n")
if len(r.Violations) > 0 {
bw.printf("## Policy violations\n\n")
for _, v := range r.Violations {
@@ -578,8 +766,74 @@ func renderMarkdown(w io.Writer, r model.Report) error {
}
bw.printf("\n")
}
}
return bw.err
// renderMarkdownFindings renders the at-a-glance summary (coverage, license mix,
// risk, copyleft, dependency resolution, policy) so Markdown reports carry the same
// overview the text renderer does.
func renderMarkdownFindings(bw *errWriter, f Findings) {
bw.printf("## Findings\n\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("- **Risk:** %s\n", f.riskSummary())
bw.printf("- **Copyleft:** %s\n", f.copyleftSummary())
bw.printf("- **Unknown/unrecognized:** %d\n", f.UnknownCount)
if f.DepsScanned {
bw.printf("- **Dependencies:** %d (resolved %d, unresolved %d)\n", f.DepsTotal, f.DepsResolved, f.DepsUnresolved)
}
bw.printf("- **Policy:** %s\n\n", f.policySummary())
}
// renderMarkdownCountTable renders one count rollup as a table with a percentage
// column and a total row.
func renderMarkdownCountTable(bw *errWriter, heading, keyHeader string, counts map[string]int, byCount bool) {
bw.printf("## %s\n\n", heading)
bw.printf("| %s | Files | %% |\n| --- | --- | --- |\n", keyHeader)
total := sumCounts(counts)
for _, kv := range sortedCountsBy(counts, byCount) {
bw.printf("| `%s` | %d | %s |\n", kv.key, kv.count, percent(kv.count, total))
}
bw.printf("| **total** | **%d** | |\n\n", total)
}
// renderMarkdownGroups renders the grouped source-file view. Under --summary it emits
// a single per-group count table; otherwise a per-group heading with a file table.
func renderMarkdownGroups(bw *errWriter, r model.Report, opts RenderOptions, incompatIDs map[string]bool) {
groups, skipped := groupFilesWith(r, opts.spec(), incompatIDs)
sortGroups(groups, opts.SortByCount)
bw.printf("## Source files by %s\n\n", opts.GroupBy)
if opts.Summary {
bw.printf("| %s | Files | Risk |\n| --- | --- | --- |\n", titleCase(opts.GroupBy.String()))
for _, g := range groups {
bw.printf("| `%s` | %d | %s |\n", g.Key, g.Count, g.Risk)
}
bw.printf("\n")
} else {
for _, g := range groups {
bw.printf("### `%s` (%d) — risk: %s\n\n", g.Key, g.Count, g.Risk)
if opts.GroupBy != GroupLicense {
bw.printf("Licenses: %s\n\n", breakdownSummary(g.Licenses))
}
bw.printf("| Path | File type | Status |\n| --- | --- | --- |\n")
for _, fr := range g.Files {
bw.printf("| `%s` | %s | %s |\n", fr.Path, orNone(fr.FileType), mdFileStatus(fr))
}
bw.printf("\n")
}
}
if skipped > 0 {
bw.printf("_Skipped (uneditable): %d_\n\n", skipped)
}
}
// titleCase upper-cases the first byte of an ASCII word for a table header.
func titleCase(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
func mdFileStatus(fr model.FileResult) string {
@@ -603,17 +857,54 @@ func mdFileStatus(fr model.FileResult) string {
// decouples wire compatibility from internal refactors, and lets us emit the
// disclaimer, a schema version, and sorted aggregates explicitly.
type jsonReport struct {
Schema string `json:"schema"`
Disclaimer string `json:"disclaimer"`
Root string `json:"root"`
Passed bool `json:"passed"`
Config jsonConfig `json:"config"`
LicenseCounts map[string]int `json:"licenseCounts"`
CategoryCounts map[string]int `json:"categoryCounts"`
FileTypeCounts map[string]int `json:"fileTypeCounts"`
Files []jsonFile `json:"files"`
Dependencies []jsonDependency `json:"dependencies"`
Violations []string `json:"violations"`
Schema string `json:"schema"`
Disclaimer string `json:"disclaimer"`
Root string `json:"root"`
Passed bool `json:"passed"`
Config jsonConfig `json:"config"`
Findings jsonFindings `json:"findings"`
LicenseCounts map[string]int `json:"licenseCounts"`
CategoryCounts map[string]int `json:"categoryCounts"`
FileTypeCounts map[string]int `json:"fileTypeCounts"`
Groups []jsonGroup `json:"groups,omitempty"`
Files []jsonFile `json:"files"`
Dependencies []jsonDependency `json:"dependencies"`
Violations []string `json:"violations"`
ViolationDetails []jsonViolation `json:"violationDetails"`
}
// jsonViolation is one attributable finding in the machine schema.
type jsonViolation struct {
Condition string `json:"condition"`
SPDXID string `json:"spdxId,omitempty"`
Path string `json:"path,omitempty"`
Message string `json:"message"`
}
// jsonFindings is the machine form of the at-a-glance summary, including the explicit
// riskLevel/worstCategory so a model can branch on a value instead of parsing strings.
type jsonFindings struct {
SourceTotal int `json:"sourceTotal"`
SourceHeadered int `json:"sourceHeadered"`
SourceMissing int `json:"sourceMissing"`
UnknownCount int `json:"unknownCount"`
Copyleft []string `json:"copyleft"`
RiskLevel string `json:"riskLevel"`
WorstCategory string `json:"worstCategory,omitempty"`
DepsScanned bool `json:"depsScanned"`
DepsTotal int `json:"depsTotal"`
DepsResolved int `json:"depsResolved"`
DepsUnresolved int `json:"depsUnresolved"`
}
// jsonGroup is one grouped bucket in the machine schema. Files is omitted under
// --summary (counts only).
type jsonGroup struct {
Key string `json:"key"`
Count int `json:"count"`
Risk string `json:"risk"`
Licenses map[string]int `json:"licenses"`
Files []jsonFile `json:"files,omitempty"`
}
type jsonConfig struct {
@@ -646,56 +937,126 @@ type jsonDependency struct {
Reason string `json:"reason,omitempty"`
}
func renderJSON(w io.Writer, r model.Report) error {
out := jsonReport{
Schema: "license-tool/report/v1",
Disclaimer: Disclaimer,
Root: r.Root,
Passed: r.Passed,
Config: jsonConfig{License: r.Config.License, Holder: r.Config.Holder, Style: r.Config.Style.String()},
LicenseCounts: nonNilCounts(r.LicenseCounts),
CategoryCounts: nonNilCounts(r.CategoryCounts),
FileTypeCounts: nonNilCounts(r.FileTypeCounts),
Violations: nonNilStrings(r.Violations),
}
out.Files = make([]jsonFile, 0, len(r.Files))
for _, fr := range sortedFiles(r.Files) {
out.Files = append(out.Files, jsonFile{
Path: fr.Path,
FileType: fr.FileType,
Skipped: fr.Skipped,
SkipReason: fr.SkipReason,
HasHeader: fr.Detected.Present,
SPDXID: fr.Detected.SPDXID,
Holder: fr.Detected.Holder,
Year: fr.Detected.Year,
Action: fr.Action,
Diff: fr.Diff,
Violations: nonNilStrings(fr.Violations),
Error: fr.Err,
})
}
out.Dependencies = make([]jsonDependency, 0, len(r.Dependencies))
for _, dep := range sortedDeps(r.Dependencies) {
out.Dependencies = append(out.Dependencies, jsonDependency{
Ecosystem: dep.Ecosystem,
Name: dep.Name,
Version: dep.Version,
SPDXID: dep.SPDXID,
Resolution: dep.Resolution.String(),
Reason: dep.Reason,
})
}
func renderJSON(w io.Writer, r model.Report, opts RenderOptions) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
// Map keys are marshaled in sorted order by encoding/json, and every slice is
// pre-sorted, so the emitted JSON is byte-stable for identical reports.
incompatIDs := incompatibleIDs(distinctSourceIDs(r.Files))
lr := opts.listingReport(r)
var groups []jsonGroup
if opts.GroupBy != GroupNone {
groups = buildJSONGroups(lr, opts, incompatIDs)
}
details := toJSONViolations(r.ViolationDetails)
findings := toJSONFindings(buildFindings(r))
// JSON always emits the complete report regardless of --summary: machine consumers
// (scripts, dashboards, LLMs) want the full data in one call and slice it
// themselves. --summary only trims the human (text/markdown) renderers.
out := jsonReport{
Schema: "license-tool/report/v1",
Disclaimer: Disclaimer,
Root: r.Root,
Passed: r.Passed,
Config: jsonConfig{License: r.Config.License, Holder: r.Config.Holder, Style: r.Config.Style.String()},
Findings: findings,
LicenseCounts: nonNilCounts(r.LicenseCounts),
CategoryCounts: nonNilCounts(r.CategoryCounts),
FileTypeCounts: nonNilCounts(r.FileTypeCounts),
Groups: groups,
Violations: nonNilStrings(r.Violations),
ViolationDetails: details,
}
out.Files = make([]jsonFile, 0, len(lr.Files))
for _, fr := range sortedFiles(lr.Files) {
out.Files = append(out.Files, toJSONFile(fr))
}
out.Dependencies = make([]jsonDependency, 0, len(r.Dependencies))
for _, dep := range sortedDeps(r.Dependencies) {
out.Dependencies = append(out.Dependencies, toJSONDependency(dep))
}
return enc.Encode(out)
}
// buildJSONGroups builds the machine grouping for opts.GroupBy. File detail is
// included unless --summary asks for counts only.
func buildJSONGroups(r model.Report, opts RenderOptions, incompatIDs map[string]bool) []jsonGroup {
groups, _ := groupFilesWith(r, opts.spec(), incompatIDs)
sortGroups(groups, opts.SortByCount)
out := make([]jsonGroup, 0, len(groups))
for _, g := range groups {
jg := jsonGroup{Key: g.Key, Count: g.Count, Risk: g.Risk, Licenses: g.Licenses}
jg.Files = make([]jsonFile, 0, len(g.Files))
for _, fr := range g.Files {
jg.Files = append(jg.Files, toJSONFile(fr))
}
out = append(out, jg)
}
return out
}
func toJSONFile(fr model.FileResult) jsonFile {
return jsonFile{
Path: fr.Path,
FileType: fr.FileType,
Skipped: fr.Skipped,
SkipReason: fr.SkipReason,
HasHeader: fr.Detected.Present,
SPDXID: fr.Detected.SPDXID,
Holder: fr.Detected.Holder,
Year: fr.Detected.Year,
Action: fr.Action,
Diff: fr.Diff,
Violations: nonNilStrings(fr.Violations),
Error: fr.Err,
}
}
func toJSONFindings(f Findings) jsonFindings {
return jsonFindings{
SourceTotal: f.SourceTotal,
SourceHeadered: f.SourceHeadered,
SourceMissing: f.SourceMissing,
UnknownCount: f.UnknownCount,
Copyleft: nonNilStrings(f.Copyleft),
RiskLevel: f.RiskLevel,
WorstCategory: f.WorstCategory,
DepsScanned: f.DepsScanned,
DepsTotal: f.DepsTotal,
DepsResolved: f.DepsResolved,
DepsUnresolved: f.DepsUnresolved,
}
}
func toJSONViolations(details []model.ViolationDetail) []jsonViolation {
out := make([]jsonViolation, 0, len(details))
for _, v := range details {
out = append(out, jsonViolation{
Condition: v.Condition.String(),
SPDXID: v.SPDXID,
Path: v.Path,
Message: v.Message,
})
}
return out
}
func toJSONDependency(dep model.DependencyLicense) jsonDependency {
return jsonDependency{
Ecosystem: dep.Ecosystem,
Name: dep.Name,
Version: dep.Version,
SPDXID: dep.SPDXID,
Resolution: dep.Resolution.String(),
Reason: dep.Reason,
}
}
// ----- ordering + formatting helpers -----
type countKV struct {
@@ -714,6 +1075,16 @@ func sortedCounts(m map[string]int) []countKV {
return out
}
// sortedCountsBy returns the entries key-sorted (default) or by descending count with
// key ties broken alphabetically when byCount is set.
func sortedCountsBy(m map[string]int, byCount bool) []countKV {
out := sortedCounts(m)
if byCount {
sort.SliceStable(out, func(i, j int) bool { return out[i].count > out[j].count })
}
return out
}
// sortedFiles returns a copy of files ordered by path, the stable key for the
// per-file views. Ties on path (which should not occur for a real tree) fall back
// to FileType then detected id to stay total.
+6 -3
View File
@@ -686,12 +686,15 @@ func TestRenderMarkdown(t *testing.T) {
assert.Contains(t, out, "**License:** `MIT`")
assert.Contains(t, out, "**Holder:** Kingsrook, LLC")
assert.Contains(t, out, "**Result:** FAIL")
assert.Contains(t, out, "## Findings")
assert.Contains(t, out, "**Risk:** low (permissive)")
assert.Contains(t, out, "## By SPDX id")
assert.Contains(t, out, "| `MIT` | 3 |")
assert.Contains(t, out, "| `MIT` | 3 | 75.0% |")
assert.Contains(t, out, "## By category")
assert.Contains(t, out, "| permissive | 3 |")
assert.Contains(t, out, "| `permissive` | 3 | 75.0% |")
assert.Contains(t, out, "## By file type")
assert.Contains(t, out, "| Go | 4 |")
assert.Contains(t, out, "| `Go` | 4 | 80.0% |")
assert.Contains(t, out, "| **total** | **5** | |")
assert.Contains(t, out, "## Source files (6)")
// mdFileStatus branches.
assert.Contains(t, out, "| `a.go` | Go | MIT |")
+20 -9
View File
@@ -41,9 +41,12 @@ func normalizeSPDX(raw string) (string, bool) {
// Then try the curated alias table for the small set of non-SPDX spellings
// that appear ubiquitously in real-world metadata.
if canon, ok := spdxAliases[strings.ToLower(s)]; ok {
// Re-validate the canonical form against the vendored index so the alias
// table can never introduce an id the rest of the tool does not know.
if spdx.Validate(canon) {
// Re-validate the canonical form against the CURATED rendering set (Lookup),
// not merely the full index (Validate): a "resolved" alias must map to an id
// policy.Classify can classify, otherwise the dependency would be marked
// resolved yet be unclassifiable (a resolved-but-unknown contradiction). An
// alias whose target is valid-but-uncurated is left unresolved on purpose.
if _, ok := spdx.Lookup(canon); ok {
return canon, true
}
}
@@ -78,6 +81,20 @@ func validateSingleSPDX(s string) (string, bool) {
// spdxAliases maps lowercased, well-known non-SPDX license spellings seen in
// Maven POM <name> elements and npm package.json fields to their canonical SPDX
// id. Keys are lowercased; the map is intentionally conservative.
//
// INVARIANTS (issue #31): every entry MUST be (1) UNAMBIGUOUS -- the spelling pins
// an exact version/variant/clause-count, so no guessing is required -- and (2) a
// CURATED id (one spdx.Lookup succeeds on), so a resolved alias is always
// classifiable. Deliberately omitted because they fail (1) or (2):
//
// - "bsd" / "bsd license": ambiguous clause count (2- vs 3- vs 4-clause). A bare
// "BSD" cannot be pinned without guessing, so it stays unresolved. Only the
// explicit 3-clause spellings below are kept.
// - "gnu lesser general public license": ambiguous version (2.1 vs 3.0) AND grant
// ("only" vs "or-later"). Cannot be pinned without inventing both, so unresolved.
// - Eclipse Public License ("EPL-2.0"/"EPL-1.0"): valid SPDX ids but outside the
// curated rendering set, so policy.Classify returns Unknown. Resolving them would
// mark a dependency resolved yet unclassifiable; they stay unresolved instead.
var spdxAliases = map[string]string{
"apache 2.0": "Apache-2.0",
"apache license 2.0": "Apache-2.0",
@@ -86,16 +103,10 @@ var spdxAliases = map[string]string{
"apache-2": "Apache-2.0",
"mit license": "MIT",
"the mit license": "MIT",
"bsd": "BSD-2-Clause",
"bsd license": "BSD-2-Clause",
"new bsd license": "BSD-3-Clause",
"3-clause bsd license": "BSD-3-Clause",
"bsd 3-clause": "BSD-3-Clause",
"the bsd 3-clause license": "BSD-3-Clause",
"eclipse public license 2.0": "EPL-2.0",
"eclipse public license - v 2.0": "EPL-2.0",
"eclipse public license 1.0": "EPL-1.0",
"gnu lesser general public license": "LGPL-3.0-or-later",
"isc license": "ISC",
"the unlicense": "Unlicense",
"mozilla public license 2.0": "MPL-2.0",
+29 -1
View File
@@ -4,6 +4,9 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/KofTwentyTwo/license-tool/internal/policy"
)
// TestValidateSingleSPDXValidateFailure covers validateSingleSPDX's
@@ -43,10 +46,19 @@ func TestNormalizeSPDX(t *testing.T) {
{"alias the apache software license", "The Apache Software License, Version 2.0", "Apache-2.0", true},
{"alias mit license", "MIT License", "MIT", true},
{"alias bsd 3-clause", "New BSD License", "BSD-3-Clause", true},
{"alias eclipse 2.0", "Eclipse Public License 2.0", "EPL-2.0", true},
{"alias agpl", "GNU Affero General Public License v3.0 or later", "AGPL-3.0-or-later", true},
{"compound expression rejected", "MIT OR Apache-2.0", "", false},
{"unrecognized string", "Totally Made Up License", "", false},
// Ambiguous aliases must NOT be guessed: a bare "BSD" cannot pick a clause
// count and a bare LGPL name cannot pick a version/grant. They stay unresolved.
{"ambiguous bsd no clause count", "BSD", "", false},
{"ambiguous bsd license no clause count", "BSD License", "", false},
{"ambiguous lgpl no version or grant", "GNU Lesser General Public License", "", false},
// Non-curated targets must NOT resolve: EPL ids pass spdx.Validate but are
// outside the curated rendering set, so policy.Classify would return Unknown,
// a resolved-but-unclassifiable contradiction. They stay unresolved.
{"non-curated epl 2.0", "Eclipse Public License 2.0", "", false},
{"non-curated epl 1.0", "Eclipse Public License 1.0", "", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
@@ -57,6 +69,22 @@ func TestNormalizeSPDX(t *testing.T) {
}
}
// TestSPDXAliasesAreCuratedAndClassifiable asserts every alias-table target is a
// curated SPDX id that policy.Classify can classify (never Unknown). This guards the
// invariant that a "resolved" dependency is always classifiable: an alias that mapped
// to a valid-but-uncurated id (e.g. EPL-*) would be resolved-but-unclassifiable.
func TestSPDXAliasesAreCuratedAndClassifiable(t *testing.T) {
for alias, id := range spdxAliases {
t.Run(alias, func(t *testing.T) {
gotID, ok := normalizeSPDX(alias)
assert.True(t, ok, "alias %q must resolve", alias)
assert.Equal(t, id, gotID)
assert.NotEqual(t, model.CategoryUnknown, policy.Classify(gotID),
"alias %q maps to %q which is not classifiable (not in the curated set)", alias, gotID)
})
}
}
// TestValidateSingleSPDX covers the three rejection branches (parse error,
// multi-id expression, validation failure) plus the success path. A single valid
// id succeeds; "OR"/garbage are rejected.
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Cross-compile the release archives into dist/ (the Gitea CI runs this on v* tags).
#
# Same targets, names and ldflags as .goreleaser.yaml: darwin/linux amd64+arm64 and
# windows amd64; tar.gz everywhere except Windows, which gets a zip.
#
# Usage: scripts/build-release.sh <version> (for example 1.2.3 or 1.2.3-rc.1)
set -euo pipefail
version=${1:?usage: scripts/build-release.sh <version>}
commit=$(git rev-parse HEAD)
date=$(date -u +%Y-%m-%dT%H:%M:%SZ)
ldflags="-s -w -X main.version=$version -X main.commit=$commit -X main.date=$date"
cd "$(dirname "${BASH_SOURCE[0]}")/.."
rm -rf dist && mkdir dist
for target in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64; do
os=${target%/*}
arch=${target#*/}
name=license-tool_${version}_${os}_${arch}
ext=
[[ $os == windows ]] && ext=.exe
mkdir "dist/$name"
CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -trimpath -ldflags "$ldflags" \
-o "dist/$name/license-tool$ext" ./cmd/license-tool
cp README.md LICENSE CHANGELOG.md "dist/$name/"
if [[ $os == windows ]]; then
(cd "dist/$name" && python3 -m zipfile -c "../$name.zip" ./*)
else
tar -czf "dist/$name.tar.gz" -C "dist/$name" .
fi
rm -r "dist/${name:?}"
done
ls -l dist