bmad-module-skill-forge 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/docs/_data/pinned.yaml +1 -1
- package/docs/bmad-synergy.md +16 -0
- package/docs/deepwiki.md +89 -0
- package/docs/verifying-a-skill.md +8 -2
- package/docs/workflows.md +17 -11
- package/package.json +2 -2
- package/src/shared/_known-workarounds.yaml +155 -0
- package/src/shared/references/pipeline-contracts.md +11 -2
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +6 -1
- package/src/shared/scripts/skf-detect-docs.py +412 -0
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +12 -1
- package/src/shared/scripts/skf-preapply.py +192 -0
- package/src/shared/scripts/skf-shape-detect.py +563 -0
- package/src/shared/scripts/skf-validate-pins.py +368 -0
- package/src/skf-analyze-source/SKILL.md +26 -20
- package/src/skf-analyze-source/references/init.md +30 -0
- package/src/skf-analyze-source/references/step-auto-scope.md +525 -0
- package/src/skf-analyze-source/references/step-shape-detect.md +79 -0
- package/src/skf-audit-skill/SKILL.md +1 -0
- package/src/skf-audit-skill/assets/drift-report-template.md +6 -0
- package/src/skf-audit-skill/references/report.md +4 -0
- package/src/skf-audit-skill/references/severity-classify.md +1 -1
- package/src/skf-audit-skill/references/step-doc-drift.md +147 -0
- package/src/skf-brief-skill/SKILL.md +7 -3
- package/src/skf-brief-skill/references/gather-intent.md +14 -0
- package/src/skf-brief-skill/references/step-auto-brief.md +171 -0
- package/src/skf-brief-skill/references/step-auto-validate.md +209 -0
- package/src/skf-create-skill/SKILL.md +3 -0
- package/src/skf-create-skill/assets/skill-sections.md +2 -0
- package/src/skf-create-skill/references/compile.md +1 -1
- package/src/skf-create-skill/references/step-auto-shard.md +110 -0
- package/src/skf-create-skill/references/step-doc-rot.md +109 -0
- package/src/skf-create-skill/references/step-doc-sources.md +114 -0
- package/src/skf-forger/SKILL.md +8 -2
- package/src/skf-test-skill/SKILL.md +8 -7
- package/src/skf-test-skill/customize.toml +2 -1
- package/src/skf-test-skill/references/external-validators.md +1 -1
- package/src/skf-test-skill/references/init.md +16 -1
- package/src/skf-test-skill/references/report.md +5 -1
- package/src/skf-test-skill/references/score.md +95 -6
- package/src/skf-test-skill/references/step-hard-gate.md +73 -0
- package/src/skf-test-skill/templates/test-report-template.md +1 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.9"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Shape Detect — classify repos into known skill shapes from manifest files.
|
|
6
|
+
|
|
7
|
+
Single source of truth for shape-level classification consumed by
|
|
8
|
+
skf-analyze-source (AN auto-scope), skf-brief-skill (BS auto-brief),
|
|
9
|
+
and skf-test-skill (TS threshold selection). Moving shape heuristics
|
|
10
|
+
into a shared script eliminates duplicate classification logic across
|
|
11
|
+
three pipelines.
|
|
12
|
+
|
|
13
|
+
The five-shape heuristic ladder (apply in order, first match wins):
|
|
14
|
+
|
|
15
|
+
1. language-reference — parser/grammar/language-toolchain project
|
|
16
|
+
Signals: parser-related deps (pest, antlr4, tree-sitter, lark ...)
|
|
17
|
+
2. stack-compose — multi-ecosystem composite project
|
|
18
|
+
Signals: manifests from 2+ distinct ecosystems
|
|
19
|
+
3. reference-app — application, CLI, or demo project
|
|
20
|
+
Signals: npm bin field, Rust [[bin]], framework deps
|
|
21
|
+
4. library-API — library exposing a programmatic API
|
|
22
|
+
Signals: main/module/exports fields, [lib] target, export count
|
|
23
|
+
5. unknown — no heuristic matched
|
|
24
|
+
|
|
25
|
+
CLI:
|
|
26
|
+
uv run python src/shared/scripts/skf-shape-detect.py \\
|
|
27
|
+
--repo-url <url> --manifests <path1,path2,...>
|
|
28
|
+
|
|
29
|
+
Input:
|
|
30
|
+
--repo-url repository URL (required; context only, no cloning)
|
|
31
|
+
--manifests comma-separated local file paths to manifest files (required)
|
|
32
|
+
|
|
33
|
+
Output (JSON on stdout):
|
|
34
|
+
shape library-API | reference-app | language-reference
|
|
35
|
+
| stack-compose | unknown
|
|
36
|
+
signals array of human-readable evidence strings
|
|
37
|
+
confidence float 0.0-1.0
|
|
38
|
+
export_count integer (total public-facing exports)
|
|
39
|
+
package_count integer (distinct packages detected)
|
|
40
|
+
|
|
41
|
+
Exit codes:
|
|
42
|
+
0 shape classified (not unknown)
|
|
43
|
+
1 unknown shape (no heuristic matched)
|
|
44
|
+
2 error (invalid args, missing/unreadable files, parse failure)
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import argparse
|
|
50
|
+
import json
|
|
51
|
+
import sys
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
from typing import Any
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
import tomllib
|
|
57
|
+
except ImportError:
|
|
58
|
+
tomllib = None # type: ignore[assignment]
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Parser/grammar deps that signal language-reference shape
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
_PARSER_DEPS_NPM = frozenset({
|
|
65
|
+
"antlr4", "antlr4-runtime", "tree-sitter", "nearley",
|
|
66
|
+
"chevrotain", "pegjs", "peggy", "ohm-js", "jison",
|
|
67
|
+
"moo", "lezer", "@lezer/generator",
|
|
68
|
+
})
|
|
69
|
+
_PARSER_DEPS_PYTHON = frozenset({
|
|
70
|
+
"antlr4-tools", "antlr4-runtime", "lark", "lark-parser",
|
|
71
|
+
"ply", "tree-sitter", "textx", "parso", "pyparsing",
|
|
72
|
+
"sly", "tatsu",
|
|
73
|
+
})
|
|
74
|
+
_PARSER_DEPS_RUST = frozenset({
|
|
75
|
+
"pest", "pest_derive", "lalrpop", "lalrpop-util",
|
|
76
|
+
"tree-sitter", "nom", "chumsky", "winnow", "logos",
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
_ALL_PARSER_DEPS = _PARSER_DEPS_NPM | _PARSER_DEPS_PYTHON | _PARSER_DEPS_RUST
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Framework deps that signal reference-app shape
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
_FRAMEWORK_DEPS_NPM = frozenset({
|
|
86
|
+
"next", "nuxt", "express", "fastify", "koa", "hono",
|
|
87
|
+
"@nestjs/core", "gatsby", "electron",
|
|
88
|
+
})
|
|
89
|
+
_FRAMEWORK_DEPS_PYTHON = frozenset({
|
|
90
|
+
"django", "flask", "fastapi", "uvicorn", "starlette",
|
|
91
|
+
"tornado", "aiohttp", "sanic", "streamlit", "gradio",
|
|
92
|
+
})
|
|
93
|
+
_FRAMEWORK_DEPS_RUST = frozenset({
|
|
94
|
+
"actix-web", "axum", "rocket", "warp", "tide",
|
|
95
|
+
"tauri", "dioxus", "leptos", "yew",
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
_ALL_FRAMEWORK_DEPS = _FRAMEWORK_DEPS_NPM | _FRAMEWORK_DEPS_PYTHON | _FRAMEWORK_DEPS_RUST
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# Helpers
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def _die(message: str, code: str = "INTERNAL_ERROR") -> None:
|
|
106
|
+
json.dump({"error": message, "code": code}, sys.stderr, ensure_ascii=False)
|
|
107
|
+
sys.stderr.write("\n")
|
|
108
|
+
sys.exit(2)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _clamp(value: float, lo: float, hi: float) -> float:
|
|
112
|
+
return max(lo, min(hi, value))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Minimal TOML parser (Python < 3.11 fallback)
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def _split_preserving_nesting(s: str, sep: str) -> list[str]:
|
|
120
|
+
parts: list[str] = []
|
|
121
|
+
buf: list[str] = []
|
|
122
|
+
depth = 0
|
|
123
|
+
in_str = ""
|
|
124
|
+
for ch in s:
|
|
125
|
+
if in_str:
|
|
126
|
+
buf.append(ch)
|
|
127
|
+
if ch == in_str:
|
|
128
|
+
in_str = ""
|
|
129
|
+
continue
|
|
130
|
+
if ch in ('"', "'"):
|
|
131
|
+
in_str = ch
|
|
132
|
+
buf.append(ch)
|
|
133
|
+
continue
|
|
134
|
+
if ch in ("[", "{"):
|
|
135
|
+
depth += 1
|
|
136
|
+
buf.append(ch)
|
|
137
|
+
continue
|
|
138
|
+
if ch in ("]", "}"):
|
|
139
|
+
depth -= 1
|
|
140
|
+
buf.append(ch)
|
|
141
|
+
continue
|
|
142
|
+
if ch == sep and depth == 0:
|
|
143
|
+
parts.append("".join(buf))
|
|
144
|
+
buf = []
|
|
145
|
+
continue
|
|
146
|
+
buf.append(ch)
|
|
147
|
+
if buf:
|
|
148
|
+
parts.append("".join(buf))
|
|
149
|
+
return parts
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _decode_toml_value(raw: str) -> Any:
|
|
153
|
+
s = raw.strip()
|
|
154
|
+
if not s:
|
|
155
|
+
return ""
|
|
156
|
+
if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
|
|
157
|
+
return s[1:-1]
|
|
158
|
+
if len(s) >= 2 and s[0] == "'" and s[-1] == "'":
|
|
159
|
+
return s[1:-1]
|
|
160
|
+
if s == "true":
|
|
161
|
+
return True
|
|
162
|
+
if s == "false":
|
|
163
|
+
return False
|
|
164
|
+
if s.startswith("["):
|
|
165
|
+
idx = s.rfind("]")
|
|
166
|
+
if idx < 0:
|
|
167
|
+
return []
|
|
168
|
+
inner = s[1:idx].strip()
|
|
169
|
+
if not inner:
|
|
170
|
+
return []
|
|
171
|
+
items = []
|
|
172
|
+
for item in _split_preserving_nesting(inner, ","):
|
|
173
|
+
item = item.strip()
|
|
174
|
+
if item and item[0] != "#":
|
|
175
|
+
items.append(_decode_toml_value(item))
|
|
176
|
+
return items
|
|
177
|
+
if s.startswith("{"):
|
|
178
|
+
idx = s.rfind("}")
|
|
179
|
+
if idx < 0:
|
|
180
|
+
return {}
|
|
181
|
+
inner = s[1:idx].strip()
|
|
182
|
+
result: dict[str, Any] = {}
|
|
183
|
+
for pair in _split_preserving_nesting(inner, ","):
|
|
184
|
+
pair = pair.strip()
|
|
185
|
+
if "=" in pair:
|
|
186
|
+
k, _, v = pair.partition("=")
|
|
187
|
+
result[k.strip()] = _decode_toml_value(v.strip())
|
|
188
|
+
return result
|
|
189
|
+
try:
|
|
190
|
+
return int(s) if "." not in s else float(s)
|
|
191
|
+
except ValueError:
|
|
192
|
+
return s
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _loads_toml_fallback(content: str) -> dict[str, Any]:
|
|
196
|
+
"""Parse the TOML subset found in pyproject.toml / Cargo.toml."""
|
|
197
|
+
root: dict[str, Any] = {}
|
|
198
|
+
current = root
|
|
199
|
+
lines = content.split("\n")
|
|
200
|
+
i = 0
|
|
201
|
+
while i < len(lines):
|
|
202
|
+
raw = lines[i]
|
|
203
|
+
i += 1
|
|
204
|
+
stripped = raw.strip()
|
|
205
|
+
if not stripped or stripped[0] == "#":
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
# [[array.of.tables]]
|
|
209
|
+
if stripped.startswith("[["):
|
|
210
|
+
end = stripped.find("]]")
|
|
211
|
+
if end < 0:
|
|
212
|
+
continue
|
|
213
|
+
path = [p.strip() for p in stripped[2:end].split(".")]
|
|
214
|
+
target = root
|
|
215
|
+
for key in path[:-1]:
|
|
216
|
+
target = target.setdefault(key, {})
|
|
217
|
+
arr = target.setdefault(path[-1], [])
|
|
218
|
+
if not isinstance(arr, list):
|
|
219
|
+
arr = [arr]
|
|
220
|
+
target[path[-1]] = arr
|
|
221
|
+
entry: dict[str, Any] = {}
|
|
222
|
+
arr.append(entry)
|
|
223
|
+
current = entry
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
# [table]
|
|
227
|
+
if stripped.startswith("[") and not stripped.startswith("[["):
|
|
228
|
+
end = stripped.find("]")
|
|
229
|
+
if end < 0:
|
|
230
|
+
continue
|
|
231
|
+
path = [p.strip() for p in stripped[1:end].split(".")]
|
|
232
|
+
current = root
|
|
233
|
+
for key in path:
|
|
234
|
+
nxt = current.setdefault(key, {})
|
|
235
|
+
if not isinstance(nxt, dict):
|
|
236
|
+
nxt = {}
|
|
237
|
+
current[key] = nxt
|
|
238
|
+
current = nxt
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
# key = value
|
|
242
|
+
eq = stripped.find("=")
|
|
243
|
+
if eq < 0:
|
|
244
|
+
continue
|
|
245
|
+
key = stripped[:eq].strip().strip('"')
|
|
246
|
+
val_str = stripped[eq + 1:].strip()
|
|
247
|
+
|
|
248
|
+
# Remove trailing comment outside strings
|
|
249
|
+
if val_str and val_str[0] not in ('"', "'", "[", "{"):
|
|
250
|
+
ci = val_str.find(" #")
|
|
251
|
+
if ci >= 0:
|
|
252
|
+
val_str = val_str[:ci].strip()
|
|
253
|
+
|
|
254
|
+
# Multi-line array
|
|
255
|
+
if val_str.startswith("[") and "]" not in val_str:
|
|
256
|
+
while i < len(lines):
|
|
257
|
+
val_str += " " + lines[i].strip()
|
|
258
|
+
i += 1
|
|
259
|
+
if "]" in val_str:
|
|
260
|
+
break
|
|
261
|
+
|
|
262
|
+
current[key] = _decode_toml_value(val_str)
|
|
263
|
+
return root
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _parse_toml(content: str) -> dict[str, Any]:
|
|
267
|
+
if tomllib is not None:
|
|
268
|
+
return tomllib.loads(content)
|
|
269
|
+
return _loads_toml_fallback(content)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
# Manifest parsers — each returns a normalised dict
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
def _parse_package_json(path: Path) -> dict[str, Any]:
|
|
277
|
+
try:
|
|
278
|
+
content = path.read_text(encoding="utf-8")
|
|
279
|
+
data = json.loads(content)
|
|
280
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
281
|
+
_die(f"Cannot parse {path.as_posix()}: {exc}", "MANIFEST_PARSE_ERROR")
|
|
282
|
+
return {} # unreachable
|
|
283
|
+
|
|
284
|
+
if not isinstance(data, dict):
|
|
285
|
+
_die(f"Expected JSON object in {path.as_posix()}, got {type(data).__name__}", "MANIFEST_PARSE_ERROR")
|
|
286
|
+
return {} # unreachable
|
|
287
|
+
|
|
288
|
+
deps: set[str] = set()
|
|
289
|
+
for key in ("dependencies", "devDependencies", "peerDependencies"):
|
|
290
|
+
section = data.get(key)
|
|
291
|
+
if isinstance(section, dict):
|
|
292
|
+
deps.update(section)
|
|
293
|
+
|
|
294
|
+
exports_field = data.get("exports")
|
|
295
|
+
export_count = 0
|
|
296
|
+
if isinstance(exports_field, dict):
|
|
297
|
+
export_count = len(exports_field)
|
|
298
|
+
elif isinstance(exports_field, str):
|
|
299
|
+
export_count = 1
|
|
300
|
+
elif data.get("main") or data.get("module"):
|
|
301
|
+
export_count = 1
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
"ecosystem": "npm",
|
|
305
|
+
"name": data.get("name", ""),
|
|
306
|
+
"deps": deps,
|
|
307
|
+
"has_bin": bool(data.get("bin")),
|
|
308
|
+
"has_library_structure": bool(
|
|
309
|
+
data.get("main") or data.get("module") or exports_field
|
|
310
|
+
),
|
|
311
|
+
"export_count": export_count,
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _dep_name_from_pep508(spec: str) -> str:
|
|
316
|
+
"""Extract package name from a PEP 508 dependency string."""
|
|
317
|
+
for ch in (">", "<", "=", "!", "[", ";", " "):
|
|
318
|
+
spec = spec.split(ch, 1)[0]
|
|
319
|
+
return spec.strip().lower()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _parse_pyproject_toml(path: Path) -> dict[str, Any]:
|
|
323
|
+
try:
|
|
324
|
+
content = path.read_text(encoding="utf-8")
|
|
325
|
+
data = _parse_toml(content)
|
|
326
|
+
except OSError as exc:
|
|
327
|
+
_die(f"Cannot read {path.as_posix()}: {exc}", "MANIFEST_READ_ERROR")
|
|
328
|
+
return {}
|
|
329
|
+
except Exception as exc:
|
|
330
|
+
_die(f"Cannot parse {path.as_posix()}: {exc}", "MANIFEST_PARSE_ERROR")
|
|
331
|
+
return {}
|
|
332
|
+
|
|
333
|
+
project = data.get("project", {})
|
|
334
|
+
if not isinstance(project, dict):
|
|
335
|
+
project = {}
|
|
336
|
+
|
|
337
|
+
deps: set[str] = set()
|
|
338
|
+
for raw in project.get("dependencies", []):
|
|
339
|
+
if isinstance(raw, str):
|
|
340
|
+
name = _dep_name_from_pep508(raw)
|
|
341
|
+
if name:
|
|
342
|
+
deps.add(name)
|
|
343
|
+
poetry_deps = (
|
|
344
|
+
data.get("tool", {}).get("poetry", {}).get("dependencies", {})
|
|
345
|
+
)
|
|
346
|
+
if isinstance(poetry_deps, dict):
|
|
347
|
+
deps.update(k.lower() for k in poetry_deps if k.lower() != "python")
|
|
348
|
+
|
|
349
|
+
scripts = project.get("scripts", {})
|
|
350
|
+
gui_scripts = project.get("gui-scripts", {})
|
|
351
|
+
if not isinstance(scripts, dict):
|
|
352
|
+
scripts = {}
|
|
353
|
+
if not isinstance(gui_scripts, dict):
|
|
354
|
+
gui_scripts = {}
|
|
355
|
+
export_count = len(scripts) + len(gui_scripts)
|
|
356
|
+
if export_count == 0 and project.get("name"):
|
|
357
|
+
export_count = 1
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
"ecosystem": "python",
|
|
361
|
+
"name": project.get("name", ""),
|
|
362
|
+
"deps": deps,
|
|
363
|
+
"has_bin": False,
|
|
364
|
+
"has_library_structure": bool(project.get("name")),
|
|
365
|
+
"export_count": export_count,
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _parse_cargo_toml(path: Path) -> dict[str, Any]:
|
|
370
|
+
try:
|
|
371
|
+
content = path.read_text(encoding="utf-8")
|
|
372
|
+
data = _parse_toml(content)
|
|
373
|
+
except OSError as exc:
|
|
374
|
+
_die(f"Cannot read {path.as_posix()}: {exc}", "MANIFEST_READ_ERROR")
|
|
375
|
+
return {}
|
|
376
|
+
except Exception as exc:
|
|
377
|
+
_die(f"Cannot parse {path.as_posix()}: {exc}", "MANIFEST_PARSE_ERROR")
|
|
378
|
+
return {}
|
|
379
|
+
|
|
380
|
+
pkg = data.get("package", {})
|
|
381
|
+
if not isinstance(pkg, dict):
|
|
382
|
+
pkg = {}
|
|
383
|
+
|
|
384
|
+
deps: set[str] = set()
|
|
385
|
+
for dep_key in ("dependencies", "dev-dependencies", "build-dependencies"):
|
|
386
|
+
section = data.get(dep_key, {})
|
|
387
|
+
if isinstance(section, dict):
|
|
388
|
+
deps.update(k.lower() for k in section)
|
|
389
|
+
|
|
390
|
+
has_lib = "lib" in data and isinstance(data["lib"], dict)
|
|
391
|
+
bin_targets = data.get("bin", [])
|
|
392
|
+
if not isinstance(bin_targets, list):
|
|
393
|
+
bin_targets = []
|
|
394
|
+
has_bin = len(bin_targets) > 0
|
|
395
|
+
|
|
396
|
+
export_count = (1 if has_lib else 0) + len(bin_targets)
|
|
397
|
+
if export_count == 0 and pkg.get("name"):
|
|
398
|
+
export_count = 1
|
|
399
|
+
|
|
400
|
+
workspace_members = data.get("workspace", {}).get("members", [])
|
|
401
|
+
if not isinstance(workspace_members, list):
|
|
402
|
+
workspace_members = []
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
"ecosystem": "rust",
|
|
406
|
+
"name": pkg.get("name", ""),
|
|
407
|
+
"deps": deps,
|
|
408
|
+
"has_bin": has_bin,
|
|
409
|
+
"has_library_structure": has_lib or bool(pkg.get("name")),
|
|
410
|
+
"export_count": export_count,
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
_PARSERS = {
|
|
415
|
+
"package.json": _parse_package_json,
|
|
416
|
+
"pyproject.toml": _parse_pyproject_toml,
|
|
417
|
+
"Cargo.toml": _parse_cargo_toml,
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _parse_manifest(path: Path) -> dict[str, Any]:
|
|
422
|
+
parser = _PARSERS.get(path.name)
|
|
423
|
+
if parser is None:
|
|
424
|
+
_die(f"Unsupported manifest type: {path.name}", "UNSUPPORTED_MANIFEST")
|
|
425
|
+
return parser(path)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# ---------------------------------------------------------------------------
|
|
429
|
+
# Core classification
|
|
430
|
+
# ---------------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
def detect(repo_url: str, manifest_paths: list[str]) -> dict[str, Any]:
|
|
433
|
+
"""Classify a repo into a skill shape from its manifest files."""
|
|
434
|
+
if not manifest_paths:
|
|
435
|
+
_die("--manifests requires at least one path", "MISSING_MANIFESTS")
|
|
436
|
+
|
|
437
|
+
parsed: list[dict[str, Any]] = []
|
|
438
|
+
for mp in manifest_paths:
|
|
439
|
+
p = Path(mp)
|
|
440
|
+
if not p.is_file():
|
|
441
|
+
_die(f"Manifest not found: {p.as_posix()}", "MANIFEST_NOT_FOUND")
|
|
442
|
+
parsed.append(_parse_manifest(p))
|
|
443
|
+
|
|
444
|
+
all_deps: set[str] = set()
|
|
445
|
+
ecosystems: set[str] = set()
|
|
446
|
+
total_exports = 0
|
|
447
|
+
signals: list[str] = []
|
|
448
|
+
has_bin = False
|
|
449
|
+
has_library_structure = False
|
|
450
|
+
|
|
451
|
+
for m in parsed:
|
|
452
|
+
eco = m["ecosystem"]
|
|
453
|
+
ecosystems.add(eco)
|
|
454
|
+
signals.append(f"has_{path_to_manifest_name(eco)}")
|
|
455
|
+
all_deps.update(m.get("deps", set()))
|
|
456
|
+
total_exports += m.get("export_count", 0)
|
|
457
|
+
if m.get("has_bin"):
|
|
458
|
+
has_bin = True
|
|
459
|
+
if m.get("has_library_structure"):
|
|
460
|
+
has_library_structure = True
|
|
461
|
+
|
|
462
|
+
package_count = len(parsed)
|
|
463
|
+
|
|
464
|
+
if total_exports > 50:
|
|
465
|
+
signals.append("exports_count_gt_50")
|
|
466
|
+
if has_bin:
|
|
467
|
+
signals.append("has_bin_field")
|
|
468
|
+
elif has_library_structure:
|
|
469
|
+
signals.append("no_bin_field")
|
|
470
|
+
if has_library_structure:
|
|
471
|
+
signals.append("has_library_structure")
|
|
472
|
+
|
|
473
|
+
# Collect dep-category matches
|
|
474
|
+
parser_deps = sorted(d for d in all_deps if d.lower() in _ALL_PARSER_DEPS)
|
|
475
|
+
framework_deps = sorted(d for d in all_deps if d.lower() in _ALL_FRAMEWORK_DEPS)
|
|
476
|
+
has_framework = len(framework_deps) > 0
|
|
477
|
+
|
|
478
|
+
for d in parser_deps:
|
|
479
|
+
signals.append(f"parser_dep:{d}")
|
|
480
|
+
for d in framework_deps:
|
|
481
|
+
signals.append(f"framework_dep:{d}")
|
|
482
|
+
if len(ecosystems) > 1:
|
|
483
|
+
signals.append("multiple_ecosystems")
|
|
484
|
+
for eco in sorted(ecosystems):
|
|
485
|
+
signals.append(f"ecosystem:{eco}")
|
|
486
|
+
|
|
487
|
+
result_base = {
|
|
488
|
+
"export_count": total_exports,
|
|
489
|
+
"package_count": package_count,
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
# --- Heuristic ladder (first match wins) ---
|
|
493
|
+
|
|
494
|
+
# 1. language-reference
|
|
495
|
+
if parser_deps:
|
|
496
|
+
confidence = _clamp(0.75 + len(parser_deps) * 0.05, 0.75, 0.85)
|
|
497
|
+
return {"shape": "language-reference", "signals": signals,
|
|
498
|
+
"confidence": round(confidence, 2), **result_base}
|
|
499
|
+
|
|
500
|
+
# 2. stack-compose
|
|
501
|
+
if len(ecosystems) > 1:
|
|
502
|
+
confidence = _clamp(0.80 + (len(ecosystems) - 2) * 0.05, 0.80, 0.90)
|
|
503
|
+
return {"shape": "stack-compose", "signals": signals,
|
|
504
|
+
"confidence": round(confidence, 2), **result_base}
|
|
505
|
+
|
|
506
|
+
# 3. reference-app
|
|
507
|
+
if has_bin or has_framework:
|
|
508
|
+
strength = (1 if has_bin else 0) + (1 if has_framework else 0)
|
|
509
|
+
confidence = _clamp(0.80 + (strength - 1) * 0.05, 0.80, 0.90)
|
|
510
|
+
return {"shape": "reference-app", "signals": signals,
|
|
511
|
+
"confidence": round(confidence, 2), **result_base}
|
|
512
|
+
|
|
513
|
+
# 4. library-API
|
|
514
|
+
if has_library_structure or total_exports > 0:
|
|
515
|
+
base = 0.65
|
|
516
|
+
if has_library_structure:
|
|
517
|
+
base = 0.80
|
|
518
|
+
if total_exports > 50:
|
|
519
|
+
base = max(base, 0.90)
|
|
520
|
+
elif total_exports > 10:
|
|
521
|
+
base = max(base, 0.80)
|
|
522
|
+
confidence = _clamp(base, 0.65, 0.95)
|
|
523
|
+
return {"shape": "library-API", "signals": signals,
|
|
524
|
+
"confidence": round(confidence, 2), **result_base}
|
|
525
|
+
|
|
526
|
+
# 5. unknown
|
|
527
|
+
return {"shape": "unknown", "signals": signals,
|
|
528
|
+
"confidence": 0.0, **result_base}
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def path_to_manifest_name(ecosystem: str) -> str:
|
|
532
|
+
return {"npm": "package_json", "python": "pyproject_toml",
|
|
533
|
+
"rust": "cargo_toml"}.get(ecosystem, ecosystem)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# ---------------------------------------------------------------------------
|
|
537
|
+
# CLI wiring
|
|
538
|
+
# ---------------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
def main(argv: list[str]) -> int:
|
|
541
|
+
parser = argparse.ArgumentParser(
|
|
542
|
+
description="Classify a repo into a known skill shape from its manifest files.",
|
|
543
|
+
)
|
|
544
|
+
parser.add_argument("--repo-url", required=True, help="Repository URL")
|
|
545
|
+
parser.add_argument(
|
|
546
|
+
"--manifests", required=True,
|
|
547
|
+
help="Comma-separated local file paths to manifest files",
|
|
548
|
+
)
|
|
549
|
+
args = parser.parse_args(argv)
|
|
550
|
+
|
|
551
|
+
manifest_paths = [p.strip() for p in args.manifests.split(",") if p.strip()]
|
|
552
|
+
if not manifest_paths:
|
|
553
|
+
_die("--manifests requires at least one path", "MISSING_MANIFESTS")
|
|
554
|
+
|
|
555
|
+
result = detect(args.repo_url, manifest_paths)
|
|
556
|
+
json.dump(result, sys.stdout, ensure_ascii=False)
|
|
557
|
+
sys.stdout.write("\n")
|
|
558
|
+
|
|
559
|
+
return 1 if result["shape"] == "unknown" else 0
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
if __name__ == "__main__":
|
|
563
|
+
sys.exit(main(sys.argv[1:]))
|