ccqa-tools 1.37.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.
Files changed (38) hide show
  1. package/README.md +177 -0
  2. package/dist/coverage/collector.cjs +229 -0
  3. package/dist/coverage/collector.d.cts +183 -0
  4. package/dist/coverage/collector.d.ts +183 -0
  5. package/dist/coverage/collector.js +225 -0
  6. package/dist/coverage/core.cjs +176 -0
  7. package/dist/coverage/core.d.cts +157 -0
  8. package/dist/coverage/core.d.ts +157 -0
  9. package/dist/coverage/core.js +163 -0
  10. package/dist/coverage/middleware.cjs +161 -0
  11. package/dist/coverage/middleware.d.cts +23 -0
  12. package/dist/coverage/middleware.d.ts +23 -0
  13. package/dist/coverage/middleware.js +158 -0
  14. package/dist/coverage/next-loader.cjs +158 -0
  15. package/dist/coverage/next-loader.d.cts +18 -0
  16. package/dist/coverage/next-loader.d.ts +19 -0
  17. package/dist/coverage/next-loader.js +158 -0
  18. package/dist/coverage/next.cjs +101 -0
  19. package/dist/coverage/next.d.cts +33 -0
  20. package/dist/coverage/next.d.ts +33 -0
  21. package/dist/coverage/next.js +100 -0
  22. package/dist/coverage/register.cjs +741 -0
  23. package/dist/coverage/register.d.cts +1 -0
  24. package/dist/coverage/register.d.ts +1 -0
  25. package/dist/coverage/register.js +716 -0
  26. package/dist/coverage/slack.cjs +228 -0
  27. package/dist/coverage/slack.d.cts +47 -0
  28. package/dist/coverage/slack.d.ts +47 -0
  29. package/dist/coverage/slack.js +225 -0
  30. package/dist/coverage/temporal-workflow.cjs +154 -0
  31. package/dist/coverage/temporal-workflow.d.cts +28 -0
  32. package/dist/coverage/temporal-workflow.d.ts +28 -0
  33. package/dist/coverage/temporal-workflow.js +153 -0
  34. package/dist/coverage/temporal.cjs +253 -0
  35. package/dist/coverage/temporal.d.cts +40 -0
  36. package/dist/coverage/temporal.d.ts +40 -0
  37. package/dist/coverage/temporal.js +250 -0
  38. package/package.json +95 -0
@@ -0,0 +1,716 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import http from "node:http";
3
+ import https from "node:https";
4
+ import * as nodeModule from "node:module";
5
+ import { fileURLToPath } from "node:url";
6
+ import { readFileSync } from "node:fs";
7
+ import { dirname, relative, resolve, sep } from "node:path";
8
+ import { parse } from "acorn";
9
+ //#region src/coverage/core.ts
10
+ const RUNTIME_KEY = Symbol.for("ccqa.coverage.runtime");
11
+ /** The name instrumented code calls through. Kept short; it appears per module. */
12
+ const GLOBAL_RECORD = "__ccqaCoverage";
13
+ function globals() {
14
+ return globalThis;
15
+ }
16
+ function getRuntime() {
17
+ return globals()[RUNTIME_KEY];
18
+ }
19
+ /**
20
+ * Memoized so the hot path skips the `globalThis` read. `installRuntime`
21
+ * primes it and never replaces an installed runtime, so once set it never
22
+ * goes stale — but it must stay undefined (and keep re-reading `globalThis`)
23
+ * until then, or a module that finished loading before `register` installs
24
+ * the runtime would be stuck uninstrumented for the rest of the process.
25
+ */
26
+ let cachedRuntime;
27
+ function runtime() {
28
+ if (cachedRuntime === void 0) cachedRuntime = globals()[RUNTIME_KEY];
29
+ return cachedRuntime;
30
+ }
31
+ /**
32
+ * Installs the process-wide runtime, or returns the one already there.
33
+ * Called by `ccqa-tools/coverage/register`; application code never calls it.
34
+ */
35
+ function installRuntime(als) {
36
+ const g = globals();
37
+ const existing = g[RUNTIME_KEY];
38
+ if (existing) {
39
+ cachedRuntime = existing;
40
+ return existing;
41
+ }
42
+ const created = {
43
+ protocol: 1,
44
+ als,
45
+ buckets: /* @__PURE__ */ new Map(),
46
+ actors: /* @__PURE__ */ new Map(),
47
+ boot: /* @__PURE__ */ new Set(),
48
+ active: 0,
49
+ unattributed: 0,
50
+ uninstrumentedFiles: 0,
51
+ uninstrumentedProcess: false,
52
+ pid: typeof process === "undefined" ? -1 : process.pid,
53
+ startedAt: Date.now()
54
+ };
55
+ g[RUNTIME_KEY] = created;
56
+ g[GLOBAL_RECORD] = record;
57
+ cachedRuntime = created;
58
+ return created;
59
+ }
60
+ /**
61
+ * Marks a file as reached. Instrumented code calls this through
62
+ * `globalThis.__ccqaCoverage`, so the call sites cost one global read per
63
+ * module and one truthiness check per invocation when coverage is off.
64
+ *
65
+ * @param topLevel set by the prologue a module runs on first import.
66
+ */
67
+ function record(fileId, topLevel) {
68
+ const rt = runtime();
69
+ if (rt === void 0) return;
70
+ if (topLevel === true) {
71
+ rt.boot.add(fileId);
72
+ return;
73
+ }
74
+ if (rt.active === 0) return;
75
+ const store = rt.als.getStore();
76
+ if (store === void 0) {
77
+ rt.unattributed++;
78
+ return;
79
+ }
80
+ store.files.add(fileId);
81
+ }
82
+ /** Returns `specId`'s file set, creating it — and arming the gate — if new. */
83
+ function openBucket(runtime, specId) {
84
+ let files = runtime.buckets.get(specId);
85
+ if (files === void 0) {
86
+ files = /* @__PURE__ */ new Set();
87
+ runtime.buckets.set(specId, files);
88
+ armGate(runtime);
89
+ }
90
+ return files;
91
+ }
92
+ /**
93
+ * The key both halves of the collector agree on. A space separates them safely:
94
+ * a spec id can never contain one, so identity keys and spec ids stay disjoint
95
+ * where the collector tracks both in one map.
96
+ */
97
+ function actorBucketKey(tag, at) {
98
+ return `${tag} ${at}`;
99
+ }
100
+ /** Drops a spec's bucket once it has been handed to the collector. */
101
+ function closeBucket(runtime, specId) {
102
+ if (runtime.buckets.delete(specId)) armGate(runtime);
103
+ }
104
+ /** Drops an identity's bucket once it has been handed to the collector. */
105
+ function closeActorBucket(runtime, key) {
106
+ if (runtime.actors.delete(key)) armGate(runtime);
107
+ }
108
+ function armGate(runtime) {
109
+ runtime.active = runtime.buckets.size + runtime.actors.size;
110
+ }
111
+ //#endregion
112
+ //#region src/coverage/wire.ts
113
+ /**
114
+ * The names a spec id travels under. Every carrier holds the same value,
115
+ * `<runId>.<specId>`, so a hop between them is a copy and never a translation.
116
+ *
117
+ * Like `core.ts` this file imports nothing: the Temporal workflow sandbox reads
118
+ * it too.
119
+ */
120
+ /** Set on the browser by ccqa at spec start, scoped to the target origin. */
121
+ const COOKIE_NAME = "__ccqa_coverage";
122
+ /** OTel baggage key, for the hop from the first service to downstream ones. */
123
+ const BAGGAGE_KEY = "ccqa.coverage";
124
+ /**
125
+ * Enables the instrumentation. Unset means the register hook is never loaded
126
+ * and the application pays nothing at all.
127
+ *
128
+ * `1` / `true` turns it on and leaves attribution to the incoming carrier.
129
+ * Any other value is itself a `<runId>.<specId>` and becomes the ambient spec
130
+ * for the process — the only way to attribute an entry point that has no
131
+ * inbound request to read, such as a worker started per spec.
132
+ */
133
+ const ENV_NAME = "CCQA_COVERAGE";
134
+ /** Where the collector pushes to. Unset means collect in memory only. */
135
+ const ENV_ENDPOINT = "CCQA_COVERAGE_ENDPOINT";
136
+ /** The current ccqa sink does not check this. Carried for a relay in front of it, or a future endpoint that does. */
137
+ const ENV_TOKEN = "CCQA_COVERAGE_TOKEN";
138
+ const SPEC_ID = /^[A-Za-z0-9._\-/]{1,200}$/;
139
+ /**
140
+ * Accepts a carrier value only if it looks like an id we wrote.
141
+ *
142
+ * The cookie is client-controlled, so this is the first of two gates: the
143
+ * second is the hub refusing runs it never started.
144
+ */
145
+ function parseSpecId(raw) {
146
+ if (!raw) return void 0;
147
+ const value = raw.trim();
148
+ if (!SPEC_ID.test(value)) return void 0;
149
+ if (value === "1" || value === "true") return void 0;
150
+ return value;
151
+ }
152
+ function readCookie(header) {
153
+ return readKeyed(header, COOKIE_NAME, ";");
154
+ }
155
+ /** Pulls our key out of a `baggage` header (W3C: `k=v;props,k2=v2`). */
156
+ function readBaggage(header) {
157
+ return readKeyed(header, BAGGAGE_KEY, ",", ";");
158
+ }
159
+ /**
160
+ * Both carriers are `key=value` lists; they differ only in what separates the
161
+ * entries, and baggage allowing properties after each value.
162
+ *
163
+ * One function because the decode-and-validate step is the part that matters,
164
+ * and two copies of it would be free to drift into accepting different things.
165
+ */
166
+ function readKeyed(header, key, between, propertiesAfter) {
167
+ if (!header) return void 0;
168
+ if (header.indexOf(key) < 0) return void 0;
169
+ for (const raw of header.split(between)) {
170
+ const entry = propertiesAfter === void 0 ? raw : raw.split(propertiesAfter)[0] ?? "";
171
+ const eq = entry.indexOf("=");
172
+ if (eq < 0) continue;
173
+ if (entry.slice(0, eq).trim() !== key) continue;
174
+ try {
175
+ return parseSpecId(decodeURIComponent(entry.slice(eq + 1).trim()));
176
+ } catch {
177
+ return;
178
+ }
179
+ }
180
+ }
181
+ //#endregion
182
+ //#region src/coverage/runtime-env.ts
183
+ function readConfig(env = process.env) {
184
+ const raw = env[ENV_NAME];
185
+ const include = (env["CCQA_COVERAGE_INCLUDE"] ?? "src").split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
186
+ return {
187
+ enabled: raw !== void 0 && raw !== "" && raw !== "0" && raw !== "false",
188
+ ambientSpecId: parseSpecId(raw),
189
+ root: env["CCQA_COVERAGE_ROOT"] ?? process.cwd(),
190
+ include,
191
+ debug: env["CCQA_COVERAGE_DEBUG"] === "1" || env["CCQA_COVERAGE_DEBUG"] === "true"
192
+ };
193
+ }
194
+ /**
195
+ * Diagnostics go to stderr and nowhere else. A `--import` preload is inherited
196
+ * by every child node process, and writing to stdout corrupts whatever the host
197
+ * was parsing there — enough to make a framework's own toolchain fail to start.
198
+ */
199
+ function debugLog(config, message) {
200
+ if (!config.debug) return;
201
+ process.stderr.write(`[ccqa-tools] ${message}\n`);
202
+ }
203
+ //#endregion
204
+ //#region src/coverage/collector.ts
205
+ /**
206
+ * Ships what this process reached to a sink, on a timer.
207
+ *
208
+ * Push, not pull: behind a load balancer nothing can address one replica of N,
209
+ * so an endpoint the runner scrapes would silently report a fraction of the
210
+ * truth. Every replica pushes instead, and the sink unions — file sets make
211
+ * that commutative, associative and idempotent, so the sink never has to know
212
+ * how many replicas there were.
213
+ */
214
+ const DEFAULT_INTERVAL_MS = 1e3;
215
+ const DEFAULT_IDLE_TTL_MS = 12e4;
216
+ /**
217
+ * How many times the exit-time flush may run. More than one because a push the
218
+ * timer started may still be in flight when the first one fires, and that one
219
+ * returns without sending anything.
220
+ */
221
+ const MAX_EXIT_FLUSHES = 3;
222
+ /**
223
+ * How often a process that instrumented nothing re-announces itself when it has
224
+ * nothing else to send.
225
+ *
226
+ * It has no files and no attributions to report, so the delta it pushes is
227
+ * empty forever after the first success — and the run that heard it is over.
228
+ * Without this, every later run against the same process is told nothing
229
+ * reported at all, rather than that one process is blind.
230
+ */
231
+ const BLIND_HEARTBEAT_MS = 3e4;
232
+ function createCollectorState() {
233
+ return {
234
+ sent: /* @__PURE__ */ new Map(),
235
+ sentBoot: /* @__PURE__ */ new Set(),
236
+ sentActors: /* @__PURE__ */ new Map(),
237
+ lastChange: /* @__PURE__ */ new Map(),
238
+ lastSentUnattributed: 0,
239
+ lastSentUninstrumentedFiles: 0,
240
+ lastSentUninstrumentedProcess: false,
241
+ lastSentAt: 0,
242
+ droppedPushes: 0
243
+ };
244
+ }
245
+ function startCollector(options, config) {
246
+ const runtime = getRuntime();
247
+ if (runtime === void 0) return () => {};
248
+ const idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
249
+ const state = createCollectorState();
250
+ let inFlight = false;
251
+ let consecutiveFailures = 0;
252
+ const flush = () => {
253
+ if (inFlight) return Promise.resolve();
254
+ evict(runtime, state, idleTtlMs);
255
+ const payload = diff(runtime, state);
256
+ if (payload === void 0) return Promise.resolve();
257
+ inFlight = true;
258
+ return post(options, payload).then(() => {
259
+ for (const [specId, files] of Object.entries(payload.specs)) {
260
+ const acked = state.sent.get(specId) ?? /* @__PURE__ */ new Set();
261
+ for (const file of files) acked.add(file);
262
+ state.sent.set(specId, acked);
263
+ }
264
+ for (const file of payload.boot) state.sentBoot.add(file);
265
+ for (const bucket of payload.actors) {
266
+ const key = actorBucketKey(bucket.tag, bucket.at);
267
+ const acked = state.sentActors.get(key) ?? /* @__PURE__ */ new Set();
268
+ for (const file of bucket.files) acked.add(file);
269
+ state.sentActors.set(key, acked);
270
+ }
271
+ state.lastSentUnattributed = payload.unattributed;
272
+ state.lastSentUninstrumentedFiles = payload.uninstrumentedFiles;
273
+ state.lastSentUninstrumentedProcess = payload.uninstrumentedProcess;
274
+ consecutiveFailures = 0;
275
+ }).catch((error) => {
276
+ state.droppedPushes++;
277
+ consecutiveFailures++;
278
+ if (config) debugLog(config, `push failed: ${String(error)}`);
279
+ if (consecutiveFailures === 1 || consecutiveFailures % 10 === 0) process.stderr.write(`[ccqa-tools] push to ${options.endpoint} failed ${consecutiveFailures} times in a row: ${String(error)}\n`);
280
+ }).finally(() => {
281
+ inFlight = false;
282
+ });
283
+ };
284
+ const timer = setInterval(() => void flush(), options.intervalMs ?? DEFAULT_INTERVAL_MS);
285
+ timer.unref?.();
286
+ let exitFlushes = 0;
287
+ const onBeforeExit = async () => {
288
+ if (consecutiveFailures > 0 || exitFlushes >= MAX_EXIT_FLUSHES) return;
289
+ exitFlushes++;
290
+ await flush();
291
+ };
292
+ process.on("beforeExit", onBeforeExit);
293
+ return () => {
294
+ clearInterval(timer);
295
+ process.off("beforeExit", onBeforeExit);
296
+ };
297
+ }
298
+ /**
299
+ * `acked` is always a subset of `reached`, and neither ever shrinks, so equal
300
+ * sizes mean nothing new — skip the copy-then-filter and check that first.
301
+ */
302
+ function freshOf(reached, acked) {
303
+ if (acked !== void 0 && acked.size === reached.size) return [];
304
+ const fresh = [];
305
+ for (const file of reached) if (acked === void 0 || !acked.has(file)) fresh.push(file);
306
+ return fresh;
307
+ }
308
+ /** Exported for tests: the sink's HTTP round trip is the only real-I/O part. */
309
+ function diff(runtime, state) {
310
+ const specs = {};
311
+ let any = false;
312
+ const now = Date.now();
313
+ for (const [specId, files] of runtime.buckets) {
314
+ const fresh = freshOf(files, state.sent.get(specId));
315
+ if (fresh.length === 0) continue;
316
+ specs[specId] = fresh;
317
+ state.lastChange.set(specId, now);
318
+ any = true;
319
+ }
320
+ const actors = [];
321
+ for (const [key, bucket] of runtime.actors) {
322
+ const fresh = freshOf(bucket.files, state.sentActors.get(key));
323
+ if (fresh.length === 0) continue;
324
+ actors.push({
325
+ tag: bucket.tag,
326
+ at: bucket.at,
327
+ files: fresh
328
+ });
329
+ state.lastChange.set(key, now);
330
+ }
331
+ const boot = freshOf(runtime.boot, state.sentBoot);
332
+ const unattributedChanged = runtime.unattributed !== state.lastSentUnattributed;
333
+ const healthChanged = runtime.uninstrumentedFiles !== state.lastSentUninstrumentedFiles || runtime.uninstrumentedProcess !== state.lastSentUninstrumentedProcess;
334
+ const reannounce = runtime.uninstrumentedProcess && now - state.lastSentAt >= BLIND_HEARTBEAT_MS;
335
+ if (!any && boot.length === 0 && actors.length === 0 && !unattributedChanged && !healthChanged && !reannounce) return;
336
+ state.lastSentAt = now;
337
+ return {
338
+ protocol: 1,
339
+ pid: runtime.pid,
340
+ startedAt: runtime.startedAt,
341
+ unattributed: runtime.unattributed,
342
+ uninstrumentedFiles: runtime.uninstrumentedFiles,
343
+ uninstrumentedProcess: runtime.uninstrumentedProcess,
344
+ specs,
345
+ boot,
346
+ actors,
347
+ droppedPushes: state.droppedPushes
348
+ };
349
+ }
350
+ /**
351
+ * Forgets specs that stopped changing and have nothing outstanding, so a long
352
+ * lived server does not accumulate every spec it has ever served — and so the
353
+ * hot-path gate falls back to zero between runs.
354
+ */
355
+ function evict(runtime, state, idleTtlMs) {
356
+ const now = Date.now();
357
+ const cutoff = now - idleTtlMs;
358
+ const actors = [...runtime.actors].map(([key, bucket]) => [key, bucket.files]);
359
+ dropQuiet([...runtime.buckets], state.sent, state, cutoff, now, (key) => closeBucket(runtime, key));
360
+ dropQuiet(actors, state.sentActors, state, cutoff, now, (key) => closeActorBucket(runtime, key));
361
+ }
362
+ function dropQuiet(entries, sent, state, cutoff, now, close) {
363
+ for (const [key, files] of entries) {
364
+ const seen = state.lastChange.get(key);
365
+ if (seen === void 0) {
366
+ state.lastChange.set(key, now);
367
+ continue;
368
+ }
369
+ if (seen > cutoff) continue;
370
+ if (freshOf(files, sent.get(key)).length > 0) continue;
371
+ close(key);
372
+ sent.delete(key);
373
+ state.lastChange.delete(key);
374
+ }
375
+ }
376
+ async function post(options, payload) {
377
+ const headers = { "content-type": "application/json" };
378
+ if (options.token) headers.authorization = `Bearer ${options.token}`;
379
+ const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
380
+ const response = await fetch(options.endpoint, {
381
+ method: "POST",
382
+ headers,
383
+ body: JSON.stringify(payload),
384
+ signal: AbortSignal.timeout(intervalMs * 5)
385
+ });
386
+ if (!response.ok) throw new Error(`sink returned ${response.status}`);
387
+ }
388
+ //#endregion
389
+ //#region src/coverage/instrument/select.ts
390
+ const SOURCE_EXTENSIONS = [
391
+ ".js",
392
+ ".mjs",
393
+ ".cjs",
394
+ ".jsx",
395
+ ".ts",
396
+ ".mts",
397
+ ".cts",
398
+ ".tsx"
399
+ ];
400
+ /**
401
+ * Decides whether a file is part of the project under test, returning its id
402
+ * if so. Returning the id here — rather than a plain boolean — spares the
403
+ * caller a second `fileIdFor` pass over the same path.
404
+ *
405
+ * `node_modules` is excluded unconditionally: instrumenting dependencies costs
406
+ * the most and answers the least — nobody adds a test because a library file
407
+ * went unreached.
408
+ */
409
+ function shouldInstrument(filename, config) {
410
+ if (filename.includes(`${sep}node_modules${sep}`)) return void 0;
411
+ if (!SOURCE_EXTENSIONS.some((extension) => filename.endsWith(extension))) return void 0;
412
+ const id = fileIdFor(filename, config.root);
413
+ if (id === void 0) return void 0;
414
+ return config.include.some((prefix) => id === prefix || id.startsWith(`${prefix}/`)) ? id : void 0;
415
+ }
416
+ /** Path relative to the project root, in posix form so ids match across hosts. */
417
+ function fileIdFor(filename, root) {
418
+ const rel = relative(resolve(root), filename);
419
+ if (rel.startsWith("..") || rel === "") return void 0;
420
+ return rel.split(sep).join("/");
421
+ }
422
+ //#endregion
423
+ //#region src/coverage/instrument/origin.ts
424
+ /**
425
+ * Reports a build output under the source it was compiled from.
426
+ *
427
+ * A server that runs from `dist/` would otherwise be measured in terms of its
428
+ * own build artefacts, which nobody can act on: the answer has to name files
429
+ * that exist in the repository. A 1:1 build — one output file per source file,
430
+ * which is what an unbundled compile produces — says exactly which source that
431
+ * is in its map's single `sources` entry.
432
+ *
433
+ * Only that single-source case is handled. A bundle's map lists every file
434
+ * that went into it and cannot say which one the code at hand came from
435
+ * without full mapping resolution, which is the build plugin's job, not the
436
+ * loader's.
437
+ */
438
+ function originalFileId(filename, code, root) {
439
+ const reference = readSourceMappingUrl(code);
440
+ if (reference === void 0) return void 0;
441
+ const map = loadMap(filename, reference);
442
+ if (map === void 0) return void 0;
443
+ const sources = map.sources;
444
+ if (!Array.isArray(sources) || sources.length !== 1) return void 0;
445
+ const source = sources[0];
446
+ if (typeof source !== "string" || source === "") return void 0;
447
+ return fileIdFor(resolve(dirname(filename), map.sourceRoot ?? "", source), root);
448
+ }
449
+ function loadMap(filename, reference) {
450
+ const json = reference.startsWith("data:") ? decodeDataUrl(reference) : readSibling(filename, reference);
451
+ if (json === void 0) return void 0;
452
+ try {
453
+ return JSON.parse(json);
454
+ } catch {
455
+ return;
456
+ }
457
+ }
458
+ function readSibling(filename, reference) {
459
+ if (/^[a-z]+:/i.test(reference)) return void 0;
460
+ try {
461
+ return readFileSync(resolve(dirname(filename), reference), "utf8");
462
+ } catch {
463
+ return;
464
+ }
465
+ }
466
+ function decodeDataUrl(url) {
467
+ const comma = url.indexOf(",");
468
+ if (comma < 0) return void 0;
469
+ const payload = url.slice(comma + 1);
470
+ try {
471
+ return url.slice(0, comma).includes(";base64") ? Buffer.from(payload, "base64").toString("utf8") : decodeURIComponent(payload);
472
+ } catch {
473
+ return;
474
+ }
475
+ }
476
+ const SOURCE_MAPPING_URL = /\/\/[#@][ \t]*sourceMappingURL=(\S+)/g;
477
+ function readSourceMappingUrl(code) {
478
+ let found;
479
+ for (const match of code.matchAll(SOURCE_MAPPING_URL)) found = match[1];
480
+ return found;
481
+ }
482
+ //#endregion
483
+ //#region src/coverage/instrument/transform.ts
484
+ /**
485
+ * Rewrites a module so that entering it, or entering one of its functions,
486
+ * calls `globalThis.__ccqaCoverage`.
487
+ *
488
+ * Two properties drive the whole shape:
489
+ *
490
+ * - **Insertions only, never a newline.** Line numbers survive untouched, so
491
+ * the source map the application already ships keeps pointing at the right
492
+ * lines and stack traces stay readable. A codegen round-trip would have
493
+ * forced us to produce and merge maps of our own.
494
+ * - **File granularity.** The record is "this file ran", so there is no need to
495
+ * track statements or branches, and the whole class of line/branch
496
+ * normalisation bugs that follow V8-to-istanbul conversion never appears.
497
+ */
498
+ const DEFAULT_MAX_DEPTH = 2;
499
+ function transform(code, options) {
500
+ const program = parseProgram(code);
501
+ if (program === void 0) return void 0;
502
+ const local = `__ccqa_${hash(options.fileId)}`;
503
+ const literal = JSON.stringify(options.fileId);
504
+ const enter = `${local}&&${local}(${literal});`;
505
+ const points = [];
506
+ collect(program, options.maxDepth ?? DEFAULT_MAX_DEPTH, points);
507
+ if (code.length === 0) return void 0;
508
+ const prologueAt = afterDirectives(code, program);
509
+ const edits = points.filter((offset) => offset > prologueAt).map((offset) => ({
510
+ offset,
511
+ text: enter
512
+ }));
513
+ edits.push({
514
+ offset: prologueAt,
515
+ text: `var ${local}=globalThis.__ccqaCoverage;${local}&&${local}(${literal},true);`
516
+ });
517
+ edits.sort((a, b) => a.offset - b.offset);
518
+ const parts = [];
519
+ let last = 0;
520
+ for (const edit of edits) {
521
+ parts.push(code.slice(last, edit.offset), edit.text);
522
+ last = edit.offset;
523
+ }
524
+ parts.push(code.slice(last));
525
+ return parts.join("");
526
+ }
527
+ function parseProgram(code) {
528
+ for (const sourceType of ["module", "script"]) try {
529
+ return parse(code, {
530
+ ecmaVersion: "latest",
531
+ sourceType,
532
+ allowHashBang: true,
533
+ allowAwaitOutsideFunction: true,
534
+ allowReturnOutsideFunction: sourceType === "script"
535
+ });
536
+ } catch {}
537
+ }
538
+ /**
539
+ * A directive prologue only counts while it is still the first thing in its
540
+ * scope. Inserting ahead of `"use strict"` demotes it to an ordinary string
541
+ * expression, and the code it governed silently starts running sloppy — the
542
+ * instrumentation would be changing the behaviour it is supposed to observe.
543
+ * Applies to a function body as much as to the module.
544
+ */
545
+ function afterDirectives(code, program) {
546
+ let offset = program.start;
547
+ if (code.startsWith("#!")) {
548
+ const newline = code.indexOf("\n");
549
+ offset = newline < 0 ? code.length : newline + 1;
550
+ }
551
+ return skipDirectives(program.body, offset);
552
+ }
553
+ function skipDirectives(statements, from) {
554
+ let offset = from;
555
+ for (const statement of statements) {
556
+ if (statement.type !== "ExpressionStatement") break;
557
+ const expression = statement.expression;
558
+ if (expression.type !== "Literal" || typeof expression.value !== "string") break;
559
+ offset = statement.end;
560
+ }
561
+ return offset;
562
+ }
563
+ const FUNCTION_TYPES = new Set([
564
+ "FunctionDeclaration",
565
+ "FunctionExpression",
566
+ "ArrowFunctionExpression"
567
+ ]);
568
+ function collect(root, maxDepth, points) {
569
+ walk(root, 0, false);
570
+ function walk(node, depth, inClass) {
571
+ const isFunction = FUNCTION_TYPES.has(node.type);
572
+ const nextDepth = isFunction ? depth + 1 : depth;
573
+ if (isFunction) {
574
+ const wanted = inClass || nextDepth <= maxDepth;
575
+ const body = node.body;
576
+ if (wanted && body && body.type === "BlockStatement") {
577
+ const statements = body.body;
578
+ points.push(skipDirectives(statements, body.start + 1));
579
+ }
580
+ }
581
+ for (const key in node) {
582
+ if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
583
+ const value = node[key];
584
+ if (Array.isArray(value)) {
585
+ for (const item of value) if (isNode(item)) walk(item, nextDepth, childInClass(node, key, inClass));
586
+ } else if (isNode(value)) walk(value, nextDepth, childInClass(node, key, inClass));
587
+ }
588
+ }
589
+ }
590
+ /** True while walking the value of a class member, false again inside its body. */
591
+ function childInClass(parent, key, inherited) {
592
+ if (parent.type === "MethodDefinition" || parent.type === "PropertyDefinition") return key === "value";
593
+ if (FUNCTION_TYPES.has(parent.type)) return false;
594
+ return inherited;
595
+ }
596
+ function isNode(value) {
597
+ return typeof value === "object" && value !== null && typeof value.type === "string";
598
+ }
599
+ /** Short, collision-resistant suffix so bundlers can hoist several modules into one scope. */
600
+ function hash(value) {
601
+ let h = 2166136261;
602
+ for (let i = 0; i < value.length; i++) {
603
+ h ^= value.charCodeAt(i);
604
+ h = Math.imul(h, 16777619);
605
+ }
606
+ return (h >>> 0).toString(36);
607
+ }
608
+ //#endregion
609
+ //#region src/coverage/instrument/hooks.ts
610
+ /**
611
+ * Instruments modules as Node loads them, so a deployment is instrumented by
612
+ * adding one flag to its command line and rebuilding nothing. What runs is the
613
+ * same artifact production runs, which is most of the reason this path is
614
+ * preferred over a build plugin.
615
+ *
616
+ * Code a bundler already swallowed is out of reach here — the loader only ever
617
+ * sees the bundle. Those runtimes use `ccqa-tools/coverage/next` instead.
618
+ */
619
+ const textDecoder = new TextDecoder();
620
+ function installLoadHooks(config, runtime) {
621
+ const registerHooks = nodeModule.registerHooks;
622
+ if (typeof registerHooks !== "function") {
623
+ runtime.uninstrumentedProcess = true;
624
+ process.stderr.write(`[ccqa-tools] load hooks unavailable on node ${process.version}; no file in this process will be instrumented\n`);
625
+ return;
626
+ }
627
+ registerHooks({ load(url, context, nextLoad) {
628
+ const result = nextLoad(url, context);
629
+ if (!url.startsWith("file:")) return result;
630
+ let filename;
631
+ try {
632
+ filename = fileURLToPath(url);
633
+ } catch {
634
+ return result;
635
+ }
636
+ const selectedId = shouldInstrument(filename, config);
637
+ if (selectedId === void 0) return result;
638
+ const source = toText(result.source);
639
+ if (source === void 0) {
640
+ runtime.uninstrumentedFiles++;
641
+ debugLog(config, `could not decode source for ${filename}; left as-is`);
642
+ return result;
643
+ }
644
+ const fileId = originalFileId(filename, source, config.root) ?? selectedId;
645
+ const instrumented = transform(source, { fileId });
646
+ if (instrumented === void 0) {
647
+ runtime.uninstrumentedFiles++;
648
+ debugLog(config, `could not parse ${fileId}; left as-is`);
649
+ return result;
650
+ }
651
+ return {
652
+ ...result,
653
+ source: instrumented
654
+ };
655
+ } });
656
+ debugLog(config, `load hooks installed for ${config.include.join(", ")} under ${config.root}`);
657
+ }
658
+ function toText(source) {
659
+ if (typeof source === "string") return source;
660
+ if (source instanceof ArrayBuffer) return textDecoder.decode(source);
661
+ if (ArrayBuffer.isView(source)) return textDecoder.decode(new Uint8Array(source.buffer, source.byteOffset, source.byteLength));
662
+ }
663
+ //#endregion
664
+ //#region src/coverage/register.ts
665
+ /**
666
+ * The Node-only half of the package, loaded with
667
+ * `node --import ccqa-tools/coverage/register` (or via `NODE_OPTIONS`).
668
+ *
669
+ * Everything here would fail inside a bundler — `node:http`, `node:module`,
670
+ * load-time source rewriting — which is exactly why it is not in `core.ts`.
671
+ * When `CCQA_COVERAGE` is unset the process never loads this file at all.
672
+ */
673
+ const config = readConfig();
674
+ if (config.enabled) {
675
+ const runtime = installRuntime(new AsyncLocalStorage());
676
+ patchServers(runtime);
677
+ installLoadHooks(config, runtime);
678
+ if (config.ambientSpecId !== void 0) {
679
+ const files = openBucket(runtime, config.ambientSpecId);
680
+ runtime.als.enterWith({
681
+ specId: config.ambientSpecId,
682
+ files
683
+ });
684
+ }
685
+ const endpoint = process.env[ENV_ENDPOINT];
686
+ if (endpoint) startCollector({
687
+ endpoint,
688
+ token: process.env[ENV_TOKEN]
689
+ }, config);
690
+ else process.stderr.write(`[ccqa-tools] collection is enabled but ${ENV_ENDPOINT} is not set; results will be discarded\n`);
691
+ debugLog(config, `armed in pid ${process.pid}`);
692
+ }
693
+ /**
694
+ * Wraps inbound requests in the spec's context.
695
+ *
696
+ * Patching `emit` rather than the handler covers servers created before this
697
+ * ran and servers created by frameworks that never expose their handler.
698
+ * `https.Server` does not inherit from `http.Server`, so both are patched.
699
+ */
700
+ function patchServers(runtime) {
701
+ for (const server of [http.Server, https.Server]) {
702
+ const prototype = server.prototype;
703
+ const original = prototype.emit;
704
+ prototype.emit = function patched() {
705
+ if (arguments[0] !== "request") return original.apply(this, arguments);
706
+ const request = arguments[1];
707
+ const specId = readCookie(request.headers.cookie) ?? readBaggage(request.headers.baggage);
708
+ if (specId === void 0) return original.apply(this, arguments);
709
+ return runtime.als.run({
710
+ specId,
711
+ files: openBucket(runtime, specId)
712
+ }, () => original.apply(this, arguments));
713
+ };
714
+ }
715
+ }
716
+ //#endregion