@cplieger/deadset-ts 8.0.0-dev.17 → 8.0.0-dev.18

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.
Files changed (60) hide show
  1. package/dist/src/analysis.js +26 -71
  2. package/dist/src/augmentations.js +1 -1
  3. package/dist/src/calls.js +267 -48
  4. package/dist/src/conformance-record.js +1 -1
  5. package/dist/src/contract/contract.generated.js +2 -2
  6. package/dist/src/exempt.js +0 -26
  7. package/dist/src/exposure.js +400 -0
  8. package/dist/src/findings/completion.js +4 -17
  9. package/dist/src/findings/emitters.js +22 -33
  10. package/dist/src/findings/intra-function.js +3 -3
  11. package/dist/src/findings/reads-and-writes.js +71 -15
  12. package/dist/src/findings/unused-declarations.js +2 -22
  13. package/dist/src/findings/visibility-narrowing.js +14 -20
  14. package/dist/src/inventory.js +18 -0
  15. package/dist/src/{components.js → liveness/components.js} +1 -1
  16. package/dist/src/{graph.js → liveness/graph.js} +38 -21
  17. package/dist/src/liveness/matrix.js +99 -0
  18. package/dist/src/liveness/sweep.js +558 -0
  19. package/dist/src/query.js +46 -0
  20. package/dist/src/references/sink.js +87 -1
  21. package/dist/src/references.js +1 -1
  22. package/dist/src/roots/published-reach.js +4 -4
  23. package/dist/src/verbs/explain.js +73 -152
  24. package/dist/src/verbs/print-retained.js +1 -1
  25. package/jsr.json +1 -1
  26. package/package.json +1 -1
  27. package/src/analysis.ts +41 -107
  28. package/src/augmentations.ts +1 -1
  29. package/src/calls.ts +326 -52
  30. package/src/conformance-record.ts +1 -1
  31. package/src/contract/contract.generated.ts +2 -2
  32. package/src/exempt.ts +31 -46
  33. package/src/exposure.ts +492 -0
  34. package/src/finding.ts +1 -1
  35. package/src/findings/boundary.ts +7 -0
  36. package/src/findings/completion.ts +5 -19
  37. package/src/findings/emitters.ts +23 -41
  38. package/src/findings/interfaces.ts +3 -3
  39. package/src/findings/intra-function.ts +3 -3
  40. package/src/findings/reads-and-writes.ts +99 -26
  41. package/src/findings/unused-declarations.ts +4 -28
  42. package/src/findings/visibility-narrowing.ts +25 -26
  43. package/src/interface-satisfaction.ts +1 -1
  44. package/src/inventory.ts +19 -0
  45. package/src/{components.ts → liveness/components.ts} +3 -3
  46. package/src/{graph.ts → liveness/graph.ts} +74 -26
  47. package/src/liveness/matrix.ts +162 -0
  48. package/src/liveness/sweep.ts +855 -0
  49. package/src/query.ts +81 -0
  50. package/src/references/sink.ts +101 -0
  51. package/src/references.ts +8 -1
  52. package/src/roots/published-reach.ts +9 -4
  53. package/src/stores.ts +2 -1
  54. package/src/tests/file-class.ts +19 -2
  55. package/src/verbs/explain.ts +81 -199
  56. package/src/verbs/print-retained.ts +1 -1
  57. package/dist/src/matrix.js +0 -275
  58. package/dist/src/sweep.js +0 -234
  59. package/src/matrix.ts +0 -466
  60. package/src/sweep.ts +0 -384
@@ -0,0 +1,855 @@
1
+ /**
2
+ * Which declarations of the run are dead, and under which liveness relation, answered once
3
+ * over the graph of every configuration. Reference counting holds a declaration live when any
4
+ * declaration references it or it is a root that names a caller; reachability holds it live
5
+ * when a path of references reaches it from a root. Reachability is settled by tiers, strongest
6
+ * first: a caller, an exemption or a live component file's markup, a suppression mark, and an
7
+ * unanswered checker question; each Contract rule "live only through" one is a view up to it.
8
+ */
9
+
10
+ import { exemptionsOf, isUnansweredRecord, type Exemption } from "../exempt.ts";
11
+ import type { InventorySymbol } from "../inventory.ts";
12
+ import { edgeWithheldBy, type Mode } from "../tests/file-class.ts";
13
+ import { componentsOf, type Component, type Positional } from "./components.ts";
14
+ import { namesACaller, OUTSIDE, type Counts, type Edge, type Graph, type Rooted } from "./graph.ts";
15
+ import type { Matrix } from "./matrix.ts";
16
+
17
+ /** The liveness relation that decided a declaration, as a finding spells it. */
18
+ export type Relation = "reference-counting" | "reachability";
19
+
20
+ /** The relations in the order a declaration's entry lists them. */
21
+ const RELATIONS: readonly Relation[] = ["reference-counting", "reachability"];
22
+
23
+ /** What one sweep seeds, counts and follows beside the matrix. */
24
+ export interface SweepPolicy {
25
+ /**
26
+ * The declarations a matched suppression names. A mark holds its declaration live
27
+ * under both relations and seeds reachability through every reference it makes.
28
+ */
29
+ readonly marked: readonly string[];
30
+ /**
31
+ * Every record the detectors found. An exempt declaration is never a candidate, and it
32
+ * seeds reachability as a mark does; it is live under reference counting only where
33
+ * something references it. A record a component file's markup holds counts only once
34
+ * that file is live.
35
+ */
36
+ readonly exempt: readonly Exemption[];
37
+ /** The declarations a question the checker did not answer could have kept live (program.33). */
38
+ readonly unanswered: readonly string[];
39
+ /**
40
+ * The component files of the run, by path. No finding is reported about a top-level
41
+ * declaration of a live one, since the markup may use it (program.20).
42
+ */
43
+ readonly componentFiles: readonly string[];
44
+ /**
45
+ * Whether programs the analysis does not see may call the published API, the scope
46
+ * declaring no consumer: a published declaration is then a live target of every test that
47
+ * references it (DS1103).
48
+ */
49
+ readonly unseenCallers: boolean;
50
+ /** The declarations the write-only kind would report but for a record's read. */
51
+ readonly writeOnly: ReadonlySet<string>;
52
+ readonly mode: Mode;
53
+ }
54
+
55
+ /** One declaration dead in the run, the relation that found it, and the counts a kind reads. */
56
+ export interface Candidate {
57
+ readonly id: string;
58
+ /** The references a production file made to the declaration, whatever the mode counts. */
59
+ readonly productionRefs: number;
60
+ /** The references a test file made to it. */
61
+ readonly testRefs: number;
62
+ /**
63
+ * Reference counting where the mode counts no reference to the declaration, and
64
+ * reachability otherwise, so a declaration dead under both carries the stronger claim.
65
+ */
66
+ readonly relation: Relation;
67
+ /**
68
+ * Whether the test-of-dead-code rule admitted the declaration: a test declaration whose
69
+ * set of referenced production declarations is non-empty and wholly dead.
70
+ */
71
+ readonly testOfDeadCode: boolean;
72
+ /** The configurations that hold the declaration, every one of which judges it dead. */
73
+ readonly configurations: readonly string[];
74
+ /**
75
+ * The declaration of test-support code that test code references, outside the
76
+ * test-of-dead-code rule, that is or contains this one, the outermost where two do.
77
+ * Such a declaration is not dead while test code references it, so neither it nor a
78
+ * declaration it contains is a member of a dead component.
79
+ */
80
+ readonly heldByTests: string | undefined;
81
+ /**
82
+ * Whether a test file declares it, no test of dead code, and the run that counts test
83
+ * references does not reach it: it is reported as a production declaration of its
84
+ * shape nothing references would be.
85
+ */
86
+ readonly unreferencedTest: boolean;
87
+ /**
88
+ * Whether it is dead only because code only tests use is: production code only tests
89
+ * use holds it and the run that counts test references reaches it, or such a
90
+ * declaration reaches it through dead declarations.
91
+ */
92
+ readonly testUsed: boolean;
93
+ /**
94
+ * Whether test code the run that counts test references reaches, and that is no test of
95
+ * dead code, references it: a test that stays keeps it.
96
+ */
97
+ readonly keptByTests: boolean;
98
+ }
99
+
100
+ /** Where a path to a declaration starts. */
101
+ export type Origin =
102
+ | { readonly k: "root"; readonly rooted: Rooted }
103
+ | { readonly k: "consumed"; readonly edge: Edge }
104
+ | { readonly k: "exemption"; readonly record: Exemption }
105
+ | { readonly k: "mark"; readonly at: number }
106
+ | { readonly k: "unanswered"; readonly at: number };
107
+
108
+ /** One step of a path: a reference, or one that reads a module and so reaches an export of it. */
109
+ type Hop =
110
+ | { readonly k: "reference"; readonly edge: Edge }
111
+ | { readonly k: "export"; readonly edge: Edge; readonly to: number };
112
+
113
+ /** How the sweep reaches one live declaration: where its strongest tier's path starts, and each hop. */
114
+ interface Path {
115
+ readonly origin: Origin;
116
+ readonly hops: readonly Hop[];
117
+ }
118
+
119
+ /** One sweep's answer. */
120
+ export interface Sweep {
121
+ /**
122
+ * Per declaration, the relations that hold it live, reference counting first. A
123
+ * declaration no relation holds live has no entry.
124
+ */
125
+ readonly liveUnder: ReadonlyMap<string, readonly Relation[]>;
126
+ /** The declarations dead in the run, in site order. */
127
+ readonly candidates: readonly Candidate[];
128
+ /** The dead components, each before every component it reaches, each identifier once per run. */
129
+ readonly components: readonly Component[];
130
+ /** The records that hold: every record but one whose component file is not live, each once. */
131
+ readonly exempt: readonly Exemption[];
132
+ /**
133
+ * The records that held back a declaration the run without records, its marks counted,
134
+ * judges dead, or one the write-only kind would report without the record's read, in the
135
+ * declarations' site order.
136
+ */
137
+ readonly retained: readonly Exemption[];
138
+ /**
139
+ * Every declaration an unanswered question could have kept live, every one that makes a
140
+ * use only a name answered, and every one only such a question keeps live or decides
141
+ * otherwise: no candidate, and the run reports none of them (program.33).
142
+ */
143
+ readonly held: readonly string[];
144
+ /** The top-level declarations of the live component files, which no finding may name. */
145
+ readonly bindings: readonly string[];
146
+ /**
147
+ * Per declaration every counted reference to which has carriers (`Reference.carriers`),
148
+ * each distinct chain of them, innermost first: what deleting a write-only subject of
149
+ * each chain with its writes also deletes.
150
+ */
151
+ readonly carried: ReadonlyMap<string, readonly (readonly string[])[]>;
152
+ /** The path the strongest tier reaching a live declaration follows, or none. */
153
+ path(id: string): Path | undefined;
154
+ }
155
+
156
+ /** One step a pass reached a declaration by. */
157
+ type Step =
158
+ | { readonly k: "seed"; readonly origin: Origin }
159
+ | { readonly k: "reference"; readonly from: number; readonly edge: Edge }
160
+ | { readonly k: "export"; readonly from: number; readonly edge: Edge };
161
+
162
+ /** One declaration a held mechanism seeds, and what seeds it. */
163
+ interface HeldSeed {
164
+ readonly at: number;
165
+ readonly origin: Origin;
166
+ }
167
+
168
+ /** What one reachability pass seeds and follows. */
169
+ interface Seeds {
170
+ /** The held seeds, which reach through every reference, in order. */
171
+ readonly held: readonly HeldSeed[];
172
+ /** Per file position, the held seeds that join once the file is live. */
173
+ readonly gated: ReadonlyMap<number, readonly HeldSeed[]>;
174
+ /** Whether a reference only a name answered is followed. */
175
+ readonly guessed: boolean;
176
+ /** Whether reference counting holds the declaration at a position live, which a gate also asks. */
177
+ readonly counted: (at: number) => boolean;
178
+ }
179
+
180
+ /** What one pass reached, how, and which gates opened. */
181
+ interface Reached {
182
+ readonly reached: readonly boolean[];
183
+ readonly by: readonly (Step | undefined)[];
184
+ readonly opened: ReadonlySet<number>;
185
+ }
186
+
187
+ /** The references to one declaration the mode counts: under `production` none test code made but those it classes as production. */
188
+ function countedRefs(made: Counts | undefined, mode: Mode): number {
189
+ if (made === undefined) {
190
+ return 0;
191
+ }
192
+ if (!mode.production) {
193
+ return made.production + made.test;
194
+ }
195
+ return made.production + (mode.consumerTestsProduction === true ? made.consumerTest : 0);
196
+ }
197
+
198
+ /** The references to one declaration, split as the mode classes them, whatever it counts. */
199
+ function splitRefs(
200
+ made: Counts | undefined,
201
+ mode: Mode,
202
+ ): { readonly production: number; readonly test: number } {
203
+ const moved = mode.consumerTestsProduction === true ? (made?.consumerTest ?? 0) : 0;
204
+ return { production: (made?.production ?? 0) + moved, test: (made?.test ?? 0) - moved };
205
+ }
206
+
207
+ /**
208
+ * Per declaration, whether one pass reaches it, the step that first reached it, and the gated
209
+ * files that opened. Held seeds reach through every reference, a test file's included; roots
210
+ * and consumed references through those the mode counts, and a held seed met after a caller
211
+ * expands its declaration again. A read of a file reaches its exports; an evaluation, a root or
212
+ * a consumed reference to a file reaches its top level alone. The roots' first hops precede the
213
+ * consumed references, so a path is the shortest from the earliest root.
214
+ */
215
+ function reach(graph: Graph, seeds: Seeds, mode: Mode, namespaces = true): Reached {
216
+ const reached = graph.symbols.map(() => false);
217
+ const holding = graph.symbols.map(() => false);
218
+ const expanded = graph.symbols.map(() => [false, false]);
219
+ const by: (Step | undefined)[] = graph.symbols.map(() => undefined);
220
+ const opened = new Set<number>();
221
+ const queue: { readonly at: number; readonly holds: boolean }[] = [];
222
+
223
+ const enter = (at: number, holds: boolean, expands: boolean, step: Step): void => {
224
+ if (!reached[at]) {
225
+ reached[at] = true;
226
+ by[at] = step;
227
+ const gate = seeds.gated.get(at);
228
+ if (gate !== undefined && seeds.counted(at)) {
229
+ opened.add(at);
230
+ for (const seed of gate) {
231
+ enter(seed.at, true, false, { k: "seed", origin: seed.origin });
232
+ }
233
+ }
234
+ if (!holds) {
235
+ queue.push({ at, holds });
236
+ }
237
+ }
238
+ if (holds && !holding[at]) {
239
+ holding[at] = true;
240
+ queue.push({ at, holds });
241
+ }
242
+ const mode = expanded[at];
243
+ if (!expands || mode === undefined || mode[holds ? 1 : 0] === true) {
244
+ return;
245
+ }
246
+ mode[holds ? 1 : 0] = true;
247
+ for (const exported of graph.exportsOf[at] ?? []) {
248
+ enter(
249
+ exported,
250
+ holds,
251
+ false,
252
+ step.k === "seed" ? step : { k: "export", from: step.from, edge: step.edge },
253
+ );
254
+ }
255
+ };
256
+ const follows = (edge: Edge, holds: boolean): boolean =>
257
+ (seeds.guessed || !edge.guessed) && (holds || !edgeWithheldBy(mode, edge));
258
+
259
+ for (const seed of seeds.held) {
260
+ enter(seed.at, true, false, { k: "seed", origin: seed.origin });
261
+ }
262
+ for (const rooted of graph.rooted) {
263
+ enter(rooted.at, false, false, { k: "seed", origin: { k: "root", rooted } });
264
+ }
265
+ const firstHops = queue.length;
266
+ for (let head = 0; ; head += 1) {
267
+ if (head === firstHops) {
268
+ for (const edge of graph.consumed) {
269
+ if (follows(edge, false)) {
270
+ enter(edge.to, false, namespaces && !edge.evaluation, {
271
+ k: "reference",
272
+ from: OUTSIDE,
273
+ edge,
274
+ });
275
+ }
276
+ }
277
+ }
278
+ const next = queue[head];
279
+ if (next === undefined) {
280
+ break;
281
+ }
282
+ for (const edge of graph.out[next.at] ?? []) {
283
+ if (follows(edge, next.holds)) {
284
+ enter(edge.to, next.holds, namespaces && !edge.evaluation, {
285
+ k: "reference",
286
+ from: next.at,
287
+ edge,
288
+ });
289
+ }
290
+ }
291
+ }
292
+ return { reached, by, opened };
293
+ }
294
+
295
+ /**
296
+ * The directory a test runner reads a manual mock from, whose exports it puts in place of
297
+ * the module's: https://jestjs.io/docs/manual-mocks
298
+ */
299
+ const MANUAL_MOCKS = "__mocks__";
300
+
301
+ /** Whether a test runner calls or reads one export of a test file: a function, or any export of a manual mock. */
302
+ export function runnerCalls(symbol: InventorySymbol): boolean {
303
+ return symbol.kind === "function" || symbol.position.path.split("/").includes(MANUAL_MOCKS);
304
+ }
305
+
306
+ /** The held seeds of the flagged declarations, each a suppression mark. */
307
+ function markSeeds(flags: readonly boolean[]): readonly HeldSeed[] {
308
+ return flags.flatMap((mark, at) => (mark ? [{ at, origin: { k: "mark", at } as const }] : []));
309
+ }
310
+
311
+ /** Per declaration of the graph, whether one of the identifiers names it. */
312
+ function flagged(graph: Graph, ids: Iterable<string>): boolean[] {
313
+ const flags = graph.symbols.map(() => false);
314
+ for (const id of ids) {
315
+ const at = graph.at(id);
316
+ if (at !== OUTSIDE) {
317
+ flags[at] = true;
318
+ }
319
+ }
320
+ return flags;
321
+ }
322
+
323
+ /**
324
+ * Per declaration of the graph, whether the run that counts test references reaches it.
325
+ * That run's roots are the graph's own and, of each test file, its top level and every
326
+ * function it exports, which a test runner may call, and every export of a manual mock,
327
+ * which the runner reads; any other export of a test file, and test-support code, which
328
+ * runs where a test imports it, are judged as any file's are. `held` seeds it as a mark
329
+ * seeds a sweep; with `namespaces` false a file read reaches none of the file's exports.
330
+ */
331
+ export function reachedWithTheTests(
332
+ graph: Graph,
333
+ held: Iterable<string>,
334
+ namespaces = true,
335
+ ): readonly boolean[] {
336
+ const tests = graph.symbols.flatMap((symbol, at): Rooted[] => {
337
+ const exported = symbol.exported && graph.symbols[graph.parent[at] ?? OUTSIDE]?.kind === "file";
338
+ const called = exported && runnerCalls(symbol);
339
+ return graph.test[at] === true &&
340
+ graph.support[at] !== true &&
341
+ (symbol.kind === "file" || called)
342
+ ? [{ at, kind: "test-runner", source: symbol.position.path }]
343
+ : [];
344
+ });
345
+ return reach(
346
+ { ...graph, rooted: [...graph.rooted, ...tests] },
347
+ {
348
+ held: markSeeds(flagged(graph, held)),
349
+ gated: new Map(),
350
+ guessed: true,
351
+ counted: () => true,
352
+ },
353
+ { production: false },
354
+ namespaces,
355
+ ).reached;
356
+ }
357
+
358
+ /** What one view of the run judges of each declaration. */
359
+ interface Judged {
360
+ readonly live: readonly (readonly Relation[])[];
361
+ /** Per declaration, whether it is a candidate, a test of dead code included. */
362
+ readonly dead: readonly boolean[];
363
+ readonly testOfDeadCode: readonly boolean[];
364
+ /** Per declaration, the relation it is dead under where it is a candidate. */
365
+ readonly relation: readonly Relation[];
366
+ }
367
+
368
+ /** The held mechanisms one view counts. */
369
+ interface View {
370
+ /** The held seeds that hold their declarations back from the candidates, in order. */
371
+ readonly records: readonly HeldSeed[];
372
+ /** Per file the markup of which holds a record, the held seeds that join once it is live. */
373
+ readonly gated: ReadonlyMap<number, readonly HeldSeed[]>;
374
+ readonly marks: boolean;
375
+ readonly guessed: boolean;
376
+ }
377
+
378
+ /** One sweep's state over the matrix. */
379
+ class Sweeping {
380
+ readonly graph: Graph;
381
+ readonly policy: SweepPolicy;
382
+ readonly marked: readonly boolean[];
383
+ readonly called: readonly boolean[];
384
+ readonly unseen: readonly boolean[];
385
+
386
+ constructor(graph: Graph, policy: SweepPolicy) {
387
+ this.graph = graph;
388
+ this.policy = policy;
389
+ this.marked = flagged(graph, policy.marked);
390
+ const called = graph.symbols.map(() => false);
391
+ const unseen = graph.symbols.map(() => false);
392
+ for (const root of graph.rooted) {
393
+ if (namesACaller(root.kind)) {
394
+ called[root.at] = true;
395
+ } else if (policy.unseenCallers && root.kind === "published-api") {
396
+ unseen[root.at] = true;
397
+ }
398
+ }
399
+ this.called = called;
400
+ this.unseen = unseen;
401
+ }
402
+
403
+ /** The mode's count of one declaration's references, those only a name answered left out unless `guessed`. */
404
+ counted(at: number, guessed: boolean): number {
405
+ const all = countedRefs(this.graph.made[at], this.policy.mode);
406
+ return guessed ? all : all - countedRefs(this.graph.guessedMade[at], this.policy.mode);
407
+ }
408
+
409
+ /** Whether reference counting holds one declaration live in a view. */
410
+ countLive(at: number, view: View): boolean {
411
+ return (
412
+ (view.marks && this.marked[at] === true) ||
413
+ this.called[at] === true ||
414
+ this.counted(at, view.guessed) > 0
415
+ );
416
+ }
417
+
418
+ /** What one view reaches: its records and marks held, its gates open once their file is live. */
419
+ reachOf(view: View, mode: Mode = this.policy.mode): Reached {
420
+ return reach(
421
+ this.graph,
422
+ {
423
+ held: [...view.records, ...(view.marks ? markSeeds(this.marked) : [])],
424
+ gated: view.gated,
425
+ guessed: view.guessed,
426
+ counted: (at) => this.countLive(at, view),
427
+ },
428
+ mode,
429
+ );
430
+ }
431
+
432
+ /**
433
+ * Judges every declaration under one view: the relations, then the candidates, which no
434
+ * record, opened gate or `kept` flag holds back, then the tests of dead code.
435
+ */
436
+ judge(view: View, reached: Reached, kept: readonly boolean[] = []): Judged {
437
+ const { graph } = this;
438
+ const exempt = graph.symbols.map(() => false);
439
+ for (const seed of [
440
+ ...view.records,
441
+ ...[...reached.opened].flatMap((file) => view.gated.get(file) ?? []),
442
+ ]) {
443
+ exempt[seed.at] = true;
444
+ }
445
+ const live = graph.symbols.map((_symbol, at): readonly Relation[] => [
446
+ ...(this.countLive(at, view) ? (["reference-counting"] as const) : []),
447
+ ...(reached.reached[at] === true ? (["reachability"] as const) : []),
448
+ ]);
449
+ const dead = graph.symbols.map(
450
+ (_symbol, at) =>
451
+ graph.subject[at] === true &&
452
+ exempt[at] !== true &&
453
+ kept[at] !== true &&
454
+ (live[at]?.length ?? 0) < RELATIONS.length,
455
+ );
456
+ const held = exempt.map((one, at) => one || (view.marks && this.marked[at] === true));
457
+ const testOfDeadCode = this.admit(dead, held);
458
+ const relation = graph.symbols.map((_symbol, at): Relation =>
459
+ this.counted(at, view.guessed) === 0 ? "reference-counting" : "reachability",
460
+ );
461
+ return { live, dead, testOfDeadCode, relation };
462
+ }
463
+
464
+ /**
465
+ * Admits every test declaration whose referenced production declarations, read from every
466
+ * reference the test makes, are non-empty and all dead. An ambient declaration is no
467
+ * production declaration here, and a published one outside programs may call counts as
468
+ * live. A support declaration a test with a live target also references is a live target of
469
+ * every test referencing it. A held test is never admitted.
470
+ */
471
+ admit(dead: boolean[], held: readonly boolean[]): readonly boolean[] {
472
+ const { graph } = this;
473
+ const testCode = (at: number): boolean => graph.test[at] === true || graph.support[at] === true;
474
+ const judged = graph.symbols.map((_symbol, at) => {
475
+ let targets = 0;
476
+ let live = 0;
477
+ if (testCode(at) && graph.subject[at] === true && held[at] !== true) {
478
+ for (const edge of graph.out[at] ?? []) {
479
+ if (
480
+ testCode(edge.to) ||
481
+ graph.subject[edge.to] !== true ||
482
+ graph.ambient[edge.to] === true
483
+ ) {
484
+ continue;
485
+ }
486
+ targets += 1;
487
+ if (dead[edge.to] !== true || this.unseen[edge.to] === true) {
488
+ live += 1;
489
+ }
490
+ }
491
+ }
492
+ return { targets, live };
493
+ });
494
+ const shared = new Set<number>();
495
+ judged.forEach(({ live }, at) => {
496
+ if (live > 0) {
497
+ for (const edge of graph.out[at] ?? []) {
498
+ if (graph.support[edge.to] === true) {
499
+ shared.add(edge.to);
500
+ }
501
+ }
502
+ }
503
+ });
504
+ const admitted = graph.symbols.map(() => false);
505
+ judged.forEach(({ targets, live }, at) => {
506
+ const sharing = (graph.out[at] ?? []).some((edge) => shared.has(edge.to) && edge.to !== at);
507
+ if (targets > 0 && live === 0 && !sharing) {
508
+ dead[at] = true;
509
+ admitted[at] = true;
510
+ }
511
+ });
512
+ return admitted;
513
+ }
514
+ }
515
+
516
+ /** The path one pass reached a declaration by, from the step it recorded back to its origin. */
517
+ function pathIn(by: readonly (Step | undefined)[], at: number): Path | undefined {
518
+ const hops: Hop[] = [];
519
+ let cursor = at;
520
+ for (let step = by[cursor]; step !== undefined; step = by[cursor]) {
521
+ if (step.k === "seed") {
522
+ return { origin: step.origin, hops };
523
+ }
524
+ hops.unshift(
525
+ step.k === "reference"
526
+ ? { k: "reference", edge: step.edge }
527
+ : { k: "export", edge: step.edge, to: cursor },
528
+ );
529
+ if (step.from === OUTSIDE) {
530
+ return { origin: { k: "consumed", edge: step.edge }, hops };
531
+ }
532
+ cursor = step.from;
533
+ }
534
+ return undefined;
535
+ }
536
+
537
+ /**
538
+ * Per declaration of the union, whether one of `seeds` is it or reaches it through `dead`
539
+ * declarations.
540
+ */
541
+ function testUsedReach(
542
+ graph: Graph,
543
+ seeds: readonly boolean[],
544
+ dead: readonly boolean[],
545
+ ): readonly boolean[] {
546
+ // A file is no candidate, and its top level is what reaches what it alone calls.
547
+ const reached = seeds.map((seed, at) => seed && dead[at] === true);
548
+ const queue = seeds.flatMap((one, at) => (one ? [at] : []));
549
+ for (let at = queue.pop(); at !== undefined; at = queue.pop()) {
550
+ for (const edge of graph.out[at] ?? []) {
551
+ if (dead[edge.to] === true && reached[edge.to] !== true) {
552
+ reached[edge.to] = true;
553
+ queue.push(edge.to);
554
+ }
555
+ }
556
+ }
557
+ return reached;
558
+ }
559
+
560
+ /** Per declaration, whether test code `reached` holds and `admitted` rejects references it. */
561
+ function keptByTests(
562
+ graph: Graph,
563
+ reached: readonly boolean[],
564
+ admitted: readonly boolean[],
565
+ ): readonly boolean[] {
566
+ const kept = graph.symbols.map(() => false);
567
+ graph.out.forEach((edges, from) => {
568
+ const testCode = graph.test[from] === true || graph.support[from] === true;
569
+ if (!testCode || reached[from] !== true || admitted[from] === true) {
570
+ return;
571
+ }
572
+ for (const edge of edges) {
573
+ if (edge.test) {
574
+ kept[edge.to] = true;
575
+ }
576
+ }
577
+ });
578
+ return kept;
579
+ }
580
+
581
+ /**
582
+ * The dead components of one sweep over the union, with the file declarations at `files`
583
+ * dead as well: a file that is deleted whole takes the declarations it holds and every
584
+ * dead declaration only its top level references with it.
585
+ */
586
+ export function deadComponents(
587
+ matrix: Matrix,
588
+ result: Pick<Sweep, "liveUnder" | "candidates">,
589
+ files: ReadonlySet<number>,
590
+ ): readonly Component[] {
591
+ const union = matrix.union;
592
+ const byId = new Map(result.candidates.map((candidate) => [candidate.id, candidate]));
593
+ const candidateAt = (at: number): Candidate | undefined => byId.get(union.symbols[at]?.id ?? "");
594
+ const positional: Positional = {
595
+ dependents: union.symbols.map((symbol) =>
596
+ (matrix.dependents.get(symbol.id) ?? [])
597
+ .map((id) => union.at(id))
598
+ .filter((at) => at !== OUTSIDE),
599
+ ),
600
+ live: union.symbols.map(
601
+ (symbol) => result.liveUnder.get(symbol.id)?.includes("reachability") === true,
602
+ ),
603
+ };
604
+ return componentsOf(
605
+ union,
606
+ union.symbols.map((_symbol, at) => {
607
+ const candidate = candidateAt(at);
608
+ return files.has(at) || (candidate !== undefined && candidate.heldByTests === undefined);
609
+ }),
610
+ union.symbols.map((_symbol, at) => candidateAt(at)?.testOfDeadCode === true),
611
+ union.symbols.map((_symbol, at) => candidateAt(at)?.unreferencedTest === true),
612
+ positional,
613
+ );
614
+ }
615
+
616
+ /** The held seeds of the policy's records, by how they hold. */
617
+ function seedsOf(
618
+ graph: Graph,
619
+ policy: SweepPolicy,
620
+ ): {
621
+ readonly records: readonly HeldSeed[];
622
+ readonly unanswered: readonly HeldSeed[];
623
+ readonly gated: ReadonlyMap<number, readonly HeldSeed[]>;
624
+ } {
625
+ const files = new Map(
626
+ graph.symbols.flatMap((symbol, at) =>
627
+ symbol.kind === "file" ? [[symbol.position.path, at] as const] : [],
628
+ ),
629
+ );
630
+ const records: HeldSeed[] = [];
631
+ const unanswered: HeldSeed[] = [];
632
+ const gated = new Map<number, HeldSeed[]>();
633
+ const gate = (path: string, seed: HeldSeed): void => {
634
+ const file = files.get(path);
635
+ if (file !== undefined) {
636
+ gated.set(file, [...(gated.get(file) ?? []), seed]);
637
+ }
638
+ };
639
+ for (const record of policy.exempt) {
640
+ const at = graph.at(record.id);
641
+ if (at === OUTSIDE) {
642
+ continue;
643
+ }
644
+ const seed = { at, origin: { k: "exemption", record } as const };
645
+ if (record.whileLive !== undefined) {
646
+ gate(record.whileLive, seed);
647
+ } else if (isUnansweredRecord(record)) {
648
+ unanswered.push(seed);
649
+ } else {
650
+ records.push(seed);
651
+ }
652
+ }
653
+ for (const id of policy.unanswered) {
654
+ const at = graph.at(id);
655
+ if (at !== OUTSIDE) {
656
+ unanswered.push({ at, origin: { k: "unanswered", at } });
657
+ }
658
+ }
659
+ return { records, unanswered, gated };
660
+ }
661
+
662
+ /**
663
+ * Answers which declarations of the run are dead under the policy, once over the union of
664
+ * every configuration's references and roots, so one configuration's code keeps a declaration
665
+ * live in the run (matrix.4, matrix.5). Where a question went unanswered, the view without the
666
+ * guessed tier decides what the gap holds: what it judges dead and the full view judges live,
667
+ * or dead by another rule or test-of-dead-code answer; those are no candidates. A record held
668
+ * a declaration back where the view without any record judges it dead.
669
+ */
670
+ export function sweep(matrix: Matrix, policy: SweepPolicy): Sweep {
671
+ const graph = matrix.union;
672
+ const sweeping = new Sweeping(graph, policy);
673
+ const { records, unanswered, gated } = seedsOf(graph, policy);
674
+ const full: View = { records: [...records, ...unanswered], gated, marks: true, guessed: true };
675
+ const reached = sweeping.reachOf(full);
676
+ const openedSeeds = [...reached.opened].flatMap((file) => gated.get(file) ?? []);
677
+
678
+ const held = new Set(policy.unanswered);
679
+ if (held.size > 0) {
680
+ for (const reference of graph.references) {
681
+ if (reference.guessed === true) {
682
+ held.add(reference.from);
683
+ }
684
+ }
685
+ const fullJudged = sweeping.judge(full, reached);
686
+ const bare: View = {
687
+ records: [...records, ...openedSeeds],
688
+ gated: new Map(),
689
+ marks: true,
690
+ guessed: false,
691
+ };
692
+ const bareJudged = sweeping.judge(bare, sweeping.reachOf(bare));
693
+ graph.symbols.forEach((symbol, at) => {
694
+ if (
695
+ bareJudged.dead[at] === true &&
696
+ (fullJudged.dead[at] !== true ||
697
+ fullJudged.testOfDeadCode[at] !== bareJudged.testOfDeadCode[at] ||
698
+ fullJudged.relation[at] !== bareJudged.relation[at])
699
+ ) {
700
+ held.add(symbol.id);
701
+ }
702
+ });
703
+ }
704
+ const judged = sweeping.judge(full, reached, flagged(graph, held));
705
+
706
+ const openedPaths = new Set(
707
+ [...reached.opened].flatMap((file) => graph.symbols[file]?.position.path ?? []),
708
+ );
709
+ const exempt = exemptionsOf(
710
+ policy.exempt.flatMap(({ whileLive, ...record }) =>
711
+ whileLive === undefined || openedPaths.has(whileLive) ? [record] : [],
712
+ ),
713
+ );
714
+ // The markup source makes each top-level binding a use of the file's (program.20), so a
715
+ // live file's bindings are live; the sweep answers which they are for the findings.
716
+ const components = new Set(policy.componentFiles);
717
+ const bindings = graph.symbols.flatMap((symbol, at) => {
718
+ const file = graph.parent[at] ?? OUTSIDE;
719
+ const holder = graph.symbols[file];
720
+ return holder?.kind === "file" &&
721
+ components.has(holder.position.path) &&
722
+ (judged.live[file]?.length ?? 0) === RELATIONS.length
723
+ ? [symbol.id]
724
+ : [];
725
+ });
726
+ const heldIds = [
727
+ ...policy.marked,
728
+ ...exempt.map((record) => record.id),
729
+ ...policy.unanswered,
730
+ ...bindings,
731
+ ];
732
+ const testReached = reachedWithTheTests(graph, heldIds);
733
+ // A test uses what it names: a file a test reads through a star re-export or a
734
+ // namespace keeps the exports nothing names at its own class.
735
+ const testNamed = reachedWithTheTests(graph, heldIds, false);
736
+ const kept = keptByTests(graph, testReached, judged.testOfDeadCode);
737
+ const testUsed = testUsedReach(
738
+ graph,
739
+ graph.symbols.map((_symbol, at) => matrix.testUsed[at] === true && testNamed[at] === true),
740
+ judged.dead,
741
+ );
742
+ const heldBy: (string | undefined)[] = graph.symbols.map(() => undefined);
743
+ const candidates: Candidate[] = [];
744
+ graph.symbols.forEach((symbol, at) => {
745
+ if (judged.dead[at] !== true) {
746
+ return;
747
+ }
748
+ const admitted = judged.testOfDeadCode[at] === true;
749
+ const split = splitRefs(graph.made[at], policy.mode);
750
+ const heldByTests =
751
+ heldBy[graph.parent[at] ?? OUTSIDE] ??
752
+ (graph.support[at] === true && split.test > 0 && !admitted ? symbol.id : undefined);
753
+ heldBy[at] = heldByTests;
754
+ const unreferenced = graph.test[at] === true && !admitted && testReached[at] !== true;
755
+ candidates.push({
756
+ id: symbol.id,
757
+ productionRefs: split.production,
758
+ testRefs: split.test,
759
+ // The run that judges a test-file declaration counts its test references.
760
+ relation: unreferenced
761
+ ? split.production + split.test === 0
762
+ ? "reference-counting"
763
+ : "reachability"
764
+ : (judged.relation[at] ?? "reachability"),
765
+ testOfDeadCode: admitted,
766
+ configurations: matrix.heldIn[at] ?? [],
767
+ heldByTests,
768
+ unreferencedTest: unreferenced,
769
+ testUsed: testUsed[at] === true,
770
+ keptByTests: kept[at] === true,
771
+ });
772
+ });
773
+ const liveUnder = new Map(
774
+ graph.symbols.flatMap((symbol, at) => {
775
+ const relations = judged.live[at] ?? [];
776
+ return relations.length === 0 ? [] : [[symbol.id, relations] as const];
777
+ }),
778
+ );
779
+
780
+ let retained: readonly Exemption[] = [];
781
+ if (exempt.length > 0) {
782
+ const without: View = { records: [], gated: new Map(), marks: true, guessed: true };
783
+ const bare = sweeping.judge(without, sweeping.reachOf(without));
784
+ const holding = new Map<string, Exemption[]>();
785
+ for (const record of exempt) {
786
+ const at = graph.at(record.id);
787
+ if (bare.dead[at] === true || policy.writeOnly.has(record.id)) {
788
+ holding.set(record.id, [...(holding.get(record.id) ?? []), record]);
789
+ }
790
+ }
791
+ retained = graph.symbols.flatMap((symbol) => holding.get(symbol.id) ?? []);
792
+ }
793
+
794
+ const tiers: View[] = [
795
+ { records: [], gated: new Map(), marks: false, guessed: false },
796
+ { records, gated, marks: false, guessed: false },
797
+ { records, gated, marks: true, guessed: false },
798
+ full,
799
+ ];
800
+ const passes: Reached[] = [];
801
+ const path = (id: string): Path | undefined => {
802
+ const at = graph.at(id);
803
+ if (at === OUTSIDE) {
804
+ return undefined;
805
+ }
806
+ for (const [place, view] of tiers.entries()) {
807
+ const pass = (passes[place] ??=
808
+ place === tiers.length - 1 ? reached : sweeping.reachOf(view));
809
+ if (pass.reached[at] === true) {
810
+ return pathIn(pass.by, at);
811
+ }
812
+ }
813
+ // A production run counts no test reference, so what only tests reach is shown by them.
814
+ if (!policy.mode.production) {
815
+ return undefined;
816
+ }
817
+ const counting = (passes[tiers.length] ??= sweeping.reachOf(tiers[0] ?? full, {
818
+ production: false,
819
+ }));
820
+ return counting.reached[at] === true ? pathIn(counting.by, at) : undefined;
821
+ };
822
+
823
+ const sweepAnswer = { liveUnder, candidates };
824
+ return {
825
+ ...sweepAnswer,
826
+ components: deadComponents(matrix, sweepAnswer, new Set()),
827
+ exempt,
828
+ retained,
829
+ held: [...held],
830
+ bindings,
831
+ carried: carriedOf(graph, policy.mode),
832
+ path,
833
+ };
834
+ }
835
+
836
+ /** {@link Sweep.carried}: a declaration one counted reference without a carrier names carries nothing. */
837
+ function carriedOf(graph: Graph, mode: Mode): ReadonlyMap<string, readonly (readonly string[])[]> {
838
+ const carried = new Map<string, (readonly string[])[]>();
839
+ const free = new Set<number>();
840
+ for (const edge of [...graph.out.flat(), ...graph.consumed]) {
841
+ if (edgeWithheldBy(mode, edge) || free.has(edge.to)) {
842
+ continue;
843
+ }
844
+ const id = graph.symbols[edge.to]?.id ?? "";
845
+ const chain = graph.references[edge.reference]?.carriers ?? [];
846
+ const known = carried.get(id) ?? [];
847
+ if (chain.length === 0) {
848
+ free.add(edge.to);
849
+ carried.delete(id);
850
+ } else if (!known.some((one) => one.join("\u0000") === chain.join("\u0000"))) {
851
+ carried.set(id, [...known, chain]);
852
+ }
853
+ }
854
+ return carried;
855
+ }