@specific.dev/spectest 0.86.0 → 0.86.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/daemon.js CHANGED
@@ -15,8 +15,9 @@
15
15
  // One sandbox = one daemon. Concurrency between tests is achieved by
16
16
  // forking the sandbox; inside a single daemon we never run two tests at
17
17
  // once — that keeps stdout capture and timeouts simple.
18
+ import { preparedImages } from "./harness/prepared-images.js";
18
19
  import { execFile, spawn } from "node:child_process";
19
- import { randomUUID } from "node:crypto";
20
+ import { createHash, randomUUID } from "node:crypto";
20
21
  import { existsSync, promises as fs, readFileSync } from "node:fs";
21
22
  import net from "node:net";
22
23
  import path from "node:path";
@@ -2947,23 +2948,31 @@ function logBootstrapTimings(t) {
2947
2948
  }
2948
2949
  console.log(`[bootstrap] total ${t.totalMs}ms across ${t.services.length} service(s)`);
2949
2950
  }
2950
- async function bootstrap() {
2951
+ async function bootstrap(prepared) {
2951
2952
  // Tee everything this bootstrap logs — ours and every service `setup`
2952
2953
  // hook's — into the boot log, so GET /boot-log can explain a slow or
2953
2954
  // finished boot. Restored in the finally at the end.
2954
2955
  const restoreBootLog = captureConsole(BOOT_LOG_SINK);
2955
2956
  try {
2956
- return await bootstrapInner();
2957
+ return await bootstrapInner(prepared);
2957
2958
  }
2958
2959
  finally {
2959
2960
  restoreBootLog();
2960
2961
  }
2961
2962
  }
2962
- async function bootstrapInner() {
2963
+ async function imageInput(svc) {
2964
+ const input = svc.image.type === "registry" ? svc.image : await resolveDockerfileBuild(svc);
2965
+ return createHash("sha256").update(JSON.stringify(input)).digest("hex");
2966
+ }
2967
+ async function bootstrapInner(prepared, identity) {
2963
2968
  const bootStart = Date.now();
2964
2969
  const cfg = requireLoaded().project.environment;
2965
2970
  const services = namedServices(cfg);
2966
2971
  const timings = new Map();
2972
+ if (prepared && (prepared.version !== 1 || !prepared.images ||
2973
+ Object.keys(prepared.images).sort().join("\n") !== services.map(s => s.name).sort().join("\n"))) {
2974
+ throw new Error("prepared image manifest does not match the environment's services");
2975
+ }
2967
2976
  progressInit(services);
2968
2977
  // Build dedup is only valid within one workspace generation — a fresh
2969
2978
  // bootstrap may follow a workspace re-upload with the same dockerfile
@@ -3010,11 +3019,25 @@ async function bootstrapInner() {
3010
3019
  const tags = new Map();
3011
3020
  // Bootstrap is the only dedup scope: all its builds share one
3012
3021
  // /workspace generation (see prepareServiceImage).
3013
- const prepImage = (svc) => prepareServiceImage(svc, { dedup: true });
3022
+ const prepImage = async (svc) => {
3023
+ if (!prepared)
3024
+ return prepareServiceImage(svc, { dedup: true });
3025
+ const entry = prepared.images[svc.name];
3026
+ if (!entry || entry.input !== await imageInput(svc) || !/^sha256:[a-f0-9]{64}$/.test(entry.image)) {
3027
+ throw new Error(`prepared image inputs changed for ${svc.name}`);
3028
+ }
3029
+ const found = await docker(["image", "inspect", "--format", "{{.Id}}", entry.image], 30_000);
3030
+ if (found.code !== 0 || found.stdout.trim() !== entry.image) {
3031
+ throw new Error(`prepared image missing for ${svc.name}: ${entry.image}`);
3032
+ }
3033
+ return { tag: entry.image, buildSteps: entry.buildSteps };
3034
+ };
3014
3035
  const prep = new Map();
3036
+ const prepOffsets = new Map();
3015
3037
  for (const svc of services) {
3016
3038
  const p = (async () => {
3017
3039
  const t0 = Date.now();
3040
+ prepOffsets.set(svc.name, Date.now() - bootStart);
3018
3041
  const { tag, buildSteps } = await prepImage(svc);
3019
3042
  progressService(svc.name, { status: "prepared", detail: undefined });
3020
3043
  tags.set(svc.name, tag);
@@ -3032,6 +3055,21 @@ async function bootstrapInner() {
3032
3055
  p.catch(() => undefined);
3033
3056
  prep.set(svc.name, p);
3034
3057
  }
3058
+ if (identity !== undefined) {
3059
+ // No application services, setup hooks, ingress, or runtime state in the builder.
3060
+ await Promise.all(prep.values());
3061
+ const images = Object.create(null);
3062
+ for (const svc of services) {
3063
+ const out = await docker(["image", "inspect", "--format", "{{.Id}}", tags.get(svc.name)], 30_000);
3064
+ const image = out.stdout.trim();
3065
+ if (out.code !== 0 || !/^sha256:[a-f0-9]{64}$/.test(image)) {
3066
+ throw new Error(`cannot resolve prepared image for ${svc.name}: ${out.stderr}`);
3067
+ }
3068
+ const ti = timings.get(svc.name);
3069
+ images[svc.name] = { input: await imageInput(svc), image, prepMs: ti.prepMs, startMs: prepOffsets.get(svc.name), buildSteps: ti.buildSteps };
3070
+ }
3071
+ return { version: 1, identity, images };
3072
+ }
3035
3073
  // Ingress (fakes + service-tls proxies) comes up BEFORE services so
3036
3074
  // that any service that calls a fake URL during its own startup
3037
3075
  // probe finds it answering. Service-tls proxies will return 502
@@ -3488,8 +3526,13 @@ async function writeCoverageInventory(svc) {
3488
3526
  },
3489
3527
  projectFiles: () => walkProjectFiles(WORKSPACE),
3490
3528
  async readProjectFile(p) {
3529
+ // Not recorded as a read: an inventory fingerprinting every
3530
+ // manifest is bookkeeping, not the suite depending on the files.
3531
+ // Recording them made the setup set name every package.json as a
3532
+ // whole file, so a version bump was "ran during environment
3533
+ // setup" — a full run, the exact case the inventory exists for.
3491
3534
  try {
3492
- return await fs.readFile(resolveProjectPath(p), "utf8");
3535
+ return await fs.readFile(resolveProjectPath(p, { record: false }), "utf8");
3493
3536
  }
3494
3537
  catch {
3495
3538
  return null;
@@ -3521,9 +3564,22 @@ async function writeCoverageInventory(svc) {
3521
3564
  * The project's reach (`harness/reach.ts`): every project file some
3522
3565
  * service's build context includes, some volume mounts, or a build
3523
3566
  * reads. Computed from the same resolution the builds use, once at the
3524
- * bring-up capture.
3567
+ * bring-up capture. The resolution's own path lookups are bookkeeping,
3568
+ * not reads: they are taken out of the read set again afterwards.
3525
3569
  */
3526
3570
  async function computeProjectReach(all) {
3571
+ const before = takeProjectReads();
3572
+ try {
3573
+ return await computeProjectReachInner(all);
3574
+ }
3575
+ finally {
3576
+ // Discard what the resolution recorded, restore what was there.
3577
+ takeProjectReads();
3578
+ for (const p of before)
3579
+ resolveProjectPath(p);
3580
+ }
3581
+ }
3582
+ async function computeProjectReachInner(all) {
3527
3583
  const files = await walkProjectFiles(WORKSPACE, [".git"]);
3528
3584
  const sources = [];
3529
3585
  for (const svc of all) {
@@ -5398,7 +5454,7 @@ function loadedSummary(l) {
5398
5454
  */
5399
5455
  export function harnessMethods(state) {
5400
5456
  return {
5401
- health: async () => ({ ok: true }),
5457
+ health: async () => ({ ok: true, capabilities: ["prepared-images-v1"] }),
5402
5458
  // Live bootstrap progress, polled by the control plane during
5403
5459
  // `bootstrap` and streamed into the test-run row. `{}` before the
5404
5460
  // first bootstrap of this harness process.
@@ -5497,10 +5553,19 @@ export function harnessMethods(state) {
5497
5553
  }
5498
5554
  return { refs: [...refs] };
5499
5555
  },
5500
- bootstrap: async () => exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => ({
5501
- ok: true,
5502
- timings: await bootstrap(),
5503
- })),
5556
+ prepareImages: async (params) => exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => {
5557
+ const restore = captureConsole(BOOT_LOG_SINK);
5558
+ try {
5559
+ return await bootstrapInner(undefined, requireString(params, "identity"));
5560
+ }
5561
+ finally {
5562
+ restore();
5563
+ }
5564
+ }),
5565
+ bootstrap: async (params) => exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => {
5566
+ const prepared = params.prepared === undefined ? undefined : preparedImages(params.prepared, params.identity, namedServices(requireLoaded().project.environment).map(s => s.name));
5567
+ return { ok: true, timings: await bootstrap(prepared) };
5568
+ }),
5504
5569
  projectSetup: async () => exclusive(state, "inFlightProjectSetup", "project-setup already in progress", () => runProjectSetup()),
5505
5570
  eval: async (params) => {
5506
5571
  const code = requireString(params, "code");
@@ -0,0 +1,14 @@
1
+ import type { BuildStep } from "./buildkit-progress.js";
2
+ export interface PreparedImages {
3
+ version: 1;
4
+ identity: string;
5
+ images: Record<string, {
6
+ input: string;
7
+ image: string;
8
+ prepMs: number;
9
+ startMs?: number;
10
+ buildSteps?: BuildStep[];
11
+ }>;
12
+ }
13
+ /** Build outputs are required inputs, never a hint to rebuild or pull again. */
14
+ export declare function preparedImages(value: unknown, identity: unknown, names: string[]): PreparedImages;
@@ -0,0 +1,18 @@
1
+ /** Build outputs are required inputs, never a hint to rebuild or pull again. */
2
+ export function preparedImages(value, identity, names) {
3
+ const manifest = value;
4
+ if (!manifest || manifest.version !== 1 || typeof identity !== "string" || !identity || manifest.identity !== identity) {
5
+ throw new Error("prepared image source/SDK identity mismatch");
6
+ }
7
+ if (!manifest.images || typeof manifest.images !== "object" ||
8
+ Object.keys(manifest.images).sort().join("\n") !== [...names].sort().join("\n")) {
9
+ throw new Error("prepared image manifest does not match the environment's services");
10
+ }
11
+ for (const name of names) {
12
+ const entry = manifest.images[name];
13
+ if (!entry || !/^sha256:[a-f0-9]{64}$/.test(entry.image) || !/^[a-f0-9]{64}$/.test(entry.input)) {
14
+ throw new Error(`invalid prepared image for ${name}`);
15
+ }
16
+ }
17
+ return manifest;
18
+ }
@@ -14,7 +14,9 @@ export declare function takeProjectReads(): string[];
14
14
  * the caller's own `fs` error names a real path — `readProjectFile` keeps
15
15
  * reporting ENOENT the way it always has.
16
16
  */
17
- export declare function resolveProjectPath(p: string): string;
17
+ export declare function resolveProjectPath(p: string, opts?: {
18
+ record?: boolean;
19
+ }): string;
18
20
  /** {@link resolveProjectPath}, but a missing file is an error that names every
19
21
  * place we looked. For inputs a user hands us by name — a fixture — where an
20
22
  * ENOENT on one guessed path reads as a spectest bug rather than a typo. */
@@ -74,11 +74,16 @@ function relative(p) {
74
74
  * the caller's own `fs` error names a real path — `readProjectFile` keeps
75
75
  * reporting ENOENT the way it always has.
76
76
  */
77
- export function resolveProjectPath(p) {
77
+ export function resolveProjectPath(p, opts = {}) {
78
78
  if (path.isAbsolute(p))
79
79
  return p;
80
80
  const rel = relative(p);
81
- PROJECT_READS.add(rel);
81
+ // A read is coverage of the file for the case that made it — unless
82
+ // the caller says otherwise: the harness's own bookkeeping reads (a
83
+ // coverage inventory fingerprinting every manifest) are not the
84
+ // suite's dependence on those files.
85
+ if (opts.record !== false)
86
+ PROJECT_READS.add(rel);
82
87
  if (rel.startsWith("spectest/")) {
83
88
  const app = path.join(APP_DIR, rel);
84
89
  if (existsSync(app))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.86.0",
3
+ "version": "0.86.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/daemon.ts CHANGED
@@ -16,9 +16,10 @@
16
16
  // forking the sandbox; inside a single daemon we never run two tests at
17
17
  // once — that keeps stdout capture and timeouts simple.
18
18
 
19
+ import { preparedImages, type PreparedImages } from "./harness/prepared-images.js";
19
20
  import { execFile, spawn } from "node:child_process";
20
21
  import type { ChildProcess } from "node:child_process";
21
- import { randomUUID } from "node:crypto";
22
+ import { createHash, randomUUID } from "node:crypto";
22
23
  import { existsSync, promises as fs, readFileSync } from "node:fs";
23
24
  import net from "node:net";
24
25
  import path from "node:path";
@@ -3512,23 +3513,34 @@ function logBootstrapTimings(t: BootstrapTimings): void {
3512
3513
  console.log(`[bootstrap] total ${t.totalMs}ms across ${t.services.length} service(s)`);
3513
3514
  }
3514
3515
 
3515
- async function bootstrap(): Promise<BootstrapTimings> {
3516
+ async function bootstrap(prepared?: PreparedImages): Promise<BootstrapTimings> {
3516
3517
  // Tee everything this bootstrap logs — ours and every service `setup`
3517
3518
  // hook's — into the boot log, so GET /boot-log can explain a slow or
3518
3519
  // finished boot. Restored in the finally at the end.
3519
3520
  const restoreBootLog = captureConsole(BOOT_LOG_SINK);
3520
3521
  try {
3521
- return await bootstrapInner();
3522
+ return await bootstrapInner(prepared);
3522
3523
  } finally {
3523
3524
  restoreBootLog();
3524
3525
  }
3525
3526
  }
3526
3527
 
3527
- async function bootstrapInner(): Promise<BootstrapTimings> {
3528
+ async function imageInput(svc: NamedService): Promise<string> {
3529
+ const input = svc.image.type === "registry" ? svc.image : await resolveDockerfileBuild(svc);
3530
+ return createHash("sha256").update(JSON.stringify(input)).digest("hex");
3531
+ }
3532
+
3533
+ async function bootstrapInner(prepared?: PreparedImages): Promise<BootstrapTimings>;
3534
+ async function bootstrapInner(prepared: undefined, identity: string): Promise<PreparedImages>;
3535
+ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Promise<BootstrapTimings | PreparedImages> {
3528
3536
  const bootStart = Date.now();
3529
3537
  const cfg: EnvironmentConfig = requireLoaded().project.environment;
3530
3538
  const services = namedServices(cfg);
3531
3539
  const timings = new Map<string, ServiceTiming>();
3540
+ if (prepared && (prepared.version !== 1 || !prepared.images ||
3541
+ Object.keys(prepared.images).sort().join("\n") !== services.map(s => s.name).sort().join("\n"))) {
3542
+ throw new Error("prepared image manifest does not match the environment's services");
3543
+ }
3532
3544
  progressInit(services);
3533
3545
  // Build dedup is only valid within one workspace generation — a fresh
3534
3546
  // bootstrap may follow a workspace re-upload with the same dockerfile
@@ -3579,12 +3591,24 @@ async function bootstrapInner(): Promise<BootstrapTimings> {
3579
3591
  const tags = new Map<string, string>();
3580
3592
  // Bootstrap is the only dedup scope: all its builds share one
3581
3593
  // /workspace generation (see prepareServiceImage).
3582
- const prepImage = (svc: NamedService): Promise<{ tag: string; buildSteps?: BuildStep[] }> =>
3583
- prepareServiceImage(svc, { dedup: true });
3594
+ const prepImage = async (svc: NamedService): Promise<{ tag: string; buildSteps?: BuildStep[] }> => {
3595
+ if (!prepared) return prepareServiceImage(svc, { dedup: true });
3596
+ const entry = prepared.images[svc.name];
3597
+ if (!entry || entry.input !== await imageInput(svc) || !/^sha256:[a-f0-9]{64}$/.test(entry.image)) {
3598
+ throw new Error(`prepared image inputs changed for ${svc.name}`);
3599
+ }
3600
+ const found = await docker(["image", "inspect", "--format", "{{.Id}}", entry.image], 30_000);
3601
+ if (found.code !== 0 || found.stdout.trim() !== entry.image) {
3602
+ throw new Error(`prepared image missing for ${svc.name}: ${entry.image}`);
3603
+ }
3604
+ return { tag: entry.image, buildSteps: entry.buildSteps };
3605
+ };
3584
3606
  const prep = new Map<string, Promise<void>>();
3607
+ const prepOffsets = new Map<string, number>();
3585
3608
  for (const svc of services) {
3586
3609
  const p = (async () => {
3587
3610
  const t0 = Date.now();
3611
+ prepOffsets.set(svc.name, Date.now() - bootStart);
3588
3612
  const { tag, buildSteps } = await prepImage(svc);
3589
3613
  progressService(svc.name, { status: "prepared", detail: undefined });
3590
3614
  tags.set(svc.name, tag);
@@ -3603,6 +3627,22 @@ async function bootstrapInner(): Promise<BootstrapTimings> {
3603
3627
  prep.set(svc.name, p);
3604
3628
  }
3605
3629
 
3630
+ if (identity !== undefined) {
3631
+ // No application services, setup hooks, ingress, or runtime state in the builder.
3632
+ await Promise.all(prep.values());
3633
+ const images: PreparedImages["images"] = Object.create(null);
3634
+ for (const svc of services) {
3635
+ const out = await docker(["image", "inspect", "--format", "{{.Id}}", tags.get(svc.name)!], 30_000);
3636
+ const image = out.stdout.trim();
3637
+ if (out.code !== 0 || !/^sha256:[a-f0-9]{64}$/.test(image)) {
3638
+ throw new Error(`cannot resolve prepared image for ${svc.name}: ${out.stderr}`);
3639
+ }
3640
+ const ti = timings.get(svc.name)!;
3641
+ images[svc.name] = { input: await imageInput(svc), image, prepMs: ti.prepMs, startMs: prepOffsets.get(svc.name), buildSteps: ti.buildSteps };
3642
+ }
3643
+ return { version: 1, identity, images };
3644
+ }
3645
+
3606
3646
  // Ingress (fakes + service-tls proxies) comes up BEFORE services so
3607
3647
  // that any service that calls a fake URL during its own startup
3608
3648
  // probe finds it answering. Service-tls proxies will return 502
@@ -4159,8 +4199,13 @@ async function writeCoverageInventory(svc: NamedService): Promise<string | undef
4159
4199
  },
4160
4200
  projectFiles: () => walkProjectFiles(WORKSPACE),
4161
4201
  async readProjectFile(p: string) {
4202
+ // Not recorded as a read: an inventory fingerprinting every
4203
+ // manifest is bookkeeping, not the suite depending on the files.
4204
+ // Recording them made the setup set name every package.json as a
4205
+ // whole file, so a version bump was "ran during environment
4206
+ // setup" — a full run, the exact case the inventory exists for.
4162
4207
  try {
4163
- return await fs.readFile(resolveProjectPath(p), "utf8");
4208
+ return await fs.readFile(resolveProjectPath(p, { record: false }), "utf8");
4164
4209
  } catch {
4165
4210
  return null;
4166
4211
  }
@@ -4189,9 +4234,21 @@ async function writeCoverageInventory(svc: NamedService): Promise<string | undef
4189
4234
  * The project's reach (`harness/reach.ts`): every project file some
4190
4235
  * service's build context includes, some volume mounts, or a build
4191
4236
  * reads. Computed from the same resolution the builds use, once at the
4192
- * bring-up capture.
4237
+ * bring-up capture. The resolution's own path lookups are bookkeeping,
4238
+ * not reads: they are taken out of the read set again afterwards.
4193
4239
  */
4194
4240
  async function computeProjectReach(all: NamedService[]): Promise<string[]> {
4241
+ const before = takeProjectReads();
4242
+ try {
4243
+ return await computeProjectReachInner(all);
4244
+ } finally {
4245
+ // Discard what the resolution recorded, restore what was there.
4246
+ takeProjectReads();
4247
+ for (const p of before) resolveProjectPath(p);
4248
+ }
4249
+ }
4250
+
4251
+ async function computeProjectReachInner(all: NamedService[]): Promise<string[]> {
4195
4252
  const files = await walkProjectFiles(WORKSPACE, [".git"]);
4196
4253
  const sources: ReachSource[] = [];
4197
4254
  for (const svc of all) {
@@ -6484,7 +6541,7 @@ function loadedSummary(l: ReturnType<typeof requireLoaded>) {
6484
6541
  */
6485
6542
  export function harnessMethods(state: RouteState): MethodTable {
6486
6543
  return {
6487
- health: async () => ({ ok: true }),
6544
+ health: async () => ({ ok: true, capabilities: ["prepared-images-v1"] }),
6488
6545
 
6489
6546
  // Live bootstrap progress, polled by the control plane during
6490
6547
  // `bootstrap` and streamed into the test-run row. `{}` before the
@@ -6593,11 +6650,20 @@ export function harnessMethods(state: RouteState): MethodTable {
6593
6650
  return { refs: [...refs] };
6594
6651
  },
6595
6652
 
6596
- bootstrap: async () =>
6597
- exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => ({
6598
- ok: true,
6599
- timings: await bootstrap(),
6600
- })),
6653
+ prepareImages: async (params) =>
6654
+ exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => {
6655
+ const restore = captureConsole(BOOT_LOG_SINK);
6656
+ try { return await bootstrapInner(undefined, requireString(params, "identity")); }
6657
+ finally { restore(); }
6658
+ }),
6659
+
6660
+ bootstrap: async (params) =>
6661
+ exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => {
6662
+ const prepared = params.prepared === undefined ? undefined : preparedImages(
6663
+ params.prepared, params.identity, namedServices(requireLoaded().project.environment).map(s => s.name),
6664
+ );
6665
+ return { ok: true, timings: await bootstrap(prepared) };
6666
+ }),
6601
6667
 
6602
6668
  projectSetup: async () =>
6603
6669
  exclusive(
@@ -0,0 +1,22 @@
1
+ import { expect, test } from "bun:test";
2
+ import { preparedImages } from "./prepared-images";
3
+
4
+ const entry = { input: "a".repeat(64), image: `sha256:${"b".repeat(64)}`, prepMs: 12 };
5
+ const manifest = () => ({ version: 1, identity: "source-and-sdk", images: { api: entry } });
6
+
7
+ test("handoff pins immutable images and rejects a result from another source or SDK", () => {
8
+ expect(preparedImages(manifest(), "source-and-sdk", ["api"]).images.api.image).toBe(entry.image);
9
+ expect(() => preparedImages(manifest(), "different-source", ["api"])).toThrow(/identity/);
10
+ });
11
+
12
+ test("a missing or added service cannot silently fall back to an in-runtime build", () => {
13
+ expect(() => preparedImages(manifest(), "source-and-sdk", ["api", "worker"])).toThrow(/services/);
14
+ expect(() => preparedImages(manifest(), "source-and-sdk", [])).toThrow(/services/);
15
+ });
16
+
17
+ test("mutable image tags and malformed manifests are rejected", () => {
18
+ const m = manifest();
19
+ m.images.api = { ...entry, image: "spectest/api:latest" };
20
+ expect(() => preparedImages(m, "source-and-sdk", ["api"])).toThrow(/invalid/);
21
+ expect(() => preparedImages(null, "source-and-sdk", ["api"])).toThrow();
22
+ });
@@ -0,0 +1,26 @@
1
+ import type { BuildStep } from "./buildkit-progress.js";
2
+
3
+ export interface PreparedImages {
4
+ version: 1;
5
+ identity: string;
6
+ images: Record<string, { input: string; image: string; prepMs: number; startMs?: number; buildSteps?: BuildStep[] }>;
7
+ }
8
+
9
+ /** Build outputs are required inputs, never a hint to rebuild or pull again. */
10
+ export function preparedImages(value: unknown, identity: unknown, names: string[]): PreparedImages {
11
+ const manifest = value as PreparedImages | null;
12
+ if (!manifest || manifest.version !== 1 || typeof identity !== "string" || !identity || manifest.identity !== identity) {
13
+ throw new Error("prepared image source/SDK identity mismatch");
14
+ }
15
+ if (!manifest.images || typeof manifest.images !== "object" ||
16
+ Object.keys(manifest.images).sort().join("\n") !== [...names].sort().join("\n")) {
17
+ throw new Error("prepared image manifest does not match the environment's services");
18
+ }
19
+ for (const name of names) {
20
+ const entry = manifest.images[name];
21
+ if (!entry || !/^sha256:[a-f0-9]{64}$/.test(entry.image) || !/^[a-f0-9]{64}$/.test(entry.input)) {
22
+ throw new Error(`invalid prepared image for ${name}`);
23
+ }
24
+ }
25
+ return manifest;
26
+ }
@@ -80,10 +80,14 @@ function relative(p: string): string {
80
80
  * the caller's own `fs` error names a real path — `readProjectFile` keeps
81
81
  * reporting ENOENT the way it always has.
82
82
  */
83
- export function resolveProjectPath(p: string): string {
83
+ export function resolveProjectPath(p: string, opts: { record?: boolean } = {}): string {
84
84
  if (path.isAbsolute(p)) return p;
85
85
  const rel = relative(p);
86
- PROJECT_READS.add(rel);
86
+ // A read is coverage of the file for the case that made it — unless
87
+ // the caller says otherwise: the harness's own bookkeeping reads (a
88
+ // coverage inventory fingerprinting every manifest) are not the
89
+ // suite's dependence on those files.
90
+ if (opts.record !== false) PROJECT_READS.add(rel);
87
91
  if (rel.startsWith("spectest/")) {
88
92
  const app = path.join(APP_DIR, rel);
89
93
  if (existsSync(app)) return app;