Skip to content

Commit fba5d84

Browse files
committed
test(tools): add a pytest suite for check_patches.py
tools/check_patches.py gates every PR on patch-set integrity, but the linter itself had no tests. A regression in it could silently start passing bad patches, which is the one thing it exists to prevent. Add tools/tests/test_check_patches.py: each test builds a small fixture patch-set in a temp directory and asserts a given check fails on exactly the violation it targets, with a clean set passing every check. Covers all six checks with a pass case and a fail case each: - series-sync: missing-from-series, extra-in-series (no backing file), duplicate entry, wrong order. - numbering: gap, duplicate number, non-NNNN-*.patch name. - single-surface: a patch touching two files (two diff --git headers). - well-formed: a patch missing a hunk, and one missing file headers. - uxr-only-switches: an added non-uxr HasSwitch(...). - no-brand-literals: an added quoted brand string literal (plus a check that a brand token inside a // comment is allowed). To let the checks run against a fixture directory, thread a patches_dir argument through the check functions and add a run_checks(patches_dir) seam plus a --patches-dir flag. The default resolves to patches/ exactly as before, so behaviour is unchanged. Wire pytest tools/tests into the patch-integrity CI job. Closes #11
1 parent 5ee4125 commit fba5d84

3 files changed

Lines changed: 187 additions & 17 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ jobs:
2626
python-version: "3.12"
2727
- name: Lint the patch series
2828
run: python tools/check_patches.py
29+
- name: Install pytest
30+
run: python -m pip install --upgrade pip pytest
31+
- name: Test the linter
32+
run: python -m pytest tools/tests -q
2933

3034
sdk-python:
3135
name: python sdk tests

tools/check_patches.py

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ def check(self, name: str, ok: bool, detail: str = "") -> None:
5757
self.failures.append(name)
5858

5959

60-
def _patch_files() -> list[Path]:
61-
return sorted(p for p in PATCHES.glob("*.patch"))
60+
def _patch_files(patches_dir: Path) -> list[Path]:
61+
return sorted(p for p in patches_dir.glob("*.patch"))
6262

6363

6464
def _strip_comment(added: str) -> str:
@@ -67,14 +67,15 @@ def _strip_comment(added: str) -> str:
6767
return added[:i] if i != -1 else added
6868

6969

70-
def check_series_sync(rep: Report, verbose: bool) -> None:
71-
if not SERIES.exists():
70+
def check_series_sync(rep: Report, patches_dir: Path, verbose: bool) -> None:
71+
series = patches_dir / "series"
72+
if not series.exists():
7273
rep.check("series-sync", False, "patches/series is missing")
7374
return
74-
listed = [ln.strip() for ln in SERIES.read_text().splitlines()
75+
listed = [ln.strip() for ln in series.read_text().splitlines()
7576
if ln.strip() and not ln.strip().startswith("#")]
7677
listed_names = [Path(x).name for x in listed]
77-
actual = [p.name for p in _patch_files()]
78+
actual = [p.name for p in _patch_files(patches_dir)]
7879

7980
dupes = sorted({n for n in listed_names if listed_names.count(n) > 1})
8081
missing_file = [n for n in listed_names if n not in actual] # in series, no file
@@ -94,10 +95,10 @@ def check_series_sync(rep: Report, verbose: bool) -> None:
9495
rep.check("series-sync", ok, detail)
9596

9697

97-
def check_numbering(rep: Report, verbose: bool) -> None:
98+
def check_numbering(rep: Report, patches_dir: Path, verbose: bool) -> None:
9899
nums: list[int] = []
99100
bad_names: list[str] = []
100-
for p in _patch_files():
101+
for p in _patch_files(patches_dir):
101102
m = PATCH_NAME_RE.match(p.name)
102103
if not m:
103104
bad_names.append(p.name)
@@ -118,14 +119,14 @@ def check_numbering(rep: Report, verbose: bool) -> None:
118119
rep.check("numbering", ok, detail)
119120

120121

121-
def check_bodies(rep: Report, verbose: bool) -> None:
122+
def check_bodies(rep: Report, patches_dir: Path, verbose: bool) -> None:
122123
"""single-surface + well-formed + uxr-only + no-brand, per patch."""
123124
multi_file: list[str] = []
124125
malformed: list[str] = []
125126
bad_switch: list[str] = []
126127
brand_hits: list[str] = []
127128

128-
for p in _patch_files():
129+
for p in _patch_files(patches_dir):
129130
text = p.read_text(encoding="utf-8", errors="replace")
130131
lines = text.splitlines()
131132
diff_headers = [ln for ln in lines if ln.startswith("diff --git ")]
@@ -159,20 +160,37 @@ def check_bodies(rep: Report, verbose: bool) -> None:
159160
else f"brand literal would ship in binary: {brand_hits}")
160161

161162

163+
def run_checks(patches_dir: Path, verbose: bool = False) -> Report:
164+
"""Run every check against `patches_dir` and return the populated Report.
165+
166+
The one seam the tests use: point this at a fixture directory (holding
167+
`*.patch` files and a `series`) to exercise each check in isolation.
168+
"""
169+
rep = Report()
170+
check_series_sync(rep, patches_dir, verbose)
171+
check_numbering(rep, patches_dir, verbose)
172+
check_bodies(rep, patches_dir, verbose)
173+
return rep
174+
175+
162176
def main() -> int:
163177
ap = argparse.ArgumentParser(description="Integrity linter for the Fortress patch set.")
164178
ap.add_argument("-v", "--verbose", action="store_true")
179+
ap.add_argument("--patches-dir", type=Path, default=PATCHES,
180+
help="directory holding the *.patch files and series (default: patches/)")
165181
args = ap.parse_args()
166182

167-
if not PATCHES.is_dir():
168-
print(f"error: {PATCHES} not found (run from the repo root)", file=sys.stderr)
183+
patches_dir = args.patches_dir
184+
if not patches_dir.is_dir():
185+
print(f"error: {patches_dir} not found (run from the repo root)", file=sys.stderr)
169186
return 1
170187

171-
print(f"Fortress patch-set linter - {len(_patch_files())} patches in {PATCHES.relative_to(REPO)}/")
172-
rep = Report()
173-
check_series_sync(rep, args.verbose)
174-
check_numbering(rep, args.verbose)
175-
check_bodies(rep, args.verbose)
188+
try:
189+
where = patches_dir.resolve().relative_to(REPO)
190+
except ValueError:
191+
where = patches_dir
192+
print(f"Fortress patch-set linter - {len(_patch_files(patches_dir))} patches in {where}/")
193+
rep = run_checks(patches_dir, args.verbose)
176194

177195
print("-" * 60)
178196
if rep.failures:

tools/tests/test_check_patches.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""
2+
Tests for tools/check_patches.py — the patch-set integrity linter.
3+
4+
The linter gates every PR, so a regression in it could silently start passing bad patches:
5+
the one thing it exists to prevent. Each test builds a tiny fixture patch-set in a temp
6+
directory and asserts that a given check fails on exactly the violation it targets — and
7+
that a clean set passes every check.
8+
9+
Run: pytest tools/tests -q
10+
"""
11+
from __future__ import annotations
12+
import sys
13+
from pathlib import Path
14+
15+
# Make `import check_patches` work when tests run from the repo root or from tools/.
16+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
17+
import check_patches as cp # noqa: E402
18+
19+
20+
# --------------------------------------------------------------------------- fixture helpers
21+
def make_patch(path="src/foo.cc", added=(" int x = 0;",), *,
22+
hunk=True, headers=True, second_file=None) -> str:
23+
"""Build a unified-diff patch. Defaults are clean and well-formed; the keyword args let a
24+
test break exactly one property (drop the hunk / the file headers, add a second file)."""
25+
lines = [f"diff --git a/{path} b/{path}", "index 1234567..89abcde 100644"]
26+
if headers:
27+
lines += [f"--- a/{path}", f"+++ b/{path}"]
28+
if hunk:
29+
lines.append(f"@@ -1,2 +1,{2 + len(added)} @@")
30+
lines.append(" context above")
31+
lines += [f"+{a}" for a in added]
32+
lines.append(" context below")
33+
if second_file: # a second `diff --git` -> two surfaces in one patch
34+
lines += [f"diff --git a/{second_file} b/{second_file}",
35+
f"--- a/{second_file}", f"+++ b/{second_file}",
36+
"@@ -1 +1,2 @@", " ctx", "+ added"]
37+
return "\n".join(lines) + "\n"
38+
39+
40+
def write_set(root: Path, patches: dict[str, str], series: list[str] | None = None) -> Path:
41+
"""Write patch files + a series into `root`. Series defaults to the files in sorted order."""
42+
for name, body in patches.items():
43+
(root / name).write_text(body, encoding="utf-8")
44+
if series is None:
45+
series = sorted(patches)
46+
(root / "series").write_text("\n".join(series) + "\n", encoding="utf-8")
47+
return root
48+
49+
50+
# A clean two-patch set: contiguous numbering, in-sync series, one file each, well-formed,
51+
# a uxr- switch (exercises the uxr-only pass path), no brand literals.
52+
def clean_patches() -> dict[str, str]:
53+
return {
54+
"0001-alpha.patch": make_patch("core/alpha.cc",
55+
added=(' if (cmd.HasSwitch("uxr-alpha")) return;',)),
56+
"0002-beta.patch": make_patch("core/beta.cc", added=(" int y = 1;",)),
57+
}
58+
59+
60+
# --------------------------------------------------------------------------- the clean baseline
61+
def test_clean_set_passes_every_check(tmp_path):
62+
rep = cp.run_checks(write_set(tmp_path, clean_patches()))
63+
assert rep.failures == []
64+
65+
66+
# --------------------------------------------------------------------------- series-sync
67+
def test_series_sync_missing_entry(tmp_path):
68+
# A patch file exists but is not listed -> apply-patches.sh would silently skip it.
69+
write_set(tmp_path, clean_patches(), series=["0001-alpha.patch"])
70+
assert cp.run_checks(tmp_path).failures == ["series-sync"]
71+
72+
73+
def test_series_sync_missing_file(tmp_path):
74+
# Series lists a patch with no backing file -> the build breaks.
75+
write_set(tmp_path, clean_patches(),
76+
series=["0001-alpha.patch", "0002-beta.patch", "0003-ghost.patch"])
77+
assert cp.run_checks(tmp_path).failures == ["series-sync"]
78+
79+
80+
def test_series_sync_duplicate_entry(tmp_path):
81+
write_set(tmp_path, clean_patches(),
82+
series=["0001-alpha.patch", "0001-alpha.patch", "0002-beta.patch"])
83+
assert cp.run_checks(tmp_path).failures == ["series-sync"]
84+
85+
86+
def test_series_sync_wrong_order(tmp_path):
87+
write_set(tmp_path, clean_patches(), series=["0002-beta.patch", "0001-alpha.patch"])
88+
assert cp.run_checks(tmp_path).failures == ["series-sync"]
89+
90+
91+
# --------------------------------------------------------------------------- numbering
92+
def test_numbering_gap(tmp_path):
93+
patches = {"0001-alpha.patch": make_patch("core/alpha.cc"),
94+
"0003-gamma.patch": make_patch("core/gamma.cc")}
95+
write_set(tmp_path, patches) # series auto-syncs, so only numbering should trip
96+
assert cp.run_checks(tmp_path).failures == ["numbering"]
97+
98+
99+
def test_numbering_duplicate_number(tmp_path):
100+
patches = {"0001-alpha.patch": make_patch("core/alpha.cc"),
101+
"0002-beta.patch": make_patch("core/beta.cc"),
102+
"0002-clone.patch": make_patch("core/clone.cc")}
103+
rep = cp.run_checks(write_set(tmp_path, patches))
104+
assert rep.failures == ["numbering"]
105+
106+
107+
def test_numbering_non_conforming_name(tmp_path):
108+
patches = {"0001-alpha.patch": make_patch("core/alpha.cc"),
109+
"not-a-numbered.patch": make_patch("core/other.cc")}
110+
assert cp.run_checks(write_set(tmp_path, patches)).failures == ["numbering"]
111+
112+
113+
# --------------------------------------------------------------------------- single-surface
114+
def test_single_surface_two_files(tmp_path):
115+
patches = {"0001-alpha.patch": make_patch("core/one.cc", second_file="core/two.cc")}
116+
assert cp.run_checks(write_set(tmp_path, patches)).failures == ["single-surface"]
117+
118+
119+
# --------------------------------------------------------------------------- well-formed
120+
def test_well_formed_missing_hunk(tmp_path):
121+
patches = {"0001-alpha.patch": make_patch("core/alpha.cc", hunk=False)}
122+
assert cp.run_checks(write_set(tmp_path, patches)).failures == ["well-formed"]
123+
124+
125+
def test_well_formed_missing_headers(tmp_path):
126+
patches = {"0001-alpha.patch": make_patch("core/alpha.cc", headers=False)}
127+
assert cp.run_checks(write_set(tmp_path, patches)).failures == ["well-formed"]
128+
129+
130+
# --------------------------------------------------------------------------- uxr-only-switches
131+
def test_uxr_only_rejects_non_uxr_switch(tmp_path):
132+
patches = {"0001-alpha.patch": make_patch(
133+
"core/alpha.cc", added=(' if (cmd.HasSwitch("legacy-mode")) return;',))}
134+
assert cp.run_checks(write_set(tmp_path, patches)).failures == ["uxr-only-switches"]
135+
136+
137+
# --------------------------------------------------------------------------- no-brand-literals
138+
def test_no_brand_literal_in_added_code(tmp_path):
139+
patches = {"0001-alpha.patch": make_patch(
140+
"core/alpha.cc", added=(' const char* k = "fortress-build";',))}
141+
assert cp.run_checks(write_set(tmp_path, patches)).failures == ["no-brand-literals"]
142+
143+
144+
def test_brand_token_in_comment_is_allowed(tmp_path):
145+
# A `//` comment mentioning a brand does not ship as a string literal, so it must pass.
146+
patches = {"0001-alpha.patch": make_patch(
147+
"core/alpha.cc", added=(" int z = 0; // fortress tweak",))}
148+
assert cp.run_checks(write_set(tmp_path, patches)).failures == []

0 commit comments

Comments
 (0)