bmad-module-skill-forge 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +3 -3
  3. package/docs/_data/pinned.yaml +19 -13
  4. package/docs/architecture.md +2 -2
  5. package/docs/concepts.md +4 -4
  6. package/docs/examples.md +32 -25
  7. package/docs/getting-started.md +3 -3
  8. package/docs/how-it-works.md +2 -2
  9. package/docs/index.md +1 -1
  10. package/docs/skill-model.md +41 -39
  11. package/docs/verifying-a-skill.md +41 -20
  12. package/docs/why-skf.md +1 -1
  13. package/docs/workflows.md +2 -2
  14. package/package.json +1 -1
  15. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
  16. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  17. package/src/shared/scripts/skf-build-change-manifest.py +881 -168
  18. package/src/shared/scripts/skf-count-tokens.py +54 -10
  19. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  20. package/src/shared/scripts/skf-emit-result-envelope.py +6 -5
  21. package/src/shared/scripts/skf-extract-public-api.py +439 -107
  22. package/src/shared/scripts/skf-extraction-inventory.py +10 -6
  23. package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
  24. package/src/shared/scripts/skf-parse-gaps.py +135 -26
  25. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  26. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  27. package/src/shared/scripts/skf-shard-body.py +239 -41
  28. package/src/shared/scripts/skf-validate-output.py +12 -3
  29. package/src/shared/scripts/skf-verify-provenance-completeness.py +470 -38
  30. package/src/shared/tool-requirements.yaml +8 -2
  31. package/src/skf-analyze-source/SKILL.md +2 -2
  32. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  33. package/src/skf-audit-skill/SKILL.md +2 -2
  34. package/src/skf-audit-skill/references/re-index.md +2 -2
  35. package/src/skf-brief-skill/SKILL.md +2 -2
  36. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  37. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  38. package/src/skf-campaign/SKILL.md +2 -2
  39. package/src/skf-campaign/references/step-05-skill-loop.md +6 -1
  40. package/src/skf-create-skill/SKILL.md +2 -2
  41. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  42. package/src/skf-create-skill/references/extract.md +2 -1
  43. package/src/skf-create-skill/references/extraction-patterns.md +3 -3
  44. package/src/skf-create-skill/references/report.md +6 -1
  45. package/src/skf-create-stack-skill/SKILL.md +2 -2
  46. package/src/skf-create-stack-skill/references/validate.md +1 -1
  47. package/src/skf-drop-skill/SKILL.md +2 -2
  48. package/src/skf-export-skill/SKILL.md +2 -2
  49. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  50. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  51. package/src/skf-export-skill/references/load-skill.md +1 -1
  52. package/src/skf-export-skill/references/token-report.md +29 -9
  53. package/src/skf-forger/SKILL.md +2 -2
  54. package/src/skf-quick-skill/SKILL.md +2 -2
  55. package/src/skf-refine-architecture/SKILL.md +6 -8
  56. package/src/skf-rename-skill/SKILL.md +2 -2
  57. package/src/skf-setup/SKILL.md +2 -2
  58. package/src/skf-test-skill/SKILL.md +2 -2
  59. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  60. package/src/skf-test-skill/references/coherence-check.md +4 -3
  61. package/src/skf-test-skill/references/coverage-check-branches.md +61 -0
  62. package/src/skf-test-skill/references/coverage-check-tiers.md +1 -1
  63. package/src/skf-test-skill/references/coverage-check.md +45 -69
  64. package/src/skf-test-skill/references/init.md +2 -1
  65. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  66. package/src/skf-test-skill/references/report.md +2 -2
  67. package/src/skf-test-skill/references/scoring-rules.md +5 -4
  68. package/src/skf-test-skill/references/source-access-protocol.md +6 -6
  69. package/src/skf-test-skill/scripts/gap-ledger.py +378 -163
  70. package/src/skf-test-skill/scripts/load-coverage-inputs.py +171 -70
  71. package/src/skf-test-skill/scripts/locate-export-segments.py +101 -20
  72. package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
  73. package/src/skf-test-skill/scripts/score-signatures.py +329 -54
  74. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  75. package/src/skf-test-skill/scripts/validate-inventory.py +108 -9
  76. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  77. package/src/skf-update-skill/SKILL.md +2 -2
  78. package/src/skf-update-skill/references/detect-changes.md +1 -1
  79. package/src/skf-update-skill/references/gap-driven.md +36 -21
  80. package/src/skf-update-skill/references/merge.md +15 -10
  81. package/src/skf-update-skill/references/re-extract.md +3 -1
  82. package/src/skf-update-skill/references/report.md +5 -4
  83. package/src/skf-update-skill/references/write.md +39 -18
  84. package/src/skf-verify-stack/SKILL.md +2 -2
  85. package/tools/cli/lib/tool-check.js +158 -2
@@ -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's scalar branch, stats.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,17 +122,20 @@ Output (stdout, one object):
98
122
  "denominatorSource": "barrel" | "scalar" | "stack" | "docsOnly",
99
123
  "denominator": <int>,
100
124
  "documented": <int>,
101
- "documentedNames": [names], # enumerated only: the documented names the denominator counts
102
- "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: []
103
127
  "missingCount": <int>, # always present; never negative
104
- "stale": [names], # enumerated: documented names not in source; others: []
128
+ "stale": [names], # enumerated, scalar on a source-read surface: documented names not in source
105
129
  "staleCount": <int>, # always present
106
- "staleApplicable": <bool>, # false for scalar/stack/docsOnly (no barrel to enumerate)
130
+ "staleApplicable": <bool>, # true for enumerated and scalar on a source-read surface
107
131
  "exportCoverage": <float>, # scalar/stack: capped at 100
108
132
  "numeratorSurplus": <int>, # scalar/stack only: max(0, documented − denominator)
109
133
  "coverageUncapped": <float>, # scalar/stack only: the ratio before the cap
110
134
  "coverageCapped": <bool>, # scalar/stack only: true when the cap bound the ratio
111
- "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
112
139
  "incomplete": [{"name", "kind", "missing"}] # docsOnly only: the incomplete items
113
140
  }
114
141
 
@@ -130,18 +157,31 @@ header lines, from the run files:
130
157
  signature mismatches, by name:
131
158
  Type the first kind the surface records for the name, else -
132
159
  Documented no for a missing name, else yes
133
- Signature no for a mismatch, yes for a compared name that
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
134
164
  matches, unverified for a documented name when no
135
165
  --signatures was given, - otherwise
136
- Source Location the file:line the surface records for the name, a
137
- record with a line preferred (gap-ledger.py's rule
138
- for a missing export's Source), else -
139
- Status FAIL for a signature mismatch, WARN for a missing or
140
- stale name, PASS otherwise
141
- The scalar and stack branches count without naming
142
- the exports, and a docs-only run has no source
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
143
180
  surface: one line says so instead (NO_TABLE).
144
- ### Coverage Summary the --coverage counts, the --signatures mismatch count
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)
145
185
  and warnings, the numerator surplus when the ratio
146
186
  was capped, and the names the --surface leaves out
147
187
  or puts back: `excluded.outsideScope` (scoped out
@@ -165,6 +205,8 @@ import sys
165
205
  from pathlib import Path
166
206
 
167
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")
168
210
 
169
211
  _INVENTORY_MODULE = None
170
212
 
@@ -269,9 +311,65 @@ def _validate(inp):
269
311
  verified = inp.get("verifiedNumerator")
270
312
  if verified is not None and (not isinstance(verified, int) or isinstance(verified, bool) or verified < 0):
271
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")
272
326
  return None
273
327
 
274
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
+
275
373
  def _docs_only(inp):
276
374
  tally = _inventory().completeness(inp["exports"])
277
375
  total = tally["total"]
@@ -342,7 +440,14 @@ def reconcile(inp, doc_text=None):
342
440
  )
343
441
 
344
442
  numerator_source = "lookup"
345
- 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:
346
451
  # §2b's numerator ground truth found the declared count padded: its
347
452
  # verified count is authoritative and replaces the lookup.
348
453
  documented = inp["verifiedNumerator"]
@@ -356,12 +461,14 @@ def reconcile(inp, doc_text=None):
356
461
  candidates = [n for n in inp["compositionNames"] if isinstance(n, str) and n]
357
462
  documented = len(names_present(candidates, doc_text))
358
463
 
359
- # The looked-up numerator and the resolved denominator are independent
360
- # measures, so `documented` can legitimately exceed `denominator`: a
361
- # consumer-surface denominator counts one surface while the documented body
362
- # may also name migration aliases, re-exported sibling symbols, and other
363
- # extras. Left unbounded that yields a negative Missing and >100% coverage
364
- # (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).
365
472
  #
366
473
  # `documented` stays the true count: it is reported as "Documented in
367
474
  # SKILL.md", so capping it would print a number that was never measured.
@@ -386,6 +493,13 @@ def reconcile(inp, doc_text=None):
386
493
  }
387
494
  if source == "scalar":
388
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"]
389
503
  return result
390
504
 
391
505
 
@@ -512,29 +626,45 @@ def _rows(coverage, surface, signatures):
512
626
  documented = set(_string_list(coverage, "documentedNames", "--coverage"))
513
627
  missing = set(_string_list(coverage, "missing", "--coverage"))
514
628
  stale = set(_string_list(coverage, "stale", "--coverage"))
515
- mismatched, compared = set(), set()
629
+ mismatched, critical, compared, graded, compared_at = set(), set(), set(), {}, {}
516
630
  if signatures is not None:
517
- mismatched = {m["name"] for m in signatures.get("mismatches") or []
518
- if isinstance(m, dict) and isinstance(m.get("name"), str)}
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})
519
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']}")
520
646
  kinds, sources = {}, {}
521
647
  if surface is not None:
522
648
  for record in surface["exports"]:
523
649
  if isinstance(record, dict) and isinstance(record.get("name"), str) and record.get("kind"):
524
650
  kinds.setdefault(record["name"], str(record["kind"]))
525
651
  sources = _gap_ledger()._surface_sources(surface)
652
+ sources.update(compared_at)
526
653
  rows = ["| Export | Type | Documented | Signature | Source Location | Status |",
527
654
  "|--------|------|------------|-----------|-----------------|--------|"]
528
655
  for name in sorted(documented | missing | stale | mismatched):
529
- if name in mismatched:
656
+ if name in critical:
530
657
  signature = "no"
658
+ elif name in mismatched:
659
+ signature = f"yes ({graded[name]})"
531
660
  elif name in compared:
532
661
  signature = "yes"
533
662
  elif signatures is None and name not in missing:
534
663
  signature = "unverified"
535
664
  else:
536
665
  signature = "-"
537
- status = "FAIL" if name in mismatched else "WARN" if name in missing or name in stale else "PASS"
666
+ status = ("FAIL" if name in critical
667
+ else "WARN" if name in mismatched or name in missing or name in stale else "PASS")
538
668
  rows.append(f"| `{_cell(name)}` | {_cell(kinds.get(name, '-'))} | {'no' if name in missing else 'yes'} "
539
669
  f"| {signature} | {_cell(sources.get(name, '-'))} | {status} |")
540
670
  return rows
@@ -557,7 +687,9 @@ def render(coverage, surface=None, signatures=None):
557
687
  files = len({r["file"] for r in surface["exports"]
558
688
  if isinstance(r, dict) and isinstance(r.get("file"), str) and r["file"]})
559
689
  lines = [f"**Files Analyzed:** {files}", "", "### Export Coverage", ""]
560
- lines += _rows(coverage, surface, signatures) if branch == "enumerated" else [NO_TABLE[branch]]
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]]
561
693
 
562
694
  denominator, documented = coverage.get("denominator"), coverage.get("documented")
563
695
  missing_count, coverage_pct = coverage.get("missingCount"), _pct(coverage.get("exportCoverage"))
@@ -575,11 +707,21 @@ def render(coverage, surface=None, signatures=None):
575
707
  f"- **Documented:** {documented} ({coverage_pct})",
576
708
  f"- **Missing Documentation:** {missing_count}{floored}"]
577
709
  mismatches = signatures.get("mismatches") if signatures is not None else None
578
- lines.append(f"- **Signature Mismatches:** {len(mismatches) if isinstance(mismatches, list) else 'N/A'}")
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")
579
717
  if coverage.get("staleApplicable"):
580
718
  lines.append(f"- **Stale Documentation:** {coverage.get('staleCount')}, documented extras included")
581
719
  else:
582
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]
583
725
  warnings = _string_list(signatures, "warnings", "--signatures") if signatures is not None else []
584
726
  if warnings:
585
727
  lines.append("- **Scoring Warnings:**")
@@ -656,6 +798,17 @@ def apply_file_inputs(inp, args):
656
798
  if not isinstance(names, list):
657
799
  raise InputFileError(f"--surface {args.surface} has no name set `{args.surface_set}`")
658
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
659
812
  if args.coverage_inputs is not None:
660
813
  data = _read_json_file(args.coverage_inputs, "--coverage-inputs")
661
814
  if not isinstance(data, dict):
@@ -730,7 +883,7 @@ def _build_parser():
730
883
  files.add_argument("--inventory", metavar="PATH", help="the validated inventory file")
731
884
  files.add_argument("--surface", metavar="PATH", help="load-coverage-inputs.py surface output")
732
885
  files.add_argument("--surface-set", default="all", metavar="NAME",
733
- 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)")
734
887
  files.add_argument("--coverage-inputs", metavar="PATH",
735
888
  help="load-coverage-inputs.py metadata output (scalar and stack branches)")
736
889
  files.add_argument("--verified", metavar="PATH",