from __future__ import annotations
import importlib.util
import re
import sys
from pathlib import Path
import pytest
_MODULE_PATH = Path(__file__).resolve().parents[2] / "scripts" / "check_representation_change.py"
_SPEC = importlib.util.spec_from_file_location("check_representation_change", _MODULE_PATH)
assert _SPEC is not None and _SPEC.loader is not None
check_representation_change = importlib.util.module_from_spec(_SPEC)
sys.modules["check_representation_change"] = check_representation_change
_SPEC.loader.exec_module(check_representation_change)
check = check_representation_change.check
find_declaration = check_representation_change.find_declaration
find_declarations = check_representation_change.find_declarations
find_near_misses = check_representation_change.find_near_misses
watched_files = check_representation_change.watched_files
WATCHED_PREFIXES = check_representation_change.WATCHED_PREFIXES
fenced_line_numbers = check_representation_change.fenced_line_numbers
find_trailers_as_git_cliff_would = check_representation_change.find_trailers_as_git_cliff_would
_REPO_ROOT = Path(__file__).resolve().parents[2]
def _read_config() -> str:
return (_REPO_ROOT / "release-plz.toml").read_text(encoding="utf-8")
def _decline_preprocessor_pattern() -> str:
rules = re.findall(
r'\{ pattern = "([^"]*Representation-Change[^"]*)", '
r'replace = "Representation-Change-Declined',
_read_config(),
)
assert rules, "no declining-trailer commit_preprocessor found in release-plz.toml"
return rules[0].replace("\\\\", "\\")
def _inclusion_footer_rule() -> str:
rules = re.findall(r'\{ footer = "([^"]*Representation-Change[^"]*)"', _read_config())
assert len(rules) == 1, (
f"expected exactly one Representation-Change footer parser, found {rules}"
)
return rules[0]
def test_watched_change_without_a_trailer_fails() -> None:
ok, message = check(["src/normalize/merge.rs"], "Fixes a thing.\n\nCloses #1.")
assert not ok
assert "src/normalize/merge.rs" in message
assert "Representation-Change: none" in message, "the message must name the way to decline"
def test_watched_change_declaring_none_passes() -> None:
ok, _ = check(
["src/normalize/merge.rs"],
"Comments only; zero non-comment lines change.\n\nRepresentation-Change: none",
)
assert ok
def test_watched_change_declaring_a_move_passes() -> None:
ok, message = check(
["src/spdi/mod.rs"],
"Representation-Change: 577 rows move, 360 merge / 205 split / 12 respell",
)
assert ok
assert "577 rows move" in message
def test_unwatched_change_needs_no_declaration() -> None:
ok, _ = check(["README.md", "tests/it/foo.rs", "src/cli/mod.rs"], "no trailer here")
assert ok
def test_a_watched_file_among_unwatched_ones_still_requires_a_declaration() -> None:
ok, _ = check(["README.md", "docs/x.md", "src/project/projector.rs"], "no trailer")
assert not ok
_A_FILE_IN_EVERY_WATCHED_DIRECTORY = (
"src/normalize/merge.rs",
"src/hgvs/variant.rs",
"src/spdi/mod.rs",
"src/project/projector.rs",
"src/reference/multi_fasta.rs",
"src/error_handling/mod.rs",
)
_A_FILE_IN_EVERY_DELIBERATELY_UNWATCHED_DIRECTORY = (
"src/conformance/spec_corpus.rs",
"src/data/cdot.rs",
"src/convert/mapper.rs",
)
@pytest.mark.parametrize("path", _A_FILE_IN_EVERY_WATCHED_DIRECTORY)
def test_every_watched_prefix_triggers(path: str) -> None:
assert watched_files([path]) == [path]
ok, _ = check([path], "no trailer")
assert not ok, f"{path} must require a declaration"
@pytest.mark.parametrize("path", _A_FILE_IN_EVERY_DELIBERATELY_UNWATCHED_DIRECTORY)
def test_a_deliberately_unwatched_directory_needs_no_declaration(path: str) -> None:
assert watched_files([path]) == []
ok, _ = check([path], "no trailer")
assert ok, f"{path} must not require a declaration"
def test_the_widened_prefixes_are_the_ones_the_backtest_measured() -> None:
for path in ("src/reference/multi_fasta.rs", "src/error_handling/mod.rs"):
ok, message = check([path], "Fixes a thing.\n\nCloses #1.")
assert not ok, f"{path} must require a declaration"
assert path in message, "the failure must name the file that demanded the trailer"
ok, _ = check([path], "Representation-Change: none")
assert ok, "declining must still pass on a newly watched directory"
ok, _ = check(["src/data/cdot.rs"], "no trailer")
assert ok, (
"src/data/ was proposed in #1853 and declined on measurement; adding it needs a "
"real, non-declining disclosure that touches src/data/ and NOT src/reference/, "
"which did not exist in either measured population"
)
unrepresented = [
prefix
for prefix in WATCHED_PREFIXES
if not any(path.startswith(prefix) for path in _A_FILE_IN_EVERY_WATCHED_DIRECTORY)
]
assert not unrepresented, (
f"{sorted(unrepresented)} is watched but has no representative path in "
"_A_FILE_IN_EVERY_WATCHED_DIRECTORY; every entry needs one so the gate is pinned "
"by behaviour rather than by restating the constant"
)
def test_watched_prefixes_match_the_release_config() -> None:
config = (Path(__file__).resolve().parents[2] / "release-plz.toml").read_text(encoding="utf-8")
documented = {f"{directory}/" for directory in re.findall(r"`(src/[a-z_]+)/`", config)}
assert documented == set(WATCHED_PREFIXES), (
f"release-plz.toml names {sorted(documented)} but the check watches "
f"{sorted(WATCHED_PREFIXES)}; the two must describe the same scope. A directory in "
"the config and not the tuple is a gate that does not exist; one in the tuple and "
"not the config is a scope the release reviewer is never told to look for."
)
@pytest.mark.parametrize("document", ["CONTRIBUTING.md", "CLAUDE.md", ".github/workflows/ci.yml"])
def test_the_prose_restatements_name_every_watched_directory(document: str) -> None:
text = (Path(__file__).resolve().parents[2] / document).read_text(encoding="utf-8")
undocumented = [prefix for prefix in WATCHED_PREFIXES if f"`{prefix.rstrip('/')}/`" not in text]
assert not undocumented, (
f"{document} does not name {sorted(undocumented)}, which "
f"scripts/check_representation_change.py watches; the check watches "
f"{sorted(WATCHED_PREFIXES)}. A watched directory missing from the prose is a "
"required check nobody was told about."
)
def test_trailer_is_found_case_insensitively() -> None:
assert find_declaration("representation-change: none") == "none"
assert find_declaration("REPRESENTATION-CHANGE: none") == "none"
def test_trailer_is_found_at_the_end_of_a_body() -> None:
body = "Some description.\n\nCloses #1522.\n\nRepresentation-Change: none\n"
assert find_declaration(body) == "none"
def test_absent_trailer_reads_as_none_not_as_a_declaration() -> None:
assert find_declaration("Representation change: none") is None, (
"the hyphenated trailer is what git-cliff matches; prose must not satisfy it"
)
def test_an_empty_trailer_value_is_not_a_declaration() -> None:
assert find_declaration("Representation-Change: \n") is None
@pytest.mark.parametrize("indent", [" ", " ", "\t", " "])
def test_an_indented_trailer_is_not_a_trailer(indent: str) -> None:
assert find_declaration(f"{indent}Representation-Change: none") is None
ok, message = check(["src/normalize/merge.rs"], f"{indent}Representation-Change: none")
assert not ok, "an indented trailer must not satisfy the check"
assert "Representation-Change: none" in message, "the message must show the column-0 form"
@pytest.mark.parametrize("value", ["none", "None", "NONE", "no", "n/a", "na"])
def test_decline_spellings_all_pass(value: str) -> None:
ok, _ = check(["src/normalize/merge.rs"], f"Representation-Change: {value}")
assert ok
_TWO_TRAILERS = (
"Representation-Change: 577 rows move, 360 merge.\n"
"\n"
"For contrast, a declining trailer looks like:\n"
"Representation-Change: none\n"
)
def test_two_trailers_are_refused() -> None:
ok, message = check(["src/normalize/merge.rs"], _TWO_TRAILERS)
assert not ok, "two trailers must not pass; the changelog and this check disagree on them"
assert "2 `Representation-Change:` trailers found" in message
assert "577 rows move, 360 merge." in message, "the message must show what it found"
assert "none" in message
def test_two_trailers_are_refused_even_with_no_watched_file() -> None:
ok, message = check(["README.md"], _TWO_TRAILERS)
assert not ok
assert "trailers found" in message
def test_two_declining_trailers_are_still_refused() -> None:
ok, _ = check(["src/hgvs/variant.rs"], "Representation-Change: none\nRepresentation-Change: no")
assert not ok
def test_an_indented_second_trailer_is_a_continuation_not_a_second_trailer() -> None:
body = (
"Representation-Change: 577 rows move, 360 merge.\n"
"\n"
"For contrast, a declining trailer looks like:\n"
" Representation-Change: none\n"
)
assert find_declarations(body) == ["577 rows move, 360 merge."]
ok, _ = check(["src/normalize/merge.rs"], body)
assert ok
def test_one_trailer_still_passes() -> None:
for body in (
"Representation-Change: none",
"Representation-Change: none. Tests only.",
"Some prose.\n\nCloses #1.\n\nRepresentation-Change: 577 rows move, 360 merge.\n",
):
ok, _ = check(["src/normalize/merge.rs"], body)
assert ok, f"{body!r} carries exactly one trailer and must pass"
def test_find_declarations_returns_every_value_in_order() -> None:
assert find_declarations("no trailer here") == []
assert find_declarations("Representation-Change: none") == ["none"]
assert find_declarations(_TWO_TRAILERS) == ["577 rows move, 360 merge.", "none"]
@pytest.mark.parametrize(
"value",
[
"none. Tests only; no file under a watched directory is touched.",
"none: comments only",
"none; the diff is one fixture JSON and one new test module",
"none — 0 of 950 real cis-allele rows move their normalized string",
"None. Ruling records and their status pins only.",
"no. Nothing here can reach a normalizer.",
"n/a: docs",
"na. generated artifact only",
"none.\n A reason spanning two lines.",
],
)
def test_a_decline_may_give_its_reason(value: str) -> None:
assert check_representation_change.declines(value), f"{value!r} declines a move"
@pytest.mark.parametrize(
"value",
[
"577 rows move, 360 merge / 205 split / 12 respell",
"3 rows of 500,004 move (0.0006%) — 2 respell, 1 merge",
"0 rows move over 5,761,302 real expressions",
"no rows move",
"nothing moves under src/normalize/",
"not measured yet",
"none, except two rows that merge",
],
)
def test_a_description_of_a_move_is_not_a_decline(value: str) -> None:
assert not check_representation_change.declines(value), f"{value!r} describes a move"
def test_an_empty_trailer_does_not_scavenge_the_next_line() -> None:
assert find_declaration("Representation-Change:\nnone\n") is None
def test_empty_trailer_on_a_watched_change_fails() -> None:
ok, _ = check(["src/normalize/merge.rs"], "Representation-Change: \n")
assert not ok
def test_both_streams_on_stdin_is_rejected() -> None:
with pytest.raises(SystemExit) as excinfo:
check_representation_change.main(["--changed-files", "-", "--declaration-file", "-"])
assert excinfo.value.code == 2
def test_decline_vocabulary_matches_the_changelog_config() -> None:
regex = _decline_preprocessor_pattern()
match = re.search(r"\(\?:([^)]+)\)", regex)
assert match is not None, (
"no decline vocabulary group in the preprocessor pattern; the vocabulary cannot be checked"
)
configured = frozenset(match.group(1).split("|"))
assert configured == check_representation_change.NONE_VALUES, (
f"release-plz.toml excludes {sorted(configured)} but the checker treats "
f"{sorted(check_representation_change.NONE_VALUES)} as declines; a word in one and "
"not the other either leaks a non-change into the changelog or hides a real one"
)
@pytest.mark.parametrize(
"value",
[
"no. 3 rows move",
"none. 577 rows move, 360 merge / 205 split",
"none. 3 rows of 500,004 move",
"na — but 2 rows respell",
"none. 12,530 rows move in the synthetic corpus",
"none. 3 rows merged",
"no. 12 rows respelled",
"none. 205 rows moved",
],
)
def test_a_decline_that_describes_a_move_fails(value: str) -> None:
ok, message = check(["src/normalize/merge.rs"], f"Representation-Change: {value}")
assert not ok, f"{value!r} declines and then describes a move"
assert "declines and then describes a move" in message
@pytest.mark.parametrize(
"value",
[
"0 rows move over 5,761,302 real expressions",
"none. 0 of 950 real cis-allele rows move their normalized string; 1 of 950 "
"changes strict verdict from accept to reject, which is the defect being fixed.",
"none. This change does grow the corpus, 78,028 -> 78,298 rows, which changes the "
"denominator of every future compare run against it.",
"none. The ruling ratifies shipped behaviour — v0.12.0 already emits the unsplit "
"form on 208 of 208 adjudicated rows.",
"none. 0,000 rows move",
"none. 000 rows merged",
],
)
def test_a_numerate_decline_is_not_a_contradiction(value: str) -> None:
assert check_representation_change.contradicted_decline(value) is None, (
f"{value!r} declines legitimately; failing it would punish a good disclosure"
)
def test_a_real_disclosure_is_not_a_contradiction() -> None:
assert (
check_representation_change.contradicted_decline(
"3 rows of 500,004 move (0.0006%) — 2 respell, 1 merge"
)
is None
)
def test_full_declaration_value_reads_continuation_lines() -> None:
body = "Representation-Change: none.\n 150 rows of 85,642 move, all 3'-direction.\n"
assert (
check_representation_change.full_declaration_value(body)
== "none.\n 150 rows of 85,642 move, all 3'-direction."
)
def test_full_declaration_value_stops_at_the_next_trailer_token() -> None:
body = "Representation-Change: none. Tests only.\nCloses: #1854\n"
assert check_representation_change.full_declaration_value(body) == "none. Tests only."
def test_full_declaration_value_stops_at_an_appended_coderabbit_summary() -> None:
body = (
"Representation-Change: none. Tests only.\n"
" and here is why it is safe.\n"
"<!-- This is an auto-generated comment: release notes by coderabbit.ai -->\n"
"## Summary by CodeRabbit\n"
"- Tests: added coverage.\n"
)
assert (
check_representation_change.full_declaration_value(body)
== "none. Tests only.\n and here is why it is safe."
)
def test_full_declaration_value_is_none_without_a_trailer() -> None:
assert check_representation_change.full_declaration_value("Just a body.\n") is None
def test_a_decline_that_moves_on_a_continuation_line_fails() -> None:
body = "Representation-Change: none.\n 150 rows of 85,642 move, all 3'-direction.\n"
ok, message = check(["src/normalize/merge.rs"], body)
assert not ok, "a decline that describes a move on a continuation line must be refused"
assert "declines and then describes a move" in message
assert "150 rows of 85,642 move" in message, "the message must quote the hidden move"
def test_a_multiline_decline_with_only_a_reason_still_passes() -> None:
body = (
"Representation-Change: none.\n"
" Comments and one new test module only; no watched file changes behaviour.\n"
)
ok, _ = check(["src/normalize/merge.rs"], body)
assert ok, "a multi-line decline whose continuation is a reason must pass"
def test_a_real_multiline_disclosure_still_passes() -> None:
body = (
"Representation-Change: 577 rows move, 360 merge / 205 split / 12 respell.\n"
" Previously-accepted inputs, so a real migration for the consumer.\n"
)
ok, message = check(["src/spdi/mod.rs"], body)
assert ok
assert "577 rows move" in message
@pytest.mark.parametrize("trailer", ["none", "no", "n/a", "na"])
def test_a_bare_decline_that_moves_on_a_continuation_line_fails(trailer: str) -> None:
body = f"Representation-Change: {trailer}\n150 rows of 85,642 move, all 3'-direction.\n"
ok, message = check(["src/normalize/merge.rs"], body)
assert not ok, f"bare {trailer!r} + a continuation-line move must be refused"
assert "declines and then describes a move" in message
assert "150 rows of 85,642 move" in message, "the message must quote the hidden move"
@pytest.mark.parametrize("trailer", ["none", "no", "n/a", "na"])
def test_a_bare_decline_with_a_reason_continuation_still_passes(trailer: str) -> None:
body = (
f"Representation-Change: {trailer}\n"
"Comments and one new test module only; no watched file changes behaviour.\n"
)
ok, _ = check(["src/normalize/merge.rs"], body)
assert ok, f"a bare {trailer!r} whose continuation is a reason must pass"
@pytest.mark.parametrize(
"value",
[
"none. 0 of 950 rows move",
"none. 0 of 950 rows move.",
"none. 0 of 78,298 rows move",
"none. 0 of 5,761,302 rows respell",
"none. 0 rows of 78,298 move",
"none. 0 of 950 real cis-allele rows move",
"none. 0 of 78,298 corpus rows move",
],
)
def test_a_quantified_zero_in_either_order_passes(value: str) -> None:
assert check_representation_change.contradicted_decline(value) is None, (
f"{value!r} is the documented quantified-zero form and must pass"
)
@pytest.mark.parametrize(
"value",
[
"no. 3 of 500,004 rows move",
"none. 1,826 of 78,298 rows move",
"none. 1826 of 78298 corpus rows move",
"none. 3 of 500,004 real cis-allele rows move",
"no. 3 rows move",
"none. 2 rows of 500,004 respell",
],
)
def test_a_nonzero_count_in_either_order_is_still_a_contradiction(value: str) -> None:
assert check_representation_change.contradicted_decline(value) is not None, (
f"{value!r} declines while disclosing a move and must be refused"
)
@pytest.mark.parametrize(
"value",
[
"none. 1 issue filed, 0 rows move",
"none. 12 clauses cited; 0 rows move",
"none. 4 tests added, no rows move",
"none. Tests only; no watched file is touched.",
],
)
def test_allowing_words_before_rows_did_not_blunt_the_decline_path(value: str) -> None:
assert check_representation_change.contradicted_decline(value) is None, (
f"{value!r} is an honest decline and must not be read as a disclosure"
)
def test_the_documented_zero_passes_the_whole_check_not_only_the_predicate() -> None:
ok, _message = check(
["src/normalize/merge.rs"],
"Representation-Change: none. 0 of 950 rows move.",
)
assert ok, "the documented quantified-zero trailer must pass the full check"
def test_the_two_decline_rules_agree_value_by_value() -> None:
changelog_rule = re.compile(_decline_preprocessor_pattern())
values = [
"none",
"NONE",
"none.",
"none. Tests only; nothing under a watched directory.",
"none: comments only",
"none; one fixture JSON",
"none — 0 of 950 rows move",
"no. Nothing reaches a normalizer.",
"n/a: docs",
"na. generated artifact only",
"577 rows move, 360 merge / 205 split",
"0 rows move over 5,761,302 real expressions",
"no rows move",
"none, except two rows that merge",
"nothing moves",
"none.\n Comments and one new test module only; no watched file changes behaviour.",
"577 rows move, 360 merge.\n Previously-accepted inputs, so a real migration.",
"none\n150 rows of 85,642 move, all 3'-direction.",
"no\nTests only; nothing under a watched directory moves.",
"n/a\nDocs only.",
"na\nGenerated artifact only.",
]
for value in values:
by_changelog = changelog_rule.search(f"Representation-Change: {value}") is not None
by_checker = check_representation_change.declines(value)
assert by_changelog == by_checker, (
f"{value!r}: release-plz.toml calls it "
f"{'a decline' if by_changelog else 'a real change'} and the checker calls it "
f"{'a decline' if by_checker else 'a real change'}; the changelog and CI must "
"agree, or a PR passes the check and is then filed as its opposite"
)
_APPENDED_BLOCK = (
"\n\n<!-- This is an auto-generated comment: release notes by coderabbit.ai -->\n"
"## Summary by CodeRabbit\n"
"- Tests: added coverage.\n"
)
@pytest.mark.parametrize(
("value", "is_a_decline"),
[
("none", True),
("NONE", True),
("none. Comment prose only.", True),
("none; tests only", True),
("n/a: docs", True),
("577 rows move, 360 merge / 205 split.", False),
("none, except two rows that merge.", False),
],
)
def test_a_trailer_keeps_its_verdict_when_text_is_appended_after_it(
value: str, is_a_decline: bool
) -> None:
changelog_rule = re.compile(_decline_preprocessor_pattern())
footer = f"Representation-Change: {value}{_APPENDED_BLOCK}"
assert (changelog_rule.search(footer) is not None) == is_a_decline, (
f"{value!r} followed by an appended block is filed as "
f"{'a real change' if is_a_decline else 'a decline'} by release-plz.toml; text "
"appended after a trailer must not change what the trailer says"
)
declaration = find_declaration(footer)
assert declaration is not None, "the checker no longer finds a trailer it used to find"
assert check_representation_change.declines(declaration) == is_a_decline, (
f"{value!r}: the checker and release-plz.toml disagree once a block is appended"
)
def test_the_ordering_prefix_is_stripped_from_rendered_headings() -> None:
config = (Path(__file__).resolve().parents[2] / "release-plz.toml").read_text(encoding="utf-8")
match = re.search(
r'postprocessors = \[\{ pattern = "((?:[^"\\]|\\.)*)", replace = "([^"]*)"', config
)
assert match is not None, "no postprocessor found; the ordering prefix would be rendered"
pattern = re.compile(match.group(1).replace("\\\\", "\\"), re.MULTILINE)
replacement = match.group(2).replace("${1}", r"\1")
rendered = "### <!-- 0 -->Representation changes\n### <!-- 7 -->Other\n"
assert pattern.sub(replacement, rendered) == "### Representation changes\n### Other\n"
subject = "- *(docs)* explain the <!-- 0 --> marker\n"
assert pattern.sub(replacement, subject) == subject, (
"the rule must be anchored to a heading; a marker quoted in a commit subject is text"
)
def test_both_representation_change_rules_are_case_insensitive() -> None:
for rule in (_inclusion_footer_rule(), _decline_preprocessor_pattern()):
flags = re.match(r"\(\?([a-z]+)\)", rule)
assert flags is not None and "i" in flags.group(1), (
f"rule {rule!r} is case-sensitive; the checker's own trailer regex is not, so the "
"two disagree about whether `REPRESENTATION-CHANGE:` is a declaration"
)
def test_contributing_documents_the_same_decline_vocabulary() -> None:
doc = (Path(__file__).resolve().parents[2] / "CONTRIBUTING.md").read_text(encoding="utf-8")
documented = {
word
for word in re.findall(r"`([a-z/]+)`(?=[,\s]|$)", doc)
if word in check_representation_change.NONE_VALUES
}
assert documented == check_representation_change.NONE_VALUES, (
f"CONTRIBUTING.md documents {sorted(documented)} as declines but the checker accepts "
f"{sorted(check_representation_change.NONE_VALUES)}"
)
_NEAR_MISSES = [
("`Representation-Change: none`", "code span"),
("`Representation-Change: 3 rows of 500,004 move`", "code span"),
("**Representation-Change:** none", "emphasis"),
("*Representation-Change: none*", "emphasis"),
("_Representation-Change: none_", "emphasis"),
("> Representation-Change: none", "block quote"),
("- Representation-Change: none", "list marker"),
("* Representation-Change: none", "list marker"),
("+ Representation-Change: none", "list marker"),
("## Representation-Change: none", "heading"),
(" Representation-Change: none", "indent"),
("\tRepresentation-Change: none", "indent"),
("Representation Change: none", "spelling"),
("Representation_Change: none", "spelling"),
("Representation-Change : none", "separated from its colon"),
("Representation-Change\t: none", "separated from its colon"),
("Representation-Change*: none", "separated from its colon"),
]
@pytest.mark.parametrize(("line", "reason"), _NEAR_MISSES)
def test_a_near_miss_is_refused_and_the_message_names_it(line: str, reason: str) -> None:
ok, message = check(["src/normalize/merge.rs"], f"Some prose.\n\n{line}\n")
assert not ok, f"{line!r} looks like a declaration and must not pass as silence"
assert reason in message, f"the message must name the problem ({reason!r}); got:\n{message}"
assert line.strip() in message, "the message must quote the offending line verbatim"
@pytest.mark.parametrize(("line", "reason"), _NEAR_MISSES)
def test_a_near_miss_is_refused_even_with_no_watched_file(line: str, reason: str) -> None:
ok, message = check(["README.md", "docs/x.md"], f"Some prose.\n\n{line}\n")
assert not ok, f"{line!r} must be refused even where no declaration is required"
assert reason in message
def test_find_near_misses_reports_the_line_and_its_reason() -> None:
body = "Prose.\n\n`Representation-Change: none`\n"
misses = find_near_misses(body)
assert len(misses) == 1
(line_number, text, reason) = misses[0]
assert line_number == 3
assert text == "`Representation-Change: none`"
assert "code span" in reason
def test_every_near_miss_shape_names_a_reason() -> None:
leads = ["", " ", "\t", "> ", "- ", "* ", "+ ", "# ", "`", "**", "_"]
separators = ["-", "_", " ", ""]
gaps = ["", " ", "\t", "*", "`", "_"]
tails = ["none", "3 rows of 500,004 move", "none`", "none**"]
checked = 0
for lead in leads:
for separator in separators:
for gap in gaps:
for tail in tails:
line = f"{lead}Representation{separator}Change{gap}: {tail}"
if check_representation_change.TRAILER_RE.match(line):
continue if not check_representation_change.NEAR_MISS_RE.match(line):
continue misses = find_near_misses(f"Prose.\n\n{line}\n")
assert misses, f"{line!r} matches the pattern and was reported as silence"
assert misses[0][2].strip(), f"{line!r} was reported with an empty reason"
checked += 1
assert checked > 100, f"only {checked} shapes reached the assertion; the battery is hollow"
def test_a_valid_trailer_silences_the_near_miss_scan() -> None:
body = (
"Representation-Change: 577 rows move, 360 merge.\n"
"\n"
"`Representation-Change: none` is what the declining form looks like.\n"
)
assert find_near_misses(body) == [], "a column-0 trailer must silence the scan"
ok, _ = check(["src/normalize/merge.rs"], body)
assert ok
@pytest.mark.parametrize(
"body",
[
"Any PR that moves a normalized output should carry a `Representation-Change:`\ntrailer.",
"- [ ] Nothing in it merely *declined*. A decline reads `Representation-Change:\n none`.",
"Both closures are tests only, so the `Representation-Change:` disclosure above\nis unaffected.",
"The check said to add a `Representation-Change:` trailer.",
],
)
def test_a_mid_sentence_mention_is_not_a_near_miss(body: str) -> None:
assert find_near_misses(body) == [], f"{body!r} mentions the field, it does not declare"
ok, _ = check(["README.md"], body)
assert ok
def test_a_line_anchored_mention_with_no_value_is_not_a_near_miss() -> None:
assert find_near_misses("`Representation-Change:`\n") == []
assert find_near_misses("> Representation-Change:\n") == []
def test_an_empty_trailer_is_still_reported_as_absent_not_as_a_near_miss() -> None:
assert find_near_misses("Representation-Change: \n") == []
ok, message = check(["src/normalize/merge.rs"], "Representation-Change: \n")
assert not ok
assert "can move normalized output" in message, "the absent-trailer message must survive"
def test_the_1838_body_is_refused_by_the_check_it_slipped_past() -> None:
body = (
"Two rationale-only corrections to decided records, in one PR because they "
"touch the same file.\n"
"\n"
"`Representation-Change: none`\n"
"\n"
"Measured, not assumed: the ledger is read only at generation time.\n"
)
assert find_declarations(body) == [], "the strict parser sees nothing — that is the defect"
ok, message = check(["tests/fixtures/grammar/hgvs_spec_normalization_overrides.json"], body)
assert not ok, "a trailer no consumer can read must not pass as a considered declaration"
assert "code span" in message
assert "`Representation-Change: none`" in message
def test_a_decline_is_neutralized_before_the_parsers_and_inclusion_leads_them() -> None:
config = _read_config()
decline = _decline_preprocessor_pattern()
assert any(word in decline for word in check_representation_change.NONE_VALUES), (
f"the decline preprocessor {decline!r} names no decline value; without it every "
"decline is grouped as a real change"
)
inclusion_at = config.index('{ footer = "')
fix_parser_at = config.index('{ message = "^fix"')
assert inclusion_at < fix_parser_at, (
"the Representation-Change inclusion rule must come before the `^fix` type parser, or "
"a declaring `fix:` is filed under Fixed instead of Representation changes"
)
def test_a_fenced_trailer_is_not_a_declaration() -> None:
body = (
"This PR refactors a helper.\n\n"
"For reference, CONTRIBUTING.md says the trailer looks like:\n\n"
"```\n"
"Representation-Change: 577 rows move, 360 merge / 205 split / 12 respell.\n"
" Previously-accepted inputs, so a real migration for the consumer.\n"
"```\n\n"
"I have not worked out the disclosure for this change yet.\n"
)
assert find_declaration(body) is None
passed, message = check(["src/normalize/mod.rs"], body)
assert not passed
assert "fenced code block" in message
def test_a_fenced_trailer_is_reported_as_a_near_miss_not_as_silence() -> None:
body = "x\n\n```\nRepresentation-Change: none\n```\n"
misses = find_near_misses(body)
assert len(misses) == 1
line_number, line, reason = misses[0]
assert line_number == 4
assert line.startswith("Representation-Change:")
assert "fenced" in reason and "column 0" in reason
@pytest.mark.parametrize(
"opener,closer",
[("```", "```"), ("~~~", "~~~"), ("````", "````"), ("```rust", "```")],
)
def test_every_fence_spelling_hides_a_trailer(opener: str, closer: str) -> None:
body = f"x\n\n{opener}\nRepresentation-Change: 9 rows move\n{closer}\n"
assert find_declaration(body) is None
def test_a_longer_fence_may_contain_a_shorter_one() -> None:
body = "x\n\n````markdown\n```\nRepresentation-Change: 577 rows move\n```\n````\n"
assert find_declaration(body) is None
def test_a_backtick_opener_whose_info_carries_a_backtick_is_not_a_fence() -> None:
body = "```see `none` below\n\nRepresentation-Change: none. Tests only.\n"
assert find_declaration(body) == "none. Tests only."
passed, _ = check(["src/normalize/mod.rs"], body)
assert passed
tilde = "~~~see `none` below\n\nRepresentation-Change: none. Tests only.\n"
assert find_declaration(tilde) is None
def test_an_unclosed_fence_runs_to_the_end() -> None:
body = "x\n\n```\nRepresentation-Change: 577 rows move\n"
assert find_declaration(body) is None
def test_a_real_trailer_after_a_closed_fence_still_counts() -> None:
body = "```\nsome code\n```\n\nRepresentation-Change: none. Tests only.\n"
assert find_declaration(body) == "none. Tests only."
passed, _ = check(["src/normalize/mod.rs"], body)
assert passed
def test_a_fenced_example_beside_a_real_trailer_is_still_refused() -> None:
body = (
"```\n"
"Representation-Change: 577 rows move\n"
"```\n\n"
"Representation-Change: none. Tests only.\n"
)
assert find_declaration(body) == "none. Tests only."
assert len(find_trailers_as_git_cliff_would(body)) == 2
passed, message = check(["src/normalize/mod.rs"], body)
assert not passed
assert "trailers found" in message
def test_two_fenced_examples_and_no_real_trailer_are_refused_naming_the_fence() -> None:
body = (
"Which of these should I write?\n\n"
"```\n"
"Representation-Change: none\n"
"```\n\n"
"or\n\n"
"```\n"
"Representation-Change: 577 rows move, 360 merge / 205 split / 12 respell.\n"
"```\n"
)
assert find_declaration(body) is None, "neither example is readable, so nothing is declared"
assert len(find_trailers_as_git_cliff_would(body)) == 2, "git-cliff counts both"
passed, message = check(["src/normalize/mod.rs"], body)
assert not passed
assert "trailers found" in message, "the duplicate count is still reported"
assert "fenced code block" in message, (
"the duplicate refusal fired without naming the fence, so the author is told to "
f"delete a trailer that is not one; message was:\n{message}"
)
assert "nothing to delete here" in message
def test_the_fenced_note_is_absent_when_a_real_trailer_is_among_the_duplicates() -> None:
body = (
"```\n"
"Representation-Change: 577 rows move\n"
"```\n\n"
"Representation-Change: none. Tests only.\n"
)
passed, message = check(["src/normalize/mod.rs"], body)
assert not passed
assert "trailers found" in message
assert "nothing to delete here" not in message
def test_contributing_md_s_own_examples_are_not_read_as_declarations() -> None:
contributing = (_REPO_ROOT / "CONTRIBUTING.md").read_text(encoding="utf-8")
assert "Representation-Change:" in contributing, "the docs stopped documenting the trailer"
fenced = fenced_line_numbers(contributing)
quoted_in_a_fence = [
number
for number, line in enumerate(contributing.splitlines(), 1)
if line.startswith("Representation-Change:") and number in fenced
]
assert quoted_in_a_fence, "expected CONTRIBUTING.md to publish the form inside a fence"
assert find_declaration(contributing) is None, (
"CONTRIBUTING.md's own examples are read as a declaration; "
f"fenced trailer lines were {quoted_in_a_fence}"
)