test-inventory 0.5.0 → 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.
@@ -26,6 +26,7 @@ export const LEVELS = {
26
26
  'config-value-unknown': 'warning',
27
27
  'file-not-run': 'warning',
28
28
  'forbidden-only': 'error',
29
+ 'dependency-not-followed': 'warning',
29
30
  'symlink-ignored': 'warning',
30
31
  'invalid-import': 'error',
31
32
  'globals-not-enabled': 'warning',
package/dist/extract.d.ts CHANGED
@@ -18,6 +18,10 @@ export interface Scope {
18
18
  hookRun: RunSettings;
19
19
  /** Fixtures of the hooks in the scope that run around each test. */
20
20
  hookFixtures: string[];
21
+ /** Fixtures of every hook in the scope, `beforeAll` and `afterAll` too, which set up what its tests run. */
22
+ setupFixtures: string[];
23
+ /** A hook in the scope takes its fixtures whole, so it may use any. */
24
+ takesAllFixtures: boolean;
21
25
  suite: PendingSuite | null;
22
26
  }
23
27
  /** Where a test or describe registered through an imported helper is written, in the helper's module. */
@@ -33,6 +37,10 @@ export interface PendingSuite {
33
37
  /** For a describe declared in an imported helper, the call in the scanned file that registers it; null otherwise. */
34
38
  site: Span | null;
35
39
  declaredAt: DeclaredAt | null;
40
+ /** The file the describe is written in. */
41
+ frame: Frame;
42
+ /** The outermost helper call in the scanned file that declares the describe; null when the file declares it. */
43
+ registeredBy: CallExpression | null;
36
44
  /** Where the scanned file registers the describe: its own call, or the outermost helper call that declares it. */
37
45
  order: number;
38
46
  /** Statements of the tests and describes directly inside, left out of the describe's body hash. */
@@ -48,6 +56,10 @@ export interface PendingTest {
48
56
  /** For a test declared in an imported helper, the call in the scanned file that registers it; null otherwise. */
49
57
  site: Span | null;
50
58
  declaredAt: DeclaredAt | null;
59
+ /** The file the test is written in. */
60
+ frame: Frame;
61
+ /** The outermost helper call in the scanned file that declares the test; null when the file declares it. */
62
+ registeredBy: CallExpression | null;
51
63
  /** Where the scanned file registers the test: its own call, or the outermost helper call that declares it. */
52
64
  order: number;
53
65
  /** States from the declaration, then from calls in the body such as `test.skip(condition)`. */
package/dist/extract.js CHANGED
@@ -140,6 +140,8 @@ class Walker {
140
140
  hooks: noHooks(),
141
141
  hookRun: {},
142
142
  hookFixtures: [],
143
+ setupFixtures: [],
144
+ takesAllFixtures: false,
143
145
  suite: null,
144
146
  };
145
147
  }
@@ -158,7 +160,7 @@ class Walker {
158
160
  helper: null,
159
161
  declaredIn: null,
160
162
  quiet: false,
161
- registeredAt: null,
163
+ registeredBy: null,
162
164
  });
163
165
  // Functions defined inside these are deferred in turn; the loop reaches them too, as they are added to the end.
164
166
  for (const entry of this.deferred)
@@ -418,6 +420,8 @@ class Walker {
418
420
  context.scope.hooks[api.name] += 1;
419
421
  if (EACH_HOOKS.has(api.name))
420
422
  context.scope.hookFixtures.push(...api.fixtures);
423
+ context.scope.setupFixtures.push(...api.fixtures);
424
+ context.scope.takesAllFixtures ||= api.takesAllFixtures;
421
425
  }
422
426
  if (api.fn) {
423
427
  if (!call.arguments.includes(api.fn))
@@ -628,7 +632,7 @@ class Walker {
628
632
  ...context,
629
633
  isInFunction: true,
630
634
  declaredIn: null,
631
- registeredAt: context.registeredAt ?? call.start,
635
+ registeredBy: context.registeredBy ?? call,
632
636
  });
633
637
  });
634
638
  this.callStack.pop();
@@ -926,7 +930,9 @@ class Walker {
926
930
  call,
927
931
  site: this.site,
928
932
  declaredAt: this.declaredAt(call),
929
- order: context.registeredAt ?? call.start,
933
+ frame: this.frame,
934
+ registeredBy: context.registeredBy,
935
+ order: (context.registeredBy ?? call).start,
930
936
  states: [...declaration.states],
931
937
  annotations: [...declaration.details.annotations],
932
938
  isInLoop: context.isInLoop,
@@ -976,6 +982,8 @@ class Walker {
976
982
  hooks: noHooks(),
977
983
  hookRun: {},
978
984
  hookFixtures: [],
985
+ setupFixtures: [],
986
+ takesAllFixtures: false,
979
987
  suite: null,
980
988
  };
981
989
  const suite = {
@@ -984,7 +992,9 @@ class Walker {
984
992
  call,
985
993
  site: this.site,
986
994
  declaredAt: this.declaredAt(call),
987
- order: context.registeredAt ?? call.start,
995
+ frame: this.frame,
996
+ registeredBy: context.registeredBy,
997
+ order: (context.registeredBy ?? call).start,
988
998
  cuts: [],
989
999
  isInCondition: context.isInCondition,
990
1000
  isInFunction: context.isInFunction,
package/dist/filter.d.ts CHANGED
@@ -11,6 +11,12 @@ export interface InventoryFilter {
11
11
  only?: boolean;
12
12
  /** Keeps tests that don't run and give no reason. Tests in a file the runner refuses are left out. */
13
13
  withoutReason?: boolean;
14
+ /**
15
+ * Keeps the tests a change to one of these files can affect: those whose `dependencies` include it, and those that
16
+ * reach code the scan can't follow (`hasUnfollowedDependencies`). Paths are from the project root, as
17
+ * `dependencies` writes them. An empty list keeps only the tests the scan can't follow.
18
+ */
19
+ related?: string[];
14
20
  }
15
21
  /** An inventory whose test records have only `id` and the keys picked with `pickTestFields()`. */
16
22
  export type PickedInventory<K extends keyof TestRecord> = Omit<Inventory, 'tests'> & {
package/dist/filter.js CHANGED
@@ -1,3 +1,4 @@
1
+ import path from 'node:path';
1
2
  import { checkObject, OptionsError } from "./options.js";
2
3
  import { summarize } from "./summary.js";
3
4
  const STATES = ['active', 'skip', 'fixme', 'todo', 'expectedToFail', 'notLoaded'];
@@ -7,11 +8,15 @@ const FILTER_KEYS = [
7
8
  'grep',
8
9
  'only',
9
10
  'withoutReason',
11
+ 'related',
10
12
  ];
11
13
  // Typed as a record of every key, so a key added to TestRecord fails to compile until it is listed here.
12
14
  const TEST_KEYS = Object.keys({
13
15
  id: true,
14
16
  bodyHash: true,
17
+ dependencyHash: true,
18
+ dependencies: true,
19
+ hasUnfollowedDependencies: true,
15
20
  suiteId: true,
16
21
  suitePath: true,
17
22
  relativeFilePath: true,
@@ -117,11 +122,16 @@ export function matcher(filter) {
117
122
  const grep = pattern(checked.grep);
118
123
  const only = flag(checked.only, 'filter.only');
119
124
  const withoutReason = flag(checked.withoutReason, 'filter.withoutReason');
125
+ // Written as a shell or Windows may write them, as `./pages/order-ticket.ts` or `pages\\order-ticket.ts`.
126
+ const related = checked.related === undefined
127
+ ? null
128
+ : new Set(list(checked.related, 'filter.related', null).map((file) => path.posix.normalize(file.replaceAll('\\', '/'))));
120
129
  return (test) => (states.length === 0 || states.includes(test.state)) &&
121
130
  (tags.length === 0 || test.tags.some((tag) => tags.includes(tag))) &&
122
131
  (grep === null || grep.test(test.fullName)) &&
123
132
  (!only || test.isOnly) &&
124
- (!withoutReason || (!test.isActive && !test.isNotLoaded && test.reason === null));
133
+ (!withoutReason || (!test.isActive && !test.isNotLoaded && test.reason === null)) &&
134
+ (related === null || test.hasUnfollowedDependencies || test.dependencies.some((file) => related.has(file)));
125
135
  }
126
136
  /**
127
137
  * Keeps the tests that pass every condition of the filter; a list keeps a test that has any of its values. The
@@ -116,6 +116,11 @@ export declare function readOptions(file: FileContext, scopes: readonly Node[],
116
116
  export declare function unresolvedOptions(file: FileContext, options: Options, result: string): Problem[];
117
117
  /** The names a function takes from the object pattern of one of its parameters, in source order. */
118
118
  export declare function destructuredNames(fn: FunctionNode | null, index: number | null): string[];
119
+ /**
120
+ * Whether a function takes one of its parameters whole, as in `(fixtures) => ...`, or keeps the rest of its object
121
+ * pattern, so that any key of it may be used.
122
+ */
123
+ export declare function takesWhole(fn: FunctionNode | null, index: number | null): boolean;
119
124
  /** A whole number, such as a timeout in milliseconds; null, with a warning, when it can't be worked out. */
120
125
  export declare function readCount(file: FileContext, name: string, option: OptionValue, problems: Problem[]): number | null;
121
126
  /** A warning for one option whose value could not be worked out. */
@@ -287,6 +287,17 @@ export function destructuredNames(fn, index) {
287
287
  const names = parameter.properties.map((property) => (property.type === 'Property' ? propertyKey(property) : null));
288
288
  return names.filter((name) => name !== null);
289
289
  }
290
+ /**
291
+ * Whether a function takes one of its parameters whole, as in `(fixtures) => ...`, or keeps the rest of its object
292
+ * pattern, so that any key of it may be used.
293
+ */
294
+ export function takesWhole(fn, index) {
295
+ const parameter = index === null ? undefined : fn?.params[index];
296
+ if (!parameter)
297
+ return false;
298
+ return (parameter.type !== 'ObjectPattern' ||
299
+ parameter.properties.some((property) => property.type === 'RestElement' || property.computed));
300
+ }
290
301
  /** A whole number, such as a timeout in milliseconds; null, with a warning, when it can't be worked out. */
291
302
  export function readCount(file, name, option, problems) {
292
303
  const { node, resolved } = option;
@@ -1,7 +1,7 @@
1
1
  import { childNodes, isFunctionNode, propertyKey, propertyName, unwrap } from "../ast.js";
2
2
  import { snippet } from "../source.js";
3
3
  import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, ModuleFunctions, requiredModule, } from "./bindings.js";
4
- import { atLeast, findFunction, hookFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, destructuredNames, readCondition, readCount, readOptions, readReason, readStrings, readTitle, refused, returnedFunction, returnedTestObject, text, unresolvedOption, unresolvedOptions, } from "./common.js";
4
+ import { atLeast, findFunction, hookFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, destructuredNames, takesWhole, readCondition, readCount, readOptions, readReason, readStrings, readTitle, refused, returnedFunction, returnedTestObject, text, unresolvedOption, unresolvedOptions, } from "./common.js";
5
5
  const MISMATCHED_SOURCES = new Set(['vitest', '@jest/globals']);
6
6
  // Maps rather than object literals, so names such as `constructor` don't match inherited properties.
7
7
  const DESCRIBES = new Map([
@@ -259,7 +259,14 @@ export class PlaywrightAdapter {
259
259
  return this.configure(call, context);
260
260
  if (HOOKS.has(key)) {
261
261
  const fn = hookFunction(this.file, context.scopes, call.arguments);
262
- return { kind: 'hook', name: key, fn, testContext: null, fixtures: destructuredNames(fn, 0) };
262
+ return {
263
+ kind: 'hook',
264
+ name: key,
265
+ fn,
266
+ testContext: null,
267
+ fixtures: destructuredNames(fn, 0),
268
+ takesAllFixtures: takesWhole(fn, 0),
269
+ };
263
270
  }
264
271
  if (RUN_MEMBERS.has(key))
265
272
  return this.runSetting(call, key, context);
@@ -475,6 +482,7 @@ export class PlaywrightAdapter {
475
482
  caseCount: null,
476
483
  testContext: null,
477
484
  fixtures: destructuredNames(hashed, 0),
485
+ takesAllFixtures: takesWhole(hashed, 0),
478
486
  usesBench: false,
479
487
  problems,
480
488
  };
@@ -1,7 +1,7 @@
1
1
  import { childNodes, isFunctionNode, isImportMetaVitest, propertyKey, propertyName, unwrap, } from "../ast.js";
2
2
  import { snippet } from "../source.js";
3
3
  import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, ModuleFunctions, requiredModule, } from "./bindings.js";
4
- import { atLeast, hookFunction, isTestName, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, origin, problem, readChain, readCondition, returnedFunction, returnedTestObject, destructuredNames, readCount, readOptions, readStrings, isTitleLiteral, readReason, namedFunction, unresolvedOption, unresolvedOptions, readTitle, refused, text, } from "./common.js";
4
+ import { atLeast, hookFunction, isTestName, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, origin, problem, readChain, readCondition, returnedFunction, returnedTestObject, destructuredNames, takesWhole, readCount, readOptions, readStrings, isTitleLiteral, readReason, namedFunction, unresolvedOption, unresolvedOptions, readTitle, refused, text, } from "./common.js";
5
5
  /** What a test function chosen at run time, as in `isCI ? it.skip : it`, is either way: only the links both have. */
6
6
  function shared(a, b) {
7
7
  if (!a || a.kind !== b?.kind)
@@ -528,6 +528,7 @@ export class VitestAdapter {
528
528
  fn,
529
529
  testContext: index === null ? null : contextNames(fn, index),
530
530
  fixtures: fixturesOf(fn, index, this.file.version),
531
+ takesAllFixtures: takesWhole(fn, index),
531
532
  };
532
533
  }
533
534
  contextSkip(call, context) {
@@ -701,6 +702,7 @@ export class VitestAdapter {
701
702
  ...base,
702
703
  testContext: contextNames(base.hashed, index),
703
704
  fixtures: fixturesOf(base.hashed, index, this.file.version),
705
+ takesAllFixtures: takesWhole(base.hashed, index),
704
706
  usesBench: atLeast(this.file.version, 5, 0) && destructuredNames(base.hashed, index).includes('bench'),
705
707
  };
706
708
  }
package/dist/hash.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Node } from 'oxc-parser';
2
2
  import type { Span } from './model.ts';
3
- export declare function shortHash(input: string): string;
3
+ export declare function shortHash(input: string | Uint8Array): string;
4
4
  /**
5
5
  * Hashes code by its syntax tree, so formatting, comments, quote style, trailing commas and line endings don't change
6
6
  * the hash. Nodes at the `cut` spans, such as the tests inside a describe, are left out. Returns null when no code is
package/dist/model.d.ts CHANGED
@@ -86,6 +86,8 @@ export interface TestDeclaration extends Declaration {
86
86
  testContext: unknown;
87
87
  /** Names the test function takes from its context, without the framework's own keys. */
88
88
  fixtures: string[];
89
+ /** The test function takes its fixtures whole, or keeps the rest of them, so it may use any. */
90
+ takesAllFixtures: boolean;
89
91
  /** Uses Vitest 5's `bench` fixture. */
90
92
  usesBench: boolean;
91
93
  }
@@ -128,6 +130,7 @@ export type ApiCall = TestDeclaration | SuiteDeclaration | {
128
130
  fn: FunctionNode | null;
129
131
  testContext: unknown;
130
132
  fixtures: string[];
133
+ takesAllFixtures: boolean;
131
134
  }
132
135
  /** Playwright `test.step()` or `test.step.skip()`. */
133
136
  | {
package/dist/records.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { type Extraction } from './extract.ts';
1
+ import { type Extraction, type PendingTest } from './extract.ts';
2
+ import type { Dependencies } from './dependency-hash.ts';
2
3
  import type { RunSettings } from './model.ts';
3
4
  import type { ParsedFile } from './parse.ts';
4
5
  import type { Diagnostic, FileRecord, Framework, HookCounts, MetaValue, SuiteRecord, TestRecord } from './types.ts';
@@ -69,6 +70,8 @@ interface RecordOptions {
69
70
  /** A Vitest 5 benchmark file, such as `*.bench.ts`. */
70
71
  isBenchmarkFile: boolean;
71
72
  config: FileConfig | null;
73
+ /** What a test runs, for its `dependencyHash`. */
74
+ dependencies: (test: PendingTest) => Dependencies;
72
75
  }
73
76
  /** The record of a scanned file, from its records and the hooks at its top level. */
74
77
  export declare function fileRecord(relativeFilePath: string, hooks: HookCounts, records: Pick<FileRecords, 'suites' | 'tests'>): FileRecord;
package/dist/records.js CHANGED
@@ -334,6 +334,7 @@ class RecordBuilder {
334
334
  }
335
335
  test(pending, suites) {
336
336
  const { declaration } = pending;
337
+ const dependencies = this.options.dependencies(pending);
337
338
  // A test from an imported helper is placed at the call in this file that registers it.
338
339
  const located = pending.site ?? pending.call;
339
340
  const scopes = outward(pending.scope);
@@ -396,6 +397,9 @@ class RecordBuilder {
396
397
  const record = {
397
398
  id,
398
399
  bodyHash: declaration.hashed ? hashNodes([declaration.hashed]) : null,
400
+ dependencyHash: dependencies.hash,
401
+ dependencies: dependencies.files,
402
+ hasUnfollowedDependencies: !dependencies.isComplete,
399
403
  suiteId: describes.length > 0 ? this.suiteIds.get(describes[describes.length - 1]) : null,
400
404
  suitePath,
401
405
  relativeFilePath: this.options.relativeFilePath,
@@ -1,3 +1,5 @@
1
+ import type { RunsWith } from './config-dependencies.ts';
2
+ import { DependencyGraph, type SnapshotFiles } from './dependency-hash.ts';
1
3
  import type { ModuleReader } from './model.ts';
2
4
  import { type CommentsMode, type FileConfig, type FileRecords } from './records.ts';
3
5
  import type { Framework } from './types.ts';
@@ -13,6 +15,12 @@ export interface FileSettings {
13
15
  isBenchmarkFile?: boolean | null;
14
16
  /** What the config the file uses sets for its tests; null without a config. */
15
17
  config?: FileConfig | null;
18
+ /** Shared by the files of one scan, so that code many tests reach is read once. */
19
+ graph?: DependencyGraph;
20
+ /** What the file's config runs with each of its tests: the config, setup files and setup projects. */
21
+ runsWith?: RunsWith;
22
+ /** Reads the snapshot files tests compare against; none when one file's code is scanned. */
23
+ snapshotFiles?: SnapshotFiles;
16
24
  }
17
25
  export interface FileResult extends FileRecords {
18
26
  usesGlobals: boolean;
package/dist/scan-file.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { DependencyGraph, fileDependencies } from "./dependency-hash.js";
1
2
  import { fileDiagnostic, toDiagnostic } from "./diagnostics.js";
2
3
  import { extract, noHooks } from "./extract.js";
3
4
  import { parseModule } from "./frameworks/bindings.js";
@@ -39,20 +40,28 @@ export function scanFile(code, relativeFilePath, settings) {
39
40
  const helper = parseModule(module);
40
41
  return helper.error ? null : frame(helper, module.relativeFilePath, settings, module.readModule);
41
42
  });
43
+ const isTypeTest = settings.framework === 'vitest' && TYPE_TEST.test(relativeFilePath);
44
+ const unfollowed = [];
45
+ const dependencies = fileDependencies(settings.graph ?? new DependencyGraph(), main, extraction, isTypeTest, (place) => unfollowed.push(place), settings.runsWith, settings.snapshotFiles);
42
46
  const records = buildRecords(extraction, parsed, {
43
47
  relativeFilePath,
44
48
  framework: settings.framework,
45
49
  comments: settings.comments,
46
- isTypeTest: settings.framework === 'vitest' && TYPE_TEST.test(relativeFilePath),
50
+ isTypeTest,
47
51
  // Vitest 5's default `benchmark.include`, when no config says.
48
52
  isBenchmarkFile: settings.framework === 'vitest' &&
49
53
  atLeast(settings.version, 5, 0) &&
50
54
  (settings.isBenchmarkFile ?? BENCHMARK.test(relativeFilePath)),
51
55
  config: settings.config ?? null,
56
+ dependencies,
52
57
  });
53
58
  return {
54
59
  ...records,
55
- diagnostics: [...diagnostics, ...records.diagnostics],
60
+ diagnostics: [
61
+ ...diagnostics,
62
+ ...records.diagnostics,
63
+ ...unfollowed.map((place) => toDiagnostic(place.problem, place.relativeFilePath, place.source)),
64
+ ],
56
65
  usesGlobals: extraction.usesGlobals,
57
66
  };
58
67
  }
package/dist/scan.js CHANGED
@@ -1,13 +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";
4
6
  import { ConfigRuns, fileConfig } from "./config-runs.js";
5
7
  import { Configs } from "./config.js";
8
+ import { DependencyGraph } from "./dependency-hash.js";
6
9
  import { fileDiagnostic } from "./diagnostics.js";
7
10
  import { findFiles, normalizePattern, readFailure } from "./files.js";
8
11
  import { detectFrameworkVersion, OLDEST } from "./framework-version.js";
9
12
  import { createInventory } from "./inventory.js";
10
- import { checkComments, checkFramework, checkObject, checkStrings, checkVersion, invalid, majorMinor, } from "./options.js";
13
+ import { checkComments, checkFramework, checkObject, checkStrings, checkVersion, invalid, majorMinor, toPosixPath, } from "./options.js";
11
14
  import { moduleResolver } from "./resolve.js";
12
15
  import { noRecords, scanFile } from "./scan-file.js";
13
16
  import { globalsCheck } from "./vitest-config.js";
@@ -50,6 +53,43 @@ function folderHint(root, patterns, framework) {
50
53
  }
51
54
  /** The JavaScript and TypeScript files the runners read, which the configs choose the test files from. */
52
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
+ }
53
93
  /** Why a scan found no test files: what the patterns matched, or whether there is a config to find them from. */
54
94
  function noFiles(root, patterns, framework, placements) {
55
95
  if (patterns) {
@@ -94,10 +134,37 @@ export async function scan(options) {
94
134
  const found = await findFiles(root, patterns ?? [SCRIPT_FILES], ignore, framework);
95
135
  projectDiagnostics.push(...found.diagnostics);
96
136
  const readerFor = moduleResolver(root);
137
+ const graph = new DependencyGraph();
97
138
  const configs = new Configs(root, framework, readerFor, rules);
98
139
  const runs = new ConfigRuns(configs, framework, rules, patterns === null);
99
140
  const placements = new Map(found.files.map((file) => [file.absolutePath, runs.placementOf(file.absolutePath)]));
100
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) };
101
168
  const notRun = [];
102
169
  const results = [];
103
170
  const globalsFiles = [];
@@ -121,6 +188,7 @@ export async function scan(options) {
121
188
  });
122
189
  continue;
123
190
  }
191
+ const runsWith = configDependencies.of(placement);
124
192
  const result = scanFile(code, relativeFilePath, {
125
193
  framework,
126
194
  version: rules,
@@ -129,6 +197,9 @@ export async function scan(options) {
129
197
  root,
130
198
  isBenchmarkFile: placement.runs === null ? null : placement.runs === 'benchmark',
131
199
  config: fileConfig(placement, framework, rules, absolutePath),
200
+ graph,
201
+ runsWith,
202
+ snapshotFiles: { ...snapshotFiles, moved: runsWith.moved },
132
203
  });
133
204
  if (result.usesGlobals)
134
205
  globalsFiles.push({ absolutePath, relativeFilePath });
package/dist/types.d.ts CHANGED
@@ -38,7 +38,7 @@ export type SettingSource = 'test' | 'suite' | 'file' | 'config';
38
38
  * @inline
39
39
  */
40
40
  export type DiagnosticLevel = 'error' | 'warning';
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';
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';
42
42
  export type MetaValue = string | number | boolean | null | MetaValue[] | {
43
43
  [key: string]: MetaValue;
44
44
  };
@@ -250,6 +250,16 @@ export interface TestRecord {
250
250
  id: Hash;
251
251
  /** Hash of the test body, ignoring whitespace and comments; null when the test has no body. */
252
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;
253
263
  /** The nearest describe; null outside any describe. */
254
264
  suiteId: Hash | null;
255
265
  /** Titles of the describes around the test, outermost first. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "test-inventory",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Test metadata from source code, not from test results.",
5
5
  "keywords": [
6
6
  "testing",
package/schema/v1.json CHANGED
@@ -754,6 +754,22 @@
754
754
  ],
755
755
  "description": "Hash of the test body, ignoring whitespace and comments; null when the test has no body."
756
756
  },
757
+ "dependencyHash": {
758
+ "type": "string",
759
+ "pattern": "^[0-9a-f]{16}$",
760
+ "description": "Hash of everything the test runs in the project: its call, the hooks, options and code of the file and of every describe around it, and the functions, classes, constants and modules these use, followed through imports. Ignores whitespace and comments. Installed packages are not in it."
761
+ },
762
+ "dependencies": {
763
+ "type": "array",
764
+ "items": {
765
+ "type": "string"
766
+ },
767
+ "description": "The files that went into `dependencyHash`, the test's own included, sorted, with `/` separators."
768
+ },
769
+ "hasUnfollowedDependencies": {
770
+ "type": "boolean",
771
+ "description": "Something the test reaches couldn't be followed, so a change there doesn't change `dependencyHash`."
772
+ },
757
773
  "suiteId": {
758
774
  "anyOf": [
759
775
  {
@@ -1160,6 +1176,9 @@
1160
1176
  "required": [
1161
1177
  "id",
1162
1178
  "bodyHash",
1179
+ "dependencyHash",
1180
+ "dependencies",
1181
+ "hasUnfollowedDependencies",
1163
1182
  "suiteId",
1164
1183
  "suitePath",
1165
1184
  "relativeFilePath",
@@ -1426,7 +1445,8 @@
1426
1445
  "bench-outside-benchmark-file",
1427
1446
  "config-value-unknown",
1428
1447
  "file-not-run",
1429
- "forbidden-only"
1448
+ "forbidden-only",
1449
+ "dependency-not-followed"
1430
1450
  ]
1431
1451
  },
1432
1452
  {