bmad-module-skill-forge 1.7.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/schemas/skill-brief.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-forge-tier-rw.py +73 -0
- 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-frontmatter.py +56 -5
- package/src/shared/scripts/skf-validate-pins.py +368 -0
- package/src/skf-analyze-source/SKILL.md +26 -20
- package/src/skf-analyze-source/assets/skill-brief-schema.md +2 -1
- package/src/skf-analyze-source/references/identify-units.md +41 -4
- package/src/skf-analyze-source/references/init.md +36 -0
- package/src/skf-analyze-source/references/scan-project.md +5 -2
- 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-analyze-source/references/unit-detection-heuristics.md +16 -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/generate-artifacts.md +5 -10
- 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-create-stack-skill/references/generate-output.md +5 -5
- 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/source-access-protocol.md +14 -0
- 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:]))
|
|
@@ -21,6 +21,7 @@ system-wide:
|
|
|
21
21
|
uv run skf-validate-frontmatter.py <skill-md-path>
|
|
22
22
|
uv run skf-validate-frontmatter.py <skill-md-path> --skill-dir-name <name>
|
|
23
23
|
uv run skf-validate-frontmatter.py <skill-md-path> --max-body-lines 500
|
|
24
|
+
uv run skf-validate-frontmatter.py <skill-md-path> --max-body-lines 500 --max-body-tokens 5000
|
|
24
25
|
|
|
25
26
|
Input:
|
|
26
27
|
Path to a SKILL.md file.
|
|
@@ -31,6 +32,11 @@ Input:
|
|
|
31
32
|
exceeds N lines. Opt-in — when omitted, body size is never checked and
|
|
32
33
|
the verdict is unchanged. Callers pass the skill-check `body.max_lines`
|
|
33
34
|
default (500) to pre-catch that hard reject before commit.
|
|
35
|
+
Optional --max-body-tokens N: when set, emit a high-severity `body`
|
|
36
|
+
issue if the estimated body token count exceeds N. Token count is
|
|
37
|
+
estimated as ceil(character_count / 4). Callers pass the skill-check
|
|
38
|
+
`body.max_tokens` default (5000) to pre-catch that soft/hard reject
|
|
39
|
+
before commit.
|
|
34
40
|
|
|
35
41
|
Output:
|
|
36
42
|
JSON object:
|
|
@@ -154,6 +160,28 @@ def body_line_count(content: str) -> int | None:
|
|
|
154
160
|
return len(lines) - (closing + 1)
|
|
155
161
|
|
|
156
162
|
|
|
163
|
+
def body_token_estimate(content: str) -> int | None:
|
|
164
|
+
"""Estimate the SKILL.md body token count as ceil(char_count / 4).
|
|
165
|
+
|
|
166
|
+
Returns None when the frontmatter delimiters are absent or unclosed
|
|
167
|
+
(same boundary condition as body_line_count). The heuristic matches
|
|
168
|
+
the agentskills.io spec's token estimation convention.
|
|
169
|
+
"""
|
|
170
|
+
if not content.startswith("---\n") and not content.startswith("---\r\n"):
|
|
171
|
+
return None
|
|
172
|
+
lines = content.splitlines()
|
|
173
|
+
closing = -1
|
|
174
|
+
for i in range(1, len(lines)):
|
|
175
|
+
if lines[i] == "---":
|
|
176
|
+
closing = i
|
|
177
|
+
break
|
|
178
|
+
if closing == -1:
|
|
179
|
+
return None
|
|
180
|
+
body = "\n".join(lines[closing + 1:])
|
|
181
|
+
char_count = len(body)
|
|
182
|
+
return -(-char_count // 4) # ceil division
|
|
183
|
+
|
|
184
|
+
|
|
157
185
|
def _validate_name(name: str, skill_dir_name: str | None) -> list[dict]:
|
|
158
186
|
"""Validate skill name format. Aligned with canonical validator.py."""
|
|
159
187
|
issues: list[dict] = []
|
|
@@ -220,14 +248,16 @@ def validate_frontmatter(
|
|
|
220
248
|
content: str,
|
|
221
249
|
skill_dir_name: str | None = None,
|
|
222
250
|
max_body_lines: int | None = None,
|
|
251
|
+
max_body_tokens: int | None = None,
|
|
223
252
|
) -> dict:
|
|
224
253
|
"""Validate SKILL.md frontmatter against agentskills.io spec.
|
|
225
254
|
|
|
226
255
|
When `max_body_lines` is set, additionally emit a high-severity `body`
|
|
227
|
-
issue if the body exceeds that many lines
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
256
|
+
issue if the body exceeds that many lines. When `max_body_tokens` is
|
|
257
|
+
set, emit a high-severity `body` issue if the estimated token count
|
|
258
|
+
exceeds that limit. Both are opt-in — the verdict is unchanged when
|
|
259
|
+
they are None. Returns a result dict with status, issues, frontmatter,
|
|
260
|
+
body_lines, body_tokens, and summary.
|
|
231
261
|
"""
|
|
232
262
|
fm, issues = parse_frontmatter(content)
|
|
233
263
|
|
|
@@ -239,6 +269,14 @@ def validate_frontmatter(
|
|
|
239
269
|
"message": f"body lines {body_lines} exceeds max {max_body_lines}",
|
|
240
270
|
})
|
|
241
271
|
|
|
272
|
+
body_tokens = body_token_estimate(content)
|
|
273
|
+
if max_body_tokens is not None and body_tokens is not None and body_tokens > max_body_tokens:
|
|
274
|
+
issues.append({
|
|
275
|
+
"severity": "high",
|
|
276
|
+
"field": "body",
|
|
277
|
+
"message": f"body token estimate {body_tokens} exceeds max {max_body_tokens}",
|
|
278
|
+
})
|
|
279
|
+
|
|
242
280
|
if fm is not None:
|
|
243
281
|
# Name validation
|
|
244
282
|
name = fm.get("name", "")
|
|
@@ -301,6 +339,7 @@ def validate_frontmatter(
|
|
|
301
339
|
"issues": issues,
|
|
302
340
|
"frontmatter": fm,
|
|
303
341
|
"body_lines": body_lines,
|
|
342
|
+
"body_tokens": body_tokens,
|
|
304
343
|
"summary": {
|
|
305
344
|
"total": len(issues),
|
|
306
345
|
**severity_counts,
|
|
@@ -345,6 +384,18 @@ def main() -> int:
|
|
|
345
384
|
"the skill-check body.max_lines default (500) to pre-catch that reject."
|
|
346
385
|
),
|
|
347
386
|
)
|
|
387
|
+
parser.add_argument(
|
|
388
|
+
"--max-body-tokens",
|
|
389
|
+
metavar="N",
|
|
390
|
+
type=int,
|
|
391
|
+
default=None,
|
|
392
|
+
help=(
|
|
393
|
+
"when set, emit a high-severity 'body' issue if the estimated body "
|
|
394
|
+
"token count exceeds N (opt-in; omitted = body tokens not checked). "
|
|
395
|
+
"Token estimate uses ceil(char_count / 4). Pass the skill-check "
|
|
396
|
+
"body.max_tokens default (5000) to pre-catch that reject."
|
|
397
|
+
),
|
|
398
|
+
)
|
|
348
399
|
parser.add_argument(
|
|
349
400
|
"-o", "--output",
|
|
350
401
|
metavar="FILE",
|
|
@@ -365,7 +416,7 @@ def main() -> int:
|
|
|
365
416
|
else:
|
|
366
417
|
content = skill_md_path.read_text(encoding="utf-8")
|
|
367
418
|
skill_dir_name = args.skill_dir_name or skill_md_path.parent.name
|
|
368
|
-
result = validate_frontmatter(content, skill_dir_name, args.max_body_lines)
|
|
419
|
+
result = validate_frontmatter(content, skill_dir_name, args.max_body_lines, args.max_body_tokens)
|
|
369
420
|
|
|
370
421
|
output_text = json.dumps(result, indent=2)
|
|
371
422
|
|