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
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- Alembic / Django Migration Idempotency & Safety Guard
4
- Checks that database migration scripts follow safe practices:
5
- - Migrations have both upgrade() and downgrade()
6
- - No destructive operations without downgrade safeguards
7
- - Migration files are not empty
8
- - Django migrations reference existing model classes
9
- Self-contained: uses only Python stdlib (ast, pathlib, subprocess, json).
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
- def _get_changed_files(target_dir: str) -> list[str] | None:
23
- """Return changed 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 = [f.strip() for f in out.splitlines() if f.strip()]
36
- if files:
37
- return files
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 issues."""
100
+ """Check an Alembic migration file for reversibility."""
70
101
  violations: list[dict] = []
71
102
 
72
- # Check for upgrade function
73
- if "def upgrade(" not in content:
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
- # Check for downgrade function
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
- # Check for destructive operations
89
- destructive_patterns = [
90
- (r"\.drop_table\(", "DROP TABLE", "Destructive: table drop without safeguard"),
91
- (r"\.drop_column\(", "DROP COLUMN", "Destructive: column drop without safeguard"),
92
- (r"\.drop_index\(", "DROP INDEX", "Destructive: index drop"),
93
- (r"batch_alter_table.*\.drop_column", "BATCH DROP COLUMN", "Batch column drop"),
94
- ]
95
- for pattern, label, reason in destructive_patterns:
96
- if re.search(pattern, content):
97
- # Check if there's a corresponding downgrade
98
- if "def downgrade(" in content:
99
- downgrade_section = content.split("def downgrade(")[1] if "def downgrade(" in content else ""
100
- if label.lower().replace(" ", "_") not in downgrade_section.lower().replace(" ", "_"):
101
- # More lenient: just flag it
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 issues."""
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
- # Check for RunPython without reverse_code
124
- if "RunPython(" in content:
125
- # Find RunPython calls and check for reverse_code
126
- run_python_pattern = re.compile(r"RunPython\(([^)]*)\)")
127
- for match in run_python_pattern.finditer(content):
128
- args = match.group(1)
129
- if "reverse_code" not in args and "run_python" not in args.lower():
130
- violations.append({
131
- "lineNo": content[:match.start()].count("\n") + 1,
132
- "rule": "run-python-no-reverse",
133
- "reason": "RunPython without reverse_code — rollback may fail",
134
- })
135
-
136
- # Check for RunSQL without reverse_sql
137
- if "RunSQL(" in content:
138
- run_sql_pattern = re.compile(r"RunSQL\(([^)]*)\)")
139
- for match in run_sql_pattern.finditer(content):
140
- args = match.group(1)
141
- if "reverse_sql" not in args:
142
- violations.append({
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 idempotency and safety issues.
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
- changed_files = _get_changed_files(dir_path)
162
- if changed_files is None:
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
- migration_files = _find_migration_files(dir_path)
169
- if not migration_files:
170
- return {
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": "No migration files were changed"},
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
- full_path = pathlib.Path(dir_path) / filepath
187
- try:
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 check = async function checkPerformance(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_performance', data: { violations: {}, message: 'No package.json found — skipping performance 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 performance 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
+ }
31
29
 
32
- if (!isNext) {
33
- return { exitCode: 0, skipped: true, name: 'check_performance', data: { violations: {}, message: 'Not a Next.js project — skipping' } };
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
- // 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_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 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
 
@@ -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 check = async function checkResponsive(targetDir) {
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
- let files;
23
- try {
24
- const out = execSync('git diff --name-only HEAD~1', {
25
- cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
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 filePath = path.join(dir, file);
47
- if (!fs.existsSync(filePath)) continue;
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 = [];