@cplieger/deadset-ts 5.1.1 → 5.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/src/analysis.js +7 -4
  2. package/dist/src/config.js +3 -0
  3. package/dist/src/configuration-files.js +23 -24
  4. package/dist/src/conformance-record.js +2 -2
  5. package/dist/src/contextual-uses.js +25 -4
  6. package/dist/src/convention-rows.js +7 -0
  7. package/dist/src/conventions.js +97 -33
  8. package/dist/src/dependencies.js +8 -0
  9. package/dist/src/entry-point-gaps.js +10 -26
  10. package/dist/src/entry-points.js +4 -0
  11. package/dist/src/findings/completion.js +27 -11
  12. package/dist/src/findings/interfaces.js +2 -6
  13. package/dist/src/findings/intra-function.js +15 -3
  14. package/dist/src/findings/unused-declarations.js +5 -6
  15. package/dist/src/findings/visibility-narrowing.js +6 -1
  16. package/dist/src/generated-file.js +72 -12
  17. package/dist/src/graph.js +5 -4
  18. package/dist/src/html-entries.js +137 -0
  19. package/dist/src/kinds.js +2 -2
  20. package/dist/src/manifest.js +5 -1
  21. package/dist/src/matrix.js +9 -3
  22. package/dist/src/namespace-objects.js +158 -0
  23. package/dist/src/partial.js +1 -1
  24. package/dist/src/references.js +120 -3
  25. package/dist/src/roots.js +52 -3
  26. package/dist/src/sweep.js +17 -0
  27. package/dist/src/test-support.js +35 -23
  28. package/dist/src/version.js +1 -1
  29. package/jsr.json +1 -1
  30. package/package.json +2 -2
  31. package/src/analysis.ts +12 -5
  32. package/src/config.ts +3 -0
  33. package/src/configuration-files.ts +28 -24
  34. package/src/conformance-record.ts +2 -2
  35. package/src/contextual-uses.ts +31 -4
  36. package/src/convention-rows.ts +15 -0
  37. package/src/conventions.ts +119 -30
  38. package/src/dependencies.ts +9 -0
  39. package/src/entry-point-gaps.ts +11 -28
  40. package/src/entry-points.ts +7 -1
  41. package/src/exempt.ts +1 -4
  42. package/src/file-facts.ts +1 -1
  43. package/src/findings/completion.ts +31 -11
  44. package/src/findings/interfaces.ts +2 -9
  45. package/src/findings/intra-function.ts +17 -3
  46. package/src/findings/unused-declarations.ts +5 -8
  47. package/src/findings/visibility-narrowing.ts +7 -1
  48. package/src/generated-file.ts +80 -12
  49. package/src/graph.ts +5 -4
  50. package/src/html-entries.ts +170 -0
  51. package/src/kinds.ts +2 -2
  52. package/src/manifest.ts +9 -3
  53. package/src/matrix.ts +21 -3
  54. package/src/namespace-objects.ts +216 -0
  55. package/src/partial.ts +1 -1
  56. package/src/references.ts +161 -3
  57. package/src/roots.ts +69 -10
  58. package/src/sweep.ts +19 -0
  59. package/src/test-support.ts +34 -22
  60. package/src/version.ts +1 -1
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Module namespace objects: the binding of a namespace import, the value an `await` of a
3
+ * dynamic `import()` produces, the first parameter of the first callback passed to `then`
4
+ * on one, and a `const` binding initialized with one of these. A use reads the exports it
5
+ * names and no other: a property access by name or by a literal index, or a destructured
6
+ * property, names one export, and any other use may read every export.
7
+ */
8
+
9
+ import {
10
+ isArrowFunction,
11
+ isAwaitExpression,
12
+ isBindingElement,
13
+ isCallExpression,
14
+ isElementAccessExpression,
15
+ isExpressionStatement,
16
+ isFunctionExpression,
17
+ isIdentifier,
18
+ isImportDeclaration,
19
+ isNamespaceImport,
20
+ isNoSubstitutionTemplateLiteral,
21
+ isObjectBindingPattern,
22
+ isParenthesizedExpression,
23
+ isPropertyAccessExpression,
24
+ isQualifiedName,
25
+ isStringLiteral,
26
+ isVariableDeclaration,
27
+ isVariableDeclarationList,
28
+ isVoidExpression,
29
+ NodeFlags,
30
+ SyntaxKind,
31
+ type Identifier,
32
+ type Node,
33
+ type SourceFile,
34
+ } from "@typescript/native/unstable/ast";
35
+
36
+ /** What one use of a namespace object reads beyond the names the checker resolves. */
37
+ export type NamespaceUse =
38
+ /** Every export of the module `specifier` names. */
39
+ | { readonly kind: "whole"; readonly specifier: Node }
40
+ /** The export a literal index names, the literal being the node to resolve. */
41
+ | { readonly kind: "indexed"; readonly literal: Node };
42
+
43
+ function isLiteralText(node: Node): boolean {
44
+ return isStringLiteral(node) || isNoSubstitutionTemplateLiteral(node);
45
+ }
46
+
47
+ /** The outermost expression one node is, through the parentheses written around it. */
48
+ function parenthesized(node: Node): Node {
49
+ let at = node;
50
+ while (isParenthesizedExpression(at.parent)) {
51
+ at = at.parent;
52
+ }
53
+ return at;
54
+ }
55
+
56
+ /** Whether one call is a dynamic `import()` whose argument is literal text. */
57
+ function isLiteralImport(node: Node): boolean {
58
+ if (!isCallExpression(node) || node.expression.kind !== SyntaxKind.ImportKeyword) {
59
+ return false;
60
+ }
61
+ const [argument] = node.arguments;
62
+ return argument !== undefined && isLiteralText(argument);
63
+ }
64
+
65
+ /** The node a `const` binding's name is visible below: the block or file its statement is in. */
66
+ function scopeOf(declaration: Node): Node {
67
+ const statement = declaration.parent.parent;
68
+ return statement.parent;
69
+ }
70
+
71
+ /** Every identifier below `scope` that spells `name`, other than `declared` and a member's name. */
72
+ function occurrences(scope: Node, name: string, declared: Node): readonly Identifier[] {
73
+ const found: Identifier[] = [];
74
+ const visit = (node: Node): void => {
75
+ if (isIdentifier(node)) {
76
+ const parent = node.parent;
77
+ const member =
78
+ (isPropertyAccessExpression(parent) && parent.name === node) ||
79
+ (isQualifiedName(parent) && parent.right === node) ||
80
+ (isBindingElement(parent) && parent.propertyName === node);
81
+ if (node !== declared && node.text === name && !member) {
82
+ found.push(node);
83
+ }
84
+ return;
85
+ }
86
+ node.forEachChild(visit);
87
+ };
88
+ scope.forEachChild(visit);
89
+ return found;
90
+ }
91
+
92
+ /**
93
+ * Every use of the namespace objects one file holds, keyed by the node the use is written
94
+ * at, that reads more than a name the checker resolves: an indexed read by a literal, and
95
+ * any use that may read every export. A shadowing declaration of a binding's name inside
96
+ * its scope is read as the binding, which can only keep an export live.
97
+ */
98
+ export function namespaceUses(file: SourceFile): ReadonlyMap<Node, NamespaceUse> {
99
+ const uses = new Map<Node, NamespaceUse>();
100
+
101
+ const binding = (name: Identifier, scope: Node, specifier: Node, seen: Set<Node>): void => {
102
+ if (seen.has(name)) {
103
+ return;
104
+ }
105
+ seen.add(name);
106
+ for (const one of occurrences(scope, name.text, name)) {
107
+ value(one, specifier, seen);
108
+ }
109
+ };
110
+
111
+ /** One use of a namespace object's value, at `node`. */
112
+ const value = (node: Node, specifier: Node, seen: Set<Node>): void => {
113
+ const at = parenthesized(node);
114
+ const parent = at.parent;
115
+ if (isExpressionStatement(parent) || isVoidExpression(parent)) {
116
+ return;
117
+ }
118
+ if (
119
+ (isPropertyAccessExpression(parent) && parent.expression === at) ||
120
+ (isQualifiedName(parent) && parent.left === at)
121
+ ) {
122
+ return;
123
+ }
124
+ if (isElementAccessExpression(parent) && parent.expression === at) {
125
+ const index = parent.argumentExpression;
126
+ uses.set(
127
+ node,
128
+ isLiteralText(index) ? { kind: "indexed", literal: index } : { kind: "whole", specifier },
129
+ );
130
+ return;
131
+ }
132
+ if (isVariableDeclaration(parent) && parent.initializer === at) {
133
+ const pattern = parent.name;
134
+ if (isObjectBindingPattern(pattern)) {
135
+ const named = pattern.elements.every(
136
+ (element) =>
137
+ element.dotDotDotToken === undefined &&
138
+ element.propertyName?.kind !== SyntaxKind.ComputedPropertyName,
139
+ );
140
+ if (!named) {
141
+ uses.set(node, { kind: "whole", specifier });
142
+ }
143
+ return;
144
+ }
145
+ const list = parent.parent;
146
+ if (
147
+ isIdentifier(pattern) &&
148
+ isVariableDeclarationList(list) &&
149
+ (list.flags & NodeFlags.Const) !== 0
150
+ ) {
151
+ binding(pattern, scopeOf(parent), specifier, seen);
152
+ return;
153
+ }
154
+ }
155
+ uses.set(node, { kind: "whole", specifier });
156
+ };
157
+
158
+ /** One use of a dynamic `import()` call, at `call`. */
159
+ const imported = (call: Node, specifier: Node, seen: Set<Node>): void => {
160
+ const at = parenthesized(call);
161
+ const parent = at.parent;
162
+ if (isExpressionStatement(parent) || isVoidExpression(parent)) {
163
+ return;
164
+ }
165
+ if (isAwaitExpression(parent)) {
166
+ value(parent, specifier, seen);
167
+ return;
168
+ }
169
+ const then = parent.parent;
170
+ if (
171
+ isPropertyAccessExpression(parent) &&
172
+ parent.expression === at &&
173
+ parent.name.text === "then" &&
174
+ isCallExpression(then) &&
175
+ then.expression === parent
176
+ ) {
177
+ const [callback] = then.arguments;
178
+ if (callback !== undefined && (isArrowFunction(callback) || isFunctionExpression(callback))) {
179
+ const [first] = callback.parameters;
180
+ if (first === undefined || isObjectBindingPattern(first.name)) {
181
+ return;
182
+ }
183
+ if (isIdentifier(first.name)) {
184
+ binding(first.name, callback, specifier, seen);
185
+ return;
186
+ }
187
+ }
188
+ }
189
+ uses.set(call, { kind: "whole", specifier });
190
+ };
191
+
192
+ const visit = (node: Node): void => {
193
+ if (isImportDeclaration(node)) {
194
+ const clause = node.importClause;
195
+ const bindings = clause?.namedBindings;
196
+ if (
197
+ clause !== undefined &&
198
+ clause.phaseModifier !== SyntaxKind.TypeKeyword &&
199
+ bindings !== undefined &&
200
+ isNamespaceImport(bindings)
201
+ ) {
202
+ binding(bindings.name, file, node.moduleSpecifier, new Set());
203
+ }
204
+ return;
205
+ }
206
+ if (isLiteralImport(node) && isCallExpression(node)) {
207
+ const [argument] = node.arguments;
208
+ if (argument !== undefined) {
209
+ imported(node, argument, new Set());
210
+ }
211
+ }
212
+ node.forEachChild(visit);
213
+ };
214
+ file.forEachChild(visit);
215
+ return uses;
216
+ }
package/src/partial.ts CHANGED
@@ -38,7 +38,7 @@ export function partialNotes(partial: Partial): readonly string[] {
38
38
  }
39
39
  const where = [...byProject].map(([configFile, n]) => `${configFile}: ${String(n)}`).join(", ");
40
40
  notes.push(
41
- `the checker answered ${counted(partial.unanswered.length, "question", "questions")} with a failure (${where}); every declaration an answer could have kept live is kept live and reported by nothing`,
41
+ `the checker answered ${counted(partial.unanswered.length, "question", "questions")} with a failure (${where}), so every declaration an answer could have kept live is kept live and reported by nothing`,
42
42
  );
43
43
  }
44
44
  return notes;
package/src/references.ts CHANGED
@@ -12,8 +12,11 @@ import {
12
12
  isForInStatement,
13
13
  isForOfStatement,
14
14
  isForStatement,
15
+ isIdentifier,
15
16
  isImportDeclaration,
16
17
  isImportEqualsDeclaration,
18
+ isMetaProperty,
19
+ isNewExpression,
17
20
  isNamespaceExport,
18
21
  isNoSubstitutionTemplateLiteral,
19
22
  isObjectLiteralExpression,
@@ -35,6 +38,8 @@ import type { Symbol as TSSymbol } from "@typescript/native/unstable/sync";
35
38
  import { aliasChains, type ChainTarget } from "./alias-chain.ts";
36
39
  import { contextualUses, type FlowAt, type LiteralAt } from "./contextual-uses.ts";
37
40
  import { destructuring, propertyReadsOf, type PropertyRead } from "./destructuring.ts";
41
+ import { specifierCandidates } from "./configuration-files.ts";
42
+ import { namespaceUses } from "./namespace-objects.ts";
38
43
  import { globExpression } from "./glob.ts";
39
44
  import { declarationsByName, nodeKey, type Inventory, type InventorySymbol } from "./inventory.ts";
40
45
  import { overrideReferences } from "./overrides.ts";
@@ -424,6 +429,27 @@ interface Evaluation {
424
429
  readonly from: string;
425
430
  }
426
431
 
432
+ /**
433
+ * One module URL a source file builds: `new URL(<literal>, import.meta.url)` or
434
+ * `import.meta.resolve(<literal>)`, the literal relative. A runtime starts the file it
435
+ * names as a module, so it is an import of that file for its effects.
436
+ */
437
+ interface ModuleURL {
438
+ readonly specifier: string;
439
+ /** The `URL` the constructor names, which must name no declaration of the target. */
440
+ readonly constructor: Node | undefined;
441
+ readonly at: Node;
442
+ }
443
+
444
+ /** One use of a module namespace object that may read every export of the module. */
445
+ interface WholeRead {
446
+ /** The module specifier, whose symbol is the module read. */
447
+ readonly specifier: Node;
448
+ /** The use, whose position the reference carries. */
449
+ readonly at: Node;
450
+ readonly from: string;
451
+ }
452
+
427
453
  /** One decorator expression, and the declaration it is attached to. */
428
454
  interface Decorated {
429
455
  readonly decorator: Node;
@@ -443,9 +469,55 @@ interface FileSites {
443
469
  readonly reads: readonly Read[];
444
470
  readonly links: readonly Link[];
445
471
  readonly evaluations: readonly Evaluation[];
472
+ readonly wholes: readonly WholeRead[];
473
+ readonly urls: readonly ModuleURL[];
446
474
  readonly decorated: readonly Decorated[];
447
475
  }
448
476
 
477
+ /** Whether one node is `import.meta.url`. */
478
+ function isImportMetaURL(node: Node): boolean {
479
+ const at = unparenthesized(node);
480
+ return (
481
+ isPropertyAccessExpression(at) &&
482
+ isMetaProperty(at.expression) &&
483
+ at.expression.keywordToken === SyntaxKind.ImportKeyword &&
484
+ at.name.text === "url"
485
+ );
486
+ }
487
+
488
+ /** The module URL one node builds, where it builds one. */
489
+ function moduleURLOf(node: Node): ModuleURL | undefined {
490
+ const relative = (argument: Node | undefined): string | undefined => {
491
+ const at = argument === undefined ? undefined : unparenthesized(argument);
492
+ return at !== undefined &&
493
+ (isStringLiteral(at) || isNoSubstitutionTemplateLiteral(at)) &&
494
+ /^\.\.?\//u.test(at.text)
495
+ ? at.text
496
+ : undefined;
497
+ };
498
+ if (isNewExpression(node) && isIdentifier(node.expression) && node.expression.text === "URL") {
499
+ const [reference, base] = node.arguments ?? [];
500
+ const specifier = relative(reference);
501
+ return specifier !== undefined && base !== undefined && isImportMetaURL(base)
502
+ ? { specifier, constructor: node.expression, at: node }
503
+ : undefined;
504
+ }
505
+ if (isCallExpression(node)) {
506
+ const callee = unparenthesized(node.expression);
507
+ const specifier = relative(node.arguments[0]);
508
+ if (
509
+ specifier !== undefined &&
510
+ isPropertyAccessExpression(callee) &&
511
+ isMetaProperty(callee.expression) &&
512
+ callee.expression.keywordToken === SyntaxKind.ImportKeyword &&
513
+ callee.name.text === "resolve"
514
+ ) {
515
+ return { specifier, constructor: undefined, at: node };
516
+ }
517
+ }
518
+ return undefined;
519
+ }
520
+
449
521
  /** Whether one node is a string literal or a template literal with no substitution. */
450
522
  function isLiteralText(node: Node | undefined): node is Node {
451
523
  return node !== undefined && (isStringLiteral(node) || isNoSubstitutionTemplateLiteral(node));
@@ -584,7 +656,10 @@ function sitesOf(
584
656
  const reads: Read[] = [];
585
657
  const links: Link[] = [];
586
658
  const evaluations: Evaluation[] = [];
659
+ const wholes: WholeRead[] = [];
660
+ const urls: ModuleURL[] = [];
587
661
  const decorated: Decorated[] = [];
662
+ const namespaces = namespaceUses(file);
588
663
  const literals: LiteralAt[] = [];
589
664
  const flows: FlowAt[] = [];
590
665
  const writes = new Set<number>();
@@ -615,6 +690,12 @@ function sitesOf(
615
690
  if (unread.has(node)) {
616
691
  return;
617
692
  }
693
+ const namespace = namespaces.get(node);
694
+ if (namespace?.kind === "whole") {
695
+ wholes.push({ specifier: namespace.specifier, at: node, from: enclosing });
696
+ } else if (namespace?.kind === "indexed") {
697
+ found.push({ node: namespace.literal, shorthand: undefined, from: enclosing, use: "read" });
698
+ }
618
699
  if (isName(node)) {
619
700
  if (!declared.has(node.pos)) {
620
701
  const site = { node, shorthand: shorthands.get(node.pos), from: enclosing };
@@ -644,6 +725,10 @@ function sitesOf(
644
725
  if (evaluated !== undefined) {
645
726
  evaluations.push({ node: evaluated, from: enclosing });
646
727
  }
728
+ const url = moduleURLOf(node);
729
+ if (url !== undefined) {
730
+ urls.push(url);
731
+ }
647
732
  const held = declarations.get(nodeKey(file, node));
648
733
  const outer = enclosing;
649
734
  if (held !== undefined) {
@@ -659,7 +744,7 @@ function sitesOf(
659
744
  };
660
745
 
661
746
  file.forEachChild(visit);
662
- return { uses: found, literals, flows, reads, links, evaluations, decorated };
747
+ return { uses: found, literals, flows, reads, links, evaluations, wholes, urls, decorated };
663
748
  }
664
749
 
665
750
  /** The declaration kinds of a property an object literal declares. */
@@ -922,6 +1007,36 @@ export function references<Brand>(
922
1007
  return topLevel.get(fileId) ?? [];
923
1008
  };
924
1009
 
1010
+ const exportsCache = new Map<number, readonly ChainTarget[]>();
1011
+ /**
1012
+ * Every declaration one module exports, each re-export walked along its chain. A module
1013
+ * the checker left unanswered may be any file, so it reads every file's exports.
1014
+ */
1015
+ const exportsRead = (module: Answer<TSSymbol>): readonly ChainTarget[] => {
1016
+ if (module === UNANSWERED) {
1017
+ return [...fileIds].flatMap((id) =>
1018
+ declaredIn(id)
1019
+ .filter((symbol) => symbol.exported)
1020
+ .map((symbol) => ({ id: symbol.id, stepped: false, guessed: true })),
1021
+ );
1022
+ }
1023
+ const known = exportsCache.get(module.id);
1024
+ if (known !== undefined) {
1025
+ return known;
1026
+ }
1027
+ const table = project.queries.exportsOfModule(module);
1028
+ const read =
1029
+ table === UNANSWERED
1030
+ ? chains.declarationsOf(module).flatMap((id) =>
1031
+ declaredIn(id)
1032
+ .filter((symbol) => symbol.exported)
1033
+ .map((symbol) => ({ id: symbol.id, stepped: false, guessed: true })),
1034
+ )
1035
+ : table.flatMap((exported) => chains.chainOf(exported));
1036
+ exportsCache.set(module.id, read);
1037
+ return read;
1038
+ };
1039
+
925
1040
  /** The identifier of the declaration one node of this program is, where it is one. */
926
1041
  const declaredAt = (node: Node): string | undefined =>
927
1042
  declarations.get(nodeKey(node.getSourceFile(), node));
@@ -1022,7 +1137,7 @@ export function references<Brand>(
1022
1137
  const fileId = declaredAt(file) ?? "";
1023
1138
  return { file, fileId, ...sitesOf(file, declarations, fileId, unread) };
1024
1139
  });
1025
- const fileIdByName = new Map(
1140
+ const fileIdByName = new Map<string, string>(
1026
1141
  walked.filter((one) => fileIds.has(one.fileId)).map((one) => [one.file.fileName, one.fileId]),
1027
1142
  );
1028
1143
  const contextual = contextualUses(
@@ -1112,7 +1227,7 @@ export function references<Brand>(
1112
1227
  }
1113
1228
  }
1114
1229
 
1115
- for (const { file, fileId, uses, reads, links, evaluations, decorated } of walked) {
1230
+ for (const { file, fileId, uses, reads, links, evaluations, wholes, urls, decorated } of walked) {
1116
1231
  const test = testFile.get(file) === true;
1117
1232
  // A component file's markup may use any binding its blocks declare at the top level.
1118
1233
  const markup = isComponentFile(file);
@@ -1142,6 +1257,8 @@ export function references<Brand>(
1142
1257
  ...uses.filter((site) => site.shorthand === undefined).map((site) => site.node),
1143
1258
  ...links.map((link) => link.node),
1144
1259
  ...evaluations.map((evaluation) => evaluation.node),
1260
+ ...wholes.map((whole) => whole.specifier),
1261
+ ...urls.flatMap((url) => url.constructor ?? []),
1145
1262
  ...bindings.map((binding) => binding.node),
1146
1263
  ]),
1147
1264
  ];
@@ -1217,6 +1334,47 @@ export function references<Brand>(
1217
1334
  }
1218
1335
  }
1219
1336
 
1337
+ for (const url of urls) {
1338
+ const constructor = url.constructor === undefined ? undefined : answered.get(url.constructor);
1339
+ if (
1340
+ typeof constructor === "object" &&
1341
+ constructor.declarations.some((handle) => ownPaths.has(handle.path))
1342
+ ) {
1343
+ continue;
1344
+ }
1345
+ const loaded = specifierCandidates(dirnamePath(file.fileName), url.specifier)
1346
+ .map((path) => fileIdByName.get(path))
1347
+ .find((id) => id !== undefined);
1348
+ if (loaded !== undefined) {
1349
+ found.push({
1350
+ from: fileId,
1351
+ to: loaded,
1352
+ position: renderPosition(file, root, url.at.getStart()),
1353
+ use: "evaluation",
1354
+ resolution: "syntax",
1355
+ test,
1356
+ });
1357
+ }
1358
+ }
1359
+
1360
+ for (const whole of wholes) {
1361
+ const module = answered.get(whole.specifier);
1362
+ if (module === undefined) {
1363
+ continue;
1364
+ }
1365
+ const position = renderPosition(file, root, whole.at.getStart());
1366
+ for (const target of exportsRead(module)) {
1367
+ found.push({
1368
+ from: whole.from,
1369
+ to: target.id,
1370
+ position,
1371
+ use: "read",
1372
+ resolution: target.guessed ? "by-name" : "batch",
1373
+ test,
1374
+ });
1375
+ }
1376
+ }
1377
+
1220
1378
  if (markup) {
1221
1379
  for (const symbol of declaredIn(fileId)) {
1222
1380
  found.push({
package/src/roots.ts CHANGED
@@ -26,6 +26,7 @@ import { globExpression } from "./glob.ts";
26
26
  import type { Host } from "./host.ts";
27
27
  import { nodeKey, type Inventory, type InventorySymbol } from "./inventory.ts";
28
28
  import type { Manifest, ManifestEntry } from "./manifest.ts";
29
+ import { relativePath, resolvePath } from "./paths.ts";
29
30
  import { byPosition } from "./position.ts";
30
31
  import { UNANSWERED } from "./query.ts";
31
32
  import type { ProjectView } from "./session.ts";
@@ -135,6 +136,50 @@ const ENTRY_KINDS = {
135
136
  script: "script",
136
137
  } as const satisfies Record<ManifestEntry["role"], RootKind>;
137
138
 
139
+ /** The rules whose rooted files a manifest target is read back to by name. */
140
+ const READ_BACK_RULES: ReadonlySet<EntryRule> = new Set([
141
+ "configuration-file",
142
+ "configuration-string",
143
+ "html-entry",
144
+ ]);
145
+
146
+ /** The extensions a manifest target's name drops: the ones a build writes. */
147
+ const OUTPUT_EXTENSION = /\.(?:d\.ts|d\.mts|d\.cts|js|mjs|cjs|jsx)$/u;
148
+
149
+ /** The extension a source file's name drops. */
150
+ const SOURCE_EXTENSION = /\.(?:d\.)?[cm]?[jt]sx?$/u;
151
+
152
+ /**
153
+ * The rooted files one manifest target reads back to by name: its path below the
154
+ * manifest's directory, past its first directory where it names one, without its output
155
+ * extension, is the name, and a rooted file is one whose path below the same directory,
156
+ * without its source extension, ends with that name at a directory boundary.
157
+ */
158
+ function readBackByName(
159
+ host: Host,
160
+ entry: ManifestEntry,
161
+ rooted: readonly SourceFile[],
162
+ ): readonly SourceFile[] {
163
+ const dir = resolvePath(host.workingDirectory(), entry.dir);
164
+ const below = relativePath(dir, resolvePath(host.workingDirectory(), entry.path));
165
+ if (below === undefined || below === "") {
166
+ return [];
167
+ }
168
+ const cut = below.indexOf("/");
169
+ const name = (cut === -1 ? below : below.slice(cut + 1)).replace(OUTPUT_EXTENSION, "");
170
+ if (name === "") {
171
+ return [];
172
+ }
173
+ const found = new Map<string, SourceFile>();
174
+ for (const file of rooted) {
175
+ const path = relativePath(dir, file.fileName)?.replace(SOURCE_EXTENSION, "");
176
+ if (path !== undefined && (path === name || path.endsWith(`/${name}`))) {
177
+ found.set(file.fileName, file);
178
+ }
179
+ }
180
+ return [...found.values()];
181
+ }
182
+
138
183
  /** Whether a configured string carries either special character. */
139
184
  function isPattern(text: string): boolean {
140
185
  return text.includes("*") || text.includes("?");
@@ -245,6 +290,14 @@ export function roots<Brand>(
245
290
  entered.push({ file, kind, source, exports });
246
291
  };
247
292
 
293
+ const points = entryPoints(
294
+ project,
295
+ files,
296
+ options.testFiles,
297
+ options.host,
298
+ targetRoot,
299
+ options.parse,
300
+ );
248
301
  const published: SourceFile[] = [];
249
302
  const entries = [
250
303
  ...options.manifest.entries.map((entry) => ({
@@ -255,14 +308,27 @@ export function roots<Brand>(
255
308
  })),
256
309
  ...(options.otherPackages ?? []).map((entry) => ({ entry, publishes: entry.published })),
257
310
  ];
311
+ const scripted = entries.flatMap(({ entry }) =>
312
+ entry.role === "script" ? files.named(entry.path) : [],
313
+ );
314
+ const readBackTo = [
315
+ ...scripted,
316
+ ...points.filter((point) => READ_BACK_RULES.has(point.rule)).map((point) => point.file),
317
+ ];
318
+ const entryFiles = (entry: ManifestEntry): readonly SourceFile[] => {
319
+ const named = files.named(entry.path);
320
+ return named.length > 0 || entry.role === "script"
321
+ ? named
322
+ : readBackByName(options.host, entry, readBackTo);
323
+ };
258
324
  for (const { entry, publishes } of entries) {
259
325
  if (options.publishedAPI && publishes) {
260
- published.push(...files.named(entry.path));
326
+ published.push(...entryFiles(entry));
261
327
  }
262
328
  }
263
329
  const publishedNames = new Set(published.map((file) => file.fileName));
264
330
  for (const { entry } of entries) {
265
- for (const file of files.named(entry.path)) {
331
+ for (const file of entryFiles(entry)) {
266
332
  const kind = ENTRY_KINDS[entry.role];
267
333
  enter(file, kind, entry.member, !publishedNames.has(file.fileName));
268
334
  }
@@ -280,14 +346,7 @@ export function roots<Brand>(
280
346
  : conventionEntries(options.conventions, files)) {
281
347
  enter(file, "convention", row, true);
282
348
  }
283
- for (const point of entryPoints(
284
- project,
285
- files,
286
- options.testFiles,
287
- options.host,
288
- targetRoot,
289
- options.parse,
290
- )) {
349
+ for (const point of points) {
291
350
  enter(point.file, point.rule, point.source, point.exports);
292
351
  }
293
352
  for (const file of files.byPath.values()) {
package/src/sweep.ts CHANGED
@@ -166,6 +166,25 @@ export function sweep(graph: Graph, input: SweepInput): Liveness {
166
166
  return { liveUnder: live, candidates };
167
167
  }
168
168
 
169
+ /**
170
+ * Per declaration of the graph, whether the run that counts test references reaches it.
171
+ * That run's roots are the graph's own and each test file, being the file's top level
172
+ * and every declaration it exports, which a test runner may call; `held` seeds it as a
173
+ * mark or an exemption seeds a sweep.
174
+ */
175
+ export function reachedWithTheTests(graph: Graph, held: readonly string[]): readonly boolean[] {
176
+ const tests = graph.symbols.flatMap((symbol, at) => {
177
+ const top =
178
+ symbol.kind === "file" || graph.symbols[graph.parent[at] ?? OUTSIDE]?.kind === "file";
179
+ return graph.test[at] === true && top && (symbol.kind === "file" || symbol.exported)
180
+ ? [{ at, kind: "test-runner" as const }]
181
+ : [];
182
+ });
183
+ return reachable({ ...graph, rooted: [...graph.rooted, ...tests] }, flagged(graph, held), {
184
+ production: false,
185
+ });
186
+ }
187
+
169
188
  /**
170
189
  * The references a loaded consumer makes that the mode counts. A production mode counts
171
190
  * none a consumer's test file made, as it counts none of the target's.