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.
Files changed (85) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +3 -3
  3. package/docs/_data/pinned.yaml +19 -13
  4. package/docs/architecture.md +2 -2
  5. package/docs/concepts.md +4 -4
  6. package/docs/examples.md +32 -25
  7. package/docs/getting-started.md +3 -3
  8. package/docs/how-it-works.md +2 -2
  9. package/docs/index.md +1 -1
  10. package/docs/skill-model.md +41 -39
  11. package/docs/verifying-a-skill.md +41 -20
  12. package/docs/why-skf.md +1 -1
  13. package/docs/workflows.md +2 -2
  14. package/package.json +1 -1
  15. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
  16. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  17. package/src/shared/scripts/skf-build-change-manifest.py +881 -168
  18. package/src/shared/scripts/skf-count-tokens.py +54 -10
  19. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  20. package/src/shared/scripts/skf-emit-result-envelope.py +6 -5
  21. package/src/shared/scripts/skf-extract-public-api.py +439 -107
  22. package/src/shared/scripts/skf-extraction-inventory.py +10 -6
  23. package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
  24. package/src/shared/scripts/skf-parse-gaps.py +135 -26
  25. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  26. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  27. package/src/shared/scripts/skf-shard-body.py +239 -41
  28. package/src/shared/scripts/skf-validate-output.py +12 -3
  29. package/src/shared/scripts/skf-verify-provenance-completeness.py +470 -38
  30. package/src/shared/tool-requirements.yaml +8 -2
  31. package/src/skf-analyze-source/SKILL.md +2 -2
  32. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  33. package/src/skf-audit-skill/SKILL.md +2 -2
  34. package/src/skf-audit-skill/references/re-index.md +2 -2
  35. package/src/skf-brief-skill/SKILL.md +2 -2
  36. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  37. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  38. package/src/skf-campaign/SKILL.md +2 -2
  39. package/src/skf-campaign/references/step-05-skill-loop.md +6 -1
  40. package/src/skf-create-skill/SKILL.md +2 -2
  41. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  42. package/src/skf-create-skill/references/extract.md +2 -1
  43. package/src/skf-create-skill/references/extraction-patterns.md +3 -3
  44. package/src/skf-create-skill/references/report.md +6 -1
  45. package/src/skf-create-stack-skill/SKILL.md +2 -2
  46. package/src/skf-create-stack-skill/references/validate.md +1 -1
  47. package/src/skf-drop-skill/SKILL.md +2 -2
  48. package/src/skf-export-skill/SKILL.md +2 -2
  49. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  50. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  51. package/src/skf-export-skill/references/load-skill.md +1 -1
  52. package/src/skf-export-skill/references/token-report.md +29 -9
  53. package/src/skf-forger/SKILL.md +2 -2
  54. package/src/skf-quick-skill/SKILL.md +2 -2
  55. package/src/skf-refine-architecture/SKILL.md +6 -8
  56. package/src/skf-rename-skill/SKILL.md +2 -2
  57. package/src/skf-setup/SKILL.md +2 -2
  58. package/src/skf-test-skill/SKILL.md +2 -2
  59. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  60. package/src/skf-test-skill/references/coherence-check.md +4 -3
  61. package/src/skf-test-skill/references/coverage-check-branches.md +61 -0
  62. package/src/skf-test-skill/references/coverage-check-tiers.md +1 -1
  63. package/src/skf-test-skill/references/coverage-check.md +45 -69
  64. package/src/skf-test-skill/references/init.md +2 -1
  65. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  66. package/src/skf-test-skill/references/report.md +2 -2
  67. package/src/skf-test-skill/references/scoring-rules.md +5 -4
  68. package/src/skf-test-skill/references/source-access-protocol.md +6 -6
  69. package/src/skf-test-skill/scripts/gap-ledger.py +378 -163
  70. package/src/skf-test-skill/scripts/load-coverage-inputs.py +171 -70
  71. package/src/skf-test-skill/scripts/locate-export-segments.py +101 -20
  72. package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
  73. package/src/skf-test-skill/scripts/score-signatures.py +329 -54
  74. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  75. package/src/skf-test-skill/scripts/validate-inventory.py +108 -9
  76. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  77. package/src/skf-update-skill/SKILL.md +2 -2
  78. package/src/skf-update-skill/references/detect-changes.md +1 -1
  79. package/src/skf-update-skill/references/gap-driven.md +36 -21
  80. package/src/skf-update-skill/references/merge.md +15 -10
  81. package/src/skf-update-skill/references/re-extract.md +3 -1
  82. package/src/skf-update-skill/references/report.md +5 -4
  83. package/src/skf-update-skill/references/write.md +39 -18
  84. package/src/skf-verify-stack/SKILL.md +2 -2
  85. package/tools/cli/lib/tool-check.js +158 -2
@@ -81,7 +81,21 @@ cites (never read; default `metadata.json`):
81
81
  gap `Missing export: {name}`, or a Medium `missing-type` gap
82
82
  `Missing type: {name}` when the --signatures result lists it in
83
83
  `missingTypes`; its Source is the file and line the --surface
84
- result's `exports[]` records for it, else `{meta}`. Each `stale`
84
+ result's `exports[]` records for it, else `{meta}`. When the
85
+ --surface result's `guards.inflation.fires`, its
86
+ `denominator.surfaceSet` is `scope.include` and its
87
+ `denominator.clause` is one whose ladder has a tier A rung
88
+ (stratified-scope, multi-entry), the `missing` names are
89
+ instead one Medium `denominator-inflation` gap `undocumented
90
+ names in the inflated scope.include union` at its
91
+ `inputs.brief` (else `skill-brief.yaml`), beside the guards
92
+ kind's gap at that brief: its issue gives the union's size
93
+ (`guards.inflation.scopeIncludeUnion`) and lists the first 50
94
+ names, each marked a type when the --signatures result lists
95
+ it in `missingTypes` and with the file and line `exports[]`
96
+ records for it, then how many more, and its remediation
97
+ corrects the denominator as the guard's gap says, then re-runs
98
+ Test Skill. Each `stale`
85
99
  name the --stale result (classify-stale) marks `fabricated` is a
86
100
  Critical `fabricated-signature` gap `Fabricated signature:
87
101
  {name}` at its `source` (one with no `source` is INVALID_INPUT,
@@ -111,29 +125,59 @@ cites (never read; default `metadata.json`):
111
125
  `Stale documentation: {name}` at the first line of SKILL.md,
112
126
  then of references/*.md in path order, that writes the name
113
127
  (validate-inventory.py's match, with --skill-dir), else
114
- `SKILL.md`. Scalar and stack branches: a
128
+ `SKILL.md`. One whose `excluded.outsideScope` row says the brief
129
+ scopes its file out (`excludeGlob`, the `scope.exclude` glob
130
+ that matches it, or `outsideInclude`, no `scope.include` glob
131
+ covering it) has its own issue, naming the entry point and the
132
+ file and line, and remediation: to keep the documentation,
133
+ narrow that exclude glob (a removal needs a `demoted-exclude`
134
+ amendment) or widen `scope.include`, else remove it. A row the
135
+ brief does not explain keeps the wording above. Scalar and
136
+ stack branches: a
115
137
  `missingCount` above 0 is one Medium `missing-export` gap
116
138
  `{missingCount} of {denominator} exports not documented` at
117
139
  `{meta}`, except that a scalar count taken from the verified
118
140
  numerator (`numeratorSource` `verified`) is replaced by one
119
141
  `Missing export: {name}` gap per --numerator `absent` name, at
120
- the first --provenance entry of that name, else `{meta}`.
121
- Every per-name gap carries `export`.
142
+ the first --provenance entry of that name, else `{meta}`. A
143
+ scalar result that counts its set (`numeratorSource` `surface`)
144
+ records the `missing` names it lists in place of the count gap,
145
+ as the barrel branch does (the inflated-union summary gap
146
+ under the same gate included), and, when `missingCount` exceeds
147
+ them, one Medium `missing-export` gap `{residual} of
148
+ {denominator} exports not documented beyond the {surfaceSet}
149
+ set` at `{meta}`. A scalar result with `staleApplicable` true
150
+ records its `stale` names as the barrel branch does. Every
151
+ per-name gap carries `export`.
122
152
  guards --input load-coverage-inputs.py's surface result; --metadata.
123
153
  `guards.deflation.fires` is a Medium `metadata-drift` gap at
124
- `{meta}` (else the result's `inputs.metadata`), and
125
- `guards.inflation.fires` a Medium `denominator-inflation` gap at
126
- the result's `inputs.brief` (else `skill-brief.yaml`), whose
127
- remediation recommends `stats.effective_denominator` when
128
- `guards.umbrella.umbrella` is true, else `scope.tier_a_include`.
154
+ `{meta}` (else the result's `inputs.metadata`): on the
155
+ `tier_a_include` basis (`guards.deflation.basis`) titled
156
+ `denominator drift: ...`, giving the tier A count, the pct and
157
+ the stored count, asking to recount by re-running Create Skill
158
+ (which stores the runner's count only when
159
+ `effective_denominator_decision.store` holds and `scope.notes`
160
+ documents the stratification, and otherwise drops the stored
161
+ count) and naming what `denominator` counts until then (its
162
+ `surfaceSet`, or the stored count on the scalar branch); else
163
+ `denominator deflation: ...`. `guards.inflation.fires` is a
164
+ Medium `denominator-inflation` gap at the result's
165
+ `inputs.brief` (else `skill-brief.yaml`), whose remediation adds
166
+ or edits `scope.tier_a_include`, listing the files rather than
167
+ the root barrel when `guards.umbrella.umbrella` is true. When
168
+ both fire on the `scope.include` basis, both remediations name
169
+ that one fix and say the `scope.include` union is itself
170
+ inflated. No remediation sets `stats.effective_denominator` by
171
+ hand, which Update Skill's recount drops.
129
172
  `guards.staleScope.fires` is one Medium `brief-scope-stale` gap
130
173
  at that brief, whose issue names each `unmatchedInclude` glob,
131
174
  each `restored` name with its file and each
132
- `unmatchedTierAInclude` glob. The deflation and inflation gaps
133
- say the brief has no `scope.tier_a_include` when
134
- `guards.tierAInclude` lists no glob, that no glob matches a file
135
- when each is an `unmatchedTierAInclude` glob, and else that no
136
- glob counts a public name (a surface without
175
+ `unmatchedTierAInclude` glob. The `scope.include`-basis
176
+ deflation gap and the inflation gap say the brief has no
177
+ `scope.tier_a_include` when `guards.tierAInclude` lists no glob,
178
+ that no glob matches a file when each is an
179
+ `unmatchedTierAInclude` glob, and else that no glob counts a
180
+ public name (a surface without
137
181
  `guards.tierAInclude` reads the `unmatchedTierAInclude` globs
138
182
  alone). A stored `stats.effective_denominator` of 0 (`pct`
139
183
  null) is written as a count against 0, not a percentage, and
@@ -145,7 +189,10 @@ cites (never read; default `metadata.json`):
145
189
  --input check-metadata-coherence.py's result; --metadata. Each
146
190
  `findings[]` entry, titled as it is and with its `detail` as the
147
191
  issue, at `{meta}`: Medium is a Medium `metadata-drift` gap, Info
148
- an Info `multi-denominator` gap.
192
+ an Info `multi-denominator` gap. A `metadata-drift` gap whose
193
+ title names the documented surface explains the `Type::method`
194
+ entries its counts differ by; a barrel one sends the fix to
195
+ Create Skill, which rebuilds `exports[]` from the source.
149
196
  provenance-line
150
197
  --input skf-verify-provenance-completeness.py verify's result.
151
198
  Each `stale[]` item whose reason is `line-not-definition`, at
@@ -220,7 +267,6 @@ import errno
220
267
  import importlib.util
221
268
  import json
222
269
  import os
223
- import posixpath
224
270
  import re
225
271
  import secrets
226
272
  import sys
@@ -713,7 +759,8 @@ _SIBLINGS: dict[str, object] = {}
713
759
 
714
760
  def _sibling(filename: str):
715
761
  """A script beside this one, loaded once: validate-inventory.py (the
716
- documented-name match the stale-documentation Source uses)."""
762
+ documented-name match the stale-documentation Source uses, and the
763
+ skill-citation scan the homonym pick reads)."""
717
764
  module = _SIBLINGS.get(filename)
718
765
  if module is None:
719
766
  path = Path(__file__).resolve().parent / filename
@@ -825,13 +872,25 @@ def _documenting_line(skill_dir: Path, name: str) -> str | None:
825
872
  return None
826
873
 
827
874
 
828
- def _outside_scope(surface: dict | None) -> set[str] | None:
875
+ def _scoped_out_by(row: dict) -> bool:
876
+ """Whether an `excluded.outsideScope` row says the brief scopes its file
877
+ out: a file, with the `scope.exclude` glob that matches it
878
+ (`excludeGlob`) or no `scope.include` glob covering it
879
+ (`outsideInclude`)."""
880
+ return (isinstance(row.get("file"), str) and bool(row["file"])
881
+ and ((isinstance(row.get("excludeGlob"), str) and bool(row["excludeGlob"]))
882
+ or row.get("outsideInclude") is True))
883
+
884
+
885
+ def _outside_scope(surface: dict | None) -> dict[str, dict] | None:
829
886
  """The names a surface result's `excluded.outsideScope` lists (defined in
830
- a file the brief scopes out). None with no surface, no such list, or a
831
- surface built without an extraction (no `extraction` object) or from
832
- one that did not read the whole scope (a `no-ast-grep` status, a
833
- `truncated` run, or `fallback.needed`): only an extraction fills that
834
- list, so the names a brief scoped out are then unknown."""
887
+ a file out of scope), each with its row: the first the brief explains
888
+ (`_scoped_out_by`), else the first with a file, else its first. None
889
+ with no surface, no such list, or a surface built without an extraction
890
+ (no `extraction` object) or from one that did not read the whole scope
891
+ (a `no-ast-grep` status, a `truncated` run, or `fallback.needed`): only
892
+ an extraction fills that list, so the names a brief scoped out are then
893
+ unknown."""
835
894
  excluded = (surface or {}).get("excluded")
836
895
  if excluded is None:
837
896
  return None
@@ -849,7 +908,16 @@ def _outside_scope(surface: dict | None) -> set[str] | None:
849
908
  if (extraction.get("status") == "no-ast-grep" or extraction.get("truncated")
850
909
  or (isinstance(fallback, dict) and fallback.get("needed"))):
851
910
  return None
852
- return {row["name"] for row in rows if isinstance(row, dict) and isinstance(row.get("name"), str)}
911
+ def rank(row: dict) -> tuple[bool, bool]:
912
+ return _scoped_out_by(row), isinstance(row.get("file"), str) and bool(row["file"])
913
+
914
+ outside: dict[str, dict] = {}
915
+ for row in rows:
916
+ if isinstance(row, dict) and isinstance(row.get("name"), str):
917
+ known = outside.get(row["name"])
918
+ if known is None or rank(row) > rank(known):
919
+ outside[row["name"]] = row
920
+ return outside
853
921
 
854
922
 
855
923
  # The extensions classify-stale reads a `defined_at` in.
@@ -860,60 +928,7 @@ _DEFINED_AT_RE = re.compile(
860
928
  r"^.+(?:" + "|".join(re.escape(e) for e in sorted(_DEFINED_AT_EXTENSIONS)) + r"):[1-9]\d*$", re.IGNORECASE)
861
929
 
862
930
 
863
- # A source citation the skill writes, `[AST:path:L12]`, `[SRC:path:L12-34]` or `[SRC:path:L12-L34]` (one with no
864
- # `:L<n>` part is ignored). Groups: prefix, path, first line, the `L` of a range's end, the range's end.
865
- # Keep identical to _SKILL_CITATION_RE in skf-verify-provenance-completeness.py.
866
- _SKILL_CITATION_RE = re.compile(
867
- r"\[(AST|SRC):([^\[\]\n]+?):L(\d+)(?:-(L?)(\d+))?\]"
868
- )
869
- # Any bracketed citation (`[AST:...]`, `[SRC:...]`, `[EXT:...]`): a line names a documented name outside them.
870
- _ANY_CITATION_RE = re.compile(r"\[[A-Z]+:[^\[\]\n]*\]")
871
-
872
-
873
- def _cited_path(path: str) -> str:
874
- """A cited or `declared_in` path as both compare: forward slashes, no `./`."""
875
- path = path.strip().replace("\\", "/")
876
- while path.startswith("./"):
877
- path = path[2:]
878
- return posixpath.normpath(path) if path else path
879
-
880
-
881
- def skill_markdown_files(skill_dir: Path) -> list[Path]:
882
- """`SKILL.md` then every `references/**/*.md`, in a stable order.
883
- Keep identical to skill_markdown_files in skf-verify-provenance-completeness.py."""
884
- files: list[Path] = []
885
- skill_md = skill_dir / "SKILL.md"
886
- if skill_md.is_file():
887
- files.append(skill_md)
888
- refs = skill_dir / "references"
889
- if refs.is_dir():
890
- files.extend(sorted(p for p in refs.rglob("*.md") if p.is_file()))
891
- return files
892
-
893
-
894
- def _skill_cited_files(skill_dir: Path, name: str) -> set[str]:
895
- """The files the skill's `[AST:]` and `[SRC:]` citations name on a line of
896
- SKILL.md or references/**/*.md that writes `name` outside its citations
897
- (validate-inventory.py's match), or on the next non-blank line (a
898
- heading, then its citation)."""
899
- pattern = _sibling("validate-inventory.py").name_pattern(name)
900
- cited: set[str] = set()
901
- for path in skill_markdown_files(skill_dir):
902
- try:
903
- text = path.read_bytes().decode("utf-8-sig", errors="replace")
904
- except OSError:
905
- continue
906
- lines = text.split("\n")
907
- for number, line in enumerate(lines):
908
- if not pattern.search(_ANY_CITATION_RE.sub(" ", line)):
909
- continue
910
- following = next((ln for ln in lines[number + 1:] if ln.strip()), "")
911
- for cited_line in (line, following):
912
- cited.update(_cited_path(m.group(2)) for m in _SKILL_CITATION_RE.finditer(cited_line))
913
- return cited
914
-
915
-
916
- def _documented_extra_at(name: str, item: dict, outside: set[str] | None,
931
+ def _documented_extra_at(name: str, item: dict, outside: dict[str, dict] | None,
917
932
  skill_dir: str | None = None) -> tuple[str, int] | None:
918
933
  """Where the source declares a stale name that is a documented extra, given
919
934
  a surface built from an extraction whose `excluded.outsideScope` does not
@@ -936,10 +951,12 @@ def _documented_extra_at(name: str, item: dict, outside: set[str] | None,
936
951
  if not skill_dir or not isinstance(declared_in, list) or not all(
937
952
  isinstance(d, str) and _DEFINED_AT_RE.match(d) for d in declared_in):
938
953
  return None
939
- by_file = {_cited_path(d.rsplit(":", 1)[0]): d for d in declared_in}
954
+ # validate-inventory.py's skill-citation scan, which score-signatures.py's plan reads too.
955
+ citations = _sibling("validate-inventory.py")
956
+ by_file = {citations._cited_path(d.rsplit(":", 1)[0]): d for d in declared_in}
940
957
  if len(by_file) < 2:
941
958
  return None
942
- cited = _skill_cited_files(Path(skill_dir), name)
959
+ cited = citations._skill_cited_files(Path(skill_dir), name)
943
960
  chosen = [d for f, d in sorted(by_file.items()) if f in cited]
944
961
  return (chosen[0], len(by_file)) if len(chosen) == 1 else None
945
962
 
@@ -961,6 +978,196 @@ def _missing_export(name: str, where: str | None, meta: str, kind: str = "export
961
978
  export=name)
962
979
 
963
980
 
981
+ def _brief_of(surface: dict | None) -> str:
982
+ """The brief a surface result read: its `inputs.brief`, else skill-brief.yaml."""
983
+ inputs = (surface or {}).get("inputs")
984
+ brief = inputs.get("brief") if isinstance(inputs, dict) else None
985
+ return brief if isinstance(brief, str) and brief else _DEFAULT_BRIEF
986
+
987
+
988
+ # The most names the issue of the inflated-union summary gap lists (#795).
989
+ _INFLATED_NAMES_LISTED = 50
990
+ # The clauses whose ladder has a tier A rung, so that a brief's `scope.tier_a_include` corrects the
991
+ # denominator an inflated `scope.include` union gave them (#795).
992
+ # Keep identical to the CLAUSE_LADDERS clauses with a `tier_a_include` rung in load-coverage-inputs.py.
993
+ _TIER_A_CLAUSES = frozenset({"stratified-scope", "multi-entry"})
994
+
995
+
996
+ def _inflated_union(surface: dict | None) -> bool:
997
+ """Whether the barrel branch counts an inflated `scope.include` union a
998
+ tier A list corrects: the surface's inflation guard fires, its
999
+ `denominator.surfaceSet` is `scope.include` and its `denominator.clause`
1000
+ has a tier A rung. A set the surface lacked (`absentSet`) counted `all`
1001
+ in its place, and the specific-modules and empty-barrel clauses count
1002
+ `scope.include` whatever tier A list the brief has: each name there
1003
+ stays its own gap."""
1004
+ guards = (surface or {}).get("guards")
1005
+ inflation = guards.get("inflation") if isinstance(guards, dict) else None
1006
+ denominator = (surface or {}).get("denominator")
1007
+ return (isinstance(inflation, dict) and inflation.get("fires") is True and isinstance(denominator, dict)
1008
+ and denominator.get("surfaceSet") == "scope.include" and denominator.get("clause") in _TIER_A_CLAUSES)
1009
+
1010
+
1011
+ def _inflated_union_gap(names: list[str], where: dict[str, str], types: set[str], brief: str,
1012
+ union: object) -> dict:
1013
+ """One gap at the brief for the names an inflated `scope.include` union
1014
+ counts and the skill does not document, in place of a gap per name: the
1015
+ union sweeps in infrastructure, so most of them belong to no authored
1016
+ surface (#795). It sits beside the guard's own `denominator-inflation`
1017
+ gap at that brief, whose fix it defers to."""
1018
+ def label(name: str) -> str:
1019
+ notes = (["a type"] if name in types else []) + ([f"`{where[name]}`"] if where.get(name) else [])
1020
+ return f"`{name}` ({', '.join(notes)})" if notes else f"`{name}`"
1021
+
1022
+ listed = [label(n) for n in names[:_INFLATED_NAMES_LISTED]]
1023
+ more = len(names) - len(listed)
1024
+ named = ", ".join(listed) + (f", and {more} more" if more else "")
1025
+ size = f" ({union} names)" if _count(union) is not None else ""
1026
+ return _gap(
1027
+ "Medium", "denominator-inflation", "undocumented names in the inflated scope.include union", brief,
1028
+ f"Correct the denominator first, as the denominator-inflation gap for `{brief}` says; then re-run Test "
1029
+ "Skill, and the names the corrected denominator counts that the skill does not document come back as gaps.",
1030
+ issue=f"the inflation guard fired: the `scope.include` union the denominator counts{size} sweeps in names "
1031
+ f"outside the authored surface, and SKILL.md and references/ do not document {len(names)} of them: "
1032
+ f"{named}")
1033
+
1034
+
1035
+ def _scoped_out_gap(name: str, row: dict, doc: str | None, brief: str) -> dict:
1036
+ """The Medium `stale-documentation` gap of a documented name an entry
1037
+ point exports from a file the brief scopes out (#796): its row names
1038
+ the `scope.exclude` glob that matches the file, or says no
1039
+ `scope.include` glob covers it, or both."""
1040
+ file, line, entry = row["file"], _count(row.get("line")), row.get("entry")
1041
+ at = f"{file}:{line}" if line else file
1042
+ glob = row.get("excludeGlob") if isinstance(row.get("excludeGlob"), str) and row.get("excludeGlob") else None
1043
+ uncovered = row.get("outsideInclude") is True
1044
+ causes = (["no `scope.include` glob covers that file"] if uncovered else []) + (
1045
+ [f"its `scope.exclude` glob `{glob}` matches {'it' if uncovered else 'that file'}"] if glob else [])
1046
+ fixes = (["widen `scope.include` to cover that file"] if uncovered else []) + (
1047
+ [f"narrow `{glob}` so it no longer matches that file (to remove the glob instead, record a `demoted-exclude` "
1048
+ "scope-expansion amendment in the brief)"] if glob else [])
1049
+ who = f"the entry point `{entry}`" if isinstance(entry, str) and entry else "an entry point"
1050
+ return _gap(
1051
+ "Medium", "stale-documentation", f"Stale documentation: {name}", doc or "SKILL.md",
1052
+ f"Decide whether `{name}` belongs on the surface: `{brief}` scopes out `{at}`, where it is defined, since "
1053
+ f"{' and '.join(causes)}. To keep its documentation, {' and '.join(fixes)}; else remove the documentation "
1054
+ f"of `{name}` at {doc or 'SKILL.md'}.",
1055
+ issue=f"{f'`{doc}`' if doc else 'SKILL.md'} documents `{name}`, which {who} exports and `{at}` defines, a "
1056
+ f"file `{brief}` scopes out (`excluded.outsideScope`)",
1057
+ export=name)
1058
+
1059
+
1060
+ def _named_inputs(surface: object, signatures: object) -> tuple[dict | None, set[str], set[str]]:
1061
+ """The --surface result, the names --signatures lists in `missingTypes` and the names whose documented
1062
+ signature it compared (`comparedNames`), for a result that names its missing and stale exports."""
1063
+ types: set[str] = set()
1064
+ scored: set[str] = set()
1065
+ if signatures is not None:
1066
+ signatures = _object(signatures, "--signatures (score-signatures.py)", "missingTypes")
1067
+ types = set(_names(signatures, "missingTypes", "--signatures"))
1068
+ scored = set(_names(signatures, "comparedNames", "--signatures"))
1069
+ if surface is not None:
1070
+ surface = _object(surface, "--surface (load-coverage-inputs.py surface)", "exports")
1071
+ return surface, types, scored
1072
+
1073
+
1074
+ def _missing_gaps(cov: dict, surface: dict | None, types: set[str], meta: str) -> list[dict]:
1075
+ """One gap per `missing` name, at the file and line the surface records for it, or, when the surface counts
1076
+ an inflated `scope.include` union a tier A list corrects (`_inflated_union`), one summary gap at the brief in
1077
+ their place (#795)."""
1078
+ where = _surface_sources(surface)
1079
+ missing = _names(cov, "missing", "--input")
1080
+ if missing and _inflated_union(surface):
1081
+ union = ((surface.get("guards") or {}).get("inflation") or {}).get("scopeIncludeUnion")
1082
+ return [_inflated_union_gap(missing, where, types, _brief_of(surface), union)]
1083
+ return [_missing_export(name, where.get(name), meta, "type" if name in types else "export") for name in missing]
1084
+
1085
+
1086
+ def _stale_gaps(cov: dict, surface: dict | None, stale: object, scored: set[str],
1087
+ skill_dir: str | None) -> list[dict]:
1088
+ """One gap per `stale` name: fabricated, a documented extra, or stale documentation (with its own wording
1089
+ for a name defined in a file the brief scopes out, #796)."""
1090
+ records: list[dict] = []
1091
+ # `excluded` is read once a stale name may be a documented extra
1092
+ outside: dict[str, dict] | None = None
1093
+ outside_read = False
1094
+ classified: dict[str, dict] = {}
1095
+ if stale is not None:
1096
+ if not isinstance(stale, list):
1097
+ raise AdapterError("--stale is not classify-stale JSON (a list)")
1098
+ classified = {i["name"]: i for i in stale if isinstance(i, dict) and isinstance(i.get("name"), str)}
1099
+ for name in _names(cov, "stale", "--input"):
1100
+ item = classified.get(name) or {}
1101
+ cited = item.get("source")
1102
+ if item.get("fabricated") is True:
1103
+ if not isinstance(cited, str) or not cited:
1104
+ # a Critical gap needs the line it cites: never downgrade it
1105
+ raise AdapterError(f"--stale: {name!r} is fabricated with no source")
1106
+ records.append(_gap(
1107
+ "Critical", "fabricated-signature", f"Fabricated signature: {name}", cited,
1108
+ f"Remove `{name}` from SKILL.md and references/, or correct it to the export the source "
1109
+ f"defines: no file its provenance cites defines it (`{cited}`).",
1110
+ issue=f"SKILL.md documents `{name}`, which the source API surface lacks and the cited "
1111
+ "file does not define",
1112
+ export=name))
1113
+ continue
1114
+ if not outside_read:
1115
+ outside, outside_read = _outside_scope(surface), True
1116
+ extra = _documented_extra_at(name, item, outside, skill_dir)
1117
+ doc = _documenting_line(Path(skill_dir), name) if skill_dir else None
1118
+ if extra:
1119
+ declared, among = extra
1120
+ # a homonym: the one declaring file the skill's own citations name
1121
+ picked = (f"; of the {among} files that declare it, the skill cites only "
1122
+ f"`{declared.rsplit(':', 1)[0]}`") if among else ""
1123
+ # never routed: update-skill leaves an `observation` alone. Signature scoring compares a
1124
+ # documented signature of a name on the surface's records (a nested sub-package's) alone.
1125
+ checked = ("Signature scoring compares its documented signature with the surface's record of it."
1126
+ if name in scored else
1127
+ "Its documented signature was not checked against that declaration.")
1128
+ records.append(_gap(
1129
+ "Info", "observation", f"Documented extra: {name}", declared,
1130
+ f"No change: `{name}` is a documented extra, a name the source still declares at "
1131
+ f"`{declared}` outside the enumerated surface, and its documentation stays. {checked}",
1132
+ issue=f"{f'`{doc}`' if doc else 'the skill'} documents `{name}`, which the enumerated source API "
1133
+ f"surface lacks and the source still declares{picked}",
1134
+ export=name))
1135
+ continue
1136
+ if outside is not None and name in outside and _scoped_out_by(outside[name]):
1137
+ # an entry point exports it from a file the brief scopes out (#796)
1138
+ records.append(_scoped_out_gap(name, outside[name], doc, _brief_of(surface)))
1139
+ continue
1140
+ records.append(_gap(
1141
+ "Medium", "stale-documentation", f"Stale documentation: {name}", doc or "SKILL.md",
1142
+ f"Check `{name}` against the source and update or remove its documentation at "
1143
+ f"{doc or 'SKILL.md'}: the enumerated source surface does not export it.",
1144
+ issue=f"SKILL.md documents `{name}`, which the enumerated source API surface lacks",
1145
+ export=name))
1146
+ return records
1147
+
1148
+
1149
+ def _residual_gap(cov: dict, meta: str) -> list[dict]:
1150
+ """A counted set's residual: the exports the stored count counts beyond the names the set lists, one gap
1151
+ when `missingCount` exceeds the listed `missing` names."""
1152
+ listed = len(_names(cov, "missing", "--input"))
1153
+ missing, denominator = _count(cov.get("missingCount")), _count(cov.get("denominator"))
1154
+ residual = (missing or 0) - listed
1155
+ if residual <= 0:
1156
+ return []
1157
+ set_name = cov.get("surfaceSet") if isinstance(cov.get("surfaceSet"), str) else "named"
1158
+ size = _count(cov.get("surfaceSetSize"))
1159
+ names = f"{size} names" if size is not None else "names"
1160
+ return [_gap(
1161
+ "Medium", "missing-export", f"{residual} of {denominator} exports not documented beyond the {set_name} set",
1162
+ meta,
1163
+ f"Find the {residual} exports the stored count (`stats.effective_denominator`) counts beyond the {names} "
1164
+ f"of the `{set_name}` set (`{meta}` lists the exports) and document each with its signature; a stale "
1165
+ "stored count is recounted by re-running Create Skill.",
1166
+ issue=f"of the {denominator} exports the stored count counts, {missing} are not documented: the "
1167
+ f"`{set_name}` set names {listed} of them, and the other {residual} are exports the count holds "
1168
+ "beyond the set's names")]
1169
+
1170
+
964
1171
  def from_coverage(cov: object, surface: object = None, signatures: object = None, numerator: object = None,
965
1172
  stale: object = None, provenance: object = None, skill_dir: str | None = None,
966
1173
  metadata: str | None = None) -> list[dict]:
@@ -970,70 +1177,16 @@ def from_coverage(cov: object, surface: object = None, signatures: object = None
970
1177
  branch = cov["branch"]
971
1178
  records: list[dict] = []
972
1179
  if branch == "enumerated":
973
- types: set[str] = set()
974
- # the names whose documented signature signature scoring compared
975
- scored: set[str] = set()
976
- if signatures is not None:
977
- signatures = _object(signatures, "--signatures (score-signatures.py)", "missingTypes")
978
- types = set(_names(signatures, "missingTypes", "--signatures"))
979
- scored = set(_names(signatures, "comparedNames", "--signatures"))
980
- if surface is not None:
981
- surface = _object(surface, "--surface (load-coverage-inputs.py surface)", "exports")
982
- where = _surface_sources(surface)
983
- # `excluded` is read once a stale name may be a documented extra
984
- outside: set[str] | None = None
985
- outside_read = False
986
- for name in _names(cov, "missing", "--input"):
987
- records.append(_missing_export(name, where.get(name), meta, "type" if name in types else "export"))
988
- classified: dict[str, dict] = {}
989
- if stale is not None:
990
- if not isinstance(stale, list):
991
- raise AdapterError("--stale is not classify-stale JSON (a list)")
992
- classified = {i["name"]: i for i in stale if isinstance(i, dict) and isinstance(i.get("name"), str)}
993
- for name in _names(cov, "stale", "--input"):
994
- item = classified.get(name) or {}
995
- cited = item.get("source")
996
- if item.get("fabricated") is True:
997
- if not isinstance(cited, str) or not cited:
998
- # a Critical gap needs the line it cites: never downgrade it
999
- raise AdapterError(f"--stale: {name!r} is fabricated with no source")
1000
- records.append(_gap(
1001
- "Critical", "fabricated-signature", f"Fabricated signature: {name}", cited,
1002
- f"Remove `{name}` from SKILL.md and references/, or correct it to the export the source "
1003
- f"defines: no file its provenance cites defines it (`{cited}`).",
1004
- issue=f"SKILL.md documents `{name}`, which the source API surface lacks and the cited "
1005
- "file does not define",
1006
- export=name))
1007
- continue
1008
- if not outside_read:
1009
- outside, outside_read = _outside_scope(surface), True
1010
- extra = _documented_extra_at(name, item, outside, skill_dir)
1011
- doc = _documenting_line(Path(skill_dir), name) if skill_dir else None
1012
- if extra:
1013
- declared, among = extra
1014
- # a homonym: the one declaring file the skill's own citations name
1015
- picked = (f"; of the {among} files that declare it, the skill cites only "
1016
- f"`{declared.rsplit(':', 1)[0]}`") if among else ""
1017
- # never routed: update-skill leaves an `observation` alone. Signature scoring compares a
1018
- # documented signature of a name on the surface's records (a nested sub-package's) alone.
1019
- checked = ("Signature scoring compares its documented signature with the surface's record of it."
1020
- if name in scored else
1021
- "Its documented signature was not checked against that declaration.")
1022
- records.append(_gap(
1023
- "Info", "observation", f"Documented extra: {name}", declared,
1024
- f"No change: `{name}` is a documented extra, a name the source still declares at "
1025
- f"`{declared}` outside the enumerated surface, and its documentation stays. {checked}",
1026
- issue=f"{f'`{doc}`' if doc else 'the skill'} documents `{name}`, which the enumerated source API "
1027
- f"surface lacks and the source still declares{picked}",
1028
- export=name))
1029
- continue
1030
- records.append(_gap(
1031
- "Medium", "stale-documentation", f"Stale documentation: {name}", doc or "SKILL.md",
1032
- f"Check `{name}` against the source and update or remove its documentation at "
1033
- f"{doc or 'SKILL.md'}: the enumerated source surface does not export it.",
1034
- issue=f"SKILL.md documents `{name}`, which the enumerated source API surface lacks",
1035
- export=name))
1180
+ surface, types, scored = _named_inputs(surface, signatures)
1181
+ records += _missing_gaps(cov, surface, types, meta)
1182
+ records += _stale_gaps(cov, surface, stale, scored, skill_dir)
1036
1183
  elif branch in ("scalar", "stack"):
1184
+ # A scalar result that counts its set lists its missing names, and one on a surface read from the
1185
+ # source its stale names: each is recorded as the barrel branch's are.
1186
+ counted = branch == "scalar" and cov.get("numeratorSource") == "surface"
1187
+ stale_named = branch == "scalar" and cov.get("staleApplicable") is True
1188
+ if counted or stale_named:
1189
+ surface, types, scored = _named_inputs(surface, signatures)
1037
1190
  if branch == "scalar" and cov.get("numeratorSource") == "verified":
1038
1191
  if numerator is None:
1039
1192
  raise AdapterError("the scalar count used the verified numerator: pass its result with "
@@ -1043,6 +1196,9 @@ def from_coverage(cov: object, surface: object = None, signatures: object = None
1043
1196
  where = _provenance_sources(_object(provenance, "--provenance (provenance map)", "entries")
1044
1197
  if provenance is not None else None)
1045
1198
  records += [_missing_export(name, where.get(name), meta) for name in absent]
1199
+ elif counted:
1200
+ records += _missing_gaps(cov, surface, types, meta)
1201
+ records += _residual_gap(cov, meta)
1046
1202
  else:
1047
1203
  missing, denominator = _count(cov.get("missingCount")), _count(cov.get("denominator"))
1048
1204
  if missing:
@@ -1052,6 +1208,8 @@ def from_coverage(cov: object, surface: object = None, signatures: object = None
1052
1208
  f"name (`{meta}` lists the exports) and document each with its signature.",
1053
1209
  issue=f"SKILL.md and references/ name {cov.get('documented')} of the {denominator} exports "
1054
1210
  f"the {branch} denominator counts"))
1211
+ if stale_named:
1212
+ records += _stale_gaps(cov, surface, stale, scored, skill_dir)
1055
1213
  elif branch != "docsOnly":
1056
1214
  raise AdapterError(f"--input (reconcile-coverage.py): unknown branch {branch!r}")
1057
1215
  return records
@@ -1086,42 +1244,86 @@ def from_guards(surface: object, metadata: str | None = None) -> list[dict]:
1086
1244
  else:
1087
1245
  no_tier_a = "no `scope.tier_a_include` glob in the brief counts a public name"
1088
1246
  deflation = guards.get("deflation") or {}
1089
- if deflation.get("fires") is True:
1247
+ inflation = guards.get("inflation") or {}
1248
+ stored = _count(deflation.get("effectiveDenominator")) or 0
1249
+ # No remediation sets `stats.effective_denominator` by hand: Update Skill's recount drops such a value (#803).
1250
+ # Both guards on the scope.include union name one fix, the brief's tier A list (#788).
1251
+ both = (deflation.get("fires") is True and inflation.get("fires") is True
1252
+ and deflation.get("basis") != "tier_a_include")
1253
+ # On an umbrella barrel the names are re-exports, each counted at the file that defines it, never the barrel.
1254
+ umbrella = (" rather than the root barrel, an umbrella of re-exports"
1255
+ if (guards.get("umbrella") or {}).get("umbrella") is True else "")
1256
+ verb = (f"Edit `scope.tier_a_include` in `{brief}` so its globs list" if brief_tier_a
1257
+ else f"Add `scope.tier_a_include` to `{brief}`, listing")
1258
+ # Only Create Skill always writes metadata.json, and it stores the runner's count only under this rule.
1259
+ stores = ("when `effective_denominator_decision.store` holds and the brief's `scope.notes` documents the "
1260
+ "stratification, and otherwise drops the stored count")
1261
+ joint = (f"{verb} the files that define the authored names{umbrella}: the `scope.include` union is itself "
1262
+ "inflated, so neither it nor the smaller stored count measures the authored surface. Then re-run "
1263
+ f"Create Skill to recount `stats.effective_denominator` in `{meta}` from the tier A set: it stores "
1264
+ f"that count {stores}.")
1265
+ if deflation.get("fires") is True and deflation.get("basis") == "tier_a_include":
1266
+ # A stored count the brief's tier A set disagrees with, either way: stale, so count it again (#813).
1267
+ pct = deflation.get("pct")
1268
+ if pct is None: # a stored 0: no percentage against it
1269
+ against = f" against a `stats.effective_denominator` of {stored}"
1270
+ else:
1271
+ below = isinstance(pct, (int, float)) and pct < 0
1272
+ against = (f", {-pct if below else pct}% {'below' if below else 'above'} `stats.effective_denominator` "
1273
+ f"({deflation.get('effectiveDenominator')})")
1274
+ # Until then: the set the denominator actually counts (a ladder with no tier A rung counts scope.include),
1275
+ # or, on an incomplete extraction's tier A set below the stored count, the stored count.
1276
+ denominator = surface.get("denominator") if isinstance(surface.get("denominator"), dict) else {}
1277
+ extraction = surface.get("extraction") if isinstance(surface.get("extraction"), dict) else {}
1278
+ if denominator.get("branch") == "scalar":
1279
+ until = " Until then the stored count stays the denominator" + (
1280
+ ": the extraction is incomplete, so the tier A set can miss names." if extraction.get("status")
1281
+ == "incomplete" else ".")
1282
+ elif isinstance(denominator.get("surfaceSet"), str):
1283
+ until = f" Until then Test Skill counts the `{denominator['surfaceSet']}` set as the denominator."
1284
+ else:
1285
+ until = ""
1286
+ records.append(_gap(
1287
+ "Medium", "metadata-drift",
1288
+ "denominator drift: effective_denominator disagrees with the brief's tier_a_include set", meta,
1289
+ f"Re-run Create Skill to recount `stats.effective_denominator` in `{meta}`: it stores the runner's count "
1290
+ f"of the brief's `scope.tier_a_include` set {stores}.{until}",
1291
+ issue=f"the brief's `scope.tier_a_include` set counts {deflation.get('rederived')} exports{against}"))
1292
+ elif deflation.get("fires") is True:
1090
1293
  if deflation.get("pct") is None: # a stored 0: no percentage above it
1091
- above = (f" against a `stats.effective_denominator` of "
1092
- f"{_count(deflation.get('effectiveDenominator')) or 0}")
1294
+ above = f" against a `stats.effective_denominator` of {stored}"
1093
1295
  else:
1094
1296
  above = (f", {deflation.get('pct')}% above `stats.effective_denominator` "
1095
1297
  f"({deflation.get('effectiveDenominator')})")
1096
- if deflation.get("pct") is None: # a 0 measures no tier: count it again
1298
+ if both:
1299
+ fix = joint
1300
+ elif deflation.get("pct") is None: # a 0 measures no tier: count it again
1097
1301
  first = ("Edit `scope.tier_a_include` in the brief so its globs match the files that define the "
1098
1302
  "authored tier" if brief_tier_a else "Check `scope.include` in the brief")
1099
1303
  fix = (f"{first}, then re-run Create Skill so `stats.effective_denominator` in `{meta}` is counted "
1100
1304
  "again.")
1101
1305
  elif brief_tier_a:
1102
- fix = (f"Set `stats.effective_denominator` in `{meta}` to the public surface the source re-derives, "
1103
- "or edit `scope.tier_a_include` in the brief so its globs match the files of the authored tier "
1104
- "the smaller count measures.")
1306
+ fix = (f"Re-run Create Skill to recount `stats.effective_denominator` in `{meta}` from the public surface "
1307
+ f"the source re-derives (it stores that count {stores}), or edit `scope.tier_a_include` in the "
1308
+ "brief so its globs match the files of the authored tier the smaller count measures.")
1105
1309
  else:
1106
- fix = (f"Set `stats.effective_denominator` in `{meta}` to the public surface the source re-derives, "
1107
- "or add `scope.tier_a_include` to the brief to name the authored tier the smaller count "
1108
- "measures.")
1310
+ fix = (f"Re-run Create Skill to recount `stats.effective_denominator` in `{meta}` from the public surface "
1311
+ f"the source re-derives (it stores that count {stores}), or add `scope.tier_a_include` to the "
1312
+ "brief to name the authored tier the smaller count measures.")
1109
1313
  records.append(_gap(
1110
1314
  "Medium", "metadata-drift",
1111
1315
  "denominator deflation: effective_denominator below source public surface without tier_a_include",
1112
1316
  meta, fix,
1113
1317
  issue=f"the re-derived surface counts {deflation.get('rederived')} exports{above}, and {no_tier_a}"))
1114
- inflation = guards.get("inflation") or {}
1115
1318
  if inflation.get("fires") is True:
1116
- if (guards.get("umbrella") or {}).get("umbrella") is True:
1117
- fix = (f"Set `stats.effective_denominator` in `{meta}` to the authored surface: the root barrel "
1118
- "is an umbrella of re-exports, so a `scope.tier_a_include` glob would still count them.")
1319
+ if both:
1320
+ fix = joint
1119
1321
  elif brief_tier_a:
1120
1322
  fix = (f"Edit `scope.tier_a_include` in `{brief}` so its globs match the files of the authored "
1121
- "surface: the denominator then counts it instead of the coarse `scope.include` union.")
1323
+ f"surface{umbrella}: the denominator then counts it instead of the coarse `scope.include` union.")
1122
1324
  else:
1123
- fix = (f"Add `scope.tier_a_include` to `{brief}`, listing the files of the authored surface, so "
1124
- "the denominator counts it instead of the coarse `scope.include` union.")
1325
+ fix = (f"Add `scope.tier_a_include` to `{brief}`, listing the files of the authored surface{umbrella}, "
1326
+ "so the denominator counts it instead of the coarse `scope.include` union.")
1125
1327
  records.append(_gap(
1126
1328
  "Medium", "denominator-inflation",
1127
1329
  "denominator inflation: coarse scope.include union exceeds authored surface", brief, fix,
@@ -1198,11 +1400,24 @@ def from_metadata_coherence(result: object, metadata: str | None = None) -> list
1198
1400
  if not isinstance(finding, dict) or not isinstance(finding.get("title"), str):
1199
1401
  raise AdapterError(f"--input: findings[{index}] has no title")
1200
1402
  detail = finding.get("detail") if isinstance(finding.get("detail"), str) else None
1201
- if finding.get("severity") == "Medium":
1403
+ if finding.get("severity") == "Medium" and "documented-surface" in finding["title"]:
1404
+ # `stats.exports_documented` and the confidence_distribution sum count every provenance entry, while
1405
+ # the named-export count leaves out `Type::method` entries: a re-run reproduces that difference
1406
+ records.append(_gap(
1407
+ "Medium", "metadata-drift", finding["title"], meta,
1408
+ f"No count in `{meta}` needs a fix when `stats.exports_documented` and the `confidence_distribution` "
1409
+ "sum agree: both count every provenance map entry, while the named-export count leaves out the "
1410
+ "`Type::method` entries, so the counts differ by the methods the skill documents. When those two "
1411
+ "disagree, Update Skill or Create Skill recounts both from the provenance map the next time it "
1412
+ "writes the skill; a hand edit does not hold.",
1413
+ issue=detail))
1414
+ elif finding.get("severity") == "Medium":
1202
1415
  records.append(_gap(
1203
1416
  "Medium", "metadata-drift", finding["title"], meta,
1204
- f"Recount the exports and correct the diverging counts in `{meta}`; update-skill applies it as "
1205
- "a metadata update.",
1417
+ f"Re-run Create Skill, which rebuilds `exports[]` and `stats.exports_public_api` in `{meta}` from "
1418
+ "the source. Update Skill does not repair this drift: each time it writes the skill it recounts "
1419
+ "`stats.exports_public_api` from the source and keeps `exports[]`, adding or dropping only the "
1420
+ "names its provenance map changes, so a metadata patch does not hold.",
1206
1421
  issue=detail))
1207
1422
  elif finding.get("severity") == "Info":
1208
1423
  records.append(_gap(