ratch 0.1.2__tar.gz → 0.1.4__tar.gz
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-0.1.2/ratch.egg-info → ratch-0.1.4}/PKG-INFO +1 -1
- {ratch-0.1.2 → ratch-0.1.4}/README.md +1 -1
- {ratch-0.1.2 → ratch-0.1.4}/pyproject.toml +1 -1
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/ai_signatures.py +39 -13
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/bdd_conventions.py +18 -10
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/catalog_size.py +13 -6
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/commit_heatmap.py +22 -3
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/font_cdn.py +1 -1
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/injected_clock.py +64 -8
- {ratch-0.1.2 → ratch-0.1.4}/ratch/cli.py +4 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/registry.py +12 -12
- {ratch-0.1.2 → ratch-0.1.4}/ratch/runner.py +2 -0
- {ratch-0.1.2 → ratch-0.1.4/ratch.egg-info}/PKG-INFO +1 -1
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_ai_signatures.py +3 -4
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_bdd_conventions.py +22 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_catalog_size.py +1 -1
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_cli.py +14 -3
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_commit_heatmap.py +8 -1
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_font_cdn.py +2 -2
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_injected_clock.py +58 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_registry.py +3 -3
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_self_host.py +2 -2
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_self_host_blackbox.py +7 -1
- {ratch-0.1.2 → ratch-0.1.4}/LICENSE +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/__init__.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/__main__.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/check.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/__init__.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/autoclose.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/circular_import.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/conflict_markers.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/doc_cli.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/doc_counts.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/docs_render.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/first_person.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/forbidden_literal.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/hash_named_test.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/internal_refs.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/loc_cap.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/manifest_purity.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/plugin_registry.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/pytest_skip.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/reassurance.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/repo_root_ssot.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/todo_issue.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/checks/vacuous_assert.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/pytest_plugin.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/reporter.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/result.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/testing.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch/workspace.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch.egg-info/SOURCES.txt +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch.egg-info/dependency_links.txt +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch.egg-info/entry_points.txt +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch.egg-info/requires.txt +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/ratch.egg-info/top_level.txt +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/setup.cfg +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_autoclose.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_check.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_circular_import.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_conflict_markers.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_doc_cli.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_doc_counts.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_docs_render.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_first_person.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_forbidden_literal.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_hash_named_test.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_internal_refs.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_loc_cap.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_manifest_purity.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_plugin_registry.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_pytest_seam.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_pytest_skip.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_reassurance.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_repo_root_ssot.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_reporter.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_result.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_runner.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_testing.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_todo_issue.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_vacuous_assert.py +0 -0
- {ratch-0.1.2 → ratch-0.1.4}/tests/test_workspace.py +0 -0
|
@@ -47,7 +47,7 @@ and CI install, run pytest, then `ratch check`.
|
|
|
47
47
|
- `doc-counts-match-ssot` — README `catalog_n` equals `discover()`
|
|
48
48
|
- `docs-equal-fresh-render` — the generated region matches a live render
|
|
49
49
|
- `tests-repo-root-ssot` — tests import one ROOT helper, not Path(__file__) walks
|
|
50
|
-
- `injected-clock` —
|
|
50
|
+
- `injected-clock` — stdlib time/datetime calls only if a Clock type exists
|
|
51
51
|
- `doc-cli-examples-valid` — documented ratch commands parse
|
|
52
52
|
- `no-autoclose-keywords-in-commits` — commit messages that would auto-close issues
|
|
53
53
|
|
|
@@ -53,11 +53,13 @@ class NoAiSignatures:
|
|
|
53
53
|
r"Assisted by",
|
|
54
54
|
"\N{ROBOT FACE}",
|
|
55
55
|
r"[\w.+-]+@users\.noreply\.[\w.]+",
|
|
56
|
-
), min_surface=1):
|
|
56
|
+
), min_surface=1, sources=("log",), files=()):
|
|
57
57
|
if min_surface < 1:
|
|
58
58
|
raise ValueError("min_surface must be >= 1")
|
|
59
59
|
self.patterns = tuple(patterns)
|
|
60
60
|
self.min_surface = min_surface
|
|
61
|
+
self.sources = tuple(sources)
|
|
62
|
+
self.files = tuple(files)
|
|
61
63
|
|
|
62
64
|
def _state(self, findings, examined_n):
|
|
63
65
|
if findings:
|
|
@@ -70,12 +72,29 @@ class NoAiSignatures:
|
|
|
70
72
|
proc = ws.run(["git", "rev-parse", "--is-shallow-repository"])
|
|
71
73
|
return proc.stdout.strip() == "true"
|
|
72
74
|
|
|
75
|
+
def _scan(self, haystack, path, prefix, findings):
|
|
76
|
+
for pattern in self.patterns:
|
|
77
|
+
if re.search(pattern, haystack, re.IGNORECASE):
|
|
78
|
+
anchor = f"{prefix}:{pattern}"
|
|
79
|
+
findings.append(
|
|
80
|
+
Finding(self.id, path, anchor,
|
|
81
|
+
message=f"provenance leak {anchor}")
|
|
82
|
+
)
|
|
83
|
+
|
|
73
84
|
def check(self, ws):
|
|
74
85
|
findings = []
|
|
75
86
|
examined_n = 0
|
|
76
|
-
if
|
|
87
|
+
if self._is_shallow(ws):
|
|
88
|
+
findings.append(
|
|
89
|
+
Finding(self.id, "<git>", "shallow",
|
|
90
|
+
message="shallow clone; history is incomplete")
|
|
91
|
+
)
|
|
92
|
+
return Result(
|
|
93
|
+
self.id, State.FAIL, examined_n=1,
|
|
94
|
+
skipped_n=ws.skipped_n, findings=findings,
|
|
95
|
+
)
|
|
96
|
+
if "log" in self.sources:
|
|
77
97
|
raw = ws.git_log(
|
|
78
|
-
rng=None,
|
|
79
98
|
fmt="%H%x00%an%x00%ae%x00%cn%x00%ce%x00%B%x00",
|
|
80
99
|
)
|
|
81
100
|
fields = raw.split("\x00")
|
|
@@ -84,16 +103,23 @@ class NoAiSignatures:
|
|
|
84
103
|
an, ae, cn, ce, body = fields[i + 1:i + 6]
|
|
85
104
|
examined_n += 1
|
|
86
105
|
haystack = "\n".join([an, ae, cn, ce, body])
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
106
|
+
self._scan(haystack, "<commit>", sha[:9], findings)
|
|
107
|
+
if "tags" in self.sources:
|
|
108
|
+
proc = ws.run([
|
|
109
|
+
"git", "for-each-ref", "refs/tags",
|
|
110
|
+
"--format=%(refname)%x00%(contents)",
|
|
111
|
+
])
|
|
112
|
+
blob = proc.stdout or ""
|
|
113
|
+
if blob.strip():
|
|
114
|
+
examined_n += 1
|
|
115
|
+
self._scan(blob, "<tag>", "tag", findings)
|
|
116
|
+
if "files" in self.sources:
|
|
117
|
+
tracked = set(ws.tracked_files())
|
|
118
|
+
for path in self.files:
|
|
119
|
+
if path not in tracked:
|
|
120
|
+
continue
|
|
121
|
+
examined_n += 1
|
|
122
|
+
self._scan(ws.read(path), path, path, findings)
|
|
97
123
|
return Result(
|
|
98
124
|
self.id, self._state(findings, examined_n),
|
|
99
125
|
examined_n=examined_n, skipped_n=ws.skipped_n,
|
|
@@ -9,8 +9,10 @@ from ratch.result import Finding, Result, State
|
|
|
9
9
|
from ratch.testing import FakeWorkspace
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
def _is_bdd_name(name):
|
|
13
|
-
if name.startswith(
|
|
12
|
+
def _is_bdd_name(name, prefix):
|
|
13
|
+
if prefix and not name.startswith(prefix):
|
|
14
|
+
return None
|
|
15
|
+
if not prefix and name.startswith("test_"):
|
|
14
16
|
return False
|
|
15
17
|
if "_should_" not in name or "_when_" not in name:
|
|
16
18
|
return False
|
|
@@ -37,11 +39,12 @@ class BddTestConventions:
|
|
|
37
39
|
"""Require BDD test names in tracked test modules.
|
|
38
40
|
|
|
39
41
|
Rule:
|
|
40
|
-
In tracked tests/*.py,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
In tracked tests/*.py, selected top-level defs must contain
|
|
43
|
+
_should_ and _when_ and must not use or as a snake_case segment.
|
|
44
|
+
prefix="" (this repo) inspects every public def and forbids a
|
|
45
|
+
test_ prefix. prefix="test_" inspects only that prefix (helpers
|
|
46
|
+
are ignored). blank_blocks=3 requires two blank-line gaps;
|
|
47
|
+
blank_blocks=0 skips body shape. Labels are ignored.
|
|
45
48
|
|
|
46
49
|
Why:
|
|
47
50
|
A test name that reads as a sentence is the readable spec; a
|
|
@@ -67,10 +70,12 @@ class BddTestConventions:
|
|
|
67
70
|
confidence = "inferred"
|
|
68
71
|
tolerates_unparseable = False
|
|
69
72
|
|
|
70
|
-
def __init__(self, min_surface=1):
|
|
73
|
+
def __init__(self, min_surface=1, prefix="", blank_blocks=3):
|
|
71
74
|
if min_surface < 1:
|
|
72
75
|
raise ValueError("min_surface must be >= 1")
|
|
73
76
|
self.min_surface = min_surface
|
|
77
|
+
self.prefix = prefix
|
|
78
|
+
self.blank_blocks = blank_blocks
|
|
74
79
|
|
|
75
80
|
def _state(self, findings, examined_n):
|
|
76
81
|
if findings:
|
|
@@ -96,13 +101,16 @@ class BddTestConventions:
|
|
|
96
101
|
name = node.name
|
|
97
102
|
if name.startswith("_"):
|
|
98
103
|
continue
|
|
99
|
-
|
|
104
|
+
verdict = _is_bdd_name(name, self.prefix)
|
|
105
|
+
if verdict is None:
|
|
106
|
+
continue
|
|
107
|
+
if not verdict:
|
|
100
108
|
findings.append(
|
|
101
109
|
Finding(self.id, path, name,
|
|
102
110
|
message=f"bdd name: {name}")
|
|
103
111
|
)
|
|
104
112
|
continue
|
|
105
|
-
if _paragraph_n(node, lines) <
|
|
113
|
+
if self.blank_blocks and _paragraph_n(node, lines) < self.blank_blocks:
|
|
106
114
|
findings.append(
|
|
107
115
|
Finding(self.id, path, name,
|
|
108
116
|
message=f"bdd blank: {name}")
|
|
@@ -8,8 +8,8 @@ class CatalogSize:
|
|
|
8
8
|
"""Report how many checks are installed, without gating.
|
|
9
9
|
|
|
10
10
|
Rule:
|
|
11
|
-
|
|
12
|
-
measured value.
|
|
11
|
+
Reports enabled_n (manifest CHECKS) and catalog_n (entry
|
|
12
|
+
points) as a measured value.
|
|
13
13
|
|
|
14
14
|
Why:
|
|
15
15
|
A gate that failed the run because the catalog was empty would
|
|
@@ -38,17 +38,24 @@ class CatalogSize:
|
|
|
38
38
|
self.min_surface = min_surface
|
|
39
39
|
|
|
40
40
|
def check(self, ws):
|
|
41
|
-
|
|
41
|
+
catalog_n = len(ws.plugin_classes())
|
|
42
|
+
enabled_n = catalog_n
|
|
43
|
+
root = getattr(ws, "repo_root", None)
|
|
44
|
+
if root is not None:
|
|
45
|
+
from ratch.registry import load_manifest
|
|
46
|
+
enabled_n = len(load_manifest(root))
|
|
42
47
|
measured = MeasuredValue(
|
|
43
|
-
value=
|
|
48
|
+
value=f"enabled_n={enabled_n} catalog_n={catalog_n}",
|
|
49
|
+
state=MState.MEASURED,
|
|
44
50
|
source="plugin_classes", measured_at=ws.now(),
|
|
45
51
|
)
|
|
46
|
-
if
|
|
52
|
+
if catalog_n < self.min_surface:
|
|
47
53
|
state = State.VACUOUS
|
|
48
54
|
else:
|
|
49
55
|
state = State.PASS
|
|
50
56
|
return Result(
|
|
51
|
-
self.id, state, examined_n=
|
|
57
|
+
self.id, state, examined_n=catalog_n, findings=[],
|
|
58
|
+
measured=measured,
|
|
52
59
|
)
|
|
53
60
|
|
|
54
61
|
def plants(self, ws):
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"""commit-heatmap eye: directory groups, file rows, time columns."""
|
|
2
|
+
import os
|
|
2
3
|
from collections import Counter, defaultdict
|
|
3
4
|
from datetime import datetime, timedelta
|
|
4
5
|
|
|
@@ -27,10 +28,14 @@ _GROWTH_STEPS = (
|
|
|
27
28
|
(316, "#", "@"),
|
|
28
29
|
)
|
|
29
30
|
_GROWTH_CAPTION = (
|
|
30
|
-
"growth (per-column ins-del, log ladder, not file size;
|
|
31
|
+
"growth (per-column ins-del, log ladder, not file size; tracked only)"
|
|
31
32
|
)
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
def _want_compact():
|
|
36
|
+
return bool(os.environ.get("RATCH_COMPACT"))
|
|
37
|
+
|
|
38
|
+
|
|
34
39
|
def _top_dir(path):
|
|
35
40
|
if "/" not in path:
|
|
36
41
|
return "(root)"
|
|
@@ -242,11 +247,14 @@ class CommitHeatmap:
|
|
|
242
247
|
touch_dir_n = Counter()
|
|
243
248
|
touch_file_n = Counter()
|
|
244
249
|
grow_file_n = Counter()
|
|
250
|
+
tracked = set(ws.tracked_files())
|
|
245
251
|
for ts, files in commits:
|
|
246
252
|
col = _bucket_key(ts, grain)
|
|
247
253
|
if col not in retained:
|
|
248
254
|
continue
|
|
249
255
|
for path, ins, dele in files:
|
|
256
|
+
if path not in tracked:
|
|
257
|
+
continue
|
|
250
258
|
top = _top_dir(path)
|
|
251
259
|
touch_dir[(top, col)] += 1
|
|
252
260
|
touch_dir_n[top] += 1
|
|
@@ -273,8 +281,15 @@ class CommitHeatmap:
|
|
|
273
281
|
_GROWTH_CAPTION, grow_dir, grow_file, dirs,
|
|
274
282
|
_group(dirs, grow_files), cols, _growth_char,
|
|
275
283
|
))
|
|
284
|
+
if _want_compact():
|
|
285
|
+
value = (
|
|
286
|
+
f"tracked-only commits={n} dirs={len(dirs)} "
|
|
287
|
+
f"files={len(touch_files)}"
|
|
288
|
+
)
|
|
289
|
+
else:
|
|
290
|
+
value = "\n".join(lines)
|
|
276
291
|
measured = MeasuredValue(
|
|
277
|
-
value=
|
|
292
|
+
value=value,
|
|
278
293
|
state=MState.MEASURED,
|
|
279
294
|
source="git_log:numstat",
|
|
280
295
|
measured_at=ws.now(),
|
|
@@ -292,7 +307,11 @@ class CommitHeatmap:
|
|
|
292
307
|
|
|
293
308
|
def fixture(self, kit):
|
|
294
309
|
return FakeWorkspace(
|
|
295
|
-
files={
|
|
310
|
+
files={
|
|
311
|
+
"a.py": "x = 1\n",
|
|
312
|
+
"ratch/checks/a.py": "x = 1\n",
|
|
313
|
+
"tests/t.py": "x = 1\n",
|
|
314
|
+
},
|
|
296
315
|
git_log_text=(
|
|
297
316
|
"\x1e1700000000\n"
|
|
298
317
|
"12\t0\tratch/checks/a.py\n"
|
|
@@ -17,13 +17,67 @@ def _has_clock_class(tree):
|
|
|
17
17
|
return False
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
def _bindings(tree):
|
|
21
|
+
out = {}
|
|
22
|
+
for node in ast.walk(tree):
|
|
23
|
+
if isinstance(node, ast.Import):
|
|
24
|
+
for alias in node.names:
|
|
25
|
+
if alias.name in ("time", "datetime"):
|
|
26
|
+
out[alias.asname or alias.name] = ("mod", alias.name)
|
|
27
|
+
elif isinstance(node, ast.ImportFrom) and node.module == "time":
|
|
28
|
+
for alias in node.names:
|
|
29
|
+
out[alias.asname or alias.name] = ("func", alias.name)
|
|
30
|
+
elif isinstance(node, ast.ImportFrom) and node.module == "datetime":
|
|
31
|
+
for alias in node.names:
|
|
32
|
+
if alias.name == "datetime":
|
|
33
|
+
out[alias.asname or alias.name] = ("cls", "datetime")
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _raw_call_attr(func, binds):
|
|
38
|
+
if isinstance(func, ast.Name):
|
|
39
|
+
bind = binds.get(func.id)
|
|
40
|
+
if bind is not None and bind[0] == "func" and bind[1] in _TIME_ATTRS:
|
|
41
|
+
return bind[1]
|
|
42
|
+
return None
|
|
43
|
+
if not isinstance(func, ast.Attribute):
|
|
44
|
+
return None
|
|
45
|
+
if isinstance(func.value, ast.Name):
|
|
46
|
+
bind = binds.get(func.value.id)
|
|
47
|
+
if bind is None:
|
|
48
|
+
return None
|
|
49
|
+
kind, origin = bind
|
|
50
|
+
if kind == "mod" and origin == "time" and func.attr in _TIME_ATTRS:
|
|
51
|
+
return func.attr
|
|
52
|
+
if (
|
|
53
|
+
kind in ("mod", "cls")
|
|
54
|
+
and origin == "datetime"
|
|
55
|
+
and func.attr in _DT_ATTRS
|
|
56
|
+
):
|
|
57
|
+
return func.attr
|
|
58
|
+
return None
|
|
59
|
+
if not isinstance(func.value, ast.Attribute):
|
|
60
|
+
return None
|
|
61
|
+
if not isinstance(func.value.value, ast.Name):
|
|
62
|
+
return None
|
|
63
|
+
if (
|
|
64
|
+
binds.get(func.value.value.id) == ("mod", "datetime")
|
|
65
|
+
and func.value.attr == "datetime"
|
|
66
|
+
and func.attr in _DT_ATTRS
|
|
67
|
+
):
|
|
68
|
+
return func.attr
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
20
72
|
def _time_hits(tree):
|
|
73
|
+
binds = _bindings(tree)
|
|
21
74
|
hits = []
|
|
22
75
|
for node in ast.walk(tree):
|
|
23
|
-
if not isinstance(node, ast.
|
|
76
|
+
if not isinstance(node, ast.Call):
|
|
24
77
|
continue
|
|
25
|
-
|
|
26
|
-
|
|
78
|
+
hit = _raw_call_attr(node.func, binds)
|
|
79
|
+
if hit is not None:
|
|
80
|
+
hits.append(hit)
|
|
27
81
|
return hits
|
|
28
82
|
|
|
29
83
|
|
|
@@ -32,8 +86,10 @@ class InjectedClock:
|
|
|
32
86
|
|
|
33
87
|
Rule:
|
|
34
88
|
Default: if any tracked *.py defines class Clock, other modules
|
|
35
|
-
must not call time.time / sleep or datetime.now /
|
|
36
|
-
clock_path.
|
|
89
|
+
must not call bound stdlib time.time / sleep or datetime.now /
|
|
90
|
+
today except clock_path. Import aliases and from-imports count.
|
|
91
|
+
clock.sleep / SYSTEM_CLOCK.monotonic are the wait/time seam,
|
|
92
|
+
not a leak. clock_paths= arms only when those files exist;
|
|
37
93
|
paths= then limits which modules are confined. This repo uses
|
|
38
94
|
the default (no Clock type → PASS).
|
|
39
95
|
|
|
@@ -42,11 +98,11 @@ class InjectedClock:
|
|
|
42
98
|
makes time uninjectable.
|
|
43
99
|
|
|
44
100
|
Proven in:
|
|
45
|
-
AST
|
|
101
|
+
AST Call scan after resolving time/datetime bindings.
|
|
46
102
|
|
|
47
103
|
Not this:
|
|
48
|
-
Not a ban on datetime.timedelta. Not a
|
|
49
|
-
Clock where none exists.
|
|
104
|
+
Not a ban on datetime.timedelta. Not a ban on Clock.sleep.
|
|
105
|
+
Not a requirement to invent a Clock where none exists.
|
|
50
106
|
"""
|
|
51
107
|
|
|
52
108
|
id = "injected-clock"
|
|
@@ -5,6 +5,7 @@ Only check builds a Workspace and a Runner (and may request nicing).
|
|
|
5
5
|
"""
|
|
6
6
|
import argparse
|
|
7
7
|
import inspect
|
|
8
|
+
import os
|
|
8
9
|
import pathlib
|
|
9
10
|
import sys
|
|
10
11
|
|
|
@@ -21,6 +22,7 @@ def build_parser():
|
|
|
21
22
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
22
23
|
check = sub.add_parser("check")
|
|
23
24
|
check.add_argument("--staged", action="store_true")
|
|
25
|
+
check.add_argument("--compact", action="store_true")
|
|
24
26
|
sub.add_parser("list")
|
|
25
27
|
describe = sub.add_parser("describe")
|
|
26
28
|
describe.add_argument("id")
|
|
@@ -57,6 +59,8 @@ def main(argv=None):
|
|
|
57
59
|
if args.command == "describe":
|
|
58
60
|
return cmd_describe(args.id)
|
|
59
61
|
|
|
62
|
+
if args.compact:
|
|
63
|
+
os.environ["RATCH_COMPACT"] = "1"
|
|
60
64
|
view = "index" if args.staged else "worktree"
|
|
61
65
|
ws = Workspace(cwd, view)
|
|
62
66
|
checks = load_manifest(cwd)
|
|
@@ -9,8 +9,6 @@ import importlib.metadata
|
|
|
9
9
|
import importlib.util
|
|
10
10
|
import pathlib
|
|
11
11
|
|
|
12
|
-
from ratch.checks.forbidden_literal import NoForbiddenLiteral
|
|
13
|
-
|
|
14
12
|
__all__ = ["discover", "load_manifest"]
|
|
15
13
|
|
|
16
14
|
|
|
@@ -21,16 +19,18 @@ def discover():
|
|
|
21
19
|
|
|
22
20
|
|
|
23
21
|
def load_manifest(repo_root):
|
|
24
|
-
"""Return the check instances a repo declares, or
|
|
22
|
+
"""Return the check instances a repo declares, or an empty list.
|
|
25
23
|
|
|
26
|
-
A repo opts into
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
A repo opts into checks by exposing CHECKS from ratch_checks.py at
|
|
25
|
+
its root. Absent that file, nothing runs: a silent default gate
|
|
26
|
+
would scan product names in consumer READMEs.
|
|
29
27
|
"""
|
|
30
28
|
manifest = pathlib.Path(repo_root) / "ratch_checks.py"
|
|
31
|
-
if manifest.is_file():
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return
|
|
36
|
-
|
|
29
|
+
if not manifest.is_file():
|
|
30
|
+
return []
|
|
31
|
+
spec = importlib.util.spec_from_file_location("ratch_checks", manifest)
|
|
32
|
+
if spec is None or spec.loader is None:
|
|
33
|
+
return []
|
|
34
|
+
module = importlib.util.module_from_spec(spec)
|
|
35
|
+
spec.loader.exec_module(module)
|
|
36
|
+
return list(module.CHECKS)
|
|
@@ -39,6 +39,8 @@ def derive_state(raw, examined_n, unparseable_n, tolerates_unparseable,
|
|
|
39
39
|
"""
|
|
40
40
|
if raw is State.ERROR:
|
|
41
41
|
return State.ERROR
|
|
42
|
+
if raw is State.NOT_APPLICABLE:
|
|
43
|
+
return State.NOT_APPLICABLE
|
|
42
44
|
if unparseable_n > 0 and not tolerates_unparseable:
|
|
43
45
|
return State.FAIL
|
|
44
46
|
if findings:
|
|
@@ -68,7 +68,7 @@ def no_ai_signatures_should_pass_when_history_carries_no_attribution_shape():
|
|
|
68
68
|
assert result.findings == []
|
|
69
69
|
|
|
70
70
|
|
|
71
|
-
def
|
|
71
|
+
def no_ai_signatures_should_fail_when_the_repo_is_shallow():
|
|
72
72
|
sha = "deadbeef0badf00d"
|
|
73
73
|
trailer = "feat: x\n\nCo-Authored-By: Bot <bot@example.test>\n"
|
|
74
74
|
ws = FakeWorkspace(
|
|
@@ -80,9 +80,8 @@ def no_ai_signatures_should_be_vacuous_when_the_repo_is_shallow():
|
|
|
80
80
|
|
|
81
81
|
result = NoAiSignatures().check(ws)
|
|
82
82
|
|
|
83
|
-
assert result.state is State.
|
|
84
|
-
assert
|
|
85
|
-
assert result.findings == []
|
|
83
|
+
assert result.state is State.FAIL
|
|
84
|
+
assert any(f.anchor == "shallow" for f in result.findings)
|
|
86
85
|
|
|
87
86
|
|
|
88
87
|
def no_ai_signatures_should_fail_when_trailer_uses_github_lowercase_casing():
|
|
@@ -114,3 +114,25 @@ def bdd_test_conventions_should_be_discoverable_when_registered_as_an_entry_poin
|
|
|
114
114
|
expected = BddTestConventions
|
|
115
115
|
|
|
116
116
|
assert found is expected
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def bdd_test_conventions_should_ignore_helpers_when_prefix_is_test_():
|
|
120
|
+
src = (
|
|
121
|
+
"def make_toml():\n return 1\n"
|
|
122
|
+
"def test_foo_should_bar_when_baz():\n"
|
|
123
|
+
" x = 1\n\n y = 2\n\n assert x\n"
|
|
124
|
+
)
|
|
125
|
+
ws = FakeWorkspace(files={"tests/t.py": src})
|
|
126
|
+
|
|
127
|
+
result = BddTestConventions(prefix="test_").check(ws)
|
|
128
|
+
|
|
129
|
+
assert result.state is State.PASS
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def bdd_test_conventions_should_skip_body_shape_when_blank_blocks_is_zero():
|
|
133
|
+
src = "def foo_should_bar_when_baz():\n x = 1\n assert x\n"
|
|
134
|
+
ws = FakeWorkspace(files={"tests/t.py": src})
|
|
135
|
+
|
|
136
|
+
result = BddTestConventions(blank_blocks=0).check(ws)
|
|
137
|
+
|
|
138
|
+
assert result.state is State.PASS
|
|
@@ -18,7 +18,7 @@ def catalog_size_should_pass_when_the_workspace_has_plugins():
|
|
|
18
18
|
|
|
19
19
|
assert result.state is State.PASS
|
|
20
20
|
assert result.measured is not None
|
|
21
|
-
assert result.measured.value
|
|
21
|
+
assert "catalog_n=1" in str(result.measured.value)
|
|
22
22
|
assert result.measured.state is MState.MEASURED
|
|
23
23
|
|
|
24
24
|
|
|
@@ -5,10 +5,18 @@ from ratch.cli import main
|
|
|
5
5
|
from ratch.registry import discover
|
|
6
6
|
from ratch.testing import make_tmp_repo
|
|
7
7
|
|
|
8
|
+
_MANIFEST = (
|
|
9
|
+
"from ratch.checks.forbidden_literal import NoForbiddenLiteral\n"
|
|
10
|
+
"CHECKS = [NoForbiddenLiteral()]\n"
|
|
11
|
+
)
|
|
12
|
+
|
|
8
13
|
|
|
9
14
|
def main_should_return_one_when_the_repo_has_a_violation(tmp_path, monkeypatch):
|
|
10
15
|
banned = "cl" + "aude"
|
|
11
|
-
repo = make_tmp_repo(tmp_path, {
|
|
16
|
+
repo = make_tmp_repo(tmp_path, {
|
|
17
|
+
"pkg/mod.py": f'MARK = "{banned}"\n',
|
|
18
|
+
"ratch_checks.py": _MANIFEST,
|
|
19
|
+
})
|
|
12
20
|
monkeypatch.chdir(repo)
|
|
13
21
|
monkeypatch.setattr(os, "nice", lambda n: None)
|
|
14
22
|
|
|
@@ -18,7 +26,10 @@ def main_should_return_one_when_the_repo_has_a_violation(tmp_path, monkeypatch):
|
|
|
18
26
|
|
|
19
27
|
|
|
20
28
|
def main_should_return_zero_when_the_repo_is_clean(tmp_path, monkeypatch):
|
|
21
|
-
repo = make_tmp_repo(tmp_path, {
|
|
29
|
+
repo = make_tmp_repo(tmp_path, {
|
|
30
|
+
"pkg/mod.py": "MARK = 'clean'\n",
|
|
31
|
+
"ratch_checks.py": _MANIFEST,
|
|
32
|
+
})
|
|
22
33
|
monkeypatch.chdir(repo)
|
|
23
34
|
monkeypatch.setattr(os, "nice", lambda n: None)
|
|
24
35
|
|
|
@@ -51,7 +62,7 @@ def main_should_list_discovered_plugins_when_invoked_with_list(
|
|
|
51
62
|
assert line == f"{name:<{width}}{label}"
|
|
52
63
|
labels[name] = label
|
|
53
64
|
assert set(labels.values()) <= {"enabled", "catalog"}
|
|
54
|
-
assert labels["no-forbidden-literal"] == "
|
|
65
|
+
assert labels["no-forbidden-literal"] == "catalog"
|
|
55
66
|
assert labels["no-external-font-cdn"] == "catalog"
|
|
56
67
|
|
|
57
68
|
|
|
@@ -18,7 +18,14 @@ _LOG = (
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def _ws(log=_LOG):
|
|
21
|
-
return FakeWorkspace(
|
|
21
|
+
return FakeWorkspace(
|
|
22
|
+
files={
|
|
23
|
+
"a.py": "x = 1\n",
|
|
24
|
+
"ratch/checks/a.py": "x = 1\n",
|
|
25
|
+
"tests/t.py": "x = 1\n",
|
|
26
|
+
},
|
|
27
|
+
git_log_text=log,
|
|
28
|
+
)
|
|
22
29
|
|
|
23
30
|
|
|
24
31
|
def parse_log_should_read_insert_and_delete_counts_when_numstat_line_is_plain():
|
|
@@ -27,12 +27,12 @@ def no_external_font_cdn_should_pass_when_css_uses_local_fonts():
|
|
|
27
27
|
assert result.state is State.PASS
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
def
|
|
30
|
+
def no_external_font_cdn_should_be_not_applicable_when_no_web_file_is_present():
|
|
31
31
|
ws = FakeWorkspace(files={"a.py": "x = 1\n"})
|
|
32
32
|
|
|
33
33
|
result = NoExternalFontCdn().check(ws)
|
|
34
34
|
|
|
35
|
-
assert result.state is State.
|
|
35
|
+
assert result.state is State.NOT_APPLICABLE
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
def no_external_font_cdn_should_bite_its_plants_when_checked_against_its_own_fixture(tmp_path):
|
|
@@ -29,6 +29,64 @@ def injected_clock_should_fail_when_a_clock_exists_and_time_is_raw():
|
|
|
29
29
|
assert found is expected
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def _armed(app):
|
|
33
|
+
return FakeWorkspace(files={
|
|
34
|
+
"clock.py": "class Clock:\n pass\n",
|
|
35
|
+
"app.py": app,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def injected_clock_should_pass_when_injected_clock_sleep_is_called():
|
|
40
|
+
ws = FakeWorkspace(files={
|
|
41
|
+
"clock.py": "class Clock:\n def sleep(self, n):\n pass\n",
|
|
42
|
+
"app.py": "def run(clock):\n clock.sleep(1)\n",
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
result = InjectedClock().check(ws)
|
|
46
|
+
|
|
47
|
+
assert result.state is State.PASS
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def injected_clock_should_fail_when_sleep_is_imported_from_time():
|
|
51
|
+
ws = _armed("from time import sleep\nsleep(1)\n")
|
|
52
|
+
|
|
53
|
+
result = InjectedClock().check(ws)
|
|
54
|
+
|
|
55
|
+
assert result.state is State.FAIL
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def injected_clock_should_fail_when_datetime_now_is_imported_from_datetime():
|
|
59
|
+
ws = _armed("from datetime import datetime\ndatetime.now()\n")
|
|
60
|
+
|
|
61
|
+
result = InjectedClock().check(ws)
|
|
62
|
+
|
|
63
|
+
assert result.state is State.FAIL
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def injected_clock_should_fail_when_datetime_datetime_now_is_called():
|
|
67
|
+
ws = _armed("import datetime\ndatetime.datetime.now()\n")
|
|
68
|
+
|
|
69
|
+
result = InjectedClock().check(ws)
|
|
70
|
+
|
|
71
|
+
assert result.state is State.FAIL
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def injected_clock_should_pass_when_system_clock_monotonic_is_called():
|
|
75
|
+
ws = _armed("SYSTEM_CLOCK.monotonic()\n")
|
|
76
|
+
|
|
77
|
+
result = InjectedClock().check(ws)
|
|
78
|
+
|
|
79
|
+
assert result.state is State.PASS
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def injected_clock_should_fail_when_time_is_imported_under_an_alias():
|
|
83
|
+
ws = _armed("import time as t\nt.time()\n")
|
|
84
|
+
|
|
85
|
+
result = InjectedClock().check(ws)
|
|
86
|
+
|
|
87
|
+
assert result.state is State.FAIL
|
|
88
|
+
|
|
89
|
+
|
|
32
90
|
def injected_clock_should_bite_its_plants_when_checked_against_its_own_fixture(tmp_path):
|
|
33
91
|
check = InjectedClock()
|
|
34
92
|
|
|
@@ -14,12 +14,12 @@ def discover_should_resolve_no_forbidden_literal_when_the_package_is_installed()
|
|
|
14
14
|
assert found is expected
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
def
|
|
17
|
+
def load_manifest_should_return_no_checks_when_no_manifest_exists(tmp_path):
|
|
18
18
|
checks = load_manifest(tmp_path)
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
found = len(checks)
|
|
21
21
|
|
|
22
|
-
assert
|
|
22
|
+
assert found == 0
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def no_forbidden_literal_should_raise_when_min_surface_is_zero():
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""The ratch repo dogfoods every tooth it ships.
|
|
2
2
|
|
|
3
3
|
load_manifest read at this repo's own root must return the twenty-three ids in
|
|
4
|
-
ratch_checks.py CHECKS, not the
|
|
5
|
-
|
|
4
|
+
ratch_checks.py CHECKS, not the empty default a fresh repo falls back to.
|
|
5
|
+
no-external-font-cdn is shipped as an entry point only.
|
|
6
6
|
"""
|
|
7
7
|
import importlib
|
|
8
8
|
|
|
@@ -25,7 +25,13 @@ def _hand_rolled_repo():
|
|
|
25
25
|
cwd=root, check=True,
|
|
26
26
|
)
|
|
27
27
|
(root / "seed.py").write_text("x = 1\n")
|
|
28
|
-
|
|
28
|
+
(root / "ratch_checks.py").write_text(
|
|
29
|
+
"from ratch.checks.forbidden_literal import NoForbiddenLiteral\n"
|
|
30
|
+
"CHECKS = [NoForbiddenLiteral()]\n"
|
|
31
|
+
)
|
|
32
|
+
subprocess.run(
|
|
33
|
+
["git", "add", "seed.py", "ratch_checks.py"], cwd=root, check=True,
|
|
34
|
+
)
|
|
29
35
|
subprocess.run(["git", "commit", "-q", "-m", "seed"], cwd=root, check=True)
|
|
30
36
|
return root
|
|
31
37
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|