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/config.js ADDED
@@ -0,0 +1,652 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import { childNodes, isFunctionNode, propertyKey, propertyName, returnValues, unwrap, } from "./ast.js";
5
+ import { fileDiagnostic, toDiagnostic } from "./diagnostics.js";
6
+ import { readFailure } from "./files.js";
7
+ import { exportName, moduleExportsValue, ownExport, parseModule, requiredModule, starReexport, } from "./frameworks/bindings.js";
8
+ import { problem } from "./frameworks/common.js";
9
+ import { parseFile } from "./parse.js";
10
+ import { snippet } from "./source.js";
11
+ import { isKey, isScopeNode, Values } from "./values.js";
12
+ import { VITEST_INCLUDE, VITEST_TYPECHECK_INCLUDE, vitestExclude } from "./vitest-defaults.js";
13
+ /** Config file names in the order each runner looks for them in a folder. */
14
+ const CONFIG_FILES = {
15
+ playwright: ['.ts', '.js', '.mts', '.mjs', '.cts', '.cjs'].map((extension) => `playwright.config${extension}`),
16
+ vitest: ['vitest.config', 'vite.config'].flatMap((name) => ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs'].map((extension) => name + extension)),
17
+ };
18
+ /** Methods that change the object or list they are called on. */
19
+ const CHANGING_METHODS = new Set([
20
+ 'push',
21
+ 'pop',
22
+ 'shift',
23
+ 'unshift',
24
+ 'splice',
25
+ 'sort',
26
+ 'reverse',
27
+ 'fill',
28
+ 'copyWithin',
29
+ ]);
30
+ /**
31
+ * The changes a module makes to its constants after declaring them, by declaration: assignments to their members,
32
+ * `delete`, `++`, methods such as `push()` and `Object.assign()`, as a config that fills `projects` in a loop does.
33
+ */
34
+ function changesIn(parsed, values) {
35
+ const changes = new Map();
36
+ const scopes = [];
37
+ const record = (target, node) => {
38
+ let expression = unwrap(target);
39
+ let key = null;
40
+ while (expression.type === 'MemberExpression') {
41
+ key = propertyName(expression);
42
+ expression = unwrap(expression.object);
43
+ }
44
+ const declaration = expression.type === 'Identifier' ? values.declarationOf(expression.name, scopes) : null;
45
+ if (declaration)
46
+ changes.set(declaration, [...(changes.get(declaration) ?? []), { key, node }]);
47
+ };
48
+ const visit = (node) => {
49
+ const isScope = isScopeNode(node);
50
+ if (isScope)
51
+ scopes.push(node);
52
+ if (node.type === 'AssignmentExpression' && node.left.type === 'MemberExpression')
53
+ record(node.left, node);
54
+ if (node.type === 'UpdateExpression' || (node.type === 'UnaryExpression' && node.operator === 'delete')) {
55
+ record(node.argument, node);
56
+ }
57
+ if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') {
58
+ const method = propertyName(node.callee);
59
+ const isAssign = method === 'assign' && node.callee.object.type === 'Identifier' && node.callee.object.name === 'Object';
60
+ if (isAssign && node.arguments[0] && node.arguments[0].type !== 'SpreadElement')
61
+ record(node.arguments[0], node);
62
+ else if (CHANGING_METHODS.has(method ?? ''))
63
+ record(node.callee.object, node);
64
+ }
65
+ for (const child of childNodes(node))
66
+ visit(child);
67
+ if (isScope)
68
+ scopes.pop();
69
+ };
70
+ visit(parsed.program);
71
+ return changes;
72
+ }
73
+ /** The functions configs are written with, which return the config they are given or merge the ones they are given. */
74
+ const CONFIG_FUNCTIONS = new Set(['defineConfig', 'defineProject', 'mergeConfig']);
75
+ /**
76
+ * Node.js path functions a config builds its folders with, given the folder the runner starts in: the root, which
77
+ * `path.resolve()` resolves a relative path from.
78
+ */
79
+ const PATH_FUNCTIONS = {
80
+ 'path.join': (_, ...parts) => path.join(...parts),
81
+ 'path.resolve': (root, ...parts) => path.resolve(root, ...parts),
82
+ 'path.dirname': (_, part) => path.dirname(part),
83
+ 'url.fileURLToPath': (_, url) => fileURLToPath(url),
84
+ };
85
+ /** What `read` returns, or null when it throws, as `fileURLToPath()` does for a URL that isn't a file's. */
86
+ function attempt(read) {
87
+ try {
88
+ return read();
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ function unknown(where) {
95
+ return { kind: 'unknown', where };
96
+ }
97
+ function emptyObject() {
98
+ return { kind: 'object', keys: new Map(), rest: null };
99
+ }
100
+ function fromValue(value) {
101
+ if (Array.isArray(value))
102
+ return { kind: 'list', items: value.map(fromValue) };
103
+ if (typeof value === 'object' && value !== null) {
104
+ const keys = new Map(Object.entries(value).map(([key, item]) => [key, fromValue(item)]));
105
+ return { kind: 'object', keys, rest: null };
106
+ }
107
+ return { kind: 'value', value };
108
+ }
109
+ /** The source text of a setting the scan can't work out; null for one of a config it couldn't read at all. */
110
+ export function unknownText(setting) {
111
+ const { where } = setting;
112
+ return where && snippet(where.frame.parsed.source.text.slice(where.node.start, where.node.end));
113
+ }
114
+ /** The setting under a key; null when the config doesn't set it. */
115
+ function child(setting, key) {
116
+ if (setting.kind === 'unknown')
117
+ return setting;
118
+ if (setting.kind !== 'object')
119
+ return null;
120
+ return setting.keys.get(key) ?? setting.rest;
121
+ }
122
+ /** The setting under a path of keys, such as `['test', 'globals']`; null when the config doesn't set it. */
123
+ export function settingAt(setting, keys) {
124
+ return keys.reduce((current, key) => (current === null ? null : child(current, key)), setting);
125
+ }
126
+ function isNullish(setting) {
127
+ return setting.kind === 'value' && (setting.value === null || setting.value === undefined);
128
+ }
129
+ /**
130
+ * `{ ...base, ...over }`: each key `over` sets replaces the one in `base`, and a spread of `over` that the scan can't
131
+ * work out may replace any. `at` is where the spread is written. A copy when `over` sets anything.
132
+ */
133
+ function spread(base, over, at) {
134
+ // Spreading null, undefined, a boolean or a number adds nothing.
135
+ if (over.kind === 'value' && typeof over.value !== 'string')
136
+ return base;
137
+ if (over.kind !== 'object')
138
+ return { kind: 'object', keys: new Map(), rest: unknown(at) };
139
+ if (over.rest)
140
+ return { kind: 'object', keys: new Map(over.keys), rest: over.rest };
141
+ return { kind: 'object', keys: new Map([...base.keys, ...over.keys]), rest: base.rest };
142
+ }
143
+ /**
144
+ * One of two settings, as the two sides of a condition: what both agree on is known, and the rest is unknown at `at`,
145
+ * where the condition is written. Null is a setting neither side sets.
146
+ */
147
+ function either(a, b, at) {
148
+ if (a !== null && (a === b || (a.kind === 'value' && b?.kind === 'value' && a.value === b.value)))
149
+ return a;
150
+ if (a?.kind !== 'object' || b?.kind !== 'object')
151
+ return unknown(at);
152
+ const keys = new Map();
153
+ for (const key of new Set([...a.keys.keys(), ...b.keys.keys()]))
154
+ keys.set(key, either(child(a, key), child(b, key), at));
155
+ return { kind: 'object', keys, rest: a.rest === b.rest ? a.rest : unknown(at) };
156
+ }
157
+ /**
158
+ * Vite's `mergeConfig(base, over)`: objects merge key by key, lists are joined with a single setting taken as a list
159
+ * of one, and `null` or `undefined` keeps the other side.
160
+ */
161
+ export function mergeDeep(base, over) {
162
+ if (isNullish(over))
163
+ return base;
164
+ if (isNullish(base) || over.kind === 'unknown')
165
+ return over;
166
+ if (base.kind === 'list' || over.kind === 'list') {
167
+ // A list joined to one the scan can't work out can't be worked out either.
168
+ if (base.kind === 'unknown')
169
+ return base;
170
+ const items = (setting) => (setting.kind === 'list' ? setting.items : [setting]);
171
+ return { kind: 'list', items: [...items(base), ...items(over)] };
172
+ }
173
+ if (over.kind !== 'object' || (base.kind !== 'object' && base.kind !== 'unknown'))
174
+ return over;
175
+ const keys = new Map(base.kind === 'object' && !over.rest ? base.keys : []);
176
+ for (const [key, value] of over.keys) {
177
+ const existing = child(base, key);
178
+ keys.set(key, existing ? mergeDeep(existing, value) : value);
179
+ }
180
+ return { kind: 'object', keys, rest: over.rest ?? (base.kind === 'object' ? base.rest : base) };
181
+ }
182
+ /** Works out a config's settings from its code, following the modules it imports them from. */
183
+ class ConfigCode {
184
+ /** The modules read for the config besides itself, in the order they were first followed. */
185
+ followed = [];
186
+ main;
187
+ framework;
188
+ root;
189
+ version;
190
+ frames = new Map();
191
+ changes = new Map();
192
+ /** Constants and exports worked out, each once however often the config refers to it. */
193
+ done = new Map();
194
+ /** Constants and exports being worked out, to stop at one that refers back to itself. */
195
+ pending = new Set();
196
+ constructor(main, framework, root, version) {
197
+ this.main = main;
198
+ this.framework = framework;
199
+ this.root = root;
200
+ this.version = version;
201
+ }
202
+ /** What a module exports under a name; null when it doesn't export it. `site` is where the name is imported. */
203
+ exported(frame, name, site) {
204
+ const { program } = frame.parsed;
205
+ const own = ownExport(program, name);
206
+ if (own?.kind === 'default')
207
+ return this.evaluate(own.value, frame, [program]);
208
+ if (own) {
209
+ const at = { frame, node: own.statement };
210
+ const found = own.kind === 'specifier' && own.source !== null
211
+ ? this.fromModule(frame, own.source, own.local, at)
212
+ : this.named(own.kind === 'specifier' ? own.local : name, frame, [program], at);
213
+ return found ?? unknown(at);
214
+ }
215
+ if (name === 'default') {
216
+ // `export *` passes on every name but the default.
217
+ const commonJs = program.body.map(moduleExportsValue).find((value) => value !== null);
218
+ return commonJs ? this.evaluate(commonJs, frame, [program]) : null;
219
+ }
220
+ for (const statement of program.body) {
221
+ const source = starReexport(statement);
222
+ const found = source === null ? null : this.fromModule(frame, source, name, site);
223
+ if (found)
224
+ return found;
225
+ }
226
+ return null;
227
+ }
228
+ /** Works out an expression; `scopes` are the scope nodes around it, outermost first. */
229
+ evaluate(node, frame, scopes) {
230
+ const expression = unwrap(node);
231
+ const site = { frame, node: expression };
232
+ switch (expression.type) {
233
+ case 'ObjectExpression':
234
+ return this.object(expression, frame, scopes);
235
+ case 'ArrayExpression':
236
+ return this.list(expression, frame, scopes);
237
+ case 'CallExpression':
238
+ return this.call(expression, frame, scopes);
239
+ case 'NewExpression':
240
+ return this.newUrl(expression, frame, scopes);
241
+ case 'Literal':
242
+ if ('regex' in expression)
243
+ return { kind: 'regex', source: expression.regex.pattern, flags: expression.regex.flags };
244
+ break;
245
+ case 'Identifier': {
246
+ const named = this.named(expression.name, frame, scopes, site);
247
+ if (named)
248
+ return named;
249
+ // Vite and Playwright give a config `__dirname` and `__filename`, also in an ES module.
250
+ const isGlobal = frame.values.declarationOf(expression.name, scopes) === null;
251
+ if (isGlobal && expression.name === '__dirname')
252
+ return { kind: 'value', value: path.dirname(frame.file) };
253
+ if (isGlobal && expression.name === '__filename')
254
+ return { kind: 'value', value: frame.file };
255
+ break;
256
+ }
257
+ case 'MemberExpression': {
258
+ if (expression.object.type === 'MetaProperty')
259
+ return this.importMeta(propertyName(expression), frame, site);
260
+ // A member of an object written in the config, as in `shared.test`, keeps what the scan can't work out in it.
261
+ const name = propertyName(expression);
262
+ if (name === null)
263
+ break;
264
+ const object = this.evaluate(expression.object, frame, scopes);
265
+ const found = object.kind === 'object' ? child(object, name) : null;
266
+ if (found)
267
+ return found;
268
+ break;
269
+ }
270
+ }
271
+ const resolved = frame.values.resolve(expression, scopes);
272
+ return resolved.ok ? fromValue(resolved.value) : unknown(site);
273
+ }
274
+ /**
275
+ * What a name refers to: a constant written in the module, or what another module exports under the imported name;
276
+ * null for any other name, whose value the file's own evaluation works out if it can.
277
+ */
278
+ named(name, frame, scopes, site) {
279
+ const constant = frame.values.constantOf(name, scopes);
280
+ const declaration = frame.values.declarationOf(name, scopes);
281
+ if (constant) {
282
+ const setting = this.once(constant.init, site, () => this.evaluate(constant.init, frame, constant.scopes));
283
+ return this.changed(setting, frame, declaration);
284
+ }
285
+ if (declaration?.type !== 'ImportSpecifier' && declaration?.type !== 'ImportDefaultSpecifier')
286
+ return null;
287
+ const imported = declaration.type === 'ImportSpecifier' ? exportName(declaration.imported) : 'default';
288
+ const source = frame.values.importSource(name, scopes);
289
+ if (source === 'vitest/config' || source === 'vitest/node') {
290
+ const defaults = this.vitestDefaults(imported, site);
291
+ if (defaults)
292
+ return defaults;
293
+ }
294
+ return this.fromModule(frame, source, imported, site) ?? unknown(site);
295
+ }
296
+ /**
297
+ * The defaults Vitest exports from `vitest/config`, for the version of the project: `defaultInclude`,
298
+ * `defaultExclude`, and of `configDefaults` the file settings, the others being unknown at `site`.
299
+ */
300
+ vitestDefaults(name, site) {
301
+ const exclude = vitestExclude(this.version);
302
+ if (name === 'defaultInclude')
303
+ return fromValue(VITEST_INCLUDE);
304
+ if (name === 'defaultExclude')
305
+ return fromValue(exclude);
306
+ if (name !== 'configDefaults')
307
+ return null;
308
+ const typecheck = fromValue({ include: VITEST_TYPECHECK_INCLUDE, exclude });
309
+ const defaults = fromValue({ include: VITEST_INCLUDE, exclude, includeSource: [] });
310
+ defaults.keys.set('typecheck', { ...typecheck, rest: unknown(site) });
311
+ return { ...defaults, rest: unknown(site) };
312
+ }
313
+ /**
314
+ * A constant's setting as the module leaves it: a key it changes later is unknown there, and all of it when the
315
+ * module changes the whole object or a list.
316
+ */
317
+ changed(setting, frame, declaration) {
318
+ let found = this.changes.get(frame);
319
+ if (!found) {
320
+ found = changesIn(frame.parsed, frame.values);
321
+ this.changes.set(frame, found);
322
+ }
323
+ const changes = found.get(declaration) ?? [];
324
+ if (changes.length === 0)
325
+ return setting;
326
+ // A change to a list or a value changes all of it.
327
+ if (setting.kind !== 'object')
328
+ return unknown({ frame, node: changes[0].node });
329
+ const whole = changes.find((change) => change.key === null);
330
+ if (whole)
331
+ return unknown({ frame, node: whole.node });
332
+ const keys = new Map(setting.keys);
333
+ for (const { key, node } of changes)
334
+ keys.set(key, unknown({ frame, node }));
335
+ return { ...setting, keys };
336
+ }
337
+ /** What a module an import names exports under a name; null when the module doesn't export it. */
338
+ fromModule(frame, specifier, name, site) {
339
+ const module = this.open(frame, specifier);
340
+ if (!module)
341
+ return unknown(site);
342
+ return this.once(`${module.relativeFilePath}\0${name}`, site, () => this.exported(module, name, site));
343
+ }
344
+ once(key, site, read) {
345
+ if (this.done.has(key))
346
+ return this.done.get(key);
347
+ if (this.pending.has(key))
348
+ return unknown(site);
349
+ this.pending.add(key);
350
+ const result = read();
351
+ this.pending.delete(key);
352
+ this.done.set(key, result);
353
+ return result;
354
+ }
355
+ /** A module a config file imports, read once; null when it isn't a local file or can't be parsed. */
356
+ open(frame, specifier) {
357
+ const module = frame.readModule(specifier);
358
+ if ('reason' in module)
359
+ return null;
360
+ let opened = this.frames.get(module);
361
+ if (!opened) {
362
+ const parsed = parseModule(module);
363
+ if (parsed.error)
364
+ return null;
365
+ opened = {
366
+ parsed,
367
+ values: new Values(),
368
+ relativeFilePath: module.relativeFilePath,
369
+ file: path.resolve(this.root, module.relativeFilePath),
370
+ readModule: module.readModule,
371
+ };
372
+ this.frames.set(module, opened);
373
+ if (module.relativeFilePath !== this.main.relativeFilePath)
374
+ this.followed.push(module.relativeFilePath);
375
+ }
376
+ return opened;
377
+ }
378
+ object(node, frame, scopes) {
379
+ let object = emptyObject();
380
+ for (const property of node.properties) {
381
+ if (property.type === 'SpreadElement') {
382
+ object = this.spreadInto(object, property.argument, frame, scopes);
383
+ continue;
384
+ }
385
+ const key = !property.computed && property.key.type === 'Identifier'
386
+ ? { ok: true, value: property.key.name }
387
+ : frame.values.resolve(property.key, scopes);
388
+ if (!key.ok || !isKey(key.value)) {
389
+ // The key could be any name, so it may replace any setting written before it.
390
+ object = { kind: 'object', keys: new Map(), rest: unknown({ frame, node: property.key }) };
391
+ continue;
392
+ }
393
+ // A method or getter is a function, which the scan can't work out.
394
+ object.keys.set(String(key.value), this.evaluate(property.value, frame, scopes));
395
+ }
396
+ return object;
397
+ }
398
+ /**
399
+ * `{ ...object, ...argument }`, also for an argument that picks between two objects, as in
400
+ * `...(process.env.CI ? { retry: 2 } : {})` or `...(process.env.CI && { retry: 2 })`.
401
+ */
402
+ spreadInto(object, argument, frame, scopes) {
403
+ const node = unwrap(argument);
404
+ const at = { frame, node };
405
+ const spreadOf = (expression) => spread(object, this.evaluate(expression, frame, scopes), at);
406
+ if (node.type === 'ConditionalExpression') {
407
+ return either(spreadOf(node.consequent), spreadOf(node.alternate), at);
408
+ }
409
+ // When the left side is false, so is the whole, and spreading it adds nothing.
410
+ if (node.type === 'LogicalExpression' && node.operator === '&&') {
411
+ return either(object, spreadOf(node.right), at);
412
+ }
413
+ return spreadOf(node);
414
+ }
415
+ list(node, frame, scopes) {
416
+ const items = [];
417
+ for (const element of node.elements) {
418
+ if (element === null)
419
+ return unknown({ frame, node });
420
+ if (element.type !== 'SpreadElement') {
421
+ items.push(this.evaluate(element, frame, scopes));
422
+ continue;
423
+ }
424
+ const spreadList = this.evaluate(element.argument, frame, scopes);
425
+ if (spreadList.kind !== 'list')
426
+ return unknown({ frame, node: element });
427
+ items.push(...spreadList.items);
428
+ }
429
+ return { kind: 'list', items };
430
+ }
431
+ /** `import.meta.dirname`, `filename` and `url` of the file. */
432
+ importMeta(name, frame, site) {
433
+ if (name === 'dirname')
434
+ return { kind: 'value', value: path.dirname(frame.file) };
435
+ if (name === 'filename')
436
+ return { kind: 'value', value: frame.file };
437
+ return name === 'url' ? { kind: 'value', value: pathToFileURL(frame.file).href } : unknown(site);
438
+ }
439
+ /** `new URL(path, base)`, as in `new URL('./e2e', import.meta.url)`, as the URL's text. */
440
+ newUrl(node, frame, scopes) {
441
+ const isUrl = node.callee.type === 'Identifier' && node.callee.name === 'URL';
442
+ const parts = isUrl ? this.strings(node.arguments, frame, scopes) : null;
443
+ const url = parts && URL.parse(parts[0], parts[1]);
444
+ return url ? { kind: 'value', value: url.href } : unknown({ frame, node });
445
+ }
446
+ /** The values of arguments that are all strings; null when one isn't, or is a spread. */
447
+ strings(args, frame, scopes) {
448
+ const values = args.map((arg) => (arg.type === 'SpreadElement' ? null : this.evaluate(arg, frame, scopes)));
449
+ return values.every((value) => value?.kind === 'value' && typeof value.value === 'string')
450
+ ? values.map((value) => value.value)
451
+ : null;
452
+ }
453
+ /**
454
+ * A call of `defineConfig()`, `defineProject()` or `mergeConfig()`, or of a Node.js path function such as
455
+ * `path.join(__dirname, 'e2e')`; any other call can't be worked out.
456
+ */
457
+ call(call, frame, scopes) {
458
+ const site = { frame, node: call };
459
+ const origin = this.origin(unwrap(call.callee), frame, scopes);
460
+ const pathFunction = origin && PATH_FUNCTIONS[`${origin.source.replace(/^node:/, '')}.${origin.name}`];
461
+ if (pathFunction) {
462
+ const parts = this.strings(call.arguments, frame, scopes);
463
+ const file = parts && attempt(() => pathFunction(this.root, ...parts));
464
+ return file === null ? unknown(site) : { kind: 'value', value: file };
465
+ }
466
+ const isPackage = origin !== null && (origin.source === '' || !/^[./#]/.test(origin.source));
467
+ const name = isPackage && CONFIG_FUNCTIONS.has(origin.name) ? origin.name : null;
468
+ const { arguments: given } = call;
469
+ const needed = name === 'mergeConfig' ? 2 : 1;
470
+ if (name === null || given.length < needed || given.some((arg) => arg.type === 'SpreadElement')) {
471
+ return unknown(site);
472
+ }
473
+ const args = given;
474
+ if (name === 'mergeConfig') {
475
+ const [base, over] = args.map((arg) => this.evaluate(arg, frame, scopes));
476
+ return mergeDeep(base, over);
477
+ }
478
+ if (this.framework === 'vitest') {
479
+ // Vitest's defineConfig() and defineProject() return the config they are given, or the one a function returns.
480
+ return isFunctionNode(args[0]) ? this.returned(args[0], frame, scopes) : this.evaluate(args[0], frame, scopes);
481
+ }
482
+ // Playwright's defineConfig() merges the configs it is given, each over the ones before it. It also merges their
483
+ // `use`, `expect`, `build`, `webServer` and `projects`, which the scan doesn't read yet.
484
+ return args.reduce((merged, arg) => spread(merged, this.evaluate(arg, frame, scopes), { frame, node: arg }), emptyObject());
485
+ }
486
+ /**
487
+ * Where a function a config calls comes from: `name` of `import { name } from 'module'` or
488
+ * `const { name } = require('module')`, `module.name` of a default or namespace import or a `require()`, or a global;
489
+ * null for one the config declares itself.
490
+ */
491
+ origin(callee, frame, scopes) {
492
+ const member = callee.type === 'MemberExpression' ? propertyName(callee) : null;
493
+ const base = member === null ? callee : unwrap(callee.object);
494
+ if (base.type !== 'Identifier')
495
+ return null;
496
+ const declaration = frame.values.declarationOf(base.name, scopes);
497
+ if (declaration === null)
498
+ return member === null ? { source: '', name: base.name } : null;
499
+ const source = declaration.type === 'VariableDeclarator'
500
+ ? declaration.init && requiredModule(declaration.init)
501
+ : frame.values.importSource(base.name, scopes);
502
+ if (!source)
503
+ return null;
504
+ if (member !== null) {
505
+ const whole = declaration.type === 'ImportDefaultSpecifier' || declaration.type === 'ImportNamespaceSpecifier';
506
+ return whole || (declaration.type === 'VariableDeclarator' && declaration.id.type === 'Identifier')
507
+ ? { source, name: member }
508
+ : null;
509
+ }
510
+ if (declaration.type === 'ImportSpecifier')
511
+ return { source, name: exportName(declaration.imported) };
512
+ if (declaration.type !== 'VariableDeclarator' || declaration.id.type !== 'ObjectPattern')
513
+ return null;
514
+ for (const item of declaration.id.properties) {
515
+ const isBinding = item.type === 'Property' && item.value.type === 'Identifier' && item.value.name === base.name;
516
+ const key = isBinding ? propertyKey(item) : null;
517
+ if (key !== null)
518
+ return { source, name: key };
519
+ }
520
+ return null;
521
+ }
522
+ /** What a config function returns, when it has a single `return` the scan can follow. */
523
+ returned(fn, frame, scopes) {
524
+ const values = returnValues(fn, true);
525
+ if (values?.length !== 1)
526
+ return unknown({ frame, node: fn });
527
+ const [{ expression, around }] = values;
528
+ return this.evaluate(expression, frame, [...scopes, fn, ...around.filter(isScopeNode)]);
529
+ }
530
+ }
531
+ /** Finds and reads the configs of one scan, and collects what it can't work out in them as diagnostics. */
532
+ export class Configs {
533
+ diagnostics = [];
534
+ root;
535
+ framework;
536
+ readerFor;
537
+ inFolders = new Map();
538
+ files = new Map();
539
+ used = new Set();
540
+ reported = new Set();
541
+ version;
542
+ constructor(root, framework, readerFor, version) {
543
+ this.root = root;
544
+ this.framework = framework;
545
+ this.readerFor = readerFor;
546
+ this.version = version;
547
+ }
548
+ /**
549
+ * The configs in a file's folder and the folders above it, up to the root, outermost first: in each folder, the one
550
+ * the runner started there would use.
551
+ */
552
+ around(file) {
553
+ const configs = [];
554
+ for (let folder = path.dirname(file);; folder = path.dirname(folder)) {
555
+ const config = this.inFolder(folder);
556
+ if (config)
557
+ configs.unshift(config);
558
+ if (folder === this.root || path.dirname(folder) === folder)
559
+ return configs;
560
+ }
561
+ }
562
+ /** The config at a path, read once however many files or projects refer to it. */
563
+ at(file) {
564
+ let config = this.files.get(file);
565
+ if (!config) {
566
+ config = this.read(file);
567
+ this.files.set(file, config);
568
+ }
569
+ return config;
570
+ }
571
+ /** A path from the root, with `/` separators; `.` for the root itself. */
572
+ relative(file) {
573
+ return path.relative(this.root, file).replaceAll('\\', '/') || '.';
574
+ }
575
+ /** Lists a config in the output, as one that scanned files use. */
576
+ use(config) {
577
+ this.used.add(config);
578
+ }
579
+ /** The configs that scanned files use, sorted by path, with the projects `projectsOf` gives each. */
580
+ records(projectsOf) {
581
+ // The default sort compares UTF-16 code units, so the order is the same on every machine and locale.
582
+ const byPath = new Map([...this.used].map((config) => [config.record.relativeFilePath, config]));
583
+ return [...byPath.keys()].sort().map((key) => {
584
+ const config = byPath.get(key);
585
+ return { ...config.record, projects: projectsOf(config) };
586
+ });
587
+ }
588
+ /** Reports a setting the scan reads but can't work out, once; `key` names it, and `effect` says what the scan does. */
589
+ reportUnknown(setting, key, effect) {
590
+ if (setting.where === null)
591
+ return;
592
+ const { frame, node } = setting.where;
593
+ const id = `${frame.relativeFilePath}\0${node.start}\0${key}`;
594
+ if (this.reported.has(id))
595
+ return;
596
+ this.reported.add(id);
597
+ const message = `The config's ${key} could not be worked out without running the config, so ${effect}.`;
598
+ const code = snippet(frame.parsed.source.text.slice(node.start, node.end));
599
+ const found = problem('config-value-unknown', node, message, code);
600
+ this.diagnostics.push(toDiagnostic(found, frame.relativeFilePath, frame.parsed.source));
601
+ }
602
+ /** The config the runner started in a folder would use; null when the folder has none. */
603
+ inFolder(folder) {
604
+ let config = this.inFolders.get(folder);
605
+ if (config === undefined) {
606
+ // The runners take a config that exists, without checking that it is a file they can read.
607
+ const name = CONFIG_FILES[this.framework].find((item) => existsSync(path.join(folder, item)));
608
+ config = name ? this.at(path.join(folder, name)) : null;
609
+ this.inFolders.set(folder, config);
610
+ }
611
+ return config;
612
+ }
613
+ read(file) {
614
+ const relativeFilePath = path.relative(this.root, file).replaceAll('\\', '/');
615
+ const unreadable = (diagnostic) => {
616
+ this.diagnostics.push(diagnostic);
617
+ return { file, record: { relativeFilePath, extends: [] }, settings: { kind: 'unknown', where: null } };
618
+ };
619
+ const none = 'so the scan reads none of its settings.';
620
+ let code;
621
+ try {
622
+ code = readFileSync(file, 'utf8');
623
+ }
624
+ catch (error) {
625
+ const message = `The config could not be read: ${readFailure(error)}, ${none}`;
626
+ return unreadable(fileDiagnostic('config-value-unknown', relativeFilePath, message));
627
+ }
628
+ const parsed = parseFile(relativeFilePath, code);
629
+ if (parsed.error) {
630
+ const { message, start, end } = parsed.error;
631
+ const found = problem('config-value-unknown', { start, end }, `The config could not be parsed: ${message}, ${none}`, snippet(parsed.source.text.slice(start, end)) || null);
632
+ return unreadable(toDiagnostic(found, relativeFilePath, parsed.source));
633
+ }
634
+ const frame = { parsed, values: new Values(), relativeFilePath, file, readModule: this.readerFor(file) };
635
+ const config = new ConfigCode(frame, this.framework, this.root, this.version);
636
+ let settings;
637
+ try {
638
+ settings = config.exported(frame, 'default', { frame, node: parsed.program });
639
+ }
640
+ catch (error) {
641
+ // The scan runs none of the project's code, so what fails here is its own and throws an Error.
642
+ const raw = error.message;
643
+ const message = `Reading the config failed unexpectedly: ${raw.replaceAll(this.root, '.')}, ${none}`;
644
+ return unreadable(fileDiagnostic('internal-error', relativeFilePath, message));
645
+ }
646
+ if (settings === null) {
647
+ const message = `The config has no default export the scan can find, ${none}`;
648
+ return unreadable(fileDiagnostic('config-value-unknown', relativeFilePath, message));
649
+ }
650
+ return { file, record: { relativeFilePath, extends: config.followed }, settings };
651
+ }
652
+ }