bmad-module-skill-forge 4.0.0 → 6.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/agents.md +1 -1
- package/docs/architecture.md +5 -5
- package/docs/campaign.md +1 -1
- package/docs/concepts.md +4 -4
- package/docs/examples.md +32 -25
- package/docs/getting-started.md +3 -3
- package/docs/how-it-works.md +2 -2
- package/docs/index.md +1 -1
- package/docs/skill-model.md +41 -39
- package/docs/troubleshooting.md +9 -3
- package/docs/verifying-a-skill.md +42 -21
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +6 -6
- package/package.json +1 -1
- package/src/knowledge/tool-resolution.md +6 -6
- package/src/shared/references/headless-gate-convention.md +4 -1
- package/src/shared/references/output-contract-schema.md +3 -3
- package/src/shared/references/pipeline-contracts.md +2 -1
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
- package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
- package/src/shared/scripts/skf-build-change-manifest.py +1180 -189
- package/src/shared/scripts/skf-classify-changed-files.py +15 -10
- package/src/shared/scripts/skf-count-tokens.py +54 -10
- package/src/shared/scripts/skf-detect-workspaces.py +67 -0
- package/src/shared/scripts/skf-emit-result-envelope.py +64 -20
- package/src/shared/scripts/skf-extract-public-api.py +472 -120
- package/src/shared/scripts/skf-extraction-inventory.py +10 -6
- package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
- package/src/shared/scripts/skf-parse-gaps.py +136 -26
- package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
- package/src/shared/scripts/skf-resolve-customization.py +197 -0
- package/src/shared/scripts/skf-shard-body.py +239 -41
- package/src/shared/scripts/skf-validate-output.py +12 -3
- package/src/shared/scripts/skf-verify-provenance-completeness.py +574 -45
- package/src/shared/tool-requirements.yaml +8 -2
- package/src/skf-analyze-source/SKILL.md +3 -2
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/references/generate-briefs.md +1 -1
- package/src/skf-analyze-source/references/headless-contract.md +1 -1
- package/src/skf-audit-skill/SKILL.md +4 -3
- package/src/skf-audit-skill/references/re-index.md +2 -2
- package/src/skf-audit-skill/references/report.md +1 -1
- package/src/skf-brief-skill/SKILL.md +3 -2
- package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
- package/src/skf-brief-skill/references/invocation-contract.md +2 -2
- package/src/skf-brief-skill/references/scope-definition.md +4 -4
- package/src/skf-campaign/SKILL.md +3 -2
- package/src/skf-campaign/references/campaign-contracts.md +2 -0
- package/src/skf-campaign/references/step-05-skill-loop.md +8 -3
- package/src/skf-campaign/references/step-10-export.md +3 -2
- package/src/skf-create-skill/SKILL.md +4 -3
- package/src/skf-create-skill/assets/skill-sections.md +1 -1
- package/src/skf-create-skill/references/ccc-index-check.md +1 -1
- package/src/skf-create-skill/references/component-extraction.md +1 -1
- package/src/skf-create-skill/references/extract.md +3 -2
- package/src/skf-create-skill/references/extraction-patterns.md +3 -3
- package/src/skf-create-skill/references/generate-artifacts.md +1 -1
- package/src/skf-create-skill/references/report.md +8 -3
- package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
- package/src/skf-create-skill/references/validate.md +1 -1
- package/src/skf-create-stack-skill/SKILL.md +3 -2
- package/src/skf-create-stack-skill/references/generate-output.md +1 -1
- package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
- package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
- package/src/skf-create-stack-skill/references/report.md +1 -1
- package/src/skf-create-stack-skill/references/validate.md +1 -1
- package/src/skf-drop-skill/SKILL.md +3 -2
- package/src/skf-drop-skill/references/invocation-contract.md +1 -1
- package/src/skf-drop-skill/references/report.md +1 -1
- package/src/skf-export-skill/SKILL.md +3 -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/invocation-contract.md +1 -1
- package/src/skf-export-skill/references/load-skill.md +1 -1
- package/src/skf-export-skill/references/summary.md +1 -1
- package/src/skf-export-skill/references/token-report.md +29 -9
- package/src/skf-forger/SKILL.md +3 -2
- package/src/skf-forger/references/pipeline-mode.md +2 -2
- package/src/skf-quick-skill/SKILL.md +4 -3
- package/src/skf-quick-skill/references/finalize.md +1 -1
- package/src/skf-quick-skill/references/halt-contract.md +2 -2
- package/src/skf-quick-skill/references/write-and-validate.md +1 -1
- package/src/skf-refine-architecture/SKILL.md +8 -9
- package/src/skf-refine-architecture/customize.toml +1 -1
- package/src/skf-refine-architecture/references/report.md +1 -1
- package/src/skf-rename-skill/SKILL.md +3 -2
- package/src/skf-rename-skill/references/invocation-contract.md +2 -2
- package/src/skf-setup/SKILL.md +3 -2
- package/src/skf-setup/references/ccc-index.md +1 -1
- package/src/skf-setup/references/invocation-contract.md +1 -1
- package/src/skf-test-skill/SKILL.md +3 -2
- package/src/skf-test-skill/assets/output-section-formats.md +6 -4
- package/src/skf-test-skill/references/coherence-check.md +11 -8
- package/src/skf-test-skill/references/coverage-check-branches.md +81 -0
- package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
- package/src/skf-test-skill/references/coverage-check.md +60 -97
- package/src/skf-test-skill/references/init.md +2 -1
- package/src/skf-test-skill/references/inventory-contract.md +27 -0
- package/src/skf-test-skill/references/invocation-contract.md +3 -3
- package/src/skf-test-skill/references/migration-section-rules.md +26 -14
- package/src/skf-test-skill/references/report.md +5 -5
- package/src/skf-test-skill/references/scoring-rules.md +8 -7
- package/src/skf-test-skill/references/signature-comparison-rules.md +72 -0
- package/src/skf-test-skill/references/source-access-protocol.md +14 -10
- package/src/skf-test-skill/references/step-hard-gate.md +1 -0
- package/src/skf-test-skill/scripts/build-result-context.py +20 -3
- package/src/skf-test-skill/scripts/gap-ledger.py +456 -176
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +516 -79
- package/src/skf-test-skill/scripts/locate-export-segments.py +685 -76
- package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
- package/src/skf-test-skill/scripts/score-signatures.py +1205 -65
- package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
- package/src/skf-test-skill/scripts/validate-inventory.py +350 -35
- package/src/skf-test-skill/templates/test-report-template.md +1 -0
- package/src/skf-update-skill/SKILL.md +3 -2
- package/src/skf-update-skill/references/detect-changes.md +1 -1
- package/src/skf-update-skill/references/gap-driven.md +54 -33
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +15 -10
- package/src/skf-update-skill/references/re-extract.md +3 -1
- package/src/skf-update-skill/references/report.md +6 -5
- package/src/skf-update-skill/references/write.md +42 -20
- package/src/skf-verify-stack/SKILL.md +4 -3
- package/tools/cli/lib/tool-check.js +158 -2
|
@@ -15,20 +15,43 @@ ratios and the gap records of the wrong signatures.
|
|
|
15
15
|
|
|
16
16
|
plan the comparisons to make. For each documented export with a
|
|
17
17
|
signature (an inventory entry of any kind but `method` that carries
|
|
18
|
-
`params`, `return_type` or `usage_signature
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
`params`, `return_type` or `usage_signature`; a function's `params`
|
|
19
|
+
counts when empty, its form for one that takes none; a `constant`
|
|
20
|
+
holding a component carries the form the skill writes as
|
|
21
|
+
`usage_signature`, references/inventory-contract.md says, and a data
|
|
22
|
+
constant none) in the signature universe, grouped by source file,
|
|
23
|
+
with the line that defines it in the source, the documented
|
|
24
|
+
signature (with the entry's `signature_form` when it has one; of
|
|
25
|
+
several entries of one name, one that lists parameters or gives a
|
|
26
|
+
usage form before one that does neither, then a full one before a
|
|
27
|
+
partial one, then, of two full or two partial forms, one that lists
|
|
28
|
+
parameters before one that gives only a usage form, else the first)
|
|
29
|
+
and, as `alternatives`, the name's other distinct forms that list
|
|
30
|
+
parameters or give a usage form, in that order: the judge compares
|
|
31
|
+
the declaration with each, by its own reading rules, and reports a
|
|
32
|
+
mismatch for each form that does not match, naming it as `form`. A
|
|
33
|
+
form that lists no member is never an alternative, and two entries
|
|
34
|
+
that say the same thing are one form (a `full` label and none, a
|
|
35
|
+
list and its items joined with `, `, whitespace runs; of two that
|
|
36
|
+
differ only by the partial label, the full one). The signature universe is the
|
|
37
|
+
surface's `all` set plus every name on its export records, so the
|
|
38
|
+
names of a Python sub-package load-coverage-inputs.py keeps out of
|
|
39
|
+
`all` (excluded.nestedEntries) keep their checks. A check sits at the
|
|
40
|
+
name's first surface record (one with a file before one with none
|
|
41
|
+
or a blank one). For a name the surface records in more than one
|
|
42
|
+
file, --skill-dir (the skill package) picks the record whose file
|
|
43
|
+
the skill cites on a line that names it
|
|
44
|
+
(validate-inventory.py's _skill_cited_files) when exactly one of
|
|
45
|
+
those files is cited; otherwise the check stays at the first record
|
|
46
|
+
and lists every record with a file as `candidates` for the judge to
|
|
47
|
+
read. Also the whole documented-signature map, and each name's
|
|
48
|
+
alternatives, for the fallback scan that has no surface lines yet.
|
|
26
49
|
score the categories. Reads every subagent result, checks its schema,
|
|
27
50
|
and computes:
|
|
28
51
|
Signature Accuracy = matching / compared * 100, where compared is
|
|
29
52
|
the documented signatures of the signature universe (with a
|
|
30
53
|
fallback scan's exports_found) and matching those no result
|
|
31
|
-
names in
|
|
54
|
+
names in a Critical mismatch: a wrong signature is wrong
|
|
32
55
|
whichever set the denominator counts
|
|
33
56
|
Type Coverage = documented types / total types * 100, where
|
|
34
57
|
total types counts the names of the --surface-set set (the
|
|
@@ -38,11 +61,101 @@ ratios and the gap records of the wrong signatures.
|
|
|
38
61
|
interface) and documented types those the inventory documents
|
|
39
62
|
A category with nothing to compare scores 100 (nothing documented
|
|
40
63
|
is wrong) and says so in `warnings`. Each signature mismatch of a
|
|
41
|
-
compared name
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
`
|
|
64
|
+
compared name is graded (grade()) against the documented form its
|
|
65
|
+
`form` names (0, or none, the check's `documented`; n its nth
|
|
66
|
+
`alternatives` entry; an index past the name's forms is listed in
|
|
67
|
+
`warnings` and graded against `documented`), by that form's own
|
|
68
|
+
`signature_form`. With --plan the forms are the plan's (a name whose
|
|
69
|
+
forms differ from the inventory's is listed in `warnings`). Each
|
|
70
|
+
becomes a record in the gap ledger's record format (gap-ledger.py append --input reads
|
|
71
|
+
--gaps-output). The judge's `difference` tag alone never lowers a
|
|
72
|
+
grade: the script checks the fact each one rests on.
|
|
73
|
+
Low `signature-mismatch` `difference` "parameter-name" reported
|
|
74
|
+
at a file the plan offered for the name (the file it sent the
|
|
75
|
+
check to, or a candidate), when that file is of a language
|
|
76
|
+
whose callers cannot pass an argument by its name
|
|
77
|
+
(POSITIONAL_EXTENSIONS: TS/JS, Go, Rust, Java, C, C++; never
|
|
78
|
+
Python, whose positional-only parameters the script cannot see,
|
|
79
|
+
nor a Vue component, whose props callers set by name)
|
|
80
|
+
Info `observation`, titled `Partial signature: {name}`
|
|
81
|
+
`difference` "omitted-optional" on a documented signature whose
|
|
82
|
+
inventory entry is `signature_form` "partial"
|
|
83
|
+
Info `observation`, titled `Annotation contradicted: {name}`
|
|
84
|
+
`difference` "annotation-contradicted" (the documented return
|
|
85
|
+
type is the one the body returns, the declared annotation says
|
|
86
|
+
otherwise) whose facts check: the documented entry states a
|
|
87
|
+
return type (a non-blank `return_type`, or a `documented_sig`
|
|
88
|
+
that writes one); `documented_sig` and `source_sig` write the
|
|
89
|
+
same parameters as signature-comparison-rules.md reads them
|
|
90
|
+
(_parameter_difference, below); `evidence_line` comes after the
|
|
91
|
+
declaration line (of the name's surface records at the
|
|
92
|
+
mismatch's file, the closest at or before the mismatch's
|
|
93
|
+
`line`, else the first; with none, the mismatch's `line`) and
|
|
94
|
+
before the next surface record in that file; and, with
|
|
95
|
+
--source-root, that line of the file under it starts with
|
|
96
|
+
`return`, or is the declaration line itself with its
|
|
97
|
+
expression body after its parameters (an arrow function's
|
|
98
|
+
`=> expr`, a Python `def ...: return x`). The parameters are
|
|
99
|
+
read from each one's parameter group, past a decorator's, a Go
|
|
100
|
+
receiver's or a C++ `operator()`'s group (a qualifier such as
|
|
101
|
+
`noexcept(...)` after a group leaves it the parameter group),
|
|
102
|
+
and compared by name and in order, by name alone after a `*`
|
|
103
|
+
marker; by type wherever both write one, with the common
|
|
104
|
+
Equivalent spellings (_type_key), a documented type that names
|
|
105
|
+
some members of the source's union included, and, in a TS/JS
|
|
106
|
+
file, with a literal default's type for a source parameter
|
|
107
|
+
that writes none; by optionality when the documented list
|
|
108
|
+
states one for some member; and by the type parameters both
|
|
109
|
+
write before the group. A list of bare names need not write an
|
|
110
|
+
optional parameter (unless leaving it out shifts a caller's
|
|
111
|
+
positions in a POSITIONAL_EXTENSIONS file), and one holding an
|
|
112
|
+
ellipsis claims nothing about what it stands for. A keyword or
|
|
113
|
+
name before the group, a default's value, a `*` or `/` marker,
|
|
114
|
+
the names of `*args` and `**kwargs`, and a leading receiver the
|
|
115
|
+
source writes (`self`, `cls`, `&self`, `this: T`) are never
|
|
116
|
+
compared; the documented list's own receiver is dropped only
|
|
117
|
+
when the source's list leads with one too, so a module-level
|
|
118
|
+
function whose first parameter is named `self` or `cls` is read
|
|
119
|
+
as a method. A signature with no group, on either side, is
|
|
120
|
+
compared by the text before its return. Still refused, on the
|
|
121
|
+
strict side, where the rules would read the two as one: type
|
|
122
|
+
variables renamed consistently, a type alias against the type
|
|
123
|
+
it names, an overload set, a union of callables against one
|
|
124
|
+
callable returning the union, a call example's `name=value`
|
|
125
|
+
(read as a default, so its list claims to be complete) or
|
|
126
|
+
positional literal (`RecursiveSplitter(512)`, read as a name),
|
|
127
|
+
a destructured parameter (compared as one name, so the order of
|
|
128
|
+
its properties and the ones it leaves out count), a parameter
|
|
129
|
+
in another language's order (Go's `id string`, C's `int n`,
|
|
130
|
+
read as one name), and a `;`-joined text, read as written. Its
|
|
131
|
+
remediation changes nothing in the skill: the annotation is
|
|
132
|
+
reported upstream. A tagged mismatch whose facts do not check is
|
|
133
|
+
graded as an untagged one, and `warnings` names the fact that
|
|
134
|
+
failed
|
|
135
|
+
Critical `signature-mismatch` every other mismatch
|
|
136
|
+
A mismatch's `file` (normalized: `/` separated, no `./`) is the file
|
|
137
|
+
it is graded and recorded at (the gap's Source) when the plan
|
|
138
|
+
offered that file for the name: the file of its check or one of the
|
|
139
|
+
check's candidates, read from --plan, else every file the surface
|
|
140
|
+
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. Results
|
|
142
|
+
that report one declaration (name, file and line) more than once
|
|
143
|
+
keep its most severe grade: Critical, then Low, then Info. Of the
|
|
144
|
+
mismatches of one form of a check that offered several files, the
|
|
145
|
+
least severe stands (the closest candidate), the first by file and
|
|
146
|
+
line on a tie: one documented form, one gap. Each form's mismatch
|
|
147
|
+
stands on its own (each usage of a `usage_signature` list too: the
|
|
148
|
+
mismatch's `documented_sig` is part of the declaration's key), and
|
|
149
|
+
a gap of a name with several forms names its form, counted from 1,
|
|
150
|
+
in its title (`Signature mismatch: {name} (form n of m)`) and its
|
|
151
|
+
Issue. For such a name the form's own label lowers a grade only when
|
|
152
|
+
the mismatch's `documented_sig` is that form's text (whitespace runs
|
|
153
|
+
as one, outer parentheses off, a list joined): otherwise `warnings`
|
|
154
|
+
says so and the mismatch is graded against the form it quotes, or
|
|
155
|
+
`documented` when it quotes none. A Low or an Info mismatch still
|
|
156
|
+
counts as matching. A mismatch a result reports for any
|
|
157
|
+
other name (one the skill does not document, or the surface lacks)
|
|
158
|
+
is listed in `warnings` instead, and no gap.
|
|
46
159
|
|
|
47
160
|
Inputs, all files in the run folder:
|
|
48
161
|
--inventory the validated inventory validate-inventory.py wrote (--output)
|
|
@@ -58,29 +171,63 @@ Inputs, all files in the run folder:
|
|
|
58
171
|
"exports_found": ["..."], (fallback scan only)
|
|
59
172
|
"types_found": ["..."], (fallback scan only)
|
|
60
173
|
"signature_mismatches": [
|
|
61
|
-
{"name", "line", "source_sig", "documented_sig", "issue"
|
|
174
|
+
{"name", "line", "source_sig", "documented_sig", "issue",
|
|
175
|
+
"difference", "file", "evidence_line", "form"}]}
|
|
176
|
+
(difference optional: one of
|
|
177
|
+
DIFFERENCES, absent or null for none;
|
|
178
|
+
file optional: the candidate the
|
|
179
|
+
mismatch was found at, a non-empty
|
|
180
|
+
string, absent or null for the
|
|
181
|
+
result's `file`; evidence_line, the
|
|
182
|
+
body line that returns the documented
|
|
183
|
+
type, an integer from 1, required
|
|
184
|
+
with "annotation-contradicted" and
|
|
185
|
+
refused with any other difference;
|
|
186
|
+
form optional: the index of the
|
|
187
|
+
documented form the mismatch is
|
|
188
|
+
reported for, an integer from 0
|
|
189
|
+
(`documented`; n the check's nth
|
|
190
|
+
`alternatives` entry), absent or null
|
|
191
|
+
for 0)
|
|
192
|
+
--skill-dir plan only: the skill package under test (the folder that
|
|
193
|
+
holds SKILL.md), whose citations pick the declaration of a
|
|
194
|
+
name the surface records in several files
|
|
195
|
+
--plan score only: the plan's output, whose checks name the files a
|
|
196
|
+
mismatch of each name may be recorded at
|
|
197
|
+
--source-root score only: the local source tree the judges read (a
|
|
198
|
+
drifted workspace included), where an annotation-contradicted
|
|
199
|
+
mismatch's `evidence_line` is checked in its file
|
|
62
200
|
|
|
63
201
|
Rounding is compute-score.py's (JS-compatible half-up, two decimals).
|
|
64
202
|
|
|
65
203
|
Usage:
|
|
66
|
-
uv run score-signatures.py plan --inventory <file> [--surface <file>] [--output <file>]
|
|
67
|
-
uv run score-signatures.py score --inventory <file> --surface <file> [--surface-set <set>] \\
|
|
68
|
-
--results <file> [--results <file> ...] [--output <file>] [--gaps-output <file>]
|
|
204
|
+
uv run score-signatures.py plan --inventory <file> [--surface <file>] [--skill-dir <dir>] [--output <file>]
|
|
205
|
+
uv run score-signatures.py score --inventory <file> --surface <file> [--surface-set <set>] [--plan <file>] \\
|
|
206
|
+
[--source-root <dir>] --results <file> [--results <file> ...] [--output <file>] [--gaps-output <file>]
|
|
69
207
|
|
|
70
208
|
Output (stdout; --output also writes it, --gaps-output writes the records):
|
|
71
209
|
plan: {"compared": N, "files": [{"file", "checks": [{"name", "line",
|
|
72
|
-
"signatureLine", "documented": {...}
|
|
210
|
+
"signatureLine", "documented": {...}, "alternatives": [{...}]
|
|
211
|
+
(only when the name has another form that lists members),
|
|
212
|
+
"candidates": [{"file", "line", "signatureLine"}] (only when the
|
|
213
|
+
citation does not settle the declaration)}]}],
|
|
214
|
+
"documentedSignatures": {...}, "documentedAlternatives": {name:
|
|
215
|
+
[{...}], for each name with alternatives}}
|
|
73
216
|
score: {"valid": true, "signatureAccuracy", "matchingSignatures",
|
|
74
217
|
"totalDocumented", "comparedNames": [the names compared],
|
|
75
218
|
"typeCoverage", "documentedTypes", "totalTypes",
|
|
76
|
-
"missingTypes": [...], "mismatches": [
|
|
219
|
+
"missingTypes": [...], "mismatches": [{"name", "file", "line",
|
|
220
|
+
"source_sig", "documented_sig", "issue", "difference" (null for
|
|
221
|
+
none), "evidence_line" (with "annotation-contradicted" alone),
|
|
222
|
+
"severity" (Critical, Low or Info)}], "gapRecords": [...],
|
|
77
223
|
"warnings": [...]}
|
|
78
224
|
or {"valid": false, "violations": [...]} when a result breaks the schema
|
|
79
225
|
|
|
80
226
|
Exit codes:
|
|
81
227
|
0 the result was printed
|
|
82
|
-
1 an input file cannot be read or is not the JSON it should be,
|
|
83
|
-
surface has no --surface-set set
|
|
228
|
+
1 an input file cannot be read or is not the JSON it should be, the
|
|
229
|
+
surface has no --surface-set set, the --skill-dir folder holds no
|
|
230
|
+
readable SKILL.md, or the --source-root folder does not exist
|
|
84
231
|
2 a subagent result breaks the schema (violations[] names each breach;
|
|
85
232
|
nothing is written to --output or --gaps-output)
|
|
86
233
|
"""
|
|
@@ -91,14 +238,83 @@ import argparse
|
|
|
91
238
|
import importlib.util
|
|
92
239
|
import json
|
|
93
240
|
import math
|
|
241
|
+
import re
|
|
94
242
|
import sys
|
|
95
|
-
from pathlib import Path
|
|
243
|
+
from pathlib import Path, PurePosixPath
|
|
96
244
|
|
|
97
245
|
# Surface kinds that count toward total_types. A Rust struct is a class and a
|
|
98
246
|
# trait an interface; `type` is a type alias.
|
|
99
247
|
TYPE_KINDS = frozenset({"interface", "type", "enum", "class", "struct", "trait"})
|
|
100
248
|
SIGNATURE_FIELDS = ("params", "return_type", "usage_signature")
|
|
101
249
|
MISMATCH_FIELDS = ("name", "line", "source_sig", "documented_sig", "issue")
|
|
250
|
+
# The optional `difference` a judge tags a mismatch with, defined in
|
|
251
|
+
# references/signature-comparison-rules.md (Difference tags): only the
|
|
252
|
+
# parameter names differ, only members the documented form leaves out that
|
|
253
|
+
# the source marks optional, or only a return annotation the body contradicts
|
|
254
|
+
# (the body returns the documented type at the mismatch's `evidence_line`).
|
|
255
|
+
DIFFERENCES = ("parameter-name", "omitted-optional", "annotation-contradicted")
|
|
256
|
+
CONTRADICTED = "annotation-contradicted"
|
|
257
|
+
# grade()'s default: an annotation-contradicted mismatch whose facts no one checked stays Critical.
|
|
258
|
+
UNCHECKED = "its facts were not checked"
|
|
259
|
+
# A body line that returns starts with the `return` keyword. The declaration
|
|
260
|
+
# line itself returns when its expression body follows its parameters: an
|
|
261
|
+
# arrow function assigned with `=` or `export default` (`=> expr`, not a
|
|
262
|
+
# `{` block), or a Python `def ...: return x`. A `type` alias's arrow is a type.
|
|
263
|
+
RETURN_START_RE = re.compile(r"return(?![\w$])")
|
|
264
|
+
ARROW_OPENER_RE = re.compile(r"(?:=|\bdefault)\s*(?:async\s*)?(?:<[^()]*>\s*)?\(")
|
|
265
|
+
ARROW_BODY_RE = re.compile(r"\s*(?::[^=]*)?=>\s*[^\s{]")
|
|
266
|
+
PYTHON_DEF_RE = re.compile(r"(?:async\s+)?def\s+\w+\s*\(")
|
|
267
|
+
PYTHON_BODY_RE = re.compile(r"\s*(?:->[^:]*)?:\s*return(?![\w$])")
|
|
268
|
+
TYPE_DECLARATION_RE = re.compile(r"(?:export\s+)?(?:declare\s+)?type\s")
|
|
269
|
+
# Where a signature's return type starts, with no parenthesized parameters.
|
|
270
|
+
RETURN_ARROW_RE = re.compile(r"->|=>|→")
|
|
271
|
+
# The prefixes a type is compared without, as signature-comparison-rules.md's Equivalent spellings reads a name
|
|
272
|
+
# with or without one: `typing.List[int]` is `List[int]`, `React.ReactNode` is `ReactNode`.
|
|
273
|
+
TYPE_PREFIX_RE = re.compile(r"(?<![\w.$])(?:typing|collections\.abc|React)\.")
|
|
274
|
+
# The `typing` names Equivalent spellings reads as the builtins they alias.
|
|
275
|
+
TYPING_ALIASES = {"List": "list", "Dict": "dict", "Tuple": "tuple", "Set": "set", "FrozenSet": "frozenset",
|
|
276
|
+
"Type": "type"}
|
|
277
|
+
# A quoted forward reference (`"Node"`), read as the type it quotes.
|
|
278
|
+
FORWARD_REFERENCE_RE = re.compile(r"(['\"])([A-Za-z_][^'\"]*)\1")
|
|
279
|
+
# The name a type starts with, before its type arguments (`dict[str, Any]`, `Promise<T>`).
|
|
280
|
+
TYPE_NAME_RE = re.compile(r"[A-Za-z_$][\w.$]*")
|
|
281
|
+
# A Rust lifetime's `'a`, followed by whitespace, `,`, `>`, `+`, `)` or the end (_lifetime says where it stands).
|
|
282
|
+
LIFETIME_RE = re.compile(r"'[A-Za-z_]\w*(?=[\s,>+)]|$)")
|
|
283
|
+
# Each closing bracket and the opening one it closes (_levels: a `>` closes only a `<`).
|
|
284
|
+
CLOSERS = {")": "(", "]": "[", "}": "{"}
|
|
285
|
+
OPENERS = {"(": ")", "[": "]", "{": "}", "<": ">"}
|
|
286
|
+
# A group a decorator or a Java annotation writes: the text before it ends with `@name`.
|
|
287
|
+
DECORATED_RE = re.compile(r"@[\w.$]+\s*$")
|
|
288
|
+
# What follows a prefix group: another `(` with no space between (C++ `operator()(int x)`), or a name and a `(`
|
|
289
|
+
# after whitespace (a Go receiver, `func (s *Server) Start(port int)`), the name no C or C++ qualifier that takes
|
|
290
|
+
# arguments after a parameter group (`f(int x) noexcept(true)`).
|
|
291
|
+
PREFIX_GROUP_RE = re.compile(r"\(|\s+(?!(?:noexcept|throw|__attribute__|requires|decltype)\b)[A-Za-z_$][\w$]*\s*\(")
|
|
292
|
+
# A receiver a method's parameter list may lead with: Python's `self` and `cls`, Rust's `self`, `&self`,
|
|
293
|
+
# `&mut self` and `mut self` (with a lifetime too, `&'a self`), and TS's `this: T` (_parameter_difference).
|
|
294
|
+
RECEIVER_RE = re.compile(r"self|cls|this|mut self|&(?:'\w+ )?(?:mut )?self")
|
|
295
|
+
# An ellipsis item, standing for parameters a form does not show (a TS/JS rest `...name` is none).
|
|
296
|
+
ELLIPSES = ("...", "…")
|
|
297
|
+
# The TS/JS source files, where a parameter with no type and a literal default has that literal's type.
|
|
298
|
+
TS_JS_EXTENSIONS = frozenset({".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"})
|
|
299
|
+
# A literal default's type (signature-comparison-rules.md, Annotations): a bigint, a number, a string or template
|
|
300
|
+
# literal, a boolean.
|
|
301
|
+
LITERAL_TYPES = (
|
|
302
|
+
(re.compile(r"[-+]?(?:0[xob][\da-f_]+|\d[\d_]*)n", re.I), "bigint"),
|
|
303
|
+
(re.compile(r"[-+]?(?:0[xob][\da-f_]+|(?:\d[\d_]*(?:\.[\d_]*)?|\.\d[\d_]*)(?:e[-+]?\d+)?)", re.I), "number"),
|
|
304
|
+
(re.compile(r"(['\"`]).*\1", re.S), "string"),
|
|
305
|
+
(re.compile(r"true|false"), "boolean"),
|
|
306
|
+
)
|
|
307
|
+
# Source files of the languages with no keyword arguments, where a caller
|
|
308
|
+
# never writes a parameter's name: TS/JS, Go, Rust, Java, C and C++. Not
|
|
309
|
+
# Python, which has keyword arguments (the script cannot see which
|
|
310
|
+
# parameters a caller must pass by position), nor Kotlin, C#, Swift or
|
|
311
|
+
# Scala, which name arguments at the call, nor a Vue component, whose
|
|
312
|
+
# documented parameters are props callers set by name.
|
|
313
|
+
POSITIONAL_EXTENSIONS = frozenset({".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".go", ".rs",
|
|
314
|
+
".java", ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp"})
|
|
315
|
+
# The grades of a mismatch, most severe first: one declaration reported
|
|
316
|
+
# twice keeps the most severe.
|
|
317
|
+
GRADES = ("Critical", "Low", "Info")
|
|
102
318
|
|
|
103
319
|
_INVENTORY_MODULE = None
|
|
104
320
|
|
|
@@ -169,6 +385,49 @@ def load_surface(path: str, surface_set: str) -> tuple[list[str], list[dict], li
|
|
|
169
385
|
return name_set(surface_set), [e for e in exports if isinstance(e, dict)], name_set("all")
|
|
170
386
|
|
|
171
387
|
|
|
388
|
+
def load_plan(path: str) -> dict[str, list[str]]:
|
|
389
|
+
"""{name: the files plan offered its check at, the planned one first,
|
|
390
|
+
then its candidates'} from plan's output."""
|
|
391
|
+
data = _read_json(path, "--plan")
|
|
392
|
+
groups = data.get("files") if isinstance(data, dict) else None
|
|
393
|
+
if not isinstance(groups, list):
|
|
394
|
+
raise InputError(f"--plan {path} is not score-signatures.py plan output (no `files` list)")
|
|
395
|
+
offered: dict[str, list[str]] = {}
|
|
396
|
+
for group in groups:
|
|
397
|
+
checks = group.get("checks") if isinstance(group, dict) else None
|
|
398
|
+
for check in checks if isinstance(checks, list) else []:
|
|
399
|
+
if not (isinstance(check, dict) and isinstance(check.get("name"), str)):
|
|
400
|
+
continue
|
|
401
|
+
files = [str(group.get("file") or "")]
|
|
402
|
+
candidates = check.get("candidates")
|
|
403
|
+
for candidate in candidates if isinstance(candidates, list) else []:
|
|
404
|
+
file = candidate.get("file") if isinstance(candidate, dict) else None
|
|
405
|
+
if isinstance(file, str) and file.strip() and file not in files:
|
|
406
|
+
files.append(file)
|
|
407
|
+
offered[check["name"]] = files
|
|
408
|
+
return offered
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def load_plan_forms(path: str) -> dict[str, list[dict]]:
|
|
412
|
+
"""{name: the documented forms plan offered it, `documented` first, then
|
|
413
|
+
its `alternatives`} from plan's output: its documentedSignatures and
|
|
414
|
+
documentedAlternatives maps (the fallback scan's), then each check's."""
|
|
415
|
+
data = _read_json(path, "--plan")
|
|
416
|
+
data = data if isinstance(data, dict) else {}
|
|
417
|
+
signatures, alternatives = data.get("documentedSignatures"), data.get("documentedAlternatives")
|
|
418
|
+
alternatives = alternatives if isinstance(alternatives, dict) else {}
|
|
419
|
+
pairs = [(n, d, alternatives.get(n)) for n, d in (signatures.items() if isinstance(signatures, dict) else [])]
|
|
420
|
+
for group in data.get("files") if isinstance(data.get("files"), list) else []:
|
|
421
|
+
checks = group.get("checks") if isinstance(group, dict) else None
|
|
422
|
+
pairs += [(c.get("name"), c.get("documented"), c.get("alternatives"))
|
|
423
|
+
for c in (checks if isinstance(checks, list) else []) if isinstance(c, dict)]
|
|
424
|
+
forms: dict[str, list[dict]] = {}
|
|
425
|
+
for name, documented, others in pairs:
|
|
426
|
+
if isinstance(name, str) and isinstance(documented, dict):
|
|
427
|
+
forms[name] = [documented, *(f for f in (others if isinstance(others, list) else []) if isinstance(f, dict))]
|
|
428
|
+
return forms
|
|
429
|
+
|
|
430
|
+
|
|
172
431
|
def check_result(item: object, where: str) -> list[str]:
|
|
173
432
|
"""The schema breaches of one per-file result object."""
|
|
174
433
|
if not isinstance(item, dict):
|
|
@@ -199,6 +458,25 @@ def check_result(item: object, where: str) -> list[str]:
|
|
|
199
458
|
for field in ("name", "source_sig", "documented_sig", "issue"):
|
|
200
459
|
if not (isinstance(entry[field], str) and entry[field].strip()):
|
|
201
460
|
errors.append(f"{at}: `{field}` must be a non-empty string")
|
|
461
|
+
if entry.get("difference") is not None and entry["difference"] not in DIFFERENCES:
|
|
462
|
+
errors.append(f"{at}: `difference` must be one of {', '.join(DIFFERENCES)} when present, "
|
|
463
|
+
f"not {json.dumps(entry['difference'])}")
|
|
464
|
+
# The body line that returns the documented type: the fact an annotation-contradicted mismatch rests on.
|
|
465
|
+
evidence = entry.get("evidence_line")
|
|
466
|
+
if entry.get("difference") == CONTRADICTED:
|
|
467
|
+
if isinstance(evidence, bool) or not isinstance(evidence, int) or evidence < 1:
|
|
468
|
+
errors.append(f"{at}: `evidence_line` must be a line number (an integer from 1) with `difference` "
|
|
469
|
+
f"{CONTRADICTED}, not {json.dumps(evidence)}")
|
|
470
|
+
elif evidence is not None:
|
|
471
|
+
errors.append(f"{at}: `evidence_line` is given only with `difference` {CONTRADICTED}, "
|
|
472
|
+
f"not {json.dumps(entry.get('difference'))}")
|
|
473
|
+
# The candidate a mismatch was found at, when the plan handed the judge several.
|
|
474
|
+
if entry.get("file") is not None and not (isinstance(entry["file"], str) and entry["file"].strip()):
|
|
475
|
+
errors.append(f"{at}: `file` must be a non-empty string when present, not {json.dumps(entry['file'])}")
|
|
476
|
+
# The documented form a mismatch is reported for, when the check offered `alternatives`.
|
|
477
|
+
form = entry.get("form")
|
|
478
|
+
if form is not None and (isinstance(form, bool) or not isinstance(form, int) or form < 0):
|
|
479
|
+
errors.append(f"{at}: `form` must be an index (an integer from 0) when present, not {json.dumps(form)}")
|
|
202
480
|
return errors
|
|
203
481
|
|
|
204
482
|
|
|
@@ -232,32 +510,128 @@ def load_results(paths: list[str]) -> tuple[list[dict], list[str]]:
|
|
|
232
510
|
|
|
233
511
|
|
|
234
512
|
def _has_signature(entry: dict) -> bool:
|
|
513
|
+
"""`params`, `return_type` or `usage_signature` as a list or a non-blank
|
|
514
|
+
string, or, for a kind the inventory contract gives `params`
|
|
515
|
+
(validate-inventory.py's SIGNATURE_KINDS), an empty `params` string: the
|
|
516
|
+
form of one that takes none."""
|
|
517
|
+
if entry.get("kind") in _inventory().SIGNATURE_KINDS and isinstance(entry.get("params"), str):
|
|
518
|
+
return True
|
|
235
519
|
return any(
|
|
236
520
|
isinstance(entry.get(f), list) or (isinstance(entry.get(f), str) and entry[f].strip())
|
|
237
521
|
for f in SIGNATURE_FIELDS
|
|
238
522
|
)
|
|
239
523
|
|
|
240
524
|
|
|
241
|
-
def
|
|
242
|
-
""
|
|
243
|
-
|
|
525
|
+
def _partial(fields: dict) -> bool:
|
|
526
|
+
return fields.get("signature_form") == "partial"
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _lists_params(fields: dict) -> bool:
|
|
530
|
+
params = fields.get("params")
|
|
531
|
+
return (isinstance(params, str) and bool(params.strip())) or (isinstance(params, list) and bool(params))
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _gives_usage(fields: dict) -> bool:
|
|
535
|
+
"""A non-blank `usage_signature` string, or a list holding one."""
|
|
536
|
+
usage = fields.get("usage_signature")
|
|
537
|
+
return any(isinstance(u, str) and bool(u.strip()) for u in (usage if isinstance(usage, list) else [usage]))
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _lists_members(fields: dict) -> bool:
|
|
541
|
+
"""A non-blank `params` string or non-empty list, or a non-blank
|
|
542
|
+
`usage_signature` string or a list holding one: an entry that says what
|
|
543
|
+
the name takes (a component's usage form names its props even when its
|
|
544
|
+
`params` is empty)."""
|
|
545
|
+
return _lists_params(fields) or _gives_usage(fields)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _rank(fields: dict) -> tuple[bool, bool, bool]:
|
|
549
|
+
"""How one of several entries of a name ranks, lowest compared first."""
|
|
550
|
+
return not _lists_members(fields), _partial(fields), not _lists_params(fields)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _form(entry: dict) -> dict:
|
|
554
|
+
"""The documented signature fields of one inventory entry, with its `signature_form` when it has one."""
|
|
555
|
+
fields = {f: entry[f] for f in SIGNATURE_FIELDS if f in entry}
|
|
556
|
+
if entry.get("signature_form") is not None:
|
|
557
|
+
fields["signature_form"] = entry["signature_form"]
|
|
558
|
+
return fields
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _field_text(value: object) -> str:
|
|
562
|
+
"""A signature field as one text: a list's items joined with `, `, whitespace runs as one space, "" for null."""
|
|
563
|
+
if value is None:
|
|
564
|
+
return ""
|
|
565
|
+
if isinstance(value, list):
|
|
566
|
+
value = ", ".join(v if isinstance(v, str) else json.dumps(v, sort_keys=True) for v in value)
|
|
567
|
+
return " ".join(value.split()) if isinstance(value, str) else json.dumps(value, sort_keys=True)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _form_key(fields: dict) -> tuple:
|
|
571
|
+
"""What a form says, for telling two entries apart (_field_text), a
|
|
572
|
+
null, blank or empty key read as absent, and the `signature_form` label
|
|
573
|
+
left out (documented_forms keeps the full one)."""
|
|
574
|
+
return tuple((f, _field_text(fields[f])) for f in SIGNATURE_FIELDS if f in fields and _field_text(fields[f]))
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def documented_forms(inventory: list[dict]) -> dict[str, list[dict]]:
|
|
578
|
+
"""{name: [its documented signature, *its alternatives]} for each
|
|
579
|
+
documented export with a signature: its distinct forms (_form), ranked.
|
|
580
|
+
Of several entries of one name, one that lists parameters or gives a
|
|
581
|
+
usage form (_lists_members) is compared before one that does neither
|
|
582
|
+
(empty or no `params` and no usage form: it never hides one that says
|
|
583
|
+
what the name takes), then a full one (no `signature_form`, or `full`)
|
|
584
|
+
before a partial one (a form the skill also gives in full is held to
|
|
585
|
+
it), then, of two full or two partial forms, one that lists parameters
|
|
586
|
+
is compared before one that gives only a usage form, else the first.
|
|
587
|
+
The first is the documented signature; the others that list members
|
|
588
|
+
follow it, in that order, as the alternatives the judge compares too (a
|
|
589
|
+
skill that documents a name briefly in one place and in full in
|
|
590
|
+
another). A form that lists no member is never an alternative. Two
|
|
591
|
+
entries that say the same thing (_form_key) are one form; of two that
|
|
592
|
+
differ only by the partial label, the full one (the stricter) stands."""
|
|
593
|
+
entries: dict[str, list[dict]] = {}
|
|
244
594
|
for entry in inventory:
|
|
245
595
|
name = entry.get("name")
|
|
246
|
-
if entry.get("kind") == "method" or not (isinstance(name, str) and name) or
|
|
596
|
+
if entry.get("kind") == "method" or not (isinstance(name, str) and name) or not _has_signature(entry):
|
|
247
597
|
continue
|
|
248
|
-
|
|
249
|
-
|
|
598
|
+
forms, fields = entries.setdefault(name, []), _form(entry)
|
|
599
|
+
same = next((i for i, f in enumerate(forms) if _form_key(f) == _form_key(fields)), None)
|
|
600
|
+
if same is None:
|
|
601
|
+
forms.append(fields)
|
|
602
|
+
elif _partial(forms[same]) and not _partial(fields):
|
|
603
|
+
# in the inventory order the full entry has, as the rank would have ordered it
|
|
604
|
+
del forms[same]
|
|
605
|
+
forms.append(fields)
|
|
606
|
+
out: dict[str, list[dict]] = {}
|
|
607
|
+
for name, forms in entries.items():
|
|
608
|
+
first, *others = sorted(forms, key=_rank) # stable: of forms that rank alike, inventory order
|
|
609
|
+
out[name] = [first, *(f for f in others if _lists_members(f))]
|
|
250
610
|
return out
|
|
251
611
|
|
|
252
612
|
|
|
613
|
+
def documented_signatures(inventory: list[dict]) -> dict[str, dict]:
|
|
614
|
+
"""{name: its documented signature fields}: the first of its
|
|
615
|
+
documented_forms, the one a check holds as `documented`."""
|
|
616
|
+
return {name: forms[0] for name, forms in documented_forms(inventory).items()}
|
|
617
|
+
|
|
618
|
+
|
|
253
619
|
def documented_names(inventory: list[dict]) -> set[str]:
|
|
254
620
|
return {e["name"] for e in inventory
|
|
255
621
|
if isinstance(e.get("name"), str) and e["name"] and e.get("kind") != "method"}
|
|
256
622
|
|
|
257
623
|
|
|
624
|
+
def _record_file(record: dict) -> str:
|
|
625
|
+
"""A surface record's file, "" for none or a blank one."""
|
|
626
|
+
file = str(record.get("file") or "")
|
|
627
|
+
return file if file.strip() else ""
|
|
628
|
+
|
|
629
|
+
|
|
258
630
|
def _records_by_name(exports: list[dict]) -> dict[str, list[dict]]:
|
|
631
|
+
"""{name: its surface records}, by file then line, a record with no file
|
|
632
|
+
last: a by-eye record never stands in for the file an extraction names."""
|
|
259
633
|
by_name: dict[str, list[dict]] = {}
|
|
260
|
-
for record in sorted(exports, key=lambda r: (
|
|
634
|
+
for record in sorted(exports, key=lambda r: (not _record_file(r), _record_file(r), r.get("line") or 0)):
|
|
261
635
|
name = record.get("name")
|
|
262
636
|
if isinstance(name, str) and name:
|
|
263
637
|
by_name.setdefault(name, []).append(record)
|
|
@@ -272,78 +646,829 @@ def signature_universe(names: list[str], exports: list[dict]) -> set[str]:
|
|
|
272
646
|
return set(names) | set(_records_by_name(exports))
|
|
273
647
|
|
|
274
648
|
|
|
275
|
-
def
|
|
649
|
+
def _path(file: str) -> str:
|
|
650
|
+
"""A path as a citation, a candidate and a surface record compare (validate-inventory.py's _cited_path)."""
|
|
651
|
+
return _inventory()._cited_path(file)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _check_at(name: str, records: list[dict], skill_dir: str | None,
|
|
655
|
+
texts: list[list[str]] | None) -> tuple[dict, list[dict] | None]:
|
|
656
|
+
"""(the surface record the check of `name` is planned at, the candidates
|
|
657
|
+
the judge reads, or None). One file among `records`: the first record.
|
|
658
|
+
Several: the record whose file the skill cites on a line that names
|
|
659
|
+
`name` when exactly one of them is cited (the first such record), else
|
|
660
|
+
the first record, with every record that names a file as a candidate.
|
|
661
|
+
`texts` is the skill's Markdown, read once (skill_markdown_lines)."""
|
|
662
|
+
first = records[0] if records else {}
|
|
663
|
+
filed = [r for r in records if _record_file(r)]
|
|
664
|
+
if len(filed) < 2 or len({_path(_record_file(r)) for r in filed}) < 2:
|
|
665
|
+
return first, None
|
|
666
|
+
if skill_dir is not None:
|
|
667
|
+
cited = {_path(_record_file(r)) for r in filed} & _inventory()._skill_cited_files(Path(skill_dir), name, texts)
|
|
668
|
+
if len(cited) == 1:
|
|
669
|
+
return next(r for r in filed if _path(_record_file(r)) in cited), None
|
|
670
|
+
return first, [{"file": _record_file(r), "line": r.get("line"), "signatureLine": r.get("signatureLine")}
|
|
671
|
+
for r in filed]
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def plan(inventory: list[dict], names: list[str] | None, exports: list[dict], skill_dir: str | None = None) -> dict:
|
|
276
675
|
"""The comparisons over the signature universe of `names` (the `all`
|
|
277
|
-
set) and `exports`; none without a surface (`names` None).
|
|
278
|
-
|
|
676
|
+
set) and `exports`; none without a surface (`names` None). `skill_dir`,
|
|
677
|
+
the skill package, settles which declaration a name the surface records
|
|
678
|
+
in several files is compared with (_check_at). A name documented in
|
|
679
|
+
several forms carries the others that list members as `alternatives`
|
|
680
|
+
(documented_forms)."""
|
|
681
|
+
forms = documented_forms(inventory)
|
|
279
682
|
files: dict[str, list[dict]] = {}
|
|
280
683
|
compared = 0
|
|
281
684
|
if names is not None:
|
|
282
685
|
by_name = _records_by_name(exports)
|
|
283
|
-
|
|
284
|
-
|
|
686
|
+
texts = _inventory().skill_markdown_lines(Path(skill_dir)) if skill_dir is not None else None
|
|
687
|
+
for name in sorted(signature_universe(names, exports) & set(forms)):
|
|
688
|
+
record, candidates = _check_at(name, by_name.get(name) or [], skill_dir, texts)
|
|
285
689
|
compared += 1
|
|
286
|
-
|
|
690
|
+
check = {
|
|
287
691
|
"name": name,
|
|
288
692
|
"line": record.get("line"),
|
|
289
693
|
"signatureLine": record.get("signatureLine"),
|
|
290
|
-
"documented":
|
|
291
|
-
}
|
|
694
|
+
"documented": forms[name][0],
|
|
695
|
+
}
|
|
696
|
+
if len(forms[name]) > 1:
|
|
697
|
+
check["alternatives"] = forms[name][1:]
|
|
698
|
+
if candidates:
|
|
699
|
+
check["candidates"] = candidates
|
|
700
|
+
files.setdefault(_record_file(record), []).append(check)
|
|
292
701
|
return {
|
|
293
702
|
"compared": compared,
|
|
294
703
|
"files": [{"file": f, "checks": checks} for f, checks in sorted(files.items())],
|
|
295
|
-
"documentedSignatures":
|
|
704
|
+
"documentedSignatures": documented_signatures(inventory),
|
|
705
|
+
# The fallback scan's alternatives: a name's form n is its nth entry here, as in a check's `alternatives`.
|
|
706
|
+
"documentedAlternatives": {name: fs[1:] for name, fs in forms.items() if len(fs) > 1},
|
|
296
707
|
}
|
|
297
708
|
|
|
298
709
|
|
|
710
|
+
def _extension(file: str) -> str:
|
|
711
|
+
"""The lower-case extension of a source path, `/` or `\\` separated."""
|
|
712
|
+
return PurePosixPath(file.replace("\\", "/")).suffix.lower()
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _offered_files(offered: dict[str, list[str]] | None, by_name: dict[str, list[dict]], name: str) -> list[str]:
|
|
716
|
+
"""The files a mismatch of `name` may be recorded at, the planned one
|
|
717
|
+
first: the plan's check of `name` (its file, then its candidates') when
|
|
718
|
+
`offered` (read from --plan) holds it, else every file the surface
|
|
719
|
+
records for `name` ("" for a record with none); [] for a name the
|
|
720
|
+
surface lacks (a fallback scan's exports_found name)."""
|
|
721
|
+
if offered is not None and name in offered:
|
|
722
|
+
return offered[name]
|
|
723
|
+
files: list[str] = []
|
|
724
|
+
for record in by_name.get(name) or []:
|
|
725
|
+
if _record_file(record) not in files:
|
|
726
|
+
files.append(_record_file(record))
|
|
727
|
+
return files
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _is_offered_file(files: list[str], file: str) -> bool:
|
|
731
|
+
"""Whether `file` is one of `files` (_offered_files), however either writes its path."""
|
|
732
|
+
return _path(file) in {_path(f) for f in files}
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _is_line(value: object) -> bool:
|
|
736
|
+
return isinstance(value, int) and not isinstance(value, bool) and value >= 1
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _lines_in(exports: list[dict], file: str) -> list[int]:
|
|
740
|
+
"""The lines of the surface records in `file`, however either writes its path."""
|
|
741
|
+
return [r["line"] for r in exports
|
|
742
|
+
if _record_file(r) and _path(_record_file(r)) == _path(file) and _is_line(r.get("line"))]
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _declaration_line(by_name: dict[str, list[dict]], name: str, file: str, line: int) -> int:
|
|
746
|
+
"""The line `name` is declared at in `file`: of its surface records there
|
|
747
|
+
(the plan's check, or the candidate it offered), the closest one at or
|
|
748
|
+
before the mismatch's `line` (a name declared twice in one file), else
|
|
749
|
+
the first; with none there, the mismatch's own `line`."""
|
|
750
|
+
lines = _lines_in(by_name.get(name) or [], file)
|
|
751
|
+
before = [at for at in lines if at <= line]
|
|
752
|
+
return max(before) if before else min(lines, default=line)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _next_declaration(exports: list[dict], file: str, line: int) -> int | None:
|
|
756
|
+
"""The line of the first surface record in `file` after `line`, None when there is none."""
|
|
757
|
+
return min((at for at in _lines_in(exports, file) if at > line), default=None)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _source_lines(source_root: str, file: str) -> list[str] | None:
|
|
761
|
+
"""The lines of `file` under `source_root`, None when it is no readable
|
|
762
|
+
file there (a path that leaves the root included)."""
|
|
763
|
+
root = Path(source_root).resolve()
|
|
764
|
+
path = (root / file).resolve()
|
|
765
|
+
try:
|
|
766
|
+
path.relative_to(root)
|
|
767
|
+
return path.read_bytes().decode("utf-8-sig", errors="replace").splitlines()
|
|
768
|
+
except (ValueError, OSError):
|
|
769
|
+
return None
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _after_parens(text: str, start: int) -> int:
|
|
773
|
+
"""The index past the `)` that closes the `(` at `start`, -1 when none does."""
|
|
774
|
+
depth = 0
|
|
775
|
+
for i in range(start, len(text)):
|
|
776
|
+
depth += {"(": 1, ")": -1}.get(text[i], 0)
|
|
777
|
+
if depth == 0:
|
|
778
|
+
return i + 1
|
|
779
|
+
return -1
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _string_end(text: str, start: int) -> int:
|
|
783
|
+
"""The index past the string literal whose quote is at `start`: up to the same quote not escaped, the end of
|
|
784
|
+
`text` when none closes it."""
|
|
785
|
+
quote, i = text[start], start + 1
|
|
786
|
+
while i < len(text):
|
|
787
|
+
if text[i] == "\\":
|
|
788
|
+
i += 2
|
|
789
|
+
elif text[i] == quote:
|
|
790
|
+
return i + 1
|
|
791
|
+
else:
|
|
792
|
+
i += 1
|
|
793
|
+
return len(text)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _lifetime(text: str, i: int, in_angle: bool, extension: str) -> bool:
|
|
797
|
+
"""Whether the `'` at `i` starts a Rust lifetime: only in a `.rs` file, `'ident` followed by whitespace, `,`,
|
|
798
|
+
`>`, `+`, `)` or the end, coming after a `&` or a `+` (whitespace aside) or inside a `<...>` (`in_angle`), as
|
|
799
|
+
in `&'a str`, `impl Tr + 'a` and `<'a>`. Any other `'` opens a string, so a TS `Omit<P, 'a,b'>` holds one."""
|
|
800
|
+
return (extension == ".rs" and bool(LIFETIME_RE.match(text, i))
|
|
801
|
+
and (in_angle or text[:i].rstrip()[-1:] in ("&", "+")))
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _opens(text: str, i: int, extension: str) -> bool:
|
|
805
|
+
"""Whether the `<` at `i` opens a pair: it follows a letter, a digit, `_`, `$`, `.`, `]` or `>` with no
|
|
806
|
+
whitespace between (`Map<`, `f<T>`, `obj.get<T>`), and a `>` that is not part of `->` or `=>` closes it
|
|
807
|
+
before the bracket pair around it closes or the text ends. Any other `<` is an operator (`x < y`, `1 << 20`,
|
|
808
|
+
`x<3)`)."""
|
|
809
|
+
if i == 0 or not (text[i - 1].isalnum() or text[i - 1] in "_$.]>"):
|
|
810
|
+
return False
|
|
811
|
+
depth, j = 0, i + 1
|
|
812
|
+
while j < len(text):
|
|
813
|
+
ch = text[j]
|
|
814
|
+
if ch in "\"`" or (ch == "'" and not _lifetime(text, j, depth == 0, extension)):
|
|
815
|
+
j = _string_end(text, j)
|
|
816
|
+
continue
|
|
817
|
+
if ch in "([{":
|
|
818
|
+
depth += 1
|
|
819
|
+
elif ch in ")]}":
|
|
820
|
+
if depth == 0:
|
|
821
|
+
return False
|
|
822
|
+
depth -= 1
|
|
823
|
+
elif ch == ">" and depth == 0 and text[j - 1] not in "-=":
|
|
824
|
+
return True
|
|
825
|
+
j += 1
|
|
826
|
+
return False
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _levels(text: str, extension: str = "") -> list[int | None]:
|
|
830
|
+
"""For each character of `text`, in a file of `extension`, the number of bracket pairs open around it, None
|
|
831
|
+
inside a string literal (its quotes included). `(`, `[` and `{` open a pair, and a `<` opens one only when it
|
|
832
|
+
follows a name, a `.`, a `]` or a `>` with no whitespace between and a `>` that is not part of `->` or `=>`
|
|
833
|
+
closes it before the bracket pair around it closes or the text ends (_opens); any other `<` is an operator
|
|
834
|
+
and nests nothing, so `(path, size=1 << 20, mode)` holds three parameters,
|
|
835
|
+
`(a: boolean = x < y, b: Map<string, number>)` two, and `Map<string, number>` keeps its comma. An opening
|
|
836
|
+
bracket and the one that closes it stand at the level outside them. A closing bracket closes the pair it
|
|
837
|
+
matches and any `<` left open inside it, and a `>` closes only a `<`, never as part of `->` or `=>`. A `"`, a
|
|
838
|
+
backquote or a `'` opens a string (_string_end), except the `'` of a Rust lifetime in a `.rs` file
|
|
839
|
+
(_lifetime)."""
|
|
840
|
+
levels: list[int | None] = []
|
|
841
|
+
stack: list[str] = []
|
|
842
|
+
i = 0
|
|
843
|
+
while i < len(text):
|
|
844
|
+
ch = text[i]
|
|
845
|
+
if ch in "\"`" or (ch == "'" and not _lifetime(text, i, bool(stack) and stack[-1] == "<", extension)):
|
|
846
|
+
end = _string_end(text, i)
|
|
847
|
+
levels.extend([None] * (end - i))
|
|
848
|
+
i = end
|
|
849
|
+
continue
|
|
850
|
+
if ch in "([{" or (ch == "<" and _opens(text, i, extension)):
|
|
851
|
+
levels.append(len(stack))
|
|
852
|
+
stack.append(ch)
|
|
853
|
+
elif ch in CLOSERS and CLOSERS[ch] in stack:
|
|
854
|
+
while stack.pop() != CLOSERS[ch]:
|
|
855
|
+
pass
|
|
856
|
+
levels.append(len(stack))
|
|
857
|
+
elif ch == ">" and stack and stack[-1] == "<" and text[i - 1:i] not in ("-", "="):
|
|
858
|
+
stack.pop()
|
|
859
|
+
levels.append(len(stack))
|
|
860
|
+
else:
|
|
861
|
+
levels.append(len(stack))
|
|
862
|
+
i += 1
|
|
863
|
+
return levels
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _split(text: str, separator: str, extension: str = "") -> list[str]:
|
|
867
|
+
"""`text` cut at each `separator` outside every bracket pair and string (_levels)."""
|
|
868
|
+
levels = _levels(text, extension)
|
|
869
|
+
cuts = [-1, *(i for i, ch in enumerate(text) if ch == separator and levels[i] == 0), len(text)]
|
|
870
|
+
return [text[a + 1:b] for a, b in zip(cuts, cuts[1:])]
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _encloses(text: str, start: int, extension: str = "") -> bool:
|
|
874
|
+
"""Whether the bracket at `start` of `text` opens a pair that the last character of `text` closes."""
|
|
875
|
+
levels = _levels(text, extension)
|
|
876
|
+
return (OPENERS.get(text[start]) == text[-1] and levels[start] == 0 == levels[-1] and len(text) - start > 1
|
|
877
|
+
and all(level is None or level > 0 for level in levels[start + 1:-1]))
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _group(text: str, extension: str = "") -> tuple[int, int] | None:
|
|
881
|
+
"""The indices of the `(` and the `)` of the parameter group of `text`: the first pair of parentheses outside
|
|
882
|
+
every bracket pair and string (_levels), before any return arrow there, that is no prefix group. A prefix group
|
|
883
|
+
is one a decorator or a Java annotation writes (the text before it ends with `@name`:
|
|
884
|
+
`@lru_cache(maxsize=None) def f(x)`), one directly followed by another `(` (C++ `operator()(int x)`), or one
|
|
885
|
+
followed, after whitespace, by a name and a `(` (a Go receiver, `func (s *Server) Start(port int)`), unless
|
|
886
|
+
that name is a C or C++ qualifier (`noexcept`, `throw`, `__attribute__`, `requires`, `decltype`: `f(int x)
|
|
887
|
+
noexcept(true)` keeps its group). A `(` after a space is no prefix either (Go's result list,
|
|
888
|
+
`Start(port int) (int, error)`). So a keyword, a name or type parameters before the group are never part of
|
|
889
|
+
it. None when there is no such group: no `(` comes before the return arrow (`make -> X`), or a group does not
|
|
890
|
+
close."""
|
|
891
|
+
levels = _levels(text, extension)
|
|
892
|
+
i = 0
|
|
893
|
+
while i < len(text):
|
|
894
|
+
if levels[i] == 0 and RETURN_ARROW_RE.match(text, i):
|
|
895
|
+
return None
|
|
896
|
+
if text[i] == "(" and levels[i] == 0:
|
|
897
|
+
end = next((j for j in range(i + 1, len(text)) if text[j] == ")" and levels[j] == 0), None)
|
|
898
|
+
if end is None:
|
|
899
|
+
return None
|
|
900
|
+
if not (DECORATED_RE.search(text, 0, i) or PREFIX_GROUP_RE.match(text, end + 1)):
|
|
901
|
+
return i, end
|
|
902
|
+
i = end
|
|
903
|
+
i += 1
|
|
904
|
+
return None
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _signature_parts(sig: str, extension: str = "") -> tuple[str, str]:
|
|
908
|
+
"""(the text before the return, its whitespace runs read as one space; the return type it writes, "" for
|
|
909
|
+
none) of a signature, in a file of `extension`. The return follows the parameter group (_group, the one
|
|
910
|
+
_parameters reads), after a `->`, `=>`, `→` or `:`, or, with no group, the first such arrow. A `;` is read as
|
|
911
|
+
any other character."""
|
|
912
|
+
text = sig.strip()
|
|
913
|
+
group = _group(text, extension)
|
|
914
|
+
if group is None:
|
|
915
|
+
arrow = RETURN_ARROW_RE.search(text)
|
|
916
|
+
head, tail = (text[:arrow.start()], text[arrow.end():]) if arrow else (text, "")
|
|
917
|
+
else:
|
|
918
|
+
head, tail = text[:group[1] + 1], re.sub(r"^\s*(?:->|=>|→|:)", "", text[group[1] + 1:])
|
|
919
|
+
return " ".join(head.split()), tail.strip().rstrip("{:").strip()
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _compact(text: str) -> str:
|
|
923
|
+
"""`text` with its whitespace runs read as one space, and none beside a character that is no letter, digit
|
|
924
|
+
or `_`, so `{ title }` is `{title}` and `dict[str, Any]` is `dict[str,Any]`."""
|
|
925
|
+
return re.sub(r"\s*([^\w\s])\s*", r"\1", " ".join(text.split()))
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _type_parameters(sig: str, extension: str = "") -> str:
|
|
929
|
+
"""The type parameters a signature writes between its name and its parameter group (`<T extends X>`, or
|
|
930
|
+
Python's `[T: int]`), compacted and without a trailing comma; "" for none, or no group."""
|
|
931
|
+
text = sig.strip()
|
|
932
|
+
group = _group(text, extension)
|
|
933
|
+
if group is None:
|
|
934
|
+
return ""
|
|
935
|
+
levels = _levels(text, extension)
|
|
936
|
+
k = len(text[:group[0]].rstrip()) - 1
|
|
937
|
+
if k < 1 or text[k] not in ">]" or levels[k] is None:
|
|
938
|
+
return ""
|
|
939
|
+
j = next((j for j in range(k - 1, -1, -1) if levels[j] is not None and levels[j] <= levels[k]), None)
|
|
940
|
+
if j is None or OPENERS.get(text[j]) != text[k] or levels[j] != levels[k]:
|
|
941
|
+
return ""
|
|
942
|
+
return _compact(text[j + 1:k]).rstrip(",")
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _parameter(text: str, extension: str = "") -> dict:
|
|
946
|
+
"""One parameter as _parameters reads it, but for its "by_name" and "receiver": its name is the text before
|
|
947
|
+
the `:` of its type and the `=` of its default (each outside every bracket pair and string; the `=` of `==`,
|
|
948
|
+
`=>`, `<=`, `>=` or `!=` is no default), the whole text when it writes neither, so a parameter in another
|
|
949
|
+
language's order (Go's `id string`) or a destructured one (`{ a, b }`) is read as one name."""
|
|
950
|
+
levels = _levels(text, extension)
|
|
951
|
+
at = next((i for i, ch in enumerate(text) if ch == "=" and levels[i] == 0
|
|
952
|
+
and text[i + 1:i + 2] not in ("=", ">") and text[i - 1:i] not in ("=", "!", "<", ">")), None)
|
|
953
|
+
head = text if at is None else text[:at]
|
|
954
|
+
colon = next((i for i, ch in enumerate(head) if ch == ":" and levels[i] == 0), None)
|
|
955
|
+
name = _compact(head if colon is None else head[:colon])
|
|
956
|
+
optional = at is not None or name.endswith("?")
|
|
957
|
+
name = name.rstrip("?")
|
|
958
|
+
key = "**" if name.startswith("**") else "*" if name.startswith("*") else name
|
|
959
|
+
return {"key": key, "name": name, "type": "" if colon is None else _compact(head[colon + 1:]),
|
|
960
|
+
"default": None if at is None else text[at + 1:].strip(), "optional": optional,
|
|
961
|
+
"variadic": key in ("*", "**") or (name.startswith("...") and name not in ELLIPSES),
|
|
962
|
+
"ellipsis": name in ELLIPSES and colon is None and at is None}
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _parameters(sig: str, extension: str = "") -> list[dict] | None:
|
|
966
|
+
"""The parameters the group of `sig` writes (_group), in a file of `extension`, None when it has no group:
|
|
967
|
+
the group split at its commas outside every bracket pair and string (_levels), so a comma inside a nested
|
|
968
|
+
type, a callback's parameters or a default's string splits nothing, without a bare `*` or `/` marker. Each is
|
|
969
|
+
{"key": its name (_parameter), `*` for any `*args` and `**` for any `**kwargs`, whose names are never
|
|
970
|
+
compared; "name": its name as written; "type": the type it writes, compacted, "" for none; "default": the text
|
|
971
|
+
of its default, None for none; "optional": whether a default or a `?` on its name marks it optional;
|
|
972
|
+
"variadic": whether it is a `*args`, a `**kwargs` or a TS/JS rest `...name`; "ellipsis": whether it is an
|
|
973
|
+
ellipsis item (`...`, `…`) standing for members it does not show; "by_name": whether it comes after a `*`
|
|
974
|
+
marker, a bare `*` or a `*args`; "receiver": whether it is the group's first item and a receiver
|
|
975
|
+
(RECEIVER_RE: `self`, `cls`, `&self`, `&mut self`, `mut self`, `this: T`), which _parameter_difference
|
|
976
|
+
drops}. A `sig` that holds a `;` outside every bracket pair and string (shared text before a name's own
|
|
977
|
+
members, an overload set or a usage list joined by `;`) has none either, so it is compared as text, read as
|
|
978
|
+
written: its first group may be a call inside the shared text."""
|
|
979
|
+
text = sig.strip()
|
|
980
|
+
group = _group(text, extension)
|
|
981
|
+
if group is None or len(_split(text, ";", extension)) > 1:
|
|
982
|
+
return None
|
|
983
|
+
params: list[dict] = []
|
|
984
|
+
by_name = False
|
|
985
|
+
for index, item in enumerate(_split(text[group[0] + 1:group[1]], ",", extension)):
|
|
986
|
+
item = item.strip()
|
|
987
|
+
if item in ("", "/"):
|
|
988
|
+
continue
|
|
989
|
+
if item == "*":
|
|
990
|
+
by_name = True
|
|
991
|
+
continue
|
|
992
|
+
param = _parameter(item, extension)
|
|
993
|
+
param.update(by_name=by_name, receiver=index == 0 and bool(RECEIVER_RE.fullmatch(param["name"])))
|
|
994
|
+
by_name = by_name or param["key"] == "*"
|
|
995
|
+
params.append(param)
|
|
996
|
+
return params
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _type_key(text: str, extension: str = "") -> frozenset:
|
|
1000
|
+
"""A written type, in a file of `extension`, as signature-comparison-rules.md's Equivalent spellings commonly
|
|
1001
|
+
reads it: the set of its union's members (cut at each `|` outside every bracket pair and string), each (its
|
|
1002
|
+
base name, the keys of its type arguments, None when it writes none), so `Optional[X]`, `Union[X, None]` and
|
|
1003
|
+
`X | None` are one key, and so are `Union[A, B]`, `A | B` and `B | A`. A `typing.`, `collections.abc.` or
|
|
1004
|
+
`React.` prefix is dropped, `typing`'s `List`, `Dict`, `Tuple`, `Set`, `FrozenSet` and `Type` are the
|
|
1005
|
+
builtins they alias, a quoted forward reference is the type it quotes outside a TS/JS file (where a quoted
|
|
1006
|
+
type is a string literal type), `Array<T>` is `T[]`, `ReadonlyArray<T>` is `readonly T[]`, and a
|
|
1007
|
+
parenthesized type is the type inside. A type it reads no other way (a callable's arrow, a literal) is one
|
|
1008
|
+
member, its compacted text. Type variables renamed consistently, a type alias against the type it names and a
|
|
1009
|
+
union of callables against one callable returning the union are different keys: the script cannot see them
|
|
1010
|
+
as one."""
|
|
1011
|
+
members: set = set()
|
|
1012
|
+
for part in _split(TYPE_PREFIX_RE.sub("", _compact(text)), "|", extension):
|
|
1013
|
+
members |= _member_keys(part.strip(), extension)
|
|
1014
|
+
return frozenset(members)
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _member_keys(text: str, extension: str = "") -> frozenset:
|
|
1018
|
+
"""The union members (_type_key) one part of a type with no `|` outside brackets stands for."""
|
|
1019
|
+
quoted = FORWARD_REFERENCE_RE.fullmatch(text) if extension not in TS_JS_EXTENSIONS else None
|
|
1020
|
+
if quoted:
|
|
1021
|
+
return _type_key(quoted.group(2), extension)
|
|
1022
|
+
if text.startswith("(") and _encloses(text, 0, extension):
|
|
1023
|
+
return _type_key(text[1:-1], extension)
|
|
1024
|
+
if text.endswith("[]") and len(text) > 2:
|
|
1025
|
+
inner = text[:-2].strip()
|
|
1026
|
+
if inner.startswith("readonly "):
|
|
1027
|
+
return frozenset({("ReadonlyArray", (_type_key(inner[len("readonly "):], extension),))})
|
|
1028
|
+
return frozenset({("Array", (_type_key(inner, extension),))})
|
|
1029
|
+
name = TYPE_NAME_RE.match(text)
|
|
1030
|
+
if name and name.end() == len(text):
|
|
1031
|
+
return frozenset({(TYPING_ALIASES.get(text, text), None)})
|
|
1032
|
+
if name and text[name.end()] in "[<" and _encloses(text, name.end(), extension):
|
|
1033
|
+
args = tuple(_type_key(arg, extension) for arg in _split(text[name.end() + 1:-1], ",", extension)
|
|
1034
|
+
if arg.strip())
|
|
1035
|
+
base = TYPING_ALIASES.get(name.group(), name.group())
|
|
1036
|
+
if base == "Optional" and len(args) == 1:
|
|
1037
|
+
return args[0] | {("None", None)}
|
|
1038
|
+
if base == "Union" and args:
|
|
1039
|
+
return frozenset().union(*args)
|
|
1040
|
+
return frozenset({(base, args)})
|
|
1041
|
+
return frozenset({(text, None)})
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _members_match(documented: tuple, source: tuple) -> bool:
|
|
1045
|
+
"""Whether two union members (_type_key) are one type: one base name and, unless either side leaves its type
|
|
1046
|
+
arguments out (`dict` against `dict[str, Any]`, which compares the base alone), as many arguments, each pair
|
|
1047
|
+
one type (_types_match)."""
|
|
1048
|
+
(base, args), (other_base, other_args) = documented, source
|
|
1049
|
+
if base != other_base:
|
|
1050
|
+
return False
|
|
1051
|
+
if args is None or other_args is None:
|
|
1052
|
+
return True
|
|
1053
|
+
return len(args) == len(other_args) and all(_types_match(a, b) for a, b in zip(args, other_args))
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _types_match(documented: frozenset, source: frozenset, subset: bool = False) -> bool:
|
|
1057
|
+
"""Whether every documented member is a source member (_members_match) and, unless `subset`, every source
|
|
1058
|
+
member a documented one. A parameter's own type is compared with `subset`: a documented type that names some
|
|
1059
|
+
of the members of the source's union is no difference, one that names a member the source lacks is."""
|
|
1060
|
+
if not all(any(_members_match(d, s) for s in source) for d in documented):
|
|
1061
|
+
return False
|
|
1062
|
+
return subset or all(any(_members_match(d, s) for d in documented) for s in source)
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def _literal_type(default: str | None) -> str:
|
|
1066
|
+
"""The type of a TS/JS literal default (LITERAL_TYPES: `number`, `string`, `boolean` or `bigint`), "" for any
|
|
1067
|
+
other default."""
|
|
1068
|
+
if default is None:
|
|
1069
|
+
return ""
|
|
1070
|
+
return next((name for pattern, name in LITERAL_TYPES if pattern.fullmatch(default)), "")
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _count(n: int) -> str:
|
|
1074
|
+
return f"{n} parameter" if n == 1 else f"{n} parameters"
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def _pair_difference(documented: dict, source: dict, stated: bool, extension: str) -> str | None:
|
|
1078
|
+
"""Why a documented parameter differs from the source parameter it pairs with, None when it does not: its
|
|
1079
|
+
type, where both write one (_types_match with `subset`), or, when the documented list states optionality for
|
|
1080
|
+
any member (`stated`), its optionality, a rest or variadic member's aside."""
|
|
1081
|
+
if documented["type"] and source["type"] and not _types_match(
|
|
1082
|
+
_type_key(documented["type"], extension), _type_key(source["type"], extension), subset=True):
|
|
1083
|
+
return (f"`{documented['name']}` is typed `{documented['type']}` in the documented list, "
|
|
1084
|
+
f"`{source['type']}` in the source")
|
|
1085
|
+
if stated and not documented["variadic"] and documented["optional"] != source["optional"]:
|
|
1086
|
+
def word(optional: bool) -> str:
|
|
1087
|
+
return "optional" if optional else "required"
|
|
1088
|
+
return (f"`{documented['name']}` is {word(documented['optional'])} in the documented list, "
|
|
1089
|
+
f"{word(source['optional'])} in the source")
|
|
1090
|
+
return None
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _complete_difference(documented: list[dict], source: list[dict], stated: bool, extension: str) -> str | None:
|
|
1094
|
+
"""Why a documented list that claims to be complete (it writes a type, a default or a `?` for some member)
|
|
1095
|
+
differs from the source's, None when it does not: it must write as many parameters, paired in order up to the
|
|
1096
|
+
first that either list writes after a `*` marker and by name from there, each pair with one name and no
|
|
1097
|
+
_pair_difference."""
|
|
1098
|
+
if len(documented) != len(source):
|
|
1099
|
+
return f"the documented list writes {_count(len(documented))}, the source {_count(len(source))}"
|
|
1100
|
+
first = min(next((i for i, p in enumerate(params) if p["by_name"]), len(params)) for params in (documented, source))
|
|
1101
|
+
for n, (d, s) in enumerate(zip(documented[:first], source[:first]), 1):
|
|
1102
|
+
if d["key"] != s["key"]:
|
|
1103
|
+
return f"parameter {n} is `{d['name']}` in the documented list, `{s['name']}` in the source"
|
|
1104
|
+
reason = _pair_difference(d, s, stated, extension)
|
|
1105
|
+
if reason:
|
|
1106
|
+
return f"parameter {n}: {reason}"
|
|
1107
|
+
named = [{p["key"]: p for p in params[first:]} for params in (documented, source)]
|
|
1108
|
+
if any(len(keys) != len(documented) - first for keys in named):
|
|
1109
|
+
return "a parameter is written twice"
|
|
1110
|
+
for key, d in named[0].items():
|
|
1111
|
+
if key not in named[1]:
|
|
1112
|
+
return f"`{d['name']}`, written after a `*` marker, is not a source parameter"
|
|
1113
|
+
reason = _pair_difference(d, named[1][key], stated, extension)
|
|
1114
|
+
if reason:
|
|
1115
|
+
return reason
|
|
1116
|
+
return None
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _partial_difference(documented: list[dict], source: list[dict], stated: bool, bare: bool,
|
|
1120
|
+
extension: str) -> str | None:
|
|
1121
|
+
"""Why a documented list that does not claim to be complete differs from the source's, None when it does
|
|
1122
|
+
not: each member it writes must be a source parameter (`*args` and `**kwargs` by kind), in the source's order
|
|
1123
|
+
(a member after a `*` marker on either side by name alone), with no _pair_difference. A list of bare names
|
|
1124
|
+
(`bare`) must also leave out only optional source parameters (a default, a `?`, `*args`, `**kwargs`, a rest),
|
|
1125
|
+
and, in a file of a language without keyword arguments (POSITIONAL_EXTENSIONS), none before one it writes,
|
|
1126
|
+
which shifts a caller's positions. A list holding an ellipsis claims nothing about what it stands for."""
|
|
1127
|
+
used: list[int] = []
|
|
1128
|
+
last = -1
|
|
1129
|
+
for d in documented:
|
|
1130
|
+
at = next((i for i, s in enumerate(source) if i not in used and s["key"] == d["key"]), None)
|
|
1131
|
+
if at is None:
|
|
1132
|
+
return f"`{d['name']}` is not a source parameter"
|
|
1133
|
+
if not (d["by_name"] or source[at]["by_name"]):
|
|
1134
|
+
if at < last:
|
|
1135
|
+
return (f"`{d['name']}` comes before `{source[last]['name']}` in the source, after it in the "
|
|
1136
|
+
"documented list")
|
|
1137
|
+
last = at
|
|
1138
|
+
used.append(at)
|
|
1139
|
+
reason = _pair_difference(d, source[at], stated, extension)
|
|
1140
|
+
if reason:
|
|
1141
|
+
return reason
|
|
1142
|
+
if not bare:
|
|
1143
|
+
return None
|
|
1144
|
+
for i, s in enumerate(source):
|
|
1145
|
+
if i in used:
|
|
1146
|
+
continue
|
|
1147
|
+
if not (s["optional"] or s["variadic"]):
|
|
1148
|
+
return f"the required source parameter `{s['name']}` is left out"
|
|
1149
|
+
later = min((j for j in used if j > i), default=None)
|
|
1150
|
+
if extension in POSITIONAL_EXTENSIONS and later is not None:
|
|
1151
|
+
return (f"the optional `{s['name']}`, left out before `{source[later]['name']}`, shifts a caller's "
|
|
1152
|
+
f"positions in a `{extension}` file")
|
|
1153
|
+
return None
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def _parameter_difference(documented_sig: str, source_sig: str, extension: str = "") -> str | None:
|
|
1157
|
+
"""Why `documented_sig` and `source_sig` do not write the same parameters as signature-comparison-rules.md
|
|
1158
|
+
reads them in a file of `extension`, None when they do. When either has no parameter group (_parameters; a
|
|
1159
|
+
`;`-joined text has none), the texts before their returns (_signature_parts) are compared, and the reason says
|
|
1160
|
+
which side has none.
|
|
1161
|
+
Otherwise: the source's leading receiver is dropped, and the documented list's only when the source leads
|
|
1162
|
+
with one too, so a documented `(self, a)` against `def f(a)` differs; type parameters both write between the
|
|
1163
|
+
name and the group (_type_parameters) must be one text; in a TS/JS file (TS_JS_EXTENSIONS) a source parameter
|
|
1164
|
+
with no type and a literal default has that literal's type (_literal_type); a list of bare names (no type,
|
|
1165
|
+
default or `?` for any member) or one holding an ellipsis need not write every parameter
|
|
1166
|
+
(_partial_difference), and any other list claims to be complete (_complete_difference). A keyword or name
|
|
1167
|
+
before the group, a type only one side writes, a default's value, a `*` or `/` marker and the names of `*args`
|
|
1168
|
+
and `**kwargs` are never compared. The reason names the count, or the parameter (by position or by name) and
|
|
1169
|
+
the fact that differs: its name, its order, its type, its optionality, or the type parameters."""
|
|
1170
|
+
documented, source = _parameters(documented_sig, extension), _parameters(source_sig, extension)
|
|
1171
|
+
if documented is None or source is None:
|
|
1172
|
+
if _signature_parts(documented_sig, extension)[0] == _signature_parts(source_sig, extension)[0]:
|
|
1173
|
+
return None
|
|
1174
|
+
side = ("neither signature has a" if documented is None and source is None
|
|
1175
|
+
else "the documented signature has no" if documented is None else "the source signature has no")
|
|
1176
|
+
return f"{side} parameter group, so the texts before the return are compared"
|
|
1177
|
+
if source and source[0]["receiver"]:
|
|
1178
|
+
source = source[1:]
|
|
1179
|
+
if documented and documented[0]["receiver"]:
|
|
1180
|
+
documented = documented[1:]
|
|
1181
|
+
written, other = _type_parameters(documented_sig, extension), _type_parameters(source_sig, extension)
|
|
1182
|
+
if written and other and written != other:
|
|
1183
|
+
return f"the type parameters differ: documented `{written}`, source `{other}`"
|
|
1184
|
+
if extension in TS_JS_EXTENSIONS:
|
|
1185
|
+
source = [{**p, "type": _literal_type(p["default"])} if not p["type"] else p for p in source]
|
|
1186
|
+
stated = any(p["optional"] for p in documented if not p["variadic"])
|
|
1187
|
+
members = [p for p in documented if not p["ellipsis"]]
|
|
1188
|
+
bare = not any(p["type"] or p["optional"] for p in documented)
|
|
1189
|
+
if bare or len(members) < len(documented):
|
|
1190
|
+
return _partial_difference(members, source, stated, bare and len(members) == len(documented), extension)
|
|
1191
|
+
return _complete_difference(documented, source, stated, extension)
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def _returns(lines: list[str], number: int, declaration_line: int) -> bool:
|
|
1195
|
+
"""Whether line `number` (from 1) of `lines` returns: a line after the
|
|
1196
|
+
declaration whose text starts with the `return` keyword, or the
|
|
1197
|
+
declaration line itself when its own expression body follows its
|
|
1198
|
+
parameters there (an arrow function's `=> expr`, a Python
|
|
1199
|
+
`def ...: return x`). A type arrow, a callback parameter, a match arm, a
|
|
1200
|
+
string or a comment never does."""
|
|
1201
|
+
code = lines[number - 1].strip()
|
|
1202
|
+
if number != declaration_line:
|
|
1203
|
+
return bool(RETURN_START_RE.match(code))
|
|
1204
|
+
if TYPE_DECLARATION_RE.match(code):
|
|
1205
|
+
return False
|
|
1206
|
+
for opener, body in ((PYTHON_DEF_RE.match(code), PYTHON_BODY_RE), (ARROW_OPENER_RE.search(code), ARROW_BODY_RE)):
|
|
1207
|
+
end = _after_parens(code, opener.end() - 1) if opener else -1
|
|
1208
|
+
if end != -1 and body.match(code, end):
|
|
1209
|
+
return True
|
|
1210
|
+
return False
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def contradiction_fact(entry: dict, documented: dict, file: str, declaration_line: int,
|
|
1214
|
+
next_line: int | None = None, source_root: str | None = None) -> str | None:
|
|
1215
|
+
"""The first fact an annotation-contradicted mismatch rests on that does
|
|
1216
|
+
not check, None when each does: the documented entry states a return
|
|
1217
|
+
type (a non-blank `return_type`, or a `documented_sig` that writes one);
|
|
1218
|
+
`documented_sig` and `source_sig` write the same parameters, as the
|
|
1219
|
+
comparison rules read them in a file of `file`'s language
|
|
1220
|
+
(_parameter_difference, which compares the texts before the return when
|
|
1221
|
+
either has no parameter group); `evidence_line` comes
|
|
1222
|
+
after the declaration line and before `next_line`, the next surface
|
|
1223
|
+
record in the file; and, with `source_root`, that line of `file` under it
|
|
1224
|
+
returns (_returns), which alone lets the declaration line itself stand.
|
|
1225
|
+
A failed parameter fact quotes the text before each one's return
|
|
1226
|
+
(_signature_parts), then says why the two differ."""
|
|
1227
|
+
evidence = entry.get("evidence_line")
|
|
1228
|
+
extension = _extension(file)
|
|
1229
|
+
documented_params, documented_return = _signature_parts(entry["documented_sig"], extension)
|
|
1230
|
+
source_params, _ = _signature_parts(entry["source_sig"], extension)
|
|
1231
|
+
return_type = documented.get("return_type")
|
|
1232
|
+
if not (isinstance(return_type, str) and return_type.strip()) and not documented_return:
|
|
1233
|
+
return "the documented signature states no return type"
|
|
1234
|
+
difference = _parameter_difference(entry["documented_sig"], entry["source_sig"], extension)
|
|
1235
|
+
if difference is not None:
|
|
1236
|
+
return f"its parameters differ: documented `{documented_params}`, source `{source_params}`: {difference}"
|
|
1237
|
+
if not _is_line(evidence) or evidence < declaration_line:
|
|
1238
|
+
return f"evidence_line {json.dumps(evidence)} is before the declaration"
|
|
1239
|
+
if next_line is not None and evidence >= next_line:
|
|
1240
|
+
return f"evidence_line {evidence} is not before the next declaration in the file, at line {next_line}"
|
|
1241
|
+
if source_root is None:
|
|
1242
|
+
return None if evidence > declaration_line else (
|
|
1243
|
+
f"evidence_line {evidence} is the declaration line, which only --source-root can check")
|
|
1244
|
+
lines = _source_lines(source_root, file)
|
|
1245
|
+
if lines is None:
|
|
1246
|
+
return f"evidence_line {evidence} is in no readable file under --source-root"
|
|
1247
|
+
if evidence > len(lines):
|
|
1248
|
+
return f"evidence_line {evidence} is past the end of the file ({len(lines)} lines)"
|
|
1249
|
+
if not _returns(lines, evidence, declaration_line):
|
|
1250
|
+
if evidence == declaration_line:
|
|
1251
|
+
return f"evidence_line {evidence}, the declaration line, has no expression body after its parameters"
|
|
1252
|
+
return f"evidence_line {evidence} does not start with `return`"
|
|
1253
|
+
return None
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
def grade(difference: str | None, file: str, documented: dict, declared: str | None = None,
|
|
1257
|
+
contradiction: str | None = UNCHECKED) -> str:
|
|
1258
|
+
"""The severity of one mismatch the result reports at `file`. The judge's
|
|
1259
|
+
`difference` lowers it only where the script confirms the fact it rests
|
|
1260
|
+
on: a parameter-name difference is Low when `declared`, the file the
|
|
1261
|
+
plan offered the check at (else `file`), is the mismatch's file and of a
|
|
1262
|
+
language with no keyword arguments; an omitted-optional difference on a
|
|
1263
|
+
form the inventory marks partial is Info; an annotation-contradicted
|
|
1264
|
+
difference is Info when `contradiction`, the fact contradiction_fact
|
|
1265
|
+
found failing, is None; every other mismatch is Critical."""
|
|
1266
|
+
if difference == "parameter-name":
|
|
1267
|
+
at = file if declared is None else declared
|
|
1268
|
+
if at.replace("\\", "/") == file.replace("\\", "/") and _extension(at) in POSITIONAL_EXTENSIONS:
|
|
1269
|
+
return "Low"
|
|
1270
|
+
if difference == "omitted-optional" and _partial(documented):
|
|
1271
|
+
return "Info"
|
|
1272
|
+
if difference == CONTRADICTED and contradiction is None:
|
|
1273
|
+
return "Info"
|
|
1274
|
+
return "Critical"
|
|
1275
|
+
|
|
1276
|
+
|
|
299
1277
|
def _gap_record(m: dict) -> dict:
|
|
300
1278
|
name, where = m["name"], f"{m['file']}:{m['line']}"
|
|
1279
|
+
issue = f"documented `{m['documented_sig']}`, source `{m['source_sig']}`: {m['issue']}"
|
|
1280
|
+
remediation = (f"Update the documented signature of `{name}` from `{m['documented_sig']}` "
|
|
1281
|
+
f"to `{m['source_sig']}`, the signature the source defines at `{where}`.")
|
|
1282
|
+
if m["severity"] == "Info" and m["difference"] == CONTRADICTED:
|
|
1283
|
+
body = f"{m['file']}:{m['evidence_line']}"
|
|
1284
|
+
return {
|
|
1285
|
+
"severity": "Info",
|
|
1286
|
+
"category": "observation",
|
|
1287
|
+
"title": f"Annotation contradicted: {name}",
|
|
1288
|
+
"source": where,
|
|
1289
|
+
"export": name,
|
|
1290
|
+
"issue": (f"the source annotates the return of `{name}` otherwise than the skill documents, and its "
|
|
1291
|
+
f"body returns the documented type at `{body}`: {issue}"),
|
|
1292
|
+
"remediation": (f"No change to the skill: it documents what `{name}` returns, as `{body}` shows. "
|
|
1293
|
+
f"Report the return annotation at `{where}` upstream, since the body contradicts it."),
|
|
1294
|
+
}
|
|
1295
|
+
if m["severity"] == "Info":
|
|
1296
|
+
return {
|
|
1297
|
+
"severity": "Info",
|
|
1298
|
+
"category": "observation",
|
|
1299
|
+
"title": f"Partial signature: {name}",
|
|
1300
|
+
"source": where,
|
|
1301
|
+
"export": name,
|
|
1302
|
+
"issue": (f"the skill labels its documented form of `{name}` partial, and it leaves out only "
|
|
1303
|
+
f"members the source marks optional: {issue}"),
|
|
1304
|
+
"remediation": (f"No change: the documented form of `{name}` is labelled partial and leaves out "
|
|
1305
|
+
f"only optional members of the signature the source defines at `{where}`."),
|
|
1306
|
+
}
|
|
1307
|
+
if m["severity"] == "Low":
|
|
1308
|
+
issue = (f"{issue} (only the parameter names differ, and a caller of a `{_extension(m['file'])}` "
|
|
1309
|
+
"file passes its arguments by position)")
|
|
301
1310
|
return {
|
|
302
|
-
"severity": "
|
|
1311
|
+
"severity": m["severity"],
|
|
303
1312
|
"category": "signature-mismatch",
|
|
304
1313
|
"title": f"Signature mismatch: {name}",
|
|
305
1314
|
"source": where,
|
|
306
1315
|
"export": name,
|
|
307
|
-
"issue":
|
|
308
|
-
"remediation":
|
|
309
|
-
f"to `{m['source_sig']}`, the signature the source defines at `{where}`."),
|
|
1316
|
+
"issue": issue,
|
|
1317
|
+
"remediation": remediation,
|
|
310
1318
|
}
|
|
311
1319
|
|
|
312
1320
|
|
|
1321
|
+
def _form_text(form: dict) -> str:
|
|
1322
|
+
"""A documented form on one line: its usage form, else its parameters and return type."""
|
|
1323
|
+
def text(value: object, sep: str = ", ") -> str:
|
|
1324
|
+
return sep.join(str(v) for v in value) if isinstance(value, list) else str(value)
|
|
1325
|
+
if _gives_usage(form):
|
|
1326
|
+
return text(form["usage_signature"], "; ")
|
|
1327
|
+
return_type = form.get("return_type")
|
|
1328
|
+
tail = f" -> {text(return_type)}" if isinstance(return_type, (str, list)) and text(return_type).strip() else ""
|
|
1329
|
+
return f"({text(form.get('params', ''))}){tail}"
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
def _sig_text(text: str) -> str:
|
|
1333
|
+
"""A signature as a mismatch's `documented_sig` and a form compare: whitespace runs as one space, outer parentheses off."""
|
|
1334
|
+
text = " ".join(text.split())
|
|
1335
|
+
while text.startswith("(") and _after_parens(text, 0) == len(text):
|
|
1336
|
+
text = text[1:-1].strip()
|
|
1337
|
+
return text
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def _quotes(documented_sig: str, form: dict) -> bool:
|
|
1341
|
+
"""Whether `documented_sig` is the text of `form`: its parameters (a list joined), or one of its usages, whole
|
|
1342
|
+
or before the return type it writes."""
|
|
1343
|
+
usage = form.get("usage_signature")
|
|
1344
|
+
texts = {_sig_text(_field_text(form.get("params")))} | {
|
|
1345
|
+
_sig_text(u) for u in (usage if isinstance(usage, list) else [usage]) if isinstance(u, str)}
|
|
1346
|
+
texts.discard("")
|
|
1347
|
+
return bool({_sig_text(documented_sig), _sig_text(_signature_parts(documented_sig)[0])} & texts)
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
def _form_gap_record(m: dict, index: int, forms: list[dict]) -> dict:
|
|
1351
|
+
"""_gap_record's record. For a name with several forms its title and Issue name the documented form the mismatch
|
|
1352
|
+
concerns, counted from 1, so the ledger keeps one gap per form (its dedupe reads the title, never the Issue)."""
|
|
1353
|
+
record = _gap_record(m)
|
|
1354
|
+
if len(forms) > 1:
|
|
1355
|
+
record["title"] += f" (form {index + 1} of {len(forms)})"
|
|
1356
|
+
record["issue"] += f" (documented form {index + 1} of {len(forms)}: `{_form_text(forms[index])}`)"
|
|
1357
|
+
return record
|
|
1358
|
+
|
|
1359
|
+
|
|
313
1360
|
def score(inventory: list[dict], names: list[str], exports: list[dict], results: list[dict],
|
|
314
|
-
type_names: list[str] | None = None
|
|
1361
|
+
type_names: list[str] | None = None, offered: dict[str, list[str]] | None = None,
|
|
1362
|
+
source_root: str | None = None, planned: dict[str, list[dict]] | None = None) -> dict:
|
|
315
1363
|
"""The two categories: Signature Accuracy over the signature universe of
|
|
316
1364
|
`names` (the `all` set) and `exports`, Type Coverage over `type_names`
|
|
317
1365
|
when given, else over `names`. A fallback scan's exports_found join the
|
|
318
|
-
universe, and `names` when no `type_names` is given.
|
|
1366
|
+
universe, and `names` when no `type_names` is given. `offered` (read
|
|
1367
|
+
from --plan) names the files the plan offered each check at;
|
|
1368
|
+
`source_root` (--source-root) is the local tree an
|
|
1369
|
+
annotation-contradicted mismatch's `evidence_line` is checked in. A
|
|
1370
|
+
mismatch is graded against the documented form its `form` names: of
|
|
1371
|
+
documented_forms, or of `planned` (read from --plan) for a name it
|
|
1372
|
+
holds, with a warning where the two differ."""
|
|
319
1373
|
by_name = _records_by_name(exports)
|
|
320
1374
|
# A fallback scan reports its own names and types.
|
|
321
1375
|
fallback_types = {n for r in results for n in r.get("types_found") or []}
|
|
322
1376
|
surface = set(names) | {n for r in results for n in r.get("exports_found") or []}
|
|
323
1377
|
documented = documented_names(inventory)
|
|
324
|
-
|
|
325
|
-
compared = (surface | signature_universe(names, exports)) & set(
|
|
1378
|
+
forms = documented_forms(inventory)
|
|
1379
|
+
compared = (surface | signature_universe(names, exports)) & set(forms)
|
|
326
1380
|
warnings = []
|
|
327
|
-
|
|
328
|
-
|
|
1381
|
+
for name in sorted(set(forms) & set(planned or {})):
|
|
1382
|
+
if planned[name] != forms[name]:
|
|
1383
|
+
differ = (f"{len(planned[name])} documented form(s), not the {len(forms[name])} the inventory gives"
|
|
1384
|
+
if len(planned[name]) != len(forms[name]) else "different forms than the inventory gives")
|
|
1385
|
+
warnings.append(f"`{name}`: the plan offered {differ}: graded against the plan's")
|
|
1386
|
+
forms[name] = planned[name]
|
|
1387
|
+
|
|
1388
|
+
graded: dict[tuple, dict] = {}
|
|
329
1389
|
seen = set()
|
|
330
1390
|
for result in results:
|
|
331
1391
|
for entry in result["signature_mismatches"]:
|
|
332
|
-
|
|
333
|
-
|
|
1392
|
+
name = entry["name"]
|
|
1393
|
+
files = _offered_files(offered, by_name, name)
|
|
1394
|
+
# A mismatch found at one of a check's candidates names that file; any other file is not taken.
|
|
1395
|
+
file = result["file"]
|
|
1396
|
+
if entry.get("file") is not None:
|
|
1397
|
+
if _is_offered_file(files, entry["file"]):
|
|
1398
|
+
file = entry["file"]
|
|
1399
|
+
elif name in compared:
|
|
1400
|
+
warnings.append(f"{result['file']}:{entry['line']}: a signature mismatch for `{name}` names "
|
|
1401
|
+
f"`{entry['file']}`, no file the plan offered its check at: recorded at "
|
|
1402
|
+
"the result's file")
|
|
1403
|
+
file = _path(file)
|
|
1404
|
+
if name not in compared:
|
|
1405
|
+
if (name, file, entry["line"]) not in seen:
|
|
1406
|
+
warnings.append(f"{file}:{entry['line']}: a signature mismatch for "
|
|
1407
|
+
f"`{name}`, which is no documented signature on the source surface: "
|
|
1408
|
+
"not scored, no gap")
|
|
1409
|
+
seen.add((name, file, entry["line"]))
|
|
334
1410
|
continue
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
1411
|
+
# The documented form the mismatch is reported for: 0 is `documented`, n the check's nth alternative.
|
|
1412
|
+
index = entry.get("form") or 0
|
|
1413
|
+
if index >= len(forms[name]):
|
|
1414
|
+
warning = (f"{file}:{entry['line']}: a signature mismatch for `{name}` names `form` {index}, no "
|
|
1415
|
+
f"form its check offered (0 to {len(forms[name]) - 1}): graded against `documented`")
|
|
1416
|
+
if warning not in warnings:
|
|
1417
|
+
warnings.append(warning)
|
|
1418
|
+
index = 0
|
|
1419
|
+
# A form's own label lowers a grade only for the form the mismatch quotes as `documented_sig`.
|
|
1420
|
+
if len(forms[name]) > 1 and not _quotes(entry["documented_sig"], forms[name][index]):
|
|
1421
|
+
quoted = next((j for j, f in enumerate(forms[name]) if _quotes(entry["documented_sig"], f)), None)
|
|
1422
|
+
if quoted is not None or index != 0:
|
|
1423
|
+
target = "documented" if quoted is None else f"form {quoted}, the one it quotes"
|
|
1424
|
+
warning = (f"{file}:{entry['line']}: a signature mismatch for `{name}` names `form` {index}, whose "
|
|
1425
|
+
f"text its `documented_sig` is not: graded against {target}")
|
|
1426
|
+
if warning not in warnings:
|
|
1427
|
+
warnings.append(warning)
|
|
1428
|
+
index = quoted or 0
|
|
1429
|
+
form = forms[name][index]
|
|
1430
|
+
# Each usage of a list is a form of its own: one key per documented text.
|
|
1431
|
+
key = (name, file, entry["line"], index, _sig_text(entry["documented_sig"]))
|
|
1432
|
+
difference = entry.get("difference")
|
|
1433
|
+
# Graded at the file only when the plan offered it: never Low at a namesake it did not offer.
|
|
1434
|
+
declared = (file if _is_offered_file(files, file) else files[0]) if files else None
|
|
1435
|
+
# The facts of an annotation-contradicted mismatch, checked once and handed to grade().
|
|
1436
|
+
fact = None
|
|
1437
|
+
if difference == CONTRADICTED:
|
|
1438
|
+
at_line = _declaration_line(by_name, name, file, entry["line"])
|
|
1439
|
+
fact = contradiction_fact(entry, form, file, at_line,
|
|
1440
|
+
_next_declaration(exports, file, at_line), source_root)
|
|
1441
|
+
warning = (f"{file}:{at_line}: the mismatch for `{name}` is tagged {CONTRADICTED}, but {fact}: "
|
|
1442
|
+
"graded as an untagged mismatch (Critical)")
|
|
1443
|
+
if fact is not None and warning not in warnings:
|
|
1444
|
+
warnings.append(warning)
|
|
1445
|
+
severity = grade(difference, file, form, declared, fact)
|
|
1446
|
+
# Every entry is graded: one declaration reported twice against one form keeps its most severe grade.
|
|
1447
|
+
if key in graded and GRADES.index(graded[key]["severity"]) <= GRADES.index(severity):
|
|
340
1448
|
continue
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
1449
|
+
graded[key] = {"name": name, "file": file, "line": entry["line"],
|
|
1450
|
+
"source_sig": entry["source_sig"], "documented_sig": entry["documented_sig"],
|
|
1451
|
+
"issue": entry["issue"], "difference": difference, "severity": severity}
|
|
1452
|
+
if difference == CONTRADICTED:
|
|
1453
|
+
graded[key]["evidence_line"] = entry.get("evidence_line")
|
|
1454
|
+
# A check that offered several files keeps one mismatch per form, its least severe (the closest candidate).
|
|
1455
|
+
# Each form's mismatch stands on its own.
|
|
1456
|
+
def order(item: tuple) -> tuple:
|
|
1457
|
+
(_, _, _, index, text), m = item
|
|
1458
|
+
return m["file"], m["line"], m["name"], index, text
|
|
1459
|
+
|
|
1460
|
+
kept, closest = [], {}
|
|
1461
|
+
for key, m in sorted(graded.items(), key=order):
|
|
1462
|
+
bucket = (m["name"], key[3], key[4])
|
|
1463
|
+
if len({_path(f) for f in _offered_files(offered, by_name, m["name"])}) < 2:
|
|
1464
|
+
kept.append((key, m))
|
|
1465
|
+
elif bucket not in closest or GRADES.index(m["severity"]) > GRADES.index(closest[bucket][1]["severity"]):
|
|
1466
|
+
closest[bucket] = (key, m)
|
|
1467
|
+
kept = sorted(kept + list(closest.values()), key=order)
|
|
1468
|
+
mismatches = [m for _, m in kept]
|
|
1469
|
+
|
|
1470
|
+
# A Low or an Info mismatch is a caller-invisible difference: it still matches.
|
|
1471
|
+
wrong = {m["name"] for m in mismatches if m["severity"] == "Critical"}
|
|
347
1472
|
if compared:
|
|
348
1473
|
signature_accuracy = round2((len(compared) - len(wrong)) / len(compared) * 100)
|
|
349
1474
|
else:
|
|
@@ -376,7 +1501,7 @@ def score(inventory: list[dict], names: list[str], exports: list[dict], results:
|
|
|
376
1501
|
"totalTypes": len(types),
|
|
377
1502
|
"missingTypes": sorted(types - documented),
|
|
378
1503
|
"mismatches": mismatches,
|
|
379
|
-
"gapRecords": [
|
|
1504
|
+
"gapRecords": [_form_gap_record(m, key[3], forms[m["name"]]) for key, m in kept],
|
|
380
1505
|
"warnings": warnings,
|
|
381
1506
|
}
|
|
382
1507
|
|
|
@@ -397,13 +1522,19 @@ def _cmd_plan(args: argparse.Namespace) -> tuple[dict, int]:
|
|
|
397
1522
|
names, exports = (None, [])
|
|
398
1523
|
if args.surface is not None:
|
|
399
1524
|
_, exports, names = load_surface(args.surface, args.surface_set)
|
|
400
|
-
|
|
1525
|
+
if args.skill_dir is not None:
|
|
1526
|
+
if not args.skill_dir.strip():
|
|
1527
|
+
raise InputError("--skill-dir is empty: pass the skill package (the folder that holds SKILL.md)")
|
|
1528
|
+
_read_text(str(Path(args.skill_dir) / "SKILL.md"), "--skill-dir")
|
|
1529
|
+
out = plan(inventory, names, exports, args.skill_dir)
|
|
401
1530
|
if args.output:
|
|
402
1531
|
_write_json(args.output, out)
|
|
403
1532
|
return out, 0
|
|
404
1533
|
|
|
405
1534
|
|
|
406
1535
|
def _cmd_score(args: argparse.Namespace) -> tuple[dict, int]:
|
|
1536
|
+
if args.source_root is not None and not (args.source_root.strip() and Path(args.source_root).is_dir()):
|
|
1537
|
+
raise InputError(f"--source-root {args.source_root!r} is no folder: pass the local source tree the judges read")
|
|
407
1538
|
inventory = load_inventory(args.inventory)
|
|
408
1539
|
chosen, exports, names = load_surface(args.surface, args.surface_set)
|
|
409
1540
|
results, violations = load_results(args.results or [])
|
|
@@ -412,7 +1543,10 @@ def _cmd_score(args: argparse.Namespace) -> tuple[dict, int]:
|
|
|
412
1543
|
if path:
|
|
413
1544
|
Path(path).unlink(missing_ok=True)
|
|
414
1545
|
return {"valid": False, "violations": violations}, 2
|
|
415
|
-
|
|
1546
|
+
offered = load_plan(args.plan) if args.plan is not None else None
|
|
1547
|
+
planned = load_plan_forms(args.plan) if args.plan is not None else None
|
|
1548
|
+
out = score(inventory, names, exports, results, None if args.surface_set == "all" else chosen, offered,
|
|
1549
|
+
args.source_root, planned)
|
|
416
1550
|
if args.output:
|
|
417
1551
|
_write_json(args.output, out)
|
|
418
1552
|
if args.gaps_output:
|
|
@@ -433,6 +1567,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
433
1567
|
p.add_argument("--surface-set", default="all",
|
|
434
1568
|
help="a surface name set, which must exist (default: all); the plan compares the signature "
|
|
435
1569
|
"universe whichever set it names")
|
|
1570
|
+
p.add_argument("--skill-dir", help="the skill package under test: its citations pick the declaration of a name "
|
|
1571
|
+
"the surface records in several files")
|
|
436
1572
|
p.add_argument("--output", help="also write the plan to this file")
|
|
437
1573
|
p.set_defaults(func=_cmd_plan)
|
|
438
1574
|
|
|
@@ -441,6 +1577,10 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
441
1577
|
p.add_argument("--surface", required=True, help="load-coverage-inputs.py surface output")
|
|
442
1578
|
p.add_argument("--surface-set", default="all",
|
|
443
1579
|
help="the surface name set Type Coverage counts (default: all)")
|
|
1580
|
+
p.add_argument("--plan", help="the plan's output: the files each check offered a mismatch at")
|
|
1581
|
+
p.add_argument("--source-root", help="the local source tree the judges read: an annotation-contradicted "
|
|
1582
|
+
"mismatch's evidence_line must start with `return` there, or be the "
|
|
1583
|
+
"declaration line with its expression body")
|
|
444
1584
|
p.add_argument("--results", action="append", help="a subagent result file (repeatable)")
|
|
445
1585
|
p.add_argument("--output", help="also write the scores to this file")
|
|
446
1586
|
p.add_argument("--gaps-output", help="write the gap ledger records to this file")
|