bmad-module-skill-forge 8.1.0 → 9.0.0

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