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.
@@ -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"})
@@ -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"})