clckernel 1.2.18 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.agents/skills/clckernel_cli/SKILL.md +23 -6
  2. package/.agents/skills/clckernel_cli/reference/audit.md +13 -3
  3. package/.agents/skills/clckernel_cli/reference/commit.md +1 -1
  4. package/.agents/skills/clckernel_cli/reference/doctor.md +5 -3
  5. package/.agents/skills/clckernel_cli/reference/feature.md +3 -3
  6. package/.agents/skills/clckernel_cli/reference/guard.md +48 -12
  7. package/.agents/skills/clckernel_cli/reference/phase.md +6 -3
  8. package/.agents/skills/clckernel_cli/reference/start.md +14 -4
  9. package/.agents/skills/clckernel_cli/reference/test.md +1 -1
  10. package/.agents/skills/clckernel_cli/reference/update.md +19 -19
  11. package/CHANGELOG.md +244 -0
  12. package/LICENSE +21 -0
  13. package/README.es.md +280 -51
  14. package/README.md +274 -50
  15. package/bin/cli.js +8 -0
  16. package/package.json +12 -2
  17. package/src/adapters/agent-os.js +161 -84
  18. package/src/adapters/astro.js +8 -6
  19. package/src/adapters/base.js +27 -46
  20. package/src/adapters/django.js +14 -13
  21. package/src/adapters/fastapi.js +16 -15
  22. package/src/adapters/go.js +11 -9
  23. package/src/adapters/laravel.js +12 -10
  24. package/src/adapters/nextjs.js +7 -5
  25. package/src/adapters/rails.js +13 -11
  26. package/src/adapters/rust.js +11 -9
  27. package/src/catalog.js +58 -49
  28. package/src/config.js +38 -240
  29. package/src/detector.js +243 -44
  30. package/src/doctor.js +18 -9
  31. package/src/generator.js +737 -309
  32. package/src/gitignore.js +144 -0
  33. package/src/index.js +257 -18
  34. package/src/io.js +409 -0
  35. package/src/known-hashes.json +309 -0
  36. package/src/onboarding.js +15 -0
  37. package/src/technologies/detector.js +1 -1
  38. package/src/technologies/guards/_shared.js +1 -1
  39. package/src/ui.js +233 -40
  40. package/src/updater.js +271 -36
  41. package/tools/_changes.js +279 -0
  42. package/tools/_changes.py +233 -0
  43. package/tools/_config.js +294 -0
  44. package/tools/_config.py +271 -0
  45. package/tools/_yaml.py +489 -0
  46. package/tools/audit.js +511 -111
  47. package/tools/audit.py +506 -128
  48. package/tools/celery_guard.py +13 -35
  49. package/tools/check_a11y.js +8 -28
  50. package/tools/check_api_contracts.js +13 -39
  51. package/tools/check_architecture.js +35 -44
  52. package/tools/check_architecture.py +172 -105
  53. package/tools/check_custom.js +108 -61
  54. package/tools/check_custom.py +108 -271
  55. package/tools/check_data_files.js +88 -0
  56. package/tools/check_db_efficiency.py +80 -65
  57. package/tools/check_hitl.js +112 -69
  58. package/tools/check_links.js +138 -0
  59. package/tools/check_migrations.py +143 -94
  60. package/tools/check_performance.js +35 -44
  61. package/tools/check_placeholders.js +104 -0
  62. package/tools/check_responsive.js +8 -28
  63. package/tools/check_scope.py +40 -31
  64. package/tools/check_seo.js +8 -28
  65. package/tools/check_skills.js +158 -0
  66. package/tools/check_storybook.js +8 -21
  67. package/tools/check_symlinks.js +40 -22
  68. package/tools/check_ui_reuse.js +17 -29
  69. package/tools/docker_guard.py +19 -41
  70. package/tools/postgres_guard.py +15 -36
  71. package/tools/redis_guard.py +13 -35
  72. package/tools/scan_secrets.js +134 -67
  73. package/tools/scan_secrets.py +163 -72
  74. package/tools/verify_tdd.js +867 -0
  75. package/tools/verify_tdd.py +407 -165
  76. package/tools/yaml.js +404 -132
  77. package/tools/check_domain_data.js +0 -100
  78. package/tools/check_test_calendar_sync.js +0 -111
@@ -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
- def _get_changed_files(target_dir: str) -> list[str] | None:
18
- """Return list of changed Python 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 = [
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
- files = _get_changed_files(dir_path)
159
- if files is None:
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
- full_path = Path(dir_path) / filepath
175
- if not full_path.exists():
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=str(full_path))
162
+ tree = ast.parse(content, filename=filepath)
185
163
  except SyntaxError:
186
164
  continue
187
165
 
@@ -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 check = async function checkA11y(targetDir) {
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
- 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 => /\.(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 filePath = path.join(dir, file);
43
- if (!fs.existsSync(filePath)) continue;
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 check = async function checkApiContracts(targetDir) {
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
- // 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
- }
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
- // 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
- }
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 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
- }
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 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
- }
10
+ const { changedFiles, readFile, nearestPackageDeps } = require('./_changes');
22
11
 
23
- let isNext;
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(fs.readFileSync(pkgPath, 'utf-8'));
21
+ const pkg = JSON.parse(pkgRaw);
26
22
  const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
27
- isNext = Boolean(deps['next']);
23
+ if (deps['next']) return null;
28
24
  } catch {
29
- isNext = false;
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
- if (!isNext) {
33
- return { exitCode: 0, skipped: true, name: 'check_architecture', data: { violations: {}, message: 'Not a Next.js project — skipping' } };
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
- // 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
- }
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 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
- }
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
- 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
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 _detect_framework(target_dir: str) -> str:
47
- """Detect whether this is a Django, FastAPI, or generic Python project."""
48
- root = pathlib.Path(target_dir)
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
- if (root / "manage.py").exists():
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
- 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"
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
- return "generic"
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
- 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
-
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
- # 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
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
- 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
- })
241
+ package = _package_parts(filepath)
242
+ forbidden = _FORBIDDEN[file_layer]
177
243
 
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
- })
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
- files = _get_changed_py_files(dir_path)
206
- if files is None:
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
- framework = _detect_framework(dir_path)
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
- 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):
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 framework == "django":
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)