bmad-module-skill-forge 3.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 (141) 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 +33 -26
  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/troubleshooting.md +8 -0
  12. package/docs/verifying-a-skill.md +46 -21
  13. package/docs/why-skf.md +1 -1
  14. package/docs/workflows.md +5 -5
  15. package/package.json +1 -1
  16. package/src/knowledge/qmd-registry.md +1 -1
  17. package/src/knowledge/version-paths.md +1 -1
  18. package/src/shared/health-check.md +2 -50
  19. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
  20. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +12 -4
  21. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  22. package/src/shared/scripts/skf-atomic-write.py +212 -59
  23. package/src/shared/scripts/skf-build-change-manifest.py +1437 -178
  24. package/src/shared/scripts/skf-classify-changed-files.py +78 -0
  25. package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
  26. package/src/shared/scripts/skf-count-tokens.py +54 -10
  27. package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
  28. package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
  29. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  30. package/src/shared/scripts/skf-emit-result-envelope.py +6 -5
  31. package/src/shared/scripts/skf-extract-public-api.py +1673 -189
  32. package/src/shared/scripts/skf-extraction-inventory.py +584 -46
  33. package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
  34. package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
  35. package/src/shared/scripts/skf-github-probe.py +47 -8
  36. package/src/shared/scripts/skf-hash-content.py +14 -8
  37. package/src/shared/scripts/skf-load-provenance.py +3 -1
  38. package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
  39. package/src/shared/scripts/skf-parse-gaps.py +135 -26
  40. package/src/shared/scripts/skf-preflight.py +15 -1
  41. package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
  42. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  43. package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
  44. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  45. package/src/shared/scripts/skf-shard-body.py +239 -41
  46. package/src/shared/scripts/skf-structural-diff.py +133 -18
  47. package/src/shared/scripts/skf-update-active-symlink.py +115 -62
  48. package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
  49. package/src/shared/scripts/skf-validate-output.py +12 -3
  50. package/src/shared/scripts/skf-verify-provenance-completeness.py +1439 -79
  51. package/src/shared/scripts/skf-write-skill-brief.py +41 -8
  52. package/src/shared/tool-requirements.yaml +8 -2
  53. package/src/skf-analyze-source/SKILL.md +2 -2
  54. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  55. package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
  56. package/src/skf-audit-skill/SKILL.md +2 -2
  57. package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
  58. package/src/skf-audit-skill/references/headless-contract.md +1 -1
  59. package/src/skf-audit-skill/references/health-check.md +2 -0
  60. package/src/skf-audit-skill/references/init.md +2 -1
  61. package/src/skf-audit-skill/references/re-index.md +14 -9
  62. package/src/skf-audit-skill/references/report.md +2 -2
  63. package/src/skf-audit-skill/references/semantic-diff.md +15 -15
  64. package/src/skf-audit-skill/references/structural-diff.md +10 -5
  65. package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
  66. package/src/skf-brief-skill/SKILL.md +3 -3
  67. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  68. package/src/skf-brief-skill/assets/skill-brief-schema.md +4 -11
  69. package/src/skf-brief-skill/references/analyze-target.md +6 -6
  70. package/src/skf-brief-skill/references/confirm-brief.md +1 -1
  71. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  72. package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
  73. package/src/skf-brief-skill/references/version-resolution.md +5 -9
  74. package/src/skf-campaign/SKILL.md +2 -2
  75. package/src/skf-campaign/references/step-05-skill-loop.md +6 -1
  76. package/src/skf-create-skill/SKILL.md +2 -2
  77. package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
  78. package/src/skf-create-skill/assets/skill-sections.md +7 -7
  79. package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
  80. package/src/skf-create-skill/references/compile.md +31 -60
  81. package/src/skf-create-skill/references/component-extraction.md +3 -3
  82. package/src/skf-create-skill/references/enrich.md +2 -2
  83. package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
  84. package/src/skf-create-skill/references/extract.md +23 -19
  85. package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
  86. package/src/skf-create-skill/references/extraction-patterns.md +10 -11
  87. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  88. package/src/skf-create-skill/references/report.md +21 -5
  89. package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
  90. package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
  91. package/src/skf-create-stack-skill/SKILL.md +2 -2
  92. package/src/skf-create-stack-skill/references/validate.md +1 -1
  93. package/src/skf-drop-skill/SKILL.md +2 -2
  94. package/src/skf-drop-skill/references/execute.md +5 -1
  95. package/src/skf-drop-skill/references/health-check.md +2 -0
  96. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  97. package/src/skf-drop-skill/references/report.md +2 -2
  98. package/src/skf-drop-skill/references/select.md +7 -3
  99. package/src/skf-export-skill/SKILL.md +2 -2
  100. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  101. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  102. package/src/skf-export-skill/references/load-skill.md +1 -1
  103. package/src/skf-export-skill/references/token-report.md +29 -9
  104. package/src/skf-forger/SKILL.md +2 -2
  105. package/src/skf-quick-skill/SKILL.md +2 -2
  106. package/src/skf-refine-architecture/SKILL.md +6 -8
  107. package/src/skf-rename-skill/SKILL.md +2 -2
  108. package/src/skf-setup/SKILL.md +2 -2
  109. package/src/skf-test-skill/SKILL.md +2 -2
  110. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  111. package/src/skf-test-skill/references/coherence-check.md +5 -4
  112. package/src/skf-test-skill/references/coverage-check-branches.md +61 -0
  113. package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
  114. package/src/skf-test-skill/references/coverage-check.md +69 -111
  115. package/src/skf-test-skill/references/init.md +5 -6
  116. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  117. package/src/skf-test-skill/references/report.md +11 -9
  118. package/src/skf-test-skill/references/scoring-rules.md +8 -6
  119. package/src/skf-test-skill/references/source-access-protocol.md +15 -18
  120. package/src/skf-test-skill/scripts/build-result-context.py +114 -21
  121. package/src/skf-test-skill/scripts/gap-ledger.py +580 -80
  122. package/src/skf-test-skill/scripts/load-coverage-inputs.py +646 -107
  123. package/src/skf-test-skill/scripts/locate-export-segments.py +101 -20
  124. package/src/skf-test-skill/scripts/reconcile-coverage.py +450 -23
  125. package/src/skf-test-skill/scripts/score-signatures.py +356 -61
  126. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  127. package/src/skf-test-skill/scripts/validate-inventory.py +108 -9
  128. package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
  129. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  130. package/src/skf-update-skill/SKILL.md +2 -2
  131. package/src/skf-update-skill/references/detect-changes.md +32 -13
  132. package/src/skf-update-skill/references/gap-driven.md +37 -22
  133. package/src/skf-update-skill/references/init.md +6 -2
  134. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  135. package/src/skf-update-skill/references/merge.md +17 -12
  136. package/src/skf-update-skill/references/re-extract.md +4 -2
  137. package/src/skf-update-skill/references/report.md +7 -4
  138. package/src/skf-update-skill/references/write.md +77 -36
  139. package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
  140. package/src/skf-verify-stack/SKILL.md +2 -2
  141. package/tools/cli/lib/tool-check.js +158 -2
@@ -19,11 +19,25 @@ It covers the four branches §2c enumerates:
19
19
  Missing = barrel_set − documented_set, Stale = documented_set − barrel_set,
20
20
  Export Coverage = |Documented| / |barrel_set| * 100.
21
21
 
22
- * "scalar" (§2b priority-1 effective_denominator, no enumerated name set):
23
- look each documented name up in SKILL.md ∪ references/*.md;
24
- Documented = count present, denominator = effective_denominator,
25
- Missing = max(0, denominator − Documented), Stale not enumerable (empty),
26
- Export Coverage = min(100, Documented / denominator * 100).
22
+ * "scalar" (§2b's scalar branch, stats.effective_denominator): the
23
+ denominator is effective_denominator, Missing = max(0, denominator −
24
+ Documented), Export Coverage = min(100, Documented / denominator * 100).
25
+ The numerator's set: when the surface counts its named set S
26
+ (`setCounted`, the surface's `denominator.setCounted`: a scope set, not
27
+ `all`, each of whose names sits at the file that defines it, read
28
+ whole), Documented = |documented_set ∩ S| (`numeratorSource`
29
+ "surface"; the doc-text lookup and `verifiedNumerator` are not read),
30
+ `missing` = S − documented_set, and a warning when |S| differs from the
31
+ denominator. Otherwise (`all`, a set read by eye or from an incomplete
32
+ extraction, no surface, or an empty S, which warns): look each
33
+ documented name up in SKILL.md ∪ references/*.md, Documented = count
34
+ present, or `verifiedNumerator`; `missing` lists no name.
35
+ Stale: on a surface read from the source (`sourceRead`, its
36
+ `guards.deflation.applicable`, or a counted set), documented_set − (S ∪
37
+ the whole public surface, `allSet`, the `all` set: a nested package's
38
+ name is in the scope sets alone), so a documented name outside S that
39
+ the source exports is neither counted nor stale; else not enumerable
40
+ (empty).
27
41
 
28
42
  * "stack" (skill_type == "stack", empty source barrel): look each
29
43
  composition-surface name (provenance-map cited contracts, ::-excluded, or
@@ -39,8 +53,9 @@ It covers the four branches §2c enumerates:
39
53
  description and, for a function or method, params and a return type
40
54
  (validate-inventory.py's rule).
41
55
 
42
- The two lookup branches bound their outputs because the numerator and the
43
- denominator are independent measures of different sets: the lookup count can
56
+ The two lookup branches (and a scalar set larger than its stored count)
57
+ bound their outputs because the numerator and the denominator are
58
+ independent measures of different sets: the lookup count can
44
59
  exceed a consumer-surface denominator without either being wrong. Unbounded,
45
60
  that produced a negative Missing and a >100% coverage that compute-score.py
46
61
  rejects as out of range. `documented` is left as the true count (it is reported
@@ -68,7 +83,12 @@ each sets the input field it names and wins over the same field in the JSON:
68
83
  --inventory PATH exports: the validated inventory validate-inventory.py
69
84
  wrote with --output
70
85
  --surface PATH barrelSet: one name set of load-coverage-inputs.py
71
- surface, chosen by --surface-set (default "all")
86
+ surface, chosen by --surface-set (default "all"),
87
+ with what the scalar branch reads: surfaceSet (that
88
+ name), allSet (its `all` set), sourceRead (its
89
+ `guards.deflation.applicable`) and setCounted (its
90
+ `denominator.setCounted`, when --surface-set names
91
+ its `denominator.surfaceSet`)
72
92
  --coverage-inputs PATH load-coverage-inputs.py metadata output: for the
73
93
  scalar branch, denominatorValue from its
74
94
  `effectiveDenominator` (stats.effective_denominator);
@@ -76,7 +96,7 @@ each sets the input field it names and wins over the same field in the JSON:
76
96
  denominatorValue from its `stack` object
77
97
  --verified PATH scalar: verify-declared-numerator.py's result; when it
78
98
  found the declared count inflated, its `verified`
79
- count is the numerator
99
+ count is the numerator (not on a counted set)
80
100
  --output PATH also write the result to this file (UTF-8 JSON); a
81
101
  refused input removes a file left there earlier
82
102
 
@@ -84,8 +104,12 @@ Input schema (one object):
84
104
  {
85
105
  "denominatorSource": "barrel" | "scalar" | "stack" | "docsOnly", # required
86
106
  "exports": [ {"name": "...", "kind": "..."}, ... ], # §1 inventory
87
- "barrelSet": ["a", "b", ...], # barrel: resolved name set
107
+ "barrelSet": ["a", "b", ...], # barrel, scalar: resolved name set
88
108
  "perFileResults": [ {"exports_found": ["a", ...]} ], # barrel: union'd if no barrelSet
109
+ "surfaceSet": "tier_a_include", # scalar: barrelSet's name (null or "" is "all")
110
+ "allSet": ["a", "b", "c", ...], # scalar: the whole public surface
111
+ "sourceRead": <bool>, # scalar: the surface was read from the source
112
+ "setCounted": <bool>, # scalar: count barrelSet (a trusted scope set)
89
113
  "denominatorValue": <int>, # scalar/stack: resolved denominator
90
114
  "compositionNames": ["lib::x", ...], # stack: names to look up
91
115
  "skillPackagePath": "/path/to/skill", # scalar/stack: SKILL.md ∪ references
@@ -98,16 +122,20 @@ Output (stdout, one object):
98
122
  "denominatorSource": "barrel" | "scalar" | "stack" | "docsOnly",
99
123
  "denominator": <int>,
100
124
  "documented": <int>,
101
- "missing": [names], # enumerated: source names not documented; others: []
125
+ "documentedNames": [names], # enumerated, scalar counting its set: the documented names the numerator counts
126
+ "missing": [names], # enumerated, scalar counting its set: source names not documented; others: []
102
127
  "missingCount": <int>, # always present; never negative
103
- "stale": [names], # enumerated: documented names not in source; others: []
128
+ "stale": [names], # enumerated, scalar on a source-read surface: documented names not in source
104
129
  "staleCount": <int>, # always present
105
- "staleApplicable": <bool>, # false for scalar/stack/docsOnly (no barrel to enumerate)
130
+ "staleApplicable": <bool>, # true for enumerated and scalar on a source-read surface
106
131
  "exportCoverage": <float>, # scalar/stack: capped at 100
107
132
  "numeratorSurplus": <int>, # scalar/stack only: max(0, documented − denominator)
108
133
  "coverageUncapped": <float>, # scalar/stack only: the ratio before the cap
109
134
  "coverageCapped": <bool>, # scalar/stack only: true when the cap bound the ratio
110
- "numeratorSource": "lookup" | "verified", # scalar only
135
+ "numeratorSource": "lookup" | "verified" | "surface", # scalar only
136
+ "surfaceSet": "<name>", # scalar counting its set: the set it counted
137
+ "surfaceSetSize": <int>, # scalar counting its set: |S|
138
+ "warnings": [str], # scalar on a source-read surface: an empty S, or |S| unlike the denominator
111
139
  "incomplete": [{"name", "kind", "missing"}] # docsOnly only: the incomplete items
112
140
  }
113
141
 
@@ -115,6 +143,56 @@ Exit codes:
115
143
  0 reconciliation emitted successfully
116
144
  1 no input, input that does not parse as JSON, or an input file that cannot be read
117
145
  2 input parsed but schema/semantics invalid (error object emitted as JSON)
146
+
147
+ Subcommand render (coverage-check.md §5 writes its output unchanged):
148
+ uv run reconcile-coverage.py render --coverage <coverage.json>
149
+ [--surface <surface.json>] [--signatures <signatures.json>]
150
+
151
+ prints the Markdown of the Coverage Analysis section that follows its
152
+ header lines, from the run files:
153
+ **Files Analyzed:** the distinct files the --surface export records name
154
+ (N/A without --surface: a docs-only run has none)
155
+ ### Export Coverage on the enumerated branch, one row per name the
156
+ denominator counts, per stale name and per name whose
157
+ signature mismatches, by name:
158
+ Type the first kind the surface records for the name, else -
159
+ Documented no for a missing name, else yes
160
+ Signature no for a Critical mismatch, yes (Low) or yes (Info)
161
+ for a name whose mismatches score-signatures.py
162
+ graded Low or Info alone (they count as matching;
163
+ Low when it has both), yes for a compared name that
164
+ matches, unverified for a documented name when no
165
+ --signatures was given, - otherwise
166
+ Source Location for a name whose signature mismatches, the file:line
167
+ of its most severe mismatch (the declaration it was
168
+ compared with); else the file:line the surface
169
+ records for the name, a record with a line preferred
170
+ (gap-ledger.py's rule for a missing export's
171
+ Source), else -
172
+ Status FAIL for a Critical signature mismatch (a mismatch
173
+ with no `severity`, from an older signatures.json,
174
+ counts as one), WARN for a missing or stale name or
175
+ a mismatch graded Low or Info alone, PASS otherwise
176
+ A scalar result with `documentedNames` (a counted
177
+ set) gets the same table. The stack branch, and the
178
+ scalar one with no set, count without naming the
179
+ exports, and a docs-only run has no source
180
+ surface: one line says so instead (NO_TABLE).
181
+ ### Coverage Summary the --coverage counts and warnings, the
182
+ --signatures count of
183
+ Critical mismatches (with the Low and Info ones,
184
+ counted as matching, beside it when there are any)
185
+ and warnings, the numerator surplus when the ratio
186
+ was capped, and the names the --surface leaves out
187
+ or puts back: `excluded.outsideScope` (scoped out
188
+ by the brief), `excluded.nestedEntries` (a nested
189
+ Python entry point's names) and
190
+ `guards.staleScope.restored`, each line only when
191
+ it names one
192
+ ### Category Scores Export Coverage, and Signature Accuracy and Type
193
+ Coverage from --signatures (N/A without it)
194
+ Exit 0 when it printed; 1 when an input cannot be read or is not the
195
+ result of the script that writes it (one line on stderr); 2 a usage error.
118
196
  """
119
197
 
120
198
  from __future__ import annotations
@@ -127,6 +205,8 @@ import sys
127
205
  from pathlib import Path
128
206
 
129
207
  VALID_SOURCES = ("barrel", "scalar", "stack", "docsOnly")
208
+ # The grades score-signatures.py gives a caller-invisible signature difference, most severe first.
209
+ NON_BLOCKING_MISMATCH = ("Low", "Info")
130
210
 
131
211
  _INVENTORY_MODULE = None
132
212
 
@@ -231,9 +311,65 @@ def _validate(inp):
231
311
  verified = inp.get("verifiedNumerator")
232
312
  if verified is not None and (not isinstance(verified, int) or isinstance(verified, bool) or verified < 0):
233
313
  return "verifiedNumerator must be an integer >= 0"
314
+ if source == "scalar" and inp.get("barrelSet") is not None:
315
+ if not isinstance(inp["barrelSet"], list):
316
+ return "scalar branch: `barrelSet` must be a list of names"
317
+ set_name = _surface_set_name(inp)
318
+ if set_name is None:
319
+ return "scalar branch: `surfaceSet` must be a set name (a string)"
320
+ all_set = inp.get("allSet")
321
+ if all_set is not None and not isinstance(all_set, list):
322
+ return "scalar branch: `allSet` must be a list of names"
323
+ if all_set is None and set_name != "all" and _reads_surface(inp):
324
+ return ("scalar branch on a surface read from the source requires `allSet` (the surface's "
325
+ "`all` set) to find the stale names")
234
326
  return None
235
327
 
236
328
 
329
+ def _surface_set_name(inp):
330
+ """`surfaceSet`, normalized once: null or empty is `all`; None for a value that is not a string.
331
+
332
+ >>> [_surface_set_name({"surfaceSet": v}) for v in (None, "", "tier_a_include", 3)]
333
+ ['all', 'all', 'tier_a_include', None]
334
+ """
335
+ name = inp.get("surfaceSet")
336
+ if name is None or name == "":
337
+ return "all"
338
+ return name if isinstance(name, str) else None
339
+
340
+
341
+ def _reads_surface(inp):
342
+ """True when the scalar branch reads its surface: a `barrelSet` read from the source (a counted set is)."""
343
+ return inp.get("barrelSet") is not None and (inp.get("sourceRead") is True or inp.get("setCounted") is True)
344
+
345
+
346
+ def _scalar_surface(inp, denominator):
347
+ """What the scalar branch reads from a surface read from the source: the stale names, the documented names
348
+ (S ∪ `allSet`, where a nested package's name sits in the scope sets alone) lacks, and, when it counts its
349
+ named set S (`setCounted`, a non-empty scope set), the documented names S holds, the names of S the skill
350
+ does not document and the set's size, with the warnings. The denominator stays the stored count."""
351
+ documented_set = build_documented_set(inp.get("exports"))
352
+ named = {n for n in inp["barrelSet"] if isinstance(n, str) and n}
353
+ set_name = _surface_set_name(inp)
354
+ whole = named | {n for n in inp.get("allSet") or [] if isinstance(n, str) and n}
355
+ warnings = []
356
+ counted = inp.get("setCounted") is True and set_name != "all"
357
+ if counted and not named:
358
+ # An empty set would read 0% by construction: the lookup counts instead.
359
+ warnings.append(f"the `{set_name}` set names no export: the numerator is looked up in the skill instead")
360
+ counted = False
361
+ out = {"counted": counted, "stale": sorted(documented_set - whole), "warnings": warnings}
362
+ if counted:
363
+ if len(named) != denominator:
364
+ warnings.append(
365
+ f"the `{set_name}` set holds {len(named)} names and the stored count "
366
+ f"(`stats.effective_denominator`) is {denominator}: the denominator stays the stored count, so "
367
+ "`missing` and `missingCount` can differ")
368
+ out.update({"documentedNames": sorted(documented_set & named), "missing": sorted(named - documented_set),
369
+ "surfaceSet": set_name, "surfaceSetSize": len(named)})
370
+ return out
371
+
372
+
237
373
  def _docs_only(inp):
238
374
  tally = _inventory().completeness(inp["exports"])
239
375
  total = tally["total"]
@@ -286,6 +422,7 @@ def reconcile(inp, doc_text=None):
286
422
  "denominatorSource": source,
287
423
  "denominator": denominator,
288
424
  "documented": documented,
425
+ "documentedNames": sorted(documented_names),
289
426
  "missing": missing,
290
427
  "missingCount": len(missing),
291
428
  "stale": stale,
@@ -303,7 +440,14 @@ def reconcile(inp, doc_text=None):
303
440
  )
304
441
 
305
442
  numerator_source = "lookup"
306
- if source == "scalar" and inp.get("verifiedNumerator") is not None:
443
+ surface = _scalar_surface(inp, denominator) if source == "scalar" and _reads_surface(inp) else None
444
+ if surface is not None and surface["counted"]:
445
+ # The named set the surface counts: the numerator counts only its documented names, so a documented
446
+ # extra never offsets an undocumented export. Neither the doc-text lookup nor §2b's verified count is
447
+ # read: both count names without knowing which the set holds.
448
+ documented = len(surface["documentedNames"])
449
+ numerator_source = "surface"
450
+ elif source == "scalar" and inp.get("verifiedNumerator") is not None:
307
451
  # §2b's numerator ground truth found the declared count padded: its
308
452
  # verified count is authoritative and replaces the lookup.
309
453
  documented = inp["verifiedNumerator"]
@@ -317,12 +461,14 @@ def reconcile(inp, doc_text=None):
317
461
  candidates = [n for n in inp["compositionNames"] if isinstance(n, str) and n]
318
462
  documented = len(names_present(candidates, doc_text))
319
463
 
320
- # The looked-up numerator and the resolved denominator are independent
321
- # measures, so `documented` can legitimately exceed `denominator`: a
322
- # consumer-surface denominator counts one surface while the documented body
323
- # may also name migration aliases, re-exported sibling symbols, and other
324
- # extras. Left unbounded that yields a negative Missing and >100% coverage
325
- # (which compute-score.py then rejects outright as out of range).
464
+ # The numerator and the resolved denominator can measure different sets,
465
+ # so `documented` can legitimately exceed `denominator`: a looked-up
466
+ # numerator counts every documented name the skill writes, migration
467
+ # aliases, re-exported sibling symbols and other extras included, against
468
+ # a consumer-surface denominator, and a counted set can hold more names
469
+ # than the stored count. Left unbounded that yields a negative Missing and
470
+ # >100% coverage (which compute-score.py then rejects outright as out of
471
+ # range).
326
472
  #
327
473
  # `documented` stays the true count: it is reported as "Documented in
328
474
  # SKILL.md", so capping it would print a number that was never measured.
@@ -347,9 +493,273 @@ def reconcile(inp, doc_text=None):
347
493
  }
348
494
  if source == "scalar":
349
495
  result["numeratorSource"] = numerator_source
496
+ if surface is not None:
497
+ # Stale is valid against the whole surface whether or not the branch counts its set.
498
+ result.update({"stale": surface["stale"], "staleCount": len(surface["stale"]), "staleApplicable": True})
499
+ if surface["counted"]:
500
+ result.update({key: surface[key] for key in ("documentedNames", "missing", "surfaceSet",
501
+ "surfaceSetSize")})
502
+ result["warnings"] = surface["warnings"]
350
503
  return result
351
504
 
352
505
 
506
+ # --- The Coverage Analysis section (render) -----------------------------------
507
+
508
+ BRANCHES = ("enumerated", "scalar", "stack", "docsOnly")
509
+ # Why a branch names no export: the line it writes in place of the table, and its stale count.
510
+ NO_NAMES = {
511
+ "scalar": "the scalar denominator (`stats.effective_denominator`) counts the exports without naming them",
512
+ "stack": "the stack denominator counts the composition surface without naming each export",
513
+ "docsOnly": "a docs-only run has no source surface",
514
+ }
515
+ NO_TABLE = {
516
+ "scalar": f"No per-export table: {NO_NAMES['scalar']}. The summary below gives the counts.",
517
+ "stack": f"No per-export table: {NO_NAMES['stack']}. The summary below gives the counts.",
518
+ "docsOnly": f"No per-export table: {NO_NAMES['docsOnly']} to compare against. The summary below gives its "
519
+ "documentation completeness.",
520
+ }
521
+
522
+ _GAP_LEDGER_MODULE = None
523
+
524
+
525
+ class RenderError(Exception):
526
+ """A render input that is not the result of the script that writes it."""
527
+
528
+
529
+ def _gap_ledger():
530
+ """gap-ledger.py beside this script: its rule for a missing export's Source gives the Source Location."""
531
+ global _GAP_LEDGER_MODULE
532
+ if _GAP_LEDGER_MODULE is None:
533
+ sibling = Path(__file__).resolve().parent / "gap-ledger.py"
534
+ spec = importlib.util.spec_from_file_location("skf_gap_ledger_render", sibling)
535
+ if spec is None or spec.loader is None or not sibling.is_file():
536
+ raise RenderError(f"gap-ledger.py not found beside {Path(__file__).name}")
537
+ module = importlib.util.module_from_spec(spec)
538
+ spec.loader.exec_module(module)
539
+ _GAP_LEDGER_MODULE = module
540
+ return _GAP_LEDGER_MODULE
541
+
542
+
543
+ def _pct(value):
544
+ """A score as the report writes it.
545
+
546
+ >>> _pct(60.0), _pct(66.67), _pct(None)
547
+ ('60%', '66.67%', 'N/A')
548
+ """
549
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
550
+ return "N/A"
551
+ return f"{int(value)}%" if float(value).is_integer() else f"{value}%"
552
+
553
+
554
+ def _cell(text):
555
+ """A table cell: a `|` would end it."""
556
+ return str(text).replace("|", "\\|")
557
+
558
+
559
+ def _string_list(data, key, label):
560
+ value = data.get(key)
561
+ if value is None:
562
+ return []
563
+ if not isinstance(value, list):
564
+ raise RenderError(f"{label}: `{key}` is not a list")
565
+ return [v for v in value if isinstance(v, str) and v]
566
+
567
+
568
+ def _incomplete(item):
569
+ """A docs-only item and the fields it lacks.
570
+
571
+ >>> _incomplete({"name": "load", "kind": "function", "missing": ["params", "return_type"]})
572
+ '`load` (function) lacks `params`, `return_type`'
573
+ """
574
+ fields = ", ".join(f"`{field}`" for field in item.get("missing") or [])
575
+ return f"`{item.get('name')}` ({item.get('kind')}) lacks {fields}"
576
+
577
+
578
+ def _left_out(surface):
579
+ """The Coverage Summary lines for the names the surface leaves out or puts back, each only when it names one.
580
+
581
+ >>> for line in _left_out({
582
+ ... "excluded": {"outsideScope": [{"name": "gen", "file": "src/gen.ts"}],
583
+ ... "nestedEntries": [{"entry": "pkg/api/__init__.py", "within": "pkg/__init__.py",
584
+ ... "names": ["router"]}]},
585
+ ... "guards": {"staleScope": {"restored": [{"name": "old", "file": "src/old.ts", "line": 4}]}}}):
586
+ ... print(line)
587
+ - **Scoped Out by the Brief:** `gen` (src/gen.ts)
588
+ - **Nested Entry Points:** `pkg/api/__init__.py` inside `pkg/__init__.py`: `router`
589
+ - **Restored by the Stale-Scope Guard:** `old` (src/old.ts:4)
590
+ >>> _left_out({"excluded": {"outsideScope": [], "nestedEntries": []}, "guards": None})
591
+ []
592
+ """
593
+ def items(value):
594
+ return [i for i in value if isinstance(i, dict)] if isinstance(value, list) else []
595
+
596
+ def where(item):
597
+ file, line = item.get("file"), item.get("line")
598
+ if not isinstance(file, str) or not file:
599
+ return ""
600
+ return f" ({file}:{line})" if isinstance(line, int) and not isinstance(line, bool) else f" ({file})"
601
+
602
+ excluded = surface.get("excluded") if isinstance(surface.get("excluded"), dict) else {}
603
+ guards = surface.get("guards") if isinstance(surface.get("guards"), dict) else {}
604
+ stale_scope = guards.get("staleScope") if isinstance(guards.get("staleScope"), dict) else {}
605
+ lines = []
606
+ scoped = [f"`{i['name']}`{where(i)}" for i in items(excluded.get("outsideScope"))
607
+ if isinstance(i.get("name"), str)]
608
+ if scoped:
609
+ lines.append(f"- **Scoped Out by the Brief:** {', '.join(scoped)}")
610
+ nested = [f"`{i['entry']}` inside `{i.get('within')}`: "
611
+ + ", ".join(f"`{n}`" for n in i.get("names") or [] if isinstance(n, str))
612
+ for i in items(excluded.get("nestedEntries")) if isinstance(i.get("entry"), str)]
613
+ if nested:
614
+ lines.append(f"- **Nested Entry Points:** {'; '.join(nested)}")
615
+ restored = [f"`{i['name']}`{where(i)}" for i in items(stale_scope.get("restored"))
616
+ if isinstance(i.get("name"), str)]
617
+ if restored:
618
+ lines.append(f"- **Restored by the Stale-Scope Guard:** {', '.join(restored)}")
619
+ return lines
620
+
621
+
622
+ def _rows(coverage, surface, signatures):
623
+ """The Export Coverage table of the enumerated branch."""
624
+ if "documentedNames" not in coverage:
625
+ raise RenderError("--coverage has no `documentedNames`: write it again with this reconcile-coverage.py")
626
+ documented = set(_string_list(coverage, "documentedNames", "--coverage"))
627
+ missing = set(_string_list(coverage, "missing", "--coverage"))
628
+ stale = set(_string_list(coverage, "stale", "--coverage"))
629
+ mismatched, critical, compared, graded, compared_at = set(), set(), set(), {}, {}
630
+ if signatures is not None:
631
+ named = [m for m in signatures.get("mismatches") or []
632
+ if isinstance(m, dict) and isinstance(m.get("name"), str)]
633
+ mismatched = {m["name"] for m in named}
634
+ # A Low or an Info mismatch (a caller-invisible difference) warns; any other fails.
635
+ critical = {m["name"] for m in named if m.get("severity") not in NON_BLOCKING_MISMATCH}
636
+ # The more severe non-blocking grade of each name: Low over Info.
637
+ for severity in reversed(NON_BLOCKING_MISMATCH):
638
+ graded.update({m["name"]: severity for m in named if m.get("severity") == severity})
639
+ compared = set(_string_list(signatures, "comparedNames", "--signatures"))
640
+ # A mismatched name sits at the declaration its most severe mismatch was compared with, which the
641
+ # surface's first record of a name it records in several files need not be.
642
+ for m in sorted(named, key=lambda m: (NON_BLOCKING_MISMATCH.index(m["severity"]) + 1
643
+ if m.get("severity") in NON_BLOCKING_MISMATCH else 0)):
644
+ if isinstance(m.get("file"), str) and m["file"] and isinstance(m.get("line"), int):
645
+ compared_at.setdefault(m["name"], f"{m['file']}:{m['line']}")
646
+ kinds, sources = {}, {}
647
+ if surface is not None:
648
+ for record in surface["exports"]:
649
+ if isinstance(record, dict) and isinstance(record.get("name"), str) and record.get("kind"):
650
+ kinds.setdefault(record["name"], str(record["kind"]))
651
+ sources = _gap_ledger()._surface_sources(surface)
652
+ sources.update(compared_at)
653
+ rows = ["| Export | Type | Documented | Signature | Source Location | Status |",
654
+ "|--------|------|------------|-----------|-----------------|--------|"]
655
+ for name in sorted(documented | missing | stale | mismatched):
656
+ if name in critical:
657
+ signature = "no"
658
+ elif name in mismatched:
659
+ signature = f"yes ({graded[name]})"
660
+ elif name in compared:
661
+ signature = "yes"
662
+ elif signatures is None and name not in missing:
663
+ signature = "unverified"
664
+ else:
665
+ signature = "-"
666
+ status = ("FAIL" if name in critical
667
+ else "WARN" if name in mismatched or name in missing or name in stale else "PASS")
668
+ rows.append(f"| `{_cell(name)}` | {_cell(kinds.get(name, '-'))} | {'no' if name in missing else 'yes'} "
669
+ f"| {signature} | {_cell(sources.get(name, '-'))} | {status} |")
670
+ return rows
671
+
672
+
673
+ def render(coverage, surface=None, signatures=None):
674
+ """The Coverage Analysis section after its header lines, as Markdown (see the module docstring)."""
675
+ if not isinstance(coverage, dict) or coverage.get("branch") not in BRANCHES:
676
+ raise RenderError("--coverage is not reconcile-coverage.py's result (no known `branch`)")
677
+ if surface is not None and not (isinstance(surface, dict) and isinstance(surface.get("exports"), list)):
678
+ raise RenderError("--surface is not load-coverage-inputs.py surface's result (no `exports` list)")
679
+ if signatures is not None and not (isinstance(signatures, dict) and signatures.get("valid") is True):
680
+ raise RenderError("--signatures is not score-signatures.py score's result (no `valid: true`)")
681
+ branch = coverage["branch"]
682
+ docs_only = branch == "docsOnly"
683
+
684
+ if surface is None:
685
+ files = "N/A: no source surface"
686
+ else:
687
+ files = len({r["file"] for r in surface["exports"]
688
+ if isinstance(r, dict) and isinstance(r.get("file"), str) and r["file"]})
689
+ lines = [f"**Files Analyzed:** {files}", "", "### Export Coverage", ""]
690
+ # A scalar result that counts its set names its exports as the enumerated branch does.
691
+ named = branch == "enumerated" or (branch == "scalar" and "documentedNames" in coverage)
692
+ lines += _rows(coverage, surface, signatures) if named else [NO_TABLE[branch]]
693
+
694
+ denominator, documented = coverage.get("denominator"), coverage.get("documented")
695
+ missing_count, coverage_pct = coverage.get("missingCount"), _pct(coverage.get("exportCoverage"))
696
+ lines += ["", "### Coverage Summary", ""]
697
+ if docs_only:
698
+ incomplete = [_incomplete(item) for item in coverage.get("incomplete") or [] if isinstance(item, dict)]
699
+ lines += [f"- **Exports Found:** {denominator} documented items",
700
+ f"- **Documented:** {documented} complete ({coverage_pct})",
701
+ f"- **Missing Documentation:** {missing_count} incomplete"
702
+ + (f": {'; '.join(incomplete)}" if incomplete else "")]
703
+ else:
704
+ capped = coverage.get("coverageCapped")
705
+ floored = " (a floored residual: the numerator exceeds the denominator)" if capped else ""
706
+ lines += [f"- **Exports Found:** {denominator}",
707
+ f"- **Documented:** {documented} ({coverage_pct})",
708
+ f"- **Missing Documentation:** {missing_count}{floored}"]
709
+ mismatches = signatures.get("mismatches") if signatures is not None else None
710
+ if isinstance(mismatches, list):
711
+ named = [m for m in mismatches if isinstance(m, dict)]
712
+ lenient = sum(1 for m in named if m.get("severity") in NON_BLOCKING_MISMATCH)
713
+ lines.append(f"- **Signature Mismatches:** {len(named) - lenient}"
714
+ + (f", plus {lenient} graded Low or Info and counted as matching" if lenient else ""))
715
+ else:
716
+ lines.append("- **Signature Mismatches:** N/A")
717
+ if coverage.get("staleApplicable"):
718
+ lines.append(f"- **Stale Documentation:** {coverage.get('staleCount')}, documented extras included")
719
+ else:
720
+ lines.append(f"- **Stale Documentation:** N/A: {NO_NAMES.get(branch, 'the denominator names no stale export')}")
721
+ noted = _string_list(coverage, "warnings", "--coverage")
722
+ if noted:
723
+ lines.append("- **Coverage Warnings:**")
724
+ lines += [f" - {w}" for w in noted]
725
+ warnings = _string_list(signatures, "warnings", "--signatures") if signatures is not None else []
726
+ if warnings:
727
+ lines.append("- **Scoring Warnings:**")
728
+ lines += [f" - {w}" for w in warnings]
729
+ if coverage.get("coverageCapped"):
730
+ lines.append(f"- **Numerator Surplus:** {coverage.get('numeratorSurplus')}, uncapped coverage "
731
+ f"{_pct(coverage.get('coverageUncapped'))}")
732
+ if surface is not None:
733
+ lines += _left_out(surface)
734
+
735
+ scores = signatures or {}
736
+ lines += ["", "### Category Scores", "", "| Category | Score |", "|----------|-------|",
737
+ f"| Export Coverage | {coverage_pct} |",
738
+ f"| Signature Accuracy | {_pct(scores.get('signatureAccuracy'))} |",
739
+ f"| Type Coverage | {_pct(scores.get('typeCoverage'))} |"]
740
+ return "\n".join(lines) + "\n"
741
+
742
+
743
+ def _render_main(argv):
744
+ parser = argparse.ArgumentParser(
745
+ prog="reconcile-coverage render",
746
+ description="Print the Coverage Analysis section's Markdown from the run files (coverage-check.md §5).",
747
+ )
748
+ parser.add_argument("--coverage", required=True, metavar="PATH", help="this script's result (coverage.json)")
749
+ parser.add_argument("--surface", metavar="PATH", help="load-coverage-inputs.py surface output (surface.json)")
750
+ parser.add_argument("--signatures", metavar="PATH", help="score-signatures.py score output (signatures.json)")
751
+ args = parser.parse_args(argv)
752
+ try:
753
+ text = render(_read_json_file(args.coverage, "--coverage"),
754
+ _read_json_file(args.surface, "--surface") if args.surface else None,
755
+ _read_json_file(args.signatures, "--signatures") if args.signatures else None)
756
+ except (InputFileError, RenderError) as exc:
757
+ print(f"error: {exc}", file=sys.stderr)
758
+ return 1
759
+ sys.stdout.write(text)
760
+ return 0
761
+
762
+
353
763
  # --- Run-folder inputs --------------------------------------------------------
354
764
 
355
765
 
@@ -388,6 +798,17 @@ def apply_file_inputs(inp, args):
388
798
  if not isinstance(names, list):
389
799
  raise InputFileError(f"--surface {args.surface} has no name set `{args.surface_set}`")
390
800
  inp["barrelSet"] = names
801
+ # The scalar branch counts the set only when the surface trusts it (`denominator.setCounted`, for the set
802
+ # it picked), and finds a stale name on any surface read from the source, against the whole public
803
+ # surface (the `all` set), not the set it counts.
804
+ inp["surfaceSet"] = args.surface_set
805
+ if isinstance(sets.get("all"), list):
806
+ inp["allSet"] = sets["all"]
807
+ guards = data.get("guards")
808
+ deflation = guards.get("deflation") if isinstance(guards, dict) else None
809
+ inp["sourceRead"] = isinstance(deflation, dict) and deflation.get("applicable") is True
810
+ picked = data.get("denominator") if isinstance(data.get("denominator"), dict) else {}
811
+ inp["setCounted"] = picked.get("setCounted") is True and picked.get("surfaceSet") == args.surface_set
391
812
  if args.coverage_inputs is not None:
392
813
  data = _read_json_file(args.coverage_inputs, "--coverage-inputs")
393
814
  if not isinstance(data, dict):
@@ -432,7 +853,10 @@ def _build_parser():
432
853
  epilog=(
433
854
  "Example (barrel branch):\n"
434
855
  " uv run reconcile-coverage.py --denominator-source barrel "
435
- "--inventory run/inventory.json --surface run/surface.json --output run/coverage.json"
856
+ "--inventory run/inventory.json --surface run/surface.json --output run/coverage.json\n"
857
+ "Render the Coverage Analysis section (see `render --help`):\n"
858
+ " uv run reconcile-coverage.py render --coverage run/coverage.json "
859
+ "[--surface run/surface.json] [--signatures run/signatures.json]"
436
860
  ),
437
861
  )
438
862
  src = parser.add_mutually_exclusive_group()
@@ -459,7 +883,7 @@ def _build_parser():
459
883
  files.add_argument("--inventory", metavar="PATH", help="the validated inventory file")
460
884
  files.add_argument("--surface", metavar="PATH", help="load-coverage-inputs.py surface output")
461
885
  files.add_argument("--surface-set", default="all", metavar="NAME",
462
- help="the name set of --surface to intersect against (default: all)")
886
+ help="the name set of --surface the barrel or scalar branch counts (default: all)")
463
887
  files.add_argument("--coverage-inputs", metavar="PATH",
464
888
  help="load-coverage-inputs.py metadata output (scalar and stack branches)")
465
889
  files.add_argument("--verified", metavar="PATH",
@@ -492,6 +916,9 @@ def _write_output(path, result):
492
916
 
493
917
 
494
918
  def main(argv=None):
919
+ argv = sys.argv[1:] if argv is None else list(argv)
920
+ if argv[:1] == ["render"]:
921
+ return _render_main(argv[1:])
495
922
  parser = _build_parser()
496
923
  args = parser.parse_args(argv)
497
924
  raw = _resolve_input(args)