bmad-module-skill-forge 4.0.0 → 5.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 +3 -3
- package/docs/_data/pinned.yaml +19 -13
- package/docs/architecture.md +2 -2
- package/docs/concepts.md +4 -4
- package/docs/examples.md +32 -25
- package/docs/getting-started.md +3 -3
- package/docs/how-it-works.md +2 -2
- package/docs/index.md +1 -1
- package/docs/skill-model.md +41 -39
- package/docs/verifying-a-skill.md +41 -20
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +2 -2
- package/package.json +1 -1
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
- package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
- package/src/shared/scripts/skf-build-change-manifest.py +881 -168
- package/src/shared/scripts/skf-count-tokens.py +54 -10
- package/src/shared/scripts/skf-detect-workspaces.py +67 -0
- package/src/shared/scripts/skf-emit-result-envelope.py +6 -5
- package/src/shared/scripts/skf-extract-public-api.py +439 -107
- package/src/shared/scripts/skf-extraction-inventory.py +10 -6
- package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
- package/src/shared/scripts/skf-parse-gaps.py +135 -26
- package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
- package/src/shared/scripts/skf-resolve-customization.py +197 -0
- package/src/shared/scripts/skf-shard-body.py +239 -41
- package/src/shared/scripts/skf-validate-output.py +12 -3
- package/src/shared/scripts/skf-verify-provenance-completeness.py +470 -38
- package/src/shared/tool-requirements.yaml +8 -2
- package/src/skf-analyze-source/SKILL.md +2 -2
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-audit-skill/SKILL.md +2 -2
- package/src/skf-audit-skill/references/re-index.md +2 -2
- package/src/skf-brief-skill/SKILL.md +2 -2
- package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
- package/src/skf-brief-skill/references/scope-definition.md +4 -4
- package/src/skf-campaign/SKILL.md +2 -2
- package/src/skf-campaign/references/step-05-skill-loop.md +6 -1
- package/src/skf-create-skill/SKILL.md +2 -2
- package/src/skf-create-skill/assets/skill-sections.md +1 -1
- package/src/skf-create-skill/references/extract.md +2 -1
- package/src/skf-create-skill/references/extraction-patterns.md +3 -3
- package/src/skf-create-skill/references/report.md +6 -1
- package/src/skf-create-stack-skill/SKILL.md +2 -2
- package/src/skf-create-stack-skill/references/validate.md +1 -1
- package/src/skf-drop-skill/SKILL.md +2 -2
- package/src/skf-export-skill/SKILL.md +2 -2
- package/src/skf-export-skill/assets/snippet-format.md +5 -5
- package/src/skf-export-skill/references/generate-snippet.md +22 -15
- package/src/skf-export-skill/references/load-skill.md +1 -1
- package/src/skf-export-skill/references/token-report.md +29 -9
- package/src/skf-forger/SKILL.md +2 -2
- package/src/skf-quick-skill/SKILL.md +2 -2
- package/src/skf-refine-architecture/SKILL.md +6 -8
- package/src/skf-rename-skill/SKILL.md +2 -2
- package/src/skf-setup/SKILL.md +2 -2
- package/src/skf-test-skill/SKILL.md +2 -2
- package/src/skf-test-skill/assets/output-section-formats.md +6 -4
- package/src/skf-test-skill/references/coherence-check.md +4 -3
- package/src/skf-test-skill/references/coverage-check-branches.md +61 -0
- package/src/skf-test-skill/references/coverage-check-tiers.md +1 -1
- package/src/skf-test-skill/references/coverage-check.md +45 -69
- package/src/skf-test-skill/references/init.md +2 -1
- package/src/skf-test-skill/references/migration-section-rules.md +26 -14
- package/src/skf-test-skill/references/report.md +2 -2
- package/src/skf-test-skill/references/scoring-rules.md +5 -4
- package/src/skf-test-skill/references/source-access-protocol.md +6 -6
- package/src/skf-test-skill/scripts/gap-ledger.py +378 -163
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +171 -70
- package/src/skf-test-skill/scripts/locate-export-segments.py +101 -20
- package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
- package/src/skf-test-skill/scripts/score-signatures.py +329 -54
- package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
- package/src/skf-test-skill/scripts/validate-inventory.py +108 -9
- package/src/skf-test-skill/templates/test-report-template.md +1 -0
- package/src/skf-update-skill/SKILL.md +2 -2
- package/src/skf-update-skill/references/detect-changes.md +1 -1
- package/src/skf-update-skill/references/gap-driven.md +36 -21
- package/src/skf-update-skill/references/merge.md +15 -10
- package/src/skf-update-skill/references/re-extract.md +3 -1
- package/src/skf-update-skill/references/report.md +5 -4
- package/src/skf-update-skill/references/write.md +39 -18
- package/src/skf-verify-stack/SKILL.md +2 -2
- package/tools/cli/lib/tool-check.js +158 -2
|
@@ -15,20 +15,33 @@ ratios and the gap records of the wrong signatures.
|
|
|
15
15
|
|
|
16
16
|
plan the comparisons to make. For each documented export with a
|
|
17
17
|
signature (an inventory entry of any kind but `method` that carries
|
|
18
|
-
`params`, `return_type` or `usage_signature
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
`params`, `return_type` or `usage_signature`; a function's `params`
|
|
19
|
+
counts when empty, its form for one that takes none) in the
|
|
20
|
+
signature universe, grouped by source file, with the line that
|
|
21
|
+
defines it in the source and the documented signature (with the
|
|
22
|
+
entry's `signature_form` when it has one; of several entries of one
|
|
23
|
+
name, one that lists parameters or gives a usage form before one
|
|
24
|
+
that does neither, then a full one before a partial one, then, of
|
|
25
|
+
two full or two partial forms, one that lists parameters before one
|
|
26
|
+
that gives only a usage form, else the first). The signature
|
|
27
|
+
universe is the surface's `all` set plus every name on its export
|
|
28
|
+
records, so the names of a Python sub-package load-coverage-inputs.py
|
|
29
|
+
keeps out of `all` (excluded.nestedEntries) keep their checks. A check sits at the
|
|
30
|
+
name's first surface record (one with a file before one with none
|
|
31
|
+
or a blank one). For a name the surface records in more than one
|
|
32
|
+
file, --skill-dir (the skill package) picks the record whose file
|
|
33
|
+
the skill cites on a line that names it
|
|
34
|
+
(validate-inventory.py's _skill_cited_files) when exactly one of
|
|
35
|
+
those files is cited; otherwise the check stays at the first record
|
|
36
|
+
and lists every record with a file as `candidates` for the judge to
|
|
37
|
+
read. Also the whole documented-signature map, for the fallback scan
|
|
38
|
+
that has no surface lines yet.
|
|
26
39
|
score the categories. Reads every subagent result, checks its schema,
|
|
27
40
|
and computes:
|
|
28
41
|
Signature Accuracy = matching / compared * 100, where compared is
|
|
29
42
|
the documented signatures of the signature universe (with a
|
|
30
43
|
fallback scan's exports_found) and matching those no result
|
|
31
|
-
names in
|
|
44
|
+
names in a Critical mismatch: a wrong signature is wrong
|
|
32
45
|
whichever set the denominator counts
|
|
33
46
|
Type Coverage = documented types / total types * 100, where
|
|
34
47
|
total types counts the names of the --surface-set set (the
|
|
@@ -38,11 +51,35 @@ ratios and the gap records of the wrong signatures.
|
|
|
38
51
|
interface) and documented types those the inventory documents
|
|
39
52
|
A category with nothing to compare scores 100 (nothing documented
|
|
40
53
|
is wrong) and says so in `warnings`. Each signature mismatch of a
|
|
41
|
-
compared name becomes a
|
|
42
|
-
|
|
43
|
-
--gaps-output)
|
|
44
|
-
the
|
|
45
|
-
|
|
54
|
+
compared name is graded (grade()) and becomes a record in the gap
|
|
55
|
+
ledger's record format (gap-ledger.py append --input reads
|
|
56
|
+
--gaps-output). The judge's `difference` tag alone never lowers a
|
|
57
|
+
grade: the script checks the fact each one rests on.
|
|
58
|
+
Low `signature-mismatch` `difference` "parameter-name" reported
|
|
59
|
+
at a file the plan offered for the name (the file it sent the
|
|
60
|
+
check to, or a candidate), when that file is of a language
|
|
61
|
+
whose callers cannot pass an argument by its name
|
|
62
|
+
(POSITIONAL_EXTENSIONS: TS/JS, Go, Rust, Java, C, C++; never
|
|
63
|
+
Python, whose positional-only parameters the script cannot see,
|
|
64
|
+
nor a Vue component, whose props callers set by name)
|
|
65
|
+
Info `observation`, titled `Partial signature: {name}`
|
|
66
|
+
`difference` "omitted-optional" on a documented signature whose
|
|
67
|
+
inventory entry is `signature_form` "partial"
|
|
68
|
+
Critical `signature-mismatch` every other mismatch
|
|
69
|
+
A mismatch's `file` (normalized: `/` separated, no `./`) is the file
|
|
70
|
+
it is graded and recorded at (the gap's Source) when the plan
|
|
71
|
+
offered that file for the name: the file of its check or one of the
|
|
72
|
+
check's candidates, read from --plan, else every file the surface
|
|
73
|
+
records for the name. Any other `file` is listed in `warnings`, and
|
|
74
|
+
the result's `file` stands, as it does when there is none. Results
|
|
75
|
+
that report one declaration (name, file and line) more than once
|
|
76
|
+
keep its most severe grade: Critical, then Low, then Info. Of the
|
|
77
|
+
mismatches of a check that offered several files, the least severe
|
|
78
|
+
stands (the closest candidate), the first by file and line on a
|
|
79
|
+
tie: one documented signature, one gap. A Low or an Info mismatch
|
|
80
|
+
still counts as matching. A mismatch a result reports for any
|
|
81
|
+
other name (one the skill does not document, or the surface lacks)
|
|
82
|
+
is listed in `warnings` instead, and no gap.
|
|
46
83
|
|
|
47
84
|
Inputs, all files in the run folder:
|
|
48
85
|
--inventory the validated inventory validate-inventory.py wrote (--output)
|
|
@@ -58,29 +95,45 @@ Inputs, all files in the run folder:
|
|
|
58
95
|
"exports_found": ["..."], (fallback scan only)
|
|
59
96
|
"types_found": ["..."], (fallback scan only)
|
|
60
97
|
"signature_mismatches": [
|
|
61
|
-
{"name", "line", "source_sig", "documented_sig", "issue"
|
|
98
|
+
{"name", "line", "source_sig", "documented_sig", "issue",
|
|
99
|
+
"difference", "file"}]} (difference optional: one of
|
|
100
|
+
DIFFERENCES, absent or null for none;
|
|
101
|
+
file optional: the candidate the
|
|
102
|
+
mismatch was found at, a non-empty
|
|
103
|
+
string, absent or null for the
|
|
104
|
+
result's `file`)
|
|
105
|
+
--skill-dir plan only: the skill package under test (the folder that
|
|
106
|
+
holds SKILL.md), whose citations pick the declaration of a
|
|
107
|
+
name the surface records in several files
|
|
108
|
+
--plan score only: the plan's output, whose checks name the files a
|
|
109
|
+
mismatch of each name may be recorded at
|
|
62
110
|
|
|
63
111
|
Rounding is compute-score.py's (JS-compatible half-up, two decimals).
|
|
64
112
|
|
|
65
113
|
Usage:
|
|
66
|
-
uv run score-signatures.py plan --inventory <file> [--surface <file>] [--output <file>]
|
|
67
|
-
uv run score-signatures.py score --inventory <file> --surface <file> [--surface-set <set>] \\
|
|
114
|
+
uv run score-signatures.py plan --inventory <file> [--surface <file>] [--skill-dir <dir>] [--output <file>]
|
|
115
|
+
uv run score-signatures.py score --inventory <file> --surface <file> [--surface-set <set>] [--plan <file>] \\
|
|
68
116
|
--results <file> [--results <file> ...] [--output <file>] [--gaps-output <file>]
|
|
69
117
|
|
|
70
118
|
Output (stdout; --output also writes it, --gaps-output writes the records):
|
|
71
119
|
plan: {"compared": N, "files": [{"file", "checks": [{"name", "line",
|
|
72
|
-
"signatureLine", "documented": {...}
|
|
120
|
+
"signatureLine", "documented": {...}, "candidates": [{"file",
|
|
121
|
+
"line", "signatureLine"}] (only when the citation does not settle
|
|
122
|
+
the declaration)}]}], "documentedSignatures": {...}}
|
|
73
123
|
score: {"valid": true, "signatureAccuracy", "matchingSignatures",
|
|
74
124
|
"totalDocumented", "comparedNames": [the names compared],
|
|
75
125
|
"typeCoverage", "documentedTypes", "totalTypes",
|
|
76
|
-
"missingTypes": [...], "mismatches": [
|
|
126
|
+
"missingTypes": [...], "mismatches": [{"name", "file", "line",
|
|
127
|
+
"source_sig", "documented_sig", "issue", "difference" (null for
|
|
128
|
+
none), "severity" (Critical, Low or Info)}], "gapRecords": [...],
|
|
77
129
|
"warnings": [...]}
|
|
78
130
|
or {"valid": false, "violations": [...]} when a result breaks the schema
|
|
79
131
|
|
|
80
132
|
Exit codes:
|
|
81
133
|
0 the result was printed
|
|
82
|
-
1 an input file cannot be read or is not the JSON it should be,
|
|
83
|
-
surface has no --surface-set set
|
|
134
|
+
1 an input file cannot be read or is not the JSON it should be, the
|
|
135
|
+
surface has no --surface-set set, or the --skill-dir folder holds no
|
|
136
|
+
readable SKILL.md
|
|
84
137
|
2 a subagent result breaks the schema (violations[] names each breach;
|
|
85
138
|
nothing is written to --output or --gaps-output)
|
|
86
139
|
"""
|
|
@@ -92,13 +145,28 @@ import importlib.util
|
|
|
92
145
|
import json
|
|
93
146
|
import math
|
|
94
147
|
import sys
|
|
95
|
-
from pathlib import Path
|
|
148
|
+
from pathlib import Path, PurePosixPath
|
|
96
149
|
|
|
97
150
|
# Surface kinds that count toward total_types. A Rust struct is a class and a
|
|
98
151
|
# trait an interface; `type` is a type alias.
|
|
99
152
|
TYPE_KINDS = frozenset({"interface", "type", "enum", "class", "struct", "trait"})
|
|
100
153
|
SIGNATURE_FIELDS = ("params", "return_type", "usage_signature")
|
|
101
154
|
MISMATCH_FIELDS = ("name", "line", "source_sig", "documented_sig", "issue")
|
|
155
|
+
# The optional `difference` a judge tags a mismatch with (coverage-check.md
|
|
156
|
+
# section 2): only the parameter names differ, or only members the documented
|
|
157
|
+
# form leaves out that the source marks optional.
|
|
158
|
+
DIFFERENCES = ("parameter-name", "omitted-optional")
|
|
159
|
+
# Source files of the languages with no keyword arguments, where a caller
|
|
160
|
+
# never writes a parameter's name: TS/JS, Go, Rust, Java, C and C++. Not
|
|
161
|
+
# Python, which has keyword arguments (the script cannot see which
|
|
162
|
+
# parameters a caller must pass by position), nor Kotlin, C#, Swift or
|
|
163
|
+
# Scala, which name arguments at the call, nor a Vue component, whose
|
|
164
|
+
# documented parameters are props callers set by name.
|
|
165
|
+
POSITIONAL_EXTENSIONS = frozenset({".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".go", ".rs",
|
|
166
|
+
".java", ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp"})
|
|
167
|
+
# The grades of a mismatch, most severe first: one declaration reported
|
|
168
|
+
# twice keeps the most severe.
|
|
169
|
+
GRADES = ("Critical", "Low", "Info")
|
|
102
170
|
|
|
103
171
|
_INVENTORY_MODULE = None
|
|
104
172
|
|
|
@@ -169,6 +237,29 @@ def load_surface(path: str, surface_set: str) -> tuple[list[str], list[dict], li
|
|
|
169
237
|
return name_set(surface_set), [e for e in exports if isinstance(e, dict)], name_set("all")
|
|
170
238
|
|
|
171
239
|
|
|
240
|
+
def load_plan(path: str) -> dict[str, list[str]]:
|
|
241
|
+
"""{name: the files plan offered its check at, the planned one first,
|
|
242
|
+
then its candidates'} from plan's output."""
|
|
243
|
+
data = _read_json(path, "--plan")
|
|
244
|
+
groups = data.get("files") if isinstance(data, dict) else None
|
|
245
|
+
if not isinstance(groups, list):
|
|
246
|
+
raise InputError(f"--plan {path} is not score-signatures.py plan output (no `files` list)")
|
|
247
|
+
offered: dict[str, list[str]] = {}
|
|
248
|
+
for group in groups:
|
|
249
|
+
checks = group.get("checks") if isinstance(group, dict) else None
|
|
250
|
+
for check in checks if isinstance(checks, list) else []:
|
|
251
|
+
if not (isinstance(check, dict) and isinstance(check.get("name"), str)):
|
|
252
|
+
continue
|
|
253
|
+
files = [str(group.get("file") or "")]
|
|
254
|
+
candidates = check.get("candidates")
|
|
255
|
+
for candidate in candidates if isinstance(candidates, list) else []:
|
|
256
|
+
file = candidate.get("file") if isinstance(candidate, dict) else None
|
|
257
|
+
if isinstance(file, str) and file.strip() and file not in files:
|
|
258
|
+
files.append(file)
|
|
259
|
+
offered[check["name"]] = files
|
|
260
|
+
return offered
|
|
261
|
+
|
|
262
|
+
|
|
172
263
|
def check_result(item: object, where: str) -> list[str]:
|
|
173
264
|
"""The schema breaches of one per-file result object."""
|
|
174
265
|
if not isinstance(item, dict):
|
|
@@ -199,6 +290,12 @@ def check_result(item: object, where: str) -> list[str]:
|
|
|
199
290
|
for field in ("name", "source_sig", "documented_sig", "issue"):
|
|
200
291
|
if not (isinstance(entry[field], str) and entry[field].strip()):
|
|
201
292
|
errors.append(f"{at}: `{field}` must be a non-empty string")
|
|
293
|
+
if entry.get("difference") is not None and entry["difference"] not in DIFFERENCES:
|
|
294
|
+
errors.append(f"{at}: `difference` must be one of {', '.join(DIFFERENCES)} when present, "
|
|
295
|
+
f"not {json.dumps(entry['difference'])}")
|
|
296
|
+
# The candidate a mismatch was found at, when the plan handed the judge several.
|
|
297
|
+
if entry.get("file") is not None and not (isinstance(entry["file"], str) and entry["file"].strip()):
|
|
298
|
+
errors.append(f"{at}: `file` must be a non-empty string when present, not {json.dumps(entry['file'])}")
|
|
202
299
|
return errors
|
|
203
300
|
|
|
204
301
|
|
|
@@ -232,21 +329,66 @@ def load_results(paths: list[str]) -> tuple[list[dict], list[str]]:
|
|
|
232
329
|
|
|
233
330
|
|
|
234
331
|
def _has_signature(entry: dict) -> bool:
|
|
332
|
+
"""`params`, `return_type` or `usage_signature` as a list or a non-blank
|
|
333
|
+
string, or, for a kind the inventory contract gives `params`
|
|
334
|
+
(validate-inventory.py's SIGNATURE_KINDS), an empty `params` string: the
|
|
335
|
+
form of one that takes none."""
|
|
336
|
+
if entry.get("kind") in _inventory().SIGNATURE_KINDS and isinstance(entry.get("params"), str):
|
|
337
|
+
return True
|
|
235
338
|
return any(
|
|
236
339
|
isinstance(entry.get(f), list) or (isinstance(entry.get(f), str) and entry[f].strip())
|
|
237
340
|
for f in SIGNATURE_FIELDS
|
|
238
341
|
)
|
|
239
342
|
|
|
240
343
|
|
|
344
|
+
def _partial(fields: dict) -> bool:
|
|
345
|
+
return fields.get("signature_form") == "partial"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _lists_params(fields: dict) -> bool:
|
|
349
|
+
params = fields.get("params")
|
|
350
|
+
return (isinstance(params, str) and bool(params.strip())) or (isinstance(params, list) and bool(params))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _gives_usage(fields: dict) -> bool:
|
|
354
|
+
"""A non-blank `usage_signature` string, or a list holding one."""
|
|
355
|
+
usage = fields.get("usage_signature")
|
|
356
|
+
return any(isinstance(u, str) and bool(u.strip()) for u in (usage if isinstance(usage, list) else [usage]))
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _lists_members(fields: dict) -> bool:
|
|
360
|
+
"""A non-blank `params` string or non-empty list, or a non-blank
|
|
361
|
+
`usage_signature` string or a list holding one: an entry that says what
|
|
362
|
+
the name takes (a component's usage form names its props even when its
|
|
363
|
+
`params` is empty)."""
|
|
364
|
+
return _lists_params(fields) or _gives_usage(fields)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _rank(fields: dict) -> tuple[bool, bool, bool]:
|
|
368
|
+
"""How one of several entries of a name ranks, lowest compared first."""
|
|
369
|
+
return not _lists_members(fields), _partial(fields), not _lists_params(fields)
|
|
370
|
+
|
|
371
|
+
|
|
241
372
|
def documented_signatures(inventory: list[dict]) -> dict[str, dict]:
|
|
242
|
-
"""{name: its documented signature fields} for each documented export
|
|
373
|
+
"""{name: its documented signature fields} for each documented export
|
|
374
|
+
with one, with the entry's `signature_form` when it has one. Of several
|
|
375
|
+
entries of one name, one that lists parameters or gives a usage form
|
|
376
|
+
(_lists_members) is compared before one that does neither (empty or no
|
|
377
|
+
`params` and no usage form: it never hides one that says what the name
|
|
378
|
+
takes), then a full one (no `signature_form`, or `full`) before a
|
|
379
|
+
partial one (a form the skill also gives in full is held to it), then,
|
|
380
|
+
of two full or two partial forms, one that lists parameters is
|
|
381
|
+
compared before one that gives only a usage form, else the first."""
|
|
243
382
|
out: dict[str, dict] = {}
|
|
244
383
|
for entry in inventory:
|
|
245
384
|
name = entry.get("name")
|
|
246
|
-
if entry.get("kind") == "method" or not (isinstance(name, str) and name) or
|
|
385
|
+
if entry.get("kind") == "method" or not (isinstance(name, str) and name) or not _has_signature(entry):
|
|
247
386
|
continue
|
|
248
|
-
if
|
|
249
|
-
|
|
387
|
+
fields = {f: entry[f] for f in SIGNATURE_FIELDS if f in entry}
|
|
388
|
+
if entry.get("signature_form") is not None:
|
|
389
|
+
fields["signature_form"] = entry["signature_form"]
|
|
390
|
+
if name not in out or _rank(fields) < _rank(out[name]):
|
|
391
|
+
out[name] = fields
|
|
250
392
|
return out
|
|
251
393
|
|
|
252
394
|
|
|
@@ -255,9 +397,17 @@ def documented_names(inventory: list[dict]) -> set[str]:
|
|
|
255
397
|
if isinstance(e.get("name"), str) and e["name"] and e.get("kind") != "method"}
|
|
256
398
|
|
|
257
399
|
|
|
400
|
+
def _record_file(record: dict) -> str:
|
|
401
|
+
"""A surface record's file, "" for none or a blank one."""
|
|
402
|
+
file = str(record.get("file") or "")
|
|
403
|
+
return file if file.strip() else ""
|
|
404
|
+
|
|
405
|
+
|
|
258
406
|
def _records_by_name(exports: list[dict]) -> dict[str, list[dict]]:
|
|
407
|
+
"""{name: its surface records}, by file then line, a record with no file
|
|
408
|
+
last: a by-eye record never stands in for the file an extraction names."""
|
|
259
409
|
by_name: dict[str, list[dict]] = {}
|
|
260
|
-
for record in sorted(exports, key=lambda r: (
|
|
410
|
+
for record in sorted(exports, key=lambda r: (not _record_file(r), _record_file(r), r.get("line") or 0)):
|
|
261
411
|
name = record.get("name")
|
|
262
412
|
if isinstance(name, str) and name:
|
|
263
413
|
by_name.setdefault(name, []).append(record)
|
|
@@ -272,23 +422,54 @@ def signature_universe(names: list[str], exports: list[dict]) -> set[str]:
|
|
|
272
422
|
return set(names) | set(_records_by_name(exports))
|
|
273
423
|
|
|
274
424
|
|
|
275
|
-
def
|
|
425
|
+
def _path(file: str) -> str:
|
|
426
|
+
"""A path as a citation, a candidate and a surface record compare (validate-inventory.py's _cited_path)."""
|
|
427
|
+
return _inventory()._cited_path(file)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _check_at(name: str, records: list[dict], skill_dir: str | None,
|
|
431
|
+
texts: list[list[str]] | None) -> tuple[dict, list[dict] | None]:
|
|
432
|
+
"""(the surface record the check of `name` is planned at, the candidates
|
|
433
|
+
the judge reads, or None). One file among `records`: the first record.
|
|
434
|
+
Several: the record whose file the skill cites on a line that names
|
|
435
|
+
`name` when exactly one of them is cited (the first such record), else
|
|
436
|
+
the first record, with every record that names a file as a candidate.
|
|
437
|
+
`texts` is the skill's Markdown, read once (skill_markdown_lines)."""
|
|
438
|
+
first = records[0] if records else {}
|
|
439
|
+
filed = [r for r in records if _record_file(r)]
|
|
440
|
+
if len(filed) < 2 or len({_path(_record_file(r)) for r in filed}) < 2:
|
|
441
|
+
return first, None
|
|
442
|
+
if skill_dir is not None:
|
|
443
|
+
cited = {_path(_record_file(r)) for r in filed} & _inventory()._skill_cited_files(Path(skill_dir), name, texts)
|
|
444
|
+
if len(cited) == 1:
|
|
445
|
+
return next(r for r in filed if _path(_record_file(r)) in cited), None
|
|
446
|
+
return first, [{"file": _record_file(r), "line": r.get("line"), "signatureLine": r.get("signatureLine")}
|
|
447
|
+
for r in filed]
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def plan(inventory: list[dict], names: list[str] | None, exports: list[dict], skill_dir: str | None = None) -> dict:
|
|
276
451
|
"""The comparisons over the signature universe of `names` (the `all`
|
|
277
|
-
set) and `exports`; none without a surface (`names` None).
|
|
452
|
+
set) and `exports`; none without a surface (`names` None). `skill_dir`,
|
|
453
|
+
the skill package, settles which declaration a name the surface records
|
|
454
|
+
in several files is compared with (_check_at)."""
|
|
278
455
|
signatures = documented_signatures(inventory)
|
|
279
456
|
files: dict[str, list[dict]] = {}
|
|
280
457
|
compared = 0
|
|
281
458
|
if names is not None:
|
|
282
459
|
by_name = _records_by_name(exports)
|
|
460
|
+
texts = _inventory().skill_markdown_lines(Path(skill_dir)) if skill_dir is not None else None
|
|
283
461
|
for name in sorted(signature_universe(names, exports) & set(signatures)):
|
|
284
|
-
record = (by_name.get(name) or [
|
|
462
|
+
record, candidates = _check_at(name, by_name.get(name) or [], skill_dir, texts)
|
|
285
463
|
compared += 1
|
|
286
|
-
|
|
464
|
+
check = {
|
|
287
465
|
"name": name,
|
|
288
466
|
"line": record.get("line"),
|
|
289
467
|
"signatureLine": record.get("signatureLine"),
|
|
290
468
|
"documented": signatures[name],
|
|
291
|
-
}
|
|
469
|
+
}
|
|
470
|
+
if candidates:
|
|
471
|
+
check["candidates"] = candidates
|
|
472
|
+
files.setdefault(_record_file(record), []).append(check)
|
|
292
473
|
return {
|
|
293
474
|
"compared": compared,
|
|
294
475
|
"files": [{"file": f, "checks": checks} for f, checks in sorted(files.items())],
|
|
@@ -296,26 +477,86 @@ def plan(inventory: list[dict], names: list[str] | None, exports: list[dict]) ->
|
|
|
296
477
|
}
|
|
297
478
|
|
|
298
479
|
|
|
480
|
+
def _extension(file: str) -> str:
|
|
481
|
+
"""The lower-case extension of a source path, `/` or `\\` separated."""
|
|
482
|
+
return PurePosixPath(file.replace("\\", "/")).suffix.lower()
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _offered_files(offered: dict[str, list[str]] | None, by_name: dict[str, list[dict]], name: str) -> list[str]:
|
|
486
|
+
"""The files a mismatch of `name` may be recorded at, the planned one
|
|
487
|
+
first: the plan's check of `name` (its file, then its candidates') when
|
|
488
|
+
`offered` (read from --plan) holds it, else every file the surface
|
|
489
|
+
records for `name` ("" for a record with none); [] for a name the
|
|
490
|
+
surface lacks (a fallback scan's exports_found name)."""
|
|
491
|
+
if offered is not None and name in offered:
|
|
492
|
+
return offered[name]
|
|
493
|
+
files: list[str] = []
|
|
494
|
+
for record in by_name.get(name) or []:
|
|
495
|
+
if _record_file(record) not in files:
|
|
496
|
+
files.append(_record_file(record))
|
|
497
|
+
return files
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _is_offered_file(files: list[str], file: str) -> bool:
|
|
501
|
+
"""Whether `file` is one of `files` (_offered_files), however either writes its path."""
|
|
502
|
+
return _path(file) in {_path(f) for f in files}
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def grade(difference: str | None, file: str, documented: dict, declared: str | None = None) -> str:
|
|
506
|
+
"""The severity of one mismatch the result reports at `file`. The judge's
|
|
507
|
+
`difference` lowers it only where the script confirms the fact it rests
|
|
508
|
+
on: a parameter-name difference is Low when `declared`, the file the
|
|
509
|
+
plan offered the check at (else `file`), is the mismatch's file and of a
|
|
510
|
+
language with no keyword arguments; an omitted-optional difference on a
|
|
511
|
+
form the inventory marks partial is Info; every other mismatch is
|
|
512
|
+
Critical."""
|
|
513
|
+
if difference == "parameter-name":
|
|
514
|
+
at = file if declared is None else declared
|
|
515
|
+
if at.replace("\\", "/") == file.replace("\\", "/") and _extension(at) in POSITIONAL_EXTENSIONS:
|
|
516
|
+
return "Low"
|
|
517
|
+
if difference == "omitted-optional" and _partial(documented):
|
|
518
|
+
return "Info"
|
|
519
|
+
return "Critical"
|
|
520
|
+
|
|
521
|
+
|
|
299
522
|
def _gap_record(m: dict) -> dict:
|
|
300
523
|
name, where = m["name"], f"{m['file']}:{m['line']}"
|
|
524
|
+
issue = f"documented `{m['documented_sig']}`, source `{m['source_sig']}`: {m['issue']}"
|
|
525
|
+
remediation = (f"Update the documented signature of `{name}` from `{m['documented_sig']}` "
|
|
526
|
+
f"to `{m['source_sig']}`, the signature the source defines at `{where}`.")
|
|
527
|
+
if m["severity"] == "Info":
|
|
528
|
+
return {
|
|
529
|
+
"severity": "Info",
|
|
530
|
+
"category": "observation",
|
|
531
|
+
"title": f"Partial signature: {name}",
|
|
532
|
+
"source": where,
|
|
533
|
+
"export": name,
|
|
534
|
+
"issue": (f"the skill labels its documented form of `{name}` partial, and it leaves out only "
|
|
535
|
+
f"members the source marks optional: {issue}"),
|
|
536
|
+
"remediation": (f"No change: the documented form of `{name}` is labelled partial and leaves out "
|
|
537
|
+
f"only optional members of the signature the source defines at `{where}`."),
|
|
538
|
+
}
|
|
539
|
+
if m["severity"] == "Low":
|
|
540
|
+
issue = (f"{issue} (only the parameter names differ, and a caller of a `{_extension(m['file'])}` "
|
|
541
|
+
"file passes its arguments by position)")
|
|
301
542
|
return {
|
|
302
|
-
"severity": "
|
|
543
|
+
"severity": m["severity"],
|
|
303
544
|
"category": "signature-mismatch",
|
|
304
545
|
"title": f"Signature mismatch: {name}",
|
|
305
546
|
"source": where,
|
|
306
547
|
"export": name,
|
|
307
|
-
"issue":
|
|
308
|
-
"remediation":
|
|
309
|
-
f"to `{m['source_sig']}`, the signature the source defines at `{where}`."),
|
|
548
|
+
"issue": issue,
|
|
549
|
+
"remediation": remediation,
|
|
310
550
|
}
|
|
311
551
|
|
|
312
552
|
|
|
313
553
|
def score(inventory: list[dict], names: list[str], exports: list[dict], results: list[dict],
|
|
314
|
-
type_names: list[str] | None = None) -> dict:
|
|
554
|
+
type_names: list[str] | None = None, offered: dict[str, list[str]] | None = None) -> dict:
|
|
315
555
|
"""The two categories: Signature Accuracy over the signature universe of
|
|
316
556
|
`names` (the `all` set) and `exports`, Type Coverage over `type_names`
|
|
317
557
|
when given, else over `names`. A fallback scan's exports_found join the
|
|
318
|
-
universe, and `names` when no `type_names` is given.
|
|
558
|
+
universe, and `names` when no `type_names` is given. `offered` (read
|
|
559
|
+
from --plan) names the files the plan offered each check at."""
|
|
319
560
|
by_name = _records_by_name(exports)
|
|
320
561
|
# A fallback scan reports its own names and types.
|
|
321
562
|
fallback_types = {n for r in results for n in r.get("types_found") or []}
|
|
@@ -325,25 +566,51 @@ def score(inventory: list[dict], names: list[str], exports: list[dict], results:
|
|
|
325
566
|
compared = (surface | signature_universe(names, exports)) & set(signatures)
|
|
326
567
|
warnings = []
|
|
327
568
|
|
|
328
|
-
|
|
569
|
+
graded: dict[tuple, dict] = {}
|
|
329
570
|
seen = set()
|
|
330
571
|
for result in results:
|
|
331
572
|
for entry in result["signature_mismatches"]:
|
|
332
|
-
|
|
333
|
-
|
|
573
|
+
name = entry["name"]
|
|
574
|
+
files = _offered_files(offered, by_name, name)
|
|
575
|
+
# A mismatch found at one of a check's candidates names that file; any other file is not taken.
|
|
576
|
+
file = result["file"]
|
|
577
|
+
if entry.get("file") is not None:
|
|
578
|
+
if _is_offered_file(files, entry["file"]):
|
|
579
|
+
file = entry["file"]
|
|
580
|
+
elif name in compared:
|
|
581
|
+
warnings.append(f"{result['file']}:{entry['line']}: a signature mismatch for `{name}` names "
|
|
582
|
+
f"`{entry['file']}`, no file the plan offered its check at: recorded at "
|
|
583
|
+
"the result's file")
|
|
584
|
+
file = _path(file)
|
|
585
|
+
key = (name, file, entry["line"])
|
|
586
|
+
if name not in compared:
|
|
587
|
+
if key not in seen:
|
|
588
|
+
warnings.append(f"{file}:{entry['line']}: a signature mismatch for "
|
|
589
|
+
f"`{name}`, which is no documented signature on the source surface: "
|
|
590
|
+
"not scored, no gap")
|
|
591
|
+
seen.add(key)
|
|
334
592
|
continue
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
593
|
+
difference = entry.get("difference")
|
|
594
|
+
# Graded at the file only when the plan offered it: never Low at a namesake it did not offer.
|
|
595
|
+
declared = (file if _is_offered_file(files, file) else files[0]) if files else None
|
|
596
|
+
severity = grade(difference, file, signatures[name], declared)
|
|
597
|
+
# Every entry is graded: one declaration reported twice keeps its most severe grade.
|
|
598
|
+
if key in graded and GRADES.index(graded[key]["severity"]) <= GRADES.index(severity):
|
|
340
599
|
continue
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
600
|
+
graded[key] = {"name": name, "file": file, "line": entry["line"],
|
|
601
|
+
"source_sig": entry["source_sig"], "documented_sig": entry["documented_sig"],
|
|
602
|
+
"issue": entry["issue"], "difference": difference, "severity": severity}
|
|
603
|
+
# A check that offered several files keeps one mismatch, its least severe (the closest candidate).
|
|
604
|
+
mismatches, closest = [], {}
|
|
605
|
+
for m in sorted(graded.values(), key=lambda m: (m["file"], m["line"], m["name"])):
|
|
606
|
+
if len({_path(f) for f in _offered_files(offered, by_name, m["name"])}) < 2:
|
|
607
|
+
mismatches.append(m)
|
|
608
|
+
elif m["name"] not in closest or GRADES.index(m["severity"]) > GRADES.index(closest[m["name"]]["severity"]):
|
|
609
|
+
closest[m["name"]] = m
|
|
610
|
+
mismatches = sorted(mismatches + list(closest.values()), key=lambda m: (m["file"], m["line"], m["name"]))
|
|
611
|
+
|
|
612
|
+
# A Low or an Info mismatch is a caller-invisible difference: it still matches.
|
|
613
|
+
wrong = {m["name"] for m in mismatches if m["severity"] == "Critical"}
|
|
347
614
|
if compared:
|
|
348
615
|
signature_accuracy = round2((len(compared) - len(wrong)) / len(compared) * 100)
|
|
349
616
|
else:
|
|
@@ -397,7 +664,11 @@ def _cmd_plan(args: argparse.Namespace) -> tuple[dict, int]:
|
|
|
397
664
|
names, exports = (None, [])
|
|
398
665
|
if args.surface is not None:
|
|
399
666
|
_, exports, names = load_surface(args.surface, args.surface_set)
|
|
400
|
-
|
|
667
|
+
if args.skill_dir is not None:
|
|
668
|
+
if not args.skill_dir.strip():
|
|
669
|
+
raise InputError("--skill-dir is empty: pass the skill package (the folder that holds SKILL.md)")
|
|
670
|
+
_read_text(str(Path(args.skill_dir) / "SKILL.md"), "--skill-dir")
|
|
671
|
+
out = plan(inventory, names, exports, args.skill_dir)
|
|
401
672
|
if args.output:
|
|
402
673
|
_write_json(args.output, out)
|
|
403
674
|
return out, 0
|
|
@@ -412,7 +683,8 @@ def _cmd_score(args: argparse.Namespace) -> tuple[dict, int]:
|
|
|
412
683
|
if path:
|
|
413
684
|
Path(path).unlink(missing_ok=True)
|
|
414
685
|
return {"valid": False, "violations": violations}, 2
|
|
415
|
-
|
|
686
|
+
offered = load_plan(args.plan) if args.plan is not None else None
|
|
687
|
+
out = score(inventory, names, exports, results, None if args.surface_set == "all" else chosen, offered)
|
|
416
688
|
if args.output:
|
|
417
689
|
_write_json(args.output, out)
|
|
418
690
|
if args.gaps_output:
|
|
@@ -433,6 +705,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
433
705
|
p.add_argument("--surface-set", default="all",
|
|
434
706
|
help="a surface name set, which must exist (default: all); the plan compares the signature "
|
|
435
707
|
"universe whichever set it names")
|
|
708
|
+
p.add_argument("--skill-dir", help="the skill package under test: its citations pick the declaration of a name "
|
|
709
|
+
"the surface records in several files")
|
|
436
710
|
p.add_argument("--output", help="also write the plan to this file")
|
|
437
711
|
p.set_defaults(func=_cmd_plan)
|
|
438
712
|
|
|
@@ -441,6 +715,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
441
715
|
p.add_argument("--surface", required=True, help="load-coverage-inputs.py surface output")
|
|
442
716
|
p.add_argument("--surface-set", default="all",
|
|
443
717
|
help="the surface name set Type Coverage counts (default: all)")
|
|
718
|
+
p.add_argument("--plan", help="the plan's output: the files each check offered a mismatch at")
|
|
444
719
|
p.add_argument("--results", action="append", help="a subagent result file (repeatable)")
|
|
445
720
|
p.add_argument("--output", help="also write the scores to this file")
|
|
446
721
|
p.add_argument("--gaps-output", help="write the gap ledger records to this file")
|