@cplieger/deadset-ts 2.2.1 → 2.3.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.
@@ -0,0 +1,496 @@
1
+ /**
2
+ * The files a tool or the platform enters without any import naming them: the
3
+ * configuration files the test runner, the linter, the mutation tester and the
4
+ * browser-test runner load by their file name, the test files the runner executes,
5
+ * and a worker a call addresses by a string literal.
6
+ *
7
+ * Each rule is read from the program and nothing else: a file name the tool
8
+ * itself looks for, a literal written in a configuration file, or a literal
9
+ * argument of a call the checker resolves to the platform's own declaration. A
10
+ * value that is not a literal is not evaluated and not guessed, so a file it would
11
+ * have named is entered by nothing here.
12
+ */
13
+
14
+ import {
15
+ isArrayLiteralExpression,
16
+ isAsExpression,
17
+ isCallExpression,
18
+ isExportAssignment,
19
+ isIdentifier,
20
+ isMetaProperty,
21
+ isNewExpression,
22
+ isNoSubstitutionTemplateLiteral,
23
+ isObjectLiteralExpression,
24
+ isParenthesizedExpression,
25
+ isPropertyAccessExpression,
26
+ isPropertyAssignment,
27
+ isSatisfiesExpression,
28
+ isStringLiteral,
29
+ type Node,
30
+ type SourceFile,
31
+ } from "@typescript/native/unstable/ast";
32
+ import { SymbolFlags } from "@typescript/native/unstable/sync";
33
+ import { globExpression } from "./glob.ts";
34
+ import { dirnamePath, relativePath, resolvePath } from "./paths.ts";
35
+ import type { ProjectView } from "./session.ts";
36
+ import type { SourceFiles } from "./source-files.ts";
37
+
38
+ /** Which rule entered a file. */
39
+ export type EntryRule =
40
+ /** The test runner: its configuration, its setup files and the test files. */
41
+ | "test-runner"
42
+ /** The linter's flat configuration. */
43
+ | "lint-configuration"
44
+ /** The mutation tester's configuration, and the test-runner configuration it names. */
45
+ | "mutation-testing"
46
+ /** The browser-test runner's configuration and the test files below its test directory. */
47
+ | "browser-tests"
48
+ /** A worker or service worker a call addresses by a string literal. */
49
+ | "worker";
50
+
51
+ /** One file a rule enters. */
52
+ export interface EntryPoint {
53
+ readonly file: SourceFile;
54
+ readonly rule: EntryRule;
55
+ /**
56
+ * What named the file: the file-name convention the tool looks for, the test-file
57
+ * pattern that classified it, or the literal a configuration or a call writes.
58
+ */
59
+ readonly source: string;
60
+ /**
61
+ * Whether what the file exports is read too. A tool loading a configuration reads
62
+ * its default export; a runtime executing a test file or a worker runs its top
63
+ * level and names none of its exports.
64
+ */
65
+ readonly exports: boolean;
66
+ }
67
+
68
+ /** One file-name convention: the stems a tool looks for, under each extension it loads. */
69
+ interface Convention {
70
+ readonly rule: EntryRule;
71
+ readonly stems: readonly string[];
72
+ readonly extensions: readonly string[];
73
+ /** Whether the file is a test-runner configuration whose setup files are read. */
74
+ readonly testRunner: boolean;
75
+ }
76
+
77
+ /** The extensions the test runner and the browser-test runner load a configuration from. */
78
+ const MODULE_EXTENSIONS = ["js", "mjs", "cjs", "ts", "mts", "cts"] as const;
79
+
80
+ /** The extensions the linter's flat configuration and the mutation tester's are loaded from. */
81
+ const SCRIPT_EXTENSIONS = ["js", "mjs", "cjs"] as const;
82
+
83
+ const CONVENTIONS: readonly Convention[] = [
84
+ {
85
+ rule: "test-runner",
86
+ stems: ["vitest.config", "vitest.workspace", "vitest.projects"],
87
+ extensions: MODULE_EXTENSIONS,
88
+ testRunner: true,
89
+ },
90
+ {
91
+ rule: "lint-configuration",
92
+ stems: ["eslint.config"],
93
+ extensions: MODULE_EXTENSIONS,
94
+ testRunner: false,
95
+ },
96
+ {
97
+ rule: "mutation-testing",
98
+ stems: ["stryker.config", "stryker.conf", ".stryker.config", ".stryker.conf"],
99
+ extensions: SCRIPT_EXTENSIONS,
100
+ testRunner: false,
101
+ },
102
+ {
103
+ rule: "mutation-testing",
104
+ stems: ["vitest.stryker.config"],
105
+ extensions: MODULE_EXTENSIONS,
106
+ testRunner: true,
107
+ },
108
+ {
109
+ rule: "browser-tests",
110
+ stems: ["playwright.config"],
111
+ extensions: MODULE_EXTENSIONS,
112
+ testRunner: false,
113
+ },
114
+ ];
115
+
116
+ /** The browser-test runner's own test-file pattern, which holds where a configuration names none. */
117
+ const BROWSER_TEST_MATCH = "**/*.{spec,test}.{js,ts,jsx,tsx,cjs,cts,cjsx,ctsx,mjs,mts,mjsx,mtsx}";
118
+
119
+ /** The members of a test-runner configuration's `test` object that name setup files. */
120
+ const SETUP_MEMBERS = ["setupFiles", "globalSetup"] as const;
121
+
122
+ /** A member that moves the directory every path of a test-runner configuration is read from. */
123
+ const ROOT_MEMBER = "root";
124
+
125
+ /** The prefixes a literal is relative with, in a module specifier and a URL reference alike. */
126
+ const RELATIVE_PREFIXES = ["./", "../"] as const;
127
+
128
+ /** The constructors whose first argument addresses a worker script. */
129
+ const WORKER_CONSTRUCTORS: ReadonlySet<string> = new Set(["Worker", "SharedWorker"]);
130
+
131
+ /** The file's name, the last segment of its path. */
132
+ function basename(path: string): string {
133
+ return path.slice(path.lastIndexOf("/") + 1);
134
+ }
135
+
136
+ /** The convention one file name is, with the spelling a root reports it by. */
137
+ function conventionOf(
138
+ name: string,
139
+ ): { readonly convention: Convention; readonly spelled: string } | undefined {
140
+ for (const convention of CONVENTIONS) {
141
+ for (const stem of convention.stems) {
142
+ if (convention.extensions.some((ext) => name === `${stem}.${ext}`)) {
143
+ return { convention, spelled: `${stem}.*` };
144
+ }
145
+ }
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ /** The text a string literal or a template with no substitution denotes. */
151
+ function literalOf(node: Node | undefined): string | undefined {
152
+ if (node !== undefined && (isStringLiteral(node) || isNoSubstitutionTemplateLiteral(node))) {
153
+ return node.text;
154
+ }
155
+ return undefined;
156
+ }
157
+
158
+ /** The expression a wrapper that changes no value holds: parentheses and type assertions. */
159
+ function unwrapped(node: Node): Node {
160
+ let at = node;
161
+ while (isParenthesizedExpression(at) || isAsExpression(at) || isSatisfiesExpression(at)) {
162
+ at = at.expression;
163
+ }
164
+ return at;
165
+ }
166
+
167
+ /**
168
+ * The object literals one configuration file's default export is written as: the
169
+ * literal itself, or each literal argument of the call it is, at any depth of calls.
170
+ * A default export written as anything else, a name or a computation, yields none,
171
+ * because its value is not in the file's text.
172
+ */
173
+ function configObjects(file: SourceFile): readonly Node[] {
174
+ const found: Node[] = [];
175
+ const collect = (node: Node): void => {
176
+ const at = unwrapped(node);
177
+ if (isObjectLiteralExpression(at)) {
178
+ found.push(at);
179
+ return;
180
+ }
181
+ if (isCallExpression(at)) {
182
+ for (const argument of at.arguments) {
183
+ collect(argument);
184
+ }
185
+ }
186
+ };
187
+ for (const statement of file.statements) {
188
+ if (isExportAssignment(statement) && !statement.isExportEquals) {
189
+ collect(statement.expression);
190
+ }
191
+ }
192
+ return found;
193
+ }
194
+
195
+ /**
196
+ * The value one member of an object literal is written with, when it is written as
197
+ * `name: value`, and undefined where the literal writes no such member.
198
+ */
199
+ function memberOf(object: Node, name: string): Node | undefined {
200
+ if (!isObjectLiteralExpression(object)) {
201
+ return undefined;
202
+ }
203
+ for (const property of object.properties) {
204
+ if (
205
+ isPropertyAssignment(property) &&
206
+ isIdentifier(property.name) &&
207
+ property.name.text === name
208
+ ) {
209
+ return property.initializer;
210
+ }
211
+ }
212
+ return undefined;
213
+ }
214
+
215
+ /**
216
+ * The strings one member holds, when it is written as a literal or an array of
217
+ * literals. `undefined` is a value the text does not state.
218
+ */
219
+ function literalsOf(value: Node): readonly string[] | undefined {
220
+ const at = unwrapped(value);
221
+ const single = literalOf(at);
222
+ if (single !== undefined) {
223
+ return [single];
224
+ }
225
+ if (!isArrayLiteralExpression(at)) {
226
+ return undefined;
227
+ }
228
+ const found: string[] = [];
229
+ for (const element of at.elements) {
230
+ const text = literalOf(unwrapped(element));
231
+ if (text === undefined) {
232
+ return undefined;
233
+ }
234
+ found.push(text);
235
+ }
236
+ return found;
237
+ }
238
+
239
+ /** Whether a literal is relative in the sense a module specifier and a URL reference share. */
240
+ function isRelative(text: string): boolean {
241
+ return RELATIVE_PREFIXES.some((prefix) => text.startsWith(prefix));
242
+ }
243
+
244
+ /** A URL reference with its query and its fragment removed, which name no part of the file. */
245
+ function pathPart(text: string): string {
246
+ const cut = text.search(/[?#]/u);
247
+ return cut < 0 ? text : text.slice(0, cut);
248
+ }
249
+
250
+ /** The own files one relative literal names, read against a directory. */
251
+ function filesNamed(files: SourceFiles, dir: string, text: string): readonly SourceFile[] {
252
+ if (!isRelative(text)) {
253
+ return [];
254
+ }
255
+ return files.named(resolvePath(dir, pathPart(text)));
256
+ }
257
+
258
+ /**
259
+ * The setup files one test-runner configuration names: every literal under
260
+ * `test.setupFiles` and `test.globalSetup`, read against the configuration's own
261
+ * directory. A configuration that moves its root names them against a directory its
262
+ * text may not state, so none is read from it.
263
+ */
264
+ function setupFilesOf(file: SourceFile, files: SourceFiles): readonly EntryPoint[] {
265
+ const objects = configObjects(file);
266
+ const tests = objects
267
+ .map((object) => memberOf(object, "test"))
268
+ .filter((value): value is Node => value !== undefined)
269
+ .map(unwrapped);
270
+ if ([...objects, ...tests].some((object) => memberOf(object, ROOT_MEMBER) !== undefined)) {
271
+ return [];
272
+ }
273
+ const dir = dirnamePath(file.fileName);
274
+ const found: EntryPoint[] = [];
275
+ for (const test of tests) {
276
+ for (const name of SETUP_MEMBERS) {
277
+ const value = memberOf(test, name);
278
+ for (const text of value === undefined ? [] : (literalsOf(value) ?? [])) {
279
+ for (const named of filesNamed(files, dir, text)) {
280
+ found.push({ file: named, rule: "test-runner", source: text, exports: false });
281
+ }
282
+ }
283
+ }
284
+ }
285
+ return found;
286
+ }
287
+
288
+ /**
289
+ * The test files one browser-test configuration names: every own file below its
290
+ * test directory that its test-file pattern matches. The directory is the literal
291
+ * `testDir` reads against the configuration's directory, or that directory where no
292
+ * object of the configuration writes one; the pattern is the literal `testMatch`, or
293
+ * the runner's own where none is written. A member whose value is not a literal
294
+ * leaves the set unknown, and an unknown set enters nothing.
295
+ */
296
+ function browserTestsOf(file: SourceFile, files: SourceFiles): readonly EntryPoint[] {
297
+ const objects = configObjects(file);
298
+ if (objects.length === 0) {
299
+ return [];
300
+ }
301
+ let testDir: string | undefined = ".";
302
+ let patterns: readonly string[] | undefined = [BROWSER_TEST_MATCH];
303
+ for (const object of objects) {
304
+ const dir = memberOf(object, "testDir");
305
+ if (dir !== undefined) {
306
+ testDir = literalOf(unwrapped(dir));
307
+ }
308
+ const match = memberOf(object, "testMatch");
309
+ if (match !== undefined) {
310
+ patterns = literalsOf(match);
311
+ }
312
+ }
313
+ if (testDir === undefined || patterns === undefined) {
314
+ return [];
315
+ }
316
+ const under = resolvePath(dirnamePath(file.fileName), testDir);
317
+ const found: EntryPoint[] = [];
318
+ for (const pattern of patterns) {
319
+ const expression = globExpression(pattern);
320
+ for (const candidate of files.byName.values()) {
321
+ const below = relativePath(under, candidate.fileName);
322
+ if (below !== undefined && expression.test(below)) {
323
+ found.push({ file: candidate, rule: "browser-tests", source: pattern, exports: false });
324
+ }
325
+ }
326
+ }
327
+ return found;
328
+ }
329
+
330
+ /** One call that addresses a worker, with the names the checker must resolve to the platform. */
331
+ interface WorkerSite {
332
+ readonly literal: string;
333
+ readonly platformNames: readonly Node[];
334
+ }
335
+
336
+ /**
337
+ * The address one worker argument writes: a relative literal, or a `URL` built from
338
+ * a literal against `import.meta.url`. Any other argument is computed and addresses
339
+ * nothing the text states.
340
+ */
341
+ function workerAddress(
342
+ argument: Node | undefined,
343
+ ): { readonly literal: string; readonly names: readonly Node[] } | undefined {
344
+ if (argument === undefined) {
345
+ return undefined;
346
+ }
347
+ const at = unwrapped(argument);
348
+ const literal = literalOf(at);
349
+ if (literal !== undefined) {
350
+ return { literal, names: [] };
351
+ }
352
+ if (!isNewExpression(at) || !isIdentifier(at.expression) || at.expression.text !== "URL") {
353
+ return undefined;
354
+ }
355
+ const [reference, base] = at.arguments ?? [];
356
+ const text = literalOf(reference === undefined ? undefined : unwrapped(reference));
357
+ if (text === undefined || base === undefined) {
358
+ return undefined;
359
+ }
360
+ const meta = unwrapped(base);
361
+ if (
362
+ !isPropertyAccessExpression(meta) ||
363
+ !isMetaProperty(meta.expression) ||
364
+ !isIdentifier(meta.name) ||
365
+ meta.name.text !== "url"
366
+ ) {
367
+ return undefined;
368
+ }
369
+ return { literal: text, names: [at.expression] };
370
+ }
371
+
372
+ /**
373
+ * Every call one file writes that addresses a worker: `new Worker(...)`,
374
+ * `new SharedWorker(...)` and `navigator.serviceWorker.register(...)`, each with an
375
+ * address its text states.
376
+ */
377
+ function workerSitesOf(file: SourceFile): readonly WorkerSite[] {
378
+ const found: WorkerSite[] = [];
379
+ const visit = (node: Node): void => {
380
+ if (
381
+ isNewExpression(node) &&
382
+ isIdentifier(node.expression) &&
383
+ WORKER_CONSTRUCTORS.has(node.expression.text)
384
+ ) {
385
+ const address = workerAddress(node.arguments?.[0]);
386
+ if (address !== undefined) {
387
+ found.push({
388
+ literal: address.literal,
389
+ platformNames: [node.expression, ...address.names],
390
+ });
391
+ }
392
+ }
393
+ if (isCallExpression(node)) {
394
+ const callee = node.expression;
395
+ if (
396
+ isPropertyAccessExpression(callee) &&
397
+ isIdentifier(callee.name) &&
398
+ callee.name.text === "register" &&
399
+ isPropertyAccessExpression(callee.expression) &&
400
+ isIdentifier(callee.expression.name) &&
401
+ callee.expression.name.text === "serviceWorker" &&
402
+ isIdentifier(callee.expression.expression) &&
403
+ callee.expression.expression.text === "navigator"
404
+ ) {
405
+ const address = workerAddress(node.arguments[0]);
406
+ if (address !== undefined) {
407
+ found.push({
408
+ literal: address.literal,
409
+ platformNames: [callee.expression.expression, ...address.names],
410
+ });
411
+ }
412
+ }
413
+ }
414
+ node.forEachChild(visit);
415
+ };
416
+ file.forEachChild(visit);
417
+ return found;
418
+ }
419
+
420
+ /**
421
+ * The workers one file addresses. A call's names must resolve to the platform's own
422
+ * declarations, which no file of the target declares and no import brings in; a
423
+ * name the target binds itself is the target's own constructor or object, which
424
+ * addresses no worker this rule knows. One batch per file that writes such a call.
425
+ */
426
+ function workersOf<Brand>(
427
+ project: ProjectView<Brand>,
428
+ file: SourceFile,
429
+ files: SourceFiles,
430
+ ): readonly EntryPoint[] {
431
+ const sites = workerSitesOf(file);
432
+ if (sites.length === 0) {
433
+ return [];
434
+ }
435
+ const names = sites.flatMap((site) => site.platformNames);
436
+ const symbols = project.symbolsAt(names.map((name) => project.handle(name)));
437
+ const platform = new Set<Node>();
438
+ names.forEach((name, index) => {
439
+ const symbol = symbols[index];
440
+ if (
441
+ symbol !== undefined &&
442
+ (symbol.flags & SymbolFlags.Alias) === 0 &&
443
+ symbol.declarations.length > 0 &&
444
+ symbol.declarations.every((handle) => !files.byName.has(handle.path))
445
+ ) {
446
+ platform.add(name);
447
+ }
448
+ });
449
+ const dir = dirnamePath(file.fileName);
450
+ const found: EntryPoint[] = [];
451
+ for (const site of sites) {
452
+ if (!site.platformNames.every((name) => platform.has(name))) {
453
+ continue;
454
+ }
455
+ for (const named of filesNamed(files, dir, site.literal)) {
456
+ found.push({ file: named, rule: "worker", source: site.literal, exports: false });
457
+ }
458
+ }
459
+ return found;
460
+ }
461
+
462
+ /**
463
+ * Every file of one project the lifted rules enter, in no particular order and
464
+ * possibly more than once: a file two rules enter appears once per rule.
465
+ *
466
+ * `testFiles` is the configuration's test-file patterns, matched against each
467
+ * file's path below the target root; a file one of them matches is a test file the
468
+ * runner executes.
469
+ */
470
+ export function entryPoints<Brand>(
471
+ project: ProjectView<Brand>,
472
+ files: SourceFiles,
473
+ testFiles: readonly string[],
474
+ ): readonly EntryPoint[] {
475
+ const found: EntryPoint[] = [];
476
+ const tests = testFiles.map((pattern) => ({ pattern, expression: globExpression(pattern) }));
477
+ for (const [path, file] of files.byPath) {
478
+ const named = conventionOf(basename(path));
479
+ if (named !== undefined) {
480
+ found.push({ file, rule: named.convention.rule, source: named.spelled, exports: true });
481
+ if (named.convention.testRunner) {
482
+ found.push(...setupFilesOf(file, files));
483
+ }
484
+ if (named.convention.rule === "browser-tests") {
485
+ found.push(...browserTestsOf(file, files));
486
+ }
487
+ }
488
+ for (const { pattern, expression } of tests) {
489
+ if (expression.test(path)) {
490
+ found.push({ file, rule: "test-runner", source: pattern, exports: false });
491
+ }
492
+ }
493
+ found.push(...workersOf(project, file, files));
494
+ }
495
+ return found;
496
+ }
package/src/finding.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The members of a finding its emitter decides: the code, where the subject is,
3
+ * what the subject is, and the sentence the finding reports. They are the
4
+ * Contract's finding members of the same names, spelled in this language's case.
5
+ *
6
+ * Every other member the finding schema requires follows from these and from the
7
+ * run: the kind's name, its fixability and its ceiling come from the code's row of
8
+ * the issue-kind vocabulary, the severity and the confidence from the resolved
9
+ * configuration, the language from the analyzer. A report completes them, so an
10
+ * emitter cannot state one that disagrees with the vocabulary.
11
+ */
12
+
13
+ /** Where the subject of a finding is. */
14
+ export interface FindingPosition {
15
+ /** The file below the target root, with the solidus as separator. */
16
+ readonly path: string;
17
+ /** The line of the subject's first character, counted from one. */
18
+ readonly line: number;
19
+ /** The column of the subject's first character, counted from one in UTF-16 code units. */
20
+ readonly column: number;
21
+ /** The line of the subject's last character. */
22
+ readonly endLine: number;
23
+ }
24
+
25
+ /** What a finding is about. */
26
+ export interface FindingSubject {
27
+ /** The stable symbol reference, or for a configured root the root as written. */
28
+ readonly ref: string;
29
+ /** The subject kind, from the closed vocabulary of the finding schema. */
30
+ readonly kind: string;
31
+ /** The display name a text line renders. */
32
+ readonly name: string;
33
+ /** The number of source lines the subject spans. */
34
+ readonly sizeLines: number;
35
+ }
36
+
37
+ /** One finding, as its emitter states it. */
38
+ export interface Finding {
39
+ readonly code: string;
40
+ readonly position: FindingPosition;
41
+ readonly symbol: FindingSubject;
42
+ readonly message: string;
43
+ }
package/src/glob.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Glob patterns over the paths of the target's own files, as the configuration
3
+ * writes them. Both `ts.test_files` and `ts.entry_files` name files this way, and
4
+ * one grammar answers for both.
5
+ *
6
+ * A configured root is not written in this grammar. It names a symbol reference
7
+ * rather than a path, `*` there spans the solidus and a brace list means nothing,
8
+ * so that rule is written where it is read.
9
+ */
10
+
11
+ /**
12
+ * One literal run of a pattern, with every character the expression syntax claims
13
+ * escaped.
14
+ *
15
+ * The set is the syntax characters and the solidus, and it is closed: an escape of any
16
+ * other character is refused outright by the expression syntax under the unicode flag,
17
+ * so escaping a hyphen, which needs none outside a character class, would refuse every
18
+ * pattern that names a hyphenated path.
19
+ */
20
+ function quoted(text: string): string {
21
+ return text.replaceAll(/[.*+?^${}()|[\]\\/]/gu, (character) => `\\${character}`);
22
+ }
23
+
24
+ /**
25
+ * The expression one glob pattern denotes. `**` spans path separators while `*` and `?`
26
+ * do not, a brace list is an alternation of its members, and every other character
27
+ * stands for itself.
28
+ */
29
+ export function globExpression(pattern: string): RegExp {
30
+ let source = "^";
31
+ let at = 0;
32
+ while (at < pattern.length) {
33
+ const rest = pattern.slice(at);
34
+ if (rest.startsWith("**/")) {
35
+ source += "(?:[^/]*/)*";
36
+ at += 3;
37
+ continue;
38
+ }
39
+ if (rest.startsWith("**")) {
40
+ source += ".*";
41
+ at += 2;
42
+ continue;
43
+ }
44
+ const character = pattern[at] ?? "";
45
+ at += 1;
46
+ if (character === "*") {
47
+ source += "[^/]*";
48
+ continue;
49
+ }
50
+ if (character === "?") {
51
+ source += "[^/]";
52
+ continue;
53
+ }
54
+ if (character === "{") {
55
+ const close = pattern.indexOf("}", at);
56
+ if (close < 0) {
57
+ source += "\\{";
58
+ continue;
59
+ }
60
+ source += `(?:${pattern.slice(at, close).split(",").map(quoted).join("|")})`;
61
+ at = close + 1;
62
+ continue;
63
+ }
64
+ source += quoted(character);
65
+ }
66
+ return new RegExp(`${source}$`, "u");
67
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { run, SETTING_OPTIONS, type Writer } from "./run.ts";
2
2
  export type { DirectoryEntry, Host, PathKind } from "./host.ts";
3
+ export { DECLINED_CONVENTIONS, type DeclinedConvention } from "./entry-point-gaps.ts";
3
4
  export {
4
5
  inventory,
5
6
  nodeKey,