bmad-module-skill-forge 4.0.0 → 5.0.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 +3 -3
- package/docs/_data/pinned.yaml +19 -13
- package/docs/architecture.md +2 -2
- package/docs/concepts.md +4 -4
- package/docs/examples.md +32 -25
- package/docs/getting-started.md +3 -3
- package/docs/how-it-works.md +2 -2
- package/docs/index.md +1 -1
- package/docs/skill-model.md +41 -39
- package/docs/verifying-a-skill.md +41 -20
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +2 -2
- package/package.json +1 -1
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
- package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
- package/src/shared/scripts/skf-build-change-manifest.py +881 -168
- package/src/shared/scripts/skf-count-tokens.py +54 -10
- package/src/shared/scripts/skf-detect-workspaces.py +67 -0
- package/src/shared/scripts/skf-emit-result-envelope.py +6 -5
- package/src/shared/scripts/skf-extract-public-api.py +439 -107
- package/src/shared/scripts/skf-extraction-inventory.py +10 -6
- package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
- package/src/shared/scripts/skf-parse-gaps.py +135 -26
- package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
- package/src/shared/scripts/skf-resolve-customization.py +197 -0
- package/src/shared/scripts/skf-shard-body.py +239 -41
- package/src/shared/scripts/skf-validate-output.py +12 -3
- package/src/shared/scripts/skf-verify-provenance-completeness.py +470 -38
- package/src/shared/tool-requirements.yaml +8 -2
- package/src/skf-analyze-source/SKILL.md +2 -2
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-audit-skill/SKILL.md +2 -2
- package/src/skf-audit-skill/references/re-index.md +2 -2
- package/src/skf-brief-skill/SKILL.md +2 -2
- package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
- package/src/skf-brief-skill/references/scope-definition.md +4 -4
- package/src/skf-campaign/SKILL.md +2 -2
- package/src/skf-campaign/references/step-05-skill-loop.md +6 -1
- package/src/skf-create-skill/SKILL.md +2 -2
- package/src/skf-create-skill/assets/skill-sections.md +1 -1
- package/src/skf-create-skill/references/extract.md +2 -1
- package/src/skf-create-skill/references/extraction-patterns.md +3 -3
- package/src/skf-create-skill/references/report.md +6 -1
- package/src/skf-create-stack-skill/SKILL.md +2 -2
- package/src/skf-create-stack-skill/references/validate.md +1 -1
- package/src/skf-drop-skill/SKILL.md +2 -2
- package/src/skf-export-skill/SKILL.md +2 -2
- package/src/skf-export-skill/assets/snippet-format.md +5 -5
- package/src/skf-export-skill/references/generate-snippet.md +22 -15
- package/src/skf-export-skill/references/load-skill.md +1 -1
- package/src/skf-export-skill/references/token-report.md +29 -9
- package/src/skf-forger/SKILL.md +2 -2
- package/src/skf-quick-skill/SKILL.md +2 -2
- package/src/skf-refine-architecture/SKILL.md +6 -8
- package/src/skf-rename-skill/SKILL.md +2 -2
- package/src/skf-setup/SKILL.md +2 -2
- package/src/skf-test-skill/SKILL.md +2 -2
- package/src/skf-test-skill/assets/output-section-formats.md +6 -4
- package/src/skf-test-skill/references/coherence-check.md +4 -3
- package/src/skf-test-skill/references/coverage-check-branches.md +61 -0
- package/src/skf-test-skill/references/coverage-check-tiers.md +1 -1
- package/src/skf-test-skill/references/coverage-check.md +45 -69
- package/src/skf-test-skill/references/init.md +2 -1
- package/src/skf-test-skill/references/migration-section-rules.md +26 -14
- package/src/skf-test-skill/references/report.md +2 -2
- package/src/skf-test-skill/references/scoring-rules.md +5 -4
- package/src/skf-test-skill/references/source-access-protocol.md +6 -6
- package/src/skf-test-skill/scripts/gap-ledger.py +378 -163
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +171 -70
- package/src/skf-test-skill/scripts/locate-export-segments.py +101 -20
- package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
- package/src/skf-test-skill/scripts/score-signatures.py +329 -54
- package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
- package/src/skf-test-skill/scripts/validate-inventory.py +108 -9
- package/src/skf-test-skill/templates/test-report-template.md +1 -0
- package/src/skf-update-skill/SKILL.md +2 -2
- package/src/skf-update-skill/references/detect-changes.md +1 -1
- package/src/skf-update-skill/references/gap-driven.md +36 -21
- package/src/skf-update-skill/references/merge.md +15 -10
- package/src/skf-update-skill/references/re-extract.md +3 -1
- package/src/skf-update-skill/references/report.md +5 -4
- package/src/skf-update-skill/references/write.md +39 -18
- package/src/skf-verify-stack/SKILL.md +2 -2
- package/tools/cli/lib/tool-check.js +158 -2
|
@@ -37,9 +37,11 @@ Subcommands:
|
|
|
37
37
|
any case) and its entry-point diff can be read, each export of a
|
|
38
38
|
language family whose entry_points.by_language status is
|
|
39
39
|
`barrel` is marked `public: true` when its name and file are a
|
|
40
|
-
pair of the diff's `public`
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
pair of the diff's `public` or `other_definitions` (each other
|
|
41
|
+
in-scope definition of a public name, #830; its name, or the
|
|
42
|
+
name `local` gives it in that file) or `extraction_gaps`, or
|
|
43
|
+
when a `public` item of its name and family has no file;
|
|
44
|
+
`public: false` otherwise. A
|
|
43
45
|
`public` item with `via` namespace and a `members` list (what
|
|
44
46
|
the submodule passes on, which users reach as `pkg.sub.name`)
|
|
45
47
|
gives its members' pairs (each member's name, or its `local`
|
|
@@ -208,6 +210,7 @@ Inventory shape (the keys this helper reads or writes):
|
|
|
208
210
|
"authoritative_files_scan": {...} | null,
|
|
209
211
|
"extraction_rules": {"recipe_set", "recipes", "scope", "ast_grep_version"},
|
|
210
212
|
"public_surface": {"by_language": {...}, "public": [...],
|
|
213
|
+
"other_definitions": [...],
|
|
211
214
|
"extraction_gaps": [...]} | null,
|
|
212
215
|
"t2_annotations": [...], "t3_items": [...], "warnings": ["..."]
|
|
213
216
|
}
|
|
@@ -447,7 +450,7 @@ class _PublicSurface:
|
|
|
447
450
|
self.barrels = {family for family, status in statuses.items() if status == "barrel"}
|
|
448
451
|
self.pairs: set[tuple[str, str]] = set()
|
|
449
452
|
self.unfiled: set[tuple[object, str]] = set() # (family, name) of a public name no file defines
|
|
450
|
-
for key in ("public", "extraction_gaps"):
|
|
453
|
+
for key in ("public", "other_definitions", "extraction_gaps"):
|
|
451
454
|
for item in diff.get(key) or []:
|
|
452
455
|
if not isinstance(item, dict) or not isinstance(item.get("name"), str) or not item["name"]:
|
|
453
456
|
continue
|
|
@@ -459,8 +462,8 @@ class _PublicSurface:
|
|
|
459
462
|
if isinstance(member, dict) and isinstance(member.get("name"), str) and member["name"]:
|
|
460
463
|
self._add(family, member["name"], member.get("local"), member.get("file"), True)
|
|
461
464
|
continue
|
|
462
|
-
self._add(family, item["name"], item.get("local") if key
|
|
463
|
-
key == "public")
|
|
465
|
+
self._add(family, item["name"], item.get("local") if key != "extraction_gaps" else None,
|
|
466
|
+
item.get("file"), key == "public")
|
|
464
467
|
|
|
465
468
|
def _add(self, family: object, name: str, local: object, path: object, listed_public: bool) -> None:
|
|
466
469
|
"""The pairs one public name (or gap) gives: (name, file) and (local, file), or, with no file, its
|
|
@@ -507,6 +510,7 @@ def public_surface(extraction: object) -> tuple[_PublicSurface | None, str | Non
|
|
|
507
510
|
# The fields of an entry_point_diff item the rule reads, which the inventory's public_surface keeps for `add`, and
|
|
508
511
|
# those of each member of a namespace item.
|
|
509
512
|
SURFACE_FIELDS = {"public": ("name", "language", "file", "local", "via", "members"),
|
|
513
|
+
"other_definitions": ("name", "language", "file", "local"),
|
|
510
514
|
"extraction_gaps": ("name", "language", "file")}
|
|
511
515
|
MEMBER_FIELDS = ("name", "local", "file")
|
|
512
516
|
NOT_PUBLIC_NAMED = 10 # the names the not-public warning spells out, as update-skill's apply does
|
|
@@ -130,12 +130,12 @@ build
|
|
|
130
130
|
recipe found (the runner's `entry_point_diff.extraction_gaps`), less the
|
|
131
131
|
ones an export of the snapshot now holds, then, with --provenance-map,
|
|
132
132
|
the baseline gaps: each entry of the map the snapshot lacks whose file
|
|
133
|
-
still declares it (the runner leaves out
|
|
134
|
-
`__version__`, and a module), which would otherwise read as
|
|
135
|
-
baseline gap gives the line that declares the name, `entry`
|
|
136
|
-
when the entry has one, its `export_type`. It is checked only
|
|
137
|
-
entry whose file is `extracted` or `read-by-eye`, whose name is
|
|
138
|
-
dotted, and that the snapshot holds neither under its name nor under
|
|
133
|
+
still declares it (the runner leaves out a Python or Rust underscore
|
|
134
|
+
name such as `__version__`, and a module), which would otherwise read as
|
|
135
|
+
removed. A baseline gap gives the line that declares the name, `entry`
|
|
136
|
+
null and, when the entry has one, its `export_type`. It is checked only
|
|
137
|
+
for an entry whose file is `extracted` or `read-by-eye`, whose name is
|
|
138
|
+
not dotted, and that the snapshot holds neither under its name nor under
|
|
139
139
|
its `reexported_as` target at that file. The selection and the
|
|
140
140
|
declaration are skf-verify-provenance-completeness.py's (`baseline_gaps`,
|
|
141
141
|
by `declared_line`), loaded from beside this script, the rule
|
|
@@ -91,10 +91,13 @@ provenance map): for a gap with an `export` (null for one with none),
|
|
|
91
91
|
"candidates": [{"source_file", "source_line", "export_type"}, ...]}
|
|
92
92
|
|
|
93
93
|
`candidates` holds every entries[] row whose export_name is the export, in
|
|
94
|
-
the map's order
|
|
95
|
-
is the
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
the map's order; when no row has that export_name, every row whose
|
|
95
|
+
`reexported_as` is the export (one the map holds under its declared name,
|
|
96
|
+
which a gap names by the public name a re-export gives it), each view then
|
|
97
|
+
adding the row's own `export_name`. With a `source_citation`, only the
|
|
98
|
+
rows whose source_file is the citation's file count (both compared
|
|
99
|
+
stripped, with backslashes read as `/` and no leading `./`), and of
|
|
100
|
+
several such rows the one whose source_line is the citation's line. Without one, every candidate counts.
|
|
98
101
|
One row left is `found` (its `entry`), several are `ambiguous` (a spot-check
|
|
99
102
|
of the wrong one would move another export's line) and none is `not-found`.
|
|
100
103
|
`match` prints {"status": "ok", "name", "citation", "map_match"} for a name
|
|
@@ -142,16 +145,34 @@ Translate (update-skill gap-driven.md §1): `translate` turns the gaps
|
|
|
142
145
|
`parse` wrote (--gaps) into the change manifest, written to -o as
|
|
143
146
|
{"mode": "gap-driven", "entries": [...]}. Each gap is routed by its
|
|
144
147
|
`category` through CHANGE_CATEGORIES (gap-driven.md §1's table), never by
|
|
145
|
-
its severity; a category the table does not hold is not routed
|
|
146
|
-
entry,
|
|
148
|
+
its severity; a category the table does not hold is not routed, and takes
|
|
149
|
+
no entry, so nothing is written for it. A `stale-documentation` gap is not
|
|
150
|
+
routed: the source has no line to repair the name from, and removing its
|
|
151
|
+
documentation unchecked could delete a name the surface missed or one the
|
|
152
|
+
source still declares. Nor is a `metadata-drift` gap: write.md §2's
|
|
153
|
+
`metadata` call recounts the counts a patch would set (except under the
|
|
154
|
+
drift override and for a docs-only skill, where it passes the counts
|
|
155
|
+
metadata.json records), so no metadata patch holds. Each entry, in this
|
|
156
|
+
key order:
|
|
147
157
|
|
|
148
158
|
name the gap's `export`, else the answered `name`
|
|
149
159
|
gap_id, category the gap's `id`, its `category` (else the
|
|
150
160
|
answered one), `severity`
|
|
151
161
|
severity
|
|
152
162
|
source_citation the gap's, left out when it is null or names a
|
|
153
|
-
line inside the skill package
|
|
154
|
-
|
|
163
|
+
line inside the skill package: `SKILL.md` or a
|
|
164
|
+
`.md` file under `references/`, written relative
|
|
165
|
+
to the package, after `{skill_package}/`, or
|
|
166
|
+
after the package folder of SKF's layout
|
|
167
|
+
(`skills/{name}/{version}/{name}/` or
|
|
168
|
+
`skills/{name}/`, after any leading folders)
|
|
169
|
+
package_citation a `split-body-mismatch` gap's in-package
|
|
170
|
+
citation, {"file", "line"}, its file relative to
|
|
171
|
+
the package (`{skill_package}/` dropped): the
|
|
172
|
+
file merge Priority 8 edits. Left out for any
|
|
173
|
+
other gap, and for one whose category is a
|
|
174
|
+
judgment's answer: an older report's Source may
|
|
175
|
+
name the body it kept
|
|
155
176
|
remediation_paths, resolved_paths, rejected_paths
|
|
156
177
|
the gap's ([] when parse had no --source-root);
|
|
157
178
|
a `provenance-completeness` gap with no
|
|
@@ -193,14 +214,20 @@ the gap's own text to judge from, each need one of:
|
|
|
193
214
|
`provenance-completeness` gap with no path to scan
|
|
194
215
|
|
|
195
216
|
--judgments answers them: a JSON object keyed by gap id, each value an
|
|
196
|
-
object of `category` (a slug,
|
|
217
|
+
object of `category` (a slug, one the table does not route giving that
|
|
218
|
+
category's reason, or null: not routed), `name` (a string, or
|
|
197
219
|
null: not routed), `rescope` (true or false; given for any `missing-export`
|
|
198
220
|
or `missing-type` gap, it is honoured) and `source_file` (a path relative
|
|
199
221
|
to the source root, or null). Other keys are left for gap-records. With
|
|
200
222
|
every need answered it writes the manifest and prints {"status":
|
|
201
223
|
"written", "output", "gap_count", "not_routed": [{"id", "title",
|
|
202
|
-
"category"}], "warnings": [...]}.
|
|
203
|
-
|
|
224
|
+
"category", "reason"}], "warnings": [...]}. A not-routed gap's `reason`
|
|
225
|
+
says why update-skill does not repair it and who does: NOT_ROUTED_REASONS
|
|
226
|
+
gives one for each ledger category the table does not route (gap-driven.md
|
|
227
|
+
§1's Not routed list), NO_REPAIR is the reason of a gap with no
|
|
228
|
+
category or one no table holds, and NO_EXPORT that of a gap whose export
|
|
229
|
+
is answered null. `warnings` holds parse's warnings as `test-report:
|
|
230
|
+
<warning>`, `test-report: not routed: <id> (<category>): <reason>` for
|
|
204
231
|
each gap not routed, and, with --read-only, `proposed-amendment: excluded
|
|
205
232
|
<path> (scope-expansion); not written: --<mode>` for each rescope.
|
|
206
233
|
|
|
@@ -865,10 +892,16 @@ def _line_number(value: object) -> int | None:
|
|
|
865
892
|
def map_match(entries: list, name: str, citation: dict | None) -> dict:
|
|
866
893
|
"""The provenance map entry an export names, keyed on its citation (see "Map match" in the module docstring)."""
|
|
867
894
|
def view(entry: dict) -> dict:
|
|
868
|
-
|
|
895
|
+
out = {key: entry.get(key) for key in ("source_file", "source_line", "export_type")}
|
|
896
|
+
if entry["export_name"].strip() != name.strip(): # matched by its reexported_as: the name to check
|
|
897
|
+
out["export_name"] = entry["export_name"]
|
|
898
|
+
return out
|
|
869
899
|
|
|
870
|
-
|
|
871
|
-
|
|
900
|
+
def named_by(key: str) -> list[dict]:
|
|
901
|
+
return [e for e in entries if isinstance(e, dict) and isinstance(e.get("export_name"), str)
|
|
902
|
+
and isinstance(e.get(key), str) and e[key].strip() == name.strip()]
|
|
903
|
+
|
|
904
|
+
named = named_by("export_name") or named_by("reexported_as")
|
|
872
905
|
left = named
|
|
873
906
|
if citation:
|
|
874
907
|
cited = _norm_file(citation.get("file"))
|
|
@@ -959,7 +992,6 @@ CHANGE_CATEGORIES = {
|
|
|
959
992
|
"provenance-completeness": "NEW_EXPORT",
|
|
960
993
|
"signature-mismatch": "MODIFIED_EXPORT",
|
|
961
994
|
"fabricated-signature": "MODIFIED_EXPORT",
|
|
962
|
-
"stale-documentation": "MODIFIED_EXPORT",
|
|
963
995
|
"provenance-line": "MOVED_EXPORT",
|
|
964
996
|
"split-body-mismatch": "STRUCTURAL_FIX",
|
|
965
997
|
"structural": "STRUCTURAL_FIX",
|
|
@@ -969,9 +1001,50 @@ CHANGE_CATEGORIES = {
|
|
|
969
1001
|
"integration-pattern": "STRUCTURAL_FIX",
|
|
970
1002
|
"migration-section": "STRUCTURAL_FIX",
|
|
971
1003
|
"scripts-assets": "STRUCTURAL_FIX",
|
|
972
|
-
"metadata-drift": "metadata update",
|
|
973
1004
|
"metadata": "metadata update",
|
|
974
1005
|
}
|
|
1006
|
+
# gap-driven.md §1's Not routed list, word for word: why update-skill repairs no gap of each other ledger category,
|
|
1007
|
+
# and who does. A gap takes NO_REPAIR when it has no category or one no table holds, and NO_EXPORT when its export
|
|
1008
|
+
# is answered null.
|
|
1009
|
+
NOT_ROUTED_REASONS = {
|
|
1010
|
+
"stale-documentation": (
|
|
1011
|
+
"the source has no line to repair the name from, so a person removes its documentation when the name is "
|
|
1012
|
+
"gone from the source, fixes the brief's scope when the surface missed it, or, when the source still "
|
|
1013
|
+
"declares the name, keeps the documentation and re-runs Test Skill at a tier that classifies it"),
|
|
1014
|
+
"metadata-drift": (
|
|
1015
|
+
"update-skill recounts `stats.exports_public_api`, `stats.exports_documented` and "
|
|
1016
|
+
"`stats.effective_denominator` each time it writes metadata.json, so a metadata patch to them does not hold "
|
|
1017
|
+
"(under `--allow-workspace-drift` and for a docs-only skill it keeps the recorded public API counts and "
|
|
1018
|
+
"`stats.effective_denominator` instead); Create Skill rebuilds diverging barrel counts from the source and "
|
|
1019
|
+
"recounts a `stats.effective_denominator` the brief's `scope.tier_a_include` set disagrees with "
|
|
1020
|
+
"(denominator drift), a person fixes denominator deflation on a brief with no tier A list by adding "
|
|
1021
|
+
"`scope.tier_a_include` to it, and documented-surface counts that differ by the `Type::method` entries the "
|
|
1022
|
+
"named-export count leaves out need no fix"),
|
|
1023
|
+
"provenance-unverified": (
|
|
1024
|
+
"the line-check rules could not verify the line, so a person checks by hand that it defines the export and, "
|
|
1025
|
+
"when another line does, sets its provenance `source_line` to that line"),
|
|
1026
|
+
"denominator-inflation": (
|
|
1027
|
+
"it concerns the brief's scope and the count design, which a person decides in the skill brief, as the "
|
|
1028
|
+
"gap's remediation says"),
|
|
1029
|
+
"brief-scope-stale": (
|
|
1030
|
+
"a person edits the brief's scope globs by hand so each matches the files it meant, since update-skill "
|
|
1031
|
+
"does not rewrite them"),
|
|
1032
|
+
"multi-denominator": (
|
|
1033
|
+
"no fix is needed, since the barrel and the documented surface measure different sets by design and the "
|
|
1034
|
+
"report records both counts"),
|
|
1035
|
+
"numerator-inflation": "a person documents the absent exports in SKILL.md or references/, reading each from "
|
|
1036
|
+
"the source",
|
|
1037
|
+
"scripts-assets-provenance": "a person adds the file's row to the provenance map's `file_entries`, or removes "
|
|
1038
|
+
"the file from the skill",
|
|
1039
|
+
"observation": "a person applies it only when they agree, since it is advisory, and a documented extra needs no "
|
|
1040
|
+
"change",
|
|
1041
|
+
"discovery": "a person improves the skill's description so realistic prompts route to it, or runs the discovery "
|
|
1042
|
+
"test Test Skill skipped",
|
|
1043
|
+
"description": "a person edits the description in the SKILL.md frontmatter as the gap suggests",
|
|
1044
|
+
"external-validator": "a person applies the skill-check diagnostic or Tessl Review suggestion to SKILL.md",
|
|
1045
|
+
}
|
|
1046
|
+
NO_REPAIR = "update-skill has no repair for this category, so a person follows the gap's remediation"
|
|
1047
|
+
NO_EXPORT = "it names no export for update-skill to repair, so a person follows the gap's remediation"
|
|
975
1048
|
EXPORT_CATEGORIES = ("NEW_EXPORT", "MODIFIED_EXPORT", "MOVED_EXPORT", "DELETED_EXPORT")
|
|
976
1049
|
RESCOPE_CATEGORIES = ("missing-export", "missing-type") # rule R1
|
|
977
1050
|
COMPLETENESS = "provenance-completeness" # rule R3
|
|
@@ -985,8 +1058,14 @@ _LEDGER_REMEDIATION = re.compile(
|
|
|
985
1058
|
r"|Find the \d+ exports the denominator counts that SKILL\.md and references/ do not name "
|
|
986
1059
|
r"\(`[^`\n]+` lists the exports\) and document each with its signature\."
|
|
987
1060
|
)
|
|
988
|
-
# A citation of generated markdown: the skill's SKILL.md or a
|
|
989
|
-
|
|
1061
|
+
# A citation of generated markdown: the skill's SKILL.md or a .md file under its references/, relative to the
|
|
1062
|
+
# package, after `{skill_package}/`, or after the package folder of SKF's layout as a project-relative path
|
|
1063
|
+
# (`skills/{name}/{version}/{name}/`, or the flat `skills/{name}/`, after any leading folders). A path under any
|
|
1064
|
+
# other folder (`docs/references/guide.md`) is source.
|
|
1065
|
+
_PACKAGE_FILE = re.compile(
|
|
1066
|
+
r"(?:\{skill_package\}/|(?:[^/]+/)*skills/(?P<name>[^/]+)/(?:[^/]+/(?P=name)/)?)?"
|
|
1067
|
+
r"(?P<file>SKILL\.md|references/(?:[^/]+/)*[^/]+\.md)")
|
|
1068
|
+
SPLIT_BODY = "split-body-mismatch"
|
|
990
1069
|
# The answers translate reads, with the JSON types each takes.
|
|
991
1070
|
_ANSWER_TYPES = {"category": (str, type(None)), "name": (str, type(None)), "rescope": (bool,),
|
|
992
1071
|
"source_file": (str, type(None))}
|
|
@@ -994,14 +1073,38 @@ _IS_WINDOWS = os.name == "nt"
|
|
|
994
1073
|
_REPLACE_WAIT_SECONDS = 5.0
|
|
995
1074
|
|
|
996
1075
|
|
|
1076
|
+
def _package_path(citation: dict | None) -> str | None:
|
|
1077
|
+
"""The citation's file relative to the skill package, or None when it names no file of the package."""
|
|
1078
|
+
path = _norm_file(citation.get("file")) if isinstance(citation, dict) else None
|
|
1079
|
+
path = path.strip("`") if path else None
|
|
1080
|
+
found = _PACKAGE_FILE.fullmatch(path) if path and ".." not in path.split("/") else None
|
|
1081
|
+
return found.group("file") if found else None
|
|
1082
|
+
|
|
1083
|
+
|
|
997
1084
|
def in_skill_package(citation: dict | None) -> bool:
|
|
998
1085
|
"""True when a citation names a line of generated markdown, not source.
|
|
999
1086
|
|
|
1000
|
-
>>> [in_skill_package({"file": f, "line": 1}) for f in ("SKILL.md", "{skill_package}/references/api.md",
|
|
1001
|
-
|
|
1087
|
+
>>> [in_skill_package({"file": f, "line": 1}) for f in ("SKILL.md", "{skill_package}/references/api.md",
|
|
1088
|
+
... "references/integrations/a-b.md", "src/a.ts",
|
|
1089
|
+
... "skills/oms-cognee/0.5.8/oms-cognee/SKILL.md",
|
|
1090
|
+
... "docs/references/guide.md")]
|
|
1091
|
+
[True, True, True, False, True, False]
|
|
1002
1092
|
"""
|
|
1003
|
-
|
|
1004
|
-
|
|
1093
|
+
return _package_path(citation) is not None
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def package_citation(citation: dict | None) -> dict | None:
|
|
1097
|
+
"""An in-package citation with its file relative to the package, else None.
|
|
1098
|
+
|
|
1099
|
+
>>> for f in ("{skill_package}/references/api.md", "SKILL.md", "skills/lib/references/api.md", "src/a.ts"):
|
|
1100
|
+
... print(package_citation({"file": f, "line": 3}))
|
|
1101
|
+
{'file': 'references/api.md', 'line': 3}
|
|
1102
|
+
{'file': 'SKILL.md', 'line': 3}
|
|
1103
|
+
{'file': 'references/api.md', 'line': 3}
|
|
1104
|
+
None
|
|
1105
|
+
"""
|
|
1106
|
+
path = _package_path(citation)
|
|
1107
|
+
return {"file": path, "line": citation.get("line")} if path else None
|
|
1005
1108
|
|
|
1006
1109
|
|
|
1007
1110
|
def written_remediation(text: object) -> bool:
|
|
@@ -1051,9 +1154,10 @@ def translate_gaps(parsed: dict, map_entries: list, judgments: dict, *, read_onl
|
|
|
1051
1154
|
entries: list[dict] = []
|
|
1052
1155
|
not_routed: list[dict] = []
|
|
1053
1156
|
|
|
1054
|
-
def skip(gap: dict, category: str | None) -> None:
|
|
1055
|
-
|
|
1056
|
-
|
|
1157
|
+
def skip(gap: dict, category: str | None, reason: str | None = None) -> None:
|
|
1158
|
+
reason = reason or NOT_ROUTED_REASONS.get(category or "", NO_REPAIR)
|
|
1159
|
+
not_routed.append({"id": gap["id"], "title": gap.get("title"), "category": category, "reason": reason})
|
|
1160
|
+
warnings.append(f"test-report: not routed: {gap['id']} ({category or 'no category'}): {reason}")
|
|
1057
1161
|
|
|
1058
1162
|
def ask(gap: dict, category: str | None, want: list[str]) -> None:
|
|
1059
1163
|
needs.append({"gap_id": gap["id"], "title": gap.get("title"), "category": category,
|
|
@@ -1079,11 +1183,14 @@ def translate_gaps(parsed: dict, map_entries: list, judgments: dict, *, read_onl
|
|
|
1079
1183
|
name = export or _answer_text(answers.get("name"))
|
|
1080
1184
|
if change in EXPORT_CATEGORIES and name is None:
|
|
1081
1185
|
if "name" in answers: # answered null: no export to repair
|
|
1082
|
-
skip(gap, category)
|
|
1186
|
+
skip(gap, category, NO_EXPORT)
|
|
1083
1187
|
else:
|
|
1084
1188
|
ask(gap, category, ["name"])
|
|
1085
1189
|
continue
|
|
1086
1190
|
citation = gap.get("source_citation")
|
|
1191
|
+
# the gap's own category, which the ledger recorded: a judged one is an older report's, whose Source may
|
|
1192
|
+
# name the body that report still kept
|
|
1193
|
+
package = package_citation(citation) if gap.get("category") == SPLIT_BODY else None
|
|
1087
1194
|
if in_skill_package(citation):
|
|
1088
1195
|
citation = None
|
|
1089
1196
|
match = map_match(map_entries, name, citation) if name else None
|
|
@@ -1123,6 +1230,8 @@ def translate_gaps(parsed: dict, map_entries: list, judgments: dict, *, read_onl
|
|
|
1123
1230
|
entry = {"name": name, "gap_id": gap["id"], "category": category, "severity": gap.get("severity")}
|
|
1124
1231
|
if citation:
|
|
1125
1232
|
entry["source_citation"] = citation
|
|
1233
|
+
if package:
|
|
1234
|
+
entry["package_citation"] = package
|
|
1126
1235
|
entry["remediation_paths"] = list(gap.get("remediation_paths") or [])
|
|
1127
1236
|
entry["resolved_paths"] = resolved
|
|
1128
1237
|
entry["rejected_paths"] = rejected
|
|
@@ -73,7 +73,7 @@ Judgment payload (stdin JSON, compute mode)::
|
|
|
73
73
|
|
|
74
74
|
{
|
|
75
75
|
"exports_public_api": 40, # int — public-entry-point export count (judgment)
|
|
76
|
-
"exports_internal": 19, # int
|
|
76
|
+
"exports_internal": 19, # int: internal export count, Python/Rust/Dart `_` names left out (judgment)
|
|
77
77
|
"effective_denominator": 12, # int|null — optional, curated-subset scopes only
|
|
78
78
|
"pattern_surfaces_documented": 8, # int — reference-app only (exports_documented proxy)
|
|
79
79
|
"exports_documented": 0, # int — stack only (own-barrel count, usually 0)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Resolve Customization: BMAD core's customization resolver, on any core from 6.4.0.
|
|
6
|
+
|
|
7
|
+
Every SKF workflow, and Ferris, reads its `_bmad/custom/` overrides through
|
|
8
|
+
BMAD core's customization resolver, `_bmad/scripts/resolve_customization.py`
|
|
9
|
+
under the project root, which the BMAD Method installer adds. SKF passes it
|
|
10
|
+
`--project-root`, a flag core takes only from 6.11.0: core 6.4.0 to 6.10.0
|
|
11
|
+
stops on it with argparse's exit 2, so SKF runs this helper in its place.
|
|
12
|
+
It takes core's long flags only (core's -s, -p and -k are refused):
|
|
13
|
+
|
|
14
|
+
uv run skf-resolve-customization.py --skill <skill-root> \\
|
|
15
|
+
--project-root <project-root> --key <key> [--key <key>]...
|
|
16
|
+
|
|
17
|
+
1. It runs core's resolver with those arguments, `--project-root` included,
|
|
18
|
+
under its own interpreter, from the project root. `uv run` gives this
|
|
19
|
+
file a Python that meets its requires-python, which core needs for
|
|
20
|
+
`tomllib`; core 6.10.0 and earlier declare none, so uv would not choose
|
|
21
|
+
one for them.
|
|
22
|
+
2. When core rejects `--project-root` (exit 2, nothing on stdout, and
|
|
23
|
+
`error: unrecognized arguments: ... --project-root` on stderr), it works
|
|
24
|
+
out the root a core before 6.11.0 picks without the flag: the nearest
|
|
25
|
+
folder, from the skill folder up and the skill folder itself first, that
|
|
26
|
+
holds `_bmad` or `.git` (a file or a folder), else the same search from
|
|
27
|
+
the project root. When that is the project root, it runs core again
|
|
28
|
+
without the flag, and that run's result is the helper's. Otherwise core
|
|
29
|
+
would read another folder's overrides, so it refuses.
|
|
30
|
+
|
|
31
|
+
Output:
|
|
32
|
+
stdout on exit 0, core's stdout byte for byte (the JSON core prints);
|
|
33
|
+
on any other exit, nothing.
|
|
34
|
+
stderr the stderr of the core run whose result is the helper's: on a
|
|
35
|
+
rerun without the flag, the rerun's only (the rejected call's is
|
|
36
|
+
dropped). A failure of the helper's own prints one line first: the
|
|
37
|
+
reason a workflow's warning quotes.
|
|
38
|
+
|
|
39
|
+
Exit codes:
|
|
40
|
+
0 core resolved the customization
|
|
41
|
+
1 core's resolver is missing (first stderr line: `not found`), could
|
|
42
|
+
not start, did not finish or was stopped by a signal, or core exited 1
|
|
43
|
+
(a customize.toml or an override it cannot read)
|
|
44
|
+
2 a core before 6.11.0 would read the overrides under another folder
|
|
45
|
+
(first stderr line: `BMAD core before 6.11.0: ...`), a usage error of
|
|
46
|
+
the helper's, or core's own exit 2 (a core from 6.11.0), passed through
|
|
47
|
+
3 core found no Python 3.11 or later (`tomllib`)
|
|
48
|
+
Any other exit code is core's own, passed through.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import argparse
|
|
54
|
+
import os
|
|
55
|
+
import re
|
|
56
|
+
import subprocess
|
|
57
|
+
import sys
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
|
|
60
|
+
CORE_RESOLVER = ("_bmad", "scripts", "resolve_customization.py")
|
|
61
|
+
FIRST_CORE_WITH_FLAG = "6.11.0"
|
|
62
|
+
CORE_TIMEOUT_SEC = 120
|
|
63
|
+
PROJECT_ROOT_FLAG = "--project-root"
|
|
64
|
+
MISSING = "not found"
|
|
65
|
+
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
66
|
+
# argparse's line for a flag it does not know, wherever the usage line wraps.
|
|
67
|
+
REJECTED_FLAG_RE = re.compile(r"error: unrecognized arguments: (?:.*\s)?--project-root(?:[=\s]|$)", re.M)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
71
|
+
parser = argparse.ArgumentParser(
|
|
72
|
+
prog="skf-resolve-customization",
|
|
73
|
+
description=(
|
|
74
|
+
"Run BMAD core's customization resolver with --project-root. On a core before "
|
|
75
|
+
f"{FIRST_CORE_WITH_FLAG}, which rejects that flag, run it again without the flag when "
|
|
76
|
+
"core would read this project's overrides anyway, and refuse otherwise."
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument("--skill", required=True, help="the skill folder that holds customize.toml")
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--project-root",
|
|
82
|
+
required=True,
|
|
83
|
+
help="the project folder: core's resolver is under its _bmad/scripts/, the overrides under _bmad/custom/",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--key",
|
|
87
|
+
action="append",
|
|
88
|
+
default=[],
|
|
89
|
+
help="a dotted key to resolve, repeatable; with none, core prints the whole merge",
|
|
90
|
+
)
|
|
91
|
+
return parser
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _nearest_marked(start: Path) -> Path | None:
|
|
95
|
+
"""The nearest folder, `start` itself first, holding `_bmad` or `.git` (a file or a folder)."""
|
|
96
|
+
current = start.resolve()
|
|
97
|
+
while True:
|
|
98
|
+
if (current / "_bmad").exists() or (current / ".git").exists():
|
|
99
|
+
return current
|
|
100
|
+
if current.parent == current:
|
|
101
|
+
return None
|
|
102
|
+
current = current.parent
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def legacy_project_root(skill: Path, cwd: Path) -> Path | None:
|
|
106
|
+
"""The project root BMAD core 6.4.0 to 6.10.0 picks without --project-root, run from `cwd`."""
|
|
107
|
+
return _nearest_marked(skill) or _nearest_marked(cwd)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _same_folder(a: Path, b: Path) -> bool:
|
|
111
|
+
try:
|
|
112
|
+
return os.path.samefile(a, b)
|
|
113
|
+
except OSError:
|
|
114
|
+
return a.resolve() == b.resolve()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def rejects_project_root(returncode: int, stdout: bytes, stderr: bytes) -> bool:
|
|
118
|
+
"""True when core stopped on --project-root as a flag it does not know; never on exit 2 alone."""
|
|
119
|
+
if returncode != 2 or stdout:
|
|
120
|
+
return False
|
|
121
|
+
text = ANSI_RE.sub("", stderr.decode("utf-8", "replace"))
|
|
122
|
+
return REJECTED_FLAG_RE.search(text) is not None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _run_core(argv: list[str], cwd: Path) -> subprocess.CompletedProcess:
|
|
126
|
+
env = dict(os.environ)
|
|
127
|
+
# Core 6.11.0 and later import config_utils from their own folder, which a safe path drops.
|
|
128
|
+
env.pop("PYTHONSAFEPATH", None)
|
|
129
|
+
# Core before 6.8.0 writes its JSON in the locale's encoding.
|
|
130
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
131
|
+
return subprocess.run(
|
|
132
|
+
argv, cwd=cwd, env=env, stdin=subprocess.DEVNULL, capture_output=True,
|
|
133
|
+
timeout=CORE_TIMEOUT_SEC, check=False,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _write(stream, data: bytes) -> None:
|
|
138
|
+
if not data:
|
|
139
|
+
return
|
|
140
|
+
buffer = getattr(stream, "buffer", None)
|
|
141
|
+
if buffer is None:
|
|
142
|
+
stream.write(data.decode("utf-8", "replace"))
|
|
143
|
+
stream.flush()
|
|
144
|
+
return
|
|
145
|
+
buffer.write(data)
|
|
146
|
+
buffer.flush()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _say(*lines: str) -> None:
|
|
150
|
+
_write(sys.stderr, "".join(line + "\n" for line in lines).encode("utf-8"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _finish(proc: subprocess.CompletedProcess) -> int:
|
|
154
|
+
"""Core's result as the helper's: its stdout only on exit 0, its stderr always."""
|
|
155
|
+
if proc.returncode < 0: # stopped by a signal: core may have printed no reason
|
|
156
|
+
_say(f"BMAD core's customization resolver was stopped by signal {-proc.returncode}")
|
|
157
|
+
_write(sys.stderr, proc.stderr)
|
|
158
|
+
return 1
|
|
159
|
+
if proc.returncode == 0:
|
|
160
|
+
_write(sys.stdout, proc.stdout)
|
|
161
|
+
_write(sys.stderr, proc.stderr)
|
|
162
|
+
return proc.returncode
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main(argv: list[str] | None = None) -> int:
|
|
166
|
+
args = _build_parser().parse_args(argv)
|
|
167
|
+
root = Path(os.path.abspath(args.project_root))
|
|
168
|
+
skill = Path(os.path.abspath(args.skill))
|
|
169
|
+
core = root.joinpath(*CORE_RESOLVER)
|
|
170
|
+
if not core.is_file():
|
|
171
|
+
_say(MISSING, f"BMAD core's customization resolver is not at {core}: the BMAD Method installer adds it.")
|
|
172
|
+
return 1
|
|
173
|
+
command = [sys.executable, str(core), "--skill", str(skill)]
|
|
174
|
+
keys = [word for key in args.key for word in ("--key", key)]
|
|
175
|
+
try:
|
|
176
|
+
first = _run_core([*command, PROJECT_ROOT_FLAG, str(root), *keys], root)
|
|
177
|
+
if not rejects_project_root(first.returncode, first.stdout, first.stderr):
|
|
178
|
+
return _finish(first)
|
|
179
|
+
legacy = legacy_project_root(skill, root)
|
|
180
|
+
if legacy is not None and _same_folder(legacy, root):
|
|
181
|
+
return _finish(_run_core([*command, *keys], root))
|
|
182
|
+
except subprocess.TimeoutExpired:
|
|
183
|
+
_say(f"BMAD core's customization resolver did not finish within {CORE_TIMEOUT_SEC} seconds")
|
|
184
|
+
return 1
|
|
185
|
+
except OSError as exc:
|
|
186
|
+
_say(f"BMAD core's customization resolver could not start: {exc}")
|
|
187
|
+
return 1
|
|
188
|
+
_say(
|
|
189
|
+
f"BMAD core before {FIRST_CORE_WITH_FLAG}: its customization resolver takes no {PROJECT_ROOT_FLAG} "
|
|
190
|
+
f"and would read the overrides under {legacy}, not under {root}; upgrade BMAD core to "
|
|
191
|
+
f"{FIRST_CORE_WITH_FLAG} or later"
|
|
192
|
+
)
|
|
193
|
+
return 2
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
if __name__ == "__main__":
|
|
197
|
+
sys.exit(main())
|