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.
Files changed (43) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/bmad-synergy.md +16 -0
  4. package/docs/deepwiki.md +89 -0
  5. package/docs/verifying-a-skill.md +8 -2
  6. package/docs/workflows.md +17 -11
  7. package/package.json +2 -2
  8. package/src/shared/_known-workarounds.yaml +155 -0
  9. package/src/shared/references/pipeline-contracts.md +11 -2
  10. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +6 -1
  11. package/src/shared/scripts/skf-detect-docs.py +412 -0
  12. package/src/shared/scripts/skf-emit-brief-result-envelope.py +12 -1
  13. package/src/shared/scripts/skf-preapply.py +192 -0
  14. package/src/shared/scripts/skf-shape-detect.py +563 -0
  15. package/src/shared/scripts/skf-validate-pins.py +368 -0
  16. package/src/skf-analyze-source/SKILL.md +26 -20
  17. package/src/skf-analyze-source/references/init.md +30 -0
  18. package/src/skf-analyze-source/references/step-auto-scope.md +525 -0
  19. package/src/skf-analyze-source/references/step-shape-detect.md +79 -0
  20. package/src/skf-audit-skill/SKILL.md +1 -0
  21. package/src/skf-audit-skill/assets/drift-report-template.md +6 -0
  22. package/src/skf-audit-skill/references/report.md +4 -0
  23. package/src/skf-audit-skill/references/severity-classify.md +1 -1
  24. package/src/skf-audit-skill/references/step-doc-drift.md +147 -0
  25. package/src/skf-brief-skill/SKILL.md +7 -3
  26. package/src/skf-brief-skill/references/gather-intent.md +14 -0
  27. package/src/skf-brief-skill/references/step-auto-brief.md +171 -0
  28. package/src/skf-brief-skill/references/step-auto-validate.md +209 -0
  29. package/src/skf-create-skill/SKILL.md +3 -0
  30. package/src/skf-create-skill/assets/skill-sections.md +2 -0
  31. package/src/skf-create-skill/references/compile.md +1 -1
  32. package/src/skf-create-skill/references/step-auto-shard.md +110 -0
  33. package/src/skf-create-skill/references/step-doc-rot.md +109 -0
  34. package/src/skf-create-skill/references/step-doc-sources.md +114 -0
  35. package/src/skf-forger/SKILL.md +8 -2
  36. package/src/skf-test-skill/SKILL.md +8 -7
  37. package/src/skf-test-skill/customize.toml +2 -1
  38. package/src/skf-test-skill/references/external-validators.md +1 -1
  39. package/src/skf-test-skill/references/init.md +16 -1
  40. package/src/skf-test-skill/references/report.md +5 -1
  41. package/src/skf-test-skill/references/score.md +95 -6
  42. package/src/skf-test-skill/references/step-hard-gate.md +73 -0
  43. 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()