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.
Files changed (39) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +7 -7
  3. package/docs/verifying-a-skill.md +9 -9
  4. package/docs/workflows.md +1 -1
  5. package/package.json +1 -1
  6. package/src/shared/scripts/skf-extract-public-api.py +15 -8
  7. package/src/shared/scripts/skf-verify-provenance-completeness.py +331 -105
  8. package/src/skf-analyze-source/SKILL.md +1 -1
  9. package/src/skf-audit-skill/SKILL.md +1 -1
  10. package/src/skf-brief-skill/SKILL.md +1 -1
  11. package/src/skf-campaign/SKILL.md +1 -1
  12. package/src/skf-create-skill/SKILL.md +1 -1
  13. package/src/skf-create-stack-skill/SKILL.md +1 -1
  14. package/src/skf-drop-skill/SKILL.md +1 -1
  15. package/src/skf-export-skill/SKILL.md +1 -1
  16. package/src/skf-forger/SKILL.md +1 -1
  17. package/src/skf-quick-skill/SKILL.md +1 -1
  18. package/src/skf-refine-architecture/SKILL.md +1 -1
  19. package/src/skf-rename-skill/SKILL.md +1 -1
  20. package/src/skf-setup/SKILL.md +1 -1
  21. package/src/skf-test-skill/SKILL.md +1 -1
  22. package/src/skf-test-skill/assets/output-section-formats.md +2 -2
  23. package/src/skf-test-skill/customize.toml +10 -0
  24. package/src/skf-test-skill/references/coverage-check-branches.md +2 -2
  25. package/src/skf-test-skill/references/coverage-check.md +8 -4
  26. package/src/skf-test-skill/references/init.md +1 -1
  27. package/src/skf-test-skill/references/inventory-contract.md +11 -7
  28. package/src/skf-test-skill/references/report.md +2 -1
  29. package/src/skf-test-skill/references/scoring-rules.md +2 -2
  30. package/src/skf-test-skill/references/signature-comparison-rules.md +7 -5
  31. package/src/skf-test-skill/references/source-access-protocol.md +1 -1
  32. package/src/skf-test-skill/scripts/gap-ledger.py +105 -53
  33. package/src/skf-test-skill/scripts/load-coverage-inputs.py +336 -59
  34. package/src/skf-test-skill/scripts/reconcile-coverage.py +46 -18
  35. package/src/skf-test-skill/scripts/score-signatures.py +527 -130
  36. package/src/skf-test-skill/scripts/validate-inventory.py +528 -68
  37. package/src/skf-test-skill/templates/test-report-template.md +4 -3
  38. package/src/skf-update-skill/SKILL.md +1 -1
  39. 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
- it: an Info `observation` gap `Documented extra: {name}` at
111
- `defined_at`. With no `defined_at`, a homonym (its `declared_in`
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
- column-0 declaration of it in a fenced block of a language
130
- floor's `_declared_names` reads (JS/TS, Python, Rust, Go),
131
- cited or not, outside an HTML comment and a data block (json, yaml...);
132
- (3) a heading, or the first cell of a table whose header's
133
- first cell names exports (floor's table headers), whose text is
134
- the name or holds a code span floor reads as the name; (4) a
135
- line whose inline code span is the name, or the name directly
136
- followed by `(`. Tiers 2 to 4 read SKILL.md, then every .md file
137
- under references/, subfolders included, in path order, outside
138
- the frontmatter (3 and 4 outside fenced code and HTML comments
139
- too); within a tier the first file, then its lowest line, wins.
140
- A bare word, or a span that holds the name among other words
141
- (`uitripled add`), documents nothing; with no such line, or
142
- without --skill-dir, the Source is `SKILL.md`. Floor's
143
- InputError (no SKILL.md, or a top-level file it cannot read or
144
- decode) leaves tier 1 empty, and a skill file that cannot be
145
- read leaves tiers 2 to 4 empty. One whose
146
- `excluded.outsideScope` row says the brief
147
- scopes its file out (`excludeGlob`, the `scope.exclude` glob
148
- that matches it, or `outsideInclude`, no `scope.include` glob
149
- covering it) has its own issue, naming the entry point and the
150
- file and line, and remediation: to keep the documentation,
151
- narrow that exclude glob (a removal needs a `demoted-exclude`
152
- amendment) or widen `scope.include`, else remove it. A row the
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 column-0 public declaration of it in a fenced code block (floor's
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
- 4. a line whose inline code span, stripped, is the name, or the name
923
- directly followed by `(`.
924
-
925
- Tiers 2 to 4 read SKILL.md, then every .md file under references/,
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, tiers 3 and 4 outside fenced code
928
- and HTML comments; within a tier the first file, then its lowest line,
929
- wins. A bare word, or a span that holds the name among other words
930
- (`uitripled add`), documents nothing. An input a tier cannot read is
931
- skipped: floor's InputError (no SKILL.md, a file that is not UTF-8)
932
- leaves tier 1 empty, an unreadable file tiers 2 to 4."""
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
- tiers: list[dict[str, str]] = [{}, {}, {}, {}]
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 block's
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
- masked = segments.mask_code("\n".join(lines[opening + 1:closing]), language).split("\n")
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[1].setdefault(declared, f"{rel}:{opening + offset + 2}")
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[2].setdefault(key, f"{rel}:{index + 1}")
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[3].setdefault(key, f"{rel}:{index + 1}")
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) -> dict[str, 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
- head = f"`{name}` is an alias: `{alias}` renames {renamed} to it, and no line of `{file}` defines `{name}`. "
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":