supercov 0.0.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.
@@ -0,0 +1,1254 @@
1
+ import { createHash } from "node:crypto";
2
+ import generate from "@babel/generator";
3
+ import { parse, type ParserPlugin } from "@babel/parser";
4
+ import traverse, { type NodePath } from "@babel/traverse";
5
+ import * as t from "@babel/types";
6
+ import type {
7
+ CoverageBranchMeta,
8
+ CoverageManifest,
9
+ CoverageLimitation,
10
+ CoveragePointMeta,
11
+ McdcDecisionMeta,
12
+ } from "./types.ts";
13
+
14
+ const RUNTIME_MODULE = "virtual:supercov-runtime";
15
+ const BEGIN = "__supercovMcdcBegin";
16
+ const CONDITION = "__supercovMcdcCondition";
17
+ const END = "__supercovMcdcEnd";
18
+ const HIT = "__supercovCoverageHit";
19
+ const SELECTION_BEGIN = "__supercovSelectionBegin";
20
+ const SELECTION_RIGHT = "__supercovSelectionRight";
21
+ const SELECTION_END = "__supercovSelectionEnd";
22
+ const WITH_REQUEST_PHASE = "__supercovWithRequestPhase";
23
+ const OPTIONAL_SELECT = "__supercovOptionalSelect";
24
+ const DEFAULT_SELECTED = "__supercovDefaultSelected";
25
+ const DEFAULT_ENTERED = "__supercovDefaultEntered";
26
+ const TRY_BEGIN = "__supercovTryBegin";
27
+ const TRY_CATCH = "__supercovTryCatch";
28
+ const TRY_END = "__supercovTryEnd";
29
+ const LOOP_BEGIN = "__supercovLoopBegin";
30
+ const LOOP_ENTERED = "__supercovLoopEntered";
31
+ const LOOP_END = "__supercovLoopEnd";
32
+
33
+ function isRemixRequestHandlerName(name: string | undefined): boolean {
34
+ return name === "loader" || name === "action";
35
+ }
36
+
37
+ function sourceFor(code: string, node: t.Node): string {
38
+ if (
39
+ node.start !== null &&
40
+ node.start !== undefined &&
41
+ node.end !== null &&
42
+ node.end !== undefined
43
+ ) {
44
+ return code.slice(node.start, node.end);
45
+ }
46
+ return generate(node).code;
47
+ }
48
+
49
+ function stableId(
50
+ file: string,
51
+ kind: string,
52
+ node: t.Node,
53
+ suffix = "",
54
+ ): string {
55
+ return createHash("sha256")
56
+ .update(`${file}:${kind}:${node.start ?? 0}:${node.end ?? 0}:${suffix}`)
57
+ .digest("hex")
58
+ .slice(0, 16);
59
+ }
60
+
61
+ function hasCompoundBooleanDecision(node: t.Expression): boolean {
62
+ if (
63
+ t.isLogicalExpression(node) &&
64
+ (node.operator === "&&" || node.operator === "||")
65
+ ) {
66
+ return true;
67
+ }
68
+ return (
69
+ t.isUnaryExpression(node, { operator: "!" }) &&
70
+ hasCompoundBooleanDecision(node.argument)
71
+ );
72
+ }
73
+
74
+ function collectConditions(
75
+ node: t.Expression,
76
+ conditions: t.Expression[],
77
+ ): void {
78
+ if (
79
+ t.isLogicalExpression(node) &&
80
+ (node.operator === "&&" || node.operator === "||")
81
+ ) {
82
+ collectConditions(node.left, conditions);
83
+ collectConditions(node.right, conditions);
84
+ return;
85
+ }
86
+ if (
87
+ t.isUnaryExpression(node, { operator: "!" }) &&
88
+ hasCompoundBooleanDecision(node.argument)
89
+ ) {
90
+ collectConditions(node.argument, conditions);
91
+ return;
92
+ }
93
+ conditions.push(node);
94
+ }
95
+
96
+ function instrumentConditions(
97
+ node: t.Expression,
98
+ frameId: t.Identifier,
99
+ nextIndex: { value: number },
100
+ decisionLogicalNodes: WeakSet<t.LogicalExpression>,
101
+ ): t.Expression {
102
+ if (
103
+ t.isLogicalExpression(node) &&
104
+ (node.operator === "&&" || node.operator === "||")
105
+ ) {
106
+ const logical = t.logicalExpression(
107
+ node.operator,
108
+ instrumentConditions(node.left, frameId, nextIndex, decisionLogicalNodes),
109
+ instrumentConditions(
110
+ node.right,
111
+ frameId,
112
+ nextIndex,
113
+ decisionLogicalNodes,
114
+ ),
115
+ );
116
+ decisionLogicalNodes.add(logical);
117
+ return logical;
118
+ }
119
+
120
+ if (
121
+ t.isUnaryExpression(node, { operator: "!" }) &&
122
+ hasCompoundBooleanDecision(node.argument)
123
+ ) {
124
+ return t.unaryExpression(
125
+ "!",
126
+ instrumentConditions(
127
+ node.argument,
128
+ frameId,
129
+ nextIndex,
130
+ decisionLogicalNodes,
131
+ ),
132
+ true,
133
+ );
134
+ }
135
+
136
+ const index = nextIndex.value;
137
+ nextIndex.value += 1;
138
+ return t.callExpression(t.identifier(CONDITION), [
139
+ t.cloneNode(frameId),
140
+ t.numericLiteral(index),
141
+ t.cloneNode(node, true),
142
+ ]);
143
+ }
144
+
145
+ function hitStatement(id: string): t.ExpressionStatement {
146
+ return t.expressionStatement(
147
+ t.callExpression(t.identifier(HIT), [t.stringLiteral(id)]),
148
+ );
149
+ }
150
+
151
+ function functionLabel(path: NodePath<t.Function>): string | undefined {
152
+ if ("id" in path.node && t.isIdentifier(path.node.id))
153
+ return path.node.id.name;
154
+ const parent = path.parentPath;
155
+ if (
156
+ parent.isObjectProperty() ||
157
+ parent.isObjectMethod() ||
158
+ parent.isClassMethod()
159
+ ) {
160
+ const key = parent.node.key;
161
+ if (t.isIdentifier(key)) return key.name;
162
+ if (t.isStringLiteral(key)) return key.value;
163
+ }
164
+ if (parent.isVariableDeclarator() && t.isIdentifier(parent.node.id))
165
+ return parent.node.id.name;
166
+ return undefined;
167
+ }
168
+
169
+ function isExecutableStatement(node: t.Statement): boolean {
170
+ if (
171
+ t.isBlockStatement(node) ||
172
+ t.isEmptyStatement(node) ||
173
+ t.isFunctionDeclaration(node) ||
174
+ node.type.startsWith("TS")
175
+ ) {
176
+ return false;
177
+ }
178
+ return !("declare" in node && node.declare === true);
179
+ }
180
+
181
+ export interface InstrumentMcdcResult {
182
+ code: string;
183
+ map: ReturnType<typeof generate>["map"];
184
+ manifest: CoverageManifest;
185
+ decisions: McdcDecisionMeta[];
186
+ }
187
+
188
+ export function instrumentMcdc(
189
+ code: string,
190
+ file: string,
191
+ ): InstrumentMcdcResult {
192
+ const parserPlugins: ParserPlugin[] = [
193
+ "typescript",
194
+ "decorators-legacy",
195
+ ...(file.endsWith("x") ? (["jsx"] as const) : []),
196
+ ];
197
+ const ast = parse(code, {
198
+ sourceType: "unambiguous",
199
+ sourceFilename: file,
200
+ errorRecovery: true,
201
+ plugins: parserPlugins,
202
+ });
203
+ const decisions: McdcDecisionMeta[] = [];
204
+ const points: CoveragePointMeta[] = [];
205
+ const branches: CoverageBranchMeta[] = [];
206
+ const limitations: CoverageLimitation[] = [];
207
+ const generatedStatements = new WeakSet<t.Statement>();
208
+ const decisionLogicalNodes = new WeakSet<t.LogicalExpression>();
209
+ let usesRequestPhaseHandler = false;
210
+
211
+ // Record executable statements before adding any instrumentation statements.
212
+ traverse(ast, {
213
+ Statement(path) {
214
+ const node = path.node;
215
+ if (
216
+ !node.loc ||
217
+ generatedStatements.has(node) ||
218
+ !isExecutableStatement(node)
219
+ )
220
+ return;
221
+ if (path.parentPath.isLabeledStatement()) return;
222
+
223
+ const id = stableId(file, "statement", node);
224
+ points.push({
225
+ id,
226
+ kind: "statement",
227
+ file,
228
+ line: node.loc.start.line,
229
+ column: node.loc.start.column + 1,
230
+ source: sourceFor(code, node),
231
+ });
232
+ const probe = hitStatement(id);
233
+ generatedStatements.add(probe);
234
+
235
+ if (
236
+ path.parentPath.isProgram() ||
237
+ path.parentPath.isBlockStatement() ||
238
+ path.parentPath.isSwitchCase()
239
+ ) {
240
+ path.insertBefore(probe);
241
+ return;
242
+ }
243
+
244
+ // Bare control-flow bodies become blocks. This preserves dangling-else,
245
+ // break, continue, return, and throw semantics while giving the body an
246
+ // independently observable statement entry.
247
+ if (
248
+ path.key === "body" ||
249
+ path.key === "consequent" ||
250
+ path.key === "alternate"
251
+ ) {
252
+ path.replaceWith(t.blockStatement([probe, node]));
253
+ path.skip();
254
+ }
255
+ },
256
+ });
257
+
258
+ // Optional links are measured at the exact nullish operand. Unlike checking
259
+ // the chain's final value, this remains accurate when a successful property
260
+ // access or function call legitimately returns undefined.
261
+ const instrumentOptionalOperand = (
262
+ path: NodePath<t.Expression>,
263
+ operand: t.Expression,
264
+ ): t.Expression => {
265
+ const node = path.node;
266
+ if (!node.loc) return operand;
267
+ const id = stableId(file, "optional-chain", node);
268
+ const shortId = `${id}:short`;
269
+ const continuedId = `${id}:continued`;
270
+ branches.push({
271
+ id,
272
+ kind: "optional-chain",
273
+ file,
274
+ line: node.loc.start.line,
275
+ column: node.loc.start.column + 1,
276
+ source: sourceFor(code, node),
277
+ alternatives: [
278
+ { id: shortId, label: "nullish / short-circuited" },
279
+ { id: continuedId, label: "non-nullish / continued" },
280
+ ],
281
+ });
282
+ return t.callExpression(t.identifier(OPTIONAL_SELECT), [
283
+ t.stringLiteral(shortId),
284
+ t.stringLiteral(continuedId),
285
+ operand,
286
+ ]);
287
+ };
288
+
289
+ traverse(ast, {
290
+ OptionalMemberExpression(path) {
291
+ if (!path.node.optional || !t.isExpression(path.node.object)) return;
292
+ path.node.object = instrumentOptionalOperand(
293
+ path as unknown as NodePath<t.Expression>,
294
+ path.node.object,
295
+ );
296
+ },
297
+ OptionalCallExpression(path) {
298
+ if (!path.node.optional || !t.isExpression(path.node.callee)) return;
299
+ const callee = path.node.callee;
300
+ if (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) {
301
+ // Preserve the receiver of `object.method?.()` by evaluating the
302
+ // object and method once and calling through Function#call.
303
+ if (t.isSuper(callee.object) || !t.isExpression(callee.object)) {
304
+ if (path.node.loc) {
305
+ limitations.push({
306
+ id: stableId(file, "dynamic-code", path.node, "optional-super"),
307
+ kind: "dynamic-code",
308
+ file,
309
+ line: path.node.loc.start.line,
310
+ column: path.node.loc.start.column + 1,
311
+ source: sourceFor(code, path.node),
312
+ reason: "optional calls through super cannot be probed without changing receiver semantics",
313
+ });
314
+ }
315
+ return;
316
+ }
317
+ const objectId = path.scope.generateUidIdentifier("supercovOptionalObject");
318
+ const callableId = path.scope.generateUidIdentifier("supercovOptionalCallable");
319
+ const frameScope = path.scope.getFunctionParent() ?? path.scope.getProgramParent();
320
+ frameScope.push({ id: t.cloneNode(objectId), kind: "let" });
321
+ frameScope.push({ id: t.cloneNode(callableId), kind: "let" });
322
+ const objectAssignment = t.assignmentExpression(
323
+ "=",
324
+ t.cloneNode(objectId),
325
+ callee.object,
326
+ );
327
+ const member = t.memberExpression(
328
+ t.cloneNode(objectId),
329
+ t.cloneNode(callee.property),
330
+ callee.computed,
331
+ );
332
+ const callableAssignment = t.assignmentExpression(
333
+ "=",
334
+ t.cloneNode(callableId),
335
+ member,
336
+ );
337
+ const selected = instrumentOptionalOperand(
338
+ path as unknown as NodePath<t.Expression>,
339
+ t.cloneNode(callableId),
340
+ );
341
+ const call = t.optionalCallExpression(
342
+ t.optionalMemberExpression(
343
+ selected,
344
+ t.identifier("call"),
345
+ false,
346
+ true,
347
+ ),
348
+ [t.cloneNode(objectId), ...path.node.arguments.map((argument) => t.cloneNode(argument))],
349
+ false,
350
+ );
351
+ path.replaceWith(
352
+ t.sequenceExpression([objectAssignment, callableAssignment, call]),
353
+ );
354
+ path.skip();
355
+ return;
356
+ }
357
+ path.node.callee = instrumentOptionalOperand(
358
+ path as unknown as NodePath<t.Expression>,
359
+ callee,
360
+ );
361
+ },
362
+ });
363
+
364
+ // Logical assignments have the same short/right split as value-selection
365
+ // expressions, but wrapping only the RHS preserves one-time LHS evaluation.
366
+ traverse(ast, {
367
+ AssignmentExpression: {
368
+ exit(path) {
369
+ const node = path.node;
370
+ if (
371
+ !node.loc ||
372
+ (node.operator !== "&&=" &&
373
+ node.operator !== "||=" &&
374
+ node.operator !== "??=")
375
+ )
376
+ return;
377
+ const id = stableId(file, "logical-assignment", node, node.operator);
378
+ const shortId = `${id}:short`;
379
+ const rightId = `${id}:right`;
380
+ branches.push({
381
+ id,
382
+ kind: "logical-assignment",
383
+ file,
384
+ line: node.loc.start.line,
385
+ column: node.loc.start.column + 1,
386
+ source: sourceFor(code, node),
387
+ alternatives: [
388
+ { id: shortId, label: "assignment skipped" },
389
+ { id: rightId, label: "right evaluated / assigned" },
390
+ ],
391
+ });
392
+ const frameId = path.scope.generateUidIdentifier("supercovSelectionFrame");
393
+ const frameScope = path.scope.getFunctionParent() ?? path.scope.getProgramParent();
394
+ frameScope.push({ id: t.cloneNode(frameId), kind: "let" });
395
+ const assignFrame = t.assignmentExpression(
396
+ "=",
397
+ t.cloneNode(frameId),
398
+ t.callExpression(t.identifier(SELECTION_BEGIN), [
399
+ t.stringLiteral(shortId),
400
+ t.stringLiteral(rightId),
401
+ ]),
402
+ );
403
+ const assignment = t.assignmentExpression(
404
+ node.operator,
405
+ t.cloneNode(node.left),
406
+ t.callExpression(t.identifier(SELECTION_RIGHT), [
407
+ t.cloneNode(frameId),
408
+ node.right,
409
+ ]),
410
+ );
411
+ path.replaceWith(
412
+ t.sequenceExpression([
413
+ assignFrame,
414
+ t.callExpression(t.identifier(SELECTION_END), [
415
+ t.cloneNode(frameId),
416
+ assignment,
417
+ ]),
418
+ ]),
419
+ );
420
+ path.skip();
421
+ },
422
+ },
423
+ });
424
+
425
+ // Parameter defaults execute before a function body. A tiny per-default
426
+ // token lets the body distinguish an evaluated default from a supplied
427
+ // value without comparing values or evaluating either expression twice.
428
+ traverse(ast, {
429
+ Function(path) {
430
+ if (!t.isBlockStatement(path.node.body)) return;
431
+ const entries: t.Statement[] = [];
432
+ const visitPattern = (pattern: t.Node): void => {
433
+ if (t.isTSParameterProperty(pattern)) {
434
+ visitPattern(pattern.parameter);
435
+ return;
436
+ }
437
+ if (t.isAssignmentPattern(pattern)) {
438
+ if (!pattern.loc) return;
439
+ const id = stableId(file, "default-value", pattern);
440
+ const defaultId = `${id}:default`;
441
+ const providedId = `${id}:provided`;
442
+ branches.push({
443
+ id,
444
+ kind: "default-value",
445
+ file,
446
+ line: pattern.loc.start.line,
447
+ column: pattern.loc.start.column + 1,
448
+ source: sourceFor(code, pattern),
449
+ alternatives: [
450
+ { id: defaultId, label: "default evaluated" },
451
+ { id: providedId, label: "value provided" },
452
+ ],
453
+ });
454
+ pattern.right = t.callExpression(t.identifier(DEFAULT_SELECTED), [
455
+ t.stringLiteral(defaultId),
456
+ pattern.right,
457
+ ]);
458
+ entries.push(
459
+ t.expressionStatement(
460
+ t.callExpression(t.identifier(DEFAULT_ENTERED), [
461
+ t.stringLiteral(defaultId),
462
+ t.stringLiteral(providedId),
463
+ ]),
464
+ ),
465
+ );
466
+ visitPattern(pattern.left);
467
+ return;
468
+ }
469
+ if (t.isRestElement(pattern)) return visitPattern(pattern.argument);
470
+ if (t.isObjectPattern(pattern)) {
471
+ for (const property of pattern.properties) {
472
+ if (t.isRestElement(property)) visitPattern(property.argument);
473
+ else visitPattern(property.value);
474
+ }
475
+ return;
476
+ }
477
+ if (t.isArrayPattern(pattern)) {
478
+ for (const element of pattern.elements)
479
+ if (element) visitPattern(element);
480
+ }
481
+ };
482
+ for (const parameter of path.node.params) visitPattern(parameter);
483
+ path.node.body.body.unshift(...entries);
484
+ },
485
+ });
486
+
487
+ // Destructuring declarations use the same token protocol. For loop-binding
488
+ // declarations consume the token at the start of each iteration, directly
489
+ // after JavaScript has completed the binding operation.
490
+ traverse(ast, {
491
+ VariableDeclaration(path) {
492
+ const parent = path.parentPath;
493
+ if (parent.isForStatement() && path.key === "init") {
494
+ let hasDefault = false;
495
+ t.traverseFast(path.node, (node) => {
496
+ if (t.isAssignmentPattern(node)) hasDefault = true;
497
+ });
498
+ const loc = path.node.loc;
499
+ if (hasDefault && loc) {
500
+ const node = path.node;
501
+ limitations.push({
502
+ id: stableId(file, "dynamic-code", node, "for-init-default"),
503
+ kind: "dynamic-code",
504
+ file,
505
+ line: loc.start.line,
506
+ column: loc.start.column + 1,
507
+ source: sourceFor(code, node),
508
+ reason: "destructuring defaults in a classic for initializer cannot yet be finalized without restructuring control flow",
509
+ });
510
+ }
511
+ return;
512
+ }
513
+ const entries: t.Statement[] = [];
514
+ const visitPattern = (pattern: t.Node): void => {
515
+ if (t.isAssignmentPattern(pattern)) {
516
+ if (!pattern.loc) return;
517
+ const id = stableId(file, "default-value", pattern);
518
+ const defaultId = `${id}:default`;
519
+ const providedId = `${id}:provided`;
520
+ branches.push({
521
+ id,
522
+ kind: "default-value",
523
+ file,
524
+ line: pattern.loc.start.line,
525
+ column: pattern.loc.start.column + 1,
526
+ source: sourceFor(code, pattern),
527
+ alternatives: [
528
+ { id: defaultId, label: "default evaluated" },
529
+ { id: providedId, label: "value provided" },
530
+ ],
531
+ });
532
+ pattern.right = t.callExpression(t.identifier(DEFAULT_SELECTED), [
533
+ t.stringLiteral(defaultId),
534
+ pattern.right,
535
+ ]);
536
+ entries.push(
537
+ t.expressionStatement(
538
+ t.callExpression(t.identifier(DEFAULT_ENTERED), [
539
+ t.stringLiteral(defaultId),
540
+ t.stringLiteral(providedId),
541
+ ]),
542
+ ),
543
+ );
544
+ visitPattern(pattern.left);
545
+ return;
546
+ }
547
+ if (t.isRestElement(pattern)) return visitPattern(pattern.argument);
548
+ if (t.isObjectPattern(pattern)) {
549
+ for (const property of pattern.properties)
550
+ visitPattern(
551
+ t.isRestElement(property) ? property.argument : property.value,
552
+ );
553
+ return;
554
+ }
555
+ if (t.isArrayPattern(pattern)) {
556
+ for (const element of pattern.elements)
557
+ if (element) visitPattern(element);
558
+ }
559
+ };
560
+ for (const declaration of path.node.declarations)
561
+ visitPattern(declaration.id);
562
+ if (entries.length === 0) return;
563
+
564
+ if (
565
+ (parent.isForOfStatement() || parent.isForInStatement()) &&
566
+ path.key === "left"
567
+ ) {
568
+ const body = parent.node.body;
569
+ parent.node.body = t.isBlockStatement(body)
570
+ ? body
571
+ : t.blockStatement([body]);
572
+ parent.node.body.body.unshift(...entries);
573
+ return;
574
+ }
575
+ if (parent.isExportNamedDeclaration()) parent.insertAfter(entries);
576
+ else path.insertAfter(entries);
577
+ },
578
+ });
579
+
580
+ // Try/catch and enumeration loops use frames finalized in `finally`, so
581
+ // return, break, continue, rejection, and throw paths remain observable.
582
+ traverse(ast, {
583
+ TryStatement(path) {
584
+ const node = path.node;
585
+ if (!node.loc || !node.handler) return;
586
+ const id = stableId(file, "try-catch", node);
587
+ const successId = `${id}:success`;
588
+ const catchId = `${id}:catch`;
589
+ branches.push({
590
+ id,
591
+ kind: "try-catch",
592
+ file,
593
+ line: node.loc.start.line,
594
+ column: node.loc.start.column + 1,
595
+ source: "try / catch",
596
+ alternatives: [
597
+ { id: successId, label: "try completed without catch" },
598
+ { id: catchId, label: "catch entered" },
599
+ ],
600
+ });
601
+ const frameId = path.scope.generateUidIdentifier("supercovTryFrame");
602
+ const frameScope = path.scope.getFunctionParent() ?? path.scope.getProgramParent();
603
+ frameScope.push({ id: t.cloneNode(frameId), kind: "let" });
604
+ path.insertBefore(
605
+ t.expressionStatement(
606
+ t.assignmentExpression(
607
+ "=",
608
+ t.cloneNode(frameId),
609
+ t.callExpression(t.identifier(TRY_BEGIN), [
610
+ t.stringLiteral(successId),
611
+ t.stringLiteral(catchId),
612
+ ]),
613
+ ),
614
+ ),
615
+ );
616
+ node.handler.body.body.unshift(
617
+ t.expressionStatement(
618
+ t.callExpression(t.identifier(TRY_CATCH), [
619
+ t.cloneNode(frameId),
620
+ t.identifier("undefined"),
621
+ ]),
622
+ ),
623
+ );
624
+ const end = t.expressionStatement(
625
+ t.callExpression(t.identifier(TRY_END), [t.cloneNode(frameId)]),
626
+ );
627
+ if (node.finalizer) node.finalizer.body.unshift(end);
628
+ else node.finalizer = t.blockStatement([end]);
629
+ path.skip();
630
+ },
631
+ "ForInStatement|ForOfStatement"(path: NodePath<t.ForInStatement | t.ForOfStatement>) {
632
+ const node = path.node;
633
+ if (!node.loc) return;
634
+ const kind = t.isForOfStatement(node) ? "for-of" : "for-in";
635
+ const id = stableId(file, kind, node);
636
+ const zeroId = `${id}:zero`;
637
+ const enteredId = `${id}:entered`;
638
+ branches.push({
639
+ id,
640
+ kind,
641
+ file,
642
+ line: node.loc.start.line,
643
+ column: node.loc.start.column + 1,
644
+ source: sourceFor(code, node.right),
645
+ alternatives: [
646
+ { id: zeroId, label: "zero iterations" },
647
+ { id: enteredId, label: "one or more iterations" },
648
+ ],
649
+ });
650
+ const frameId = path.scope.generateUidIdentifier("supercovLoopFrame");
651
+ const frameScope = path.scope.getFunctionParent() ?? path.scope.getProgramParent();
652
+ frameScope.push({ id: t.cloneNode(frameId), kind: "let" });
653
+ const assignment = t.expressionStatement(
654
+ t.assignmentExpression(
655
+ "=",
656
+ t.cloneNode(frameId),
657
+ t.callExpression(t.identifier(LOOP_BEGIN), [
658
+ t.stringLiteral(zeroId),
659
+ t.stringLiteral(enteredId),
660
+ ]),
661
+ ),
662
+ );
663
+ const loop = t.cloneNode(node, true);
664
+ loop.body = t.isBlockStatement(loop.body)
665
+ ? loop.body
666
+ : t.blockStatement([loop.body]);
667
+ loop.body.body.unshift(
668
+ t.expressionStatement(
669
+ t.callExpression(t.identifier(LOOP_ENTERED), [
670
+ t.cloneNode(frameId),
671
+ ]),
672
+ ),
673
+ );
674
+ let loopStatement: t.Statement = loop;
675
+ let replacementPath: NodePath<t.Statement> = path;
676
+ while (
677
+ replacementPath.parentPath?.isLabeledStatement() &&
678
+ replacementPath.parentPath.node.body === replacementPath.node
679
+ ) {
680
+ loopStatement = t.labeledStatement(
681
+ t.cloneNode(replacementPath.parentPath.node.label),
682
+ loopStatement,
683
+ );
684
+ replacementPath = replacementPath.parentPath;
685
+ }
686
+ const wrapped = t.tryStatement(
687
+ t.blockStatement([loopStatement]),
688
+ null,
689
+ t.blockStatement([
690
+ t.expressionStatement(
691
+ t.callExpression(t.identifier(LOOP_END), [t.cloneNode(frameId)]),
692
+ ),
693
+ ]),
694
+ );
695
+ if (replacementPath === path) {
696
+ path.insertBefore(assignment);
697
+ path.replaceWith(wrapped);
698
+ path.skip();
699
+ } else {
700
+ replacementPath.replaceWith(t.blockStatement([assignment, wrapped]));
701
+ replacementPath.skip();
702
+ }
703
+ },
704
+ });
705
+
706
+ // Function entry is separate from statement coverage so empty functions and
707
+ // expression-bodied arrows remain visible in the completeness denominator.
708
+ traverse(ast, {
709
+ Function(path) {
710
+ const node = path.node;
711
+ if (!node.loc || !node.body) return;
712
+ const id = stableId(file, "function", node);
713
+ points.push({
714
+ id,
715
+ kind: "function",
716
+ file,
717
+ line: node.loc.start.line,
718
+ column: node.loc.start.column + 1,
719
+ source: sourceFor(code, node),
720
+ ...(functionLabel(path) ? { label: functionLabel(path) } : {}),
721
+ });
722
+ const probe = hitStatement(id);
723
+ generatedStatements.add(probe);
724
+ if (t.isBlockStatement(node.body)) {
725
+ node.body.body.unshift(probe);
726
+ } else {
727
+ node.body = t.blockStatement([probe, t.returnStatement(node.body)]);
728
+ }
729
+ },
730
+ });
731
+
732
+ const instrumentDecision = (
733
+ path: NodePath<t.Expression>,
734
+ kind: McdcDecisionMeta["kind"],
735
+ ): void => {
736
+ if (!path.node.loc) return;
737
+ const originalConditions: t.Expression[] = [];
738
+ collectConditions(path.node, originalConditions);
739
+ if (originalConditions.length === 0) return;
740
+
741
+ const id = stableId(file, "decision", path.node, kind);
742
+ const meta: McdcDecisionMeta = {
743
+ id,
744
+ file,
745
+ line: path.node.loc.start.line,
746
+ column: path.node.loc.start.column + 1,
747
+ source: sourceFor(code, path.node),
748
+ conditions: originalConditions.map((condition) =>
749
+ sourceFor(code, condition),
750
+ ),
751
+ kind,
752
+ };
753
+ decisions.push(meta);
754
+
755
+ const frameId = path.scope.generateUidIdentifier("supercovMcdcFrame");
756
+ // A loop predicate's path scope may be represented by Babel as the loop
757
+ // body's block. Declaring the frame there puts it after the predicate that
758
+ // uses it (and is especially visible in async generators). Hoist scratch
759
+ // frames to the nearest function/program scope instead.
760
+ const frameScope =
761
+ path.scope.getFunctionParent() ?? path.scope.getProgramParent();
762
+ frameScope.push({ id: t.cloneNode(frameId), kind: "let" });
763
+ const instrumented = instrumentConditions(
764
+ path.node,
765
+ frameId,
766
+ { value: 0 },
767
+ decisionLogicalNodes,
768
+ );
769
+ const begin = t.callExpression(t.identifier(BEGIN), [
770
+ t.stringLiteral(id),
771
+ t.valueToNode(meta),
772
+ ]);
773
+ const assignFrame = t.assignmentExpression(
774
+ "=",
775
+ t.cloneNode(frameId),
776
+ begin,
777
+ );
778
+ const end = t.callExpression(t.identifier(END), [
779
+ t.cloneNode(frameId),
780
+ instrumented,
781
+ ]);
782
+ path.replaceWith(t.sequenceExpression([assignFrame, end]));
783
+ path.skip();
784
+ };
785
+
786
+ traverse(ast, {
787
+ IfStatement(path) {
788
+ instrumentDecision(path.get("test"), "if");
789
+ },
790
+ ConditionalExpression(path) {
791
+ instrumentDecision(path.get("test"), "ternary");
792
+ },
793
+ WhileStatement(path) {
794
+ instrumentDecision(path.get("test"), "while");
795
+ },
796
+ DoWhileStatement(path) {
797
+ instrumentDecision(path.get("test"), "do-while");
798
+ },
799
+ ForStatement(path) {
800
+ const test = path.get("test");
801
+ if (test.node) instrumentDecision(test as NodePath<t.Expression>, "for");
802
+ },
803
+ });
804
+
805
+ // A logical expression outside a control predicate selects a value or
806
+ // render path. Record whether it short-circuited or evaluated its RHS;
807
+ // Boolean MC/DC would be misleading when both selected values are truthy.
808
+ traverse(ast, {
809
+ LogicalExpression: {
810
+ exit(path) {
811
+ const node = path.node;
812
+ if (!node.loc || decisionLogicalNodes.has(node)) return;
813
+ const id = stableId(file, "logical-value", node, node.operator);
814
+ const shortId = `${id}:short`;
815
+ const rightId = `${id}:right`;
816
+ branches.push({
817
+ id,
818
+ kind: "logical-value",
819
+ file,
820
+ line: node.loc.start.line,
821
+ column: node.loc.start.column + 1,
822
+ source: sourceFor(code, node),
823
+ alternatives: [
824
+ { id: shortId, label: "short-circuit / left selected" },
825
+ { id: rightId, label: "right evaluated / selected" },
826
+ ],
827
+ });
828
+
829
+ const frameId = path.scope.generateUidIdentifier(
830
+ "supercovSelectionFrame",
831
+ );
832
+ const frameScope =
833
+ path.scope.getFunctionParent() ?? path.scope.getProgramParent();
834
+ frameScope.push({ id: t.cloneNode(frameId), kind: "let" });
835
+ const begin = t.callExpression(t.identifier(SELECTION_BEGIN), [
836
+ t.stringLiteral(shortId),
837
+ t.stringLiteral(rightId),
838
+ ]);
839
+ const assign = t.assignmentExpression("=", t.cloneNode(frameId), begin);
840
+ const right = t.callExpression(t.identifier(SELECTION_RIGHT), [
841
+ t.cloneNode(frameId),
842
+ node.right,
843
+ ]);
844
+ const selection = t.logicalExpression(node.operator, node.left, right);
845
+ const end = t.callExpression(t.identifier(SELECTION_END), [
846
+ t.cloneNode(frameId),
847
+ selection,
848
+ ]);
849
+ path.replaceWith(t.sequenceExpression([assign, end]));
850
+ path.skip();
851
+ },
852
+ },
853
+ });
854
+
855
+ // Switch alternatives are observable independently of whether their bodies
856
+ // are empty or fall through to another case. An implicit no-match probe must
857
+ // live after the switch rather than in a synthetic default: a matched case
858
+ // can legally fall through to the end and must not be counted as no-match.
859
+ traverse(ast, {
860
+ SwitchStatement(path) {
861
+ const node = path.node;
862
+ if (!node.loc) return;
863
+ const id = stableId(file, "switch", node);
864
+ const hasDefault = node.cases.some(
865
+ (switchCase) => switchCase.test === null,
866
+ );
867
+ const enteredId = hasDefault
868
+ ? undefined
869
+ : path.scope.generateUidIdentifier("supercovSwitchEntered");
870
+ const alternatives = node.cases.map((switchCase, index) => {
871
+ const alternativeId = `${id}:case:${index}`;
872
+ const label = switchCase.test
873
+ ? `case ${sourceFor(code, switchCase.test)}`
874
+ : "default";
875
+ const probe = hitStatement(alternativeId);
876
+ generatedStatements.add(probe);
877
+ switchCase.consequent.unshift(
878
+ ...(enteredId
879
+ ? [
880
+ t.expressionStatement(
881
+ t.assignmentExpression(
882
+ "=",
883
+ t.cloneNode(enteredId),
884
+ t.booleanLiteral(true),
885
+ ),
886
+ ),
887
+ ]
888
+ : []),
889
+ probe,
890
+ );
891
+ return { id: alternativeId, label };
892
+ });
893
+ let noMatchId: string | undefined;
894
+ if (!hasDefault) {
895
+ const alternativeId = `${id}:no-match`;
896
+ noMatchId = alternativeId;
897
+ alternatives.push({
898
+ id: alternativeId,
899
+ label: "no matching case",
900
+ });
901
+ }
902
+ branches.push({
903
+ id,
904
+ kind: "switch",
905
+ file,
906
+ line: node.loc.start.line,
907
+ column: node.loc.start.column + 1,
908
+ source: sourceFor(code, node.discriminant),
909
+ alternatives,
910
+ });
911
+ if (!enteredId || !noMatchId) return;
912
+
913
+ let switchStatement: t.Statement = node;
914
+ let replacementPath: NodePath<t.Statement> = path;
915
+ while (
916
+ replacementPath.parentPath?.isLabeledStatement() &&
917
+ replacementPath.parentPath.node.body === replacementPath.node
918
+ ) {
919
+ switchStatement = t.labeledStatement(
920
+ t.cloneNode(replacementPath.parentPath.node.label),
921
+ switchStatement,
922
+ );
923
+ replacementPath = replacementPath.parentPath;
924
+ }
925
+ const noMatchProbe = hitStatement(noMatchId);
926
+ generatedStatements.add(noMatchProbe);
927
+ replacementPath.replaceWith(
928
+ t.blockStatement([
929
+ t.variableDeclaration("let", [
930
+ t.variableDeclarator(
931
+ t.cloneNode(enteredId),
932
+ t.booleanLiteral(false),
933
+ ),
934
+ ]),
935
+ switchStatement,
936
+ t.ifStatement(
937
+ t.unaryExpression("!", t.cloneNode(enteredId)),
938
+ noMatchProbe,
939
+ ),
940
+ ]),
941
+ );
942
+ replacementPath.skip();
943
+ },
944
+ });
945
+
946
+ // Runtime-generated source cannot be assigned a truthful static
947
+ // denominator without parsing and instrumenting the generated program in
948
+ // its own execution realm. Discover it and block a completeness verdict
949
+ // instead of silently claiming 100% for only the surrounding file.
950
+ traverse(ast, {
951
+ CallExpression(path) {
952
+ const callee = path.node.callee;
953
+ if (!path.node.loc || !t.isIdentifier(callee, { name: "eval" })) return;
954
+ limitations.push({
955
+ id: stableId(file, "dynamic-code", path.node, "eval"),
956
+ kind: "dynamic-code",
957
+ file,
958
+ line: path.node.loc.start.line,
959
+ column: path.node.loc.start.column + 1,
960
+ source: sourceFor(code, path.node),
961
+ reason: "eval-generated source has no stable pre-run coverage denominator",
962
+ });
963
+ },
964
+ NewExpression(path) {
965
+ if (
966
+ !path.node.loc ||
967
+ !t.isIdentifier(path.node.callee, { name: "Function" })
968
+ )
969
+ return;
970
+ limitations.push({
971
+ id: stableId(file, "dynamic-code", path.node, "Function"),
972
+ kind: "dynamic-code",
973
+ file,
974
+ line: path.node.loc.start.line,
975
+ column: path.node.loc.start.column + 1,
976
+ source: sourceFor(code, path.node),
977
+ reason: "Function-generated source has no stable pre-run coverage denominator",
978
+ });
979
+ },
980
+ });
981
+
982
+ // Route requests carry the Playwright phase as a private test-only header.
983
+ // Wrap Remix request entry points so Node's AsyncLocalStorage can propagate
984
+ // that exact phase through every awaited helper without application edits.
985
+ // This runs after source instrumentation so generated wrappers never become
986
+ // coverage obligations themselves.
987
+ if (/^app\/routes\//.test(file)) {
988
+ traverse(ast, {
989
+ ExportNamedDeclaration(path) {
990
+ const declaration = path.node.declaration;
991
+ if (t.isVariableDeclaration(declaration)) {
992
+ for (const declarator of declaration.declarations) {
993
+ if (
994
+ t.isIdentifier(declarator.id) &&
995
+ isRemixRequestHandlerName(declarator.id.name) &&
996
+ declarator.init
997
+ ) {
998
+ declarator.init = t.callExpression(
999
+ t.identifier(WITH_REQUEST_PHASE),
1000
+ [declarator.init as t.Expression],
1001
+ );
1002
+ usesRequestPhaseHandler = true;
1003
+ }
1004
+ }
1005
+ return;
1006
+ }
1007
+
1008
+ if (
1009
+ t.isFunctionDeclaration(declaration) &&
1010
+ declaration.id &&
1011
+ isRemixRequestHandlerName(declaration.id.name)
1012
+ ) {
1013
+ const exportedName = declaration.id.name;
1014
+ const originalId = path.scope.generateUidIdentifier(
1015
+ `${exportedName}CoverageOriginal`,
1016
+ );
1017
+ declaration.id = originalId;
1018
+ const wrappedExport = t.exportNamedDeclaration(
1019
+ t.variableDeclaration("const", [
1020
+ t.variableDeclarator(
1021
+ t.identifier(exportedName),
1022
+ t.callExpression(t.identifier(WITH_REQUEST_PHASE), [
1023
+ t.cloneNode(originalId),
1024
+ ]),
1025
+ ),
1026
+ ]),
1027
+ );
1028
+ path.replaceWithMultiple([declaration, wrappedExport]);
1029
+ usesRequestPhaseHandler = true;
1030
+ return;
1031
+ }
1032
+
1033
+ if (!path.node.source) return;
1034
+ const handlerSpecifiers = path.node.specifiers.filter(
1035
+ (specifier): specifier is t.ExportSpecifier =>
1036
+ t.isExportSpecifier(specifier) &&
1037
+ t.isIdentifier(specifier.exported) &&
1038
+ isRemixRequestHandlerName(specifier.exported.name),
1039
+ );
1040
+ if (handlerSpecifiers.length === 0) return;
1041
+
1042
+ const replacements: Array<t.Statement | t.ModuleDeclaration> = [];
1043
+ const untouched = path.node.specifiers.filter(
1044
+ (specifier) =>
1045
+ !handlerSpecifiers.includes(specifier as t.ExportSpecifier),
1046
+ );
1047
+ if (untouched.length > 0) {
1048
+ replacements.push(
1049
+ t.exportNamedDeclaration(
1050
+ null,
1051
+ untouched,
1052
+ t.cloneNode(path.node.source),
1053
+ ),
1054
+ );
1055
+ }
1056
+ for (const specifier of handlerSpecifiers) {
1057
+ const exportedName = (specifier.exported as t.Identifier).name;
1058
+ const importedId = path.scope.generateUidIdentifier(
1059
+ `${exportedName}CoverageOriginal`,
1060
+ );
1061
+ replacements.push(
1062
+ t.importDeclaration(
1063
+ [
1064
+ t.importSpecifier(
1065
+ t.cloneNode(importedId),
1066
+ t.cloneNode(specifier.local),
1067
+ ),
1068
+ ],
1069
+ t.cloneNode(path.node.source),
1070
+ ),
1071
+ t.exportNamedDeclaration(
1072
+ t.variableDeclaration("const", [
1073
+ t.variableDeclarator(
1074
+ t.identifier(exportedName),
1075
+ t.callExpression(t.identifier(WITH_REQUEST_PHASE), [
1076
+ t.cloneNode(importedId),
1077
+ ]),
1078
+ ),
1079
+ ]),
1080
+ ),
1081
+ );
1082
+ }
1083
+ path.replaceWithMultiple(replacements);
1084
+ usesRequestPhaseHandler = true;
1085
+ },
1086
+ });
1087
+ }
1088
+
1089
+ if (/^app\/entry\.server\.[cm]?[jt]sx?$/.test(file)) {
1090
+ traverse(ast, {
1091
+ ExportDefaultDeclaration(path) {
1092
+ const declaration = path.node.declaration;
1093
+ if (t.isFunctionDeclaration(declaration)) {
1094
+ const originalId = path.scope.generateUidIdentifier(
1095
+ "handleRequestCoverageOriginal",
1096
+ );
1097
+ declaration.id = originalId;
1098
+ path.replaceWithMultiple([
1099
+ declaration,
1100
+ t.exportDefaultDeclaration(
1101
+ t.callExpression(t.identifier(WITH_REQUEST_PHASE), [
1102
+ t.cloneNode(originalId),
1103
+ ]),
1104
+ ),
1105
+ ]);
1106
+ } else {
1107
+ path.node.declaration = t.callExpression(
1108
+ t.identifier(WITH_REQUEST_PHASE),
1109
+ [declaration as t.Expression],
1110
+ );
1111
+ }
1112
+ usesRequestPhaseHandler = true;
1113
+ },
1114
+ });
1115
+ }
1116
+
1117
+ // HTTP servers and WebSocket libraries expose request-bearing callbacks
1118
+ // outside framework route exports. Wrap well-known listener boundaries and
1119
+ // let the runtime scan callback arguments for Fetch or Node request headers.
1120
+ traverse(ast, {
1121
+ CallExpression(path) {
1122
+ const callee = path.node.callee;
1123
+ const property =
1124
+ (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) &&
1125
+ !callee.computed &&
1126
+ t.isIdentifier(callee.property)
1127
+ ? callee.property.name
1128
+ : undefined;
1129
+ const identifier = t.isIdentifier(callee) ? callee.name : property;
1130
+ let callbackIndex = -1;
1131
+ if (
1132
+ (property === "on" || property === "once" || property === "addListener") &&
1133
+ t.isStringLiteral(path.node.arguments[0]) &&
1134
+ ["request", "upgrade", "connection"].includes(path.node.arguments[0].value)
1135
+ ) {
1136
+ callbackIndex = 1;
1137
+ } else if (identifier === "createServer") {
1138
+ for (let index = path.node.arguments.length - 1; index >= 0; index -= 1) {
1139
+ const argument = path.node.arguments[index];
1140
+ if (
1141
+ t.isFunctionExpression(argument) ||
1142
+ t.isArrowFunctionExpression(argument) ||
1143
+ t.isIdentifier(argument) ||
1144
+ t.isMemberExpression(argument)
1145
+ ) {
1146
+ callbackIndex = index;
1147
+ break;
1148
+ }
1149
+ }
1150
+ }
1151
+ if (callbackIndex < 0) return;
1152
+ const callback = path.node.arguments[callbackIndex];
1153
+ if (!callback || !t.isExpression(callback)) return;
1154
+ if (
1155
+ t.isCallExpression(callback) &&
1156
+ t.isIdentifier(callback.callee, { name: WITH_REQUEST_PHASE })
1157
+ )
1158
+ return;
1159
+ path.node.arguments[callbackIndex] = t.callExpression(
1160
+ t.identifier(WITH_REQUEST_PHASE),
1161
+ [callback],
1162
+ );
1163
+ usesRequestPhaseHandler = true;
1164
+ },
1165
+ });
1166
+
1167
+ const manifest: CoverageManifest = {
1168
+ decisions,
1169
+ points,
1170
+ branches,
1171
+ ...(limitations.length > 0 ? { limitations } : {}),
1172
+ };
1173
+ if (
1174
+ decisions.length > 0 ||
1175
+ points.length > 0 ||
1176
+ branches.length > 0 ||
1177
+ usesRequestPhaseHandler
1178
+ ) {
1179
+ ast.program.body.unshift(
1180
+ t.importDeclaration(
1181
+ [
1182
+ t.importSpecifier(t.identifier(BEGIN), t.identifier("mcdcBegin")),
1183
+ t.importSpecifier(
1184
+ t.identifier(CONDITION),
1185
+ t.identifier("mcdcCondition"),
1186
+ ),
1187
+ t.importSpecifier(t.identifier(END), t.identifier("mcdcEnd")),
1188
+ t.importSpecifier(t.identifier(HIT), t.identifier("coverageHit")),
1189
+ t.importSpecifier(
1190
+ t.identifier(SELECTION_BEGIN),
1191
+ t.identifier("selectionBegin"),
1192
+ ),
1193
+ t.importSpecifier(
1194
+ t.identifier(SELECTION_RIGHT),
1195
+ t.identifier("selectionRight"),
1196
+ ),
1197
+ t.importSpecifier(
1198
+ t.identifier(SELECTION_END),
1199
+ t.identifier("selectionEnd"),
1200
+ ),
1201
+ t.importSpecifier(
1202
+ t.identifier(OPTIONAL_SELECT),
1203
+ t.identifier("optionalSelect"),
1204
+ ),
1205
+ t.importSpecifier(
1206
+ t.identifier(DEFAULT_SELECTED),
1207
+ t.identifier("defaultSelected"),
1208
+ ),
1209
+ t.importSpecifier(
1210
+ t.identifier(DEFAULT_ENTERED),
1211
+ t.identifier("defaultEntered"),
1212
+ ),
1213
+ t.importSpecifier(t.identifier(TRY_BEGIN), t.identifier("tryBegin")),
1214
+ t.importSpecifier(t.identifier(TRY_CATCH), t.identifier("tryCatch")),
1215
+ t.importSpecifier(t.identifier(TRY_END), t.identifier("tryEnd")),
1216
+ t.importSpecifier(
1217
+ t.identifier(LOOP_BEGIN),
1218
+ t.identifier("loopBegin"),
1219
+ ),
1220
+ t.importSpecifier(
1221
+ t.identifier(LOOP_ENTERED),
1222
+ t.identifier("loopEntered"),
1223
+ ),
1224
+ t.importSpecifier(t.identifier(LOOP_END), t.identifier("loopEnd")),
1225
+ ...(usesRequestPhaseHandler
1226
+ ? [
1227
+ t.importSpecifier(
1228
+ t.identifier(WITH_REQUEST_PHASE),
1229
+ t.identifier("withRequestPhase"),
1230
+ ),
1231
+ ]
1232
+ : []),
1233
+ ],
1234
+ t.stringLiteral(RUNTIME_MODULE),
1235
+ ),
1236
+ );
1237
+ }
1238
+
1239
+ const output = generate(
1240
+ {
1241
+ ...ast,
1242
+ },
1243
+ {
1244
+ sourceMaps: true,
1245
+ sourceFileName: file,
1246
+ retainLines: true,
1247
+ comments: true,
1248
+ },
1249
+ code,
1250
+ );
1251
+ return { code: output.code, map: output.map, manifest, decisions };
1252
+ }
1253
+
1254
+ export const mcdcRuntimeModuleId = RUNTIME_MODULE;