bmad-module-skill-forge 7.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.
Files changed (87) 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 +8 -8
  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 +957 -183
  25. package/src/skf-analyze-source/SKILL.md +1 -1
  26. package/src/skf-audit-skill/SKILL.md +1 -1
  27. package/src/skf-audit-skill/customize.toml +4 -3
  28. package/src/skf-audit-skill/references/report.md +1 -1
  29. package/src/skf-brief-skill/SKILL.md +1 -1
  30. package/src/skf-campaign/SKILL.md +1 -1
  31. package/src/skf-campaign/assets/campaign-state-schema.json +58 -0
  32. package/src/skf-campaign/references/campaign-contracts.md +1 -1
  33. package/src/skf-campaign/references/step-06-batch.md +2 -2
  34. package/src/skf-campaign/references/step-07-capstone.md +1 -1
  35. package/src/skf-campaign/references/step-08-verify.md +1 -1
  36. package/src/skf-campaign/references/step-09-refine.md +1 -1
  37. package/src/skf-campaign/references/step-11-maintenance.md +1 -1
  38. package/src/skf-campaign/scripts/campaign-render-batch.py +43 -10
  39. package/src/skf-campaign/scripts/campaign-report.py +60 -4
  40. package/src/skf-campaign/scripts/campaign-state.py +67 -19
  41. package/src/skf-campaign/templates/campaign-report-template.md +4 -0
  42. package/src/skf-create-skill/SKILL.md +1 -1
  43. package/src/skf-create-skill/assets/skill-sections.md +2 -2
  44. package/src/skf-create-skill/references/report.md +2 -2
  45. package/src/skf-create-skill/references/validate.md +49 -29
  46. package/src/skf-create-stack-skill/SKILL.md +1 -1
  47. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  48. package/src/skf-create-stack-skill/references/report.md +3 -3
  49. package/src/skf-create-stack-skill/references/validate.md +9 -4
  50. package/src/skf-drop-skill/SKILL.md +1 -1
  51. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  52. package/src/skf-export-skill/SKILL.md +1 -1
  53. package/src/skf-export-skill/references/summary.md +1 -1
  54. package/src/skf-forger/SKILL.md +1 -1
  55. package/src/skf-quick-skill/SKILL.md +1 -1
  56. package/src/skf-quick-skill/references/batch-mode.md +28 -2
  57. package/src/skf-quick-skill/references/compile.md +2 -0
  58. package/src/skf-quick-skill/references/finalize.md +3 -2
  59. package/src/skf-quick-skill/references/halt-contract.md +3 -3
  60. package/src/skf-quick-skill/references/quick-extract.md +2 -1
  61. package/src/skf-quick-skill/references/write-and-validate.md +11 -5
  62. package/src/skf-refine-architecture/SKILL.md +1 -1
  63. package/src/skf-refine-architecture/customize.toml +1 -1
  64. package/src/skf-refine-architecture/references/report.md +2 -2
  65. package/src/skf-rename-skill/SKILL.md +1 -1
  66. package/src/skf-rename-skill/references/report.md +1 -1
  67. package/src/skf-setup/SKILL.md +1 -1
  68. package/src/skf-test-skill/SKILL.md +1 -1
  69. package/src/skf-test-skill/customize.toml +10 -0
  70. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  71. package/src/skf-test-skill/references/coverage-check.md +2 -2
  72. package/src/skf-test-skill/references/init.md +1 -1
  73. package/src/skf-test-skill/references/invocation-contract.md +1 -1
  74. package/src/skf-test-skill/references/report.md +3 -2
  75. package/src/skf-test-skill/references/scoring-rules.md +2 -2
  76. package/src/skf-test-skill/references/signature-comparison-rules.md +4 -1
  77. package/src/skf-test-skill/scripts/gap-ledger.py +218 -46
  78. package/src/skf-test-skill/scripts/load-coverage-inputs.py +12 -6
  79. package/src/skf-test-skill/templates/test-report-template.md +4 -3
  80. package/src/skf-update-skill/SKILL.md +1 -1
  81. package/src/skf-update-skill/references/gap-driven.md +1 -1
  82. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  83. package/src/skf-update-skill/references/merge.md +1 -1
  84. package/src/skf-update-skill/references/report.md +18 -7
  85. package/src/skf-update-skill/references/write.md +24 -12
  86. package/src/skf-verify-stack/SKILL.md +1 -1
  87. 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,119 @@ 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, 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.
494
+ The renames read:
495
+ - an export-list item `L as NAME` (`export { ... }`,
496
+ `export type { ... }`, on one line or an item of a multi-line
497
+ list), `L` `default` when the list has a `from`
498
+ (`export { default as NAME } from './m'`), a string side unquoted
499
+ only when the string is the whole side (`L as "NAME"`,
500
+ `"L" as NAME`), the list's opening line at column 0 as written, so
501
+ that a comment may open it;
502
+ - a local `export { NAME }` or `export type { NAME }` item without
503
+ `as`, when a column-0 import binds NAME to another name
504
+ (`import { L as NAME }`, or `import NAME from`, `L` `default`), at
505
+ the export;
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;`);
517
+ - a Python `from X import L as NAME` item (on one line, in
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.
525
+ A TS/JS line is read with its comments removed (an export list split
526
+ on a copy whose one-line strings are blanked), a Python line with its
527
+ `#` comment cut. `L` comes from the rename's `from` module, else from
528
+ the column-0 import that binds `L`, whose imported name `L` becomes
529
+ (`default` for a default import; a namespace import binds none). The
530
+ module is resolved below the source root, under the lookup's guards
531
+ for a cited file: a TS/JS `./` or `../` module from the renaming
532
+ file's folder, as the path itself, a `.js`-family path's TypeScript
533
+ twin (`./m.js` names `m.ts`), `<path><ext>` or `<path>/index<ext>`
534
+ (`.d.ts` included); a Python relative module from the file's folder
535
+ (`.` the folder's own `__init__`), or an absolute one below the parent
536
+ of the file's package root, as `m.py`, `m.pyi`, `m/__init__.py` or
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.
494
584
  Otherwise the reason is `defined` (a cited file defines the name),
495
585
  `unchecked` (no rule could check a cited file, and none defines it) or
496
586
  `no-entry` (the map has no entry for the name).
@@ -511,8 +601,9 @@ Subcommands:
511
601
  a `def`, `class`, assignment, annotation, type alias, TS/JS
512
602
  declaration, `exports.NAME =`, or an alias that renames
513
603
  (`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,
604
+ `export { Y as NAME }`, `export * as NAME from`), a
605
+ `module.exports` key with a value or a method, or, for the name
606
+ `default`, any `export default` line. A plain or star import,
516
607
  an `as` that keeps the imported name (`from m import NAME as NAME`,
517
608
  `export { NAME as NAME }`), `export import NAME =`,
518
609
  `export default NAME`, an export-list item without `as`, a
@@ -521,10 +612,18 @@ Subcommands:
521
612
  `const NAME = require(...)`, `const { NAME } = require(...)`,
522
613
  `exports.NAME = require(...)`, `{ NAME: require(...) }`), an
523
614
  indented line and any line of a Python
524
- file that does not tokenize never count (an item of a multi-line list
615
+ file that does not tokenize never count, except that for the name
616
+ `default` a column-0 `export default` line counts whatever follows it,
617
+ an import or an imported local included (an item of a multi-line list
525
618
  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
619
+ `aliased` name's `defined_at` is its rename's line). For a
620
+ `not-defined` name the alias check reads more lines than this rule: a
621
+ file's candidate lines are every column-0 line that declares the name,
622
+ a Python `from X import L as NAME` item one level in a column-0
623
+ `try`, `except`, `else`, `if` or `elif` block, and a TS/JS local
624
+ `export { NAME }` item when a column-0 import binds NAME to another
625
+ name, and its `declared_in` lists the file at the first of them. A
626
+ module or package named NAME (`NAME.py`, `NAME/__init__.py`, a TS/JS
528
627
  `NAME.<ext>` or `NAME/index.<ext>`) declares NAME too: at its line 1
529
628
  when no line of it
530
629
  declares NAME, unless `--inventory` gives NAME a `function`, `hook`,
@@ -566,18 +665,20 @@ Subcommands:
566
665
  test output lives in (`node_modules`, `dist`, `build`, `vendor`,
567
666
  `_vendor`, `tests`, `testing`, `fixtures`, `__mocks__`,
568
667
  `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
668
+ the like), each judged on the path below the walk root, the
669
+ end-to-end test folders `e2e` and `__fixtures__` wherever they lie,
670
+ and, as the first folder below the walk root or below a package root
671
+ inside it (a folder that holds `package.json`, the top of an
672
+ `__init__.py` chain), `example`, `demo`, `demos`, `sample`, `samples`,
673
+ `scripts`, `playground`, `bench`, `benchmark`, `migrations`, `docs`,
674
+ `examples` and `benchmarks` unless it holds a file an entry cites
675
+ (deeper in a package such a folder can be package code, as Storybook's
676
+ `code/addons/docs/`). A cited file is read in any folder, a hidden one
677
+ and one the walk skips included, but only inside the source root (a
678
+ path that is absolute or climbs out of it is not read), with no
679
+ symlink on its path and under a name the walk reads (no test file,
680
+ test configuration file or `*.min.js`). The path is relative to the
681
+ source root. A dotted name, or a map whose entries cite files
581
682
  but no Python or TS/JS one, is not looked up (`defined_at` and
582
683
  `declared_in` null, as for a fabricated name). A filesystem error
583
684
  during the lookup of the names that are not fabricated gives each of
@@ -1928,8 +2029,12 @@ def _tsjs_definition_lines(
1928
2029
  depend on the name either): a name its pattern binds counts when the
1929
2030
  `const` / `let` / `var` line it is keyed by opens a statement here.
1930
2031
  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
2032
+ dotted export name. For the undotted name `default`, the name SKF's
2033
+ extraction gives a module's default export, a column-0 `export default`
2034
+ line counts whatever follows it, with `declarations_only` too, and
2035
+ whatever block holds it; an indented one (as in a formatted
2036
+ `declare module` block) does not. `declarators` splits
2037
+ a declarator list (`_js_declarators`, or `_SourceCache.js_declarators`, which splits each
1933
2038
  line once for every export looked up). With `declarations_only`,
1934
2039
  `export import NAME =`, `export default NAME`, an export-list item
1935
2040
  without an `as` that renames, a `module.exports` shorthand key and an
@@ -1953,6 +2058,11 @@ def _tsjs_definition_lines(
1953
2058
  re.compile(rf"^\s*export\s+import\s+(?:type\s+)?{esc}\s*=(?![=>])"),
1954
2059
  re.compile(rf"^\s*export\s+default\s+{esc}\s*;?\s*$"),
1955
2060
  ]
2061
+ if name == "default" and not dotted:
2062
+ # the export named `default`: a column-0 `export default` line,
2063
+ # whatever follows it (a name, a declaration, an expression, or
2064
+ # nothing, the value on the next line), decorators before it
2065
+ statements.append(re.compile(rf"^(?:{_JS_DECORATOR}\s*)*export\s+default{end}"))
1956
2066
  # One declarator of a `const` / `let` / `var` list: `NAME = v`,
1957
2067
  # `NAME: T = v`, `NAME!: T`, or a bare `NAME`. A destructuring pattern
1958
2068
  # starts with `{` or `[` and never matches: the names a pattern that
@@ -2452,8 +2562,10 @@ def _stale_line_check(
2452
2562
 
2453
2563
  # The folders and files the `defined_at` walk leaves out, judged
2454
2564
  # 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.
2565
+ # installed output, a vendored copy of another package, and tests and their
2566
+ # configuration. The folders are the walk's alone: a file the map cites
2567
+ # is read in any of them, a test file, its configuration or a minified
2568
+ # bundle never.
2457
2569
  # Keep identical to EXCLUDED_DIR_NAMES in skf-resolve-authoritative-files.py.
2458
2570
  EXCLUDED_DIR_NAMES = {
2459
2571
  "node_modules", "__pycache__", "dist", "build", ".webpack",
@@ -2491,8 +2603,7 @@ LOOKUP_EXTRA_FOLDERS = frozenset({
2491
2603
  "bower_components", "esm", "cjs", "umd",
2492
2604
  })
2493
2605
  LOOKUP_SKIPPED_FOLDERS = frozenset(
2494
- EXCLUDED_DIR_NAMES | _VENDORED_SEGMENTS | TEST_FOLDERS | PY_NON_CORE_TOPS
2495
- | LOOKUP_EXTRA_FOLDERS
2606
+ EXCLUDED_DIR_NAMES | _VENDORED_SEGMENTS | TEST_FOLDERS | LOOKUP_EXTRA_FOLDERS
2496
2607
  )
2497
2608
  # The test folders the walk alone skips wherever they lie (end-to-end tests,
2498
2609
  # Jest's `__fixtures__` beside the code): a file the map cites in one is
@@ -2500,15 +2611,17 @@ LOOKUP_SKIPPED_FOLDERS = frozenset(
2500
2611
  LOOKUP_WALK_FOLDERS = frozenset({"e2e", "__fixtures__"})
2501
2612
  # The folders the walk alone skips as the first folder below its walk root
2502
2613
  # 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.
2614
+ # examples and demos, samples, scripts, playgrounds, benchmarks, migrations
2615
+ # and docs (`PY_NON_CORE_TOPS`, which skf-extract-public-api.py also leaves
2616
+ # out only as a top folder), which hold code that uses, changes or documents
2617
+ # the package rather than its API. Deeper in a package such a folder can be
2618
+ # live package code (cognee's `cognee/modules/migrations/`, Storybook's
2619
+ # `code/addons/docs/`), so it is walked there; one that holds a file the map
2620
+ # cites is walked too, and that file is still read.
2508
2621
  LOOKUP_TOP_FOLDERS = frozenset({
2509
2622
  "example", "demo", "demos", "sample", "samples", "scripts", "playground",
2510
2623
  "bench", "benchmark", "migrations",
2511
- })
2624
+ }) | PY_NON_CORE_TOPS
2512
2625
  # The kinds of a documented inventory (validate-inventory.py's `kind`) that a
2513
2626
  # module cannot be: such a name takes no line 1 from a module so named. A
2514
2627
  # `method` is left out: reconcile-coverage.py never lists a method as stale,
@@ -2647,7 +2760,9 @@ def declared_line(
2647
2760
  `class`, assignment, annotation, type alias, TS/JS declaration, or an
2648
2761
  alias that renames, such as `from X import Y as NAME`; a plain or star
2649
2762
  import, an `as` that keeps the imported name, a re-export and an
2650
- import-valued binding never count), else, with `module_fallback`, its
2763
+ import-valued binding never count, except that for the name `default`
2764
+ a column-0 `export default` line counts whatever follows it, an import
2765
+ or an imported local included), else, with `module_fallback`, its
2651
2766
  line 1 when it is the module or package so named (`_module_name`).
2652
2767
 
2653
2768
  `source_file` is the file's path below the source root, as recorded
@@ -2778,66 +2893,535 @@ def baseline_gaps(
2778
2893
  return gaps, unchecked
2779
2894
 
2780
2895
 
2781
- # A one-line TS/JS string literal, blanked before an export list is read.
2896
+ # A one-line TS/JS string literal: an export list is split into items on a
2897
+ # copy of its lines whose strings are blanked, quotes and length kept.
2782
2898
  _JS_STRING_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|`(?:[^`\\]|\\.)*`")
2783
2899
  _JS_IDENT_RE = re.compile(r"[A-Za-z_$][\w$]*")
2900
+ # The `from` clause after an export list's closing brace, and its module.
2901
+ _JS_FROM_RE = re.compile(r"""^\s*from\s*(["'])([^"']*)\1""")
2902
+ # An import statement (not `import(...)` nor `import.meta`); its clause
2903
+ # and module (`import X, { a as b } from './m'`); `import X = ...`; a
2904
+ # side-effect import (`import './m'`); an item of its brace list.
2905
+ _JS_IMPORT_RE = re.compile(r"^\s*import(?![\w$])(?!\s*[(.])")
2906
+ _JS_IMPORT_CLAUSE_RE = re.compile(
2907
+ r"""^\s*import\s+(?:type\s+(?!from(?![\w$])))?(.*?)\s*(?<![\w$])from\s*(["'])([^"']*)\2""",
2908
+ re.S,
2909
+ )
2910
+ _JS_IMPORT_EQUALS_RE = re.compile(r"^\s*import\s+(?:type\s+)?([\w$]+)\s*=(?!=)")
2911
+ _JS_IMPORT_BARE_RE = re.compile(r"""^\s*import\s*["']""")
2912
+ _JS_IMPORT_ITEM_RE = re.compile(rf"^(?:type\s+)?({_JS_NAME_OR_STRING})(?:\s+as\s+([\w$]+))?$")
2913
+ _JS_IMPORT_DEFAULT_RE = re.compile(r"^([\w$]+)\s*(?:,|$)")
2914
+ _JS_IMPORT_NAMESPACE_RE = re.compile(r"^\*\s*as\s+([\w$]+)")
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`).
2923
+ _JS_NAME_IS_RE = re.compile(
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>;?)$"
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*$")
2933
+ # A line that begins with one of these goes on with the value above it (a
2934
+ # member, an operator, a call, an index or a tagged template).
2935
+ _JS_VALUE_GOES_ON = _JS_CONTINUATION_START | frozenset("([`")
2936
+ # Python's `from X import ...`, with its module.
2937
+ _PY_FROM_CLAUSE_RE = re.compile(r"^\s*from\s+([\w.]+)\s+import\s+(.*)$")
2938
+ # The column-0 header of a block whose body may hold a compatibility import.
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*$"
2946
+ # The files a relative TS/JS module names, after the path itself:
2947
+ # `<path><ext>`, then `<path>/index<ext>`.
2948
+ _TSJS_MODULE_EXTENSIONS = (".ts", ".tsx", ".d.ts", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")
2949
+ # The TypeScript files a `.js`-family module names (`./m.js` is `m.ts`).
2950
+ _TSJS_TWINS = {
2951
+ ".js": (".ts", ".tsx", ".d.ts"), ".jsx": (".tsx",), ".mjs": (".mts", ".d.mts"),
2952
+ ".cjs": (".cts", ".d.cts"),
2953
+ }
2784
2954
 
2955
+ # One name an import binds: (line from 1, local name, imported name, module).
2956
+ # A namespace import binds its local name to no imported name (None).
2957
+ _Binding = tuple[int, str, str | None, str | None]
2785
2958
 
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:
2959
+
2960
+ def _js_blanked(code: str) -> str:
2961
+ """A comment-free TS/JS line with each one-line string's contents
2962
+ blanked, its quotes and its length kept."""
2963
+ return _JS_STRING_RE.sub(lambda m: m[0][0] + " " * (len(m[0]) - 2) + m[0][-1], code)
2964
+
2965
+
2966
+ def _js_export_sides(item: str) -> tuple[str, str | None] | None:
2967
+ """(own name, the name after `as`, None without one) of an export-list
2968
+ item, a string side unquoted only when the string is the whole side
2969
+ (`L as "NAME"`, `"L" as NAME`). None for any other text."""
2970
+ m = _JS_EXPORT_ITEM_RE.match(item.strip())
2971
+ if m is None:
2972
+ return None
2973
+ own, exposed = (s[1:-1] if s and s[:1] in "'\"" else s for s in (m.group(1), m.group(2)))
2974
+ return own, exposed
2975
+
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
+
2991
+ def _js_export_lists(
2992
+ lines: list[str], code: list[str]
2993
+ ) -> list[tuple[list[tuple[int, str]], str | None]]:
2994
+ """Each export list (`export { ... }`, `export type { ... }`) whose
2995
+ opening line is at column 0 as written (a comment may open the line):
2996
+ its items, as (line from 1, the item's comment-free text), and the
2997
+ module its `from` names, None for a local list. `code` holds the lines
2998
+ with comments removed (`_js_code_lines`). The list is split on a copy
2999
+ whose strings are blanked (`_js_blanked`), so a comma or a brace in a
3000
+ string splits nothing, and an item keeps its strings."""
3001
+ blank = [_js_blanked(c) for c in code]
3002
+ lists: list[tuple[list[tuple[int, str]], str | None]] = []
3003
+ i, n = 0, len(code)
3004
+ while i < n:
3005
+ opened = _JS_EXPORT_LIST_RE.match(blank[i]) if _is_column_0(lines[i]) else None
3006
+ if opened is None:
3007
+ i += 1
3008
+ continue
3009
+ j, col, items = i, opened.start(1), []
3010
+ while True:
3011
+ segment = blank[j][col:]
3012
+ end = segment.find("}")
3013
+ pos = col
3014
+ for piece in (segment if end < 0 else segment[:end]).split(","):
3015
+ text = code[j][pos : pos + len(piece)].strip()
3016
+ if text:
3017
+ items.append((j + 1, text))
3018
+ pos += len(piece) + 1
3019
+ if end >= 0 or j + 1 >= n:
3020
+ break
3021
+ j, col = j + 1, 0
3022
+ module = None
3023
+ if end >= 0:
3024
+ after, k = code[j][col + end + 1 :], j + 1
3025
+ while not after.strip() and k < n: # `}` then `from` on the next line
3026
+ after, k = code[k], k + 1
3027
+ found = _JS_FROM_RE.match(after)
3028
+ module = found.group(2) if found else None
3029
+ lists.append((items, module))
3030
+ i = j + 1
3031
+ return lists
3032
+
3033
+
3034
+ def _js_import_bindings(lines: list[str], code: list[str]) -> list[_Binding]:
3035
+ """The names each column-0 import binds (`_Binding`): `import X from`
3036
+ binds X to the imported name `default`, `import { L as X }` binds X to
3037
+ L and `import { X }` X to X, on one line or over several, and a
3038
+ namespace import (`import * as X`, `import X = ...`) binds X to none."""
3039
+ found: list[_Binding] = []
3040
+ i, n = 0, len(code)
3041
+ while i < n:
3042
+ opens = _is_column_0(lines[i]) and _JS_IMPORT_RE.match(code[i]) is not None
3043
+ if not opens or _JS_IMPORT_BARE_RE.match(code[i]):
3044
+ i += 1
2834
3045
  continue
2835
- local = found.group(1)
2836
- if local == name or (not python and local == "default"):
3046
+ j, text = i, code[i]
3047
+ while "{" in text and "}" not in text and j + 1 < n:
3048
+ j += 1
3049
+ text += "\n" + code[j]
3050
+ clause = _JS_IMPORT_CLAUSE_RE.match(text)
3051
+ if clause is None and j + 1 < n and _JS_FROM_RE.match(code[j + 1]):
3052
+ j += 1
3053
+ text += "\n" + code[j]
3054
+ clause = _JS_IMPORT_CLAUSE_RE.match(text)
3055
+ line = i + 1
3056
+ if clause is not None:
3057
+ names, module = clause.group(1).strip(), clause.group(3)
3058
+ default = _JS_IMPORT_DEFAULT_RE.match(names)
3059
+ if default is not None:
3060
+ found.append((line, default.group(1), "default", module))
3061
+ names = names[default.end() :].strip()
3062
+ if names.startswith("*"):
3063
+ namespace = _JS_IMPORT_NAMESPACE_RE.match(names)
3064
+ if namespace is not None:
3065
+ found.append((line, namespace.group(1), None, module))
3066
+ elif names.startswith("{"):
3067
+ for item in names[1:].split("}", 1)[0].split(","):
3068
+ m = _JS_IMPORT_ITEM_RE.match(item.strip())
3069
+ if m is None:
3070
+ continue
3071
+ imported = m.group(1)
3072
+ imported = imported[1:-1] if imported[:1] in "'\"" else imported
3073
+ local = m.group(2) or imported
3074
+ if _JS_IDENT_RE.fullmatch(local):
3075
+ found.append((line, local, imported, module))
3076
+ else:
3077
+ equals = _JS_IMPORT_EQUALS_RE.match(text)
3078
+ if equals is not None:
3079
+ found.append((line, equals.group(1), None, None))
3080
+ i = j + 1
3081
+ return found
3082
+
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
+
3095
+ def _py_block_imports(lines: list[str], code: list[str], starts: set[int]) -> set[int]:
3096
+ """The lines (from 1) on which a `from X import` statement starts one
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`)."""
3102
+ found: set[int] = set()
3103
+ header, level = False, None
3104
+ for number in sorted(starts):
3105
+ text = lines[number - 1]
3106
+ if _is_column_0(text):
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
2837
3110
  continue
2838
- if (_PY_IDENT_RE if python else _JS_IDENT_RE).fullmatch(local):
2839
- locals_.append(local)
2840
- return locals_
3111
+ indent = len(text) - len(text.lstrip())
3112
+ if level is None:
3113
+ level = indent # the block's first statement sets its level
3114
+ if header and indent == level and _PY_FROM_CLAUSE_RE.match(code[number - 1]):
3115
+ found.add(number)
3116
+ return found
3117
+
3118
+
3119
+ def _py_from_clauses(
3120
+ code: list[str], starts: set[int], keep: Callable[[int], bool]
3121
+ ) -> list[tuple[int, str, list[tuple[int, str]]]]:
3122
+ """(line from 1, module, items) of each `from X import` statement that
3123
+ starts on a line `keep` accepts, an item as (line from 1, its text):
3124
+ on one line, in parentheses or continued with a backslash, a block
3125
+ header's one-line body included (`_py_from_clause`)."""
3126
+ clauses: list[tuple[int, str, list[tuple[int, str]]]] = []
3127
+ i, n = 0, len(code)
3128
+ while i < n:
3129
+ opened = _py_from_clause(code[i]) if i + 1 in starts and keep(i + 1) else None
3130
+ if opened is None:
3131
+ i += 1
3132
+ continue
3133
+ rest, items, j = opened.group(2).strip(), [], i
3134
+ paren = rest.startswith("(")
3135
+ if paren:
3136
+ rest = rest[1:]
3137
+ while True:
3138
+ if paren:
3139
+ done = ")" in rest
3140
+ piece = rest.split(")", 1)[0]
3141
+ else:
3142
+ piece = rest.rstrip()
3143
+ done = not piece.endswith("\\")
3144
+ piece = piece if done else piece[:-1]
3145
+ items += [(j + 1, t.strip()) for t in piece.split(",") if t.strip()]
3146
+ if done or j + 1 >= n:
3147
+ break
3148
+ j += 1
3149
+ rest = code[j]
3150
+ clauses.append((i + 1, opened.group(1), items))
3151
+ i = j + 1
3152
+ return clauses
3153
+
3154
+
3155
+ class _Renames:
3156
+ """The renames one file makes, read once for the suspects' lookup (see
3157
+ `renames`), and the lines it adds to a name's declarations
3158
+ (`candidates`). `parts` is the file's path below the source root,
3159
+ `lines` its lines, `cache` the `_SourceCache` its rules ran with. A
3160
+ TS/JS line is read with its comments removed and an export list split
3161
+ with its strings blanked (`_js_export_lists`), a Python line with its
3162
+ `#` comment cut; a Python file that does not tokenize renames
3163
+ nothing."""
3164
+
3165
+ def __init__(self, parts: tuple[str, ...], lines: list[str], cache: _SourceCache) -> None:
3166
+ self.parts = parts
3167
+ self.lines = lines
3168
+ self.cache = cache
3169
+ self.text = "\n".join(lines)
3170
+ path = Path(*parts)
3171
+ self.python = _rule_family(parts[-1]) == "python"
3172
+ self.starts: set[int] | None = None
3173
+ if self.python:
3174
+ self.code = [line.split("#", 1)[0] for line in lines]
3175
+ self.starts = cache.py_statement_starts(path, lines)
3176
+ else:
3177
+ self.code = cache.js_code_lines(path, lines)
3178
+ self._clauses: list[tuple[int, str, list[tuple[int, str]]]] | None = None
3179
+ self._lists: list[tuple[list[tuple[int, str]], str | None]] | None = None
3180
+ self._bindings: list[_Binding] | None = None
3181
+
3182
+ # -- reading, once per file ---------------------------------------------
3183
+
3184
+ def _py_clauses(self) -> list[tuple[int, str, list[tuple[int, str]]]]:
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`)."""
3188
+ if self._clauses is None:
3189
+ if self.starts is None:
3190
+ self._clauses = []
3191
+ else:
3192
+ blocks = _py_block_imports(self.lines, self.code, self.starts)
3193
+ self._clauses = _py_from_clauses(
3194
+ self.code, self.starts,
3195
+ lambda n: _is_column_0(self.lines[n - 1]) or n in blocks,
3196
+ )
3197
+ return self._clauses
3198
+
3199
+ def _js_lists(self) -> list[tuple[list[tuple[int, str]], str | None]]:
3200
+ if self._lists is None:
3201
+ self._lists = _js_export_lists(self.lines, self.code)
3202
+ return self._lists
3203
+
3204
+ def _imports(self) -> list[_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."""
3208
+ if self._bindings is None:
3209
+ if not self.python:
3210
+ self._bindings = _js_import_bindings(self.lines, self.code)
3211
+ elif self.starts is None:
3212
+ self._bindings = []
3213
+ else:
3214
+ found: list[_Binding] = []
3215
+ for start, module, items in self._py_clauses():
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
+ ):
3219
+ continue
3220
+ for _, item in items:
3221
+ m = _PY_IMPORT_ITEM_RE.match(item)
3222
+ if m is not None and _PY_IDENT_RE.fullmatch(m.group(1)):
3223
+ found.append((start, m.group(2) or m.group(1), m.group(1), module))
3224
+ for number in sorted(self.starts):
3225
+ m = _PY_IMPORT_RE.match(self.code[number - 1])
3226
+ if m is None or not _is_column_0(self.lines[number - 1]):
3227
+ continue
3228
+ for item in m.group(1).split(","):
3229
+ bound = _PY_IMPORT_ITEM_RE.match(item.strip())
3230
+ if bound is not None: # `import a.b as c`, `import a`: a namespace
3231
+ local = bound.group(2) or bound.group(1).split(".", 1)[0]
3232
+ found.append((number, local, None, None))
3233
+ found.sort(key=lambda b: b[0])
3234
+ self._bindings = found
3235
+ return self._bindings
3236
+
3237
+ def binding(self, local: str, number: int) -> tuple[str | None, str | None] | None:
3238
+ """(imported name, module) of the column-0 import that binds `local`
3239
+ for line `number` (from 1): the last one at or above it, else, in
3240
+ TS/JS, where imports are hoisted, the first below. The imported name
3241
+ is None for a namespace import (it binds no name). None when no
3242
+ import binds it."""
3243
+ above = below = None
3244
+ for line, name, imported, module in self._imports():
3245
+ if name != local:
3246
+ continue
3247
+ if line <= number:
3248
+ above = (imported, module)
3249
+ elif below is None:
3250
+ below = (imported, module)
3251
+ return above if above is not None or self.python else below
3252
+
3253
+ def _bound(self, local: str, number: int) -> tuple[str | None, str | None]:
3254
+ """`local` as a rename's `L` with no `from`: the imported name and
3255
+ module of the import that binds it (no name for a namespace import),
3256
+ else `local` and no module."""
3257
+ return self.binding(local, number) or (local, None)
3258
+
3259
+ def _goes_on(self, index: int) -> bool:
3260
+ """True when the TS/JS statement on line `index` (from 0) goes on
3261
+ with the next line that holds code."""
3262
+ for code in self.code[index + 1 :]:
3263
+ if code.strip():
3264
+ return code.lstrip()[0] in _JS_VALUE_GOES_ON
3265
+ return False
3266
+
3267
+ # -- the answers --------------------------------------------------------
3268
+
3269
+ def candidates(self, name: str, cache: _SourceCache, module_fallback: bool) -> list[int]:
3270
+ """The lines (from 1, sorted) of the file the suspects' lookup reads
3271
+ for `name`: every column-0 line that declares it (`declared_line`'s
3272
+ rule; with `module_fallback`, line 1 of the module or package so
3273
+ named when none does), a Python `from X import L as NAME` item one
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`)."""
3279
+ if not name or "." in name:
3280
+ return []
3281
+ path = Path(*self.parts)
3282
+ top, _ = _line_matches(path.suffix.lower(), path, self.lines, name, cache, True)
3283
+ found = set(top)
3284
+ if not found and module_fallback and _module_name(self.parts) == name:
3285
+ found.add(1)
3286
+ esc = re.escape(name)
3287
+ if self.python:
3288
+ if re.search(rf"(?<!\w)as\s+{esc}(?!\w)", self.text):
3289
+ found.update(
3290
+ line for _, _, items in self._py_clauses() for line, item in items
3291
+ if self._py_renames(item, name) is not None
3292
+ )
3293
+ elif re.search(rf"(?<![\w$])(?:as|import(?:\s+type)?)\s+{esc}(?![\w$])", self.text) and any(
3294
+ local == name and imported not in (None, name)
3295
+ for _, local, imported, _ in self._imports()
3296
+ ):
3297
+ found.update(
3298
+ line for items, module in self._js_lists() if module is None
3299
+ for line, item in items if _js_export_sides(item) == (name, None)
3300
+ )
3301
+ return sorted(found)
3302
+
3303
+ @staticmethod
3304
+ def _py_renames(item: str, name: str) -> str | None:
3305
+ """`L` of a `from X import` item `L as NAME`, L another identifier."""
3306
+ m = _PY_IMPORT_ITEM_RE.match(item)
3307
+ if m is None or m.group(2) != name or m.group(1) == name:
3308
+ return None
3309
+ return m.group(1) if _PY_IDENT_RE.fullmatch(m.group(1)) else None
3310
+
3311
+ def renames(self, number: int, name: str) -> list[tuple[str, str | None]]:
3312
+ """(`L`, the module it comes from, None when unknown) for each
3313
+ rename to `name` on line `number` (from 1), `L` an identifier other
3314
+ than `name`:
3315
+
3316
+ - an export-list item `L as NAME` (`export { ... }`, `export type
3317
+ { ... }`, the list's opening line at column 0 as written, a string
3318
+ side unquoted when the string is the whole side), `L` `default`
3319
+ included when the list has a `from`;
3320
+ - a local export-list item `NAME` without `as`, when a column-0
3321
+ import binds NAME to another name (`import { L as NAME }`, or
3322
+ `import NAME from`, `L` `default`);
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.
3340
+
3341
+ The module is the list's or the clause's `from`; a rename with none
3342
+ takes the column-0 import that binds `L` (`binding`), `L` becoming
3343
+ its imported name, and a namespace import's name renames nothing.
3344
+ Empty for any other line: `as NAME` in a comment or a string, a TS
3345
+ type assertion (`const NAME = x as NAME`), a call or any other
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`."""
3355
+ if not 0 < number <= len(self.lines):
3356
+ return []
3357
+ found: list[tuple[str | None, str | None]] = []
3358
+ index = number - 1
3359
+ if self.python:
3360
+ for _, module, items in self._py_clauses():
3361
+ for line, item in items:
3362
+ local = self._py_renames(item, name) if line == number else None
3363
+ if local is not None:
3364
+ found.append((local, module))
3365
+ starts = self.starts or set()
3366
+ if number in starts and _is_column_0(self.lines[index]):
3367
+ m = re.match(_PY_NAME_IS.format(name=re.escape(name)), self.code[index])
3368
+ if m is not None and m.group(1) != name:
3369
+ found.append(self._bound(m.group(1), number))
3370
+ return [(local, module) for local, module in found if local is not None]
3371
+ for items, module in self._js_lists():
3372
+ for line, item in items:
3373
+ sides = _js_export_sides(item) if line == number else None
3374
+ if sides is None:
3375
+ continue
3376
+ own, exposed = sides
3377
+ if exposed is None and own == name and module is None:
3378
+ bound = self.binding(name, number) # a plain re-export of an import
3379
+ if bound is not None and bound[0] != name:
3380
+ found.append(bound)
3381
+ elif exposed == name and own != name and _JS_IDENT_RE.fullmatch(own):
3382
+ if module is not None:
3383
+ found.append((own, module))
3384
+ elif own != "default":
3385
+ found.append(self._bound(own, number))
3386
+ m = _JS_NAME_IS_RE.match(self.code[index]) if _is_column_0(self.lines[index]) else None
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"])
3391
+ ):
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))
3404
+ return [(local, module) for local, module in found if local is not None]
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
2841
3425
 
2842
3426
 
2843
3427
  class _DeclarationLookup:
@@ -2857,15 +3441,29 @@ class _DeclarationLookup:
2857
3441
  (`_is_package_root`) that hold no cited file, the files
2858
3442
  `_lookup_skips_file` names and every symlink. Only the rule families
2859
3443
  (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`)."""
3444
+ file). A cited file is read in any folder, a hidden one and one the walk
3445
+ skips included, but only inside the source root, with no symlink on its
3446
+ path and under a name `_lookup_skips_file` accepts (`_readable_below`).
3447
+ The walk runs once for all the names no cited file settles: each file is
3448
+ read once, and only a file whose text holds a name goes through the
3449
+ rules. In the suspects' pass (`keep_declaring`), a file's declarations
3450
+ are its candidate lines (`_Renames.candidates`: every column-0
3451
+ declaring line, a Python compatibility import one level in a column-0
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`)."""
2869
3467
 
2870
3468
  def __init__(self, entries: list[dict], source_root: Path) -> None:
2871
3469
  self.root = source_root
@@ -2884,9 +3482,15 @@ class _DeclarationLookup:
2884
3482
  # the cited files read, kept for the walk: a few files at most
2885
3483
  self._cited: dict[tuple[str, ...], list[str] | None] = {}
2886
3484
  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
3485
+ # during the suspects' pass only (`keep_declaring`): the renames of
3486
+ # each file a declaration was found in, by path, and each (path, name)
3487
+ # its candidate lines, which `renamed` reads
3488
+ self._declaring: dict[str, _Renames] | None = None
3489
+ self._candidates: dict[tuple[str, str], list[int]] = {}
3490
+ # the files a rename's `from` resolves to and the renaming files, that no entry cites
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] = {}
2890
3494
 
2891
3495
  # -- the walk root ------------------------------------------------------
2892
3496
 
@@ -2951,20 +3555,14 @@ class _DeclarationLookup:
2951
3555
 
2952
3556
  # -- reading ------------------------------------------------------------
2953
3557
 
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
3558
  def _reads_cited(self, parts: tuple[str, ...]) -> bool:
2959
3559
  """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)
3560
+ entries cite, in any folder (one the walk skips or a hidden one
3561
+ included), with no symlink on its path, inside the source root and
3562
+ under a name `_lookup_skips_file` accepts (`_readable_below`)."""
3563
+ return _rule_family(parts[-1]) in self.families and _readable_below(
3564
+ self.root, parts, self.real_root
3565
+ )
2968
3566
 
2969
3567
  def _cited_lines(self, parts: tuple[str, ...]) -> list[str] | None:
2970
3568
  if parts not in self._cited:
@@ -2973,6 +3571,74 @@ class _DeclarationLookup:
2973
3571
  self._cited[parts] = _text_lines(_read_source(path)) if readable else None
2974
3572
  return self._cited[parts]
2975
3573
 
3574
+ def _lines_of(self, parts: tuple[str, ...]) -> list[str] | None:
3575
+ """The lines of a file the alias check reads, a cited one or one a
3576
+ rename's `from` resolves to, under `_reads_cited`'s guards, or the
3577
+ renaming file, whose lines `renamed` keeps; each file read once."""
3578
+ if parts in self._cited:
3579
+ return self._cited[parts]
3580
+ if parts not in self._resolved:
3581
+ readable = self._reads_cited(parts)
3582
+ path = self.root.joinpath(*parts)
3583
+ self._resolved[parts] = _text_lines(_read_source(path)) if readable else None
3584
+ return self._resolved[parts]
3585
+
3586
+ def _declares(self, parts: tuple[str, ...], local: str) -> bool:
3587
+ """True when a file declares `local` by `declared_line`'s rule, no
3588
+ module or package so named counting."""
3589
+ lines = self._lines_of(parts)
3590
+ return lines is not None and declared_line(
3591
+ parts, local, lines=lines, cache=self._cited_cache, module_fallback=False
3592
+ ) is not None
3593
+
3594
+ def _resolve(self, parts: tuple[str, ...], module: str) -> list[tuple[str, ...]]:
3595
+ """The files below the source root that a rename's module names, from
3596
+ the renaming file `parts`, each one the lookup may read
3597
+ (`_reads_cited`). TS/JS: a `./` or `../` module only, from the file's
3598
+ folder: the path itself, a `.js`-family path's TypeScript twin
3599
+ (`./m.js` names `m.ts`), `<path><ext>` and `<path>/index<ext>`
3600
+ (`_TSJS_MODULE_EXTENSIONS`). Python: a relative module (`.m`, `..m`,
3601
+ `.` the folder's own `__init__`) from the file's folder, or an
3602
+ absolute one below the parent of the file's package root
3603
+ (`_package_root`): `m.py`, `m.pyi`, `m/__init__.py` and
3604
+ `m/__init__.pyi`. Empty for a bare package name, a tsconfig `paths`
3605
+ alias, a module that climbs out of the source root and one with no
3606
+ such file."""
3607
+ if _rule_family(parts[-1]) == "python":
3608
+ dots = len(module) - len(module.lstrip("."))
3609
+ names = module[dots:].split(".") if module[dots:] else []
3610
+ if any(not n for n in names):
3611
+ return []
3612
+ if dots:
3613
+ if dots > len(parts):
3614
+ return []
3615
+ base = parts[: len(parts) - dots]
3616
+ else:
3617
+ root = self._package_root(self._real_folder(parts[:-1]), "python")
3618
+ if root is None or not names:
3619
+ return []
3620
+ base = root[:-1]
3621
+ here = (*base, *names)
3622
+ tried = [(*here[:-1], here[-1] + ext) for ext in (".py", ".pyi")] if names else []
3623
+ tried += [(*here, "__init__.py"), (*here, "__init__.pyi")]
3624
+ else:
3625
+ if module not in (".", "..") and not module.startswith(("./", "../")):
3626
+ return [] # a package name or a `paths` alias: not read
3627
+ path = posixpath.normpath(posixpath.join(*parts[:-1], module))
3628
+ if path == ".." or path.startswith("../"):
3629
+ return []
3630
+ here = () if path == "." else tuple(path.split("/"))
3631
+ tried = []
3632
+ if here:
3633
+ stem, ext = posixpath.splitext(here[-1])
3634
+ if ext.lower() in TSJS_EXTENSIONS:
3635
+ tried.append(here)
3636
+ twins = _TSJS_TWINS.get(ext.lower(), ())
3637
+ tried += [(*here[:-1], stem + twin) for twin in twins]
3638
+ tried += [(*here[:-1], here[-1] + ext) for ext in _TSJS_MODULE_EXTENSIONS]
3639
+ tried += [(*here, "index" + ext) for ext in _TSJS_MODULE_EXTENSIONS]
3640
+ return [p for p in dict.fromkeys(tried) if self._reads_cited(p)]
3641
+
2976
3642
  def _is_package_root(self, folder: tuple[str, ...], filenames: list[str]) -> bool:
2977
3643
  """True when a walked folder (its `filenames`) is a package root of a
2978
3644
  family the walk reads, as `_package_root` finds one: TS/JS, a folder
@@ -3032,21 +3698,31 @@ class _DeclarationLookup:
3032
3698
  ) -> str | None:
3033
3699
  """`file:line` of the first line of a file that declares `name`, else,
3034
3700
  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:
3701
+ so named (`declared_line`). After `keep_declaring`, the first of the
3702
+ file's candidate lines (`_Renames.candidates`, which adds a Python
3703
+ compatibility import and a TS/JS re-export of a renaming import),
3704
+ each kept, with the file's renames, for `renamed`."""
3705
+ file = "/".join(parts)
3706
+ if self._declaring is None:
3707
+ line = declared_line(
3708
+ parts, name, lines=lines, cache=cache, module_fallback=module_fallback
3709
+ )
3710
+ return None if line is None else f"{file}:{line}"
3711
+ renames = self._declaring.get(file) or _Renames(parts, lines, cache)
3712
+ candidates = renames.candidates(name, cache, module_fallback)
3713
+ if not candidates:
3041
3714
  return None
3042
- if self._declaring is not None:
3043
- self._declaring['/'.join(parts)] = lines
3044
- return f"{'/'.join(parts)}:{line}"
3715
+ self._declaring[file] = renames
3716
+ self._candidates[(file, name)] = candidates
3717
+ return f"{file}:{candidates[0]}"
3045
3718
 
3046
3719
  # -- the answer ---------------------------------------------------------
3047
3720
 
3048
3721
  def find(
3049
- self, cited: dict[str, list[str]], strict: frozenset[str] = frozenset()
3722
+ self,
3723
+ cited: dict[str, list[str]],
3724
+ strict: frozenset[str] = frozenset(),
3725
+ cited_fallback: bool = True,
3050
3726
  ) -> tuple[dict[str, str], dict[str, list[str]]]:
3051
3727
  """For the names of `cited` (name -> the source_file values its
3052
3728
  entries record): name -> `file:line` for each one the source
@@ -3055,7 +3731,10 @@ class _DeclarationLookup:
3055
3731
  cited file settles). A dotted name is never looked up, and a name of
3056
3732
  `strict` takes no line 1 from a module or package so named that is
3057
3733
  not itself the export (`_exports_itself`), in a cited file or in
3058
- the walk."""
3734
+ the walk. Without `cited_fallback` (the suspects' pass), a cited
3735
+ file takes no line 1 from its module or package so named, the export
3736
+ or not, so that the walk runs and finds the renames; the walk keeps
3737
+ its module rule."""
3059
3738
  found: dict[str, str] = {}
3060
3739
  if not self.families:
3061
3740
  return found, {}
@@ -3067,7 +3746,8 @@ class _DeclarationLookup:
3067
3746
  lines = self._cited_lines(parts)
3068
3747
  hit = None if lines is None else self._first(
3069
3748
  parts, lines, name, self._cited_cache,
3070
- name not in strict or _exports_itself(parts, "\n".join(lines)),
3749
+ cited_fallback
3750
+ and (name not in strict or _exports_itself(parts, "\n".join(lines))),
3071
3751
  )
3072
3752
  if hit is not None:
3073
3753
  found[name] = hit
@@ -3112,7 +3792,9 @@ class _DeclarationLookup:
3112
3792
  continue
3113
3793
  if lines is None:
3114
3794
  lines = _text_lines(text)
3115
- 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()
3116
3798
  path = Path(*parts)
3117
3799
  if path.suffix.lower() in PYTHON_EXTENSIONS and (
3118
3800
  cache.py_statement_starts(path, lines) is None
@@ -3125,33 +3807,107 @@ class _DeclarationLookup:
3125
3807
  return {name: sorted(_without_stubs(h)) for name, h in hits.items()}, unsure
3126
3808
 
3127
3809
  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."""
3810
+ """From now on, keep the candidate lines and the renames of each file
3811
+ a declaration is found in, for `renamed`: the suspects' pass, which
3812
+ comes last."""
3130
3813
  self._declaring = {}
3814
+ self._candidates = {}
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
+
3840
+ def _aliases(
3841
+ self, parts: tuple[str, ...], local: str, module: str | None,
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
3131
3882
 
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."""
3883
+ def renamed(
3884
+ self, name: str, hits: list[str], files: list[str]
3885
+ ) -> tuple[str, str] | None:
3886
+ """(`file:line`, `L`) of the first rename to `name` that is an alias
3887
+ of the declaration of `L` a file of `files` (source_file values, as
3888
+ recorded) holds (`_aliases`), directly or through a chain of renames
3889
+ across the files it resolves: per file of `hits` (`file:line`
3890
+ declarations of `name` the lookup found, sorted), per candidate
3891
+ line in order (`_Renames.candidates`), each rename the line makes
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
3895
+ `keep_declaring` (`_first`), never read again; a file a rename's
3896
+ module resolves to is read once. None when no line is such an
3897
+ alias."""
3141
3898
  cited = list(dict.fromkeys(filter(None, map(_cited_parts, files))))
3142
3899
  for hit in sorted(hits):
3143
3900
  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():
3901
+ renames = (self._declaring or {}).get(file)
3902
+ if renames is None or not number.isdigit():
3146
3903
  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
3904
+ if renames.parts not in self._cited: # read already, and `_aliases` may check it
3905
+ self._resolved.setdefault(renames.parts, renames.lines)
3906
+ for line in self._candidates.get((file, name), [int(number)]):
3907
+ for local, module in renames.renames(line, name):
3908
+ end = self._aliases(renames.parts, local, module, cited, set())
3909
+ if end is not None:
3910
+ return f"{file}:{line}", end
3155
3911
  return None
3156
3912
 
3157
3913
 
@@ -3193,16 +3949,31 @@ def classify_stale(
3193
3949
  (`defined_at`, and `declared_in` when the walk looked for it). A name
3194
3950
  the cited files do not define is looked up too, after the others and
3195
3951
  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
3202
- entry gives `source` and how `defined_at` and `declared_in` are found.
3203
- `kinds` (`inventory_kinds`, None without `--inventory`) keeps a name of
3204
- a `NO_MODULE_KINDS` kind from a module or package so named, unless an
3205
- entry of the name is a `module` or `package` one.
3952
+ no other name), with no line 1 from a cited module or package so named,
3953
+ and none in the walk unless it is the export, and is `aliased`, not
3954
+ fabricated, when one of its candidate lines renames to it the
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.
3206
3977
  """
3207
3978
  entries = [e for e in prov.get("entries") or [] if isinstance(e, dict)]
3208
3979
  results: list[dict] = []
@@ -3271,30 +4042,33 @@ def classify_stale(
3271
4042
  found, declared = {}, {}
3272
4043
  # the suspects' own lookup, after: an error there clears no name settled
3273
4044
  # above, and keeps every suspect fabricated
3274
- aliased: dict[str, str] = {}
4045
+ aliased: dict[str, tuple[str, str]] = {} # name -> (the rename's `file:line`, the chain-end `L`)
3275
4046
  suspect_declared: dict[str, list[str]] = {}
3276
4047
  if suspects and lookup is not None:
3277
4048
  try:
3278
- # a suspect is strict: a cited module so named settles it before
3279
- # the walk only when it is the export
4049
+ # a suspect is strict, and a cited module so named never settles
4050
+ # it before the walk, which finds the renames
3280
4051
  lookup.keep_declaring()
3281
- suspect_found, suspect_declared = lookup.find(suspects, frozenset(suspects))
4052
+ suspect_found, suspect_declared = lookup.find(
4053
+ suspects, frozenset(suspects), cited_fallback=False
4054
+ )
3282
4055
  for name, files in suspects.items():
3283
4056
  hits = suspect_declared[name] if name in suspect_declared else (
3284
4057
  [suspect_found[name]] if name in suspect_found else []
3285
4058
  )
3286
- hit = lookup.renamed(name, hits, files)
3287
- if hit is not None:
3288
- aliased[name] = hit
4059
+ alias = lookup.renamed(name, hits, files)
4060
+ if alias is not None:
4061
+ aliased[name] = alias
3289
4062
  except _LOOKUP_ERRORS:
3290
4063
  aliased, suspect_declared = {}, {}
3291
4064
  for item in results:
3292
4065
  name = item["name"]
3293
4066
  if name in suspects:
3294
4067
  if name in aliased:
4068
+ defined_at, local = aliased[name]
3295
4069
  item.update(
3296
4070
  fabricated=False, reason=STALE_ALIASED,
3297
- defined_at=aliased[name], declared_in=suspect_declared.get(name),
4071
+ defined_at=defined_at, declared_in=suspect_declared.get(name), renames=local,
3298
4072
  )
3299
4073
  continue # any other suspect stays fabricated, both null
3300
4074
  item["defined_at"] = found.get(name)