@softspark/ai-toolkit 4.20.0 → 4.22.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.
@@ -0,0 +1,493 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # Copyright 2024-2026 Lukasz Krzemien (biuro@softspark.eu)
4
+ # Source: https://github.com/softspark/ai-toolkit
5
+
6
+ """Split gate for skill refactors.
7
+
8
+ Proves that moving content out of a SKILL.md body into reference/ lost nothing.
9
+ Run it after every body -> reference/ split, before the change is considered done.
10
+
11
+ Four gates:
12
+
13
+ A code preservation Every fenced code-block line from the pre-split body
14
+ still exists in the body + reference/ union. Commands,
15
+ snippets and templates are the risk surface — losing one
16
+ silently changes what the skill tells the model to run.
17
+ B content trace Every non-blank line removed from the body reappears in
18
+ reference/. Warn by default (prose gets reworded into
19
+ pointers during a legitimate split), error under --strict.
20
+ C always-loaded Sections that must load on every run stayed in the body:
21
+ sections Rules, Gotchas, When NOT to Use. Only sections that
22
+ existed before the split are required after it.
23
+ D routing stability Frontmatter description is byte-identical. The
24
+ description decides which prompts select the skill, so
25
+ changing it during a refactor is a behavioural change
26
+ wearing a refactor's clothes.
27
+ E link integrity Every relative link in the body and in reference/ resolves
28
+ from the file that holds it. A split invalidates every
29
+ `(reference/x.md)` path that moves down a directory, and
30
+ validate.py only checks links in SKILL.md — so a broken
31
+ pointer inside reference/ ships silently.
32
+
33
+ Stdlib-only. Human-readable text to stdout; --json for machine use.
34
+
35
+ Usage:
36
+ python3 scripts/check_split.py <skill> --before <path-to-pre-split-SKILL.md>
37
+ python3 scripts/check_split.py <skill> --base-ref HEAD
38
+ python3 scripts/check_split.py <skill> --base-ref HEAD --strict
39
+ python3 scripts/check_split.py <skill> --base-ref HEAD --json
40
+ python3 scripts/check_split.py <skill> --base-ref HEAD --toolkit-dir /path
41
+
42
+ Exit codes:
43
+ 0 all gates passed
44
+ 1 a gate failed
45
+ 2 usage error (unknown skill, missing/duplicate source, unreadable git ref)
46
+ """
47
+ from __future__ import annotations
48
+
49
+ import json
50
+ import re
51
+ import subprocess
52
+ import sys
53
+ from pathlib import Path
54
+
55
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
56
+ from _common import toolkit_dir as default_toolkit_dir
57
+
58
+ # Sections that carry prescriptive process and environment-specific traps.
59
+ # They must load on every run, so they never move down into reference/.
60
+ ALWAYS_LOADED_SECTIONS = ("Rules", "Gotchas", "When NOT to Use")
61
+
62
+ HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s+(.*?)\s*#*\s*$")
63
+ FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})")
64
+ LINK_RE = re.compile(r"\]\(([^)\s]+)\)")
65
+ # Inline code spans are literal text. A regex such as
66
+ # `type=["']password["'](?!.*autocomplete)` reads as a link to the naive matcher.
67
+ INLINE_CODE_RE = re.compile(r"`+[^`]*`+")
68
+
69
+ # Link targets that are not files on disk and must not be resolved.
70
+ NON_FILE_PREFIXES = ("#", "/", "mailto:", "tel:", "$")
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Parsing helpers
75
+ # ---------------------------------------------------------------------------
76
+
77
+ def split_frontmatter(text: str) -> tuple[str, str]:
78
+ """Return (frontmatter_block, body). Frontmatter is '' when absent."""
79
+ lines = text.splitlines()
80
+ if not lines or lines[0].strip() != "---":
81
+ return "", text
82
+ for idx in range(1, len(lines)):
83
+ if lines[idx].strip() == "---":
84
+ return "\n".join(lines[1:idx]), "\n".join(lines[idx + 1:])
85
+ # Unterminated frontmatter — treat the whole file as body rather than
86
+ # silently swallowing it.
87
+ return "", text
88
+
89
+
90
+ def description_of(frontmatter: str) -> str:
91
+ """Extract the `description:` value, unquoted, from a frontmatter block."""
92
+ for line in frontmatter.splitlines():
93
+ if not line.startswith("description:"):
94
+ continue
95
+ value = line[len("description:"):].strip()
96
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ("\"", "'"):
97
+ value = value[1:-1]
98
+ return value
99
+ return ""
100
+
101
+
102
+ def fenced_code_lines(body: str) -> list[str]:
103
+ """Return the lines strictly inside fenced code blocks, stripped, non-empty.
104
+
105
+ Nested fences are not a thing in CommonMark: the first fence opens, a fence
106
+ of the same character closes. Tracking the opening character avoids treating
107
+ a ``` inside a ~~~ block as a terminator.
108
+ """
109
+ out: list[str] = []
110
+ fence_char = ""
111
+ for line in body.splitlines():
112
+ match = FENCE_RE.match(line)
113
+ if match:
114
+ marker = match.group(1)[0]
115
+ if not fence_char:
116
+ fence_char = marker
117
+ continue
118
+ if marker == fence_char:
119
+ fence_char = ""
120
+ continue
121
+ if fence_char:
122
+ stripped = line.strip()
123
+ if stripped:
124
+ out.append(stripped)
125
+ return out
126
+
127
+
128
+ def normalize(line: str) -> str:
129
+ """Normalize a prose line for comparison.
130
+
131
+ Headings lose their leading hashes so that a section promoted from `###` in
132
+ the body to `##` in a reference file still matches. Internal whitespace runs
133
+ collapse so that reflowed markdown does not read as lost content.
134
+ """
135
+ stripped = line.strip()
136
+ heading = HEADING_RE.match(stripped)
137
+ if heading:
138
+ stripped = heading.group(1).strip()
139
+ return " ".join(stripped.split())
140
+
141
+
142
+ def nonblank_normalized(text: str) -> list[str]:
143
+ return [n for n in (normalize(line) for line in text.splitlines()) if n]
144
+
145
+
146
+ def section_headings(body: str) -> set[str]:
147
+ """Return the set of heading titles present in a body."""
148
+ out: set[str] = set()
149
+ fence_char = ""
150
+ for line in body.splitlines():
151
+ match = FENCE_RE.match(line)
152
+ if match:
153
+ marker = match.group(1)[0]
154
+ if not fence_char:
155
+ fence_char = marker
156
+ elif marker == fence_char:
157
+ fence_char = ""
158
+ continue
159
+ if fence_char:
160
+ continue
161
+ heading = HEADING_RE.match(line)
162
+ if heading:
163
+ out.add(heading.group(1).strip())
164
+ return out
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Gates
169
+ # ---------------------------------------------------------------------------
170
+
171
+ def gate_a_code(before_body: str, union_lines: set[str]) -> dict:
172
+ """Every fenced code line from the pre-split body survives somewhere."""
173
+ missing = [line for line in fenced_code_lines(before_body) if line not in union_lines]
174
+ # Preserve order, drop repeats — a line repeated in three snippets is one
175
+ # loss to fix, not three.
176
+ seen: set[str] = set()
177
+ unique = [line for line in missing if not (line in seen or seen.add(line))]
178
+ return {
179
+ "status": "pass" if not unique else "fail",
180
+ "code_lines_lost": len(unique),
181
+ "missing": unique,
182
+ }
183
+
184
+
185
+ def gate_b_trace(before_body: str, after_body: str, reference_text: str,
186
+ strict: bool) -> dict:
187
+ """Every non-blank line removed from the body reappears in reference/."""
188
+ after_set = set(nonblank_normalized(after_body))
189
+ reference_set = set(nonblank_normalized(reference_text))
190
+ reference_blob = "\n".join(reference_set)
191
+
192
+ unmatched: list[str] = []
193
+ seen: set[str] = set()
194
+ for line in nonblank_normalized(before_body):
195
+ if line in after_set or line in seen:
196
+ continue
197
+ seen.add(line)
198
+ if line in reference_set:
199
+ continue
200
+ # Substring match catches a pointer that reworded the original line
201
+ # while keeping its content, e.g. a heading folded into a sentence.
202
+ if line in reference_blob:
203
+ continue
204
+ unmatched.append(line)
205
+
206
+ if not unmatched:
207
+ status = "pass"
208
+ else:
209
+ status = "fail" if strict else "warn"
210
+ return {
211
+ "status": status,
212
+ "unmatched_count": len(unmatched),
213
+ "unmatched": unmatched,
214
+ }
215
+
216
+
217
+ def gate_c_sections(before_body: str, after_body: str) -> dict:
218
+ """Sections that must load on every run did not move into reference/."""
219
+ before_headings = section_headings(before_body)
220
+ after_headings = section_headings(after_body)
221
+ missing = [
222
+ name for name in ALWAYS_LOADED_SECTIONS
223
+ if name in before_headings and name not in after_headings
224
+ ]
225
+ return {
226
+ "status": "pass" if not missing else "fail",
227
+ "missing": missing,
228
+ "checked": [name for name in ALWAYS_LOADED_SECTIONS if name in before_headings],
229
+ }
230
+
231
+
232
+ def markdown_links(text: str) -> list[str]:
233
+ """Markdown link targets that sit outside fenced code blocks.
234
+
235
+ Links inside a fence are examples, not pointers — resolving them would flag
236
+ every sample path a skill documents.
237
+ """
238
+ out: list[str] = []
239
+ fence = ""
240
+ for line in text.splitlines():
241
+ match = FENCE_RE.match(line)
242
+ if match:
243
+ marker = match.group(1)[0]
244
+ fence = "" if fence == marker else (fence or marker)
245
+ continue
246
+ if fence:
247
+ continue
248
+ out.extend(LINK_RE.findall(INLINE_CODE_RE.sub(" ", line)))
249
+ return out
250
+
251
+
252
+ def gate_e_links(skill_dir: Path, after_body: str) -> dict:
253
+ """Every relative link resolves from the file that holds it.
254
+
255
+ Checked across the body and every reference/*.md, because moving a section
256
+ down one directory silently invalidates the paths it carried, and
257
+ validate.py's own link check never looks inside reference/.
258
+ """
259
+ sources: list[tuple[Path, str]] = [(skill_dir / "SKILL.md", after_body)]
260
+ ref_dir = skill_dir / "reference"
261
+ if ref_dir.is_dir():
262
+ for path in sorted(ref_dir.rglob("*.md")):
263
+ sources.append((path, path.read_text(encoding="utf-8")))
264
+
265
+ broken: list[str] = []
266
+ checked = 0
267
+ for path, text in sources:
268
+ for target in markdown_links(text):
269
+ if "://" in target or target.startswith(NON_FILE_PREFIXES):
270
+ continue
271
+ relative = target.split("#", 1)[0]
272
+ if not relative:
273
+ continue
274
+ checked += 1
275
+ if not (path.parent / relative).exists():
276
+ broken.append(f"{path.name} -> {target}")
277
+
278
+ return {
279
+ "status": "pass" if not broken else "fail",
280
+ "links_checked": checked,
281
+ "broken_count": len(broken),
282
+ "broken": broken,
283
+ }
284
+
285
+
286
+ def gate_d_description(before_fm: str, after_fm: str) -> dict:
287
+ """Routing must not move during a refactor."""
288
+ before = description_of(before_fm)
289
+ after = description_of(after_fm)
290
+ return {
291
+ "status": "pass" if before == after else "fail",
292
+ "before": before,
293
+ "after": after,
294
+ }
295
+
296
+
297
+ # ---------------------------------------------------------------------------
298
+ # Sources
299
+ # ---------------------------------------------------------------------------
300
+
301
+ def read_before_from_git(tk_dir: Path, skill: str, ref: str) -> str:
302
+ """Read the pre-split SKILL.md out of a git ref."""
303
+ rel = f"app/skills/{skill}/SKILL.md"
304
+ try:
305
+ result = subprocess.run(
306
+ ["git", "-C", str(tk_dir), "show", f"{ref}:{rel}"],
307
+ capture_output=True,
308
+ text=True,
309
+ check=False,
310
+ )
311
+ except (OSError, ValueError) as exc: # git missing, bad argument shape
312
+ fail_usage(f"cannot run git: {exc}")
313
+ if result.returncode != 0:
314
+ fail_usage(
315
+ f"cannot read {rel} at ref '{ref}': "
316
+ f"{result.stderr.strip() or 'git exited ' + str(result.returncode)}"
317
+ )
318
+ return result.stdout
319
+
320
+
321
+ def read_reference_text(skill_dir: Path) -> tuple[str, list[str]]:
322
+ """Concatenate every reference/*.md file. Returns (text, filenames)."""
323
+ ref_dir = skill_dir / "reference"
324
+ if not ref_dir.is_dir():
325
+ return "", []
326
+ chunks: list[str] = []
327
+ names: list[str] = []
328
+ for path in sorted(ref_dir.rglob("*.md")):
329
+ chunks.append(path.read_text(encoding="utf-8"))
330
+ names.append(str(path.relative_to(skill_dir)))
331
+ return "\n".join(chunks), names
332
+
333
+
334
+ # ---------------------------------------------------------------------------
335
+ # CLI
336
+ # ---------------------------------------------------------------------------
337
+
338
+ def fail_usage(message: str) -> None:
339
+ print(f"ERROR: {message}", file=sys.stderr)
340
+ sys.exit(2)
341
+
342
+
343
+ def parse_args(argv: list[str]) -> dict:
344
+ opts = {
345
+ "skill": "",
346
+ "before": "",
347
+ "base_ref": "",
348
+ "toolkit_dir": str(default_toolkit_dir),
349
+ "json": False,
350
+ "strict": False,
351
+ }
352
+ idx = 0
353
+ while idx < len(argv):
354
+ arg = argv[idx]
355
+ if arg == "--json":
356
+ opts["json"] = True
357
+ elif arg == "--strict":
358
+ opts["strict"] = True
359
+ elif arg in ("-h", "--help"):
360
+ print(__doc__)
361
+ sys.exit(0)
362
+ elif arg in ("--before", "--base-ref", "--toolkit-dir"):
363
+ idx += 1
364
+ if idx >= len(argv):
365
+ fail_usage(f"{arg} requires a value")
366
+ key = arg.lstrip("-").replace("-", "_")
367
+ opts[key] = argv[idx]
368
+ elif arg.startswith("-"):
369
+ fail_usage(f"unknown option: {arg}")
370
+ elif not opts["skill"]:
371
+ opts["skill"] = arg
372
+ else:
373
+ fail_usage(f"unexpected argument: {arg}")
374
+ idx += 1
375
+ return opts
376
+
377
+
378
+ def report_text(result: dict) -> None:
379
+ gates = result["gates"]
380
+ print(f"## Split gate — {result['skill']}")
381
+ print()
382
+
383
+ code = gates["A_code_preservation"]
384
+ print(f" code lines lost: {code['code_lines_lost']}")
385
+ for line in code["missing"][:20]:
386
+ print(f" LOST: {line}")
387
+ if len(code["missing"]) > 20:
388
+ print(f" ... and {len(code['missing']) - 20} more")
389
+
390
+ trace = gates["B_content_trace"]
391
+ label = "ERROR" if trace["status"] == "fail" else "WARN"
392
+ if trace["unmatched_count"]:
393
+ print(f" {label}: {trace['unmatched_count']} removed lines not found in reference/")
394
+ for line in trace["unmatched"][:20]:
395
+ print(f" UNTRACED: {line}")
396
+ if trace["unmatched_count"] > 20:
397
+ print(f" ... and {trace['unmatched_count'] - 20} more")
398
+ else:
399
+ print(" OK: every removed line traced to reference/")
400
+
401
+ sections = gates["C_always_loaded_sections"]
402
+ if sections["missing"]:
403
+ print(f" ERROR: always-loaded sections left the body: {', '.join(sections['missing'])}")
404
+ else:
405
+ checked = ", ".join(sections["checked"]) or "none present"
406
+ print(f" OK: always-loaded sections intact ({checked})")
407
+
408
+ desc = gates["D_description_stable"]
409
+ if desc["status"] == "fail":
410
+ print(" ERROR: frontmatter description changed — routing moved")
411
+ print(f" before: {desc['before']}")
412
+ print(f" after: {desc['after']}")
413
+ else:
414
+ print(" OK: description unchanged")
415
+
416
+ links = gates["E_link_integrity"]
417
+ if links["broken"]:
418
+ print(f" ERROR: {links['broken_count']} relative links do not resolve")
419
+ for entry in links["broken"][:20]:
420
+ print(f" BROKEN: {entry}")
421
+ if links["broken_count"] > 20:
422
+ print(f" ... and {links['broken_count'] - 20} more")
423
+ else:
424
+ print(f" OK: {links['links_checked']} relative links resolve")
425
+
426
+ print()
427
+ print(f" body: {result['body_before_bytes']} -> {result['body_after_bytes']} bytes")
428
+ print(f" reference files: {len(result['reference_files'])}")
429
+ print()
430
+ print("SPLIT GATE PASSED" if result["ok"] else "SPLIT GATE FAILED")
431
+
432
+
433
+ def main(argv: list[str]) -> int:
434
+ opts = parse_args(argv)
435
+
436
+ if not opts["skill"]:
437
+ fail_usage("missing skill name")
438
+ if bool(opts["before"]) == bool(opts["base_ref"]):
439
+ fail_usage("pass exactly one of --before <path> or --base-ref <ref>")
440
+
441
+ tk_dir = Path(opts["toolkit_dir"]).resolve()
442
+ skill_dir = tk_dir / "app" / "skills" / opts["skill"]
443
+ skill_file = skill_dir / "SKILL.md"
444
+ if not skill_file.is_file():
445
+ fail_usage(f"no SKILL.md at {skill_file}")
446
+
447
+ if opts["before"]:
448
+ before_path = Path(opts["before"])
449
+ if not before_path.is_file():
450
+ fail_usage(f"--before file not found: {before_path}")
451
+ before_text = before_path.read_text(encoding="utf-8")
452
+ else:
453
+ before_text = read_before_from_git(tk_dir, opts["skill"], opts["base_ref"])
454
+
455
+ after_text = skill_file.read_text(encoding="utf-8")
456
+ before_fm, before_body = split_frontmatter(before_text)
457
+ after_fm, after_body = split_frontmatter(after_text)
458
+ reference_text, reference_files = read_reference_text(skill_dir)
459
+
460
+ union_lines = {
461
+ line.strip()
462
+ for line in (after_body + "\n" + reference_text).splitlines()
463
+ if line.strip()
464
+ }
465
+
466
+ gates = {
467
+ "A_code_preservation": gate_a_code(before_body, union_lines),
468
+ "B_content_trace": gate_b_trace(before_body, after_body, reference_text, opts["strict"]),
469
+ "C_always_loaded_sections": gate_c_sections(before_body, after_body),
470
+ "D_description_stable": gate_d_description(before_fm, after_fm),
471
+ "E_link_integrity": gate_e_links(skill_dir, after_body),
472
+ }
473
+ ok = all(gate["status"] != "fail" for gate in gates.values())
474
+
475
+ result = {
476
+ "skill": opts["skill"],
477
+ "strict": opts["strict"],
478
+ "gates": gates,
479
+ "body_before_bytes": len(before_body.encode("utf-8")),
480
+ "body_after_bytes": len(after_body.encode("utf-8")),
481
+ "reference_files": reference_files,
482
+ "ok": ok,
483
+ }
484
+
485
+ if opts["json"]:
486
+ print(json.dumps(result, indent=2, ensure_ascii=False))
487
+ else:
488
+ report_text(result)
489
+ return 0 if ok else 1
490
+
491
+
492
+ if __name__ == "__main__":
493
+ sys.exit(main(sys.argv[1:]))