@mintwork/honest-tests 0.0.0-stage → 0.1.1

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 (75) hide show
  1. package/.claude-plugin/marketplace.json +22 -0
  2. package/.claude-plugin/plugin.json +20 -0
  3. package/CHANGELOG.md +56 -0
  4. package/LICENSE +21 -0
  5. package/README.md +280 -2
  6. package/commands/audit.md +17 -0
  7. package/commands/mutate.md +10 -0
  8. package/dist/adapters/index.d.ts +3 -0
  9. package/dist/adapters/index.js +8 -0
  10. package/dist/adapters/js/ast.d.ts +24 -0
  11. package/dist/adapters/js/ast.js +199 -0
  12. package/dist/adapters/js/index.d.ts +25 -0
  13. package/dist/adapters/js/index.js +122 -0
  14. package/dist/adapters/js/mutants.d.ts +8 -0
  15. package/dist/adapters/js/mutants.js +190 -0
  16. package/dist/adapters/js/parse-tests.d.ts +5 -0
  17. package/dist/adapters/js/parse-tests.js +1101 -0
  18. package/dist/adapters/python/index.d.ts +17 -0
  19. package/dist/adapters/python/index.js +26 -0
  20. package/dist/cli.d.ts +2 -0
  21. package/dist/cli.js +21 -0
  22. package/dist/core/engine.d.ts +44 -0
  23. package/dist/core/engine.js +205 -0
  24. package/dist/core/types.d.ts +195 -0
  25. package/dist/core/types.js +7 -0
  26. package/dist/index.d.ts +6 -0
  27. package/dist/index.js +5 -0
  28. package/dist/mutate/runner.d.ts +59 -0
  29. package/dist/mutate/runner.js +379 -0
  30. package/dist/report/colors.d.ts +11 -0
  31. package/dist/report/colors.js +13 -0
  32. package/dist/report/human.d.ts +6 -0
  33. package/dist/report/human.js +79 -0
  34. package/dist/report/json.d.ts +4 -0
  35. package/dist/report/json.js +8 -0
  36. package/dist/rules/asserts-on-mock.d.ts +2 -0
  37. package/dist/rules/asserts-on-mock.js +53 -0
  38. package/dist/rules/asserts-on-own-literal.d.ts +2 -0
  39. package/dist/rules/asserts-on-own-literal.js +36 -0
  40. package/dist/rules/catch-only-assertion.d.ts +2 -0
  41. package/dist/rules/catch-only-assertion.js +22 -0
  42. package/dist/rules/conditional-assertion.d.ts +2 -0
  43. package/dist/rules/conditional-assertion.js +30 -0
  44. package/dist/rules/excessive-mocking.d.ts +3 -0
  45. package/dist/rules/excessive-mocking.js +22 -0
  46. package/dist/rules/focused-test.d.ts +2 -0
  47. package/dist/rules/focused-test.js +15 -0
  48. package/dist/rules/helpers.d.ts +5 -0
  49. package/dist/rules/helpers.js +18 -0
  50. package/dist/rules/index.d.ts +3 -0
  51. package/dist/rules/index.js +31 -0
  52. package/dist/rules/mocked-unit-under-test.d.ts +2 -0
  53. package/dist/rules/mocked-unit-under-test.js +32 -0
  54. package/dist/rules/never-failing-assertion.d.ts +2 -0
  55. package/dist/rules/never-failing-assertion.js +43 -0
  56. package/dist/rules/no-assertions.d.ts +2 -0
  57. package/dist/rules/no-assertions.js +16 -0
  58. package/dist/rules/skipped-test.d.ts +2 -0
  59. package/dist/rules/skipped-test.js +15 -0
  60. package/dist/rules/snapshot-only.d.ts +2 -0
  61. package/dist/rules/snapshot-only.js +18 -0
  62. package/dist/rules/swallowed-assertion.d.ts +2 -0
  63. package/dist/rules/swallowed-assertion.js +18 -0
  64. package/dist/rules/tautological-assertion.d.ts +2 -0
  65. package/dist/rules/tautological-assertion.js +42 -0
  66. package/dist/run.d.ts +12 -0
  67. package/dist/run.js +204 -0
  68. package/integrations/codex/AGENTS.md +20 -0
  69. package/integrations/cursor/honest-tests.mdc +24 -0
  70. package/package.json +73 -4
  71. package/skills/honest-tests/SKILL.md +141 -0
  72. package/skills/honest-tests/references/audit-procedure.md +128 -0
  73. package/skills/honest-tests/references/mutation-testing.md +68 -0
  74. package/skills/honest-tests/references/test-smells.md +268 -0
  75. package/skills/honest-tests/references/writing-honest-tests.md +96 -0
@@ -0,0 +1,1101 @@
1
+ import { basename } from 'node:path';
2
+ import { chainProps, chainRoot, describeExpr, isCall, isFunction, isMember, normText, parseSource, propName, stringValue, textOf, unwrap, walk, } from './ast.js';
3
+ const TEST_BASES = new Set(['it', 'test', 'specify', 'xit', 'fit', 'xtest']);
4
+ const SUITE_BASES = new Set(['describe', 'context', 'suite', 'xdescribe', 'fdescribe', 'xcontext']);
5
+ const TEST_MEMBER_MODS = new Set([
6
+ 'only',
7
+ 'skip',
8
+ 'todo',
9
+ 'each',
10
+ 'concurrent',
11
+ 'sequential',
12
+ 'fails',
13
+ 'failing',
14
+ 'skipIf',
15
+ 'runIf',
16
+ 'for',
17
+ 'shuffle',
18
+ ]);
19
+ const CHAI_WORDS = new Set([
20
+ 'to',
21
+ 'be',
22
+ 'been',
23
+ 'is',
24
+ 'that',
25
+ 'which',
26
+ 'and',
27
+ 'has',
28
+ 'have',
29
+ 'with',
30
+ 'at',
31
+ 'of',
32
+ 'same',
33
+ 'but',
34
+ 'does',
35
+ 'still',
36
+ 'also',
37
+ 'deep',
38
+ 'nested',
39
+ 'own',
40
+ 'ordered',
41
+ 'any',
42
+ 'all',
43
+ 'should',
44
+ ]);
45
+ const MODIFIER_WORDS = new Set(['not', 'resolves', 'rejects', ...CHAI_WORDS]);
46
+ const MOCK_CONFIG = /^(mockReturnValue|mockReturnValueOnce|mockResolvedValue|mockResolvedValueOnce|mockRejectedValue|mockRejectedValueOnce|mockImplementation|mockImplementationOnce|mockReturnThis)$/;
47
+ const MODULE_MOCK_FNS = new Set(['mock', 'doMock', 'unstable_mockModule']);
48
+ const HELPER_ASSERT = /^(expect|assert|verify|check|ensure|should|validate)([A-Z_]|$)/;
49
+ const FAIL_CALLS = new Set(['fail', 'expect.fail', 'assert.fail', 'chai.assert.fail']);
50
+ /** Modules whose exports are assertion functions: `import { equal } from 'node:assert'`. */
51
+ const ASSERT_MODULES = /^((node:)?assert(\/strict)?|chai|uvu\/assert|power-assert|tap|tape)$/;
52
+ /** `expect.<x>(...)` entry points that start a matcher chain like `expect(...)` does. */
53
+ const EXPECT_ENTRY_PROPS = new Set(['soft', 'poll', 'element']);
54
+ /** Jasmine-style globals that are often also ordinary local names (`fit()` a text, `context`). */
55
+ const SHADOWABLE_TEST_BASES = new Set(['fit', 'xit', 'xtest', 'fdescribe', 'xdescribe', 'context', 'xcontext', 'specify', 'suite']);
56
+ /** Calls that undo a mock's configured behavior. */
57
+ const MOCK_RESTORE = /^(mockRestore|mockReset)$/;
58
+ const MATCHER_KINDS = [
59
+ [/^(toMatchSnapshot|toMatchInlineSnapshot|toMatchFileSnapshot|toThrowErrorMatchingSnapshot|toThrowErrorMatchingInlineSnapshot|matchSnapshot)$/, 'snapshot'],
60
+ [/^(toBe|toEqual|toStrictEqual|toMatchObject|equal|equals|eq|eql|strictEqual|deepEqual|deepStrictEqual|notEqual|notStrictEqual|notDeepEqual|notDeepStrictEqual|toBeCloseTo|closeTo|sameMembers|members|true|false)$/, 'equal'],
61
+ [/^(toBeTruthy|ok|isOk|isTrue|assert)$/, 'truthy'],
62
+ [/^(toBeFalsy|notOk|isNotOk|isFalse)$/, 'falsy'],
63
+ [/^(toBeDefined|exist|exists|isDefined|isNotUndefined)$/, 'defined'],
64
+ [/^(toBeUndefined|undefined|isUndefined)$/, 'undefined'],
65
+ [/^(toBeNull|null|isNull)$/, 'null'],
66
+ [/^(toHaveLength|lengthOf|length|toHaveSize)$/, 'length'],
67
+ [/^(toBeGreaterThanOrEqual|gte|least|isAtLeast)$/, 'compare-gte'],
68
+ [/^(toBeGreaterThan|toBeLessThan|toBeLessThanOrEqual|gt|lt|lte|above|below|most|isAbove|isBelow|isAtMost)$/, 'compare-other'],
69
+ [/^(toThrow|toThrowError|throw|throws|Throw|rejects|doesNotThrow)$/, 'throws'],
70
+ [/^(toHaveBeenCalled|toHaveBeenCalledTimes|toHaveBeenCalledWith|toHaveBeenLastCalledWith|toHaveBeenNthCalledWith|toBeCalled|toBeCalledWith|toBeCalledTimes|toHaveReturned|toHaveReturnedWith|toHaveReturnedTimes|toHaveBeenCalledOnce|calledWith|called|calledOnce)$/, 'called'],
71
+ [/^(toBeInstanceOf|toBeTypeOf|instanceOf|instanceof|a|an|typeOf|isString|isNumber|isArray|isObject|isFunction)$/, 'type'],
72
+ ];
73
+ export function matcherKindOf(name) {
74
+ if (!name)
75
+ return 'none';
76
+ for (const [re, kind] of MATCHER_KINDS)
77
+ if (re.test(name))
78
+ return kind;
79
+ return 'other';
80
+ }
81
+ function resolveTestCall(call, localNames) {
82
+ const mods = [];
83
+ let curried = false;
84
+ let cur = call.callee;
85
+ for (;;) {
86
+ if (cur.type === 'Identifier')
87
+ break;
88
+ if (isMember(cur)) {
89
+ const name = propName(cur);
90
+ if (!name || !TEST_MEMBER_MODS.has(name))
91
+ return null;
92
+ mods.unshift(name);
93
+ cur = cur.object;
94
+ continue;
95
+ }
96
+ if (isCall(cur) && !curried) {
97
+ // it.each(table)(name, fn) / test.skipIf(cond)(name, fn)
98
+ curried = true;
99
+ cur = cur.callee;
100
+ continue;
101
+ }
102
+ return null;
103
+ }
104
+ const base = cur.name;
105
+ const isTest = TEST_BASES.has(base);
106
+ const isSuite = SUITE_BASES.has(base);
107
+ if (!isTest && !isSuite)
108
+ return null;
109
+ if (localNames?.has(base) && SHADOWABLE_TEST_BASES.has(base))
110
+ return null;
111
+ if (curried && !mods.some((m) => m === 'each' || m === 'skipIf' || m === 'runIf' || m === 'for'))
112
+ return null;
113
+ let modifier = null;
114
+ if (base.startsWith('x') || mods.includes('skip'))
115
+ modifier = 'skip';
116
+ if (base.startsWith('f') && base !== 'fails')
117
+ modifier = 'only';
118
+ if (mods.includes('only'))
119
+ modifier = 'only';
120
+ if (mods.includes('todo'))
121
+ modifier = 'todo';
122
+ return { base, kind: isTest ? 'test' : 'suite', modifier, args: call.arguments };
123
+ }
124
+ function lineText(lines, start, end) {
125
+ const slice = lines.slice(start - 1, Math.min(end, start + 2));
126
+ return slice.map((l) => l.trim()).join(' ').slice(0, 200);
127
+ }
128
+ /** Expected unit-under-test name for a test file: `pricing.test.ts` -> `pricing`. */
129
+ export function unitNameForTestFile(path) {
130
+ let name = basename(path).replace(/\.(m|c)?[jt]sx?$/, '');
131
+ name = name.replace(/[._-](test|spec|unit|int|integration)$/i, '');
132
+ name = name.replace(/\.(test|spec)$/i, '');
133
+ return name.toLowerCase();
134
+ }
135
+ function specifierUnitName(spec) {
136
+ const parts = spec.split('/').filter(Boolean);
137
+ let last = parts[parts.length - 1] ?? '';
138
+ last = last.replace(/\.(m|c)?[jt]sx?$/, '');
139
+ if (last === 'index' && parts.length > 1)
140
+ last = parts[parts.length - 2];
141
+ return last.toLowerCase();
142
+ }
143
+ function isLocalSpecifier(spec) {
144
+ return /^(\.{1,2}\/|\/|@\/|~\/?|#|src\/)/.test(spec);
145
+ }
146
+ /** Find mock configuration in a subtree (top-level setup or a single test body). */
147
+ function scanMocks(source, root, skip) {
148
+ const setups = [];
149
+ walk(root, (node) => {
150
+ if (skip && skip(node))
151
+ return false;
152
+ if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier' && node.init) {
153
+ const init = unwrap(node.init);
154
+ if (isCall(init)) {
155
+ const rootNode = chainRoot(init);
156
+ const props = chainProps(init);
157
+ const isMockFactory = rootNode.type === 'Identifier' && (rootNode.name === 'vi' || rootNode.name === 'jest') && (props[0] === 'fn' || props[0] === 'spyOn');
158
+ if (isMockFactory) {
159
+ const configures = props.some((p) => MOCK_CONFIG.test(p)) || (props[0] === 'fn' && innermostCallArgs(init).length > 0);
160
+ setups.push({ target: node.id.name, line: node.loc.start.line, configuresReturn: configures });
161
+ if (props[0] === 'spyOn') {
162
+ const spy = innermostCall(init);
163
+ const target = spyTarget(source, spy);
164
+ if (target)
165
+ setups.push({ target, line: node.loc.start.line, configuresReturn: configures });
166
+ }
167
+ }
168
+ }
169
+ }
170
+ if (isCall(node) && isMember(node.callee)) {
171
+ const callee = node.callee;
172
+ const name = propName(callee);
173
+ if (name && MOCK_CONFIG.test(name)) {
174
+ const target = mockTargetText(source, callee.object);
175
+ if (target)
176
+ setups.push({ target, line: node.loc.start.line, configuresReturn: true });
177
+ }
178
+ }
179
+ });
180
+ return { setups };
181
+ }
182
+ function innermostCall(call) {
183
+ let cur = call;
184
+ let last = call;
185
+ for (;;) {
186
+ if (isCall(cur)) {
187
+ last = cur;
188
+ cur = cur.callee;
189
+ }
190
+ else if (isMember(cur))
191
+ cur = cur.object;
192
+ else
193
+ return last;
194
+ }
195
+ }
196
+ function innermostCallArgs(call) {
197
+ return innermostCall(call).arguments;
198
+ }
199
+ function spyTarget(source, spy) {
200
+ const [obj, method] = spy.arguments;
201
+ const m = stringValue(method);
202
+ if (!obj || !m)
203
+ return null;
204
+ return `${normText(source, obj)}.${m}`;
205
+ }
206
+ /** Text of what a `.mockReturnValue` call configures: `fn`, `vi.mocked(fn)` -> `fn`, `vi.spyOn(o,'m')` -> `o.m`. */
207
+ function mockTargetText(source, obj) {
208
+ const node = unwrap(obj);
209
+ if (isCall(node)) {
210
+ const root = chainRoot(node);
211
+ const props = chainProps(node);
212
+ if (root.type === 'Identifier' && (root.name === 'vi' || root.name === 'jest')) {
213
+ if (props[0] === 'mocked' && node.arguments[0])
214
+ return normText(source, node.arguments[0]);
215
+ if (props[0] === 'spyOn')
216
+ return spyTarget(source, innermostCall(node));
217
+ return null;
218
+ }
219
+ // chained: fn.mockReturnValueOnce(1).mockReturnValueOnce(2)
220
+ if (isMember(node.callee)) {
221
+ const n = propName(node.callee);
222
+ if (n && MOCK_CONFIG.test(n))
223
+ return mockTargetText(source, node.callee.object);
224
+ }
225
+ return null;
226
+ }
227
+ return normText(source, node);
228
+ }
229
+ function calleeName(source, call) {
230
+ return isCall(call) ? normText(source, call.callee) : '';
231
+ }
232
+ function containsFailOrThrow(source, node) {
233
+ if (!node)
234
+ return false;
235
+ let found = false;
236
+ walk(node, (n) => {
237
+ if (found)
238
+ return false;
239
+ if (isFunction(n) || n.type === 'FunctionDeclaration')
240
+ return n === node ? undefined : false;
241
+ if (n.type === 'ThrowStatement')
242
+ found = true;
243
+ if (isCall(n) && FAIL_CALLS.has(calleeName(source, n)))
244
+ found = true;
245
+ return undefined;
246
+ });
247
+ return found;
248
+ }
249
+ /** `vi.mock('./x', async (orig) => ({ ...(await orig()), f }))`: the factory pulls in the real module. */
250
+ function factoryCallsItsParam(factory) {
251
+ if (!factory || !isFunction(factory))
252
+ return false;
253
+ const param = factory.params[0];
254
+ if (!param || param.type !== 'Identifier')
255
+ return false;
256
+ let found = false;
257
+ walk(factory.body, (n) => {
258
+ if (found)
259
+ return false;
260
+ if (isCall(n) && n.callee.type === 'Identifier' && n.callee.name === param.name)
261
+ found = true;
262
+ return undefined;
263
+ });
264
+ return found;
265
+ }
266
+ const EMPTY_VOCAB = { assertFns: new Map(), assertRoots: new Set(['assert']), helpers: new Set() };
267
+ /** True if `call` starts or is an assertion: expect chains, assert.*, imported assert fns, asserting helpers. */
268
+ function isAssertionCall(call, vocab) {
269
+ if (!isCall(call))
270
+ return false;
271
+ const callee = call.callee;
272
+ const root = chainRoot(callee);
273
+ if (root.type === 'Identifier') {
274
+ if (root.name === 'expect' || vocab.assertRoots.has(root.name))
275
+ return true;
276
+ if (callee === root && (vocab.assertFns.has(root.name) || vocab.helpers.has(root.name)))
277
+ return true;
278
+ }
279
+ const name = callee.type === 'Identifier' ? callee.name : isMember(callee) ? propName(callee) : null;
280
+ if (name && HELPER_ASSERT.test(name))
281
+ return true;
282
+ return isMember(callee) && chainProps(callee).includes('should');
283
+ }
284
+ function containsAssertion(node, vocab) {
285
+ if (!node)
286
+ return false;
287
+ let found = false;
288
+ walk(node, (n) => {
289
+ if (found)
290
+ return false;
291
+ if (isAssertionCall(n, vocab))
292
+ found = true;
293
+ return undefined;
294
+ });
295
+ return found;
296
+ }
297
+ /** `if (a) {assert} else if (b) {assert} else {assert}`: some assertion runs whichever branch is taken. */
298
+ function everyBranchAsserts(node, vocab) {
299
+ if (node.type === 'IfStatement') {
300
+ if (!node.alternate || !containsAssertion(node.consequent, vocab))
301
+ return false;
302
+ return everyBranchAsserts(node.alternate, vocab);
303
+ }
304
+ if (node.type === 'ConditionalExpression')
305
+ return containsAssertion(node.consequent, vocab) && containsAssertion(node.alternate, vocab);
306
+ return containsAssertion(node, vocab);
307
+ }
308
+ /** The value a guard tests: `!r.success` / `r.success === false` / `(x)` -> `r.success` / `x`. */
309
+ function guardOperand(source, test) {
310
+ let node = unwrap(test);
311
+ for (;;) {
312
+ if (node.type === 'UnaryExpression' && node.operator === '!')
313
+ node = unwrap(node.argument);
314
+ else if (node.type === 'BinaryExpression' && ['===', '!==', '==', '!='].includes(node.operator)) {
315
+ const right = unwrap(node.right);
316
+ const left = unwrap(node.left);
317
+ const isConst = (n) => n.type === 'BooleanLiteral' ||
318
+ n.type === 'NullLiteral' ||
319
+ n.type === 'StringLiteral' ||
320
+ n.type === 'NumericLiteral' ||
321
+ (n.type === 'Identifier' && n.name === 'undefined');
322
+ if (isConst(right))
323
+ node = left;
324
+ else if (isConst(left))
325
+ node = right;
326
+ else
327
+ break;
328
+ }
329
+ else
330
+ break;
331
+ }
332
+ return normText(source, node);
333
+ }
334
+ function containsReturn(node) {
335
+ let found = false;
336
+ walk(node, (n) => {
337
+ if (found || isFunction(n) || n.type === 'FunctionDeclaration')
338
+ return false;
339
+ if (n.type === 'ReturnStatement')
340
+ found = true;
341
+ return undefined;
342
+ });
343
+ return found;
344
+ }
345
+ /** Variables the catch body sets to `true` (`threw = true`), so a later `expect(threw)` proves it ran. */
346
+ function flagsSetIn(node) {
347
+ const out = [];
348
+ walk(node, (n) => {
349
+ if (n.type === 'AssignmentExpression' && n.left.type === 'Identifier' && unwrap(n.right).type === 'BooleanLiteral')
350
+ out.push(n.left.name);
351
+ });
352
+ return out;
353
+ }
354
+ /**
355
+ * A catch whose skipping fails the test anyway: the try throws/fails after the risky call, the catch
356
+ * returns and the code after the try fails, or the catch sets a flag that is asserted afterwards.
357
+ */
358
+ function catchIsGuarded(source, tryStmt, parent, vocab) {
359
+ if (containsFailOrThrow(source, tryStmt.block))
360
+ return true;
361
+ const handler = tryStmt.handler;
362
+ if (!handler || !parent || !('body' in parent) || !Array.isArray(parent.body))
363
+ return false;
364
+ const siblings = parent.body;
365
+ const after = siblings.slice(siblings.indexOf(tryStmt) + 1);
366
+ if (after.length === 0)
367
+ return false;
368
+ if (containsReturn(handler.body) && after.some((s) => containsFailOrThrow(source, s)))
369
+ return true;
370
+ const flags = flagsSetIn(handler.body);
371
+ if (flags.length === 0)
372
+ return false;
373
+ return after.some((s) => {
374
+ let hit = false;
375
+ walk(s, (n) => {
376
+ if (hit)
377
+ return false;
378
+ if (isAssertionCall(n, vocab) && isCall(n) && n.arguments.some((a) => a.type === 'Identifier' && flags.includes(a.name)))
379
+ hit = true;
380
+ return undefined;
381
+ });
382
+ return hit;
383
+ });
384
+ }
385
+ function assertionContext(source, target, ancestors, bodyIndex, vocab = EMPTY_VOCAB) {
386
+ const ctx = { inSwallowingTry: false, inCatch: false, catchGuarded: false, inConditional: false };
387
+ const guards = [];
388
+ for (let i = ancestors.length - 1; i >= bodyIndex; i--) {
389
+ const a = ancestors[i];
390
+ const child = ancestors[i + 1] ?? target;
391
+ if (a.type === 'CatchClause') {
392
+ ctx.inCatch = true;
393
+ const tryStmt = ancestors[i - 1];
394
+ if (tryStmt && tryStmt.type === 'TryStatement' && catchIsGuarded(source, tryStmt, ancestors[i - 2], vocab))
395
+ ctx.catchGuarded = true;
396
+ }
397
+ if (a.type === 'TryStatement' && child === a.block && a.handler) {
398
+ if (!containsFailOrThrow(source, a.handler.body))
399
+ ctx.inSwallowingTry = true;
400
+ }
401
+ if ((a.type === 'IfStatement' || a.type === 'ConditionalExpression') && child !== a.test) {
402
+ // Walk up an else-if chain to its head; if every branch asserts, something is always checked.
403
+ let j = i;
404
+ while (j - 1 >= bodyIndex) {
405
+ const up = ancestors[j - 1];
406
+ if (up.type === 'IfStatement' && up.alternate === ancestors[j])
407
+ j--;
408
+ else
409
+ break;
410
+ }
411
+ if (!everyBranchAsserts(ancestors[j], vocab)) {
412
+ ctx.inConditional = true;
413
+ guards.push(guardOperand(source, a.test));
414
+ }
415
+ }
416
+ if (a.type === 'LogicalExpression' && child === a.right) {
417
+ ctx.inConditional = true;
418
+ // `expect(a).toBe(1) && expect(b)...`: the left side is an assertion returning undefined — never excusable.
419
+ guards.push(isAssertionCall(chainCallOf(a.left), vocab) ? '\u0000assertion' : guardOperand(source, a.left));
420
+ }
421
+ }
422
+ if (guards.length)
423
+ ctx.guards = guards;
424
+ return ctx;
425
+ }
426
+ /** Innermost call at the root of a matcher chain (`expect(x).toBe(1)` -> `expect(x)`). */
427
+ function chainCallOf(node) {
428
+ let cur = unwrap(node);
429
+ for (;;) {
430
+ if (isCall(cur) && isMember(cur.callee))
431
+ cur = cur.callee.object;
432
+ else if (isMember(cur))
433
+ cur = cur.object;
434
+ else
435
+ return cur;
436
+ }
437
+ }
438
+ /** TS wrappers / parens that don't change a value. */
439
+ const VALUE_WRAPPERS = new Set(['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'TSTypeAssertion', 'ParenthesizedExpression', 'AwaitExpression']);
440
+ const PURE_GLOBALS = new Set(['undefined', 'NaN', 'Infinity']);
441
+ /** A value we know is a primitive (e.g. an array's `length`) but whose node we can't point at. */
442
+ const PRIMITIVE = Symbol('primitive');
443
+ /** Static key of a member read or object property: `p.a`, `p['a']`, `p[0]`, `{ a: 1 }`, `{ 'a': 1 }`. */
444
+ function staticKey(key, computed, pure) {
445
+ if (!computed)
446
+ return key.type === 'Identifier' ? key.name : key.type === 'StringLiteral' ? key.value : key.type === 'NumericLiteral' ? String(key.value) : null;
447
+ const k = resolveLiteral(key, pure);
448
+ if (k === PRIMITIVE || !k)
449
+ return null;
450
+ if (k.type === 'StringLiteral')
451
+ return k.value;
452
+ if (k.type === 'NumericLiteral')
453
+ return String(k.value);
454
+ return stringValue(k);
455
+ }
456
+ /** Look `key` up among an object literal's own properties (later ones win; spreads of known literals are followed). */
457
+ function ownProperty(obj, key, pure) {
458
+ for (let i = obj.properties.length - 1; i >= 0; i--) {
459
+ const p = obj.properties[i];
460
+ if (p.type === 'SpreadElement') {
461
+ const inner = resolveLiteral(p.argument, pure);
462
+ if (inner && inner !== PRIMITIVE && inner.type === 'ObjectExpression') {
463
+ const hit = ownProperty(inner, key, pure);
464
+ if (hit)
465
+ return hit;
466
+ continue;
467
+ }
468
+ return null;
469
+ }
470
+ if (p.type !== 'ObjectProperty')
471
+ return null;
472
+ const k = staticKey(p.key, p.computed, pure);
473
+ if (k === null)
474
+ return null;
475
+ if (k === key)
476
+ return p.value;
477
+ }
478
+ return null;
479
+ }
480
+ /**
481
+ * The literal node a pure expression evaluates to, following bindings and own-property reads.
482
+ * Returns null when a read isn't an own property written in the literal: `{}.polluted` comes from
483
+ * the prototype, which code under test can change (prototype-pollution tests rely on exactly that).
484
+ */
485
+ function resolveLiteral(node, pure) {
486
+ const n = unwrap(node);
487
+ if (n.type === 'Identifier')
488
+ return pure.has(n.name) ? resolveLiteral(pure.get(n.name).init, pure) : null;
489
+ if (!isMember(n))
490
+ return n;
491
+ const obj = resolveLiteral(n.object, pure);
492
+ const key = staticKey(n.property, n.computed, pure);
493
+ if (!obj || obj === PRIMITIVE || key === null)
494
+ return null;
495
+ if (obj.type === 'ObjectExpression') {
496
+ const v = ownProperty(obj, key, pure);
497
+ return v === null ? null : v === PRIMITIVE ? PRIMITIVE : resolveLiteral(v, pure);
498
+ }
499
+ const isString = obj.type === 'StringLiteral' || obj.type === 'TemplateLiteral';
500
+ if (obj.type !== 'ArrayExpression' && !isString)
501
+ return null;
502
+ if (key === 'length')
503
+ return PRIMITIVE;
504
+ if (!/^\d+$/.test(key))
505
+ return null;
506
+ if (isString)
507
+ return PRIMITIVE;
508
+ const els = obj.elements;
509
+ if (els.some((e) => !e || e.type === 'SpreadElement'))
510
+ return null;
511
+ const el = els[Number(key)];
512
+ return el ? resolveLiteral(el, pure) : null;
513
+ }
514
+ /**
515
+ * True if `node` is built only from literals and `pure` bindings: object/array/template literals,
516
+ * operators and own-property reads. Any call, `new`, function, import or outer identifier makes it
517
+ * impure, as does a read that could reach the prototype.
518
+ */
519
+ function isPureValue(node, pure) {
520
+ const n = unwrap(node);
521
+ switch (n.type) {
522
+ case 'StringLiteral':
523
+ case 'NumericLiteral':
524
+ case 'BooleanLiteral':
525
+ case 'NullLiteral':
526
+ case 'BigIntLiteral':
527
+ case 'RegExpLiteral':
528
+ return true;
529
+ case 'Identifier':
530
+ return PURE_GLOBALS.has(n.name) || pure.has(n.name);
531
+ case 'TemplateLiteral':
532
+ return n.expressions.every((e) => isPureValue(e, pure));
533
+ case 'ObjectExpression':
534
+ return n.properties.every((p) => {
535
+ if (p.type === 'SpreadElement')
536
+ return isPureValue(p.argument, pure);
537
+ if (p.type !== 'ObjectProperty')
538
+ return false; // methods and getters run code
539
+ if (p.computed && !isPureValue(p.key, pure))
540
+ return false;
541
+ return isPureValue(p.value, pure);
542
+ });
543
+ case 'ArrayExpression':
544
+ return n.elements.every((e) => !e || isPureValue(e.type === 'SpreadElement' ? e.argument : e, pure));
545
+ case 'UnaryExpression':
546
+ return n.operator !== 'delete' && isPureValue(n.argument, pure);
547
+ case 'BinaryExpression':
548
+ // `in` / `instanceof` consult the prototype chain.
549
+ if (n.operator === 'in' || n.operator === 'instanceof')
550
+ return false;
551
+ return n.left.type !== 'PrivateName' && isPureValue(n.left, pure) && isPureValue(n.right, pure);
552
+ case 'LogicalExpression':
553
+ return isPureValue(n.left, pure) && isPureValue(n.right, pure);
554
+ case 'ConditionalExpression':
555
+ return isPureValue(n.test, pure) && isPureValue(n.consequent, pure) && isPureValue(n.alternate, pure);
556
+ case 'MemberExpression':
557
+ case 'OptionalMemberExpression':
558
+ if (n.computed && !isPureValue(n.property, pure))
559
+ return false;
560
+ return isPureValue(n.object, pure) && resolveLiteral(n, pure) !== null;
561
+ default:
562
+ return false;
563
+ }
564
+ }
565
+ /** First pure binding an expression reads, for evidence. */
566
+ function firstPureRef(node, pure) {
567
+ let found = null;
568
+ walk(node, (n, anc) => {
569
+ if (found)
570
+ return false;
571
+ const parent = anc[anc.length - 1];
572
+ if (n.type !== 'Identifier' || !pure.has(n.name))
573
+ return undefined;
574
+ if (parent && isMember(parent) && parent.property === n && !parent.computed)
575
+ return undefined;
576
+ if (parent && parent.type === 'ObjectProperty' && parent.key === n && parent.value !== n && !parent.computed)
577
+ return undefined;
578
+ found = n.name;
579
+ return undefined;
580
+ });
581
+ return found;
582
+ }
583
+ /** A call whose arguments only get compared, never mutated: expect(...), matcher calls, assert.*. */
584
+ function isComparingCall(call, vocab) {
585
+ if (!isCall(call))
586
+ return false;
587
+ const callee = call.callee;
588
+ const root = chainRoot(callee);
589
+ if (root.type !== 'Identifier')
590
+ return false;
591
+ if (root.name === 'expect')
592
+ return true;
593
+ if (vocab.assertRoots.has(root.name))
594
+ return true;
595
+ return callee === root && vocab.assertFns.has(root.name);
596
+ }
597
+ const PURE_CONTAINERS = new Set([
598
+ 'MemberExpression',
599
+ 'OptionalMemberExpression',
600
+ 'ObjectProperty',
601
+ 'ObjectExpression',
602
+ 'ArrayExpression',
603
+ 'SpreadElement',
604
+ 'TemplateLiteral',
605
+ 'BinaryExpression',
606
+ 'LogicalExpression',
607
+ 'ConditionalExpression',
608
+ ...VALUE_WRAPPERS,
609
+ ]);
610
+ /**
611
+ * Variables in a test body bound to values built purely from literals (`const p = { service: 's3' }`)
612
+ * and only ever read by assertions or by other such literals — never passed to code, mutated or
613
+ * reassigned.
614
+ */
615
+ function ownLiteralBindings(body, vocab) {
616
+ const decls = new Map();
617
+ const bindingCount = new Map();
618
+ const bump = (name) => bindingCount.set(name, (bindingCount.get(name) ?? 0) + 1);
619
+ walk(body, (n) => {
620
+ if (n.type === 'VariableDeclarator') {
621
+ if (n.id.type === 'Identifier') {
622
+ bump(n.id.name);
623
+ if (n.init)
624
+ decls.set(n.id.name, n);
625
+ }
626
+ else
627
+ walk(n.id, (p) => void (p.type === 'Identifier' && bump(p.name)));
628
+ }
629
+ if (isFunction(n) || n.type === 'FunctionDeclaration' || n.type === 'CatchClause') {
630
+ const params = n.type === 'CatchClause' ? (n.param ? [n.param] : []) : n.params;
631
+ for (const p of params)
632
+ walk(p, (q) => void (q.type === 'Identifier' && bump(q.name)));
633
+ if (n.type === 'FunctionDeclaration' && n.id)
634
+ bump(n.id.name);
635
+ }
636
+ });
637
+ const pure = new Map();
638
+ for (const [name, d] of decls)
639
+ if (bindingCount.get(name) === 1)
640
+ pure.set(name, { line: d.loc.start.line, init: d.init });
641
+ /** Every read of a pure binding must end in an assertion's arguments or another pure literal. */
642
+ const referencesOk = (name) => {
643
+ let ok = true;
644
+ walk(body, (n, anc) => {
645
+ if (!ok)
646
+ return false;
647
+ if (n.type !== 'Identifier' || n.name !== name)
648
+ return undefined;
649
+ const parent = anc[anc.length - 1];
650
+ if (!parent)
651
+ return undefined;
652
+ if (parent.type === 'VariableDeclarator' && parent.id === n)
653
+ return undefined;
654
+ if (isMember(parent) && parent.property === n && !parent.computed)
655
+ return undefined;
656
+ if (parent.type === 'ObjectProperty' && parent.key === n && parent.value !== n && !parent.computed)
657
+ return undefined;
658
+ if (anc.some((a) => a.type.startsWith('TS') && !VALUE_WRAPPERS.has(a.type)))
659
+ return undefined; // type position
660
+ let child = n;
661
+ for (let i = anc.length - 1; i >= 0; i--) {
662
+ const p = anc[i];
663
+ const climbs = PURE_CONTAINERS.has(p.type) ||
664
+ (p.type === 'UnaryExpression' && p.operator !== 'delete');
665
+ // A member read climbs only from its object (or computed key); `p.x(...)` is caught at the call.
666
+ if (climbs && !(p.type === 'ObjectProperty' && p.key === child && !p.computed)) {
667
+ child = p;
668
+ continue;
669
+ }
670
+ if (p.type === 'VariableDeclarator' && p.init === child && p.id.type === 'Identifier' && pure.has(p.id.name))
671
+ return undefined;
672
+ if (isCall(p) && p.arguments.includes(child) && isComparingCall(p, vocab))
673
+ return undefined;
674
+ ok = false;
675
+ return false;
676
+ }
677
+ ok = false;
678
+ return false;
679
+ });
680
+ return ok;
681
+ };
682
+ for (let changed = true; changed;) {
683
+ changed = false;
684
+ for (const name of [...pure.keys()]) {
685
+ if (isPureValue(pure.get(name).init, pure) && referencesOk(name))
686
+ continue;
687
+ pure.delete(name);
688
+ changed = true;
689
+ }
690
+ }
691
+ return pure;
692
+ }
693
+ /** Attach `ownLiteral` when an assertion operand reads only test-local literal bindings. */
694
+ function withOwnLiteral(info, node, pure) {
695
+ if (info.kind === 'literal' || !isPureValue(node, pure))
696
+ return info;
697
+ const name = firstPureRef(node, pure);
698
+ // Inline literals (`expect({ a: 1 })`) are tautological-/never-failing-assertion's territory.
699
+ if (!name)
700
+ return { ...info, pureLiteral: true };
701
+ return { ...info, pureLiteral: true, ownLiteral: { name, line: pure.get(name).line } };
702
+ }
703
+ function scanAssertions(source, lines, body, aliases, vocab = EMPTY_VOCAB) {
704
+ const assertions = [];
705
+ const pure = ownLiteralBindings(body, vocab);
706
+ const describeOperand = (n) => withOwnLiteral(describeExpr(source, n), n, pure);
707
+ let helperAssertionCalls = 0;
708
+ let hasAssertionCountGuard = false;
709
+ const withAlias = (info) => {
710
+ if (info.kind === 'identifier' && aliases.has(info.text))
711
+ return { ...info, aliasCalleeText: aliases.get(info.text) };
712
+ return info;
713
+ };
714
+ walk(body, (node, ancestors) => {
715
+ if (!isCall(node))
716
+ return;
717
+ const callee = node.callee;
718
+ const bodyIndex = 0;
719
+ // expect.assertions(n) / expect.hasAssertions()
720
+ if (isMember(callee) && callee.object.type === 'Identifier' && callee.object.name === 'expect') {
721
+ const n = propName(callee);
722
+ if (n === 'assertions' || n === 'hasAssertions')
723
+ hasAssertionCountGuard = true;
724
+ if (!EXPECT_ENTRY_PROPS.has(n ?? ''))
725
+ return;
726
+ }
727
+ const isExpectRoot = (callee.type === 'Identifier' && callee.name === 'expect') ||
728
+ (isMember(callee) && callee.object.type === 'Identifier' && callee.object.name === 'expect' && EXPECT_ENTRY_PROPS.has(propName(callee) ?? ''));
729
+ // expect.poll(fn) asserts on what fn returns, not on the function object.
730
+ const polls = isMember(callee) && propName(callee) === 'poll';
731
+ if (isExpectRoot) {
732
+ // Climb the matcher chain: expect(x).not.resolves.toBe(y)
733
+ const props = [];
734
+ let cur = node;
735
+ let called = false;
736
+ let args = [];
737
+ let i = ancestors.length - 1;
738
+ for (; i >= 0; i--) {
739
+ const p = ancestors[i];
740
+ if (isMember(p) && p.object === cur) {
741
+ const n = propName(p);
742
+ if (n)
743
+ props.push(n);
744
+ cur = p;
745
+ continue;
746
+ }
747
+ if (isCall(p) && p.callee === cur) {
748
+ called = true;
749
+ args = p.arguments;
750
+ cur = p;
751
+ }
752
+ break;
753
+ }
754
+ const outerParent = called ? ancestors[i - 1] : ancestors[i];
755
+ const matcher = [...props].reverse().find((p) => !MODIFIER_WORDS.has(p)) ?? null;
756
+ const chaiStyle = props.some((p) => CHAI_WORDS.has(p));
757
+ let kind = matcherKindOf(matcher);
758
+ if (!called && !chaiStyle)
759
+ kind = 'none';
760
+ if (matcher === 'true' && chaiStyle)
761
+ kind = 'equal';
762
+ const isAsync = props.includes('resolves') || props.includes('rejects');
763
+ const awaited = cur === body ||
764
+ (!!outerParent &&
765
+ (outerParent.type === 'AwaitExpression' ||
766
+ outerParent.type === 'ReturnStatement' ||
767
+ outerParent.type === 'ArrayExpression' ||
768
+ (outerParent.type === 'ArrowFunctionExpression' && outerParent.body === cur) ||
769
+ (isMember(outerParent) && ['then', 'catch', 'finally'].includes(propName(outerParent) ?? '')) ||
770
+ // const pending = expect(p).rejects.toThrow(); ...; await pending
771
+ (outerParent.type === 'VariableDeclarator' && outerParent.id.type === 'Identifier' && isReferencedElsewhere(body, outerParent.id))));
772
+ const subjectNode = node.arguments[0];
773
+ let subject = subjectNode ? withAlias(describeOperand(subjectNode)) : null;
774
+ if (subject && polls)
775
+ subject = { text: subject.text, kind: 'other' };
776
+ const start = node.loc.start;
777
+ assertions.push({
778
+ line: start.line,
779
+ column: start.column + 1,
780
+ text: lineText(lines, start.line, cur.loc.end.line),
781
+ style: 'expect',
782
+ subject,
783
+ matcher,
784
+ matcherKind: kind,
785
+ negated: props.includes('not'),
786
+ args: args.map(describeOperand),
787
+ async: isAsync,
788
+ awaitedOrReturned: awaited,
789
+ context: assertionContext(source, node, ancestors, bodyIndex, vocab),
790
+ });
791
+ return;
792
+ }
793
+ // assert(x) / assert.equal(a, b) / assert.strict.equal(...) / equal(a, b) imported from node:assert
794
+ const root = chainRoot(callee);
795
+ const importedFn = callee.type === 'Identifier' ? vocab.assertFns.get(callee.name) : undefined;
796
+ if (importedFn !== undefined || (root.type === 'Identifier' && vocab.assertRoots.has(root.name) && (callee === root || isMember(callee)))) {
797
+ const method = importedFn ?? (callee === root ? 'assert' : propName(callee));
798
+ if (method === 'fail') {
799
+ helperAssertionCalls++;
800
+ return;
801
+ }
802
+ const start = node.loc.start;
803
+ const [subj, ...rest] = node.arguments;
804
+ const m = method ?? 'assert';
805
+ assertions.push({
806
+ line: start.line,
807
+ column: start.column + 1,
808
+ text: lineText(lines, start.line, node.loc.end.line),
809
+ style: 'assert',
810
+ subject: subj ? withAlias(describeOperand(subj)) : null,
811
+ matcher: m,
812
+ matcherKind: matcherKindOf(m),
813
+ negated: /^(not|isNot|doesNot)/.test(m),
814
+ args: rest.slice(0, 1).map(describeOperand),
815
+ async: m === 'rejects' || m === 'doesNotReject',
816
+ awaitedOrReturned: true,
817
+ context: assertionContext(source, node, ancestors, bodyIndex, vocab),
818
+ });
819
+ return;
820
+ }
821
+ // Helper assertions: expectValidUser(u), assertOk(res), request(app).get('/').expect(200), x.should.equal(1)
822
+ const name = callee.type === 'Identifier' ? callee.name : isMember(callee) ? propName(callee) : null;
823
+ if (name && (HELPER_ASSERT.test(name) || name === 'expect'))
824
+ helperAssertionCalls++;
825
+ else if (callee.type === 'Identifier' && vocab.helpers.has(callee.name))
826
+ helperAssertionCalls++;
827
+ else if (isMember(callee) && chainProps(callee).includes('should'))
828
+ helperAssertionCalls++;
829
+ return;
830
+ });
831
+ return { assertions, helperAssertionCalls, hasAssertionCountGuard };
832
+ }
833
+ /** True if `id` is used anywhere in `scope` other than at its own declaration. */
834
+ function isReferencedElsewhere(scope, id) {
835
+ let found = false;
836
+ walk(scope, (n) => {
837
+ if (found)
838
+ return false;
839
+ if (n.type === 'Identifier' && n !== id && n.name === id.name)
840
+ found = true;
841
+ return undefined;
842
+ });
843
+ return found;
844
+ }
845
+ function collectAliases(source, body) {
846
+ const aliases = new Map();
847
+ walk(body, (node) => {
848
+ if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier' && node.init) {
849
+ const init = unwrap(node.init);
850
+ if (isCall(init))
851
+ aliases.set(node.id.name, normText(source, init.callee));
852
+ }
853
+ });
854
+ return aliases;
855
+ }
856
+ /** Which local names are assertion functions/modules, and which file-local functions assert. */
857
+ function assertVocabulary(ast) {
858
+ const assertFns = new Map();
859
+ const assertRoots = new Set(['assert']);
860
+ const bind = (imported, local) => {
861
+ if (imported === 'expect')
862
+ return;
863
+ if (imported === 'assert' || imported === 'strict' || imported === 'default')
864
+ assertRoots.add(local);
865
+ else
866
+ assertFns.set(local, imported);
867
+ };
868
+ for (const stmt of ast.program.body) {
869
+ if (stmt.type !== 'ImportDeclaration' || !ASSERT_MODULES.test(stmt.source.value))
870
+ continue;
871
+ for (const sp of stmt.specifiers) {
872
+ if (sp.type === 'ImportSpecifier')
873
+ bind(sp.imported.type === 'Identifier' ? sp.imported.name : sp.imported.value, sp.local.name);
874
+ else
875
+ assertRoots.add(sp.local.name);
876
+ }
877
+ }
878
+ // const assert = require('node:assert'); const { equal } = require('assert/strict')
879
+ walk(ast, (node) => {
880
+ if (node.type !== 'VariableDeclarator' || !node.init)
881
+ return;
882
+ let init = unwrap(node.init);
883
+ if (isMember(init) && propName(init) === 'strict')
884
+ init = init.object;
885
+ if (!isCall(init) || init.callee.type !== 'Identifier' || init.callee.name !== 'require')
886
+ return;
887
+ const spec = stringValue(init.arguments[0]);
888
+ if (!spec || !ASSERT_MODULES.test(spec))
889
+ return;
890
+ if (node.id.type === 'Identifier')
891
+ assertRoots.add(node.id.name);
892
+ else if (node.id.type === 'ObjectPattern') {
893
+ for (const prop of node.id.properties) {
894
+ if (prop.type !== 'ObjectProperty' || prop.value.type !== 'Identifier')
895
+ continue;
896
+ const key = prop.key.type === 'Identifier' ? prop.key.name : stringValue(prop.key);
897
+ if (key)
898
+ bind(key, prop.value.name);
899
+ }
900
+ }
901
+ });
902
+ // File-local helpers that assert: function check(x) { expect(x)... } / const check = (x) => assert.ok(x)
903
+ const candidates = new Map();
904
+ walk(ast, (node) => {
905
+ if (node.type === 'FunctionDeclaration' && node.id)
906
+ candidates.set(node.id.name, node.body);
907
+ if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier' && node.init && isFunction(unwrap(node.init))) {
908
+ candidates.set(node.id.name, unwrap(node.init).body);
909
+ }
910
+ });
911
+ const vocab = { assertFns, assertRoots, helpers: new Set() };
912
+ for (let changed = true; changed;) {
913
+ changed = false;
914
+ for (const [name, body] of candidates) {
915
+ if (vocab.helpers.has(name) || !containsAssertion(body, vocab))
916
+ continue;
917
+ vocab.helpers.add(name);
918
+ changed = true;
919
+ }
920
+ }
921
+ return vocab;
922
+ }
923
+ /** Names declared by functions/variables anywhere in the file (imports excluded). */
924
+ function declaredNames(ast) {
925
+ const names = new Set();
926
+ const addPattern = (p) => {
927
+ if (p.type === 'Identifier')
928
+ names.add(p.name);
929
+ else if (p.type === 'ObjectPattern') {
930
+ for (const prop of p.properties)
931
+ addPattern(prop.type === 'RestElement' ? prop.argument : prop.value);
932
+ }
933
+ else if (p.type === 'ArrayPattern') {
934
+ for (const el of p.elements)
935
+ if (el)
936
+ addPattern(el);
937
+ }
938
+ else if (p.type === 'AssignmentPattern')
939
+ addPattern(p.left);
940
+ else if (p.type === 'RestElement')
941
+ addPattern(p.argument);
942
+ };
943
+ walk(ast, (node) => {
944
+ if (node.type === 'FunctionDeclaration' && node.id)
945
+ names.add(node.id.name);
946
+ if (node.type === 'VariableDeclarator')
947
+ addPattern(node.id);
948
+ });
949
+ return names;
950
+ }
951
+ /** `spy.mockRestore()` / `fn.mockReset()` / `vi.restoreAllMocks()` inside a test body. */
952
+ function scanRestores(source, body) {
953
+ const out = [];
954
+ walk(body, (node) => {
955
+ if (!isCall(node) || !isMember(node.callee))
956
+ return;
957
+ const callee = node.callee;
958
+ const name = propName(callee);
959
+ if (!name)
960
+ return;
961
+ const line = node.loc.start.line;
962
+ const obj = callee.object;
963
+ if (obj.type === 'Identifier' && (obj.name === 'vi' || obj.name === 'jest') && /^(restoreAllMocks|resetAllMocks)$/.test(name)) {
964
+ out.push({ target: null, line });
965
+ }
966
+ else if (MOCK_RESTORE.test(name)) {
967
+ const target = mockTargetText(source, obj);
968
+ if (target)
969
+ out.push({ target, line });
970
+ }
971
+ });
972
+ return out;
973
+ }
974
+ function testName(source, arg) {
975
+ if (!arg)
976
+ return '<anonymous>';
977
+ // Template names keep their spacing: `adds ${n} items`, not `adds ${n}items`.
978
+ return stringValue(arg) ?? textOf(source, arg).replace(/\s+/g, ' ').trim();
979
+ }
980
+ export function parseJsTestFile(path, source) {
981
+ const ast = parseSource(path, source);
982
+ const lines = source.split(/\r?\n/);
983
+ const tests = [];
984
+ const moduleMocks = [];
985
+ const unitSpies = [];
986
+ const unitName = unitNameForTestFile(path);
987
+ // Imports: local name -> specifier
988
+ const imports = new Map();
989
+ const importedSpecs = new Set();
990
+ for (const stmt of ast.program.body) {
991
+ if (stmt.type === 'ImportDeclaration') {
992
+ importedSpecs.add(stmt.source.value);
993
+ for (const s of stmt.specifiers)
994
+ imports.set(s.local.name, stmt.source.value);
995
+ }
996
+ }
997
+ walk(ast, (node) => {
998
+ // require('x') and dynamic import('x') also count as imports
999
+ if (isCall(node) && node.callee.type === 'Identifier' && node.callee.name === 'require') {
1000
+ const s = stringValue(node.arguments[0]);
1001
+ if (s)
1002
+ importedSpecs.add(s);
1003
+ }
1004
+ if (node.type === 'ImportExpression' || (isCall(node) && node.callee.type === 'Import')) {
1005
+ const arg = node.type === 'ImportExpression' ? node.source : node.arguments[0];
1006
+ const s = stringValue(arg);
1007
+ if (s)
1008
+ importedSpecs.add(s);
1009
+ }
1010
+ });
1011
+ const vocab = assertVocabulary(ast);
1012
+ const localNames = declaredNames(ast);
1013
+ const isUnitSpec = (spec) => isLocalSpecifier(spec) && specifierUnitName(spec) === unitName && unitName.length > 0;
1014
+ const importsUnit = [...importedSpecs].some(isUnitSpec);
1015
+ // Locate test bodies first so file-level mock scanning can skip them.
1016
+ const testBodies = new Set();
1017
+ walk(ast, (node, ancestors) => {
1018
+ if (node.type !== 'CallExpression')
1019
+ return;
1020
+ // A test-looking call inside a test body is not a test (runners reject nested tests): it's a local fn.
1021
+ const info = ancestors.some((a) => testBodies.has(a)) ? null : resolveTestCall(node, localNames);
1022
+ if (info) {
1023
+ const fn = info.args.slice(1).find((a) => isFunction(a));
1024
+ if (fn && info.kind === 'test')
1025
+ testBodies.add(fn);
1026
+ const start = node.loc.start;
1027
+ const tc = {
1028
+ name: testName(source, info.args[0]),
1029
+ kind: info.kind,
1030
+ line: start.line,
1031
+ endLine: node.loc.end.line,
1032
+ modifier: info.modifier,
1033
+ headText: lineText(lines, start.line, start.line),
1034
+ analyzable: !!fn,
1035
+ assertions: [],
1036
+ helperAssertionCalls: 0,
1037
+ hasAssertionCountGuard: false,
1038
+ mockSetups: [],
1039
+ };
1040
+ tc._fn = fn;
1041
+ tests.push(tc);
1042
+ return;
1043
+ }
1044
+ // vi.mock('../src/x') / jest.mock(...)
1045
+ const callee = node.callee;
1046
+ if (isMember(callee) && callee.object.type === 'Identifier' && (callee.object.name === 'vi' || callee.object.name === 'jest')) {
1047
+ const fnName = propName(callee);
1048
+ if (fnName === 'spyOn') {
1049
+ const [obj] = node.arguments;
1050
+ const objName = obj && obj.type === 'Identifier' ? obj.name : null;
1051
+ if (objName && imports.has(objName) && isUnitSpec(imports.get(objName))) {
1052
+ unitSpies.push({
1053
+ target: spyTarget(source, node) ?? normText(source, node),
1054
+ line: node.loc.start.line,
1055
+ text: lineText(lines, node.loc.start.line, node.loc.end.line),
1056
+ });
1057
+ }
1058
+ }
1059
+ if (fnName && MODULE_MOCK_FNS.has(fnName)) {
1060
+ const spec = stringValue(node.arguments[0]);
1061
+ if (spec) {
1062
+ const factory = node.arguments[1];
1063
+ const factoryText = factory ? textOf(source, factory) : '';
1064
+ const partial = /importOriginal|requireActual|importActual/.test(factoryText) || factoryCallsItsParam(factory);
1065
+ moduleMocks.push({
1066
+ specifier: spec,
1067
+ line: node.loc.start.line,
1068
+ text: lineText(lines, node.loc.start.line, node.loc.start.line),
1069
+ isUnitUnderTest: isUnitSpec(spec) && importsUnit,
1070
+ partial,
1071
+ });
1072
+ }
1073
+ }
1074
+ }
1075
+ });
1076
+ const fileLevel = scanMocks(source, ast, (n) => testBodies.has(n));
1077
+ // Only keep unit spies that actually replace behavior.
1078
+ const replacedTargets = new Set(fileLevel.setups.filter((s) => s.configuresReturn).map((s) => s.target));
1079
+ for (const fn of testBodies)
1080
+ for (const s of scanMocks(source, fn).setups)
1081
+ if (s.configuresReturn)
1082
+ replacedTargets.add(s.target);
1083
+ const behaviorSpies = unitSpies.filter((s) => replacedTargets.has(s.target));
1084
+ for (const tc of tests) {
1085
+ const fn = tc._fn;
1086
+ delete tc._fn;
1087
+ if (!fn || tc.kind !== 'test')
1088
+ continue;
1089
+ const fnBody = fn.body;
1090
+ const aliases = collectAliases(source, fnBody);
1091
+ const scan = scanAssertions(source, lines, fnBody, aliases, vocab);
1092
+ tc.assertions = scan.assertions;
1093
+ tc.helperAssertionCalls = scan.helperAssertionCalls;
1094
+ tc.hasAssertionCountGuard = scan.hasAssertionCountGuard;
1095
+ tc.mockSetups = [...fileLevel.setups, ...scanMocks(source, fnBody).setups];
1096
+ const restores = scanRestores(source, fnBody);
1097
+ if (restores.length)
1098
+ tc.mockRestores = restores;
1099
+ }
1100
+ return { file: path, language: 'js', tests, moduleMocks, unitSpies: behaviorSpies, lines };
1101
+ }