bmad-module-skill-forge 7.0.0 → 8.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +8 -8
- 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 +957 -183
- package/src/skf-analyze-source/SKILL.md +1 -1
- package/src/skf-audit-skill/SKILL.md +1 -1
- package/src/skf-audit-skill/customize.toml +4 -3
- package/src/skf-audit-skill/references/report.md +1 -1
- package/src/skf-brief-skill/SKILL.md +1 -1
- package/src/skf-campaign/SKILL.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/SKILL.md +1 -1
- 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/SKILL.md +1 -1
- 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/SKILL.md +1 -1
- package/src/skf-drop-skill/references/invocation-contract.md +1 -1
- package/src/skf-export-skill/SKILL.md +1 -1
- package/src/skf-export-skill/references/summary.md +1 -1
- package/src/skf-forger/SKILL.md +1 -1
- package/src/skf-quick-skill/SKILL.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/SKILL.md +1 -1
- package/src/skf-refine-architecture/customize.toml +1 -1
- package/src/skf-refine-architecture/references/report.md +2 -2
- package/src/skf-rename-skill/SKILL.md +1 -1
- package/src/skf-rename-skill/references/report.md +1 -1
- package/src/skf-setup/SKILL.md +1 -1
- package/src/skf-test-skill/SKILL.md +1 -1
- package/src/skf-test-skill/customize.toml +10 -0
- 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/init.md +1 -1
- package/src/skf-test-skill/references/invocation-contract.md +1 -1
- package/src/skf-test-skill/references/report.md +3 -2
- package/src/skf-test-skill/references/scoring-rules.md +2 -2
- package/src/skf-test-skill/references/signature-comparison-rules.md +4 -1
- package/src/skf-test-skill/scripts/gap-ledger.py +218 -46
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +12 -6
- package/src/skf-test-skill/templates/test-report-template.md +4 -3
- package/src/skf-update-skill/SKILL.md +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 +18 -7
- package/src/skf-update-skill/references/write.md +24 -12
- package/src/skf-verify-stack/SKILL.md +1 -1
- package/src/skf-verify-stack/references/report.md +1 -1
|
@@ -126,7 +126,12 @@ follow the `source_file` extension:
|
|
|
126
126
|
(`export function`, `export interface`, `export const` and so on),
|
|
127
127
|
which is the line cited;
|
|
128
128
|
`export import NAME =`; `export * as NAME from` (NAME may be quoted);
|
|
129
|
-
`export default NAME`;
|
|
129
|
+
`export default NAME`; for the name `default`, the one SKF's extraction
|
|
130
|
+
gives a module's default export, any `export default` line at column 0,
|
|
131
|
+
whatever follows it (`export default storybookTest;`,
|
|
132
|
+
`export default async function postinstall() {}`, an anonymous
|
|
133
|
+
function or class, an object or any other expression, the value on
|
|
134
|
+
the next line), decorators before `export` allowed;
|
|
130
135
|
an `export { ... }` list whose exposed name is NAME, an identifier or
|
|
131
136
|
a quoted string on either side of `as`; CommonJS
|
|
132
137
|
`exports.NAME =`, `module.exports.NAME =`, or NAME as a key or
|
|
@@ -463,34 +468,119 @@ Subcommands:
|
|
|
463
468
|
| "skipped-export-type"
|
|
464
469
|
| "skipped-language" | "unreadable"
|
|
465
470
|
| "no-file",
|
|
466
|
-
"definition_lines": [<int>, ...] | null}, ...]
|
|
471
|
+
"definition_lines": [<int>, ...] | null}, ...],
|
|
472
|
+
"renames": "<L>"}, # an `aliased` item only
|
|
467
473
|
...
|
|
468
474
|
]
|
|
469
475
|
|
|
470
476
|
`fabricated` is true, with reason `not-defined`, when the name has an
|
|
471
477
|
entry and every entry's `line_check` is `file-missing`, or `checked`
|
|
472
478
|
with an empty `definition_lines`: the export is in neither the surface
|
|
473
|
-
nor the cited file, and no
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
`
|
|
482
|
-
`
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
479
|
+
nor the cited file, and no line elsewhere renames to it, directly or
|
|
480
|
+
through a chain of renames, the declaration a cited file holds. Such a
|
|
481
|
+
rename gives the reason `aliased` and `fabricated` false, and its line
|
|
482
|
+
is the `defined_at`: the first, per file of the name's declarations the
|
|
483
|
+
lookup below finds (its `declared_in`, else the cited file's) and per
|
|
484
|
+
candidate line of that file in order, that renames to NAME a name `L`
|
|
485
|
+
which a file of a `checked` entry of the name declares by the same
|
|
486
|
+
rule (no module or package so named counting; for `L` `default`, a
|
|
487
|
+
column-0 `export default`, `export { X as default }` or
|
|
488
|
+
`exports.default =`), or which a file the rename resolves to declares
|
|
489
|
+
or renames, by the chain below, the chain ending at such a cited
|
|
490
|
+
declaration. The name that cited file declares where the chain ends
|
|
491
|
+
(for a one-step rename, the `L` the line at `defined_at` renames) is
|
|
492
|
+
the item's `renames`, a key no other item has, so that test-skill's
|
|
493
|
+
provenance line gap can say how to record the rename.
|
|
494
|
+
The renames read:
|
|
495
|
+
- an export-list item `L as NAME` (`export { ... }`,
|
|
496
|
+
`export type { ... }`, on one line or an item of a multi-line
|
|
497
|
+
list), `L` `default` when the list has a `from`
|
|
498
|
+
(`export { default as NAME } from './m'`), a string side unquoted
|
|
499
|
+
only when the string is the whole side (`L as "NAME"`,
|
|
500
|
+
`"L" as NAME`), the list's opening line at column 0 as written, so
|
|
501
|
+
that a comment may open it;
|
|
502
|
+
- a local `export { NAME }` or `export type { NAME }` item without
|
|
503
|
+
`as`, when a column-0 import binds NAME to another name
|
|
504
|
+
(`import { L as NAME }`, or `import NAME from`, `L` `default`), at
|
|
505
|
+
the export;
|
|
506
|
+
- a column-0 `const`, `let` or `var NAME = L`, annotated or not
|
|
507
|
+
(`const NAME: <type> = L`, the type on the line, holding no `;`
|
|
508
|
+
and no `=` but in `=>`), `type NAME = L` or a generic
|
|
509
|
+
`type NAME<P1, ...> = L<P1, ...>` whose arguments are its bare
|
|
510
|
+
parameters, the same names in the same order, exported or not,
|
|
511
|
+
`L` a bare identifier and the statement ending on its line;
|
|
512
|
+
- for NAME `default`, a column-0 `export default L` (the `;`
|
|
513
|
+
optional), `L` a bare identifier that a column-0 import or
|
|
514
|
+
declaration of the same file binds and the statement ending on its
|
|
515
|
+
line (`import { createEntry } from './api'` then
|
|
516
|
+
`export default createEntry;`);
|
|
517
|
+
- a Python `from X import L as NAME` item (on one line, in
|
|
518
|
+
parentheses or after a backslash), the statement at column 0, one
|
|
519
|
+
level in a column-0 `try`, `except`, `else`, `if`, `elif`, `with`
|
|
520
|
+
or `finally` block or as such a header's one-line body after its
|
|
521
|
+
first colon (`try: from X import L as NAME`), as compatibility
|
|
522
|
+
imports are written, and a column-0 Python `NAME = L` or
|
|
523
|
+
`NAME: <type> = L` (the type holding no `;` or `=`), `L` a bare
|
|
524
|
+
identifier.
|
|
525
|
+
A TS/JS line is read with its comments removed (an export list split
|
|
526
|
+
on a copy whose one-line strings are blanked), a Python line with its
|
|
527
|
+
`#` comment cut. `L` comes from the rename's `from` module, else from
|
|
528
|
+
the column-0 import that binds `L`, whose imported name `L` becomes
|
|
529
|
+
(`default` for a default import; a namespace import binds none). The
|
|
530
|
+
module is resolved below the source root, under the lookup's guards
|
|
531
|
+
for a cited file: a TS/JS `./` or `../` module from the renaming
|
|
532
|
+
file's folder, as the path itself, a `.js`-family path's TypeScript
|
|
533
|
+
twin (`./m.js` names `m.ts`), `<path><ext>` or `<path>/index<ext>`
|
|
534
|
+
(`.d.ts` included); a Python relative module from the file's folder
|
|
535
|
+
(`.` the folder's own `__init__`), or an absolute one below the parent
|
|
536
|
+
of the file's package root, as `m.py`, `m.pyi`, `m/__init__.py` or
|
|
537
|
+
`m/__init__.pyi`. An `L` with no module resolves to the renaming file
|
|
538
|
+
itself. When one of the files the rename resolves to that declare `L`
|
|
539
|
+
is a cited file, the rename is an alias. Otherwise the rename is
|
|
540
|
+
followed as a chain through each of those files that declares `L` or
|
|
541
|
+
renames it (a middle file): each candidate line of `L` there that
|
|
542
|
+
renames it, by a form above (an export-list alias, a compatibility
|
|
543
|
+
import in a block or on one line, an import then a plain re-export,
|
|
544
|
+
an assignment of a bare `L`, annotated or not, an `export default L`
|
|
545
|
+
of a bound `L`, which a rename of `default` reaches:
|
|
546
|
+
`export { default as NAME } from`, or `import NAME from` re-exported)
|
|
547
|
+
or by a Python column-0 `L = M.L2`, annotated or not, whose `M` a
|
|
548
|
+
column-0 `from X import M` binds (the module `X` joined with `M`, `.`
|
|
549
|
+
prefixes kept), is the next step, read and resolved the same way, until
|
|
550
|
+
a step reaches a cited file that declares its name (an alias) or none
|
|
551
|
+
does (fabricated, so a rename of another file's same-named `L` stays
|
|
552
|
+
fabricated). Each (file, name) pair, a resolved file that declares
|
|
553
|
+
or renames the name, is followed once per first rename, so a chain that
|
|
554
|
+
loops ends, fabricated. When the first rename's module resolves to no
|
|
555
|
+
file (a package name, never looked up in `node_modules`, a tsconfig
|
|
556
|
+
`paths` alias, a package `exports` map, a path with no such file) or
|
|
557
|
+
to files that declare no `L` (a barrel that re-exports it), any cited
|
|
558
|
+
file that declares `L` clears the name; at a later step of a chain,
|
|
559
|
+
such a module ends the chain, fabricated, unless one of those files
|
|
560
|
+
renames `L` (a middle file, followed as above). The suspects' cited pass
|
|
561
|
+
settles no such name at line 1 of a cited module or package so named
|
|
562
|
+
before the walk, even when the module is itself the export (a column-0
|
|
563
|
+
`export default` or `module.exports =`), so the walk always runs; the
|
|
564
|
+
walk keeps its module rule, so `declared_in` may list that module at
|
|
565
|
+
its line 1 when the walk reads its folder. Any other rename stays
|
|
566
|
+
fabricated: a TS type assertion (`x as NAME`), a
|
|
567
|
+
call or any other expression (`const NAME = make()`, and `NAME = a.b`
|
|
568
|
+
at the documented name's own line: `L = M.L2` is only a later step of
|
|
569
|
+
a chain), a default export of any other expression
|
|
570
|
+
(`export default make();`, an object, an arrow, a value that goes on
|
|
571
|
+
to the next line, an indented `export default L`, an `L` no import or
|
|
572
|
+
declaration of the file binds), a generic alias whose parameters are
|
|
573
|
+
constrained, defaulted, swapped or instantiated
|
|
574
|
+
(`type NAME<T extends X> = L<T>`, `type NAME<T = X> = L<T>`,
|
|
575
|
+
`type NAME<K, V> = L<V, K>`, `type NAME = L<string>`),
|
|
576
|
+
`export * as NAME from`, Python
|
|
577
|
+
`import M as NAME`, a rename of a module (`import * as L`,
|
|
578
|
+
`from . import L as NAME`: no module or package so named declares
|
|
579
|
+
`L`), a rename inside a `def`, a `class` or a deeper block (two levels
|
|
580
|
+
in a `with` block included), `as NAME` in a comment or a string, a
|
|
581
|
+
`default as NAME` whose module has only `module.exports =`, and a
|
|
582
|
+
chain that leaves the files the resolver reads, loops or ends at
|
|
583
|
+
another declaration.
|
|
494
584
|
Otherwise the reason is `defined` (a cited file defines the name),
|
|
495
585
|
`unchecked` (no rule could check a cited file, and none defines it) or
|
|
496
586
|
`no-entry` (the map has no entry for the name).
|
|
@@ -511,8 +601,9 @@ Subcommands:
|
|
|
511
601
|
a `def`, `class`, assignment, annotation, type alias, TS/JS
|
|
512
602
|
declaration, `exports.NAME =`, or an alias that renames
|
|
513
603
|
(`import X as NAME`, `from X import Y as NAME`,
|
|
514
|
-
`export { Y as NAME }`, `export * as NAME from`),
|
|
515
|
-
`module.exports` key with a value or a method
|
|
604
|
+
`export { Y as NAME }`, `export * as NAME from`), a
|
|
605
|
+
`module.exports` key with a value or a method, or, for the name
|
|
606
|
+
`default`, any `export default` line. A plain or star import,
|
|
516
607
|
an `as` that keeps the imported name (`from m import NAME as NAME`,
|
|
517
608
|
`export { NAME as NAME }`), `export import NAME =`,
|
|
518
609
|
`export default NAME`, an export-list item without `as`, a
|
|
@@ -521,10 +612,18 @@ Subcommands:
|
|
|
521
612
|
`const NAME = require(...)`, `const { NAME } = require(...)`,
|
|
522
613
|
`exports.NAME = require(...)`, `{ NAME: require(...) }`), an
|
|
523
614
|
indented line and any line of a Python
|
|
524
|
-
file that does not tokenize never count
|
|
615
|
+
file that does not tokenize never count, except that for the name
|
|
616
|
+
`default` a column-0 `export default` line counts whatever follows it,
|
|
617
|
+
an import or an imported local included (an item of a multi-line list
|
|
525
618
|
counts at its own line, the list's opening line at column 0, so an
|
|
526
|
-
`aliased` name's `defined_at` is its rename's line).
|
|
527
|
-
|
|
619
|
+
`aliased` name's `defined_at` is its rename's line). For a
|
|
620
|
+
`not-defined` name the alias check reads more lines than this rule: a
|
|
621
|
+
file's candidate lines are every column-0 line that declares the name,
|
|
622
|
+
a Python `from X import L as NAME` item one level in a column-0
|
|
623
|
+
`try`, `except`, `else`, `if` or `elif` block, and a TS/JS local
|
|
624
|
+
`export { NAME }` item when a column-0 import binds NAME to another
|
|
625
|
+
name, and its `declared_in` lists the file at the first of them. A
|
|
626
|
+
module or package named NAME (`NAME.py`, `NAME/__init__.py`, a TS/JS
|
|
528
627
|
`NAME.<ext>` or `NAME/index.<ext>`) declares NAME too: at its line 1
|
|
529
628
|
when no line of it
|
|
530
629
|
declares NAME, unless `--inventory` gives NAME a `function`, `hook`,
|
|
@@ -566,18 +665,20 @@ Subcommands:
|
|
|
566
665
|
test output lives in (`node_modules`, `dist`, `build`, `vendor`,
|
|
567
666
|
`_vendor`, `tests`, `testing`, `fixtures`, `__mocks__`,
|
|
568
667
|
`site-packages`, `env`, `bower_components`, `esm`, `cjs`, `umd` and
|
|
569
|
-
the like),
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
`
|
|
574
|
-
`
|
|
575
|
-
`
|
|
576
|
-
(deeper in a package such a folder can be package code
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
668
|
+
the like), each judged on the path below the walk root, the
|
|
669
|
+
end-to-end test folders `e2e` and `__fixtures__` wherever they lie,
|
|
670
|
+
and, as the first folder below the walk root or below a package root
|
|
671
|
+
inside it (a folder that holds `package.json`, the top of an
|
|
672
|
+
`__init__.py` chain), `example`, `demo`, `demos`, `sample`, `samples`,
|
|
673
|
+
`scripts`, `playground`, `bench`, `benchmark`, `migrations`, `docs`,
|
|
674
|
+
`examples` and `benchmarks` unless it holds a file an entry cites
|
|
675
|
+
(deeper in a package such a folder can be package code, as Storybook's
|
|
676
|
+
`code/addons/docs/`). A cited file is read in any folder, a hidden one
|
|
677
|
+
and one the walk skips included, but only inside the source root (a
|
|
678
|
+
path that is absolute or climbs out of it is not read), with no
|
|
679
|
+
symlink on its path and under a name the walk reads (no test file,
|
|
680
|
+
test configuration file or `*.min.js`). The path is relative to the
|
|
681
|
+
source root. A dotted name, or a map whose entries cite files
|
|
581
682
|
but no Python or TS/JS one, is not looked up (`defined_at` and
|
|
582
683
|
`declared_in` null, as for a fabricated name). A filesystem error
|
|
583
684
|
during the lookup of the names that are not fabricated gives each of
|
|
@@ -1928,8 +2029,12 @@ def _tsjs_definition_lines(
|
|
|
1928
2029
|
depend on the name either): a name its pattern binds counts when the
|
|
1929
2030
|
`const` / `let` / `var` line it is keyed by opens a statement here.
|
|
1930
2031
|
Class members count only for a
|
|
1931
|
-
dotted export name. `
|
|
1932
|
-
|
|
2032
|
+
dotted export name. For the undotted name `default`, the name SKF's
|
|
2033
|
+
extraction gives a module's default export, a column-0 `export default`
|
|
2034
|
+
line counts whatever follows it, with `declarations_only` too, and
|
|
2035
|
+
whatever block holds it; an indented one (as in a formatted
|
|
2036
|
+
`declare module` block) does not. `declarators` splits
|
|
2037
|
+
a declarator list (`_js_declarators`, or `_SourceCache.js_declarators`, which splits each
|
|
1933
2038
|
line once for every export looked up). With `declarations_only`,
|
|
1934
2039
|
`export import NAME =`, `export default NAME`, an export-list item
|
|
1935
2040
|
without an `as` that renames, a `module.exports` shorthand key and an
|
|
@@ -1953,6 +2058,11 @@ def _tsjs_definition_lines(
|
|
|
1953
2058
|
re.compile(rf"^\s*export\s+import\s+(?:type\s+)?{esc}\s*=(?![=>])"),
|
|
1954
2059
|
re.compile(rf"^\s*export\s+default\s+{esc}\s*;?\s*$"),
|
|
1955
2060
|
]
|
|
2061
|
+
if name == "default" and not dotted:
|
|
2062
|
+
# the export named `default`: a column-0 `export default` line,
|
|
2063
|
+
# whatever follows it (a name, a declaration, an expression, or
|
|
2064
|
+
# nothing, the value on the next line), decorators before it
|
|
2065
|
+
statements.append(re.compile(rf"^(?:{_JS_DECORATOR}\s*)*export\s+default{end}"))
|
|
1956
2066
|
# One declarator of a `const` / `let` / `var` list: `NAME = v`,
|
|
1957
2067
|
# `NAME: T = v`, `NAME!: T`, or a bare `NAME`. A destructuring pattern
|
|
1958
2068
|
# starts with `{` or `[` and never matches: the names a pattern that
|
|
@@ -2452,8 +2562,10 @@ def _stale_line_check(
|
|
|
2452
2562
|
|
|
2453
2563
|
# The folders and files the `defined_at` walk leaves out, judged
|
|
2454
2564
|
# case-insensitively on the path below its walk root: generated, built and
|
|
2455
|
-
# installed output, a vendored copy of another package, tests and their
|
|
2456
|
-
# configuration
|
|
2565
|
+
# installed output, a vendored copy of another package, and tests and their
|
|
2566
|
+
# configuration. The folders are the walk's alone: a file the map cites
|
|
2567
|
+
# is read in any of them, a test file, its configuration or a minified
|
|
2568
|
+
# bundle never.
|
|
2457
2569
|
# Keep identical to EXCLUDED_DIR_NAMES in skf-resolve-authoritative-files.py.
|
|
2458
2570
|
EXCLUDED_DIR_NAMES = {
|
|
2459
2571
|
"node_modules", "__pycache__", "dist", "build", ".webpack",
|
|
@@ -2491,8 +2603,7 @@ LOOKUP_EXTRA_FOLDERS = frozenset({
|
|
|
2491
2603
|
"bower_components", "esm", "cjs", "umd",
|
|
2492
2604
|
})
|
|
2493
2605
|
LOOKUP_SKIPPED_FOLDERS = frozenset(
|
|
2494
|
-
EXCLUDED_DIR_NAMES | _VENDORED_SEGMENTS | TEST_FOLDERS |
|
|
2495
|
-
| LOOKUP_EXTRA_FOLDERS
|
|
2606
|
+
EXCLUDED_DIR_NAMES | _VENDORED_SEGMENTS | TEST_FOLDERS | LOOKUP_EXTRA_FOLDERS
|
|
2496
2607
|
)
|
|
2497
2608
|
# The test folders the walk alone skips wherever they lie (end-to-end tests,
|
|
2498
2609
|
# Jest's `__fixtures__` beside the code): a file the map cites in one is
|
|
@@ -2500,15 +2611,17 @@ LOOKUP_SKIPPED_FOLDERS = frozenset(
|
|
|
2500
2611
|
LOOKUP_WALK_FOLDERS = frozenset({"e2e", "__fixtures__"})
|
|
2501
2612
|
# The folders the walk alone skips as the first folder below its walk root
|
|
2502
2613
|
# or below a package root it passes through (`_package_root`'s roots):
|
|
2503
|
-
# examples and demos, samples, scripts, playgrounds, benchmarks
|
|
2504
|
-
#
|
|
2505
|
-
#
|
|
2506
|
-
#
|
|
2507
|
-
#
|
|
2614
|
+
# examples and demos, samples, scripts, playgrounds, benchmarks, migrations
|
|
2615
|
+
# and docs (`PY_NON_CORE_TOPS`, which skf-extract-public-api.py also leaves
|
|
2616
|
+
# out only as a top folder), which hold code that uses, changes or documents
|
|
2617
|
+
# the package rather than its API. Deeper in a package such a folder can be
|
|
2618
|
+
# live package code (cognee's `cognee/modules/migrations/`, Storybook's
|
|
2619
|
+
# `code/addons/docs/`), so it is walked there; one that holds a file the map
|
|
2620
|
+
# cites is walked too, and that file is still read.
|
|
2508
2621
|
LOOKUP_TOP_FOLDERS = frozenset({
|
|
2509
2622
|
"example", "demo", "demos", "sample", "samples", "scripts", "playground",
|
|
2510
2623
|
"bench", "benchmark", "migrations",
|
|
2511
|
-
})
|
|
2624
|
+
}) | PY_NON_CORE_TOPS
|
|
2512
2625
|
# The kinds of a documented inventory (validate-inventory.py's `kind`) that a
|
|
2513
2626
|
# module cannot be: such a name takes no line 1 from a module so named. A
|
|
2514
2627
|
# `method` is left out: reconcile-coverage.py never lists a method as stale,
|
|
@@ -2647,7 +2760,9 @@ def declared_line(
|
|
|
2647
2760
|
`class`, assignment, annotation, type alias, TS/JS declaration, or an
|
|
2648
2761
|
alias that renames, such as `from X import Y as NAME`; a plain or star
|
|
2649
2762
|
import, an `as` that keeps the imported name, a re-export and an
|
|
2650
|
-
import-valued binding never count
|
|
2763
|
+
import-valued binding never count, except that for the name `default`
|
|
2764
|
+
a column-0 `export default` line counts whatever follows it, an import
|
|
2765
|
+
or an imported local included), else, with `module_fallback`, its
|
|
2651
2766
|
line 1 when it is the module or package so named (`_module_name`).
|
|
2652
2767
|
|
|
2653
2768
|
`source_file` is the file's path below the source root, as recorded
|
|
@@ -2778,66 +2893,535 @@ def baseline_gaps(
|
|
|
2778
2893
|
return gaps, unchecked
|
|
2779
2894
|
|
|
2780
2895
|
|
|
2781
|
-
# A one-line TS/JS string literal
|
|
2896
|
+
# A one-line TS/JS string literal: an export list is split into items on a
|
|
2897
|
+
# copy of its lines whose strings are blanked, quotes and length kept.
|
|
2782
2898
|
_JS_STRING_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|`(?:[^`\\]|\\.)*`")
|
|
2783
2899
|
_JS_IDENT_RE = re.compile(r"[A-Za-z_$][\w$]*")
|
|
2900
|
+
# The `from` clause after an export list's closing brace, and its module.
|
|
2901
|
+
_JS_FROM_RE = re.compile(r"""^\s*from\s*(["'])([^"']*)\1""")
|
|
2902
|
+
# An import statement (not `import(...)` nor `import.meta`); its clause
|
|
2903
|
+
# and module (`import X, { a as b } from './m'`); `import X = ...`; a
|
|
2904
|
+
# side-effect import (`import './m'`); an item of its brace list.
|
|
2905
|
+
_JS_IMPORT_RE = re.compile(r"^\s*import(?![\w$])(?!\s*[(.])")
|
|
2906
|
+
_JS_IMPORT_CLAUSE_RE = re.compile(
|
|
2907
|
+
r"""^\s*import\s+(?:type\s+(?!from(?![\w$])))?(.*?)\s*(?<![\w$])from\s*(["'])([^"']*)\2""",
|
|
2908
|
+
re.S,
|
|
2909
|
+
)
|
|
2910
|
+
_JS_IMPORT_EQUALS_RE = re.compile(r"^\s*import\s+(?:type\s+)?([\w$]+)\s*=(?!=)")
|
|
2911
|
+
_JS_IMPORT_BARE_RE = re.compile(r"""^\s*import\s*["']""")
|
|
2912
|
+
_JS_IMPORT_ITEM_RE = re.compile(rf"^(?:type\s+)?({_JS_NAME_OR_STRING})(?:\s+as\s+([\w$]+))?$")
|
|
2913
|
+
_JS_IMPORT_DEFAULT_RE = re.compile(r"^([\w$]+)\s*(?:,|$)")
|
|
2914
|
+
_JS_IMPORT_NAMESPACE_RE = re.compile(r"^\*\s*as\s+([\w$]+)")
|
|
2915
|
+
# `const|let|var NAME = L` (or `NAME: <type> = L`, the type on the line,
|
|
2916
|
+
# holding no `;`, no `=` but in `=>` and no `,` that opens another
|
|
2917
|
+
# declarator) and `type NAME = L` (or `type NAME<P1, ...> = L<A1, ...>`,
|
|
2918
|
+
# one trailing comma allowed in either list), exported or not:
|
|
2919
|
+
# NAME (`var` or `type`), the type's parameters, L, its arguments, and the
|
|
2920
|
+
# `;` when the line ends the statement (`end`). `renames` reads a generic
|
|
2921
|
+
# one only when its arguments are its bare parameters in order
|
|
2922
|
+
# (`_js_passes_parameters`).
|
|
2923
|
+
_JS_NAME_IS_RE = re.compile(
|
|
2924
|
+
r"^\s*(?:export\s+)?(?:(?:const|let|var)\s+(?P<var>[\w$]+)"
|
|
2925
|
+
r"(?:\s*:(?!=)\s*(?:[^=;,\s]|=>|,(?!\s*[\w$]+\s*=(?![=>])))(?:[^=;,]|=>|,(?!\s*[\w$]+\s*=(?![=>])))*?)?"
|
|
2926
|
+
r"|type\s+(?P<type>[\w$]+)(?:\s*<(?P<params>[^<>]*)>)?)"
|
|
2927
|
+
r"\s*(?<![=!])=(?![=>])\s*(?P<value>[A-Za-z_$][\w$]*)(?:\s*<(?P<args>[^<>]*)>)?\s*(?P<end>;?)$"
|
|
2928
|
+
)
|
|
2929
|
+
# `export default L`, `L` a bare identifier, and the `;` when the line ends
|
|
2930
|
+
# the statement (`end`): for the name `default`, `renames` reads it as a
|
|
2931
|
+
# rename of `L` when an import or a declaration of the same file binds `L`.
|
|
2932
|
+
_JS_EXPORT_DEFAULT_IS_RE = re.compile(r"^\s*export\s+default\s+(?P<value>[A-Za-z_$][\w$]*)\s*(?P<end>;?)\s*$")
|
|
2933
|
+
# A line that begins with one of these goes on with the value above it (a
|
|
2934
|
+
# member, an operator, a call, an index or a tagged template).
|
|
2935
|
+
_JS_VALUE_GOES_ON = _JS_CONTINUATION_START | frozenset("([`")
|
|
2936
|
+
# Python's `from X import ...`, with its module.
|
|
2937
|
+
_PY_FROM_CLAUSE_RE = re.compile(r"^\s*from\s+([\w.]+)\s+import\s+(.*)$")
|
|
2938
|
+
# The column-0 header of a block whose body may hold a compatibility import.
|
|
2939
|
+
_PY_BLOCK_HEADER_RE = re.compile(r"^(?:try|except|else|if|elif|with|finally)(?!\w)")
|
|
2940
|
+
# A column-0 Python `NAME = L` or `NAME: <type> = L`, the type holding no
|
|
2941
|
+
# `=` or `;` (`{name}` escaped): L.
|
|
2942
|
+
_PY_NAME_IS = r"^{name}\s*(?::(?!=)[^=;]+?)?(?<![=!<>])=(?!=)\s*([A-Za-z_]\w*)\s*$"
|
|
2943
|
+
# A column-0 Python `NAME = M.L2` or `NAME: <type> = M.L2` (`{name}`
|
|
2944
|
+
# escaped): (M, L2), a step of a rename chain only (`_Renames.member`).
|
|
2945
|
+
_PY_NAME_IS_MEMBER = r"^{name}\s*(?::(?!=)[^=;]+?)?(?<![!<>])=(?!=)\s*([A-Za-z_]\w*)\.([A-Za-z_]\w*)\s*$"
|
|
2946
|
+
# The files a relative TS/JS module names, after the path itself:
|
|
2947
|
+
# `<path><ext>`, then `<path>/index<ext>`.
|
|
2948
|
+
_TSJS_MODULE_EXTENSIONS = (".ts", ".tsx", ".d.ts", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")
|
|
2949
|
+
# The TypeScript files a `.js`-family module names (`./m.js` is `m.ts`).
|
|
2950
|
+
_TSJS_TWINS = {
|
|
2951
|
+
".js": (".ts", ".tsx", ".d.ts"), ".jsx": (".tsx",), ".mjs": (".mts", ".d.mts"),
|
|
2952
|
+
".cjs": (".cts", ".d.cts"),
|
|
2953
|
+
}
|
|
2784
2954
|
|
|
2955
|
+
# One name an import binds: (line from 1, local name, imported name, module).
|
|
2956
|
+
# A namespace import binds its local name to no imported name (None).
|
|
2957
|
+
_Binding = tuple[int, str, str | None, str | None]
|
|
2785
2958
|
|
|
2786
|
-
|
|
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
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
if
|
|
2959
|
+
|
|
2960
|
+
def _js_blanked(code: str) -> str:
|
|
2961
|
+
"""A comment-free TS/JS line with each one-line string's contents
|
|
2962
|
+
blanked, its quotes and its length kept."""
|
|
2963
|
+
return _JS_STRING_RE.sub(lambda m: m[0][0] + " " * (len(m[0]) - 2) + m[0][-1], code)
|
|
2964
|
+
|
|
2965
|
+
|
|
2966
|
+
def _js_export_sides(item: str) -> tuple[str, str | None] | None:
|
|
2967
|
+
"""(own name, the name after `as`, None without one) of an export-list
|
|
2968
|
+
item, a string side unquoted only when the string is the whole side
|
|
2969
|
+
(`L as "NAME"`, `"L" as NAME`). None for any other text."""
|
|
2970
|
+
m = _JS_EXPORT_ITEM_RE.match(item.strip())
|
|
2971
|
+
if m is None:
|
|
2972
|
+
return None
|
|
2973
|
+
own, exposed = (s[1:-1] if s and s[:1] in "'\"" else s for s in (m.group(1), m.group(2)))
|
|
2974
|
+
return own, exposed
|
|
2975
|
+
|
|
2976
|
+
|
|
2977
|
+
def _js_passes_parameters(params: str | None, args: str | None) -> bool:
|
|
2978
|
+
"""True when a `NAME = L` line's type parameters (`type NAME<P1, ...>`)
|
|
2979
|
+
reach `L` unchanged: neither parameters nor arguments, or bare
|
|
2980
|
+
parameters given as `L`'s arguments, the same names in the same order
|
|
2981
|
+
(`type NAME<K, V> = L<K, V>`). False for a constraint or a default
|
|
2982
|
+
(`<T extends X>`, `<T = X>`), a swap (`L<V, K>`), an instantiation
|
|
2983
|
+
(`L<string>`) and arguments with no parameters."""
|
|
2984
|
+
if params is None or args is None:
|
|
2985
|
+
return params is None and args is None
|
|
2986
|
+
names = [p.strip() for p in params.strip().removesuffix(",").split(",")]
|
|
2987
|
+
given = [a.strip() for a in args.strip().removesuffix(",").split(",")]
|
|
2988
|
+
return all(_JS_IDENT_RE.fullmatch(n) for n in names) and names == given
|
|
2989
|
+
|
|
2990
|
+
|
|
2991
|
+
def _js_export_lists(
|
|
2992
|
+
lines: list[str], code: list[str]
|
|
2993
|
+
) -> list[tuple[list[tuple[int, str]], str | None]]:
|
|
2994
|
+
"""Each export list (`export { ... }`, `export type { ... }`) whose
|
|
2995
|
+
opening line is at column 0 as written (a comment may open the line):
|
|
2996
|
+
its items, as (line from 1, the item's comment-free text), and the
|
|
2997
|
+
module its `from` names, None for a local list. `code` holds the lines
|
|
2998
|
+
with comments removed (`_js_code_lines`). The list is split on a copy
|
|
2999
|
+
whose strings are blanked (`_js_blanked`), so a comma or a brace in a
|
|
3000
|
+
string splits nothing, and an item keeps its strings."""
|
|
3001
|
+
blank = [_js_blanked(c) for c in code]
|
|
3002
|
+
lists: list[tuple[list[tuple[int, str]], str | None]] = []
|
|
3003
|
+
i, n = 0, len(code)
|
|
3004
|
+
while i < n:
|
|
3005
|
+
opened = _JS_EXPORT_LIST_RE.match(blank[i]) if _is_column_0(lines[i]) else None
|
|
3006
|
+
if opened is None:
|
|
3007
|
+
i += 1
|
|
3008
|
+
continue
|
|
3009
|
+
j, col, items = i, opened.start(1), []
|
|
3010
|
+
while True:
|
|
3011
|
+
segment = blank[j][col:]
|
|
3012
|
+
end = segment.find("}")
|
|
3013
|
+
pos = col
|
|
3014
|
+
for piece in (segment if end < 0 else segment[:end]).split(","):
|
|
3015
|
+
text = code[j][pos : pos + len(piece)].strip()
|
|
3016
|
+
if text:
|
|
3017
|
+
items.append((j + 1, text))
|
|
3018
|
+
pos += len(piece) + 1
|
|
3019
|
+
if end >= 0 or j + 1 >= n:
|
|
3020
|
+
break
|
|
3021
|
+
j, col = j + 1, 0
|
|
3022
|
+
module = None
|
|
3023
|
+
if end >= 0:
|
|
3024
|
+
after, k = code[j][col + end + 1 :], j + 1
|
|
3025
|
+
while not after.strip() and k < n: # `}` then `from` on the next line
|
|
3026
|
+
after, k = code[k], k + 1
|
|
3027
|
+
found = _JS_FROM_RE.match(after)
|
|
3028
|
+
module = found.group(2) if found else None
|
|
3029
|
+
lists.append((items, module))
|
|
3030
|
+
i = j + 1
|
|
3031
|
+
return lists
|
|
3032
|
+
|
|
3033
|
+
|
|
3034
|
+
def _js_import_bindings(lines: list[str], code: list[str]) -> list[_Binding]:
|
|
3035
|
+
"""The names each column-0 import binds (`_Binding`): `import X from`
|
|
3036
|
+
binds X to the imported name `default`, `import { L as X }` binds X to
|
|
3037
|
+
L and `import { X }` X to X, on one line or over several, and a
|
|
3038
|
+
namespace import (`import * as X`, `import X = ...`) binds X to none."""
|
|
3039
|
+
found: list[_Binding] = []
|
|
3040
|
+
i, n = 0, len(code)
|
|
3041
|
+
while i < n:
|
|
3042
|
+
opens = _is_column_0(lines[i]) and _JS_IMPORT_RE.match(code[i]) is not None
|
|
3043
|
+
if not opens or _JS_IMPORT_BARE_RE.match(code[i]):
|
|
3044
|
+
i += 1
|
|
2834
3045
|
continue
|
|
2835
|
-
|
|
2836
|
-
|
|
3046
|
+
j, text = i, code[i]
|
|
3047
|
+
while "{" in text and "}" not in text and j + 1 < n:
|
|
3048
|
+
j += 1
|
|
3049
|
+
text += "\n" + code[j]
|
|
3050
|
+
clause = _JS_IMPORT_CLAUSE_RE.match(text)
|
|
3051
|
+
if clause is None and j + 1 < n and _JS_FROM_RE.match(code[j + 1]):
|
|
3052
|
+
j += 1
|
|
3053
|
+
text += "\n" + code[j]
|
|
3054
|
+
clause = _JS_IMPORT_CLAUSE_RE.match(text)
|
|
3055
|
+
line = i + 1
|
|
3056
|
+
if clause is not None:
|
|
3057
|
+
names, module = clause.group(1).strip(), clause.group(3)
|
|
3058
|
+
default = _JS_IMPORT_DEFAULT_RE.match(names)
|
|
3059
|
+
if default is not None:
|
|
3060
|
+
found.append((line, default.group(1), "default", module))
|
|
3061
|
+
names = names[default.end() :].strip()
|
|
3062
|
+
if names.startswith("*"):
|
|
3063
|
+
namespace = _JS_IMPORT_NAMESPACE_RE.match(names)
|
|
3064
|
+
if namespace is not None:
|
|
3065
|
+
found.append((line, namespace.group(1), None, module))
|
|
3066
|
+
elif names.startswith("{"):
|
|
3067
|
+
for item in names[1:].split("}", 1)[0].split(","):
|
|
3068
|
+
m = _JS_IMPORT_ITEM_RE.match(item.strip())
|
|
3069
|
+
if m is None:
|
|
3070
|
+
continue
|
|
3071
|
+
imported = m.group(1)
|
|
3072
|
+
imported = imported[1:-1] if imported[:1] in "'\"" else imported
|
|
3073
|
+
local = m.group(2) or imported
|
|
3074
|
+
if _JS_IDENT_RE.fullmatch(local):
|
|
3075
|
+
found.append((line, local, imported, module))
|
|
3076
|
+
else:
|
|
3077
|
+
equals = _JS_IMPORT_EQUALS_RE.match(text)
|
|
3078
|
+
if equals is not None:
|
|
3079
|
+
found.append((line, equals.group(1), None, None))
|
|
3080
|
+
i = j + 1
|
|
3081
|
+
return found
|
|
3082
|
+
|
|
3083
|
+
|
|
3084
|
+
def _py_from_clause(code: str) -> re.Match[str] | None:
|
|
3085
|
+
"""The `from X import` clause (`_PY_FROM_CLAUSE_RE`) a Python statement
|
|
3086
|
+
line opens, its `#` comment cut: the line itself, or the one-line body
|
|
3087
|
+
after the first colon of a column-0 `try`, `except`, `else`, `if`,
|
|
3088
|
+
`elif`, `with` or `finally` header (`try: from X import L as NAME`)."""
|
|
3089
|
+
found = _PY_FROM_CLAUSE_RE.match(code)
|
|
3090
|
+
if found is None and _PY_BLOCK_HEADER_RE.match(code) and ":" in code:
|
|
3091
|
+
found = _PY_FROM_CLAUSE_RE.match(code.split(":", 1)[1])
|
|
3092
|
+
return found
|
|
3093
|
+
|
|
3094
|
+
|
|
3095
|
+
def _py_block_imports(lines: list[str], code: list[str], starts: set[int]) -> set[int]:
|
|
3096
|
+
"""The lines (from 1) on which a `from X import` statement starts one
|
|
3097
|
+
level under a column-0 `try`, `except`, `else`, `if`, `elif`, `with` or
|
|
3098
|
+
`finally` (a compatibility import), or as the one-line body of such a
|
|
3099
|
+
header, after its first colon (`try: from X import L as NAME`), never
|
|
3100
|
+
in a `def`, a `class` or deeper. `code` holds the lines with their `#`
|
|
3101
|
+
comments cut, `starts` the statement starts (`_py_statement_starts`)."""
|
|
3102
|
+
found: set[int] = set()
|
|
3103
|
+
header, level = False, None
|
|
3104
|
+
for number in sorted(starts):
|
|
3105
|
+
text = lines[number - 1]
|
|
3106
|
+
if _is_column_0(text):
|
|
3107
|
+
header, level = _PY_BLOCK_HEADER_RE.match(code[number - 1]) is not None, None
|
|
3108
|
+
if header and _py_from_clause(code[number - 1]) is not None:
|
|
3109
|
+
found.add(number) # the header's own one-line body
|
|
2837
3110
|
continue
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
3111
|
+
indent = len(text) - len(text.lstrip())
|
|
3112
|
+
if level is None:
|
|
3113
|
+
level = indent # the block's first statement sets its level
|
|
3114
|
+
if header and indent == level and _PY_FROM_CLAUSE_RE.match(code[number - 1]):
|
|
3115
|
+
found.add(number)
|
|
3116
|
+
return found
|
|
3117
|
+
|
|
3118
|
+
|
|
3119
|
+
def _py_from_clauses(
|
|
3120
|
+
code: list[str], starts: set[int], keep: Callable[[int], bool]
|
|
3121
|
+
) -> list[tuple[int, str, list[tuple[int, str]]]]:
|
|
3122
|
+
"""(line from 1, module, items) of each `from X import` statement that
|
|
3123
|
+
starts on a line `keep` accepts, an item as (line from 1, its text):
|
|
3124
|
+
on one line, in parentheses or continued with a backslash, a block
|
|
3125
|
+
header's one-line body included (`_py_from_clause`)."""
|
|
3126
|
+
clauses: list[tuple[int, str, list[tuple[int, str]]]] = []
|
|
3127
|
+
i, n = 0, len(code)
|
|
3128
|
+
while i < n:
|
|
3129
|
+
opened = _py_from_clause(code[i]) if i + 1 in starts and keep(i + 1) else None
|
|
3130
|
+
if opened is None:
|
|
3131
|
+
i += 1
|
|
3132
|
+
continue
|
|
3133
|
+
rest, items, j = opened.group(2).strip(), [], i
|
|
3134
|
+
paren = rest.startswith("(")
|
|
3135
|
+
if paren:
|
|
3136
|
+
rest = rest[1:]
|
|
3137
|
+
while True:
|
|
3138
|
+
if paren:
|
|
3139
|
+
done = ")" in rest
|
|
3140
|
+
piece = rest.split(")", 1)[0]
|
|
3141
|
+
else:
|
|
3142
|
+
piece = rest.rstrip()
|
|
3143
|
+
done = not piece.endswith("\\")
|
|
3144
|
+
piece = piece if done else piece[:-1]
|
|
3145
|
+
items += [(j + 1, t.strip()) for t in piece.split(",") if t.strip()]
|
|
3146
|
+
if done or j + 1 >= n:
|
|
3147
|
+
break
|
|
3148
|
+
j += 1
|
|
3149
|
+
rest = code[j]
|
|
3150
|
+
clauses.append((i + 1, opened.group(1), items))
|
|
3151
|
+
i = j + 1
|
|
3152
|
+
return clauses
|
|
3153
|
+
|
|
3154
|
+
|
|
3155
|
+
class _Renames:
|
|
3156
|
+
"""The renames one file makes, read once for the suspects' lookup (see
|
|
3157
|
+
`renames`), and the lines it adds to a name's declarations
|
|
3158
|
+
(`candidates`). `parts` is the file's path below the source root,
|
|
3159
|
+
`lines` its lines, `cache` the `_SourceCache` its rules ran with. A
|
|
3160
|
+
TS/JS line is read with its comments removed and an export list split
|
|
3161
|
+
with its strings blanked (`_js_export_lists`), a Python line with its
|
|
3162
|
+
`#` comment cut; a Python file that does not tokenize renames
|
|
3163
|
+
nothing."""
|
|
3164
|
+
|
|
3165
|
+
def __init__(self, parts: tuple[str, ...], lines: list[str], cache: _SourceCache) -> None:
|
|
3166
|
+
self.parts = parts
|
|
3167
|
+
self.lines = lines
|
|
3168
|
+
self.cache = cache
|
|
3169
|
+
self.text = "\n".join(lines)
|
|
3170
|
+
path = Path(*parts)
|
|
3171
|
+
self.python = _rule_family(parts[-1]) == "python"
|
|
3172
|
+
self.starts: set[int] | None = None
|
|
3173
|
+
if self.python:
|
|
3174
|
+
self.code = [line.split("#", 1)[0] for line in lines]
|
|
3175
|
+
self.starts = cache.py_statement_starts(path, lines)
|
|
3176
|
+
else:
|
|
3177
|
+
self.code = cache.js_code_lines(path, lines)
|
|
3178
|
+
self._clauses: list[tuple[int, str, list[tuple[int, str]]]] | None = None
|
|
3179
|
+
self._lists: list[tuple[list[tuple[int, str]], str | None]] | None = None
|
|
3180
|
+
self._bindings: list[_Binding] | None = None
|
|
3181
|
+
|
|
3182
|
+
# -- reading, once per file ---------------------------------------------
|
|
3183
|
+
|
|
3184
|
+
def _py_clauses(self) -> list[tuple[int, str, list[tuple[int, str]]]]:
|
|
3185
|
+
"""The `from X import` statements at column 0, one level in a
|
|
3186
|
+
column-0 block or a block header's one-line body
|
|
3187
|
+
(`_py_block_imports`)."""
|
|
3188
|
+
if self._clauses is None:
|
|
3189
|
+
if self.starts is None:
|
|
3190
|
+
self._clauses = []
|
|
3191
|
+
else:
|
|
3192
|
+
blocks = _py_block_imports(self.lines, self.code, self.starts)
|
|
3193
|
+
self._clauses = _py_from_clauses(
|
|
3194
|
+
self.code, self.starts,
|
|
3195
|
+
lambda n: _is_column_0(self.lines[n - 1]) or n in blocks,
|
|
3196
|
+
)
|
|
3197
|
+
return self._clauses
|
|
3198
|
+
|
|
3199
|
+
def _js_lists(self) -> list[tuple[list[tuple[int, str]], str | None]]:
|
|
3200
|
+
if self._lists is None:
|
|
3201
|
+
self._lists = _js_export_lists(self.lines, self.code)
|
|
3202
|
+
return self._lists
|
|
3203
|
+
|
|
3204
|
+
def _imports(self) -> list[_Binding]:
|
|
3205
|
+
"""The names the file's column-0 imports bind (`_Binding`); a
|
|
3206
|
+
compatibility import, one level in a block or a block header's
|
|
3207
|
+
one-line body, binds none."""
|
|
3208
|
+
if self._bindings is None:
|
|
3209
|
+
if not self.python:
|
|
3210
|
+
self._bindings = _js_import_bindings(self.lines, self.code)
|
|
3211
|
+
elif self.starts is None:
|
|
3212
|
+
self._bindings = []
|
|
3213
|
+
else:
|
|
3214
|
+
found: list[_Binding] = []
|
|
3215
|
+
for start, module, items in self._py_clauses():
|
|
3216
|
+
if not _is_column_0(self.lines[start - 1]) or (
|
|
3217
|
+
_PY_FROM_CLAUSE_RE.match(self.code[start - 1]) is None # `try: from ...`
|
|
3218
|
+
):
|
|
3219
|
+
continue
|
|
3220
|
+
for _, item in items:
|
|
3221
|
+
m = _PY_IMPORT_ITEM_RE.match(item)
|
|
3222
|
+
if m is not None and _PY_IDENT_RE.fullmatch(m.group(1)):
|
|
3223
|
+
found.append((start, m.group(2) or m.group(1), m.group(1), module))
|
|
3224
|
+
for number in sorted(self.starts):
|
|
3225
|
+
m = _PY_IMPORT_RE.match(self.code[number - 1])
|
|
3226
|
+
if m is None or not _is_column_0(self.lines[number - 1]):
|
|
3227
|
+
continue
|
|
3228
|
+
for item in m.group(1).split(","):
|
|
3229
|
+
bound = _PY_IMPORT_ITEM_RE.match(item.strip())
|
|
3230
|
+
if bound is not None: # `import a.b as c`, `import a`: a namespace
|
|
3231
|
+
local = bound.group(2) or bound.group(1).split(".", 1)[0]
|
|
3232
|
+
found.append((number, local, None, None))
|
|
3233
|
+
found.sort(key=lambda b: b[0])
|
|
3234
|
+
self._bindings = found
|
|
3235
|
+
return self._bindings
|
|
3236
|
+
|
|
3237
|
+
def binding(self, local: str, number: int) -> tuple[str | None, str | None] | None:
|
|
3238
|
+
"""(imported name, module) of the column-0 import that binds `local`
|
|
3239
|
+
for line `number` (from 1): the last one at or above it, else, in
|
|
3240
|
+
TS/JS, where imports are hoisted, the first below. The imported name
|
|
3241
|
+
is None for a namespace import (it binds no name). None when no
|
|
3242
|
+
import binds it."""
|
|
3243
|
+
above = below = None
|
|
3244
|
+
for line, name, imported, module in self._imports():
|
|
3245
|
+
if name != local:
|
|
3246
|
+
continue
|
|
3247
|
+
if line <= number:
|
|
3248
|
+
above = (imported, module)
|
|
3249
|
+
elif below is None:
|
|
3250
|
+
below = (imported, module)
|
|
3251
|
+
return above if above is not None or self.python else below
|
|
3252
|
+
|
|
3253
|
+
def _bound(self, local: str, number: int) -> tuple[str | None, str | None]:
|
|
3254
|
+
"""`local` as a rename's `L` with no `from`: the imported name and
|
|
3255
|
+
module of the import that binds it (no name for a namespace import),
|
|
3256
|
+
else `local` and no module."""
|
|
3257
|
+
return self.binding(local, number) or (local, None)
|
|
3258
|
+
|
|
3259
|
+
def _goes_on(self, index: int) -> bool:
|
|
3260
|
+
"""True when the TS/JS statement on line `index` (from 0) goes on
|
|
3261
|
+
with the next line that holds code."""
|
|
3262
|
+
for code in self.code[index + 1 :]:
|
|
3263
|
+
if code.strip():
|
|
3264
|
+
return code.lstrip()[0] in _JS_VALUE_GOES_ON
|
|
3265
|
+
return False
|
|
3266
|
+
|
|
3267
|
+
# -- the answers --------------------------------------------------------
|
|
3268
|
+
|
|
3269
|
+
def candidates(self, name: str, cache: _SourceCache, module_fallback: bool) -> list[int]:
|
|
3270
|
+
"""The lines (from 1, sorted) of the file the suspects' lookup reads
|
|
3271
|
+
for `name`: every column-0 line that declares it (`declared_line`'s
|
|
3272
|
+
rule; with `module_fallback`, line 1 of the module or package so
|
|
3273
|
+
named when none does), a Python `from X import L as NAME` item one
|
|
3274
|
+
level in a column-0 `try`, `except`, `else`, `if`, `elif`, `with`
|
|
3275
|
+
or `finally` block or as such a header's one-line body
|
|
3276
|
+
(`try: from X import L as NAME`), and a TS/JS local export-list
|
|
3277
|
+
item `NAME` without `as` when a column-0 import binds NAME to
|
|
3278
|
+
another name (`import { L as NAME }`, `import NAME from`)."""
|
|
3279
|
+
if not name or "." in name:
|
|
3280
|
+
return []
|
|
3281
|
+
path = Path(*self.parts)
|
|
3282
|
+
top, _ = _line_matches(path.suffix.lower(), path, self.lines, name, cache, True)
|
|
3283
|
+
found = set(top)
|
|
3284
|
+
if not found and module_fallback and _module_name(self.parts) == name:
|
|
3285
|
+
found.add(1)
|
|
3286
|
+
esc = re.escape(name)
|
|
3287
|
+
if self.python:
|
|
3288
|
+
if re.search(rf"(?<!\w)as\s+{esc}(?!\w)", self.text):
|
|
3289
|
+
found.update(
|
|
3290
|
+
line for _, _, items in self._py_clauses() for line, item in items
|
|
3291
|
+
if self._py_renames(item, name) is not None
|
|
3292
|
+
)
|
|
3293
|
+
elif re.search(rf"(?<![\w$])(?:as|import(?:\s+type)?)\s+{esc}(?![\w$])", self.text) and any(
|
|
3294
|
+
local == name and imported not in (None, name)
|
|
3295
|
+
for _, local, imported, _ in self._imports()
|
|
3296
|
+
):
|
|
3297
|
+
found.update(
|
|
3298
|
+
line for items, module in self._js_lists() if module is None
|
|
3299
|
+
for line, item in items if _js_export_sides(item) == (name, None)
|
|
3300
|
+
)
|
|
3301
|
+
return sorted(found)
|
|
3302
|
+
|
|
3303
|
+
@staticmethod
|
|
3304
|
+
def _py_renames(item: str, name: str) -> str | None:
|
|
3305
|
+
"""`L` of a `from X import` item `L as NAME`, L another identifier."""
|
|
3306
|
+
m = _PY_IMPORT_ITEM_RE.match(item)
|
|
3307
|
+
if m is None or m.group(2) != name or m.group(1) == name:
|
|
3308
|
+
return None
|
|
3309
|
+
return m.group(1) if _PY_IDENT_RE.fullmatch(m.group(1)) else None
|
|
3310
|
+
|
|
3311
|
+
def renames(self, number: int, name: str) -> list[tuple[str, str | None]]:
|
|
3312
|
+
"""(`L`, the module it comes from, None when unknown) for each
|
|
3313
|
+
rename to `name` on line `number` (from 1), `L` an identifier other
|
|
3314
|
+
than `name`:
|
|
3315
|
+
|
|
3316
|
+
- an export-list item `L as NAME` (`export { ... }`, `export type
|
|
3317
|
+
{ ... }`, the list's opening line at column 0 as written, a string
|
|
3318
|
+
side unquoted when the string is the whole side), `L` `default`
|
|
3319
|
+
included when the list has a `from`;
|
|
3320
|
+
- a local export-list item `NAME` without `as`, when a column-0
|
|
3321
|
+
import binds NAME to another name (`import { L as NAME }`, or
|
|
3322
|
+
`import NAME from`, `L` `default`);
|
|
3323
|
+
- a column-0 `const`, `let` or `var NAME = L`, annotated or not
|
|
3324
|
+
(`const NAME: <type> = L`, the type on the line, holding no `;`
|
|
3325
|
+
and no `=` but in `=>`), `type NAME = L` or a generic
|
|
3326
|
+
`type NAME<P1, ...> = L<P1, ...>` whose arguments are its bare
|
|
3327
|
+
parameters, the same names in the same order, exported or not,
|
|
3328
|
+
`L` a bare identifier and the statement ending there;
|
|
3329
|
+
- for `name` `default`, a column-0 `export default L`
|
|
3330
|
+
(`_JS_EXPORT_DEFAULT_IS_RE`, the `;` optional), `L` a bare
|
|
3331
|
+
identifier and the statement ending there: `L` taken from the
|
|
3332
|
+
import that binds it (`binding`) or, when the file declares `L`
|
|
3333
|
+
(`declared_line`, no module so named), from the file itself;
|
|
3334
|
+
- a Python `from X import L as NAME` item, the statement at column 0,
|
|
3335
|
+
one level in a column-0 `try`, `except`, `else`, `if`, `elif`,
|
|
3336
|
+
`with` or `finally` block or as such a header's one-line body
|
|
3337
|
+
(`try: from X import L as NAME`), and a column-0 `NAME = L` or
|
|
3338
|
+
`NAME: <type> = L` (the type holding no `;` or `=`), `L` a bare
|
|
3339
|
+
identifier.
|
|
3340
|
+
|
|
3341
|
+
The module is the list's or the clause's `from`; a rename with none
|
|
3342
|
+
takes the column-0 import that binds `L` (`binding`), `L` becoming
|
|
3343
|
+
its imported name, and a namespace import's name renames nothing.
|
|
3344
|
+
Empty for any other line: `as NAME` in a comment or a string, a TS
|
|
3345
|
+
type assertion (`const NAME = x as NAME`), a call or any other
|
|
3346
|
+
expression (`NAME = a.b` included: `member` reads it, as a step of
|
|
3347
|
+
a chain only), a default export of anything but a bare `L` an
|
|
3348
|
+
import or a declaration of the file binds (`export default make()`,
|
|
3349
|
+
an object, an arrow, a value that goes on to the next line, a
|
|
3350
|
+
namespace import's `L`), a generic alias whose parameters are
|
|
3351
|
+
constrained, defaulted, swapped or instantiated (`type NAME<T
|
|
3352
|
+
extends X> = L<T>`, `type NAME<K, V> = L<V, K>`, `type NAME =
|
|
3353
|
+
L<string>`), a Python `import M as NAME` and a rename inside a
|
|
3354
|
+
`def` or a `class`."""
|
|
3355
|
+
if not 0 < number <= len(self.lines):
|
|
3356
|
+
return []
|
|
3357
|
+
found: list[tuple[str | None, str | None]] = []
|
|
3358
|
+
index = number - 1
|
|
3359
|
+
if self.python:
|
|
3360
|
+
for _, module, items in self._py_clauses():
|
|
3361
|
+
for line, item in items:
|
|
3362
|
+
local = self._py_renames(item, name) if line == number else None
|
|
3363
|
+
if local is not None:
|
|
3364
|
+
found.append((local, module))
|
|
3365
|
+
starts = self.starts or set()
|
|
3366
|
+
if number in starts and _is_column_0(self.lines[index]):
|
|
3367
|
+
m = re.match(_PY_NAME_IS.format(name=re.escape(name)), self.code[index])
|
|
3368
|
+
if m is not None and m.group(1) != name:
|
|
3369
|
+
found.append(self._bound(m.group(1), number))
|
|
3370
|
+
return [(local, module) for local, module in found if local is not None]
|
|
3371
|
+
for items, module in self._js_lists():
|
|
3372
|
+
for line, item in items:
|
|
3373
|
+
sides = _js_export_sides(item) if line == number else None
|
|
3374
|
+
if sides is None:
|
|
3375
|
+
continue
|
|
3376
|
+
own, exposed = sides
|
|
3377
|
+
if exposed is None and own == name and module is None:
|
|
3378
|
+
bound = self.binding(name, number) # a plain re-export of an import
|
|
3379
|
+
if bound is not None and bound[0] != name:
|
|
3380
|
+
found.append(bound)
|
|
3381
|
+
elif exposed == name and own != name and _JS_IDENT_RE.fullmatch(own):
|
|
3382
|
+
if module is not None:
|
|
3383
|
+
found.append((own, module))
|
|
3384
|
+
elif own != "default":
|
|
3385
|
+
found.append(self._bound(own, number))
|
|
3386
|
+
m = _JS_NAME_IS_RE.match(self.code[index]) if _is_column_0(self.lines[index]) else None
|
|
3387
|
+
if (
|
|
3388
|
+
m is not None and (m["var"] or m["type"]) == name and m["value"] != name
|
|
3389
|
+
and (m["end"] or not self._goes_on(index))
|
|
3390
|
+
and _js_passes_parameters(m["params"], m["args"])
|
|
3391
|
+
):
|
|
3392
|
+
found.append(self._bound(m["value"], number))
|
|
3393
|
+
m = (
|
|
3394
|
+
_JS_EXPORT_DEFAULT_IS_RE.match(self.code[index])
|
|
3395
|
+
if name == "default" and _is_column_0(self.lines[index]) else None
|
|
3396
|
+
)
|
|
3397
|
+
if m is not None and m["value"] != name and (m["end"] or not self._goes_on(index)):
|
|
3398
|
+
bound = self.binding(m["value"], number) # a namespace import's (None, module) renames nothing
|
|
3399
|
+
if bound is not None:
|
|
3400
|
+
found.append(bound)
|
|
3401
|
+
elif declared_line(self.parts, m["value"], lines=self.lines, cache=self.cache,
|
|
3402
|
+
module_fallback=False) is not None:
|
|
3403
|
+
found.append((m["value"], None))
|
|
3404
|
+
return [(local, module) for local, module in found if local is not None]
|
|
3405
|
+
|
|
3406
|
+
def member(self, number: int, name: str) -> tuple[str, str] | None:
|
|
3407
|
+
"""(`L2`, its module) of a column-0 Python `NAME = M.L2` (or
|
|
3408
|
+
`NAME: <type> = M.L2`) on line `number` (from 1) whose `M` a
|
|
3409
|
+
column-0 `from X import M` binds: the module is `X` joined with
|
|
3410
|
+
`M`'s imported name, `.` prefixes kept (`from . import _core` gives
|
|
3411
|
+
`._core`). A step of a rename chain only
|
|
3412
|
+
(`_DeclarationLookup._aliases`): at the documented name's own line
|
|
3413
|
+
it renames nothing. None for any other line, an `M` an
|
|
3414
|
+
`import a.b as M` binds included."""
|
|
3415
|
+
if not self.python or not 0 < number <= len(self.lines):
|
|
3416
|
+
return None
|
|
3417
|
+
if number not in (self.starts or set()) or not _is_column_0(self.lines[number - 1]):
|
|
3418
|
+
return None
|
|
3419
|
+
m = re.match(_PY_NAME_IS_MEMBER.format(name=re.escape(name)), self.code[number - 1])
|
|
3420
|
+
bound = None if m is None else self.binding(m.group(1), number)
|
|
3421
|
+
if bound is None or bound[0] is None or bound[1] is None:
|
|
3422
|
+
return None
|
|
3423
|
+
imported, module = bound
|
|
3424
|
+
return m.group(2), (module if module.endswith(".") else module + ".") + imported
|
|
2841
3425
|
|
|
2842
3426
|
|
|
2843
3427
|
class _DeclarationLookup:
|
|
@@ -2857,15 +3441,29 @@ class _DeclarationLookup:
|
|
|
2857
3441
|
(`_is_package_root`) that hold no cited file, the files
|
|
2858
3442
|
`_lookup_skips_file` names and every symlink. Only the rule families
|
|
2859
3443
|
(Python, TS/JS) the entries cite are read (both when no entry cites a
|
|
2860
|
-
file). A cited file is read
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
3444
|
+
file). A cited file is read in any folder, a hidden one and one the walk
|
|
3445
|
+
skips included, but only inside the source root, with no symlink on its
|
|
3446
|
+
path and under a name `_lookup_skips_file` accepts (`_readable_below`).
|
|
3447
|
+
The walk runs once for all the names no cited file settles: each file is
|
|
3448
|
+
read once, and only a file whose text holds a name goes through the
|
|
3449
|
+
rules. In the suspects' pass (`keep_declaring`), a file's declarations
|
|
3450
|
+
are its candidate lines (`_Renames.candidates`: every column-0
|
|
3451
|
+
declaring line, a Python compatibility import one level in a column-0
|
|
3452
|
+
block or as a block header's one-line body, and a TS/JS re-export of a
|
|
3453
|
+
renaming import), the file listed at the first, and `renamed` then
|
|
3454
|
+
tells which of a name's candidate lines is an alias of the declaration
|
|
3455
|
+
a cited file holds, from the lines the lookup read, reading too the
|
|
3456
|
+
files a rename's `from` resolves to (`_resolve`) and, along a chain of
|
|
3457
|
+
renames (`_aliases`), the files each later step resolves to. The
|
|
3458
|
+
renames read include an annotated `const`, `let` or `var` or Python
|
|
3459
|
+
`NAME: <type> = L`, a generic `type NAME<P1, ...> = L<P1, ...>`, an
|
|
3460
|
+
import one level in a `with` or `finally` block or as a block header's
|
|
3461
|
+
one-line body, a column-0 `export default L` of an `L` an import or a
|
|
3462
|
+
declaration of the file binds, and a middle step `L = M.L2`; a chain
|
|
3463
|
+
that leaves the resolved files, loops or ends at another declaration,
|
|
3464
|
+
a constrained, defaulted, swapped or instantiated generic, a call and
|
|
3465
|
+
a default export of any other expression stay fabricated.
|
|
3466
|
+
Any error of the filesystem raises (`_LOOKUP_ERRORS`)."""
|
|
2869
3467
|
|
|
2870
3468
|
def __init__(self, entries: list[dict], source_root: Path) -> None:
|
|
2871
3469
|
self.root = source_root
|
|
@@ -2884,9 +3482,15 @@ class _DeclarationLookup:
|
|
|
2884
3482
|
# the cited files read, kept for the walk: a few files at most
|
|
2885
3483
|
self._cited: dict[tuple[str, ...], list[str] | None] = {}
|
|
2886
3484
|
self._cited_cache = _SourceCache()
|
|
2887
|
-
# during the suspects' pass only (`keep_declaring`): the
|
|
2888
|
-
# file a declaration was found in, by path,
|
|
2889
|
-
|
|
3485
|
+
# during the suspects' pass only (`keep_declaring`): the renames of
|
|
3486
|
+
# each file a declaration was found in, by path, and each (path, name)
|
|
3487
|
+
# its candidate lines, which `renamed` reads
|
|
3488
|
+
self._declaring: dict[str, _Renames] | None = None
|
|
3489
|
+
self._candidates: dict[tuple[str, str], list[int]] = {}
|
|
3490
|
+
# the files a rename's `from` resolves to and the renaming files, that no entry cites
|
|
3491
|
+
self._resolved: dict[tuple[str, ...], list[str] | None] = {}
|
|
3492
|
+
# the renames of each file a rename chain goes through (`_aliases`), by path
|
|
3493
|
+
self._chain: dict[tuple[str, ...], _Renames | None] = {}
|
|
2890
3494
|
|
|
2891
3495
|
# -- the walk root ------------------------------------------------------
|
|
2892
3496
|
|
|
@@ -2951,20 +3555,14 @@ class _DeclarationLookup:
|
|
|
2951
3555
|
|
|
2952
3556
|
# -- reading ------------------------------------------------------------
|
|
2953
3557
|
|
|
2954
|
-
def _below_walk_root(self, parts: tuple[str, ...]) -> tuple[str, ...]:
|
|
2955
|
-
depth = len(self.folder)
|
|
2956
|
-
return parts[depth:] if parts[:depth] == self.folder else parts
|
|
2957
|
-
|
|
2958
3558
|
def _reads_cited(self, parts: tuple[str, ...]) -> bool:
|
|
2959
3559
|
"""True when the lookup reads a cited file: one of a family the
|
|
2960
|
-
entries cite, in
|
|
2961
|
-
inside the source root
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
return False
|
|
2967
|
-
return _readable_below(self.root, parts, self.real_root)
|
|
3560
|
+
entries cite, in any folder (one the walk skips or a hidden one
|
|
3561
|
+
included), with no symlink on its path, inside the source root and
|
|
3562
|
+
under a name `_lookup_skips_file` accepts (`_readable_below`)."""
|
|
3563
|
+
return _rule_family(parts[-1]) in self.families and _readable_below(
|
|
3564
|
+
self.root, parts, self.real_root
|
|
3565
|
+
)
|
|
2968
3566
|
|
|
2969
3567
|
def _cited_lines(self, parts: tuple[str, ...]) -> list[str] | None:
|
|
2970
3568
|
if parts not in self._cited:
|
|
@@ -2973,6 +3571,74 @@ class _DeclarationLookup:
|
|
|
2973
3571
|
self._cited[parts] = _text_lines(_read_source(path)) if readable else None
|
|
2974
3572
|
return self._cited[parts]
|
|
2975
3573
|
|
|
3574
|
+
def _lines_of(self, parts: tuple[str, ...]) -> list[str] | None:
|
|
3575
|
+
"""The lines of a file the alias check reads, a cited one or one a
|
|
3576
|
+
rename's `from` resolves to, under `_reads_cited`'s guards, or the
|
|
3577
|
+
renaming file, whose lines `renamed` keeps; each file read once."""
|
|
3578
|
+
if parts in self._cited:
|
|
3579
|
+
return self._cited[parts]
|
|
3580
|
+
if parts not in self._resolved:
|
|
3581
|
+
readable = self._reads_cited(parts)
|
|
3582
|
+
path = self.root.joinpath(*parts)
|
|
3583
|
+
self._resolved[parts] = _text_lines(_read_source(path)) if readable else None
|
|
3584
|
+
return self._resolved[parts]
|
|
3585
|
+
|
|
3586
|
+
def _declares(self, parts: tuple[str, ...], local: str) -> bool:
|
|
3587
|
+
"""True when a file declares `local` by `declared_line`'s rule, no
|
|
3588
|
+
module or package so named counting."""
|
|
3589
|
+
lines = self._lines_of(parts)
|
|
3590
|
+
return lines is not None and declared_line(
|
|
3591
|
+
parts, local, lines=lines, cache=self._cited_cache, module_fallback=False
|
|
3592
|
+
) is not None
|
|
3593
|
+
|
|
3594
|
+
def _resolve(self, parts: tuple[str, ...], module: str) -> list[tuple[str, ...]]:
|
|
3595
|
+
"""The files below the source root that a rename's module names, from
|
|
3596
|
+
the renaming file `parts`, each one the lookup may read
|
|
3597
|
+
(`_reads_cited`). TS/JS: a `./` or `../` module only, from the file's
|
|
3598
|
+
folder: the path itself, a `.js`-family path's TypeScript twin
|
|
3599
|
+
(`./m.js` names `m.ts`), `<path><ext>` and `<path>/index<ext>`
|
|
3600
|
+
(`_TSJS_MODULE_EXTENSIONS`). Python: a relative module (`.m`, `..m`,
|
|
3601
|
+
`.` the folder's own `__init__`) from the file's folder, or an
|
|
3602
|
+
absolute one below the parent of the file's package root
|
|
3603
|
+
(`_package_root`): `m.py`, `m.pyi`, `m/__init__.py` and
|
|
3604
|
+
`m/__init__.pyi`. Empty for a bare package name, a tsconfig `paths`
|
|
3605
|
+
alias, a module that climbs out of the source root and one with no
|
|
3606
|
+
such file."""
|
|
3607
|
+
if _rule_family(parts[-1]) == "python":
|
|
3608
|
+
dots = len(module) - len(module.lstrip("."))
|
|
3609
|
+
names = module[dots:].split(".") if module[dots:] else []
|
|
3610
|
+
if any(not n for n in names):
|
|
3611
|
+
return []
|
|
3612
|
+
if dots:
|
|
3613
|
+
if dots > len(parts):
|
|
3614
|
+
return []
|
|
3615
|
+
base = parts[: len(parts) - dots]
|
|
3616
|
+
else:
|
|
3617
|
+
root = self._package_root(self._real_folder(parts[:-1]), "python")
|
|
3618
|
+
if root is None or not names:
|
|
3619
|
+
return []
|
|
3620
|
+
base = root[:-1]
|
|
3621
|
+
here = (*base, *names)
|
|
3622
|
+
tried = [(*here[:-1], here[-1] + ext) for ext in (".py", ".pyi")] if names else []
|
|
3623
|
+
tried += [(*here, "__init__.py"), (*here, "__init__.pyi")]
|
|
3624
|
+
else:
|
|
3625
|
+
if module not in (".", "..") and not module.startswith(("./", "../")):
|
|
3626
|
+
return [] # a package name or a `paths` alias: not read
|
|
3627
|
+
path = posixpath.normpath(posixpath.join(*parts[:-1], module))
|
|
3628
|
+
if path == ".." or path.startswith("../"):
|
|
3629
|
+
return []
|
|
3630
|
+
here = () if path == "." else tuple(path.split("/"))
|
|
3631
|
+
tried = []
|
|
3632
|
+
if here:
|
|
3633
|
+
stem, ext = posixpath.splitext(here[-1])
|
|
3634
|
+
if ext.lower() in TSJS_EXTENSIONS:
|
|
3635
|
+
tried.append(here)
|
|
3636
|
+
twins = _TSJS_TWINS.get(ext.lower(), ())
|
|
3637
|
+
tried += [(*here[:-1], stem + twin) for twin in twins]
|
|
3638
|
+
tried += [(*here[:-1], here[-1] + ext) for ext in _TSJS_MODULE_EXTENSIONS]
|
|
3639
|
+
tried += [(*here, "index" + ext) for ext in _TSJS_MODULE_EXTENSIONS]
|
|
3640
|
+
return [p for p in dict.fromkeys(tried) if self._reads_cited(p)]
|
|
3641
|
+
|
|
2976
3642
|
def _is_package_root(self, folder: tuple[str, ...], filenames: list[str]) -> bool:
|
|
2977
3643
|
"""True when a walked folder (its `filenames`) is a package root of a
|
|
2978
3644
|
family the walk reads, as `_package_root` finds one: TS/JS, a folder
|
|
@@ -3032,21 +3698,31 @@ class _DeclarationLookup:
|
|
|
3032
3698
|
) -> str | None:
|
|
3033
3699
|
"""`file:line` of the first line of a file that declares `name`, else,
|
|
3034
3700
|
with `module_fallback`, its line 1 when it is the module or package
|
|
3035
|
-
so named (`declared_line`). After `keep_declaring`, the
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
)
|
|
3040
|
-
if
|
|
3701
|
+
so named (`declared_line`). After `keep_declaring`, the first of the
|
|
3702
|
+
file's candidate lines (`_Renames.candidates`, which adds a Python
|
|
3703
|
+
compatibility import and a TS/JS re-export of a renaming import),
|
|
3704
|
+
each kept, with the file's renames, for `renamed`."""
|
|
3705
|
+
file = "/".join(parts)
|
|
3706
|
+
if self._declaring is None:
|
|
3707
|
+
line = declared_line(
|
|
3708
|
+
parts, name, lines=lines, cache=cache, module_fallback=module_fallback
|
|
3709
|
+
)
|
|
3710
|
+
return None if line is None else f"{file}:{line}"
|
|
3711
|
+
renames = self._declaring.get(file) or _Renames(parts, lines, cache)
|
|
3712
|
+
candidates = renames.candidates(name, cache, module_fallback)
|
|
3713
|
+
if not candidates:
|
|
3041
3714
|
return None
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
return f"{
|
|
3715
|
+
self._declaring[file] = renames
|
|
3716
|
+
self._candidates[(file, name)] = candidates
|
|
3717
|
+
return f"{file}:{candidates[0]}"
|
|
3045
3718
|
|
|
3046
3719
|
# -- the answer ---------------------------------------------------------
|
|
3047
3720
|
|
|
3048
3721
|
def find(
|
|
3049
|
-
self,
|
|
3722
|
+
self,
|
|
3723
|
+
cited: dict[str, list[str]],
|
|
3724
|
+
strict: frozenset[str] = frozenset(),
|
|
3725
|
+
cited_fallback: bool = True,
|
|
3050
3726
|
) -> tuple[dict[str, str], dict[str, list[str]]]:
|
|
3051
3727
|
"""For the names of `cited` (name -> the source_file values its
|
|
3052
3728
|
entries record): name -> `file:line` for each one the source
|
|
@@ -3055,7 +3731,10 @@ class _DeclarationLookup:
|
|
|
3055
3731
|
cited file settles). A dotted name is never looked up, and a name of
|
|
3056
3732
|
`strict` takes no line 1 from a module or package so named that is
|
|
3057
3733
|
not itself the export (`_exports_itself`), in a cited file or in
|
|
3058
|
-
the walk.
|
|
3734
|
+
the walk. Without `cited_fallback` (the suspects' pass), a cited
|
|
3735
|
+
file takes no line 1 from its module or package so named, the export
|
|
3736
|
+
or not, so that the walk runs and finds the renames; the walk keeps
|
|
3737
|
+
its module rule."""
|
|
3059
3738
|
found: dict[str, str] = {}
|
|
3060
3739
|
if not self.families:
|
|
3061
3740
|
return found, {}
|
|
@@ -3067,7 +3746,8 @@ class _DeclarationLookup:
|
|
|
3067
3746
|
lines = self._cited_lines(parts)
|
|
3068
3747
|
hit = None if lines is None else self._first(
|
|
3069
3748
|
parts, lines, name, self._cited_cache,
|
|
3070
|
-
|
|
3749
|
+
cited_fallback
|
|
3750
|
+
and (name not in strict or _exports_itself(parts, "\n".join(lines))),
|
|
3071
3751
|
)
|
|
3072
3752
|
if hit is not None:
|
|
3073
3753
|
found[name] = hit
|
|
@@ -3112,7 +3792,9 @@ class _DeclarationLookup:
|
|
|
3112
3792
|
continue
|
|
3113
3793
|
if lines is None:
|
|
3114
3794
|
lines = _text_lines(text)
|
|
3115
|
-
|
|
3795
|
+
# this file's rules only; a declaring file's `_Renames` keeps it
|
|
3796
|
+
# for `renamed`
|
|
3797
|
+
cache = _SourceCache()
|
|
3116
3798
|
path = Path(*parts)
|
|
3117
3799
|
if path.suffix.lower() in PYTHON_EXTENSIONS and (
|
|
3118
3800
|
cache.py_statement_starts(path, lines) is None
|
|
@@ -3125,33 +3807,107 @@ class _DeclarationLookup:
|
|
|
3125
3807
|
return {name: sorted(_without_stubs(h)) for name, h in hits.items()}, unsure
|
|
3126
3808
|
|
|
3127
3809
|
def keep_declaring(self) -> None:
|
|
3128
|
-
"""From now on, keep the lines of each file
|
|
3129
|
-
for `renamed`: the suspects' pass, which
|
|
3810
|
+
"""From now on, keep the candidate lines and the renames of each file
|
|
3811
|
+
a declaration is found in, for `renamed`: the suspects' pass, which
|
|
3812
|
+
comes last."""
|
|
3130
3813
|
self._declaring = {}
|
|
3814
|
+
self._candidates = {}
|
|
3815
|
+
|
|
3816
|
+
def _renames_of(self, parts: tuple[str, ...]) -> _Renames | None:
|
|
3817
|
+
"""The renames of a file a rename chain goes through, read once
|
|
3818
|
+
(`_lines_of`); None when the lookup does not read it."""
|
|
3819
|
+
if parts not in self._chain:
|
|
3820
|
+
lines = self._lines_of(parts)
|
|
3821
|
+
self._chain[parts] = None if lines is None else _Renames(parts, lines, self._cited_cache)
|
|
3822
|
+
return self._chain[parts]
|
|
3823
|
+
|
|
3824
|
+
def _steps(self, parts: tuple[str, ...], local: str) -> list[tuple[str, str | None]]:
|
|
3825
|
+
"""The renames of `local` a file a rename chain goes through makes,
|
|
3826
|
+
each a next step (`L2`, its module, None when unknown): per
|
|
3827
|
+
candidate line of `local` in order (`_Renames.candidates`), each
|
|
3828
|
+
rename the line makes (`_Renames.renames`) and a Python
|
|
3829
|
+
`local = M.L2` (`_Renames.member`). Empty when the file renames no
|
|
3830
|
+
`local` or the lookup does not read it."""
|
|
3831
|
+
renames = self._renames_of(parts)
|
|
3832
|
+
steps: list[tuple[str, str | None]] = []
|
|
3833
|
+
for line in [] if renames is None else renames.candidates(local, self._cited_cache, False):
|
|
3834
|
+
steps.extend(renames.renames(line, local))
|
|
3835
|
+
member = renames.member(line, local)
|
|
3836
|
+
if member is not None:
|
|
3837
|
+
steps.append(member)
|
|
3838
|
+
return steps
|
|
3839
|
+
|
|
3840
|
+
def _aliases(
|
|
3841
|
+
self, parts: tuple[str, ...], local: str, module: str | None,
|
|
3842
|
+
cited: list[tuple[str, ...]], seen: set[tuple[tuple[str, ...], str]],
|
|
3843
|
+
first: bool = True,
|
|
3844
|
+
) -> str | None:
|
|
3845
|
+
"""The name a cited file declares when a rename in the file `parts`
|
|
3846
|
+
of `local` from `module` (None when unknown) renames that
|
|
3847
|
+
declaration, directly (`local` itself) or through a chain of
|
|
3848
|
+
renames (the name at the chain's end); None when it does not. The
|
|
3849
|
+
files `local` resolves to are the file `parts` when `module` is
|
|
3850
|
+
None, else the files the module resolves to (`_resolve`). When one
|
|
3851
|
+
of them that declares `local` is cited, the rename is an alias.
|
|
3852
|
+
Otherwise the chain follows each of them that declares `local` or
|
|
3853
|
+
renames it (`_steps`: an export-list alias, a compatibility import
|
|
3854
|
+
in a block or on one line, an import then a plain re-export, an
|
|
3855
|
+
assignment of a bare `L`, annotated or not, an `export default L`
|
|
3856
|
+
of a bound `L`, or a Python `local = M.L2`): each rename is the
|
|
3857
|
+
next step, until a step reaches a cited file that declares its
|
|
3858
|
+
name, or none does. When no file declares `local` (a package name,
|
|
3859
|
+
a path that names no file, a file that declares no `local`, such as
|
|
3860
|
+
a barrel that re-exports it), a cited file that declares `local`
|
|
3861
|
+
clears the name at the `first` rename only; at a later step that
|
|
3862
|
+
case ends the chain unless a resolved file renames `local`. `seen`
|
|
3863
|
+
holds the (file, name) pairs the chain followed, each a resolved
|
|
3864
|
+
file that declares or renames the name, fresh for each first
|
|
3865
|
+
rename: a chain that comes back to one (a loop) ends. No module or
|
|
3866
|
+
package so named counts."""
|
|
3867
|
+
resolved = [parts] if module is None else self._resolve(parts, module) if module else []
|
|
3868
|
+
declaring = [p for p in resolved if self._declares(p, local)]
|
|
3869
|
+
if not declaring and first and any(self._declares(p, local) for p in cited):
|
|
3870
|
+
return local
|
|
3871
|
+
if any(p in cited for p in declaring):
|
|
3872
|
+
return local
|
|
3873
|
+
for file in resolved:
|
|
3874
|
+
if (file, local) in seen:
|
|
3875
|
+
continue
|
|
3876
|
+
seen.add((file, local))
|
|
3877
|
+
for name, source in self._steps(file, local):
|
|
3878
|
+
end = self._aliases(file, name, source, cited, seen, first=False)
|
|
3879
|
+
if end is not None:
|
|
3880
|
+
return end
|
|
3881
|
+
return None
|
|
3131
3882
|
|
|
3132
|
-
def renamed(
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3883
|
+
def renamed(
|
|
3884
|
+
self, name: str, hits: list[str], files: list[str]
|
|
3885
|
+
) -> tuple[str, str] | None:
|
|
3886
|
+
"""(`file:line`, `L`) of the first rename to `name` that is an alias
|
|
3887
|
+
of the declaration of `L` a file of `files` (source_file values, as
|
|
3888
|
+
recorded) holds (`_aliases`), directly or through a chain of renames
|
|
3889
|
+
across the files it resolves: per file of `hits` (`file:line`
|
|
3890
|
+
declarations of `name` the lookup found, sorted), per candidate
|
|
3891
|
+
line in order (`_Renames.candidates`), each rename the line makes
|
|
3892
|
+
(`_Renames.renames`). `L` is the name the cited file declares where
|
|
3893
|
+
the chain ends: for a one-step rename, the name that rename
|
|
3894
|
+
renames. The lines are the ones the lookup read after
|
|
3895
|
+
`keep_declaring` (`_first`), never read again; a file a rename's
|
|
3896
|
+
module resolves to is read once. None when no line is such an
|
|
3897
|
+
alias."""
|
|
3141
3898
|
cited = list(dict.fromkeys(filter(None, map(_cited_parts, files))))
|
|
3142
3899
|
for hit in sorted(hits):
|
|
3143
3900
|
file, _, number = hit.rpartition(":")
|
|
3144
|
-
|
|
3145
|
-
if
|
|
3901
|
+
renames = (self._declaring or {}).get(file)
|
|
3902
|
+
if renames is None or not number.isdigit():
|
|
3146
3903
|
continue
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
return hit
|
|
3904
|
+
if renames.parts not in self._cited: # read already, and `_aliases` may check it
|
|
3905
|
+
self._resolved.setdefault(renames.parts, renames.lines)
|
|
3906
|
+
for line in self._candidates.get((file, name), [int(number)]):
|
|
3907
|
+
for local, module in renames.renames(line, name):
|
|
3908
|
+
end = self._aliases(renames.parts, local, module, cited, set())
|
|
3909
|
+
if end is not None:
|
|
3910
|
+
return f"{file}:{line}", end
|
|
3155
3911
|
return None
|
|
3156
3912
|
|
|
3157
3913
|
|
|
@@ -3193,16 +3949,31 @@ def classify_stale(
|
|
|
3193
3949
|
(`defined_at`, and `declared_in` when the walk looked for it). A name
|
|
3194
3950
|
the cited files do not define is looked up too, after the others and
|
|
3195
3951
|
in a lookup of its own (an error there keeps it fabricated and clears
|
|
3196
|
-
no other name), with no module or package so named
|
|
3197
|
-
is the export, and is `aliased`, not
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3952
|
+
no other name), with no line 1 from a cited module or package so named,
|
|
3953
|
+
and none in the walk unless it is the export, and is `aliased`, not
|
|
3954
|
+
fabricated, when one of its candidate lines renames to it the
|
|
3955
|
+
declaration a `checked` entry's file holds, directly or through a
|
|
3956
|
+
chain of renames across the files it resolves
|
|
3957
|
+
(`_DeclarationLookup.renamed`, which resolves the rename's `from` and
|
|
3958
|
+
follows the chain until it reaches a cited declaration, leaves the
|
|
3959
|
+
files it reads, loops or ends at another declaration), and its
|
|
3960
|
+
`renames` then holds the name the cited file declares where the chain
|
|
3961
|
+
ends, for a one-step rename the name that line renames (no other item
|
|
3962
|
+
has the key); any other such name keeps both null. The renames read
|
|
3963
|
+
include an annotated `const`, `let` or `var` or Python
|
|
3964
|
+
`NAME: <type> = L`, a generic `type NAME<P1, ...> = L<P1, ...>`, an
|
|
3965
|
+
import one level in a `with` or `finally` block or as a block header's
|
|
3966
|
+
one-line body, a column-0 `export default L` of an `L` an import or a
|
|
3967
|
+
declaration of the file binds, and a middle step `L = M.L2`; a chain
|
|
3968
|
+
that leaves the resolved files, loops or ends at another declaration,
|
|
3969
|
+
a constrained, defaulted, swapped or instantiated generic, a call and
|
|
3970
|
+
a default export of any other expression stay fabricated.
|
|
3971
|
+
The classify-stale section of the module docstring gives each reason,
|
|
3972
|
+
what a `no-file` entry means, which entry gives `source` and how
|
|
3973
|
+
`defined_at` and `declared_in` are found. `kinds` (`inventory_kinds`,
|
|
3974
|
+
None without `--inventory`) keeps a name of a `NO_MODULE_KINDS` kind
|
|
3975
|
+
from a module or package so named, unless an entry of the name is a
|
|
3976
|
+
`module` or `package` one.
|
|
3206
3977
|
"""
|
|
3207
3978
|
entries = [e for e in prov.get("entries") or [] if isinstance(e, dict)]
|
|
3208
3979
|
results: list[dict] = []
|
|
@@ -3271,30 +4042,33 @@ def classify_stale(
|
|
|
3271
4042
|
found, declared = {}, {}
|
|
3272
4043
|
# the suspects' own lookup, after: an error there clears no name settled
|
|
3273
4044
|
# above, and keeps every suspect fabricated
|
|
3274
|
-
aliased: dict[str, str] = {}
|
|
4045
|
+
aliased: dict[str, tuple[str, str]] = {} # name -> (the rename's `file:line`, the chain-end `L`)
|
|
3275
4046
|
suspect_declared: dict[str, list[str]] = {}
|
|
3276
4047
|
if suspects and lookup is not None:
|
|
3277
4048
|
try:
|
|
3278
|
-
# a suspect is strict
|
|
3279
|
-
# the walk
|
|
4049
|
+
# a suspect is strict, and a cited module so named never settles
|
|
4050
|
+
# it before the walk, which finds the renames
|
|
3280
4051
|
lookup.keep_declaring()
|
|
3281
|
-
suspect_found, suspect_declared = lookup.find(
|
|
4052
|
+
suspect_found, suspect_declared = lookup.find(
|
|
4053
|
+
suspects, frozenset(suspects), cited_fallback=False
|
|
4054
|
+
)
|
|
3282
4055
|
for name, files in suspects.items():
|
|
3283
4056
|
hits = suspect_declared[name] if name in suspect_declared else (
|
|
3284
4057
|
[suspect_found[name]] if name in suspect_found else []
|
|
3285
4058
|
)
|
|
3286
|
-
|
|
3287
|
-
if
|
|
3288
|
-
aliased[name] =
|
|
4059
|
+
alias = lookup.renamed(name, hits, files)
|
|
4060
|
+
if alias is not None:
|
|
4061
|
+
aliased[name] = alias
|
|
3289
4062
|
except _LOOKUP_ERRORS:
|
|
3290
4063
|
aliased, suspect_declared = {}, {}
|
|
3291
4064
|
for item in results:
|
|
3292
4065
|
name = item["name"]
|
|
3293
4066
|
if name in suspects:
|
|
3294
4067
|
if name in aliased:
|
|
4068
|
+
defined_at, local = aliased[name]
|
|
3295
4069
|
item.update(
|
|
3296
4070
|
fabricated=False, reason=STALE_ALIASED,
|
|
3297
|
-
defined_at=
|
|
4071
|
+
defined_at=defined_at, declared_in=suspect_declared.get(name), renames=local,
|
|
3298
4072
|
)
|
|
3299
4073
|
continue # any other suspect stays fabricated, both null
|
|
3300
4074
|
item["defined_at"] = found.get(name)
|