From 66271d056ee7a236cdb7386ab4ba78997d53a246 Mon Sep 17 00:00:00 2001 From: chumanic Date: Mon, 12 Oct 2026 10:10:15 +0800 Subject: [PATCH] feat(artifacts): compare correction evidence with prior reports --- CHANGELOG.md | 10 ++ README.md | 7 +- docs/artifact-delivery.md | 37 ++++++ src/cli/commands/artifacts.ts | 38 ++++-- src/cli/index.ts | 1 + src/domain/artifact-report-comparison.ts | 112 ++++++++++++++++ src/reporters/artifacts-html.ts | 16 +++ .../artifact-report-comparison.test.ts | 123 ++++++++++++++++++ 8 files changed, 332 insertions(+), 12 deletions(-) create mode 100644 src/domain/artifact-report-comparison.ts create mode 100644 tests/integration/artifact-report-comparison.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b97191e..3a40c2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Source continuation — 2026-10-12 + +- Add `artifacts --compare` to review actual corrected files against a preserved + JSON inspection from the same contract, with visible HTML/text/JSON transitions. +- Separate finding-category resolution and reconciliation count improvements + from business acceptance; reject invalid or incomplete comparison evidence. +- Bound historical reads to1MiB and existing report ceilings, reuse stable-file + reads and strict JSON parsing, and omit previous messages/record positions. +- Preserve source0.1.7 and existing public releases; no publishing is performed. + ## 0.1.7 source candidate - 2026-10-10 - Add `artifacts --format html`: a self-contained offline delivery review with diff --git a/README.md b/README.md index 62d25f2..cdb72d9 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,12 @@ > **Alpha · source candidate v0.1.7 · Node.js 20+** > SkillSync performs offline checks of local Skill content. It does not execute Skill scripts, does not read credentials, and does not enable live provider, remote-worker, or runtime capabilities. -Source v0.1.7 adds an offline human acceptance report on top of bounded composite-key reconciliation. The published GitHub package +Source v0.1.7 adds an offline human acceptance report on top of bounded composite-key reconciliation. +Use `artifacts --compare ` to review whether a corrected +delivery resolves finding categories or improves individual reconciliation counts. +Changed contracts and incomplete history remain unavailable, without copying old +messages or record values. See the [correction review guide](docs/artifact-delivery.md#review-a-correction-against-its-previous-report). +The published GitHub package is still v0.1.4 and npm is still 0.1.0; neither includes the new rule. [![Terminal demo](https://raw.githubusercontent.com/Chumaniac/skillsync/main/docs/assets/verify-demo.svg)](https://github.com/Chumaniac/skillsync/blob/main/docs/assets/verify-demo.svg) diff --git a/docs/artifact-delivery.md b/docs/artifact-delivery.md index 6329f49..58ad306 100644 --- a/docs/artifact-delivery.md +++ b/docs/artifact-delivery.md @@ -71,6 +71,43 @@ absent from the published GitHub0.1.4/npm0.1.0 packages. ## Per-key integer reconciliation +### Review a correction against its previous report + +The source CLI accepts `--compare `. Save an ordinary +JSON inspection before correcting a separate copy of the delivery, then inspect +the corrected directory using the same contract: + +```bash +(set -C; node dist/cli/index.js artifacts --contract reviewed-contract.json \ + --path preserved-original --format json > previous-artifacts.json) +(set -C; node dist/cli/index.js artifacts --contract reviewed-contract.json \ + --path corrected-copy --compare previous-artifacts.json --format html > correction-review.html) +``` + +The first command may exit1 for a complete failed check; preserve that report. +The second reads at most1MiB of strict UTF-8 JSON through the existing bounded +stable-file reader. Invalid schema, duplicate keys, inconsistent digests/counts, +duplicate files, symlinks, over-limit input and read changes fail setup before +inspecting the current directory. Histories with an earlier derived comparison +are structurally checked; their derived transitions are not reused. + +Matching contract hashes and ordered reconciliation declarations are required. +Unknown checks, failed Receipt binding, missing file-set identity or a saturated +256-finding list make comparison unavailable; no omitted finding is declared +resolved. Unavailable comparison returns exit2 and a failed overall result, while +`comparison.current_status` retains the independently observed current status. +Ordinary inspections without `--compare` keep their existing outputs and exits. + +Comparable reports show new, ongoing and resolved **finding categories** by +code/file/column, and improved, regressed, mixed or unchanged defect counts per +declared reconciliation. Record identities and row positions are not compared; +the same counts can refer to different records. At most128 transitions are +retained and64 are displayed in HTML, with full-scope counts. Previous messages, +record positions and raw values are not forwarded. A report byte hash identifies +the caller-supplied history, which is not authenticated or a reviewed content +pin. Passing checks or fewer defects do not establish business approval. This +addition is source-only and does not change any public package or release. + Source candidate 0.1.6 supports `keyed_integer_sum_equals`. It is absent from published npm0.1.0 and the existing GitHub0.1.4 package. Each side declares a CSV path, required nonempty string `key`, and required integer `value` column. diff --git a/src/cli/commands/artifacts.ts b/src/cli/commands/artifacts.ts index a61350d..be7307d 100644 --- a/src/cli/commands/artifacts.ts +++ b/src/cli/commands/artifacts.ts @@ -9,6 +9,7 @@ import { parseStrictJson } from "../../domain/strict-json.js"; import { inspectArtifactContent, type ArtifactFacts, type ArtifactFinding } from "../../domain/artifact-content.js"; import { createArtifactReferenceIndex } from "../../domain/artifact-references.js"; import { createArtifactReconciliationIndex, type ReconciliationSummary } from "../../domain/artifact-reconciliation.js"; +import { artifactSetHash, compareArtifactReports, parseArtifactReport, type ArtifactComparison } from "../../domain/artifact-report-comparison.js"; import { redactLocalPaths } from "../../reporters/local-paths.js"; import { renderArtifactsHtml } from "../../reporters/artifacts-html.js"; import { readWorkspaceFile, scanStagedWorkspace, WorkspaceTreeError } from "../../sandbox/workspace-tree.js"; @@ -20,7 +21,7 @@ const manifestSchema = z.object({ schema: z.literal("skilltape.dev/delivery/v1") files: z.array(z.object({ path: z.string().min(1).max(1024), bytes: z.number().int().min(0).max(16 * 1024 * 1024), sha256: sha }).strict()).max(10_000), }).strict(); -export type ArtifactsOptions = { contract: string; delivery?: string; path?: string; includeRowEvidence?: boolean }; +export type ArtifactsOptions = { contract: string; delivery?: string; path?: string; includeRowEvidence?: boolean; compare?: string }; export type ArtifactReport = { schema: "skillsync.artifacts-report/v1"; status: "passed" | "failed"; exitCode: number; evidence: "physical-files"; execution: "not-run"; provenance: "not-authenticated"; @@ -29,22 +30,34 @@ export type ArtifactReport = { files: Array<{ path: string; bytes: number; sha256: string; rows?: number }>; findings: ArtifactFinding[]; reconciliations?: ReconciliationSummary[]; + comparison?: ArtifactComparison; }; -function setHash(files: ArtifactReport["files"]): string { - const digest = createHash("sha256"); - for (const file of [...files].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)))) { - const size = Buffer.alloc(8); size.writeBigUInt64BE(BigInt(file.bytes)); - digest.update(file.path); digest.update(Buffer.from([0])); digest.update(size); digest.update(file.sha256); - } - return digest.digest("hex"); -} - function readJson(bytes: Uint8Array): unknown { return parseStrictJson(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); } export async function runArtifacts(options: ArtifactsOptions): Promise { + if (!options.compare) return inspectArtifacts(options); + let before: ArtifactReport, bytes: Uint8Array; + try { + bytes = await readWorkspaceFile(options.compare, 1024 * 1024); + before = parseArtifactReport(readJson(bytes)); + } catch { + return { schema: "skillsync.artifacts-report/v1", status: "failed", exitCode: 2, + evidence: "physical-files", execution: "not-run", provenance: "not-authenticated", + receipt_binding: "not-provided", files: [], findings: [{ code: "artifact.baseline-invalid" }] }; + } + const report = await inspectArtifacts(options); + report.comparison = compareArtifactReports(before, report, hash(bytes)); + if (report.comparison.status === "unavailable") { + report.status = "failed"; report.exitCode = 2; + if (report.findings.length < 256) report.findings.push({ code: "artifact.baseline-unavailable" }); + } + return report; +} + +async function inspectArtifacts(options: ArtifactsOptions): Promise { const report: ArtifactReport = { schema: "skillsync.artifacts-report/v1", status: "failed", exitCode: 1, evidence: "physical-files", execution: "not-run", provenance: "not-authenticated", receipt_binding: options.delivery ? "failed" : "not-provided", files: [], findings: [] }; @@ -117,7 +130,7 @@ export async function runArtifacts(options: ArtifactsOptions): Promise Buffer.compare(Buffer.from(left.path), Buffer.from(right.path))); - report.artifact_set_sha256 = setHash(report.files); + report.artifact_set_sha256 = artifactSetHash(report.files); if (manifest) { if (manifest.artifact_set_sha256 !== report.artifact_set_sha256) issue("delivery.artifact-set-digest"); for (const file of report.files) { @@ -151,6 +164,9 @@ export function renderArtifacts(report: ArtifactReport, format = "text"): string const safe = redactLocalPaths(report); if (format === "json") return JSON.stringify(safe, null, 2) + "\n"; return [`Artifact validation: ${safe.status}`, `Evidence: physical files; execution: not-run; receipt binding: ${safe.receipt_binding}`, + ...(safe.comparison ? [`Baseline comparison: ${safe.comparison.status}${safe.comparison.reason ? `; ${safe.comparison.reason}` : ""}`, + ...(safe.comparison.findings ? [`Finding categories: ${safe.comparison.findings.added} new; ${safe.comparison.findings.ongoing} ongoing; ${safe.comparison.findings.resolved} resolved`] : [])] : []), + ...(safe.comparison?.checks ? [`Record check changes: ${safe.comparison.checks.filter(check => check.change === "improved").length} improved; ${safe.comparison.checks.filter(check => check.change === "regressed").length} regressed; ${safe.comparison.checks.filter(check => check.change === "changed").length} mixed changes; ${safe.comparison.checks.filter(check => check.change === "unchanged").length} unchanged`] : []), `Files: ${safe.files.length}`, ...(safe.reconciliations ?? []).map(item => `Record reconciliation: ${item.status}${item.status !== "unknown" ? `; source keys=${item.sourceKeys}; target keys=${item.targetKeys}; missing=${item.missingKeys}; unexpected=${item.unexpectedKeys}; mismatched values=${item.valueMismatches}` : ""}`), ...safe.findings.map(finding => `${finding.code}${finding.path ? ` file=${JSON.stringify(finding.path)}` : ""}${finding.field ? ` field=${JSON.stringify(finding.field)}` : ""}${finding.row !== undefined ? ` row=${finding.row}` : ""}${finding.message ? ` ${finding.message}` : ""}`)].join("\n") + "\n"; } diff --git a/src/cli/index.ts b/src/cli/index.ts index adaccd2..8654396 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -569,6 +569,7 @@ export function createCli(io: CliIO = defaultCliIO): Command { .requiredOption("--contract ", "JSON or YAML artifact contract") .option("--delivery ", "SkillTape delivery bundle directory") .option("--path ", "Standalone artifact directory; choose this or --delivery") + .option("--compare ", "Compare with a bounded prior artifacts JSON report from the same contract") .option("--format ", "Output format: text, json or offline html", "text") .action(async (options: ArtifactsOptions & { format?: string }) => { if (options.format !== "text" && options.format !== "json" && options.format !== "html") throw new Error("artifacts format must be text, json or html"); diff --git a/src/domain/artifact-report-comparison.ts b/src/domain/artifact-report-comparison.ts new file mode 100644 index 0000000..4caf0d9 --- /dev/null +++ b/src/domain/artifact-report-comparison.ts @@ -0,0 +1,112 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; +import type { ArtifactReport } from "../cli/commands/artifacts.js"; + +const sha = z.string().regex(/^[0-9a-f]{64}$/); +const count = z.number().int().min(0).max(100_000); +const status = z.enum(["passed", "failed"]); +const checkStatus = z.enum(["passed", "failed", "unknown"]); +const path = z.string().min(1).max(1024).refine(value => !value.startsWith("/") && + !/^[A-Za-z]:/.test(value) && !/[\\\p{Cc}]/u.test(value) && + value.split("/").every(part => part && part !== "." && part !== "..")); +const field = z.string().min(1).max(128); +const side = z.object({ path, key: z.union([field, z.array(field).min(2).max(4)]), value: field }).strict(); +const change = z.enum(["improved", "regressed", "changed", "unchanged"]); +export const artifactComparisonSchema = z.object({ + schema: z.literal("skillsync.artifact-comparison/v1"), status: z.enum(["compared", "unavailable"]), + reason: z.enum(["contract-changed", "incomplete-evidence", "check-declarations-changed"]).optional(), + baseline_report_sha256: sha, baseline_status: status, current_status: status, + baseline_artifact_set_sha256: sha.optional(), current_artifact_set_sha256: sha.optional(), + findings: z.object({ added: count, ongoing: count, resolved: count }).strict().optional(), + checks: z.array(z.object({ index: z.number().int().min(0).max(127), before: checkStatus, + after: checkStatus, change }).strict()).max(128).optional(), +}).strict(); +export type ArtifactComparison = z.infer; + +const summary = z.object({ type: z.literal("keyed_integer_sum_equals"), source: side, target: side, + status: checkStatus, sourceKeys: count.optional(), targetKeys: count.optional(), missingKeys: count.optional(), + unexpectedKeys: count.optional(), valueMismatches: count.optional(), + recordEvidence: z.object({ total: count, truncated: z.boolean(), items: z.array(z.object({ + kind: z.enum(["value_mismatch", "missing", "unexpected"]), sourceRows: z.array(count.min(1)).max(8), + targetRows: z.array(count.min(1)).max(8), sourceRowsTruncated: z.boolean(), targetRowsTruncated: z.boolean(), + }).strict()).max(64) }).strict().optional(), +}).strict().superRefine((item, context) => { + const values = [item.sourceKeys, item.targetKeys, item.missingKeys, item.unexpectedKeys, item.valueMismatches]; + if (item.status === "unknown" ? values.some(value => value !== undefined) : values.some(value => value === undefined)) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "incomplete reconciliation counts" }); + } + if (item.status === "passed" && [item.missingKeys, item.unexpectedKeys, item.valueMismatches].some(value => value !== 0)) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "inconsistent reconciliation status" }); + } +}); + +const reportSchema = z.object({ schema: z.literal("skillsync.artifacts-report/v1"), status, + exitCode: z.number().int().min(0).max(2), evidence: z.literal("physical-files"), execution: z.literal("not-run"), + provenance: z.literal("not-authenticated"), receipt_binding: z.enum(["verified", "not-provided", "failed"]), + contract_sha256: sha.optional(), artifact_set_sha256: sha.optional(), + files: z.array(z.object({ path, bytes: z.number().int().min(0).max(16 * 1024 * 1024), + sha256: sha, rows: count.optional() }).strict()).max(10_000), + findings: z.array(z.object({ code: z.string().min(1).max(128).regex(/^[a-z][a-z0-9.-]*$/), + path: path.optional(), field: z.union([field, z.array(field).min(1).max(4)]).optional(), + row: count.min(1).optional(), message: z.string().max(4096).optional(), + }).strict()).max(256), reconciliations: z.array(summary).max(128).optional(), + comparison: artifactComparisonSchema.optional(), +}).strict().superRefine((report, context) => { + if (new Set(report.files.map(file => file.path)).size !== report.files.length || + report.files.reduce((total, file) => total + file.bytes, 0) > 64 * 1024 * 1024 || + (report.artifact_set_sha256 !== undefined && report.artifact_set_sha256 !== artifactSetHash(report.files)) || + (report.status === "passed" && (report.exitCode !== 0 || report.findings.length || + report.reconciliations?.some(item => item.status !== "passed"))) || + (report.status === "failed" && report.exitCode === 0)) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "inconsistent artifact report" }); + } +}); + +export function artifactSetHash(files: ArtifactReport["files"]): string { + const digest = createHash("sha256"); + for (const file of [...files].sort((a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path)))) { + const size = Buffer.alloc(8); size.writeBigUInt64BE(BigInt(file.bytes)); + digest.update(file.path); digest.update(Buffer.from([0])); digest.update(size); digest.update(file.sha256); + } + return digest.digest("hex"); +} + +export function parseArtifactReport(value: unknown): ArtifactReport { + return reportSchema.parse(value); +} + +/** Caller-supplied history is not authenticated; never forward its messages or record positions. */ +export function compareArtifactReports(before: ArtifactReport, after: ArtifactReport, digest: string): ArtifactComparison { + const result: ArtifactComparison = { schema: "skillsync.artifact-comparison/v1", status: "unavailable", + baseline_report_sha256: digest, baseline_status: before.status, current_status: after.status, + ...(before.artifact_set_sha256 ? { baseline_artifact_set_sha256: before.artifact_set_sha256 } : {}), + ...(after.artifact_set_sha256 ? { current_artifact_set_sha256: after.artifact_set_sha256 } : {}) }; + if (!before.contract_sha256 || before.contract_sha256 !== after.contract_sha256) return { ...result, reason: "contract-changed" }; + const left = before.reconciliations ?? [], right = after.reconciliations ?? []; + // A saturated finding list may have omitted defects; do not call them resolved. + if ([before, after].some(report => !report.artifact_set_sha256 || report.receipt_binding === "failed" || + report.findings.length >= 256 || report.comparison?.status === "unavailable" || + report.reconciliations?.some(item => item.status === "unknown"))) { + return { ...result, reason: "incomplete-evidence" }; + } + const identity = (item: (typeof left)[number]) => JSON.stringify([item.type, item.source, item.target]); + if (left.length !== right.length || left.some((item, index) => identity(item) !== identity(right[index]))) { + return { ...result, reason: "check-declarations-changed" }; + } + const findings = (report: ArtifactReport) => new Set(report.findings.map(item => JSON.stringify([item.code, item.path, item.field]))); + const a = findings(before), b = findings(after); + result.status = "compared"; + result.findings = { added: [...b].filter(key => !a.has(key)).length, + ongoing: [...b].filter(key => a.has(key)).length, resolved: [...a].filter(key => !b.has(key)).length }; + result.checks = left.map((item, index) => { + const current = right[index]; + const x = [item.missingKeys!, item.unexpectedKeys!, item.valueMismatches!]; + const y = [current.missingKeys!, current.unexpectedKeys!, current.valueMismatches!]; + const equal = x.every((value, i) => value === y[i]); + const kind = item.status !== current.status ? current.status === "passed" ? "improved" : "regressed" : + equal ? "unchanged" : y.every((value, i) => value <= x[i]) ? "improved" : + y.every((value, i) => value >= x[i]) ? "regressed" : "changed"; + return { index, before: item.status, after: current.status, change: kind }; + }); + return result; +} diff --git a/src/reporters/artifacts-html.ts b/src/reporters/artifacts-html.ts index cb21268..ccf8868 100644 --- a/src/reporters/artifacts-html.ts +++ b/src/reporters/artifacts-html.ts @@ -60,6 +60,21 @@ function reconciliationCard(check: ReconciliationSummary, rowBudget: { remaining `; } +function comparisonSection(report: ArtifactReport): string { + const value = report.comparison; + if (!value) return ""; + const checks = value.checks ?? []; + return `

Changes since baseline review

+

Caller-supplied history is not authenticated. The current directory was inspected again; previous messages and record positions are not copied into this comparison.

+

Previous result: ${text(value.baseline_status)} · Current inspection: ${text(value.current_status)}

+ ${value.status === "unavailable" ? `

Comparison unavailable: ${text(value.reason)}. No improvement or resolution is established.

` : `

Finding categories: ${text(value.findings?.added)} new · ${text(value.findings?.ongoing)} ongoing · ${text(value.findings?.resolved)} resolved. Categories use code, file and column, not record identities.

+

Record checks: ${checks.filter(check => check.change === "improved").length} improved · ${checks.filter(check => check.change === "regressed").length} regressed · ${checks.filter(check => check.change === "changed").length} mixed changes · ${checks.filter(check => check.change === "unchanged").length} unchanged. Reduced defect counts are not business approval.

+

Showing ${Math.min(CHECK_DETAILS, checks.length)} of ${checks.length} check transitions, in declaration order.

+
+ ${checks.slice(0, CHECK_DETAILS).map(check => ``).join("") || ''}
Declared checkPreviousCurrentChange
${text(check.index + 1)}${text(check.before)}${text(check.after)}${text(check.change)}
No integer reconciliation was declared.
`} +
Baseline report identity${text(value.baseline_report_sha256)}

SHA-256 binds the supplied JSON bytes; it does not establish their author or truth.

`; +} + /** Use the same path policy as text/JSON, including when called directly. */ export function renderArtifactsHtml(rawReport: ArtifactReport): string { const report = redactLocalPaths(rawReport); @@ -84,6 +99,7 @@ export function renderArtifactsHtml(rawReport: ArtifactReport): string {

${passed ? "The observed files satisfy the declared local checks. Review the expectations and responsible approval before using this result." : "This delivery has findings or incomplete checks. Use the file and identity metadata below to review the affected requirement."}

${report.files.length}Observed files
${report.findings.length}Review findings
${failedChecks}Failed record checks
${incomplete}Incomplete record checks
Receipt / file binding${text(report.receipt_binding)}Hash consistency with supplied local evidence; author identity is not authenticated.
+${comparisonSection(report)}

Allocation and identity checks

Showing ${Math.min(CHECK_DETAILS, checks.length)} of ${checks.length} reconciliation checks. Failed and incomplete checks appear first.

${ordered.slice(0, CHECK_DETAILS).map(check => reconciliationCard(check, rowBudget)).join("") || "

No record reconciliation was declared. Other declared file checks are reflected in the result.

"}

Review actions

${report.findings.length ? `
${report.findings.slice(0, 256).map(findingRow).join("")}
FindingAffected metadataNext review step
` : "

No finding was reported for the declared scope. This does not authenticate source truth or establish business approval.

"}
diff --git a/tests/integration/artifact-report-comparison.test.ts b/tests/integration/artifact-report-comparison.test.ts new file mode 100644 index 0000000..979e131 --- /dev/null +++ b/tests/integration/artifact-report-comparison.test.ts @@ -0,0 +1,123 @@ +import { cp, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { runCli } from "../../src/cli/index.js"; +import { renderArtifacts, runArtifacts, type ArtifactReport } from "../../src/cli/commands/artifacts.js"; +import { compareArtifactReports, parseArtifactReport } from "../../src/domain/artifact-report-comparison.js"; + +async function fixture(check: (root: string, options: { path: string; contract: string }) => Promise) { + const root = await mkdtemp(join(tmpdir(), "skillsync-comparison-")); + try { + await cp(resolve("fixtures/product/tenant-reconciliation"), root, { recursive: true }); + await check(root, { path: join(root, "artifacts"), contract: join(root, "contract.json") }); + } finally { await rm(root, { recursive: true, force: true }); } +} + +async function save(root: string, value: ArtifactReport) { + const path = join(root, "previous.json"); + await writeFile(path, renderArtifacts(value, "json")); + return path; +} + +describe("artifact correction review", () => { + it("compares actual corrected files, preserves originals and omits private record values", async () => fixture(async (root, options) => { + const correct = await readFile(join(options.path, "allocations.csv")); + await cp(join(root, "faults/shifted-allocations.csv"), join(options.path, "allocations.csv")); + const before = await runArtifacts(options); + expect(before.exitCode).toBe(1); + before.findings[0].message = "PRIVATE_HISTORY_MESSAGE"; + const baseline = await save(root, before), preserved = await readFile(baseline); + await writeFile(join(options.path, "allocations.csv"), correct); + const result = await runCli(["artifacts", "--contract", options.contract, "--path", options.path, + "--compare", baseline, "--format", "html"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Changes since baseline review"); + expect(result.stdout).toContain("1 improved"); + expect(result.stdout).toContain("resolved"); + expect(result.stdout).not.toMatch(/PRIVATE_HISTORY_MESSAGE|ORG_A|ORG_B|ORDER_1| fixture(async (root, options) => { + const baseline = await save(root, await runArtifacts(options)); + await cp(join(root, "faults/shifted-allocations.csv"), join(options.path, "allocations.csv")); + const after = await runArtifacts({ ...options, compare: baseline }); + expect(after.exitCode).toBe(1); + expect(after.comparison?.checks?.[0].change).toBe("regressed"); + expect(after.comparison?.findings).toEqual({ added: 1, ongoing: 0, resolved: 0 }); + const ordinary = await runArtifacts(options); + expect(ordinary.comparison).toBeUndefined(); + expect(renderArtifacts(ordinary, "html")).not.toContain("Changes since baseline review"); + })); + + it("fails comparison on changed expectations while retaining the current inspection result", async () => fixture(async (root, options) => { + const before = await runArtifacts(options); + before.contract_sha256 = "b".repeat(64); + const after = await runArtifacts({ ...options, compare: await save(root, before) }); + expect(after.exitCode).toBe(2); + expect(after.status).toBe("failed"); + expect(after.comparison).toMatchObject({ status: "unavailable", reason: "contract-changed", current_status: "passed" }); + expect(after.comparison?.findings).toBeUndefined(); + expect(renderArtifacts(after, "html")).not.toContain("Declared checks passed"); + })); + + it("never resolves partial, saturated or mismatched-declaration evidence", async () => fixture(async (_root, options) => { + const report = await runArtifacts(options); + const unknown = structuredClone(report); unknown.status = "failed"; unknown.exitCode = 1; + unknown.reconciliations = [{ ...report.reconciliations![0], status: "unknown", sourceKeys: undefined, + targetKeys: undefined, missingKeys: undefined, unexpectedKeys: undefined, valueMismatches: undefined }]; + for (const prior of [unknown, { ...report, receipt_binding: "failed" as const }, + { ...report, status: "failed" as const, exitCode: 1, findings: Array.from({ length: 256 }, () => ({ code: "artifact.missing" })) }]) { + const value = compareArtifactReports(prior, report, "a".repeat(64)); + expect(value).toMatchObject({ status: "unavailable", reason: "incomplete-evidence" }); + expect(value.findings).toBeUndefined(); + } + const altered = structuredClone(report); altered.reconciliations![0].target.key = "another-column"; + expect(compareArtifactReports(altered, report, "a".repeat(64)).reason).toBe("check-declarations-changed"); + })); + + it("strictly validates historical reports before the current directory is inspected", async () => fixture(async (root, options) => { + const good = await runArtifacts(options); + const duplicate = { ...good, files: [...good.files, good.files[0]] }; + for (const invalid of [{ ...good, schema: "other" }, { ...good, rawRecords: ["PRIVATE_VALUE"] }, duplicate, + { ...good, artifact_set_sha256: "a".repeat(64) }, { ...good, files: [{ ...good.files[0], path: "../escape" }] }, + { ...good, reconciliations: [{ ...good.reconciliations![0], valueMismatches: -1 }] }, + { ...good, findings: Array.from({ length: 257 }, () => ({ code: "artifact.missing" })) }]) { + const baseline = join(root, "invalid.json"); await writeFile(baseline, JSON.stringify(invalid)); + const result = await runArtifacts({ ...options, path: join(root, "ABSENT_PRIVATE_PATH"), compare: baseline }); + expect(result.exitCode).toBe(2); + expect(result.findings).toEqual([{ code: "artifact.baseline-invalid" }]); + expect(renderArtifacts(result, "json")).not.toContain("PRIVATE_"); + } + })); + + it("bounds bytes, refuses symlinks and rejects duplicate JSON members without leaking setup data", async () => fixture(async (root, options) => { + const oversized = join(root, "large.json"); await writeFile(oversized, " ".repeat(1024 * 1024 + 1)); + const original = await save(root, await runArtifacts(options)); + const link = join(root, "linked.json"); await symlink(original, link); + const duplicate = join(root, "duplicate.json"); await writeFile(duplicate, '{"schema":"a","schema":"b"}'); + for (const compare of [oversized, link, duplicate, join(root, "MISSING_PRIVATE_PATH")]) { + const result = await runArtifacts({ ...options, compare }); + expect(result.findings).toEqual([{ code: "artifact.baseline-invalid" }]); + expect(renderArtifacts(result, "html")).not.toContain(root); + } + })); + + it("bounds HTML transitions while keeping truthful full-scope counts", async () => fixture(async (_root, options) => { + const before = await runArtifacts(options), after = structuredClone(before); + before.reconciliations = Array.from({ length: 128 }, () => before.reconciliations![0]); + after.reconciliations = Array.from({ length: 128 }, () => after.reconciliations![0]); + after.comparison = compareArtifactReports(before, after, "c".repeat(64)); + const html = renderArtifacts(after, "html"); + expect(html).toContain("Showing 64 of 128 check transitions"); + expect(html).toContain("128 unchanged"); + expect(html).not.toContain('65'); + expect(Buffer.byteLength(html)).toBeLessThan(1024 * 1024); + })); +});