apply: CSS @charset rule disabled when header is inserted before it #7

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

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

@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):

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:

/*
  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:

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

    {
        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):

    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 — @charset must be the first thing in the stylesheet (optional BOM aside); a preceding comment makes it invalid/ignored.

🤖 Generated with Claude Code

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

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.

**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._
KofTwentyTwo commented 2026-06-04 20:27:57 +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#7