feat: detect script type by shebang for extensionless files #10

Closed
opened 2026-06-04 16:07:36 +00:00 by KofTwentyTwo · 2 comments
KofTwentyTwo commented 2026-06-04 16:07:36 +00:00 (Migrated from github.com)

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

# 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.):

#!/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:

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.:

    // 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).
## 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`).
KofTwentyTwo commented 2026-06-04 16:29:13 +00:00 (Migrated from github.com)

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.

**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._
KofTwentyTwo commented 2026-06-04 20:28:03 +00:00 (Migrated from github.com)

Resolved on develop in commit 742c124e6ec2afa9312075db3112bc2def3c91ff.

Validation:

Closing as fixed.

Resolved on `develop` in commit `742c124e6ec2afa9312075db3112bc2def3c91ff`. Validation: - CI: success, https://github.com/KofTwentyTwo/license-tool/actions/runs/26977377097 - CodeQL: success, https://github.com/KofTwentyTwo/license-tool/actions/runs/26977376421 - Coverage gate: 100% (2087/2087) Closing as fixed.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: KofTwentyTwo/license-tool#10