bmad-module-skill-forge 5.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 (91) 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 +4 -4
  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-verify-provenance-completeness.py +108 -11
  21. package/src/skf-analyze-source/SKILL.md +1 -0
  22. package/src/skf-analyze-source/references/generate-briefs.md +1 -1
  23. package/src/skf-analyze-source/references/headless-contract.md +1 -1
  24. package/src/skf-audit-skill/SKILL.md +2 -1
  25. package/src/skf-audit-skill/references/report.md +1 -1
  26. package/src/skf-brief-skill/SKILL.md +1 -0
  27. package/src/skf-brief-skill/references/invocation-contract.md +2 -2
  28. package/src/skf-campaign/SKILL.md +1 -0
  29. package/src/skf-campaign/references/campaign-contracts.md +2 -0
  30. package/src/skf-campaign/references/step-05-skill-loop.md +2 -2
  31. package/src/skf-campaign/references/step-10-export.md +3 -2
  32. package/src/skf-create-skill/SKILL.md +2 -1
  33. package/src/skf-create-skill/references/ccc-index-check.md +1 -1
  34. package/src/skf-create-skill/references/component-extraction.md +1 -1
  35. package/src/skf-create-skill/references/extract.md +1 -1
  36. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  37. package/src/skf-create-skill/references/report.md +2 -2
  38. package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
  39. package/src/skf-create-skill/references/validate.md +1 -1
  40. package/src/skf-create-stack-skill/SKILL.md +1 -0
  41. package/src/skf-create-stack-skill/references/generate-output.md +1 -1
  42. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  43. package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
  44. package/src/skf-create-stack-skill/references/report.md +1 -1
  45. package/src/skf-drop-skill/SKILL.md +1 -0
  46. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  47. package/src/skf-drop-skill/references/report.md +1 -1
  48. package/src/skf-export-skill/SKILL.md +1 -0
  49. package/src/skf-export-skill/references/invocation-contract.md +1 -1
  50. package/src/skf-export-skill/references/summary.md +1 -1
  51. package/src/skf-forger/SKILL.md +1 -0
  52. package/src/skf-forger/references/pipeline-mode.md +2 -2
  53. package/src/skf-quick-skill/SKILL.md +2 -1
  54. package/src/skf-quick-skill/references/finalize.md +1 -1
  55. package/src/skf-quick-skill/references/halt-contract.md +2 -2
  56. package/src/skf-quick-skill/references/write-and-validate.md +1 -1
  57. package/src/skf-refine-architecture/SKILL.md +2 -1
  58. package/src/skf-refine-architecture/customize.toml +1 -1
  59. package/src/skf-refine-architecture/references/report.md +1 -1
  60. package/src/skf-rename-skill/SKILL.md +1 -0
  61. package/src/skf-rename-skill/references/invocation-contract.md +2 -2
  62. package/src/skf-setup/SKILL.md +1 -0
  63. package/src/skf-setup/references/ccc-index.md +1 -1
  64. package/src/skf-setup/references/invocation-contract.md +1 -1
  65. package/src/skf-test-skill/SKILL.md +1 -0
  66. package/src/skf-test-skill/assets/output-section-formats.md +4 -4
  67. package/src/skf-test-skill/references/coherence-check.md +8 -6
  68. package/src/skf-test-skill/references/coverage-check-branches.md +23 -3
  69. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  70. package/src/skf-test-skill/references/coverage-check.md +30 -43
  71. package/src/skf-test-skill/references/init.md +1 -1
  72. package/src/skf-test-skill/references/inventory-contract.md +27 -0
  73. package/src/skf-test-skill/references/invocation-contract.md +3 -3
  74. package/src/skf-test-skill/references/report.md +3 -3
  75. package/src/skf-test-skill/references/scoring-rules.md +6 -6
  76. package/src/skf-test-skill/references/signature-comparison-rules.md +72 -0
  77. package/src/skf-test-skill/references/source-access-protocol.md +12 -8
  78. package/src/skf-test-skill/references/step-hard-gate.md +1 -0
  79. package/src/skf-test-skill/scripts/build-result-context.py +20 -3
  80. package/src/skf-test-skill/scripts/gap-ledger.py +78 -13
  81. package/src/skf-test-skill/scripts/load-coverage-inputs.py +345 -9
  82. package/src/skf-test-skill/scripts/locate-export-segments.py +591 -63
  83. package/src/skf-test-skill/scripts/score-signatures.py +939 -74
  84. package/src/skf-test-skill/scripts/validate-inventory.py +245 -29
  85. package/src/skf-update-skill/SKILL.md +1 -0
  86. package/src/skf-update-skill/references/gap-driven.md +28 -22
  87. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  88. package/src/skf-update-skill/references/merge.md +4 -4
  89. package/src/skf-update-skill/references/report.md +1 -1
  90. package/src/skf-update-skill/references/write.md +5 -4
  91. 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,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
@@ -54,7 +113,10 @@ reading files and writing one JSON object:
54
113
  glob that matches it (`excludeGlob`) or no
55
114
  scope.include glob covering it
56
115
  (`outsideInclude`); exports[] only gives each name's
57
- kind, line and signature line, looked up by the
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
58
120
  name its defining file gives it (an aliased
59
121
  re-export's `local`) and that file.
60
122
  extraction_gaps are listed to read by eye, and
@@ -142,7 +204,13 @@ reconcile-coverage.py and score-signatures.py --surface-set pick one.
142
204
 
143
205
  Candidates and guards (with an extraction, --brief or --metadata): each
144
206
  set's count, the metadata `stats.effective_denominator` (--metadata), and
145
- the extractor's own effective_denominator and its basis. Both guards run
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
146
214
  only on a surface read from the source. The deflation guard compares a
147
215
  re-derived set with `stats.effective_denominator` and names it in `basis`.
148
216
  It is the tier_a_include set (`tier_a_include`) when the clause's ladder
@@ -253,6 +321,7 @@ a stylesheet, holds nothing to read and is in neither list.
253
321
 
254
322
  Usage:
255
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>]
256
325
  uv run load-coverage-inputs.py metadata --metadata <metadata.json> \\
257
326
  [--provenance <provenance-map.json>] [--output <file>]
258
327
  uv run load-coverage-inputs.py surface [--extraction <file>] [--quick <file>]... \\
@@ -264,7 +333,8 @@ Usage:
264
333
  Exit codes:
265
334
  0 the result was printed (and written to --output)
266
335
  1 an input file is missing, unreadable or not the JSON (or the YAML
267
- 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)
268
338
  2 a per-file result breaks the schema: {"valid": false, "violations":
269
339
  [...]} names each breach and nothing is written to --output; or a
270
340
  usage error (argparse)
@@ -294,6 +364,40 @@ CITATION_FORMS = {
294
364
  "DOC": "[DOC:",
295
365
  }
296
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*)*)")
297
401
  # Bookkeeping variants folded to their base name, in rule order.
298
402
  FOLD_SUFFIXES = ("_def", "_exact")
299
403
  FOLD_PREFIX = "a11y_"
@@ -334,7 +438,9 @@ SHARED_SCRIPTS = Path(__file__).resolve().parent.parent.parent / "shared" / "scr
334
438
 
335
439
  def _sibling(filename: str, folder: Path | None = None):
336
440
  """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
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
338
444
  `folder`, one in that folder: SHARED_SCRIPTS'
339
445
  skf-resolve-authoritative-files.py (a skill brief's YAML)."""
340
446
  module = _SIBLINGS.get(filename)
@@ -421,6 +527,199 @@ def census(skill_dir: str) -> dict:
421
527
  }
422
528
 
423
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
+
424
723
  # --------------------------------------------------------------------------
425
724
  # metadata
426
725
  # --------------------------------------------------------------------------
@@ -577,6 +876,14 @@ def matching(records: list[dict], globs: list[str], excludes: list[str]) -> list
577
876
  and not any(glob_match(_norm(r["file"]), g) for g in excludes)})
578
877
 
579
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
+
580
887
  def _apart(a: str, b: str) -> bool:
581
888
  """True when, before either has a `*` or `?`, the two globs spell a different segment at the same
582
889
  place, so no file can match both. Any other pair counts as overlapping, even one no file can match
@@ -735,7 +1042,7 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
735
1042
  "staleScope": {"unmatchedInclude": unmatched, "unmatchedTierAInclude": unmatched_tier_a,
736
1043
  "restored": []},
737
1044
  "gaps": [], "scope": scope, "scopeType": recorded.get("type"), "umbrella": dict(NO_UMBRELLA),
738
- "extraction": extraction}
1045
+ "subpathEntries": None, "extraction": extraction}
739
1046
 
740
1047
  diff = data.get("entry_point_diff") if isinstance(data.get("entry_point_diff"), dict) else {}
741
1048
  # Each in-scope definition of a public name is a record of its own (#830): the runner's other
@@ -760,6 +1067,11 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
760
1067
  subpath_files = {e.get("file") for e in entry_files
761
1068
  if e.get("subpath") not in ROOT_SUBPATHS and "*" not in str(e.get("subpath"))}
762
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}
763
1075
 
764
1076
  # A scope.include glob that matches no file: the brief predates the
765
1077
  # source, so a root export defined in a file no glob covers comes back,
@@ -790,10 +1102,16 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
790
1102
  # under the name its defining file gives it, `local`.
791
1103
  found = index.get((info.get("local") or name, file)) or index.get((name, file))
792
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
793
1113
  (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"))
1114
+ _record(name, None if kind == "re-export" else kind, file, line, signature_line, "extraction"))
797
1115
 
798
1116
  # The umbrella ratio reads the public rows alone: an other definition is no root barrel name there.
799
1117
  root_names = [p for p in _members_in_place(rows["public"]) if p.get("entry") in root_files]
@@ -824,6 +1142,8 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
824
1142
  "ratio": round2(len(reexported) / len(root_names)) if root_names else None,
825
1143
  "umbrella": bool(root_names) and len(reexported) / len(root_names) > UMBRELLA_RATIO,
826
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,
827
1147
  "extraction": extraction,
828
1148
  }
829
1149
 
@@ -1065,13 +1385,17 @@ def surface(args: argparse.Namespace) -> dict:
1065
1385
  and not (scope["include"] and all(_apart(g, i) for i in scope["include"]))
1066
1386
  and not any(_inside(g, e) for e in scope["exclude"])]
1067
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]] = {}
1068
1390
  if filed and scope["include"]:
1069
1391
  sets["scope.include"] = matching(filed, scope["include"], scope["exclude"])
1392
+ set_files["scope.include"] = matching_files(filed, scope["include"], scope["exclude"])
1070
1393
  in_include = [r for r in filed if not scope["include"]
1071
1394
  or any(glob_match(_norm(r["file"]), g) for g in scope["include"])]
1072
1395
  tier_a_names = matching(in_include, tier_a, scope["exclude"]) if tier_a else []
1073
1396
  if tier_a_names:
1074
1397
  sets["tier_a_include"] = tier_a_names
1398
+ set_files["tier_a_include"] = matching_files(in_include, tier_a, scope["exclude"])
1075
1399
 
1076
1400
  # The clause: an extraction's entry-point status (one the per-file scan replaces says only that it found no
1077
1401
  # entry point), else a quick output's (an export is a barrel; none, and no warning, an empty one), else what
@@ -1119,7 +1443,10 @@ def surface(args: argparse.Namespace) -> dict:
1119
1443
  "statsEffectiveDenominator": stats_eff,
1120
1444
  "tierAIncludeUnion": len(sets["tier_a_include"]) if "tier_a_include" in sets else None,
1121
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,
1122
1448
  "subpathUnion": len(sets["subpaths"]) if "subpaths" in sets else None,
1449
+ "subpathEntries": extracted["subpathEntries"] if extracted is not None else None,
1123
1450
  "rootBarrel": len(sets["root"]) if "root" in sets else None,
1124
1451
  "extractorEffectiveDenominator": extraction.get("effectiveDenominator"),
1125
1452
  "extractorBasis": extraction.get("effectiveDenominatorBasis"),
@@ -1201,6 +1528,10 @@ def _cmd_census(args: argparse.Namespace) -> dict:
1201
1528
  return census(args.skill_dir)
1202
1529
 
1203
1530
 
1531
+ def _cmd_floor(args: argparse.Namespace) -> dict:
1532
+ return floor(args.skill_dir)
1533
+
1534
+
1204
1535
  def _cmd_metadata(args: argparse.Namespace) -> dict:
1205
1536
  meta = _object(args.metadata, "--metadata")
1206
1537
  provenance = _object(args.provenance, "--provenance") if args.provenance else None
@@ -1219,6 +1550,11 @@ def _build_parser() -> argparse.ArgumentParser:
1219
1550
  p.add_argument("--output", help="also write the result to this file")
1220
1551
  p.set_defaults(func=_cmd_census)
1221
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
+
1222
1558
  p = sub.add_parser("metadata", help="the metadata.json and provenance-map counts")
1223
1559
  p.add_argument("--metadata", required=True, help="the skill's metadata.json")
1224
1560
  p.add_argument("--provenance", help="the provenance map, when there is one")