bmad-module-skill-forge 8.0.0 → 8.1.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.
@@ -476,17 +476,21 @@ Subcommands:
476
476
  `fabricated` is true, with reason `not-defined`, when the name has an
477
477
  entry and every entry's `line_check` is `file-missing`, or `checked`
478
478
  with an empty `definition_lines`: the export is in neither the surface
479
- nor the cited file, and no line elsewhere renames to it the
480
- declaration a cited file holds. Such a rename gives the reason
481
- `aliased` and `fabricated` false, and its line is the `defined_at`:
482
- the first, per file of the name's declarations the lookup below finds
483
- (its `declared_in`, else the cited file's) and per candidate line of
484
- that file in order, that renames to NAME a name `L` which a file of a
485
- `checked` entry of the name declares by the same rule (no module or
486
- package so named counting; for `L` `default`, a column-0
487
- `export default`, `export { X as default }` or `exports.default =`).
488
- That `L` is the item's `renames`, a key no other item has, so that
489
- test-skill's provenance line gap can say how to record the rename.
479
+ nor the cited file, and no line elsewhere renames to it, directly or
480
+ through a chain of renames, the declaration a cited file holds. Such a
481
+ rename gives the reason `aliased` and `fabricated` false, and its line
482
+ is the `defined_at`: the first, per file of the name's declarations the
483
+ lookup below finds (its `declared_in`, else the cited file's) and per
484
+ candidate line of that file in order, that renames to NAME a name `L`
485
+ which a file of a `checked` entry of the name declares by the same
486
+ rule (no module or package so named counting; for `L` `default`, a
487
+ column-0 `export default`, `export { X as default }` or
488
+ `exports.default =`), or which a file the rename resolves to declares
489
+ or renames, by the chain below, the chain ending at such a cited
490
+ declaration. The name that cited file declares where the chain ends
491
+ (for a one-step rename, the `L` the line at `defined_at` renames) is
492
+ the item's `renames`, a key no other item has, so that test-skill's
493
+ provenance line gap can say how to record the rename.
490
494
  The renames read:
491
495
  - an export-list item `L as NAME` (`export { ... }`,
492
496
  `export type { ... }`, on one line or an item of a multi-line
@@ -499,14 +503,25 @@ Subcommands:
499
503
  `as`, when a column-0 import binds NAME to another name
500
504
  (`import { L as NAME }`, or `import NAME from`, `L` `default`), at
501
505
  the export;
502
- - a column-0 `const`, `let` or `var NAME = L` or `type NAME = L`,
503
- exported or not, `L` a bare identifier and the statement ending on
504
- its line;
506
+ - a column-0 `const`, `let` or `var NAME = L`, annotated or not
507
+ (`const NAME: <type> = L`, the type on the line, holding no `;`
508
+ and no `=` but in `=>`), `type NAME = L` or a generic
509
+ `type NAME<P1, ...> = L<P1, ...>` whose arguments are its bare
510
+ parameters, the same names in the same order, exported or not,
511
+ `L` a bare identifier and the statement ending on its line;
512
+ - for NAME `default`, a column-0 `export default L` (the `;`
513
+ optional), `L` a bare identifier that a column-0 import or
514
+ declaration of the same file binds and the statement ending on its
515
+ line (`import { createEntry } from './api'` then
516
+ `export default createEntry;`);
505
517
  - a Python `from X import L as NAME` item (on one line, in
506
- parentheses or after a backslash), the statement at column 0 or
507
- one level in a column-0 `try`, `except`, `else`, `if` or `elif`
508
- block, as compatibility imports are written, and a column-0 Python
509
- `NAME = L`, `L` a bare identifier.
518
+ parentheses or after a backslash), the statement at column 0, one
519
+ level in a column-0 `try`, `except`, `else`, `if`, `elif`, `with`
520
+ or `finally` block or as such a header's one-line body after its
521
+ first colon (`try: from X import L as NAME`), as compatibility
522
+ imports are written, and a column-0 Python `NAME = L` or
523
+ `NAME: <type> = L` (the type holding no `;` or `=`), `L` a bare
524
+ identifier.
510
525
  A TS/JS line is read with its comments removed (an export list split
511
526
  on a copy whose one-line strings are blanked), a Python line with its
512
527
  `#` comment cut. `L` comes from the rename's `from` module, else from
@@ -519,26 +534,53 @@ Subcommands:
519
534
  (`.d.ts` included); a Python relative module from the file's folder
520
535
  (`.` the folder's own `__init__`), or an absolute one below the parent
521
536
  of the file's package root, as `m.py`, `m.pyi`, `m/__init__.py` or
522
- `m/__init__.pyi`. An `L` with no module that the renaming file
523
- declares itself resolves to that file. When the files the rename
524
- resolves to declare `L`, one of them must be a cited file, so a rename
525
- of another file's same-named `L` stays fabricated. When the module
526
- resolves to no file (a package name, never looked up in
527
- `node_modules`, a tsconfig `paths` alias, a package `exports` map, a
528
- path with no such file) or to files that declare no `L` (a barrel that
529
- re-exports it), any cited file that declares `L` clears the name. The
530
- suspects' cited pass settles no such name at line 1 of a cited module
531
- or package so named before the walk, even when the module is itself
532
- the export (a column-0 `export default` or `module.exports =`), so the
533
- walk always runs; the walk keeps its module rule, so `declared_in` may
534
- list that module at its line 1 when the walk reads its folder. Any
535
- other rename stays fabricated: a TS type assertion (`x as NAME`), a
536
- call or any other expression (`const NAME = make()`, `NAME = a.b`),
537
- `export * as NAME from`, Python `import M as NAME`, a rename of a
538
- module (`import * as L`, `from . import L as NAME`: no module or
539
- package so named declares `L`), a rename inside a `def`, a `class` or
540
- a deeper block, `as NAME` in a comment or a string, and a
541
- `default as NAME` whose module has only `module.exports =`.
537
+ `m/__init__.pyi`. An `L` with no module resolves to the renaming file
538
+ itself. When one of the files the rename resolves to that declare `L`
539
+ is a cited file, the rename is an alias. Otherwise the rename is
540
+ followed as a chain through each of those files that declares `L` or
541
+ renames it (a middle file): each candidate line of `L` there that
542
+ renames it, by a form above (an export-list alias, a compatibility
543
+ import in a block or on one line, an import then a plain re-export,
544
+ an assignment of a bare `L`, annotated or not, an `export default L`
545
+ of a bound `L`, which a rename of `default` reaches:
546
+ `export { default as NAME } from`, or `import NAME from` re-exported)
547
+ or by a Python column-0 `L = M.L2`, annotated or not, whose `M` a
548
+ column-0 `from X import M` binds (the module `X` joined with `M`, `.`
549
+ prefixes kept), is the next step, read and resolved the same way, until
550
+ a step reaches a cited file that declares its name (an alias) or none
551
+ does (fabricated, so a rename of another file's same-named `L` stays
552
+ fabricated). Each (file, name) pair, a resolved file that declares
553
+ or renames the name, is followed once per first rename, so a chain that
554
+ loops ends, fabricated. When the first rename's module resolves to no
555
+ file (a package name, never looked up in `node_modules`, a tsconfig
556
+ `paths` alias, a package `exports` map, a path with no such file) or
557
+ to files that declare no `L` (a barrel that re-exports it), any cited
558
+ file that declares `L` clears the name; at a later step of a chain,
559
+ such a module ends the chain, fabricated, unless one of those files
560
+ renames `L` (a middle file, followed as above). The suspects' cited pass
561
+ settles no such name at line 1 of a cited module or package so named
562
+ before the walk, even when the module is itself the export (a column-0
563
+ `export default` or `module.exports =`), so the walk always runs; the
564
+ walk keeps its module rule, so `declared_in` may list that module at
565
+ its line 1 when the walk reads its folder. Any other rename stays
566
+ fabricated: a TS type assertion (`x as NAME`), a
567
+ call or any other expression (`const NAME = make()`, and `NAME = a.b`
568
+ at the documented name's own line: `L = M.L2` is only a later step of
569
+ a chain), a default export of any other expression
570
+ (`export default make();`, an object, an arrow, a value that goes on
571
+ to the next line, an indented `export default L`, an `L` no import or
572
+ declaration of the file binds), a generic alias whose parameters are
573
+ constrained, defaulted, swapped or instantiated
574
+ (`type NAME<T extends X> = L<T>`, `type NAME<T = X> = L<T>`,
575
+ `type NAME<K, V> = L<V, K>`, `type NAME = L<string>`),
576
+ `export * as NAME from`, Python
577
+ `import M as NAME`, a rename of a module (`import * as L`,
578
+ `from . import L as NAME`: no module or package so named declares
579
+ `L`), a rename inside a `def`, a `class` or a deeper block (two levels
580
+ in a `with` block included), `as NAME` in a comment or a string, a
581
+ `default as NAME` whose module has only `module.exports =`, and a
582
+ chain that leaves the files the resolver reads, loops or ends at
583
+ another declaration.
542
584
  Otherwise the reason is `defined` (a cited file defines the name),
543
585
  `unchecked` (no rule could check a cited file, and none defines it) or
544
586
  `no-entry` (the map has no entry for the name).
@@ -2870,18 +2912,37 @@ _JS_IMPORT_BARE_RE = re.compile(r"""^\s*import\s*["']""")
2870
2912
  _JS_IMPORT_ITEM_RE = re.compile(rf"^(?:type\s+)?({_JS_NAME_OR_STRING})(?:\s+as\s+([\w$]+))?$")
2871
2913
  _JS_IMPORT_DEFAULT_RE = re.compile(r"^([\w$]+)\s*(?:,|$)")
2872
2914
  _JS_IMPORT_NAMESPACE_RE = re.compile(r"^\*\s*as\s+([\w$]+)")
2873
- # `const|let|var NAME = L` and `type NAME = L`, exported or not: (NAME, L,
2874
- # the `;` when the line ends the statement).
2915
+ # `const|let|var NAME = L` (or `NAME: <type> = L`, the type on the line,
2916
+ # holding no `;`, no `=` but in `=>` and no `,` that opens another
2917
+ # declarator) and `type NAME = L` (or `type NAME<P1, ...> = L<A1, ...>`,
2918
+ # one trailing comma allowed in either list), exported or not:
2919
+ # NAME (`var` or `type`), the type's parameters, L, its arguments, and the
2920
+ # `;` when the line ends the statement (`end`). `renames` reads a generic
2921
+ # one only when its arguments are its bare parameters in order
2922
+ # (`_js_passes_parameters`).
2875
2923
  _JS_NAME_IS_RE = re.compile(
2876
- r"^\s*(?:export\s+)?(?:const|let|var|type)\s+([\w$]+)\s*=(?![=>])\s*([A-Za-z_$][\w$]*)\s*(;?)$"
2924
+ r"^\s*(?:export\s+)?(?:(?:const|let|var)\s+(?P<var>[\w$]+)"
2925
+ r"(?:\s*:(?!=)\s*(?:[^=;,\s]|=>|,(?!\s*[\w$]+\s*=(?![=>])))(?:[^=;,]|=>|,(?!\s*[\w$]+\s*=(?![=>])))*?)?"
2926
+ r"|type\s+(?P<type>[\w$]+)(?:\s*<(?P<params>[^<>]*)>)?)"
2927
+ r"\s*(?<![=!])=(?![=>])\s*(?P<value>[A-Za-z_$][\w$]*)(?:\s*<(?P<args>[^<>]*)>)?\s*(?P<end>;?)$"
2877
2928
  )
2929
+ # `export default L`, `L` a bare identifier, and the `;` when the line ends
2930
+ # the statement (`end`): for the name `default`, `renames` reads it as a
2931
+ # rename of `L` when an import or a declaration of the same file binds `L`.
2932
+ _JS_EXPORT_DEFAULT_IS_RE = re.compile(r"^\s*export\s+default\s+(?P<value>[A-Za-z_$][\w$]*)\s*(?P<end>;?)\s*$")
2878
2933
  # A line that begins with one of these goes on with the value above it (a
2879
2934
  # member, an operator, a call, an index or a tagged template).
2880
2935
  _JS_VALUE_GOES_ON = _JS_CONTINUATION_START | frozenset("([`")
2881
2936
  # Python's `from X import ...`, with its module.
2882
2937
  _PY_FROM_CLAUSE_RE = re.compile(r"^\s*from\s+([\w.]+)\s+import\s+(.*)$")
2883
2938
  # The column-0 header of a block whose body may hold a compatibility import.
2884
- _PY_BLOCK_HEADER_RE = re.compile(r"^(?:try|except|else|if|elif)(?!\w)")
2939
+ _PY_BLOCK_HEADER_RE = re.compile(r"^(?:try|except|else|if|elif|with|finally)(?!\w)")
2940
+ # A column-0 Python `NAME = L` or `NAME: <type> = L`, the type holding no
2941
+ # `=` or `;` (`{name}` escaped): L.
2942
+ _PY_NAME_IS = r"^{name}\s*(?::(?!=)[^=;]+?)?(?<![=!<>])=(?!=)\s*([A-Za-z_]\w*)\s*$"
2943
+ # A column-0 Python `NAME = M.L2` or `NAME: <type> = M.L2` (`{name}`
2944
+ # escaped): (M, L2), a step of a rename chain only (`_Renames.member`).
2945
+ _PY_NAME_IS_MEMBER = r"^{name}\s*(?::(?!=)[^=;]+?)?(?<![!<>])=(?!=)\s*([A-Za-z_]\w*)\.([A-Za-z_]\w*)\s*$"
2885
2946
  # The files a relative TS/JS module names, after the path itself:
2886
2947
  # `<path><ext>`, then `<path>/index<ext>`.
2887
2948
  _TSJS_MODULE_EXTENSIONS = (".ts", ".tsx", ".d.ts", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")
@@ -2913,6 +2974,20 @@ def _js_export_sides(item: str) -> tuple[str, str | None] | None:
2913
2974
  return own, exposed
2914
2975
 
2915
2976
 
2977
+ def _js_passes_parameters(params: str | None, args: str | None) -> bool:
2978
+ """True when a `NAME = L` line's type parameters (`type NAME<P1, ...>`)
2979
+ reach `L` unchanged: neither parameters nor arguments, or bare
2980
+ parameters given as `L`'s arguments, the same names in the same order
2981
+ (`type NAME<K, V> = L<K, V>`). False for a constraint or a default
2982
+ (`<T extends X>`, `<T = X>`), a swap (`L<V, K>`), an instantiation
2983
+ (`L<string>`) and arguments with no parameters."""
2984
+ if params is None or args is None:
2985
+ return params is None and args is None
2986
+ names = [p.strip() for p in params.strip().removesuffix(",").split(",")]
2987
+ given = [a.strip() for a in args.strip().removesuffix(",").split(",")]
2988
+ return all(_JS_IDENT_RE.fullmatch(n) for n in names) and names == given
2989
+
2990
+
2916
2991
  def _js_export_lists(
2917
2992
  lines: list[str], code: list[str]
2918
2993
  ) -> list[tuple[list[tuple[int, str]], str | None]]:
@@ -3006,18 +3081,32 @@ def _js_import_bindings(lines: list[str], code: list[str]) -> list[_Binding]:
3006
3081
  return found
3007
3082
 
3008
3083
 
3084
+ def _py_from_clause(code: str) -> re.Match[str] | None:
3085
+ """The `from X import` clause (`_PY_FROM_CLAUSE_RE`) a Python statement
3086
+ line opens, its `#` comment cut: the line itself, or the one-line body
3087
+ after the first colon of a column-0 `try`, `except`, `else`, `if`,
3088
+ `elif`, `with` or `finally` header (`try: from X import L as NAME`)."""
3089
+ found = _PY_FROM_CLAUSE_RE.match(code)
3090
+ if found is None and _PY_BLOCK_HEADER_RE.match(code) and ":" in code:
3091
+ found = _PY_FROM_CLAUSE_RE.match(code.split(":", 1)[1])
3092
+ return found
3093
+
3094
+
3009
3095
  def _py_block_imports(lines: list[str], code: list[str], starts: set[int]) -> set[int]:
3010
3096
  """The lines (from 1) on which a `from X import` statement starts one
3011
- level under a column-0 `try`, `except`, `else`, `if` or `elif` (a
3012
- compatibility import), never in a `def`, a `class` or deeper. `code`
3013
- holds the lines with their `#` comments cut, `starts` the statement
3014
- starts (`_py_statement_starts`)."""
3097
+ level under a column-0 `try`, `except`, `else`, `if`, `elif`, `with` or
3098
+ `finally` (a compatibility import), or as the one-line body of such a
3099
+ header, after its first colon (`try: from X import L as NAME`), never
3100
+ in a `def`, a `class` or deeper. `code` holds the lines with their `#`
3101
+ comments cut, `starts` the statement starts (`_py_statement_starts`)."""
3015
3102
  found: set[int] = set()
3016
3103
  header, level = False, None
3017
3104
  for number in sorted(starts):
3018
3105
  text = lines[number - 1]
3019
3106
  if _is_column_0(text):
3020
3107
  header, level = _PY_BLOCK_HEADER_RE.match(code[number - 1]) is not None, None
3108
+ if header and _py_from_clause(code[number - 1]) is not None:
3109
+ found.add(number) # the header's own one-line body
3021
3110
  continue
3022
3111
  indent = len(text) - len(text.lstrip())
3023
3112
  if level is None:
@@ -3032,11 +3121,12 @@ def _py_from_clauses(
3032
3121
  ) -> list[tuple[int, str, list[tuple[int, str]]]]:
3033
3122
  """(line from 1, module, items) of each `from X import` statement that
3034
3123
  starts on a line `keep` accepts, an item as (line from 1, its text):
3035
- on one line, in parentheses or continued with a backslash."""
3124
+ on one line, in parentheses or continued with a backslash, a block
3125
+ header's one-line body included (`_py_from_clause`)."""
3036
3126
  clauses: list[tuple[int, str, list[tuple[int, str]]]] = []
3037
3127
  i, n = 0, len(code)
3038
3128
  while i < n:
3039
- opened = _PY_FROM_CLAUSE_RE.match(code[i]) if i + 1 in starts and keep(i + 1) else None
3129
+ opened = _py_from_clause(code[i]) if i + 1 in starts and keep(i + 1) else None
3040
3130
  if opened is None:
3041
3131
  i += 1
3042
3132
  continue
@@ -3075,6 +3165,7 @@ class _Renames:
3075
3165
  def __init__(self, parts: tuple[str, ...], lines: list[str], cache: _SourceCache) -> None:
3076
3166
  self.parts = parts
3077
3167
  self.lines = lines
3168
+ self.cache = cache
3078
3169
  self.text = "\n".join(lines)
3079
3170
  path = Path(*parts)
3080
3171
  self.python = _rule_family(parts[-1]) == "python"
@@ -3091,8 +3182,9 @@ class _Renames:
3091
3182
  # -- reading, once per file ---------------------------------------------
3092
3183
 
3093
3184
  def _py_clauses(self) -> list[tuple[int, str, list[tuple[int, str]]]]:
3094
- """The `from X import` statements at column 0 or one level in a
3095
- column-0 block (`_py_block_imports`)."""
3185
+ """The `from X import` statements at column 0, one level in a
3186
+ column-0 block or a block header's one-line body
3187
+ (`_py_block_imports`)."""
3096
3188
  if self._clauses is None:
3097
3189
  if self.starts is None:
3098
3190
  self._clauses = []
@@ -3110,7 +3202,9 @@ class _Renames:
3110
3202
  return self._lists
3111
3203
 
3112
3204
  def _imports(self) -> list[_Binding]:
3113
- """The names the file's column-0 imports bind (`_Binding`)."""
3205
+ """The names the file's column-0 imports bind (`_Binding`); a
3206
+ compatibility import, one level in a block or a block header's
3207
+ one-line body, binds none."""
3114
3208
  if self._bindings is None:
3115
3209
  if not self.python:
3116
3210
  self._bindings = _js_import_bindings(self.lines, self.code)
@@ -3119,7 +3213,9 @@ class _Renames:
3119
3213
  else:
3120
3214
  found: list[_Binding] = []
3121
3215
  for start, module, items in self._py_clauses():
3122
- if not _is_column_0(self.lines[start - 1]):
3216
+ if not _is_column_0(self.lines[start - 1]) or (
3217
+ _PY_FROM_CLAUSE_RE.match(self.code[start - 1]) is None # `try: from ...`
3218
+ ):
3123
3219
  continue
3124
3220
  for _, item in items:
3125
3221
  m = _PY_IMPORT_ITEM_RE.match(item)
@@ -3175,10 +3271,11 @@ class _Renames:
3175
3271
  for `name`: every column-0 line that declares it (`declared_line`'s
3176
3272
  rule; with `module_fallback`, line 1 of the module or package so
3177
3273
  named when none does), a Python `from X import L as NAME` item one
3178
- level in a column-0 `try`, `except`, `else`, `if` or `elif` block,
3179
- and a TS/JS local export-list item `NAME` without `as` when a
3180
- column-0 import binds NAME to another name (`import { L as NAME }`,
3181
- `import NAME from`)."""
3274
+ level in a column-0 `try`, `except`, `else`, `if`, `elif`, `with`
3275
+ or `finally` block or as such a header's one-line body
3276
+ (`try: from X import L as NAME`), and a TS/JS local export-list
3277
+ item `NAME` without `as` when a column-0 import binds NAME to
3278
+ another name (`import { L as NAME }`, `import NAME from`)."""
3182
3279
  if not name or "." in name:
3183
3280
  return []
3184
3281
  path = Path(*self.parts)
@@ -3223,20 +3320,38 @@ class _Renames:
3223
3320
  - a local export-list item `NAME` without `as`, when a column-0
3224
3321
  import binds NAME to another name (`import { L as NAME }`, or
3225
3322
  `import NAME from`, `L` `default`);
3226
- - a column-0 `const`, `let` or `var NAME = L` or `type NAME = L`,
3227
- exported or not, `L` a bare identifier and the statement ending
3228
- there;
3229
- - a Python `from X import L as NAME` item, the statement at column 0
3230
- or one level in a column-0 `try`, `except`, `else`, `if` or `elif`
3231
- block, and a column-0 `NAME = L`, `L` a bare identifier.
3323
+ - a column-0 `const`, `let` or `var NAME = L`, annotated or not
3324
+ (`const NAME: <type> = L`, the type on the line, holding no `;`
3325
+ and no `=` but in `=>`), `type NAME = L` or a generic
3326
+ `type NAME<P1, ...> = L<P1, ...>` whose arguments are its bare
3327
+ parameters, the same names in the same order, exported or not,
3328
+ `L` a bare identifier and the statement ending there;
3329
+ - for `name` `default`, a column-0 `export default L`
3330
+ (`_JS_EXPORT_DEFAULT_IS_RE`, the `;` optional), `L` a bare
3331
+ identifier and the statement ending there: `L` taken from the
3332
+ import that binds it (`binding`) or, when the file declares `L`
3333
+ (`declared_line`, no module so named), from the file itself;
3334
+ - a Python `from X import L as NAME` item, the statement at column 0,
3335
+ one level in a column-0 `try`, `except`, `else`, `if`, `elif`,
3336
+ `with` or `finally` block or as such a header's one-line body
3337
+ (`try: from X import L as NAME`), and a column-0 `NAME = L` or
3338
+ `NAME: <type> = L` (the type holding no `;` or `=`), `L` a bare
3339
+ identifier.
3232
3340
 
3233
3341
  The module is the list's or the clause's `from`; a rename with none
3234
3342
  takes the column-0 import that binds `L` (`binding`), `L` becoming
3235
3343
  its imported name, and a namespace import's name renames nothing.
3236
3344
  Empty for any other line: `as NAME` in a comment or a string, a TS
3237
3345
  type assertion (`const NAME = x as NAME`), a call or any other
3238
- expression, a Python `import M as NAME` and a rename inside a `def`
3239
- or a `class`."""
3346
+ expression (`NAME = a.b` included: `member` reads it, as a step of
3347
+ a chain only), a default export of anything but a bare `L` an
3348
+ import or a declaration of the file binds (`export default make()`,
3349
+ an object, an arrow, a value that goes on to the next line, a
3350
+ namespace import's `L`), a generic alias whose parameters are
3351
+ constrained, defaulted, swapped or instantiated (`type NAME<T
3352
+ extends X> = L<T>`, `type NAME<K, V> = L<V, K>`, `type NAME =
3353
+ L<string>`), a Python `import M as NAME` and a rename inside a
3354
+ `def` or a `class`."""
3240
3355
  if not 0 < number <= len(self.lines):
3241
3356
  return []
3242
3357
  found: list[tuple[str | None, str | None]] = []
@@ -3249,7 +3364,7 @@ class _Renames:
3249
3364
  found.append((local, module))
3250
3365
  starts = self.starts or set()
3251
3366
  if number in starts and _is_column_0(self.lines[index]):
3252
- m = re.match(rf"^{re.escape(name)}\s*=(?!=)\s*([A-Za-z_]\w*)\s*$", self.code[index])
3367
+ m = re.match(_PY_NAME_IS.format(name=re.escape(name)), self.code[index])
3253
3368
  if m is not None and m.group(1) != name:
3254
3369
  found.append(self._bound(m.group(1), number))
3255
3370
  return [(local, module) for local, module in found if local is not None]
@@ -3269,12 +3384,45 @@ class _Renames:
3269
3384
  elif own != "default":
3270
3385
  found.append(self._bound(own, number))
3271
3386
  m = _JS_NAME_IS_RE.match(self.code[index]) if _is_column_0(self.lines[index]) else None
3272
- if m is not None and m.group(1) == name and m.group(2) != name and (
3273
- m.group(3) or not self._goes_on(index)
3387
+ if (
3388
+ m is not None and (m["var"] or m["type"]) == name and m["value"] != name
3389
+ and (m["end"] or not self._goes_on(index))
3390
+ and _js_passes_parameters(m["params"], m["args"])
3274
3391
  ):
3275
- found.append(self._bound(m.group(2), number))
3392
+ found.append(self._bound(m["value"], number))
3393
+ m = (
3394
+ _JS_EXPORT_DEFAULT_IS_RE.match(self.code[index])
3395
+ if name == "default" and _is_column_0(self.lines[index]) else None
3396
+ )
3397
+ if m is not None and m["value"] != name and (m["end"] or not self._goes_on(index)):
3398
+ bound = self.binding(m["value"], number) # a namespace import's (None, module) renames nothing
3399
+ if bound is not None:
3400
+ found.append(bound)
3401
+ elif declared_line(self.parts, m["value"], lines=self.lines, cache=self.cache,
3402
+ module_fallback=False) is not None:
3403
+ found.append((m["value"], None))
3276
3404
  return [(local, module) for local, module in found if local is not None]
3277
3405
 
3406
+ def member(self, number: int, name: str) -> tuple[str, str] | None:
3407
+ """(`L2`, its module) of a column-0 Python `NAME = M.L2` (or
3408
+ `NAME: <type> = M.L2`) on line `number` (from 1) whose `M` a
3409
+ column-0 `from X import M` binds: the module is `X` joined with
3410
+ `M`'s imported name, `.` prefixes kept (`from . import _core` gives
3411
+ `._core`). A step of a rename chain only
3412
+ (`_DeclarationLookup._aliases`): at the documented name's own line
3413
+ it renames nothing. None for any other line, an `M` an
3414
+ `import a.b as M` binds included."""
3415
+ if not self.python or not 0 < number <= len(self.lines):
3416
+ return None
3417
+ if number not in (self.starts or set()) or not _is_column_0(self.lines[number - 1]):
3418
+ return None
3419
+ m = re.match(_PY_NAME_IS_MEMBER.format(name=re.escape(name)), self.code[number - 1])
3420
+ bound = None if m is None else self.binding(m.group(1), number)
3421
+ if bound is None or bound[0] is None or bound[1] is None:
3422
+ return None
3423
+ imported, module = bound
3424
+ return m.group(2), (module if module.endswith(".") else module + ".") + imported
3425
+
3278
3426
 
3279
3427
  class _DeclarationLookup:
3280
3428
  """Where the source declares the stale names (classify-stale's
@@ -3301,11 +3449,21 @@ class _DeclarationLookup:
3301
3449
  rules. In the suspects' pass (`keep_declaring`), a file's declarations
3302
3450
  are its candidate lines (`_Renames.candidates`: every column-0
3303
3451
  declaring line, a Python compatibility import one level in a column-0
3304
- block and a TS/JS re-export of a renaming import), the file listed at
3305
- the first, and `renamed` then tells which of a name's candidate lines
3306
- is an alias of the declaration a cited file holds, from the lines the
3307
- lookup read, reading too the files a rename's `from` resolves to
3308
- (`_resolve`). Any error of the filesystem raises (`_LOOKUP_ERRORS`)."""
3452
+ block or as a block header's one-line body, and a TS/JS re-export of a
3453
+ renaming import), the file listed at the first, and `renamed` then
3454
+ tells which of a name's candidate lines is an alias of the declaration
3455
+ a cited file holds, from the lines the lookup read, reading too the
3456
+ files a rename's `from` resolves to (`_resolve`) and, along a chain of
3457
+ renames (`_aliases`), the files each later step resolves to. The
3458
+ renames read include an annotated `const`, `let` or `var` or Python
3459
+ `NAME: <type> = L`, a generic `type NAME<P1, ...> = L<P1, ...>`, an
3460
+ import one level in a `with` or `finally` block or as a block header's
3461
+ one-line body, a column-0 `export default L` of an `L` an import or a
3462
+ declaration of the file binds, and a middle step `L = M.L2`; a chain
3463
+ that leaves the resolved files, loops or ends at another declaration,
3464
+ a constrained, defaulted, swapped or instantiated generic, a call and
3465
+ a default export of any other expression stay fabricated.
3466
+ Any error of the filesystem raises (`_LOOKUP_ERRORS`)."""
3309
3467
 
3310
3468
  def __init__(self, entries: list[dict], source_root: Path) -> None:
3311
3469
  self.root = source_root
@@ -3331,6 +3489,8 @@ class _DeclarationLookup:
3331
3489
  self._candidates: dict[tuple[str, str], list[int]] = {}
3332
3490
  # the files a rename's `from` resolves to and the renaming files, that no entry cites
3333
3491
  self._resolved: dict[tuple[str, ...], list[str] | None] = {}
3492
+ # the renames of each file a rename chain goes through (`_aliases`), by path
3493
+ self._chain: dict[tuple[str, ...], _Renames | None] = {}
3334
3494
 
3335
3495
  # -- the walk root ------------------------------------------------------
3336
3496
 
@@ -3632,7 +3792,9 @@ class _DeclarationLookup:
3632
3792
  continue
3633
3793
  if lines is None:
3634
3794
  lines = _text_lines(text)
3635
- cache = _SourceCache() # this file's rules only: nothing kept
3795
+ # this file's rules only; a declaring file's `_Renames` keeps it
3796
+ # for `renamed`
3797
+ cache = _SourceCache()
3636
3798
  path = Path(*parts)
3637
3799
  if path.suffix.lower() in PYTHON_EXTENSIONS and (
3638
3800
  cache.py_statement_starts(path, lines) is None
@@ -3651,35 +3813,85 @@ class _DeclarationLookup:
3651
3813
  self._declaring = {}
3652
3814
  self._candidates = {}
3653
3815
 
3816
+ def _renames_of(self, parts: tuple[str, ...]) -> _Renames | None:
3817
+ """The renames of a file a rename chain goes through, read once
3818
+ (`_lines_of`); None when the lookup does not read it."""
3819
+ if parts not in self._chain:
3820
+ lines = self._lines_of(parts)
3821
+ self._chain[parts] = None if lines is None else _Renames(parts, lines, self._cited_cache)
3822
+ return self._chain[parts]
3823
+
3824
+ def _steps(self, parts: tuple[str, ...], local: str) -> list[tuple[str, str | None]]:
3825
+ """The renames of `local` a file a rename chain goes through makes,
3826
+ each a next step (`L2`, its module, None when unknown): per
3827
+ candidate line of `local` in order (`_Renames.candidates`), each
3828
+ rename the line makes (`_Renames.renames`) and a Python
3829
+ `local = M.L2` (`_Renames.member`). Empty when the file renames no
3830
+ `local` or the lookup does not read it."""
3831
+ renames = self._renames_of(parts)
3832
+ steps: list[tuple[str, str | None]] = []
3833
+ for line in [] if renames is None else renames.candidates(local, self._cited_cache, False):
3834
+ steps.extend(renames.renames(line, local))
3835
+ member = renames.member(line, local)
3836
+ if member is not None:
3837
+ steps.append(member)
3838
+ return steps
3839
+
3654
3840
  def _aliases(
3655
3841
  self, parts: tuple[str, ...], local: str, module: str | None,
3656
- cited: list[tuple[str, ...]],
3657
- ) -> bool:
3658
- """True when a rename in the file `parts` of `local` from `module`
3659
- (None when unknown) renames the declaration a cited file holds: when
3660
- the files the module resolves to (`_resolve`) declare `local`, one
3661
- of them must be cited; otherwise (a package name, a path that names
3662
- no file, a file that declares no `local`, such as a barrel that
3663
- re-exports it) a cited file must declare `local`. A `local` with no
3664
- module that the file `parts` declares itself resolves to that file,
3665
- which must be cited. No module or package so named counts."""
3666
- if module is None and self._declares(parts, local):
3667
- return parts in cited
3668
- resolved = self._resolve(parts, module) if module else []
3669
- resolved = [p for p in resolved if self._declares(p, local)]
3670
- if resolved:
3671
- return any(p in cited for p in resolved)
3672
- return any(self._declares(p, local) for p in cited)
3842
+ cited: list[tuple[str, ...]], seen: set[tuple[tuple[str, ...], str]],
3843
+ first: bool = True,
3844
+ ) -> str | None:
3845
+ """The name a cited file declares when a rename in the file `parts`
3846
+ of `local` from `module` (None when unknown) renames that
3847
+ declaration, directly (`local` itself) or through a chain of
3848
+ renames (the name at the chain's end); None when it does not. The
3849
+ files `local` resolves to are the file `parts` when `module` is
3850
+ None, else the files the module resolves to (`_resolve`). When one
3851
+ of them that declares `local` is cited, the rename is an alias.
3852
+ Otherwise the chain follows each of them that declares `local` or
3853
+ renames it (`_steps`: an export-list alias, a compatibility import
3854
+ in a block or on one line, an import then a plain re-export, an
3855
+ assignment of a bare `L`, annotated or not, an `export default L`
3856
+ of a bound `L`, or a Python `local = M.L2`): each rename is the
3857
+ next step, until a step reaches a cited file that declares its
3858
+ name, or none does. When no file declares `local` (a package name,
3859
+ a path that names no file, a file that declares no `local`, such as
3860
+ a barrel that re-exports it), a cited file that declares `local`
3861
+ clears the name at the `first` rename only; at a later step that
3862
+ case ends the chain unless a resolved file renames `local`. `seen`
3863
+ holds the (file, name) pairs the chain followed, each a resolved
3864
+ file that declares or renames the name, fresh for each first
3865
+ rename: a chain that comes back to one (a loop) ends. No module or
3866
+ package so named counts."""
3867
+ resolved = [parts] if module is None else self._resolve(parts, module) if module else []
3868
+ declaring = [p for p in resolved if self._declares(p, local)]
3869
+ if not declaring and first and any(self._declares(p, local) for p in cited):
3870
+ return local
3871
+ if any(p in cited for p in declaring):
3872
+ return local
3873
+ for file in resolved:
3874
+ if (file, local) in seen:
3875
+ continue
3876
+ seen.add((file, local))
3877
+ for name, source in self._steps(file, local):
3878
+ end = self._aliases(file, name, source, cited, seen, first=False)
3879
+ if end is not None:
3880
+ return end
3881
+ return None
3673
3882
 
3674
3883
  def renamed(
3675
3884
  self, name: str, hits: list[str], files: list[str]
3676
3885
  ) -> tuple[str, str] | None:
3677
3886
  """(`file:line`, `L`) of the first rename to `name` that is an alias
3678
3887
  of the declaration of `L` a file of `files` (source_file values, as
3679
- recorded) holds (`_aliases`): per file of `hits` (`file:line`
3888
+ recorded) holds (`_aliases`), directly or through a chain of renames
3889
+ across the files it resolves: per file of `hits` (`file:line`
3680
3890
  declarations of `name` the lookup found, sorted), per candidate
3681
3891
  line in order (`_Renames.candidates`), each rename the line makes
3682
- (`_Renames.renames`). The lines are the ones the lookup read after
3892
+ (`_Renames.renames`). `L` is the name the cited file declares where
3893
+ the chain ends: for a one-step rename, the name that rename
3894
+ renames. The lines are the ones the lookup read after
3683
3895
  `keep_declaring` (`_first`), never read again; a file a rename's
3684
3896
  module resolves to is read once. None when no line is such an
3685
3897
  alias."""
@@ -3693,8 +3905,9 @@ class _DeclarationLookup:
3693
3905
  self._resolved.setdefault(renames.parts, renames.lines)
3694
3906
  for line in self._candidates.get((file, name), [int(number)]):
3695
3907
  for local, module in renames.renames(line, name):
3696
- if self._aliases(renames.parts, local, module, cited):
3697
- return f"{file}:{line}", local
3908
+ end = self._aliases(renames.parts, local, module, cited, set())
3909
+ if end is not None:
3910
+ return f"{file}:{line}", end
3698
3911
  return None
3699
3912
 
3700
3913
 
@@ -3739,15 +3952,28 @@ def classify_stale(
3739
3952
  no other name), with no line 1 from a cited module or package so named,
3740
3953
  and none in the walk unless it is the export, and is `aliased`, not
3741
3954
  fabricated, when one of its candidate lines renames to it the
3742
- declaration a `checked` entry's file holds (`_DeclarationLookup.renamed`,
3743
- which resolves the rename's `from`), and its `renames` then holds the
3744
- name that line renames (no other item has the key); any other such
3745
- name keeps both null. The classify-stale section of the module
3746
- docstring gives each reason, what a `no-file` entry means, which
3747
- entry gives `source` and how `defined_at` and `declared_in` are found.
3748
- `kinds` (`inventory_kinds`, None without `--inventory`) keeps a name of
3749
- a `NO_MODULE_KINDS` kind from a module or package so named, unless an
3750
- entry of the name is a `module` or `package` one.
3955
+ declaration a `checked` entry's file holds, directly or through a
3956
+ chain of renames across the files it resolves
3957
+ (`_DeclarationLookup.renamed`, which resolves the rename's `from` and
3958
+ follows the chain until it reaches a cited declaration, leaves the
3959
+ files it reads, loops or ends at another declaration), and its
3960
+ `renames` then holds the name the cited file declares where the chain
3961
+ ends, for a one-step rename the name that line renames (no other item
3962
+ has the key); any other such name keeps both null. The renames read
3963
+ include an annotated `const`, `let` or `var` or Python
3964
+ `NAME: <type> = L`, a generic `type NAME<P1, ...> = L<P1, ...>`, an
3965
+ import one level in a `with` or `finally` block or as a block header's
3966
+ one-line body, a column-0 `export default L` of an `L` an import or a
3967
+ declaration of the file binds, and a middle step `L = M.L2`; a chain
3968
+ that leaves the resolved files, loops or ends at another declaration,
3969
+ a constrained, defaulted, swapped or instantiated generic, a call and
3970
+ a default export of any other expression stay fabricated.
3971
+ The classify-stale section of the module docstring gives each reason,
3972
+ what a `no-file` entry means, which entry gives `source` and how
3973
+ `defined_at` and `declared_in` are found. `kinds` (`inventory_kinds`,
3974
+ None without `--inventory`) keeps a name of a `NO_MODULE_KINDS` kind
3975
+ from a module or package so named, unless an entry of the name is a
3976
+ `module` or `package` one.
3751
3977
  """
3752
3978
  entries = [e for e in prov.get("entries") or [] if isinstance(e, dict)]
3753
3979
  results: list[dict] = []
@@ -3816,7 +4042,7 @@ def classify_stale(
3816
4042
  found, declared = {}, {}
3817
4043
  # the suspects' own lookup, after: an error there clears no name settled
3818
4044
  # above, and keeps every suspect fabricated
3819
- aliased: dict[str, tuple[str, str]] = {} # name -> (the rename's `file:line`, the `L` it renames)
4045
+ aliased: dict[str, tuple[str, str]] = {} # name -> (the rename's `file:line`, the chain-end `L`)
3820
4046
  suspect_declared: dict[str, list[str]] = {}
3821
4047
  if suspects and lookup is not None:
3822
4048
  try: