bmad-module-skill-forge 4.0.0 → 6.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 (128) 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/agents.md +1 -1
  5. package/docs/architecture.md +5 -5
  6. package/docs/campaign.md +1 -1
  7. package/docs/concepts.md +4 -4
  8. package/docs/examples.md +32 -25
  9. package/docs/getting-started.md +3 -3
  10. package/docs/how-it-works.md +2 -2
  11. package/docs/index.md +1 -1
  12. package/docs/skill-model.md +41 -39
  13. package/docs/troubleshooting.md +9 -3
  14. package/docs/verifying-a-skill.md +42 -21
  15. package/docs/why-skf.md +1 -1
  16. package/docs/workflows.md +6 -6
  17. package/package.json +1 -1
  18. package/src/knowledge/tool-resolution.md +6 -6
  19. package/src/shared/references/headless-gate-convention.md +4 -1
  20. package/src/shared/references/output-contract-schema.md +3 -3
  21. package/src/shared/references/pipeline-contracts.md +2 -1
  22. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  23. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
  24. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  25. package/src/shared/scripts/skf-build-change-manifest.py +1180 -189
  26. package/src/shared/scripts/skf-classify-changed-files.py +15 -10
  27. package/src/shared/scripts/skf-count-tokens.py +54 -10
  28. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  29. package/src/shared/scripts/skf-emit-result-envelope.py +64 -20
  30. package/src/shared/scripts/skf-extract-public-api.py +472 -120
  31. package/src/shared/scripts/skf-extraction-inventory.py +10 -6
  32. package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
  33. package/src/shared/scripts/skf-parse-gaps.py +136 -26
  34. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  35. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  36. package/src/shared/scripts/skf-shard-body.py +239 -41
  37. package/src/shared/scripts/skf-validate-output.py +12 -3
  38. package/src/shared/scripts/skf-verify-provenance-completeness.py +574 -45
  39. package/src/shared/tool-requirements.yaml +8 -2
  40. package/src/skf-analyze-source/SKILL.md +3 -2
  41. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  42. package/src/skf-analyze-source/references/generate-briefs.md +1 -1
  43. package/src/skf-analyze-source/references/headless-contract.md +1 -1
  44. package/src/skf-audit-skill/SKILL.md +4 -3
  45. package/src/skf-audit-skill/references/re-index.md +2 -2
  46. package/src/skf-audit-skill/references/report.md +1 -1
  47. package/src/skf-brief-skill/SKILL.md +3 -2
  48. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  49. package/src/skf-brief-skill/references/invocation-contract.md +2 -2
  50. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  51. package/src/skf-campaign/SKILL.md +3 -2
  52. package/src/skf-campaign/references/campaign-contracts.md +2 -0
  53. package/src/skf-campaign/references/step-05-skill-loop.md +8 -3
  54. package/src/skf-campaign/references/step-10-export.md +3 -2
  55. package/src/skf-create-skill/SKILL.md +4 -3
  56. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  57. package/src/skf-create-skill/references/ccc-index-check.md +1 -1
  58. package/src/skf-create-skill/references/component-extraction.md +1 -1
  59. package/src/skf-create-skill/references/extract.md +3 -2
  60. package/src/skf-create-skill/references/extraction-patterns.md +3 -3
  61. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  62. package/src/skf-create-skill/references/report.md +8 -3
  63. package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
  64. package/src/skf-create-skill/references/validate.md +1 -1
  65. package/src/skf-create-stack-skill/SKILL.md +3 -2
  66. package/src/skf-create-stack-skill/references/generate-output.md +1 -1
  67. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  68. package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
  69. package/src/skf-create-stack-skill/references/report.md +1 -1
  70. package/src/skf-create-stack-skill/references/validate.md +1 -1
  71. package/src/skf-drop-skill/SKILL.md +3 -2
  72. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  73. package/src/skf-drop-skill/references/report.md +1 -1
  74. package/src/skf-export-skill/SKILL.md +3 -2
  75. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  76. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  77. package/src/skf-export-skill/references/invocation-contract.md +1 -1
  78. package/src/skf-export-skill/references/load-skill.md +1 -1
  79. package/src/skf-export-skill/references/summary.md +1 -1
  80. package/src/skf-export-skill/references/token-report.md +29 -9
  81. package/src/skf-forger/SKILL.md +3 -2
  82. package/src/skf-forger/references/pipeline-mode.md +2 -2
  83. package/src/skf-quick-skill/SKILL.md +4 -3
  84. package/src/skf-quick-skill/references/finalize.md +1 -1
  85. package/src/skf-quick-skill/references/halt-contract.md +2 -2
  86. package/src/skf-quick-skill/references/write-and-validate.md +1 -1
  87. package/src/skf-refine-architecture/SKILL.md +8 -9
  88. package/src/skf-refine-architecture/customize.toml +1 -1
  89. package/src/skf-refine-architecture/references/report.md +1 -1
  90. package/src/skf-rename-skill/SKILL.md +3 -2
  91. package/src/skf-rename-skill/references/invocation-contract.md +2 -2
  92. package/src/skf-setup/SKILL.md +3 -2
  93. package/src/skf-setup/references/ccc-index.md +1 -1
  94. package/src/skf-setup/references/invocation-contract.md +1 -1
  95. package/src/skf-test-skill/SKILL.md +3 -2
  96. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  97. package/src/skf-test-skill/references/coherence-check.md +11 -8
  98. package/src/skf-test-skill/references/coverage-check-branches.md +81 -0
  99. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  100. package/src/skf-test-skill/references/coverage-check.md +60 -97
  101. package/src/skf-test-skill/references/init.md +2 -1
  102. package/src/skf-test-skill/references/inventory-contract.md +27 -0
  103. package/src/skf-test-skill/references/invocation-contract.md +3 -3
  104. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  105. package/src/skf-test-skill/references/report.md +5 -5
  106. package/src/skf-test-skill/references/scoring-rules.md +8 -7
  107. package/src/skf-test-skill/references/signature-comparison-rules.md +72 -0
  108. package/src/skf-test-skill/references/source-access-protocol.md +14 -10
  109. package/src/skf-test-skill/references/step-hard-gate.md +1 -0
  110. package/src/skf-test-skill/scripts/build-result-context.py +20 -3
  111. package/src/skf-test-skill/scripts/gap-ledger.py +456 -176
  112. package/src/skf-test-skill/scripts/load-coverage-inputs.py +516 -79
  113. package/src/skf-test-skill/scripts/locate-export-segments.py +685 -76
  114. package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
  115. package/src/skf-test-skill/scripts/score-signatures.py +1205 -65
  116. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  117. package/src/skf-test-skill/scripts/validate-inventory.py +350 -35
  118. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  119. package/src/skf-update-skill/SKILL.md +3 -2
  120. package/src/skf-update-skill/references/detect-changes.md +1 -1
  121. package/src/skf-update-skill/references/gap-driven.md +54 -33
  122. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  123. package/src/skf-update-skill/references/merge.md +15 -10
  124. package/src/skf-update-skill/references/re-extract.md +3 -1
  125. package/src/skf-update-skill/references/report.md +6 -5
  126. package/src/skf-update-skill/references/write.md +42 -20
  127. package/src/skf-verify-stack/SKILL.md +4 -3
  128. 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,15 +189,27 @@ 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
- Each `stale[]` item whose reason is `line-not-definition`, at
152
- its `{source_file}:{source_line}` with `export`: a Low
198
+ Each `stale[]` item, at its `{source_file}:{source_line}` with
199
+ `export`, by `reason`: `line-not-definition` is a Low
153
200
  `provenance-line` gap `Provenance line is not the definition of
154
201
  {export_name}` when `definition_lines` holds a line, else an Info
155
202
  `provenance-unverified` gap `Provenance line not verified for
156
- {export_name}`. Its other items are not gaps here.
203
+ {export_name}`, and its issue names the base of a `folded_to`
204
+ variant; `file-missing` is a Low `provenance-line` gap
205
+ `Provenance file is missing for {export_name}`;
206
+ `line-out-of-bounds` a Low `provenance-line` gap `Provenance line
207
+ is out of range for {export_name}`; `line-invalid` an Info
208
+ `provenance-unverified` gap `Provenance line not verified for
209
+ {export_name}` at `{source_file}` alone. Its `missing`,
210
+ `orphaned` and `citations` are not gaps here, and no record is
211
+ appended for an export the ledger already records as a
212
+ `fabricated-signature` gap.
157
213
  coherence --input aggregate-coherence.py's result. Each
158
214
  `invalidReferences[]` entry at `SKILL.md:{line}`, by `status`:
159
215
  `missing` is a Critical `broken-reference` gap, `inaccurate` a
@@ -220,7 +276,6 @@ import errno
220
276
  import importlib.util
221
277
  import json
222
278
  import os
223
- import posixpath
224
279
  import re
225
280
  import secrets
226
281
  import sys
@@ -262,8 +317,13 @@ CATEGORIES: dict[str, tuple[str, str]] = {
262
317
  "stale-documentation": ("Coverage", "documentation for an export the enumerated source surface lacks"),
263
318
  "missing-type": ("Coverage", "a type or interface the skill does not document"),
264
319
  "provenance-completeness": ("Coverage", "an export the skill documents that the provenance map lacks"),
265
- "provenance-line": ("Coverage", "a provenance line that is not the definition of its export"),
266
- "provenance-unverified": ("Coverage", "a provenance line the line-check rules could not verify"),
320
+ "provenance-line": (
321
+ "Coverage",
322
+ "a provenance line that is not the definition of its export, or a citation whose file or line the source "
323
+ "lacks",
324
+ ),
325
+ "provenance-unverified": (
326
+ "Coverage", "a provenance line the line-check rules could not verify, or one that is not a line number"),
267
327
  "metadata-drift": ("Coverage", "export counts in the metadata that diverge from each other"),
268
328
  "denominator-inflation": ("Coverage", "a scope include union larger than the provenance map"),
269
329
  "brief-scope-stale": (
@@ -281,7 +341,10 @@ CATEGORIES: dict[str, tuple[str, str]] = {
281
341
  "a reference whose real path leaves the skill, its source and, for a stack, the skills folder",
282
342
  ),
283
343
  "integration-pattern": ("Coherence", "an integration pattern that is incomplete"),
284
- "split-body-mismatch": ("Coherence", "SKILL.md and a references/ file that disagree about an export"),
344
+ "split-body-mismatch": (
345
+ "Coherence",
346
+ "two files of the skill (SKILL.md or a references/ file) that disagree about an export",
347
+ ),
285
348
  "scripts-assets": ("Coherence", "a Scripts & Assets section that is missing or names a missing file"),
286
349
  "scripts-assets-provenance": ("Coherence", "a script or asset file without a provenance entry"),
287
350
  "structural": (
@@ -713,7 +776,8 @@ _SIBLINGS: dict[str, object] = {}
713
776
 
714
777
  def _sibling(filename: str):
715
778
  """A script beside this one, loaded once: validate-inventory.py (the
716
- documented-name match the stale-documentation Source uses)."""
779
+ documented-name match the stale-documentation Source uses, and the
780
+ skill-citation scan the homonym pick reads)."""
717
781
  module = _SIBLINGS.get(filename)
718
782
  if module is None:
719
783
  path = Path(__file__).resolve().parent / filename
@@ -825,13 +889,25 @@ def _documenting_line(skill_dir: Path, name: str) -> str | None:
825
889
  return None
826
890
 
827
891
 
828
- def _outside_scope(surface: dict | None) -> set[str] | None:
892
+ def _scoped_out_by(row: dict) -> bool:
893
+ """Whether an `excluded.outsideScope` row says the brief scopes its file
894
+ out: a file, with the `scope.exclude` glob that matches it
895
+ (`excludeGlob`) or no `scope.include` glob covering it
896
+ (`outsideInclude`)."""
897
+ return (isinstance(row.get("file"), str) and bool(row["file"])
898
+ and ((isinstance(row.get("excludeGlob"), str) and bool(row["excludeGlob"]))
899
+ or row.get("outsideInclude") is True))
900
+
901
+
902
+ def _outside_scope(surface: dict | None) -> dict[str, dict] | None:
829
903
  """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."""
904
+ a file out of scope), each with its row: the first the brief explains
905
+ (`_scoped_out_by`), else the first with a file, else its first. None
906
+ with no surface, no such list, or a surface built without an extraction
907
+ (no `extraction` object) or from one that did not read the whole scope
908
+ (a `no-ast-grep` status, a `truncated` run, or `fallback.needed`): only
909
+ an extraction fills that list, so the names a brief scoped out are then
910
+ unknown."""
835
911
  excluded = (surface or {}).get("excluded")
836
912
  if excluded is None:
837
913
  return None
@@ -849,7 +925,16 @@ def _outside_scope(surface: dict | None) -> set[str] | None:
849
925
  if (extraction.get("status") == "no-ast-grep" or extraction.get("truncated")
850
926
  or (isinstance(fallback, dict) and fallback.get("needed"))):
851
927
  return None
852
- return {row["name"] for row in rows if isinstance(row, dict) and isinstance(row.get("name"), str)}
928
+ def rank(row: dict) -> tuple[bool, bool]:
929
+ return _scoped_out_by(row), isinstance(row.get("file"), str) and bool(row["file"])
930
+
931
+ outside: dict[str, dict] = {}
932
+ for row in rows:
933
+ if isinstance(row, dict) and isinstance(row.get("name"), str):
934
+ known = outside.get(row["name"])
935
+ if known is None or rank(row) > rank(known):
936
+ outside[row["name"]] = row
937
+ return outside
853
938
 
854
939
 
855
940
  # The extensions classify-stale reads a `defined_at` in.
@@ -860,60 +945,7 @@ _DEFINED_AT_RE = re.compile(
860
945
  r"^.+(?:" + "|".join(re.escape(e) for e in sorted(_DEFINED_AT_EXTENSIONS)) + r"):[1-9]\d*$", re.IGNORECASE)
861
946
 
862
947
 
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,
948
+ def _documented_extra_at(name: str, item: dict, outside: dict[str, dict] | None,
917
949
  skill_dir: str | None = None) -> tuple[str, int] | None:
918
950
  """Where the source declares a stale name that is a documented extra, given
919
951
  a surface built from an extraction whose `excluded.outsideScope` does not
@@ -936,10 +968,12 @@ def _documented_extra_at(name: str, item: dict, outside: set[str] | None,
936
968
  if not skill_dir or not isinstance(declared_in, list) or not all(
937
969
  isinstance(d, str) and _DEFINED_AT_RE.match(d) for d in declared_in):
938
970
  return None
939
- by_file = {_cited_path(d.rsplit(":", 1)[0]): d for d in declared_in}
971
+ # validate-inventory.py's skill-citation scan, which score-signatures.py's plan reads too.
972
+ citations = _sibling("validate-inventory.py")
973
+ by_file = {citations._cited_path(d.rsplit(":", 1)[0]): d for d in declared_in}
940
974
  if len(by_file) < 2:
941
975
  return None
942
- cited = _skill_cited_files(Path(skill_dir), name)
976
+ cited = citations._skill_cited_files(Path(skill_dir), name)
943
977
  chosen = [d for f, d in sorted(by_file.items()) if f in cited]
944
978
  return (chosen[0], len(by_file)) if len(chosen) == 1 else None
945
979
 
@@ -961,6 +995,196 @@ def _missing_export(name: str, where: str | None, meta: str, kind: str = "export
961
995
  export=name)
962
996
 
963
997
 
998
+ def _brief_of(surface: dict | None) -> str:
999
+ """The brief a surface result read: its `inputs.brief`, else skill-brief.yaml."""
1000
+ inputs = (surface or {}).get("inputs")
1001
+ brief = inputs.get("brief") if isinstance(inputs, dict) else None
1002
+ return brief if isinstance(brief, str) and brief else _DEFAULT_BRIEF
1003
+
1004
+
1005
+ # The most names the issue of the inflated-union summary gap lists (#795).
1006
+ _INFLATED_NAMES_LISTED = 50
1007
+ # The clauses whose ladder has a tier A rung, so that a brief's `scope.tier_a_include` corrects the
1008
+ # denominator an inflated `scope.include` union gave them (#795).
1009
+ # Keep identical to the CLAUSE_LADDERS clauses with a `tier_a_include` rung in load-coverage-inputs.py.
1010
+ _TIER_A_CLAUSES = frozenset({"stratified-scope", "multi-entry"})
1011
+
1012
+
1013
+ def _inflated_union(surface: dict | None) -> bool:
1014
+ """Whether the barrel branch counts an inflated `scope.include` union a
1015
+ tier A list corrects: the surface's inflation guard fires, its
1016
+ `denominator.surfaceSet` is `scope.include` and its `denominator.clause`
1017
+ has a tier A rung. A set the surface lacked (`absentSet`) counted `all`
1018
+ in its place, and the specific-modules and empty-barrel clauses count
1019
+ `scope.include` whatever tier A list the brief has: each name there
1020
+ stays its own gap."""
1021
+ guards = (surface or {}).get("guards")
1022
+ inflation = guards.get("inflation") if isinstance(guards, dict) else None
1023
+ denominator = (surface or {}).get("denominator")
1024
+ return (isinstance(inflation, dict) and inflation.get("fires") is True and isinstance(denominator, dict)
1025
+ and denominator.get("surfaceSet") == "scope.include" and denominator.get("clause") in _TIER_A_CLAUSES)
1026
+
1027
+
1028
+ def _inflated_union_gap(names: list[str], where: dict[str, str], types: set[str], brief: str,
1029
+ union: object) -> dict:
1030
+ """One gap at the brief for the names an inflated `scope.include` union
1031
+ counts and the skill does not document, in place of a gap per name: the
1032
+ union sweeps in infrastructure, so most of them belong to no authored
1033
+ surface (#795). It sits beside the guard's own `denominator-inflation`
1034
+ gap at that brief, whose fix it defers to."""
1035
+ def label(name: str) -> str:
1036
+ notes = (["a type"] if name in types else []) + ([f"`{where[name]}`"] if where.get(name) else [])
1037
+ return f"`{name}` ({', '.join(notes)})" if notes else f"`{name}`"
1038
+
1039
+ listed = [label(n) for n in names[:_INFLATED_NAMES_LISTED]]
1040
+ more = len(names) - len(listed)
1041
+ named = ", ".join(listed) + (f", and {more} more" if more else "")
1042
+ size = f" ({union} names)" if _count(union) is not None else ""
1043
+ return _gap(
1044
+ "Medium", "denominator-inflation", "undocumented names in the inflated scope.include union", brief,
1045
+ f"Correct the denominator first, as the denominator-inflation gap for `{brief}` says; then re-run Test "
1046
+ "Skill, and the names the corrected denominator counts that the skill does not document come back as gaps.",
1047
+ issue=f"the inflation guard fired: the `scope.include` union the denominator counts{size} sweeps in names "
1048
+ f"outside the authored surface, and SKILL.md and references/ do not document {len(names)} of them: "
1049
+ f"{named}")
1050
+
1051
+
1052
+ def _scoped_out_gap(name: str, row: dict, doc: str | None, brief: str) -> dict:
1053
+ """The Medium `stale-documentation` gap of a documented name an entry
1054
+ point exports from a file the brief scopes out (#796): its row names
1055
+ the `scope.exclude` glob that matches the file, or says no
1056
+ `scope.include` glob covers it, or both."""
1057
+ file, line, entry = row["file"], _count(row.get("line")), row.get("entry")
1058
+ at = f"{file}:{line}" if line else file
1059
+ glob = row.get("excludeGlob") if isinstance(row.get("excludeGlob"), str) and row.get("excludeGlob") else None
1060
+ uncovered = row.get("outsideInclude") is True
1061
+ causes = (["no `scope.include` glob covers that file"] if uncovered else []) + (
1062
+ [f"its `scope.exclude` glob `{glob}` matches {'it' if uncovered else 'that file'}"] if glob else [])
1063
+ fixes = (["widen `scope.include` to cover that file"] if uncovered else []) + (
1064
+ [f"narrow `{glob}` so it no longer matches that file (to remove the glob instead, record a `demoted-exclude` "
1065
+ "scope-expansion amendment in the brief)"] if glob else [])
1066
+ who = f"the entry point `{entry}`" if isinstance(entry, str) and entry else "an entry point"
1067
+ return _gap(
1068
+ "Medium", "stale-documentation", f"Stale documentation: {name}", doc or "SKILL.md",
1069
+ f"Decide whether `{name}` belongs on the surface: `{brief}` scopes out `{at}`, where it is defined, since "
1070
+ f"{' and '.join(causes)}. To keep its documentation, {' and '.join(fixes)}; else remove the documentation "
1071
+ f"of `{name}` at {doc or 'SKILL.md'}.",
1072
+ issue=f"{f'`{doc}`' if doc else 'SKILL.md'} documents `{name}`, which {who} exports and `{at}` defines, a "
1073
+ f"file `{brief}` scopes out (`excluded.outsideScope`)",
1074
+ export=name)
1075
+
1076
+
1077
+ def _named_inputs(surface: object, signatures: object) -> tuple[dict | None, set[str], set[str]]:
1078
+ """The --surface result, the names --signatures lists in `missingTypes` and the names whose documented
1079
+ signature it compared (`comparedNames`), for a result that names its missing and stale exports."""
1080
+ types: set[str] = set()
1081
+ scored: set[str] = set()
1082
+ if signatures is not None:
1083
+ signatures = _object(signatures, "--signatures (score-signatures.py)", "missingTypes")
1084
+ types = set(_names(signatures, "missingTypes", "--signatures"))
1085
+ scored = set(_names(signatures, "comparedNames", "--signatures"))
1086
+ if surface is not None:
1087
+ surface = _object(surface, "--surface (load-coverage-inputs.py surface)", "exports")
1088
+ return surface, types, scored
1089
+
1090
+
1091
+ def _missing_gaps(cov: dict, surface: dict | None, types: set[str], meta: str) -> list[dict]:
1092
+ """One gap per `missing` name, at the file and line the surface records for it, or, when the surface counts
1093
+ an inflated `scope.include` union a tier A list corrects (`_inflated_union`), one summary gap at the brief in
1094
+ their place (#795)."""
1095
+ where = _surface_sources(surface)
1096
+ missing = _names(cov, "missing", "--input")
1097
+ if missing and _inflated_union(surface):
1098
+ union = ((surface.get("guards") or {}).get("inflation") or {}).get("scopeIncludeUnion")
1099
+ return [_inflated_union_gap(missing, where, types, _brief_of(surface), union)]
1100
+ return [_missing_export(name, where.get(name), meta, "type" if name in types else "export") for name in missing]
1101
+
1102
+
1103
+ def _stale_gaps(cov: dict, surface: dict | None, stale: object, scored: set[str],
1104
+ skill_dir: str | None) -> list[dict]:
1105
+ """One gap per `stale` name: fabricated, a documented extra, or stale documentation (with its own wording
1106
+ for a name defined in a file the brief scopes out, #796)."""
1107
+ records: list[dict] = []
1108
+ # `excluded` is read once a stale name may be a documented extra
1109
+ outside: dict[str, dict] | None = None
1110
+ outside_read = False
1111
+ classified: dict[str, dict] = {}
1112
+ if stale is not None:
1113
+ if not isinstance(stale, list):
1114
+ raise AdapterError("--stale is not classify-stale JSON (a list)")
1115
+ classified = {i["name"]: i for i in stale if isinstance(i, dict) and isinstance(i.get("name"), str)}
1116
+ for name in _names(cov, "stale", "--input"):
1117
+ item = classified.get(name) or {}
1118
+ cited = item.get("source")
1119
+ if item.get("fabricated") is True:
1120
+ if not isinstance(cited, str) or not cited:
1121
+ # a Critical gap needs the line it cites: never downgrade it
1122
+ raise AdapterError(f"--stale: {name!r} is fabricated with no source")
1123
+ records.append(_gap(
1124
+ "Critical", "fabricated-signature", f"Fabricated signature: {name}", cited,
1125
+ f"Remove `{name}` from SKILL.md and references/, or correct it to the export the source "
1126
+ f"defines: no file its provenance cites defines it (`{cited}`).",
1127
+ issue=f"SKILL.md documents `{name}`, which the source API surface lacks and the cited "
1128
+ "file does not define",
1129
+ export=name))
1130
+ continue
1131
+ if not outside_read:
1132
+ outside, outside_read = _outside_scope(surface), True
1133
+ extra = _documented_extra_at(name, item, outside, skill_dir)
1134
+ doc = _documenting_line(Path(skill_dir), name) if skill_dir else None
1135
+ if extra:
1136
+ declared, among = extra
1137
+ # a homonym: the one declaring file the skill's own citations name
1138
+ picked = (f"; of the {among} files that declare it, the skill cites only "
1139
+ f"`{declared.rsplit(':', 1)[0]}`") if among else ""
1140
+ # never routed: update-skill leaves an `observation` alone. Signature scoring compares a
1141
+ # documented signature of a name on the surface's records (a nested sub-package's) alone.
1142
+ checked = ("Signature scoring compares its documented signature with the surface's record of it."
1143
+ if name in scored else
1144
+ "Its documented signature was not checked against that declaration.")
1145
+ records.append(_gap(
1146
+ "Info", "observation", f"Documented extra: {name}", declared,
1147
+ f"No change: `{name}` is a documented extra, a name the source still declares at "
1148
+ f"`{declared}` outside the enumerated surface, and its documentation stays. {checked}",
1149
+ issue=f"{f'`{doc}`' if doc else 'the skill'} documents `{name}`, which the enumerated source API "
1150
+ f"surface lacks and the source still declares{picked}",
1151
+ export=name))
1152
+ continue
1153
+ if outside is not None and name in outside and _scoped_out_by(outside[name]):
1154
+ # an entry point exports it from a file the brief scopes out (#796)
1155
+ records.append(_scoped_out_gap(name, outside[name], doc, _brief_of(surface)))
1156
+ continue
1157
+ records.append(_gap(
1158
+ "Medium", "stale-documentation", f"Stale documentation: {name}", doc or "SKILL.md",
1159
+ f"Check `{name}` against the source and update or remove its documentation at "
1160
+ f"{doc or 'SKILL.md'}: the enumerated source surface does not export it.",
1161
+ issue=f"SKILL.md documents `{name}`, which the enumerated source API surface lacks",
1162
+ export=name))
1163
+ return records
1164
+
1165
+
1166
+ def _residual_gap(cov: dict, meta: str) -> list[dict]:
1167
+ """A counted set's residual: the exports the stored count counts beyond the names the set lists, one gap
1168
+ when `missingCount` exceeds the listed `missing` names."""
1169
+ listed = len(_names(cov, "missing", "--input"))
1170
+ missing, denominator = _count(cov.get("missingCount")), _count(cov.get("denominator"))
1171
+ residual = (missing or 0) - listed
1172
+ if residual <= 0:
1173
+ return []
1174
+ set_name = cov.get("surfaceSet") if isinstance(cov.get("surfaceSet"), str) else "named"
1175
+ size = _count(cov.get("surfaceSetSize"))
1176
+ names = f"{size} names" if size is not None else "names"
1177
+ return [_gap(
1178
+ "Medium", "missing-export", f"{residual} of {denominator} exports not documented beyond the {set_name} set",
1179
+ meta,
1180
+ f"Find the {residual} exports the stored count (`stats.effective_denominator`) counts beyond the {names} "
1181
+ f"of the `{set_name}` set (`{meta}` lists the exports) and document each with its signature; a stale "
1182
+ "stored count is recounted by re-running Create Skill.",
1183
+ issue=f"of the {denominator} exports the stored count counts, {missing} are not documented: the "
1184
+ f"`{set_name}` set names {listed} of them, and the other {residual} are exports the count holds "
1185
+ "beyond the set's names")]
1186
+
1187
+
964
1188
  def from_coverage(cov: object, surface: object = None, signatures: object = None, numerator: object = None,
965
1189
  stale: object = None, provenance: object = None, skill_dir: str | None = None,
966
1190
  metadata: str | None = None) -> list[dict]:
@@ -970,70 +1194,16 @@ def from_coverage(cov: object, surface: object = None, signatures: object = None
970
1194
  branch = cov["branch"]
971
1195
  records: list[dict] = []
972
1196
  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))
1197
+ surface, types, scored = _named_inputs(surface, signatures)
1198
+ records += _missing_gaps(cov, surface, types, meta)
1199
+ records += _stale_gaps(cov, surface, stale, scored, skill_dir)
1036
1200
  elif branch in ("scalar", "stack"):
1201
+ # A scalar result that counts its set lists its missing names, and one on a surface read from the
1202
+ # source its stale names: each is recorded as the barrel branch's are.
1203
+ counted = branch == "scalar" and cov.get("numeratorSource") == "surface"
1204
+ stale_named = branch == "scalar" and cov.get("staleApplicable") is True
1205
+ if counted or stale_named:
1206
+ surface, types, scored = _named_inputs(surface, signatures)
1037
1207
  if branch == "scalar" and cov.get("numeratorSource") == "verified":
1038
1208
  if numerator is None:
1039
1209
  raise AdapterError("the scalar count used the verified numerator: pass its result with "
@@ -1043,6 +1213,9 @@ def from_coverage(cov: object, surface: object = None, signatures: object = None
1043
1213
  where = _provenance_sources(_object(provenance, "--provenance (provenance map)", "entries")
1044
1214
  if provenance is not None else None)
1045
1215
  records += [_missing_export(name, where.get(name), meta) for name in absent]
1216
+ elif counted:
1217
+ records += _missing_gaps(cov, surface, types, meta)
1218
+ records += _residual_gap(cov, meta)
1046
1219
  else:
1047
1220
  missing, denominator = _count(cov.get("missingCount")), _count(cov.get("denominator"))
1048
1221
  if missing:
@@ -1052,6 +1225,8 @@ def from_coverage(cov: object, surface: object = None, signatures: object = None
1052
1225
  f"name (`{meta}` lists the exports) and document each with its signature.",
1053
1226
  issue=f"SKILL.md and references/ name {cov.get('documented')} of the {denominator} exports "
1054
1227
  f"the {branch} denominator counts"))
1228
+ if stale_named:
1229
+ records += _stale_gaps(cov, surface, stale, scored, skill_dir)
1055
1230
  elif branch != "docsOnly":
1056
1231
  raise AdapterError(f"--input (reconcile-coverage.py): unknown branch {branch!r}")
1057
1232
  return records
@@ -1086,42 +1261,86 @@ def from_guards(surface: object, metadata: str | None = None) -> list[dict]:
1086
1261
  else:
1087
1262
  no_tier_a = "no `scope.tier_a_include` glob in the brief counts a public name"
1088
1263
  deflation = guards.get("deflation") or {}
1089
- if deflation.get("fires") is True:
1264
+ inflation = guards.get("inflation") or {}
1265
+ stored = _count(deflation.get("effectiveDenominator")) or 0
1266
+ # No remediation sets `stats.effective_denominator` by hand: Update Skill's recount drops such a value (#803).
1267
+ # Both guards on the scope.include union name one fix, the brief's tier A list (#788).
1268
+ both = (deflation.get("fires") is True and inflation.get("fires") is True
1269
+ and deflation.get("basis") != "tier_a_include")
1270
+ # On an umbrella barrel the names are re-exports, each counted at the file that defines it, never the barrel.
1271
+ umbrella = (" rather than the root barrel, an umbrella of re-exports"
1272
+ if (guards.get("umbrella") or {}).get("umbrella") is True else "")
1273
+ verb = (f"Edit `scope.tier_a_include` in `{brief}` so its globs list" if brief_tier_a
1274
+ else f"Add `scope.tier_a_include` to `{brief}`, listing")
1275
+ # Only Create Skill always writes metadata.json, and it stores the runner's count only under this rule.
1276
+ stores = ("when `effective_denominator_decision.store` holds and the brief's `scope.notes` documents the "
1277
+ "stratification, and otherwise drops the stored count")
1278
+ joint = (f"{verb} the files that define the authored names{umbrella}: the `scope.include` union is itself "
1279
+ "inflated, so neither it nor the smaller stored count measures the authored surface. Then re-run "
1280
+ f"Create Skill to recount `stats.effective_denominator` in `{meta}` from the tier A set: it stores "
1281
+ f"that count {stores}.")
1282
+ if deflation.get("fires") is True and deflation.get("basis") == "tier_a_include":
1283
+ # A stored count the brief's tier A set disagrees with, either way: stale, so count it again (#813).
1284
+ pct = deflation.get("pct")
1285
+ if pct is None: # a stored 0: no percentage against it
1286
+ against = f" against a `stats.effective_denominator` of {stored}"
1287
+ else:
1288
+ below = isinstance(pct, (int, float)) and pct < 0
1289
+ against = (f", {-pct if below else pct}% {'below' if below else 'above'} `stats.effective_denominator` "
1290
+ f"({deflation.get('effectiveDenominator')})")
1291
+ # Until then: the set the denominator actually counts (a ladder with no tier A rung counts scope.include),
1292
+ # or, on an incomplete extraction's tier A set below the stored count, the stored count.
1293
+ denominator = surface.get("denominator") if isinstance(surface.get("denominator"), dict) else {}
1294
+ extraction = surface.get("extraction") if isinstance(surface.get("extraction"), dict) else {}
1295
+ if denominator.get("branch") == "scalar":
1296
+ until = " Until then the stored count stays the denominator" + (
1297
+ ": the extraction is incomplete, so the tier A set can miss names." if extraction.get("status")
1298
+ == "incomplete" else ".")
1299
+ elif isinstance(denominator.get("surfaceSet"), str):
1300
+ until = f" Until then Test Skill counts the `{denominator['surfaceSet']}` set as the denominator."
1301
+ else:
1302
+ until = ""
1303
+ records.append(_gap(
1304
+ "Medium", "metadata-drift",
1305
+ "denominator drift: effective_denominator disagrees with the brief's tier_a_include set", meta,
1306
+ f"Re-run Create Skill to recount `stats.effective_denominator` in `{meta}`: it stores the runner's count "
1307
+ f"of the brief's `scope.tier_a_include` set {stores}.{until}",
1308
+ issue=f"the brief's `scope.tier_a_include` set counts {deflation.get('rederived')} exports{against}"))
1309
+ elif deflation.get("fires") is True:
1090
1310
  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}")
1311
+ above = f" against a `stats.effective_denominator` of {stored}"
1093
1312
  else:
1094
1313
  above = (f", {deflation.get('pct')}% above `stats.effective_denominator` "
1095
1314
  f"({deflation.get('effectiveDenominator')})")
1096
- if deflation.get("pct") is None: # a 0 measures no tier: count it again
1315
+ if both:
1316
+ fix = joint
1317
+ elif deflation.get("pct") is None: # a 0 measures no tier: count it again
1097
1318
  first = ("Edit `scope.tier_a_include` in the brief so its globs match the files that define the "
1098
1319
  "authored tier" if brief_tier_a else "Check `scope.include` in the brief")
1099
1320
  fix = (f"{first}, then re-run Create Skill so `stats.effective_denominator` in `{meta}` is counted "
1100
1321
  "again.")
1101
1322
  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.")
1323
+ fix = (f"Re-run Create Skill to recount `stats.effective_denominator` in `{meta}` from the public surface "
1324
+ f"the source re-derives (it stores that count {stores}), or edit `scope.tier_a_include` in the "
1325
+ "brief so its globs match the files of the authored tier the smaller count measures.")
1105
1326
  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.")
1327
+ fix = (f"Re-run Create Skill to recount `stats.effective_denominator` in `{meta}` from the public surface "
1328
+ f"the source re-derives (it stores that count {stores}), or add `scope.tier_a_include` to the "
1329
+ "brief to name the authored tier the smaller count measures.")
1109
1330
  records.append(_gap(
1110
1331
  "Medium", "metadata-drift",
1111
1332
  "denominator deflation: effective_denominator below source public surface without tier_a_include",
1112
1333
  meta, fix,
1113
1334
  issue=f"the re-derived surface counts {deflation.get('rederived')} exports{above}, and {no_tier_a}"))
1114
- inflation = guards.get("inflation") or {}
1115
1335
  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.")
1336
+ if both:
1337
+ fix = joint
1119
1338
  elif brief_tier_a:
1120
1339
  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.")
1340
+ f"surface{umbrella}: the denominator then counts it instead of the coarse `scope.include` union.")
1122
1341
  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.")
1342
+ fix = (f"Add `scope.tier_a_include` to `{brief}`, listing the files of the authored surface{umbrella}, "
1343
+ "so the denominator counts it instead of the coarse `scope.include` union.")
1125
1344
  records.append(_gap(
1126
1345
  "Medium", "denominator-inflation",
1127
1346
  "denominator inflation: coarse scope.include union exceeds authored surface", brief, fix,
@@ -1198,11 +1417,24 @@ def from_metadata_coherence(result: object, metadata: str | None = None) -> list
1198
1417
  if not isinstance(finding, dict) or not isinstance(finding.get("title"), str):
1199
1418
  raise AdapterError(f"--input: findings[{index}] has no title")
1200
1419
  detail = finding.get("detail") if isinstance(finding.get("detail"), str) else None
1201
- if finding.get("severity") == "Medium":
1420
+ if finding.get("severity") == "Medium" and "documented-surface" in finding["title"]:
1421
+ # `stats.exports_documented` and the confidence_distribution sum count every provenance entry, while
1422
+ # the named-export count leaves out `Type::method` entries: a re-run reproduces that difference
1202
1423
  records.append(_gap(
1203
1424
  "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.",
1425
+ f"No count in `{meta}` needs a fix when `stats.exports_documented` and the `confidence_distribution` "
1426
+ "sum agree: both count every provenance map entry, while the named-export count leaves out the "
1427
+ "`Type::method` entries, so the counts differ by the methods the skill documents. When those two "
1428
+ "disagree, Update Skill or Create Skill recounts both from the provenance map the next time it "
1429
+ "writes the skill; a hand edit does not hold.",
1430
+ issue=detail))
1431
+ elif finding.get("severity") == "Medium":
1432
+ records.append(_gap(
1433
+ "Medium", "metadata-drift", finding["title"], meta,
1434
+ f"Re-run Create Skill, which rebuilds `exports[]` and `stats.exports_public_api` in `{meta}` from "
1435
+ "the source. Update Skill does not repair this drift: each time it writes the skill it recounts "
1436
+ "`stats.exports_public_api` from the source and keeps `exports[]`, adding or dropping only the "
1437
+ "names its provenance map changes, so a metadata patch does not hold.",
1206
1438
  issue=detail))
1207
1439
  elif finding.get("severity") == "Info":
1208
1440
  records.append(_gap(
@@ -1217,15 +1449,52 @@ def from_metadata_coherence(result: object, metadata: str | None = None) -> list
1217
1449
 
1218
1450
 
1219
1451
  def from_provenance_line(result: object) -> list[dict]:
1220
- """verify's `line-not-definition` items: provenance line gaps."""
1452
+ """verify's `stale[]` items: provenance line gaps, one per citation that does not resolve or is not the
1453
+ definition line."""
1221
1454
  result = _object(result, "--input (skf-verify-provenance-completeness.py verify)", "stale")
1222
1455
  records: list[dict] = []
1223
1456
  for item in _list(result, "stale", "--input"):
1224
- if not isinstance(item, dict) or item.get("reason") != "line-not-definition":
1457
+ if not isinstance(item, dict):
1225
1458
  continue
1459
+ reason = item.get("reason")
1226
1460
  name, file, line = item.get("export_name"), item.get("source_file"), item.get("source_line")
1227
- defs = [n for n in item.get("definition_lines") or [] if _count(n) is not None]
1228
1461
  where = f"{file}:{line}"
1462
+ if reason == "file-missing":
1463
+ records.append(_gap(
1464
+ "Low", "provenance-line", f"Provenance file is missing for {name}", where,
1465
+ f"Set the provenance `source_file` and `source_line` of `{name}` to the file and line that define "
1466
+ "it in the pinned source, and move its citations there. update-skill `--from-test-report` does not "
1467
+ "repair this: it leaves the entry unchanged, with a warning that names the file.",
1468
+ issue=f"the provenance map records `{name}` at `{where}`, but the pinned source has no readable "
1469
+ f"file `{file}`; the export may live in another file, such as one with another extension.",
1470
+ export=name))
1471
+ continue
1472
+ if reason == "line-out-of-bounds":
1473
+ records.append(_gap(
1474
+ "Low", "provenance-line", f"Provenance line is out of range for {name}", where,
1475
+ f"Set the provenance `source_line` of `{name}` in `{file}` to the line that defines it and move "
1476
+ "its citations to that line; update-skill `--from-test-report` applies this when the file defines "
1477
+ "it on one line.",
1478
+ issue=f"the provenance map records `{name}` at `{where}`, which is not a line of `{file}` (past "
1479
+ "its end, or below 1).",
1480
+ export=name))
1481
+ continue
1482
+ if reason == "line-invalid": # no line number to cite: the Source is the file alone
1483
+ records.append(_gap(
1484
+ "Info", "provenance-unverified", f"Provenance line not verified for {name}", f"{file}",
1485
+ f"Set the provenance `source_line` of `{name}` in `{file}` to the number of the line that defines "
1486
+ "it.",
1487
+ issue=f"the provenance map records `{name}` in `{file}` at line `{line}`, which is not a line "
1488
+ "number, so no rule could check it; check by hand",
1489
+ export=name))
1490
+ continue
1491
+ if reason != "line-not-definition":
1492
+ continue
1493
+ defs = [n for n in item.get("definition_lines") or [] if _count(n) is not None]
1494
+ base = item.get("folded_to") if isinstance(item.get("folded_to"), str) and item.get("folded_to") else None
1495
+ # a bookkeeping variant the file never names was checked against its base (#847)
1496
+ folded = f" `{name}` is a bookkeeping variant `{file}` never names, checked against its base `{base}`." \
1497
+ if base else ""
1229
1498
  if defs:
1230
1499
  lines = ", ".join(str(n) for n in defs)
1231
1500
  records.append(_gap(
@@ -1234,18 +1503,27 @@ def from_provenance_line(result: object) -> list[dict]:
1234
1503
  "and move its citations to that line; update-skill `--from-test-report` applies this when the "
1235
1504
  "file defines it on one line.",
1236
1505
  issue=f"the provenance map records `{name}` at `{where}`, which is not the line that defines "
1237
- f"it; the lines that define it are {lines}.",
1506
+ f"it; the lines that define it are {lines}.{folded}",
1238
1507
  export=name))
1239
1508
  else:
1240
1509
  records.append(_gap(
1241
1510
  "Info", "provenance-unverified", f"Provenance line not verified for {name}", where,
1242
- f"Open `{file}` and confirm that line {line} defines `{name}`; if another line does, set the "
1243
- "provenance `source_line` to it.",
1244
- issue=f"the line-check rules found no definition line for `{name}` in `{file}`; check by hand",
1511
+ f"Open `{file}` and confirm that line {line} defines `{base or name}`; if another line does, set "
1512
+ "the provenance `source_line` to it.",
1513
+ issue=f"the line-check rules found no definition line for `{name}` in `{file}`; check by hand"
1514
+ + (f".{folded}" if folded else ""),
1245
1515
  export=name))
1246
1516
  return records
1247
1517
 
1248
1518
 
1519
+ def without_fabricated(ledger: dict, records: list[dict]) -> list[dict]:
1520
+ """`--from provenance-line`'s records but those of an export the ledger already records as a
1521
+ `fabricated-signature` gap: coverage-check §5b appends that record first, and an export the source no longer
1522
+ holds takes no provenance line fix."""
1523
+ fabricated = {r.get("export") for r in ledger["records"] if r.get("category") == "fabricated-signature"}
1524
+ return [r for r in records if not r.get("export") or r.get("export") not in fabricated]
1525
+
1526
+
1249
1527
  def from_coherence(result: object) -> list[dict]:
1250
1528
  """aggregate-coherence.py's invalid references."""
1251
1529
  result = _object(result, "--input (aggregate-coherence.py)", "invalidReferences")
@@ -1541,6 +1819,8 @@ def _cmd_append(args: argparse.Namespace) -> int:
1541
1819
  # one waits here instead of writing over the records it adds.
1542
1820
  with ledger_lock(path):
1543
1821
  ledger = load_ledger(path) if path.exists() else new_ledger(path)
1822
+ if args.adapter == "provenance-line":
1823
+ records = without_fabricated(ledger, records)
1544
1824
  appended, duplicates = append_records(ledger, args.stage, records)
1545
1825
  save_ledger(path, ledger)
1546
1826
  except LedgerError as exc: