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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +1 -1
- package/docs/_data/pinned.yaml +9 -10
- package/docs/campaign.md +2 -2
- package/docs/skill-model.md +3 -3
- package/docs/verifying-a-skill.md +17 -18
- package/docs/workflows.md +7 -7
- package/package.json +1 -1
- package/src/shared/references/description-guard-protocol.md +12 -10
- package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +38 -0
- package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +5 -0
- package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +8 -3
- package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +6 -1
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +7 -2
- package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +1 -1
- package/src/shared/scripts/skf-parse-gaps.py +4 -1
- package/src/shared/scripts/skf-quick-batch.py +8 -4
- package/src/shared/scripts/skf-verify-provenance-completeness.py +727 -179
- package/src/skf-audit-skill/customize.toml +4 -3
- package/src/skf-audit-skill/references/report.md +1 -1
- package/src/skf-campaign/assets/campaign-state-schema.json +58 -0
- package/src/skf-campaign/references/campaign-contracts.md +1 -1
- package/src/skf-campaign/references/step-06-batch.md +2 -2
- package/src/skf-campaign/references/step-07-capstone.md +1 -1
- package/src/skf-campaign/references/step-08-verify.md +1 -1
- package/src/skf-campaign/references/step-09-refine.md +1 -1
- package/src/skf-campaign/references/step-11-maintenance.md +1 -1
- package/src/skf-campaign/scripts/campaign-render-batch.py +43 -10
- package/src/skf-campaign/scripts/campaign-report.py +60 -4
- package/src/skf-campaign/scripts/campaign-state.py +67 -19
- package/src/skf-campaign/templates/campaign-report-template.md +4 -0
- package/src/skf-create-skill/assets/skill-sections.md +2 -2
- package/src/skf-create-skill/references/report.md +2 -2
- package/src/skf-create-skill/references/validate.md +49 -29
- package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
- package/src/skf-create-stack-skill/references/report.md +3 -3
- package/src/skf-create-stack-skill/references/validate.md +9 -4
- package/src/skf-drop-skill/references/invocation-contract.md +1 -1
- package/src/skf-export-skill/references/summary.md +1 -1
- package/src/skf-quick-skill/references/batch-mode.md +28 -2
- package/src/skf-quick-skill/references/compile.md +2 -0
- package/src/skf-quick-skill/references/finalize.md +3 -2
- package/src/skf-quick-skill/references/halt-contract.md +3 -3
- package/src/skf-quick-skill/references/quick-extract.md +2 -1
- package/src/skf-quick-skill/references/write-and-validate.md +11 -5
- package/src/skf-refine-architecture/customize.toml +1 -1
- package/src/skf-refine-architecture/references/report.md +2 -2
- package/src/skf-rename-skill/references/report.md +1 -1
- package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
- package/src/skf-test-skill/references/coverage-check.md +2 -2
- package/src/skf-test-skill/references/invocation-contract.md +1 -1
- package/src/skf-test-skill/references/report.md +1 -1
- package/src/skf-test-skill/references/scoring-rules.md +1 -1
- package/src/skf-test-skill/references/signature-comparison-rules.md +4 -1
- package/src/skf-test-skill/scripts/gap-ledger.py +196 -46
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +12 -6
- package/src/skf-update-skill/references/gap-driven.md +1 -1
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +1 -1
- package/src/skf-update-skill/references/report.md +18 -7
- package/src/skf-update-skill/references/write.md +24 -12
- 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
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
`
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
`
|
|
482
|
-
`L`
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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`),
|
|
515
|
-
`module.exports` key with a value or a method
|
|
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
|
|
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).
|
|
527
|
-
|
|
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),
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
`
|
|
574
|
-
`
|
|
575
|
-
`
|
|
576
|
-
(deeper in a package such a folder can be package code
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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. `
|
|
1932
|
-
|
|
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
|
|
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 |
|
|
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
|
|
2504
|
-
#
|
|
2505
|
-
#
|
|
2506
|
-
#
|
|
2507
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
and
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
for
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
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
|
-
|
|
2836
|
-
|
|
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
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
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
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
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
|
|
2888
|
-
# file a declaration was found in, by path,
|
|
2889
|
-
|
|
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
|
|
2961
|
-
inside the source root
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
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
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
)
|
|
3040
|
-
if
|
|
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
|
-
|
|
3043
|
-
|
|
3044
|
-
return f"{
|
|
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,
|
|
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
|
-
|
|
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
|
|
3129
|
-
for `renamed`: the suspects' pass, which
|
|
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
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
list
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
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
|
-
|
|
3145
|
-
if
|
|
3689
|
+
renames = (self._declaring or {}).get(file)
|
|
3690
|
+
if renames is None or not number.isdigit():
|
|
3146
3691
|
continue
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
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
|
|
3197
|
-
is the export, and is `aliased`, not
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
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
|
|
3279
|
-
# the walk
|
|
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(
|
|
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
|
-
|
|
3287
|
-
if
|
|
3288
|
-
aliased[name] =
|
|
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=
|
|
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)
|