diff --git a/docs/python-pyc.md b/docs/python-pyc.md index b68c74bf7a..92e1bcc43d 100644 --- a/docs/python-pyc.md +++ b/docs/python-pyc.md @@ -42,8 +42,8 @@ even when that owner reaches it again through the same container. Relationships are collected after the marshal graph is complete, so a container that a nested code object also references is not summarized while its items are still missing. Each object reports name, optional qualname, -filename, -argument counts, flags, names, locals (or `localsplusnames` on 3.11+), const +filename, the signed 32-bit `co_firstlineno` value, argument counts, flags, +names, locals (or `localsplusnames` on 3.11+), const summaries, and bytecode length plus SHA-256. Bytecode is not decompiled. Const strings may be truncated in the summary while diff --git a/src/domain/python/pythonPyc.ts b/src/domain/python/pythonPyc.ts index e5415a89cc..eed477c998 100644 --- a/src/domain/python/pythonPyc.ts +++ b/src/domain/python/pythonPyc.ts @@ -8,6 +8,7 @@ export const PYTHON_PYC_CHECK_SOURCE = 0x2; const uint = z.number().int().min(0).max(0xffffffff); const index = z.number().int().nonnegative(); +const signedInt32 = z.number().int().min(-0x80000000).max(0x7fffffff); /** Non-negative CPython `int` counts. `0xffffffff` is -1 and is not evidence. */ const signedCount = z.number().int().min(0).max(0x7fffffff); @@ -137,7 +138,7 @@ const pythonConstSchema = z.discriminatedUnion("kind", [ z.strictObject({ kind: z.literal("code"), name: z.string(), - firstlineno: uint, + firstlineno: signedInt32, }), z.strictObject({ kind: z.literal("tuple"), length: uint }), z.strictObject({ kind: z.literal("list"), length: uint }), @@ -157,7 +158,7 @@ const pythonCodeFields = { name: z.string(), qualname: z.string().nullable(), filename: z.string(), - firstlineno: uint, + firstlineno: signedInt32, argcount: signedCount, posonlyargcount: signedCount.nullable(), kwonlyargcount: signedCount, diff --git a/src/python/PythonPyc.fixture.ts b/src/python/PythonPyc.fixture.ts index 550ba51b90..370b8dfda5 100644 --- a/src/python/PythonPyc.fixture.ts +++ b/src/python/PythonPyc.fixture.ts @@ -8,6 +8,11 @@ const u32 = (value: number): Buffer => { bytes.writeUInt32LE(value); return bytes; }; +const i32 = (value: number): Buffer => { + const bytes = Buffer.alloc(4); + bytes.writeInt32LE(value); + return bytes; +}; const concat = (...parts: Buffer[]): Buffer => Buffer.concat(parts); /** 16-byte CPython 3.7+ pyc header. Hash-based pycs store an 8-byte source hash. */ @@ -136,7 +141,7 @@ export const marshalCode = ( marshalShortAscii(fields.qualname ?? fields.name ?? ""), ); parts.push( - u32(fields.firstlineno ?? 1), + i32(fields.firstlineno ?? 1), marshalBytes(fields.linetable ?? Buffer.alloc(0)), ); if (layout === "py311") diff --git a/src/python/PythonPycParser.test.ts b/src/python/PythonPycParser.test.ts index a2fce3a085..bde2ad727e 100644 --- a/src/python/PythonPycParser.test.ts +++ b/src/python/PythonPycParser.test.ts @@ -72,6 +72,46 @@ it("reads 3.7, 3.10 and 3.12 layouts including nested code", () => { ); }); +it("preserves signed co_firstlineno in code objects and code constants", () => { + const nested = marshalCode("py311", { + name: "inner", + firstlineno: -7, + }); + const bytes = Buffer.concat([ + pythonPycHeader(3531), + marshalCode("py311", { + firstlineno: -1, + consts: [nested], + }), + ]); + const report = parse(bytes); + expect(root(report).firstlineno).toBe(-1); + expect(root(report).consts).toContainEqual({ + kind: "code", + name: "inner", + firstlineno: -7, + }); + expect(pythonPycSchema.safeParse(report).success).toBe(true); + expect( + pythonPycSchema.safeParse({ + ...report, + code_objects: [ + { ...root(report), firstlineno: -0x8000_0001 }, + ...report.code_objects.slice(1), + ], + }).success, + ).toBe(false); + expect( + pythonPycSchema.safeParse({ + ...report, + code_objects: [ + { ...root(report), firstlineno: 0x8000_0000 }, + ...report.code_objects.slice(1), + ], + }).success, + ).toBe(false); +}); + it("decodes a hash-based header and a FLAG_REF top-level code object", () => { const bytes = pythonPycFixture("py311", { flaggedCode: true, diff --git a/src/python/PythonPycParser.ts b/src/python/PythonPycParser.ts index 03b79bddab..acfe4e1365 100644 --- a/src/python/PythonPycParser.ts +++ b/src/python/PythonPycParser.ts @@ -457,7 +457,7 @@ class MarshalReader { this.layout === "py311" ? asString(this.readObject(depth + 1), "co_qualname") : null; - const firstlineno = this.u32(); + const firstlineno = this.i32(); const linetable = asBytes(this.readObject(depth + 1), "co_linetable"); const exceptiontable = this.layout === "py311" diff --git a/tests/acceptance/pythonPycCli.test.ts b/tests/acceptance/pythonPycCli.test.ts index f10e78ce16..579b036d91 100644 --- a/tests/acceptance/pythonPycCli.test.ts +++ b/tests/acceptance/pythonPycCli.test.ts @@ -2,7 +2,11 @@ import { mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; import { toolContract } from "../../src/contracts/toolContracts.js"; -import { pythonPycFixture } from "../../src/python/PythonPyc.fixture.js"; +import { + marshalCode, + pythonPycFixture, + pythonPycHeader, +} from "../../src/python/PythonPyc.fixture.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { cliTest } from "../support/cli/cliFixture.js"; @@ -24,6 +28,39 @@ cliTest( }, ); +cliTest( + "preserves negative CPython first line numbers through the public CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-python-pyc-lines-"); + const path = join(root, "sample.pyc"); + const nested = marshalCode("py311", { + name: "inner", + firstlineno: -7, + }); + const bytes = Buffer.concat([ + pythonPycHeader(3531), + marshalCode("py311", { + firstlineno: -1, + consts: [nested], + }), + ]); + await writeFile(path, bytes); + const result = await cli.run({ + arguments: ["inspect-python-pyc", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_python_pyc").outputSchema.parse( + result.json, + ); + expect(evidence.normalized_result.code_objects[0]?.firstlineno).toBe(-1); + expect(evidence.normalized_result.code_objects[0]?.consts).toContainEqual({ + kind: "code", + name: "inner", + firstlineno: -7, + }); + }, +); + cliTest( "resolves a relative pyc path against the working directory", async ({ cli }) => { diff --git a/tests/boundary/mcp/pythonPycMcp.test.ts b/tests/boundary/mcp/pythonPycMcp.test.ts index 07d13da005..c8a70cb31c 100644 --- a/tests/boundary/mcp/pythonPycMcp.test.ts +++ b/tests/boundary/mcp/pythonPycMcp.test.ts @@ -1,11 +1,16 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { Ajv2020 } from "ajv/dist/2020.js"; import { expect } from "vitest"; import { toolContract } from "../../../src/contracts/toolContracts.js"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; -import { pythonPycFixture } from "../../../src/python/PythonPyc.fixture.js"; +import { + marshalCode, + pythonPycFixture, + pythonPycHeader, +} from "../../../src/python/PythonPyc.fixture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { mcpTest, type TestMcp } from "../../support/mcp/mcpFixture.js"; @@ -29,9 +34,27 @@ mcpTest( readOnlyHint: false, idempotentHint: true, }); + if (advertised?.outputSchema === undefined) + throw new Error("Missing inspect_python_pyc output schema"); + const outputSchema: Record = advertised.outputSchema; + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + expect(ajv.validateSchema(outputSchema)).toBe(true); const root = await createTestTempDirectory("rea-python-pyc-mcp-"); const path = join(root, "sample.pyc"); - await writeFile(path, pythonPycFixture("py311", { nested: true })); + const nested = marshalCode("py311", { + name: "inner", + firstlineno: -7, + }); + await writeFile( + path, + Buffer.concat([ + pythonPycHeader(3531), + marshalCode("py311", { + firstlineno: -1, + consts: [nested], + }), + ]), + ); const response = await client.callTool({ name: "inspect_python_pyc", arguments: { path }, @@ -40,8 +63,20 @@ mcpTest( const evidence = toolContract("inspect_python_pyc").outputSchema.parse( response.structuredContent, ); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); expect(evidence.normalized_result.header.python_version).toBe("3.12"); + expect(evidence.normalized_result.code_objects[0]?.firstlineno).toBe(-1); expect(evidence.normalized_result.code_objects[1]?.name).toBe("inner"); + expect(evidence.normalized_result.code_objects[0]?.consts).toContainEqual( + { + kind: "code", + name: "inner", + firstlineno: -7, + }, + ); } finally { await session.close(); }