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,233 +1,61 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
3
  Custom Rules Guard — Python
4
- Reads .clc-forge.yml and executes user-defined regex rules against changed files.
5
- Self-contained: uses only Python stdlib + optional pyyaml.
4
+ Reads .clckernel.yaml and executes user-defined regex rules against changed files.
5
+ Self-contained: Python stdlib + optional PyYAML, and the sibling helpers
6
+ _changes.py and _yaml.py (the YAML subset parser, shared with _config.py).
6
7
  """
7
8
 
9
+ from __future__ import annotations
10
+
8
11
  import json
9
12
  import os
10
13
  import pathlib
11
14
  import re
12
- import subprocess
13
15
  import sys
14
16
  from typing import Any, Optional
15
17
 
16
- # ═══════════════════════════════════════════════════════════════════════
17
- # YAML Parser — try pyyaml first, fallback to inline minimal parser
18
- # ═══════════════════════════════════════════════════════════════════════
19
-
20
- def _parse_yaml(text: str) -> dict:
21
- """Parse YAML text. Uses PyYAML if available, otherwise inline minimal parser."""
22
- try:
23
- import yaml
24
- result = yaml.safe_load(text)
25
- return result if isinstance(result, dict) else {}
26
- except ImportError:
27
- pass
28
- except Exception:
29
- raise
18
+ # Sibling helper shipped next to the guards; works both as a script and when
19
+ # loaded by audit.py through importlib.
20
+ _TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
21
+ if _TOOLS_DIR not in sys.path:
22
+ sys.path.insert(0, _TOOLS_DIR)
23
+ import _changes # noqa: E402
30
24
 
31
- return _minimal_yaml_parse(text)
32
25
 
26
+ def _yaml_module():
27
+ """The shared parser (tools/_yaml.py), imported only when a config exists.
33
28
 
34
- def _minimal_yaml_parse(text: str) -> dict:
35
- """
36
- Minimal YAML parser (~80 lines).
37
- Supports: scalars, block sequences, block mappings, inline flow sequences,
38
- comments. No anchors/aliases, no multiple documents.
29
+ Install ships it next to _changes.py; an older install without it fails
30
+ closed (run `clckernel update`).
39
31
  """
40
- lines = text.split('\n')
41
- root: dict = {}
42
- # Stack entries: (obj, indent, parent_obj, parent_key)
43
- stack: list[tuple[dict, int, Optional[dict], Optional[str]]] = [
44
- (root, -1, None, None)
45
- ]
46
-
47
- for line_text in lines:
48
- # Strip comments (but not inside quoted strings)
49
- stripped = line_text.rstrip()
50
- comment_idx = _find_comment(stripped)
51
- if comment_idx != -1:
52
- stripped = stripped[:comment_idx]
53
- if not stripped or stripped.isspace():
54
- continue
55
-
56
- indent = len(stripped) - len(stripped.lstrip())
57
- trimmed = stripped.lstrip()
58
-
59
- # Pop stack back to correct parent
60
- while len(stack) > 1 and stack[-1][1] >= indent:
61
- stack.pop()
62
- entry = stack[-1]
63
- obj = entry[0]
64
- parent_obj = entry[2]
65
- parent_key = entry[3]
66
-
67
- # ── Sequence item: "- rest" ──
68
- if trimmed.startswith('- '):
69
- rest = trimmed[2:].strip()
70
-
71
- if ': ' in rest:
72
- # "- key: value" — mapping inside a sequence
73
- arr = _get_or_create_array(parent_obj, parent_key, obj)
74
- if arr is not None:
75
- item = {}
76
- colon_idx = rest.index(': ')
77
- k = rest[:colon_idx].strip()
78
- v = rest[colon_idx + 2:].strip()
79
- item[k] = _parse_scalar(v)
80
- arr.append(item)
81
- stack.append((item, indent, arr, None))
82
- elif parent_obj is not None and parent_key is not None:
83
- # "- scalar" inside a sequence
84
- pk = parent_obj.get(parent_key)
85
- if isinstance(pk, list):
86
- pk.append(_parse_scalar(rest))
87
- continue
88
-
89
- # ── key: value pair ──
90
- kv_match = re.match(r'^([^:]+):\s*(.*)$', trimmed)
91
- if kv_match:
92
- key = kv_match.group(1).strip()
93
- val = kv_match.group(2).strip()
94
-
95
- if not val or val == '|' or val == '>':
96
- # Empty value → children follow
97
- if key not in obj or not isinstance(obj[key], (dict, list)):
98
- obj[key] = {}
99
- stack.append((obj[key] if isinstance(obj[key], dict) else {}, indent, obj, key))
100
- elif val.startswith('[') and val.endswith(']'):
101
- inner = val[1:-1].strip()
102
- if inner:
103
- obj[key] = [_parse_scalar(s.strip()) for s in inner.split(',')]
104
- else:
105
- obj[key] = []
106
- elif val.startswith('{') and val.endswith('}'):
107
- obj[key] = _parse_flow_mapping(val)
108
- else:
109
- obj[key] = _parse_scalar(val)
110
- continue
111
-
112
- return root
113
-
114
-
115
- def _find_comment(line: str) -> int:
116
- """Find index of comment character not inside quotes."""
117
- in_single = False
118
- in_double = False
119
- for i, ch in enumerate(line):
120
- if ch == "'" and not in_double:
121
- in_single = not in_single
122
- elif ch == '"' and not in_single:
123
- in_double = not in_double
124
- elif ch == '#' and not in_single and not in_double:
125
- return i
126
- return -1
127
-
128
-
129
- def _get_or_create_array(parent_obj, parent_key, current_obj):
130
- """Get or create an array for sequence items."""
131
- if parent_obj is None or parent_key is None:
132
- return None
133
- pk = parent_obj.get(parent_key)
134
- if isinstance(pk, list):
135
- return pk
136
- if isinstance(pk, dict) and len(pk) == 0:
137
- parent_obj[parent_key] = []
138
- return parent_obj[parent_key]
139
- return None
140
-
141
-
142
- def _parse_scalar(val: str) -> Any:
143
- """Parse a YAML scalar value."""
144
- if val == '' or val == '~' or val == 'null':
145
- return None
146
- if val == 'true':
147
- return True
148
- if val == 'false':
149
- return False
150
- if re.match(r'^-?\d+$', val):
151
- return int(val)
152
- if re.match(r'^-?\d+\.\d+$', val):
153
- return float(val)
154
- # Strip quotes and unescape
155
- if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
156
- return val[1:-1].replace('\\', '')
157
- return val
158
-
159
-
160
- def _parse_flow_mapping(s: str) -> dict:
161
- """Parse a YAML inline flow mapping {key: value, ...}."""
162
- inner = s[1:-1].strip()
163
- if not inner:
164
- return {}
165
- obj = {}
166
- for pair in inner.split(','):
167
- colon_idx = pair.find(':')
168
- if colon_idx == -1:
169
- continue
170
- k = pair[:colon_idx].strip()
171
- v = pair[colon_idx + 1:].strip()
172
- obj[k] = _parse_scalar(v)
173
- return obj
32
+ try:
33
+ import _yaml
34
+ except ImportError as exc:
35
+ raise RuntimeError(
36
+ "tools/_yaml.py is missing: run `clckernel update` so the guards can read the config"
37
+ ) from exc
38
+ return _yaml
174
39
 
175
40
 
176
41
  # ═══════════════════════════════════════════════════════════════════════
177
- # Glob-to-Regex Converter (~15 lines)
178
- # Handles: **, *, {a,b} alternation
42
+ # Main Guard Contract
179
43
  # ═══════════════════════════════════════════════════════════════════════
180
44
 
181
- import fnmatch
182
-
183
-
184
- def _glob_to_regex(glob_pattern: str) -> re.Pattern:
185
- """Convert a glob pattern to a compiled regex. Handles **, *, {a,b}."""
186
- # fnmatch.translate converts globs but adds \Z$ at end, we need to adjust
187
- # for ** patterns that need to match across path separators
188
- parts = glob_pattern.split('/')
189
- regex_parts = []
190
- i = 0
191
- while i < len(parts):
192
- part = parts[i]
193
- if part == '**':
194
- # ** matches zero or more path segments
195
- regex_parts.append('(?:.+/)?')
196
- # Skip trailing empty segment if glob ends with **
197
- i += 1
198
- continue
199
- else:
200
- # Convert single segment with fnmatch
201
- translated = fnmatch.translate(part)
202
- # fnmatch.translate adds (?s:...) and \Z at end; strip wrapper
203
- # Remove leading (?s: and trailing )\Z
204
- inner = translated
205
- if inner.startswith('(?s:'):
206
- inner = inner[4:]
207
- if inner.endswith(')\\Z'):
208
- inner = inner[:-3]
209
- elif inner.endswith('\\Z'):
210
- inner = inner[:-2]
211
- regex_parts.append(inner)
212
- i += 1
213
-
214
- full = '/'.join(regex_parts)
215
- return re.compile('^' + full + '$')
216
-
45
+ # Lookup order: the canonical name first, then older names kept for compatibility.
46
+ CONFIG_NAMES = ['.clckernel.yaml', '.clckernel.yml', '.clc-forge.yaml', '.clc-forge.yml']
47
+ CANONICAL_CONFIG = CONFIG_NAMES[0]
48
+ DEPRECATED_PREFIX = '.clc-forge.'
217
49
 
218
- # ═══════════════════════════════════════════════════════════════════════
219
- # Main Guard Contract
220
- # ═══════════════════════════════════════════════════════════════════════
221
50
 
222
51
  def check(target_dir: Optional[str] = None) -> dict:
223
52
  """
224
53
  Custom rules guard entry point.
225
- Reads .clc-forge.yml and executes user-defined regex rules.
226
- Returns: {exit_code, skipped, name, data: {violations, message}}
54
+ Reads .clckernel.yaml and executes user-defined regex rules.
55
+ Returns: {exit_code, skipped, name, data: {violations, message[, warnings]}}
227
56
  """
228
57
  dir_path = pathlib.Path(target_dir or os.getcwd())
229
- candidates = ['.clckernel.yml', '.clckernel.yaml', '.clc-forge.yml', '.clc-forge.yaml']
230
- config_path = next((dir_path / c for c in candidates if (dir_path / c).exists()), None)
58
+ config_path = next((dir_path / c for c in CONFIG_NAMES if (dir_path / c).exists()), None)
231
59
 
232
60
  # ── 1. Load config ──────────────────────────────────────────────
233
61
  if not config_path:
@@ -235,27 +63,42 @@ def check(target_dir: Optional[str] = None) -> dict:
235
63
  "exit_code": 0,
236
64
  "skipped": True,
237
65
  "name": "check_custom",
238
- "data": {"violations": {}, "message": "No .clckernel.yml or .clc-forge.yml found"},
66
+ "data": {"violations": {}, "message": f"No {CANONICAL_CONFIG} found"},
67
+ }
68
+
69
+ config_name = config_path.name
70
+ result = _run_rules(dir_path, config_path, config_name)
71
+ if config_name.startswith(DEPRECATED_PREFIX):
72
+ result["data"]["warnings"] = [f"{config_name}: deprecated config name, rename to {CANONICAL_CONFIG}"]
73
+ return result
74
+
75
+
76
+ def _run_rules(dir_path: pathlib.Path, config_path: pathlib.Path, config_name: str) -> dict:
77
+ """Validate the rules in one config file and run them against changed files."""
78
+
79
+ def config_error(message: str) -> dict:
80
+ return {
81
+ "exit_code": 1,
82
+ "skipped": False,
83
+ "name": "check_custom",
84
+ "data": {"violations": {}, "message": f"Config error in {config_name}: {message}"},
239
85
  }
240
86
 
241
87
  try:
242
- config = _parse_yaml(config_path.read_text(encoding='utf-8'))
88
+ yaml_module = _yaml_module()
89
+ config = yaml_module.parse_yaml(config_path.read_text(encoding='utf-8'))
243
90
  except Exception as e:
91
+ # Fail closed: rules we cannot read must not turn into a silent PASS.
244
92
  return {
245
- "exit_code": 0,
246
- "skipped": True,
93
+ "exit_code": 1,
94
+ "skipped": False,
247
95
  "name": "check_custom",
248
- "data": {"violations": {}, "message": f"Config parse error: {e}"},
96
+ "data": {"violations": {}, "message": f"Config parse error in {config_name}: {e}"},
249
97
  }
250
98
 
251
99
  rules = config.get('rules') if isinstance(config, dict) else None
252
100
  if rules is not None and not isinstance(rules, list):
253
- return {
254
- "exit_code": 0,
255
- "skipped": True,
256
- "name": "check_custom",
257
- "data": {"violations": {}, "message": 'Config error: "rules" must be a list'},
258
- }
101
+ return config_error('"rules" must be a list')
259
102
  if not isinstance(rules, list) or len(rules) == 0:
260
103
  return {
261
104
  "exit_code": 0,
@@ -264,42 +107,40 @@ def check(target_dir: Optional[str] = None) -> dict:
264
107
  "data": {"violations": {}, "message": "No custom rules defined"},
265
108
  }
266
109
 
267
- # ── 2. Validate rules: compile regexes, check required fields ───
110
+ # ── 2. Validate rules: shape and regex; any invalid rule fails the guard ──
268
111
  validated = []
269
- for rule in rules:
270
- if not isinstance(rule, dict):
271
- continue
272
- if not rule.get('name') or not rule.get('pattern') or not rule.get('message'):
112
+ errors = []
113
+ for i, rule in enumerate(rules):
114
+ problems = _validate_rule(rule)
115
+ is_named = isinstance(rule, dict) and isinstance(rule.get('name'), str)
116
+ label = f'"{rule["name"]}"' if is_named else f'#{i + 1}'
117
+ if problems:
118
+ errors.append(f"rule {label}: {', '.join(problems)}")
273
119
  continue
274
120
  try:
275
121
  regex = re.compile(rule['pattern'], re.MULTILINE)
276
- scope_re = _glob_to_regex(rule.get('scope', '**/*'))
277
- exclude_re = [_glob_to_regex(ex) for ex in rule.get('exclude', [])]
278
- validated.append({
279
- 'name': rule['name'],
280
- 'pattern': rule['pattern'],
281
- 'message': rule['message'],
282
- 'severity': rule.get('severity', 'error'),
283
- 'exclude': rule.get('exclude', []),
284
- 'regex': regex,
285
- 'scope_re': scope_re,
286
- 'exclude_re': exclude_re,
287
- })
288
- except re.error:
289
- # Invalid regex — skip this rule silently
122
+ except re.error as e:
123
+ errors.append(f'rule {label}: invalid pattern: {e}')
290
124
  continue
291
-
292
- if len(validated) == 0:
293
- return {
294
- "exit_code": 0,
295
- "skipped": False,
296
- "name": "check_custom",
297
- "data": {"violations": {}, "message": "No valid custom rules after validation"},
298
- }
299
-
300
- # ── 3. Get changed files via git diff ───────────────────────────
301
- files = _get_changed_files(dir_path)
302
- if files is None:
125
+ validated.append({
126
+ 'name': rule['name'],
127
+ 'pattern': rule['pattern'],
128
+ 'message': rule['message'],
129
+ 'severity': rule.get('severity') or 'error',
130
+ 'exclude': rule.get('exclude') or [],
131
+ 'regex': regex,
132
+ 'scope_re': yaml_module.glob_to_regex(rule.get('scope') or '**/*'),
133
+ 'exclude_re': [yaml_module.glob_to_regex(ex) for ex in rule.get('exclude') or []],
134
+ })
135
+
136
+ if errors:
137
+ return config_error('; '.join(errors))
138
+
139
+ # ── 3. Changed files of the snapshot being verified ─────────────
140
+ # The index, or HEAD when nothing is staged; never the working copy.
141
+ changes = _changes.changed_files(str(dir_path))
142
+ mode, files = changes["mode"], changes["files"]
143
+ if mode is None:
303
144
  return {
304
145
  "exit_code": 0,
305
146
  "skipped": True,
@@ -319,11 +160,9 @@ def check(target_dir: Optional[str] = None) -> dict:
319
160
  has_error = False
320
161
 
321
162
  for file in files:
322
- file_path = pathlib.Path(dir_path) / file
323
- try:
324
- content = file_path.read_text(encoding='utf-8')
325
- except (PermissionError, OSError):
326
- continue # permission denied or unreadable — skip
163
+ content = _changes.read_file(str(dir_path), file, mode)
164
+ if content is None:
165
+ continue # missing or binary in the snapshot — skip
327
166
 
328
167
  for rule in validated:
329
168
  if not rule['scope_re'].match(file):
@@ -331,6 +170,8 @@ def check(target_dir: Optional[str] = None) -> dict:
331
170
  if any(re.match(str(ex.pattern), file) for ex in rule['exclude_re']):
332
171
  continue
333
172
 
173
+ # Empty matches (e.g. `x*`, `^`) are violations too; check_custom.js
174
+ # steps over them one code point at a time to report the same ones.
334
175
  for m in rule['regex'].finditer(content):
335
176
  line_no = content[:m.start()].count('\n') + 1
336
177
  if file not in violations:
@@ -360,28 +201,22 @@ def check(target_dir: Optional[str] = None) -> dict:
360
201
  }
361
202
 
362
203
 
363
- def _get_changed_files(dir_path: str) -> Optional[list[str]]:
364
- """Get list of changed files via git diff. Returns None if no diff available."""
365
- try:
366
- result = subprocess.run(
367
- ['git', 'diff', '--name-only', 'HEAD~1'],
368
- cwd=dir_path, capture_output=True, text=True, timeout=10,
369
- )
370
- files = [f for f in result.stdout.split('\n') if f.strip()]
371
- if files:
372
- return files
373
- except Exception:
374
- pass
375
-
376
- try:
377
- result = subprocess.run(
378
- ['git', 'diff', '--name-only', '--cached'],
379
- cwd=dir_path, capture_output=True, text=True, timeout=10,
380
- )
381
- files = [f for f in result.stdout.split('\n') if f.strip()]
382
- return files if files else []
383
- except Exception:
384
- return None
204
+ def _validate_rule(rule: Any) -> list:
205
+ """Shape problems of one rule (empty list when valid)."""
206
+ if not isinstance(rule, dict):
207
+ return ['must be a mapping']
208
+ problems = []
209
+ for field in ('name', 'pattern', 'message'):
210
+ if not isinstance(rule.get(field), str) or rule.get(field) == '':
211
+ problems.append(f'"{field}" must be a non-empty string')
212
+ if rule.get('severity') is not None and rule.get('severity') not in ('error', 'warning'):
213
+ problems.append('"severity" must be "error" or "warning"')
214
+ if rule.get('scope') is not None and not isinstance(rule.get('scope'), str):
215
+ problems.append('"scope" must be a string')
216
+ exclude = rule.get('exclude')
217
+ if exclude is not None and not (isinstance(exclude, list) and all(isinstance(e, str) for e in exclude)):
218
+ problems.append('"exclude" must be a list of strings')
219
+ return problems
385
220
 
386
221
 
387
222
  # ═══════════════════════════════════════════════════════════════════════
@@ -391,5 +226,7 @@ def _get_changed_files(dir_path: str) -> Optional[list[str]]:
391
226
  if __name__ == '__main__':
392
227
  target = sys.argv[1] if len(sys.argv) > 1 else None
393
228
  result = check(target)
229
+ for warning in result["data"].get("warnings", []):
230
+ print(f"[check_custom] warning: {warning}", file=sys.stderr)
394
231
  print(json.dumps(result, indent=2))
395
232
  sys.exit(result["exit_code"])
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Data File Guard
4
+ * Every .json file changed in the snapshot must parse as JSON, whatever folder
5
+ * it lives in. A failure names the file and the parse error (reason and
6
+ * line/column); the file content is never printed.
7
+ * Only JSON: YAML data files may use full YAML, which the strict subset parser
8
+ * of the harness (tools/yaml.js) cannot validate without false positives.
9
+ * Self-contained: uses only Node.js built-ins.
10
+ *
11
+ * @param {string} targetDir - Repository root (defaults to cwd)
12
+ * @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
13
+ */
14
+ const { changedFiles, readFile } = require('./_changes');
15
+
16
+ const NAME = 'check_data_files';
17
+ const MAX_REASON = 120;
18
+
19
+ /** Line and column (1-based) of a character offset. */
20
+ function lineColumn(text, offset) {
21
+ const before = text.slice(0, offset).split('\n');
22
+ return { line: before.length, column: before[before.length - 1].length + 1 };
23
+ }
24
+
25
+ /**
26
+ * Parse error without the file content: newer V8 messages quote the input
27
+ * (`..., "<content>" is not valid JSON`), so that part is dropped and the
28
+ * position, when reported, becomes a line and column.
29
+ */
30
+ function describeError(err, text) {
31
+ let reason = String(err && err.message ? err.message : err)
32
+ .replace(/,\s*"[\s\S]*" is not valid JSON$/, '')
33
+ .replace(/\s*\(line \d+ column \d+\)$/, '');
34
+ const position = /\s*(?:in JSON\s*)?at position (\d+)/.exec(reason);
35
+ let where = '';
36
+ if (position) {
37
+ reason = reason.slice(0, position.index);
38
+ const { line, column } = lineColumn(text, Number(position[1]));
39
+ where = ` at line ${line}, column ${column}`;
40
+ }
41
+ reason = reason.trim().slice(0, MAX_REASON) || 'Invalid JSON';
42
+ return `Invalid JSON${where}: ${reason}`;
43
+ }
44
+
45
+ const check = async function checkDataFiles(targetDir) {
46
+ const dir = targetDir || process.cwd();
47
+ const { mode, files } = changedFiles(dir, { extensions: ['.json'] });
48
+
49
+ if (files.length === 0) {
50
+ return {
51
+ exitCode: 0, skipped: true, name: NAME,
52
+ data: { violations: [], message: 'No changed .json files' },
53
+ };
54
+ }
55
+
56
+ const violations = [];
57
+ for (const file of files) {
58
+ const text = readFile(dir, file, mode);
59
+ if (text === null) continue;
60
+ const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
61
+ try {
62
+ JSON.parse(body);
63
+ } catch (err) {
64
+ violations.push({ file, message: describeError(err, body) });
65
+ }
66
+ }
67
+
68
+ return {
69
+ exitCode: violations.length > 0 ? 1 : 0,
70
+ skipped: false,
71
+ name: NAME,
72
+ data: {
73
+ violations,
74
+ message: violations.length > 0
75
+ ? `${violations.length} JSON file(s) do not parse: ${violations.map(v => v.file).join(', ')}`
76
+ : `${files.length} changed JSON file(s) parse`,
77
+ },
78
+ };
79
+ };
80
+
81
+ module.exports = check;
82
+
83
+ if (require.main === module) {
84
+ check(process.argv[2]).then(result => {
85
+ console.log(JSON.stringify(result, null, 2));
86
+ process.exit(result.exitCode);
87
+ });
88
+ }