@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.
@@ -531,7 +531,7 @@ function readRun(engine, host, scope, config, mode, detectors, extra) {
531
531
  }));
532
532
  // Test-support code is code only test code imports in any configuration of the run: a
533
533
  // configuration that holds no test file says nothing about what the tests import.
534
- const support = testSupportFiles(withOutside.flatMap((one) => one.projectRead.held.symbols), withOutside.flatMap((one) => one.references), withOutside.flatMap((one) => one.projectRead.rooted.liveUnderReachability), withOutside.flatMap((one) => one.testFiles), testEvidence(host, targetRoot, withOutside));
534
+ const { support, testUsed } = testSupportFiles(withOutside.flatMap((one) => one.projectRead.held.symbols), withOutside.flatMap((one) => one.references), withOutside.flatMap((one) => one.projectRead.rooted.liveUnderReachability), withOutside.flatMap((one) => one.testFiles), testEvidence(host, targetRoot, withOutside));
535
535
  const settled = settledSatisfaction(withOutside.map((one) => ({ evidence: one.evidence, held: one.projectRead.held })), [...withOutside.flatMap((one) => one.references), ...read.consumers.references]);
536
536
  const answers = withOutside.map((one, index) => {
537
537
  const { projectRead } = one;
@@ -544,6 +544,7 @@ function readRun(engine, host, scope, config, mode, detectors, extra) {
544
544
  const made = supportReferences([...one.references, ...(satisfied?.references ?? [])], support);
545
545
  const held = new Set(projectRead.held.symbols.map((symbol) => symbol.position.path));
546
546
  const supportFiles = [...support].filter((path) => held.has(path)).sort();
547
+ const testUsedFiles = [...testUsed].filter((path) => held.has(path)).sort();
547
548
  const configured = {
548
549
  configuration: projectRead.configuration,
549
550
  symbols: projectRead.held.symbols,
@@ -551,6 +552,7 @@ function readRun(engine, host, scope, config, mode, detectors, extra) {
551
552
  roots: projectRead.rooted.liveUnderReachability,
552
553
  testFiles: [...one.testFiles, ...supportFiles],
553
554
  ...(supportFiles.length === 0 ? {} : { supportFiles }),
555
+ ...(testUsedFiles.length === 0 ? {} : { testUsedFiles }),
554
556
  ...(projectRead.held.ambient.size === 0 ? {} : { ambient: projectRead.held.ambient }),
555
557
  ...(projectRead.referenceOnly.length === 0
556
558
  ? {}
@@ -15,45 +15,76 @@ function isGlobalBlock(symbol, held, files) {
15
15
  !held.modules.has(symbol.id));
16
16
  }
17
17
  /**
18
- * For each reference to a declaration an augmentation block, or the global scope of a
19
- * file in `scripts`, holds, a read at the same place of every interface and namespace
20
- * between it and the block or the file, and of the block itself. `scripts` holds the
21
- * files that are no module, by identifier.
18
+ * Per declaration, the interfaces and namespaces between it and the augmentation block or
19
+ * the file in `scripts` that holds it, with the block itself, or none where no such block
20
+ * or file holds it; and whether that holder is the global scope rather than a module
21
+ * declaration. `scripts` holds the files that are no module, by identifier.
22
22
  */
23
- export function augmentationReferences(held, found, scripts = new Set()) {
23
+ function holdersOf(held, scripts) {
24
24
  const files = new Set(held.symbols.filter((one) => one.kind === "file").map((one) => one.id));
25
25
  const globals = new Set(held.symbols.filter((one) => isGlobalBlock(one, held, files)).map((one) => one.id));
26
26
  if (globals.size === 0 && held.modules.size === 0 && scripts.size === 0) {
27
- return [];
27
+ return undefined;
28
28
  }
29
29
  const byId = new Map(held.symbols.map((one) => [one.id, one]));
30
- const holders = (id) => {
30
+ return (id) => {
31
31
  const ids = [];
32
32
  for (let at = byId.get(byId.get(id)?.parent ?? ""); at !== undefined; at = byId.get(at.parent)) {
33
33
  if (globals.has(at.id) || held.modules.has(at.id)) {
34
- return [...ids, at.id];
34
+ return { ids: [...ids, at.id], global: globals.has(at.id) };
35
35
  }
36
36
  if (scripts.has(at.id)) {
37
- return ids;
37
+ return { ids, global: true };
38
38
  }
39
39
  if (at.kind === "interface" || at.kind === "namespace") {
40
40
  ids.push(at.id);
41
41
  }
42
42
  }
43
- return [];
43
+ return { ids: [], global: false };
44
44
  };
45
+ }
46
+ /**
47
+ * The ambient interfaces of the global scope among `interfaces`: each one a declaration
48
+ * file or a `declare` holds that a `declare global` block, or the top level of a file in
49
+ * `scripts`, declares, directly or inside a namespace there.
50
+ */
51
+ export function ambientGlobalInterfaces(held, interfaces, scripts) {
52
+ const holders = holdersOf(held, scripts);
53
+ return holders === undefined
54
+ ? []
55
+ : interfaces.filter((id) => held.ambient.has(id) && holders(id).global);
56
+ }
57
+ /**
58
+ * For each reference to a declaration an augmentation block, or the global scope of a
59
+ * file in `scripts` (the files that are no module), holds, a read at the same place of
60
+ * every interface and namespace between it and the block or the file, and of the block
61
+ * itself. A read through an interface of `merged`, an ambient global interface merging
62
+ * into a language or library one, is a production read whoever writes it: it types a
63
+ * value the runtime provides, so a test reading a member needs it as code does.
64
+ */
65
+ export function augmentationReferences(held, found, scripts = new Set(), merged = new Set()) {
66
+ const holders = holdersOf(held, scripts);
67
+ if (holders === undefined) {
68
+ return [];
69
+ }
70
+ const fileOf = new Map(held.symbols.filter((one) => one.kind === "file").map((one) => [one.position.path, one.id]));
45
71
  // Two uses at one place of declarations one block holds read the block once there.
46
72
  const made = new Set(found.map(referenceKey));
47
73
  const reads = new Map();
48
74
  for (const reference of found) {
49
- for (const to of holders(reference.to)) {
75
+ const { ids } = holders(reference.to);
76
+ // A test's read through such an interface is held by the test file, which the test
77
+ // runner loads whichever of its declarations runs.
78
+ const typed = reference.test && ids.some((id) => merged.has(id));
79
+ const from = typed ? (fileOf.get(reference.position.path) ?? reference.from) : reference.from;
80
+ for (const to of ids) {
50
81
  const read = {
51
- from: reference.from,
82
+ from,
52
83
  to,
53
84
  position: reference.position,
54
85
  use: "read",
55
86
  resolution: reference.resolution,
56
- test: reference.test,
87
+ test: reference.test && !typed,
57
88
  };
58
89
  const key = referenceKey(read);
59
90
  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
  };
@@ -61,6 +61,29 @@ const ANGULAR_PACKAGE_KEYS = [
61
61
  "styles",
62
62
  "scripts",
63
63
  ].map((key) => ({ key, files: ["angular.json"] }));
64
+ /**
65
+ * The test runners and the assertion, mock and runner libraries whose import is test
66
+ * evidence beside each runner a row reads test files for. Each applies whether or not a
67
+ * manifest declares it, so none is a row.
68
+ */
69
+ export const TEST_LIBRARIES = [
70
+ "@jest/globals",
71
+ "@playwright/test",
72
+ "ava",
73
+ "chai",
74
+ "expect",
75
+ "jasmine",
76
+ "jest",
77
+ "mocha",
78
+ "msw",
79
+ "nock",
80
+ "node:test",
81
+ "sinon",
82
+ "tap",
83
+ "uvu",
84
+ ];
85
+ /** The scopes every package of which is a test library. */
86
+ export const TEST_LIBRARY_SCOPES = ["@testing-library/"];
64
87
  /** Every row, ordered by name and then by range. */
65
88
  export const CONVENTION_ROWS = [
66
89
  {
@@ -105,6 +105,19 @@ function reachabilityClasses(input) {
105
105
  return published.has(id) ? outside : "certain";
106
106
  };
107
107
  }
108
+ /** The target symbols one test of dead code references, by id: production declarations the sweep judges. */
109
+ function testTargets(union, id) {
110
+ return (union.out[union.at(id)] ?? []).flatMap((edge) => {
111
+ const target = union.symbols[edge.to];
112
+ const testCode = union.test[edge.to] === true || union.support[edge.to] === true;
113
+ return target !== undefined &&
114
+ !testCode &&
115
+ union.ambient[edge.to] !== true &&
116
+ union.subject[edge.to] === true
117
+ ? [target.id]
118
+ : [];
119
+ });
120
+ }
108
121
  /**
109
122
  * The confidence ceiling of a test of dead code: the lowest reachability class among the
110
123
  * target symbols it references, because its deadness is theirs, whether or not they share
@@ -113,23 +126,28 @@ function reachabilityClasses(input) {
113
126
  function testCeilings(input, classOf) {
114
127
  const union = input.swept.matrix.union;
115
128
  const tests = new Set(input.swept.sweep.candidates.filter((one) => one.testOfDeadCode).map((one) => one.id));
116
- return (id) => {
117
- let lowest = "certain";
118
- if (!tests.has(id)) {
119
- return lowest;
120
- }
121
- for (const edge of union.out[union.at(id)] ?? []) {
122
- const target = union.symbols[edge.to];
123
- const testCode = union.test[edge.to] === true || union.support[edge.to] === true;
124
- if (target !== undefined &&
125
- !testCode &&
126
- union.ambient[edge.to] !== true &&
127
- union.subject[edge.to] === true) {
128
- lowest = capped(lowest, classOf(target.id));
129
- }
129
+ return (id) => tests.has(id)
130
+ ? testTargets(union, id).reduce((lowest, target) => capped(lowest, classOf(target)), "certain")
131
+ : "certain";
132
+ }
133
+ /**
134
+ * The subjects dead only because production code only tests use is: each declaration the
135
+ * sweep marks test-used, each `DS1502` file of such code, and each test of dead code every
136
+ * target of which is one. Nothing they hold references a dead declaration outside them, so
137
+ * the rest of a component they share is reported without them.
138
+ */
139
+ function testUsedSubjects(input) {
140
+ const { matrix, sweep } = input.swept;
141
+ const union = matrix.union;
142
+ const used = new Set(sweep.candidates.filter((one) => one.testUsed).map((one) => one.id));
143
+ for (const test of sweep.candidates.filter((one) => one.testOfDeadCode)) {
144
+ const targets = testTargets(union, test.id);
145
+ if (targets.length > 0 && targets.every((target) => used.has(target))) {
146
+ used.add(test.id);
130
147
  }
131
- return lowest;
132
- };
148
+ }
149
+ const files = new Set(union.symbols.flatMap((symbol, at) => matrix.testUsed[at] === true ? [symbol.position.path] : []));
150
+ return { declarations: used, files };
133
151
  }
134
152
  /**
135
153
  * The ceiling the roots the run found put on one dead subject: a subject other declarations
@@ -251,6 +269,7 @@ export function completed(input, findings, kinds = KINDS) {
251
269
  const candidates = new Map(swept.sweep.candidates.map((candidate) => [candidate.id, candidate]));
252
270
  const classOf = reachabilityClasses(input);
253
271
  const ceilingOf = testCeilings(input, classOf);
272
+ const testUsed = testUsedSubjects(input);
254
273
  const { components, computed, files } = runComponents(swept, findings);
255
274
  const componentOf = findingComponents(swept, components, input.config.reporters.cascade);
256
275
  const held = new Set(components.filter((one) => one.held).flatMap((one) => one.members));
@@ -317,7 +336,12 @@ export function completed(input, findings, kinds = KINDS) {
317
336
  const counts = candidate === undefined
318
337
  ? union.made[at]
319
338
  : { production: candidate.productionRefs, test: candidate.testRefs };
320
- const reachabilityClass = judged === undefined ? (finding.reachabilityClass ?? "certain") : classOf(judged.id);
339
+ const ownClass = judged === undefined ? (finding.reachabilityClass ?? "certain") : classOf(judged.id);
340
+ const used = judged !== undefined
341
+ ? candidate !== undefined && testUsed.declarations.has(judged.id)
342
+ : finding.code === FILE_NEVER_IMPORTED && testUsed.files.has(finding.position.path);
343
+ const reachabilityClass = used && candidate?.testOfDeadCode !== true ? "possible" : ownClass;
344
+ const untested = capped(capped(capped(ownClass, row.maxClass), rootsCeiling(input, finding, candidate)), judged === undefined ? "certain" : ceilingOf(judged.id));
321
345
  const generated = input.files.generated?.has(finding.position.path) === true;
322
346
  const fixability = generated
323
347
  ? "none"
@@ -341,7 +365,8 @@ export function completed(input, findings, kinds = KINDS) {
341
365
  position: finding.position,
342
366
  symbol: finding.symbol,
343
367
  reachabilityClass,
344
- confidence: capped(capped(capped(reachabilityClass, row.maxClass), rootsCeiling(input, finding, candidate)), judged === undefined ? "certain" : ceilingOf(judged.id)),
368
+ confidence: used ? "possible" : untested,
369
+ ...(used ? { componentConfidence: untested } : {}),
345
370
  ...(candidate === undefined ? {} : { livenessRelation: candidate.relation }),
346
371
  testOnly: judged !== undefined && counts?.production === 0 && counts.test > 0,
347
372
  generated,
@@ -365,7 +390,7 @@ export function completed(input, findings, kinds = KINDS) {
365
390
  /**
366
391
  * The findings with every finding of a dead component capped by the lowest confidence
367
392
  * among the component's root members, so the minimum confidence reports or withholds a
368
- * component whole.
393
+ * component whole but for the findings test-used code lowers on their own.
369
394
  */
370
395
  function componentCapped(findings, dead) {
371
396
  const lowest = new Map();
@@ -373,16 +398,27 @@ function componentCapped(findings, dead) {
373
398
  const { id, root } = finding.component;
374
399
  if (root && dead.has(id)) {
375
400
  const held = lowest.get(id);
376
- lowest.set(id, held === undefined ? finding.confidence : capped(held, finding.confidence));
401
+ const own = componentConfidenceOf(finding);
402
+ lowest.set(id, held === undefined ? own : capped(held, own));
377
403
  }
378
404
  }
379
405
  return findings.map((finding) => {
380
406
  const cap = lowest.get(finding.component.id);
381
- return cap === undefined || RANK[cap] >= RANK[finding.confidence]
382
- ? finding
383
- : { ...finding, confidence: cap };
407
+ if (cap === undefined) {
408
+ return finding;
409
+ }
410
+ const own = finding.componentConfidence;
411
+ return {
412
+ ...finding,
413
+ confidence: capped(finding.confidence, cap),
414
+ ...(own === undefined ? {} : { componentConfidence: capped(own, cap) }),
415
+ };
384
416
  });
385
417
  }
418
+ /** The confidence a finding gives its component's cap and the dials. */
419
+ function componentConfidenceOf(finding) {
420
+ return finding.componentConfidence ?? finding.confidence;
421
+ }
386
422
  /** Whether the dials withhold a finding by its own severity or confidence. */
387
423
  function dialed(finding, minConfidence) {
388
424
  return finding.severity === "allow" || RANK[finding.confidence] < RANK[minConfidence];
@@ -397,7 +433,8 @@ function dialed(finding, minConfidence) {
397
433
  export function dialsOf(input, findings) {
398
434
  const minConfidence = input.config.analysis.minConfidence;
399
435
  const components = new Set(findings
400
- .filter((finding) => finding.component.root && dialed(finding, minConfidence))
436
+ .filter((finding) => finding.component.root &&
437
+ dialed({ ...finding, confidence: componentConfidenceOf(finding) }, minConfidence))
401
438
  .map((finding) => finding.component.id));
402
439
  return {
403
440
  allows: (code) => {
@@ -41,8 +41,9 @@ const TEST_OF_DEAD_CODE = "every target symbol this test references is reported
41
41
  * The code one candidate is reported under, or none, each symbol once under the most
42
42
  * specific code: a test of dead code first; no other test-file declaration or enum
43
43
  * member nothing names; a test-support declaration test code references as test-only
44
- * use; then deprecation, a test reference, and the unreferenced codes, an unimportable
45
- * unused export being the unreachable kind's.
44
+ * use; then deprecation, a test reference, or only dead production references beside a
45
+ * test that stays, and the unreferenced codes, an unimportable unused export being the
46
+ * unreachable kind's.
46
47
  */
47
48
  function codeOf(judged) {
48
49
  const { candidate, symbol } = judged;
@@ -65,7 +66,9 @@ function codeOf(judged) {
65
66
  if (candidate.productionRefs === 0 && judged.deprecated) {
66
67
  return "DS1006";
67
68
  }
68
- if (candidate.productionRefs === 0 && candidate.testRefs > 0) {
69
+ if (candidate.testRefs > 0 &&
70
+ (candidate.productionRefs === 0 ||
71
+ (candidate.relation === "reachability" && candidate.keptByTests))) {
69
72
  return "DS1004";
70
73
  }
71
74
  if (MEMBERS.has(symbol.kind)) {
@@ -109,7 +112,9 @@ function messageOf(code, judged) {
109
112
  case "DS1006":
110
113
  return `deprecated ${word} has no production reference`;
111
114
  case "DS1004":
112
- return `${word} is referenced only from test files and never from production code`;
115
+ return candidate.productionRefs === 0
116
+ ? `${word} is referenced only from test files and never from production code`
117
+ : `${word} is referenced only from test files and from declarations that are themselves dead`;
113
118
  }
114
119
  const subject = code === "DS1003"
115
120
  ? `${visibilityWord(symbol)}${word}`
@@ -34,12 +34,17 @@ const RUN_ONLY = new Set(["configuration-string", "script"]);
34
34
  function entersByName(kinds) {
35
35
  return [...(kinds ?? [])].some((kind) => !PUBLISHING.has(kind) && !RUN_ONLY.has(kind));
36
36
  }
37
+ /**
38
+ * The root kinds whose root of a file enters no export by name: a document's import roots
39
+ * each export it imports, so an export it does not import is judged as in any file.
40
+ */
41
+ const PER_NAME = new Set(["document-import"]);
37
42
  /**
38
43
  * Whether the roots of a declaration's file enter it by name: a test runner's root
39
44
  * enters only an export the runner calls, so another export of a test file is judged.
40
45
  */
41
46
  function fileEntersByName(kinds, symbol) {
42
- const entering = [...(kinds ?? [])].filter((kind) => !PUBLISHING.has(kind) && !RUN_ONLY.has(kind));
47
+ const entering = [...(kinds ?? [])].filter((kind) => !PUBLISHING.has(kind) && !RUN_ONLY.has(kind) && !PER_NAME.has(kind));
43
48
  return (entering.some((kind) => kind !== "test-runner") || (entering.length > 0 && runnerCalls(symbol)));
44
49
  }
45
50
  /**
@@ -163,7 +168,7 @@ function narrowing(facts, at, symbol, world) {
163
168
  * export no code outside the target can import, because the manifest declares
164
169
  * `exports` and no root reaches it. Each arm yields to a more specific kind, so one
165
170
  * declaration is reported once: a test whose every subject is dead, an interface, a
166
- * declaration only a test file references, and a deprecated one.
171
+ * declaration only a test file references or a test that stays keeps, and a deprecated one.
167
172
  */
168
173
  function unreachable(facts, at, symbol, candidate, input) {
169
174
  if (!input.boundary.encapsulated ||
@@ -171,7 +176,7 @@ function unreachable(facts, at, symbol, candidate, input) {
171
176
  (facts.rootedBy[at]?.size ?? 0) > 0 ||
172
177
  candidate.testOfDeadCode ||
173
178
  symbol.kind === "interface" ||
174
- (candidate.productionRefs === 0 && candidate.testRefs > 0) ||
179
+ (candidate.testRefs > 0 && (candidate.productionRefs === 0 || candidate.keptByTests)) ||
175
180
  (candidate.productionRefs === 0 && input.deprecated.has(symbol.id))) {
176
181
  return undefined;
177
182
  }
package/dist/src/kinds.js CHANGED
@@ -43,7 +43,7 @@ const ROWS = [
43
43
  code: "DS1004",
44
44
  name: "test-only-use",
45
45
  languages: ["go", "ts"],
46
- 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.",
46
+ 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.",
47
47
  defaultSeverity: "deny",
48
48
  maxClass: "certain",
49
49
  fixability: "deletable",
@@ -110,7 +110,7 @@ const ROWS = [
110
110
  name: "redundant-export-keyword",
111
111
  languages: ["ts"],
112
112
  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.",
113
- precondition: '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.',
113
+ precondition: '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.',
114
114
  defaultSeverity: "warn",
115
115
  maxClass: "certain",
116
116
  fixability: "narrowable",
@@ -70,11 +70,13 @@ export function matrixOf(per) {
70
70
  }
71
71
  });
72
72
  symbols.sort((a, b) => byPosition(a.position, b.position));
73
+ const testUsed = new Set(per.flatMap((one) => one.testUsedFiles ?? []));
73
74
  return {
74
75
  configurations: per.map((one) => one.configuration),
75
76
  graphs: per.map((one) => graphOf(one.symbols, one.references, one.roots, one.testFiles, one.supportFiles, one.ambient)),
76
77
  union: graphOf(symbols, references, per.flatMap((one) => one.roots), [...new Set(per.flatMap((one) => one.testFiles))], [...new Set(per.flatMap((one) => one.supportFiles ?? []))], new Set(per.flatMap((one) => [...(one.ambient ?? [])]))),
77
78
  heldIn: symbols.map((symbol) => holders.get(symbol.id) ?? []),
79
+ testUsed: symbols.map((symbol) => testUsed.has(symbol.position.path)),
78
80
  dependents,
79
81
  };
80
82
  }
@@ -121,6 +123,9 @@ export function sweepMatrix(matrix, input) {
121
123
  union.ambient[edge.to] === true ||
122
124
  union.subject[edge.to] !== true ||
123
125
  judged[edge.to] !== undefined);
126
+ const admittedAt = (at) => judged[at]?.every((candidate) => candidate.testOfDeadCode) === true && subjectsDead(at);
127
+ const kept = keptByTests(union, testReached, admittedAt);
128
+ const testUsed = testUsedReach(union, union.symbols.map((_symbol, at) => matrix.testUsed[at] === true && testReached[at] === true), judged.map((one) => one !== undefined));
124
129
  const candidates = [];
125
130
  union.symbols.forEach((symbol, at) => {
126
131
  const all = judged[at];
@@ -128,7 +133,7 @@ export function sweepMatrix(matrix, input) {
128
133
  return;
129
134
  }
130
135
  const held = matrix.heldIn[at] ?? [];
131
- const admitted = all.every((candidate) => candidate.testOfDeadCode) && subjectsDead(at);
136
+ const admitted = admittedAt(at);
132
137
  const testRefs = union.made[at]?.test ?? 0;
133
138
  const heldByTests = heldBy[union.parent[at] ?? OUTSIDE] ??
134
139
  (union.support[at] === true && testRefs > 0 && !admitted ? symbol.id : undefined);
@@ -148,6 +153,8 @@ export function sweepMatrix(matrix, input) {
148
153
  configurations: held,
149
154
  heldByTests,
150
155
  unreferencedTest: unreferenced,
156
+ testUsed: testUsed[at] === true,
157
+ keptByTests: kept[at] === true,
151
158
  });
152
159
  });
153
160
  const liveUnder = liveAnywhere(union.symbols, per);
@@ -157,6 +164,37 @@ export function sweepMatrix(matrix, input) {
157
164
  components: deadComponents(matrix, { liveUnder, candidates }, new Set()),
158
165
  };
159
166
  }
167
+ /** Per declaration of the union, whether one of `seeds` is it or reaches it through `dead` declarations. */
168
+ function testUsedReach(union, seeds, dead) {
169
+ // A file is no candidate, and its top level is what reaches what it alone calls.
170
+ const reached = seeds.map((seed, at) => seed && dead[at] === true);
171
+ const queue = seeds.flatMap((one, at) => (one ? [at] : []));
172
+ for (let at = queue.pop(); at !== undefined; at = queue.pop()) {
173
+ for (const edge of union.out[at] ?? []) {
174
+ if (dead[edge.to] === true && reached[edge.to] !== true) {
175
+ reached[edge.to] = true;
176
+ queue.push(edge.to);
177
+ }
178
+ }
179
+ }
180
+ return reached;
181
+ }
182
+ /** Per declaration of the union, whether test code `reached` holds and `admitted` rejects references it. */
183
+ function keptByTests(union, reached, admitted) {
184
+ const kept = union.symbols.map(() => false);
185
+ union.out.forEach((edges, from) => {
186
+ const testCode = union.test[from] === true || union.support[from] === true;
187
+ if (!testCode || reached[from] !== true || admitted(from)) {
188
+ return;
189
+ }
190
+ for (const edge of edges) {
191
+ if (edge.test) {
192
+ kept[edge.to] = true;
193
+ }
194
+ }
195
+ });
196
+ return kept;
197
+ }
160
198
  /**
161
199
  * The dead components of one sweep over the union, with the file declarations at `files`
162
200
  * dead as well: a file that is deleted whole takes the declarations it holds and every
@@ -1,4 +1,4 @@
1
- import { isArrayLiteralExpression, isAsExpression, isBinaryExpression, isCallExpression, isDecorator, isDeleteExpression, isElementAccessExpression, isExportDeclaration, isExpressionStatement, isExternalModuleReference, isForInStatement, isForOfStatement, isForStatement, isIdentifier, isImportDeclaration, isImportEqualsDeclaration, isMetaProperty, isNewExpression, isNamespaceExport, isNoSubstitutionTemplateLiteral, isObjectLiteralExpression, isParenthesizedExpression, isPostfixUnaryExpression, isPrefixUnaryExpression, isPropertyAccessExpression, isPropertyAssignment, isShorthandPropertyAssignment, isSpreadAssignment, isSpreadElement, isStringLiteral, isVoidExpression, SyntaxKind, } from "@typescript/native/ast";
1
+ import { isArrayLiteralExpression, isAsExpression, isBinaryExpression, isCallExpression, isDecorator, isDeleteExpression, isElementAccessExpression, isExportDeclaration, isExpressionStatement, isExternalModuleReference, isForInStatement, isForOfStatement, isForStatement, isIdentifier, isImportDeclaration, isImportEqualsDeclaration, isInterfaceDeclaration, isMetaProperty, isModuleBlock, isModuleDeclaration, isNewExpression, isNamespaceExport, isNoSubstitutionTemplateLiteral, isObjectLiteralExpression, isParenthesizedExpression, isPostfixUnaryExpression, isPrefixUnaryExpression, isPropertyAccessExpression, isPropertyAssignment, isShorthandPropertyAssignment, isSpreadAssignment, isSpreadElement, isStringLiteral, isVoidExpression, SyntaxKind, } from "@typescript/native/ast";
2
2
  import { aliasChains } from "./alias-chain.js";
3
3
  import { contextualUses } from "./contextual-uses.js";
4
4
  import { destructuring, propertyReadsOf } from "./destructuring.js";
@@ -6,7 +6,7 @@ import { specifierCandidates } from "./configuration-files.js";
6
6
  import { namespaceUses } from "./namespace-objects.js";
7
7
  import { globExpression } from "./glob.js";
8
8
  import { declarationsByName, isParameterProperty, nodeKey, } from "./inventory.js";
9
- import { augmentationReferences } from "./augmentations.js";
9
+ import { ambientGlobalInterfaces, augmentationReferences } from "./augmentations.js";
10
10
  import { elementAccessReferences } from "./element-access.js";
11
11
  import { indexedAccessReferences } from "./indexed-access.js";
12
12
  import { keyofComparisonReferences } from "./keyof-comparisons.js";
@@ -1135,6 +1135,37 @@ export function references(project, held, targetRoot, options) {
1135
1135
  }
1136
1136
  }
1137
1137
  }
1138
+ /** The ambient global interfaces of the walked files that merge into a declaration outside the project's own files. */
1139
+ const libraryMerged = (scripts) => {
1140
+ const names = new Map();
1141
+ const visit = (node) => {
1142
+ if (isInterfaceDeclaration(node)) {
1143
+ const id = declaredAt(node);
1144
+ if (id !== undefined) {
1145
+ names.set(id, node.name);
1146
+ }
1147
+ }
1148
+ else if (isModuleDeclaration(node) || isModuleBlock(node)) {
1149
+ node.forEachChild(visit);
1150
+ }
1151
+ };
1152
+ for (const { file } of walked) {
1153
+ file.forEachChild(visit);
1154
+ }
1155
+ const global = new Set(ambientGlobalInterfaces(held, [...names.keys()], scripts));
1156
+ const asked = [...names].filter(([id]) => global.has(id));
1157
+ if (asked.length === 0) {
1158
+ return new Set();
1159
+ }
1160
+ const answers = project.symbolsAt(asked.map(([, name]) => project.handle(name)));
1161
+ return new Set(asked.flatMap(([id], at) => {
1162
+ const symbol = answers[at];
1163
+ return typeof symbol === "object" &&
1164
+ symbol.declarations.some((handle) => !ownPaths.has(handle.path))
1165
+ ? [id]
1166
+ : [];
1167
+ }));
1168
+ };
1138
1169
  const usesOf = new Map(walked.map((one) => [one.file, one.uses]));
1139
1170
  for (const [file, writes] of valued) {
1140
1171
  const uses = usesOf.get(file) ?? [];
@@ -1159,7 +1190,7 @@ export function references(project, held, targetRoot, options) {
1159
1190
  typedLookups = (options.requestCount?.() ?? 0) - before;
1160
1191
  }
1161
1192
  const scripts = new Set(walked.filter((one) => one.file.externalModuleIndicator === undefined).map((one) => one.fileId));
1162
- found.push(...augmentationReferences(held, found, scripts));
1193
+ found.push(...augmentationReferences(held, found, scripts, libraryMerged(scripts)));
1163
1194
  found.sort((a, b) => byPosition(a.position, b.position) ||
1164
1195
  compare(a.to, b.to) ||
1165
1196
  compare(a.from, b.from) ||
@@ -57,6 +57,7 @@ export const GO_REF_EXPRESSIONS = [
57
57
  `^go://${GO_PATH}#${GO_IDENT}$`,
58
58
  `^go://${GO_PATH}#${GO_IDENT}(?:\\.${GO_IDENT})+$`,
59
59
  `^go://${GO_PATH}#${GO_IDENT}(?:\\.${GO_IDENT})?\\[${GO_IDENT}\\]$`,
60
+ `^go://${GO_PATH}#(?:${GO_IDENT}(?:\\.${GO_IDENT})?)?\\(${GO_IDENT}\\)$`,
60
61
  `^go://${GO_PATH}#${GO_FILE}:file$`,
61
62
  `^go://${GO_PATH}#${GO_PATH}(?:@${GO_VERSION})?:require$`,
62
63
  `^go://${GO_PATH}#${GO_PATH}(?:@${GO_VERSION})?:replace$`,
@@ -5,21 +5,18 @@
5
5
  * another such file, so it is judged with the tests: what it references is referenced by
6
6
  * test code.
7
7
  */
8
- import { CONVENTION_ROWS } from "./convention-rows.js";
8
+ import { CONVENTION_ROWS, TEST_LIBRARIES, TEST_LIBRARY_SCOPES } from "./convention-rows.js";
9
9
  import { packageOfSpecifier } from "./dependencies.js";
10
10
  /** The root kind that never takes a file out of test-support code: the skip only withholds. */
11
11
  const SKIP_ROOT = "type-error";
12
12
  /**
13
13
  * The test runners and test libraries an import of which is test evidence: each runner a
14
- * convention row reads test files for, the platform's own runner, and the libraries below.
14
+ * convention row reads test files for, and the convention table's test libraries.
15
15
  */
16
16
  const TEST_PACKAGES = new Set([
17
17
  ...CONVENTION_ROWS.filter((row) => row.testFiles !== undefined).map((row) => row.package),
18
- "node:test",
19
- "@playwright/test",
18
+ ...TEST_LIBRARIES,
20
19
  ]);
21
- /** The scopes every package of which is a test library. */
22
- const TEST_SCOPES = ["@testing-library/"];
23
20
  /**
24
21
  * The Node.js built-in modules a bare specifier names, which are no package. The set is
25
22
  * closed: Node.js adds a new built-in under the `node:` scheme alone
@@ -73,7 +70,7 @@ const NODE_BUILTINS = new Set([
73
70
  function namesTestPackage(specifier) {
74
71
  const name = specifier.startsWith("node:") ? specifier : packageOfSpecifier(specifier);
75
72
  return (name !== undefined &&
76
- (TEST_PACKAGES.has(name) || TEST_SCOPES.some((scope) => name.startsWith(scope))));
73
+ (TEST_PACKAGES.has(name) || TEST_LIBRARY_SCOPES.some((scope) => name.startsWith(scope))));
77
74
  }
78
75
  /**
79
76
  * Whether a file carries test evidence: an import of a test runner or a test library, or
@@ -106,9 +103,9 @@ function evidenceReader(tests, evidence) {
106
103
  * The files of one project that are test-support code, below the target root: the
107
104
  * largest set of files a test file reaches, none of them a test file or holding a root,
108
105
  * each carrying test evidence of its own, whose every user outside the set is a test
109
- * file. A root has a caller outside the reference graph: a runtime, a tool, a
110
- * configuration, or a consumer of a library's published API. `testFiles` are the files
111
- * the test-file rules classified.
106
+ * file; and the files only tests reach that are not among them. A root has a caller
107
+ * outside the reference graph: a runtime, a tool, a configuration, or a consumer of a
108
+ * library's published API. `testFiles` are the files the test-file rules classified.
112
109
  */
113
110
  export function testSupportFiles(symbols, references, roots, testFiles, evidence) {
114
111
  const fileOf = new Map(symbols.map((symbol) => [symbol.id, symbol.position.path]));
@@ -162,7 +159,7 @@ export function testSupportFiles(symbols, references, roots, testFiles, evidence
162
159
  const onlyTests = withoutOutsideUsers(new Set([...reached].filter((path) => !rooted.has(path))));
163
160
  const carries = evidenceReader(tests, evidence);
164
161
  const support = withoutOutsideUsers(new Set([...onlyTests].filter((path) => carries(onlyTests, path))));
165
- return support;
162
+ return { support, testUsed: new Set([...onlyTests].filter((path) => !support.has(path))) };
166
163
  }
167
164
  /** The references with every one a test-support file makes counted as a test reference. */
168
165
  export function supportReferences(references, support) {
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/deadset-ts",
3
- "version": "8.0.0-dev.1",
3
+ "version": "8.0.0-dev.2",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/deadset-ts",
3
- "version": "8.0.0-dev.1",
3
+ "version": "8.0.0-dev.2",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Deterministic dead-code analysis for TypeScript, class and type members included",