@cplieger/deadset-ts 5.0.0 → 5.1.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 +1 -1
- package/dist/src/analysis.js +145 -39
- package/dist/src/configuration-files.js +108 -4
- package/dist/src/conformance-record.js +3 -9
- package/dist/src/contextual-uses.js +247 -0
- package/dist/src/convention-rows.js +6 -0
- package/dist/src/conventions.js +123 -14
- package/dist/src/dependencies.js +8 -2
- package/dist/src/destructuring.js +6 -6
- package/dist/src/entry-points.js +8 -5
- package/dist/src/exempt-classes.js +6 -1
- package/dist/src/exempt.js +35 -16
- package/dist/src/file-facts.js +23 -20
- package/dist/src/findings/completion.js +27 -3
- package/dist/src/findings/interfaces.js +6 -2
- package/dist/src/findings/reads-and-writes.js +53 -9
- package/dist/src/findings/unused-declarations.js +40 -1
- package/dist/src/generated-file.js +24 -0
- package/dist/src/graph.js +1 -1
- package/dist/src/interface-satisfaction.js +2 -68
- package/dist/src/inventory.js +17 -4
- package/dist/src/kinds.js +4 -3
- package/dist/src/manifest.js +29 -4
- package/dist/src/outside-tests.js +139 -0
- package/dist/src/overrides.js +104 -0
- package/dist/src/paths.js +17 -0
- package/dist/src/query.js +15 -0
- package/dist/src/references.js +229 -11
- package/dist/src/resolve.js +12 -11
- package/dist/src/roots.js +121 -37
- package/dist/src/session.js +4 -3
- package/dist/src/stores.js +3 -0
- package/dist/src/template-field.js +60 -3
- package/dist/src/value-flow.js +74 -0
- package/dist/src/version.js +1 -1
- package/jsr.json +1 -1
- package/package.json +2 -2
- package/src/analysis.ts +194 -49
- package/src/configuration-files.ts +137 -2
- package/src/conformance-record.ts +3 -10
- package/src/contextual-uses.ts +358 -0
- package/src/convention-rows.ts +8 -0
- package/src/conventions.ts +161 -13
- package/src/dependencies.ts +14 -1
- package/src/destructuring.ts +16 -6
- package/src/entry-points.ts +16 -3
- package/src/exempt-classes.ts +6 -1
- package/src/exempt.ts +67 -16
- package/src/file-facts.ts +26 -20
- package/src/findings/completion.ts +31 -3
- package/src/findings/interfaces.ts +9 -2
- package/src/findings/reads-and-writes.ts +70 -9
- package/src/findings/unused-declarations.ts +51 -1
- package/src/generated-file.ts +27 -0
- package/src/graph.ts +4 -4
- package/src/interface-satisfaction.ts +1 -92
- package/src/inventory.ts +19 -4
- package/src/kinds.ts +5 -3
- package/src/manifest.ts +40 -4
- package/src/outside-tests.ts +191 -0
- package/src/overrides.ts +136 -0
- package/src/paths.ts +18 -0
- package/src/query.ts +26 -0
- package/src/references.ts +294 -22
- package/src/resolve.ts +14 -13
- package/src/roots.ts +152 -44
- package/src/session.ts +4 -2
- package/src/stores.ts +9 -0
- package/src/template-field.ts +65 -3
- package/src/value-flow.ts +104 -0
- package/src/version.ts +1 -1
|
@@ -146,8 +146,9 @@ export function completed(
|
|
|
146
146
|
const classOf = reachabilityClasses(input);
|
|
147
147
|
const componentOf = findingComponents(swept, input.config.reporters.cascade);
|
|
148
148
|
let minted = swept.sweep.components.length;
|
|
149
|
+
const dead = new Set(swept.sweep.components.map((component) => component.id));
|
|
149
150
|
|
|
150
|
-
|
|
151
|
+
const done = findings.map((finding): CompletedFinding => {
|
|
151
152
|
const row = kinds.get(finding.code);
|
|
152
153
|
if (row === undefined) {
|
|
153
154
|
throw new Error(`${finding.code} names no live row of the issue-kind vocabulary`);
|
|
@@ -183,6 +184,7 @@ export function completed(
|
|
|
183
184
|
? union.made[at]
|
|
184
185
|
: { production: candidate.productionRefs, test: candidate.testRefs };
|
|
185
186
|
const reachabilityClass = judged === undefined ? "certain" : classOf(judged.id);
|
|
187
|
+
const generated = input.files.generated?.has(finding.position.path) === true;
|
|
186
188
|
return {
|
|
187
189
|
code: finding.code,
|
|
188
190
|
kind: row.name,
|
|
@@ -193,7 +195,7 @@ export function completed(
|
|
|
193
195
|
confidence: capped(reachabilityClass, row.maxClass),
|
|
194
196
|
...(candidate === undefined ? {} : { livenessRelation: candidate.relation }),
|
|
195
197
|
testOnly: judged !== undefined && counts?.production === 0 && counts.test > 0,
|
|
196
|
-
generated
|
|
198
|
+
generated,
|
|
197
199
|
component,
|
|
198
200
|
retainedBy: [],
|
|
199
201
|
configurations:
|
|
@@ -202,12 +204,38 @@ export function completed(
|
|
|
202
204
|
? swept.matrix.configurations
|
|
203
205
|
: (swept.matrix.heldIn[at] ?? swept.matrix.configurations)),
|
|
204
206
|
consumersLoaded: input.boundary.consumers.loaded,
|
|
205
|
-
fixability: row.fixability,
|
|
207
|
+
fixability: generated ? "none" : row.fixability,
|
|
206
208
|
severity: severityOf(input, finding.code, row.defaultSeverity),
|
|
207
209
|
message: finding.message,
|
|
208
210
|
details: finding.details ?? {},
|
|
209
211
|
};
|
|
210
212
|
});
|
|
213
|
+
return componentCapped(done, dead);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The findings with every finding of a dead component capped by the lowest confidence
|
|
218
|
+
* among the component's root members, so the minimum confidence reports or withholds a
|
|
219
|
+
* component whole.
|
|
220
|
+
*/
|
|
221
|
+
function componentCapped(
|
|
222
|
+
findings: readonly CompletedFinding[],
|
|
223
|
+
dead: ReadonlySet<string>,
|
|
224
|
+
): readonly CompletedFinding[] {
|
|
225
|
+
const lowest = new Map<string, Confidence>();
|
|
226
|
+
for (const finding of findings) {
|
|
227
|
+
const { id, root } = finding.component;
|
|
228
|
+
if (root && dead.has(id)) {
|
|
229
|
+
const held = lowest.get(id);
|
|
230
|
+
lowest.set(id, held === undefined ? finding.confidence : capped(held, finding.confidence));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return findings.map((finding) => {
|
|
234
|
+
const cap = lowest.get(finding.component.id);
|
|
235
|
+
return cap === undefined || RANK[cap] >= RANK[finding.confidence]
|
|
236
|
+
? finding
|
|
237
|
+
: { ...finding, confidence: cap };
|
|
238
|
+
});
|
|
211
239
|
}
|
|
212
240
|
|
|
213
241
|
/** Whether the dials withhold a finding by its own severity or confidence. */
|
|
@@ -31,6 +31,7 @@ function compare(a: string, b: string): number {
|
|
|
31
31
|
function indexed(input: EmitterInput): {
|
|
32
32
|
readonly symbolOf: (id: string) => InventorySymbol | undefined;
|
|
33
33
|
readonly inTest: (id: string) => boolean;
|
|
34
|
+
readonly inSupport: (id: string) => boolean;
|
|
34
35
|
readonly candidateOf: (id: string) => Candidate | undefined;
|
|
35
36
|
readonly implementationsOf: (id: string) => readonly PositionedSymbol[];
|
|
36
37
|
} {
|
|
@@ -43,6 +44,7 @@ function indexed(input: EmitterInput): {
|
|
|
43
44
|
return {
|
|
44
45
|
symbolOf,
|
|
45
46
|
inTest: (id) => union.test[union.at(id)] === true,
|
|
47
|
+
inSupport: (id) => union.support[union.at(id)] === true,
|
|
46
48
|
candidateOf: (id) => candidates.get(id),
|
|
47
49
|
implementationsOf: (id) =>
|
|
48
50
|
[...(input.implementations.classes.get(id) ?? [])]
|
|
@@ -80,14 +82,19 @@ function findingOf(
|
|
|
80
82
|
* interface, whose members fall with it unreported; and `DS1203`, a method of a live
|
|
81
83
|
* interface no reference names, unless it is a marker, a method whose every
|
|
82
84
|
* implementation has an empty body. A test file's declaration is neither kind's
|
|
83
|
-
* subject, because a production sweep counts none of the references it can have
|
|
85
|
+
* subject, because a production sweep counts none of the references it can have, and
|
|
86
|
+
* neither is a test-support declaration test code references, which that code keeps live.
|
|
84
87
|
*/
|
|
85
88
|
export const interfaces: Emitter = (input) => {
|
|
86
89
|
const run = indexed(input);
|
|
87
90
|
const found: Finding[] = [];
|
|
88
91
|
for (const candidate of input.swept.sweep.candidates) {
|
|
89
92
|
const symbol = run.symbolOf(candidate.id);
|
|
90
|
-
if (
|
|
93
|
+
if (
|
|
94
|
+
symbol === undefined ||
|
|
95
|
+
run.inTest(candidate.id) ||
|
|
96
|
+
(run.inSupport(candidate.id) && candidate.testRefs > 0)
|
|
97
|
+
) {
|
|
91
98
|
continue;
|
|
92
99
|
}
|
|
93
100
|
if (symbol.kind === "interface") {
|
|
@@ -62,23 +62,76 @@ function findingAbout(
|
|
|
62
62
|
interface Uses {
|
|
63
63
|
readonly writes: FindingPosition[];
|
|
64
64
|
reads: number;
|
|
65
|
+
/** The import bindings only the writes use. */
|
|
66
|
+
readonly alone: Set<string>;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
/**
|
|
68
70
|
* 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
|
|
70
|
-
* store into one runs it.
|
|
71
|
+
* a member of an object type, or a class member that is state rather than code. A getter
|
|
72
|
+
* or a setter is code, so a store into one runs it.
|
|
71
73
|
*/
|
|
72
74
|
function writable(run: Run, symbol: InventorySymbol): boolean {
|
|
73
|
-
if (symbol.kind === "variable") {
|
|
75
|
+
if (symbol.kind === "variable" || symbol.kind === "type-member") {
|
|
74
76
|
return true;
|
|
75
77
|
}
|
|
76
78
|
return symbol.kind === "class-member" && !run.input.stores.accessors.has(symbol.id);
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
/** The reader's word for each kind of the write-only kind's subject. */
|
|
82
|
+
const SUBJECT_WORDS: Readonly<Partial<Record<InventorySymbol["kind"], string>>> = {
|
|
83
|
+
variable: "variable",
|
|
84
|
+
"class-member": "member",
|
|
85
|
+
"type-member": "type member",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The type parameters of one member's declaring type that only the member names, which
|
|
90
|
+
* deleting the member leaves unused.
|
|
91
|
+
*/
|
|
92
|
+
function parametersOnlyNamedBy(
|
|
93
|
+
run: Run,
|
|
94
|
+
namedBy: ReadonlyMap<string, ReadonlySet<string>>,
|
|
95
|
+
member: InventorySymbol,
|
|
96
|
+
): readonly string[] {
|
|
97
|
+
return [...run.symbols.values()]
|
|
98
|
+
.filter((symbol) => {
|
|
99
|
+
const from = namedBy.get(symbol.id);
|
|
100
|
+
return (
|
|
101
|
+
symbol.kind === "type-parameter" &&
|
|
102
|
+
symbol.parent === member.parent &&
|
|
103
|
+
from?.size === 1 &&
|
|
104
|
+
from.has(member.id)
|
|
105
|
+
);
|
|
106
|
+
})
|
|
107
|
+
.map((symbol) => symbol.name);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** What one write-only finding says, the declarations its deletion takes with it included. */
|
|
111
|
+
function writeOnlyMessage(
|
|
112
|
+
run: Run,
|
|
113
|
+
namedBy: ReadonlyMap<string, ReadonlySet<string>>,
|
|
114
|
+
symbol: InventorySymbol,
|
|
115
|
+
held: Uses,
|
|
116
|
+
): string {
|
|
117
|
+
const writes = held.writes.length === 1 ? "once" : `at ${String(held.writes.length)} positions`;
|
|
118
|
+
const read = run.input.stores.readInTests.has(symbol.id)
|
|
119
|
+
? "read only from test files"
|
|
120
|
+
: "never read";
|
|
121
|
+
const cascade = [
|
|
122
|
+
...parametersOnlyNamedBy(run, namedBy, symbol).map((name) => `type parameter ${name}`),
|
|
123
|
+
...[...held.alone].sort().map((name) => `import ${name}`),
|
|
124
|
+
];
|
|
125
|
+
const word = SUBJECT_WORDS[symbol.kind] ?? "member";
|
|
126
|
+
const message = `${word} ${symbol.name} is written ${writes} and ${read}`;
|
|
127
|
+
return cascade.length === 0
|
|
128
|
+
? message
|
|
129
|
+
: `${message}, and deleting it with its writes deletes ${cascade.join(" and ")} too`;
|
|
130
|
+
}
|
|
131
|
+
|
|
79
132
|
/**
|
|
80
|
-
* `DS1301`: a module-level variable or a class member the counted
|
|
81
|
-
* and never read, named with every position written.
|
|
133
|
+
* `DS1301`: a module-level variable, a type member or a class member the counted
|
|
134
|
+
* references store into and never read, named with every position written.
|
|
82
135
|
*
|
|
83
136
|
* The subject is a live declaration: a store is a reference, so the sweep holds a
|
|
84
137
|
* written one live and its candidates are never this kind's. Every reference that is
|
|
@@ -91,7 +144,7 @@ function writeOnlySymbols(run: Run): readonly Finding[] {
|
|
|
91
144
|
for (const reference of stores.references) {
|
|
92
145
|
let held = uses.get(reference.to);
|
|
93
146
|
if (held === undefined) {
|
|
94
|
-
held = { writes: [], reads: 0 };
|
|
147
|
+
held = { writes: [], reads: 0, alone: new Set() };
|
|
95
148
|
uses.set(reference.to, held);
|
|
96
149
|
}
|
|
97
150
|
// A consumer's store is written at a position no report of the target can name, so
|
|
@@ -100,6 +153,9 @@ function writeOnlySymbols(run: Run): readonly Finding[] {
|
|
|
100
153
|
held.reads += 1;
|
|
101
154
|
continue;
|
|
102
155
|
}
|
|
156
|
+
for (const name of reference.alone ?? []) {
|
|
157
|
+
held.alone.add(name);
|
|
158
|
+
}
|
|
103
159
|
const at = reference.position;
|
|
104
160
|
const key = positionKey(at);
|
|
105
161
|
if (!held.writes.some((written) => positionKey(written) === key)) {
|
|
@@ -107,6 +163,13 @@ function writeOnlySymbols(run: Run): readonly Finding[] {
|
|
|
107
163
|
}
|
|
108
164
|
}
|
|
109
165
|
|
|
166
|
+
const namedBy = new Map<string, Set<string>>();
|
|
167
|
+
for (const reference of stores.references) {
|
|
168
|
+
if (run.symbols.get(reference.to)?.kind === "type-parameter") {
|
|
169
|
+
namedBy.set(reference.to, (namedBy.get(reference.to) ?? new Set()).add(reference.from));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
110
173
|
const found: Finding[] = [];
|
|
111
174
|
for (const symbol of matrix.union.symbols) {
|
|
112
175
|
const held = uses.get(symbol.id);
|
|
@@ -120,10 +183,8 @@ function writeOnlySymbols(run: Run): readonly Finding[] {
|
|
|
120
183
|
) {
|
|
121
184
|
continue;
|
|
122
185
|
}
|
|
123
|
-
const writes = held.writes.length === 1 ? "once" : `at ${String(held.writes.length)} positions`;
|
|
124
|
-
const word = symbol.kind === "variable" ? "variable" : "member";
|
|
125
186
|
found.push(
|
|
126
|
-
findingAbout(symbol, "DS1301",
|
|
187
|
+
findingAbout(symbol, "DS1301", writeOnlyMessage(run, namedBy, symbol, held), {
|
|
127
188
|
writePositions: held.writes,
|
|
128
189
|
}),
|
|
129
190
|
);
|
|
@@ -142,6 +142,52 @@ function messageOf(code: string, judged: Judged): string {
|
|
|
142
142
|
return `${subject} has no reference in the target`;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* The members nothing writes or reads that are brands: each names a type parameter of
|
|
147
|
+
* its declaring type that no other part of the declaration names, other than another
|
|
148
|
+
* such member. They are what make two instantiations of the type distinct types, so
|
|
149
|
+
* deleting them would let one instantiation stand for another.
|
|
150
|
+
*/
|
|
151
|
+
function brandMembers(input: EmitterInput): ReadonlySet<string> {
|
|
152
|
+
const union = input.swept.matrix.union;
|
|
153
|
+
const unreferenced = new Map(
|
|
154
|
+
input.swept.sweep.candidates
|
|
155
|
+
.filter((candidate) => candidate.productionRefs + candidate.testRefs === 0)
|
|
156
|
+
.map((candidate) => [candidate.id, candidate]),
|
|
157
|
+
);
|
|
158
|
+
const namedBy = new Map<string, Set<string>>();
|
|
159
|
+
for (const reference of input.stores.references) {
|
|
160
|
+
const to = union.symbols[union.at(reference.to)];
|
|
161
|
+
if (to?.kind === "type-parameter") {
|
|
162
|
+
namedBy.set(to.id, (namedBy.get(to.id) ?? new Set()).add(reference.from));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const brands = new Set<string>();
|
|
166
|
+
for (const [parameter, from] of namedBy) {
|
|
167
|
+
const owner = union.symbols[union.at(parameter)]?.parent;
|
|
168
|
+
const members = [...from].map((id) => union.symbols[union.at(id)]);
|
|
169
|
+
if (
|
|
170
|
+
members.every(
|
|
171
|
+
(member) =>
|
|
172
|
+
member !== undefined &&
|
|
173
|
+
member.parent === owner &&
|
|
174
|
+
BRANDS.has(member.kind) &&
|
|
175
|
+
unreferenced.has(member.id),
|
|
176
|
+
)
|
|
177
|
+
) {
|
|
178
|
+
for (const member of members) {
|
|
179
|
+
if (member !== undefined) {
|
|
180
|
+
brands.add(member.id);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return brands;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The kinds of member a brand is: a data member of a type or of a class. */
|
|
189
|
+
const BRANDS: ReadonlySet<SymbolKind> = new Set(["type-member", "class-member"]);
|
|
190
|
+
|
|
145
191
|
/** The findings of the unused-declarations family, `DS1000` to `DS1099`. */
|
|
146
192
|
export const unusedDeclarations: Emitter = (input) => familyFindings(input, false);
|
|
147
193
|
|
|
@@ -157,6 +203,7 @@ function familyFindings(input: EmitterInput, fallen: boolean): Finding[] {
|
|
|
157
203
|
const union = input.swept.matrix.union;
|
|
158
204
|
const dead = new Set(input.swept.sweep.candidates.map((candidate) => candidate.id));
|
|
159
205
|
const unreachable = unreachableExports(input);
|
|
206
|
+
let brands: ReadonlySet<string> | undefined;
|
|
160
207
|
|
|
161
208
|
const found: Finding[] = [];
|
|
162
209
|
for (const candidate of input.swept.sweep.candidates) {
|
|
@@ -179,7 +226,10 @@ function familyFindings(input: EmitterInput, fallen: boolean): Finding[] {
|
|
|
179
226
|
unreachable: unreachable.has(symbol.id),
|
|
180
227
|
};
|
|
181
228
|
const code = codeOf(judged);
|
|
182
|
-
if (
|
|
229
|
+
if (
|
|
230
|
+
code === undefined ||
|
|
231
|
+
(code === "DS1003" && (brands ??= brandMembers(input)).has(symbol.id))
|
|
232
|
+
) {
|
|
183
233
|
continue;
|
|
184
234
|
}
|
|
185
235
|
const endLine = Math.max(symbol.position.line, symbol.endLine);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generated-file class on TypeScript: every declaration in a file below a directory
|
|
3
|
+
* an applied convention row names as generated is retained, because the framework owns
|
|
4
|
+
* the file and a deletion there is undone at the next generation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { DetectorInput, Evidence } from "./exempt.ts";
|
|
8
|
+
|
|
9
|
+
/** The generated-file detector, recording each file's first line. */
|
|
10
|
+
export function generatedFile<Brand>(input: DetectorInput<Brand>): readonly Evidence[] {
|
|
11
|
+
const generated = input.generated;
|
|
12
|
+
if (generated === undefined || generated.size === 0) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
return input.held.symbols.flatMap((symbol): Evidence[] => {
|
|
16
|
+
const row = generated.get(symbol.position.path);
|
|
17
|
+
return symbol.kind === "file" || row === undefined
|
|
18
|
+
? []
|
|
19
|
+
: [
|
|
20
|
+
{
|
|
21
|
+
id: symbol.id,
|
|
22
|
+
detail: `generated below a directory the convention row ${row} names`,
|
|
23
|
+
site: { path: symbol.position.path, line: 1, column: 1 },
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
});
|
|
27
|
+
}
|
package/src/graph.ts
CHANGED
|
@@ -122,9 +122,9 @@ export interface Graph {
|
|
|
122
122
|
/** Every root naming a declaration the inventory holds, in the order the root set gives them. */
|
|
123
123
|
readonly rooted: readonly Rooted[];
|
|
124
124
|
/**
|
|
125
|
-
* Every reference a loaded consumer makes to a declaration the
|
|
126
|
-
* order given.
|
|
127
|
-
* names live under both relations and reaches on from there.
|
|
125
|
+
* Every reference a loaded consumer or an unheld file makes to a declaration the
|
|
126
|
+
* inventory holds, in the order given. Such a file is a caller the analysis read, so the
|
|
127
|
+
* sweep holds what it names live under both relations and reaches on from there.
|
|
128
128
|
*/
|
|
129
129
|
readonly consumed: readonly Edge[];
|
|
130
130
|
/** The position of the declaration one identifier names, or {@link OUTSIDE}. */
|
|
@@ -167,7 +167,7 @@ export function graphOf(
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
const edge = { to, test: reference.test, evaluation: reference.use === "evaluation" };
|
|
170
|
-
if (reference.consumer !== undefined) {
|
|
170
|
+
if (reference.consumer !== undefined || reference.unheld === true) {
|
|
171
171
|
consumed.push(edge);
|
|
172
172
|
continue;
|
|
173
173
|
}
|
|
@@ -14,33 +14,16 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
|
-
isArrayLiteralExpression,
|
|
18
|
-
isArrowFunction,
|
|
19
|
-
isAsExpression,
|
|
20
|
-
isBinaryExpression,
|
|
21
|
-
isCallExpression,
|
|
22
17
|
isClassDeclaration,
|
|
23
18
|
isClassExpression,
|
|
24
19
|
isClassStaticBlockDeclaration,
|
|
25
|
-
isConditionalExpression,
|
|
26
20
|
isFunctionDeclaration,
|
|
27
21
|
isFunctionExpression,
|
|
28
22
|
isGetAccessorDeclaration,
|
|
29
23
|
isHeritageClause,
|
|
30
|
-
isIdentifier,
|
|
31
24
|
isMethodDeclaration,
|
|
32
|
-
isNewExpression,
|
|
33
25
|
isObjectLiteralExpression,
|
|
34
|
-
isParameterDeclaration,
|
|
35
|
-
isParenthesizedExpression,
|
|
36
|
-
isPropertyAssignment,
|
|
37
|
-
isPropertyDeclaration,
|
|
38
|
-
isReturnStatement,
|
|
39
|
-
isSatisfiesExpression,
|
|
40
26
|
isSetAccessorDeclaration,
|
|
41
|
-
isShorthandPropertyAssignment,
|
|
42
|
-
isSpreadElement,
|
|
43
|
-
isVariableDeclaration,
|
|
44
27
|
ModifierFlags,
|
|
45
28
|
SyntaxKind,
|
|
46
29
|
type Expression,
|
|
@@ -53,6 +36,7 @@ import type { DetectorInput, Evidence } from "./exempt.ts";
|
|
|
53
36
|
import { nodeKey } from "./inventory.ts";
|
|
54
37
|
import { renderPosition } from "./position.ts";
|
|
55
38
|
import { must } from "./query.ts";
|
|
39
|
+
import { handedOn, valuesOf } from "./value-flow.ts";
|
|
56
40
|
|
|
57
41
|
/** One class the inventory holds, by its declaration. */
|
|
58
42
|
interface ClassAt {
|
|
@@ -85,14 +69,6 @@ interface Pair {
|
|
|
85
69
|
readonly sites: Node[];
|
|
86
70
|
}
|
|
87
71
|
|
|
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
72
|
/** Whether one node declares a static member, whose `this` is the class itself. */
|
|
97
73
|
function isStatic(node: Node): boolean {
|
|
98
74
|
const flags = (node as { readonly modifierFlags?: number }).modifierFlags ?? 0;
|
|
@@ -109,73 +85,6 @@ function isObjectLiteralMethod(node: Node): boolean {
|
|
|
109
85
|
);
|
|
110
86
|
}
|
|
111
87
|
|
|
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
88
|
/** What one project's walk found to resolve. */
|
|
180
89
|
interface Walked {
|
|
181
90
|
readonly classes: readonly ClassAt[];
|
package/src/inventory.ts
CHANGED
|
@@ -303,7 +303,10 @@ function typeParametersOf(node: Node): readonly Node[] {
|
|
|
303
303
|
if (
|
|
304
304
|
isFunctionDeclaration(node) ||
|
|
305
305
|
isMethodDeclaration(node) ||
|
|
306
|
-
isMethodSignatureDeclaration(node)
|
|
306
|
+
isMethodSignatureDeclaration(node) ||
|
|
307
|
+
isClassDeclaration(node) ||
|
|
308
|
+
isInterfaceDeclaration(node) ||
|
|
309
|
+
isTypeAliasDeclaration(node)
|
|
307
310
|
) {
|
|
308
311
|
return node.typeParameters ?? [];
|
|
309
312
|
}
|
|
@@ -467,7 +470,20 @@ function nameNodeOf(node: Node): Node | undefined {
|
|
|
467
470
|
* needs the declaration this enumeration made for it.
|
|
468
471
|
*/
|
|
469
472
|
export function nodeKey(file: SourceFile, node: Node): string {
|
|
470
|
-
return `${file.fileName}:${String(node.pos)}:${String(node.end)}`;
|
|
473
|
+
return `${file.fileName}:${String(node.pos)}:${String(node.end)}:${String(node.kind)}`;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** `export default`, up to the keyword that stands where an unnamed default export's name would. */
|
|
477
|
+
const DEFAULT_EXPORT = /^export\s+(?=default\b)/u;
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Where an unnamed declaration is placed: at `default` when it is the default export, so it
|
|
481
|
+
* never shares the file's own position, else at its first token.
|
|
482
|
+
*/
|
|
483
|
+
function unnamedStart(node: Node): number {
|
|
484
|
+
const start = node.getStart();
|
|
485
|
+
const prefix = DEFAULT_EXPORT.exec(node.getText());
|
|
486
|
+
return prefix === null ? start : start + prefix[0].length;
|
|
471
487
|
}
|
|
472
488
|
|
|
473
489
|
/** The kind a class element takes: a method, or a property or accessor. */
|
|
@@ -562,12 +578,11 @@ export function inventory<Brand>(
|
|
|
562
578
|
name: Node | undefined,
|
|
563
579
|
named: { readonly component: Component; readonly localName: string | undefined },
|
|
564
580
|
): Building {
|
|
565
|
-
const at = name ?? node;
|
|
566
581
|
const record = keep({
|
|
567
582
|
file: owner.file,
|
|
568
583
|
node,
|
|
569
584
|
module: owner.module,
|
|
570
|
-
position: renderPosition(owner.file, targetRoot,
|
|
585
|
+
position: renderPosition(owner.file, targetRoot, name?.getStart() ?? unnamedStart(node)),
|
|
571
586
|
endLine: renderPosition(owner.file, targetRoot, Math.max(node.end - 1, 0)).line,
|
|
572
587
|
kind,
|
|
573
588
|
parentChain: owner.kind === "file" ? [] : chainOf(owner),
|
package/src/kinds.ts
CHANGED
|
@@ -60,6 +60,8 @@ const ROWS: readonly KindRow[] = [
|
|
|
60
60
|
name: "unused-member",
|
|
61
61
|
languages: ["go", "ts"],
|
|
62
62
|
rule: "A struct field, class member or type member with no reference, a private member included. A member referenced only from its own declaration site counts as unreferenced.",
|
|
63
|
+
precondition:
|
|
64
|
+
"A TypeScript member that no value writes and nothing reads, whose type names a type parameter of its declaring interface, class or type alias, is not reported when every part of that declaration that names the type parameter is a member no value writes and nothing reads. TypeScript compares object types by their members, so such members are what make two instantiations of their type distinct types, and deleting them leaves the type parameter phantom and lets one instantiation stand for another. A member a value writes is decided by the rule of DS1301 instead, whatever its type names.",
|
|
63
65
|
defaultSeverity: "deny",
|
|
64
66
|
maxClass: "certain",
|
|
65
67
|
fixability: "deletable",
|
|
@@ -101,7 +103,7 @@ const ROWS: readonly KindRow[] = [
|
|
|
101
103
|
languages: ["go", "ts"],
|
|
102
104
|
rule: "An exported symbol whose every reference is inside the symbol's own package or module, reported as a candidate for unexporting. The subject is a package-level declaration or a method, and three subjects are excluded: an interface method, whose exportedness is the contract of the interface that declares it; a struct field, which an encoder reads by name; and a method that satisfies an interface some symbol uses as a type, which cannot be unexported without its type ceasing to satisfy that interface. The finding names the narrower visibility the references support. A declared cross-language edge counts as an out-of-package reference; where the edge's other side is unknown to the analyzer the finding is emitted pending.",
|
|
103
105
|
precondition:
|
|
104
|
-
"Closed world only. Reported always for a main package and an internal/ directory tree, and for a published package only when the configuration declares the consumer set complete and every declared consumer loads. A library with no consumer loaded and no complete consumer set declared gets this finding on its internal/ tree and its main packages and never on its published API. A symbol the rule of DS1301 holds for is not reported under this code, whatever severity the configuration gives DS1301, because the write-only finding names the defect to fix first.",
|
|
106
|
+
"Closed world only. Reported always for a main package and an internal/ directory tree, and for a published package only when the configuration declares the consumer set complete and every declared consumer loads. A library with no consumer loaded and no complete consumer set declared gets this finding on its internal/ tree and its main packages and never on its published API. A symbol the rule of DS1301 holds for is not reported under this code, whatever severity the configuration gives DS1301, because the write-only finding names the defect to fix first. A type that a parameter or a result of an exported function or method of its own package names is not reported when a reference from outside the package references that function or method, because the caller holds values of the type, and unexporting it would leave an exported signature naming a type its callers cannot name.",
|
|
105
107
|
defaultSeverity: "warn",
|
|
106
108
|
maxClass: "certain",
|
|
107
109
|
fixability: "narrowable",
|
|
@@ -113,7 +115,7 @@ const ROWS: readonly KindRow[] = [
|
|
|
113
115
|
languages: ["go"],
|
|
114
116
|
rule: "An exported symbol of a non-internal package whose every reference is inside the target module, reported as a candidate for relocation behind an internal boundary. The subject is a package-level declaration or a method, and three subjects are excluded: an interface method, whose exportedness is the contract of the interface that declares it; a struct field, which an encoder reads by name; and a method that satisfies an interface some symbol uses as a type, which cannot be unexported without its type ceasing to satisfy that interface. The finding names the narrower visibility the references support. A declared cross-language edge counts as an out-of-package reference; where the edge's other side is unknown to the analyzer the finding is emitted pending.",
|
|
115
117
|
precondition:
|
|
116
|
-
"Closed world only. Reported always for a main package and an internal/ directory tree, and for a published package only when the configuration declares the consumer set complete and every declared consumer loads. A library with no consumer loaded and no complete consumer set declared gets this finding on its internal/ tree and its main packages and never on its published API. A symbol the rule of DS1301 holds for is not reported under this code, whatever severity the configuration gives DS1301, because the write-only finding names the defect to fix first.",
|
|
118
|
+
"Closed world only. Reported always for a main package and an internal/ directory tree, and for a published package only when the configuration declares the consumer set complete and every declared consumer loads. A library with no consumer loaded and no complete consumer set declared gets this finding on its internal/ tree and its main packages and never on its published API. A symbol the rule of DS1301 holds for is not reported under this code, whatever severity the configuration gives DS1301, because the write-only finding names the defect to fix first. A type that a parameter or a result of an exported function or method of its own package names is not reported when a reference from outside the target module references that function or method, because the caller holds values of the type, and moving it behind an internal boundary would leave an exported signature naming a type its callers cannot name.",
|
|
117
119
|
defaultSeverity: "warn",
|
|
118
120
|
maxClass: "certain",
|
|
119
121
|
fixability: "narrowable",
|
|
@@ -177,7 +179,7 @@ const ROWS: readonly KindRow[] = [
|
|
|
177
179
|
code: "DS1301",
|
|
178
180
|
name: "write-only-symbol",
|
|
179
181
|
languages: ["go", "ts"],
|
|
180
|
-
rule: "A package-level or module-level variable, struct field, class member or collection that production code writes and never reads. In production mode a read from a test file counts as no read. A comparison of struct values reads every field of the type: in Go, a value of a struct type used as an operand of `==` or `!=`, as a map key, or as a `switch` tag or `case` expression is a read of every field of that type, transitively through the fields of every struct type it holds, recorded at the comparison, because equality reads every field and a field that decides equality carries information. In TypeScript a comparison of two object references reads no member, because `===` compares identity and never a member. The finding names each write position, so the deletion set is visible.",
|
|
182
|
+
rule: "A package-level or module-level variable, struct field, class member, type member or collection that production code writes and never reads. In production mode a read from a test file counts as no read. A comparison of struct values reads every field of the type: in Go, a value of a struct type used as an operand of `==` or `!=`, as a map key, or as a `switch` tag or `case` expression is a read of every field of that type, transitively through the fields of every struct type it holds, recorded at the comparison, because equality reads every field and a field that decides equality carries information. In TypeScript a comparison of two object references reads no member, because `===` compares identity and never a member. In TypeScript a property of an object literal writes the member of the same name that the literal's contextual type declares, whether or not the compiler checks the literal for excess properties. Where that contextual type is a union or an intersection, the property writes the member of that name on each constituent that declares one. So a literal that a declaration annotates, a function returns, a call passes or a `satisfies` expression checks writes the members of the type it is given. A store through the value a field or a member holds reads that field or member rather than writing it, because the store lands in storage every other holder of the value reads. In Go that store is an assignment, an increment, a compound assignment or a `delete` whose target is reached through a struct field holding a map, a slice or a pointer. Examples are `s.f[k] = v`, `s.f.x = v` and `delete(s.f, k)`. The same store through a field holding an array or a struct is a store into the field itself. TypeScript holds every object by reference, so an element or property store through a member's value reads the member. The finding names each write position, so the deletion set is visible. Deleting the subject and its writes also deletes every declaration whose only uses they hold. Such a declaration is a type parameter of the subject's declaring type that the subject's type alone names, or an import binding the writes alone use. The finding's message names each such declaration.",
|
|
181
183
|
defaultSeverity: "deny",
|
|
182
184
|
maxClass: "certain",
|
|
183
185
|
fixability: "deletable",
|
package/src/manifest.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The entry points a package manifest names, which are roots because something
|
|
3
3
|
* outside the analyzed program reaches them: a consumer importing the package, or a
|
|
4
|
-
* shell running its command or one of its scripts.
|
|
5
|
-
*
|
|
6
|
-
* One call reads one manifest, the one in the directory it is given.
|
|
4
|
+
* shell running its command or one of its scripts. {@link readManifest} reads one
|
|
5
|
+
* directory's manifest, {@link packageEntries} those of the packages below the target root.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import type { Host } from "./host.ts";
|
|
10
|
-
import { isAbsolutePath, joinPath, resolvePath } from "./paths.ts";
|
|
9
|
+
import { dirnamePath, isAbsolutePath, joinPath, relativePath, resolvePath } from "./paths.ts";
|
|
11
10
|
|
|
12
11
|
/** The manifest's own name. */
|
|
13
12
|
const MANIFEST = "package.json";
|
|
@@ -205,3 +204,40 @@ export function readManifest(host: Host, targetRoot: string): Manifest {
|
|
|
205
204
|
declaresExports: manifest["exports"] !== undefined,
|
|
206
205
|
};
|
|
207
206
|
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The entry points every package manifest between the target root and each compiler
|
|
210
|
+
* configuration names: each directory holding a `package.json` that a configuration sits
|
|
211
|
+
* in or below, the target root and `known` excluded, whose manifests the run reads
|
|
212
|
+
* otherwise. Each entry is spelled under its manifest's path below the target root, and
|
|
213
|
+
* is published by its own manifest's rule, as the target's own entries are.
|
|
214
|
+
*/
|
|
215
|
+
export function packageEntries(
|
|
216
|
+
host: Host,
|
|
217
|
+
targetRoot: string,
|
|
218
|
+
configFiles: readonly string[],
|
|
219
|
+
known: ReadonlySet<string>,
|
|
220
|
+
): readonly ManifestEntry[] {
|
|
221
|
+
const dirs = new Set<string>();
|
|
222
|
+
for (const configFile of configFiles) {
|
|
223
|
+
for (
|
|
224
|
+
let dir = dirnamePath(resolvePath(targetRoot, configFile));
|
|
225
|
+
dir !== targetRoot && relativePath(targetRoot, dir) !== undefined;
|
|
226
|
+
dir = dirnamePath(dir)
|
|
227
|
+
) {
|
|
228
|
+
if (!known.has(dir) && host.kindOf(joinPath(dir, MANIFEST)) === "file") {
|
|
229
|
+
dirs.add(dir);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return [...dirs].sort().flatMap((dir) => {
|
|
234
|
+
const manifest = readManifest(host, dir);
|
|
235
|
+
const where = `${relativePath(targetRoot, dir) ?? dir}/${MANIFEST}`;
|
|
236
|
+
return manifest.entries.map((entry) => ({
|
|
237
|
+
...entry,
|
|
238
|
+
member: `${where} ${entry.member}`,
|
|
239
|
+
published:
|
|
240
|
+
entry.published && (!manifest.declaresExports || entry.member.startsWith("exports")),
|
|
241
|
+
}));
|
|
242
|
+
});
|
|
243
|
+
}
|