Compare commits

..
75 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
james.maes eb66a7616e docs: changelog for v0.3.0 2026-06-04 15:42:01 -05:00
james.maes 742c124e6e docs: record local develop merge 2026-06-04 15:08:46 -05:00
james.maes 8c789ce50c docs: record final verification gate 2026-06-04 15:04:41 -05:00
james.maes c63e2514f1 fix: update homebrew release cask 2026-06-04 14:51:28 -05:00
james.maes 03510d13d1 docs: mark audit fixes integrated 2026-06-04 14:29:12 -05:00
james.maes 36ec31d511 fix: satisfy integrated quality gates 2026-06-04 14:23:58 -05:00
james.maes 9893efdc37 merge: dependency release docs audit fixes
# Conflicts:
#	cmd/license-tool/commands.go
#	cmd/license-tool/commands_test.go
2026-06-04 12:04:51 -05:00
james.maes 0e2b665463 merge: spdx policy audit fixes
# Conflicts:
#	cmd/license-tool/commands.go
#	cmd/license-tool/commands_test.go
2026-06-04 12:02:31 -05:00
james.maes 5936852a1b merge: cli reporting audit fixes
# Conflicts:
#	cmd/license-tool/commands.go
#	cmd/license-tool/commands_test.go
2026-06-04 12:01:21 -05:00
james.maes 0232911ea9 merge: write safety audit fixes
# Conflicts:
#	internal/applier/applier.go
2026-06-04 12:00:27 -05:00
james.maes 6c482a964d merge: filetype coverage audit fixes 2026-06-04 11:59:32 -05:00
james.maes a2c764876d merge: header placement audit fixes 2026-06-04 11:59:24 -05:00
james.maes 42a3dada18 docs: expand audit fix scope 2026-06-04 11:59:14 -05:00
james.maes 9fff8debaf feat(filetype): add shebang-aware coverage
Refs #9, #10.
2026-06-04 11:58:11 -05:00
james.maes bf86694c37 fix(header): preserve leading semantic prefixes
Keep Go build constraints, CSS charset rules, and markup doctypes ahead of inserted managed headers.

Refs #6

Refs #7

Refs #8
2026-06-04 11:53:39 -05:00
james.maes c4538c6907 fix(spdx): validate renderable and policy license ids
Reject unrenderable write licenses and exact-match policy typos before execution.

Closes #19

Closes #20
2026-06-04 11:50:04 -05:00
james.maes b0b566e600 fix(write-safety): gate writes and scope commits
Refs #12, #13, #14, #15.
2026-06-04 11:49:14 -05:00
james.maes 54bf7d015a docs: document init and audit summaries
Updates README coverage for the v0.2 init wizard, non-TTY flag validation, overwrite guard, and audit findings summary.

Refs #11
2026-06-04 11:49:13 -05:00
james.maes 3e18e6c8d5 fix: harden release and dependency docs
Pins release workflow actions and tools, discovers nested dependency manifests, clarifies Gradle tool-tier behavior, and refreshes public support/security scaffolding.

Refs #21 #22 #23 #26
2026-06-04 11:41:43 -05:00
james.maes 5d862308b5 fix(header): preserve coding equals before headers
Share the managed-header sentinel and preserve-boundary rules across detect and render.

Refs #24

Refs #27
2026-06-04 11:41:39 -05:00
james.maes 7d2bf46d0a fix: honor cli reporting contract
Maps CLI exit codes, restores check/audit rendering controls, supports dependency skip alias, and rejects invalid resolver tiers. Covers #16, #17, #18, #25.
2026-06-04 11:41:29 -05:00
james.maes 926e59c0fd docs: plan audit fixes 2026-06-04 11:32:24 -05:00
87 changed files with 10143 additions and 1159 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/*
+48
View File
@@ -0,0 +1,48 @@
name: Bug report
description: Report a reproducible license-tool failure or incorrect result.
title: "bug: "
labels:
- bug
body:
- type: markdown
attributes:
value: |
Do not include vulnerability details in a public issue. Use SECURITY.md for private reporting.
- type: textarea
id: summary
attributes:
label: Summary
description: What happened, and what did you expect instead?
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Reproduction
description: Minimal commands, config, and fixture files needed to reproduce.
placeholder: |
license-tool audit ./repo --format json
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: Output from license-tool version.
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: OS, shell, package manager, and relevant ecosystem tooling.
validations:
required: false
- type: textarea
id: logs
attributes:
label: Output
description: Relevant command output, with secrets removed.
render: text
validations:
required: false
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/KofTwentyTwo/license-tool/security/policy
about: Report vulnerabilities privately through the repository security policy.
- name: Releases
url: https://github.com/KofTwentyTwo/license-tool/releases
about: Download the latest release and inspect published changelogs.
@@ -0,0 +1,34 @@
name: Feature request
description: Propose a new capability or behavior change.
title: "feat: "
labels:
- enhancement
body:
- type: textarea
id: problem
attributes:
label: Problem
description: What workflow or limitation should this address?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposal
description: Describe the behavior you want license-tool to provide.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives
description: Existing workarounds or approaches you considered.
validations:
required: false
- type: textarea
id: context
attributes:
label: Additional context
description: Repos, ecosystems, policies, or output formats affected.
validations:
required: false
+15
View File
@@ -0,0 +1,15 @@
## Summary
-
## Verification
- [ ] `gofmt -l .`
- [ ] `go vet ./...`
- [ ] `golangci-lint run`
- [ ] `go test ./... -race -cover`
- [ ] `go run github.com/vladopajic/go-test-coverage/v2@v2.18.8 --config=.testcoverage.yml`
## Notes
-
-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@v6
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@v6
with:
go-version: "1.26"
cache: true
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
version: latest
args: release --clean
env:
# GITHUB_TOKEN publishes the GitHub Release in this repo.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# HOMEBREW_TAP_TOKEN pushes the generated formula to KofTwentyTwo/homebrew-tap.
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
+11 -7
View File
@@ -59,20 +59,24 @@ release:
changelog:
use: github
brews:
homebrew_casks:
- name: license-tool
# Write the formula under Formula/ (Homebrew's convention and where brew looks);
# the default empty directory drops it at the tap root, where brew ignores it.
directory: Formula
ids:
- license-tool
binaries:
- license-tool
repository:
owner: KofTwentyTwo
name: homebrew-tap
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
homepage: https://github.com/KofTwentyTwo/license-tool
description: "Audit and standardize license headers and metadata across codebases"
license: MIT
commit_author:
name: github-actions[bot]
email: 41898282+github-actions[bot]@users.noreply.github.com
test: |
system "#{bin}/license-tool", "version"
hooks:
post:
install: |
if OS.mac?
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/license-tool"]
end
+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$
+65
View File
@@ -7,6 +7,71 @@ 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.
- Added GitHub issue templates, a pull request template, support guidance, and refreshed security-policy documentation.
### Changed
- Aligned init/apply/license SPDX validation with the licenses that can actually be rendered by the bundled SPDX data.
- Validated policy `required`, `allow`, and `deny` entries as exact SPDX identifiers, matching the current exact-match policy engine.
- Extended dependency audit discovery to nested manifests while preserving ignore and exclude behavior.
- Made Gradle tool-tier dependency resolution report an explicit unsupported reason.
- Pinned release workflow actions and GoReleaser, and updated release packaging to publish the Homebrew cask.
### Fixed
- Preserve leading Go build constraints, CSS `@charset` rules, and markup doctypes before inserted headers so `apply` does not silently change build selection, stylesheet encoding, or browser rendering mode.
- Preserve Python `coding=` and `coding:` pragmas before inserted headers.
- Centralized header sentinel and placement logic so audit and render paths agree on preserve-first rules.
- Made the `license` command use the same write safety gates and commit support as `apply`.
- Scoped apply/license commits to files touched by the license run so unrelated dirty working-tree changes are not staged.
- Made `apply` honor include and gitignore scope controls before writing files.
- Rendered unified diffs for dry-run `apply` and `license` runs, including JSON diff entries only when present.
- Restored CLI error output and documented exit-code behavior.
- Made `check --fail-on` override policy fail conditions, including repeated and comma-separated flag values.
- Honored audit/check `--output` controls, rendered check reports, and bound the documented `--no-deps` flag.
- Rejected invalid `--resolve-deps` values instead of silently falling back to `ondisk`.
## [0.2.1] - 2026-06-04
### Fixed
+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`.
+7
View File
@@ -0,0 +1,7 @@
# Developers
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.
+122 -8
View File
@@ -11,7 +11,7 @@ The tool has two halves: an audit half that reports the licenses in use across d
Homebrew releases are configured through GoReleaser:
```bash
brew install KofTwentyTwo/tap/license-tool
brew install --cask KofTwentyTwo/tap/license-tool
```
From source:
@@ -52,11 +52,102 @@ license-tool audit
license-tool audit ./some/repo
license-tool audit --format json --output audit.json
license-tool audit --format markdown --output LICENSE-AUDIT.md
license-tool audit --no-deps
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,
the policy result, and dependency resolution counts when dependencies are found.
```text
findings:
source files: 12 (headered 10, missing 2)
license types: AGPL-3.0-or-later 10, none 2
unknown/unrecognized: 0
copyleft: AGPL-3.0-or-later
dependencies: 8 (resolved 6, unresolved 2)
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`.
The default `ondisk` tier reads already-resolved metadata. The `tool` tier
currently augments Maven resolution only; npm uses installed `node_modules`
metadata, and Gradle remains detect-only with an explicit unsupported-tool-tier
reason in the dependency report.
### check
@@ -93,12 +184,29 @@ 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 this prompts for missing required fields; in CI or a non-TTY it hard-errors rather than hanging.
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,
and validates through the same gate. Missing or invalid license and holder values
are usage errors. If `.license-tool.yaml` already exists, `init` refuses to
overwrite it unless `--force` is passed.
### Shared flags
```text
@@ -106,8 +214,6 @@ license-tool init
--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
@@ -122,7 +228,7 @@ license-tool init
## 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. Missing required fields (license, holder) prompt on a TTY and hard-error in CI.
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)
@@ -131,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"
@@ -161,14 +269,20 @@ The risky code paths are covered by tests:
- Header detection (recognize an existing license header; never touch a non-license doc comment).
- Audit/check policy and exit-code matrix, including the `unresolved` dependency path.
## Support
Use [GitHub Issues](https://github.com/KofTwentyTwo/license-tool/issues) for public
bug reports, feature requests, and documentation defects. Do not report security
vulnerabilities in public issues; follow [SECURITY.md](SECURITY.md) instead.
## Release Automation
Release automation is handled by GoReleaser.
- Branch pushes and PRs run CI.
- Pushes to `develop`, `release/*`, `rc/*`, and `main` also upload branch build artifacts.
- Version tags matching `vX.Y.Z` create production GitHub Releases and publish the Homebrew formula.
- Version tags matching `vX.Y.Z` create production GitHub Releases and publish the Homebrew cask.
- Version tags carrying an rc or prerelease suffix create GitHub prereleases.
- The Homebrew formula publishes to `KofTwentyTwo/homebrew-tap`, which requires the `HOMEBREW_TAP_TOKEN` repository secret.
- The Homebrew cask publishes to `KofTwentyTwo/homebrew-tap`, which requires the `HOMEBREW_TAP_TOKEN` repository secret.
See [RELEASING.md](RELEASING.md) for the full flow.
+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.
+24 -45
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
@@ -37,8 +44,9 @@ git ls-remote --heads origin
./scripts/verify-release.sh
```
It runs, in order: format/vet/lint/test, `goreleaser check`, a
`goreleaser release --snapshot --clean` dry run, tap repo and token existence,
It runs, in order: formatting, vet, `golangci-lint`, race tests with the
same coverage profile used by CI, the 100% coverage checker, `goreleaser check`,
a `goreleaser release --snapshot --clean` dry run, tap repo and token existence,
the CHANGELOG date check, the SECURITY/README content checks, and a clean-tree check.
You can run the individual GoReleaser gates directly:
@@ -47,7 +55,8 @@ git ls-remote --heads origin
gofmt -l .
go vet ./...
golangci-lint run
go test ./... -race -cover
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
goreleaser check
goreleaser release --snapshot --clean
```
@@ -66,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
@@ -77,51 +86,21 @@ From `main`:
2. Commit changelog changes:
```bash
git commit -am "Update CHANGELOG for v$VERSION"
git commit -am "docs: changelog for v$VERSION"
```
3. Tag and push:
3. Push `main` and wait for CI to pass on the release commit.
4. Tag and push. Prefer a signed annotated tag:
```bash
git tag vX.Y.Z
git tag -s vX.Y.Z -m "vX.Y.Z"
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 formula 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 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 formula 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`.
+12 -8
View File
@@ -4,20 +4,24 @@
| Version | Supported |
| ------- | --------- |
| 1.0.x | yes |
| 0.x | no |
| Latest published release | yes |
| Older releases | no |
| Unreleased branch builds and snapshots | no |
`license-tool` is currently pre-1.0. Security fixes are released on the latest
published release line, and users should upgrade to the newest GitHub Release when
a fix is published.
## Reporting A Vulnerability
**Do not open a public GitHub issue for security vulnerabilities.**
Instead, please use one of the following methods:
Use GitHub's private vulnerability reporting flow from the repository security
policy page:
1. **GitHub Security Advisories**: Create a private security advisory at
`https://github.com/KofTwentyTwo/license-tool/security/advisories`
2. **PGP Encryption**: If you have the maintainer's PGP public key,
encrypt your report and send it via email.
```text
https://github.com/KofTwentyTwo/license-tool/security/policy
```
### What To Include
+14
View File
@@ -0,0 +1,14 @@
# Support
Use GitHub Issues for public support requests:
```text
https://github.com/KofTwentyTwo/license-tool/issues
```
Open a bug report for reproducible failures, incorrect audit output, release
installation problems, or documentation defects. Open a feature request for new
ecosystem support, policy/reporting changes, or workflow improvements.
For security vulnerabilities, do not include details in a public issue. Follow
the private reporting instructions in [SECURITY.md](SECURITY.md).
+320 -90
View File
@@ -1,8 +1,14 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"sort"
"strings"
"github.com/spf13/cobra"
"golang.org/x/term"
@@ -11,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"
@@ -25,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
@@ -48,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),
@@ -75,8 +78,49 @@ type auditFlags struct {
format string
output string
deps bool
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 {
return f.deps && !f.noDeps
}
func newAuditCmd(shared *sharedFlags) *cobra.Command {
@@ -89,22 +133,32 @@ func newAuditCmd(shared *sharedFlags) *cobra.Command {
path := argPath(args)
cfg, err := config.Resolve(path, sharedToFlags(shared), config.Options{Interactive: false})
if err != nil {
return err
return usageError(err)
}
format, err := report.ParseFormat(f.format)
if err != nil {
return err
return usageError(err)
}
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.deps,
IncludeDeps: f.includeDeps(),
ResolveDeps: f.resolveDeps,
AllowToolShellOut: f.resolveDeps == "tool",
}, buildAuditPipeline(cfg, shared))
if err != nil {
return internalError(err)
}
if err := renderCommandReport(cmd, f.output, r, format, renderOpts); err != nil {
return err
}
return report.Render(cmd.OutOrStdout(), r, format)
return nil
},
}
bindAuditFlags(cmd, f, false)
@@ -121,23 +175,40 @@ func newCheckCmd(shared *sharedFlags) *cobra.Command {
path := argPath(args)
cfg, err := config.Resolve(path, sharedToFlags(shared), config.Options{Interactive: false})
if err != nil {
return err
return usageError(err)
}
if cmd.Flags().Changed("fail-on") {
failOn, parseErr := parseFailOnFlags(f.failOn)
if parseErr != nil {
return usageError(parseErr)
}
cfg.Policy.FailOn = failOn
}
format, err := report.ParseFormat(f.format)
if err != nil {
return err
return usageError(err)
}
code, err := report.Check(path, cfg, report.Options{
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.deps,
IncludeDeps: f.includeDeps(),
ResolveDeps: f.resolveDeps,
AllowToolShellOut: f.resolveDeps == "tool",
}, buildAuditPipeline(cfg, shared))
if err != nil {
return internalError(err)
}
if err := renderCommandReport(cmd, f.output, r, format, renderOpts); err != nil {
return err
}
if code != 0 {
os.Exit(code)
if !r.Passed {
return withExitCode(exitCheckFailure, errors.New("check failed: policy conditions were met"))
}
return nil
},
@@ -153,7 +224,13 @@ func bindAuditFlags(cmd *cobra.Command, f *auditFlags, isCheck bool) {
cmd.Flags().StringVar(&f.format, "format", defaultFormat, "output format: text|json|markdown")
cmd.Flags().StringVar(&f.output, "output", "", "write report to file instead of stdout")
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")
}
@@ -165,6 +242,7 @@ type applyFlags struct {
holder string
year string
style string
format string
write bool
allowDirty bool
force bool
@@ -182,26 +260,35 @@ func newApplyCmd(shared *sharedFlags) *cobra.Command {
path := argPath(args)
cfg, err := config.Resolve(path, applyToFlags(shared, f), config.Options{Interactive: isTTY(), RequireApply: true})
if err != nil {
return err
return usageError(err)
}
// config.Resolve already validates the merged license (which equals f.license
// whenever the flag is set) against the vendored SPDX list, so no second check
// is needed here.
format, err := report.ParseFormat(f.format)
if err != nil {
return usageError(err)
}
// config.Resolve already validates that the merged license can be rendered
// for write operations, so no second check is needed here.
r, err := applier.Apply(path, cfg, applier.Options{
Write: f.write,
Includes: cfg.Includes,
AllowDirty: f.allowDirty,
Force: f.force,
NoGitignore: shared.noGitignore,
Commit: f.commit,
CommitMessage: f.commitMessage,
ManageLicenseFile: cfg.ManageLicenseFile,
})
if err != nil {
return err
return writeOrInternalError(err)
}
return report.Render(cmd.OutOrStdout(), r, report.FormatText)
if err := report.Render(cmd.OutOrStdout(), r, format); err != nil {
return internalError(err)
}
return nil
},
}
bindApplyFlags(cmd, f)
cmd.Flags().StringVar(&f.format, "format", "text", "output format: text|json|markdown")
return cmd
}
@@ -215,22 +302,29 @@ func newLicenseCmd(shared *sharedFlags) *cobra.Command {
path := argPath(args)
cfg, err := config.Resolve(path, applyToFlags(shared, f), config.Options{Interactive: isTTY(), RequireApply: true})
if err != nil {
return err
return usageError(err)
}
// config.Resolve already validates the merged license (which equals f.license
// whenever the flag is set) against the vendored SPDX list, so no second check
// is needed here.
results, err := applier.ManageLicenseFiles(path, cfg, applier.Options{
// config.Resolve already validates that the merged license can be rendered
// for write operations, so no second check is needed here.
results, err := applier.License(path, cfg, applier.Options{
Write: f.write,
Includes: cfg.Includes,
AllowDirty: f.allowDirty,
Force: f.force,
NoGitignore: shared.noGitignore,
Commit: f.commit,
CommitMessage: f.commitMessage,
ManageLicenseFile: true,
})
if err != nil {
return err
return writeOrInternalError(err)
}
out := cmd.OutOrStdout()
for _, fr := range results {
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", fr.Path, fr.Action)
fmt.Fprintf(out, "%s: %s\n", fr.Path, fr.Action)
if fr.Diff != "" {
fmt.Fprintln(out, strings.TrimRight(fr.Diff, "\n"))
}
}
return nil
},
@@ -239,6 +333,95 @@ func newLicenseCmd(shared *sharedFlags) *cobra.Command {
return cmd
}
func isWriteRefusal(err error) bool {
msg := err.Error()
return strings.Contains(msg, "refusing to write") || strings.Contains(msg, "already exists (use --force)")
}
func usageError(err error) error {
return withExitCode(exitUsage, err)
}
func internalError(err error) error {
return withExitCode(exitInternal, err)
}
func writeOrInternalError(err error) error {
if isWriteRefusal(err) {
return withExitCode(exitWriteRefused, err)
}
return internalError(err)
}
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, opts report.RenderOptions) error {
if output == "" {
if err := report.RenderWithOptions(cmd.OutOrStdout(), r, format, opts); err != nil {
return internalError(err)
}
return nil
}
f, err := createReportFile(output)
if err != nil {
return internalError(fmt.Errorf("report: create output %s: %w", output, err))
}
renderErr := report.RenderWithOptions(f, r, format, opts)
closeErr := f.Close()
if renderErr != nil {
return internalError(renderErr)
}
if closeErr != nil {
return internalError(fmt.Errorf("report: close output %s: %w", output, closeErr))
}
return nil
}
func parseFailOnFlags(raw []string) ([]model.FailCondition, error) {
tokens := splitFlagTokens(raw)
out := make([]model.FailCondition, 0, len(tokens))
for _, token := range tokens {
switch token {
case model.FailOnMissingHeader.String():
out = append(out, model.FailOnMissingHeader)
case model.FailOnUnknownLicense.String():
out = append(out, model.FailOnUnknownLicense)
case model.FailOnPolicyViolation.String():
out = append(out, model.FailOnPolicyViolation)
case model.FailOnUnresolvedDependency.String():
out = append(out, model.FailOnUnresolvedDependency)
default:
return nil, fmt.Errorf("check: unknown fail-on condition %q", token)
}
}
return out, nil
}
func splitFlagTokens(raw []string) []string {
var tokens []string
for _, item := range raw {
for _, token := range strings.Split(item, ",") {
token = strings.TrimSpace(token)
if token != "" {
tokens = append(tokens, token)
}
}
}
return tokens
}
func validateResolveDeps(raw string) error {
switch raw {
case "ondisk", "tool", "off":
return nil
default:
return fmt.Errorf("audit: unknown dependency resolver tier %q (expected ondisk|tool|off)", raw)
}
}
// bindApplyFlags binds the apply/license flag set shared by both commands.
func bindApplyFlags(cmd *cobra.Command, f *applyFlags) {
cmd.Flags().StringVar(&f.license, "license", "", "target SPDX license id (validated against the vendored list)")
@@ -253,45 +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 license
// or empty holder is rejected identically regardless of how the answers arrived.
func answersToConfig(a initAnswers) (model.Config, error) {
if !spdx.Validate(a.License) {
return model.Config{}, fmt.Errorf("init: %q is not a recognized SPDX license identifier", a.License)
}
if a.Holder == "" {
return model.Config{}, fmt.Errorf("init: copyright holder is required")
}
cfg := config.Defaults()
cfg.License = a.License
cfg.Holder = a.Holder
if a.Year != "" {
ys, err := config.ParseYearSpec(a.Year)
if err != nil {
return model.Config{}, err
}
cfg.Year = ys
}
if a.Style != "" {
st, err := config.ParseStyle(a.Style)
if err != nil {
return model.Config{}, err
}
cfg.Style = st
}
cfg.ManageLicenseFile = a.ManageLicenseFile
cfg.Excludes = a.Excludes
return cfg, nil
}
func newInitCmd(shared *sharedFlags) *cobra.Command {
f := &applyFlags{}
cmd := &cobra.Command{
@@ -301,24 +449,27 @@ 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 {
return err
}
cfg, err := answersToConfig(a)
a, err := interactiveCollect(path, a, isTTY())
if err != nil {
return err
return usageError(err)
}
cfg, err := initwizard.Translate(a, initwizard.TranslateOptions{})
if err != nil {
return usageError(err)
}
target, err := config.WriteFile(path, cfg, f.force)
if err != nil {
return err
return writeOrInternalError(err)
}
fmt.Fprintf(out, "wrote %s\n", target)
return nil
@@ -350,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.LookupFunc(cfg)
classify := config.ContentLookupFunc(cfg)
return report.Pipeline{
Enumerate: func(root string, excludes []string) ([]report.SourceFile, error) {
entries, err := enumerate.Enumerate(root, enumerate.Options{
Includes: shared.include,
entries, err := enumerate.WithContent(root, enumerate.Options{
Includes: cfg.Includes,
Excludes: excludes,
NoGitignore: shared.noGitignore,
}, classify)
@@ -370,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
@@ -385,19 +548,86 @@ func buildAuditPipeline(cfg model.Config, shared *sharedFlags) report.Pipeline {
},
Detect: detect.Detect,
ResolveDeps: func(root string, allowToolShellOut bool) ([]model.DependencyLicense, error) {
var out []model.DependencyLicense
for _, r := range resolve.Detected(root) {
deps, rerr := r.Resolve(root, model.ResolveOptions{AllowToolShellOut: allowToolShellOut})
if rerr != nil {
return nil, rerr
}
out = append(out, deps...)
}
return out, nil
return resolveDependencyManifests(root, cfg.Excludes, shared.noGitignore, allowToolShellOut)
},
}
}
var dependencyManifestFileType = model.FileType{Name: "Dependency manifest"}
var dependencyManifestNames = map[string]struct{}{
"build.gradle": {},
"build.gradle.kts": {},
"package.json": {},
"pom.xml": {},
}
var dependencyDiscoveryPrunes = []string{
".gradle/",
"**/.gradle/",
"build/",
"**/build/",
"dist/",
"**/dist/",
"node_modules/",
"**/node_modules/",
"vendor/",
"**/vendor/",
}
func resolveDependencyManifests(root string, excludes []string, noGitignore bool, allowToolShellOut bool) ([]model.DependencyLicense, error) {
prunes := append([]string{}, dependencyDiscoveryPrunes...)
prunes = append(prunes, excludes...)
entries, err := enumerate.Enumerate(root, enumerate.Options{
Excludes: prunes,
NoGitignore: noGitignore,
}, dependencyManifestClassifier)
if err != nil {
return nil, err
}
var out []model.DependencyLicense
for _, dir := range dependencyManifestDirs(root, entries) {
for _, r := range resolve.Detected(dir) {
deps, rerr := r.Resolve(dir, model.ResolveOptions{AllowToolShellOut: allowToolShellOut})
if rerr != nil {
return nil, rerr
}
out = append(out, deps...)
}
}
return out, nil
}
func dependencyManifestClassifier(path string) (model.FileType, bool) {
if _, ok := dependencyManifestNames[filepath.Base(path)]; !ok {
return model.FileType{}, false
}
return dependencyManifestFileType, true
}
func dependencyManifestDirs(root string, entries []enumerate.Entry) []string {
seen := map[string]struct{}{}
for _, entry := range entries {
if entry.Skip {
continue
}
relDir := filepath.Dir(entry.Path)
if relDir == "." {
seen[root] = struct{}{}
continue
}
seen[filepath.Join(root, filepath.FromSlash(relDir))] = struct{}{}
}
dirs := make([]string, 0, len(seen))
for dir := range seen {
dirs = append(dirs, dir)
}
sort.Strings(dirs)
return dirs
}
// sharedToFlags adapts the CLI's sharedFlags into the config package's Flags.
func sharedToFlags(s *sharedFlags) config.Flags {
return config.Flags{
File diff suppressed because it is too large. Load diff
+46 -5
View File
@@ -4,8 +4,11 @@
package main
import (
"errors"
"fmt"
"os"
"github.com/spf13/cobra"
)
// Build metadata, injected via -ldflags by GoReleaser (see .goreleaser.yaml).
@@ -15,15 +18,53 @@ var (
date = "unknown"
)
const (
exitOK = 0
exitCheckFailure = 1
exitUsage = 2
exitWriteRefused = 3
exitInternal = 4
)
func main() {
root := newRootCmd(buildInfo{version: version, commit: commit, date: date})
os.Exit(execute(root))
}
func execute(root *cobra.Command) int {
if err := root.Execute(); err != nil {
// Cobra has already printed the error; exit non-zero. Richer per-mode exit
// codes (1 check failure, 3 write refused, 4 internal) are produced inside
// the subcommand RunE bodies, which call os.Exit directly when they need a
// specific code.
os.Exit(2)
fmt.Fprintln(root.ErrOrStderr(), err)
return exitCode(err)
}
return exitOK
}
type commandError struct {
code int
err error
}
func (e commandError) Error() string {
return e.err.Error()
}
func (e commandError) Unwrap() error {
return e.err
}
func withExitCode(code int, err error) error {
if err == nil {
return nil
}
return commandError{code: code, err: err}
}
func exitCode(err error) int {
var ce commandError
if errors.As(err, &ce) {
return ce.code
}
return exitUsage
}
// buildInfo carries the ldflags-injected version metadata into the command tree so
+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 non-deprecated SPDX ids in sorted order with
// no duplicates. WHY common-first: the operator sees sensible defaults at the top
// before the long alphabetical tail they can still filter into.
func licenseSelectOptions() []huh.Option[string] {
common := spdx.CommonIDs()
seen := make(map[string]bool, len(common))
opts := make([]huh.Option[string], 0, len(common))
for _, id := range common {
seen[id] = true
opts = append(opts, huh.NewOption(id+" (common)", id))
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.IDs() {
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?
+30
View File
@@ -0,0 +1,30 @@
# PLAN: Audit Fixes
## Goal
Resolve GitHub issues #6 through #27 locally, using TDD, while preserving the 100% CI coverage gate and leaving the branch ready for a manual review pass before any push or release.
## Approach
Work on `feature/GH-12-27-audit-fixes` and split fixes into isolated worktrees by concern: header boundaries, file-type coverage, write safety, CLI/reporting, policy/SPDX, dependency/release/docs, and shared header placement. Each slice starts with failing behavior tests, then minimal implementation, then local verification. Results are integrated back into this branch and finally merged to local `develop` only after the full test and CI-equivalent gate passes.
## Files Affected
- `cmd/license-tool/*`: CLI output, exit codes, command behavior tests, wizard filtering.
- `internal/applier/*`: write gates, scoped enumeration, dry-run diffs, scoped commits.
- `internal/report/*`: report rendering for apply/check/audit outputs.
- `internal/config/*`: flag/config layering and policy ID validation.
- `internal/spdx/*`: renderable license contract.
- `internal/resolve/*`: resolver tier validation and dependency manifest discovery.
- `internal/render/*`, `internal/detect/*`, `internal/header/*`: shared header placement behavior and preserve-first constructs.
- `internal/filetype/*`, `internal/enumerate/*`: file-type coverage and shebang-based classification.
- `.github/workflows/*`, `.goreleaser.yaml`: release supply-chain pinning.
- `docs/*`, `SECURITY.md`, `.github/*`: public support and status scaffolding.
## Steps
1. Create worktrees and assign disjoint issue slices.
2. For each slice, write one failing test for one user-visible behavior.
3. Implement the minimal fix and keep the slice green.
4. Integrate slices into the feature branch, resolving conflicts by preserving tested behavior.
5. Run `gofmt`, `go vet`, `golangci-lint run`, `go test ./... -race -cover`, the 100% coverage gate, `go build ./...`, and gitleaks.
6. Merge the passing feature branch back into local `develop`.
## Open Questions
- None. Push, release tags, and promotion to `main` remain out of scope until manual review and explicit approval.
+29
View File
@@ -0,0 +1,29 @@
# Session State
**Last Updated:** 2026-06-09
## Current Status
**v0.4.0 is RELEASED and verified.** Nothing in progress. Clean stopping point.
Resume on `develop` (default branch). Working tree clean.
## 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`.
## 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 `//`.
+15
View File
@@ -0,0 +1,15 @@
# TODO
## 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
## 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).
+11 -11
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`).
@@ -113,7 +113,7 @@ Repo `github.com/KofTwentyTwo/license-tool`, MIT, Go module `github.com/KofTwent
| `release.yml` (dist-generated) | `release.yml` (GoReleaser action on `v*` tags) | same trigger pattern |
| `ci.yml` (fmt/clippy/test/build) | `ci.yml` (gofmt/vet/golangci-lint/`go test -race`/build) | same branches, same per-OS artifact uploads |
| `.tar.xz` archives | `.tar.gz` (zip on Windows) | GoReleaser default |
| `Formula/notion-sql.rb` in tap | `Formula/license-tool.rb` (GoReleaser-generated) | same `KofTwentyTwo/homebrew-tap` |
| Homebrew tap package | `Casks/license-tool.rb` (GoReleaser-generated) | same `KofTwentyTwo/homebrew-tap` |
| `dependabot.yml` (cargo + actions) | `dependabot.yml` (`gomod` + actions) | |
| `RELEASING.md` (dist commands) | `RELEASING.md` (GoReleaser commands) | gitflow identical |
| `scripts/verify-release.sh` (cargo+dist gates, emoji) | same 6-gate shape (go+goreleaser gates, plain text) | emoji removed per rules |
@@ -151,12 +151,12 @@ archives:
format_overrides: [{goos: windows, formats: [zip]}]
checksum: {name_template: checksums.txt}
release: {prerelease: auto}
brews:
homebrew_casks:
- repository: {owner: KofTwentyTwo, name: homebrew-tap, token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"}
name: license-tool
binaries: [license-tool]
homepage: https://github.com/KofTwentyTwo/license-tool
description: "Audit and standardize license headers and metadata across codebases"
license: MIT
```
### CI / release workflows
@@ -166,15 +166,15 @@ brews:
### Required GitHub setup (same as notion-sql)
- `KofTwentyTwo/homebrew-tap` exists (confirmed; already holds `notion-sql` and `limen` formulas).
- `KofTwentyTwo/homebrew-tap` exists and receives the generated cask.
- Add Actions secret `HOMEBREW_TAP_TOKEN` (contents-write on the tap) to the `license-tool` repo.
- Branch protection on `main` and `develop`; verify with `gh auth status` and `git ls-remote`.
- `verify-release.sh` gates before tagging: format/vet/lint/test, `goreleaser check` and a `goreleaser release --snapshot --clean` dry run, tap repo + token existence, CHANGELOG date set, SECURITY/README content checks, clean working tree.
## Post-approval sequencing (deferred until you approve this design)
## Completed sequencing
1. Create the KofTwentyTwo GitHub issue describing the tool (tracking).
2. Create `github.com/KofTwentyTwo/license-tool`; scaffold the full notion-sql-equivalent skeleton (community files, CI/release workflows, `.goreleaser.yaml`, layout) and commit to `main`, then branch `develop` as the default working branch with protection on both.
3. Add the `HOMEBREW_TAP_TOKEN` Actions secret.
4. Branch `feature/GH-<n>-initial-implementation` off `develop`; implement against this design with tests; PR into `develop`.
5. Promote `develop` to `main` and tag `v0.1.0` to validate the GoReleaser + Homebrew path end to end (`brew install KofTwentyTwo/tap/license-tool`).
1. Created `github.com/KofTwentyTwo/license-tool` with `develop` as the working branch.
2. Scaffolded community files, CI/release workflows, `.goreleaser.yaml`, and the Go package layout.
3. Added GoReleaser release automation for GitHub Releases and the Homebrew cask path.
4. Shipped the initial implementation and follow-up bugfixes through `v0.2.1`.
5. Current install UX: `brew install --cask KofTwentyTwo/tap/license-tool`.
+6 -7
View File
@@ -1,8 +1,7 @@
# license-tool — Requirements (the contract)
> Status: DRAFT for review. Produced from a `/grill-me` session on 2026-06-03.
> This file is the agreed scope. The companion `license-tool-design.md` says how it is built.
> Nothing here is implemented yet; repo, issue, and branch creation are deferred to your go-ahead.
> Status: implemented baseline, retained as the product contract. Produced from a `/grill-me` session on 2026-06-03 and updated as-built on 2026-06-04.
> The companion `license-tool-design.md` records the implementation shape.
## 1. Purpose
@@ -25,7 +24,7 @@ It is intentionally generic: the target license, copyright holder, year policy,
| 9 | Apply safety | **Dry-run + unified diff by default**; explicit `--write` to apply. **Clean git working tree required** (override `--allow-dirty`); **atomic** temp-then-rename writes; git is the undo; **opt-in `--commit`** makes one atomic conventional commit per repo. Never deletes non-header content. Non-git dirs require `--force` |
| 10 | Configuration | **Layered YAML**. Committed per-repo `.license-tool.yaml` declares the repo's license identity and is both the apply input and the check expectation. Precedence: flags > repo config > user/global config > built-in defaults. Interactive prompt fills missing required fields on a TTY; a **hard error (no hang) in CI/non-TTY** |
| 11 | Audit policy | **Policy-driven classification**: category-tag each license (permissive / weak-copyleft / strong-copyleft / network-copyleft / proprietary / unknown) from SPDX metadata; flag heterogeneity and a curated set of well-known hard incompatibilities; enforce a config-defined policy (allow/deny SPDX lists, required license, fail conditions). **Always prints "not legal advice."** |
| 12 | Distribution | New **public** repo `github.com/KofTwentyTwo/license-tool`, **MIT** licensed (its own code). Productized like `notion-sql` (see §4): **GoReleaser** (not cargo-dist), gitflow branches, GitHub Actions CI + release, tagged releases with artifacts, Homebrew via `KofTwentyTwo/homebrew-tap` (`brew install KofTwentyTwo/tap/license-tool`), full community scaffolding |
| 12 | Distribution | Public repo `github.com/KofTwentyTwo/license-tool`, **MIT** licensed (its own code). Productized like `notion-sql` (see §4): **GoReleaser** (not cargo-dist), gitflow branches, GitHub Actions CI + release, tagged releases with artifacts, Homebrew cask via `KofTwentyTwo/homebrew-tap` (`brew install --cask KofTwentyTwo/tap/license-tool`), full community scaffolding |
| 13 | Name | `license-tool` |
## 3. Modes (from the request)
@@ -47,12 +46,12 @@ The repo must match notion-sql's productization, translated Rust to Go (GoReleas
- Per-OS branch builds on push (macOS, Windows) uploading artifacts named `license-tool-<run_id>-<sha>-<os>`.
**Release / publishing (`.github/workflows/release.yml` + `.goreleaser.yaml`)**
- Tag matching `v[0-9]+.[0-9]+.[0-9]+*` runs GoReleaser: cross-compile mac/linux arm64+amd64 and windows amd64, archives + `checksums.txt`, GitHub Release (prerelease when the tag carries an rc/prerelease suffix), and Homebrew formula publish to `KofTwentyTwo/homebrew-tap` as `Formula/license-tool.rb`.
- Install UX: `brew install KofTwentyTwo/tap/license-tool`.
- Tag matching `v[0-9]+.[0-9]+.[0-9]+*` runs GoReleaser: cross-compile mac/linux arm64+amd64 and windows amd64, archives + `checksums.txt`, GitHub Release (prerelease when the tag carries an rc/prerelease suffix), and Homebrew cask publish to `KofTwentyTwo/homebrew-tap` as `Casks/license-tool.rb`.
- Install UX: `brew install --cask KofTwentyTwo/tap/license-tool`.
- Requires Actions secret `HOMEBREW_TAP_TOKEN` with contents-write on the tap; never hardcoded.
**Governance / community files (mirror notion-sql, adapted to this domain)**
- `README.md`, `LICENSE` (MIT), `CHANGELOG.md` (Keep a Changelog + SemVer, with `[Unreleased]`), `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md` (supported-versions table + private advisory reporting), `RELEASING.md` (gitflow + GoReleaser commands), `scripts/verify-release.sh` (pre-release gate), `.github/ISSUE_TEMPLATE/{bug_report.md,feature_request.md,config.yml}`, `.github/pull_request_template.md`, `.github/dependabot.yml` (`gomod` + `github-actions`, weekly, limit 5), `.gitignore` (Go build output, `/dist/`, env/secrets, OS/editor cruft, agent state `.serena/.claude/.codex/.agents/.cursor`, `REQUEST*.md`).
- `README.md`, `LICENSE` (MIT), `CHANGELOG.md` (Keep a Changelog + SemVer, with `[Unreleased]`), `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md` (supported-versions table + private advisory reporting), `RELEASING.md` (gitflow + GoReleaser commands), `scripts/verify-release.sh` (pre-release gate), `.github/ISSUE_TEMPLATE/{bug_report.yml,feature_request.yml,config.yml}`, `.github/pull_request_template.md`, `.github/dependabot.yml` (`gomod` + `github-actions`, weekly, limit 5), `.gitignore` (Go build output, `/dist/`, env/secrets, OS/editor cruft, agent state `.serena/.claude/.codex/.agents/.cursor`, `REQUEST*.md`).
**Divergence note:** notion-sql's `verify-release.sh` uses emoji status markers. Per your no-emoji rule, our copy will use plain `[ok]`/`[FAIL]` text unless you want a byte-for-byte emoji match.
+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=
+98 -22
View File
@@ -12,6 +12,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"time"
"github.com/hexops/gotextdiff"
@@ -32,10 +33,14 @@ import (
type Options struct {
// Write applies changes; when false the run is a dry-run producing diffs only.
Write bool
// Includes restricts source-file processing to matching globs.
Includes []string
// AllowDirty permits writing to a dirty git working tree (--allow-dirty).
AllowDirty bool
// Force permits writing in a non-git directory (--force).
Force bool
// NoGitignore disables .gitignore inheritance on the non-git walk path.
NoGitignore bool
// Commit makes one atomic conventional commit per repo after a successful write.
Commit bool
// CommitMessage is the commit message template (--commit-message); empty = default.
@@ -91,10 +96,12 @@ func Apply(path string, cfg model.Config, opts Options) (model.Report, error) {
})
}
entries, err := enumerate.Enumerate(path, enumerate.Options{
Excludes: cfg.Excludes,
Force: opts.Force,
}, config.LookupFunc(cfg))
entries, err := enumerate.WithContent(path, enumerate.Options{
Includes: opts.Includes,
Excludes: cfg.Excludes,
NoGitignore: opts.NoGitignore,
Force: opts.Force,
}, config.ContentLookupFunc(cfg))
if err != nil {
return model.Report{}, err
}
@@ -124,7 +131,9 @@ func Apply(path string, cfg model.Config, opts Options) (model.Report, error) {
continue
}
fr.Action = action
fr.Diff = diff
if !opts.Write {
fr.Diff = diff
}
if opts.Write && action != "none" && action != "skip" {
if werr := AtomicWrite(e.AbsPath, newContent); werr != nil {
fr.Err = werr.Error()
@@ -142,11 +151,7 @@ func Apply(path string, cfg model.Config, opts Options) (model.Report, error) {
}
if opts.Write && opts.Commit {
msg := opts.CommitMessage
if msg == "" {
msg = fmt.Sprintf("chore: standardize license headers to %s", cfg.License)
}
if cerr := gitutil.Commit(path, msg); cerr != nil {
if cerr := commitChangedFiles(path, cfg, opts, files, "chore: standardize license headers to %s"); cerr != nil {
return model.Report{}, cerr
}
}
@@ -154,6 +159,28 @@ func Apply(path string, cfg model.Config, opts Options) (model.Report, error) {
return report.Build(path, cfg, files, nil, nil), nil
}
// License manages only the top-level LICENSE files with the same write gate and
// scoped commit semantics as Apply.
func License(path string, cfg model.Config, opts Options) ([]model.FileResult, error) {
if opts.Write {
if err := gateWrite(path, opts); err != nil {
return nil, err
}
}
files, err := ManageLicenseFiles(path, cfg, opts)
if err != nil {
return nil, err
}
if opts.Write && opts.Commit {
if err := commitChangedFiles(path, cfg, opts, files, "chore: standardize license files to %s"); err != nil {
return nil, err
}
}
return files, nil
}
// ApplyFile renders and computes the new content for a single file in memory,
// returning the new bytes, the unified diff, and the action ("none"|"insert"|
// "replace"|"skip"). It performs no disk I/O so it is safe to unit-test; Apply
@@ -202,40 +229,53 @@ 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:
fr.Action = "insert"
}
fr.Diff = unifiedDiff(rel, existing, want)
if opts.Write {
fr.Diff = ""
}
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
@@ -248,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()
}
@@ -278,6 +329,31 @@ func AtomicWrite(path string, content []byte) error {
return os.Rename(tmpName, path)
}
func commitChangedFiles(path string, cfg model.Config, opts Options, files []model.FileResult, defaultMessage string) error {
msg := opts.CommitMessage
if msg == "" {
msg = fmt.Sprintf(defaultMessage, cfg.License)
}
return gitutil.CommitPaths(path, msg, changedPaths(files))
}
func changedPaths(files []model.FileResult) []string {
seen := map[string]bool{}
paths := make([]string, 0, len(files))
for _, fr := range files {
if fr.Err != "" || fr.Action == "none" || fr.Action == "skip" || fr.Action == "" {
continue
}
if seen[fr.Path] {
continue
}
seen[fr.Path] = true
paths = append(paths, fr.Path)
}
sort.Strings(paths)
return paths
}
// gateWrite enforces the write-safety policy: in a git repo, refuse a dirty tree
// unless --allow-dirty; outside a git repo, refuse unless --force.
func gateWrite(path string, opts Options) error {
+306 -2
View File
@@ -28,11 +28,37 @@ func goFileType() model.FileType {
CommentStyle: model.CommentStyle{Block: true, Open: "/*", Close: "*/"},
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
{Kind: model.PreserveGoBuildConstraint, Before: false},
{Kind: model.PreservePackageDecl, Before: true},
},
}
}
func cssFileType() model.FileType {
return model.FileType{
Name: "CSS",
Extensions: []string{".css"},
CommentStyle: model.CommentStyle{Block: true, Open: "/*", Close: "*/"},
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
{Kind: model.PreserveCSSCharset, Before: false},
},
}
}
func markupFileType() model.FileType {
return model.FileType{
Name: "XML/HTML",
Extensions: []string{".html"},
CommentStyle: model.CommentStyle{Block: true, Open: "<!--", Close: "-->"},
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
{Kind: model.PreserveXMLDecl, Before: false},
{Kind: model.PreserveDoctype, Before: false},
},
}
}
// phpFileType returns a block-comment PHP file type whose preserve rules mirror the
// builtin table: BOM, then a (universal) shebang, then the <?php open tag, all before
// the header. It is defined locally for the same reason goFileType is -- to avoid
@@ -50,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 {
@@ -302,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
@@ -387,6 +464,110 @@ 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
ft model.FileType
content []byte
prefix string
}{
{
name: "go build constraint",
ft: goFileType(),
content: []byte("//go:build linux\n\npackage x\n"),
prefix: "//go:build linux\n\n",
},
{
name: "css charset",
ft: cssFileType(),
content: []byte("@charset \"UTF-8\";\nbody {}\n"),
prefix: "@charset \"UTF-8\";\n",
},
{
name: "doctype",
ft: markupFileType(),
content: []byte("<!DOCTYPE html>\n<html></html>\n"),
prefix: "<!DOCTYPE html>\n",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
once, _, action1, err := ApplyFile(c.content, c.ft, agplHeaderFunc(t))
require.NoError(t, err)
require.Equal(t, "insert", action1)
assert.True(t, strings.HasPrefix(string(once), c.prefix))
twice, diff2, action2, err := ApplyFile(once, c.ft, agplHeaderFunc(t))
require.NoError(t, err)
assert.Equal(t, "none", action2)
assert.Empty(t, diff2)
assert.Equal(t, once, twice)
})
}
}
// TestApplyFileShebangPHPIdempotent is the applier-level regression guard for the
// universal-shebang fix on a BLOCK-comment type. A PHP CLI script begins with a "#!"
// line then "<?php"; the header must land after both, and a second apply must be a
@@ -519,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)
}
@@ -540,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.
@@ -606,6 +836,7 @@ func TestApplyDryRun(t *testing.T) {
root := t.TempDir()
gitInit(t, root)
writeFile(t, root, "main.go", "package main\n")
writeFile(t, root, "tool", "#!/usr/bin/env python3\nprint('ok')\n")
writeFile(t, root, "data.json", "{}\n") // uncommentable: skipped
gitAddCommit(t, root)
@@ -620,6 +851,11 @@ func TestApplyDryRun(t *testing.T) {
assert.NotEmpty(t, main.Diff)
assert.Empty(t, main.Err)
tool := findResult(t, rep.Files, "tool")
assert.Equal(t, "Python", tool.FileType)
assert.Equal(t, "insert", tool.Action)
assert.NotEmpty(t, tool.Diff)
json := findResult(t, rep.Files, "data.json")
assert.True(t, json.Skipped)
assert.Equal(t, "none", json.Action)
@@ -827,3 +1063,71 @@ func TestApplyManageLicenseFilesError(t *testing.T) {
_, err := Apply(root, agplConfig(), Options{Write: false, ManageLicenseFile: true})
require.ErrorIs(t, err, boom)
}
func TestLicenseWorkflow(t *testing.T) {
t.Run("dry-run returns managed diffs without writing", func(t *testing.T) {
root := t.TempDir()
files, err := License(root, agplConfig(), Options{})
require.NoError(t, err)
assert.NotEmpty(t, findResult(t, files, "LICENSE").Diff)
_, statErr := os.Stat(filepath.Join(root, "LICENSE"))
assert.True(t, os.IsNotExist(statErr))
})
t.Run("write gate error is surfaced", func(t *testing.T) {
_, err := License(t.TempDir(), agplConfig(), Options{Write: true})
require.Error(t, err)
assert.Contains(t, err.Error(), "non-git directory")
})
t.Run("manage error is surfaced", func(t *testing.T) {
cfg := agplConfig()
cfg.License = "NOPE-1.0"
_, err := License(t.TempDir(), cfg, Options{})
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown license")
})
t.Run("write commit succeeds with custom message", func(t *testing.T) {
root := t.TempDir()
gitInit(t, root)
writeFile(t, root, "main.go", "package main\n")
gitAddCommit(t, root)
_, err := License(root, agplConfig(), Options{
Write: true,
Commit: true,
CommitMessage: "chore: add license files",
})
require.NoError(t, err)
assert.Equal(t, "chore: add license files", gitHeadSubject(t, root))
})
t.Run("write commit with no changes errors", func(t *testing.T) {
root := t.TempDir()
gitInit(t, root)
writeFile(t, root, "main.go", "package main\n")
gitAddCommit(t, root)
_, err := License(root, agplConfig(), Options{Write: true, Commit: true})
require.NoError(t, err)
_, err = License(root, agplConfig(), Options{Write: true, Commit: true})
require.Error(t, err)
assert.Contains(t, err.Error(), "no paths to commit")
})
}
func TestChangedPaths(t *testing.T) {
files := []model.FileResult{
{Path: "z.go", Action: "insert"},
{Path: "z.go", Action: "replace"},
{Path: "none.go", Action: "none"},
{Path: "skip.go", Action: "skip"},
{Path: "empty.go"},
{Path: "err.go", Action: "insert", Err: "write failed"},
{Path: "a.go", Action: "replace"},
}
assert.Equal(t, []string{"a.go", "z.go"}, changedPaths(files))
}
+53 -11
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{
@@ -112,7 +115,8 @@ func Defaults() model.Config {
// > built-in defaults. Lower layers seed the base; each higher layer overrides
// only the fields it actually sets. After merging, required fields (license,
// holder) are prompted for on a TTY (opts.Interactive) or hard-error otherwise.
// The target license is validated against the vendored SPDX list.
// The target license is validated against the vendored SPDX list. Write operations
// additionally require a license id the tool can render from its curated templates.
func Resolve(path string, flags Flags, opts Options) (model.Config, error) {
cfg := Defaults()
@@ -162,11 +166,19 @@ func Resolve(path string, flags Flags, opts Options) (model.Config, error) {
}
}
// Validate any resolved license against the vendored SPDX index. An empty license
// is valid on a read-only run (nothing to validate). WHY here and not per-layer:
// only the final, merged value matters.
if cfg.License != "" && !spdx.Validate(cfg.License) {
return model.Config{}, fmt.Errorf("config: %q is not a recognized SPDX license identifier", cfg.License)
// Validate any resolved license against the vendored SPDX index. Write operations
// also require the curated render set because apply/license/init cannot emit text
// for every valid SPDX id. WHY here and not per-layer: only the final, merged
// value matters.
if cfg.License != "" {
if !spdx.Validate(cfg.License) {
return model.Config{}, fmt.Errorf("config: %q is not a recognized SPDX license identifier", cfg.License)
}
if opts.RequireApply {
if _, ok := spdx.Lookup(cfg.License); !ok {
return model.Config{}, fmt.Errorf("config: %q is a recognized SPDX license identifier, but license-tool cannot render it", cfg.License)
}
}
}
return cfg, nil
@@ -202,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
@@ -229,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)
}
@@ -292,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"`
@@ -358,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.
@@ -385,12 +402,25 @@ func mergeSchema(cfg *model.Config, fs fileSchema) error {
// overridden only when the file provides it.
func mergePolicy(p *model.Policy, ps policySchema) error {
if ps.Required != "" {
if !spdx.Validate(ps.Required) {
return fmt.Errorf("config: policy.required %q is not a recognized SPDX license identifier", ps.Required)
}
p.Required = ps.Required
}
if len(ps.Allow) > 0 {
for _, id := range ps.Allow {
if !spdx.Validate(id) {
return fmt.Errorf("config: policy.allow %q is not a recognized SPDX license identifier", id)
}
}
p.Allow = append([]string(nil), ps.Allow...)
}
if len(ps.Deny) > 0 {
for _, id := range ps.Deny {
if !spdx.Validate(id) {
return fmt.Errorf("config: policy.deny %q is not a recognized SPDX license identifier", id)
}
}
p.Deny = append([]string(nil), ps.Deny...)
}
if len(ps.FailOn) > 0 {
@@ -428,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
}
@@ -483,6 +516,15 @@ func LookupFunc(cfg model.Config) func(path string) (model.FileType, bool) {
return filetype.Merge(cfg.FileTypeOverrides)
}
// ContentLookupFunc returns the content-aware file-type lookup for cfg, preserving
// the same override layering as LookupFunc before falling back to shebang content.
func ContentLookupFunc(cfg model.Config) func(path string, head []byte) (model.FileType, bool) {
if len(cfg.FileTypeOverrides) == 0 {
return filetype.LookupContent
}
return filetype.MergeContent(cfg.FileTypeOverrides)
}
// ParseYearSpec parses a raw --year/config token into a model.YearSpec.
// Accepts "current", "git", "YYYY", and "YYYY-YYYY".
func ParseYearSpec(raw string) (model.YearSpec, error) {
@@ -643,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.
+211 -17
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 {
@@ -387,6 +392,31 @@ func TestLookupFunc(t *testing.T) {
})
}
func TestContentLookupFunc(t *testing.T) {
t.Run("no overrides returns builtin content lookup", func(t *testing.T) {
look := ContentLookupFunc(model.Config{})
ft, ok := look("script", []byte("#!/usr/bin/env python3\nprint('ok')\n"))
if !ok || ft.Name != "Python" {
t.Errorf("builtin content lookup failed for python shebang: ok=%v ft=%+v", ok, ft)
}
})
t.Run("overrides layered onto content lookup", func(t *testing.T) {
cfg := model.Config{
FileTypeOverrides: map[string]model.FileType{
".myext": {Name: "custom", Extensions: []string{".myext"}, CommentStyle: model.CommentStyle{LinePrefix: "// "}},
},
}
look := ContentLookupFunc(cfg)
if ft, ok := look("file.myext", []byte("#!/usr/bin/env python3\nprint('ok')\n")); !ok || ft.Name != "custom" {
t.Errorf("override content lookup failed: ok=%v ft=%+v", ok, ft)
}
if ft, ok := look("script", []byte("#!/usr/bin/env python3\nprint('ok')\n")); !ok || ft.Name != "Python" {
t.Errorf("shebang lookup lost after merge: ok=%v ft=%+v", ok, ft)
}
})
}
func TestParseFailConditions(t *testing.T) {
t.Run("all known tokens", func(t *testing.T) {
got, err := parseFailConditions([]string{"missing-header", "unknown-license", "policy-violation", "unresolved-dependency"})
@@ -429,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
`)
@@ -458,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")
@@ -484,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)
@@ -499,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) {
@@ -586,6 +648,30 @@ func TestResolveLicenseValidation(t *testing.T) {
}
})
t.Run("valid unrenderable spdx id passes read-only resolution", func(t *testing.T) {
isolateXDG(t)
repo := t.TempDir()
cfg, err := Resolve(repo, Flags{License: "Zlib", Holder: "Acme"}, Options{Interactive: false})
if err != nil {
t.Fatalf("Resolve error: %v", err)
}
if cfg.License != "Zlib" {
t.Errorf("license = %q", cfg.License)
}
})
t.Run("valid unrenderable spdx id rejected for write resolution", func(t *testing.T) {
isolateXDG(t)
repo := t.TempDir()
_, err := Resolve(repo, Flags{License: "Zlib", Holder: "Acme"}, Options{Interactive: false, RequireApply: true})
if err == nil {
t.Fatal("unrenderable SPDX id should be rejected for write resolution")
}
if !strings.Contains(err.Error(), `"Zlib"`) || !strings.Contains(err.Error(), "cannot render") {
t.Fatalf("error should name the unrenderable SPDX id, got %v", err)
}
})
t.Run("unknown spdx id rejected", func(t *testing.T) {
isolateXDG(t)
repo := t.TempDir()
@@ -603,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")
}
@@ -612,16 +698,94 @@ 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")
}
})
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")
_, err := Resolve(repo, Flags{}, Options{Interactive: false})
if err == nil {
t.Fatal("invalid policy.required should error")
}
if !strings.Contains(err.Error(), "policy.required") || !strings.Contains(err.Error(), "NOT-A-LICENSE") {
t.Fatalf("error should name policy.required and the bad value, got %v", err)
}
})
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")
_, err := Resolve(repo, Flags{}, Options{Interactive: false})
if err == nil {
t.Fatal("policy.required expression should error")
}
if !strings.Contains(err.Error(), "policy.required") || !strings.Contains(err.Error(), "MIT OR Apache-2.0") {
t.Fatalf("error should name policy.required and the bad value, got %v", err)
}
})
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")
_, err := Resolve(repo, Flags{}, Options{Interactive: false})
if err == nil {
t.Fatal("invalid policy.allow should error")
}
if !strings.Contains(err.Error(), "policy.allow") || !strings.Contains(err.Error(), "MIT AND") {
t.Fatalf("error should name policy.allow and the bad value, got %v", err)
}
})
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")
_, err := Resolve(repo, Flags{}, Options{Interactive: false})
if err == nil {
t.Fatal("policy.allow expression should error")
}
if !strings.Contains(err.Error(), "policy.allow") || !strings.Contains(err.Error(), "MIT OR Apache-2.0") {
t.Fatalf("error should name policy.allow and the bad value, got %v", err)
}
})
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")
_, err := Resolve(repo, Flags{}, Options{Interactive: false})
if err == nil {
t.Fatal("invalid policy.deny should error")
}
if !strings.Contains(err.Error(), "policy.deny") || !strings.Contains(err.Error(), "GPL-2.0-typo") {
t.Fatalf("error should name policy.deny and the bad value, got %v", err)
}
})
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")
_, err := Resolve(repo, Flags{}, Options{Interactive: false})
if err == nil {
t.Fatal("policy.deny expression should error")
}
if !strings.Contains(err.Error(), "policy.deny") || !strings.Contains(err.Error(), "GPL-2.0-only AND Apache-2.0") {
t.Fatalf("error should name policy.deny and the bad value, got %v", err)
}
})
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:
@@ -644,6 +808,32 @@ policy:
t.Errorf("policy.fail_on overridden wrong: %+v", cfg.Policy.FailOn)
}
})
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), `
license: MIT
holder: Acme
policy:
required: Zlib
allow: [MIT, Zlib]
deny: [GPL-2.0-only]
`)
cfg, err := Resolve(repo, Flags{}, Options{Interactive: false})
if err != nil {
t.Fatalf("Resolve error: %v", err)
}
if cfg.Policy.Required != "Zlib" {
t.Errorf("policy.required = %q", cfg.Policy.Required)
}
if len(cfg.Policy.Allow) != 2 || cfg.Policy.Allow[1] != "Zlib" {
t.Errorf("policy.allow should preserve valid ids: %+v", cfg.Policy.Allow)
}
if len(cfg.Policy.Deny) != 1 || cfg.Policy.Deny[0] != "GPL-2.0-only" {
t.Errorf("policy.deny should preserve valid ids: %+v", cfg.Policy.Deny)
}
})
}
func TestUserConfigPath(t *testing.T) {
@@ -783,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")
}
@@ -793,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 {
@@ -877,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)
@@ -947,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",
@@ -962,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 {
@@ -980,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)
}
@@ -1031,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)
@@ -1094,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")
}
})
+56 -115
View File
@@ -21,13 +21,14 @@ import (
"github.com/google/licensecheck"
managedheader "github.com/KofTwentyTwo/license-tool/internal/header"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/KofTwentyTwo/license-tool/internal/spdx"
)
// Sentinel is the marker license-tool writes into headers it manages, giving the
// highest-confidence signal that a leading comment is safe to replace.
const Sentinel = "license-tool:managed"
const Sentinel = managedheader.Sentinel
// preserveBoundaryFn is the seam Detect calls to compute the preserve-first
// boundary. It defaults to the real PreserveBoundary (which is incapable of
@@ -88,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.
@@ -169,53 +171,7 @@ func Detect(content []byte, ft model.FileType) (model.DetectedHeader, error) {
// then a coding pragma. Rules marked Before (package declarations) are never consumed
// here: the header precedes them.
func PreserveBoundary(content []byte, ft model.FileType) (int, error) {
pos := 0
// A UTF-8 BOM, when allowed, is always the very first bytes (U+FEFF). Written as an
// escape so the BOM byte sequence does not appear literally in this source file.
const utf8BOM = "\uFEFF"
if hasRule(ft, model.PreserveBOM, false) && strings.HasPrefix(string(content), utf8BOM) {
pos += len(utf8BOM)
}
// A leading "#!" is a kernel-level shebang valid for ANY executable script
// regardless of language, so it is preserved universally rather than gated on the
// file type listing PreserveShebang. This stays safe because the line is only
// consumed when it actually starts with "#!" (which only a real shebang does) and
// only at the very top of the file (pos 0); it can never over-consume an ordinary
// hash comment further down. All OTHER per-type rules remain gated as before.
allowShebang := true
allowXML := hasRule(ft, model.PreserveXMLDecl, false)
allowPHP := hasRule(ft, model.PreservePHPOpen, false)
allowPragma := hasRule(ft, model.PreserveCodingPragma, false)
// Consume contiguous preserve-AFTER lines in file order. We loop because several may
// stack (shebang then coding pragma). We stop at the first line that is not a
// consumable prefix; that is the header insertion point.
for pos < len(content) {
lineEnd := lineEndOffset(content, pos)
lineText := string(content[pos:lineEnd])
trimmed := strings.TrimRight(lineText, "\r\n")
consumed := false
switch {
case allowShebang && strings.HasPrefix(trimmed, "#!"):
consumed = true
case allowXML && strings.HasPrefix(strings.TrimSpace(trimmed), "<?xml"):
consumed = true
case allowPHP && strings.HasPrefix(strings.TrimSpace(trimmed), "<?php"):
consumed = true
case allowPragma && isCodingPragma(trimmed):
consumed = true
}
if !consumed {
break
}
pos = lineEnd
}
return pos, nil
return managedheader.PreserveBoundary(content, ft), nil
}
// FingerprintLicense attempts to map a block of header text to an SPDX id using the
@@ -245,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
@@ -328,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 == "" {
@@ -341,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) {
@@ -350,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
}
@@ -381,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
@@ -487,16 +449,6 @@ func extractYear(commentText string) string {
// --- small parsing helpers (WHY package-local: avoid pulling regexp for a few
// fixed-shape extractions; keeps detection allocation-light and easy to audit). ---
// hasRule reports whether ft lists a preserve rule of the given kind and direction.
func hasRule(ft model.FileType, kind model.PreserveKind, before bool) bool {
for _, r := range ft.PreserveFirst {
if r.Kind == kind && r.Before == before {
return true
}
}
return false
}
// lineEndOffset returns the offset just past the newline terminating the line at pos
// (or len(content) for the final unterminated line).
func lineEndOffset(content []byte, pos int) int {
@@ -508,17 +460,6 @@ func lineEndOffset(content []byte, pos int) int {
return len(content)
}
// isCodingPragma reports whether trimmed is an encoding pragma like
// "# -*- coding: utf-8 -*-" or "# vim: ..." style coding declaration.
func isCodingPragma(trimmed string) bool {
t := strings.TrimSpace(trimmed)
if !strings.HasPrefix(t, "#") {
return false
}
lower := strings.ToLower(t)
return strings.Contains(lower, "coding:") || strings.Contains(lower, "coding=")
}
// stripBlockInner removes leading per-line decoration ("*" continuation markers and
// surrounding whitespace) commonly used inside /* ... */ blocks, returning the inner
// text as joined lines so phrase matching sees the prose, not the box drawing.
+146 -24
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
@@ -250,6 +286,77 @@ func TestDetectPreserveBoundaryError(t *testing.T) {
assert.Equal(t, model.DetectedHeader{}, got)
}
func TestDetectFindsManagedHeaderAfterPreserveFirstConstructs(t *testing.T) {
goFT := model.FileType{
Name: "Go",
CommentStyle: model.CommentStyle{Block: true, Open: "/*", Close: "*/"},
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
{Kind: model.PreserveGoBuildConstraint, Before: false},
{Kind: model.PreservePackageDecl, Before: true},
},
}
cssFT := model.FileType{
Name: "CSS",
CommentStyle: model.CommentStyle{Block: true, Open: "/*", Close: "*/"},
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
{Kind: model.PreserveCSSCharset, Before: false},
},
}
markupFT := model.FileType{
Name: "XML/HTML",
CommentStyle: model.CommentStyle{Block: true, Open: "<!--", Close: "-->"},
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
{Kind: model.PreserveXMLDecl, Before: false},
{Kind: model.PreserveDoctype, Before: false},
},
}
cases := []struct {
name string
ft model.FileType
prefix string
header string
trailer string
}{
{
name: "go build constraint",
ft: goFT,
prefix: "//go:build linux\n\n",
header: "/*\n SPDX-License-Tool: " + Sentinel + "\n SPDX-License-Identifier: MIT\n*/\n\n",
trailer: "package main\n",
},
{
name: "css charset",
ft: cssFT,
prefix: "@charset \"UTF-8\";\n",
header: "/*\n SPDX-License-Tool: " + Sentinel + "\n SPDX-License-Identifier: MIT\n*/\n\n",
trailer: "body {}\n",
},
{
name: "doctype",
ft: markupFT,
prefix: "<!DOCTYPE html>\n",
header: "<!--\n SPDX-License-Tool: " + Sentinel + "\n SPDX-License-Identifier: MIT\n-->\n\n",
trailer: "<html></html>\n",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
content := []byte(c.prefix + c.header + c.trailer)
got, err := Detect(content, c.ft)
require.NoError(t, err)
require.True(t, got.Present)
assert.True(t, got.ViaSentinel)
assert.Equal(t, len(c.prefix), got.StartByte)
assert.Equal(t, len(c.prefix)+len(c.header), got.EndByte)
})
}
}
// --- PreserveBoundary --------------------------------------------------------
func TestPreserveBoundary(t *testing.T) {
@@ -470,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)
@@ -480,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)
@@ -549,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",
@@ -698,20 +834,6 @@ func TestExtractYear(t *testing.T) {
// --- small helpers -----------------------------------------------------------
func TestHasRule(t *testing.T) {
ft := lineFT()
assert.True(t, hasRule(ft, model.PreservePackageDecl, true))
assert.False(t, hasRule(ft, model.PreservePackageDecl, false))
assert.False(t, hasRule(ft, model.PreserveShebang, false))
}
func TestIsCodingPragma(t *testing.T) {
assert.True(t, isCodingPragma("# -*- coding: utf-8 -*-"))
assert.True(t, isCodingPragma("# coding=latin-1"))
assert.False(t, isCodingPragma("# just a comment"))
assert.False(t, isCodingPragma("not a hash line"))
}
func TestStripBlockInner(t *testing.T) {
in := "\n * line one\r\n * line two\n"
out := stripBlockInner(in)
+78 -32
View File
@@ -50,6 +50,10 @@ type Entry struct {
SkipReason string
}
// ContentClassifier classifies a path using the leading file bytes when the path
// alone is not enough, such as extensionless scripts with a shebang.
type ContentClassifier func(path string, head []byte) (model.FileType, bool)
// skip reasons surfaced to the report. WHY centralized: detect.go and report.go key
// off these exact tokens, so they are defined once to keep callers in sync.
const (
@@ -78,6 +82,18 @@ var (
// root is inside a working tree. Only outside git do we approximate those semantics
// with a manual walk, which is necessarily a smaller subset of git's behavior.
func Enumerate(root string, opts Options, classify func(path string) (model.FileType, bool)) ([]Entry, error) {
return enumerate(root, opts, func(path string, _ []byte) (model.FileType, bool) {
return classify(path)
}, false)
}
// WithContent is the content-aware variant of Enumerate. It reads the leading
// bytes once, passes them to classify, and reuses them for the binary check.
func WithContent(root string, opts Options, classify ContentClassifier) ([]Entry, error) {
return enumerate(root, opts, classify, true)
}
func enumerate(root string, opts Options, classify ContentClassifier, contentAware bool) ([]Entry, error) {
absRoot, err := filepathAbs(root)
if err != nil {
return nil, err
@@ -93,35 +109,85 @@ func Enumerate(root string, opts Options, classify func(path string) (model.File
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
}
abs := filepath.Join(absRoot, filepath.FromSlash(rel))
entry := classifyEntry(rel, abs, classify)
var entry Entry
if contentAware {
entry = classifyEntryContent(rel, abs, classify)
} else {
entry = classifyEntry(rel, abs, func(path string) (model.FileType, bool) {
ft, ok := classify(path, nil)
return ft, ok
})
}
entries = append(entries, entry)
}
return entries, nil
}
func classifyEntryContent(rel, abs string, classify ContentClassifier) Entry {
entry := Entry{Path: rel, AbsPath: abs}
info, err := os.Lstat(abs)
if err != nil {
entry.Skip = true
entry.SkipReason = reasonUnknownType
return entry
}
if info.Mode()&os.ModeSymlink != 0 {
entry.Skip = true
entry.SkipReason = reasonSymlink
return entry
}
if !info.Mode().IsRegular() {
entry.Skip = true
entry.SkipReason = reasonUnknownType
return entry
}
head, rerr := readHead(abs)
ft, ok := classify(rel, head)
if !ok {
entry.Skip = true
entry.SkipReason = reasonUnknownType
return entry
}
entry.FileType = ft
if ft.Skip {
entry.Skip = true
entry.SkipReason = reasonUncommentable
return entry
}
if rerr == nil && IsBinary(head) {
entry.Skip = true
entry.SkipReason = reasonBinary
return entry
}
return entry
}
// classifyEntry resolves one path into an Entry, applying the skip ladder in the
// order that matters: symlink (never follow) before binary (never read past a NUL)
// before type classification (unknown vs uncommentable). git ls-files can list a
@@ -193,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
@@ -100,6 +100,16 @@ func TestClassifyEntryLstatError(t *testing.T) {
assert.Equal(t, "ghost.go", entry.Path)
}
func TestClassifyEntryContentLstatError(t *testing.T) {
root := t.TempDir()
missing := filepath.Join(root, "ghost")
entry := classifyEntryContent("ghost", missing, filetype.LookupContent)
assert.True(t, entry.Skip)
assert.Equal(t, reasonUnknownType, entry.SkipReason)
assert.Equal(t, "ghost", entry.Path)
}
// TestClassifyEntryNonRegular covers the non-regular-file branch: a directory path
// (which the walk path or a submodule gitlink can surface) is skipped as unknown type
// rather than read.
@@ -113,6 +123,16 @@ func TestClassifyEntryNonRegular(t *testing.T) {
assert.Equal(t, reasonUnknownType, entry.SkipReason)
}
func TestClassifyEntryContentNonRegular(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "adir")
require.NoError(t, os.Mkdir(dir, 0o755))
entry := classifyEntryContent("adir", dir, filetype.LookupContent)
assert.True(t, entry.Skip)
assert.Equal(t, reasonUnknownType, entry.SkipReason)
}
// TestClassifyEntrySymlinkDirect covers the symlink branch in isolation (the git/walk
// integration tests skip on Windows; this keeps the unit-level branch deterministic
// on non-Windows hosts).
@@ -131,6 +151,21 @@ func TestClassifyEntrySymlinkDirect(t *testing.T) {
assert.Equal(t, reasonSymlink, entry.SkipReason)
}
func TestClassifyEntryContentSymlinkDirect(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink semantics differ on windows")
}
root := t.TempDir()
target := filepath.Join(root, "target")
require.NoError(t, os.WriteFile(target, []byte("#!/usr/bin/env python3\n"), 0o644))
link := filepath.Join(root, "link")
require.NoError(t, os.Symlink("target", link))
entry := classifyEntryContent("link", link, filetype.LookupContent)
assert.True(t, entry.Skip)
assert.Equal(t, reasonSymlink, entry.SkipReason)
}
// TestEnumerateWalkError covers the discovery-error return in Enumerate together with
// the WalkDir callback's err!=nil branch: a nonexistent root is not a git repo, so the
// walk runs and its first callback receives the stat error, which propagates out.
+80 -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) {
@@ -345,6 +342,65 @@ func TestEnumerateNonGitConfigOverrides(t *testing.T) {
assert.Equal(t, reasonUnknownType, other.SkipReason)
}
func TestWithContentDetectsExtensionlessShebang(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "tool", "#!/usr/bin/env python3\nprint('ok')\n")
entries, err := WithContent(root, Options{}, filetype.LookupContent)
require.NoError(t, err)
byPath := indexEntries(entries)
tool := byPath["tool"]
assert.False(t, tool.Skip)
assert.Equal(t, "Python", tool.FileType.Name)
}
func TestWithContentSkipAndOverrideBehavior(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "unknown-script", "#!/usr/bin/env mystery\nrun\n")
writeFile(t, root, "plain", "plain text\n")
writeFile(t, root, "package.json", "{}\n")
writeFile(t, root, "binary-tool", "#!/usr/bin/env python3\n\x00")
writeFile(t, root, "thing.myext", "#!/usr/bin/env python3\nprint('path wins')\n")
classify := filetype.MergeContent(map[string]model.FileType{
".myext": {
Name: "MyExt",
Extensions: []string{".myext"},
CommentStyle: model.CommentStyle{Block: false, LinePrefix: "// "},
},
})
entries, err := WithContent(root, Options{}, classify)
require.NoError(t, err)
byPath := indexEntries(entries)
cases := []struct {
path string
wantReason string
wantType string
}{
{"unknown-script", reasonUnknownType, ""},
{"plain", reasonUnknownType, ""},
{"package.json", reasonUncommentable, "JSON"},
{"binary-tool", reasonBinary, "Python"},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
e := byPath[tc.path]
assert.True(t, e.Skip)
assert.Equal(t, tc.wantReason, e.SkipReason)
assert.Equal(t, tc.wantType, e.FileType.Name)
})
}
custom := byPath["thing.myext"]
assert.False(t, custom.Skip)
assert.Equal(t, "MyExt", custom.FileType.Name)
}
// TestEnumerateRelativeRoot verifies a relative root is resolved to an absolute path
// and entries carry correct relative + absolute paths.
func TestEnumerateRelativeRoot(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"))
}
+139 -3
View File
@@ -13,6 +13,7 @@
package filetype
import (
"bytes"
"path/filepath"
"strings"
@@ -65,7 +66,7 @@ var builtin = []model.FileType{
Name: "Go",
Extensions: []string{".go"},
CommentStyle: block("/*", "*/"),
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), before(model.PreservePackageDecl)},
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveGoBuildConstraint), before(model.PreservePackageDecl)},
},
{
Name: "Swift",
@@ -73,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"},
@@ -95,7 +102,7 @@ var builtin = []model.FileType{
Name: "CSS",
Extensions: []string{".css", ".scss", ".less"},
CommentStyle: block("/*", "*/"),
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveCSSCharset)},
},
// Hash-comment languages.
@@ -111,6 +118,24 @@ var builtin = []model.FileType{
CommentStyle: line("# "),
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveShebang)},
},
{
Name: "Perl",
Extensions: []string{".pl", ".pm", ".t"},
CommentStyle: line("# "),
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveShebang)},
},
{
Name: "PowerShell",
Extensions: []string{".ps1", ".psm1"},
CommentStyle: line("# "),
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveShebang)},
},
{
Name: "R",
Extensions: []string{".r"},
CommentStyle: line("# "),
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveShebang)},
},
{
Name: "Ruby",
Extensions: []string{".rb"},
@@ -123,6 +148,19 @@ var builtin = []model.FileType{
CommentStyle: line("# "),
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
},
{
Name: "Makefile",
Extensions: []string{".mk"},
Filenames: []string{"Makefile", "GNUmakefile"},
CommentStyle: line("# "),
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
},
{
Name: "TOML",
Extensions: []string{".toml"},
CommentStyle: line("# "),
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
},
{
Name: "Nix",
Extensions: []string{".nix"},
@@ -149,7 +187,7 @@ var builtin = []model.FileType{
Extensions: []string{".xml", ".html", ".htm", ".xhtml", ".svg", ".pom"},
Filenames: []string{"pom.xml"},
CommentStyle: block("<!--", "-->"),
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveXMLDecl)},
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveXMLDecl), after(model.PreserveDoctype)},
},
// SQL and double-dash languages.
@@ -165,6 +203,12 @@ var builtin = []model.FileType{
CommentStyle: line("-- "),
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveShebang)},
},
{
Name: "Batch",
Extensions: []string{".bat", ".cmd"},
CommentStyle: line("REM "),
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
},
// PHP: header goes after a CLI shebang (if any) and the opening <?php tag. The
// shebang is listed BEFORE php-open for table correctness/clarity; a "#!" line on
@@ -221,6 +265,83 @@ func Lookup(path string) (model.FileType, bool) {
return model.FileType{}, false
}
// LookupContent resolves a file path and leading content to its FileType. It keeps
// path lookup authoritative, then falls back to content only when the path misses.
func LookupContent(path string, head []byte) (model.FileType, bool) {
if ft, ok := Lookup(path); ok {
return ft, true
}
if name, ok := shebangInterpreter(head); ok {
return lookupInterpreter(name, Lookup)
}
return model.FileType{}, false
}
func shebangInterpreter(head []byte) (string, bool) {
if !bytes.HasPrefix(head, []byte("#!")) {
return "", false
}
lineEnd := bytes.IndexByte(head, '\n')
if lineEnd < 0 {
lineEnd = len(head)
}
line := strings.TrimSpace(strings.TrimRight(string(head[2:lineEnd]), "\r"))
if line == "" {
return "", false
}
parts := strings.Fields(line)
name := normalizeInterpreter(parts[0])
if name != "env" {
return name, true
}
for _, part := range parts[1:] {
if strings.HasPrefix(part, "-") || strings.Contains(part, "=") {
continue
}
return normalizeInterpreter(part), true
}
return "", false
}
func normalizeInterpreter(raw string) string {
name := strings.ToLower(filepath.Base(strings.Trim(raw, `"'`)))
name = strings.TrimSuffix(name, ".exe")
for len(name) > 0 {
last := name[len(name)-1]
if (last < '0' || last > '9') && last != '.' {
break
}
name = name[:len(name)-1]
}
return name
}
func lookupInterpreter(name string, lookup func(path string) (model.FileType, bool)) (model.FileType, bool) {
switch name {
case "sh", "bash", "dash", "zsh", "ksh":
return lookup("script.sh")
case "python", "pythonw":
return lookup("script.py")
case "perl":
return lookup("script.pl")
case "pwsh", "powershell":
return lookup("script.ps1")
case "r", "rscript":
return lookup("script.r")
case "ruby":
return lookup("script.rb")
case "php":
return lookup("script.php")
case "lua":
return lookup("script.lua")
case "node", "nodejs":
return lookup("script.js")
default:
return model.FileType{}, false
}
}
// Merge returns a copy of the built-in table layered with user overrides, keyed by
// extension (e.g. ".myext"). An override that names an existing extension replaces
// that extension's mapping; a new extension adds coverage. The returned function
@@ -260,3 +381,18 @@ func Merge(overrides map[string]model.FileType) func(path string) (model.FileTyp
return model.FileType{}, false
}
}
// MergeContent is the content-aware counterpart to Merge. The merged path lookup
// stays authoritative; shebang fallback is used only after that lookup misses.
func MergeContent(overrides map[string]model.FileType) func(path string, head []byte) (model.FileType, bool) {
lookup := Merge(overrides)
return func(path string, head []byte) (model.FileType, bool) {
if ft, ok := lookup(path); ok {
return ft, true
}
if name, ok := shebangInterpreter(head); ok {
return lookupInterpreter(name, lookup)
}
return model.FileType{}, false
}
}
+116 -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},
@@ -36,11 +37,24 @@ func TestLookupTable(t *testing.T) {
{"script.py", "Python", true, false, false},
{"run.sh", "Shell", true, false, false},
{"run.bash", "Shell", true, false, false},
{"script.pl", "Perl", true, false, false},
{"lib.pm", "Perl", true, false, false},
{"unit.t", "Perl", true, false, false},
{"script.ps1", "PowerShell", true, false, false},
{"module.psm1", "PowerShell", true, false, false},
{"analysis.r", "R", true, false, false},
{"analysis.R", "R", true, false, false},
{"Makefile", "Makefile", true, false, false},
{"GNUmakefile", "Makefile", true, false, false},
{"rules.mk", "Makefile", true, false, false},
{"flake.nix", "Nix", true, false, false},
{"main.tf", "HCL/Terraform", true, false, false},
{"vars.tfvars", "HCL/Terraform", true, false, false},
{"Cargo.toml", "TOML", true, false, false},
{"config.yaml", "YAML", true, false, false},
{"config.yml", "YAML", true, false, false},
{"build.bat", "Batch", true, false, false},
{"build.cmd", "Batch", true, false, false},
{"pom.xml", "XML/HTML", true, true, false},
{"index.html", "XML/HTML", true, true, false},
{"icon.svg", "XML/HTML", true, true, false},
@@ -70,6 +84,90 @@ func TestLookupTable(t *testing.T) {
}
}
func TestBatchUsesREMLineComments(t *testing.T) {
ft, ok := Lookup("build.bat")
require.True(t, ok)
assert.False(t, ft.CommentStyle.Block)
assert.Equal(t, "REM ", ft.CommentStyle.LinePrefix)
assert.Empty(t, ft.CommentStyle.Open)
assert.Empty(t, ft.CommentStyle.Close)
}
func TestLookupContentUsesPathLookupBeforeShebang(t *testing.T) {
ft, ok := LookupContent("script.py", []byte("#!/usr/bin/env ruby\nputs 'wrong'\n"))
require.True(t, ok)
assert.Equal(t, "Python", ft.Name)
}
func TestLookupContentDetectsExtensionlessShebangs(t *testing.T) {
cases := []struct {
name string
content string
wantName string
}{
{"direct perl", "#!/usr/bin/perl\nprint qq(ok)\n", "Perl"},
{"env python version suffix", "#!/usr/bin/env python3.11\nprint('ok')\n", "Python"},
{"env powershell exe", "#!/usr/bin/env pwsh.exe\nWrite-Host ok\n", "PowerShell"},
{"env rscript", "#!/usr/bin/env Rscript\nprint('ok')\n", "R"},
{"direct shell", "#!/bin/sh\necho ok\n", "Shell"},
{"direct node", "#!/usr/bin/node\nconsole.log('ok')\n", "JavaScript/TypeScript"},
{"direct ruby", "#!/usr/bin/ruby\nputs 'ok'\n", "Ruby"},
{"direct php", "#!/usr/bin/php\n<?php echo 'ok';\n", "PHP"},
{"direct lua", "#!/usr/bin/lua\nprint('ok')\n", "Lua"},
{"env skips options and assignments", "#!/usr/bin/env FOO=bar -S python3\nprint('ok')\n", "Python"},
{"final shebang line", "#!/usr/bin/perl", "Perl"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ft, ok := LookupContent("script", []byte(tc.content))
require.True(t, ok)
assert.Equal(t, tc.wantName, ft.Name)
})
}
}
func TestLookupContentSkipsUnknownShebangsAndPlainFiles(t *testing.T) {
cases := []struct {
name string
content string
}{
{"unknown interpreter", "#!/usr/bin/env mystery\nrun\n"},
{"no shebang", "echo not executable\n"},
{"empty shebang", "#!\n"},
{"env without interpreter", "#!/usr/bin/env -S FOO=bar\n"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ft, ok := LookupContent("script", []byte(tc.content))
assert.False(t, ok)
assert.Equal(t, model.FileType{}, ft)
})
}
}
func TestMergeContentLayersOverridesBeforeShebang(t *testing.T) {
override := model.FileType{
Name: "ToolScript",
Extensions: []string{".tool"},
CommentStyle: model.CommentStyle{Block: false, LinePrefix: "// "},
}
lookup := MergeContent(map[string]model.FileType{".tool": override})
ft, ok := lookup("run.tool", []byte("#!/usr/bin/env python3\nprint('ok')\n"))
require.True(t, ok)
assert.Equal(t, "ToolScript", ft.Name)
ft, ok = lookup("run", []byte("#!/usr/bin/env python3\nprint('ok')\n"))
require.True(t, ok)
assert.Equal(t, "Python", ft.Name)
ft, ok = lookup("run", []byte("plain text\n"))
assert.False(t, ok)
assert.Equal(t, model.FileType{}, ft)
}
func TestPreserveFirstOrdering(t *testing.T) {
// Python: BOM, then shebang, then coding pragma; header lands after all three.
py, ok := Lookup("setup.py")
@@ -88,8 +186,25 @@ func TestPreserveFirstOrdering(t *testing.T) {
// XML: BOM then xml-decl.
xml, ok := Lookup("pom.xml")
require.True(t, ok)
require.Len(t, xml.PreserveFirst, 2)
require.Len(t, xml.PreserveFirst, 3)
assert.Equal(t, model.PreserveXMLDecl, xml.PreserveFirst[1].Kind)
assert.Equal(t, model.PreserveDoctype, xml.PreserveFirst[2].Kind)
// Go: BOM, then build constraints, before package.
goFT, ok := Lookup("main.go")
require.True(t, ok)
require.Len(t, goFT.PreserveFirst, 3)
assert.Equal(t, model.PreserveBOM, goFT.PreserveFirst[0].Kind)
assert.Equal(t, model.PreserveGoBuildConstraint, goFT.PreserveFirst[1].Kind)
assert.Equal(t, model.PreservePackageDecl, goFT.PreserveFirst[2].Kind)
assert.True(t, goFT.PreserveFirst[2].Before)
// CSS: BOM then @charset.
css, ok := Lookup("style.css")
require.True(t, ok)
require.Len(t, css.PreserveFirst, 2)
assert.Equal(t, model.PreserveBOM, css.PreserveFirst[0].Kind)
assert.Equal(t, model.PreserveCSSCharset, css.PreserveFirst[1].Kind)
// PHP: BOM, then shebang (CLI scripts), then php-open. The shebang precedes the
// <?php tag so a "#!" line stays line 1 on an executable PHP script.
+19
View File
@@ -101,3 +101,22 @@ func Commit(path, message string) error {
}
return nil
}
// CommitPaths stages and commits only the named repo-relative paths. Pre-existing
// staged or unstaged changes outside this pathset are left alone.
func CommitPaths(path, message string, paths []string) error {
if len(paths) == 0 {
return fmt.Errorf("gitutil: no paths to commit")
}
addArgs := append([]string{"add", "--"}, paths...)
if _, err := run(path, addArgs...); err != nil {
return err
}
commitArgs := append([]string{"commit", "-m", message, "--only", "--"}, paths...)
if _, err := run(path, commitArgs...); err != nil {
return err
}
return nil
}
+51
View File
@@ -317,3 +317,54 @@ func TestCommit(t *testing.T) {
require.Error(t, err)
})
}
func TestCommitPaths(t *testing.T) {
requireGit(t)
t.Run("commits only named paths", func(t *testing.T) {
root := newRepo(t)
writeFile(t, root, "a.go", "package a\n")
writeFile(t, root, "b.go", "package b\n")
commitAt(t, root, "init", "2020")
writeFile(t, root, "a.go", "package a\n\nvar Changed = true\n")
writeFile(t, root, "b.go", "package b\n\nvar Dirty = true\n")
runGit(t, root, nil, "add", "b.go")
err := CommitPaths(root, "chore: scoped", []string{"a.go"})
require.NoError(t, err)
msg, gerr := run(root, "log", "-1", "--format=%s")
require.NoError(t, gerr)
assert.Equal(t, "chore: scoped", msg)
changed, gerr := run(root, "show", "--name-only", "--format=", "HEAD")
require.NoError(t, gerr)
assert.Equal(t, "a.go", changed)
status, gerr := run(root, "status", "--porcelain")
require.NoError(t, gerr)
assert.Contains(t, status, "b.go")
})
t.Run("empty pathset errors before commit", func(t *testing.T) {
root := newRepo(t)
err := CommitPaths(root, "chore: empty", nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "no paths to commit")
})
t.Run("non-repo errors on add", func(t *testing.T) {
err := CommitPaths(t.TempDir(), "chore: scoped", []string{"a.go"})
require.Error(t, err)
})
t.Run("nothing to commit errors after add", func(t *testing.T) {
root := newRepo(t)
writeFile(t, root, "a.go", "package a\n")
commitAt(t, root, "init", "2020")
err := CommitPaths(root, "chore: no-op", []string{"a.go"})
require.Error(t, err)
})
}
+138
View File
@@ -0,0 +1,138 @@
// Package header owns the shared rules for managed license headers.
package header
import (
"strings"
"github.com/KofTwentyTwo/license-tool/internal/model"
)
// Sentinel is the marker embedded in every header this tool writes.
const Sentinel = "license-tool:managed"
var bom = []byte{0xEF, 0xBB, 0xBF}
// PreserveBoundary returns the byte offset after all leading preserve-first
// prefixes the file type places before the managed header.
func PreserveBoundary(content []byte, ft model.FileType) int {
pos := 0
if HasRule(ft, model.PreserveBOM, false) && hasBOM(content[pos:]) {
pos += len(bom)
}
allowShebang := true
allowXML := HasRule(ft, model.PreserveXMLDecl, false)
allowPHP := HasRule(ft, model.PreservePHPOpen, false)
allowPragma := HasRule(ft, model.PreserveCodingPragma, false)
allowGoBuild := HasRule(ft, model.PreserveGoBuildConstraint, false)
allowCSSCharset := HasRule(ft, model.PreserveCSSCharset, false)
allowDoctype := HasRule(ft, model.PreserveDoctype, false)
for pos < len(content) {
lineEnd := LineEndOffset(content, pos)
lineText := string(content[pos:lineEnd])
trimmed := strings.TrimRight(lineText, "\r\n")
consumed := false
switch {
case allowShebang && strings.HasPrefix(trimmed, "#!"):
consumed = true
case allowXML && strings.HasPrefix(strings.TrimSpace(trimmed), "<?xml"):
consumed = true
case allowPHP && strings.HasPrefix(strings.TrimSpace(trimmed), "<?php"):
consumed = true
case allowPragma && IsCodingPragma(trimmed):
consumed = true
case allowGoBuild:
if next, ok := GoBuildConstraintBoundary(content, pos); ok {
pos = next
continue
}
case allowCSSCharset && IsCSSCharset(trimmed):
consumed = true
case allowDoctype && IsDoctype(trimmed):
consumed = true
}
if !consumed {
break
}
pos = lineEnd
}
return pos
}
// HasRule reports whether ft lists a preserve rule of the given kind and direction.
func HasRule(ft model.FileType, kind model.PreserveKind, before bool) bool {
for _, r := range ft.PreserveFirst {
if r.Kind == kind && r.Before == before {
return true
}
}
return false
}
// LineEndOffset returns the offset just past the newline terminating the line at pos
// or len(content) for the final unterminated line.
func LineEndOffset(content []byte, pos int) int {
for i := pos; i < len(content); i++ {
if content[i] == '\n' {
return i + 1
}
}
return len(content)
}
// GoBuildConstraintBoundary advances past a leading Go build-constraint block and
// its following blank separator, if present.
func GoBuildConstraintBoundary(content []byte, pos int) (int, bool) {
current := pos
sawConstraint := false
for current < len(content) {
lineEnd := LineEndOffset(content, current)
lineText := string(content[current:lineEnd])
trimmed := strings.TrimSpace(strings.TrimRight(lineText, "\r\n"))
switch {
case isGoBuildConstraint(trimmed):
sawConstraint = true
current = lineEnd
default:
if sawConstraint && trimmed == "" {
return lineEnd, true
}
return current, sawConstraint
}
}
return current, sawConstraint
}
func isGoBuildConstraint(trimmed string) bool {
return strings.HasPrefix(trimmed, "//go:build") || strings.HasPrefix(trimmed, "// +build")
}
// IsCSSCharset reports whether trimmed is a CSS @charset at-rule.
func IsCSSCharset(trimmed string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(trimmed)), "@charset")
}
// IsDoctype reports whether trimmed is a markup doctype declaration.
func IsDoctype(trimmed string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(trimmed)), "<!doctype")
}
// IsCodingPragma reports whether trimmed is a Python/Ruby encoding pragma.
func IsCodingPragma(trimmed string) bool {
t := strings.TrimSpace(trimmed)
if !strings.HasPrefix(t, "#") {
return false
}
lower := strings.ToLower(t)
return strings.Contains(lower, "coding:") || strings.Contains(lower, "coding=")
}
func hasBOM(content []byte) bool {
return len(content) >= len(bom) && content[0] == bom[0] && content[1] == bom[1] && content[2] == bom[2]
}
+359
View File
@@ -0,0 +1,359 @@
package header
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/KofTwentyTwo/license-tool/internal/model"
)
func TestPreserveBoundary(t *testing.T) {
hashFT := model.FileType{
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
{Kind: model.PreserveShebang, Before: false},
{Kind: model.PreserveCodingPragma, Before: false},
},
}
xmlFT := model.FileType{
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
{Kind: model.PreserveXMLDecl, Before: false},
{Kind: model.PreservePHPOpen, Before: false},
},
}
goFT := model.FileType{
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
{Kind: model.PreserveGoBuildConstraint, Before: false},
},
}
cssFT := model.FileType{
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
{Kind: model.PreserveCSSCharset, Before: false},
},
}
markupFT := model.FileType{
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
{Kind: model.PreserveXMLDecl, Before: false},
{Kind: model.PreserveDoctype, Before: false},
},
}
beforeFT := model.FileType{
PreserveFirst: []model.PreserveRule{
{Kind: model.PreservePackageDecl, Before: true},
},
}
tests := []struct {
name string
content string
ft model.FileType
want string
}{
{
name: "no prefixes leaves boundary at zero",
content: "package main\n",
ft: hashFT,
want: "package main\n",
},
{
name: "shebang is consumed universally",
content: "#!/bin/sh\necho hi\n",
ft: model.FileType{},
want: "echo hi\n",
},
{
name: "final unterminated shebang line",
content: "#!/bin/sh",
ft: model.FileType{},
want: "",
},
{
name: "BOM is consumed first",
content: "\uFEFF#!/bin/sh\necho hi\n",
ft: hashFT,
want: "echo hi\n",
},
{
name: "BOM is not consumed without rule",
content: "\uFEFFpackage main\n",
ft: beforeFT,
want: "\uFEFFpackage main\n",
},
{
name: "xml declaration is consumed",
content: "<?xml version=\"1.0\"?>\n<root/>\n",
ft: xmlFT,
want: "<root/>\n",
},
{
name: "php open tag is consumed",
content: "<?php\n// body\n",
ft: xmlFT,
want: "// body\n",
},
{
name: "coding pragma with colon form is consumed",
content: "# -*- coding: utf-8 -*-\nprint(1)\n",
ft: hashFT,
want: "print(1)\n",
},
{
name: "coding pragma with equals form is consumed",
content: "# coding=utf-8\nprint(1)\n",
ft: hashFT,
want: "print(1)\n",
},
{
name: "go build constraint is consumed with blank separator",
content: "//go:build linux\n\npackage main\n",
ft: goFT,
want: "package main\n",
},
{
name: "go build constraint after BOM is consumed",
content: "\uFEFF//go:build linux\n\npackage main\n",
ft: goFT,
want: "package main\n",
},
{
name: "go build constraint after shebang is consumed",
content: "#!/usr/bin/env go\n//go:build linux\n\npackage main\n",
ft: goFT,
want: "package main\n",
},
{
name: "go build constraint with CRLF is consumed",
content: "//go:build linux\r\n\r\npackage main\r\n",
ft: goFT,
want: "package main\r\n",
},
{
name: "legacy go build constraint is consumed",
content: "// +build linux\n\npackage main\n",
ft: goFT,
want: "package main\n",
},
{
name: "combined go build constraints are consumed",
content: "//go:build linux\n// +build linux\n\npackage main\n",
ft: goFT,
want: "package main\n",
},
{
name: "go build constraint tolerates missing blank separator",
content: "//go:build linux\npackage main\n",
ft: goFT,
want: "package main\n",
},
{
name: "go build rule does not consume ordinary line comment",
content: "// ordinary comment\npackage main\n",
ft: goFT,
want: "// ordinary comment\npackage main\n",
},
{
name: "go-looking line is not consumed without rule",
content: "//go:build linux\n\npackage main\n",
ft: model.FileType{},
want: "//go:build linux\n\npackage main\n",
},
{
name: "css charset is consumed",
content: "@charset \"UTF-8\";\nbody {}\n",
ft: cssFT,
want: "body {}\n",
},
{
name: "css charset after BOM is consumed",
content: "\uFEFF@charset \"UTF-8\";\nbody {}\n",
ft: cssFT,
want: "body {}\n",
},
{
name: "css charset is case insensitive",
content: "@CHARSET \"UTF-8\";\nbody {}\n",
ft: cssFT,
want: "body {}\n",
},
{
name: "css-looking line is not consumed without rule",
content: "@charset \"UTF-8\";\nbody {}\n",
ft: model.FileType{},
want: "@charset \"UTF-8\";\nbody {}\n",
},
{
name: "doctype is consumed",
content: "<!DOCTYPE html>\n<html></html>\n",
ft: markupFT,
want: "<html></html>\n",
},
{
name: "doctype after BOM is consumed",
content: "\uFEFF<!DOCTYPE html>\n<html></html>\n",
ft: markupFT,
want: "<html></html>\n",
},
{
name: "doctype is case insensitive",
content: "<!doctype html>\n<html></html>\n",
ft: markupFT,
want: "<html></html>\n",
},
{
name: "xml declaration then doctype are consumed",
content: "<?xml version=\"1.0\"?>\n<!DOCTYPE svg>\n<svg/>\n",
ft: markupFT,
want: "<svg/>\n",
},
{
name: "doctype-looking line is not consumed without rule",
content: "<!DOCTYPE html>\n<html></html>\n",
ft: model.FileType{},
want: "<!DOCTYPE html>\n<html></html>\n",
},
{
name: "coding pragma is not consumed without rule",
content: "# coding=utf-8\nprint(1)\n",
ft: model.FileType{},
want: "# coding=utf-8\nprint(1)\n",
},
{
name: "before rule is never consumed",
content: "package main\nfunc main(){}\n",
ft: beforeFT,
want: "package main\nfunc main(){}\n",
},
{
name: "non-prefix line stops consumption",
content: "#!/bin/sh\necho hi\n# coding=utf-8\n",
ft: hashFT,
want: "echo hi\n# coding=utf-8\n",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
content := []byte(tc.content)
got := PreserveBoundary(content, tc.ft)
assert.Equal(t, tc.want, string(content[got:]))
})
}
}
func TestHasRule(t *testing.T) {
ftBOMAfter := model.FileType{
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveShebang, Before: false},
{Kind: model.PreserveBOM, Before: false},
},
}
ftPackageBefore := model.FileType{
PreserveFirst: []model.PreserveRule{
{Kind: model.PreservePackageDecl, Before: true},
},
}
cases := []struct {
name string
ft model.FileType
kind model.PreserveKind
before bool
want bool
}{
{"matches kind and before", ftBOMAfter, model.PreserveBOM, false, true},
{"kind present but before mismatches", ftBOMAfter, model.PreserveBOM, true, false},
{"kind absent entirely", ftPackageBefore, model.PreserveBOM, false, false},
{"empty rule list", model.FileType{}, model.PreserveShebang, false, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
assert.Equal(t, c.want, HasRule(c.ft, c.kind, c.before))
})
}
}
func TestLineEndOffset(t *testing.T) {
content := []byte("a\nbc")
assert.Equal(t, 2, LineEndOffset(content, 0))
assert.Equal(t, len(content), LineEndOffset(content, 2))
assert.Equal(t, len(content), LineEndOffset(content, len(content)))
}
func TestGoBuildConstraintBoundary(t *testing.T) {
cases := []struct {
name string
content string
want string
wantOK bool
}{
{
name: "go build form",
content: "//go:build linux\n\npackage main\n",
want: "package main\n",
wantOK: true,
},
{
name: "only one blank separator is consumed",
content: "//go:build linux\n\n\npackage main\n",
want: "\npackage main\n",
wantOK: true,
},
{
name: "legacy plus build form",
content: "// +build linux\n\npackage main\n",
want: "package main\n",
wantOK: true,
},
{
name: "ordinary line comment",
content: "// not a constraint\npackage main\n",
want: "// not a constraint\npackage main\n",
wantOK: false,
},
{
name: "empty content",
content: "",
want: "",
wantOK: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
content := []byte(c.content)
got, ok := GoBuildConstraintBoundary(content, 0)
assert.Equal(t, c.wantOK, ok)
assert.Equal(t, c.want, string(content[got:]))
})
}
}
func TestIsCodingPragma(t *testing.T) {
assert.True(t, IsCodingPragma("# -*- coding: utf-8 -*-"))
assert.True(t, IsCodingPragma("# coding=latin-1"))
assert.True(t, IsCodingPragma("# vim: set fileencoding=utf-8 :"))
assert.False(t, IsCodingPragma("# just a comment"))
assert.False(t, IsCodingPragma("not a hash line"))
}
func TestIsCSSCharset(t *testing.T) {
assert.True(t, IsCSSCharset("@charset \"UTF-8\";"))
assert.True(t, IsCSSCharset(" @CHARSET \"UTF-8\";"))
assert.False(t, IsCSSCharset("body { color: black; }"))
}
func TestIsDoctype(t *testing.T) {
assert.True(t, IsDoctype("<!DOCTYPE html>"))
assert.True(t, IsDoctype(" <!doctype html>"))
assert.False(t, IsDoctype("<html></html>"))
}
func TestHasBOM(t *testing.T) {
assert.True(t, hasBOM([]byte{0xEF, 0xBB, 0xBF, 'x'}))
assert.False(t, hasBOM([]byte{0xEF, 0xBB}))
assert.False(t, hasBOM([]byte("abc")))
}
+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))
}
+45
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
@@ -159,6 +176,13 @@ const (
// PreservePackageDecl is a package/module declaration (Go, Java, Kotlin) that
// the header must precede rather than follow.
PreservePackageDecl
// PreserveGoBuildConstraint is a Go "//go:build" or "// +build" constraint
// block that must remain in the file's leading comment group.
PreserveGoBuildConstraint
// PreserveCSSCharset is a CSS "@charset" rule that must lead the stylesheet.
PreserveCSSCharset
// PreserveDoctype is an HTML/XML "<!DOCTYPE ...>" declaration.
PreserveDoctype
)
// PreserveRule is a single preserve-first directive in a FileType's ordered list.
@@ -225,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.
@@ -376,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
+3 -124
View File
@@ -31,6 +31,7 @@ import (
"strings"
"github.com/KofTwentyTwo/license-tool/internal/gitutil"
managedheader "github.com/KofTwentyTwo/license-tool/internal/header"
"github.com/KofTwentyTwo/license-tool/internal/model"
)
@@ -38,17 +39,6 @@ import (
// frozen-but-stubbed sibling package; every other path is fully implemented.
var errNotImplemented = errors.New("render: not implemented")
// sentinel is the marker embedded in every header this tool writes. It mirrors
// detect.Sentinel by value (we cannot import internal/detect without risking an
// import cycle through the applier, and the constant is part of the cross-package
// contract). A future detection pass keys on this string for the highest-confidence
// "this comment is safe to replace" signal.
//
// WHY duplicated rather than imported: the value is a fixed wire contract shared by
// the writer (here) and the reader (internal/detect); both reference the same
// literal so neither package depends on the other's build.
const sentinel = "license-tool:managed"
// HeaderInput is everything needed to render one file's header.
type HeaderInput struct {
// License is the resolved license (text + standard header + id + category).
@@ -101,7 +91,7 @@ func plaintextHeader(in HeaderInput) string {
// The sentinel rides as the first line so detection finds it before parsing any
// tags. It is comment-wrapped along with everything else.
lines = append(lines, "SPDX-License-Tool: "+sentinel)
lines = append(lines, "SPDX-License-Tool: "+managedheader.Sentinel)
emitReuse := in.Style == model.StyleReuse || in.Style == model.StyleReusePlusNotice
emitNotice := in.Style == model.StyleNotice || in.Style == model.StyleReusePlusNotice
@@ -267,7 +257,7 @@ func Replace(content []byte, headerLF string, start, end int) (newContent []byte
// separator; we re-normalize that separator to eol so an inserted header on a CRLF
// file does not introduce a lone LF.
func insertAt(content []byte, ft model.FileType, header string, eol string) ([]byte, string) {
at := preserveBoundary(content, ft)
at := managedheader.PreserveBoundary(content, ft)
prefix := content[:at]
rest := content[at:]
@@ -307,119 +297,8 @@ func replaceSpan(content []byte, header string, start, end int) ([]byte, string)
return b, "replace"
}
// preserveBoundary returns the byte offset after all leading preserve-first
// prefixes the file type marks AFTER (shebang, xml-decl, php-open, BOM, coding
// pragma). A construct marked Before (a package declaration) does NOT advance the
// boundary: the header is placed before it, so the boundary stays at the start of
// content (after any AFTER prefixes), which is before the package line.
//
// WHY recompute here rather than call detect.PreserveBoundary: detect is a sibling
// package this owner must not depend on at runtime (and it is itself a stub in this
// build). The rules are pure data on ft.PreserveFirst, so the computation is local
// and self-contained.
func preserveBoundary(content []byte, ft model.FileType) int {
pos := 0
// A BOM, when present and the type preserves it, is always first.
if hasRule(ft, model.PreserveBOM, false) && hasBOM(content[pos:]) {
pos += len(bom)
}
// A leading "#!" is a kernel-level shebang valid for ANY executable script
// regardless of language, so it is preserved universally rather than gated on the
// file type listing PreserveShebang. We consume it here (right after any BOM)
// independent of the rule list. This stays safe because the line is only consumed
// when it actually starts with "#!" (which only a real shebang does) and only at
// the very top of the file; it can never over-consume an ordinary hash comment
// below. All OTHER per-type rules below remain gated by ft.PreserveFirst as before.
pos = advanceLineIf(content, pos, func(line string) bool {
return strings.HasPrefix(line, "#!")
})
for _, rule := range ft.PreserveFirst {
if rule.Before {
// Header goes before this construct; do not advance past it.
continue
}
switch rule.Kind {
case model.PreserveBOM:
// Handled above to guarantee it leads.
case model.PreserveShebang:
pos = advanceLineIf(content, pos, func(line string) bool {
return strings.HasPrefix(line, "#!")
})
case model.PreserveXMLDecl:
pos = advanceLineIf(content, pos, func(line string) bool {
return strings.HasPrefix(strings.TrimSpace(line), "<?xml")
})
case model.PreservePHPOpen:
pos = advanceLineIf(content, pos, func(line string) bool {
return strings.HasPrefix(strings.TrimSpace(line), "<?php")
})
case model.PreserveCodingPragma:
pos = advanceLineIf(content, pos, func(line string) bool {
t := strings.TrimSpace(line)
return strings.HasPrefix(t, "#") && strings.Contains(t, "coding:")
})
}
}
return pos
}
// hasRule reports whether ft lists a preserve rule of kind k with the given Before.
func hasRule(ft model.FileType, k model.PreserveKind, before bool) bool {
for _, r := range ft.PreserveFirst {
if r.Kind == k && r.Before == before {
return true
}
}
return false
}
// advanceLineIf, if the line beginning at pos satisfies match, returns the offset
// just past that line's terminator (or end of content). Otherwise returns pos
// unchanged. It is newline-agnostic: it consumes a trailing CRLF or LF.
func advanceLineIf(content []byte, pos int, match func(line string) bool) int {
if pos >= len(content) {
return pos
}
nl := indexByteFrom(content, pos, '\n')
var line string
var next int
if nl < 0 {
line = string(content[pos:])
next = len(content)
} else {
// Trim a trailing CR so CRLF lines match the same predicate as LF lines.
line = strings.TrimSuffix(string(content[pos:nl]), "\r")
next = nl + 1
}
if match(line) {
return next
}
return pos
}
// indexByteFrom returns the index of the first b at or after start, or -1.
func indexByteFrom(content []byte, start int, b byte) int {
for i := start; i < len(content); i++ {
if content[i] == b {
return i
}
}
return -1
}
// EOL handling ----------------------------------------------------------------
// bom is the UTF-8 byte-order mark.
var bom = []byte{0xEF, 0xBB, 0xBF}
// hasBOM reports whether content begins with a UTF-8 BOM.
func hasBOM(content []byte) bool {
return len(content) >= 3 && content[0] == bom[0] && content[1] == bom[1] && content[2] == bom[2]
}
// detectEOL returns the dominant line ending of content: "\r\n" if the first line
// break is a CRLF, else "\n". Empty or newline-free content defaults to LF.
//
+79 -79
View File
@@ -1,6 +1,7 @@
package render
import (
"go/build"
"os"
"os/exec"
"path/filepath"
@@ -10,6 +11,7 @@ import (
"time"
"github.com/KofTwentyTwo/license-tool/internal/filetype"
managedheader "github.com/KofTwentyTwo/license-tool/internal/header"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/KofTwentyTwo/license-tool/internal/spdx"
"github.com/stretchr/testify/assert"
@@ -91,7 +93,7 @@ func TestHeaderStylesPlaintextContent(t *testing.T) {
Style: model.StyleReuse, FileType: goFT,
},
mustContain: []string{
sentinel,
managedheader.Sentinel,
"SPDX-FileCopyrightText: 2026 Kingsrook, LLC",
"SPDX-License-Identifier: AGPL-3.0-or-later",
},
@@ -104,7 +106,7 @@ func TestHeaderStylesPlaintextContent(t *testing.T) {
Style: model.StyleNotice, FileType: goFT,
},
mustContain: []string{
sentinel,
managedheader.Sentinel,
"Copyright (c) 2026 Kingsrook, LLC",
"This program is free software",
"GNU Affero General Public License",
@@ -118,7 +120,7 @@ func TestHeaderStylesPlaintextContent(t *testing.T) {
Style: model.StyleReusePlusNotice, FileType: goFT,
},
mustContain: []string{
sentinel,
managedheader.Sentinel,
"SPDX-FileCopyrightText: 2021-2026 Kingsrook, LLC",
"SPDX-License-Identifier: AGPL-3.0-or-later",
"This program is free software",
@@ -426,6 +428,67 @@ func TestSpliceInsertAfterCodingPragma(t *testing.T) {
assert.Less(t, idxPragma, idxHeader, "header is placed after the coding pragma")
}
func TestSpliceInsertAfterCodingEqualsPragma(t *testing.T) {
ft, header := makeHeaderLF(t, "x.py")
content := []byte("# coding=utf-8\nprint('hi')\n")
out, action := Splice(content, ft, header, model.DetectedHeader{Present: false})
got := string(out)
assert.Equal(t, "insert", action)
assert.True(t, strings.HasPrefix(got, "# coding=utf-8\n"),
"coding pragma must remain before the header")
idxPragma := strings.Index(got, "coding=utf-8")
idxHeader := strings.Index(got, "SPDX-License-Identifier: MIT")
assert.Less(t, idxPragma, idxHeader, "header is placed after the coding pragma")
}
func TestSplicePreservesGoBuildConstraintSemantics(t *testing.T) {
ft, header := makeHeaderLF(t, "x.go")
content := []byte("//go:build linux\n\npackage foo\n")
require.False(t, goFileMatches(t, content, "darwin"),
"fixture must be excluded before the header is inserted")
out, action := Splice(content, ft, header, model.DetectedHeader{Present: false})
got := string(out)
assert.Equal(t, "insert", action)
assert.True(t, strings.HasPrefix(got, "//go:build linux\n\n"),
"build constraint and terminating blank line must remain before the header")
assert.False(t, goFileMatches(t, out, "darwin"),
"inserted header must not void the build constraint")
}
func TestSpliceInsertAfterCSSCharset(t *testing.T) {
ft, header := makeHeaderLF(t, "x.css")
content := []byte("@charset \"UTF-8\";\n.cafe { content: \"hello\"; }\n")
out, action := Splice(content, ft, header, model.DetectedHeader{Present: false})
got := string(out)
assert.Equal(t, "insert", action)
assert.True(t, strings.HasPrefix(got, "@charset \"UTF-8\";\n"),
"@charset must remain the first stylesheet bytes")
idxCharset := strings.Index(got, "@charset")
idxHeader := strings.Index(got, "SPDX-License-Identifier: MIT")
assert.Less(t, idxCharset, idxHeader, "header is placed after @charset")
}
func TestSpliceInsertAfterMarkupDoctype(t *testing.T) {
ft, header := makeHeaderLF(t, "index.html")
content := []byte("<!DOCTYPE html>\n<html lang=\"en\"></html>\n")
out, action := Splice(content, ft, header, model.DetectedHeader{Present: false})
got := string(out)
assert.Equal(t, "insert", action)
assert.True(t, strings.HasPrefix(got, "<!DOCTYPE html>\n"),
"doctype must remain the first document content")
idxDoctype := strings.Index(strings.ToLower(got), "<!doctype")
idxHeader := strings.Index(got, "SPDX-License-Identifier: MIT")
assert.Less(t, idxDoctype, idxHeader, "header is placed after doctype")
}
func TestSplicePreservesBOM(t *testing.T) {
ft, header := makeHeaderLF(t, "x.go")
content := append([]byte{0xEF, 0xBB, 0xBF}, []byte("package main\n")...)
@@ -743,82 +806,6 @@ func TestWrapCommentBlankLineThroughHeader(t *testing.T) {
}
}
// TestHasRule drives hasRule directly to cover both the match and the no-match
// (return false) outcomes, including kind-match-but-Before-mismatch.
func TestHasRule(t *testing.T) {
ftBOMAfter := model.FileType{
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveShebang, Before: false},
{Kind: model.PreserveBOM, Before: false},
},
}
ftPackageBefore := model.FileType{
PreserveFirst: []model.PreserveRule{
{Kind: model.PreservePackageDecl, Before: true},
},
}
cases := []struct {
name string
ft model.FileType
kind model.PreserveKind
before bool
want bool
}{
{"matches kind and before", ftBOMAfter, model.PreserveBOM, false, true},
{"kind present but before mismatches", ftBOMAfter, model.PreserveBOM, true, false},
{"kind absent entirely", ftPackageBefore, model.PreserveBOM, false, false},
{"empty rule list", model.FileType{}, model.PreserveShebang, false, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
assert.Equal(t, c.want, hasRule(c.ft, c.kind, c.before))
})
}
}
// TestPreserveBoundaryNoBOMRule covers the hasRule miss through preserveBoundary:
// a Go file (no PreserveBOM rule) whose content begins with BOM bytes must NOT
// advance past them via the BOM branch (the type does not preserve a BOM), so the
// boundary stays at 0 and the header would lead the (here, BOM-prefixed) content.
func TestPreserveBoundaryNoBOMRule(t *testing.T) {
ftNoBOM := model.FileType{
Name: "fake",
PreserveFirst: []model.PreserveRule{
{Kind: model.PreservePackageDecl, Before: true},
},
}
content := append([]byte{0xEF, 0xBB, 0xBF}, []byte("package main\n")...)
at := preserveBoundary(content, ftNoBOM)
assert.Equal(t, 0, at, "without a PreserveBOM rule the boundary does not skip the BOM bytes")
}
// TestAdvanceLineIfAtEOF drives advanceLineIf's pos>=len(content) guard: starting
// at or past the end returns pos unchanged without consulting the predicate.
func TestAdvanceLineIfAtEOF(t *testing.T) {
called := false
match := func(string) bool {
called = true
return true
}
cases := []struct {
name string
content []byte
pos int
}{
{"pos equals length", []byte("abc"), 3},
{"pos past length", []byte("abc"), 10},
{"empty content pos zero", []byte(""), 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
called = false
got := advanceLineIf(c.content, c.pos, match)
assert.Equal(t, c.pos, got, "EOF guard returns pos unchanged")
assert.False(t, called, "predicate must not be consulted past EOF")
})
}
}
func TestSpliceNeverAltersBytesOutsideHeaderRegion(t *testing.T) {
// Safety invariant: for an insert, the entire original content must appear
// verbatim and contiguously somewhere in the output (only the header is added).
@@ -832,3 +819,16 @@ func TestSpliceNeverAltersBytesOutsideHeaderRegion(t *testing.T) {
assert.Equal(t, string(content), string(out[pkgIdx:]),
"all original bytes from the insertion point survive unchanged")
}
func goFileMatches(t *testing.T, content []byte, goos string) bool {
t.Helper()
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "buildtag.go"), content, 0o644))
ctxt := build.Default
ctxt.GOOS = goos
ctxt.GOARCH = "amd64"
matches, err := ctxt.MatchFile(dir, "buildtag.go")
require.NoError(t, err)
return matches
}
+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
}
+527 -133
View File
@@ -15,6 +15,7 @@ import (
"fmt"
"io"
"sort"
"strings"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/KofTwentyTwo/license-tool/internal/policy"
@@ -30,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
@@ -185,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
@@ -284,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,
@@ -308,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)
}
@@ -334,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
@@ -355,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)
}
@@ -409,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")
@@ -424,48 +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)
}
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")
renderTextViolations(bw, r)
bw.printf("dependencies: %d\n", len(r.Dependencies))
for _, dep := range sortedDeps(r.Dependencies) {
bw.printf(" %s\n", depLine(dep))
}
bw.printf("\n")
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
}
if len(r.Violations) > 0 {
bw.printf("policy violations:\n")
for _, v := range r.Violations {
@@ -473,8 +585,94 @@ 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) {
diffFiles := make([]model.FileResult, 0)
for _, fr := range sortedFiles(files) {
if fr.Diff != "" {
diffFiles = append(diffFiles, fr)
}
}
if len(diffFiles) == 0 {
return
}
bw.printf("pending diffs:\n")
for _, fr := range diffFiles {
bw.printf("%s\n", strings.TrimRight(fr.Diff, "\n"))
}
bw.printf("\n")
}
func fileLine(fr model.FileResult) string {
@@ -503,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")
@@ -514,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 {
@@ -557,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 {
@@ -582,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 {
@@ -611,6 +923,7 @@ type jsonFile struct {
Holder string `json:"holder,omitempty"`
Year string `json:"year,omitempty"`
Action string `json:"action,omitempty"`
Diff string `json:"diff,omitempty"`
Violations []string `json:"violations,omitempty"`
Error string `json:"error,omitempty"`
}
@@ -624,55 +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,
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 {
@@ -691,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.
+55 -3
View File
@@ -642,6 +642,20 @@ func TestRenderText(t *testing.T) {
})
}
func TestRenderTextIncludesUnifiedDiffs(t *testing.T) {
r := emptyReport()
r.Files = []model.FileResult{
{Path: "a.go", FileType: "Go", Action: "insert", Diff: "--- a.go\n+++ a.go\n@@ -1 +1,2 @@\n package a\n+// SPDX-License-Identifier: MIT\n"},
}
var buf bytes.Buffer
require.NoError(t, Render(&buf, r, FormatText))
out := buf.String()
assert.Contains(t, out, "pending diffs:")
assert.Contains(t, out, "+++ a.go")
assert.Contains(t, out, "+// SPDX-License-Identifier: MIT")
}
// fileLine covers the unresolved-dep "no reason" -> (none) and the empty-config
// orNone paths exercised through a tailored report.
func TestRenderTextOrNoneFallbacks(t *testing.T) {
@@ -672,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 |")
@@ -759,6 +776,41 @@ func TestRenderJSON(t *testing.T) {
})
}
func TestRenderJSONDiffField(t *testing.T) {
t.Run("includes non-empty diff", func(t *testing.T) {
r := emptyReport()
r.Files = []model.FileResult{
{Path: "a.go", FileType: "Go", Action: "insert", Diff: "--- a.go\n+++ a.go\n@@ -1 +1,2 @@\n package a\n+// SPDX-License-Identifier: MIT\n"},
}
var buf bytes.Buffer
require.NoError(t, Render(&buf, r, FormatJSON))
var got jsonReport
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
require.Len(t, got.Files, 1)
assert.Contains(t, got.Files[0].Diff, "+++ a.go")
assert.Contains(t, got.Files[0].Diff, "+// SPDX-License-Identifier: MIT")
})
t.Run("omits empty diff", func(t *testing.T) {
r := emptyReport()
r.Files = []model.FileResult{{Path: "a.go", FileType: "Go", Action: "none"}}
var buf bytes.Buffer
require.NoError(t, Render(&buf, r, FormatJSON))
var raw map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &raw))
files, ok := raw["files"].([]any)
require.True(t, ok)
require.Len(t, files, 1)
fileEntry, ok := files[0].(map[string]any)
require.True(t, ok)
assert.NotContains(t, fileEntry, "diff")
})
}
// nonNilCounts / nonNilStrings nil branches: a Report with nil maps/slices must
// still serialize to {} / [] (the empty report uses non-nil maps, so exercise the
// nil path directly through a report with nil aggregates).
+10 -2
View File
@@ -50,16 +50,24 @@ func (r *GradleResolver) Detect(path string) bool {
// It is deliberately a surface scan, not a full Gradle model build: detect-only.
var gradleDepRE = regexp.MustCompile(`["']([\w.\-]+:[\w.\-]+:[\w.\-${}]+)["']`)
const (
gradleOnDiskReason = "gradle on-disk dependency-license resolution is not supported (detect-only; no on-disk license metadata, no network fetch)"
gradleToolReason = "Gradle tool-tier dependency-license resolution is not supported; this resolver remains detect-only and does not shell out"
)
// Resolve implements model.Resolver. It surface-scans the build script(s) for
// dependency coordinates and returns each as an unresolved DependencyLicense with
// a reason. It never resolves a license: Gradle resolution is out of scope for v1.
// a reason. It never resolves a license: Gradle resolution is detect-only.
func (r *GradleResolver) Resolve(path string, opts model.ResolveOptions) ([]model.DependencyLicense, error) {
coords := map[string]string{} // coordinate "group:artifact" -> version
for _, m := range gradleManifests {
scanGradleManifest(filepath.Join(path, m), coords)
}
const reason = "gradle dependency-license resolution not implemented in v1 (detect-only; no on-disk license metadata, no network fetch)"
reason := gradleOnDiskReason
if opts.AllowToolShellOut {
reason = gradleToolReason
}
names := make([]string, 0, len(coords))
for n := range coords {
+15 -1
View File
@@ -72,7 +72,7 @@ dependencies {
names = append(names, d.Name)
assert.Equal(t, model.ResolutionUnresolved, d.Resolution)
assert.Equal(t, "gradle", d.Ecosystem)
assert.Contains(t, d.Reason, "not implemented in v1")
assert.Contains(t, d.Reason, "detect-only")
}
// Sorted and deduplicated.
@@ -90,6 +90,20 @@ dependencies {
assert.Empty(t, byName["org.example:propver"].Version)
}
func TestGradleResolveToolTierReason(t *testing.T) {
dir := t.TempDir()
content := `dependencies { implementation 'com.google.guava:guava:31.1-jre' }`
require.NoError(t, os.WriteFile(filepath.Join(dir, "build.gradle"), []byte(content), 0o644))
r := &GradleResolver{}
out, err := r.Resolve(dir, model.ResolveOptions{AllowToolShellOut: true})
require.NoError(t, err)
require.Len(t, out, 1)
assert.Equal(t, model.ResolutionUnresolved, out[0].Resolution)
assert.Equal(t, "Gradle tool-tier dependency-license resolution is not supported; this resolver remains detect-only and does not shell out", out[0].Reason)
}
// TestGradleResolveNoManifest verifies Resolve on a directory with no build script
// returns an empty (non-nil-handled) result: scanGradleManifest's read-error path.
func TestGradleResolveNoManifest(t *testing.T) {
+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.
+21
View File
@@ -223,6 +223,27 @@ func idsFrom(s *store, err error) []string {
return out
}
// RenderableIDs returns every SPDX identifier with vendored license detail the tool
// can render, sorted by id. It is intentionally narrower than IDs(), which returns
// every non-deprecated id from the full SPDX index.
func RenderableIDs() []string {
return renderableIDsFrom(get())
}
// renderableIDsFrom builds the sorted id list from the curated rendering set. On a
// snapshot load error it returns nil, matching IDs()'s empty-picker degradation.
func renderableIDsFrom(s *store, err error) []string {
if err != nil {
return nil
}
out := make([]string, 0, len(s.licenses))
for id := range s.licenses {
out = append(out, id)
}
sort.Strings(out)
return out
}
// CommonIDs returns a curated, display-ordered shortlist of the licenses users pick
// most often. WHY a hand-ordered slice rather than a sorted subset of IDs: the order
// is editorial (most-recommended first, permissive before copyleft), so the picker
+8 -3
View File
@@ -163,15 +163,19 @@ func TestAccessorsWithLoadError(t *testing.T) {
assert.Equal(t, "", listVersionFrom(nil, errStore), "ListVersion must be empty on load error")
assert.Nil(t, idsFrom(nil, errStore), "IDs must be nil on load error so the picker degrades to empty")
assert.Nil(t, renderableIDsFrom(nil, errStore), "RenderableIDs must be nil on load error so the picker degrades to empty")
}
// TestAccessorsFromHelpersHappyPath confirms the extracted helpers preserve the
// non-error behavior they were factored out of.
func TestAccessorsFromHelpersHappyPath(t *testing.T) {
s := &store{
ids: map[string]indexEntry{"MIT": {LicenseID: "MIT", Name: "MIT License"}},
licenses: map[string]model.License{"MIT": {SPDXID: "MIT", Text: "body"}},
version: "test-1.0",
ids: map[string]indexEntry{"MIT": {LicenseID: "MIT", Name: "MIT License"}},
licenses: map[string]model.License{
"Apache-2.0": {SPDXID: "Apache-2.0", Text: "apache body"},
"MIT": {SPDXID: "MIT", Text: "body"},
},
version: "test-1.0",
}
assert.True(t, validateFrom(s, nil)("MIT"))
@@ -185,6 +189,7 @@ func TestAccessorsFromHelpersHappyPath(t *testing.T) {
assert.False(t, ok)
assert.Equal(t, "test-1.0", listVersionFrom(s, nil))
assert.Equal(t, []string{"Apache-2.0", "MIT"}, renderableIDsFrom(s, nil))
}
// TestClassifyAllBranches drives every arm of classify, including the default
+8
View File
@@ -120,6 +120,14 @@ func TestIDs(t *testing.T) {
assert.Contains(t, ids, "MIT", "the canonical MIT id should be offered")
}
func TestRenderableIDs(t *testing.T) {
ids := RenderableIDs()
require.NotEmpty(t, ids, "the vendored snapshot must yield renderable ids")
assert.True(t, sort.StringsAreSorted(ids), "RenderableIDs() must be sorted")
assert.Contains(t, ids, "MIT", "curated MIT id should be renderable")
assert.NotContains(t, ids, "Zlib", "valid SPDX ids without vendored detail must not be renderable")
}
// TestCommonIDs confirms every curated shortlist id validates against the full
// vendored index, so the picker can never present a "common" option that the rest
// of the tool would later reject as an unknown SPDX id.
+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
+15 -5
View File
@@ -36,22 +36,32 @@ fi
# ---------------------------------------------------------------------------
# Gate 2/6: Static analysis
# ---------------------------------------------------------------------------
echo "=== Gate 2/6: go vet ==="
echo "=== Gate 2/6: static analysis ==="
if go vet ./...; then
pass "go vet"
else
fail "go vet reported problems"
fi
if golangci-lint run; then
pass "golangci-lint run"
else
fail "golangci-lint reported problems"
fi
# ---------------------------------------------------------------------------
# Gate 3/6: Tests with the race detector
# Gate 3/6: Tests with the race detector and coverage gate
# ---------------------------------------------------------------------------
echo "=== Gate 3/6: go test -race ==="
if go test ./... -race; then
pass "go test ./... -race"
echo "=== Gate 3/6: go test -race + coverage ==="
if go test ./... -race -coverpkg=./internal/...,./cmd/... -covermode=atomic -coverprofile=cover.out; then
pass "go test ./... -race with coverage profile"
else
fail "tests failed under the race detector"
fi
if go run github.com/vladopajic/go-test-coverage/v2@v2.18.8 --config=.testcoverage.yml; then
pass "100% coverage gate"
else
fail "coverage gate failed"
fi
# ---------------------------------------------------------------------------
# Gate 4/6: Release tooling (GoReleaser)