Compare commits

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

Refs #6

Refs #7

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

Closes #19

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

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

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

Refs #24

Refs #27
2026-06-04 11:41:39 -05:00
james.maes 7d2bf46d0a fix: honor cli reporting contract
Maps CLI exit codes, restores check/audit rendering controls, supports dependency skip alias, and rejects invalid resolver tiers. Covers #16, #17, #18, #25.
2026-06-04 11:41:29 -05:00
james.maes 926e59c0fd docs: plan audit fixes 2026-06-04 11:32:24 -05:00
james.maesandJames Maes f4a111f8ec fix: preserve shebang universally so headers never precede #! (#5)
Co-authored-by: James Maes <jmaes@dmdbrands.com>
2026-06-04 10:58:58 -05:00
47 changed files with 3076 additions and 415 deletions

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
name: Bug report
description: Report a reproducible license-tool failure or incorrect result.
title: "bug: "
labels:
- bug
body:
- type: markdown
attributes:
value: |
Do not include vulnerability details in a public issue. Use SECURITY.md for private reporting.
- type: textarea
id: summary
attributes:
label: Summary
description: What happened, and what did you expect instead?
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Reproduction
description: Minimal commands, config, and fixture files needed to reproduce.
placeholder: |
license-tool audit ./repo --format json
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: Output from license-tool version.
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: OS, shell, package manager, and relevant ecosystem tooling.
validations:
required: false
- type: textarea
id: logs
attributes:
label: Output
description: Relevant command output, with secrets removed.
render: text
validations:
required: false
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/KofTwentyTwo/license-tool/security/policy
about: Report vulnerabilities privately through the repository security policy.
- name: Releases
url: https://github.com/KofTwentyTwo/license-tool/releases
about: Download the latest release and inspect published changelogs.
@@ -0,0 +1,34 @@
name: Feature request
description: Propose a new capability or behavior change.
title: "feat: "
labels:
- enhancement
body:
- type: textarea
id: problem
attributes:
label: Problem
description: What workflow or limitation should this address?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposal
description: Describe the behavior you want license-tool to provide.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives
description: Existing workarounds or approaches you considered.
validations:
required: false
- type: textarea
id: context
attributes:
label: Additional context
description: Repos, ecosystems, policies, or output formats affected.
validations:
required: false
+15
View File
@@ -0,0 +1,15 @@
## Summary
-
## Verification
- [ ] `gofmt -l .`
- [ ] `go vet ./...`
- [ ] `golangci-lint run`
- [ ] `go test ./... -race -cover`
- [ ] `go run github.com/vladopajic/go-test-coverage/v2@v2.18.8 --config=.testcoverage.yml`
## Notes
-
+6 -6
View File
@@ -15,25 +15,25 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# GoReleaser needs full history and all tags to compute the changelog
# and resolve the previous tag.
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.26"
go-version: "1.26.4"
cache: true
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
with:
version: latest
version: v2.16.0
args: release --clean
env:
# GITHUB_TOKEN publishes the GitHub Release in this repo.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# HOMEBREW_TAP_TOKEN pushes the generated formula to KofTwentyTwo/homebrew-tap.
# HOMEBREW_TAP_TOKEN pushes the generated cask to KofTwentyTwo/homebrew-tap.
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
+11 -7
View File
@@ -59,20 +59,24 @@ release:
changelog:
use: github
brews:
homebrew_casks:
- name: license-tool
# Write the formula under Formula/ (Homebrew's convention and where brew looks);
# the default empty directory drops it at the tap root, where brew ignores it.
directory: Formula
ids:
- license-tool
binaries:
- license-tool
repository:
owner: KofTwentyTwo
name: homebrew-tap
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
homepage: https://github.com/KofTwentyTwo/license-tool
description: "Audit and standardize license headers and metadata across codebases"
license: MIT
commit_author:
name: github-actions[bot]
email: 41898282+github-actions[bot]@users.noreply.github.com
test: |
system "#{bin}/license-tool", "version"
hooks:
post:
install: |
if OS.mac?
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/license-tool"]
end
+32
View File
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.3.0] - 2026-06-04
### 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
- Preserve a leading `#!` shebang universally (not per-type), so `apply` never inserts the header before the shebang. Fixes corruption of shebang-bearing scripts whose type lacked an explicit shebang rule (e.g. a PHP CLI script `#!/usr/bin/env php` + `<?php`), where the block-comment header was written at byte 0, breaking the file.
## [0.2.0] - 2026-06-03
### Added
+5
View File
@@ -0,0 +1,5 @@
# 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 ./...`.
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.
+43 -6
View File
@@ -11,7 +11,7 @@ The tool has two halves: an audit half that reports the licenses in use across d
Homebrew releases are configured through GoReleaser:
```bash
brew install KofTwentyTwo/tap/license-tool
brew install --cask KofTwentyTwo/tap/license-tool
```
From source:
@@ -52,11 +52,32 @@ 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
```
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)
```
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 +114,22 @@ license-tool license --license AGPL-3.0-or-later --holder "Kingsrook, LLC" --wri
### init
Scaffold a `.license-tool.yaml` for the repo. On a TTY 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` runs a wizard with
a filterable SPDX license picker that lists common licenses first, then prompts
for holder, year policy, header style, and whether to manage the top-level
`LICENSE` files. The holder prompt requires a non-empty value, and the license
and year prompts validate against the same parsers used by the flag path.
```bash
license-tool init
license-tool init --license MIT --holder "Example, Inc." --year git --style reuse+notice
```
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
@@ -122,7 +153,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. 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)
@@ -161,14 +192,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.
+12 -9
View File
@@ -37,8 +37,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 +48,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
```
@@ -77,19 +79,20 @@ 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 and CodeQL 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:
creates the GitHub Release, and publishes the Homebrew cask to:
```text
KofTwentyTwo/homebrew-tap
@@ -98,7 +101,7 @@ KofTwentyTwo/homebrew-tap
End users install with:
```bash
brew install KofTwentyTwo/tap/license-tool
brew install --cask KofTwentyTwo/tap/license-tool
```
## Required GitHub Setup
@@ -116,7 +119,7 @@ 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
workflow checks out that repository and commits cask updates. Do not hardcode this token
in workflow files.
Before the first production release:
+12 -8
View File
@@ -4,20 +4,24 @@
| Version | Supported |
| ------- | --------- |
| 1.0.x | yes |
| 0.x | no |
| Latest published release | yes |
| Older releases | no |
| Unreleased branch builds and snapshots | no |
`license-tool` is currently pre-1.0. Security fixes are released on the latest
published release line, and users should upgrade to the newest GitHub Release when
a fix is published.
## Reporting A Vulnerability
**Do not open a public GitHub issue for security vulnerabilities.**
Instead, please use one of the following methods:
Use GitHub's private vulnerability reporting flow from the repository security
policy page:
1. **GitHub Security Advisories**: Create a private security advisory at
`https://github.com/KofTwentyTwo/license-tool/security/advisories`
2. **PGP Encryption**: If you have the maintainer's PGP public key,
encrypt your report and send it via email.
```text
https://github.com/KofTwentyTwo/license-tool/security/policy
```
### What To Include
+14
View File
@@ -0,0 +1,14 @@
# Support
Use GitHub Issues for public support requests:
```text
https://github.com/KofTwentyTwo/license-tool/issues
```
Open a bug report for reproducible failures, incorrect audit output, release
installation problems, or documentation defects. Open a feature request for new
ecosystem support, policy/reporting changes, or workflow improvements.
For security vulnerabilities, do not include details in a public issue. Follow
the private reporting instructions in [SECURITY.md](SECURITY.md).
+246 -39
View File
@@ -1,8 +1,13 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"github.com/spf13/cobra"
"golang.org/x/term"
@@ -75,10 +80,15 @@ type auditFlags struct {
format string
output string
deps bool
noDeps bool
resolveDeps string
failOn []string
}
func (f auditFlags) includeDeps() bool {
return f.deps && !f.noDeps
}
func newAuditCmd(shared *sharedFlags) *cobra.Command {
f := &auditFlags{}
cmd := &cobra.Command{
@@ -89,22 +99,28 @@ 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)
}
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); err != nil {
return err
}
return report.Render(cmd.OutOrStdout(), r, format)
return nil
},
}
bindAuditFlags(cmd, f, false)
@@ -121,23 +137,36 @@ 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)
}
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); 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,6 +182,7 @@ 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")
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 +195,7 @@ type applyFlags struct {
holder string
year string
style string
format string
write bool
allowDirty bool
force bool
@@ -182,26 +213,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: shared.include,
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 +255,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: shared.include,
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 +286,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) error {
if output == "" {
if err := report.Render(cmd.OutOrStdout(), r, format); 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.Render(f, r, format)
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)")
@@ -261,12 +397,16 @@ var interactiveCollect = collectInteractive
// 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.
// gate that both the TTY and flag-only paths funnel through, so an invalid or
// unrenderable license and an empty holder are rejected identically regardless of
// how the answers arrived.
func answersToConfig(a initAnswers) (model.Config, error) {
if !spdx.Validate(a.License) {
return model.Config{}, fmt.Errorf("init: %q is not a recognized SPDX license identifier", a.License)
}
if _, ok := spdx.Lookup(a.License); !ok {
return model.Config{}, fmt.Errorf("init: %q is a recognized SPDX license identifier, but license-tool cannot render it", a.License)
}
if a.Holder == "" {
return model.Config{}, fmt.Errorf("init: copyright holder is required")
}
@@ -310,15 +450,15 @@ func newInitCmd(shared *sharedFlags) *cobra.Command {
Excludes: shared.exclude,
}
if err := interactiveCollect(&a, isTTY()); err != nil {
return err
return usageError(err)
}
cfg, err := answersToConfig(a)
if err != nil {
return err
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
@@ -351,10 +491,10 @@ func newVersionCmd(info buildInfo) *cobra.Command {
// directly; ResolveDeps iterates the ecosystem resolvers detected at the root and
// concatenates their findings (unresolved entries included, never guessed).
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{
entries, err := enumerate.WithContent(root, enumerate.Options{
Includes: shared.include,
Excludes: excludes,
NoGitignore: shared.noGitignore,
@@ -385,19 +525,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{
+527 -8
View File
@@ -2,7 +2,9 @@ package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
@@ -13,7 +15,9 @@ import (
"github.com/stretchr/testify/require"
"github.com/KofTwentyTwo/license-tool/internal/config"
"github.com/KofTwentyTwo/license-tool/internal/enumerate"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/KofTwentyTwo/license-tool/internal/report"
"github.com/KofTwentyTwo/license-tool/internal/spdx"
)
@@ -61,6 +65,18 @@ func runRoot(t *testing.T, args ...string) (string, error) {
return out.String(), err
}
func executeRoot(t *testing.T, args ...string) (stdout string, stderr string, code int) {
t.Helper()
root := newRootCmd(testBuildInfo)
var out bytes.Buffer
var errOut bytes.Buffer
root.SetOut(&out)
root.SetErr(&errOut)
root.SetArgs(args)
code = execute(root)
return out.String(), errOut.String(), code
}
// writeFile writes content under dir/rel, creating parent directories, and returns
// the absolute path.
func writeFile(t *testing.T, dir, rel, content string) string {
@@ -72,6 +88,7 @@ func writeFile(t *testing.T, dir, rel, content string) string {
}
const configYAML = "license: AGPL-3.0-or-later\nholder: Acme, LLC\nyear: \"2026\"\n"
const configNoManagedYAML = configYAML + "manage_license_file: false\n"
// fixtureDir creates a temp directory with a committed .license-tool.yaml and a
// headerless Go source file.
@@ -89,6 +106,7 @@ func initGitRepo(t *testing.T, dir string) {
t.Helper()
for _, args := range [][]string{
{"init"},
{"config", "commit.gpgsign", "false"},
{"add", "-A"},
{"commit", "-m", "initial"},
} {
@@ -100,6 +118,31 @@ func initGitRepo(t *testing.T, dir string) {
}
}
func gitOutput(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
out, err := cmd.CombinedOutput()
require.NoError(t, err, "git %v failed:\n%s", args, out)
return strings.TrimSpace(string(out))
}
func jsonFileEntry(t *testing.T, out, path string) map[string]any {
t.Helper()
var raw map[string]any
require.NoError(t, json.Unmarshal([]byte(out), &raw))
files, ok := raw["files"].([]any)
require.True(t, ok)
for _, file := range files {
entry, ok := file.(map[string]any)
require.True(t, ok)
if entry["path"] == path {
return entry
}
}
t.Fatalf("no JSON file entry for %q in %v", path, files)
return nil
}
func TestNewRootCmd(t *testing.T) {
root := newRootCmd(testBuildInfo)
assert.Equal(t, "license-tool", root.Use)
@@ -148,6 +191,63 @@ func TestVersionCommand(t *testing.T) {
assert.Contains(t, out, "SPDX license list: "+spdx.ListVersion())
}
func TestExecutePrintsReturnedUsageErrors(t *testing.T) {
isolateEnv(t)
dir := fixtureDir(t)
stdout, stderr, code := executeRoot(t, "audit", dir, "--format", "xml", "--deps=false")
assert.Equal(t, 2, code)
assert.Empty(t, stdout)
assert.Contains(t, stderr, `unknown format "xml"`)
}
func TestExecuteReturnsZeroOnSuccess(t *testing.T) {
isolateEnv(t)
stdout, stderr, code := executeRoot(t, "version")
assert.Equal(t, 0, code)
assert.Contains(t, stdout, "license-tool 1.2.3")
assert.Empty(t, stderr)
}
func TestExecuteMapsUntypedCobraErrors(t *testing.T) {
isolateEnv(t)
stdout, stderr, code := executeRoot(t, "bogus")
assert.Equal(t, 2, code)
assert.Empty(t, stdout)
assert.Contains(t, stderr, `unknown command "bogus"`)
}
func TestExecuteMapsWriteRefusals(t *testing.T) {
isolateEnv(t)
dir := t.TempDir()
writeFile(t, dir, ".license-tool.yaml", configYAML)
writeFile(t, dir, "main.go", "package main\n")
stdout, stderr, code := executeRoot(t, "apply", dir, "--write")
assert.Equal(t, 3, code)
assert.Empty(t, stdout)
assert.Contains(t, stderr, "non-git directory without --force")
}
func TestExecuteMapsInternalErrors(t *testing.T) {
isolateEnv(t)
missing := filepath.Join(t.TempDir(), "does-not-exist")
stdout, stderr, code := executeRoot(t, "check", missing, "--deps=false")
assert.Equal(t, 4, code)
assert.Empty(t, stdout)
assert.Contains(t, stderr, "enumerate")
}
func TestWithExitCodeNilError(t *testing.T) {
assert.NoError(t, withExitCode(exitUsage, nil))
}
func TestAuditCommand(t *testing.T) {
isolateEnv(t)
@@ -167,6 +267,27 @@ func TestAuditCommand(t *testing.T) {
assert.Contains(t, out, `"schema": "license-tool/report/v1"`)
})
t.Run("output file", func(t *testing.T) {
dir := fixtureDir(t)
outPath := filepath.Join(dir, "audit.json")
out, err := runRoot(t, "audit", dir, "--format", "json", "--output", outPath, "--deps=false")
require.NoError(t, err)
assert.Empty(t, out)
data, rerr := os.ReadFile(outPath)
require.NoError(t, rerr)
assert.Contains(t, string(data), `"schema": "license-tool/report/v1"`)
})
t.Run("output file create error is internal error", func(t *testing.T) {
dir := fixtureDir(t)
outPath := filepath.Join(dir, "missing", "audit.json")
stdout, stderr, code := executeRoot(t, "audit", dir, "--format", "json", "--output", outPath, "--deps=false")
assert.Equal(t, 4, code)
assert.Empty(t, stdout)
assert.Contains(t, stderr, "create output")
})
t.Run("markdown format", func(t *testing.T) {
dir := fixtureDir(t)
out, err := runRoot(t, "audit", dir, "--format", "markdown", "--deps=false")
@@ -219,6 +340,71 @@ func TestAuditCommand(t *testing.T) {
assert.Contains(t, out, "unresolved")
})
t.Run("no-deps alias skips dependency resolution", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, ".license-tool.yaml", configYAML)
writeFile(t, dir, "package.json", `{"name":"x","dependencies":{"left-pad":"^1.0.0"}}`)
out, err := runRoot(t, "audit", dir, "--format", "json", "--no-deps")
require.NoError(t, err)
assert.Contains(t, out, `"dependencies": []`)
assert.NotContains(t, out, "left-pad")
})
t.Run("invalid resolver tier is usage error", func(t *testing.T) {
dir := fixtureDir(t)
stdout, stderr, code := executeRoot(t, "audit", dir, "--resolve-deps", "typo", "--deps=false")
assert.Equal(t, 2, code)
assert.Empty(t, stdout)
assert.Contains(t, stderr, `unknown dependency resolver tier "typo"`)
})
t.Run("deps resolution discovers nested manifests", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, ".license-tool.yaml", configYAML)
writeFile(t, dir, filepath.Join("services", "api", "package.json"), `{"name":"api","dependencies":{"left-pad":"1.3.0"}}`)
writeFile(t, dir, filepath.Join("services", "api", "node_modules", "left-pad", "package.json"), `{"name":"left-pad","version":"1.3.0","license":"MIT"}`)
out, err := runRoot(t, "audit", dir, "--format", "json")
require.NoError(t, err)
var got struct {
Dependencies []struct {
Ecosystem string `json:"ecosystem"`
Name string `json:"name"`
Version string `json:"version"`
SPDXID string `json:"spdxId"`
Resolution string `json:"resolution"`
} `json:"dependencies"`
}
require.NoError(t, json.Unmarshal([]byte(out), &got))
require.Len(t, got.Dependencies, 1)
assert.Equal(t, "npm", got.Dependencies[0].Ecosystem)
assert.Equal(t, "left-pad", got.Dependencies[0].Name)
assert.Equal(t, "1.3.0", got.Dependencies[0].Version)
assert.Equal(t, "MIT", got.Dependencies[0].SPDXID)
assert.Equal(t, "resolved", got.Dependencies[0].Resolution)
})
t.Run("gradle tool tier reports unsupported resolver", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, ".license-tool.yaml", configYAML)
writeFile(t, dir, "build.gradle", `dependencies { implementation 'com.google.guava:guava:31.1-jre' }`)
out, err := runRoot(t, "audit", dir, "--format", "json", "--resolve-deps", "tool")
require.NoError(t, err)
var got struct {
Dependencies []struct {
Name string `json:"name"`
Reason string `json:"reason"`
} `json:"dependencies"`
}
require.NoError(t, json.Unmarshal([]byte(out), &got))
require.Len(t, got.Dependencies, 1)
assert.Equal(t, "com.google.guava:guava", got.Dependencies[0].Name)
assert.Contains(t, got.Dependencies[0].Reason, "Gradle tool-tier dependency-license resolution is not supported")
})
t.Run("resolve dependency error aborts audit", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, ".license-tool.yaml", configYAML)
@@ -268,6 +454,51 @@ func TestCheckCommand(t *testing.T) {
require.NoError(t, err)
})
t.Run("fail-on flag overrides check policy", func(t *testing.T) {
dir := fixtureDir(t)
out, err := runRoot(t, "check", dir, "--fail-on", "unresolved-dependency", "--deps=false")
require.NoError(t, err)
assert.Contains(t, out, "result: PASS")
})
t.Run("json output file", func(t *testing.T) {
dir := fixtureDir(t)
outPath := filepath.Join(dir, "check.json")
out, err := runRoot(t, "check", dir, "--format", "json", "--output", outPath, "--fail-on", "unresolved-dependency", "--deps=false")
require.NoError(t, err)
assert.Empty(t, out)
data, rerr := os.ReadFile(outPath)
require.NoError(t, rerr)
assert.Contains(t, string(data), `"schema": "license-tool/report/v1"`)
assert.Contains(t, string(data), `"passed": true`)
})
t.Run("invalid fail-on flag is usage error", func(t *testing.T) {
dir := fixtureDir(t)
stdout, stderr, code := executeRoot(t, "check", dir, "--fail-on", "bogus", "--deps=false")
assert.Equal(t, 2, code)
assert.Empty(t, stdout)
assert.Contains(t, stderr, `unknown fail-on condition "bogus"`)
})
t.Run("output file create error is internal error", func(t *testing.T) {
dir := fixtureDir(t)
outPath := filepath.Join(dir, "missing", "check.json")
stdout, stderr, code := executeRoot(t, "check", dir, "--output", outPath, "--fail-on", "unresolved-dependency", "--deps=false")
assert.Equal(t, 4, code)
assert.Empty(t, stdout)
assert.Contains(t, stderr, "create output")
})
t.Run("invalid resolver tier is usage error", func(t *testing.T) {
dir := fixtureDir(t)
stdout, stderr, code := executeRoot(t, "check", dir, "--resolve-deps", "typo", "--fail-on", "unresolved-dependency", "--deps=false")
assert.Equal(t, 2, code)
assert.Empty(t, stdout)
assert.Contains(t, stderr, `unknown dependency resolver tier "typo"`)
})
t.Run("config resolve error", func(t *testing.T) {
dir := fixtureDir(t)
_, err := runRoot(t, "check", dir, "--config", filepath.Join(dir, "missing.yaml"))
@@ -308,12 +539,11 @@ func TestCheckCommand(t *testing.T) {
func TestCheckFailingExits(t *testing.T) {
if os.Getenv("LICENSE_TOOL_CHECK_EXIT_CHILD") == "1" {
// Child: a headerless tree under the default fail_on fails the policy gate,
// so check calls os.Exit(1).
// so the top-level executor should exit 1 after rendering the report.
dir := os.Getenv("LICENSE_TOOL_CHECK_EXIT_DIR")
root := newRootCmd(testBuildInfo)
root.SetArgs([]string{"check", dir, "--deps=false"})
_ = root.Execute()
return
os.Exit(execute(root))
}
isolateEnv(t)
@@ -335,6 +565,54 @@ func TestCheckFailingExits(t *testing.T) {
var exitErr *exec.ExitError
require.ErrorAs(t, err, &exitErr, "child should exit non-zero; output:\n%s", out)
assert.Equal(t, 1, exitErr.ExitCode(), "check failure exits with code 1")
assert.Contains(t, string(out), "license-tool audit report")
assert.Contains(t, string(out), "result: FAIL")
}
func TestDependencyManifestClassifier(t *testing.T) {
ft, ok := dependencyManifestClassifier(filepath.Join("services", "api", "package.json"))
require.True(t, ok)
assert.Equal(t, dependencyManifestFileType, ft)
ft, ok = dependencyManifestClassifier("main.go")
assert.False(t, ok)
assert.Equal(t, model.FileType{}, ft)
}
func TestDependencyManifestDirs(t *testing.T) {
root := filepath.Join(string(os.PathSeparator), "repo")
entries := []enumerate.Entry{
{Path: "package.json"},
{Path: "services/api/package.json"},
{Path: "services/api/build.gradle"},
{Path: "ignored/package.json", Skip: true},
}
assert.Equal(t, []string{
root,
filepath.Join(root, "services", "api"),
}, dependencyManifestDirs(root, entries))
}
func TestResolveDependencyManifestsHonorsIgnoreAndPrunesHeavyDirs(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, ".gitignore", "ignored/\n")
writeFile(t, dir, filepath.Join("services", "api", "package.json"), `{"name":"api","dependencies":{"left-pad":"1.3.0"}}`)
writeFile(t, dir, filepath.Join("services", "api", "node_modules", "left-pad", "package.json"), `{"name":"left-pad","version":"1.3.0","license":"MIT"}`)
writeFile(t, dir, filepath.Join("ignored", "package.json"), `{"name":"ignored","dependencies":{"ignored-lib":"1.0.0"}}`)
writeFile(t, dir, filepath.Join("node_modules", "scanned-if-not-pruned", "package.json"), `{"name":"scanned-if-not-pruned","dependencies":{"bad":"1.0.0"}}`)
deps, err := resolveDependencyManifests(dir, nil, false, false)
require.NoError(t, err)
require.Len(t, deps, 1)
assert.Equal(t, "left-pad", deps[0].Name)
assert.Equal(t, "MIT", deps[0].SPDXID)
}
func TestResolveDependencyManifestsMissingRoot(t *testing.T) {
deps, err := resolveDependencyManifests(filepath.Join(t.TempDir(), "missing"), nil, false, false)
require.Error(t, err)
assert.Nil(t, deps)
}
func TestApplyCommand(t *testing.T) {
@@ -346,6 +624,8 @@ func TestApplyCommand(t *testing.T) {
out, err := runRoot(t, "apply", dir)
require.NoError(t, err)
assert.Contains(t, out, "license-tool audit report")
assert.Contains(t, out, "@@")
assert.Contains(t, out, "+ SPDX-License-Identifier: AGPL-3.0-or-later")
// Dry run leaves the source untouched.
content, rerr := os.ReadFile(filepath.Join(dir, "main.go"))
@@ -353,6 +633,22 @@ func TestApplyCommand(t *testing.T) {
assert.NotContains(t, string(content), "GNU Affero")
})
t.Run("dry-run json includes unified diff", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, ".license-tool.yaml", configNoManagedYAML)
writeFile(t, dir, "main.go", "package main\n")
initGitRepo(t, dir)
out, err := runRoot(t, "apply", dir, "--include", "main.go", "--format", "json")
require.NoError(t, err)
main := jsonFileEntry(t, out, "main.go")
diff, ok := main["diff"].(string)
require.True(t, ok)
assert.Contains(t, diff, "+++ Go")
assert.Contains(t, diff, "+ SPDX-License-Identifier: AGPL-3.0-or-later")
})
t.Run("write applies headers", func(t *testing.T) {
dir := fixtureDir(t)
initGitRepo(t, dir)
@@ -365,6 +661,19 @@ func TestApplyCommand(t *testing.T) {
assert.Contains(t, string(content), "GNU Affero")
})
t.Run("write json omits unified diff", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, ".license-tool.yaml", configNoManagedYAML)
writeFile(t, dir, "main.go", "package main\n")
initGitRepo(t, dir)
out, err := runRoot(t, "apply", dir, "--write", "--include", "main.go", "--format", "json")
require.NoError(t, err)
main := jsonFileEntry(t, out, "main.go")
assert.NotContains(t, main, "diff")
})
t.Run("config resolve error", func(t *testing.T) {
dir := fixtureDir(t)
_, err := runRoot(t, "apply", dir, "--config", filepath.Join(dir, "missing.yaml"))
@@ -380,6 +689,23 @@ func TestApplyCommand(t *testing.T) {
assert.Contains(t, err.Error(), "not a recognized SPDX license identifier")
})
t.Run("bad format", func(t *testing.T) {
dir := fixtureDir(t)
_, err := runRoot(t, "apply", dir, "--format", "xml")
require.Error(t, err)
assert.Contains(t, err.Error(), `unknown format "xml"`)
})
t.Run("valid but non-curated license rejected before apply", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "main.go", "package main\n")
_, err := runRoot(t, "apply", dir, "--license", "Zlib", "--holder", "Acme")
require.Error(t, err)
assert.Contains(t, err.Error(), `"Zlib"`)
assert.Contains(t, err.Error(), "cannot render")
assert.NotContains(t, err.Error(), "unknown license")
})
t.Run("apply error: write in non-git dir without force", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, ".license-tool.yaml", configYAML)
@@ -388,6 +714,71 @@ func TestApplyCommand(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "non-git directory without --force")
})
t.Run("write honors include scope", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, ".license-tool.yaml", configNoManagedYAML)
writeFile(t, dir, "included.go", "package included\n")
writeFile(t, dir, "ignored.go", "package ignored\n")
initGitRepo(t, dir)
_, err := runRoot(t, "apply", dir, "--write", "--include", "included.go")
require.NoError(t, err)
included, rerr := os.ReadFile(filepath.Join(dir, "included.go"))
require.NoError(t, rerr)
assert.Contains(t, string(included), "SPDX-License-Identifier: AGPL-3.0-or-later")
ignored, rerr := os.ReadFile(filepath.Join(dir, "ignored.go"))
require.NoError(t, rerr)
assert.NotContains(t, string(ignored), "SPDX-License-Identifier")
})
t.Run("allow-dirty commit leaves unrelated changes uncommitted", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, ".license-tool.yaml", configNoManagedYAML)
writeFile(t, dir, "main.go", "package main\n")
writeFile(t, dir, "unrelated.go", "package unrelated\n")
initGitRepo(t, dir)
writeFile(t, dir, "unrelated.go", "package unrelated\n\nvar Dirty = true\n")
_, err := runRoot(t, "apply", dir,
"--write",
"--allow-dirty",
"--commit",
"--commit-message", "chore: scoped license update",
"--include", "main.go",
)
require.NoError(t, err)
assert.Equal(t, "chore: scoped license update", gitOutput(t, dir, "log", "-1", "--format=%s"))
changedInCommit := gitOutput(t, dir, "show", "--name-only", "--format=", "HEAD")
assert.Contains(t, changedInCommit, "main.go")
assert.NotContains(t, changedInCommit, "unrelated.go")
assert.Contains(t, gitOutput(t, dir, "status", "--porcelain"), "unrelated.go")
})
t.Run("valid but non-curated license maps to usage error", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "main.go", "package main\n")
stdout, stderr, code := executeRoot(t, "apply", dir, "--license", "Zlib", "--holder", "Acme")
assert.Equal(t, 2, code)
assert.Empty(t, stdout)
assert.Contains(t, stderr, `"Zlib"`)
assert.Contains(t, stderr, "cannot render")
assert.NotContains(t, stderr, "unknown license")
})
t.Run("render error is internal error", func(t *testing.T) {
dir := fixtureDir(t)
root := newRootCmd(testBuildInfo)
root.SetOut(errorWriter{err: errors.New("write failed")})
root.SetArgs([]string{"apply", dir})
err := root.Execute()
require.Error(t, err)
assert.Equal(t, exitInternal, exitCode(err))
assert.Contains(t, err.Error(), "write failed")
})
}
func TestLicenseCommand(t *testing.T) {
@@ -399,6 +790,8 @@ func TestLicenseCommand(t *testing.T) {
require.NoError(t, err)
assert.Contains(t, out, "LICENSE:")
assert.Contains(t, out, filepath.Join("LICENSES", "AGPL-3.0-or-later.txt"))
assert.Contains(t, out, "+++ LICENSE")
assert.Contains(t, out, "+GNU AFFERO GENERAL PUBLIC LICENSE")
})
t.Run("write creates LICENSE files", func(t *testing.T) {
@@ -414,6 +807,41 @@ func TestLicenseCommand(t *testing.T) {
require.NoError(t, statErr)
})
t.Run("write in non-git dir without force is refused", func(t *testing.T) {
dir := fixtureDir(t)
_, err := runRoot(t, "license", dir, "--write")
require.Error(t, err)
assert.Contains(t, err.Error(), "non-git directory without --force")
_, statErr := os.Stat(filepath.Join(dir, "LICENSE"))
assert.True(t, os.IsNotExist(statErr))
})
t.Run("write in dirty git tree without allow-dirty is refused", func(t *testing.T) {
dir := fixtureDir(t)
initGitRepo(t, dir)
writeFile(t, dir, "main.go", "package main\n\nvar Dirty = true\n")
_, err := runRoot(t, "license", dir, "--write")
require.Error(t, err)
assert.Contains(t, err.Error(), "dirty git tree")
_, statErr := os.Stat(filepath.Join(dir, "LICENSE"))
assert.True(t, os.IsNotExist(statErr))
})
t.Run("commit flags create scoped license-file commit", func(t *testing.T) {
dir := fixtureDir(t)
initGitRepo(t, dir)
_, err := runRoot(t, "license", dir, "--write", "--commit", "--commit-message", "chore: add license files")
require.NoError(t, err)
assert.Equal(t, "chore: add license files", gitOutput(t, dir, "log", "-1", "--format=%s"))
changedInCommit := gitOutput(t, dir, "show", "--name-only", "--format=", "HEAD")
assert.Contains(t, changedInCommit, "LICENSE")
assert.Contains(t, changedInCommit, filepath.ToSlash(filepath.Join("LICENSES", "AGPL-3.0-or-later.txt")))
assert.Empty(t, gitOutput(t, dir, "status", "--porcelain"))
})
t.Run("config resolve error", func(t *testing.T) {
dir := fixtureDir(t)
_, err := runRoot(t, "license", dir, "--config", filepath.Join(dir, "missing.yaml"))
@@ -429,15 +857,17 @@ func TestLicenseCommand(t *testing.T) {
assert.Contains(t, err.Error(), "not a recognized SPDX license identifier")
})
t.Run("valid but non-curated license surfaces a manage error", func(t *testing.T) {
// Zlib is a real SPDX id (passes config validation and spdx.Validate) but is
// outside the curated rendering set, so ManageLicenseFiles' spdx.Lookup fails
// and the command returns that error.
t.Run("valid but non-curated license rejected before rendering", func(t *testing.T) {
// Zlib is a real SPDX id, but this write path needs a license template the
// tool can render. The command must fail during config resolution rather than
// reaching ManageLicenseFiles and surfacing an unknown-license render error.
dir := t.TempDir()
writeFile(t, dir, "main.go", "package main\n")
_, err := runRoot(t, "license", dir, "--license", "Zlib", "--holder", "Acme")
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown license")
assert.Contains(t, err.Error(), `"Zlib"`)
assert.Contains(t, err.Error(), "cannot render")
assert.NotContains(t, err.Error(), "unknown license")
})
}
@@ -468,6 +898,16 @@ func TestInitCommand(t *testing.T) {
assert.Contains(t, err.Error(), "SPDX license identifier")
})
t.Run("valid but non-curated license rejected before scaffold", func(t *testing.T) {
dir := t.TempDir()
_, err := runRoot(t, "init", dir, "--license", "Zlib", "--holder", "Acme")
require.Error(t, err)
assert.Contains(t, err.Error(), `"Zlib"`)
assert.Contains(t, err.Error(), "cannot render")
_, statErr := os.Stat(filepath.Join(dir, ".license-tool.yaml"))
assert.True(t, os.IsNotExist(statErr), "unsupported render license must not be scaffolded")
})
t.Run("existing file needs force", func(t *testing.T) {
dir := t.TempDir()
_, err := runRoot(t, "init", dir, "--license", "MIT", "--holder", "Acme")
@@ -545,6 +985,65 @@ func TestAnswersToConfig(t *testing.T) {
})
}
func TestParseFailOnFlags(t *testing.T) {
got, err := parseFailOnFlags([]string{"missing-header, unknown-license", "policy-violation", "unresolved-dependency"})
require.NoError(t, err)
assert.Equal(t, []model.FailCondition{
model.FailOnMissingHeader,
model.FailOnUnknownLicense,
model.FailOnPolicyViolation,
model.FailOnUnresolvedDependency,
}, got)
_, err = parseFailOnFlags([]string{"missing-header", "bogus"})
require.Error(t, err)
assert.Contains(t, err.Error(), `unknown fail-on condition "bogus"`)
}
func TestRenderCommandReportErrors(t *testing.T) {
root := newRootCmd(testBuildInfo)
var out bytes.Buffer
root.SetOut(&out)
err := renderCommandReport(root, "", model.Report{}, report.Format(99))
require.Error(t, err)
assert.Equal(t, exitInternal, exitCode(err))
assert.Empty(t, out.String())
err = renderCommandReport(root, filepath.Join(t.TempDir(), "report.txt"), model.Report{}, report.Format(99))
require.Error(t, err)
assert.Equal(t, exitInternal, exitCode(err))
orig := createReportFile
createReportFile = func(string) (io.WriteCloser, error) {
return closeErrorWriter{closeErr: errors.New("close failed")}, nil
}
t.Cleanup(func() { createReportFile = orig })
err = renderCommandReport(root, filepath.Join(t.TempDir(), "report.txt"), model.Report{}, report.FormatText)
require.Error(t, err)
assert.Equal(t, exitInternal, exitCode(err))
assert.Contains(t, err.Error(), "close failed")
}
func TestWriteOrInternalErrorClassifiesUnexpectedErrors(t *testing.T) {
err := writeOrInternalError(errors.New("disk full"))
require.Error(t, err)
assert.Equal(t, exitInternal, exitCode(err))
}
func TestLicenseSelectOptions(t *testing.T) {
opts := licenseSelectOptions()
require.NotEmpty(t, opts, "license picker should offer renderable licenses")
values := make([]string, 0, len(opts))
for _, opt := range opts {
values = append(values, opt.Value)
}
assert.Contains(t, values, "MIT")
assert.NotContains(t, values, "Zlib", "picker must not offer a license the tool cannot render")
}
// TestInitCommandInteractiveCollectError covers the init RunE branch where the
// interactiveCollect seam returns an error: the command must propagate it verbatim
// and write nothing. The seam is overridden so the failure is deterministic without
@@ -648,3 +1147,23 @@ func TestBuildInfoString(t *testing.T) {
assert.Equal(t, "license-tool 9.9.9 (commit abc1234, built 2026-02-03)", b.String())
assert.True(t, strings.HasPrefix(b.String(), "license-tool "))
}
type errorWriter struct {
err error
}
func (w errorWriter) Write([]byte) (int, error) {
return 0, w.err
}
type closeErrorWriter struct {
closeErr error
}
func (w closeErrorWriter) Write(p []byte) (int, error) {
return len(p), nil
}
func (w closeErrorWriter) Close() error {
return w.closeErr
}
+46 -5
View File
@@ -4,8 +4,11 @@
package main
import (
"errors"
"fmt"
"os"
"github.com/spf13/cobra"
)
// Build metadata, injected via -ldflags by GoReleaser (see .goreleaser.yaml).
@@ -15,15 +18,53 @@ var (
date = "unknown"
)
const (
exitOK = 0
exitCheckFailure = 1
exitUsage = 2
exitWriteRefused = 3
exitInternal = 4
)
func main() {
root := newRootCmd(buildInfo{version: version, commit: commit, date: date})
os.Exit(execute(root))
}
func execute(root *cobra.Command) int {
if err := root.Execute(); err != nil {
// Cobra has already printed the error; exit non-zero. Richer per-mode exit
// codes (1 check failure, 3 write refused, 4 internal) are produced inside
// the subcommand RunE bodies, which call os.Exit directly when they need a
// specific code.
os.Exit(2)
fmt.Fprintln(root.ErrOrStderr(), err)
return exitCode(err)
}
return exitOK
}
type commandError struct {
code int
err error
}
func (e commandError) Error() string {
return e.err.Error()
}
func (e commandError) Unwrap() error {
return e.err
}
func withExitCode(code int, err error) error {
if err == nil {
return nil
}
return commandError{code: code, err: err}
}
func exitCode(err error) int {
var ce commandError
if errors.As(err, &ce) {
return ce.code
}
return exitUsage
}
// buildInfo carries the ldflags-injected version metadata into the command tree so
+4 -4
View File
@@ -30,9 +30,9 @@ type initAnswers struct {
// 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.
// recommended set), then the remaining renderable SPDX ids in sorted order with no
// duplicates. WHY common-first: the operator sees sensible defaults at the top
// before the alphabetical tail they can still filter into.
func licenseSelectOptions() []huh.Option[string] {
common := spdx.CommonIDs()
seen := make(map[string]bool, len(common))
@@ -41,7 +41,7 @@ func licenseSelectOptions() []huh.Option[string] {
seen[id] = true
opts = append(opts, huh.NewOption(id+" (common)", id))
}
for _, id := range spdx.IDs() {
for _, id := range spdx.RenderableIDs() {
if seen[id] {
continue
}
+30
View File
@@ -0,0 +1,30 @@
# PLAN: Audit Fixes
## Goal
Resolve GitHub issues #6 through #27 locally, using TDD, while preserving the 100% CI coverage gate and leaving the branch ready for a manual review pass before any push or release.
## Approach
Work on `feature/GH-12-27-audit-fixes` and split fixes into isolated worktrees by concern: header boundaries, file-type coverage, write safety, CLI/reporting, policy/SPDX, dependency/release/docs, and shared header placement. Each slice starts with failing behavior tests, then minimal implementation, then local verification. Results are integrated back into this branch and finally merged to local `develop` only after the full test and CI-equivalent gate passes.
## Files Affected
- `cmd/license-tool/*`: CLI output, exit codes, command behavior tests, wizard filtering.
- `internal/applier/*`: write gates, scoped enumeration, dry-run diffs, scoped commits.
- `internal/report/*`: report rendering for apply/check/audit outputs.
- `internal/config/*`: flag/config layering and policy ID validation.
- `internal/spdx/*`: renderable license contract.
- `internal/resolve/*`: resolver tier validation and dependency manifest discovery.
- `internal/render/*`, `internal/detect/*`, `internal/header/*`: shared header placement behavior and preserve-first constructs.
- `internal/filetype/*`, `internal/enumerate/*`: file-type coverage and shebang-based classification.
- `.github/workflows/*`, `.goreleaser.yaml`: release supply-chain pinning.
- `docs/*`, `SECURITY.md`, `.github/*`: public support and status scaffolding.
## Steps
1. Create worktrees and assign disjoint issue slices.
2. For each slice, write one failing test for one user-visible behavior.
3. Implement the minimal fix and keep the slice green.
4. Integrate slices into the feature branch, resolving conflicts by preserving tested behavior.
5. Run `gofmt`, `go vet`, `golangci-lint run`, `go test ./... -race -cover`, the 100% coverage gate, `go build ./...`, and gitleaks.
6. Merge the passing feature branch back into local `develop`.
## Open Questions
- None. Push, release tags, and promotion to `main` remain out of scope until manual review and explicit approval.
+27
View File
@@ -0,0 +1,27 @@
# Session State
## Current Branch
`feature/GH-12-27-audit-fixes`
## Current Objective
Resolve GitHub issues #6 through #27 using TDD, keep the 100% coverage gate passing, and merge the passing local feature branch back to local `develop`.
## Status
- Branch created from `develop`.
- Audit issues filed and de-duplicated against existing open issues #6 through #11.
- Plan and TODO created.
- Worktrees created under `/private/tmp/license-tool-worktrees/` for write safety, CLI reporting, SPDX/policy, dependency/release/docs, and header placement slices.
- Worker agents assigned:
- Write safety: #12, #13, #14, #15 on `feature/GH-12-27-write-safety`.
- CLI/reporting: #16, #17, #18, #25 on `feature/GH-12-27-cli-reporting`.
- SPDX/policy: #19, #20 on `feature/GH-12-27-spdx-policy`.
- Dependency/release/docs: #11, #21, #22, #23, #26 on `feature/GH-12-27-deps-release-docs`.
- Header placement: #6, #7, #8, #24, #27 on `feature/GH-12-27-header-placement`.
- File-type coverage: #9 and #10 on `feature/GH-9-10-filetype-coverage`.
- GitHub issue comments reviewed for #6 through #27 and closed tracker #3.
- Worker branches integrated into `feature/GH-12-27-audit-fixes`.
- Local gates passed: `gofmt -l .`, `go vet ./...`, `golangci-lint run`, `go test ./... -race -cover`, 100% configured coverage gate, `go build ./...`, `goreleaser check`, and `gitleaks detect`.
- `feature/GH-12-27-audit-fixes` fast-forward merged into local `develop`.
## Next Step
Manual review and testing before any push to GitHub, promotion to `main`, tag, or release.
+59
View File
@@ -0,0 +1,59 @@
# TODO: Audit Fixes
## Tracking
- [x] Worker agents assigned for all implementation slices.
- [x] GitHub issue comments reviewed for #6 through #27.
- [x] Keep `docs/SESSION-STATE.md` current after each integrated slice.
- [x] Keep coverage at the repository gate: total coverage 100% for the gated set.
## Header Boundary Preservation
- [x] #6: Preserve Go `//go:build` and `// +build` constraints before inserted headers.
- [x] #7: Preserve CSS `@charset` before inserted headers.
- [x] #8: Preserve HTML/XML `<!DOCTYPE>` before inserted headers.
## File-Type Coverage
- [x] #9: Add Perl, PowerShell, R, Makefile, TOML, and Batch file-type support.
- [x] #10: Detect extensionless script type from shebang content.
## README Docs
- [x] #11: Document the init wizard and audit findings summary accurately.
## Write Safety
- [x] #12: Make `license` honor write gates and either implement or reject commit options.
- [x] #13: Prevent dirty-tree apply commits from staging unrelated work.
- [x] #14: Make apply honor include and gitignore scope controls.
- [x] #15: Emit unified diffs during dry-run apply and license runs.
## Header Placement
- [x] #24: Preserve Python `coding=` pragmas before inserted headers.
- [x] #27: Deduplicate shared sentinel and header placement behavior.
## CLI And Reports
- [x] #16: Print errors and map documented exit codes.
- [x] #17: Make check fail-condition CLI overrides affect behavior.
- [x] #18: Honor audit/check output controls and documented dependency skip behavior.
- [x] #25: Reject invalid dependency resolver tier values.
## Policy And SPDX
- [x] #19: Align accepted SPDX IDs with renderable license support.
- [x] #20: Validate policy required, allow, and deny IDs or expressions.
## Dependencies And Release
- [x] #21: Pin release workflow actions and release tooling.
- [x] #22: Make Gradle tool-tier behavior explicit or implemented.
- [x] #23: Discover nested dependency manifests.
## Public Docs
- [x] #26: Refresh public support, security-policy, and scaffolding docs.
## Final Gates
- [x] `gofmt -l .`
- [x] `go vet ./...`
- [x] `golangci-lint run`
- [x] `go test ./... -race -cover`
- [x] `go test ./... -race -coverpkg=./internal/...,./cmd/... -covermode=atomic -coverprofile=cover.out`
- [x] `go run github.com/vladopajic/go-test-coverage/v2@v2.18.8 --config=.testcoverage.yml`
- [x] `go build ./...`
- [x] `goreleaser check`
- [x] `gitleaks detect --source . --no-banner --redact --exit-code 1`
- [x] Merge passing feature branch to local `develop`.
+10 -10
View File
@@ -113,7 +113,7 @@ Repo `github.com/KofTwentyTwo/license-tool`, MIT, Go module `github.com/KofTwent
| `release.yml` (dist-generated) | `release.yml` (GoReleaser action on `v*` tags) | same trigger pattern |
| `ci.yml` (fmt/clippy/test/build) | `ci.yml` (gofmt/vet/golangci-lint/`go test -race`/build) | same branches, same per-OS artifact uploads |
| `.tar.xz` archives | `.tar.gz` (zip on Windows) | GoReleaser default |
| `Formula/notion-sql.rb` in tap | `Formula/license-tool.rb` (GoReleaser-generated) | same `KofTwentyTwo/homebrew-tap` |
| Homebrew tap package | `Casks/license-tool.rb` (GoReleaser-generated) | same `KofTwentyTwo/homebrew-tap` |
| `dependabot.yml` (cargo + actions) | `dependabot.yml` (`gomod` + actions) | |
| `RELEASING.md` (dist commands) | `RELEASING.md` (GoReleaser commands) | gitflow identical |
| `scripts/verify-release.sh` (cargo+dist gates, emoji) | same 6-gate shape (go+goreleaser gates, plain text) | emoji removed per rules |
@@ -151,12 +151,12 @@ archives:
format_overrides: [{goos: windows, formats: [zip]}]
checksum: {name_template: checksums.txt}
release: {prerelease: auto}
brews:
homebrew_casks:
- repository: {owner: KofTwentyTwo, name: homebrew-tap, token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"}
name: license-tool
binaries: [license-tool]
homepage: https://github.com/KofTwentyTwo/license-tool
description: "Audit and standardize license headers and metadata across codebases"
license: MIT
```
### CI / release workflows
@@ -166,15 +166,15 @@ brews:
### Required GitHub setup (same as notion-sql)
- `KofTwentyTwo/homebrew-tap` exists (confirmed; already holds `notion-sql` and `limen` formulas).
- `KofTwentyTwo/homebrew-tap` exists and receives the generated cask.
- Add Actions secret `HOMEBREW_TAP_TOKEN` (contents-write on the tap) to the `license-tool` repo.
- Branch protection on `main` and `develop`; verify with `gh auth status` and `git ls-remote`.
- `verify-release.sh` gates before tagging: format/vet/lint/test, `goreleaser check` and a `goreleaser release --snapshot --clean` dry run, tap repo + token existence, CHANGELOG date set, SECURITY/README content checks, clean working tree.
## Post-approval sequencing (deferred until you approve this design)
## Completed sequencing
1. Create the KofTwentyTwo GitHub issue describing the tool (tracking).
2. Create `github.com/KofTwentyTwo/license-tool`; scaffold the full notion-sql-equivalent skeleton (community files, CI/release workflows, `.goreleaser.yaml`, layout) and commit to `main`, then branch `develop` as the default working branch with protection on both.
3. Add the `HOMEBREW_TAP_TOKEN` Actions secret.
4. Branch `feature/GH-<n>-initial-implementation` off `develop`; implement against this design with tests; PR into `develop`.
5. Promote `develop` to `main` and tag `v0.1.0` to validate the GoReleaser + Homebrew path end to end (`brew install KofTwentyTwo/tap/license-tool`).
1. Created `github.com/KofTwentyTwo/license-tool` with `develop` as the working branch.
2. Scaffolded community files, CI/release workflows, `.goreleaser.yaml`, and the Go package layout.
3. Added GoReleaser release automation for GitHub Releases and the Homebrew cask path.
4. Shipped the initial implementation and follow-up bugfixes through `v0.2.1`.
5. Current install UX: `brew install --cask KofTwentyTwo/tap/license-tool`.
+6 -7
View File
@@ -1,8 +1,7 @@
# license-tool — Requirements (the contract)
> Status: DRAFT for review. Produced from a `/grill-me` session on 2026-06-03.
> This file is the agreed scope. The companion `license-tool-design.md` says how it is built.
> Nothing here is implemented yet; repo, issue, and branch creation are deferred to your go-ahead.
> Status: implemented baseline, retained as the product contract. Produced from a `/grill-me` session on 2026-06-03 and updated as-built on 2026-06-04.
> The companion `license-tool-design.md` records the implementation shape.
## 1. Purpose
@@ -25,7 +24,7 @@ It is intentionally generic: the target license, copyright holder, year policy,
| 9 | Apply safety | **Dry-run + unified diff by default**; explicit `--write` to apply. **Clean git working tree required** (override `--allow-dirty`); **atomic** temp-then-rename writes; git is the undo; **opt-in `--commit`** makes one atomic conventional commit per repo. Never deletes non-header content. Non-git dirs require `--force` |
| 10 | Configuration | **Layered YAML**. Committed per-repo `.license-tool.yaml` declares the repo's license identity and is both the apply input and the check expectation. Precedence: flags > repo config > user/global config > built-in defaults. Interactive prompt fills missing required fields on a TTY; a **hard error (no hang) in CI/non-TTY** |
| 11 | Audit policy | **Policy-driven classification**: category-tag each license (permissive / weak-copyleft / strong-copyleft / network-copyleft / proprietary / unknown) from SPDX metadata; flag heterogeneity and a curated set of well-known hard incompatibilities; enforce a config-defined policy (allow/deny SPDX lists, required license, fail conditions). **Always prints "not legal advice."** |
| 12 | Distribution | New **public** repo `github.com/KofTwentyTwo/license-tool`, **MIT** licensed (its own code). Productized like `notion-sql` (see §4): **GoReleaser** (not cargo-dist), gitflow branches, GitHub Actions CI + release, tagged releases with artifacts, Homebrew via `KofTwentyTwo/homebrew-tap` (`brew install KofTwentyTwo/tap/license-tool`), full community scaffolding |
| 12 | Distribution | Public repo `github.com/KofTwentyTwo/license-tool`, **MIT** licensed (its own code). Productized like `notion-sql` (see §4): **GoReleaser** (not cargo-dist), gitflow branches, GitHub Actions CI + release, tagged releases with artifacts, Homebrew cask via `KofTwentyTwo/homebrew-tap` (`brew install --cask KofTwentyTwo/tap/license-tool`), full community scaffolding |
| 13 | Name | `license-tool` |
## 3. Modes (from the request)
@@ -47,12 +46,12 @@ The repo must match notion-sql's productization, translated Rust to Go (GoReleas
- Per-OS branch builds on push (macOS, Windows) uploading artifacts named `license-tool-<run_id>-<sha>-<os>`.
**Release / publishing (`.github/workflows/release.yml` + `.goreleaser.yaml`)**
- Tag matching `v[0-9]+.[0-9]+.[0-9]+*` runs GoReleaser: cross-compile mac/linux arm64+amd64 and windows amd64, archives + `checksums.txt`, GitHub Release (prerelease when the tag carries an rc/prerelease suffix), and Homebrew formula publish to `KofTwentyTwo/homebrew-tap` as `Formula/license-tool.rb`.
- Install UX: `brew install KofTwentyTwo/tap/license-tool`.
- Tag matching `v[0-9]+.[0-9]+.[0-9]+*` runs GoReleaser: cross-compile mac/linux arm64+amd64 and windows amd64, archives + `checksums.txt`, GitHub Release (prerelease when the tag carries an rc/prerelease suffix), and Homebrew cask publish to `KofTwentyTwo/homebrew-tap` as `Casks/license-tool.rb`.
- Install UX: `brew install --cask KofTwentyTwo/tap/license-tool`.
- Requires Actions secret `HOMEBREW_TAP_TOKEN` with contents-write on the tap; never hardcoded.
**Governance / community files (mirror notion-sql, adapted to this domain)**
- `README.md`, `LICENSE` (MIT), `CHANGELOG.md` (Keep a Changelog + SemVer, with `[Unreleased]`), `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md` (supported-versions table + private advisory reporting), `RELEASING.md` (gitflow + GoReleaser commands), `scripts/verify-release.sh` (pre-release gate), `.github/ISSUE_TEMPLATE/{bug_report.md,feature_request.md,config.yml}`, `.github/pull_request_template.md`, `.github/dependabot.yml` (`gomod` + `github-actions`, weekly, limit 5), `.gitignore` (Go build output, `/dist/`, env/secrets, OS/editor cruft, agent state `.serena/.claude/.codex/.agents/.cursor`, `REQUEST*.md`).
- `README.md`, `LICENSE` (MIT), `CHANGELOG.md` (Keep a Changelog + SemVer, with `[Unreleased]`), `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md` (supported-versions table + private advisory reporting), `RELEASING.md` (gitflow + GoReleaser commands), `scripts/verify-release.sh` (pre-release gate), `.github/ISSUE_TEMPLATE/{bug_report.yml,feature_request.yml,config.yml}`, `.github/pull_request_template.md`, `.github/dependabot.yml` (`gomod` + `github-actions`, weekly, limit 5), `.gitignore` (Go build output, `/dist/`, env/secrets, OS/editor cruft, agent state `.serena/.claude/.codex/.agents/.cursor`, `REQUEST*.md`).
**Divergence note:** notion-sql's `verify-release.sh` uses emoji status markers. Per your no-emoji rule, our copy will use plain `[ok]`/`[FAIL]` text unless you want a byte-for-byte emoji match.
+65 -10
View File
@@ -12,6 +12,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"time"
"github.com/hexops/gotextdiff"
@@ -32,10 +33,14 @@ import (
type Options struct {
// Write applies changes; when false the run is a dry-run producing diffs only.
Write bool
// Includes restricts source-file processing to matching globs.
Includes []string
// AllowDirty permits writing to a dirty git working tree (--allow-dirty).
AllowDirty bool
// Force permits writing in a non-git directory (--force).
Force bool
// NoGitignore disables .gitignore inheritance on the non-git walk path.
NoGitignore bool
// Commit makes one atomic conventional commit per repo after a successful write.
Commit bool
// CommitMessage is the commit message template (--commit-message); empty = default.
@@ -91,10 +96,12 @@ func Apply(path string, cfg model.Config, opts Options) (model.Report, error) {
})
}
entries, err := enumerate.Enumerate(path, enumerate.Options{
Excludes: cfg.Excludes,
Force: opts.Force,
}, config.LookupFunc(cfg))
entries, err := enumerate.WithContent(path, enumerate.Options{
Includes: opts.Includes,
Excludes: cfg.Excludes,
NoGitignore: opts.NoGitignore,
Force: opts.Force,
}, config.ContentLookupFunc(cfg))
if err != nil {
return model.Report{}, err
}
@@ -124,7 +131,9 @@ func Apply(path string, cfg model.Config, opts Options) (model.Report, error) {
continue
}
fr.Action = action
fr.Diff = diff
if !opts.Write {
fr.Diff = diff
}
if opts.Write && action != "none" && action != "skip" {
if werr := AtomicWrite(e.AbsPath, newContent); werr != nil {
fr.Err = werr.Error()
@@ -142,11 +151,7 @@ func Apply(path string, cfg model.Config, opts Options) (model.Report, error) {
}
if opts.Write && opts.Commit {
msg := opts.CommitMessage
if msg == "" {
msg = fmt.Sprintf("chore: standardize license headers to %s", cfg.License)
}
if cerr := gitutil.Commit(path, msg); cerr != nil {
if cerr := commitChangedFiles(path, cfg, opts, files, "chore: standardize license headers to %s"); cerr != nil {
return model.Report{}, cerr
}
}
@@ -154,6 +159,28 @@ func Apply(path string, cfg model.Config, opts Options) (model.Report, error) {
return report.Build(path, cfg, files, nil, nil), nil
}
// License manages only the top-level LICENSE files with the same write gate and
// scoped commit semantics as Apply.
func License(path string, cfg model.Config, opts Options) ([]model.FileResult, error) {
if opts.Write {
if err := gateWrite(path, opts); err != nil {
return nil, err
}
}
files, err := ManageLicenseFiles(path, cfg, opts)
if err != nil {
return nil, err
}
if opts.Write && opts.Commit {
if err := commitChangedFiles(path, cfg, opts, files, "chore: standardize license files to %s"); err != nil {
return nil, err
}
}
return files, nil
}
// ApplyFile renders and computes the new content for a single file in memory,
// returning the new bytes, the unified diff, and the action ("none"|"insert"|
// "replace"|"skip"). It performs no disk I/O so it is safe to unit-test; Apply
@@ -225,6 +252,9 @@ func writeManaged(root, rel string, want []byte, opts Options) model.FileResult
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 {
@@ -278,6 +308,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 {
+188
View File
@@ -28,11 +28,54 @@ 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
// depending on the filetype package's table from the applier tests.
func phpFileType() model.FileType {
return model.FileType{
Name: "PHP",
Extensions: []string{".php"},
CommentStyle: model.CommentStyle{Block: true, Open: "/*", Close: "*/"},
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
{Kind: model.PreserveShebang, Before: false},
{Kind: model.PreservePHPOpen, 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 {
@@ -370,6 +413,77 @@ func TestApplyFile(t *testing.T) {
})
}
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
// byte-identical no-op. Before the fix the header was spliced above the shebang, so the
// second pass re-detected differently and the result was not idempotent.
func TestApplyFileShebangPHPIdempotent(t *testing.T) {
content := []byte("#!/usr/bin/env php\n<?php\necho 'hi';\n")
once, _, action1, err := ApplyFile(content, phpFileType(), agplHeaderFunc(t))
require.NoError(t, err)
require.Equal(t, "insert", action1)
got := string(once)
assert.True(t, strings.HasPrefix(got, "#!/usr/bin/env php\n<?php\n"),
"the shebang stays line 1 and <?php line 2, with the header after both; got %q", got)
idxShebang := strings.Index(got, "#!")
idxHeader := strings.Index(got, "SPDX-License-Identifier: AGPL-3.0-or-later")
require.GreaterOrEqual(t, idxHeader, 0)
assert.Equal(t, 0, idxShebang, "the shebang must start at byte 0")
assert.Less(t, idxShebang, idxHeader, "the header is inserted after the shebang")
twice, diff2, action2, err := ApplyFile(once, phpFileType(), agplHeaderFunc(t))
require.NoError(t, err)
assert.Equal(t, "none", action2, "a second apply detects the managed header and changes nothing")
assert.Empty(t, diff2)
assert.Equal(t, once, twice, "apply twice must be byte-identical")
}
// --- ManageLicenseFiles ---------------------------------------------------
// TestManageLicenseFilesUnknownLicense verifies an unknown SPDX id is rejected
@@ -561,6 +675,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)
@@ -575,6 +690,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)
@@ -782,3 +902,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))
}
+37 -6
View File
@@ -112,7 +112,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 +163,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
@@ -385,12 +394,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 {
@@ -483,6 +505,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) {
+153
View File
@@ -387,6 +387,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"})
@@ -586,6 +611,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()
@@ -618,6 +667,84 @@ func TestResolveBadConfigSurfaces(t *testing.T) {
}
})
t.Run("invalid policy required expression errors with field and value", func(t *testing.T) {
isolateXDG(t)
repo := t.TempDir()
writeFile(t, filepath.Join(repo, repoConfigName), "license: MIT\nholder: A\npolicy:\n required: NOT-A-LICENSE\n")
_, 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()
@@ -644,6 +771,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) {
+3 -63
View File
@@ -21,13 +21,14 @@ import (
"github.com/google/licensecheck"
managedheader "github.com/KofTwentyTwo/license-tool/internal/header"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/KofTwentyTwo/license-tool/internal/spdx"
)
// Sentinel is the marker license-tool writes into headers it manages, giving the
// highest-confidence signal that a leading comment is safe to replace.
const Sentinel = "license-tool:managed"
const Sentinel = managedheader.Sentinel
// preserveBoundaryFn is the seam Detect calls to compute the preserve-first
// boundary. It defaults to the real PreserveBoundary (which is incapable of
@@ -169,47 +170,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)
}
allowShebang := hasRule(ft, model.PreserveShebang, false)
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
@@ -481,16 +442,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 {
@@ -502,17 +453,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.
+124 -14
View File
@@ -48,6 +48,35 @@ func hashFT() model.FileType {
}
}
// cFT is a block-comment type (C/C++ shape) that lists NO shebang rule -- only BOM.
// It exists to prove the universal-shebang fix: PreserveBoundary must consume a leading
// "#!" even though this type never lists PreserveShebang.
func cFT() model.FileType {
return model.FileType{
Name: "C",
Extensions: []string{".c"},
CommentStyle: model.CommentStyle{Block: true, Open: "/*", Close: "*/"},
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
},
}
}
// phpFT is a block-comment type that preserves a (universal) shebang then the <?php
// open tag, mirroring the builtin PHP table ordering.
func phpFT() model.FileType {
return model.FileType{
Name: "PHP",
Extensions: []string{".php"},
CommentStyle: model.CommentStyle{Block: true, Open: "/*", Close: "*/"},
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
{Kind: model.PreserveShebang, Before: false},
{Kind: model.PreservePHPOpen, Before: false},
},
}
}
// xmlFT is a block-comment type that preserves an XML declaration and PHP open tag.
func xmlFT() model.FileType {
return model.FileType{
@@ -221,6 +250,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) {
@@ -273,6 +373,30 @@ func TestPreserveBoundary(t *testing.T) {
ft: xmlFT(),
wantRest: "// body\n",
},
{
// Regression: a block-comment type that does NOT list PreserveShebang must
// still have its leading "#!" consumed, so the header lands below the shebang.
name: "shebang consumed on block type without a shebang rule",
content: "#!/usr/bin/tcc -run\nint main(void){return 0;}\n",
ft: cFT(),
wantRest: "int main(void){return 0;}\n",
},
{
// Regression: PHP CLI script -- the universal shebang AND the <?php open tag
// are both consumed, in file order, so the header lands after line 2.
name: "php shebang then php-open both consumed",
content: "#!/usr/bin/env php\n<?php\necho 'hi';\n",
ft: phpFT(),
wantRest: "echo 'hi';\n",
},
{
// The universal rule is inert without a shebang: a block-comment file whose
// first line is ordinary code keeps the boundary at zero.
name: "no shebang leaves block-type boundary at zero",
content: "int main(void){return 0;}\n",
ft: cFT(),
wantRest: "int main(void){return 0;}\n",
},
{
name: "package decl (Before rule) is never consumed",
content: "package main\nfunc main(){}\n",
@@ -645,20 +769,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)
+69 -1
View File
@@ -50,6 +50,10 @@ type Entry struct {
SkipReason string
}
// ContentClassifier classifies a path using the leading file bytes when the path
// alone is not enough, such as extensionless scripts with a shebang.
type ContentClassifier func(path string, head []byte) (model.FileType, bool)
// skip reasons surfaced to the report. WHY centralized: detect.go and report.go key
// off these exact tokens, so they are defined once to keep callers in sync.
const (
@@ -78,6 +82,18 @@ var (
// root is inside a working tree. Only outside git do we approximate those semantics
// with a manual walk, which is necessarily a smaller subset of git's behavior.
func Enumerate(root string, opts Options, classify func(path string) (model.FileType, bool)) ([]Entry, error) {
return enumerate(root, opts, func(path string, _ []byte) (model.FileType, bool) {
return classify(path)
}, false)
}
// WithContent is the content-aware variant of Enumerate. It reads the leading
// bytes once, passes them to classify, and reuses them for the binary check.
func WithContent(root string, opts Options, classify ContentClassifier) ([]Entry, error) {
return enumerate(root, opts, classify, true)
}
func enumerate(root string, opts Options, classify ContentClassifier, contentAware bool) ([]Entry, error) {
absRoot, err := filepathAbs(root)
if err != nil {
return nil, err
@@ -115,13 +131,65 @@ func Enumerate(root string, opts Options, classify func(path string) (model.File
}
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
@@ -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.
+59
View File
@@ -345,6 +345,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) {
+139 -5
View File
@@ -13,6 +13,7 @@
package filetype
import (
"bytes"
"path/filepath"
"strings"
@@ -65,7 +66,7 @@ var builtin = []model.FileType{
Name: "Go",
Extensions: []string{".go"},
CommentStyle: block("/*", "*/"),
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), before(model.PreservePackageDecl)},
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveGoBuildConstraint), before(model.PreservePackageDecl)},
},
{
Name: "Swift",
@@ -95,7 +96,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 +112,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 +142,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 +181,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,13 +197,23 @@ 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 the opening <?php tag.
// 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
// a PHP CLI script is line 1, the <?php tag line 2, then the header. (The universal
// shebang preservation in render/detect already covers this; the explicit rule
// documents the ordering.)
{
Name: "PHP",
Extensions: []string{".php", ".phtml"},
CommentStyle: block("/*", "*/"),
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreservePHPOpen)},
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveShebang), after(model.PreservePHPOpen)},
},
// Uncommentable formats: detected and reported, never written.
@@ -217,6 +259,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
@@ -256,3 +375,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
}
}
+121 -4
View File
@@ -36,11 +36,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 +83,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,14 +185,34 @@ 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)
// PHP: BOM then php-open.
// 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.
php, ok := Lookup("index.php")
require.True(t, ok)
require.Len(t, php.PreserveFirst, 2)
assert.Equal(t, model.PreservePHPOpen, php.PreserveFirst[1].Kind)
require.Len(t, php.PreserveFirst, 3)
assert.Equal(t, model.PreserveBOM, php.PreserveFirst[0].Kind)
assert.Equal(t, model.PreserveShebang, php.PreserveFirst[1].Kind)
assert.Equal(t, model.PreservePHPOpen, php.PreserveFirst[2].Kind)
// Java: header goes BEFORE the package declaration.
java, ok := Lookup("Main.java")
+19
View File
@@ -101,3 +101,22 @@ func Commit(path, message string) error {
}
return nil
}
// CommitPaths stages and commits only the named repo-relative paths. Pre-existing
// staged or unstaged changes outside this pathset are left alone.
func CommitPaths(path, message string, paths []string) error {
if len(paths) == 0 {
return fmt.Errorf("gitutil: no paths to commit")
}
addArgs := append([]string{"add", "--"}, paths...)
if _, err := run(path, addArgs...); err != nil {
return err
}
commitArgs := append([]string{"commit", "-m", message, "--only", "--"}, paths...)
if _, err := run(path, commitArgs...); err != nil {
return err
}
return nil
}
+51
View File
@@ -317,3 +317,54 @@ func TestCommit(t *testing.T) {
require.Error(t, err)
})
}
func TestCommitPaths(t *testing.T) {
requireGit(t)
t.Run("commits only named paths", func(t *testing.T) {
root := newRepo(t)
writeFile(t, root, "a.go", "package a\n")
writeFile(t, root, "b.go", "package b\n")
commitAt(t, root, "init", "2020")
writeFile(t, root, "a.go", "package a\n\nvar Changed = true\n")
writeFile(t, root, "b.go", "package b\n\nvar Dirty = true\n")
runGit(t, root, nil, "add", "b.go")
err := CommitPaths(root, "chore: scoped", []string{"a.go"})
require.NoError(t, err)
msg, gerr := run(root, "log", "-1", "--format=%s")
require.NoError(t, gerr)
assert.Equal(t, "chore: scoped", msg)
changed, gerr := run(root, "show", "--name-only", "--format=", "HEAD")
require.NoError(t, gerr)
assert.Equal(t, "a.go", changed)
status, gerr := run(root, "status", "--porcelain")
require.NoError(t, gerr)
assert.Contains(t, status, "b.go")
})
t.Run("empty pathset errors before commit", func(t *testing.T) {
root := newRepo(t)
err := CommitPaths(root, "chore: empty", nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "no paths to commit")
})
t.Run("non-repo errors on add", func(t *testing.T) {
err := CommitPaths(t.TempDir(), "chore: scoped", []string{"a.go"})
require.Error(t, err)
})
t.Run("nothing to commit errors after add", func(t *testing.T) {
root := newRepo(t)
writeFile(t, root, "a.go", "package a\n")
commitAt(t, root, "init", "2020")
err := CommitPaths(root, "chore: no-op", []string{"a.go"})
require.Error(t, err)
})
}
+138
View File
@@ -0,0 +1,138 @@
// Package header owns the shared rules for managed license headers.
package header
import (
"strings"
"github.com/KofTwentyTwo/license-tool/internal/model"
)
// Sentinel is the marker embedded in every header this tool writes.
const Sentinel = "license-tool:managed"
var bom = []byte{0xEF, 0xBB, 0xBF}
// PreserveBoundary returns the byte offset after all leading preserve-first
// prefixes the file type places before the managed header.
func PreserveBoundary(content []byte, ft model.FileType) int {
pos := 0
if HasRule(ft, model.PreserveBOM, false) && hasBOM(content[pos:]) {
pos += len(bom)
}
allowShebang := true
allowXML := HasRule(ft, model.PreserveXMLDecl, false)
allowPHP := HasRule(ft, model.PreservePHPOpen, false)
allowPragma := HasRule(ft, model.PreserveCodingPragma, false)
allowGoBuild := HasRule(ft, model.PreserveGoBuildConstraint, false)
allowCSSCharset := HasRule(ft, model.PreserveCSSCharset, false)
allowDoctype := HasRule(ft, model.PreserveDoctype, false)
for pos < len(content) {
lineEnd := LineEndOffset(content, pos)
lineText := string(content[pos:lineEnd])
trimmed := strings.TrimRight(lineText, "\r\n")
consumed := false
switch {
case allowShebang && strings.HasPrefix(trimmed, "#!"):
consumed = true
case allowXML && strings.HasPrefix(strings.TrimSpace(trimmed), "<?xml"):
consumed = true
case allowPHP && strings.HasPrefix(strings.TrimSpace(trimmed), "<?php"):
consumed = true
case allowPragma && IsCodingPragma(trimmed):
consumed = true
case allowGoBuild:
if next, ok := GoBuildConstraintBoundary(content, pos); ok {
pos = next
continue
}
case allowCSSCharset && IsCSSCharset(trimmed):
consumed = true
case allowDoctype && IsDoctype(trimmed):
consumed = true
}
if !consumed {
break
}
pos = lineEnd
}
return pos
}
// HasRule reports whether ft lists a preserve rule of the given kind and direction.
func HasRule(ft model.FileType, kind model.PreserveKind, before bool) bool {
for _, r := range ft.PreserveFirst {
if r.Kind == kind && r.Before == before {
return true
}
}
return false
}
// LineEndOffset returns the offset just past the newline terminating the line at pos
// or len(content) for the final unterminated line.
func LineEndOffset(content []byte, pos int) int {
for i := pos; i < len(content); i++ {
if content[i] == '\n' {
return i + 1
}
}
return len(content)
}
// GoBuildConstraintBoundary advances past a leading Go build-constraint block and
// its following blank separator, if present.
func GoBuildConstraintBoundary(content []byte, pos int) (int, bool) {
current := pos
sawConstraint := false
for current < len(content) {
lineEnd := LineEndOffset(content, current)
lineText := string(content[current:lineEnd])
trimmed := strings.TrimSpace(strings.TrimRight(lineText, "\r\n"))
switch {
case isGoBuildConstraint(trimmed):
sawConstraint = true
current = lineEnd
default:
if sawConstraint && trimmed == "" {
return lineEnd, true
}
return current, sawConstraint
}
}
return current, sawConstraint
}
func isGoBuildConstraint(trimmed string) bool {
return strings.HasPrefix(trimmed, "//go:build") || strings.HasPrefix(trimmed, "// +build")
}
// IsCSSCharset reports whether trimmed is a CSS @charset at-rule.
func IsCSSCharset(trimmed string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(trimmed)), "@charset")
}
// IsDoctype reports whether trimmed is a markup doctype declaration.
func IsDoctype(trimmed string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(trimmed)), "<!doctype")
}
// IsCodingPragma reports whether trimmed is a Python/Ruby encoding pragma.
func IsCodingPragma(trimmed string) bool {
t := strings.TrimSpace(trimmed)
if !strings.HasPrefix(t, "#") {
return false
}
lower := strings.ToLower(t)
return strings.Contains(lower, "coding:") || strings.Contains(lower, "coding=")
}
func hasBOM(content []byte) bool {
return len(content) >= len(bom) && content[0] == bom[0] && content[1] == bom[1] && content[2] == bom[2]
}
+359
View File
@@ -0,0 +1,359 @@
package header
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/KofTwentyTwo/license-tool/internal/model"
)
func TestPreserveBoundary(t *testing.T) {
hashFT := model.FileType{
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
{Kind: model.PreserveShebang, Before: false},
{Kind: model.PreserveCodingPragma, Before: false},
},
}
xmlFT := model.FileType{
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
{Kind: model.PreserveXMLDecl, Before: false},
{Kind: model.PreservePHPOpen, Before: false},
},
}
goFT := model.FileType{
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
{Kind: model.PreserveGoBuildConstraint, Before: false},
},
}
cssFT := model.FileType{
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
{Kind: model.PreserveCSSCharset, Before: false},
},
}
markupFT := model.FileType{
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveBOM, Before: false},
{Kind: model.PreserveXMLDecl, Before: false},
{Kind: model.PreserveDoctype, Before: false},
},
}
beforeFT := model.FileType{
PreserveFirst: []model.PreserveRule{
{Kind: model.PreservePackageDecl, Before: true},
},
}
tests := []struct {
name string
content string
ft model.FileType
want string
}{
{
name: "no prefixes leaves boundary at zero",
content: "package main\n",
ft: hashFT,
want: "package main\n",
},
{
name: "shebang is consumed universally",
content: "#!/bin/sh\necho hi\n",
ft: model.FileType{},
want: "echo hi\n",
},
{
name: "final unterminated shebang line",
content: "#!/bin/sh",
ft: model.FileType{},
want: "",
},
{
name: "BOM is consumed first",
content: "\uFEFF#!/bin/sh\necho hi\n",
ft: hashFT,
want: "echo hi\n",
},
{
name: "BOM is not consumed without rule",
content: "\uFEFFpackage main\n",
ft: beforeFT,
want: "\uFEFFpackage main\n",
},
{
name: "xml declaration is consumed",
content: "<?xml version=\"1.0\"?>\n<root/>\n",
ft: xmlFT,
want: "<root/>\n",
},
{
name: "php open tag is consumed",
content: "<?php\n// body\n",
ft: xmlFT,
want: "// body\n",
},
{
name: "coding pragma with colon form is consumed",
content: "# -*- coding: utf-8 -*-\nprint(1)\n",
ft: hashFT,
want: "print(1)\n",
},
{
name: "coding pragma with equals form is consumed",
content: "# coding=utf-8\nprint(1)\n",
ft: hashFT,
want: "print(1)\n",
},
{
name: "go build constraint is consumed with blank separator",
content: "//go:build linux\n\npackage main\n",
ft: goFT,
want: "package main\n",
},
{
name: "go build constraint after BOM is consumed",
content: "\uFEFF//go:build linux\n\npackage main\n",
ft: goFT,
want: "package main\n",
},
{
name: "go build constraint after shebang is consumed",
content: "#!/usr/bin/env go\n//go:build linux\n\npackage main\n",
ft: goFT,
want: "package main\n",
},
{
name: "go build constraint with CRLF is consumed",
content: "//go:build linux\r\n\r\npackage main\r\n",
ft: goFT,
want: "package main\r\n",
},
{
name: "legacy go build constraint is consumed",
content: "// +build linux\n\npackage main\n",
ft: goFT,
want: "package main\n",
},
{
name: "combined go build constraints are consumed",
content: "//go:build linux\n// +build linux\n\npackage main\n",
ft: goFT,
want: "package main\n",
},
{
name: "go build constraint tolerates missing blank separator",
content: "//go:build linux\npackage main\n",
ft: goFT,
want: "package main\n",
},
{
name: "go build rule does not consume ordinary line comment",
content: "// ordinary comment\npackage main\n",
ft: goFT,
want: "// ordinary comment\npackage main\n",
},
{
name: "go-looking line is not consumed without rule",
content: "//go:build linux\n\npackage main\n",
ft: model.FileType{},
want: "//go:build linux\n\npackage main\n",
},
{
name: "css charset is consumed",
content: "@charset \"UTF-8\";\nbody {}\n",
ft: cssFT,
want: "body {}\n",
},
{
name: "css charset after BOM is consumed",
content: "\uFEFF@charset \"UTF-8\";\nbody {}\n",
ft: cssFT,
want: "body {}\n",
},
{
name: "css charset is case insensitive",
content: "@CHARSET \"UTF-8\";\nbody {}\n",
ft: cssFT,
want: "body {}\n",
},
{
name: "css-looking line is not consumed without rule",
content: "@charset \"UTF-8\";\nbody {}\n",
ft: model.FileType{},
want: "@charset \"UTF-8\";\nbody {}\n",
},
{
name: "doctype is consumed",
content: "<!DOCTYPE html>\n<html></html>\n",
ft: markupFT,
want: "<html></html>\n",
},
{
name: "doctype after BOM is consumed",
content: "\uFEFF<!DOCTYPE html>\n<html></html>\n",
ft: markupFT,
want: "<html></html>\n",
},
{
name: "doctype is case insensitive",
content: "<!doctype html>\n<html></html>\n",
ft: markupFT,
want: "<html></html>\n",
},
{
name: "xml declaration then doctype are consumed",
content: "<?xml version=\"1.0\"?>\n<!DOCTYPE svg>\n<svg/>\n",
ft: markupFT,
want: "<svg/>\n",
},
{
name: "doctype-looking line is not consumed without rule",
content: "<!DOCTYPE html>\n<html></html>\n",
ft: model.FileType{},
want: "<!DOCTYPE html>\n<html></html>\n",
},
{
name: "coding pragma is not consumed without rule",
content: "# coding=utf-8\nprint(1)\n",
ft: model.FileType{},
want: "# coding=utf-8\nprint(1)\n",
},
{
name: "before rule is never consumed",
content: "package main\nfunc main(){}\n",
ft: beforeFT,
want: "package main\nfunc main(){}\n",
},
{
name: "non-prefix line stops consumption",
content: "#!/bin/sh\necho hi\n# coding=utf-8\n",
ft: hashFT,
want: "echo hi\n# coding=utf-8\n",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
content := []byte(tc.content)
got := PreserveBoundary(content, tc.ft)
assert.Equal(t, tc.want, string(content[got:]))
})
}
}
func TestHasRule(t *testing.T) {
ftBOMAfter := model.FileType{
PreserveFirst: []model.PreserveRule{
{Kind: model.PreserveShebang, Before: false},
{Kind: model.PreserveBOM, Before: false},
},
}
ftPackageBefore := model.FileType{
PreserveFirst: []model.PreserveRule{
{Kind: model.PreservePackageDecl, Before: true},
},
}
cases := []struct {
name string
ft model.FileType
kind model.PreserveKind
before bool
want bool
}{
{"matches kind and before", ftBOMAfter, model.PreserveBOM, false, true},
{"kind present but before mismatches", ftBOMAfter, model.PreserveBOM, true, false},
{"kind absent entirely", ftPackageBefore, model.PreserveBOM, false, false},
{"empty rule list", model.FileType{}, model.PreserveShebang, false, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
assert.Equal(t, c.want, HasRule(c.ft, c.kind, c.before))
})
}
}
func TestLineEndOffset(t *testing.T) {
content := []byte("a\nbc")
assert.Equal(t, 2, LineEndOffset(content, 0))
assert.Equal(t, len(content), LineEndOffset(content, 2))
assert.Equal(t, len(content), LineEndOffset(content, len(content)))
}
func TestGoBuildConstraintBoundary(t *testing.T) {
cases := []struct {
name string
content string
want string
wantOK bool
}{
{
name: "go build form",
content: "//go:build linux\n\npackage main\n",
want: "package main\n",
wantOK: true,
},
{
name: "only one blank separator is consumed",
content: "//go:build linux\n\n\npackage main\n",
want: "\npackage main\n",
wantOK: true,
},
{
name: "legacy plus build form",
content: "// +build linux\n\npackage main\n",
want: "package main\n",
wantOK: true,
},
{
name: "ordinary line comment",
content: "// not a constraint\npackage main\n",
want: "// not a constraint\npackage main\n",
wantOK: false,
},
{
name: "empty content",
content: "",
want: "",
wantOK: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
content := []byte(c.content)
got, ok := GoBuildConstraintBoundary(content, 0)
assert.Equal(t, c.wantOK, ok)
assert.Equal(t, c.want, string(content[got:]))
})
}
}
func TestIsCodingPragma(t *testing.T) {
assert.True(t, IsCodingPragma("# -*- coding: utf-8 -*-"))
assert.True(t, IsCodingPragma("# coding=latin-1"))
assert.True(t, IsCodingPragma("# vim: set fileencoding=utf-8 :"))
assert.False(t, IsCodingPragma("# just a comment"))
assert.False(t, IsCodingPragma("not a hash line"))
}
func TestIsCSSCharset(t *testing.T) {
assert.True(t, IsCSSCharset("@charset \"UTF-8\";"))
assert.True(t, IsCSSCharset(" @CHARSET \"UTF-8\";"))
assert.False(t, IsCSSCharset("body { color: black; }"))
}
func TestIsDoctype(t *testing.T) {
assert.True(t, IsDoctype("<!DOCTYPE html>"))
assert.True(t, IsDoctype(" <!doctype html>"))
assert.False(t, IsDoctype("<html></html>"))
}
func TestHasBOM(t *testing.T) {
assert.True(t, hasBOM([]byte{0xEF, 0xBB, 0xBF, 'x'}))
assert.False(t, hasBOM([]byte{0xEF, 0xBB}))
assert.False(t, hasBOM([]byte("abc")))
}
+7
View File
@@ -159,6 +159,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.
+3 -113
View File
@@ -31,6 +31,7 @@ import (
"strings"
"github.com/KofTwentyTwo/license-tool/internal/gitutil"
managedheader "github.com/KofTwentyTwo/license-tool/internal/header"
"github.com/KofTwentyTwo/license-tool/internal/model"
)
@@ -38,17 +39,6 @@ import (
// frozen-but-stubbed sibling package; every other path is fully implemented.
var errNotImplemented = errors.New("render: not implemented")
// sentinel is the marker embedded in every header this tool writes. It mirrors
// detect.Sentinel by value (we cannot import internal/detect without risking an
// import cycle through the applier, and the constant is part of the cross-package
// contract). A future detection pass keys on this string for the highest-confidence
// "this comment is safe to replace" signal.
//
// WHY duplicated rather than imported: the value is a fixed wire contract shared by
// the writer (here) and the reader (internal/detect); both reference the same
// literal so neither package depends on the other's build.
const sentinel = "license-tool:managed"
// HeaderInput is everything needed to render one file's header.
type HeaderInput struct {
// License is the resolved license (text + standard header + id + category).
@@ -101,7 +91,7 @@ func plaintextHeader(in HeaderInput) string {
// The sentinel rides as the first line so detection finds it before parsing any
// tags. It is comment-wrapped along with everything else.
lines = append(lines, "SPDX-License-Tool: "+sentinel)
lines = append(lines, "SPDX-License-Tool: "+managedheader.Sentinel)
emitReuse := in.Style == model.StyleReuse || in.Style == model.StyleReusePlusNotice
emitNotice := in.Style == model.StyleNotice || in.Style == model.StyleReusePlusNotice
@@ -267,7 +257,7 @@ func Replace(content []byte, headerLF string, start, end int) (newContent []byte
// separator; we re-normalize that separator to eol so an inserted header on a CRLF
// file does not introduce a lone LF.
func insertAt(content []byte, ft model.FileType, header string, eol string) ([]byte, string) {
at := preserveBoundary(content, ft)
at := managedheader.PreserveBoundary(content, ft)
prefix := content[:at]
rest := content[at:]
@@ -307,108 +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)
}
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.
//
+162 -79
View File
@@ -1,6 +1,7 @@
package render
import (
"go/build"
"os"
"os/exec"
"path/filepath"
@@ -10,6 +11,7 @@ import (
"time"
"github.com/KofTwentyTwo/license-tool/internal/filetype"
managedheader "github.com/KofTwentyTwo/license-tool/internal/header"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/KofTwentyTwo/license-tool/internal/spdx"
"github.com/stretchr/testify/assert"
@@ -91,7 +93,7 @@ func TestHeaderStylesPlaintextContent(t *testing.T) {
Style: model.StyleReuse, FileType: goFT,
},
mustContain: []string{
sentinel,
managedheader.Sentinel,
"SPDX-FileCopyrightText: 2026 Kingsrook, LLC",
"SPDX-License-Identifier: AGPL-3.0-or-later",
},
@@ -104,7 +106,7 @@ func TestHeaderStylesPlaintextContent(t *testing.T) {
Style: model.StyleNotice, FileType: goFT,
},
mustContain: []string{
sentinel,
managedheader.Sentinel,
"Copyright (c) 2026 Kingsrook, LLC",
"This program is free software",
"GNU Affero General Public License",
@@ -118,7 +120,7 @@ func TestHeaderStylesPlaintextContent(t *testing.T) {
Style: model.StyleReusePlusNotice, FileType: goFT,
},
mustContain: []string{
sentinel,
managedheader.Sentinel,
"SPDX-FileCopyrightText: 2021-2026 Kingsrook, LLC",
"SPDX-License-Identifier: AGPL-3.0-or-later",
"This program is free software",
@@ -328,6 +330,89 @@ func TestSpliceInsertAfterPHPOpen(t *testing.T) {
assert.Less(t, idxOpen, idxHeader)
}
// TestSpliceShebangPreservedForBlockCommentTypes is the regression guard for the
// universal-shebang fix. Before the fix, a leading "#!" was only preserved when the
// file type explicitly listed PreserveShebang; a BLOCK-comment type either lacked the
// rule (C/C++) or ordered it after php-open (PHP), so the header was spliced ABOVE the
// shebang and the script stopped being executable. The fix preserves any leading "#!"
// universally, so on every block-comment type the shebang must stay line 1 with the
// header inserted strictly after it.
func TestSpliceShebangPreservedForBlockCommentTypes(t *testing.T) {
cases := []struct {
name string
sampleFile string
content string
// wantPrefix is the exact byte prefix that must lead the output (the shebang,
// plus any prolog such as <?php, all before the header).
wantPrefix string
}{
{
// PHP CLI script: "#!" line 1, "<?php" line 2, header after both. PHP is a
// block-comment type whose rule list orders shebang before php-open.
name: "php shebang then php-open",
sampleFile: "x.php",
content: "#!/usr/bin/env php\n<?php\necho 'hi';\n",
wantPrefix: "#!/usr/bin/env php\n<?php\n",
},
{
// JavaScript is a block-comment type that DOES list PreserveShebang: a node
// CLI shebang must stay line 1 with the /* */ header after it.
name: "javascript block-comment type with shebang",
sampleFile: "cli.js",
content: "#!/usr/bin/env node\nconsole.log('hi');\n",
wantPrefix: "#!/usr/bin/env node\n",
},
{
// C/C++ is a block-comment type that does NOT list PreserveShebang at all.
// This is the case the universal rule exists for: preservation must NOT be
// gated on the type listing the rule. (binfmt_misc lets a "#!" launch any
// interpreter, so a shebang on a .c source is legal and must survive.)
name: "c block-comment type without a shebang rule",
sampleFile: "x.c",
content: "#!/usr/bin/tcc -run\nint main(void){return 0;}\n",
wantPrefix: "#!/usr/bin/tcc -run\n",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ft, header := makeHeaderLF(t, c.sampleFile)
out, action := Splice([]byte(c.content), ft, header, model.DetectedHeader{Present: false})
got := string(out)
assert.Equal(t, "insert", action)
assert.True(t, strings.HasPrefix(got, c.wantPrefix),
"the shebang (and any prolog) must remain the file's leading bytes; got %q", got)
idxShebang := strings.Index(got, "#!")
idxHeader := strings.Index(got, "SPDX-License-Identifier: MIT")
require.GreaterOrEqual(t, idxHeader, 0, "header must be present")
assert.Equal(t, 0, idxShebang, "the shebang must start at byte 0 (line 1)")
assert.Less(t, idxShebang, idxHeader, "the header must be inserted after the shebang")
assert.Contains(t, got, ft.CommentStyle.Open,
"the inserted header uses the type's block-comment open delimiter")
})
}
}
// TestSpliceNonShebangBlockCommentUnaffected proves the universal rule is inert when
// there is no shebang: a block-comment file whose first line is ordinary code must get
// the header at the very top (byte 0), exactly as before the fix. This pins the "only
// consumed when the line actually starts with #!" safety claim.
func TestSpliceNonShebangBlockCommentUnaffected(t *testing.T) {
ft, header := makeHeaderLF(t, "x.c")
content := []byte("int main(void){return 0;}\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, ft.CommentStyle.Open),
"with no shebang the header leads at byte 0; got %q", got)
idxHeader := strings.Index(got, "SPDX-License-Identifier: MIT")
idxBody := strings.Index(got, "int main")
assert.Less(t, idxHeader, idxBody, "header precedes the first real line when there is no shebang")
}
func TestSpliceInsertAfterCodingPragma(t *testing.T) {
ft, header := makeHeaderLF(t, "x.py")
content := []byte("#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\nprint('hi')\n")
@@ -343,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")...)
@@ -660,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).
@@ -749,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
}
+23
View File
@@ -15,6 +15,7 @@ import (
"fmt"
"io"
"sort"
"strings"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/KofTwentyTwo/license-tool/internal/policy"
@@ -460,6 +461,8 @@ func renderText(w io.Writer, r model.Report) error {
}
bw.printf("\n")
renderTextDiffs(bw, r.Files)
bw.printf("dependencies: %d\n", len(r.Dependencies))
for _, dep := range sortedDeps(r.Dependencies) {
bw.printf(" %s\n", depLine(dep))
@@ -477,6 +480,24 @@ func renderText(w io.Writer, r model.Report) error {
return bw.err
}
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 {
switch {
case fr.Err != "":
@@ -611,6 +632,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"`
}
@@ -649,6 +671,7 @@ func renderJSON(w io.Writer, r model.Report) error {
Holder: fr.Detected.Holder,
Year: fr.Detected.Year,
Action: fr.Action,
Diff: fr.Diff,
Violations: nonNilStrings(fr.Violations),
Error: fr.Err,
})
+49
View File
@@ -642,6 +642,20 @@ func TestRenderText(t *testing.T) {
})
}
func TestRenderTextIncludesUnifiedDiffs(t *testing.T) {
r := emptyReport()
r.Files = []model.FileResult{
{Path: "a.go", FileType: "Go", Action: "insert", Diff: "--- a.go\n+++ a.go\n@@ -1 +1,2 @@\n package a\n+// SPDX-License-Identifier: MIT\n"},
}
var buf bytes.Buffer
require.NoError(t, Render(&buf, r, FormatText))
out := buf.String()
assert.Contains(t, out, "pending diffs:")
assert.Contains(t, out, "+++ a.go")
assert.Contains(t, out, "+// SPDX-License-Identifier: MIT")
}
// fileLine covers the unresolved-dep "no reason" -> (none) and the empty-config
// orNone paths exercised through a tailored report.
func TestRenderTextOrNoneFallbacks(t *testing.T) {
@@ -759,6 +773,41 @@ func TestRenderJSON(t *testing.T) {
})
}
func TestRenderJSONDiffField(t *testing.T) {
t.Run("includes non-empty diff", func(t *testing.T) {
r := emptyReport()
r.Files = []model.FileResult{
{Path: "a.go", FileType: "Go", Action: "insert", Diff: "--- a.go\n+++ a.go\n@@ -1 +1,2 @@\n package a\n+// SPDX-License-Identifier: MIT\n"},
}
var buf bytes.Buffer
require.NoError(t, Render(&buf, r, FormatJSON))
var got jsonReport
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
require.Len(t, got.Files, 1)
assert.Contains(t, got.Files[0].Diff, "+++ a.go")
assert.Contains(t, got.Files[0].Diff, "+// SPDX-License-Identifier: MIT")
})
t.Run("omits empty diff", func(t *testing.T) {
r := emptyReport()
r.Files = []model.FileResult{{Path: "a.go", FileType: "Go", Action: "none"}}
var buf bytes.Buffer
require.NoError(t, Render(&buf, r, FormatJSON))
var raw map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &raw))
files, ok := raw["files"].([]any)
require.True(t, ok)
require.Len(t, files, 1)
fileEntry, ok := files[0].(map[string]any)
require.True(t, ok)
assert.NotContains(t, fileEntry, "diff")
})
}
// nonNilCounts / nonNilStrings nil branches: a Report with nil maps/slices must
// still serialize to {} / [] (the empty report uses non-nil maps, so exercise the
// nil path directly through a report with nil aggregates).
+10 -2
View File
@@ -50,16 +50,24 @@ func (r *GradleResolver) Detect(path string) bool {
// It is deliberately a surface scan, not a full Gradle model build: detect-only.
var gradleDepRE = regexp.MustCompile(`["']([\w.\-]+:[\w.\-]+:[\w.\-${}]+)["']`)
const (
gradleOnDiskReason = "gradle on-disk dependency-license resolution is not supported (detect-only; no on-disk license metadata, no network fetch)"
gradleToolReason = "Gradle tool-tier dependency-license resolution is not supported; this resolver remains detect-only and does not shell out"
)
// Resolve implements model.Resolver. It surface-scans the build script(s) for
// dependency coordinates and returns each as an unresolved DependencyLicense with
// a reason. It never resolves a license: Gradle resolution is out of scope for v1.
// a reason. It never resolves a license: Gradle resolution is detect-only.
func (r *GradleResolver) Resolve(path string, opts model.ResolveOptions) ([]model.DependencyLicense, error) {
coords := map[string]string{} // coordinate "group:artifact" -> version
for _, m := range gradleManifests {
scanGradleManifest(filepath.Join(path, m), coords)
}
const reason = "gradle dependency-license resolution not implemented in v1 (detect-only; no on-disk license metadata, no network fetch)"
reason := gradleOnDiskReason
if opts.AllowToolShellOut {
reason = gradleToolReason
}
names := make([]string, 0, len(coords))
for n := range coords {
+15 -1
View File
@@ -72,7 +72,7 @@ dependencies {
names = append(names, d.Name)
assert.Equal(t, model.ResolutionUnresolved, d.Resolution)
assert.Equal(t, "gradle", d.Ecosystem)
assert.Contains(t, d.Reason, "not implemented in v1")
assert.Contains(t, d.Reason, "detect-only")
}
// Sorted and deduplicated.
@@ -90,6 +90,20 @@ dependencies {
assert.Empty(t, byName["org.example:propver"].Version)
}
func TestGradleResolveToolTierReason(t *testing.T) {
dir := t.TempDir()
content := `dependencies { implementation 'com.google.guava:guava:31.1-jre' }`
require.NoError(t, os.WriteFile(filepath.Join(dir, "build.gradle"), []byte(content), 0o644))
r := &GradleResolver{}
out, err := r.Resolve(dir, model.ResolveOptions{AllowToolShellOut: true})
require.NoError(t, err)
require.Len(t, out, 1)
assert.Equal(t, model.ResolutionUnresolved, out[0].Resolution)
assert.Equal(t, "Gradle tool-tier dependency-license resolution is not supported; this resolver remains detect-only and does not shell out", out[0].Reason)
}
// TestGradleResolveNoManifest verifies Resolve on a directory with no build script
// returns an empty (non-nil-handled) result: scanGradleManifest's read-error path.
func TestGradleResolveNoManifest(t *testing.T) {
+21
View File
@@ -223,6 +223,27 @@ func idsFrom(s *store, err error) []string {
return out
}
// RenderableIDs returns every SPDX identifier with vendored license detail the tool
// can render, sorted by id. It is intentionally narrower than IDs(), which returns
// every non-deprecated id from the full SPDX index.
func RenderableIDs() []string {
return renderableIDsFrom(get())
}
// renderableIDsFrom builds the sorted id list from the curated rendering set. On a
// snapshot load error it returns nil, matching IDs()'s empty-picker degradation.
func renderableIDsFrom(s *store, err error) []string {
if err != nil {
return nil
}
out := make([]string, 0, len(s.licenses))
for id := range s.licenses {
out = append(out, id)
}
sort.Strings(out)
return out
}
// CommonIDs returns a curated, display-ordered shortlist of the licenses users pick
// most often. WHY a hand-ordered slice rather than a sorted subset of IDs: the order
// is editorial (most-recommended first, permissive before copyleft), so the picker
+8 -3
View File
@@ -163,15 +163,19 @@ func TestAccessorsWithLoadError(t *testing.T) {
assert.Equal(t, "", listVersionFrom(nil, errStore), "ListVersion must be empty on load error")
assert.Nil(t, idsFrom(nil, errStore), "IDs must be nil on load error so the picker degrades to empty")
assert.Nil(t, renderableIDsFrom(nil, errStore), "RenderableIDs must be nil on load error so the picker degrades to empty")
}
// TestAccessorsFromHelpersHappyPath confirms the extracted helpers preserve the
// non-error behavior they were factored out of.
func TestAccessorsFromHelpersHappyPath(t *testing.T) {
s := &store{
ids: map[string]indexEntry{"MIT": {LicenseID: "MIT", Name: "MIT License"}},
licenses: map[string]model.License{"MIT": {SPDXID: "MIT", Text: "body"}},
version: "test-1.0",
ids: map[string]indexEntry{"MIT": {LicenseID: "MIT", Name: "MIT License"}},
licenses: map[string]model.License{
"Apache-2.0": {SPDXID: "Apache-2.0", Text: "apache body"},
"MIT": {SPDXID: "MIT", Text: "body"},
},
version: "test-1.0",
}
assert.True(t, validateFrom(s, nil)("MIT"))
@@ -185,6 +189,7 @@ func TestAccessorsFromHelpersHappyPath(t *testing.T) {
assert.False(t, ok)
assert.Equal(t, "test-1.0", listVersionFrom(s, nil))
assert.Equal(t, []string{"Apache-2.0", "MIT"}, renderableIDsFrom(s, nil))
}
// TestClassifyAllBranches drives every arm of classify, including the default
+8
View File
@@ -120,6 +120,14 @@ func TestIDs(t *testing.T) {
assert.Contains(t, ids, "MIT", "the canonical MIT id should be offered")
}
func TestRenderableIDs(t *testing.T) {
ids := RenderableIDs()
require.NotEmpty(t, ids, "the vendored snapshot must yield renderable ids")
assert.True(t, sort.StringsAreSorted(ids), "RenderableIDs() must be sorted")
assert.Contains(t, ids, "MIT", "curated MIT id should be renderable")
assert.NotContains(t, ids, "Zlib", "valid SPDX ids without vendored detail must not be renderable")
}
// TestCommonIDs confirms every curated shortlist id validates against the full
// vendored index, so the picker can never present a "common" option that the rest
// of the tool would later reject as an unknown SPDX id.
+15 -5
View File
@@ -36,22 +36,32 @@ fi
# ---------------------------------------------------------------------------
# Gate 2/6: Static analysis
# ---------------------------------------------------------------------------
echo "=== Gate 2/6: go vet ==="
echo "=== Gate 2/6: static analysis ==="
if go vet ./...; then
pass "go vet"
else
fail "go vet reported problems"
fi
if golangci-lint run; then
pass "golangci-lint run"
else
fail "golangci-lint reported problems"
fi
# ---------------------------------------------------------------------------
# Gate 3/6: Tests with the race detector
# Gate 3/6: Tests with the race detector and coverage gate
# ---------------------------------------------------------------------------
echo "=== Gate 3/6: go test -race ==="
if go test ./... -race; then
pass "go test ./... -race"
echo "=== Gate 3/6: go test -race + coverage ==="
if go test ./... -race -coverpkg=./internal/...,./cmd/... -covermode=atomic -coverprofile=cover.out; then
pass "go test ./... -race with coverage profile"
else
fail "tests failed under the race detector"
fi
if go run github.com/vladopajic/go-test-coverage/v2@v2.18.8 --config=.testcoverage.yml; then
pass "100% coverage gate"
else
fail "coverage gate failed"
fi
# ---------------------------------------------------------------------------
# Gate 4/6: Release tooling (GoReleaser)