diff --git a/tests/copyright_gate_test.sh b/tests/copyright_gate_test.sh index 2f02feedb..f73a8d4a2 100755 --- a/tests/copyright_gate_test.sh +++ b/tests/copyright_gate_test.sh @@ -53,6 +53,13 @@ git -C "$tmp" commit -qm base # captured into a variable, never piped: `gate | grep -q` would make grep close the pipe early and, with pipefail, # report the gate's SIGPIPE instead of its verdict +NOTICE_BLOCK=$(python3 - "$tmp/copyright.py" <<'PYN' +import sys +src = open(sys.argv[1]).read() +notice = src.split('NOTICE = """')[1].split('""".split')[0] +print("".join("# " + l + "\n" if l.strip() else "#\n" for l in notice.splitlines()), end="") +PYN +) gate() { ( cd "$tmp" && python3 copyright.py --check 2>&1 ); } gate_has() { case "$(gate)" in *"$1"*) return 0;; *) return 1;; esac; } @@ -137,5 +144,30 @@ check "near-miss beside an existing notice leaves exactly two copyright lines" \ check "and the near-miss spelling is the one that went" "[ $(grep -c 'bong-water-bong$' "$both") -eq 0 ]" "$(grep 'Copyright' "$both")" rm -f "$both"; git -C "$tmp" update-index --force-remove both.py 2>/dev/null +# 2. the two-liner without the licence block: --fix must leave ONE notice, not a copy of the pair beside the block +# (engine#2129 -- --fix used to prepend the block, keep the two-liner, and then fail its own --check with "duplicated +# inside the header window", so the file it had just written was invalid and neither mode said so). +printf '# Copyright 2026 bong-water-bong\n# SPDX-License-Identifier: Apache-2.0\n\nx = 1\n' > "$tmp/twoliner.py" +git -C "$tmp" add twoliner.py +( cd "$tmp" && python3 copyright.py --fix >/dev/null 2>&1 ) +check "--fix on a two-liner leaves a file that its own --check accepts" \ + "! ( cd \"$tmp\" && python3 copyright.py --check 2>&1 | grep -q duplicated )" \ + "$( cd "$tmp" && python3 copyright.py --check 2>&1 )" +check "...and exactly one SPDX line is left in the window" \ + "[ \$(head -20 \"$tmp/twoliner.py\" | grep -c 'SPDX-License-Identifier') -eq 1 ]" \ + "window: $(head -20 "$tmp/twoliner.py" | grep -c 'SPDX-License-Identifier') SPDX line(s)" + +# 3. the block pasted twice: --fix must leave ONE notice. This is #2192's second shape -- `--fix` printed `repaired:` and +# left the copy in place, so the file it had just written still failed the very check that sent it there. +printf '%s%s\nx = 1\n' "$NOTICE_BLOCK" "$NOTICE_BLOCK" > "$tmp/doubled.py" +git -C "$tmp" add doubled.py +( cd "$tmp" && python3 copyright.py --fix >/dev/null 2>&1 ) +check "--fix on a doubled block leaves a file that its own --check accepts" \ + "! ( cd \"$tmp\" && python3 copyright.py --check 2>&1 | grep -q duplicated )" \ + "$( cd "$tmp" && python3 copyright.py --check 2>&1 )" +check "...and exactly one SPDX line survives the repair" \ + "[ \$(grep -c 'SPDX-License-Identifier' \"$tmp/doubled.py\") -eq 1 ]" \ + "$(grep -c 'SPDX-License-Identifier' "$tmp/doubled.py") SPDX line(s)" + echo "$([ $fail -eq 0 ] && echo PASS || echo FAIL)" -exit $((fail)) +exit $((fail)) \ No newline at end of file diff --git a/tools/copyright.py b/tools/copyright.py old mode 100644 new mode 100755 index 705dc082c..2e3888f6a --- a/tools/copyright.py +++ b/tools/copyright.py @@ -229,6 +229,21 @@ def near_miss_holder(line): return n >= HOLDER_NEAR_MISS_PREFIX +_CANON_LINES = {re.sub(r"^\W+", "", n).strip() for n in NOTICE} + + +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. + """ + return re.sub(r"^\W+", "", line).strip() in _CANON_LINES + + def repair_notice(text, block): """The file already HAS the canonical notice, plus a near-miss of our holder: delete the misspelling. @@ -236,8 +251,13 @@ def repair_notice(text, block): and it needed its own path because it is neither "notice missing" (has_notice() is true, so --fix skipped the file) nor "duplicate canonical pair" (duplicated_notice() only reported it, even under --fix). Dropping the copyright line and the SPDX line directly beneath it keeps a near-miss pair from leaving an orphan. + + It also keeps the FIRST canonical notice and drops later copies of the same line, which is the doubled-block shape: + --fix used to answer `repaired:` for that state while `--check` went on reporting the duplicate, i.e. the one mode + that could fix it claimed to and did not. """ out, drop_next_spdx = [], False + seen_canonical = set() for line in text.splitlines(keepends=True): if near_miss_holder(line): drop_next_spdx = True @@ -246,6 +266,15 @@ def repair_notice(text, block): drop_next_spdx = False continue drop_next_spdx = False + # A SECOND canonical notice is the same defect as a near-miss beside the first: the block pasted twice. `--check` + # has always reported it (`duplicated_notice`); `--fix` used to print "repaired" and leave the copy in place, so the + # file it had just written still failed the very check that sent it there. The first occurrence is kept and later + # ones dropped, which is what "delete the second copy" means. + if canonical_notice_line(line): + key = re.sub(r"^\W+", "", line).strip() + if key in seen_canonical: + continue + seen_canonical.add(key) out.append(line) repaired = "".join(out) return repaired if has_notice(repaired) else add_notice(repaired, block) @@ -258,7 +287,7 @@ def add_notice(text, block): # block and leave the hand-written one in place, which is how a file ended up with two notices and a green # `--check` (engine#1606). Dual attribution with a different holder is untouched - only the window is read. head = lines[:NOTICE_WINDOW] - 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)] lines = kept + lines[NOTICE_WINDOW:] return "".join(lines[:insert_at]) + block + "".join(lines[insert_at:])