bmad-module-skill-forge 7.0.0 → 8.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 (68) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/docs/_data/pinned.yaml +9 -10
  4. package/docs/campaign.md +2 -2
  5. package/docs/skill-model.md +3 -3
  6. package/docs/verifying-a-skill.md +17 -18
  7. package/docs/workflows.md +7 -7
  8. package/package.json +1 -1
  9. package/src/shared/references/description-guard-protocol.md +12 -10
  10. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +1 -1
  11. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +1 -1
  12. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +38 -0
  13. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +5 -0
  14. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +1 -1
  15. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +1 -1
  16. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +8 -3
  17. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +1 -1
  18. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +6 -1
  19. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  20. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +7 -2
  21. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +1 -1
  22. package/src/shared/scripts/skf-parse-gaps.py +4 -1
  23. package/src/shared/scripts/skf-quick-batch.py +8 -4
  24. package/src/shared/scripts/skf-verify-provenance-completeness.py +727 -179
  25. package/src/skf-audit-skill/customize.toml +4 -3
  26. package/src/skf-audit-skill/references/report.md +1 -1
  27. package/src/skf-campaign/assets/campaign-state-schema.json +58 -0
  28. package/src/skf-campaign/references/campaign-contracts.md +1 -1
  29. package/src/skf-campaign/references/step-06-batch.md +2 -2
  30. package/src/skf-campaign/references/step-07-capstone.md +1 -1
  31. package/src/skf-campaign/references/step-08-verify.md +1 -1
  32. package/src/skf-campaign/references/step-09-refine.md +1 -1
  33. package/src/skf-campaign/references/step-11-maintenance.md +1 -1
  34. package/src/skf-campaign/scripts/campaign-render-batch.py +43 -10
  35. package/src/skf-campaign/scripts/campaign-report.py +60 -4
  36. package/src/skf-campaign/scripts/campaign-state.py +67 -19
  37. package/src/skf-campaign/templates/campaign-report-template.md +4 -0
  38. package/src/skf-create-skill/assets/skill-sections.md +2 -2
  39. package/src/skf-create-skill/references/report.md +2 -2
  40. package/src/skf-create-skill/references/validate.md +49 -29
  41. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  42. package/src/skf-create-stack-skill/references/report.md +3 -3
  43. package/src/skf-create-stack-skill/references/validate.md +9 -4
  44. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  45. package/src/skf-export-skill/references/summary.md +1 -1
  46. package/src/skf-quick-skill/references/batch-mode.md +28 -2
  47. package/src/skf-quick-skill/references/compile.md +2 -0
  48. package/src/skf-quick-skill/references/finalize.md +3 -2
  49. package/src/skf-quick-skill/references/halt-contract.md +3 -3
  50. package/src/skf-quick-skill/references/quick-extract.md +2 -1
  51. package/src/skf-quick-skill/references/write-and-validate.md +11 -5
  52. package/src/skf-refine-architecture/customize.toml +1 -1
  53. package/src/skf-refine-architecture/references/report.md +2 -2
  54. package/src/skf-rename-skill/references/report.md +1 -1
  55. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  56. package/src/skf-test-skill/references/coverage-check.md +2 -2
  57. package/src/skf-test-skill/references/invocation-contract.md +1 -1
  58. package/src/skf-test-skill/references/report.md +1 -1
  59. package/src/skf-test-skill/references/scoring-rules.md +1 -1
  60. package/src/skf-test-skill/references/signature-comparison-rules.md +4 -1
  61. package/src/skf-test-skill/scripts/gap-ledger.py +196 -46
  62. package/src/skf-test-skill/scripts/load-coverage-inputs.py +12 -6
  63. package/src/skf-update-skill/references/gap-driven.md +1 -1
  64. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  65. package/src/skf-update-skill/references/merge.md +1 -1
  66. package/src/skf-update-skill/references/report.md +18 -7
  67. package/src/skf-update-skill/references/write.md +24 -12
  68. package/src/skf-verify-stack/references/report.md +1 -1
@@ -126,7 +126,12 @@ follow the `source_file` extension:
126
126
  (`export function`, `export interface`, `export const` and so on),
127
127
  which is the line cited;
128
128
  `export import NAME =`; `export * as NAME from` (NAME may be quoted);
129
- `export default NAME`;
129
+ `export default NAME`; for the name `default`, the one SKF's extraction
130
+ gives a module's default export, any `export default` line at column 0,
131
+ whatever follows it (`export default storybookTest;`,
132
+ `export default async function postinstall() {}`, an anonymous
133
+ function or class, an object or any other expression, the value on
134
+ the next line), decorators before `export` allowed;
130
135
  an `export { ... }` list whose exposed name is NAME, an identifier or
131
136
  a quoted string on either side of `as`; CommonJS
132
137
  `exports.NAME =`, `module.exports.NAME =`, or NAME as a key or
@@ -463,34 +468,77 @@ Subcommands:
463
468
  | "skipped-export-type"
464
469
  | "skipped-language" | "unreadable"
465
470
  | "no-file",
466
- "definition_lines": [<int>, ...] | null}, ...]},
471
+ "definition_lines": [<int>, ...] | null}, ...],
472
+ "renames": "<L>"}, # an `aliased` item only
467
473
  ...
468
474
  ]
469
475
 
470
476
  `fabricated` is true, with reason `not-defined`, when the name has an
471
477
  entry and every entry's `line_check` is `file-missing`, or `checked`
472
478
  with an empty `definition_lines`: the export is in neither the surface
473
- nor the cited file, and no item of an export list or of a Python
474
- `from X import` clause elsewhere renames to it a name that file
475
- declares. Such an alias (`export { API_Entry as Entry }` or
476
- `export type { ... }`, on one line or an item of a multi-line list;
477
- `from X import L as NAME`, on one line or an item in parentheses or
478
- after a backslash) gives the reason `aliased` and `fabricated` false:
479
- the first of the name's declarations the lookup below finds (its
480
- `declared_in`, else the cited file's) that is such an item
481
- `L as NAME`, where a file of a `checked` entry of the name declares
482
- `L` by the same rule (no module or package so named counting), is its
483
- `defined_at`. A TS/JS line is read with its comments removed and its
484
- one-line strings blanked, a Python line with its `#` comment cut.
485
- Neither the rename's `from` module nor `L`'s own binding is resolved,
486
- so an `L` that another file declares under the same name counts too.
487
- Any other rename stays fabricated: `export { default as NAME }`,
488
- `const NAME = L`, `type NAME = L`, an import then an export of NAME,
489
- Python `NAME = L`, a TS type assertion (`x as NAME`) and `as NAME` in
490
- a comment or a string; and so does a name whose cited file is the
491
- module so named and itself the export (a column-0 `export default` or
492
- `module.exports =`), which settles it at its line 1, so no walk looks
493
- for an alias elsewhere.
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.
490
+ The renames read:
491
+ - an export-list item `L as NAME` (`export { ... }`,
492
+ `export type { ... }`, on one line or an item of a multi-line
493
+ list), `L` `default` when the list has a `from`
494
+ (`export { default as NAME } from './m'`), a string side unquoted
495
+ only when the string is the whole side (`L as "NAME"`,
496
+ `"L" as NAME`), the list's opening line at column 0 as written, so
497
+ that a comment may open it;
498
+ - a local `export { NAME }` or `export type { NAME }` item without
499
+ `as`, when a column-0 import binds NAME to another name
500
+ (`import { L as NAME }`, or `import NAME from`, `L` `default`), at
501
+ 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;
505
+ - 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.
510
+ A TS/JS line is read with its comments removed (an export list split
511
+ on a copy whose one-line strings are blanked), a Python line with its
512
+ `#` comment cut. `L` comes from the rename's `from` module, else from
513
+ the column-0 import that binds `L`, whose imported name `L` becomes
514
+ (`default` for a default import; a namespace import binds none). The
515
+ module is resolved below the source root, under the lookup's guards
516
+ for a cited file: a TS/JS `./` or `../` module from the renaming
517
+ file's folder, as the path itself, a `.js`-family path's TypeScript
518
+ twin (`./m.js` names `m.ts`), `<path><ext>` or `<path>/index<ext>`
519
+ (`.d.ts` included); a Python relative module from the file's folder
520
+ (`.` the folder's own `__init__`), or an absolute one below the parent
521
+ 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 =`.
494
542
  Otherwise the reason is `defined` (a cited file defines the name),
495
543
  `unchecked` (no rule could check a cited file, and none defines it) or
496
544
  `no-entry` (the map has no entry for the name).
@@ -511,8 +559,9 @@ Subcommands:
511
559
  a `def`, `class`, assignment, annotation, type alias, TS/JS
512
560
  declaration, `exports.NAME =`, or an alias that renames
513
561
  (`import X as NAME`, `from X import Y as NAME`,
514
- `export { Y as NAME }`, `export * as NAME from`), or a
515
- `module.exports` key with a value or a method. A plain or star import,
562
+ `export { Y as NAME }`, `export * as NAME from`), a
563
+ `module.exports` key with a value or a method, or, for the name
564
+ `default`, any `export default` line. A plain or star import,
516
565
  an `as` that keeps the imported name (`from m import NAME as NAME`,
517
566
  `export { NAME as NAME }`), `export import NAME =`,
518
567
  `export default NAME`, an export-list item without `as`, a
@@ -521,10 +570,18 @@ Subcommands:
521
570
  `const NAME = require(...)`, `const { NAME } = require(...)`,
522
571
  `exports.NAME = require(...)`, `{ NAME: require(...) }`), an
523
572
  indented line and any line of a Python
524
- file that does not tokenize never count (an item of a multi-line list
573
+ file that does not tokenize never count, except that for the name
574
+ `default` a column-0 `export default` line counts whatever follows it,
575
+ an import or an imported local included (an item of a multi-line list
525
576
  counts at its own line, the list's opening line at column 0, so an
526
- `aliased` name's `defined_at` is its rename's line). A module or
527
- package named NAME (`NAME.py`, `NAME/__init__.py`, a TS/JS
577
+ `aliased` name's `defined_at` is its rename's line). For a
578
+ `not-defined` name the alias check reads more lines than this rule: a
579
+ file's candidate lines are every column-0 line that declares the name,
580
+ a Python `from X import L as NAME` item one level in a column-0
581
+ `try`, `except`, `else`, `if` or `elif` block, and a TS/JS local
582
+ `export { NAME }` item when a column-0 import binds NAME to another
583
+ name, and its `declared_in` lists the file at the first of them. A
584
+ module or package named NAME (`NAME.py`, `NAME/__init__.py`, a TS/JS
528
585
  `NAME.<ext>` or `NAME/index.<ext>`) declares NAME too: at its line 1
529
586
  when no line of it
530
587
  declares NAME, unless `--inventory` gives NAME a `function`, `hook`,
@@ -566,18 +623,20 @@ Subcommands:
566
623
  test output lives in (`node_modules`, `dist`, `build`, `vendor`,
567
624
  `_vendor`, `tests`, `testing`, `fixtures`, `__mocks__`,
568
625
  `site-packages`, `env`, `bower_components`, `esm`, `cjs`, `umd` and
569
- the like), `docs`, `examples` and `benchmarks`, each judged on the
570
- path below the walk root, the end-to-end test folders `e2e` and
571
- `__fixtures__` wherever they lie, and, as the first folder below the
572
- walk root or below a package root inside it (a folder that holds
573
- `package.json`, the top of an `__init__.py` chain), `example`, `demo`,
574
- `demos`, `sample`, `samples`, `scripts`, `playground`, `bench`,
575
- `benchmark` and `migrations` unless it holds a file an entry cites
576
- (deeper in a package such a folder can be package code). A cited file
577
- is read only inside the source root (a path that is absolute or climbs
578
- out of it is not read), with no symlink on its path and under the same
579
- skips, those walk-only ones aside. The path is relative to the source
580
- root. A dotted name, or a map whose entries cite files
626
+ the like), each judged on the path below the walk root, the
627
+ end-to-end test folders `e2e` and `__fixtures__` wherever they lie,
628
+ and, as the first folder below the walk root or below a package root
629
+ inside it (a folder that holds `package.json`, the top of an
630
+ `__init__.py` chain), `example`, `demo`, `demos`, `sample`, `samples`,
631
+ `scripts`, `playground`, `bench`, `benchmark`, `migrations`, `docs`,
632
+ `examples` and `benchmarks` unless it holds a file an entry cites
633
+ (deeper in a package such a folder can be package code, as Storybook's
634
+ `code/addons/docs/`). A cited file is read in any folder, a hidden one
635
+ and one the walk skips included, but only inside the source root (a
636
+ path that is absolute or climbs out of it is not read), with no
637
+ symlink on its path and under a name the walk reads (no test file,
638
+ test configuration file or `*.min.js`). The path is relative to the
639
+ source root. A dotted name, or a map whose entries cite files
581
640
  but no Python or TS/JS one, is not looked up (`defined_at` and
582
641
  `declared_in` null, as for a fabricated name). A filesystem error
583
642
  during the lookup of the names that are not fabricated gives each of
@@ -1928,8 +1987,12 @@ def _tsjs_definition_lines(
1928
1987
  depend on the name either): a name its pattern binds counts when the
1929
1988
  `const` / `let` / `var` line it is keyed by opens a statement here.
1930
1989
  Class members count only for a
1931
- dotted export name. `declarators` splits a declarator list
1932
- (`_js_declarators`, or `_SourceCache.js_declarators`, which splits each
1990
+ dotted export name. For the undotted name `default`, the name SKF's
1991
+ extraction gives a module's default export, a column-0 `export default`
1992
+ line counts whatever follows it, with `declarations_only` too, and
1993
+ whatever block holds it; an indented one (as in a formatted
1994
+ `declare module` block) does not. `declarators` splits
1995
+ a declarator list (`_js_declarators`, or `_SourceCache.js_declarators`, which splits each
1933
1996
  line once for every export looked up). With `declarations_only`,
1934
1997
  `export import NAME =`, `export default NAME`, an export-list item
1935
1998
  without an `as` that renames, a `module.exports` shorthand key and an
@@ -1953,6 +2016,11 @@ def _tsjs_definition_lines(
1953
2016
  re.compile(rf"^\s*export\s+import\s+(?:type\s+)?{esc}\s*=(?![=>])"),
1954
2017
  re.compile(rf"^\s*export\s+default\s+{esc}\s*;?\s*$"),
1955
2018
  ]
2019
+ if name == "default" and not dotted:
2020
+ # the export named `default`: a column-0 `export default` line,
2021
+ # whatever follows it (a name, a declaration, an expression, or
2022
+ # nothing, the value on the next line), decorators before it
2023
+ statements.append(re.compile(rf"^(?:{_JS_DECORATOR}\s*)*export\s+default{end}"))
1956
2024
  # One declarator of a `const` / `let` / `var` list: `NAME = v`,
1957
2025
  # `NAME: T = v`, `NAME!: T`, or a bare `NAME`. A destructuring pattern
1958
2026
  # starts with `{` or `[` and never matches: the names a pattern that
@@ -2452,8 +2520,10 @@ def _stale_line_check(
2452
2520
 
2453
2521
  # The folders and files the `defined_at` walk leaves out, judged
2454
2522
  # case-insensitively on the path below its walk root: generated, built and
2455
- # installed output, a vendored copy of another package, tests and their
2456
- # configuration, docs, examples and benchmarks.
2523
+ # installed output, a vendored copy of another package, and tests and their
2524
+ # configuration. The folders are the walk's alone: a file the map cites
2525
+ # is read in any of them, a test file, its configuration or a minified
2526
+ # bundle never.
2457
2527
  # Keep identical to EXCLUDED_DIR_NAMES in skf-resolve-authoritative-files.py.
2458
2528
  EXCLUDED_DIR_NAMES = {
2459
2529
  "node_modules", "__pycache__", "dist", "build", ".webpack",
@@ -2491,8 +2561,7 @@ LOOKUP_EXTRA_FOLDERS = frozenset({
2491
2561
  "bower_components", "esm", "cjs", "umd",
2492
2562
  })
2493
2563
  LOOKUP_SKIPPED_FOLDERS = frozenset(
2494
- EXCLUDED_DIR_NAMES | _VENDORED_SEGMENTS | TEST_FOLDERS | PY_NON_CORE_TOPS
2495
- | LOOKUP_EXTRA_FOLDERS
2564
+ EXCLUDED_DIR_NAMES | _VENDORED_SEGMENTS | TEST_FOLDERS | LOOKUP_EXTRA_FOLDERS
2496
2565
  )
2497
2566
  # The test folders the walk alone skips wherever they lie (end-to-end tests,
2498
2567
  # Jest's `__fixtures__` beside the code): a file the map cites in one is
@@ -2500,15 +2569,17 @@ LOOKUP_SKIPPED_FOLDERS = frozenset(
2500
2569
  LOOKUP_WALK_FOLDERS = frozenset({"e2e", "__fixtures__"})
2501
2570
  # The folders the walk alone skips as the first folder below its walk root
2502
2571
  # or below a package root it passes through (`_package_root`'s roots):
2503
- # examples and demos, samples, scripts, playgrounds, benchmarks and
2504
- # migrations, which hold code that uses or changes the package rather than
2505
- # its API. Deeper in a package such a folder can be live package code
2506
- # (cognee's `cognee/modules/migrations/`), so it is walked there; one that
2507
- # holds a file the map cites is walked too, and that file is still read.
2572
+ # examples and demos, samples, scripts, playgrounds, benchmarks, migrations
2573
+ # and docs (`PY_NON_CORE_TOPS`, which skf-extract-public-api.py also leaves
2574
+ # out only as a top folder), which hold code that uses, changes or documents
2575
+ # the package rather than its API. Deeper in a package such a folder can be
2576
+ # live package code (cognee's `cognee/modules/migrations/`, Storybook's
2577
+ # `code/addons/docs/`), so it is walked there; one that holds a file the map
2578
+ # cites is walked too, and that file is still read.
2508
2579
  LOOKUP_TOP_FOLDERS = frozenset({
2509
2580
  "example", "demo", "demos", "sample", "samples", "scripts", "playground",
2510
2581
  "bench", "benchmark", "migrations",
2511
- })
2582
+ }) | PY_NON_CORE_TOPS
2512
2583
  # The kinds of a documented inventory (validate-inventory.py's `kind`) that a
2513
2584
  # module cannot be: such a name takes no line 1 from a module so named. A
2514
2585
  # `method` is left out: reconcile-coverage.py never lists a method as stale,
@@ -2647,7 +2718,9 @@ def declared_line(
2647
2718
  `class`, assignment, annotation, type alias, TS/JS declaration, or an
2648
2719
  alias that renames, such as `from X import Y as NAME`; a plain or star
2649
2720
  import, an `as` that keeps the imported name, a re-export and an
2650
- import-valued binding never count), else, with `module_fallback`, its
2721
+ import-valued binding never count, except that for the name `default`
2722
+ a column-0 `export default` line counts whatever follows it, an import
2723
+ or an imported local included), else, with `module_fallback`, its
2651
2724
  line 1 when it is the module or package so named (`_module_name`).
2652
2725
 
2653
2726
  `source_file` is the file's path below the source root, as recorded
@@ -2778,66 +2851,429 @@ def baseline_gaps(
2778
2851
  return gaps, unchecked
2779
2852
 
2780
2853
 
2781
- # A one-line TS/JS string literal, blanked before an export list is read.
2854
+ # A one-line TS/JS string literal: an export list is split into items on a
2855
+ # copy of its lines whose strings are blanked, quotes and length kept.
2782
2856
  _JS_STRING_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|`(?:[^`\\]|\\.)*`")
2783
2857
  _JS_IDENT_RE = re.compile(r"[A-Za-z_$][\w$]*")
2858
+ # The `from` clause after an export list's closing brace, and its module.
2859
+ _JS_FROM_RE = re.compile(r"""^\s*from\s*(["'])([^"']*)\1""")
2860
+ # An import statement (not `import(...)` nor `import.meta`); its clause
2861
+ # and module (`import X, { a as b } from './m'`); `import X = ...`; a
2862
+ # side-effect import (`import './m'`); an item of its brace list.
2863
+ _JS_IMPORT_RE = re.compile(r"^\s*import(?![\w$])(?!\s*[(.])")
2864
+ _JS_IMPORT_CLAUSE_RE = re.compile(
2865
+ r"""^\s*import\s+(?:type\s+(?!from(?![\w$])))?(.*?)\s*(?<![\w$])from\s*(["'])([^"']*)\2""",
2866
+ re.S,
2867
+ )
2868
+ _JS_IMPORT_EQUALS_RE = re.compile(r"^\s*import\s+(?:type\s+)?([\w$]+)\s*=(?!=)")
2869
+ _JS_IMPORT_BARE_RE = re.compile(r"""^\s*import\s*["']""")
2870
+ _JS_IMPORT_ITEM_RE = re.compile(rf"^(?:type\s+)?({_JS_NAME_OR_STRING})(?:\s+as\s+([\w$]+))?$")
2871
+ _JS_IMPORT_DEFAULT_RE = re.compile(r"^([\w$]+)\s*(?:,|$)")
2872
+ _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).
2875
+ _JS_NAME_IS_RE = re.compile(
2876
+ r"^\s*(?:export\s+)?(?:const|let|var|type)\s+([\w$]+)\s*=(?![=>])\s*([A-Za-z_$][\w$]*)\s*(;?)$"
2877
+ )
2878
+ # A line that begins with one of these goes on with the value above it (a
2879
+ # member, an operator, a call, an index or a tagged template).
2880
+ _JS_VALUE_GOES_ON = _JS_CONTINUATION_START | frozenset("([`")
2881
+ # Python's `from X import ...`, with its module.
2882
+ _PY_FROM_CLAUSE_RE = re.compile(r"^\s*from\s+([\w.]+)\s+import\s+(.*)$")
2883
+ # 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)")
2885
+ # The files a relative TS/JS module names, after the path itself:
2886
+ # `<path><ext>`, then `<path>/index<ext>`.
2887
+ _TSJS_MODULE_EXTENSIONS = (".ts", ".tsx", ".d.ts", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")
2888
+ # The TypeScript files a `.js`-family module names (`./m.js` is `m.ts`).
2889
+ _TSJS_TWINS = {
2890
+ ".js": (".ts", ".tsx", ".d.ts"), ".jsx": (".tsx",), ".mjs": (".mts", ".d.mts"),
2891
+ ".cjs": (".cts", ".d.cts"),
2892
+ }
2784
2893
 
2894
+ # One name an import binds: (line from 1, local name, imported name, module).
2895
+ # A namespace import binds its local name to no imported name (None).
2896
+ _Binding = tuple[int, str, str | None, str | None]
2785
2897
 
2786
- def _renamed_locals(lines: list[str], index: int, name: str, family: str) -> list[str]:
2787
- """The names line `index` (from 0) of a file renames to `name` as an item
2788
- of an export list (`export { L as NAME }` or `export type { ... }`, on
2789
- one line or an item of a multi-line list) or of a Python
2790
- `from X import` clause (on one line, or an item of one in parentheses
2791
- or continued with a backslash): each `L` an identifier other than
2792
- `name`, and in TS/JS other than `default` (`export { default as NAME }`
2793
- renames the default export, no declared name). A TS/JS line is read
2794
- with its `//` and `/* */` comments removed and each one-line string
2795
- blanked, a Python line with its `#` comment cut (a `from X import`
2796
- clause holds no string). The list opens at the nearest column-0
2797
- `export {` (`from X import`) at or above the line, and must not close
2798
- above it. Empty for any other line: `as NAME` in a comment or a string,
2799
- a TS type assertion (`const NAME = x as NAME`), `import { L as NAME }`
2800
- and Python's `import M as NAME` rename nothing here. Neither the `from`
2801
- module nor the binding of `L` is resolved: the caller takes `L` as
2802
- the name a cited file declares."""
2803
- if not 0 <= index < len(lines) or not re.search(
2804
- rf"(?<![\w$])as\s+{re.escape(name)}(?![\w$])", lines[index]
2805
- ):
2806
- return []
2807
- python = family == "python"
2808
- if python:
2809
- code = [line.split("#", 1)[0] for line in lines[: index + 1]]
2810
- opener, close, item_re = _PY_FROM_IMPORT_RE, ")", _PY_IMPORT_ITEM_RE
2811
- else:
2812
- code = [_JS_STRING_RE.sub('""', line) for line in _js_code_lines(lines[: index + 1])]
2813
- opener, close, item_re = _JS_EXPORT_LIST_RE, "}", _JS_EXPORT_ITEM_RE
2814
- for start in range(index, -1, -1):
2815
- opened = opener.match(code[start]) if _is_column_0(code[start]) else None
2816
- if opened is not None:
2817
- break
2818
- if start < index and close in code[start]:
2819
- return [] # a list that closes above the line holds it not
2820
- else:
2821
- return []
2822
- head = opened.group(1)
2823
- if start < index:
2824
- # an item of a list the opening line leaves open: in parentheses, or
2825
- # a Python clause each of whose lines above ends with a backslash
2826
- continued = all(line.rstrip().endswith("\\") for line in code[start:index])
2827
- if close in head or (python and not head.lstrip().startswith("(") and not continued):
2828
- return []
2829
- head = code[index]
2830
- locals_: list[str] = []
2831
- for item in head.split(close, 1)[0].replace("(", " ").replace("\\", " ").split(","):
2832
- found = item_re.match(item.strip())
2833
- if found is None or found.group(2) != name:
2898
+
2899
+ def _js_blanked(code: str) -> str:
2900
+ """A comment-free TS/JS line with each one-line string's contents
2901
+ blanked, its quotes and its length kept."""
2902
+ return _JS_STRING_RE.sub(lambda m: m[0][0] + " " * (len(m[0]) - 2) + m[0][-1], code)
2903
+
2904
+
2905
+ def _js_export_sides(item: str) -> tuple[str, str | None] | None:
2906
+ """(own name, the name after `as`, None without one) of an export-list
2907
+ item, a string side unquoted only when the string is the whole side
2908
+ (`L as "NAME"`, `"L" as NAME`). None for any other text."""
2909
+ m = _JS_EXPORT_ITEM_RE.match(item.strip())
2910
+ if m is None:
2911
+ return None
2912
+ own, exposed = (s[1:-1] if s and s[:1] in "'\"" else s for s in (m.group(1), m.group(2)))
2913
+ return own, exposed
2914
+
2915
+
2916
+ def _js_export_lists(
2917
+ lines: list[str], code: list[str]
2918
+ ) -> list[tuple[list[tuple[int, str]], str | None]]:
2919
+ """Each export list (`export { ... }`, `export type { ... }`) whose
2920
+ opening line is at column 0 as written (a comment may open the line):
2921
+ its items, as (line from 1, the item's comment-free text), and the
2922
+ module its `from` names, None for a local list. `code` holds the lines
2923
+ with comments removed (`_js_code_lines`). The list is split on a copy
2924
+ whose strings are blanked (`_js_blanked`), so a comma or a brace in a
2925
+ string splits nothing, and an item keeps its strings."""
2926
+ blank = [_js_blanked(c) for c in code]
2927
+ lists: list[tuple[list[tuple[int, str]], str | None]] = []
2928
+ i, n = 0, len(code)
2929
+ while i < n:
2930
+ opened = _JS_EXPORT_LIST_RE.match(blank[i]) if _is_column_0(lines[i]) else None
2931
+ if opened is None:
2932
+ i += 1
2834
2933
  continue
2835
- local = found.group(1)
2836
- if local == name or (not python and local == "default"):
2934
+ j, col, items = i, opened.start(1), []
2935
+ while True:
2936
+ segment = blank[j][col:]
2937
+ end = segment.find("}")
2938
+ pos = col
2939
+ for piece in (segment if end < 0 else segment[:end]).split(","):
2940
+ text = code[j][pos : pos + len(piece)].strip()
2941
+ if text:
2942
+ items.append((j + 1, text))
2943
+ pos += len(piece) + 1
2944
+ if end >= 0 or j + 1 >= n:
2945
+ break
2946
+ j, col = j + 1, 0
2947
+ module = None
2948
+ if end >= 0:
2949
+ after, k = code[j][col + end + 1 :], j + 1
2950
+ while not after.strip() and k < n: # `}` then `from` on the next line
2951
+ after, k = code[k], k + 1
2952
+ found = _JS_FROM_RE.match(after)
2953
+ module = found.group(2) if found else None
2954
+ lists.append((items, module))
2955
+ i = j + 1
2956
+ return lists
2957
+
2958
+
2959
+ def _js_import_bindings(lines: list[str], code: list[str]) -> list[_Binding]:
2960
+ """The names each column-0 import binds (`_Binding`): `import X from`
2961
+ binds X to the imported name `default`, `import { L as X }` binds X to
2962
+ L and `import { X }` X to X, on one line or over several, and a
2963
+ namespace import (`import * as X`, `import X = ...`) binds X to none."""
2964
+ found: list[_Binding] = []
2965
+ i, n = 0, len(code)
2966
+ while i < n:
2967
+ opens = _is_column_0(lines[i]) and _JS_IMPORT_RE.match(code[i]) is not None
2968
+ if not opens or _JS_IMPORT_BARE_RE.match(code[i]):
2969
+ i += 1
2970
+ continue
2971
+ j, text = i, code[i]
2972
+ while "{" in text and "}" not in text and j + 1 < n:
2973
+ j += 1
2974
+ text += "\n" + code[j]
2975
+ clause = _JS_IMPORT_CLAUSE_RE.match(text)
2976
+ if clause is None and j + 1 < n and _JS_FROM_RE.match(code[j + 1]):
2977
+ j += 1
2978
+ text += "\n" + code[j]
2979
+ clause = _JS_IMPORT_CLAUSE_RE.match(text)
2980
+ line = i + 1
2981
+ if clause is not None:
2982
+ names, module = clause.group(1).strip(), clause.group(3)
2983
+ default = _JS_IMPORT_DEFAULT_RE.match(names)
2984
+ if default is not None:
2985
+ found.append((line, default.group(1), "default", module))
2986
+ names = names[default.end() :].strip()
2987
+ if names.startswith("*"):
2988
+ namespace = _JS_IMPORT_NAMESPACE_RE.match(names)
2989
+ if namespace is not None:
2990
+ found.append((line, namespace.group(1), None, module))
2991
+ elif names.startswith("{"):
2992
+ for item in names[1:].split("}", 1)[0].split(","):
2993
+ m = _JS_IMPORT_ITEM_RE.match(item.strip())
2994
+ if m is None:
2995
+ continue
2996
+ imported = m.group(1)
2997
+ imported = imported[1:-1] if imported[:1] in "'\"" else imported
2998
+ local = m.group(2) or imported
2999
+ if _JS_IDENT_RE.fullmatch(local):
3000
+ found.append((line, local, imported, module))
3001
+ else:
3002
+ equals = _JS_IMPORT_EQUALS_RE.match(text)
3003
+ if equals is not None:
3004
+ found.append((line, equals.group(1), None, None))
3005
+ i = j + 1
3006
+ return found
3007
+
3008
+
3009
+ def _py_block_imports(lines: list[str], code: list[str], starts: set[int]) -> set[int]:
3010
+ """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`)."""
3015
+ found: set[int] = set()
3016
+ header, level = False, None
3017
+ for number in sorted(starts):
3018
+ text = lines[number - 1]
3019
+ if _is_column_0(text):
3020
+ header, level = _PY_BLOCK_HEADER_RE.match(code[number - 1]) is not None, None
2837
3021
  continue
2838
- if (_PY_IDENT_RE if python else _JS_IDENT_RE).fullmatch(local):
2839
- locals_.append(local)
2840
- return locals_
3022
+ indent = len(text) - len(text.lstrip())
3023
+ if level is None:
3024
+ level = indent # the block's first statement sets its level
3025
+ if header and indent == level and _PY_FROM_CLAUSE_RE.match(code[number - 1]):
3026
+ found.add(number)
3027
+ return found
3028
+
3029
+
3030
+ def _py_from_clauses(
3031
+ code: list[str], starts: set[int], keep: Callable[[int], bool]
3032
+ ) -> list[tuple[int, str, list[tuple[int, str]]]]:
3033
+ """(line from 1, module, items) of each `from X import` statement that
3034
+ 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."""
3036
+ clauses: list[tuple[int, str, list[tuple[int, str]]]] = []
3037
+ i, n = 0, len(code)
3038
+ while i < n:
3039
+ opened = _PY_FROM_CLAUSE_RE.match(code[i]) if i + 1 in starts and keep(i + 1) else None
3040
+ if opened is None:
3041
+ i += 1
3042
+ continue
3043
+ rest, items, j = opened.group(2).strip(), [], i
3044
+ paren = rest.startswith("(")
3045
+ if paren:
3046
+ rest = rest[1:]
3047
+ while True:
3048
+ if paren:
3049
+ done = ")" in rest
3050
+ piece = rest.split(")", 1)[0]
3051
+ else:
3052
+ piece = rest.rstrip()
3053
+ done = not piece.endswith("\\")
3054
+ piece = piece if done else piece[:-1]
3055
+ items += [(j + 1, t.strip()) for t in piece.split(",") if t.strip()]
3056
+ if done or j + 1 >= n:
3057
+ break
3058
+ j += 1
3059
+ rest = code[j]
3060
+ clauses.append((i + 1, opened.group(1), items))
3061
+ i = j + 1
3062
+ return clauses
3063
+
3064
+
3065
+ class _Renames:
3066
+ """The renames one file makes, read once for the suspects' lookup (see
3067
+ `renames`), and the lines it adds to a name's declarations
3068
+ (`candidates`). `parts` is the file's path below the source root,
3069
+ `lines` its lines, `cache` the `_SourceCache` its rules ran with. A
3070
+ TS/JS line is read with its comments removed and an export list split
3071
+ with its strings blanked (`_js_export_lists`), a Python line with its
3072
+ `#` comment cut; a Python file that does not tokenize renames
3073
+ nothing."""
3074
+
3075
+ def __init__(self, parts: tuple[str, ...], lines: list[str], cache: _SourceCache) -> None:
3076
+ self.parts = parts
3077
+ self.lines = lines
3078
+ self.text = "\n".join(lines)
3079
+ path = Path(*parts)
3080
+ self.python = _rule_family(parts[-1]) == "python"
3081
+ self.starts: set[int] | None = None
3082
+ if self.python:
3083
+ self.code = [line.split("#", 1)[0] for line in lines]
3084
+ self.starts = cache.py_statement_starts(path, lines)
3085
+ else:
3086
+ self.code = cache.js_code_lines(path, lines)
3087
+ self._clauses: list[tuple[int, str, list[tuple[int, str]]]] | None = None
3088
+ self._lists: list[tuple[list[tuple[int, str]], str | None]] | None = None
3089
+ self._bindings: list[_Binding] | None = None
3090
+
3091
+ # -- reading, once per file ---------------------------------------------
3092
+
3093
+ 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`)."""
3096
+ if self._clauses is None:
3097
+ if self.starts is None:
3098
+ self._clauses = []
3099
+ else:
3100
+ blocks = _py_block_imports(self.lines, self.code, self.starts)
3101
+ self._clauses = _py_from_clauses(
3102
+ self.code, self.starts,
3103
+ lambda n: _is_column_0(self.lines[n - 1]) or n in blocks,
3104
+ )
3105
+ return self._clauses
3106
+
3107
+ def _js_lists(self) -> list[tuple[list[tuple[int, str]], str | None]]:
3108
+ if self._lists is None:
3109
+ self._lists = _js_export_lists(self.lines, self.code)
3110
+ return self._lists
3111
+
3112
+ def _imports(self) -> list[_Binding]:
3113
+ """The names the file's column-0 imports bind (`_Binding`)."""
3114
+ if self._bindings is None:
3115
+ if not self.python:
3116
+ self._bindings = _js_import_bindings(self.lines, self.code)
3117
+ elif self.starts is None:
3118
+ self._bindings = []
3119
+ else:
3120
+ found: list[_Binding] = []
3121
+ for start, module, items in self._py_clauses():
3122
+ if not _is_column_0(self.lines[start - 1]):
3123
+ continue
3124
+ for _, item in items:
3125
+ m = _PY_IMPORT_ITEM_RE.match(item)
3126
+ if m is not None and _PY_IDENT_RE.fullmatch(m.group(1)):
3127
+ found.append((start, m.group(2) or m.group(1), m.group(1), module))
3128
+ for number in sorted(self.starts):
3129
+ m = _PY_IMPORT_RE.match(self.code[number - 1])
3130
+ if m is None or not _is_column_0(self.lines[number - 1]):
3131
+ continue
3132
+ for item in m.group(1).split(","):
3133
+ bound = _PY_IMPORT_ITEM_RE.match(item.strip())
3134
+ if bound is not None: # `import a.b as c`, `import a`: a namespace
3135
+ local = bound.group(2) or bound.group(1).split(".", 1)[0]
3136
+ found.append((number, local, None, None))
3137
+ found.sort(key=lambda b: b[0])
3138
+ self._bindings = found
3139
+ return self._bindings
3140
+
3141
+ def binding(self, local: str, number: int) -> tuple[str | None, str | None] | None:
3142
+ """(imported name, module) of the column-0 import that binds `local`
3143
+ for line `number` (from 1): the last one at or above it, else, in
3144
+ TS/JS, where imports are hoisted, the first below. The imported name
3145
+ is None for a namespace import (it binds no name). None when no
3146
+ import binds it."""
3147
+ above = below = None
3148
+ for line, name, imported, module in self._imports():
3149
+ if name != local:
3150
+ continue
3151
+ if line <= number:
3152
+ above = (imported, module)
3153
+ elif below is None:
3154
+ below = (imported, module)
3155
+ return above if above is not None or self.python else below
3156
+
3157
+ def _bound(self, local: str, number: int) -> tuple[str | None, str | None]:
3158
+ """`local` as a rename's `L` with no `from`: the imported name and
3159
+ module of the import that binds it (no name for a namespace import),
3160
+ else `local` and no module."""
3161
+ return self.binding(local, number) or (local, None)
3162
+
3163
+ def _goes_on(self, index: int) -> bool:
3164
+ """True when the TS/JS statement on line `index` (from 0) goes on
3165
+ with the next line that holds code."""
3166
+ for code in self.code[index + 1 :]:
3167
+ if code.strip():
3168
+ return code.lstrip()[0] in _JS_VALUE_GOES_ON
3169
+ return False
3170
+
3171
+ # -- the answers --------------------------------------------------------
3172
+
3173
+ def candidates(self, name: str, cache: _SourceCache, module_fallback: bool) -> list[int]:
3174
+ """The lines (from 1, sorted) of the file the suspects' lookup reads
3175
+ for `name`: every column-0 line that declares it (`declared_line`'s
3176
+ rule; with `module_fallback`, line 1 of the module or package so
3177
+ 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`)."""
3182
+ if not name or "." in name:
3183
+ return []
3184
+ path = Path(*self.parts)
3185
+ top, _ = _line_matches(path.suffix.lower(), path, self.lines, name, cache, True)
3186
+ found = set(top)
3187
+ if not found and module_fallback and _module_name(self.parts) == name:
3188
+ found.add(1)
3189
+ esc = re.escape(name)
3190
+ if self.python:
3191
+ if re.search(rf"(?<!\w)as\s+{esc}(?!\w)", self.text):
3192
+ found.update(
3193
+ line for _, _, items in self._py_clauses() for line, item in items
3194
+ if self._py_renames(item, name) is not None
3195
+ )
3196
+ elif re.search(rf"(?<![\w$])(?:as|import(?:\s+type)?)\s+{esc}(?![\w$])", self.text) and any(
3197
+ local == name and imported not in (None, name)
3198
+ for _, local, imported, _ in self._imports()
3199
+ ):
3200
+ found.update(
3201
+ line for items, module in self._js_lists() if module is None
3202
+ for line, item in items if _js_export_sides(item) == (name, None)
3203
+ )
3204
+ return sorted(found)
3205
+
3206
+ @staticmethod
3207
+ def _py_renames(item: str, name: str) -> str | None:
3208
+ """`L` of a `from X import` item `L as NAME`, L another identifier."""
3209
+ m = _PY_IMPORT_ITEM_RE.match(item)
3210
+ if m is None or m.group(2) != name or m.group(1) == name:
3211
+ return None
3212
+ return m.group(1) if _PY_IDENT_RE.fullmatch(m.group(1)) else None
3213
+
3214
+ def renames(self, number: int, name: str) -> list[tuple[str, str | None]]:
3215
+ """(`L`, the module it comes from, None when unknown) for each
3216
+ rename to `name` on line `number` (from 1), `L` an identifier other
3217
+ than `name`:
3218
+
3219
+ - an export-list item `L as NAME` (`export { ... }`, `export type
3220
+ { ... }`, the list's opening line at column 0 as written, a string
3221
+ side unquoted when the string is the whole side), `L` `default`
3222
+ included when the list has a `from`;
3223
+ - a local export-list item `NAME` without `as`, when a column-0
3224
+ import binds NAME to another name (`import { L as NAME }`, or
3225
+ `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.
3232
+
3233
+ The module is the list's or the clause's `from`; a rename with none
3234
+ takes the column-0 import that binds `L` (`binding`), `L` becoming
3235
+ its imported name, and a namespace import's name renames nothing.
3236
+ Empty for any other line: `as NAME` in a comment or a string, a TS
3237
+ 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`."""
3240
+ if not 0 < number <= len(self.lines):
3241
+ return []
3242
+ found: list[tuple[str | None, str | None]] = []
3243
+ index = number - 1
3244
+ if self.python:
3245
+ for _, module, items in self._py_clauses():
3246
+ for line, item in items:
3247
+ local = self._py_renames(item, name) if line == number else None
3248
+ if local is not None:
3249
+ found.append((local, module))
3250
+ starts = self.starts or set()
3251
+ 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])
3253
+ if m is not None and m.group(1) != name:
3254
+ found.append(self._bound(m.group(1), number))
3255
+ return [(local, module) for local, module in found if local is not None]
3256
+ for items, module in self._js_lists():
3257
+ for line, item in items:
3258
+ sides = _js_export_sides(item) if line == number else None
3259
+ if sides is None:
3260
+ continue
3261
+ own, exposed = sides
3262
+ if exposed is None and own == name and module is None:
3263
+ bound = self.binding(name, number) # a plain re-export of an import
3264
+ if bound is not None and bound[0] != name:
3265
+ found.append(bound)
3266
+ elif exposed == name and own != name and _JS_IDENT_RE.fullmatch(own):
3267
+ if module is not None:
3268
+ found.append((own, module))
3269
+ elif own != "default":
3270
+ found.append(self._bound(own, number))
3271
+ 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)
3274
+ ):
3275
+ found.append(self._bound(m.group(2), number))
3276
+ return [(local, module) for local, module in found if local is not None]
2841
3277
 
2842
3278
 
2843
3279
  class _DeclarationLookup:
@@ -2857,15 +3293,19 @@ class _DeclarationLookup:
2857
3293
  (`_is_package_root`) that hold no cited file, the files
2858
3294
  `_lookup_skips_file` names and every symlink. Only the rule families
2859
3295
  (Python, TS/JS) the entries cite are read (both when no entry cites a
2860
- file). A cited file is read inside the source root only, with no symlink
2861
- on its path and the same skips but the walk-only ones, judged below the
2862
- walk root when it lies there. The walk runs once for all the names no
2863
- cited file settles: each file is read once, and only a file whose text
2864
- holds a name goes through the rules. `renamed` then tells which of a
2865
- name's declarations is an alias of a name a cited file declares (an
2866
- item of an export list or of a Python `from X import` clause), from
2867
- the lines the lookup read. Any error of the filesystem raises
2868
- (`_LOOKUP_ERRORS`)."""
3296
+ file). A cited file is read in any folder, a hidden one and one the walk
3297
+ skips included, but only inside the source root, with no symlink on its
3298
+ path and under a name `_lookup_skips_file` accepts (`_readable_below`).
3299
+ The walk runs once for all the names no cited file settles: each file is
3300
+ read once, and only a file whose text holds a name goes through the
3301
+ rules. In the suspects' pass (`keep_declaring`), a file's declarations
3302
+ are its candidate lines (`_Renames.candidates`: every column-0
3303
+ 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`)."""
2869
3309
 
2870
3310
  def __init__(self, entries: list[dict], source_root: Path) -> None:
2871
3311
  self.root = source_root
@@ -2884,9 +3324,13 @@ class _DeclarationLookup:
2884
3324
  # the cited files read, kept for the walk: a few files at most
2885
3325
  self._cited: dict[tuple[str, ...], list[str] | None] = {}
2886
3326
  self._cited_cache = _SourceCache()
2887
- # during the suspects' pass only (`keep_declaring`): the lines of each
2888
- # file a declaration was found in, by path, which `renamed` reads
2889
- self._declaring: dict[str, list[str]] | None = None
3327
+ # during the suspects' pass only (`keep_declaring`): the renames of
3328
+ # each file a declaration was found in, by path, and each (path, name)
3329
+ # its candidate lines, which `renamed` reads
3330
+ self._declaring: dict[str, _Renames] | None = None
3331
+ self._candidates: dict[tuple[str, str], list[int]] = {}
3332
+ # the files a rename's `from` resolves to and the renaming files, that no entry cites
3333
+ self._resolved: dict[tuple[str, ...], list[str] | None] = {}
2890
3334
 
2891
3335
  # -- the walk root ------------------------------------------------------
2892
3336
 
@@ -2951,20 +3395,14 @@ class _DeclarationLookup:
2951
3395
 
2952
3396
  # -- reading ------------------------------------------------------------
2953
3397
 
2954
- def _below_walk_root(self, parts: tuple[str, ...]) -> tuple[str, ...]:
2955
- depth = len(self.folder)
2956
- return parts[depth:] if parts[:depth] == self.folder else parts
2957
-
2958
3398
  def _reads_cited(self, parts: tuple[str, ...]) -> bool:
2959
3399
  """True when the lookup reads a cited file: one of a family the
2960
- entries cite, in no skipped place, with no symlink on its path and
2961
- inside the source root."""
2962
- if _rule_family(parts[-1]) not in self.families:
2963
- return False
2964
- below = self._below_walk_root(parts)
2965
- if any(_lookup_skips_folder(s) for s in below[:-1]):
2966
- return False
2967
- return _readable_below(self.root, parts, self.real_root)
3400
+ entries cite, in any folder (one the walk skips or a hidden one
3401
+ included), with no symlink on its path, inside the source root and
3402
+ under a name `_lookup_skips_file` accepts (`_readable_below`)."""
3403
+ return _rule_family(parts[-1]) in self.families and _readable_below(
3404
+ self.root, parts, self.real_root
3405
+ )
2968
3406
 
2969
3407
  def _cited_lines(self, parts: tuple[str, ...]) -> list[str] | None:
2970
3408
  if parts not in self._cited:
@@ -2973,6 +3411,74 @@ class _DeclarationLookup:
2973
3411
  self._cited[parts] = _text_lines(_read_source(path)) if readable else None
2974
3412
  return self._cited[parts]
2975
3413
 
3414
+ def _lines_of(self, parts: tuple[str, ...]) -> list[str] | None:
3415
+ """The lines of a file the alias check reads, a cited one or one a
3416
+ rename's `from` resolves to, under `_reads_cited`'s guards, or the
3417
+ renaming file, whose lines `renamed` keeps; each file read once."""
3418
+ if parts in self._cited:
3419
+ return self._cited[parts]
3420
+ if parts not in self._resolved:
3421
+ readable = self._reads_cited(parts)
3422
+ path = self.root.joinpath(*parts)
3423
+ self._resolved[parts] = _text_lines(_read_source(path)) if readable else None
3424
+ return self._resolved[parts]
3425
+
3426
+ def _declares(self, parts: tuple[str, ...], local: str) -> bool:
3427
+ """True when a file declares `local` by `declared_line`'s rule, no
3428
+ module or package so named counting."""
3429
+ lines = self._lines_of(parts)
3430
+ return lines is not None and declared_line(
3431
+ parts, local, lines=lines, cache=self._cited_cache, module_fallback=False
3432
+ ) is not None
3433
+
3434
+ def _resolve(self, parts: tuple[str, ...], module: str) -> list[tuple[str, ...]]:
3435
+ """The files below the source root that a rename's module names, from
3436
+ the renaming file `parts`, each one the lookup may read
3437
+ (`_reads_cited`). TS/JS: a `./` or `../` module only, from the file's
3438
+ folder: the path itself, a `.js`-family path's TypeScript twin
3439
+ (`./m.js` names `m.ts`), `<path><ext>` and `<path>/index<ext>`
3440
+ (`_TSJS_MODULE_EXTENSIONS`). Python: a relative module (`.m`, `..m`,
3441
+ `.` the folder's own `__init__`) from the file's folder, or an
3442
+ absolute one below the parent of the file's package root
3443
+ (`_package_root`): `m.py`, `m.pyi`, `m/__init__.py` and
3444
+ `m/__init__.pyi`. Empty for a bare package name, a tsconfig `paths`
3445
+ alias, a module that climbs out of the source root and one with no
3446
+ such file."""
3447
+ if _rule_family(parts[-1]) == "python":
3448
+ dots = len(module) - len(module.lstrip("."))
3449
+ names = module[dots:].split(".") if module[dots:] else []
3450
+ if any(not n for n in names):
3451
+ return []
3452
+ if dots:
3453
+ if dots > len(parts):
3454
+ return []
3455
+ base = parts[: len(parts) - dots]
3456
+ else:
3457
+ root = self._package_root(self._real_folder(parts[:-1]), "python")
3458
+ if root is None or not names:
3459
+ return []
3460
+ base = root[:-1]
3461
+ here = (*base, *names)
3462
+ tried = [(*here[:-1], here[-1] + ext) for ext in (".py", ".pyi")] if names else []
3463
+ tried += [(*here, "__init__.py"), (*here, "__init__.pyi")]
3464
+ else:
3465
+ if module not in (".", "..") and not module.startswith(("./", "../")):
3466
+ return [] # a package name or a `paths` alias: not read
3467
+ path = posixpath.normpath(posixpath.join(*parts[:-1], module))
3468
+ if path == ".." or path.startswith("../"):
3469
+ return []
3470
+ here = () if path == "." else tuple(path.split("/"))
3471
+ tried = []
3472
+ if here:
3473
+ stem, ext = posixpath.splitext(here[-1])
3474
+ if ext.lower() in TSJS_EXTENSIONS:
3475
+ tried.append(here)
3476
+ twins = _TSJS_TWINS.get(ext.lower(), ())
3477
+ tried += [(*here[:-1], stem + twin) for twin in twins]
3478
+ tried += [(*here[:-1], here[-1] + ext) for ext in _TSJS_MODULE_EXTENSIONS]
3479
+ tried += [(*here, "index" + ext) for ext in _TSJS_MODULE_EXTENSIONS]
3480
+ return [p for p in dict.fromkeys(tried) if self._reads_cited(p)]
3481
+
2976
3482
  def _is_package_root(self, folder: tuple[str, ...], filenames: list[str]) -> bool:
2977
3483
  """True when a walked folder (its `filenames`) is a package root of a
2978
3484
  family the walk reads, as `_package_root` finds one: TS/JS, a folder
@@ -3032,21 +3538,31 @@ class _DeclarationLookup:
3032
3538
  ) -> str | None:
3033
3539
  """`file:line` of the first line of a file that declares `name`, else,
3034
3540
  with `module_fallback`, its line 1 when it is the module or package
3035
- so named (`declared_line`). After `keep_declaring`, the file's lines
3036
- are kept for `renamed`."""
3037
- line = declared_line(
3038
- parts, name, lines=lines, cache=cache, module_fallback=module_fallback
3039
- )
3040
- if line is None:
3541
+ so named (`declared_line`). After `keep_declaring`, the first of the
3542
+ file's candidate lines (`_Renames.candidates`, which adds a Python
3543
+ compatibility import and a TS/JS re-export of a renaming import),
3544
+ each kept, with the file's renames, for `renamed`."""
3545
+ file = "/".join(parts)
3546
+ if self._declaring is None:
3547
+ line = declared_line(
3548
+ parts, name, lines=lines, cache=cache, module_fallback=module_fallback
3549
+ )
3550
+ return None if line is None else f"{file}:{line}"
3551
+ renames = self._declaring.get(file) or _Renames(parts, lines, cache)
3552
+ candidates = renames.candidates(name, cache, module_fallback)
3553
+ if not candidates:
3041
3554
  return None
3042
- if self._declaring is not None:
3043
- self._declaring['/'.join(parts)] = lines
3044
- return f"{'/'.join(parts)}:{line}"
3555
+ self._declaring[file] = renames
3556
+ self._candidates[(file, name)] = candidates
3557
+ return f"{file}:{candidates[0]}"
3045
3558
 
3046
3559
  # -- the answer ---------------------------------------------------------
3047
3560
 
3048
3561
  def find(
3049
- self, cited: dict[str, list[str]], strict: frozenset[str] = frozenset()
3562
+ self,
3563
+ cited: dict[str, list[str]],
3564
+ strict: frozenset[str] = frozenset(),
3565
+ cited_fallback: bool = True,
3050
3566
  ) -> tuple[dict[str, str], dict[str, list[str]]]:
3051
3567
  """For the names of `cited` (name -> the source_file values its
3052
3568
  entries record): name -> `file:line` for each one the source
@@ -3055,7 +3571,10 @@ class _DeclarationLookup:
3055
3571
  cited file settles). A dotted name is never looked up, and a name of
3056
3572
  `strict` takes no line 1 from a module or package so named that is
3057
3573
  not itself the export (`_exports_itself`), in a cited file or in
3058
- the walk."""
3574
+ the walk. Without `cited_fallback` (the suspects' pass), a cited
3575
+ file takes no line 1 from its module or package so named, the export
3576
+ or not, so that the walk runs and finds the renames; the walk keeps
3577
+ its module rule."""
3059
3578
  found: dict[str, str] = {}
3060
3579
  if not self.families:
3061
3580
  return found, {}
@@ -3067,7 +3586,8 @@ class _DeclarationLookup:
3067
3586
  lines = self._cited_lines(parts)
3068
3587
  hit = None if lines is None else self._first(
3069
3588
  parts, lines, name, self._cited_cache,
3070
- name not in strict or _exports_itself(parts, "\n".join(lines)),
3589
+ cited_fallback
3590
+ and (name not in strict or _exports_itself(parts, "\n".join(lines))),
3071
3591
  )
3072
3592
  if hit is not None:
3073
3593
  found[name] = hit
@@ -3125,33 +3645,56 @@ class _DeclarationLookup:
3125
3645
  return {name: sorted(_without_stubs(h)) for name, h in hits.items()}, unsure
3126
3646
 
3127
3647
  def keep_declaring(self) -> None:
3128
- """From now on, keep the lines of each file a declaration is found in,
3129
- for `renamed`: the suspects' pass, which comes last."""
3648
+ """From now on, keep the candidate lines and the renames of each file
3649
+ a declaration is found in, for `renamed`: the suspects' pass, which
3650
+ comes last."""
3130
3651
  self._declaring = {}
3131
-
3132
- def renamed(self, name: str, hits: list[str], files: list[str]) -> str | None:
3133
- """The first of `hits` (`file:line` declarations of `name` the lookup
3134
- found, sorted) whose line renames to `name`, as an item of an export
3135
- list or of a Python `from X import` clause (`_renamed_locals`), a
3136
- name `L` one of `files` (source_file values, as recorded) declares:
3137
- `declared_line` gives `L` a line of that file, no module or package
3138
- so named counting. The lines are the ones the lookup read after
3139
- `keep_declaring` (`_first`), never read again. None when no hit is
3140
- such an alias."""
3652
+ self._candidates = {}
3653
+
3654
+ def _aliases(
3655
+ 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)
3673
+
3674
+ def renamed(
3675
+ self, name: str, hits: list[str], files: list[str]
3676
+ ) -> tuple[str, str] | None:
3677
+ """(`file:line`, `L`) of the first rename to `name` that is an alias
3678
+ of the declaration of `L` a file of `files` (source_file values, as
3679
+ recorded) holds (`_aliases`): per file of `hits` (`file:line`
3680
+ declarations of `name` the lookup found, sorted), per candidate
3681
+ line in order (`_Renames.candidates`), each rename the line makes
3682
+ (`_Renames.renames`). The lines are the ones the lookup read after
3683
+ `keep_declaring` (`_first`), never read again; a file a rename's
3684
+ module resolves to is read once. None when no line is such an
3685
+ alias."""
3141
3686
  cited = list(dict.fromkeys(filter(None, map(_cited_parts, files))))
3142
3687
  for hit in sorted(hits):
3143
3688
  file, _, number = hit.rpartition(":")
3144
- lines, family = (self._declaring or {}).get(file), _rule_family(file)
3145
- if lines is None or family is None or not number.isdigit():
3689
+ renames = (self._declaring or {}).get(file)
3690
+ if renames is None or not number.isdigit():
3146
3691
  continue
3147
- for local in _renamed_locals(lines, int(number) - 1, name, family):
3148
- for parts in cited:
3149
- source = self._cited_lines(parts)
3150
- if source is not None and declared_line(
3151
- parts, local, lines=source, cache=self._cited_cache,
3152
- module_fallback=False,
3153
- ) is not None:
3154
- return hit
3692
+ if renames.parts not in self._cited: # read already, and `_aliases` may check it
3693
+ self._resolved.setdefault(renames.parts, renames.lines)
3694
+ for line in self._candidates.get((file, name), [int(number)]):
3695
+ for local, module in renames.renames(line, name):
3696
+ if self._aliases(renames.parts, local, module, cited):
3697
+ return f"{file}:{line}", local
3155
3698
  return None
3156
3699
 
3157
3700
 
@@ -3193,12 +3736,14 @@ def classify_stale(
3193
3736
  (`defined_at`, and `declared_in` when the walk looked for it). A name
3194
3737
  the cited files do not define is looked up too, after the others and
3195
3738
  in a lookup of its own (an error there keeps it fabricated and clears
3196
- no other name), with no module or package so named counting unless it
3197
- is the export, and is `aliased`, not fabricated, when one of its
3198
- declarations is an export-list or `from X import` item renaming to it
3199
- a name a `checked` entry's file declares (`_DeclarationLookup.renamed`);
3200
- any other such name keeps both null. The classify-stale section of the
3201
- module docstring gives each reason, what a `no-file` entry means, which
3739
+ no other name), with no line 1 from a cited module or package so named,
3740
+ and none in the walk unless it is the export, and is `aliased`, not
3741
+ 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
3202
3747
  entry gives `source` and how `defined_at` and `declared_in` are found.
3203
3748
  `kinds` (`inventory_kinds`, None without `--inventory`) keeps a name of
3204
3749
  a `NO_MODULE_KINDS` kind from a module or package so named, unless an
@@ -3271,30 +3816,33 @@ def classify_stale(
3271
3816
  found, declared = {}, {}
3272
3817
  # the suspects' own lookup, after: an error there clears no name settled
3273
3818
  # above, and keeps every suspect fabricated
3274
- aliased: dict[str, str] = {}
3819
+ aliased: dict[str, tuple[str, str]] = {} # name -> (the rename's `file:line`, the `L` it renames)
3275
3820
  suspect_declared: dict[str, list[str]] = {}
3276
3821
  if suspects and lookup is not None:
3277
3822
  try:
3278
- # a suspect is strict: a cited module so named settles it before
3279
- # the walk only when it is the export
3823
+ # a suspect is strict, and a cited module so named never settles
3824
+ # it before the walk, which finds the renames
3280
3825
  lookup.keep_declaring()
3281
- suspect_found, suspect_declared = lookup.find(suspects, frozenset(suspects))
3826
+ suspect_found, suspect_declared = lookup.find(
3827
+ suspects, frozenset(suspects), cited_fallback=False
3828
+ )
3282
3829
  for name, files in suspects.items():
3283
3830
  hits = suspect_declared[name] if name in suspect_declared else (
3284
3831
  [suspect_found[name]] if name in suspect_found else []
3285
3832
  )
3286
- hit = lookup.renamed(name, hits, files)
3287
- if hit is not None:
3288
- aliased[name] = hit
3833
+ alias = lookup.renamed(name, hits, files)
3834
+ if alias is not None:
3835
+ aliased[name] = alias
3289
3836
  except _LOOKUP_ERRORS:
3290
3837
  aliased, suspect_declared = {}, {}
3291
3838
  for item in results:
3292
3839
  name = item["name"]
3293
3840
  if name in suspects:
3294
3841
  if name in aliased:
3842
+ defined_at, local = aliased[name]
3295
3843
  item.update(
3296
3844
  fabricated=False, reason=STALE_ALIASED,
3297
- defined_at=aliased[name], declared_in=suspect_declared.get(name),
3845
+ defined_at=defined_at, declared_in=suspect_declared.get(name), renames=local,
3298
3846
  )
3299
3847
  continue # any other suspect stays fabricated, both null
3300
3848
  item["defined_at"] = found.get(name)