diff --git a/docs/python-packaged-applications.md b/docs/python-packaged-applications.md index fd561be399..ab0b7680e9 100644 --- a/docs/python-packaged-applications.md +++ b/docs/python-packaged-applications.md @@ -50,6 +50,15 @@ table index; original names remain available in the supplied artifact. Path failures use bounded escaped previews and report the original length; accepted paths and retained source names are not shortened. +`compare_artifacts` compares these node-less records as declarations using the +container, container name, source name and type code. Equal option or dependency +declarations can therefore compare unchanged without implying that an external +dependency's bytes were found. For materialized PyInstaller members, the same +fields participate in the metadata delta independently of content identity; +archive offsets and table positions remain location facts, not declaration +identity. Missing declaration facts and unavailable non-metadata content remain +unknown. + Request `detail: "summary"` for a compact inspection, then query retained Evidence with `inspect_analysis_view`. An inventory view can select `collection: "occurrences"`, `entry_kinds: ["metadata"]`, exact logical paths or diff --git a/src/domain/artifactComparison.ts b/src/domain/artifactComparison.ts index 73084df6e7..a9f69be471 100644 --- a/src/domain/artifactComparison.ts +++ b/src/domain/artifactComparison.ts @@ -14,6 +14,7 @@ import { parseArtifactInventoryEvidence, type InventorySet, } from "./artifactInventoryEvidence.js"; +import { pyInstallerEntryDeclaration } from "./artifactFormatMetadata.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; import { canonicalJson } from "./comparisonSemantics.js"; @@ -363,14 +364,84 @@ const classifyPath = (input: ClassifyPathInput): ArtifactChange | null => { const base = buildArtifactChangeBase(input); const integrity = classifyIntegrity(input, base); if (integrity !== null) return integrity; + const declaration = classifyPyInstallerDeclaration(input, base); + if (declaration.handled) return declaration.change; const availability = classifyAvailability(input, base); if (availability !== null) return availability; const dimensions = changedDimensions(input); + if (pyInstallerMetadataUnknown(input)) { + if (dimensions.length === 0) + return { + ...base, + classification: "unknown", + dimensions: ["availability"], + }; + dimensions.push("availability"); + } return dimensions.length === 0 ? null : { ...base, classification: "changed", dimensions }; }; +const pyInstallerMetadataUnknown = (input: ClassifyPathInput): boolean => + (isPyInstallerEntry(input.leftOccurrence) && + !isPyInstallerEntry(input.rightOccurrence)) || + (isPyInstallerEntry(input.rightOccurrence) && + !isPyInstallerEntry(input.leftOccurrence)); + +const isPyInstallerEntry = ( + occurrence: ArtifactOccurrence | undefined, +): occurrence is ArtifactOccurrence & { + readonly format_metadata: Extract< + NonNullable, + { readonly kind: "pyinstaller-entry" } + >; +} => occurrence?.format_metadata?.kind === "pyinstaller-entry"; + +const classifyPyInstallerDeclaration = ( + input: ClassifyPathInput, + base: ReturnType, +): { readonly handled: boolean; readonly change: ArtifactChange | null } => { + const leftIsDeclaration = input.leftOccurrence?.entry_kind === "metadata"; + const rightIsDeclaration = input.rightOccurrence?.entry_kind === "metadata"; + if (!leftIsDeclaration && !rightIsDeclaration) + return { handled: false, change: null }; + // Metadata is not inherently a PyInstaller declaration. Only apply this + // format-specific comparison when a producer explicitly identified one. + if ( + !isPyInstallerEntry(input.leftOccurrence) && + !isPyInstallerEntry(input.rightOccurrence) + ) + return { handled: false, change: null }; + if (!leftIsDeclaration || !rightIsDeclaration) + return { handled: false, change: null }; + if ( + !isPyInstallerEntry(input.leftOccurrence) || + !isPyInstallerEntry(input.rightOccurrence) + ) + return { + handled: true, + change: { + ...base, + classification: "unknown", + dimensions: ["availability"], + }, + }; + const changed = + canonicalJson( + pyInstallerEntryDeclaration(input.leftOccurrence.format_metadata), + ) !== + canonicalJson( + pyInstallerEntryDeclaration(input.rightOccurrence.format_metadata), + ); + return { + handled: true, + change: changed + ? { ...base, classification: "changed", dimensions: ["metadata"] } + : null, + }; +}; + const changedDimensions = (input: { readonly leftNode: ArtifactNode | undefined; readonly rightNode: ArtifactNode | undefined; @@ -430,7 +501,15 @@ const metadataChanged = ( input.leftOccurrence?.encrypted !== input.rightOccurrence?.encrypted || JSON.stringify(left.limitations) !== JSON.stringify(right.limitations) || JSON.stringify(input.leftOccurrence?.limitations) !== - JSON.stringify(input.rightOccurrence?.limitations); + JSON.stringify(input.rightOccurrence?.limitations) || + (isPyInstallerEntry(input.leftOccurrence) && + isPyInstallerEntry(input.rightOccurrence) && + canonicalJson( + pyInstallerEntryDeclaration(input.leftOccurrence.format_metadata), + ) !== + canonicalJson( + pyInstallerEntryDeclaration(input.rightOccurrence.format_metadata), + )); const nodeFor = ( occurrence: ArtifactOccurrence | undefined, diff --git a/src/domain/artifactFormatMetadata.ts b/src/domain/artifactFormatMetadata.ts index 4ba7f6475d..8e4aca78be 100644 --- a/src/domain/artifactFormatMetadata.ts +++ b/src/domain/artifactFormatMetadata.ts @@ -88,6 +88,17 @@ export const pyInstallerEntryMetadataSchema = z.strictObject({ export type PyInstallerEntryMetadata = z.infer< typeof pyInstallerEntryMetadataSchema >; + +/** Declaration identity that is stable across archive layout changes. */ +export const pyInstallerEntryDeclaration = ( + metadata: PyInstallerEntryMetadata, +) => ({ + container: metadata.container, + container_name: metadata.container_name, + source_name: metadata.source_name, + type_code: metadata.type_code, +}); + export const artifactFormatMetadataSchema = z.discriminatedUnion("kind", [ godotPckMetadataSchema, godotPckEntryMetadataSchema, diff --git a/tests/boundary/mcp/artifactMcp.test.ts b/tests/boundary/mcp/artifactMcp.test.ts index 94f7e5150c..c6b27cc793 100644 --- a/tests/boundary/mcp/artifactMcp.test.ts +++ b/tests/boundary/mcp/artifactMcp.test.ts @@ -3,6 +3,13 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js"; +import { + artifactEdgeId, + artifactGraphDigest, + artifactManifestId, + occurrenceIdForLocation, +} from "../../../src/domain/artifactIdentity.js"; +import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js"; import { ok as resultOk } from "../../../src/domain/result.js"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -24,6 +31,8 @@ import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js"; import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComparisonExample.js"; import { createServer } from "../../../src/server/createServer.js"; import { observed } from "../../fixtures/analysisExecution.js"; +import { cArchiveExecutable } from "../../../src/artifacts/pyinstaller/CArchive.fixture.js"; +import { compareUnicodeCodePoints } from "../../../src/domain/unicodeCodePointOrder.js"; const integrityMessage = (unpacked: boolean): string => `${ @@ -660,6 +669,354 @@ it("compares summary Evidence through retained complete inspection parents", asy const toolEvidence = (value: unknown) => parseEvidence(value); +const pyInstallerComparisonArchive = ( + typeCode: "m" | "s", + includeSymlink: boolean, + includePrefix = false, +) => + cArchiveExecutable({ + layout: "2.1", + pythonVersion: 309, + members: [ + ...(includePrefix + ? [ + { + name: "prefix.bin", + typeCode: "x" as const, + payload: Buffer.from("prefix bytes"), + compress: false, + }, + ] + : []), + { + name: "helper", + typeCode, + payload: Buffer.from("same helper bytes"), + compress: false, + }, + { + name: "pyi-contents-directory _internal", + typeCode: "o", + payload: Buffer.alloc(0), + compress: false, + }, + { + name: "shared-app:assets/data.bin", + typeCode: "d", + payload: Buffer.alloc(0), + compress: false, + }, + ...(includeSymlink + ? [ + { + name: "unavailable-link", + typeCode: "n" as const, + payload: Buffer.from("target"), + compress: false, + }, + ] + : []), + ], + }); + +const inspectArtifactInventoryThroughMcp = async ( + client: Client, + path: string, +) => { + const opened = await client.callTool({ + name: "open_binary", + arguments: { path }, + }); + expect(opened.isError).not.toBe(true); + const reply = await client.callTool({ + name: "inspect_artifact", + arguments: {}, + }); + expect(reply.isError).not.toBe(true); + const inspection = artifactInspectionResultSchema.parse( + toolEvidence(reply.structuredContent).normalized_result, + ); + const inventory = inspection.substeps.find( + ({ operation }) => operation === "inventory_artifact", + )?.evidence; + if (inventory === undefined) throw new Error("missing inventory Evidence"); + return parseEvidence(inventory); +}; + +const compareArtifactEvidenceThroughMcp = ( + client: Client, + left: Record, + right: Record, +) => client.callTool({ name: "compare_artifacts", arguments: { left, right } }); + +const withForeignMetadataAndNestedZipMember = ( + evidence: ReturnType, +) => { + const inventory = artifactInventoryResultSchema.parse( + evidence.normalized_result, + ); + const helper = inventory.occurrences.find( + ({ logical_path }) => logical_path === "helper", + ); + if (helper === undefined) throw new Error("missing helper occurrence"); + const { format_metadata: _formatMetadata, ...helperWithoutMetadata } = helper; + const foreignMetadataId = occurrenceIdForLocation({ + rootArtifactId: inventory.manifest.root_artifact_id, + logicalPath: "foreign-meta", + entryKind: "metadata", + }); + const foreignMetadata = { + ...helperWithoutMetadata, + occurrence_id: foreignMetadataId, + logical_path: "foreign-meta", + entry_kind: "metadata" as const, + artifact_format: "zip" as const, + }; + const nestedZipMember = { + ...helperWithoutMetadata, + occurrence_id: occurrenceIdForLocation({ + rootArtifactId: inventory.manifest.root_artifact_id, + logicalPath: "helper/member.txt", + entryKind: "file", + }), + parent_occurrence_id: helper.occurrence_id, + logical_path: "helper/member.txt", + artifact_format: "zip" as const, + }; + const occurrences = inventory.occurrences + .concat(foreignMetadata, nestedZipMember) + .sort((left, right) => + compareUnicodeCodePoints(left.logical_path, right.logical_path), + ); + const edges = inventory.edges + .map((edge) => { + const occurrence = occurrences.find( + ({ occurrence_id }) => occurrence_id === edge.occurrence_id, + ); + return occurrence === undefined + ? edge + : { ...edge, edge_id: artifactEdgeId(edge, occurrence) }; + }) + .sort((left, right) => + compareUnicodeCodePoints(left.edge_id, right.edge_id), + ); + const graphSha256 = artifactGraphDigest({ + nodes: inventory.nodes, + occurrences, + edges, + contradictions: inventory.integrity_contradictions, + }); + const manifestId = artifactManifestId( + inventory.manifest.root_artifact_id, + graphSha256, + ); + const subject = evidence.subject; + if (subject === null) throw new Error("missing inventory subject"); + return createEvidence( + { + path: subject.local_path, + sha256: subject.digest.sha256, + format: subject.format, + }, + evidence.provider, + { + operation: "inventory_artifact", + parameters: evidence.parameters, + confidence: evidence.confidence, + authority: evidence.authority, + result: jsonValueSchema.parse({ + ...inventory, + occurrences, + edges, + manifest: { + ...inventory.manifest, + occurrence_count: occurrences.length, + edge_count: edges.length, + graph_sha256: graphSha256, + manifest_id: manifestId, + }, + }), + }, + ); +}; + +it("does not apply PyInstaller declaration unknowns to foreign metadata or nested ZIP members", async () => { + const root = await createTestTempDirectory("rea-pyinstaller-scope-mcp-"); + const archive = join(root, "sample"); + await writeFile(archive, pyInstallerComparisonArchive("m", false)); + const session = createTestBinarySession(new ArtifactProvider(process.env)); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "pyinstaller-scope-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const observed = await inspectArtifactInventoryThroughMcp(client, archive); + const foreign = withForeignMetadataAndNestedZipMember(observed); + const response = await compareArtifactEvidenceThroughMcp( + client, + foreign, + foreign, + ); + expect(response.isError).not.toBe(true); + expect( + toolEvidence(response.structuredContent).normalized_result, + ).toMatchObject({ + status: "unchanged", + summary: { unknown: 0 }, + changes: [], + }); + } finally { + await Promise.allSettled([client.close(), server.close(), session.close()]); + } +}); + +it("compares PyInstaller declaration facts without inventing payload identity", async () => { + const root = await createTestTempDirectory("rea-pyinstaller-compare-mcp-"); + const leftPath = join(root, "module"); + const rightPath = join(root, "script"); + const declarationsPath = join(root, "declarations"); + const modulePath = join(root, "module-clean"); + const shiftedPath = join(root, "module-shifted"); + await Promise.all([ + writeFile(leftPath, pyInstallerComparisonArchive("m", true)), + writeFile(rightPath, pyInstallerComparisonArchive("s", true)), + writeFile(declarationsPath, pyInstallerComparisonArchive("s", false)), + writeFile(modulePath, pyInstallerComparisonArchive("m", false)), + writeFile(shiftedPath, pyInstallerComparisonArchive("m", false, true)), + ]); + const session = createTestBinarySession(new ArtifactProvider(process.env)); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "pyinstaller-compare-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const left = await inspectArtifactInventoryThroughMcp(client, leftPath); + const right = await inspectArtifactInventoryThroughMcp(client, rightPath); + const clean = await inspectArtifactInventoryThroughMcp( + client, + declarationsPath, + ); + const module = await inspectArtifactInventoryThroughMcp(client, modulePath); + const shifted = await inspectArtifactInventoryThroughMcp( + client, + shiftedPath, + ); + const leftInventory = artifactInventoryResultSchema.parse( + left.normalized_result, + ); + const rightInventory = artifactInventoryResultSchema.parse( + right.normalized_result, + ); + const leftHelper = leftInventory.occurrences.find( + ({ logical_path }) => logical_path === "helper", + ); + const rightHelper = rightInventory.occurrences.find( + ({ logical_path }) => logical_path === "helper", + ); + expect(leftHelper?.artifact_id).toBe(rightHelper?.artifact_id); + expect(leftHelper?.format_metadata).toMatchObject({ type_code: "m" }); + expect(rightHelper?.format_metadata).toMatchObject({ type_code: "s" }); + expect( + leftInventory.occurrences.filter( + ({ entry_kind }) => entry_kind === "metadata", + ), + ).toHaveLength(2); + + const changed = await compareArtifactEvidenceThroughMcp( + client, + left, + right, + ); + expect(changed.isError).not.toBe(true); + const changedResult = toolEvidence( + changed.structuredContent, + ).normalized_result; + expect(changedResult).toMatchObject({ + status: "unknown", + summary: { changed: 2, unchanged: 2, unknown: 1 }, + changes: expect.arrayContaining([ + expect.objectContaining({ + logical_path: ".", + classification: "changed", + dimensions: expect.arrayContaining(["content"]), + }), + expect.objectContaining({ + logical_path: "helper", + classification: "changed", + dimensions: ["metadata"], + left_occurrence_id: leftHelper?.occurrence_id, + right_occurrence_id: rightHelper?.occurrence_id, + }), + expect.objectContaining({ + logical_path: "unavailable-link", + classification: "unknown", + dimensions: ["availability"], + }), + ]), + }); + expect( + z + .object({ changes: z.array(z.object({ logical_path: z.string() })) }) + .parse(changedResult) + .changes.map(({ logical_path }) => logical_path), + ).not.toContain("__pyinstaller__/runtime-options/1"); + expect( + z + .object({ changes: z.array(z.object({ logical_path: z.string() })) }) + .parse(changedResult) + .changes.map(({ logical_path }) => logical_path), + ).not.toContain("__pyinstaller__/dependencies/2"); + + const declarationsOnly = await client.callTool({ + name: "compare_artifacts", + arguments: { left: clean, right: clean }, + }); + expect(declarationsOnly.isError).not.toBe(true); + expect( + toolEvidence(declarationsOnly.structuredContent).normalized_result, + ).toMatchObject({ + status: "unchanged", + summary: { unchanged: 4, unknown: 0 }, + changes: [], + }); + + const layoutOnly = await client.callTool({ + name: "compare_artifacts", + arguments: { left: module, right: shifted }, + }); + expect(layoutOnly.isError).not.toBe(true); + const layoutChanges = z + .object({ changes: z.array(z.object({ logical_path: z.string() })) }) + .parse(toolEvidence(layoutOnly.structuredContent).normalized_result) + .changes.map(({ logical_path }) => logical_path); + expect(layoutChanges).toContain("prefix.bin"); + expect(layoutChanges).not.toContain("helper"); + + const unavailableControl = await client.callTool({ + name: "compare_artifacts", + arguments: { left, right: left }, + }); + expect(unavailableControl.isError).not.toBe(true); + expect( + toolEvidence(unavailableControl.structuredContent).normalized_result, + ).toMatchObject({ + status: "unknown", + changes: [ + expect.objectContaining({ + logical_path: "unavailable-link", + classification: "unknown", + }), + ], + }); + } finally { + await Promise.allSettled([client.close(), server.close(), session.close()]); + } +}); + it("discovers artifact target prerequisites and preserves typed MCP refusals", async () => { const root = await createTestTempDirectory("rea-artifact-availability-mcp-"); const zip = new ZipWriter(new Uint8ArrayWriter());