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/celery_guard.py
CHANGED
|
@@ -9,33 +9,14 @@ from __future__ import annotations
|
|
|
9
9
|
import ast
|
|
10
10
|
import json
|
|
11
11
|
import os
|
|
12
|
-
import subprocess
|
|
13
12
|
import sys
|
|
14
|
-
from pathlib import Path
|
|
15
13
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 = [
|
|
31
|
-
f.strip() for f in out.splitlines()
|
|
32
|
-
if f.strip().endswith('.py')
|
|
33
|
-
]
|
|
34
|
-
if files:
|
|
35
|
-
return files
|
|
36
|
-
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
37
|
-
continue
|
|
38
|
-
return None
|
|
14
|
+
# Sibling helper shipped next to the guards; works both as a script and when
|
|
15
|
+
# loaded by audit.py through importlib.
|
|
16
|
+
_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
17
|
+
if _TOOLS_DIR not in sys.path:
|
|
18
|
+
sys.path.insert(0, _TOOLS_DIR)
|
|
19
|
+
import _changes # noqa: E402
|
|
39
20
|
|
|
40
21
|
|
|
41
22
|
class CeleryVisitor(ast.NodeVisitor):
|
|
@@ -155,8 +136,10 @@ def check(target_dir: str | None = None) -> dict:
|
|
|
155
136
|
"""
|
|
156
137
|
dir_path = target_dir or os.getcwd()
|
|
157
138
|
|
|
158
|
-
|
|
159
|
-
|
|
139
|
+
# The snapshot being verified: the index, or HEAD when nothing is staged.
|
|
140
|
+
changes = _changes.changed_files(dir_path, extensions=[".py"])
|
|
141
|
+
mode, files = changes["mode"], changes["files"]
|
|
142
|
+
if mode is None:
|
|
160
143
|
return {
|
|
161
144
|
"exit_code": 0, "skipped": True, "name": "celery_guard",
|
|
162
145
|
"data": {"findings": [], "message": "No git diff available"},
|
|
@@ -171,17 +154,12 @@ def check(target_dir: str | None = None) -> dict:
|
|
|
171
154
|
all_violations: list[dict] = []
|
|
172
155
|
|
|
173
156
|
for filepath in files:
|
|
174
|
-
|
|
175
|
-
if
|
|
176
|
-
continue
|
|
177
|
-
|
|
178
|
-
try:
|
|
179
|
-
content = full_path.read_text(encoding="utf-8", errors="replace")
|
|
180
|
-
except (OSError, PermissionError):
|
|
157
|
+
content = _changes.read_file(dir_path, filepath, mode)
|
|
158
|
+
if content is None:
|
|
181
159
|
continue
|
|
182
160
|
|
|
183
161
|
try:
|
|
184
|
-
tree = ast.parse(content, filename=
|
|
162
|
+
tree = ast.parse(content, filename=filepath)
|
|
185
163
|
except SyntaxError:
|
|
186
164
|
continue
|
|
187
165
|
|
package/tools/check_a11y.js
CHANGED
|
@@ -8,28 +8,15 @@
|
|
|
8
8
|
* @param {string} targetDir - Directory to scan (defaults to cwd)
|
|
9
9
|
* @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
|
|
10
10
|
*/
|
|
11
|
-
const
|
|
12
|
-
const fs = require('fs');
|
|
13
|
-
const path = require('path');
|
|
14
|
-
const { execSync } = require('child_process');
|
|
11
|
+
const { changedFiles, readFile } = require('./_changes');
|
|
15
12
|
|
|
13
|
+
const check = async function checkA11y(targetDir) {
|
|
16
14
|
const dir = targetDir || process.cwd();
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
});
|
|
23
|
-
files = out.split('\n').filter(f => /\.(tsx|jsx)$/.test(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 => /\.(tsx|jsx)$/.test(f.trim()));
|
|
30
|
-
} catch {
|
|
31
|
-
return { exitCode: 0, skipped: true, name: 'check_a11y', data: { violations: {}, message: 'No git diff available' } };
|
|
32
|
-
}
|
|
16
|
+
// The snapshot being verified: the index, or HEAD when nothing is staged.
|
|
17
|
+
const { mode, files } = changedFiles(dir, { extensions: ['.tsx', '.jsx'] });
|
|
18
|
+
if (mode === null) {
|
|
19
|
+
return { exitCode: 0, skipped: true, name: 'check_a11y', data: { violations: {}, message: 'No git diff available' } };
|
|
33
20
|
}
|
|
34
21
|
|
|
35
22
|
if (!files || files.length === 0) {
|
|
@@ -39,15 +26,8 @@ const check = async function checkA11y(targetDir) {
|
|
|
39
26
|
const violations = {};
|
|
40
27
|
|
|
41
28
|
for (const file of files) {
|
|
42
|
-
const
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
let content;
|
|
46
|
-
try {
|
|
47
|
-
content = fs.readFileSync(filePath, 'utf-8');
|
|
48
|
-
} catch {
|
|
49
|
-
continue;
|
|
50
|
-
}
|
|
29
|
+
const content = readFile(dir, file, mode);
|
|
30
|
+
if (content === null) continue;
|
|
51
31
|
|
|
52
32
|
const lines = content.split('\n');
|
|
53
33
|
const fileViolations = [];
|
|
@@ -7,40 +7,16 @@
|
|
|
7
7
|
* @param {string} targetDir - Directory to scan (defaults to cwd)
|
|
8
8
|
* @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
|
|
9
9
|
*/
|
|
10
|
-
const
|
|
11
|
-
const fs = require('fs');
|
|
12
|
-
const path = require('path');
|
|
13
|
-
const { execSync } = require('child_process');
|
|
10
|
+
const { changedFiles, readFile, nearestPackageDeps } = require('./_changes');
|
|
14
11
|
|
|
12
|
+
const check = async function checkApiContracts(targetDir) {
|
|
15
13
|
const dir = targetDir || process.cwd();
|
|
16
14
|
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
const pkgPath = path.join(dir, 'package.json');
|
|
20
|
-
if (fs.existsSync(pkgPath)) {
|
|
21
|
-
try {
|
|
22
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
23
|
-
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
24
|
-
hasZod = Boolean(deps['zod']);
|
|
25
|
-
} catch { /* skip */ }
|
|
26
|
-
}
|
|
15
|
+
// The snapshot being verified: the index, or HEAD when nothing is staged.
|
|
16
|
+
const { mode, files } = changedFiles(dir, { extensions: ['.ts', '.js', '.tsx', '.jsx'] });
|
|
27
17
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
try {
|
|
31
|
-
const out = execSync('git diff --name-only HEAD~1', {
|
|
32
|
-
cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
|
|
33
|
-
});
|
|
34
|
-
files = out.split('\n').filter(f => /\.(ts|js|tsx|jsx)$/.test(f.trim()));
|
|
35
|
-
} catch {
|
|
36
|
-
try {
|
|
37
|
-
const out = execSync('git diff --name-only --cached', {
|
|
38
|
-
cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
|
|
39
|
-
});
|
|
40
|
-
files = out.split('\n').filter(f => /\.(ts|js|tsx|jsx)$/.test(f.trim()));
|
|
41
|
-
} catch {
|
|
42
|
-
return { exitCode: 0, skipped: true, name: 'check_api_contracts', data: { violations: {}, message: 'No git diff available' } };
|
|
43
|
-
}
|
|
18
|
+
if (mode === null) {
|
|
19
|
+
return { exitCode: 0, skipped: true, name: 'check_api_contracts', data: { violations: {}, message: 'No git diff available' } };
|
|
44
20
|
}
|
|
45
21
|
|
|
46
22
|
if (!files || files.length === 0) {
|
|
@@ -57,17 +33,15 @@ const check = async function checkApiContracts(targetDir) {
|
|
|
57
33
|
}
|
|
58
34
|
|
|
59
35
|
const violations = {};
|
|
36
|
+
const pkgCache = new Map();
|
|
60
37
|
|
|
61
38
|
for (const file of apiFiles) {
|
|
62
|
-
const
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
} catch {
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
39
|
+
const content = readFile(dir, file, mode);
|
|
40
|
+
if (content === null) continue;
|
|
41
|
+
|
|
42
|
+
// Zod available to this file: its nearest package.json (a monorepo part's
|
|
43
|
+
// own, else the root's), as of the same snapshot.
|
|
44
|
+
const hasZod = Boolean((nearestPackageDeps(dir, file, mode, pkgCache) || {}).zod);
|
|
71
45
|
|
|
72
46
|
const fileViolations = [];
|
|
73
47
|
|
|
@@ -7,48 +7,46 @@
|
|
|
7
7
|
* @param {string} targetDir - Directory to scan (defaults to cwd)
|
|
8
8
|
* @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
|
|
9
9
|
*/
|
|
10
|
-
const
|
|
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
|
-
// Verify this is a Next.js project
|
|
18
|
-
const pkgPath = path.join(dir, 'package.json');
|
|
19
|
-
if (!fs.existsSync(pkgPath)) {
|
|
20
|
-
return { exitCode: 0, skipped: true, name: 'check_architecture', data: { violations: {}, message: 'No package.json found — skipping architecture check' } };
|
|
21
|
-
}
|
|
10
|
+
const { changedFiles, readFile, nearestPackageDeps } = require('./_changes');
|
|
22
11
|
|
|
23
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Skip message when the root package.json (as of the snapshot) is missing or
|
|
14
|
+
* does not depend on next; null when the root is a Next.js project.
|
|
15
|
+
* @returns {string|null}
|
|
16
|
+
*/
|
|
17
|
+
function rootSkip(dir, mode) {
|
|
18
|
+
const pkgRaw = readFile(dir, 'package.json', mode);
|
|
19
|
+
if (pkgRaw === null) return 'No package.json found — skipping architecture check';
|
|
24
20
|
try {
|
|
25
|
-
const pkg = JSON.parse(
|
|
21
|
+
const pkg = JSON.parse(pkgRaw);
|
|
26
22
|
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
27
|
-
|
|
23
|
+
if (deps['next']) return null;
|
|
28
24
|
} catch {
|
|
29
|
-
|
|
25
|
+
// Malformed package.json: not a Next.js project.
|
|
30
26
|
}
|
|
27
|
+
return 'Not a Next.js project — skipping';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const check = async function checkArchitecture(targetDir) {
|
|
31
|
+
const dir = targetDir || process.cwd();
|
|
32
|
+
|
|
33
|
+
// The snapshot being verified: the index, or HEAD when nothing is staged.
|
|
34
|
+
const { mode, files } = changedFiles(dir, { extensions: ['.tsx', '.jsx', '.ts', '.js'] });
|
|
31
35
|
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
// Each changed file belongs to the project of its nearest package.json (a
|
|
37
|
+
// monorepo part's own, else the root's), as of the same snapshot: only
|
|
38
|
+
// files of a Next.js project are checked.
|
|
39
|
+
const pkgCache = new Map();
|
|
40
|
+
const nextFiles = files.filter(f => Boolean((nearestPackageDeps(dir, f, mode, pkgCache) || {}).next));
|
|
41
|
+
|
|
42
|
+
// No changed file in a Next.js project: the root package.json decides.
|
|
43
|
+
if (nextFiles.length === 0) {
|
|
44
|
+
const skip = rootSkip(dir, mode);
|
|
45
|
+
if (skip) return { exitCode: 0, skipped: true, name: 'check_architecture', data: { violations: {}, message: skip } };
|
|
34
46
|
}
|
|
35
47
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
const out = execSync('git diff --name-only HEAD~1', {
|
|
40
|
-
cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
|
|
41
|
-
});
|
|
42
|
-
files = out.split('\n').filter(f => /\.(tsx|jsx|ts|js)$/.test(f.trim()));
|
|
43
|
-
} catch {
|
|
44
|
-
try {
|
|
45
|
-
const out = execSync('git diff --name-only --cached', {
|
|
46
|
-
cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
|
|
47
|
-
});
|
|
48
|
-
files = out.split('\n').filter(f => /\.(tsx|jsx|ts|js)$/.test(f.trim()));
|
|
49
|
-
} catch {
|
|
50
|
-
return { exitCode: 0, skipped: true, name: 'check_architecture', data: { violations: {}, message: 'No git diff available' } };
|
|
51
|
-
}
|
|
48
|
+
if (mode === null) {
|
|
49
|
+
return { exitCode: 0, skipped: true, name: 'check_architecture', data: { violations: {}, message: 'No git diff available' } };
|
|
52
50
|
}
|
|
53
51
|
|
|
54
52
|
if (!files || files.length === 0) {
|
|
@@ -57,16 +55,9 @@ const check = async function checkArchitecture(targetDir) {
|
|
|
57
55
|
|
|
58
56
|
const violations = {};
|
|
59
57
|
|
|
60
|
-
for (const file of
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
let content;
|
|
65
|
-
try {
|
|
66
|
-
content = fs.readFileSync(filePath, 'utf-8');
|
|
67
|
-
} catch {
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
58
|
+
for (const file of nextFiles) {
|
|
59
|
+
const content = readFile(dir, file, mode);
|
|
60
|
+
if (content === null) continue;
|
|
70
61
|
|
|
71
62
|
const fileViolations = [];
|
|
72
63
|
const isLayout = /layout\.(tsx|jsx|ts|js)$/.test(file);
|
|
@@ -6,6 +6,9 @@ Validates that Python code respects architectural layer boundaries:
|
|
|
6
6
|
- Application layer should not import from Infrastructure or Routes
|
|
7
7
|
- Routes/API should not import from Domain directly (go through Application)
|
|
8
8
|
For Django: checks that models don't import from views, views don't import from urls, etc.
|
|
9
|
+
The framework is decided per changed file, from its nearest project root in the
|
|
10
|
+
snapshot (manage.py / pyproject.toml / setup.cfg / requirements*.txt), so each
|
|
11
|
+
part of a monorepo gets its own rules.
|
|
9
12
|
Self-contained: uses only Python stdlib (ast, pathlib, subprocess, json).
|
|
10
13
|
"""
|
|
11
14
|
from __future__ import annotations
|
|
@@ -13,56 +16,115 @@ from __future__ import annotations
|
|
|
13
16
|
import ast
|
|
14
17
|
import json
|
|
15
18
|
import os
|
|
16
|
-
import pathlib
|
|
17
19
|
import re
|
|
18
|
-
import subprocess
|
|
19
20
|
import sys
|
|
20
21
|
|
|
22
|
+
# Sibling helper shipped next to the guards; works both as a script and when
|
|
23
|
+
# loaded by audit.py through importlib.
|
|
24
|
+
_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
25
|
+
if _TOOLS_DIR not in sys.path:
|
|
26
|
+
sys.path.insert(0, _TOOLS_DIR)
|
|
27
|
+
import _changes # noqa: E402
|
|
21
28
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
else:
|
|
30
|
-
args.append(ref)
|
|
31
|
-
out = subprocess.check_output(
|
|
32
|
-
args, cwd=target_dir,
|
|
33
|
-
stderr=subprocess.DEVNULL, text=True,
|
|
34
|
-
)
|
|
35
|
-
files = [
|
|
36
|
-
f.strip() for f in out.splitlines()
|
|
37
|
-
if f.strip().endswith(".py")
|
|
38
|
-
]
|
|
39
|
-
if files:
|
|
40
|
-
return files
|
|
41
|
-
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
42
|
-
continue
|
|
43
|
-
return None
|
|
29
|
+
|
|
30
|
+
# Files that make their directory a Python project root (requirements*.txt too).
|
|
31
|
+
_ROOT_MARKERS = ("manage.py", "pyproject.toml", "setup.cfg")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_requirements(name: str) -> bool:
|
|
35
|
+
return name.startswith("requirements") and name.endswith(".txt")
|
|
44
36
|
|
|
45
37
|
|
|
46
|
-
def
|
|
47
|
-
"""
|
|
48
|
-
|
|
38
|
+
def _markers_by_dir(target_dir: str, mode: str | None) -> dict[str, list[str]]:
|
|
39
|
+
"""Project-marker file names of the snapshot, by directory ("" for the repository root)."""
|
|
40
|
+
by_dir: dict[str, list[str]] = {}
|
|
41
|
+
for rel in _changes.list_files(target_dir, mode):
|
|
42
|
+
head, _, name = rel.rpartition("/")
|
|
43
|
+
if name in _ROOT_MARKERS or _is_requirements(name):
|
|
44
|
+
by_dir.setdefault(head, []).append(name)
|
|
45
|
+
return by_dir
|
|
49
46
|
|
|
50
|
-
|
|
47
|
+
|
|
48
|
+
def _framework_of(target_dir: str, mode: str | None, directory: str, names: list[str]) -> str:
|
|
49
|
+
"""Framework of the project rooted at directory, from its marker files (snapshot content)."""
|
|
50
|
+
if "manage.py" in names:
|
|
51
51
|
return "django"
|
|
52
|
+
found = "generic"
|
|
53
|
+
for name in sorted(names):
|
|
54
|
+
text = _changes.read_file(target_dir, f"{directory}/{name}" if directory else name, mode)
|
|
55
|
+
if text is None:
|
|
56
|
+
continue
|
|
57
|
+
text = text.lower()
|
|
58
|
+
if "django" in text:
|
|
59
|
+
return "django"
|
|
60
|
+
if "fastapi" in text:
|
|
61
|
+
found = "fastapi"
|
|
62
|
+
return found
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _nearest_framework(
|
|
66
|
+
target_dir: str, mode: str | None, filepath: str,
|
|
67
|
+
markers: dict[str, list[str]], cache: dict[str, str],
|
|
68
|
+
) -> str:
|
|
69
|
+
"""
|
|
70
|
+
Framework of the nearest project root of a repository path: the file's
|
|
71
|
+
directory, then each parent up to the repository root; the first one
|
|
72
|
+
holding a project marker decides (a monorepo part's own manifest wins over
|
|
73
|
+
the root one). Memoized per directory in cache.
|
|
74
|
+
"""
|
|
75
|
+
visited: list[str] = []
|
|
76
|
+
current = filepath.rpartition("/")[0]
|
|
77
|
+
framework = "generic"
|
|
78
|
+
while True:
|
|
79
|
+
if current in cache:
|
|
80
|
+
framework = cache[current]
|
|
81
|
+
break
|
|
82
|
+
visited.append(current)
|
|
83
|
+
if current in markers:
|
|
84
|
+
framework = _framework_of(target_dir, mode, current, markers[current])
|
|
85
|
+
break
|
|
86
|
+
if not current:
|
|
87
|
+
break
|
|
88
|
+
current = current.rpartition("/")[0]
|
|
89
|
+
for directory in visited:
|
|
90
|
+
cache[directory] = framework
|
|
91
|
+
return framework
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _package_parts(filepath: str) -> list[str]:
|
|
95
|
+
"""Directory segments of a file: the package a relative import resolves against."""
|
|
96
|
+
return [part for part in filepath.replace("\\", "/").split("/")[:-1] if part]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _imported_modules(node: ast.AST, filepath: str, with_names: bool = True) -> list[str]:
|
|
100
|
+
"""
|
|
101
|
+
Every module an import statement reaches, as dotted absolute names.
|
|
52
102
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
103
|
+
`import a, b` yields a and b; `from m import x` yields m and m.x (x may be a
|
|
104
|
+
submodule; only with with_names); relative imports are resolved against
|
|
105
|
+
the file's package path.
|
|
106
|
+
"""
|
|
107
|
+
if isinstance(node, ast.Import):
|
|
108
|
+
return [alias.name for alias in node.names]
|
|
109
|
+
if not isinstance(node, ast.ImportFrom):
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
if node.level:
|
|
113
|
+
package = _package_parts(filepath)
|
|
114
|
+
keep = max(0, len(package) - (node.level - 1))
|
|
115
|
+
base = package[:keep]
|
|
116
|
+
else:
|
|
117
|
+
base = []
|
|
118
|
+
if node.module:
|
|
119
|
+
base = base + node.module.split(".")
|
|
64
120
|
|
|
65
|
-
|
|
121
|
+
modules = [".".join(base)] if base else []
|
|
122
|
+
if not with_names:
|
|
123
|
+
return modules
|
|
124
|
+
for alias in node.names:
|
|
125
|
+
if alias.name != "*":
|
|
126
|
+
modules.append(".".join(base + [alias.name]))
|
|
127
|
+
return modules
|
|
66
128
|
|
|
67
129
|
|
|
68
130
|
def _check_django_layers(
|
|
@@ -91,14 +153,7 @@ def _check_django_layers(
|
|
|
91
153
|
return violations # Can't determine layer, skip
|
|
92
154
|
|
|
93
155
|
for node in ast.walk(tree):
|
|
94
|
-
|
|
95
|
-
if isinstance(node, ast.ImportFrom) and node.module:
|
|
96
|
-
imported = node.module
|
|
97
|
-
elif isinstance(node, ast.Import):
|
|
98
|
-
imported = node.names[0].name if node.names else ""
|
|
99
|
-
else:
|
|
100
|
-
continue
|
|
101
|
-
|
|
156
|
+
for imported in _imported_modules(node, filepath, with_names=False):
|
|
102
157
|
# Django models should not import from views, urls, admin
|
|
103
158
|
if file_layer == "models":
|
|
104
159
|
bad_targets = ("views", "urls", "admin", "serializers", "forms")
|
|
@@ -134,60 +189,73 @@ def _check_django_layers(
|
|
|
134
189
|
return violations
|
|
135
190
|
|
|
136
191
|
|
|
192
|
+
# Clean Architecture layers: the directory names that mark each one.
|
|
193
|
+
_LAYER_DIRS = {
|
|
194
|
+
"domain": ("domain",),
|
|
195
|
+
"application": ("application", "use_cases"),
|
|
196
|
+
"infrastructure": ("infrastructure", "adapters"),
|
|
197
|
+
"routes": ("routes", "api"),
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
# For each layer, the directory names its code must not import from.
|
|
201
|
+
_FORBIDDEN = {
|
|
202
|
+
"domain": ("application", "use_cases", "infrastructure", "adapters", "routes", "api"),
|
|
203
|
+
"application": ("infrastructure", "adapters", "routes", "api"),
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
_REASONS = {
|
|
207
|
+
"domain": "Domain layer imports from '{0}' — domain must be framework-independent",
|
|
208
|
+
"application": "Application layer imports from '{0}' — use dependency inversion",
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _file_layer(filepath: str) -> str | None:
|
|
213
|
+
"""The layer of a file: the first layer (by priority) whose name is a path segment."""
|
|
214
|
+
segments = set(_package_parts(filepath))
|
|
215
|
+
for layer, names in _LAYER_DIRS.items():
|
|
216
|
+
if segments.intersection(names):
|
|
217
|
+
return layer
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _own_prefix_len(module_parts: list[str], package: list[str]) -> int:
|
|
222
|
+
"""Leading segments an imported module shares with the importing file's package."""
|
|
223
|
+
n = 0
|
|
224
|
+
for a, b in zip(module_parts, package):
|
|
225
|
+
if a != b:
|
|
226
|
+
break
|
|
227
|
+
n += 1
|
|
228
|
+
return n
|
|
229
|
+
|
|
230
|
+
|
|
137
231
|
def _check_clean_architecture(
|
|
138
232
|
filepath: str, content: str, tree: ast.Module,
|
|
139
233
|
) -> list[dict]:
|
|
140
234
|
"""Check Clean Architecture boundary violations for generic Python projects."""
|
|
141
235
|
violations: list[dict] = []
|
|
142
236
|
|
|
143
|
-
|
|
144
|
-
if
|
|
145
|
-
|
|
146
|
-
elif "/application" in filepath or "/use_cases" in filepath:
|
|
147
|
-
file_layer = "application"
|
|
148
|
-
elif "/infrastructure" in filepath or "/adapters" in filepath:
|
|
149
|
-
file_layer = "infrastructure"
|
|
150
|
-
elif "/routes" in filepath or "/api" in filepath:
|
|
151
|
-
file_layer = "routes"
|
|
152
|
-
else:
|
|
153
|
-
return violations # Can't determine layer
|
|
237
|
+
file_layer = _file_layer(filepath)
|
|
238
|
+
if file_layer not in _FORBIDDEN:
|
|
239
|
+
return violations # Unknown layer, or one with no outgoing restriction
|
|
154
240
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if isinstance(node, ast.ImportFrom) and node.module:
|
|
158
|
-
imported = node.module
|
|
159
|
-
elif isinstance(node, ast.Import):
|
|
160
|
-
imported = node.names[0].name if node.names else ""
|
|
161
|
-
else:
|
|
162
|
-
continue
|
|
163
|
-
|
|
164
|
-
# Domain must not import from application, infrastructure, or routes
|
|
165
|
-
if file_layer == "domain":
|
|
166
|
-
bad = ("application", "use_cases", "infrastructure", "adapters", "routes", "api")
|
|
167
|
-
for b in bad:
|
|
168
|
-
if f".{b}" in imported or imported.startswith(b):
|
|
169
|
-
violations.append({
|
|
170
|
-
"lineNo": getattr(node, "lineno", 0),
|
|
171
|
-
"module": imported,
|
|
172
|
-
"reason": (
|
|
173
|
-
f"Domain layer imports from '{b}' — "
|
|
174
|
-
f"domain must be framework-independent"
|
|
175
|
-
),
|
|
176
|
-
})
|
|
241
|
+
package = _package_parts(filepath)
|
|
242
|
+
forbidden = _FORBIDDEN[file_layer]
|
|
177
243
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
244
|
+
for node in ast.walk(tree):
|
|
245
|
+
reported: set[str] = set()
|
|
246
|
+
for imported in _imported_modules(node, filepath):
|
|
247
|
+
parts = imported.split(".")
|
|
248
|
+
# Segments shared with the file's own package (e.g. a root package
|
|
249
|
+
# named `api`) are the file's location, not the imported layer.
|
|
250
|
+
tail = parts[_own_prefix_len(parts, package):]
|
|
251
|
+
for b in forbidden:
|
|
252
|
+
if b in tail and b not in reported:
|
|
253
|
+
reported.add(b)
|
|
254
|
+
violations.append({
|
|
255
|
+
"lineNo": getattr(node, "lineno", 0),
|
|
256
|
+
"module": imported,
|
|
257
|
+
"reason": _REASONS[file_layer].format(b),
|
|
258
|
+
})
|
|
191
259
|
|
|
192
260
|
return violations
|
|
193
261
|
|
|
@@ -202,8 +270,10 @@ def check(target_dir: str | None = None) -> dict:
|
|
|
202
270
|
"""
|
|
203
271
|
dir_path = target_dir or os.getcwd()
|
|
204
272
|
|
|
205
|
-
|
|
206
|
-
|
|
273
|
+
# The snapshot being verified: the index, or HEAD when nothing is staged.
|
|
274
|
+
changes = _changes.changed_files(dir_path, extensions=[".py"])
|
|
275
|
+
mode, files = changes["mode"], changes["files"]
|
|
276
|
+
if mode is None:
|
|
207
277
|
return {
|
|
208
278
|
"exit_code": 0, "skipped": True, "name": "check_architecture",
|
|
209
279
|
"data": {"violations": {}, "message": "No git diff available"},
|
|
@@ -215,17 +285,14 @@ def check(target_dir: str | None = None) -> dict:
|
|
|
215
285
|
"data": {"violations": {}, "message": "No Python files changed"},
|
|
216
286
|
}
|
|
217
287
|
|
|
218
|
-
|
|
288
|
+
# Each file is judged by its own project part (Django in backend/, FastAPI in api/, ...).
|
|
289
|
+
markers = _markers_by_dir(dir_path, mode)
|
|
290
|
+
frameworks: dict[str, str] = {}
|
|
219
291
|
violations: dict[str, list[dict]] = {}
|
|
220
292
|
|
|
221
293
|
for filepath in files:
|
|
222
|
-
|
|
223
|
-
if
|
|
224
|
-
continue
|
|
225
|
-
|
|
226
|
-
try:
|
|
227
|
-
content = full_path.read_text(encoding="utf-8", errors="replace")
|
|
228
|
-
except (OSError, PermissionError):
|
|
294
|
+
content = _changes.read_file(dir_path, filepath, mode)
|
|
295
|
+
if content is None:
|
|
229
296
|
continue
|
|
230
297
|
|
|
231
298
|
try:
|
|
@@ -233,7 +300,7 @@ def check(target_dir: str | None = None) -> dict:
|
|
|
233
300
|
except SyntaxError:
|
|
234
301
|
continue
|
|
235
302
|
|
|
236
|
-
if
|
|
303
|
+
if _nearest_framework(dir_path, mode, filepath, markers, frameworks) == "django":
|
|
237
304
|
file_violations = _check_django_layers(filepath, content, tree)
|
|
238
305
|
else:
|
|
239
306
|
file_violations = _check_clean_architecture(filepath, content, tree)
|