Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43fa40c111
|
||
|
|
48bf47d4a8 | ||
|
|
7da7f93fc7 | ||
|
|
147082206f | ||
|
|
8a8bc4b1e4 | ||
|
|
c32a65621c | ||
|
|
9cf00681b7 | ||
|
|
a14dee227a | ||
|
|
e1e4563e03 | ||
|
|
5f11b5ad38 | ||
|
|
d4c72311cf | ||
|
|
e116c60b88 | ||
|
|
c035b8be6b | ||
|
|
472d78b914 | ||
|
|
6497f0be81 | ||
|
|
05157ddf48 | ||
|
|
20a0cd0f48 | ||
|
|
194fb0e49f | ||
|
|
7159bc1b48 | ||
|
|
3852c9122b | ||
|
|
56c3e9410b | ||
|
|
2822d862d8 | ||
|
|
ce192cad54 | ||
|
|
bbba9d28bf | ||
|
|
366ffece45 | ||
|
|
b43e362a21 | ||
|
|
83df6402ab | ||
|
|
96389108a8 | ||
|
|
29c7108c80 | ||
|
|
daef9c396d | ||
|
|
48fd159368 | ||
|
|
b28d245c60 | ||
|
|
dd4b34ba93 | ||
|
|
c3755f7baf | ||
|
|
6051a36198 | ||
|
|
32b44e5143 | ||
|
|
0031b00eb3 | ||
|
|
09c8174841 | ||
|
|
a6cc331fea | ||
|
|
45dcfd90e1 | ||
|
|
ddcc51d27f | ||
|
|
bafa2810a6 | ||
|
|
4e3561ef29 | ||
|
|
44d4986063 | ||
|
|
1bfddf89f9 | ||
|
|
ca3479ef02 | ||
|
|
f80ab905f2 | ||
|
|
ef70f64fff | ||
|
|
4773596e5d | ||
|
|
fa1b86da03 | ||
|
|
5a66996d73 | ||
|
|
cff8435402 | ||
|
|
7e177a914f | ||
|
|
eb66a7616e | ||
|
|
742c124e6e | ||
|
|
8c789ce50c | ||
|
|
c63e2514f1 | ||
|
|
03510d13d1 | ||
|
|
36ec31d511 | ||
|
|
9893efdc37 | ||
|
|
0e2b665463 | ||
|
|
5936852a1b | ||
|
|
0232911ea9 | ||
|
|
6c482a964d | ||
|
|
a2c764876d | ||
|
|
42a3dada18 | ||
|
|
9fff8debaf | ||
|
|
bf86694c37 | ||
|
|
c4538c6907 | ||
|
|
b0b566e600 | ||
|
|
54bf7d015a | ||
|
|
3e18e6c8d5 | ||
|
|
5d862308b5 | ||
|
|
7d2bf46d0a | ||
|
|
926e59c0fd |
No files matched your search
@@ -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/*
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
-
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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$
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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?
|
||||
@@ -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.
|
||||
@@ -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 `//`.
|
||||
@@ -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
@@ -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`.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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")))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:]...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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, "/")
|
||||
}
|
||||
@@ -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)"])
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Executable
+34
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user