bmad-module-skill-forge 8.0.0 → 9.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 +7 -7
- package/docs/verifying-a-skill.md +9 -9
- package/docs/workflows.md +1 -1
- package/package.json +1 -1
- package/src/shared/scripts/skf-extract-public-api.py +15 -8
- package/src/shared/scripts/skf-verify-provenance-completeness.py +331 -105
- package/src/skf-analyze-source/SKILL.md +1 -1
- package/src/skf-audit-skill/SKILL.md +1 -1
- package/src/skf-brief-skill/SKILL.md +1 -1
- package/src/skf-campaign/SKILL.md +1 -1
- package/src/skf-create-skill/SKILL.md +1 -1
- package/src/skf-create-stack-skill/SKILL.md +1 -1
- package/src/skf-drop-skill/SKILL.md +1 -1
- package/src/skf-export-skill/SKILL.md +1 -1
- package/src/skf-forger/SKILL.md +1 -1
- package/src/skf-quick-skill/SKILL.md +1 -1
- package/src/skf-refine-architecture/SKILL.md +1 -1
- package/src/skf-rename-skill/SKILL.md +1 -1
- package/src/skf-setup/SKILL.md +1 -1
- package/src/skf-test-skill/SKILL.md +1 -1
- package/src/skf-test-skill/assets/output-section-formats.md +2 -2
- package/src/skf-test-skill/customize.toml +10 -0
- package/src/skf-test-skill/references/coverage-check-branches.md +2 -2
- package/src/skf-test-skill/references/coverage-check.md +8 -4
- package/src/skf-test-skill/references/init.md +1 -1
- package/src/skf-test-skill/references/inventory-contract.md +11 -7
- package/src/skf-test-skill/references/report.md +2 -1
- package/src/skf-test-skill/references/scoring-rules.md +2 -2
- package/src/skf-test-skill/references/signature-comparison-rules.md +7 -5
- package/src/skf-test-skill/references/source-access-protocol.md +1 -1
- package/src/skf-test-skill/scripts/gap-ledger.py +105 -53
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +336 -59
- package/src/skf-test-skill/scripts/reconcile-coverage.py +46 -18
- package/src/skf-test-skill/scripts/score-signatures.py +527 -130
- package/src/skf-test-skill/scripts/validate-inventory.py +528 -68
- package/src/skf-test-skill/templates/test-report-template.md +4 -3
- package/src/skf-update-skill/SKILL.md +1 -1
- package/src/skf-verify-stack/SKILL.md +1 -1
|
@@ -27,25 +27,74 @@ ratios and the gap records of the wrong signatures.
|
|
|
27
27
|
partial one, then, of two full or two partial forms, one that lists
|
|
28
28
|
parameters before one that gives only a usage form, else the first)
|
|
29
29
|
and, as `alternatives`, the name's other distinct forms that list
|
|
30
|
-
parameters or give a usage form,
|
|
31
|
-
|
|
30
|
+
parameters or give a usage form, then those that list neither but
|
|
31
|
+
state a return type (a `return_type` neither blank nor a list of
|
|
32
|
+
value names, #914), in that order: the judge compares the
|
|
33
|
+
declaration with each, by its own reading rules, and reports a
|
|
32
34
|
mismatch for each form that does not match, naming it as `form`. A
|
|
33
|
-
form that lists no member
|
|
34
|
-
that say the same thing are one form
|
|
35
|
-
list and its items joined with `, `,
|
|
36
|
-
differ only by the partial label, the
|
|
35
|
+
form that lists no member and states no return type is never an
|
|
36
|
+
alternative, and two entries that say the same thing are one form
|
|
37
|
+
(a `full` label and none, a list and its items joined with `, `,
|
|
38
|
+
whitespace runs; of two that differ only by the partial label, the
|
|
39
|
+
full one). The signature universe is the
|
|
37
40
|
surface's `all` set plus every name on its export records, so the
|
|
38
41
|
names of a Python sub-package load-coverage-inputs.py keeps out of
|
|
39
42
|
`all` (excluded.nestedEntries) keep their checks. A check sits at the
|
|
40
43
|
name's first surface record (one with a file before one with none
|
|
41
|
-
or a blank one
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
or a blank one and, in one file, one with a line before one with
|
|
45
|
+
none). A line-less record of a file another of the name's records
|
|
46
|
+
gives a line, as _path reads both, is skipped (_lined_records), a
|
|
47
|
+
`./src/a.py` one that sorts first by raw path beside `src/a.py` at a
|
|
48
|
+
line included: it is never the check's record, never a candidate
|
|
49
|
+
and never a declaration. For a name the surface records in more
|
|
50
|
+
than one file, --skill-dir (the skill package) picks the record
|
|
51
|
+
whose file the skill cites on a line that names it
|
|
44
52
|
(validate-inventory.py's _skill_cited_files) when exactly one of
|
|
45
53
|
those files is cited; otherwise the check stays at the first record
|
|
46
|
-
and lists every record with a file
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
and lists every record with a file, a skipped one aside, as
|
|
55
|
+
`candidates` for the judge to read. With --skill-dir a form is also
|
|
56
|
+
bound (#918) to the one
|
|
57
|
+
declaration of its name its own citations name: the citations of
|
|
58
|
+
the places the inventory's `at` gives its entries
|
|
59
|
+
(validate-inventory.py's form_citations): on that line; on the
|
|
60
|
+
next non-blank line, unless that line writes the name (another
|
|
61
|
+
place of it, such as the next table row) or, in a fenced block, is
|
|
62
|
+
a comment (it heads the next declaration); in a fenced block, on
|
|
63
|
+
the comment lines just above it; and, when the block holds no other
|
|
64
|
+
place the inventory gives the name, on the block's first non-blank
|
|
65
|
+
line when it is a comment and on the first non-blank line after its
|
|
66
|
+
closing fence unless that line writes the name. A place where the
|
|
67
|
+
inventory gives the name two forms that differ (a table row that
|
|
68
|
+
documents one declaration's form and quotes another's as a
|
|
69
|
+
contrast) gives none of them a citation: each is bound only by its
|
|
70
|
+
other places, and a form written only there is compared as one with
|
|
71
|
+
no own citation (_shared_places); two entries that say the same
|
|
72
|
+
thing at one place are one form, as everywhere. A citation names a
|
|
73
|
+
declaration when it cites its file with no line, or at a line or in
|
|
74
|
+
an `L` range that holds a line of its span (cites_declaration):
|
|
75
|
+
from its line to the line before the next declaration the surface
|
|
76
|
+
records in its file, of any name, an other declaration included, or
|
|
77
|
+
to the end of the file when none follows (_span_end). So a citation
|
|
78
|
+
of a function's body names it, and a range over two declarations
|
|
79
|
+
names both. The extraction records no declaration's last line, so a
|
|
80
|
+
span runs past its body, over any helper the surface does not
|
|
81
|
+
record, to the next declaration it does.
|
|
82
|
+
The declarations are the name's surface records with a file, a
|
|
83
|
+
skipped line-less one aside, and its `otherDeclarations` (one the
|
|
84
|
+
extraction records in a file no record of the name names, such as a
|
|
85
|
+
same-named function a lazy re-export passes on, which no entry point
|
|
86
|
+
exports), each once per file and line. A form whose citations name
|
|
87
|
+
exactly one is listed in the
|
|
88
|
+
check's `bound` with its index and that declaration, and the judge
|
|
89
|
+
compares it with that declaration alone, whatever the check's
|
|
90
|
+
location and candidates; a form whose citations name none or two,
|
|
91
|
+
and a form that writes only a `return_type` (it gives no `at`), is
|
|
92
|
+
compared as before. An other declaration is never the planned
|
|
93
|
+
record and never a candidate: it reaches the judge only as a bound
|
|
94
|
+
form's declaration. With no form bound the plan is the one a
|
|
95
|
+
surface without `otherDeclarations` gives. Also the whole
|
|
96
|
+
documented-signature map, and each name's alternatives, for the
|
|
97
|
+
fallback scan that has no surface lines yet.
|
|
49
98
|
score the categories. Reads every subagent result, checks its schema,
|
|
50
99
|
and computes:
|
|
51
100
|
Signature Accuracy = matching / compared * 100, where compared is
|
|
@@ -58,7 +107,10 @@ ratios and the gap records of the wrong signatures.
|
|
|
58
107
|
denominator set coverage-check section 2b picked) whose kind is
|
|
59
108
|
a type kind (TYPE_KINDS: interface, type alias, enum and class,
|
|
60
109
|
with a Rust struct counted as a class and a trait as an
|
|
61
|
-
interface) and documented types those the inventory documents
|
|
110
|
+
interface) and documented types those the inventory documents;
|
|
111
|
+
a name the stale-scope guard restored whose kind the provenance
|
|
112
|
+
map does not give (#913) is left out and named in `warnings`,
|
|
113
|
+
in place of the warning that the surface has no type
|
|
62
114
|
A category with nothing to compare scores 100 (nothing documented
|
|
63
115
|
is wrong) and says so in `warnings`. Each signature mismatch of a
|
|
64
116
|
compared name is graded (grade()) against the documented form its
|
|
@@ -84,14 +136,17 @@ ratios and the gap records of the wrong signatures.
|
|
|
84
136
|
`difference` "annotation-contradicted" (the documented return
|
|
85
137
|
type is the one the body returns, the declared annotation says
|
|
86
138
|
otherwise) whose facts check: the documented entry states a
|
|
87
|
-
return type (a
|
|
88
|
-
that
|
|
139
|
+
return type (a `return_type`, or the return a `documented_sig`
|
|
140
|
+
writes, that is neither blank nor a list of value names:
|
|
141
|
+
_states_return); `documented_sig` and `source_sig` write the
|
|
89
142
|
same parameters as signature-comparison-rules.md reads them
|
|
90
143
|
(_parameter_difference, below); `evidence_line` comes after the
|
|
91
144
|
declaration line (of the name's surface records at the
|
|
92
145
|
mismatch's file, the closest at or before the mismatch's
|
|
93
146
|
`line`, else the first; with none, the mismatch's `line`) and
|
|
94
|
-
before the next surface record in that file
|
|
147
|
+
before the next surface record in that file (a bound form's
|
|
148
|
+
declaration line is its bound declaration's, and each bound
|
|
149
|
+
declaration counts as a surface record there); and, with
|
|
95
150
|
--source-root, that line of the file under it starts with
|
|
96
151
|
`return`, or is the declaration line itself with its
|
|
97
152
|
expression body after its parameters (an arrow function's
|
|
@@ -138,29 +193,42 @@ ratios and the gap records of the wrong signatures.
|
|
|
138
193
|
offered that file for the name: the file of its check or one of the
|
|
139
194
|
check's candidates, read from --plan, else every file the surface
|
|
140
195
|
records for the name. Any other `file` is listed in `warnings`, and
|
|
141
|
-
the result's `file` stands, as it does when there is none.
|
|
196
|
+
the result's `file` stands, as it does when there is none. A
|
|
197
|
+
mismatch of a form the plan bound (`bound`, #918) is taken at its
|
|
198
|
+
bound declaration's file and line instead, whatever file the judge
|
|
199
|
+
names, and a judge that names another one (or none, from a group in
|
|
200
|
+
another file) is listed in `warnings`; the declaration joins no
|
|
201
|
+
name, set or count, and the gap's title and form index are the
|
|
202
|
+
form's as for any other mismatch. Results
|
|
142
203
|
that report one declaration (name, file and line) more than once
|
|
143
204
|
keep its most severe grade: Critical, then Low, then Info. Of the
|
|
144
|
-
mismatches of one form of a check that offered several
|
|
145
|
-
least severe stands (the closest candidate), the first by
|
|
146
|
-
line on a tie: one documented form, one gap. Each form's mismatch
|
|
205
|
+
mismatches of one unbound form of a check that offered several
|
|
206
|
+
files, the least severe stands (the closest candidate), the first by
|
|
207
|
+
file and line on a tie: one documented form, one gap. Each form's mismatch
|
|
147
208
|
stands on its own (each usage of a `usage_signature` list too: the
|
|
148
209
|
mismatch's `documented_sig` is part of the declaration's key), and
|
|
149
210
|
a gap of a name with several forms names its form, counted from 1,
|
|
150
211
|
in its title (`Signature mismatch: {name} (form n of m)`) and its
|
|
151
212
|
Issue. For such a name the form's own label lowers a grade only when
|
|
152
213
|
the mismatch's `documented_sig` is that form's text (whitespace runs
|
|
153
|
-
as one, outer parentheses off, a list joined
|
|
154
|
-
|
|
155
|
-
`
|
|
156
|
-
|
|
214
|
+
as one, outer parentheses off, a list joined, a name or a dotted
|
|
215
|
+
receiver before the parameters left off, and a blank or absent
|
|
216
|
+
`params` read as `()` for a form with no usage form): otherwise the
|
|
217
|
+
mismatch is graded against the form it quotes, which `warnings`
|
|
218
|
+
names, or, when it quotes none, stays at the form its `form` names
|
|
219
|
+
(that form's title, Issue and bound declaration, #919) and is graded
|
|
220
|
+
as a full form, which `warnings` says when that form is not
|
|
221
|
+
`documented` or is partial. A Low or an Info mismatch still counts
|
|
222
|
+
as matching. A mismatch a result reports for any
|
|
157
223
|
other name (one the skill does not document, or the surface lacks)
|
|
158
224
|
is listed in `warnings` instead, and no gap.
|
|
159
225
|
|
|
160
226
|
Inputs, all files in the run folder:
|
|
161
227
|
--inventory the validated inventory validate-inventory.py wrote (--output)
|
|
162
228
|
--surface load-coverage-inputs.py surface output: `exports[]` (name,
|
|
163
|
-
kind, file, line, signatureLine)
|
|
229
|
+
kind, file, line, signatureLine), the name `sets` and
|
|
230
|
+
`otherDeclarations` (the same fields; a declaration plan may
|
|
231
|
+
bind a form to, never a name it compares or counts);
|
|
164
232
|
--surface-set picks the set (default "all") score's Type
|
|
165
233
|
Coverage counts, while plan and Signature Accuracy compare
|
|
166
234
|
the signature universe whichever set it picks
|
|
@@ -191,9 +259,11 @@ Inputs, all files in the run folder:
|
|
|
191
259
|
for 0)
|
|
192
260
|
--skill-dir plan only: the skill package under test (the folder that
|
|
193
261
|
holds SKILL.md), whose citations pick the declaration of a
|
|
194
|
-
name the surface records in several files
|
|
262
|
+
name the surface records in several files, and bind each form
|
|
263
|
+
whose own citations name one declaration of its name to it
|
|
195
264
|
--plan score only: the plan's output, whose checks name the files a
|
|
196
|
-
mismatch of each name may be recorded at
|
|
265
|
+
mismatch of each name may be recorded at, and the declaration
|
|
266
|
+
each bound form's mismatch is taken at
|
|
197
267
|
--source-root score only: the local source tree the judges read (a
|
|
198
268
|
drifted workspace included), where an annotation-contradicted
|
|
199
269
|
mismatch's `evidence_line` is checked in its file
|
|
@@ -208,9 +278,12 @@ Usage:
|
|
|
208
278
|
Output (stdout; --output also writes it, --gaps-output writes the records):
|
|
209
279
|
plan: {"compared": N, "files": [{"file", "checks": [{"name", "line",
|
|
210
280
|
"signatureLine", "documented": {...}, "alternatives": [{...}]
|
|
211
|
-
(only when the name has another form that lists members
|
|
281
|
+
(only when the name has another form that lists members or
|
|
282
|
+
states a return type),
|
|
212
283
|
"candidates": [{"file", "line", "signatureLine"}] (only when the
|
|
213
|
-
citation does not settle the declaration)
|
|
284
|
+
citation does not settle the declaration), "bound": [{"form",
|
|
285
|
+
"file", "line", "signatureLine"}] (only when a form's own
|
|
286
|
+
citations name one declaration of the name)}]}],
|
|
214
287
|
"documentedSignatures": {...}, "documentedAlternatives": {name:
|
|
215
288
|
[{...}], for each name with alternatives}}
|
|
216
289
|
score: {"valid": true, "signatureAccuracy", "matchingSignatures",
|
|
@@ -267,6 +340,12 @@ PYTHON_BODY_RE = re.compile(r"\s*(?:->[^:]*)?:\s*return(?![\w$])")
|
|
|
267
340
|
TYPE_DECLARATION_RE = re.compile(r"(?:export\s+)?(?:declare\s+)?type\s")
|
|
268
341
|
# Where a signature's return type starts, with no parenthesized parameters.
|
|
269
342
|
RETURN_ARROW_RE = re.compile(r"->|=>|→")
|
|
343
|
+
# A name, or a dotted receiver and name, before a signature's parameter group (`run_pipeline(`, `coco.default_env(`):
|
|
344
|
+
# a mismatch's `documented_sig` may write it where the form writes only the parameters (_quotes).
|
|
345
|
+
LEADING_NAME_RE = re.compile(r"^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*\s*(?=\()")
|
|
346
|
+
# The dotted receiver before a usage's called name (`coco.` in `coco.default_env()`): a mismatch's `documented_sig`
|
|
347
|
+
# may write the call without it (_quotes).
|
|
348
|
+
CALL_RECEIVER_RE = re.compile(r"^(?:[A-Za-z_$][\w$]*\s*\.\s*)+(?=[A-Za-z_$][\w$]*\s*\()")
|
|
270
349
|
# The prefixes a type is compared without, as signature-comparison-rules.md's Equivalent spellings reads a name
|
|
271
350
|
# with or without one: `typing.List[int]` is `List[int]`, `React.ReactNode` is `ReactNode`.
|
|
272
351
|
TYPE_PREFIX_RE = re.compile(r"(?<![\w.$])(?:typing|collections\.abc|React)\.")
|
|
@@ -311,6 +390,33 @@ LITERAL_TYPES = (
|
|
|
311
390
|
# documented parameters are props callers set by name.
|
|
312
391
|
POSITIONAL_EXTENSIONS = frozenset({".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".go", ".rs",
|
|
313
392
|
".java", ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp"})
|
|
393
|
+
# The builtin type names that start lowercase, by the source files of their language: Python, TS/JS (and the
|
|
394
|
+
# script of a .vue, .svelte or .astro file), Go, Rust, the C family (Java, C, C++) and C#. A parenthesized,
|
|
395
|
+
# bracketed or braced list of lowercase names writes a return type when one of them is a type name of the file's
|
|
396
|
+
# language (`(str, int)`, Go's `(int, error)`, `[string, number]`), else names of values, which state none
|
|
397
|
+
# (signature-comparison-rules.md, Annotations; _states_return). A file of a language with no table, or none,
|
|
398
|
+
# checks the names of every table (ALL_LOWERCASE_TYPES).
|
|
399
|
+
LOWERCASE_TYPES = {extension: names for extensions, names in (
|
|
400
|
+
((".py", ".pyi"), frozenset({"str", "int", "float", "complex", "bool", "bytes", "bytearray", "memoryview", "list",
|
|
401
|
+
"dict", "set", "frozenset", "tuple", "object", "type", "range", "slice"})),
|
|
402
|
+
((*TS_JS_EXTENSIONS, ".vue", ".svelte", ".astro"),
|
|
403
|
+
frozenset({"string", "number", "boolean", "bigint", "symbol", "object", "any", "unknown", "never", "void",
|
|
404
|
+
"undefined", "null"})),
|
|
405
|
+
((".go",), frozenset({"bool", "byte", "rune", "string", "error", "any", "comparable", "int", "int8", "int16",
|
|
406
|
+
"int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "uintptr", "float32",
|
|
407
|
+
"float64", "complex64", "complex128"})),
|
|
408
|
+
((".rs",), frozenset({"bool", "char", "str", "i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16", "u32",
|
|
409
|
+
"u64", "u128", "usize", "f32", "f64"})),
|
|
410
|
+
((".java", ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp"),
|
|
411
|
+
frozenset({"int", "long", "short", "char", "float", "double", "void", "bool", "boolean", "byte", "string",
|
|
412
|
+
"object"})),
|
|
413
|
+
((".cs",), frozenset({"bool", "byte", "sbyte", "char", "decimal", "double", "float", "int", "uint", "nint",
|
|
414
|
+
"nuint", "long", "ulong", "short", "ushort", "object", "string", "dynamic", "void"})),
|
|
415
|
+
) for extension in extensions}
|
|
416
|
+
ALL_LOWERCASE_TYPES = frozenset().union(*LOWERCASE_TYPES.values())
|
|
417
|
+
# A list of value names (_states_return): its brackets, and a bare name that starts lowercase.
|
|
418
|
+
VALUE_LIST_BRACKETS = {"(": ")", "[": "]", "{": "}"}
|
|
419
|
+
VALUE_NAME_RE = re.compile(r"[a-z][\w$]*", re.ASCII)
|
|
314
420
|
# The grades of a mismatch, most severe first: one declaration reported
|
|
315
421
|
# twice keeps the most severe.
|
|
316
422
|
GRADES = ("Critical", "Low", "Info")
|
|
@@ -384,6 +490,18 @@ def load_surface(path: str, surface_set: str) -> tuple[list[str], list[dict], li
|
|
|
384
490
|
return name_set(surface_set), [e for e in exports if isinstance(e, dict)], name_set("all")
|
|
385
491
|
|
|
386
492
|
|
|
493
|
+
def load_other_declarations(path: str) -> list[dict]:
|
|
494
|
+
"""The surface's `otherDeclarations` (#918): each declaration of a
|
|
495
|
+
surface name, in a file none of its records names, that the extraction
|
|
496
|
+
records and no entry point exports, which plan may bind a form to. Each
|
|
497
|
+
one with a name and a file; [] when the surface has none (an older
|
|
498
|
+
surface included)."""
|
|
499
|
+
data = _read_json(path, "--surface")
|
|
500
|
+
items = data.get("otherDeclarations") if isinstance(data, dict) else None
|
|
501
|
+
return [d for d in (items if isinstance(items, list) else [])
|
|
502
|
+
if isinstance(d, dict) and isinstance(d.get("name"), str) and d["name"] and _record_file(d)]
|
|
503
|
+
|
|
504
|
+
|
|
387
505
|
def load_plan(path: str) -> dict[str, list[str]]:
|
|
388
506
|
"""{name: the files plan offered its check at, the planned one first,
|
|
389
507
|
then its candidates'} from plan's output."""
|
|
@@ -427,6 +545,24 @@ def load_plan_forms(path: str) -> dict[str, list[dict]]:
|
|
|
427
545
|
return forms
|
|
428
546
|
|
|
429
547
|
|
|
548
|
+
def load_plan_bound(path: str) -> dict[str, dict[int, dict]]:
|
|
549
|
+
"""{name: {form index: the declaration plan bound that form to, {"file",
|
|
550
|
+
"line"}}} from each check's `bound` (#918); a name with none is left
|
|
551
|
+
out, and so is an item with no form index or no file."""
|
|
552
|
+
data = _read_json(path, "--plan")
|
|
553
|
+
groups = data.get("files") if isinstance(data, dict) else None
|
|
554
|
+
bound: dict[str, dict[int, dict]] = {}
|
|
555
|
+
for group in groups if isinstance(groups, list) else []:
|
|
556
|
+
checks = group.get("checks") if isinstance(group, dict) else None
|
|
557
|
+
for check in checks if isinstance(checks, list) else []:
|
|
558
|
+
items = check.get("bound") if isinstance(check, dict) and isinstance(check.get("name"), str) else None
|
|
559
|
+
for item in items if isinstance(items, list) else []:
|
|
560
|
+
form = item.get("form") if isinstance(item, dict) else None
|
|
561
|
+
if isinstance(form, int) and not isinstance(form, bool) and form >= 0 and _record_file(item):
|
|
562
|
+
bound.setdefault(check["name"], {})[form] = {"file": _record_file(item), "line": item.get("line")}
|
|
563
|
+
return bound
|
|
564
|
+
|
|
565
|
+
|
|
430
566
|
def check_result(item: object, where: str) -> list[str]:
|
|
431
567
|
"""The schema breaches of one per-file result object."""
|
|
432
568
|
if not isinstance(item, dict):
|
|
@@ -544,6 +680,14 @@ def _lists_members(fields: dict) -> bool:
|
|
|
544
680
|
return _lists_params(fields) or _gives_usage(fields)
|
|
545
681
|
|
|
546
682
|
|
|
683
|
+
def _gives_return(fields: dict) -> bool:
|
|
684
|
+
"""A `return_type` that states a return type (_states_return, in every
|
|
685
|
+
language's reading: a list of value names such as `(nodes, edges)`, and
|
|
686
|
+
a blank one, state none): an entry that says what the name gives back,
|
|
687
|
+
even when it lists no member (#914)."""
|
|
688
|
+
return _states_return(_field_text(fields.get("return_type")))
|
|
689
|
+
|
|
690
|
+
|
|
547
691
|
def _rank(fields: dict) -> tuple[bool, bool, bool]:
|
|
548
692
|
"""How one of several entries of a name ranks, lowest compared first."""
|
|
549
693
|
return not _lists_members(fields), _partial(fields), not _lists_params(fields)
|
|
@@ -570,42 +714,95 @@ def _form_key(fields: dict) -> tuple:
|
|
|
570
714
|
return _inventory().form_key(fields)
|
|
571
715
|
|
|
572
716
|
|
|
573
|
-
def
|
|
574
|
-
"""{name: [
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
(empty or no `params` and no usage form: it never hides one that says
|
|
579
|
-
what the name takes), then a full one (no `signature_form`, or `full`)
|
|
580
|
-
before a partial one (a form the skill also gives in full is held to
|
|
581
|
-
it), then, of two full or two partial forms, one that lists parameters
|
|
582
|
-
is compared before one that gives only a usage form, else the first.
|
|
583
|
-
The first is the documented signature; the others that list members
|
|
584
|
-
follow it, in that order, as the alternatives the judge compares too (a
|
|
585
|
-
skill that documents a name briefly in one place and in full in
|
|
586
|
-
another). A form that lists no member is never an alternative. Two
|
|
587
|
-
entries that say the same thing (_form_key) are one form; of two that
|
|
588
|
-
differ only by the partial label, the full one (the stricter) stands."""
|
|
589
|
-
entries: dict[str, list[dict]] = {}
|
|
717
|
+
def _forms_and_places(inventory: list[dict]) -> dict[str, list[tuple[dict, list[tuple[str, int]]]]]:
|
|
718
|
+
"""{name: [(each of documented_forms' forms, the places the entries
|
|
719
|
+
that give it write it, from their `at`: (its path, its line))]}, in
|
|
720
|
+
documented_forms' order. An entry with no good `at` adds no place."""
|
|
721
|
+
entries: dict[str, list[tuple[dict, list[tuple[str, int]]]]] = {}
|
|
590
722
|
for entry in inventory:
|
|
591
723
|
name = entry.get("name")
|
|
592
724
|
if entry.get("kind") == "method" or not (isinstance(name, str) and name) or not _has_signature(entry):
|
|
593
725
|
continue
|
|
594
726
|
forms, fields = entries.setdefault(name, []), _form(entry)
|
|
595
|
-
|
|
727
|
+
place = _inventory().parse_at(entry.get("at"))
|
|
728
|
+
places = [place] if place is not None else []
|
|
729
|
+
same = next((i for i, (f, _) in enumerate(forms) if _form_key(f) == _form_key(fields)), None)
|
|
596
730
|
if same is None:
|
|
597
|
-
forms.append(fields)
|
|
598
|
-
elif _partial(forms[same]) and not _partial(fields):
|
|
731
|
+
forms.append((fields, places))
|
|
732
|
+
elif _partial(forms[same][0]) and not _partial(fields):
|
|
599
733
|
# in the inventory order the full entry has, as the rank would have ordered it
|
|
734
|
+
places = forms[same][1] + places
|
|
600
735
|
del forms[same]
|
|
601
|
-
forms.append(fields)
|
|
602
|
-
|
|
736
|
+
forms.append((fields, places))
|
|
737
|
+
else:
|
|
738
|
+
forms[same][1].extend(places)
|
|
739
|
+
out: dict[str, list[tuple[dict, list[tuple[str, int]]]]] = {}
|
|
603
740
|
for name, forms in entries.items():
|
|
604
|
-
|
|
605
|
-
|
|
741
|
+
# stable: of forms that rank alike, inventory order
|
|
742
|
+
first, *others = sorted(forms, key=lambda form: _rank(form[0]))
|
|
743
|
+
out[name] = [first, *(f for f in others if _lists_members(f[0]) or _gives_return(f[0]))]
|
|
606
744
|
return out
|
|
607
745
|
|
|
608
746
|
|
|
747
|
+
def _shared_places(inventory: list[dict]) -> dict[str, set[tuple[str, int]]]:
|
|
748
|
+
"""{name: each place (parse_at's (path, line)) where the inventory's
|
|
749
|
+
entries give the name two or more forms that differ (_form_key), such as a
|
|
750
|
+
table row that documents one declaration's form and quotes another's as a
|
|
751
|
+
contrast}. For a name declared more than once, the citations of such a
|
|
752
|
+
place bind none of its forms (#918): they cannot say which form they are
|
|
753
|
+
for (_bindings). Entries that say the same thing
|
|
754
|
+
at one place are one form, so their place is not shared; a `method`, and
|
|
755
|
+
an entry with no form, write no form of the name."""
|
|
756
|
+
keys: dict[tuple[str, tuple[str, int]], set[tuple]] = {}
|
|
757
|
+
for entry in inventory:
|
|
758
|
+
name, place = entry.get("name"), _inventory().parse_at(entry.get("at"))
|
|
759
|
+
if (entry.get("kind") == "method" or not (isinstance(name, str) and name) or place is None
|
|
760
|
+
or not _has_signature(entry)):
|
|
761
|
+
continue
|
|
762
|
+
keys.setdefault((name, place), set()).add(_form_key(_form(entry)))
|
|
763
|
+
shared: dict[str, set[tuple[str, int]]] = {}
|
|
764
|
+
for (name, place), forms in keys.items():
|
|
765
|
+
if len(forms) > 1:
|
|
766
|
+
shared.setdefault(name, set()).add(place)
|
|
767
|
+
return shared
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _places_by_name(inventory: list[dict]) -> dict[str, set[tuple[str, int]]]:
|
|
771
|
+
"""{name: every place the inventory gives it, (its path, its line) from
|
|
772
|
+
an entry's good `at`}, a form plan drops (one that lists no member and
|
|
773
|
+
states no return type, after another) and an entry with no form
|
|
774
|
+
included; a `method` is no place of its name."""
|
|
775
|
+
spots: dict[str, set[tuple[str, int]]] = {}
|
|
776
|
+
for entry in inventory:
|
|
777
|
+
name, place = entry.get("name"), _inventory().parse_at(entry.get("at"))
|
|
778
|
+
if entry.get("kind") != "method" and isinstance(name, str) and name and place is not None:
|
|
779
|
+
spots.setdefault(name, set()).add(place)
|
|
780
|
+
return spots
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def documented_forms(inventory: list[dict]) -> dict[str, list[dict]]:
|
|
784
|
+
"""{name: [its documented signature, *its alternatives]} for each
|
|
785
|
+
documented export with a signature: its distinct forms (_form), ranked.
|
|
786
|
+
Of several entries of one name, one that lists parameters or gives a
|
|
787
|
+
usage form (_lists_members) is compared before one that does neither
|
|
788
|
+
(empty or no `params` and no usage form: it never hides one that says
|
|
789
|
+
what the name takes), then a full one (no `signature_form`, or `full`)
|
|
790
|
+
before a partial one (a form the skill also gives in full is held to
|
|
791
|
+
it), then, of two full or two partial forms, one that lists parameters
|
|
792
|
+
is compared before one that gives only a usage form, else the first.
|
|
793
|
+
The first is the documented signature; the others that list members or
|
|
794
|
+
state a return type (_gives_return) follow it, in that order, as the
|
|
795
|
+
alternatives the judge compares too (a skill that documents a name
|
|
796
|
+
briefly in one place and in full in another, or a call example beside a
|
|
797
|
+
form that states only what it returns, #914): one that lists no member
|
|
798
|
+
ranks after every one that does, so it never changes the documented
|
|
799
|
+
signature. A form that lists no member and states no return type is
|
|
800
|
+
never an alternative. Two entries that say the same thing (_form_key)
|
|
801
|
+
are one form; of two that differ only by the partial label, the full one
|
|
802
|
+
(the stricter) stands."""
|
|
803
|
+
return {name: [fields for fields, _ in forms] for name, forms in _forms_and_places(inventory).items()}
|
|
804
|
+
|
|
805
|
+
|
|
609
806
|
def documented_signatures(inventory: list[dict]) -> dict[str, dict]:
|
|
610
807
|
"""{name: its documented signature fields}: the first of its
|
|
611
808
|
documented_forms, the one a check holds as `documented`."""
|
|
@@ -624,16 +821,33 @@ def _record_file(record: dict) -> str:
|
|
|
624
821
|
|
|
625
822
|
|
|
626
823
|
def _records_by_name(exports: list[dict]) -> dict[str, list[dict]]:
|
|
627
|
-
"""{name: its surface records}, by file then line
|
|
628
|
-
last
|
|
824
|
+
"""{name: its surface records}, by file then line: a record with no file
|
|
825
|
+
last and, in one file, a record with a line before one with none, so a
|
|
826
|
+
by-eye record stands in neither for the file an extraction names nor for
|
|
827
|
+
the line it records there (#921; a file the two write differently, such
|
|
828
|
+
as `./src/a.py` and `src/a.py`, is _lined_records's to settle)."""
|
|
629
829
|
by_name: dict[str, list[dict]] = {}
|
|
630
|
-
for record in sorted(exports, key=lambda r: (not _record_file(r), _record_file(r), r.get("line")
|
|
830
|
+
for record in sorted(exports, key=lambda r: (not _record_file(r), _record_file(r), not _is_line(r.get("line")),
|
|
831
|
+
r.get("line") if _is_line(r.get("line")) else 0)):
|
|
631
832
|
name = record.get("name")
|
|
632
833
|
if isinstance(name, str) and name:
|
|
633
834
|
by_name.setdefault(name, []).append(record)
|
|
634
835
|
return by_name
|
|
635
836
|
|
|
636
837
|
|
|
838
|
+
def _lined_records(records: list[dict]) -> list[dict]:
|
|
839
|
+
"""`records` (a name's surface records, never its `otherDeclarations`)
|
|
840
|
+
less each one with a file and no line whose file, as _path reads both,
|
|
841
|
+
holds another of them with a line (#921): after a `readByEye` rerun of
|
|
842
|
+
`surface`, the extraction's record and a by-eye one with no line share
|
|
843
|
+
a file, and the extraction's line wins there. A record with no file is
|
|
844
|
+
kept (it sorts last), and so is a line-less record of a file none of
|
|
845
|
+
`records` gives a line, which still stands in for that file."""
|
|
846
|
+
lined = {_path(_record_file(r)) for r in records if _record_file(r) and _is_line(r.get("line"))}
|
|
847
|
+
return [r for r in records
|
|
848
|
+
if not _record_file(r) or _is_line(r.get("line")) or _path(_record_file(r)) not in lined]
|
|
849
|
+
|
|
850
|
+
|
|
637
851
|
def signature_universe(names: list[str], exports: list[dict]) -> set[str]:
|
|
638
852
|
"""The names whose documented signature is compared: the `all` set
|
|
639
853
|
(`names`) plus every name on the surface's export records. A nested
|
|
@@ -647,14 +861,23 @@ def _path(file: str) -> str:
|
|
|
647
861
|
return _inventory()._cited_path(file)
|
|
648
862
|
|
|
649
863
|
|
|
864
|
+
def _candidate(record: dict) -> dict:
|
|
865
|
+
"""A surface record or an other declaration as a check names it: its file, line and signature line."""
|
|
866
|
+
return {"file": _record_file(record), "line": record.get("line"), "signatureLine": record.get("signatureLine")}
|
|
867
|
+
|
|
868
|
+
|
|
650
869
|
def _check_at(name: str, records: list[dict], skill_dir: str | None,
|
|
651
870
|
texts: list[list[str]] | None) -> tuple[dict, list[dict] | None]:
|
|
652
871
|
"""(the surface record the check of `name` is planned at, the candidates
|
|
653
|
-
the judge reads, or None)
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
the first record
|
|
657
|
-
`
|
|
872
|
+
the judge reads, or None), of `records` less a line-less one of a file
|
|
873
|
+
another of them gives a line (_lined_records, before the first record is
|
|
874
|
+
picked: a `./` spelling of the file sorts first). One file among them:
|
|
875
|
+
the first record. Several: the record whose file the skill cites on a
|
|
876
|
+
line that names `name` when exactly one of them is cited (the first
|
|
877
|
+
such record), else the first record, with every record that names a
|
|
878
|
+
file as a candidate. `texts` is the skill's Markdown, read once
|
|
879
|
+
(skill_markdown_lines)."""
|
|
880
|
+
records = _lined_records(records)
|
|
658
881
|
first = records[0] if records else {}
|
|
659
882
|
filed = [r for r in records if _record_file(r)]
|
|
660
883
|
if len(filed) < 2 or len({_path(_record_file(r)) for r in filed}) < 2:
|
|
@@ -663,43 +886,126 @@ def _check_at(name: str, records: list[dict], skill_dir: str | None,
|
|
|
663
886
|
cited = {_path(_record_file(r)) for r in filed} & _inventory()._skill_cited_files(Path(skill_dir), name, texts)
|
|
664
887
|
if len(cited) == 1:
|
|
665
888
|
return next(r for r in filed if _path(_record_file(r)) in cited), None
|
|
666
|
-
return first, [
|
|
667
|
-
|
|
889
|
+
return first, [_candidate(r) for r in filed]
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _span_end(located: list[dict], file: str, line: object) -> int | None:
|
|
893
|
+
"""The last line of the declaration at `file` and `line`, for a citation
|
|
894
|
+
of its body: the line before the next declaration `located` (the
|
|
895
|
+
surface's records and its `otherDeclarations`, of every name) records in
|
|
896
|
+
that file, or sys.maxsize when none follows it (it runs to the end of the
|
|
897
|
+
file). The extraction records no declaration's last line, so the next
|
|
898
|
+
declaration it records bounds it, as it bounds an annotation-contradicted
|
|
899
|
+
`evidence_line` in score. None for a declaration with no line."""
|
|
900
|
+
if not _is_line(line):
|
|
901
|
+
return None
|
|
902
|
+
following = _next_declaration(located, file, line)
|
|
903
|
+
return sys.maxsize if following is None else max(line, following - 1)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def _bindings(name: str, records: list[dict], others: list[dict], places: list[list[tuple[str, int]]],
|
|
907
|
+
every: set[tuple[str, int]], texts: dict[str, list[str]], shared: set[tuple[str, int]],
|
|
908
|
+
located: list[dict]) -> list[dict]:
|
|
909
|
+
"""The forms of `name` bound to a declaration (#918): {"form": its index
|
|
910
|
+
(0 `documented`, n the nth alternative), "file", "line",
|
|
911
|
+
"signatureLine"} for each form whose own citations name exactly one of
|
|
912
|
+
the name's declarations, its surface `records` with a file (less a
|
|
913
|
+
line-less one of a file another of them gives a line, _lined_records)
|
|
914
|
+
and its `others` (the surface's `otherDeclarations`), each once per file
|
|
915
|
+
and line. A form's own citations are those of the places its entries write
|
|
916
|
+
it (`places`, from their `at`), in the skill's Markdown `texts` by path,
|
|
917
|
+
as validate-inventory.py's form_citations reads them, with the name's
|
|
918
|
+
other places in that file (of `every` place the inventory gives it,
|
|
919
|
+
_places_by_name) as those a fenced block's own citations must not be
|
|
920
|
+
shared with; a place in `shared` (_shared_places: one that writes two
|
|
921
|
+
forms of the name that differ) gives none when the name is declared more
|
|
922
|
+
than once (in two files, or at two lines of one), so a form written only
|
|
923
|
+
there is compared as one with no own citation. A name declared once
|
|
924
|
+
keeps them: they can bind a form to no other declaration, and give the
|
|
925
|
+
judge the line of the one there is. One names a declaration as
|
|
926
|
+
cites_declaration says: its file, with no line, or at a line or in a
|
|
927
|
+
range that holds a line of its span, from its line to the line before
|
|
928
|
+
the next declaration `located` records in that file (_span_end). A form
|
|
929
|
+
whose citations name none, or two, is bound to nothing."""
|
|
930
|
+
inventory = _inventory()
|
|
931
|
+
declarations, seen = [], set()
|
|
932
|
+
for declaration in [r for r in _lined_records(records) if _record_file(r)] + others:
|
|
933
|
+
key = (_path(_record_file(declaration)), declaration.get("line"))
|
|
934
|
+
if key not in seen:
|
|
935
|
+
seen.add(key)
|
|
936
|
+
declarations.append(declaration)
|
|
937
|
+
ends = [_span_end(located, _record_file(d), d.get("line")) for d in declarations]
|
|
938
|
+
# declared once (one file, at most one line in it, a per-file record with no line beside it, one kept only when
|
|
939
|
+
# no surface record there has a line): no other declaration a shared place's citation could bind a form to, so
|
|
940
|
+
# its shared places still bind
|
|
941
|
+
homonyms = (len({_path(_record_file(d)) for d in declarations}) > 1
|
|
942
|
+
or sum(_is_line(d.get("line")) for d in declarations) > 1)
|
|
943
|
+
bound = []
|
|
944
|
+
for index, at in enumerate(places):
|
|
945
|
+
citations = set()
|
|
946
|
+
for path, line in at:
|
|
947
|
+
if homonyms and (path, line) in shared:
|
|
948
|
+
continue
|
|
949
|
+
others_there = {other for where, other in every if where == path and other != line}
|
|
950
|
+
citations |= inventory.form_citations(texts.get(path) or [], line, name, others_there)
|
|
951
|
+
named = [d for d, end in zip(declarations, ends)
|
|
952
|
+
if inventory.cites_declaration(citations, _record_file(d), d.get("line"), end)]
|
|
953
|
+
if len(named) == 1:
|
|
954
|
+
bound.append({"form": index, **_candidate(named[0])})
|
|
955
|
+
return bound
|
|
668
956
|
|
|
669
957
|
|
|
670
|
-
def plan(inventory: list[dict], names: list[str] | None, exports: list[dict], skill_dir: str | None = None
|
|
958
|
+
def plan(inventory: list[dict], names: list[str] | None, exports: list[dict], skill_dir: str | None = None,
|
|
959
|
+
others: list[dict] | None = None) -> dict:
|
|
671
960
|
"""The comparisons over the signature universe of `names` (the `all`
|
|
672
961
|
set) and `exports`; none without a surface (`names` None). `skill_dir`,
|
|
673
962
|
the skill package, settles which declaration a name the surface records
|
|
674
|
-
in several files is compared with (_check_at)
|
|
675
|
-
|
|
676
|
-
(
|
|
677
|
-
forms
|
|
963
|
+
in several files is compared with (_check_at), and binds each form whose
|
|
964
|
+
own citations name one declaration of the name, a surface record or one
|
|
965
|
+
of `others` (the surface's `otherDeclarations`), to it (_bindings), a
|
|
966
|
+
place that writes two of its forms that differ giving none of them a
|
|
967
|
+
citation (_shared_places) when the name is declared more than once, and
|
|
968
|
+
the next declaration `exports` or `others` records in a file ending the
|
|
969
|
+
span of the one before it (_span_end). A
|
|
970
|
+
name documented in several forms carries the others that list members
|
|
971
|
+
or state a return type as `alternatives` (documented_forms)."""
|
|
972
|
+
forms = _forms_and_places(inventory)
|
|
973
|
+
spots = _places_by_name(inventory)
|
|
974
|
+
shared = _shared_places(inventory)
|
|
678
975
|
files: dict[str, list[dict]] = {}
|
|
679
976
|
compared = 0
|
|
680
977
|
if names is not None:
|
|
681
978
|
by_name = _records_by_name(exports)
|
|
682
|
-
|
|
979
|
+
others_by_name = _records_by_name(others or [])
|
|
980
|
+
texts = _inventory().skill_markdown_by_path(Path(skill_dir)) if skill_dir is not None else None
|
|
981
|
+
lines = list(texts.values()) if texts is not None else None
|
|
683
982
|
for name in sorted(signature_universe(names, exports) & set(forms)):
|
|
684
|
-
record, candidates = _check_at(name, by_name.get(name) or [], skill_dir,
|
|
983
|
+
record, candidates = _check_at(name, by_name.get(name) or [], skill_dir, lines)
|
|
685
984
|
compared += 1
|
|
686
985
|
check = {
|
|
687
986
|
"name": name,
|
|
688
987
|
"line": record.get("line"),
|
|
689
988
|
"signatureLine": record.get("signatureLine"),
|
|
690
|
-
"documented": forms[name][0],
|
|
989
|
+
"documented": forms[name][0][0],
|
|
691
990
|
}
|
|
692
991
|
if len(forms[name]) > 1:
|
|
693
|
-
check["alternatives"] = forms[name][1:]
|
|
992
|
+
check["alternatives"] = [fields for fields, _ in forms[name][1:]]
|
|
694
993
|
if candidates:
|
|
695
994
|
check["candidates"] = candidates
|
|
995
|
+
bound = (_bindings(name, by_name.get(name) or [], others_by_name.get(name) or [],
|
|
996
|
+
[at for _, at in forms[name]], spots.get(name, set()), texts,
|
|
997
|
+
shared.get(name, set()), exports + (others or []))
|
|
998
|
+
if texts is not None else [])
|
|
999
|
+
if bound:
|
|
1000
|
+
check["bound"] = bound
|
|
696
1001
|
files.setdefault(_record_file(record), []).append(check)
|
|
1002
|
+
named_forms = {name: [fields for fields, _ in fs] for name, fs in forms.items()}
|
|
697
1003
|
return {
|
|
698
1004
|
"compared": compared,
|
|
699
1005
|
"files": [{"file": f, "checks": checks} for f, checks in sorted(files.items())],
|
|
700
|
-
"documentedSignatures":
|
|
1006
|
+
"documentedSignatures": {name: fs[0] for name, fs in named_forms.items()},
|
|
701
1007
|
# The fallback scan's alternatives: a name's form n is its nth entry here, as in a check's `alternatives`.
|
|
702
|
-
"documentedAlternatives": {name: fs[1:] for name, fs in
|
|
1008
|
+
"documentedAlternatives": {name: fs[1:] for name, fs in named_forms.items() if len(fs) > 1},
|
|
703
1009
|
}
|
|
704
1010
|
|
|
705
1011
|
|
|
@@ -749,7 +1055,8 @@ def _declaration_line(by_name: dict[str, list[dict]], name: str, file: str, line
|
|
|
749
1055
|
|
|
750
1056
|
|
|
751
1057
|
def _next_declaration(exports: list[dict], file: str, line: int) -> int | None:
|
|
752
|
-
"""The line of the first surface record in `file` after `line
|
|
1058
|
+
"""The line of the first surface record in `file` after `line` (a bound declaration included), None when there
|
|
1059
|
+
is none."""
|
|
753
1060
|
return min((at for at in _lines_in(exports, file) if at > line), default=None)
|
|
754
1061
|
|
|
755
1062
|
|
|
@@ -1206,11 +1513,33 @@ def _returns(lines: list[str], number: int, declaration_line: int) -> bool:
|
|
|
1206
1513
|
return False
|
|
1207
1514
|
|
|
1208
1515
|
|
|
1516
|
+
def _states_return(text: object, extension: str = "") -> bool:
|
|
1517
|
+
"""Whether `text`, a `return_type` or the return a documented signature
|
|
1518
|
+
writes (_signature_parts), states a return type in a file of
|
|
1519
|
+
`extension`, as signature-comparison-rules.md (Annotations) reads it:
|
|
1520
|
+
not when it is blank or no text, nor when it is a parenthesized,
|
|
1521
|
+
bracketed or braced list of value names (`(nodes, edges)`,
|
|
1522
|
+
`[state, setState]`, `{ data, error }`), each item a bare name that
|
|
1523
|
+
starts lowercase and is no type name of the file's language
|
|
1524
|
+
(LOWERCASE_TYPES; every table for another language or ""). A single
|
|
1525
|
+
name (`result`), a list of types (`(str, int)`), a list that writes a
|
|
1526
|
+
type among its names and any other text state one."""
|
|
1527
|
+
if not isinstance(text, str) or not text.strip():
|
|
1528
|
+
return False
|
|
1529
|
+
text = text.strip()
|
|
1530
|
+
if VALUE_LIST_BRACKETS.get(text[0]) != text[-1]:
|
|
1531
|
+
return True
|
|
1532
|
+
types = LOWERCASE_TYPES.get(extension, ALL_LOWERCASE_TYPES)
|
|
1533
|
+
names = [item.strip() for item in text[1:-1].split(",")]
|
|
1534
|
+
return not all(VALUE_NAME_RE.fullmatch(name) and name not in types for name in names)
|
|
1535
|
+
|
|
1536
|
+
|
|
1209
1537
|
def contradiction_fact(entry: dict, documented: dict, file: str, declaration_line: int,
|
|
1210
1538
|
next_line: int | None = None, source_root: str | None = None) -> str | None:
|
|
1211
1539
|
"""The first fact an annotation-contradicted mismatch rests on that does
|
|
1212
1540
|
not check, None when each does: the documented entry states a return
|
|
1213
|
-
type (a
|
|
1541
|
+
type (a `return_type`, or the return a `documented_sig` writes, that is
|
|
1542
|
+
neither blank nor a list of value names: _states_return);
|
|
1214
1543
|
`documented_sig` and `source_sig` write the same parameters, as the
|
|
1215
1544
|
comparison rules read them in a file of `file`'s language
|
|
1216
1545
|
(_parameter_difference, which compares the texts before the return when
|
|
@@ -1225,7 +1554,7 @@ def contradiction_fact(entry: dict, documented: dict, file: str, declaration_lin
|
|
|
1225
1554
|
documented_params, documented_return = _signature_parts(entry["documented_sig"], extension)
|
|
1226
1555
|
source_params, _ = _signature_parts(entry["source_sig"], extension)
|
|
1227
1556
|
return_type = documented.get("return_type")
|
|
1228
|
-
if not (
|
|
1557
|
+
if not _states_return(return_type, extension) and not _states_return(documented_return, extension):
|
|
1229
1558
|
return "the documented signature states no return type"
|
|
1230
1559
|
difference = _parameter_difference(entry["documented_sig"], entry["source_sig"], extension)
|
|
1231
1560
|
if difference is not None:
|
|
@@ -1315,14 +1644,18 @@ def _gap_record(m: dict) -> dict:
|
|
|
1315
1644
|
|
|
1316
1645
|
|
|
1317
1646
|
def _form_text(form: dict) -> str:
|
|
1318
|
-
"""A documented form on one line: its usage form, else its parameters and return type
|
|
1647
|
+
"""A documented form on one line: its usage form, else its parameters and return type (`-> R` alone for a form
|
|
1648
|
+
with no `params` that writes a return type)."""
|
|
1319
1649
|
def text(value: object, sep: str = ", ") -> str:
|
|
1320
1650
|
return sep.join(str(v) for v in value) if isinstance(value, list) else str(value)
|
|
1321
1651
|
if _gives_usage(form):
|
|
1322
1652
|
return text(form["usage_signature"], "; ")
|
|
1323
1653
|
return_type = form.get("return_type")
|
|
1324
1654
|
tail = f" -> {text(return_type)}" if isinstance(return_type, (str, list)) and text(return_type).strip() else ""
|
|
1325
|
-
|
|
1655
|
+
params = form.get("params")
|
|
1656
|
+
if params is None and tail:
|
|
1657
|
+
return tail.lstrip()
|
|
1658
|
+
return f"({text('' if params is None else params)}){tail}"
|
|
1326
1659
|
|
|
1327
1660
|
|
|
1328
1661
|
def _sig_text(text: str) -> str:
|
|
@@ -1334,13 +1667,20 @@ def _sig_text(text: str) -> str:
|
|
|
1334
1667
|
|
|
1335
1668
|
|
|
1336
1669
|
def _quotes(documented_sig: str, form: dict) -> bool:
|
|
1337
|
-
"""Whether `documented_sig` is the text of `form`: its parameters (a list joined
|
|
1338
|
-
or
|
|
1670
|
+
"""Whether `documented_sig` is the text of `form`: its parameters (a list joined; blank or absent, `()`, for a
|
|
1671
|
+
form with no usage form, so `()` never quotes a usage-only form), or one of its usages, whole or before the
|
|
1672
|
+
return type it writes, with or without a name or a dotted receiver before its parameter group
|
|
1673
|
+
(`run_pipeline(tasks, data)`, `coco.default_env()`: #919); a usage also reads without its dotted receiver, its
|
|
1674
|
+
name kept (`default_env()` quotes `coco.default_env()`)."""
|
|
1339
1675
|
usage = form.get("usage_signature")
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1676
|
+
usages = [" ".join(u.split()) for u in (usage if isinstance(usage, list) else [usage]) if isinstance(u, str)]
|
|
1677
|
+
texts = {_sig_text(t) for u in usages for t in (u, CALL_RECEIVER_RE.sub("", u, count=1))} - {""}
|
|
1678
|
+
params = _sig_text(_field_text(form.get("params")))
|
|
1679
|
+
if params or not _gives_usage(form):
|
|
1680
|
+
texts.add(params)
|
|
1681
|
+
head = _signature_parts(documented_sig)[0]
|
|
1682
|
+
named = LEADING_NAME_RE.sub("", head, count=1)
|
|
1683
|
+
return bool({_sig_text(documented_sig), _sig_text(head), _sig_text(named)} & texts)
|
|
1344
1684
|
|
|
1345
1685
|
|
|
1346
1686
|
def _form_gap_record(m: dict, index: int, forms: list[dict]) -> dict:
|
|
@@ -1353,9 +1693,39 @@ def _form_gap_record(m: dict, index: int, forms: list[dict]) -> dict:
|
|
|
1353
1693
|
return record
|
|
1354
1694
|
|
|
1355
1695
|
|
|
1696
|
+
def _form_index(name: str, entry: dict, forms: list[dict]) -> tuple[int, bool, list[str]]:
|
|
1697
|
+
"""(the index of the documented form `entry`, a mismatch of `name`, is
|
|
1698
|
+
graded against, whether its label may lower the grade, the notes that
|
|
1699
|
+
say why the form or its grading is not the one its `form` names): an
|
|
1700
|
+
index past `forms` is `documented`; for a name with several forms, one
|
|
1701
|
+
whose text its `documented_sig` is not (_quotes) gives way to the form
|
|
1702
|
+
it quotes, else it stays (its title, Issue and bound declaration are
|
|
1703
|
+
that form's, #919) and its label lowers nothing: it is graded as a full
|
|
1704
|
+
form, with a note when it is not `documented` or is partial."""
|
|
1705
|
+
notes = []
|
|
1706
|
+
# the judge's own `form`, which a note names even when it is past `forms`
|
|
1707
|
+
index = named = entry.get("form") or 0
|
|
1708
|
+
if index >= len(forms):
|
|
1709
|
+
notes.append(f"a signature mismatch for `{name}` names `form` {index}, no form its check offered "
|
|
1710
|
+
f"(0 to {len(forms) - 1}): graded against `documented`")
|
|
1711
|
+
index = 0
|
|
1712
|
+
if len(forms) > 1 and not _quotes(entry["documented_sig"], forms[index]):
|
|
1713
|
+
quoted = next((j for j, f in enumerate(forms) if _quotes(entry["documented_sig"], f)), None)
|
|
1714
|
+
if quoted is not None:
|
|
1715
|
+
notes.append(f"a signature mismatch for `{name}` names `form` {named}, whose text its "
|
|
1716
|
+
f"`documented_sig` is not: graded against form {quoted}, the one it quotes")
|
|
1717
|
+
return quoted, True, notes
|
|
1718
|
+
if index != 0 or _partial(forms[index]):
|
|
1719
|
+
notes.append(f"a signature mismatch for `{name}` names `form` {named}, whose text its "
|
|
1720
|
+
f"`documented_sig` is not, nor any other form's: graded against form {index} as a full form")
|
|
1721
|
+
return index, False, notes
|
|
1722
|
+
return index, True, notes
|
|
1723
|
+
|
|
1724
|
+
|
|
1356
1725
|
def score(inventory: list[dict], names: list[str], exports: list[dict], results: list[dict],
|
|
1357
1726
|
type_names: list[str] | None = None, offered: dict[str, list[str]] | None = None,
|
|
1358
|
-
source_root: str | None = None, planned: dict[str, list[dict]] | None = None
|
|
1727
|
+
source_root: str | None = None, planned: dict[str, list[dict]] | None = None,
|
|
1728
|
+
bound: dict[str, dict[int, dict]] | None = None) -> dict:
|
|
1359
1729
|
"""The two categories: Signature Accuracy over the signature universe of
|
|
1360
1730
|
`names` (the `all` set) and `exports`, Type Coverage over `type_names`
|
|
1361
1731
|
when given, else over `names`. A fallback scan's exports_found join the
|
|
@@ -1365,8 +1735,17 @@ def score(inventory: list[dict], names: list[str], exports: list[dict], results:
|
|
|
1365
1735
|
annotation-contradicted mismatch's `evidence_line` is checked in. A
|
|
1366
1736
|
mismatch is graded against the documented form its `form` names: of
|
|
1367
1737
|
documented_forms, or of `planned` (read from --plan) for a name it
|
|
1368
|
-
holds, with a warning where the two differ.
|
|
1738
|
+
holds, with a warning where the two differ. `bound` (read from --plan,
|
|
1739
|
+
#918) gives the declaration each bound form of a name is compared with:
|
|
1740
|
+
that form's mismatch is taken at its file and line, with a warning when
|
|
1741
|
+
the judge names another file, and its line bounds the next declaration
|
|
1742
|
+
in that file as a surface record's does. A bound declaration joins no
|
|
1743
|
+
name, set or count."""
|
|
1744
|
+
bound = bound or {}
|
|
1369
1745
|
by_name = _records_by_name(exports)
|
|
1746
|
+
# The declarations a line is located among: the surface's records, and each one a form is bound to (an other
|
|
1747
|
+
# declaration only through a binding).
|
|
1748
|
+
located = exports + [{"name": name, **at} for name, forms in bound.items() for at in forms.values()]
|
|
1370
1749
|
# A fallback scan reports its own names and types.
|
|
1371
1750
|
fallback_types = {n for r in results for n in r.get("types_found") or []}
|
|
1372
1751
|
surface = set(names) | {n for r in results for n in r.get("exports_found") or []}
|
|
@@ -1388,52 +1767,54 @@ def score(inventory: list[dict], names: list[str], exports: list[dict], results:
|
|
|
1388
1767
|
name = entry["name"]
|
|
1389
1768
|
files = _offered_files(offered, by_name, name)
|
|
1390
1769
|
# A mismatch found at one of a check's candidates names that file; any other file is not taken.
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
file = entry["file"]
|
|
1395
|
-
elif name in compared:
|
|
1396
|
-
warnings.append(f"{result['file']}:{entry['line']}: a signature mismatch for `{name}` names "
|
|
1397
|
-
f"`{entry['file']}`, no file the plan offered its check at: recorded at "
|
|
1398
|
-
"the result's file")
|
|
1399
|
-
file = _path(file)
|
|
1770
|
+
named = entry.get("file")
|
|
1771
|
+
file = _path(named if named is not None and _is_offered_file(files, named) else result["file"])
|
|
1772
|
+
line = entry["line"]
|
|
1400
1773
|
if name not in compared:
|
|
1401
|
-
if (name, file,
|
|
1402
|
-
warnings.append(f"{file}:{
|
|
1774
|
+
if (name, file, line) not in seen:
|
|
1775
|
+
warnings.append(f"{file}:{line}: a signature mismatch for "
|
|
1403
1776
|
f"`{name}`, which is no documented signature on the source surface: "
|
|
1404
1777
|
"not scored, no gap")
|
|
1405
|
-
seen.add((name, file,
|
|
1778
|
+
seen.add((name, file, line))
|
|
1406
1779
|
continue
|
|
1407
1780
|
# The documented form the mismatch is reported for: 0 is `documented`, n the check's nth alternative.
|
|
1408
|
-
index = entry.get("form") or 0
|
|
1409
|
-
if index >= len(forms[name]):
|
|
1410
|
-
warning = (f"{file}:{entry['line']}: a signature mismatch for `{name}` names `form` {index}, no "
|
|
1411
|
-
f"form its check offered (0 to {len(forms[name]) - 1}): graded against `documented`")
|
|
1412
|
-
if warning not in warnings:
|
|
1413
|
-
warnings.append(warning)
|
|
1414
|
-
index = 0
|
|
1415
1781
|
# A form's own label lowers a grade only for the form the mismatch quotes as `documented_sig`.
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1782
|
+
index, quoted, notes = _form_index(name, entry, forms[name])
|
|
1783
|
+
# A bound form is compared with its bound declaration alone: its mismatch is taken there (#918).
|
|
1784
|
+
at = bound.get(name, {}).get(index)
|
|
1785
|
+
if at is not None:
|
|
1786
|
+
judged = _path(named if named is not None else result["file"])
|
|
1787
|
+
file, line = _path(at["file"]), at["line"] if _is_line(at["line"]) else line
|
|
1788
|
+
if judged != file:
|
|
1789
|
+
warnings.append(f"{result['file']}:{entry['line']}: a signature mismatch for `{name}` names "
|
|
1790
|
+
f"`{judged}`, but its form {index} is bound to the declaration at "
|
|
1791
|
+
f"{file}:{line}: recorded there")
|
|
1792
|
+
elif named is not None and not _is_offered_file(files, named):
|
|
1793
|
+
warnings.append(f"{result['file']}:{entry['line']}: a signature mismatch for `{name}` names "
|
|
1794
|
+
f"`{named}`, no file the plan offered its check at: recorded at "
|
|
1795
|
+
"the result's file")
|
|
1796
|
+
for note in notes:
|
|
1797
|
+
if f"{file}:{line}: {note}" not in warnings:
|
|
1798
|
+
warnings.append(f"{file}:{line}: {note}")
|
|
1425
1799
|
form = forms[name][index]
|
|
1800
|
+
if not quoted:
|
|
1801
|
+
# A text that quotes no form never earns that form's partial label (#919).
|
|
1802
|
+
form = {f: v for f, v in form.items() if f != "signature_form"}
|
|
1426
1803
|
# Each usage of a list is a form of its own: one key per documented text.
|
|
1427
|
-
key = (name, file,
|
|
1804
|
+
key = (name, file, line, index, _sig_text(entry["documented_sig"]))
|
|
1428
1805
|
difference = entry.get("difference")
|
|
1429
|
-
# Graded at the file only when the plan offered it: never Low at a
|
|
1430
|
-
|
|
1806
|
+
# Graded at the file only when the plan offered it (a bound form its declaration's): never Low at a
|
|
1807
|
+
# namesake it did not offer.
|
|
1808
|
+
if at is not None:
|
|
1809
|
+
declared = file
|
|
1810
|
+
else:
|
|
1811
|
+
declared = (file if _is_offered_file(files, file) else files[0]) if files else None
|
|
1431
1812
|
# The facts of an annotation-contradicted mismatch, checked once and handed to grade().
|
|
1432
1813
|
fact = None
|
|
1433
1814
|
if difference == CONTRADICTED:
|
|
1434
|
-
at_line = _declaration_line(by_name, name, file,
|
|
1815
|
+
at_line = line if at is not None else _declaration_line(by_name, name, file, line)
|
|
1435
1816
|
fact = contradiction_fact(entry, form, file, at_line,
|
|
1436
|
-
_next_declaration(
|
|
1817
|
+
_next_declaration(located, file, at_line), source_root)
|
|
1437
1818
|
warning = (f"{file}:{at_line}: the mismatch for `{name}` is tagged {CONTRADICTED}, but {fact}: "
|
|
1438
1819
|
"graded as an untagged mismatch (Critical)")
|
|
1439
1820
|
if fact is not None and warning not in warnings:
|
|
@@ -1442,13 +1823,13 @@ def score(inventory: list[dict], names: list[str], exports: list[dict], results:
|
|
|
1442
1823
|
# Every entry is graded: one declaration reported twice against one form keeps its most severe grade.
|
|
1443
1824
|
if key in graded and GRADES.index(graded[key]["severity"]) <= GRADES.index(severity):
|
|
1444
1825
|
continue
|
|
1445
|
-
graded[key] = {"name": name, "file": file, "line":
|
|
1826
|
+
graded[key] = {"name": name, "file": file, "line": line,
|
|
1446
1827
|
"source_sig": entry["source_sig"], "documented_sig": entry["documented_sig"],
|
|
1447
1828
|
"issue": entry["issue"], "difference": difference, "severity": severity}
|
|
1448
1829
|
if difference == CONTRADICTED:
|
|
1449
1830
|
graded[key]["evidence_line"] = entry.get("evidence_line")
|
|
1450
|
-
# A check that offered several files keeps one mismatch per form, its least severe (the closest candidate)
|
|
1451
|
-
# Each form's mismatch stands on its own.
|
|
1831
|
+
# A check that offered several files keeps one mismatch per form, its least severe (the closest candidate);
|
|
1832
|
+
# a bound form was offered its one declaration. Each form's mismatch stands on its own.
|
|
1452
1833
|
def order(item: tuple) -> tuple:
|
|
1453
1834
|
(_, _, _, index, text), m = item
|
|
1454
1835
|
return m["file"], m["line"], m["name"], index, text
|
|
@@ -1456,7 +1837,9 @@ def score(inventory: list[dict], names: list[str], exports: list[dict], results:
|
|
|
1456
1837
|
kept, closest = [], {}
|
|
1457
1838
|
for key, m in sorted(graded.items(), key=order):
|
|
1458
1839
|
bucket = (m["name"], key[3], key[4])
|
|
1459
|
-
|
|
1840
|
+
single = key[3] in bound.get(m["name"], {}) or len(
|
|
1841
|
+
{_path(f) for f in _offered_files(offered, by_name, m["name"])}) < 2
|
|
1842
|
+
if single:
|
|
1460
1843
|
kept.append((key, m))
|
|
1461
1844
|
elif bucket not in closest or GRADES.index(m["severity"]) > GRADES.index(closest[bucket][1]["severity"]):
|
|
1462
1845
|
closest[bucket] = (key, m)
|
|
@@ -1477,12 +1860,22 @@ def score(inventory: list[dict], names: list[str], exports: list[dict], results:
|
|
|
1477
1860
|
return "type"
|
|
1478
1861
|
return next((r.get("kind") for r in by_name.get(name, []) if r.get("kind")), None)
|
|
1479
1862
|
|
|
1480
|
-
|
|
1863
|
+
pool = surface if type_names is None else set(type_names)
|
|
1864
|
+
types = {n for n in pool if kind_of(n) in TYPE_KINDS}
|
|
1865
|
+
# A name the stale-scope guard restored with no kind (#913: the provenance map gives it none at its file) may be
|
|
1866
|
+
# a type: Type Coverage leaves it out and names it, never lowering the score.
|
|
1867
|
+
kindless = sorted(n for n in pool - fallback_types if kind_of(n) is None
|
|
1868
|
+
and any(r.get("origin") == "restored" for r in by_name.get(n, [])))
|
|
1481
1869
|
documented_types = types & documented
|
|
1482
1870
|
if types:
|
|
1483
1871
|
type_coverage = round2(len(documented_types) / len(types) * 100)
|
|
1484
1872
|
else:
|
|
1485
1873
|
type_coverage = 100.0
|
|
1874
|
+
if kindless:
|
|
1875
|
+
warnings.append("Type Coverage leaves out each name the stale-scope guard restored with no kind (the "
|
|
1876
|
+
"provenance map, when one is given, gives it no kind Type Coverage reads at its file): "
|
|
1877
|
+
+ ", ".join(f"`{n}`" for n in kindless))
|
|
1878
|
+
elif not types:
|
|
1486
1879
|
warnings.append("the source surface has no interface, type alias, enum or class: Type "
|
|
1487
1880
|
"Coverage has nothing to cover and scores 100")
|
|
1488
1881
|
|
|
@@ -1515,14 +1908,15 @@ def _write_json(path: str, payload) -> None:
|
|
|
1515
1908
|
|
|
1516
1909
|
def _cmd_plan(args: argparse.Namespace) -> tuple[dict, int]:
|
|
1517
1910
|
inventory = load_inventory(args.inventory)
|
|
1518
|
-
names, exports = (None, [])
|
|
1911
|
+
names, exports, others = (None, [], [])
|
|
1519
1912
|
if args.surface is not None:
|
|
1520
1913
|
_, exports, names = load_surface(args.surface, args.surface_set)
|
|
1914
|
+
others = load_other_declarations(args.surface)
|
|
1521
1915
|
if args.skill_dir is not None:
|
|
1522
1916
|
if not args.skill_dir.strip():
|
|
1523
1917
|
raise InputError("--skill-dir is empty: pass the skill package (the folder that holds SKILL.md)")
|
|
1524
1918
|
_read_text(str(Path(args.skill_dir) / "SKILL.md"), "--skill-dir")
|
|
1525
|
-
out = plan(inventory, names, exports, args.skill_dir)
|
|
1919
|
+
out = plan(inventory, names, exports, args.skill_dir, others)
|
|
1526
1920
|
if args.output:
|
|
1527
1921
|
_write_json(args.output, out)
|
|
1528
1922
|
return out, 0
|
|
@@ -1541,8 +1935,9 @@ def _cmd_score(args: argparse.Namespace) -> tuple[dict, int]:
|
|
|
1541
1935
|
return {"valid": False, "violations": violations}, 2
|
|
1542
1936
|
offered = load_plan(args.plan) if args.plan is not None else None
|
|
1543
1937
|
planned = load_plan_forms(args.plan) if args.plan is not None else None
|
|
1938
|
+
bound = load_plan_bound(args.plan) if args.plan is not None else None
|
|
1544
1939
|
out = score(inventory, names, exports, results, None if args.surface_set == "all" else chosen, offered,
|
|
1545
|
-
args.source_root, planned)
|
|
1940
|
+
args.source_root, planned, bound)
|
|
1546
1941
|
if args.output:
|
|
1547
1942
|
_write_json(args.output, out)
|
|
1548
1943
|
if args.gaps_output:
|
|
@@ -1564,7 +1959,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
1564
1959
|
help="a surface name set, which must exist (default: all); the plan compares the signature "
|
|
1565
1960
|
"universe whichever set it names")
|
|
1566
1961
|
p.add_argument("--skill-dir", help="the skill package under test: its citations pick the declaration of a name "
|
|
1567
|
-
"the surface records in several files"
|
|
1962
|
+
"the surface records in several files, and bind each form whose own "
|
|
1963
|
+
"citations name one declaration of its name to it")
|
|
1568
1964
|
p.add_argument("--output", help="also write the plan to this file")
|
|
1569
1965
|
p.set_defaults(func=_cmd_plan)
|
|
1570
1966
|
|
|
@@ -1573,7 +1969,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
1573
1969
|
p.add_argument("--surface", required=True, help="load-coverage-inputs.py surface output")
|
|
1574
1970
|
p.add_argument("--surface-set", default="all",
|
|
1575
1971
|
help="the surface name set Type Coverage counts (default: all)")
|
|
1576
|
-
p.add_argument("--plan", help="the plan's output: the files each check offered a mismatch at"
|
|
1972
|
+
p.add_argument("--plan", help="the plan's output: the files each check offered a mismatch at, and the "
|
|
1973
|
+
"declaration each bound form's mismatch is taken at")
|
|
1577
1974
|
p.add_argument("--source-root", help="the local source tree the judges read: an annotation-contradicted "
|
|
1578
1975
|
"mismatch's evidence_line must start with `return` there, or be the "
|
|
1579
1976
|
"declaration line with its expression body")
|