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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +6 -4
- package/docs/_data/pinned.yaml +1 -1
- package/docs/bmad-synergy.md +2 -2
- package/docs/getting-started.md +1 -1
- package/docs/skill-model.md +26 -32
- package/docs/troubleshooting.md +13 -1
- package/docs/why-skf.md +5 -4
- package/docs/workflows.md +53 -0
- package/package.json +2 -2
- package/src/knowledge/ccc-bridge.md +1 -1
- package/src/shared/references/output-contract-schema.md +10 -0
- package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +134 -0
- package/src/shared/scripts/skf-detect-tools.py +359 -0
- package/src/shared/scripts/skf-emit-result-envelope.py +419 -0
- package/src/shared/scripts/skf-extract-public-api.py +505 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +413 -0
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +324 -0
- package/src/shared/scripts/skf-preflight.py +14 -4
- package/src/shared/scripts/skf-qmd-classify-collections.py +212 -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-frontmatter.py +9 -3
- package/src/shared/scripts/skf-validate-output.py +24 -7
- package/src/skf-create-skill/steps-c/step-06-validate.md +1 -1
- 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-setup/SKILL.md +25 -10
- package/src/skf-setup/references/tier-rules.md +2 -2
- package/src/skf-setup/steps-c/step-01-detect-and-tier.md +58 -71
- package/src/skf-setup/steps-c/step-01b-ccc-index.md +49 -66
- package/src/skf-setup/steps-c/step-02-write-config.md +59 -86
- package/src/skf-setup/steps-c/step-03-auto-index.md +73 -91
- package/src/skf-setup/steps-c/step-04-report.md +135 -11
- package/src/skf-setup/steps-c/step-05-health-check.md +4 -2
- package/src/skf-test-skill/steps-c/step-01-init.md +4 -4
- 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:]))
|