@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.
- package/CHANGELOG.md +107 -0
- package/README.md +32 -22
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/agents/code-reviewer.md +32 -15
- package/app/skills/a11y-validate/SKILL.md +61 -179
- package/app/skills/a11y-validate/reference/scanner-categories.md +174 -0
- package/app/skills/brainstorm/SKILL.md +174 -0
- package/app/skills/ci/SKILL.md +1 -0
- package/app/skills/debug/SKILL.md +2 -1
- package/app/skills/documentation-standards/SKILL.md +25 -5
- package/app/skills/fix/SKILL.md +1 -1
- package/app/skills/hipaa-validate/SKILL.md +25 -221
- package/app/skills/hipaa-validate/reference/scanner-categories.md +224 -0
- package/app/skills/pr/SKILL.md +1 -1
- package/app/skills/review/SKILL.md +49 -4
- package/app/skills/seo-validate/SKILL.md +63 -309
- package/app/skills/seo-validate/reference/scanner-categories.md +304 -0
- package/app/surface.json +296 -0
- package/benchmarks/ecosystem-doctor-snapshot.json +15 -19
- package/kb/procedures/release-preparation-sop.md +44 -2
- package/kb/reference/architecture-overview.md +3 -3
- package/kb/reference/skills-catalog.md +10 -14
- package/llms-full.txt +59 -20
- package/manifest.json +3 -3
- package/package.json +6 -3
- package/scripts/check_split.py +493 -0
- package/scripts/surface_manifest.py +246 -0
- package/scripts/sync_badges.py +133 -0
- package/scripts/validate.py +84 -2
|
@@ -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:]))
|