ccqa-tools 1.40.3 → 1.40.4

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.
@@ -1,56 +1,81 @@
1
1
  let node_path = require("node:path");
2
2
  let acorn = require("acorn");
3
+ let node_module = require("node:module");
3
4
  //#region src/coverage/instrument/select.ts
5
+ const SOURCE_EXTENSIONS = [
6
+ ".js",
7
+ ".mjs",
8
+ ".cjs",
9
+ ".jsx",
10
+ ".ts",
11
+ ".mts",
12
+ ".cts",
13
+ ".tsx"
14
+ ];
15
+ /**
16
+ * Decides whether a file is part of the project under test, returning its id
17
+ * if so. Returning the id here — rather than a plain boolean — spares the
18
+ * caller a second `fileIdFor` pass over the same path.
19
+ *
20
+ * `node_modules` is excluded unconditionally: instrumenting dependencies costs
21
+ * the most and answers the least — nobody adds a test because a library file
22
+ * went unreached.
23
+ */
24
+ function shouldInstrument(filename, config) {
25
+ if (filename.includes(`${node_path.sep}node_modules${node_path.sep}`)) return void 0;
26
+ if (!SOURCE_EXTENSIONS.some((extension) => filename.endsWith(extension))) return void 0;
27
+ const id = fileIdFor(filename, config.root);
28
+ if (id === void 0) return void 0;
29
+ return config.include.some((prefix) => id === prefix || id.startsWith(`${prefix}/`)) ? id : void 0;
30
+ }
4
31
  /** Path relative to the project root, in posix form so ids match across hosts. */
5
32
  function fileIdFor(filename, root) {
6
33
  const rel = (0, node_path.relative)((0, node_path.resolve)(root), filename);
7
34
  if (rel.startsWith("..") || rel === "") return void 0;
8
35
  return rel.split(node_path.sep).join("/");
9
36
  }
10
- //#endregion
11
- //#region src/coverage/instrument/transform.ts
12
37
  /**
13
- * Rewrites a module so that entering it, or entering one of its functions,
14
- * calls `globalThis.__ccqaCoverage`.
15
- *
16
- * Two properties drive the whole shape:
17
- *
18
- * - **Insertions only, never a newline.** Line numbers survive untouched, so
19
- * the source map the application already ships keeps pointing at the right
20
- * lines and stack traces stay readable. A codegen round-trip would have
21
- * forced us to produce and merge maps of our own.
22
- * - **File granularity.** The record is "this file ran", so there is no need to
23
- * track statements or branches, and the whole class of line/branch
24
- * normalisation bugs that follow V8-to-istanbul conversion never appears.
38
+ * The exact texts both instrumenter dialects splice in, built in one place so
39
+ * a file instrumented from TypeScript and one instrumented from compiled
40
+ * JavaScript are byte-identical where it matters.
25
41
  */
26
- const DEFAULT_MAX_DEPTH = 2;
42
+ function probeTexts(fileId) {
43
+ const local = `__ccqa_${hash(fileId)}`;
44
+ const literal = JSON.stringify(fileId);
45
+ return {
46
+ enter: `;${local}&&${local}(${literal});`,
47
+ prologue: `;var ${local}=globalThis.__ccqaCoverage;${local}&&${local}(${literal},true);`
48
+ };
49
+ }
50
+ /** Applies insert-only edits (no newlines) so line numbers survive untouched. */
51
+ function splice(code, edits) {
52
+ const parts = [];
53
+ let last = 0;
54
+ for (const edit of edits) {
55
+ parts.push(code.slice(last, edit.offset), edit.text);
56
+ last = edit.offset;
57
+ }
58
+ parts.push(code.slice(last));
59
+ return parts.join("");
60
+ }
27
61
  function transform(code, options) {
28
62
  const program = parseProgram(code);
29
63
  if (program === void 0) return void 0;
30
- const local = `__ccqa_${hash(options.fileId)}`;
31
- const literal = JSON.stringify(options.fileId);
32
- const enter = `${local}&&${local}(${literal});`;
64
+ const { enter, prologue } = probeTexts(options.fileId);
33
65
  const points = [];
34
- collect(program, options.maxDepth ?? DEFAULT_MAX_DEPTH, points);
66
+ collect$1(program, options.maxDepth ?? 2, points);
35
67
  if (code.length === 0) return void 0;
36
- const prologueAt = afterDirectives(code, program);
68
+ const prologueAt = afterDirectives$1(code, program);
37
69
  const edits = points.filter((offset) => offset > prologueAt).map((offset) => ({
38
70
  offset,
39
71
  text: enter
40
72
  }));
41
73
  edits.push({
42
74
  offset: prologueAt,
43
- text: `var ${local}=globalThis.__ccqaCoverage;${local}&&${local}(${literal},true);`
75
+ text: prologue
44
76
  });
45
77
  edits.sort((a, b) => a.offset - b.offset);
46
- const parts = [];
47
- let last = 0;
48
- for (const edit of edits) {
49
- parts.push(code.slice(last, edit.offset), edit.text);
50
- last = edit.offset;
51
- }
52
- parts.push(code.slice(last));
53
- return parts.join("");
78
+ return splice(code, edits);
54
79
  }
55
80
  function parseProgram(code) {
56
81
  for (const sourceType of ["module", "script"]) try {
@@ -70,7 +95,7 @@ function parseProgram(code) {
70
95
  * instrumentation would be changing the behaviour it is supposed to observe.
71
96
  * Applies to a function body as much as to the module.
72
97
  */
73
- function afterDirectives(code, program) {
98
+ function afterDirectives$1(code, program) {
74
99
  let offset = program.start;
75
100
  if (code.startsWith("#!")) {
76
101
  const newline = code.indexOf("\n");
@@ -93,7 +118,7 @@ const FUNCTION_TYPES = new Set([
93
118
  "FunctionExpression",
94
119
  "ArrowFunctionExpression"
95
120
  ]);
96
- function collect(root, maxDepth, points) {
121
+ function collect$1(root, maxDepth, points) {
97
122
  walk(root, 0, false);
98
123
  function walk(node, depth, inClass) {
99
124
  const isFunction = FUNCTION_TYPES.has(node.type);
@@ -110,13 +135,13 @@ function collect(root, maxDepth, points) {
110
135
  if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
111
136
  const value = node[key];
112
137
  if (Array.isArray(value)) {
113
- for (const item of value) if (isNode(item)) walk(item, nextDepth, childInClass(node, key, inClass));
114
- } else if (isNode(value)) walk(value, nextDepth, childInClass(node, key, inClass));
138
+ for (const item of value) if (isNode(item)) walk(item, nextDepth, childInClass$1(node, key, inClass));
139
+ } else if (isNode(value)) walk(value, nextDepth, childInClass$1(node, key, inClass));
115
140
  }
116
141
  }
117
142
  }
118
143
  /** True while walking the value of a class member, false again inside its body. */
119
- function childInClass(parent, key, inherited) {
144
+ function childInClass$1(parent, key, inherited) {
120
145
  if (parent.type === "MethodDefinition" || parent.type === "PropertyDefinition") return key === "value";
121
146
  if (FUNCTION_TYPES.has(parent.type)) return false;
122
147
  return inherited;
@@ -134,20 +159,162 @@ function hash(value) {
134
159
  return (h >>> 0).toString(36);
135
160
  }
136
161
  //#endregion
162
+ //#region src/coverage/instrument/transform-ts.ts
163
+ /**
164
+ * TypeScript/TSX form of the instrumenter.
165
+ *
166
+ * The webpack post-loader sees compiled JavaScript and parses with acorn;
167
+ * Turbopack hands loaders the *original* source, before its own TypeScript
168
+ * and JSX transforms, so this dialect parses with the `typescript` compiler
169
+ * API instead and splices the same probes into the untranspiled text. The
170
+ * output stays TypeScript — the bundler's own pipeline compiles it after us —
171
+ * which is what keeps this file free of any JSX/downlevel emit of its own.
172
+ *
173
+ * Same contract as `transform`: insertions only, never a newline, so line
174
+ * numbers and the framework's source maps survive untouched.
175
+ *
176
+ * `typescript` is loaded lazily and is not a dependency of this package: this
177
+ * path only runs at build time inside a project that compiles TypeScript,
178
+ * where the compiler is present by definition. It is resolved from the
179
+ * *project* first — resolving from this package's own position would lean on
180
+ * the package manager's hoisting, and could pick a different compiler than
181
+ * the one the project builds with. When it is somehow absent the file is
182
+ * left uninstrumented and the loader warns.
183
+ */
184
+ const ownRequire = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
185
+ let tsModule;
186
+ function loadTypescript(resolveFrom) {
187
+ if (tsModule !== void 0) return tsModule;
188
+ if (resolveFrom !== void 0) try {
189
+ tsModule = (0, node_module.createRequire)((0, node_path.join)(resolveFrom, "noop.js"))("typescript");
190
+ return tsModule;
191
+ } catch {}
192
+ try {
193
+ tsModule = ownRequire("typescript");
194
+ } catch {
195
+ tsModule = null;
196
+ }
197
+ return tsModule;
198
+ }
199
+ /** Whether the TypeScript dialect can run at all in this process. */
200
+ function typescriptAvailable(resolveFrom) {
201
+ return loadTypescript(resolveFrom) !== null;
202
+ }
203
+ function transformTs(code, options) {
204
+ const ts = loadTypescript(options.resolveFrom);
205
+ if (ts === null || code.length === 0) return void 0;
206
+ const kind = scriptKindFor(ts, options.extension ?? ".tsx");
207
+ const sourceFile = ts.createSourceFile("module.tsx", code, ts.ScriptTarget.Latest, false, kind);
208
+ const diagnostics = sourceFile.parseDiagnostics;
209
+ if (Array.isArray(diagnostics) && diagnostics.length > 0) return void 0;
210
+ const { enter, prologue } = probeTexts(options.fileId);
211
+ const points = [];
212
+ collect(ts, sourceFile, options.maxDepth ?? 2, points);
213
+ const first = sourceFile.statements[0];
214
+ const base = first === void 0 ? code.length : first.getStart(sourceFile);
215
+ const prologueAt = afterDirectives(ts, sourceFile.statements, base);
216
+ const edits = points.filter((offset) => offset > prologueAt).map((offset) => ({
217
+ offset,
218
+ text: enter
219
+ }));
220
+ edits.push({
221
+ offset: prologueAt,
222
+ text: prologue
223
+ });
224
+ edits.sort((a, b) => a.offset - b.offset);
225
+ return splice(code, edits);
226
+ }
227
+ function scriptKindFor(ts, extension) {
228
+ switch (extension) {
229
+ case ".ts":
230
+ case ".mts":
231
+ case ".cts": return ts.ScriptKind.TS;
232
+ case ".jsx": return ts.ScriptKind.JSX;
233
+ case ".js":
234
+ case ".mjs":
235
+ case ".cjs": return ts.ScriptKind.JSX;
236
+ default: return ts.ScriptKind.TSX;
237
+ }
238
+ }
239
+ function collect(ts, sourceFile, maxDepth, points) {
240
+ walk(sourceFile, 0, false);
241
+ function walk(node, depth, inClass) {
242
+ const isFunction = isFunctionLike(ts, node);
243
+ const nextDepth = isFunction ? depth + 1 : depth;
244
+ if (isFunction) {
245
+ const wanted = inClass || nextDepth <= maxDepth;
246
+ const body = node.body;
247
+ if (wanted && body !== void 0 && ts.isBlock(body)) points.push(afterDirectives(ts, body.statements, body.getStart(sourceFile) + 1));
248
+ }
249
+ ts.forEachChild(node, (child) => walk(child, nextDepth, childInClass(ts, node, child, inClass)));
250
+ }
251
+ }
252
+ function isFunctionLike(ts, node) {
253
+ return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node) || ts.isConstructorDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node);
254
+ }
255
+ /**
256
+ * Mirrors the acorn walk's rule: true for the function a class member *is* or
257
+ * holds, false again once inside any function body. In this AST a class
258
+ * method is itself the function-like — there is no separate `value` node —
259
+ * so membership is decided when the class hands its members down.
260
+ */
261
+ function childInClass(ts, parent, child, inherited) {
262
+ if (ts.isClassDeclaration(parent) || ts.isClassExpression(parent)) return ts.isMethodDeclaration(child) || ts.isConstructorDeclaration(child) || ts.isGetAccessorDeclaration(child) || ts.isSetAccessorDeclaration(child) || ts.isPropertyDeclaration(child);
263
+ if (ts.isPropertyDeclaration(parent)) return child === parent.initializer;
264
+ if (isFunctionLike(ts, parent)) return false;
265
+ return inherited;
266
+ }
267
+ /**
268
+ * Same rule as the acorn dialect: a directive prologue only counts while it
269
+ * is still the first thing in its scope, and `"use client"` / `"use server"`
270
+ * are directives to Next, so nothing may be inserted ahead of them.
271
+ */
272
+ function afterDirectives(ts, statements, from) {
273
+ let offset = from;
274
+ for (const statement of statements) {
275
+ if (!ts.isExpressionStatement(statement)) break;
276
+ if (!ts.isStringLiteral(statement.expression)) break;
277
+ offset = statement.end;
278
+ }
279
+ return offset;
280
+ }
281
+ //#endregion
137
282
  //#region src/coverage/next/loader.ts
138
283
  /**
139
- * webpack loader form of the instrumenter, for code that reaches the runtime as
140
- * part of a bundle and so is invisible to the load hooks.
284
+ * The loader form of the instrumenter, for code that reaches the runtime as
285
+ * part of a bundle and so is invisible to the load hooks. One loader, two
286
+ * dialects, because the two bundlers hand it different inputs:
287
+ *
288
+ * - **webpack** registers it with `enforce: "post"`, so it sees compiled
289
+ * JavaScript and parses with acorn (`dialect: "compiled"`, the default).
290
+ * - **Turbopack** has no post phase — rule loaders run on the *original*
291
+ * TypeScript/TSX — so that registration passes `dialect: "source"` and the
292
+ * file is parsed with the `typescript` compiler API instead.
141
293
  *
142
- * It is registered with `enforce: "post"` so it sees JavaScript: webpack runs
143
- * post loaders last, after the framework's own TypeScript/JSX transform, which
144
- * keeps this file free of a TypeScript parser.
294
+ * Scoping also differs: webpack's rule carries `include`/`exclude` matchers,
295
+ * while Turbopack rules are extension globs, so the source dialect gets the
296
+ * include prefixes as options and filters through `shouldInstrument` — the
297
+ * same decision the runtime load hooks use.
145
298
  */
146
299
  function ccqaCoverageLoader(source) {
147
- const root = this.getOptions?.().root ?? process.cwd();
148
- const fileId = fileIdFor(this.resourcePath, root);
300
+ const options = this.getOptions?.() ?? {};
301
+ const root = options.root ?? process.cwd();
302
+ const dialect = options.dialect ?? "compiled";
303
+ const fileId = dialect === "source" ? shouldInstrument(this.resourcePath, {
304
+ root,
305
+ include: options.include ?? []
306
+ }) : fileIdFor(this.resourcePath, root);
149
307
  if (fileId === void 0) return source;
150
- const instrumented = transform(source, { fileId });
308
+ if (source.trim().length === 0) return source;
309
+ if (dialect === "source" && !typescriptAvailable(root)) {
310
+ this.emitWarning?.(/* @__PURE__ */ new Error(`ccqa-tools needs the \`typescript\` package to instrument Turbopack builds; ${fileId} left uninstrumented`));
311
+ return source;
312
+ }
313
+ const instrumented = dialect === "source" ? transformTs(source, {
314
+ fileId,
315
+ extension: (0, node_path.extname)(this.resourcePath),
316
+ resolveFrom: root
317
+ }) : transform(source, { fileId });
151
318
  if (instrumented === void 0) {
152
319
  this.emitWarning?.(/* @__PURE__ */ new Error(`ccqa-tools could not parse ${fileId}; left uninstrumented`));
153
320
  return source;
@@ -1,17 +1,30 @@
1
1
  //#region src/coverage/next/loader.d.ts
2
2
  /**
3
- * webpack loader form of the instrumenter, for code that reaches the runtime as
4
- * part of a bundle and so is invisible to the load hooks.
3
+ * The loader form of the instrumenter, for code that reaches the runtime as
4
+ * part of a bundle and so is invisible to the load hooks. One loader, two
5
+ * dialects, because the two bundlers hand it different inputs:
5
6
  *
6
- * It is registered with `enforce: "post"` so it sees JavaScript: webpack runs
7
- * post loaders last, after the framework's own TypeScript/JSX transform, which
8
- * keeps this file free of a TypeScript parser.
7
+ * - **webpack** registers it with `enforce: "post"`, so it sees compiled
8
+ * JavaScript and parses with acorn (`dialect: "compiled"`, the default).
9
+ * - **Turbopack** has no post phase rule loaders run on the *original*
10
+ * TypeScript/TSX — so that registration passes `dialect: "source"` and the
11
+ * file is parsed with the `typescript` compiler API instead.
12
+ *
13
+ * Scoping also differs: webpack's rule carries `include`/`exclude` matchers,
14
+ * while Turbopack rules are extension globs, so the source dialect gets the
15
+ * include prefixes as options and filters through `shouldInstrument` — the
16
+ * same decision the runtime load hooks use.
9
17
  */
18
+ interface LoaderOptions {
19
+ root?: string;
20
+ /** How the input is parsed; see the module comment. */
21
+ dialect?: "compiled" | "source";
22
+ /** Root-relative directory prefixes to instrument (source dialect only). */
23
+ include?: string[];
24
+ }
10
25
  interface LoaderContext {
11
26
  resourcePath: string;
12
- getOptions?: () => {
13
- root?: string;
14
- };
27
+ getOptions?: () => LoaderOptions;
15
28
  emitWarning?: (warning: Error) => void;
16
29
  }
17
30
  declare function ccqaCoverageLoader(this: LoaderContext, source: string): string;
@@ -1,17 +1,30 @@
1
1
  //#region src/coverage/next/loader.d.ts
2
2
  /**
3
- * webpack loader form of the instrumenter, for code that reaches the runtime as
4
- * part of a bundle and so is invisible to the load hooks.
3
+ * The loader form of the instrumenter, for code that reaches the runtime as
4
+ * part of a bundle and so is invisible to the load hooks. One loader, two
5
+ * dialects, because the two bundlers hand it different inputs:
5
6
  *
6
- * It is registered with `enforce: "post"` so it sees JavaScript: webpack runs
7
- * post loaders last, after the framework's own TypeScript/JSX transform, which
8
- * keeps this file free of a TypeScript parser.
7
+ * - **webpack** registers it with `enforce: "post"`, so it sees compiled
8
+ * JavaScript and parses with acorn (`dialect: "compiled"`, the default).
9
+ * - **Turbopack** has no post phase rule loaders run on the *original*
10
+ * TypeScript/TSX — so that registration passes `dialect: "source"` and the
11
+ * file is parsed with the `typescript` compiler API instead.
12
+ *
13
+ * Scoping also differs: webpack's rule carries `include`/`exclude` matchers,
14
+ * while Turbopack rules are extension globs, so the source dialect gets the
15
+ * include prefixes as options and filters through `shouldInstrument` — the
16
+ * same decision the runtime load hooks use.
9
17
  */
18
+ interface LoaderOptions {
19
+ root?: string;
20
+ /** How the input is parsed; see the module comment. */
21
+ dialect?: "compiled" | "source";
22
+ /** Root-relative directory prefixes to instrument (source dialect only). */
23
+ include?: string[];
24
+ }
10
25
  interface LoaderContext {
11
26
  resourcePath: string;
12
- getOptions?: () => {
13
- root?: string;
14
- };
27
+ getOptions?: () => LoaderOptions;
15
28
  emitWarning?: (warning: Error) => void;
16
29
  }
17
30
  declare function ccqaCoverageLoader(this: LoaderContext, source: string): string;
@@ -1,56 +1,81 @@
1
- import { relative, resolve, sep } from "node:path";
1
+ import { extname, join, relative, resolve, sep } from "node:path";
2
2
  import { parse } from "acorn";
3
+ import { createRequire } from "node:module";
3
4
  //#region src/coverage/instrument/select.ts
5
+ const SOURCE_EXTENSIONS = [
6
+ ".js",
7
+ ".mjs",
8
+ ".cjs",
9
+ ".jsx",
10
+ ".ts",
11
+ ".mts",
12
+ ".cts",
13
+ ".tsx"
14
+ ];
15
+ /**
16
+ * Decides whether a file is part of the project under test, returning its id
17
+ * if so. Returning the id here — rather than a plain boolean — spares the
18
+ * caller a second `fileIdFor` pass over the same path.
19
+ *
20
+ * `node_modules` is excluded unconditionally: instrumenting dependencies costs
21
+ * the most and answers the least — nobody adds a test because a library file
22
+ * went unreached.
23
+ */
24
+ function shouldInstrument(filename, config) {
25
+ if (filename.includes(`${sep}node_modules${sep}`)) return void 0;
26
+ if (!SOURCE_EXTENSIONS.some((extension) => filename.endsWith(extension))) return void 0;
27
+ const id = fileIdFor(filename, config.root);
28
+ if (id === void 0) return void 0;
29
+ return config.include.some((prefix) => id === prefix || id.startsWith(`${prefix}/`)) ? id : void 0;
30
+ }
4
31
  /** Path relative to the project root, in posix form so ids match across hosts. */
5
32
  function fileIdFor(filename, root) {
6
33
  const rel = relative(resolve(root), filename);
7
34
  if (rel.startsWith("..") || rel === "") return void 0;
8
35
  return rel.split(sep).join("/");
9
36
  }
10
- //#endregion
11
- //#region src/coverage/instrument/transform.ts
12
37
  /**
13
- * Rewrites a module so that entering it, or entering one of its functions,
14
- * calls `globalThis.__ccqaCoverage`.
15
- *
16
- * Two properties drive the whole shape:
17
- *
18
- * - **Insertions only, never a newline.** Line numbers survive untouched, so
19
- * the source map the application already ships keeps pointing at the right
20
- * lines and stack traces stay readable. A codegen round-trip would have
21
- * forced us to produce and merge maps of our own.
22
- * - **File granularity.** The record is "this file ran", so there is no need to
23
- * track statements or branches, and the whole class of line/branch
24
- * normalisation bugs that follow V8-to-istanbul conversion never appears.
38
+ * The exact texts both instrumenter dialects splice in, built in one place so
39
+ * a file instrumented from TypeScript and one instrumented from compiled
40
+ * JavaScript are byte-identical where it matters.
25
41
  */
26
- const DEFAULT_MAX_DEPTH = 2;
42
+ function probeTexts(fileId) {
43
+ const local = `__ccqa_${hash(fileId)}`;
44
+ const literal = JSON.stringify(fileId);
45
+ return {
46
+ enter: `;${local}&&${local}(${literal});`,
47
+ prologue: `;var ${local}=globalThis.__ccqaCoverage;${local}&&${local}(${literal},true);`
48
+ };
49
+ }
50
+ /** Applies insert-only edits (no newlines) so line numbers survive untouched. */
51
+ function splice(code, edits) {
52
+ const parts = [];
53
+ let last = 0;
54
+ for (const edit of edits) {
55
+ parts.push(code.slice(last, edit.offset), edit.text);
56
+ last = edit.offset;
57
+ }
58
+ parts.push(code.slice(last));
59
+ return parts.join("");
60
+ }
27
61
  function transform(code, options) {
28
62
  const program = parseProgram(code);
29
63
  if (program === void 0) return void 0;
30
- const local = `__ccqa_${hash(options.fileId)}`;
31
- const literal = JSON.stringify(options.fileId);
32
- const enter = `${local}&&${local}(${literal});`;
64
+ const { enter, prologue } = probeTexts(options.fileId);
33
65
  const points = [];
34
- collect(program, options.maxDepth ?? DEFAULT_MAX_DEPTH, points);
66
+ collect$1(program, options.maxDepth ?? 2, points);
35
67
  if (code.length === 0) return void 0;
36
- const prologueAt = afterDirectives(code, program);
68
+ const prologueAt = afterDirectives$1(code, program);
37
69
  const edits = points.filter((offset) => offset > prologueAt).map((offset) => ({
38
70
  offset,
39
71
  text: enter
40
72
  }));
41
73
  edits.push({
42
74
  offset: prologueAt,
43
- text: `var ${local}=globalThis.__ccqaCoverage;${local}&&${local}(${literal},true);`
75
+ text: prologue
44
76
  });
45
77
  edits.sort((a, b) => a.offset - b.offset);
46
- const parts = [];
47
- let last = 0;
48
- for (const edit of edits) {
49
- parts.push(code.slice(last, edit.offset), edit.text);
50
- last = edit.offset;
51
- }
52
- parts.push(code.slice(last));
53
- return parts.join("");
78
+ return splice(code, edits);
54
79
  }
55
80
  function parseProgram(code) {
56
81
  for (const sourceType of ["module", "script"]) try {
@@ -70,7 +95,7 @@ function parseProgram(code) {
70
95
  * instrumentation would be changing the behaviour it is supposed to observe.
71
96
  * Applies to a function body as much as to the module.
72
97
  */
73
- function afterDirectives(code, program) {
98
+ function afterDirectives$1(code, program) {
74
99
  let offset = program.start;
75
100
  if (code.startsWith("#!")) {
76
101
  const newline = code.indexOf("\n");
@@ -93,7 +118,7 @@ const FUNCTION_TYPES = new Set([
93
118
  "FunctionExpression",
94
119
  "ArrowFunctionExpression"
95
120
  ]);
96
- function collect(root, maxDepth, points) {
121
+ function collect$1(root, maxDepth, points) {
97
122
  walk(root, 0, false);
98
123
  function walk(node, depth, inClass) {
99
124
  const isFunction = FUNCTION_TYPES.has(node.type);
@@ -110,13 +135,13 @@ function collect(root, maxDepth, points) {
110
135
  if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
111
136
  const value = node[key];
112
137
  if (Array.isArray(value)) {
113
- for (const item of value) if (isNode(item)) walk(item, nextDepth, childInClass(node, key, inClass));
114
- } else if (isNode(value)) walk(value, nextDepth, childInClass(node, key, inClass));
138
+ for (const item of value) if (isNode(item)) walk(item, nextDepth, childInClass$1(node, key, inClass));
139
+ } else if (isNode(value)) walk(value, nextDepth, childInClass$1(node, key, inClass));
115
140
  }
116
141
  }
117
142
  }
118
143
  /** True while walking the value of a class member, false again inside its body. */
119
- function childInClass(parent, key, inherited) {
144
+ function childInClass$1(parent, key, inherited) {
120
145
  if (parent.type === "MethodDefinition" || parent.type === "PropertyDefinition") return key === "value";
121
146
  if (FUNCTION_TYPES.has(parent.type)) return false;
122
147
  return inherited;
@@ -134,20 +159,162 @@ function hash(value) {
134
159
  return (h >>> 0).toString(36);
135
160
  }
136
161
  //#endregion
162
+ //#region src/coverage/instrument/transform-ts.ts
163
+ /**
164
+ * TypeScript/TSX form of the instrumenter.
165
+ *
166
+ * The webpack post-loader sees compiled JavaScript and parses with acorn;
167
+ * Turbopack hands loaders the *original* source, before its own TypeScript
168
+ * and JSX transforms, so this dialect parses with the `typescript` compiler
169
+ * API instead and splices the same probes into the untranspiled text. The
170
+ * output stays TypeScript — the bundler's own pipeline compiles it after us —
171
+ * which is what keeps this file free of any JSX/downlevel emit of its own.
172
+ *
173
+ * Same contract as `transform`: insertions only, never a newline, so line
174
+ * numbers and the framework's source maps survive untouched.
175
+ *
176
+ * `typescript` is loaded lazily and is not a dependency of this package: this
177
+ * path only runs at build time inside a project that compiles TypeScript,
178
+ * where the compiler is present by definition. It is resolved from the
179
+ * *project* first — resolving from this package's own position would lean on
180
+ * the package manager's hoisting, and could pick a different compiler than
181
+ * the one the project builds with. When it is somehow absent the file is
182
+ * left uninstrumented and the loader warns.
183
+ */
184
+ const ownRequire = createRequire(import.meta.url);
185
+ let tsModule;
186
+ function loadTypescript(resolveFrom) {
187
+ if (tsModule !== void 0) return tsModule;
188
+ if (resolveFrom !== void 0) try {
189
+ tsModule = createRequire(join(resolveFrom, "noop.js"))("typescript");
190
+ return tsModule;
191
+ } catch {}
192
+ try {
193
+ tsModule = ownRequire("typescript");
194
+ } catch {
195
+ tsModule = null;
196
+ }
197
+ return tsModule;
198
+ }
199
+ /** Whether the TypeScript dialect can run at all in this process. */
200
+ function typescriptAvailable(resolveFrom) {
201
+ return loadTypescript(resolveFrom) !== null;
202
+ }
203
+ function transformTs(code, options) {
204
+ const ts = loadTypescript(options.resolveFrom);
205
+ if (ts === null || code.length === 0) return void 0;
206
+ const kind = scriptKindFor(ts, options.extension ?? ".tsx");
207
+ const sourceFile = ts.createSourceFile("module.tsx", code, ts.ScriptTarget.Latest, false, kind);
208
+ const diagnostics = sourceFile.parseDiagnostics;
209
+ if (Array.isArray(diagnostics) && diagnostics.length > 0) return void 0;
210
+ const { enter, prologue } = probeTexts(options.fileId);
211
+ const points = [];
212
+ collect(ts, sourceFile, options.maxDepth ?? 2, points);
213
+ const first = sourceFile.statements[0];
214
+ const base = first === void 0 ? code.length : first.getStart(sourceFile);
215
+ const prologueAt = afterDirectives(ts, sourceFile.statements, base);
216
+ const edits = points.filter((offset) => offset > prologueAt).map((offset) => ({
217
+ offset,
218
+ text: enter
219
+ }));
220
+ edits.push({
221
+ offset: prologueAt,
222
+ text: prologue
223
+ });
224
+ edits.sort((a, b) => a.offset - b.offset);
225
+ return splice(code, edits);
226
+ }
227
+ function scriptKindFor(ts, extension) {
228
+ switch (extension) {
229
+ case ".ts":
230
+ case ".mts":
231
+ case ".cts": return ts.ScriptKind.TS;
232
+ case ".jsx": return ts.ScriptKind.JSX;
233
+ case ".js":
234
+ case ".mjs":
235
+ case ".cjs": return ts.ScriptKind.JSX;
236
+ default: return ts.ScriptKind.TSX;
237
+ }
238
+ }
239
+ function collect(ts, sourceFile, maxDepth, points) {
240
+ walk(sourceFile, 0, false);
241
+ function walk(node, depth, inClass) {
242
+ const isFunction = isFunctionLike(ts, node);
243
+ const nextDepth = isFunction ? depth + 1 : depth;
244
+ if (isFunction) {
245
+ const wanted = inClass || nextDepth <= maxDepth;
246
+ const body = node.body;
247
+ if (wanted && body !== void 0 && ts.isBlock(body)) points.push(afterDirectives(ts, body.statements, body.getStart(sourceFile) + 1));
248
+ }
249
+ ts.forEachChild(node, (child) => walk(child, nextDepth, childInClass(ts, node, child, inClass)));
250
+ }
251
+ }
252
+ function isFunctionLike(ts, node) {
253
+ return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node) || ts.isConstructorDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node);
254
+ }
255
+ /**
256
+ * Mirrors the acorn walk's rule: true for the function a class member *is* or
257
+ * holds, false again once inside any function body. In this AST a class
258
+ * method is itself the function-like — there is no separate `value` node —
259
+ * so membership is decided when the class hands its members down.
260
+ */
261
+ function childInClass(ts, parent, child, inherited) {
262
+ if (ts.isClassDeclaration(parent) || ts.isClassExpression(parent)) return ts.isMethodDeclaration(child) || ts.isConstructorDeclaration(child) || ts.isGetAccessorDeclaration(child) || ts.isSetAccessorDeclaration(child) || ts.isPropertyDeclaration(child);
263
+ if (ts.isPropertyDeclaration(parent)) return child === parent.initializer;
264
+ if (isFunctionLike(ts, parent)) return false;
265
+ return inherited;
266
+ }
267
+ /**
268
+ * Same rule as the acorn dialect: a directive prologue only counts while it
269
+ * is still the first thing in its scope, and `"use client"` / `"use server"`
270
+ * are directives to Next, so nothing may be inserted ahead of them.
271
+ */
272
+ function afterDirectives(ts, statements, from) {
273
+ let offset = from;
274
+ for (const statement of statements) {
275
+ if (!ts.isExpressionStatement(statement)) break;
276
+ if (!ts.isStringLiteral(statement.expression)) break;
277
+ offset = statement.end;
278
+ }
279
+ return offset;
280
+ }
281
+ //#endregion
137
282
  //#region src/coverage/next/loader.ts
138
283
  /**
139
- * webpack loader form of the instrumenter, for code that reaches the runtime as
140
- * part of a bundle and so is invisible to the load hooks.
284
+ * The loader form of the instrumenter, for code that reaches the runtime as
285
+ * part of a bundle and so is invisible to the load hooks. One loader, two
286
+ * dialects, because the two bundlers hand it different inputs:
287
+ *
288
+ * - **webpack** registers it with `enforce: "post"`, so it sees compiled
289
+ * JavaScript and parses with acorn (`dialect: "compiled"`, the default).
290
+ * - **Turbopack** has no post phase — rule loaders run on the *original*
291
+ * TypeScript/TSX — so that registration passes `dialect: "source"` and the
292
+ * file is parsed with the `typescript` compiler API instead.
141
293
  *
142
- * It is registered with `enforce: "post"` so it sees JavaScript: webpack runs
143
- * post loaders last, after the framework's own TypeScript/JSX transform, which
144
- * keeps this file free of a TypeScript parser.
294
+ * Scoping also differs: webpack's rule carries `include`/`exclude` matchers,
295
+ * while Turbopack rules are extension globs, so the source dialect gets the
296
+ * include prefixes as options and filters through `shouldInstrument` — the
297
+ * same decision the runtime load hooks use.
145
298
  */
146
299
  function ccqaCoverageLoader(source) {
147
- const root = this.getOptions?.().root ?? process.cwd();
148
- const fileId = fileIdFor(this.resourcePath, root);
300
+ const options = this.getOptions?.() ?? {};
301
+ const root = options.root ?? process.cwd();
302
+ const dialect = options.dialect ?? "compiled";
303
+ const fileId = dialect === "source" ? shouldInstrument(this.resourcePath, {
304
+ root,
305
+ include: options.include ?? []
306
+ }) : fileIdFor(this.resourcePath, root);
149
307
  if (fileId === void 0) return source;
150
- const instrumented = transform(source, { fileId });
308
+ if (source.trim().length === 0) return source;
309
+ if (dialect === "source" && !typescriptAvailable(root)) {
310
+ this.emitWarning?.(/* @__PURE__ */ new Error(`ccqa-tools needs the \`typescript\` package to instrument Turbopack builds; ${fileId} left uninstrumented`));
311
+ return source;
312
+ }
313
+ const instrumented = dialect === "source" ? transformTs(source, {
314
+ fileId,
315
+ extension: extname(this.resourcePath),
316
+ resolveFrom: root
317
+ }) : transform(source, { fileId });
151
318
  if (instrumented === void 0) {
152
319
  this.emitWarning?.(/* @__PURE__ */ new Error(`ccqa-tools could not parse ${fileId}; left uninstrumented`));
153
320
  return source;
@@ -1,6 +1,18 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let node_module = require("node:module");
3
3
  let node_path = require("node:path");
4
+ //#region src/coverage/instrument/select.ts
5
+ const SOURCE_EXTENSIONS = [
6
+ ".js",
7
+ ".mjs",
8
+ ".cjs",
9
+ ".jsx",
10
+ ".ts",
11
+ ".mts",
12
+ ".cts",
13
+ ".tsx"
14
+ ];
15
+ //#endregion
4
16
  //#region src/coverage/wire.ts
5
17
  /**
6
18
  * Enables the instrumentation. Unset means the register hook is never loaded
@@ -69,12 +81,22 @@ function debugLog(config, message) {
69
81
  * coverage already reads V8's own counters and needs nothing injected.
70
82
  */
71
83
  const require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
72
- /** Wraps a Next config, preserving any `webpack` hook it already has. */
84
+ /**
85
+ * Wraps a Next config, preserving any `webpack` hook and `turbopack` rules it
86
+ * already has. Both bundlers are wired because the config does not know which
87
+ * one will build it: webpack gets a post-loader over compiled JavaScript,
88
+ * Turbopack — which has no post phase and never calls the `webpack` hook —
89
+ * gets rule loaders that instrument the original TypeScript instead. Missing
90
+ * either half means a Turbopack (or webpack) build silently ships
91
+ * uninstrumented server code whose only coverage is the module-load boot set.
92
+ */
73
93
  function withCoverage(config, options = {}) {
74
94
  if (!(options.enabled ?? process.env["CCQA_COVERAGE"] !== void 0)) return config;
75
95
  const root = (0, node_path.resolve)(options.root ?? readConfig().root);
76
- const include = (options.include ?? ["src"]).map((dir) => (0, node_path.resolve)(root, dir));
96
+ const relativeInclude = (options.include ?? readConfig().include).map((dir) => dir.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, ""));
97
+ const include = relativeInclude.map((dir) => (0, node_path.resolve)(root, dir));
77
98
  const previous = config.webpack;
99
+ debugLog(readConfig(), `instrumenting server bundles under ${include.join(", ")}`);
78
100
  return {
79
101
  ...config,
80
102
  webpack(webpackConfig, context) {
@@ -84,7 +106,7 @@ function withCoverage(config, options = {}) {
84
106
  next.module.rules ??= [];
85
107
  next.module.rules.push({
86
108
  enforce: "post",
87
- test: /\.(?:[cm]?js|jsx|tsx?)$/,
109
+ test: /\.(?:[cm]?[jt]s|jsx|tsx)$/,
88
110
  include,
89
111
  exclude: /[\\/]node_modules[\\/]/,
90
112
  use: [{
@@ -92,10 +114,49 @@ function withCoverage(config, options = {}) {
92
114
  options: { root }
93
115
  }]
94
116
  });
95
- debugLog(readConfig(), `instrumenting server bundles under ${include.join(", ")}`);
96
117
  return next;
97
- }
118
+ },
119
+ turbopack: withTurbopackRules(config.turbopack, root, relativeInclude)
120
+ };
121
+ }
122
+ /**
123
+ * Turbopack rule globs match by extension, everywhere — there is no
124
+ * `include` matcher — so project scoping happens inside the loader through
125
+ * `shouldInstrument`. The rule's condition does what the webpack half's
126
+ * `isServer` gate and `exclude` matcher do: `not browser` keeps probes out
127
+ * of client bundles, `not foreign` keeps dependencies out of the loader
128
+ * entirely. No `as` is set, so the instrumented output stays the same module
129
+ * type and flows through the framework's own TypeScript pipeline.
130
+ *
131
+ * One measured trap: Turbopack serves unchanged files from its persistent
132
+ * cache without consulting rules again, so judging a rule change by an
133
+ * incremental build lies — a rule "not firing" may just be a warm cache.
134
+ */
135
+ function withTurbopackRules(existing, root, include) {
136
+ const rule = {
137
+ condition: { all: [{ not: "browser" }, { not: "foreign" }] },
138
+ loaders: [{
139
+ loader: require$1.resolve("./next-loader.cjs"),
140
+ options: {
141
+ root,
142
+ include,
143
+ dialect: "source"
144
+ }
145
+ }]
98
146
  };
147
+ const rules = { ...existing?.rules };
148
+ for (const extension of SOURCE_EXTENSIONS) {
149
+ const glob = `*${extension}`;
150
+ rules[glob] = [...toArray(rules[glob]), rule];
151
+ }
152
+ return {
153
+ ...existing,
154
+ rules
155
+ };
156
+ }
157
+ function toArray(value) {
158
+ if (value === void 0) return [];
159
+ return Array.isArray(value) ? value : [value];
99
160
  }
100
161
  //#endregion
101
162
  exports.withCoverage = withCoverage;
@@ -17,6 +17,10 @@
17
17
  * `__ccqaCoverage` calls to browsers, where the front-end side of ccqa's
18
18
  * coverage already reads V8's own counters and needs nothing injected.
19
19
  */
20
+ type TurbopackRules = Record<string, unknown>;
21
+ interface TurbopackConfig {
22
+ rules?: TurbopackRules;
23
+ }
20
24
  interface CoverageNextOptions {
21
25
  /** Project root that file ids are relative to. Defaults to `process.cwd()`. */
22
26
  root?: string;
@@ -25,9 +29,18 @@ interface CoverageNextOptions {
25
29
  /** Set false to build without instrumentation while keeping the config in place. */
26
30
  enabled?: boolean;
27
31
  }
28
- /** Wraps a Next config, preserving any `webpack` hook it already has. */
32
+ /**
33
+ * Wraps a Next config, preserving any `webpack` hook and `turbopack` rules it
34
+ * already has. Both bundlers are wired because the config does not know which
35
+ * one will build it: webpack gets a post-loader over compiled JavaScript,
36
+ * Turbopack — which has no post phase and never calls the `webpack` hook —
37
+ * gets rule loaders that instrument the original TypeScript instead. Missing
38
+ * either half means a Turbopack (or webpack) build silently ships
39
+ * uninstrumented server code whose only coverage is the module-load boot set.
40
+ */
29
41
  declare function withCoverage<T extends {
30
42
  webpack?: unknown;
43
+ turbopack?: TurbopackConfig;
31
44
  }>(config: T, options?: CoverageNextOptions): T;
32
45
  //#endregion
33
46
  export { CoverageNextOptions, withCoverage };
@@ -17,6 +17,10 @@
17
17
  * `__ccqaCoverage` calls to browsers, where the front-end side of ccqa's
18
18
  * coverage already reads V8's own counters and needs nothing injected.
19
19
  */
20
+ type TurbopackRules = Record<string, unknown>;
21
+ interface TurbopackConfig {
22
+ rules?: TurbopackRules;
23
+ }
20
24
  interface CoverageNextOptions {
21
25
  /** Project root that file ids are relative to. Defaults to `process.cwd()`. */
22
26
  root?: string;
@@ -25,9 +29,18 @@ interface CoverageNextOptions {
25
29
  /** Set false to build without instrumentation while keeping the config in place. */
26
30
  enabled?: boolean;
27
31
  }
28
- /** Wraps a Next config, preserving any `webpack` hook it already has. */
32
+ /**
33
+ * Wraps a Next config, preserving any `webpack` hook and `turbopack` rules it
34
+ * already has. Both bundlers are wired because the config does not know which
35
+ * one will build it: webpack gets a post-loader over compiled JavaScript,
36
+ * Turbopack — which has no post phase and never calls the `webpack` hook —
37
+ * gets rule loaders that instrument the original TypeScript instead. Missing
38
+ * either half means a Turbopack (or webpack) build silently ships
39
+ * uninstrumented server code whose only coverage is the module-load boot set.
40
+ */
29
41
  declare function withCoverage<T extends {
30
42
  webpack?: unknown;
43
+ turbopack?: TurbopackConfig;
31
44
  }>(config: T, options?: CoverageNextOptions): T;
32
45
  //#endregion
33
46
  export { CoverageNextOptions, withCoverage };
@@ -1,5 +1,17 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { resolve } from "node:path";
3
+ //#region src/coverage/instrument/select.ts
4
+ const SOURCE_EXTENSIONS = [
5
+ ".js",
6
+ ".mjs",
7
+ ".cjs",
8
+ ".jsx",
9
+ ".ts",
10
+ ".mts",
11
+ ".cts",
12
+ ".tsx"
13
+ ];
14
+ //#endregion
3
15
  //#region src/coverage/wire.ts
4
16
  /**
5
17
  * Enables the instrumentation. Unset means the register hook is never loaded
@@ -68,12 +80,22 @@ function debugLog(config, message) {
68
80
  * coverage already reads V8's own counters and needs nothing injected.
69
81
  */
70
82
  const require = createRequire(import.meta.url);
71
- /** Wraps a Next config, preserving any `webpack` hook it already has. */
83
+ /**
84
+ * Wraps a Next config, preserving any `webpack` hook and `turbopack` rules it
85
+ * already has. Both bundlers are wired because the config does not know which
86
+ * one will build it: webpack gets a post-loader over compiled JavaScript,
87
+ * Turbopack — which has no post phase and never calls the `webpack` hook —
88
+ * gets rule loaders that instrument the original TypeScript instead. Missing
89
+ * either half means a Turbopack (or webpack) build silently ships
90
+ * uninstrumented server code whose only coverage is the module-load boot set.
91
+ */
72
92
  function withCoverage(config, options = {}) {
73
93
  if (!(options.enabled ?? process.env["CCQA_COVERAGE"] !== void 0)) return config;
74
94
  const root = resolve(options.root ?? readConfig().root);
75
- const include = (options.include ?? ["src"]).map((dir) => resolve(root, dir));
95
+ const relativeInclude = (options.include ?? readConfig().include).map((dir) => dir.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, ""));
96
+ const include = relativeInclude.map((dir) => resolve(root, dir));
76
97
  const previous = config.webpack;
98
+ debugLog(readConfig(), `instrumenting server bundles under ${include.join(", ")}`);
77
99
  return {
78
100
  ...config,
79
101
  webpack(webpackConfig, context) {
@@ -83,7 +105,7 @@ function withCoverage(config, options = {}) {
83
105
  next.module.rules ??= [];
84
106
  next.module.rules.push({
85
107
  enforce: "post",
86
- test: /\.(?:[cm]?js|jsx|tsx?)$/,
108
+ test: /\.(?:[cm]?[jt]s|jsx|tsx)$/,
87
109
  include,
88
110
  exclude: /[\\/]node_modules[\\/]/,
89
111
  use: [{
@@ -91,10 +113,49 @@ function withCoverage(config, options = {}) {
91
113
  options: { root }
92
114
  }]
93
115
  });
94
- debugLog(readConfig(), `instrumenting server bundles under ${include.join(", ")}`);
95
116
  return next;
96
- }
117
+ },
118
+ turbopack: withTurbopackRules(config.turbopack, root, relativeInclude)
119
+ };
120
+ }
121
+ /**
122
+ * Turbopack rule globs match by extension, everywhere — there is no
123
+ * `include` matcher — so project scoping happens inside the loader through
124
+ * `shouldInstrument`. The rule's condition does what the webpack half's
125
+ * `isServer` gate and `exclude` matcher do: `not browser` keeps probes out
126
+ * of client bundles, `not foreign` keeps dependencies out of the loader
127
+ * entirely. No `as` is set, so the instrumented output stays the same module
128
+ * type and flows through the framework's own TypeScript pipeline.
129
+ *
130
+ * One measured trap: Turbopack serves unchanged files from its persistent
131
+ * cache without consulting rules again, so judging a rule change by an
132
+ * incremental build lies — a rule "not firing" may just be a warm cache.
133
+ */
134
+ function withTurbopackRules(existing, root, include) {
135
+ const rule = {
136
+ condition: { all: [{ not: "browser" }, { not: "foreign" }] },
137
+ loaders: [{
138
+ loader: require.resolve("./next-loader.cjs"),
139
+ options: {
140
+ root,
141
+ include,
142
+ dialect: "source"
143
+ }
144
+ }]
97
145
  };
146
+ const rules = { ...existing?.rules };
147
+ for (const extension of SOURCE_EXTENSIONS) {
148
+ const glob = `*${extension}`;
149
+ rules[glob] = [...toArray(rules[glob]), rule];
150
+ }
151
+ return {
152
+ ...existing,
153
+ rules
154
+ };
155
+ }
156
+ function toArray(value) {
157
+ if (value === void 0) return [];
158
+ return Array.isArray(value) ? value : [value];
98
159
  }
99
160
  //#endregion
100
161
  export { withCoverage };
@@ -557,31 +557,36 @@ function readSourceMappingUrl(code) {
557
557
  for (const match of code.matchAll(SOURCE_MAPPING_URL)) found = match[1];
558
558
  return found;
559
559
  }
560
- //#endregion
561
- //#region src/coverage/instrument/transform.ts
562
560
  /**
563
- * Rewrites a module so that entering it, or entering one of its functions,
564
- * calls `globalThis.__ccqaCoverage`.
565
- *
566
- * Two properties drive the whole shape:
567
- *
568
- * - **Insertions only, never a newline.** Line numbers survive untouched, so
569
- * the source map the application already ships keeps pointing at the right
570
- * lines and stack traces stay readable. A codegen round-trip would have
571
- * forced us to produce and merge maps of our own.
572
- * - **File granularity.** The record is "this file ran", so there is no need to
573
- * track statements or branches, and the whole class of line/branch
574
- * normalisation bugs that follow V8-to-istanbul conversion never appears.
561
+ * The exact texts both instrumenter dialects splice in, built in one place so
562
+ * a file instrumented from TypeScript and one instrumented from compiled
563
+ * JavaScript are byte-identical where it matters.
575
564
  */
576
- const DEFAULT_MAX_DEPTH = 2;
565
+ function probeTexts(fileId) {
566
+ const local = `__ccqa_${hash(fileId)}`;
567
+ const literal = JSON.stringify(fileId);
568
+ return {
569
+ enter: `;${local}&&${local}(${literal});`,
570
+ prologue: `;var ${local}=globalThis.__ccqaCoverage;${local}&&${local}(${literal},true);`
571
+ };
572
+ }
573
+ /** Applies insert-only edits (no newlines) so line numbers survive untouched. */
574
+ function splice(code, edits) {
575
+ const parts = [];
576
+ let last = 0;
577
+ for (const edit of edits) {
578
+ parts.push(code.slice(last, edit.offset), edit.text);
579
+ last = edit.offset;
580
+ }
581
+ parts.push(code.slice(last));
582
+ return parts.join("");
583
+ }
577
584
  function transform(code, options) {
578
585
  const program = parseProgram(code);
579
586
  if (program === void 0) return void 0;
580
- const local = `__ccqa_${hash(options.fileId)}`;
581
- const literal = JSON.stringify(options.fileId);
582
- const enter = `${local}&&${local}(${literal});`;
587
+ const { enter, prologue } = probeTexts(options.fileId);
583
588
  const points = [];
584
- collect(program, options.maxDepth ?? DEFAULT_MAX_DEPTH, points);
589
+ collect(program, options.maxDepth ?? 2, points);
585
590
  if (code.length === 0) return void 0;
586
591
  const prologueAt = afterDirectives(code, program);
587
592
  const edits = points.filter((offset) => offset > prologueAt).map((offset) => ({
@@ -590,17 +595,10 @@ function transform(code, options) {
590
595
  }));
591
596
  edits.push({
592
597
  offset: prologueAt,
593
- text: `var ${local}=globalThis.__ccqaCoverage;${local}&&${local}(${literal},true);`
598
+ text: prologue
594
599
  });
595
600
  edits.sort((a, b) => a.offset - b.offset);
596
- const parts = [];
597
- let last = 0;
598
- for (const edit of edits) {
599
- parts.push(code.slice(last, edit.offset), edit.text);
600
- last = edit.offset;
601
- }
602
- parts.push(code.slice(last));
603
- return parts.join("");
601
+ return splice(code, edits);
604
602
  }
605
603
  function parseProgram(code) {
606
604
  for (const sourceType of ["module", "script"]) try {
@@ -532,31 +532,36 @@ function readSourceMappingUrl(code) {
532
532
  for (const match of code.matchAll(SOURCE_MAPPING_URL)) found = match[1];
533
533
  return found;
534
534
  }
535
- //#endregion
536
- //#region src/coverage/instrument/transform.ts
537
535
  /**
538
- * Rewrites a module so that entering it, or entering one of its functions,
539
- * calls `globalThis.__ccqaCoverage`.
540
- *
541
- * Two properties drive the whole shape:
542
- *
543
- * - **Insertions only, never a newline.** Line numbers survive untouched, so
544
- * the source map the application already ships keeps pointing at the right
545
- * lines and stack traces stay readable. A codegen round-trip would have
546
- * forced us to produce and merge maps of our own.
547
- * - **File granularity.** The record is "this file ran", so there is no need to
548
- * track statements or branches, and the whole class of line/branch
549
- * normalisation bugs that follow V8-to-istanbul conversion never appears.
536
+ * The exact texts both instrumenter dialects splice in, built in one place so
537
+ * a file instrumented from TypeScript and one instrumented from compiled
538
+ * JavaScript are byte-identical where it matters.
550
539
  */
551
- const DEFAULT_MAX_DEPTH = 2;
540
+ function probeTexts(fileId) {
541
+ const local = `__ccqa_${hash(fileId)}`;
542
+ const literal = JSON.stringify(fileId);
543
+ return {
544
+ enter: `;${local}&&${local}(${literal});`,
545
+ prologue: `;var ${local}=globalThis.__ccqaCoverage;${local}&&${local}(${literal},true);`
546
+ };
547
+ }
548
+ /** Applies insert-only edits (no newlines) so line numbers survive untouched. */
549
+ function splice(code, edits) {
550
+ const parts = [];
551
+ let last = 0;
552
+ for (const edit of edits) {
553
+ parts.push(code.slice(last, edit.offset), edit.text);
554
+ last = edit.offset;
555
+ }
556
+ parts.push(code.slice(last));
557
+ return parts.join("");
558
+ }
552
559
  function transform(code, options) {
553
560
  const program = parseProgram(code);
554
561
  if (program === void 0) return void 0;
555
- const local = `__ccqa_${hash(options.fileId)}`;
556
- const literal = JSON.stringify(options.fileId);
557
- const enter = `${local}&&${local}(${literal});`;
562
+ const { enter, prologue } = probeTexts(options.fileId);
558
563
  const points = [];
559
- collect(program, options.maxDepth ?? DEFAULT_MAX_DEPTH, points);
564
+ collect(program, options.maxDepth ?? 2, points);
560
565
  if (code.length === 0) return void 0;
561
566
  const prologueAt = afterDirectives(code, program);
562
567
  const edits = points.filter((offset) => offset > prologueAt).map((offset) => ({
@@ -565,17 +570,10 @@ function transform(code, options) {
565
570
  }));
566
571
  edits.push({
567
572
  offset: prologueAt,
568
- text: `var ${local}=globalThis.__ccqaCoverage;${local}&&${local}(${literal},true);`
573
+ text: prologue
569
574
  });
570
575
  edits.sort((a, b) => a.offset - b.offset);
571
- const parts = [];
572
- let last = 0;
573
- for (const edit of edits) {
574
- parts.push(code.slice(last, edit.offset), edit.text);
575
- last = edit.offset;
576
- }
577
- parts.push(code.slice(last));
578
- return parts.join("");
576
+ return splice(code, edits);
579
577
  }
580
578
  function parseProgram(code) {
581
579
  for (const sourceType of ["module", "script"]) try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa-tools",
3
- "version": "1.40.3",
3
+ "version": "1.40.4",
4
4
  "type": "module",
5
5
  "description": "Instrumentation the application under test installs, for ccqa. Currently: per-spec reach coverage.",
6
6
  "repository": {