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

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 (72) hide show
  1. package/README.md +1 -1
  2. package/dist/src/analysis.js +61 -10
  3. package/dist/src/augmentations.js +63 -1
  4. package/dist/src/build-scripts.js +437 -0
  5. package/dist/src/conformance-record.js +1 -1
  6. package/dist/src/contextual-uses.js +56 -9
  7. package/dist/src/convention-rows.js +39 -2
  8. package/dist/src/conventions.js +15 -0
  9. package/dist/src/dependencies.js +98 -1
  10. package/dist/src/discover.js +14 -82
  11. package/dist/src/entry-points.js +9 -18
  12. package/dist/src/exempt.js +8 -0
  13. package/dist/src/findings/completion.js +66 -28
  14. package/dist/src/findings/intra-function.js +4 -2
  15. package/dist/src/findings/reads-and-writes.js +7 -6
  16. package/dist/src/findings/unused-declarations.js +4 -1
  17. package/dist/src/graph.js +5 -3
  18. package/dist/src/intra-function-parts.js +45 -17
  19. package/dist/src/kinds.js +8 -8
  20. package/dist/src/manifest.js +18 -0
  21. package/dist/src/matrix.js +38 -4
  22. package/dist/src/query.js +1 -0
  23. package/dist/src/report.js +23 -4
  24. package/dist/src/reporters/annotations.js +15 -8
  25. package/dist/src/reporters/sarif.js +12 -8
  26. package/dist/src/roots.js +197 -42
  27. package/dist/src/serialization-contract.js +590 -12
  28. package/dist/src/sweep.js +7 -9
  29. package/dist/src/test-globals.js +234 -0
  30. package/dist/src/type-errors.js +11 -3
  31. package/dist/src/unseen-loads.js +107 -0
  32. package/dist/src/verbs/analyze.js +24 -3
  33. package/dist/src/workflow-steps.js +32 -15
  34. package/dist/src/yaml-document.js +355 -0
  35. package/jsr.json +1 -1
  36. package/package.json +2 -2
  37. package/src/analysis.ts +109 -12
  38. package/src/augmentations.ts +77 -2
  39. package/src/build-scripts.ts +564 -0
  40. package/src/conformance-record.ts +1 -1
  41. package/src/contextual-uses.ts +64 -12
  42. package/src/convention-rows.ts +47 -2
  43. package/src/conventions.ts +16 -0
  44. package/src/dependencies.ts +110 -1
  45. package/src/discover.ts +14 -94
  46. package/src/entry-points.ts +10 -25
  47. package/src/exempt.ts +16 -0
  48. package/src/file-facts.ts +3 -0
  49. package/src/findings/boundary.ts +2 -0
  50. package/src/findings/completion.ts +76 -30
  51. package/src/findings/intra-function.ts +4 -2
  52. package/src/findings/reads-and-writes.ts +7 -6
  53. package/src/findings/unused-declarations.ts +6 -1
  54. package/src/graph.ts +5 -3
  55. package/src/intra-function-parts.ts +55 -16
  56. package/src/kinds.ts +8 -8
  57. package/src/manifest.ts +21 -3
  58. package/src/matrix.ts +51 -4
  59. package/src/query.ts +11 -0
  60. package/src/report.ts +56 -16
  61. package/src/reporters/annotations.ts +21 -10
  62. package/src/reporters/reporter.ts +1 -3
  63. package/src/reporters/sarif.ts +16 -8
  64. package/src/roots.ts +226 -46
  65. package/src/serialization-contract.ts +758 -12
  66. package/src/sweep.ts +23 -9
  67. package/src/test-globals.ts +275 -0
  68. package/src/type-errors.ts +14 -3
  69. package/src/unseen-loads.ts +145 -0
  70. package/src/verbs/analyze.ts +28 -3
  71. package/src/workflow-steps.ts +35 -17
  72. package/src/yaml-document.ts +377 -0
package/src/sweep.ts CHANGED
@@ -33,6 +33,8 @@ export interface Exemption {
33
33
  * The record holds only while one of them is called through its interface.
34
34
  */
35
35
  readonly implementing?: Implementing;
36
+ /** Whether the record holds only where a write the mode counts stores into the declaration. */
37
+ readonly whileWritten?: true;
36
38
  }
37
39
 
38
40
  /** The interface methods one retained class member implements, and whether its body is empty. */
@@ -195,10 +197,14 @@ export function runnerCalls(symbol: InventorySymbol): boolean {
195
197
  * That run's roots are the graph's own and, of each test file, its top level and every
196
198
  * function it exports, which a test runner may call, and every export of a manual mock,
197
199
  * which the runner reads; any other export of a test file, and test-support code, which
198
- * runs where a test imports it, are judged as any file's are.
199
- * `held` seeds it as a mark or an exemption seeds a sweep.
200
+ * runs where a test imports it, are judged as any file's are. `held` seeds it as a mark
201
+ * seeds a sweep; with `namespaces` false a file read reaches none of the file's exports.
200
202
  */
201
- export function reachedWithTheTests(graph: Graph, held: readonly string[]): readonly boolean[] {
203
+ export function reachedWithTheTests(
204
+ graph: Graph,
205
+ held: readonly string[],
206
+ namespaces = true,
207
+ ): readonly boolean[] {
202
208
  const tests = graph.symbols.flatMap((symbol, at) => {
203
209
  const exported = symbol.exported && graph.symbols[graph.parent[at] ?? OUTSIDE]?.kind === "file";
204
210
  const called = exported && runnerCalls(symbol);
@@ -208,9 +214,12 @@ export function reachedWithTheTests(graph: Graph, held: readonly string[]): read
208
214
  ? [{ at, kind: "test-runner" as const }]
209
215
  : [];
210
216
  });
211
- return reachable({ ...graph, rooted: [...graph.rooted, ...tests] }, flagged(graph, held), {
212
- production: false,
213
- });
217
+ return reachable(
218
+ { ...graph, rooted: [...graph.rooted, ...tests] },
219
+ flagged(graph, held),
220
+ { production: false },
221
+ namespaces,
222
+ );
214
223
  }
215
224
 
216
225
  /**
@@ -240,7 +249,12 @@ function flagged(graph: Graph, ids: readonly string[]): boolean[] {
240
249
  * included, while a root reaches through the references the mode counts. The held seed
241
250
  * runs first, so what it reaches is expanded under the wider rule.
242
251
  */
243
- function reachable(graph: Graph, held: readonly boolean[], mode: Mode): readonly boolean[] {
252
+ function reachable(
253
+ graph: Graph,
254
+ held: readonly boolean[],
255
+ mode: Mode,
256
+ namespaces = true,
257
+ ): readonly boolean[] {
244
258
  const reached = graph.symbols.map(() => false);
245
259
  const expanded = graph.symbols.map(() => false);
246
260
  const queue: number[] = [];
@@ -269,7 +283,7 @@ function reachable(graph: Graph, held: readonly boolean[], mode: Mode): readonly
269
283
  if (!holding && mode.production && edge.test) {
270
284
  continue;
271
285
  }
272
- enter(edge.to, !edge.evaluation);
286
+ enter(edge.to, namespaces && !edge.evaluation);
273
287
  }
274
288
  }
275
289
  };
@@ -285,7 +299,7 @@ function reachable(graph: Graph, held: readonly boolean[], mode: Mode): readonly
285
299
  }
286
300
  // A consumer's reference reaches what it names as one of the target's own does.
287
301
  for (const edge of callsOf(graph, mode)) {
288
- enter(edge.to, !edge.evaluation);
302
+ enter(edge.to, namespaces && !edge.evaluation);
289
303
  }
290
304
  walk(false);
291
305
  return reached;
@@ -0,0 +1,275 @@
1
+ /**
2
+ * The globals a test runner injects into every file it runs, which no import names: a
3
+ * Vitest configuration whose `test.globals` is `true` gives each test and each file it
4
+ * loads `describe`, `it`, `expect` and `vi` (https://vitest.dev/config/globals). A program
5
+ * analyzed without the runner's declarations of them reads every use as an error, so a
6
+ * configuration under such a runner is read with them; and a file that calls one is
7
+ * written for the runner, as one that imports it is.
8
+ */
9
+
10
+ import {
11
+ isAsExpression,
12
+ isBinaryExpression,
13
+ isCallExpression,
14
+ isExportAssignment,
15
+ isExpressionStatement,
16
+ isIdentifier,
17
+ isObjectLiteralExpression,
18
+ isParenthesizedExpression,
19
+ isPropertyAccessExpression,
20
+ isPropertyAssignment,
21
+ isSatisfiesExpression,
22
+ SyntaxKind,
23
+ type Node,
24
+ type SourceFile,
25
+ } from "@typescript/native/ast";
26
+ import type { ParseFile } from "./configuration-files.ts";
27
+ import type { Host } from "./host.ts";
28
+ import { dirnamePath, joinPath } from "./paths.ts";
29
+ import { UNANSWERED } from "./query.ts";
30
+ import type { Engine, ProjectView } from "./session.ts";
31
+ import { writtenPath, type WorkspaceProgram } from "./workspace-programs.ts";
32
+
33
+ /** The test runner whose configuration injects the globals, and the `types` entry declaring them. */
34
+ export const GLOBALS_RUNNER = "vitest";
35
+ const GLOBALS_TYPES = "vitest/globals";
36
+
37
+ /** The configuration files the runner reads its settings from, by name. */
38
+ const RUNNER_CONFIG = /^(?:vitest|vite)\.config\.(?:js|mjs|cjs|ts|mts|cts)$/u;
39
+
40
+ /** The globals `vitest/globals` declares that a test or a helper calls or reads a member of. */
41
+ const GLOBAL_NAMES: ReadonlySet<string> = new Set([
42
+ "afterAll",
43
+ "afterEach",
44
+ "assert",
45
+ "assertType",
46
+ "beforeAll",
47
+ "beforeEach",
48
+ "bench",
49
+ "describe",
50
+ "expect",
51
+ "expectTypeOf",
52
+ "it",
53
+ "onTestFailed",
54
+ "onTestFinished",
55
+ "suite",
56
+ "test",
57
+ "vi",
58
+ "vitest",
59
+ ]);
60
+
61
+ /** The expression one wrapper that changes no value holds, and a call's first argument. */
62
+ function configObject(node: Node): Node | undefined {
63
+ let at: Node | undefined = node;
64
+ while (at !== undefined) {
65
+ if (isParenthesizedExpression(at) || isAsExpression(at) || isSatisfiesExpression(at)) {
66
+ at = at.expression;
67
+ } else if (isCallExpression(at)) {
68
+ at = at.arguments[0];
69
+ } else {
70
+ return at;
71
+ }
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ /** The value one object literal writes for one member as `name: value`. */
77
+ function member(object: Node | undefined, name: string): Node | undefined {
78
+ if (object === undefined || !isObjectLiteralExpression(object)) {
79
+ return undefined;
80
+ }
81
+ for (const property of object.properties) {
82
+ if (
83
+ isPropertyAssignment(property) &&
84
+ isIdentifier(property.name) &&
85
+ property.name.text === name
86
+ ) {
87
+ return configObject(property.initializer);
88
+ }
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ /** Whether one runner configuration's default export sets `test.globals` to `true`. */
94
+ function setsGlobals(file: SourceFile): boolean {
95
+ for (const statement of file.statements) {
96
+ let exported: Node | undefined;
97
+ if (isExportAssignment(statement)) {
98
+ exported = statement.expression;
99
+ } else if (isExpressionStatement(statement) && isBinaryExpression(statement.expression)) {
100
+ const { left, operatorToken, right } = statement.expression;
101
+ if (
102
+ operatorToken.kind === SyntaxKind.EqualsToken &&
103
+ isPropertyAccessExpression(left) &&
104
+ isIdentifier(left.expression) &&
105
+ left.expression.text === "module" &&
106
+ isIdentifier(left.name) &&
107
+ left.name.text === "exports"
108
+ ) {
109
+ exported = right;
110
+ }
111
+ }
112
+ const object = exported === undefined ? undefined : configObject(exported);
113
+ const globals = member(member(object, "test"), "globals");
114
+ if (globals?.kind === SyntaxKind.TrueKeyword) {
115
+ return true;
116
+ }
117
+ }
118
+ return false;
119
+ }
120
+
121
+ /** Whether the runner's configuration in one manifest's directory injects the globals. */
122
+ function injectsGlobals(host: Host, parse: ParseFile, dir: string): boolean {
123
+ let names: readonly string[];
124
+ try {
125
+ names = host
126
+ .readDirectory(dir)
127
+ .filter((entry) => !entry.directory && RUNNER_CONFIG.test(entry.name))
128
+ .map((entry) => entry.name);
129
+ } catch {
130
+ return false;
131
+ }
132
+ return names.some((name) => {
133
+ const path = joinPath(dir, name);
134
+ try {
135
+ return setsGlobals(parse(path, host.readFile(path)));
136
+ } catch {
137
+ return false;
138
+ }
139
+ });
140
+ }
141
+
142
+ /** Whether a file declaring the globals is installed where the configuration resolves packages from. */
143
+ function globalsInstalled(host: Host, configFile: string): boolean {
144
+ for (let dir = dirnamePath(configFile); ; dir = dirnamePath(dir)) {
145
+ if (host.kindOf(joinPath(dir, "node_modules", GLOBALS_RUNNER, "globals.d.ts")) === "file") {
146
+ return true;
147
+ }
148
+ if (dirnamePath(dir) === dir) {
149
+ return false;
150
+ }
151
+ }
152
+ }
153
+
154
+ /** The directory of the manifest nearest one configuration file, at or above its own. */
155
+ function manifestDir(host: Host, configFile: string): string | undefined {
156
+ for (let dir = dirnamePath(configFile); ; dir = dirnamePath(dir)) {
157
+ if (host.kindOf(joinPath(dir, "package.json")) === "file") {
158
+ return dir;
159
+ }
160
+ if (dirnamePath(dir) === dir) {
161
+ return undefined;
162
+ }
163
+ }
164
+ }
165
+
166
+ function typesOf(value: unknown): readonly string[] | undefined {
167
+ return Array.isArray(value) && value.every((one) => typeof one === "string") ? value : undefined;
168
+ }
169
+
170
+ /**
171
+ * How the snapshot reads each configuration of `configFiles` whose nearest manifest's
172
+ * runner configuration injects the globals and that can resolve their declarations: as
173
+ * `programs` reads it, else as itself, with the globals' declarations added to its `types`.
174
+ * A configuration the compiler cannot read is left for its project to refuse.
175
+ */
176
+ export function withTestGlobals(
177
+ engine: Engine,
178
+ host: Host,
179
+ parse: ParseFile,
180
+ configFiles: readonly string[],
181
+ programs: ReadonlyMap<string, WorkspaceProgram>,
182
+ ): ReadonlyMap<string, WorkspaceProgram> {
183
+ const found = new Map(programs);
184
+ const injects = new Map<string, boolean>();
185
+ for (const configFile of configFiles) {
186
+ const dir = manifestDir(host, configFile);
187
+ if (dir === undefined || !globalsInstalled(host, configFile)) {
188
+ continue;
189
+ }
190
+ if (!injects.has(dir)) {
191
+ injects.set(dir, injectsGlobals(host, parse, dir));
192
+ }
193
+ if (injects.get(dir) !== true) {
194
+ continue;
195
+ }
196
+ let base: readonly string[];
197
+ try {
198
+ base = typesOf(engine.parseConfigFile(configFile).options.types) ?? [];
199
+ } catch {
200
+ continue;
201
+ }
202
+ const held = found.get(configFile);
203
+ if (held?.kind === "program") {
204
+ const listed = typesOf(held.params.compilerOptions.types) ?? base;
205
+ if (!listed.includes(GLOBALS_TYPES)) {
206
+ found.set(configFile, {
207
+ kind: "program",
208
+ params: {
209
+ ...held.params,
210
+ compilerOptions: { ...held.params.compilerOptions, types: [...listed, GLOBALS_TYPES] },
211
+ },
212
+ });
213
+ }
214
+ continue;
215
+ }
216
+ let written: Record<string, unknown> = { extends: configFile };
217
+ if (held !== undefined) {
218
+ try {
219
+ written = JSON.parse(held.text) as Record<string, unknown>;
220
+ } catch {
221
+ continue;
222
+ }
223
+ }
224
+ const options = (written["compilerOptions"] ?? {}) as Record<string, unknown>;
225
+ const listed = typesOf(options["types"]) ?? base;
226
+ if (listed.includes(GLOBALS_TYPES)) {
227
+ continue;
228
+ }
229
+ found.set(configFile, {
230
+ kind: "configuration",
231
+ file: held?.file ?? writtenPath(host, configFile),
232
+ text: `${JSON.stringify(
233
+ { ...written, compilerOptions: { ...options, types: [...listed, GLOBALS_TYPES] } },
234
+ null,
235
+ 2,
236
+ )}\n`,
237
+ });
238
+ }
239
+ return found;
240
+ }
241
+
242
+ /**
243
+ * Whether one file names a global the runner injects, which is how a file written for the
244
+ * runner's globals reads: a call of one of the names or a read of a member of it that
245
+ * resolves to a declaration outside the project's own files, the runner's declaration
246
+ * file. A name the file binds, imported, declared or a parameter, resolves to its own.
247
+ */
248
+ export function namesTestGlobals<Brand>(project: ProjectView<Brand>, file: SourceFile): boolean {
249
+ const named: Node[] = [];
250
+ const visit = (node: Node): void => {
251
+ const callee = isCallExpression(node)
252
+ ? node.expression
253
+ : isPropertyAccessExpression(node)
254
+ ? node.expression
255
+ : undefined;
256
+ if (callee !== undefined && isIdentifier(callee) && GLOBAL_NAMES.has(callee.text)) {
257
+ named.push(callee);
258
+ }
259
+ node.forEachChild(visit);
260
+ };
261
+ file.forEachChild(visit);
262
+ if (named.length === 0) {
263
+ return false;
264
+ }
265
+ const own = project.ownPaths();
266
+ return project
267
+ .symbolsAt(named.map((node) => project.handle(node)))
268
+ .some(
269
+ (symbol) =>
270
+ symbol === UNANSWERED ||
271
+ (symbol !== undefined &&
272
+ symbol.declarations.length > 0 &&
273
+ symbol.declarations.every((handle) => !own.has(handle.path))),
274
+ );
275
+ }
@@ -31,6 +31,13 @@ import { findWorkspace, type Workspace } from "./workspace.ts";
31
31
  /** The compiler's codes for an import whose module it cannot find. */
32
32
  const MODULE_NOT_FOUND: ReadonlySet<number> = new Set([2307, 2792]);
33
33
 
34
+ /**
35
+ * The compiler's codes for where the configuration places a file, which leave every type
36
+ * the file's statements carry as it is: an output that would overwrite an input (5055,
37
+ * 5056), a file outside `rootDir` (6059), one a composite project does not list (6307).
38
+ */
39
+ const CONFIGURATION_LEVEL: ReadonlySet<number> = new Set([5055, 5056, 6059, 6307]);
40
+
34
41
  const MANIFEST = "package.json";
35
42
  const MANIFEST_SECTIONS = [
36
43
  "dependencies",
@@ -290,8 +297,8 @@ function messageOf(text: string, targetRoot: string): string {
290
297
 
291
298
  /**
292
299
  * Reads the errors one project's own files carry. A diagnostic marking unnecessary code
293
- * is no error here, and one in a file that is not the project's own is that file's
294
- * package's to answer for.
300
+ * or about where the configuration places a file is no error here, and one in a file that
301
+ * is not the project's own is that file's package's to answer for.
295
302
  */
296
303
  export function readErrors<Brand>(
297
304
  project: ProjectView<Brand>,
@@ -313,7 +320,11 @@ export function readErrors<Brand>(
313
320
  const skips: Skip[] = [];
314
321
  for (const { diagnostic, at } of errors) {
315
322
  const file = diagnostic.fileName === undefined ? undefined : files.get(diagnostic.fileName);
316
- if (file === undefined || diagnostic.reportsUnnecessary === true) {
323
+ if (
324
+ file === undefined ||
325
+ diagnostic.reportsUnnecessary === true ||
326
+ CONFIGURATION_LEVEL.has(diagnostic.code)
327
+ ) {
317
328
  continue;
318
329
  }
319
330
  const path = relativePath(context.targetRoot, file.fileName) ?? file.fileName;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The loads of a project's production files whose target the analysis cannot resolve: an
3
+ * `import()` or `require()` call whose path is computed and starts with no relative literal
4
+ * naming a directory below the calling file's. The files such a load runs are roots the
5
+ * analysis does not see, so the configuration's roots are never complete.
6
+ */
7
+
8
+ import {
9
+ isAsExpression,
10
+ isBinaryExpression,
11
+ isCallExpression,
12
+ isIdentifier,
13
+ isNoSubstitutionTemplateLiteral,
14
+ isParenthesizedExpression,
15
+ isSatisfiesExpression,
16
+ isStringLiteral,
17
+ isTemplateExpression,
18
+ SyntaxKind,
19
+ type Node,
20
+ } from "@typescript/native/ast";
21
+ import { dirnamePath, relativePath, resolvePath } from "./paths.ts";
22
+ import { renderPosition, type Position } from "./position.ts";
23
+ import { UNANSWERED } from "./query.ts";
24
+ import type { ProjectView } from "./session.ts";
25
+ import { sourceFilesOf } from "./source-files.ts";
26
+
27
+ /** One load the analysis cannot resolve, in the configuration whose production file writes it. */
28
+ export interface UnseenLoad {
29
+ readonly configuration: string;
30
+ /** The position of the call. */
31
+ readonly position: Position;
32
+ }
33
+
34
+ /** The expression a wrapper that changes no value holds: parentheses and type assertions. */
35
+ function unwrapped(node: Node): Node {
36
+ let at = node;
37
+ while (isParenthesizedExpression(at) || isAsExpression(at) || isSatisfiesExpression(at)) {
38
+ at = at.expression;
39
+ }
40
+ return at;
41
+ }
42
+
43
+ /** The literal text a computed path starts with: a template's head, or a concatenation's first literal. */
44
+ export function leadingLiteral(node: Node): string | undefined {
45
+ const at = unwrapped(node);
46
+ if (isTemplateExpression(at)) {
47
+ return at.head.text;
48
+ }
49
+ if (isBinaryExpression(at) && at.operatorToken.kind === SyntaxKind.PlusToken) {
50
+ const left = unwrapped(at.left);
51
+ return isStringLiteral(left) || isNoSubstitutionTemplateLiteral(left)
52
+ ? left.text
53
+ : leadingLiteral(at.left);
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ /**
59
+ * The directory below `dir` a computed load's leading literal names, which the entry
60
+ * rules root every file of, or none where it names a package, `dir` itself or a
61
+ * directory holding it, each of which would name files no rule roots.
62
+ */
63
+ export function computedLoadDirectory(dir: string, prefix: string): string | undefined {
64
+ if (!(prefix.startsWith("./") || prefix.startsWith("../")) || !prefix.endsWith("/")) {
65
+ return undefined;
66
+ }
67
+ const target = resolvePath(dir, prefix);
68
+ return target === dir || relativePath(target, dir) !== undefined ? undefined : target;
69
+ }
70
+
71
+ /** Whether a computed path's leading literal names a package, whose files are none of the target's. */
72
+ function namesPackage(prefix: string | undefined): boolean {
73
+ return (
74
+ prefix !== undefined &&
75
+ prefix !== "" &&
76
+ !prefix.startsWith(".") &&
77
+ !prefix.startsWith("/") &&
78
+ !/^[A-Za-z][A-Za-z\d+.-]*:/u.test(prefix)
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Every unresolved load the project's production files write, in file then source order.
84
+ * `testFiles` holds the test files by their path below the target root. A `require` call
85
+ * loads a module only where the name resolves to no declaration of the project's own
86
+ * files: one the project declares is its own function.
87
+ */
88
+ export function unseenLoadsOf<Brand>(
89
+ project: ProjectView<Brand>,
90
+ configuration: string,
91
+ targetRoot: string,
92
+ testFiles: readonly string[],
93
+ ): readonly UnseenLoad[] {
94
+ const tests = new Set(testFiles);
95
+ const sites: { readonly position: Position; readonly require: Node | undefined }[] = [];
96
+ for (const [path, file] of sourceFilesOf(project, targetRoot).byPath) {
97
+ if (tests.has(path) || file.isDeclarationFile) {
98
+ continue;
99
+ }
100
+ const dir = dirnamePath(file.fileName);
101
+ const visit = (node: Node): void => {
102
+ if (isCallExpression(node)) {
103
+ const callee = node.expression;
104
+ const required = isIdentifier(callee) && callee.text === "require";
105
+ const [first] = node.arguments;
106
+ const argument = first === undefined ? undefined : unwrapped(first);
107
+ if (
108
+ (callee.kind === SyntaxKind.ImportKeyword || required) &&
109
+ argument !== undefined &&
110
+ !isStringLiteral(argument) &&
111
+ !isNoSubstitutionTemplateLiteral(argument)
112
+ ) {
113
+ const prefix = leadingLiteral(argument);
114
+ const seen =
115
+ namesPackage(prefix) ||
116
+ (prefix !== undefined && computedLoadDirectory(dir, prefix) !== undefined);
117
+ if (!seen) {
118
+ sites.push({
119
+ position: renderPosition(file, targetRoot, node.getStart()),
120
+ require: required ? callee : undefined,
121
+ });
122
+ }
123
+ }
124
+ }
125
+ node.forEachChild(visit);
126
+ };
127
+ file.forEachChild(visit);
128
+ }
129
+ const requires = sites.flatMap((site) => (site.require === undefined ? [] : [site.require]));
130
+ const own = project.ownPaths();
131
+ const local = new Set<Node>();
132
+ project.symbolsAt(requires.map((node) => project.handle(node))).forEach((symbol, at) => {
133
+ const node = requires[at];
134
+ if (
135
+ node !== undefined &&
136
+ symbol !== UNANSWERED &&
137
+ symbol?.declarations.some((handle) => own.has(handle.path)) === true
138
+ ) {
139
+ local.add(node);
140
+ }
141
+ });
142
+ return sites
143
+ .filter((site) => site.require === undefined || !local.has(site.require))
144
+ .map((site) => ({ configuration, position: site.position }));
145
+ }
@@ -10,7 +10,7 @@ import { ConfigError, kindOf, type Format } from "../config.ts";
10
10
  import { CONFORMANCE, DECLARED_GAPS } from "../conformance.ts";
11
11
  import { moduleIdentity } from "../consumers.ts";
12
12
  import { analyzerProvenance, verdictOf } from "../findings-pass.ts";
13
- import { joinPath, normalizePath, relativePath, resolvePath } from "../paths.ts";
13
+ import { dirnamePath, joinPath, normalizePath, relativePath, resolvePath } from "../paths.ts";
14
14
  import type { Host } from "../host.ts";
15
15
  import type { ManifestEntry } from "../manifest.ts";
16
16
  import type { NotBuilt } from "../discover.ts";
@@ -30,6 +30,7 @@ import { RENDERINGS } from "../reporters/reporters.ts";
30
30
  import { parseTemplate, TemplateError, type Template } from "../reporters/template.ts";
31
31
  import { resolve } from "../resolve.ts";
32
32
  import { otherSchemeRows, rowReason, writeBaseline, type Recorded } from "../suppress-file.ts";
33
+ import type { UnseenLoad } from "../unseen-loads.ts";
33
34
  import { CONTRACT_VERSION } from "../version.ts";
34
35
  import { EXIT_CLEAN, EXIT_FAILURE, type Verb, type VerbOptions } from "./verb.ts";
35
36
 
@@ -113,6 +114,27 @@ function unmappedNotes(
113
114
  });
114
115
  }
115
116
 
117
+ /**
118
+ * One note per load of a production file the run cannot resolve, each site once: a file
119
+ * it loads is a root the run does not see, so `ts.roots_complete` lifts no cap there.
120
+ */
121
+ function unseenLoadNotes(loads: readonly UnseenLoad[]): readonly ReportNote[] {
122
+ return [
123
+ ...new Map(
124
+ loads.map((load) => {
125
+ const { path, line, column } = load.position;
126
+ const site = `${path}:${String(line)}:${String(column)}`;
127
+ const note: ReportNote = {
128
+ kind: "computed-load",
129
+ path: dirnamePath(path),
130
+ message: `${site} loads a module by a computed path the analysis cannot resolve, so the configurations compiling it report what no root reaches at probable at most, whatever ts.roots_complete says`,
131
+ };
132
+ return [site, note] as const;
133
+ }),
134
+ ).values(),
135
+ ];
136
+ }
137
+
116
138
  /** Each project the run dropped as a report names it, by its configuration file below the target. */
117
139
  function notBuiltOf(dropped: readonly NotBuilt[], targetRoot: string): readonly ReportNotBuilt[] {
118
140
  return dropped.map((one) => {
@@ -309,7 +331,11 @@ export const analyzeVerb: Verb = ({ err, host, inputs, scope, openClient, option
309
331
  typeErrorSkips: run.typeErrorSkips,
310
332
  unanswered: unansweredCounts(analysis),
311
333
  conventionsApplied: run.conventionsApplied,
312
- notes: unmappedNotes(run.unmappedEntries, resolvePath(invoked, targetRoot)),
334
+ listsComponents: config.reporters.cascade === "full",
335
+ notes: [
336
+ ...unmappedNotes(run.unmappedEntries, resolvePath(invoked, targetRoot)),
337
+ ...unseenLoadNotes(run.unseenLoads),
338
+ ],
313
339
  });
314
340
  report = capFindings(sortFindings(built, config.reporters.sort), config.reporters.maxFindings);
315
341
  verdict = verdictOf(result, config);
@@ -324,7 +350,6 @@ export const analyzeVerb: Verb = ({ err, host, inputs, scope, openClient, option
324
350
  try {
325
351
  host.writeDocument(documents.report, json(report));
326
352
  const options = {
327
- failOn: config.reporters.failOn,
328
353
  readSource: (path: string) => host.readFile(joinPath(targetRoot, path)),
329
354
  template,
330
355
  };
@@ -97,12 +97,11 @@ export function runCommands(text: string, key: RegExp = RUN_LINE): readonly stri
97
97
  }
98
98
 
99
99
  /**
100
- * Every token the recipe lines of the target root's makefile hold, each with the directory
101
- * below the root it is read against: a recipe line is one that starts with a tab, joined
102
- * with the lines a trailing `\` continues it onto, and make runs each in a shell of its
103
- * own, so a `cd` moves the directory of the later commands of its line alone.
100
+ * The commands of the recipe lines of the target root's makefile, each line's in order: a
101
+ * recipe line is one that starts with a tab, joined with the lines a trailing `\`
102
+ * continues it onto.
104
103
  */
105
- function recipeTokens(host: Host, root: string): readonly { token: string; dir: string }[] {
104
+ function recipeLines(host: Host, root: string): readonly (readonly string[])[] {
106
105
  const path = MAKEFILES.map((name) => joinPath(root, name)).find(
107
106
  (candidate) => host.kindOf(candidate) === "file",
108
107
  );
@@ -110,15 +109,23 @@ function recipeTokens(host: Host, root: string): readonly { token: string; dir:
110
109
  if (text === undefined) {
111
110
  return [];
112
111
  }
112
+ return text
113
+ .replace(/\\\r?\n/gu, " ")
114
+ .split(/\r?\n/u)
115
+ .filter((line) => line.startsWith("\t"))
116
+ .map((line) => withoutComment(line.slice(1).replace(/^[@+-]+/u, "")).split(COMMAND_SEPARATORS));
117
+ }
118
+
119
+ /**
120
+ * Every token the recipe lines of the target root's makefile hold, each with the directory
121
+ * below the root it is read against: make runs each line in a shell of its own, so a `cd`
122
+ * moves the directory of the later commands of its line alone.
123
+ */
124
+ function recipeTokens(host: Host, root: string): readonly { token: string; dir: string }[] {
113
125
  const found: { token: string; dir: string }[] = [];
114
- for (const line of text.replace(/\\\r?\n/gu, " ").split(/\r?\n/u)) {
115
- if (!line.startsWith("\t")) {
116
- continue;
117
- }
126
+ for (const line of recipeLines(host, root)) {
118
127
  let dir = root;
119
- for (const command of withoutComment(line.slice(1).replace(/^[@+-]+/u, "")).split(
120
- COMMAND_SEPARATORS,
121
- )) {
128
+ for (const command of line) {
122
129
  const tokens = scriptTokens(command);
123
130
  const [first, target, ...rest] = tokens;
124
131
  if (first === "cd" && target !== undefined && rest.length === 0 && !COMPUTED.test(target)) {
@@ -156,13 +163,27 @@ function withoutComment(line: string): string {
156
163
  }
157
164
 
158
165
  /**
159
- * Every token the target root's workflow steps, git hooks and makefile hold: the `run:`
166
+ * Every command the target root's workflow steps, git hooks and makefile run: the `run:`
160
167
  * steps of the workflow files in `.github/workflows`, every line of each file in `.husky`
161
168
  * and `.githooks`, the `entry:` commands of `.pre-commit-config.yaml`, the `run:` commands
162
169
  * of a lefthook file and the makefile's recipe lines.
163
170
  */
171
+ export function workflowCommands(host: Host, targetRoot: string): readonly string[] {
172
+ const root = resolvePath(host.workingDirectory(), targetRoot);
173
+ return [...stepCommands(host, root), ...recipeLines(host, root).flat()];
174
+ }
175
+
176
+ /** Every token the commands {@link workflowCommands} reads hold. */
164
177
  export function workflowTokens(host: Host, targetRoot: string): readonly string[] {
165
178
  const root = resolvePath(host.workingDirectory(), targetRoot);
179
+ return [
180
+ ...stepCommands(host, root).flatMap((command) => scriptTokens(command)),
181
+ ...recipeTokens(host, root).map(({ token }) => token),
182
+ ];
183
+ }
184
+
185
+ /** The commands of the workflow steps, git hooks and hook managers at one root. */
186
+ function stepCommands(host: Host, root: string): readonly string[] {
166
187
  const commands: string[] = [];
167
188
  for (const path of filesIn(host, joinPath(root, WORKFLOWS), (name) => /\.ya?ml$/u.test(name))) {
168
189
  commands.push(...runCommands(readText(host, path) ?? ""));
@@ -175,10 +196,7 @@ export function workflowTokens(host: Host, targetRoot: string): readonly string[
175
196
  for (const [name, key] of HOOK_MANAGERS) {
176
197
  commands.push(...runCommands(readText(host, joinPath(root, name)) ?? "", key));
177
198
  }
178
- return [
179
- ...commands.flatMap((command) => scriptTokens(command)),
180
- ...recipeTokens(host, root).map(({ token }) => token),
181
- ];
199
+ return commands;
182
200
  }
183
201
 
184
202
  /**