bmad-module-skill-forge 1.1.0 → 1.3.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 (45) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +6 -4
  3. package/docs/_data/pinned.yaml +1 -1
  4. package/docs/bmad-synergy.md +2 -2
  5. package/docs/getting-started.md +1 -1
  6. package/docs/skill-model.md +26 -32
  7. package/docs/troubleshooting.md +13 -1
  8. package/docs/why-skf.md +5 -4
  9. package/docs/workflows.md +53 -0
  10. package/package.json +2 -2
  11. package/src/knowledge/ccc-bridge.md +1 -1
  12. package/src/shared/references/output-contract-schema.md +10 -0
  13. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +134 -0
  14. package/src/shared/scripts/skf-detect-tools.py +359 -0
  15. package/src/shared/scripts/skf-emit-result-envelope.py +419 -0
  16. package/src/shared/scripts/skf-extract-public-api.py +505 -0
  17. package/src/shared/scripts/skf-forge-tier-rw.py +413 -0
  18. package/src/shared/scripts/skf-merge-ccc-exclusions.py +324 -0
  19. package/src/shared/scripts/skf-preflight.py +14 -4
  20. package/src/shared/scripts/skf-qmd-classify-collections.py +212 -0
  21. package/src/shared/scripts/skf-render-quick-metadata.py +192 -0
  22. package/src/shared/scripts/skf-resolve-package.py +264 -0
  23. package/src/shared/scripts/skf-validate-frontmatter.py +9 -3
  24. package/src/shared/scripts/skf-validate-output.py +24 -7
  25. package/src/skf-create-skill/steps-c/step-06-validate.md +1 -1
  26. package/src/skf-quick-skill/SKILL.md +178 -10
  27. package/src/skf-quick-skill/assets/skill-template.md +5 -1
  28. package/src/skf-quick-skill/references/registry-resolution.md +2 -0
  29. package/src/skf-quick-skill/steps-c/step-01-resolve-target.md +84 -16
  30. package/src/skf-quick-skill/steps-c/step-02-ecosystem-check.md +3 -3
  31. package/src/skf-quick-skill/steps-c/step-03-quick-extract.md +86 -43
  32. package/src/skf-quick-skill/steps-c/step-04-compile.md +49 -56
  33. package/src/skf-quick-skill/steps-c/step-05-write-and-validate.md +164 -0
  34. package/src/skf-quick-skill/steps-c/{step-06-write.md → step-06-finalize.md} +15 -7
  35. package/src/skf-quick-skill/steps-c/step-07-health-check.md +5 -3
  36. package/src/skf-setup/SKILL.md +25 -10
  37. package/src/skf-setup/references/tier-rules.md +2 -2
  38. package/src/skf-setup/steps-c/step-01-detect-and-tier.md +58 -71
  39. package/src/skf-setup/steps-c/step-01b-ccc-index.md +49 -66
  40. package/src/skf-setup/steps-c/step-02-write-config.md +59 -86
  41. package/src/skf-setup/steps-c/step-03-auto-index.md +73 -91
  42. package/src/skf-setup/steps-c/step-04-report.md +135 -11
  43. package/src/skf-setup/steps-c/step-05-health-check.md +4 -2
  44. package/src/skf-test-skill/steps-c/step-01-init.md +4 -4
  45. package/src/skf-quick-skill/steps-c/step-05-validate.md +0 -193
@@ -0,0 +1,505 @@
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Extract Public API — pure parser for manifest + entry-point content.
6
+
7
+ Takes a JSON payload on stdin describing one logical package (one
8
+ manifest, one or more entry-point files) and emits a structured
9
+ extraction inventory: package name, version, description, public
10
+ exports, declared dependencies, and (for Maven/Gradle) discovered
11
+ sub-modules. The helper does NO file I/O — the caller fetches files
12
+ (via gh api / web browsing / local filesystem / wherever) and pipes
13
+ their contents in. Multi-module monorepos are caller-orchestrated:
14
+ invoke once per module, aggregate the results.
15
+
16
+ Supported languages (--language values; first listed is preferred):
17
+
18
+ js, ts, javascript, typescript package.json index.{js,ts}, src/index.{js,ts}
19
+ python pyproject.toml __init__.py / setup.py
20
+ rust Cargo.toml src/lib.rs
21
+ go go.mod top-level *.go
22
+ java pom.xml src/main/java/**/*.java
23
+ kotlin build.gradle* src/main/kotlin/**/*.kt
24
+
25
+ Mode flag (currently `quick` only; `full` reserved for skf-create-skill):
26
+
27
+ --mode quick Top-level exports only (one entry file per language).
28
+
29
+ Input JSON shape (stdin):
30
+
31
+ {
32
+ "language": "python",
33
+ "manifest": {"path": "pyproject.toml", "content": "..."},
34
+ "entries": [{"path": "src/foo/__init__.py", "content": "..."}, ...],
35
+ "mode": "quick"
36
+ }
37
+
38
+ Output JSON shape (stdout):
39
+
40
+ {
41
+ "language": "python",
42
+ "package_name": "foo",
43
+ "version": "1.2.3",
44
+ "description": "...",
45
+ "exports": [{"name": "Bar", "type": "class", "source_file": "..."}, ...],
46
+ "dependencies": ["requests", "pydantic", ...],
47
+ "modules": ["server", "client"], (Maven/Gradle only)
48
+ "extra": {"group_id": "com.example"}, (Maven only)
49
+ "warnings": ["..."] (parse failures, fallbacks)
50
+ }
51
+
52
+ Exit codes:
53
+
54
+ 0 success
55
+ 1 payload-level error (unknown language, no manifest content)
56
+ 2 stdin / argparse / JSON-decode error
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import argparse
62
+ import json
63
+ import re
64
+ import sys
65
+ import tomllib
66
+ import xml.etree.ElementTree as ET
67
+ from typing import Callable
68
+
69
+ # --------------------------------------------------------------------------
70
+ # Manifest parsers
71
+ # --------------------------------------------------------------------------
72
+
73
+
74
+ def parse_package_json(content: str) -> dict:
75
+ try:
76
+ data = json.loads(content)
77
+ except json.JSONDecodeError as e:
78
+ return {"_parse_error": f"package.json JSON parse error: {e}"}
79
+ if not isinstance(data, dict):
80
+ return {"_parse_error": "package.json root is not an object"}
81
+ return {
82
+ "name": data.get("name"),
83
+ "version": data.get("version"),
84
+ "description": data.get("description"),
85
+ "main": data.get("main"),
86
+ "exports": data.get("exports"),
87
+ "dependencies": list((data.get("dependencies") or {}).keys()),
88
+ "modules": [],
89
+ }
90
+
91
+
92
+ def parse_pyproject_toml(content: str) -> dict:
93
+ try:
94
+ data = tomllib.loads(content)
95
+ except tomllib.TOMLDecodeError as e:
96
+ return {"_parse_error": f"pyproject.toml parse error: {e}"}
97
+ project = data.get("project") or {}
98
+ raw_deps = project.get("dependencies") or []
99
+ dep_names: list[str] = []
100
+ for d in raw_deps:
101
+ if isinstance(d, str):
102
+ m = re.match(r"^([A-Za-z0-9_.\-]+)", d.strip())
103
+ if m:
104
+ dep_names.append(m.group(1))
105
+ return {
106
+ "name": project.get("name"),
107
+ "version": project.get("version"),
108
+ "description": project.get("description"),
109
+ "dependencies": dep_names,
110
+ "modules": [],
111
+ }
112
+
113
+
114
+ def parse_setup_py(content: str) -> dict:
115
+ """Best-effort regex parse of setup.py — does NOT execute the file."""
116
+
117
+ def find_kwarg(name: str) -> str | None:
118
+ m = re.search(rf'\b{name}\s*=\s*["\']([^"\']+)["\']', content)
119
+ return m.group(1) if m else None
120
+
121
+ return {
122
+ "name": find_kwarg("name"),
123
+ "version": find_kwarg("version"),
124
+ "description": find_kwarg("description"),
125
+ "dependencies": [],
126
+ "modules": [],
127
+ }
128
+
129
+
130
+ def parse_cargo_toml(content: str) -> dict:
131
+ try:
132
+ data = tomllib.loads(content)
133
+ except tomllib.TOMLDecodeError as e:
134
+ return {"_parse_error": f"Cargo.toml parse error: {e}"}
135
+ pkg = data.get("package") or {}
136
+ return {
137
+ "name": pkg.get("name"),
138
+ "version": pkg.get("version"),
139
+ "description": pkg.get("description"),
140
+ "dependencies": list((data.get("dependencies") or {}).keys()),
141
+ "modules": [],
142
+ }
143
+
144
+
145
+ def parse_go_mod(content: str) -> dict:
146
+ name: str | None = None
147
+ deps: list[str] = []
148
+ in_require_block = False
149
+ for line in content.splitlines():
150
+ s = line.strip()
151
+ if not s or s.startswith("//"):
152
+ continue
153
+ if s.startswith("module "):
154
+ name = s.split(None, 1)[1].strip()
155
+ continue
156
+ if s.startswith("require ("):
157
+ in_require_block = True
158
+ continue
159
+ if s == ")" and in_require_block:
160
+ in_require_block = False
161
+ continue
162
+ if s.startswith("require "):
163
+ parts = s.split(None, 2)
164
+ if len(parts) >= 2:
165
+ deps.append(parts[1])
166
+ continue
167
+ if in_require_block:
168
+ parts = s.split()
169
+ if parts:
170
+ deps.append(parts[0])
171
+ return {
172
+ "name": name,
173
+ "version": None,
174
+ "description": None,
175
+ "dependencies": deps,
176
+ "modules": [],
177
+ }
178
+
179
+
180
+ def _strip_ns(tag: str) -> str:
181
+ return tag.split("}", 1)[1] if "}" in tag else tag
182
+
183
+
184
+ def parse_pom_xml(content: str) -> dict:
185
+ try:
186
+ root = ET.fromstring(content)
187
+ except ET.ParseError as e:
188
+ return {"_parse_error": f"pom.xml parse error: {e}"}
189
+
190
+ group_id = artifact_id = version = description = None
191
+ modules: list[str] = []
192
+ deps: list[str] = []
193
+
194
+ for child in root:
195
+ tag = _strip_ns(child.tag)
196
+ if tag == "groupId":
197
+ group_id = (child.text or "").strip() or None
198
+ elif tag == "artifactId":
199
+ artifact_id = (child.text or "").strip() or None
200
+ elif tag == "version":
201
+ version = (child.text or "").strip() or None
202
+ elif tag == "description":
203
+ description = (child.text or "").strip() or None
204
+ elif tag == "modules":
205
+ for m in child:
206
+ if _strip_ns(m.tag) == "module":
207
+ text = (m.text or "").strip()
208
+ if text:
209
+ modules.append(text)
210
+ elif tag == "dependencies":
211
+ for d in child:
212
+ d_artifact = None
213
+ for c in d:
214
+ if _strip_ns(c.tag) == "artifactId":
215
+ d_artifact = (c.text or "").strip() or None
216
+ if d_artifact:
217
+ deps.append(d_artifact)
218
+
219
+ out = {
220
+ "name": artifact_id,
221
+ "version": version,
222
+ "description": description,
223
+ "dependencies": deps,
224
+ "modules": modules,
225
+ }
226
+ if group_id:
227
+ out["_extra"] = {"group_id": group_id}
228
+ return out
229
+
230
+
231
+ def parse_gradle(content: str) -> dict:
232
+ """Best-effort regex parse of build.gradle / build.gradle.kts."""
233
+
234
+ def find_assignment(name: str) -> str | None:
235
+ for pat in (
236
+ rf'\b{name}\s*=\s*["\']([^"\']+)["\']',
237
+ rf'\b{name}\s+["\']([^"\']+)["\']',
238
+ rf'\b{name}\s*\(\s*["\']([^"\']+)["\']',
239
+ rf'\b{name}\s*:\s*["\']([^"\']+)["\']',
240
+ ):
241
+ m = re.search(pat, content)
242
+ if m:
243
+ return m.group(1)
244
+ return None
245
+
246
+ return {
247
+ "name": find_assignment("artifactId") or find_assignment("group"),
248
+ "version": find_assignment("version"),
249
+ "description": find_assignment("description"),
250
+ "dependencies": [],
251
+ "modules": [],
252
+ }
253
+
254
+
255
+ def parse_settings_gradle(content: str) -> list[str]:
256
+ """Extract include('...') / include(":a", ":b") entries."""
257
+ modules: list[str] = []
258
+ for m in re.finditer(r"include\s*\(?(.+?)\)?$", content, flags=re.MULTILINE):
259
+ for s in re.findall(r"""['"]:?([^'"]+)['"]""", m.group(1)):
260
+ modules.append(s)
261
+ return modules
262
+
263
+
264
+ # --------------------------------------------------------------------------
265
+ # Export scanners
266
+ # --------------------------------------------------------------------------
267
+
268
+ _JS_DECL_RE = re.compile(
269
+ r"^export\s+(?:default\s+)?(const|function|class|type|interface|enum)\s+(\w+)",
270
+ re.MULTILINE,
271
+ )
272
+ _JS_REEXPORT_RE = re.compile(r"^export\s*\{([^}]+)\}", re.MULTILINE)
273
+
274
+
275
+ def scan_exports_js(content: str, source_file: str) -> list[dict]:
276
+ out: list[dict] = []
277
+ seen: set[str] = set()
278
+ for m in _JS_DECL_RE.finditer(content):
279
+ kind, name = m.group(1), m.group(2)
280
+ if name not in seen:
281
+ seen.add(name)
282
+ out.append({"name": name, "type": kind, "source_file": source_file})
283
+ for m in _JS_REEXPORT_RE.finditer(content):
284
+ for piece in m.group(1).split(","):
285
+ piece = piece.strip()
286
+ if not piece:
287
+ continue
288
+ # Handle `foo as bar` — keep the local name (foo) and the alias (bar);
289
+ # we only emit one entry, the exposed name.
290
+ if " as " in piece:
291
+ _, _, exposed = piece.partition(" as ")
292
+ exposed = exposed.strip()
293
+ else:
294
+ exposed = piece
295
+ m2 = re.match(r"\w+$", exposed)
296
+ if m2 and exposed not in seen:
297
+ seen.add(exposed)
298
+ out.append({"name": exposed, "type": "re-export", "source_file": source_file})
299
+ return out
300
+
301
+
302
+ def scan_exports_python(content: str, source_file: str) -> list[dict]:
303
+ all_names: list[str] | None = None
304
+ m = re.search(r"^__all__\s*=\s*\[([^\]]+)\]", content, re.MULTILINE | re.DOTALL)
305
+ if m:
306
+ all_names = re.findall(r"""['"]([^'"]+)['"]""", m.group(1))
307
+
308
+ out: list[dict] = []
309
+ for m in re.finditer(r"^(def|class)\s+(\w+)", content, re.MULTILINE):
310
+ kind, name = m.group(1), m.group(2)
311
+ if name.startswith("_"):
312
+ continue
313
+ if all_names is not None and name not in all_names:
314
+ continue
315
+ out.append({"name": name, "type": kind, "source_file": source_file})
316
+ return out
317
+
318
+
319
+ def scan_exports_rust(content: str, source_file: str) -> list[dict]:
320
+ out: list[dict] = []
321
+ for m in re.finditer(
322
+ r"^\s*pub\s+(fn|struct|enum|trait|mod|type|const|static)\s+(\w+)",
323
+ content,
324
+ re.MULTILINE,
325
+ ):
326
+ out.append({"name": m.group(2), "type": m.group(1), "source_file": source_file})
327
+ return out
328
+
329
+
330
+ def scan_exports_go(content: str, source_file: str) -> list[dict]:
331
+ out: list[dict] = []
332
+ for m in re.finditer(r"^(func|type|var|const)\s+([A-Z]\w*)", content, re.MULTILINE):
333
+ out.append({"name": m.group(2), "type": m.group(1), "source_file": source_file})
334
+ return out
335
+
336
+
337
+ _JAVA_PUBLIC_RE = re.compile(
338
+ r"^\s*public\s+(?:static\s+|final\s+|abstract\s+)*(class|interface|enum|record)\s+(\w+)",
339
+ re.MULTILINE,
340
+ )
341
+ _JAVA_ANNOTATION_RE = re.compile(
342
+ r"^\s*@(RestController|Service|Component|Configuration|Controller|Repository|Bean)\b",
343
+ re.MULTILINE,
344
+ )
345
+
346
+
347
+ def scan_exports_java(content: str, source_file: str) -> list[dict]:
348
+ out: list[dict] = []
349
+ seen: set[str] = set()
350
+ for m in _JAVA_PUBLIC_RE.finditer(content):
351
+ name = m.group(2)
352
+ if name in seen:
353
+ continue
354
+ seen.add(name)
355
+ out.append({"name": name, "type": m.group(1), "source_file": source_file})
356
+ # Annotation-marked classes (Spring/Jakarta CDI) — find the annotation, then
357
+ # the next class/interface/enum/record declaration after it.
358
+ for m in _JAVA_ANNOTATION_RE.finditer(content):
359
+ annotation = m.group(1)
360
+ tail = content[m.end():]
361
+ m2 = re.search(r"\b(class|interface|enum|record)\s+(\w+)", tail)
362
+ if m2:
363
+ name = m2.group(2)
364
+ if name not in seen:
365
+ seen.add(name)
366
+ out.append({"name": name, "type": annotation.lower(), "source_file": source_file})
367
+ return out
368
+
369
+
370
+ _KOTLIN_DECL_RE = re.compile(
371
+ r"^(?!\s*(?:internal|private)\b)"
372
+ r"\s*(?:open\s+|sealed\s+|data\s+|abstract\s+)*"
373
+ r"(fun|class|object|interface)\s+(\w+)",
374
+ re.MULTILINE,
375
+ )
376
+
377
+
378
+ def scan_exports_kotlin(content: str, source_file: str) -> list[dict]:
379
+ """Kotlin defaults to public — omit internal/private declarations."""
380
+ out: list[dict] = []
381
+ seen: set[str] = set()
382
+ for m in _KOTLIN_DECL_RE.finditer(content):
383
+ name = m.group(2)
384
+ if name in seen:
385
+ continue
386
+ seen.add(name)
387
+ out.append({"name": name, "type": m.group(1), "source_file": source_file})
388
+ return out
389
+
390
+
391
+ # --------------------------------------------------------------------------
392
+ # Orchestrator
393
+ # --------------------------------------------------------------------------
394
+
395
+ ManifestParser = Callable[[str], dict]
396
+ ExportScanner = Callable[[str, str], list[dict]]
397
+
398
+ LANGUAGE_DISPATCH: dict[str, tuple[ManifestParser, ExportScanner]] = {
399
+ "js": (parse_package_json, scan_exports_js),
400
+ "ts": (parse_package_json, scan_exports_js),
401
+ "javascript": (parse_package_json, scan_exports_js),
402
+ "typescript": (parse_package_json, scan_exports_js),
403
+ "python": (parse_pyproject_toml, scan_exports_python),
404
+ "rust": (parse_cargo_toml, scan_exports_rust),
405
+ "go": (parse_go_mod, scan_exports_go),
406
+ "java": (parse_pom_xml, scan_exports_java),
407
+ "kotlin": (parse_gradle, scan_exports_kotlin),
408
+ }
409
+
410
+
411
+ def _select_manifest_parser(language: str, manifest_path: str) -> ManifestParser:
412
+ """Pick the right manifest parser, special-casing Python's setup.py."""
413
+ if language == "python" and manifest_path.endswith("setup.py"):
414
+ return parse_setup_py
415
+ return LANGUAGE_DISPATCH[language][0]
416
+
417
+
418
+ def extract(payload: dict) -> dict:
419
+ """Orchestrate manifest parse + export scan for one logical package."""
420
+ warnings: list[str] = []
421
+ language = (payload.get("language") or "").lower()
422
+ if language not in LANGUAGE_DISPATCH:
423
+ return {"_error": f"unknown language: {language!r}; expected one of {sorted(LANGUAGE_DISPATCH)}"}
424
+
425
+ manifest = payload.get("manifest") or {}
426
+ manifest_path = (manifest.get("path") or "").strip()
427
+ manifest_content = manifest.get("content") or ""
428
+
429
+ if not manifest_content:
430
+ warnings.append("no manifest content provided; metadata will be empty")
431
+ parsed: dict = {}
432
+ else:
433
+ manifest_parser = _select_manifest_parser(language, manifest_path)
434
+ parsed = manifest_parser(manifest_content)
435
+ if "_parse_error" in parsed:
436
+ warnings.append(parsed["_parse_error"])
437
+ parsed = {}
438
+
439
+ _, scanner = LANGUAGE_DISPATCH[language]
440
+ exports: list[dict] = []
441
+ for entry in payload.get("entries") or []:
442
+ if not isinstance(entry, dict):
443
+ continue
444
+ content = entry.get("content") or ""
445
+ path = entry.get("path") or ""
446
+ if not content:
447
+ continue
448
+ try:
449
+ exports.extend(scanner(content, path))
450
+ except Exception as e: # noqa: BLE001 — best-effort: scanner errors are warnings, not fatal
451
+ warnings.append(f"export scan failed for {path}: {e}")
452
+
453
+ result = {
454
+ "language": language,
455
+ "package_name": parsed.get("name"),
456
+ "version": parsed.get("version"),
457
+ "description": parsed.get("description"),
458
+ "exports": exports,
459
+ "dependencies": parsed.get("dependencies", []),
460
+ "modules": parsed.get("modules", []),
461
+ "warnings": warnings,
462
+ }
463
+ if "_extra" in parsed:
464
+ result["extra"] = parsed["_extra"]
465
+ return result
466
+
467
+
468
+ # --------------------------------------------------------------------------
469
+ # CLI
470
+ # --------------------------------------------------------------------------
471
+
472
+
473
+ def main(argv: list[str]) -> int:
474
+ parser = argparse.ArgumentParser(
475
+ description="Extract public-API surface from manifest + entry-point content (pure parser, no I/O).",
476
+ )
477
+ parser.add_argument(
478
+ "--mode",
479
+ default="quick",
480
+ choices=("quick",),
481
+ help="Extraction mode (only 'quick' currently; 'full' reserved for skf-create-skill).",
482
+ )
483
+ args = parser.parse_args(argv)
484
+
485
+ raw = sys.stdin.read()
486
+ if not raw.strip():
487
+ sys.stderr.write("error: no input on stdin (expected JSON payload)\n")
488
+ return 2
489
+ try:
490
+ payload = json.loads(raw)
491
+ except json.JSONDecodeError as e:
492
+ sys.stderr.write(f"error: stdin JSON parse error: {e}\n")
493
+ return 2
494
+ if not isinstance(payload, dict):
495
+ sys.stderr.write("error: stdin payload must be a JSON object\n")
496
+ return 2
497
+
498
+ payload.setdefault("mode", args.mode)
499
+ result = extract(payload)
500
+ print(json.dumps(result, indent=2))
501
+ return 1 if "_error" in result else 0
502
+
503
+
504
+ if __name__ == "__main__":
505
+ sys.exit(main(sys.argv[1:]))