license-tool silently skips extensionless executable scripts (e.g. bin/deploy, scripts/release, hooks/pre-commit) even when they carry a shebang such as #!/bin/bash or #!/usr/bin/env python3. These files have a clear, unambiguous language signal — the interpreter named in the shebang — but the file-type classifier only matches by exact base name or by lowercased extension, so an extensionless script matches nothing and is reported as unknown type and never stamped.
This proposes a conservative shebang-based file-type fallback: when (and only when) name/extension lookup fails and the file's first line begins with #!, parse the interpreter from the shebang and resolve it to the matching comment style (Shell/Python/Ruby/Perl → #; Node → /* */; PHP → /* */; etc.). Unknown interpreters keep the current skip behavior.
This is the natural complement to the universal-shebang preservation that already shipped in PR #5 / v0.2.1 (fix: preserve shebang universally so headers never precede #!). PR #5 guaranteed that if we stamp a script, the header lands after the #! line. This issue guarantees that an extensionless script with a #! line is actually recognized as a stampable script in the first place.
Type / Severity
Type: enhancement (feature)
Severity: low-to-medium. No data loss or corruption. The current behavior is safe (skip), but it is a real coverage gap: a large class of real-world source files (CI scripts, git hooks, dev tooling in bin/) are conventionally extensionless with a shebang, and today they all silently fall out of audit/apply as unknown type.
Reproduction
# Build the tool from the repo root.
go build -o /tmp/license-tool ./cmd/license-tool
# Create a throwaway repo with one extensionless shell script.
mkdir -p /tmp/lt-repro/bin &&cd /tmp/lt-repro
git init -b main -q
cat > bin/deploy <<'EOF'
#!/bin/bash
set -euo pipefail
echo "deploying"
EOF
chmod +x bin/deploy
git add -A && git commit -qm "add deploy script"# Audit. The script is classified "unknown type" and skipped.
/tmp/license-tool audit .
Observed: bin/deploy is reported as skipped with reason unknown type (set in internal/enumerate/enumerate.go, classifyEntry, when classify(rel) returns ok == false). apply therefore never writes a header to it.
Expected: bin/deploy is recognized as Shell via its #!/bin/bash shebang and gets a #-prefixed header inserted after the shebang line (e.g.):
A second extensionless script with an unrecognized interpreter (e.g. first line #!/usr/bin/env elvish) must still be skipped as unknown type — the fallback is conservative.
Root cause
filetype.Lookup in internal/filetype/filetype.go (lines 209–222) resolves a path by exact base name first, then lowercased extension only:
funcLookup(pathstring)(model.FileType,bool){base:=filepath.Base(path)ifft,ok:=nameIndex[base];ok{returnft,true}ext:=strings.ToLower(filepath.Ext(base))ifext==""{returnmodel.FileType{},false// <-- extensionless => no match}ifft,ok:=extIndex[ext];ok{returnft,true}returnmodel.FileType{},false}
For an extensionless file like bin/deploy, filepath.Ext returns "", so Lookup returns (FileType{}, false) without ever inspecting the file's contents. Lookup is path-only by signature — it never sees the bytes, so it cannot consult the shebang.
That false then flows through the classify seam:
config.LookupFunc (internal/config/config.go:479–484) returns either filetype.Lookup or a filetype.Merge(...) closure (the override-aware variant in filetype.go:232–262, which has the same name-then-ext logic and the same extensionless dead-end).
cmd/license-tool/commands.gobuildAuditPipeline (line 354) passes that closure as classify into enumerate.Enumerate.
enumerate.classifyEntry (internal/enumerate/enumerate.go, ~lines 153–158) does ft, ok := classify(rel); if !ok { entry.Skip = true; entry.SkipReason = reasonUnknownType }.
So the file is marked Skip / "unknown type" and is never handed to detect.Detect or render/apply — it is silently dropped.
Note the asymmetry this creates with the boundary code, which is already shebang-aware universally (post PR #5):
render.preserveBoundary (internal/render/render.go:320–367) consumes a leading #! line unconditionally (lines 335–337), independent of the rule list.
detect.PreserveBoundary (internal/detect/detect.go:171–219) hardcodes allowShebang := true (line 187) and consumes a leading #! line for any type.
Both boundary impls already treat #! as "must be first, for any file." The classifier is the one place that still ignores the shebang — so the tool knows how to place a header around a shebang but refuses to recognize the file the shebang identifies.
Proposed fix
Add a conservative shebang-based classification fallback. Recommended shape:
New content-aware lookup in internal/filetype/filetype.go.Lookup (and the Merge closure) are path-only and must stay so for their existing callers and tests. Introduce a sibling that accepts the leading bytes, e.g.:
// LookupWithContent resolves a path to its FileType, falling back to the file's// shebang interpreter when name/extension lookup fails. head is the first chunk// of the file's bytes (a single line is sufficient).funcLookupWithContent(pathstring,head[]byte)(model.FileType,bool)
It first delegates to the existing Lookup(path); on a miss, and only when head begins with #!, it parses the interpreter and resolves to a built-in FileType. Keep Lookup/Merge returning their current results so existing behavior and tests are untouched; add an override-aware MergeWithContent (or have the Merge closure accept an optional head) for parity with the config path.
Shebang interpreter parser. Parse the first line (strip #!, split on whitespace; if the binary is env, take the next token; basename the path; strip a trailing version suffix like python3 → python, ruby2.7 → ruby). Map interpreter → existing built-in FileType from the builtin table in filetype.go (do not invent new comment styles — reuse the table so config overrides and preserve rules stay consistent):
interpreter(s)
resolves to built-in
comment style
sh, bash, dash, zsh, ksh
Shell
# line
python, python3
Python
# line
ruby
Ruby
# line
perl
(Perl → # )
# line
node, nodejs
JavaScript/TypeScript
/* */ block
php
PHP
/* */ block
Note: there is currently no Perl entry in builtin. Either add a minimal Perl FileType (CommentStyle: line("# "), PreserveFirst: [after(PreserveBOM), after(PreserveShebang)]) or map perl onto the Shell entry's # style. Adding a Perl entry is cleaner and self-documents the mapping. Any interpreter not in the table → return false (skip, unchanged).
The chosen FileType already carries after(PreserveShebang) in its PreserveFirst (Shell line 112, Python line 106, Ruby line 118, PHP line 178; JS/TS line 92), and even if it did not, render.preserveBoundary / detect.PreserveBoundary preserve #!universally (the PR #5 precedent) — so the inserted header is guaranteed to land after the shebang. This is the key reason the fix is safe: the placement machinery is already shebang-correct; we are only making classification agree.
Wire the head bytes through the classify seam.enumerate.classifyEntry already reads file bytes in this region for the binary check (reasonBinary, ~line 169–173). Pass the leading bytes (or first line) it already has into the content-aware lookup before falling back to reasonUnknownType, so an extensionless #! script resolves to a real FileType instead of being skipped. Update config.LookupFunc / buildAuditPipeline plumbing accordingly so both Lookup and Merge-override paths get the content-aware variant.
Only trigger when name/ext lookup has already missed (extensions still win — a .py with a weird shebang stays Python).
Only when the first line literally starts with #!.
Only resolve interpreters explicitly in the mapping table; everything else stays unknown type.
No change to Skip/uncommentable types.
100% coverage gate must stay green..testcoverage.yml enforces total: 100 over ./internal/... and ./cmd/... (only cmd/license-tool/main.go, cmd/license-tool/wizard.go, and scripts/gen_spdx.go are excluded). Every new branch — each interpreter arm, the env indirection, the version-suffix stripping, the non-#! early return, the unknown-interpreter skip, and the new plumbing in enumerate/config — must be exercised by tests. Add a CHANGELOG entry and cut it under the next release (the project tags releases — see v0.2.1).
Tests & acceptance criteria
Add to internal/filetype/filetype_test.go (and internal/enumerate tests for the wired path):
Extensionless file with #!/bin/bash resolves to Shell (# line style).
Extensionless file with #!/usr/bin/env python3 resolves to Python (env indirection + python3 → python suffix strip).
Extensionless file with #!/usr/bin/ruby resolves to Ruby.
Extensionless file with #!/usr/bin/env node resolves to JavaScript/TypeScript (/* */ block style).
Extensionless file with #!/usr/bin/perl resolves to Perl/# style (per the chosen mapping).
Extensionless file with #!/usr/bin/env elvish (or any unmapped interpreter) → no match (ok == false), still skipped as unknown type.
Extensionless file whose first line is not#! (e.g. set -e) → no match, skipped.
A .py file whose shebang is #!/bin/bash still resolves to Python (extension wins; shebang fallback never overrides a real ext/name match).
Lookup (path-only) behavior is unchanged for all existing cases; existing filetype_test.go cases still pass byte-for-byte.
End-to-end: the Reproduction script's bin/deploy is no longer reported unknown type; apply writes a # header after the #!/bin/bash line (assert the shebang remains line 1 — this is the PR #5 invariant), and a second apply is idempotent (action == "none").
Config file_types override path (filetype.Merge / config.LookupFunc) also benefits from the shebang fallback, with at least one test through that closure.
go test ./... passes and go-test-coverage against .testcoverage.yml still reports 100% total (no new uncovered branches).
References
File-type table + Lookup dead-end on extensionless files: internal/filetype/filetype.go:209-222 (and the Merge closure with the same logic, :232-262); the builtin table at :50-187 (Shell :108-113, Python :102-107, Ruby :114-119, JS/TS :88-93, PHP :174-179).
Skip-on-unknown classification: internal/enumerate/enumerate.goclassifyEntry (reasonUnknownType set when classify returns false, :153-158); binary-check read of leading bytes in the same function (:169-173).
Classify seam wiring: internal/config/config.go:479-484 (LookupFunc) and cmd/license-tool/commands.go:354 (buildAuditPipeline).
Universal-shebang precedent (the pattern to follow): PR #5 / commit f4a111ffix: preserve shebang universally so headers never precede #! (#5), released as v0.2.1. Implemented in internal/render/render.go:320-367 (preserveBoundary, unconditional #! consume at :335-337) and internal/detect/detect.go:171-219 (PreserveBoundary, allowShebang := true at :187).
Model types for the mapping: model.FileType / model.CommentStyle / model.PreserveRule / model.PreserveKind (incl. PreserveShebang, PreserveBOM) in internal/model/model.go:132-189.
Coverage gate that must remain at 100%: .testcoverage.yml (total: 100).
## Summary
`license-tool` silently skips extensionless executable scripts (e.g. `bin/deploy`, `scripts/release`, `hooks/pre-commit`) even when they carry a shebang such as `#!/bin/bash` or `#!/usr/bin/env python3`. These files have a clear, unambiguous language signal — the interpreter named in the shebang — but the file-type classifier only matches by exact base name or by lowercased extension, so an extensionless script matches nothing and is reported as `unknown type` and never stamped.
This proposes a conservative **shebang-based file-type fallback**: when (and only when) name/extension lookup fails *and* the file's first line begins with `#!`, parse the interpreter from the shebang and resolve it to the matching comment style (Shell/Python/Ruby/Perl → `#`; Node → `/* */`; PHP → `/* */`; etc.). Unknown interpreters keep the current skip behavior.
This is the natural complement to the universal-shebang preservation that already shipped in PR #5 / v0.2.1 (`fix: preserve shebang universally so headers never precede #!`). PR #5 guaranteed that *if* we stamp a script, the header lands after the `#!` line. This issue guarantees that an extensionless script with a `#!` line is actually *recognized* as a stampable script in the first place.
## Type / Severity
- **Type:** enhancement (feature)
- **Severity:** low-to-medium. No data loss or corruption. The current behavior is safe (skip), but it is a real coverage gap: a large class of real-world source files (CI scripts, git hooks, dev tooling in `bin/`) are conventionally extensionless with a shebang, and today they all silently fall out of `audit`/`apply` as `unknown type`.
## Reproduction
```bash
# Build the tool from the repo root.
go build -o /tmp/license-tool ./cmd/license-tool
# Create a throwaway repo with one extensionless shell script.
mkdir -p /tmp/lt-repro/bin && cd /tmp/lt-repro
git init -b main -q
cat > bin/deploy <<'EOF'
#!/bin/bash
set -euo pipefail
echo "deploying"
EOF
chmod +x bin/deploy
git add -A && git commit -qm "add deploy script"
# Audit. The script is classified "unknown type" and skipped.
/tmp/license-tool audit .
```
Observed: `bin/deploy` is reported as skipped with reason `unknown type` (set in `internal/enumerate/enumerate.go`, `classifyEntry`, when `classify(rel)` returns `ok == false`). `apply` therefore never writes a header to it.
Expected: `bin/deploy` is recognized as **Shell** via its `#!/bin/bash` shebang and gets a `#`-prefixed header inserted *after* the shebang line (e.g.):
```bash
#!/bin/bash
# SPDX-License-Tool: license-tool:managed
# SPDX-FileCopyrightText: 2026 <holder>
# SPDX-License-Identifier: <id>
set -euo pipefail
echo "deploying"
```
A second extensionless script with an unrecognized interpreter (e.g. first line `#!/usr/bin/env elvish`) must still be skipped as `unknown type` — the fallback is conservative.
## Root cause
`filetype.Lookup` in `internal/filetype/filetype.go` (lines 209–222) resolves a path by **exact base name first, then lowercased extension only**:
```go
func Lookup(path string) (model.FileType, bool) {
base := filepath.Base(path)
if ft, ok := nameIndex[base]; ok {
return ft, true
}
ext := strings.ToLower(filepath.Ext(base))
if ext == "" {
return model.FileType{}, false // <-- extensionless => no match
}
if ft, ok := extIndex[ext]; ok {
return ft, true
}
return model.FileType{}, false
}
```
For an extensionless file like `bin/deploy`, `filepath.Ext` returns `""`, so `Lookup` returns `(FileType{}, false)` without ever inspecting the file's contents. `Lookup` is path-only by signature — it never sees the bytes, so it cannot consult the shebang.
That `false` then flows through the classify seam:
- `config.LookupFunc` (`internal/config/config.go:479–484`) returns either `filetype.Lookup` or a `filetype.Merge(...)` closure (the override-aware variant in `filetype.go:232–262`, which has the **same** name-then-ext logic and the same extensionless dead-end).
- `cmd/license-tool/commands.go` `buildAuditPipeline` (line 354) passes that closure as `classify` into `enumerate.Enumerate`.
- `enumerate.classifyEntry` (`internal/enumerate/enumerate.go`, ~lines 153–158) does `ft, ok := classify(rel); if !ok { entry.Skip = true; entry.SkipReason = reasonUnknownType }`.
So the file is marked `Skip` / `"unknown type"` and is never handed to `detect.Detect` or `render`/apply — it is silently dropped.
Note the asymmetry this creates with the boundary code, which is already shebang-aware **universally** (post PR #5):
- `render.preserveBoundary` (`internal/render/render.go:320–367`) consumes a leading `#!` line unconditionally (lines 335–337), independent of the rule list.
- `detect.PreserveBoundary` (`internal/detect/detect.go:171–219`) hardcodes `allowShebang := true` (line 187) and consumes a leading `#!` line for any type.
Both boundary impls already treat `#!` as "must be first, for any file." The classifier is the one place that still ignores the shebang — so the tool knows how to *place a header around* a shebang but refuses to *recognize the file* the shebang identifies.
## Proposed fix
Add a conservative shebang-based classification fallback. Recommended shape:
1. **New content-aware lookup in `internal/filetype/filetype.go`.** `Lookup` (and the `Merge` closure) are path-only and must stay so for their existing callers and tests. Introduce a sibling that accepts the leading bytes, e.g.:
```go
// LookupWithContent resolves a path to its FileType, falling back to the file's
// shebang interpreter when name/extension lookup fails. head is the first chunk
// of the file's bytes (a single line is sufficient).
func LookupWithContent(path string, head []byte) (model.FileType, bool)
```
It first delegates to the existing `Lookup(path)`; on a miss, **and only when `head` begins with `#!`**, it parses the interpreter and resolves to a built-in `FileType`. Keep `Lookup`/`Merge` returning their current results so existing behavior and tests are untouched; add an override-aware `MergeWithContent` (or have the `Merge` closure accept an optional head) for parity with the config path.
2. **Shebang interpreter parser.** Parse the first line (strip `#!`, split on whitespace; if the binary is `env`, take the next token; basename the path; strip a trailing version suffix like `python3` → `python`, `ruby2.7` → `ruby`). Map interpreter → existing built-in `FileType` from the `builtin` table in `filetype.go` (do **not** invent new comment styles — reuse the table so config overrides and preserve rules stay consistent):
| interpreter(s) | resolves to built-in | comment style |
|---|---|---|
| `sh`, `bash`, `dash`, `zsh`, `ksh` | Shell | `# ` line |
| `python`, `python3` | Python | `# ` line |
| `ruby` | Ruby | `# ` line |
| `perl` | (Perl → `# `) | `# ` line |
| `node`, `nodejs` | JavaScript/TypeScript | `/* */` block |
| `php` | PHP | `/* */` block |
Note: there is currently **no Perl entry** in `builtin`. Either add a minimal Perl `FileType` (`CommentStyle: line("# ")`, `PreserveFirst: [after(PreserveBOM), after(PreserveShebang)]`) or map `perl` onto the Shell entry's `#` style. Adding a Perl entry is cleaner and self-documents the mapping. Any interpreter not in the table → **return `false`** (skip, unchanged).
The chosen `FileType` already carries `after(PreserveShebang)` in its `PreserveFirst` (Shell line 112, Python line 106, Ruby line 118, PHP line 178; JS/TS line 92), and even if it did not, `render.preserveBoundary` / `detect.PreserveBoundary` preserve `#!` **universally** (the PR #5 precedent) — so the inserted header is guaranteed to land after the shebang. This is the key reason the fix is safe: the placement machinery is already shebang-correct; we are only making classification agree.
3. **Wire the head bytes through the classify seam.** `enumerate.classifyEntry` already reads file bytes in this region for the binary check (`reasonBinary`, ~line 169–173). Pass the leading bytes (or first line) it already has into the content-aware lookup before falling back to `reasonUnknownType`, so an extensionless `#!` script resolves to a real `FileType` instead of being skipped. Update `config.LookupFunc` / `buildAuditPipeline` plumbing accordingly so both `Lookup` and `Merge`-override paths get the content-aware variant.
4. **Conservatism guardrails (mirror PR #5's reasoning):**
- Only trigger when name/ext lookup has already missed (extensions still win — a `.py` with a weird shebang stays Python).
- Only when the first line literally starts with `#!`.
- Only resolve interpreters explicitly in the mapping table; everything else stays `unknown type`.
- No change to `Skip`/uncommentable types.
5. **100% coverage gate must stay green.** `.testcoverage.yml` enforces `total: 100` over `./internal/...` and `./cmd/...` (only `cmd/license-tool/main.go`, `cmd/license-tool/wizard.go`, and `scripts/gen_spdx.go` are excluded). Every new branch — each interpreter arm, the `env` indirection, the version-suffix stripping, the non-`#!` early return, the unknown-interpreter skip, and the new plumbing in `enumerate`/`config` — must be exercised by tests. Add a CHANGELOG entry and cut it under the next release (the project tags releases — see `v0.2.1`).
## Tests & acceptance criteria
Add to `internal/filetype/filetype_test.go` (and `internal/enumerate` tests for the wired path):
- [ ] Extensionless file with `#!/bin/bash` resolves to **Shell** (`# ` line style).
- [ ] Extensionless file with `#!/usr/bin/env python3` resolves to **Python** (`env` indirection + `python3` → `python` suffix strip).
- [ ] Extensionless file with `#!/usr/bin/ruby` resolves to **Ruby**.
- [ ] Extensionless file with `#!/usr/bin/env node` resolves to **JavaScript/TypeScript** (`/* */` block style).
- [ ] Extensionless file with `#!/usr/bin/perl` resolves to Perl/`#` style (per the chosen mapping).
- [ ] Extensionless file with `#!/usr/bin/env elvish` (or any unmapped interpreter) → **no match** (`ok == false`), still skipped as `unknown type`.
- [ ] Extensionless file whose first line is **not** `#!` (e.g. `set -e`) → no match, skipped.
- [ ] A `.py` file whose shebang is `#!/bin/bash` still resolves to **Python** (extension wins; shebang fallback never overrides a real ext/name match).
- [ ] `Lookup` (path-only) behavior is unchanged for all existing cases; existing `filetype_test.go` cases still pass byte-for-byte.
- [ ] End-to-end: the Reproduction script's `bin/deploy` is no longer reported `unknown type`; `apply` writes a `#` header **after** the `#!/bin/bash` line (assert the shebang remains line 1 — this is the PR #5 invariant), and a second `apply` is idempotent (`action == "none"`).
- [ ] Config `file_types` override path (`filetype.Merge` / `config.LookupFunc`) also benefits from the shebang fallback, with at least one test through that closure.
- [ ] `go test ./...` passes and `go-test-coverage` against `.testcoverage.yml` still reports 100% total (no new uncovered branches).
## References
- File-type table + `Lookup` dead-end on extensionless files: `internal/filetype/filetype.go:209-222` (and the `Merge` closure with the same logic, `:232-262`); the `builtin` table at `:50-187` (Shell `:108-113`, Python `:102-107`, Ruby `:114-119`, JS/TS `:88-93`, PHP `:174-179`).
- Skip-on-unknown classification: `internal/enumerate/enumerate.go` `classifyEntry` (`reasonUnknownType` set when `classify` returns `false`, ~`:153-158`); binary-check read of leading bytes in the same function (~`:169-173`).
- Classify seam wiring: `internal/config/config.go:479-484` (`LookupFunc`) and `cmd/license-tool/commands.go:354` (`buildAuditPipeline`).
- Universal-shebang precedent (the pattern to follow): PR #5 / commit `f4a111f` `fix: preserve shebang universally so headers never precede #! (#5)`, released as `v0.2.1`. Implemented in `internal/render/render.go:320-367` (`preserveBoundary`, unconditional `#!` consume at `:335-337`) and `internal/detect/detect.go:171-219` (`PreserveBoundary`, `allowShebang := true` at `:187`).
- Model types for the mapping: `model.FileType` / `model.CommentStyle` / `model.PreserveRule` / `model.PreserveKind` (incl. `PreserveShebang`, `PreserveBOM`) in `internal/model/model.go:132-189`.
- Coverage gate that must remain at 100%: `.testcoverage.yml` (`total: 100`).
Status: confirmed — extensionless scripts with a #! shebang are classified unknown type and never stamped; reproduced against a fresh build (bin/deploy with #!/bin/bash → [skipped: unknown type]).
Severity: low-to-medium (matches the label's framing). Justified: no data loss/corruption — current behavior is the safe skip. It is a genuine coverage gap (bin/, git hooks, CI scripts are conventionally extensionless), but the workaround (a config file_types filename override) exists today, so this is an enhancement, not a defect.
Verified root cause: internal/filetype/filetype.go:Lookup:209-222 — for an extensionless base name, filepath.Ext returns "" and the function returns (FileType{}, false) at line 215-217 without ever inspecting file bytes (it is path-only by signature). The Merge closure carries the identical extensionless dead-end at filetype.go:248-261 (early return false at :254-256). That false flows through internal/enumerate/enumerate.go:classifyEntry:153-159, which sets Skip = true / reasonUnknownType before any byte is read for classification. Confirmed the binary-check read (readHead) already happens later in the same function at :171, so the leading bytes are available to plumb in. All other cited refs check out: config.LookupFunc at config.go:479-484, buildAuditPipeline classify wiring at commands.go:354, and both boundaries are already shebang-universal (render.go:preserveBoundary:335-337 unconditional #! consume; detect.go:PreserveBoundary:187allowShebang := true).
Suggested fix: Follow the PR #5 / v0.2.1 universal-shebang precedent. Add a content-aware sibling (LookupWithContent(path string, head []byte)) that delegates to Lookup first and only on a miss — and only when head starts with #! — parses the interpreter (strip #!, split on whitespace, follow env to the next token, basename, strip trailing version digits) and maps it to an existing builtinFileType. This must touch BOTH the path-only Lookup and the Merge-override closure (mirror via a MergeWithContent), plus the filetype table itself (no Perl entry exists today — add one or map perl onto Shell's # style; the issue's table is otherwise correct, and the target types already carry after(PreserveShebang)). Wire the head bytes through classifyEntry (reuse the bytes readHead already reads) and update the config.LookupFunc / buildAuditPipeline plumbing. Keep Lookup/Merge return values byte-for-byte unchanged so existing tests pass, and cover every new branch (each interpreter arm, env indirection, version-strip, non-#! early return, unknown-interpreter skip, both plumbing paths) to hold the .testcoverage.ymltotal: 100 gate.
Corrections to the issue: none material. The body is unusually accurate — every line citation verified against the current tree. Minor note: the body describes the binary read as living at "~line 169-173" of enumerate.go; the actual readHead/IsBinary call is at :171, which is within that range, so no correction needed. The "low-to-medium" self-assessment is fair.
Open questions (maintainer decisions before implementing):
Perl: add a new builtin Perl FileType (cleaner, self-documenting) or alias onto Shell's # style? The issue recommends the former.
Interpreter table scope: is the proposed set (sh/bash/dash/zsh/ksh, python[3], ruby, perl, node/nodejs, php) the intended initial coverage, or should it be exhaustive vs. deliberately minimal-and-conservative?
env flag-passing edge case: #!/usr/bin/env -S python3 -u — does the parser need to skip env options (-S, -i, VAR=val) to find the real interpreter, or is the simple "next token after env" acceptable for v1?
Overlaps: none confirmed from this issue alone. It explicitly builds on PR #5 / commit f4a111f (v0.2.1, universal-shebang preservation) as the placement precedent, but that is shipped, not an open dependency. A maintainer should scan other open enhancement issues for any competing file-type-classification proposal before scheduling.
Automated maintainer review.
**Status**: confirmed — extensionless scripts with a `#!` shebang are classified `unknown type` and never stamped; reproduced against a fresh build (`bin/deploy` with `#!/bin/bash` → `[skipped: unknown type]`).
**Severity**: low-to-medium (matches the label's framing). Justified: no data loss/corruption — current behavior is the safe `skip`. It is a genuine coverage gap (`bin/`, git hooks, CI scripts are conventionally extensionless), but the workaround (a config `file_types` filename override) exists today, so this is an enhancement, not a defect.
**Verified root cause**: `internal/filetype/filetype.go:Lookup:209-222` — for an extensionless base name, `filepath.Ext` returns `""` and the function returns `(FileType{}, false)` at line 215-217 without ever inspecting file bytes (it is path-only by signature). The `Merge` closure carries the identical extensionless dead-end at `filetype.go:248-261` (early `return false` at `:254-256`). That `false` flows through `internal/enumerate/enumerate.go:classifyEntry:153-159`, which sets `Skip = true` / `reasonUnknownType` before any byte is read for classification. Confirmed the binary-check read (`readHead`) already happens later in the same function at `:171`, so the leading bytes are available to plumb in. All other cited refs check out: `config.LookupFunc` at `config.go:479-484`, `buildAuditPipeline` classify wiring at `commands.go:354`, and both boundaries are already shebang-universal (`render.go:preserveBoundary:335-337` unconditional `#!` consume; `detect.go:PreserveBoundary:187` `allowShebang := true`).
**Suggested fix**: Follow the PR #5 / v0.2.1 universal-shebang precedent. Add a content-aware sibling (`LookupWithContent(path string, head []byte)`) that delegates to `Lookup` first and only on a miss — and only when `head` starts with `#!` — parses the interpreter (strip `#!`, split on whitespace, follow `env` to the next token, basename, strip trailing version digits) and maps it to an existing `builtin` `FileType`. This must touch BOTH the path-only `Lookup` and the `Merge`-override closure (mirror via a `MergeWithContent`), plus the filetype table itself (no Perl entry exists today — add one or map `perl` onto Shell's `#` style; the issue's table is otherwise correct, and the target types already carry `after(PreserveShebang)`). Wire the head bytes through `classifyEntry` (reuse the bytes `readHead` already reads) and update the `config.LookupFunc` / `buildAuditPipeline` plumbing. Keep `Lookup`/`Merge` return values byte-for-byte unchanged so existing tests pass, and cover every new branch (each interpreter arm, `env` indirection, version-strip, non-`#!` early return, unknown-interpreter skip, both plumbing paths) to hold the `.testcoverage.yml` `total: 100` gate.
**Corrections to the issue**: none material. The body is unusually accurate — every line citation verified against the current tree. Minor note: the body describes the binary read as living at "~line 169-173" of `enumerate.go`; the actual `readHead`/`IsBinary` call is at `:171`, which is within that range, so no correction needed. The "low-to-medium" self-assessment is fair.
**Open questions** (maintainer decisions before implementing):
1. Perl: add a new `builtin` Perl `FileType` (cleaner, self-documenting) or alias onto Shell's `#` style? The issue recommends the former.
2. Interpreter table scope: is the proposed set (sh/bash/dash/zsh/ksh, python[3], ruby, perl, node/nodejs, php) the intended initial coverage, or should it be exhaustive vs. deliberately minimal-and-conservative?
3. `env` flag-passing edge case: `#!/usr/bin/env -S python3 -u` — does the parser need to skip `env` options (`-S`, `-i`, `VAR=val`) to find the real interpreter, or is the simple "next token after env" acceptable for v1?
**Overlaps**: none confirmed from this issue alone. It explicitly builds on PR #5 / commit `f4a111f` (v0.2.1, universal-shebang preservation) as the placement precedent, but that is shipped, not an open dependency. A maintainer should scan other open `enhancement` issues for any competing file-type-classification proposal before scheduling.
_Automated maintainer review._
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
license-toolsilently skips extensionless executable scripts (e.g.bin/deploy,scripts/release,hooks/pre-commit) even when they carry a shebang such as#!/bin/bashor#!/usr/bin/env python3. These files have a clear, unambiguous language signal — the interpreter named in the shebang — but the file-type classifier only matches by exact base name or by lowercased extension, so an extensionless script matches nothing and is reported asunknown typeand never stamped.This proposes a conservative shebang-based file-type fallback: when (and only when) name/extension lookup fails and the file's first line begins with
#!, parse the interpreter from the shebang and resolve it to the matching comment style (Shell/Python/Ruby/Perl →#; Node →/* */; PHP →/* */; etc.). Unknown interpreters keep the current skip behavior.This is the natural complement to the universal-shebang preservation that already shipped in PR #5 / v0.2.1 (
fix: preserve shebang universally so headers never precede #!). PR #5 guaranteed that if we stamp a script, the header lands after the#!line. This issue guarantees that an extensionless script with a#!line is actually recognized as a stampable script in the first place.Type / Severity
bin/) are conventionally extensionless with a shebang, and today they all silently fall out ofaudit/applyasunknown type.Reproduction
Observed:
bin/deployis reported as skipped with reasonunknown type(set ininternal/enumerate/enumerate.go,classifyEntry, whenclassify(rel)returnsok == false).applytherefore never writes a header to it.Expected:
bin/deployis recognized as Shell via its#!/bin/bashshebang and gets a#-prefixed header inserted after the shebang line (e.g.):A second extensionless script with an unrecognized interpreter (e.g. first line
#!/usr/bin/env elvish) must still be skipped asunknown type— the fallback is conservative.Root cause
filetype.Lookupininternal/filetype/filetype.go(lines 209–222) resolves a path by exact base name first, then lowercased extension only:For an extensionless file like
bin/deploy,filepath.Extreturns"", soLookupreturns(FileType{}, false)without ever inspecting the file's contents.Lookupis path-only by signature — it never sees the bytes, so it cannot consult the shebang.That
falsethen flows through the classify seam:config.LookupFunc(internal/config/config.go:479–484) returns eitherfiletype.Lookupor afiletype.Merge(...)closure (the override-aware variant infiletype.go:232–262, which has the same name-then-ext logic and the same extensionless dead-end).cmd/license-tool/commands.gobuildAuditPipeline(line 354) passes that closure asclassifyintoenumerate.Enumerate.enumerate.classifyEntry(internal/enumerate/enumerate.go, ~lines 153–158) doesft, ok := classify(rel); if !ok { entry.Skip = true; entry.SkipReason = reasonUnknownType }.So the file is marked
Skip/"unknown type"and is never handed todetect.Detectorrender/apply — it is silently dropped.Note the asymmetry this creates with the boundary code, which is already shebang-aware universally (post PR #5):
render.preserveBoundary(internal/render/render.go:320–367) consumes a leading#!line unconditionally (lines 335–337), independent of the rule list.detect.PreserveBoundary(internal/detect/detect.go:171–219) hardcodesallowShebang := true(line 187) and consumes a leading#!line for any type.Both boundary impls already treat
#!as "must be first, for any file." The classifier is the one place that still ignores the shebang — so the tool knows how to place a header around a shebang but refuses to recognize the file the shebang identifies.Proposed fix
Add a conservative shebang-based classification fallback. Recommended shape:
New content-aware lookup in
internal/filetype/filetype.go.Lookup(and theMergeclosure) are path-only and must stay so for their existing callers and tests. Introduce a sibling that accepts the leading bytes, e.g.:It first delegates to the existing
Lookup(path); on a miss, and only whenheadbegins with#!, it parses the interpreter and resolves to a built-inFileType. KeepLookup/Mergereturning their current results so existing behavior and tests are untouched; add an override-awareMergeWithContent(or have theMergeclosure accept an optional head) for parity with the config path.Shebang interpreter parser. Parse the first line (strip
#!, split on whitespace; if the binary isenv, take the next token; basename the path; strip a trailing version suffix likepython3→python,ruby2.7→ruby). Map interpreter → existing built-inFileTypefrom thebuiltintable infiletype.go(do not invent new comment styles — reuse the table so config overrides and preserve rules stay consistent):sh,bash,dash,zsh,ksh#linepython,python3#lineruby#lineperl#)#linenode,nodejs/* */blockphp/* */blockNote: there is currently no Perl entry in
builtin. Either add a minimal PerlFileType(CommentStyle: line("# "),PreserveFirst: [after(PreserveBOM), after(PreserveShebang)]) or mapperlonto the Shell entry's#style. Adding a Perl entry is cleaner and self-documents the mapping. Any interpreter not in the table → returnfalse(skip, unchanged).The chosen
FileTypealready carriesafter(PreserveShebang)in itsPreserveFirst(Shell line 112, Python line 106, Ruby line 118, PHP line 178; JS/TS line 92), and even if it did not,render.preserveBoundary/detect.PreserveBoundarypreserve#!universally (the PR #5 precedent) — so the inserted header is guaranteed to land after the shebang. This is the key reason the fix is safe: the placement machinery is already shebang-correct; we are only making classification agree.Wire the head bytes through the classify seam.
enumerate.classifyEntryalready reads file bytes in this region for the binary check (reasonBinary, ~line 169–173). Pass the leading bytes (or first line) it already has into the content-aware lookup before falling back toreasonUnknownType, so an extensionless#!script resolves to a realFileTypeinstead of being skipped. Updateconfig.LookupFunc/buildAuditPipelineplumbing accordingly so bothLookupandMerge-override paths get the content-aware variant.Conservatism guardrails (mirror PR #5's reasoning):
.pywith a weird shebang stays Python).#!.unknown type.Skip/uncommentable types.100% coverage gate must stay green.
.testcoverage.ymlenforcestotal: 100over./internal/...and./cmd/...(onlycmd/license-tool/main.go,cmd/license-tool/wizard.go, andscripts/gen_spdx.goare excluded). Every new branch — each interpreter arm, theenvindirection, the version-suffix stripping, the non-#!early return, the unknown-interpreter skip, and the new plumbing inenumerate/config— must be exercised by tests. Add a CHANGELOG entry and cut it under the next release (the project tags releases — seev0.2.1).Tests & acceptance criteria
Add to
internal/filetype/filetype_test.go(andinternal/enumeratetests for the wired path):#!/bin/bashresolves to Shell (#line style).#!/usr/bin/env python3resolves to Python (envindirection +python3→pythonsuffix strip).#!/usr/bin/rubyresolves to Ruby.#!/usr/bin/env noderesolves to JavaScript/TypeScript (/* */block style).#!/usr/bin/perlresolves to Perl/#style (per the chosen mapping).#!/usr/bin/env elvish(or any unmapped interpreter) → no match (ok == false), still skipped asunknown type.#!(e.g.set -e) → no match, skipped..pyfile whose shebang is#!/bin/bashstill resolves to Python (extension wins; shebang fallback never overrides a real ext/name match).Lookup(path-only) behavior is unchanged for all existing cases; existingfiletype_test.gocases still pass byte-for-byte.bin/deployis no longer reportedunknown type;applywrites a#header after the#!/bin/bashline (assert the shebang remains line 1 — this is the PR #5 invariant), and a secondapplyis idempotent (action == "none").file_typesoverride path (filetype.Merge/config.LookupFunc) also benefits from the shebang fallback, with at least one test through that closure.go test ./...passes andgo-test-coverageagainst.testcoverage.ymlstill reports 100% total (no new uncovered branches).References
Lookupdead-end on extensionless files:internal/filetype/filetype.go:209-222(and theMergeclosure with the same logic,:232-262); thebuiltintable at:50-187(Shell:108-113, Python:102-107, Ruby:114-119, JS/TS:88-93, PHP:174-179).internal/enumerate/enumerate.goclassifyEntry(reasonUnknownTypeset whenclassifyreturnsfalse,:153-158); binary-check read of leading bytes in the same function (:169-173).internal/config/config.go:479-484(LookupFunc) andcmd/license-tool/commands.go:354(buildAuditPipeline).f4a111ffix: preserve shebang universally so headers never precede #! (#5), released asv0.2.1. Implemented ininternal/render/render.go:320-367(preserveBoundary, unconditional#!consume at:335-337) andinternal/detect/detect.go:171-219(PreserveBoundary,allowShebang := trueat:187).model.FileType/model.CommentStyle/model.PreserveRule/model.PreserveKind(incl.PreserveShebang,PreserveBOM) ininternal/model/model.go:132-189..testcoverage.yml(total: 100).Status: confirmed — extensionless scripts with a
#!shebang are classifiedunknown typeand never stamped; reproduced against a fresh build (bin/deploywith#!/bin/bash→[skipped: unknown type]).Severity: low-to-medium (matches the label's framing). Justified: no data loss/corruption — current behavior is the safe
skip. It is a genuine coverage gap (bin/, git hooks, CI scripts are conventionally extensionless), but the workaround (a configfile_typesfilename override) exists today, so this is an enhancement, not a defect.Verified root cause:
internal/filetype/filetype.go:Lookup:209-222— for an extensionless base name,filepath.Extreturns""and the function returns(FileType{}, false)at line 215-217 without ever inspecting file bytes (it is path-only by signature). TheMergeclosure carries the identical extensionless dead-end atfiletype.go:248-261(earlyreturn falseat:254-256). Thatfalseflows throughinternal/enumerate/enumerate.go:classifyEntry:153-159, which setsSkip = true/reasonUnknownTypebefore any byte is read for classification. Confirmed the binary-check read (readHead) already happens later in the same function at:171, so the leading bytes are available to plumb in. All other cited refs check out:config.LookupFuncatconfig.go:479-484,buildAuditPipelineclassify wiring atcommands.go:354, and both boundaries are already shebang-universal (render.go:preserveBoundary:335-337unconditional#!consume;detect.go:PreserveBoundary:187allowShebang := true).Suggested fix: Follow the PR #5 / v0.2.1 universal-shebang precedent. Add a content-aware sibling (
LookupWithContent(path string, head []byte)) that delegates toLookupfirst and only on a miss — and only whenheadstarts with#!— parses the interpreter (strip#!, split on whitespace, followenvto the next token, basename, strip trailing version digits) and maps it to an existingbuiltinFileType. This must touch BOTH the path-onlyLookupand theMerge-override closure (mirror via aMergeWithContent), plus the filetype table itself (no Perl entry exists today — add one or mapperlonto Shell's#style; the issue's table is otherwise correct, and the target types already carryafter(PreserveShebang)). Wire the head bytes throughclassifyEntry(reuse the bytesreadHeadalready reads) and update theconfig.LookupFunc/buildAuditPipelineplumbing. KeepLookup/Mergereturn values byte-for-byte unchanged so existing tests pass, and cover every new branch (each interpreter arm,envindirection, version-strip, non-#!early return, unknown-interpreter skip, both plumbing paths) to hold the.testcoverage.ymltotal: 100gate.Corrections to the issue: none material. The body is unusually accurate — every line citation verified against the current tree. Minor note: the body describes the binary read as living at "~line 169-173" of
enumerate.go; the actualreadHead/IsBinarycall is at:171, which is within that range, so no correction needed. The "low-to-medium" self-assessment is fair.Open questions (maintainer decisions before implementing):
builtinPerlFileType(cleaner, self-documenting) or alias onto Shell's#style? The issue recommends the former.envflag-passing edge case:#!/usr/bin/env -S python3 -u— does the parser need to skipenvoptions (-S,-i,VAR=val) to find the real interpreter, or is the simple "next token after env" acceptable for v1?Overlaps: none confirmed from this issue alone. It explicitly builds on PR #5 / commit
f4a111f(v0.2.1, universal-shebang preservation) as the placement precedent, but that is shipped, not an open dependency. A maintainer should scan other openenhancementissues for any competing file-type-classification proposal before scheduling.Automated maintainer review.
Resolved on
developin commit742c124e6ec2afa9312075db3112bc2def3c91ff.Validation:
Closing as fixed.