diff --git a/package.json b/package.json index e54d6fc..7346bc7 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "artifact-bundle-verifier-smoke": "tsx scripts/artifact-bundle-verifier-smoke.ts", "artifact-redaction-smoke": "tsx scripts/artifact-redaction-smoke.ts", "artifact-patch-git-apply-smoke": "tsx scripts/artifact-patch-git-apply-smoke.ts", + "mounted-workspace-diff-smoke": "tsx scripts/mounted-workspace-diff-smoke.ts", "policy-validation-smoke": "tsx scripts/policy-validation-smoke.ts", "workspace-policy-smoke": "tsx scripts/workspace-policy-smoke.ts", "artifact-contract-smoke": "tsx scripts/artifact-contract-smoke.ts", diff --git a/packages/runtime-playground/src/artifact-bundle-builder.ts b/packages/runtime-playground/src/artifact-bundle-builder.ts index 44f061b..46dbc56 100644 --- a/packages/runtime-playground/src/artifact-bundle-builder.ts +++ b/packages/runtime-playground/src/artifact-bundle-builder.ts @@ -115,7 +115,7 @@ export class ArtifactBundleBuilder { .map((ref) => artifactManifestFile(join(source.artifactRoot, ref.path), "runtime-snapshot", "application/json")), ) const capturedMounts = await source.captureMountedFiles(filesDirectory, redactor) - const { mountDiffs, changedFiles, patch } = await source.captureMountDiffs(filesDirectory, redactor) + const { mountDiffs, changedFiles, patch, diagnostics: mountDiffDiagnostics } = await source.captureMountDiffs(filesDirectory, redactor) const changedFilesJson = redactor.redact("files/changed-files.json", `${JSON.stringify(changedFiles, null, 2)}\n`) const redactedPatch = redactor.redact("files/patch.diff", patch) const contentDigest = artifactContentDigest(changedFilesJson, redactedPatch) @@ -143,6 +143,13 @@ export class ArtifactBundleBuilder { } source.recordArtifactsCollected(bundleId, createdAt, spec) const diagnostics = buildArtifactDiagnostics(source.observations) + diagnostics.diagnostics.push(...mountDiffDiagnostics) + diagnostics.summary.total = diagnostics.diagnostics.length + diagnostics.summary.error = diagnostics.diagnostics.filter((diagnostic) => diagnostic.severity === "error").length + diagnostics.summary.warning = diagnostics.diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length + diagnostics.summary.notice = diagnostics.diagnostics.filter((diagnostic) => diagnostic.severity === "notice").length + diagnostics.summary.info = diagnostics.diagnostics.filter((diagnostic) => diagnostic.severity === "info").length + diagnostics.status = diagnostics.summary.total > 0 ? "reported" : "clean" const testResults = buildTestResults() const review = buildArtifactReview({ artifactId: bundleId, diff --git a/packages/runtime-playground/src/artifacts.ts b/packages/runtime-playground/src/artifacts.ts index 99f5ee2..f4f00ef 100644 --- a/packages/runtime-playground/src/artifacts.ts +++ b/packages/runtime-playground/src/artifacts.ts @@ -54,9 +54,12 @@ export interface MountDiff { mountIndex: number source: string target: string - baselineSource: string + baselineSource?: string artifactPath: string changed: boolean + status: "changed" | "unchanged" | "skipped" | "failed" + reason?: string + error?: string } export interface ChangedFile { @@ -82,6 +85,7 @@ export interface MountDiffsResult { mountDiffs: MountDiff[] changedFiles: CanonicalChangedFiles patch: string + diagnostics: ArtifactDiagnostic[] } interface RedactionResult { diff --git a/packages/runtime-playground/src/mounted-artifact-capture.ts b/packages/runtime-playground/src/mounted-artifact-capture.ts index 6220240..3979346 100644 --- a/packages/runtime-playground/src/mounted-artifact-capture.ts +++ b/packages/runtime-playground/src/mounted-artifact-capture.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto" import { copyFile, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises" import { basename, dirname, join } from "node:path" -import type { MountSpec } from "@chubes4/wp-codebox-core" +import type { ArtifactDiagnostic, MountSpec } from "@chubes4/wp-codebox-core" import { MAX_CAPTURED_MOUNT_FILE_BYTES, MAX_CAPTURED_MOUNT_FILES, @@ -52,15 +52,60 @@ export async function captureMountDiffs(artifactRoot: string, filesDirectory: st const diffs: MountDiff[] = [] const changedFiles: ChangedFile[] = [] const patches: string[] = [] + const diagnostics: ArtifactDiagnostic[] = [] for (const [mountIndex, mount] of mounts.entries()) { const baselineSource = typeof mount.metadata?.baselineSource === "string" ? mount.metadata.baselineSource : "" - if (mount.mode !== "readwrite" || !baselineSource) { + if (mount.mode !== "readwrite") { continue } - const diff = await directoryDiff(baselineSource, mount.source, mount.target) const artifactPath = `files/diffs/mount-${mountIndex}.patch` + if (!baselineSource) { + await writeFile(join(artifactRoot, artifactPath), "") + diffs.push({ + mountIndex, + source: mount.source, + target: mount.target, + artifactPath, + changed: false, + status: "skipped", + reason: "missing-baseline-source", + }) + continue + } + + let diff: Awaited> + try { + diff = await directoryDiff(baselineSource, mount.source, mount.target) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await writeFile(join(artifactRoot, artifactPath), "") + diffs.push({ + mountIndex, + source: mount.source, + target: mount.target, + baselineSource, + artifactPath, + changed: false, + status: "failed", + reason: "diff-extraction-failed", + error: message, + }) + diagnostics.push({ + id: `mount-${mountIndex}-diff-extraction-failed`, + type: "mount-diff-extraction-failed", + severity: "error", + message: `Failed to compare mounted workspace ${mount.target} against its baseline: ${message}`, + category: "artifact-capture", + source: mount.source, + path: mount.target, + refs: [{ path: artifactPath, kind: "diff" }], + details: { mountIndex, baselineSource, target: mount.target }, + }) + continue + } + await writeFile(join(artifactRoot, artifactPath), redactor.redact(artifactPath, diff.patch)) diffs.push({ mountIndex, @@ -69,6 +114,7 @@ export async function captureMountDiffs(artifactRoot: string, filesDirectory: st baselineSource, artifactPath, changed: diff.patch.trim().length > 0, + status: diff.patch.trim().length > 0 ? "changed" : "unchanged", }) patches.push(diff.patch) changedFiles.push( @@ -88,6 +134,7 @@ export async function captureMountDiffs(artifactRoot: string, filesDirectory: st files: changedFiles, }, patch: patches.filter((patch) => patch.length > 0).join("\n"), + diagnostics, } } diff --git a/scripts/mounted-workspace-diff-smoke.ts b/scripts/mounted-workspace-diff-smoke.ts new file mode 100644 index 0000000..512f248 --- /dev/null +++ b/scripts/mounted-workspace-diff-smoke.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict" +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import type { MountSpec } from "@chubes4/wp-codebox-core" +import { ArtifactRedactor } from "../packages/runtime-playground/src/artifacts.js" +import { captureMountDiffs } from "../packages/runtime-playground/src/mounted-artifact-capture.js" + +const root = await mkdtemp(join(tmpdir(), "wp-codebox-mounted-diff-smoke-")) + +try { + const artifactRoot = join(root, "artifacts") + const filesDirectory = join(artifactRoot, "files") + const baseline = join(root, "baseline") + const workspace = join(root, "workspace") + await mkdir(filesDirectory, { recursive: true }) + await mkdir(baseline, { recursive: true }) + await mkdir(workspace, { recursive: true }) + + await writeFile(join(baseline, "plugin.php"), " [file.relativePath, file])) + + assert.equal(result.diagnostics.length, 0) + assert.equal(result.mountDiffs.length, 1) + assert.equal(result.mountDiffs[0].status, "changed") + assert.equal(result.mountDiffs[0].changed, true) + assert.equal(changed.get("generated.txt")?.status, "added") + assert.equal(changed.get("plugin.php")?.status, "modified") + assert.equal(changed.get("delete-me.txt")?.status, "deleted") + assert.match(result.patch, /diff --git a\/workspace\/plugin\/generated\.txt b\/workspace\/plugin\/generated\.txt/) + assert.match(result.patch, /\+cooked/) + assert.match(result.patch, /diff --git a\/workspace\/plugin\/plugin\.php b\/workspace\/plugin\/plugin\.php/) + assert.match(result.patch, /\+\/\/ after/) + assert.match(result.patch, /deleted file mode 100644/) + + const mountPatch = await readFile(join(artifactRoot, result.mountDiffs[0].artifactPath), "utf8") + assert.equal(mountPatch, result.patch) + + const missingBaselineResult = await captureMountDiffs(artifactRoot, filesDirectory, [ + { + type: "directory", + source: workspace, + target: "/workspace/untracked", + mode: "readwrite", + metadata: { sourceMode: "repo-backed" }, + }, + ], new ArtifactRedactor()) + assert.equal(missingBaselineResult.patch, "") + assert.equal(missingBaselineResult.changedFiles.files.length, 0) + assert.equal(missingBaselineResult.mountDiffs[0].status, "skipped") + assert.equal(missingBaselineResult.mountDiffs[0].reason, "missing-baseline-source") +} finally { + await rm(root, { recursive: true, force: true }) +}