@cplieger/deadset-ts 3.1.0 → 3.3.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/README.md +3 -3
- package/dist/src/analysis.js +39 -13
- package/dist/src/decorator.js +64 -0
- package/dist/src/exempt-classes.js +83 -0
- package/dist/src/exempt.js +152 -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 +33 -1
- package/dist/src/sweep.js +35 -25
- package/dist/src/template-field.js +173 -0
- package/dist/src/version.js +1 -1
- package/jsr.json +1 -1
- package/package.json +6 -6
- package/src/analysis.ts +59 -14
- package/src/decorator.ts +74 -0
- package/src/exempt-classes.ts +132 -0
- package/src/exempt.ts +226 -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 +39 -1
- package/src/sweep.ts +60 -25
- package/src/template-field.ts +214 -0
- package/src/version.ts +1 -1
package/src/exempt.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exemption framework: the classes of reason for which a declaration the reference
|
|
3
|
+
* graph alone would report is live all the same. A class is policy over what one
|
|
4
|
+
* project's checker answers, and its detector names the declarations it holds back;
|
|
5
|
+
* this module runs the detectors, decides which of their records stand, and answers
|
|
6
|
+
* which records held something back. It names no class's detector: the composition
|
|
7
|
+
* hands it the table of the classes it has.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Config } from "./config.ts";
|
|
11
|
+
import { ConfigError } from "./config.ts";
|
|
12
|
+
import {
|
|
13
|
+
EXEMPTION_CLASSES,
|
|
14
|
+
isExemptionClass,
|
|
15
|
+
TS_EXEMPTION_CLASSES,
|
|
16
|
+
typescriptVisibilityOf,
|
|
17
|
+
type ExemptionClass,
|
|
18
|
+
type TSExemptionClass,
|
|
19
|
+
type TypeScriptVisibility,
|
|
20
|
+
} from "./exempt-classes.ts";
|
|
21
|
+
import type { Inventory, InventorySymbol, Visibility } from "./inventory.ts";
|
|
22
|
+
import type { Matrix } from "./matrix.ts";
|
|
23
|
+
import { byPosition, positionKey, type Position } from "./position.ts";
|
|
24
|
+
import type { ProjectView } from "./session.ts";
|
|
25
|
+
import type { Templates } from "./template-field.ts";
|
|
26
|
+
import { sweep, type Exemption, type Mode, type SweepInput } from "./sweep.ts";
|
|
27
|
+
|
|
28
|
+
/** One declaration a detector holds back, and where and why. */
|
|
29
|
+
export interface Evidence {
|
|
30
|
+
/** The identifier of the declaration held back. */
|
|
31
|
+
readonly id: string;
|
|
32
|
+
/** One clause naming the relation, then the thing it relates to. */
|
|
33
|
+
readonly detail: string;
|
|
34
|
+
readonly site: Position;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What a detector reads of one project, while the project's view is open. */
|
|
38
|
+
export interface DetectorInput<Brand> {
|
|
39
|
+
readonly project: ProjectView<Brand>;
|
|
40
|
+
readonly held: Inventory;
|
|
41
|
+
readonly targetRoot: string;
|
|
42
|
+
/** The configured templates, read once for the run. */
|
|
43
|
+
readonly templates: Templates;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One class's detection over one project. The framework records each piece of evidence
|
|
48
|
+
* under the class the table names the detector by, so a detector names no class.
|
|
49
|
+
*/
|
|
50
|
+
export type Detector = <Brand>(input: DetectorInput<Brand>) => readonly Evidence[];
|
|
51
|
+
|
|
52
|
+
/** The detector of each class one composition implements. */
|
|
53
|
+
export type Detectors = ReadonlyMap<TSExemptionClass, Detector>;
|
|
54
|
+
|
|
55
|
+
/** What decides which records of one project stand. */
|
|
56
|
+
export interface Holding {
|
|
57
|
+
/** The classes the configuration switches off, whose detectors do not run. */
|
|
58
|
+
readonly disabled: ReadonlySet<ExemptionClass>;
|
|
59
|
+
readonly mode: Mode;
|
|
60
|
+
/** The paths the reference pass classified as test files. */
|
|
61
|
+
readonly testFiles: ReadonlySet<string>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The classes `exemptions.disabled` switches off. A name is refused unless the
|
|
66
|
+
* vocabulary holds it; a class that runs on another language only is admitted and
|
|
67
|
+
* switches nothing off here, so one configuration serves every analyzer.
|
|
68
|
+
*/
|
|
69
|
+
export function disabledClasses(config: Config): ReadonlySet<ExemptionClass> {
|
|
70
|
+
const disabled = new Set<ExemptionClass>();
|
|
71
|
+
for (const name of config.exemptionsDisabled) {
|
|
72
|
+
if (!isExemptionClass(name)) {
|
|
73
|
+
const known = EXEMPTION_CLASSES.map((row) => row.class).join(", ");
|
|
74
|
+
throw new ConfigError(
|
|
75
|
+
"malformed",
|
|
76
|
+
"exemptions.disabled",
|
|
77
|
+
`exemptions.disabled: ${JSON.stringify(name)} is not an exemption class: the classes are ${known}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
disabled.add(name);
|
|
81
|
+
}
|
|
82
|
+
return disabled;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether a class whose row states `allowed` may retain a member of one visibility. A
|
|
87
|
+
* declaration the inventory does not hold has no visibility to refuse it by.
|
|
88
|
+
*/
|
|
89
|
+
function retainable(allowed: TypeScriptVisibility, visibility: Visibility | undefined): boolean {
|
|
90
|
+
if (visibility === "private") {
|
|
91
|
+
return allowed.private;
|
|
92
|
+
}
|
|
93
|
+
if (visibility === "private-name") {
|
|
94
|
+
return allowed.privateName;
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The records one project's detectors found, each class in the vocabulary's order and
|
|
101
|
+
* none of a switched-off class. Evidence on a private member the class's row says it
|
|
102
|
+
* cannot retain is dropped first, whatever the detector found. Under a production mode
|
|
103
|
+
* a record whose evidence is written in a test file holds nothing, as a reference a
|
|
104
|
+
* test file makes is none there.
|
|
105
|
+
*/
|
|
106
|
+
export function computeExemptions<Brand>(
|
|
107
|
+
input: DetectorInput<Brand>,
|
|
108
|
+
detectors: Detectors,
|
|
109
|
+
holding: Holding,
|
|
110
|
+
): readonly Exemption[] {
|
|
111
|
+
const visibility = new Map(input.held.symbols.map((symbol) => [symbol.id, symbol.visibility]));
|
|
112
|
+
const found: Exemption[] = [];
|
|
113
|
+
for (const exemptionClass of TS_EXEMPTION_CLASSES) {
|
|
114
|
+
const detect = detectors.get(exemptionClass);
|
|
115
|
+
if (detect === undefined || holding.disabled.has(exemptionClass)) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const allowed = typescriptVisibilityOf(exemptionClass);
|
|
119
|
+
for (const evidence of detect(input)) {
|
|
120
|
+
if (!retainable(allowed, visibility.get(evidence.id))) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (holding.mode.production && holding.testFiles.has(evidence.site.path)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
found.push({
|
|
127
|
+
id: evidence.id,
|
|
128
|
+
class: exemptionClass,
|
|
129
|
+
detail: evidence.detail,
|
|
130
|
+
site: evidence.site,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return exemptionsOf(found);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Two strings ordered bytewise. */
|
|
138
|
+
function compare(a: string, b: string): number {
|
|
139
|
+
if (a === b) {
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
return a < b ? -1 : 1;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Records as a run holds them: ordered by site, then class, then declaration, and one
|
|
147
|
+
* per declaration, class and detail, the one at the first site. A fact found at many
|
|
148
|
+
* sites, or by several projects, is one record.
|
|
149
|
+
*/
|
|
150
|
+
export function exemptionsOf(found: readonly Exemption[]): readonly Exemption[] {
|
|
151
|
+
const ordered = [...found].sort(
|
|
152
|
+
(a, b) => byPosition(a.site, b.site) || compare(a.class, b.class) || compare(a.id, b.id),
|
|
153
|
+
);
|
|
154
|
+
const seen = new Set<string>();
|
|
155
|
+
return ordered.filter((record) => {
|
|
156
|
+
const fact = [record.id, record.class, record.detail].join("\u0000");
|
|
157
|
+
if (seen.has(fact)) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
seen.add(fact);
|
|
161
|
+
return true;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The records per declaration, each declaration's in the order given. */
|
|
166
|
+
function byDeclaration(records: readonly Exemption[]): ReadonlyMap<string, readonly Exemption[]> {
|
|
167
|
+
const grouped = new Map<string, Exemption[]>();
|
|
168
|
+
for (const record of records) {
|
|
169
|
+
const held = grouped.get(record.id);
|
|
170
|
+
if (held === undefined) {
|
|
171
|
+
grouped.set(record.id, [record]);
|
|
172
|
+
} else {
|
|
173
|
+
held.push(record);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return grouped;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The records that held back a declaration some configuration of the run would
|
|
181
|
+
* otherwise judge dead: each configuration's graph is swept once more with no
|
|
182
|
+
* exemption and the marks it was given, and a record is retained when one of those
|
|
183
|
+
* sweeps judged its declaration a candidate. A record that holds a declaration back in
|
|
184
|
+
* one configuration is retained whatever another configuration answers about it, as a
|
|
185
|
+
* run of that configuration alone would report the declaration without it. A record
|
|
186
|
+
* on a declaration that a relation or a mark holds live in every configuration held
|
|
187
|
+
* nothing back. The order is the declarations' site order, and for one declaration
|
|
188
|
+
* the input's.
|
|
189
|
+
*/
|
|
190
|
+
export function retainedIn(matrix: Matrix, input: SweepInput): readonly Exemption[] {
|
|
191
|
+
const exempt = input.exempt ?? [];
|
|
192
|
+
if (exempt.length === 0) {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
const bare: SweepInput = { marked: input.marked, mode: input.mode };
|
|
196
|
+
const dead = new Set(
|
|
197
|
+
matrix.graphs.flatMap((graph) =>
|
|
198
|
+
sweep(graph, bare).candidates.map((candidate) => candidate.id),
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
const held = byDeclaration(exempt.filter((record) => dead.has(record.id)));
|
|
202
|
+
return matrix.union.symbols.flatMap((symbol) => held.get(symbol.id) ?? []);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* One line per held-back declaration, in site order: its reference, then each record's
|
|
207
|
+
* class, site and detail, all separated by tabs.
|
|
208
|
+
*/
|
|
209
|
+
export function retainedLines(
|
|
210
|
+
symbols: readonly InventorySymbol[],
|
|
211
|
+
retained: readonly Exemption[],
|
|
212
|
+
): readonly string[] {
|
|
213
|
+
const held = byDeclaration(retained);
|
|
214
|
+
return symbols.flatMap((symbol) => {
|
|
215
|
+
const records = held.get(symbol.id);
|
|
216
|
+
if (records === undefined) {
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
const fields = records.flatMap((record) => [
|
|
220
|
+
record.class,
|
|
221
|
+
positionKey(record.site),
|
|
222
|
+
record.detail,
|
|
223
|
+
]);
|
|
224
|
+
return [[symbol.ref, ...fields].join("\t")];
|
|
225
|
+
});
|
|
226
|
+
}
|
|
@@ -0,0 +1,417 @@
|
|
|
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
|
+
|
|
16
|
+
import {
|
|
17
|
+
isArrayLiteralExpression,
|
|
18
|
+
isArrowFunction,
|
|
19
|
+
isAsExpression,
|
|
20
|
+
isBinaryExpression,
|
|
21
|
+
isCallExpression,
|
|
22
|
+
isClassDeclaration,
|
|
23
|
+
isClassExpression,
|
|
24
|
+
isClassStaticBlockDeclaration,
|
|
25
|
+
isConditionalExpression,
|
|
26
|
+
isFunctionDeclaration,
|
|
27
|
+
isFunctionExpression,
|
|
28
|
+
isGetAccessorDeclaration,
|
|
29
|
+
isHeritageClause,
|
|
30
|
+
isIdentifier,
|
|
31
|
+
isMethodDeclaration,
|
|
32
|
+
isNewExpression,
|
|
33
|
+
isObjectLiteralExpression,
|
|
34
|
+
isParameterDeclaration,
|
|
35
|
+
isParenthesizedExpression,
|
|
36
|
+
isPropertyAssignment,
|
|
37
|
+
isPropertyDeclaration,
|
|
38
|
+
isReturnStatement,
|
|
39
|
+
isSatisfiesExpression,
|
|
40
|
+
isSetAccessorDeclaration,
|
|
41
|
+
isShorthandPropertyAssignment,
|
|
42
|
+
isSpreadElement,
|
|
43
|
+
isVariableDeclaration,
|
|
44
|
+
ModifierFlags,
|
|
45
|
+
SyntaxKind,
|
|
46
|
+
type Expression,
|
|
47
|
+
type Node,
|
|
48
|
+
type SourceFile,
|
|
49
|
+
} from "@typescript/native/unstable/ast";
|
|
50
|
+
import { SymbolFlags, type Type } from "@typescript/native/unstable/sync";
|
|
51
|
+
import { aliasChains } from "./alias-chain.ts";
|
|
52
|
+
import type { DetectorInput, Evidence } from "./exempt.ts";
|
|
53
|
+
import { nodeKey } from "./inventory.ts";
|
|
54
|
+
import { renderPosition } from "./position.ts";
|
|
55
|
+
import { DEFAULT_BATCH_CAP } from "./references.ts";
|
|
56
|
+
|
|
57
|
+
/** One class the inventory holds, by its declaration. */
|
|
58
|
+
interface ClassAt {
|
|
59
|
+
readonly id: string;
|
|
60
|
+
/** The name node, whose type is the class's instance type. */
|
|
61
|
+
readonly name: Node;
|
|
62
|
+
/** Whether the class declares type parameters, so its values carry instantiations of it. */
|
|
63
|
+
readonly generic: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One value that flows into a position the checker types from its context. */
|
|
67
|
+
interface Flow {
|
|
68
|
+
readonly value: Expression;
|
|
69
|
+
/** The class `this` stands for, where the value is `this`. */
|
|
70
|
+
readonly self: ClassAt | undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One entry of a class's `implements` clause. */
|
|
74
|
+
interface Clause {
|
|
75
|
+
readonly owner: ClassAt;
|
|
76
|
+
readonly entry: Node;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One class type reaching one interface type, and the places it does. */
|
|
80
|
+
interface Pair {
|
|
81
|
+
readonly owner: ClassAt;
|
|
82
|
+
readonly source: Type;
|
|
83
|
+
readonly target: Type;
|
|
84
|
+
readonly name: string;
|
|
85
|
+
readonly sites: Node[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The operators of an assignment whose right side is stored into the left side's type. */
|
|
89
|
+
const STORES: ReadonlySet<SyntaxKind> = new Set([
|
|
90
|
+
SyntaxKind.EqualsToken,
|
|
91
|
+
SyntaxKind.QuestionQuestionEqualsToken,
|
|
92
|
+
SyntaxKind.BarBarEqualsToken,
|
|
93
|
+
SyntaxKind.AmpersandAmpersandEqualsToken,
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
/** Whether one node declares a static member, whose `this` is the class itself. */
|
|
97
|
+
function isStatic(node: Node): boolean {
|
|
98
|
+
const flags = (node as { readonly modifierFlags?: number }).modifierFlags ?? 0;
|
|
99
|
+
return (flags & ModifierFlags.Static) !== 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Whether one node is a method or an accessor of an object literal, whose `this` is the literal. */
|
|
103
|
+
function isObjectLiteralMethod(node: Node): boolean {
|
|
104
|
+
return (
|
|
105
|
+
(isMethodDeclaration(node) ||
|
|
106
|
+
isGetAccessorDeclaration(node) ||
|
|
107
|
+
isSetAccessorDeclaration(node)) &&
|
|
108
|
+
isObjectLiteralExpression(node.parent)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether a declaration writes its type, so its initializer is read in that type's context. */
|
|
113
|
+
function writesType(node: Node): boolean {
|
|
114
|
+
return (node as { readonly type?: Node }).type !== undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The values one written expression hands on: the expression itself, or what it
|
|
119
|
+
* evaluates to through parentheses, a conditional and the operators whose result is
|
|
120
|
+
* one of their operands.
|
|
121
|
+
*/
|
|
122
|
+
function valuesOf(expression: Expression): readonly Expression[] {
|
|
123
|
+
if (isParenthesizedExpression(expression)) {
|
|
124
|
+
return valuesOf(expression.expression);
|
|
125
|
+
}
|
|
126
|
+
if (isConditionalExpression(expression)) {
|
|
127
|
+
return [...valuesOf(expression.whenTrue), ...valuesOf(expression.whenFalse)];
|
|
128
|
+
}
|
|
129
|
+
if (isBinaryExpression(expression)) {
|
|
130
|
+
const operator = expression.operatorToken.kind;
|
|
131
|
+
if (operator === SyntaxKind.QuestionQuestionToken || operator === SyntaxKind.BarBarToken) {
|
|
132
|
+
return [...valuesOf(expression.left), ...valuesOf(expression.right)];
|
|
133
|
+
}
|
|
134
|
+
if (operator === SyntaxKind.AmpersandAmpersandToken || operator === SyntaxKind.CommaToken) {
|
|
135
|
+
return valuesOf(expression.right);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return [expression];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The expressions one node hands to a position typed by its context. */
|
|
142
|
+
function handedOn(node: Node): readonly Expression[] {
|
|
143
|
+
if (
|
|
144
|
+
(isVariableDeclaration(node) || isPropertyDeclaration(node) || isParameterDeclaration(node)) &&
|
|
145
|
+
node.initializer !== undefined &&
|
|
146
|
+
writesType(node)
|
|
147
|
+
) {
|
|
148
|
+
return [node.initializer];
|
|
149
|
+
}
|
|
150
|
+
if (isBinaryExpression(node) && STORES.has(node.operatorToken.kind)) {
|
|
151
|
+
return [node.right];
|
|
152
|
+
}
|
|
153
|
+
if (isCallExpression(node) || isNewExpression(node)) {
|
|
154
|
+
return (node.arguments ?? []).filter((argument) => !isSpreadElement(argument));
|
|
155
|
+
}
|
|
156
|
+
if (isReturnStatement(node)) {
|
|
157
|
+
return node.expression === undefined ? [] : [node.expression];
|
|
158
|
+
}
|
|
159
|
+
if (isArrowFunction(node)) {
|
|
160
|
+
return node.body.kind === SyntaxKind.Block ? [] : [node.body];
|
|
161
|
+
}
|
|
162
|
+
if (isArrayLiteralExpression(node)) {
|
|
163
|
+
return node.elements.filter(
|
|
164
|
+
(element) => !isSpreadElement(element) && element.kind !== SyntaxKind.OmittedExpression,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (isPropertyAssignment(node)) {
|
|
168
|
+
return [node.initializer];
|
|
169
|
+
}
|
|
170
|
+
if (isShorthandPropertyAssignment(node)) {
|
|
171
|
+
return isIdentifier(node.name) ? [node.name] : [];
|
|
172
|
+
}
|
|
173
|
+
if (isAsExpression(node) || isSatisfiesExpression(node)) {
|
|
174
|
+
return [node.expression];
|
|
175
|
+
}
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** What one project's walk found to resolve. */
|
|
180
|
+
interface Walked {
|
|
181
|
+
readonly classes: readonly ClassAt[];
|
|
182
|
+
readonly clauses: readonly Clause[];
|
|
183
|
+
readonly flows: readonly Flow[];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The classes, the `implements` entries and the flowing values of one project's own
|
|
188
|
+
* files. `this` is the class an instance member belongs to, so a function that is not
|
|
189
|
+
* an arrow, a static member and a static block each stop it.
|
|
190
|
+
*/
|
|
191
|
+
function walk(files: readonly SourceFile[], declarations: ReadonlyMap<string, string>): Walked {
|
|
192
|
+
const classes: ClassAt[] = [];
|
|
193
|
+
const clauses: Clause[] = [];
|
|
194
|
+
const flows: Flow[] = [];
|
|
195
|
+
for (const file of files) {
|
|
196
|
+
const visit = (node: Node, self: ClassAt | undefined): void => {
|
|
197
|
+
let inner = self;
|
|
198
|
+
if (isClassDeclaration(node) || isClassExpression(node)) {
|
|
199
|
+
const id = declarations.get(nodeKey(file, node));
|
|
200
|
+
const name = node.name;
|
|
201
|
+
const owner =
|
|
202
|
+
id === undefined || name === undefined
|
|
203
|
+
? undefined
|
|
204
|
+
: { id, name, generic: node.typeParameters !== undefined };
|
|
205
|
+
if (owner !== undefined) {
|
|
206
|
+
classes.push(owner);
|
|
207
|
+
for (const clause of node.heritageClauses ?? []) {
|
|
208
|
+
if (isHeritageClause(clause) && clause.token === SyntaxKind.ImplementsKeyword) {
|
|
209
|
+
clauses.push(...clause.types.map((entry) => ({ owner, entry })));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
inner = owner;
|
|
214
|
+
} else if (
|
|
215
|
+
isFunctionDeclaration(node) ||
|
|
216
|
+
isFunctionExpression(node) ||
|
|
217
|
+
isClassStaticBlockDeclaration(node) ||
|
|
218
|
+
isStatic(node) ||
|
|
219
|
+
isObjectLiteralMethod(node)
|
|
220
|
+
) {
|
|
221
|
+
inner = undefined;
|
|
222
|
+
}
|
|
223
|
+
for (const written of handedOn(node)) {
|
|
224
|
+
for (const value of valuesOf(written)) {
|
|
225
|
+
flows.push({
|
|
226
|
+
value,
|
|
227
|
+
self: value.kind === SyntaxKind.ThisKeyword ? inner : undefined,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
node.forEachChild((child) => {
|
|
232
|
+
visit(child, inner);
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
file.forEachChild((child) => {
|
|
236
|
+
visit(child, undefined);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return { classes, clauses, flows };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The interface-satisfaction detector: for each class type that reaches an interface
|
|
244
|
+
* type it is assignable to, the class's members whose names the interface's members
|
|
245
|
+
* carry, each recorded at the place the value flows.
|
|
246
|
+
*/
|
|
247
|
+
export function interfaceSatisfaction<Brand>(input: DetectorInput<Brand>): readonly Evidence[] {
|
|
248
|
+
const { project, held, targetRoot } = input;
|
|
249
|
+
const checker = project.checker;
|
|
250
|
+
const files = project.ownSourceFiles();
|
|
251
|
+
const { classes, clauses, flows } = walk(files, held.declarations);
|
|
252
|
+
if (classes.length === 0) {
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// One list, so the batches are capped runs of the whole project rather than of a
|
|
257
|
+
// file: a class's name, an `implements` entry and a value are each one question.
|
|
258
|
+
const asked = [
|
|
259
|
+
...classes.map((owner) => owner.name),
|
|
260
|
+
...clauses.map((clause) => clause.entry),
|
|
261
|
+
...flows.filter((flow) => flow.self === undefined).map((flow) => flow.value),
|
|
262
|
+
];
|
|
263
|
+
const typeAt = new Map<Node, Type>();
|
|
264
|
+
for (let from = 0; from < asked.length; from += DEFAULT_BATCH_CAP) {
|
|
265
|
+
const run = asked.slice(from, from + DEFAULT_BATCH_CAP);
|
|
266
|
+
checker.getTypeAtLocation(run).forEach((type, index) => {
|
|
267
|
+
const node = run[index];
|
|
268
|
+
if (type !== undefined && node !== undefined) {
|
|
269
|
+
typeAt.set(node, type);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const classOfType = new Map<number, ClassAt>();
|
|
275
|
+
const instanceOf = new Map<string, Type>();
|
|
276
|
+
for (const owner of classes) {
|
|
277
|
+
const type = typeAt.get(owner.name);
|
|
278
|
+
if (type !== undefined) {
|
|
279
|
+
classOfType.set(type.id, owner);
|
|
280
|
+
instanceOf.set(owner.id, type);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const instantiates = classes.some((owner) => owner.generic);
|
|
284
|
+
|
|
285
|
+
const constituents = new Map<number, readonly Type[]>();
|
|
286
|
+
const partsOf = (type: Type): readonly Type[] => {
|
|
287
|
+
if (!type.isUnionType() && !type.isIntersectionType()) {
|
|
288
|
+
return [type];
|
|
289
|
+
}
|
|
290
|
+
let parts = constituents.get(type.id);
|
|
291
|
+
if (parts === undefined) {
|
|
292
|
+
parts = type.getTypes();
|
|
293
|
+
constituents.set(type.id, parts);
|
|
294
|
+
}
|
|
295
|
+
return parts;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const targets = new Map<number, ClassAt | undefined>();
|
|
299
|
+
/** The classes of the target one value's type is an instance of, with the instance type. */
|
|
300
|
+
const classesOf = (type: Type): readonly { owner: ClassAt; type: Type }[] =>
|
|
301
|
+
partsOf(type).flatMap((part) => {
|
|
302
|
+
const direct = classOfType.get(part.id);
|
|
303
|
+
if (direct !== undefined) {
|
|
304
|
+
return [{ owner: direct, type: part }];
|
|
305
|
+
}
|
|
306
|
+
if (!instantiates || !part.isTypeReference()) {
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
if (!targets.has(part.id)) {
|
|
310
|
+
targets.set(part.id, classOfType.get(part.getTarget().id));
|
|
311
|
+
}
|
|
312
|
+
const generic = targets.get(part.id);
|
|
313
|
+
return generic === undefined ? [] : [{ owner: generic, type: part }];
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const interfaceNames = new Map<number, string | undefined>();
|
|
317
|
+
/** The interface types one position's type is or holds, each with the interface's name. */
|
|
318
|
+
const interfacesOf = (type: Type): readonly { type: Type; name: string }[] =>
|
|
319
|
+
partsOf(type).flatMap((part) => {
|
|
320
|
+
if (!interfaceNames.has(part.id)) {
|
|
321
|
+
const symbol = part.isObjectType() ? part.getSymbol() : undefined;
|
|
322
|
+
interfaceNames.set(
|
|
323
|
+
part.id,
|
|
324
|
+
symbol !== undefined && (symbol.flags & SymbolFlags.Interface) !== 0
|
|
325
|
+
? symbol.name
|
|
326
|
+
: undefined,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
const name = interfaceNames.get(part.id);
|
|
330
|
+
return name === undefined ? [] : [{ type: part, name }];
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const pairs = new Map<string, Pair>();
|
|
334
|
+
const reach = (owner: ClassAt, source: Type, target: Type, name: string, site: Node): void => {
|
|
335
|
+
const key = `${String(source.id)}:${String(target.id)}`;
|
|
336
|
+
const pair = pairs.get(key);
|
|
337
|
+
if (pair === undefined) {
|
|
338
|
+
pairs.set(key, { owner, source, target, name, sites: [site] });
|
|
339
|
+
} else {
|
|
340
|
+
pair.sites.push(site);
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
for (const clause of clauses) {
|
|
345
|
+
const source = instanceOf.get(clause.owner.id);
|
|
346
|
+
const target = typeAt.get(clause.entry);
|
|
347
|
+
if (source === undefined || target === undefined) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
for (const reached of interfacesOf(target)) {
|
|
351
|
+
reach(clause.owner, source, reached.type, reached.name, clause.entry);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const flow of flows) {
|
|
356
|
+
const self = flow.self === undefined ? undefined : instanceOf.get(flow.self.id);
|
|
357
|
+
const valueType = typeAt.get(flow.value);
|
|
358
|
+
const sources =
|
|
359
|
+
flow.self !== undefined && self !== undefined
|
|
360
|
+
? [{ owner: flow.self, type: self }]
|
|
361
|
+
: valueType === undefined
|
|
362
|
+
? []
|
|
363
|
+
: classesOf(valueType);
|
|
364
|
+
if (sources.length === 0) {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
const position = checker.getContextualType(flow.value);
|
|
368
|
+
if (position === undefined) {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
for (const reached of interfacesOf(position)) {
|
|
372
|
+
for (const source of sources) {
|
|
373
|
+
reach(source.owner, source.type, reached.type, reached.name, flow.value);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const chains = aliasChains(project, held);
|
|
379
|
+
const required = new Map<number, ReadonlySet<string>>();
|
|
380
|
+
const members = new Map<string, ReadonlyMap<string, readonly string[]>>();
|
|
381
|
+
const found: Evidence[] = [];
|
|
382
|
+
for (const pair of pairs.values()) {
|
|
383
|
+
if (!checker.isTypeAssignableTo(pair.source, pair.target)) {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
let names = required.get(pair.target.id);
|
|
387
|
+
if (names === undefined) {
|
|
388
|
+
names = new Set(
|
|
389
|
+
checker.getPropertiesOfType(pair.target).map((property) => property.escapedName),
|
|
390
|
+
);
|
|
391
|
+
required.set(pair.target.id, names);
|
|
392
|
+
}
|
|
393
|
+
let answering = members.get(pair.owner.id);
|
|
394
|
+
if (answering === undefined) {
|
|
395
|
+
const instance = instanceOf.get(pair.owner.id) ?? pair.source;
|
|
396
|
+
answering = new Map(
|
|
397
|
+
checker
|
|
398
|
+
.getPropertiesOfType(instance)
|
|
399
|
+
.map((property) => [property.escapedName, chains.declarationsOf(property)]),
|
|
400
|
+
);
|
|
401
|
+
members.set(pair.owner.id, answering);
|
|
402
|
+
}
|
|
403
|
+
const detail = `satisfies ${pair.name}`;
|
|
404
|
+
for (const name of names) {
|
|
405
|
+
for (const id of answering.get(name) ?? []) {
|
|
406
|
+
for (const site of pair.sites) {
|
|
407
|
+
found.push({
|
|
408
|
+
id,
|
|
409
|
+
detail,
|
|
410
|
+
site: renderPosition(site.getSourceFile(), targetRoot, site.getStart()),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return found;
|
|
417
|
+
}
|