bmad-module-skill-forge 1.2.0 → 1.4.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/skill-model.md +26 -32
- package/docs/troubleshooting.md +12 -0
- package/docs/workflows.md +87 -15
- package/package.json +2 -2
- package/src/shared/references/output-contract-schema.md +10 -0
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +58 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +77 -0
- package/src/shared/scripts/schemas/workspace-detection.v1.json +44 -0
- package/src/shared/scripts/skf-detect-language.py +277 -0
- package/src/shared/scripts/skf-detect-workspaces.py +427 -0
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +257 -0
- package/src/shared/scripts/skf-extract-public-api.py +534 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +73 -0
- package/src/shared/scripts/skf-recommend-scope-type.py +369 -0
- package/src/shared/scripts/skf-render-quick-metadata.py +192 -0
- package/src/shared/scripts/skf-resolve-package.py +264 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +293 -0
- package/src/shared/scripts/skf-validate-output.py +24 -7
- package/src/shared/scripts/skf-write-skill-brief.py +509 -0
- package/src/skf-brief-skill/SKILL.md +41 -12
- package/src/skf-brief-skill/assets/description-voice-examples.md +19 -0
- package/src/skf-brief-skill/assets/scope-templates.md +5 -0
- package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -40
- package/src/skf-brief-skill/references/draft-checkpoint.md +46 -0
- package/src/skf-brief-skill/references/headless-args.md +22 -0
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +26 -0
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +35 -0
- package/src/skf-brief-skill/references/qmd-collection-registration.md +52 -0
- package/src/skf-brief-skill/references/version-resolution.md +46 -0
- package/src/skf-brief-skill/steps-c/step-01-gather-intent.md +164 -14
- package/src/skf-brief-skill/steps-c/step-02-analyze-target.md +118 -50
- package/src/skf-brief-skill/steps-c/step-03-scope-definition.md +48 -5
- package/src/skf-brief-skill/steps-c/step-04-confirm-brief.md +33 -19
- package/src/skf-brief-skill/steps-c/step-05-write-brief.md +93 -97
- package/src/skf-brief-skill/steps-c/step-06-health-check.md +11 -2
- package/src/skf-quick-skill/SKILL.md +178 -10
- package/src/skf-quick-skill/assets/skill-template.md +5 -1
- package/src/skf-quick-skill/references/registry-resolution.md +2 -0
- package/src/skf-quick-skill/steps-c/step-01-resolve-target.md +84 -16
- package/src/skf-quick-skill/steps-c/step-02-ecosystem-check.md +3 -3
- package/src/skf-quick-skill/steps-c/step-03-quick-extract.md +86 -43
- package/src/skf-quick-skill/steps-c/step-04-compile.md +49 -56
- package/src/skf-quick-skill/steps-c/step-05-write-and-validate.md +164 -0
- package/src/skf-quick-skill/steps-c/{step-06-write.md → step-06-finalize.md} +15 -7
- package/src/skf-quick-skill/steps-c/step-07-health-check.md +5 -3
- package/src/skf-quick-skill/steps-c/step-05-validate.md +0 -193
|
@@ -0,0 +1,534 @@
|
|
|
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
|
+
# Release-time placeholder versions that appear in committed manifests but
|
|
419
|
+
# resolve to a real version only at publish time. Briefs that silently inherit
|
|
420
|
+
# these as the resolved version produce skills tagged with garbage version
|
|
421
|
+
# strings; surface them at brief-creation instead.
|
|
422
|
+
_PLACEHOLDER_VERSION_PREFIXES = ("workspace:",)
|
|
423
|
+
_PLACEHOLDER_VERSION_EXACTS = frozenset({"0.0.0-development", "0.0.0-semantically-released"})
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _detect_placeholder_version(version: str | None) -> str | None:
|
|
427
|
+
"""Return the original placeholder string if `version` is a known release-time sentinel, else None."""
|
|
428
|
+
if not version or not isinstance(version, str):
|
|
429
|
+
return None
|
|
430
|
+
v = version.strip().lower()
|
|
431
|
+
if v in _PLACEHOLDER_VERSION_EXACTS:
|
|
432
|
+
return version
|
|
433
|
+
if any(v.startswith(prefix) for prefix in _PLACEHOLDER_VERSION_PREFIXES):
|
|
434
|
+
return version
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def extract(payload: dict) -> dict:
|
|
439
|
+
"""Orchestrate manifest parse + export scan for one logical package."""
|
|
440
|
+
warnings: list[str] = []
|
|
441
|
+
language = (payload.get("language") or "").lower()
|
|
442
|
+
if language not in LANGUAGE_DISPATCH:
|
|
443
|
+
return {"_error": f"unknown language: {language!r}; expected one of {sorted(LANGUAGE_DISPATCH)}"}
|
|
444
|
+
|
|
445
|
+
manifest = payload.get("manifest") or {}
|
|
446
|
+
manifest_path = (manifest.get("path") or "").strip()
|
|
447
|
+
manifest_content = manifest.get("content") or ""
|
|
448
|
+
|
|
449
|
+
if not manifest_content:
|
|
450
|
+
warnings.append("no manifest content provided; metadata will be empty")
|
|
451
|
+
parsed: dict = {}
|
|
452
|
+
else:
|
|
453
|
+
manifest_parser = _select_manifest_parser(language, manifest_path)
|
|
454
|
+
parsed = manifest_parser(manifest_content)
|
|
455
|
+
if "_parse_error" in parsed:
|
|
456
|
+
warnings.append(parsed["_parse_error"])
|
|
457
|
+
parsed = {}
|
|
458
|
+
|
|
459
|
+
placeholder = _detect_placeholder_version(parsed.get("version"))
|
|
460
|
+
if placeholder is not None:
|
|
461
|
+
warnings.append(
|
|
462
|
+
f"manifest version {placeholder!r} is a release-time placeholder "
|
|
463
|
+
f"(workspace protocol or semantic-release sentinel) and is not a real version; "
|
|
464
|
+
f"the brief will fall back to user-supplied target_version or the default"
|
|
465
|
+
)
|
|
466
|
+
parsed["version"] = None
|
|
467
|
+
|
|
468
|
+
_, scanner = LANGUAGE_DISPATCH[language]
|
|
469
|
+
exports: list[dict] = []
|
|
470
|
+
for entry in payload.get("entries") or []:
|
|
471
|
+
if not isinstance(entry, dict):
|
|
472
|
+
continue
|
|
473
|
+
content = entry.get("content") or ""
|
|
474
|
+
path = entry.get("path") or ""
|
|
475
|
+
if not content:
|
|
476
|
+
continue
|
|
477
|
+
try:
|
|
478
|
+
exports.extend(scanner(content, path))
|
|
479
|
+
except Exception as e: # noqa: BLE001 — best-effort: scanner errors are warnings, not fatal
|
|
480
|
+
warnings.append(f"export scan failed for {path}: {e}")
|
|
481
|
+
|
|
482
|
+
result = {
|
|
483
|
+
"language": language,
|
|
484
|
+
"package_name": parsed.get("name"),
|
|
485
|
+
"version": parsed.get("version"),
|
|
486
|
+
"description": parsed.get("description"),
|
|
487
|
+
"exports": exports,
|
|
488
|
+
"dependencies": parsed.get("dependencies", []),
|
|
489
|
+
"modules": parsed.get("modules", []),
|
|
490
|
+
"warnings": warnings,
|
|
491
|
+
}
|
|
492
|
+
if "_extra" in parsed:
|
|
493
|
+
result["extra"] = parsed["_extra"]
|
|
494
|
+
return result
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# --------------------------------------------------------------------------
|
|
498
|
+
# CLI
|
|
499
|
+
# --------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def main(argv: list[str]) -> int:
|
|
503
|
+
parser = argparse.ArgumentParser(
|
|
504
|
+
description="Extract public-API surface from manifest + entry-point content (pure parser, no I/O).",
|
|
505
|
+
)
|
|
506
|
+
parser.add_argument(
|
|
507
|
+
"--mode",
|
|
508
|
+
default="quick",
|
|
509
|
+
choices=("quick",),
|
|
510
|
+
help="Extraction mode (only 'quick' currently; 'full' reserved for skf-create-skill).",
|
|
511
|
+
)
|
|
512
|
+
args = parser.parse_args(argv)
|
|
513
|
+
|
|
514
|
+
raw = sys.stdin.read()
|
|
515
|
+
if not raw.strip():
|
|
516
|
+
sys.stderr.write("error: no input on stdin (expected JSON payload)\n")
|
|
517
|
+
return 2
|
|
518
|
+
try:
|
|
519
|
+
payload = json.loads(raw)
|
|
520
|
+
except json.JSONDecodeError as e:
|
|
521
|
+
sys.stderr.write(f"error: stdin JSON parse error: {e}\n")
|
|
522
|
+
return 2
|
|
523
|
+
if not isinstance(payload, dict):
|
|
524
|
+
sys.stderr.write("error: stdin payload must be a JSON object\n")
|
|
525
|
+
return 2
|
|
526
|
+
|
|
527
|
+
payload.setdefault("mode", args.mode)
|
|
528
|
+
result = extract(payload)
|
|
529
|
+
print(json.dumps(result, indent=2))
|
|
530
|
+
return 1 if "_error" in result else 0
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
if __name__ == "__main__":
|
|
534
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -30,6 +30,18 @@ Subcommands:
|
|
|
30
30
|
does not exist. Idempotent — refuses to overwrite an
|
|
31
31
|
existing file (preserves user customization).
|
|
32
32
|
|
|
33
|
+
register-qmd-collection
|
|
34
|
+
Append-or-replace a single entry in the `qmd_collections`
|
|
35
|
+
array. Reads the entry as JSON on stdin (must include
|
|
36
|
+
`name`; `name` is the upsert key — existing entry with
|
|
37
|
+
the same `name` is replaced, otherwise appended). All
|
|
38
|
+
other forge-tier state (tools / tier / ccc_index /
|
|
39
|
+
ccc_index_registry / other qmd_collections entries) is
|
|
40
|
+
preserved verbatim. Used by skf-brief-skill step-05 §5
|
|
41
|
+
and skf-create-skill to register Deep-tier QMD
|
|
42
|
+
collections without re-rendering the whole file in
|
|
43
|
+
prose.
|
|
44
|
+
|
|
33
45
|
clean-stale Two cleanup operations gated by flags:
|
|
34
46
|
--qmd-live-names a,b,c — remove qmd_collections
|
|
35
47
|
entries whose `name` is not in the comma-separated
|
|
@@ -303,6 +315,61 @@ def cmd_init_prefs(target: Path) -> None:
|
|
|
303
315
|
_ok({"exists": True, "wrote": True, "path": str(target), "first_run": True})
|
|
304
316
|
|
|
305
317
|
|
|
318
|
+
def cmd_register_qmd_collection(target: Path) -> None:
|
|
319
|
+
# Note: render_forge_tier_yaml() emits exactly the six known top-level
|
|
320
|
+
# sections (tools, tier, tier_detected_at, ccc_index, ccc_index_registry,
|
|
321
|
+
# qmd_collections). If a future schema revision adds a new top-level key,
|
|
322
|
+
# this subcommand will silently drop it — same limitation that already
|
|
323
|
+
# affects cmd_write_tools and cmd_clean_stale. Update render_forge_tier_yaml()
|
|
324
|
+
# AND those three subcommands together when the schema grows.
|
|
325
|
+
raw = sys.stdin.read()
|
|
326
|
+
if not raw.strip():
|
|
327
|
+
_die(1, "register-qmd-collection: empty stdin (expected JSON entry)")
|
|
328
|
+
try:
|
|
329
|
+
entry = json.loads(raw)
|
|
330
|
+
except json.JSONDecodeError as e:
|
|
331
|
+
_die(1, f"register-qmd-collection: invalid JSON on stdin: {e}")
|
|
332
|
+
|
|
333
|
+
if not isinstance(entry, dict):
|
|
334
|
+
_die(1, "register-qmd-collection: entry must be a JSON object")
|
|
335
|
+
name = entry.get("name")
|
|
336
|
+
if not name or not isinstance(name, str):
|
|
337
|
+
_die(1, "register-qmd-collection: entry must include a non-empty 'name' string")
|
|
338
|
+
|
|
339
|
+
data = _read_yaml(target)
|
|
340
|
+
if data is None:
|
|
341
|
+
_die(1, f"register-qmd-collection: target does not exist: {target}. "
|
|
342
|
+
f"Run setup workflow first to create forge-tier.yaml.")
|
|
343
|
+
|
|
344
|
+
collections = list(data.get("qmd_collections") or [])
|
|
345
|
+
replaced = False
|
|
346
|
+
for i, existing in enumerate(collections):
|
|
347
|
+
if isinstance(existing, dict) and existing.get("name") == name:
|
|
348
|
+
collections[i] = entry
|
|
349
|
+
replaced = True
|
|
350
|
+
break
|
|
351
|
+
if not replaced:
|
|
352
|
+
collections.append(entry)
|
|
353
|
+
|
|
354
|
+
payload = {
|
|
355
|
+
"tools": data.get("tools", {}),
|
|
356
|
+
"tier": data.get("tier", "Quick"),
|
|
357
|
+
"tier_detected_at": data.get("tier_detected_at",
|
|
358
|
+
datetime.now(timezone.utc).isoformat()),
|
|
359
|
+
"ccc_index": data.get("ccc_index", {}),
|
|
360
|
+
"ccc_index_registry": data.get("ccc_index_registry", []),
|
|
361
|
+
"qmd_collections": collections,
|
|
362
|
+
}
|
|
363
|
+
rendered = render_forge_tier_yaml(payload)
|
|
364
|
+
_atomic_write(target, rendered)
|
|
365
|
+
_ok({
|
|
366
|
+
"name": name,
|
|
367
|
+
"action": "replaced" if replaced else "appended",
|
|
368
|
+
"qmd_collections_count": len(collections),
|
|
369
|
+
"wrote": str(target),
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
|
|
306
373
|
def cmd_clean_stale(target: Path, qmd_live_names: list[str] | None,
|
|
307
374
|
prune_missing_ccc_paths: bool) -> None:
|
|
308
375
|
data = _read_yaml(target)
|
|
@@ -394,6 +461,10 @@ def main() -> None:
|
|
|
394
461
|
p_clean.add_argument("--prune-missing-ccc-paths", action="store_true",
|
|
395
462
|
help="Remove ccc_index_registry entries whose path no longer exists.")
|
|
396
463
|
|
|
464
|
+
p_register = sub.add_parser("register-qmd-collection",
|
|
465
|
+
help="Append-or-replace a single qmd_collections entry by name")
|
|
466
|
+
p_register.add_argument("--target", type=Path, required=True)
|
|
467
|
+
|
|
397
468
|
args = parser.parse_args()
|
|
398
469
|
|
|
399
470
|
if args.cmd == "read":
|
|
@@ -407,6 +478,8 @@ def main() -> None:
|
|
|
407
478
|
if args.qmd_live_names is not None:
|
|
408
479
|
live = [n.strip() for n in args.qmd_live_names.split(",") if n.strip()]
|
|
409
480
|
cmd_clean_stale(args.target, live, args.prune_missing_ccc_paths)
|
|
481
|
+
elif args.cmd == "register-qmd-collection":
|
|
482
|
+
cmd_register_qmd_collection(args.target)
|
|
410
483
|
|
|
411
484
|
|
|
412
485
|
if __name__ == "__main__":
|