bmad-module-skill-forge 8.0.0 → 9.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/docs/_data/pinned.yaml +7 -7
- package/docs/verifying-a-skill.md +9 -9
- package/docs/workflows.md +1 -1
- package/package.json +1 -1
- package/src/shared/scripts/skf-extract-public-api.py +15 -8
- package/src/shared/scripts/skf-verify-provenance-completeness.py +331 -105
- package/src/skf-analyze-source/SKILL.md +1 -1
- package/src/skf-audit-skill/SKILL.md +1 -1
- package/src/skf-brief-skill/SKILL.md +1 -1
- package/src/skf-campaign/SKILL.md +1 -1
- package/src/skf-create-skill/SKILL.md +1 -1
- package/src/skf-create-stack-skill/SKILL.md +1 -1
- package/src/skf-drop-skill/SKILL.md +1 -1
- package/src/skf-export-skill/SKILL.md +1 -1
- package/src/skf-forger/SKILL.md +1 -1
- package/src/skf-quick-skill/SKILL.md +1 -1
- package/src/skf-refine-architecture/SKILL.md +1 -1
- package/src/skf-rename-skill/SKILL.md +1 -1
- package/src/skf-setup/SKILL.md +1 -1
- package/src/skf-test-skill/SKILL.md +1 -1
- package/src/skf-test-skill/assets/output-section-formats.md +2 -2
- package/src/skf-test-skill/customize.toml +10 -0
- package/src/skf-test-skill/references/coverage-check-branches.md +2 -2
- package/src/skf-test-skill/references/coverage-check.md +8 -4
- package/src/skf-test-skill/references/init.md +1 -1
- package/src/skf-test-skill/references/inventory-contract.md +11 -7
- package/src/skf-test-skill/references/report.md +2 -1
- package/src/skf-test-skill/references/scoring-rules.md +2 -2
- package/src/skf-test-skill/references/signature-comparison-rules.md +7 -5
- package/src/skf-test-skill/references/source-access-protocol.md +1 -1
- package/src/skf-test-skill/scripts/gap-ledger.py +105 -53
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +336 -59
- package/src/skf-test-skill/scripts/reconcile-coverage.py +46 -18
- package/src/skf-test-skill/scripts/score-signatures.py +527 -130
- package/src/skf-test-skill/scripts/validate-inventory.py +528 -68
- package/src/skf-test-skill/templates/test-report-template.md +4 -3
- package/src/skf-update-skill/SKILL.md +1 -1
- package/src/skf-verify-stack/SKILL.md +1 -1
|
@@ -106,9 +106,12 @@ cites (never read; default `metadata.json`):
|
|
|
106
106
|
`extraction` object, not `no-ast-grep`, `truncated` or
|
|
107
107
|
`fallback.needed`: only such an extraction fills
|
|
108
108
|
`excluded.outsideScope`, the names a brief scoped out) and its
|
|
109
|
-
`excluded.outsideScope` list does not list
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
`excluded.outsideScope` list does not list it (a row of a
|
|
110
|
+
namespace's member named like its namespace, whose `namespace`
|
|
111
|
+
is its `name`, #969, does not count unless its `file` is the
|
|
112
|
+
one the gap would be at): an Info `observation` gap
|
|
113
|
+
`Documented extra: {name}` at `defined_at`. With no
|
|
114
|
+
`defined_at`, a homonym (its `declared_in`
|
|
112
115
|
lists several declaring files) is one too, with --skill-dir,
|
|
113
116
|
when the skill's `[AST:path:L<n>]` and `[SRC:path:L<n>]`
|
|
114
117
|
citations on the lines of SKILL.md and references/**/*.md that
|
|
@@ -126,30 +129,38 @@ cites (never read; default `metadata.json`):
|
|
|
126
129
|
first that writes it as an export, the first tier that holds
|
|
127
130
|
the name winning: (1) its first origin in the documented
|
|
128
131
|
inventory's floor (load-coverage-inputs.py floor); (2) a
|
|
129
|
-
|
|
130
|
-
floor's
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
`
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
132
|
+
heading, or the first cell of a table whose header's first
|
|
133
|
+
cell names exports (floor's table headers), whose text is the
|
|
134
|
+
name or holds a code span floor reads as the name; (3) a line
|
|
135
|
+
whose inline code span is the name, or the name directly
|
|
136
|
+
followed by `(`; (4) a column-0 declaration of it in a fenced
|
|
137
|
+
block of a language floor's `_declared_names` reads (JS/TS,
|
|
138
|
+
Python, Rust, Go), cited or not, so a def an example writes
|
|
139
|
+
under the name is cited only when nothing above documents it;
|
|
140
|
+
(5) a line of a fenced block that holds the name as a whole
|
|
141
|
+
quoted string (`"name"` or `'name'`), such as an `__all__` item.
|
|
142
|
+
Tiers 2 to 5 read SKILL.md, then every .md file under
|
|
143
|
+
references/, subfolders included, in path order, outside the
|
|
144
|
+
frontmatter (2 and 3 outside fenced code and HTML comments, 4
|
|
145
|
+
and 5 inside a fence outside an HTML comment and a data block
|
|
146
|
+
(json, yaml...)); within a tier the first file, then its lowest
|
|
147
|
+
line, wins. A bare word, a span that holds the name among other
|
|
148
|
+
words (`uitripled add`), or a quoted string outside a fence or
|
|
149
|
+
holding other words too (`"add x"`) documents nothing; with no
|
|
150
|
+
such line, or without --skill-dir, the Source is `SKILL.md`.
|
|
151
|
+
Floor's InputError (no SKILL.md, or a top-level file it cannot
|
|
152
|
+
read or decode) leaves tier 1 empty, and a skill file that
|
|
153
|
+
cannot be read leaves tiers 2 to 5 empty. One whose
|
|
154
|
+
`excluded.outsideScope` row says the brief scopes its file out
|
|
155
|
+
(`excludeGlob`, the `scope.exclude` glob that matches it, or
|
|
156
|
+
`outsideInclude`, no `scope.include` glob covering it), unless
|
|
157
|
+
that row is a namespace's member named like its namespace (its
|
|
158
|
+
`namespace` is its `name`, #969) whose `file` is not the one a
|
|
159
|
+
documented extra's gap would be at, has its own issue, naming the
|
|
160
|
+
entry point and the file and line, and remediation: to keep the
|
|
161
|
+
documentation, narrow that exclude glob (a removal needs a
|
|
162
|
+
`demoted-exclude` amendment) or widen `scope.include`, else
|
|
163
|
+
remove it. A row the
|
|
153
164
|
brief does not explain keeps the wording above. Every stale
|
|
154
165
|
name's issue (fabricated, documented extra, stale documentation
|
|
155
166
|
or scoped out) opens with the line that documents it, as
|
|
@@ -406,6 +417,9 @@ EFFORT = {
|
|
|
406
417
|
_ID_RE = re.compile(r"^GAP-(\d{3,})$")
|
|
407
418
|
_STAGE_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
408
419
|
_RUN_ID_FROM_NAME = re.compile(r"^test-findings-(.+)\.json$")
|
|
420
|
+
# A whole quoted string, `"name"` or `'name'`. The lookahead lets matches overlap, so an apostrophe earlier on the
|
|
421
|
+
# line (`don't 'add'`) does not swallow the next string; the text between two strings is a harmless extra key.
|
|
422
|
+
_QUOTED_RE = re.compile(r"""(?=(["'])([^"'\n]+)\1)""")
|
|
409
423
|
|
|
410
424
|
|
|
411
425
|
class LedgerError(Exception):
|
|
@@ -913,26 +927,33 @@ def _documenting_lines(skill_dir: Path) -> dict[str, str]:
|
|
|
913
927
|
1. its first origin in the documented inventory's floor
|
|
914
928
|
(load-coverage-inputs.py `floor`: SKILL.md and the top-level
|
|
915
929
|
references/*.md);
|
|
916
|
-
2. a
|
|
917
|
-
`_declared_names`), cited or not, outside an HTML comment and a data
|
|
918
|
-
block;
|
|
919
|
-
3. a heading, or the first cell of a table whose header's first cell is
|
|
930
|
+
2. a heading, or the first cell of a table whose header's first cell is
|
|
920
931
|
one of floor's `FLOOR_TABLE_HEADERS`, whose stripped text is the name
|
|
921
932
|
or holds a code span floor's `_floor_name` reads as the name;
|
|
922
|
-
|
|
923
|
-
directly followed by `(
|
|
924
|
-
|
|
925
|
-
|
|
933
|
+
3. a line whose inline code span, stripped, is the name, or the name
|
|
934
|
+
directly followed by `(`;
|
|
935
|
+
4. a column-0 public declaration of it in a fenced code block (floor's
|
|
936
|
+
`_declared_names`), cited or not, so a def an example writes under
|
|
937
|
+
the name (a connector author's own `mount_table_target`) is cited
|
|
938
|
+
only when nothing above documents it;
|
|
939
|
+
5. a raw (unmasked) line of a fenced code block that holds the name as a
|
|
940
|
+
whole quoted string (`"name"` or `'name'`), such as an `__all__`
|
|
941
|
+
item; it only replaces the bare `SKILL.md` fallback.
|
|
942
|
+
|
|
943
|
+
Tiers 2 to 5 read SKILL.md, then every .md file under references/,
|
|
926
944
|
subfolders included, in path order (locate-export-segments.py
|
|
927
|
-
`skill_files`), never the frontmatter
|
|
928
|
-
and HTML comments
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
945
|
+
`skill_files`), never the frontmatter: tiers 2 and 3 outside fenced code
|
|
946
|
+
and HTML comments, tiers 4 and 5 inside a fence outside an HTML comment
|
|
947
|
+
and a data block (json, yaml...). Within a tier the first file, then its
|
|
948
|
+
lowest line, wins. A bare word, a span that holds the name among other
|
|
949
|
+
words (`uitripled add`), or a quoted string outside a fence or holding
|
|
950
|
+
other words too (`"add x"`) documents nothing. An input a tier cannot
|
|
951
|
+
read is skipped: floor's InputError (no SKILL.md, a file that is not
|
|
952
|
+
UTF-8) leaves tier 1 empty, an unreadable file tiers 2 to 5."""
|
|
933
953
|
coverage = _sibling("load-coverage-inputs.py")
|
|
934
954
|
segments = _sibling("locate-export-segments.py")
|
|
935
|
-
|
|
955
|
+
# In merge order: floor, heading or export-table cell, code span, fenced declaration, quoted string.
|
|
956
|
+
tiers: list[dict[str, str]] = [{}, {}, {}, {}, {}]
|
|
936
957
|
try:
|
|
937
958
|
for entry in coverage.floor(str(skill_dir))["names"]:
|
|
938
959
|
origin = entry["origins"][0]
|
|
@@ -943,8 +964,9 @@ def _documenting_lines(skill_dir: Path) -> dict[str, str]:
|
|
|
943
964
|
files = segments.skill_files(skill_dir)
|
|
944
965
|
except OSError:
|
|
945
966
|
files = []
|
|
946
|
-
# floor's fence filter: a fence in an HTML comment or a data block declares nothing, and a Svelte
|
|
947
|
-
# `export let` is a component prop. Unlike floor, an unheaded fence counts
|
|
967
|
+
# floor's fence filter: a fence in an HTML comment or a data block declares and quotes nothing, and a Svelte
|
|
968
|
+
# block's `export let` is a component prop. Unlike floor, an unheaded fence counts; a Svelte, shell or untagged
|
|
969
|
+
# fence's quoted strings count too.
|
|
948
970
|
js_family = segments.JS_FAMILY - {"svelte"}
|
|
949
971
|
for rel, lines in files:
|
|
950
972
|
start = segments._frontmatter_end(lines)
|
|
@@ -954,9 +976,13 @@ def _documenting_lines(skill_dir: Path) -> dict[str, str]:
|
|
|
954
976
|
language = segments._language(info)
|
|
955
977
|
if opening in comments or language in segments.DATA_LANGUAGES:
|
|
956
978
|
continue
|
|
957
|
-
|
|
979
|
+
raw = lines[opening + 1:closing]
|
|
980
|
+
masked = segments.mask_code("\n".join(raw), language).split("\n")
|
|
958
981
|
for offset, declared in coverage._declared_names(masked, language, js_family):
|
|
959
|
-
tiers[
|
|
982
|
+
tiers[3].setdefault(declared, f"{rel}:{opening + offset + 2}")
|
|
983
|
+
for offset, line in enumerate(raw):
|
|
984
|
+
for match in _QUOTED_RE.finditer(line):
|
|
985
|
+
tiers[4].setdefault(match.group(2), f"{rel}:{opening + offset + 2}")
|
|
960
986
|
headings, prose = segments._prose(lines, start, code)
|
|
961
987
|
shaped = [(index, text) for index, _level, text, _above in headings]
|
|
962
988
|
written = list(shaped)
|
|
@@ -968,12 +994,12 @@ def _documenting_lines(skill_dir: Path) -> dict[str, str]:
|
|
|
968
994
|
for index, text in sorted(shaped):
|
|
969
995
|
for key in [text.strip()] + [coverage._floor_name(span) for span in segments._inline_code(text)]:
|
|
970
996
|
if key:
|
|
971
|
-
tiers[
|
|
997
|
+
tiers[1].setdefault(key, f"{rel}:{index + 1}")
|
|
972
998
|
for index, text in sorted(written):
|
|
973
999
|
for span in map(str.strip, segments._inline_code(text)):
|
|
974
1000
|
for key in (span, span.split("(", 1)[0] if "(" in span else None):
|
|
975
1001
|
if key:
|
|
976
|
-
tiers[
|
|
1002
|
+
tiers[2].setdefault(key, f"{rel}:{index + 1}")
|
|
977
1003
|
found: dict[str, str] = {}
|
|
978
1004
|
for tier in tiers:
|
|
979
1005
|
for key, line in tier.items():
|
|
@@ -1002,7 +1028,8 @@ def _scoped_out_by(row: dict) -> bool:
|
|
|
1002
1028
|
or row.get("outsideInclude") is True))
|
|
1003
1029
|
|
|
1004
1030
|
|
|
1005
|
-
def _outside_scope(surface: dict | None
|
|
1031
|
+
def _outside_scope(surface: dict | None, stale: dict[str, dict] | None = None,
|
|
1032
|
+
skill_dir: str | None = None) -> dict[str, dict] | None:
|
|
1006
1033
|
"""The names a surface result's `excluded.outsideScope` lists (defined in
|
|
1007
1034
|
a file out of scope), each with its row: the first the brief explains
|
|
1008
1035
|
(`_scoped_out_by`), else the first with a file, else its first. None
|
|
@@ -1010,7 +1037,13 @@ def _outside_scope(surface: dict | None) -> dict[str, dict] | None:
|
|
|
1010
1037
|
(no `extraction` object) or from one that did not read the whole scope
|
|
1011
1038
|
(a `no-ast-grep` status, a `truncated` run, or `fallback.needed`): only
|
|
1012
1039
|
an extraction fills that list, so the names a brief scoped out are then
|
|
1013
|
-
unknown.
|
|
1040
|
+
unknown. A namespace's member that has the namespace's own name (its
|
|
1041
|
+
`namespace` is its `name`, #969), such as a function a submodule
|
|
1042
|
+
imports from a file out of scope, is no row here: the documented name
|
|
1043
|
+
is the submodule, which no brief scoped out, while another row of the
|
|
1044
|
+
name still lists it. It stays when its `file` is the one the classified
|
|
1045
|
+
stale item of its name (`stale`) says the source declares it in, where
|
|
1046
|
+
`_documented_extra_at` would put the gap: that name is the member."""
|
|
1014
1047
|
excluded = (surface or {}).get("excluded")
|
|
1015
1048
|
if excluded is None:
|
|
1016
1049
|
return None
|
|
@@ -1034,12 +1067,27 @@ def _outside_scope(surface: dict | None) -> dict[str, dict] | None:
|
|
|
1034
1067
|
outside: dict[str, dict] = {}
|
|
1035
1068
|
for row in rows:
|
|
1036
1069
|
if isinstance(row, dict) and isinstance(row.get("name"), str):
|
|
1070
|
+
if (isinstance(row.get("namespace"), str) and row["namespace"] and row["namespace"] == row["name"]
|
|
1071
|
+
and not _declared_in_file(row, (stale or {}).get(row["name"]) or {}, skill_dir)):
|
|
1072
|
+
continue # the member, not the module the skill documents by that name
|
|
1037
1073
|
known = outside.get(row["name"])
|
|
1038
1074
|
if known is None or rank(row) > rank(known):
|
|
1039
1075
|
outside[row["name"]] = row
|
|
1040
1076
|
return outside
|
|
1041
1077
|
|
|
1042
1078
|
|
|
1079
|
+
def _declared_in_file(row: dict, item: dict, skill_dir: str | None) -> bool:
|
|
1080
|
+
"""Whether a stale item says the source declares its name in an
|
|
1081
|
+
`excluded.outsideScope` row's file: its `defined_at`, or its one cited
|
|
1082
|
+
`declared_in` item, the place `_documented_extra_at` reads whatever the
|
|
1083
|
+
brief scopes out (an empty `outside`)."""
|
|
1084
|
+
at = _documented_extra_at(row["name"], item, {}, skill_dir)
|
|
1085
|
+
if not at or not isinstance(row.get("file"), str) or not row["file"]:
|
|
1086
|
+
return False
|
|
1087
|
+
cited_path = _sibling("validate-inventory.py")._cited_path
|
|
1088
|
+
return cited_path(at[0].rsplit(":", 1)[0]) == cited_path(row["file"])
|
|
1089
|
+
|
|
1090
|
+
|
|
1043
1091
|
# The extensions classify-stale reads a `defined_at` in.
|
|
1044
1092
|
# Keep identical to PYTHON_EXTENSIONS | TSJS_EXTENSIONS in skf-verify-provenance-completeness.py.
|
|
1045
1093
|
_DEFINED_AT_EXTENSIONS = frozenset({".py", ".pyi", ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"})
|
|
@@ -1059,7 +1107,9 @@ def _documented_extra_at(name: str, item: dict, outside: dict[str, dict] | None,
|
|
|
1059
1107
|
single cited declaring file (two or more cited, none, or no --skill-dir),
|
|
1060
1108
|
a `defined_at` or `declared_in` item not a Python or TS/JS `file:line`, a
|
|
1061
1109
|
dotted name, no surface or one without an extraction, or a name a brief
|
|
1062
|
-
scoped out
|
|
1110
|
+
scoped out (a namespace's member named like its namespace scopes out
|
|
1111
|
+
nothing: `_outside_scope` drops its row, #969, unless its file is the
|
|
1112
|
+
one this returns)."""
|
|
1063
1113
|
if outside is None or name in outside or "." in name:
|
|
1064
1114
|
return None
|
|
1065
1115
|
defined_at = item.get("defined_at")
|
|
@@ -1236,7 +1286,7 @@ def _stale_gaps(cov: dict, surface: dict | None, stale: object, scored: set[str]
|
|
|
1236
1286
|
export=name))
|
|
1237
1287
|
continue
|
|
1238
1288
|
if not outside_read:
|
|
1239
|
-
outside, outside_read = _outside_scope(surface), True
|
|
1289
|
+
outside, outside_read = _outside_scope(surface, classified, skill_dir), True
|
|
1240
1290
|
extra = _documented_extra_at(name, item, outside, skill_dir)
|
|
1241
1291
|
if extra:
|
|
1242
1292
|
declared, among = extra
|
|
@@ -1578,7 +1628,9 @@ def _alias_fix(name: str, file: str, item: dict) -> str:
|
|
|
1578
1628
|
alias_file, _, alias_line = alias.rpartition(":")
|
|
1579
1629
|
named = isinstance(local, str) and bool(local)
|
|
1580
1630
|
renamed = f"`{local}`" if named else "a name a cited file declares"
|
|
1581
|
-
|
|
1631
|
+
of = f"{renamed}, which a cited file declares" if named else renamed
|
|
1632
|
+
head = (f"`{name}` is an alias of {of}; the source declares the alias at `{alias}`, and no line of `{file}` "
|
|
1633
|
+
f"defines `{name}`. ")
|
|
1582
1634
|
cite = (f"the alias: set `source_file` to `{alias_file}` and `source_line` to `{alias_line}`, and move the "
|
|
1583
1635
|
f"citations of `{name}` there.")
|
|
1584
1636
|
if local == "default":
|