clckernel 1.2.6
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.md +124 -0
- package/README.es.md +166 -0
- package/README.md +167 -0
- package/bin/cli.js +12 -0
- package/docs/Journal/001-adr-clckernel-governance.md +18 -0
- package/package.json +44 -0
- package/src/adapters/astro.js +67 -0
- package/src/adapters/base.js +109 -0
- package/src/adapters/django.js +70 -0
- package/src/adapters/fastapi.js +69 -0
- package/src/adapters/go.js +50 -0
- package/src/adapters/laravel.js +52 -0
- package/src/adapters/nextjs.js +72 -0
- package/src/adapters/rails.js +54 -0
- package/src/adapters/rust.js +51 -0
- package/src/catalog.js +217 -0
- package/src/config.js +250 -0
- package/src/detector.js +85 -0
- package/src/doctor.js +125 -0
- package/src/generator.js +473 -0
- package/src/index.js +52 -0
- package/src/technologies/detector.js +243 -0
- package/src/technologies/guards/_shared.js +66 -0
- package/src/ui.js +101 -0
- package/src/yaml.js +173 -0
- package/test/adapters/astro.test.js +102 -0
- package/test/adapters/base.test.js +86 -0
- package/test/adapters/django.test.js +95 -0
- package/test/adapters/fastapi.test.js +88 -0
- package/test/adapters/go.test.js +81 -0
- package/test/adapters/laravel.test.js +106 -0
- package/test/adapters/nextjs.test.js +113 -0
- package/test/adapters/rails.test.js +108 -0
- package/test/adapters/rust.test.js +83 -0
- package/test/catalog.test.js +170 -0
- package/test/config.test.js +512 -0
- package/test/detector.test.js +345 -0
- package/test/doctor.test.js +302 -0
- package/test/generator.test.js +419 -0
- package/test/guards_new.test.js +95 -0
- package/test/helpers.js +36 -0
- package/test/integration/cli-flow.test.js +270 -0
- package/test/technologies/celery_guard.test.js +185 -0
- package/test/technologies/detector.test.js +342 -0
- package/test/technologies/docker_guard.test.js +187 -0
- package/test/technologies/integration.test.js +123 -0
- package/test/technologies/postgres_guard.test.js +87 -0
- package/test/technologies/redis_guard.test.js +128 -0
- package/tools/audit.js +219 -0
- package/tools/audit.py +273 -0
- package/tools/celery_guard.py +209 -0
- package/tools/check_a11y.js +109 -0
- package/tools/check_api_contracts.js +139 -0
- package/tools/check_architecture.js +139 -0
- package/tools/check_architecture.py +264 -0
- package/tools/check_custom.js +163 -0
- package/tools/check_custom.py +395 -0
- package/tools/check_db_efficiency.py +190 -0
- package/tools/check_migrations.py +223 -0
- package/tools/check_performance.js +142 -0
- package/tools/check_responsive.js +131 -0
- package/tools/check_scope.py +180 -0
- package/tools/check_seo.js +139 -0
- package/tools/check_storybook.js +108 -0
- package/tools/check_ui_reuse.js +135 -0
- package/tools/docker_guard.py +210 -0
- package/tools/postgres_guard.py +192 -0
- package/tools/redis_guard.py +197 -0
- package/tools/scan_secrets.js +109 -0
- package/tools/scan_secrets.py +153 -0
- package/tools/verify_tdd.py +212 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Secrets & API Key Scanner
|
|
4
|
+
* Detects hardcoded secrets, API keys, tokens, and private keys in staged/changed files.
|
|
5
|
+
* Self-contained: uses only Node.js built-ins.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} targetDir - Directory to scan (defaults to cwd)
|
|
8
|
+
* @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
|
|
9
|
+
*/
|
|
10
|
+
const check = async function scanSecrets(targetDir) {
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { execSync } = require('child_process');
|
|
14
|
+
|
|
15
|
+
const dir = targetDir || process.cwd();
|
|
16
|
+
|
|
17
|
+
// Collect changed files via git, fall back to package.json check
|
|
18
|
+
let files;
|
|
19
|
+
try {
|
|
20
|
+
const out = execSync('git diff --name-only HEAD~1', {
|
|
21
|
+
cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
|
|
22
|
+
});
|
|
23
|
+
files = out.split('\n').filter(f => f.trim());
|
|
24
|
+
} catch {
|
|
25
|
+
try {
|
|
26
|
+
const out = execSync('git diff --name-only --cached', {
|
|
27
|
+
cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
|
|
28
|
+
});
|
|
29
|
+
files = out.split('\n').filter(f => f.trim());
|
|
30
|
+
} catch {
|
|
31
|
+
// No git history — skip gracefully
|
|
32
|
+
return { exitCode: 0, skipped: true, name: 'scan_secrets', data: { findings: {}, message: 'No git diff available' } };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (files.length === 0) {
|
|
37
|
+
return { exitCode: 0, skipped: false, name: 'scan_secrets', data: { findings: {}, message: 'No changed files to scan' } };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const patterns = [
|
|
41
|
+
{ regex: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/, label: 'Private Key' },
|
|
42
|
+
{ regex: /AKIA[0-9A-Z]{16}/, label: 'AWS Access Key' },
|
|
43
|
+
{ regex: /(?:api[_-]?key|apikey|secret[_-]?key|api[_-]?secret)\s*[=:]\s*['"][A-Za-z0-9_\-]{16,}['"]/i, label: 'API Key' },
|
|
44
|
+
{ regex: /(?:password|passwd|pwd)\s*[=:]\s*['"][^'"]{6,}['"]/i, label: 'Hardcoded Password' },
|
|
45
|
+
{ regex: /(?:secret|token|auth[_-]?token|bearer)\s*[=:]\s*['"][A-Za-z0-9_\-\.]{16,}['"]/i, label: 'Secret/Token' },
|
|
46
|
+
{ regex: /ghp_[A-Za-z0-9]{36}/, label: 'GitHub Personal Access Token' },
|
|
47
|
+
{ regex: /sk-[A-Za-z0-9]{20,}/, label: 'OpenAI/Stripe API Key' },
|
|
48
|
+
{ regex: /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/, label: 'JWT Token' },
|
|
49
|
+
{ regex: /(?:SUPABASE|NEXT_PUBLIC|VITE)_\w*_KEY\s*=\s*['"][^'"]{8,}['"]/i, label: 'Framework Secret Key' },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
// Skip binary/generated files
|
|
53
|
+
const skipExtensions = ['.png', '.jpg', '.gif', '.svg', '.ico', '.woff', '.woff2', '.ttf', '.lock', '.min.js', '.min.css'];
|
|
54
|
+
|
|
55
|
+
const findings = {};
|
|
56
|
+
for (const file of files) {
|
|
57
|
+
const ext = path.extname(file);
|
|
58
|
+
if (skipExtensions.includes(ext)) continue;
|
|
59
|
+
|
|
60
|
+
const filePath = path.join(dir, file);
|
|
61
|
+
if (!fs.existsSync(filePath)) continue;
|
|
62
|
+
|
|
63
|
+
let content;
|
|
64
|
+
try {
|
|
65
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
66
|
+
} catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const lines = content.split('\n');
|
|
71
|
+
const fileFindings = [];
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < lines.length; i++) {
|
|
74
|
+
const line = lines[i];
|
|
75
|
+
for (const { regex, label } of patterns) {
|
|
76
|
+
if (regex.test(line)) {
|
|
77
|
+
const col = line.search(regex);
|
|
78
|
+
fileFindings.push({ lineNo: i + 1, col, label, line: line.trim().substring(0, 120) });
|
|
79
|
+
break; // One finding per line
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (fileFindings.length > 0) {
|
|
85
|
+
findings[file] = fileFindings;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const totalCount = Object.values(findings).flat().length;
|
|
90
|
+
return {
|
|
91
|
+
exitCode: totalCount > 0 ? 1 : 0,
|
|
92
|
+
skipped: false,
|
|
93
|
+
name: 'scan_secrets',
|
|
94
|
+
data: {
|
|
95
|
+
findings,
|
|
96
|
+
message: totalCount > 0
|
|
97
|
+
? `${totalCount} potential secret(s) found in ${Object.keys(findings).length} file(s)`
|
|
98
|
+
: 'No secrets detected',
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
module.exports = check;
|
|
103
|
+
|
|
104
|
+
if (require.main === module) {
|
|
105
|
+
check(process.argv[2]).then(result => {
|
|
106
|
+
console.log(JSON.stringify(result, null, 2));
|
|
107
|
+
process.exit(result.exitCode);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Secrets & API Key Scanner
|
|
4
|
+
Detects hardcoded secrets, API keys, tokens, and private keys in changed files.
|
|
5
|
+
Self-contained: uses only Python stdlib (re, pathlib, subprocess, json).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import pathlib
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
# One finding per matching line: (line_no, label, matched_snippet)
|
|
17
|
+
PATTERNS: list[tuple[str, str]] = [
|
|
18
|
+
(r"-----BEGIN (?:RSA |EC )?PRIVATE KEY-----", "Private Key"),
|
|
19
|
+
(r"AKIA[0-9A-Z]{16}", "AWS Access Key"),
|
|
20
|
+
(
|
|
21
|
+
r"(?:api[_-]?key|apikey|secret[_-]?key|api[_-]?secret)\s*[=:]\s*['\"]"
|
|
22
|
+
r"[A-Za-z0-9_\-]{16,}['\"]",
|
|
23
|
+
"API Key",
|
|
24
|
+
),
|
|
25
|
+
(
|
|
26
|
+
r"(?:password|passwd|pwd)\s*[=:]\s*['\"][^'\"]{6,}['\"]",
|
|
27
|
+
"Hardcoded Password",
|
|
28
|
+
),
|
|
29
|
+
(
|
|
30
|
+
r"(?:secret|token|auth[_-]?token|bearer)\s*[=:]\s*['\"]"
|
|
31
|
+
r"[A-Za-z0-9_\-\.]{16,}['\"]",
|
|
32
|
+
"Secret/Token",
|
|
33
|
+
),
|
|
34
|
+
(r"ghp_[A-Za-z0-9]{36}", "GitHub Personal Access Token"),
|
|
35
|
+
(r"sk-[A-Za-z0-9]{20,}", "OpenAI/Stripe API Key"),
|
|
36
|
+
(r"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+", "JWT Token"),
|
|
37
|
+
(
|
|
38
|
+
r"(?:SUPABASE|NEXT_PUBLIC|VITE)_\w*_KEY\s*=\s*['\"][^'\"]{8,}['\"]",
|
|
39
|
+
"Framework Secret Key",
|
|
40
|
+
),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
SKIP_EXTENSIONS = frozenset(
|
|
44
|
+
{
|
|
45
|
+
".png", ".jpg", ".gif", ".svg", ".ico",
|
|
46
|
+
".woff", ".woff2", ".ttf", ".lock",
|
|
47
|
+
".min.js", ".min.css",
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _get_changed_files(target_dir: str) -> list[str] | None:
|
|
53
|
+
"""Return list of changed files from git diff, or None on failure."""
|
|
54
|
+
for ref in ("HEAD~1", "--cached"):
|
|
55
|
+
try:
|
|
56
|
+
args = ["git", "diff", "--name-only"]
|
|
57
|
+
if ref == "--cached":
|
|
58
|
+
args.append("--cached")
|
|
59
|
+
else:
|
|
60
|
+
args.append(ref)
|
|
61
|
+
out = subprocess.check_output(
|
|
62
|
+
args, cwd=target_dir,
|
|
63
|
+
stderr=subprocess.DEVNULL, text=True,
|
|
64
|
+
)
|
|
65
|
+
files = [f.strip() for f in out.splitlines() if f.strip()]
|
|
66
|
+
if files:
|
|
67
|
+
return files
|
|
68
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
69
|
+
continue
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _compile_patterns() -> list[tuple[re.Pattern, str]]:
|
|
74
|
+
"""Compile all secret-detection patterns once."""
|
|
75
|
+
return [(re.compile(p), label) for p, label in PATTERNS]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def check(target_dir: str | None = None) -> dict:
|
|
79
|
+
"""
|
|
80
|
+
Scan changed files for secrets and API keys.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
dict with keys: exit_code, skipped, name, data
|
|
84
|
+
data.findings maps filepath -> list of {lineNo, label, snippet}
|
|
85
|
+
"""
|
|
86
|
+
dir_path = target_dir or os.getcwd()
|
|
87
|
+
|
|
88
|
+
files = _get_changed_files(dir_path)
|
|
89
|
+
if files is None:
|
|
90
|
+
return {
|
|
91
|
+
"exit_code": 0, "skipped": True, "name": "scan_secrets",
|
|
92
|
+
"data": {"findings": {}, "message": "No git diff available"},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if not files:
|
|
96
|
+
return {
|
|
97
|
+
"exit_code": 0, "skipped": False, "name": "scan_secrets",
|
|
98
|
+
"data": {"findings": {}, "message": "No changed files to scan"},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
compiled = _compile_patterns()
|
|
102
|
+
findings: dict[str, list[dict]] = {}
|
|
103
|
+
|
|
104
|
+
for filepath in files:
|
|
105
|
+
ext = pathlib.Path(filepath).suffix
|
|
106
|
+
if ext in SKIP_EXTENSIONS:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
full_path = pathlib.Path(dir_path) / filepath
|
|
110
|
+
if not full_path.exists():
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
content = full_path.read_text(encoding="utf-8", errors="replace")
|
|
115
|
+
except (OSError, PermissionError):
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
file_hits: list[dict] = []
|
|
119
|
+
for line_no, line in enumerate(content.splitlines(), start=1):
|
|
120
|
+
for regex, label in compiled:
|
|
121
|
+
m = regex.search(line)
|
|
122
|
+
if m:
|
|
123
|
+
file_hits.append({
|
|
124
|
+
"lineNo": line_no,
|
|
125
|
+
"label": label,
|
|
126
|
+
"snippet": line.strip()[:120],
|
|
127
|
+
})
|
|
128
|
+
break # one finding per line
|
|
129
|
+
|
|
130
|
+
if file_hits:
|
|
131
|
+
findings[filepath] = file_hits
|
|
132
|
+
|
|
133
|
+
total = sum(len(v) for v in findings.values())
|
|
134
|
+
if total:
|
|
135
|
+
msg = (
|
|
136
|
+
f"{total} potential secret(s) found in "
|
|
137
|
+
f"{len(findings)} file(s)"
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
msg = "No secrets detected"
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
"exit_code": 1 if total else 0,
|
|
144
|
+
"skipped": False,
|
|
145
|
+
"name": "scan_secrets",
|
|
146
|
+
"data": {"findings": findings, "message": msg},
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
result = check(sys.argv[1] if len(sys.argv) > 1 else None)
|
|
152
|
+
print(json.dumps(result, indent=2))
|
|
153
|
+
sys.exit(result["exit_code"])
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
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).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import pathlib
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
|
|
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
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _detect_test_framework(target_dir: str) -> str:
|
|
39
|
+
"""Detect which test framework is in use."""
|
|
40
|
+
root = pathlib.Path(target_dir)
|
|
41
|
+
|
|
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
|
+
|
|
53
|
+
# Check for Django manage.py test
|
|
54
|
+
if (root / "manage.py").exists():
|
|
55
|
+
return "django"
|
|
56
|
+
|
|
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
|
+
return "unknown"
|
|
67
|
+
|
|
68
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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)
|
|
83
|
+
|
|
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
|
+
|
|
92
|
+
return sorted(test_files)
|
|
93
|
+
|
|
94
|
+
|
|
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
|
|
98
|
+
try:
|
|
99
|
+
content = full_path.read_text(encoding="utf-8", errors="replace")
|
|
100
|
+
except OSError:
|
|
101
|
+
return 0
|
|
102
|
+
|
|
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
|
+
|
|
108
|
+
|
|
109
|
+
def check(target_dir: str | None = None) -> dict:
|
|
110
|
+
"""
|
|
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
|
|
117
|
+
"""
|
|
118
|
+
dir_path = target_dir or os.getcwd()
|
|
119
|
+
|
|
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}"
|
|
159
|
+
)
|
|
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}"
|
|
166
|
+
)
|
|
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}"
|
|
173
|
+
)
|
|
174
|
+
exit_code = 0
|
|
175
|
+
else:
|
|
176
|
+
msg = (
|
|
177
|
+
f"No Python code changes detected. "
|
|
178
|
+
f"Framework: {framework}. Total test methods: {total_tests}"
|
|
179
|
+
)
|
|
180
|
+
exit_code = 0
|
|
181
|
+
|
|
182
|
+
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
|
+
},
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
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
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
result = check(sys.argv[1] if len(sys.argv) > 1 else None)
|
|
211
|
+
print(json.dumps(result, indent=2))
|
|
212
|
+
sys.exit(result["exit_code"])
|