bmad-module-skill-forge 5.0.0 → 7.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 (104) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/agents.md +1 -1
  4. package/docs/architecture.md +3 -3
  5. package/docs/campaign.md +1 -1
  6. package/docs/troubleshooting.md +9 -3
  7. package/docs/verifying-a-skill.md +9 -9
  8. package/docs/workflows.md +7 -7
  9. package/package.json +1 -1
  10. package/src/knowledge/tool-resolution.md +6 -6
  11. package/src/shared/references/headless-gate-convention.md +4 -1
  12. package/src/shared/references/output-contract-schema.md +3 -3
  13. package/src/shared/references/pipeline-contracts.md +2 -1
  14. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  15. package/src/shared/scripts/skf-build-change-manifest.py +332 -54
  16. package/src/shared/scripts/skf-classify-changed-files.py +15 -10
  17. package/src/shared/scripts/skf-emit-result-envelope.py +58 -15
  18. package/src/shared/scripts/skf-extract-public-api.py +33 -13
  19. package/src/shared/scripts/skf-parse-gaps.py +3 -2
  20. package/src/shared/scripts/skf-quick-batch.py +16 -3
  21. package/src/shared/scripts/skf-verify-provenance-completeness.py +317 -44
  22. package/src/skf-analyze-source/SKILL.md +1 -0
  23. package/src/skf-analyze-source/customize.toml +3 -2
  24. package/src/skf-analyze-source/references/auto-docs-only.md +2 -2
  25. package/src/skf-analyze-source/references/generate-briefs.md +5 -3
  26. package/src/skf-analyze-source/references/headless-contract.md +3 -3
  27. package/src/skf-analyze-source/references/step-auto-scope.md +2 -2
  28. package/src/skf-audit-skill/SKILL.md +2 -1
  29. package/src/skf-audit-skill/references/report.md +1 -1
  30. package/src/skf-brief-skill/SKILL.md +1 -0
  31. package/src/skf-brief-skill/references/invocation-contract.md +2 -2
  32. package/src/skf-campaign/SKILL.md +1 -0
  33. package/src/skf-campaign/references/campaign-contracts.md +3 -1
  34. package/src/skf-campaign/references/step-05-skill-loop.md +2 -2
  35. package/src/skf-campaign/references/step-07-capstone.md +3 -3
  36. package/src/skf-campaign/references/step-08-verify.md +3 -3
  37. package/src/skf-campaign/references/step-09-refine.md +3 -3
  38. package/src/skf-campaign/references/step-10-export.md +3 -2
  39. package/src/skf-campaign/scripts/campaign-state.py +22 -5
  40. package/src/skf-create-skill/SKILL.md +3 -2
  41. package/src/skf-create-skill/customize.toml +4 -4
  42. package/src/skf-create-skill/references/batch-mode.md +3 -1
  43. package/src/skf-create-skill/references/ccc-index-check.md +1 -1
  44. package/src/skf-create-skill/references/component-extraction.md +1 -1
  45. package/src/skf-create-skill/references/extract.md +1 -1
  46. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  47. package/src/skf-create-skill/references/report.md +9 -4
  48. package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
  49. package/src/skf-create-skill/references/validate.md +2 -2
  50. package/src/skf-create-stack-skill/SKILL.md +1 -0
  51. package/src/skf-create-stack-skill/references/generate-output.md +1 -1
  52. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  53. package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
  54. package/src/skf-create-stack-skill/references/report.md +1 -1
  55. package/src/skf-create-stack-skill/references/validate.md +12 -4
  56. package/src/skf-drop-skill/SKILL.md +1 -0
  57. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  58. package/src/skf-drop-skill/references/report.md +1 -1
  59. package/src/skf-export-skill/SKILL.md +1 -0
  60. package/src/skf-export-skill/references/invocation-contract.md +1 -1
  61. package/src/skf-export-skill/references/summary.md +1 -1
  62. package/src/skf-forger/SKILL.md +1 -0
  63. package/src/skf-forger/references/pipeline-mode.md +2 -2
  64. package/src/skf-quick-skill/SKILL.md +2 -1
  65. package/src/skf-quick-skill/references/batch-mode.md +2 -0
  66. package/src/skf-quick-skill/references/finalize.md +1 -1
  67. package/src/skf-quick-skill/references/halt-contract.md +2 -2
  68. package/src/skf-quick-skill/references/write-and-validate.md +20 -10
  69. package/src/skf-refine-architecture/SKILL.md +2 -1
  70. package/src/skf-refine-architecture/customize.toml +1 -1
  71. package/src/skf-refine-architecture/references/report.md +1 -1
  72. package/src/skf-rename-skill/SKILL.md +1 -0
  73. package/src/skf-rename-skill/references/invocation-contract.md +2 -2
  74. package/src/skf-setup/SKILL.md +1 -0
  75. package/src/skf-setup/references/ccc-index.md +1 -1
  76. package/src/skf-setup/references/invocation-contract.md +1 -1
  77. package/src/skf-test-skill/SKILL.md +1 -0
  78. package/src/skf-test-skill/assets/output-section-formats.md +4 -4
  79. package/src/skf-test-skill/references/coherence-check.md +8 -6
  80. package/src/skf-test-skill/references/coverage-check-branches.md +23 -3
  81. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  82. package/src/skf-test-skill/references/coverage-check.md +30 -43
  83. package/src/skf-test-skill/references/init.md +1 -1
  84. package/src/skf-test-skill/references/inventory-contract.md +27 -0
  85. package/src/skf-test-skill/references/invocation-contract.md +3 -3
  86. package/src/skf-test-skill/references/report.md +3 -3
  87. package/src/skf-test-skill/references/scoring-rules.md +7 -7
  88. package/src/skf-test-skill/references/signature-comparison-rules.md +74 -0
  89. package/src/skf-test-skill/references/source-access-protocol.md +12 -8
  90. package/src/skf-test-skill/references/step-hard-gate.md +1 -0
  91. package/src/skf-test-skill/scripts/build-result-context.py +20 -3
  92. package/src/skf-test-skill/scripts/gap-ledger.py +78 -13
  93. package/src/skf-test-skill/scripts/load-coverage-inputs.py +575 -9
  94. package/src/skf-test-skill/scripts/locate-export-segments.py +591 -63
  95. package/src/skf-test-skill/scripts/score-signatures.py +937 -76
  96. package/src/skf-test-skill/scripts/validate-inventory.py +325 -30
  97. package/src/skf-update-skill/SKILL.md +1 -0
  98. package/src/skf-update-skill/customize.toml +1 -1
  99. package/src/skf-update-skill/references/gap-driven.md +28 -22
  100. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  101. package/src/skf-update-skill/references/merge.md +4 -4
  102. package/src/skf-update-skill/references/report.md +11 -4
  103. package/src/skf-update-skill/references/write.md +6 -5
  104. package/src/skf-verify-stack/SKILL.md +2 -1
@@ -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,129 @@ 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. No kind is mapped, and a
25
+ signature is read only where the place writes it in a fixed
26
+ form (Signatures, below). A name is a plain
27
+ identifier (`[A-Za-z_$][\\w$]*`) once a leading `new ` and
28
+ everything from the first `(` or `<` are cut, so `Meta<T>`,
29
+ `fetch(url): Promise<T>` and `new Client(opts)` give `Meta`,
30
+ `fetch` and `Client`. A dotted or `::` name gives none, so a
31
+ namespace-style skill such as zod (`z.object`) gets no table
32
+ floor. The forms, read outside the frontmatter and HTML
33
+ comments (a table row or heading inside fenced code gives none):
34
+ table-row each inline code span in the first cell of a
35
+ table row (cells split on an unescaped `|`)
36
+ whose header row's first cell is, in any case,
37
+ singular or plural and bare or wrapped in `**`
38
+ or `__`, Export, Symbol, Function, Class,
39
+ Interface, Type, Hook, Component, Helper,
40
+ Constant, Enum or API (a Name, Method, Prop,
41
+ Param, Import or Subpath table gives none). The
42
+ row's Kind cell, as written, is the name's
43
+ `kindLabel`: the skill's own label, never mapped
44
+ to a kind (the first in origin order when two
45
+ rows label the name differently; null with no
46
+ Kind column)
47
+ heading an ATX heading whose whole text is one code
48
+ span, as in ``### `Meta<T>` `` (a heading with
49
+ other words, ``## Testing with `mount` ``, gives
50
+ none)
51
+ declaration a column-0 public declaration in a fenced block
52
+ whose first non-blank line is a comment (`//`,
53
+ `#`, `/* */`, `--`, `<!-- -->` or `;`) holding an
54
+ `[AST:` or `[SRC:` citation, or naming a path one
55
+ such citation of the skill names (its `:12`,
56
+ `:L12` or `:12-30` left out). A citation counts
57
+ only with its `:L<n>` (`[AST:src/a.ts:L12]`, not
58
+ `[AST:src/a.ts]`), and one on the line above the
59
+ fence heads nothing. Comments and strings are
60
+ masked first. TS/JS (not Svelte, whose `export
61
+ let` is a prop): `export` then `function`,
62
+ `const`, `let`, `var`, `class`, `interface`,
63
+ `type` or `enum` (`declare`, `abstract` and
64
+ `async` may come between), or `export default
65
+ function` or `class` with a name; Python: `def`,
66
+ `async def` or `class`, a name not starting with
67
+ `_`; Rust: a `pub` (not `pub(crate)`) `fn`,
68
+ `struct`, `enum`, `trait`, `type`, `const` or
69
+ `static`, and a `macro_rules!` under
70
+ `#[macro_export]`; Go: `func`, `type`, `var` or
71
+ `const` with a capitalized name, each name of a
72
+ `type (`, `var (` or `const (` group too. A
73
+ block in another language, an untagged one or a
74
+ data block (json, yaml, text...) gives none.
75
+ Signatures (#888): an origin whose place writes the name's
76
+ signature in a fixed form that is surely complete also carries
77
+ it as `signature`, {"params", "return_type"}, two strings, each
78
+ one's whitespace runs read as one space, which
79
+ validate-inventory.py --floor refuses an inventory for
80
+ changing; any other place pins nothing, since a wrong pin would
81
+ be forced into the inventory. `params` is the text inside the
82
+ parameter group (score-signatures.py's _group: the first pair
83
+ of parentheses outside every bracket pair and string), and
84
+ `return_type` the type written after it (score-signatures.py's
85
+ _signature_parts), "" for none. A form that writes neither
86
+ (`f()`, `useQuery<T>()`) gives none, so an empty call claims no
87
+ form. A span (a table cell's or a heading's) is complete when
88
+ it is the name (or no name, as a Signature cell's `(x: A) =>
89
+ B`), its type parameters, one parameter group, then nothing or
90
+ a return separator (`->`, `=>`, `→` or `:`) and the return, with
91
+ nothing else: `useQuery(key).data`, `f(a) / f(a, b)`, a
92
+ trailing comment, a `;` or `,` outside the return's brackets,
93
+ an unclosed bracket, `Meta<T>` (no group) and `new Client(opts)`
94
+ give none. By form:
95
+ table-row a row whose first cell names one name (a row
96
+ naming several gives none), each cell's `\\|`
97
+ read as `|`: the leading code span of its
98
+ Signature or Signatures cell, else, when that
99
+ span writes no parameter group (`Typed<X>`),
100
+ the one span of its first cell that gives the
101
+ name (another export's span there, as in
102
+ `fetch` (same as `http.request(opts)`), gives
103
+ nothing). A Signature span with a group that is
104
+ no complete form (`client.get(k) -> str`,
105
+ `new Client(opts)`) gives none, so a shorter
106
+ first-cell call never stands in for it. A header
107
+ cell matches whole, so a labelled one is not a
108
+ Signature column, and a Props, Params or
109
+ Parameters cell is never read (its shared
110
+ members and usage forms stay the reader's).
111
+ heading its code span
112
+ declaration a function's (`function`, `def`, `fn` or Go's
113
+ `func` before its name; a `const` holding an
114
+ arrow gives none): its lines as one, as
115
+ skf-extract-public-api.py's
116
+ _declaration_signature joins them (a parameter
117
+ list split over lines joined, line comments
118
+ left out), the body cut off first, at the first
119
+ `:` (Python) or `{`, `;` or ` where ` (any other
120
+ language) outside every bracket pair and string
121
+ after the parameter group, a `{` directly after
122
+ a TS return `:` opening the return type
123
+ (`(): { a: T } {` returns `{ a: T }`). None for
124
+ one with anything but type parameters between
125
+ its name and the group (a Go `func(...)`
126
+ return), one whose opener follows, or whose
127
+ uncut text ends with, `interface`, `struct`,
128
+ `is`, `keyof`, `|`, `&`, `?`, `:`, `\\`, `=>` or
129
+ `->` (the opener or the line end is inside the
130
+ return type: Go's `interface{}`, a TS `asserts
131
+ x is {...}`, `() => {...}` or conditional type,
132
+ `): Promise<A> |`), or one whose brackets or
133
+ strings do not all close.
134
+ A form written only in prose is never read: it stays the
135
+ reader's. Each origin keeps the first signature its place
136
+ writes.
137
+ Output: {"files": [...], "names": [{"name", "kindLabel",
138
+ "origins": [{"file", "line", "form", "signature"?}]}],
139
+ "summary": {"names", "byForm"}}, names sorted, each one's
140
+ origins in file order then line, `signature` only on an origin
141
+ that has one, `byForm` counting the origins of each form; the
142
+ same skill gives the same bytes.
143
+
21
144
  metadata the metadata.json and provenance-map counts. Cluster A
22
145
  (`stats.exports_public_api`, the `exports[]` length) and Cluster
23
146
  B (`stats.exports_documented`, the provenance entry names, the
@@ -54,7 +177,10 @@ reading files and writing one JSON object:
54
177
  glob that matches it (`excludeGlob`) or no
55
178
  scope.include glob covering it
56
179
  (`outsideInclude`); exports[] only gives each name's
57
- kind, line and signature line, looked up by the
180
+ kind, line and signature line (a local export
181
+ list's item: its binding's declaration_line and
182
+ declaration_signature_line, when the extractor
183
+ records them), looked up by the
58
184
  name its defining file gives it (an aliased
59
185
  re-export's `local`) and that file.
60
186
  extraction_gaps are listed to read by eye, and
@@ -142,7 +268,13 @@ reconcile-coverage.py and score-signatures.py --surface-set pick one.
142
268
 
143
269
  Candidates and guards (with an extraction, --brief or --metadata): each
144
270
  set's count, the metadata `stats.effective_denominator` (--metadata), and
145
- the extractor's own effective_denominator and its basis. Both guards run
271
+ the extractor's own effective_denominator and its basis; for the
272
+ Denominator annotations, `tierAIncludeFiles` and `scopeIncludeFiles` (the
273
+ distinct files the set's names sit at: the files that define them when
274
+ each name sits at its defining file) and `subpathEntries` (an extraction's
275
+ distinct in-scope non-root, non-wildcard subpaths, each package's subpath
276
+ once however many files it names), each null when its set is absent.
277
+ Both guards run
146
278
  only on a surface read from the source. The deflation guard compares a
147
279
  re-derived set with `stats.effective_denominator` and names it in `basis`.
148
280
  It is the tier_a_include set (`tier_a_include`) when the clause's ladder
@@ -253,6 +385,7 @@ a stylesheet, holds nothing to read and is in neither list.
253
385
 
254
386
  Usage:
255
387
  uv run load-coverage-inputs.py census --skill-dir <dir> [--output <file>]
388
+ uv run load-coverage-inputs.py floor --skill-dir <dir> [--output <file>]
256
389
  uv run load-coverage-inputs.py metadata --metadata <metadata.json> \\
257
390
  [--provenance <provenance-map.json>] [--output <file>]
258
391
  uv run load-coverage-inputs.py surface [--extraction <file>] [--quick <file>]... \\
@@ -264,7 +397,8 @@ Usage:
264
397
  Exit codes:
265
398
  0 the result was printed (and written to --output)
266
399
  1 an input file is missing, unreadable or not the JSON (or the YAML
267
- brief) it should be (one line on stderr)
400
+ brief) it should be, or the --skill-dir holds no SKILL.md or a skill
401
+ file that is not UTF-8 (one line on stderr)
268
402
  2 a per-file result breaks the schema: {"valid": false, "violations":
269
403
  [...]} names each breach and nothing is written to --output; or a
270
404
  usage error (argparse)
@@ -294,6 +428,57 @@ CITATION_FORMS = {
294
428
  "DOC": "[DOC:",
295
429
  }
296
430
  LOCAL_FORMS = ("AST", "SRC", "from skill")
431
+ # floor: the forms a documented name takes, in the order an origin on one line sorts by.
432
+ FLOOR_FORMS = ("table-row", "heading", "declaration")
433
+ # The first header cells (lower case, singular or plural) of a table whose first column names exports.
434
+ FLOOR_TABLE_HEADERS = frozenset({
435
+ "export", "exports", "symbol", "symbols", "function", "functions", "class", "classes", "interface", "interfaces",
436
+ "type", "types", "hook", "hooks", "component", "components", "helper", "helpers", "constant", "constants",
437
+ "enum", "enums", "api", "apis"})
438
+ # The header words (as _header_word reads them, whole) of a floor table's column that writes the row's signature
439
+ # (#888); a labelled header, such as `Signature (simplified)`, is not one.
440
+ FLOOR_SIGNATURE_HEADERS = frozenset({"signature", "signatures"})
441
+ # A complete form's name, before its type parameters and parameter group (group 1, absent for none).
442
+ SPAN_NAME_RE = re.compile(r"\s*([A-Za-z_$][\w$]*)?\s*")
443
+ # What a complete form writes after its parameter group before its return: `->`, `=>`, `→` or `:` (not `::`).
444
+ RETURN_SEPARATOR_RE = re.compile(r"\s*(?:->|=>|→|:(?!:))")
445
+ # A declaration that gets a form: a function keyword before its name, on its masked line.
446
+ FUNCTION_KEYWORD_RE = re.compile(r"(?<![\w$])(?:function|def|fn|func)(?![\w$])")
447
+ # After a declaration's parameter group: the `:` a TS return type follows (a `{` right after it opens the type, not
448
+ # the body), and a Rust `where` clause, which the body comes after.
449
+ RETURN_COLON_RE = re.compile(r"\s*:\s*")
450
+ WHERE_CLAUSE_RE = re.compile(r"\s+where(?![\w$])")
451
+ # A declaration's text that ends in the middle of its return type: a body opener after one of these is part of that
452
+ # type, cut short (Go's `interface{}` and `struct{...}`, a TS union, intersection, `asserts x is {...}`, `keyof {...}`,
453
+ # conditional or function type), and so is a declaration that ends with one (`): Promise<A> |`, Python's `\`).
454
+ OPENER_IN_TYPE_RE = re.compile(r"(?:(?<![\w$])(?:interface|struct|is|keyof)|[|&?:\\]|=>|->)\s*$")
455
+ # A header cell wrapped in `**` or `__` (group 2 its text).
456
+ EMPHASIS_RE = re.compile(r"(\*\*|__)(.+)\1")
457
+ PLAIN_IDENTIFIER_RE = re.compile(r"[A-Za-z_$][\w$]*")
458
+ TABLE_CELL_RE = re.compile(r"(?<!\\)\|")
459
+ # A comment's opening marker: `//`, `#`, `/*`, `--`, `<!--` or `;`.
460
+ COMMENT_MARKER_RE = re.compile(r"[ \t]*(?://+|#+|/\*+|--|<!--|;+)")
461
+ # A cited path's line suffix: `:12`, `:L12`, `:12-30` or `:L12-L30`.
462
+ LINE_SUFFIX_RE = re.compile(r":L?\d+(?:-L?\d+)?$")
463
+ PYTHON_LANGUAGES = frozenset({"python", "py", "python3"})
464
+ RUST_LANGUAGES = frozenset({"rust", "rs"})
465
+ GO_LANGUAGES = frozenset({"go", "golang"})
466
+ # Column-0 public declarations (group 1 the name), matched on the block with comments and strings masked.
467
+ JS_DECLARATION_RE = re.compile(
468
+ r"export\s+(?:default\s+(?:abstract\s+)?(?:async\s+)?(?:function(?:\s*\*\s*|\s+)|class\s+)"
469
+ r"|(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?"
470
+ r"(?:function(?:\s*\*\s*|\s+)|const\s+enum\s+|const\s+|let\s+|var\s+|class\s+|interface\s+|type\s+|enum\s+))"
471
+ r"(?!(?:extends|implements)(?![\w$]))([A-Za-z_$][\w$]*)")
472
+ PYTHON_DECLARATION_RE = re.compile(r"(?:async\s+def|def|class)\s+([A-Za-z_]\w*)")
473
+ RUST_DECLARATION_RE = re.compile(
474
+ r"pub\s+(?:(?:async|unsafe|const|extern)\s+)*(?:fn|struct|enum|trait|type|const|static)\s+(?:mut\s+)?"
475
+ r"([A-Za-z_]\w*)")
476
+ RUST_MACRO_EXPORT_RE = re.compile(r"#\[macro_export\]")
477
+ RUST_MACRO_RE = re.compile(r"(?:#\[[^\]]*\][ \t]*)*macro_rules![ \t]*([A-Za-z_]\w*)")
478
+ # Go: a single declaration (group 1 its keyword, group 2 its names) or a group's opening line (group 1).
479
+ GO_DECLARATION_RE = re.compile(r"(func|type|var|const)[ \t]+([A-Za-z_]\w*(?:[ \t]*,[ \t]*[A-Za-z_]\w*)*)")
480
+ GO_GROUP_RE = re.compile(r"(type|var|const)[ \t]*\([ \t]*$")
481
+ GO_GROUP_NAMES_RE = re.compile(r"[ \t]+([A-Za-z_]\w*(?:[ \t]*,[ \t]*[A-Za-z_]\w*)*)")
297
482
  # Bookkeeping variants folded to their base name, in rule order.
298
483
  FOLD_SUFFIXES = ("_def", "_exact")
299
484
  FOLD_PREFIX = "a11y_"
@@ -334,7 +519,9 @@ SHARED_SCRIPTS = Path(__file__).resolve().parent.parent.parent / "shared" / "scr
334
519
 
335
520
  def _sibling(filename: str, folder: Path | None = None):
336
521
  """A script beside this one, loaded once: validate-inventory.py (the doc
337
- text) and score-signatures.py (the per-file result schema); or, with
522
+ text, and the skill citations floor reads), locate-export-segments.py
523
+ (the line model floor reads the skill with) and score-signatures.py (the
524
+ per-file result schema); or, with
338
525
  `folder`, one in that folder: SHARED_SCRIPTS'
339
526
  skf-resolve-authoritative-files.py (a skill brief's YAML)."""
340
527
  module = _SIBLINGS.get(filename)
@@ -421,6 +608,348 @@ def census(skill_dir: str) -> dict:
421
608
  }
422
609
 
423
610
 
611
+ # --------------------------------------------------------------------------
612
+ # floor
613
+ # --------------------------------------------------------------------------
614
+
615
+
616
+ def _floor_name(text: str) -> str | None:
617
+ """The plain identifier `text` writes once a leading `new ` and everything from the first `(` or `<` are
618
+ cut; None for any other text, a dotted or `::` name among them."""
619
+ text = text.strip()
620
+ if text.startswith("new "):
621
+ text = text[4:]
622
+ text = re.split(r"[(<]", text, maxsplit=1)[0].strip()
623
+ return text if PLAIN_IDENTIFIER_RE.fullmatch(text) else None
624
+
625
+
626
+ def _header_word(cell: str) -> str:
627
+ """A header cell's word, lower case, unwrapped from `**` or `__`."""
628
+ m = EMPHASIS_RE.fullmatch(cell)
629
+ return (m.group(2) if m else cell).strip().lower()
630
+
631
+
632
+ def _cells(row: str) -> list[str]:
633
+ """A table row's cells, split on each unescaped `|`, each stripped."""
634
+ row = row.strip()
635
+ if row.startswith("|"):
636
+ row = row[1:]
637
+ if row.endswith("|") and not row.endswith("\\|"):
638
+ row = row[:-1]
639
+ return [cell.strip() for cell in TABLE_CELL_RE.split(row)]
640
+
641
+
642
+ def _whole_span(text: str, segments) -> str | None:
643
+ """The code span `text` is, when the whole of it is one; else None."""
644
+ spans = segments._inline_code(text)
645
+ if len(spans) != 1:
646
+ return None
647
+ run = len(text) - len(text.lstrip("`"))
648
+ return spans[0] if text == "`" * run + spans[0] + "`" * run else None
649
+
650
+
651
+ def _comment_text(line: str) -> str | None:
652
+ """A comment line's text, its markers stripped (`// x`, `# x`, `/* x */`, `-- x`, `<!-- x -->`, `; x`);
653
+ None for a line that is no comment. Linear in the line's length."""
654
+ m = COMMENT_MARKER_RE.match(line)
655
+ if not m:
656
+ return None
657
+ text = line[m.end():].strip()
658
+ if text.endswith("-->"):
659
+ text = text[:-3]
660
+ elif text.endswith("*/"):
661
+ text = text[:-2].rstrip("*")
662
+ return text.strip()
663
+
664
+
665
+ def _headed(lines: list[str], opening: int, closing: int, cited: set[str], inventory) -> bool:
666
+ """Whether a source citation heads the fenced block opening at `opening`: its first non-blank line is a
667
+ comment holding a citation, or naming a path the skill cites (a `:12`, `:L12` or `:12-30` suffix left
668
+ out). A citation on the line above the fence heads nothing."""
669
+ first = next((lines[i] for i in range(opening + 1, closing) if lines[i].strip()), None)
670
+ text = _comment_text(first) if first is not None else None
671
+ if not text:
672
+ return False
673
+ if inventory._SKILL_CITATION_RE.search(text):
674
+ return True
675
+ suffix = LINE_SUFFIX_RE.search(text)
676
+ path = text[:suffix.start()] if suffix else text
677
+ return bool(path) and not any(c.isspace() for c in path) and inventory._cited_path(path) in cited
678
+
679
+
680
+ def _html_comment_lines(lines: list[str], start: int, code: list[bool]) -> set[int]:
681
+ """The lines inside an HTML comment, the fenced lines it holds included, read as _prose reads one: a line
682
+ outside fenced code that starts `<!--` opens it, the next such line holding `-->` closes it."""
683
+ inside, open_ = set(), False
684
+ for i in range(start, len(lines)):
685
+ if open_:
686
+ inside.add(i)
687
+ open_ = code[i] or "-->" not in lines[i]
688
+ elif not code[i] and lines[i].strip().startswith("<!--"):
689
+ inside.add(i)
690
+ open_ = "-->" not in lines[i].strip()[4:]
691
+ return inside
692
+
693
+
694
+ def _names(text: str) -> list[str]:
695
+ return [name.strip() for name in text.split(",")]
696
+
697
+
698
+ def _declared_names(lines: list[str], language: str, js_family) -> list[tuple[int, str]]:
699
+ """(offset, name) of each column-0 public declaration in a block's masked lines, by its language."""
700
+ found: list[tuple[int, str]] = []
701
+ if language in js_family:
702
+ found = [(i, m.group(1)) for i, line in enumerate(lines) if (m := JS_DECLARATION_RE.match(line))]
703
+ elif language in PYTHON_LANGUAGES:
704
+ found = [(i, m.group(1)) for i, line in enumerate(lines)
705
+ if (m := PYTHON_DECLARATION_RE.match(line)) and not m.group(1).startswith("_")]
706
+ elif language in RUST_LANGUAGES:
707
+ exported = False # a `#[macro_export]` above, with only blank or attribute lines between
708
+ for i, line in enumerate(lines):
709
+ if m := RUST_DECLARATION_RE.match(line):
710
+ found.append((i, m.group(1)))
711
+ exported = exported or bool(RUST_MACRO_EXPORT_RE.match(line))
712
+ macro = RUST_MACRO_RE.match(line)
713
+ if macro and exported:
714
+ found.append((i, macro.group(1)))
715
+ if macro or (line.strip() and not line.startswith("#[")):
716
+ exported = False
717
+ elif language in GO_LANGUAGES:
718
+ group, depth = False, 0 # inside a `type (`, `var (` or `const (` group, and its bracket depth there
719
+ for i, line in enumerate(lines):
720
+ if group:
721
+ if depth == 0 and line.lstrip().startswith(")"):
722
+ group = False
723
+ continue
724
+ if depth == 0 and (m := GO_GROUP_NAMES_RE.match(line)):
725
+ found += [(i, name) for name in _names(m.group(1)) if name[0].isupper()]
726
+ depth = max(0, depth + sum(map(line.count, "{([")) - sum(map(line.count, "})]")))
727
+ elif GO_GROUP_RE.match(line):
728
+ group, depth = True, 0
729
+ elif m := GO_DECLARATION_RE.match(line):
730
+ found += [(i, name) for name in _names(m.group(2)) if name[0].isupper()]
731
+ return found
732
+
733
+
734
+ def _form(text: str, group: tuple[int, int], extension: str = "") -> dict | None:
735
+ """{"params", "return_type"} of a form whose parameter group is `group`, whitespace runs read as one space: the
736
+ text inside the group, and the return after it (score-signatures.py's _signature_parts), "" for none; None for a
737
+ form that writes neither (`f()`)."""
738
+ form = {"params": " ".join(text[group[0] + 1:group[1]].split()),
739
+ "return_type": " ".join(_sibling("score-signatures.py")._signature_parts(text, extension)[1].split())}
740
+ return form if form["params"] or form["return_type"] else None
741
+
742
+
743
+ def _type_parameters_only(text: str) -> bool:
744
+ """Whether `text` is empty or one `<...>` or `[...]` pair, the type parameters a form writes before its group."""
745
+ text = text.strip()
746
+ if not text:
747
+ return True
748
+ if text[0] not in "<[" or text[-1] != {"<": ">", "[": "]"}[text[0]]:
749
+ return False
750
+ depth = 0
751
+ for i, ch in enumerate(text):
752
+ if ch in "<[(":
753
+ depth += 1
754
+ elif ch in ">])" and not (ch == ">" and text[i - 1:i] in ("-", "=")):
755
+ depth -= 1
756
+ if depth == 0 and i != len(text) - 1:
757
+ return False
758
+ return depth == 0
759
+
760
+
761
+ def _call_form(span: str, name: str | None) -> dict | None:
762
+ """The signature a span writes when it is a complete form (_form): `name` or no name (a Signature cell's
763
+ `(x: A) => B`), its type parameters, one parameter group (score-signatures.py's _group), then nothing, or a
764
+ return separator (`->`, `=>`, `→` or `:`) and the return, with nothing else: no other text after the group
765
+ (`useQuery(key).data`, `f(a) / f(a, b)`, a trailing comment), no `;`, `,`, `#`, `//` or `/*` in the return
766
+ outside its brackets and strings, and every bracket and string closed. None for any other span, a constructor
767
+ call (`new Client(opts)`) and a span with no parameter group (`Meta<T>`) among them."""
768
+ scorer = _sibling("score-signatures.py")
769
+ text = span.strip()
770
+ group = scorer._group(text)
771
+ if group is None or text.startswith("new ") or scorer._levels(text + " ")[-1] != 0:
772
+ return None
773
+ head = SPAN_NAME_RE.match(text, 0, group[0])
774
+ if head.group(1) not in (None, name) or not _type_parameters_only(text[head.end():group[0]]):
775
+ return None
776
+ tail = text[group[1] + 1:]
777
+ separator = RETURN_SEPARATOR_RE.match(tail)
778
+ returned = tail[separator.end():] if separator else ""
779
+ if (separator is None and tail.strip()) or (separator is not None and not returned.strip()):
780
+ return None
781
+ levels = scorer._levels(returned)
782
+ if any(levels[i] == 0 and (ch in ";,#" or returned.startswith(("//", "/*"), i)) for i, ch in enumerate(returned)):
783
+ return None
784
+ return _form(text, group)
785
+
786
+
787
+ def _leading_span(cell: str) -> str | None:
788
+ """The code span a table cell opens with, None when it opens with none."""
789
+ run = len(cell) - len(cell.lstrip("`"))
790
+ spans = _sibling("locate-export-segments.py")._inline_code(cell) if run else []
791
+ return spans[0] if spans and cell.startswith("`" * run + spans[0] + "`" * run) else None
792
+
793
+
794
+ def _row_form(header: list[str], row: list[str]) -> dict | None:
795
+ """The signature a floor table's row writes for the one name its first cell names (a row naming several gives
796
+ none), each cell's `\\|` read as `|`: the leading code span of its Signature or Signatures cell, else, when that
797
+ span writes no parameter group, the one span of its first cell that gives that name (another export's span there
798
+ gives nothing), each a complete form (_call_form). A Signature span with a group that is no complete form gives
799
+ none. A Props, Params or Parameters cell is never read."""
800
+ segments = _sibling("locate-export-segments.py")
801
+ names = {name for name in map(_floor_name, segments._inline_code(row[0])) if name}
802
+ if len(names) != 1:
803
+ return None
804
+ [name] = names
805
+ cells = [cell.replace("\\|", "|") for cell in row]
806
+ at = next((i for i, cell in enumerate(header) if _header_word(cell) in FLOOR_SIGNATURE_HEADERS), None)
807
+ span = _leading_span(cells[at]) if at is not None and at < len(cells) else None
808
+ if span is not None and (form := _call_form(span, name)):
809
+ return form
810
+ if span is not None and _sibling("score-signatures.py")._group(span.strip()) is not None:
811
+ return None # the cell writes a call the floor cannot pin: a shorter first-cell form would hide it
812
+ own = [span for span in segments._inline_code(cells[0]) if _floor_name(span) == name]
813
+ return _call_form(own[0], name) if len(own) == 1 else None
814
+
815
+
816
+ def _function_declaration(line: str, language: str, js_family) -> int | None:
817
+ """Where the name ends on a block's masked line, one _declared_names reads, that declares a function (`function`,
818
+ `def`, `fn` or Go's `func` before its name); None for a line that declares no function."""
819
+ if language in js_family:
820
+ m, at = JS_DECLARATION_RE.match(line), 1
821
+ elif language in PYTHON_LANGUAGES:
822
+ m, at = PYTHON_DECLARATION_RE.match(line), 1
823
+ elif language in RUST_LANGUAGES:
824
+ m, at = RUST_DECLARATION_RE.match(line), 1
825
+ elif language in GO_LANGUAGES:
826
+ m, at = GO_DECLARATION_RE.match(line), 2
827
+ else:
828
+ return False
829
+ return m.end(at) if m and FUNCTION_KEYWORD_RE.search(line, 0, m.start(at)) else None
830
+
831
+
832
+ def _declaration_form(rows: list[str], offset: int, language: str, name_end: int) -> dict | None:
833
+ """The signature the function declaration at `offset` of a block's unmasked `rows` writes: the declaration on one
834
+ line (skf-extract-public-api.py's _declaration_signature: a parameter list split over lines joined, line comments
835
+ left out), its body cut at the first `:` (Python) or `{`, `;` or ` where ` (any other language) outside every
836
+ bracket pair and string after the parameter group, a `{` directly after a TS return `:` opening the return type,
837
+ then read as _form reads it. None when anything but type parameters sits between the name's end (`name_end`,
838
+ the masked line's offset, which masking keeps) and the group (a Go `func(...)` return read as the group), when
839
+ the opener follows, or the uncut text ends with, `interface`, `struct`, `is`, `keyof`, `|`, `&`, `?`, `:`, `\\`,
840
+ `=>` or `->` (it is inside the return type: Go's `interface{}`, a TS `asserts x is {...}`, `() => {...}` or
841
+ conditional type, a line that ends mid-type), or a bracket or string it opens does not close."""
842
+ scorer = _sibling("score-signatures.py")
843
+ extractor = _sibling("skf-extract-public-api.py", SHARED_SCRIPTS)
844
+ python, rust = language in PYTHON_LANGUAGES, language in RUST_LANGUAGES
845
+ extension = ".rs" if rust else ""
846
+ text = extractor._declaration_signature(rows, offset, "python" if python else "rust" if rust else language)
847
+ group = scorer._group(text, extension)
848
+ if group is None or not _type_parameters_only(text[name_end:group[0]]):
849
+ return None
850
+ levels = scorer._levels(text, extension)
851
+ for i in range(group[1] + 1, len(text)):
852
+ if levels[i] != 0:
853
+ continue
854
+ if python:
855
+ body = text[i] == ":"
856
+ else:
857
+ body = (text[i] == ";" or bool(WHERE_CLAUSE_RE.match(text, i))
858
+ or (text[i] == "{" and not RETURN_COLON_RE.fullmatch(text, group[1] + 1, i)))
859
+ if body:
860
+ if OPENER_IN_TYPE_RE.search(text, 0, i):
861
+ return None
862
+ text = text[:i]
863
+ break
864
+ else:
865
+ if OPENER_IN_TYPE_RE.search(text):
866
+ return None
867
+ if scorer._levels(text + " ", extension)[-1] != 0:
868
+ return None
869
+ return _form(text, group, extension)
870
+
871
+
872
+ def floor(skill_dir: str) -> dict:
873
+ """The names the skill documents in a fixed form, each with its origins (see the module docstring)."""
874
+ root = Path(skill_dir)
875
+ if not (root / "SKILL.md").is_file():
876
+ raise InputError(f"--skill-dir {skill_dir} holds no SKILL.md")
877
+ segments = _sibling("locate-export-segments.py")
878
+ inventory = _sibling("validate-inventory.py")
879
+ refs = root / "references"
880
+ files = ["SKILL.md"] + ([f"references/{p.name}" for p in sorted(refs.glob("*.md")) if p.is_file()]
881
+ if refs.is_dir() else [])
882
+ texts = []
883
+ for rel in files:
884
+ try:
885
+ texts.append(segments._split_lines((root / rel).read_bytes().decode("utf-8-sig")))
886
+ except (OSError, UnicodeDecodeError) as exc:
887
+ raise InputError(f"cannot read {rel} under {skill_dir}: {exc}") from exc
888
+ cited = {inventory._cited_path(m.group(2))
889
+ for lines in texts for line in lines for m in inventory._SKILL_CITATION_RE.finditer(line)}
890
+
891
+ found: dict[str, set[tuple[int, int, str, str | None]]] = {}
892
+ signatures: dict[tuple[str, int, int, str], dict] = {} # the first per (name, file, line, form), in span order
893
+
894
+ def add(name: str | None, file_index: int, index: int, form: str, label: str | None = None,
895
+ signature: dict | None = None) -> None:
896
+ if name:
897
+ found.setdefault(name, set()).add((file_index, index + 1, form, label))
898
+ if signature is not None:
899
+ signatures.setdefault((name, file_index, index + 1, form), signature)
900
+
901
+ js_family = segments.JS_FAMILY - {"svelte"} # a Svelte block's `export let` is a component prop
902
+ for file_index, lines in enumerate(texts):
903
+ start = segments._frontmatter_end(lines)
904
+ blocks, code = segments._fenced_blocks(lines, start)
905
+ headings, prose = segments._prose(lines, start, code)
906
+ for block in prose:
907
+ if block["kind"] != "table-row" or block["header"] is None:
908
+ continue
909
+ header = _cells(block["header"])
910
+ if _header_word(header[0]) not in FLOOR_TABLE_HEADERS:
911
+ continue
912
+ row = _cells(lines[block["start"]])
913
+ kind_at = next((i for i, cell in enumerate(header) if _header_word(cell) == "kind"), None)
914
+ label = row[kind_at] if kind_at is not None and kind_at < len(row) and row[kind_at] else None
915
+ signature = _row_form(header, row)
916
+ for span in segments._inline_code(row[0]):
917
+ add(_floor_name(span), file_index, block["start"], "table-row", label, signature)
918
+ for index, _level, text, _above in headings:
919
+ span = _whole_span(text, segments)
920
+ if span is not None:
921
+ add(_floor_name(span), file_index, index, "heading", None, _call_form(span, _floor_name(span)))
922
+ comments = _html_comment_lines(lines, start, code)
923
+ for opening, closing, info in blocks:
924
+ language = segments._language(info)
925
+ if (opening in comments or language in segments.DATA_LANGUAGES
926
+ or not _headed(lines, opening, closing, cited, inventory)):
927
+ continue
928
+ masked = segments.mask_code("\n".join(lines[opening + 1:closing]), language).split("\n")
929
+ for offset, name in _declared_names(masked, language, js_family):
930
+ name_end = _function_declaration(masked[offset], language, js_family)
931
+ signature = (_declaration_form(lines[opening + 1:closing], offset, language, name_end)
932
+ if name_end is not None else None)
933
+ add(name, file_index, opening + 1 + offset, "declaration", None, signature)
934
+
935
+ names = []
936
+ by_form = dict.fromkeys(FLOOR_FORMS, 0)
937
+ for name in sorted(found):
938
+ origins = sorted(found[name], key=lambda o: (o[0], o[1], FLOOR_FORMS.index(o[2]), o[3] or ""))
939
+ seen, kept = set(), []
940
+ for file_index, line, form, _label in origins:
941
+ if (file_index, line, form) not in seen:
942
+ seen.add((file_index, line, form))
943
+ origin = {"file": files[file_index], "line": line, "form": form}
944
+ if (name, file_index, line, form) in signatures:
945
+ origin["signature"] = signatures[(name, file_index, line, form)]
946
+ kept.append(origin)
947
+ by_form[form] += 1
948
+ label = next((o[3] for o in origins if o[2] == "table-row" and o[3]), None)
949
+ names.append({"name": name, "kindLabel": label, "origins": kept})
950
+ return {"files": files, "names": names, "summary": {"names": len(names), "byForm": by_form}}
951
+
952
+
424
953
  # --------------------------------------------------------------------------
425
954
  # metadata
426
955
  # --------------------------------------------------------------------------
@@ -577,6 +1106,14 @@ def matching(records: list[dict], globs: list[str], excludes: list[str]) -> list
577
1106
  and not any(glob_match(_norm(r["file"]), g) for g in excludes)})
578
1107
 
579
1108
 
1109
+ def matching_files(records: list[dict], globs: list[str], excludes: list[str]) -> list[str]:
1110
+ """The distinct files `matching` takes its names from: each record's file one of `globs` matches and no
1111
+ exclude glob does."""
1112
+ return sorted({r["file"] for r in records if r.get("file")
1113
+ and any(glob_match(_norm(r["file"]), g) for g in globs)
1114
+ and not any(glob_match(_norm(r["file"]), g) for g in excludes)})
1115
+
1116
+
580
1117
  def _apart(a: str, b: str) -> bool:
581
1118
  """True when, before either has a `*` or `?`, the two globs spell a different segment at the same
582
1119
  place, so no file can match both. Any other pair counts as overlapping, even one no file can match
@@ -735,7 +1272,7 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
735
1272
  "staleScope": {"unmatchedInclude": unmatched, "unmatchedTierAInclude": unmatched_tier_a,
736
1273
  "restored": []},
737
1274
  "gaps": [], "scope": scope, "scopeType": recorded.get("type"), "umbrella": dict(NO_UMBRELLA),
738
- "extraction": extraction}
1275
+ "subpathEntries": None, "extraction": extraction}
739
1276
 
740
1277
  diff = data.get("entry_point_diff") if isinstance(data.get("entry_point_diff"), dict) else {}
741
1278
  # Each in-scope definition of a public name is a record of its own (#830): the runner's other
@@ -760,6 +1297,11 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
760
1297
  subpath_files = {e.get("file") for e in entry_files
761
1298
  if e.get("subpath") not in ROOT_SUBPATHS and "*" not in str(e.get("subpath"))}
762
1299
  root_files = {e.get("file") for e in entry_files if e.get("subpath") in ROOT_SUBPATHS} - set(nested)
1300
+ # The distinct in-scope subpaths of those (a package's subpath once, however many files its conditions
1301
+ # name), for the multi-entry annotation.
1302
+ subpath_entries = {(e.get("package"), e.get("subpath")) for e in entry_files
1303
+ if e.get("subpath") not in ROOT_SUBPATHS and "*" not in str(e.get("subpath"))
1304
+ and e.get("in_scope") is not False}
763
1305
 
764
1306
  # A scope.include glob that matches no file: the brief predates the
765
1307
  # source, so a root export defined in a file no glob covers comes back,
@@ -790,10 +1332,16 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
790
1332
  # under the name its defining file gives it, `local`.
791
1333
  found = index.get((info.get("local") or name, file)) or index.get((name, file))
792
1334
  kind = found.get("export_type") if found else None
1335
+ # A local `export { ... }` list's item is planned at the declaration it
1336
+ # names (#836): the line and the signature line both come from that pair.
1337
+ if found and isinstance(found.get("declaration_line"), int):
1338
+ line, signature_line = found["declaration_line"], found.get("declaration_signature_line")
1339
+ elif found:
1340
+ line, signature_line = found.get("source_line"), found.get("signature_line")
1341
+ else:
1342
+ line, signature_line = info.get("line"), None
793
1343
  (nested_records if info.get("entry") in nested else records).append(
794
- _record(name, None if kind == "re-export" else kind, file,
795
- found.get("source_line") if found else info.get("line"),
796
- found.get("signature_line") if found else None, "extraction"))
1344
+ _record(name, None if kind == "re-export" else kind, file, line, signature_line, "extraction"))
797
1345
 
798
1346
  # The umbrella ratio reads the public rows alone: an other definition is no root barrel name there.
799
1347
  root_names = [p for p in _members_in_place(rows["public"]) if p.get("entry") in root_files]
@@ -824,6 +1372,8 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
824
1372
  "ratio": round2(len(reexported) / len(root_names)) if root_names else None,
825
1373
  "umbrella": bool(root_names) and len(reexported) / len(root_names) > UMBRELLA_RATIO,
826
1374
  },
1375
+ # The in-scope subpaths the `subpaths` set reads (None when there is no such set).
1376
+ "subpathEntries": len(subpath_entries) if subpath_files else None,
827
1377
  "extraction": extraction,
828
1378
  }
829
1379
 
@@ -1065,13 +1615,17 @@ def surface(args: argparse.Namespace) -> dict:
1065
1615
  and not (scope["include"] and all(_apart(g, i) for i in scope["include"]))
1066
1616
  and not any(_inside(g, e) for e in scope["exclude"])]
1067
1617
  filed = [r for r in records if r["file"]]
1618
+ # The files each scope set's names sit at, for the Denominator annotations' file counts.
1619
+ set_files: dict[str, list[str]] = {}
1068
1620
  if filed and scope["include"]:
1069
1621
  sets["scope.include"] = matching(filed, scope["include"], scope["exclude"])
1622
+ set_files["scope.include"] = matching_files(filed, scope["include"], scope["exclude"])
1070
1623
  in_include = [r for r in filed if not scope["include"]
1071
1624
  or any(glob_match(_norm(r["file"]), g) for g in scope["include"])]
1072
1625
  tier_a_names = matching(in_include, tier_a, scope["exclude"]) if tier_a else []
1073
1626
  if tier_a_names:
1074
1627
  sets["tier_a_include"] = tier_a_names
1628
+ set_files["tier_a_include"] = matching_files(in_include, tier_a, scope["exclude"])
1075
1629
 
1076
1630
  # The clause: an extraction's entry-point status (one the per-file scan replaces says only that it found no
1077
1631
  # entry point), else a quick output's (an export is a barrel; none, and no warning, an empty one), else what
@@ -1119,7 +1673,10 @@ def surface(args: argparse.Namespace) -> dict:
1119
1673
  "statsEffectiveDenominator": stats_eff,
1120
1674
  "tierAIncludeUnion": len(sets["tier_a_include"]) if "tier_a_include" in sets else None,
1121
1675
  "scopeIncludeUnion": len(sets["scope.include"]) if "scope.include" in sets else None,
1676
+ "tierAIncludeFiles": len(set_files["tier_a_include"]) if "tier_a_include" in sets else None,
1677
+ "scopeIncludeFiles": len(set_files["scope.include"]) if "scope.include" in sets else None,
1122
1678
  "subpathUnion": len(sets["subpaths"]) if "subpaths" in sets else None,
1679
+ "subpathEntries": extracted["subpathEntries"] if extracted is not None else None,
1123
1680
  "rootBarrel": len(sets["root"]) if "root" in sets else None,
1124
1681
  "extractorEffectiveDenominator": extraction.get("effectiveDenominator"),
1125
1682
  "extractorBasis": extraction.get("effectiveDenominatorBasis"),
@@ -1201,6 +1758,10 @@ def _cmd_census(args: argparse.Namespace) -> dict:
1201
1758
  return census(args.skill_dir)
1202
1759
 
1203
1760
 
1761
+ def _cmd_floor(args: argparse.Namespace) -> dict:
1762
+ return floor(args.skill_dir)
1763
+
1764
+
1204
1765
  def _cmd_metadata(args: argparse.Namespace) -> dict:
1205
1766
  meta = _object(args.metadata, "--metadata")
1206
1767
  provenance = _object(args.provenance, "--provenance") if args.provenance else None
@@ -1219,6 +1780,11 @@ def _build_parser() -> argparse.ArgumentParser:
1219
1780
  p.add_argument("--output", help="also write the result to this file")
1220
1781
  p.set_defaults(func=_cmd_census)
1221
1782
 
1783
+ p = sub.add_parser("floor", help="the names the skill documents in a fixed form, which the inventory keeps")
1784
+ p.add_argument("--skill-dir", required=True, help="the skill package: SKILL.md and references/")
1785
+ p.add_argument("--output", help="also write the result to this file")
1786
+ p.set_defaults(func=_cmd_floor)
1787
+
1222
1788
  p = sub.add_parser("metadata", help="the metadata.json and provenance-map counts")
1223
1789
  p.add_argument("--metadata", required=True, help="the skill's metadata.json")
1224
1790
  p.add_argument("--provenance", help="the provenance map, when there is one")