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.
Files changed (86) hide show
  1. {ratch-0.1.0/ratch.egg-info → ratch-0.1.2}/PKG-INFO +1 -1
  2. ratch-0.1.2/README.md +72 -0
  3. {ratch-0.1.0 → ratch-0.1.2}/pyproject.toml +8 -1
  4. ratch-0.1.2/ratch/checks/__init__.py +32 -0
  5. {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/ai_signatures.py +4 -2
  6. ratch-0.1.2/ratch/checks/autoclose.py +83 -0
  7. {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/bdd_conventions.py +43 -3
  8. {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/catalog_size.py +3 -2
  9. {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/circular_import.py +3 -2
  10. ratch-0.1.2/ratch/checks/commit_heatmap.py +301 -0
  11. {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/conflict_markers.py +3 -2
  12. ratch-0.1.2/ratch/checks/doc_cli.py +119 -0
  13. {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/doc_counts.py +3 -2
  14. {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/docs_render.py +3 -2
  15. {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/first_person.py +3 -2
  16. {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/font_cdn.py +3 -2
  17. {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/forbidden_literal.py +4 -2
  18. {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/hash_named_test.py +3 -2
  19. ratch-0.1.2/ratch/checks/injected_clock.py +135 -0
  20. ratch-0.1.2/ratch/checks/internal_refs.py +85 -0
  21. ratch-0.1.2/ratch/checks/loc_cap.py +81 -0
  22. {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/manifest_purity.py +4 -2
  23. {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/plugin_registry.py +15 -18
  24. {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/pytest_skip.py +11 -6
  25. ratch-0.1.2/ratch/checks/reassurance.py +84 -0
  26. ratch-0.1.2/ratch/checks/repo_root_ssot.py +84 -0
  27. {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/todo_issue.py +3 -2
  28. {ratch-0.1.0 → ratch-0.1.2}/ratch/checks/vacuous_assert.py +3 -2
  29. {ratch-0.1.0 → ratch-0.1.2}/ratch/cli.py +15 -10
  30. {ratch-0.1.0 → ratch-0.1.2}/ratch/reporter.py +8 -2
  31. {ratch-0.1.0 → ratch-0.1.2}/ratch/testing.py +1 -1
  32. {ratch-0.1.0 → ratch-0.1.2}/ratch/workspace.py +2 -2
  33. {ratch-0.1.0 → ratch-0.1.2/ratch.egg-info}/PKG-INFO +1 -1
  34. {ratch-0.1.0 → ratch-0.1.2}/ratch.egg-info/SOURCES.txt +14 -0
  35. {ratch-0.1.0 → ratch-0.1.2}/ratch.egg-info/entry_points.txt +7 -0
  36. {ratch-0.1.0 → ratch-0.1.2}/tests/test_ai_signatures.py +5 -1
  37. ratch-0.1.2/tests/test_autoclose.py +45 -0
  38. {ratch-0.1.0 → ratch-0.1.2}/tests/test_bdd_conventions.py +43 -10
  39. {ratch-0.1.0 → ratch-0.1.2}/tests/test_catalog_size.py +16 -3
  40. {ratch-0.1.0 → ratch-0.1.2}/tests/test_check.py +12 -12
  41. {ratch-0.1.0 → ratch-0.1.2}/tests/test_circular_import.py +9 -0
  42. {ratch-0.1.0 → ratch-0.1.2}/tests/test_cli.py +4 -1
  43. ratch-0.1.2/tests/test_commit_heatmap.py +140 -0
  44. {ratch-0.1.0 → ratch-0.1.2}/tests/test_conflict_markers.py +15 -2
  45. ratch-0.1.2/tests/test_doc_cli.py +54 -0
  46. {ratch-0.1.0 → ratch-0.1.2}/tests/test_doc_counts.py +23 -3
  47. {ratch-0.1.0 → ratch-0.1.2}/tests/test_docs_render.py +21 -4
  48. {ratch-0.1.0 → ratch-0.1.2}/tests/test_first_person.py +38 -2
  49. {ratch-0.1.0 → ratch-0.1.2}/tests/test_font_cdn.py +17 -2
  50. {ratch-0.1.0 → ratch-0.1.2}/tests/test_forbidden_literal.py +5 -1
  51. {ratch-0.1.0 → ratch-0.1.2}/tests/test_hash_named_test.py +15 -2
  52. ratch-0.1.2/tests/test_injected_clock.py +66 -0
  53. ratch-0.1.2/tests/test_internal_refs.py +45 -0
  54. ratch-0.1.2/tests/test_loc_cap.py +57 -0
  55. {ratch-0.1.0 → ratch-0.1.2}/tests/test_manifest_purity.py +24 -2
  56. {ratch-0.1.0 → ratch-0.1.2}/tests/test_plugin_registry.py +12 -2
  57. {ratch-0.1.0 → ratch-0.1.2}/tests/test_pytest_seam.py +26 -5
  58. {ratch-0.1.0 → ratch-0.1.2}/tests/test_pytest_skip.py +28 -2
  59. ratch-0.1.2/tests/test_reassurance.py +44 -0
  60. {ratch-0.1.0 → ratch-0.1.2}/tests/test_registry.py +22 -13
  61. ratch-0.1.2/tests/test_repo_root_ssot.py +44 -0
  62. {ratch-0.1.0 → ratch-0.1.2}/tests/test_result.py +7 -5
  63. {ratch-0.1.0 → ratch-0.1.2}/tests/test_runner.py +12 -4
  64. {ratch-0.1.0 → ratch-0.1.2}/tests/test_self_host.py +13 -5
  65. {ratch-0.1.0 → ratch-0.1.2}/tests/test_self_host_blackbox.py +12 -6
  66. {ratch-0.1.0 → ratch-0.1.2}/tests/test_testing.py +5 -7
  67. {ratch-0.1.0 → ratch-0.1.2}/tests/test_todo_issue.py +13 -2
  68. {ratch-0.1.0 → ratch-0.1.2}/tests/test_vacuous_assert.py +17 -2
  69. ratch-0.1.0/README.md +0 -30
  70. ratch-0.1.0/ratch/checks/__init__.py +0 -7
  71. ratch-0.1.0/ratch/checks/commit_heatmap.py +0 -89
  72. ratch-0.1.0/tests/test_commit_heatmap.py +0 -37
  73. {ratch-0.1.0 → ratch-0.1.2}/LICENSE +0 -0
  74. {ratch-0.1.0 → ratch-0.1.2}/ratch/__init__.py +0 -0
  75. {ratch-0.1.0 → ratch-0.1.2}/ratch/__main__.py +0 -0
  76. {ratch-0.1.0 → ratch-0.1.2}/ratch/check.py +0 -0
  77. {ratch-0.1.0 → ratch-0.1.2}/ratch/pytest_plugin.py +0 -0
  78. {ratch-0.1.0 → ratch-0.1.2}/ratch/registry.py +0 -0
  79. {ratch-0.1.0 → ratch-0.1.2}/ratch/result.py +0 -0
  80. {ratch-0.1.0 → ratch-0.1.2}/ratch/runner.py +0 -0
  81. {ratch-0.1.0 → ratch-0.1.2}/ratch.egg-info/dependency_links.txt +0 -0
  82. {ratch-0.1.0 → ratch-0.1.2}/ratch.egg-info/requires.txt +0 -0
  83. {ratch-0.1.0 → ratch-0.1.2}/ratch.egg-info/top_level.txt +0 -0
  84. {ratch-0.1.0 → ratch-0.1.2}/setup.cfg +0 -0
  85. {ratch-0.1.0 → ratch-0.1.2}/tests/test_reporter.py +0 -0
  86. {ratch-0.1.0 → ratch-0.1.2}/tests/test_workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ratch
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: The ratchet only turns one way: code is the single source of truth.
5
5
  License: Apache-2.0
6
6
  Requires-Python: >=3.11
ratch-0.1.2/README.md ADDED
@@ -0,0 +1,72 @@
1
+ > **[中文](README.zh.md)** · English
2
+
3
+ # ratch
4
+
5
+ [![ci](https://github.com/wan9yu/ratch/actions/workflows/ci.yml/badge.svg)](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.0"
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
- agent-runner/tests/invariants/test_no_ai_signatures.py
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 = ("agent-runner/tests/invariants/test_no_ai_signatures.py",)
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
- internal/pillar-1-teeth.md
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 = ("internal/pillar-1-teeth.md",)
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": "def foo_should_bar_when_baz():\n pass\n"}
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
- internal/specs/2026-09-17-ratchet-v1-spine-design.md
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 = ("internal/specs/2026-09-17-ratchet-v1-spine-design.md",)
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
- argus-gateway/tests/test_invariant_no_circular_import.py
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 = ("argus-gateway/tests/test_invariant_no_circular_import.py",)
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
- internal/pillar-1-teeth.md
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 = ("internal/pillar-1-teeth.md",)
37
+ proven_in = ()
37
38
  confidence = "inferred"
38
39
  tolerates_unparseable = False
39
40