clckernel 1.2.19 → 2.0.1
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 +250 -0
- package/LICENSE +21 -0
- package/README.es.md +280 -51
- package/README.md +274 -50
- package/bin/deprecated.js +13 -0
- package/package.json +14 -4
- 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
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
Alembic / Django Migration
|
|
4
|
-
Checks that database migration scripts
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
|
|
8
|
-
- Django
|
|
9
|
-
|
|
3
|
+
Alembic / Django Migration Reversibility & Safety Guard
|
|
4
|
+
Checks that database migration scripts are reversible:
|
|
5
|
+
- Alembic migrations have both upgrade() and downgrade()
|
|
6
|
+
- A destructive op in upgrade() (drop_table/drop_column/drop_index/
|
|
7
|
+
drop_constraint) has a matching create/add op in downgrade()
|
|
8
|
+
- Django RunPython/RunSQL operations pass a reverse (positionally or by keyword)
|
|
9
|
+
- Django migration files are not empty
|
|
10
|
+
Self-contained: uses only Python stdlib (ast-based checks; pathlib, subprocess, json).
|
|
10
11
|
"""
|
|
11
12
|
from __future__ import annotations
|
|
12
13
|
|
|
@@ -14,30 +15,31 @@ import ast
|
|
|
14
15
|
import json
|
|
15
16
|
import os
|
|
16
17
|
import pathlib
|
|
17
|
-
import re
|
|
18
|
-
import subprocess
|
|
19
18
|
import sys
|
|
20
19
|
|
|
20
|
+
# Sibling helper shipped next to the guards; works both as a script and when
|
|
21
|
+
# loaded by audit.py through importlib.
|
|
22
|
+
_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
23
|
+
if _TOOLS_DIR not in sys.path:
|
|
24
|
+
sys.path.insert(0, _TOOLS_DIR)
|
|
25
|
+
import _changes # noqa: E402
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
39
|
-
continue
|
|
40
|
-
return None
|
|
27
|
+
|
|
28
|
+
def _is_migration_path(rel: str) -> bool:
|
|
29
|
+
"""
|
|
30
|
+
True for the paths _find_migration_files would report: alembic/versions/*.py
|
|
31
|
+
and *.py (except __init__.py) directly inside a migrations/ directory that
|
|
32
|
+
is not hidden and not under __pycache__.
|
|
33
|
+
"""
|
|
34
|
+
parts = pathlib.PurePosixPath(rel).parts
|
|
35
|
+
if not rel.endswith(".py") or len(parts) < 2:
|
|
36
|
+
return False
|
|
37
|
+
if parts[:2] == ("alembic", "versions") and len(parts) == 3:
|
|
38
|
+
return True
|
|
39
|
+
if parts[-2] != "migrations" or parts[-1] == "__init__.py":
|
|
40
|
+
return False
|
|
41
|
+
migrations_dir = "/".join(parts[:-1])
|
|
42
|
+
return not migrations_dir.startswith(".") and "__pycache__" not in migrations_dir
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
def _find_migration_files(target_dir: str) -> list[str]:
|
|
@@ -65,51 +67,104 @@ def _find_migration_files(target_dir: str) -> list[str]:
|
|
|
65
67
|
return migrations
|
|
66
68
|
|
|
67
69
|
|
|
70
|
+
# Destructive Alembic ops and the ops in downgrade() that undo each of them.
|
|
71
|
+
_DESTRUCTIVE_OPS = {
|
|
72
|
+
"drop_table": ("create_table",),
|
|
73
|
+
"drop_column": ("add_column",),
|
|
74
|
+
"drop_index": ("create_index",),
|
|
75
|
+
"drop_constraint": (
|
|
76
|
+
"create_foreign_key", "create_unique_constraint", "create_check_constraint",
|
|
77
|
+
"create_primary_key", "create_exclude_constraint",
|
|
78
|
+
),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _method_calls(node: ast.AST | None) -> list[ast.Call]:
|
|
83
|
+
"""Every `<receiver>.<method>(...)` call under node (op.x, batch_op.x, ...)."""
|
|
84
|
+
if node is None:
|
|
85
|
+
return []
|
|
86
|
+
return [
|
|
87
|
+
n for n in ast.walk(node)
|
|
88
|
+
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _module_function(tree: ast.Module, name: str) -> ast.FunctionDef | None:
|
|
93
|
+
for node in tree.body:
|
|
94
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name:
|
|
95
|
+
return node
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
68
99
|
def _check_alembic_migration(filepath: str, content: str) -> list[dict]:
|
|
69
|
-
"""Check an Alembic migration file for
|
|
100
|
+
"""Check an Alembic migration file for reversibility."""
|
|
70
101
|
violations: list[dict] = []
|
|
71
102
|
|
|
72
|
-
|
|
73
|
-
|
|
103
|
+
try:
|
|
104
|
+
tree = ast.parse(content, filename=filepath)
|
|
105
|
+
except SyntaxError as exc:
|
|
106
|
+
return [{
|
|
107
|
+
"lineNo": exc.lineno or 0,
|
|
108
|
+
"rule": "unparseable-migration",
|
|
109
|
+
"reason": "Migration is not valid Python — reversibility cannot be checked",
|
|
110
|
+
}]
|
|
111
|
+
|
|
112
|
+
upgrade = _module_function(tree, "upgrade")
|
|
113
|
+
downgrade = _module_function(tree, "downgrade")
|
|
114
|
+
|
|
115
|
+
if upgrade is None:
|
|
74
116
|
violations.append({
|
|
75
117
|
"lineNo": 0,
|
|
76
118
|
"rule": "missing-upgrade",
|
|
77
119
|
"reason": "Migration has no upgrade() function",
|
|
78
120
|
})
|
|
79
121
|
|
|
80
|
-
|
|
81
|
-
if "def downgrade(" not in content:
|
|
122
|
+
if downgrade is None:
|
|
82
123
|
violations.append({
|
|
83
124
|
"lineNo": 0,
|
|
84
125
|
"rule": "missing-downgrade",
|
|
85
126
|
"reason": "Migration has no downgrade() function — rollback impossible",
|
|
86
127
|
})
|
|
87
128
|
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
violations.append({
|
|
103
|
-
"lineNo": 0,
|
|
104
|
-
"rule": f"destructive-{label.lower().replace(' ', '_')}",
|
|
105
|
-
"reason": f"{reason} — verify downgrade covers this",
|
|
106
|
-
})
|
|
129
|
+
# A destructive op in upgrade() is reversible only when downgrade()
|
|
130
|
+
# recreates what it removed. An empty / `pass` downgrade recreates nothing.
|
|
131
|
+
undo_ops = {call.func.attr for call in _method_calls(downgrade)}
|
|
132
|
+
for call in _method_calls(upgrade):
|
|
133
|
+
op = call.func.attr
|
|
134
|
+
if op in _DESTRUCTIVE_OPS and not undo_ops.intersection(_DESTRUCTIVE_OPS[op]):
|
|
135
|
+
violations.append({
|
|
136
|
+
"lineNo": call.lineno,
|
|
137
|
+
"rule": f"destructive-{op}",
|
|
138
|
+
"reason": (
|
|
139
|
+
f"upgrade() calls {op}() but downgrade() has no "
|
|
140
|
+
f"{' / '.join(_DESTRUCTIVE_OPS[op])}() — the migration is not reversible"
|
|
141
|
+
),
|
|
142
|
+
})
|
|
107
143
|
|
|
108
144
|
return violations
|
|
109
145
|
|
|
110
146
|
|
|
147
|
+
# Django operations that need a reverse, with the keyword that supplies it.
|
|
148
|
+
# The reverse is also accepted as the second positional argument.
|
|
149
|
+
_DJANGO_REVERSIBLE = {
|
|
150
|
+
"RunPython": ("reverse_code", "run-python-no-reverse",
|
|
151
|
+
"RunPython without reverse_code — rollback may fail"),
|
|
152
|
+
"RunSQL": ("reverse_sql", "run-sql-no-reverse",
|
|
153
|
+
"RunSQL without reverse_sql — rollback may fail"),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _call_name(call: ast.Call) -> str:
|
|
158
|
+
func = call.func
|
|
159
|
+
if isinstance(func, ast.Attribute):
|
|
160
|
+
return func.attr
|
|
161
|
+
if isinstance(func, ast.Name):
|
|
162
|
+
return func.id
|
|
163
|
+
return ""
|
|
164
|
+
|
|
165
|
+
|
|
111
166
|
def _check_django_migration(filepath: str, content: str) -> list[dict]:
|
|
112
|
-
"""Check a Django migration file for
|
|
167
|
+
"""Check a Django migration file for reversibility."""
|
|
113
168
|
violations: list[dict] = []
|
|
114
169
|
|
|
115
170
|
# Check for empty migration
|
|
@@ -120,37 +175,33 @@ def _check_django_migration(filepath: str, content: str) -> list[dict]:
|
|
|
120
175
|
"reason": "Migration file appears empty or near-empty",
|
|
121
176
|
})
|
|
122
177
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
"lineNo": content[:match.start()].count("\n") + 1,
|
|
144
|
-
"rule": "run-sql-no-reverse",
|
|
145
|
-
"reason": "RunSQL without reverse_sql — rollback may fail",
|
|
146
|
-
})
|
|
178
|
+
try:
|
|
179
|
+
tree = ast.parse(content, filename=filepath)
|
|
180
|
+
except SyntaxError as exc:
|
|
181
|
+
violations.append({
|
|
182
|
+
"lineNo": exc.lineno or 0,
|
|
183
|
+
"rule": "unparseable-migration",
|
|
184
|
+
"reason": "Migration is not valid Python — reversibility cannot be checked",
|
|
185
|
+
})
|
|
186
|
+
return violations
|
|
187
|
+
|
|
188
|
+
for node in ast.walk(tree):
|
|
189
|
+
if not isinstance(node, ast.Call):
|
|
190
|
+
continue
|
|
191
|
+
spec = _DJANGO_REVERSIBLE.get(_call_name(node))
|
|
192
|
+
if spec is None:
|
|
193
|
+
continue
|
|
194
|
+
keyword, rule, reason = spec
|
|
195
|
+
has_reverse = len(node.args) >= 2 or any(kw.arg == keyword for kw in node.keywords)
|
|
196
|
+
if not has_reverse:
|
|
197
|
+
violations.append({"lineNo": node.lineno, "rule": rule, "reason": reason})
|
|
147
198
|
|
|
148
199
|
return violations
|
|
149
200
|
|
|
150
201
|
|
|
151
202
|
def check(target_dir: str | None = None) -> dict:
|
|
152
203
|
"""
|
|
153
|
-
Check migration files for
|
|
204
|
+
Check migration files for reversibility and safety issues.
|
|
154
205
|
|
|
155
206
|
Returns:
|
|
156
207
|
dict with keys: exit_code, skipped, name, data
|
|
@@ -158,35 +209,33 @@ def check(target_dir: str | None = None) -> dict:
|
|
|
158
209
|
"""
|
|
159
210
|
dir_path = target_dir or os.getcwd()
|
|
160
211
|
|
|
161
|
-
|
|
162
|
-
|
|
212
|
+
# The snapshot being verified: the index, or HEAD when nothing is staged.
|
|
213
|
+
changes = _changes.changed_files(dir_path)
|
|
214
|
+
mode = changes["mode"]
|
|
215
|
+
if mode is None:
|
|
163
216
|
return {
|
|
164
217
|
"exit_code": 0, "skipped": True, "name": "check_migrations",
|
|
165
218
|
"data": {"violations": {}, "message": "No git diff available"},
|
|
166
219
|
}
|
|
167
220
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
"exit_code": 0, "skipped": False, "name": "check_migrations",
|
|
172
|
-
"data": {"violations": {}, "message": "No migration files found"},
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
# Only check migrations that were changed
|
|
176
|
-
changed_migrations = [f for f in migration_files if f in changed_files]
|
|
221
|
+
# Changed migrations come from the snapshot's path list: a migration
|
|
222
|
+
# deleted or edited in the working copy must not hide the staged one.
|
|
223
|
+
changed_migrations = [f for f in changes["files"] if _is_migration_path(f)]
|
|
177
224
|
if not changed_migrations:
|
|
225
|
+
message = (
|
|
226
|
+
"No migration files were changed" if _find_migration_files(dir_path)
|
|
227
|
+
else "No migration files found"
|
|
228
|
+
)
|
|
178
229
|
return {
|
|
179
230
|
"exit_code": 0, "skipped": False, "name": "check_migrations",
|
|
180
|
-
"data": {"violations": {}, "message":
|
|
231
|
+
"data": {"violations": {}, "message": message},
|
|
181
232
|
}
|
|
182
233
|
|
|
183
234
|
violations: dict[str, list[dict]] = {}
|
|
184
235
|
|
|
185
236
|
for filepath in changed_migrations:
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
content = full_path.read_text(encoding="utf-8", errors="replace")
|
|
189
|
-
except (OSError, PermissionError):
|
|
237
|
+
content = _changes.read_file(dir_path, filepath, mode)
|
|
238
|
+
if content is None:
|
|
190
239
|
continue
|
|
191
240
|
|
|
192
241
|
# Determine migration framework
|
|
@@ -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_performance', data: { violations: {}, message: 'No package.json found — skipping performance 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 performance 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
|
+
}
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
const check = async function checkPerformance(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'] });
|
|
35
|
+
|
|
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_performance', 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_performance', data: { violations: {}, message: 'No git diff available' } };
|
|
51
|
-
}
|
|
48
|
+
if (mode === null) {
|
|
49
|
+
return { exitCode: 0, skipped: true, name: 'check_performance', 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 checkPerformance(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
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forgotten Placeholder Guard
|
|
4
|
+
* In every .md/.yml/.yaml/.json file changed in the snapshot, flags the
|
|
5
|
+
* markers a draft leaves behind: the uppercase draft words listed in
|
|
6
|
+
* DRAFT_WORDS (whole words), "lorem ipsum" (any case) and unresolved
|
|
7
|
+
* `{{...}}` slots.
|
|
8
|
+
* Inside templates/ the `{{...}}` slots are legitimate and not flagged.
|
|
9
|
+
* GitHub Actions expressions (`${{ ... }}`) are never flagged; in Markdown,
|
|
10
|
+
* code spans and fenced code blocks are ignored.
|
|
11
|
+
* Skills the harness installs (clckernel_cli) and registry files are ignored.
|
|
12
|
+
* Self-contained: uses only Node.js built-ins.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} targetDir - Repository root (defaults to cwd)
|
|
15
|
+
* @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
|
|
16
|
+
*/
|
|
17
|
+
const { changedFiles, readFile } = require('./_changes');
|
|
18
|
+
|
|
19
|
+
const NAME = 'check_placeholders';
|
|
20
|
+
const EXTENSIONS = ['.md', '.yml', '.yaml', '.json'];
|
|
21
|
+
const HARNESS_SKILLS = new Set(['clckernel_cli', 'clc_forge_cli']);
|
|
22
|
+
const FENCE_RE = /^\s{0,3}(`{3,}|~{3,})/;
|
|
23
|
+
// Spelled in parts so this source never carries the markers it looks for.
|
|
24
|
+
const DRAFT_WORDS = [['TO', 'DO'], ['T', 'BD'], ['FIX', 'ME']].map(parts => parts.join(''));
|
|
25
|
+
const MARKERS = [
|
|
26
|
+
...DRAFT_WORDS.map(word => ({ label: word, re: new RegExp(`\\b${word}\\b`) })),
|
|
27
|
+
{ label: 'lorem ipsum', re: /\blorem\s+ipsum\b/i },
|
|
28
|
+
];
|
|
29
|
+
const SLOT = { label: 'unresolved {{...}}', re: /(?<!\$)\{\{[^{}\n]*\}\}/ };
|
|
30
|
+
|
|
31
|
+
function isHarnessFile(file) {
|
|
32
|
+
return file.split('/').some(segment => HARNESS_SKILLS.has(segment));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True for a file under a templates/ folder (at any depth). */
|
|
36
|
+
function inTemplates(file) {
|
|
37
|
+
return file.split('/').slice(0, -1).includes('templates');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Markdown lines with fenced code blocks blanked and code spans removed. */
|
|
41
|
+
function proseLines(text) {
|
|
42
|
+
let fence = null;
|
|
43
|
+
return text.split(/\r?\n/).map(line => {
|
|
44
|
+
const open = FENCE_RE.exec(line);
|
|
45
|
+
if (fence) {
|
|
46
|
+
if (open && open[1][0] === fence[0] && open[1].length >= fence.length) fence = null;
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
if (open) {
|
|
50
|
+
fence = open[1];
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
return line.replace(/(`+)[^`]*?\1/g, '');
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const check = async function checkPlaceholders(targetDir) {
|
|
58
|
+
const dir = targetDir || process.cwd();
|
|
59
|
+
const { mode, files } = changedFiles(dir, { extensions: EXTENSIONS });
|
|
60
|
+
const candidates = files.filter(f => !isHarnessFile(f));
|
|
61
|
+
|
|
62
|
+
if (candidates.length === 0) {
|
|
63
|
+
return {
|
|
64
|
+
exitCode: 0, skipped: true, name: NAME,
|
|
65
|
+
data: { violations: [], message: 'No changed .md/.yml/.yaml/.json files' },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const violations = [];
|
|
70
|
+
for (const file of candidates) {
|
|
71
|
+
const text = readFile(dir, file, mode);
|
|
72
|
+
if (text === null) continue;
|
|
73
|
+
const markers = inTemplates(file) ? MARKERS : [...MARKERS, SLOT];
|
|
74
|
+
const lines = file.endsWith('.md') ? proseLines(text) : text.split(/\r?\n/);
|
|
75
|
+
lines.forEach((line, index) => {
|
|
76
|
+
for (const { label, re } of markers) {
|
|
77
|
+
if (re.test(line)) {
|
|
78
|
+
violations.push({ file, line: index + 1, message: `Forgotten placeholder (${label}): ${line.trim().slice(0, 120)}` });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
exitCode: violations.length > 0 ? 1 : 0,
|
|
86
|
+
skipped: false,
|
|
87
|
+
name: NAME,
|
|
88
|
+
data: {
|
|
89
|
+
violations,
|
|
90
|
+
message: violations.length > 0
|
|
91
|
+
? `${violations.length} forgotten placeholder(s) found`
|
|
92
|
+
: `No forgotten placeholders in ${candidates.length} changed file(s)`,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
module.exports = check;
|
|
98
|
+
|
|
99
|
+
if (require.main === module) {
|
|
100
|
+
check(process.argv[2]).then(result => {
|
|
101
|
+
console.log(JSON.stringify(result, null, 2));
|
|
102
|
+
process.exit(result.exitCode);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -12,28 +12,15 @@
|
|
|
12
12
|
* @param {string} targetDir - Directory to scan (defaults to cwd)
|
|
13
13
|
* @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
|
|
14
14
|
*/
|
|
15
|
-
const
|
|
16
|
-
const fs = require('fs');
|
|
17
|
-
const path = require('path');
|
|
18
|
-
const { execSync } = require('child_process');
|
|
15
|
+
const { changedFiles, readFile } = require('./_changes');
|
|
19
16
|
|
|
17
|
+
const check = async function checkResponsive(targetDir) {
|
|
20
18
|
const dir = targetDir || process.cwd();
|
|
21
19
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
});
|
|
27
|
-
files = out.split('\n').filter(f => /\.(tsx|jsx|vue|astro|html|css)$/.test(f.trim()));
|
|
28
|
-
} catch {
|
|
29
|
-
try {
|
|
30
|
-
const out = execSync('git diff --name-only --cached', {
|
|
31
|
-
cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
|
|
32
|
-
});
|
|
33
|
-
files = out.split('\n').filter(f => /\.(tsx|jsx|vue|astro|html|css)$/.test(f.trim()));
|
|
34
|
-
} catch {
|
|
35
|
-
return { exitCode: 0, skipped: true, name: 'check_responsive', data: { violations: {}, message: 'No git diff available' } };
|
|
36
|
-
}
|
|
20
|
+
// The snapshot being verified: the index, or HEAD when nothing is staged.
|
|
21
|
+
const { mode, files } = changedFiles(dir, { extensions: ['.tsx', '.jsx', '.vue', '.astro', '.html', '.css'] });
|
|
22
|
+
if (mode === null) {
|
|
23
|
+
return { exitCode: 0, skipped: true, name: 'check_responsive', data: { violations: {}, message: 'No git diff available' } };
|
|
37
24
|
}
|
|
38
25
|
|
|
39
26
|
if (!files || files.length === 0) {
|
|
@@ -43,15 +30,8 @@ const check = async function checkResponsive(targetDir) {
|
|
|
43
30
|
const violations = {};
|
|
44
31
|
|
|
45
32
|
for (const file of files) {
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
let content;
|
|
50
|
-
try {
|
|
51
|
-
content = fs.readFileSync(filePath, 'utf-8');
|
|
52
|
-
} catch {
|
|
53
|
-
continue;
|
|
54
|
-
}
|
|
33
|
+
const content = readFile(dir, file, mode);
|
|
34
|
+
if (content === null) continue;
|
|
55
35
|
|
|
56
36
|
const lines = content.split('\n');
|
|
57
37
|
const fileViolations = [];
|