opencode-skills-collection 3.1.10 → 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.
- package/bundled-skills/.antigravity-install-manifest.json +8 -1
- package/bundled-skills/browser-testing-with-devtools/SKILL.md +334 -0
- package/bundled-skills/docs/integrations/jetski-cortex.md +3 -3
- package/bundled-skills/docs/integrations/jetski-gemini-loader/README.md +1 -1
- package/bundled-skills/docs/maintainers/repo-growth-seo.md +3 -3
- package/bundled-skills/docs/maintainers/skills-update-guide.md +1 -1
- package/bundled-skills/docs/sources/sources.md +4 -0
- package/bundled-skills/docs/users/bundles.md +1 -1
- package/bundled-skills/docs/users/claude-code-skills.md +1 -1
- package/bundled-skills/docs/users/gemini-cli-skills.md +1 -1
- package/bundled-skills/docs/users/getting-started.md +1 -1
- package/bundled-skills/docs/users/kiro-integration.md +1 -1
- package/bundled-skills/docs/users/usage.md +4 -4
- package/bundled-skills/docs/users/visual-guide.md +4 -4
- package/bundled-skills/drizzle-migration-conflict/SKILL.md +179 -0
- package/bundled-skills/drizzle-migration-conflict/references/ci-policy.md +87 -0
- package/bundled-skills/drizzle-migration-conflict/references/conflict-resolution.md +163 -0
- package/bundled-skills/drizzle-migration-conflict/references/report-template.md +69 -0
- package/bundled-skills/drizzle-migration-conflict/references/sources.md +51 -0
- package/bundled-skills/drizzle-migration-conflict/scripts/check_drizzle_migrations.py +721 -0
- package/bundled-skills/frontend-lighthouse/SKILL.md +348 -0
- package/bundled-skills/pre-release-review/SKILL.md +198 -0
- package/bundled-skills/pre-release-review/references/checklist.md +104 -0
- package/bundled-skills/pre-release-review/references/report-template.md +91 -0
- package/bundled-skills/re-create/SKILL.md +251 -0
- package/bundled-skills/weaviate/SKILL.md +132 -0
- package/bundled-skills/weaviate/references/ask.md +36 -0
- package/bundled-skills/weaviate/references/create_collection.md +152 -0
- package/bundled-skills/weaviate/references/environment_requirements.md +34 -0
- package/bundled-skills/weaviate/references/example_data.md +24 -0
- package/bundled-skills/weaviate/references/explore_collection.md +50 -0
- package/bundled-skills/weaviate/references/fetch_filter.md +88 -0
- package/bundled-skills/weaviate/references/get_collection.md +32 -0
- package/bundled-skills/weaviate/references/hybrid_search.md +47 -0
- package/bundled-skills/weaviate/references/import_data.md +160 -0
- package/bundled-skills/weaviate/references/keyword_search.md +38 -0
- package/bundled-skills/weaviate/references/list_collections.md +31 -0
- package/bundled-skills/weaviate/references/query_search.md +38 -0
- package/bundled-skills/weaviate/references/semantic_search.md +46 -0
- package/bundled-skills/weaviate/scripts/ask.py +106 -0
- package/bundled-skills/weaviate/scripts/create_collection.py +359 -0
- package/bundled-skills/weaviate/scripts/example_data.py +945 -0
- package/bundled-skills/weaviate/scripts/explore_collection.py +295 -0
- package/bundled-skills/weaviate/scripts/fetch_filter.py +261 -0
- package/bundled-skills/weaviate/scripts/get_collection.py +122 -0
- package/bundled-skills/weaviate/scripts/hybrid_search.py +157 -0
- package/bundled-skills/weaviate/scripts/import.py +701 -0
- package/bundled-skills/weaviate/scripts/keyword_search.py +142 -0
- package/bundled-skills/weaviate/scripts/list_collections.py +77 -0
- package/bundled-skills/weaviate/scripts/query_search.py +135 -0
- package/bundled-skills/weaviate/scripts/semantic_search.py +139 -0
- package/bundled-skills/weaviate/scripts/weaviate_conn.py +231 -0
- package/bundled-skills/weaviate-cookbooks/SKILL.md +67 -0
- package/bundled-skills/weaviate-cookbooks/references/advanced_rag.md +274 -0
- package/bundled-skills/weaviate-cookbooks/references/agentic_rag.md +360 -0
- package/bundled-skills/weaviate-cookbooks/references/async_client.md +428 -0
- package/bundled-skills/weaviate-cookbooks/references/basic_agent.md +270 -0
- package/bundled-skills/weaviate-cookbooks/references/basic_rag.md +219 -0
- package/bundled-skills/weaviate-cookbooks/references/data_explorer.md +336 -0
- package/bundled-skills/weaviate-cookbooks/references/environment_requirements.md +78 -0
- package/bundled-skills/weaviate-cookbooks/references/frontend_interface.md +104 -0
- package/bundled-skills/weaviate-cookbooks/references/pdf_multimodal_rag.md +635 -0
- package/bundled-skills/weaviate-cookbooks/references/project_setup.md +75 -0
- package/bundled-skills/weaviate-cookbooks/references/query_agent_chatbot.md +163 -0
- package/package.json +1 -1
- 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())
|