Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions cr_checker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ python cr_checker.py -t <template_file> [options] <inputs>
- **--offset**: Force this many characters (plus any trailing blank lines) at the start of the file to be treated as a recognized preamble, overriding auto-detection. Character-based, not byte-based. Rarely needed: a leading shebang is detected and preserved automatically; use this only for other preamble kinds the tool doesn't (yet) recognize.
- **-f**, **--fix**: Setting script into fix mode where copyright header will be added to the files if it's missing from same.
- **--remove-offset**: Number of characters to remove before appending proper copyright header (works only with `--fix` option).
- **--force**: With `--fix`, also rewrite headers whose similarity to the template is below the auto-fix threshold (normally left untouched and only reported, since they may be a genuinely different license text). Never affects a duplicate-header file, which always requires manual review. Ignored without `--fix`.
- **--force**: With `--fix`, also rewrite headers whose similarity to the template is below the auto-fix threshold (normally left untouched and only reported, since they may be a genuinely different license text). Never affects a duplicate-header file or one with a genuine SPDX license mismatch, both of which always require manual review. Ignored without `--fix`.
- **--modified-only**: Only check files that differ from `HEAD` (staged and/or unstaged), e.g. for a fast, incremental pre-commit run. Takes precedence over `inputs`.
- **inputs**: Directories or files to parse, or a parameter file prefixed with @ that lists files or directories. Optional -- when omitted, the whole repository (per `git ls-files`) is checked.

Expand Down Expand Up @@ -132,8 +132,16 @@ bazel run //:copyright.fix -- --force
```

`--force` never touches a file with a *duplicate* copyright header --
that always requires manual review, regardless of similarity. **Always
review the resulting diff afterwards**, since a low similarity score can
that always requires manual review, regardless of similarity. It also never
touches a header with a genuine *SPDX license mismatch* (e.g. an existing
`MIT` header where the template expects `Apache-2.0`) -- that's detected
separately from the overall similarity score (a short header can score
high similarity overall while still naming a different license), and is
always left for manual review since silently overwriting someone else's
license declaration is a legal/compliance decision, not a formatting fix.
The comparison is lenient about spacing, dots and hyphens (`Apache-2.0` ==
`Apache 2.0` == `apache2.0`), so only an actual license difference trips it.
**Always review the resulting diff afterwards**, since a low similarity score can
also mean the existing text is a genuinely different, unrelated license.

`--offset=<NUM>` is only needed to force-treat a preamble kind the tool
Expand Down
80 changes: 66 additions & 14 deletions cr_checker/tests/test_cr_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,8 @@ def test_classify_scores_formatting_drift_highly(tmp_path):

def test_classify_scores_unrelated_license_low(tmp_path):
"""A header for a genuinely different license must score well below the
auto-fix threshold, so `--fix` never silently overwrites it."""
auto-fix threshold, AND be caught by the SPDX mismatch guard -- either
would prevent `--fix` from silently overwriting it."""
cr_checker = load_cr_checker_module()
header_template = load_template("rs")
config_file = write_config(tmp_path, "Author")
Expand All @@ -507,10 +508,24 @@ def test_classify_scores_unrelated_license_low(tmp_path):
layout = cr_checker.locate_header(unrelated_header)
status, similarity = cr_checker.classify(layout, header_template, config_file)

assert status is cr_checker.Status.WRONG_FORMAT
assert status is cr_checker.Status.LICENSE_MISMATCH
assert similarity < cr_checker.HEADER_SIMILARITY_THRESHOLD


def test_spdx_mismatch_ignores_spacing_dots_and_hyphens(tmp_path):
"""A harmless formatting variant of the SAME identifier (spacing/dots/
hyphens differ but the license doesn't) must NOT trip the mismatch guard."""
cr_checker = load_cr_checker_module()
header_template = load_template("py")
config_file = write_config(tmp_path, "Author")
variant_header = "# Copyright (c) 2024 Author\n#\n# SPDX-License-Identifier: apache 2.0\n"

layout = cr_checker.locate_header(variant_header)
status, _ = cr_checker.classify(layout, header_template, config_file)

assert status is not cr_checker.Status.LICENSE_MISMATCH


# --- duplicate_similarity (diagnostics for DUPLICATE status) ---


Expand Down Expand Up @@ -799,16 +814,17 @@ def test_process_files_check_mode_ignores_remove_offset(tmp_path):

def test_process_files_fix_without_force_leaves_low_similarity_header_untouched(tmp_path):
"""Baseline: a WRONG_FORMAT header that scores below
`HEADER_SIMILARITY_THRESHOLD` (a genuinely different license, not just a
formatting drift) must be left alone by `--fix` without `--force`."""
`HEADER_SIMILARITY_THRESHOLD` (unrecognizable boilerplate, though it
carries the same SPDX identifier so it isn't a LICENSE_MISMATCH) must be
left alone by `--fix` without `--force`."""
cr_checker = load_cr_checker_module()
header_template = load_template("py")
config = write_config(tmp_path, "Author")
unrelated_header = (
"# Copyright (c) 2020 Some Other Corp. All rights reserved.\n"
"# Licensed under the MIT License; see the LICENSE file for details.\n"
"# Copyright presence only, then completely different padding text follows\n"
"# padding padding padding padding padding padding padding\n"
"#\n"
"# SPDX-License-Identifier: MIT\n"
"# SPDX-License-Identifier: Apache-2.0\n"
)
test_file = tmp_path / "file.py"
test_file.write_text(unrelated_header + "print('hi')\n", encoding="utf-8")
Expand All @@ -828,16 +844,18 @@ def test_process_files_fix_without_force_leaves_low_similarity_header_untouched(


def test_process_files_fix_force_rewrites_low_similarity_header(tmp_path):
"""With `force=True`, the same low-similarity header IS rewritten --
`--force` is an explicit, opt-in override of the similarity guard."""
"""With `force=True`, the same low-similarity (but same-license) header IS
rewritten -- `--force` is an explicit, opt-in override of the similarity
guard, though never of the separate SPDX mismatch guard (see
`test_process_files_fix_force_does_not_touch_license_mismatch`)."""
cr_checker = load_cr_checker_module()
header_template = load_template("py")
config = write_config(tmp_path, "Author")
unrelated_header = (
"# Copyright (c) 2020 Some Other Corp. All rights reserved.\n"
"# Licensed under the MIT License; see the LICENSE file for details.\n"
"# Copyright presence only, then completely different padding text follows\n"
"# padding padding padding padding padding padding padding\n"
"#\n"
"# SPDX-License-Identifier: MIT\n"
"# SPDX-License-Identifier: Apache-2.0\n"
)
test_file = tmp_path / "file.py"
test_file.write_text(unrelated_header + "print('hi')\n", encoding="utf-8")
Expand All @@ -855,10 +873,44 @@ def test_process_files_fix_force_rewrites_low_similarity_header(tmp_path):
expected_header = header_template.format(year=datetime.now().year, author="Author")
fixed = test_file.read_text(encoding="utf-8")
assert results["fixed"] == 1
assert "Some Other Corp" not in fixed
assert "padding" not in fixed
assert fixed == expected_header + "\n" + "print('hi')\n"


def test_process_files_fix_force_does_not_touch_license_mismatch(tmp_path):
"""`force` only bypasses the *similarity* guard for WRONG_FORMAT /
MISPLACED_AND_WRONG_FORMAT; LICENSE_MISMATCH is not in `FIXABLE_STATUSES`
at all and must still be left for manual review even with `force=True`,
since silently overwriting a genuinely different license's SPDX
identifier is a legal/compliance-significant action, not cosmetic
drift."""
cr_checker = load_cr_checker_module()
header_template = load_template("py")
config = write_config(tmp_path, "Author")
unrelated_header = (
"# Copyright (c) 2020 Some Other Corp. All rights reserved.\n"
"# Licensed under the MIT License; see the LICENSE file for details.\n"
"#\n"
"# SPDX-License-Identifier: MIT\n"
)
test_file = tmp_path / "file.py"
test_file.write_text(unrelated_header + "print('hi')\n", encoding="utf-8")

results = cr_checker.process_files(
[test_file],
{"py": header_template},
True,
config=config,
use_mmap=False,
encoding="utf-8",
force=True,
)

assert results["fixed"] == 0
assert results["license_mismatch"] == 1
assert test_file.read_text(encoding="utf-8") == unrelated_header + "print('hi')\n"


def test_process_files_fix_force_does_not_touch_duplicate(tmp_path):
"""`force` only bypasses the *similarity* guard for WRONG_FORMAT /
MISPLACED_AND_WRONG_FORMAT; DUPLICATE is not in `FIXABLE_STATUSES` at
Expand Down Expand Up @@ -913,7 +965,7 @@ def test_process_files_check_mode_ignores_force(tmp_path):
)

assert results["fixed"] == 0
assert results["wrong_format"] == 1
assert results["license_mismatch"] == 1
assert test_file.read_text(encoding="utf-8") == unrelated_header + "print('hi')\n"


Expand Down
27 changes: 24 additions & 3 deletions cr_checker/tests/test_cr_checker_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,14 @@ def test_real_tool_source_file_is_compliant():

results = cr_checker.process_files([TOOL_MODULE_PATH], templates, fix=False)

assert results == {"missing": 0, "misplaced": 0, "wrong_format": 0, "duplicate": 0, "fixed": 0}
assert results == {
"missing": 0,
"misplaced": 0,
"wrong_format": 0,
"duplicate": 0,
"license_mismatch": 0,
"fixed": 0,
}


def test_real_build_file_is_compliant():
Expand All @@ -380,7 +387,14 @@ def test_real_build_file_is_compliant():

results = cr_checker.process_files([build_file], templates, fix=False)

assert results == {"missing": 0, "misplaced": 0, "wrong_format": 0, "duplicate": 0, "fixed": 0}
assert results == {
"missing": 0,
"misplaced": 0,
"wrong_format": 0,
"duplicate": 0,
"license_mismatch": 0,
"fixed": 0,
}


def test_real_exclusion_file_skips_real_templates_ini():
Expand All @@ -399,7 +413,14 @@ def test_real_exclusion_file_skips_real_templates_ini():
exclusion=exclusion,
)

assert results == {"missing": 0, "misplaced": 0, "wrong_format": 0, "duplicate": 0, "fixed": 0}
assert results == {
"missing": 0,
"misplaced": 0,
"wrong_format": 0,
"duplicate": 0,
"license_mismatch": 0,
"fixed": 0,
}


def test_real_config_author_is_used_when_fixing(tmp_path):
Expand Down
Loading
Loading