bmad-module-skill-forge 3.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 +33 -26
- 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/troubleshooting.md +8 -0
- package/docs/verifying-a-skill.md +46 -21
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +5 -5
- package/package.json +1 -1
- package/src/knowledge/qmd-registry.md +1 -1
- package/src/knowledge/version-paths.md +1 -1
- package/src/shared/health-check.md +2 -50
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +12 -4
- package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
- package/src/shared/scripts/skf-atomic-write.py +212 -59
- package/src/shared/scripts/skf-build-change-manifest.py +1437 -178
- package/src/shared/scripts/skf-classify-changed-files.py +78 -0
- package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
- package/src/shared/scripts/skf-count-tokens.py +54 -10
- package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
- package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
- 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 +1673 -189
- package/src/shared/scripts/skf-extraction-inventory.py +584 -46
- package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
- package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
- package/src/shared/scripts/skf-github-probe.py +47 -8
- package/src/shared/scripts/skf-hash-content.py +14 -8
- package/src/shared/scripts/skf-load-provenance.py +3 -1
- package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
- package/src/shared/scripts/skf-parse-gaps.py +135 -26
- package/src/shared/scripts/skf-preflight.py +15 -1
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
- package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
- package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
- 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-structural-diff.py +133 -18
- package/src/shared/scripts/skf-update-active-symlink.py +115 -62
- package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
- package/src/shared/scripts/skf-validate-output.py +12 -3
- package/src/shared/scripts/skf-verify-provenance-completeness.py +1439 -79
- package/src/shared/scripts/skf-write-skill-brief.py +41 -8
- 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-analyze-source/references/map-unit-exports.md +1 -1
- package/src/skf-audit-skill/SKILL.md +2 -2
- package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
- package/src/skf-audit-skill/references/headless-contract.md +1 -1
- package/src/skf-audit-skill/references/health-check.md +2 -0
- package/src/skf-audit-skill/references/init.md +2 -1
- package/src/skf-audit-skill/references/re-index.md +14 -9
- package/src/skf-audit-skill/references/report.md +2 -2
- package/src/skf-audit-skill/references/semantic-diff.md +15 -15
- package/src/skf-audit-skill/references/structural-diff.md +10 -5
- package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
- package/src/skf-brief-skill/SKILL.md +3 -3
- package/src/skf-brief-skill/assets/scope-templates.md +1 -1
- package/src/skf-brief-skill/assets/skill-brief-schema.md +4 -11
- package/src/skf-brief-skill/references/analyze-target.md +6 -6
- package/src/skf-brief-skill/references/confirm-brief.md +1 -1
- package/src/skf-brief-skill/references/scope-definition.md +4 -4
- package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
- package/src/skf-brief-skill/references/version-resolution.md +5 -9
- 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/compile-assembly-rules.md +5 -3
- package/src/skf-create-skill/assets/skill-sections.md +7 -7
- package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
- package/src/skf-create-skill/references/compile.md +31 -60
- package/src/skf-create-skill/references/component-extraction.md +3 -3
- package/src/skf-create-skill/references/enrich.md +2 -2
- package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
- package/src/skf-create-skill/references/extract.md +23 -19
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
- package/src/skf-create-skill/references/extraction-patterns.md +10 -11
- package/src/skf-create-skill/references/generate-artifacts.md +1 -1
- package/src/skf-create-skill/references/report.md +21 -5
- package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
- package/src/skf-create-skill/references/sub/fetch-docs.md +1 -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-drop-skill/references/execute.md +5 -1
- package/src/skf-drop-skill/references/health-check.md +2 -0
- package/src/skf-drop-skill/references/invocation-contract.md +1 -1
- package/src/skf-drop-skill/references/report.md +2 -2
- package/src/skf-drop-skill/references/select.md +7 -3
- 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 +5 -4
- package/src/skf-test-skill/references/coverage-check-branches.md +61 -0
- package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
- package/src/skf-test-skill/references/coverage-check.md +69 -111
- package/src/skf-test-skill/references/init.md +5 -6
- package/src/skf-test-skill/references/migration-section-rules.md +26 -14
- package/src/skf-test-skill/references/report.md +11 -9
- package/src/skf-test-skill/references/scoring-rules.md +8 -6
- package/src/skf-test-skill/references/source-access-protocol.md +15 -18
- package/src/skf-test-skill/scripts/build-result-context.py +114 -21
- package/src/skf-test-skill/scripts/gap-ledger.py +580 -80
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +646 -107
- package/src/skf-test-skill/scripts/locate-export-segments.py +101 -20
- package/src/skf-test-skill/scripts/reconcile-coverage.py +450 -23
- package/src/skf-test-skill/scripts/score-signatures.py +356 -61
- 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/scripts/verify-declared-numerator.py +7 -5
- 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 +32 -13
- package/src/skf-update-skill/references/gap-driven.md +37 -22
- package/src/skf-update-skill/references/init.md +6 -2
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +17 -12
- package/src/skf-update-skill/references/re-extract.md +4 -2
- package/src/skf-update-skill/references/report.md +7 -4
- package/src/skf-update-skill/references/write.md +77 -36
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
- package/src/skf-verify-stack/SKILL.md +2 -2
- package/tools/cli/lib/tool-check.js +158 -2
|
@@ -15,18 +15,34 @@ 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
|
-
|
|
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.
|
|
23
39
|
score the categories. Reads every subagent result, checks its schema,
|
|
24
40
|
and computes:
|
|
25
41
|
Signature Accuracy = matching / compared * 100, where compared is
|
|
26
|
-
the documented signatures the
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
42
|
+
the documented signatures of the signature universe (with a
|
|
43
|
+
fallback scan's exports_found) and matching those no result
|
|
44
|
+
names in a Critical mismatch: a wrong signature is wrong
|
|
45
|
+
whichever set the denominator counts
|
|
30
46
|
Type Coverage = documented types / total types * 100, where
|
|
31
47
|
total types counts the names of the --surface-set set (the
|
|
32
48
|
denominator set coverage-check section 2b picked) whose kind is
|
|
@@ -35,18 +51,43 @@ ratios and the gap records of the wrong signatures.
|
|
|
35
51
|
interface) and documented types those the inventory documents
|
|
36
52
|
A category with nothing to compare scores 100 (nothing documented
|
|
37
53
|
is wrong) and says so in `warnings`. Each signature mismatch of a
|
|
38
|
-
compared name becomes a
|
|
39
|
-
|
|
40
|
-
--gaps-output)
|
|
41
|
-
the
|
|
42
|
-
|
|
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.
|
|
43
83
|
|
|
44
84
|
Inputs, all files in the run folder:
|
|
45
85
|
--inventory the validated inventory validate-inventory.py wrote (--output)
|
|
46
86
|
--surface load-coverage-inputs.py surface output: `exports[]` (name,
|
|
47
87
|
kind, file, line, signatureLine) and the name `sets`;
|
|
48
|
-
--surface-set picks the set (default "all")
|
|
49
|
-
|
|
88
|
+
--surface-set picks the set (default "all") score's Type
|
|
89
|
+
Coverage counts, while plan and Signature Accuracy compare
|
|
90
|
+
the signature universe whichever set it picks
|
|
50
91
|
--results a subagent result, as the subagent returned it (a wrapping
|
|
51
92
|
markdown fence is stripped), repeatable. One object, or a
|
|
52
93
|
list of them:
|
|
@@ -54,28 +95,45 @@ Inputs, all files in the run folder:
|
|
|
54
95
|
"exports_found": ["..."], (fallback scan only)
|
|
55
96
|
"types_found": ["..."], (fallback scan only)
|
|
56
97
|
"signature_mismatches": [
|
|
57
|
-
{"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
|
|
58
110
|
|
|
59
111
|
Rounding is compute-score.py's (JS-compatible half-up, two decimals).
|
|
60
112
|
|
|
61
113
|
Usage:
|
|
62
|
-
uv run score-signatures.py plan --inventory <file> [--surface <file>] [--output <file>]
|
|
63
|
-
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>] \\
|
|
64
116
|
--results <file> [--results <file> ...] [--output <file>] [--gaps-output <file>]
|
|
65
117
|
|
|
66
118
|
Output (stdout; --output also writes it, --gaps-output writes the records):
|
|
67
119
|
plan: {"compared": N, "files": [{"file", "checks": [{"name", "line",
|
|
68
|
-
"signatureLine", "documented": {...}
|
|
120
|
+
"signatureLine", "documented": {...}, "candidates": [{"file",
|
|
121
|
+
"line", "signatureLine"}] (only when the citation does not settle
|
|
122
|
+
the declaration)}]}], "documentedSignatures": {...}}
|
|
69
123
|
score: {"valid": true, "signatureAccuracy", "matchingSignatures",
|
|
70
|
-
"totalDocumented", "
|
|
71
|
-
"
|
|
124
|
+
"totalDocumented", "comparedNames": [the names compared],
|
|
125
|
+
"typeCoverage", "documentedTypes", "totalTypes",
|
|
126
|
+
"missingTypes": [...], "mismatches": [{"name", "file", "line",
|
|
127
|
+
"source_sig", "documented_sig", "issue", "difference" (null for
|
|
128
|
+
none), "severity" (Critical, Low or Info)}], "gapRecords": [...],
|
|
72
129
|
"warnings": [...]}
|
|
73
130
|
or {"valid": false, "violations": [...]} when a result breaks the schema
|
|
74
131
|
|
|
75
132
|
Exit codes:
|
|
76
133
|
0 the result was printed
|
|
77
|
-
1 an input file cannot be read or is not the JSON it should be,
|
|
78
|
-
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
|
|
79
137
|
2 a subagent result breaks the schema (violations[] names each breach;
|
|
80
138
|
nothing is written to --output or --gaps-output)
|
|
81
139
|
"""
|
|
@@ -87,13 +145,28 @@ import importlib.util
|
|
|
87
145
|
import json
|
|
88
146
|
import math
|
|
89
147
|
import sys
|
|
90
|
-
from pathlib import Path
|
|
148
|
+
from pathlib import Path, PurePosixPath
|
|
91
149
|
|
|
92
150
|
# Surface kinds that count toward total_types. A Rust struct is a class and a
|
|
93
151
|
# trait an interface; `type` is a type alias.
|
|
94
152
|
TYPE_KINDS = frozenset({"interface", "type", "enum", "class", "struct", "trait"})
|
|
95
153
|
SIGNATURE_FIELDS = ("params", "return_type", "usage_signature")
|
|
96
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")
|
|
97
170
|
|
|
98
171
|
_INVENTORY_MODULE = None
|
|
99
172
|
|
|
@@ -164,6 +237,29 @@ def load_surface(path: str, surface_set: str) -> tuple[list[str], list[dict], li
|
|
|
164
237
|
return name_set(surface_set), [e for e in exports if isinstance(e, dict)], name_set("all")
|
|
165
238
|
|
|
166
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
|
+
|
|
167
263
|
def check_result(item: object, where: str) -> list[str]:
|
|
168
264
|
"""The schema breaches of one per-file result object."""
|
|
169
265
|
if not isinstance(item, dict):
|
|
@@ -194,6 +290,12 @@ def check_result(item: object, where: str) -> list[str]:
|
|
|
194
290
|
for field in ("name", "source_sig", "documented_sig", "issue"):
|
|
195
291
|
if not (isinstance(entry[field], str) and entry[field].strip()):
|
|
196
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'])}")
|
|
197
299
|
return errors
|
|
198
300
|
|
|
199
301
|
|
|
@@ -227,21 +329,66 @@ def load_results(paths: list[str]) -> tuple[list[dict], list[str]]:
|
|
|
227
329
|
|
|
228
330
|
|
|
229
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
|
|
230
338
|
return any(
|
|
231
339
|
isinstance(entry.get(f), list) or (isinstance(entry.get(f), str) and entry[f].strip())
|
|
232
340
|
for f in SIGNATURE_FIELDS
|
|
233
341
|
)
|
|
234
342
|
|
|
235
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
|
+
|
|
236
372
|
def documented_signatures(inventory: list[dict]) -> dict[str, dict]:
|
|
237
|
-
"""{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."""
|
|
238
382
|
out: dict[str, dict] = {}
|
|
239
383
|
for entry in inventory:
|
|
240
384
|
name = entry.get("name")
|
|
241
|
-
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):
|
|
242
386
|
continue
|
|
243
|
-
if
|
|
244
|
-
|
|
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
|
|
245
392
|
return out
|
|
246
393
|
|
|
247
394
|
|
|
@@ -250,30 +397,79 @@ def documented_names(inventory: list[dict]) -> set[str]:
|
|
|
250
397
|
if isinstance(e.get("name"), str) and e["name"] and e.get("kind") != "method"}
|
|
251
398
|
|
|
252
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
|
+
|
|
253
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."""
|
|
254
409
|
by_name: dict[str, list[dict]] = {}
|
|
255
|
-
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)):
|
|
256
411
|
name = record.get("name")
|
|
257
412
|
if isinstance(name, str) and name:
|
|
258
413
|
by_name.setdefault(name, []).append(record)
|
|
259
414
|
return by_name
|
|
260
415
|
|
|
261
416
|
|
|
262
|
-
def
|
|
417
|
+
def signature_universe(names: list[str], exports: list[dict]) -> set[str]:
|
|
418
|
+
"""The names whose documented signature is compared: the `all` set
|
|
419
|
+
(`names`) plus every name on the surface's export records. A nested
|
|
420
|
+
Python top's names leave `all` but keep their records, so a documented
|
|
421
|
+
one still has its signature checked."""
|
|
422
|
+
return set(names) | set(_records_by_name(exports))
|
|
423
|
+
|
|
424
|
+
|
|
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:
|
|
451
|
+
"""The comparisons over the signature universe of `names` (the `all`
|
|
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)."""
|
|
263
455
|
signatures = documented_signatures(inventory)
|
|
264
456
|
files: dict[str, list[dict]] = {}
|
|
265
457
|
compared = 0
|
|
266
458
|
if names is not None:
|
|
267
459
|
by_name = _records_by_name(exports)
|
|
268
|
-
|
|
269
|
-
|
|
460
|
+
texts = _inventory().skill_markdown_lines(Path(skill_dir)) if skill_dir is not None else None
|
|
461
|
+
for name in sorted(signature_universe(names, exports) & set(signatures)):
|
|
462
|
+
record, candidates = _check_at(name, by_name.get(name) or [], skill_dir, texts)
|
|
270
463
|
compared += 1
|
|
271
|
-
|
|
464
|
+
check = {
|
|
272
465
|
"name": name,
|
|
273
466
|
"line": record.get("line"),
|
|
274
467
|
"signatureLine": record.get("signatureLine"),
|
|
275
468
|
"documented": signatures[name],
|
|
276
|
-
}
|
|
469
|
+
}
|
|
470
|
+
if candidates:
|
|
471
|
+
check["candidates"] = candidates
|
|
472
|
+
files.setdefault(_record_file(record), []).append(check)
|
|
277
473
|
return {
|
|
278
474
|
"compared": compared,
|
|
279
475
|
"files": [{"file": f, "checks": checks} for f, checks in sorted(files.items())],
|
|
@@ -281,52 +477,140 @@ def plan(inventory: list[dict], names: list[str] | None, exports: list[dict]) ->
|
|
|
281
477
|
}
|
|
282
478
|
|
|
283
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
|
+
|
|
284
522
|
def _gap_record(m: dict) -> dict:
|
|
285
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)")
|
|
286
542
|
return {
|
|
287
|
-
"severity": "
|
|
543
|
+
"severity": m["severity"],
|
|
288
544
|
"category": "signature-mismatch",
|
|
289
545
|
"title": f"Signature mismatch: {name}",
|
|
290
546
|
"source": where,
|
|
291
547
|
"export": name,
|
|
292
|
-
"issue":
|
|
293
|
-
"remediation":
|
|
294
|
-
f"to `{m['source_sig']}`, the signature the source defines at `{where}`."),
|
|
548
|
+
"issue": issue,
|
|
549
|
+
"remediation": remediation,
|
|
295
550
|
}
|
|
296
551
|
|
|
297
552
|
|
|
298
553
|
def score(inventory: list[dict], names: list[str], exports: list[dict], results: list[dict],
|
|
299
|
-
type_names: list[str] | None = None) -> dict:
|
|
300
|
-
"""The two categories: Signature Accuracy over
|
|
301
|
-
|
|
554
|
+
type_names: list[str] | None = None, offered: dict[str, list[str]] | None = None) -> dict:
|
|
555
|
+
"""The two categories: Signature Accuracy over the signature universe of
|
|
556
|
+
`names` (the `all` set) and `exports`, Type Coverage over `type_names`
|
|
557
|
+
when given, else over `names`. A fallback scan's exports_found join the
|
|
558
|
+
universe, and `names` when no `type_names` is given. `offered` (read
|
|
559
|
+
from --plan) names the files the plan offered each check at."""
|
|
302
560
|
by_name = _records_by_name(exports)
|
|
303
561
|
# A fallback scan reports its own names and types.
|
|
304
562
|
fallback_types = {n for r in results for n in r.get("types_found") or []}
|
|
305
563
|
surface = set(names) | {n for r in results for n in r.get("exports_found") or []}
|
|
306
564
|
documented = documented_names(inventory)
|
|
307
565
|
signatures = documented_signatures(inventory)
|
|
308
|
-
compared = surface & set(signatures)
|
|
566
|
+
compared = (surface | signature_universe(names, exports)) & set(signatures)
|
|
309
567
|
warnings = []
|
|
310
568
|
|
|
311
|
-
|
|
569
|
+
graded: dict[tuple, dict] = {}
|
|
312
570
|
seen = set()
|
|
313
571
|
for result in results:
|
|
314
572
|
for entry in result["signature_mismatches"]:
|
|
315
|
-
|
|
316
|
-
|
|
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)
|
|
317
592
|
continue
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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):
|
|
323
599
|
continue
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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"}
|
|
330
614
|
if compared:
|
|
331
615
|
signature_accuracy = round2((len(compared) - len(wrong)) / len(compared) * 100)
|
|
332
616
|
else:
|
|
@@ -353,6 +637,7 @@ def score(inventory: list[dict], names: list[str], exports: list[dict], results:
|
|
|
353
637
|
"signatureAccuracy": signature_accuracy,
|
|
354
638
|
"matchingSignatures": len(compared) - len(wrong),
|
|
355
639
|
"totalDocumented": len(compared),
|
|
640
|
+
"comparedNames": sorted(compared),
|
|
356
641
|
"typeCoverage": type_coverage,
|
|
357
642
|
"documentedTypes": len(documented_types),
|
|
358
643
|
"totalTypes": len(types),
|
|
@@ -378,8 +663,12 @@ def _cmd_plan(args: argparse.Namespace) -> tuple[dict, int]:
|
|
|
378
663
|
inventory = load_inventory(args.inventory)
|
|
379
664
|
names, exports = (None, [])
|
|
380
665
|
if args.surface is not None:
|
|
381
|
-
|
|
382
|
-
|
|
666
|
+
_, exports, names = load_surface(args.surface, args.surface_set)
|
|
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)
|
|
383
672
|
if args.output:
|
|
384
673
|
_write_json(args.output, out)
|
|
385
674
|
return out, 0
|
|
@@ -394,7 +683,8 @@ def _cmd_score(args: argparse.Namespace) -> tuple[dict, int]:
|
|
|
394
683
|
if path:
|
|
395
684
|
Path(path).unlink(missing_ok=True)
|
|
396
685
|
return {"valid": False, "violations": violations}, 2
|
|
397
|
-
|
|
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)
|
|
398
688
|
if args.output:
|
|
399
689
|
_write_json(args.output, out)
|
|
400
690
|
if args.gaps_output:
|
|
@@ -412,7 +702,11 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
412
702
|
p = sub.add_parser("plan", help="the signature comparisons for the subagents to make")
|
|
413
703
|
p.add_argument("--inventory", required=True, help="the validated inventory file")
|
|
414
704
|
p.add_argument("--surface", help="load-coverage-inputs.py surface output")
|
|
415
|
-
p.add_argument("--surface-set", default="all",
|
|
705
|
+
p.add_argument("--surface-set", default="all",
|
|
706
|
+
help="a surface name set, which must exist (default: all); the plan compares the signature "
|
|
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")
|
|
416
710
|
p.add_argument("--output", help="also write the plan to this file")
|
|
417
711
|
p.set_defaults(func=_cmd_plan)
|
|
418
712
|
|
|
@@ -421,6 +715,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
421
715
|
p.add_argument("--surface", required=True, help="load-coverage-inputs.py surface output")
|
|
422
716
|
p.add_argument("--surface-set", default="all",
|
|
423
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")
|
|
424
719
|
p.add_argument("--results", action="append", help="a subagent result file (repeatable)")
|
|
425
720
|
p.add_argument("--output", help="also write the scores to this file")
|
|
426
721
|
p.add_argument("--gaps-output", help="write the gap ledger records to this file")
|