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

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 (66) hide show
  1. package/dist/src/analysis.js +30 -6
  2. package/dist/src/augmentations.js +105 -12
  3. package/dist/src/build-scripts.js +437 -0
  4. package/dist/src/conformance-record.js +1 -1
  5. package/dist/src/convention-rows.js +62 -2
  6. package/dist/src/conventions.js +15 -0
  7. package/dist/src/dependencies.js +89 -0
  8. package/dist/src/entry-points.js +9 -18
  9. package/dist/src/findings/completion.js +122 -49
  10. package/dist/src/findings/intra-function.js +4 -2
  11. package/dist/src/findings/reads-and-writes.js +7 -6
  12. package/dist/src/findings/unused-declarations.js +13 -5
  13. package/dist/src/findings/visibility-narrowing.js +8 -3
  14. package/dist/src/graph.js +5 -3
  15. package/dist/src/intra-function-parts.js +45 -17
  16. package/dist/src/kinds.js +8 -8
  17. package/dist/src/manifest.js +18 -0
  18. package/dist/src/matrix.js +76 -4
  19. package/dist/src/references.js +34 -3
  20. package/dist/src/report.js +23 -4
  21. package/dist/src/reporters/sarif.js +12 -8
  22. package/dist/src/roots.js +138 -11
  23. package/dist/src/serialization-contract.js +258 -8
  24. package/dist/src/suppress.js +1 -0
  25. package/dist/src/sweep.js +7 -9
  26. package/dist/src/test-globals.js +234 -0
  27. package/dist/src/test-support.js +8 -11
  28. package/dist/src/type-errors.js +11 -3
  29. package/dist/src/unseen-loads.js +107 -0
  30. package/dist/src/verbs/analyze.js +24 -2
  31. package/dist/src/yaml-document.js +355 -0
  32. package/jsr.json +1 -1
  33. package/package.json +1 -1
  34. package/src/analysis.ts +48 -8
  35. package/src/augmentations.ts +134 -16
  36. package/src/build-scripts.ts +564 -0
  37. package/src/conformance-record.ts +1 -1
  38. package/src/convention-rows.ts +72 -2
  39. package/src/conventions.ts +16 -0
  40. package/src/dependencies.ts +96 -0
  41. package/src/entry-points.ts +10 -25
  42. package/src/file-facts.ts +3 -0
  43. package/src/finding.ts +5 -0
  44. package/src/findings/completion.ts +153 -56
  45. package/src/findings/intra-function.ts +4 -2
  46. package/src/findings/reads-and-writes.ts +7 -6
  47. package/src/findings/unused-declarations.ts +17 -5
  48. package/src/findings/visibility-narrowing.ts +9 -3
  49. package/src/graph.ts +5 -3
  50. package/src/intra-function-parts.ts +55 -16
  51. package/src/kinds.ts +8 -8
  52. package/src/manifest.ts +21 -3
  53. package/src/matrix.ts +122 -4
  54. package/src/references.ts +38 -2
  55. package/src/report.ts +56 -16
  56. package/src/reporters/sarif.ts +16 -8
  57. package/src/roots.ts +162 -12
  58. package/src/serialization-contract.ts +309 -7
  59. package/src/suppress.ts +1 -0
  60. package/src/sweep.ts +21 -9
  61. package/src/test-globals.ts +275 -0
  62. package/src/test-support.ts +17 -13
  63. package/src/type-errors.ts +14 -3
  64. package/src/unseen-loads.ts +145 -0
  65. package/src/verbs/analyze.ts +28 -2
  66. package/src/yaml-document.ts +377 -0
@@ -10,14 +10,16 @@
10
10
  * value that is not a literal is not evaluated and not guessed, so a file it would
11
11
  * have named is entered by nothing here.
12
12
  */
13
- import { isArrayLiteralExpression, isAsExpression, isBinaryExpression, isCallExpression, isExportAssignment, isIdentifier, isMetaProperty, isNewExpression, isNoSubstitutionTemplateLiteral, isObjectLiteralExpression, isParenthesizedExpression, isPropertyAccessExpression, isPropertyAssignment, isSatisfiesExpression, isStringLiteral, isTemplateExpression, SyntaxKind, } from "@typescript/native/ast";
13
+ import { isArrayLiteralExpression, isAsExpression, isCallExpression, isExportAssignment, isIdentifier, isMetaProperty, isNewExpression, isNoSubstitutionTemplateLiteral, isObjectLiteralExpression, isParenthesizedExpression, isPropertyAccessExpression, isPropertyAssignment, isSatisfiesExpression, isStringLiteral, SyntaxKind, } from "@typescript/native/ast";
14
14
  import { SymbolFlags } from "@typescript/native/sync";
15
15
  import { configurationFiles, filesNamedBy, moduleStrings, } from "./configuration-files.js";
16
+ import { buildScriptEntries } from "./build-scripts.js";
16
17
  import { globExpression } from "./glob.js";
17
18
  import { htmlEntries } from "./html-entries.js";
18
19
  import { documentEntries } from "./mdx-entries.js";
19
20
  import { dirnamePath, relativePath, resolvePath } from "./paths.js";
20
21
  import { UNANSWERED } from "./query.js";
22
+ import { computedLoadDirectory, leadingLiteral } from "./unseen-loads.js";
21
23
  import { workflowEntries } from "./workflow-steps.js";
22
24
  /** The extensions the test runner and the browser-test runner load a configuration from. */
23
25
  const MODULE_EXTENSIONS = ["js", "mjs", "cjs", "ts", "mts", "cts"];
@@ -210,7 +212,7 @@ function configurationEntries(host, targetRoot, files, parse, selected) {
210
212
  strings(dirnamePath(document.path), unselectedIn(document.path, document.strings));
211
213
  }
212
214
  for (const { file } of outside) {
213
- strings(dirnamePath(file.fileName), moduleStrings(file).map((one) => one.text));
215
+ strings(dirnamePath(file.fileName), unselected(file));
214
216
  }
215
217
  return found;
216
218
  }
@@ -398,17 +400,6 @@ function workersOf(project, file, files) {
398
400
  }
399
401
  return found;
400
402
  }
401
- /** The literal text a computed path starts with: a template's head, or a concatenation's first literal. */
402
- function leadingLiteral(node) {
403
- const at = unwrapped(node);
404
- if (isTemplateExpression(at)) {
405
- return at.head.text;
406
- }
407
- if (isBinaryExpression(at) && at.operatorToken.kind === SyntaxKind.PlusToken) {
408
- return literalOf(unwrapped(at.left)) ?? leadingLiteral(at.left);
409
- }
410
- return undefined;
411
- }
412
403
  /** Whether one call is `import.meta.glob(...)`. */
413
404
  function isMetaGlob(node) {
414
405
  if (!isCallExpression(node) || !isPropertyAccessExpression(node.expression)) {
@@ -432,11 +423,8 @@ function computedImportsOf(file, files) {
432
423
  const dir = dirnamePath(file.fileName);
433
424
  const found = [];
434
425
  const below = (prefix) => {
435
- const target = resolvePath(dir, prefix);
436
- if (!isModuleRelative(prefix) ||
437
- !prefix.endsWith("/") ||
438
- target === dir ||
439
- relativePath(target, dir) !== undefined) {
426
+ const target = computedLoadDirectory(dir, prefix);
427
+ if (target === undefined) {
440
428
  return;
441
429
  }
442
430
  for (const candidate of files.byName.values()) {
@@ -522,6 +510,9 @@ export function entryPoints(project, files, testFiles, host, targetRoot, parse,
522
510
  ...(names === undefined ? {} : { names }),
523
511
  });
524
512
  }
513
+ for (const { file, source } of buildScriptEntries(host, targetRoot, files, parse)) {
514
+ found.push({ file, rule: "bundler-entry", source, exports: true });
515
+ }
525
516
  for (const { file, source } of workflowEntries(host, targetRoot, files)) {
526
517
  found.push({ file, rule: "workflow-step", source, exports: true });
527
518
  }
@@ -105,6 +105,32 @@ function reachabilityClasses(input) {
105
105
  return published.has(id) ? outside : "certain";
106
106
  };
107
107
  }
108
+ /**
109
+ * Whether one test of dead code references a target symbol that is published API of a run
110
+ * that declares no consumer, which outside programs the run cannot see may use, so the
111
+ * target counts as live for the test and the test is no test of dead code.
112
+ */
113
+ export function testsUnseenAPI(input) {
114
+ if (input.boundary.consumers.declared.length > 0) {
115
+ return () => false;
116
+ }
117
+ const union = input.swept.matrix.union;
118
+ const published = publishedIds(union);
119
+ return (id) => testTargets(union, id).some((target) => published.has(target));
120
+ }
121
+ /** The target symbols one test of dead code references, by id: production declarations the sweep judges. */
122
+ function testTargets(union, id) {
123
+ return (union.out[union.at(id)] ?? []).flatMap((edge) => {
124
+ const target = union.symbols[edge.to];
125
+ const testCode = union.test[edge.to] === true || union.support[edge.to] === true;
126
+ return target !== undefined &&
127
+ !testCode &&
128
+ union.ambient[edge.to] !== true &&
129
+ union.subject[edge.to] === true
130
+ ? [target.id]
131
+ : [];
132
+ });
133
+ }
108
134
  /**
109
135
  * The confidence ceiling of a test of dead code: the lowest reachability class among the
110
136
  * target symbols it references, because its deadness is theirs, whether or not they share
@@ -113,33 +139,41 @@ function reachabilityClasses(input) {
113
139
  function testCeilings(input, classOf) {
114
140
  const union = input.swept.matrix.union;
115
141
  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
- }
142
+ return (id) => tests.has(id)
143
+ ? testTargets(union, id).reduce((lowest, target) => capped(lowest, classOf(target)), "certain")
144
+ : "certain";
145
+ }
146
+ /**
147
+ * The subjects dead only because production code only tests use is: each declaration the
148
+ * sweep marks test-used, each `DS1502` file of such code, and each test of dead code every
149
+ * target of which is one. Nothing they hold references a dead declaration outside them, so
150
+ * the rest of a component they share is reported without them.
151
+ */
152
+ function testUsedSubjects(input) {
153
+ const { matrix, sweep } = input.swept;
154
+ const union = matrix.union;
155
+ const used = new Set(sweep.candidates.filter((one) => one.testUsed).map((one) => one.id));
156
+ for (const test of sweep.candidates.filter((one) => one.testOfDeadCode)) {
157
+ const targets = testTargets(union, test.id);
158
+ if (targets.length > 0 && targets.every((target) => used.has(target))) {
159
+ used.add(test.id);
130
160
  }
131
- return lowest;
132
- };
161
+ }
162
+ const files = new Set(union.symbols.flatMap((symbol, at) => matrix.testUsed[at] === true ? [symbol.position.path] : []));
163
+ return { declarations: used, files };
133
164
  }
134
165
  /**
135
166
  * The ceiling the roots the run found put on one dead subject: a subject other declarations
136
- * reference, found dead only because no root reaches them, is `probable` unless
137
- * the configuration declares the roots complete, because a root the analysis cannot see
138
- * would make it live; every other declaration has none below `certain`.
167
+ * reference, found dead only because no root reaches them, is `probable` unless the
168
+ * configuration declares the roots complete and no configuration holding the subject loads
169
+ * a module the analysis cannot resolve, because a root the analysis cannot see would make
170
+ * it live; every other declaration has none below `certain`.
139
171
  */
140
- function rootsCeiling(input, finding, candidate) {
172
+ function rootsCeiling(input, finding, candidate, configurations) {
141
173
  const unreachable = candidate?.relation === "reachability" || finding.unreachableOnly === true;
142
- return unreachable && !input.config.ts.rootsComplete ? "probable" : "certain";
174
+ const unseen = new Set((input.files.unseenLoads ?? []).map((load) => load.configuration));
175
+ const complete = input.config.ts.rootsComplete && !configurations.some((one) => unseen.has(one));
176
+ return unreachable && !complete ? "probable" : "certain";
143
177
  }
144
178
  /**
145
179
  * The component each dead declaration of the run falls in, and none for a live one, with
@@ -152,8 +186,24 @@ function findingComponents(swept, components, cascade) {
152
186
  held.set(member, component);
153
187
  }
154
188
  }
189
+ const listed = membersListed(swept);
190
+ return (id) => {
191
+ const component = held.get(id);
192
+ return component === undefined
193
+ ? undefined
194
+ : {
195
+ id: component.id,
196
+ root: component.roots.includes(id),
197
+ symbolCount: component.members.length,
198
+ deletableLines: component.deletableLines,
199
+ ...(cascade === "full" ? { members: listed(component.members) } : {}),
200
+ };
201
+ };
202
+ }
203
+ /** The members of a component as the run lists them, positioned and in the canonical order. */
204
+ function membersListed(swept) {
155
205
  const symbols = new Map(swept.matrix.union.symbols.map((symbol) => [symbol.id, symbol]));
156
- const listed = (component) => component.members
206
+ return (members) => members
157
207
  .flatMap((member) => {
158
208
  const symbol = symbols.get(member);
159
209
  return symbol === undefined
@@ -170,18 +220,6 @@ function findingComponents(swept, components, cascade) {
170
220
  ];
171
221
  })
172
222
  .sort((a, b) => byPosition(a.position, b.position));
173
- return (id) => {
174
- const component = held.get(id);
175
- return component === undefined
176
- ? undefined
177
- : {
178
- id: component.id,
179
- root: component.roots.includes(id),
180
- symbolCount: component.members.length,
181
- deletableLines: component.deletableLines,
182
- ...(cascade === "full" ? { members: listed(component) } : {}),
183
- };
184
- };
185
223
  }
186
224
  /**
187
225
  * The run's dead components with each file a `DS1502` finding reports dead among them, so
@@ -251,6 +289,7 @@ export function completed(input, findings, kinds = KINDS) {
251
289
  const candidates = new Map(swept.sweep.candidates.map((candidate) => [candidate.id, candidate]));
252
290
  const classOf = reachabilityClasses(input);
253
291
  const ceilingOf = testCeilings(input, classOf);
292
+ const testUsed = testUsedSubjects(input);
254
293
  const { components, computed, files } = runComponents(swept, findings);
255
294
  const componentOf = findingComponents(swept, components, input.config.reporters.cascade);
256
295
  const held = new Set(components.filter((one) => one.held).flatMap((one) => one.members));
@@ -258,11 +297,12 @@ export function completed(input, findings, kinds = KINDS) {
258
297
  let minted = computed;
259
298
  const dead = new Set(components.map((component) => component.id));
260
299
  const fallingWith = new Map();
261
- for (const { heldByTests } of swept.sweep.candidates) {
300
+ for (const { id, heldByTests } of swept.sweep.candidates) {
262
301
  if (heldByTests !== undefined) {
263
- fallingWith.set(heldByTests, (fallingWith.get(heldByTests) ?? 0) + 1);
302
+ fallingWith.set(heldByTests, [...(fallingWith.get(heldByTests) ?? []), id]);
264
303
  }
265
304
  }
305
+ const listed = membersListed(swept);
266
306
  const done = findings.map((finding) => {
267
307
  const row = kinds.get(finding.code);
268
308
  if (row === undefined) {
@@ -307,17 +347,40 @@ export function completed(input, findings, kinds = KINDS) {
307
347
  let component = found;
308
348
  if (component === undefined) {
309
349
  minted += 1;
350
+ const falling = symbol?.kind === "interface" ? fallingWith.get(symbol.id) : undefined;
310
351
  component = {
311
352
  id: componentId(minted),
312
353
  root: true,
313
- symbolCount: symbol?.kind === "interface" ? (fallingWith.get(symbol.id) ?? 1) : 1,
354
+ symbolCount: falling?.length ?? 1,
314
355
  deletableLines: 0,
356
+ ...(input.config.reporters.cascade !== "full"
357
+ ? {}
358
+ : falling === undefined
359
+ ? {
360
+ members: [
361
+ {
362
+ ref: finding.symbol.ref,
363
+ name: finding.symbol.name,
364
+ position: finding.position,
365
+ },
366
+ ],
367
+ }
368
+ : { members: listed(falling) }),
315
369
  };
316
370
  }
317
371
  const counts = candidate === undefined
318
372
  ? union.made[at]
319
373
  : { production: candidate.productionRefs, test: candidate.testRefs };
320
- const reachabilityClass = judged === undefined ? (finding.reachabilityClass ?? "certain") : classOf(judged.id);
374
+ const ownClass = judged === undefined ? (finding.reachabilityClass ?? "certain") : classOf(judged.id);
375
+ const used = judged !== undefined
376
+ ? candidate !== undefined && testUsed.declarations.has(judged.id)
377
+ : finding.code === FILE_NEVER_IMPORTED && testUsed.files.has(finding.position.path);
378
+ const reachabilityClass = used && candidate?.testOfDeadCode !== true ? "possible" : ownClass;
379
+ const configurations = candidate?.configurations ??
380
+ (judged === undefined && !part
381
+ ? swept.matrix.configurations
382
+ : (swept.matrix.heldIn[at] ?? swept.matrix.configurations));
383
+ const untested = capped(capped(capped(ownClass, row.maxClass), rootsCeiling(input, finding, candidate, configurations)), judged === undefined ? "certain" : ceilingOf(judged.id));
321
384
  const generated = input.files.generated?.has(finding.position.path) === true;
322
385
  const fixability = generated
323
386
  ? "none"
@@ -341,16 +404,14 @@ export function completed(input, findings, kinds = KINDS) {
341
404
  position: finding.position,
342
405
  symbol: finding.symbol,
343
406
  reachabilityClass,
344
- confidence: capped(capped(capped(reachabilityClass, row.maxClass), rootsCeiling(input, finding, candidate)), judged === undefined ? "certain" : ceilingOf(judged.id)),
407
+ confidence: used ? "possible" : untested,
408
+ ...(used ? { componentConfidence: untested } : {}),
345
409
  ...(candidate === undefined ? {} : { livenessRelation: candidate.relation }),
346
410
  testOnly: judged !== undefined && counts?.production === 0 && counts.test > 0,
347
411
  generated,
348
412
  component,
349
413
  retainedBy: [],
350
- configurations: candidate?.configurations ??
351
- (judged === undefined && !part
352
- ? swept.matrix.configurations
353
- : (swept.matrix.heldIn[at] ?? swept.matrix.configurations)),
414
+ configurations,
354
415
  consumersLoaded: input.boundary.consumers.loaded,
355
416
  fixability,
356
417
  severity: severityOf(input, finding.code, row.defaultSeverity),
@@ -365,7 +426,7 @@ export function completed(input, findings, kinds = KINDS) {
365
426
  /**
366
427
  * The findings with every finding of a dead component capped by the lowest confidence
367
428
  * among the component's root members, so the minimum confidence reports or withholds a
368
- * component whole.
429
+ * component whole but for the findings test-used code lowers on their own.
369
430
  */
370
431
  function componentCapped(findings, dead) {
371
432
  const lowest = new Map();
@@ -373,16 +434,27 @@ function componentCapped(findings, dead) {
373
434
  const { id, root } = finding.component;
374
435
  if (root && dead.has(id)) {
375
436
  const held = lowest.get(id);
376
- lowest.set(id, held === undefined ? finding.confidence : capped(held, finding.confidence));
437
+ const own = componentConfidenceOf(finding);
438
+ lowest.set(id, held === undefined ? own : capped(held, own));
377
439
  }
378
440
  }
379
441
  return findings.map((finding) => {
380
442
  const cap = lowest.get(finding.component.id);
381
- return cap === undefined || RANK[cap] >= RANK[finding.confidence]
382
- ? finding
383
- : { ...finding, confidence: cap };
443
+ if (cap === undefined) {
444
+ return finding;
445
+ }
446
+ const own = finding.componentConfidence;
447
+ return {
448
+ ...finding,
449
+ confidence: capped(finding.confidence, cap),
450
+ ...(own === undefined ? {} : { componentConfidence: capped(own, cap) }),
451
+ };
384
452
  });
385
453
  }
454
+ /** The confidence a finding gives its component's cap and the dials. */
455
+ function componentConfidenceOf(finding) {
456
+ return finding.componentConfidence ?? finding.confidence;
457
+ }
386
458
  /** Whether the dials withhold a finding by its own severity or confidence. */
387
459
  function dialed(finding, minConfidence) {
388
460
  return finding.severity === "allow" || RANK[finding.confidence] < RANK[minConfidence];
@@ -397,7 +469,8 @@ function dialed(finding, minConfidence) {
397
469
  export function dialsOf(input, findings) {
398
470
  const minConfidence = input.config.analysis.minConfidence;
399
471
  const components = new Set(findings
400
- .filter((finding) => finding.component.root && dialed(finding, minConfidence))
472
+ .filter((finding) => finding.component.root &&
473
+ dialed({ ...finding, confidence: componentConfidenceOf(finding) }, minConfidence))
401
474
  .map((finding) => finding.component.id));
402
475
  return {
403
476
  allows: (code) => {
@@ -28,7 +28,8 @@ function overlapClause(overlap, part) {
28
28
  * caller may pass a function used as a value more arguments than it declares, so there one
29
29
  * a read parameter follows is renamed to an underscore name, which the compilers read as
30
30
  * declared unread, and one no read parameter follows is deleted: kept where the call's
31
- * inference may read its annotation, at `probable` where nothing reads the annotation.
31
+ * inference may read its annotation, at `probable` where nothing reads the annotation or
32
+ * where it is a name a contextually typed object pattern binds.
32
33
  */
33
34
  function parameterVerdict(part, free, freeButValued) {
34
35
  const place = part.parameter;
@@ -45,7 +46,8 @@ function parameterVerdict(part, free, freeButValued) {
45
46
  if (place?.annotated === "steers") {
46
47
  return undefined;
47
48
  }
48
- return { fix: "delete", confidence: place?.annotated === "free" ? "probable" : "certain" };
49
+ const unseen = place?.annotated === "free" || place?.contextualPattern === true;
50
+ return { fix: "delete", confidence: unseen ? "probable" : "certain" };
49
51
  }
50
52
  /**
51
53
  * Whether one part is reported. A parameter needs its function's signature free to
@@ -1,7 +1,7 @@
1
1
  import { positionKey } from "../position.js";
2
2
  import { declarationKey } from "../ref.js";
3
- /** The containers whose type parameters `DS1303` reports: a function and a class method. */
4
- const SIGNATURES = new Set(["function", "method"]);
3
+ /** The containers whose type parameters `DS1303` reports: a function, a class method and a type alias. */
4
+ const SIGNATURES = new Set(["function", "method", "type"]);
5
5
  function indexed(input) {
6
6
  const { matrix, sweep } = input.swept;
7
7
  return {
@@ -165,10 +165,11 @@ function unusedEnumMembers(run) {
165
165
  return unnamed(run, "enum-member").map((symbol) => findingAbout(symbol, "DS1302", `enum member ${symbol.name} is named nowhere`));
166
166
  }
167
167
  /**
168
- * `DS1303`: a type parameter of a function or a class method that neither the
169
- * declaration's signature nor its body names. A type parameter of a class, an interface,
170
- * a type alias or a method signature belongs to a type declaration and is never
171
- * reported, because a phantom parameter makes two instantiations distinct types.
168
+ * `DS1303`: a type parameter of a function, a class method or a type alias that neither
169
+ * the declaration's signature nor its body names. An alias names no type of its own, so
170
+ * a parameter its type never names makes no two instantiations distinct. One of a class,
171
+ * an interface or a method signature is never reported: a class or an interface merges
172
+ * only with declarations of the same parameters.
172
173
  */
173
174
  function unusedTypeParameters(run) {
174
175
  return unnamed(run, "type-parameter")
@@ -1,3 +1,4 @@
1
+ import { testsUnseenAPI } from "./completion.js";
1
2
  import { unreachableExports } from "./visibility-narrowing.js";
2
3
  /** The kinds of declaration that are members of a class, an interface, a type or an enum. */
3
4
  const MEMBERS = new Set([
@@ -41,13 +42,14 @@ const TEST_OF_DEAD_CODE = "every target symbol this test references is reported
41
42
  * The code one candidate is reported under, or none, each symbol once under the most
42
43
  * specific code: a test of dead code first; no other test-file declaration or enum
43
44
  * 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.
45
+ * use; then deprecation, a test reference, or only dead production references beside a
46
+ * test that stays, and the unreferenced codes, an unimportable unused export being the
47
+ * unreachable kind's.
46
48
  */
47
49
  function codeOf(judged) {
48
50
  const { candidate, symbol } = judged;
49
51
  if (candidate.testOfDeadCode) {
50
- return "DS1005";
52
+ return judged.testsUnseenAPI ? undefined : "DS1005";
51
53
  }
52
54
  if ((judged.test && !candidate.unreferencedTest) || NOT_THIS_FAMILY.has(symbol.kind)) {
53
55
  return undefined;
@@ -65,7 +67,9 @@ function codeOf(judged) {
65
67
  if (candidate.productionRefs === 0 && judged.deprecated) {
66
68
  return "DS1006";
67
69
  }
68
- if (candidate.productionRefs === 0 && candidate.testRefs > 0) {
70
+ if (candidate.testRefs > 0 &&
71
+ (candidate.productionRefs === 0 ||
72
+ (candidate.relation === "reachability" && candidate.keptByTests))) {
69
73
  return "DS1004";
70
74
  }
71
75
  if (MEMBERS.has(symbol.kind)) {
@@ -109,7 +113,9 @@ function messageOf(code, judged) {
109
113
  case "DS1006":
110
114
  return `deprecated ${word} has no production reference`;
111
115
  case "DS1004":
112
- return `${word} is referenced only from test files and never from production code`;
116
+ return candidate.productionRefs === 0
117
+ ? `${word} is referenced only from test files and never from production code`
118
+ : `${word} is referenced only from test files and from declarations that are themselves dead`;
113
119
  }
114
120
  const subject = code === "DS1003"
115
121
  ? `${visibilityWord(symbol)}${word}`
@@ -194,6 +200,7 @@ function familyFindings(input, fallen) {
194
200
  const union = input.swept.matrix.union;
195
201
  const dead = new Set(input.swept.sweep.candidates.map((candidate) => candidate.id));
196
202
  const unreachable = unreachableExports(input);
203
+ const unseenAPI = testsUnseenAPI(input);
197
204
  let brands;
198
205
  const found = [];
199
206
  for (const candidate of input.swept.sweep.candidates) {
@@ -212,6 +219,7 @@ function familyFindings(input, fallen) {
212
219
  test: union.test[at] === true,
213
220
  deprecated: input.deprecated.has(symbol.id),
214
221
  unreachable: unreachable.has(symbol.id),
222
+ testsUnseenAPI: candidate.testOfDeadCode && unseenAPI(candidate.id),
215
223
  };
216
224
  const code = codeOf(judged);
217
225
  if (code === undefined ||
@@ -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/graph.js CHANGED
@@ -34,9 +34,9 @@ const SWEPT = {
34
34
  * Whether a root of each kind names a caller, which holds it live under both relations: a
35
35
  * tool reads what it loads and what its strings name, a runtime runs a test, setup or
36
36
  * worker file, a page's module script, a command, a script or a workflow step, the manifest
37
- * names what enters the package, an alias global stands for its export, and an entry file,
38
- * a configured root and a pattern assert a caller. A library's published API only supposes
39
- * an unseen consumer, so it is live under reachability alone.
37
+ * names what enters the package, an alias global stands for its export, an outside
38
+ * declaration names a global interface merging into it, and an entry file, a configured root
39
+ * and a pattern assert a caller. A published API supposes a consumer: reachability alone.
40
40
  */
41
41
  const NAMES_A_CALLER = {
42
42
  "entry-file": true,
@@ -49,8 +49,10 @@ const NAMES_A_CALLER = {
49
49
  pattern: true,
50
50
  "type-error": true,
51
51
  "type-query-alias": true,
52
+ "global-augmentation": true,
52
53
  "configuration-file": true,
53
54
  "configuration-string": true,
55
+ "bundler-entry": true,
54
56
  "test-runner": true,
55
57
  "browser-tests": true,
56
58
  worker: true,
@@ -772,10 +772,9 @@ function calleeSteers(project, signature, at) {
772
772
  }
773
773
  /**
774
774
  * The parts with each name of `contextual` judged where the compiler gives its function a
775
- * contextual type: the code handing the argument may act on what an object pattern reads,
776
- * so each such name is read, and an annotated parameter is marked with whether the call's
777
- * inference may read its annotation. A question the checker leaves unanswered keeps the
778
- * name read.
775
+ * contextual type: a name an object pattern binds is marked as one, and an annotated
776
+ * parameter with whether the call's inference may read its annotation. A question the
777
+ * checker leaves unanswered keeps the name read.
779
778
  */
780
779
  function withContextualRead(project, parts, contextual) {
781
780
  if (contextual.length === 0) {
@@ -791,15 +790,17 @@ function withContextualRead(project, parts, contextual) {
791
790
  continue;
792
791
  }
793
792
  const place = one.part.parameter;
794
- judged.set(one.part, one.pattern || answer === UNANSWERED || place === undefined
793
+ judged.set(one.part, answer === UNANSWERED || place === undefined
795
794
  ? { ...one.part, used: true }
796
- : {
797
- ...one.part,
798
- parameter: {
799
- ...place,
800
- annotated: calleeSteers(project, one.signature, one.at) ? "steers" : "free",
801
- },
802
- });
795
+ : one.pattern
796
+ ? { ...one.part, parameter: { ...place, contextualPattern: true } }
797
+ : {
798
+ ...one.part,
799
+ parameter: {
800
+ ...place,
801
+ annotated: calleeSteers(project, one.signature, one.at) ? "steers" : "free",
802
+ },
803
+ });
803
804
  }
804
805
  return parts.map((part) => judged.get(part) ?? part);
805
806
  }
@@ -884,15 +885,28 @@ function functionOf(block) {
884
885
  }
885
886
  }
886
887
  /**
887
- * The locals one block declares with `let`, `const` or `var`, each with the names of the
888
- * block that spell it. A `var` of a nested block is one where no nested function of its
889
- * function names it, since the function scope holds it past the block's end.
888
+ * The locals one block declares with `let`, `const`, `var` or a function declaration
889
+ * inside a function, each with the names of the block that spell it. A `var` of a nested
890
+ * block is one where no nested function of its function names it, since the function scope
891
+ * holds it past the block's end.
890
892
  */
891
893
  function localsOf(block) {
892
894
  const declared = [];
893
895
  const scope = functionOf(block);
894
896
  const outlives = scope?.body !== block;
895
897
  block.statements.forEach((statement, at) => {
898
+ if (isFunctionDeclaration(statement)) {
899
+ if (scope !== undefined && statement.name !== undefined && statement.body !== undefined) {
900
+ declared.push({
901
+ name: statement.name,
902
+ statement: at,
903
+ initialized: true,
904
+ outlives: false,
905
+ hoisted: true,
906
+ });
907
+ }
908
+ return;
909
+ }
896
910
  if (!isVariableStatement(statement)) {
897
911
  return;
898
912
  }
@@ -902,6 +916,11 @@ function localsOf(block) {
902
916
  return;
903
917
  }
904
918
  for (const declaration of list.declarations) {
919
+ // A write to a local typed `never` is the compiler's exhaustiveness check, which
920
+ // fails to compile once a case goes unhandled.
921
+ if (declaration.type?.kind === SyntaxKind.NeverKeyword) {
922
+ continue;
923
+ }
905
924
  for (const name of boundNames(declaration.name)) {
906
925
  if (!blockScoped &&
907
926
  outlives &&
@@ -913,6 +932,7 @@ function localsOf(block) {
913
932
  statement: at,
914
933
  initialized: declaration.initializer !== undefined,
915
934
  outlives: !blockScoped && outlives,
935
+ hoisted: false,
916
936
  });
917
937
  }
918
938
  }
@@ -953,12 +973,20 @@ function deadStores(file, root, block, holder, locals, ids) {
953
973
  used: false,
954
974
  });
955
975
  for (const local of locals) {
956
- const uses = local.uses.filter((use) => readsBinding(use, local.name, ids));
976
+ const own = local.hoisted ? block.statements[local.statement] : undefined;
977
+ // A function's reads of itself keep it no more than its absence would.
978
+ const uses = local.uses.filter((use) => readsBinding(use, local.name, ids) && (own === undefined || !within(use, own)));
957
979
  if (!local.outlives && uses.every(isPlainWrite)) {
958
- const targets = [...(local.initialized ? [local.name] : []), ...uses];
980
+ // A declaration with no initializer writes `undefined`, the one store left to name.
981
+ const writes = [...(local.initialized ? [local.name] : []), ...uses];
982
+ const targets = writes.length === 0 ? [local.name] : writes;
959
983
  found.push(...targets.map((target) => deadAt(local, target)));
960
984
  continue;
961
985
  }
986
+ // A read anywhere in the block reads a function declaration, which is written first.
987
+ if (local.hoisted) {
988
+ continue;
989
+ }
962
990
  if (uses.some((use) => inNestedFunction(use, block))) {
963
991
  continue;
964
992
  }