bmad-module-skill-forge 6.0.0 → 8.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 (82) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/docs/_data/pinned.yaml +9 -10
  4. package/docs/campaign.md +2 -2
  5. package/docs/skill-model.md +3 -3
  6. package/docs/verifying-a-skill.md +17 -18
  7. package/docs/workflows.md +10 -10
  8. package/package.json +1 -1
  9. package/src/shared/references/description-guard-protocol.md +12 -10
  10. package/src/shared/references/output-contract-schema.md +1 -1
  11. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +1 -1
  12. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +1 -1
  13. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +38 -0
  14. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +5 -0
  15. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +1 -1
  16. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +1 -1
  17. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +8 -3
  18. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +1 -1
  19. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +6 -1
  20. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  21. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +7 -2
  22. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +1 -1
  23. package/src/shared/scripts/skf-parse-gaps.py +4 -1
  24. package/src/shared/scripts/skf-quick-batch.py +24 -7
  25. package/src/shared/scripts/skf-verify-provenance-completeness.py +809 -85
  26. package/src/skf-analyze-source/customize.toml +3 -2
  27. package/src/skf-analyze-source/references/auto-docs-only.md +2 -2
  28. package/src/skf-analyze-source/references/generate-briefs.md +5 -3
  29. package/src/skf-analyze-source/references/headless-contract.md +2 -2
  30. package/src/skf-analyze-source/references/step-auto-scope.md +2 -2
  31. package/src/skf-audit-skill/customize.toml +4 -3
  32. package/src/skf-audit-skill/references/report.md +1 -1
  33. package/src/skf-campaign/assets/campaign-state-schema.json +58 -0
  34. package/src/skf-campaign/references/campaign-contracts.md +1 -1
  35. package/src/skf-campaign/references/step-06-batch.md +2 -2
  36. package/src/skf-campaign/references/step-07-capstone.md +3 -3
  37. package/src/skf-campaign/references/step-08-verify.md +3 -3
  38. package/src/skf-campaign/references/step-09-refine.md +3 -3
  39. package/src/skf-campaign/references/step-11-maintenance.md +1 -1
  40. package/src/skf-campaign/scripts/campaign-render-batch.py +43 -10
  41. package/src/skf-campaign/scripts/campaign-report.py +60 -4
  42. package/src/skf-campaign/scripts/campaign-state.py +82 -17
  43. package/src/skf-campaign/templates/campaign-report-template.md +4 -0
  44. package/src/skf-create-skill/SKILL.md +2 -2
  45. package/src/skf-create-skill/assets/skill-sections.md +2 -2
  46. package/src/skf-create-skill/customize.toml +4 -4
  47. package/src/skf-create-skill/references/batch-mode.md +3 -1
  48. package/src/skf-create-skill/references/report.md +9 -4
  49. package/src/skf-create-skill/references/validate.md +49 -29
  50. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  51. package/src/skf-create-stack-skill/references/report.md +3 -3
  52. package/src/skf-create-stack-skill/references/validate.md +21 -8
  53. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  54. package/src/skf-export-skill/references/summary.md +1 -1
  55. package/src/skf-quick-skill/SKILL.md +1 -1
  56. package/src/skf-quick-skill/references/batch-mode.md +30 -2
  57. package/src/skf-quick-skill/references/compile.md +2 -0
  58. package/src/skf-quick-skill/references/finalize.md +3 -2
  59. package/src/skf-quick-skill/references/halt-contract.md +3 -3
  60. package/src/skf-quick-skill/references/quick-extract.md +2 -1
  61. package/src/skf-quick-skill/references/write-and-validate.md +27 -11
  62. package/src/skf-refine-architecture/customize.toml +1 -1
  63. package/src/skf-refine-architecture/references/report.md +2 -2
  64. package/src/skf-rename-skill/references/report.md +1 -1
  65. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  66. package/src/skf-test-skill/references/coverage-check.md +4 -4
  67. package/src/skf-test-skill/references/inventory-contract.md +1 -1
  68. package/src/skf-test-skill/references/invocation-contract.md +1 -1
  69. package/src/skf-test-skill/references/report.md +1 -1
  70. package/src/skf-test-skill/references/scoring-rules.md +2 -2
  71. package/src/skf-test-skill/references/signature-comparison-rules.md +7 -2
  72. package/src/skf-test-skill/scripts/gap-ledger.py +196 -46
  73. package/src/skf-test-skill/scripts/load-coverage-inputs.py +253 -17
  74. package/src/skf-test-skill/scripts/score-signatures.py +9 -13
  75. package/src/skf-test-skill/scripts/validate-inventory.py +91 -12
  76. package/src/skf-update-skill/customize.toml +1 -1
  77. package/src/skf-update-skill/references/gap-driven.md +1 -1
  78. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  79. package/src/skf-update-skill/references/merge.md +1 -1
  80. package/src/skf-update-skill/references/report.md +27 -9
  81. package/src/skf-update-skill/references/write.md +24 -12
  82. package/src/skf-verify-stack/references/report.md +1 -1
@@ -21,12 +21,15 @@ reading files and writing one JSON object:
21
21
  floor the documented inventory's floor (#790): every name SKILL.md
22
22
  and each .md file directly in references/ (a subfolder is not
23
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
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
26
27
  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
28
+ everything from the first `(`, `<` or `[` are cut, so
29
+ `Meta<T>`, `App[P, R]`, `fetch(url): Promise<T>` and
30
+ `new Client(opts)` give `Meta`, `App`, `fetch` and `Client`;
31
+ an empty `[]` at the cut (a TS array type, `Foo[]`) gives
32
+ none. A dotted or `::` name gives none, so a
30
33
  namespace-style skill such as zod (`z.object`) gets no table
31
34
  floor. The forms, read outside the frontmatter and HTML
32
35
  comments (a table row or heading inside fenced code gives none):
@@ -71,11 +74,74 @@ reading files and writing one JSON object:
71
74
  `type (`, `var (` or `const (` group too. A
72
75
  block in another language, an untagged one or a
73
76
  data block (json, yaml, text...) gives none.
77
+ Signatures (#888): an origin whose place writes the name's
78
+ signature in a fixed form that is surely complete also carries
79
+ it as `signature`, {"params", "return_type"}, two strings, each
80
+ one's whitespace runs read as one space, which
81
+ validate-inventory.py --floor refuses an inventory for
82
+ changing; any other place pins nothing, since a wrong pin would
83
+ be forced into the inventory. `params` is the text inside the
84
+ parameter group (score-signatures.py's _group: the first pair
85
+ of parentheses outside every bracket pair and string), and
86
+ `return_type` the type written after it (score-signatures.py's
87
+ _signature_parts), "" for none. A form that writes neither
88
+ (`f()`, `useQuery<T>()`) gives none, so an empty call claims no
89
+ form. A span (a table cell's or a heading's) is complete when
90
+ it is the name (or no name, as a Signature cell's `(x: A) =>
91
+ B`), its type parameters, one parameter group, then nothing or
92
+ a return separator (`->`, `=>`, `→` or `:`) and the return, with
93
+ nothing else: `useQuery(key).data`, `f(a) / f(a, b)`, a
94
+ trailing comment, a `;` or `,` outside the return's brackets,
95
+ an unclosed bracket, `Meta<T>` (no group) and `new Client(opts)`
96
+ give none. By form:
97
+ table-row a row whose first cell names one name (a row
98
+ naming several gives none), each cell's `\\|`
99
+ read as `|`: the leading code span of its
100
+ Signature or Signatures cell, else, when that
101
+ span writes no parameter group (`Typed<X>`),
102
+ the one span of its first cell that gives the
103
+ name (another export's span there, as in
104
+ `fetch` (same as `http.request(opts)`), gives
105
+ nothing). A Signature span with a group that is
106
+ no complete form (`client.get(k) -> str`,
107
+ `new Client(opts)`) gives none, so a shorter
108
+ first-cell call never stands in for it. A header
109
+ cell matches whole, so a labelled one is not a
110
+ Signature column, and a Props, Params or
111
+ Parameters cell is never read (its shared
112
+ members and usage forms stay the reader's).
113
+ heading its code span
114
+ declaration a function's (`function`, `def`, `fn` or Go's
115
+ `func` before its name; a `const` holding an
116
+ arrow gives none): its lines as one, as
117
+ skf-extract-public-api.py's
118
+ _declaration_signature joins them (a parameter
119
+ list split over lines joined, line comments
120
+ left out), the body cut off first, at the first
121
+ `:` (Python) or `{`, `;` or ` where ` (any other
122
+ language) outside every bracket pair and string
123
+ after the parameter group, a `{` directly after
124
+ a TS return `:` opening the return type
125
+ (`(): { a: T } {` returns `{ a: T }`). None for
126
+ one with anything but type parameters between
127
+ its name and the group (a Go `func(...)`
128
+ return), one whose opener follows, or whose
129
+ uncut text ends with, `interface`, `struct`,
130
+ `is`, `keyof`, `|`, `&`, `?`, `:`, `\\`, `=>` or
131
+ `->` (the opener or the line end is inside the
132
+ return type: Go's `interface{}`, a TS `asserts
133
+ x is {...}`, `() => {...}` or conditional type,
134
+ `): Promise<A> |`), or one whose brackets or
135
+ strings do not all close.
136
+ A form written only in prose is never read: it stays the
137
+ reader's. Each origin keeps the first signature its place
138
+ writes.
74
139
  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.
140
+ "origins": [{"file", "line", "form", "signature"?}]}],
141
+ "summary": {"names", "byForm"}}, names sorted, each one's
142
+ origins in file order then line, `signature` only on an origin
143
+ that has one, `byForm` counting the origins of each form; the
144
+ same skill gives the same bytes.
79
145
 
80
146
  metadata the metadata.json and provenance-map counts. Cluster A
81
147
  (`stats.exports_public_api`, the `exports[]` length) and Cluster
@@ -371,6 +437,23 @@ FLOOR_TABLE_HEADERS = frozenset({
371
437
  "export", "exports", "symbol", "symbols", "function", "functions", "class", "classes", "interface", "interfaces",
372
438
  "type", "types", "hook", "hooks", "component", "components", "helper", "helpers", "constant", "constants",
373
439
  "enum", "enums", "api", "apis"})
440
+ # The header words (as _header_word reads them, whole) of a floor table's column that writes the row's signature
441
+ # (#888); a labelled header, such as `Signature (simplified)`, is not one.
442
+ FLOOR_SIGNATURE_HEADERS = frozenset({"signature", "signatures"})
443
+ # A complete form's name, before its type parameters and parameter group (group 1, absent for none).
444
+ SPAN_NAME_RE = re.compile(r"\s*([A-Za-z_$][\w$]*)?\s*")
445
+ # What a complete form writes after its parameter group before its return: `->`, `=>`, `→` or `:` (not `::`).
446
+ RETURN_SEPARATOR_RE = re.compile(r"\s*(?:->|=>|→|:(?!:))")
447
+ # A declaration that gets a form: a function keyword before its name, on its masked line.
448
+ FUNCTION_KEYWORD_RE = re.compile(r"(?<![\w$])(?:function|def|fn|func)(?![\w$])")
449
+ # After a declaration's parameter group: the `:` a TS return type follows (a `{` right after it opens the type, not
450
+ # the body), and a Rust `where` clause, which the body comes after.
451
+ RETURN_COLON_RE = re.compile(r"\s*:\s*")
452
+ WHERE_CLAUSE_RE = re.compile(r"\s+where(?![\w$])")
453
+ # A declaration's text that ends in the middle of its return type: a body opener after one of these is part of that
454
+ # type, cut short (Go's `interface{}` and `struct{...}`, a TS union, intersection, `asserts x is {...}`, `keyof {...}`,
455
+ # conditional or function type), and so is a declaration that ends with one (`): Promise<A> |`, Python's `\`).
456
+ OPENER_IN_TYPE_RE = re.compile(r"(?:(?<![\w$])(?:interface|struct|is|keyof)|[|&?:\\]|=>|->)\s*$")
374
457
  # A header cell wrapped in `**` or `__` (group 2 its text).
375
458
  EMPHASIS_RE = re.compile(r"(\*\*|__)(.+)\1")
376
459
  PLAIN_IDENTIFIER_RE = re.compile(r"[A-Za-z_$][\w$]*")
@@ -533,12 +616,16 @@ def census(skill_dir: str) -> dict:
533
616
 
534
617
 
535
618
  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."""
619
+ """The plain identifier `text` writes once a leading `new ` and everything from the first `(`, `<` or `[` are
620
+ cut; None for any other text, a dotted or `::` name and an array type (`Foo[]`, an empty `[]` at the cut)
621
+ among them."""
538
622
  text = text.strip()
539
623
  if text.startswith("new "):
540
624
  text = text[4:]
541
- text = re.split(r"[(<]", text, maxsplit=1)[0].strip()
625
+ cut = re.match(r"([^(<\[]*)(\[\s*\])?", text)
626
+ if cut.group(2):
627
+ return None
628
+ text = cut.group(1).strip()
542
629
  return text if PLAIN_IDENTIFIER_RE.fullmatch(text) else None
543
630
 
544
631
 
@@ -650,6 +737,144 @@ def _declared_names(lines: list[str], language: str, js_family) -> list[tuple[in
650
737
  return found
651
738
 
652
739
 
740
+ def _form(text: str, group: tuple[int, int], extension: str = "") -> dict | None:
741
+ """{"params", "return_type"} of a form whose parameter group is `group`, whitespace runs read as one space: the
742
+ text inside the group, and the return after it (score-signatures.py's _signature_parts), "" for none; None for a
743
+ form that writes neither (`f()`)."""
744
+ form = {"params": " ".join(text[group[0] + 1:group[1]].split()),
745
+ "return_type": " ".join(_sibling("score-signatures.py")._signature_parts(text, extension)[1].split())}
746
+ return form if form["params"] or form["return_type"] else None
747
+
748
+
749
+ def _type_parameters_only(text: str) -> bool:
750
+ """Whether `text` is empty or one `<...>` or `[...]` pair, the type parameters a form writes before its group."""
751
+ text = text.strip()
752
+ if not text:
753
+ return True
754
+ if text[0] not in "<[" or text[-1] != {"<": ">", "[": "]"}[text[0]]:
755
+ return False
756
+ depth = 0
757
+ for i, ch in enumerate(text):
758
+ if ch in "<[(":
759
+ depth += 1
760
+ elif ch in ">])" and not (ch == ">" and text[i - 1:i] in ("-", "=")):
761
+ depth -= 1
762
+ if depth == 0 and i != len(text) - 1:
763
+ return False
764
+ return depth == 0
765
+
766
+
767
+ def _call_form(span: str, name: str | None) -> dict | None:
768
+ """The signature a span writes when it is a complete form (_form): `name` or no name (a Signature cell's
769
+ `(x: A) => B`), its type parameters, one parameter group (score-signatures.py's _group), then nothing, or a
770
+ return separator (`->`, `=>`, `→` or `:`) and the return, with nothing else: no other text after the group
771
+ (`useQuery(key).data`, `f(a) / f(a, b)`, a trailing comment), no `;`, `,`, `#`, `//` or `/*` in the return
772
+ outside its brackets and strings, and every bracket and string closed. None for any other span, a constructor
773
+ call (`new Client(opts)`) and a span with no parameter group (`Meta<T>`) among them."""
774
+ scorer = _sibling("score-signatures.py")
775
+ text = span.strip()
776
+ group = scorer._group(text)
777
+ if group is None or text.startswith("new ") or scorer._levels(text + " ")[-1] != 0:
778
+ return None
779
+ head = SPAN_NAME_RE.match(text, 0, group[0])
780
+ if head.group(1) not in (None, name) or not _type_parameters_only(text[head.end():group[0]]):
781
+ return None
782
+ tail = text[group[1] + 1:]
783
+ separator = RETURN_SEPARATOR_RE.match(tail)
784
+ returned = tail[separator.end():] if separator else ""
785
+ if (separator is None and tail.strip()) or (separator is not None and not returned.strip()):
786
+ return None
787
+ levels = scorer._levels(returned)
788
+ if any(levels[i] == 0 and (ch in ";,#" or returned.startswith(("//", "/*"), i)) for i, ch in enumerate(returned)):
789
+ return None
790
+ return _form(text, group)
791
+
792
+
793
+ def _leading_span(cell: str) -> str | None:
794
+ """The code span a table cell opens with, None when it opens with none."""
795
+ run = len(cell) - len(cell.lstrip("`"))
796
+ spans = _sibling("locate-export-segments.py")._inline_code(cell) if run else []
797
+ return spans[0] if spans and cell.startswith("`" * run + spans[0] + "`" * run) else None
798
+
799
+
800
+ def _row_form(header: list[str], row: list[str]) -> dict | None:
801
+ """The signature a floor table's row writes for the one name its first cell names (a row naming several gives
802
+ none), each cell's `\\|` read as `|`: the leading code span of its Signature or Signatures cell, else, when that
803
+ span writes no parameter group, the one span of its first cell that gives that name (another export's span there
804
+ gives nothing), each a complete form (_call_form). A Signature span with a group that is no complete form gives
805
+ none. A Props, Params or Parameters cell is never read."""
806
+ segments = _sibling("locate-export-segments.py")
807
+ names = {name for name in map(_floor_name, segments._inline_code(row[0])) if name}
808
+ if len(names) != 1:
809
+ return None
810
+ [name] = names
811
+ cells = [cell.replace("\\|", "|") for cell in row]
812
+ at = next((i for i, cell in enumerate(header) if _header_word(cell) in FLOOR_SIGNATURE_HEADERS), None)
813
+ span = _leading_span(cells[at]) if at is not None and at < len(cells) else None
814
+ if span is not None and (form := _call_form(span, name)):
815
+ return form
816
+ if span is not None and _sibling("score-signatures.py")._group(span.strip()) is not None:
817
+ return None # the cell writes a call the floor cannot pin: a shorter first-cell form would hide it
818
+ own = [span for span in segments._inline_code(cells[0]) if _floor_name(span) == name]
819
+ return _call_form(own[0], name) if len(own) == 1 else None
820
+
821
+
822
+ def _function_declaration(line: str, language: str, js_family) -> int | None:
823
+ """Where the name ends on a block's masked line, one _declared_names reads, that declares a function (`function`,
824
+ `def`, `fn` or Go's `func` before its name); None for a line that declares no function."""
825
+ if language in js_family:
826
+ m, at = JS_DECLARATION_RE.match(line), 1
827
+ elif language in PYTHON_LANGUAGES:
828
+ m, at = PYTHON_DECLARATION_RE.match(line), 1
829
+ elif language in RUST_LANGUAGES:
830
+ m, at = RUST_DECLARATION_RE.match(line), 1
831
+ elif language in GO_LANGUAGES:
832
+ m, at = GO_DECLARATION_RE.match(line), 2
833
+ else:
834
+ return False
835
+ return m.end(at) if m and FUNCTION_KEYWORD_RE.search(line, 0, m.start(at)) else None
836
+
837
+
838
+ def _declaration_form(rows: list[str], offset: int, language: str, name_end: int) -> dict | None:
839
+ """The signature the function declaration at `offset` of a block's unmasked `rows` writes: the declaration on one
840
+ line (skf-extract-public-api.py's _declaration_signature: a parameter list split over lines joined, line comments
841
+ left out), its body cut at the first `:` (Python) or `{`, `;` or ` where ` (any other language) outside every
842
+ bracket pair and string after the parameter group, a `{` directly after a TS return `:` opening the return type,
843
+ then read as _form reads it. None when anything but type parameters sits between the name's end (`name_end`,
844
+ the masked line's offset, which masking keeps) and the group (a Go `func(...)` return read as the group), when
845
+ the opener follows, or the uncut text ends with, `interface`, `struct`, `is`, `keyof`, `|`, `&`, `?`, `:`, `\\`,
846
+ `=>` or `->` (it is inside the return type: Go's `interface{}`, a TS `asserts x is {...}`, `() => {...}` or
847
+ conditional type, a line that ends mid-type), or a bracket or string it opens does not close."""
848
+ scorer = _sibling("score-signatures.py")
849
+ extractor = _sibling("skf-extract-public-api.py", SHARED_SCRIPTS)
850
+ python, rust = language in PYTHON_LANGUAGES, language in RUST_LANGUAGES
851
+ extension = ".rs" if rust else ""
852
+ text = extractor._declaration_signature(rows, offset, "python" if python else "rust" if rust else language)
853
+ group = scorer._group(text, extension)
854
+ if group is None or not _type_parameters_only(text[name_end:group[0]]):
855
+ return None
856
+ levels = scorer._levels(text, extension)
857
+ for i in range(group[1] + 1, len(text)):
858
+ if levels[i] != 0:
859
+ continue
860
+ if python:
861
+ body = text[i] == ":"
862
+ else:
863
+ body = (text[i] == ";" or bool(WHERE_CLAUSE_RE.match(text, i))
864
+ or (text[i] == "{" and not RETURN_COLON_RE.fullmatch(text, group[1] + 1, i)))
865
+ if body:
866
+ if OPENER_IN_TYPE_RE.search(text, 0, i):
867
+ return None
868
+ text = text[:i]
869
+ break
870
+ else:
871
+ if OPENER_IN_TYPE_RE.search(text):
872
+ return None
873
+ if scorer._levels(text + " ", extension)[-1] != 0:
874
+ return None
875
+ return _form(text, group, extension)
876
+
877
+
653
878
  def floor(skill_dir: str) -> dict:
654
879
  """The names the skill documents in a fixed form, each with its origins (see the module docstring)."""
655
880
  root = Path(skill_dir)
@@ -670,10 +895,14 @@ def floor(skill_dir: str) -> dict:
670
895
  for lines in texts for line in lines for m in inventory._SKILL_CITATION_RE.finditer(line)}
671
896
 
672
897
  found: dict[str, set[tuple[int, int, str, str | None]]] = {}
898
+ signatures: dict[tuple[str, int, int, str], dict] = {} # the first per (name, file, line, form), in span order
673
899
 
674
- def add(name: str | None, file_index: int, index: int, form: str, label: str | None = None) -> None:
900
+ def add(name: str | None, file_index: int, index: int, form: str, label: str | None = None,
901
+ signature: dict | None = None) -> None:
675
902
  if name:
676
903
  found.setdefault(name, set()).add((file_index, index + 1, form, label))
904
+ if signature is not None:
905
+ signatures.setdefault((name, file_index, index + 1, form), signature)
677
906
 
678
907
  js_family = segments.JS_FAMILY - {"svelte"} # a Svelte block's `export let` is a component prop
679
908
  for file_index, lines in enumerate(texts):
@@ -689,12 +918,13 @@ def floor(skill_dir: str) -> dict:
689
918
  row = _cells(lines[block["start"]])
690
919
  kind_at = next((i for i, cell in enumerate(header) if _header_word(cell) == "kind"), None)
691
920
  label = row[kind_at] if kind_at is not None and kind_at < len(row) and row[kind_at] else None
921
+ signature = _row_form(header, row)
692
922
  for span in segments._inline_code(row[0]):
693
- add(_floor_name(span), file_index, block["start"], "table-row", label)
923
+ add(_floor_name(span), file_index, block["start"], "table-row", label, signature)
694
924
  for index, _level, text, _above in headings:
695
925
  span = _whole_span(text, segments)
696
926
  if span is not None:
697
- add(_floor_name(span), file_index, index, "heading")
927
+ add(_floor_name(span), file_index, index, "heading", None, _call_form(span, _floor_name(span)))
698
928
  comments = _html_comment_lines(lines, start, code)
699
929
  for opening, closing, info in blocks:
700
930
  language = segments._language(info)
@@ -703,7 +933,10 @@ def floor(skill_dir: str) -> dict:
703
933
  continue
704
934
  masked = segments.mask_code("\n".join(lines[opening + 1:closing]), language).split("\n")
705
935
  for offset, name in _declared_names(masked, language, js_family):
706
- add(name, file_index, opening + 1 + offset, "declaration")
936
+ name_end = _function_declaration(masked[offset], language, js_family)
937
+ signature = (_declaration_form(lines[opening + 1:closing], offset, language, name_end)
938
+ if name_end is not None else None)
939
+ add(name, file_index, opening + 1 + offset, "declaration", None, signature)
707
940
 
708
941
  names = []
709
942
  by_form = dict.fromkeys(FLOOR_FORMS, 0)
@@ -713,7 +946,10 @@ def floor(skill_dir: str) -> dict:
713
946
  for file_index, line, form, _label in origins:
714
947
  if (file_index, line, form) not in seen:
715
948
  seen.add((file_index, line, form))
716
- kept.append({"file": files[file_index], "line": line, "form": form})
949
+ origin = {"file": files[file_index], "line": line, "form": form}
950
+ if (name, file_index, line, form) in signatures:
951
+ origin["signature"] = signatures[(name, file_index, line, form)]
952
+ kept.append(origin)
717
953
  by_form[form] += 1
718
954
  label = next((o[3] for o in origins if o[2] == "table-row" and o[3]), None)
719
955
  names.append({"name": name, "kindLabel": label, "origins": kept})
@@ -245,7 +245,6 @@ from pathlib import Path, PurePosixPath
245
245
  # Surface kinds that count toward total_types. A Rust struct is a class and a
246
246
  # trait an interface; `type` is a type alias.
247
247
  TYPE_KINDS = frozenset({"interface", "type", "enum", "class", "struct", "trait"})
248
- SIGNATURE_FIELDS = ("params", "return_type", "usage_signature")
249
248
  MISMATCH_FIELDS = ("name", "line", "source_sig", "documented_sig", "issue")
250
249
  # The optional `difference` a judge tags a mismatch with, defined in
251
250
  # references/signature-comparison-rules.md (Difference tags): only the
@@ -518,7 +517,7 @@ def _has_signature(entry: dict) -> bool:
518
517
  return True
519
518
  return any(
520
519
  isinstance(entry.get(f), list) or (isinstance(entry.get(f), str) and entry[f].strip())
521
- for f in SIGNATURE_FIELDS
520
+ for f in _inventory().SIGNATURE_FIELDS
522
521
  )
523
522
 
524
523
 
@@ -552,26 +551,23 @@ def _rank(fields: dict) -> tuple[bool, bool, bool]:
552
551
 
553
552
  def _form(entry: dict) -> dict:
554
553
  """The documented signature fields of one inventory entry, with its `signature_form` when it has one."""
555
- fields = {f: entry[f] for f in SIGNATURE_FIELDS if f in entry}
554
+ fields = {f: entry[f] for f in _inventory().SIGNATURE_FIELDS if f in entry}
556
555
  if entry.get("signature_form") is not None:
557
556
  fields["signature_form"] = entry["signature_form"]
558
557
  return fields
559
558
 
560
559
 
561
560
  def _field_text(value: object) -> str:
562
- """A signature field as one text: a list's items joined with `, `, whitespace runs as one space, "" for null."""
563
- if value is None:
564
- return ""
565
- if isinstance(value, list):
566
- value = ", ".join(v if isinstance(v, str) else json.dumps(v, sort_keys=True) for v in value)
567
- return " ".join(value.split()) if isinstance(value, str) else json.dumps(value, sort_keys=True)
561
+ """A signature field as one text (validate-inventory.py's field_text, which the floor check reads too): a
562
+ list's items joined with `, `, whitespace runs as one space, "" for null."""
563
+ return _inventory().field_text(value)
568
564
 
569
565
 
570
566
  def _form_key(fields: dict) -> tuple:
571
- """What a form says, for telling two entries apart (_field_text), a
572
- null, blank or empty key read as absent, and the `signature_form` label
573
- left out (documented_forms keeps the full one)."""
574
- return tuple((f, _field_text(fields[f])) for f in SIGNATURE_FIELDS if f in fields and _field_text(fields[f]))
567
+ """What a form says, for telling two entries apart (validate-inventory.py's form_key, which the floor check
568
+ reads too): a null, blank or empty key read as absent, and the `signature_form` label left out
569
+ (documented_forms keeps the full one)."""
570
+ return _inventory().form_key(fields)
575
571
 
576
572
 
577
573
  def documented_forms(inventory: list[dict]) -> dict[str, list[dict]]:
@@ -53,7 +53,19 @@ kept when any inventory entry has that name, whatever its kind: kinds stay
53
53
  the reader's. Each checked name not
54
54
  kept is a violation naming its first checked origin, and the inventory is
55
55
  invalid, as for a fabricated name, so the prompt re-dispatches the subagent
56
- with the names it left out.
56
+ with the names it left out. A checked origin that carries a `signature`
57
+ (#888: the `params` and `return_type` the skill writes there in a fixed
58
+ form) is carried when an entry of that name, whatever its kind, reads as it
59
+ does: each of `params` and `return_type` the signature pins non-blank gives
60
+ the same field_text (whitespace runs as one space, a list's items joined
61
+ with `, `, a null field as blank), and a `params` that opens with the shared
62
+ text the contract has several names' entries carry, then `; `, carries the
63
+ pinned one after it. A field the signature leaves blank, and the entry's
64
+ other fields, a `usage_signature` among them, are not read.
65
+ Each checked origin no entry carries is a violation naming the origin and
66
+ its signature, for a name left out too, and the inventory is invalid: the
67
+ form is never overwritten, so the re-dispatched subagent copies it as
68
+ given.
57
69
 
58
70
  An entry's optional `signature_form` says how the skill presents its
59
71
  signature: `partial` only when the skill labels the form partial (a
@@ -86,7 +98,10 @@ directly in references/ writes it as a fixed string, case-sensitive, never
86
98
  inside a longer identifier (the Export match rule of
87
99
  skf-scan-skill-md-structure.py usage-scope): on a side where the name ends
88
100
  in a letter, digit, `_` or `$`, the next character may not be one of those.
89
- So `get` is found in `get(url)` but not in `target` or `getAll`.
101
+ So `get` is found in `get(url)` but not in `target` or `getAll`. The form
102
+ helpers `field_text` and `form_key` are shared too: score-signatures.py
103
+ tells an entry's forms apart with them, and the floor check reads a floor
104
+ origin's `signature` with `field_text`.
90
105
 
91
106
  The skill-citation scan is shared too: `_skill_cited_files` (with
92
107
  `_cited_path`, `skill_markdown_files` and `_SKILL_CITATION_RE`) gives the
@@ -145,12 +160,15 @@ Output (stdout, one object):
145
160
  }
146
161
 
147
162
  Exit codes:
148
- 0 inventory valid (every sampled name found, every checked floor name kept)
163
+ 0 inventory valid (every sampled name found, every checked floor name
164
+ kept and every checked floor signature carried)
149
165
  1 no input provided, the --input file cannot be read, the
150
166
  --skill-package folder holds no readable SKILL.md, or the --floor file
151
- cannot be read or is not a floor (usage error)
167
+ cannot be read or is not a floor, a malformed `signature` among them
168
+ (usage error)
152
169
  2 inventory invalid (parse failure, schema violation, a sampled name
153
- absent or a floor name left out; result JSON emitted)
170
+ absent, a floor name left out or a floor signature no entry carries;
171
+ result JSON emitted)
154
172
  """
155
173
 
156
174
  from __future__ import annotations
@@ -207,6 +225,13 @@ OTHER_SIDE_SHAPES = (
207
225
  # be complete (the docs-only Export Coverage rule).
208
226
  SIGNATURE_KINDS = ("function", "method")
209
227
 
228
+ # An entry's signature fields, the ones form_key reads (score-signatures.py reads them from here).
229
+ SIGNATURE_FIELDS = ("params", "return_type", "usage_signature")
230
+
231
+ # The fields of a floor origin's `signature` (#888), each a string, and the
232
+ # ones the floor check compares with an entry's.
233
+ FLOOR_SIGNATURE_FIELDS = ("params", "return_type")
234
+
210
235
  # Characters that continue an identifier in the languages SKF documents
211
236
  # (`$` for JS/TS names such as `$state`).
212
237
  IDENT_CHARS = r"\w$"
@@ -409,6 +434,25 @@ def spot_check(inventory, skill_package_path):
409
434
  }
410
435
 
411
436
 
437
+ # --- Signature forms ----------------------------------------------------------
438
+
439
+
440
+ def field_text(value: object) -> str:
441
+ """A signature field as one text: a list's items joined with `, `, whitespace runs as one space, "" for null."""
442
+ if value is None:
443
+ return ""
444
+ if isinstance(value, list):
445
+ value = ", ".join(v if isinstance(v, str) else json.dumps(v, sort_keys=True) for v in value)
446
+ return " ".join(value.split()) if isinstance(value, str) else json.dumps(value, sort_keys=True)
447
+
448
+
449
+ def form_key(fields: dict) -> tuple:
450
+ """What a form says, for telling two entries apart (field_text), a
451
+ null, blank or empty key read as absent, and the `signature_form` label
452
+ left out (score-signatures.py's documented_forms keeps the full one)."""
453
+ return tuple((f, field_text(fields[f])) for f in SIGNATURE_FIELDS if f in fields and field_text(fields[f]))
454
+
455
+
412
456
  # --- Floor ------------------------------------------------------------------
413
457
 
414
458
 
@@ -416,10 +460,17 @@ class FloorError(Exception):
416
460
  """The --floor file cannot be read or is not a floor: exit 1."""
417
461
 
418
462
 
463
+ def _is_signature(value):
464
+ """A floor origin's `signature`: an object of exactly the strings `params` and `return_type`."""
465
+ return (isinstance(value, dict) and sorted(value) == sorted(FLOOR_SIGNATURE_FIELDS)
466
+ and all(isinstance(value[f], str) for f in FLOOR_SIGNATURE_FIELDS))
467
+
468
+
419
469
  def _is_origin(origin):
420
470
  return (isinstance(origin, dict) and isinstance(origin.get("file"), str) and bool(origin["file"])
421
471
  and isinstance(origin.get("line"), int) and not isinstance(origin["line"], bool)
422
- and isinstance(origin.get("form"), str))
472
+ and isinstance(origin.get("form"), str)
473
+ and ("signature" not in origin or _is_signature(origin["signature"])))
423
474
 
424
475
 
425
476
  def read_floor(path):
@@ -436,7 +487,8 @@ def read_floor(path):
436
487
  and isinstance(item.get("origins"), list) and all(_is_origin(o) for o in item["origins"])
437
488
  for item in names):
438
489
  raise FloorError(f"{path} is not a floor: `names` must give each name's `name` and `origins` "
439
- "(each a file, a line and a form)")
490
+ "(each a file, a line and a form, and an optional `signature` of the strings "
491
+ "`params` and `return_type`)")
440
492
  return data
441
493
 
442
494
 
@@ -450,33 +502,60 @@ def _listed_path(ref, skill_package_path):
450
502
  return _cited_path(ref)
451
503
 
452
504
 
505
+ def _carries(entry, signature):
506
+ """Whether an entry carries a floor origin's signature: each field it pins non-blank has the same field_text
507
+ there, a `params` that opens with members the skill states once for several names (the contract's shared
508
+ text, then `; `) included."""
509
+ for field in FLOOR_SIGNATURE_FIELDS:
510
+ wanted, given = field_text(signature[field]), field_text(entry.get(field))
511
+ if wanted and given != wanted and not (field == "params" and given.endswith("; " + wanted)):
512
+ return False
513
+ return True
514
+
515
+
453
516
  def floor_check(inventory, floor, skill_package_path=None):
454
- """The floor names the inventory leaves out: (the `floor` result, its violations).
517
+ """The floor names and signatures the inventory leaves out: (the `floor` result, its violations).
455
518
 
456
519
  A floor name is checked when one of its origins is SKILL.md or a file
457
520
  the inventory's `references` lists (the spot-check's files, an absolute
458
521
  path inside `skill_package_path` matched by its path relative to it),
459
522
  and kept when any inventory entry has that name, whatever its kind. A
460
- name left out is a violation naming its first checked origin.
523
+ name left out is a violation naming its first checked origin. Then each
524
+ checked origin with a `signature` is carried when an entry of that name,
525
+ whatever its kind, gives the same field_text to each field the
526
+ signature pins non-blank (a blank pinned `return_type` leaves the
527
+ entry's free; a `params` may open with shared text and `; `, _carries);
528
+ one no entry carries is a violation naming the origin and the
529
+ signature, for a name left out too.
461
530
  """
462
531
  listed = inventory.get("references")
463
532
  files = {"SKILL.md"} | {_listed_path(ref, skill_package_path)
464
533
  for ref in (listed if isinstance(listed, list) else [])
465
534
  if isinstance(ref, str) and ref.strip()}
466
- kept = {e.get("name") for e in inventory.get("exports") or [] if isinstance(e, dict)}
535
+ entries = [e for e in inventory.get("exports") or [] if isinstance(e, dict)]
536
+ kept = {e.get("name") for e in entries}
467
537
  names = floor.get("names") or []
468
- checked, absent, violations = 0, [], []
538
+ checked, absent, violations, signed = 0, [], [], []
469
539
  for item in names:
470
540
  origins = [o for o in item["origins"] if _cited_path(o["file"]) in files]
471
541
  if not origins:
472
542
  continue
473
543
  checked += 1
474
544
  name = item["name"]
545
+ signed += [(name, o) for o in origins if "signature" in o]
475
546
  if name in kept:
476
547
  continue
477
548
  absent.append(name)
478
549
  where = f"{origins[0]['file']}:{origins[0]['line']} ({origins[0]['form']})"
479
550
  violations.append(f"floor: `{name}` is documented at {where}, but no inventory entry lists it")
551
+ for name, origin in signed:
552
+ signature = {f: origin["signature"][f] for f in FLOOR_SIGNATURE_FIELDS}
553
+ if any(_carries(e, signature) for e in entries if e.get("name") == name):
554
+ continue
555
+ where = f"{origin['file']}:{origin['line']} ({origin['form']})"
556
+ violations.append(f"floor: `{name}` is documented at {where} with the signature "
557
+ f"{json.dumps(signature, ensure_ascii=False)}, but no inventory entry of that name "
558
+ "carries it; copy it as given")
480
559
  return {"names": len(names), "checked": checked, "absent": absent}, violations
481
560
 
482
561
 
@@ -782,7 +861,7 @@ def _build_parser():
782
861
  parser.add_argument(
783
862
  "--floor",
784
863
  metavar="PATH",
785
- help="Check the inventory keeps the names of this load-coverage-inputs.py floor file.",
864
+ help="Check the inventory keeps the names and signatures of this load-coverage-inputs.py floor file.",
786
865
  )
787
866
  return parser
788
867
 
@@ -52,7 +52,7 @@ persistent_facts = [
52
52
  # command is called as:
53
53
  # <on_complete> --result-path=<{forge_version}/update-skill-result-latest.json>
54
54
  # It is not called for --detect-only or --dry-run, which write no result
55
- # file, nor when a run halts.
55
+ # file, nor when a run halts or did not write its -latest copy.
56
56
  # Useful for Slack notifications, dashboard ingest, CI hooks, or chaining a
57
57
  # downstream skill (audit, export, test). A failure is reported to the user
58
58
  # in one line and never fails the workflow; it does not reach the result
@@ -99,7 +99,7 @@ Read the gaps of the test report at `{test_report_path}` and translate them into
99
99
 
100
100
  - `stale-documentation` (the skill documents a name the source's enumerated surface lacks and that is not a documented extra, an Info `observation`): the source has no line to repair the name from, so a person removes its documentation when the name is gone from the source, fixes the brief's scope when the surface missed it, or, when the source still declares the name, keeps the documentation and re-runs Test Skill at a tier that classifies it.
101
101
  - `metadata-drift` (divergent export counts in the metadata): update-skill recounts `stats.exports_public_api`, `stats.exports_documented` and `stats.effective_denominator` each time it writes metadata.json, so a metadata patch to them does not hold (under `--allow-workspace-drift` and for a docs-only skill it keeps the recorded public API counts and `stats.effective_denominator` instead); Create Skill rebuilds diverging barrel counts from the source and recounts a `stats.effective_denominator` the brief's `scope.tier_a_include` set disagrees with (denominator drift), a person fixes denominator deflation on a brief with no tier A list by adding `scope.tier_a_include` to it, and documented-surface counts that differ by the `Type::method` entries the named-export count leaves out need no fix.
102
- - `provenance-unverified`: the line-check rules could not verify the line, or the recorded line is not a number, so a person checks by hand which line defines the export and, when it is not the recorded one, sets its provenance `source_line` to that line.
102
+ - `provenance-unverified`: the line-check rules could not verify the line, or the recorded line is not a number, so a person checks by hand which line defines the export and, when it is not the recorded one, sets its provenance `source_line` to that line; when the gap names an alias, no line of the cited file defines the export, so a person records the rename in its provenance entry (`export_name` the name the alias renames, `reexported_as` the export) only when no other entry has that `export_name` and the alias does not rename a default export, and otherwise cites the alias line, as the remediation says.
103
103
  - `denominator-inflation`: it concerns the brief's scope and the count design, which a person decides in the skill brief, as the gap's remediation says.
104
104
  - `brief-scope-stale`: a person edits the brief's scope globs by hand so each matches the files it meant, since update-skill does not rewrite them.
105
105
  - `multi-denominator`: no fix is needed, since the barrel and the documented surface measure different sets by design and the report records both counts.
@@ -9,4 +9,4 @@ The argument set, gate map, outputs and headless result line of `skf-update-skil
9
9
  | **Gates** | step 1: init.md §4's degraded-rebuild gate [D]/[X] when no provenance map is found (headless: halts, or takes [D] under `--allow-degraded`; a gap-driven run halts `blocked` there instead); init.md §4b's [G]/[S] test-report offer, interactive only (headless warns `unconsumed-test-report`); Confirm Gate [C] (init.md §8) | step 2, each only when triggered: detect-changes.md §1b's promote prompt and §1c's scope-expansion prompt [P]/[S]/[U] (headless: defers each candidate to a person), §2.2's deletion-ratio gate [C]/[B]/[A] (headless: [C]) | step 2g (gap-driven): gap-driven.md §1 rule R1's [D]/[R] for each gap whose remediation names removal (headless: [D], or [R] only when the export is internal or `#[doc(hidden)]`) | step 4: Confirm Gate [C] only after the user resolved [MANUAL] conflicts (merge.md §4); a clean merge auto-proceeds, and a headless run with conflicts halts `halted-for-manual-mismatch` before anything is written |
10
10
  | **Outputs** | A new version folder `{skill_group}/{new_version}/` and its forge folder, beside the unchanged previous version (a gap-driven repair writes the current version in place), and a finished run's `update-skill-result-{YYYYMMDD-HHmmss}.json` and `update-skill-result-latest.json` in that version's forge folder (a run that found no change: the current one's). `--detect-only` and `--dry-run` write none of these, nor the skill brief. Run state goes to a run folder under `{project-root}/_bmad-output/.skf-run/` that step 7 removes; a halt keeps it |
11
11
  | **Concurrency** | A second real update of the same skill halts `halted-for-concurrent-run` while the first holds its run lock (init.md §1b); `--detect-only` and `--dry-run` take no lock. write.md §8 halts with the same status when another flip of the `active` link holds its lock for 5 seconds. A remote skill is read from a private tree, and no mode writes to the shared workspace clone before write.md §9. |
12
- | **Headless** | No gate prompts: each resolves as the Gates row says, and the run records the decision. One `SKF_UPDATE_RESULT_JSON` line (report.md §5b), at step 6 or at a HALT, printed by the shared emitter and checked against `shared/scripts/schemas/skf-update-result-envelope.v1.json`. It is the run's last line: the shared health check displays it after everything else; a HALT displays its line and stops. Pipelines branch on `skf_update.status`: `success`, `no-changes`, `detect-only` and `dry-run` are successful exits. A caller reads the run's own `SKF_UPDATE_RESULT_JSON` line. A run that printed no envelope line has no result to read, whatever the process exit code: it was cut off, or could not write its result, and `update-skill-result-latest.json` may then describe an earlier run, so it is never read in its place. |
12
+ | **Headless** | No gate prompts: each resolves as the Gates row says, and the run records the decision. One `SKF_UPDATE_RESULT_JSON` line (report.md §5b), at step 6 or at a HALT, printed by the shared emitter and checked against `shared/scripts/schemas/skf-update-result-envelope.v1.json`. It is the run's last line: the shared health check displays it after everything else; a HALT displays its line and stops. Pipelines branch on `skf_update.status`: `success`, `no-changes`, `detect-only` and `dry-run` are successful exits. A headless run whose emitter refused its result line for good ends on a `halted-for-write-failure` line instead, with `error.phase` `report:result-contract` and an `error.reason` that starts `result-contract-not-written`, and nothing it wrote is undone. A caller reads the run's own `SKF_UPDATE_RESULT_JSON` line. A run that printed no envelope line has no result to read, whatever the process exit code: it was cut off, or could not write its result, and `update-skill-result-latest.json` may then describe an earlier run, so it is never read in its place. |
@@ -299,7 +299,7 @@ uv run {runLockHelper} acquire \
299
299
  ```
300
300
 
301
301
  It renames the staged folder to `{skill_group}/{new_version}/` in one step, so no reader ever sees a half-copied version.
302
- 6. **Create the forge folder** `{forge_data_folder}/{skill_name}/{new_version}/` and copy `provenance-map.json`, `evidence-report.md` and `extraction-rules.yaml` from `{forge_version}` into it, each when it exists: step 5 updates the provenance map and appends this update to the evidence report there, and audit-skill reads the extraction rules there. Test reports, drift reports and result files belong to the version they were made for and stay where they are.
302
+ 6. **Create the forge folder** `{forge_data_folder}/{skill_name}/{new_version}/` and copy into it each of `provenance-map.json`, `evidence-report.md` and `extraction-rules.yaml`: from `{forge_version}` when it is there, else from `{forge_data_folder}/{skill_name}/` when it is there and init.md §4 bound `{provenance_map_path}` to that flat folder's map (an older skill's flat forge data, `knowledge/version-paths.md`). Copy, never move: the previous version's files and the flat ones stay as they are. Step 5 updates the provenance map and appends this update to the evidence report there; the extraction rules record how the skill was extracted. Test reports, drift reports and result files belong to the version they were made for and stay where they are.
303
303
  7. **Rebind** `{skill_package}` ← `{skill_group}/{new_version}/{skill_name}` and `{forge_version}` ← `{forge_data_folder}/{skill_name}/{new_version}`. Every later step reads and writes the new version there; `{manual_inventory}` keeps the amended path §4 bound.
304
304
 
305
305
  If no `{atomicWriteProbeOrder}` candidate resolves, or the stage, copy, write, publish or forge-folder step fails, HALT with status `halted-for-write-failure`, naming the failed step (for a missing helper: "skf-atomic-write.py is missing; re-install SKF"). The halt procedure takes `phase: "merge:new-version-folder"`, `path: "{skill_group}/{new_version}"`, `reason: "<the failed step>: <its error>"`; its rollback removes what this section created, `{version_staging}`, `{skill_group}/{new_version}/` and `{forge_data_folder}/{skill_name}/{new_version}/`, none of which existed before `begin` recorded them. The previous version is unchanged.