@cplieger/deadset-ts 3.13.0 → 4.0.0

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 (88) hide show
  1. package/README.md +3 -3
  2. package/dist/bin/node-host.js +10 -7
  3. package/dist/src/alias-chain.js +59 -7
  4. package/dist/src/analysis.js +119 -23
  5. package/dist/src/calls.js +23 -22
  6. package/dist/src/class-members.js +10 -11
  7. package/dist/src/configured-declarations.js +22 -9
  8. package/dist/src/dependencies.js +34 -17
  9. package/dist/src/deprecation.js +35 -24
  10. package/dist/src/destructuring.js +37 -29
  11. package/dist/src/discover.js +105 -28
  12. package/dist/src/entry-points.js +7 -0
  13. package/dist/src/enum-group.js +46 -40
  14. package/dist/src/exempt.js +42 -1
  15. package/dist/src/findings/emitters.js +15 -1
  16. package/dist/src/findings/interfaces.js +7 -1
  17. package/dist/src/framework-lifecycle.js +8 -6
  18. package/dist/src/implementations.js +45 -23
  19. package/dist/src/interface-satisfaction.js +30 -27
  20. package/dist/src/intra-function-parts.js +37 -20
  21. package/dist/src/inventory.js +85 -4
  22. package/dist/src/json-chunks.js +76 -0
  23. package/dist/src/partial.js +23 -0
  24. package/dist/src/query.js +207 -0
  25. package/dist/src/references.js +92 -20
  26. package/dist/src/reflective-lookup.js +18 -9
  27. package/dist/src/report.js +3 -1
  28. package/dist/src/reporters/annotations.js +9 -5
  29. package/dist/src/reporters/json.js +3 -2
  30. package/dist/src/reporters/sarif.js +2 -1
  31. package/dist/src/reporters/template.js +48 -7
  32. package/dist/src/reporters/text.js +8 -6
  33. package/dist/src/roots.js +27 -11
  34. package/dist/src/serialization-contract.js +12 -10
  35. package/dist/src/session.js +79 -39
  36. package/dist/src/suppress-file.js +2 -1
  37. package/dist/src/sweep.js +4 -1
  38. package/dist/src/value-reach.js +14 -19
  39. package/dist/src/verbs/analyze.js +20 -3
  40. package/dist/src/verbs/explain.js +22 -9
  41. package/dist/src/verbs/print-projects.js +40 -7
  42. package/dist/src/verbs/print-retained.js +6 -2
  43. package/dist/src/verbs/print-roots.js +5 -1
  44. package/jsr.json +1 -1
  45. package/package.json +3 -3
  46. package/src/alias-chain.ts +75 -11
  47. package/src/analysis.ts +223 -61
  48. package/src/calls.ts +10 -9
  49. package/src/class-members.ts +10 -11
  50. package/src/configured-declarations.ts +29 -13
  51. package/src/dependencies.ts +42 -20
  52. package/src/deprecation.ts +53 -27
  53. package/src/destructuring.ts +50 -39
  54. package/src/discover.ts +177 -33
  55. package/src/entry-points.ts +7 -0
  56. package/src/enum-group.ts +56 -42
  57. package/src/exempt.ts +50 -2
  58. package/src/findings/emitters.ts +18 -1
  59. package/src/findings/interfaces.ts +7 -1
  60. package/src/framework-lifecycle.ts +10 -6
  61. package/src/host.ts +5 -3
  62. package/src/implementations.ts +59 -23
  63. package/src/interface-satisfaction.ts +30 -27
  64. package/src/intra-function-parts.ts +43 -25
  65. package/src/inventory.ts +97 -4
  66. package/src/json-chunks.ts +87 -0
  67. package/src/partial.ts +40 -0
  68. package/src/query.ts +522 -0
  69. package/src/references.ts +119 -38
  70. package/src/reflective-lookup.ts +21 -11
  71. package/src/report.ts +14 -2
  72. package/src/reporters/annotations.ts +23 -21
  73. package/src/reporters/json.ts +3 -2
  74. package/src/reporters/reporter.ts +2 -2
  75. package/src/reporters/sarif.ts +3 -2
  76. package/src/reporters/template.ts +50 -8
  77. package/src/reporters/text.ts +12 -12
  78. package/src/roots.ts +34 -12
  79. package/src/serialization-contract.ts +12 -10
  80. package/src/session.ts +146 -58
  81. package/src/suppress-file.ts +6 -2
  82. package/src/sweep.ts +9 -4
  83. package/src/value-reach.ts +20 -23
  84. package/src/verbs/analyze.ts +25 -3
  85. package/src/verbs/explain.ts +20 -9
  86. package/src/verbs/print-projects.ts +53 -7
  87. package/src/verbs/print-retained.ts +6 -2
  88. package/src/verbs/print-roots.ts +5 -1
package/src/discover.ts CHANGED
@@ -4,7 +4,7 @@ import type { BuildConfiguration, ProjectConfiguration } from "./config.ts";
4
4
  import type { Host } from "./host.ts";
5
5
  import { dirnamePath, isAbsolutePath, joinPath, relativePath, resolvePath } from "./paths.ts";
6
6
  import type { Scope } from "./scope.ts";
7
- import type { Engine } from "./session.ts";
7
+ import type { Engine, ProjectDiagnostics } from "./session.ts";
8
8
 
9
9
  /** Directory names discovery does not descend into. */
10
10
  export const IGNORED_DIRECTORIES: ReadonlySet<string> = new Set(["node_modules", ".git"]);
@@ -20,24 +20,60 @@ const CONFIG_FILE = /^tsconfig.*\.json$/u;
20
20
  */
21
21
  export class DiscoveryError extends Error {
22
22
  /** The diagnostics the compiler reported, in the order it reported them. */
23
- readonly diagnostics: readonly Diagnostic[];
23
+ readonly diagnostics: readonly LocatedDiagnostic[];
24
24
 
25
- constructor(message: string, diagnostics: readonly Diagnostic[]) {
25
+ constructor(message: string, diagnostics: readonly LocatedDiagnostic[]) {
26
26
  super(message);
27
27
  this.name = "DiscoveryError";
28
28
  this.diagnostics = diagnostics;
29
29
  }
30
30
  }
31
31
 
32
+ /** The category an error diagnostic carries, which the protocol sends as a number. */
33
+ const ERROR_CATEGORY: number = DiagnosticCategory.Error;
34
+
32
35
  /** Whether one diagnostic is an error, which is what fails a run closed. */
33
36
  export function isError(diagnostic: Diagnostic): boolean {
34
- return diagnostic.category === DiagnosticCategory.Error;
37
+ return diagnostic.category === ERROR_CATEGORY;
35
38
  }
36
39
 
37
- /** One diagnostic as a line naming its position, its code and its message. */
38
- export function renderDiagnostic(diagnostic: Diagnostic): string {
39
- const where = diagnostic.fileName ?? "<no file>";
40
- return `${where}:${String(diagnostic.pos)}: TS${String(diagnostic.code)}: ${diagnostic.text}`;
40
+ /** One diagnostic, with the line and column its offset falls on where it names a file. */
41
+ export interface LocatedDiagnostic {
42
+ readonly diagnostic: Diagnostic;
43
+ /** Where the diagnostic starts, counted from one; absent where it names no position. */
44
+ readonly at?: { readonly line: number; readonly column: number } | undefined;
45
+ }
46
+
47
+ /**
48
+ * One diagnostic as a line naming its file, its line and column, its code and its
49
+ * message: `path:line:column: TScode: message`, the position left out where the
50
+ * diagnostic names none and the file where it names none. `display` spells the
51
+ * file's path; it answers the path itself where it is not given.
52
+ */
53
+ export function renderDiagnostic(
54
+ located: LocatedDiagnostic,
55
+ display: (path: string) => string = (path) => path,
56
+ ): string {
57
+ const { diagnostic, at } = located;
58
+ const message = `TS${String(diagnostic.code)}: ${diagnostic.text}`;
59
+ if (diagnostic.fileName === undefined) {
60
+ return message;
61
+ }
62
+ const where = display(diagnostic.fileName);
63
+ return at === undefined
64
+ ? `${where}: ${message}`
65
+ : `${where}:${String(at.line)}:${String(at.column)}: ${message}`;
66
+ }
67
+
68
+ /** Each diagnostic with the line and column its offset falls on. */
69
+ function locatedIn(
70
+ diagnostics: readonly Diagnostic[],
71
+ locate: (fileName: string, offset: number) => { line: number; column: number } | undefined,
72
+ ): readonly LocatedDiagnostic[] {
73
+ return diagnostics.map((diagnostic) => ({
74
+ diagnostic,
75
+ at: diagnostic.fileName === undefined ? undefined : locate(diagnostic.fileName, diagnostic.pos),
76
+ }));
41
77
  }
42
78
 
43
79
  /**
@@ -55,6 +91,26 @@ export function diagnosticErrors(sets: {
55
91
  return [...sets.configParsing, ...sets.syntactic, ...sets.semantic].filter(isError);
56
92
  }
57
93
 
94
+ /** Why one open project cannot be analyzed, split by whether it loaded at all. */
95
+ interface ProjectErrors {
96
+ /**
97
+ * The errors in the configuration itself: no input matched, an option that does not
98
+ * parse. A project carrying one did not load, whatever its files say.
99
+ */
100
+ readonly load: readonly LocatedDiagnostic[];
101
+ /** The errors in the files of a project that loaded: their syntax and their types. */
102
+ readonly check: readonly LocatedDiagnostic[];
103
+ }
104
+
105
+ /** The errors one open project carries, each located in the file it names. */
106
+ export function projectErrors(sets: ProjectDiagnostics): ProjectErrors {
107
+ const load = sets.configParsing.filter(isError);
108
+ return {
109
+ load: locatedIn(load, sets.locate),
110
+ check: load.length > 0 ? [] : locatedIn(diagnosticErrors(sets), sets.locate),
111
+ };
112
+ }
113
+
58
114
  /** One project a run analyzes. */
59
115
  export interface DiscoveredProject {
60
116
  /**
@@ -68,10 +124,54 @@ export interface DiscoveredProject {
68
124
  readonly configFile: string;
69
125
  }
70
126
 
127
+ /**
128
+ * One project discovery derived and the run could not build, with the first line of
129
+ * the error that dropped it.
130
+ */
131
+ export interface NotBuilt {
132
+ /** The name the project would have carried, as {@link DiscoveredProject.id} spells it. */
133
+ readonly id: string;
134
+ /** The compiler configuration file, absolute. */
135
+ readonly configFile: string;
136
+ /**
137
+ * The error's first line, every path below the target root spelled relative to it, so
138
+ * a report naming it carries no path of the machine it ran on.
139
+ */
140
+ readonly error: string;
141
+ }
142
+
143
+ /**
144
+ * The first line of one error about the target, every mention of the target root
145
+ * spelled relative to it: a path below the root loses the root and its separator, and
146
+ * the root itself is `.`.
147
+ */
148
+ export function targetRelative(text: string, targetRoot: string): string {
149
+ const root = targetRoot.endsWith("/") ? targetRoot.slice(0, -1) : targetRoot;
150
+ const [first = ""] = text.split(/\r?\n/u);
151
+ const spelled = first.split(`${root}/`).join("").split(root).join(".").trim();
152
+ return spelled === "" ? "the configuration could not be built" : spelled;
153
+ }
154
+
155
+ /**
156
+ * Whether one configuration is the analyzer's own guess: discovery derived it and the
157
+ * scope did not name it. A guess that does not load is dropped; any other ends the run.
158
+ */
159
+ export function isGuess(discovery: Discovery, configFile: string): boolean {
160
+ return discovery.derived && !discovery.fromScope.includes(configFile);
161
+ }
162
+
71
163
  /** Every project discovery found, and where each came from. */
72
164
  export interface Discovery {
73
165
  /** The projects, in discovery order, each once. */
74
166
  readonly projects: readonly DiscoveredProject[];
167
+ /**
168
+ * Whether discovery derived the projects rather than reading them from a declared
169
+ * matrix. A derived project that fails to load is dropped and named in
170
+ * {@link Discovery.notBuilt}; a declared one ends the run.
171
+ */
172
+ readonly derived: boolean;
173
+ /** The derived configurations the compiler could not read, in discovery order. */
174
+ readonly notBuilt: readonly NotBuilt[];
75
175
  /** The configuration file of each project, in the same order. */
76
176
  readonly configFiles: readonly string[];
77
177
  /**
@@ -174,9 +274,16 @@ function scopeConfigFiles(host: Host, scope: Scope): string[] {
174
274
  /**
175
275
  * Every configuration file reached from `pending`, in the order discovery reads them:
176
276
  * each once, each followed by the configurations it references, transitively. A
177
- * configuration the compiler cannot read ends the run naming it.
277
+ * configuration the compiler cannot read ends the run naming it, unless `unreadable`
278
+ * is given, which is then told the configuration and why, and the walk goes on
279
+ * without it.
178
280
  */
179
- function derivedFrom(engine: Engine, host: Host, pending: string[]): string[] {
281
+ function derivedFrom(
282
+ engine: Engine,
283
+ host: Host,
284
+ pending: string[],
285
+ unreadable?: (configFile: string, reason: string) => void,
286
+ ): string[] {
180
287
  const seen = new Set<string>();
181
288
  const found: string[] = [];
182
289
  while (pending.length > 0) {
@@ -189,7 +296,17 @@ function derivedFrom(engine: Engine, host: Host, pending: string[]): string[] {
189
296
  continue;
190
297
  }
191
298
  seen.add(path);
192
- parseOrRefuse(engine, path, path);
299
+ const reason = unreadableReason(engine, path);
300
+ if (reason !== undefined) {
301
+ if (unreadable === undefined) {
302
+ throw new DiscoveryError(
303
+ `${path} cannot be read as a compiler configuration: ${reason}`,
304
+ [],
305
+ );
306
+ }
307
+ unreadable(path, reason);
308
+ continue;
309
+ }
193
310
  found.push(path);
194
311
  pending.push(...referencesOf(host, path));
195
312
  }
@@ -232,20 +349,25 @@ function derivedId(targetRoot: string, configFile: string): string {
232
349
  return relativePath(targetRoot, configFile) ?? configFile;
233
350
  }
234
351
 
352
+ /** Why the compiler cannot read one configuration file, or undefined where it can. */
353
+ function unreadableReason(engine: Engine, configFile: string): string | undefined {
354
+ try {
355
+ engine.parseConfigFile(configFile);
356
+ return undefined;
357
+ } catch (error: unknown) {
358
+ return error instanceof Error ? error.message : String(error);
359
+ }
360
+ }
361
+
235
362
  /**
236
363
  * Resolves one compiler configuration file, or ends the run naming it: a project
237
364
  * opened from a configuration the compiler cannot read would be analyzed with no
238
365
  * type information.
239
366
  */
240
367
  function parseOrRefuse(engine: Engine, configFile: string, named: string): void {
241
- try {
242
- engine.parseConfigFile(configFile);
243
- } catch (error: unknown) {
244
- throw new DiscoveryError(
245
- `${named} cannot be read as a compiler configuration: ` +
246
- (error instanceof Error ? error.message : String(error)),
247
- [],
248
- );
368
+ const reason = unreadableReason(engine, configFile);
369
+ if (reason !== undefined) {
370
+ throw new DiscoveryError(`${named} cannot be read as a compiler configuration: ${reason}`, []);
249
371
  }
250
372
  }
251
373
 
@@ -299,26 +421,50 @@ function declaredProjects(
299
421
  );
300
422
  }
301
423
  }
302
- return { projects, configFiles: projects.map((project) => project.configFile), fromScope: [] };
424
+ return {
425
+ projects,
426
+ derived: false,
427
+ notBuilt: [],
428
+ configFiles: projects.map((project) => project.configFile),
429
+ fromScope: [],
430
+ };
303
431
  }
304
432
 
305
433
  /**
306
434
  * The projects a run derives where the matrix declares none, in the order discovery
307
435
  * reads them: the paths the scope names, then every compiler configuration under
308
436
  * the target root that is not inside an ignored directory, then the configurations
309
- * each of those references, transitively. Each configuration appears once.
437
+ * each of those references, transitively. Each configuration appears once. A
438
+ * configuration the scope names directly is the caller's own and ends the run where
439
+ * it cannot be read; one the walk found is dropped and named.
310
440
  */
311
441
  function derivedProjects(engine: Engine, host: Host, scope: Scope): Discovery {
442
+ const targetRoot = scope.target.path;
312
443
  const fromScope = scopeConfigFiles(host, scope);
313
- const configFiles = derivedFrom(engine, host, [
314
- ...fromScope,
315
- ...configFilesUnder(host, scope.target.path),
316
- ]);
444
+ const notBuilt: NotBuilt[] = [];
445
+ const configFiles = derivedFrom(
446
+ engine,
447
+ host,
448
+ [...fromScope, ...configFilesUnder(host, targetRoot)],
449
+ (configFile, reason) => {
450
+ if (fromScope.includes(configFile)) {
451
+ throw new DiscoveryError(
452
+ `${configFile} cannot be read as a compiler configuration: ${reason}`,
453
+ [],
454
+ );
455
+ }
456
+ notBuilt.push({
457
+ id: derivedId(targetRoot, configFile),
458
+ configFile,
459
+ error: targetRelative(reason, resolvePath(host.workingDirectory(), targetRoot)),
460
+ });
461
+ },
462
+ );
317
463
  const projects = configFiles.map((configFile) => ({
318
- id: derivedId(scope.target.path, configFile),
464
+ id: derivedId(targetRoot, configFile),
319
465
  configFile,
320
466
  }));
321
- return { projects, configFiles, fromScope };
467
+ return { projects, derived: true, notBuilt, configFiles, fromScope };
322
468
  }
323
469
 
324
470
  /**
@@ -331,12 +477,10 @@ function derivedProjects(engine: Engine, host: Host, scope: Scope): Discovery {
331
477
  * scope and the target tree. A platform entry names a configuration of another
332
478
  * language and plays no part here.
333
479
  *
334
- * A configuration file the compiler cannot read ends the run with the failure code,
335
- * so a project that would have been analyzed with no type information is never
336
- * analyzed at all, whether the matrix declared it or discovery derived it. What a
337
- * configuration's own contents say is refused once its project is open, by
338
- * {@link diagnosticErrors} over the three sets a program reports, because a
339
- * resolution carries no diagnostics.
480
+ * A configuration file the compiler cannot read is never analyzed. One the matrix or
481
+ * the scope names ends the run with the failure code; a guess is dropped and named in
482
+ * {@link Discovery.notBuilt}. What a configuration's own contents say is read once its
483
+ * project is open, by {@link diagnosticErrors}, because a resolution carries none.
340
484
  *
341
485
  * Nothing reads a build output: a project is the files its configuration names, so a
342
486
  * package whose sources ship as TypeScript is analyzed as it stands.
@@ -32,6 +32,7 @@ import {
32
32
  import { SymbolFlags } from "@typescript/native/unstable/sync";
33
33
  import { globExpression } from "./glob.ts";
34
34
  import { dirnamePath, relativePath, resolvePath } from "./paths.ts";
35
+ import { UNANSWERED } from "./query.ts";
35
36
  import type { ProjectView } from "./session.ts";
36
37
  import type { SourceFiles } from "./source-files.ts";
37
38
 
@@ -435,8 +436,14 @@ function workersOf<Brand>(
435
436
  const names = sites.flatMap((site) => site.platformNames);
436
437
  const symbols = project.symbolsAt(names.map((name) => project.handle(name)));
437
438
  const platform = new Set<Node>();
439
+ // A name the checker could not resolve is read as the platform's, so a worker it
440
+ // stands on is rooted rather than reported.
438
441
  names.forEach((name, index) => {
439
442
  const symbol = symbols[index];
443
+ if (symbol === UNANSWERED) {
444
+ platform.add(name);
445
+ return;
446
+ }
440
447
  if (
441
448
  symbol !== undefined &&
442
449
  (symbol.flags & SymbolFlags.Alias) === 0 &&
package/src/enum-group.ts CHANGED
@@ -33,7 +33,8 @@ import { aliasChains } from "./alias-chain.ts";
33
33
  import type { DetectorInput, Evidence } from "./exempt.ts";
34
34
  import { nodeKey } from "./inventory.ts";
35
35
  import { renderPosition } from "./position.ts";
36
- import { DEFAULT_BATCH_CAP } from "./references.ts";
36
+ import { isAnswered, UNANSWERED, type Answer } from "./query.ts";
37
+ import type { ProjectView } from "./session.ts";
37
38
 
38
39
  /** The operand types a conversion produces an enum value from. */
39
40
  const NUMBER = TypeFlags.NumberLike;
@@ -146,25 +147,38 @@ function sitesOf(file: SourceFile): readonly Site[] {
146
147
  * union, and itself otherwise. An enum's own value, or one of its members, is a value the
147
148
  * source names, whichever number or string it also is.
148
149
  */
149
- function partsOf(type: Type): readonly Type[] {
150
- const parts = type.isUnionType() ? type.getTypes() : [type];
151
- return parts.filter((part) => (part.flags & TypeFlags.EnumLike) === 0);
150
+ function partsOf<Brand>(project: ProjectView<Brand>, type: Type): Answer<readonly Type[]> {
151
+ const parts = type.isUnionType() ? project.queries.constituents(type) : [type];
152
+ return isAnswered(parts)
153
+ ? parts.filter((part) => (part.flags & TypeFlags.EnumLike) === 0)
154
+ : parts;
152
155
  }
153
156
 
157
+ /** The detail a site records whose operand's type the checker could not answer. */
158
+ const UNTYPED_DETAIL = "converted from a value of unanswered type";
159
+
154
160
  /**
155
161
  * The detail one site records about the enum it produces, or undefined where the
156
162
  * operand's type converts nothing: a number, or a string an assertion converts, is
157
163
  * converted, a value of type `any` or `unknown` is decoded, and a value of an enum is one
158
164
  * the source names.
159
165
  */
160
- function detailOf(site: Site, enumName: string, operand: Type | undefined): string | undefined {
166
+ function detailOf<Brand>(
167
+ project: ProjectView<Brand>,
168
+ site: Site,
169
+ enumName: string,
170
+ operand: Answer<Type> | undefined,
171
+ ): string | undefined {
161
172
  if (site.form === "lookup") {
162
173
  return `looked up by an element access on ${enumName}`;
163
174
  }
164
175
  if (operand === undefined) {
165
176
  return undefined;
166
177
  }
167
- const parts = partsOf(operand);
178
+ const parts = operand === UNANSWERED ? UNANSWERED : partsOf(project, operand);
179
+ if (parts === UNANSWERED) {
180
+ return UNTYPED_DETAIL;
181
+ }
168
182
  if (parts.some((part) => (part.flags & NUMBER) !== 0)) {
169
183
  return "converted from number";
170
184
  }
@@ -204,35 +218,37 @@ export function enumGroup<Brand>(input: DetectorInput<Brand>): readonly Evidence
204
218
  }
205
219
 
206
220
  const sites = files.flatMap(sitesOf);
207
- const named = new Map<Site, string>();
221
+ const named = new Map<Site, readonly string[]>();
208
222
  const chains = aliasChains(project, held);
209
- for (let from = 0; from < sites.length; from += DEFAULT_BATCH_CAP) {
210
- const run = sites.slice(from, from + DEFAULT_BATCH_CAP);
211
- project.symbolsAt(run.map((site) => project.handle(site.name))).forEach((symbol, index) => {
212
- const site = run[index];
213
- if (symbol === undefined || site === undefined) {
214
- return;
215
- }
216
- const target = chains.chainOf(symbol).find((link) => enums.has(link.id));
217
- if (target !== undefined) {
218
- named.set(site, target.id);
223
+ // A name the checker could not resolve may stand for any enum of the target it spells.
224
+ const enumsNamed = (text: string): readonly string[] =>
225
+ [...enums].flatMap(([id, name]) => (name === text ? [id] : []));
226
+ project.symbolsAt(sites.map((site) => project.handle(site.name))).forEach((symbol, index) => {
227
+ const site = sites[index];
228
+ if (symbol === undefined || site === undefined) {
229
+ return;
230
+ }
231
+ if (symbol === UNANSWERED) {
232
+ const ids = enumsNamed(site.name.getText());
233
+ if (ids.length > 0) {
234
+ named.set(site, ids);
219
235
  }
220
- });
221
- }
236
+ return;
237
+ }
238
+ const target = chains.chainOf(symbol).find((link) => enums.has(link.id));
239
+ if (target !== undefined) {
240
+ named.set(site, [target.id]);
241
+ }
242
+ });
222
243
 
223
244
  const typed = sites.filter((site) => named.has(site) && site.operand !== undefined);
224
- const operandType = new Map<Site, Type>();
225
- for (let from = 0; from < typed.length; from += DEFAULT_BATCH_CAP) {
226
- const run = typed.slice(from, from + DEFAULT_BATCH_CAP);
227
- project.checker
228
- .getTypeAtLocation(run.map((site) => site.operand ?? site.at))
229
- .forEach((type, index) => {
230
- const site = run[index];
231
- if (type !== undefined && site !== undefined) {
232
- operandType.set(site, type);
233
- }
234
- });
235
- }
245
+ const operandType = new Map<Site, Answer<Type>>();
246
+ project.queries.typesAt(typed.map((site) => site.operand ?? site.at)).forEach((type, index) => {
247
+ const site = typed[index];
248
+ if (type !== undefined && site !== undefined) {
249
+ operandType.set(site, type);
250
+ }
251
+ });
236
252
 
237
253
  const members = new Map<string, string[]>();
238
254
  for (const symbol of held.symbols) {
@@ -248,17 +264,15 @@ export function enumGroup<Brand>(input: DetectorInput<Brand>): readonly Evidence
248
264
 
249
265
  const found: Evidence[] = [];
250
266
  for (const site of sites) {
251
- const enumId = named.get(site);
252
- if (enumId === undefined) {
253
- continue;
254
- }
255
- const detail = detailOf(site, enums.get(enumId) ?? "", operandType.get(site));
256
- if (detail === undefined) {
257
- continue;
258
- }
259
- const position = renderPosition(site.at.getSourceFile(), targetRoot, site.at.getStart());
260
- for (const id of members.get(enumId) ?? []) {
261
- found.push({ id, detail, site: position });
267
+ for (const enumId of named.get(site) ?? []) {
268
+ const detail = detailOf(project, site, enums.get(enumId) ?? "", operandType.get(site));
269
+ if (detail === undefined) {
270
+ continue;
271
+ }
272
+ const position = renderPosition(site.at.getSourceFile(), targetRoot, site.at.getStart());
273
+ for (const id of members.get(enumId) ?? []) {
274
+ found.push({ id, detail, site: position });
275
+ }
262
276
  }
263
277
  }
264
278
  return found;
package/src/exempt.ts CHANGED
@@ -18,9 +18,10 @@ import {
18
18
  type TSExemptionClass,
19
19
  type TypeScriptVisibility,
20
20
  } from "./exempt-classes.ts";
21
- import type { Inventory, InventorySymbol, Visibility } from "./inventory.ts";
21
+ import type { Inventory, InventorySymbol, SymbolKind, Visibility } from "./inventory.ts";
22
22
  import type { Matrix } from "./matrix.ts";
23
23
  import { byPosition, positionKey, type Position } from "./position.ts";
24
+ import { Unanswerable } from "./query.ts";
24
25
  import type { ProjectView } from "./session.ts";
25
26
  import type { Templates } from "./template-field.ts";
26
27
  import { sweep, type Exemption, type Mode, type SweepInput } from "./sweep.ts";
@@ -100,6 +101,53 @@ function retainable(allowed: TypeScriptVisibility, visibility: Visibility | unde
100
101
  return true;
101
102
  }
102
103
 
104
+ /**
105
+ * The kinds of declaration each class can hold back, which a detector that stops holds
106
+ * back whole. A class with no row here holds back every declaration but a file.
107
+ */
108
+ const RETAINABLE: ReadonlyMap<TSExemptionClass, ReadonlySet<SymbolKind>> = new Map([
109
+ ["interface-satisfaction", new Set<SymbolKind>(["method", "class-member"])],
110
+ ["enum-group", new Set<SymbolKind>(["enum-member"])],
111
+ ["injection-container", new Set<SymbolKind>(["method", "class-member"])],
112
+ ["framework-lifecycle", new Set<SymbolKind>(["method", "class-member"])],
113
+ [
114
+ "serialization-contract",
115
+ new Set<SymbolKind>(["method", "class-member", "interface-method", "type-member"]),
116
+ ],
117
+ ]);
118
+
119
+ /** The detail of a record a detector that could not finish holds. */
120
+ const UNANSWERED_DETAIL = "kept live by a question the checker did not answer";
121
+
122
+ /** Whether one record is held by a detector that could not finish. */
123
+ export function isUnansweredRecord(record: Exemption): boolean {
124
+ return record.detail === UNANSWERED_DETAIL;
125
+ }
126
+
127
+ /**
128
+ * What one detector holds back over one project. A detector that stops because the
129
+ * checker left a question it needs unanswered holds back every declaration of the
130
+ * project its class could hold back, each recorded at its own position, so the gap
131
+ * never yields a finding.
132
+ */
133
+ function detected<Brand>(
134
+ exemptionClass: TSExemptionClass,
135
+ detect: Detector,
136
+ input: DetectorInput<Brand>,
137
+ ): readonly Evidence[] {
138
+ try {
139
+ return detect(input);
140
+ } catch (error: unknown) {
141
+ if (!(error instanceof Unanswerable)) {
142
+ throw error;
143
+ }
144
+ const kinds = RETAINABLE.get(exemptionClass);
145
+ return input.held.symbols
146
+ .filter((symbol) => (kinds === undefined ? symbol.kind !== "file" : kinds.has(symbol.kind)))
147
+ .map((symbol) => ({ id: symbol.id, detail: UNANSWERED_DETAIL, site: symbol.position }));
148
+ }
149
+ }
150
+
103
151
  /**
104
152
  * The records one project's detectors found, each class in the vocabulary's order and
105
153
  * none of a switched-off class. Evidence on a private member the class's row says it
@@ -120,7 +168,7 @@ export function computeExemptions<Brand>(
120
168
  continue;
121
169
  }
122
170
  const allowed = typescriptVisibilityOf(exemptionClass);
123
- for (const evidence of detect(input)) {
171
+ for (const evidence of detected(exemptionClass, detect, input)) {
124
172
  if (!retainable(allowed, visibility.get(evidence.id))) {
125
173
  continue;
126
174
  }
@@ -24,6 +24,22 @@ export const EMITTERS: Emitters = new Map<string, Emitter>([
24
24
  ["intra-function", intraFunction],
25
25
  ]);
26
26
 
27
+ /**
28
+ * The references of every declaration an unanswered question could have kept live, which
29
+ * the run reports nothing about: a finding about one would be computed from the gap.
30
+ */
31
+ function heldRefs(input: EmitterInput): ReadonlySet<string> {
32
+ const held = new Set(input.swept.heldByUnanswered);
33
+ if (held.size === 0) {
34
+ return held;
35
+ }
36
+ return new Set(
37
+ input.swept.matrix.union.symbols
38
+ .filter((symbol) => held.has(symbol.id))
39
+ .map((symbol) => symbol.ref),
40
+ );
41
+ }
42
+
27
43
  /** What the suppressions of a run swept under their marks are decided against. */
28
44
  export interface Suppressed {
29
45
  /** The same run swept with no mark, which is where a kind about liveness reports a marked declaration. */
@@ -92,9 +108,10 @@ export function decidedFindings(
92
108
  suppressed.records.length === 0 ? [] : completedOver(suppressed.unmarked, fallenMembers);
93
109
  const would = [...unmarked, ...found];
94
110
  const ledger = ledgerOf(suppressed.records, would, dialsOver(input, found, suppressed, unmarked));
111
+ const unanswered = heldRefs(input);
95
112
  const kept = reportable(
96
113
  input,
97
- found.filter((finding) => !ledger.withheld(finding)),
114
+ found.filter((finding) => !ledger.withheld(finding) && !unanswered.has(finding.symbol.ref)),
98
115
  );
99
116
  const evaluated = evaluateEdges(kept, input.boundary.edges, input.swept.matrix.union.symbols);
100
117
  return {
@@ -131,8 +131,14 @@ function unusedMessage(candidate: Candidate): string {
131
131
  : UNUSED_MESSAGE;
132
132
  }
133
133
 
134
- /** Whether one interface method has implementations and every one has an empty body. */
134
+ /**
135
+ * Whether one interface method has implementations and every one has an empty body, or
136
+ * is one whose implementations the run could not read in full.
137
+ */
135
138
  function marker(input: EmitterInput, method: string): boolean {
139
+ if (input.implementations.unknown.has(method)) {
140
+ return true;
141
+ }
136
142
  const bodies = [...(input.implementations.bodies.get(method)?.values() ?? [])];
137
143
  return bodies.length > 0 && bodies.every((empty) => empty);
138
144
  }
@@ -30,6 +30,7 @@ import {
30
30
  import type { DetectorInput, Evidence } from "./exempt.ts";
31
31
  import { nodeKey, type Inventory } from "./inventory.ts";
32
32
  import { renderPosition } from "./position.ts";
33
+ import { must } from "./query.ts";
33
34
  import type { ProjectView } from "./session.ts";
34
35
 
35
36
  /** One class that is a component of one contract, and what says it is. */
@@ -182,7 +183,7 @@ function byBases<Brand>(
182
183
  if (classes.length === 0) {
183
184
  return [];
184
185
  }
185
- const checker = project.checker;
186
+ const queries = project.queries;
186
187
  const ancestry = new Map<number, readonly Type[]>();
187
188
  const ancestorsOf = (type: Type, seen: Set<number>): readonly Type[] => {
188
189
  const known = ancestry.get(type.id);
@@ -192,15 +193,16 @@ function byBases<Brand>(
192
193
  const declared = type.isClassOrInterface()
193
194
  ? type
194
195
  : type.isTypeReference()
195
- ? type.getTarget()
196
+ ? must(queries.targetOf(type))
196
197
  : undefined;
197
198
  if (declared === undefined || !declared.isClassOrInterface() || seen.has(declared.id)) {
198
199
  return [];
199
200
  }
200
201
  seen.add(declared.id);
201
- const found = checker
202
- .getBaseTypes(declared)
203
- .flatMap((base) => [base, ...ancestorsOf(base, seen)]);
202
+ const found = must(queries.baseTypes(declared)).flatMap((base) => [
203
+ base,
204
+ ...ancestorsOf(base, seen),
205
+ ]);
204
206
  ancestry.set(type.id, found);
205
207
  return found;
206
208
  };
@@ -215,7 +217,9 @@ function byBases<Brand>(
215
217
  if (type === undefined) {
216
218
  return;
217
219
  }
218
- const ancestors = ancestorsOf(type, new Set()).flatMap((base) => base.getSymbol() ?? []);
220
+ const ancestors = ancestorsOf(type, new Set()).flatMap(
221
+ (base) => must(queries.symbolOfType(base)) ?? [],
222
+ );
219
223
  for (const contract of based) {
220
224
  for (const entry of contract.bases) {
221
225
  if (ancestors.some((symbol) => names(entry, symbol, chains))) {
package/src/host.ts CHANGED
@@ -35,8 +35,10 @@ export interface Host {
35
35
  */
36
36
  analyzerVersion(): string;
37
37
  /**
38
- * Writes one document whole, or a throw naming why it cannot. The write is atomic: a
39
- * reader of the path sees the previous document or this one and never a part of it.
38
+ * Writes one document from its pieces, in order, or a throw naming why it cannot. The
39
+ * pieces are written as they come, so the document is never held as one string. The
40
+ * write is atomic: a reader of the path sees the previous document or this one and
41
+ * never a part of it.
40
42
  */
41
- writeFile(path: string, text: string): void;
43
+ writeDocument(path: string, pieces: Iterable<string>): void;
42
44
  }