bmad-module-skill-forge 1.8.0 → 1.9.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/.claude-plugin/marketplace.json +1 -1
- package/docs/_data/pinned.yaml +1 -1
- package/docs/bmad-synergy.md +16 -0
- package/docs/deepwiki.md +89 -0
- package/docs/verifying-a-skill.md +8 -2
- package/docs/workflows.md +17 -11
- package/package.json +2 -2
- package/src/shared/_known-workarounds.yaml +155 -0
- package/src/shared/references/pipeline-contracts.md +11 -2
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +6 -1
- package/src/shared/scripts/skf-detect-docs.py +412 -0
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +12 -1
- package/src/shared/scripts/skf-preapply.py +192 -0
- package/src/shared/scripts/skf-shape-detect.py +563 -0
- package/src/shared/scripts/skf-validate-pins.py +368 -0
- package/src/skf-analyze-source/SKILL.md +26 -20
- package/src/skf-analyze-source/references/init.md +30 -0
- package/src/skf-analyze-source/references/step-auto-scope.md +525 -0
- package/src/skf-analyze-source/references/step-shape-detect.md +79 -0
- package/src/skf-audit-skill/SKILL.md +1 -0
- package/src/skf-audit-skill/assets/drift-report-template.md +6 -0
- package/src/skf-audit-skill/references/report.md +4 -0
- package/src/skf-audit-skill/references/severity-classify.md +1 -1
- package/src/skf-audit-skill/references/step-doc-drift.md +147 -0
- package/src/skf-brief-skill/SKILL.md +7 -3
- package/src/skf-brief-skill/references/gather-intent.md +14 -0
- package/src/skf-brief-skill/references/step-auto-brief.md +171 -0
- package/src/skf-brief-skill/references/step-auto-validate.md +209 -0
- package/src/skf-create-skill/SKILL.md +3 -0
- package/src/skf-create-skill/assets/skill-sections.md +2 -0
- package/src/skf-create-skill/references/compile.md +1 -1
- package/src/skf-create-skill/references/step-auto-shard.md +110 -0
- package/src/skf-create-skill/references/step-doc-rot.md +109 -0
- package/src/skf-create-skill/references/step-doc-sources.md +114 -0
- package/src/skf-forger/SKILL.md +8 -2
- package/src/skf-test-skill/SKILL.md +8 -7
- package/src/skf-test-skill/customize.toml +2 -1
- package/src/skf-test-skill/references/external-validators.md +1 -1
- package/src/skf-test-skill/references/init.md +16 -1
- package/src/skf-test-skill/references/report.md +5 -1
- package/src/skf-test-skill/references/score.md +95 -6
- package/src/skf-test-skill/references/step-hard-gate.md +73 -0
- package/src/skf-test-skill/templates/test-report-template.md +1 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.9"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Detect Docs — discover documentation URLs for a GitHub repository.
|
|
6
|
+
|
|
7
|
+
Standalone doc-detection script that discovers documentation URLs through
|
|
8
|
+
multiple methods. BS, CS, AS, and campaign workflows all locate a repo's
|
|
9
|
+
docs through this shared entry point, eliminating duplicate detection logic.
|
|
10
|
+
|
|
11
|
+
Detection chain (all four methods attempted, results aggregated):
|
|
12
|
+
|
|
13
|
+
1. homepageUrl — GitHub repo metadata homepage field
|
|
14
|
+
2. readme_link — links parsed from the repo README
|
|
15
|
+
3. pages_api — GitHub Pages site URL
|
|
16
|
+
4. docs_folder — markdown files in the repo's docs/ directory
|
|
17
|
+
|
|
18
|
+
CLI:
|
|
19
|
+
uv run python src/shared/scripts/skf-detect-docs.py \\
|
|
20
|
+
--repo-url <url> [--local-path <path>] [--skip-pages-api]
|
|
21
|
+
|
|
22
|
+
Input:
|
|
23
|
+
--repo-url GitHub repository URL (required)
|
|
24
|
+
--local-path local clone path for docs/ folder scan (optional)
|
|
25
|
+
--skip-pages-api skip GitHub Pages API detection (optional flag)
|
|
26
|
+
|
|
27
|
+
Output (JSON array on stdout):
|
|
28
|
+
[
|
|
29
|
+
{
|
|
30
|
+
"url": "https://docs.example.com",
|
|
31
|
+
"detected_via": "homepageUrl",
|
|
32
|
+
"content_hash": "sha256:a1b2c3...",
|
|
33
|
+
"content_type": "api-docs"
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
Exit codes:
|
|
38
|
+
0 found >=1 documentation source
|
|
39
|
+
1 no documentation sources found (empty array)
|
|
40
|
+
2 error (invalid args, gh not found, etc.)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import argparse
|
|
46
|
+
import base64
|
|
47
|
+
import hashlib
|
|
48
|
+
import json
|
|
49
|
+
import re
|
|
50
|
+
import subprocess
|
|
51
|
+
import sys
|
|
52
|
+
import urllib.error
|
|
53
|
+
import urllib.request
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
from typing import Any, Dict, List, Optional
|
|
56
|
+
|
|
57
|
+
_GITHUB_URL_RE = re.compile(
|
|
58
|
+
r"https?://(?:www\.)?github\.com/([^/\s]+)/([^/\s.]+?)(?:\.git)?/?$",
|
|
59
|
+
re.IGNORECASE,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
USER_AGENT = "skf-detect-docs/1.0 (+https://github.com/armelhbobdad/bmad-module-skill-forge)"
|
|
63
|
+
|
|
64
|
+
_FETCH_TIMEOUT = 15
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# URL exclusion patterns (AC #5)
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
_EXCLUSION_PATTERNS = re.compile(
|
|
71
|
+
r"(?:^|/)(?:"
|
|
72
|
+
r"CHANGELOG|CHANGES|HISTORY"
|
|
73
|
+
r"|MIGRATION|MIGRATING|UPGRADE"
|
|
74
|
+
r"|RELEASE|release-notes|releases"
|
|
75
|
+
r"|CONTRIBUTING|CODE_OF_CONDUCT"
|
|
76
|
+
r"|LICENSE|SECURITY"
|
|
77
|
+
r")(?:\.|/|$)",
|
|
78
|
+
re.IGNORECASE,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# README link scanning patterns
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
_MD_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
|
|
86
|
+
_HTML_LINK_RE = re.compile(r'<a\s[^>]*href=["\']([^"\']+)["\']', re.IGNORECASE)
|
|
87
|
+
_BARE_URL_RE = re.compile(r"^(https?://\S+)$", re.MULTILINE)
|
|
88
|
+
|
|
89
|
+
_DOC_DOMAIN_RE = re.compile(
|
|
90
|
+
r"(?:docs\.|\.readthedocs\.|wiki\.|documentation\.)",
|
|
91
|
+
re.IGNORECASE,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
_DOC_PATH_RE = re.compile(
|
|
95
|
+
r"(?:/docs/|/documentation/|/api/|/reference/|/guide/|/wiki/)",
|
|
96
|
+
re.IGNORECASE,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
_DOC_TEXT_RE = re.compile(
|
|
100
|
+
r"(?:documentation|docs|api\s+reference|guide|wiki)",
|
|
101
|
+
re.IGNORECASE,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
_REJECT_URL_RE = re.compile(
|
|
105
|
+
r"(?:"
|
|
106
|
+
r"github\.com/[^/]+/[^/]+/(?:issues|pull|actions)"
|
|
107
|
+
r"|img\.shields\.io"
|
|
108
|
+
r"|badge"
|
|
109
|
+
r"|\.(?:svg|png|gif|jpg|jpeg)(?:\?|$)"
|
|
110
|
+
r"|travis-ci\."
|
|
111
|
+
r"|circleci\."
|
|
112
|
+
r"|npmjs\.com"
|
|
113
|
+
r"|pypi\.org"
|
|
114
|
+
r"|crates\.io"
|
|
115
|
+
r"|twitter\.com|x\.com"
|
|
116
|
+
r"|discord\.(?:gg|com)"
|
|
117
|
+
r"|slack\.com"
|
|
118
|
+
r")",
|
|
119
|
+
re.IGNORECASE,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Content type classification
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
_API_DOCS_RE = re.compile(r"(?:/api/|/reference/|/sdk/|api-docs|api\.)", re.IGNORECASE)
|
|
128
|
+
_GUIDE_RE = re.compile(
|
|
129
|
+
r"(?:/guide/|/tutorial/|/getting-started|/quickstart|/howto)",
|
|
130
|
+
re.IGNORECASE,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _classify_content_type(url: str) -> str:
|
|
135
|
+
if _API_DOCS_RE.search(url):
|
|
136
|
+
return "api-docs"
|
|
137
|
+
if _GUIDE_RE.search(url):
|
|
138
|
+
return "guide"
|
|
139
|
+
return "reference"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# URL helpers
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def _is_excluded(url: str) -> bool:
|
|
147
|
+
return bool(_EXCLUSION_PATTERNS.search(url))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _is_github_self_url(url: str, owner: str, repo: str) -> bool:
|
|
151
|
+
return bool(re.match(
|
|
152
|
+
rf"https?://(?:www\.)?github\.com/{re.escape(owner)}/{re.escape(repo)}/?$",
|
|
153
|
+
url,
|
|
154
|
+
re.IGNORECASE,
|
|
155
|
+
))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _is_doc_url(url: str, link_text: str = "") -> bool:
|
|
159
|
+
if _REJECT_URL_RE.search(url):
|
|
160
|
+
return False
|
|
161
|
+
if _DOC_DOMAIN_RE.search(url):
|
|
162
|
+
return True
|
|
163
|
+
if _DOC_PATH_RE.search(url):
|
|
164
|
+
return True
|
|
165
|
+
if link_text and _DOC_TEXT_RE.search(link_text):
|
|
166
|
+
return True
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# gh CLI helper
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def _run_gh(args: List[str]) -> Optional[str]:
|
|
175
|
+
try:
|
|
176
|
+
result = subprocess.run(
|
|
177
|
+
["gh"] + args,
|
|
178
|
+
capture_output=True,
|
|
179
|
+
text=True,
|
|
180
|
+
)
|
|
181
|
+
if result.returncode == 0:
|
|
182
|
+
return result.stdout.strip()
|
|
183
|
+
return None
|
|
184
|
+
except FileNotFoundError:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _check_gh_available() -> bool:
|
|
189
|
+
try:
|
|
190
|
+
subprocess.run(
|
|
191
|
+
["gh", "--version"],
|
|
192
|
+
capture_output=True,
|
|
193
|
+
text=True,
|
|
194
|
+
)
|
|
195
|
+
return True
|
|
196
|
+
except FileNotFoundError:
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
# Content hashing
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def _fetch_and_hash(url: str) -> Optional[str]:
|
|
205
|
+
if url.startswith("file://"):
|
|
206
|
+
try:
|
|
207
|
+
local_path = url[7:]
|
|
208
|
+
with open(local_path, "rb") as fh:
|
|
209
|
+
content = fh.read()
|
|
210
|
+
return "sha256:" + hashlib.sha256(content).hexdigest()
|
|
211
|
+
except (OSError, IOError):
|
|
212
|
+
return None
|
|
213
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
214
|
+
try:
|
|
215
|
+
with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
|
|
216
|
+
content = resp.read()
|
|
217
|
+
return "sha256:" + hashlib.sha256(content).hexdigest()
|
|
218
|
+
except Exception:
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# Detection method 1 — homepageUrl
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
def _detect_homepage_url(owner: str, repo: str) -> List[Dict[str, Any]]:
|
|
227
|
+
raw = _run_gh(["api", f"repos/{owner}/{repo}", "--jq", ".homepage"])
|
|
228
|
+
if not raw or raw == "null":
|
|
229
|
+
return []
|
|
230
|
+
url = raw.strip()
|
|
231
|
+
if not url:
|
|
232
|
+
return []
|
|
233
|
+
if _is_github_self_url(url, owner, repo):
|
|
234
|
+
return []
|
|
235
|
+
return [{"url": url, "detected_via": "homepageUrl", "content_type": _classify_content_type(url)}]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# Detection method 2 — README link scanning
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def _detect_readme_links(owner: str, repo: str) -> List[Dict[str, Any]]:
|
|
243
|
+
raw = _run_gh(["api", f"repos/{owner}/{repo}/readme"])
|
|
244
|
+
if not raw:
|
|
245
|
+
return []
|
|
246
|
+
try:
|
|
247
|
+
data = json.loads(raw)
|
|
248
|
+
except json.JSONDecodeError:
|
|
249
|
+
return []
|
|
250
|
+
encoded = data.get("content", "")
|
|
251
|
+
if not encoded:
|
|
252
|
+
return []
|
|
253
|
+
try:
|
|
254
|
+
readme_text = base64.b64decode(encoded).decode("utf-8", errors="replace")
|
|
255
|
+
except Exception:
|
|
256
|
+
return []
|
|
257
|
+
|
|
258
|
+
urls_with_text: List[tuple] = []
|
|
259
|
+
for text, url in _MD_LINK_RE.findall(readme_text):
|
|
260
|
+
urls_with_text.append((url.strip(), text.strip()))
|
|
261
|
+
for url in _HTML_LINK_RE.findall(readme_text):
|
|
262
|
+
urls_with_text.append((url.strip(), ""))
|
|
263
|
+
for url in _BARE_URL_RE.findall(readme_text):
|
|
264
|
+
urls_with_text.append((url.strip(), ""))
|
|
265
|
+
|
|
266
|
+
results: List[Dict[str, Any]] = []
|
|
267
|
+
seen: set = set()
|
|
268
|
+
for url, text in urls_with_text:
|
|
269
|
+
if not url.startswith("http"):
|
|
270
|
+
continue
|
|
271
|
+
if url in seen:
|
|
272
|
+
continue
|
|
273
|
+
if _is_doc_url(url, text):
|
|
274
|
+
seen.add(url)
|
|
275
|
+
results.append({
|
|
276
|
+
"url": url,
|
|
277
|
+
"detected_via": "readme_link",
|
|
278
|
+
"content_type": _classify_content_type(url),
|
|
279
|
+
})
|
|
280
|
+
return results
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
# Detection method 3 — Pages API
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def _detect_pages_api(owner: str, repo: str) -> List[Dict[str, Any]]:
|
|
288
|
+
raw = _run_gh(["api", f"repos/{owner}/{repo}/pages", "--jq", ".html_url"])
|
|
289
|
+
if not raw or raw == "null":
|
|
290
|
+
return []
|
|
291
|
+
url = raw.strip()
|
|
292
|
+
if not url:
|
|
293
|
+
return []
|
|
294
|
+
return [{"url": url, "detected_via": "pages_api", "content_type": _classify_content_type(url)}]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
# Detection method 4 — docs/ folder
|
|
299
|
+
# ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
def _detect_docs_folder(owner: str, repo: str, local_path: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
302
|
+
results: List[Dict[str, Any]] = []
|
|
303
|
+
if local_path:
|
|
304
|
+
docs_dir = Path(local_path) / "docs"
|
|
305
|
+
if docs_dir.is_dir():
|
|
306
|
+
for md_file in sorted(docs_dir.rglob("*.md")):
|
|
307
|
+
file_url = "file://" + md_file.as_posix()
|
|
308
|
+
results.append({
|
|
309
|
+
"url": file_url,
|
|
310
|
+
"detected_via": "docs_folder",
|
|
311
|
+
"content_type": _classify_content_type(md_file.as_posix()),
|
|
312
|
+
})
|
|
313
|
+
else:
|
|
314
|
+
raw = _run_gh(["api", f"repos/{owner}/{repo}/contents/docs"])
|
|
315
|
+
if not raw:
|
|
316
|
+
return []
|
|
317
|
+
try:
|
|
318
|
+
entries = json.loads(raw)
|
|
319
|
+
except json.JSONDecodeError:
|
|
320
|
+
return []
|
|
321
|
+
if not isinstance(entries, list):
|
|
322
|
+
return []
|
|
323
|
+
for entry in entries:
|
|
324
|
+
name = entry.get("name", "")
|
|
325
|
+
if not name.lower().endswith(".md"):
|
|
326
|
+
continue
|
|
327
|
+
download_url = entry.get("download_url", "")
|
|
328
|
+
if download_url:
|
|
329
|
+
results.append({
|
|
330
|
+
"url": download_url,
|
|
331
|
+
"detected_via": "docs_folder",
|
|
332
|
+
"content_type": _classify_content_type(download_url),
|
|
333
|
+
})
|
|
334
|
+
return results
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ---------------------------------------------------------------------------
|
|
338
|
+
# Main detection orchestrator
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
def detect(
|
|
342
|
+
repo_url: str,
|
|
343
|
+
local_path: Optional[str] = None,
|
|
344
|
+
skip_pages_api: bool = False,
|
|
345
|
+
) -> List[Dict[str, Any]]:
|
|
346
|
+
m = _GITHUB_URL_RE.match(repo_url.strip())
|
|
347
|
+
if not m:
|
|
348
|
+
return []
|
|
349
|
+
|
|
350
|
+
owner, repo = m.group(1), m.group(2)
|
|
351
|
+
|
|
352
|
+
all_results: List[Dict[str, Any]] = []
|
|
353
|
+
all_results.extend(_detect_homepage_url(owner, repo))
|
|
354
|
+
all_results.extend(_detect_readme_links(owner, repo))
|
|
355
|
+
if not skip_pages_api:
|
|
356
|
+
all_results.extend(_detect_pages_api(owner, repo))
|
|
357
|
+
all_results.extend(_detect_docs_folder(owner, repo, local_path))
|
|
358
|
+
|
|
359
|
+
filtered = [r for r in all_results if not _is_excluded(r["url"])]
|
|
360
|
+
|
|
361
|
+
seen: set = set()
|
|
362
|
+
deduped: List[Dict[str, Any]] = []
|
|
363
|
+
for r in filtered:
|
|
364
|
+
if r["url"] not in seen:
|
|
365
|
+
seen.add(r["url"])
|
|
366
|
+
deduped.append(r)
|
|
367
|
+
|
|
368
|
+
for entry in deduped:
|
|
369
|
+
entry["content_hash"] = _fetch_and_hash(entry["url"])
|
|
370
|
+
|
|
371
|
+
return deduped
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
# CLI entry point
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
def main() -> int:
|
|
379
|
+
parser = argparse.ArgumentParser(
|
|
380
|
+
description="Detect documentation URLs for a GitHub repository.",
|
|
381
|
+
)
|
|
382
|
+
parser.add_argument("--repo-url", required=True, help="GitHub repository URL")
|
|
383
|
+
parser.add_argument("--local-path", default=None, help="Local clone path for docs/ folder scan")
|
|
384
|
+
parser.add_argument("--skip-pages-api", action="store_true", help="Skip GitHub Pages API detection")
|
|
385
|
+
args = parser.parse_args()
|
|
386
|
+
|
|
387
|
+
if not _check_gh_available():
|
|
388
|
+
json.dump({"error": "gh CLI not found", "code": "GH_NOT_FOUND"}, sys.stderr)
|
|
389
|
+
sys.stderr.write("\n")
|
|
390
|
+
return 2
|
|
391
|
+
|
|
392
|
+
m = _GITHUB_URL_RE.match(args.repo_url.strip())
|
|
393
|
+
if not m:
|
|
394
|
+
json.dump({"error": f"Not a GitHub URL: {args.repo_url}", "code": "INVALID_URL"}, sys.stderr)
|
|
395
|
+
sys.stderr.write("\n")
|
|
396
|
+
return 2
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
results = detect(args.repo_url, args.local_path, args.skip_pages_api)
|
|
400
|
+
except Exception as exc:
|
|
401
|
+
json.dump({"error": str(exc), "code": "DETECTION_ERROR"}, sys.stderr)
|
|
402
|
+
sys.stderr.write("\n")
|
|
403
|
+
return 2
|
|
404
|
+
|
|
405
|
+
json.dump(results, sys.stdout, separators=(",", ":"))
|
|
406
|
+
sys.stdout.write("\n")
|
|
407
|
+
|
|
408
|
+
return 0 if results else 1
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
if __name__ == "__main__":
|
|
412
|
+
raise SystemExit(main())
|
|
@@ -44,7 +44,8 @@ Context payload shape (consumed by `emit`):
|
|
|
44
44
|
"halt_reason": null | "input-missing" | "input-invalid" |
|
|
45
45
|
"forge-tier-missing" | "target-inaccessible" |
|
|
46
46
|
"gh-auth-failed" | "write-failed" |
|
|
47
|
-
"overwrite-cancelled" | "user-cancelled"
|
|
47
|
+
"overwrite-cancelled" | "user-cancelled",
|
|
48
|
+
"mode": null | "auto"
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
The caller does NOT supply exit_code — the script derives it from
|
|
@@ -105,6 +106,8 @@ HALT_TO_EXIT = {
|
|
|
105
106
|
"user-cancelled": 6,
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
VALID_MODES = {None, "auto"}
|
|
110
|
+
|
|
108
111
|
# Envelope key order — fixed so byte-stable diffs are possible.
|
|
109
112
|
KEY_ORDER = [
|
|
110
113
|
"status",
|
|
@@ -115,6 +118,7 @@ KEY_ORDER = [
|
|
|
115
118
|
"scope_type",
|
|
116
119
|
"exit_code",
|
|
117
120
|
"halt_reason",
|
|
121
|
+
"mode",
|
|
118
122
|
]
|
|
119
123
|
|
|
120
124
|
|
|
@@ -152,6 +156,10 @@ def assemble(ctx: dict[str, Any]) -> dict[str, Any]:
|
|
|
152
156
|
if not skill_name or not isinstance(skill_name, str):
|
|
153
157
|
_die(f"skill_name is required and must be a non-empty string; got {skill_name!r}")
|
|
154
158
|
|
|
159
|
+
mode = ctx.get("mode")
|
|
160
|
+
if mode not in VALID_MODES:
|
|
161
|
+
_die(f"mode must be one of {sorted(m for m in VALID_MODES if m is not None)} or null; got {mode!r}")
|
|
162
|
+
|
|
155
163
|
envelope = {
|
|
156
164
|
"status": status,
|
|
157
165
|
"brief_path": ctx.get("brief_path"),
|
|
@@ -161,6 +169,7 @@ def assemble(ctx: dict[str, Any]) -> dict[str, Any]:
|
|
|
161
169
|
"scope_type": scope_type,
|
|
162
170
|
"exit_code": HALT_TO_EXIT[halt_reason],
|
|
163
171
|
"halt_reason": halt_reason,
|
|
172
|
+
"mode": mode,
|
|
164
173
|
}
|
|
165
174
|
# Re-emit in canonical key order
|
|
166
175
|
return {k: envelope[k] for k in KEY_ORDER}
|
|
@@ -181,6 +190,8 @@ def validate(envelope: dict[str, Any]) -> None:
|
|
|
181
190
|
_die(f"halt_reason invalid: {envelope.get('halt_reason')!r}")
|
|
182
191
|
if envelope.get("scope_type") not in VALID_SCOPE_TYPES:
|
|
183
192
|
_die(f"scope_type invalid: {envelope.get('scope_type')!r}")
|
|
193
|
+
if envelope.get("mode") not in VALID_MODES:
|
|
194
|
+
_die(f"mode invalid: {envelope.get('mode')!r}")
|
|
184
195
|
if envelope.get("exit_code") not in {0, 2, 3, 4, 5, 6}:
|
|
185
196
|
_die(f"exit_code invalid: {envelope.get('exit_code')!r}")
|
|
186
197
|
expected_exit = HALT_TO_EXIT[envelope.get("halt_reason")]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.9"
|
|
3
|
+
# dependencies = ["pyyaml"]
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Pre-Apply — apply known workarounds before pipeline iteration.
|
|
6
|
+
|
|
7
|
+
Loads a shared seed registry of known workarounds and optionally a project-local
|
|
8
|
+
registry, then scans target directory files for fingerprint matches and applies
|
|
9
|
+
the corresponding fixes.
|
|
10
|
+
|
|
11
|
+
CLI:
|
|
12
|
+
uv run python src/shared/scripts/skf-preapply.py \
|
|
13
|
+
--target-dir <path> [--registry <path>] [--local-registry <path>] [--log-dir <path>]
|
|
14
|
+
|
|
15
|
+
Input:
|
|
16
|
+
--target-dir directory to scan for fingerprint matches (required)
|
|
17
|
+
--registry path to shared seed registry YAML (optional, defaults to
|
|
18
|
+
src/shared/_known-workarounds.yaml relative to script)
|
|
19
|
+
--local-registry path to project-local override registry YAML (optional)
|
|
20
|
+
--log-dir directory to write preapply-log.json (optional)
|
|
21
|
+
|
|
22
|
+
Output (JSON on stdout):
|
|
23
|
+
{
|
|
24
|
+
"applied": [{"fingerprint": "...", "fix": "...", "file": "...", "severity": "..."}],
|
|
25
|
+
"skipped_count": 0,
|
|
26
|
+
"registry_version": 1
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
Exit codes:
|
|
30
|
+
0 success (applied >= 0 fixes without errors)
|
|
31
|
+
2 error (invalid args, missing registry, YAML parse error)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import argparse
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import sys
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any, Dict, List, NoReturn
|
|
43
|
+
|
|
44
|
+
import yaml
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _err(message: str, code: str) -> NoReturn:
|
|
48
|
+
json.dump({"error": message, "code": code}, sys.stderr)
|
|
49
|
+
sys.stderr.write("\n")
|
|
50
|
+
sys.exit(2)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _load_registry(path: Path) -> Dict[str, Any]:
|
|
54
|
+
if not path.is_file():
|
|
55
|
+
_err(f"Registry not found: {path}", "REGISTRY_NOT_FOUND")
|
|
56
|
+
try:
|
|
57
|
+
text = path.read_text(encoding="utf-8")
|
|
58
|
+
data = yaml.safe_load(text)
|
|
59
|
+
except yaml.YAMLError as exc:
|
|
60
|
+
_err(f"YAML parse error in {path}: {exc}", "YAML_PARSE_ERROR")
|
|
61
|
+
if not isinstance(data, dict) or "workarounds" not in data:
|
|
62
|
+
_err(f"Invalid registry format in {path}", "INVALID_REGISTRY")
|
|
63
|
+
return data
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _merge_registries(
|
|
67
|
+
shared: Dict[str, Any], local: Dict[str, Any] | None
|
|
68
|
+
) -> tuple[List[Dict[str, Any]], int]:
|
|
69
|
+
version = shared.get("version", 1)
|
|
70
|
+
by_fp: Dict[str, Dict[str, Any]] = {}
|
|
71
|
+
for entry in shared.get("workarounds", []):
|
|
72
|
+
by_fp[entry["fingerprint"]] = entry
|
|
73
|
+
if local is not None:
|
|
74
|
+
for entry in local.get("workarounds", []):
|
|
75
|
+
by_fp[entry["fingerprint"]] = entry
|
|
76
|
+
return list(by_fp.values()), version
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _match_and_apply(
|
|
80
|
+
workarounds: List[Dict[str, Any]], target_dir: Path
|
|
81
|
+
) -> tuple[List[Dict[str, Any]], int]:
|
|
82
|
+
applied: List[Dict[str, Any]] = []
|
|
83
|
+
skipped = 0
|
|
84
|
+
|
|
85
|
+
md_files = sorted(target_dir.rglob("*.md"))
|
|
86
|
+
|
|
87
|
+
for wa in workarounds:
|
|
88
|
+
fp = wa["fingerprint"]
|
|
89
|
+
fix = wa["fix"]
|
|
90
|
+
use_regex = wa.get("regex", False)
|
|
91
|
+
matched = False
|
|
92
|
+
|
|
93
|
+
for md_file in md_files:
|
|
94
|
+
content = md_file.read_text(encoding="utf-8")
|
|
95
|
+
if use_regex:
|
|
96
|
+
try:
|
|
97
|
+
pattern = re.compile(fp)
|
|
98
|
+
except re.error as exc:
|
|
99
|
+
_err(
|
|
100
|
+
f"Invalid regex in workaround fingerprint: {exc}",
|
|
101
|
+
"INVALID_REGEX",
|
|
102
|
+
)
|
|
103
|
+
if pattern.search(content):
|
|
104
|
+
new_content = pattern.sub(fix, content)
|
|
105
|
+
md_file.write_text(new_content, encoding="utf-8")
|
|
106
|
+
applied.append({
|
|
107
|
+
"fingerprint": fp,
|
|
108
|
+
"fix": fix,
|
|
109
|
+
"file": md_file.relative_to(target_dir).as_posix(),
|
|
110
|
+
"severity": wa.get("severity", "low"),
|
|
111
|
+
})
|
|
112
|
+
matched = True
|
|
113
|
+
else:
|
|
114
|
+
if fp in content:
|
|
115
|
+
new_content = content.replace(fp, fix)
|
|
116
|
+
md_file.write_text(new_content, encoding="utf-8")
|
|
117
|
+
applied.append({
|
|
118
|
+
"fingerprint": fp,
|
|
119
|
+
"fix": fix,
|
|
120
|
+
"file": md_file.relative_to(target_dir).as_posix(),
|
|
121
|
+
"severity": wa.get("severity", "low"),
|
|
122
|
+
})
|
|
123
|
+
matched = True
|
|
124
|
+
|
|
125
|
+
if not matched:
|
|
126
|
+
skipped += 1
|
|
127
|
+
|
|
128
|
+
return applied, skipped
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main(argv: List[str] | None = None) -> None:
|
|
132
|
+
parser = argparse.ArgumentParser(
|
|
133
|
+
description="Apply known workarounds before pipeline iteration."
|
|
134
|
+
)
|
|
135
|
+
parser.add_argument(
|
|
136
|
+
"--target-dir", required=True, type=Path,
|
|
137
|
+
help="Directory to scan for fingerprint matches",
|
|
138
|
+
)
|
|
139
|
+
default_registry = (
|
|
140
|
+
Path(__file__).resolve().parent.parent / "_known-workarounds.yaml"
|
|
141
|
+
)
|
|
142
|
+
parser.add_argument(
|
|
143
|
+
"--registry", type=Path, default=default_registry,
|
|
144
|
+
help="Path to shared seed registry YAML",
|
|
145
|
+
)
|
|
146
|
+
parser.add_argument(
|
|
147
|
+
"--local-registry", type=Path, default=None,
|
|
148
|
+
help="Path to project-local override registry YAML",
|
|
149
|
+
)
|
|
150
|
+
parser.add_argument(
|
|
151
|
+
"--log-dir", type=Path, default=None,
|
|
152
|
+
help="Directory to write preapply-log.json",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
args = parser.parse_args(argv)
|
|
156
|
+
|
|
157
|
+
if not args.target_dir.is_dir():
|
|
158
|
+
_err(
|
|
159
|
+
f"Target directory not found: {args.target_dir}",
|
|
160
|
+
"TARGET_NOT_FOUND",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
shared = _load_registry(args.registry)
|
|
164
|
+
|
|
165
|
+
local = None
|
|
166
|
+
if args.local_registry is not None:
|
|
167
|
+
local = _load_registry(args.local_registry)
|
|
168
|
+
|
|
169
|
+
workarounds, version = _merge_registries(shared, local)
|
|
170
|
+
applied, skipped = _match_and_apply(workarounds, args.target_dir)
|
|
171
|
+
|
|
172
|
+
result = {
|
|
173
|
+
"applied": applied,
|
|
174
|
+
"skipped_count": skipped,
|
|
175
|
+
"registry_version": version,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
json.dump(result, sys.stdout)
|
|
179
|
+
sys.stdout.write("\n")
|
|
180
|
+
|
|
181
|
+
if args.log_dir is not None:
|
|
182
|
+
args.log_dir.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
log_path = args.log_dir / "preapply-log.json"
|
|
184
|
+
with open(
|
|
185
|
+
log_path, "w", encoding="utf-8", newline="\n"
|
|
186
|
+
) as f:
|
|
187
|
+
json.dump(result, f, indent=2)
|
|
188
|
+
f.write("\n")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
main()
|