@cplieger/deadset-ts 8.0.0-dev.1 → 8.0.0-dev.2

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/src/analysis.ts CHANGED
@@ -977,7 +977,7 @@ function readRun<Extra>(
977
977
  }));
978
978
  // Test-support code is code only test code imports in any configuration of the run: a
979
979
  // configuration that holds no test file says nothing about what the tests import.
980
- const support = testSupportFiles(
980
+ const { support, testUsed } = testSupportFiles(
981
981
  withOutside.flatMap((one) => one.projectRead.held.symbols),
982
982
  withOutside.flatMap((one) => one.references),
983
983
  withOutside.flatMap((one) => one.projectRead.rooted.liveUnderReachability),
@@ -999,6 +999,7 @@ function readRun<Extra>(
999
999
  const made = supportReferences([...one.references, ...(satisfied?.references ?? [])], support);
1000
1000
  const held = new Set(projectRead.held.symbols.map((symbol) => symbol.position.path));
1001
1001
  const supportFiles = [...support].filter((path) => held.has(path)).sort();
1002
+ const testUsedFiles = [...testUsed].filter((path) => held.has(path)).sort();
1002
1003
  const configured: Configured = {
1003
1004
  configuration: projectRead.configuration,
1004
1005
  symbols: projectRead.held.symbols,
@@ -1006,6 +1007,7 @@ function readRun<Extra>(
1006
1007
  roots: projectRead.rooted.liveUnderReachability,
1007
1008
  testFiles: [...one.testFiles, ...supportFiles],
1008
1009
  ...(supportFiles.length === 0 ? {} : { supportFiles }),
1010
+ ...(testUsedFiles.length === 0 ? {} : { testUsedFiles }),
1009
1011
  ...(projectRead.held.ambient.size === 0 ? {} : { ambient: projectRead.held.ambient }),
1010
1012
  ...(projectRead.referenceOnly.length === 0
1011
1013
  ? {}
@@ -26,25 +26,24 @@ function isGlobalBlock(
26
26
  }
27
27
 
28
28
  /**
29
- * For each reference to a declaration an augmentation block, or the global scope of a
30
- * file in `scripts`, holds, a read at the same place of every interface and namespace
31
- * between it and the block or the file, and of the block itself. `scripts` holds the
32
- * files that are no module, by identifier.
29
+ * Per declaration, the interfaces and namespaces between it and the augmentation block or
30
+ * the file in `scripts` that holds it, with the block itself, or none where no such block
31
+ * or file holds it; and whether that holder is the global scope rather than a module
32
+ * declaration. `scripts` holds the files that are no module, by identifier.
33
33
  */
34
- export function augmentationReferences(
34
+ function holdersOf(
35
35
  held: Inventory,
36
- found: readonly Reference[],
37
- scripts: ReadonlySet<string> = new Set(),
38
- ): readonly Reference[] {
36
+ scripts: ReadonlySet<string>,
37
+ ): ((id: string) => { readonly ids: readonly string[]; readonly global: boolean }) | undefined {
39
38
  const files = new Set(held.symbols.filter((one) => one.kind === "file").map((one) => one.id));
40
39
  const globals = new Set(
41
40
  held.symbols.filter((one) => isGlobalBlock(one, held, files)).map((one) => one.id),
42
41
  );
43
42
  if (globals.size === 0 && held.modules.size === 0 && scripts.size === 0) {
44
- return [];
43
+ return undefined;
45
44
  }
46
45
  const byId = new Map(held.symbols.map((one) => [one.id, one]));
47
- const holders = (id: string): string[] => {
46
+ return (id) => {
48
47
  const ids: string[] = [];
49
48
  for (
50
49
  let at = byId.get(byId.get(id)?.parent ?? "");
@@ -52,29 +51,73 @@ export function augmentationReferences(
52
51
  at = byId.get(at.parent)
53
52
  ) {
54
53
  if (globals.has(at.id) || held.modules.has(at.id)) {
55
- return [...ids, at.id];
54
+ return { ids: [...ids, at.id], global: globals.has(at.id) };
56
55
  }
57
56
  if (scripts.has(at.id)) {
58
- return ids;
57
+ return { ids, global: true };
59
58
  }
60
59
  if (at.kind === "interface" || at.kind === "namespace") {
61
60
  ids.push(at.id);
62
61
  }
63
62
  }
64
- return [];
63
+ return { ids: [], global: false };
65
64
  };
65
+ }
66
+
67
+ /**
68
+ * The ambient interfaces of the global scope among `interfaces`: each one a declaration
69
+ * file or a `declare` holds that a `declare global` block, or the top level of a file in
70
+ * `scripts`, declares, directly or inside a namespace there.
71
+ */
72
+ export function ambientGlobalInterfaces(
73
+ held: Inventory,
74
+ interfaces: readonly string[],
75
+ scripts: ReadonlySet<string>,
76
+ ): readonly string[] {
77
+ const holders = holdersOf(held, scripts);
78
+ return holders === undefined
79
+ ? []
80
+ : interfaces.filter((id) => held.ambient.has(id) && holders(id).global);
81
+ }
82
+
83
+ /**
84
+ * For each reference to a declaration an augmentation block, or the global scope of a
85
+ * file in `scripts` (the files that are no module), holds, a read at the same place of
86
+ * every interface and namespace between it and the block or the file, and of the block
87
+ * itself. A read through an interface of `merged`, an ambient global interface merging
88
+ * into a language or library one, is a production read whoever writes it: it types a
89
+ * value the runtime provides, so a test reading a member needs it as code does.
90
+ */
91
+ export function augmentationReferences(
92
+ held: Inventory,
93
+ found: readonly Reference[],
94
+ scripts: ReadonlySet<string> = new Set(),
95
+ merged: ReadonlySet<string> = new Set(),
96
+ ): readonly Reference[] {
97
+ const holders = holdersOf(held, scripts);
98
+ if (holders === undefined) {
99
+ return [];
100
+ }
101
+ const fileOf = new Map(
102
+ held.symbols.filter((one) => one.kind === "file").map((one) => [one.position.path, one.id]),
103
+ );
66
104
  // Two uses at one place of declarations one block holds read the block once there.
67
105
  const made = new Set(found.map(referenceKey));
68
106
  const reads = new Map<string, Reference>();
69
107
  for (const reference of found) {
70
- for (const to of holders(reference.to)) {
108
+ const { ids } = holders(reference.to);
109
+ // A test's read through such an interface is held by the test file, which the test
110
+ // runner loads whichever of its declarations runs.
111
+ const typed = reference.test && ids.some((id) => merged.has(id));
112
+ const from = typed ? (fileOf.get(reference.position.path) ?? reference.from) : reference.from;
113
+ for (const to of ids) {
71
114
  const read: Reference = {
72
- from: reference.from,
115
+ from,
73
116
  to,
74
117
  position: reference.position,
75
118
  use: "read",
76
119
  resolution: reference.resolution,
77
- test: reference.test,
120
+ test: reference.test && !typed,
78
121
  };
79
122
  const key = referenceKey(read);
80
123
  if (!made.has(key) && !reads.has(key)) {
@@ -4,7 +4,7 @@ export const RECORD = {
4
4
  conformance: {
5
5
  corpusVersion: "6.0.0",
6
6
  result: "pass",
7
- digest: "sha256:76c07ac14bda6e302444918d832b01844bac6f29b6f22a742d538804480c0769",
7
+ digest: "sha256:d65daae4b7c95050c07896b166d0250d233cca71f97de75a6086c1d80fe76232",
8
8
  },
9
9
  gaps: [],
10
10
  } as const;
@@ -209,6 +209,31 @@ const ANGULAR_PACKAGE_KEYS: readonly PackageKey[] = [
209
209
  "scripts",
210
210
  ].map((key) => ({ key, files: ["angular.json"] }));
211
211
 
212
+ /**
213
+ * The test runners and the assertion, mock and runner libraries whose import is test
214
+ * evidence beside each runner a row reads test files for. Each applies whether or not a
215
+ * manifest declares it, so none is a row.
216
+ */
217
+ export const TEST_LIBRARIES: readonly string[] = [
218
+ "@jest/globals",
219
+ "@playwright/test",
220
+ "ava",
221
+ "chai",
222
+ "expect",
223
+ "jasmine",
224
+ "jest",
225
+ "mocha",
226
+ "msw",
227
+ "nock",
228
+ "node:test",
229
+ "sinon",
230
+ "tap",
231
+ "uvu",
232
+ ];
233
+
234
+ /** The scopes every package of which is a test library. */
235
+ export const TEST_LIBRARY_SCOPES: readonly string[] = ["@testing-library/"];
236
+
212
237
  /** Every row, ordered by name and then by range. */
213
238
  export const CONVENTION_ROWS: readonly ConventionRow[] = [
214
239
  {
package/src/finding.ts CHANGED
@@ -119,6 +119,11 @@ export interface CompletedFinding extends Finding {
119
119
  readonly reachabilityClass: Confidence;
120
120
  /** The reachability class capped by the code's ceiling. */
121
121
  readonly confidence: Confidence;
122
+ /**
123
+ * Where test-used code alone lowers the confidence, the one the finding would carry
124
+ * otherwise, which its component's cap and the dials read.
125
+ */
126
+ readonly componentConfidence?: Confidence;
122
127
  /**
123
128
  * The relation that decided the subject: present on a declaration the sweep judged
124
129
  * dead, absent on a live one and on a subject that is no declaration.
@@ -132,6 +132,20 @@ function reachabilityClasses(input: EmitterInput): (id: string) => Confidence {
132
132
  };
133
133
  }
134
134
 
135
+ /** The target symbols one test of dead code references, by id: production declarations the sweep judges. */
136
+ function testTargets(union: Graph, id: string): readonly string[] {
137
+ return (union.out[union.at(id)] ?? []).flatMap((edge) => {
138
+ const target = union.symbols[edge.to];
139
+ const testCode = union.test[edge.to] === true || union.support[edge.to] === true;
140
+ return target !== undefined &&
141
+ !testCode &&
142
+ union.ambient[edge.to] !== true &&
143
+ union.subject[edge.to] === true
144
+ ? [target.id]
145
+ : [];
146
+ });
147
+ }
148
+
135
149
  /**
136
150
  * The confidence ceiling of a test of dead code: the lowest reachability class among the
137
151
  * target symbols it references, because its deadness is theirs, whether or not they share
@@ -145,25 +159,40 @@ function testCeilings(
145
159
  const tests = new Set(
146
160
  input.swept.sweep.candidates.filter((one) => one.testOfDeadCode).map((one) => one.id),
147
161
  );
148
- return (id) => {
149
- let lowest: Confidence = "certain";
150
- if (!tests.has(id)) {
151
- return lowest;
152
- }
153
- for (const edge of union.out[union.at(id)] ?? []) {
154
- const target = union.symbols[edge.to];
155
- const testCode = union.test[edge.to] === true || union.support[edge.to] === true;
156
- if (
157
- target !== undefined &&
158
- !testCode &&
159
- union.ambient[edge.to] !== true &&
160
- union.subject[edge.to] === true
161
- ) {
162
- lowest = capped(lowest, classOf(target.id));
163
- }
162
+ return (id) =>
163
+ tests.has(id)
164
+ ? testTargets(union, id).reduce<Confidence>(
165
+ (lowest, target) => capped(lowest, classOf(target)),
166
+ "certain",
167
+ )
168
+ : "certain";
169
+ }
170
+
171
+ /**
172
+ * The subjects dead only because production code only tests use is: each declaration the
173
+ * sweep marks test-used, each `DS1502` file of such code, and each test of dead code every
174
+ * target of which is one. Nothing they hold references a dead declaration outside them, so
175
+ * the rest of a component they share is reported without them.
176
+ */
177
+ function testUsedSubjects(input: EmitterInput): {
178
+ readonly declarations: ReadonlySet<string>;
179
+ readonly files: ReadonlySet<string>;
180
+ } {
181
+ const { matrix, sweep } = input.swept;
182
+ const union = matrix.union;
183
+ const used = new Set(sweep.candidates.filter((one) => one.testUsed).map((one) => one.id));
184
+ for (const test of sweep.candidates.filter((one) => one.testOfDeadCode)) {
185
+ const targets = testTargets(union, test.id);
186
+ if (targets.length > 0 && targets.every((target) => used.has(target))) {
187
+ used.add(test.id);
164
188
  }
165
- return lowest;
166
- };
189
+ }
190
+ const files = new Set(
191
+ union.symbols.flatMap((symbol, at) =>
192
+ matrix.testUsed[at] === true ? [symbol.position.path] : [],
193
+ ),
194
+ );
195
+ return { declarations: used, files };
167
196
  }
168
197
 
169
198
  /**
@@ -321,6 +350,7 @@ export function completed(
321
350
  const candidates = new Map(swept.sweep.candidates.map((candidate) => [candidate.id, candidate]));
322
351
  const classOf = reachabilityClasses(input);
323
352
  const ceilingOf = testCeilings(input, classOf);
353
+ const testUsed = testUsedSubjects(input);
324
354
  const { components, computed, files } = runComponents(swept, findings);
325
355
  const componentOf = findingComponents(swept, components, input.config.reporters.cascade);
326
356
  const held = new Set(components.filter((one) => one.held).flatMap((one) => one.members));
@@ -394,8 +424,17 @@ export function completed(
394
424
  candidate === undefined
395
425
  ? union.made[at]
396
426
  : { production: candidate.productionRefs, test: candidate.testRefs };
397
- const reachabilityClass =
427
+ const ownClass =
398
428
  judged === undefined ? (finding.reachabilityClass ?? "certain") : classOf(judged.id);
429
+ const used =
430
+ judged !== undefined
431
+ ? candidate !== undefined && testUsed.declarations.has(judged.id)
432
+ : finding.code === FILE_NEVER_IMPORTED && testUsed.files.has(finding.position.path);
433
+ const reachabilityClass = used && candidate?.testOfDeadCode !== true ? "possible" : ownClass;
434
+ const untested = capped(
435
+ capped(capped(ownClass, row.maxClass), rootsCeiling(input, finding, candidate)),
436
+ judged === undefined ? "certain" : ceilingOf(judged.id),
437
+ );
399
438
  const generated = input.files.generated?.has(finding.position.path) === true;
400
439
  const fixability = generated
401
440
  ? "none"
@@ -420,10 +459,8 @@ export function completed(
420
459
  position: finding.position,
421
460
  symbol: finding.symbol,
422
461
  reachabilityClass,
423
- confidence: capped(
424
- capped(capped(reachabilityClass, row.maxClass), rootsCeiling(input, finding, candidate)),
425
- judged === undefined ? "certain" : ceilingOf(judged.id),
426
- ),
462
+ confidence: used ? "possible" : untested,
463
+ ...(used ? { componentConfidence: untested } : {}),
427
464
  ...(candidate === undefined ? {} : { livenessRelation: candidate.relation }),
428
465
  testOnly: judged !== undefined && counts?.production === 0 && counts.test > 0,
429
466
  generated,
@@ -450,7 +487,7 @@ export function completed(
450
487
  /**
451
488
  * The findings with every finding of a dead component capped by the lowest confidence
452
489
  * among the component's root members, so the minimum confidence reports or withholds a
453
- * component whole.
490
+ * component whole but for the findings test-used code lowers on their own.
454
491
  */
455
492
  function componentCapped(
456
493
  findings: readonly CompletedFinding[],
@@ -461,17 +498,29 @@ function componentCapped(
461
498
  const { id, root } = finding.component;
462
499
  if (root && dead.has(id)) {
463
500
  const held = lowest.get(id);
464
- lowest.set(id, held === undefined ? finding.confidence : capped(held, finding.confidence));
501
+ const own = componentConfidenceOf(finding);
502
+ lowest.set(id, held === undefined ? own : capped(held, own));
465
503
  }
466
504
  }
467
505
  return findings.map((finding) => {
468
506
  const cap = lowest.get(finding.component.id);
469
- return cap === undefined || RANK[cap] >= RANK[finding.confidence]
470
- ? finding
471
- : { ...finding, confidence: cap };
507
+ if (cap === undefined) {
508
+ return finding;
509
+ }
510
+ const own = finding.componentConfidence;
511
+ return {
512
+ ...finding,
513
+ confidence: capped(finding.confidence, cap),
514
+ ...(own === undefined ? {} : { componentConfidence: capped(own, cap) }),
515
+ };
472
516
  });
473
517
  }
474
518
 
519
+ /** The confidence a finding gives its component's cap and the dials. */
520
+ function componentConfidenceOf(finding: CompletedFinding): Confidence {
521
+ return finding.componentConfidence ?? finding.confidence;
522
+ }
523
+
475
524
  /** Whether the dials withhold a finding by its own severity or confidence. */
476
525
  function dialed(
477
526
  finding: Pick<CompletedFinding, "severity" | "confidence">,
@@ -494,7 +543,11 @@ export function dialsOf(
494
543
  const minConfidence = input.config.analysis.minConfidence;
495
544
  const components = new Set(
496
545
  findings
497
- .filter((finding) => finding.component.root && dialed(finding, minConfidence))
546
+ .filter(
547
+ (finding) =>
548
+ finding.component.root &&
549
+ dialed({ ...finding, confidence: componentConfidenceOf(finding) }, minConfidence),
550
+ )
498
551
  .map((finding) => finding.component.id),
499
552
  );
500
553
  return {
@@ -61,8 +61,9 @@ interface Judged {
61
61
  * The code one candidate is reported under, or none, each symbol once under the most
62
62
  * specific code: a test of dead code first; no other test-file declaration or enum
63
63
  * member nothing names; a test-support declaration test code references as test-only
64
- * use; then deprecation, a test reference, and the unreferenced codes, an unimportable
65
- * unused export being the unreachable kind's.
64
+ * use; then deprecation, a test reference, or only dead production references beside a
65
+ * test that stays, and the unreferenced codes, an unimportable unused export being the
66
+ * unreachable kind's.
66
67
  */
67
68
  function codeOf(judged: Judged): string | undefined {
68
69
  const { candidate, symbol } = judged;
@@ -85,7 +86,11 @@ function codeOf(judged: Judged): string | undefined {
85
86
  if (candidate.productionRefs === 0 && judged.deprecated) {
86
87
  return "DS1006";
87
88
  }
88
- if (candidate.productionRefs === 0 && candidate.testRefs > 0) {
89
+ if (
90
+ candidate.testRefs > 0 &&
91
+ (candidate.productionRefs === 0 ||
92
+ (candidate.relation === "reachability" && candidate.keptByTests))
93
+ ) {
89
94
  return "DS1004";
90
95
  }
91
96
  if (MEMBERS.has(symbol.kind)) {
@@ -132,7 +137,9 @@ function messageOf(code: string, judged: Judged): string {
132
137
  case "DS1006":
133
138
  return `deprecated ${word} has no production reference`;
134
139
  case "DS1004":
135
- return `${word} is referenced only from test files and never from production code`;
140
+ return candidate.productionRefs === 0
141
+ ? `${word} is referenced only from test files and never from production code`
142
+ : `${word} is referenced only from test files and from declarations that are themselves dead`;
136
143
  }
137
144
  const subject =
138
145
  code === "DS1003"
@@ -63,6 +63,12 @@ function entersByName(kinds: ReadonlySet<RootKind> | undefined): boolean {
63
63
  return [...(kinds ?? [])].some((kind) => !PUBLISHING.has(kind) && !RUN_ONLY.has(kind));
64
64
  }
65
65
 
66
+ /**
67
+ * The root kinds whose root of a file enters no export by name: a document's import roots
68
+ * each export it imports, so an export it does not import is judged as in any file.
69
+ */
70
+ const PER_NAME: ReadonlySet<RootKind> = new Set<RootKind>(["document-import"]);
71
+
66
72
  /**
67
73
  * Whether the roots of a declaration's file enter it by name: a test runner's root
68
74
  * enters only an export the runner calls, so another export of a test file is judged.
@@ -72,7 +78,7 @@ function fileEntersByName(
72
78
  symbol: InventorySymbol,
73
79
  ): boolean {
74
80
  const entering = [...(kinds ?? [])].filter(
75
- (kind) => !PUBLISHING.has(kind) && !RUN_ONLY.has(kind),
81
+ (kind) => !PUBLISHING.has(kind) && !RUN_ONLY.has(kind) && !PER_NAME.has(kind),
76
82
  );
77
83
  return (
78
84
  entering.some((kind) => kind !== "test-runner") || (entering.length > 0 && runnerCalls(symbol))
@@ -222,7 +228,7 @@ function narrowing(
222
228
  * export no code outside the target can import, because the manifest declares
223
229
  * `exports` and no root reaches it. Each arm yields to a more specific kind, so one
224
230
  * declaration is reported once: a test whose every subject is dead, an interface, a
225
- * declaration only a test file references, and a deprecated one.
231
+ * declaration only a test file references or a test that stays keeps, and a deprecated one.
226
232
  */
227
233
  function unreachable(
228
234
  facts: Facts,
@@ -237,7 +243,7 @@ function unreachable(
237
243
  (facts.rootedBy[at]?.size ?? 0) > 0 ||
238
244
  candidate.testOfDeadCode ||
239
245
  symbol.kind === "interface" ||
240
- (candidate.productionRefs === 0 && candidate.testRefs > 0) ||
246
+ (candidate.testRefs > 0 && (candidate.productionRefs === 0 || candidate.keptByTests)) ||
241
247
  (candidate.productionRefs === 0 && input.deprecated.has(symbol.id))
242
248
  ) {
243
249
  return undefined;
package/src/kinds.ts CHANGED
@@ -76,7 +76,7 @@ const ROWS: readonly KindRow[] = [
76
76
  code: "DS1004",
77
77
  name: "test-only-use",
78
78
  languages: ["go", "ts"],
79
- rule: "A symbol with zero production references and at least one test reference: an unused-exported, unused-unexported or unused-member candidate whose test reference count is not zero, reported once under this code. A reference from a consumer's test files is a test reference unless the configuration counts consumer tests as production. A reference from test-support code, a package or file that only tests reach and that carries test evidence of its own language, is a test reference. A declaration of test-support code is test code and is no subject of this code, as grammar/analysis.md states. An interface method whose only calls and selections through the interface are in test code, and that DS1203's precondition does not exempt, is reported under this code. The methods implementing it are not reported with it, because the test reaches them through it.",
79
+ rule: "A symbol with zero production references and at least one test reference: an unused-exported, unused-unexported or unused-member candidate whose test reference count is not zero, reported once under this code. A reference from a consumer's test files is a test reference unless the configuration counts consumer tests as production. A reference from test-support code, a package or file that only tests reach and that carries test evidence of its own language, is a test reference. A declaration of test-support code is test code and is no subject of this code, as grammar/analysis.md states. An interface method whose only calls and selections through the interface are in test code, and that DS1203's precondition does not exempt, is reported under this code. The methods implementing it are not reported with it, because the test reaches them through it. A candidate found dead under reachability, every production reference to which is made by a declaration itself dead, is reported under this code as well where test code that is no test of dead code, and that the run counting test references reaches, references it, because that test keeps it, and the finding's message names both kinds of reference.",
80
80
  defaultSeverity: "deny",
81
81
  maxClass: "certain",
82
82
  fixability: "deletable",
@@ -146,7 +146,7 @@ const ROWS: readonly KindRow[] = [
146
146
  languages: ["ts"],
147
147
  rule: "An exported declaration used only inside its own file, reported as a candidate for removing the export keyword. The finding names the narrower visibility the references support.",
148
148
  precondition:
149
- 'Closed world only. Reported in a file that is not an entry file, a file only a configuration string or a script token names being none, because each runs the file and reads no name it exports, and a test file being one only for the exports its test runner calls, as grammar/analysis.md "Test code" states, and that either belongs to a project whose consumer set the configuration declares complete or is reached by no manifest export. A declared cross-language edge counts as a reference from outside the file. Where the edge\'s other side is unknown to the analyzer, the finding is emitted pending. 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.',
149
+ 'Closed world only. Reported in a file that is not an entry file, a file only a configuration string or a script token names being none, because each runs the file and reads no name it exports, and a test file being one only for the exports its test runner calls, as grammar/analysis.md "Test code" states, and a file a Markdown or MDX document imports one only for the exports the import reads, as grammar/analysis.md "Markdown and MDX documents" states, and that either belongs to a project whose consumer set the configuration declares complete or is reached by no manifest export. A declared cross-language edge counts as a reference from outside the file. Where the edge\'s other side is unknown to the analyzer, the finding is emitted pending. 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.',
150
150
  defaultSeverity: "warn",
151
151
  maxClass: "certain",
152
152
  fixability: "narrowable",
package/src/matrix.ts CHANGED
@@ -30,6 +30,8 @@ export interface Configured {
30
30
  readonly testFiles: readonly string[];
31
31
  /** The paths of the test-support files, which only test code imports. */
32
32
  readonly supportFiles?: readonly string[] | undefined;
33
+ /** The paths of the files only tests reach that are no test-support file. */
34
+ readonly testUsedFiles?: readonly string[] | undefined;
33
35
  /** The ids of the ambient declarations, which describe code the target does not implement. */
34
36
  readonly ambient?: ReadonlySet<string> | undefined;
35
37
  /**
@@ -60,6 +62,11 @@ export interface Matrix {
60
62
  * whose project holds it, in the run's order.
61
63
  */
62
64
  readonly heldIn: readonly (readonly string[])[];
65
+ /**
66
+ * Per declaration of {@link Matrix.union}, whether a file only tests reach that is no
67
+ * test-support file holds it in some configuration: production code only tests use.
68
+ */
69
+ readonly testUsed: readonly boolean[];
63
70
  /** Each declaration's dependents, by id, as the first configuration holding it states them. */
64
71
  readonly dependents: ReadonlyMap<string, readonly string[]>;
65
72
  }
@@ -81,6 +88,17 @@ export interface MatrixCandidate extends Candidate {
81
88
  * shape nothing references would be.
82
89
  */
83
90
  readonly unreferencedTest: boolean;
91
+ /**
92
+ * Whether it is dead only because code only tests use is: production code only tests
93
+ * use holds it and the run that counts test references reaches it, or such a
94
+ * declaration reaches it through dead declarations.
95
+ */
96
+ readonly testUsed: boolean;
97
+ /**
98
+ * Whether test code the run that counts test references reaches, and that is no test of
99
+ * dead code, references it: a test that stays keeps it.
100
+ */
101
+ readonly keptByTests: boolean;
84
102
  }
85
103
 
86
104
  /** One run's sweep. */
@@ -158,6 +176,7 @@ export function matrixOf(per: readonly Configured[]): Matrix {
158
176
  }
159
177
  });
160
178
  symbols.sort((a, b) => byPosition(a.position, b.position));
179
+ const testUsed = new Set(per.flatMap((one) => one.testUsedFiles ?? []));
161
180
 
162
181
  return {
163
182
  configurations: per.map((one) => one.configuration),
@@ -173,6 +192,7 @@ export function matrixOf(per: readonly Configured[]): Matrix {
173
192
  new Set(per.flatMap((one) => [...(one.ambient ?? [])])),
174
193
  ),
175
194
  heldIn: symbols.map((symbol) => holders.get(symbol.id) ?? []),
195
+ testUsed: symbols.map((symbol) => testUsed.has(symbol.position.path)),
176
196
  dependents,
177
197
  };
178
198
  }
@@ -227,6 +247,14 @@ export function sweepMatrix(matrix: Matrix, input: SweepInput): SweepResult {
227
247
  union.subject[edge.to] !== true ||
228
248
  judged[edge.to] !== undefined,
229
249
  );
250
+ const admittedAt = (at: number): boolean =>
251
+ judged[at]?.every((candidate) => candidate.testOfDeadCode) === true && subjectsDead(at);
252
+ const kept = keptByTests(union, testReached, admittedAt);
253
+ const testUsed = testUsedReach(
254
+ union,
255
+ union.symbols.map((_symbol, at) => matrix.testUsed[at] === true && testReached[at] === true),
256
+ judged.map((one) => one !== undefined),
257
+ );
230
258
  const candidates: MatrixCandidate[] = [];
231
259
  union.symbols.forEach((symbol, at) => {
232
260
  const all = judged[at];
@@ -234,7 +262,7 @@ export function sweepMatrix(matrix: Matrix, input: SweepInput): SweepResult {
234
262
  return;
235
263
  }
236
264
  const held = matrix.heldIn[at] ?? [];
237
- const admitted = all.every((candidate) => candidate.testOfDeadCode) && subjectsDead(at);
265
+ const admitted = admittedAt(at);
238
266
  const testRefs = union.made[at]?.test ?? 0;
239
267
  const heldByTests =
240
268
  heldBy[union.parent[at] ?? OUTSIDE] ??
@@ -255,6 +283,8 @@ export function sweepMatrix(matrix: Matrix, input: SweepInput): SweepResult {
255
283
  configurations: held,
256
284
  heldByTests,
257
285
  unreferencedTest: unreferenced,
286
+ testUsed: testUsed[at] === true,
287
+ keptByTests: kept[at] === true,
258
288
  });
259
289
  });
260
290
 
@@ -266,6 +296,47 @@ export function sweepMatrix(matrix: Matrix, input: SweepInput): SweepResult {
266
296
  };
267
297
  }
268
298
 
299
+ /** Per declaration of the union, whether one of `seeds` is it or reaches it through `dead` declarations. */
300
+ function testUsedReach(
301
+ union: Graph,
302
+ seeds: readonly boolean[],
303
+ dead: readonly boolean[],
304
+ ): readonly boolean[] {
305
+ // A file is no candidate, and its top level is what reaches what it alone calls.
306
+ const reached = seeds.map((seed, at) => seed && dead[at] === true);
307
+ const queue = seeds.flatMap((one, at) => (one ? [at] : []));
308
+ for (let at = queue.pop(); at !== undefined; at = queue.pop()) {
309
+ for (const edge of union.out[at] ?? []) {
310
+ if (dead[edge.to] === true && reached[edge.to] !== true) {
311
+ reached[edge.to] = true;
312
+ queue.push(edge.to);
313
+ }
314
+ }
315
+ }
316
+ return reached;
317
+ }
318
+
319
+ /** Per declaration of the union, whether test code `reached` holds and `admitted` rejects references it. */
320
+ function keptByTests(
321
+ union: Graph,
322
+ reached: readonly boolean[],
323
+ admitted: (at: number) => boolean,
324
+ ): readonly boolean[] {
325
+ const kept = union.symbols.map(() => false);
326
+ union.out.forEach((edges, from) => {
327
+ const testCode = union.test[from] === true || union.support[from] === true;
328
+ if (!testCode || reached[from] !== true || admitted(from)) {
329
+ return;
330
+ }
331
+ for (const edge of edges) {
332
+ if (edge.test) {
333
+ kept[edge.to] = true;
334
+ }
335
+ }
336
+ });
337
+ return kept;
338
+ }
339
+
269
340
  /**
270
341
  * The dead components of one sweep over the union, with the file declarations at `files`
271
342
  * dead as well: a file that is deleted whole takes the declarations it holds and every