bmad-module-skill-forge 4.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +3 -3
  3. package/docs/_data/pinned.yaml +19 -13
  4. package/docs/agents.md +1 -1
  5. package/docs/architecture.md +5 -5
  6. package/docs/campaign.md +1 -1
  7. package/docs/concepts.md +4 -4
  8. package/docs/examples.md +32 -25
  9. package/docs/getting-started.md +3 -3
  10. package/docs/how-it-works.md +2 -2
  11. package/docs/index.md +1 -1
  12. package/docs/skill-model.md +41 -39
  13. package/docs/troubleshooting.md +9 -3
  14. package/docs/verifying-a-skill.md +42 -21
  15. package/docs/why-skf.md +1 -1
  16. package/docs/workflows.md +6 -6
  17. package/package.json +1 -1
  18. package/src/knowledge/tool-resolution.md +6 -6
  19. package/src/shared/references/headless-gate-convention.md +4 -1
  20. package/src/shared/references/output-contract-schema.md +3 -3
  21. package/src/shared/references/pipeline-contracts.md +2 -1
  22. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  23. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
  24. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  25. package/src/shared/scripts/skf-build-change-manifest.py +1180 -189
  26. package/src/shared/scripts/skf-classify-changed-files.py +15 -10
  27. package/src/shared/scripts/skf-count-tokens.py +54 -10
  28. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  29. package/src/shared/scripts/skf-emit-result-envelope.py +64 -20
  30. package/src/shared/scripts/skf-extract-public-api.py +472 -120
  31. package/src/shared/scripts/skf-extraction-inventory.py +10 -6
  32. package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
  33. package/src/shared/scripts/skf-parse-gaps.py +136 -26
  34. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  35. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  36. package/src/shared/scripts/skf-shard-body.py +239 -41
  37. package/src/shared/scripts/skf-validate-output.py +12 -3
  38. package/src/shared/scripts/skf-verify-provenance-completeness.py +574 -45
  39. package/src/shared/tool-requirements.yaml +8 -2
  40. package/src/skf-analyze-source/SKILL.md +3 -2
  41. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  42. package/src/skf-analyze-source/references/generate-briefs.md +1 -1
  43. package/src/skf-analyze-source/references/headless-contract.md +1 -1
  44. package/src/skf-audit-skill/SKILL.md +4 -3
  45. package/src/skf-audit-skill/references/re-index.md +2 -2
  46. package/src/skf-audit-skill/references/report.md +1 -1
  47. package/src/skf-brief-skill/SKILL.md +3 -2
  48. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  49. package/src/skf-brief-skill/references/invocation-contract.md +2 -2
  50. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  51. package/src/skf-campaign/SKILL.md +3 -2
  52. package/src/skf-campaign/references/campaign-contracts.md +2 -0
  53. package/src/skf-campaign/references/step-05-skill-loop.md +8 -3
  54. package/src/skf-campaign/references/step-10-export.md +3 -2
  55. package/src/skf-create-skill/SKILL.md +4 -3
  56. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  57. package/src/skf-create-skill/references/ccc-index-check.md +1 -1
  58. package/src/skf-create-skill/references/component-extraction.md +1 -1
  59. package/src/skf-create-skill/references/extract.md +3 -2
  60. package/src/skf-create-skill/references/extraction-patterns.md +3 -3
  61. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  62. package/src/skf-create-skill/references/report.md +8 -3
  63. package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
  64. package/src/skf-create-skill/references/validate.md +1 -1
  65. package/src/skf-create-stack-skill/SKILL.md +3 -2
  66. package/src/skf-create-stack-skill/references/generate-output.md +1 -1
  67. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  68. package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
  69. package/src/skf-create-stack-skill/references/report.md +1 -1
  70. package/src/skf-create-stack-skill/references/validate.md +1 -1
  71. package/src/skf-drop-skill/SKILL.md +3 -2
  72. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  73. package/src/skf-drop-skill/references/report.md +1 -1
  74. package/src/skf-export-skill/SKILL.md +3 -2
  75. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  76. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  77. package/src/skf-export-skill/references/invocation-contract.md +1 -1
  78. package/src/skf-export-skill/references/load-skill.md +1 -1
  79. package/src/skf-export-skill/references/summary.md +1 -1
  80. package/src/skf-export-skill/references/token-report.md +29 -9
  81. package/src/skf-forger/SKILL.md +3 -2
  82. package/src/skf-forger/references/pipeline-mode.md +2 -2
  83. package/src/skf-quick-skill/SKILL.md +4 -3
  84. package/src/skf-quick-skill/references/finalize.md +1 -1
  85. package/src/skf-quick-skill/references/halt-contract.md +2 -2
  86. package/src/skf-quick-skill/references/write-and-validate.md +1 -1
  87. package/src/skf-refine-architecture/SKILL.md +8 -9
  88. package/src/skf-refine-architecture/customize.toml +1 -1
  89. package/src/skf-refine-architecture/references/report.md +1 -1
  90. package/src/skf-rename-skill/SKILL.md +3 -2
  91. package/src/skf-rename-skill/references/invocation-contract.md +2 -2
  92. package/src/skf-setup/SKILL.md +3 -2
  93. package/src/skf-setup/references/ccc-index.md +1 -1
  94. package/src/skf-setup/references/invocation-contract.md +1 -1
  95. package/src/skf-test-skill/SKILL.md +3 -2
  96. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  97. package/src/skf-test-skill/references/coherence-check.md +11 -8
  98. package/src/skf-test-skill/references/coverage-check-branches.md +81 -0
  99. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  100. package/src/skf-test-skill/references/coverage-check.md +60 -97
  101. package/src/skf-test-skill/references/init.md +2 -1
  102. package/src/skf-test-skill/references/inventory-contract.md +27 -0
  103. package/src/skf-test-skill/references/invocation-contract.md +3 -3
  104. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  105. package/src/skf-test-skill/references/report.md +5 -5
  106. package/src/skf-test-skill/references/scoring-rules.md +8 -7
  107. package/src/skf-test-skill/references/signature-comparison-rules.md +72 -0
  108. package/src/skf-test-skill/references/source-access-protocol.md +14 -10
  109. package/src/skf-test-skill/references/step-hard-gate.md +1 -0
  110. package/src/skf-test-skill/scripts/build-result-context.py +20 -3
  111. package/src/skf-test-skill/scripts/gap-ledger.py +456 -176
  112. package/src/skf-test-skill/scripts/load-coverage-inputs.py +516 -79
  113. package/src/skf-test-skill/scripts/locate-export-segments.py +685 -76
  114. package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
  115. package/src/skf-test-skill/scripts/score-signatures.py +1205 -65
  116. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  117. package/src/skf-test-skill/scripts/validate-inventory.py +350 -35
  118. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  119. package/src/skf-update-skill/SKILL.md +3 -2
  120. package/src/skf-update-skill/references/detect-changes.md +1 -1
  121. package/src/skf-update-skill/references/gap-driven.md +54 -33
  122. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  123. package/src/skf-update-skill/references/merge.md +15 -10
  124. package/src/skf-update-skill/references/re-extract.md +3 -1
  125. package/src/skf-update-skill/references/report.md +6 -5
  126. package/src/skf-update-skill/references/write.md +42 -20
  127. package/src/skf-verify-stack/SKILL.md +4 -3
  128. package/tools/cli/lib/tool-check.js +158 -2
@@ -71,8 +71,15 @@ not read as a missing public export nor as an orphan.
71
71
  Definition lines: after a citation resolves (the file exists and the line
72
72
  is in range, so the older reasons win), the line must define the entry's
73
73
  export. NAME is the raw `export_name` (not canonicalized); for a dotted
74
- name (`App.update`) it is the last segment. The rules follow the
75
- `source_file` extension:
74
+ name (`App.update`) it is the last segment. A bookkeeping variant
75
+ (`X_def`, `X_exact`, `a11y_X`, test-skill's Source Access Protocol fold
76
+ without `--fold-prefix` or `--keep`) that the cited file never names (no
77
+ whole identifier in its text) is checked against its base `X` instead,
78
+ its finding carries `folded_to`, and `summary.line_check_folded` counts
79
+ it; the raw name always comes first, so an export really named
80
+ `parse_exact`, or bound in a shape the rules do not cover, is judged as
81
+ written. classify-stale never folds. The rules
82
+ follow the `source_file` extension:
76
83
 
77
84
  - Python (`.py`, `.pyi`): the `def` / `async def` / `class NAME` line
78
85
  itself (a decorator line above it does not count); an assignment
@@ -89,7 +96,19 @@ name (`App.update`) it is the last segment. The rules follow the
89
96
  a keyword argument or a parameter never matches; a file that does not
90
97
  tokenize is matched line by line. A `from X import *` line counts only
91
98
  when no other line of the file defines NAME (a re-export the
92
- extraction could not trace is cited at its star import).
99
+ extraction could not trace is cited at its star import). An
100
+ `@overload` group, where every line found is a `def` / `async def
101
+ NAME` line at one indentation in one enclosing block (one class, not
102
+ two) and every one but at most the last has `@overload` or
103
+ `@<module>.overload` (`@typing.overload`,
104
+ `@typing_extensions.overload`, `@t.overload` after
105
+ `import typing as t`) in its contiguous decorator block, defines NAME
106
+ at each of its lines, the stubs and the implementation. A comment line
107
+ may sit in the decorator block, and so may a decorator's arguments on
108
+ lines of their own, unless the file does not tokenize. A recorded line
109
+ that misses the group gives its first line alone, which is its first
110
+ stub: the public-API runner, its merge rule and the structural diff
111
+ all record that line, so `fix` can move the entry there.
93
112
  - TS/JS (`.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, `.cjs`): a
94
113
  `function` (or `function*`), `class`, `interface`, `type`, `enum`,
95
114
  `namespace`, `const`, `let` or `var` declaration of NAME, with any of
@@ -98,7 +117,11 @@ name (`App.update`) it is the last segment. The rules follow the
98
117
  and `class`; a name inside a decorator's arguments is not declared);
99
118
  a later declarator of a `const`, `let` or `var` list
100
119
  (`export const a = 1, NAME = 2;`, or `export const a = 1,` then
101
- ` NAME = 2;` as Prettier writes it: see Declarator lists below); NAME
120
+ ` NAME = 2;` as Prettier writes it: see Declarator lists below);
121
+ NAME bound by a destructuring pattern that begins such a list
122
+ (`export const { a, b: NAME } = obj;`, `const [NAME] = list`, the
123
+ pattern on one line or several), on the line where NAME appears (see
124
+ Declarator lists below); NAME
102
125
  on the line right after a line that ends in its declaration keyword
103
126
  (`export function`, `export interface`, `export const` and so on),
104
127
  which is the line cited;
@@ -125,14 +148,23 @@ name (`App.update`) it is the last segment. The rules follow the
125
148
  Declarator lists: a `const`, `let` or `var` list is split at its
126
149
  top-level commas, outside strings, regular expression literals and
127
150
  brackets, a type-argument list included (`Map<K, V>`: a `<` right after
128
- a name), up to a top-level `;`, and a destructuring pattern declares
129
- nothing. The list goes on to the next line when a line ends inside a
130
- bracket, after a top-level comma or after an operator waiting for its
131
- operand, or when the next line starts with an operator (a member chain or
132
- a ternary broken over lines), so a declarator on a line of its own
133
- counts. A `<` where an expression begins (JSX, a type assertion, a generic
151
+ a name), up to a top-level `;`. The list goes on to the next line when a
152
+ line ends inside a bracket, after a top-level comma or after an operator
153
+ waiting for its operand, or when the next line starts with an operator (a
154
+ member chain or a ternary broken over lines), so a declarator on a line
155
+ of its own counts. A `<` where an expression begins (JSX, a type assertion, a generic
134
156
  arrow function's type parameters) is not followed: no later declarator of
135
- the list counts after it.
157
+ the list counts after it. A destructuring pattern that begins the list,
158
+ on the keyword's line, declares the names it binds: a shorthand key, the
159
+ target after `key:`, an array element, a rest `...NAME` and the names of
160
+ a nested pattern, each on the line where it appears (the pattern may
161
+ span lines), with its statement's column. A renamed or computed key, a
162
+ default value, the type annotation after the pattern and anything inside
163
+ a string or a comment bind nothing, and neither does a pattern that does
164
+ not close. The later declarators after a pattern count as any others. A
165
+ pattern that is a later declarator (`export const a = 1, { b } = c`) or
166
+ that sits on the line after a split keyword (`export const`, then
167
+ ` { b } = c;`) is not covered: it declares nothing.
136
168
 
137
169
  Indentation: for a dotted name every match counts. For an undotted name,
138
170
  matches at any indentation count, but when the file has a column-0 match
@@ -140,7 +172,8 @@ the indented ones are dropped, so a local shadow never outranks the
140
172
  module-level definition. A line inside a multi-line import, export list
141
173
  or `module.exports` object takes the column of the line its statement
142
174
  starts on, and so do the name line of a declaration split after its
143
- keyword and a later declarator on a line of its own. When the recorded
175
+ keyword, a later declarator on a line of its own and the line of a name
176
+ a destructuring pattern binds. When the recorded
144
177
  line is itself an indented match that this rule dropped (a method
145
178
  recorded under an undotted name beside a module-level function of the
146
179
  same name), the finding lists the recorded line too, so it carries
@@ -149,7 +182,11 @@ several lines and no caller auto-moves it.
149
182
  Entries in other files, and entries whose `export_type` is `module` or
150
183
  `package` (case-insensitive; neither has a definition line), are skipped
151
184
  and counted in `summary.line_check_skipped`. A `line-not-definition` item
152
- lists every defining line in the file in `definition_lines`. An empty list
185
+ lists every defining line in the file in `definition_lines`, the recorded
186
+ line included when it is an indented match the column-0 rule dropped (see
187
+ Indentation). The one exception is a Python `@overload` group: it lists
188
+ the group's first line alone, unless the recorded line is such a dropped
189
+ indented match, which still gives several lines. An empty list
153
190
  means the rules found no definition line, which may be a shape they do not
154
191
  cover: the entry is unverified, not gone, and no caller auto-fixes it.
155
192
 
@@ -232,7 +269,9 @@ Subcommands:
232
269
  "source_line": <int|null>,
233
270
  "reason": "file-missing" | "line-out-of-bounds" | "line-invalid"
234
271
  | "line-not-definition",
235
- "definition_lines": [<int>, ...]}, # line-not-definition only
272
+ "definition_lines": [<int>, ...], # line-not-definition only
273
+ "folded_to": "<base name>"}, # line-not-definition only,
274
+ # a variant checked as its base
236
275
  ...
237
276
  ],
238
277
  "citations": [
@@ -272,6 +311,7 @@ Subcommands:
272
311
  "stale_count": <int>,
273
312
  "citations_checked": <int>, # entries whose citation was resolved
274
313
  "line_check_skipped": <int>, # resolved entries with no line rule
314
+ "line_check_folded": <int>, # variants checked against their base
275
315
  "stale_check": "checked" | "skipped-no-source-root",
276
316
  "skill_citations_scanned": <int>, # [AST:]/[SRC:] with a line
277
317
  "skill_citations_matched": <int>, # of those, citing an entry
@@ -383,11 +423,15 @@ Subcommands:
383
423
  | "skipped-language",
384
424
  "definition_lines": [<int>, ...] | null, # checked only
385
425
  "source_line": <int> | null, # --line
386
- "line_is_definition": <bool> | null # --line, checked only
426
+ "line_is_definition": <bool> | null, # --line, checked only
427
+ "folded_to": "<base name>" # a folded variant only
387
428
  }
388
429
 
389
430
  With `--line`, a recorded line that is an indented match the column-0
390
- rule dropped joins `definition_lines`, as in `verify`.
431
+ rule dropped joins `definition_lines`, and one that misses a Python
432
+ `@overload` group gives its first line alone, as in `verify`. A
433
+ bookkeeping variant the file never names is checked against its base,
434
+ as in `verify`, and only then does the key `folded_to` appear.
391
435
 
392
436
  classify-stale --names <coverage.json> --provenance <provenance-map.json>
393
437
  --source-root <path> [--inventory <inventory.json>]
@@ -397,7 +441,8 @@ Subcommands:
397
441
  documented name the enumerated source surface lacks): the
398
442
  definition-lines rules above, run for every provenance entry whose raw
399
443
  `export_name` is the name, at its `source_file` and `source_line`, with
400
- its `export_type`. `--names` holds reconcile-coverage.py's result (its
444
+ its `export_type`, and with no bookkeeping-variant fold (the name is
445
+ judged as written). `--names` holds reconcile-coverage.py's result (its
401
446
  `stale` names) or a JSON array of names. `--inventory` holds the
402
447
  documented inventory, validate-inventory.py's `--output` file or its
403
448
  whole result: the `kind` it gives each name decides the module rule
@@ -450,8 +495,9 @@ Subcommands:
450
495
  `export default NAME`, an export-list item without `as`, a
451
496
  `module.exports` shorthand key, a binding whose value is an import
452
497
  (`require(...)` or `(await) import(...)`, on its line or the next:
453
- `const NAME = require(...)`, `exports.NAME = require(...)`,
454
- `{ NAME: require(...) }`), an indented line and any line of a Python
498
+ `const NAME = require(...)`, `const { NAME } = require(...)`,
499
+ `exports.NAME = require(...)`, `{ NAME: require(...) }`), an
500
+ indented line and any line of a Python
455
501
  file that does not tokenize never count. A module or package named
456
502
  NAME (`NAME.py`, `NAME/__init__.py`, a TS/JS `NAME.<ext>` or
457
503
  `NAME/index.<ext>`) declares NAME too: at its line 1 when no line of it
@@ -599,6 +645,7 @@ Exit codes:
599
645
  from __future__ import annotations
600
646
 
601
647
  import argparse
648
+ import bisect
602
649
  import copy
603
650
  import importlib.util
604
651
  import io
@@ -913,6 +960,10 @@ _PY_SPLIT_DECL_RE = re.compile(r"^\s*(?:async\s+def|def|class)\s*\\$")
913
960
  # A line that can matter to a name it does not hold: an import (which may go
914
961
  # on over the next lines) or a `def \` / `class \` whose name is on the next.
915
962
  _PY_NAMELESS_MATCH_RE = re.compile(r"^\s*(?:from|import|async|def|class)\b")
963
+ # The decorator of an `@overload` stub, imported by name or used through its
964
+ # module under any name (`typing`, `typing_extensions`, `t` after
965
+ # `import typing as t`).
966
+ _PY_OVERLOAD_RE = re.compile(r"^\s*@\s*(?:[A-Za-z_]\w*\.)?overload\s*$")
916
967
  # Tokens that never begin a statement.
917
968
  _PY_LAYOUT_TOKENS = frozenset(
918
969
  {
@@ -989,6 +1040,11 @@ _DECL_MAYBE = "maybe"
989
1040
  # What `_js_declarators` gives: None, or (brackets left open, how) the list
990
1041
  # goes on with, and the pieces that begin a declarator.
991
1042
  _DeclSplit = tuple[tuple[str, str] | None, list[str]]
1043
+ # What `_js_destructured` gives for a `const` / `let` / `var` statement
1044
+ # whose first declarator is a destructuring pattern: the (name, line) of
1045
+ # each name it binds, and the (line, text after the `=` on that line) its
1046
+ # initializer starts at, None when it has none.
1047
+ _PatternDecl = tuple[list[tuple[str, int]], tuple[int, str] | None]
992
1048
  # After one of these, `/` begins a regular expression and `<` begins JSX, a
993
1049
  # type assertion or a generic arrow function's type parameters.
994
1050
  _JS_EXPRESSION_START = frozenset("([{,;=?:&|^%+-*/<>!~")
@@ -1004,6 +1060,28 @@ def definition_name(export_name: str) -> str:
1004
1060
  return export_name.rsplit(".", 1)[-1]
1005
1061
 
1006
1062
 
1063
+ # Bookkeeping variants folded to their base name, in rule order: test-skill's
1064
+ # Source Access Protocol defines the fold (`X_def`, `X_exact`, `a11y_X` -> `X`).
1065
+ # Keep identical to FOLD_SUFFIXES and FOLD_PREFIX in load-coverage-inputs.py.
1066
+ FOLD_SUFFIXES = ("_def", "_exact")
1067
+ FOLD_PREFIX = "a11y_"
1068
+
1069
+
1070
+ def fold_base(export_name: str) -> str | None:
1071
+ """The base name a bookkeeping variant folds to, None for a name that is
1072
+ no variant: a suffix in FOLD_SUFFIXES first, else FOLD_PREFIX, never the
1073
+ whole name (`_def` alone is no variant). The rules of
1074
+ load-coverage-inputs.py `fold_names`, without `--fold-prefix` or
1075
+ `--keep`: the line check tries the raw name first, so a real export
1076
+ named like a variant is never folded."""
1077
+ for suffix in FOLD_SUFFIXES:
1078
+ if export_name.endswith(suffix) and len(export_name) > len(suffix):
1079
+ return export_name[: -len(suffix)]
1080
+ if export_name.startswith(FOLD_PREFIX) and len(export_name) > len(FOLD_PREFIX):
1081
+ return export_name[len(FOLD_PREFIX):]
1082
+ return None
1083
+
1084
+
1007
1085
  def _read_lines(path: Path) -> list[str] | None:
1008
1086
  """Read a text file as lines numbered like `check_citation` counts them
1009
1087
  (universal newlines; a UTF-8 BOM is dropped so it never hides line 1).
@@ -1105,15 +1183,17 @@ def _js_code_lines(lines: list[str]) -> list[str]:
1105
1183
 
1106
1184
 
1107
1185
  class _SourceCache:
1108
- """Source lines, Python statement starts and TS/JS comment-free lines,
1109
- read once per file, and TS/JS declarator lists, split once per line
1110
- (the split does not depend on the export looked up)."""
1186
+ """Source lines, Python statement starts, TS/JS comment-free lines and
1187
+ the names TS/JS destructuring patterns bind, read once per file, and
1188
+ TS/JS declarator lists, split once per line (neither depends on the
1189
+ export looked up)."""
1111
1190
 
1112
1191
  def __init__(self) -> None:
1113
1192
  self._lines: dict[Path, list[str] | None] = {}
1114
1193
  self._starts: dict[Path, set[int] | None] = {}
1115
1194
  self._js_code: dict[Path, list[str]] = {}
1116
1195
  self._js_quiet: dict[Path, list[bool]] = {}
1196
+ self._js_patterns: dict[Path, dict[int, _PatternDecl]] = {}
1117
1197
  self._js_decl: dict[tuple[str, str, bool], _DeclSplit] = {}
1118
1198
 
1119
1199
  def lines(self, path: Path) -> list[str] | None:
@@ -1136,6 +1216,13 @@ class _SourceCache:
1136
1216
  self._js_quiet[path] = _js_quiet_lines(code_lines)
1137
1217
  return self._js_quiet[path]
1138
1218
 
1219
+ def js_destructured(
1220
+ self, path: Path, code_lines: list[str]
1221
+ ) -> dict[int, _PatternDecl]:
1222
+ if path not in self._js_patterns:
1223
+ self._js_patterns[path] = _js_destructured(code_lines)
1224
+ return self._js_patterns[path]
1225
+
1139
1226
  def js_declarators(
1140
1227
  self, text: str, opened: str = "", fresh: bool = True
1141
1228
  ) -> _DeclSplit:
@@ -1269,6 +1356,76 @@ def _python_definition_lines(
1269
1356
  return found or star_imports
1270
1357
 
1271
1358
 
1359
+ def _py_overloaded(lines: list[str], number: int, starts: set[int] | None) -> bool:
1360
+ """True when the `def` on line `number` has `@overload` or
1361
+ `@<module>.overload` (`@typing.overload`, `@t.overload`) in its
1362
+ contiguous decorator block: the decorator lines at its indentation
1363
+ right above it, up to a blank line or a line that is not a decorator.
1364
+ A comment line is passed over, and so, in a file that tokenizes, is a
1365
+ line on which no statement begins (a decorator's arguments over
1366
+ several lines)."""
1367
+ text = lines[number - 1]
1368
+ indent = text[: len(text) - len(text.lstrip())]
1369
+ i = number - 1
1370
+ while i >= 1:
1371
+ text = lines[i - 1]
1372
+ code = text.split("#", 1)[0]
1373
+ if not text.strip():
1374
+ return False
1375
+ if not code.strip() or (starts is not None and i not in starts):
1376
+ i -= 1
1377
+ continue
1378
+ if not code.lstrip().startswith("@") or text[: len(text) - len(text.lstrip())] != indent:
1379
+ return False
1380
+ if _PY_OVERLOAD_RE.match(code):
1381
+ return True
1382
+ i -= 1
1383
+ return False
1384
+
1385
+
1386
+ def _py_enclosing_line(
1387
+ lines: list[str], number: int, indent: str, starts: set[int] | None
1388
+ ) -> int | None:
1389
+ """The line of the block that holds the statement on line `number`,
1390
+ indented by `indent`: the nearest line above it where a statement
1391
+ begins with less indentation. None at module level."""
1392
+ i = number - 1
1393
+ while indent and i >= 1:
1394
+ text = lines[i - 1]
1395
+ if text.split("#", 1)[0].strip() and (starts is None or i in starts):
1396
+ if len(text) - len(text.lstrip()) < len(indent):
1397
+ return i
1398
+ i -= 1
1399
+ return None
1400
+
1401
+
1402
+ def _python_overload_start(
1403
+ lines: list[str], name: str, defs: list[int], starts: set[int] | None
1404
+ ) -> int | None:
1405
+ """The first of `defs` when they are one `@overload` group: two lines
1406
+ or more, each a `def` / `async def NAME` line, all at one indentation
1407
+ in one enclosing block (`_py_enclosing_line`, so two classes'
1408
+ methods never make one group), and each but at most the last
1409
+ decorated as an overload (`_py_overloaded`). None otherwise."""
1410
+ if len(defs) < 2:
1411
+ return None
1412
+ decl = re.compile(rf"^(\s*)(?:async\s+def|def)\s+{re.escape(name)}(?!\w)")
1413
+ indents = set()
1414
+ for number in defs:
1415
+ m = decl.match(lines[number - 1].split("#", 1)[0])
1416
+ if m is None:
1417
+ return None
1418
+ indents.add(m.group(1))
1419
+ if len(indents) > 1:
1420
+ return None
1421
+ (indent,) = indents
1422
+ if len({_py_enclosing_line(lines, number, indent, starts) for number in defs}) > 1:
1423
+ return None
1424
+ if not all(_py_overloaded(lines, number, starts) for number in defs[:-1]):
1425
+ return None
1426
+ return defs[0]
1427
+
1428
+
1272
1429
  def _js_export_item_binds(item: str, name: str, alias_only: bool = False) -> bool:
1273
1430
  """True when one `export { ... }` item exposes `name`: the name after
1274
1431
  `as`, else (unless `alias_only`) the item's own name, an identifier or
@@ -1469,6 +1626,252 @@ def _js_paren_tail_is_call(after_open: str) -> bool:
1469
1626
  return False
1470
1627
 
1471
1628
 
1629
+ def _js_pattern_bindings(
1630
+ text: str, start: int
1631
+ ) -> tuple[list[tuple[str, int]], int | None]:
1632
+ """The names the destructuring pattern whose `{` or `[` is at
1633
+ `text[start]` binds, each with its offset in `text`, and the offset
1634
+ just past the pattern's closing bracket.
1635
+
1636
+ An object pattern's property is `...target`, a shorthand key or
1637
+ `key: target` (a key is a name, a string, a number or a computed
1638
+ `[...]`, and only a shorthand key binds itself); an array pattern's
1639
+ element is `...target`, a target or a hole; a target is a name or a
1640
+ nested pattern. A `= default` is skipped to the next comma at the
1641
+ pattern's depth, and so is a property or element of any other shape,
1642
+ which binds nothing. '...', "..." and `...` strings are skipped
1643
+ whole, so no bracket or comma inside one counts. Whitespace, newlines
1644
+ included, may sit anywhere between the parts.
1645
+
1646
+ Returns ([], None) when `text[start]` opens no pattern, the pattern
1647
+ does not close in `text`, a string in it does not end, or a bracket in
1648
+ it closes one it did not open. A pure function of its arguments (no
1649
+ module state), so another script can carry a copy of it.
1650
+ """
1651
+ n = len(text)
1652
+ names: list[tuple[str, int]] = []
1653
+
1654
+ def space(i: int) -> int:
1655
+ while i < n and text[i].isspace():
1656
+ i += 1
1657
+ return i
1658
+
1659
+ def name_end(i: int) -> int:
1660
+ """The end of the identifier at `i`, `i` itself when none starts there."""
1661
+ if i < n and (text[i].isalpha() or text[i] in "_$"):
1662
+ i += 1
1663
+ while i < n and (text[i].isalnum() or text[i] in "_$"):
1664
+ i += 1
1665
+ return i
1666
+
1667
+ def string_end(i: int) -> int | None:
1668
+ """The offset past the string whose quote is at `i`."""
1669
+ quote = text[i]
1670
+ i += 1
1671
+ while i < n:
1672
+ if text[i] == "\\":
1673
+ i += 2
1674
+ elif text[i] == quote:
1675
+ return i + 1
1676
+ else:
1677
+ i += 1
1678
+ return None
1679
+
1680
+ def skip(i: int, to_comma: bool = True) -> int | None:
1681
+ """From `i`, the offset of the next comma (unless not `to_comma`)
1682
+ or closing bracket at depth 0; with `to_comma` False, `text[i]`
1683
+ opens a bracket and the offset past the one closing it."""
1684
+ depth = 0
1685
+ while i < n:
1686
+ ch = text[i]
1687
+ if ch in "'\"`":
1688
+ end = string_end(i)
1689
+ if end is None:
1690
+ return None
1691
+ i = end
1692
+ continue
1693
+ if ch in "([{":
1694
+ depth += 1
1695
+ elif ch in ")]}":
1696
+ if depth == 0:
1697
+ return i
1698
+ depth -= 1
1699
+ if depth == 0 and not to_comma:
1700
+ return i + 1
1701
+ elif ch == "," and depth == 0 and to_comma:
1702
+ return i
1703
+ i += 1
1704
+ return None
1705
+
1706
+ def default(i: int) -> int | None:
1707
+ """After a target: past its `= default`, when it has one."""
1708
+ i = space(i)
1709
+ if i < n and text[i] == "=" and text[i + 1 : i + 2] not in ("=", ">"):
1710
+ return skip(i + 1)
1711
+ return i
1712
+
1713
+ def bind(i: int, end: int, close: str) -> int | None:
1714
+ """The name `text[i:end]` is bound when its property or element
1715
+ ends after it or takes a default (any other text: another shape)."""
1716
+ after = space(end)
1717
+ if after < n and (
1718
+ text[after] in (",", close)
1719
+ or text[after] == "=" and text[after + 1 : after + 2] not in ("=", ">")
1720
+ ):
1721
+ names.append((text[i:end], i))
1722
+ return default(after)
1723
+ return after
1724
+
1725
+ def target(i: int, close: str) -> int | None:
1726
+ if i < n and text[i] in "{[":
1727
+ end = pattern(i)
1728
+ return None if end is None else default(end)
1729
+ end = name_end(i)
1730
+ return i if end == i else bind(i, end, close)
1731
+
1732
+ def element(i: int, close: str) -> int | None:
1733
+ """One property or element at `i`: the offset after it."""
1734
+ if text.startswith("...", i):
1735
+ return target(space(i + 3), close)
1736
+ if close == "]":
1737
+ return target(i, close)
1738
+ if text[i] in "'\"":
1739
+ end = string_end(i)
1740
+ elif text[i] == "[":
1741
+ end = skip(i, to_comma=False) # a computed key
1742
+ else:
1743
+ end = name_end(i)
1744
+ if end > i:
1745
+ after = space(end)
1746
+ if after < n and text[after] == ":":
1747
+ return target(space(after + 1), close)
1748
+ return bind(i, end, close) # a shorthand key
1749
+ while end < n and (text[end].isalnum() or text[end] in "._"):
1750
+ end += 1 # a number key
1751
+ if end is None:
1752
+ return None
1753
+ after = space(end)
1754
+ if end > i and after < n and text[after] == ":":
1755
+ return target(space(after + 1), close)
1756
+ return after
1757
+
1758
+ def pattern(i: int) -> int | None:
1759
+ """The pattern opening at `i`: the offset past its closing bracket."""
1760
+ close = "}" if text[i] == "{" else "]"
1761
+ i += 1
1762
+ while True:
1763
+ i = space(i)
1764
+ if i >= n:
1765
+ return None
1766
+ if text[i] == close:
1767
+ return i + 1
1768
+ if text[i] in ")]}":
1769
+ return None
1770
+ if text[i] == ",":
1771
+ i += 1 # a hole, or a stray comma
1772
+ continue
1773
+ end = element(i, close)
1774
+ if end is None:
1775
+ return None
1776
+ end = space(end)
1777
+ if end < n and text[end] not in (",", close):
1778
+ end = skip(end) # any other shape binds nothing
1779
+ if end is None:
1780
+ return None
1781
+ i = end + 1 if end < n and text[end] == "," else end
1782
+
1783
+ if not (0 <= start < n and text[start] in "{["):
1784
+ return [], None
1785
+ end = pattern(start)
1786
+ return (names, end) if end is not None else ([], None)
1787
+
1788
+
1789
+ def _js_initializer_start(text: str, end: int) -> int | None:
1790
+ """The offset just past the `=` that begins the initializer of a
1791
+ declarator whose name or pattern ends at `end`, a type annotation
1792
+ (`: T`, brackets, strings and `=>` in it included) skipped. None when
1793
+ neither `=` nor `:` follows, or when a `,` or `;` outside the
1794
+ annotation's brackets, or the end of `text`, comes first."""
1795
+ n = len(text)
1796
+ i = end
1797
+ while i < n and text[i].isspace():
1798
+ i += 1
1799
+ if i < n and text[i] == "=":
1800
+ return None if text[i + 1 : i + 2] == "=" else i + 1
1801
+ if not (i < n and text[i] == ":"):
1802
+ return None
1803
+ i += 1
1804
+ depth = 0
1805
+ while i < n:
1806
+ ch = text[i]
1807
+ if ch in "'\"`":
1808
+ i += 1
1809
+ while i < n and text[i] != ch:
1810
+ i += 2 if text[i] == "\\" else 1
1811
+ elif text.startswith("=>", i):
1812
+ i += 1
1813
+ elif ch in "([{<":
1814
+ depth += 1
1815
+ elif ch in ")]}>":
1816
+ depth -= 1
1817
+ if depth < 0:
1818
+ return None
1819
+ elif ch in ",;" and depth == 0:
1820
+ return None
1821
+ elif ch == "=" and depth == 0:
1822
+ return None if text[i + 1 : i + 2] == "=" else i + 1
1823
+ i += 1
1824
+ return None
1825
+
1826
+
1827
+ def _js_destructured(code_lines: list[str]) -> dict[int, _PatternDecl]:
1828
+ """For each line that opens a `const` / `let` / `var` statement whose
1829
+ first declarator is a destructuring pattern (`_JS_VAR_DECL_RE`, the
1830
+ rest starting with `{` or `[`): the names the pattern binds, each with
1831
+ the line it appears on, and where its initializer starts.
1832
+
1833
+ The patterns are read from the comment-free lines joined with `\\n`
1834
+ (`_js_pattern_bindings`), so a pattern may span lines and a name's
1835
+ offset maps back to its line. A pattern that does not close, or that
1836
+ nests deeper than the interpreter's recursion limit, binds nothing and
1837
+ gives no item.
1838
+ """
1839
+ starts: list[tuple[int, int]] = []
1840
+ for number, code in enumerate(code_lines, start=1):
1841
+ if "{" not in code and "[" not in code:
1842
+ continue
1843
+ m = _JS_VAR_DECL_RE.match(code)
1844
+ if m is not None and m.group(1)[:1] in ("{", "["):
1845
+ starts.append((number, m.start(1)))
1846
+ if not starts:
1847
+ return {}
1848
+ text = "\n".join(code_lines)
1849
+ offsets: list[int] = []
1850
+ pos = 0
1851
+ for code in code_lines:
1852
+ offsets.append(pos)
1853
+ pos += len(code) + 1
1854
+
1855
+ def line_of(offset: int) -> int:
1856
+ return bisect.bisect_right(offsets, offset)
1857
+
1858
+ out: dict[int, _PatternDecl] = {}
1859
+ for number, column in starts:
1860
+ try:
1861
+ names, end = _js_pattern_bindings(text, offsets[number - 1] + column)
1862
+ except RecursionError:
1863
+ continue # patterns nested past the interpreter's limit: none bound
1864
+ if end is None:
1865
+ continue
1866
+ value = _js_initializer_start(text, end)
1867
+ at = None
1868
+ if value is not None:
1869
+ line = line_of(value)
1870
+ at = (line, code_lines[line - 1][value - offsets[line - 1] :])
1871
+ out[number] = ([(name, line_of(offset)) for name, offset in names], at)
1872
+ return out
1873
+
1874
+
1472
1875
  def _tsjs_definition_lines(
1473
1876
  lines: list[str],
1474
1877
  code_lines: list[str],
@@ -1477,6 +1880,7 @@ def _tsjs_definition_lines(
1477
1880
  declarators: Callable[..., _DeclSplit] = _js_declarators,
1478
1881
  declarations_only: bool = False,
1479
1882
  quiet: list[bool] | None = None,
1883
+ destructured: dict[int, _PatternDecl] | None = None,
1480
1884
  ) -> list[tuple[int, bool]]:
1481
1885
  """(line, at column 0) for every line that defines `name`.
1482
1886
 
@@ -1486,15 +1890,20 @@ def _tsjs_definition_lines(
1486
1890
  JSDoc line or a commented-out declaration never matches. A line inside
1487
1891
  a multi-line `export { ... }` list or `module.exports = { ... }` object
1488
1892
  takes the column of the line the statement starts on, and so do the
1489
- name line of a declaration whose keyword ends the line above it and a
1490
- later declarator on a line of its own. Class members count only for a
1893
+ name line of a declaration whose keyword ends the line above it, a
1894
+ later declarator on a line of its own and the line of a name that a
1895
+ destructuring pattern binds. `destructured` is
1896
+ `_js_destructured(code_lines)` when the caller has it (it does not
1897
+ depend on the name either): a name its pattern binds counts when the
1898
+ `const` / `let` / `var` line it is keyed by opens a statement here.
1899
+ Class members count only for a
1491
1900
  dotted export name. `declarators` splits a declarator list
1492
1901
  (`_js_declarators`, or `_SourceCache.js_declarators`, which splits each
1493
1902
  line once for every export looked up). With `declarations_only`,
1494
1903
  `export import NAME =`, `export default NAME`, an export-list item
1495
1904
  without an `as` that renames, a `module.exports` shorthand key and an
1496
- import-valued binding (a declarator, `exports.NAME =` or a
1497
- `module.exports` key whose value starts with `require(` or an
1905
+ import-valued binding (a declarator, a destructured name, `exports.NAME =`
1906
+ or a `module.exports` key whose value starts with `require(` or an
1498
1907
  `(await) import(`, on its line or the next one) never count, nor does
1499
1908
  a class member.
1500
1909
  """
@@ -1515,7 +1924,8 @@ def _tsjs_definition_lines(
1515
1924
  ]
1516
1925
  # One declarator of a `const` / `let` / `var` list: `NAME = v`,
1517
1926
  # `NAME: T = v`, `NAME!: T`, or a bare `NAME`. A destructuring pattern
1518
- # starts with `{` or `[` and never matches.
1927
+ # starts with `{` or `[` and never matches: the names a pattern that
1928
+ # begins the list binds come from `destructured` (`_js_pattern_bindings`).
1519
1929
  declarator = re.compile(rf"^\s*{esc}{end}\s*(?:!?\s*:|=|$)")
1520
1930
  # With declarations_only, the value bound to NAME (the rest of a
1521
1931
  # declarator, of `exports.NAME =` or of a `module.exports` key), to
@@ -1590,6 +2000,22 @@ def _tsjs_definition_lines(
1590
2000
  decls: list[tuple[bool, str, str]] = []
1591
2001
  if quiet is None:
1592
2002
  quiet = _js_quiet_lines(code_lines)
2003
+ if destructured is None:
2004
+ destructured = _js_destructured(code_lines)
2005
+
2006
+ def pattern_binds(number: int) -> list[int]:
2007
+ """The lines on which the pattern that begins the `const` / `let` /
2008
+ `var` list of line `number` binds `name` (none with
2009
+ `declarations_only` when its value is an import)."""
2010
+ decl = destructured.get(number)
2011
+ if decl is None:
2012
+ return []
2013
+ bound, value = decl
2014
+ at = [line for bound_name, line in bound if bound_name == name]
2015
+ if at and declarations_only and value is not None and imported(value[1], value[0]):
2016
+ return []
2017
+ return at
2018
+
1593
2019
  for number, (text, code) in enumerate(zip(lines, code_lines), start=1):
1594
2020
  if quiet[number - 1] and not (decls or split or cont) and name not in code:
1595
2021
  continue # no rule below can match it: spares a scan per name
@@ -1637,7 +2063,8 @@ def _tsjs_definition_lines(
1637
2063
  found.append((number, top))
1638
2064
  continue
1639
2065
  top = _is_column_0(text)
1640
- # every rule that matches a line needs the name in it
2066
+ # every rule that matches a line needs the name in it, but for a
2067
+ # destructuring pattern, whose names may sit on the lines below
1641
2068
  named = name in code
1642
2069
  if named and is_statement(code, number):
1643
2070
  found.append((number, top))
@@ -1647,6 +2074,9 @@ def _tsjs_definition_lines(
1647
2074
  carry, pieces = declarators(m.group(1))
1648
2075
  if carry is not None:
1649
2076
  decls.append((top, *carry))
2077
+ # `export const { a, b: NAME } = obj;`, on one line or several:
2078
+ # each name's line, at the statement's column
2079
+ found.extend((line, top) for line in pattern_binds(number))
1650
2080
  if named and any(declares(p, number) for p in pieces):
1651
2081
  # a later declarator: `export const a = 1, NAME = 2;`
1652
2082
  found.append((number, top))
@@ -1741,6 +2171,42 @@ def _definition_matches(
1741
2171
  return _line_matches(suffix, path, lines, export_name, cache, declarations_only)
1742
2172
 
1743
2173
 
2174
+ def _folded_matches(
2175
+ source_file: str,
2176
+ export_name: str,
2177
+ source_root: Path,
2178
+ cache: _SourceCache | None = None,
2179
+ ) -> tuple[tuple[list[int], set[int]] | None, str | None]:
2180
+ """(`_definition_matches` of the name the line check judges, the base
2181
+ name a bookkeeping variant was folded to, else None).
2182
+
2183
+ The raw name comes first. Only when the file never names it (no whole
2184
+ identifier in its text, so no shape the rules miss can define it) and
2185
+ `fold_base` gives a base that some line matches are the base's matches
2186
+ returned, with the base; otherwise the raw name's, with None. So a real
2187
+ export named like a variant (`parse_exact` defined as itself, or bound
2188
+ in a shape the rules do not cover) is judged as written, and
2189
+ classify-stale, which never folds, does not call this.
2190
+ """
2191
+ cache = cache if cache is not None else _SourceCache()
2192
+ matches = _definition_matches(source_file, export_name, source_root, cache)
2193
+ if matches is None or matches[1]:
2194
+ return matches, None
2195
+ base = fold_base(export_name)
2196
+ if base is None:
2197
+ return matches, None
2198
+ # the variant's own identifiers: `mockChannel_def`, or `a11y_App` of `a11y_App.update`
2199
+ own = sorted(set(export_name.split(".")) - set(base.split(".")))
2200
+ named = re.compile(r"(?<![\w$])(?:" + "|".join(map(re.escape, own)) + r")(?![\w$])")
2201
+ lines = cache.lines((source_root / source_file.strip().replace("\\", "/")).resolve())
2202
+ if not own or lines is None or any(named.search(text) for text in lines):
2203
+ return matches, None
2204
+ folded = _definition_matches(source_file, base, source_root, cache)
2205
+ if folded is None or not folded[1]:
2206
+ return matches, None
2207
+ return folded, base
2208
+
2209
+
1744
2210
  def _line_matches(
1745
2211
  suffix: str,
1746
2212
  path: Path,
@@ -1766,7 +2232,7 @@ def _line_matches(
1766
2232
  code_lines = cache.js_code_lines(path, lines)
1767
2233
  found = _tsjs_definition_lines(
1768
2234
  lines, code_lines, name, dotted, cache.js_declarators, declarations_only,
1769
- cache.js_quiet_lines(path, code_lines),
2235
+ cache.js_quiet_lines(path, code_lines), cache.js_destructured(path, code_lines),
1770
2236
  )
1771
2237
  if declarations_only:
1772
2238
  top = {n for n, at_column_0 in found if at_column_0}
@@ -1790,21 +2256,63 @@ def find_definition_lines(
1790
2256
  return None if matches is None else matches[0]
1791
2257
 
1792
2258
 
2259
+ def _missed_definition_lines(
2260
+ source_file: str,
2261
+ export_name: str,
2262
+ source_root: Path,
2263
+ cache: _SourceCache,
2264
+ line: int,
2265
+ defs: list[int],
2266
+ every: set[int],
2267
+ ) -> list[int]:
2268
+ """The `definition_lines` a finding lists for a recorded `line` that is
2269
+ not one of `defs` (`_definition_matches` over the same `cache`).
2270
+
2271
+ A recorded line that is an indented match the column-0 rule dropped
2272
+ joins them, so the finding carries several lines and nobody moves it.
2273
+ Otherwise a Python file's `@overload` group gives its first line alone
2274
+ (`_python_overload_start`), the line to move the entry to, and any
2275
+ other answer gives `defs` as they are.
2276
+ """
2277
+ if line in every:
2278
+ return sorted(set(defs) | {line})
2279
+ rel = source_file.strip().replace("\\", "/")
2280
+ if Path(rel).suffix.lower() not in PYTHON_EXTENSIONS or len(defs) < 2:
2281
+ return defs
2282
+ path = (source_root / rel).resolve()
2283
+ lines = cache.lines(path)
2284
+ if lines is None:
2285
+ return defs
2286
+ first = _python_overload_start(
2287
+ lines, definition_name(export_name), defs, cache.py_statement_starts(path, lines)
2288
+ )
2289
+ return defs if first is None else [first]
2290
+
2291
+
1793
2292
  def definition_lines_report(
1794
2293
  source_file: str,
1795
2294
  export_name: str,
1796
2295
  source_root: Path,
1797
2296
  line: int | None = None,
1798
2297
  export_type: str | None = None,
2298
+ fold: bool = True,
1799
2299
  ) -> dict:
1800
2300
  """What `verify` would say about one entry's line (the definition-lines
1801
2301
  subcommand). Raises ValueError when the file exists but cannot be read.
1802
2302
 
1803
2303
  `line_check` is `file-missing`, `skipped-export-type` (a `module` or
1804
2304
  `package`, no definition line), `skipped-language` (no rule for the
1805
- extension) or `checked`. With `line`, a recorded line that is an
1806
- indented match the column-0 rule dropped joins `definition_lines`, as in
1807
- `verify`, so the answer carries several lines.
2305
+ extension) or `checked`. With `line`, a recorded line that is not a
2306
+ definition line gets the lines `verify` would list
2307
+ (`_missed_definition_lines`): an indented match the column-0 rule
2308
+ dropped joins `definition_lines`, so the answer carries several lines,
2309
+ and a Python `@overload` group gives its first line alone.
2310
+
2311
+ With `fold` (the default, as `verify` checks), a bookkeeping variant the
2312
+ file never names is checked against its base name (`_folded_matches`),
2313
+ and the report gains `folded_to`, the base, only when it folded.
2314
+ classify-stale passes `fold=False`: the fabricated-signature test judges
2315
+ the documented name as written.
1808
2316
  """
1809
2317
  report: dict = {
1810
2318
  "source_file": source_file,
@@ -1823,14 +2331,23 @@ def definition_lines_report(
1823
2331
  covered = suffix in PYTHON_EXTENSIONS or suffix in TSJS_EXTENSIONS
1824
2332
  report["line_check"] = "skipped-export-type" if covered else "skipped-language"
1825
2333
  return report
1826
- matches = _definition_matches(source_file, export_name, source_root)
2334
+ cache = _SourceCache()
2335
+ if fold:
2336
+ matches, folded_to = _folded_matches(source_file, export_name, source_root, cache)
2337
+ else:
2338
+ matches, folded_to = _definition_matches(source_file, export_name, source_root, cache), None
1827
2339
  if matches is None:
1828
2340
  raise ValueError(f"failed to read source file {source_root / rel}")
2341
+ if folded_to is not None:
2342
+ report["folded_to"] = folded_to
2343
+ judged = folded_to or export_name
1829
2344
  defs, every = matches
1830
2345
  if line is not None:
1831
2346
  report["line_is_definition"] = line in defs
1832
- if line not in defs and line in every:
1833
- defs = sorted(set(defs) | {line})
2347
+ if line not in defs:
2348
+ defs = _missed_definition_lines(
2349
+ source_file, judged, source_root, cache, line, defs, every
2350
+ )
1834
2351
  report["definition_lines"] = defs
1835
2352
  return report
1836
2353
 
@@ -1875,7 +2392,10 @@ def _entry_citation(entry: dict) -> str | None:
1875
2392
  def _stale_line_check(
1876
2393
  entry: dict, name: str, source_root: Path
1877
2394
  ) -> tuple[str, list[int] | None]:
1878
- """(line_check, definition_lines) of one provenance entry of a stale name."""
2395
+ """(line_check, definition_lines) of one provenance entry of a stale name.
2396
+
2397
+ The documented name is judged as written, with no bookkeeping-variant
2398
+ fold: a variant the cited file never defines stays fabricated."""
1879
2399
  source_file = _entry_file(entry)
1880
2400
  if source_file is None:
1881
2401
  # no file to check: it neither proves the name fabricated nor clears it
@@ -1889,6 +2409,7 @@ def _stale_line_check(
1889
2409
  source_root,
1890
2410
  line if isinstance(line, int) else None,
1891
2411
  export_type if isinstance(export_type, str) else None,
2412
+ fold=False,
1892
2413
  )
1893
2414
  except ValueError:
1894
2415
  # the file exists but cannot be read: nothing proves it
@@ -2161,8 +2682,8 @@ def baseline_gaps(
2161
2682
  ) -> tuple[list[dict], list[dict]]:
2162
2683
  """The provenance entries an extraction left out whose cited file still
2163
2684
  declares them: the recipe runner never reports a module, an alias that
2164
- renames, a dunder such as `__version__` or another underscore name, so
2165
- a diff would read each as removed.
2685
+ renames, a dunder such as `__version__` or another Python or Rust
2686
+ underscore name, so a diff would read each as removed.
2166
2687
 
2167
2688
  `held` holds the (name, file) of every export the extraction found,
2168
2689
  `files` the files whose entries are checked (the ones read), and
@@ -3245,6 +3766,7 @@ def verify(
3245
3766
  stale: list[dict] = []
3246
3767
  citations_checked = 0
3247
3768
  line_check_skipped = 0
3769
+ line_check_folded = 0
3248
3770
  if source_root is not None:
3249
3771
  cache = _SourceCache()
3250
3772
  entries = prov.get("entries")
@@ -3285,19 +3807,25 @@ def verify(
3285
3807
  if not line_rule_applies(sf, entry.get("export_type")):
3286
3808
  line_check_skipped += 1
3287
3809
  continue
3288
- matches = _definition_matches(sf, name, source_root, cache)
3810
+ # A bookkeeping variant the file never names is checked
3811
+ # against its base name (`folded_to`).
3812
+ matches, folded_to = _folded_matches(sf, name, source_root, cache)
3289
3813
  if matches is None:
3290
3814
  line_check_skipped += 1
3291
3815
  continue
3816
+ if folded_to is not None:
3817
+ line_check_folded += 1
3292
3818
  defs, every = matches
3293
3819
  if line not in defs:
3294
- if line in every:
3295
- # The recorded line is an indented match the
3296
- # column-0 rule dropped: keep it, so the finding
3297
- # lists several lines and nobody auto-moves it.
3298
- defs = sorted(set(defs) | {line})
3820
+ # An indented match the column-0 rule dropped joins
3821
+ # the lines, so nobody auto-moves it; an @overload
3822
+ # group gives its first line alone, the move target.
3299
3823
  item["reason"] = STALE_LINE_NOT_DEFINITION
3300
- item["definition_lines"] = defs
3824
+ item["definition_lines"] = _missed_definition_lines(
3825
+ sf, folded_to or name, source_root, cache, line, defs, every
3826
+ )
3827
+ if folded_to is not None:
3828
+ item["folded_to"] = folded_to
3301
3829
  stale.append(item)
3302
3830
  stale.sort(key=lambda s: (s["export_name"], s.get("source_file") or ""))
3303
3831
 
@@ -3339,6 +3867,7 @@ def verify(
3339
3867
  "stale_count": len(stale),
3340
3868
  "citations_checked": citations_checked,
3341
3869
  "line_check_skipped": line_check_skipped,
3870
+ "line_check_folded": line_check_folded,
3342
3871
  "stale_check": "checked"
3343
3872
  if source_root is not None
3344
3873
  else "skipped-no-source-root",