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
@@ -81,29 +81,107 @@ 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,
88
- never a lesser gap); any other is a Medium
89
- `stale-documentation` gap `Stale documentation: {name}` at the
90
- first line of SKILL.md, then of references/*.md in path order,
91
- that writes the name (validate-inventory.py's match, with
92
- --skill-dir), else `SKILL.md`. Scalar and stack branches: a
102
+ never a lesser gap). One whose `defined_at` (where the source
103
+ declares it) is a Python or TS/JS `file:line` is a documented
104
+ extra when its name has no dot and the --surface result was
105
+ built from an extraction that read the whole scope (an
106
+ `extraction` object, not `no-ast-grep`, `truncated` or
107
+ `fallback.needed`: only such an extraction fills
108
+ `excluded.outsideScope`, the names a brief scoped out) and its
109
+ `excluded.outsideScope` list does not list
110
+ it: an Info `observation` gap `Documented extra: {name}` at
111
+ `defined_at`. With no `defined_at`, a homonym (its `declared_in`
112
+ lists several declaring files) is one too, with --skill-dir,
113
+ when the skill's `[AST:path:L<n>]` and `[SRC:path:L<n>]`
114
+ citations on the lines of SKILL.md and references/**/*.md that
115
+ name it (outside the citations), or on the next non-blank line
116
+ after one, cite exactly one of those files: the gap
117
+ is at that file's `declared_in` item, and two cited or none
118
+ leave it Medium. The documented extra's issue names the line
119
+ that documents it (with --skill-dir) and its remediation asks no
120
+ change (update-skill never routes it) and says whether signature
121
+ scoring compared its documented signature (a name the
122
+ --signatures result lists in `comparedNames`). `excluded` is
123
+ read once a stale name that is not fabricated needs it, and a
124
+ malformed one is then INVALID_INPUT. Any other is a Medium `stale-documentation` gap
125
+ `Stale documentation: {name}` at the first line of SKILL.md,
126
+ then of references/*.md in path order, that writes the name
127
+ (validate-inventory.py's match, with --skill-dir), else
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
93
137
  `missingCount` above 0 is one Medium `missing-export` gap
94
138
  `{missingCount} of {denominator} exports not documented` at
95
139
  `{meta}`, except that a scalar count taken from the verified
96
140
  numerator (`numeratorSource` `verified`) is replaced by one
97
141
  `Missing export: {name}` gap per --numerator `absent` name, at
98
- the first --provenance entry of that name, else `{meta}`.
99
- 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`.
100
152
  guards --input load-coverage-inputs.py's surface result; --metadata.
101
153
  `guards.deflation.fires` is a Medium `metadata-drift` gap at
102
- `{meta}` (else the result's `inputs.metadata`), and
103
- `guards.inflation.fires` a Medium `denominator-inflation` gap at
104
- the result's `inputs.brief` (else `skill-brief.yaml`), whose
105
- remediation recommends `stats.effective_denominator` when
106
- `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.
172
+ `guards.staleScope.fires` is one Medium `brief-scope-stale` gap
173
+ at that brief, whose issue names each `unmatchedInclude` glob,
174
+ each `restored` name with its file and each
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
181
+ `guards.tierAInclude` reads the `unmatchedTierAInclude` globs
182
+ alone). A stored `stats.effective_denominator` of 0 (`pct`
183
+ null) is written as a count against 0, not a percentage, and
184
+ its fix re-runs Create Skill to count it again.
107
185
  numerator --input verify-declared-numerator.py's result; --metadata.
108
186
  `inflated` true is a High `numerator-inflation` gap at `{meta}`
109
187
  whose issue lists the `absent` names.
@@ -111,7 +189,10 @@ cites (never read; default `metadata.json`):
111
189
  --input check-metadata-coherence.py's result; --metadata. Each
112
190
  `findings[]` entry, titled as it is and with its `detail` as the
113
191
  issue, at `{meta}`: Medium is a Medium `metadata-drift` gap, Info
114
- 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.
115
196
  provenance-line
116
197
  --input skf-verify-provenance-completeness.py verify's result.
117
198
  Each `stale[]` item whose reason is `line-not-definition`, at
@@ -224,13 +305,15 @@ CATEGORIES: dict[str, tuple[str, str]] = {
224
305
  "missing-export": ("Coverage", "an exported function or class the skill does not document"),
225
306
  "signature-mismatch": ("Coverage", "a documented signature that differs from the source"),
226
307
  "fabricated-signature": ("Coverage", "a documented export that is absent at the cited source line"),
227
- "stale-documentation": ("Coverage", "documentation for an export the source no longer has"),
308
+ "stale-documentation": ("Coverage", "documentation for an export the enumerated source surface lacks"),
228
309
  "missing-type": ("Coverage", "a type or interface the skill does not document"),
229
310
  "provenance-completeness": ("Coverage", "an export the skill documents that the provenance map lacks"),
230
311
  "provenance-line": ("Coverage", "a provenance line that is not the definition of its export"),
231
312
  "provenance-unverified": ("Coverage", "a provenance line the line-check rules could not verify"),
232
313
  "metadata-drift": ("Coverage", "export counts in the metadata that diverge from each other"),
233
314
  "denominator-inflation": ("Coverage", "a scope include union larger than the provenance map"),
315
+ "brief-scope-stale": (
316
+ "Coverage", "a scope.include or scope.tier_a_include glob in the brief that matches no source file"),
234
317
  "numerator-inflation": (
235
318
  "Coverage",
236
319
  "a documented count equal to the denominator while declared exports are absent from the skill",
@@ -252,7 +335,7 @@ CATEGORIES: dict[str, tuple[str, str]] = {
252
335
  "a section, code fence, table or usage example in SKILL.md that is missing or wrong",
253
336
  ),
254
337
  "metadata": ("Structural", "missing optional metadata or examples"),
255
- "observation": ("Structural", "a style suggestion or another non-blocking observation"),
338
+ "observation": ("Structural", "a style suggestion, a documented extra or another non-blocking observation"),
256
339
  "discovery": ("Discovery", "a discovery test that misrouted prompts or did not run"),
257
340
  "description": ("Discovery", "a description whose triggers need optimizing"),
258
341
  "external-validator": ("External", "a skill-check diagnostic or a Tessl Review suggestion"),
@@ -527,18 +610,45 @@ def ledger_lock(path: Path, timeout: float | None = None):
527
610
  that the file it locked is still the one at the path: otherwise a waiter
528
611
  on the removed file and a newcomer on a new one would both get a lock.
529
612
  Windows removes no file that is open, so there the file goes after the
530
- release, unless a waiter has it open.
613
+ release, unless a waiter has it open. While the removal is under way (the
614
+ remover's own delete handle is open, or a virus scanner's or the search
615
+ indexer's), Windows refuses a new open of the file with PermissionError:
616
+ an append takes that for a held lock and tries again until the same
617
+ deadline, then raises LEDGER_LOCKED naming the last refusal. A refusal
618
+ with a folder at the path, or with nothing there on an immediate second
619
+ try, is a lasting denial and is raised as is.
531
620
  """
532
621
  timeout = LOCK_TIMEOUT_SECONDS if timeout is None else timeout
533
622
  lock_path = path.with_name(path.name + ".skf-lock")
534
623
  path.parent.mkdir(parents=True, exist_ok=True)
535
624
  deadline = time.monotonic() + timeout
536
625
 
537
- def refuse() -> LedgerError:
538
- return LedgerError("LEDGER_LOCKED", f"another append held {lock_path} for more than {timeout:g} s")
626
+ def refuse(cause: OSError | None = None) -> LedgerError:
627
+ message = f"another append held {lock_path} for more than {timeout:g} s"
628
+ if cause is not None:
629
+ message += f" (last open refused: {cause})"
630
+ return LedgerError("LEDGER_LOCKED", message)
539
631
 
632
+ vanished = False
540
633
  while True:
541
- fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o644)
634
+ try:
635
+ fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o644)
636
+ except PermissionError as exc:
637
+ # Windows refuses to open a file whose removal is pending. A folder
638
+ # at the path, or nothing there twice running (once can be the
639
+ # removal ending just now), is a lasting denial: raise it.
640
+ if not _IS_WINDOWS or os.path.isdir(lock_path):
641
+ raise
642
+ if not os.path.lexists(lock_path):
643
+ if vanished:
644
+ raise
645
+ vanished = True
646
+ continue
647
+ vanished = False
648
+ if time.monotonic() >= deadline:
649
+ raise refuse(exc) from exc
650
+ time.sleep(_LOCK_POLL_SECONDS)
651
+ continue
542
652
  try:
543
653
  while not _try_lock(fd):
544
654
  if time.monotonic() >= deadline:
@@ -649,7 +759,8 @@ _SIBLINGS: dict[str, object] = {}
649
759
 
650
760
  def _sibling(filename: str):
651
761
  """A script beside this one, loaded once: validate-inventory.py (the
652
- documented-name match the stale-documentation Source uses)."""
762
+ documented-name match the stale-documentation Source uses, and the
763
+ skill-citation scan the homonym pick reads)."""
653
764
  module = _SIBLINGS.get(filename)
654
765
  if module is None:
655
766
  path = Path(__file__).resolve().parent / filename
@@ -761,6 +872,95 @@ def _documenting_line(skill_dir: Path, name: str) -> str | None:
761
872
  return None
762
873
 
763
874
 
875
+ def _scoped_out_by(row: dict) -> bool:
876
+ """Whether an `excluded.outsideScope` row says the brief scopes its file
877
+ out: a file, with the `scope.exclude` glob that matches it
878
+ (`excludeGlob`) or no `scope.include` glob covering it
879
+ (`outsideInclude`)."""
880
+ return (isinstance(row.get("file"), str) and bool(row["file"])
881
+ and ((isinstance(row.get("excludeGlob"), str) and bool(row["excludeGlob"]))
882
+ or row.get("outsideInclude") is True))
883
+
884
+
885
+ def _outside_scope(surface: dict | None) -> dict[str, dict] | None:
886
+ """The names a surface result's `excluded.outsideScope` lists (defined in
887
+ a file out of scope), each with its row: the first the brief explains
888
+ (`_scoped_out_by`), else the first with a file, else its first. None
889
+ with no surface, no such list, or a surface built without an extraction
890
+ (no `extraction` object) or from one that did not read the whole scope
891
+ (a `no-ast-grep` status, a `truncated` run, or `fallback.needed`): only
892
+ an extraction fills that list, so the names a brief scoped out are then
893
+ unknown."""
894
+ excluded = (surface or {}).get("excluded")
895
+ if excluded is None:
896
+ return None
897
+ if not isinstance(excluded, dict):
898
+ raise AdapterError("--surface: 'excluded' must be an object")
899
+ rows = excluded.get("outsideScope")
900
+ if rows is None:
901
+ return None
902
+ if not isinstance(rows, list):
903
+ raise AdapterError("--surface: 'excluded.outsideScope' must be a list")
904
+ extraction = (surface or {}).get("extraction")
905
+ if not isinstance(extraction, dict):
906
+ return None
907
+ fallback = extraction.get("fallback")
908
+ if (extraction.get("status") == "no-ast-grep" or extraction.get("truncated")
909
+ or (isinstance(fallback, dict) and fallback.get("needed"))):
910
+ return None
911
+ def rank(row: dict) -> tuple[bool, bool]:
912
+ return _scoped_out_by(row), isinstance(row.get("file"), str) and bool(row["file"])
913
+
914
+ outside: dict[str, dict] = {}
915
+ for row in rows:
916
+ if isinstance(row, dict) and isinstance(row.get("name"), str):
917
+ known = outside.get(row["name"])
918
+ if known is None or rank(row) > rank(known):
919
+ outside[row["name"]] = row
920
+ return outside
921
+
922
+
923
+ # The extensions classify-stale reads a `defined_at` in.
924
+ # Keep identical to PYTHON_EXTENSIONS | TSJS_EXTENSIONS in skf-verify-provenance-completeness.py.
925
+ _DEFINED_AT_EXTENSIONS = frozenset({".py", ".pyi", ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"})
926
+ # classify-stale's `defined_at`: a Python or TS/JS file and a line.
927
+ _DEFINED_AT_RE = re.compile(
928
+ r"^.+(?:" + "|".join(re.escape(e) for e in sorted(_DEFINED_AT_EXTENSIONS)) + r"):[1-9]\d*$", re.IGNORECASE)
929
+
930
+
931
+ def _documented_extra_at(name: str, item: dict, outside: dict[str, dict] | None,
932
+ skill_dir: str | None = None) -> tuple[str, int] | None:
933
+ """Where the source declares a stale name that is a documented extra, given
934
+ a surface built from an extraction whose `excluded.outsideScope` does not
935
+ list the name: (the item's `defined_at`, 0), or, with no `defined_at`,
936
+ (the one `declared_in` item whose file the skill cites on a line that
937
+ names it, the number of files `declared_in` lists) when it lists several.
938
+ None for anything else, which stays a Medium gap: no `defined_at` and no
939
+ single cited declaring file (two or more cited, none, or no --skill-dir),
940
+ a `defined_at` or `declared_in` item not a Python or TS/JS `file:line`, a
941
+ dotted name, no surface or one without an extraction, or a name a brief
942
+ scoped out."""
943
+ if outside is None or name in outside or "." in name:
944
+ return None
945
+ defined_at = item.get("defined_at")
946
+ if defined_at is not None:
947
+ if not isinstance(defined_at, str) or not _DEFINED_AT_RE.match(defined_at):
948
+ return None
949
+ return defined_at, 0
950
+ declared_in = item.get("declared_in")
951
+ if not skill_dir or not isinstance(declared_in, list) or not all(
952
+ isinstance(d, str) and _DEFINED_AT_RE.match(d) for d in declared_in):
953
+ return None
954
+ # validate-inventory.py's skill-citation scan, which score-signatures.py's plan reads too.
955
+ citations = _sibling("validate-inventory.py")
956
+ by_file = {citations._cited_path(d.rsplit(":", 1)[0]): d for d in declared_in}
957
+ if len(by_file) < 2:
958
+ return None
959
+ cited = citations._skill_cited_files(Path(skill_dir), name)
960
+ chosen = [d for f, d in sorted(by_file.items()) if f in cited]
961
+ return (chosen[0], len(by_file)) if len(chosen) == 1 else None
962
+
963
+
764
964
  def _missing_export(name: str, where: str | None, meta: str, kind: str = "export") -> dict:
765
965
  if where:
766
966
  read = f"read its definition at `{where}`"
@@ -778,6 +978,196 @@ def _missing_export(name: str, where: str | None, meta: str, kind: str = "export
778
978
  export=name)
779
979
 
780
980
 
981
+ def _brief_of(surface: dict | None) -> str:
982
+ """The brief a surface result read: its `inputs.brief`, else skill-brief.yaml."""
983
+ inputs = (surface or {}).get("inputs")
984
+ brief = inputs.get("brief") if isinstance(inputs, dict) else None
985
+ return brief if isinstance(brief, str) and brief else _DEFAULT_BRIEF
986
+
987
+
988
+ # The most names the issue of the inflated-union summary gap lists (#795).
989
+ _INFLATED_NAMES_LISTED = 50
990
+ # The clauses whose ladder has a tier A rung, so that a brief's `scope.tier_a_include` corrects the
991
+ # denominator an inflated `scope.include` union gave them (#795).
992
+ # Keep identical to the CLAUSE_LADDERS clauses with a `tier_a_include` rung in load-coverage-inputs.py.
993
+ _TIER_A_CLAUSES = frozenset({"stratified-scope", "multi-entry"})
994
+
995
+
996
+ def _inflated_union(surface: dict | None) -> bool:
997
+ """Whether the barrel branch counts an inflated `scope.include` union a
998
+ tier A list corrects: the surface's inflation guard fires, its
999
+ `denominator.surfaceSet` is `scope.include` and its `denominator.clause`
1000
+ has a tier A rung. A set the surface lacked (`absentSet`) counted `all`
1001
+ in its place, and the specific-modules and empty-barrel clauses count
1002
+ `scope.include` whatever tier A list the brief has: each name there
1003
+ stays its own gap."""
1004
+ guards = (surface or {}).get("guards")
1005
+ inflation = guards.get("inflation") if isinstance(guards, dict) else None
1006
+ denominator = (surface or {}).get("denominator")
1007
+ return (isinstance(inflation, dict) and inflation.get("fires") is True and isinstance(denominator, dict)
1008
+ and denominator.get("surfaceSet") == "scope.include" and denominator.get("clause") in _TIER_A_CLAUSES)
1009
+
1010
+
1011
+ def _inflated_union_gap(names: list[str], where: dict[str, str], types: set[str], brief: str,
1012
+ union: object) -> dict:
1013
+ """One gap at the brief for the names an inflated `scope.include` union
1014
+ counts and the skill does not document, in place of a gap per name: the
1015
+ union sweeps in infrastructure, so most of them belong to no authored
1016
+ surface (#795). It sits beside the guard's own `denominator-inflation`
1017
+ gap at that brief, whose fix it defers to."""
1018
+ def label(name: str) -> str:
1019
+ notes = (["a type"] if name in types else []) + ([f"`{where[name]}`"] if where.get(name) else [])
1020
+ return f"`{name}` ({', '.join(notes)})" if notes else f"`{name}`"
1021
+
1022
+ listed = [label(n) for n in names[:_INFLATED_NAMES_LISTED]]
1023
+ more = len(names) - len(listed)
1024
+ named = ", ".join(listed) + (f", and {more} more" if more else "")
1025
+ size = f" ({union} names)" if _count(union) is not None else ""
1026
+ return _gap(
1027
+ "Medium", "denominator-inflation", "undocumented names in the inflated scope.include union", brief,
1028
+ f"Correct the denominator first, as the denominator-inflation gap for `{brief}` says; then re-run Test "
1029
+ "Skill, and the names the corrected denominator counts that the skill does not document come back as gaps.",
1030
+ issue=f"the inflation guard fired: the `scope.include` union the denominator counts{size} sweeps in names "
1031
+ f"outside the authored surface, and SKILL.md and references/ do not document {len(names)} of them: "
1032
+ f"{named}")
1033
+
1034
+
1035
+ def _scoped_out_gap(name: str, row: dict, doc: str | None, brief: str) -> dict:
1036
+ """The Medium `stale-documentation` gap of a documented name an entry
1037
+ point exports from a file the brief scopes out (#796): its row names
1038
+ the `scope.exclude` glob that matches the file, or says no
1039
+ `scope.include` glob covers it, or both."""
1040
+ file, line, entry = row["file"], _count(row.get("line")), row.get("entry")
1041
+ at = f"{file}:{line}" if line else file
1042
+ glob = row.get("excludeGlob") if isinstance(row.get("excludeGlob"), str) and row.get("excludeGlob") else None
1043
+ uncovered = row.get("outsideInclude") is True
1044
+ causes = (["no `scope.include` glob covers that file"] if uncovered else []) + (
1045
+ [f"its `scope.exclude` glob `{glob}` matches {'it' if uncovered else 'that file'}"] if glob else [])
1046
+ fixes = (["widen `scope.include` to cover that file"] if uncovered else []) + (
1047
+ [f"narrow `{glob}` so it no longer matches that file (to remove the glob instead, record a `demoted-exclude` "
1048
+ "scope-expansion amendment in the brief)"] if glob else [])
1049
+ who = f"the entry point `{entry}`" if isinstance(entry, str) and entry else "an entry point"
1050
+ return _gap(
1051
+ "Medium", "stale-documentation", f"Stale documentation: {name}", doc or "SKILL.md",
1052
+ f"Decide whether `{name}` belongs on the surface: `{brief}` scopes out `{at}`, where it is defined, since "
1053
+ f"{' and '.join(causes)}. To keep its documentation, {' and '.join(fixes)}; else remove the documentation "
1054
+ f"of `{name}` at {doc or 'SKILL.md'}.",
1055
+ issue=f"{f'`{doc}`' if doc else 'SKILL.md'} documents `{name}`, which {who} exports and `{at}` defines, a "
1056
+ f"file `{brief}` scopes out (`excluded.outsideScope`)",
1057
+ export=name)
1058
+
1059
+
1060
+ def _named_inputs(surface: object, signatures: object) -> tuple[dict | None, set[str], set[str]]:
1061
+ """The --surface result, the names --signatures lists in `missingTypes` and the names whose documented
1062
+ signature it compared (`comparedNames`), for a result that names its missing and stale exports."""
1063
+ types: set[str] = set()
1064
+ scored: set[str] = set()
1065
+ if signatures is not None:
1066
+ signatures = _object(signatures, "--signatures (score-signatures.py)", "missingTypes")
1067
+ types = set(_names(signatures, "missingTypes", "--signatures"))
1068
+ scored = set(_names(signatures, "comparedNames", "--signatures"))
1069
+ if surface is not None:
1070
+ surface = _object(surface, "--surface (load-coverage-inputs.py surface)", "exports")
1071
+ return surface, types, scored
1072
+
1073
+
1074
+ def _missing_gaps(cov: dict, surface: dict | None, types: set[str], meta: str) -> list[dict]:
1075
+ """One gap per `missing` name, at the file and line the surface records for it, or, when the surface counts
1076
+ an inflated `scope.include` union a tier A list corrects (`_inflated_union`), one summary gap at the brief in
1077
+ their place (#795)."""
1078
+ where = _surface_sources(surface)
1079
+ missing = _names(cov, "missing", "--input")
1080
+ if missing and _inflated_union(surface):
1081
+ union = ((surface.get("guards") or {}).get("inflation") or {}).get("scopeIncludeUnion")
1082
+ return [_inflated_union_gap(missing, where, types, _brief_of(surface), union)]
1083
+ return [_missing_export(name, where.get(name), meta, "type" if name in types else "export") for name in missing]
1084
+
1085
+
1086
+ def _stale_gaps(cov: dict, surface: dict | None, stale: object, scored: set[str],
1087
+ skill_dir: str | None) -> list[dict]:
1088
+ """One gap per `stale` name: fabricated, a documented extra, or stale documentation (with its own wording
1089
+ for a name defined in a file the brief scopes out, #796)."""
1090
+ records: list[dict] = []
1091
+ # `excluded` is read once a stale name may be a documented extra
1092
+ outside: dict[str, dict] | None = None
1093
+ outside_read = False
1094
+ classified: dict[str, dict] = {}
1095
+ if stale is not None:
1096
+ if not isinstance(stale, list):
1097
+ raise AdapterError("--stale is not classify-stale JSON (a list)")
1098
+ classified = {i["name"]: i for i in stale if isinstance(i, dict) and isinstance(i.get("name"), str)}
1099
+ for name in _names(cov, "stale", "--input"):
1100
+ item = classified.get(name) or {}
1101
+ cited = item.get("source")
1102
+ if item.get("fabricated") is True:
1103
+ if not isinstance(cited, str) or not cited:
1104
+ # a Critical gap needs the line it cites: never downgrade it
1105
+ raise AdapterError(f"--stale: {name!r} is fabricated with no source")
1106
+ records.append(_gap(
1107
+ "Critical", "fabricated-signature", f"Fabricated signature: {name}", cited,
1108
+ f"Remove `{name}` from SKILL.md and references/, or correct it to the export the source "
1109
+ f"defines: no file its provenance cites defines it (`{cited}`).",
1110
+ issue=f"SKILL.md documents `{name}`, which the source API surface lacks and the cited "
1111
+ "file does not define",
1112
+ export=name))
1113
+ continue
1114
+ if not outside_read:
1115
+ outside, outside_read = _outside_scope(surface), True
1116
+ extra = _documented_extra_at(name, item, outside, skill_dir)
1117
+ doc = _documenting_line(Path(skill_dir), name) if skill_dir else None
1118
+ if extra:
1119
+ declared, among = extra
1120
+ # a homonym: the one declaring file the skill's own citations name
1121
+ picked = (f"; of the {among} files that declare it, the skill cites only "
1122
+ f"`{declared.rsplit(':', 1)[0]}`") if among else ""
1123
+ # never routed: update-skill leaves an `observation` alone. Signature scoring compares a
1124
+ # documented signature of a name on the surface's records (a nested sub-package's) alone.
1125
+ checked = ("Signature scoring compares its documented signature with the surface's record of it."
1126
+ if name in scored else
1127
+ "Its documented signature was not checked against that declaration.")
1128
+ records.append(_gap(
1129
+ "Info", "observation", f"Documented extra: {name}", declared,
1130
+ f"No change: `{name}` is a documented extra, a name the source still declares at "
1131
+ f"`{declared}` outside the enumerated surface, and its documentation stays. {checked}",
1132
+ issue=f"{f'`{doc}`' if doc else 'the skill'} documents `{name}`, which the enumerated source API "
1133
+ f"surface lacks and the source still declares{picked}",
1134
+ export=name))
1135
+ continue
1136
+ if outside is not None and name in outside and _scoped_out_by(outside[name]):
1137
+ # an entry point exports it from a file the brief scopes out (#796)
1138
+ records.append(_scoped_out_gap(name, outside[name], doc, _brief_of(surface)))
1139
+ continue
1140
+ records.append(_gap(
1141
+ "Medium", "stale-documentation", f"Stale documentation: {name}", doc or "SKILL.md",
1142
+ f"Check `{name}` against the source and update or remove its documentation at "
1143
+ f"{doc or 'SKILL.md'}: the enumerated source surface does not export it.",
1144
+ issue=f"SKILL.md documents `{name}`, which the enumerated source API surface lacks",
1145
+ export=name))
1146
+ return records
1147
+
1148
+
1149
+ def _residual_gap(cov: dict, meta: str) -> list[dict]:
1150
+ """A counted set's residual: the exports the stored count counts beyond the names the set lists, one gap
1151
+ when `missingCount` exceeds the listed `missing` names."""
1152
+ listed = len(_names(cov, "missing", "--input"))
1153
+ missing, denominator = _count(cov.get("missingCount")), _count(cov.get("denominator"))
1154
+ residual = (missing or 0) - listed
1155
+ if residual <= 0:
1156
+ return []
1157
+ set_name = cov.get("surfaceSet") if isinstance(cov.get("surfaceSet"), str) else "named"
1158
+ size = _count(cov.get("surfaceSetSize"))
1159
+ names = f"{size} names" if size is not None else "names"
1160
+ return [_gap(
1161
+ "Medium", "missing-export", f"{residual} of {denominator} exports not documented beyond the {set_name} set",
1162
+ meta,
1163
+ f"Find the {residual} exports the stored count (`stats.effective_denominator`) counts beyond the {names} "
1164
+ f"of the `{set_name}` set (`{meta}` lists the exports) and document each with its signature; a stale "
1165
+ "stored count is recounted by re-running Create Skill.",
1166
+ issue=f"of the {denominator} exports the stored count counts, {missing} are not documented: the "
1167
+ f"`{set_name}` set names {listed} of them, and the other {residual} are exports the count holds "
1168
+ "beyond the set's names")]
1169
+
1170
+
781
1171
  def from_coverage(cov: object, surface: object = None, signatures: object = None, numerator: object = None,
782
1172
  stale: object = None, provenance: object = None, skill_dir: str | None = None,
783
1173
  metadata: str | None = None) -> list[dict]:
@@ -787,42 +1177,16 @@ def from_coverage(cov: object, surface: object = None, signatures: object = None
787
1177
  branch = cov["branch"]
788
1178
  records: list[dict] = []
789
1179
  if branch == "enumerated":
790
- types = set()
791
- if signatures is not None:
792
- types = set(_names(_object(signatures, "--signatures (score-signatures.py)", "missingTypes"),
793
- "missingTypes", "--signatures"))
794
- where = _surface_sources(_object(surface, "--surface (load-coverage-inputs.py surface)", "exports")
795
- if surface is not None else None)
796
- for name in _names(cov, "missing", "--input"):
797
- records.append(_missing_export(name, where.get(name), meta, "type" if name in types else "export"))
798
- classified: dict[str, dict] = {}
799
- if stale is not None:
800
- if not isinstance(stale, list):
801
- raise AdapterError("--stale is not classify-stale JSON (a list)")
802
- classified = {i["name"]: i for i in stale if isinstance(i, dict) and isinstance(i.get("name"), str)}
803
- for name in _names(cov, "stale", "--input"):
804
- item = classified.get(name) or {}
805
- cited = item.get("source")
806
- if item.get("fabricated") is True:
807
- if not isinstance(cited, str) or not cited:
808
- # a Critical gap needs the line it cites: never downgrade it
809
- raise AdapterError(f"--stale: {name!r} is fabricated with no source")
810
- records.append(_gap(
811
- "Critical", "fabricated-signature", f"Fabricated signature: {name}", cited,
812
- f"Remove `{name}` from SKILL.md and references/, or correct it to the export the source "
813
- f"defines: no file its provenance cites defines it (`{cited}`).",
814
- issue=f"SKILL.md documents `{name}`, which the source API surface lacks and the cited "
815
- "file does not define",
816
- export=name))
817
- continue
818
- doc = _documenting_line(Path(skill_dir), name) if skill_dir else None
819
- records.append(_gap(
820
- "Medium", "stale-documentation", f"Stale documentation: {name}", doc or "SKILL.md",
821
- f"Check `{name}` against the source and update or remove its documentation at "
822
- f"{doc or 'SKILL.md'}: the enumerated source surface does not export it.",
823
- issue=f"SKILL.md documents `{name}`, which the enumerated source API surface lacks",
824
- export=name))
1180
+ surface, types, scored = _named_inputs(surface, signatures)
1181
+ records += _missing_gaps(cov, surface, types, meta)
1182
+ records += _stale_gaps(cov, surface, stale, scored, skill_dir)
825
1183
  elif branch in ("scalar", "stack"):
1184
+ # A scalar result that counts its set lists its missing names, and one on a surface read from the
1185
+ # source its stale names: each is recorded as the barrel branch's are.
1186
+ counted = branch == "scalar" and cov.get("numeratorSource") == "surface"
1187
+ stale_named = branch == "scalar" and cov.get("staleApplicable") is True
1188
+ if counted or stale_named:
1189
+ surface, types, scored = _named_inputs(surface, signatures)
826
1190
  if branch == "scalar" and cov.get("numeratorSource") == "verified":
827
1191
  if numerator is None:
828
1192
  raise AdapterError("the scalar count used the verified numerator: pass its result with "
@@ -832,6 +1196,9 @@ def from_coverage(cov: object, surface: object = None, signatures: object = None
832
1196
  where = _provenance_sources(_object(provenance, "--provenance (provenance map)", "entries")
833
1197
  if provenance is not None else None)
834
1198
  records += [_missing_export(name, where.get(name), meta) for name in absent]
1199
+ elif counted:
1200
+ records += _missing_gaps(cov, surface, types, meta)
1201
+ records += _residual_gap(cov, meta)
835
1202
  else:
836
1203
  missing, denominator = _count(cov.get("missingCount")), _count(cov.get("denominator"))
837
1204
  if missing:
@@ -841,13 +1208,15 @@ def from_coverage(cov: object, surface: object = None, signatures: object = None
841
1208
  f"name (`{meta}` lists the exports) and document each with its signature.",
842
1209
  issue=f"SKILL.md and references/ name {cov.get('documented')} of the {denominator} exports "
843
1210
  f"the {branch} denominator counts"))
1211
+ if stale_named:
1212
+ records += _stale_gaps(cov, surface, stale, scored, skill_dir)
844
1213
  elif branch != "docsOnly":
845
1214
  raise AdapterError(f"--input (reconcile-coverage.py): unknown branch {branch!r}")
846
1215
  return records
847
1216
 
848
1217
 
849
1218
  def from_guards(surface: object, metadata: str | None = None) -> list[dict]:
850
- """load-coverage-inputs.py surface's deflation and inflation guards."""
1219
+ """load-coverage-inputs.py surface's deflation, inflation and stale-scope guards."""
851
1220
  surface = _object(surface, "--input (load-coverage-inputs.py surface)", "guards", "inputs")
852
1221
  guards = surface["guards"]
853
1222
  if guards is None:
@@ -858,31 +1227,147 @@ def from_guards(surface: object, metadata: str | None = None) -> list[dict]:
858
1227
  meta = metadata or inputs.get("metadata") or _DEFAULT_METADATA
859
1228
  brief = inputs.get("brief") or _DEFAULT_BRIEF
860
1229
  records: list[dict] = []
1230
+ stale = guards.get("staleScope") or {}
1231
+ label = "--input (load-coverage-inputs.py surface): guards.staleScope"
1232
+ # A tier A glob that matches no file counts no name, and so does one that
1233
+ # matches only excluded files or files that define no name: when the
1234
+ # guards fire, the brief has no tier A glob, or none of its globs counts a
1235
+ # name. `tierAInclude` (the brief's tier A globs) is absent from a surface
1236
+ # older than #712, where only the stale globs say the brief has some.
1237
+ tier_a_globs = _names(stale, "unmatchedTierAInclude", label)
1238
+ brief_tier_a = list(dict.fromkeys(
1239
+ _names(guards, "tierAInclude", "--input (load-coverage-inputs.py surface): guards") + tier_a_globs))
1240
+ if not brief_tier_a:
1241
+ no_tier_a = "the brief has no `scope.tier_a_include`"
1242
+ elif set(brief_tier_a) <= set(tier_a_globs):
1243
+ no_tier_a = "no `scope.tier_a_include` glob in the brief matches a file"
1244
+ else:
1245
+ no_tier_a = "no `scope.tier_a_include` glob in the brief counts a public name"
861
1246
  deflation = guards.get("deflation") or {}
862
- if deflation.get("fires") is True:
1247
+ inflation = guards.get("inflation") or {}
1248
+ stored = _count(deflation.get("effectiveDenominator")) or 0
1249
+ # No remediation sets `stats.effective_denominator` by hand: Update Skill's recount drops such a value (#803).
1250
+ # Both guards on the scope.include union name one fix, the brief's tier A list (#788).
1251
+ both = (deflation.get("fires") is True and inflation.get("fires") is True
1252
+ and deflation.get("basis") != "tier_a_include")
1253
+ # On an umbrella barrel the names are re-exports, each counted at the file that defines it, never the barrel.
1254
+ umbrella = (" rather than the root barrel, an umbrella of re-exports"
1255
+ if (guards.get("umbrella") or {}).get("umbrella") is True else "")
1256
+ verb = (f"Edit `scope.tier_a_include` in `{brief}` so its globs list" if brief_tier_a
1257
+ else f"Add `scope.tier_a_include` to `{brief}`, listing")
1258
+ # Only Create Skill always writes metadata.json, and it stores the runner's count only under this rule.
1259
+ stores = ("when `effective_denominator_decision.store` holds and the brief's `scope.notes` documents the "
1260
+ "stratification, and otherwise drops the stored count")
1261
+ joint = (f"{verb} the files that define the authored names{umbrella}: the `scope.include` union is itself "
1262
+ "inflated, so neither it nor the smaller stored count measures the authored surface. Then re-run "
1263
+ f"Create Skill to recount `stats.effective_denominator` in `{meta}` from the tier A set: it stores "
1264
+ f"that count {stores}.")
1265
+ if deflation.get("fires") is True and deflation.get("basis") == "tier_a_include":
1266
+ # A stored count the brief's tier A set disagrees with, either way: stale, so count it again (#813).
1267
+ pct = deflation.get("pct")
1268
+ if pct is None: # a stored 0: no percentage against it
1269
+ against = f" against a `stats.effective_denominator` of {stored}"
1270
+ else:
1271
+ below = isinstance(pct, (int, float)) and pct < 0
1272
+ against = (f", {-pct if below else pct}% {'below' if below else 'above'} `stats.effective_denominator` "
1273
+ f"({deflation.get('effectiveDenominator')})")
1274
+ # Until then: the set the denominator actually counts (a ladder with no tier A rung counts scope.include),
1275
+ # or, on an incomplete extraction's tier A set below the stored count, the stored count.
1276
+ denominator = surface.get("denominator") if isinstance(surface.get("denominator"), dict) else {}
1277
+ extraction = surface.get("extraction") if isinstance(surface.get("extraction"), dict) else {}
1278
+ if denominator.get("branch") == "scalar":
1279
+ until = " Until then the stored count stays the denominator" + (
1280
+ ": the extraction is incomplete, so the tier A set can miss names." if extraction.get("status")
1281
+ == "incomplete" else ".")
1282
+ elif isinstance(denominator.get("surfaceSet"), str):
1283
+ until = f" Until then Test Skill counts the `{denominator['surfaceSet']}` set as the denominator."
1284
+ else:
1285
+ until = ""
1286
+ records.append(_gap(
1287
+ "Medium", "metadata-drift",
1288
+ "denominator drift: effective_denominator disagrees with the brief's tier_a_include set", meta,
1289
+ f"Re-run Create Skill to recount `stats.effective_denominator` in `{meta}`: it stores the runner's count "
1290
+ f"of the brief's `scope.tier_a_include` set {stores}.{until}",
1291
+ issue=f"the brief's `scope.tier_a_include` set counts {deflation.get('rederived')} exports{against}"))
1292
+ elif deflation.get("fires") is True:
1293
+ if deflation.get("pct") is None: # a stored 0: no percentage above it
1294
+ above = f" against a `stats.effective_denominator` of {stored}"
1295
+ else:
1296
+ above = (f", {deflation.get('pct')}% above `stats.effective_denominator` "
1297
+ f"({deflation.get('effectiveDenominator')})")
1298
+ if both:
1299
+ fix = joint
1300
+ elif deflation.get("pct") is None: # a 0 measures no tier: count it again
1301
+ first = ("Edit `scope.tier_a_include` in the brief so its globs match the files that define the "
1302
+ "authored tier" if brief_tier_a else "Check `scope.include` in the brief")
1303
+ fix = (f"{first}, then re-run Create Skill so `stats.effective_denominator` in `{meta}` is counted "
1304
+ "again.")
1305
+ elif brief_tier_a:
1306
+ fix = (f"Re-run Create Skill to recount `stats.effective_denominator` in `{meta}` from the public surface "
1307
+ f"the source re-derives (it stores that count {stores}), or edit `scope.tier_a_include` in the "
1308
+ "brief so its globs match the files of the authored tier the smaller count measures.")
1309
+ else:
1310
+ fix = (f"Re-run Create Skill to recount `stats.effective_denominator` in `{meta}` from the public surface "
1311
+ f"the source re-derives (it stores that count {stores}), or add `scope.tier_a_include` to the "
1312
+ "brief to name the authored tier the smaller count measures.")
863
1313
  records.append(_gap(
864
1314
  "Medium", "metadata-drift",
865
1315
  "denominator deflation: effective_denominator below source public surface without tier_a_include",
866
- meta,
867
- f"Set `stats.effective_denominator` in `{meta}` to the public surface the source re-derives, or "
868
- "add `scope.tier_a_include` to the brief to name the authored tier the smaller count measures.",
869
- issue=f"the re-derived surface counts {deflation.get('rederived')} exports, "
870
- f"{deflation.get('pct')}% above `stats.effective_denominator` "
871
- f"({deflation.get('effectiveDenominator')}), and the brief has no `scope.tier_a_include`"))
872
- inflation = guards.get("inflation") or {}
1316
+ meta, fix,
1317
+ issue=f"the re-derived surface counts {deflation.get('rederived')} exports{above}, and {no_tier_a}"))
873
1318
  if inflation.get("fires") is True:
874
- if (guards.get("umbrella") or {}).get("umbrella") is True:
875
- fix = (f"Set `stats.effective_denominator` in `{meta}` to the authored surface: the root barrel "
876
- "is an umbrella of re-exports, so a `scope.tier_a_include` glob would still count them.")
1319
+ if both:
1320
+ fix = joint
1321
+ elif brief_tier_a:
1322
+ fix = (f"Edit `scope.tier_a_include` in `{brief}` so its globs match the files of the authored "
1323
+ f"surface{umbrella}: the denominator then counts it instead of the coarse `scope.include` union.")
877
1324
  else:
878
- fix = (f"Add `scope.tier_a_include` to `{brief}`, listing the files of the authored surface, so "
879
- "the denominator counts it instead of the coarse `scope.include` union.")
1325
+ fix = (f"Add `scope.tier_a_include` to `{brief}`, listing the files of the authored surface{umbrella}, "
1326
+ "so the denominator counts it instead of the coarse `scope.include` union.")
880
1327
  records.append(_gap(
881
1328
  "Medium", "denominator-inflation",
882
1329
  "denominator inflation: coarse scope.include union exceeds authored surface", brief, fix,
883
1330
  issue=f"the `scope.include` union counts {inflation.get('scopeIncludeUnion')} exports, "
884
1331
  f"{inflation.get('pct')}% above the {inflation.get('provenanceEntries')} provenance entries, "
885
- "and the brief has no `scope.tier_a_include`"))
1332
+ f"and {no_tier_a}"))
1333
+ if stale.get("fires") is True:
1334
+ globs = _names(stale, "unmatchedInclude", label)
1335
+ restored = sorted({(r["name"], r["file"] if isinstance(r.get("file"), str) else "")
1336
+ for r in _list(stale, "restored", label)
1337
+ if isinstance(r, dict) and isinstance(r.get("name"), str) and r["name"]})
1338
+ names = ", ".join(f"`{name}` in `{file}`" if file else f"`{name}`" for name, file in restored)
1339
+ if len(globs) == 1:
1340
+ unmatched = f"the `scope.include` glob {_code(globs)} matches no file in the source tested"
1341
+ else:
1342
+ unmatched = f"the `scope.include` globs {_code(globs)} match no file in the source tested"
1343
+ if not restored:
1344
+ back = "no root export was restored"
1345
+ elif len(restored) == 1:
1346
+ back = f"the `all` set restores 1 root export, defined in a file no include glob covers: {names}"
1347
+ else:
1348
+ back = (f"the `all` set restores {len(restored)} root exports, defined in files no include glob "
1349
+ f"covers: {names}")
1350
+ if len(tier_a_globs) == 1:
1351
+ tier_a = (f"the `scope.tier_a_include` glob {_code(tier_a_globs)} matches no file in the source "
1352
+ "tested, so it counts no name")
1353
+ else:
1354
+ tier_a = (f"the `scope.tier_a_include` globs {_code(tier_a_globs)} match no file in the source "
1355
+ "tested, so they count no name")
1356
+ exclude = ", and add to `scope.exclude` any restored file the brief meant to leave out"
1357
+ if not tier_a_globs: # the include globs alone, as #677 wrote the gap
1358
+ lists, rewrite, clauses = ["scope.include"], "an include glob", [unmatched, back]
1359
+ elif not globs: # no include glob is stale, so nothing was restored
1360
+ lists, rewrite, clauses, exclude = ["scope.tier_a_include"], "a tier A glob", [tier_a], ""
1361
+ else:
1362
+ lists = ["scope.include", "scope.tier_a_include"]
1363
+ rewrite, clauses = "an include or tier A glob", [unmatched, back, tier_a]
1364
+ edit = " and ".join(f"`{key}`" for key in lists)
1365
+ records.append(_gap(
1366
+ "Medium", "brief-scope-stale", f"stale brief scope: {' and '.join(lists)} globs match no source file",
1367
+ brief,
1368
+ f"Edit {edit} in `{brief}` by hand so each glob matches the files it meant in this version of the "
1369
+ f"source (update-skill does not rewrite {rewrite}){exclude}.",
1370
+ issue="; ".join(clauses)))
886
1371
  return records
887
1372
 
888
1373
 
@@ -899,10 +1384,11 @@ def from_numerator(numerator: object, metadata: str | None = None) -> list[dict]
899
1384
  f"numerator inflation: {declared - verified} of {declared} declared exports absent from "
900
1385
  "SKILL.md/references",
901
1386
  meta,
902
- f"Document the absent exports in SKILL.md, or set `stats.exports_documented` in `{meta}` to the "
903
- f"{verified} exports the skill documents.",
904
- issue=f"`stats.exports_documented` equals the denominator, and these declared exports are absent "
905
- f"from SKILL.md and references/: {_code(absent)}")]
1387
+ f"Document the absent exports in SKILL.md or references/: the skill documents {verified} of its "
1388
+ f"{declared} declared exports.",
1389
+ issue=f"a declared count (`stats.exports_documented`, or the provenance map's distinct entry names) "
1390
+ f"equals `stats.effective_denominator`, and these declared exports are absent from SKILL.md and "
1391
+ f"references/: {_code(absent)}")]
906
1392
 
907
1393
 
908
1394
  def from_metadata_coherence(result: object, metadata: str | None = None) -> list[dict]:
@@ -914,11 +1400,24 @@ def from_metadata_coherence(result: object, metadata: str | None = None) -> list
914
1400
  if not isinstance(finding, dict) or not isinstance(finding.get("title"), str):
915
1401
  raise AdapterError(f"--input: findings[{index}] has no title")
916
1402
  detail = finding.get("detail") if isinstance(finding.get("detail"), str) else None
917
- if finding.get("severity") == "Medium":
1403
+ if finding.get("severity") == "Medium" and "documented-surface" in finding["title"]:
1404
+ # `stats.exports_documented` and the confidence_distribution sum count every provenance entry, while
1405
+ # the named-export count leaves out `Type::method` entries: a re-run reproduces that difference
1406
+ records.append(_gap(
1407
+ "Medium", "metadata-drift", finding["title"], meta,
1408
+ f"No count in `{meta}` needs a fix when `stats.exports_documented` and the `confidence_distribution` "
1409
+ "sum agree: both count every provenance map entry, while the named-export count leaves out the "
1410
+ "`Type::method` entries, so the counts differ by the methods the skill documents. When those two "
1411
+ "disagree, Update Skill or Create Skill recounts both from the provenance map the next time it "
1412
+ "writes the skill; a hand edit does not hold.",
1413
+ issue=detail))
1414
+ elif finding.get("severity") == "Medium":
918
1415
  records.append(_gap(
919
1416
  "Medium", "metadata-drift", finding["title"], meta,
920
- f"Recount the exports and correct the diverging counts in `{meta}`; update-skill applies it as "
921
- "a metadata update.",
1417
+ f"Re-run Create Skill, which rebuilds `exports[]` and `stats.exports_public_api` in `{meta}` from "
1418
+ "the source. Update Skill does not repair this drift: each time it writes the skill it recounts "
1419
+ "`stats.exports_public_api` from the source and keeps `exports[]`, adding or dropping only the "
1420
+ "names its provenance map changes, so a metadata patch does not hold.",
922
1421
  issue=detail))
923
1422
  elif finding.get("severity") == "Info":
924
1423
  records.append(_gap(
@@ -1329,10 +1828,11 @@ def _build_parser() -> argparse.ArgumentParser:
1329
1828
  "(structure takes it more than once)")
1330
1829
  p.add_argument("--from", dest="adapter", choices=tuple(ADAPTER_OPTIONS),
1331
1830
  help="build the records from the --input result of a script (see the docstring's Adapters)")
1332
- p.add_argument("--surface", help="coverage: load-coverage-inputs.py surface's result (Sources, kinds)")
1831
+ p.add_argument("--surface", help="coverage: load-coverage-inputs.py surface's result (Sources, kinds, "
1832
+ "the names outside scope)")
1333
1833
  p.add_argument("--signatures", help="coverage: score-signatures.py score's result (missingTypes)")
1334
1834
  p.add_argument("--numerator", help="coverage: verify-declared-numerator.py's result (absent names)")
1335
- p.add_argument("--stale", help="coverage: classify-stale's result (fabricated signatures)")
1835
+ p.add_argument("--stale", help="coverage: classify-stale's result (fabricated signatures, documented extras)")
1336
1836
  p.add_argument("--provenance", help="coverage: the provenance map (an absent name's Source)")
1337
1837
  p.add_argument("--skill-dir", help="coverage: the skill package (a stale name's documenting line)")
1338
1838
  p.add_argument("--metadata", help="the metadata.json path a record cites as its Source (not read)")