From 60981e0801724e0b97835e437225a2324d072edf Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:00:24 +0800 Subject: [PATCH] fix(python): honor version-specific code equality Fixes #6547 --- src/python/PythonPycParser.test.ts | 62 +++++++++++++++++++++++++++ src/python/PythonPycParser.ts | 8 +++- tests/acceptance/pythonPycCli.test.ts | 56 ++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/src/python/PythonPycParser.test.ts b/src/python/PythonPycParser.test.ts index bde2ad727e..d3bc5ad83c 100644 --- a/src/python/PythonPycParser.test.ts +++ b/src/python/PythonPycParser.test.ts @@ -918,6 +918,68 @@ it("accepts code objects as set members and dictionary keys", () => { expect(pythonPycSchema.safeParse(report).success).toBe(true); }); +it("uses the code equality fields for each CPython layout", () => { + const codeCollections = ( + layout: "py38" | "py311", + different: boolean, + ): [Buffer, Buffer] => { + const lineA = marshalCode(layout, { + name: "f", + filename: "fixture.py", + firstlineno: 1, + linetable: Buffer.from([2, 1]), + }); + const lineB = marshalCode(layout, { + name: "f", + filename: "fixture.py", + firstlineno: 1, + linetable: Buffer.from([2, different ? 2 : 1]), + }); + const frozenSet = Buffer.concat([ + Buffer.from([0x3e]), + u32(2), + lineA, + lineB, + ]); + const dict = Buffer.concat([ + Buffer.from([0x7b]), + lineA, + Buffer.from([0x4e]), + lineB, + Buffer.from([0x54, 0x30]), + ]); + return [frozenSet, dict]; + }; + for (const [magic, layout] of [ + [3413, "py38"], + [3439, "py38"], + ] as const) { + const [frozenSet, dict] = codeCollections(layout, true); + const report = parse( + Buffer.concat([ + pythonPycHeader(magic), + marshalCode(layout, { consts: [frozenSet, dict] }), + ]), + ); + expect(root(report).consts).toEqual([ + { kind: "frozenset", length: 1 }, + { kind: "dict", length: 1 }, + ]); + } + + const [frozenSet, dict] = codeCollections("py311", true); + const py312 = parse( + Buffer.concat([ + pythonPycHeader(3531), + marshalCode("py311", { consts: [frozenSet, dict] }), + ]), + ); + expect(root(py312).consts).toEqual([ + { kind: "frozenset", length: 2 }, + { kind: "dict", length: 2 }, + ]); +}); + it("keeps the first dictionary key when a later key compares equal", () => { const key = (filename: string): Buffer => marshalCode("py311", { diff --git a/src/python/PythonPycParser.ts b/src/python/PythonPycParser.ts index acfe4e1365..54c7839a9a 100644 --- a/src/python/PythonPycParser.ts +++ b/src/python/PythonPycParser.ts @@ -1156,8 +1156,12 @@ class MarshalReader { `${node.bytecode.bytes}:${node.bytecode.sha256}`, this.#constKeyItems(material.consts), ...nameTables, - material.linetable, - material.exceptiontable, + // CPython started including line and exception tables in code equality + // with the 3.11 code layout; earlier layouts still retain their digests + // in CodeEquality, but those fields do not affect container identity. + ...(this.layout === "py311" + ? [material.linetable, material.exceptiontable] + : []), ]; const key = this.#boundedJoin( "", diff --git a/tests/acceptance/pythonPycCli.test.ts b/tests/acceptance/pythonPycCli.test.ts index 579b036d91..d83d8404a1 100644 --- a/tests/acceptance/pythonPycCli.test.ts +++ b/tests/acceptance/pythonPycCli.test.ts @@ -61,6 +61,62 @@ cliTest( }, ); +cliTest( + "counts code keys using the selected CPython equality rules", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-python-pyc-equality-"); + const cases = [ + { magic: 3413, layout: "py38" as const, lengths: [1, 1] }, + { magic: 3439, layout: "py38" as const, lengths: [1, 1] }, + { magic: 3531, layout: "py311" as const, lengths: [2, 2] }, + ]; + for (const [index, testCase] of cases.entries()) { + const code = (linetable: Buffer) => + marshalCode(testCase.layout, { + name: "f", + filename: "fixture.py", + firstlineno: 1, + linetable, + }); + const frozenSet = Buffer.concat([ + Buffer.from([0x3e]), + Buffer.from([2, 0, 0, 0]), + code(Buffer.from([2, 1])), + code(Buffer.from([2, 2])), + ]); + const dict = Buffer.concat([ + Buffer.from([0x7b]), + code(Buffer.from([2, 1])), + Buffer.from([0x4e]), + code(Buffer.from([2, 2])), + Buffer.from([0x54, 0x30]), + ]); + const path = join(root, `case-${index}.pyc`); + await writeFile( + path, + Buffer.concat([ + pythonPycHeader(testCase.magic), + marshalCode(testCase.layout, { consts: [frozenSet, dict] }), + ]), + ); + 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]?.consts.map((value) => + value.kind === "frozenset" || value.kind === "dict" + ? value.length + : null, + ), + ).toEqual(testCase.lengths); + } + }, +); + cliTest( "resolves a relative pyc path against the working directory", async ({ cli }) => {