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,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Zod API Contract Guard
|
|
4
|
+
* Validates that API routes use Zod for request/response schema validation.
|
|
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 checkApiContracts(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
|
+
// Check if Zod is available
|
|
18
|
+
let hasZod = false;
|
|
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
|
+
}
|
|
27
|
+
|
|
28
|
+
// Collect API route files
|
|
29
|
+
let files;
|
|
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
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!files || files.length === 0) {
|
|
47
|
+
return { exitCode: 0, skipped: false, name: 'check_api_contracts', data: { violations: {}, message: 'No files changed' } };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Identify API route files (Next.js App Router or Pages Router)
|
|
51
|
+
const apiFiles = files.filter(f =>
|
|
52
|
+
f.includes('/api/') || f.includes('route.') || f.includes('/routes/')
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (apiFiles.length === 0) {
|
|
56
|
+
return { exitCode: 0, skipped: false, name: 'check_api_contracts', data: { violations: {}, message: 'No API route files changed' } };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const violations = {};
|
|
60
|
+
|
|
61
|
+
for (const file of apiFiles) {
|
|
62
|
+
const filePath = path.join(dir, file);
|
|
63
|
+
if (!fs.existsSync(filePath)) continue;
|
|
64
|
+
|
|
65
|
+
let content;
|
|
66
|
+
try {
|
|
67
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
68
|
+
} catch {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const fileViolations = [];
|
|
73
|
+
|
|
74
|
+
// Check: API route handler without Zod validation
|
|
75
|
+
const hasHandler = /export\s+(?:async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH)/.test(content);
|
|
76
|
+
if (hasHandler) {
|
|
77
|
+
// Look for request body parsing without Zod
|
|
78
|
+
if (/await\s+req\.json\s*\(\s*\)/.test(content) && !/\.parse\s*\(|\.safeParse\s*\(/.test(content)) {
|
|
79
|
+
fileViolations.push({
|
|
80
|
+
lineNo: 0,
|
|
81
|
+
rule: 'no-body-validation',
|
|
82
|
+
message: 'API route parses request body without Zod validation — use z.object().parse() or .safeParse()',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Look for NextRequest without Zod
|
|
87
|
+
if (/NextRequest/.test(content) && !/z\.|zod/.test(content)) {
|
|
88
|
+
fileViolations.push({
|
|
89
|
+
lineNo: 0,
|
|
90
|
+
rule: 'no-request-validation',
|
|
91
|
+
message: 'API route uses NextRequest without Zod schema validation',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Check: response without schema validation
|
|
96
|
+
if (/Response\.json\s*\(/.test(content) && !/z\.|zod/.test(content) && hasZod) {
|
|
97
|
+
fileViolations.push({
|
|
98
|
+
lineNo: 0,
|
|
99
|
+
rule: 'no-response-validation',
|
|
100
|
+
message: 'API route returns JSON without Zod response schema — consider validating output',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Check: fetch calls without response validation
|
|
106
|
+
if (/fetch\s*\(/.test(content) && !/z\.|zod/.test(content) && hasZod) {
|
|
107
|
+
fileViolations.push({
|
|
108
|
+
lineNo: 0,
|
|
109
|
+
rule: 'no-fetch-validation',
|
|
110
|
+
message: 'fetch() call without Zod response parsing — validate external API responses',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (fileViolations.length > 0) {
|
|
115
|
+
violations[file] = fileViolations;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const totalCount = Object.values(violations).flat().length;
|
|
120
|
+
return {
|
|
121
|
+
exitCode: totalCount > 0 ? 1 : 0,
|
|
122
|
+
skipped: false,
|
|
123
|
+
name: 'check_api_contracts',
|
|
124
|
+
data: {
|
|
125
|
+
violations,
|
|
126
|
+
message: totalCount > 0
|
|
127
|
+
? `${totalCount} API contract issue(s) found in ${Object.keys(violations).length} file(s)`
|
|
128
|
+
: 'All API contract checks passed',
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
module.exports = check;
|
|
133
|
+
|
|
134
|
+
if (require.main === module) {
|
|
135
|
+
check(process.argv[2]).then(result => {
|
|
136
|
+
console.log(JSON.stringify(result, null, 2));
|
|
137
|
+
process.exit(result.exitCode);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Next.js Architecture & Server Components Guard
|
|
4
|
+
* Validates Server Component boundaries, layout protection, and layer separation.
|
|
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 checkArchitecture(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
|
+
// 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
|
+
}
|
|
22
|
+
|
|
23
|
+
let isNext;
|
|
24
|
+
try {
|
|
25
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
26
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
27
|
+
isNext = Boolean(deps['next']);
|
|
28
|
+
} catch {
|
|
29
|
+
isNext = false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!isNext) {
|
|
33
|
+
return { exitCode: 0, skipped: true, name: 'check_architecture', data: { violations: {}, message: 'Not a Next.js project — skipping' } };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Collect changed files
|
|
37
|
+
let files;
|
|
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
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!files || files.length === 0) {
|
|
55
|
+
return { exitCode: 0, skipped: false, name: 'check_architecture', data: { violations: {}, message: 'No files changed' } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const violations = {};
|
|
59
|
+
|
|
60
|
+
for (const file of files) {
|
|
61
|
+
const filePath = path.join(dir, file);
|
|
62
|
+
if (!fs.existsSync(filePath)) continue;
|
|
63
|
+
|
|
64
|
+
let content;
|
|
65
|
+
try {
|
|
66
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
67
|
+
} catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const fileViolations = [];
|
|
72
|
+
const isLayout = /layout\.(tsx|jsx|ts|js)$/.test(file);
|
|
73
|
+
const isPage = /page\.(tsx|jsx|ts|js)$/.test(file);
|
|
74
|
+
const hasUseClient = /['"]use client['"]/.test(content);
|
|
75
|
+
|
|
76
|
+
// Check: layout.tsx should NOT have "use client"
|
|
77
|
+
if (isLayout && hasUseClient) {
|
|
78
|
+
fileViolations.push({ lineNo: 1, module: 'layout-boundary', reason: 'Layout must be a Server Component — remove "use client"' });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Check: page.tsx should NOT have "use client" (prefer Server Components)
|
|
82
|
+
if (isPage && hasUseClient) {
|
|
83
|
+
fileViolations.push({ lineNo: 1, module: 'page-boundary', reason: 'Page uses "use client" — prefer Server Component unless client interactivity is required' });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check: files with "use client" importing server-only modules
|
|
87
|
+
if (hasUseClient) {
|
|
88
|
+
if (/require\s*\(\s*['"]fs['"]\s*\)/.test(content) || /from\s+['"]fs['"]/.test(content)) {
|
|
89
|
+
fileViolations.push({ lineNo: 0, module: 'server-in-client', reason: 'Client Component imports "fs" — use a Server Component or API route instead' });
|
|
90
|
+
}
|
|
91
|
+
if (/require\s*\(\s*['"]path['"]\s*\)/.test(content) || /from\s+['"]path['"]/.test(content)) {
|
|
92
|
+
fileViolations.push({ lineNo: 0, module: 'server-in-client', reason: 'Client Component imports "path" — move logic to Server Component' });
|
|
93
|
+
}
|
|
94
|
+
if (/require\s*\(\s*['"]child_process['"]\s*\)/.test(content) || /from\s+['"]child_process['"]/.test(content)) {
|
|
95
|
+
fileViolations.push({ lineNo: 0, module: 'server-in-client', reason: 'Client Component imports "child_process" — this is a server-only module' });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Check: files without "use client" using useState/useEffect
|
|
100
|
+
if (!hasUseClient && (/\buseState\b/.test(content) || /\buseEffect\b/.test(content))) {
|
|
101
|
+
fileViolations.push({ lineNo: 0, module: 'missing-use-client', reason: 'Uses React hooks without "use client" directive — add directive or move to Client Component' });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Check: files without "use client" using onClick/onSubmit handlers
|
|
105
|
+
if (!hasUseClient && /on(Click|Submit|Change|Focus|Blur)\s*=\s*\{/.test(content)) {
|
|
106
|
+
fileViolations.push({ lineNo: 0, module: 'event-handler', reason: 'Event handler in Server Component — move to Client Component with "use client"' });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Check: "use server" should only be in API or action files
|
|
110
|
+
if (/['"]use server['"]/.test(content) && !/action|api|route/i.test(file)) {
|
|
111
|
+
fileViolations.push({ lineNo: 1, module: 'server-action-location', reason: '"use server" in non-action file — Server Actions belong in dedicated action files' });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (fileViolations.length > 0) {
|
|
115
|
+
violations[file] = fileViolations;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const totalCount = Object.values(violations).flat().length;
|
|
120
|
+
return {
|
|
121
|
+
exitCode: totalCount > 0 ? 1 : 0,
|
|
122
|
+
skipped: false,
|
|
123
|
+
name: 'check_architecture',
|
|
124
|
+
data: {
|
|
125
|
+
violations,
|
|
126
|
+
message: totalCount > 0
|
|
127
|
+
? `${totalCount} architecture issue(s) found in ${Object.keys(violations).length} file(s)`
|
|
128
|
+
: 'All architecture checks passed',
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
module.exports = check;
|
|
133
|
+
|
|
134
|
+
if (require.main === module) {
|
|
135
|
+
check(process.argv[2]).then(result => {
|
|
136
|
+
console.log(JSON.stringify(result, null, 2));
|
|
137
|
+
process.exit(result.exitCode);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Clean Architecture / Django Layer Boundary Guard
|
|
4
|
+
Validates that Python code respects architectural layer boundaries:
|
|
5
|
+
- Domain layer should not import from Application or Infrastructure
|
|
6
|
+
- Application layer should not import from Infrastructure or Routes
|
|
7
|
+
- Routes/API should not import from Domain directly (go through Application)
|
|
8
|
+
For Django: checks that models don't import from views, views don't import from urls, etc.
|
|
9
|
+
Self-contained: uses only Python stdlib (ast, pathlib, subprocess, json).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import pathlib
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_changed_py_files(target_dir: str) -> list[str] | None:
|
|
23
|
+
"""Return changed .py files from git diff, or None on failure."""
|
|
24
|
+
for ref in ("HEAD~1", "--cached"):
|
|
25
|
+
try:
|
|
26
|
+
args = ["git", "diff", "--name-only"]
|
|
27
|
+
if ref == "--cached":
|
|
28
|
+
args.append("--cached")
|
|
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
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _detect_framework(target_dir: str) -> str:
|
|
47
|
+
"""Detect whether this is a Django, FastAPI, or generic Python project."""
|
|
48
|
+
root = pathlib.Path(target_dir)
|
|
49
|
+
|
|
50
|
+
if (root / "manage.py").exists():
|
|
51
|
+
return "django"
|
|
52
|
+
|
|
53
|
+
for name in ("pyproject.toml", "requirements.txt"):
|
|
54
|
+
fpath = root / name
|
|
55
|
+
if fpath.exists():
|
|
56
|
+
try:
|
|
57
|
+
text = fpath.read_text(encoding="utf-8", errors="replace").lower()
|
|
58
|
+
except OSError:
|
|
59
|
+
continue
|
|
60
|
+
if "django" in text:
|
|
61
|
+
return "django"
|
|
62
|
+
if "fastapi" in text:
|
|
63
|
+
return "fastapi"
|
|
64
|
+
|
|
65
|
+
return "generic"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _check_django_layers(
|
|
69
|
+
filepath: str, content: str, tree: ast.Module,
|
|
70
|
+
) -> list[dict]:
|
|
71
|
+
"""Check Django layer boundary violations."""
|
|
72
|
+
violations: list[dict] = []
|
|
73
|
+
rel_path = filepath
|
|
74
|
+
|
|
75
|
+
# Determine file layer from path
|
|
76
|
+
if "/models" in rel_path or rel_path.endswith("/models.py"):
|
|
77
|
+
file_layer = "models"
|
|
78
|
+
elif "/views" in rel_path or rel_path.endswith("/views.py"):
|
|
79
|
+
file_layer = "views"
|
|
80
|
+
elif "/urls" in rel_path or rel_path.endswith("/urls.py"):
|
|
81
|
+
file_layer = "urls"
|
|
82
|
+
elif "/serializers" in rel_path or rel_path.endswith("/serializers.py"):
|
|
83
|
+
file_layer = "serializers"
|
|
84
|
+
elif "/forms" in rel_path or rel_path.endswith("/forms.py"):
|
|
85
|
+
file_layer = "forms"
|
|
86
|
+
elif "/admin" in rel_path or rel_path.endswith("/admin.py"):
|
|
87
|
+
file_layer = "admin"
|
|
88
|
+
elif "/tasks" in rel_path or rel_path.endswith("/tasks.py"):
|
|
89
|
+
file_layer = "tasks"
|
|
90
|
+
else:
|
|
91
|
+
return violations # Can't determine layer, skip
|
|
92
|
+
|
|
93
|
+
for node in ast.walk(tree):
|
|
94
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
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
|
+
|
|
102
|
+
# Django models should not import from views, urls, admin
|
|
103
|
+
if file_layer == "models":
|
|
104
|
+
bad_targets = ("views", "urls", "admin", "serializers", "forms")
|
|
105
|
+
for bad in bad_targets:
|
|
106
|
+
if f".{bad}" in imported or imported == bad:
|
|
107
|
+
violations.append({
|
|
108
|
+
"lineNo": getattr(node, "lineno", 0),
|
|
109
|
+
"module": imported,
|
|
110
|
+
"reason": (
|
|
111
|
+
f"Model imports from {bad} layer — "
|
|
112
|
+
f"models must not depend on presentation or routing"
|
|
113
|
+
),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
# Views should not import from other views or urls
|
|
117
|
+
if file_layer == "views":
|
|
118
|
+
if ".urls" in imported or imported.endswith("urls"):
|
|
119
|
+
violations.append({
|
|
120
|
+
"lineNo": getattr(node, "lineno", 0),
|
|
121
|
+
"module": imported,
|
|
122
|
+
"reason": "View imports from urls — views should not depend on URL configuration",
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
# URLs should not import from models directly
|
|
126
|
+
if file_layer == "urls":
|
|
127
|
+
if ".models" in imported or imported.endswith("models"):
|
|
128
|
+
violations.append({
|
|
129
|
+
"lineNo": getattr(node, "lineno", 0),
|
|
130
|
+
"module": imported,
|
|
131
|
+
"reason": "URL module imports models directly — route through views",
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
return violations
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _check_clean_architecture(
|
|
138
|
+
filepath: str, content: str, tree: ast.Module,
|
|
139
|
+
) -> list[dict]:
|
|
140
|
+
"""Check Clean Architecture boundary violations for generic Python projects."""
|
|
141
|
+
violations: list[dict] = []
|
|
142
|
+
|
|
143
|
+
# Detect layer from path
|
|
144
|
+
if "/domain" in filepath:
|
|
145
|
+
file_layer = "domain"
|
|
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
|
|
154
|
+
|
|
155
|
+
for node in ast.walk(tree):
|
|
156
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
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
|
+
})
|
|
177
|
+
|
|
178
|
+
# Application must not import from infrastructure or routes
|
|
179
|
+
if file_layer == "application":
|
|
180
|
+
bad = ("infrastructure", "adapters", "routes", "api")
|
|
181
|
+
for b in bad:
|
|
182
|
+
if f".{b}" in imported or imported.startswith(b):
|
|
183
|
+
violations.append({
|
|
184
|
+
"lineNo": getattr(node, "lineno", 0),
|
|
185
|
+
"module": imported,
|
|
186
|
+
"reason": (
|
|
187
|
+
f"Application layer imports from '{b}' — "
|
|
188
|
+
f"use dependency inversion"
|
|
189
|
+
),
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
return violations
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def check(target_dir: str | None = None) -> dict:
|
|
196
|
+
"""
|
|
197
|
+
Validate architecture layer boundaries in changed Python files.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
dict with keys: exit_code, skipped, name, data
|
|
201
|
+
data.violations maps filepath -> list of {lineNo, module, reason}
|
|
202
|
+
"""
|
|
203
|
+
dir_path = target_dir or os.getcwd()
|
|
204
|
+
|
|
205
|
+
files = _get_changed_py_files(dir_path)
|
|
206
|
+
if files is None:
|
|
207
|
+
return {
|
|
208
|
+
"exit_code": 0, "skipped": True, "name": "check_architecture",
|
|
209
|
+
"data": {"violations": {}, "message": "No git diff available"},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if not files:
|
|
213
|
+
return {
|
|
214
|
+
"exit_code": 0, "skipped": False, "name": "check_architecture",
|
|
215
|
+
"data": {"violations": {}, "message": "No Python files changed"},
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
framework = _detect_framework(dir_path)
|
|
219
|
+
violations: dict[str, list[dict]] = {}
|
|
220
|
+
|
|
221
|
+
for filepath in files:
|
|
222
|
+
full_path = pathlib.Path(dir_path) / filepath
|
|
223
|
+
if not full_path.exists():
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
content = full_path.read_text(encoding="utf-8", errors="replace")
|
|
228
|
+
except (OSError, PermissionError):
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
tree = ast.parse(content, filename=filepath)
|
|
233
|
+
except SyntaxError:
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
if framework == "django":
|
|
237
|
+
file_violations = _check_django_layers(filepath, content, tree)
|
|
238
|
+
else:
|
|
239
|
+
file_violations = _check_clean_architecture(filepath, content, tree)
|
|
240
|
+
|
|
241
|
+
if file_violations:
|
|
242
|
+
violations[filepath] = file_violations
|
|
243
|
+
|
|
244
|
+
total = sum(len(v) for v in violations.values())
|
|
245
|
+
if total:
|
|
246
|
+
msg = (
|
|
247
|
+
f"{total} architecture violation(s) found in "
|
|
248
|
+
f"{len(violations)} file(s)"
|
|
249
|
+
)
|
|
250
|
+
else:
|
|
251
|
+
msg = "All architecture checks passed"
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
"exit_code": 1 if total else 0,
|
|
255
|
+
"skipped": False,
|
|
256
|
+
"name": "check_architecture",
|
|
257
|
+
"data": {"violations": violations, "message": msg},
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
result = check(sys.argv[1] if len(sys.argv) > 1 else None)
|
|
263
|
+
print(json.dumps(result, indent=2))
|
|
264
|
+
sys.exit(result["exit_code"])
|