bmad-module-skill-forge 4.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +3 -3
  3. package/docs/_data/pinned.yaml +19 -13
  4. package/docs/agents.md +1 -1
  5. package/docs/architecture.md +5 -5
  6. package/docs/campaign.md +1 -1
  7. package/docs/concepts.md +4 -4
  8. package/docs/examples.md +32 -25
  9. package/docs/getting-started.md +3 -3
  10. package/docs/how-it-works.md +2 -2
  11. package/docs/index.md +1 -1
  12. package/docs/skill-model.md +41 -39
  13. package/docs/troubleshooting.md +9 -3
  14. package/docs/verifying-a-skill.md +42 -21
  15. package/docs/why-skf.md +1 -1
  16. package/docs/workflows.md +6 -6
  17. package/package.json +1 -1
  18. package/src/knowledge/tool-resolution.md +6 -6
  19. package/src/shared/references/headless-gate-convention.md +4 -1
  20. package/src/shared/references/output-contract-schema.md +3 -3
  21. package/src/shared/references/pipeline-contracts.md +2 -1
  22. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  23. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
  24. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  25. package/src/shared/scripts/skf-build-change-manifest.py +1180 -189
  26. package/src/shared/scripts/skf-classify-changed-files.py +15 -10
  27. package/src/shared/scripts/skf-count-tokens.py +54 -10
  28. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  29. package/src/shared/scripts/skf-emit-result-envelope.py +64 -20
  30. package/src/shared/scripts/skf-extract-public-api.py +472 -120
  31. package/src/shared/scripts/skf-extraction-inventory.py +10 -6
  32. package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
  33. package/src/shared/scripts/skf-parse-gaps.py +136 -26
  34. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  35. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  36. package/src/shared/scripts/skf-shard-body.py +239 -41
  37. package/src/shared/scripts/skf-validate-output.py +12 -3
  38. package/src/shared/scripts/skf-verify-provenance-completeness.py +574 -45
  39. package/src/shared/tool-requirements.yaml +8 -2
  40. package/src/skf-analyze-source/SKILL.md +3 -2
  41. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  42. package/src/skf-analyze-source/references/generate-briefs.md +1 -1
  43. package/src/skf-analyze-source/references/headless-contract.md +1 -1
  44. package/src/skf-audit-skill/SKILL.md +4 -3
  45. package/src/skf-audit-skill/references/re-index.md +2 -2
  46. package/src/skf-audit-skill/references/report.md +1 -1
  47. package/src/skf-brief-skill/SKILL.md +3 -2
  48. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  49. package/src/skf-brief-skill/references/invocation-contract.md +2 -2
  50. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  51. package/src/skf-campaign/SKILL.md +3 -2
  52. package/src/skf-campaign/references/campaign-contracts.md +2 -0
  53. package/src/skf-campaign/references/step-05-skill-loop.md +8 -3
  54. package/src/skf-campaign/references/step-10-export.md +3 -2
  55. package/src/skf-create-skill/SKILL.md +4 -3
  56. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  57. package/src/skf-create-skill/references/ccc-index-check.md +1 -1
  58. package/src/skf-create-skill/references/component-extraction.md +1 -1
  59. package/src/skf-create-skill/references/extract.md +3 -2
  60. package/src/skf-create-skill/references/extraction-patterns.md +3 -3
  61. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  62. package/src/skf-create-skill/references/report.md +8 -3
  63. package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
  64. package/src/skf-create-skill/references/validate.md +1 -1
  65. package/src/skf-create-stack-skill/SKILL.md +3 -2
  66. package/src/skf-create-stack-skill/references/generate-output.md +1 -1
  67. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  68. package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
  69. package/src/skf-create-stack-skill/references/report.md +1 -1
  70. package/src/skf-create-stack-skill/references/validate.md +1 -1
  71. package/src/skf-drop-skill/SKILL.md +3 -2
  72. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  73. package/src/skf-drop-skill/references/report.md +1 -1
  74. package/src/skf-export-skill/SKILL.md +3 -2
  75. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  76. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  77. package/src/skf-export-skill/references/invocation-contract.md +1 -1
  78. package/src/skf-export-skill/references/load-skill.md +1 -1
  79. package/src/skf-export-skill/references/summary.md +1 -1
  80. package/src/skf-export-skill/references/token-report.md +29 -9
  81. package/src/skf-forger/SKILL.md +3 -2
  82. package/src/skf-forger/references/pipeline-mode.md +2 -2
  83. package/src/skf-quick-skill/SKILL.md +4 -3
  84. package/src/skf-quick-skill/references/finalize.md +1 -1
  85. package/src/skf-quick-skill/references/halt-contract.md +2 -2
  86. package/src/skf-quick-skill/references/write-and-validate.md +1 -1
  87. package/src/skf-refine-architecture/SKILL.md +8 -9
  88. package/src/skf-refine-architecture/customize.toml +1 -1
  89. package/src/skf-refine-architecture/references/report.md +1 -1
  90. package/src/skf-rename-skill/SKILL.md +3 -2
  91. package/src/skf-rename-skill/references/invocation-contract.md +2 -2
  92. package/src/skf-setup/SKILL.md +3 -2
  93. package/src/skf-setup/references/ccc-index.md +1 -1
  94. package/src/skf-setup/references/invocation-contract.md +1 -1
  95. package/src/skf-test-skill/SKILL.md +3 -2
  96. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  97. package/src/skf-test-skill/references/coherence-check.md +11 -8
  98. package/src/skf-test-skill/references/coverage-check-branches.md +81 -0
  99. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  100. package/src/skf-test-skill/references/coverage-check.md +60 -97
  101. package/src/skf-test-skill/references/init.md +2 -1
  102. package/src/skf-test-skill/references/inventory-contract.md +27 -0
  103. package/src/skf-test-skill/references/invocation-contract.md +3 -3
  104. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  105. package/src/skf-test-skill/references/report.md +5 -5
  106. package/src/skf-test-skill/references/scoring-rules.md +8 -7
  107. package/src/skf-test-skill/references/signature-comparison-rules.md +72 -0
  108. package/src/skf-test-skill/references/source-access-protocol.md +14 -10
  109. package/src/skf-test-skill/references/step-hard-gate.md +1 -0
  110. package/src/skf-test-skill/scripts/build-result-context.py +20 -3
  111. package/src/skf-test-skill/scripts/gap-ledger.py +456 -176
  112. package/src/skf-test-skill/scripts/load-coverage-inputs.py +516 -79
  113. package/src/skf-test-skill/scripts/locate-export-segments.py +685 -76
  114. package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
  115. package/src/skf-test-skill/scripts/score-signatures.py +1205 -65
  116. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  117. package/src/skf-test-skill/scripts/validate-inventory.py +350 -35
  118. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  119. package/src/skf-update-skill/SKILL.md +3 -2
  120. package/src/skf-update-skill/references/detect-changes.md +1 -1
  121. package/src/skf-update-skill/references/gap-driven.md +54 -33
  122. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  123. package/src/skf-update-skill/references/merge.md +15 -10
  124. package/src/skf-update-skill/references/re-extract.md +3 -1
  125. package/src/skf-update-skill/references/report.md +6 -5
  126. package/src/skf-update-skill/references/write.md +42 -20
  127. package/src/skf-verify-stack/SKILL.md +4 -3
  128. package/tools/cli/lib/tool-check.js +158 -2
@@ -9,7 +9,7 @@ coverage-check.md decides whether a bookkeeping variant is a real export,
9
9
  and makes the judgments the denominator clause needs where no file holds
10
10
  them (--pattern-reference-intent, and --curated-subset and --barrel where
11
11
  the extraction does not say); the clause, everything it counts, folds or
12
- unions on the way runs here, so no count is made by hand. Three subcommands, each
12
+ unions on the way runs here, so no count is made by hand. Four subcommands, each
13
13
  reading files and writing one JSON object:
14
14
 
15
15
  census docs-only detection. Counts the citations SKILL.md and each .md
@@ -18,6 +18,65 @@ reading files and writing one JSON object:
18
18
  (`[QMD:` and `[DOC:` are counted and decide nothing). A skill is
19
19
  docs-only when it cites `[EXT:` and no local form.
20
20
 
21
+ floor the documented inventory's floor (#790): every name SKILL.md
22
+ and each .md file directly in references/ (a subfolder is not
23
+ read) write in a fixed form, which validate-inventory.py --floor
24
+ refuses an inventory for leaving out. It owns names only: no
25
+ kind is mapped and no signature read. A name is a plain
26
+ identifier (`[A-Za-z_$][\\w$]*`) once a leading `new ` and
27
+ everything from the first `(` or `<` are cut, so `Meta<T>`,
28
+ `fetch(url): Promise<T>` and `new Client(opts)` give `Meta`,
29
+ `fetch` and `Client`. A dotted or `::` name gives none, so a
30
+ namespace-style skill such as zod (`z.object`) gets no table
31
+ floor. The forms, read outside the frontmatter and HTML
32
+ comments (a table row or heading inside fenced code gives none):
33
+ table-row each inline code span in the first cell of a
34
+ table row (cells split on an unescaped `|`)
35
+ whose header row's first cell is, in any case,
36
+ singular or plural and bare or wrapped in `**`
37
+ or `__`, Export, Symbol, Function, Class,
38
+ Interface, Type, Hook, Component, Helper,
39
+ Constant, Enum or API (a Name, Method, Prop,
40
+ Param, Import or Subpath table gives none). The
41
+ row's Kind cell, as written, is the name's
42
+ `kindLabel`: the skill's own label, never mapped
43
+ to a kind (the first in origin order when two
44
+ rows label the name differently; null with no
45
+ Kind column)
46
+ heading an ATX heading whose whole text is one code
47
+ span, as in ``### `Meta<T>` `` (a heading with
48
+ other words, ``## Testing with `mount` ``, gives
49
+ none)
50
+ declaration a column-0 public declaration in a fenced block
51
+ whose first non-blank line is a comment (`//`,
52
+ `#`, `/* */`, `--`, `<!-- -->` or `;`) holding an
53
+ `[AST:` or `[SRC:` citation, or naming a path one
54
+ such citation of the skill names (its `:12`,
55
+ `:L12` or `:12-30` left out). A citation counts
56
+ only with its `:L<n>` (`[AST:src/a.ts:L12]`, not
57
+ `[AST:src/a.ts]`), and one on the line above the
58
+ fence heads nothing. Comments and strings are
59
+ masked first. TS/JS (not Svelte, whose `export
60
+ let` is a prop): `export` then `function`,
61
+ `const`, `let`, `var`, `class`, `interface`,
62
+ `type` or `enum` (`declare`, `abstract` and
63
+ `async` may come between), or `export default
64
+ function` or `class` with a name; Python: `def`,
65
+ `async def` or `class`, a name not starting with
66
+ `_`; Rust: a `pub` (not `pub(crate)`) `fn`,
67
+ `struct`, `enum`, `trait`, `type`, `const` or
68
+ `static`, and a `macro_rules!` under
69
+ `#[macro_export]`; Go: `func`, `type`, `var` or
70
+ `const` with a capitalized name, each name of a
71
+ `type (`, `var (` or `const (` group too. A
72
+ block in another language, an untagged one or a
73
+ data block (json, yaml, text...) gives none.
74
+ Output: {"files": [...], "names": [{"name", "kindLabel",
75
+ "origins": [{"file", "line", "form"}]}], "summary": {"names",
76
+ "byForm"}}, names sorted, each one's origins in file order then
77
+ line, `byForm` counting the origins of each form; the same skill
78
+ gives the same bytes.
79
+
21
80
  metadata the metadata.json and provenance-map counts. Cluster A
22
81
  (`stats.exports_public_api`, the `exports[]` length) and Cluster
23
82
  B (`stats.exports_documented`, the provenance entry names, the
@@ -39,14 +98,25 @@ reading files and writing one JSON object:
39
98
  surface the enumerated source surface, from every input given (their
40
99
  union):
41
100
  --extraction skf-extract-public-api.py --mode full output:
42
- the names in entry_point_diff.public, a
101
+ the names in entry_point_diff.public, each
102
+ other in-scope definition of one
103
+ (entry_point_diff.other_definitions, #830) a
104
+ record of its own at its file, a
43
105
  namespace with a `members` list given as its
44
106
  members (the names its module passes on, each
45
107
  at its file, once with a public name of the
46
108
  same name and file), less the outside_scope
47
- names (defined in a file the brief scopes
48
- out); exports[] only gives each name's
49
- kind, line and signature line, looked up by the
109
+ names (defined in a file out of scope), which
110
+ `excluded.outsideScope` lists with their file,
111
+ line and entry point and, when the brief
112
+ scopes the file out, why: the scope.exclude
113
+ glob that matches it (`excludeGlob`) or no
114
+ scope.include glob covering it
115
+ (`outsideInclude`); exports[] only gives each name's
116
+ kind, line and signature line (a local export
117
+ list's item: its binding's declaration_line and
118
+ declaration_signature_line, when the extractor
119
+ records them), looked up by the
50
120
  name its defining file gives it (an aliased
51
121
  re-export's `local`) and that file.
52
122
  extraction_gaps are listed to read by eye, and
@@ -112,10 +182,11 @@ reading files and writing one JSON object:
112
182
  Name sets (surface `sets`, each sorted): `all` (the union above, less a
113
183
  nested Python top's names);
114
184
  `scope.include` and `tier_a_include` when the scope has those globs and a
115
- record names a file: the names whose file (an extraction's definition file,
185
+ record names a file: the names with a file (any of the name's records: an
186
+ extraction's definition file, each other in-scope definition's included,
116
187
  the quick output's defined_in, else its source_file, a per-file result's
117
- file, a provenance entry's source_file) matches a glob of the list and no
118
- scope.exclude glob,
188
+ file, a provenance entry's source_file) that matches a glob of the list
189
+ and no scope.exclude glob,
119
190
  and for `tier_a_include` a scope.include glob too when the scope has one,
120
191
  so a name with no file is in `all` only and a restored name is in no
121
192
  tier_a_include set (a tier_a_include glob an extraction lists as matching
@@ -127,29 +198,45 @@ exclude of `src/gen/**`; a list that counts no name, each glob left out
127
198
  or matching only excluded files, files scope.include leaves out or files
128
199
  no record names, gives no tier_a_include set); and, from an
129
200
  extraction only, `subpaths` (the names a non-root `exports` map subpath
130
- without `*` reaches) and `root` (the names the root entry points reach).
201
+ without `*` reaches) and `root` (the names the root entry points reach),
202
+ each through the entry point of any of the name's definitions.
131
203
  reconcile-coverage.py and score-signatures.py --surface-set pick one.
132
204
 
133
205
  Candidates and guards (with an extraction, --brief or --metadata): each
134
206
  set's count, the metadata `stats.effective_denominator` (--metadata), and
135
- the extractor's own effective_denominator and its basis. Both guards run
136
- only on a surface read from the source. The deflation guard compares the
137
- re-derived scope.include set (the `all` set without one) with
138
- `stats.effective_denominator` and fires above DEFLATION_PCT when there is
139
- no tier A list (with an extraction or --fallback-scan, no tier_a_include
140
- set; otherwise no tier_a_include glob left in the list, since a name read
141
- by eye at Quick tier or State 4, and a quick name with no defined_in,
142
- carries its entry file, not the file that defines it); a stored 0 is
143
- deflation, not a
144
- denominator (`pct` null), and fires under that condition when any name is
145
- re-derived. The inflation guard compares the scope.include set with the
146
- provenance entry count (--provenance) and fires above INFLATION_PCT under
147
- the same condition; `guards.tierAInclude` lists the scope's
207
+ the extractor's own effective_denominator and its basis; for the
208
+ Denominator annotations, `tierAIncludeFiles` and `scopeIncludeFiles` (the
209
+ distinct files the set's names sit at: the files that define them when
210
+ each name sits at its defining file) and `subpathEntries` (an extraction's
211
+ distinct in-scope non-root, non-wildcard subpaths, each package's subpath
212
+ once however many files it names), each null when its set is absent.
213
+ Both guards run
214
+ only on a surface read from the source. The deflation guard compares a
215
+ re-derived set with `stats.effective_denominator` and names it in `basis`.
216
+ It is the tier_a_include set (`tier_a_include`) when the clause's ladder
217
+ holds a stored count (stratified-scope, specific-modules, multi-entry), the
218
+ surface has that set and each name sits at the file that defines it (a
219
+ runner extraction, --fallback-scan, or quick output whose every name
220
+ carries its defined_in, with no --per-file or --name beside it): it fires
221
+ when the stored count differs from the set by more than DEFLATION_PCT
222
+ either way, since Create Skill stores the runner's tier A count. Otherwise
223
+ it is the scope.include set (`scope.include`, else `all` when the surface
224
+ has no scope.include set), firing above DEFLATION_PCT when there is no tier
225
+ A list (with an extraction or --fallback-scan, no tier_a_include set;
226
+ otherwise no tier_a_include glob left in the list, since a name read by eye
227
+ at Quick tier or State 4, and a quick name with no defined_in, carries its
228
+ entry file, not the file that defines it). A stored 0 is deflation, not a
229
+ denominator (`pct` null), and fires under its basis's condition when any
230
+ name is re-derived. The inflation guard compares the scope.include set
231
+ with the provenance entry count (--provenance) and fires above
232
+ INFLATION_PCT when there is no tier A list, as the scope.include basis
233
+ reads it; `guards.tierAInclude` lists the scope's
148
234
  tier_a_include globs ([] when it has none) for gap-ledger.py's wording;
149
235
  the umbrella ratio (an
150
236
  extraction only) is the share of the root entry points' names that are
151
237
  re-exports (a namespace with `members` counting as its members there
152
- too, each a re-export), and above UMBRELLA_RATIO the barrel is an
238
+ too, each a re-export, and an other in-scope definition left out), and
239
+ above UMBRELLA_RATIO the barrel is an
153
240
  umbrella; the
154
241
  stale-scope guard (an extraction with scope.include or tier_a_include only)
155
242
  fires when the runner lists an include or tier_a_include glob that matches
@@ -162,7 +249,14 @@ included, and `*` and `?` stay inside one.
162
249
  Denominator (surface `denominator`): the Source API Surface Definition's
163
250
  clause (source-access-protocol.md), the branch reconcile-coverage.py takes
164
251
  and the set it and score-signatures.py --surface-set read: {clause, branch,
165
- surfaceSet, ladderStep, absentSet, rebuild, barrel}. The inputs: the scope
252
+ surfaceSet, ladderStep, absentSet, rebuild, barrel, setCounted}.
253
+ `setCounted` (null off the scalar branch) says whether the scalar branch
254
+ counts its `surfaceSet` as the numerator's set: true only for a scope set
255
+ (not `all`) whose every name sits at the file that defines it, as the
256
+ deflation guard's tier A basis requires (a runner extraction, the fallback
257
+ scan, quick output whose every name carries its defined_in), read whole (an
258
+ extraction neither `incomplete` nor truncated); otherwise the scalar branch
259
+ keeps its looked-up numerator. The inputs: the scope
166
260
  type (the brief's, else the extraction's, else metadata.json's
167
261
  `scope_type`), metadata.json's `skill_type` and `stats.effective_denominator`,
168
262
  the extraction's `arms`, the deflation guard, and:
@@ -200,7 +294,13 @@ The last three walk a ladder, `ladderStep` naming the rung taken: rung 1,
200
294
  `stats.effective_denominator` when metadata.json has one above 0 (a stored
201
295
  0 is no denominator), is the `scalar` branch, whose `surfaceSet` (the set score-signatures.py scores) is
202
296
  tier_a_include, else scope.include, else all, unless the deflation guard
203
- fires: then the barrel branch over scope.include. The next rungs are sets,
297
+ fires: then the barrel branch over tier_a_include when the ladder has that
298
+ rung (stratified-scope, multi-entry) and the surface has the set, else over
299
+ scope.include (when the inflation guard fires too, that union is itself
300
+ inflated, and both gaps ask for the brief's tier A list, never a stored
301
+ count set by hand). On an incomplete extraction, which can miss tier A
302
+ names, a tier A set below the stored count fires the guard and leaves the
303
+ scalar branch. The next rungs are sets,
204
304
  each taken when the surface has it, the last taken in any case:
205
305
  tier_a_include then scope.include (stratified-scope), scope.include
206
306
  (specific-modules), tier_a_include then subpaths (multi-entry). A set the
@@ -211,12 +311,17 @@ found no ast-grep (`no-ast-grep`), read no file in scope, read files only
211
311
  in languages it has no recipe for (`files_without_recipes` and no export),
212
312
  or found files no recipe reads while the skill's language (the --brief
213
313
  `language`, else the metadata.json `language`) is not one RECIPE_LANGUAGES
214
- holds, so the per-file scan has to run instead. An `incomplete` run keeps
215
- its exports; `extraction.readByEye` lists the files its errors and file
216
- issues name.
314
+ holds, so the per-file scan has to run instead. A run keeps its exports
315
+ whatever its status; `extraction.readByEye` lists the files to read by eye,
316
+ at any status: each file the runner could not parse or decode (a
317
+ `syntax-errors` or `not-utf8` file issue) and, on an `incomplete` run, each
318
+ file a failed call names. `extraction.unread` lists the files nobody can
319
+ read (a `missing` or `unreadable` file issue); a `no-recipes` file, such as
320
+ a stylesheet, holds nothing to read and is in neither list.
217
321
 
218
322
  Usage:
219
323
  uv run load-coverage-inputs.py census --skill-dir <dir> [--output <file>]
324
+ uv run load-coverage-inputs.py floor --skill-dir <dir> [--output <file>]
220
325
  uv run load-coverage-inputs.py metadata --metadata <metadata.json> \\
221
326
  [--provenance <provenance-map.json>] [--output <file>]
222
327
  uv run load-coverage-inputs.py surface [--extraction <file>] [--quick <file>]... \\
@@ -228,7 +333,8 @@ Usage:
228
333
  Exit codes:
229
334
  0 the result was printed (and written to --output)
230
335
  1 an input file is missing, unreadable or not the JSON (or the YAML
231
- brief) it should be (one line on stderr)
336
+ brief) it should be, or the --skill-dir holds no SKILL.md or a skill
337
+ file that is not UTF-8 (one line on stderr)
232
338
  2 a per-file result breaks the schema: {"valid": false, "violations":
233
339
  [...]} names each breach and nothing is written to --output; or a
234
340
  usage error (argparse)
@@ -258,6 +364,40 @@ CITATION_FORMS = {
258
364
  "DOC": "[DOC:",
259
365
  }
260
366
  LOCAL_FORMS = ("AST", "SRC", "from skill")
367
+ # floor: the forms a documented name takes, in the order an origin on one line sorts by.
368
+ FLOOR_FORMS = ("table-row", "heading", "declaration")
369
+ # The first header cells (lower case, singular or plural) of a table whose first column names exports.
370
+ FLOOR_TABLE_HEADERS = frozenset({
371
+ "export", "exports", "symbol", "symbols", "function", "functions", "class", "classes", "interface", "interfaces",
372
+ "type", "types", "hook", "hooks", "component", "components", "helper", "helpers", "constant", "constants",
373
+ "enum", "enums", "api", "apis"})
374
+ # A header cell wrapped in `**` or `__` (group 2 its text).
375
+ EMPHASIS_RE = re.compile(r"(\*\*|__)(.+)\1")
376
+ PLAIN_IDENTIFIER_RE = re.compile(r"[A-Za-z_$][\w$]*")
377
+ TABLE_CELL_RE = re.compile(r"(?<!\\)\|")
378
+ # A comment's opening marker: `//`, `#`, `/*`, `--`, `<!--` or `;`.
379
+ COMMENT_MARKER_RE = re.compile(r"[ \t]*(?://+|#+|/\*+|--|<!--|;+)")
380
+ # A cited path's line suffix: `:12`, `:L12`, `:12-30` or `:L12-L30`.
381
+ LINE_SUFFIX_RE = re.compile(r":L?\d+(?:-L?\d+)?$")
382
+ PYTHON_LANGUAGES = frozenset({"python", "py", "python3"})
383
+ RUST_LANGUAGES = frozenset({"rust", "rs"})
384
+ GO_LANGUAGES = frozenset({"go", "golang"})
385
+ # Column-0 public declarations (group 1 the name), matched on the block with comments and strings masked.
386
+ JS_DECLARATION_RE = re.compile(
387
+ r"export\s+(?:default\s+(?:abstract\s+)?(?:async\s+)?(?:function(?:\s*\*\s*|\s+)|class\s+)"
388
+ r"|(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?"
389
+ r"(?:function(?:\s*\*\s*|\s+)|const\s+enum\s+|const\s+|let\s+|var\s+|class\s+|interface\s+|type\s+|enum\s+))"
390
+ r"(?!(?:extends|implements)(?![\w$]))([A-Za-z_$][\w$]*)")
391
+ PYTHON_DECLARATION_RE = re.compile(r"(?:async\s+def|def|class)\s+([A-Za-z_]\w*)")
392
+ RUST_DECLARATION_RE = re.compile(
393
+ r"pub\s+(?:(?:async|unsafe|const|extern)\s+)*(?:fn|struct|enum|trait|type|const|static)\s+(?:mut\s+)?"
394
+ r"([A-Za-z_]\w*)")
395
+ RUST_MACRO_EXPORT_RE = re.compile(r"#\[macro_export\]")
396
+ RUST_MACRO_RE = re.compile(r"(?:#\[[^\]]*\][ \t]*)*macro_rules![ \t]*([A-Za-z_]\w*)")
397
+ # Go: a single declaration (group 1 its keyword, group 2 its names) or a group's opening line (group 1).
398
+ GO_DECLARATION_RE = re.compile(r"(func|type|var|const)[ \t]+([A-Za-z_]\w*(?:[ \t]*,[ \t]*[A-Za-z_]\w*)*)")
399
+ GO_GROUP_RE = re.compile(r"(type|var|const)[ \t]*\([ \t]*$")
400
+ GO_GROUP_NAMES_RE = re.compile(r"[ \t]+([A-Za-z_]\w*(?:[ \t]*,[ \t]*[A-Za-z_]\w*)*)")
261
401
  # Bookkeeping variants folded to their base name, in rule order.
262
402
  FOLD_SUFFIXES = ("_def", "_exact")
263
403
  FOLD_PREFIX = "a11y_"
@@ -298,7 +438,9 @@ SHARED_SCRIPTS = Path(__file__).resolve().parent.parent.parent / "shared" / "scr
298
438
 
299
439
  def _sibling(filename: str, folder: Path | None = None):
300
440
  """A script beside this one, loaded once: validate-inventory.py (the doc
301
- text) and score-signatures.py (the per-file result schema); or, with
441
+ text, and the skill citations floor reads), locate-export-segments.py
442
+ (the line model floor reads the skill with) and score-signatures.py (the
443
+ per-file result schema); or, with
302
444
  `folder`, one in that folder: SHARED_SCRIPTS'
303
445
  skf-resolve-authoritative-files.py (a skill brief's YAML)."""
304
446
  module = _SIBLINGS.get(filename)
@@ -385,6 +527,199 @@ def census(skill_dir: str) -> dict:
385
527
  }
386
528
 
387
529
 
530
+ # --------------------------------------------------------------------------
531
+ # floor
532
+ # --------------------------------------------------------------------------
533
+
534
+
535
+ def _floor_name(text: str) -> str | None:
536
+ """The plain identifier `text` writes once a leading `new ` and everything from the first `(` or `<` are
537
+ cut; None for any other text, a dotted or `::` name among them."""
538
+ text = text.strip()
539
+ if text.startswith("new "):
540
+ text = text[4:]
541
+ text = re.split(r"[(<]", text, maxsplit=1)[0].strip()
542
+ return text if PLAIN_IDENTIFIER_RE.fullmatch(text) else None
543
+
544
+
545
+ def _header_word(cell: str) -> str:
546
+ """A header cell's word, lower case, unwrapped from `**` or `__`."""
547
+ m = EMPHASIS_RE.fullmatch(cell)
548
+ return (m.group(2) if m else cell).strip().lower()
549
+
550
+
551
+ def _cells(row: str) -> list[str]:
552
+ """A table row's cells, split on each unescaped `|`, each stripped."""
553
+ row = row.strip()
554
+ if row.startswith("|"):
555
+ row = row[1:]
556
+ if row.endswith("|") and not row.endswith("\\|"):
557
+ row = row[:-1]
558
+ return [cell.strip() for cell in TABLE_CELL_RE.split(row)]
559
+
560
+
561
+ def _whole_span(text: str, segments) -> str | None:
562
+ """The code span `text` is, when the whole of it is one; else None."""
563
+ spans = segments._inline_code(text)
564
+ if len(spans) != 1:
565
+ return None
566
+ run = len(text) - len(text.lstrip("`"))
567
+ return spans[0] if text == "`" * run + spans[0] + "`" * run else None
568
+
569
+
570
+ def _comment_text(line: str) -> str | None:
571
+ """A comment line's text, its markers stripped (`// x`, `# x`, `/* x */`, `-- x`, `<!-- x -->`, `; x`);
572
+ None for a line that is no comment. Linear in the line's length."""
573
+ m = COMMENT_MARKER_RE.match(line)
574
+ if not m:
575
+ return None
576
+ text = line[m.end():].strip()
577
+ if text.endswith("-->"):
578
+ text = text[:-3]
579
+ elif text.endswith("*/"):
580
+ text = text[:-2].rstrip("*")
581
+ return text.strip()
582
+
583
+
584
+ def _headed(lines: list[str], opening: int, closing: int, cited: set[str], inventory) -> bool:
585
+ """Whether a source citation heads the fenced block opening at `opening`: its first non-blank line is a
586
+ comment holding a citation, or naming a path the skill cites (a `:12`, `:L12` or `:12-30` suffix left
587
+ out). A citation on the line above the fence heads nothing."""
588
+ first = next((lines[i] for i in range(opening + 1, closing) if lines[i].strip()), None)
589
+ text = _comment_text(first) if first is not None else None
590
+ if not text:
591
+ return False
592
+ if inventory._SKILL_CITATION_RE.search(text):
593
+ return True
594
+ suffix = LINE_SUFFIX_RE.search(text)
595
+ path = text[:suffix.start()] if suffix else text
596
+ return bool(path) and not any(c.isspace() for c in path) and inventory._cited_path(path) in cited
597
+
598
+
599
+ def _html_comment_lines(lines: list[str], start: int, code: list[bool]) -> set[int]:
600
+ """The lines inside an HTML comment, the fenced lines it holds included, read as _prose reads one: a line
601
+ outside fenced code that starts `<!--` opens it, the next such line holding `-->` closes it."""
602
+ inside, open_ = set(), False
603
+ for i in range(start, len(lines)):
604
+ if open_:
605
+ inside.add(i)
606
+ open_ = code[i] or "-->" not in lines[i]
607
+ elif not code[i] and lines[i].strip().startswith("<!--"):
608
+ inside.add(i)
609
+ open_ = "-->" not in lines[i].strip()[4:]
610
+ return inside
611
+
612
+
613
+ def _names(text: str) -> list[str]:
614
+ return [name.strip() for name in text.split(",")]
615
+
616
+
617
+ def _declared_names(lines: list[str], language: str, js_family) -> list[tuple[int, str]]:
618
+ """(offset, name) of each column-0 public declaration in a block's masked lines, by its language."""
619
+ found: list[tuple[int, str]] = []
620
+ if language in js_family:
621
+ found = [(i, m.group(1)) for i, line in enumerate(lines) if (m := JS_DECLARATION_RE.match(line))]
622
+ elif language in PYTHON_LANGUAGES:
623
+ found = [(i, m.group(1)) for i, line in enumerate(lines)
624
+ if (m := PYTHON_DECLARATION_RE.match(line)) and not m.group(1).startswith("_")]
625
+ elif language in RUST_LANGUAGES:
626
+ exported = False # a `#[macro_export]` above, with only blank or attribute lines between
627
+ for i, line in enumerate(lines):
628
+ if m := RUST_DECLARATION_RE.match(line):
629
+ found.append((i, m.group(1)))
630
+ exported = exported or bool(RUST_MACRO_EXPORT_RE.match(line))
631
+ macro = RUST_MACRO_RE.match(line)
632
+ if macro and exported:
633
+ found.append((i, macro.group(1)))
634
+ if macro or (line.strip() and not line.startswith("#[")):
635
+ exported = False
636
+ elif language in GO_LANGUAGES:
637
+ group, depth = False, 0 # inside a `type (`, `var (` or `const (` group, and its bracket depth there
638
+ for i, line in enumerate(lines):
639
+ if group:
640
+ if depth == 0 and line.lstrip().startswith(")"):
641
+ group = False
642
+ continue
643
+ if depth == 0 and (m := GO_GROUP_NAMES_RE.match(line)):
644
+ found += [(i, name) for name in _names(m.group(1)) if name[0].isupper()]
645
+ depth = max(0, depth + sum(map(line.count, "{([")) - sum(map(line.count, "})]")))
646
+ elif GO_GROUP_RE.match(line):
647
+ group, depth = True, 0
648
+ elif m := GO_DECLARATION_RE.match(line):
649
+ found += [(i, name) for name in _names(m.group(2)) if name[0].isupper()]
650
+ return found
651
+
652
+
653
+ def floor(skill_dir: str) -> dict:
654
+ """The names the skill documents in a fixed form, each with its origins (see the module docstring)."""
655
+ root = Path(skill_dir)
656
+ if not (root / "SKILL.md").is_file():
657
+ raise InputError(f"--skill-dir {skill_dir} holds no SKILL.md")
658
+ segments = _sibling("locate-export-segments.py")
659
+ inventory = _sibling("validate-inventory.py")
660
+ refs = root / "references"
661
+ files = ["SKILL.md"] + ([f"references/{p.name}" for p in sorted(refs.glob("*.md")) if p.is_file()]
662
+ if refs.is_dir() else [])
663
+ texts = []
664
+ for rel in files:
665
+ try:
666
+ texts.append(segments._split_lines((root / rel).read_bytes().decode("utf-8-sig")))
667
+ except (OSError, UnicodeDecodeError) as exc:
668
+ raise InputError(f"cannot read {rel} under {skill_dir}: {exc}") from exc
669
+ cited = {inventory._cited_path(m.group(2))
670
+ for lines in texts for line in lines for m in inventory._SKILL_CITATION_RE.finditer(line)}
671
+
672
+ found: dict[str, set[tuple[int, int, str, str | None]]] = {}
673
+
674
+ def add(name: str | None, file_index: int, index: int, form: str, label: str | None = None) -> None:
675
+ if name:
676
+ found.setdefault(name, set()).add((file_index, index + 1, form, label))
677
+
678
+ js_family = segments.JS_FAMILY - {"svelte"} # a Svelte block's `export let` is a component prop
679
+ for file_index, lines in enumerate(texts):
680
+ start = segments._frontmatter_end(lines)
681
+ blocks, code = segments._fenced_blocks(lines, start)
682
+ headings, prose = segments._prose(lines, start, code)
683
+ for block in prose:
684
+ if block["kind"] != "table-row" or block["header"] is None:
685
+ continue
686
+ header = _cells(block["header"])
687
+ if _header_word(header[0]) not in FLOOR_TABLE_HEADERS:
688
+ continue
689
+ row = _cells(lines[block["start"]])
690
+ kind_at = next((i for i, cell in enumerate(header) if _header_word(cell) == "kind"), None)
691
+ label = row[kind_at] if kind_at is not None and kind_at < len(row) and row[kind_at] else None
692
+ for span in segments._inline_code(row[0]):
693
+ add(_floor_name(span), file_index, block["start"], "table-row", label)
694
+ for index, _level, text, _above in headings:
695
+ span = _whole_span(text, segments)
696
+ if span is not None:
697
+ add(_floor_name(span), file_index, index, "heading")
698
+ comments = _html_comment_lines(lines, start, code)
699
+ for opening, closing, info in blocks:
700
+ language = segments._language(info)
701
+ if (opening in comments or language in segments.DATA_LANGUAGES
702
+ or not _headed(lines, opening, closing, cited, inventory)):
703
+ continue
704
+ masked = segments.mask_code("\n".join(lines[opening + 1:closing]), language).split("\n")
705
+ for offset, name in _declared_names(masked, language, js_family):
706
+ add(name, file_index, opening + 1 + offset, "declaration")
707
+
708
+ names = []
709
+ by_form = dict.fromkeys(FLOOR_FORMS, 0)
710
+ for name in sorted(found):
711
+ origins = sorted(found[name], key=lambda o: (o[0], o[1], FLOOR_FORMS.index(o[2]), o[3] or ""))
712
+ seen, kept = set(), []
713
+ for file_index, line, form, _label in origins:
714
+ if (file_index, line, form) not in seen:
715
+ seen.add((file_index, line, form))
716
+ kept.append({"file": files[file_index], "line": line, "form": form})
717
+ by_form[form] += 1
718
+ label = next((o[3] for o in origins if o[2] == "table-row" and o[3]), None)
719
+ names.append({"name": name, "kindLabel": label, "origins": kept})
720
+ return {"files": files, "names": names, "summary": {"names": len(names), "byForm": by_form}}
721
+
722
+
388
723
  # --------------------------------------------------------------------------
389
724
  # metadata
390
725
  # --------------------------------------------------------------------------
@@ -541,6 +876,14 @@ def matching(records: list[dict], globs: list[str], excludes: list[str]) -> list
541
876
  and not any(glob_match(_norm(r["file"]), g) for g in excludes)})
542
877
 
543
878
 
879
+ def matching_files(records: list[dict], globs: list[str], excludes: list[str]) -> list[str]:
880
+ """The distinct files `matching` takes its names from: each record's file one of `globs` matches and no
881
+ exclude glob does."""
882
+ return sorted({r["file"] for r in records if r.get("file")
883
+ and any(glob_match(_norm(r["file"]), g) for g in globs)
884
+ and not any(glob_match(_norm(r["file"]), g) for g in excludes)})
885
+
886
+
544
887
  def _apart(a: str, b: str) -> bool:
545
888
  """True when, before either has a `*` or `?`, the two globs spell a different segment at the same
546
889
  place, so no file can match both. Any other pair counts as overlapping, even one no file can match
@@ -607,9 +950,14 @@ def _members_in_place(public: list[dict]) -> list[dict]:
607
950
  return expanded
608
951
 
609
952
 
953
+ # The runner's file issues for a file it could not open at all: no one can read it by eye either.
954
+ UNREADABLE_ISSUES = frozenset({"missing", "unreadable"})
955
+
956
+
610
957
  def _extraction_summary(data: dict, language: str | None) -> dict:
611
958
  """The surface's `extraction` object: the runner's status and counts,
612
- the files to read by eye and the fallback verdict."""
959
+ the files to read by eye, the files no one can read and the fallback
960
+ verdict."""
613
961
  counts = data.get("counts") if isinstance(data.get("counts"), dict) else {}
614
962
  arms = data.get("arms") if isinstance(data.get("arms"), dict) else {}
615
963
  entry_points = data.get("entry_points") if isinstance(data.get("entry_points"), dict) else {}
@@ -626,8 +974,11 @@ def _extraction_summary(data: dict, language: str | None) -> dict:
626
974
  reason = (f"no recipe reads the skill's language, {language} "
627
975
  f"({', '.join(sorted(without))} in scope)")
628
976
  errors = [e for e in data.get("errors") or [] if isinstance(e, dict)]
629
- read_by_eye = sorted({str(i["file"]) for i in data.get("file_issues") or []
630
- if isinstance(i, dict) and i.get("file") and i.get("issue") != "no-recipes"}
977
+ issues = [i for i in data.get("file_issues") or [] if isinstance(i, dict) and i.get("file")]
978
+ # A file missing from disk or one the runner could not open is no file to read by eye.
979
+ unread = sorted({str(i["file"]) for i in issues if i.get("issue") in UNREADABLE_ISSUES})
980
+ read_by_eye = sorted({str(i["file"]) for i in issues
981
+ if i.get("issue") != "no-recipes" and i.get("issue") not in UNREADABLE_ISSUES}
631
982
  | {str(e["first_file"]) for e in errors if e.get("first_file")}
632
983
  | {str(f) for e in errors if isinstance(e.get("unread"), list) for f in e["unread"] if f})
633
984
  return {
@@ -644,6 +995,7 @@ def _extraction_summary(data: dict, language: str | None) -> dict:
644
995
  "entryPointsFound": (bool(entry_points["files"]) if isinstance(entry_points.get("files"), list)
645
996
  and data.get("files_in_scope") else None),
646
997
  "readByEye": read_by_eye,
998
+ "unread": unread,
647
999
  "fallback": {"needed": reason is not None, "reason": reason},
648
1000
  }
649
1001
 
@@ -651,6 +1003,21 @@ def _extraction_summary(data: dict, language: str | None) -> dict:
651
1003
  NO_UMBRELLA = {"entries": [], "names": 0, "reexported": 0, "ratio": None, "umbrella": False}
652
1004
 
653
1005
 
1006
+ def _outside_row(row: dict, scope: dict) -> dict:
1007
+ """An outside_scope row as `excluded.outsideScope` lists it: its name,
1008
+ file, line and entry point, and why the brief scopes the file out when
1009
+ it does (#796): `excludeGlob`, the first scope.exclude glob that matches
1010
+ the file, and `outsideInclude`, true when scope.include has globs and
1011
+ none matches it. Neither (null and false) when the brief does not
1012
+ explain it, such as a file no recipe reads or no file at all."""
1013
+ file = row.get("file") if isinstance(row.get("file"), str) and row.get("file") else None
1014
+ path = _norm(file) if file else None
1015
+ return {"name": row.get("name"), "file": file, "line": row.get("line"), "entry": row.get("entry"),
1016
+ "excludeGlob": next((g for g in scope["exclude"] if glob_match(path, g)), None) if path else None,
1017
+ "outsideInclude": bool(path and scope["include"]
1018
+ and not any(glob_match(path, g) for g in scope["include"]))}
1019
+
1020
+
654
1021
  def from_extraction(data: dict, language: str | None = None, brief: dict | None = None) -> dict:
655
1022
  """The public surface of an extraction, less what lies outside scope,
656
1023
  plus the root exports a stale scope.include glob left out. A nested
@@ -675,11 +1042,15 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
675
1042
  "staleScope": {"unmatchedInclude": unmatched, "unmatchedTierAInclude": unmatched_tier_a,
676
1043
  "restored": []},
677
1044
  "gaps": [], "scope": scope, "scopeType": recorded.get("type"), "umbrella": dict(NO_UMBRELLA),
678
- "extraction": extraction}
1045
+ "subpathEntries": None, "extraction": extraction}
679
1046
 
680
1047
  diff = data.get("entry_point_diff") if isinstance(data.get("entry_point_diff"), dict) else {}
681
- public = _members_in_place([p for p in diff.get("public") or []
682
- if isinstance(p, dict) and isinstance(p.get("name"), str)])
1048
+ # Each in-scope definition of a public name is a record of its own (#830): the runner's other
1049
+ # definitions join the public rows, so a set holds a name when any of its files matches, and
1050
+ # the signature plan reads every declaration.
1051
+ rows = {key: [p for p in diff.get(key) or [] if isinstance(p, dict) and isinstance(p.get("name"), str)]
1052
+ for key in ("public", "other_definitions")}
1053
+ public = _members_in_place(rows["public"] + rows["other_definitions"])
683
1054
  outside = [o for o in diff.get("outside_scope") or [] if isinstance(o, dict)]
684
1055
  gaps = [g for g in diff.get("extraction_gaps") or [] if isinstance(g, dict)]
685
1056
 
@@ -690,12 +1061,17 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
690
1061
  # records (so the scope sets a specific-modules or stratified brief
691
1062
  # counts) and their outside_scope and extraction_gaps rows.
692
1063
  nested = _nested_tops(entry_files, set(scope["include"]) | set(scope["tier_a_include"]),
693
- {p.get("entry") for p in public})
1064
+ {p.get("entry") for p in rows["public"]})
694
1065
  nested_names = {top: sorted({p["name"] for p in public if p.get("entry") == top}) for top in nested}
695
1066
 
696
1067
  subpath_files = {e.get("file") for e in entry_files
697
1068
  if e.get("subpath") not in ROOT_SUBPATHS and "*" not in str(e.get("subpath"))}
698
1069
  root_files = {e.get("file") for e in entry_files if e.get("subpath") in ROOT_SUBPATHS} - set(nested)
1070
+ # The distinct in-scope subpaths of those (a package's subpath once, however many files its conditions
1071
+ # name), for the multi-entry annotation.
1072
+ subpath_entries = {(e.get("package"), e.get("subpath")) for e in entry_files
1073
+ if e.get("subpath") not in ROOT_SUBPATHS and "*" not in str(e.get("subpath"))
1074
+ and e.get("in_scope") is not False}
699
1075
 
700
1076
  # A scope.include glob that matches no file: the brief predates the
701
1077
  # source, so a root export defined in a file no glob covers comes back,
@@ -726,25 +1102,32 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
726
1102
  # under the name its defining file gives it, `local`.
727
1103
  found = index.get((info.get("local") or name, file)) or index.get((name, file))
728
1104
  kind = found.get("export_type") if found else None
1105
+ # A local `export { ... }` list's item is planned at the declaration it
1106
+ # names (#836): the line and the signature line both come from that pair.
1107
+ if found and isinstance(found.get("declaration_line"), int):
1108
+ line, signature_line = found["declaration_line"], found.get("declaration_signature_line")
1109
+ elif found:
1110
+ line, signature_line = found.get("source_line"), found.get("signature_line")
1111
+ else:
1112
+ line, signature_line = info.get("line"), None
729
1113
  (nested_records if info.get("entry") in nested else records).append(
730
- _record(name, None if kind == "re-export" else kind, file,
731
- found.get("source_line") if found else info.get("line"),
732
- found.get("signature_line") if found else None, "extraction"))
1114
+ _record(name, None if kind == "re-export" else kind, file, line, signature_line, "extraction"))
733
1115
 
734
- root_names = [p for p in public if p.get("entry") in root_files]
1116
+ # The umbrella ratio reads the public rows alone: an other definition is no root barrel name there.
1117
+ root_names = [p for p in _members_in_place(rows["public"]) if p.get("entry") in root_files]
735
1118
  reexported = [p for p in root_names if p.get("via") not in (None, "declaration")]
736
1119
 
737
1120
  sets = {}
738
1121
  if subpath_files:
739
1122
  sets["subpaths"] = sorted({p["name"] for p in public if p.get("entry") in subpath_files})
740
1123
  if root_files:
741
- sets["root"] = sorted({p["name"] for p in root_names})
1124
+ sets["root"] = sorted({p["name"] for p in public if p.get("entry") in root_files})
742
1125
 
743
1126
  return {
744
1127
  "records": records,
745
1128
  "nestedRecords": nested_records,
746
1129
  "sets": sets,
747
- "outside": [{"name": o.get("name"), "file": o.get("file")} for o in outside],
1130
+ "outside": [_outside_row(o, scope) for o in outside],
748
1131
  "nested": [{"entry": top, "within": nested[top], "names": nested_names[top]} for top in sorted(nested)],
749
1132
  "staleScope": {"unmatchedInclude": unmatched, "unmatchedTierAInclude": unmatched_tier_a,
750
1133
  "restored": [{"name": o["name"], "file": o["file"], "line": o.get("line")}
@@ -759,6 +1142,8 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
759
1142
  "ratio": round2(len(reexported) / len(root_names)) if root_names else None,
760
1143
  "umbrella": bool(root_names) and len(reexported) / len(root_names) > UMBRELLA_RATIO,
761
1144
  },
1145
+ # The in-scope subpaths the `subpaths` set reads (None when there is no such set).
1146
+ "subpathEntries": len(subpath_entries) if subpath_files else None,
762
1147
  "extraction": extraction,
763
1148
  }
764
1149
 
@@ -828,12 +1213,16 @@ def _over(clause: str, sets: dict, name: str, step: int | None, rebuild: str | N
828
1213
 
829
1214
  def pick_denominator(sets: dict, stats_eff: int | None, deflation_fires: bool, barrel: str | None, arms: dict,
830
1215
  scope_type: str | None, skill_type: str | None, monorepo: bool, pattern_reference: bool,
831
- curated: bool, source_read: bool) -> dict:
1216
+ curated: bool, source_read: bool, set_trusted: bool = False) -> dict:
832
1217
  """The Source API Surface Definition's clause for a skill, with the branch
833
- and the name set reconcile-coverage.py and score-signatures.py read, and
834
- the entry points' state it was picked by (see "Denominator" above)."""
835
- return {**_pick(sets, stats_eff, deflation_fires, barrel, arms, scope_type, skill_type, monorepo,
836
- pattern_reference, curated, source_read), "barrel": barrel}
1218
+ and the name set reconcile-coverage.py and score-signatures.py read, the
1219
+ entry points' state it was picked by and, on the scalar branch, whether
1220
+ it counts that set (`set_trusted`: each name at its defining file, read
1221
+ whole; see "Denominator" above)."""
1222
+ picked = _pick(sets, stats_eff, deflation_fires, barrel, arms, scope_type, skill_type, monorepo,
1223
+ pattern_reference, curated, source_read)
1224
+ counted = (set_trusted and picked["surfaceSet"] not in (None, "all")) if picked["branch"] == "scalar" else None
1225
+ return {**picked, "barrel": barrel, "setCounted": counted}
837
1226
 
838
1227
 
839
1228
  def _pick(sets: dict, stats_eff: int | None, deflation_fires: bool, barrel: str | None, arms: dict,
@@ -868,7 +1257,9 @@ def _pick(sets: dict, stats_eff: int | None, deflation_fires: bool, barrel: str
868
1257
  elif stats_eff:
869
1258
  # A stored 0 is no denominator: the next rung.
870
1259
  if deflation_fires:
871
- return _over(clause, sets, "scope.include", step)
1260
+ # The ladder's tier A rung when it has one and the surface has the set, else scope.include.
1261
+ rung = "tier_a_include" if "tier_a_include" in ladder and "tier_a_include" in sets else "scope.include"
1262
+ return _over(clause, sets, rung, step)
872
1263
  scored = next((name for name in ("tier_a_include", "scope.include") if name in sets), "all")
873
1264
  return {"clause": clause, "branch": "scalar", "surfaceSet": scored, "ladderStep": step,
874
1265
  "absentSet": None, "rebuild": None}
@@ -994,13 +1385,56 @@ def surface(args: argparse.Namespace) -> dict:
994
1385
  and not (scope["include"] and all(_apart(g, i) for i in scope["include"]))
995
1386
  and not any(_inside(g, e) for e in scope["exclude"])]
996
1387
  filed = [r for r in records if r["file"]]
1388
+ # The files each scope set's names sit at, for the Denominator annotations' file counts.
1389
+ set_files: dict[str, list[str]] = {}
997
1390
  if filed and scope["include"]:
998
1391
  sets["scope.include"] = matching(filed, scope["include"], scope["exclude"])
1392
+ set_files["scope.include"] = matching_files(filed, scope["include"], scope["exclude"])
999
1393
  in_include = [r for r in filed if not scope["include"]
1000
1394
  or any(glob_match(_norm(r["file"]), g) for g in scope["include"])]
1001
1395
  tier_a_names = matching(in_include, tier_a, scope["exclude"]) if tier_a else []
1002
1396
  if tier_a_names:
1003
1397
  sets["tier_a_include"] = tier_a_names
1398
+ set_files["tier_a_include"] = matching_files(in_include, tier_a, scope["exclude"])
1399
+
1400
+ # The clause: an extraction's entry-point status (one the per-file scan replaces says only that it found no
1401
+ # entry point), else a quick output's (an export is a barrel; none, and no warning, an empty one), else what
1402
+ # the workflow read (--barrel).
1403
+ barrel = None
1404
+ summary = extracted["extraction"] if extracted is not None else None
1405
+ if summary is not None and not summary["fallback"]["needed"]:
1406
+ barrel = summary["entryPointStatus"]
1407
+ elif summary is not None and summary["entryPointsFound"] is False:
1408
+ barrel = "no-entry-point"
1409
+ elif quick_outputs:
1410
+ barrel = "barrel" if any(q["exports"] for q in quick_outputs) else (
1411
+ None if any(q.get("warnings") for q in quick_outputs) else "empty-barrel")
1412
+ if barrel is None and args.barrel:
1413
+ barrel = BARREL_FLAG[args.barrel]
1414
+ recorded_type = extracted["scopeType"] if extracted is not None else None
1415
+ meta_type = meta.get("scope_type") if isinstance(meta, dict) and isinstance(meta.get("scope_type"), str) else None
1416
+ arms = summary["arms"] if summary is not None else {}
1417
+ # The extraction's verdict, the rule Create Skill stores the count by, else the workflow's judgment.
1418
+ curated = summary["curatedSubset"] if summary is not None and summary["curatedSubset"] is not None \
1419
+ else bool(args.curated_subset)
1420
+ scope_type = (brief or {}).get("type") or recorded_type or meta_type
1421
+ skill_type = meta.get("skill_type") if isinstance(meta, dict) else None
1422
+ monorepo = bool(args.monorepo or arms.get("monorepo"))
1423
+ # The clause alone (no stored count, no guard): only a clause whose ladder holds a stored count measures it
1424
+ # against the tier A set.
1425
+ clause = _pick(sets, None, False, barrel, arms, scope_type, skill_type, monorepo,
1426
+ bool(args.pattern_reference_intent), curated, source_read)["clause"]
1427
+ swap = False
1428
+ # Each name sits at the file that defines it (a runner extraction, the fallback scan, quick output whose every
1429
+ # name carries its defined_in); a name read by eye, or a quick name with no defined_in, sits at its entry file.
1430
+ runner = extracted is not None and not extracted["extraction"]["fallback"]["needed"]
1431
+ quick_traced = (bool(quick_outputs) and not args.per_file and not args.name
1432
+ and all(isinstance(e.get("defined_in"), str) and e["defined_in"]
1433
+ for q in quick_outputs for e in q["exports"] if isinstance(e, dict)))
1434
+ defining = runner or bool(args.fallback_scan) or quick_traced
1435
+ # The scalar branch counts its scope set only from such names read whole: an incomplete or truncated
1436
+ # extraction can miss names (#791, #792).
1437
+ whole = summary is None or (summary["status"] != "incomplete" and not summary["truncated"])
1004
1438
 
1005
1439
  if extracted is not None or brief is not None or meta is not None:
1006
1440
  include_union = len(sets["scope.include"]) if "scope.include" in sets else len(sets["all"])
@@ -1009,7 +1443,10 @@ def surface(args: argparse.Namespace) -> dict:
1009
1443
  "statsEffectiveDenominator": stats_eff,
1010
1444
  "tierAIncludeUnion": len(sets["tier_a_include"]) if "tier_a_include" in sets else None,
1011
1445
  "scopeIncludeUnion": len(sets["scope.include"]) if "scope.include" in sets else None,
1446
+ "tierAIncludeFiles": len(set_files["tier_a_include"]) if "tier_a_include" in sets else None,
1447
+ "scopeIncludeFiles": len(set_files["scope.include"]) if "scope.include" in sets else None,
1012
1448
  "subpathUnion": len(sets["subpaths"]) if "subpaths" in sets else None,
1449
+ "subpathEntries": extracted["subpathEntries"] if extracted is not None else None,
1013
1450
  "rootBarrel": len(sets["root"]) if "root" in sets else None,
1014
1451
  "extractorEffectiveDenominator": extraction.get("effectiveDenominator"),
1015
1452
  "extractorBasis": extraction.get("effectiveDenominatorBasis"),
@@ -1021,14 +1458,27 @@ def surface(args: argparse.Namespace) -> dict:
1021
1458
  # file, not the file that defines it).
1022
1459
  no_tier_a = ("tier_a_include" not in sets if extracted is not None or args.fallback_scan
1023
1460
  else not tier_a)
1024
- deflation = {"applicable": source_read and stats_eff is not None, "effectiveDenominator": stats_eff,
1025
- "rederived": include_union if source_read else None, "pct": None, "fires": False}
1461
+ # A clause whose ladder holds a stored count checks it against the tier A set, both ways, when each name
1462
+ # sits at the file that defines it (a runner extraction, the fallback scan, quick output whose every name
1463
+ # carries its defined_in): Create Skill stores the runner's tier A count, so one beyond the threshold is
1464
+ # stale (#813). A name read by eye, or a quick name with no defined_in, sits at its entry file.
1465
+ tier_a_basis = defining and clause in CLAUSE_LADDERS and "tier_a_include" in sets
1466
+ rederived = len(sets["tier_a_include"]) if tier_a_basis else include_union
1467
+ basis = "tier_a_include" if tier_a_basis else "scope.include" if "scope.include" in sets else "all"
1468
+ deflation = {"applicable": source_read and stats_eff is not None, "basis": basis,
1469
+ "effectiveDenominator": stats_eff, "rederived": rederived if source_read else None, "pct": None,
1470
+ "fires": False}
1026
1471
  if deflation["applicable"] and stats_eff:
1027
- deflation["pct"] = round2((include_union - stats_eff) / stats_eff * 100)
1028
- deflation["fires"] = no_tier_a and deflation["pct"] > DEFLATION_PCT
1472
+ deflation["pct"] = round2((rederived - stats_eff) / stats_eff * 100)
1473
+ deflation["fires"] = (abs(deflation["pct"]) > DEFLATION_PCT if tier_a_basis
1474
+ else no_tier_a and deflation["pct"] > DEFLATION_PCT)
1029
1475
  elif deflation["applicable"]:
1030
1476
  # A stored 0 is deflation, not a denominator, once a name is re-derived.
1031
- deflation["fires"] = no_tier_a and include_union > 0
1477
+ deflation["fires"] = (tier_a_basis or no_tier_a) and rederived > 0
1478
+ # An incomplete extraction can miss tier A names: a tier A set below the stored count is drift to report,
1479
+ # not a denominator to take.
1480
+ incomplete = runner and extracted["extraction"]["status"] == "incomplete"
1481
+ swap = deflation["fires"] and not (incomplete and tier_a_basis and (deflation["pct"] or 0) < 0)
1032
1482
  entry_count = len(_entries(provenance)) if provenance is not None else 0
1033
1483
  inflation = {"applicable": source_read and no_tier_a and bool(scope["include"]) and entry_count > 0,
1034
1484
  "scopeIncludeUnion": include_union if scope["include"] else None,
@@ -1052,31 +1502,9 @@ def surface(args: argparse.Namespace) -> dict:
1052
1502
  "umbrellaRatio": UMBRELLA_RATIO},
1053
1503
  }
1054
1504
 
1055
- # The clause: an extraction's entry-point status (one the per-file scan replaces says only that it found no
1056
- # entry point), else a quick output's (an export is a barrel; none, and no warning, an empty one), else what
1057
- # the workflow read (--barrel).
1058
- barrel = None
1059
- summary = extracted["extraction"] if extracted is not None else None
1060
- if summary is not None and not summary["fallback"]["needed"]:
1061
- barrel = summary["entryPointStatus"]
1062
- elif summary is not None and summary["entryPointsFound"] is False:
1063
- barrel = "no-entry-point"
1064
- elif quick_outputs:
1065
- barrel = "barrel" if any(q["exports"] for q in quick_outputs) else (
1066
- None if any(q.get("warnings") for q in quick_outputs) else "empty-barrel")
1067
- if barrel is None and args.barrel:
1068
- barrel = BARREL_FLAG[args.barrel]
1069
- recorded_type = extracted["scopeType"] if extracted is not None else None
1070
- meta_type = meta.get("scope_type") if isinstance(meta, dict) and isinstance(meta.get("scope_type"), str) else None
1071
- arms = summary["arms"] if summary is not None else {}
1072
- # The extraction's verdict, the rule Create Skill stores the count by, else the workflow's judgment.
1073
- curated = summary["curatedSubset"] if summary is not None and summary["curatedSubset"] is not None \
1074
- else bool(args.curated_subset)
1075
- out["denominator"] = pick_denominator(
1076
- sets, stats_eff, bool((out["guards"] or {}).get("deflation", {}).get("fires")), barrel, arms,
1077
- (brief or {}).get("type") or recorded_type or meta_type,
1078
- meta.get("skill_type") if isinstance(meta, dict) else None, bool(args.monorepo or arms.get("monorepo")),
1079
- bool(args.pattern_reference_intent), curated, source_read)
1505
+ out["denominator"] = pick_denominator(sets, stats_eff, swap, barrel, arms, scope_type, skill_type, monorepo,
1506
+ bool(args.pattern_reference_intent), curated, source_read,
1507
+ defining and whole)
1080
1508
 
1081
1509
  return {
1082
1510
  "inputs": {
@@ -1100,6 +1528,10 @@ def _cmd_census(args: argparse.Namespace) -> dict:
1100
1528
  return census(args.skill_dir)
1101
1529
 
1102
1530
 
1531
+ def _cmd_floor(args: argparse.Namespace) -> dict:
1532
+ return floor(args.skill_dir)
1533
+
1534
+
1103
1535
  def _cmd_metadata(args: argparse.Namespace) -> dict:
1104
1536
  meta = _object(args.metadata, "--metadata")
1105
1537
  provenance = _object(args.provenance, "--provenance") if args.provenance else None
@@ -1118,6 +1550,11 @@ def _build_parser() -> argparse.ArgumentParser:
1118
1550
  p.add_argument("--output", help="also write the result to this file")
1119
1551
  p.set_defaults(func=_cmd_census)
1120
1552
 
1553
+ p = sub.add_parser("floor", help="the names the skill documents in a fixed form, which the inventory keeps")
1554
+ p.add_argument("--skill-dir", required=True, help="the skill package: SKILL.md and references/")
1555
+ p.add_argument("--output", help="also write the result to this file")
1556
+ p.set_defaults(func=_cmd_floor)
1557
+
1121
1558
  p = sub.add_parser("metadata", help="the metadata.json and provenance-map counts")
1122
1559
  p.add_argument("--metadata", required=True, help="the skill's metadata.json")
1123
1560
  p.add_argument("--provenance", help="the provenance map, when there is one")