4"""Reject .gitignore directory patterns that match at arbitrary depth.
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.
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.
21Anchoring the patterns fixed the instance. This gate fixes the CLASS: an
22unanchored directory pattern cannot be added again without a decision.
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:
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
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.
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.
52Run with --selftest to prove the rule fires on an unanchored pattern and stays
53quiet on every legal form.
56from __future__
import annotations
61from dataclasses
import dataclass
62from pathlib
import Path
64sys.path.insert(0, str(Path(__file__).resolve().parent))
66from selftest_assert
import expect, report
70 [
"git",
"rev-parse",
"--show-toplevel"],
79RESERVED_DIR_NAMES = frozenset(
94MARKER =
"gitignore-scope-ok:"
101 """One unanchored directory pattern."""
103 def __init__(self, lineno: int, pattern: str) ->
None:
104 """Record one unanchored .gitignore pattern and where it was written."""
106 self.pattern = pattern
108 def __str__(self) -> str:
109 """Render the finding together with its remediation.
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.
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."
126@dataclass(frozen=True)
128 """One reasoned marker bound to the exact unanchored pattern it waives."""
137class MarkerParseState:
138 """Mutable outputs shared while parsing one .gitignore."""
140 findings: list[Finding]
141 bindings: list[MarkerBinding]
142 errors: list[tuple[int, str]]
145def is_anchored(pattern: str) -> bool:
146 """True when `pattern` is bound to a known place in the tree."""
147 if pattern.startswith(
"/"):
149 return "/" in pattern.rstrip(
"/")
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:
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"))
165 errors.append((lineno,
"marker reason is blank"))
167 return (lineno, reason)
173 pending: tuple[int, str] |
None,
174 state: MarkerParseState,
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"
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))
187 if needs_exemption
and pending
is not None:
188 state.bindings.append(MarkerBinding(pending[0], lineno, line, pending[1]))
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))
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):
202 if pending
is not None:
203 state.errors.append((pending[0],
"marker is not bound to a pattern"))
205 elif line.startswith(
"#"):
206 pending = _comment_marker(line, lineno, pending, state.errors)
208 _bind_pattern(line, lineno, pending, state)
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
215def scan(text: str) -> list[Finding]:
216 """Every unanchored directory pattern in `text`, with its line number."""
217 return _analyse(text)[0]
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
226def _assert_fires(failures: list[str]) ->
None:
227 """Assert an unanchored directory pattern fires -- the #377 defect shape."""
228 got = scan(
"build/\n")
230 [f.pattern
for f
in got] == [
"build/"],
231 "a bare 'build/' fires (the #377 landmine)",
235 [f.pattern
for f
in scan(
"output/\nra/\nDebug/\n")] == [
"output/",
"ra/",
"Debug/"],
236 "every plausible source name fires (output/, ra/, Debug/)",
240 [f.lineno
for f
in scan(
"# a comment\n\nbuild/\n")] == [3],
241 "the reported line number is the pattern's own",
246def _assert_quiet(failures: list[str]) ->
None:
247 """Assert every legal anchoring form stays quiet.
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.
253 expect(
not scan(
"/build/\n"),
"a root-anchored '/build/' stays quiet", failures)
255 not scan(
"/examples/**/build/\n"),
256 "a root-scoped '/examples/**/build/' stays quiet",
259 expect(
not scan(
"tests/Unity/\n"),
"an interior slash anchors the pattern", failures)
261 not scan(
"CMakeFiles/\n__pycache__/\nnode_modules/\n"),
262 "tool-reserved names stay quiet unanchored",
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)
268 not scan(f
"# {MARKER} mdl writes it relative to its own cwd\ndownloads/\n"),
269 "an explicit marker with a reason stays quiet",
274def _assert_marker_discipline(failures: list[str]) ->
None:
275 """Assert the waiver marker needs a reason and cannot leak past its block.
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
282 bool(scan(f
"# {MARKER}\ndownloads/\n")),
283 "an EMPTY marker reason does NOT waive the rule",
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",
291 bindings, errors = marker_bindings(f
"# {MARKER} generated downloads\ndownloads/\n")
294 and bindings[0].pattern ==
"downloads/"
295 and bindings[0].reason ==
"generated downloads"
297 "a marker binds exactly one governed pattern with its reason",
300 bindings, errors = marker_bindings(f
"# {MARKER} orphan\n/build/\n")
302 not bindings
and bool(errors),
303 "a marker on an already anchored pattern fails closed as orphaned",
306 real = (REPO_ROOT /
".gitignore").read_text()
308 len([ln
for ln
in real.split(
"\n")
if ln.strip()]) >= PATTERN_FLOOR,
309 "the real .gitignore was actually read (floor check)",
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)
324def main(argv: list[str]) -> int:
325 """Reject .gitignore patterns that match at every directory depth.
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.
333 Returns 0 when every pattern is anchored or justified, 1 otherwise.
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:])
341 path = REPO_ROOT /
".gitignore"
342 if not path.is_file():
343 print(
"check_gitignore_scope.py: FATAL -- .gitignore not found", file=sys.stderr)
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:
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 "
356 findings = scan(text)
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",
364 for finding
in findings:
365 print(finding, file=sys.stderr)
367 print(f
"check_gitignore_scope.py: {len(patterns)} pattern(s), all anchored or justified.")
371if __name__ ==
"__main__":
372 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.