clckernel 1.2.19 → 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.
- package/.agents/skills/clckernel_cli/SKILL.md +23 -6
- package/.agents/skills/clckernel_cli/reference/audit.md +13 -3
- package/.agents/skills/clckernel_cli/reference/commit.md +1 -1
- package/.agents/skills/clckernel_cli/reference/doctor.md +5 -3
- package/.agents/skills/clckernel_cli/reference/feature.md +3 -3
- package/.agents/skills/clckernel_cli/reference/guard.md +48 -12
- package/.agents/skills/clckernel_cli/reference/phase.md +6 -3
- package/.agents/skills/clckernel_cli/reference/start.md +14 -4
- package/.agents/skills/clckernel_cli/reference/test.md +1 -1
- package/.agents/skills/clckernel_cli/reference/update.md +19 -19
- package/CHANGELOG.md +244 -0
- package/LICENSE +21 -0
- package/README.es.md +280 -51
- package/README.md +274 -50
- package/bin/cli.js +8 -0
- package/package.json +12 -2
- package/src/adapters/agent-os.js +161 -84
- package/src/adapters/astro.js +8 -6
- package/src/adapters/base.js +23 -38
- package/src/adapters/django.js +14 -13
- package/src/adapters/fastapi.js +16 -15
- package/src/adapters/go.js +11 -9
- package/src/adapters/laravel.js +12 -10
- package/src/adapters/nextjs.js +7 -5
- package/src/adapters/rails.js +13 -11
- package/src/adapters/rust.js +11 -9
- package/src/catalog.js +58 -49
- package/src/config.js +38 -240
- package/src/detector.js +243 -44
- package/src/doctor.js +18 -9
- package/src/generator.js +734 -302
- package/src/gitignore.js +60 -14
- package/src/index.js +257 -18
- package/src/io.js +409 -0
- package/src/known-hashes.json +309 -0
- package/src/onboarding.js +15 -0
- package/src/technologies/detector.js +1 -1
- package/src/technologies/guards/_shared.js +1 -1
- package/src/ui.js +233 -40
- package/src/updater.js +271 -38
- package/tools/_changes.js +279 -0
- package/tools/_changes.py +233 -0
- package/tools/_config.js +294 -0
- package/tools/_config.py +271 -0
- package/tools/_yaml.py +489 -0
- package/tools/audit.js +511 -111
- package/tools/audit.py +506 -128
- package/tools/celery_guard.py +13 -35
- package/tools/check_a11y.js +8 -28
- package/tools/check_api_contracts.js +13 -39
- package/tools/check_architecture.js +35 -44
- package/tools/check_architecture.py +172 -105
- package/tools/check_custom.js +108 -61
- package/tools/check_custom.py +108 -271
- package/tools/check_data_files.js +88 -0
- package/tools/check_db_efficiency.py +80 -65
- package/tools/check_hitl.js +112 -69
- package/tools/check_links.js +138 -0
- package/tools/check_migrations.py +143 -94
- package/tools/check_performance.js +35 -44
- package/tools/check_placeholders.js +104 -0
- package/tools/check_responsive.js +8 -28
- package/tools/check_scope.py +40 -31
- package/tools/check_seo.js +8 -28
- package/tools/check_skills.js +158 -0
- package/tools/check_storybook.js +8 -21
- package/tools/check_symlinks.js +40 -22
- package/tools/check_ui_reuse.js +17 -29
- package/tools/docker_guard.py +19 -41
- package/tools/postgres_guard.py +15 -36
- package/tools/redis_guard.py +13 -35
- package/tools/scan_secrets.js +134 -67
- package/tools/scan_secrets.py +163 -72
- package/tools/verify_tdd.js +867 -0
- package/tools/verify_tdd.py +407 -165
- package/tools/yaml.js +404 -132
- package/tools/check_domain_data.js +0 -100
- package/tools/check_test_calendar_sync.js +0 -111
package/tools/verify_tdd.py
CHANGED
|
@@ -1,209 +1,451 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
TDD
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
39
|
-
"""
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
70
|
-
"""
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
175
|
-
else
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
348
|
+
return _result(1, data)
|
|
349
|
+
return None
|
|
181
350
|
|
|
351
|
+
|
|
352
|
+
def _group_data(root: str, groups: dict) -> dict:
|
|
182
353
|
return {
|
|
183
|
-
"
|
|
184
|
-
"
|
|
185
|
-
"
|
|
186
|
-
"
|
|
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
|
|
199
|
-
"""
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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__":
|