Skip to content
Merged
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
12 changes: 12 additions & 0 deletions skills/update-copyright/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ description: >
3. Relay stdout (notice source, file count, changed paths) to the user.
4. Never add a copyright header to a file that does not already have one.

## Third-party attribution

Files derived from an upstream project carry that project's credit ahead of
TeamDev's own, e.g. `Copyright 2023, The Flogger Authors; 2025, TeamDev. All rights reserved.`.
The IntelliJ profile names a single holder, but re-stamping **preserves any
holder listed ahead of the profile's own** — segments separated by `;` — and
refreshes only the trailing (profile-holder) year, yielding
`… The Flogger Authors; 2026, TeamDev. …`. This keeps the upstream attribution
that Apache-2.0 §4(c) requires, instead of collapsing the line to a TeamDev-only
notice. A header that carries only the profile's own holder is stamped exactly
as before.

## Files distributed by the `config` repository

The shared mechanism — what "project-owned" means, how to detect a
Expand Down
73 changes: 62 additions & 11 deletions skills/update-copyright/scripts/update_copyright.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,20 +363,27 @@ def strip_leading_blank_lines(text: str) -> str:
return re.sub(r"^(?:[ \t]*\r?\n)+", "", text)


def strip_existing_header(text: str, style: str) -> tuple[str, bool]:
def strip_existing_header(text: str, style: str) -> tuple[str, str | None]:
"""Split off a leading copyright header.

Returns ``(remaining_text, header)`` where ``header`` is the consumed
header text, or ``None`` when the text has no recognizable copyright
header. The caller inspects ``header`` to carry any third-party attribution
forward (see ``preserve_foreign_attribution``).
"""
if style == "block" and text.startswith("/*"):
close = text.find("*/")
if close != -1:
candidate = text[: close + 2]
if is_copyright_header(candidate):
return strip_leading_blank_lines(text[close + 2 :]), True
return strip_leading_blank_lines(text[close + 2 :]), candidate

if style == "xml" and text.startswith("<!--"):
close = text.find("-->")
if close != -1:
candidate = text[: close + 3]
if is_copyright_header(candidate):
return strip_leading_blank_lines(text[close + 3 :]), True
return strip_leading_blank_lines(text[close + 3 :]), candidate

if style == "hash":
# A freshly rendered header (see build_header) never has a truly
Expand Down Expand Up @@ -404,9 +411,9 @@ def strip_existing_header(text: str, style: str) -> tuple[str, bool]:
break
candidate = text[:end]
if candidate and is_copyright_header(candidate):
return strip_leading_blank_lines(text[end:]), True
return strip_leading_blank_lines(text[end:]), candidate

return text, False
return text, None


def is_copyright_header(text: str) -> bool:
Expand All @@ -416,20 +423,64 @@ def is_copyright_header(text: str) -> bool:
)


def updated_text(text: str, notice: str, style: str) -> str:
def preserve_foreign_attribution(header: str, notice: str, year: str) -> str:
"""Carry third-party copyright credit from the old *header* into *notice*.

The IntelliJ profile stamps a single-holder line — e.g.
``Copyright <year>, TeamDev. All rights reserved.`` — so a header that reads
``Copyright 2023, The Flogger Authors; 2024, TeamDev. ...`` would otherwise
lose the upstream credit on every re-stamp. Apache-2.0 §4(c) requires
retaining that credit. When the existing header lists holders ahead of the
profile's own (separated by ``;``), keep them verbatim and refresh only the
trailing (profile-holder) year; return the notice to stamp.

Nothing is hard-coded to a specific project: the profile's first line
defines ``head`` (text before its year) and ``tail`` (text after it), and
any segments the old header carries between them, before the last, are
preserved as foreign attribution.
"""
lines = notice.split("\n")
head, sep, tail = lines[0].partition(year)
if not sep or not tail:
# The profile's first line carries no year token to anchor on, so there
# is no holder segment to preserve; stamp the notice unchanged.
return notice
match = re.search(
re.escape(head) + r"(?P<middle>.*?)" + re.escape(tail), header
)
if match is None:
# The old header's copyright line does not match the profile's shape
# (different holder or wording); leave the notice untouched.
return notice
segments = match.group("middle").split(";")
if len(segments) < 2:
# Only the profile holder's own credit is present — the ordinary case;
# the plain notice (with the current year) is already correct.
return notice
last = segments[-1]
leading = last[: len(last) - len(last.lstrip())]
segments[-1] = leading + year
merged_first = head + ";".join(segments) + tail
return "\n".join([merged_first, *lines[1:]])


def updated_text(text: str, notice: str, style: str, year: str) -> str:
original = text
bom = "\ufeff" if text.startswith("\ufeff") else ""
if bom:
text = text[1:]
newline = newline_for(text)
prefix, body = split_leading_directive(text, style, newline)
body, had_header = strip_existing_header(body, style)
if not had_header:
body, header = strip_existing_header(body, style)
if header is None:
return original
notice = preserve_foreign_attribution(header, notice, year)
return bom + prefix + build_header(notice, style, newline) + body


def update_file(root: Path, path: Path, notice: str, dry_run: bool) -> bool:
def update_file(
root: Path, path: Path, notice: str, year: str, dry_run: bool
) -> bool:
absolute = root / path
style = style_for(path)
if style is None:
Expand All @@ -444,7 +495,7 @@ def update_file(root: Path, path: Path, notice: str, dry_run: bool) -> bool:
print(f"Skipping non-UTF-8 file: {path}", file=sys.stderr)
return False

next_text = updated_text(text, notice, style)
next_text = updated_text(text, notice, style, year)
if next_text == text:
return False

Expand All @@ -468,7 +519,7 @@ def main() -> int:
changed = [
path
for path in paths
if update_file(root, path, notice, dry_run=dry_run)
if update_file(root, path, notice, args.year, dry_run=dry_run)
]

rel_profile = profile_path.relative_to(root)
Expand Down
123 changes: 123 additions & 0 deletions skills/update-copyright/tests/test_update_copyright.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,104 @@ def test_consumer_repo_default_run_skips_distributed_files(self) -> None:
FRESH_BLOCK + "class Foo {}\n",
)

def test_existing_header_retains_third_party_attribution(self) -> None:
# Regression test: files derived from an Apache-2.0 upstream (e.g. Flogger)
# carry a dual-attribution first line. Apache-2.0 §4(c) requires
# retaining the upstream credit, so re-stamping must keep it and
# refresh only the profile holder's (TeamDev) year — never collapse
# the line to a TeamDev-only notice.
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
self.write_derived_profile(root)
source = root / "AbstractLogger.kt"
source.write_text(
"/*\n"
" * Copyright 2023, The Flogger Authors; 2025, TeamDev."
" All rights reserved.\n"
" *\n"
" * Licensed under the Apache License.\n"
" */\n"
"\n"
"package io.spine.logging\n",
encoding="utf-8",
)

result = self.run_script(root, "--year", "2026", "AbstractLogger.kt")

self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Updated 1 file(s).", result.stdout)
self.assertEqual(
source.read_text(encoding="utf-8"),
"/*\n"
" * Copyright 2023, The Flogger Authors; 2026, TeamDev."
" All rights reserved.\n"
" *\n"
" * Licensed under the Apache License.\n"
" */\n"
"\n"
"package io.spine.logging\n",
)

def test_third_party_attribution_update_is_idempotent(self) -> None:
# A header already crediting the upstream and carrying the current
# year must be left untouched on a re-run.
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
self.write_derived_profile(root)
source = root / "AbstractLogger.kt"
current = (
"/*\n"
" * Copyright 2023, The Flogger Authors; 2026, TeamDev."
" All rights reserved.\n"
" *\n"
" * Licensed under the Apache License.\n"
" */\n"
"\n"
"package io.spine.logging\n"
)
source.write_text(current, encoding="utf-8")

result = self.run_script(root, "--year", "2026", "AbstractLogger.kt")

self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Updated 0 file(s).", result.stdout)
self.assertEqual(source.read_text(encoding="utf-8"), current)

def test_multiple_third_party_holders_are_all_retained(self) -> None:
# Generality: every holder listed ahead of the profile's own is kept
# verbatim; only the trailing (profile-holder) year is refreshed.
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
self.write_derived_profile(root)
source = root / "ValueQueue.kt"
source.write_text(
"/*\n"
" * Copyright 2015, Google Inc.; 2023, The Flogger Authors;"
" 2024, TeamDev. All rights reserved.\n"
" *\n"
" * Licensed under the Apache License.\n"
" */\n"
"\n"
"package io.spine.logging\n",
encoding="utf-8",
)

result = self.run_script(root, "--year", "2026", "ValueQueue.kt")

self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Updated 1 file(s).", result.stdout)
self.assertEqual(
source.read_text(encoding="utf-8"),
"/*\n"
" * Copyright 2015, Google Inc.; 2023, The Flogger Authors;"
" 2026, TeamDev. All rights reserved.\n"
" *\n"
" * Licensed under the Apache License.\n"
" */\n"
"\n"
"package io.spine.logging\n",
)

@staticmethod
def run_script(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
Expand Down Expand Up @@ -384,6 +482,31 @@ def write_profile(root: Path) -> None:
encoding="utf-8",
)

@staticmethod
def write_derived_profile(root: Path) -> None:
# A profile shaped like the real TeamDev one: the holder and
# "All rights reserved." share the first line, so an upstream-derived
# header's third-party prefix sits inside that same line.
copyright_dir = root / ".idea" / "copyright"
copyright_dir.mkdir(parents=True)
(copyright_dir / "profiles_settings.xml").write_text(
'<component name="CopyrightManager">'
'<settings default="Default" />'
"</component>\n",
encoding="utf-8",
)
(copyright_dir / "Default.xml").write_text(
'<component name="CopyrightManager">'
"<copyright>"
'<option name="notice" value="'
"Copyright ${today.year}, TeamDev. All rights reserved."
"&#10;&#10;Licensed under the Apache License."
'" />'
"</copyright>"
"</component>\n",
encoding="utf-8",
)


if __name__ == "__main__":
unittest.main()