@specific.dev/spectest 0.119.0 → 0.119.2

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.
@@ -127,6 +127,10 @@ export declare const INVENTORY_REPORT = "inventory.units";
127
127
  export type ServiceCoverage = true | {
128
128
  adapters?: readonly CoverageAdapter[];
129
129
  reports?: CoverageReportsMode;
130
+ /** Repository-relative root of this service's build/bundler workspace.
131
+ * For a monorepo built from packages/, set sourceRoot: "packages".
132
+ * Paths are checked against the repository; no suffix guessing. */
133
+ sourceRoot?: string;
130
134
  };
131
135
  /** The `reports` mode of a `coverage` value, for the reports no adapter
132
136
  * wrote (the program's own, a `command`'s). */
@@ -259,7 +263,13 @@ export declare function npmManifestUnit(projectPath: string, text: string): stri
259
263
  * Prints one `npm:<name>@<version>` per line.
260
264
  */
261
265
  export declare const NODE_INVENTORY_SCRIPT = "\"use strict\";\nconst fs = require(\"node:fs\"), path = require(\"node:path\");\nconst out = new Set();\nfunction isRealDir(p) { try { return fs.lstatSync(p).isDirectory(); } catch (e) { return false; } }\nfunction readPkg(d) {\n try {\n const j = JSON.parse(fs.readFileSync(path.join(d, \"package.json\"), \"utf8\"));\n if (j && typeof j.name === \"string\" && j.name) out.add(\"npm:\" + j.name + (typeof j.version === \"string\" && j.version ? \"@\" + j.version : \"\"));\n } catch (e) {}\n}\nfunction visit(d, depth) {\n readPkg(d);\n if (depth < 3) { const nested = path.join(d, \"node_modules\"); if (isRealDir(nested)) walkNm(nested, depth + 1); }\n}\nfunction walkNm(nm, depth) {\n let names; try { names = fs.readdirSync(nm); } catch (e) { return; }\n for (const n of names) {\n if (n === \".bin\" || n === \".cache\" || n === \".modules.yaml\") continue;\n if (n === \".pnpm\") {\n let ps; try { ps = fs.readdirSync(path.join(nm, \".pnpm\")); } catch (e) { continue; }\n for (const p of ps) { const inner = path.join(nm, \".pnpm\", p, \"node_modules\"); if (isRealDir(inner)) walkNm(inner, depth + 1); }\n continue;\n }\n if (n.startsWith(\"@\")) {\n let ss; try { ss = fs.readdirSync(path.join(nm, n)); } catch (e) { continue; }\n for (const s of ss) { const d = path.join(nm, n, s); if (isRealDir(d)) visit(d, depth); }\n } else { const d = path.join(nm, n); if (isRealDir(d)) visit(d, depth); }\n }\n}\nlet d = process.cwd();\nfor (;;) {\n const nm = path.join(d, \"node_modules\");\n if (isRealDir(nm)) walkNm(nm, 0);\n const up = path.dirname(d);\n if (up === d) break;\n d = up;\n}\nprocess.stdout.write(Array.from(out).sort().join(\"\\n\") + (out.size ? \"\\n\" : \"\"));\n";
262
- export declare function node(): CoverageAdapter;
266
+ export interface NodeCoverageOptions {
267
+ /** Generated runtime entrypoints, relative to the service working directory.
268
+ * Track their exact contents as units rather than inventing repository lines.
269
+ * A content change changes the inventory and invalidates dependent tests. */
270
+ generated?: readonly string[];
271
+ }
272
+ export declare function node(options?: NodeCoverageOptions): CoverageAdapter;
263
273
  /**
264
274
  * Ask every node process that holds a hook socket in `dir` for a dump.
265
275
  * A socket nobody answers (its process died without unlinking — SIGKILL,
package/dist/coverage.js CHANGED
@@ -57,14 +57,19 @@ export function validateCoverage(service, cov) {
57
57
  const obj = typeof cov === "object" && cov !== null ? cov : null;
58
58
  const adapters = obj?.adapters;
59
59
  const reports = obj?.reports;
60
+ const sourceRoot = obj?.sourceRoot;
60
61
  // `explains` is what `applyCoverageAdapters` adds for the wire; the
61
62
  // config is validated again after that, so it must pass here.
62
63
  const explains = obj?.explains;
63
- const knownKeys = obj !== null && Object.keys(obj).every((k) => k === "adapters" || k === "reports" || k === "explains");
64
+ const knownKeys = obj !== null && Object.keys(obj).every((k) => k === "adapters" || k === "reports" || k === "explains" || k === "sourceRoot");
65
+ const okRoot = sourceRoot === undefined || (typeof sourceRoot === "string" &&
66
+ (sourceRoot === "." || (sourceRoot.length > 0 && !sourceRoot.includes("\\") &&
67
+ !sourceRoot.includes(":") && !sourceRoot.includes("\0") &&
68
+ sourceRoot.split("/").every(p => p !== "" && p !== "." && p !== ".."))));
64
69
  const okReports = reports === undefined || reports === "delta" || reports === "cumulative";
65
70
  const okAdapters = adapters === undefined || Array.isArray(adapters);
66
71
  const okExplains = explains === undefined || (Array.isArray(explains) && explains.every((e) => typeof e === "string"));
67
- if (obj !== null && knownKeys && okReports && okAdapters && okExplains && (adapters !== undefined || reports !== undefined)) {
72
+ if (obj !== null && knownKeys && okRoot && okReports && okAdapters && okExplains && (adapters !== undefined || reports !== undefined)) {
68
73
  for (const a of adapters ?? []) {
69
74
  const ok = typeof a === "object" &&
70
75
  a !== null &&
@@ -111,7 +116,9 @@ export function applyCoverageAdapters(key, svc) {
111
116
  // `explains` rides the wire config so the control plane's selector can
112
117
  // read it without knowing any adapter.
113
118
  const explains = [...new Set(adapters.flatMap((a) => a.explains ?? []))];
119
+ const sourceRoot = typeof svc.coverage === "object" ? svc.coverage.sourceRoot : undefined;
114
120
  const wire = {
121
+ ...(sourceRoot !== undefined ? { sourceRoot } : {}),
115
122
  adapters: adapters.map((a) => withWireName(a)),
116
123
  ...(mode ? { reports: mode } : {}),
117
124
  ...(explains.length > 0 ? { explains } : {}),
@@ -241,6 +248,21 @@ export const NODE_COVERAGE_HOOK = `"use strict";
241
248
  if (post("Profiler.enable") === null) return;
242
249
  post("Profiler.startPreciseCoverage", { callCount: true, detailed: true });
243
250
 
251
+ // Attach before app scripts execute. Enabling only at capture loses the
252
+ // source of collected vm contexts (e.g. Temporal workflow bundles).
253
+ // Bound V8's collected-script cache; live sources remain owned by V8.
254
+ post("Debugger.enable", { maxScriptsCacheSize: 16 * 1024 * 1024 });
255
+
256
+ const GENERATED = new Map();
257
+ try {
258
+ for (const file of JSON.parse(process.env.SPECTEST_COVERAGE_GENERATED_FILES || "[]")) {
259
+ GENERATED.set(url.pathToFileURL(path.resolve(file)).href, file);
260
+ }
261
+ } catch (e) {}
262
+ const generatedUnits = new Map();
263
+ function generatedUnit(file, text) {
264
+ return "generated:" + process.env.SPECTEST_COVERAGE_SERVICE + ":" + file + "#" + crypto.createHash("sha256").update(text).digest("hex");
265
+ }
244
266
  const SELF = url.pathToFileURL(__filename).href;
245
267
  const SCRIPTS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SCRIPTS_SUBDIR)});
246
268
  const MARKERS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_MARKERS_SUBDIR)});
@@ -347,31 +369,47 @@ export const NODE_COVERAGE_HOOK = `"use strict";
347
369
  } catch (e) {
348
370
  return null;
349
371
  }
350
- if (!data || typeof data !== "object" || !("mappings" in data)) return null;
351
- delete data.sourcesContent;
352
372
  // A source inside node_modules is a dependency: its unit id rides
353
373
  // beside the map (\`sourceUnits[i]\`, null for the project's own), so
354
374
  // the control plane never has to know what node_modules is.
355
375
  const mapDir = mapUrl ? path.dirname(url.fileURLToPath(mapUrl)) : path.dirname(scriptPath);
356
- const root = typeof data.sourceRoot === "string" && data.sourceRoot ? data.sourceRoot : "";
357
- const sourceUnits = (Array.isArray(data.sources) ? data.sources : []).map(function (src) {
358
- if (typeof src !== "string") return null;
359
- let abs;
360
- if (src.startsWith("file://")) {
361
- try {
362
- abs = url.fileURLToPath(src);
363
- } catch (e) {
364
- return null;
376
+ function sourceUnits(data) {
377
+ const root = typeof data.sourceRoot === "string" ? data.sourceRoot : "";
378
+ return data.sources.map(function (src) {
379
+ if (typeof src !== "string") return null;
380
+ let abs;
381
+ if (src.startsWith("file://")) {
382
+ try {
383
+ abs = url.fileURLToPath(src);
384
+ } catch (e) {
385
+ return null;
386
+ }
387
+ } else if (/^[a-z][a-z0-9+.-]*:/i.test(src)) {
388
+ return src.indexOf("/node_modules/") >= 0 ? unitFromText(src) : null;
389
+ } else {
390
+ const rel = root && !src.startsWith("/") ? root.replace(/\\/$/, "") + "/" + src : src;
391
+ abs = path.resolve(mapDir, rel);
365
392
  }
366
- } else if (/^[a-z][a-z0-9+.-]*:/i.test(src)) {
367
- return src.indexOf("/node_modules/") >= 0 ? unitFromText(src) : null;
368
- } else {
369
- const rel = root && !src.startsWith("/") ? root.replace(/\\/$/, "") + "/" + src : src;
370
- abs = path.resolve(mapDir, rel);
393
+ return abs.indexOf("/node_modules/") >= 0 ? unitOf(abs) : null;
394
+ });
395
+ }
396
+ function prepareMap(data, depth) {
397
+ if (!data || typeof data !== "object" || depth > 32) return false;
398
+ delete data.sourcesContent;
399
+ if (Array.isArray(data.sections)) {
400
+ if ("mappings" in data) return false;
401
+ return data.sections.every(function (section) {
402
+ return section && !section.url && prepareMap(section.map, depth + 1);
403
+ });
371
404
  }
372
- return abs.indexOf("/node_modules/") >= 0 ? unitOf(abs) : null;
373
- });
374
- return { url: mapUrl, data: data, sourceUnits: sourceUnits };
405
+ if (!Array.isArray(data.sources) || typeof data.mappings !== "string") return false;
406
+ data.x_spectestSourceUnits = sourceUnits(data);
407
+ return true;
408
+ }
409
+ if (!prepareMap(data, 0)) return null;
410
+ const units = data.x_spectestSourceUnits;
411
+ delete data.x_spectestSourceUnits;
412
+ return { url: mapUrl, data: data, sourceUnits: units };
375
413
  }
376
414
  function writeAtomic(file, text) {
377
415
  const tmp = file + "." + process.pid + "-" + wt.threadId + ".tmp";
@@ -420,34 +458,44 @@ export const NODE_COVERAGE_HOOK = `"use strict";
420
458
  if (kept.length === 0 && units.size === 0) return;
421
459
  const fresh = [];
422
460
  for (const s of kept) {
423
- if (!known.has(s.scriptId)) fresh.push(s);
461
+ if (!known.has(s.scriptId) && !generatedUnits.has(s.scriptId)) fresh.push(s);
424
462
  }
425
463
  if (fresh.length > 0) {
426
464
  try {
427
465
  fs.mkdirSync(SCRIPTS, { recursive: true });
428
466
  fs.mkdirSync(MARKERS, { recursive: true });
429
467
  } catch (e) {}
430
- post("Debugger.enable");
431
468
  for (const s of fresh) {
432
469
  const r = post("Debugger.getScriptSource", { scriptId: s.scriptId });
433
470
  try {
434
- known.set(s.scriptId, describe(s, r && r.scriptSource));
471
+ const source = r && r.scriptSource;
472
+ if (GENERATED.has(s.url) && typeof source === "string") {
473
+ generatedUnits.set(s.scriptId, generatedUnit(GENERATED.get(s.url), source));
474
+ continue;
475
+ }
476
+ const id = describe(s, source);
477
+ // A failed source fetch remains visible in this dump, but is not
478
+ // memoized as a successful descriptor for subsequent captures.
479
+ if (typeof source === "string") known.set(s.scriptId, id);
480
+ s.missingSourceRecord = id;
435
481
  } catch (e) {}
436
482
  }
437
- post("Debugger.disable");
438
483
  }
439
484
  // A ran function is one flat array: [start, length, count] for its
440
485
  // extent (V8's first range), then [offset from start, length, count]
441
486
  // per block range in V8's order. Small numbers, so the dump gzips to
442
487
  // less than half of V8's own JSON; name and block flag live in the
443
488
  // script record, joined on the extent.
489
+ for (const s of kept) {
490
+ if (generatedUnits.has(s.scriptId)) units.add(generatedUnits.get(s.scriptId));
491
+ }
444
492
  const doc = {
445
493
  spectestVersion: 2,
446
494
  units: Array.from(units).sort(),
447
- result: kept.map(function (s) {
495
+ result: kept.filter(function (s) { return !generatedUnits.has(s.scriptId); }).map(function (s) {
448
496
  return {
449
497
  url: s.url,
450
- spectestScript: known.get(s.scriptId) || null,
498
+ spectestScript: known.get(s.scriptId) || s.missingSourceRecord || null,
451
499
  functions: s.functions.filter(ran).map(function (f) {
452
500
  const first = f.ranges[0];
453
501
  const out = [first.startOffset, first.endOffset - first.startOffset, first.count];
@@ -625,7 +673,15 @@ for (;;) {
625
673
  }
626
674
  process.stdout.write(Array.from(out).sort().join("\\n") + (out.size ? "\\n" : ""));
627
675
  `;
628
- export function node() {
676
+ export function node(options = {}) {
677
+ if (options.generated !== undefined && (!Array.isArray(options.generated) || options.generated.some(p => typeof p !== "string"))) {
678
+ throw new Error("coverage.node generated must be a list of relative files");
679
+ }
680
+ const generated = [...new Set(options.generated ?? [])].sort();
681
+ if (generated.some(p => !p || p.includes("\\") || p.includes(":") || p.includes("\0") ||
682
+ p.split("/").some(c => !c || c === "." || c === ".."))) {
683
+ throw new Error("coverage.node generated paths must be relative files without traversal");
684
+ }
629
685
  return {
630
686
  name: "node",
631
687
  reports: "delta",
@@ -637,6 +693,16 @@ export function node() {
637
693
  const b64 = Buffer.from(NODE_INVENTORY_SCRIPT).toString("base64");
638
694
  const r = await ctx.exec(`echo ${b64} | base64 -d | node -`);
639
695
  const units = new Set(r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("npm:")));
696
+ if (generated.length) {
697
+ const script = `const fs=require("node:fs"),crypto=require("node:crypto");
698
+ for(const file of ${JSON.stringify(generated)}) console.log(
699
+ ${JSON.stringify("generated:" + ctx.service + ":")} + file + "#" +
700
+ crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"));`;
701
+ const result = await ctx.exec(`echo ${Buffer.from(script).toString("base64")} | base64 -d | node -`);
702
+ for (const unit of result.stdout.trim().split("\n"))
703
+ if (unit)
704
+ units.add(unit);
705
+ }
640
706
  for (const f of await ctx.projectFiles()) {
641
707
  if (f !== "package.json" && !f.endsWith("/package.json"))
642
708
  continue;
@@ -646,9 +712,13 @@ export function node() {
646
712
  }
647
713
  return [...units].sort();
648
714
  },
649
- configure(svc) {
715
+ configure(svc, info) {
650
716
  const env = appendEnvFlag(svc.env, "NODE_OPTIONS", `--require ${NODE_COVERAGE_HOOK_PATH}`);
651
717
  env[NODE_COVERAGE_DIR_ENV] = COVERAGE_CONTAINER_DIR;
718
+ if (generated.length) {
719
+ env.SPECTEST_COVERAGE_GENERATED_FILES = JSON.stringify(generated);
720
+ env.SPECTEST_COVERAGE_SERVICE = info.key;
721
+ }
652
722
  return {
653
723
  ...svc,
654
724
  env,
package/dist/daemon.js CHANGED
@@ -1003,7 +1003,7 @@ async function resolveDockerfileBuild(svc) {
1003
1003
  throw new Error(`service "${svc.name}" image is not a Dockerfile build`);
1004
1004
  }
1005
1005
  const contextRel = image.context ?? ".";
1006
- const contextDir = resolveExistingProjectPath(contextRel, `service "${svc.name}" build context`);
1006
+ const contextDir = resolveExistingProjectPath(contextRel, `service "${svc.name}" build context`, { record: false });
1007
1007
  let content;
1008
1008
  let adjacent = null;
1009
1009
  let adjacentPath = null;
@@ -1214,7 +1214,10 @@ async function runServiceBuild(name, image, tag) {
1214
1214
  buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, contextDir];
1215
1215
  }
1216
1216
  progressService(name, { status: "building", detail: "starting build" });
1217
- const build = await shxStream("docker", buildArgs, 1_800_000, buildEnv, (line) => {
1217
+ // The controller owns the bootstrap/prepareImages deadline and tears
1218
+ // down the VM on expiry. A separate build timer would cut short an
1219
+ // environment's explicit timeoutSecs allowance (e.g. long Rust builds).
1220
+ const build = await shxStream("docker", buildArgs, undefined, buildEnv, (line) => {
1218
1221
  // BuildKit `--progress=plain` declares each step as
1219
1222
  // `#N [<stage> M/N] <cmd>`; the legacy builder as `Step M/N : <cmd>`.
1220
1223
  // Track the most-recent step as live detail.
@@ -3436,6 +3439,9 @@ async function captureServiceCoverage(baseline = false) {
3436
3439
  const root = await coverageRoot(svc.name);
3437
3440
  if (root)
3438
3441
  read.root = root;
3442
+ if (typeof svc.coverage === "object" && svc.coverage.sourceRoot !== undefined) {
3443
+ read.sourceRoot = svc.coverage.sourceRoot;
3444
+ }
3439
3445
  // What ships is what ran during this capture, whatever the tool
3440
3446
  // counts: cumulative lcov is subtracted against the previous
3441
3447
  // capture (module memory, forks with the env); a delta adapter's
@@ -61,12 +61,12 @@ export declare function isForeignSource(path: string): boolean;
61
61
  */
62
62
  export declare function isDocumentScriptPath(pathname: string): boolean;
63
63
  /**
64
- * Decode a source map document. Index maps (`sections`) are not
65
- * supported and decode to `null`, as does anything malformed — the
64
+ * Decode flat and embedded index maps. Unsupported external section URLs
65
+ * and malformed maps decode to `null` — the
66
66
  * caller then falls back to attributing the script to its own URL, which
67
67
  * the repo mapping cannot resolve, which is the safe answer.
68
68
  */
69
- export declare function decodeSourceMap(doc: unknown): DecodedSourceMap | null;
69
+ export declare function decodeSourceMap(doc: unknown, depth?: number): DecodedSourceMap | null;
70
70
  /**
71
71
  * Attribute generated line counts to original (file, line)s through the
72
72
  * map. Every original line a generated line's segments name gets that
@@ -83,6 +83,8 @@ export function decodeVlq(segment) {
83
83
  shift = 0;
84
84
  }
85
85
  }
86
+ if (shift !== 0)
87
+ throw new Error("unterminated VLQ");
86
88
  return out;
87
89
  }
88
90
  /**
@@ -121,6 +123,9 @@ export function normalizeSourcePath(src, sourceRoot) {
121
123
  /** Sources nobody's change can be attributed to: dependencies and bundler
122
124
  * runtime shims. */
123
125
  export function isForeignSource(path) {
126
+ // Preserve project namespaces; only the explicit Turbopack runtime is foreign.
127
+ if (path.startsWith("turbopack:///[turbopack]/"))
128
+ return true;
124
129
  return (path.length === 0 ||
125
130
  path.includes("node_modules/") ||
126
131
  path.startsWith("webpack/") ||
@@ -143,16 +148,53 @@ export function isDocumentScriptPath(pathname) {
143
148
  return !last.includes(".");
144
149
  }
145
150
  /**
146
- * Decode a source map document. Index maps (`sections`) are not
147
- * supported and decode to `null`, as does anything malformed — the
151
+ * Decode flat and embedded index maps. Unsupported external section URLs
152
+ * and malformed maps decode to `null` — the
148
153
  * caller then falls back to attributing the script to its own URL, which
149
154
  * the repo mapping cannot resolve, which is the safe answer.
150
155
  */
151
- export function decodeSourceMap(doc) {
156
+ export function decodeSourceMap(doc, depth = 0) {
157
+ if (depth > 32)
158
+ return null;
152
159
  if (!doc || typeof doc !== "object")
153
160
  return null;
154
161
  const m = doc;
155
- if (m.sections || !Array.isArray(m.sources) || typeof m.mappings !== "string") {
162
+ if (m.sections !== undefined) {
163
+ if (!Array.isArray(m.sections) || m.mappings !== undefined)
164
+ return null;
165
+ const out = { sources: [], lines: [] };
166
+ let previous;
167
+ for (const section of m.sections) {
168
+ const { line, column } = section?.offset ?? {};
169
+ if (section?.url !== undefined || !Number.isSafeInteger(line) || line < 0 ||
170
+ !Number.isSafeInteger(column) || column < 0 || line > 1_000_000 ||
171
+ (previous && (line < previous[0] || (line === previous[0] && column <= previous[1]))))
172
+ return null;
173
+ previous = [line, column];
174
+ const child = decodeSourceMap(section.map, depth + 1);
175
+ if (!child)
176
+ return null;
177
+ if (out.lines.some((segments, i) => i > line && segments.length > 0 ||
178
+ i === line && segments.some(s => s.col >= column)))
179
+ return null;
180
+ const base = out.sources.length;
181
+ for (const source of child.sources)
182
+ out.sources.push(source);
183
+ for (let i = 0; i < child.lines.length; i++) {
184
+ const target = line + i;
185
+ if (target > 1_000_000)
186
+ return null;
187
+ while (out.lines.length <= target)
188
+ out.lines.push([]);
189
+ for (const seg of child.lines[i])
190
+ out.lines[target].push({
191
+ col: seg.col + (i === 0 ? column : 0), src: seg.src + base, line: seg.line,
192
+ });
193
+ }
194
+ }
195
+ return out;
196
+ }
197
+ if (!Array.isArray(m.sources) || typeof m.mappings !== "string") {
156
198
  return null;
157
199
  }
158
200
  const sourceRoot = typeof m.sourceRoot === "string" ? m.sourceRoot : undefined;
@@ -169,10 +211,16 @@ export function decodeSourceMap(doc) {
169
211
  if (seg.length === 0)
170
212
  continue;
171
213
  const f = decodeVlq(seg);
214
+ if (![1, 4, 5].includes(f.length))
215
+ return null;
172
216
  col += f[0];
217
+ if (!Number.isSafeInteger(col) || col < 0)
218
+ return null;
173
219
  if (f.length >= 4) {
174
220
  src += f[1];
175
221
  line += f[2];
222
+ if (src < 0 || src >= sources.length || !Number.isSafeInteger(line) || line < 0)
223
+ return null;
176
224
  segs.push({ col, src, line });
177
225
  }
178
226
  }
@@ -32,6 +32,8 @@ export interface ServiceCoverageCapture {
32
32
  * never has to fetch code to do so. Absent for a container it could
33
33
  * not be read from. */
34
34
  root?: string;
35
+ /** Repository root of the service build/bundler workspace, when explicit. */
36
+ sourceRoot?: string;
35
37
  /** Set when the capture failed: the command failed, the directory was
36
38
  * empty, no file held an `SF:` record, or the reports were too large.
37
39
  * A case with an error here fails — a hole in the coverage data must
package/dist/index.js CHANGED
@@ -296,7 +296,7 @@ export function validateServiceImage(serviceName, input) {
296
296
  if (typeof image.context !== "string" || image.context === "") {
297
297
  throw new Error(`service "${serviceName}" image context must be a directory path relative to the project root (\`"."\` for the root)`);
298
298
  }
299
- const dir = resolveExistingProjectPath(image.context, `service "${serviceName}" build context`);
299
+ const dir = resolveExistingProjectPath(image.context, `service "${serviceName}" build context`, { record: false });
300
300
  if (!statSync(dir).isDirectory()) {
301
301
  throw new Error(`service "${serviceName}" build context ${JSON.stringify(image.context)} is not a directory`);
302
302
  }
@@ -20,4 +20,6 @@ export declare function resolveProjectPath(p: string, opts?: {
20
20
  /** {@link resolveProjectPath}, but a missing file is an error that names every
21
21
  * place we looked. For inputs a user hands us by name — a fixture — where an
22
22
  * ENOENT on one guessed path reads as a spectest bug rather than a typo. */
23
- export declare function resolveExistingProjectPath(p: string, what?: string): string;
23
+ export declare function resolveExistingProjectPath(p: string, what?: string, opts?: {
24
+ record?: boolean;
25
+ }): string;
@@ -68,8 +68,8 @@ export function resolveProjectPath(p, opts = {}) {
68
68
  /** {@link resolveProjectPath}, but a missing file is an error that names every
69
69
  * place we looked. For inputs a user hands us by name — a fixture — where an
70
70
  * ENOENT on one guessed path reads as a spectest bug rather than a typo. */
71
- export function resolveExistingProjectPath(p, what = "file") {
72
- const resolved = resolveProjectPath(p);
71
+ export function resolveExistingProjectPath(p, what = "file", opts = {}) {
72
+ const resolved = resolveProjectPath(p, opts);
73
73
  if (existsSync(resolved))
74
74
  return resolved;
75
75
  const rel = relative(p);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.119.0",
3
+ "version": "0.119.2",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -51,6 +51,14 @@ describe("validateCoverage", () => {
51
51
  expect(() => validateCoverage("a", { adapters: [], extra: 1 })).toThrow(/invalid `coverage` value/);
52
52
  expect(() => validateCoverage("a", {})).toThrow(/invalid `coverage` value/);
53
53
  });
54
+ test("sourceRoot is explicit, validated, and survives adapter wire serialization", () => {
55
+ const out = applyCoverageAdapters("web", { ...base, coverage: { adapters: [node(), browser()], sourceRoot: "packages" } });
56
+ expect(JSON.parse(JSON.stringify(out)).coverage.sourceRoot).toBe("packages");
57
+ expect(() => validateCoverage("web", out.coverage)).not.toThrow();
58
+ for (const sourceRoot of ["", "/packages", "../packages", "packages//dashboard", "packages/./dashboard", "C:\\app", "bad\0root"]) {
59
+ expect(() => validateCoverage("web", { adapters: [node()], sourceRoot })).toThrow();
60
+ }
61
+ });
54
62
  test("reports mode: cumulative unless declared; the shipped adapters write deltas", () => {
55
63
  expect(coverageReportsMode(undefined)).toBe("cumulative");
56
64
  expect(coverageReportsMode(true)).toBe("cumulative");
@@ -375,6 +383,76 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
375
383
  expect(new Set(all.map((s) => s.spectestScript))).toEqual(ids);
376
384
  });
377
385
 
386
+ test("declared generated entrypoints are content-addressed units in capture and inventory", async () => {
387
+ const dir = await scratch();
388
+ const adapter = node({ generated: ["server.js"] });
389
+ const config = adapter.configure!({ ...base, env: {} }, {key: "web"});
390
+ const env = {...hookEnv(dir), SPECTEST_COVERAGE_SERVICE: config.env!.SPECTEST_COVERAGE_SERVICE!,
391
+ SPECTEST_COVERAGE_GENERATED_FILES: config.env!.SPECTEST_COVERAGE_GENERATED_FILES!};
392
+ async function inventory() {
393
+ return adapter.inventory!({ service: "web", projectFiles: async () => [], readProjectFile: async () => null,
394
+ signal: new AbortController().signal,
395
+ async exec(command) {
396
+ const proc = Bun.spawn(["sh", "-c", command], {cwd:dir, stdout:"pipe", stderr:"pipe"});
397
+ const stdout = await new Response(proc.stdout).text();
398
+ const stderr = await new Response(proc.stderr).text();
399
+ if (await proc.exited !== 0) throw new Error(stderr);
400
+ return {stdout,stderr};
401
+ }
402
+ });
403
+ }
404
+ async function run() {
405
+ const child = spawn("node", [path.join(dir,"server.js")], {cwd:dir,env,stdio:"ignore"});
406
+ expect(await new Promise(r => child.on("exit",r))).toBe(0);
407
+ }
408
+ await fs.writeFile(path.join(dir,"server.js"), "globalThis.value = 1;\n");
409
+ const first = await inventory();
410
+ expect(first).toHaveLength(1);
411
+ expect(first[0]).toMatch(/^generated:web:server.js#[a-f0-9]{64}$/);
412
+ await run();
413
+ const dump = await readDump(dir,(await dumpsIn(dir))[0]!);
414
+ expect((dump as Dump & {units:string[]}).units).toEqual(first);
415
+ expect(dump.result).toEqual([]);
416
+ expect((await records(dir)).size).toBe(0);
417
+ await fs.writeFile(path.join(dir,"server.js"), "globalThis.value = 2;\n");
418
+ expect(await inventory()).not.toEqual(first);
419
+ expect(() => node({generated:["../server.js"]})).toThrow();
420
+ });
421
+
422
+ test("collected VM contexts retain their compiled source with a bounded debugger cache", async () => {
423
+ const dir = await scratch();
424
+ await fs.writeFile(path.join(dir, "main.js"), `
425
+ const vm = require("node:vm");
426
+ new vm.Script("globalThis.value = 42; /*" + "x".repeat(8 * 1024 * 1024) + "*/", {
427
+ filename: "file:///tmp/workflow-bundle.js"
428
+ }).runInNewContext();
429
+ for (let i = 0; i < 3; i++) global.gc();
430
+ `);
431
+ const child = spawn("node", ["--expose-gc", path.join(dir, "main.js")], { env: hookEnv(dir), stdio: "ignore" });
432
+ expect(await new Promise(r => child.on("exit", r))).toBe(0);
433
+ const rec = (await records(dir)).get("file:///tmp/workflow-bundle.js")!;
434
+ expect(rec.lineLengths?.length).toBe(1);
435
+ expect(rec.lineLengths![0]).toBeGreaterThan(8 * 1024 * 1024);
436
+ expect(JSON.stringify(rec).length).toBeLessThan(1024);
437
+ });
438
+
439
+ test("indexed maps retain offsets and dependency units and strip nested source contents", async () => {
440
+ const dir = await scratch();
441
+ await fs.writeFile(path.join(dir, "main.js"), "globalThis.value = 1;\n");
442
+ const leaf = { version: 3, sources: ["turbopack:///[project]/dashboard/page.ts", "webpack:///node_modules/pkg/a.js"],
443
+ sourcesContent: ["secret application source", "dependency source"], mappings: "AAAA,CCAA" };
444
+ await fs.writeFile(path.join(dir, "main.js.map"), JSON.stringify({ version: 3, sections: [
445
+ { offset: { line: 0, column: 0 }, map: { version: 3, sections: [{ offset: { line: 0, column: 0 }, map: leaf }] } }
446
+ ] }));
447
+ expect(await runOnce(dir, "main.js")).toBe(0);
448
+ const rec = (await records(dir)).get(`file://${path.join(dir, "main.js")}`)!;
449
+ const text = JSON.stringify(rec.sourceMap);
450
+ expect(text).not.toContain("sourcesContent");
451
+ expect(text).not.toContain("secret application source");
452
+ expect(text).toContain('"x_spectestSourceUnits":[null,"npm:pkg"]');
453
+ expect(text).toContain('"offset":{"line":0,"column":0}');
454
+ });
455
+
378
456
  test("short-lived CLI processes write at exit; the map next to the script is recorded once, no NODE_V8_COVERAGE", async () => {
379
457
  const dir = await scratch();
380
458
  await fs.writeFile(path.join(dir, "cli.js"), "function used() { return 1 }\nfunction unused() { return 2 }\nused();\n");
package/src/coverage.ts CHANGED
@@ -164,7 +164,14 @@ export const INVENTORY_REPORT = "inventory.units";
164
164
  * program itself writes count (`reports`, default `"cumulative"`). */
165
165
  export type ServiceCoverage =
166
166
  | true
167
- | { adapters?: readonly CoverageAdapter[]; reports?: CoverageReportsMode };
167
+ | {
168
+ adapters?: readonly CoverageAdapter[];
169
+ reports?: CoverageReportsMode;
170
+ /** Repository-relative root of this service's build/bundler workspace.
171
+ * For a monorepo built from packages/, set sourceRoot: "packages".
172
+ * Paths are checked against the repository; no suffix guessing. */
173
+ sourceRoot?: string;
174
+ };
168
175
 
169
176
  /** The `reports` mode of a `coverage` value, for the reports no adapter
170
177
  * wrote (the program's own, a `command`'s). */
@@ -184,14 +191,19 @@ export function validateCoverage(service: string, cov: unknown): void {
184
191
  const obj = typeof cov === "object" && cov !== null ? (cov as Record<string, unknown>) : null;
185
192
  const adapters = obj?.adapters;
186
193
  const reports = obj?.reports;
194
+ const sourceRoot = obj?.sourceRoot;
187
195
  // `explains` is what `applyCoverageAdapters` adds for the wire; the
188
196
  // config is validated again after that, so it must pass here.
189
197
  const explains = obj?.explains;
190
- const knownKeys = obj !== null && Object.keys(obj).every((k) => k === "adapters" || k === "reports" || k === "explains");
198
+ const knownKeys = obj !== null && Object.keys(obj).every((k) => k === "adapters" || k === "reports" || k === "explains" || k === "sourceRoot");
199
+ const okRoot = sourceRoot === undefined || (typeof sourceRoot === "string" &&
200
+ (sourceRoot === "." || (sourceRoot.length > 0 && !sourceRoot.includes("\\") &&
201
+ !sourceRoot.includes(":") && !sourceRoot.includes("\0") &&
202
+ sourceRoot.split("/").every(p => p !== "" && p !== "." && p !== ".."))));
191
203
  const okReports = reports === undefined || reports === "delta" || reports === "cumulative";
192
204
  const okAdapters = adapters === undefined || Array.isArray(adapters);
193
205
  const okExplains = explains === undefined || (Array.isArray(explains) && explains.every((e) => typeof e === "string"));
194
- if (obj !== null && knownKeys && okReports && okAdapters && okExplains && (adapters !== undefined || reports !== undefined)) {
206
+ if (obj !== null && knownKeys && okRoot && okReports && okAdapters && okExplains && (adapters !== undefined || reports !== undefined)) {
195
207
  for (const a of (adapters as unknown[] | undefined) ?? []) {
196
208
  const ok =
197
209
  typeof a === "object" &&
@@ -246,7 +258,9 @@ export function applyCoverageAdapters<S extends ServiceConfig>(key: string, svc:
246
258
  // `explains` rides the wire config so the control plane's selector can
247
259
  // read it without knowing any adapter.
248
260
  const explains = [...new Set(adapters.flatMap((a) => a.explains ?? []))];
261
+ const sourceRoot = typeof svc.coverage === "object" ? svc.coverage.sourceRoot : undefined;
249
262
  const wire = {
263
+ ...(sourceRoot !== undefined ? { sourceRoot } : {}),
250
264
  adapters: adapters.map((a) => withWireName(a)),
251
265
  ...(mode ? { reports: mode } : {}),
252
266
  ...(explains.length > 0 ? { explains } : {}),
@@ -387,6 +401,21 @@ export const NODE_COVERAGE_HOOK = `"use strict";
387
401
  if (post("Profiler.enable") === null) return;
388
402
  post("Profiler.startPreciseCoverage", { callCount: true, detailed: true });
389
403
 
404
+ // Attach before app scripts execute. Enabling only at capture loses the
405
+ // source of collected vm contexts (e.g. Temporal workflow bundles).
406
+ // Bound V8's collected-script cache; live sources remain owned by V8.
407
+ post("Debugger.enable", { maxScriptsCacheSize: 16 * 1024 * 1024 });
408
+
409
+ const GENERATED = new Map();
410
+ try {
411
+ for (const file of JSON.parse(process.env.SPECTEST_COVERAGE_GENERATED_FILES || "[]")) {
412
+ GENERATED.set(url.pathToFileURL(path.resolve(file)).href, file);
413
+ }
414
+ } catch (e) {}
415
+ const generatedUnits = new Map();
416
+ function generatedUnit(file, text) {
417
+ return "generated:" + process.env.SPECTEST_COVERAGE_SERVICE + ":" + file + "#" + crypto.createHash("sha256").update(text).digest("hex");
418
+ }
390
419
  const SELF = url.pathToFileURL(__filename).href;
391
420
  const SCRIPTS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SCRIPTS_SUBDIR)});
392
421
  const MARKERS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_MARKERS_SUBDIR)});
@@ -493,31 +522,47 @@ export const NODE_COVERAGE_HOOK = `"use strict";
493
522
  } catch (e) {
494
523
  return null;
495
524
  }
496
- if (!data || typeof data !== "object" || !("mappings" in data)) return null;
497
- delete data.sourcesContent;
498
525
  // A source inside node_modules is a dependency: its unit id rides
499
526
  // beside the map (\`sourceUnits[i]\`, null for the project's own), so
500
527
  // the control plane never has to know what node_modules is.
501
528
  const mapDir = mapUrl ? path.dirname(url.fileURLToPath(mapUrl)) : path.dirname(scriptPath);
502
- const root = typeof data.sourceRoot === "string" && data.sourceRoot ? data.sourceRoot : "";
503
- const sourceUnits = (Array.isArray(data.sources) ? data.sources : []).map(function (src) {
504
- if (typeof src !== "string") return null;
505
- let abs;
506
- if (src.startsWith("file://")) {
507
- try {
508
- abs = url.fileURLToPath(src);
509
- } catch (e) {
510
- return null;
529
+ function sourceUnits(data) {
530
+ const root = typeof data.sourceRoot === "string" ? data.sourceRoot : "";
531
+ return data.sources.map(function (src) {
532
+ if (typeof src !== "string") return null;
533
+ let abs;
534
+ if (src.startsWith("file://")) {
535
+ try {
536
+ abs = url.fileURLToPath(src);
537
+ } catch (e) {
538
+ return null;
539
+ }
540
+ } else if (/^[a-z][a-z0-9+.-]*:/i.test(src)) {
541
+ return src.indexOf("/node_modules/") >= 0 ? unitFromText(src) : null;
542
+ } else {
543
+ const rel = root && !src.startsWith("/") ? root.replace(/\\/$/, "") + "/" + src : src;
544
+ abs = path.resolve(mapDir, rel);
511
545
  }
512
- } else if (/^[a-z][a-z0-9+.-]*:/i.test(src)) {
513
- return src.indexOf("/node_modules/") >= 0 ? unitFromText(src) : null;
514
- } else {
515
- const rel = root && !src.startsWith("/") ? root.replace(/\\/$/, "") + "/" + src : src;
516
- abs = path.resolve(mapDir, rel);
546
+ return abs.indexOf("/node_modules/") >= 0 ? unitOf(abs) : null;
547
+ });
548
+ }
549
+ function prepareMap(data, depth) {
550
+ if (!data || typeof data !== "object" || depth > 32) return false;
551
+ delete data.sourcesContent;
552
+ if (Array.isArray(data.sections)) {
553
+ if ("mappings" in data) return false;
554
+ return data.sections.every(function (section) {
555
+ return section && !section.url && prepareMap(section.map, depth + 1);
556
+ });
517
557
  }
518
- return abs.indexOf("/node_modules/") >= 0 ? unitOf(abs) : null;
519
- });
520
- return { url: mapUrl, data: data, sourceUnits: sourceUnits };
558
+ if (!Array.isArray(data.sources) || typeof data.mappings !== "string") return false;
559
+ data.x_spectestSourceUnits = sourceUnits(data);
560
+ return true;
561
+ }
562
+ if (!prepareMap(data, 0)) return null;
563
+ const units = data.x_spectestSourceUnits;
564
+ delete data.x_spectestSourceUnits;
565
+ return { url: mapUrl, data: data, sourceUnits: units };
521
566
  }
522
567
  function writeAtomic(file, text) {
523
568
  const tmp = file + "." + process.pid + "-" + wt.threadId + ".tmp";
@@ -566,34 +611,44 @@ export const NODE_COVERAGE_HOOK = `"use strict";
566
611
  if (kept.length === 0 && units.size === 0) return;
567
612
  const fresh = [];
568
613
  for (const s of kept) {
569
- if (!known.has(s.scriptId)) fresh.push(s);
614
+ if (!known.has(s.scriptId) && !generatedUnits.has(s.scriptId)) fresh.push(s);
570
615
  }
571
616
  if (fresh.length > 0) {
572
617
  try {
573
618
  fs.mkdirSync(SCRIPTS, { recursive: true });
574
619
  fs.mkdirSync(MARKERS, { recursive: true });
575
620
  } catch (e) {}
576
- post("Debugger.enable");
577
621
  for (const s of fresh) {
578
622
  const r = post("Debugger.getScriptSource", { scriptId: s.scriptId });
579
623
  try {
580
- known.set(s.scriptId, describe(s, r && r.scriptSource));
624
+ const source = r && r.scriptSource;
625
+ if (GENERATED.has(s.url) && typeof source === "string") {
626
+ generatedUnits.set(s.scriptId, generatedUnit(GENERATED.get(s.url), source));
627
+ continue;
628
+ }
629
+ const id = describe(s, source);
630
+ // A failed source fetch remains visible in this dump, but is not
631
+ // memoized as a successful descriptor for subsequent captures.
632
+ if (typeof source === "string") known.set(s.scriptId, id);
633
+ s.missingSourceRecord = id;
581
634
  } catch (e) {}
582
635
  }
583
- post("Debugger.disable");
584
636
  }
585
637
  // A ran function is one flat array: [start, length, count] for its
586
638
  // extent (V8's first range), then [offset from start, length, count]
587
639
  // per block range in V8's order. Small numbers, so the dump gzips to
588
640
  // less than half of V8's own JSON; name and block flag live in the
589
641
  // script record, joined on the extent.
642
+ for (const s of kept) {
643
+ if (generatedUnits.has(s.scriptId)) units.add(generatedUnits.get(s.scriptId));
644
+ }
590
645
  const doc = {
591
646
  spectestVersion: 2,
592
647
  units: Array.from(units).sort(),
593
- result: kept.map(function (s) {
648
+ result: kept.filter(function (s) { return !generatedUnits.has(s.scriptId); }).map(function (s) {
594
649
  return {
595
650
  url: s.url,
596
- spectestScript: known.get(s.scriptId) || null,
651
+ spectestScript: known.get(s.scriptId) || s.missingSourceRecord || null,
597
652
  functions: s.functions.filter(ran).map(function (f) {
598
653
  const first = f.ranges[0];
599
654
  const out = [first.startOffset, first.endOffset - first.startOffset, first.count];
@@ -774,7 +829,22 @@ for (;;) {
774
829
  process.stdout.write(Array.from(out).sort().join("\\n") + (out.size ? "\\n" : ""));
775
830
  `;
776
831
 
777
- export function node(): CoverageAdapter {
832
+ export interface NodeCoverageOptions {
833
+ /** Generated runtime entrypoints, relative to the service working directory.
834
+ * Track their exact contents as units rather than inventing repository lines.
835
+ * A content change changes the inventory and invalidates dependent tests. */
836
+ generated?: readonly string[];
837
+ }
838
+
839
+ export function node(options: NodeCoverageOptions = {}): CoverageAdapter {
840
+ if (options.generated !== undefined && (!Array.isArray(options.generated) || options.generated.some(p => typeof p !== "string"))) {
841
+ throw new Error("coverage.node generated must be a list of relative files");
842
+ }
843
+ const generated: string[] = [...new Set<string>(options.generated ?? [])].sort();
844
+ if (generated.some(p => !p || p.includes("\\") || p.includes(":") || p.includes("\0") ||
845
+ p.split("/").some(c => !c || c === "." || c === ".."))) {
846
+ throw new Error("coverage.node generated paths must be relative files without traversal");
847
+ }
778
848
  return {
779
849
  name: "node",
780
850
  reports: "delta",
@@ -786,6 +856,14 @@ export function node(): CoverageAdapter {
786
856
  const b64 = Buffer.from(NODE_INVENTORY_SCRIPT).toString("base64");
787
857
  const r = await ctx.exec(`echo ${b64} | base64 -d | node -`);
788
858
  const units = new Set<string>(r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("npm:")));
859
+ if (generated.length) {
860
+ const script = `const fs=require("node:fs"),crypto=require("node:crypto");
861
+ for(const file of ${JSON.stringify(generated)}) console.log(
862
+ ${JSON.stringify("generated:" + ctx.service + ":")} + file + "#" +
863
+ crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"));`;
864
+ const result = await ctx.exec(`echo ${Buffer.from(script).toString("base64")} | base64 -d | node -`);
865
+ for (const unit of result.stdout.trim().split("\n")) if (unit) units.add(unit);
866
+ }
789
867
  for (const f of await ctx.projectFiles()) {
790
868
  if (f !== "package.json" && !f.endsWith("/package.json")) continue;
791
869
  const text = await ctx.readProjectFile(f);
@@ -793,9 +871,13 @@ export function node(): CoverageAdapter {
793
871
  }
794
872
  return [...units].sort();
795
873
  },
796
- configure(svc) {
874
+ configure(svc, info) {
797
875
  const env = appendEnvFlag(svc.env, "NODE_OPTIONS", `--require ${NODE_COVERAGE_HOOK_PATH}`);
798
876
  env[NODE_COVERAGE_DIR_ENV] = COVERAGE_CONTAINER_DIR;
877
+ if (generated.length) {
878
+ env.SPECTEST_COVERAGE_GENERATED_FILES = JSON.stringify(generated);
879
+ env.SPECTEST_COVERAGE_SERVICE = info.key;
880
+ }
799
881
  return {
800
882
  ...svc,
801
883
  env,
package/src/daemon.ts CHANGED
@@ -1351,7 +1351,7 @@ async function resolveDockerfileBuild(svc: NamedService): Promise<DockerfileBuil
1351
1351
  throw new Error(`service "${svc.name}" image is not a Dockerfile build`);
1352
1352
  }
1353
1353
  const contextRel = image.context ?? ".";
1354
- const contextDir = resolveExistingProjectPath(contextRel, `service "${svc.name}" build context`);
1354
+ const contextDir = resolveExistingProjectPath(contextRel, `service "${svc.name}" build context`, { record: false });
1355
1355
  let content: string;
1356
1356
  let adjacent: string | null = null;
1357
1357
  let adjacentPath: string | null = null;
@@ -1576,7 +1576,10 @@ async function runServiceBuild(
1576
1576
  buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, contextDir];
1577
1577
  }
1578
1578
  progressService(name, { status: "building", detail: "starting build" });
1579
- const build = await shxStream("docker", buildArgs, 1_800_000, buildEnv, (line) => {
1579
+ // The controller owns the bootstrap/prepareImages deadline and tears
1580
+ // down the VM on expiry. A separate build timer would cut short an
1581
+ // environment's explicit timeoutSecs allowance (e.g. long Rust builds).
1582
+ const build = await shxStream("docker", buildArgs, undefined, buildEnv, (line) => {
1580
1583
  // BuildKit `--progress=plain` declares each step as
1581
1584
  // `#N [<stage> M/N] <cmd>`; the legacy builder as `Step M/N : <cmd>`.
1582
1585
  // Track the most-recent step as live detail.
@@ -4068,6 +4071,9 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
4068
4071
  }
4069
4072
  const root = await coverageRoot(svc.name);
4070
4073
  if (root) read.root = root;
4074
+ if (typeof svc.coverage === "object" && svc.coverage.sourceRoot !== undefined) {
4075
+ read.sourceRoot = svc.coverage.sourceRoot;
4076
+ }
4071
4077
  // What ships is what ran during this capture, whatever the tool
4072
4078
  // counts: cumulative lcov is subtracted against the previous
4073
4079
  // capture (module memory, forks with the env); a delta adapter's
@@ -48,8 +48,8 @@ describe("source maps", () => {
48
48
  expect(files.get("src/b.ts")!.get(5)).toBe(0);
49
49
  });
50
50
 
51
- test("index maps and garbage decode to null", () => {
52
- expect(decodeSourceMap({ version: 3, sections: [] })).toBeNull();
51
+ test("empty index maps are generated code; malformed maps remain unresolved", () => {
52
+ expect(decodeSourceMap({ version: 3, sections: [] })).toEqual({ sources: [], lines: [] });
53
53
  expect(decodeSourceMap({ version: 3, sources: ["a"], mappings: "!!" })).toBeNull();
54
54
  expect(decodeSourceMap("nope")).toBeNull();
55
55
  });
@@ -112,3 +112,18 @@ describe("turbopack + inline scripts", () => {
112
112
  expect(isDocumentScriptPath("/app.js")).toBe(false);
113
113
  });
114
114
  });
115
+
116
+ test("nested indexed maps apply section line and column offsets and reject overlaps", () => {
117
+ const leaf = { version: 3, sources: ["turbopack:///[project]/dashboard/a.ts"], mappings: "AAAA;AACA" };
118
+ const doc = { version: 3, sections: [
119
+ { offset: { line: 1, column: 3 }, map: { version: 3, sections: [{offset: {line: 0, column: 2}, map: leaf}] } },
120
+ { offset: { line: 3, column: 0 }, map: { version: 3, sources: ["b.ts"], mappings: "AAAA" } },
121
+ ] };
122
+ const map = decodeSourceMap(doc)!;
123
+ expect(map.lines).toEqual([[], [{col:5, src:0, line:0}], [{col:0, src:0, line:1}], [{col:0, src:1, line:0}]]);
124
+ expect([...mapToOriginal(map, new Map([[2,1],[3,2],[4,3]])).keys()]).toEqual([leaf.sources[0], "b.ts"]);
125
+ expect(decodeSourceMap({version:3,sections:[{offset:{line:0,column:0},url:"remote.map"}]})).toBeNull();
126
+ expect(decodeSourceMap({...doc,sections:[doc.sections[1],doc.sections[0]]})).toBeNull();
127
+ expect(isForeignSource("turbopack:///[turbopack]/browser/runtime/base/runtime-base.ts")).toBe(true);
128
+ expect(isForeignSource("turbopack:///[project]/turbopack/runtime.ts")).toBe(false);
129
+ });
@@ -115,6 +115,7 @@ export function decodeVlq(segment: string): number[] {
115
115
  shift = 0;
116
116
  }
117
117
  }
118
+ if (shift !== 0) throw new Error("unterminated VLQ");
118
119
  return out;
119
120
  }
120
121
 
@@ -152,6 +153,8 @@ export function normalizeSourcePath(src: string, sourceRoot?: string): string {
152
153
  /** Sources nobody's change can be attributed to: dependencies and bundler
153
154
  * runtime shims. */
154
155
  export function isForeignSource(path: string): boolean {
156
+ // Preserve project namespaces; only the explicit Turbopack runtime is foreign.
157
+ if (path.startsWith("turbopack:///[turbopack]/")) return true;
155
158
  return (
156
159
  path.length === 0 ||
157
160
  path.includes("node_modules/") ||
@@ -178,12 +181,13 @@ export function isDocumentScriptPath(pathname: string): boolean {
178
181
  }
179
182
 
180
183
  /**
181
- * Decode a source map document. Index maps (`sections`) are not
182
- * supported and decode to `null`, as does anything malformed — the
184
+ * Decode flat and embedded index maps. Unsupported external section URLs
185
+ * and malformed maps decode to `null` — the
183
186
  * caller then falls back to attributing the script to its own URL, which
184
187
  * the repo mapping cannot resolve, which is the safe answer.
185
188
  */
186
- export function decodeSourceMap(doc: unknown): DecodedSourceMap | null {
189
+ export function decodeSourceMap(doc: unknown, depth = 0): DecodedSourceMap | null {
190
+ if (depth > 32) return null;
187
191
  if (!doc || typeof doc !== "object") return null;
188
192
  const m = doc as {
189
193
  version?: number;
@@ -192,7 +196,34 @@ export function decodeSourceMap(doc: unknown): DecodedSourceMap | null {
192
196
  mappings?: unknown;
193
197
  sections?: unknown;
194
198
  };
195
- if (m.sections || !Array.isArray(m.sources) || typeof m.mappings !== "string") {
199
+ if (m.sections !== undefined) {
200
+ if (!Array.isArray(m.sections) || m.mappings !== undefined) return null;
201
+ const out: DecodedSourceMap = { sources: [], lines: [] };
202
+ let previous: [number, number] | undefined;
203
+ for (const section of m.sections) {
204
+ const { line, column } = section?.offset ?? {};
205
+ if (section?.url !== undefined || !Number.isSafeInteger(line) || line < 0 ||
206
+ !Number.isSafeInteger(column) || column < 0 || line > 1_000_000 ||
207
+ (previous && (line < previous[0] || (line === previous[0] && column <= previous[1])))) return null;
208
+ previous = [line, column];
209
+ const child = decodeSourceMap(section.map, depth + 1);
210
+ if (!child) return null;
211
+ if (out.lines.some((segments, i) => i > line && segments.length > 0 ||
212
+ i === line && segments.some(s => s.col >= column))) return null;
213
+ const base = out.sources.length;
214
+ for (const source of child.sources) out.sources.push(source);
215
+ for (let i = 0; i < child.lines.length; i++) {
216
+ const target = line + i;
217
+ if (target > 1_000_000) return null;
218
+ while (out.lines.length <= target) out.lines.push([]);
219
+ for (const seg of child.lines[i]!) out.lines[target]!.push({
220
+ col: seg.col + (i === 0 ? column : 0), src: seg.src + base, line: seg.line,
221
+ });
222
+ }
223
+ }
224
+ return out;
225
+ }
226
+ if (!Array.isArray(m.sources) || typeof m.mappings !== "string") {
196
227
  return null;
197
228
  }
198
229
  const sourceRoot = typeof m.sourceRoot === "string" ? m.sourceRoot : undefined;
@@ -210,10 +241,13 @@ export function decodeSourceMap(doc: unknown): DecodedSourceMap | null {
210
241
  for (const seg of group.split(",")) {
211
242
  if (seg.length === 0) continue;
212
243
  const f = decodeVlq(seg);
244
+ if (![1,4,5].includes(f.length)) return null;
213
245
  col += f[0];
246
+ if (!Number.isSafeInteger(col) || col < 0) return null;
214
247
  if (f.length >= 4) {
215
248
  src += f[1];
216
249
  line += f[2];
250
+ if (src < 0 || src >= sources.length || !Number.isSafeInteger(line) || line < 0) return null;
217
251
  segs.push({ col, src, line });
218
252
  }
219
253
  }
@@ -76,6 +76,8 @@ export interface ServiceCoverageCapture {
76
76
  * never has to fetch code to do so. Absent for a container it could
77
77
  * not be read from. */
78
78
  root?: string;
79
+ /** Repository root of the service build/bundler workspace, when explicit. */
80
+ sourceRoot?: string;
79
81
  /** Set when the capture failed: the command failed, the directory was
80
82
  * empty, no file held an `SF:` record, or the reports were too large.
81
83
  * A case with an error here fails — a hole in the coverage data must
package/src/index.ts CHANGED
@@ -1384,7 +1384,7 @@ export function validateServiceImage(serviceName: string, input: ServiceImage):
1384
1384
  `service "${serviceName}" image context must be a directory path relative to the project root (\`"."\` for the root)`,
1385
1385
  );
1386
1386
  }
1387
- const dir = resolveExistingProjectPath(image.context, `service "${serviceName}" build context`);
1387
+ const dir = resolveExistingProjectPath(image.context, `service "${serviceName}" build context`, { record: false });
1388
1388
  if (!statSync(dir).isDirectory()) {
1389
1389
  throw new Error(
1390
1390
  `service "${serviceName}" build context ${JSON.stringify(image.context)} is not a directory`,
@@ -0,0 +1,25 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ test("resolving a build context records no directory read, while source files remain covered", async () => {
7
+ const dir = await mkdtemp(path.join(os.tmpdir(), "spectest-project-reads-"));
8
+ try {
9
+ await mkdir(path.join(dir, "packages"));
10
+ await writeFile(path.join(dir, "packages/Dockerfile"), "FROM scratch\n");
11
+ const module = new URL("./project-files.ts", import.meta.url).pathname;
12
+ const script = `import {resolveExistingProjectPath,takeProjectReads} from ${JSON.stringify(module)};
13
+ import {validateServiceImage} from ${JSON.stringify(new URL("./index.ts", import.meta.url).pathname)};
14
+ resolveExistingProjectPath(".","build context",{record:false});
15
+ resolveExistingProjectPath("packages","build context",{record:false});
16
+ validateServiceImage("web",{type:"dockerfile",context:"packages",path:"packages/Dockerfile"});
17
+ console.log(JSON.stringify(takeProjectReads()));`;
18
+ const proc = Bun.spawn([process.execPath, "-e", script], {
19
+ env: {...process.env, SPECTEST_WORKSPACE:dir}, stdout:"pipe", stderr:"pipe",
20
+ });
21
+ const text = await new Response(proc.stdout).text();
22
+ expect(await proc.exited).toBe(0);
23
+ expect(JSON.parse(text)).toEqual(["packages/Dockerfile"]);
24
+ } finally { await rm(dir, {recursive:true, force:true}); }
25
+ });
@@ -72,8 +72,8 @@ export function resolveProjectPath(p: string, opts: { record?: boolean } = {}):
72
72
  /** {@link resolveProjectPath}, but a missing file is an error that names every
73
73
  * place we looked. For inputs a user hands us by name — a fixture — where an
74
74
  * ENOENT on one guessed path reads as a spectest bug rather than a typo. */
75
- export function resolveExistingProjectPath(p: string, what = "file"): string {
76
- const resolved = resolveProjectPath(p);
75
+ export function resolveExistingProjectPath(p: string, what = "file", opts: { record?: boolean } = {}): string {
76
+ const resolved = resolveProjectPath(p, opts);
77
77
  if (existsSync(resolved)) return resolved;
78
78
  const rel = relative(p);
79
79
  const looked = path.isAbsolute(p)