bmad-module-skill-forge 6.0.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/docs/_data/pinned.yaml +1 -1
- package/docs/workflows.md +4 -4
- package/package.json +1 -1
- package/src/shared/references/output-contract-schema.md +1 -1
- package/src/shared/scripts/skf-quick-batch.py +16 -3
- package/src/shared/scripts/skf-verify-provenance-completeness.py +209 -33
- 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-campaign/references/campaign-contracts.md +1 -1
- 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/scripts/campaign-state.py +22 -5
- package/src/skf-create-skill/SKILL.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 +7 -2
- package/src/skf-create-skill/references/validate.md +1 -1
- package/src/skf-create-stack-skill/references/validate.md +12 -4
- package/src/skf-quick-skill/SKILL.md +1 -1
- package/src/skf-quick-skill/references/batch-mode.md +2 -0
- package/src/skf-quick-skill/references/write-and-validate.md +19 -9
- package/src/skf-test-skill/references/coverage-check.md +2 -2
- package/src/skf-test-skill/references/inventory-contract.md +1 -1
- package/src/skf-test-skill/references/scoring-rules.md +2 -2
- package/src/skf-test-skill/references/signature-comparison-rules.md +3 -1
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +241 -11
- 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/report.md +10 -3
- package/src/skf-update-skill/references/write.md +1 -1
|
@@ -21,8 +21,9 @@ 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
28
|
everything from the first `(` or `<` are cut, so `Meta<T>`,
|
|
28
29
|
`fetch(url): Promise<T>` and `new Client(opts)` give `Meta`,
|
|
@@ -71,11 +72,74 @@ reading files and writing one JSON object:
|
|
|
71
72
|
`type (`, `var (` or `const (` group too. A
|
|
72
73
|
block in another language, an untagged one or a
|
|
73
74
|
data block (json, yaml, text...) gives none.
|
|
75
|
+
Signatures (#888): an origin whose place writes the name's
|
|
76
|
+
signature in a fixed form that is surely complete also carries
|
|
77
|
+
it as `signature`, {"params", "return_type"}, two strings, each
|
|
78
|
+
one's whitespace runs read as one space, which
|
|
79
|
+
validate-inventory.py --floor refuses an inventory for
|
|
80
|
+
changing; any other place pins nothing, since a wrong pin would
|
|
81
|
+
be forced into the inventory. `params` is the text inside the
|
|
82
|
+
parameter group (score-signatures.py's _group: the first pair
|
|
83
|
+
of parentheses outside every bracket pair and string), and
|
|
84
|
+
`return_type` the type written after it (score-signatures.py's
|
|
85
|
+
_signature_parts), "" for none. A form that writes neither
|
|
86
|
+
(`f()`, `useQuery<T>()`) gives none, so an empty call claims no
|
|
87
|
+
form. A span (a table cell's or a heading's) is complete when
|
|
88
|
+
it is the name (or no name, as a Signature cell's `(x: A) =>
|
|
89
|
+
B`), its type parameters, one parameter group, then nothing or
|
|
90
|
+
a return separator (`->`, `=>`, `→` or `:`) and the return, with
|
|
91
|
+
nothing else: `useQuery(key).data`, `f(a) / f(a, b)`, a
|
|
92
|
+
trailing comment, a `;` or `,` outside the return's brackets,
|
|
93
|
+
an unclosed bracket, `Meta<T>` (no group) and `new Client(opts)`
|
|
94
|
+
give none. By form:
|
|
95
|
+
table-row a row whose first cell names one name (a row
|
|
96
|
+
naming several gives none), each cell's `\\|`
|
|
97
|
+
read as `|`: the leading code span of its
|
|
98
|
+
Signature or Signatures cell, else, when that
|
|
99
|
+
span writes no parameter group (`Typed<X>`),
|
|
100
|
+
the one span of its first cell that gives the
|
|
101
|
+
name (another export's span there, as in
|
|
102
|
+
`fetch` (same as `http.request(opts)`), gives
|
|
103
|
+
nothing). A Signature span with a group that is
|
|
104
|
+
no complete form (`client.get(k) -> str`,
|
|
105
|
+
`new Client(opts)`) gives none, so a shorter
|
|
106
|
+
first-cell call never stands in for it. A header
|
|
107
|
+
cell matches whole, so a labelled one is not a
|
|
108
|
+
Signature column, and a Props, Params or
|
|
109
|
+
Parameters cell is never read (its shared
|
|
110
|
+
members and usage forms stay the reader's).
|
|
111
|
+
heading its code span
|
|
112
|
+
declaration a function's (`function`, `def`, `fn` or Go's
|
|
113
|
+
`func` before its name; a `const` holding an
|
|
114
|
+
arrow gives none): its lines as one, as
|
|
115
|
+
skf-extract-public-api.py's
|
|
116
|
+
_declaration_signature joins them (a parameter
|
|
117
|
+
list split over lines joined, line comments
|
|
118
|
+
left out), the body cut off first, at the first
|
|
119
|
+
`:` (Python) or `{`, `;` or ` where ` (any other
|
|
120
|
+
language) outside every bracket pair and string
|
|
121
|
+
after the parameter group, a `{` directly after
|
|
122
|
+
a TS return `:` opening the return type
|
|
123
|
+
(`(): { a: T } {` returns `{ a: T }`). None for
|
|
124
|
+
one with anything but type parameters between
|
|
125
|
+
its name and the group (a Go `func(...)`
|
|
126
|
+
return), one whose opener follows, or whose
|
|
127
|
+
uncut text ends with, `interface`, `struct`,
|
|
128
|
+
`is`, `keyof`, `|`, `&`, `?`, `:`, `\\`, `=>` or
|
|
129
|
+
`->` (the opener or the line end is inside the
|
|
130
|
+
return type: Go's `interface{}`, a TS `asserts
|
|
131
|
+
x is {...}`, `() => {...}` or conditional type,
|
|
132
|
+
`): Promise<A> |`), or one whose brackets or
|
|
133
|
+
strings do not all close.
|
|
134
|
+
A form written only in prose is never read: it stays the
|
|
135
|
+
reader's. Each origin keeps the first signature its place
|
|
136
|
+
writes.
|
|
74
137
|
Output: {"files": [...], "names": [{"name", "kindLabel",
|
|
75
|
-
"origins": [{"file", "line", "form"}]}],
|
|
76
|
-
"byForm"}}, names sorted, each one's
|
|
77
|
-
|
|
78
|
-
|
|
138
|
+
"origins": [{"file", "line", "form", "signature"?}]}],
|
|
139
|
+
"summary": {"names", "byForm"}}, names sorted, each one's
|
|
140
|
+
origins in file order then line, `signature` only on an origin
|
|
141
|
+
that has one, `byForm` counting the origins of each form; the
|
|
142
|
+
same skill gives the same bytes.
|
|
79
143
|
|
|
80
144
|
metadata the metadata.json and provenance-map counts. Cluster A
|
|
81
145
|
(`stats.exports_public_api`, the `exports[]` length) and Cluster
|
|
@@ -371,6 +435,23 @@ FLOOR_TABLE_HEADERS = frozenset({
|
|
|
371
435
|
"export", "exports", "symbol", "symbols", "function", "functions", "class", "classes", "interface", "interfaces",
|
|
372
436
|
"type", "types", "hook", "hooks", "component", "components", "helper", "helpers", "constant", "constants",
|
|
373
437
|
"enum", "enums", "api", "apis"})
|
|
438
|
+
# The header words (as _header_word reads them, whole) of a floor table's column that writes the row's signature
|
|
439
|
+
# (#888); a labelled header, such as `Signature (simplified)`, is not one.
|
|
440
|
+
FLOOR_SIGNATURE_HEADERS = frozenset({"signature", "signatures"})
|
|
441
|
+
# A complete form's name, before its type parameters and parameter group (group 1, absent for none).
|
|
442
|
+
SPAN_NAME_RE = re.compile(r"\s*([A-Za-z_$][\w$]*)?\s*")
|
|
443
|
+
# What a complete form writes after its parameter group before its return: `->`, `=>`, `→` or `:` (not `::`).
|
|
444
|
+
RETURN_SEPARATOR_RE = re.compile(r"\s*(?:->|=>|→|:(?!:))")
|
|
445
|
+
# A declaration that gets a form: a function keyword before its name, on its masked line.
|
|
446
|
+
FUNCTION_KEYWORD_RE = re.compile(r"(?<![\w$])(?:function|def|fn|func)(?![\w$])")
|
|
447
|
+
# After a declaration's parameter group: the `:` a TS return type follows (a `{` right after it opens the type, not
|
|
448
|
+
# the body), and a Rust `where` clause, which the body comes after.
|
|
449
|
+
RETURN_COLON_RE = re.compile(r"\s*:\s*")
|
|
450
|
+
WHERE_CLAUSE_RE = re.compile(r"\s+where(?![\w$])")
|
|
451
|
+
# A declaration's text that ends in the middle of its return type: a body opener after one of these is part of that
|
|
452
|
+
# type, cut short (Go's `interface{}` and `struct{...}`, a TS union, intersection, `asserts x is {...}`, `keyof {...}`,
|
|
453
|
+
# conditional or function type), and so is a declaration that ends with one (`): Promise<A> |`, Python's `\`).
|
|
454
|
+
OPENER_IN_TYPE_RE = re.compile(r"(?:(?<![\w$])(?:interface|struct|is|keyof)|[|&?:\\]|=>|->)\s*$")
|
|
374
455
|
# A header cell wrapped in `**` or `__` (group 2 its text).
|
|
375
456
|
EMPHASIS_RE = re.compile(r"(\*\*|__)(.+)\1")
|
|
376
457
|
PLAIN_IDENTIFIER_RE = re.compile(r"[A-Za-z_$][\w$]*")
|
|
@@ -650,6 +731,144 @@ def _declared_names(lines: list[str], language: str, js_family) -> list[tuple[in
|
|
|
650
731
|
return found
|
|
651
732
|
|
|
652
733
|
|
|
734
|
+
def _form(text: str, group: tuple[int, int], extension: str = "") -> dict | None:
|
|
735
|
+
"""{"params", "return_type"} of a form whose parameter group is `group`, whitespace runs read as one space: the
|
|
736
|
+
text inside the group, and the return after it (score-signatures.py's _signature_parts), "" for none; None for a
|
|
737
|
+
form that writes neither (`f()`)."""
|
|
738
|
+
form = {"params": " ".join(text[group[0] + 1:group[1]].split()),
|
|
739
|
+
"return_type": " ".join(_sibling("score-signatures.py")._signature_parts(text, extension)[1].split())}
|
|
740
|
+
return form if form["params"] or form["return_type"] else None
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _type_parameters_only(text: str) -> bool:
|
|
744
|
+
"""Whether `text` is empty or one `<...>` or `[...]` pair, the type parameters a form writes before its group."""
|
|
745
|
+
text = text.strip()
|
|
746
|
+
if not text:
|
|
747
|
+
return True
|
|
748
|
+
if text[0] not in "<[" or text[-1] != {"<": ">", "[": "]"}[text[0]]:
|
|
749
|
+
return False
|
|
750
|
+
depth = 0
|
|
751
|
+
for i, ch in enumerate(text):
|
|
752
|
+
if ch in "<[(":
|
|
753
|
+
depth += 1
|
|
754
|
+
elif ch in ">])" and not (ch == ">" and text[i - 1:i] in ("-", "=")):
|
|
755
|
+
depth -= 1
|
|
756
|
+
if depth == 0 and i != len(text) - 1:
|
|
757
|
+
return False
|
|
758
|
+
return depth == 0
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _call_form(span: str, name: str | None) -> dict | None:
|
|
762
|
+
"""The signature a span writes when it is a complete form (_form): `name` or no name (a Signature cell's
|
|
763
|
+
`(x: A) => B`), its type parameters, one parameter group (score-signatures.py's _group), then nothing, or a
|
|
764
|
+
return separator (`->`, `=>`, `→` or `:`) and the return, with nothing else: no other text after the group
|
|
765
|
+
(`useQuery(key).data`, `f(a) / f(a, b)`, a trailing comment), no `;`, `,`, `#`, `//` or `/*` in the return
|
|
766
|
+
outside its brackets and strings, and every bracket and string closed. None for any other span, a constructor
|
|
767
|
+
call (`new Client(opts)`) and a span with no parameter group (`Meta<T>`) among them."""
|
|
768
|
+
scorer = _sibling("score-signatures.py")
|
|
769
|
+
text = span.strip()
|
|
770
|
+
group = scorer._group(text)
|
|
771
|
+
if group is None or text.startswith("new ") or scorer._levels(text + " ")[-1] != 0:
|
|
772
|
+
return None
|
|
773
|
+
head = SPAN_NAME_RE.match(text, 0, group[0])
|
|
774
|
+
if head.group(1) not in (None, name) or not _type_parameters_only(text[head.end():group[0]]):
|
|
775
|
+
return None
|
|
776
|
+
tail = text[group[1] + 1:]
|
|
777
|
+
separator = RETURN_SEPARATOR_RE.match(tail)
|
|
778
|
+
returned = tail[separator.end():] if separator else ""
|
|
779
|
+
if (separator is None and tail.strip()) or (separator is not None and not returned.strip()):
|
|
780
|
+
return None
|
|
781
|
+
levels = scorer._levels(returned)
|
|
782
|
+
if any(levels[i] == 0 and (ch in ";,#" or returned.startswith(("//", "/*"), i)) for i, ch in enumerate(returned)):
|
|
783
|
+
return None
|
|
784
|
+
return _form(text, group)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _leading_span(cell: str) -> str | None:
|
|
788
|
+
"""The code span a table cell opens with, None when it opens with none."""
|
|
789
|
+
run = len(cell) - len(cell.lstrip("`"))
|
|
790
|
+
spans = _sibling("locate-export-segments.py")._inline_code(cell) if run else []
|
|
791
|
+
return spans[0] if spans and cell.startswith("`" * run + spans[0] + "`" * run) else None
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _row_form(header: list[str], row: list[str]) -> dict | None:
|
|
795
|
+
"""The signature a floor table's row writes for the one name its first cell names (a row naming several gives
|
|
796
|
+
none), each cell's `\\|` read as `|`: the leading code span of its Signature or Signatures cell, else, when that
|
|
797
|
+
span writes no parameter group, the one span of its first cell that gives that name (another export's span there
|
|
798
|
+
gives nothing), each a complete form (_call_form). A Signature span with a group that is no complete form gives
|
|
799
|
+
none. A Props, Params or Parameters cell is never read."""
|
|
800
|
+
segments = _sibling("locate-export-segments.py")
|
|
801
|
+
names = {name for name in map(_floor_name, segments._inline_code(row[0])) if name}
|
|
802
|
+
if len(names) != 1:
|
|
803
|
+
return None
|
|
804
|
+
[name] = names
|
|
805
|
+
cells = [cell.replace("\\|", "|") for cell in row]
|
|
806
|
+
at = next((i for i, cell in enumerate(header) if _header_word(cell) in FLOOR_SIGNATURE_HEADERS), None)
|
|
807
|
+
span = _leading_span(cells[at]) if at is not None and at < len(cells) else None
|
|
808
|
+
if span is not None and (form := _call_form(span, name)):
|
|
809
|
+
return form
|
|
810
|
+
if span is not None and _sibling("score-signatures.py")._group(span.strip()) is not None:
|
|
811
|
+
return None # the cell writes a call the floor cannot pin: a shorter first-cell form would hide it
|
|
812
|
+
own = [span for span in segments._inline_code(cells[0]) if _floor_name(span) == name]
|
|
813
|
+
return _call_form(own[0], name) if len(own) == 1 else None
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _function_declaration(line: str, language: str, js_family) -> int | None:
|
|
817
|
+
"""Where the name ends on a block's masked line, one _declared_names reads, that declares a function (`function`,
|
|
818
|
+
`def`, `fn` or Go's `func` before its name); None for a line that declares no function."""
|
|
819
|
+
if language in js_family:
|
|
820
|
+
m, at = JS_DECLARATION_RE.match(line), 1
|
|
821
|
+
elif language in PYTHON_LANGUAGES:
|
|
822
|
+
m, at = PYTHON_DECLARATION_RE.match(line), 1
|
|
823
|
+
elif language in RUST_LANGUAGES:
|
|
824
|
+
m, at = RUST_DECLARATION_RE.match(line), 1
|
|
825
|
+
elif language in GO_LANGUAGES:
|
|
826
|
+
m, at = GO_DECLARATION_RE.match(line), 2
|
|
827
|
+
else:
|
|
828
|
+
return False
|
|
829
|
+
return m.end(at) if m and FUNCTION_KEYWORD_RE.search(line, 0, m.start(at)) else None
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _declaration_form(rows: list[str], offset: int, language: str, name_end: int) -> dict | None:
|
|
833
|
+
"""The signature the function declaration at `offset` of a block's unmasked `rows` writes: the declaration on one
|
|
834
|
+
line (skf-extract-public-api.py's _declaration_signature: a parameter list split over lines joined, line comments
|
|
835
|
+
left out), its body cut at the first `:` (Python) or `{`, `;` or ` where ` (any other language) outside every
|
|
836
|
+
bracket pair and string after the parameter group, a `{` directly after a TS return `:` opening the return type,
|
|
837
|
+
then read as _form reads it. None when anything but type parameters sits between the name's end (`name_end`,
|
|
838
|
+
the masked line's offset, which masking keeps) and the group (a Go `func(...)` return read as the group), when
|
|
839
|
+
the opener follows, or the uncut text ends with, `interface`, `struct`, `is`, `keyof`, `|`, `&`, `?`, `:`, `\\`,
|
|
840
|
+
`=>` or `->` (it is inside the return type: Go's `interface{}`, a TS `asserts x is {...}`, `() => {...}` or
|
|
841
|
+
conditional type, a line that ends mid-type), or a bracket or string it opens does not close."""
|
|
842
|
+
scorer = _sibling("score-signatures.py")
|
|
843
|
+
extractor = _sibling("skf-extract-public-api.py", SHARED_SCRIPTS)
|
|
844
|
+
python, rust = language in PYTHON_LANGUAGES, language in RUST_LANGUAGES
|
|
845
|
+
extension = ".rs" if rust else ""
|
|
846
|
+
text = extractor._declaration_signature(rows, offset, "python" if python else "rust" if rust else language)
|
|
847
|
+
group = scorer._group(text, extension)
|
|
848
|
+
if group is None or not _type_parameters_only(text[name_end:group[0]]):
|
|
849
|
+
return None
|
|
850
|
+
levels = scorer._levels(text, extension)
|
|
851
|
+
for i in range(group[1] + 1, len(text)):
|
|
852
|
+
if levels[i] != 0:
|
|
853
|
+
continue
|
|
854
|
+
if python:
|
|
855
|
+
body = text[i] == ":"
|
|
856
|
+
else:
|
|
857
|
+
body = (text[i] == ";" or bool(WHERE_CLAUSE_RE.match(text, i))
|
|
858
|
+
or (text[i] == "{" and not RETURN_COLON_RE.fullmatch(text, group[1] + 1, i)))
|
|
859
|
+
if body:
|
|
860
|
+
if OPENER_IN_TYPE_RE.search(text, 0, i):
|
|
861
|
+
return None
|
|
862
|
+
text = text[:i]
|
|
863
|
+
break
|
|
864
|
+
else:
|
|
865
|
+
if OPENER_IN_TYPE_RE.search(text):
|
|
866
|
+
return None
|
|
867
|
+
if scorer._levels(text + " ", extension)[-1] != 0:
|
|
868
|
+
return None
|
|
869
|
+
return _form(text, group, extension)
|
|
870
|
+
|
|
871
|
+
|
|
653
872
|
def floor(skill_dir: str) -> dict:
|
|
654
873
|
"""The names the skill documents in a fixed form, each with its origins (see the module docstring)."""
|
|
655
874
|
root = Path(skill_dir)
|
|
@@ -670,10 +889,14 @@ def floor(skill_dir: str) -> dict:
|
|
|
670
889
|
for lines in texts for line in lines for m in inventory._SKILL_CITATION_RE.finditer(line)}
|
|
671
890
|
|
|
672
891
|
found: dict[str, set[tuple[int, int, str, str | None]]] = {}
|
|
892
|
+
signatures: dict[tuple[str, int, int, str], dict] = {} # the first per (name, file, line, form), in span order
|
|
673
893
|
|
|
674
|
-
def add(name: str | None, file_index: int, index: int, form: str, label: str | None = None
|
|
894
|
+
def add(name: str | None, file_index: int, index: int, form: str, label: str | None = None,
|
|
895
|
+
signature: dict | None = None) -> None:
|
|
675
896
|
if name:
|
|
676
897
|
found.setdefault(name, set()).add((file_index, index + 1, form, label))
|
|
898
|
+
if signature is not None:
|
|
899
|
+
signatures.setdefault((name, file_index, index + 1, form), signature)
|
|
677
900
|
|
|
678
901
|
js_family = segments.JS_FAMILY - {"svelte"} # a Svelte block's `export let` is a component prop
|
|
679
902
|
for file_index, lines in enumerate(texts):
|
|
@@ -689,12 +912,13 @@ def floor(skill_dir: str) -> dict:
|
|
|
689
912
|
row = _cells(lines[block["start"]])
|
|
690
913
|
kind_at = next((i for i, cell in enumerate(header) if _header_word(cell) == "kind"), None)
|
|
691
914
|
label = row[kind_at] if kind_at is not None and kind_at < len(row) and row[kind_at] else None
|
|
915
|
+
signature = _row_form(header, row)
|
|
692
916
|
for span in segments._inline_code(row[0]):
|
|
693
|
-
add(_floor_name(span), file_index, block["start"], "table-row", label)
|
|
917
|
+
add(_floor_name(span), file_index, block["start"], "table-row", label, signature)
|
|
694
918
|
for index, _level, text, _above in headings:
|
|
695
919
|
span = _whole_span(text, segments)
|
|
696
920
|
if span is not None:
|
|
697
|
-
add(_floor_name(span), file_index, index, "heading")
|
|
921
|
+
add(_floor_name(span), file_index, index, "heading", None, _call_form(span, _floor_name(span)))
|
|
698
922
|
comments = _html_comment_lines(lines, start, code)
|
|
699
923
|
for opening, closing, info in blocks:
|
|
700
924
|
language = segments._language(info)
|
|
@@ -703,7 +927,10 @@ def floor(skill_dir: str) -> dict:
|
|
|
703
927
|
continue
|
|
704
928
|
masked = segments.mask_code("\n".join(lines[opening + 1:closing]), language).split("\n")
|
|
705
929
|
for offset, name in _declared_names(masked, language, js_family):
|
|
706
|
-
|
|
930
|
+
name_end = _function_declaration(masked[offset], language, js_family)
|
|
931
|
+
signature = (_declaration_form(lines[opening + 1:closing], offset, language, name_end)
|
|
932
|
+
if name_end is not None else None)
|
|
933
|
+
add(name, file_index, opening + 1 + offset, "declaration", None, signature)
|
|
707
934
|
|
|
708
935
|
names = []
|
|
709
936
|
by_form = dict.fromkeys(FLOOR_FORMS, 0)
|
|
@@ -713,7 +940,10 @@ def floor(skill_dir: str) -> dict:
|
|
|
713
940
|
for file_index, line, form, _label in origins:
|
|
714
941
|
if (file_index, line, form) not in seen:
|
|
715
942
|
seen.add((file_index, line, form))
|
|
716
|
-
|
|
943
|
+
origin = {"file": files[file_index], "line": line, "form": form}
|
|
944
|
+
if (name, file_index, line, form) in signatures:
|
|
945
|
+
origin["signature"] = signatures[(name, file_index, line, form)]
|
|
946
|
+
kept.append(origin)
|
|
717
947
|
by_form[form] += 1
|
|
718
948
|
label = next((o[3] for o in origins if o[2] == "table-row" and o[3]), None)
|
|
719
949
|
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
|
|
@@ -267,20 +267,27 @@ It writes the per-run record `{forge_version}/update-skill-result-{YYYYMMDD-HHmm
|
|
|
267
267
|
SKF_UPDATE_RESULT_JSON: {"skf_update": {"status": "success", "skill_name": ..., "version": ..., "previous_version": ..., "update_mode": ..., "files_written": [...], "headless_decisions": [...], "warnings": [...], "error": null}}
|
|
268
268
|
```
|
|
269
269
|
|
|
270
|
-
In `{headless_mode}`, bind `{result_envelope_line}` to that line and do not display it here: the shared health check displays it verbatim as the run's last line. An interactive run runs the command for its result files and need not show the line. When the emitter exits non-zero and its `message` names the payload, fix the payload once and run it again; a write it could not make adds `result_file_write_failed` to the line's `warnings[]`.
|
|
270
|
+
In `{headless_mode}`, bind `{result_envelope_line}` to that line and do not display it here: the shared health check displays it verbatim as the run's last line. An interactive run runs the command for its result files and need not show the line. When the emitter exits non-zero and its `message` names the payload, fix the payload once and run it again; a write it could not make adds `result_file_write_failed` to the line's `warnings[]`. When the emitter exits non-zero and no retry applies, or fails again, or no path resolved for `{emitEnvelopeHelper}`, this run has no result contract: display its `message` (or that the emitter is missing), bind no `{result_envelope_line}`, and skip the hook as below.
|
|
271
271
|
|
|
272
272
|
- `status`: `"success"` here; §1 stages `"no-changes"`, §1a `"detect-only"` and §1b `"dry-run"`; a halt prints its own `halted-for-*` or `blocked` line through its step's halt procedure and never reaches this step. The full enum lives in the schema.
|
|
273
273
|
- `files_written[]`: `references` stands for every `references/*.md` file of the package this run wrote, added or removed, one value however many: merge.md §6b's merge, at any priority, write.md §6's provenance fixes and write.md §7's body split. Read it from the files on disk, never from memory: a `references/` path in the `files_written[]` write.md §6's provenance `fix` printed; a file the `Body split` row lists in this run's `## Update Operation` section of `{forge_version}/evidence-report.md`, the one write.md §4 appended, never an earlier update's; outside gap-driven mode, a `references/*.md` file that `{skill_package}` and the previous version's package (`{skill_group}/{baseline_version}/{skill_name}/references/`) do not hold alike, one lacking it or the two differing; in gap-driven mode, which repairs the version in place, a file in `{run_dir}/references/`, where merge.md §6b staged each one it wrote, or a path `{run_dir}/references-removed.txt` lists.
|
|
274
274
|
- `headless_decisions[]`: every gate's record in the run's decision log (init.md §4 degraded-rebuild and §8 confirmation, detect-changes.md §1b / §1c / §2.2, gap-driven.md §1 rule R1, merge.md §8). Each entry `{gate, default_action, taken_action, reason, evidence?}`. Empty when no gate auto-resolved.
|
|
275
275
|
- `error`: null on every exit this step prints. A halt's line carries `{phase, path?, reason}`, and pipelines branch on `error !== null` for non-zero exit semantics.
|
|
276
|
-
- `warnings[]`: every entry the run recorded, among them `source-tree:`, `source-not-fetched`, `file-diff-unavailable`, `no-baseline-time`, `moved-check-skipped` and `unknown-language` (detect-changes.md §2.1 Category A), `new-files-not-checked` (Category D: a skill with no brief, so no new script or asset was looked for), `source-version-lower`, `unconsumed-test-report` (init.md §4b), `interrupted-run-cleaned` and `interrupted-run-not-cleaned` (init.md §1b), `test-report:` entries (what the test report's helpers could not read, and `test-report: not routed: {id} ({category}): {reason}` for each gap gap-driven.md §1 did not route), `proposed-amendment:` (a read-only run's brief decisions, not written), `doc-fetch-failed`, `doc-not-hashed` and `doc-drift-not-checked` (a docs-only skill's documents), `workspace-clone-not-updated`, `target-ref-not-recorded`, `workspace_drift_overridden` (gap-driven.md §3), `run-state-not-finished`, `public-api-counted-by-hand:` (write.md §2: the recipe runner exited 2, so the public names were found without it; it quotes the runner's error line) and `provenance:` entries (write.md §3 and §6: provenance findings left for a person, and spot-check entries §3 left for a person to decide).
|
|
276
|
+
- `warnings[]`: every entry the run recorded, among them `source-tree:`, `source-not-fetched`, `file-diff-unavailable`, `no-baseline-time`, `moved-check-skipped` and `unknown-language` (detect-changes.md §2.1 Category A), `new-files-not-checked` (Category D: a skill with no brief, so no new script or asset was looked for), `source-version-lower`, `unconsumed-test-report` (init.md §4b), `interrupted-run-cleaned` and `interrupted-run-not-cleaned` (init.md §1b), `test-report:` entries (what the test report's helpers could not read, and `test-report: not routed: {id} ({category}): {reason}` for each gap gap-driven.md §1 did not route), `proposed-amendment:` (a read-only run's brief decisions, not written), `doc-fetch-failed`, `doc-not-hashed` and `doc-drift-not-checked` (a docs-only skill's documents), `workspace-clone-not-updated`, `target-ref-not-recorded`, `workspace_drift_overridden` (gap-driven.md §3), `run-state-not-finished`, `result_file_write_failed` (a result file the emitter could not write; the hook then does not run), `public-api-counted-by-hand:` (write.md §2: the recipe runner exited 2, so the public names were found without it; it quotes the runner's error line) and `provenance:` entries (write.md §3 and §6: provenance findings left for a person, and spot-check entries §3 left for a person to decide).
|
|
277
277
|
|
|
278
|
-
**Post-finalization hook.** A finished run, this one or §1's no-change exit, fires the hook; `--detect-only`, `--dry-run` and a halt never do (customize.toml says so). If `{onCompleteCommand}` (resolved in SKILL.md On Activation §4 from `workflow.on_complete`) is non-empty,
|
|
278
|
+
**Post-finalization hook.** A finished run, this one or §1's no-change exit, fires the hook; `--detect-only`, `--dry-run` and a halt never do (customize.toml says so). If `{onCompleteCommand}` (resolved in SKILL.md On Activation §4 from `workflow.on_complete`) is non-empty, the emitter exited 0 and the line's `warnings[]` hold no entry that starts with `result_file_write_failed` (the emitter wrote both result files), invoke it:
|
|
279
279
|
|
|
280
280
|
```bash
|
|
281
281
|
{onCompleteCommand} --result-path={forge_version}/update-skill-result-latest.json
|
|
282
282
|
```
|
|
283
283
|
|
|
284
|
+
The line has no `result_path`: any `result_file_write_failed` entry means this run's `update-skill-result-latest.json` was not written, since the emitter stops at the first write that fails. An interactive run reads the line for this check without displaying it. When `{onCompleteCommand}` is non-empty but the hook does not run, skip it and say why:
|
|
285
|
+
|
|
286
|
+
- The run has no result contract (above): "on_complete skipped: no result contract was written ({the reason})".
|
|
287
|
+
- The line holds such an entry: "on_complete skipped: `update-skill-result-latest.json` was not written."
|
|
288
|
+
|
|
289
|
+
The hook never gets a `-latest` copy this run failed to write.
|
|
290
|
+
|
|
284
291
|
Run it with a bounded timeout (default 60s). On success, log an Info note and continue; on non-zero exit, timeout, or any failure, tell the user in one line with the reason (the line and the result files are written before the hook runs, so they cannot carry its failure) and continue. The hook must never fail the workflow: it is integration glue (notify a CI router, chain audit/export/test) orthogonal to the update outcome. Empty `{onCompleteCommand}` = no-op, no log entry.
|
|
285
292
|
|
|
286
293
|
### 6. Chain to Health Check
|
|
@@ -461,7 +461,7 @@ Do not rely on per-call ad-hoc preservation logic: use the helper.
|
|
|
461
461
|
|
|
462
462
|
**If skill-check is available:**
|
|
463
463
|
|
|
464
|
-
- Run `npx skill-check check "{skill_package}" --fix --format json` **inside the §0 guard**. One call validates the package against the spec, scores it, fixes what has one answer and runs the security scan (on unless `--no-security-scan` is passed). Read the JSON, not the exit code: the quality score (`scores[].score`, the entry matching the skill, or a top-level `qualityScore` on an older skill-check), the remaining `diagnostics[]`, `fixed[]` and the security findings. When the scan could not run (no `SNYK_TOKEN`), record `Security: SKIP (SNYK_TOKEN not configured)`.
|
|
464
|
+
- Run `npx skill-check check "{skill_package}" --fix --format json` **inside the §0 guard**. One call validates the package against the spec, scores it, fixes what has one answer and runs the security scan (on unless `--no-security-scan` is passed). Read the JSON, not the exit code: the quality score (`scores[].score`, the entry matching the skill, or a top-level `qualityScore` on an older skill-check), the remaining `diagnostics[]`, `fixed[]` and the security findings. When the scan could not run (no `SNYK_TOKEN`: skill-check exits non-zero and its stderr names `SNYK_TOKEN`), record `Security: SKIP (SNYK_TOKEN not configured)`.
|
|
465
465
|
- **Context sync after --fix:** if `fixed[]` is non-empty, re-read the SKILL.md `--fix` changed into the in-context copy, so the report does not work from a copy that differs from the file on disk. The §0 guard has already restored `description` if it diverged.
|
|
466
466
|
- If `body.max_lines` or `body.max_tokens` is reported, split the body selectively with `{shardBodyHelper}` (resolve it ← first existing path in `{shardBodyProbeOrder}`), **inside the §0 guard**: from `{project-root}`, run `uv run {shardBodyHelper} "{skill_package}/SKILL.md" --budget 400 --word-budget 4500 --skip-manual`, with `--budget 500` in place of `--budget 400` when only `body.max_tokens` is reported. It moves the largest Tier 2 (`## Full`) sections to `references/`, each replaced by a `> See` pointer to a file it never overwrites, until the body is within that many lines and 4,500 words, the same headroom under skill-check's 5,000 that 400 lines leave under its 500, and keeps Tier 1 inline (inline passive context achieves 100% task accuracy vs 79% for on-demand retrieval). **Never move a section that holds a `[MANUAL]` block:** init.md §5 inventories SKILL.md only, so a block moved to `references/` would leave the next update's inventory; `--skip-manual` keeps the helper off every section a block reaches into. No Tier 1 content is trimmed, and `split-body` never runs after the helper. Add each WARN below to `warnings[]` as well. Act on its report:
|
|
467
467
|
- **Exit 1 or 2, or no JSON:** HALT with status `blocked` (halt procedure: `phase: "write:body-split"`, `path: "{skill_package}/SKILL.md"`, `reason: "body split failed: {its stderr line}"`). On exit 2 that line ends `already written: {each references/*.md file it wrote before the failure, or none}`: name each of those files in the halt message.
|