bmad-module-skill-forge 4.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +3 -3
- package/docs/_data/pinned.yaml +19 -13
- package/docs/agents.md +1 -1
- package/docs/architecture.md +5 -5
- package/docs/campaign.md +1 -1
- package/docs/concepts.md +4 -4
- package/docs/examples.md +32 -25
- package/docs/getting-started.md +3 -3
- package/docs/how-it-works.md +2 -2
- package/docs/index.md +1 -1
- package/docs/skill-model.md +41 -39
- package/docs/troubleshooting.md +9 -3
- package/docs/verifying-a-skill.md +42 -21
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +6 -6
- package/package.json +1 -1
- package/src/knowledge/tool-resolution.md +6 -6
- package/src/shared/references/headless-gate-convention.md +4 -1
- package/src/shared/references/output-contract-schema.md +3 -3
- package/src/shared/references/pipeline-contracts.md +2 -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 +11 -3
- package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
- package/src/shared/scripts/skf-build-change-manifest.py +1180 -189
- package/src/shared/scripts/skf-classify-changed-files.py +15 -10
- package/src/shared/scripts/skf-count-tokens.py +54 -10
- package/src/shared/scripts/skf-detect-workspaces.py +67 -0
- package/src/shared/scripts/skf-emit-result-envelope.py +64 -20
- package/src/shared/scripts/skf-extract-public-api.py +472 -120
- package/src/shared/scripts/skf-extraction-inventory.py +10 -6
- package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
- package/src/shared/scripts/skf-parse-gaps.py +136 -26
- package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
- package/src/shared/scripts/skf-resolve-customization.py +197 -0
- package/src/shared/scripts/skf-shard-body.py +239 -41
- package/src/shared/scripts/skf-validate-output.py +12 -3
- package/src/shared/scripts/skf-verify-provenance-completeness.py +574 -45
- package/src/shared/tool-requirements.yaml +8 -2
- package/src/skf-analyze-source/SKILL.md +3 -2
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/references/generate-briefs.md +1 -1
- package/src/skf-analyze-source/references/headless-contract.md +1 -1
- package/src/skf-audit-skill/SKILL.md +4 -3
- package/src/skf-audit-skill/references/re-index.md +2 -2
- package/src/skf-audit-skill/references/report.md +1 -1
- package/src/skf-brief-skill/SKILL.md +3 -2
- package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
- package/src/skf-brief-skill/references/invocation-contract.md +2 -2
- package/src/skf-brief-skill/references/scope-definition.md +4 -4
- package/src/skf-campaign/SKILL.md +3 -2
- package/src/skf-campaign/references/campaign-contracts.md +2 -0
- package/src/skf-campaign/references/step-05-skill-loop.md +8 -3
- package/src/skf-campaign/references/step-10-export.md +3 -2
- package/src/skf-create-skill/SKILL.md +4 -3
- package/src/skf-create-skill/assets/skill-sections.md +1 -1
- package/src/skf-create-skill/references/ccc-index-check.md +1 -1
- package/src/skf-create-skill/references/component-extraction.md +1 -1
- package/src/skf-create-skill/references/extract.md +3 -2
- package/src/skf-create-skill/references/extraction-patterns.md +3 -3
- package/src/skf-create-skill/references/generate-artifacts.md +1 -1
- package/src/skf-create-skill/references/report.md +8 -3
- package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
- package/src/skf-create-skill/references/validate.md +1 -1
- package/src/skf-create-stack-skill/SKILL.md +3 -2
- package/src/skf-create-stack-skill/references/generate-output.md +1 -1
- package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
- package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
- package/src/skf-create-stack-skill/references/report.md +1 -1
- package/src/skf-create-stack-skill/references/validate.md +1 -1
- package/src/skf-drop-skill/SKILL.md +3 -2
- package/src/skf-drop-skill/references/invocation-contract.md +1 -1
- package/src/skf-drop-skill/references/report.md +1 -1
- package/src/skf-export-skill/SKILL.md +3 -2
- package/src/skf-export-skill/assets/snippet-format.md +5 -5
- package/src/skf-export-skill/references/generate-snippet.md +22 -15
- package/src/skf-export-skill/references/invocation-contract.md +1 -1
- package/src/skf-export-skill/references/load-skill.md +1 -1
- package/src/skf-export-skill/references/summary.md +1 -1
- package/src/skf-export-skill/references/token-report.md +29 -9
- package/src/skf-forger/SKILL.md +3 -2
- package/src/skf-forger/references/pipeline-mode.md +2 -2
- package/src/skf-quick-skill/SKILL.md +4 -3
- package/src/skf-quick-skill/references/finalize.md +1 -1
- package/src/skf-quick-skill/references/halt-contract.md +2 -2
- package/src/skf-quick-skill/references/write-and-validate.md +1 -1
- package/src/skf-refine-architecture/SKILL.md +8 -9
- package/src/skf-refine-architecture/customize.toml +1 -1
- package/src/skf-refine-architecture/references/report.md +1 -1
- package/src/skf-rename-skill/SKILL.md +3 -2
- package/src/skf-rename-skill/references/invocation-contract.md +2 -2
- package/src/skf-setup/SKILL.md +3 -2
- package/src/skf-setup/references/ccc-index.md +1 -1
- package/src/skf-setup/references/invocation-contract.md +1 -1
- package/src/skf-test-skill/SKILL.md +3 -2
- package/src/skf-test-skill/assets/output-section-formats.md +6 -4
- package/src/skf-test-skill/references/coherence-check.md +11 -8
- package/src/skf-test-skill/references/coverage-check-branches.md +81 -0
- package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
- package/src/skf-test-skill/references/coverage-check.md +60 -97
- package/src/skf-test-skill/references/init.md +2 -1
- package/src/skf-test-skill/references/inventory-contract.md +27 -0
- package/src/skf-test-skill/references/invocation-contract.md +3 -3
- package/src/skf-test-skill/references/migration-section-rules.md +26 -14
- package/src/skf-test-skill/references/report.md +5 -5
- package/src/skf-test-skill/references/scoring-rules.md +8 -7
- package/src/skf-test-skill/references/signature-comparison-rules.md +72 -0
- package/src/skf-test-skill/references/source-access-protocol.md +14 -10
- package/src/skf-test-skill/references/step-hard-gate.md +1 -0
- package/src/skf-test-skill/scripts/build-result-context.py +20 -3
- package/src/skf-test-skill/scripts/gap-ledger.py +456 -176
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +516 -79
- package/src/skf-test-skill/scripts/locate-export-segments.py +685 -76
- package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
- package/src/skf-test-skill/scripts/score-signatures.py +1205 -65
- package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
- package/src/skf-test-skill/scripts/validate-inventory.py +350 -35
- package/src/skf-test-skill/templates/test-report-template.md +1 -0
- package/src/skf-update-skill/SKILL.md +3 -2
- package/src/skf-update-skill/references/detect-changes.md +1 -1
- package/src/skf-update-skill/references/gap-driven.md +54 -33
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +15 -10
- package/src/skf-update-skill/references/re-extract.md +3 -1
- package/src/skf-update-skill/references/report.md +6 -5
- package/src/skf-update-skill/references/write.md +42 -20
- package/src/skf-verify-stack/SKILL.md +4 -3
- package/tools/cli/lib/tool-check.js +158 -2
|
@@ -71,8 +71,15 @@ not read as a missing public export nor as an orphan.
|
|
|
71
71
|
Definition lines: after a citation resolves (the file exists and the line
|
|
72
72
|
is in range, so the older reasons win), the line must define the entry's
|
|
73
73
|
export. NAME is the raw `export_name` (not canonicalized); for a dotted
|
|
74
|
-
name (`App.update`) it is the last segment.
|
|
75
|
-
`
|
|
74
|
+
name (`App.update`) it is the last segment. A bookkeeping variant
|
|
75
|
+
(`X_def`, `X_exact`, `a11y_X`, test-skill's Source Access Protocol fold
|
|
76
|
+
without `--fold-prefix` or `--keep`) that the cited file never names (no
|
|
77
|
+
whole identifier in its text) is checked against its base `X` instead,
|
|
78
|
+
its finding carries `folded_to`, and `summary.line_check_folded` counts
|
|
79
|
+
it; the raw name always comes first, so an export really named
|
|
80
|
+
`parse_exact`, or bound in a shape the rules do not cover, is judged as
|
|
81
|
+
written. classify-stale never folds. The rules
|
|
82
|
+
follow the `source_file` extension:
|
|
76
83
|
|
|
77
84
|
- Python (`.py`, `.pyi`): the `def` / `async def` / `class NAME` line
|
|
78
85
|
itself (a decorator line above it does not count); an assignment
|
|
@@ -89,7 +96,19 @@ name (`App.update`) it is the last segment. The rules follow the
|
|
|
89
96
|
a keyword argument or a parameter never matches; a file that does not
|
|
90
97
|
tokenize is matched line by line. A `from X import *` line counts only
|
|
91
98
|
when no other line of the file defines NAME (a re-export the
|
|
92
|
-
extraction could not trace is cited at its star import).
|
|
99
|
+
extraction could not trace is cited at its star import). An
|
|
100
|
+
`@overload` group, where every line found is a `def` / `async def
|
|
101
|
+
NAME` line at one indentation in one enclosing block (one class, not
|
|
102
|
+
two) and every one but at most the last has `@overload` or
|
|
103
|
+
`@<module>.overload` (`@typing.overload`,
|
|
104
|
+
`@typing_extensions.overload`, `@t.overload` after
|
|
105
|
+
`import typing as t`) in its contiguous decorator block, defines NAME
|
|
106
|
+
at each of its lines, the stubs and the implementation. A comment line
|
|
107
|
+
may sit in the decorator block, and so may a decorator's arguments on
|
|
108
|
+
lines of their own, unless the file does not tokenize. A recorded line
|
|
109
|
+
that misses the group gives its first line alone, which is its first
|
|
110
|
+
stub: the public-API runner, its merge rule and the structural diff
|
|
111
|
+
all record that line, so `fix` can move the entry there.
|
|
93
112
|
- TS/JS (`.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, `.cjs`): a
|
|
94
113
|
`function` (or `function*`), `class`, `interface`, `type`, `enum`,
|
|
95
114
|
`namespace`, `const`, `let` or `var` declaration of NAME, with any of
|
|
@@ -98,7 +117,11 @@ name (`App.update`) it is the last segment. The rules follow the
|
|
|
98
117
|
and `class`; a name inside a decorator's arguments is not declared);
|
|
99
118
|
a later declarator of a `const`, `let` or `var` list
|
|
100
119
|
(`export const a = 1, NAME = 2;`, or `export const a = 1,` then
|
|
101
|
-
` NAME = 2;` as Prettier writes it: see Declarator lists below);
|
|
120
|
+
` NAME = 2;` as Prettier writes it: see Declarator lists below);
|
|
121
|
+
NAME bound by a destructuring pattern that begins such a list
|
|
122
|
+
(`export const { a, b: NAME } = obj;`, `const [NAME] = list`, the
|
|
123
|
+
pattern on one line or several), on the line where NAME appears (see
|
|
124
|
+
Declarator lists below); NAME
|
|
102
125
|
on the line right after a line that ends in its declaration keyword
|
|
103
126
|
(`export function`, `export interface`, `export const` and so on),
|
|
104
127
|
which is the line cited;
|
|
@@ -125,14 +148,23 @@ name (`App.update`) it is the last segment. The rules follow the
|
|
|
125
148
|
Declarator lists: a `const`, `let` or `var` list is split at its
|
|
126
149
|
top-level commas, outside strings, regular expression literals and
|
|
127
150
|
brackets, a type-argument list included (`Map<K, V>`: a `<` right after
|
|
128
|
-
a name), up to a top-level
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
counts. A `<` where an expression begins (JSX, a type assertion, a generic
|
|
151
|
+
a name), up to a top-level `;`. The list goes on to the next line when a
|
|
152
|
+
line ends inside a bracket, after a top-level comma or after an operator
|
|
153
|
+
waiting for its operand, or when the next line starts with an operator (a
|
|
154
|
+
member chain or a ternary broken over lines), so a declarator on a line
|
|
155
|
+
of its own counts. A `<` where an expression begins (JSX, a type assertion, a generic
|
|
134
156
|
arrow function's type parameters) is not followed: no later declarator of
|
|
135
|
-
the list counts after it.
|
|
157
|
+
the list counts after it. A destructuring pattern that begins the list,
|
|
158
|
+
on the keyword's line, declares the names it binds: a shorthand key, the
|
|
159
|
+
target after `key:`, an array element, a rest `...NAME` and the names of
|
|
160
|
+
a nested pattern, each on the line where it appears (the pattern may
|
|
161
|
+
span lines), with its statement's column. A renamed or computed key, a
|
|
162
|
+
default value, the type annotation after the pattern and anything inside
|
|
163
|
+
a string or a comment bind nothing, and neither does a pattern that does
|
|
164
|
+
not close. The later declarators after a pattern count as any others. A
|
|
165
|
+
pattern that is a later declarator (`export const a = 1, { b } = c`) or
|
|
166
|
+
that sits on the line after a split keyword (`export const`, then
|
|
167
|
+
` { b } = c;`) is not covered: it declares nothing.
|
|
136
168
|
|
|
137
169
|
Indentation: for a dotted name every match counts. For an undotted name,
|
|
138
170
|
matches at any indentation count, but when the file has a column-0 match
|
|
@@ -140,7 +172,8 @@ the indented ones are dropped, so a local shadow never outranks the
|
|
|
140
172
|
module-level definition. A line inside a multi-line import, export list
|
|
141
173
|
or `module.exports` object takes the column of the line its statement
|
|
142
174
|
starts on, and so do the name line of a declaration split after its
|
|
143
|
-
keyword
|
|
175
|
+
keyword, a later declarator on a line of its own and the line of a name
|
|
176
|
+
a destructuring pattern binds. When the recorded
|
|
144
177
|
line is itself an indented match that this rule dropped (a method
|
|
145
178
|
recorded under an undotted name beside a module-level function of the
|
|
146
179
|
same name), the finding lists the recorded line too, so it carries
|
|
@@ -149,7 +182,11 @@ several lines and no caller auto-moves it.
|
|
|
149
182
|
Entries in other files, and entries whose `export_type` is `module` or
|
|
150
183
|
`package` (case-insensitive; neither has a definition line), are skipped
|
|
151
184
|
and counted in `summary.line_check_skipped`. A `line-not-definition` item
|
|
152
|
-
lists every defining line in the file in `definition_lines
|
|
185
|
+
lists every defining line in the file in `definition_lines`, the recorded
|
|
186
|
+
line included when it is an indented match the column-0 rule dropped (see
|
|
187
|
+
Indentation). The one exception is a Python `@overload` group: it lists
|
|
188
|
+
the group's first line alone, unless the recorded line is such a dropped
|
|
189
|
+
indented match, which still gives several lines. An empty list
|
|
153
190
|
means the rules found no definition line, which may be a shape they do not
|
|
154
191
|
cover: the entry is unverified, not gone, and no caller auto-fixes it.
|
|
155
192
|
|
|
@@ -232,7 +269,9 @@ Subcommands:
|
|
|
232
269
|
"source_line": <int|null>,
|
|
233
270
|
"reason": "file-missing" | "line-out-of-bounds" | "line-invalid"
|
|
234
271
|
| "line-not-definition",
|
|
235
|
-
"definition_lines": [<int>, ...]
|
|
272
|
+
"definition_lines": [<int>, ...], # line-not-definition only
|
|
273
|
+
"folded_to": "<base name>"}, # line-not-definition only,
|
|
274
|
+
# a variant checked as its base
|
|
236
275
|
...
|
|
237
276
|
],
|
|
238
277
|
"citations": [
|
|
@@ -272,6 +311,7 @@ Subcommands:
|
|
|
272
311
|
"stale_count": <int>,
|
|
273
312
|
"citations_checked": <int>, # entries whose citation was resolved
|
|
274
313
|
"line_check_skipped": <int>, # resolved entries with no line rule
|
|
314
|
+
"line_check_folded": <int>, # variants checked against their base
|
|
275
315
|
"stale_check": "checked" | "skipped-no-source-root",
|
|
276
316
|
"skill_citations_scanned": <int>, # [AST:]/[SRC:] with a line
|
|
277
317
|
"skill_citations_matched": <int>, # of those, citing an entry
|
|
@@ -383,11 +423,15 @@ Subcommands:
|
|
|
383
423
|
| "skipped-language",
|
|
384
424
|
"definition_lines": [<int>, ...] | null, # checked only
|
|
385
425
|
"source_line": <int> | null, # --line
|
|
386
|
-
"line_is_definition": <bool> | null
|
|
426
|
+
"line_is_definition": <bool> | null, # --line, checked only
|
|
427
|
+
"folded_to": "<base name>" # a folded variant only
|
|
387
428
|
}
|
|
388
429
|
|
|
389
430
|
With `--line`, a recorded line that is an indented match the column-0
|
|
390
|
-
rule dropped joins `definition_lines`,
|
|
431
|
+
rule dropped joins `definition_lines`, and one that misses a Python
|
|
432
|
+
`@overload` group gives its first line alone, as in `verify`. A
|
|
433
|
+
bookkeeping variant the file never names is checked against its base,
|
|
434
|
+
as in `verify`, and only then does the key `folded_to` appear.
|
|
391
435
|
|
|
392
436
|
classify-stale --names <coverage.json> --provenance <provenance-map.json>
|
|
393
437
|
--source-root <path> [--inventory <inventory.json>]
|
|
@@ -397,7 +441,8 @@ Subcommands:
|
|
|
397
441
|
documented name the enumerated source surface lacks): the
|
|
398
442
|
definition-lines rules above, run for every provenance entry whose raw
|
|
399
443
|
`export_name` is the name, at its `source_file` and `source_line`, with
|
|
400
|
-
its `export_type
|
|
444
|
+
its `export_type`, and with no bookkeeping-variant fold (the name is
|
|
445
|
+
judged as written). `--names` holds reconcile-coverage.py's result (its
|
|
401
446
|
`stale` names) or a JSON array of names. `--inventory` holds the
|
|
402
447
|
documented inventory, validate-inventory.py's `--output` file or its
|
|
403
448
|
whole result: the `kind` it gives each name decides the module rule
|
|
@@ -450,8 +495,9 @@ Subcommands:
|
|
|
450
495
|
`export default NAME`, an export-list item without `as`, a
|
|
451
496
|
`module.exports` shorthand key, a binding whose value is an import
|
|
452
497
|
(`require(...)` or `(await) import(...)`, on its line or the next:
|
|
453
|
-
`const NAME = require(...)`, `
|
|
454
|
-
`{ NAME: require(...) }`), an
|
|
498
|
+
`const NAME = require(...)`, `const { NAME } = require(...)`,
|
|
499
|
+
`exports.NAME = require(...)`, `{ NAME: require(...) }`), an
|
|
500
|
+
indented line and any line of a Python
|
|
455
501
|
file that does not tokenize never count. A module or package named
|
|
456
502
|
NAME (`NAME.py`, `NAME/__init__.py`, a TS/JS `NAME.<ext>` or
|
|
457
503
|
`NAME/index.<ext>`) declares NAME too: at its line 1 when no line of it
|
|
@@ -599,6 +645,7 @@ Exit codes:
|
|
|
599
645
|
from __future__ import annotations
|
|
600
646
|
|
|
601
647
|
import argparse
|
|
648
|
+
import bisect
|
|
602
649
|
import copy
|
|
603
650
|
import importlib.util
|
|
604
651
|
import io
|
|
@@ -913,6 +960,10 @@ _PY_SPLIT_DECL_RE = re.compile(r"^\s*(?:async\s+def|def|class)\s*\\$")
|
|
|
913
960
|
# A line that can matter to a name it does not hold: an import (which may go
|
|
914
961
|
# on over the next lines) or a `def \` / `class \` whose name is on the next.
|
|
915
962
|
_PY_NAMELESS_MATCH_RE = re.compile(r"^\s*(?:from|import|async|def|class)\b")
|
|
963
|
+
# The decorator of an `@overload` stub, imported by name or used through its
|
|
964
|
+
# module under any name (`typing`, `typing_extensions`, `t` after
|
|
965
|
+
# `import typing as t`).
|
|
966
|
+
_PY_OVERLOAD_RE = re.compile(r"^\s*@\s*(?:[A-Za-z_]\w*\.)?overload\s*$")
|
|
916
967
|
# Tokens that never begin a statement.
|
|
917
968
|
_PY_LAYOUT_TOKENS = frozenset(
|
|
918
969
|
{
|
|
@@ -989,6 +1040,11 @@ _DECL_MAYBE = "maybe"
|
|
|
989
1040
|
# What `_js_declarators` gives: None, or (brackets left open, how) the list
|
|
990
1041
|
# goes on with, and the pieces that begin a declarator.
|
|
991
1042
|
_DeclSplit = tuple[tuple[str, str] | None, list[str]]
|
|
1043
|
+
# What `_js_destructured` gives for a `const` / `let` / `var` statement
|
|
1044
|
+
# whose first declarator is a destructuring pattern: the (name, line) of
|
|
1045
|
+
# each name it binds, and the (line, text after the `=` on that line) its
|
|
1046
|
+
# initializer starts at, None when it has none.
|
|
1047
|
+
_PatternDecl = tuple[list[tuple[str, int]], tuple[int, str] | None]
|
|
992
1048
|
# After one of these, `/` begins a regular expression and `<` begins JSX, a
|
|
993
1049
|
# type assertion or a generic arrow function's type parameters.
|
|
994
1050
|
_JS_EXPRESSION_START = frozenset("([{,;=?:&|^%+-*/<>!~")
|
|
@@ -1004,6 +1060,28 @@ def definition_name(export_name: str) -> str:
|
|
|
1004
1060
|
return export_name.rsplit(".", 1)[-1]
|
|
1005
1061
|
|
|
1006
1062
|
|
|
1063
|
+
# Bookkeeping variants folded to their base name, in rule order: test-skill's
|
|
1064
|
+
# Source Access Protocol defines the fold (`X_def`, `X_exact`, `a11y_X` -> `X`).
|
|
1065
|
+
# Keep identical to FOLD_SUFFIXES and FOLD_PREFIX in load-coverage-inputs.py.
|
|
1066
|
+
FOLD_SUFFIXES = ("_def", "_exact")
|
|
1067
|
+
FOLD_PREFIX = "a11y_"
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def fold_base(export_name: str) -> str | None:
|
|
1071
|
+
"""The base name a bookkeeping variant folds to, None for a name that is
|
|
1072
|
+
no variant: a suffix in FOLD_SUFFIXES first, else FOLD_PREFIX, never the
|
|
1073
|
+
whole name (`_def` alone is no variant). The rules of
|
|
1074
|
+
load-coverage-inputs.py `fold_names`, without `--fold-prefix` or
|
|
1075
|
+
`--keep`: the line check tries the raw name first, so a real export
|
|
1076
|
+
named like a variant is never folded."""
|
|
1077
|
+
for suffix in FOLD_SUFFIXES:
|
|
1078
|
+
if export_name.endswith(suffix) and len(export_name) > len(suffix):
|
|
1079
|
+
return export_name[: -len(suffix)]
|
|
1080
|
+
if export_name.startswith(FOLD_PREFIX) and len(export_name) > len(FOLD_PREFIX):
|
|
1081
|
+
return export_name[len(FOLD_PREFIX):]
|
|
1082
|
+
return None
|
|
1083
|
+
|
|
1084
|
+
|
|
1007
1085
|
def _read_lines(path: Path) -> list[str] | None:
|
|
1008
1086
|
"""Read a text file as lines numbered like `check_citation` counts them
|
|
1009
1087
|
(universal newlines; a UTF-8 BOM is dropped so it never hides line 1).
|
|
@@ -1105,15 +1183,17 @@ def _js_code_lines(lines: list[str]) -> list[str]:
|
|
|
1105
1183
|
|
|
1106
1184
|
|
|
1107
1185
|
class _SourceCache:
|
|
1108
|
-
"""Source lines, Python statement starts
|
|
1109
|
-
|
|
1110
|
-
|
|
1186
|
+
"""Source lines, Python statement starts, TS/JS comment-free lines and
|
|
1187
|
+
the names TS/JS destructuring patterns bind, read once per file, and
|
|
1188
|
+
TS/JS declarator lists, split once per line (neither depends on the
|
|
1189
|
+
export looked up)."""
|
|
1111
1190
|
|
|
1112
1191
|
def __init__(self) -> None:
|
|
1113
1192
|
self._lines: dict[Path, list[str] | None] = {}
|
|
1114
1193
|
self._starts: dict[Path, set[int] | None] = {}
|
|
1115
1194
|
self._js_code: dict[Path, list[str]] = {}
|
|
1116
1195
|
self._js_quiet: dict[Path, list[bool]] = {}
|
|
1196
|
+
self._js_patterns: dict[Path, dict[int, _PatternDecl]] = {}
|
|
1117
1197
|
self._js_decl: dict[tuple[str, str, bool], _DeclSplit] = {}
|
|
1118
1198
|
|
|
1119
1199
|
def lines(self, path: Path) -> list[str] | None:
|
|
@@ -1136,6 +1216,13 @@ class _SourceCache:
|
|
|
1136
1216
|
self._js_quiet[path] = _js_quiet_lines(code_lines)
|
|
1137
1217
|
return self._js_quiet[path]
|
|
1138
1218
|
|
|
1219
|
+
def js_destructured(
|
|
1220
|
+
self, path: Path, code_lines: list[str]
|
|
1221
|
+
) -> dict[int, _PatternDecl]:
|
|
1222
|
+
if path not in self._js_patterns:
|
|
1223
|
+
self._js_patterns[path] = _js_destructured(code_lines)
|
|
1224
|
+
return self._js_patterns[path]
|
|
1225
|
+
|
|
1139
1226
|
def js_declarators(
|
|
1140
1227
|
self, text: str, opened: str = "", fresh: bool = True
|
|
1141
1228
|
) -> _DeclSplit:
|
|
@@ -1269,6 +1356,76 @@ def _python_definition_lines(
|
|
|
1269
1356
|
return found or star_imports
|
|
1270
1357
|
|
|
1271
1358
|
|
|
1359
|
+
def _py_overloaded(lines: list[str], number: int, starts: set[int] | None) -> bool:
|
|
1360
|
+
"""True when the `def` on line `number` has `@overload` or
|
|
1361
|
+
`@<module>.overload` (`@typing.overload`, `@t.overload`) in its
|
|
1362
|
+
contiguous decorator block: the decorator lines at its indentation
|
|
1363
|
+
right above it, up to a blank line or a line that is not a decorator.
|
|
1364
|
+
A comment line is passed over, and so, in a file that tokenizes, is a
|
|
1365
|
+
line on which no statement begins (a decorator's arguments over
|
|
1366
|
+
several lines)."""
|
|
1367
|
+
text = lines[number - 1]
|
|
1368
|
+
indent = text[: len(text) - len(text.lstrip())]
|
|
1369
|
+
i = number - 1
|
|
1370
|
+
while i >= 1:
|
|
1371
|
+
text = lines[i - 1]
|
|
1372
|
+
code = text.split("#", 1)[0]
|
|
1373
|
+
if not text.strip():
|
|
1374
|
+
return False
|
|
1375
|
+
if not code.strip() or (starts is not None and i not in starts):
|
|
1376
|
+
i -= 1
|
|
1377
|
+
continue
|
|
1378
|
+
if not code.lstrip().startswith("@") or text[: len(text) - len(text.lstrip())] != indent:
|
|
1379
|
+
return False
|
|
1380
|
+
if _PY_OVERLOAD_RE.match(code):
|
|
1381
|
+
return True
|
|
1382
|
+
i -= 1
|
|
1383
|
+
return False
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def _py_enclosing_line(
|
|
1387
|
+
lines: list[str], number: int, indent: str, starts: set[int] | None
|
|
1388
|
+
) -> int | None:
|
|
1389
|
+
"""The line of the block that holds the statement on line `number`,
|
|
1390
|
+
indented by `indent`: the nearest line above it where a statement
|
|
1391
|
+
begins with less indentation. None at module level."""
|
|
1392
|
+
i = number - 1
|
|
1393
|
+
while indent and i >= 1:
|
|
1394
|
+
text = lines[i - 1]
|
|
1395
|
+
if text.split("#", 1)[0].strip() and (starts is None or i in starts):
|
|
1396
|
+
if len(text) - len(text.lstrip()) < len(indent):
|
|
1397
|
+
return i
|
|
1398
|
+
i -= 1
|
|
1399
|
+
return None
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
def _python_overload_start(
|
|
1403
|
+
lines: list[str], name: str, defs: list[int], starts: set[int] | None
|
|
1404
|
+
) -> int | None:
|
|
1405
|
+
"""The first of `defs` when they are one `@overload` group: two lines
|
|
1406
|
+
or more, each a `def` / `async def NAME` line, all at one indentation
|
|
1407
|
+
in one enclosing block (`_py_enclosing_line`, so two classes'
|
|
1408
|
+
methods never make one group), and each but at most the last
|
|
1409
|
+
decorated as an overload (`_py_overloaded`). None otherwise."""
|
|
1410
|
+
if len(defs) < 2:
|
|
1411
|
+
return None
|
|
1412
|
+
decl = re.compile(rf"^(\s*)(?:async\s+def|def)\s+{re.escape(name)}(?!\w)")
|
|
1413
|
+
indents = set()
|
|
1414
|
+
for number in defs:
|
|
1415
|
+
m = decl.match(lines[number - 1].split("#", 1)[0])
|
|
1416
|
+
if m is None:
|
|
1417
|
+
return None
|
|
1418
|
+
indents.add(m.group(1))
|
|
1419
|
+
if len(indents) > 1:
|
|
1420
|
+
return None
|
|
1421
|
+
(indent,) = indents
|
|
1422
|
+
if len({_py_enclosing_line(lines, number, indent, starts) for number in defs}) > 1:
|
|
1423
|
+
return None
|
|
1424
|
+
if not all(_py_overloaded(lines, number, starts) for number in defs[:-1]):
|
|
1425
|
+
return None
|
|
1426
|
+
return defs[0]
|
|
1427
|
+
|
|
1428
|
+
|
|
1272
1429
|
def _js_export_item_binds(item: str, name: str, alias_only: bool = False) -> bool:
|
|
1273
1430
|
"""True when one `export { ... }` item exposes `name`: the name after
|
|
1274
1431
|
`as`, else (unless `alias_only`) the item's own name, an identifier or
|
|
@@ -1469,6 +1626,252 @@ def _js_paren_tail_is_call(after_open: str) -> bool:
|
|
|
1469
1626
|
return False
|
|
1470
1627
|
|
|
1471
1628
|
|
|
1629
|
+
def _js_pattern_bindings(
|
|
1630
|
+
text: str, start: int
|
|
1631
|
+
) -> tuple[list[tuple[str, int]], int | None]:
|
|
1632
|
+
"""The names the destructuring pattern whose `{` or `[` is at
|
|
1633
|
+
`text[start]` binds, each with its offset in `text`, and the offset
|
|
1634
|
+
just past the pattern's closing bracket.
|
|
1635
|
+
|
|
1636
|
+
An object pattern's property is `...target`, a shorthand key or
|
|
1637
|
+
`key: target` (a key is a name, a string, a number or a computed
|
|
1638
|
+
`[...]`, and only a shorthand key binds itself); an array pattern's
|
|
1639
|
+
element is `...target`, a target or a hole; a target is a name or a
|
|
1640
|
+
nested pattern. A `= default` is skipped to the next comma at the
|
|
1641
|
+
pattern's depth, and so is a property or element of any other shape,
|
|
1642
|
+
which binds nothing. '...', "..." and `...` strings are skipped
|
|
1643
|
+
whole, so no bracket or comma inside one counts. Whitespace, newlines
|
|
1644
|
+
included, may sit anywhere between the parts.
|
|
1645
|
+
|
|
1646
|
+
Returns ([], None) when `text[start]` opens no pattern, the pattern
|
|
1647
|
+
does not close in `text`, a string in it does not end, or a bracket in
|
|
1648
|
+
it closes one it did not open. A pure function of its arguments (no
|
|
1649
|
+
module state), so another script can carry a copy of it.
|
|
1650
|
+
"""
|
|
1651
|
+
n = len(text)
|
|
1652
|
+
names: list[tuple[str, int]] = []
|
|
1653
|
+
|
|
1654
|
+
def space(i: int) -> int:
|
|
1655
|
+
while i < n and text[i].isspace():
|
|
1656
|
+
i += 1
|
|
1657
|
+
return i
|
|
1658
|
+
|
|
1659
|
+
def name_end(i: int) -> int:
|
|
1660
|
+
"""The end of the identifier at `i`, `i` itself when none starts there."""
|
|
1661
|
+
if i < n and (text[i].isalpha() or text[i] in "_$"):
|
|
1662
|
+
i += 1
|
|
1663
|
+
while i < n and (text[i].isalnum() or text[i] in "_$"):
|
|
1664
|
+
i += 1
|
|
1665
|
+
return i
|
|
1666
|
+
|
|
1667
|
+
def string_end(i: int) -> int | None:
|
|
1668
|
+
"""The offset past the string whose quote is at `i`."""
|
|
1669
|
+
quote = text[i]
|
|
1670
|
+
i += 1
|
|
1671
|
+
while i < n:
|
|
1672
|
+
if text[i] == "\\":
|
|
1673
|
+
i += 2
|
|
1674
|
+
elif text[i] == quote:
|
|
1675
|
+
return i + 1
|
|
1676
|
+
else:
|
|
1677
|
+
i += 1
|
|
1678
|
+
return None
|
|
1679
|
+
|
|
1680
|
+
def skip(i: int, to_comma: bool = True) -> int | None:
|
|
1681
|
+
"""From `i`, the offset of the next comma (unless not `to_comma`)
|
|
1682
|
+
or closing bracket at depth 0; with `to_comma` False, `text[i]`
|
|
1683
|
+
opens a bracket and the offset past the one closing it."""
|
|
1684
|
+
depth = 0
|
|
1685
|
+
while i < n:
|
|
1686
|
+
ch = text[i]
|
|
1687
|
+
if ch in "'\"`":
|
|
1688
|
+
end = string_end(i)
|
|
1689
|
+
if end is None:
|
|
1690
|
+
return None
|
|
1691
|
+
i = end
|
|
1692
|
+
continue
|
|
1693
|
+
if ch in "([{":
|
|
1694
|
+
depth += 1
|
|
1695
|
+
elif ch in ")]}":
|
|
1696
|
+
if depth == 0:
|
|
1697
|
+
return i
|
|
1698
|
+
depth -= 1
|
|
1699
|
+
if depth == 0 and not to_comma:
|
|
1700
|
+
return i + 1
|
|
1701
|
+
elif ch == "," and depth == 0 and to_comma:
|
|
1702
|
+
return i
|
|
1703
|
+
i += 1
|
|
1704
|
+
return None
|
|
1705
|
+
|
|
1706
|
+
def default(i: int) -> int | None:
|
|
1707
|
+
"""After a target: past its `= default`, when it has one."""
|
|
1708
|
+
i = space(i)
|
|
1709
|
+
if i < n and text[i] == "=" and text[i + 1 : i + 2] not in ("=", ">"):
|
|
1710
|
+
return skip(i + 1)
|
|
1711
|
+
return i
|
|
1712
|
+
|
|
1713
|
+
def bind(i: int, end: int, close: str) -> int | None:
|
|
1714
|
+
"""The name `text[i:end]` is bound when its property or element
|
|
1715
|
+
ends after it or takes a default (any other text: another shape)."""
|
|
1716
|
+
after = space(end)
|
|
1717
|
+
if after < n and (
|
|
1718
|
+
text[after] in (",", close)
|
|
1719
|
+
or text[after] == "=" and text[after + 1 : after + 2] not in ("=", ">")
|
|
1720
|
+
):
|
|
1721
|
+
names.append((text[i:end], i))
|
|
1722
|
+
return default(after)
|
|
1723
|
+
return after
|
|
1724
|
+
|
|
1725
|
+
def target(i: int, close: str) -> int | None:
|
|
1726
|
+
if i < n and text[i] in "{[":
|
|
1727
|
+
end = pattern(i)
|
|
1728
|
+
return None if end is None else default(end)
|
|
1729
|
+
end = name_end(i)
|
|
1730
|
+
return i if end == i else bind(i, end, close)
|
|
1731
|
+
|
|
1732
|
+
def element(i: int, close: str) -> int | None:
|
|
1733
|
+
"""One property or element at `i`: the offset after it."""
|
|
1734
|
+
if text.startswith("...", i):
|
|
1735
|
+
return target(space(i + 3), close)
|
|
1736
|
+
if close == "]":
|
|
1737
|
+
return target(i, close)
|
|
1738
|
+
if text[i] in "'\"":
|
|
1739
|
+
end = string_end(i)
|
|
1740
|
+
elif text[i] == "[":
|
|
1741
|
+
end = skip(i, to_comma=False) # a computed key
|
|
1742
|
+
else:
|
|
1743
|
+
end = name_end(i)
|
|
1744
|
+
if end > i:
|
|
1745
|
+
after = space(end)
|
|
1746
|
+
if after < n and text[after] == ":":
|
|
1747
|
+
return target(space(after + 1), close)
|
|
1748
|
+
return bind(i, end, close) # a shorthand key
|
|
1749
|
+
while end < n and (text[end].isalnum() or text[end] in "._"):
|
|
1750
|
+
end += 1 # a number key
|
|
1751
|
+
if end is None:
|
|
1752
|
+
return None
|
|
1753
|
+
after = space(end)
|
|
1754
|
+
if end > i and after < n and text[after] == ":":
|
|
1755
|
+
return target(space(after + 1), close)
|
|
1756
|
+
return after
|
|
1757
|
+
|
|
1758
|
+
def pattern(i: int) -> int | None:
|
|
1759
|
+
"""The pattern opening at `i`: the offset past its closing bracket."""
|
|
1760
|
+
close = "}" if text[i] == "{" else "]"
|
|
1761
|
+
i += 1
|
|
1762
|
+
while True:
|
|
1763
|
+
i = space(i)
|
|
1764
|
+
if i >= n:
|
|
1765
|
+
return None
|
|
1766
|
+
if text[i] == close:
|
|
1767
|
+
return i + 1
|
|
1768
|
+
if text[i] in ")]}":
|
|
1769
|
+
return None
|
|
1770
|
+
if text[i] == ",":
|
|
1771
|
+
i += 1 # a hole, or a stray comma
|
|
1772
|
+
continue
|
|
1773
|
+
end = element(i, close)
|
|
1774
|
+
if end is None:
|
|
1775
|
+
return None
|
|
1776
|
+
end = space(end)
|
|
1777
|
+
if end < n and text[end] not in (",", close):
|
|
1778
|
+
end = skip(end) # any other shape binds nothing
|
|
1779
|
+
if end is None:
|
|
1780
|
+
return None
|
|
1781
|
+
i = end + 1 if end < n and text[end] == "," else end
|
|
1782
|
+
|
|
1783
|
+
if not (0 <= start < n and text[start] in "{["):
|
|
1784
|
+
return [], None
|
|
1785
|
+
end = pattern(start)
|
|
1786
|
+
return (names, end) if end is not None else ([], None)
|
|
1787
|
+
|
|
1788
|
+
|
|
1789
|
+
def _js_initializer_start(text: str, end: int) -> int | None:
|
|
1790
|
+
"""The offset just past the `=` that begins the initializer of a
|
|
1791
|
+
declarator whose name or pattern ends at `end`, a type annotation
|
|
1792
|
+
(`: T`, brackets, strings and `=>` in it included) skipped. None when
|
|
1793
|
+
neither `=` nor `:` follows, or when a `,` or `;` outside the
|
|
1794
|
+
annotation's brackets, or the end of `text`, comes first."""
|
|
1795
|
+
n = len(text)
|
|
1796
|
+
i = end
|
|
1797
|
+
while i < n and text[i].isspace():
|
|
1798
|
+
i += 1
|
|
1799
|
+
if i < n and text[i] == "=":
|
|
1800
|
+
return None if text[i + 1 : i + 2] == "=" else i + 1
|
|
1801
|
+
if not (i < n and text[i] == ":"):
|
|
1802
|
+
return None
|
|
1803
|
+
i += 1
|
|
1804
|
+
depth = 0
|
|
1805
|
+
while i < n:
|
|
1806
|
+
ch = text[i]
|
|
1807
|
+
if ch in "'\"`":
|
|
1808
|
+
i += 1
|
|
1809
|
+
while i < n and text[i] != ch:
|
|
1810
|
+
i += 2 if text[i] == "\\" else 1
|
|
1811
|
+
elif text.startswith("=>", i):
|
|
1812
|
+
i += 1
|
|
1813
|
+
elif ch in "([{<":
|
|
1814
|
+
depth += 1
|
|
1815
|
+
elif ch in ")]}>":
|
|
1816
|
+
depth -= 1
|
|
1817
|
+
if depth < 0:
|
|
1818
|
+
return None
|
|
1819
|
+
elif ch in ",;" and depth == 0:
|
|
1820
|
+
return None
|
|
1821
|
+
elif ch == "=" and depth == 0:
|
|
1822
|
+
return None if text[i + 1 : i + 2] == "=" else i + 1
|
|
1823
|
+
i += 1
|
|
1824
|
+
return None
|
|
1825
|
+
|
|
1826
|
+
|
|
1827
|
+
def _js_destructured(code_lines: list[str]) -> dict[int, _PatternDecl]:
|
|
1828
|
+
"""For each line that opens a `const` / `let` / `var` statement whose
|
|
1829
|
+
first declarator is a destructuring pattern (`_JS_VAR_DECL_RE`, the
|
|
1830
|
+
rest starting with `{` or `[`): the names the pattern binds, each with
|
|
1831
|
+
the line it appears on, and where its initializer starts.
|
|
1832
|
+
|
|
1833
|
+
The patterns are read from the comment-free lines joined with `\\n`
|
|
1834
|
+
(`_js_pattern_bindings`), so a pattern may span lines and a name's
|
|
1835
|
+
offset maps back to its line. A pattern that does not close, or that
|
|
1836
|
+
nests deeper than the interpreter's recursion limit, binds nothing and
|
|
1837
|
+
gives no item.
|
|
1838
|
+
"""
|
|
1839
|
+
starts: list[tuple[int, int]] = []
|
|
1840
|
+
for number, code in enumerate(code_lines, start=1):
|
|
1841
|
+
if "{" not in code and "[" not in code:
|
|
1842
|
+
continue
|
|
1843
|
+
m = _JS_VAR_DECL_RE.match(code)
|
|
1844
|
+
if m is not None and m.group(1)[:1] in ("{", "["):
|
|
1845
|
+
starts.append((number, m.start(1)))
|
|
1846
|
+
if not starts:
|
|
1847
|
+
return {}
|
|
1848
|
+
text = "\n".join(code_lines)
|
|
1849
|
+
offsets: list[int] = []
|
|
1850
|
+
pos = 0
|
|
1851
|
+
for code in code_lines:
|
|
1852
|
+
offsets.append(pos)
|
|
1853
|
+
pos += len(code) + 1
|
|
1854
|
+
|
|
1855
|
+
def line_of(offset: int) -> int:
|
|
1856
|
+
return bisect.bisect_right(offsets, offset)
|
|
1857
|
+
|
|
1858
|
+
out: dict[int, _PatternDecl] = {}
|
|
1859
|
+
for number, column in starts:
|
|
1860
|
+
try:
|
|
1861
|
+
names, end = _js_pattern_bindings(text, offsets[number - 1] + column)
|
|
1862
|
+
except RecursionError:
|
|
1863
|
+
continue # patterns nested past the interpreter's limit: none bound
|
|
1864
|
+
if end is None:
|
|
1865
|
+
continue
|
|
1866
|
+
value = _js_initializer_start(text, end)
|
|
1867
|
+
at = None
|
|
1868
|
+
if value is not None:
|
|
1869
|
+
line = line_of(value)
|
|
1870
|
+
at = (line, code_lines[line - 1][value - offsets[line - 1] :])
|
|
1871
|
+
out[number] = ([(name, line_of(offset)) for name, offset in names], at)
|
|
1872
|
+
return out
|
|
1873
|
+
|
|
1874
|
+
|
|
1472
1875
|
def _tsjs_definition_lines(
|
|
1473
1876
|
lines: list[str],
|
|
1474
1877
|
code_lines: list[str],
|
|
@@ -1477,6 +1880,7 @@ def _tsjs_definition_lines(
|
|
|
1477
1880
|
declarators: Callable[..., _DeclSplit] = _js_declarators,
|
|
1478
1881
|
declarations_only: bool = False,
|
|
1479
1882
|
quiet: list[bool] | None = None,
|
|
1883
|
+
destructured: dict[int, _PatternDecl] | None = None,
|
|
1480
1884
|
) -> list[tuple[int, bool]]:
|
|
1481
1885
|
"""(line, at column 0) for every line that defines `name`.
|
|
1482
1886
|
|
|
@@ -1486,15 +1890,20 @@ def _tsjs_definition_lines(
|
|
|
1486
1890
|
JSDoc line or a commented-out declaration never matches. A line inside
|
|
1487
1891
|
a multi-line `export { ... }` list or `module.exports = { ... }` object
|
|
1488
1892
|
takes the column of the line the statement starts on, and so do the
|
|
1489
|
-
name line of a declaration whose keyword ends the line above it
|
|
1490
|
-
later declarator on a line of its own
|
|
1893
|
+
name line of a declaration whose keyword ends the line above it, a
|
|
1894
|
+
later declarator on a line of its own and the line of a name that a
|
|
1895
|
+
destructuring pattern binds. `destructured` is
|
|
1896
|
+
`_js_destructured(code_lines)` when the caller has it (it does not
|
|
1897
|
+
depend on the name either): a name its pattern binds counts when the
|
|
1898
|
+
`const` / `let` / `var` line it is keyed by opens a statement here.
|
|
1899
|
+
Class members count only for a
|
|
1491
1900
|
dotted export name. `declarators` splits a declarator list
|
|
1492
1901
|
(`_js_declarators`, or `_SourceCache.js_declarators`, which splits each
|
|
1493
1902
|
line once for every export looked up). With `declarations_only`,
|
|
1494
1903
|
`export import NAME =`, `export default NAME`, an export-list item
|
|
1495
1904
|
without an `as` that renames, a `module.exports` shorthand key and an
|
|
1496
|
-
import-valued binding (a declarator, `exports.NAME =`
|
|
1497
|
-
`module.exports` key whose value starts with `require(` or an
|
|
1905
|
+
import-valued binding (a declarator, a destructured name, `exports.NAME =`
|
|
1906
|
+
or a `module.exports` key whose value starts with `require(` or an
|
|
1498
1907
|
`(await) import(`, on its line or the next one) never count, nor does
|
|
1499
1908
|
a class member.
|
|
1500
1909
|
"""
|
|
@@ -1515,7 +1924,8 @@ def _tsjs_definition_lines(
|
|
|
1515
1924
|
]
|
|
1516
1925
|
# One declarator of a `const` / `let` / `var` list: `NAME = v`,
|
|
1517
1926
|
# `NAME: T = v`, `NAME!: T`, or a bare `NAME`. A destructuring pattern
|
|
1518
|
-
# starts with `{` or `[` and never matches
|
|
1927
|
+
# starts with `{` or `[` and never matches: the names a pattern that
|
|
1928
|
+
# begins the list binds come from `destructured` (`_js_pattern_bindings`).
|
|
1519
1929
|
declarator = re.compile(rf"^\s*{esc}{end}\s*(?:!?\s*:|=|$)")
|
|
1520
1930
|
# With declarations_only, the value bound to NAME (the rest of a
|
|
1521
1931
|
# declarator, of `exports.NAME =` or of a `module.exports` key), to
|
|
@@ -1590,6 +2000,22 @@ def _tsjs_definition_lines(
|
|
|
1590
2000
|
decls: list[tuple[bool, str, str]] = []
|
|
1591
2001
|
if quiet is None:
|
|
1592
2002
|
quiet = _js_quiet_lines(code_lines)
|
|
2003
|
+
if destructured is None:
|
|
2004
|
+
destructured = _js_destructured(code_lines)
|
|
2005
|
+
|
|
2006
|
+
def pattern_binds(number: int) -> list[int]:
|
|
2007
|
+
"""The lines on which the pattern that begins the `const` / `let` /
|
|
2008
|
+
`var` list of line `number` binds `name` (none with
|
|
2009
|
+
`declarations_only` when its value is an import)."""
|
|
2010
|
+
decl = destructured.get(number)
|
|
2011
|
+
if decl is None:
|
|
2012
|
+
return []
|
|
2013
|
+
bound, value = decl
|
|
2014
|
+
at = [line for bound_name, line in bound if bound_name == name]
|
|
2015
|
+
if at and declarations_only and value is not None and imported(value[1], value[0]):
|
|
2016
|
+
return []
|
|
2017
|
+
return at
|
|
2018
|
+
|
|
1593
2019
|
for number, (text, code) in enumerate(zip(lines, code_lines), start=1):
|
|
1594
2020
|
if quiet[number - 1] and not (decls or split or cont) and name not in code:
|
|
1595
2021
|
continue # no rule below can match it: spares a scan per name
|
|
@@ -1637,7 +2063,8 @@ def _tsjs_definition_lines(
|
|
|
1637
2063
|
found.append((number, top))
|
|
1638
2064
|
continue
|
|
1639
2065
|
top = _is_column_0(text)
|
|
1640
|
-
# every rule that matches a line needs the name in it
|
|
2066
|
+
# every rule that matches a line needs the name in it, but for a
|
|
2067
|
+
# destructuring pattern, whose names may sit on the lines below
|
|
1641
2068
|
named = name in code
|
|
1642
2069
|
if named and is_statement(code, number):
|
|
1643
2070
|
found.append((number, top))
|
|
@@ -1647,6 +2074,9 @@ def _tsjs_definition_lines(
|
|
|
1647
2074
|
carry, pieces = declarators(m.group(1))
|
|
1648
2075
|
if carry is not None:
|
|
1649
2076
|
decls.append((top, *carry))
|
|
2077
|
+
# `export const { a, b: NAME } = obj;`, on one line or several:
|
|
2078
|
+
# each name's line, at the statement's column
|
|
2079
|
+
found.extend((line, top) for line in pattern_binds(number))
|
|
1650
2080
|
if named and any(declares(p, number) for p in pieces):
|
|
1651
2081
|
# a later declarator: `export const a = 1, NAME = 2;`
|
|
1652
2082
|
found.append((number, top))
|
|
@@ -1741,6 +2171,42 @@ def _definition_matches(
|
|
|
1741
2171
|
return _line_matches(suffix, path, lines, export_name, cache, declarations_only)
|
|
1742
2172
|
|
|
1743
2173
|
|
|
2174
|
+
def _folded_matches(
|
|
2175
|
+
source_file: str,
|
|
2176
|
+
export_name: str,
|
|
2177
|
+
source_root: Path,
|
|
2178
|
+
cache: _SourceCache | None = None,
|
|
2179
|
+
) -> tuple[tuple[list[int], set[int]] | None, str | None]:
|
|
2180
|
+
"""(`_definition_matches` of the name the line check judges, the base
|
|
2181
|
+
name a bookkeeping variant was folded to, else None).
|
|
2182
|
+
|
|
2183
|
+
The raw name comes first. Only when the file never names it (no whole
|
|
2184
|
+
identifier in its text, so no shape the rules miss can define it) and
|
|
2185
|
+
`fold_base` gives a base that some line matches are the base's matches
|
|
2186
|
+
returned, with the base; otherwise the raw name's, with None. So a real
|
|
2187
|
+
export named like a variant (`parse_exact` defined as itself, or bound
|
|
2188
|
+
in a shape the rules do not cover) is judged as written, and
|
|
2189
|
+
classify-stale, which never folds, does not call this.
|
|
2190
|
+
"""
|
|
2191
|
+
cache = cache if cache is not None else _SourceCache()
|
|
2192
|
+
matches = _definition_matches(source_file, export_name, source_root, cache)
|
|
2193
|
+
if matches is None or matches[1]:
|
|
2194
|
+
return matches, None
|
|
2195
|
+
base = fold_base(export_name)
|
|
2196
|
+
if base is None:
|
|
2197
|
+
return matches, None
|
|
2198
|
+
# the variant's own identifiers: `mockChannel_def`, or `a11y_App` of `a11y_App.update`
|
|
2199
|
+
own = sorted(set(export_name.split(".")) - set(base.split(".")))
|
|
2200
|
+
named = re.compile(r"(?<![\w$])(?:" + "|".join(map(re.escape, own)) + r")(?![\w$])")
|
|
2201
|
+
lines = cache.lines((source_root / source_file.strip().replace("\\", "/")).resolve())
|
|
2202
|
+
if not own or lines is None or any(named.search(text) for text in lines):
|
|
2203
|
+
return matches, None
|
|
2204
|
+
folded = _definition_matches(source_file, base, source_root, cache)
|
|
2205
|
+
if folded is None or not folded[1]:
|
|
2206
|
+
return matches, None
|
|
2207
|
+
return folded, base
|
|
2208
|
+
|
|
2209
|
+
|
|
1744
2210
|
def _line_matches(
|
|
1745
2211
|
suffix: str,
|
|
1746
2212
|
path: Path,
|
|
@@ -1766,7 +2232,7 @@ def _line_matches(
|
|
|
1766
2232
|
code_lines = cache.js_code_lines(path, lines)
|
|
1767
2233
|
found = _tsjs_definition_lines(
|
|
1768
2234
|
lines, code_lines, name, dotted, cache.js_declarators, declarations_only,
|
|
1769
|
-
cache.js_quiet_lines(path, code_lines),
|
|
2235
|
+
cache.js_quiet_lines(path, code_lines), cache.js_destructured(path, code_lines),
|
|
1770
2236
|
)
|
|
1771
2237
|
if declarations_only:
|
|
1772
2238
|
top = {n for n, at_column_0 in found if at_column_0}
|
|
@@ -1790,21 +2256,63 @@ def find_definition_lines(
|
|
|
1790
2256
|
return None if matches is None else matches[0]
|
|
1791
2257
|
|
|
1792
2258
|
|
|
2259
|
+
def _missed_definition_lines(
|
|
2260
|
+
source_file: str,
|
|
2261
|
+
export_name: str,
|
|
2262
|
+
source_root: Path,
|
|
2263
|
+
cache: _SourceCache,
|
|
2264
|
+
line: int,
|
|
2265
|
+
defs: list[int],
|
|
2266
|
+
every: set[int],
|
|
2267
|
+
) -> list[int]:
|
|
2268
|
+
"""The `definition_lines` a finding lists for a recorded `line` that is
|
|
2269
|
+
not one of `defs` (`_definition_matches` over the same `cache`).
|
|
2270
|
+
|
|
2271
|
+
A recorded line that is an indented match the column-0 rule dropped
|
|
2272
|
+
joins them, so the finding carries several lines and nobody moves it.
|
|
2273
|
+
Otherwise a Python file's `@overload` group gives its first line alone
|
|
2274
|
+
(`_python_overload_start`), the line to move the entry to, and any
|
|
2275
|
+
other answer gives `defs` as they are.
|
|
2276
|
+
"""
|
|
2277
|
+
if line in every:
|
|
2278
|
+
return sorted(set(defs) | {line})
|
|
2279
|
+
rel = source_file.strip().replace("\\", "/")
|
|
2280
|
+
if Path(rel).suffix.lower() not in PYTHON_EXTENSIONS or len(defs) < 2:
|
|
2281
|
+
return defs
|
|
2282
|
+
path = (source_root / rel).resolve()
|
|
2283
|
+
lines = cache.lines(path)
|
|
2284
|
+
if lines is None:
|
|
2285
|
+
return defs
|
|
2286
|
+
first = _python_overload_start(
|
|
2287
|
+
lines, definition_name(export_name), defs, cache.py_statement_starts(path, lines)
|
|
2288
|
+
)
|
|
2289
|
+
return defs if first is None else [first]
|
|
2290
|
+
|
|
2291
|
+
|
|
1793
2292
|
def definition_lines_report(
|
|
1794
2293
|
source_file: str,
|
|
1795
2294
|
export_name: str,
|
|
1796
2295
|
source_root: Path,
|
|
1797
2296
|
line: int | None = None,
|
|
1798
2297
|
export_type: str | None = None,
|
|
2298
|
+
fold: bool = True,
|
|
1799
2299
|
) -> dict:
|
|
1800
2300
|
"""What `verify` would say about one entry's line (the definition-lines
|
|
1801
2301
|
subcommand). Raises ValueError when the file exists but cannot be read.
|
|
1802
2302
|
|
|
1803
2303
|
`line_check` is `file-missing`, `skipped-export-type` (a `module` or
|
|
1804
2304
|
`package`, no definition line), `skipped-language` (no rule for the
|
|
1805
|
-
extension) or `checked`. With `line`, a recorded line that is
|
|
1806
|
-
|
|
1807
|
-
`
|
|
2305
|
+
extension) or `checked`. With `line`, a recorded line that is not a
|
|
2306
|
+
definition line gets the lines `verify` would list
|
|
2307
|
+
(`_missed_definition_lines`): an indented match the column-0 rule
|
|
2308
|
+
dropped joins `definition_lines`, so the answer carries several lines,
|
|
2309
|
+
and a Python `@overload` group gives its first line alone.
|
|
2310
|
+
|
|
2311
|
+
With `fold` (the default, as `verify` checks), a bookkeeping variant the
|
|
2312
|
+
file never names is checked against its base name (`_folded_matches`),
|
|
2313
|
+
and the report gains `folded_to`, the base, only when it folded.
|
|
2314
|
+
classify-stale passes `fold=False`: the fabricated-signature test judges
|
|
2315
|
+
the documented name as written.
|
|
1808
2316
|
"""
|
|
1809
2317
|
report: dict = {
|
|
1810
2318
|
"source_file": source_file,
|
|
@@ -1823,14 +2331,23 @@ def definition_lines_report(
|
|
|
1823
2331
|
covered = suffix in PYTHON_EXTENSIONS or suffix in TSJS_EXTENSIONS
|
|
1824
2332
|
report["line_check"] = "skipped-export-type" if covered else "skipped-language"
|
|
1825
2333
|
return report
|
|
1826
|
-
|
|
2334
|
+
cache = _SourceCache()
|
|
2335
|
+
if fold:
|
|
2336
|
+
matches, folded_to = _folded_matches(source_file, export_name, source_root, cache)
|
|
2337
|
+
else:
|
|
2338
|
+
matches, folded_to = _definition_matches(source_file, export_name, source_root, cache), None
|
|
1827
2339
|
if matches is None:
|
|
1828
2340
|
raise ValueError(f"failed to read source file {source_root / rel}")
|
|
2341
|
+
if folded_to is not None:
|
|
2342
|
+
report["folded_to"] = folded_to
|
|
2343
|
+
judged = folded_to or export_name
|
|
1829
2344
|
defs, every = matches
|
|
1830
2345
|
if line is not None:
|
|
1831
2346
|
report["line_is_definition"] = line in defs
|
|
1832
|
-
if line not in defs
|
|
1833
|
-
defs =
|
|
2347
|
+
if line not in defs:
|
|
2348
|
+
defs = _missed_definition_lines(
|
|
2349
|
+
source_file, judged, source_root, cache, line, defs, every
|
|
2350
|
+
)
|
|
1834
2351
|
report["definition_lines"] = defs
|
|
1835
2352
|
return report
|
|
1836
2353
|
|
|
@@ -1875,7 +2392,10 @@ def _entry_citation(entry: dict) -> str | None:
|
|
|
1875
2392
|
def _stale_line_check(
|
|
1876
2393
|
entry: dict, name: str, source_root: Path
|
|
1877
2394
|
) -> tuple[str, list[int] | None]:
|
|
1878
|
-
"""(line_check, definition_lines) of one provenance entry of a stale name.
|
|
2395
|
+
"""(line_check, definition_lines) of one provenance entry of a stale name.
|
|
2396
|
+
|
|
2397
|
+
The documented name is judged as written, with no bookkeeping-variant
|
|
2398
|
+
fold: a variant the cited file never defines stays fabricated."""
|
|
1879
2399
|
source_file = _entry_file(entry)
|
|
1880
2400
|
if source_file is None:
|
|
1881
2401
|
# no file to check: it neither proves the name fabricated nor clears it
|
|
@@ -1889,6 +2409,7 @@ def _stale_line_check(
|
|
|
1889
2409
|
source_root,
|
|
1890
2410
|
line if isinstance(line, int) else None,
|
|
1891
2411
|
export_type if isinstance(export_type, str) else None,
|
|
2412
|
+
fold=False,
|
|
1892
2413
|
)
|
|
1893
2414
|
except ValueError:
|
|
1894
2415
|
# the file exists but cannot be read: nothing proves it
|
|
@@ -2161,8 +2682,8 @@ def baseline_gaps(
|
|
|
2161
2682
|
) -> tuple[list[dict], list[dict]]:
|
|
2162
2683
|
"""The provenance entries an extraction left out whose cited file still
|
|
2163
2684
|
declares them: the recipe runner never reports a module, an alias that
|
|
2164
|
-
renames, a dunder such as `__version__` or another
|
|
2165
|
-
a diff would read each as removed.
|
|
2685
|
+
renames, a dunder such as `__version__` or another Python or Rust
|
|
2686
|
+
underscore name, so a diff would read each as removed.
|
|
2166
2687
|
|
|
2167
2688
|
`held` holds the (name, file) of every export the extraction found,
|
|
2168
2689
|
`files` the files whose entries are checked (the ones read), and
|
|
@@ -3245,6 +3766,7 @@ def verify(
|
|
|
3245
3766
|
stale: list[dict] = []
|
|
3246
3767
|
citations_checked = 0
|
|
3247
3768
|
line_check_skipped = 0
|
|
3769
|
+
line_check_folded = 0
|
|
3248
3770
|
if source_root is not None:
|
|
3249
3771
|
cache = _SourceCache()
|
|
3250
3772
|
entries = prov.get("entries")
|
|
@@ -3285,19 +3807,25 @@ def verify(
|
|
|
3285
3807
|
if not line_rule_applies(sf, entry.get("export_type")):
|
|
3286
3808
|
line_check_skipped += 1
|
|
3287
3809
|
continue
|
|
3288
|
-
|
|
3810
|
+
# A bookkeeping variant the file never names is checked
|
|
3811
|
+
# against its base name (`folded_to`).
|
|
3812
|
+
matches, folded_to = _folded_matches(sf, name, source_root, cache)
|
|
3289
3813
|
if matches is None:
|
|
3290
3814
|
line_check_skipped += 1
|
|
3291
3815
|
continue
|
|
3816
|
+
if folded_to is not None:
|
|
3817
|
+
line_check_folded += 1
|
|
3292
3818
|
defs, every = matches
|
|
3293
3819
|
if line not in defs:
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
# lists several lines and nobody auto-moves it.
|
|
3298
|
-
defs = sorted(set(defs) | {line})
|
|
3820
|
+
# An indented match the column-0 rule dropped joins
|
|
3821
|
+
# the lines, so nobody auto-moves it; an @overload
|
|
3822
|
+
# group gives its first line alone, the move target.
|
|
3299
3823
|
item["reason"] = STALE_LINE_NOT_DEFINITION
|
|
3300
|
-
item["definition_lines"] =
|
|
3824
|
+
item["definition_lines"] = _missed_definition_lines(
|
|
3825
|
+
sf, folded_to or name, source_root, cache, line, defs, every
|
|
3826
|
+
)
|
|
3827
|
+
if folded_to is not None:
|
|
3828
|
+
item["folded_to"] = folded_to
|
|
3301
3829
|
stale.append(item)
|
|
3302
3830
|
stale.sort(key=lambda s: (s["export_name"], s.get("source_file") or ""))
|
|
3303
3831
|
|
|
@@ -3339,6 +3867,7 @@ def verify(
|
|
|
3339
3867
|
"stale_count": len(stale),
|
|
3340
3868
|
"citations_checked": citations_checked,
|
|
3341
3869
|
"line_check_skipped": line_check_skipped,
|
|
3870
|
+
"line_check_folded": line_check_folded,
|
|
3342
3871
|
"stale_check": "checked"
|
|
3343
3872
|
if source_root is not None
|
|
3344
3873
|
else "skipped-no-source-root",
|