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.
Files changed (82) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/docs/_data/pinned.yaml +9 -10
  4. package/docs/campaign.md +2 -2
  5. package/docs/skill-model.md +3 -3
  6. package/docs/verifying-a-skill.md +17 -18
  7. package/docs/workflows.md +10 -10
  8. package/package.json +1 -1
  9. package/src/shared/references/description-guard-protocol.md +12 -10
  10. package/src/shared/references/output-contract-schema.md +1 -1
  11. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +1 -1
  12. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +1 -1
  13. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +38 -0
  14. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +5 -0
  15. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +1 -1
  16. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +1 -1
  17. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +8 -3
  18. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +1 -1
  19. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +6 -1
  20. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  21. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +7 -2
  22. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +1 -1
  23. package/src/shared/scripts/skf-parse-gaps.py +4 -1
  24. package/src/shared/scripts/skf-quick-batch.py +24 -7
  25. package/src/shared/scripts/skf-verify-provenance-completeness.py +809 -85
  26. package/src/skf-analyze-source/customize.toml +3 -2
  27. package/src/skf-analyze-source/references/auto-docs-only.md +2 -2
  28. package/src/skf-analyze-source/references/generate-briefs.md +5 -3
  29. package/src/skf-analyze-source/references/headless-contract.md +2 -2
  30. package/src/skf-analyze-source/references/step-auto-scope.md +2 -2
  31. package/src/skf-audit-skill/customize.toml +4 -3
  32. package/src/skf-audit-skill/references/report.md +1 -1
  33. package/src/skf-campaign/assets/campaign-state-schema.json +58 -0
  34. package/src/skf-campaign/references/campaign-contracts.md +1 -1
  35. package/src/skf-campaign/references/step-06-batch.md +2 -2
  36. package/src/skf-campaign/references/step-07-capstone.md +3 -3
  37. package/src/skf-campaign/references/step-08-verify.md +3 -3
  38. package/src/skf-campaign/references/step-09-refine.md +3 -3
  39. package/src/skf-campaign/references/step-11-maintenance.md +1 -1
  40. package/src/skf-campaign/scripts/campaign-render-batch.py +43 -10
  41. package/src/skf-campaign/scripts/campaign-report.py +60 -4
  42. package/src/skf-campaign/scripts/campaign-state.py +82 -17
  43. package/src/skf-campaign/templates/campaign-report-template.md +4 -0
  44. package/src/skf-create-skill/SKILL.md +2 -2
  45. package/src/skf-create-skill/assets/skill-sections.md +2 -2
  46. package/src/skf-create-skill/customize.toml +4 -4
  47. package/src/skf-create-skill/references/batch-mode.md +3 -1
  48. package/src/skf-create-skill/references/report.md +9 -4
  49. package/src/skf-create-skill/references/validate.md +49 -29
  50. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  51. package/src/skf-create-stack-skill/references/report.md +3 -3
  52. package/src/skf-create-stack-skill/references/validate.md +21 -8
  53. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  54. package/src/skf-export-skill/references/summary.md +1 -1
  55. package/src/skf-quick-skill/SKILL.md +1 -1
  56. package/src/skf-quick-skill/references/batch-mode.md +30 -2
  57. package/src/skf-quick-skill/references/compile.md +2 -0
  58. package/src/skf-quick-skill/references/finalize.md +3 -2
  59. package/src/skf-quick-skill/references/halt-contract.md +3 -3
  60. package/src/skf-quick-skill/references/quick-extract.md +2 -1
  61. package/src/skf-quick-skill/references/write-and-validate.md +27 -11
  62. package/src/skf-refine-architecture/customize.toml +1 -1
  63. package/src/skf-refine-architecture/references/report.md +2 -2
  64. package/src/skf-rename-skill/references/report.md +1 -1
  65. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  66. package/src/skf-test-skill/references/coverage-check.md +4 -4
  67. package/src/skf-test-skill/references/inventory-contract.md +1 -1
  68. package/src/skf-test-skill/references/invocation-contract.md +1 -1
  69. package/src/skf-test-skill/references/report.md +1 -1
  70. package/src/skf-test-skill/references/scoring-rules.md +2 -2
  71. package/src/skf-test-skill/references/signature-comparison-rules.md +7 -2
  72. package/src/skf-test-skill/scripts/gap-ledger.py +196 -46
  73. package/src/skf-test-skill/scripts/load-coverage-inputs.py +253 -17
  74. package/src/skf-test-skill/scripts/score-signatures.py +9 -13
  75. package/src/skf-test-skill/scripts/validate-inventory.py +91 -12
  76. package/src/skf-update-skill/customize.toml +1 -1
  77. package/src/skf-update-skill/references/gap-driven.md +1 -1
  78. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  79. package/src/skf-update-skill/references/merge.md +1 -1
  80. package/src/skf-update-skill/references/report.md +27 -9
  81. package/src/skf-update-skill/references/write.md +24 -12
  82. 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" | "defined" | "unchecked" | "no-entry",
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. Otherwise the reason is `defined` (a cited file
473
- defines the name), `unchecked` (no rule could check a cited file, and
474
- none defines it) or `no-entry` (the map has no entry for the name).
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 is not looked up (null). A declaration is a line at
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`), or a
492
- `module.exports` key with a value or a method. A plain or star import,
562
+ `export { Y as NAME }`, `export * as NAME from`), a
563
+ `module.exports` key with a value or a method, or, for the name
564
+ `default`, any `export default` line. A plain or star import,
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. A module or package named
502
- NAME (`NAME.py`, `NAME/__init__.py`, a TS/JS `NAME.<ext>` or
503
- `NAME/index.<ext>`) declares NAME too: at its line 1 when no line of it
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). The walk reads, sorted by path, every file below the
528
- walk root: the deepest folder that the files of the map's entries
529
- share, widened to its package root (Python: the nearest folder at or
530
- above it that holds `__init__.py`, then up while the parent holds
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), `docs`, `examples` and `benchmarks`, each judged on the
542
- path below the walk root, the end-to-end test folders `e2e` and
543
- `__fixtures__` wherever they lie, and, as the first folder below the
544
- walk root or below a package root inside it (a folder that holds
545
- `package.json`, the top of an `__init__.py` chain), `example`, `demo`,
546
- `demos`, `sample`, `samples`, `scripts`, `playground`, `bench`,
547
- `benchmark` and `migrations` unless it holds a file an entry cites
548
- (deeper in a package such a folder can be package code). A cited file
549
- is read only inside the source root (a path that is absolute or climbs
550
- out of it is not read), with no symlink on its path and under the same
551
- skips, those walk-only ones aside. The path is relative to the source
552
- root. A dotted name, or a map whose entries cite files
626
+ the like), each judged on the path below the walk root, the
627
+ end-to-end test folders `e2e` and `__fixtures__` wherever they lie,
628
+ and, as the first folder below the walk root or below a package root
629
+ inside it (a folder that holds `package.json`, the top of an
630
+ `__init__.py` chain), `example`, `demo`, `demos`, `sample`, `samples`,
631
+ `scripts`, `playground`, `bench`, `benchmark`, `migrations`, `docs`,
632
+ `examples` and `benchmarks` unless it holds a file an entry cites
633
+ (deeper in a package such a folder can be package code, as Storybook's
634
+ `code/addons/docs/`). A cited file is read in any folder, a hidden one
635
+ and one the walk skips included, but only inside the source root (a
636
+ path that is absolute or climbs out of it is not read), with no
637
+ symlink on its path and under a name the walk reads (no test file,
638
+ test configuration file or `*.min.js`). The path is relative to the
639
+ source root. A dotted name, or a map whose entries cite files
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), and a filesystem error
555
- during the lookup gives every name null in both (the exit code stays
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. `declarators` splits a declarator list
1901
- (`_js_declarators`, or `_SourceCache.js_declarators`, which splits each
1990
+ dotted export name. For the undotted name `default`, the name SKF's
1991
+ extraction gives a module's default export, a column-0 `export default`
1992
+ line counts whatever follows it, with `declarations_only` too, and
1993
+ whatever block holds it; an indented one (as in a formatted
1994
+ `declare module` block) does not. `declarators` splits
1995
+ a declarator list (`_js_declarators`, or `_SourceCache.js_declarators`, which splits each
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, docs, examples and benchmarks.
2523
+ # installed output, a vendored copy of another package, and tests and their
2524
+ # configuration. The folders are the walk's alone: a file the map cites
2525
+ # is read in any of them, a test file, its configuration or a minified
2526
+ # bundle never.
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 | PY_NON_CORE_TOPS
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 and
2471
- # migrations, which hold code that uses or changes the package rather than
2472
- # its API. Deeper in a package such a folder can be live package code
2473
- # (cognee's `cognee/modules/migrations/`), so it is walked there; one that
2474
- # holds a file the map cites is walked too, and that file is still read.
2572
+ # examples and demos, samples, scripts, playgrounds, benchmarks, migrations
2573
+ # and docs (`PY_NON_CORE_TOPS`, which skf-extract-public-api.py also leaves
2574
+ # out only as a top folder), which hold code that uses, changes or documents
2575
+ # the package rather than its API. Deeper in a package such a folder can be
2576
+ # live package code (cognee's `cognee/modules/migrations/`, Storybook's
2577
+ # `code/addons/docs/`), so it is walked there; one that holds a file the map
2578
+ # cites is walked too, and that file is still read.
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), else, with `module_fallback`, its
2721
+ import-valued binding never count, except that for the name `default`
2722
+ a column-0 `export default` line counts whatever follows it, an import
2723
+ or an imported local included), else, with `module_fallback`, its
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 inside the source root only, with no symlink
2766
- on its path and the same skips but the walk-only ones, judged below the
2767
- walk root when it lies there. The walk runs once for all the names no
2768
- cited file settles: each file is read once, and only a file whose text
2769
- holds a name goes through the rules. Any error of the filesystem raises
2770
- (`_LOOKUP_ERRORS`)."""
3296
+ file). A cited file is read in any folder, a hidden one and one the walk
3297
+ skips included, but only inside the source root, with no symlink on its
3298
+ path and under a name `_lookup_skips_file` accepts (`_readable_below`).
3299
+ The walk runs once for all the names no cited file settles: each file is
3300
+ read once, and only a file whose text holds a name goes through the
3301
+ rules. In the suspects' pass (`keep_declaring`), a file's declarations
3302
+ are its candidate lines (`_Renames.candidates`: every column-0
3303
+ declaring line, a Python compatibility import one level in a column-0
3304
+ block and a TS/JS re-export of a renaming import), the file listed at
3305
+ the first, and `renamed` then tells which of a name's candidate lines
3306
+ is an alias of the declaration a cited file holds, from the lines the
3307
+ lookup read, reading too the files a rename's `from` resolves to
3308
+ (`_resolve`). Any error of the filesystem raises (`_LOOKUP_ERRORS`)."""
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 no skipped place, with no symlink on its path and
2860
- inside the source root."""
2861
- if _rule_family(parts[-1]) not in self.families:
2862
- return False
2863
- below = self._below_walk_root(parts)
2864
- if any(_lookup_skips_folder(s) for s in below[:-1]):
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
- line = declared_line(
2936
- parts, name, lines=lines, cache=cache, module_fallback=module_fallback
2937
- )
2938
- return f"{'/'.join(parts)}:{line}" if line is not None else None
3541
+ so named (`declared_line`). After `keep_declaring`, the first of the
3542
+ file's candidate lines (`_Renames.candidates`, which adds a Python
3543
+ compatibility import and a TS/JS re-export of a renaming import),
3544
+ each kept, with the file's renames, for `renamed`."""
3545
+ file = "/".join(parts)
3546
+ if self._declaring is None:
3547
+ line = declared_line(
3548
+ parts, name, lines=lines, cache=cache, module_fallback=module_fallback
3549
+ )
3550
+ return None if line is None else f"{file}:{line}"
3551
+ renames = self._declaring.get(file) or _Renames(parts, lines, cache)
3552
+ candidates = renames.candidates(name, cache, module_fallback)
3553
+ if not candidates:
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, cited: dict[str, list[str]], strict: frozenset[str] = frozenset()
3562
+ self,
3563
+ cited: dict[str, list[str]],
3564
+ strict: frozenset[str] = frozenset(),
3565
+ cited_fallback: bool = True,
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
- name not in strict or _exports_itself(parts, "\n".join(lines)),
3589
+ cited_fallback
3590
+ and (name not in strict or _exports_itself(parts, "\n".join(lines))),
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). The
3058
- classify-stale section of the module docstring gives each reason, what
3059
- a `no-file` entry means, which entry gives `source` and how
3060
- `defined_at` and `declared_in` are found. `kinds` (`inventory_kinds`,
3061
- None without `--inventory`) keeps a name of a `NO_MODULE_KINDS` kind
3062
- from a module or package so named, unless an entry of the name is a
3063
- `module` or `package` one.
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 (never a fabricated one): name -> its cited files
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 not fabricated:
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
- found, declared = _DeclarationLookup(entries, source_root).find(
3118
- cited, frozenset(strict)
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
- item["defined_at"] = found.get(item["name"])
3125
- item["declared_in"] = declared.get(item["name"])
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