@cplieger/deadset-ts 3.2.0 → 3.3.1
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/README.md +2 -2
- package/dist/src/analysis.js +14 -2
- package/dist/src/decorator.js +64 -0
- package/dist/src/exempt-classes.js +57 -10
- package/dist/src/exempt.js +23 -3
- package/dist/src/findings/dependencies-and-module-machinery.js +2 -0
- package/dist/src/findings/emitter.js +1 -0
- package/dist/src/findings/emitters.js +19 -0
- package/dist/src/findings/interfaces.js +2 -0
- package/dist/src/findings/intra-function.js +2 -0
- package/dist/src/findings/non-code-artifacts.js +2 -0
- package/dist/src/findings/reads-and-writes.js +2 -0
- package/dist/src/findings/self-check.js +2 -0
- package/dist/src/findings/unused-declarations.js +2 -0
- package/dist/src/findings/visibility-narrowing.js +2 -0
- package/dist/src/interface-satisfaction.js +310 -0
- package/dist/src/member-names.js +76 -0
- package/dist/src/references.js +22 -3
- package/dist/src/reflective-lookup.js +96 -0
- package/dist/src/run.js +60 -176
- package/dist/src/template-field.js +173 -0
- package/dist/src/verbs/print-config.js +9 -0
- package/dist/src/verbs/print-projects.js +34 -0
- package/dist/src/verbs/print-retained.js +23 -0
- package/dist/src/verbs/print-roots.js +36 -0
- package/dist/src/verbs/verb.js +5 -0
- package/dist/src/verbs/version.js +7 -0
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/analysis.ts +24 -6
- package/src/decorator.ts +74 -0
- package/src/exempt-classes.ts +75 -12
- package/src/exempt.ts +29 -3
- package/src/findings/dependencies-and-module-machinery.ts +4 -0
- package/src/findings/emitter.ts +19 -0
- package/src/findings/emitters.ts +21 -0
- package/src/findings/interfaces.ts +4 -0
- package/src/findings/intra-function.ts +4 -0
- package/src/findings/non-code-artifacts.ts +4 -0
- package/src/findings/reads-and-writes.ts +4 -0
- package/src/findings/self-check.ts +4 -0
- package/src/findings/unused-declarations.ts +4 -0
- package/src/findings/visibility-narrowing.ts +4 -0
- package/src/interface-satisfaction.ts +417 -0
- package/src/member-names.ts +89 -0
- package/src/references.ts +41 -6
- package/src/reflective-lookup.ts +127 -0
- package/src/run.ts +64 -200
- package/src/template-field.ts +214 -0
- package/src/verbs/print-config.ts +10 -0
- package/src/verbs/print-projects.ts +35 -0
- package/src/verbs/print-retained.ts +24 -0
- package/src/verbs/print-roots.ts +38 -0
- package/src/verbs/verb.ts +38 -0
- package/src/verbs/version.ts +8 -0
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ deadset-ts is a command-line analyzer for TypeScript and JavaScript projects. It
|
|
|
8
8
|
|
|
9
9
|
deadset-ts is report-only. It never edits source, and it refuses any `--fix` flag.
|
|
10
10
|
|
|
11
|
-
**Status: pre-release.** This version implements contract 3.0.0 and ships the analyzer's foundation: one compiler session per run over the projects `analysis.configurations` declares, in the order it lists them, each a compiler configuration file below the target, or, where it declares none, every `tsconfig` project discovered under the target (the explicit scope, every `tsconfig*.json`, each project's `references`), a fail-closed read of every project's diagnostics, configuration decoding against the contract's closed key list with `print-config` and its provenance, `print-projects`, the report-only guard, the symbol inventory of every declaration a project's own files hold down to its class and type members, the one-pass reference resolution over that inventory, which resolves each file's identifiers in batches ([batch-cap calibration](docs/batch-cap-calibration.md) is the measurement behind the batch size), and the root set `print-roots` prints. A root is a declaration the analysis keeps live without a reference: what a file exports when the manifest names it through `main`, `module`, `types`, `bin` or `exports`, or when `ts.entry_files` matches it; a library target's published API; the configuration, setup and test files of Vitest, Stryker and Playwright, the flat configuration of ESLint, and a worker or service worker a call addresses by a string literal; and every declaration a `roots.patterns` entry names. An entry that names nothing is reported as `DS1704` and fails the run. The entry-point conventions of other tools are not read, and `DECLINED_CONVENTIONS` lists each one with the reason. The analysis is reached through the command, and through `run()` for a caller that runs the command in-process; no other verb reports findings yet, so `analyze`, `explain` and `describe` exit with a usage message. The one runtime dependency is the `typescript` package at exactly 7.0.2, installed under the `@typescript/native` name until TypeScript 7.1 ships a compiler API the tooling can share.
|
|
11
|
+
**Status: pre-release.** This version implements contract 3.0.0 and ships the analyzer's foundation: one compiler session per run over the projects `analysis.configurations` declares, in the order it lists them, each a compiler configuration file below the target, or, where it declares none, every `tsconfig` project discovered under the target (the explicit scope, every `tsconfig*.json`, each project's `references`), a fail-closed read of every project's diagnostics, configuration decoding against the contract's closed key list with `print-config` and its provenance, `print-projects`, the report-only guard, the symbol inventory of every declaration a project's own files hold down to its class and type members, the one-pass reference resolution over that inventory, which resolves each file's identifiers in batches ([batch-cap calibration](docs/batch-cap-calibration.md) is the measurement behind the batch size), and the root set `print-roots` prints. A root is a declaration the analysis keeps live without a reference: what a file exports when the manifest names it through `main`, `module`, `types`, `bin` or `exports`, or when `ts.entry_files` matches it; a library target's published API; the configuration, setup and test files of Vitest, Stryker and Playwright, the flat configuration of ESLint, and a worker or service worker a call addresses by a string literal; and every declaration a `roots.patterns` entry names. An entry that names nothing is reported as `DS1704` and fails the run. The entry-point conventions of other tools are not read, and `DECLINED_CONVENTIONS` lists each one with the reason. Four exemption classes hold a declaration back from the sweep, and `print-retained` lists what each one held back: `interface-satisfaction`, a member an interface requires of a class whose value reaches that interface; `decorator`, a decorated member and every member of a decorated class; and, at the lowest confidence, `template-field`, a member an action of a template under `analysis.template_dirs` names, and `reflective-lookup`, a member a literal key of an element access or of `Reflect.get`, `Reflect.set` or `Reflect.has` names. None of them holds back a `#private` member. The analysis is reached through the command, and through `run()` for a caller that runs the command in-process; no other verb reports findings yet, so `analyze`, `explain` and `describe` exit with a usage message. The one runtime dependency is the `typescript` package at exactly 7.0.2, installed under the `@typescript/native` name until TypeScript 7.1 ships a compiler API the tooling can share.
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
@@ -28,7 +28,7 @@ The npm package carries the command and the library as JavaScript that the compi
|
|
|
28
28
|
npx deadset-ts version
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
`version` prints the analyzer version and the Contract version it implements, and exits 0. `print-config`, `print-projects`, `print-roots` and `print-retained` print what their row of the table says and exit 0, or 1 when `print-roots` reports a `DS1704`, and `print-retained` exits 2 when an `exemptions.disabled` entry is not an exemption class. `analyze`, `explain`, `describe`, any other invocation and no arguments print the usage line and exit 2.
|
|
31
|
+
`version` prints the analyzer version and the Contract version it implements, and exits 0. `print-config`, `print-projects`, `print-roots` and `print-retained` print what their row of the table says and exit 0, or 1 when `print-roots` reports a `DS1704`, and `print-retained` exits 2 when an `exemptions.disabled` entry is not an exemption class or an `analysis.template_dirs` entry is not a directory below the target root. `analyze`, `explain`, `describe`, any other invocation and no arguments print the usage line and exit 2.
|
|
32
32
|
|
|
33
33
|
| Verb | Job |
|
|
34
34
|
| ---------------- | ------------------------------------------------------------------------------- |
|
package/dist/src/analysis.js
CHANGED
|
@@ -7,16 +7,20 @@
|
|
|
7
7
|
* carries a project's identity in its type so a handle cannot cross to another
|
|
8
8
|
* project's checker, and that holds only for views the session itself hands out.
|
|
9
9
|
*/
|
|
10
|
+
import { decorator } from "./decorator.js";
|
|
10
11
|
import { diagnosticErrors, discoverProjects, DiscoveryError } from "./discover.js";
|
|
11
12
|
import { computeExemptions, disabledClasses, exemptionsOf, retainedIn, } from "./exempt.js";
|
|
13
|
+
import { interfaceSatisfaction } from "./interface-satisfaction.js";
|
|
12
14
|
import { inventory } from "./inventory.js";
|
|
13
15
|
import { readManifest } from "./manifest.js";
|
|
14
16
|
import { matrixOf, sweepMatrix } from "./matrix.js";
|
|
15
17
|
import { relativePath, resolvePath } from "./paths.js";
|
|
16
18
|
import { byPosition } from "./position.js";
|
|
19
|
+
import { reflectiveLookup } from "./reflective-lookup.js";
|
|
17
20
|
import { references } from "./references.js";
|
|
18
21
|
import { roots, unmatchedEverywhere, unmatchedRoots } from "./roots.js";
|
|
19
22
|
import { diagnosticsOf, runSession } from "./session.js";
|
|
23
|
+
import { readTemplates, templateField } from "./template-field.js";
|
|
20
24
|
/** The setting whose source decides where a finding about a configured root sits. */
|
|
21
25
|
const ROOTS_SETTING = "roots.patterns";
|
|
22
26
|
/**
|
|
@@ -119,7 +123,12 @@ export function runRoots(engine, host, scope, config, provenance) {
|
|
|
119
123
|
};
|
|
120
124
|
}
|
|
121
125
|
/** The exemption classes this analyzer detects, each by its detector; any other retains nothing. */
|
|
122
|
-
const DETECTORS = new Map(
|
|
126
|
+
const DETECTORS = new Map([
|
|
127
|
+
["interface-satisfaction", interfaceSatisfaction],
|
|
128
|
+
["template-field", templateField],
|
|
129
|
+
["reflective-lookup", reflectiveLookup],
|
|
130
|
+
["decorator", decorator],
|
|
131
|
+
]);
|
|
123
132
|
/**
|
|
124
133
|
* Sweeps the run the scope and the configuration describe. Each project's references
|
|
125
134
|
* and exemptions are read against its own inventory while its view is open; the
|
|
@@ -133,6 +142,9 @@ const DETECTORS = new Map();
|
|
|
133
142
|
export function runSweep(engine, host, scope, config, input, detectors = DETECTORS) {
|
|
134
143
|
const targetRoot = scope.target.path;
|
|
135
144
|
const disabled = disabledClasses(config);
|
|
145
|
+
const templates = disabled.has("template-field")
|
|
146
|
+
? { delimiters: config.analysis.templateDelimiters, files: [] }
|
|
147
|
+
: readTemplates(host, targetRoot, config.analysis);
|
|
136
148
|
const read = readProjects(engine, host, scope, config, (project, projectRead) => {
|
|
137
149
|
const resolved = references(project, projectRead.held, targetRoot, {
|
|
138
150
|
testFiles: config.ts.testFiles,
|
|
@@ -144,7 +156,7 @@ export function runSweep(engine, host, scope, config, input, detectors = DETECTO
|
|
|
144
156
|
roots: projectRead.rooted.liveUnderReachability,
|
|
145
157
|
testFiles: resolved.testFilePaths,
|
|
146
158
|
};
|
|
147
|
-
const exempt = computeExemptions({ project, held: projectRead.held, targetRoot }, detectors, {
|
|
159
|
+
const exempt = computeExemptions({ project, held: projectRead.held, targetRoot, templates }, detectors, {
|
|
148
160
|
disabled,
|
|
149
161
|
mode: input.mode,
|
|
150
162
|
testFiles: new Set(resolved.testFilePaths),
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decorator class: a member a decorator expression is attached to, and every
|
|
3
|
+
* member of a class a decorator expression is attached to, is held back, because the
|
|
4
|
+
* decorator receives the member or the class when the class is defined and may reach
|
|
5
|
+
* the member by its name.
|
|
6
|
+
*
|
|
7
|
+
* The rule is read from the tree alone: a decorator is attached where it is written.
|
|
8
|
+
* The use the decorator itself makes of what it decorates is the reference pass's,
|
|
9
|
+
* which records it as a `decorator` reference; this class records only that the
|
|
10
|
+
* declaration is held back, and the member set of a decorated class is every member
|
|
11
|
+
* the inventory holds for the class, static members included.
|
|
12
|
+
*/
|
|
13
|
+
import { isCallExpression, isDecorator, } from "@typescript/native/unstable/ast";
|
|
14
|
+
import { nodeKey } from "./inventory.js";
|
|
15
|
+
import { renderPosition } from "./position.js";
|
|
16
|
+
/** The kinds of declaration a decorator is attached to and holds back. */
|
|
17
|
+
const DECORATED = new Set(["class", "method", "class-member"]);
|
|
18
|
+
/** How one decorator is spelled in a record: its expression, without a call's arguments. */
|
|
19
|
+
function spelled(decorator) {
|
|
20
|
+
const expression = decorator.expression;
|
|
21
|
+
const named = isCallExpression(expression) ? expression.expression : expression;
|
|
22
|
+
return `@${named.getText().replace(/\s+/gu, "")}`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The decorator detector: each decorated member, and each member of a decorated
|
|
26
|
+
* class, recorded at the decorator.
|
|
27
|
+
*/
|
|
28
|
+
export function decorator(input) {
|
|
29
|
+
const { project, held, targetRoot } = input;
|
|
30
|
+
const byId = new Map(held.symbols.map((symbol) => [symbol.id, symbol]));
|
|
31
|
+
const membersOf = new Map();
|
|
32
|
+
for (const symbol of held.symbols) {
|
|
33
|
+
if (symbol.kind === "method" || symbol.kind === "class-member") {
|
|
34
|
+
const members = membersOf.get(symbol.parent);
|
|
35
|
+
if (members === undefined) {
|
|
36
|
+
membersOf.set(symbol.parent, [symbol]);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
members.push(symbol);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const found = [];
|
|
44
|
+
for (const file of project.ownSourceFiles()) {
|
|
45
|
+
const visit = (node) => {
|
|
46
|
+
const id = held.declarations.get(nodeKey(file, node));
|
|
47
|
+
const symbol = id === undefined ? undefined : byId.get(id);
|
|
48
|
+
if (symbol !== undefined && DECORATED.has(symbol.kind)) {
|
|
49
|
+
const modifiers = node.modifiers ?? [];
|
|
50
|
+
for (const attached of modifiers.filter(isDecorator)) {
|
|
51
|
+
const detail = `decorated by ${spelled(attached)}`;
|
|
52
|
+
const site = renderPosition(file, targetRoot, attached.getStart());
|
|
53
|
+
const reached = symbol.kind === "class" ? (membersOf.get(symbol.id) ?? []) : [symbol];
|
|
54
|
+
for (const member of reached) {
|
|
55
|
+
found.push({ id: member.id, detail, site });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
node.forEachChild(visit);
|
|
60
|
+
};
|
|
61
|
+
file.forEachChild(visit);
|
|
62
|
+
}
|
|
63
|
+
return found;
|
|
64
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The exemption classes of the Contract's vocabulary, in its order: each class's name,
|
|
3
|
-
* the languages it runs on
|
|
3
|
+
* the languages it runs on, the confidence it is recorded at and, for a class that runs
|
|
4
|
+
* on TypeScript, which private members it may retain.
|
|
4
5
|
*
|
|
5
6
|
* The rows are written out rather than read from the Contract at run time, for the
|
|
6
7
|
* reason the fixed severity codes are, and a test holds them equal to the Contract
|
|
@@ -10,26 +11,72 @@
|
|
|
10
11
|
*/
|
|
11
12
|
/** Every class of the vocabulary, in the order the Contract lists them. */
|
|
12
13
|
export const EXEMPTION_CLASSES = [
|
|
13
|
-
{
|
|
14
|
+
{
|
|
15
|
+
class: "interface-satisfaction",
|
|
16
|
+
languages: ["go", "ts"],
|
|
17
|
+
confidence: "certain",
|
|
18
|
+
typescriptVisibility: { private: false, privateName: false },
|
|
19
|
+
},
|
|
14
20
|
{ class: "encoding-reflection", languages: ["go"], confidence: "certain" },
|
|
15
21
|
{ class: "format-verb-contract", languages: ["go"], confidence: "certain" },
|
|
16
22
|
{ class: "errors-duck-typing", languages: ["go"], confidence: "certain" },
|
|
17
|
-
{
|
|
23
|
+
{
|
|
24
|
+
class: "enum-group",
|
|
25
|
+
languages: ["go", "ts"],
|
|
26
|
+
confidence: "certain",
|
|
27
|
+
typescriptVisibility: { private: false, privateName: false },
|
|
28
|
+
},
|
|
18
29
|
{ class: "generated-file", languages: ["go"], confidence: "certain" },
|
|
19
30
|
{ class: "linkname-cgo-asm-plugin", languages: ["go"], confidence: "certain" },
|
|
20
|
-
{
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
{
|
|
32
|
+
class: "template-field",
|
|
33
|
+
languages: ["go", "ts"],
|
|
34
|
+
confidence: "possible",
|
|
35
|
+
typescriptVisibility: { private: true, privateName: false },
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
class: "reflective-lookup",
|
|
39
|
+
languages: ["go", "ts"],
|
|
40
|
+
confidence: "possible",
|
|
41
|
+
typescriptVisibility: { private: true, privateName: false },
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
class: "decorator",
|
|
45
|
+
languages: ["ts"],
|
|
46
|
+
confidence: "certain",
|
|
47
|
+
typescriptVisibility: { private: true, privateName: false },
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
class: "injection-container",
|
|
51
|
+
languages: ["ts"],
|
|
52
|
+
confidence: "certain",
|
|
53
|
+
typescriptVisibility: { private: true, privateName: false },
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
class: "framework-lifecycle",
|
|
57
|
+
languages: ["ts"],
|
|
58
|
+
confidence: "certain",
|
|
59
|
+
typescriptVisibility: { private: true, privateName: false },
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
class: "serialization-contract",
|
|
63
|
+
languages: ["ts"],
|
|
64
|
+
confidence: "certain",
|
|
65
|
+
typescriptVisibility: { private: true, privateName: false },
|
|
66
|
+
},
|
|
26
67
|
];
|
|
27
68
|
function runsOnTS(row) {
|
|
28
69
|
const languages = row.languages;
|
|
29
70
|
return languages.includes("ts");
|
|
30
71
|
}
|
|
72
|
+
const TS_ROWS = EXEMPTION_CLASSES.filter(runsOnTS);
|
|
31
73
|
/** The classes that run on TypeScript, in the vocabulary's order. */
|
|
32
|
-
export const TS_EXEMPTION_CLASSES =
|
|
74
|
+
export const TS_EXEMPTION_CLASSES = TS_ROWS.map((row) => row.class);
|
|
75
|
+
const VISIBILITY = new Map(TS_ROWS.map((row) => [row.class, row.typescriptVisibility]));
|
|
76
|
+
/** Which private members one class that runs on TypeScript may retain. */
|
|
77
|
+
export function typescriptVisibilityOf(exemptionClass) {
|
|
78
|
+
return VISIBILITY.get(exemptionClass) ?? { private: false, privateName: false };
|
|
79
|
+
}
|
|
33
80
|
/** Whether one name is a class of the vocabulary, whichever language it runs on. */
|
|
34
81
|
export function isExemptionClass(name) {
|
|
35
82
|
return EXEMPTION_CLASSES.some((row) => row.class === name);
|
package/dist/src/exempt.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* hands it the table of the classes it has.
|
|
8
8
|
*/
|
|
9
9
|
import { ConfigError } from "./config.js";
|
|
10
|
-
import { EXEMPTION_CLASSES, isExemptionClass, TS_EXEMPTION_CLASSES, } from "./exempt-classes.js";
|
|
10
|
+
import { EXEMPTION_CLASSES, isExemptionClass, TS_EXEMPTION_CLASSES, typescriptVisibilityOf, } from "./exempt-classes.js";
|
|
11
11
|
import { byPosition, positionKey } from "./position.js";
|
|
12
12
|
import { sweep } from "./sweep.js";
|
|
13
13
|
/**
|
|
@@ -26,19 +26,39 @@ export function disabledClasses(config) {
|
|
|
26
26
|
}
|
|
27
27
|
return disabled;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Whether a class whose row states `allowed` may retain a member of one visibility. A
|
|
31
|
+
* declaration the inventory does not hold has no visibility to refuse it by.
|
|
32
|
+
*/
|
|
33
|
+
function retainable(allowed, visibility) {
|
|
34
|
+
if (visibility === "private") {
|
|
35
|
+
return allowed.private;
|
|
36
|
+
}
|
|
37
|
+
if (visibility === "private-name") {
|
|
38
|
+
return allowed.privateName;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
29
42
|
/**
|
|
30
43
|
* The records one project's detectors found, each class in the vocabulary's order and
|
|
31
|
-
* none of a switched-off class.
|
|
32
|
-
*
|
|
44
|
+
* none of a switched-off class. Evidence on a private member the class's row says it
|
|
45
|
+
* cannot retain is dropped first, whatever the detector found. Under a production mode
|
|
46
|
+
* a record whose evidence is written in a test file holds nothing, as a reference a
|
|
47
|
+
* test file makes is none there.
|
|
33
48
|
*/
|
|
34
49
|
export function computeExemptions(input, detectors, holding) {
|
|
50
|
+
const visibility = new Map(input.held.symbols.map((symbol) => [symbol.id, symbol.visibility]));
|
|
35
51
|
const found = [];
|
|
36
52
|
for (const exemptionClass of TS_EXEMPTION_CLASSES) {
|
|
37
53
|
const detect = detectors.get(exemptionClass);
|
|
38
54
|
if (detect === undefined || holding.disabled.has(exemptionClass)) {
|
|
39
55
|
continue;
|
|
40
56
|
}
|
|
57
|
+
const allowed = typescriptVisibilityOf(exemptionClass);
|
|
41
58
|
for (const evidence of detect(input)) {
|
|
59
|
+
if (!retainable(allowed, visibility.get(evidence.id))) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
42
62
|
if (holding.mode.production && holding.testFiles.has(evidence.site.path)) {
|
|
43
63
|
continue;
|
|
44
64
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { dependenciesAndModuleMachinery } from "./dependencies-and-module-machinery.js";
|
|
2
|
+
import { interfaces } from "./interfaces.js";
|
|
3
|
+
import { intraFunction } from "./intra-function.js";
|
|
4
|
+
import { nonCodeArtifacts } from "./non-code-artifacts.js";
|
|
5
|
+
import { readsAndWrites } from "./reads-and-writes.js";
|
|
6
|
+
import { selfCheck } from "./self-check.js";
|
|
7
|
+
import { unusedDeclarations } from "./unused-declarations.js";
|
|
8
|
+
import { visibilityNarrowing } from "./visibility-narrowing.js";
|
|
9
|
+
/** Every kind family this analyzer reports, each by its emitter, in code order. */
|
|
10
|
+
export const EMITTERS = new Map([
|
|
11
|
+
["unused-declarations", unusedDeclarations],
|
|
12
|
+
["visibility-narrowing", visibilityNarrowing],
|
|
13
|
+
["interfaces", interfaces],
|
|
14
|
+
["reads-and-writes", readsAndWrites],
|
|
15
|
+
["non-code-artifacts", nonCodeArtifacts],
|
|
16
|
+
["dependencies-and-module-machinery", dependenciesAndModuleMachinery],
|
|
17
|
+
["self-check", selfCheck],
|
|
18
|
+
["intra-function", intraFunction],
|
|
19
|
+
]);
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interface-satisfaction class: a class member is held back when a value of the
|
|
3
|
+
* class reaches a position the checker types as an interface requiring a member of
|
|
4
|
+
* that name, so a caller reaches the member through the interface while no name in
|
|
5
|
+
* the program resolves to it.
|
|
6
|
+
*
|
|
7
|
+
* A value reaches such a position as an `implements` entry, the initializer of a
|
|
8
|
+
* declaration whose type is written, an assignment, an argument, a returned value, an
|
|
9
|
+
* array element, an object property, or the operand of `as` or `satisfies`, read
|
|
10
|
+
* through parentheses, conditionals and the operators whose result is an operand.
|
|
11
|
+
* `this` in an instance member is a value of its class. Types are asked in batches;
|
|
12
|
+
* the contextual lookup has none, so it is asked only of a value whose type is a class
|
|
13
|
+
* of the target, and each distinct pair, interface and class is asked once.
|
|
14
|
+
*/
|
|
15
|
+
import { isArrayLiteralExpression, isArrowFunction, isAsExpression, isBinaryExpression, isCallExpression, isClassDeclaration, isClassExpression, isClassStaticBlockDeclaration, isConditionalExpression, isFunctionDeclaration, isFunctionExpression, isGetAccessorDeclaration, isHeritageClause, isIdentifier, isMethodDeclaration, isNewExpression, isObjectLiteralExpression, isParameterDeclaration, isParenthesizedExpression, isPropertyAssignment, isPropertyDeclaration, isReturnStatement, isSatisfiesExpression, isSetAccessorDeclaration, isShorthandPropertyAssignment, isSpreadElement, isVariableDeclaration, ModifierFlags, SyntaxKind, } from "@typescript/native/unstable/ast";
|
|
16
|
+
import { SymbolFlags } from "@typescript/native/unstable/sync";
|
|
17
|
+
import { aliasChains } from "./alias-chain.js";
|
|
18
|
+
import { nodeKey } from "./inventory.js";
|
|
19
|
+
import { renderPosition } from "./position.js";
|
|
20
|
+
import { DEFAULT_BATCH_CAP } from "./references.js";
|
|
21
|
+
/** The operators of an assignment whose right side is stored into the left side's type. */
|
|
22
|
+
const STORES = new Set([
|
|
23
|
+
SyntaxKind.EqualsToken,
|
|
24
|
+
SyntaxKind.QuestionQuestionEqualsToken,
|
|
25
|
+
SyntaxKind.BarBarEqualsToken,
|
|
26
|
+
SyntaxKind.AmpersandAmpersandEqualsToken,
|
|
27
|
+
]);
|
|
28
|
+
/** Whether one node declares a static member, whose `this` is the class itself. */
|
|
29
|
+
function isStatic(node) {
|
|
30
|
+
const flags = node.modifierFlags ?? 0;
|
|
31
|
+
return (flags & ModifierFlags.Static) !== 0;
|
|
32
|
+
}
|
|
33
|
+
/** Whether one node is a method or an accessor of an object literal, whose `this` is the literal. */
|
|
34
|
+
function isObjectLiteralMethod(node) {
|
|
35
|
+
return ((isMethodDeclaration(node) ||
|
|
36
|
+
isGetAccessorDeclaration(node) ||
|
|
37
|
+
isSetAccessorDeclaration(node)) &&
|
|
38
|
+
isObjectLiteralExpression(node.parent));
|
|
39
|
+
}
|
|
40
|
+
/** Whether a declaration writes its type, so its initializer is read in that type's context. */
|
|
41
|
+
function writesType(node) {
|
|
42
|
+
return node.type !== undefined;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The values one written expression hands on: the expression itself, or what it
|
|
46
|
+
* evaluates to through parentheses, a conditional and the operators whose result is
|
|
47
|
+
* one of their operands.
|
|
48
|
+
*/
|
|
49
|
+
function valuesOf(expression) {
|
|
50
|
+
if (isParenthesizedExpression(expression)) {
|
|
51
|
+
return valuesOf(expression.expression);
|
|
52
|
+
}
|
|
53
|
+
if (isConditionalExpression(expression)) {
|
|
54
|
+
return [...valuesOf(expression.whenTrue), ...valuesOf(expression.whenFalse)];
|
|
55
|
+
}
|
|
56
|
+
if (isBinaryExpression(expression)) {
|
|
57
|
+
const operator = expression.operatorToken.kind;
|
|
58
|
+
if (operator === SyntaxKind.QuestionQuestionToken || operator === SyntaxKind.BarBarToken) {
|
|
59
|
+
return [...valuesOf(expression.left), ...valuesOf(expression.right)];
|
|
60
|
+
}
|
|
61
|
+
if (operator === SyntaxKind.AmpersandAmpersandToken || operator === SyntaxKind.CommaToken) {
|
|
62
|
+
return valuesOf(expression.right);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return [expression];
|
|
66
|
+
}
|
|
67
|
+
/** The expressions one node hands to a position typed by its context. */
|
|
68
|
+
function handedOn(node) {
|
|
69
|
+
if ((isVariableDeclaration(node) || isPropertyDeclaration(node) || isParameterDeclaration(node)) &&
|
|
70
|
+
node.initializer !== undefined &&
|
|
71
|
+
writesType(node)) {
|
|
72
|
+
return [node.initializer];
|
|
73
|
+
}
|
|
74
|
+
if (isBinaryExpression(node) && STORES.has(node.operatorToken.kind)) {
|
|
75
|
+
return [node.right];
|
|
76
|
+
}
|
|
77
|
+
if (isCallExpression(node) || isNewExpression(node)) {
|
|
78
|
+
return (node.arguments ?? []).filter((argument) => !isSpreadElement(argument));
|
|
79
|
+
}
|
|
80
|
+
if (isReturnStatement(node)) {
|
|
81
|
+
return node.expression === undefined ? [] : [node.expression];
|
|
82
|
+
}
|
|
83
|
+
if (isArrowFunction(node)) {
|
|
84
|
+
return node.body.kind === SyntaxKind.Block ? [] : [node.body];
|
|
85
|
+
}
|
|
86
|
+
if (isArrayLiteralExpression(node)) {
|
|
87
|
+
return node.elements.filter((element) => !isSpreadElement(element) && element.kind !== SyntaxKind.OmittedExpression);
|
|
88
|
+
}
|
|
89
|
+
if (isPropertyAssignment(node)) {
|
|
90
|
+
return [node.initializer];
|
|
91
|
+
}
|
|
92
|
+
if (isShorthandPropertyAssignment(node)) {
|
|
93
|
+
return isIdentifier(node.name) ? [node.name] : [];
|
|
94
|
+
}
|
|
95
|
+
if (isAsExpression(node) || isSatisfiesExpression(node)) {
|
|
96
|
+
return [node.expression];
|
|
97
|
+
}
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The classes, the `implements` entries and the flowing values of one project's own
|
|
102
|
+
* files. `this` is the class an instance member belongs to, so a function that is not
|
|
103
|
+
* an arrow, a static member and a static block each stop it.
|
|
104
|
+
*/
|
|
105
|
+
function walk(files, declarations) {
|
|
106
|
+
const classes = [];
|
|
107
|
+
const clauses = [];
|
|
108
|
+
const flows = [];
|
|
109
|
+
for (const file of files) {
|
|
110
|
+
const visit = (node, self) => {
|
|
111
|
+
let inner = self;
|
|
112
|
+
if (isClassDeclaration(node) || isClassExpression(node)) {
|
|
113
|
+
const id = declarations.get(nodeKey(file, node));
|
|
114
|
+
const name = node.name;
|
|
115
|
+
const owner = id === undefined || name === undefined
|
|
116
|
+
? undefined
|
|
117
|
+
: { id, name, generic: node.typeParameters !== undefined };
|
|
118
|
+
if (owner !== undefined) {
|
|
119
|
+
classes.push(owner);
|
|
120
|
+
for (const clause of node.heritageClauses ?? []) {
|
|
121
|
+
if (isHeritageClause(clause) && clause.token === SyntaxKind.ImplementsKeyword) {
|
|
122
|
+
clauses.push(...clause.types.map((entry) => ({ owner, entry })));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
inner = owner;
|
|
127
|
+
}
|
|
128
|
+
else if (isFunctionDeclaration(node) ||
|
|
129
|
+
isFunctionExpression(node) ||
|
|
130
|
+
isClassStaticBlockDeclaration(node) ||
|
|
131
|
+
isStatic(node) ||
|
|
132
|
+
isObjectLiteralMethod(node)) {
|
|
133
|
+
inner = undefined;
|
|
134
|
+
}
|
|
135
|
+
for (const written of handedOn(node)) {
|
|
136
|
+
for (const value of valuesOf(written)) {
|
|
137
|
+
flows.push({
|
|
138
|
+
value,
|
|
139
|
+
self: value.kind === SyntaxKind.ThisKeyword ? inner : undefined,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
node.forEachChild((child) => {
|
|
144
|
+
visit(child, inner);
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
file.forEachChild((child) => {
|
|
148
|
+
visit(child, undefined);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return { classes, clauses, flows };
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The interface-satisfaction detector: for each class type that reaches an interface
|
|
155
|
+
* type it is assignable to, the class's members whose names the interface's members
|
|
156
|
+
* carry, each recorded at the place the value flows.
|
|
157
|
+
*/
|
|
158
|
+
export function interfaceSatisfaction(input) {
|
|
159
|
+
const { project, held, targetRoot } = input;
|
|
160
|
+
const checker = project.checker;
|
|
161
|
+
const files = project.ownSourceFiles();
|
|
162
|
+
const { classes, clauses, flows } = walk(files, held.declarations);
|
|
163
|
+
if (classes.length === 0) {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
// One list, so the batches are capped runs of the whole project rather than of a
|
|
167
|
+
// file: a class's name, an `implements` entry and a value are each one question.
|
|
168
|
+
const asked = [
|
|
169
|
+
...classes.map((owner) => owner.name),
|
|
170
|
+
...clauses.map((clause) => clause.entry),
|
|
171
|
+
...flows.filter((flow) => flow.self === undefined).map((flow) => flow.value),
|
|
172
|
+
];
|
|
173
|
+
const typeAt = new Map();
|
|
174
|
+
for (let from = 0; from < asked.length; from += DEFAULT_BATCH_CAP) {
|
|
175
|
+
const run = asked.slice(from, from + DEFAULT_BATCH_CAP);
|
|
176
|
+
checker.getTypeAtLocation(run).forEach((type, index) => {
|
|
177
|
+
const node = run[index];
|
|
178
|
+
if (type !== undefined && node !== undefined) {
|
|
179
|
+
typeAt.set(node, type);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const classOfType = new Map();
|
|
184
|
+
const instanceOf = new Map();
|
|
185
|
+
for (const owner of classes) {
|
|
186
|
+
const type = typeAt.get(owner.name);
|
|
187
|
+
if (type !== undefined) {
|
|
188
|
+
classOfType.set(type.id, owner);
|
|
189
|
+
instanceOf.set(owner.id, type);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const instantiates = classes.some((owner) => owner.generic);
|
|
193
|
+
const constituents = new Map();
|
|
194
|
+
const partsOf = (type) => {
|
|
195
|
+
if (!type.isUnionType() && !type.isIntersectionType()) {
|
|
196
|
+
return [type];
|
|
197
|
+
}
|
|
198
|
+
let parts = constituents.get(type.id);
|
|
199
|
+
if (parts === undefined) {
|
|
200
|
+
parts = type.getTypes();
|
|
201
|
+
constituents.set(type.id, parts);
|
|
202
|
+
}
|
|
203
|
+
return parts;
|
|
204
|
+
};
|
|
205
|
+
const targets = new Map();
|
|
206
|
+
/** The classes of the target one value's type is an instance of, with the instance type. */
|
|
207
|
+
const classesOf = (type) => partsOf(type).flatMap((part) => {
|
|
208
|
+
const direct = classOfType.get(part.id);
|
|
209
|
+
if (direct !== undefined) {
|
|
210
|
+
return [{ owner: direct, type: part }];
|
|
211
|
+
}
|
|
212
|
+
if (!instantiates || !part.isTypeReference()) {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
if (!targets.has(part.id)) {
|
|
216
|
+
targets.set(part.id, classOfType.get(part.getTarget().id));
|
|
217
|
+
}
|
|
218
|
+
const generic = targets.get(part.id);
|
|
219
|
+
return generic === undefined ? [] : [{ owner: generic, type: part }];
|
|
220
|
+
});
|
|
221
|
+
const interfaceNames = new Map();
|
|
222
|
+
/** The interface types one position's type is or holds, each with the interface's name. */
|
|
223
|
+
const interfacesOf = (type) => partsOf(type).flatMap((part) => {
|
|
224
|
+
if (!interfaceNames.has(part.id)) {
|
|
225
|
+
const symbol = part.isObjectType() ? part.getSymbol() : undefined;
|
|
226
|
+
interfaceNames.set(part.id, symbol !== undefined && (symbol.flags & SymbolFlags.Interface) !== 0
|
|
227
|
+
? symbol.name
|
|
228
|
+
: undefined);
|
|
229
|
+
}
|
|
230
|
+
const name = interfaceNames.get(part.id);
|
|
231
|
+
return name === undefined ? [] : [{ type: part, name }];
|
|
232
|
+
});
|
|
233
|
+
const pairs = new Map();
|
|
234
|
+
const reach = (owner, source, target, name, site) => {
|
|
235
|
+
const key = `${String(source.id)}:${String(target.id)}`;
|
|
236
|
+
const pair = pairs.get(key);
|
|
237
|
+
if (pair === undefined) {
|
|
238
|
+
pairs.set(key, { owner, source, target, name, sites: [site] });
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
pair.sites.push(site);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
for (const clause of clauses) {
|
|
245
|
+
const source = instanceOf.get(clause.owner.id);
|
|
246
|
+
const target = typeAt.get(clause.entry);
|
|
247
|
+
if (source === undefined || target === undefined) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
for (const reached of interfacesOf(target)) {
|
|
251
|
+
reach(clause.owner, source, reached.type, reached.name, clause.entry);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
for (const flow of flows) {
|
|
255
|
+
const self = flow.self === undefined ? undefined : instanceOf.get(flow.self.id);
|
|
256
|
+
const valueType = typeAt.get(flow.value);
|
|
257
|
+
const sources = flow.self !== undefined && self !== undefined
|
|
258
|
+
? [{ owner: flow.self, type: self }]
|
|
259
|
+
: valueType === undefined
|
|
260
|
+
? []
|
|
261
|
+
: classesOf(valueType);
|
|
262
|
+
if (sources.length === 0) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const position = checker.getContextualType(flow.value);
|
|
266
|
+
if (position === undefined) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
for (const reached of interfacesOf(position)) {
|
|
270
|
+
for (const source of sources) {
|
|
271
|
+
reach(source.owner, source.type, reached.type, reached.name, flow.value);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const chains = aliasChains(project, held);
|
|
276
|
+
const required = new Map();
|
|
277
|
+
const members = new Map();
|
|
278
|
+
const found = [];
|
|
279
|
+
for (const pair of pairs.values()) {
|
|
280
|
+
if (!checker.isTypeAssignableTo(pair.source, pair.target)) {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
let names = required.get(pair.target.id);
|
|
284
|
+
if (names === undefined) {
|
|
285
|
+
names = new Set(checker.getPropertiesOfType(pair.target).map((property) => property.escapedName));
|
|
286
|
+
required.set(pair.target.id, names);
|
|
287
|
+
}
|
|
288
|
+
let answering = members.get(pair.owner.id);
|
|
289
|
+
if (answering === undefined) {
|
|
290
|
+
const instance = instanceOf.get(pair.owner.id) ?? pair.source;
|
|
291
|
+
answering = new Map(checker
|
|
292
|
+
.getPropertiesOfType(instance)
|
|
293
|
+
.map((property) => [property.escapedName, chains.declarationsOf(property)]));
|
|
294
|
+
members.set(pair.owner.id, answering);
|
|
295
|
+
}
|
|
296
|
+
const detail = `satisfies ${pair.name}`;
|
|
297
|
+
for (const name of names) {
|
|
298
|
+
for (const id of answering.get(name) ?? []) {
|
|
299
|
+
for (const site of pair.sites) {
|
|
300
|
+
found.push({
|
|
301
|
+
id,
|
|
302
|
+
detail,
|
|
303
|
+
site: renderPosition(site.getSourceFile(), targetRoot, site.getStart()),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return found;
|
|
310
|
+
}
|