clckernel 1.2.18 → 2.0.0

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 (78) hide show
  1. package/.agents/skills/clckernel_cli/SKILL.md +23 -6
  2. package/.agents/skills/clckernel_cli/reference/audit.md +13 -3
  3. package/.agents/skills/clckernel_cli/reference/commit.md +1 -1
  4. package/.agents/skills/clckernel_cli/reference/doctor.md +5 -3
  5. package/.agents/skills/clckernel_cli/reference/feature.md +3 -3
  6. package/.agents/skills/clckernel_cli/reference/guard.md +48 -12
  7. package/.agents/skills/clckernel_cli/reference/phase.md +6 -3
  8. package/.agents/skills/clckernel_cli/reference/start.md +14 -4
  9. package/.agents/skills/clckernel_cli/reference/test.md +1 -1
  10. package/.agents/skills/clckernel_cli/reference/update.md +19 -19
  11. package/CHANGELOG.md +244 -0
  12. package/LICENSE +21 -0
  13. package/README.es.md +280 -51
  14. package/README.md +274 -50
  15. package/bin/cli.js +8 -0
  16. package/package.json +12 -2
  17. package/src/adapters/agent-os.js +161 -84
  18. package/src/adapters/astro.js +8 -6
  19. package/src/adapters/base.js +27 -46
  20. package/src/adapters/django.js +14 -13
  21. package/src/adapters/fastapi.js +16 -15
  22. package/src/adapters/go.js +11 -9
  23. package/src/adapters/laravel.js +12 -10
  24. package/src/adapters/nextjs.js +7 -5
  25. package/src/adapters/rails.js +13 -11
  26. package/src/adapters/rust.js +11 -9
  27. package/src/catalog.js +58 -49
  28. package/src/config.js +38 -240
  29. package/src/detector.js +243 -44
  30. package/src/doctor.js +18 -9
  31. package/src/generator.js +737 -309
  32. package/src/gitignore.js +144 -0
  33. package/src/index.js +257 -18
  34. package/src/io.js +409 -0
  35. package/src/known-hashes.json +309 -0
  36. package/src/onboarding.js +15 -0
  37. package/src/technologies/detector.js +1 -1
  38. package/src/technologies/guards/_shared.js +1 -1
  39. package/src/ui.js +233 -40
  40. package/src/updater.js +271 -36
  41. package/tools/_changes.js +279 -0
  42. package/tools/_changes.py +233 -0
  43. package/tools/_config.js +294 -0
  44. package/tools/_config.py +271 -0
  45. package/tools/_yaml.py +489 -0
  46. package/tools/audit.js +511 -111
  47. package/tools/audit.py +506 -128
  48. package/tools/celery_guard.py +13 -35
  49. package/tools/check_a11y.js +8 -28
  50. package/tools/check_api_contracts.js +13 -39
  51. package/tools/check_architecture.js +35 -44
  52. package/tools/check_architecture.py +172 -105
  53. package/tools/check_custom.js +108 -61
  54. package/tools/check_custom.py +108 -271
  55. package/tools/check_data_files.js +88 -0
  56. package/tools/check_db_efficiency.py +80 -65
  57. package/tools/check_hitl.js +112 -69
  58. package/tools/check_links.js +138 -0
  59. package/tools/check_migrations.py +143 -94
  60. package/tools/check_performance.js +35 -44
  61. package/tools/check_placeholders.js +104 -0
  62. package/tools/check_responsive.js +8 -28
  63. package/tools/check_scope.py +40 -31
  64. package/tools/check_seo.js +8 -28
  65. package/tools/check_skills.js +158 -0
  66. package/tools/check_storybook.js +8 -21
  67. package/tools/check_symlinks.js +40 -22
  68. package/tools/check_ui_reuse.js +17 -29
  69. package/tools/docker_guard.py +19 -41
  70. package/tools/postgres_guard.py +15 -36
  71. package/tools/redis_guard.py +13 -35
  72. package/tools/scan_secrets.js +134 -67
  73. package/tools/scan_secrets.py +163 -72
  74. package/tools/verify_tdd.js +867 -0
  75. package/tools/verify_tdd.py +407 -165
  76. package/tools/yaml.js +404 -132
  77. package/tools/check_domain_data.js +0 -100
  78. package/tools/check_test_calendar_sync.js +0 -111
@@ -1,209 +1,451 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- TDD Flow Validator
4
- Validates that test files exist for changed code, checks for test patterns,
5
- and reports on test coverage presence (pytest, unittest, manage.py test).
6
- Self-contained: uses only Python stdlib (pathlib, subprocess, json).
3
+ Red -> Green TDD Verifier (pytest)
4
+
5
+ Proves that a change to Python production code ships with tests that
6
+ FAIL without the change (red) and PASS with it (green).
7
+
8
+ Two trees are compared:
9
+ - something staged: BASE = HEAD, CANDIDATE = the index (staged content)
10
+ - nothing staged: BASE = HEAD~1, CANDIDATE = HEAD
11
+ Both trees are materialized from git into temporary directories, so the
12
+ working copy is never evaluated and the caller's index/HEAD/worktree are
13
+ never touched.
14
+
15
+ Each changed file belongs to its project root: the nearest ancestor (inside
16
+ the repository) holding a Python project marker, else the repository root.
17
+ Files are grouped by root and each group is verified on its own, with
18
+ cwd and PYTHONPATH set to that root in the temporary tree; a group whose code
19
+ changed without a changed test in the same group fails. With a single root
20
+ the result is the same as a whole-repository run.
21
+
22
+ The project config (.clckernel.yaml) widens or narrows what counts as
23
+ production code: a file matching `tdd.code` is code whatever its language,
24
+ a file matching `tdd.ignore` never is. A .py file matching `tdd.code` is
25
+ handed to verify_tdd.js when it is installed next to this file (it is the
26
+ code of the JS suite, e.g. Python tools tested by JS tests).
27
+
28
+ Self-contained: Python 3.9+ standard library only.
7
29
  """
8
30
  from __future__ import annotations
9
31
 
32
+ import importlib.util
10
33
  import json
11
34
  import os
12
35
  import pathlib
36
+ import shutil
13
37
  import subprocess
14
38
  import sys
39
+ import tarfile
40
+ import tempfile
15
41
 
42
+ # Sibling helper shipped next to the guards; works both as a script and when
43
+ # loaded by audit.py through importlib.
44
+ _TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
45
+ if _TOOLS_DIR not in sys.path:
46
+ sys.path.insert(0, _TOOLS_DIR)
47
+ import _changes # noqa: E402
16
48
 
17
- def _get_changed_files(target_dir: str) -> list[str] | None:
18
- """Return list of changed files from git diff, or None on failure."""
19
- for ref in ("HEAD~1", "--cached"):
20
- try:
21
- args = ["git", "diff", "--name-only"]
22
- if ref == "--cached":
23
- args.append("--cached")
24
- else:
25
- args.append(ref)
26
- out = subprocess.check_output(
27
- args, cwd=target_dir,
28
- stderr=subprocess.DEVNULL, text=True,
29
- )
30
- files = [f.strip() for f in out.splitlines() if f.strip()]
31
- if files:
32
- return files
33
- except (subprocess.CalledProcessError, FileNotFoundError):
34
- continue
35
- return None
49
+ NAME = "verify_tdd"
50
+ DEFAULT_TIMEOUT = 300
51
+ OUTPUT_TAIL_LINES = 40
52
+ TEST_DIRS = {"tests", "test"}
53
+ # Collected test-support files: overlaid on BASE, never passed to pytest.
54
+ SUPPORT_NAMES = {"conftest.py", "__init__.py"}
55
+ # Files that make their directory a Python project root.
56
+ PROJECT_MARKERS = ("pyproject.toml", "requirements.txt", "setup.cfg", "setup.py", "manage.py")
36
57
 
37
58
 
38
- def _detect_test_framework(target_dir: str) -> str:
39
- """Detect which test framework is in use."""
40
- root = pathlib.Path(target_dir)
59
+ class GitError(Exception):
60
+ """Raised when a git command fails."""
41
61
 
42
- # Check for pytest.ini or pytest config
43
- for name in ("pytest.ini", "setup.cfg", "pyproject.toml"):
44
- fpath = root / name
45
- if fpath.exists():
46
- try:
47
- text = fpath.read_text(encoding="utf-8", errors="replace").lower()
48
- except OSError:
49
- continue
50
- if "pytest" in text:
51
- return "pytest"
52
62
 
53
- # Check for Django manage.py test
54
- if (root / "manage.py").exists():
55
- return "django"
63
+ def _never(_rel: str) -> bool:
64
+ return False
56
65
 
57
- # Check for unittest patterns
58
- for py_file in root.rglob("test_*.py"):
59
- try:
60
- content = py_file.read_text(encoding="utf-8", errors="replace")
61
- if "import unittest" in content or "unittest.TestCase" in content:
62
- return "unittest"
63
- except OSError:
64
- continue
65
66
 
66
- return "unknown"
67
+ NO_RULES = {"extra": _never, "ignore": _never, "handed": _never}
68
+ # The other-language verifier: when it is installed next to this one, a .py
69
+ # file listed in tdd.code is its code (e.g. Python tools tested by JS tests).
70
+ SIBLING_VERIFIER = "verify_tdd.js"
67
71
 
68
72
 
69
- def _find_test_files(target_dir: str) -> list[str]:
70
- """Find all test files in the project."""
71
- root = pathlib.Path(target_dir)
72
- test_files: list[str] = []
73
+ def _code_rules(repo: str) -> dict:
74
+ """
75
+ What counts as code beyond .py (tdd.code), what never does (tdd.ignore),
76
+ and which .py files are handed to the sibling verifier: those matching
77
+ tdd.code when verify_tdd.js is installed next to this file. Listing a .py
78
+ file in tdd.code has no other purpose, and verify_tdd.js already counts it
79
+ as code, so each file has exactly one owner.
80
+ """
81
+ config = _changes.project_config(repo)
82
+ extra = _changes.glob_matcher(config["tdd"]["code"] if config else [])
83
+ sibling = os.path.isfile(os.path.join(_TOOLS_DIR, SIBLING_VERIFIER))
84
+ return {
85
+ "extra": extra,
86
+ "ignore": _changes.glob_matcher(config["tdd"]["ignore"] if config else []),
87
+ "handed": (lambda rel: rel.endswith(".py") and extra(rel)) if sibling else _never,
88
+ }
73
89
 
74
- for py_file in root.rglob("test_*.py"):
75
- rel = str(py_file.relative_to(root))
76
- if "__pycache__" not in rel:
77
- test_files.append(rel)
78
90
 
79
- for py_file in root.rglob("*_test.py"):
80
- rel = str(py_file.relative_to(root))
81
- if "__pycache__" not in rel and rel not in test_files:
82
- test_files.append(rel)
91
+ def _in_scope(rel: str, rules: dict) -> bool:
92
+ """True for a file the verifier looks at: .py, or listed in tdd.code (minus handed files)."""
93
+ if rules["handed"](rel):
94
+ return False
95
+ return rel.endswith(".py") or rules["extra"](rel)
83
96
 
84
- # Check tests/ directories
85
- for tests_dir in root.rglob("tests"):
86
- if tests_dir.is_dir():
87
- for py_file in tests_dir.glob("*.py"):
88
- rel = str(py_file.relative_to(root))
89
- if "__pycache__" not in rel and py_file.name != "__init__.py" and rel not in test_files:
90
- test_files.append(rel)
91
97
 
92
- return sorted(test_files)
98
+ def _git(repo: str, *args: str) -> str:
99
+ try:
100
+ return subprocess.run(
101
+ ["git", *args], cwd=repo, check=True,
102
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
103
+ ).stdout
104
+ except (subprocess.CalledProcessError, FileNotFoundError) as exc:
105
+ raise GitError(str(exc)) from exc
93
106
 
94
107
 
95
- def _count_test_methods(target_dir: str, filepath: str) -> int:
96
- """Count test methods in a test file."""
97
- full_path = pathlib.Path(target_dir) / filepath
108
+ def _rev_exists(repo: str, rev: str) -> bool:
98
109
  try:
99
- content = full_path.read_text(encoding="utf-8", errors="replace")
100
- except OSError:
101
- return 0
110
+ _git(repo, "rev-parse", "--verify", "--quiet", f"{rev}^{{commit}}")
111
+ return True
112
+ except GitError:
113
+ return False
102
114
 
103
- import re
104
- # Count test_ method definitions and TestCase subclasses
105
- test_funcs = len(re.findall(r"def (test_\w+)", content))
106
- return test_funcs
107
115
 
108
-
109
- def check(target_dir: str | None = None) -> dict:
116
+ def _resolve_trees(repo: str, rules: dict = NO_RULES) -> dict | None:
110
117
  """
111
- Validate TDD flow: tests exist, test framework detected, coverage signals.
112
-
113
- Returns:
114
- dict with keys: exit_code, skipped, name, data
115
- data.framework, data.test_files, data.changed_code_files,
116
- data.test_count, data.has_test_coverage
118
+ Pick BASE/CANDIDATE and list the changed files (deletions ignored). Pure
119
+ renames (identical content moved to a new path) are not changes to verify:
120
+ they are listed apart in "renamed" and count neither as code nor as tests.
117
121
  """
118
- dir_path = target_dir or os.getcwd()
122
+ all_changes = _changes.changed_files(repo)
123
+ if all_changes["mode"] is None:
124
+ raise GitError("could not list the changed files")
125
+ pure = _changes.pure_renames(repo, all_changes["mode"])
126
+ changes = {"mode": all_changes["mode"], "files": [f for f in all_changes["files"] if f not in pure]}
127
+ renamed = [f for f in all_changes["files"] if f in pure and _in_scope(f, rules)]
128
+ has_head = _rev_exists(repo, "HEAD")
129
+
130
+ if changes["mode"] == "staged":
131
+ if not has_head and not changes["files"]:
132
+ return None # no commits and nothing staged
133
+ return {"mode": "staged", "base": "HEAD" if has_head else None,
134
+ "candidate": None, "changed": changes["files"], "renamed": renamed,
135
+ "snapshot": changes["mode"]}
136
+
137
+ base = "HEAD~1" if _rev_exists(repo, "HEAD~1") else None
138
+ return {"mode": "manual", "base": base, "candidate": "HEAD", "changed": changes["files"],
139
+ "renamed": renamed, "snapshot": changes["mode"]}
140
+
141
+
142
+ def _project_root(rel: str, files: set) -> str:
143
+ """Nearest ancestor dir of rel holding a project marker; "." for the repository root."""
144
+ parts = pathlib.PurePosixPath(rel).parts[:-1]
145
+ for depth in range(len(parts), 0, -1):
146
+ root = "/".join(parts[:depth])
147
+ if any(f"{root}/{marker}" in files for marker in PROJECT_MARKERS):
148
+ return root
149
+ return "."
150
+
151
+
152
+ def _by_root(repo: str, trees: dict, rules: dict = NO_RULES) -> dict:
153
+ """Changed .py (and tdd.code) files grouped by project root (insertion order: first seen)."""
154
+ files = set(_changes.list_files(repo, trees["snapshot"]))
155
+ roots: dict = {}
156
+ for rel in trees["changed"]:
157
+ if _in_scope(rel, rules):
158
+ roots.setdefault(_project_root(rel, files), []).append(rel)
159
+ return roots
160
+
161
+
162
+ def _relative(rel: str, root: str) -> str:
163
+ return rel if root == "." else rel[len(root) + 1:]
164
+
165
+
166
+ def _is_test_path(rel: str) -> bool:
167
+ parts = pathlib.PurePosixPath(rel).parts
168
+ name = parts[-1]
169
+ return (
170
+ name.startswith("test_")
171
+ or name.endswith("_test.py")
172
+ or any(part in TEST_DIRS for part in parts[:-1])
173
+ )
174
+
175
+
176
+ def _classify(changed: list[str], rules: dict = NO_RULES) -> dict:
177
+ """Split into tests, support and code; tdd.code files are code, tdd.ignore files never are."""
178
+ tests: list[str] = []
179
+ support: list[str] = []
180
+ code: list[str] = []
181
+ for rel in changed:
182
+ if not _in_scope(rel, rules):
183
+ continue
184
+ if rules["extra"](rel):
185
+ if not rules["ignore"](rel):
186
+ code.append(rel)
187
+ continue
188
+ name = pathlib.PurePosixPath(rel).name
189
+ if name == "conftest.py":
190
+ support.append(rel)
191
+ elif _is_test_path(rel):
192
+ (support if name in SUPPORT_NAMES else tests).append(rel)
193
+ elif not rules["ignore"](rel):
194
+ code.append(rel)
195
+ return {"tests": tests, "support": support, "code": code}
196
+
197
+
198
+ def _materialize(repo: str, rev: str | None, dest: str) -> None:
199
+ """Write a tree into dest: the index when rev is None, else a commit."""
200
+ if rev is None:
201
+ _git(repo, "checkout-index", "-a", f"--prefix={dest.rstrip(os.sep)}{os.sep}")
202
+ return
203
+ proc = subprocess.Popen(
204
+ ["git", "archive", "--format=tar", rev], cwd=repo,
205
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
206
+ )
207
+ root = os.path.realpath(dest)
208
+ try:
209
+ with tarfile.open(fileobj=proc.stdout, mode="r|") as tar:
210
+ for member in tar:
211
+ target = os.path.realpath(os.path.join(root, member.name))
212
+ if target != root and not target.startswith(root + os.sep):
213
+ raise GitError(f"unsafe path in archive: {member.name}")
214
+ if hasattr(tarfile, "data_filter"):
215
+ tar.extract(member, root, filter="data")
216
+ else:
217
+ tar.extract(member, root)
218
+ finally:
219
+ if proc.stdout:
220
+ proc.stdout.close()
221
+ stderr = proc.stderr.read().decode("utf-8", "replace") if proc.stderr else ""
222
+ if proc.wait() != 0:
223
+ raise GitError(f"git archive {rev} failed: {stderr.strip()}")
224
+
225
+
226
+ def _overlay(src_root: str, dest_root: str, files: list[str]) -> None:
227
+ for rel in files:
228
+ dest = os.path.join(dest_root, rel)
229
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
230
+ shutil.copy2(os.path.join(src_root, rel), dest)
231
+
232
+
233
+ def _timeout() -> float:
234
+ try:
235
+ value = float(os.environ.get("CLC_TDD_TIMEOUT", DEFAULT_TIMEOUT))
236
+ return value if value > 0 else DEFAULT_TIMEOUT
237
+ except ValueError:
238
+ return DEFAULT_TIMEOUT
239
+
240
+
241
+ def _tail(text: str) -> str:
242
+ return "\n".join(text.splitlines()[-OUTPUT_TAIL_LINES:])
243
+
119
244
 
120
- changed = _get_changed_files(dir_path)
121
- if changed is None:
122
- return {
123
- "exit_code": 0, "skipped": True, "name": "verify_tdd",
124
- "data": {"message": "No git diff available"},
125
- }
126
-
127
- if not changed:
128
- return {
129
- "exit_code": 0, "skipped": False, "name": "verify_tdd",
130
- "data": {
131
- "framework": "unknown", "test_files": [],
132
- "changed_code_files": [], "test_count": 0,
133
- "has_test_coverage": False,
134
- "message": "No changed files",
135
- },
136
- }
137
-
138
- framework = _detect_test_framework(dir_path)
139
- test_files = _find_test_files(dir_path)
140
-
141
- # Separate test files from code files in the diff
142
- changed_tests = [f for f in changed if f.endswith(".py") and _is_test_file(f)]
143
- changed_code = [f for f in changed if f.endswith(".py") and not _is_test_file(f)]
144
-
145
- # Count total test methods
146
- total_tests = sum(_count_test_methods(dir_path, tf) for tf in test_files)
147
-
148
- # Determine if there's meaningful test coverage
149
- has_tests = len(test_files) > 0
150
- has_test_in_diff = len(changed_tests) > 0
151
- has_code_in_diff = len(changed_code) > 0
152
-
153
- # TDD indicator: if code changed but no test changed, flag it
154
- if has_code_in_diff and not has_test_in_diff and has_tests:
155
- msg = (
156
- f"Code files changed without corresponding test changes "
157
- f"({len(changed_code)} code files, {len(changed_tests)} test files). "
158
- f"Framework: {framework}. Total test methods: {total_tests}"
245
+ def _run_pytest(tree: str, tests: list[str]) -> dict:
246
+ env = dict(os.environ)
247
+ env["PYTHONDONTWRITEBYTECODE"] = "1"
248
+ # Put the materialized tree ahead of anything installed (e.g. an editable
249
+ # install pointing at the working copy).
250
+ roots = [tree] + ([os.path.join(tree, "src")] if os.path.isdir(os.path.join(tree, "src")) else [])
251
+ env["PYTHONPATH"] = os.pathsep.join(roots + ([env["PYTHONPATH"]] if env.get("PYTHONPATH") else []))
252
+ cmd = [sys.executable, "-m", "pytest", *tests, "-q", "-p", "no:cacheprovider"]
253
+ try:
254
+ proc = subprocess.run(
255
+ cmd, cwd=tree, env=env, timeout=_timeout(),
256
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
159
257
  )
160
- exit_code = 1
161
- elif has_code_in_diff and not has_test_in_diff and not has_tests:
162
- msg = (
163
- f"No test files found in project. "
164
- f"{len(changed_code)} code file(s) changed without tests. "
165
- f"Framework: {framework}"
258
+ return {"exit_code": proc.returncode, "timed_out": False, "output": _tail(proc.stdout)}
259
+ except subprocess.TimeoutExpired as exc:
260
+ out = exc.stdout.decode("utf-8", "replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
261
+ return {"exit_code": None, "timed_out": True, "output": _tail(out)}
262
+
263
+
264
+ def _result(exit_code: int, data: dict, skipped: bool = False) -> dict:
265
+ return {"exit_code": exit_code, "skipped": skipped, "name": NAME, "data": data}
266
+
267
+
268
+ def _pytest_missing(data: dict) -> dict | None:
269
+ if importlib.util.find_spec("pytest") is not None:
270
+ return None
271
+ data["message"] = (
272
+ f"pytest is not importable by {sys.executable}: red->green cannot be verified. "
273
+ "Install it with `pip install pytest`."
274
+ )
275
+ return _result(1, data)
276
+
277
+
278
+ def _materialize_trees(repo: str, trees: dict, tmp: str) -> tuple:
279
+ candidate_dir = os.path.join(tmp, "candidate")
280
+ base_dir = os.path.join(tmp, "base")
281
+ os.makedirs(candidate_dir)
282
+ os.makedirs(base_dir)
283
+ _materialize(repo, trees["candidate"], candidate_dir)
284
+ if trees["base"] is not None:
285
+ _materialize(repo, trees["base"], base_dir)
286
+ return candidate_dir, base_dir
287
+
288
+
289
+ def _red_green(candidate_root: str, base_root: str, root: str, groups: dict, data: dict) -> dict:
290
+ """Green on CANDIDATE, then red on BASE + the changed tests, inside one project root."""
291
+ run_files = [_relative(f, root) for f in groups["tests"]]
292
+ overlay_files = [_relative(f, root) for f in groups["tests"] + groups["support"]]
293
+ candidate_dir = candidate_root if root == "." else os.path.join(candidate_root, root)
294
+ base_dir = base_root if root == "." else os.path.join(base_root, root)
295
+ os.makedirs(base_dir, exist_ok=True)
296
+
297
+ green = _run_pytest(candidate_dir, run_files)
298
+ data["green"] = green
299
+ if green["exit_code"] != 0:
300
+ reason = "timed out" if green["timed_out"] else f"pytest exit {green['exit_code']}"
301
+ data["message"] = f"Changed tests do not pass with the change ({reason})."
302
+ return _result(1, data)
303
+
304
+ _overlay(candidate_dir, base_dir, overlay_files)
305
+ red = _run_pytest(base_dir, run_files)
306
+ data["red"] = red
307
+ if red["exit_code"] in (1, 2):
308
+ data["message"] = (
309
+ f"TDD red->green verified: {len(run_files)} test file(s) fail without the change "
310
+ f"and pass with it."
166
311
  )
167
- exit_code = 1
168
- elif has_test_in_diff:
169
- msg = (
170
- f"TDD flow detected: {len(changed_tests)} test file(s) changed "
171
- f"alongside {len(changed_code)} code file(s). "
172
- f"Framework: {framework}. Total test methods: {total_tests}"
312
+ return _result(0, data)
313
+ if red["exit_code"] == 0:
314
+ data["message"] = (
315
+ "Changed tests pass without the change: they do not prove the new behavior."
173
316
  )
174
- exit_code = 0
175
- else:
176
- msg = (
177
- f"No Python code changes detected. "
178
- f"Framework: {framework}. Total test methods: {total_tests}"
317
+ return _result(1, data)
318
+ reason = "timed out" if red["timed_out"] else f"pytest exit {red['exit_code']}"
319
+ data["message"] = f"Red run on BASE did not fail cleanly ({reason}); see red output."
320
+ return _result(1, data)
321
+
322
+
323
+ def _verify(repo: str, trees: dict, groups: dict, data: dict, root: str = ".") -> dict:
324
+ missing = _pytest_missing(data)
325
+ if missing:
326
+ return missing
327
+ with tempfile.TemporaryDirectory(prefix="clc-verify-tdd-") as tmp:
328
+ candidate_dir, base_dir = _materialize_trees(repo, trees, tmp)
329
+ return _red_green(candidate_dir, base_dir, root, groups, data)
330
+
331
+
332
+ def _precheck(groups: dict, data: dict, lang_label: str = "Python") -> dict | None:
333
+ """Outcome decided without running tests, or None when red/green must run."""
334
+ if not groups["code"]:
335
+ if groups["tests"]:
336
+ data["message"] = "Only tests changed: characterization tests allowed."
337
+ data["warning"] = "No production code changed, so red->green was not verified."
338
+ elif data.get("pure_renames"):
339
+ data["message"] = "Only pure renames: nothing to verify."
340
+ else:
341
+ data["message"] = f"No {lang_label} production code changed: nothing to verify."
342
+ return _result(0, data)
343
+ if not groups["tests"]:
344
+ data["message"] = (
345
+ f"No test evidence: {len(groups['code'])} production code file(s) changed "
346
+ "without any changed test file."
179
347
  )
180
- exit_code = 0
348
+ return _result(1, data)
349
+ return None
181
350
 
351
+
352
+ def _group_data(root: str, groups: dict) -> dict:
182
353
  return {
183
- "exit_code": exit_code,
184
- "skipped": False,
185
- "name": "verify_tdd",
186
- "data": {
187
- "framework": framework,
188
- "test_files": test_files[:20], # Cap at 20 for readability
189
- "changed_code_files": changed_code,
190
- "changed_test_files": changed_tests,
191
- "test_count": total_tests,
192
- "has_test_coverage": has_tests,
193
- "message": msg,
194
- },
354
+ "root": root,
355
+ "changed_tests": groups["tests"],
356
+ "changed_support": groups["support"],
357
+ "changed_code": groups["code"],
195
358
  }
196
359
 
197
360
 
198
- def _is_test_file(filepath: str) -> bool:
199
- """Check if a filepath is a test file."""
200
- basename = pathlib.Path(filepath).name
201
- return (
202
- basename.startswith("test_")
203
- or basename.endswith("_test.py")
204
- or "/tests/" in filepath
205
- or "/test/" in filepath
206
- )
361
+ def _verify_roots(repo: str, trees: dict, roots: dict, data: dict, rules: dict = NO_RULES) -> dict:
362
+ """Several project roots: each group passes on its own, or the change fails."""
363
+ results = []
364
+ pending = []
365
+ for root, files in roots.items():
366
+ groups = _classify(files, rules)
367
+ gdata = _group_data(root, groups)
368
+ early = _precheck(groups, gdata)
369
+ if early is not None and early["exit_code"] == 1:
370
+ gdata["message"] = (
371
+ f"No test evidence: {len(groups['code'])} production code file(s) changed in {root} "
372
+ f"without any changed test file in {root}."
373
+ )
374
+ results.append((root, groups, gdata, early))
375
+ if early is None:
376
+ pending.append(len(results) - 1)
377
+
378
+ if pending:
379
+ missing = _pytest_missing({})
380
+ try:
381
+ with tempfile.TemporaryDirectory(prefix="clc-verify-tdd-") as tmp:
382
+ candidate_dir, base_dir = (None, None) if missing else _materialize_trees(repo, trees, tmp)
383
+ for idx in pending:
384
+ root, groups, gdata, _ = results[idx]
385
+ outcome = _pytest_missing(gdata) or _red_green(candidate_dir, base_dir, root, groups, gdata)
386
+ results[idx] = (root, groups, gdata, outcome)
387
+ except (GitError, OSError) as exc:
388
+ data["message"] = f"Could not materialize the trees to verify: {exc}"
389
+ return _result(1, data)
390
+
391
+ data["groups"] = [dict(gdata, exit_code=outcome["exit_code"]) for _, _, gdata, outcome in results]
392
+ failed = [g for g in data["groups"] if g["exit_code"] != 0]
393
+ shown = failed or data["groups"]
394
+ data["message"] = " ".join(f"[{g['root']}] {g['message']}" for g in shown)
395
+ return _result(1 if failed else 0, data)
396
+
397
+
398
+ def check(target_dir: str | None = None) -> dict:
399
+ """
400
+ Verify red->green for the current change.
401
+
402
+ Returns:
403
+ dict with keys exit_code, skipped, name, data. data includes mode,
404
+ base, candidate, changed_tests, changed_support, changed_code,
405
+ message and, when tests ran, green/red pytest results.
406
+ """
407
+ dir_path = target_dir or os.getcwd()
408
+ try:
409
+ repo = _git(dir_path, "rev-parse", "--show-toplevel").strip()
410
+ except GitError:
411
+ return _result(0, {"message": "Not a git repository: nothing to verify"}, skipped=True)
412
+ # An invalid config raises here: never verify against a config we cannot read.
413
+ rules = _code_rules(repo)
414
+ try:
415
+ trees = _resolve_trees(repo, rules)
416
+ except GitError:
417
+ return _result(0, {"message": "Not a git repository: nothing to verify"}, skipped=True)
418
+ if trees is None:
419
+ return _result(0, {"message": "No commits and nothing staged: nothing to verify"}, skipped=True)
420
+
421
+ groups = _classify(trees["changed"], rules)
422
+ data = {
423
+ "mode": trees["mode"],
424
+ "base": trees["base"] or "(empty tree)",
425
+ "candidate": trees["candidate"] or "(index)",
426
+ "changed_tests": groups["tests"],
427
+ "changed_support": groups["support"],
428
+ "changed_code": groups["code"],
429
+ }
430
+ if trees["renamed"]:
431
+ data["pure_renames"] = trees["renamed"]
432
+
433
+ roots = _by_root(repo, trees, rules)
434
+ if len(roots) > 1:
435
+ return _verify_roots(repo, trees, roots, data, rules)
436
+ root = next(iter(roots), ".")
437
+ if root != ".":
438
+ data["root"] = root
439
+
440
+ early = _precheck(groups, data)
441
+ if early is not None:
442
+ return early
443
+
444
+ try:
445
+ return _verify(repo, trees, groups, data, root)
446
+ except (GitError, OSError) as exc:
447
+ data["message"] = f"Could not materialize the trees to verify: {exc}"
448
+ return _result(1, data)
207
449
 
208
450
 
209
451
  if __name__ == "__main__":