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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +1 -1
- package/docs/_data/pinned.yaml +9 -10
- package/docs/campaign.md +2 -2
- package/docs/skill-model.md +3 -3
- package/docs/verifying-a-skill.md +17 -18
- package/docs/workflows.md +10 -10
- package/package.json +1 -1
- package/src/shared/references/description-guard-protocol.md +12 -10
- package/src/shared/references/output-contract-schema.md +1 -1
- package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +38 -0
- package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +5 -0
- package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +8 -3
- package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +6 -1
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +7 -2
- package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +1 -1
- package/src/shared/scripts/skf-parse-gaps.py +4 -1
- package/src/shared/scripts/skf-quick-batch.py +24 -7
- package/src/shared/scripts/skf-verify-provenance-completeness.py +809 -85
- package/src/skf-analyze-source/customize.toml +3 -2
- package/src/skf-analyze-source/references/auto-docs-only.md +2 -2
- package/src/skf-analyze-source/references/generate-briefs.md +5 -3
- package/src/skf-analyze-source/references/headless-contract.md +2 -2
- package/src/skf-analyze-source/references/step-auto-scope.md +2 -2
- package/src/skf-audit-skill/customize.toml +4 -3
- package/src/skf-audit-skill/references/report.md +1 -1
- package/src/skf-campaign/assets/campaign-state-schema.json +58 -0
- package/src/skf-campaign/references/campaign-contracts.md +1 -1
- package/src/skf-campaign/references/step-06-batch.md +2 -2
- package/src/skf-campaign/references/step-07-capstone.md +3 -3
- package/src/skf-campaign/references/step-08-verify.md +3 -3
- package/src/skf-campaign/references/step-09-refine.md +3 -3
- package/src/skf-campaign/references/step-11-maintenance.md +1 -1
- package/src/skf-campaign/scripts/campaign-render-batch.py +43 -10
- package/src/skf-campaign/scripts/campaign-report.py +60 -4
- package/src/skf-campaign/scripts/campaign-state.py +82 -17
- package/src/skf-campaign/templates/campaign-report-template.md +4 -0
- package/src/skf-create-skill/SKILL.md +2 -2
- package/src/skf-create-skill/assets/skill-sections.md +2 -2
- package/src/skf-create-skill/customize.toml +4 -4
- package/src/skf-create-skill/references/batch-mode.md +3 -1
- package/src/skf-create-skill/references/report.md +9 -4
- package/src/skf-create-skill/references/validate.md +49 -29
- package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
- package/src/skf-create-stack-skill/references/report.md +3 -3
- package/src/skf-create-stack-skill/references/validate.md +21 -8
- package/src/skf-drop-skill/references/invocation-contract.md +1 -1
- package/src/skf-export-skill/references/summary.md +1 -1
- package/src/skf-quick-skill/SKILL.md +1 -1
- package/src/skf-quick-skill/references/batch-mode.md +30 -2
- package/src/skf-quick-skill/references/compile.md +2 -0
- package/src/skf-quick-skill/references/finalize.md +3 -2
- package/src/skf-quick-skill/references/halt-contract.md +3 -3
- package/src/skf-quick-skill/references/quick-extract.md +2 -1
- package/src/skf-quick-skill/references/write-and-validate.md +27 -11
- package/src/skf-refine-architecture/customize.toml +1 -1
- package/src/skf-refine-architecture/references/report.md +2 -2
- package/src/skf-rename-skill/references/report.md +1 -1
- package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
- package/src/skf-test-skill/references/coverage-check.md +4 -4
- package/src/skf-test-skill/references/inventory-contract.md +1 -1
- package/src/skf-test-skill/references/invocation-contract.md +1 -1
- package/src/skf-test-skill/references/report.md +1 -1
- package/src/skf-test-skill/references/scoring-rules.md +2 -2
- package/src/skf-test-skill/references/signature-comparison-rules.md +7 -2
- package/src/skf-test-skill/scripts/gap-ledger.py +196 -46
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +253 -17
- package/src/skf-test-skill/scripts/score-signatures.py +9 -13
- package/src/skf-test-skill/scripts/validate-inventory.py +91 -12
- package/src/skf-update-skill/customize.toml +1 -1
- package/src/skf-update-skill/references/gap-driven.md +1 -1
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +1 -1
- package/src/skf-update-skill/references/report.md +27 -9
- package/src/skf-update-skill/references/write.md +24 -12
- 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.
|
|
25
|
-
|
|
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 `(
|
|
28
|
-
`fetch(url): Promise<T>` and
|
|
29
|
-
`
|
|
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"}]}],
|
|
76
|
-
"byForm"}}, names sorted, each one's
|
|
77
|
-
|
|
78
|
-
|
|
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 `(
|
|
537
|
-
cut; None for any other text, a dotted or `::` name
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
563
|
-
|
|
564
|
-
|
|
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 (
|
|
572
|
-
null, blank or empty key read as absent, and the `signature_form` label
|
|
573
|
-
|
|
574
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|