Skip to content

copyright: --fix keeps blank lines (a blank comment line is not a notice line) - #2228

Merged
bong-water-water-bong merged 1 commit into
mainfrom
cl-game/copyright-fix-keeps-blank-lines
Nov 11, 2026
Merged

bong-water-water-bong merged 1 commit into
mainfrom
cl-game/copyright-fix-keeps-blank-lines

Conversation

@bong-water-water-bong

@bong-water-water-bong bong-water-water-bong commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

tools/copyright.py --fix deletes blank lines from the files it repairs. I found it while reviewing #2183: build there is red on --check for 13 two-liner headers, and the obvious fix, --fix, changed those files by +143 −68 when it should have only added lines.

Cause

_CANON_LINES is built from every line of NOTICE. The licence block's blank comment lines (#, //) reduce to "", so the set contained the empty string, and canonical_notice_line() returned true for every blank or bare-comment line.

  • add_notice() drops every line in the 20-line header window that it treats as a notice line. That removed the separator after a file's description, the blank lines between #include groups, and the one after namespace {.
  • repair_notice()'s doubled-block dedupe keeps the first copy of each canonical line. With "" in the set, it dropped every blank line after the first in the whole file, past the header window too: 0 of 36 kept in the test case below.

Fix

A line with no words in it cannot be a claim. The set and the predicate now exclude the empty key (+8 −2 in tools/copyright.py, most of it the comment saying why).

Measured

main this PR
--fix on #2183's 13 files at 7297f03 +143 −68, then --check rc 0 +156 −0 (12 licence lines each), then --check rc 0
new case: two-liner header, add path FAIL, lost 5 blank lines ok
new case: near-miss holder, repair path FAIL, lost 7 blank lines ok
tests/copyright_check_untracked_test.py 2 failures of 19 0 failures of 19
tests/copyright_gate_test.sh PASS PASS

Both new cases compare the written file with the exact expected text, so a single lost line fails them.

Overlap

#2199 also edits tools/copyright.py and the same test file. It adds the short-header detection and message (short_notice, strip_short_header), not _CANON_LINES or canonical_notice_line(). The hunks are disjoint, but whichever lands second will need a trivial rebase of the test file's tail.

Refs #2183 (review at 7297f03).

🤖 Generated with Claude Code

…ice line)

The licence block's own blank comment lines (`#`, `//`) reduce to "", so
_CANON_LINES held the empty string and canonical_notice_line() said yes to
every blank or bare-comment line in a file. Two effects:

- add_notice() dropped every such line in the 20-line header window. On #2183's
  13 two-liner headers --fix gave +143 -68: it removed the separator after the
  description, the blank lines between #include groups and the one after
  `namespace {`.
- repair_notice()'s doubled-block dedupe dropped every blank line after the
  first in the whole file (0 of 36 kept in the test case).

Only lines with words in them can be a claim, so the set and the predicate
now exclude the empty key. With this, --fix on the same 13 files gives +156 -0
(exactly the 12 licence lines each), and --check passes.

tests/copyright_check_untracked_test.py: two cases compare the written file
with the exact expected text, for the add path and for the repair path (blank
lines past the header window). Main's tool fails both (lost 5, lost 7);
with the fix all 19 pass. tests/copyright_gate_test.sh still passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Blank Line Handling Bug

The fix addresses a bug where blank comment lines in the licence block were being treated as canonical notice lines, causing --fix to incorrectly delete blank lines from files. This occurred because the _CANON_LINES set included the empty string, which resulted in canonical_notice_line() returning true for all blank or bare-comment lines. The fix ensures only lines with actual content are considered canonical.

# The licence block's own blank comment lines (`#`, `//`) reduce to "", so a set built from every NOTICE line held the
# empty string, and canonical_notice_line() said yes to every blank or bare-comment line in a file. add_notice() then
# deleted each one in the header window (the separator after the description, the blank lines between #include groups
# and after `namespace {`), and repair_notice()'s doubled-block dedupe deleted every blank line after the first in the
# whole file. Only lines with words in them can be a claim, so only those are canonical.
_CANON_LINES = {k for k in (re.sub(r"^\W+", "", n).strip() for n in NOTICE) if k}


def canonical_notice_line(line):
    """True when the line IS one of the canonical notice lines, spelled exactly.

    engine#2129, met while fixing a new file's header: an SPDX two-liner without the licence block is "missing" to
    has_notice(), so --fix prepends the block -- and the two-liner it just merged into that block stayed behind, so the
    file --fix had just written failed --check with "duplicated inside the header window". near_miss_holder() cannot
    cover it by construction: it returns False for an exact holder, because an exact holder next to the canonical block
    is the *repaired* state. The block already contains both lines, so a copy inside the window is not a second claim.
    """
    key = re.sub(r"^\W+", "", line).strip()
    return bool(key) and key in _CANON_LINES

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at f08a74c — and I have to own the cause: this defect was introduced by the fix I READY'd in #2192.

The chain, exactly

# #2192 (reviewed READY by me):
_CANON_LINES = {re.sub(r"^\W+", "", n).strip() for n in NOTICE}
canonical_notice_line = lambda line: re.sub(r"^\W+", "", line).strip() in _CANON_LINES
-    kept = [l for l in head if not near_miss_holder(l)]
+    kept = [l for l in head if not near_miss_holder(l) and not canonical_notice_line(l)]

NOTICE contains blank separator lines (between the SPDX line and the licence paragraph, between paragraphs). Each reduces to "", so "" ∈ _CANON_LINES and canonical_notice_line("") is True for every blank line. The filter then drops every blank line in the header window, and repair_notice()'s doubled-block dedupe drops every blank line after the first in the whole file — the +143 −68 measured here.

I reviewed that diff, called the reasoning "correct rather than convenient", and did not ask the one question the code begs: what is in _CANON_LINES besides the lines with words in them? A set built from a multi-line string with blank lines in it has "" in it, and a predicate that says "this line is the canonical notice" then says yes to nothing at all.

The fix states the right principle

"Only lines with words in them can be a claim, so only those are canonical."

That is the invariant, and it is the reason this is a fix rather than a patch. Two asks:

  1. make it unrepresentable — build the set with if n.strip(), or assert "" not in _CANON_LINES next to it. The comment explains the hazard; the code should make the hazard impossible, because the next person to extend NOTICE will not read a comment;
  2. an arm for this defect: tests/copyright_check_untracked_test.py covers a different bug — "--check must see a file that is not staged yet" — so nothing in this PR fails if blank lines start being deleted again. A file with a # separator inside its header, --fix, then assert the separator survives, is the arm that pins the regression I caused.

And the diffstat trap, which I nearly fell into twice

git diff --stat origin/main...HEAD showed 16 files, +743/−40 — most of it main's own movement (tools/hrx_f16_narrowing_check.py is already on main). Measured against the merge base, this PR is 2 files, +47/−2. Same trap as #2035 (+23,692 that was main's commits); the only way to read a diffstat is against the merge base.

@bong-water-water-bong
bong-water-water-bong merged commit ada88bb into main Nov 11, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the cl-game/copyright-fix-keeps-blank-lines branch November 11, 2026 21:33
bong-water-water-bong pushed a commit that referenced this pull request Nov 12, 2026
… so strip_short_header() is redundant
bong-water-water-bong pushed a commit that referenced this pull request Nov 12, 2026
Answers the CHANGES at 751aa35. Main has taken two --fix changes since this branch
was cut:

- #2192 (83012c8) repairs a short header, including one behind a `#!` -- `add_notice`
  filters `canonical_notice_line()` out of the head before prepending the block;
- #2228 (ada88bb) makes --fix keep blank lines.

Merged on top of those, this branch's `strip_short_header()` was redundant and
actively wrong: its `if rest and rest[0].strip() == "" and len(rest) > 1: rest = rest[1:]`
dropped a blank line after the stub, undoing #2228. The reviewer measured both:

| file | main | this head (before) |
|---|---|---|
| blank.sh (`#!` + stub + blank + code) | blank kept | blank dropped |
| blank.h (stub + blank + `#pragma once`) | blank kept | blank dropped |

So the function and its call are gone. What this PR keeps is the half main does not
have: `short_notice()` and the sentence it prints --

    copyright + SPDX present but the licence block is not (paste the 16-line form)

-- which is why the PR exists at all: main's `copyright.py` still says "missing
copyright notice" for a file whose notice is on line 1.

Re-ran the reviewer's four-case table at this head: `short.h`, `tool.py` (#! + # stub),
`blank.sh` (#! + stub + blank), `blank.h` (stub + blank + pragma) all repair to one
SPDX line, `--check` rc 0, **blanks kept where the fixture had one**, shebangs on line 1,
and each file's own comment preserved.

tests/copyright_check_untracked_test.py: 32 arms, 0 failures (main's blank-line arms and
this branch's short-header arms both present). copyright.py --check on this tree: rc 0.
bong-water-water-bong added a commit that referenced this pull request Nov 12, 2026
…2199)

* copyright: a short header is named as one, not as a missing notice

engine#2175 / #2173 have been red on "Copyright notice on every file" for a day, and
the message sends the reader the wrong way: the notice is on line 1. What those files
keep is `// Copyright ...` and `// SPDX-License-Identifier: Apache-2.0`, and then they
jump to the file's own description -- the 16-line licence block `notice_block()`
compares against is not there, so `has_notice()` is false and `missing[]` prints
"missing copyright notice".

Measured on the two heads, same work, four lines apart:

  #2175 audio_mel.h line 4:  // audio_mel.h -- the log-mel front-end: hann-windowed STFT ...
  #2133 audio_mel.h line 4:  // Licensed under the Apache License, Version 2.0 (the "License");

#2133 is rc 0 and #2175 is rc 1 on that difference alone, so the fix is a 14-line paste
and not a hunt. `short_notice()` names the shape:

  copyright + SPDX present but the licence block is not (paste the 16-line form): <path>

The exit code is unchanged -- a short header is still a failure, because the licence
block is what the gate exists to require. Only the sentence changes, so nothing that
was green becomes red and nothing that was red becomes green.

Test: three arms in tests/copyright_check_untracked_test.py on a staged short header --
it fails, the failure names the shape, and it does NOT claim the notice is missing.
Verified: that test 18 arms / 0 failures; `copyright.py --check` on this tree rc 0;
and the predicate on the real files (short.h -> has_notice False / short_notice True,
full.h -> has_notice True / short_notice False).

Refs #2175, #2173, #2133.

* copyright: --fix replaces a short header instead of stacking a second one

Measured on the shape #2199 names: a header keeping only `// Copyright ...` and
`// SPDX-License-Identifier: Apache-2.0`.

  $ copyright.py --check     copyright + SPDX present but the licence block is not: short.h
  $ copyright.py --fix       added: short.h
  $ copyright.py --check     copyright notice duplicated inside the header window: short.h   rc 1

--fix prepended the canonical block and LEFT the two stub lines, so the file came out
with the Copyright/SPDX pair twice, `duplicated_notice()` fired, and the check stayed
red. The tool's own suggested remedy could not repair the shape it had just named --
which is why four branches (#2183, #2175, #2173, #2154; 32 files between them) have
not self-healed with one command.

`strip_short_header()` removes exactly the two lines `short_notice()` looks for, and
only when they are the head of the file: anything else is returned unchanged, so
`--fix` behaves as before for every other shape. The file's own header text and a
leading `#!` are kept.

After: --fix -> "added: short.h"; --check rc 0; one SPDX line; the block present; the
description line intact.

Test: five arms in tests/copyright_check_untracked_test.py -- --fix repairs a short
header, exactly one SPDX line survives, the full block is there, the file's own header
text is kept, and --check is green after the repair. That file: 23 arms, 0 failures.
`copyright.py --check` on this tree rc 0 (nothing that was green became red).

Refs #2199, #2175, #2183, #2154.

* copyright: --fix finds a short header behind a shebang

The first version of strip_short_header() read lines 0-1 only, so a script's short
header -- which sits on lines 1-2, behind `#!` -- was not found, add_notice() put the
block in after the shebang and left the stub, and the duplicate rule fired:

  $ copyright.py --check   copyright + SPDX present but the licence block is not: short.sh
  $ copyright.py --fix     added: short.sh
  $ copyright.py --check   copyright notice duplicated inside the header window: short.sh   rc 1

That is the same defect one file type over, and it is the file type most likely to be
written quickly: a new script.

Now the two lines are located after a leading `#!` and the shebang is carried through.
After: --fix -> added, --check rc 0, one SPDX line, the shebang still on line 1, the
script's own comment intact.

Five arms in tests/copyright_check_untracked_test.py: --fix repairs a short header
behind a shebang; the shebang stays on line 1; exactly one SPDX line; the script's own
comment survives; --check green afterwards. That file: 28 arms, 0 failures.
`copyright.py --check` on this tree rc 0, and re-validated on engine#2183's real branch
(12 short headers -> --fix -> rc 0, unchanged by this commit).

Refs #2199, #2183.

* copyright: drop strip_short_header, now that main's --fix does that job

Answers the CHANGES at 751aa35. Main has taken two --fix changes since this branch
was cut:

- #2192 (83012c8) repairs a short header, including one behind a `#!` -- `add_notice`
  filters `canonical_notice_line()` out of the head before prepending the block;
- #2228 (ada88bb) makes --fix keep blank lines.

Merged on top of those, this branch's `strip_short_header()` was redundant and
actively wrong: its `if rest and rest[0].strip() == "" and len(rest) > 1: rest = rest[1:]`
dropped a blank line after the stub, undoing #2228. The reviewer measured both:

| file | main | this head (before) |
|---|---|---|
| blank.sh (`#!` + stub + blank + code) | blank kept | blank dropped |
| blank.h (stub + blank + `#pragma once`) | blank kept | blank dropped |

So the function and its call are gone. What this PR keeps is the half main does not
have: `short_notice()` and the sentence it prints --

    copyright + SPDX present but the licence block is not (paste the 16-line form)

-- which is why the PR exists at all: main's `copyright.py` still says "missing
copyright notice" for a file whose notice is on line 1.

Re-ran the reviewer's four-case table at this head: `short.h`, `tool.py` (#! + # stub),
`blank.sh` (#! + stub + blank), `blank.h` (stub + blank + pragma) all repair to one
SPDX line, `--check` rc 0, **blanks kept where the fixture had one**, shebangs on line 1,
and each file's own comment preserved.

tests/copyright_check_untracked_test.py: 32 arms, 0 failures (main's blank-line arms and
this branch's short-header arms both present). copyright.py --check on this tree: rc 0.

---------

Co-authored-by: bong-water-water-bong <bong@water>
Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant