ratch 0.1.0__tar.gz → 0.1.2__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.0/ratch.egg-info → ratch-0.1.2}/PKG-INFO +1 -1
- ratch-0.1.2/README.md +72 -0
- {ratch-0.1.0 → ratch-0.1.2}/pyproject.toml +8 -1
- ratch-0.1.2/ratch/checks/__init__.py +32 -0
- {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/ai_signatures.py +4 -2
- ratch-0.1.2/ratch/checks/autoclose.py +83 -0
- {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/bdd_conventions.py +43 -3
- {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/catalog_size.py +3 -2
- {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/circular_import.py +3 -2
- ratch-0.1.2/ratch/checks/commit_heatmap.py +301 -0
- {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/conflict_markers.py +3 -2
- ratch-0.1.2/ratch/checks/doc_cli.py +119 -0
- {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/doc_counts.py +3 -2
- {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/docs_render.py +3 -2
- {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/first_person.py +3 -2
- {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/font_cdn.py +3 -2
- {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/forbidden_literal.py +4 -2
- {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/hash_named_test.py +3 -2
- ratch-0.1.2/ratch/checks/injected_clock.py +135 -0
- ratch-0.1.2/ratch/checks/internal_refs.py +85 -0
- ratch-0.1.2/ratch/checks/loc_cap.py +81 -0
- {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/manifest_purity.py +4 -2
- {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/plugin_registry.py +15 -18
- {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/pytest_skip.py +11 -6
- ratch-0.1.2/ratch/checks/reassurance.py +84 -0
- ratch-0.1.2/ratch/checks/repo_root_ssot.py +84 -0
- {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/todo_issue.py +3 -2
- {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/vacuous_assert.py +3 -2
- {ratch-0.1.0 → ratch-0.1.2}/ratch/cli.py +15 -10
- {ratch-0.1.0 → ratch-0.1.2}/ratch/reporter.py +8 -2
- {ratch-0.1.0 → ratch-0.1.2}/ratch/testing.py +1 -1
- {ratch-0.1.0 → ratch-0.1.2}/ratch/workspace.py +2 -2
- {ratch-0.1.0 → ratch-0.1.2/ratch.egg-info}/PKG-INFO +1 -1
- {ratch-0.1.0 → ratch-0.1.2}/ratch.egg-info/SOURCES.txt +14 -0
- {ratch-0.1.0 → ratch-0.1.2}/ratch.egg-info/entry_points.txt +7 -0
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_ai_signatures.py +5 -1
- ratch-0.1.2/tests/test_autoclose.py +45 -0
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_bdd_conventions.py +43 -10
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_catalog_size.py +16 -3
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_check.py +12 -12
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_circular_import.py +9 -0
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_cli.py +4 -1
- ratch-0.1.2/tests/test_commit_heatmap.py +140 -0
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_conflict_markers.py +15 -2
- ratch-0.1.2/tests/test_doc_cli.py +54 -0
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_doc_counts.py +23 -3
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_docs_render.py +21 -4
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_first_person.py +38 -2
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_font_cdn.py +17 -2
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_forbidden_literal.py +5 -1
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_hash_named_test.py +15 -2
- ratch-0.1.2/tests/test_injected_clock.py +66 -0
- ratch-0.1.2/tests/test_internal_refs.py +45 -0
- ratch-0.1.2/tests/test_loc_cap.py +57 -0
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_manifest_purity.py +24 -2
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_plugin_registry.py +12 -2
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_pytest_seam.py +26 -5
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_pytest_skip.py +28 -2
- ratch-0.1.2/tests/test_reassurance.py +44 -0
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_registry.py +22 -13
- ratch-0.1.2/tests/test_repo_root_ssot.py +44 -0
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_result.py +7 -5
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_runner.py +12 -4
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_self_host.py +13 -5
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_self_host_blackbox.py +12 -6
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_testing.py +5 -7
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_todo_issue.py +13 -2
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_vacuous_assert.py +17 -2
- ratch-0.1.0/README.md +0 -30
- ratch-0.1.0/ratch/checks/__init__.py +0 -7
- ratch-0.1.0/ratch/checks/commit_heatmap.py +0 -89
- ratch-0.1.0/tests/test_commit_heatmap.py +0 -37
- {ratch-0.1.0 → ratch-0.1.2}/LICENSE +0 -0
- {ratch-0.1.0 → ratch-0.1.2}/ratch/__init__.py +0 -0
- {ratch-0.1.0 → ratch-0.1.2}/ratch/__main__.py +0 -0
- {ratch-0.1.0 → ratch-0.1.2}/ratch/check.py +0 -0
- {ratch-0.1.0 → ratch-0.1.2}/ratch/pytest_plugin.py +0 -0
- {ratch-0.1.0 → ratch-0.1.2}/ratch/registry.py +0 -0
- {ratch-0.1.0 → ratch-0.1.2}/ratch/result.py +0 -0
- {ratch-0.1.0 → ratch-0.1.2}/ratch/runner.py +0 -0
- {ratch-0.1.0 → ratch-0.1.2}/ratch.egg-info/dependency_links.txt +0 -0
- {ratch-0.1.0 → ratch-0.1.2}/ratch.egg-info/requires.txt +0 -0
- {ratch-0.1.0 → ratch-0.1.2}/ratch.egg-info/top_level.txt +0 -0
- {ratch-0.1.0 → ratch-0.1.2}/setup.cfg +0 -0
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_reporter.py +0 -0
- {ratch-0.1.0 → ratch-0.1.2}/tests/test_workspace.py +0 -0
ratch-0.1.2/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
> **[中文](README.zh.md)** · English
|
|
2
|
+
|
|
3
|
+
# ratch
|
|
4
|
+
|
|
5
|
+
[](https://github.com/wan9yu/ratch/actions/workflows/ci.yml)
|
|
6
|
+
|
|
7
|
+
<!-- ratch:authored -->
|
|
8
|
+
A written team agreement works when people keep it. An agent does not
|
|
9
|
+
share that embarrassment. Prompts and policy files are not deterministic.
|
|
10
|
+
ratch is the extreme-programming form of the agreement: a good practice
|
|
11
|
+
pushed until git can re-prove it on every commit. Git author metadata no
|
|
12
|
+
longer names which agent made the commit.
|
|
13
|
+
|
|
14
|
+
Install with `pip install ratch`. Put a `ratch_checks.py` at the
|
|
15
|
+
repository root (see `examples/ratch_checks.py`). Run
|
|
16
|
+
`python -m ratch check`. Gates that fail exit non-zero. Eyes print and
|
|
17
|
+
never change the exit code. `ratch list` and `ratch describe no-forbidden-literal` show the
|
|
18
|
+
catalog; they do not run checks.
|
|
19
|
+
|
|
20
|
+
This repository is gated by its own root `ratch_checks.py`. `./build.sh`
|
|
21
|
+
and CI install, run pytest, then `ratch check`.
|
|
22
|
+
|
|
23
|
+
**Provenance** — commit identity is no longer a person:
|
|
24
|
+
|
|
25
|
+
- `no-forbidden-literal` — author-tool tokens in content, paths, or the tip commit
|
|
26
|
+
- `no-first-person` — record prose without first-person voice
|
|
27
|
+
- `no-ai-signatures` — attribution trailers that survive rebase
|
|
28
|
+
|
|
29
|
+
**Debris agents leave in the tree:**
|
|
30
|
+
|
|
31
|
+
- `no-conflict-markers` — unfinished merge markers
|
|
32
|
+
- `no-vacuous-assert` — `assert True` / `assert False` placeholders
|
|
33
|
+
- `no-pytest-skip` — skipped tests that read green
|
|
34
|
+
- `no-hash-named-test` — test files named with a forgotten hex suffix
|
|
35
|
+
- `todo-has-issue-ref` — debt markers without an issue citation
|
|
36
|
+
- `bdd-test-conventions` — test names that read as a sentence; two blank lines split the body into three blocks
|
|
37
|
+
- `no-circular-import` — a package that only imports in one order
|
|
38
|
+
- `no-reassurance-words` — prose that tells the reader to stop checking
|
|
39
|
+
- `loc-cap` — Python modules over 1000 lines
|
|
40
|
+
- `no-external-font-cdn` — shipped HTML/CSS/JS fetching remote webfonts
|
|
41
|
+
|
|
42
|
+
**The agreement must apply to the agreement:**
|
|
43
|
+
|
|
44
|
+
- `plugin-registry` — every plugin has plants, fixture, and headings
|
|
45
|
+
- `manifest-purity` — `ratch_checks.py` is importable and repeatable
|
|
46
|
+
- `no-internal-refs` — no pointers into a gitignored notes tree
|
|
47
|
+
- `doc-counts-match-ssot` — README `catalog_n` equals `discover()`
|
|
48
|
+
- `docs-equal-fresh-render` — the generated region matches a live render
|
|
49
|
+
- `tests-repo-root-ssot` — tests import one ROOT helper, not Path(__file__) walks
|
|
50
|
+
- `injected-clock` — raw time.time only if a Clock type exists
|
|
51
|
+
- `doc-cli-examples-valid` — documented ratch commands parse
|
|
52
|
+
- `no-autoclose-keywords-in-commits` — commit messages that would auto-close issues
|
|
53
|
+
|
|
54
|
+
**Eyes** — evidence, never a failed run:
|
|
55
|
+
|
|
56
|
+
- `catalog-size` — how many plugins are installed
|
|
57
|
+
- `commit-heatmap` — directory-grouped file × time touches and numstat growth
|
|
58
|
+
|
|
59
|
+
ratch will never gate on judgment. It does not score, grade, or block on:
|
|
60
|
+
|
|
61
|
+
- naming quality
|
|
62
|
+
- abstraction quality
|
|
63
|
+
- prose quality (including commit-message quality)
|
|
64
|
+
- test meaningfulness
|
|
65
|
+
|
|
66
|
+
Those calls belong to review by people. ratch gates only what a machine
|
|
67
|
+
can prove.
|
|
68
|
+
<!-- ratch:authored:end -->
|
|
69
|
+
|
|
70
|
+
<!-- ratch:generated:checks -->
|
|
71
|
+
catalog_n=24
|
|
72
|
+
<!-- ratch:generated:checks:end -->
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ratch"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "The ratchet only turns one way: code is the single source of truth."
|
|
9
9
|
license = {text = "Apache-2.0"}
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -36,6 +36,13 @@ doc-counts-match-ssot = "ratch.checks.doc_counts:DocCountsMatchSSot"
|
|
|
36
36
|
docs-equal-fresh-render = "ratch.checks.docs_render:DocsEqualFreshRender"
|
|
37
37
|
catalog-size = "ratch.checks.catalog_size:CatalogSize"
|
|
38
38
|
commit-heatmap = "ratch.checks.commit_heatmap:CommitHeatmap"
|
|
39
|
+
no-internal-refs = "ratch.checks.internal_refs:NoInternalRefs"
|
|
40
|
+
no-reassurance-words = "ratch.checks.reassurance:NoReassuranceWords"
|
|
41
|
+
tests-repo-root-ssot = "ratch.checks.repo_root_ssot:RepoRootSSot"
|
|
42
|
+
loc-cap = "ratch.checks.loc_cap:LocCap"
|
|
43
|
+
injected-clock = "ratch.checks.injected_clock:InjectedClock"
|
|
44
|
+
doc-cli-examples-valid = "ratch.checks.doc_cli:DocCliExamplesValid"
|
|
45
|
+
no-autoclose-keywords-in-commits = "ratch.checks.autoclose:NoAutocloseKeywords"
|
|
39
46
|
|
|
40
47
|
[tool.setuptools.packages.find]
|
|
41
48
|
include = ["ratch*"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Shipped check plugins. Discovery is via entry points, not this module."""
|
|
2
|
+
import fnmatch
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def is_test_py(path):
|
|
6
|
+
return path.endswith(".py") and (
|
|
7
|
+
path.startswith("tests/") or "/tests/" in path
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def match_globs(path, globs):
|
|
12
|
+
"""True if globs is None or path matches any glob (``**`` = any dirs)."""
|
|
13
|
+
if globs is None:
|
|
14
|
+
return True
|
|
15
|
+
return any(_match_one(path, pattern) for pattern in globs)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _match_one(path, pattern):
|
|
19
|
+
if fnmatch.fnmatch(path, pattern):
|
|
20
|
+
return True
|
|
21
|
+
if "**" not in pattern:
|
|
22
|
+
return False
|
|
23
|
+
head, tail = pattern.split("**", 1)
|
|
24
|
+
prefix = head.rstrip("/")
|
|
25
|
+
suffix = tail.lstrip("/")
|
|
26
|
+
if prefix and path != prefix and not path.startswith(prefix + "/"):
|
|
27
|
+
return False
|
|
28
|
+
rest = path[len(prefix):].lstrip("/") if prefix else path
|
|
29
|
+
if not suffix:
|
|
30
|
+
return True
|
|
31
|
+
base = rest.rsplit("/", 1)[-1]
|
|
32
|
+
return fnmatch.fnmatch(rest, suffix) or fnmatch.fnmatch(base, suffix)
|
|
@@ -30,7 +30,9 @@ class NoAiSignatures:
|
|
|
30
30
|
clean.
|
|
31
31
|
|
|
32
32
|
Proven in:
|
|
33
|
-
|
|
33
|
+
A full-history scan of attribution trailers (Co-Authored-By,
|
|
34
|
+
Generated with, Assisted by, robot glyph, noreply addresses);
|
|
35
|
+
a shallow clone must not read as clean.
|
|
34
36
|
|
|
35
37
|
Not this:
|
|
36
38
|
Not a judgment on who or what wrote the code. Only fixed attribution
|
|
@@ -41,7 +43,7 @@ class NoAiSignatures:
|
|
|
41
43
|
tier = "A"
|
|
42
44
|
kind = "gate"
|
|
43
45
|
scope = "global"
|
|
44
|
-
proven_in = (
|
|
46
|
+
proven_in = ()
|
|
45
47
|
confidence = "breadth"
|
|
46
48
|
tolerates_unparseable = False
|
|
47
49
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""no-autoclose-keywords-in-commits check."""
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from ratch.check import Plant
|
|
5
|
+
from ratch.result import Finding, Result, State
|
|
6
|
+
from ratch.testing import FakeWorkspace
|
|
7
|
+
|
|
8
|
+
_RX = re.compile(
|
|
9
|
+
r"\b(?:fix(?:es|ed)?|close(?:s|d)?|resolve(?:s|d)?)\s+#\d+",
|
|
10
|
+
re.IGNORECASE,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NoAutocloseKeywords:
|
|
15
|
+
"""Forbid GitHub auto-close phrases in commit messages.
|
|
16
|
+
|
|
17
|
+
Rule:
|
|
18
|
+
git log subjects and bodies must not match fix/close/resolve
|
|
19
|
+
plus a hash-number issue citation.
|
|
20
|
+
|
|
21
|
+
Why:
|
|
22
|
+
Agent-written trailers close issues by accident; the message
|
|
23
|
+
is the SSOT that GitHub will act on.
|
|
24
|
+
|
|
25
|
+
Proven in:
|
|
26
|
+
git log format subject+body scanned with one regex.
|
|
27
|
+
|
|
28
|
+
Not this:
|
|
29
|
+
Not a ban on citing issues in code comments.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
id = "no-autoclose-keywords-in-commits"
|
|
33
|
+
tier = "A"
|
|
34
|
+
kind = "gate"
|
|
35
|
+
scope = "global"
|
|
36
|
+
proven_in = ()
|
|
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
|
+
text = ws.git_log(fmt="%s%n%b%n%x1e")
|
|
54
|
+
records = [block.strip() for block in text.split("\x1e") if block.strip()]
|
|
55
|
+
findings = []
|
|
56
|
+
examined_n = len(records)
|
|
57
|
+
for block in records:
|
|
58
|
+
hit = _RX.search(block)
|
|
59
|
+
if hit:
|
|
60
|
+
findings.append(
|
|
61
|
+
Finding(self.id, "<commit>", hit.group(0),
|
|
62
|
+
message=f"autoclose: {hit.group(0)}")
|
|
63
|
+
)
|
|
64
|
+
return Result(
|
|
65
|
+
self.id, self._state(findings, examined_n),
|
|
66
|
+
examined_n=examined_n, skipped_n=ws.skipped_n, findings=findings,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def plants(self, ws):
|
|
70
|
+
yield Plant(
|
|
71
|
+
label="fixes",
|
|
72
|
+
planted_ws=FakeWorkspace(
|
|
73
|
+
files={"a.py": "x = 1\n"},
|
|
74
|
+
git_log_text="Fixes #12\n\x1e",
|
|
75
|
+
),
|
|
76
|
+
expected=(self.id, "<commit>", "Fixes #12"),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def fixture(self, kit):
|
|
80
|
+
return FakeWorkspace(
|
|
81
|
+
files={"a.py": "x = 1\n"},
|
|
82
|
+
git_log_text="tighten the parser\n\x1e",
|
|
83
|
+
)
|
|
@@ -17,6 +17,22 @@ def _is_bdd_name(name):
|
|
|
17
17
|
return "or" not in name.split("_")
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
def _paragraph_n(node, lines):
|
|
21
|
+
start = node.lineno
|
|
22
|
+
end = node.end_lineno or node.lineno
|
|
23
|
+
body = lines[start:end]
|
|
24
|
+
groups = 0
|
|
25
|
+
in_group = False
|
|
26
|
+
for line in body:
|
|
27
|
+
if line.strip():
|
|
28
|
+
if not in_group:
|
|
29
|
+
groups += 1
|
|
30
|
+
in_group = True
|
|
31
|
+
else:
|
|
32
|
+
in_group = False
|
|
33
|
+
return groups
|
|
34
|
+
|
|
35
|
+
|
|
20
36
|
class BddTestConventions:
|
|
21
37
|
"""Require BDD test names in tracked test modules.
|
|
22
38
|
|
|
@@ -24,6 +40,8 @@ class BddTestConventions:
|
|
|
24
40
|
In tracked tests/*.py, every top-level def whose name does not
|
|
25
41
|
start with underscore must contain _should_ and _when_, must not
|
|
26
42
|
start with test_, and must not use or as a snake_case segment.
|
|
43
|
+
The body must be three blocks separated by two blank lines.
|
|
44
|
+
Labels such as given/when/then are allowed and ignored.
|
|
27
45
|
|
|
28
46
|
Why:
|
|
29
47
|
A test name that reads as a sentence is the readable spec; a
|
|
@@ -31,7 +49,9 @@ class BddTestConventions:
|
|
|
31
49
|
share one convention.
|
|
32
50
|
|
|
33
51
|
Proven in:
|
|
34
|
-
|
|
52
|
+
A test name reads as subject_should_outcome_when_condition,
|
|
53
|
+
with no test_ prefix and no or segment. Two blank lines in the
|
|
54
|
+
body mark three blocks; no given/when/then labels.
|
|
35
55
|
|
|
36
56
|
Not this:
|
|
37
57
|
Not a requirement on private helpers. Not a ban on tokens that
|
|
@@ -43,7 +63,7 @@ class BddTestConventions:
|
|
|
43
63
|
tier = "A"
|
|
44
64
|
kind = "gate"
|
|
45
65
|
scope = "global"
|
|
46
|
-
proven_in = (
|
|
66
|
+
proven_in = ()
|
|
47
67
|
confidence = "inferred"
|
|
48
68
|
tolerates_unparseable = False
|
|
49
69
|
|
|
@@ -69,6 +89,7 @@ class BddTestConventions:
|
|
|
69
89
|
tree = ws.ast(path)
|
|
70
90
|
if tree is None:
|
|
71
91
|
continue
|
|
92
|
+
lines = ws.read(path).splitlines()
|
|
72
93
|
for node in tree.body:
|
|
73
94
|
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
74
95
|
continue
|
|
@@ -80,6 +101,12 @@ class BddTestConventions:
|
|
|
80
101
|
Finding(self.id, path, name,
|
|
81
102
|
message=f"bdd name: {name}")
|
|
82
103
|
)
|
|
104
|
+
continue
|
|
105
|
+
if _paragraph_n(node, lines) < 3:
|
|
106
|
+
findings.append(
|
|
107
|
+
Finding(self.id, path, name,
|
|
108
|
+
message=f"bdd blank: {name}")
|
|
109
|
+
)
|
|
83
110
|
return Result(
|
|
84
111
|
self.id, self._state(findings, examined_n),
|
|
85
112
|
examined_n=examined_n, skipped_n=ws.skipped_n,
|
|
@@ -101,8 +128,21 @@ class BddTestConventions:
|
|
|
101
128
|
),
|
|
102
129
|
expected=(self.id, "tests/t.py", "foo_should_pass_or_fail_when_x"),
|
|
103
130
|
)
|
|
131
|
+
body = "\n".join(f" a{i} = {i}" for i in range(6))
|
|
132
|
+
yield Plant(
|
|
133
|
+
label="dense-body",
|
|
134
|
+
planted_ws=FakeWorkspace(
|
|
135
|
+
files={"tests/t.py": f"def foo_should_bar_when_baz():\n{body}\n"}
|
|
136
|
+
),
|
|
137
|
+
expected=(self.id, "tests/t.py", "foo_should_bar_when_baz"),
|
|
138
|
+
)
|
|
104
139
|
|
|
105
140
|
def fixture(self, kit):
|
|
106
141
|
return FakeWorkspace(
|
|
107
|
-
files={"tests/t.py":
|
|
142
|
+
files={"tests/t.py": (
|
|
143
|
+
"def foo_should_bar_when_baz():\n"
|
|
144
|
+
" x = 1\n\n"
|
|
145
|
+
" y = 2\n\n"
|
|
146
|
+
" assert x\n"
|
|
147
|
+
)}
|
|
108
148
|
)
|
|
@@ -17,7 +17,8 @@ class CatalogSize:
|
|
|
17
17
|
measures.
|
|
18
18
|
|
|
19
19
|
Proven in:
|
|
20
|
-
|
|
20
|
+
This repository: an eye that reports how many checks are
|
|
21
|
+
installed, without failing the run.
|
|
21
22
|
|
|
22
23
|
Not this:
|
|
23
24
|
Not a gate. VACUOUS on an empty catalog does not fail the run.
|
|
@@ -27,7 +28,7 @@ class CatalogSize:
|
|
|
27
28
|
tier = "A"
|
|
28
29
|
kind = "eye"
|
|
29
30
|
scope = "global"
|
|
30
|
-
proven_in = (
|
|
31
|
+
proven_in = ()
|
|
31
32
|
confidence = "inferred"
|
|
32
33
|
tolerates_unparseable = False
|
|
33
34
|
|
|
@@ -55,7 +55,8 @@ class NoCircularImport:
|
|
|
55
55
|
in-process import would be masked by an already-populated sys.modules.
|
|
56
56
|
|
|
57
57
|
Proven in:
|
|
58
|
-
|
|
58
|
+
A fresh-interpreter import of the package: CPython cycle
|
|
59
|
+
strings FAIL; any other import error is ERROR, never FAIL.
|
|
59
60
|
|
|
60
61
|
Not this:
|
|
61
62
|
Not a static import-graph analysis and not a style rule. Only an
|
|
@@ -69,7 +70,7 @@ class NoCircularImport:
|
|
|
69
70
|
tier = "A"
|
|
70
71
|
kind = "gate"
|
|
71
72
|
scope = "global"
|
|
72
|
-
proven_in = (
|
|
73
|
+
proven_in = ()
|
|
73
74
|
confidence = "portable-with-config"
|
|
74
75
|
tolerates_unparseable = False
|
|
75
76
|
min_surface = 1
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""commit-heatmap eye: directory groups, file rows, time columns."""
|
|
2
|
+
from collections import Counter, defaultdict
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
|
|
5
|
+
from ratch.check import Plant
|
|
6
|
+
from ratch.result import MeasuredValue, MState, Result, State
|
|
7
|
+
from ratch.testing import FakeWorkspace
|
|
8
|
+
|
|
9
|
+
_MAX_COMMITS = 500
|
|
10
|
+
_MAX_DIRS = 8
|
|
11
|
+
_MAX_FILES = 24
|
|
12
|
+
_MAX_COLS = 14
|
|
13
|
+
_DENSITY = (
|
|
14
|
+
(0, " "),
|
|
15
|
+
(1, "."),
|
|
16
|
+
(3, ":"),
|
|
17
|
+
(6, "*"),
|
|
18
|
+
(11, "+"),
|
|
19
|
+
(21, "#"),
|
|
20
|
+
)
|
|
21
|
+
# log-ish |net| cutoffs: 10^0, 10^1, ~10^1.5, 10^2, ~10^2.5
|
|
22
|
+
_GROWTH_STEPS = (
|
|
23
|
+
(1, ".", "o"),
|
|
24
|
+
(10, ":", "~"),
|
|
25
|
+
(32, "*", "-"),
|
|
26
|
+
(100, "+", "="),
|
|
27
|
+
(316, "#", "@"),
|
|
28
|
+
)
|
|
29
|
+
_GROWTH_CAPTION = (
|
|
30
|
+
"growth (per-column ins-del, log ladder, not file size; full history)"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _top_dir(path):
|
|
35
|
+
if "/" not in path:
|
|
36
|
+
return "(root)"
|
|
37
|
+
return path.split("/", 1)[0]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _normalize_path(path):
|
|
41
|
+
if " => " not in path:
|
|
42
|
+
return path
|
|
43
|
+
left, right = path.split(" => ", 1)
|
|
44
|
+
if "{" in left and right.endswith("}"):
|
|
45
|
+
prefix, _old = left.split("{", 1)
|
|
46
|
+
return prefix + right[:-1]
|
|
47
|
+
return right
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_stat_line(line):
|
|
51
|
+
parts = line.split("\t", 2)
|
|
52
|
+
if len(parts) != 3:
|
|
53
|
+
return None
|
|
54
|
+
ins_s, del_s, raw_path = parts
|
|
55
|
+
path = _normalize_path(raw_path)
|
|
56
|
+
if ins_s == "-" and del_s == "-":
|
|
57
|
+
return path, None, None
|
|
58
|
+
try:
|
|
59
|
+
return path, int(ins_s), int(del_s)
|
|
60
|
+
except ValueError:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_log(text):
|
|
65
|
+
commits = []
|
|
66
|
+
for rec in text.split("\x1e"):
|
|
67
|
+
lines = [line.strip() for line in rec.splitlines() if line.strip()]
|
|
68
|
+
if not lines:
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
ts = int(lines[0])
|
|
72
|
+
except ValueError:
|
|
73
|
+
continue
|
|
74
|
+
files = []
|
|
75
|
+
for line in lines[1:]:
|
|
76
|
+
parsed = _parse_stat_line(line)
|
|
77
|
+
if parsed is not None:
|
|
78
|
+
files.append(parsed)
|
|
79
|
+
commits.append((ts, files))
|
|
80
|
+
if len(commits) >= _MAX_COMMITS:
|
|
81
|
+
break
|
|
82
|
+
return commits
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _grain(span_hours):
|
|
86
|
+
if span_hours <= 48:
|
|
87
|
+
return "hour"
|
|
88
|
+
if span_hours <= 14 * 24:
|
|
89
|
+
return "day"
|
|
90
|
+
return "week"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _bucket_key(ts, grain):
|
|
94
|
+
dt = datetime.fromtimestamp(ts)
|
|
95
|
+
if grain == "hour":
|
|
96
|
+
return dt.strftime("%m-%d %H")
|
|
97
|
+
if grain == "day":
|
|
98
|
+
return dt.strftime("%m-%d")
|
|
99
|
+
year, week, _ = dt.isocalendar()
|
|
100
|
+
return f"{year}-W{week:02d}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _bucket_keys(start_ts, end_ts, grain, max_cols=_MAX_COLS):
|
|
104
|
+
start = datetime.fromtimestamp(start_ts)
|
|
105
|
+
end = datetime.fromtimestamp(end_ts)
|
|
106
|
+
if grain == "hour":
|
|
107
|
+
cursor = start.replace(minute=0, second=0, microsecond=0)
|
|
108
|
+
step = timedelta(hours=1)
|
|
109
|
+
elif grain == "day":
|
|
110
|
+
cursor = start.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
111
|
+
step = timedelta(days=1)
|
|
112
|
+
else:
|
|
113
|
+
cursor = (start - timedelta(days=start.weekday())).replace(
|
|
114
|
+
hour=0, minute=0, second=0, microsecond=0,
|
|
115
|
+
)
|
|
116
|
+
step = timedelta(days=7)
|
|
117
|
+
keys = []
|
|
118
|
+
while cursor <= end:
|
|
119
|
+
keys.append(_bucket_key(cursor.timestamp(), grain))
|
|
120
|
+
cursor += step
|
|
121
|
+
return keys[-max_cols:]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _cell_char(n):
|
|
125
|
+
ch = " "
|
|
126
|
+
for threshold, glyph in _DENSITY:
|
|
127
|
+
if n >= threshold:
|
|
128
|
+
ch = glyph
|
|
129
|
+
return ch
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _growth_char(n):
|
|
133
|
+
if n == 0:
|
|
134
|
+
return " "
|
|
135
|
+
glyph = " "
|
|
136
|
+
for threshold, pos, neg in _GROWTH_STEPS:
|
|
137
|
+
if abs(n) >= threshold:
|
|
138
|
+
if n > 0:
|
|
139
|
+
glyph = pos
|
|
140
|
+
else:
|
|
141
|
+
glyph = neg
|
|
142
|
+
return glyph
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _row(label, key, cols, cell, width, char_fn):
|
|
146
|
+
cells = "".join(f"{char_fn(cell.get((key, col), 0)):>3}" for col in cols)
|
|
147
|
+
return f"{label:<{width}} {cells}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _header(cols, width):
|
|
151
|
+
return " " * width + " " + "".join(f"{col[-3:]:>3}" for col in cols)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _short(path, width):
|
|
155
|
+
if len(path) <= width:
|
|
156
|
+
return path
|
|
157
|
+
return "..." + path[-(width - 3):]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _render_panel(title, dir_cell, file_cell, dirs, files_by_dir, cols, char_fn):
|
|
161
|
+
width = 28
|
|
162
|
+
lines = [title, "dirs", _header(cols, width)]
|
|
163
|
+
for name in dirs:
|
|
164
|
+
lines.append(_row(name, name, cols, dir_cell, width, char_fn))
|
|
165
|
+
lines.append("files")
|
|
166
|
+
for name in dirs:
|
|
167
|
+
grouped = files_by_dir.get(name, [])
|
|
168
|
+
if not grouped:
|
|
169
|
+
continue
|
|
170
|
+
lines.append(f"{name}/")
|
|
171
|
+
for path in grouped:
|
|
172
|
+
lines.append(
|
|
173
|
+
_row(_short(path, width), path, cols, file_cell, width, char_fn)
|
|
174
|
+
)
|
|
175
|
+
return lines
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _group(dirs, ranked_files):
|
|
179
|
+
files_by_dir = defaultdict(list)
|
|
180
|
+
allowed = set(dirs)
|
|
181
|
+
for path in ranked_files:
|
|
182
|
+
top = _top_dir(path)
|
|
183
|
+
if top in allowed:
|
|
184
|
+
files_by_dir[top].append(path)
|
|
185
|
+
return files_by_dir
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class CommitHeatmap:
|
|
189
|
+
"""Report a directory-grouped file x time commit heatmap.
|
|
190
|
+
|
|
191
|
+
Rule:
|
|
192
|
+
Recent commits are shown as two panels on the same axes:
|
|
193
|
+
touch counts, then per-column insert-minus-delete net.
|
|
194
|
+
|
|
195
|
+
Why:
|
|
196
|
+
How often a path moved and how much mass it gained are different
|
|
197
|
+
facts. Both are git-recomputable; neither is a quality score.
|
|
198
|
+
|
|
199
|
+
Proven in:
|
|
200
|
+
git log --numstat timestamps and paths, folded into a density
|
|
201
|
+
grid. Directories first, files second, time across. Growth is
|
|
202
|
+
per-column net, not file size.
|
|
203
|
+
|
|
204
|
+
Not this:
|
|
205
|
+
Not a gate. Not a cumulative stock. Not role inference.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
id = "commit-heatmap"
|
|
209
|
+
tier = "A"
|
|
210
|
+
kind = "eye"
|
|
211
|
+
scope = "global"
|
|
212
|
+
proven_in = ()
|
|
213
|
+
confidence = "depth-once"
|
|
214
|
+
tolerates_unparseable = False
|
|
215
|
+
|
|
216
|
+
def __init__(self, min_surface=1, max_dirs=_MAX_DIRS, max_files=_MAX_FILES):
|
|
217
|
+
if min_surface < 1:
|
|
218
|
+
raise ValueError("min_surface must be >= 1")
|
|
219
|
+
self.min_surface = min_surface
|
|
220
|
+
self.max_dirs = max_dirs
|
|
221
|
+
self.max_files = max_files
|
|
222
|
+
|
|
223
|
+
def check(self, ws):
|
|
224
|
+
commits = _parse_log(
|
|
225
|
+
ws.git_log(
|
|
226
|
+
fmt="%x1e%at",
|
|
227
|
+
extra=("--numstat", "-n", str(_MAX_COMMITS)),
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
n = len(commits)
|
|
231
|
+
if n < self.min_surface:
|
|
232
|
+
return Result(self.id, State.VACUOUS, examined_n=n, findings=[])
|
|
233
|
+
timestamps = [ts for ts, _files in commits]
|
|
234
|
+
span = max((max(timestamps) - min(timestamps)) / 3600, 0)
|
|
235
|
+
grain = _grain(span)
|
|
236
|
+
cols = _bucket_keys(min(timestamps), max(timestamps), grain)
|
|
237
|
+
retained = set(cols)
|
|
238
|
+
touch_dir = defaultdict(int)
|
|
239
|
+
touch_file = defaultdict(int)
|
|
240
|
+
grow_dir = defaultdict(int)
|
|
241
|
+
grow_file = defaultdict(int)
|
|
242
|
+
touch_dir_n = Counter()
|
|
243
|
+
touch_file_n = Counter()
|
|
244
|
+
grow_file_n = Counter()
|
|
245
|
+
for ts, files in commits:
|
|
246
|
+
col = _bucket_key(ts, grain)
|
|
247
|
+
if col not in retained:
|
|
248
|
+
continue
|
|
249
|
+
for path, ins, dele in files:
|
|
250
|
+
top = _top_dir(path)
|
|
251
|
+
touch_dir[(top, col)] += 1
|
|
252
|
+
touch_dir_n[top] += 1
|
|
253
|
+
touch_file[(path, col)] += 1
|
|
254
|
+
touch_file_n[path] += 1
|
|
255
|
+
if ins is None:
|
|
256
|
+
continue
|
|
257
|
+
net = ins - dele
|
|
258
|
+
grow_dir[(top, col)] += net
|
|
259
|
+
grow_file[(path, col)] += net
|
|
260
|
+
grow_file_n[path] += net
|
|
261
|
+
if not touch_dir_n:
|
|
262
|
+
return Result(self.id, State.VACUOUS, examined_n=n, findings=[])
|
|
263
|
+
dirs = [name for name, _ in touch_dir_n.most_common(self.max_dirs)]
|
|
264
|
+
touch_files = [p for p, _ in touch_file_n.most_common(self.max_files)]
|
|
265
|
+
grow_files = sorted(
|
|
266
|
+
grow_file_n, key=lambda path: grow_file_n[path], reverse=True,
|
|
267
|
+
)[: self.max_files]
|
|
268
|
+
lines = _render_panel(
|
|
269
|
+
"touches", touch_dir, touch_file, dirs,
|
|
270
|
+
_group(dirs, touch_files), cols, _cell_char,
|
|
271
|
+
)
|
|
272
|
+
lines.extend(_render_panel(
|
|
273
|
+
_GROWTH_CAPTION, grow_dir, grow_file, dirs,
|
|
274
|
+
_group(dirs, grow_files), cols, _growth_char,
|
|
275
|
+
))
|
|
276
|
+
measured = MeasuredValue(
|
|
277
|
+
value="\n".join(lines),
|
|
278
|
+
state=MState.MEASURED,
|
|
279
|
+
source="git_log:numstat",
|
|
280
|
+
measured_at=ws.now(),
|
|
281
|
+
)
|
|
282
|
+
return Result(
|
|
283
|
+
self.id, State.PASS, examined_n=n, findings=[], measured=measured,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def plants(self, ws):
|
|
287
|
+
yield Plant(
|
|
288
|
+
label="empty-log",
|
|
289
|
+
planted_ws=FakeWorkspace(files={"a.py": "x = 1\n"}, git_log_text=""),
|
|
290
|
+
expected=(self.id, "git_log", "0"),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def fixture(self, kit):
|
|
294
|
+
return FakeWorkspace(
|
|
295
|
+
files={"a.py": "x = 1\n"},
|
|
296
|
+
git_log_text=(
|
|
297
|
+
"\x1e1700000000\n"
|
|
298
|
+
"12\t0\tratch/checks/a.py\n"
|
|
299
|
+
"3\t1\ttests/t.py\n"
|
|
300
|
+
),
|
|
301
|
+
)
|
|
@@ -22,7 +22,8 @@ class NoConflictMarkers:
|
|
|
22
22
|
on every commit.
|
|
23
23
|
|
|
24
24
|
Proven in:
|
|
25
|
-
|
|
25
|
+
Leftover-merge scan: git conflict start and end markers,
|
|
26
|
+
never Markdown setext underlines.
|
|
26
27
|
|
|
27
28
|
Not this:
|
|
28
29
|
Not a Markdown rule. A setext underline of equals signs is
|
|
@@ -33,7 +34,7 @@ class NoConflictMarkers:
|
|
|
33
34
|
tier = "A"
|
|
34
35
|
kind = "gate"
|
|
35
36
|
scope = "global"
|
|
36
|
-
proven_in = (
|
|
37
|
+
proven_in = ()
|
|
37
38
|
confidence = "inferred"
|
|
38
39
|
tolerates_unparseable = False
|
|
39
40
|
|