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
ratch/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""ratch — a ratchet for repository invariants.
|
|
2
|
+
|
|
3
|
+
Decision: code is the single source of truth (SSOT); docs are pointers,
|
|
4
|
+
generated, or signed. Rejected: a docs/ tree. Because: a copied fact rots.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ratch.result import (
|
|
8
|
+
EXIT,
|
|
9
|
+
PRECEDENCE,
|
|
10
|
+
Finding,
|
|
11
|
+
MeasuredValue,
|
|
12
|
+
MState,
|
|
13
|
+
Result,
|
|
14
|
+
State,
|
|
15
|
+
run_exit_code,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"EXIT",
|
|
20
|
+
"PRECEDENCE",
|
|
21
|
+
"Finding",
|
|
22
|
+
"MeasuredValue",
|
|
23
|
+
"MState",
|
|
24
|
+
"Result",
|
|
25
|
+
"State",
|
|
26
|
+
"run_exit_code",
|
|
27
|
+
]
|
ratch/__main__.py
ADDED
ratch/check.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""The Check contract: the shape every rule instance must present.
|
|
2
|
+
|
|
3
|
+
A check is configured by instantiation — constructor arguments on the
|
|
4
|
+
instance, never a config file. The runtime-checkable Protocol lets the
|
|
5
|
+
runner accept any object that exposes this shape, keeping rules and the
|
|
6
|
+
engine decoupled.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
from ratch.result import Result
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
# TYPE_CHECKING-only: resolves the Workspace forward reference for
|
|
19
|
+
# static type checkers; not imported at runtime (no ratch code calls
|
|
20
|
+
# get_type_hints on these annotations, and this avoids a runtime
|
|
21
|
+
# import).
|
|
22
|
+
from ratch.workspace import Workspace
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Plant:
|
|
27
|
+
label: str
|
|
28
|
+
planted_ws: object
|
|
29
|
+
expected: tuple[str, str, str]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@runtime_checkable
|
|
33
|
+
class Check(Protocol):
|
|
34
|
+
id: str
|
|
35
|
+
tier: str
|
|
36
|
+
kind: str
|
|
37
|
+
scope: str
|
|
38
|
+
proven_in: tuple[str, ...]
|
|
39
|
+
confidence: str
|
|
40
|
+
tolerates_unparseable: bool
|
|
41
|
+
|
|
42
|
+
def check(self, ws) -> Result: ...
|
|
43
|
+
|
|
44
|
+
def plants(self, ws) -> Iterable[Plant]: ...
|
|
45
|
+
|
|
46
|
+
def fixture(self, kit) -> Workspace: ...
|
ratch/checks/__init__.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""No-AI-signatures check."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from subprocess import CompletedProcess
|
|
6
|
+
|
|
7
|
+
from ratch.check import Plant
|
|
8
|
+
from ratch.result import Finding, Result, State
|
|
9
|
+
from ratch.testing import FakeWorkspace
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _log_text(commits):
|
|
13
|
+
"""Render commits exactly as ws.git_log(fmt=...) would: each commit is
|
|
14
|
+
``sha\\0an\\0ae\\0cn\\0ce\\0body\\0`` and git joins records with ``\\n``."""
|
|
15
|
+
recs = ["\x00".join(c) + "\x00" for c in commits]
|
|
16
|
+
return "\n".join(recs) + "\n"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NoAiSignatures:
|
|
20
|
+
"""Forbid AI-attribution shapes anywhere in the full commit history.
|
|
21
|
+
|
|
22
|
+
Rule:
|
|
23
|
+
No commit message or committer/author identity, across the entire
|
|
24
|
+
reachable history, may match any configured attribution shape.
|
|
25
|
+
|
|
26
|
+
Why:
|
|
27
|
+
An AI attribution trailer or bot identity is a provenance leak that
|
|
28
|
+
survives squash and rebase; only a scan of the whole history proves
|
|
29
|
+
its absence, and a shallow clone that hides history must not read as
|
|
30
|
+
clean.
|
|
31
|
+
|
|
32
|
+
Proven in:
|
|
33
|
+
agent-runner/tests/invariants/test_no_ai_signatures.py
|
|
34
|
+
|
|
35
|
+
Not this:
|
|
36
|
+
Not a judgment on who or what wrote the code. Only fixed attribution
|
|
37
|
+
SHAPES are rejected; authorship, naming, and content are untouched.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
id = "no-ai-signatures"
|
|
41
|
+
tier = "A"
|
|
42
|
+
kind = "gate"
|
|
43
|
+
scope = "global"
|
|
44
|
+
proven_in = ("agent-runner/tests/invariants/test_no_ai_signatures.py",)
|
|
45
|
+
confidence = "breadth"
|
|
46
|
+
tolerates_unparseable = False
|
|
47
|
+
|
|
48
|
+
def __init__(self, patterns=(
|
|
49
|
+
r"Co-Authored-By:",
|
|
50
|
+
r"Generated with",
|
|
51
|
+
r"Assisted by",
|
|
52
|
+
"\N{ROBOT FACE}",
|
|
53
|
+
r"[\w.+-]+@users\.noreply\.[\w.]+",
|
|
54
|
+
), min_surface=1):
|
|
55
|
+
if min_surface < 1:
|
|
56
|
+
raise ValueError("min_surface must be >= 1")
|
|
57
|
+
self.patterns = tuple(patterns)
|
|
58
|
+
self.min_surface = min_surface
|
|
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 _is_shallow(self, ws):
|
|
68
|
+
proc = ws.run(["git", "rev-parse", "--is-shallow-repository"])
|
|
69
|
+
return proc.stdout.strip() == "true"
|
|
70
|
+
|
|
71
|
+
def check(self, ws):
|
|
72
|
+
findings = []
|
|
73
|
+
examined_n = 0
|
|
74
|
+
if not self._is_shallow(ws):
|
|
75
|
+
raw = ws.git_log(
|
|
76
|
+
rng=None,
|
|
77
|
+
fmt="%H%x00%an%x00%ae%x00%cn%x00%ce%x00%B%x00",
|
|
78
|
+
)
|
|
79
|
+
fields = raw.split("\x00")
|
|
80
|
+
for i in range(0, len(fields) - 5, 6):
|
|
81
|
+
sha = fields[i].strip()
|
|
82
|
+
an, ae, cn, ce, body = fields[i + 1:i + 6]
|
|
83
|
+
examined_n += 1
|
|
84
|
+
haystack = "\n".join([an, ae, cn, ce, body])
|
|
85
|
+
for pattern in self.patterns:
|
|
86
|
+
# IGNORECASE so GitHub's canonical `Co-authored-by:` casing
|
|
87
|
+
# (and `generated with` / `assisted by`) is caught too
|
|
88
|
+
# (resolves Fable finding: default missed lowercase trailers).
|
|
89
|
+
if re.search(pattern, haystack, re.IGNORECASE):
|
|
90
|
+
anchor = f"{sha[:9]}:{pattern}"
|
|
91
|
+
findings.append(
|
|
92
|
+
Finding(self.id, "<commit>", anchor,
|
|
93
|
+
message=f"provenance leak {anchor}")
|
|
94
|
+
)
|
|
95
|
+
return Result(
|
|
96
|
+
self.id, self._state(findings, examined_n),
|
|
97
|
+
examined_n=examined_n, skipped_n=ws.skipped_n,
|
|
98
|
+
unparseable_n=0, findings=findings,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def plants(self, ws):
|
|
102
|
+
sha = "0f1e2d3c4b5a6978"
|
|
103
|
+
bot = "".join(["c", "l", "a", "u", "d", "e"]) # vendor token, never typed whole
|
|
104
|
+
body = f"feat: thing\n\nCo-Authored-By: {bot} <{bot}-bot@example.test>\n"
|
|
105
|
+
planted = FakeWorkspace(
|
|
106
|
+
files={"ok.py": "x = 1\n"},
|
|
107
|
+
run_table={("git", "rev-parse", "--is-shallow-repository"):
|
|
108
|
+
CompletedProcess([], 0, stdout="false\n")},
|
|
109
|
+
git_log_text=_log_text([(sha, "Dev", "dev@example.test",
|
|
110
|
+
"Dev", "dev@example.test", body)]),
|
|
111
|
+
)
|
|
112
|
+
yield Plant(
|
|
113
|
+
label="commit:co-authored-by",
|
|
114
|
+
planted_ws=planted, # type: ignore[arg-type]
|
|
115
|
+
expected=(self.id, "<commit>", f"{sha[:9]}:Co-Authored-By:"),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def fixture(self, kit):
|
|
119
|
+
return FakeWorkspace(
|
|
120
|
+
files={"clean.py": "x = 1\n"},
|
|
121
|
+
run_table={("git", "rev-parse", "--is-shallow-repository"):
|
|
122
|
+
CompletedProcess([], 0, stdout="false\n")},
|
|
123
|
+
git_log_text=_log_text([("feedface12345678", "Dev", "dev@example.test",
|
|
124
|
+
"Dev", "dev@example.test", "feat: initial\n")]),
|
|
125
|
+
)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""BDD test-naming check."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ast
|
|
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
|
+
|
|
12
|
+
def _is_bdd_name(name):
|
|
13
|
+
if name.startswith("test_"):
|
|
14
|
+
return False
|
|
15
|
+
if "_should_" not in name or "_when_" not in name:
|
|
16
|
+
return False
|
|
17
|
+
return "or" not in name.split("_")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BddTestConventions:
|
|
21
|
+
"""Require BDD test names in tracked test modules.
|
|
22
|
+
|
|
23
|
+
Rule:
|
|
24
|
+
In tracked tests/*.py, every top-level def whose name does not
|
|
25
|
+
start with underscore must contain _should_ and _when_, must not
|
|
26
|
+
start with test_, and must not use or as a snake_case segment.
|
|
27
|
+
|
|
28
|
+
Why:
|
|
29
|
+
A test name that reads as a sentence is the readable spec; a
|
|
30
|
+
machine can prove the shape so pytest collection and review
|
|
31
|
+
share one convention.
|
|
32
|
+
|
|
33
|
+
Proven in:
|
|
34
|
+
internal/pillar-1-teeth.md
|
|
35
|
+
|
|
36
|
+
Not this:
|
|
37
|
+
Not a requirement on private helpers. Not a ban on tokens that
|
|
38
|
+
merely contain the letters o-r (error, format). Not a change to
|
|
39
|
+
pytest python_functions.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
id = "bdd-test-conventions"
|
|
43
|
+
tier = "A"
|
|
44
|
+
kind = "gate"
|
|
45
|
+
scope = "global"
|
|
46
|
+
proven_in = ("internal/pillar-1-teeth.md",)
|
|
47
|
+
confidence = "inferred"
|
|
48
|
+
tolerates_unparseable = False
|
|
49
|
+
|
|
50
|
+
def __init__(self, min_surface=1):
|
|
51
|
+
if min_surface < 1:
|
|
52
|
+
raise ValueError("min_surface must be >= 1")
|
|
53
|
+
self.min_surface = min_surface
|
|
54
|
+
|
|
55
|
+
def _state(self, findings, examined_n):
|
|
56
|
+
if findings:
|
|
57
|
+
return State.FAIL
|
|
58
|
+
if examined_n < self.min_surface:
|
|
59
|
+
return State.VACUOUS
|
|
60
|
+
return State.PASS
|
|
61
|
+
|
|
62
|
+
def check(self, ws):
|
|
63
|
+
findings = []
|
|
64
|
+
examined_n = 0
|
|
65
|
+
for path in ws.tracked_files():
|
|
66
|
+
if not is_test_py(path):
|
|
67
|
+
continue
|
|
68
|
+
examined_n += 1
|
|
69
|
+
tree = ws.ast(path)
|
|
70
|
+
if tree is None:
|
|
71
|
+
continue
|
|
72
|
+
for node in tree.body:
|
|
73
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
74
|
+
continue
|
|
75
|
+
name = node.name
|
|
76
|
+
if name.startswith("_"):
|
|
77
|
+
continue
|
|
78
|
+
if not _is_bdd_name(name):
|
|
79
|
+
findings.append(
|
|
80
|
+
Finding(self.id, path, name,
|
|
81
|
+
message=f"bdd name: {name}")
|
|
82
|
+
)
|
|
83
|
+
return Result(
|
|
84
|
+
self.id, self._state(findings, examined_n),
|
|
85
|
+
examined_n=examined_n, skipped_n=ws.skipped_n,
|
|
86
|
+
unparseable_n=ws.unparseable_n, findings=findings,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def plants(self, ws):
|
|
90
|
+
yield Plant(
|
|
91
|
+
label="test-prefix",
|
|
92
|
+
planted_ws=FakeWorkspace(
|
|
93
|
+
files={"tests/t.py": "def test_foo():\n pass\n"}
|
|
94
|
+
),
|
|
95
|
+
expected=(self.id, "tests/t.py", "test_foo"),
|
|
96
|
+
)
|
|
97
|
+
yield Plant(
|
|
98
|
+
label="or-segment",
|
|
99
|
+
planted_ws=FakeWorkspace(
|
|
100
|
+
files={"tests/t.py": "def foo_should_pass_or_fail_when_x():\n pass\n"}
|
|
101
|
+
),
|
|
102
|
+
expected=(self.id, "tests/t.py", "foo_should_pass_or_fail_when_x"),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def fixture(self, kit):
|
|
106
|
+
return FakeWorkspace(
|
|
107
|
+
files={"tests/t.py": "def foo_should_bar_when_baz():\n pass\n"}
|
|
108
|
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""catalog-size eye."""
|
|
2
|
+
from ratch.check import Plant
|
|
3
|
+
from ratch.result import MeasuredValue, MState, Result, State
|
|
4
|
+
from ratch.testing import FakeWorkspace
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CatalogSize:
|
|
8
|
+
"""Report how many checks are installed, without gating.
|
|
9
|
+
|
|
10
|
+
Rule:
|
|
11
|
+
The installed ratch.checks catalog size is reported as a
|
|
12
|
+
measured value.
|
|
13
|
+
|
|
14
|
+
Why:
|
|
15
|
+
A gate that failed the run because the catalog was empty would
|
|
16
|
+
confuse absence of plugins with a rule break; an eye only
|
|
17
|
+
measures.
|
|
18
|
+
|
|
19
|
+
Proven in:
|
|
20
|
+
internal/specs/2026-09-17-ratchet-v1-spine-design.md
|
|
21
|
+
|
|
22
|
+
Not this:
|
|
23
|
+
Not a gate. VACUOUS on an empty catalog does not fail the run.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
id = "catalog-size"
|
|
27
|
+
tier = "A"
|
|
28
|
+
kind = "eye"
|
|
29
|
+
scope = "global"
|
|
30
|
+
proven_in = ("internal/specs/2026-09-17-ratchet-v1-spine-design.md",)
|
|
31
|
+
confidence = "inferred"
|
|
32
|
+
tolerates_unparseable = False
|
|
33
|
+
|
|
34
|
+
def __init__(self, min_surface=1):
|
|
35
|
+
if min_surface < 1:
|
|
36
|
+
raise ValueError("min_surface must be >= 1")
|
|
37
|
+
self.min_surface = min_surface
|
|
38
|
+
|
|
39
|
+
def check(self, ws):
|
|
40
|
+
n = len(ws.plugin_classes())
|
|
41
|
+
measured = MeasuredValue(
|
|
42
|
+
value=n, state=MState.MEASURED,
|
|
43
|
+
source="plugin_classes", measured_at=ws.now(),
|
|
44
|
+
)
|
|
45
|
+
if n < self.min_surface:
|
|
46
|
+
state = State.VACUOUS
|
|
47
|
+
else:
|
|
48
|
+
state = State.PASS
|
|
49
|
+
return Result(
|
|
50
|
+
self.id, state, examined_n=n, findings=[], measured=measured,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def plants(self, ws):
|
|
54
|
+
yield Plant(
|
|
55
|
+
label="empty-catalog",
|
|
56
|
+
planted_ws=FakeWorkspace(files={"a.py": "x = 1\n"}, plugin_classes={}),
|
|
57
|
+
expected=(self.id, "catalog", "0"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def fixture(self, kit):
|
|
61
|
+
return FakeWorkspace(
|
|
62
|
+
files={"a.py": "x = 1\n"},
|
|
63
|
+
plugin_classes={"no-forbidden-literal": object},
|
|
64
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""No-circular-import check."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
|
|
8
|
+
from ratch.check import Plant
|
|
9
|
+
from ratch.result import Finding, Result, State
|
|
10
|
+
from ratch.workspace import Workspace
|
|
11
|
+
|
|
12
|
+
_CYCLE_SIGNATURES = ("partially initialized module", "circular import")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _strip_trailing_path(line):
|
|
16
|
+
"""Drop CPython's trailing ``(/abs/path/mod.py)`` from an ImportError line.
|
|
17
|
+
|
|
18
|
+
That path varies by machine and tmp dir; removing it makes the anchor
|
|
19
|
+
deterministic so a planted cycle yields a stable finding identity.
|
|
20
|
+
"""
|
|
21
|
+
if line.endswith(")"):
|
|
22
|
+
open_at = line.rfind(" (")
|
|
23
|
+
if open_at != -1 and "/" in line[open_at:]:
|
|
24
|
+
return line[:open_at].rstrip()
|
|
25
|
+
return line
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _cycle_line(stderr):
|
|
29
|
+
lines = [ln.strip() for ln in stderr.splitlines() if ln.strip()]
|
|
30
|
+
for line in reversed(lines):
|
|
31
|
+
if any(sig in line for sig in _CYCLE_SIGNATURES):
|
|
32
|
+
return _strip_trailing_path(line)
|
|
33
|
+
return lines[-1] if lines else ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _write_package(root, name, modules):
|
|
37
|
+
pkg = pathlib.Path(root) / name
|
|
38
|
+
pkg.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
for mod_name, text in modules.items():
|
|
40
|
+
(pkg / mod_name).write_text(text)
|
|
41
|
+
return pkg
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NoCircularImport:
|
|
45
|
+
"""Forbid a package that cannot be imported because of an import cycle.
|
|
46
|
+
|
|
47
|
+
Rule:
|
|
48
|
+
Importing the configured top-level package in a fresh interpreter
|
|
49
|
+
must not raise a CPython circular-import error.
|
|
50
|
+
|
|
51
|
+
Why:
|
|
52
|
+
A cycle imports clean in one entry order and explodes in another, so
|
|
53
|
+
it hides until a production import path hits the bad order; a
|
|
54
|
+
subprocess import proves the package loads on every commit, where an
|
|
55
|
+
in-process import would be masked by an already-populated sys.modules.
|
|
56
|
+
|
|
57
|
+
Proven in:
|
|
58
|
+
argus-gateway/tests/test_invariant_no_circular_import.py
|
|
59
|
+
|
|
60
|
+
Not this:
|
|
61
|
+
Not a static import-graph analysis and not a style rule. Only an
|
|
62
|
+
actual interpreter cycle FAILs; a missing dependency or any other
|
|
63
|
+
import error is reported as ERROR (could-not-measure), never FAIL.
|
|
64
|
+
The subprocess import runs against the checked-out worktree, not
|
|
65
|
+
the index, because Workspace.run always uses repo_root as cwd.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
id = "no-circular-import"
|
|
69
|
+
tier = "A"
|
|
70
|
+
kind = "gate"
|
|
71
|
+
scope = "global"
|
|
72
|
+
proven_in = ("argus-gateway/tests/test_invariant_no_circular_import.py",)
|
|
73
|
+
confidence = "portable-with-config"
|
|
74
|
+
tolerates_unparseable = False
|
|
75
|
+
min_surface = 1
|
|
76
|
+
|
|
77
|
+
def __init__(self, package, interpreter=None):
|
|
78
|
+
self.package = package
|
|
79
|
+
self.interpreter = interpreter
|
|
80
|
+
|
|
81
|
+
def check(self, ws):
|
|
82
|
+
interp = self.interpreter or sys.executable
|
|
83
|
+
proc = ws.run([interp, "-c", f"import {self.package}"])
|
|
84
|
+
if proc.returncode == 0:
|
|
85
|
+
return Result(self.id, State.PASS, examined_n=1)
|
|
86
|
+
stderr = proc.stderr or ""
|
|
87
|
+
if any(sig in stderr for sig in _CYCLE_SIGNATURES):
|
|
88
|
+
anchor = _cycle_line(stderr)
|
|
89
|
+
return Result(
|
|
90
|
+
self.id, State.FAIL, examined_n=1,
|
|
91
|
+
findings=[Finding(self.id, self.package, anchor,
|
|
92
|
+
message=f"circular import: {anchor}")],
|
|
93
|
+
)
|
|
94
|
+
return Result(self.id, State.ERROR, examined_n=0)
|
|
95
|
+
|
|
96
|
+
def plants(self, ws):
|
|
97
|
+
root = pathlib.Path(tempfile.mkdtemp(prefix="ratch-circ-"))
|
|
98
|
+
_write_package(root, self.package, {
|
|
99
|
+
"__init__.py": f"from {self.package} import a\n",
|
|
100
|
+
"a.py": f"from {self.package}.b import beta\n\nalpha = 1\n",
|
|
101
|
+
"b.py": f"from {self.package}.a import alpha\n\nbeta = 2\n",
|
|
102
|
+
})
|
|
103
|
+
planted = Workspace(root)
|
|
104
|
+
probe = NoCircularImport(self.package, interpreter=self.interpreter)
|
|
105
|
+
probed = probe.check(planted)
|
|
106
|
+
if probed.state is not State.FAIL or not probed.findings:
|
|
107
|
+
raise AssertionError(
|
|
108
|
+
f"{self.id}: probe could not reproduce the planted cycle "
|
|
109
|
+
f"(got {probed.state})")
|
|
110
|
+
finding = probed.findings[0]
|
|
111
|
+
yield Plant(
|
|
112
|
+
label=f"circular:{self.package}",
|
|
113
|
+
planted_ws=planted,
|
|
114
|
+
expected=(self.id, self.package, finding.anchor),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def fixture(self, kit):
|
|
118
|
+
root = pathlib.Path(tempfile.mkdtemp(prefix="ratch-circ-clean-"))
|
|
119
|
+
_write_package(root, self.package, {"__init__.py": "value = 42\n"})
|
|
120
|
+
return Workspace(root)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""commit-heatmap eye, lifted from argus-gateway commit_heatmap hour buckets."""
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from ratch.check import Plant
|
|
5
|
+
from ratch.result import MeasuredValue, MState, Result, State
|
|
6
|
+
from ratch.testing import FakeWorkspace
|
|
7
|
+
|
|
8
|
+
_MAX_COMMITS = 500
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _hour_counts(text):
|
|
12
|
+
hours = [0] * 24
|
|
13
|
+
n = 0
|
|
14
|
+
for line in text.splitlines():
|
|
15
|
+
line = line.strip()
|
|
16
|
+
if not line:
|
|
17
|
+
continue
|
|
18
|
+
try:
|
|
19
|
+
ts = int(line)
|
|
20
|
+
except ValueError:
|
|
21
|
+
continue
|
|
22
|
+
hours[datetime.fromtimestamp(ts).hour] += 1
|
|
23
|
+
n += 1
|
|
24
|
+
if n >= _MAX_COMMITS:
|
|
25
|
+
break
|
|
26
|
+
return hours, n
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CommitHeatmap:
|
|
30
|
+
"""Report the busiest hour-of-day in recent git history.
|
|
31
|
+
|
|
32
|
+
Rule:
|
|
33
|
+
Recent commit timestamps are bucketed by local hour of day and
|
|
34
|
+
the peak bucket is reported as a measured value.
|
|
35
|
+
|
|
36
|
+
Why:
|
|
37
|
+
A commit heatmap is a lens on when the repo actually moves;
|
|
38
|
+
failing the run because Tuesday was quiet would turn a dashboard
|
|
39
|
+
into a gate. argus-gateway's commit_heatmap is the structure x
|
|
40
|
+
time tool; this eye keeps only the hour histogram.
|
|
41
|
+
|
|
42
|
+
Proven in:
|
|
43
|
+
argus-gateway/tools/commit_heatmap.py
|
|
44
|
+
|
|
45
|
+
Not this:
|
|
46
|
+
Not the ASCII directory heatmap. Not role filters, --exclude, or
|
|
47
|
+
depth. Not a gate.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
id = "commit-heatmap"
|
|
51
|
+
tier = "A"
|
|
52
|
+
kind = "eye"
|
|
53
|
+
scope = "global"
|
|
54
|
+
proven_in = ("argus-gateway/tools/commit_heatmap.py",)
|
|
55
|
+
confidence = "depth-once"
|
|
56
|
+
tolerates_unparseable = False
|
|
57
|
+
|
|
58
|
+
def __init__(self, min_surface=1):
|
|
59
|
+
if min_surface < 1:
|
|
60
|
+
raise ValueError("min_surface must be >= 1")
|
|
61
|
+
self.min_surface = min_surface
|
|
62
|
+
|
|
63
|
+
def check(self, ws):
|
|
64
|
+
hours, n = _hour_counts(ws.git_log(fmt="%at"))
|
|
65
|
+
if n < self.min_surface:
|
|
66
|
+
return Result(self.id, State.VACUOUS, examined_n=n, findings=[])
|
|
67
|
+
peak_hour = max(range(24), key=lambda hour: hours[hour])
|
|
68
|
+
measured = MeasuredValue(
|
|
69
|
+
value=f"{peak_hour}h:{hours[peak_hour]}/{n}",
|
|
70
|
+
state=MState.MEASURED,
|
|
71
|
+
source="git_log:%at",
|
|
72
|
+
measured_at=ws.now(),
|
|
73
|
+
)
|
|
74
|
+
return Result(
|
|
75
|
+
self.id, State.PASS, examined_n=n, findings=[], measured=measured,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def plants(self, ws):
|
|
79
|
+
yield Plant(
|
|
80
|
+
label="empty-log",
|
|
81
|
+
planted_ws=FakeWorkspace(files={"a.py": "x = 1\n"}, git_log_text=""),
|
|
82
|
+
expected=(self.id, "git_log", "0"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def fixture(self, kit):
|
|
86
|
+
return FakeWorkspace(
|
|
87
|
+
files={"a.py": "x = 1\n"},
|
|
88
|
+
git_log_text="1700000000\n1700003600\n",
|
|
89
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""No-conflict-markers 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
|
+
_START = "<" * 7
|
|
9
|
+
_END = ">" * 7
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NoConflictMarkers:
|
|
13
|
+
"""Forbid leftover git conflict markers in tracked files.
|
|
14
|
+
|
|
15
|
+
Rule:
|
|
16
|
+
No tracked file may contain a line whose stripped prefix is the
|
|
17
|
+
seven-character git conflict start or end marker.
|
|
18
|
+
|
|
19
|
+
Why:
|
|
20
|
+
A leftover conflict marker is an unfinished merge that no
|
|
21
|
+
reviewer reliably catches by eye; a machine can prove absence
|
|
22
|
+
on every commit.
|
|
23
|
+
|
|
24
|
+
Proven in:
|
|
25
|
+
internal/pillar-1-teeth.md
|
|
26
|
+
|
|
27
|
+
Not this:
|
|
28
|
+
Not a Markdown rule. A setext underline of equals signs is
|
|
29
|
+
allowed; only the git start and end markers are rejected.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
id = "no-conflict-markers"
|
|
33
|
+
tier = "A"
|
|
34
|
+
kind = "gate"
|
|
35
|
+
scope = "global"
|
|
36
|
+
proven_in = ("internal/pillar-1-teeth.md",)
|
|
37
|
+
confidence = "inferred"
|
|
38
|
+
tolerates_unparseable = False
|
|
39
|
+
|
|
40
|
+
def __init__(self, min_surface=1):
|
|
41
|
+
if min_surface < 1:
|
|
42
|
+
raise ValueError("min_surface must be >= 1")
|
|
43
|
+
self.min_surface = min_surface
|
|
44
|
+
|
|
45
|
+
def _state(self, findings, examined_n):
|
|
46
|
+
if findings:
|
|
47
|
+
return State.FAIL
|
|
48
|
+
if examined_n < self.min_surface:
|
|
49
|
+
return State.VACUOUS
|
|
50
|
+
return State.PASS
|
|
51
|
+
|
|
52
|
+
def check(self, ws):
|
|
53
|
+
findings = []
|
|
54
|
+
examined_n = 0
|
|
55
|
+
for path in ws.tracked_files():
|
|
56
|
+
examined_n += 1
|
|
57
|
+
for line in ws.read(path).splitlines():
|
|
58
|
+
stripped = line.lstrip()
|
|
59
|
+
if stripped.startswith((_START, _END)):
|
|
60
|
+
anchor = " ".join(line.split())
|
|
61
|
+
findings.append(
|
|
62
|
+
Finding(self.id, path, anchor,
|
|
63
|
+
message=f"conflict marker: {anchor}")
|
|
64
|
+
)
|
|
65
|
+
return Result(
|
|
66
|
+
self.id, self._state(findings, examined_n),
|
|
67
|
+
examined_n=examined_n, skipped_n=ws.skipped_n, findings=findings,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def plants(self, ws):
|
|
71
|
+
line = _START + " HEAD"
|
|
72
|
+
yield Plant(
|
|
73
|
+
label="start-marker",
|
|
74
|
+
planted_ws=FakeWorkspace(files={"a.py": line + "\nx = 1\n"}),
|
|
75
|
+
expected=(self.id, "a.py", line),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def fixture(self, kit):
|
|
79
|
+
return FakeWorkspace(files={"a.py": "x = 1\n"})
|