ratch 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ratch/__init__.py +27 -0
- ratch/__main__.py +6 -0
- ratch/check.py +46 -0
- ratch/checks/__init__.py +7 -0
- ratch/checks/ai_signatures.py +125 -0
- ratch/checks/bdd_conventions.py +108 -0
- ratch/checks/catalog_size.py +64 -0
- ratch/checks/circular_import.py +120 -0
- ratch/checks/commit_heatmap.py +89 -0
- ratch/checks/conflict_markers.py +79 -0
- ratch/checks/doc_counts.py +111 -0
- ratch/checks/docs_render.py +87 -0
- ratch/checks/first_person.py +131 -0
- ratch/checks/font_cdn.py +84 -0
- ratch/checks/forbidden_literal.py +131 -0
- ratch/checks/hash_named_test.py +83 -0
- ratch/checks/manifest_purity.py +187 -0
- ratch/checks/plugin_registry.py +425 -0
- ratch/checks/pytest_skip.py +92 -0
- ratch/checks/todo_issue.py +80 -0
- ratch/checks/vacuous_assert.py +81 -0
- ratch/cli.py +60 -0
- ratch/pytest_plugin.py +54 -0
- ratch/registry.py +36 -0
- ratch/reporter.py +26 -0
- ratch/result.py +71 -0
- ratch/runner.py +118 -0
- ratch/testing.py +133 -0
- ratch/workspace.py +188 -0
- ratch-0.1.0.dist-info/METADATA +10 -0
- ratch-0.1.0.dist-info/RECORD +35 -0
- ratch-0.1.0.dist-info/WHEEL +5 -0
- ratch-0.1.0.dist-info/entry_points.txt +24 -0
- ratch-0.1.0.dist-info/licenses/LICENSE +201 -0
- ratch-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""doc-counts-match-ssot check."""
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from ratch.check import Plant
|
|
5
|
+
from ratch.registry import discover
|
|
6
|
+
from ratch.result import Finding, Result, State
|
|
7
|
+
from ratch.testing import FakeWorkspace
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def render_checks_region():
|
|
11
|
+
return f"catalog_n={len(discover())}\n"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Pin:
|
|
15
|
+
def __init__(self, path, pattern, ssot_key):
|
|
16
|
+
self.path = path
|
|
17
|
+
self.pattern = re.compile(pattern)
|
|
18
|
+
self.ssot_key = ssot_key
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_DEFAULT_PINS = (
|
|
22
|
+
Pin("README.md", r"^catalog_n=(\d+)$", "discover_n"),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DocCountsMatchSSot:
|
|
27
|
+
"""Pin a README capture to a runtime catalog count.
|
|
28
|
+
|
|
29
|
+
Rule:
|
|
30
|
+
README.md must contain a catalog_n=N line whose N equals
|
|
31
|
+
len(discover()).
|
|
32
|
+
|
|
33
|
+
Why:
|
|
34
|
+
A hand-typed catalog size in prose rots the moment a plugin is
|
|
35
|
+
added; the entry-point catalog is the SSOT.
|
|
36
|
+
|
|
37
|
+
Proven in:
|
|
38
|
+
internal/specs/2026-09-17-ratchet-v1-spine-design.md
|
|
39
|
+
|
|
40
|
+
Not this:
|
|
41
|
+
Not a full README renderer. Not an enabled-set count (that
|
|
42
|
+
needs a Workspace-aware manifest reader). Not a match scoped
|
|
43
|
+
only to the generated HTML region.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
id = "doc-counts-match-ssot"
|
|
47
|
+
tier = "A"
|
|
48
|
+
kind = "gate"
|
|
49
|
+
scope = "global"
|
|
50
|
+
proven_in = ("internal/specs/2026-09-17-ratchet-v1-spine-design.md",)
|
|
51
|
+
confidence = "inferred"
|
|
52
|
+
tolerates_unparseable = False
|
|
53
|
+
|
|
54
|
+
def __init__(self, min_surface=1, pins=None):
|
|
55
|
+
if min_surface < 1:
|
|
56
|
+
raise ValueError("min_surface must be >= 1")
|
|
57
|
+
self.min_surface = min_surface
|
|
58
|
+
self.pins = _DEFAULT_PINS if pins is None else pins
|
|
59
|
+
|
|
60
|
+
def _state(self, findings, examined_n):
|
|
61
|
+
if findings:
|
|
62
|
+
return State.FAIL
|
|
63
|
+
if examined_n < self.min_surface:
|
|
64
|
+
return State.VACUOUS
|
|
65
|
+
return State.PASS
|
|
66
|
+
|
|
67
|
+
def _ssot(self, key):
|
|
68
|
+
if key == "discover_n":
|
|
69
|
+
return str(len(discover()))
|
|
70
|
+
raise ValueError(f"unknown ssot_key {key}")
|
|
71
|
+
|
|
72
|
+
def check(self, ws):
|
|
73
|
+
tracked = set(ws.tracked_files())
|
|
74
|
+
findings = []
|
|
75
|
+
texts = {}
|
|
76
|
+
for pin in self.pins:
|
|
77
|
+
if pin.path not in tracked:
|
|
78
|
+
continue
|
|
79
|
+
if pin.path not in texts:
|
|
80
|
+
texts[pin.path] = ws.read(pin.path)
|
|
81
|
+
matched = False
|
|
82
|
+
for line in texts[pin.path].splitlines():
|
|
83
|
+
hit = pin.pattern.search(line)
|
|
84
|
+
if hit is None:
|
|
85
|
+
continue
|
|
86
|
+
matched = True
|
|
87
|
+
if hit.group(1) != self._ssot(pin.ssot_key):
|
|
88
|
+
findings.append(
|
|
89
|
+
Finding(self.id, pin.path, "catalog_n",
|
|
90
|
+
message="catalog_n disagrees with discover()")
|
|
91
|
+
)
|
|
92
|
+
if not matched:
|
|
93
|
+
findings.append(
|
|
94
|
+
Finding(self.id, pin.path, "catalog_n",
|
|
95
|
+
message="catalog_n line missing")
|
|
96
|
+
)
|
|
97
|
+
return Result(
|
|
98
|
+
self.id, self._state(findings, len(texts)),
|
|
99
|
+
examined_n=len(texts), skipped_n=ws.skipped_n, findings=findings,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def plants(self, ws):
|
|
103
|
+
wrong = f"catalog_n={len(discover()) + 1}\n"
|
|
104
|
+
yield Plant(
|
|
105
|
+
label="wrong-catalog-n",
|
|
106
|
+
planted_ws=FakeWorkspace(files={"README.md": wrong}),
|
|
107
|
+
expected=(self.id, "README.md", "catalog_n"),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def fixture(self, kit):
|
|
111
|
+
return FakeWorkspace(files={"README.md": render_checks_region()})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""docs-equal-fresh-render check."""
|
|
2
|
+
from ratch.check import Plant
|
|
3
|
+
from ratch.checks.doc_counts import render_checks_region
|
|
4
|
+
from ratch.result import Finding, Result, State
|
|
5
|
+
from ratch.testing import FakeWorkspace
|
|
6
|
+
|
|
7
|
+
_START = "<!-- ratch:generated:checks -->"
|
|
8
|
+
_END = "<!-- ratch:generated:checks:end -->"
|
|
9
|
+
_README = "README.md"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _region(text):
|
|
13
|
+
if _START not in text or _END not in text:
|
|
14
|
+
return None
|
|
15
|
+
return text.split(_START, 1)[1].split(_END, 1)[0]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DocsEqualFreshRender:
|
|
19
|
+
"""Require the README generated region to match a fresh render.
|
|
20
|
+
|
|
21
|
+
Rule:
|
|
22
|
+
README.md must contain the generated-checks markers whose inner
|
|
23
|
+
body equals render_checks_region().
|
|
24
|
+
|
|
25
|
+
Why:
|
|
26
|
+
A catalog_n line that a human typed can still be the wrong
|
|
27
|
+
shape (extra lines, missing newline). Byte-equal to the live
|
|
28
|
+
renderer is the SSOT.
|
|
29
|
+
|
|
30
|
+
Proven in:
|
|
31
|
+
internal/specs/2026-09-17-ratchet-v1-spine-design.md
|
|
32
|
+
|
|
33
|
+
Not this:
|
|
34
|
+
Not a rewrite of the authored fence. Not a full README
|
|
35
|
+
generator. Not an enabled-set count.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
id = "docs-equal-fresh-render"
|
|
39
|
+
tier = "A"
|
|
40
|
+
kind = "gate"
|
|
41
|
+
scope = "global"
|
|
42
|
+
proven_in = ("internal/specs/2026-09-17-ratchet-v1-spine-design.md",)
|
|
43
|
+
confidence = "inferred"
|
|
44
|
+
tolerates_unparseable = False
|
|
45
|
+
|
|
46
|
+
def __init__(self, min_surface=1):
|
|
47
|
+
if min_surface < 1:
|
|
48
|
+
raise ValueError("min_surface must be >= 1")
|
|
49
|
+
self.min_surface = min_surface
|
|
50
|
+
|
|
51
|
+
def _state(self, findings, examined_n):
|
|
52
|
+
if findings:
|
|
53
|
+
return State.FAIL
|
|
54
|
+
if examined_n < self.min_surface:
|
|
55
|
+
return State.VACUOUS
|
|
56
|
+
return State.PASS
|
|
57
|
+
|
|
58
|
+
def check(self, ws):
|
|
59
|
+
if _README not in ws.tracked_files():
|
|
60
|
+
return Result(
|
|
61
|
+
self.id, self._state([], 0),
|
|
62
|
+
examined_n=0, skipped_n=ws.skipped_n, findings=[],
|
|
63
|
+
)
|
|
64
|
+
body = _region(ws.read(_README))
|
|
65
|
+
expected = "\n" + render_checks_region()
|
|
66
|
+
findings = []
|
|
67
|
+
if body is None or body != expected:
|
|
68
|
+
findings.append(
|
|
69
|
+
Finding(self.id, _README, "generated:checks",
|
|
70
|
+
message="generated region stale")
|
|
71
|
+
)
|
|
72
|
+
return Result(
|
|
73
|
+
self.id, self._state(findings, 1),
|
|
74
|
+
examined_n=1, skipped_n=ws.skipped_n, findings=findings,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def plants(self, ws):
|
|
78
|
+
stale = f"{_START}\ncatalog_n=0\n{_END}\n"
|
|
79
|
+
yield Plant(
|
|
80
|
+
label="stale-region",
|
|
81
|
+
planted_ws=FakeWorkspace(files={_README: stale}),
|
|
82
|
+
expected=(self.id, _README, "generated:checks"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def fixture(self, kit):
|
|
86
|
+
text = f"{_START}\n{render_checks_region()}{_END}\n"
|
|
87
|
+
return FakeWorkspace(files={_README: text})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""No-first-person check."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import fnmatch
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from ratch.check import Plant
|
|
8
|
+
from ratch.result import Finding, Result, State
|
|
9
|
+
from ratch.testing import FakeWorkspace
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NoFirstPerson:
|
|
13
|
+
"""Forbid first-person voice in tracked prose.
|
|
14
|
+
|
|
15
|
+
Rule:
|
|
16
|
+
No tracked file matching the configured path globs may contain a
|
|
17
|
+
first-person pronoun: the English words I, we, our, us as whole
|
|
18
|
+
words, or the CJK self-reference 我.
|
|
19
|
+
|
|
20
|
+
Why:
|
|
21
|
+
First-person voice binds shipped prose to one author's standpoint
|
|
22
|
+
where the project must speak with a single impersonal voice; a
|
|
23
|
+
machine proves the pronoun's absence so no reviewer polices tone
|
|
24
|
+
by eye on every commit.
|
|
25
|
+
|
|
26
|
+
Proven in:
|
|
27
|
+
kairos/tests/test_invariant_no_self_reference.py
|
|
28
|
+
|
|
29
|
+
Not this:
|
|
30
|
+
Not a grammar, clarity, or readability rule. It never rewrites
|
|
31
|
+
prose and never judges style; it rejects only an exact pronoun
|
|
32
|
+
match, and deliberately exempts the "I/O" boundary term and, under
|
|
33
|
+
the allow-team-we polarity, the collective 我们.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
id = "no-first-person"
|
|
37
|
+
tier = "A"
|
|
38
|
+
kind = "gate"
|
|
39
|
+
scope = "global"
|
|
40
|
+
proven_in = ("kairos/tests/test_invariant_no_self_reference.py",)
|
|
41
|
+
confidence = "breadth"
|
|
42
|
+
tolerates_unparseable = False
|
|
43
|
+
|
|
44
|
+
def __init__(self, polarity="forbid-all", tokens_en=("I", "we", "our", "us"),
|
|
45
|
+
tokens_cjk=("我",), paths=("*.md",), min_surface=1):
|
|
46
|
+
if min_surface < 1:
|
|
47
|
+
raise ValueError("min_surface must be >= 1")
|
|
48
|
+
self.polarity = polarity
|
|
49
|
+
self.paths = tuple(paths)
|
|
50
|
+
self.min_surface = min_surface
|
|
51
|
+
# allow-team-we: drop the collective EN pronouns (keep only "I"),
|
|
52
|
+
# and let the CJK builder exempt 我们.
|
|
53
|
+
if polarity == "allow-team-we":
|
|
54
|
+
self.tokens_en = tuple(t for t in tokens_en if t == "I")
|
|
55
|
+
else:
|
|
56
|
+
self.tokens_en = tuple(tokens_en)
|
|
57
|
+
self.tokens_cjk = tuple(tokens_cjk)
|
|
58
|
+
self._en_rx = self._build_en_rx()
|
|
59
|
+
self._cjk_rx = self._build_cjk_rx()
|
|
60
|
+
|
|
61
|
+
def _build_en_rx(self):
|
|
62
|
+
if not self.tokens_en:
|
|
63
|
+
return None
|
|
64
|
+
# Self-host critical (blocking, non-weakenable gate): "I" never matches
|
|
65
|
+
# inside the I/O boundary term, and NO re.IGNORECASE — IGNORECASE would
|
|
66
|
+
# bite a lowercase loop variable `i`, `i.e.`, or the acronym `US`.
|
|
67
|
+
# Match only sentence-case forms: I, We/we, Our/our, Us/us.
|
|
68
|
+
parts = ["I(?!/O)" if t == "I"
|
|
69
|
+
else f"[{t[0].upper()}{t[0]}]{re.escape(t[1:])}"
|
|
70
|
+
for t in self.tokens_en]
|
|
71
|
+
return re.compile(r"\b(?:" + "|".join(parts) + r")\b")
|
|
72
|
+
|
|
73
|
+
def _build_cjk_rx(self):
|
|
74
|
+
if not self.tokens_cjk:
|
|
75
|
+
return None
|
|
76
|
+
parts = []
|
|
77
|
+
for t in self.tokens_cjk:
|
|
78
|
+
if t == "我" and self.polarity == "allow-team-we":
|
|
79
|
+
parts.append("我(?!们)") # allow the collective 我们
|
|
80
|
+
else:
|
|
81
|
+
parts.append(re.escape(t))
|
|
82
|
+
return re.compile("|".join(parts))
|
|
83
|
+
|
|
84
|
+
def _state(self, findings, examined_n):
|
|
85
|
+
if findings:
|
|
86
|
+
return State.FAIL
|
|
87
|
+
if examined_n < self.min_surface:
|
|
88
|
+
return State.VACUOUS
|
|
89
|
+
return State.PASS
|
|
90
|
+
|
|
91
|
+
def check(self, ws):
|
|
92
|
+
files = [p for p in ws.tracked_files()
|
|
93
|
+
if any(fnmatch.fnmatch(p, g) for g in self.paths)]
|
|
94
|
+
findings = []
|
|
95
|
+
examined_n = 0
|
|
96
|
+
for path in files:
|
|
97
|
+
examined_n += 1
|
|
98
|
+
for lineno, line in enumerate(ws.read(path).splitlines(), start=1):
|
|
99
|
+
if (self._en_rx and self._en_rx.search(line)) or \
|
|
100
|
+
(self._cjk_rx and self._cjk_rx.search(line)):
|
|
101
|
+
anchor = " ".join(line.split())
|
|
102
|
+
findings.append(
|
|
103
|
+
Finding(self.id, path, anchor, line=lineno,
|
|
104
|
+
message=f"first-person: {anchor}")
|
|
105
|
+
)
|
|
106
|
+
return Result(
|
|
107
|
+
self.id, self._state(findings, examined_n),
|
|
108
|
+
examined_n=examined_n, skipped_n=ws.skipped_n,
|
|
109
|
+
unparseable_n=0, findings=findings,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def plants(self, ws):
|
|
113
|
+
for glob in self.paths:
|
|
114
|
+
fname = glob.replace("*", "note") # e.g. "*.md" -> "note.md"
|
|
115
|
+
for token in self.tokens_en:
|
|
116
|
+
line = f"Line with {token} here."
|
|
117
|
+
yield Plant(
|
|
118
|
+
label=f"en:{token}:{glob}",
|
|
119
|
+
planted_ws=FakeWorkspace(files={fname: line + "\n"}),
|
|
120
|
+
expected=(self.id, fname, line),
|
|
121
|
+
)
|
|
122
|
+
for token in self.tokens_cjk:
|
|
123
|
+
line = f"这一行有{token}字。"
|
|
124
|
+
yield Plant(
|
|
125
|
+
label=f"cjk:{token}:{glob}",
|
|
126
|
+
planted_ws=FakeWorkspace(files={fname: line + "\n"}),
|
|
127
|
+
expected=(self.id, fname, line),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def fixture(self, kit):
|
|
131
|
+
return FakeWorkspace(files={"README.md": "The ratchet turns one way.\n"})
|
ratch/checks/font_cdn.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""No-external-font-cdn check."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ratch.check import Plant
|
|
5
|
+
from ratch.result import Finding, Result, State
|
|
6
|
+
from ratch.testing import FakeWorkspace
|
|
7
|
+
|
|
8
|
+
_CDNS = (
|
|
9
|
+
"fonts.googleapis.com",
|
|
10
|
+
"fonts.gstatic.com",
|
|
11
|
+
"typekit.net",
|
|
12
|
+
)
|
|
13
|
+
_WEB = (".html", ".css", ".js")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NoExternalFontCdn:
|
|
17
|
+
"""Forbid hosted webfont CDNs in shipped HTML/CSS/JS.
|
|
18
|
+
|
|
19
|
+
Rule:
|
|
20
|
+
Tracked .html/.css/.js files must not contain known font-CDN
|
|
21
|
+
host substrings.
|
|
22
|
+
|
|
23
|
+
Why:
|
|
24
|
+
A remote font fetch is a privacy and availability leak; fonts
|
|
25
|
+
must be served from the same origin.
|
|
26
|
+
|
|
27
|
+
Proven in:
|
|
28
|
+
internal/pillar-1-teeth.md
|
|
29
|
+
|
|
30
|
+
Not this:
|
|
31
|
+
Not a ban on local @font-face. Not a scan of markdown prose.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
id = "no-external-font-cdn"
|
|
35
|
+
tier = "A"
|
|
36
|
+
kind = "gate"
|
|
37
|
+
scope = "global"
|
|
38
|
+
proven_in = ("internal/pillar-1-teeth.md",)
|
|
39
|
+
confidence = "inferred"
|
|
40
|
+
tolerates_unparseable = False
|
|
41
|
+
|
|
42
|
+
def __init__(self, min_surface=1):
|
|
43
|
+
if min_surface < 1:
|
|
44
|
+
raise ValueError("min_surface must be >= 1")
|
|
45
|
+
self.min_surface = min_surface
|
|
46
|
+
|
|
47
|
+
def _state(self, findings, examined_n):
|
|
48
|
+
if findings:
|
|
49
|
+
return State.FAIL
|
|
50
|
+
if examined_n < self.min_surface:
|
|
51
|
+
return State.VACUOUS
|
|
52
|
+
return State.PASS
|
|
53
|
+
|
|
54
|
+
def check(self, ws):
|
|
55
|
+
findings = []
|
|
56
|
+
examined_n = 0
|
|
57
|
+
for path in ws.tracked_files():
|
|
58
|
+
if not path.endswith(_WEB):
|
|
59
|
+
continue
|
|
60
|
+
examined_n += 1
|
|
61
|
+
text = ws.read(path)
|
|
62
|
+
for cdn in _CDNS:
|
|
63
|
+
if cdn in text:
|
|
64
|
+
findings.append(
|
|
65
|
+
Finding(self.id, path, cdn,
|
|
66
|
+
message=f"font cdn: {cdn}")
|
|
67
|
+
)
|
|
68
|
+
return Result(
|
|
69
|
+
self.id, self._state(findings, examined_n),
|
|
70
|
+
examined_n=examined_n, skipped_n=ws.skipped_n, findings=findings,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def plants(self, ws):
|
|
74
|
+
host = "fonts.googleapis.com"
|
|
75
|
+
yield Plant(
|
|
76
|
+
label="google-fonts",
|
|
77
|
+
planted_ws=FakeWorkspace(
|
|
78
|
+
files={"static/x.css": f"@import url(https://{host}/css);\n"}
|
|
79
|
+
),
|
|
80
|
+
expected=(self.id, "static/x.css", host),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def fixture(self, kit):
|
|
84
|
+
return FakeWorkspace(files={"static/x.css": "body { font-family: sans-serif; }\n"})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""No-forbidden-literal check."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from ratch.check import Plant
|
|
7
|
+
from ratch.result import Finding, Result, State
|
|
8
|
+
from ratch.testing import FakeWorkspace
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _sample_for(pattern):
|
|
12
|
+
parts = []
|
|
13
|
+
i = 0
|
|
14
|
+
while i < len(pattern):
|
|
15
|
+
if pattern[i] == "[" and i + 2 < len(pattern) and pattern[i + 2] == "]":
|
|
16
|
+
parts.append(pattern[i + 1])
|
|
17
|
+
i += 3
|
|
18
|
+
else:
|
|
19
|
+
parts.append(pattern[i])
|
|
20
|
+
i += 1
|
|
21
|
+
return "".join(parts)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NoForbiddenLiteral:
|
|
25
|
+
"""Forbid a fixed set of author-tool literals anywhere they can leak.
|
|
26
|
+
|
|
27
|
+
Rule:
|
|
28
|
+
No tracked file content, tracked filename, or committer identity may
|
|
29
|
+
match any configured forbidden pattern.
|
|
30
|
+
|
|
31
|
+
Why:
|
|
32
|
+
An author-tool literal in shipped content, a path, or a commit trailer
|
|
33
|
+
is a provenance leak that no reviewer reliably catches by eye; a
|
|
34
|
+
machine can prove its absence on every commit.
|
|
35
|
+
|
|
36
|
+
Proven in:
|
|
37
|
+
tremor/tests/lint_no_forbidden_literal.py
|
|
38
|
+
|
|
39
|
+
Not this:
|
|
40
|
+
Not a style or taste rule. Naming, wording, and intent are untouched;
|
|
41
|
+
only an exact pattern match across three mechanical vectors is rejected.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
id = "no-forbidden-literal"
|
|
45
|
+
tier = "A"
|
|
46
|
+
kind = "gate"
|
|
47
|
+
scope = "global"
|
|
48
|
+
proven_in = ("tremor/tests/lint_no_forbidden_literal.py",)
|
|
49
|
+
confidence = "breadth"
|
|
50
|
+
tolerates_unparseable = False
|
|
51
|
+
|
|
52
|
+
def __init__(self, patterns=("cl[a]ude",), min_surface=1):
|
|
53
|
+
if min_surface < 1:
|
|
54
|
+
raise ValueError("min_surface must be >= 1")
|
|
55
|
+
self.patterns = tuple(patterns)
|
|
56
|
+
self.min_surface = min_surface
|
|
57
|
+
|
|
58
|
+
def _state(self, findings, examined_n):
|
|
59
|
+
if findings:
|
|
60
|
+
return State.FAIL
|
|
61
|
+
if examined_n < self.min_surface:
|
|
62
|
+
return State.VACUOUS
|
|
63
|
+
return State.PASS
|
|
64
|
+
|
|
65
|
+
def check(self, ws):
|
|
66
|
+
tracked = ws.tracked_files()
|
|
67
|
+
cached = ws.view == "index"
|
|
68
|
+
head = ws.view == "HEAD"
|
|
69
|
+
findings = []
|
|
70
|
+
for pattern in self.patterns:
|
|
71
|
+
rx = re.compile(pattern)
|
|
72
|
+
for path, line, text in ws.git_grep(pattern, cached=cached, head=head):
|
|
73
|
+
anchor = " ".join(text.split())
|
|
74
|
+
findings.append(
|
|
75
|
+
Finding(self.id, path, anchor, line=line,
|
|
76
|
+
message=f"content: {anchor}")
|
|
77
|
+
)
|
|
78
|
+
for path in tracked:
|
|
79
|
+
if rx.search(path):
|
|
80
|
+
findings.append(
|
|
81
|
+
Finding(self.id, path, path, message=f"filename: {path}")
|
|
82
|
+
)
|
|
83
|
+
for field, value in ws.commit_identity().items():
|
|
84
|
+
if rx.search(str(value)):
|
|
85
|
+
anchor = f"{field}:{value}"
|
|
86
|
+
findings.append(
|
|
87
|
+
Finding(self.id, "<commit>", anchor,
|
|
88
|
+
message=f"commit {anchor}")
|
|
89
|
+
)
|
|
90
|
+
examined_n = len(tracked) + 1
|
|
91
|
+
return Result(
|
|
92
|
+
self.id, self._state(findings, examined_n),
|
|
93
|
+
examined_n=examined_n, skipped_n=ws.skipped_n,
|
|
94
|
+
unparseable_n=0, findings=findings,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def plants(self, ws):
|
|
98
|
+
for pattern in self.patterns:
|
|
99
|
+
sample = _sample_for(pattern)
|
|
100
|
+
|
|
101
|
+
content_path = "planted/content.py"
|
|
102
|
+
yield Plant(
|
|
103
|
+
label=f"content:{pattern}",
|
|
104
|
+
planted_ws=FakeWorkspace(files={content_path: sample + "\n"}),
|
|
105
|
+
expected=(self.id, content_path, sample),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
fname = f"planted/{sample}_helper.py"
|
|
109
|
+
yield Plant(
|
|
110
|
+
label=f"filename:{pattern}",
|
|
111
|
+
planted_ws=FakeWorkspace(files={fname: "x = 1\n"}),
|
|
112
|
+
expected=(self.id, fname, fname),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
email = sample + "@example.test"
|
|
116
|
+
yield Plant(
|
|
117
|
+
label=f"commit:{pattern}",
|
|
118
|
+
planted_ws=FakeWorkspace(
|
|
119
|
+
files={"ok.py": "x = 1\n"},
|
|
120
|
+
identity={"name": "Dev", "email": email,
|
|
121
|
+
"subject": "init", "body": ""},
|
|
122
|
+
),
|
|
123
|
+
expected=(self.id, "<commit>", f"email:{email}"),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def fixture(self, kit):
|
|
127
|
+
return FakeWorkspace(
|
|
128
|
+
files={"clean.py": "x = 1\n"},
|
|
129
|
+
identity={"name": "Dev", "email": "dev@example.test",
|
|
130
|
+
"subject": "init", "body": ""},
|
|
131
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""No-hash-named-test check."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from ratch.check import Plant
|
|
7
|
+
from ratch.checks import is_test_py
|
|
8
|
+
from ratch.result import Finding, Result, State
|
|
9
|
+
from ratch.testing import FakeWorkspace
|
|
10
|
+
|
|
11
|
+
_HEX_STEM = re.compile(r"_[a-f0-9]{6,8}$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _stem(path):
|
|
15
|
+
return path.rsplit("/", 1)[-1].removesuffix(".py")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NoHashNamedTest:
|
|
19
|
+
"""Forbid opaque hex suffixes on test filenames.
|
|
20
|
+
|
|
21
|
+
Rule:
|
|
22
|
+
No tracked tests/*.py stem may end in underscore plus 6 to 8
|
|
23
|
+
hex digits.
|
|
24
|
+
|
|
25
|
+
Why:
|
|
26
|
+
A truncated hash in a filename is opaque once the commit is
|
|
27
|
+
forgotten; a readable stem stays reviewable.
|
|
28
|
+
|
|
29
|
+
Proven in:
|
|
30
|
+
internal/pillar-1-teeth.md
|
|
31
|
+
|
|
32
|
+
Not this:
|
|
33
|
+
Not a ban on hex elsewhere in the path. Not a check on non-test
|
|
34
|
+
modules.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
id = "no-hash-named-test"
|
|
38
|
+
tier = "A"
|
|
39
|
+
kind = "gate"
|
|
40
|
+
scope = "global"
|
|
41
|
+
proven_in = ("internal/pillar-1-teeth.md",)
|
|
42
|
+
confidence = "inferred"
|
|
43
|
+
tolerates_unparseable = False
|
|
44
|
+
|
|
45
|
+
def __init__(self, min_surface=1):
|
|
46
|
+
if min_surface < 1:
|
|
47
|
+
raise ValueError("min_surface must be >= 1")
|
|
48
|
+
self.min_surface = min_surface
|
|
49
|
+
|
|
50
|
+
def _state(self, findings, examined_n):
|
|
51
|
+
if findings:
|
|
52
|
+
return State.FAIL
|
|
53
|
+
if examined_n < self.min_surface:
|
|
54
|
+
return State.VACUOUS
|
|
55
|
+
return State.PASS
|
|
56
|
+
|
|
57
|
+
def check(self, ws):
|
|
58
|
+
findings = []
|
|
59
|
+
examined_n = 0
|
|
60
|
+
for path in ws.tracked_files():
|
|
61
|
+
if not is_test_py(path):
|
|
62
|
+
continue
|
|
63
|
+
examined_n += 1
|
|
64
|
+
if _HEX_STEM.search(_stem(path)):
|
|
65
|
+
findings.append(
|
|
66
|
+
Finding(self.id, path, path,
|
|
67
|
+
message=f"hash-named test: {path}")
|
|
68
|
+
)
|
|
69
|
+
return Result(
|
|
70
|
+
self.id, self._state(findings, examined_n),
|
|
71
|
+
examined_n=examined_n, skipped_n=ws.skipped_n, findings=findings,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def plants(self, ws):
|
|
75
|
+
path = "tests/test_foo_a1b2c3d4.py"
|
|
76
|
+
yield Plant(
|
|
77
|
+
label="hex-stem",
|
|
78
|
+
planted_ws=FakeWorkspace(files={path: "x = 1\n"}),
|
|
79
|
+
expected=(self.id, path, path),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def fixture(self, kit):
|
|
83
|
+
return FakeWorkspace(files={"tests/test_foo.py": "x = 1\n"})
|