opencode-skills-collection 3.1.9 → 3.1.11

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 (66) hide show
  1. package/bundled-skills/.antigravity-install-manifest.json +8 -1
  2. package/bundled-skills/browser-testing-with-devtools/SKILL.md +334 -0
  3. package/bundled-skills/docs/integrations/jetski-cortex.md +3 -3
  4. package/bundled-skills/docs/integrations/jetski-gemini-loader/README.md +1 -1
  5. package/bundled-skills/docs/maintainers/repo-growth-seo.md +3 -3
  6. package/bundled-skills/docs/maintainers/skills-update-guide.md +1 -1
  7. package/bundled-skills/docs/sources/sources.md +4 -0
  8. package/bundled-skills/docs/users/bundles.md +1 -1
  9. package/bundled-skills/docs/users/claude-code-skills.md +1 -1
  10. package/bundled-skills/docs/users/gemini-cli-skills.md +1 -1
  11. package/bundled-skills/docs/users/getting-started.md +1 -1
  12. package/bundled-skills/docs/users/kiro-integration.md +1 -1
  13. package/bundled-skills/docs/users/usage.md +4 -4
  14. package/bundled-skills/docs/users/visual-guide.md +4 -4
  15. package/bundled-skills/drizzle-migration-conflict/SKILL.md +179 -0
  16. package/bundled-skills/drizzle-migration-conflict/references/ci-policy.md +87 -0
  17. package/bundled-skills/drizzle-migration-conflict/references/conflict-resolution.md +163 -0
  18. package/bundled-skills/drizzle-migration-conflict/references/report-template.md +69 -0
  19. package/bundled-skills/drizzle-migration-conflict/references/sources.md +51 -0
  20. package/bundled-skills/drizzle-migration-conflict/scripts/check_drizzle_migrations.py +721 -0
  21. package/bundled-skills/frontend-lighthouse/SKILL.md +348 -0
  22. package/bundled-skills/pre-release-review/SKILL.md +198 -0
  23. package/bundled-skills/pre-release-review/references/checklist.md +104 -0
  24. package/bundled-skills/pre-release-review/references/report-template.md +91 -0
  25. package/bundled-skills/re-create/SKILL.md +251 -0
  26. package/bundled-skills/weaviate/SKILL.md +132 -0
  27. package/bundled-skills/weaviate/references/ask.md +36 -0
  28. package/bundled-skills/weaviate/references/create_collection.md +152 -0
  29. package/bundled-skills/weaviate/references/environment_requirements.md +34 -0
  30. package/bundled-skills/weaviate/references/example_data.md +24 -0
  31. package/bundled-skills/weaviate/references/explore_collection.md +50 -0
  32. package/bundled-skills/weaviate/references/fetch_filter.md +88 -0
  33. package/bundled-skills/weaviate/references/get_collection.md +32 -0
  34. package/bundled-skills/weaviate/references/hybrid_search.md +47 -0
  35. package/bundled-skills/weaviate/references/import_data.md +160 -0
  36. package/bundled-skills/weaviate/references/keyword_search.md +38 -0
  37. package/bundled-skills/weaviate/references/list_collections.md +31 -0
  38. package/bundled-skills/weaviate/references/query_search.md +38 -0
  39. package/bundled-skills/weaviate/references/semantic_search.md +46 -0
  40. package/bundled-skills/weaviate/scripts/ask.py +106 -0
  41. package/bundled-skills/weaviate/scripts/create_collection.py +359 -0
  42. package/bundled-skills/weaviate/scripts/example_data.py +945 -0
  43. package/bundled-skills/weaviate/scripts/explore_collection.py +295 -0
  44. package/bundled-skills/weaviate/scripts/fetch_filter.py +261 -0
  45. package/bundled-skills/weaviate/scripts/get_collection.py +122 -0
  46. package/bundled-skills/weaviate/scripts/hybrid_search.py +157 -0
  47. package/bundled-skills/weaviate/scripts/import.py +701 -0
  48. package/bundled-skills/weaviate/scripts/keyword_search.py +142 -0
  49. package/bundled-skills/weaviate/scripts/list_collections.py +77 -0
  50. package/bundled-skills/weaviate/scripts/query_search.py +135 -0
  51. package/bundled-skills/weaviate/scripts/semantic_search.py +139 -0
  52. package/bundled-skills/weaviate/scripts/weaviate_conn.py +231 -0
  53. package/bundled-skills/weaviate-cookbooks/SKILL.md +67 -0
  54. package/bundled-skills/weaviate-cookbooks/references/advanced_rag.md +274 -0
  55. package/bundled-skills/weaviate-cookbooks/references/agentic_rag.md +360 -0
  56. package/bundled-skills/weaviate-cookbooks/references/async_client.md +428 -0
  57. package/bundled-skills/weaviate-cookbooks/references/basic_agent.md +270 -0
  58. package/bundled-skills/weaviate-cookbooks/references/basic_rag.md +219 -0
  59. package/bundled-skills/weaviate-cookbooks/references/data_explorer.md +336 -0
  60. package/bundled-skills/weaviate-cookbooks/references/environment_requirements.md +78 -0
  61. package/bundled-skills/weaviate-cookbooks/references/frontend_interface.md +104 -0
  62. package/bundled-skills/weaviate-cookbooks/references/pdf_multimodal_rag.md +635 -0
  63. package/bundled-skills/weaviate-cookbooks/references/project_setup.md +75 -0
  64. package/bundled-skills/weaviate-cookbooks/references/query_agent_chatbot.md +163 -0
  65. package/package.json +1 -1
  66. package/skills_index.json +156 -0
@@ -0,0 +1,721 @@
1
+ #!/usr/bin/env python3
2
+ """Read-only structural checks for Drizzle migration outputs.
3
+
4
+ This helper never connects to a database, never imports project code, and never writes
5
+ files. It only reads migration directories, parses `_journal.json`/snapshot JSON, and
6
+ reports structural inconsistencies.
7
+
8
+ Exit codes:
9
+ 0 All checked migration directories are clean (no errors or warnings).
10
+ 1 At least one error or warning issue was found.
11
+ 2 No migration directories were discovered (pass --config or --migrations-dir).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import re
20
+ import sys
21
+ from dataclasses import asdict, dataclass
22
+ from pathlib import Path
23
+ from typing import Any, Iterable
24
+
25
+ CONFIG_NAME_PATTERN = re.compile(r"^drizzle(?:[.-].+)?\.config\.(?:ts|js|mjs|cjs|mts|cts)$")
26
+ COMMON_DIRS = (
27
+ "drizzle",
28
+ "migrations",
29
+ "src/db/migrations",
30
+ "db/migrations",
31
+ )
32
+ SKIP_DIR_NAMES = {
33
+ ".git",
34
+ ".hg",
35
+ ".svn",
36
+ "node_modules",
37
+ ".next",
38
+ ".nuxt",
39
+ "dist",
40
+ "build",
41
+ "coverage",
42
+ "target",
43
+ "vendor",
44
+ "__pycache__",
45
+ }
46
+ CONFLICT_MARKERS = ("<<<<<<<", "=======", ">>>>>>>")
47
+ TEXT_SUFFIXES = {".sql", ".json", ".ts", ".js", ".mts", ".mjs", ".cts", ".cjs"}
48
+
49
+
50
+ @dataclass
51
+ class Issue:
52
+ severity: str
53
+ code: str
54
+ path: str
55
+ message: str
56
+
57
+
58
+ @dataclass
59
+ class DirectoryReport:
60
+ path: str
61
+ structure: str
62
+ issues: list[Issue]
63
+
64
+
65
+ def parse_args() -> argparse.Namespace:
66
+ parser = argparse.ArgumentParser(
67
+ description="Check Drizzle migration directories for read-only structural conflicts."
68
+ )
69
+ parser.add_argument("--root", default=".", help="Repository root or package root. Default: .")
70
+ parser.add_argument(
71
+ "--config",
72
+ action="append",
73
+ default=[],
74
+ help="Drizzle config file to inspect for an out directory. May be passed more than once.",
75
+ )
76
+ parser.add_argument(
77
+ "--migrations-dir",
78
+ action="append",
79
+ default=[],
80
+ help="Migration output directory. May be passed more than once.",
81
+ )
82
+ parser.add_argument(
83
+ "--allow-outside-root",
84
+ action="store_true",
85
+ help=(
86
+ "Allow explicit config/out or migration directories outside --root. "
87
+ "Only use when the user has named the exact path and you have confirmed it "
88
+ "contains no sensitive content; the script will still skip known vendored "
89
+ "directories but cannot guarantee what lives under an arbitrary root."
90
+ ),
91
+ )
92
+ parser.add_argument("--json", action="store_true", help="Print JSON output.")
93
+ return parser.parse_args()
94
+
95
+
96
+ def strip_json_comments(text: str) -> str:
97
+ text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
98
+ text = re.sub(r"(^|\s)//.*$", r"\1", text, flags=re.M)
99
+ return text
100
+
101
+
102
+ def read_json(path: Path) -> tuple[Any | None, str | None]:
103
+ try:
104
+ return json.loads(strip_json_comments(path.read_text(encoding="utf-8"))), None
105
+ except Exception as exc: # noqa: BLE001 - error text is reported to the caller.
106
+ return None, str(exc)
107
+
108
+
109
+ def path_in_root(path: Path, root: Path) -> bool:
110
+ try:
111
+ path.resolve().relative_to(root.resolve())
112
+ return True
113
+ except ValueError:
114
+ return False
115
+
116
+
117
+ def normalize_dir(root: Path, value: str) -> Path:
118
+ candidate = Path(value.strip())
119
+ if not candidate.is_absolute():
120
+ candidate = root / candidate
121
+ return candidate.resolve()
122
+
123
+
124
+ def relative(path: Path, root: Path) -> str:
125
+ try:
126
+ return str(path.relative_to(root))
127
+ except ValueError:
128
+ return str(path)
129
+
130
+
131
+ def make_issue(severity: str, code: str, path: Path | str, root: Path, message: str) -> Issue:
132
+ if isinstance(path, Path):
133
+ issue_path = relative(path, root)
134
+ else:
135
+ issue_path = path
136
+ return Issue(severity=severity, code=code, path=issue_path, message=message)
137
+
138
+
139
+ def add_issue(issues: list[Issue], severity: str, code: str, path: Path, root: Path, message: str) -> None:
140
+ issues.append(make_issue(severity, code, path, root, message))
141
+
142
+
143
+ def iter_config_files(
144
+ root: Path, explicit_configs: Iterable[str], allow_outside_root: bool
145
+ ) -> tuple[list[Path], list[Issue]]:
146
+ issues: list[Issue] = []
147
+ configs: list[Path] = []
148
+ seen: set[Path] = set()
149
+
150
+ for value in explicit_configs:
151
+ path = normalize_dir(root, value)
152
+ if not allow_outside_root and not path_in_root(path, root):
153
+ issues.append(
154
+ make_issue(
155
+ "error",
156
+ "config-outside-root",
157
+ path,
158
+ root,
159
+ "Config path is outside --root. Pass --allow-outside-root only after verifying it is intended.",
160
+ )
161
+ )
162
+ continue
163
+ if not path.exists():
164
+ issues.append(make_issue("error", "missing-config", path, root, "Config file does not exist."))
165
+ continue
166
+ if path not in seen:
167
+ seen.add(path)
168
+ configs.append(path)
169
+ if explicit_configs:
170
+ return configs, issues
171
+
172
+ for current_root, dirnames, filenames in os.walk(root):
173
+ dirnames[:] = [name for name in dirnames if name not in SKIP_DIR_NAMES]
174
+ base = Path(current_root)
175
+ for filename in filenames:
176
+ if CONFIG_NAME_PATTERN.match(filename):
177
+ path = (base / filename).resolve()
178
+ if path not in seen:
179
+ seen.add(path)
180
+ configs.append(path)
181
+ return configs, issues
182
+
183
+
184
+ def parse_config_out_dirs(root: Path, configs: list[Path], allow_outside_root: bool) -> tuple[list[Path], list[Issue]]:
185
+ dirs: list[Path] = []
186
+ issues: list[Issue] = []
187
+ seen: set[Path] = set()
188
+
189
+ for config in configs:
190
+ try:
191
+ text = config.read_text(encoding="utf-8")
192
+ except UnicodeDecodeError as exc:
193
+ issues.append(make_issue("warning", "unreadable-config", config, root, f"Cannot read config as UTF-8: {exc}"))
194
+ continue
195
+ matches = list(re.finditer(r'''\bout\s*:\s*['"`]([^'"`]+)['"`]''', text))
196
+ if not matches:
197
+ issues.append(
198
+ make_issue(
199
+ "warning",
200
+ "config-out-not-found",
201
+ config,
202
+ root,
203
+ "No literal out directory found in config. If `out` is computed "
204
+ "(e.g. process.env.MIGRATIONS_DIR), pass --migrations-dir explicitly "
205
+ "so the migration directory is not missed.",
206
+ )
207
+ )
208
+ continue
209
+ for match in matches:
210
+ path = normalize_dir(config.parent, match.group(1))
211
+ if not allow_outside_root and not path_in_root(path, root):
212
+ issues.append(
213
+ make_issue(
214
+ "error",
215
+ "migrations-dir-outside-root",
216
+ path,
217
+ root,
218
+ "Config out directory is outside --root; refusing to scan it by default.",
219
+ )
220
+ )
221
+ continue
222
+ if path not in seen:
223
+ seen.add(path)
224
+ dirs.append(path)
225
+ return dirs, issues
226
+
227
+
228
+ def discover_dirs(args: argparse.Namespace, root: Path) -> tuple[list[Path], list[Issue]]:
229
+ issues: list[Issue] = []
230
+ dirs: list[Path] = []
231
+ seen: set[Path] = set()
232
+
233
+ for value in args.migrations_dir:
234
+ path = normalize_dir(root, value)
235
+ if not args.allow_outside_root and not path_in_root(path, root):
236
+ issues.append(
237
+ make_issue(
238
+ "error",
239
+ "migrations-dir-outside-root",
240
+ path,
241
+ root,
242
+ "Migration directory is outside --root; refusing to scan it by default.",
243
+ )
244
+ )
245
+ continue
246
+ if path not in seen:
247
+ seen.add(path)
248
+ dirs.append(path)
249
+
250
+ configs, config_issues = iter_config_files(root, args.config, args.allow_outside_root)
251
+ issues.extend(config_issues)
252
+ if not args.migrations_dir and configs:
253
+ if not args.config and len(configs) > 1:
254
+ issue_paths = ", ".join(relative(config, root) for config in configs)
255
+ issues.append(
256
+ make_issue(
257
+ "error",
258
+ "multiple-drizzle-configs",
259
+ root,
260
+ root,
261
+ f"Multiple Drizzle config files found ({issue_paths}); pass --config or --migrations-dir explicitly.",
262
+ )
263
+ )
264
+ return [], issues
265
+ config_dirs, out_issues = parse_config_out_dirs(root, configs, args.allow_outside_root)
266
+ issues.extend(out_issues)
267
+ for path in config_dirs:
268
+ if path.exists() and path not in seen:
269
+ seen.add(path)
270
+ dirs.append(path)
271
+
272
+ if dirs or issues:
273
+ return dirs, issues
274
+
275
+ # Only use common fallbacks when there are no Drizzle configs to disambiguate the output.
276
+ for value in COMMON_DIRS:
277
+ path = normalize_dir(root, value)
278
+ if path.exists() and path not in seen:
279
+ seen.add(path)
280
+ dirs.append(path)
281
+
282
+ return dirs, issues
283
+
284
+
285
+ def iter_text_files(directory: Path) -> Iterable[Path]:
286
+ for current_root, dirnames, filenames in os.walk(directory):
287
+ dirnames[:] = [name for name in dirnames if name not in SKIP_DIR_NAMES]
288
+ base = Path(current_root)
289
+ for filename in filenames:
290
+ path = base / filename
291
+ if path.suffix in TEXT_SUFFIXES:
292
+ yield path
293
+
294
+
295
+ def has_conflict_markers(path: Path) -> bool:
296
+ try:
297
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
298
+ if line.startswith(CONFLICT_MARKERS):
299
+ return True
300
+ except OSError:
301
+ return False
302
+ return False
303
+
304
+
305
+ def scan_conflict_markers(directory: Path, root: Path, issues: list[Issue]) -> None:
306
+ for path in iter_text_files(directory):
307
+ if has_conflict_markers(path):
308
+ add_issue(
309
+ issues,
310
+ "error",
311
+ "conflict-marker",
312
+ path,
313
+ root,
314
+ "File contains Git conflict markers.",
315
+ )
316
+
317
+
318
+ def structure_signals(directory: Path) -> tuple[bool, bool, list[Path]]:
319
+ journal = (directory / "meta" / "_journal.json").exists()
320
+ root_sql = any(path.is_file() for path in directory.glob("*.sql"))
321
+ meta_snapshots = any(path.is_file() for path in (directory / "meta").glob("*_snapshot.json"))
322
+ child_dirs = [path for path in directory.iterdir() if path.is_dir() and path.name != "meta"]
323
+ child_migration_files = any(
324
+ (child / "migration.sql").exists() or (child / "snapshot.json").exists() for child in child_dirs
325
+ )
326
+ legacy_signal = journal or root_sql or meta_snapshots
327
+ folder_signal = child_migration_files or (bool(child_dirs) and not legacy_signal)
328
+ return legacy_signal, folder_signal, child_dirs
329
+
330
+
331
+ def detect_structure(directory: Path) -> str:
332
+ if not directory.exists():
333
+ return "missing"
334
+ legacy_signal, folder_signal, _ = structure_signals(directory)
335
+ if legacy_signal and folder_signal:
336
+ return "mixed"
337
+ if legacy_signal:
338
+ return "legacy"
339
+ if folder_signal:
340
+ return "folder-based"
341
+ return "unknown"
342
+
343
+
344
+ def migration_number(stem: str) -> str | None:
345
+ match = re.match(r"^(\d+)(?:[_-].*)?$", stem)
346
+ return match.group(1) if match else None
347
+
348
+
349
+ def snapshot_names_for_entry(entry: dict[str, Any]) -> set[str]:
350
+ names: set[str] = set()
351
+ idx = entry.get("idx")
352
+ tag = entry.get("tag")
353
+ if isinstance(idx, int):
354
+ names.add(f"{idx:04d}_snapshot.json")
355
+ elif isinstance(idx, str) and idx.isdigit():
356
+ names.add(f"{int(idx):04d}_snapshot.json")
357
+ if isinstance(tag, str):
358
+ prefix = tag.split("_", 1)[0].split("-", 1)[0]
359
+ if prefix.isdigit():
360
+ names.add(f"{int(prefix):04d}_snapshot.json")
361
+ names.add(f"{prefix}_snapshot.json")
362
+ return names
363
+
364
+
365
+ def check_duplicate_values(
366
+ entries: list[dict[str, Any]], key: str, journal: Path, root: Path, issues: list[Issue]
367
+ ) -> None:
368
+ values: dict[Any, int] = {}
369
+ for entry in entries:
370
+ value = entry.get(key)
371
+ if value is None:
372
+ continue
373
+ values[value] = values.get(value, 0) + 1
374
+ for value, count in values.items():
375
+ if count > 1:
376
+ add_issue(
377
+ issues,
378
+ "error",
379
+ f"duplicate-{key}",
380
+ journal,
381
+ root,
382
+ f"_journal.json contains duplicate {key} value {value!r} ({count} entries).",
383
+ )
384
+
385
+
386
+ def check_idx_gap(entries: list[dict[str, Any]], journal: Path, root: Path, issues: list[Issue]) -> None:
387
+ """Warn when journal `idx` values are not contiguous starting from 0."""
388
+ idx_values: list[int] = []
389
+ for entry in entries:
390
+ idx = entry.get("idx")
391
+ if isinstance(idx, bool):
392
+ continue
393
+ if isinstance(idx, int):
394
+ idx_values.append(idx)
395
+ elif isinstance(idx, str) and idx.isdigit():
396
+ idx_values.append(int(idx))
397
+ if not idx_values:
398
+ return
399
+ sorted_idx = sorted(set(idx_values))
400
+ expected = list(range(sorted_idx[0], sorted_idx[0] + len(sorted_idx)))
401
+ if sorted_idx != expected or sorted_idx[0] != 0:
402
+ missing = sorted(set(expected) - set(sorted_idx))
403
+ gap_text = f"missing indices {missing}" if missing else f"starts at {sorted_idx[0]} instead of 0"
404
+ add_issue(
405
+ issues,
406
+ "warning",
407
+ "idx-gap",
408
+ journal,
409
+ root,
410
+ f"_journal.json idx sequence is not contiguous from 0 ({gap_text}). This can indicate a "
411
+ "conflict or a manually deleted migration.",
412
+ )
413
+
414
+
415
+ def check_snapshot_chain(
416
+ snapshots: list[tuple[Path, Any]], directory: Path, root: Path, issues: list[Issue]
417
+ ) -> None:
418
+ """Validate that snapshot `prevId` links form a chain over known snapshot `id` values."""
419
+ id_to_paths: dict[str, list[Path]] = {}
420
+ parsed: list[tuple[Path, str | None, str | None]] = []
421
+ for path, data in snapshots:
422
+ if not isinstance(data, dict):
423
+ continue
424
+ snap_id = data.get("id")
425
+ prev_id = data.get("prevId")
426
+ if isinstance(snap_id, str) and snap_id:
427
+ id_to_paths.setdefault(snap_id, []).append(path)
428
+ parsed.append((path, snap_id, prev_id if isinstance(prev_id, str) else None))
429
+ else:
430
+ parsed.append((path, None, prev_id if isinstance(prev_id, str) else None))
431
+
432
+ for snap_id, paths in id_to_paths.items():
433
+ if len(paths) > 1:
434
+ joined = ", ".join(relative(path, root) for path in paths)
435
+ add_issue(
436
+ issues,
437
+ "error",
438
+ "duplicate-snapshot-id",
439
+ paths[0],
440
+ root,
441
+ f"Multiple snapshot files share id {snap_id!r}: {joined}. Drizzle uses snapshot ids to "
442
+ "chain migrations; duplicates usually mean a generated file was copied instead of regenerated.",
443
+ )
444
+
445
+ known_ids = set(id_to_paths.keys())
446
+ for path, snap_id, prev_id in parsed:
447
+ if prev_id is None or prev_id == "":
448
+ continue
449
+ if prev_id not in known_ids:
450
+ add_issue(
451
+ issues,
452
+ "warning",
453
+ "broken-snapshot-chain",
454
+ path,
455
+ root,
456
+ f"Snapshot prevId {prev_id!r} does not match any snapshot id in {relative(directory, root)}. "
457
+ "The migration chain may be broken by a conflict or a partial repair.",
458
+ )
459
+
460
+
461
+ def validate_snapshot_json(path: Path, root: Path, issues: list[Issue]) -> Any | None:
462
+ data, error = read_json(path)
463
+ if error:
464
+ add_issue(issues, "error", "invalid-snapshot-json", path, root, f"Cannot parse snapshot JSON: {error}")
465
+ return None
466
+ return data
467
+
468
+
469
+ def check_legacy(directory: Path, root: Path) -> DirectoryReport:
470
+ issues: list[Issue] = []
471
+ journal = directory / "meta" / "_journal.json"
472
+ data, error = read_json(journal)
473
+ if error:
474
+ add_issue(issues, "error", "invalid-journal", journal, root, f"Cannot parse _journal.json: {error}")
475
+ scan_conflict_markers(directory, root, issues)
476
+ return DirectoryReport(str(relative(directory, root)), "legacy", issues)
477
+
478
+ if not isinstance(data, dict) or not isinstance(data.get("entries"), list):
479
+ add_issue(
480
+ issues,
481
+ "error",
482
+ "invalid-journal-shape",
483
+ journal,
484
+ root,
485
+ "_journal.json must be an object with an entries array.",
486
+ )
487
+ entries: list[dict[str, Any]] = []
488
+ else:
489
+ entries = [entry for entry in data["entries"] if isinstance(entry, dict)]
490
+ check_duplicate_values(entries, "idx", journal, root, issues)
491
+ check_duplicate_values(entries, "tag", journal, root, issues)
492
+ check_idx_gap(entries, journal, root, issues)
493
+
494
+ expected_sql: set[str] = set()
495
+ expected_snapshots: set[str] = set()
496
+ for entry in entries:
497
+ tag = entry.get("tag")
498
+ if isinstance(tag, str) and tag:
499
+ expected_sql.add(f"{tag}.sql")
500
+ sql_path = directory / f"{tag}.sql"
501
+ if not sql_path.exists():
502
+ add_issue(
503
+ issues,
504
+ "error",
505
+ "missing-sql",
506
+ sql_path,
507
+ root,
508
+ f"Journal entry tag {tag!r} does not have a matching SQL file.",
509
+ )
510
+ snapshots = snapshot_names_for_entry(entry)
511
+ expected_snapshots.update(snapshots)
512
+ if snapshots and not any((directory / "meta" / name).exists() for name in snapshots):
513
+ add_issue(
514
+ issues,
515
+ "error",
516
+ "missing-snapshot",
517
+ directory / "meta" / sorted(snapshots)[0],
518
+ root,
519
+ f"Journal entry {entry!r} does not have a matching snapshot file.",
520
+ )
521
+
522
+ sql_files = sorted(path for path in directory.glob("*.sql") if path.is_file())
523
+ by_number: dict[str, list[Path]] = {}
524
+ for path in sql_files:
525
+ number = migration_number(path.stem)
526
+ if number:
527
+ by_number.setdefault(number, []).append(path)
528
+ if path.name not in expected_sql:
529
+ add_issue(
530
+ issues,
531
+ "warning",
532
+ "orphan-sql",
533
+ path,
534
+ root,
535
+ "SQL migration is not referenced by _journal.json.",
536
+ )
537
+
538
+ for number, paths in by_number.items():
539
+ if len(paths) > 1:
540
+ joined = ", ".join(relative(path, root) for path in paths)
541
+ add_issue(
542
+ issues,
543
+ "error",
544
+ "duplicate-migration-number",
545
+ paths[0],
546
+ root,
547
+ f"Multiple SQL migrations share number {number}: {joined}.",
548
+ )
549
+
550
+ snapshot_files = sorted((directory / "meta").glob("*_snapshot.json"))
551
+ parsed_snapshots: list[tuple[Path, Any | None]] = []
552
+ for path in snapshot_files:
553
+ data = validate_snapshot_json(path, root, issues)
554
+ parsed_snapshots.append((path, data))
555
+ if path.name not in expected_snapshots:
556
+ add_issue(
557
+ issues,
558
+ "warning",
559
+ "orphan-snapshot",
560
+ path,
561
+ root,
562
+ "Snapshot file is not referenced by _journal.json.",
563
+ )
564
+
565
+ check_snapshot_chain(parsed_snapshots, directory, root, issues)
566
+
567
+ scan_conflict_markers(directory, root, issues)
568
+ return DirectoryReport(str(relative(directory, root)), "legacy", issues)
569
+
570
+
571
+ def check_folder_based(directory: Path, root: Path) -> DirectoryReport:
572
+ issues: list[Issue] = []
573
+ names: dict[str, list[Path]] = {}
574
+ child_dirs = [path for path in directory.iterdir() if path.is_dir() and path.name != "meta"]
575
+ for child in sorted(child_dirs):
576
+ names.setdefault(child.name.lower(), []).append(child)
577
+ migration_sql = child / "migration.sql"
578
+ snapshot_json = child / "snapshot.json"
579
+ if not migration_sql.exists():
580
+ add_issue(
581
+ issues,
582
+ "error",
583
+ "missing-migration-sql",
584
+ migration_sql,
585
+ root,
586
+ "Folder-based migration is missing migration.sql.",
587
+ )
588
+ if not snapshot_json.exists():
589
+ add_issue(
590
+ issues,
591
+ "error",
592
+ "missing-snapshot-json",
593
+ snapshot_json,
594
+ root,
595
+ "Folder-based migration is missing snapshot.json.",
596
+ )
597
+ else:
598
+ validate_snapshot_json(snapshot_json, root, issues)
599
+
600
+ for lower_name, paths in names.items():
601
+ if len(paths) > 1:
602
+ joined = ", ".join(relative(path, root) for path in paths)
603
+ add_issue(
604
+ issues,
605
+ "error",
606
+ "duplicate-migration-directory",
607
+ paths[0],
608
+ root,
609
+ f"Migration directory name differs only by case for {lower_name!r}: {joined}.",
610
+ )
611
+
612
+ scan_conflict_markers(directory, root, issues)
613
+ return DirectoryReport(str(relative(directory, root)), "folder-based", issues)
614
+
615
+
616
+ def check_mixed(directory: Path, root: Path) -> DirectoryReport:
617
+ issues: list[Issue] = []
618
+ add_issue(
619
+ issues,
620
+ "error",
621
+ "mixed-structure",
622
+ directory,
623
+ root,
624
+ "Legacy journal/root SQL signals and folder-based migration signals coexist; choose the intended migration structure before repair.",
625
+ )
626
+ scan_conflict_markers(directory, root, issues)
627
+ return DirectoryReport(str(relative(directory, root)), "mixed", issues)
628
+
629
+
630
+ def check_directory(directory: Path, root: Path) -> DirectoryReport:
631
+ if not directory.exists():
632
+ return DirectoryReport(
633
+ str(relative(directory, root)),
634
+ "missing",
635
+ [
636
+ Issue(
637
+ severity="error",
638
+ code="missing-migrations-dir",
639
+ path=relative(directory, root),
640
+ message="Migration directory does not exist.",
641
+ )
642
+ ],
643
+ )
644
+
645
+ structure = detect_structure(directory)
646
+ if structure == "mixed":
647
+ return check_mixed(directory, root)
648
+ if structure == "legacy":
649
+ return check_legacy(directory, root)
650
+ if structure == "folder-based":
651
+ return check_folder_based(directory, root)
652
+
653
+ issues: list[Issue] = []
654
+ add_issue(
655
+ issues,
656
+ "warning",
657
+ "unknown-structure",
658
+ directory,
659
+ root,
660
+ "Could not identify a legacy or folder-based Drizzle migration structure; skipping recursive scan.",
661
+ )
662
+ return DirectoryReport(str(relative(directory, root)), "unknown", issues)
663
+
664
+
665
+ def report_as_json(root: Path, reports: list[DirectoryReport]) -> str:
666
+ return json.dumps(
667
+ {
668
+ "root": str(root),
669
+ "checked_dirs": [asdict(report) for report in reports],
670
+ "issue_count": sum(len(report.issues) for report in reports),
671
+ "note": "This helper is structural only and does not replace drizzle-kit check.",
672
+ },
673
+ indent=2,
674
+ sort_keys=True,
675
+ )
676
+
677
+
678
+ def report_as_text(root: Path, reports: list[DirectoryReport]) -> str:
679
+ lines = [f"Drizzle migration check root: {root}"]
680
+ lines.append("Note: this helper is structural only and does not replace drizzle-kit check.")
681
+ if not reports:
682
+ lines.append("No migration directories found. Pass --config or --migrations-dir if detection missed one.")
683
+ return "\n".join(lines)
684
+
685
+ for report in reports:
686
+ lines.append(f"\nDirectory: {report.path}")
687
+ lines.append(f"Structure: {report.structure}")
688
+ if not report.issues:
689
+ lines.append("Issues: none")
690
+ continue
691
+ lines.append("Issues:")
692
+ for issue in report.issues:
693
+ lines.append(f"- [{issue.severity}] {issue.code}: {issue.path} - {issue.message}")
694
+ return "\n".join(lines)
695
+
696
+
697
+ def main() -> int:
698
+ args = parse_args()
699
+ root = Path(args.root).resolve()
700
+ dirs, discovery_issues = discover_dirs(args, root)
701
+ reports: list[DirectoryReport] = []
702
+ if discovery_issues:
703
+ reports.append(DirectoryReport(".", "discovery", discovery_issues))
704
+ reports.extend(check_directory(path, root) for path in dirs)
705
+
706
+ if args.json:
707
+ print(report_as_json(root, reports))
708
+ else:
709
+ print(report_as_text(root, reports))
710
+
711
+ if not reports:
712
+ return 2
713
+ if any(issue.severity == "error" for report in reports for issue in report.issues):
714
+ return 1
715
+ if any(issue.severity == "warning" for report in reports for issue in report.issues):
716
+ return 1
717
+ return 0
718
+
719
+
720
+ if __name__ == "__main__":
721
+ sys.exit(main())