test-inventory 0.1.2 → 0.2.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/extract.js CHANGED
@@ -1,8 +1,16 @@
1
- import { childNodes, isImportMetaVitest, propertyName, unwrap } from "./ast.js";
1
+ import { childNodes, isFunctionNode, isImportMetaVitest, propertyName, unwrap } from "./ast.js";
2
2
  import { exportName } from "./frameworks/bindings.js";
3
3
  import { problem } from "./frameworks/common.js";
4
4
  import { snippet, splitLines } from "./source.js";
5
5
  import { isScopeNode } from "./values.js";
6
+ /** How many helpers deep a call is followed into the functions it calls, far more than real suites nest. */
7
+ const MAX_DEPTH = 16;
8
+ /**
9
+ * How many helper calls are followed in one file, and from each call in a test or hook, counting the calls inside the
10
+ * helpers; far more than real suites make. Helpers that call each other in several places multiply: ten calls at each
11
+ * of six levels are a million.
12
+ */
13
+ const MAX_CALLS = 10_000;
6
14
  /** Whether a function is called where it is written, so that its tests are registered there. */
7
15
  function calledWhereWritten(call, written) {
8
16
  return (call.scope === written.scope &&
@@ -39,21 +47,46 @@ class Walker {
39
47
  suites = [];
40
48
  tests = [];
41
49
  problems = [];
42
- parsed;
43
- adapter;
44
- values;
50
+ /** The scanned file. */
51
+ main;
52
+ /** The file whose code is being walked: the scanned file, or the module of an imported helper. */
53
+ frame;
54
+ /** While an imported helper is walked, the call in the scanned file that walks it; records and problems point there. */
55
+ site = null;
56
+ openModule;
57
+ frames = new Map();
58
+ /** Problems of helper modules' own imports, reported once, at the first call that walks the module. */
59
+ moduleProblems = new Set();
60
+ /** The file each describe is written in, so that a describe's body hash only leaves out code from its own file. */
61
+ suiteFrames = new WeakMap();
45
62
  effects = new Map();
46
63
  helperCalls = [];
47
64
  /** Where each function with a name is written, outside test and hook bodies. */
48
65
  written = new Map();
49
- /** Every call by name to a function declared in the file. */
66
+ /**
67
+ * Every call by name to a function declared in the file, once per call: a call inside a helper is walked again each
68
+ * time the helper is, and the first walk, from where the helper is called, places it.
69
+ */
50
70
  callSites = new Map();
51
71
  /** The function with a name each test is declared in, which registers the test when it is called. */
52
72
  declaredIn = new Map();
53
- constructor(parsed, adapter, values) {
54
- this.parsed = parsed;
55
- this.adapter = adapter;
56
- this.values = values;
73
+ deferred = [];
74
+ isDeferred = new Set();
75
+ /** Functions walked at one of their calls, so that their tests are recorded where they are registered. */
76
+ walkedAtCall = new Set();
77
+ /** The functions being walked at their calls, innermost last, to stop recursion. */
78
+ callStack = [];
79
+ /** Helper calls walked in the file so far, up to MAX_CALLS. */
80
+ callsWalked = 0;
81
+ /** Problems already reported, since a function walked at several calls finds the same ones each time. */
82
+ reported = new Set();
83
+ /** For each file's program, the scopes around each function in it. */
84
+ scopeChains = new Map();
85
+ constructor(main, openModule) {
86
+ this.main = main;
87
+ this.frame = main;
88
+ this.openModule = openModule;
89
+ const { adapter } = main;
57
90
  this.fileScope = {
58
91
  kind: 'file',
59
92
  parent: null,
@@ -65,7 +98,7 @@ class Walker {
65
98
  };
66
99
  }
67
100
  run() {
68
- this.visit(this.parsed.program, {
101
+ this.visit(this.main.parsed.program, {
69
102
  scope: this.fileScope,
70
103
  test: null,
71
104
  hookScope: null,
@@ -76,15 +109,23 @@ class Walker {
76
109
  isInFunction: false,
77
110
  helper: null,
78
111
  declaredIn: null,
112
+ quiet: false,
113
+ registeredAt: null,
79
114
  });
80
- const exported = this.exportedFunctions(this.parsed.program);
115
+ // Functions defined inside these are deferred in turn; the loop reaches them too, as they are added to the end.
116
+ for (const entry of this.deferred)
117
+ this.walkWhereWritten(entry);
118
+ // Records follow the order the file registers them in; the sort is stable, so one call's tests keep theirs.
119
+ this.tests.sort((a, b) => a.order - b.order);
120
+ this.suites.sort((a, b) => a.order - b.order);
121
+ const exported = this.exportedFunctions(this.main.parsed.program);
81
122
  this.applyHelpers(exported);
82
123
  this.checkFunctionTests(exported);
83
124
  return {
84
125
  suites: this.suites,
85
126
  tests: this.tests,
86
- problems: [...this.adapter.problems, ...this.problems],
87
- usesGlobals: this.adapter.usesGlobals,
127
+ problems: [...this.main.adapter.problems, ...this.problems],
128
+ usesGlobals: this.main.adapter.usesGlobals,
88
129
  };
89
130
  }
90
131
  visit(node, context) {
@@ -103,11 +144,15 @@ class Walker {
103
144
  }
104
145
  visitNode(node, context) {
105
146
  switch (node.type) {
106
- case 'CallExpression':
107
- this.helperCall(node, context);
147
+ case 'CallExpression': {
148
+ const helper = this.helperCall(node, context);
108
149
  if (this.call(node, context) || this.loopCall(node, context))
109
150
  return;
110
- break;
151
+ this.visitChildren(node, context);
152
+ if (helper)
153
+ this.walkAtCall(helper, node, context);
154
+ return;
155
+ }
111
156
  case 'IfStatement': {
112
157
  // In-source tests sit inside `if (import.meta.vitest)`, which is how they are written, not a condition.
113
158
  if (isImportMetaVitest(node.test))
@@ -158,14 +203,23 @@ class Walker {
158
203
  // A function outside any test or hook runs when it is called; what it sets applies where it is called.
159
204
  const helper = context.test || context.hookScope ? null : node;
160
205
  const named = helper !== null && this.isNamed(node);
161
- if (named)
206
+ // In an imported helper's module, a function runs only when it is called, and is walked then.
207
+ if (named && this.frame !== this.main)
208
+ return;
209
+ if (named && !this.isDeferred.has(node)) {
210
+ // Its calls decide where its tests are registered, and a call may come after it, so it waits.
162
211
  this.written.set(node, { ...this.placement(context), declaredIn: context.declaredIn });
212
+ this.isDeferred.add(node);
213
+ this.deferred.push({ fn: node, context, scopes: [...this.scopes], parents: [...this.parents] });
214
+ return;
215
+ }
216
+ if (named)
217
+ return;
163
218
  this.visitChildren(node, {
164
219
  ...context,
165
220
  isInFunction: true,
166
221
  helper: helper ?? context.helper,
167
222
  guards: helper ? [] : context.guards,
168
- declaredIn: named ? node : context.declaredIn,
169
223
  });
170
224
  return;
171
225
  }
@@ -202,7 +256,7 @@ class Walker {
202
256
  this.scopes.pop();
203
257
  }
204
258
  text(node) {
205
- return snippet(this.parsed.source.slice(node.start, node.end));
259
+ return snippet(this.frame.parsed.source.slice(node.start, node.end));
206
260
  }
207
261
  /** `symbols.forEach((symbol) => test(...))` declares its tests in a loop. */
208
262
  loopCall(call, context) {
@@ -221,7 +275,7 @@ class Walker {
221
275
  return true;
222
276
  }
223
277
  call(call, context) {
224
- const api = this.adapter.classify(call, {
278
+ const api = this.frame.adapter.classify(call, {
225
279
  scopes: this.scopes,
226
280
  testContext: context.testContext,
227
281
  inTest: context.test !== null,
@@ -240,7 +294,11 @@ class Walker {
240
294
  this.declareSuite(call, api, context);
241
295
  return true;
242
296
  case 'modifier': {
243
- (context.test?.problems ?? this.problems).push(...api.problems);
297
+ if (context.test)
298
+ context.test.problems.push(...api.problems.map((item) => this.here(item)));
299
+ else
300
+ for (const item of api.problems)
301
+ this.report(item);
244
302
  if (api.condition.applies === 'never')
245
303
  return true;
246
304
  const own = api.condition.applies === 'sometimes' ? [api.condition.text] : [];
@@ -249,7 +307,7 @@ class Walker {
249
307
  condition: joinConditions([...context.guards, ...own]),
250
308
  reason: api.reason,
251
309
  hasDynamicReason: api.hasDynamicReason,
252
- line: this.parsed.source.line(call.start),
310
+ line: this.lineOf(call),
253
311
  };
254
312
  if (context.helper)
255
313
  this.effectsOf(context.helper).push({ kind: 'state', setting, call });
@@ -258,7 +316,8 @@ class Walker {
258
316
  return true;
259
317
  }
260
318
  case 'configure':
261
- this.problems.push(...api.problems);
319
+ for (const item of api.problems)
320
+ this.report(item);
262
321
  if (context.helper) {
263
322
  this.effectsOf(context.helper).push({ kind: 'configure', mode: api.mode, locks: api.locks, call });
264
323
  }
@@ -281,11 +340,11 @@ class Walker {
281
340
  return true;
282
341
  case 'annotations':
283
342
  context.test?.annotations.push(...api.items);
284
- context.test?.problems.push(...api.problems);
343
+ context.test?.problems.push(...api.problems.map((item) => this.here(item)));
285
344
  return true;
286
345
  case 'invalid': {
287
346
  const afterLoad = context.test !== null || context.hookScope !== null || context.helper !== null;
288
- this.problems.push(afterLoad ? { ...api.problem, afterLoad } : api.problem);
347
+ this.report(afterLoad ? { ...api.problem, afterLoad } : api.problem);
289
348
  return true;
290
349
  }
291
350
  case 'other':
@@ -299,29 +358,239 @@ class Walker {
299
358
  }
300
359
  /** The function a name refers to when it is declared in the file, as a function or a variable holding one. */
301
360
  functionNamed(name, scopes) {
302
- const declaration = this.values.declarationOf(name, scopes);
361
+ return this.functionIn(this.frame, name, scopes);
362
+ }
363
+ functionIn(frame, name, scopes) {
364
+ const declaration = frame.values.declarationOf(name, scopes);
303
365
  if (declaration?.type === 'FunctionDeclaration')
304
366
  return declaration;
305
367
  const init = declaration?.type === 'VariableDeclarator' && declaration.init ? unwrap(declaration.init) : null;
306
368
  return init?.type === 'ArrowFunctionExpression' || init?.type === 'FunctionExpression' ? init : null;
307
369
  }
308
- /** Records a call to a function declared in the file, so that the states it sets can follow the call. */
370
+ /**
371
+ * Records a call to a function declared in the file. A call from the file or a describe returns the function, to be
372
+ * walked there: its tests are registered in the describe around the call, and what it sets applies there. A call
373
+ * from a test, a hook or another function is applied later, through the states the function sets.
374
+ */
309
375
  helperCall(call, context) {
310
376
  const callee = unwrap(call.callee);
311
- const fn = callee.type === 'Identifier' ? this.functionNamed(callee.name, this.scopes) : null;
312
- if (!fn)
313
- return;
314
- const sites = this.callSites.get(fn) ?? [];
315
- sites.push(this.placement(context));
377
+ if (callee.type !== 'Identifier')
378
+ return null;
379
+ const fn = this.functionNamed(callee.name, this.scopes);
380
+ if (!fn) {
381
+ const runsHere = !context.test && !context.hookScope && !context.helper;
382
+ const imported = runsHere && this.callStack.length < MAX_DEPTH ? this.importedFunction(callee.name) : null;
383
+ return imported && !this.callStack.includes(imported.fn) ? this.withinLimit(imported, call) : null;
384
+ }
385
+ const sites = this.callSites.get(fn) ?? new Map();
386
+ if (!sites.has(call))
387
+ sites.set(call, this.placement(context));
316
388
  this.callSites.set(fn, sites);
317
389
  if (context.helper) {
318
390
  this.effectsOf(context.helper).push({ kind: 'call', fn, guards: context.guards });
319
391
  }
320
- else {
321
- const target = context.test ?? context.hookScope ?? context.scope;
322
- const scope = context.test || context.hookScope ? null : context.scope;
323
- this.helperCalls.push({ fn, target, scope, guards: context.guards });
392
+ else if (context.test || context.hookScope) {
393
+ this.helperCalls.push({
394
+ call,
395
+ fn,
396
+ target: context.test ?? context.hookScope,
397
+ guards: context.guards,
398
+ });
399
+ }
400
+ else if (!this.callStack.includes(fn) && this.callStack.length < MAX_DEPTH) {
401
+ return this.withinLimit({ fn, frame: this.frame }, call);
324
402
  }
403
+ return null;
404
+ }
405
+ /** The function to walk at a call, while the file has calls left to walk; past MAX_CALLS, the call is reported once. */
406
+ withinLimit(callee, call) {
407
+ if (this.callsWalked < MAX_CALLS) {
408
+ this.callsWalked++;
409
+ return callee;
410
+ }
411
+ if (this.callsWalked === MAX_CALLS) {
412
+ this.callsWalked++;
413
+ this.report(problem('test-in-function', call, `The file's helper functions are called more than ${MAX_CALLS.toLocaleString('en-US')} times, counting the calls inside them, so the scan stops following them here. Tests they register from here on are missing, or listed once where they are written.`));
414
+ }
415
+ return null;
416
+ }
417
+ /** The function a name imported from a local module stands for, in the file that writes it; null otherwise. */
418
+ importedFunction(name) {
419
+ const declaration = this.frame.values.declarationOf(name, this.scopes);
420
+ const source = this.frame.values.importSource(name, this.scopes);
421
+ if (source === null)
422
+ return null;
423
+ const imported = declaration?.type === 'ImportSpecifier' ? exportName(declaration.imported) : 'default';
424
+ const frame = this.openFrom(this.frame, source);
425
+ return frame ? this.exportedFunction(frame, imported, new Set(), 0) : null;
426
+ }
427
+ /** A module a file imports, read as a frame once; null when it can't be read or parsed. */
428
+ openFrom(frame, source) {
429
+ const module = frame.readModule?.(source);
430
+ if (!module || 'reason' in module)
431
+ return null;
432
+ if (!this.frames.has(module))
433
+ this.frames.set(module, this.openModule(module));
434
+ return this.frames.get(module) ?? null;
435
+ }
436
+ /**
437
+ * The function a module exports under a name, written there or re-exported from another module, as a barrel file
438
+ * does; null when the export isn't a function the scan can find. Each module is searched once for a name, however many
439
+ * barrels lead to it: one searched before didn't have it, or the search would have ended there.
440
+ */
441
+ exportedFunction(frame, name, searched, depth) {
442
+ const key = `${frame.relativeFilePath}\0${name}`;
443
+ if (searched.has(key) || depth >= MAX_DEPTH)
444
+ return null;
445
+ searched.add(key);
446
+ const { program } = frame.parsed;
447
+ const local = (localName) => {
448
+ const fn = this.functionIn(frame, localName, [program]);
449
+ return fn ? { fn, frame } : null;
450
+ };
451
+ const from = (source, exported) => {
452
+ const module = this.openFrom(frame, source);
453
+ return module ? this.exportedFunction(module, exported, searched, depth + 1) : null;
454
+ };
455
+ for (const statement of program.body) {
456
+ if (statement.type === 'ExportDefaultDeclaration' && name === 'default') {
457
+ const declaration = statement.declaration;
458
+ if (declaration.type === 'FunctionDeclaration' || isFunctionNode(declaration))
459
+ return { fn: declaration, frame };
460
+ return declaration.type === 'Identifier' ? local(declaration.name) : null;
461
+ }
462
+ if (statement.type === 'ExportAllDeclaration' && !statement.exported) {
463
+ const found = from(statement.source.value, name);
464
+ if (found)
465
+ return found;
466
+ }
467
+ if (statement.type !== 'ExportNamedDeclaration')
468
+ continue;
469
+ const declaration = statement.declaration;
470
+ if (declaration?.type === 'FunctionDeclaration' && declaration.id?.name === name)
471
+ return { fn: declaration, frame };
472
+ if (declaration?.type === 'VariableDeclaration') {
473
+ const declarator = declaration.declarations.find((item) => item.id.type === 'Identifier' && item.id.name === name);
474
+ if (declarator)
475
+ return local(name);
476
+ }
477
+ const specifier = statement.specifiers.find((item) => exportName(item.exported) === name);
478
+ if (specifier) {
479
+ const localName = exportName(specifier.local);
480
+ return statement.source ? from(statement.source.value, localName) : local(localName);
481
+ }
482
+ }
483
+ return null;
484
+ }
485
+ /**
486
+ * Walks a function at one of its calls from the file or a describe, as the runner runs it there: with the scopes
487
+ * where it is written, and its parameters bound to the call's arguments so that titles built from them resolve. A
488
+ * function imported from another module is walked in that module, and what it registers points at this call.
489
+ */
490
+ walkAtCall({ fn, frame }, call, context) {
491
+ this.walkedAtCall.add(fn);
492
+ // Arguments are worked out where the call is, in the file and scopes of the caller.
493
+ const spread = call.arguments.findIndex((item) => item.type === 'SpreadElement');
494
+ const args = (spread === -1 ? call.arguments : call.arguments.slice(0, spread)).map((item) => this.frame.values.resolve(item, this.scopes));
495
+ const caller = this.frame;
496
+ const site = this.site;
497
+ if (frame !== caller) {
498
+ this.frame = frame;
499
+ this.site ??= call;
500
+ }
501
+ const scopes = this.scopeChain(fn);
502
+ const saved = this.scopes.splice(0, this.scopes.length, ...scopes);
503
+ this.callStack.push(fn);
504
+ frame.values.withArguments(fn, args, scopes, () => {
505
+ this.visitCallback(fn, {
506
+ ...context,
507
+ isInFunction: true,
508
+ declaredIn: null,
509
+ registeredAt: context.registeredAt ?? call.start,
510
+ });
511
+ });
512
+ this.callStack.pop();
513
+ this.scopes.splice(0, this.scopes.length, ...saved);
514
+ // What the module's own imports couldn't resolve is reported at the call, as it is why tests may be missing.
515
+ if (frame !== caller) {
516
+ for (const item of frame.adapter.problems.filter((found) => !this.moduleProblems.has(found))) {
517
+ this.moduleProblems.add(item);
518
+ this.report(item);
519
+ }
520
+ }
521
+ this.frame = caller;
522
+ this.site = site;
523
+ }
524
+ /**
525
+ * Walks a deferred function where it is written. One that was walked at its calls only collects the states it sets,
526
+ * for calls from tests and hooks; one that wasn't records its tests where they are written, as nothing places them.
527
+ */
528
+ walkWhereWritten({ fn, context, scopes, parents }) {
529
+ const savedScopes = this.scopes.splice(0, this.scopes.length, ...scopes);
530
+ const savedParents = this.parents.splice(0, this.parents.length, ...parents);
531
+ this.visitChildren(fn, {
532
+ ...context,
533
+ isInFunction: true,
534
+ helper: fn,
535
+ guards: [],
536
+ declaredIn: fn,
537
+ quiet: this.walkedAtCall.has(fn),
538
+ });
539
+ this.scopes.splice(0, this.scopes.length, ...savedScopes);
540
+ this.parents.splice(0, this.parents.length, ...savedParents);
541
+ }
542
+ /** The scopes around a function where it is written, outermost first, to resolve names in it at its calls. */
543
+ scopeChain(fn) {
544
+ const { program } = this.frame.parsed;
545
+ let chains = this.scopeChains.get(program);
546
+ if (!chains) {
547
+ const found = new Map();
548
+ const around = [];
549
+ const visit = (node) => {
550
+ if (node.type === 'FunctionDeclaration' || isFunctionNode(node))
551
+ found.set(node, [...around]);
552
+ const isScope = isScopeNode(node);
553
+ if (isScope)
554
+ around.push(node);
555
+ for (const child of childNodes(node))
556
+ visit(child);
557
+ if (isScope)
558
+ around.pop();
559
+ };
560
+ visit(program);
561
+ chains = found;
562
+ this.scopeChains.set(program, chains);
563
+ }
564
+ return chains.get(fn);
565
+ }
566
+ /** Reports a problem not tied to a test, once, however many times a function with it is walked. */
567
+ report(found) {
568
+ const item = this.here(found);
569
+ // Problems found in code point at it.
570
+ const key = `${item.code}\0${item.node.start}\0${item.message}`;
571
+ if (this.reported.has(key))
572
+ return;
573
+ this.reported.add(key);
574
+ this.problems.push(item);
575
+ }
576
+ /** A problem found in an imported helper points at the call in the scanned file that walks the helper. */
577
+ here(item) {
578
+ return this.site ? { ...item, node: this.site } : item;
579
+ }
580
+ /** The line in the scanned file of code being walked: its own, or that of the call walking an imported helper. */
581
+ lineOf(node) {
582
+ return this.main.parsed.source.line((this.site ?? node).start);
583
+ }
584
+ /** Where an imported helper writes a test or describe; null for code in the scanned file. */
585
+ declaredAt(call) {
586
+ if (!this.site)
587
+ return null;
588
+ return { relativeFilePath: this.frame.relativeFilePath, source: this.frame.parsed.source, start: call.start };
589
+ }
590
+ /** Leaves a test or describe out of the describe's body hash, when both are written in the same file. */
591
+ cut(scope, call) {
592
+ if (scope.suite && this.suiteFrames.get(scope.suite) === this.frame)
593
+ scope.suite.cuts.push(this.statement(call));
325
594
  }
326
595
  /** Functions the file exports, whose callers in other files are not followed. */
327
596
  exportedFunctions(program) {
@@ -357,9 +626,18 @@ class Walker {
357
626
  */
358
627
  applyHelpers(exported) {
359
628
  const reached = new Set();
629
+ // For one call from a test or hook: the conditions each helper was expanded under, and how many expansions it took.
630
+ let expanded = new Map();
631
+ let steps = 0;
360
632
  const expand = (fn, guards, path) => {
361
633
  if (path.has(fn) || exported.has(fn))
362
634
  return [];
635
+ // A helper reached again under the same conditions sets the same states again.
636
+ const conditions = expanded.get(fn) ?? new Set();
637
+ const key = guards.join('\0');
638
+ if (conditions.has(key) || ++steps > MAX_CALLS)
639
+ return [];
640
+ expanded.set(fn, conditions.add(key));
363
641
  reached.add(fn);
364
642
  return (this.effects.get(fn) ?? []).flatMap((effect) => {
365
643
  if (effect.kind === 'call')
@@ -371,18 +649,23 @@ class Walker {
371
649
  });
372
650
  };
373
651
  for (const call of this.helperCalls) {
652
+ expanded = new Map();
653
+ steps = 0;
374
654
  for (const effect of expand(call.fn, call.guards, new Set())) {
655
+ // `describe.configure()` does nothing in a test or hook body.
375
656
  if (effect.kind === 'state')
376
657
  call.target.states.push(effect.setting);
377
- else if (call.scope)
378
- configure(call.scope, effect);
658
+ }
659
+ if (steps > MAX_CALLS) {
660
+ const callee = snippet(this.frame.parsed.source.slice(call.call.callee.start, call.call.callee.end));
661
+ this.problems.push(problem('state-in-helper', call.call, `${callee}() calls helper functions more than ${MAX_CALLS.toLocaleString('en-US')} times, counting the calls inside them, so the states they set past that are not applied.`, callee));
379
662
  }
380
663
  }
381
664
  for (const [fn, effects] of this.effects) {
382
665
  const own = effects.find((effect) => effect.kind !== 'call');
383
- if (!own || (reached.has(fn) && !exported.has(fn)))
666
+ if (!own || ((reached.has(fn) || this.walkedAtCall.has(fn)) && !exported.has(fn)))
384
667
  continue;
385
- const callee = snippet(this.parsed.source.slice(own.call.callee.start, own.call.callee.end));
668
+ const callee = snippet(this.frame.parsed.source.slice(own.call.callee.start, own.call.callee.end));
386
669
  const why = exported.has(fn) ? 'is exported' : 'no test, hook or describe in the file calls';
387
670
  this.problems.push(problem('state-in-helper', own.call, `${callee}() is in a function that ${why}; it applies to the tests that call the function, so it is not applied here.`, callee));
388
671
  }
@@ -391,7 +674,7 @@ class Walker {
391
674
  misplaced(fn, exported) {
392
675
  if (exported.has(fn))
393
676
  return 'the file exports the function';
394
- const sites = this.callSites.get(fn) ?? [];
677
+ const sites = [...(this.callSites.get(fn)?.values() ?? [])];
395
678
  if (sites.length === 0)
396
679
  return 'nothing in the file calls the function by name';
397
680
  if (sites.length > 1)
@@ -419,29 +702,32 @@ class Walker {
419
702
  nested(call, context) {
420
703
  if (!context.test && !context.hookScope)
421
704
  return false;
422
- this.problems.push(problem('nested-test', call, 'A test or describe declared inside a test or hook body is not collected by the runner, so it is not listed.', snippet(this.parsed.source.slice(call.start, call.end))));
705
+ this.report(problem('nested-test', call, 'A test or describe declared inside a test or hook body is not collected by the runner, so it is not listed.', snippet(this.frame.parsed.source.slice(call.start, call.end))));
423
706
  return true;
424
707
  }
425
708
  declareTest(call, declaration, context) {
426
- if (this.nested(call, context))
709
+ if (context.quiet || this.nested(call, context))
427
710
  return;
428
711
  const statement = this.statement(call);
429
712
  const test = {
430
713
  scope: context.scope,
431
714
  declaration,
432
715
  call,
716
+ site: this.site,
717
+ declaredAt: this.declaredAt(call),
718
+ order: context.registeredAt ?? call.start,
433
719
  states: [...declaration.states],
434
720
  annotations: [...declaration.details.annotations],
435
721
  isInLoop: context.isInLoop,
436
722
  isInCondition: context.isInCondition,
437
723
  isInFunction: context.isInFunction,
438
724
  comments: this.commentsAbove(statement.start),
439
- problems: [...declaration.problems],
725
+ problems: declaration.problems.map((item) => this.here(item)),
440
726
  };
441
727
  this.tests.push(test);
442
728
  if (context.declaredIn)
443
729
  this.declaredIn.set(test, context.declaredIn);
444
- context.scope.suite?.cuts.push(statement);
730
+ this.cut(context.scope, call);
445
731
  if (declaration.fn) {
446
732
  this.visitCallback(declaration.fn, {
447
733
  ...context,
@@ -454,9 +740,10 @@ class Walker {
454
740
  }
455
741
  }
456
742
  declareSuite(call, declaration, context) {
457
- if (this.nested(call, context))
743
+ if (context.quiet || this.nested(call, context))
458
744
  return;
459
- this.problems.push(...declaration.problems);
745
+ for (const item of declaration.problems)
746
+ this.report(item);
460
747
  const scope = {
461
748
  kind: 'describe',
462
749
  parent: context.scope,
@@ -466,10 +753,19 @@ class Walker {
466
753
  details: declaration.details,
467
754
  suite: null,
468
755
  };
469
- const suite = { scope, declaration, call, cuts: [] };
756
+ const suite = {
757
+ scope,
758
+ declaration,
759
+ call,
760
+ site: this.site,
761
+ declaredAt: this.declaredAt(call),
762
+ order: context.registeredAt ?? call.start,
763
+ cuts: [],
764
+ };
470
765
  scope.suite = suite;
471
766
  this.suites.push(suite);
472
- context.scope.suite?.cuts.push(this.statement(call));
767
+ this.cut(context.scope, call);
768
+ this.suiteFrames.set(suite, this.frame);
473
769
  if (declaration.fn) {
474
770
  this.visitCallback(declaration.fn, {
475
771
  ...context,
@@ -489,7 +785,7 @@ class Walker {
489
785
  }
490
786
  /** Comment lines on the lines directly above a statement, with no blank line in between. */
491
787
  commentsAbove(start) {
492
- const { comments, source } = this.parsed;
788
+ const { comments, source } = this.frame.parsed;
493
789
  const lines = [];
494
790
  let next = start;
495
791
  // Comments are in source order; find the last one that ends before the statement.
@@ -512,6 +808,7 @@ class Walker {
512
808
  return lines;
513
809
  }
514
810
  }
515
- export function extract(parsed, adapter, values) {
516
- return new Walker(parsed, adapter, values).run();
811
+ /** Walks the scanned file; `openModule` reads the modules of imported helpers that the file calls. */
812
+ export function extract(main, openModule) {
813
+ return new Walker(main, openModule).run();
517
814
  }
@@ -1,5 +1,6 @@
1
1
  import type { Expression, ModuleExportName, Node, Program, VariableDeclarator } from 'oxc-parser';
2
- import type { ModuleReader, Problem } from '../model.ts';
2
+ import type { ModuleReader, ModuleSource, Problem } from '../model.ts';
3
+ import { type ParsedFile } from '../parse.ts';
3
4
  import { type FileContext } from './common.ts';
4
5
  /** A name imported from a module other than the framework, which may be a custom `test` object in a fixtures file. */
5
6
  export interface ModuleImport {
@@ -26,26 +27,33 @@ export declare function requiredModule(expression: Expression): string | null;
26
27
  export declare function bindAll(declarators: VariableDeclarator[], bind: (declarator: VariableDeclarator) => boolean): void;
27
28
  /** Whether a module exports `name` as a test object. */
28
29
  export declare function exportsTestObject(program: Program, name: string, objects: TestObjects): boolean;
29
- /** Whether a module re-exports `name` from another module, which the scan does not follow. */
30
- export declare function reexportsFromModule(program: Program, name: string, isFramework: (source: string) => boolean): boolean;
31
- /**
32
- * Whether a module exports `name` as a value built from something imported from another module, directly or through
33
- * its own variables, as in `const test = baseTest.extend({...})` and then `export const browserTest = test`.
34
- */
35
- export declare function buildsFromModule(program: Program, name: string, isFramework: (source: string) => boolean): boolean;
36
- /** Whether a module imports or requires any of the given sources. */
30
+ /** Whether a module imports, re-exports or requires any of the given sources. */
37
31
  export declare function importsFrom(program: Program, matches: (source: string) => boolean): boolean;
32
+ /** Whether a name a module imports from another module is a test object there, which follows that module in turn. */
33
+ export type FollowImport = (source: string, imported: string) => boolean;
38
34
  /** How a framework recognizes the modules and test objects of a followed file. */
39
35
  export interface Framework {
40
36
  name: string;
41
37
  isSource: (source: string) => boolean;
42
- testObjects: (program: Program) => TestObjects;
38
+ /** The test objects of a followed module, which uses `follow` for the names it imports from other modules. */
39
+ testObjects: (program: Program, follow: FollowImport) => TestObjects;
43
40
  }
41
+ /** Shared with the walk of imported helpers, so that a module both read and walked is parsed once. */
42
+ export declare function parseModule(module: ModuleSource): ParsedFile;
44
43
  /**
45
- * Follows one import to see whether the module exports a test object. Only one level is followed. A module that has
46
- * nothing to do with the framework is not reported: the call only looked like a test.
44
+ * Whether `name` from `source` is a test object, following the module through the modules it re-exports from or builds
45
+ * on, as deep as it takes. When it isn't, the reason says where the chain ends; it is null for a module that has
46
+ * nothing to do with the framework, as when a call only looked like a test. Each module is followed once for a name,
47
+ * however many imports lead to it, as fixtures files that build on several others share their bases.
47
48
  */
49
+ export declare function followModule(source: string, name: string, readModule: ModuleReader | null, framework: Framework, path?: readonly string[], followed?: Map<string, Followed>): Followed;
50
+ interface Followed {
51
+ exported: boolean;
52
+ reason: string | null;
53
+ }
54
+ /** Follows the import of `name` in a test file, with the diagnostic to report when it isn't a test object. */
48
55
  export declare function followImport(name: string, imported: ModuleImport, readModule: ModuleReader | null, file: FileContext, framework: Framework): {
49
56
  exported: boolean;
50
57
  problem: Problem | null;
51
58
  };
59
+ export {};