bmad-module-skill-forge 4.0.0 → 5.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 (85) 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/architecture.md +2 -2
  5. package/docs/concepts.md +4 -4
  6. package/docs/examples.md +32 -25
  7. package/docs/getting-started.md +3 -3
  8. package/docs/how-it-works.md +2 -2
  9. package/docs/index.md +1 -1
  10. package/docs/skill-model.md +41 -39
  11. package/docs/verifying-a-skill.md +41 -20
  12. package/docs/why-skf.md +1 -1
  13. package/docs/workflows.md +2 -2
  14. package/package.json +1 -1
  15. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
  16. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  17. package/src/shared/scripts/skf-build-change-manifest.py +881 -168
  18. package/src/shared/scripts/skf-count-tokens.py +54 -10
  19. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  20. package/src/shared/scripts/skf-emit-result-envelope.py +6 -5
  21. package/src/shared/scripts/skf-extract-public-api.py +439 -107
  22. package/src/shared/scripts/skf-extraction-inventory.py +10 -6
  23. package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
  24. package/src/shared/scripts/skf-parse-gaps.py +135 -26
  25. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  26. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  27. package/src/shared/scripts/skf-shard-body.py +239 -41
  28. package/src/shared/scripts/skf-validate-output.py +12 -3
  29. package/src/shared/scripts/skf-verify-provenance-completeness.py +470 -38
  30. package/src/shared/tool-requirements.yaml +8 -2
  31. package/src/skf-analyze-source/SKILL.md +2 -2
  32. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  33. package/src/skf-audit-skill/SKILL.md +2 -2
  34. package/src/skf-audit-skill/references/re-index.md +2 -2
  35. package/src/skf-brief-skill/SKILL.md +2 -2
  36. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  37. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  38. package/src/skf-campaign/SKILL.md +2 -2
  39. package/src/skf-campaign/references/step-05-skill-loop.md +6 -1
  40. package/src/skf-create-skill/SKILL.md +2 -2
  41. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  42. package/src/skf-create-skill/references/extract.md +2 -1
  43. package/src/skf-create-skill/references/extraction-patterns.md +3 -3
  44. package/src/skf-create-skill/references/report.md +6 -1
  45. package/src/skf-create-stack-skill/SKILL.md +2 -2
  46. package/src/skf-create-stack-skill/references/validate.md +1 -1
  47. package/src/skf-drop-skill/SKILL.md +2 -2
  48. package/src/skf-export-skill/SKILL.md +2 -2
  49. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  50. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  51. package/src/skf-export-skill/references/load-skill.md +1 -1
  52. package/src/skf-export-skill/references/token-report.md +29 -9
  53. package/src/skf-forger/SKILL.md +2 -2
  54. package/src/skf-quick-skill/SKILL.md +2 -2
  55. package/src/skf-refine-architecture/SKILL.md +6 -8
  56. package/src/skf-rename-skill/SKILL.md +2 -2
  57. package/src/skf-setup/SKILL.md +2 -2
  58. package/src/skf-test-skill/SKILL.md +2 -2
  59. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  60. package/src/skf-test-skill/references/coherence-check.md +4 -3
  61. package/src/skf-test-skill/references/coverage-check-branches.md +61 -0
  62. package/src/skf-test-skill/references/coverage-check-tiers.md +1 -1
  63. package/src/skf-test-skill/references/coverage-check.md +45 -69
  64. package/src/skf-test-skill/references/init.md +2 -1
  65. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  66. package/src/skf-test-skill/references/report.md +2 -2
  67. package/src/skf-test-skill/references/scoring-rules.md +5 -4
  68. package/src/skf-test-skill/references/source-access-protocol.md +6 -6
  69. package/src/skf-test-skill/scripts/gap-ledger.py +378 -163
  70. package/src/skf-test-skill/scripts/load-coverage-inputs.py +171 -70
  71. package/src/skf-test-skill/scripts/locate-export-segments.py +101 -20
  72. package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
  73. package/src/skf-test-skill/scripts/score-signatures.py +329 -54
  74. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  75. package/src/skf-test-skill/scripts/validate-inventory.py +108 -9
  76. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  77. package/src/skf-update-skill/SKILL.md +2 -2
  78. package/src/skf-update-skill/references/detect-changes.md +1 -1
  79. package/src/skf-update-skill/references/gap-driven.md +36 -21
  80. package/src/skf-update-skill/references/merge.md +15 -10
  81. package/src/skf-update-skill/references/re-extract.md +3 -1
  82. package/src/skf-update-skill/references/report.md +5 -4
  83. package/src/skf-update-skill/references/write.md +39 -18
  84. package/src/skf-verify-stack/SKILL.md +2 -2
  85. package/tools/cli/lib/tool-check.js +158 -2
@@ -89,7 +89,19 @@ name (`App.update`) it is the last segment. The rules follow the
89
89
  a keyword argument or a parameter never matches; a file that does not
90
90
  tokenize is matched line by line. A `from X import *` line counts only
91
91
  when no other line of the file defines NAME (a re-export the
92
- extraction could not trace is cited at its star import).
92
+ extraction could not trace is cited at its star import). An
93
+ `@overload` group, where every line found is a `def` / `async def
94
+ NAME` line at one indentation in one enclosing block (one class, not
95
+ two) and every one but at most the last has `@overload` or
96
+ `@<module>.overload` (`@typing.overload`,
97
+ `@typing_extensions.overload`, `@t.overload` after
98
+ `import typing as t`) in its contiguous decorator block, defines NAME
99
+ at each of its lines, the stubs and the implementation. A comment line
100
+ may sit in the decorator block, and so may a decorator's arguments on
101
+ lines of their own, unless the file does not tokenize. A recorded line
102
+ that misses the group gives its first line alone, which is its first
103
+ stub: the public-API runner, its merge rule and the structural diff
104
+ all record that line, so `fix` can move the entry there.
93
105
  - TS/JS (`.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, `.cjs`): a
94
106
  `function` (or `function*`), `class`, `interface`, `type`, `enum`,
95
107
  `namespace`, `const`, `let` or `var` declaration of NAME, with any of
@@ -98,7 +110,11 @@ name (`App.update`) it is the last segment. The rules follow the
98
110
  and `class`; a name inside a decorator's arguments is not declared);
99
111
  a later declarator of a `const`, `let` or `var` list
100
112
  (`export const a = 1, NAME = 2;`, or `export const a = 1,` then
101
- ` NAME = 2;` as Prettier writes it: see Declarator lists below); NAME
113
+ ` NAME = 2;` as Prettier writes it: see Declarator lists below);
114
+ NAME bound by a destructuring pattern that begins such a list
115
+ (`export const { a, b: NAME } = obj;`, `const [NAME] = list`, the
116
+ pattern on one line or several), on the line where NAME appears (see
117
+ Declarator lists below); NAME
102
118
  on the line right after a line that ends in its declaration keyword
103
119
  (`export function`, `export interface`, `export const` and so on),
104
120
  which is the line cited;
@@ -125,14 +141,23 @@ name (`App.update`) it is the last segment. The rules follow the
125
141
  Declarator lists: a `const`, `let` or `var` list is split at its
126
142
  top-level commas, outside strings, regular expression literals and
127
143
  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
144
+ a name), up to a top-level `;`. The list goes on to the next line when a
145
+ line ends inside a bracket, after a top-level comma or after an operator
146
+ waiting for its operand, or when the next line starts with an operator (a
147
+ member chain or a ternary broken over lines), so a declarator on a line
148
+ of its own counts. A `<` where an expression begins (JSX, a type assertion, a generic
134
149
  arrow function's type parameters) is not followed: no later declarator of
135
- the list counts after it.
150
+ the list counts after it. A destructuring pattern that begins the list,
151
+ on the keyword's line, declares the names it binds: a shorthand key, the
152
+ target after `key:`, an array element, a rest `...NAME` and the names of
153
+ a nested pattern, each on the line where it appears (the pattern may
154
+ span lines), with its statement's column. A renamed or computed key, a
155
+ default value, the type annotation after the pattern and anything inside
156
+ a string or a comment bind nothing, and neither does a pattern that does
157
+ not close. The later declarators after a pattern count as any others. A
158
+ pattern that is a later declarator (`export const a = 1, { b } = c`) or
159
+ that sits on the line after a split keyword (`export const`, then
160
+ ` { b } = c;`) is not covered: it declares nothing.
136
161
 
137
162
  Indentation: for a dotted name every match counts. For an undotted name,
138
163
  matches at any indentation count, but when the file has a column-0 match
@@ -140,7 +165,8 @@ the indented ones are dropped, so a local shadow never outranks the
140
165
  module-level definition. A line inside a multi-line import, export list
141
166
  or `module.exports` object takes the column of the line its statement
142
167
  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
168
+ keyword, a later declarator on a line of its own and the line of a name
169
+ a destructuring pattern binds. When the recorded
144
170
  line is itself an indented match that this rule dropped (a method
145
171
  recorded under an undotted name beside a module-level function of the
146
172
  same name), the finding lists the recorded line too, so it carries
@@ -149,7 +175,11 @@ several lines and no caller auto-moves it.
149
175
  Entries in other files, and entries whose `export_type` is `module` or
150
176
  `package` (case-insensitive; neither has a definition line), are skipped
151
177
  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
178
+ lists every defining line in the file in `definition_lines`, the recorded
179
+ line included when it is an indented match the column-0 rule dropped (see
180
+ Indentation). The one exception is a Python `@overload` group: it lists
181
+ the group's first line alone, unless the recorded line is such a dropped
182
+ indented match, which still gives several lines. An empty list
153
183
  means the rules found no definition line, which may be a shape they do not
154
184
  cover: the entry is unverified, not gone, and no caller auto-fixes it.
155
185
 
@@ -387,7 +417,8 @@ Subcommands:
387
417
  }
388
418
 
389
419
  With `--line`, a recorded line that is an indented match the column-0
390
- rule dropped joins `definition_lines`, as in `verify`.
420
+ rule dropped joins `definition_lines`, and one that misses a Python
421
+ `@overload` group gives its first line alone, as in `verify`.
391
422
 
392
423
  classify-stale --names <coverage.json> --provenance <provenance-map.json>
393
424
  --source-root <path> [--inventory <inventory.json>]
@@ -450,8 +481,9 @@ Subcommands:
450
481
  `export default NAME`, an export-list item without `as`, a
451
482
  `module.exports` shorthand key, a binding whose value is an import
452
483
  (`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
484
+ `const NAME = require(...)`, `const { NAME } = require(...)`,
485
+ `exports.NAME = require(...)`, `{ NAME: require(...) }`), an
486
+ indented line and any line of a Python
455
487
  file that does not tokenize never count. A module or package named
456
488
  NAME (`NAME.py`, `NAME/__init__.py`, a TS/JS `NAME.<ext>` or
457
489
  `NAME/index.<ext>`) declares NAME too: at its line 1 when no line of it
@@ -599,6 +631,7 @@ Exit codes:
599
631
  from __future__ import annotations
600
632
 
601
633
  import argparse
634
+ import bisect
602
635
  import copy
603
636
  import importlib.util
604
637
  import io
@@ -913,6 +946,10 @@ _PY_SPLIT_DECL_RE = re.compile(r"^\s*(?:async\s+def|def|class)\s*\\$")
913
946
  # A line that can matter to a name it does not hold: an import (which may go
914
947
  # on over the next lines) or a `def \` / `class \` whose name is on the next.
915
948
  _PY_NAMELESS_MATCH_RE = re.compile(r"^\s*(?:from|import|async|def|class)\b")
949
+ # The decorator of an `@overload` stub, imported by name or used through its
950
+ # module under any name (`typing`, `typing_extensions`, `t` after
951
+ # `import typing as t`).
952
+ _PY_OVERLOAD_RE = re.compile(r"^\s*@\s*(?:[A-Za-z_]\w*\.)?overload\s*$")
916
953
  # Tokens that never begin a statement.
917
954
  _PY_LAYOUT_TOKENS = frozenset(
918
955
  {
@@ -989,6 +1026,11 @@ _DECL_MAYBE = "maybe"
989
1026
  # What `_js_declarators` gives: None, or (brackets left open, how) the list
990
1027
  # goes on with, and the pieces that begin a declarator.
991
1028
  _DeclSplit = tuple[tuple[str, str] | None, list[str]]
1029
+ # What `_js_destructured` gives for a `const` / `let` / `var` statement
1030
+ # whose first declarator is a destructuring pattern: the (name, line) of
1031
+ # each name it binds, and the (line, text after the `=` on that line) its
1032
+ # initializer starts at, None when it has none.
1033
+ _PatternDecl = tuple[list[tuple[str, int]], tuple[int, str] | None]
992
1034
  # After one of these, `/` begins a regular expression and `<` begins JSX, a
993
1035
  # type assertion or a generic arrow function's type parameters.
994
1036
  _JS_EXPRESSION_START = frozenset("([{,;=?:&|^%+-*/<>!~")
@@ -1105,15 +1147,17 @@ def _js_code_lines(lines: list[str]) -> list[str]:
1105
1147
 
1106
1148
 
1107
1149
  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)."""
1150
+ """Source lines, Python statement starts, TS/JS comment-free lines and
1151
+ the names TS/JS destructuring patterns bind, read once per file, and
1152
+ TS/JS declarator lists, split once per line (neither depends on the
1153
+ export looked up)."""
1111
1154
 
1112
1155
  def __init__(self) -> None:
1113
1156
  self._lines: dict[Path, list[str] | None] = {}
1114
1157
  self._starts: dict[Path, set[int] | None] = {}
1115
1158
  self._js_code: dict[Path, list[str]] = {}
1116
1159
  self._js_quiet: dict[Path, list[bool]] = {}
1160
+ self._js_patterns: dict[Path, dict[int, _PatternDecl]] = {}
1117
1161
  self._js_decl: dict[tuple[str, str, bool], _DeclSplit] = {}
1118
1162
 
1119
1163
  def lines(self, path: Path) -> list[str] | None:
@@ -1136,6 +1180,13 @@ class _SourceCache:
1136
1180
  self._js_quiet[path] = _js_quiet_lines(code_lines)
1137
1181
  return self._js_quiet[path]
1138
1182
 
1183
+ def js_destructured(
1184
+ self, path: Path, code_lines: list[str]
1185
+ ) -> dict[int, _PatternDecl]:
1186
+ if path not in self._js_patterns:
1187
+ self._js_patterns[path] = _js_destructured(code_lines)
1188
+ return self._js_patterns[path]
1189
+
1139
1190
  def js_declarators(
1140
1191
  self, text: str, opened: str = "", fresh: bool = True
1141
1192
  ) -> _DeclSplit:
@@ -1269,6 +1320,76 @@ def _python_definition_lines(
1269
1320
  return found or star_imports
1270
1321
 
1271
1322
 
1323
+ def _py_overloaded(lines: list[str], number: int, starts: set[int] | None) -> bool:
1324
+ """True when the `def` on line `number` has `@overload` or
1325
+ `@<module>.overload` (`@typing.overload`, `@t.overload`) in its
1326
+ contiguous decorator block: the decorator lines at its indentation
1327
+ right above it, up to a blank line or a line that is not a decorator.
1328
+ A comment line is passed over, and so, in a file that tokenizes, is a
1329
+ line on which no statement begins (a decorator's arguments over
1330
+ several lines)."""
1331
+ text = lines[number - 1]
1332
+ indent = text[: len(text) - len(text.lstrip())]
1333
+ i = number - 1
1334
+ while i >= 1:
1335
+ text = lines[i - 1]
1336
+ code = text.split("#", 1)[0]
1337
+ if not text.strip():
1338
+ return False
1339
+ if not code.strip() or (starts is not None and i not in starts):
1340
+ i -= 1
1341
+ continue
1342
+ if not code.lstrip().startswith("@") or text[: len(text) - len(text.lstrip())] != indent:
1343
+ return False
1344
+ if _PY_OVERLOAD_RE.match(code):
1345
+ return True
1346
+ i -= 1
1347
+ return False
1348
+
1349
+
1350
+ def _py_enclosing_line(
1351
+ lines: list[str], number: int, indent: str, starts: set[int] | None
1352
+ ) -> int | None:
1353
+ """The line of the block that holds the statement on line `number`,
1354
+ indented by `indent`: the nearest line above it where a statement
1355
+ begins with less indentation. None at module level."""
1356
+ i = number - 1
1357
+ while indent and i >= 1:
1358
+ text = lines[i - 1]
1359
+ if text.split("#", 1)[0].strip() and (starts is None or i in starts):
1360
+ if len(text) - len(text.lstrip()) < len(indent):
1361
+ return i
1362
+ i -= 1
1363
+ return None
1364
+
1365
+
1366
+ def _python_overload_start(
1367
+ lines: list[str], name: str, defs: list[int], starts: set[int] | None
1368
+ ) -> int | None:
1369
+ """The first of `defs` when they are one `@overload` group: two lines
1370
+ or more, each a `def` / `async def NAME` line, all at one indentation
1371
+ in one enclosing block (`_py_enclosing_line`, so two classes'
1372
+ methods never make one group), and each but at most the last
1373
+ decorated as an overload (`_py_overloaded`). None otherwise."""
1374
+ if len(defs) < 2:
1375
+ return None
1376
+ decl = re.compile(rf"^(\s*)(?:async\s+def|def)\s+{re.escape(name)}(?!\w)")
1377
+ indents = set()
1378
+ for number in defs:
1379
+ m = decl.match(lines[number - 1].split("#", 1)[0])
1380
+ if m is None:
1381
+ return None
1382
+ indents.add(m.group(1))
1383
+ if len(indents) > 1:
1384
+ return None
1385
+ (indent,) = indents
1386
+ if len({_py_enclosing_line(lines, number, indent, starts) for number in defs}) > 1:
1387
+ return None
1388
+ if not all(_py_overloaded(lines, number, starts) for number in defs[:-1]):
1389
+ return None
1390
+ return defs[0]
1391
+
1392
+
1272
1393
  def _js_export_item_binds(item: str, name: str, alias_only: bool = False) -> bool:
1273
1394
  """True when one `export { ... }` item exposes `name`: the name after
1274
1395
  `as`, else (unless `alias_only`) the item's own name, an identifier or
@@ -1469,6 +1590,252 @@ def _js_paren_tail_is_call(after_open: str) -> bool:
1469
1590
  return False
1470
1591
 
1471
1592
 
1593
+ def _js_pattern_bindings(
1594
+ text: str, start: int
1595
+ ) -> tuple[list[tuple[str, int]], int | None]:
1596
+ """The names the destructuring pattern whose `{` or `[` is at
1597
+ `text[start]` binds, each with its offset in `text`, and the offset
1598
+ just past the pattern's closing bracket.
1599
+
1600
+ An object pattern's property is `...target`, a shorthand key or
1601
+ `key: target` (a key is a name, a string, a number or a computed
1602
+ `[...]`, and only a shorthand key binds itself); an array pattern's
1603
+ element is `...target`, a target or a hole; a target is a name or a
1604
+ nested pattern. A `= default` is skipped to the next comma at the
1605
+ pattern's depth, and so is a property or element of any other shape,
1606
+ which binds nothing. '...', "..." and `...` strings are skipped
1607
+ whole, so no bracket or comma inside one counts. Whitespace, newlines
1608
+ included, may sit anywhere between the parts.
1609
+
1610
+ Returns ([], None) when `text[start]` opens no pattern, the pattern
1611
+ does not close in `text`, a string in it does not end, or a bracket in
1612
+ it closes one it did not open. A pure function of its arguments (no
1613
+ module state), so another script can carry a copy of it.
1614
+ """
1615
+ n = len(text)
1616
+ names: list[tuple[str, int]] = []
1617
+
1618
+ def space(i: int) -> int:
1619
+ while i < n and text[i].isspace():
1620
+ i += 1
1621
+ return i
1622
+
1623
+ def name_end(i: int) -> int:
1624
+ """The end of the identifier at `i`, `i` itself when none starts there."""
1625
+ if i < n and (text[i].isalpha() or text[i] in "_$"):
1626
+ i += 1
1627
+ while i < n and (text[i].isalnum() or text[i] in "_$"):
1628
+ i += 1
1629
+ return i
1630
+
1631
+ def string_end(i: int) -> int | None:
1632
+ """The offset past the string whose quote is at `i`."""
1633
+ quote = text[i]
1634
+ i += 1
1635
+ while i < n:
1636
+ if text[i] == "\\":
1637
+ i += 2
1638
+ elif text[i] == quote:
1639
+ return i + 1
1640
+ else:
1641
+ i += 1
1642
+ return None
1643
+
1644
+ def skip(i: int, to_comma: bool = True) -> int | None:
1645
+ """From `i`, the offset of the next comma (unless not `to_comma`)
1646
+ or closing bracket at depth 0; with `to_comma` False, `text[i]`
1647
+ opens a bracket and the offset past the one closing it."""
1648
+ depth = 0
1649
+ while i < n:
1650
+ ch = text[i]
1651
+ if ch in "'\"`":
1652
+ end = string_end(i)
1653
+ if end is None:
1654
+ return None
1655
+ i = end
1656
+ continue
1657
+ if ch in "([{":
1658
+ depth += 1
1659
+ elif ch in ")]}":
1660
+ if depth == 0:
1661
+ return i
1662
+ depth -= 1
1663
+ if depth == 0 and not to_comma:
1664
+ return i + 1
1665
+ elif ch == "," and depth == 0 and to_comma:
1666
+ return i
1667
+ i += 1
1668
+ return None
1669
+
1670
+ def default(i: int) -> int | None:
1671
+ """After a target: past its `= default`, when it has one."""
1672
+ i = space(i)
1673
+ if i < n and text[i] == "=" and text[i + 1 : i + 2] not in ("=", ">"):
1674
+ return skip(i + 1)
1675
+ return i
1676
+
1677
+ def bind(i: int, end: int, close: str) -> int | None:
1678
+ """The name `text[i:end]` is bound when its property or element
1679
+ ends after it or takes a default (any other text: another shape)."""
1680
+ after = space(end)
1681
+ if after < n and (
1682
+ text[after] in (",", close)
1683
+ or text[after] == "=" and text[after + 1 : after + 2] not in ("=", ">")
1684
+ ):
1685
+ names.append((text[i:end], i))
1686
+ return default(after)
1687
+ return after
1688
+
1689
+ def target(i: int, close: str) -> int | None:
1690
+ if i < n and text[i] in "{[":
1691
+ end = pattern(i)
1692
+ return None if end is None else default(end)
1693
+ end = name_end(i)
1694
+ return i if end == i else bind(i, end, close)
1695
+
1696
+ def element(i: int, close: str) -> int | None:
1697
+ """One property or element at `i`: the offset after it."""
1698
+ if text.startswith("...", i):
1699
+ return target(space(i + 3), close)
1700
+ if close == "]":
1701
+ return target(i, close)
1702
+ if text[i] in "'\"":
1703
+ end = string_end(i)
1704
+ elif text[i] == "[":
1705
+ end = skip(i, to_comma=False) # a computed key
1706
+ else:
1707
+ end = name_end(i)
1708
+ if end > i:
1709
+ after = space(end)
1710
+ if after < n and text[after] == ":":
1711
+ return target(space(after + 1), close)
1712
+ return bind(i, end, close) # a shorthand key
1713
+ while end < n and (text[end].isalnum() or text[end] in "._"):
1714
+ end += 1 # a number key
1715
+ if end is None:
1716
+ return None
1717
+ after = space(end)
1718
+ if end > i and after < n and text[after] == ":":
1719
+ return target(space(after + 1), close)
1720
+ return after
1721
+
1722
+ def pattern(i: int) -> int | None:
1723
+ """The pattern opening at `i`: the offset past its closing bracket."""
1724
+ close = "}" if text[i] == "{" else "]"
1725
+ i += 1
1726
+ while True:
1727
+ i = space(i)
1728
+ if i >= n:
1729
+ return None
1730
+ if text[i] == close:
1731
+ return i + 1
1732
+ if text[i] in ")]}":
1733
+ return None
1734
+ if text[i] == ",":
1735
+ i += 1 # a hole, or a stray comma
1736
+ continue
1737
+ end = element(i, close)
1738
+ if end is None:
1739
+ return None
1740
+ end = space(end)
1741
+ if end < n and text[end] not in (",", close):
1742
+ end = skip(end) # any other shape binds nothing
1743
+ if end is None:
1744
+ return None
1745
+ i = end + 1 if end < n and text[end] == "," else end
1746
+
1747
+ if not (0 <= start < n and text[start] in "{["):
1748
+ return [], None
1749
+ end = pattern(start)
1750
+ return (names, end) if end is not None else ([], None)
1751
+
1752
+
1753
+ def _js_initializer_start(text: str, end: int) -> int | None:
1754
+ """The offset just past the `=` that begins the initializer of a
1755
+ declarator whose name or pattern ends at `end`, a type annotation
1756
+ (`: T`, brackets, strings and `=>` in it included) skipped. None when
1757
+ neither `=` nor `:` follows, or when a `,` or `;` outside the
1758
+ annotation's brackets, or the end of `text`, comes first."""
1759
+ n = len(text)
1760
+ i = end
1761
+ while i < n and text[i].isspace():
1762
+ i += 1
1763
+ if i < n and text[i] == "=":
1764
+ return None if text[i + 1 : i + 2] == "=" else i + 1
1765
+ if not (i < n and text[i] == ":"):
1766
+ return None
1767
+ i += 1
1768
+ depth = 0
1769
+ while i < n:
1770
+ ch = text[i]
1771
+ if ch in "'\"`":
1772
+ i += 1
1773
+ while i < n and text[i] != ch:
1774
+ i += 2 if text[i] == "\\" else 1
1775
+ elif text.startswith("=>", i):
1776
+ i += 1
1777
+ elif ch in "([{<":
1778
+ depth += 1
1779
+ elif ch in ")]}>":
1780
+ depth -= 1
1781
+ if depth < 0:
1782
+ return None
1783
+ elif ch in ",;" and depth == 0:
1784
+ return None
1785
+ elif ch == "=" and depth == 0:
1786
+ return None if text[i + 1 : i + 2] == "=" else i + 1
1787
+ i += 1
1788
+ return None
1789
+
1790
+
1791
+ def _js_destructured(code_lines: list[str]) -> dict[int, _PatternDecl]:
1792
+ """For each line that opens a `const` / `let` / `var` statement whose
1793
+ first declarator is a destructuring pattern (`_JS_VAR_DECL_RE`, the
1794
+ rest starting with `{` or `[`): the names the pattern binds, each with
1795
+ the line it appears on, and where its initializer starts.
1796
+
1797
+ The patterns are read from the comment-free lines joined with `\\n`
1798
+ (`_js_pattern_bindings`), so a pattern may span lines and a name's
1799
+ offset maps back to its line. A pattern that does not close, or that
1800
+ nests deeper than the interpreter's recursion limit, binds nothing and
1801
+ gives no item.
1802
+ """
1803
+ starts: list[tuple[int, int]] = []
1804
+ for number, code in enumerate(code_lines, start=1):
1805
+ if "{" not in code and "[" not in code:
1806
+ continue
1807
+ m = _JS_VAR_DECL_RE.match(code)
1808
+ if m is not None and m.group(1)[:1] in ("{", "["):
1809
+ starts.append((number, m.start(1)))
1810
+ if not starts:
1811
+ return {}
1812
+ text = "\n".join(code_lines)
1813
+ offsets: list[int] = []
1814
+ pos = 0
1815
+ for code in code_lines:
1816
+ offsets.append(pos)
1817
+ pos += len(code) + 1
1818
+
1819
+ def line_of(offset: int) -> int:
1820
+ return bisect.bisect_right(offsets, offset)
1821
+
1822
+ out: dict[int, _PatternDecl] = {}
1823
+ for number, column in starts:
1824
+ try:
1825
+ names, end = _js_pattern_bindings(text, offsets[number - 1] + column)
1826
+ except RecursionError:
1827
+ continue # patterns nested past the interpreter's limit: none bound
1828
+ if end is None:
1829
+ continue
1830
+ value = _js_initializer_start(text, end)
1831
+ at = None
1832
+ if value is not None:
1833
+ line = line_of(value)
1834
+ at = (line, code_lines[line - 1][value - offsets[line - 1] :])
1835
+ out[number] = ([(name, line_of(offset)) for name, offset in names], at)
1836
+ return out
1837
+
1838
+
1472
1839
  def _tsjs_definition_lines(
1473
1840
  lines: list[str],
1474
1841
  code_lines: list[str],
@@ -1477,6 +1844,7 @@ def _tsjs_definition_lines(
1477
1844
  declarators: Callable[..., _DeclSplit] = _js_declarators,
1478
1845
  declarations_only: bool = False,
1479
1846
  quiet: list[bool] | None = None,
1847
+ destructured: dict[int, _PatternDecl] | None = None,
1480
1848
  ) -> list[tuple[int, bool]]:
1481
1849
  """(line, at column 0) for every line that defines `name`.
1482
1850
 
@@ -1486,15 +1854,20 @@ def _tsjs_definition_lines(
1486
1854
  JSDoc line or a commented-out declaration never matches. A line inside
1487
1855
  a multi-line `export { ... }` list or `module.exports = { ... }` object
1488
1856
  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
1857
+ name line of a declaration whose keyword ends the line above it, a
1858
+ later declarator on a line of its own and the line of a name that a
1859
+ destructuring pattern binds. `destructured` is
1860
+ `_js_destructured(code_lines)` when the caller has it (it does not
1861
+ depend on the name either): a name its pattern binds counts when the
1862
+ `const` / `let` / `var` line it is keyed by opens a statement here.
1863
+ Class members count only for a
1491
1864
  dotted export name. `declarators` splits a declarator list
1492
1865
  (`_js_declarators`, or `_SourceCache.js_declarators`, which splits each
1493
1866
  line once for every export looked up). With `declarations_only`,
1494
1867
  `export import NAME =`, `export default NAME`, an export-list item
1495
1868
  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
1869
+ import-valued binding (a declarator, a destructured name, `exports.NAME =`
1870
+ or a `module.exports` key whose value starts with `require(` or an
1498
1871
  `(await) import(`, on its line or the next one) never count, nor does
1499
1872
  a class member.
1500
1873
  """
@@ -1515,7 +1888,8 @@ def _tsjs_definition_lines(
1515
1888
  ]
1516
1889
  # One declarator of a `const` / `let` / `var` list: `NAME = v`,
1517
1890
  # `NAME: T = v`, `NAME!: T`, or a bare `NAME`. A destructuring pattern
1518
- # starts with `{` or `[` and never matches.
1891
+ # starts with `{` or `[` and never matches: the names a pattern that
1892
+ # begins the list binds come from `destructured` (`_js_pattern_bindings`).
1519
1893
  declarator = re.compile(rf"^\s*{esc}{end}\s*(?:!?\s*:|=|$)")
1520
1894
  # With declarations_only, the value bound to NAME (the rest of a
1521
1895
  # declarator, of `exports.NAME =` or of a `module.exports` key), to
@@ -1590,6 +1964,22 @@ def _tsjs_definition_lines(
1590
1964
  decls: list[tuple[bool, str, str]] = []
1591
1965
  if quiet is None:
1592
1966
  quiet = _js_quiet_lines(code_lines)
1967
+ if destructured is None:
1968
+ destructured = _js_destructured(code_lines)
1969
+
1970
+ def pattern_binds(number: int) -> list[int]:
1971
+ """The lines on which the pattern that begins the `const` / `let` /
1972
+ `var` list of line `number` binds `name` (none with
1973
+ `declarations_only` when its value is an import)."""
1974
+ decl = destructured.get(number)
1975
+ if decl is None:
1976
+ return []
1977
+ bound, value = decl
1978
+ at = [line for bound_name, line in bound if bound_name == name]
1979
+ if at and declarations_only and value is not None and imported(value[1], value[0]):
1980
+ return []
1981
+ return at
1982
+
1593
1983
  for number, (text, code) in enumerate(zip(lines, code_lines), start=1):
1594
1984
  if quiet[number - 1] and not (decls or split or cont) and name not in code:
1595
1985
  continue # no rule below can match it: spares a scan per name
@@ -1637,7 +2027,8 @@ def _tsjs_definition_lines(
1637
2027
  found.append((number, top))
1638
2028
  continue
1639
2029
  top = _is_column_0(text)
1640
- # every rule that matches a line needs the name in it
2030
+ # every rule that matches a line needs the name in it, but for a
2031
+ # destructuring pattern, whose names may sit on the lines below
1641
2032
  named = name in code
1642
2033
  if named and is_statement(code, number):
1643
2034
  found.append((number, top))
@@ -1647,6 +2038,9 @@ def _tsjs_definition_lines(
1647
2038
  carry, pieces = declarators(m.group(1))
1648
2039
  if carry is not None:
1649
2040
  decls.append((top, *carry))
2041
+ # `export const { a, b: NAME } = obj;`, on one line or several:
2042
+ # each name's line, at the statement's column
2043
+ found.extend((line, top) for line in pattern_binds(number))
1650
2044
  if named and any(declares(p, number) for p in pieces):
1651
2045
  # a later declarator: `export const a = 1, NAME = 2;`
1652
2046
  found.append((number, top))
@@ -1766,7 +2160,7 @@ def _line_matches(
1766
2160
  code_lines = cache.js_code_lines(path, lines)
1767
2161
  found = _tsjs_definition_lines(
1768
2162
  lines, code_lines, name, dotted, cache.js_declarators, declarations_only,
1769
- cache.js_quiet_lines(path, code_lines),
2163
+ cache.js_quiet_lines(path, code_lines), cache.js_destructured(path, code_lines),
1770
2164
  )
1771
2165
  if declarations_only:
1772
2166
  top = {n for n, at_column_0 in found if at_column_0}
@@ -1790,6 +2184,39 @@ def find_definition_lines(
1790
2184
  return None if matches is None else matches[0]
1791
2185
 
1792
2186
 
2187
+ def _missed_definition_lines(
2188
+ source_file: str,
2189
+ export_name: str,
2190
+ source_root: Path,
2191
+ cache: _SourceCache,
2192
+ line: int,
2193
+ defs: list[int],
2194
+ every: set[int],
2195
+ ) -> list[int]:
2196
+ """The `definition_lines` a finding lists for a recorded `line` that is
2197
+ not one of `defs` (`_definition_matches` over the same `cache`).
2198
+
2199
+ A recorded line that is an indented match the column-0 rule dropped
2200
+ joins them, so the finding carries several lines and nobody moves it.
2201
+ Otherwise a Python file's `@overload` group gives its first line alone
2202
+ (`_python_overload_start`), the line to move the entry to, and any
2203
+ other answer gives `defs` as they are.
2204
+ """
2205
+ if line in every:
2206
+ return sorted(set(defs) | {line})
2207
+ rel = source_file.strip().replace("\\", "/")
2208
+ if Path(rel).suffix.lower() not in PYTHON_EXTENSIONS or len(defs) < 2:
2209
+ return defs
2210
+ path = (source_root / rel).resolve()
2211
+ lines = cache.lines(path)
2212
+ if lines is None:
2213
+ return defs
2214
+ first = _python_overload_start(
2215
+ lines, definition_name(export_name), defs, cache.py_statement_starts(path, lines)
2216
+ )
2217
+ return defs if first is None else [first]
2218
+
2219
+
1793
2220
  def definition_lines_report(
1794
2221
  source_file: str,
1795
2222
  export_name: str,
@@ -1802,9 +2229,11 @@ def definition_lines_report(
1802
2229
 
1803
2230
  `line_check` is `file-missing`, `skipped-export-type` (a `module` or
1804
2231
  `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.
2232
+ extension) or `checked`. With `line`, a recorded line that is not a
2233
+ definition line gets the lines `verify` would list
2234
+ (`_missed_definition_lines`): an indented match the column-0 rule
2235
+ dropped joins `definition_lines`, so the answer carries several lines,
2236
+ and a Python `@overload` group gives its first line alone.
1808
2237
  """
1809
2238
  report: dict = {
1810
2239
  "source_file": source_file,
@@ -1823,14 +2252,17 @@ def definition_lines_report(
1823
2252
  covered = suffix in PYTHON_EXTENSIONS or suffix in TSJS_EXTENSIONS
1824
2253
  report["line_check"] = "skipped-export-type" if covered else "skipped-language"
1825
2254
  return report
1826
- matches = _definition_matches(source_file, export_name, source_root)
2255
+ cache = _SourceCache()
2256
+ matches = _definition_matches(source_file, export_name, source_root, cache)
1827
2257
  if matches is None:
1828
2258
  raise ValueError(f"failed to read source file {source_root / rel}")
1829
2259
  defs, every = matches
1830
2260
  if line is not None:
1831
2261
  report["line_is_definition"] = line in defs
1832
- if line not in defs and line in every:
1833
- defs = sorted(set(defs) | {line})
2262
+ if line not in defs:
2263
+ defs = _missed_definition_lines(
2264
+ source_file, export_name, source_root, cache, line, defs, every
2265
+ )
1834
2266
  report["definition_lines"] = defs
1835
2267
  return report
1836
2268
 
@@ -2161,8 +2593,8 @@ def baseline_gaps(
2161
2593
  ) -> tuple[list[dict], list[dict]]:
2162
2594
  """The provenance entries an extraction left out whose cited file still
2163
2595
  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.
2596
+ renames, a dunder such as `__version__` or another Python or Rust
2597
+ underscore name, so a diff would read each as removed.
2166
2598
 
2167
2599
  `held` holds the (name, file) of every export the extraction found,
2168
2600
  `files` the files whose entries are checked (the ones read), and
@@ -3291,13 +3723,13 @@ def verify(
3291
3723
  continue
3292
3724
  defs, every = matches
3293
3725
  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})
3726
+ # An indented match the column-0 rule dropped joins
3727
+ # the lines, so nobody auto-moves it; an @overload
3728
+ # group gives its first line alone, the move target.
3299
3729
  item["reason"] = STALE_LINE_NOT_DEFINITION
3300
- item["definition_lines"] = defs
3730
+ item["definition_lines"] = _missed_definition_lines(
3731
+ sf, name, source_root, cache, line, defs, every
3732
+ )
3301
3733
  stale.append(item)
3302
3734
  stale.sort(key=lambda s: (s["export_name"], s.get("source_file") or ""))
3303
3735