bmad-module-skill-forge 5.0.0 → 7.0.0

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