apply corrupts CSS/SCSS/LESS files that begin with a @charset rule. The CSS spec requires @charset "<encoding>"; to be the very first bytes of the stylesheet (only an optional UTF-8 BOM may precede it). Any character before it — including a comment — makes the @charset declaration invalid and silently ignored by browsers, which can fall back to the wrong encoding and mis-render non-ASCII content.
The CSS file type uses a /* */ block comment and only preserves a BOM (PreserveBOM). When license-tool apply --write runs on a .css file whose first line is @charset "UTF-8";, the rendered /* ... */ header is inserted at byte 0, before the @charset line, disabling the encoding declaration.
This is the CSS analogue of the shebang bug fixed in PR #5 (v0.2.1): a construct that MUST lead the file is not being treated as preserve-first, so the header lands ahead of it and breaks the file.
Type / Severity
Type: Bug (file corruption — produces invalid output that the consuming engine silently rejects).
Severity: High. apply --write mutates source files in place; the breakage is silent (no parse error from license-tool, and browsers don't error — they just ignore the @charset and may pick the wrong encoding). It is exactly the failure mode PR #5 set the precedent for fixing.
Reproduction
Sample file styles.css (the @charset rule MUST be the first bytes per the CSS spec):
@charset"UTF-8";.café{content:"héllo";}
Commands (run from a temp dir; --force allows a non-git dir, otherwise run inside a clean git repo):
Observed result — the header is spliced in at byte 0, pushing @charset down to line 5:
/*
SPDX-License-Tool: license-tool:managed
SPDX-FileCopyrightText: 2026 Acme
SPDX-License-Identifier: MIT
*/@charset"UTF-8";.café{content:"héllo";}
Because the @charset is no longer the first bytes, browsers treat it as invalid and ignore it. Expected result: the header is inserted after the @charset line (and after any BOM):
@charset"UTF-8";/*
SPDX-License-Tool: license-tool:managed
SPDX-FileCopyrightText: 2026 Acme
SPDX-License-Identifier: MIT
*/.café{content:"héllo";}
Root cause
Two independent "preserve-first" boundary implementations decide where the header is spliced, and neither knows about CSS @charset:
Insert path — internal/render/render.go, function preserveBoundary (lines ~320-367). It consumes a BOM, then a universal leading #! shebang, then iterates ft.PreserveFirst handling only PreserveBOM, PreserveShebang, PreserveXMLDecl, PreservePHPOpen, and PreserveCodingPragma. There is no rule kind for a CSS @charset line, so for a .css file the boundary stays at byte 0 (after the optional BOM) and insertAt (lines ~269-286) splices the header before @charset.
Detect path — internal/detect/detect.go, function PreserveBoundary (lines ~171-219). The mirror computation used by detection. It consumes BOM, a universal shebang, then gated XML/PHP/pragma lines — again with no notion of @charset. (The two impls are intentionally duplicated to avoid an import cycle; the file-doc comment in render.go lines ~310-319 explains why they must agree.)
File-type table — internal/filetype/filetype.go, the CSS entry (lines ~94-99):
It only lists after(model.PreserveBOM). Nothing tells the boundary logic that a leading @charset "..."; must be preserved AFTER (kept above the header).
Why this breaks: @charset is a "must-be-first" construct, structurally identical to the shebang case from PR #5 — except that, unlike #!, it is specific to CSS, so it should be expressed as a per-type preserve-AFTER rule on the CSS entry rather than handled universally. The CSS spec (CSS Syntax Level 3, §3.1; MDN @charset) is explicit: @charset is only honored when it is the literal first token of the stylesheet (an optional BOM may precede it); a preceding comment voids it.
Note the SCSS/LESS extensions share the CSS entry. @charset is also valid/emitted in Sass output, so preserving it for .scss/.less is correct and harmless (the matcher only fires when a line actually begins with @charset).
Proposed fix
Mirror the universal-shebang fix from PR #5 (f4a111f), but as a CSS-specific preserve-AFTER rule (it is not universal like a shebang):
Add a new preserve kind — internal/model/model.go. Append a new PreserveKind constant to the existing iota block (lines ~148-162), e.g. PreserveCSSCharset. Appending at the end keeps the existing iota values (and the frozen model contract) stable. Document it like the others: "a CSS @charset \"...\"; rule that must lead the stylesheet."
Handle it in BOTH boundary impls (this is the key lesson from PR #5 — fix render and detect together so they agree byte-for-byte):
internal/render/render.gopreserveBoundary: add a case model.PreserveCSSCharset: in the for _, rule := range ft.PreserveFirst switch (lines ~344-364) that advances past a line whose trimmed text begins with @charset (case-insensitively — CSS keywords are ASCII case-insensitive; match strings.HasPrefix(strings.ToLower(strings.TrimSpace(line)), "@charset")). It naturally sits after the BOM consumption, which is correct ordering (BOM may precede @charset).
internal/detect/detect.goPreserveBoundary: add an allowCSSCharset := hasRule(ft, model.PreserveCSSCharset, false) gate and a matching case allowCSSCharset && strings.HasPrefix(strings.ToLower(strings.TrimSpace(trimmed)), "@charset"): arm in the line-consumption switch (lines ~201-210). Keeping it gated by hasRule (unlike the universal shebang) confines it to CSS so it can never absorb an @charset-looking line in another language.
Prefer matching @charset (the at-keyword) rather than the full @charset "..."; with quotes, so a stray-but-real @charset line is still preserved; the line-level advanceLineIf/line-loop machinery already consumes exactly one line and only at the top of the file, so it cannot over-consume.
Add the rule to the CSS filetype entry — internal/filetype/filetype.go (lines ~94-99):
Use the existing after(...) helper (lines ~32-35), consistent with how PreserveShebang/PreserveXMLDecl are listed on other entries.
Keep the 100% coverage gate green..testcoverage.yml enforces total: 100 over ./internal/... and ./cmd/... (only main.go, wizard.go, and the //go:build ignorescripts/gen_spdx.go are excluded). Every new branch — the new case in render.preserveBoundary, the new switch arm and allowCSSCharset gate in detect.PreserveBoundary, the new PreserveCSSCharsetString()/usage if any, and the new filetype entry — MUST be exercised by tests, or CI fails. PR #5 added matching tests in internal/render/render_test.go, internal/detect/detect_test.go, internal/filetype/filetype_test.go, and internal/applier/applier_test.go; follow the same fan-out.
Tests & acceptance criteria
Acceptance criteria:
apply (dry-run and --write) on a .css/.scss/.less file starting with @charset "UTF-8"; inserts the header after the @charset line; @charset remains the first bytes of the file (after an optional BOM).
A BOM + @charset combination keeps order: BOM, then @charset, then the header.
A .css file with no@charset is unaffected (header still goes at byte 0, after any BOM).
An @charset-looking line is NOT consumed in non-CSS types (e.g. a CSS-in-JS string in a .js file is untouched), because the rule is gated by hasRule in the detect path and only present on the CSS entry.
apply is idempotent: re-running after a write produces action none (the detect-path PreserveBoundary change must match the render-path change so detection finds the header span at the correct offset).
go test ./... passes and the go-test-coverage gate stays at 100%.
internal/render/render_test.go: a table test like TestSpliceShebangPreservedForBlockCommentTypes (added by PR #5, ~line 331) but for CSS — assert the output prefix is exactly the @charset line (and BOM+@charset in a second case), and that a no-@charset.css still inserts at byte 0.
internal/detect/detect_test.go: assert PreserveBoundary on .css content advances past a leading @charset line, and a round-trip detect after insert reports Present == true with the correct StartByte.
internal/filetype/filetype_test.go: extend the existing CSS rows (lines ~33-35) / add an assertion that the CSS entry's PreserveFirst now contains the @charset rule.
internal/applier/applier_test.go: an end-to-end Apply --write case on a @charset-leading .css, asserting the byte output and idempotency on a second run.
internal/model/model_test.go: if PreserveKind has a String() or is otherwise enumerated in tests, cover the new constant to keep coverage at 100%.
References
Shebang precedent (the pattern to follow), PR #5 / commit f4a111f, "fix: preserve shebang universally so headers never precede #!":
internal/render/render.gopreserveBoundary (the universal #! consumption + the per-rule switch): lines ~320-367
internal/detect/detect.goPreserveBoundary (allowShebang := true + the line-consumption switch): lines ~171-219
Spec: CSS Syntax Module Level 3 §3.1 (Determining the fallback encoding) and MDN @charset — @charset must be the first thing in the stylesheet (optional BOM aside); a preceding comment makes it invalid/ignored.
## Summary
`apply` corrupts CSS/SCSS/LESS files that begin with a `@charset` rule. The CSS spec requires `@charset "<encoding>";` to be the **very first bytes** of the stylesheet (only an optional UTF-8 BOM may precede it). Any character before it — including a comment — makes the `@charset` declaration **invalid and silently ignored** by browsers, which can fall back to the wrong encoding and mis-render non-ASCII content.
The `CSS` file type uses a `/* */` block comment and only preserves a BOM (`PreserveBOM`). When `license-tool apply --write` runs on a `.css` file whose first line is `@charset "UTF-8";`, the rendered `/* ... */` header is inserted at byte 0, **before** the `@charset` line, disabling the encoding declaration.
This is the CSS analogue of the shebang bug fixed in PR #5 (v0.2.1): a construct that MUST lead the file is not being treated as preserve-first, so the header lands ahead of it and breaks the file.
## Type / Severity
- **Type:** Bug (file corruption — produces invalid output that the consuming engine silently rejects).
- **Severity:** High. `apply --write` mutates source files in place; the breakage is silent (no parse error from `license-tool`, and browsers don't error — they just ignore the `@charset` and may pick the wrong encoding). It is exactly the failure mode PR #5 set the precedent for fixing.
## Reproduction
Sample file `styles.css` (the `@charset` rule MUST be the first bytes per the CSS spec):
```css
@charset "UTF-8";
.café { content: "héllo"; }
```
Commands (run from a temp dir; `--force` allows a non-git dir, otherwise run inside a clean git repo):
```sh
mkdir /tmp/css-repo && cd /tmp/css-repo
printf '@charset "UTF-8";\n.caf\xc3\xa9 { content: "h\xc3\xa9llo"; }\n' > styles.css
# Dry-run shows the corrupting diff:
license-tool apply --license MIT --holder "Acme" --year 2026 --force .
# Actually write it:
license-tool apply --license MIT --holder "Acme" --year 2026 --write --force .
```
Observed result — the header is spliced in at byte 0, pushing `@charset` down to line 5:
```css
/*
SPDX-License-Tool: license-tool:managed
SPDX-FileCopyrightText: 2026 Acme
SPDX-License-Identifier: MIT
*/
@charset "UTF-8";
.café { content: "héllo"; }
```
Because the `@charset` is no longer the first bytes, browsers treat it as invalid and ignore it. Expected result: the header is inserted **after** the `@charset` line (and after any BOM):
```css
@charset "UTF-8";
/*
SPDX-License-Tool: license-tool:managed
SPDX-FileCopyrightText: 2026 Acme
SPDX-License-Identifier: MIT
*/
.café { content: "héllo"; }
```
## Root cause
Two independent "preserve-first" boundary implementations decide where the header is spliced, and neither knows about CSS `@charset`:
1. **Insert path — `internal/render/render.go`, function `preserveBoundary` (lines ~320-367).** It consumes a BOM, then a universal leading `#!` shebang, then iterates `ft.PreserveFirst` handling only `PreserveBOM`, `PreserveShebang`, `PreserveXMLDecl`, `PreservePHPOpen`, and `PreserveCodingPragma`. There is no rule kind for a CSS `@charset` line, so for a `.css` file the boundary stays at byte 0 (after the optional BOM) and `insertAt` (lines ~269-286) splices the header before `@charset`.
2. **Detect path — `internal/detect/detect.go`, function `PreserveBoundary` (lines ~171-219).** The mirror computation used by detection. It consumes BOM, a universal shebang, then gated XML/PHP/pragma lines — again with no notion of `@charset`. (The two impls are intentionally duplicated to avoid an import cycle; the file-doc comment in `render.go` lines ~310-319 explains why they must agree.)
3. **File-type table — `internal/filetype/filetype.go`, the `CSS` entry (lines ~94-99):**
```go
{
Name: "CSS",
Extensions: []string{".css", ".scss", ".less"},
CommentStyle: block("/*", "*/"),
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM)},
},
```
It only lists `after(model.PreserveBOM)`. Nothing tells the boundary logic that a leading `@charset "...";` must be preserved AFTER (kept above the header).
Why this breaks: `@charset` is a "must-be-first" construct, structurally identical to the shebang case from PR #5 — except that, unlike `#!`, it is specific to CSS, so it should be expressed as a per-type preserve-AFTER rule on the `CSS` entry rather than handled universally. The CSS spec (CSS Syntax Level 3, §3.1; MDN `@charset`) is explicit: `@charset` is only honored when it is the literal first token of the stylesheet (an optional BOM may precede it); a preceding comment voids it.
Note the SCSS/LESS extensions share the `CSS` entry. `@charset` is also valid/emitted in Sass output, so preserving it for `.scss`/`.less` is correct and harmless (the matcher only fires when a line actually begins with `@charset`).
## Proposed fix
Mirror the universal-shebang fix from PR #5 (`f4a111f`), but as a **CSS-specific preserve-AFTER rule** (it is not universal like a shebang):
1. **Add a new preserve kind — `internal/model/model.go`.** Append a new `PreserveKind` constant to the existing iota block (lines ~148-162), e.g. `PreserveCSSCharset`. Appending at the end keeps the existing iota values (and the frozen `model` contract) stable. Document it like the others: "a CSS `@charset \"...\";` rule that must lead the stylesheet."
2. **Handle it in BOTH boundary impls** (this is the key lesson from PR #5 — fix render and detect together so they agree byte-for-byte):
- `internal/render/render.go` `preserveBoundary`: add a `case model.PreserveCSSCharset:` in the `for _, rule := range ft.PreserveFirst` switch (lines ~344-364) that advances past a line whose trimmed text begins with `@charset` (case-insensitively — CSS keywords are ASCII case-insensitive; match `strings.HasPrefix(strings.ToLower(strings.TrimSpace(line)), "@charset")`). It naturally sits after the BOM consumption, which is correct ordering (BOM may precede `@charset`).
- `internal/detect/detect.go` `PreserveBoundary`: add an `allowCSSCharset := hasRule(ft, model.PreserveCSSCharset, false)` gate and a matching `case allowCSSCharset && strings.HasPrefix(strings.ToLower(strings.TrimSpace(trimmed)), "@charset"):` arm in the line-consumption `switch` (lines ~201-210). Keeping it gated by `hasRule` (unlike the universal shebang) confines it to CSS so it can never absorb an `@charset`-looking line in another language.
Prefer matching `@charset` (the at-keyword) rather than the full `@charset "..."; ` with quotes, so a stray-but-real `@charset` line is still preserved; the line-level `advanceLineIf`/line-loop machinery already consumes exactly one line and only at the top of the file, so it cannot over-consume.
3. **Add the rule to the CSS filetype entry — `internal/filetype/filetype.go`** (lines ~94-99):
```go
PreserveFirst: []model.PreserveRule{after(model.PreserveBOM), after(model.PreserveCSSCharset)},
```
Use the existing `after(...)` helper (lines ~32-35), consistent with how `PreserveShebang`/`PreserveXMLDecl` are listed on other entries.
4. **Keep the 100% coverage gate green.** `.testcoverage.yml` enforces `total: 100` over `./internal/...` and `./cmd/...` (only `main.go`, `wizard.go`, and the `//go:build ignore` `scripts/gen_spdx.go` are excluded). Every new branch — the new `case` in `render.preserveBoundary`, the new `switch` arm and `allowCSSCharset` gate in `detect.PreserveBoundary`, the new `PreserveCSSCharset` `String()`/usage if any, and the new filetype entry — MUST be exercised by tests, or CI fails. PR #5 added matching tests in `internal/render/render_test.go`, `internal/detect/detect_test.go`, `internal/filetype/filetype_test.go`, and `internal/applier/applier_test.go`; follow the same fan-out.
## Tests & acceptance criteria
Acceptance criteria:
- `apply` (dry-run and `--write`) on a `.css`/`.scss`/`.less` file starting with `@charset "UTF-8";` inserts the header **after** the `@charset` line; `@charset` remains the first bytes of the file (after an optional BOM).
- A BOM + `@charset` combination keeps order: BOM, then `@charset`, then the header.
- A `.css` file with **no** `@charset` is unaffected (header still goes at byte 0, after any BOM).
- An `@charset`-looking line is NOT consumed in non-CSS types (e.g. a CSS-in-JS string in a `.js` file is untouched), because the rule is gated by `hasRule` in the detect path and only present on the CSS entry.
- `apply` is idempotent: re-running after a write produces action `none` (the detect-path `PreserveBoundary` change must match the render-path change so detection finds the header span at the correct offset).
- `go test ./...` passes and the `go-test-coverage` gate stays at 100%.
Suggested tests (mirroring PR #5's structure):
- `internal/render/render_test.go`: a table test like `TestSpliceShebangPreservedForBlockCommentTypes` (added by PR #5, ~line 331) but for CSS — assert the output prefix is exactly the `@charset` line (and BOM+`@charset` in a second case), and that a no-`@charset` `.css` still inserts at byte 0.
- `internal/detect/detect_test.go`: assert `PreserveBoundary` on `.css` content advances past a leading `@charset` line, and a round-trip detect after insert reports `Present == true` with the correct `StartByte`.
- `internal/filetype/filetype_test.go`: extend the existing CSS rows (lines ~33-35) / add an assertion that the `CSS` entry's `PreserveFirst` now contains the `@charset` rule.
- `internal/applier/applier_test.go`: an end-to-end `Apply --write` case on a `@charset`-leading `.css`, asserting the byte output and idempotency on a second run.
- `internal/model/model_test.go`: if `PreserveKind` has a `String()` or is otherwise enumerated in tests, cover the new constant to keep coverage at 100%.
## References
- Shebang precedent (the pattern to follow), PR #5 / commit `f4a111f`, "fix: preserve shebang universally so headers never precede #!":
- `internal/render/render.go` `preserveBoundary` (the universal `#!` consumption + the per-rule `switch`): lines ~320-367
- `internal/detect/detect.go` `PreserveBoundary` (`allowShebang := true` + the line-consumption `switch`): lines ~171-219
- File-type table, `CSS` entry: `internal/filetype/filetype.go` lines ~94-99; helpers `after`/`before` lines ~32-40.
- Preserve-kind contract: `internal/model/model.go` `PreserveKind` iota (lines ~146-162) and `PreserveRule` (lines ~167-173).
- Insert mechanics: `internal/render/render.go` `Splice`/`insertAt` (lines ~240-286).
- Coverage gate: `.testcoverage.yml` (`total: 100`).
- Spec: CSS Syntax Module Level 3 §3.1 (Determining the fallback encoding) and MDN [`@charset`](https://developer.mozilla.org/en-US/docs/Web/CSS/@charset) — `@charset` must be the first thing in the stylesheet (optional BOM aside); a preceding comment makes it invalid/ignored.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Status: confirmed — reproduced byte-for-byte: apply --write on a .css file whose first line is @charset "UTF-8"; splices the /* */ header at byte 0, pushing @charset down (verified to line 7 in a temp repo with the v0.2.1 binary). Per CSS Syntax L3 §3.1, that silently voids the encoding declaration.
Severity: high (agrees with label bug/the issue's High). Silent in-place source corruption with no error from the tool or the browser; matches the failure mode PR #5 set precedent for. Not critical because it is scoped to CSS-family files that lead with @charset (a minority), and the original bytes remain recoverable from VCS.
Verified root cause (all three sites confirmed against current main, HEAD f4a111f):
internal/render/render.go:preserveBoundary — the per-rule switch (lines 344-364) handles PreserveBOM/Shebang/XMLDecl/PHPOpen/CodingPragma only; no @charset arm, so for CSS the boundary stays at byte 0 (post-BOM) and insertAt splices ahead of @charset.
internal/detect/detect.go:PreserveBoundary — mirror line-consumption switch (lines 201-210) has no @charset case either; the two impls are deliberately duplicated (render.go comment lines 316-319) and must stay in agreement.
internal/filetype/filetype.go — CSS entry (lines 94-99) lists only after(model.PreserveBOM).
Suggested fix (mirrors the PR #5 universal-shebang precedent, but CSS-specific because @charset is not universal like #!):
Append a PreserveCSSCharset constant at the END of the PreserveKind iota in internal/model/model.go (lines 148-162) so existing iota values stay stable.
Touch BOTH boundaries so they agree: add a case model.PreserveCSSCharset: in render.preserveBoundary and a gated allowCSSCharset := hasRule(ft, model.PreserveCSSCharset, false) arm in detect.PreserveBoundary, matching strings.HasPrefix(strings.ToLower(strings.TrimSpace(line)), "@charset") (ASCII case-insensitive). Keep the detect arm gated by hasRule (unlike the always-on shebang) so it can never fire in non-CSS types.
Add after(model.PreserveCSSCharset) to the CSS filetype entry's PreserveFirst.
Keep the 100% gate green (.testcoverage.yml total: 100): every new branch needs a test. Follow PR #5's fan-out — render_test.go, detect_test.go, filetype_test.go, applier_test.go, plus model_test.go if PreserveKind is enumerated there.
Corrections to the issue: Essentially none — the body is accurate, including the file:line refs (render switch is 344-364, detect switch 201-210, CSS entry 94-99, iota 148-162) and the both-boundaries-plus-table requirement. One nit: the body cites insertAt at "lines ~269-286"; in this checkout the insert path is the function at render.go around lines 288-307 (b = append(b, header...) then content[end:]). The mechanism is exactly as described; only that one line range is approximate.
Open questions:
Match the at-keyword @charset (as proposed) or the stricter full @charset "...";? At-keyword is more forgiving and the line loop only consumes one top-of-file line, so over-consumption isn't a real risk — but confirm intended strictness.
SCSS/LESS share the CSS entry. @charset is valid in Sass output; preserving it for .scss/.less is harmless (matcher only fires on a real leading @charset). Confirm that's acceptable rather than splitting CSS into its own entry.
Overlaps: none open. Conceptual sibling of PR #5 / commit f4a111f (shebang), which is the pattern to follow; no other open issue covers @charset.
Automated maintainer review.
**Status:** confirmed — reproduced byte-for-byte: `apply --write` on a `.css` file whose first line is `@charset "UTF-8";` splices the `/* */` header at byte 0, pushing `@charset` down (verified to line 7 in a temp repo with the v0.2.1 binary). Per CSS Syntax L3 §3.1, that silently voids the encoding declaration.
**Severity:** high (agrees with label `bug`/the issue's High). Silent in-place source corruption with no error from the tool or the browser; matches the failure mode PR #5 set precedent for. Not critical because it is scoped to CSS-family files that lead with `@charset` (a minority), and the original bytes remain recoverable from VCS.
**Verified root cause** (all three sites confirmed against current `main`, HEAD `f4a111f`):
- `internal/render/render.go:preserveBoundary` — the per-rule `switch` (lines 344-364) handles `PreserveBOM`/`Shebang`/`XMLDecl`/`PHPOpen`/`CodingPragma` only; no `@charset` arm, so for CSS the boundary stays at byte 0 (post-BOM) and `insertAt` splices ahead of `@charset`.
- `internal/detect/detect.go:PreserveBoundary` — mirror line-consumption `switch` (lines 201-210) has no `@charset` case either; the two impls are deliberately duplicated (render.go comment lines 316-319) and must stay in agreement.
- `internal/filetype/filetype.go` — `CSS` entry (lines 94-99) lists only `after(model.PreserveBOM)`.
**Suggested fix** (mirrors the PR #5 universal-shebang precedent, but CSS-specific because `@charset` is not universal like `#!`):
1. Append a `PreserveCSSCharset` constant at the END of the `PreserveKind` iota in `internal/model/model.go` (lines 148-162) so existing iota values stay stable.
2. Touch BOTH boundaries so they agree: add a `case model.PreserveCSSCharset:` in `render.preserveBoundary` and a gated `allowCSSCharset := hasRule(ft, model.PreserveCSSCharset, false)` arm in `detect.PreserveBoundary`, matching `strings.HasPrefix(strings.ToLower(strings.TrimSpace(line)), "@charset")` (ASCII case-insensitive). Keep the detect arm gated by `hasRule` (unlike the always-on shebang) so it can never fire in non-CSS types.
3. Add `after(model.PreserveCSSCharset)` to the `CSS` filetype entry's `PreserveFirst`.
4. Keep the 100% gate green (`.testcoverage.yml total: 100`): every new branch needs a test. Follow PR #5's fan-out — `render_test.go`, `detect_test.go`, `filetype_test.go`, `applier_test.go`, plus `model_test.go` if `PreserveKind` is enumerated there.
**Corrections to the issue:** Essentially none — the body is accurate, including the file:line refs (render switch is 344-364, detect switch 201-210, CSS entry 94-99, iota 148-162) and the both-boundaries-plus-table requirement. One nit: the body cites `insertAt` at "lines ~269-286"; in this checkout the insert path is the function at render.go around lines 288-307 (`b = append(b, header...)` then `content[end:]`). The mechanism is exactly as described; only that one line range is approximate.
**Open questions:**
- Match the at-keyword `@charset` (as proposed) or the stricter full `@charset "...";`? At-keyword is more forgiving and the line loop only consumes one top-of-file line, so over-consumption isn't a real risk — but confirm intended strictness.
- SCSS/LESS share the CSS entry. `@charset` is valid in Sass output; preserving it for `.scss`/`.less` is harmless (matcher only fires on a real leading `@charset`). Confirm that's acceptable rather than splitting CSS into its own entry.
**Overlaps:** none open. Conceptual sibling of PR #5 / commit `f4a111f` (shebang), which is the pattern to follow; no other open issue covers `@charset`.
_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
applycorrupts CSS/SCSS/LESS files that begin with a@charsetrule. The CSS spec requires@charset "<encoding>";to be the very first bytes of the stylesheet (only an optional UTF-8 BOM may precede it). Any character before it — including a comment — makes the@charsetdeclaration invalid and silently ignored by browsers, which can fall back to the wrong encoding and mis-render non-ASCII content.The
CSSfile type uses a/* */block comment and only preserves a BOM (PreserveBOM). Whenlicense-tool apply --writeruns on a.cssfile whose first line is@charset "UTF-8";, the rendered/* ... */header is inserted at byte 0, before the@charsetline, disabling the encoding declaration.This is the CSS analogue of the shebang bug fixed in PR #5 (v0.2.1): a construct that MUST lead the file is not being treated as preserve-first, so the header lands ahead of it and breaks the file.
Type / Severity
apply --writemutates source files in place; the breakage is silent (no parse error fromlicense-tool, and browsers don't error — they just ignore the@charsetand may pick the wrong encoding). It is exactly the failure mode PR #5 set the precedent for fixing.Reproduction
Sample file
styles.css(the@charsetrule MUST be the first bytes per the CSS spec):Commands (run from a temp dir;
--forceallows a non-git dir, otherwise run inside a clean git repo):Observed result — the header is spliced in at byte 0, pushing
@charsetdown to line 5:Because the
@charsetis no longer the first bytes, browsers treat it as invalid and ignore it. Expected result: the header is inserted after the@charsetline (and after any BOM):Root cause
Two independent "preserve-first" boundary implementations decide where the header is spliced, and neither knows about CSS
@charset:Insert path —
internal/render/render.go, functionpreserveBoundary(lines ~320-367). It consumes a BOM, then a universal leading#!shebang, then iteratesft.PreserveFirsthandling onlyPreserveBOM,PreserveShebang,PreserveXMLDecl,PreservePHPOpen, andPreserveCodingPragma. There is no rule kind for a CSS@charsetline, so for a.cssfile the boundary stays at byte 0 (after the optional BOM) andinsertAt(lines ~269-286) splices the header before@charset.Detect path —
internal/detect/detect.go, functionPreserveBoundary(lines ~171-219). The mirror computation used by detection. It consumes BOM, a universal shebang, then gated XML/PHP/pragma lines — again with no notion of@charset. (The two impls are intentionally duplicated to avoid an import cycle; the file-doc comment inrender.golines ~310-319 explains why they must agree.)File-type table —
internal/filetype/filetype.go, theCSSentry (lines ~94-99):It only lists
after(model.PreserveBOM). Nothing tells the boundary logic that a leading@charset "...";must be preserved AFTER (kept above the header).Why this breaks:
@charsetis a "must-be-first" construct, structurally identical to the shebang case from PR #5 — except that, unlike#!, it is specific to CSS, so it should be expressed as a per-type preserve-AFTER rule on theCSSentry rather than handled universally. The CSS spec (CSS Syntax Level 3, §3.1; MDN@charset) is explicit:@charsetis only honored when it is the literal first token of the stylesheet (an optional BOM may precede it); a preceding comment voids it.Note the SCSS/LESS extensions share the
CSSentry.@charsetis also valid/emitted in Sass output, so preserving it for.scss/.lessis correct and harmless (the matcher only fires when a line actually begins with@charset).Proposed fix
Mirror the universal-shebang fix from PR #5 (
f4a111f), but as a CSS-specific preserve-AFTER rule (it is not universal like a shebang):Add a new preserve kind —
internal/model/model.go. Append a newPreserveKindconstant to the existing iota block (lines ~148-162), e.g.PreserveCSSCharset. Appending at the end keeps the existing iota values (and the frozenmodelcontract) stable. Document it like the others: "a CSS@charset \"...\";rule that must lead the stylesheet."Handle it in BOTH boundary impls (this is the key lesson from PR #5 — fix render and detect together so they agree byte-for-byte):
internal/render/render.gopreserveBoundary: add acase model.PreserveCSSCharset:in thefor _, rule := range ft.PreserveFirstswitch (lines ~344-364) that advances past a line whose trimmed text begins with@charset(case-insensitively — CSS keywords are ASCII case-insensitive; matchstrings.HasPrefix(strings.ToLower(strings.TrimSpace(line)), "@charset")). It naturally sits after the BOM consumption, which is correct ordering (BOM may precede@charset).internal/detect/detect.goPreserveBoundary: add anallowCSSCharset := hasRule(ft, model.PreserveCSSCharset, false)gate and a matchingcase allowCSSCharset && strings.HasPrefix(strings.ToLower(strings.TrimSpace(trimmed)), "@charset"):arm in the line-consumptionswitch(lines ~201-210). Keeping it gated byhasRule(unlike the universal shebang) confines it to CSS so it can never absorb an@charset-looking line in another language.Prefer matching
@charset(the at-keyword) rather than the full@charset "...";with quotes, so a stray-but-real@charsetline is still preserved; the line-leveladvanceLineIf/line-loop machinery already consumes exactly one line and only at the top of the file, so it cannot over-consume.Add the rule to the CSS filetype entry —
internal/filetype/filetype.go(lines ~94-99):Use the existing
after(...)helper (lines ~32-35), consistent with howPreserveShebang/PreserveXMLDeclare listed on other entries.Keep the 100% coverage gate green.
.testcoverage.ymlenforcestotal: 100over./internal/...and./cmd/...(onlymain.go,wizard.go, and the//go:build ignorescripts/gen_spdx.goare excluded). Every new branch — the newcaseinrender.preserveBoundary, the newswitcharm andallowCSSCharsetgate indetect.PreserveBoundary, the newPreserveCSSCharsetString()/usage if any, and the new filetype entry — MUST be exercised by tests, or CI fails. PR #5 added matching tests ininternal/render/render_test.go,internal/detect/detect_test.go,internal/filetype/filetype_test.go, andinternal/applier/applier_test.go; follow the same fan-out.Tests & acceptance criteria
Acceptance criteria:
apply(dry-run and--write) on a.css/.scss/.lessfile starting with@charset "UTF-8";inserts the header after the@charsetline;@charsetremains the first bytes of the file (after an optional BOM).@charsetcombination keeps order: BOM, then@charset, then the header..cssfile with no@charsetis unaffected (header still goes at byte 0, after any BOM).@charset-looking line is NOT consumed in non-CSS types (e.g. a CSS-in-JS string in a.jsfile is untouched), because the rule is gated byhasRulein the detect path and only present on the CSS entry.applyis idempotent: re-running after a write produces actionnone(the detect-pathPreserveBoundarychange must match the render-path change so detection finds the header span at the correct offset).go test ./...passes and thego-test-coveragegate stays at 100%.Suggested tests (mirroring PR #5's structure):
internal/render/render_test.go: a table test likeTestSpliceShebangPreservedForBlockCommentTypes(added by PR #5, ~line 331) but for CSS — assert the output prefix is exactly the@charsetline (and BOM+@charsetin a second case), and that a no-@charset.cssstill inserts at byte 0.internal/detect/detect_test.go: assertPreserveBoundaryon.csscontent advances past a leading@charsetline, and a round-trip detect after insert reportsPresent == truewith the correctStartByte.internal/filetype/filetype_test.go: extend the existing CSS rows (lines ~33-35) / add an assertion that theCSSentry'sPreserveFirstnow contains the@charsetrule.internal/applier/applier_test.go: an end-to-endApply --writecase on a@charset-leading.css, asserting the byte output and idempotency on a second run.internal/model/model_test.go: ifPreserveKindhas aString()or is otherwise enumerated in tests, cover the new constant to keep coverage at 100%.References
f4a111f, "fix: preserve shebang universally so headers never precede #!":internal/render/render.gopreserveBoundary(the universal#!consumption + the per-ruleswitch): lines ~320-367internal/detect/detect.goPreserveBoundary(allowShebang := true+ the line-consumptionswitch): lines ~171-219CSSentry:internal/filetype/filetype.golines ~94-99; helpersafter/beforelines ~32-40.internal/model/model.goPreserveKindiota (lines ~146-162) andPreserveRule(lines ~167-173).internal/render/render.goSplice/insertAt(lines ~240-286)..testcoverage.yml(total: 100).@charset—@charsetmust be the first thing in the stylesheet (optional BOM aside); a preceding comment makes it invalid/ignored.🤖 Generated with Claude Code
Status: confirmed — reproduced byte-for-byte:
apply --writeon a.cssfile whose first line is@charset "UTF-8";splices the/* */header at byte 0, pushing@charsetdown (verified to line 7 in a temp repo with the v0.2.1 binary). Per CSS Syntax L3 §3.1, that silently voids the encoding declaration.Severity: high (agrees with label
bug/the issue's High). Silent in-place source corruption with no error from the tool or the browser; matches the failure mode PR #5 set precedent for. Not critical because it is scoped to CSS-family files that lead with@charset(a minority), and the original bytes remain recoverable from VCS.Verified root cause (all three sites confirmed against current
main, HEADf4a111f):internal/render/render.go:preserveBoundary— the per-ruleswitch(lines 344-364) handlesPreserveBOM/Shebang/XMLDecl/PHPOpen/CodingPragmaonly; no@charsetarm, so for CSS the boundary stays at byte 0 (post-BOM) andinsertAtsplices ahead of@charset.internal/detect/detect.go:PreserveBoundary— mirror line-consumptionswitch(lines 201-210) has no@charsetcase either; the two impls are deliberately duplicated (render.go comment lines 316-319) and must stay in agreement.internal/filetype/filetype.go—CSSentry (lines 94-99) lists onlyafter(model.PreserveBOM).Suggested fix (mirrors the PR #5 universal-shebang precedent, but CSS-specific because
@charsetis not universal like#!):PreserveCSSCharsetconstant at the END of thePreserveKindiota ininternal/model/model.go(lines 148-162) so existing iota values stay stable.case model.PreserveCSSCharset:inrender.preserveBoundaryand a gatedallowCSSCharset := hasRule(ft, model.PreserveCSSCharset, false)arm indetect.PreserveBoundary, matchingstrings.HasPrefix(strings.ToLower(strings.TrimSpace(line)), "@charset")(ASCII case-insensitive). Keep the detect arm gated byhasRule(unlike the always-on shebang) so it can never fire in non-CSS types.after(model.PreserveCSSCharset)to theCSSfiletype entry'sPreserveFirst..testcoverage.yml total: 100): every new branch needs a test. Follow PR #5's fan-out —render_test.go,detect_test.go,filetype_test.go,applier_test.go, plusmodel_test.goifPreserveKindis enumerated there.Corrections to the issue: Essentially none — the body is accurate, including the file:line refs (render switch is 344-364, detect switch 201-210, CSS entry 94-99, iota 148-162) and the both-boundaries-plus-table requirement. One nit: the body cites
insertAtat "lines ~269-286"; in this checkout the insert path is the function at render.go around lines 288-307 (b = append(b, header...)thencontent[end:]). The mechanism is exactly as described; only that one line range is approximate.Open questions:
@charset(as proposed) or the stricter full@charset "...";? At-keyword is more forgiving and the line loop only consumes one top-of-file line, so over-consumption isn't a real risk — but confirm intended strictness.@charsetis valid in Sass output; preserving it for.scss/.lessis harmless (matcher only fires on a real leading@charset). Confirm that's acceptable rather than splitting CSS into its own entry.Overlaps: none open. Conceptual sibling of PR #5 / commit
f4a111f(shebang), which is the pattern to follow; no other open issue covers@charset.Automated maintainer review.
Resolved on
developin commit742c124e6ec2afa9312075db3112bc2def3c91ff.Validation:
Closing as fixed.