@cplieger/deadset-ts 8.0.0-dev.1 → 8.0.0-dev.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/analysis.js +30 -6
- package/dist/src/augmentations.js +105 -12
- package/dist/src/build-scripts.js +437 -0
- package/dist/src/conformance-record.js +1 -1
- package/dist/src/convention-rows.js +62 -2
- package/dist/src/conventions.js +15 -0
- package/dist/src/dependencies.js +89 -0
- package/dist/src/entry-points.js +9 -18
- package/dist/src/findings/completion.js +122 -49
- package/dist/src/findings/intra-function.js +4 -2
- package/dist/src/findings/reads-and-writes.js +7 -6
- package/dist/src/findings/unused-declarations.js +13 -5
- package/dist/src/findings/visibility-narrowing.js +8 -3
- package/dist/src/graph.js +5 -3
- package/dist/src/intra-function-parts.js +45 -17
- package/dist/src/kinds.js +8 -8
- package/dist/src/manifest.js +18 -0
- package/dist/src/matrix.js +76 -4
- package/dist/src/references.js +34 -3
- package/dist/src/report.js +23 -4
- package/dist/src/reporters/sarif.js +12 -8
- package/dist/src/roots.js +138 -11
- package/dist/src/serialization-contract.js +258 -8
- package/dist/src/suppress.js +1 -0
- package/dist/src/sweep.js +7 -9
- package/dist/src/test-globals.js +234 -0
- package/dist/src/test-support.js +8 -11
- package/dist/src/type-errors.js +11 -3
- package/dist/src/unseen-loads.js +107 -0
- package/dist/src/verbs/analyze.js +24 -2
- package/dist/src/yaml-document.js +355 -0
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/analysis.ts +48 -8
- package/src/augmentations.ts +134 -16
- package/src/build-scripts.ts +564 -0
- package/src/conformance-record.ts +1 -1
- package/src/convention-rows.ts +72 -2
- package/src/conventions.ts +16 -0
- package/src/dependencies.ts +96 -0
- package/src/entry-points.ts +10 -25
- package/src/file-facts.ts +3 -0
- package/src/finding.ts +5 -0
- package/src/findings/completion.ts +153 -56
- package/src/findings/intra-function.ts +4 -2
- package/src/findings/reads-and-writes.ts +7 -6
- package/src/findings/unused-declarations.ts +17 -5
- package/src/findings/visibility-narrowing.ts +9 -3
- package/src/graph.ts +5 -3
- package/src/intra-function-parts.ts +55 -16
- package/src/kinds.ts +8 -8
- package/src/manifest.ts +21 -3
- package/src/matrix.ts +122 -4
- package/src/references.ts +38 -2
- package/src/report.ts +56 -16
- package/src/reporters/sarif.ts +16 -8
- package/src/roots.ts +162 -12
- package/src/serialization-contract.ts +309 -7
- package/src/suppress.ts +1 -0
- package/src/sweep.ts +21 -9
- package/src/test-globals.ts +275 -0
- package/src/test-support.ts +17 -13
- package/src/type-errors.ts +14 -3
- package/src/unseen-loads.ts +145 -0
- package/src/verbs/analyze.ts +28 -2
- package/src/yaml-document.ts +377 -0
|
@@ -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
|
+
}
|
package/src/test-support.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* test code.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { CONVENTION_ROWS } from "./convention-rows.ts";
|
|
9
|
+
import { CONVENTION_ROWS, TEST_LIBRARIES, TEST_LIBRARY_SCOPES } from "./convention-rows.ts";
|
|
10
10
|
import { packageOfSpecifier } from "./dependencies.ts";
|
|
11
11
|
import type { InventorySymbol } from "./inventory.ts";
|
|
12
12
|
import type { Reference } from "./references.ts";
|
|
@@ -17,17 +17,13 @@ const SKIP_ROOT = "type-error";
|
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* The test runners and test libraries an import of which is test evidence: each runner a
|
|
20
|
-
* convention row reads test files for, the
|
|
20
|
+
* convention row reads test files for, and the convention table's test libraries.
|
|
21
21
|
*/
|
|
22
22
|
const TEST_PACKAGES: ReadonlySet<string> = new Set([
|
|
23
23
|
...CONVENTION_ROWS.filter((row) => row.testFiles !== undefined).map((row) => row.package),
|
|
24
|
-
|
|
25
|
-
"@playwright/test",
|
|
24
|
+
...TEST_LIBRARIES,
|
|
26
25
|
]);
|
|
27
26
|
|
|
28
|
-
/** The scopes every package of which is a test library. */
|
|
29
|
-
const TEST_SCOPES = ["@testing-library/"] as const;
|
|
30
|
-
|
|
31
27
|
/**
|
|
32
28
|
* The Node.js built-in modules a bare specifier names, which are no package. The set is
|
|
33
29
|
* closed: Node.js adds a new built-in under the `node:` scheme alone
|
|
@@ -94,7 +90,7 @@ function namesTestPackage(specifier: string): boolean {
|
|
|
94
90
|
const name = specifier.startsWith("node:") ? specifier : packageOfSpecifier(specifier);
|
|
95
91
|
return (
|
|
96
92
|
name !== undefined &&
|
|
97
|
-
(TEST_PACKAGES.has(name) ||
|
|
93
|
+
(TEST_PACKAGES.has(name) || TEST_LIBRARY_SCOPES.some((scope) => name.startsWith(scope)))
|
|
98
94
|
);
|
|
99
95
|
}
|
|
100
96
|
|
|
@@ -134,13 +130,21 @@ function evidenceReader(
|
|
|
134
130
|
};
|
|
135
131
|
}
|
|
136
132
|
|
|
133
|
+
/** The files only tests reach, split by whether each carries test evidence of its own. */
|
|
134
|
+
interface TestReached {
|
|
135
|
+
/** The test-support files: test code. */
|
|
136
|
+
readonly support: ReadonlySet<string>;
|
|
137
|
+
/** The files only tests reach that are no test-support file: production code only tests use. */
|
|
138
|
+
readonly testUsed: ReadonlySet<string>;
|
|
139
|
+
}
|
|
140
|
+
|
|
137
141
|
/**
|
|
138
142
|
* The files of one project that are test-support code, below the target root: the
|
|
139
143
|
* largest set of files a test file reaches, none of them a test file or holding a root,
|
|
140
144
|
* each carrying test evidence of its own, whose every user outside the set is a test
|
|
141
|
-
* file
|
|
142
|
-
*
|
|
143
|
-
* the test-file rules classified.
|
|
145
|
+
* file; and the files only tests reach that are not among them. A root has a caller
|
|
146
|
+
* outside the reference graph: a runtime, a tool, a configuration, or a consumer of a
|
|
147
|
+
* library's published API. `testFiles` are the files the test-file rules classified.
|
|
144
148
|
*/
|
|
145
149
|
export function testSupportFiles(
|
|
146
150
|
symbols: readonly InventorySymbol[],
|
|
@@ -148,7 +152,7 @@ export function testSupportFiles(
|
|
|
148
152
|
roots: readonly Root[],
|
|
149
153
|
testFiles: readonly string[],
|
|
150
154
|
evidence: TestEvidence,
|
|
151
|
-
):
|
|
155
|
+
): TestReached {
|
|
152
156
|
const fileOf = new Map(symbols.map((symbol) => [symbol.id, symbol.position.path]));
|
|
153
157
|
const users = new Map<string, Set<string>>();
|
|
154
158
|
const uses = new Map<string, Set<string>>();
|
|
@@ -203,7 +207,7 @@ export function testSupportFiles(
|
|
|
203
207
|
const support = withoutOutsideUsers(
|
|
204
208
|
new Set([...onlyTests].filter((path) => carries(onlyTests, path))),
|
|
205
209
|
);
|
|
206
|
-
return support;
|
|
210
|
+
return { support, testUsed: new Set([...onlyTests].filter((path) => !support.has(path))) };
|
|
207
211
|
}
|
|
208
212
|
|
|
209
213
|
/** The references with every one a test-support file makes counted as a test reference. */
|
package/src/type-errors.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
+
}
|
package/src/verbs/analyze.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|