test-inventory 0.6.1 → 0.7.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.
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' | 'dependency-not-followed';
41
+ export type DiagnosticCode = 'unresolved-test-import' | 'parse-error' | 'read-error' | 'framework-mismatch' | 'unresolved-tag' | 'unresolved-annotation' | 'unresolved-cases' | 'cases-not-expanded' | '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
  };
@@ -222,6 +222,11 @@ export interface SuiteRecord {
222
222
  isShuffled: boolean;
223
223
  /** Declared with `.each` or `.for`. */
224
224
  isParameterized: boolean;
225
+ /**
226
+ * With `expandCases`, which case of its table or pass of its loop the describe is, from 0, as Vitest's `%#` counts;
227
+ * null without `expandCases`, or when the describe isn't one of several.
228
+ */
229
+ caseIndex: Count | null;
225
230
  tags: string[];
226
231
  annotations: Annotation[];
227
232
  /** Playwright only. */
@@ -381,10 +386,17 @@ export interface TestRecord {
381
386
  /** Declared with `.each` or `.for`, or inside a describe declared with them, so the runner repeats it. */
382
387
  isParameterized: boolean;
383
388
  /**
384
- * How many times the runner repeats the test: the cases of its own table times those of each describe table around
385
- * it. Null when a table is not written out in the file, or the test is not parameterized.
389
+ * How many times the runner repeats the test: the cases of its own table times those of each describe table and the
390
+ * passes of each loop around it. Null when the scan can't work out a table or a loop's values, or the test is neither
391
+ * parameterized nor in a loop.
386
392
  */
387
393
  caseCount: Count | null;
394
+ /**
395
+ * With `expandCases`, which case of its own table, or pass of the innermost loop around it inside its describe, the
396
+ * test is, from 0, as Vitest's `%#` counts; null without `expandCases`, or when the test isn't one of several. The
397
+ * case of a describe table or loop around the describe is the describe's `caseIndex`.
398
+ */
399
+ caseIndex: Count | null;
388
400
  /** Declared inside a loop, `forEach` or `map`. */
389
401
  isInLoop: boolean;
390
402
  /** Declared inside `if`, `?:`, `&&`, `switch` or a `catch` block. */
package/dist/values.d.ts CHANGED
@@ -11,6 +11,8 @@ export type Resolved = {
11
11
  ok: false;
12
12
  importedFrom: string | null;
13
13
  };
14
+ /** What another module exports under a name, `default` included, for an import of the file. */
15
+ export type ImportReader = (source: string, name: string) => Resolved;
14
16
  /** Whether a value can be used as a property name. */
15
17
  export declare function isKey(value: Value): value is string | number;
16
18
  export declare function isScopeNode(node: Node): boolean;
@@ -22,6 +24,11 @@ export declare class Values {
22
24
  private readonly pending;
23
25
  /** The functions being read at one of their calls, innermost last. */
24
26
  private readonly calls;
27
+ private readonly imports;
28
+ private readonly assigned;
29
+ private readonly changed;
30
+ /** Without `imports`, a value imported from another module is not worked out. */
31
+ constructor(imports?: ImportReader | null);
25
32
  /**
26
33
  * Runs `read` with a function's parameters bound to the arguments of one call, worked out where the call is, so that
27
34
  * titles and values built from them resolve, as in `test(\`${side} rejects a zero quantity\`)` inside
@@ -29,6 +36,18 @@ export declare class Values {
29
36
  * takes the parameter's default value, worked out in `scopes`, the scopes around the function.
30
37
  */
31
38
  withArguments<T>(fn: FunctionNode, args: readonly Resolved[], scopes: readonly Node[], read: () => T): T;
39
+ /**
40
+ * Runs `read` with the names a loop declares bound to one of the values it goes over, as in
41
+ * `for (const [tier, bps] of Object.entries(FEES))`. `declarator` is the loop's variable declarator.
42
+ */
43
+ withLoopValue<T>(declarator: Node, pattern: Node, value: Resolved, body: Node, scopes: readonly Node[], read: () => T): T;
44
+ private assignedIn;
45
+ private withBound;
46
+ /**
47
+ * Binds the names of a parameter or a loop variable to the parts of `value` they take, as destructuring does. A
48
+ * missing `value`, after a spread argument, leaves them unknown, unless a default gives them one.
49
+ */
50
+ private bind;
32
51
  /** `scopes` lists the scope nodes around the expression, outermost first. */
33
52
  resolve(node: Expression, scopes: readonly Node[]): Resolved;
34
53
  /**
@@ -43,12 +62,31 @@ export declare class Values {
43
62
  } | null;
44
63
  /** The module a name is imported from where it is used; null when it isn't an import. */
45
64
  importSource(name: string, scopes: readonly Node[]): string | null;
65
+ /**
66
+ * Whether a name refers to a parameter or loop variable bound to a value where it is used: an argument of the helper
67
+ * call being walked, or a value of the case or pass of an expansion.
68
+ */
69
+ isBound(name: string, scopes: readonly Node[]): boolean;
46
70
  /** Whether a name refers to a function or class declared in the file. */
47
71
  isFunction(name: string, scopes: readonly Node[]): boolean;
48
72
  private propertyKey;
49
- private resolveName;
73
+ /**
74
+ * The calls titles are often built with, all of them pure: `Object.keys()`, `Object.values()`, `Object.entries()`,
75
+ * `JSON.stringify()` with one argument, `String()`, and a string's `toUpperCase()`, `toLowerCase()` and `trim()` and
76
+ * an array's `join()`.
77
+ */
78
+ private call;
79
+ /** Whether a name refers to a global, as no scope around the use declares it. */
80
+ private isGlobal;
81
+ /** `data.name` where `data` is `import * as data`: what the module exports as `name`; null for any other member. */
82
+ private namespaceMember;
83
+ private imported;
84
+ /** What a name refers to where it is used; `scopes` lists the scope nodes around the use, outermost first. */
85
+ resolveName(name: string, scopes: readonly Node[]): Resolved;
50
86
  /** A parameter of a function read at one of its calls: the argument given there. */
51
87
  private resolveParameter;
88
+ /** Names the module changes while it loads, as in `WATCHED.push('MSFT')`, whose declared value isn't the one used. */
89
+ private changedAtLoad;
52
90
  private resolveConstant;
53
91
  private enumMembers;
54
92
  private lookup;
package/dist/values.js CHANGED
@@ -1,4 +1,4 @@
1
- import { propertyName, unwrap } from "./ast.js";
1
+ import { childNodes, isFunctionNode, propertyName, unwrap } from "./ast.js";
2
2
  const UNRESOLVED = { ok: false, importedFrom: null };
3
3
  function isPrimitive(value) {
4
4
  return typeof value !== 'object' || value === null;
@@ -58,6 +58,13 @@ export class Values {
58
58
  pending = new Set();
59
59
  /** The functions being read at one of their calls, innermost last. */
60
60
  calls = [];
61
+ imports;
62
+ assigned = new WeakMap();
63
+ changed = new WeakMap();
64
+ /** Without `imports`, a value imported from another module is not worked out. */
65
+ constructor(imports = null) {
66
+ this.imports = imports;
67
+ }
61
68
  /**
62
69
  * Runs `read` with a function's parameters bound to the arguments of one call, worked out where the call is, so that
63
70
  * titles and values built from them resolve, as in `test(\`${side} rejects a zero quantity\`)` inside
@@ -67,16 +74,33 @@ export class Values {
67
74
  withArguments(fn, args, scopes, read) {
68
75
  const parameters = new Map();
69
76
  for (const [index, parameter] of fn.params.entries()) {
70
- const target = parameter.type === 'AssignmentPattern' ? parameter.left : parameter;
71
- if (target.type !== 'Identifier')
72
- continue;
73
- const argument = args.at(index);
74
- if (argument)
75
- parameters.set(target.name, () => argument);
76
- else if (parameter.type === 'AssignmentPattern') {
77
- parameters.set(target.name, () => this.resolve(parameter.right, [...scopes, fn]));
78
- }
77
+ this.bind(parameter, args.at(index), parameters, [...scopes, fn]);
79
78
  }
79
+ // A parameter the function assigns to may hold another value by the time it is read.
80
+ for (const name of this.assignedIn(fn.body))
81
+ parameters.delete(name);
82
+ return this.withBound(fn, parameters, read);
83
+ }
84
+ /**
85
+ * Runs `read` with the names a loop declares bound to one of the values it goes over, as in
86
+ * `for (const [tier, bps] of Object.entries(FEES))`. `declarator` is the loop's variable declarator.
87
+ */
88
+ withLoopValue(declarator, pattern, value, body, scopes, read) {
89
+ const names = new Map();
90
+ this.bind(pattern, value, names, scopes);
91
+ for (const name of this.assignedIn(body))
92
+ names.delete(name);
93
+ return this.withBound(declarator, names, read);
94
+ }
95
+ assignedIn(body) {
96
+ let names = this.assigned.get(body);
97
+ if (!names) {
98
+ names = assignedNames(body);
99
+ this.assigned.set(body, names);
100
+ }
101
+ return names;
102
+ }
103
+ withBound(fn, parameters, read) {
80
104
  this.calls.push({ fn, parameters, cache: new Map() });
81
105
  try {
82
106
  return read();
@@ -85,6 +109,64 @@ export class Values {
85
109
  this.calls.pop();
86
110
  }
87
111
  }
112
+ /**
113
+ * Binds the names of a parameter or a loop variable to the parts of `value` they take, as destructuring does. A
114
+ * missing `value`, after a spread argument, leaves them unknown, unless a default gives them one.
115
+ */
116
+ bind(pattern, value, names, scopes) {
117
+ switch (pattern.type) {
118
+ case 'Identifier':
119
+ if (value)
120
+ names.set(pattern.name, () => value);
121
+ return;
122
+ case 'AssignmentPattern': {
123
+ const useDefault = !value || (value.ok && value.value === undefined);
124
+ if (!useDefault) {
125
+ this.bind(pattern.left, value, names, scopes);
126
+ return;
127
+ }
128
+ const fallback = pattern.right;
129
+ if (pattern.left.type === 'Identifier') {
130
+ names.set(pattern.left.name, () => this.resolve(fallback, scopes));
131
+ return;
132
+ }
133
+ this.bind(pattern.left, this.resolve(fallback, scopes), names, scopes);
134
+ return;
135
+ }
136
+ case 'ArrayPattern':
137
+ for (const [index, element] of pattern.elements.entries()) {
138
+ if (!element || !value)
139
+ continue;
140
+ const list = value.ok && Array.isArray(value.value) ? value.value : null;
141
+ const part = (rest) => list ? { ok: true, value: rest ? list.slice(index) : list[index] } : value.ok ? UNRESOLVED : value;
142
+ if (element.type === 'RestElement')
143
+ this.bind(element.argument, part(true), names, scopes);
144
+ else
145
+ this.bind(element, part(false), names, scopes);
146
+ }
147
+ return;
148
+ case 'ObjectPattern': {
149
+ if (!value)
150
+ return;
151
+ const object = value.ok && isPlainObject(value.value) ? value.value : null;
152
+ const taken = [];
153
+ for (const property of pattern.properties) {
154
+ if (property.type === 'RestElement') {
155
+ const rest = object && Object.fromEntries(Object.entries(object).filter(([key]) => !taken.includes(key)));
156
+ this.bind(property.argument, rest ? { ok: true, value: rest } : value.ok ? UNRESOLVED : value, names, scopes);
157
+ continue;
158
+ }
159
+ const key = this.propertyKey(property.key, property.computed, scopes);
160
+ const name = key.ok && isKey(key.value) ? String(key.value) : null;
161
+ if (name !== null)
162
+ taken.push(name);
163
+ const part = object && name !== null ? { ok: true, value: object[name] } : value.ok ? UNRESOLVED : value;
164
+ this.bind(property.value, part, names, scopes);
165
+ }
166
+ return;
167
+ }
168
+ }
169
+ }
88
170
  /** `scopes` lists the scope nodes around the expression, outermost first. */
89
171
  resolve(node, scopes) {
90
172
  const expression = unwrap(node);
@@ -105,7 +187,7 @@ export class Values {
105
187
  return { ok: true, value: text };
106
188
  }
107
189
  case 'BinaryExpression': {
108
- if (expression.operator !== '+')
190
+ if (!BINARY.has(expression.operator))
109
191
  return UNRESOLVED;
110
192
  const left = this.resolve(expression.left, scopes);
111
193
  if (!left.ok)
@@ -115,13 +197,27 @@ export class Values {
115
197
  return right;
116
198
  if (!isPrimitive(left.value) || !isPrimitive(right.value))
117
199
  return UNRESOLVED;
118
- if (typeof left.value === 'number' && typeof right.value === 'number') {
119
- return { ok: true, value: left.value + right.value };
120
- }
121
- return typeof left.value === 'string' || typeof right.value === 'string'
122
- ? { ok: true, value: String(left.value) + String(right.value) }
123
- : UNRESOLVED;
200
+ return binary(expression.operator, left.value, right.value);
124
201
  }
202
+ case 'LogicalExpression': {
203
+ const left = this.resolve(expression.left, scopes);
204
+ if (!left.ok)
205
+ return left;
206
+ const useLeft = expression.operator === '&&'
207
+ ? !left.value
208
+ : expression.operator === '||'
209
+ ? Boolean(left.value)
210
+ : left.value !== null && left.value !== undefined;
211
+ return useLeft ? left : this.resolve(expression.right, scopes);
212
+ }
213
+ case 'ConditionalExpression': {
214
+ const test = this.resolve(expression.test, scopes);
215
+ if (!test.ok)
216
+ return test;
217
+ return this.resolve(test.value ? expression.consequent : expression.alternate, scopes);
218
+ }
219
+ case 'CallExpression':
220
+ return this.call(expression, scopes);
125
221
  case 'UnaryExpression': {
126
222
  if (expression.operator !== '!' && expression.operator !== '-')
127
223
  return UNRESOLVED;
@@ -181,6 +277,9 @@ export class Values {
181
277
  case 'Identifier':
182
278
  return this.resolveName(expression.name, scopes);
183
279
  case 'MemberExpression': {
280
+ const namespace = this.namespaceMember(expression, scopes);
281
+ if (namespace)
282
+ return namespace;
184
283
  const object = this.resolve(expression.object, scopes);
185
284
  if (!object.ok)
186
285
  return object;
@@ -190,6 +289,8 @@ export class Values {
190
289
  if (!key.ok)
191
290
  return key;
192
291
  const container = object.value;
292
+ if (typeof container === 'string' && key.value === 'length')
293
+ return { ok: true, value: container.length };
193
294
  if (typeof container !== 'object' || container === null || !isKey(key.value))
194
295
  return UNRESOLVED;
195
296
  const name = String(key.value);
@@ -218,6 +319,16 @@ export class Values {
218
319
  const binding = this.lookup(name, scopes)?.binding;
219
320
  return binding?.kind === 'import' ? binding.source : null;
220
321
  }
322
+ /**
323
+ * Whether a name refers to a parameter or loop variable bound to a value where it is used: an argument of the helper
324
+ * call being walked, or a value of the case or pass of an expansion.
325
+ */
326
+ isBound(name, scopes) {
327
+ const binding = this.lookup(name, scopes)?.binding;
328
+ if (binding?.kind !== 'other')
329
+ return false;
330
+ return this.calls.findLast((item) => item.fn === binding.declaration)?.parameters.has(name) ?? false;
331
+ }
221
332
  /** Whether a name refers to a function or class declared in the file. */
222
333
  isFunction(name, scopes) {
223
334
  return this.lookup(name, scopes)?.binding.kind === 'function';
@@ -229,6 +340,88 @@ export class Values {
229
340
  return { ok: true, value: key.name };
230
341
  return this.resolve(key, scopes);
231
342
  }
343
+ /**
344
+ * The calls titles are often built with, all of them pure: `Object.keys()`, `Object.values()`, `Object.entries()`,
345
+ * `JSON.stringify()` with one argument, `String()`, and a string's `toUpperCase()`, `toLowerCase()` and `trim()` and
346
+ * an array's `join()`.
347
+ */
348
+ call(expression, scopes) {
349
+ const callee = unwrap(expression.callee);
350
+ if (expression.optional || expression.arguments.some((item) => item.type === 'SpreadElement'))
351
+ return UNRESOLVED;
352
+ const args = expression.arguments;
353
+ if (callee.type === 'Identifier' &&
354
+ callee.name === 'String' &&
355
+ args.length === 1 &&
356
+ this.isGlobal('String', scopes)) {
357
+ const value = this.resolve(args[0], scopes);
358
+ return value.ok && isPrimitive(value.value)
359
+ ? { ok: true, value: String(value.value) }
360
+ : value.ok
361
+ ? UNRESOLVED
362
+ : value;
363
+ }
364
+ if (callee.type !== 'MemberExpression' || callee.computed)
365
+ return UNRESOLVED;
366
+ const method = propertyName(callee);
367
+ const target = unwrap(callee.object);
368
+ if (target.type === 'Identifier' && (target.name === 'Object' || target.name === 'JSON')) {
369
+ if (!this.isGlobal(target.name, scopes) || args.length !== 1)
370
+ return UNRESOLVED;
371
+ const value = this.resolve(args[0], scopes);
372
+ if (!value.ok)
373
+ return value;
374
+ if (target.name === 'JSON')
375
+ return method === 'stringify' ? { ok: true, value: JSON.stringify(value.value) } : UNRESOLVED;
376
+ if (!isPlainObject(value.value) && !Array.isArray(value.value))
377
+ return UNRESOLVED;
378
+ if (method === 'keys')
379
+ return { ok: true, value: Object.keys(value.value) };
380
+ if (method === 'values')
381
+ return { ok: true, value: Object.values(value.value) };
382
+ return method === 'entries' ? { ok: true, value: Object.entries(value.value) } : UNRESOLVED;
383
+ }
384
+ const object = this.resolve(callee.object, scopes);
385
+ if (!object.ok)
386
+ return object;
387
+ const text = object.value;
388
+ if (typeof text === 'string' && args.length === 0 && STRING_METHODS.has(method)) {
389
+ return {
390
+ ok: true,
391
+ value: method === 'trim' ? text.trim() : method === 'toUpperCase' ? text.toUpperCase() : text.toLowerCase(),
392
+ };
393
+ }
394
+ if (!Array.isArray(text) || method !== 'join' || args.length > 1 || !text.every(isPrimitive))
395
+ return UNRESOLVED;
396
+ const separator = args.length === 0 ? { ok: true, value: ',' } : this.resolve(args[0], scopes);
397
+ if (!separator.ok)
398
+ return separator;
399
+ return typeof separator.value === 'string' ? { ok: true, value: text.join(separator.value) } : UNRESOLVED;
400
+ }
401
+ /** Whether a name refers to a global, as no scope around the use declares it. */
402
+ isGlobal(name, scopes) {
403
+ return this.lookup(name, scopes) === null;
404
+ }
405
+ /** `data.name` where `data` is `import * as data`: what the module exports as `name`; null for any other member. */
406
+ namespaceMember(expression, scopes) {
407
+ const object = unwrap(expression.object);
408
+ if (object.type !== 'Identifier')
409
+ return null;
410
+ const binding = this.lookup(object.name, scopes)?.binding;
411
+ if (binding?.kind !== 'import' || binding.imported !== null)
412
+ return null;
413
+ const key = expression.computed
414
+ ? this.resolve(expression.property, scopes)
415
+ : { ok: true, value: propertyName(expression) };
416
+ if (!key.ok || !isKey(key.value))
417
+ return { ok: false, importedFrom: binding.source };
418
+ return this.imported(binding.source, String(key.value));
419
+ }
420
+ imported(source, name) {
421
+ const resolved = name !== null && this.imports ? this.imports(source, name) : UNRESOLVED;
422
+ return resolved.ok ? resolved : { ok: false, importedFrom: source };
423
+ }
424
+ /** What a name refers to where it is used; `scopes` lists the scope nodes around the use, outermost first. */
232
425
  resolveName(name, scopes) {
233
426
  const found = this.lookup(name, scopes);
234
427
  if (!found)
@@ -236,11 +429,15 @@ export class Values {
236
429
  const { binding } = found;
237
430
  switch (binding.kind) {
238
431
  case 'const':
432
+ if (this.changedAtLoad(binding.scopes[0]).has(name))
433
+ return UNRESOLVED;
239
434
  return this.resolveConstant(binding.init, binding.scopes);
240
435
  case 'enum':
241
436
  return { ok: true, value: this.enumMembers(binding.node, binding.scopes) };
242
437
  case 'import':
243
- return { ok: false, importedFrom: binding.source };
438
+ if (this.changedAtLoad(scopes[0]).has(name))
439
+ return { ok: false, importedFrom: binding.source };
440
+ return this.imported(binding.source, binding.imported);
244
441
  case 'function':
245
442
  return UNRESOLVED;
246
443
  case 'other':
@@ -252,6 +449,15 @@ export class Values {
252
449
  const call = this.calls.findLast((item) => item.fn === fn);
253
450
  return call?.parameters.get(name)?.() ?? UNRESOLVED;
254
451
  }
452
+ /** Names the module changes while it loads, as in `WATCHED.push('MSFT')`, whose declared value isn't the one used. */
453
+ changedAtLoad(program) {
454
+ let names = this.changed.get(program);
455
+ if (!names) {
456
+ names = changedNames(program);
457
+ this.changed.set(program, names);
458
+ }
459
+ return names;
460
+ }
255
461
  resolveConstant(init, scopes) {
256
462
  const cache = this.calls.at(-1)?.cache ?? this.cache;
257
463
  const cached = cache.get(init);
@@ -275,6 +481,9 @@ export class Values {
275
481
  : { ok: true, value: next };
276
482
  if (name.ok && isKey(name.value) && resolved.ok && isKey(resolved.value)) {
277
483
  members[String(name.value)] = resolved.value;
484
+ // TypeScript maps a numeric member's value back to its name too.
485
+ if (typeof resolved.value === 'number')
486
+ members[String(resolved.value)] = String(name.value);
278
487
  next = typeof resolved.value === 'number' ? resolved.value + 1 : null;
279
488
  }
280
489
  else {
@@ -300,6 +509,197 @@ export class Values {
300
509
  return bindings;
301
510
  }
302
511
  }
512
+ const BINARY = new Set(['+', '-', '*', '/', '%', '===', '!==', '<', '<=', '>', '>=']);
513
+ const ARITHMETIC = new Set(['-', '*', '/', '%']);
514
+ const STRING_METHODS = new Set(['toUpperCase', 'toLowerCase', 'trim']);
515
+ /**
516
+ * A binary operator on two primitives, as JavaScript works it out; arithmetic only on numbers, as in
517
+ * `30 * 1000`, and comparisons only between values of one type.
518
+ */
519
+ function binary(operator, left, right) {
520
+ if (operator === '===')
521
+ return { ok: true, value: left === right };
522
+ if (operator === '!==')
523
+ return { ok: true, value: left !== right };
524
+ if (operator === '+') {
525
+ if (typeof left === 'number' && typeof right === 'number')
526
+ return { ok: true, value: left + right };
527
+ return typeof left === 'string' || typeof right === 'string'
528
+ ? { ok: true, value: String(left) + String(right) }
529
+ : UNRESOLVED;
530
+ }
531
+ const numbers = typeof left === 'number' && typeof right === 'number';
532
+ if (numbers && ARITHMETIC.has(operator)) {
533
+ const value = operator === '-'
534
+ ? left - right
535
+ : operator === '*'
536
+ ? left * right
537
+ : operator === '/'
538
+ ? left / right
539
+ : left % right;
540
+ // Transforms fold `-NaN` differently, so the runner may see either sign; no real table holds NaN.
541
+ return Number.isNaN(value) ? UNRESOLVED : { ok: true, value };
542
+ }
543
+ const comparable = numbers || (typeof left === 'string' && typeof right === 'string');
544
+ if (!comparable || ARITHMETIC.has(operator))
545
+ return UNRESOLVED;
546
+ const [a, b] = [left, right];
547
+ const value = operator === '<' ? a < b : operator === '<=' ? a <= b : operator === '>' ? a > b : a >= b;
548
+ return { ok: true, value };
549
+ }
550
+ const MUTATING_METHODS = new Set([
551
+ 'push',
552
+ 'pop',
553
+ 'shift',
554
+ 'unshift',
555
+ 'splice',
556
+ 'sort',
557
+ 'reverse',
558
+ 'fill',
559
+ 'copyWithin',
560
+ ]);
561
+ const OBJECT_MUTATORS = new Set(['assign', 'defineProperty', 'defineProperties', 'setPrototypeOf']);
562
+ /** The identifier a member chain such as `ROUTING.venues[0]` starts from; null for any other expression. */
563
+ function rootName(node) {
564
+ let current = unwrap(node);
565
+ while (current.type === 'MemberExpression')
566
+ current = unwrap(current.object);
567
+ return current.type === 'Identifier' ? current.name : null;
568
+ }
569
+ /**
570
+ * Names assigned to or changed anywhere in `body`, its functions included: `x = ...`, `x++`, `[x] = ...`,
571
+ * `for (x of ...)`, `x.push(...)`.
572
+ */
573
+ function assignedNames(body) {
574
+ const names = new Set();
575
+ const targets = (target) => {
576
+ switch (target?.type) {
577
+ case 'Identifier':
578
+ names.add(target.name);
579
+ break;
580
+ case 'ArrayPattern':
581
+ for (const element of target.elements)
582
+ targets(element);
583
+ break;
584
+ case 'ObjectPattern':
585
+ for (const property of target.properties)
586
+ targets(property.type === 'RestElement' ? property : property.value);
587
+ break;
588
+ case 'AssignmentPattern':
589
+ targets(target.left);
590
+ break;
591
+ case 'RestElement':
592
+ targets(target.argument);
593
+ break;
594
+ }
595
+ };
596
+ const walk = (node) => {
597
+ if (node.type === 'AssignmentExpression')
598
+ targets(node.left);
599
+ if (node.type === 'UpdateExpression')
600
+ targets(node.argument);
601
+ if (node.type === 'ForOfStatement' || node.type === 'ForInStatement')
602
+ targets(node.left);
603
+ // A parameter whose value the function changes, as in `list.push(...)`, no longer holds the argument's value.
604
+ const changed = mutatedName(node);
605
+ if (changed !== null)
606
+ names.add(changed);
607
+ for (const child of childNodes(node))
608
+ walk(child);
609
+ };
610
+ walk(body);
611
+ return names;
612
+ }
613
+ /** Calls whose callbacks run after the runner has collected the tests: tests, hooks and steps. */
614
+ const RUNS_LATER = new Set([
615
+ 'test',
616
+ 'it',
617
+ 'bench',
618
+ 'beforeEach',
619
+ 'afterEach',
620
+ 'beforeAll',
621
+ 'afterAll',
622
+ 'aroundEach',
623
+ 'aroundAll',
624
+ ]);
625
+ /** Calls whose callbacks run while the runner collects the tests, also when they hang off `test`. */
626
+ const RUNS_NOW = new Set(['describe', 'suite', 'context']);
627
+ /** The names of a call's callee, as in `test.describe.serial`: the identifier it starts from and each property. */
628
+ function calleeNames(callee) {
629
+ const names = [];
630
+ let current = unwrap(callee);
631
+ while (current.type === 'MemberExpression' || current.type === 'CallExpression') {
632
+ if (current.type === 'CallExpression') {
633
+ current = unwrap(current.callee);
634
+ continue;
635
+ }
636
+ names.push(propertyName(current) ?? '');
637
+ current = unwrap(current.object);
638
+ }
639
+ if (current.type === 'Identifier')
640
+ names.push(current.name);
641
+ return names;
642
+ }
643
+ /** The name a mutation changes the value of, as in `WATCHED.push('MSFT')` or `ROUTING.market = 'NYSE'`; or null. */
644
+ function mutatedName(node) {
645
+ switch (node.type) {
646
+ case 'AssignmentExpression':
647
+ return node.left.type === 'MemberExpression' ? rootName(node.left) : null;
648
+ case 'UpdateExpression':
649
+ return node.argument.type === 'MemberExpression' ? rootName(node.argument) : null;
650
+ case 'UnaryExpression':
651
+ return node.operator === 'delete' ? rootName(node.argument) : null;
652
+ case 'CallExpression': {
653
+ const callee = unwrap(node.callee);
654
+ if (callee.type !== 'MemberExpression')
655
+ return null;
656
+ const method = propertyName(callee) ?? '';
657
+ if (MUTATING_METHODS.has(method))
658
+ return rootName(callee.object);
659
+ const target = node.arguments.at(0);
660
+ return OBJECT_MUTATORS.has(method) && rootName(callee.object) === 'Object' && target ? rootName(target) : null;
661
+ }
662
+ default:
663
+ return null;
664
+ }
665
+ }
666
+ /**
667
+ * Names whose value the module changes while it loads, as describe callbacks and helpers run then: a member assigned
668
+ * or deleted, a mutating method such as `push()`, or `Object.assign()`, also through an alias such as
669
+ * `const all = SIDES`. The callbacks of tests, hooks and steps run after the runner has read titles and tables, so
670
+ * what they change doesn't count.
671
+ */
672
+ function changedNames(program) {
673
+ const names = new Set();
674
+ const aliases = new Map();
675
+ const walk = (node, later) => {
676
+ if (!later) {
677
+ const name = mutatedName(node);
678
+ if (name !== null)
679
+ names.add(name);
680
+ }
681
+ if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier' && node.init?.type === 'Identifier') {
682
+ aliases.set(node.id.name, node.init.name);
683
+ }
684
+ if (node.type === 'CallExpression') {
685
+ const callee = calleeNames(node.callee);
686
+ const runsLater = !later && callee.some((name) => RUNS_LATER.has(name)) && !callee.some((name) => RUNS_NOW.has(name));
687
+ walk(node.callee, later);
688
+ for (const argument of node.arguments)
689
+ walk(argument, later || (runsLater && isFunctionNode(argument)));
690
+ return;
691
+ }
692
+ for (const child of childNodes(node))
693
+ walk(child, later);
694
+ };
695
+ walk(program, false);
696
+ for (const name of [...names]) {
697
+ for (let target = aliases.get(name); target !== undefined && !names.has(target); target = aliases.get(target)) {
698
+ names.add(target);
699
+ }
700
+ }
701
+ return names;
702
+ }
303
703
  function isPlainObject(value) {
304
704
  return typeof value === 'object' && value !== null && !Array.isArray(value);
305
705
  }
@@ -337,6 +737,13 @@ function collectBindings(scope, scopes) {
337
737
  bindings.set(specifier.local.name, {
338
738
  kind: 'import',
339
739
  source: statement.source.value,
740
+ imported: specifier.type === 'ImportSpecifier'
741
+ ? specifier.imported.type === 'Identifier'
742
+ ? specifier.imported.name
743
+ : specifier.imported.value
744
+ : specifier.type === 'ImportDefaultSpecifier'
745
+ ? 'default'
746
+ : null,
340
747
  declaration: specifier,
341
748
  });
342
749
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "test-inventory",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Test metadata from source code, not from test results.",
5
5
  "keywords": [
6
6
  "testing",