test-inventory 0.4.1 → 0.6.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 (51) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +4 -1
  3. package/dist/ast.d.ts +3 -3
  4. package/dist/ast.js +4 -4
  5. package/dist/bin.js +1 -0
  6. package/dist/cli.d.ts +4 -2
  7. package/dist/cli.js +27 -7
  8. package/dist/config-dependencies.d.ts +62 -0
  9. package/dist/config-dependencies.js +231 -0
  10. package/dist/config-runs.d.ts +113 -0
  11. package/dist/config-runs.js +807 -0
  12. package/dist/config.d.ts +90 -0
  13. package/dist/config.js +652 -0
  14. package/dist/dependency-hash.d.ts +242 -0
  15. package/dist/dependency-hash.js +1458 -0
  16. package/dist/diagnostics.js +6 -1
  17. package/dist/extract.d.ts +20 -0
  18. package/dist/extract.js +20 -4
  19. package/dist/filter.d.ts +6 -0
  20. package/dist/filter.js +18 -1
  21. package/dist/frameworks/bindings.d.ts +22 -0
  22. package/dist/frameworks/bindings.js +19 -9
  23. package/dist/frameworks/common.d.ts +5 -0
  24. package/dist/frameworks/common.js +11 -0
  25. package/dist/frameworks/playwright.d.ts +2 -1
  26. package/dist/frameworks/playwright.js +11 -2
  27. package/dist/frameworks/vitest.d.ts +2 -1
  28. package/dist/frameworks/vitest.js +9 -3
  29. package/dist/hash.d.ts +1 -1
  30. package/dist/index.d.ts +1 -1
  31. package/dist/inventory.d.ts +2 -2
  32. package/dist/inventory.js +2 -1
  33. package/dist/json.js +2 -0
  34. package/dist/model.d.ts +9 -1
  35. package/dist/options.d.ts +2 -2
  36. package/dist/records.d.ts +58 -2
  37. package/dist/records.js +196 -28
  38. package/dist/scan-file.d.ts +13 -1
  39. package/dist/scan-file.js +16 -4
  40. package/dist/scan-source.js +1 -1
  41. package/dist/scan.d.ts +2 -1
  42. package/dist/scan.js +131 -18
  43. package/dist/types.d.ts +83 -7
  44. package/dist/values.d.ts +5 -0
  45. package/dist/values.js +5 -0
  46. package/dist/vitest-config.d.ts +6 -4
  47. package/dist/vitest-config.js +32 -28
  48. package/dist/vitest-defaults.d.ts +6 -0
  49. package/dist/vitest-defaults.js +16 -0
  50. package/package.json +10 -8
  51. package/schema/v1.json +200 -6
package/dist/scan.js CHANGED
@@ -1,11 +1,16 @@
1
- import { existsSync, statSync } from 'node:fs';
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { glob } from 'tinyglobby';
5
+ import { ConfigDependencies } from "./config-dependencies.js";
6
+ import { ConfigRuns, fileConfig } from "./config-runs.js";
7
+ import { Configs } from "./config.js";
8
+ import { DependencyGraph } from "./dependency-hash.js";
4
9
  import { fileDiagnostic } from "./diagnostics.js";
5
10
  import { findFiles, normalizePattern, readFailure } from "./files.js";
6
11
  import { detectFrameworkVersion, OLDEST } from "./framework-version.js";
7
12
  import { createInventory } from "./inventory.js";
8
- import { checkComments, checkFramework, checkObject, checkStrings, checkVersion, invalid, majorMinor, } from "./options.js";
13
+ import { checkComments, checkFramework, checkObject, checkStrings, checkVersion, invalid, majorMinor, toPosixPath, } from "./options.js";
9
14
  import { moduleResolver } from "./resolve.js";
10
15
  import { noRecords, scanFile } from "./scan-file.js";
11
16
  import { globalsCheck } from "./vitest-config.js";
@@ -46,14 +51,66 @@ function folderHint(root, patterns, framework) {
46
51
  const example = `${folder.slice(0, end)}/**/*.${framework === 'playwright' ? 'spec' : 'test'}.ts`;
47
52
  return ` "${folder}" is a folder; to scan the test files in it, use a pattern such as "${example}".`;
48
53
  }
54
+ /** The JavaScript and TypeScript files the runners read, which the configs choose the test files from. */
55
+ const SCRIPT_FILES = '**/*.{js,jsx,ts,tsx,mjs,mjsx,mts,mtsx,cjs,cjsx,cts,ctsx}';
56
+ /** Whether an error says a file or folder isn't there, as a snapshot isn't before a test first writes it. */
57
+ function isMissing(error) {
58
+ return error.code === 'ENOENT';
59
+ }
60
+ /**
61
+ * The files in a folder of the project and the folders in it, linked files too, as paths in the folder with `/`;
62
+ * none for no folder.
63
+ */
64
+ function filesIn(root, folder) {
65
+ const base = path.join(root, folder);
66
+ try {
67
+ return readdirSync(base, { recursive: true, withFileTypes: true })
68
+ .filter((entry) => entry.isFile() || (entry.isSymbolicLink() && isFile(path.join(entry.parentPath, entry.name))))
69
+ .map((entry) => toPosixPath(path.relative(base, path.join(entry.parentPath, entry.name))))
70
+ .sort();
71
+ }
72
+ catch (error) {
73
+ return isMissing(error) ? [] : 'unreadable';
74
+ }
75
+ }
76
+ function isFile(file) {
77
+ try {
78
+ return statSync(file).isFile();
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
84
+ /** A file's bytes, from the project root; null when there is none. */
85
+ function bytesOf(root, file) {
86
+ try {
87
+ return readFileSync(path.join(root, file));
88
+ }
89
+ catch (error) {
90
+ return isMissing(error) ? null : 'unreadable';
91
+ }
92
+ }
93
+ /** Why a scan found no test files: what the patterns matched, or whether there is a config to find them from. */
94
+ function noFiles(root, patterns, framework, placements) {
95
+ if (patterns) {
96
+ const list = patterns.map((pattern) => `"${pattern}"`).join(', ');
97
+ return `No test files match ${list} in the root.${folderHint(root, patterns, framework)}`;
98
+ }
99
+ if ([...placements.values()].some((placement) => placement.config)) {
100
+ return 'No test files were found from the configs in the root.';
101
+ }
102
+ const name = framework === 'playwright' ? 'Playwright' : 'Vitest';
103
+ return `No patterns were given, and no ${name} config is in the root or a folder below it, so there are no test files to find.`;
104
+ }
49
105
  /**
50
- * Scans the test files that match `patterns` under `root`, one file at a time.
106
+ * Scans the test files that match `patterns` under `root`, or without patterns those the configs run, one file at a
107
+ * time.
51
108
  *
52
109
  * Throws a `TypeError` for invalid options. Problems with files and code are reported as diagnostics.
53
110
  */
54
111
  export async function scan(options) {
55
112
  const checked = checkObject(options, 'options');
56
- const patterns = checkStrings(checked.patterns, 'options.patterns', true);
113
+ const patterns = checked.patterns === undefined ? null : checkStrings(checked.patterns, 'options.patterns', true);
57
114
  const framework = checkFramework(checked.framework, 'options.framework');
58
115
  const ignore = checkStrings(checked.ignore, 'options.ignore', false);
59
116
  const comments = checkComments(checked.comments, 'options.comments');
@@ -73,45 +130,101 @@ export async function scan(options) {
73
130
  : majorMinor(frameworkVersion);
74
131
  const source = override === null ? (detected?.source ?? 'package.json') : '.';
75
132
  const projectDiagnostics = versionDiagnostics(framework, frameworkVersion, source, range);
76
- const found = await findFiles(root, patterns, ignore, framework);
133
+ // Without patterns, the configs choose among all JavaScript and TypeScript files.
134
+ const found = await findFiles(root, patterns ?? [SCRIPT_FILES], ignore, framework);
77
135
  projectDiagnostics.push(...found.diagnostics);
78
- if (found.files.length === 0) {
79
- const list = patterns.map((pattern) => `"${pattern}"`).join(', ');
80
- const message = `No test files match ${list} in the root.${folderHint(root, patterns, framework)}`;
81
- projectDiagnostics.push(fileDiagnostic('no-files-matched', '.', message));
82
- }
83
136
  const readerFor = moduleResolver(root);
137
+ const graph = new DependencyGraph();
138
+ const configs = new Configs(root, framework, readerFor, rules);
139
+ const runs = new ConfigRuns(configs, framework, rules, patterns === null);
140
+ const placements = new Map(found.files.map((file) => [file.absolutePath, runs.placementOf(file.absolutePath)]));
141
+ const files = patterns ? found.files : found.files.filter((file) => placements.get(file.absolutePath)?.runs);
142
+ // A setup project's files may be outside the patterns, or ignored for the scan, so they are looked for in its testDir.
143
+ const setupDirs = new Set();
144
+ for (const { config, projects } of placements.values()) {
145
+ const seen = new Set();
146
+ const names = (projects ?? []).flatMap((project) => project.dependencies);
147
+ for (let name = names.pop(); config && name !== undefined; name = names.pop()) {
148
+ const project = seen.has(name) ? null : runs.projectNamed(config, name);
149
+ seen.add(name);
150
+ if (!project)
151
+ continue;
152
+ setupDirs.add(project.about.testDir);
153
+ names.push(...project.about.dependencies);
154
+ }
155
+ }
156
+ // Searched from the folder itself, as a folder named like `(auth)` would be read as a glob in a pattern.
157
+ const setupFiles = await Promise.all([...setupDirs].map((folder) => glob(SCRIPT_FILES, { cwd: folder, absolute: true, ignore: ['**/node_modules/**'] })));
158
+ const candidates = setupFiles.flat().map((file) => path.resolve(file));
159
+ const configDependencies = new ConfigDependencies(framework, runs, readerFor, candidates);
160
+ // Many tests of a file compare against one folder, so each is listed once.
161
+ const listed = new Map();
162
+ const list = (folder) => {
163
+ if (!listed.has(folder))
164
+ listed.set(folder, filesIn(root, folder));
165
+ return listed.get(folder);
166
+ };
167
+ const snapshotFiles = { framework, list, read: (file) => bytesOf(root, file) };
168
+ const notRun = [];
84
169
  const results = [];
85
170
  const globalsFiles = [];
86
- for (const { absolutePath, relativeFilePath } of found.files) {
171
+ for (const { absolutePath, relativeFilePath } of files) {
172
+ const placement = placements.get(absolutePath);
173
+ if (placement.config)
174
+ configs.use(placement.config);
175
+ const config = placement.config?.record.relativeFilePath ?? null;
87
176
  let code;
88
177
  try {
89
178
  code = await readFile(absolutePath, 'utf8');
90
179
  }
91
180
  catch (error) {
181
+ const records = noRecords(relativeFilePath);
92
182
  results.push({
93
- ...noRecords(relativeFilePath),
183
+ ...records,
184
+ file: { ...records.file, config },
94
185
  diagnostics: [
95
186
  fileDiagnostic('read-error', relativeFilePath, `The file could not be read: ${readFailure(error)}.`),
96
187
  ],
97
188
  });
98
189
  continue;
99
190
  }
191
+ const runsWith = configDependencies.of(placement);
100
192
  const result = scanFile(code, relativeFilePath, {
101
193
  framework,
102
194
  version: rules,
103
195
  comments,
104
196
  readModule: readerFor(absolutePath),
105
197
  root,
198
+ isBenchmarkFile: placement.runs === null ? null : placement.runs === 'benchmark',
199
+ config: fileConfig(placement, framework, rules, absolutePath),
200
+ graph,
201
+ runsWith,
202
+ snapshotFiles: { ...snapshotFiles, moved: runsWith.moved },
106
203
  });
107
204
  if (result.usesGlobals)
108
205
  globalsFiles.push({ absolutePath, relativeFilePath });
109
- results.push(result);
206
+ // A file without tests that no config runs, such as a source file a broad pattern matches, misses nothing.
207
+ if (placement.runs === false && result.tests.length > 0) {
208
+ const message = `The file has tests, but no config in its folder or above runs it, so the runner doesn't collect them; the nearest config is ${config}.`;
209
+ notRun.push(fileDiagnostic('file-not-run', relativeFilePath, message));
210
+ }
211
+ results.push({ ...result, file: { ...result.file, config } });
212
+ }
213
+ const missingGlobals = globalsCheck(root, configs, (file) => placements.get(file));
214
+ let globals = null;
215
+ for (const { absolutePath, relativeFilePath } of globalsFiles) {
216
+ const message = missingGlobals(absolutePath);
217
+ if (message === null)
218
+ continue;
219
+ globals = fileDiagnostic('globals-not-enabled', relativeFilePath, message);
220
+ break;
110
221
  }
111
- const enabled = globalsCheck(root);
112
- const globalsFile = globalsFiles.find((file) => !enabled(file.absolutePath))?.relativeFilePath;
113
- if (globalsFile !== undefined) {
114
- projectDiagnostics.push(fileDiagnostic('globals-not-enabled', globalsFile, "The file uses Vitest's global test functions, but no Vitest or Vite config with globals: true and no tsconfig with vitest/globals was found."));
222
+ projectDiagnostics.push(...configs.diagnostics, ...notRun, ...(globals ? [globals] : []));
223
+ // After the config warnings, which may say why no file was found.
224
+ if (files.length === 0) {
225
+ projectDiagnostics.push(fileDiagnostic('no-files-matched', '.', noFiles(root, patterns, framework, placements)));
115
226
  }
116
- return createInventory(framework, frameworkVersion, found.files.length, results, projectDiagnostics);
227
+ const fileCount = files.length;
228
+ const records = configs.records((config) => runs.projectsIn(config));
229
+ return createInventory(framework, frameworkVersion, fileCount, results, projectDiagnostics, records);
117
230
  }
package/dist/types.d.ts CHANGED
@@ -20,18 +20,25 @@ export type Framework = 'playwright' | 'vitest';
20
20
  */
21
21
  export type TestState = 'active' | 'skip' | 'fixme' | 'todo' | 'expectedToFail' | 'notLoaded';
22
22
  /**
23
- * Where a test's state is set: on the test, on a describe around it, or at file level.
23
+ * Where a test's state is set: on the test, on a describe around it, at file level, or by a Vitest tag the config
24
+ * defines.
24
25
  *
25
26
  * @inline
26
27
  */
27
- export type StateSource = 'test' | 'suite' | 'file';
28
+ export type StateSource = 'test' | 'suite' | 'file' | 'config';
29
+ /**
30
+ * Where a test's run setting comes from: the test, a describe around it, the file, or the config its file uses.
31
+ *
32
+ * @inline
33
+ */
34
+ export type SettingSource = 'test' | 'suite' | 'file' | 'config';
28
35
  /**
29
36
  * `error`: tests may be missing from the output. `warning`: a value is missing or approximate.
30
37
  *
31
38
  * @inline
32
39
  */
33
40
  export type DiagnosticLevel = 'error' | 'warning';
34
- export type DiagnosticCode = 'unresolved-test-import' | 'parse-error' | 'read-error' | 'framework-mismatch' | 'unresolved-tag' | 'unresolved-annotation' | 'unresolved-cases' | 'test-in-loop' | 'dynamic-title' | 'duplicate-title' | 'duplicate-id' | 'internal-error' | 'invalid-chain' | 'skip-without-body' | 'test-without-body' | 'describe-not-called' | 'async-describe' | 'nested-test' | 'tag-without-at' | 'removed-api' | 'only-in-skipped-describe' | 'config-may-change-state' | 'symlink-ignored' | 'invalid-import' | 'globals-not-enabled' | 'unknown-framework-version' | 'unsupported-framework-version' | 'local-test-object' | 'state-in-helper' | 'unresolved-option' | 'dynamic-reason' | 'no-files-matched' | 'unsupported-file' | 'approximate-framework-version' | 'test-in-function' | 'empty-title' | 'bench-outside-benchmark-file';
41
+ export type DiagnosticCode = 'unresolved-test-import' | 'parse-error' | 'read-error' | 'framework-mismatch' | 'unresolved-tag' | 'unresolved-annotation' | 'unresolved-cases' | 'test-in-loop' | 'dynamic-title' | 'duplicate-title' | 'duplicate-id' | 'internal-error' | 'invalid-chain' | 'skip-without-body' | 'test-without-body' | 'describe-not-called' | 'async-describe' | 'nested-test' | 'tag-without-at' | 'removed-api' | 'only-in-skipped-describe' | 'config-may-change-state' | 'undefined-tag' | 'symlink-ignored' | 'invalid-import' | 'globals-not-enabled' | 'unknown-framework-version' | 'unsupported-framework-version' | 'local-test-object' | 'state-in-helper' | 'unresolved-option' | 'dynamic-reason' | 'no-files-matched' | 'unsupported-file' | 'approximate-framework-version' | 'test-in-function' | 'empty-title' | 'bench-outside-benchmark-file' | 'config-value-unknown' | 'file-not-run' | 'forbidden-only' | 'dependency-not-followed';
35
42
  export type MetaValue = string | number | boolean | null | MetaValue[] | {
36
43
  [key: string]: MetaValue;
37
44
  };
@@ -45,6 +52,8 @@ export interface Inventory {
45
52
  /** Read from the project, or given with `--framework-version`; null when unknown. */
46
53
  frameworkVersion: string | null;
47
54
  summary: Summary;
55
+ /** The configs the scanned files use, sorted by path. */
56
+ configs: ConfigRecord[];
48
57
  /** One per scanned file, in the order the files were scanned. */
49
58
  files: FileRecord[];
50
59
  suites: SuiteRecord[];
@@ -53,12 +62,14 @@ export interface Inventory {
53
62
  }
54
63
  /**
55
64
  * One line of the JSON Lines output (`--format jsonl`): the inventory with each record on its own line, marked by
56
- * `type`. The `inventory` line comes first, then the files, suites, tests and diagnostics in the order of the JSON
57
- * output, and the `summary` line last.
65
+ * `type`. The `inventory` line comes first, then the configs, files, suites, tests and diagnostics in the order of the
66
+ * JSON output, and the `summary` line last.
58
67
  */
59
68
  export type InventoryLine = ({
60
69
  type: 'inventory';
61
70
  } & Pick<Inventory, 'schemaVersion' | 'tool' | 'framework' | 'frameworkVersion'>) | ({
71
+ type: 'config';
72
+ } & ConfigRecord) | ({
62
73
  type: 'file';
63
74
  } & FileRecord) | ({
64
75
  type: 'suite';
@@ -103,11 +114,40 @@ export interface Summary {
103
114
  /** Tests per annotation type. */
104
115
  annotationCounts: Record<string, Count>;
105
116
  }
117
+ /**
118
+ * A Playwright or Vitest config that scanned files use: the nearest one in a file's folder or a folder above it, up to
119
+ * the root, as the runner started in that folder would. Read as code, never run.
120
+ */
121
+ export interface ConfigRecord {
122
+ /** Path from the project root, with `/` separators. */
123
+ relativeFilePath: string;
124
+ /** Files the config takes settings from through imports, such as a shared base config, in the order read. */
125
+ extends: string[];
126
+ /**
127
+ * The projects the config runs tests in: Playwright's `projects`, or one for a config without them; Vitest's, with
128
+ * each browser instance as one. Null when the scan can't work them out.
129
+ */
130
+ projects: ProjectRecord[] | null;
131
+ }
132
+ /** @inline */
133
+ export interface ProjectRecord {
134
+ /** As the runner names it: `''` for a project without a name. */
135
+ name: string;
136
+ /** The folder the project's test files are found in, from the project root: Playwright's `testDir`, Vitest's `dir`. */
137
+ testDir: string;
138
+ /** Playwright's projects that run before this one; `[]` for Vitest. */
139
+ dependencies: string[];
140
+ }
106
141
  /** A scanned file, also one that has no tests or could not be read. */
107
142
  export interface FileRecord {
108
143
  /** Path from the project root, with `/` separators. */
109
144
  relativeFilePath: string;
110
145
  fileName: string;
146
+ /**
147
+ * The config the file uses, from the project root: the outermost config at or above it that runs it, as the runner
148
+ * started at the root would, else the nearest. Null when there is none.
149
+ */
150
+ config: string | null;
111
151
  /** Hooks at the top level of the file, which run around every test in it. */
112
152
  hooks: HookCounts;
113
153
  suiteCount: Count;
@@ -210,6 +250,16 @@ export interface TestRecord {
210
250
  id: Hash;
211
251
  /** Hash of the test body, ignoring whitespace and comments; null when the test has no body. */
212
252
  bodyHash: Hash | null;
253
+ /**
254
+ * Hash of everything the test runs in the project: its call, the hooks, options and code of the file and of every
255
+ * describe around it, and the functions, classes, constants and modules these use, followed through imports. Ignores
256
+ * whitespace and comments. Installed packages are not in it.
257
+ */
258
+ dependencyHash: Hash;
259
+ /** The files that went into `dependencyHash`, the test's own included, sorted, with `/` separators. */
260
+ dependencies: string[];
261
+ /** Something the test reaches couldn't be followed, so a change there doesn't change `dependencyHash`. */
262
+ hasUnfollowedDependencies: boolean;
213
263
  /** The nearest describe; null outside any describe. */
214
264
  suiteId: Hash | null;
215
265
  /** Titles of the describes around the test, outermost first. */
@@ -275,7 +325,7 @@ export interface TestRecord {
275
325
  hooks: HookCounts;
276
326
  /**
277
327
  * In milliseconds: the last `test.setTimeout()` in the body or a `beforeEach`, otherwise the nearest describe or file
278
- * that sets one. Null when the file sets none, so the config's timeout applies.
328
+ * that sets one, otherwise the config. Null when none sets it, so the runner's default applies.
279
329
  */
280
330
  timeout: Count | null;
281
331
  /** Playwright `test.slow()` applies: the runner triples the timeout. */
@@ -288,8 +338,29 @@ export interface TestRecord {
288
338
  retryDelay: Count | null;
289
339
  /** Vitest only: source text of the `retry` object's `condition`, a RegExp or a function. */
290
340
  retryCondition: string | null;
291
- /** Vitest only: how many times the runner runs the test again after the first run. */
341
+ /**
342
+ * How many times the runner runs the test again after the first run: Vitest's `repeats`, or Playwright's `repeatEach`
343
+ * from the config less one.
344
+ */
292
345
  repeats: Count | null;
346
+ /**
347
+ * Where `timeout` comes from; null when nothing sets it. A null `timeout` from the config means the config sets it, but
348
+ * its projects set different values or the value depends on a condition, as in `process.env.CI ? 2 : 0`.
349
+ */
350
+ timeoutSource: SettingSource | null;
351
+ /** Where `retries`, `retryDelay` and `retryCondition` come from, read as `timeoutSource` is. */
352
+ retriesSource: SettingSource | null;
353
+ /** Where `repeats` comes from, read as `timeoutSource` is. */
354
+ repeatsSource: SettingSource | null;
355
+ /**
356
+ * The config's source text for `timeout` when the config sets it under a condition, as in
357
+ * `process.env.CI ? 60_000 : 30_000`, shortened when long; null otherwise.
358
+ */
359
+ timeoutConfigText: string | null;
360
+ /** The config's source text for `retries`, read as `timeoutConfigText` is. */
361
+ retriesConfigText: string | null;
362
+ /** The config's source text for `repeats`, Playwright's `repeatEach`, read as `timeoutConfigText` is. */
363
+ repeatsConfigText: string | null;
293
364
  /**
294
365
  * Names the test function takes from its context, then those of the `beforeEach`, `afterEach` and `aroundEach` hooks
295
366
  * around it, once each. Vitest's own context keys, such as `expect` and `task`, are left out.
@@ -320,6 +391,11 @@ export interface TestRecord {
320
391
  isInCondition: boolean;
321
392
  /** Declared inside a plain function. */
322
393
  isInFunction: boolean;
394
+ /**
395
+ * Names of the projects that run the test, of the config its file uses, after each project's files and Playwright's
396
+ * `grep` and `grepInvert`. `[]` when no project runs it; null when there is no config, or the scan can't tell.
397
+ */
398
+ projects: string[] | null;
323
399
  }
324
400
  /** @inline */
325
401
  export interface Step {
package/dist/values.d.ts CHANGED
@@ -36,6 +36,11 @@ export declare class Values {
36
36
  * the file does not declare it, as with globals.
37
37
  */
38
38
  declarationOf(name: string, scopes: readonly Node[]): Node | null;
39
+ /** The value a `const` name is declared with, and the scopes around that declaration; null for any other name. */
40
+ constantOf(name: string, scopes: readonly Node[]): {
41
+ init: Expression;
42
+ scopes: readonly Node[];
43
+ } | null;
39
44
  /** The module a name is imported from where it is used; null when it isn't an import. */
40
45
  importSource(name: string, scopes: readonly Node[]): string | null;
41
46
  /** Whether a name refers to a function or class declared in the file. */
package/dist/values.js CHANGED
@@ -208,6 +208,11 @@ export class Values {
208
208
  declarationOf(name, scopes) {
209
209
  return this.lookup(name, scopes)?.binding.declaration ?? null;
210
210
  }
211
+ /** The value a `const` name is declared with, and the scopes around that declaration; null for any other name. */
212
+ constantOf(name, scopes) {
213
+ const binding = this.lookup(name, scopes)?.binding;
214
+ return binding?.kind === 'const' ? { init: binding.init, scopes: binding.scopes } : null;
215
+ }
211
216
  /** The module a name is imported from where it is used; null when it isn't an import. */
212
217
  importSource(name, scopes) {
213
218
  const binding = this.lookup(name, scopes)?.binding;
@@ -1,6 +1,8 @@
1
+ import type { Placement } from './config-runs.ts';
2
+ import { type Configs } from './config.ts';
1
3
  /**
2
- * Returns a check of whether Vitest's globals are turned on for a file, by a config in its folder or any folder
3
- * above it up to the root, as in a monorepo where each package has its own config. Config files are read as code and
4
- * never run.
4
+ * Returns a check of whether Vitest's globals are turned on for a file: by the project of the config it uses, or by a
5
+ * tsconfig in its folder or any folder above it up to the root. The check gives the message to report when they are
6
+ * not, and null when they are or may be. Config files are read as code, never run.
5
7
  */
6
- export declare function globalsCheck(root: string): (file: string) => boolean;
8
+ export declare function globalsCheck(root: string, configs: Configs, placementOf: (file: string) => Placement): (file: string) => string | null;
@@ -1,19 +1,6 @@
1
1
  import { readdirSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { childNodes } from "./ast.js";
4
- import { parseFile } from "./parse.js";
5
- const CONFIG_FILES = ['vitest.config', 'vite.config'].flatMap((name) => ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs'].map((extension) => name + extension));
6
- function setsGlobals(node) {
7
- if (node.type === 'Property' &&
8
- !node.computed &&
9
- ((node.key.type === 'Identifier' && node.key.name === 'globals') ||
10
- (node.key.type === 'Literal' && node.key.value === 'globals')) &&
11
- node.value.type === 'Literal' &&
12
- node.value.value === true) {
13
- return true;
14
- }
15
- return childNodes(node).some(setsGlobals);
16
- }
3
+ import { settingAt } from "./config.js";
17
4
  /** A file's text, or null when it is missing or can't be read. */
18
5
  function readText(file) {
19
6
  try {
@@ -23,13 +10,8 @@ function readText(file) {
23
10
  return null;
24
11
  }
25
12
  }
26
- /** Whether a folder has a Vitest or Vite config with `globals: true`, or a tsconfig with `vitest/globals`. */
27
- function enablesGlobals(directory) {
28
- for (const name of CONFIG_FILES) {
29
- const code = readText(path.join(directory, name));
30
- if (code !== null && setsGlobals(parseFile(name, code).program))
31
- return true;
32
- }
13
+ /** Whether a folder has a tsconfig with `vitest/globals` in its types. */
14
+ function typesGlobals(directory) {
33
15
  let names;
34
16
  try {
35
17
  names = readdirSync(directory);
@@ -39,21 +21,43 @@ function enablesGlobals(directory) {
39
21
  }
40
22
  return names.some((name) => /^tsconfig.*\.json$/.test(name) && (readText(path.join(directory, name)) ?? '').includes('"vitest/globals"'));
41
23
  }
24
+ const ASSUMED = "files that use Vitest's global test functions are taken to have them";
25
+ /** Whether a project's settings turn Vitest's globals on; null when the scan can't tell. */
26
+ function turnsOnGlobals(settings, configs) {
27
+ // A project the scan can't work out was reported where its files were asked for.
28
+ if (settings.kind === 'unknown')
29
+ return null;
30
+ const globals = settingAt(settings, ['test', 'globals']);
31
+ if (globals?.kind !== 'unknown')
32
+ return globals?.kind === 'value' && globals.value === true;
33
+ configs.reportUnknown(globals, 'test.globals', ASSUMED);
34
+ return null;
35
+ }
42
36
  /**
43
- * Returns a check of whether Vitest's globals are turned on for a file, by a config in its folder or any folder
44
- * above it up to the root, as in a monorepo where each package has its own config. Config files are read as code and
45
- * never run.
37
+ * Returns a check of whether Vitest's globals are turned on for a file: by the project of the config it uses, or by a
38
+ * tsconfig in its folder or any folder above it up to the root. The check gives the message to report when they are
39
+ * not, and null when they are or may be. Config files are read as code, never run.
46
40
  */
47
- export function globalsCheck(root) {
41
+ export function globalsCheck(root, configs, placementOf) {
48
42
  const known = new Map();
49
- const enabled = (directory) => {
43
+ const typed = (directory) => {
50
44
  let result = known.get(directory);
51
45
  if (result === undefined) {
52
46
  const parent = path.dirname(directory);
53
- result = enablesGlobals(directory) || (directory !== root && parent !== directory && enabled(parent));
47
+ result = typesGlobals(directory) || (directory !== root && parent !== directory && typed(parent));
54
48
  known.set(directory, result);
55
49
  }
56
50
  return result;
57
51
  };
58
- return (file) => enabled(path.dirname(file));
52
+ return (file) => {
53
+ if (typed(path.dirname(file)))
54
+ return null;
55
+ const { config, settings } = placementOf(file);
56
+ if (config && settings && turnsOnGlobals(settings, configs) !== false)
57
+ return null;
58
+ const why = config
59
+ ? `${config.record.relativeFilePath} doesn't set test.globals to true for it`
60
+ : 'no Vitest or Vite config was found';
61
+ return `The file uses Vitest's global test functions, but ${why} and no tsconfig has vitest/globals in its types.`;
62
+ };
59
63
  }
@@ -0,0 +1,6 @@
1
+ /** Vitest's default file settings, as `configDefaults` in `vitest/config` holds them. */
2
+ export declare const VITEST_INCLUDE: string[];
3
+ export declare const VITEST_TYPECHECK_INCLUDE: string[];
4
+ export declare const VITEST_BENCHMARK_INCLUDE: string[];
5
+ /** Vitest's default `exclude`: Vitest 4 left out the folders and tool configs that Vitest 3 excludes. */
6
+ export declare function vitestExclude(version: [number, number] | null): string[];
@@ -0,0 +1,16 @@
1
+ /** Vitest's default file settings, as `configDefaults` in `vitest/config` holds them. */
2
+ export const VITEST_INCLUDE = ['**/*.{test,spec}.?(c|m)[jt]s?(x)'];
3
+ export const VITEST_TYPECHECK_INCLUDE = ['**/*.{test,spec}-d.?(c|m)[jt]s?(x)'];
4
+ export const VITEST_BENCHMARK_INCLUDE = ['**/*.{bench,benchmark}.?(c|m)[jt]s?(x)'];
5
+ /** Vitest's default `exclude`: Vitest 4 left out the folders and tool configs that Vitest 3 excludes. */
6
+ export function vitestExclude(version) {
7
+ if (version === null || version[0] >= 4)
8
+ return ['**/node_modules/**', '**/.git/**'];
9
+ return [
10
+ '**/node_modules/**',
11
+ '**/dist/**',
12
+ '**/cypress/**',
13
+ '**/.{idea,git,cache,output,temp}/**',
14
+ '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*',
15
+ ];
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "test-inventory",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Test metadata from source code, not from test results.",
5
5
  "keywords": [
6
6
  "testing",
@@ -79,7 +79,8 @@
79
79
  "changeset": "changeset"
80
80
  },
81
81
  "dependencies": {
82
- "oxc-parser": "0.154.0",
82
+ "oxc-parser": "0.156.0",
83
+ "picomatch": "^4.0.7",
83
84
  "tinyglobby": "^0.2.17"
84
85
  },
85
86
  "devDependencies": {
@@ -87,19 +88,20 @@
87
88
  "@changesets/changelog-github": "^1.0.1",
88
89
  "@changesets/cli": "^3.0.3",
89
90
  "@types/node": "~22.12.0",
90
- "@vitest/coverage-v8": "5.0.4",
91
+ "@types/picomatch": "^4.0.3",
92
+ "@vitest/coverage-v8": "5.0.5",
91
93
  "ajv": "^8.20.0",
92
- "eslint": "~10.12.0",
94
+ "eslint": "~10.13.0",
93
95
  "prettier": "3.9.10",
94
96
  "publint": "^0.3.25",
95
97
  "ts-json-schema-generator": "2.9.0",
96
98
  "typedoc": "^0.28.20",
97
- "typedoc-plugin-markdown": "^4.13.1",
99
+ "typedoc-plugin-markdown": "^4.14.1",
98
100
  "typedoc-vitepress-theme": "^1.1.4",
99
101
  "typescript": "~6.0.3",
100
- "typescript-eslint": "~8.71.2",
101
- "vitepress": "2.0.0-alpha.20",
102
+ "typescript-eslint": "~8.72.1",
103
+ "vitepress": "2.0.0-alpha.21",
102
104
  "vitepress-plugin-llms": "^1.14.0",
103
- "vitest": "5.0.4"
105
+ "vitest": "5.0.5"
104
106
  }
105
107
  }