ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_gitignore_scope.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Reject .gitignore directory patterns that match at arbitrary depth.
5
6WHY THIS EXISTS
7---------------
8`.gitignore` line 2 was `build/` for the life of the tree. A pattern with a
9trailing slash and no leading slash matches at EVERY depth, so any directory
10named `build` -- anywhere -- was silently untrackable. #359's reorganisation
11created `scripts/build/` (PATHREF-OK: #359 renamed it), and its six
12files were tracked only because `git mv` moves files that were already
13tracked. A seventh, newly created, would never have been added, and
14`git add` would have reported nothing wrong.
15
16That is the whole failure mode: it produces no error, in either direction. Git
17declines to add the file and says nothing, and the thirteen checkers that
18excluded the substring `/build/` skipped the directory while reporting a clean
19tree. It survived for years because nothing it did was ever visible.
20
21Anchoring the patterns fixed the instance. This gate fixes the CLASS: an
22unanchored directory pattern cannot be added again without a decision.
23
24WHAT COUNTS AS ANCHORED
25-----------------------
26A directory pattern (one ending in `/`) is anchored when it carries a leading
27slash, or contains a slash anywhere but the end -- either form binds it to a
28known place in the tree:
29
30 /build/ anchored to the repo root
31 /examples/**/build/ anchored under examples/, any depth beneath it
32 tests/Unity/ anchored (an interior slash roots the pattern)
33 build/ NOT anchored -- matches at every depth
34 Debug/ NOT anchored
35
36TWO WAYS TO BE UNANCHORED AND LEGAL
37-----------------------------------
381. RESERVED_DIR_NAMES -- names a tool owns. CMake writes `CMakeFiles/`, CPython
39 writes `__pycache__/`, npm writes `node_modules/`. Nobody can legitimately
40 author a source directory with one of those names, so matching them at any
41 depth cannot swallow source. This is a judgement about the NAME, not a
42 convenience list, and that is why `build`, `output`, `ra`, `Debug` and
43 `Release` are not on it: every one of them is a plausible source directory,
44 and `ra/` had in fact already swallowed the vendored FSP blob trees.
45
462. An explicit `gitignore-scope-ok: <reason>` marker in the comment block
47 directly above the pattern. `downloads/` is the real case: apps/host/mdl
48 writes it relative to whatever directory it runs from, so there is no single
49 root to anchor it to. The marker records that this was decided rather than
50 overlooked, and an empty reason is not accepted.
51
52Run with --selftest to prove the rule fires on an unanchored pattern and stays
53quiet on every legal form.
54"""
55
56from __future__ import annotations
57
58import argparse
59import subprocess
60import sys
61from dataclasses import dataclass
62from pathlib import Path
63
64sys.path.insert(0, str(Path(__file__).resolve().parent))
65
66from selftest_assert import expect, report
67
68REPO_ROOT = Path(
69 subprocess.run(
70 ["git", "rev-parse", "--show-toplevel"], # noqa: S607 -- trusted: fixed git argv
71 capture_output=True,
72 text=True,
73 check=True,
74 ).stdout.strip()
75)
76
77# Directory names owned by a tool. See the module docstring: this is a claim
78# that the NAME is reserved, not that the directory is uninteresting.
79RESERVED_DIR_NAMES = frozenset(
80 {
81 ".git",
82 ".idea",
83 ".metadata",
84 ".settings",
85 ".venv",
86 ".vscode",
87 "CMakeFiles",
88 "__pycache__",
89 "node_modules",
90 "venv",
91 }
92)
93
94MARKER = "gitignore-scope-ok:"
95
96# A .gitignore this size cannot legitimately collapse to a handful of lines.
97PATTERN_FLOOR = 20
98
99
100class Finding:
101 """One unanchored directory pattern."""
102
103 def __init__(self, lineno: int, pattern: str) -> None:
104 """Record one unanchored .gitignore pattern and where it was written."""
105 self.lineno = lineno
106 self.pattern = pattern
107
108 def __str__(self) -> str:
109 """Render the finding together with its remediation.
110
111 The message carries the fix rather than just the diagnosis, because
112 the failure mode is silent -- an unanchored pattern matches at every
113 depth and hides files nobody meant to ignore.
114 """
115 return (
116 f".gitignore:{self.lineno}: [GI001] '{self.pattern}' has no leading slash "
117 "and no interior slash, so git matches it at EVERY depth.\n"
118 f" Anchor it (/{self.pattern}), scope it to the roots where it is "
119 "actually produced\n"
120 f" (/examples/**/{self.pattern}), or record the decision with a "
121 f"'{MARKER} <reason>'\n"
122 " comment directly above it."
123 )
124
125
126@dataclass(frozen=True)
127class MarkerBinding:
128 """One reasoned marker bound to the exact unanchored pattern it waives."""
129
130 marker_line: int
131 pattern_line: int
132 pattern: str
133 reason: str
134
135
136@dataclass
137class MarkerParseState:
138 """Mutable outputs shared while parsing one .gitignore."""
139
140 findings: list[Finding]
141 bindings: list[MarkerBinding]
142 errors: list[tuple[int, str]]
143
144
145def is_anchored(pattern: str) -> bool:
146 """True when `pattern` is bound to a known place in the tree."""
147 if pattern.startswith("/"):
148 return True
149 return "/" in pattern.rstrip("/")
150
151
152def _comment_marker(
153 line: str,
154 lineno: int,
155 pending: tuple[int, str] | None,
156 errors: list[tuple[int, str]],
157) -> tuple[int, str] | None:
158 """Update one comment block's pending marker."""
159 if MARKER not in line:
160 return pending
161 reason = line.split(MARKER, 1)[1].strip()
162 if pending is not None:
163 errors.append((pending[0], "marker is not bound to a pattern"))
164 if not reason:
165 errors.append((lineno, "marker reason is blank"))
166 return None
167 return (lineno, reason)
168
169
170def _bind_pattern(
171 line: str,
172 lineno: int,
173 pending: tuple[int, str] | None,
174 state: MarkerParseState,
175) -> None:
176 """Consume one non-comment line and bind/reject its pending marker."""
177 if line.startswith("!"):
178 message = "marker is bound to a negation"
179 elif not line.endswith("/"):
180 message = "marker is bound to a file pattern"
181 else:
182 name = line.rstrip("/")
183 needs_exemption = not is_anchored(line) and name not in RESERVED_DIR_NAMES
184 if needs_exemption and pending is None:
185 state.findings.append(Finding(lineno, line))
186 return
187 if needs_exemption and pending is not None:
188 state.bindings.append(MarkerBinding(pending[0], lineno, line, pending[1]))
189 return
190 message = "marker is bound to a pattern that needs no exemption"
191 if pending is not None:
192 state.errors.append((pending[0], message))
193
194
195def _analyse(text: str) -> tuple[list[Finding], list[MarkerBinding], list[tuple[int, str]]]:
196 """Return violations, exact marker bindings, and malformed/orphan markers."""
197 state = MarkerParseState([], [], [])
198 pending: tuple[int, str] | None = None
199 for lineno, raw in enumerate(text.split("\n"), start=1):
200 line = raw.strip()
201 if not line:
202 if pending is not None:
203 state.errors.append((pending[0], "marker is not bound to a pattern"))
204 pending = None
205 elif line.startswith("#"):
206 pending = _comment_marker(line, lineno, pending, state.errors)
207 else:
208 _bind_pattern(line, lineno, pending, state)
209 pending = None
210 if pending is not None:
211 state.errors.append((pending[0], "marker reaches end of file without a pattern"))
212 return state.findings, state.bindings, state.errors
213
214
215def scan(text: str) -> list[Finding]:
216 """Every unanchored directory pattern in `text`, with its line number."""
217 return _analyse(text)[0]
218
219
220def marker_bindings(text: str) -> tuple[list[MarkerBinding], list[tuple[int, str]]]:
221 """Expose the exact bindings consumed by this gate to governance inventory."""
222 _findings, bindings, errors = _analyse(text)
223 return bindings, errors
224
225
226def _assert_fires(failures: list[str]) -> None:
227 """Assert an unanchored directory pattern fires -- the #377 defect shape."""
228 got = scan("build/\n")
229 expect(
230 [f.pattern for f in got] == ["build/"],
231 "a bare 'build/' fires (the #377 landmine)",
232 failures,
233 )
234 expect(
235 [f.pattern for f in scan("output/\nra/\nDebug/\n")] == ["output/", "ra/", "Debug/"],
236 "every plausible source name fires (output/, ra/, Debug/)",
237 failures,
238 )
239 expect(
240 [f.lineno for f in scan("# a comment\n\nbuild/\n")] == [3],
241 "the reported line number is the pattern's own",
242 failures,
243 )
244
245
246def _assert_quiet(failures: list[str]) -> None:
247 """Assert every legal anchoring form stays quiet.
248
249 Split from the fires cases because these are what stop the rule being
250 unusable: a gate that flags correct anchoring gets switched off, and then
251 the defect it exists to catch comes straight back.
252 """
253 expect(not scan("/build/\n"), "a root-anchored '/build/' stays quiet", failures)
254 expect(
255 not scan("/examples/**/build/\n"),
256 "a root-scoped '/examples/**/build/' stays quiet",
257 failures,
258 )
259 expect(not scan("tests/Unity/\n"), "an interior slash anchors the pattern", failures)
260 expect(
261 not scan("CMakeFiles/\n__pycache__/\nnode_modules/\n"),
262 "tool-reserved names stay quiet unanchored",
263 failures,
264 )
265 expect(not scan("*.o\n*.elf\n"), "file patterns are out of scope", failures)
266 expect(not scan("!libs/third_party/**/ra/\n"), "a negation stays quiet", failures)
267 expect(
268 not scan(f"# {MARKER} mdl writes it relative to its own cwd\ndownloads/\n"),
269 "an explicit marker with a reason stays quiet",
270 failures,
271 )
272
273
274def _assert_marker_discipline(failures: list[str]) -> None:
275 """Assert the waiver marker needs a reason and cannot leak past its block.
276
277 A marker that waives the rule without saying why, or that keeps waiving it
278 for every pattern below, is how an explicit exemption turns into a silent
279 blanket one.
280 """
281 expect(
282 bool(scan(f"# {MARKER}\ndownloads/\n")),
283 "an EMPTY marker reason does NOT waive the rule",
284 failures,
285 )
286 expect(
287 [f.pattern for f in scan(f"# {MARKER} ok\ndownloads/\n\nbuild/\n")] == ["build/"],
288 "a marker does not leak past its own comment block",
289 failures,
290 )
291 bindings, errors = marker_bindings(f"# {MARKER} generated downloads\ndownloads/\n")
292 expect(
293 len(bindings) == 1
294 and bindings[0].pattern == "downloads/"
295 and bindings[0].reason == "generated downloads"
296 and not errors,
297 "a marker binds exactly one governed pattern with its reason",
298 failures,
299 )
300 bindings, errors = marker_bindings(f"# {MARKER} orphan\n/build/\n")
301 expect(
302 not bindings and bool(errors),
303 "a marker on an already anchored pattern fails closed as orphaned",
304 failures,
305 )
306 real = (REPO_ROOT / ".gitignore").read_text()
307 expect(
308 len([ln for ln in real.split("\n") if ln.strip()]) >= PATTERN_FLOOR,
309 "the real .gitignore was actually read (floor check)",
310 failures,
311 )
312
313
314def selftest() -> int:
315 """Assert the rule fires on the real defect and stays quiet on legal forms."""
316 print("check_gitignore_scope.py --selftest")
317 failures: list[str] = []
318 _assert_fires(failures)
319 _assert_quiet(failures)
320 _assert_marker_discipline(failures)
321 return report(failures)
322
323
324def main(argv: list[str]) -> int:
325 """Reject .gitignore patterns that match at every directory depth.
326
327 A slashless pattern such as ``build`` matches ANY directory of that name
328 anywhere in the tree, which is how a first-party source directory under
329 ``scripts/build/`` -- PATHREF-OK: #359 has since renamed it away -- became
330 invisible to git and to every gate at once (#377). Anchoring makes the
331 intended scope explicit.
332
333 Returns 0 when every pattern is anchored or justified, 1 otherwise.
334 """
335 ap = argparse.ArgumentParser(description="Reject unanchored .gitignore directory patterns")
336 ap.add_argument("--selftest", action="store_true", help="assert both directions")
337 args = ap.parse_args(argv[1:])
338 if args.selftest:
339 return selftest()
340
341 path = REPO_ROOT / ".gitignore"
342 if not path.is_file():
343 print("check_gitignore_scope.py: FATAL -- .gitignore not found", file=sys.stderr)
344 return 2
345 text = path.read_text()
346 patterns = [ln for ln in text.split("\n") if ln.strip() and not ln.strip().startswith("#")]
347 if len(patterns) < PATTERN_FLOOR:
348 print(
349 f"check_gitignore_scope.py: FATAL -- only {len(patterns)} pattern(s) read, "
350 f"floor is {PATTERN_FLOOR}. A collapsed read reports success because it "
351 "saw nothing.",
352 file=sys.stderr,
353 )
354 return 2
355
356 findings = scan(text)
357 if findings:
358 print(
359 f"\n{len(findings)} unanchored .gitignore directory pattern(s) -- each one "
360 "silently\nhides any directory of that name, at any depth, from git AND "
361 "from every checker:\n",
362 file=sys.stderr,
363 )
364 for finding in findings:
365 print(finding, file=sys.stderr)
366 return 1
367 print(f"check_gitignore_scope.py: {len(patterns)} pattern(s), all anchored or justified.")
368 return 0
369
370
371if __name__ == "__main__":
372 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298