diff --git a/qa/release-v2/README.md b/qa/release-v2/README.md index f4e41cf25..c02a17b9c 100644 --- a/qa/release-v2/README.md +++ b/qa/release-v2/README.md @@ -80,7 +80,7 @@ Screenshots and traces stay untracked under `.screenshots/release-v2// The decision is `GO` only when there are no P0/P1 findings and no critical gaps; otherwise it is `NO-GO`. The report command itself treats missing, null, malformed, wrong-candidate, incomplete, assignment-inconsistent, or case-rollup-inconsistent metadata as an explicit critical metadata gap. It does not depend on a separate verification run to force `NO-GO`. -Aggregation refuses symlinked packet reports, `ledger.json`, and `master-report.md`. Artifact and evidence paths must exist, be absolute, and ultimately resolve inside the assigned `.screenshots/release-v2//` directory; a lexically contained symlink that resolves outside is rejected. +Aggregation refuses symlinked packet reports, `ledger.json`, and `master-report.md`. Primary report artifacts plus finding and performance evidence must be existing regular files under the packet's assigned `.screenshots/release-v2//` directory. Independent reproduction evidence may use an existing regular file from any agent directory under the canonical `.screenshots/release-v2/` root. Every artifact path must be absolute and symlink-free; paths that escape the applicable root are rejected. ## Native browser zoom diff --git a/scripts/release-v2-qa/geometry.mts b/scripts/release-v2-qa/geometry.mts index 1493ee886..d102c19ad 100644 --- a/scripts/release-v2-qa/geometry.mts +++ b/scripts/release-v2-qa/geometry.mts @@ -52,11 +52,31 @@ const placementIsValid = (placement: GeometryPlacement) => placement.width >= 1 && placement.height >= 1; +const placementsOverlap = (first: GeometryPlacement, second: GeometryPlacement) => + first.xOffset < second.xOffset + second.width && + second.xOffset < first.xOffset + first.width && + first.yOffset < second.yOffset + second.height && + second.yOffset < first.yOffset + first.height; + +const compareItemLayouts = (first: GeometryItemLayout, second: GeometryItemLayout) => { + if (first.itemId < second.itemId) return -1; + if (first.itemId > second.itemId) return 1; + return 0; +}; + export const findFixtureGeometryErrors = (geometry: FixtureGeometry) => { const errors: string[] = []; const layoutsById = new Map(geometry.layouts.map((layout) => [layout.id, layout])); const sectionsById = new Map(geometry.sections.map((section) => [section.id, section])); const sectionLayoutsByKey = new Map(); + const itemsByDomain = new Map(); + + const addItemLayout = (itemLayout: GeometryItemLayout) => { + const key = `${itemLayout.layoutId}\0${itemLayout.sectionId}`; + const itemLayouts = itemsByDomain.get(key) ?? []; + itemLayouts.push(itemLayout); + itemsByDomain.set(key, itemLayouts); + }; for (const sectionLayout of geometry.sectionLayouts) { const key = `${sectionLayout.layoutId}\0${sectionLayout.sectionId}`; @@ -145,6 +165,26 @@ export const findFixtureGeometryErrors = (geometry: FixtureGeometry) => { errors.push( `item ${itemLayout.itemId} in layout ${layout.id} exceeds its ${capacity.columns}-column parent ${capacity.kind}`, ); + continue; + } + addItemLayout(itemLayout); + } + + const sortedDomains = [...itemsByDomain.entries()].toSorted(([first], [second]) => { + if (first < second) return -1; + if (first > second) return 1; + return 0; + }); + for (const [domain, itemLayouts] of sortedDomains) { + const separatorIndex = domain.indexOf("\0"); + const layoutId = domain.slice(0, separatorIndex); + const sectionId = domain.slice(separatorIndex + 1); + const sortedItemLayouts = itemLayouts.toSorted(compareItemLayouts); + for (const [index, first] of sortedItemLayouts.entries()) { + for (const second of sortedItemLayouts.slice(index + 1)) { + if (!placementsOverlap(first, second)) continue; + errors.push(`item ${first.itemId} overlaps item ${second.itemId} in layout ${layoutId} section ${sectionId}`); + } } } diff --git a/scripts/release-v2-qa/geometry.spec.ts b/scripts/release-v2-qa/geometry.spec.ts index f0dd9c97c..9b6aa3956 100644 --- a/scripts/release-v2-qa/geometry.spec.ts +++ b/scripts/release-v2-qa/geometry.spec.ts @@ -66,6 +66,149 @@ const baseGeometry = (): FixtureGeometry => ({ }); describe("release-v2 QA fixture geometry", () => { + test("rejects exact duplicate item placements in the same section", () => { + const geometry = baseGeometry(); + geometry.itemLayouts.push({ + itemId: "duplicate-item", + boardId: "board", + sectionId: "level-3", + layoutId: "layout-mobile", + xOffset: 0, + yOffset: 0, + width: 1, + height: 2, + }); + + expect(findFixtureGeometryErrors(geometry)).toContain( + "item duplicate-item overlaps item nested-item in layout layout-mobile section level-3", + ); + }); + + test("rejects partial item overlaps in the same section", () => { + const geometry = baseGeometry(); + geometry.sections = [{ id: "root", boardId: "board", kind: "empty", xOffset: 0 }]; + geometry.sectionLayouts = []; + geometry.itemLayouts = [ + { + itemId: "first-item", + boardId: "board", + sectionId: "root", + layoutId: "layout-mobile", + xOffset: 0, + yOffset: 0, + width: 2, + height: 2, + }, + { + itemId: "second-item", + boardId: "board", + sectionId: "root", + layoutId: "layout-mobile", + xOffset: 1, + yOffset: 1, + width: 2, + height: 2, + }, + ]; + + expect(findFixtureGeometryErrors(geometry)).toContain( + "item first-item overlaps item second-item in layout layout-mobile section root", + ); + }); + + test("accepts tightly packed items that only share edges", () => { + const geometry = baseGeometry(); + geometry.sections = [{ id: "root", boardId: "board", kind: "empty", xOffset: 0 }]; + geometry.sectionLayouts = []; + geometry.itemLayouts = [ + { + itemId: "top-left", + boardId: "board", + sectionId: "root", + layoutId: "layout-mobile", + xOffset: 0, + yOffset: 0, + width: 1, + height: 2, + }, + { + itemId: "top-right", + boardId: "board", + sectionId: "root", + layoutId: "layout-mobile", + xOffset: 1, + yOffset: 0, + width: 2, + height: 2, + }, + { + itemId: "bottom", + boardId: "board", + sectionId: "root", + layoutId: "layout-mobile", + xOffset: 0, + yOffset: 2, + width: 3, + height: 1, + }, + ]; + + expect(findFixtureGeometryErrors(geometry)).toEqual([]); + }); + + test("does not compare local coordinates from different parent sections", () => { + const geometry = baseGeometry(); + geometry.sections = [ + { id: "root", boardId: "board", kind: "empty", xOffset: 0 }, + { id: "left-container", boardId: "board", kind: "container", xOffset: null }, + { id: "right-container", boardId: "board", kind: "container", xOffset: null }, + ]; + geometry.sectionLayouts = [ + { + sectionId: "left-container", + layoutId: "layout-mobile", + parentSectionId: "root", + xOffset: 0, + yOffset: 0, + width: 1, + height: 2, + }, + { + sectionId: "right-container", + layoutId: "layout-mobile", + parentSectionId: "root", + xOffset: 1, + yOffset: 0, + width: 2, + height: 2, + }, + ]; + geometry.itemLayouts = [ + { + itemId: "left-item", + boardId: "board", + sectionId: "left-container", + layoutId: "layout-mobile", + xOffset: 0, + yOffset: 0, + width: 1, + height: 1, + }, + { + itemId: "right-item", + boardId: "board", + sectionId: "right-container", + layoutId: "layout-mobile", + xOffset: 0, + yOffset: 0, + width: 1, + height: 1, + }, + ]; + + expect(findFixtureGeometryErrors(geometry)).toEqual([]); + }); + test("rejects an item that fits total columns but overflows the gutter-reduced main lane", () => { const geometry = baseGeometry(); geometry.layouts[0] = { diff --git a/scripts/release-v2-qa/report-path-integrity.mts b/scripts/release-v2-qa/report-path-integrity.mts index 9c8a7e4a0..9fd08524b 100644 --- a/scripts/release-v2-qa/report-path-integrity.mts +++ b/scripts/release-v2-qa/report-path-integrity.mts @@ -35,23 +35,39 @@ export const writeSafeReportFile = async ( } }; -export const validateResolvedArtifactPath = async ( - expectedPacketDirectory: string, +interface ArtifactDirectoryMessages { + absolutePathRequirement: string; + directoryName: string; +} + +const packetArtifactDirectoryMessages: ArtifactDirectoryMessages = { + absolutePathRequirement: "the packet's absolute artifact directory", + directoryName: "packet artifact directory", +}; + +const reproductionArtifactDirectoryMessages: ArtifactDirectoryMessages = { + absolutePathRequirement: "the absolute release-v2 artifact directory", + directoryName: "release-v2 artifact directory", +}; + +const validateResolvedArtifactPathWithin = async ( + expectedDirectory: string, artifactPath: string, label: string, + messages: ArtifactDirectoryMessages, ): Promise => { - if (!path.isAbsolute(artifactPath) || !isPathWithin(expectedPacketDirectory, artifactPath)) { - return `${label} must use the packet's absolute artifact directory`; + if (!path.isAbsolute(artifactPath) || !isPathWithin(expectedDirectory, artifactPath)) { + return `${label} must use ${messages.absolutePathRequirement}`; } - let resolvedPacketDirectory: string; + let resolvedExpectedDirectory: string; try { - resolvedPacketDirectory = await realpath(expectedPacketDirectory); + resolvedExpectedDirectory = await realpath(expectedDirectory); } catch (error) { - return `${label} packet artifact directory does not resolve (${errorCode(error) ?? "unknown error"})`; + return `${label} ${messages.directoryName} does not resolve (${errorCode(error) ?? "unknown error"})`; } - if (resolvedPacketDirectory !== path.resolve(expectedPacketDirectory)) { - return `${label} packet artifact directory must not contain symlinks`; + if (resolvedExpectedDirectory !== path.resolve(expectedDirectory)) { + return `${label} ${messages.directoryName} must not contain symlinks`; } let resolvedArtifact: string; @@ -60,11 +76,11 @@ export const validateResolvedArtifactPath = async ( } catch (error) { return `${label} does not resolve (${errorCode(error) ?? "unknown error"})`; } - if (!isPathWithin(resolvedPacketDirectory, resolvedArtifact)) { - return `${label} resolves outside the packet artifact directory`; + if (!isPathWithin(resolvedExpectedDirectory, resolvedArtifact)) { + return `${label} resolves outside the ${messages.directoryName}`; } try { - await assertSafeContainedPath(resolvedPacketDirectory, artifactPath, label); + await assertSafeContainedPath(resolvedExpectedDirectory, artifactPath, label); const artifactStats = await lstat(artifactPath); if (!artifactStats.isFile()) return `${label} must resolve to an existing regular file`; } catch (error) { @@ -72,3 +88,22 @@ export const validateResolvedArtifactPath = async ( } return null; }; + +export const validateResolvedArtifactPath = async ( + expectedPacketDirectory: string, + artifactPath: string, + label: string, +): Promise => + validateResolvedArtifactPathWithin(expectedPacketDirectory, artifactPath, label, packetArtifactDirectoryMessages); + +export const validateResolvedReproductionEvidencePath = async ( + releaseArtifactDirectory: string, + artifactPath: string, + label: string, +): Promise => + validateResolvedArtifactPathWithin( + releaseArtifactDirectory, + artifactPath, + label, + reproductionArtifactDirectoryMessages, + ); diff --git a/scripts/release-v2-qa/report-path-integrity.spec.ts b/scripts/release-v2-qa/report-path-integrity.spec.ts index 298d71e50..e0435dc5a 100644 --- a/scripts/release-v2-qa/report-path-integrity.spec.ts +++ b/scripts/release-v2-qa/report-path-integrity.spec.ts @@ -11,6 +11,7 @@ import { assertSafeReportPath, readSafeReportFile, validateResolvedArtifactPath, + validateResolvedReproductionEvidencePath, writeSafeReportFile, } from "./report-path-integrity.mts"; @@ -46,7 +47,8 @@ describe("release-v2 QA report path integrity", () => { it("rejects an artifact symlink that ultimately escapes its packet directory", async () => { const root = await temporaryDirectory("artifact-escape"); - const packetDirectory = path.join(root, ".screenshots", "release-v2", "preflight-01"); + const releaseArtifactDirectory = path.join(root, ".screenshots", "release-v2"); + const packetDirectory = path.join(releaseArtifactDirectory, "preflight-01"); const outsideArtifact = path.join(root, "outside.png"); const linkedArtifact = path.join(packetDirectory, "escape.png"); await mkdir(packetDirectory, { recursive: true }); @@ -56,6 +58,13 @@ describe("release-v2 QA report path integrity", () => { await expect( validateResolvedArtifactPath(packetDirectory, linkedArtifact, "preflight-01: artifact"), ).resolves.toContain("resolves outside"); + await expect( + validateResolvedReproductionEvidencePath( + releaseArtifactDirectory, + linkedArtifact, + "preflight-01: reproduction evidence", + ), + ).resolves.toContain("resolves outside"); }); it("accepts a resolved regular artifact inside the packet directory", async () => { @@ -68,9 +77,28 @@ describe("release-v2 QA report path integrity", () => { await expect(validateResolvedArtifactPath(packetDirectory, artifact, "preflight-01: artifact")).resolves.toBeNull(); }); + it("accepts independent reproduction evidence from another release-v2 agent directory", async () => { + const root = await temporaryDirectory("reproduction-artifact-valid"); + const releaseArtifactDirectory = path.join(root, ".screenshots", "release-v2"); + const packetDirectory = path.join(releaseArtifactDirectory, "board-03"); + const reproductionDirectory = path.join(releaseArtifactDirectory, "repro-board03-remove-358d-b"); + const artifact = path.join(reproductionDirectory, "remove-confirmation.png"); + await mkdir(packetDirectory, { recursive: true }); + await mkdir(reproductionDirectory, { recursive: true }); + await writeFile(artifact, "valid"); + + await expect( + validateResolvedReproductionEvidencePath(releaseArtifactDirectory, artifact, "board-03: reproduction evidence"), + ).resolves.toBeNull(); + await expect( + validateResolvedArtifactPath(packetDirectory, artifact, "board-03: finding evidence"), + ).resolves.toContain("packet's absolute artifact directory"); + }); + it("rejects an artifact directory and an in-directory artifact symlink", async () => { const root = await temporaryDirectory("artifact-not-file"); - const packetDirectory = path.join(root, ".screenshots", "release-v2", "preflight-01"); + const releaseArtifactDirectory = path.join(root, ".screenshots", "release-v2"); + const packetDirectory = path.join(releaseArtifactDirectory, "preflight-01"); const artifactDirectory = path.join(packetDirectory, "looks-like-evidence.png"); const realArtifact = path.join(packetDirectory, "real.png"); const linkedArtifact = path.join(packetDirectory, "linked.png"); @@ -84,11 +112,46 @@ describe("release-v2 QA report path integrity", () => { await expect( validateResolvedArtifactPath(packetDirectory, linkedArtifact, "preflight-01: artifact"), ).resolves.toContain("symlink"); + await expect( + validateResolvedReproductionEvidencePath( + releaseArtifactDirectory, + artifactDirectory, + "preflight-01: reproduction evidence", + ), + ).resolves.toContain("regular file"); + await expect( + validateResolvedReproductionEvidencePath( + releaseArtifactDirectory, + linkedArtifact, + "preflight-01: reproduction evidence", + ), + ).resolves.toContain("symlink"); + }); + + it("rejects relative, escaping, and missing independent reproduction evidence paths", async () => { + const root = await temporaryDirectory("reproduction-artifact-unsafe"); + const releaseArtifactDirectory = path.join(root, ".screenshots", "release-v2"); + const reproductionDirectory = path.join(releaseArtifactDirectory, "reproducer-01"); + const outsideArtifact = path.join(root, "outside.png"); + const missingArtifact = path.join(reproductionDirectory, "missing.png"); + await mkdir(reproductionDirectory, { recursive: true }); + await writeFile(outsideArtifact, "outside"); + + for (const artifact of ["relative.png", outsideArtifact, missingArtifact]) { + await expect( + validateResolvedReproductionEvidencePath( + releaseArtifactDirectory, + artifact, + "preflight-01: reproduction evidence", + ), + ).resolves.not.toBeNull(); + } }); it.runIf(process.platform !== "win32")("rejects a FIFO artifact", async () => { const root = await temporaryDirectory("artifact-fifo"); - const packetDirectory = path.join(root, ".screenshots", "release-v2", "preflight-01"); + const releaseArtifactDirectory = path.join(root, ".screenshots", "release-v2"); + const packetDirectory = path.join(releaseArtifactDirectory, "preflight-01"); const fifoArtifact = path.join(packetDirectory, "capture.pipe"); await mkdir(packetDirectory, { recursive: true }); const result = spawnSync("mkfifo", [fifoArtifact]); @@ -97,11 +160,19 @@ describe("release-v2 QA report path integrity", () => { await expect( validateResolvedArtifactPath(packetDirectory, fifoArtifact, "preflight-01: artifact"), ).resolves.toContain("regular file"); + await expect( + validateResolvedReproductionEvidencePath( + releaseArtifactDirectory, + fifoArtifact, + "preflight-01: reproduction evidence", + ), + ).resolves.toContain("regular file"); }); it.runIf(process.platform !== "win32")("rejects a socket artifact", async () => { const root = await temporaryDirectory("artifact-socket"); - const packetDirectory = path.join(root, ".screenshots", "release-v2", "preflight-01"); + const releaseArtifactDirectory = path.join(root, ".screenshots", "release-v2"); + const packetDirectory = path.join(releaseArtifactDirectory, "preflight-01"); const socketArtifact = path.join(packetDirectory, "capture.sock"); await mkdir(packetDirectory, { recursive: true }); const server = createServer(); @@ -113,6 +184,13 @@ describe("release-v2 QA report path integrity", () => { await expect( validateResolvedArtifactPath(packetDirectory, socketArtifact, "preflight-01: artifact"), ).resolves.toContain("regular file"); + await expect( + validateResolvedReproductionEvidencePath( + releaseArtifactDirectory, + socketArtifact, + "preflight-01: reproduction evidence", + ), + ).resolves.toContain("regular file"); } finally { await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); } diff --git a/scripts/release-v2-qa/report.mts b/scripts/release-v2-qa/report.mts index 97edafa3a..4311188fb 100644 --- a/scripts/release-v2-qa/report.mts +++ b/scripts/release-v2-qa/report.mts @@ -14,6 +14,7 @@ import { assertSafeReportPath, readSafeReportFile, validateResolvedArtifactPath, + validateResolvedReproductionEvidencePath, writeSafeReportFile, } from "./report-path-integrity.mts"; import { resolveCheckoutCandidateSha } from "./provenance.mts"; @@ -436,8 +437,9 @@ const collectHumanReportErrors = (content: string, packet: Packet, metadata: Rep }; const collectArtifactErrors = async (packet: Packet, metadata: ReportMetadata): Promise => { - const packetArtifactDirectory = resolve(repoRoot, ".screenshots/release-v2", packet.id); - const artifacts = [ + const releaseArtifactDirectory = resolve(repoRoot, ".screenshots/release-v2"); + const packetArtifactDirectory = resolve(releaseArtifactDirectory, packet.id); + const packetArtifacts = [ ...metadata.artifacts.map((artifact) => ({ artifact, label: `${packet.id}: artifact` })), ...metadata.findings.flatMap((finding, findingIndex) => (finding.evidence ?? []).map((artifact) => ({ @@ -451,19 +453,23 @@ const collectArtifactErrors = async (packet: Packet, metadata: ReportMetadata): label: `${packet.id}: performance.measurements[${measurementIndex}] evidence`, })), ), - ...metadata.independentReproductions.flatMap((reproduction, reproductionIndex) => - reproduction.evidence.map((artifact) => ({ - artifact, - label: `${packet.id}: independentReproductions[${reproductionIndex}] evidence`, - })), - ), ]; const errors: string[] = []; - for (const { artifact, label } of artifacts) { + for (const { artifact, label } of packetArtifacts) { const error = await validateResolvedArtifactPath(packetArtifactDirectory, artifact, label); if (error) errors.push(error); } + for (const [reproductionIndex, reproduction] of metadata.independentReproductions.entries()) { + for (const artifact of reproduction.evidence) { + const error = await validateResolvedReproductionEvidencePath( + releaseArtifactDirectory, + artifact, + `${packet.id}: independentReproductions[${reproductionIndex}] evidence`, + ); + if (error) errors.push(error); + } + } return errors; }; diff --git a/scripts/release-v2-qa/seed.mts b/scripts/release-v2-qa/seed.mts index 9f9ab52d4..0f1366c76 100644 --- a/scripts/release-v2-qa/seed.mts +++ b/scripts/release-v2-qa/seed.mts @@ -670,6 +670,48 @@ const packPositions = (itemDefinitions: ItemDefinition[], board: BoardDefinition }); }; +const denseCollisionPlacementGeometry = (columnCount: number) => { + if (columnCount < 3) throw new Error("The dense collision fixture requires at least three columns"); + if (columnCount === 3) { + return [ + { xOffset: 0, yOffset: 0, width: 1, height: 2 }, + { xOffset: 1, yOffset: 0, width: 2, height: 1 }, + { xOffset: 1, yOffset: 1, width: 1, height: 1 }, + { xOffset: 0, yOffset: 2, width: 2, height: 1 }, + { xOffset: 2, yOffset: 2, width: 1, height: 2 }, + { xOffset: 0, yOffset: 3, width: 1, height: 1 }, + { xOffset: 0, yOffset: 4, width: 3, height: 1 }, + { xOffset: 0, yOffset: 5, width: 1, height: 2 }, + { xOffset: 1, yOffset: 5, width: 1, height: 1 }, + { xOffset: 2, yOffset: 5, width: 1, height: 2 }, + { xOffset: 1, yOffset: 6, width: 1, height: 1 }, + { xOffset: 0, yOffset: 7, width: 2, height: 2 }, + ]; + } + + const laneBoundary = (lane: number) => Math.floor((columnCount * lane) / 4); + const fromLanes = (startLane: number, endLane: number, yOffset: number, height: number) => ({ + xOffset: laneBoundary(startLane), + yOffset, + width: laneBoundary(endLane) - laneBoundary(startLane), + height, + }); + return [ + fromLanes(0, 1, 0, 2), + fromLanes(1, 3, 0, 1), + fromLanes(3, 4, 0, 2), + fromLanes(1, 2, 1, 1), + fromLanes(0, 2, 2, 1), + fromLanes(2, 3, 2, 2), + fromLanes(3, 4, 2, 1), + fromLanes(0, 1, 3, 2), + fromLanes(1, 2, 3, 1), + fromLanes(3, 4, 3, 2), + fromLanes(1, 3, 4, 1), + fromLanes(0, 3, 5, 2), + ]; +}; + const itemForKind = ( boardHandle: string, handle: string, @@ -1062,22 +1104,23 @@ const seedBoardsAsync = async (optionsByKind: Map) => { } if (board.handle === "dense-collisions") { positionRows.push( - ...boardLayouts(board).flatMap((layout) => - boardItems.map((item, index) => { - const columnCount = mainLaneColumnCount(layout); - const width = Math.min(columnCount, 2 + (index % 3)); + ...boardLayouts(board).flatMap((layout) => { + const placements = denseCollisionPlacementGeometry(mainLaneColumnCount(layout)); + if (placements.length !== boardItems.length) { + throw new Error(`Dense collision placement count does not match the item count in layout ${layout.handle}`); + } + return boardItems.map((item, index) => { + const placement = placements.at(index); + if (!placement) throw new Error(`Missing dense collision placement ${index + 1}`); return { boardHandle: board.handle, itemHandle: item.handle, layoutHandle: layout.handle, sectionHandle: "root", - xOffset: Math.min(index % 3, columnCount - width), - yOffset: index % 2, - width, - height: 2 + (index % 2), + ...placement, }; - }), - ), + }); + }), ); } else { positionRows.push(...packPositions(boardItems, board)); diff --git a/scripts/release-v2-qa/verify.mts b/scripts/release-v2-qa/verify.mts index 896647307..3aa1980de 100644 --- a/scripts/release-v2-qa/verify.mts +++ b/scripts/release-v2-qa/verify.mts @@ -18,7 +18,12 @@ import type { } from "./permissions.mts"; import { resolveCheckoutCandidateSha } from "./provenance.mts"; import { collectHumanWidgetStatusErrors, normalizeReportMetadata } from "./report-integrity.mts"; -import { assertSafeReportPath, readSafeReportFile, validateResolvedArtifactPath } from "./report-path-integrity.mts"; +import { + assertSafeReportPath, + readSafeReportFile, + validateResolvedArtifactPath, + validateResolvedReproductionEvidencePath, +} from "./report-path-integrity.mts"; import { createCandidateBuildPaths, validateRuntimeExecutionContract } from "./runtime.mts"; import { assertSafeRunRoot, isPathWithin, validateFixtureUrl } from "./safety.mts"; @@ -1152,8 +1157,8 @@ const main = async (): Promise => { errors.push(`${packet.id}: independent reproduction has invalid outcome`); } for (const evidence of reproduction.evidence ?? []) { - const artifactError = await validateResolvedArtifactPath( - resolve(repoRoot, ".screenshots/release-v2", packet.id), + const artifactError = await validateResolvedReproductionEvidencePath( + resolve(repoRoot, ".screenshots/release-v2"), evidence, `${packet.id}: reproduction evidence`, );