@cplieger/deadset-ts 3.5.0 → 3.7.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/dist/src/analysis.js +70 -14
- package/dist/src/components.js +5 -1
- package/dist/src/dependencies.js +493 -0
- package/dist/src/deprecation.js +83 -0
- package/dist/src/discover.js +1 -1
- package/dist/src/enum-group.js +217 -0
- package/dist/src/file-facts.js +101 -0
- package/dist/src/finding.js +0 -11
- package/dist/src/findings/boundary.js +1 -0
- package/dist/src/findings/completion.js +135 -0
- package/dist/src/findings/dependencies-and-module-machinery.js +57 -1
- package/dist/src/findings/emitters.js +10 -0
- package/dist/src/findings/interfaces.js +95 -2
- package/dist/src/findings/non-code-artifacts.js +97 -1
- package/dist/src/findings/reads-and-writes.js +146 -2
- package/dist/src/findings/unused-declarations.js +150 -1
- package/dist/src/findings/visibility-narrowing.js +239 -2
- package/dist/src/implementations.js +210 -0
- package/dist/src/inventory.js +2 -2
- package/dist/src/kinds.js +47 -45
- package/dist/src/resolve.js +1 -1
- package/dist/src/stores.js +32 -0
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/analysis.ts +113 -16
- package/src/components.ts +6 -1
- package/src/dependencies.ts +617 -0
- package/src/deprecation.ts +101 -0
- package/src/discover.ts +1 -1
- package/src/enum-group.ts +265 -0
- package/src/file-facts.ts +143 -0
- package/src/finding.ts +76 -10
- package/src/findings/boundary.ts +39 -0
- package/src/findings/completion.ts +163 -0
- package/src/findings/dependencies-and-module-machinery.ts +64 -1
- package/src/findings/emitter.ts +24 -3
- package/src/findings/emitters.ts +13 -1
- package/src/findings/interfaces.ts +137 -3
- package/src/findings/non-code-artifacts.ts +124 -2
- package/src/findings/reads-and-writes.ts +196 -3
- package/src/findings/unused-declarations.ts +175 -1
- package/src/findings/visibility-narrowing.ts +319 -3
- package/src/implementations.ts +286 -0
- package/src/inventory.ts +2 -2
- package/src/kinds.ts +79 -45
- package/src/resolve.ts +1 -1
- package/src/stores.ts +77 -0
|
@@ -1,4 +1,197 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Finding, FindingDetails, FindingPosition } from "../finding.ts";
|
|
2
|
+
import type { InventorySymbol } from "../inventory.ts";
|
|
3
|
+
import type { MatrixCandidate } from "../matrix.ts";
|
|
4
|
+
import { positionKey } from "../position.ts";
|
|
5
|
+
import type { Emitter, EmitterInput } from "./emitter.ts";
|
|
2
6
|
|
|
3
|
-
/** The
|
|
4
|
-
|
|
7
|
+
/** The codes of this family. */
|
|
8
|
+
type ReadsAndWritesCode = "DS1301" | "DS1302" | "DS1303";
|
|
9
|
+
|
|
10
|
+
/** The containers whose type parameters `DS1303` reports: a function and a class method. */
|
|
11
|
+
const SIGNATURES: ReadonlySet<string> = new Set(["function", "method"]);
|
|
12
|
+
|
|
13
|
+
/** What the family reads of one run, indexed by declaration. */
|
|
14
|
+
interface Run {
|
|
15
|
+
readonly input: EmitterInput;
|
|
16
|
+
readonly symbols: ReadonlyMap<string, InventorySymbol>;
|
|
17
|
+
readonly candidates: ReadonlyMap<string, MatrixCandidate>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function indexed(input: EmitterInput): Run {
|
|
21
|
+
const { matrix, sweep } = input.swept;
|
|
22
|
+
return {
|
|
23
|
+
input,
|
|
24
|
+
symbols: new Map(matrix.union.symbols.map((symbol) => [symbol.id, symbol])),
|
|
25
|
+
candidates: new Map(sweep.candidates.map((candidate) => [candidate.id, candidate])),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Where one declaration is, as a finding states it. */
|
|
30
|
+
function positionOf(symbol: InventorySymbol): FindingPosition {
|
|
31
|
+
return {
|
|
32
|
+
path: symbol.position.path,
|
|
33
|
+
line: symbol.position.line,
|
|
34
|
+
column: symbol.position.column,
|
|
35
|
+
endLine: Math.max(symbol.position.line, symbol.endLine),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The finding one code makes about one declaration. */
|
|
40
|
+
function findingAbout(
|
|
41
|
+
symbol: InventorySymbol,
|
|
42
|
+
code: ReadsAndWritesCode,
|
|
43
|
+
message: string,
|
|
44
|
+
details?: FindingDetails,
|
|
45
|
+
): Finding {
|
|
46
|
+
const position = positionOf(symbol);
|
|
47
|
+
return {
|
|
48
|
+
code,
|
|
49
|
+
position,
|
|
50
|
+
symbol: {
|
|
51
|
+
ref: symbol.ref,
|
|
52
|
+
kind: symbol.kind,
|
|
53
|
+
name: symbol.name,
|
|
54
|
+
sizeLines: position.endLine - position.line + 1,
|
|
55
|
+
},
|
|
56
|
+
message,
|
|
57
|
+
...(details === undefined ? {} : { details }),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** How one declaration's counted references divide for the write-only kind. */
|
|
62
|
+
interface Uses {
|
|
63
|
+
readonly writes: FindingPosition[];
|
|
64
|
+
reads: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether one declaration is a subject of the write-only kind: a module-level variable,
|
|
69
|
+
* or a class member that is state rather than code. A getter or a setter is code, so a
|
|
70
|
+
* store into one runs it.
|
|
71
|
+
*/
|
|
72
|
+
function writable(run: Run, symbol: InventorySymbol): boolean {
|
|
73
|
+
if (symbol.kind === "variable") {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
return symbol.kind === "class-member" && !run.input.stores.accessors.has(symbol.id);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `DS1301`: a module-level variable or a class member the counted references store into
|
|
81
|
+
* and never read, named with every position written.
|
|
82
|
+
*
|
|
83
|
+
* The subject is a live declaration: a store is a reference, so the sweep holds a
|
|
84
|
+
* written one live and its candidates are never this kind's. Every reference that is
|
|
85
|
+
* not a store reads, and so does an exemption record naming the declaration.
|
|
86
|
+
*/
|
|
87
|
+
function writeOnlySymbols(run: Run): readonly Finding[] {
|
|
88
|
+
const { stores } = run.input;
|
|
89
|
+
const { matrix } = run.input.swept;
|
|
90
|
+
const uses = new Map<string, Uses>();
|
|
91
|
+
for (const reference of stores.references) {
|
|
92
|
+
let held = uses.get(reference.to);
|
|
93
|
+
if (held === undefined) {
|
|
94
|
+
held = { writes: [], reads: 0 };
|
|
95
|
+
uses.set(reference.to, held);
|
|
96
|
+
}
|
|
97
|
+
if (reference.use !== "write") {
|
|
98
|
+
held.reads += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const at = reference.position;
|
|
102
|
+
const key = positionKey(at);
|
|
103
|
+
if (!held.writes.some((written) => positionKey(written) === key)) {
|
|
104
|
+
held.writes.push({ path: at.path, line: at.line, column: at.column, endLine: at.line });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const found: Finding[] = [];
|
|
109
|
+
for (const symbol of matrix.union.symbols) {
|
|
110
|
+
const held = uses.get(symbol.id);
|
|
111
|
+
if (
|
|
112
|
+
held === undefined ||
|
|
113
|
+
held.writes.length === 0 ||
|
|
114
|
+
held.reads > 0 ||
|
|
115
|
+
!writable(run, symbol) ||
|
|
116
|
+
stores.exempt.has(symbol.id) ||
|
|
117
|
+
run.candidates.has(symbol.id)
|
|
118
|
+
) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const writes = held.writes.length === 1 ? "once" : `at ${String(held.writes.length)} positions`;
|
|
122
|
+
const word = symbol.kind === "variable" ? "variable" : "member";
|
|
123
|
+
found.push(
|
|
124
|
+
findingAbout(symbol, "DS1301", `${word} ${symbol.name} is written ${writes} and never read`, {
|
|
125
|
+
writePositions: held.writes,
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return found;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The candidates no reference of any file names whose container is not itself dead, of
|
|
134
|
+
* one kind. A reference from a test file alone leaves a candidate to the test-only kind,
|
|
135
|
+
* and a member of a dead container falls with it and is reported inside its component.
|
|
136
|
+
*/
|
|
137
|
+
function unnamed(run: Run, kind: InventorySymbol["kind"]): readonly InventorySymbol[] {
|
|
138
|
+
return run.input.swept.sweep.candidates.flatMap((candidate): InventorySymbol[] => {
|
|
139
|
+
const symbol = run.symbols.get(candidate.id);
|
|
140
|
+
if (
|
|
141
|
+
symbol?.kind !== kind ||
|
|
142
|
+
candidate.productionRefs > 0 ||
|
|
143
|
+
candidate.testRefs > 0 ||
|
|
144
|
+
run.candidates.has(symbol.parent)
|
|
145
|
+
) {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
return [symbol];
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* `DS1302`: a member of an enum declaration that nothing names. A member an enum-group
|
|
154
|
+
* record holds back is no candidate, so a member a conversion can produce never reaches
|
|
155
|
+
* this kind.
|
|
156
|
+
*/
|
|
157
|
+
function unusedEnumMembers(run: Run): readonly Finding[] {
|
|
158
|
+
return unnamed(run, "enum-member").map((symbol) =>
|
|
159
|
+
findingAbout(symbol, "DS1302", `enum member ${symbol.name} is named nowhere`),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `DS1303`: a type parameter of a function or a class method that neither the
|
|
165
|
+
* declaration's signature nor its body names. A type parameter of a class, an interface,
|
|
166
|
+
* a type alias or a method signature belongs to a type declaration and is never
|
|
167
|
+
* reported, because a phantom parameter makes two instantiations distinct types.
|
|
168
|
+
*/
|
|
169
|
+
function unusedTypeParameters(run: Run): readonly Finding[] {
|
|
170
|
+
return unnamed(run, "type-parameter")
|
|
171
|
+
.filter((symbol) => SIGNATURES.has(run.symbols.get(symbol.parent)?.kind ?? ""))
|
|
172
|
+
.map((symbol) =>
|
|
173
|
+
findingAbout(
|
|
174
|
+
symbol,
|
|
175
|
+
"DS1303",
|
|
176
|
+
`type parameter ${symbol.name} is named in neither its signature nor its body`,
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The references of the declarations the write-only kind reports, whatever the
|
|
183
|
+
* severity: a narrowing kind yields each to it, since deleting the declaration
|
|
184
|
+
* supersedes narrowing it.
|
|
185
|
+
*/
|
|
186
|
+
export function writeOnlyDeclarations(input: EmitterInput): ReadonlySet<string> {
|
|
187
|
+
return new Set(writeOnlySymbols(indexed(input)).map((finding) => finding.symbol.ref));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The findings of the reads-and-writes family, `DS1300` to `DS1399`, in code order and
|
|
192
|
+
* each code's in site order.
|
|
193
|
+
*/
|
|
194
|
+
export const readsAndWrites: Emitter = (input) => {
|
|
195
|
+
const run = indexed(input);
|
|
196
|
+
return [...writeOnlySymbols(run), ...unusedEnumMembers(run), ...unusedTypeParameters(run)];
|
|
197
|
+
};
|
|
@@ -1,4 +1,178 @@
|
|
|
1
|
+
import type { Finding } from "../finding.ts";
|
|
2
|
+
import type { InventorySymbol, SymbolKind } from "../inventory.ts";
|
|
3
|
+
import type { MatrixCandidate } from "../matrix.ts";
|
|
1
4
|
import type { Emitter } from "./emitter.ts";
|
|
5
|
+
import { unreachableExports } from "./visibility-narrowing.ts";
|
|
6
|
+
|
|
7
|
+
/** The kinds of declaration that are members of a class, an interface, a type or an enum. */
|
|
8
|
+
const MEMBERS: ReadonlySet<SymbolKind> = new Set([
|
|
9
|
+
"method",
|
|
10
|
+
"class-member",
|
|
11
|
+
"type-member",
|
|
12
|
+
"enum-member",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The kinds of declaration no code of this family reports: an interface and a method an
|
|
17
|
+
* interface declares are the interface kinds' subjects, and a type parameter is the
|
|
18
|
+
* subject of the unused-type-parameter kind or of none.
|
|
19
|
+
*/
|
|
20
|
+
const NOT_THIS_FAMILY: ReadonlySet<SymbolKind> = new Set([
|
|
21
|
+
"file",
|
|
22
|
+
"interface",
|
|
23
|
+
"interface-method",
|
|
24
|
+
"type-parameter",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/** The reader's word for each kind of declaration a finding of the family names. */
|
|
28
|
+
const WORDS: Readonly<Record<SymbolKind, string>> = {
|
|
29
|
+
file: "file",
|
|
30
|
+
function: "function",
|
|
31
|
+
class: "class",
|
|
32
|
+
interface: "interface",
|
|
33
|
+
type: "type alias",
|
|
34
|
+
enum: "enum",
|
|
35
|
+
namespace: "namespace",
|
|
36
|
+
variable: "variable",
|
|
37
|
+
"export-alias": "re-export",
|
|
38
|
+
method: "method",
|
|
39
|
+
"class-member": "class member",
|
|
40
|
+
"interface-method": "interface method",
|
|
41
|
+
"type-member": "type member",
|
|
42
|
+
"enum-member": "enum member",
|
|
43
|
+
"type-parameter": "type parameter",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** What the test-of-dead-code kind says, which states the rule it applies. */
|
|
47
|
+
const TEST_OF_DEAD_CODE = "every target symbol this test references is reported dead";
|
|
48
|
+
|
|
49
|
+
/** What the run holds about one candidate that decides its code. */
|
|
50
|
+
interface Judged {
|
|
51
|
+
readonly candidate: MatrixCandidate;
|
|
52
|
+
readonly symbol: InventorySymbol;
|
|
53
|
+
/** Whether a test file declares it. */
|
|
54
|
+
readonly test: boolean;
|
|
55
|
+
/** Whether its container is itself dead, so it falls with the container. */
|
|
56
|
+
readonly containerDead: boolean;
|
|
57
|
+
readonly deprecated: boolean;
|
|
58
|
+
/** Whether the unreachable-export kind reports it. */
|
|
59
|
+
readonly unreachable: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The code one candidate is reported under, or none, each symbol once under the most
|
|
64
|
+
* specific code: a test of dead code first; no other test-file declaration, member of a
|
|
65
|
+
* dead container or enum member nothing names; then deprecation, a test reference, and
|
|
66
|
+
* the unreferenced codes, an unimportable unused export being the unreachable kind's.
|
|
67
|
+
*/
|
|
68
|
+
function codeOf(judged: Judged): string | undefined {
|
|
69
|
+
const { candidate, symbol } = judged;
|
|
70
|
+
if (candidate.testOfDeadCode) {
|
|
71
|
+
return "DS1005";
|
|
72
|
+
}
|
|
73
|
+
if (judged.test || judged.containerDead || NOT_THIS_FAMILY.has(symbol.kind)) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const referenced = candidate.productionRefs + candidate.testRefs > 0;
|
|
77
|
+
if (symbol.kind === "enum-member" && !referenced) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
if (candidate.productionRefs === 0 && judged.deprecated) {
|
|
81
|
+
return "DS1006";
|
|
82
|
+
}
|
|
83
|
+
if (candidate.productionRefs === 0 && candidate.testRefs > 0) {
|
|
84
|
+
return "DS1004";
|
|
85
|
+
}
|
|
86
|
+
if (MEMBERS.has(symbol.kind)) {
|
|
87
|
+
return "DS1003";
|
|
88
|
+
}
|
|
89
|
+
if (!symbol.exported) {
|
|
90
|
+
return "DS1002";
|
|
91
|
+
}
|
|
92
|
+
return judged.unreachable ? undefined : "DS1001";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The visibility a member's message names, where it narrows the member's reach. */
|
|
96
|
+
function visibilityWord(symbol: InventorySymbol): string {
|
|
97
|
+
switch (symbol.visibility) {
|
|
98
|
+
case "private":
|
|
99
|
+
case "private-name":
|
|
100
|
+
return "private ";
|
|
101
|
+
case "protected":
|
|
102
|
+
return "protected ";
|
|
103
|
+
case "public":
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* What one finding says: what the subject is, and what the analysis found about its
|
|
110
|
+
* references. A candidate reachability found has references, each from a declaration
|
|
111
|
+
* that is itself dead, so saying it has none would be false.
|
|
112
|
+
*/
|
|
113
|
+
function messageOf(code: string, judged: Judged): string {
|
|
114
|
+
const { candidate, symbol } = judged;
|
|
115
|
+
const word = WORDS[symbol.kind];
|
|
116
|
+
switch (code) {
|
|
117
|
+
case "DS1005":
|
|
118
|
+
return TEST_OF_DEAD_CODE;
|
|
119
|
+
case "DS1006":
|
|
120
|
+
return `deprecated ${word} has no production reference`;
|
|
121
|
+
case "DS1004":
|
|
122
|
+
return `${word} is referenced only from test files and never from production code`;
|
|
123
|
+
}
|
|
124
|
+
const subject =
|
|
125
|
+
code === "DS1003"
|
|
126
|
+
? `${visibilityWord(symbol)}${word}`
|
|
127
|
+
: symbol.kind === "export-alias"
|
|
128
|
+
? word
|
|
129
|
+
: `${symbol.exported ? "exported" : "unexported"} ${word}`;
|
|
130
|
+
if (candidate.relation === "reachability") {
|
|
131
|
+
return `${subject} is referenced only from declarations that are themselves dead`;
|
|
132
|
+
}
|
|
133
|
+
if (code === "DS1001") {
|
|
134
|
+
return `${subject} has no reference in the target and none from any loaded consumer`;
|
|
135
|
+
}
|
|
136
|
+
return `${subject} has no reference in the target`;
|
|
137
|
+
}
|
|
2
138
|
|
|
3
139
|
/** The findings of the unused-declarations family, `DS1000` to `DS1099`. */
|
|
4
|
-
export const unusedDeclarations: Emitter = () =>
|
|
140
|
+
export const unusedDeclarations: Emitter = (input) => {
|
|
141
|
+
const union = input.swept.matrix.union;
|
|
142
|
+
const dead = new Set(input.swept.sweep.candidates.map((candidate) => candidate.id));
|
|
143
|
+
const unreachable = unreachableExports(input);
|
|
144
|
+
|
|
145
|
+
const found: Finding[] = [];
|
|
146
|
+
for (const candidate of input.swept.sweep.candidates) {
|
|
147
|
+
const at = union.at(candidate.id);
|
|
148
|
+
const symbol = union.symbols[at];
|
|
149
|
+
if (symbol === undefined) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const judged: Judged = {
|
|
153
|
+
candidate,
|
|
154
|
+
symbol,
|
|
155
|
+
test: union.test[at] === true,
|
|
156
|
+
containerDead: dead.has(symbol.parent),
|
|
157
|
+
deprecated: input.deprecated.has(symbol.id),
|
|
158
|
+
unreachable: unreachable.has(symbol.id),
|
|
159
|
+
};
|
|
160
|
+
const code = codeOf(judged);
|
|
161
|
+
if (code === undefined) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const endLine = Math.max(symbol.position.line, symbol.endLine);
|
|
165
|
+
found.push({
|
|
166
|
+
code,
|
|
167
|
+
position: { ...symbol.position, endLine },
|
|
168
|
+
symbol: {
|
|
169
|
+
ref: symbol.ref,
|
|
170
|
+
kind: symbol.kind,
|
|
171
|
+
name: symbol.name,
|
|
172
|
+
sizeLines: endLine - symbol.position.line + 1,
|
|
173
|
+
},
|
|
174
|
+
message: messageOf(code, judged),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return found;
|
|
178
|
+
};
|
|
@@ -1,4 +1,320 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Finding } from "../finding.ts";
|
|
2
|
+
import { OUTSIDE, type Graph } from "../graph.ts";
|
|
3
|
+
import type { InventorySymbol, SymbolKind } from "../inventory.ts";
|
|
4
|
+
import type { MatrixCandidate } from "../matrix.ts";
|
|
5
|
+
import type { RootKind } from "../roots.ts";
|
|
6
|
+
import type { PendingFinding } from "./boundary.ts";
|
|
7
|
+
import type { Emitter, EmitterInput } from "./emitter.ts";
|
|
8
|
+
import { writeOnlyDeclarations } from "./reads-and-writes.ts";
|
|
2
9
|
|
|
3
|
-
|
|
4
|
-
|
|
10
|
+
const UNNECESSARY_EXPORT = "DS1101";
|
|
11
|
+
const UNREACHABLE_EXPORT = "DS1103";
|
|
12
|
+
const REDUNDANT_EXPORT_KEYWORD = "DS1104";
|
|
13
|
+
|
|
14
|
+
/** How far the references to one declaration reach, from the narrowest. */
|
|
15
|
+
type Reach = "file" | "package" | "outside";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The root kinds by which a consumer importing the package reaches a declaration. A
|
|
19
|
+
* declaration or a file rooted by any other kind is entered by something that names it
|
|
20
|
+
* from outside the reference graph: a runtime, a tool, a framework or the configuration.
|
|
21
|
+
*/
|
|
22
|
+
const PUBLISHING: ReadonlySet<RootKind> = new Set<RootKind>(["manifest-entry", "published-api"]);
|
|
23
|
+
|
|
24
|
+
/** The reader's word for each kind of declaration this family reports. */
|
|
25
|
+
const WORDS: Partial<Readonly<Record<SymbolKind, string>>> = {
|
|
26
|
+
function: "function",
|
|
27
|
+
class: "class",
|
|
28
|
+
interface: "interface",
|
|
29
|
+
type: "type alias",
|
|
30
|
+
enum: "enum",
|
|
31
|
+
namespace: "namespace",
|
|
32
|
+
variable: "variable",
|
|
33
|
+
"export-alias": "re-export",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** What the family found: the findings it reports, and those a declared edge holds pending. */
|
|
37
|
+
export interface Narrowings {
|
|
38
|
+
readonly findings: readonly Finding[];
|
|
39
|
+
/** Ordered by edge, then by side. */
|
|
40
|
+
readonly pending: readonly PendingFinding[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Per declaration of the run, the facts both rules read. */
|
|
44
|
+
interface Facts {
|
|
45
|
+
readonly graph: Graph;
|
|
46
|
+
/** The position of the file that holds each declaration. */
|
|
47
|
+
readonly fileOf: readonly number[];
|
|
48
|
+
/** The root kinds naming each declaration. */
|
|
49
|
+
readonly rootedBy: readonly ReadonlySet<RootKind>[];
|
|
50
|
+
/** Per file, whether a manifest export reaches a declaration it holds. */
|
|
51
|
+
readonly published: readonly boolean[];
|
|
52
|
+
/** Per declaration, how many references name it, and how far they reach. */
|
|
53
|
+
readonly named: readonly number[];
|
|
54
|
+
readonly reach: readonly Reach[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function publishes(kinds: ReadonlySet<RootKind> | undefined): boolean {
|
|
58
|
+
return [...(kinds ?? [])].some((kind) => PUBLISHING.has(kind));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function entersByName(kinds: ReadonlySet<RootKind> | undefined): boolean {
|
|
62
|
+
return [...(kinds ?? [])].some((kind) => !PUBLISHING.has(kind));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Folds the run's references into the reach of each declaration.
|
|
67
|
+
*
|
|
68
|
+
* A reference reaches the file that holds the declaration that makes it. A reference
|
|
69
|
+
* from a declaration the inventory does not hold is made by a consumer, and one from a
|
|
70
|
+
* re-export a manifest export reaches stands for every consumer importing the name it
|
|
71
|
+
* publishes; either reaches outside the package.
|
|
72
|
+
*/
|
|
73
|
+
function factsOf(graph: Graph): Facts {
|
|
74
|
+
const { symbols, parent } = graph;
|
|
75
|
+
const fileOf = symbols.map((_symbol, at) => {
|
|
76
|
+
let file = at;
|
|
77
|
+
while (file !== OUTSIDE && symbols[file]?.kind !== "file") {
|
|
78
|
+
file = parent[file] ?? OUTSIDE;
|
|
79
|
+
}
|
|
80
|
+
return file;
|
|
81
|
+
});
|
|
82
|
+
const rootedBy = symbols.map(() => new Set<RootKind>());
|
|
83
|
+
for (const root of graph.rooted) {
|
|
84
|
+
rootedBy[root.at]?.add(root.kind);
|
|
85
|
+
}
|
|
86
|
+
const published = symbols.map(() => false);
|
|
87
|
+
symbols.forEach((_symbol, at) => {
|
|
88
|
+
const file = fileOf[at] ?? OUTSIDE;
|
|
89
|
+
if (file !== OUTSIDE && publishes(rootedBy[at])) {
|
|
90
|
+
published[file] = true;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const order: readonly Reach[] = ["file", "package", "outside"];
|
|
95
|
+
const wider = (a: Reach, b: Reach): Reach => (order.indexOf(a) >= order.indexOf(b) ? a : b);
|
|
96
|
+
const inGraph = symbols.map(() => 0);
|
|
97
|
+
const reach = symbols.map((): Reach => "file");
|
|
98
|
+
graph.out.forEach((edges, from) => {
|
|
99
|
+
const republished = symbols[from]?.kind === "export-alias" && publishes(rootedBy[from]);
|
|
100
|
+
for (const edge of edges) {
|
|
101
|
+
inGraph[edge.to] = (inGraph[edge.to] ?? 0) + 1;
|
|
102
|
+
const scope: Reach = republished
|
|
103
|
+
? "outside"
|
|
104
|
+
: fileOf[from] === fileOf[edge.to]
|
|
105
|
+
? "file"
|
|
106
|
+
: "package";
|
|
107
|
+
reach[edge.to] = wider(reach[edge.to] ?? "file", scope);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
const named = graph.made.map((counts) => counts.production + counts.test);
|
|
111
|
+
named.forEach((count, at) => {
|
|
112
|
+
if (count > (inGraph[at] ?? 0)) {
|
|
113
|
+
reach[at] = "outside";
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
return { graph, fileOf, rootedBy, published, named, reach };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Whether one declaration is a declaration its file's export table names. */
|
|
120
|
+
function topLevelExport(facts: Facts, at: number, symbol: InventorySymbol): boolean {
|
|
121
|
+
const parent = facts.graph.parent[at] ?? OUTSIDE;
|
|
122
|
+
return symbol.exported && parent !== OUTSIDE && facts.graph.symbols[parent]?.kind === "file";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function wordOf(symbol: InventorySymbol): string {
|
|
126
|
+
return WORDS[symbol.kind] ?? symbol.kind;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The visibility each narrowing code names, which only the narrowing codes carry. */
|
|
130
|
+
const NARROWER: Readonly<Record<string, "file" | "package">> = {
|
|
131
|
+
[REDUNDANT_EXPORT_KEYWORD]: "file",
|
|
132
|
+
[UNNECESSARY_EXPORT]: "package",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
function findingOf(symbol: InventorySymbol, code: string, message: string): Finding {
|
|
136
|
+
const { path, line, column } = symbol.position;
|
|
137
|
+
const narrower = Object.hasOwn(NARROWER, code) ? NARROWER[code] : undefined;
|
|
138
|
+
const endLine = Math.max(line, symbol.endLine);
|
|
139
|
+
return {
|
|
140
|
+
code,
|
|
141
|
+
position: { path, line, column, endLine },
|
|
142
|
+
symbol: {
|
|
143
|
+
ref: symbol.ref,
|
|
144
|
+
kind: symbol.kind,
|
|
145
|
+
name: symbol.name,
|
|
146
|
+
sizeLines: endLine - line + 1,
|
|
147
|
+
},
|
|
148
|
+
message,
|
|
149
|
+
details: narrower === undefined ? {} : { narrowerVisibility: narrower },
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The narrowing finding about one live declaration, if one holds: an export of a file,
|
|
155
|
+
* no re-export and none the write-only kind reports, that a reference names and nothing
|
|
156
|
+
* enters by name. Every reference inside its own file makes it a redundant export
|
|
157
|
+
* keyword, where no manifest export reaches the file or the world is closed; references
|
|
158
|
+
* from other files of the package alone make a published one unnecessary when closed.
|
|
159
|
+
*/
|
|
160
|
+
function narrowing(
|
|
161
|
+
facts: Facts,
|
|
162
|
+
at: number,
|
|
163
|
+
symbol: InventorySymbol,
|
|
164
|
+
world: { readonly closed: boolean; readonly writeOnly: ReadonlySet<string> },
|
|
165
|
+
): Finding | undefined {
|
|
166
|
+
const { closed } = world;
|
|
167
|
+
const file = facts.fileOf[at] ?? OUTSIDE;
|
|
168
|
+
if (
|
|
169
|
+
!topLevelExport(facts, at, symbol) ||
|
|
170
|
+
world.writeOnly.has(symbol.ref) ||
|
|
171
|
+
symbol.kind === "export-alias" ||
|
|
172
|
+
entersByName(facts.rootedBy[at]) ||
|
|
173
|
+
entersByName(facts.rootedBy[file]) ||
|
|
174
|
+
(facts.named[at] ?? 0) === 0
|
|
175
|
+
) {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
const word = wordOf(symbol);
|
|
179
|
+
switch (facts.reach[at]) {
|
|
180
|
+
case "file":
|
|
181
|
+
if (facts.published[file] === true && !closed) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
return findingOf(
|
|
185
|
+
symbol,
|
|
186
|
+
REDUNDANT_EXPORT_KEYWORD,
|
|
187
|
+
`exported ${word} is referenced only inside the file that declares it`,
|
|
188
|
+
);
|
|
189
|
+
case "package":
|
|
190
|
+
if (!closed || !publishes(facts.rootedBy[at])) {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
return findingOf(
|
|
194
|
+
symbol,
|
|
195
|
+
UNNECESSARY_EXPORT,
|
|
196
|
+
`exported ${word} is referenced only inside the package that declares it`,
|
|
197
|
+
);
|
|
198
|
+
default:
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The unreachable-export finding about one dead declaration, if one holds: an unused
|
|
205
|
+
* export no code outside the target can import, because the manifest declares
|
|
206
|
+
* `exports` and no root reaches it. Each arm yields to a more specific kind, so one
|
|
207
|
+
* declaration is reported once: a test whose every subject is dead, an interface, a
|
|
208
|
+
* declaration only a test file references, and a deprecated one.
|
|
209
|
+
*/
|
|
210
|
+
function unreachable(
|
|
211
|
+
facts: Facts,
|
|
212
|
+
at: number,
|
|
213
|
+
symbol: InventorySymbol,
|
|
214
|
+
candidate: MatrixCandidate,
|
|
215
|
+
input: EmitterInput,
|
|
216
|
+
): Finding | undefined {
|
|
217
|
+
if (
|
|
218
|
+
!input.boundary.encapsulated ||
|
|
219
|
+
!topLevelExport(facts, at, symbol) ||
|
|
220
|
+
(facts.rootedBy[at]?.size ?? 0) > 0 ||
|
|
221
|
+
candidate.testOfDeadCode ||
|
|
222
|
+
symbol.kind === "interface" ||
|
|
223
|
+
(candidate.productionRefs === 0 && candidate.testRefs > 0) ||
|
|
224
|
+
(candidate.productionRefs === 0 && input.deprecated.has(symbol.id))
|
|
225
|
+
) {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
const found =
|
|
229
|
+
candidate.relation === "reachability"
|
|
230
|
+
? "is referenced only from declarations that are themselves dead"
|
|
231
|
+
: "has no reference in the target";
|
|
232
|
+
return findingOf(
|
|
233
|
+
symbol,
|
|
234
|
+
UNREACHABLE_EXPORT,
|
|
235
|
+
`exported ${wordOf(symbol)} ${found}, and nothing outside can import the file that declares it`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Two strings ordered bytewise. */
|
|
240
|
+
function compare(a: string, b: string): number {
|
|
241
|
+
if (a === b) {
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
return a < b ? -1 : 1;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The visibility-narrowing family over one run: `DS1101`, `DS1103` and `DS1104`.
|
|
249
|
+
*
|
|
250
|
+
* A finding about a symbol a declared cross-language edge names is pending instead of
|
|
251
|
+
* reported, once per side naming it: the edge counts as a reference from outside the
|
|
252
|
+
* file and the package, and whether it is a use is decided on the side in the other
|
|
253
|
+
* language.
|
|
254
|
+
*/
|
|
255
|
+
export function narrowings(input: EmitterInput): Narrowings {
|
|
256
|
+
const { boundary } = input;
|
|
257
|
+
const facts = factsOf(input.swept.matrix.union);
|
|
258
|
+
const { declared, loaded } = boundary.consumers;
|
|
259
|
+
const closed = input.config.consumersComplete && declared.every((id) => loaded.includes(id));
|
|
260
|
+
const world = { closed, writeOnly: writeOnlyDeclarations(input) };
|
|
261
|
+
const candidates = new Map(
|
|
262
|
+
input.swept.sweep.candidates.map((candidate) => [candidate.id, candidate]),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const findings: Finding[] = [];
|
|
266
|
+
const pending: PendingFinding[] = [];
|
|
267
|
+
facts.graph.symbols.forEach((symbol, at) => {
|
|
268
|
+
const candidate = candidates.get(symbol.id);
|
|
269
|
+
const found =
|
|
270
|
+
candidate === undefined
|
|
271
|
+
? narrowing(facts, at, symbol, world)
|
|
272
|
+
: unreachable(facts, at, symbol, candidate, input);
|
|
273
|
+
if (found === undefined) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const named = boundary.edges.filter((side) => side.symbol === symbol.ref);
|
|
277
|
+
if (named.length === 0) {
|
|
278
|
+
findings.push(found);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
for (const side of named) {
|
|
282
|
+
pending.push({ ...side, finding: found });
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
pending.sort((a, b) => compare(a.edge, b.edge) || compare(a.side, b.side));
|
|
286
|
+
return { findings, pending };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The declarations the unreachable-export kind reports, whether or not a declared edge
|
|
291
|
+
* holds the finding pending: the unused-declaration kinds yield each to it.
|
|
292
|
+
*/
|
|
293
|
+
export function unreachableExports(input: EmitterInput): ReadonlySet<string> {
|
|
294
|
+
const facts = factsOf(input.swept.matrix.union);
|
|
295
|
+
const found = new Set<string>();
|
|
296
|
+
for (const candidate of input.swept.sweep.candidates) {
|
|
297
|
+
const at = facts.graph.at(candidate.id);
|
|
298
|
+
const symbol = facts.graph.symbols[at];
|
|
299
|
+
if (symbol !== undefined && unreachable(facts, at, symbol, candidate, input) !== undefined) {
|
|
300
|
+
found.add(symbol.id);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return found;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The findings of the visibility-narrowing family, `DS1100` to `DS1199`. A finding a
|
|
308
|
+
* declared edge holds pending belongs in that edge's evaluation, which this analyzer
|
|
309
|
+
* does not publish yet, so a run that holds one is refused rather than reported
|
|
310
|
+
* without it.
|
|
311
|
+
*/
|
|
312
|
+
export const visibilityNarrowing: Emitter = (input) => {
|
|
313
|
+
const { findings, pending } = narrowings(input);
|
|
314
|
+
if (pending.length > 0) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
`${String(pending.length)} finding(s) a declared edge holds pending, and no edge evaluation is published`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
return findings;
|
|
320
|
+
};
|