bmad-module-skill-forge 6.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 +10 -10
- package/package.json +1 -1
- package/src/shared/references/description-guard-protocol.md +12 -10
- package/src/shared/references/output-contract-schema.md +1 -1
- 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 +24 -7
- package/src/shared/scripts/skf-verify-provenance-completeness.py +809 -85
- package/src/skf-analyze-source/customize.toml +3 -2
- package/src/skf-analyze-source/references/auto-docs-only.md +2 -2
- package/src/skf-analyze-source/references/generate-briefs.md +5 -3
- package/src/skf-analyze-source/references/headless-contract.md +2 -2
- package/src/skf-analyze-source/references/step-auto-scope.md +2 -2
- 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 +3 -3
- package/src/skf-campaign/references/step-08-verify.md +3 -3
- package/src/skf-campaign/references/step-09-refine.md +3 -3
- 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 +82 -17
- package/src/skf-campaign/templates/campaign-report-template.md +4 -0
- package/src/skf-create-skill/SKILL.md +2 -2
- package/src/skf-create-skill/assets/skill-sections.md +2 -2
- package/src/skf-create-skill/customize.toml +4 -4
- package/src/skf-create-skill/references/batch-mode.md +3 -1
- package/src/skf-create-skill/references/report.md +9 -4
- 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 +21 -8
- 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/SKILL.md +1 -1
- package/src/skf-quick-skill/references/batch-mode.md +30 -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 +27 -11
- 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 +4 -4
- package/src/skf-test-skill/references/inventory-contract.md +1 -1
- 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 +2 -2
- package/src/skf-test-skill/references/signature-comparison-rules.md +7 -2
- package/src/skf-test-skill/scripts/gap-ledger.py +196 -46
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +253 -17
- package/src/skf-test-skill/scripts/score-signatures.py +9 -13
- package/src/skf-test-skill/scripts/validate-inventory.py +91 -12
- package/src/skf-update-skill/customize.toml +1 -1
- 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 +27 -9
- 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
|
|
@@ -455,23 +460,88 @@ Subcommands:
|
|
|
455
460
|
"source": "<source_file>:<source_line>" | "<source_file>" | null,
|
|
456
461
|
"defined_at": "<path>:<line>" | null,
|
|
457
462
|
"declared_in": ["<path>:<line>", ...] | null,
|
|
458
|
-
"reason": "not-defined" | "
|
|
463
|
+
"reason": "not-defined" | "aliased" | "defined" | "unchecked"
|
|
464
|
+
| "no-entry",
|
|
459
465
|
"entries": [{"source_file": "<as recorded>",
|
|
460
466
|
"source_line": <as recorded>,
|
|
461
467
|
"line_check": "checked" | "file-missing"
|
|
462
468
|
| "skipped-export-type"
|
|
463
469
|
| "skipped-language" | "unreadable"
|
|
464
470
|
| "no-file",
|
|
465
|
-
"definition_lines": [<int>, ...] | null}, ...]
|
|
471
|
+
"definition_lines": [<int>, ...] | null}, ...],
|
|
472
|
+
"renames": "<L>"}, # an `aliased` item only
|
|
466
473
|
...
|
|
467
474
|
]
|
|
468
475
|
|
|
469
476
|
`fabricated` is true, with reason `not-defined`, when the name has an
|
|
470
477
|
entry and every entry's `line_check` is `file-missing`, or `checked`
|
|
471
478
|
with an empty `definition_lines`: the export is in neither the surface
|
|
472
|
-
nor the cited file
|
|
473
|
-
|
|
474
|
-
|
|
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 =`.
|
|
542
|
+
Otherwise the reason is `defined` (a cited file defines the name),
|
|
543
|
+
`unchecked` (no rule could check a cited file, and none defines it) or
|
|
544
|
+
`no-entry` (the map has no entry for the name).
|
|
475
545
|
An entry with no `source_file` (update-skill writes one for an
|
|
476
546
|
`unknown` NEW_EXPORT) cites no file to check: its `line_check` is
|
|
477
547
|
`no-file`, which, like a skipped or unreadable check, neither proves
|
|
@@ -483,13 +553,15 @@ Subcommands:
|
|
|
483
553
|
`defined_at` is where the source declares a name that is not
|
|
484
554
|
fabricated, so test-skill can tell a documented extra (a name outside
|
|
485
555
|
the surface that the source still declares) from stale documentation;
|
|
486
|
-
a fabricated name
|
|
556
|
+
a fabricated name has none and no `declared_in` (both null), the
|
|
557
|
+
lookup reading it only for an alias. A declaration is a line at
|
|
487
558
|
column 0 that the definition-line rules match with imports left out:
|
|
488
559
|
a `def`, `class`, assignment, annotation, type alias, TS/JS
|
|
489
560
|
declaration, `exports.NAME =`, or an alias that renames
|
|
490
561
|
(`import X as NAME`, `from X import Y as NAME`,
|
|
491
|
-
`export { Y as NAME }`, `export * as NAME from`),
|
|
492
|
-
`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,
|
|
493
565
|
an `as` that keeps the imported name (`from m import NAME as NAME`,
|
|
494
566
|
`export { NAME as NAME }`), `export import NAME =`,
|
|
495
567
|
`export default NAME`, an export-list item without `as`, a
|
|
@@ -498,9 +570,20 @@ Subcommands:
|
|
|
498
570
|
`const NAME = require(...)`, `const { NAME } = require(...)`,
|
|
499
571
|
`exports.NAME = require(...)`, `{ NAME: require(...) }`), an
|
|
500
572
|
indented line and any line of a Python
|
|
501
|
-
file that does not tokenize never count
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
576
|
+
counts at its own line, the list's opening line at column 0, so an
|
|
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
|
|
585
|
+
`NAME.<ext>` or `NAME/index.<ext>`) declares NAME too: at its line 1
|
|
586
|
+
when no line of it
|
|
504
587
|
declares NAME, unless `--inventory` gives NAME a `function`, `hook`,
|
|
505
588
|
`class`, `interface`, `enum` or `type` kind (any of its kinds, when it
|
|
506
589
|
gives several; a `method` kind is not read), no entry of NAME is a
|
|
@@ -524,10 +607,12 @@ Subcommands:
|
|
|
524
607
|
and `defined_at` is the one item when the list has exactly one and no
|
|
525
608
|
walked Python file that does not tokenize holds the name (a homonym,
|
|
526
609
|
two files or more, gives null: test-skill decides it by the files the
|
|
527
|
-
skill cites)
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
610
|
+
skill cites), save for an `aliased` name, whose `defined_at` is its
|
|
611
|
+
alias whatever the list holds. The walk reads, sorted by path, every
|
|
612
|
+
file below the walk root: the deepest folder that the files of the
|
|
613
|
+
map's entries share, widened to its package root (Python: the nearest
|
|
614
|
+
folder at or above it that holds `__init__.py`, then up while the
|
|
615
|
+
parent holds
|
|
531
616
|
one, never the source root itself; TS/JS: the nearest folder at or
|
|
532
617
|
above it that holds `package.json`), leaving out the entries whose
|
|
533
618
|
file lies outside every package; the source root when none lies in
|
|
@@ -538,22 +623,27 @@ Subcommands:
|
|
|
538
623
|
test output lives in (`node_modules`, `dist`, `build`, `vendor`,
|
|
539
624
|
`_vendor`, `tests`, `testing`, `fixtures`, `__mocks__`,
|
|
540
625
|
`site-packages`, `env`, `bower_components`, `esm`, `cjs`, `umd` and
|
|
541
|
-
the like),
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
`
|
|
546
|
-
`
|
|
547
|
-
`
|
|
548
|
-
(deeper in a package such a folder can be package code
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
|
553
640
|
but no Python or TS/JS one, is not looked up (`defined_at` and
|
|
554
|
-
`declared_in` null, as for a fabricated name)
|
|
555
|
-
during the lookup
|
|
556
|
-
0).
|
|
641
|
+
`declared_in` null, as for a fabricated name). A filesystem error
|
|
642
|
+
during the lookup of the names that are not fabricated gives each of
|
|
643
|
+
them null in both (the exit code stays 0). The `not-defined` names are
|
|
644
|
+
looked up apart, after the others and whether or not that lookup
|
|
645
|
+
failed: an error in their walk or their alias check keeps only them
|
|
646
|
+
fabricated, and clears no other name's answer.
|
|
557
647
|
|
|
558
648
|
kind-at --file <path> --line <n> --recipes <recipes>
|
|
559
649
|
[--language <lang>] [--name <export_name>]
|
|
@@ -1897,8 +1987,12 @@ def _tsjs_definition_lines(
|
|
|
1897
1987
|
depend on the name either): a name its pattern binds counts when the
|
|
1898
1988
|
`const` / `let` / `var` line it is keyed by opens a statement here.
|
|
1899
1989
|
Class members count only for a
|
|
1900
|
-
dotted export name. `
|
|
1901
|
-
|
|
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
|
|
1902
1996
|
line once for every export looked up). With `declarations_only`,
|
|
1903
1997
|
`export import NAME =`, `export default NAME`, an export-list item
|
|
1904
1998
|
without an `as` that renames, a `module.exports` shorthand key and an
|
|
@@ -1922,6 +2016,11 @@ def _tsjs_definition_lines(
|
|
|
1922
2016
|
re.compile(rf"^\s*export\s+import\s+(?:type\s+)?{esc}\s*=(?![=>])"),
|
|
1923
2017
|
re.compile(rf"^\s*export\s+default\s+{esc}\s*;?\s*$"),
|
|
1924
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}"))
|
|
1925
2024
|
# One declarator of a `const` / `let` / `var` list: `NAME = v`,
|
|
1926
2025
|
# `NAME: T = v`, `NAME!: T`, or a bare `NAME`. A destructuring pattern
|
|
1927
2026
|
# starts with `{` or `[` and never matches: the names a pattern that
|
|
@@ -2360,6 +2459,8 @@ STALE_NOT_DEFINED = "not-defined"
|
|
|
2360
2459
|
STALE_DEFINED = "defined"
|
|
2361
2460
|
STALE_UNCHECKED = "unchecked"
|
|
2362
2461
|
STALE_NO_ENTRY = "no-entry"
|
|
2462
|
+
# a not-defined name a declaration renames to it from a name a cited file declares
|
|
2463
|
+
STALE_ALIASED = "aliased"
|
|
2363
2464
|
|
|
2364
2465
|
|
|
2365
2466
|
def stale_names(data: object) -> list[str]:
|
|
@@ -2419,8 +2520,10 @@ def _stale_line_check(
|
|
|
2419
2520
|
|
|
2420
2521
|
# The folders and files the `defined_at` walk leaves out, judged
|
|
2421
2522
|
# case-insensitively on the path below its walk root: generated, built and
|
|
2422
|
-
# installed output, a vendored copy of another package, tests and their
|
|
2423
|
-
# 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.
|
|
2424
2527
|
# Keep identical to EXCLUDED_DIR_NAMES in skf-resolve-authoritative-files.py.
|
|
2425
2528
|
EXCLUDED_DIR_NAMES = {
|
|
2426
2529
|
"node_modules", "__pycache__", "dist", "build", ".webpack",
|
|
@@ -2458,8 +2561,7 @@ LOOKUP_EXTRA_FOLDERS = frozenset({
|
|
|
2458
2561
|
"bower_components", "esm", "cjs", "umd",
|
|
2459
2562
|
})
|
|
2460
2563
|
LOOKUP_SKIPPED_FOLDERS = frozenset(
|
|
2461
|
-
EXCLUDED_DIR_NAMES | _VENDORED_SEGMENTS | TEST_FOLDERS |
|
|
2462
|
-
| LOOKUP_EXTRA_FOLDERS
|
|
2564
|
+
EXCLUDED_DIR_NAMES | _VENDORED_SEGMENTS | TEST_FOLDERS | LOOKUP_EXTRA_FOLDERS
|
|
2463
2565
|
)
|
|
2464
2566
|
# The test folders the walk alone skips wherever they lie (end-to-end tests,
|
|
2465
2567
|
# Jest's `__fixtures__` beside the code): a file the map cites in one is
|
|
@@ -2467,15 +2569,17 @@ LOOKUP_SKIPPED_FOLDERS = frozenset(
|
|
|
2467
2569
|
LOOKUP_WALK_FOLDERS = frozenset({"e2e", "__fixtures__"})
|
|
2468
2570
|
# The folders the walk alone skips as the first folder below its walk root
|
|
2469
2571
|
# or below a package root it passes through (`_package_root`'s roots):
|
|
2470
|
-
# examples and demos, samples, scripts, playgrounds, benchmarks
|
|
2471
|
-
#
|
|
2472
|
-
#
|
|
2473
|
-
#
|
|
2474
|
-
#
|
|
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.
|
|
2475
2579
|
LOOKUP_TOP_FOLDERS = frozenset({
|
|
2476
2580
|
"example", "demo", "demos", "sample", "samples", "scripts", "playground",
|
|
2477
2581
|
"bench", "benchmark", "migrations",
|
|
2478
|
-
})
|
|
2582
|
+
}) | PY_NON_CORE_TOPS
|
|
2479
2583
|
# The kinds of a documented inventory (validate-inventory.py's `kind`) that a
|
|
2480
2584
|
# module cannot be: such a name takes no line 1 from a module so named. A
|
|
2481
2585
|
# `method` is left out: reconcile-coverage.py never lists a method as stale,
|
|
@@ -2614,7 +2718,9 @@ def declared_line(
|
|
|
2614
2718
|
`class`, assignment, annotation, type alias, TS/JS declaration, or an
|
|
2615
2719
|
alias that renames, such as `from X import Y as NAME`; a plain or star
|
|
2616
2720
|
import, an `as` that keeps the imported name, a re-export and an
|
|
2617
|
-
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
|
|
2618
2724
|
line 1 when it is the module or package so named (`_module_name`).
|
|
2619
2725
|
|
|
2620
2726
|
`source_file` is the file's path below the source root, as recorded
|
|
@@ -2745,6 +2851,431 @@ def baseline_gaps(
|
|
|
2745
2851
|
return gaps, unchecked
|
|
2746
2852
|
|
|
2747
2853
|
|
|
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.
|
|
2856
|
+
_JS_STRING_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|`(?:[^`\\]|\\.)*`")
|
|
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
|
+
}
|
|
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]
|
|
2897
|
+
|
|
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
|
|
2933
|
+
continue
|
|
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
|
|
3021
|
+
continue
|
|
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]
|
|
3277
|
+
|
|
3278
|
+
|
|
2748
3279
|
class _DeclarationLookup:
|
|
2749
3280
|
"""Where the source declares the stale names (classify-stale's
|
|
2750
3281
|
`defined_at`): a column-0 declaration, an alias that renames included, a
|
|
@@ -2762,12 +3293,19 @@ class _DeclarationLookup:
|
|
|
2762
3293
|
(`_is_package_root`) that hold no cited file, the files
|
|
2763
3294
|
`_lookup_skips_file` names and every symlink. Only the rule families
|
|
2764
3295
|
(Python, TS/JS) the entries cite are read (both when no entry cites a
|
|
2765
|
-
file). A cited file is read
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
(`
|
|
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`)."""
|
|
2771
3309
|
|
|
2772
3310
|
def __init__(self, entries: list[dict], source_root: Path) -> None:
|
|
2773
3311
|
self.root = source_root
|
|
@@ -2786,6 +3324,13 @@ class _DeclarationLookup:
|
|
|
2786
3324
|
# the cited files read, kept for the walk: a few files at most
|
|
2787
3325
|
self._cited: dict[tuple[str, ...], list[str] | None] = {}
|
|
2788
3326
|
self._cited_cache = _SourceCache()
|
|
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] = {}
|
|
2789
3334
|
|
|
2790
3335
|
# -- the walk root ------------------------------------------------------
|
|
2791
3336
|
|
|
@@ -2850,20 +3395,14 @@ class _DeclarationLookup:
|
|
|
2850
3395
|
|
|
2851
3396
|
# -- reading ------------------------------------------------------------
|
|
2852
3397
|
|
|
2853
|
-
def _below_walk_root(self, parts: tuple[str, ...]) -> tuple[str, ...]:
|
|
2854
|
-
depth = len(self.folder)
|
|
2855
|
-
return parts[depth:] if parts[:depth] == self.folder else parts
|
|
2856
|
-
|
|
2857
3398
|
def _reads_cited(self, parts: tuple[str, ...]) -> bool:
|
|
2858
3399
|
"""True when the lookup reads a cited file: one of a family the
|
|
2859
|
-
entries cite, in
|
|
2860
|
-
inside the source root
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
return False
|
|
2866
|
-
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
|
+
)
|
|
2867
3406
|
|
|
2868
3407
|
def _cited_lines(self, parts: tuple[str, ...]) -> list[str] | None:
|
|
2869
3408
|
if parts not in self._cited:
|
|
@@ -2872,6 +3411,74 @@ class _DeclarationLookup:
|
|
|
2872
3411
|
self._cited[parts] = _text_lines(_read_source(path)) if readable else None
|
|
2873
3412
|
return self._cited[parts]
|
|
2874
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
|
+
|
|
2875
3482
|
def _is_package_root(self, folder: tuple[str, ...], filenames: list[str]) -> bool:
|
|
2876
3483
|
"""True when a walked folder (its `filenames`) is a package root of a
|
|
2877
3484
|
family the walk reads, as `_package_root` finds one: TS/JS, a folder
|
|
@@ -2921,8 +3528,8 @@ class _DeclarationLookup:
|
|
|
2921
3528
|
found.append((*below, filename))
|
|
2922
3529
|
return sorted(found)
|
|
2923
3530
|
|
|
2924
|
-
@staticmethod
|
|
2925
3531
|
def _first(
|
|
3532
|
+
self,
|
|
2926
3533
|
parts: tuple[str, ...],
|
|
2927
3534
|
lines: list[str],
|
|
2928
3535
|
name: str,
|
|
@@ -2931,16 +3538,31 @@ class _DeclarationLookup:
|
|
|
2931
3538
|
) -> str | None:
|
|
2932
3539
|
"""`file:line` of the first line of a file that declares `name`, else,
|
|
2933
3540
|
with `module_fallback`, its line 1 when it is the module or package
|
|
2934
|
-
so named (`declared_line`).
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
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:
|
|
3554
|
+
return None
|
|
3555
|
+
self._declaring[file] = renames
|
|
3556
|
+
self._candidates[(file, name)] = candidates
|
|
3557
|
+
return f"{file}:{candidates[0]}"
|
|
2939
3558
|
|
|
2940
3559
|
# -- the answer ---------------------------------------------------------
|
|
2941
3560
|
|
|
2942
3561
|
def find(
|
|
2943
|
-
self,
|
|
3562
|
+
self,
|
|
3563
|
+
cited: dict[str, list[str]],
|
|
3564
|
+
strict: frozenset[str] = frozenset(),
|
|
3565
|
+
cited_fallback: bool = True,
|
|
2944
3566
|
) -> tuple[dict[str, str], dict[str, list[str]]]:
|
|
2945
3567
|
"""For the names of `cited` (name -> the source_file values its
|
|
2946
3568
|
entries record): name -> `file:line` for each one the source
|
|
@@ -2949,7 +3571,10 @@ class _DeclarationLookup:
|
|
|
2949
3571
|
cited file settles). A dotted name is never looked up, and a name of
|
|
2950
3572
|
`strict` takes no line 1 from a module or package so named that is
|
|
2951
3573
|
not itself the export (`_exports_itself`), in a cited file or in
|
|
2952
|
-
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."""
|
|
2953
3578
|
found: dict[str, str] = {}
|
|
2954
3579
|
if not self.families:
|
|
2955
3580
|
return found, {}
|
|
@@ -2961,7 +3586,8 @@ class _DeclarationLookup:
|
|
|
2961
3586
|
lines = self._cited_lines(parts)
|
|
2962
3587
|
hit = None if lines is None else self._first(
|
|
2963
3588
|
parts, lines, name, self._cited_cache,
|
|
2964
|
-
|
|
3589
|
+
cited_fallback
|
|
3590
|
+
and (name not in strict or _exports_itself(parts, "\n".join(lines))),
|
|
2965
3591
|
)
|
|
2966
3592
|
if hit is not None:
|
|
2967
3593
|
found[name] = hit
|
|
@@ -3018,6 +3644,59 @@ class _DeclarationLookup:
|
|
|
3018
3644
|
hits[name].append(hit)
|
|
3019
3645
|
return {name: sorted(_without_stubs(h)) for name, h in hits.items()}, unsure
|
|
3020
3646
|
|
|
3647
|
+
def keep_declaring(self) -> None:
|
|
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."""
|
|
3651
|
+
self._declaring = {}
|
|
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."""
|
|
3686
|
+
cited = list(dict.fromkeys(filter(None, map(_cited_parts, files))))
|
|
3687
|
+
for hit in sorted(hits):
|
|
3688
|
+
file, _, number = hit.rpartition(":")
|
|
3689
|
+
renames = (self._declaring or {}).get(file)
|
|
3690
|
+
if renames is None or not number.isdigit():
|
|
3691
|
+
continue
|
|
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
|
|
3698
|
+
return None
|
|
3699
|
+
|
|
3021
3700
|
|
|
3022
3701
|
def inventory_kinds(data: object, path: str) -> dict[str, frozenset[str]]:
|
|
3023
3702
|
"""name -> the kinds a documented inventory gives it (`--inventory`):
|
|
@@ -3054,18 +3733,28 @@ def classify_stale(
|
|
|
3054
3733
|
"""The fabricated-signature test for each stale name: the definition-lines
|
|
3055
3734
|
rules at every provenance entry whose raw `export_name` is the name, and
|
|
3056
3735
|
for a name that is not fabricated, where the source declares it
|
|
3057
|
-
(`defined_at`, and `declared_in` when the walk looked for it).
|
|
3058
|
-
|
|
3059
|
-
a
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
`
|
|
3736
|
+
(`defined_at`, and `declared_in` when the walk looked for it). A name
|
|
3737
|
+
the cited files do not define is looked up too, after the others and
|
|
3738
|
+
in a lookup of its own (an error there keeps it fabricated and clears
|
|
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
|
|
3747
|
+
entry gives `source` and how `defined_at` and `declared_in` are found.
|
|
3748
|
+
`kinds` (`inventory_kinds`, None without `--inventory`) keeps a name of
|
|
3749
|
+
a `NO_MODULE_KINDS` kind from a module or package so named, unless an
|
|
3750
|
+
entry of the name is a `module` or `package` one.
|
|
3064
3751
|
"""
|
|
3065
3752
|
entries = [e for e in prov.get("entries") or [] if isinstance(e, dict)]
|
|
3066
3753
|
results: list[dict] = []
|
|
3067
|
-
# the names to look up
|
|
3754
|
+
# the names to look up that are not fabricated: name -> its cited files
|
|
3068
3755
|
cited: dict[str, list[str]] = {}
|
|
3756
|
+
# the not-defined names an alias may clear: name -> its checked entries' files
|
|
3757
|
+
suspects: dict[str, list[str]] = {}
|
|
3069
3758
|
# the names a module so named does not declare
|
|
3070
3759
|
strict: set[str] = set()
|
|
3071
3760
|
for name in names:
|
|
@@ -3097,7 +3786,12 @@ def classify_stale(
|
|
|
3097
3786
|
reason = STALE_UNCHECKED
|
|
3098
3787
|
source = next(filter(None, map(_entry_citation, mine)), None)
|
|
3099
3788
|
fabricated = reason == STALE_NOT_DEFINED
|
|
3100
|
-
if
|
|
3789
|
+
if fabricated:
|
|
3790
|
+
# a missing file declares nothing an alias could rename
|
|
3791
|
+
checked = [c["source_file"] for c in checks if c["line_check"] == "checked"]
|
|
3792
|
+
if checked:
|
|
3793
|
+
suspects[name] = checked
|
|
3794
|
+
else:
|
|
3101
3795
|
cited[name] = [f for f in map(_entry_file, mine) if f is not None]
|
|
3102
3796
|
documented = (kinds or {}).get(name, frozenset())
|
|
3103
3797
|
if documented & NO_MODULE_KINDS and not any(map(_module_entry, mine)):
|
|
@@ -3113,16 +3807,46 @@ def classify_stale(
|
|
|
3113
3807
|
"entries": checks,
|
|
3114
3808
|
}
|
|
3115
3809
|
)
|
|
3810
|
+
lookup: _DeclarationLookup | None = None
|
|
3116
3811
|
try:
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
)
|
|
3812
|
+
lookup = _DeclarationLookup(entries, source_root)
|
|
3813
|
+
found, declared = lookup.find(cited, frozenset(strict))
|
|
3120
3814
|
except _LOOKUP_ERRORS:
|
|
3121
3815
|
# a lookup that fails proves nothing: every name stays null
|
|
3122
3816
|
found, declared = {}, {}
|
|
3817
|
+
# the suspects' own lookup, after: an error there clears no name settled
|
|
3818
|
+
# above, and keeps every suspect fabricated
|
|
3819
|
+
aliased: dict[str, tuple[str, str]] = {} # name -> (the rename's `file:line`, the `L` it renames)
|
|
3820
|
+
suspect_declared: dict[str, list[str]] = {}
|
|
3821
|
+
if suspects and lookup is not None:
|
|
3822
|
+
try:
|
|
3823
|
+
# a suspect is strict, and a cited module so named never settles
|
|
3824
|
+
# it before the walk, which finds the renames
|
|
3825
|
+
lookup.keep_declaring()
|
|
3826
|
+
suspect_found, suspect_declared = lookup.find(
|
|
3827
|
+
suspects, frozenset(suspects), cited_fallback=False
|
|
3828
|
+
)
|
|
3829
|
+
for name, files in suspects.items():
|
|
3830
|
+
hits = suspect_declared[name] if name in suspect_declared else (
|
|
3831
|
+
[suspect_found[name]] if name in suspect_found else []
|
|
3832
|
+
)
|
|
3833
|
+
alias = lookup.renamed(name, hits, files)
|
|
3834
|
+
if alias is not None:
|
|
3835
|
+
aliased[name] = alias
|
|
3836
|
+
except _LOOKUP_ERRORS:
|
|
3837
|
+
aliased, suspect_declared = {}, {}
|
|
3123
3838
|
for item in results:
|
|
3124
|
-
|
|
3125
|
-
|
|
3839
|
+
name = item["name"]
|
|
3840
|
+
if name in suspects:
|
|
3841
|
+
if name in aliased:
|
|
3842
|
+
defined_at, local = aliased[name]
|
|
3843
|
+
item.update(
|
|
3844
|
+
fabricated=False, reason=STALE_ALIASED,
|
|
3845
|
+
defined_at=defined_at, declared_in=suspect_declared.get(name), renames=local,
|
|
3846
|
+
)
|
|
3847
|
+
continue # any other suspect stays fabricated, both null
|
|
3848
|
+
item["defined_at"] = found.get(name)
|
|
3849
|
+
item["declared_in"] = declared.get(name)
|
|
3126
3850
|
return results
|
|
3127
3851
|
|
|
3128
3852
|
|