Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb66a7616e | ||
|
|
742c124e6e | ||
|
|
8c789ce50c | ||
|
|
c63e2514f1 | ||
|
|
03510d13d1 | ||
|
|
36ec31d511 | ||
|
|
9893efdc37 | ||
|
|
0e2b665463 | ||
|
|
5936852a1b | ||
|
|
0232911ea9 | ||
|
|
6c482a964d | ||
|
|
a2c764876d | ||
|
|
42a3dada18 | ||
|
|
9fff8debaf | ||
|
|
bf86694c37 | ||
|
|
c4538c6907 | ||
|
|
b0b566e600 | ||
|
|
54bf7d015a | ||
|
|
3e18e6c8d5 | ||
|
|
5d862308b5 | ||
|
|
7d2bf46d0a | ||
|
|
926e59c0fd | ||
|
|
f4a111f8ec |
No files matched your search
@@ -0,0 +1,48 @@
|
||||
name: Bug report
|
||||
description: Report a reproducible license-tool failure or incorrect result.
|
||||
title: "bug: "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Do not include vulnerability details in a public issue. Use SECURITY.md for private reporting.
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: What happened, and what did you expect instead?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: Reproduction
|
||||
description: Minimal commands, config, and fixture files needed to reproduce.
|
||||
placeholder: |
|
||||
license-tool audit ./repo --format json
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: Output from license-tool version.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: OS, shell, package manager, and relevant ecosystem tooling.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Output
|
||||
description: Relevant command output, with secrets removed.
|
||||
render: text
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/KofTwentyTwo/license-tool/security/policy
|
||||
about: Report vulnerabilities privately through the repository security policy.
|
||||
- name: Releases
|
||||
url: https://github.com/KofTwentyTwo/license-tool/releases
|
||||
about: Download the latest release and inspect published changelogs.
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Feature request
|
||||
description: Propose a new capability or behavior change.
|
||||
title: "feat: "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
description: What workflow or limitation should this address?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposal
|
||||
description: Describe the behavior you want license-tool to provide.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives
|
||||
description: Existing workarounds or approaches you considered.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Repos, ecosystems, policies, or output formats affected.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,15 @@
|
||||
## Summary
|
||||
|
||||
-
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] `gofmt -l .`
|
||||
- [ ] `go vet ./...`
|
||||
- [ ] `golangci-lint run`
|
||||
- [ ] `go test ./... -race -cover`
|
||||
- [ ] `go run github.com/vladopajic/go-test-coverage/v2@v2.18.8 --config=.testcoverage.yml`
|
||||
|
||||
## Notes
|
||||
|
||||
-
|
||||
@@ -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
@@ -59,20 +59,24 @@ release:
|
||||
changelog:
|
||||
use: github
|
||||
|
||||
brews:
|
||||
homebrew_casks:
|
||||
- name: license-tool
|
||||
# Write the formula under Formula/ (Homebrew's convention and where brew looks);
|
||||
# the default empty directory drops it at the tap root, where brew ignores it.
|
||||
directory: Formula
|
||||
ids:
|
||||
- license-tool
|
||||
binaries:
|
||||
- license-tool
|
||||
repository:
|
||||
owner: KofTwentyTwo
|
||||
name: homebrew-tap
|
||||
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
|
||||
homepage: https://github.com/KofTwentyTwo/license-tool
|
||||
description: "Audit and standardize license headers and metadata across codebases"
|
||||
license: MIT
|
||||
commit_author:
|
||||
name: github-actions[bot]
|
||||
email: 41898282+github-actions[bot]@users.noreply.github.com
|
||||
test: |
|
||||
system "#{bin}/license-tool", "version"
|
||||
hooks:
|
||||
post:
|
||||
install: |
|
||||
if OS.mac?
|
||||
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/license-tool"]
|
||||
end
|
||||
@@ -7,6 +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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -4,20 +4,24 @@
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| 1.0.x | yes |
|
||||
| 0.x | no |
|
||||
| Latest published release | yes |
|
||||
| Older releases | no |
|
||||
| Unreleased branch builds and snapshots | no |
|
||||
|
||||
`license-tool` is currently pre-1.0. Security fixes are released on the latest
|
||||
published release line, and users should upgrade to the newest GitHub Release when
|
||||
a fix is published.
|
||||
|
||||
## Reporting A Vulnerability
|
||||
|
||||
**Do not open a public GitHub issue for security vulnerabilities.**
|
||||
|
||||
Instead, please use one of the following methods:
|
||||
Use GitHub's private vulnerability reporting flow from the repository security
|
||||
policy page:
|
||||
|
||||
1. **GitHub Security Advisories**: Create a private security advisory at
|
||||
`https://github.com/KofTwentyTwo/license-tool/security/advisories`
|
||||
|
||||
2. **PGP Encryption**: If you have the maintainer's PGP public key,
|
||||
encrypt your report and send it via email.
|
||||
```text
|
||||
https://github.com/KofTwentyTwo/license-tool/security/policy
|
||||
```
|
||||
|
||||
### What To Include
|
||||
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# Support
|
||||
|
||||
Use GitHub Issues for public support requests:
|
||||
|
||||
```text
|
||||
https://github.com/KofTwentyTwo/license-tool/issues
|
||||
```
|
||||
|
||||
Open a bug report for reproducible failures, incorrect audit output, release
|
||||
installation problems, or documentation defects. Open a feature request for new
|
||||
ecosystem support, policy/reporting changes, or workflow improvements.
|
||||
|
||||
For security vulnerabilities, do not include details in a public issue. Follow
|
||||
the private reporting instructions in [SECURITY.md](SECURITY.md).
|
||||
+246
-39
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# PLAN: Audit Fixes
|
||||
|
||||
## Goal
|
||||
Resolve GitHub issues #6 through #27 locally, using TDD, while preserving the 100% CI coverage gate and leaving the branch ready for a manual review pass before any push or release.
|
||||
|
||||
## Approach
|
||||
Work on `feature/GH-12-27-audit-fixes` and split fixes into isolated worktrees by concern: header boundaries, file-type coverage, write safety, CLI/reporting, policy/SPDX, dependency/release/docs, and shared header placement. Each slice starts with failing behavior tests, then minimal implementation, then local verification. Results are integrated back into this branch and finally merged to local `develop` only after the full test and CI-equivalent gate passes.
|
||||
|
||||
## Files Affected
|
||||
- `cmd/license-tool/*`: CLI output, exit codes, command behavior tests, wizard filtering.
|
||||
- `internal/applier/*`: write gates, scoped enumeration, dry-run diffs, scoped commits.
|
||||
- `internal/report/*`: report rendering for apply/check/audit outputs.
|
||||
- `internal/config/*`: flag/config layering and policy ID validation.
|
||||
- `internal/spdx/*`: renderable license contract.
|
||||
- `internal/resolve/*`: resolver tier validation and dependency manifest discovery.
|
||||
- `internal/render/*`, `internal/detect/*`, `internal/header/*`: shared header placement behavior and preserve-first constructs.
|
||||
- `internal/filetype/*`, `internal/enumerate/*`: file-type coverage and shebang-based classification.
|
||||
- `.github/workflows/*`, `.goreleaser.yaml`: release supply-chain pinning.
|
||||
- `docs/*`, `SECURITY.md`, `.github/*`: public support and status scaffolding.
|
||||
|
||||
## Steps
|
||||
1. Create worktrees and assign disjoint issue slices.
|
||||
2. For each slice, write one failing test for one user-visible behavior.
|
||||
3. Implement the minimal fix and keep the slice green.
|
||||
4. Integrate slices into the feature branch, resolving conflicts by preserving tested behavior.
|
||||
5. Run `gofmt`, `go vet`, `golangci-lint run`, `go test ./... -race -cover`, the 100% coverage gate, `go build ./...`, and gitleaks.
|
||||
6. Merge the passing feature branch back into local `develop`.
|
||||
|
||||
## Open Questions
|
||||
- None. Push, release tags, and promotion to `main` remain out of scope until manual review and explicit approval.
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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
@@ -113,7 +113,7 @@ Repo `github.com/KofTwentyTwo/license-tool`, MIT, Go module `github.com/KofTwent
|
||||
| `release.yml` (dist-generated) | `release.yml` (GoReleaser action on `v*` tags) | same trigger pattern |
|
||||
| `ci.yml` (fmt/clippy/test/build) | `ci.yml` (gofmt/vet/golangci-lint/`go test -race`/build) | same branches, same per-OS artifact uploads |
|
||||
| `.tar.xz` archives | `.tar.gz` (zip on Windows) | GoReleaser default |
|
||||
| `Formula/notion-sql.rb` in tap | `Formula/license-tool.rb` (GoReleaser-generated) | same `KofTwentyTwo/homebrew-tap` |
|
||||
| Homebrew tap package | `Casks/license-tool.rb` (GoReleaser-generated) | same `KofTwentyTwo/homebrew-tap` |
|
||||
| `dependabot.yml` (cargo + actions) | `dependabot.yml` (`gomod` + actions) | |
|
||||
| `RELEASING.md` (dist commands) | `RELEASING.md` (GoReleaser commands) | gitflow identical |
|
||||
| `scripts/verify-release.sh` (cargo+dist gates, emoji) | same 6-gate shape (go+goreleaser gates, plain text) | emoji removed per rules |
|
||||
@@ -151,12 +151,12 @@ archives:
|
||||
format_overrides: [{goos: windows, formats: [zip]}]
|
||||
checksum: {name_template: checksums.txt}
|
||||
release: {prerelease: auto}
|
||||
brews:
|
||||
homebrew_casks:
|
||||
- repository: {owner: KofTwentyTwo, name: homebrew-tap, token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"}
|
||||
name: license-tool
|
||||
binaries: [license-tool]
|
||||
homepage: https://github.com/KofTwentyTwo/license-tool
|
||||
description: "Audit and standardize license headers and metadata across codebases"
|
||||
license: MIT
|
||||
```
|
||||
|
||||
### CI / release workflows
|
||||
@@ -166,15 +166,15 @@ brews:
|
||||
|
||||
### Required GitHub setup (same as notion-sql)
|
||||
|
||||
- `KofTwentyTwo/homebrew-tap` exists (confirmed; already holds `notion-sql` and `limen` formulas).
|
||||
- `KofTwentyTwo/homebrew-tap` exists and receives the generated cask.
|
||||
- Add Actions secret `HOMEBREW_TAP_TOKEN` (contents-write on the tap) to the `license-tool` repo.
|
||||
- Branch protection on `main` and `develop`; verify with `gh auth status` and `git ls-remote`.
|
||||
- `verify-release.sh` gates before tagging: format/vet/lint/test, `goreleaser check` and a `goreleaser release --snapshot --clean` dry run, tap repo + token existence, CHANGELOG date set, SECURITY/README content checks, clean working tree.
|
||||
|
||||
## Post-approval sequencing (deferred until you approve this design)
|
||||
## Completed sequencing
|
||||
|
||||
1. Create the KofTwentyTwo GitHub issue describing the tool (tracking).
|
||||
2. Create `github.com/KofTwentyTwo/license-tool`; scaffold the full notion-sql-equivalent skeleton (community files, CI/release workflows, `.goreleaser.yaml`, layout) and commit to `main`, then branch `develop` as the default working branch with protection on both.
|
||||
3. Add the `HOMEBREW_TAP_TOKEN` Actions secret.
|
||||
4. Branch `feature/GH-<n>-initial-implementation` off `develop`; implement against this design with tests; PR into `develop`.
|
||||
5. Promote `develop` to `main` and tag `v0.1.0` to validate the GoReleaser + Homebrew path end to end (`brew install KofTwentyTwo/tap/license-tool`).
|
||||
1. Created `github.com/KofTwentyTwo/license-tool` with `develop` as the working branch.
|
||||
2. Scaffolded community files, CI/release workflows, `.goreleaser.yaml`, and the Go package layout.
|
||||
3. Added GoReleaser release automation for GitHub Releases and the Homebrew cask path.
|
||||
4. Shipped the initial implementation and follow-up bugfixes through `v0.2.1`.
|
||||
5. Current install UX: `brew install --cask KofTwentyTwo/tap/license-tool`.
|
||||
@@ -1,8 +1,7 @@
|
||||
# license-tool — Requirements (the contract)
|
||||
|
||||
> Status: DRAFT for review. Produced from a `/grill-me` session on 2026-06-03.
|
||||
> This file is the agreed scope. The companion `license-tool-design.md` says how it is built.
|
||||
> Nothing here is implemented yet; repo, issue, and branch creation are deferred to your go-ahead.
|
||||
> Status: implemented baseline, retained as the product contract. Produced from a `/grill-me` session on 2026-06-03 and updated as-built on 2026-06-04.
|
||||
> The companion `license-tool-design.md` records the implementation shape.
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
@@ -25,7 +24,7 @@ It is intentionally generic: the target license, copyright holder, year policy,
|
||||
| 9 | Apply safety | **Dry-run + unified diff by default**; explicit `--write` to apply. **Clean git working tree required** (override `--allow-dirty`); **atomic** temp-then-rename writes; git is the undo; **opt-in `--commit`** makes one atomic conventional commit per repo. Never deletes non-header content. Non-git dirs require `--force` |
|
||||
| 10 | Configuration | **Layered YAML**. Committed per-repo `.license-tool.yaml` declares the repo's license identity and is both the apply input and the check expectation. Precedence: flags > repo config > user/global config > built-in defaults. Interactive prompt fills missing required fields on a TTY; a **hard error (no hang) in CI/non-TTY** |
|
||||
| 11 | Audit policy | **Policy-driven classification**: category-tag each license (permissive / weak-copyleft / strong-copyleft / network-copyleft / proprietary / unknown) from SPDX metadata; flag heterogeneity and a curated set of well-known hard incompatibilities; enforce a config-defined policy (allow/deny SPDX lists, required license, fail conditions). **Always prints "not legal advice."** |
|
||||
| 12 | Distribution | New **public** repo `github.com/KofTwentyTwo/license-tool`, **MIT** licensed (its own code). Productized like `notion-sql` (see §4): **GoReleaser** (not cargo-dist), gitflow branches, GitHub Actions CI + release, tagged releases with artifacts, Homebrew via `KofTwentyTwo/homebrew-tap` (`brew install KofTwentyTwo/tap/license-tool`), full community scaffolding |
|
||||
| 12 | Distribution | Public repo `github.com/KofTwentyTwo/license-tool`, **MIT** licensed (its own code). Productized like `notion-sql` (see §4): **GoReleaser** (not cargo-dist), gitflow branches, GitHub Actions CI + release, tagged releases with artifacts, Homebrew cask via `KofTwentyTwo/homebrew-tap` (`brew install --cask KofTwentyTwo/tap/license-tool`), full community scaffolding |
|
||||
| 13 | Name | `license-tool` |
|
||||
|
||||
## 3. Modes (from the request)
|
||||
@@ -47,12 +46,12 @@ The repo must match notion-sql's productization, translated Rust to Go (GoReleas
|
||||
- Per-OS branch builds on push (macOS, Windows) uploading artifacts named `license-tool-<run_id>-<sha>-<os>`.
|
||||
|
||||
**Release / publishing (`.github/workflows/release.yml` + `.goreleaser.yaml`)**
|
||||
- Tag matching `v[0-9]+.[0-9]+.[0-9]+*` runs GoReleaser: cross-compile mac/linux arm64+amd64 and windows amd64, archives + `checksums.txt`, GitHub Release (prerelease when the tag carries an rc/prerelease suffix), and Homebrew formula publish to `KofTwentyTwo/homebrew-tap` as `Formula/license-tool.rb`.
|
||||
- Install UX: `brew install KofTwentyTwo/tap/license-tool`.
|
||||
- Tag matching `v[0-9]+.[0-9]+.[0-9]+*` runs GoReleaser: cross-compile mac/linux arm64+amd64 and windows amd64, archives + `checksums.txt`, GitHub Release (prerelease when the tag carries an rc/prerelease suffix), and Homebrew cask publish to `KofTwentyTwo/homebrew-tap` as `Casks/license-tool.rb`.
|
||||
- Install UX: `brew install --cask KofTwentyTwo/tap/license-tool`.
|
||||
- Requires Actions secret `HOMEBREW_TAP_TOKEN` with contents-write on the tap; never hardcoded.
|
||||
|
||||
**Governance / community files (mirror notion-sql, adapted to this domain)**
|
||||
- `README.md`, `LICENSE` (MIT), `CHANGELOG.md` (Keep a Changelog + SemVer, with `[Unreleased]`), `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md` (supported-versions table + private advisory reporting), `RELEASING.md` (gitflow + GoReleaser commands), `scripts/verify-release.sh` (pre-release gate), `.github/ISSUE_TEMPLATE/{bug_report.md,feature_request.md,config.yml}`, `.github/pull_request_template.md`, `.github/dependabot.yml` (`gomod` + `github-actions`, weekly, limit 5), `.gitignore` (Go build output, `/dist/`, env/secrets, OS/editor cruft, agent state `.serena/.claude/.codex/.agents/.cursor`, `REQUEST*.md`).
|
||||
- `README.md`, `LICENSE` (MIT), `CHANGELOG.md` (Keep a Changelog + SemVer, with `[Unreleased]`), `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md` (supported-versions table + private advisory reporting), `RELEASING.md` (gitflow + GoReleaser commands), `scripts/verify-release.sh` (pre-release gate), `.github/ISSUE_TEMPLATE/{bug_report.yml,feature_request.yml,config.yml}`, `.github/pull_request_template.md`, `.github/dependabot.yml` (`gomod` + `github-actions`, weekly, limit 5), `.gitignore` (Go build output, `/dist/`, env/secrets, OS/editor cruft, agent state `.serena/.claude/.codex/.agents/.cursor`, `REQUEST*.md`).
|
||||
|
||||
**Divergence note:** notion-sql's `verify-release.sh` uses emoji status markers. Per your no-emoji rule, our copy will use plain `[ok]`/`[FAIL]` text unless you want a byte-for-byte emoji match.
|
||||
|
||||
|
||||
+65
-10
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -101,3 +101,22 @@ func Commit(path, message string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CommitPaths stages and commits only the named repo-relative paths. Pre-existing
|
||||
// staged or unstaged changes outside this pathset are left alone.
|
||||
func CommitPaths(path, message string, paths []string) error {
|
||||
if len(paths) == 0 {
|
||||
return fmt.Errorf("gitutil: no paths to commit")
|
||||
}
|
||||
|
||||
addArgs := append([]string{"add", "--"}, paths...)
|
||||
if _, err := run(path, addArgs...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
commitArgs := append([]string{"commit", "-m", message, "--only", "--"}, paths...)
|
||||
if _, err := run(path, commitArgs...); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -317,3 +317,54 @@ func TestCommit(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCommitPaths(t *testing.T) {
|
||||
requireGit(t)
|
||||
|
||||
t.Run("commits only named paths", func(t *testing.T) {
|
||||
root := newRepo(t)
|
||||
writeFile(t, root, "a.go", "package a\n")
|
||||
writeFile(t, root, "b.go", "package b\n")
|
||||
commitAt(t, root, "init", "2020")
|
||||
|
||||
writeFile(t, root, "a.go", "package a\n\nvar Changed = true\n")
|
||||
writeFile(t, root, "b.go", "package b\n\nvar Dirty = true\n")
|
||||
runGit(t, root, nil, "add", "b.go")
|
||||
|
||||
err := CommitPaths(root, "chore: scoped", []string{"a.go"})
|
||||
require.NoError(t, err)
|
||||
|
||||
msg, gerr := run(root, "log", "-1", "--format=%s")
|
||||
require.NoError(t, gerr)
|
||||
assert.Equal(t, "chore: scoped", msg)
|
||||
|
||||
changed, gerr := run(root, "show", "--name-only", "--format=", "HEAD")
|
||||
require.NoError(t, gerr)
|
||||
assert.Equal(t, "a.go", changed)
|
||||
|
||||
status, gerr := run(root, "status", "--porcelain")
|
||||
require.NoError(t, gerr)
|
||||
assert.Contains(t, status, "b.go")
|
||||
})
|
||||
|
||||
t.Run("empty pathset errors before commit", func(t *testing.T) {
|
||||
root := newRepo(t)
|
||||
err := CommitPaths(root, "chore: empty", nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no paths to commit")
|
||||
})
|
||||
|
||||
t.Run("non-repo errors on add", func(t *testing.T) {
|
||||
err := CommitPaths(t.TempDir(), "chore: scoped", []string{"a.go"})
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("nothing to commit errors after add", func(t *testing.T) {
|
||||
root := newRepo(t)
|
||||
writeFile(t, root, "a.go", "package a\n")
|
||||
commitAt(t, root, "init", "2020")
|
||||
|
||||
err := CommitPaths(root, "chore: no-op", []string{"a.go"})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Package header owns the shared rules for managed license headers.
|
||||
package header
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/KofTwentyTwo/license-tool/internal/model"
|
||||
)
|
||||
|
||||
// Sentinel is the marker embedded in every header this tool writes.
|
||||
const Sentinel = "license-tool:managed"
|
||||
|
||||
var bom = []byte{0xEF, 0xBB, 0xBF}
|
||||
|
||||
// PreserveBoundary returns the byte offset after all leading preserve-first
|
||||
// prefixes the file type places before the managed header.
|
||||
func PreserveBoundary(content []byte, ft model.FileType) int {
|
||||
pos := 0
|
||||
|
||||
if HasRule(ft, model.PreserveBOM, false) && hasBOM(content[pos:]) {
|
||||
pos += len(bom)
|
||||
}
|
||||
|
||||
allowShebang := true
|
||||
allowXML := HasRule(ft, model.PreserveXMLDecl, false)
|
||||
allowPHP := HasRule(ft, model.PreservePHPOpen, false)
|
||||
allowPragma := HasRule(ft, model.PreserveCodingPragma, false)
|
||||
allowGoBuild := HasRule(ft, model.PreserveGoBuildConstraint, false)
|
||||
allowCSSCharset := HasRule(ft, model.PreserveCSSCharset, false)
|
||||
allowDoctype := HasRule(ft, model.PreserveDoctype, false)
|
||||
|
||||
for pos < len(content) {
|
||||
lineEnd := LineEndOffset(content, pos)
|
||||
lineText := string(content[pos:lineEnd])
|
||||
trimmed := strings.TrimRight(lineText, "\r\n")
|
||||
consumed := false
|
||||
|
||||
switch {
|
||||
case allowShebang && strings.HasPrefix(trimmed, "#!"):
|
||||
consumed = true
|
||||
case allowXML && strings.HasPrefix(strings.TrimSpace(trimmed), "<?xml"):
|
||||
consumed = true
|
||||
case allowPHP && strings.HasPrefix(strings.TrimSpace(trimmed), "<?php"):
|
||||
consumed = true
|
||||
case allowPragma && IsCodingPragma(trimmed):
|
||||
consumed = true
|
||||
case allowGoBuild:
|
||||
if next, ok := GoBuildConstraintBoundary(content, pos); ok {
|
||||
pos = next
|
||||
continue
|
||||
}
|
||||
case allowCSSCharset && IsCSSCharset(trimmed):
|
||||
consumed = true
|
||||
case allowDoctype && IsDoctype(trimmed):
|
||||
consumed = true
|
||||
}
|
||||
|
||||
if !consumed {
|
||||
break
|
||||
}
|
||||
pos = lineEnd
|
||||
}
|
||||
|
||||
return pos
|
||||
}
|
||||
|
||||
// HasRule reports whether ft lists a preserve rule of the given kind and direction.
|
||||
func HasRule(ft model.FileType, kind model.PreserveKind, before bool) bool {
|
||||
for _, r := range ft.PreserveFirst {
|
||||
if r.Kind == kind && r.Before == before {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LineEndOffset returns the offset just past the newline terminating the line at pos
|
||||
// or len(content) for the final unterminated line.
|
||||
func LineEndOffset(content []byte, pos int) int {
|
||||
for i := pos; i < len(content); i++ {
|
||||
if content[i] == '\n' {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
return len(content)
|
||||
}
|
||||
|
||||
// GoBuildConstraintBoundary advances past a leading Go build-constraint block and
|
||||
// its following blank separator, if present.
|
||||
func GoBuildConstraintBoundary(content []byte, pos int) (int, bool) {
|
||||
current := pos
|
||||
sawConstraint := false
|
||||
for current < len(content) {
|
||||
lineEnd := LineEndOffset(content, current)
|
||||
lineText := string(content[current:lineEnd])
|
||||
trimmed := strings.TrimSpace(strings.TrimRight(lineText, "\r\n"))
|
||||
|
||||
switch {
|
||||
case isGoBuildConstraint(trimmed):
|
||||
sawConstraint = true
|
||||
current = lineEnd
|
||||
default:
|
||||
if sawConstraint && trimmed == "" {
|
||||
return lineEnd, true
|
||||
}
|
||||
return current, sawConstraint
|
||||
}
|
||||
}
|
||||
return current, sawConstraint
|
||||
}
|
||||
|
||||
func isGoBuildConstraint(trimmed string) bool {
|
||||
return strings.HasPrefix(trimmed, "//go:build") || strings.HasPrefix(trimmed, "// +build")
|
||||
}
|
||||
|
||||
// IsCSSCharset reports whether trimmed is a CSS @charset at-rule.
|
||||
func IsCSSCharset(trimmed string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(trimmed)), "@charset")
|
||||
}
|
||||
|
||||
// IsDoctype reports whether trimmed is a markup doctype declaration.
|
||||
func IsDoctype(trimmed string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(trimmed)), "<!doctype")
|
||||
}
|
||||
|
||||
// IsCodingPragma reports whether trimmed is a Python/Ruby encoding pragma.
|
||||
func IsCodingPragma(trimmed string) bool {
|
||||
t := strings.TrimSpace(trimmed)
|
||||
if !strings.HasPrefix(t, "#") {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(t)
|
||||
return strings.Contains(lower, "coding:") || strings.Contains(lower, "coding=")
|
||||
}
|
||||
|
||||
func hasBOM(content []byte) bool {
|
||||
return len(content) >= len(bom) && content[0] == bom[0] && content[1] == bom[1] && content[2] == bom[2]
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package header
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/KofTwentyTwo/license-tool/internal/model"
|
||||
)
|
||||
|
||||
func TestPreserveBoundary(t *testing.T) {
|
||||
hashFT := model.FileType{
|
||||
PreserveFirst: []model.PreserveRule{
|
||||
{Kind: model.PreserveBOM, Before: false},
|
||||
{Kind: model.PreserveShebang, Before: false},
|
||||
{Kind: model.PreserveCodingPragma, Before: false},
|
||||
},
|
||||
}
|
||||
xmlFT := model.FileType{
|
||||
PreserveFirst: []model.PreserveRule{
|
||||
{Kind: model.PreserveBOM, Before: false},
|
||||
{Kind: model.PreserveXMLDecl, Before: false},
|
||||
{Kind: model.PreservePHPOpen, Before: false},
|
||||
},
|
||||
}
|
||||
goFT := model.FileType{
|
||||
PreserveFirst: []model.PreserveRule{
|
||||
{Kind: model.PreserveBOM, Before: false},
|
||||
{Kind: model.PreserveGoBuildConstraint, Before: false},
|
||||
},
|
||||
}
|
||||
cssFT := model.FileType{
|
||||
PreserveFirst: []model.PreserveRule{
|
||||
{Kind: model.PreserveBOM, Before: false},
|
||||
{Kind: model.PreserveCSSCharset, Before: false},
|
||||
},
|
||||
}
|
||||
markupFT := model.FileType{
|
||||
PreserveFirst: []model.PreserveRule{
|
||||
{Kind: model.PreserveBOM, Before: false},
|
||||
{Kind: model.PreserveXMLDecl, Before: false},
|
||||
{Kind: model.PreserveDoctype, Before: false},
|
||||
},
|
||||
}
|
||||
beforeFT := model.FileType{
|
||||
PreserveFirst: []model.PreserveRule{
|
||||
{Kind: model.PreservePackageDecl, Before: true},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
ft model.FileType
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no prefixes leaves boundary at zero",
|
||||
content: "package main\n",
|
||||
ft: hashFT,
|
||||
want: "package main\n",
|
||||
},
|
||||
{
|
||||
name: "shebang is consumed universally",
|
||||
content: "#!/bin/sh\necho hi\n",
|
||||
ft: model.FileType{},
|
||||
want: "echo hi\n",
|
||||
},
|
||||
{
|
||||
name: "final unterminated shebang line",
|
||||
content: "#!/bin/sh",
|
||||
ft: model.FileType{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "BOM is consumed first",
|
||||
content: "\uFEFF#!/bin/sh\necho hi\n",
|
||||
ft: hashFT,
|
||||
want: "echo hi\n",
|
||||
},
|
||||
{
|
||||
name: "BOM is not consumed without rule",
|
||||
content: "\uFEFFpackage main\n",
|
||||
ft: beforeFT,
|
||||
want: "\uFEFFpackage main\n",
|
||||
},
|
||||
{
|
||||
name: "xml declaration is consumed",
|
||||
content: "<?xml version=\"1.0\"?>\n<root/>\n",
|
||||
ft: xmlFT,
|
||||
want: "<root/>\n",
|
||||
},
|
||||
{
|
||||
name: "php open tag is consumed",
|
||||
content: "<?php\n// body\n",
|
||||
ft: xmlFT,
|
||||
want: "// body\n",
|
||||
},
|
||||
{
|
||||
name: "coding pragma with colon form is consumed",
|
||||
content: "# -*- coding: utf-8 -*-\nprint(1)\n",
|
||||
ft: hashFT,
|
||||
want: "print(1)\n",
|
||||
},
|
||||
{
|
||||
name: "coding pragma with equals form is consumed",
|
||||
content: "# coding=utf-8\nprint(1)\n",
|
||||
ft: hashFT,
|
||||
want: "print(1)\n",
|
||||
},
|
||||
{
|
||||
name: "go build constraint is consumed with blank separator",
|
||||
content: "//go:build linux\n\npackage main\n",
|
||||
ft: goFT,
|
||||
want: "package main\n",
|
||||
},
|
||||
{
|
||||
name: "go build constraint after BOM is consumed",
|
||||
content: "\uFEFF//go:build linux\n\npackage main\n",
|
||||
ft: goFT,
|
||||
want: "package main\n",
|
||||
},
|
||||
{
|
||||
name: "go build constraint after shebang is consumed",
|
||||
content: "#!/usr/bin/env go\n//go:build linux\n\npackage main\n",
|
||||
ft: goFT,
|
||||
want: "package main\n",
|
||||
},
|
||||
{
|
||||
name: "go build constraint with CRLF is consumed",
|
||||
content: "//go:build linux\r\n\r\npackage main\r\n",
|
||||
ft: goFT,
|
||||
want: "package main\r\n",
|
||||
},
|
||||
{
|
||||
name: "legacy go build constraint is consumed",
|
||||
content: "// +build linux\n\npackage main\n",
|
||||
ft: goFT,
|
||||
want: "package main\n",
|
||||
},
|
||||
{
|
||||
name: "combined go build constraints are consumed",
|
||||
content: "//go:build linux\n// +build linux\n\npackage main\n",
|
||||
ft: goFT,
|
||||
want: "package main\n",
|
||||
},
|
||||
{
|
||||
name: "go build constraint tolerates missing blank separator",
|
||||
content: "//go:build linux\npackage main\n",
|
||||
ft: goFT,
|
||||
want: "package main\n",
|
||||
},
|
||||
{
|
||||
name: "go build rule does not consume ordinary line comment",
|
||||
content: "// ordinary comment\npackage main\n",
|
||||
ft: goFT,
|
||||
want: "// ordinary comment\npackage main\n",
|
||||
},
|
||||
{
|
||||
name: "go-looking line is not consumed without rule",
|
||||
content: "//go:build linux\n\npackage main\n",
|
||||
ft: model.FileType{},
|
||||
want: "//go:build linux\n\npackage main\n",
|
||||
},
|
||||
{
|
||||
name: "css charset is consumed",
|
||||
content: "@charset \"UTF-8\";\nbody {}\n",
|
||||
ft: cssFT,
|
||||
want: "body {}\n",
|
||||
},
|
||||
{
|
||||
name: "css charset after BOM is consumed",
|
||||
content: "\uFEFF@charset \"UTF-8\";\nbody {}\n",
|
||||
ft: cssFT,
|
||||
want: "body {}\n",
|
||||
},
|
||||
{
|
||||
name: "css charset is case insensitive",
|
||||
content: "@CHARSET \"UTF-8\";\nbody {}\n",
|
||||
ft: cssFT,
|
||||
want: "body {}\n",
|
||||
},
|
||||
{
|
||||
name: "css-looking line is not consumed without rule",
|
||||
content: "@charset \"UTF-8\";\nbody {}\n",
|
||||
ft: model.FileType{},
|
||||
want: "@charset \"UTF-8\";\nbody {}\n",
|
||||
},
|
||||
{
|
||||
name: "doctype is consumed",
|
||||
content: "<!DOCTYPE html>\n<html></html>\n",
|
||||
ft: markupFT,
|
||||
want: "<html></html>\n",
|
||||
},
|
||||
{
|
||||
name: "doctype after BOM is consumed",
|
||||
content: "\uFEFF<!DOCTYPE html>\n<html></html>\n",
|
||||
ft: markupFT,
|
||||
want: "<html></html>\n",
|
||||
},
|
||||
{
|
||||
name: "doctype is case insensitive",
|
||||
content: "<!doctype html>\n<html></html>\n",
|
||||
ft: markupFT,
|
||||
want: "<html></html>\n",
|
||||
},
|
||||
{
|
||||
name: "xml declaration then doctype are consumed",
|
||||
content: "<?xml version=\"1.0\"?>\n<!DOCTYPE svg>\n<svg/>\n",
|
||||
ft: markupFT,
|
||||
want: "<svg/>\n",
|
||||
},
|
||||
{
|
||||
name: "doctype-looking line is not consumed without rule",
|
||||
content: "<!DOCTYPE html>\n<html></html>\n",
|
||||
ft: model.FileType{},
|
||||
want: "<!DOCTYPE html>\n<html></html>\n",
|
||||
},
|
||||
{
|
||||
name: "coding pragma is not consumed without rule",
|
||||
content: "# coding=utf-8\nprint(1)\n",
|
||||
ft: model.FileType{},
|
||||
want: "# coding=utf-8\nprint(1)\n",
|
||||
},
|
||||
{
|
||||
name: "before rule is never consumed",
|
||||
content: "package main\nfunc main(){}\n",
|
||||
ft: beforeFT,
|
||||
want: "package main\nfunc main(){}\n",
|
||||
},
|
||||
{
|
||||
name: "non-prefix line stops consumption",
|
||||
content: "#!/bin/sh\necho hi\n# coding=utf-8\n",
|
||||
ft: hashFT,
|
||||
want: "echo hi\n# coding=utf-8\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
content := []byte(tc.content)
|
||||
got := PreserveBoundary(content, tc.ft)
|
||||
assert.Equal(t, tc.want, string(content[got:]))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasRule(t *testing.T) {
|
||||
ftBOMAfter := model.FileType{
|
||||
PreserveFirst: []model.PreserveRule{
|
||||
{Kind: model.PreserveShebang, Before: false},
|
||||
{Kind: model.PreserveBOM, Before: false},
|
||||
},
|
||||
}
|
||||
ftPackageBefore := model.FileType{
|
||||
PreserveFirst: []model.PreserveRule{
|
||||
{Kind: model.PreservePackageDecl, Before: true},
|
||||
},
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
ft model.FileType
|
||||
kind model.PreserveKind
|
||||
before bool
|
||||
want bool
|
||||
}{
|
||||
{"matches kind and before", ftBOMAfter, model.PreserveBOM, false, true},
|
||||
{"kind present but before mismatches", ftBOMAfter, model.PreserveBOM, true, false},
|
||||
{"kind absent entirely", ftPackageBefore, model.PreserveBOM, false, false},
|
||||
{"empty rule list", model.FileType{}, model.PreserveShebang, false, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
assert.Equal(t, c.want, HasRule(c.ft, c.kind, c.before))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineEndOffset(t *testing.T) {
|
||||
content := []byte("a\nbc")
|
||||
assert.Equal(t, 2, LineEndOffset(content, 0))
|
||||
assert.Equal(t, len(content), LineEndOffset(content, 2))
|
||||
assert.Equal(t, len(content), LineEndOffset(content, len(content)))
|
||||
}
|
||||
|
||||
func TestGoBuildConstraintBoundary(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "go build form",
|
||||
content: "//go:build linux\n\npackage main\n",
|
||||
want: "package main\n",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "only one blank separator is consumed",
|
||||
content: "//go:build linux\n\n\npackage main\n",
|
||||
want: "\npackage main\n",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "legacy plus build form",
|
||||
content: "// +build linux\n\npackage main\n",
|
||||
want: "package main\n",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "ordinary line comment",
|
||||
content: "// not a constraint\npackage main\n",
|
||||
want: "// not a constraint\npackage main\n",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "empty content",
|
||||
content: "",
|
||||
want: "",
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
content := []byte(c.content)
|
||||
got, ok := GoBuildConstraintBoundary(content, 0)
|
||||
assert.Equal(t, c.wantOK, ok)
|
||||
assert.Equal(t, c.want, string(content[got:]))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCodingPragma(t *testing.T) {
|
||||
assert.True(t, IsCodingPragma("# -*- coding: utf-8 -*-"))
|
||||
assert.True(t, IsCodingPragma("# coding=latin-1"))
|
||||
assert.True(t, IsCodingPragma("# vim: set fileencoding=utf-8 :"))
|
||||
assert.False(t, IsCodingPragma("# just a comment"))
|
||||
assert.False(t, IsCodingPragma("not a hash line"))
|
||||
}
|
||||
|
||||
func TestIsCSSCharset(t *testing.T) {
|
||||
assert.True(t, IsCSSCharset("@charset \"UTF-8\";"))
|
||||
assert.True(t, IsCSSCharset(" @CHARSET \"UTF-8\";"))
|
||||
assert.False(t, IsCSSCharset("body { color: black; }"))
|
||||
}
|
||||
|
||||
func TestIsDoctype(t *testing.T) {
|
||||
assert.True(t, IsDoctype("<!DOCTYPE html>"))
|
||||
assert.True(t, IsDoctype(" <!doctype html>"))
|
||||
assert.False(t, IsDoctype("<html></html>"))
|
||||
}
|
||||
|
||||
func TestHasBOM(t *testing.T) {
|
||||
assert.True(t, hasBOM([]byte{0xEF, 0xBB, 0xBF, 'x'}))
|
||||
assert.False(t, hasBOM([]byte{0xEF, 0xBB}))
|
||||
assert.False(t, hasBOM([]byte("abc")))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -50,16 +50,24 @@ func (r *GradleResolver) Detect(path string) bool {
|
||||
// It is deliberately a surface scan, not a full Gradle model build: detect-only.
|
||||
var gradleDepRE = regexp.MustCompile(`["']([\w.\-]+:[\w.\-]+:[\w.\-${}]+)["']`)
|
||||
|
||||
const (
|
||||
gradleOnDiskReason = "gradle on-disk dependency-license resolution is not supported (detect-only; no on-disk license metadata, no network fetch)"
|
||||
gradleToolReason = "Gradle tool-tier dependency-license resolution is not supported; this resolver remains detect-only and does not shell out"
|
||||
)
|
||||
|
||||
// Resolve implements model.Resolver. It surface-scans the build script(s) for
|
||||
// dependency coordinates and returns each as an unresolved DependencyLicense with
|
||||
// a reason. It never resolves a license: Gradle resolution is out of scope for v1.
|
||||
// a reason. It never resolves a license: Gradle resolution is detect-only.
|
||||
func (r *GradleResolver) Resolve(path string, opts model.ResolveOptions) ([]model.DependencyLicense, error) {
|
||||
coords := map[string]string{} // coordinate "group:artifact" -> version
|
||||
for _, m := range gradleManifests {
|
||||
scanGradleManifest(filepath.Join(path, m), coords)
|
||||
}
|
||||
|
||||
const reason = "gradle dependency-license resolution not implemented in v1 (detect-only; no on-disk license metadata, no network fetch)"
|
||||
reason := gradleOnDiskReason
|
||||
if opts.AllowToolShellOut {
|
||||
reason = gradleToolReason
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(coords))
|
||||
for n := range coords {
|
||||
|
||||
@@ -72,7 +72,7 @@ dependencies {
|
||||
names = append(names, d.Name)
|
||||
assert.Equal(t, model.ResolutionUnresolved, d.Resolution)
|
||||
assert.Equal(t, "gradle", d.Ecosystem)
|
||||
assert.Contains(t, d.Reason, "not implemented in v1")
|
||||
assert.Contains(t, d.Reason, "detect-only")
|
||||
}
|
||||
|
||||
// Sorted and deduplicated.
|
||||
@@ -90,6 +90,20 @@ dependencies {
|
||||
assert.Empty(t, byName["org.example:propver"].Version)
|
||||
}
|
||||
|
||||
func TestGradleResolveToolTierReason(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := `dependencies { implementation 'com.google.guava:guava:31.1-jre' }`
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "build.gradle"), []byte(content), 0o644))
|
||||
|
||||
r := &GradleResolver{}
|
||||
out, err := r.Resolve(dir, model.ResolveOptions{AllowToolShellOut: true})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 1)
|
||||
|
||||
assert.Equal(t, model.ResolutionUnresolved, out[0].Resolution)
|
||||
assert.Equal(t, "Gradle tool-tier dependency-license resolution is not supported; this resolver remains detect-only and does not shell out", out[0].Reason)
|
||||
}
|
||||
|
||||
// TestGradleResolveNoManifest verifies Resolve on a directory with no build script
|
||||
// returns an empty (non-nil-handled) result: scanGradleManifest's read-error path.
|
||||
func TestGradleResolveNoManifest(t *testing.T) {
|
||||
|
||||
@@ -223,6 +223,27 @@ func idsFrom(s *store, err error) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// RenderableIDs returns every SPDX identifier with vendored license detail the tool
|
||||
// can render, sorted by id. It is intentionally narrower than IDs(), which returns
|
||||
// every non-deprecated id from the full SPDX index.
|
||||
func RenderableIDs() []string {
|
||||
return renderableIDsFrom(get())
|
||||
}
|
||||
|
||||
// renderableIDsFrom builds the sorted id list from the curated rendering set. On a
|
||||
// snapshot load error it returns nil, matching IDs()'s empty-picker degradation.
|
||||
func renderableIDsFrom(s *store, err error) []string {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(s.licenses))
|
||||
for id := range s.licenses {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// CommonIDs returns a curated, display-ordered shortlist of the licenses users pick
|
||||
// most often. WHY a hand-ordered slice rather than a sorted subset of IDs: the order
|
||||
// is editorial (most-recommended first, permissive before copyleft), so the picker
|
||||
|
||||
@@ -163,15 +163,19 @@ func TestAccessorsWithLoadError(t *testing.T) {
|
||||
assert.Equal(t, "", listVersionFrom(nil, errStore), "ListVersion must be empty on load error")
|
||||
|
||||
assert.Nil(t, idsFrom(nil, errStore), "IDs must be nil on load error so the picker degrades to empty")
|
||||
assert.Nil(t, renderableIDsFrom(nil, errStore), "RenderableIDs must be nil on load error so the picker degrades to empty")
|
||||
}
|
||||
|
||||
// TestAccessorsFromHelpersHappyPath confirms the extracted helpers preserve the
|
||||
// non-error behavior they were factored out of.
|
||||
func TestAccessorsFromHelpersHappyPath(t *testing.T) {
|
||||
s := &store{
|
||||
ids: map[string]indexEntry{"MIT": {LicenseID: "MIT", Name: "MIT License"}},
|
||||
licenses: map[string]model.License{"MIT": {SPDXID: "MIT", Text: "body"}},
|
||||
version: "test-1.0",
|
||||
ids: map[string]indexEntry{"MIT": {LicenseID: "MIT", Name: "MIT License"}},
|
||||
licenses: map[string]model.License{
|
||||
"Apache-2.0": {SPDXID: "Apache-2.0", Text: "apache body"},
|
||||
"MIT": {SPDXID: "MIT", Text: "body"},
|
||||
},
|
||||
version: "test-1.0",
|
||||
}
|
||||
|
||||
assert.True(t, validateFrom(s, nil)("MIT"))
|
||||
@@ -185,6 +189,7 @@ func TestAccessorsFromHelpersHappyPath(t *testing.T) {
|
||||
assert.False(t, ok)
|
||||
|
||||
assert.Equal(t, "test-1.0", listVersionFrom(s, nil))
|
||||
assert.Equal(t, []string{"Apache-2.0", "MIT"}, renderableIDsFrom(s, nil))
|
||||
}
|
||||
|
||||
// TestClassifyAllBranches drives every arm of classify, including the default
|
||||
|
||||
@@ -120,6 +120,14 @@ func TestIDs(t *testing.T) {
|
||||
assert.Contains(t, ids, "MIT", "the canonical MIT id should be offered")
|
||||
}
|
||||
|
||||
func TestRenderableIDs(t *testing.T) {
|
||||
ids := RenderableIDs()
|
||||
require.NotEmpty(t, ids, "the vendored snapshot must yield renderable ids")
|
||||
assert.True(t, sort.StringsAreSorted(ids), "RenderableIDs() must be sorted")
|
||||
assert.Contains(t, ids, "MIT", "curated MIT id should be renderable")
|
||||
assert.NotContains(t, ids, "Zlib", "valid SPDX ids without vendored detail must not be renderable")
|
||||
}
|
||||
|
||||
// TestCommonIDs confirms every curated shortlist id validates against the full
|
||||
// vendored index, so the picker can never present a "common" option that the rest
|
||||
// of the tool would later reject as an unknown SPDX id.
|
||||
|
||||
@@ -36,22 +36,32 @@ fi
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate 2/6: Static analysis
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "=== Gate 2/6: go vet ==="
|
||||
echo "=== Gate 2/6: static analysis ==="
|
||||
if go vet ./...; then
|
||||
pass "go vet"
|
||||
else
|
||||
fail "go vet reported problems"
|
||||
fi
|
||||
if golangci-lint run; then
|
||||
pass "golangci-lint run"
|
||||
else
|
||||
fail "golangci-lint reported problems"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate 3/6: Tests with the race detector
|
||||
# Gate 3/6: Tests with the race detector and coverage gate
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "=== Gate 3/6: go test -race ==="
|
||||
if go test ./... -race; then
|
||||
pass "go test ./... -race"
|
||||
echo "=== Gate 3/6: go test -race + coverage ==="
|
||||
if go test ./... -race -coverpkg=./internal/...,./cmd/... -covermode=atomic -coverprofile=cover.out; then
|
||||
pass "go test ./... -race with coverage profile"
|
||||
else
|
||||
fail "tests failed under the race detector"
|
||||
fi
|
||||
if go run github.com/vladopajic/go-test-coverage/v2@v2.18.8 --config=.testcoverage.yml; then
|
||||
pass "100% coverage gate"
|
||||
else
|
||||
fail "coverage gate failed"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate 4/6: Release tooling (GoReleaser)
|
||||
|
||||
Reference in new issue
Block a user