diff --git a/src/native/parsers/demangle.test.ts b/src/native/parsers/demangle.test.ts index dd17e34aad..ab2e5b5a4e 100644 --- a/src/native/parsers/demangle.test.ts +++ b/src/native/parsers/demangle.test.ts @@ -59,6 +59,43 @@ describe("parseDemangledSymbols", () => { ).toEqual([...Array(8).fill("invalid"), "unchanged"]); }); + it("keeps the demangler's adjusted spelling of an undecoded name out of demangled", () => { + const inputs = [ + "__T0Bogus", + "__$sBogus", + "___stack_chk_fail", + "strlen", + "Symbol", + "entry", + "__T04main3FooV", + "s4main3FooV", + ]; + const output = [ + "_T0Bogus", + "_$sBogus", + "__stack_chk_fail", + "$strlen", + "$Symbol", + "$entry", + "main.Foo", + "main.Foo", + ].join("\n"); + expect(parseDemangledSymbols(inputs, `${output}\n`)).toEqual([ + { input: "__T0Bogus", output: "_T0Bogus", status: "invalid" }, + { input: "__$sBogus", output: "_$sBogus", status: "invalid" }, + { + input: "___stack_chk_fail", + output: "__stack_chk_fail", + status: "unchanged", + }, + { input: "strlen", output: "$strlen", status: "unchanged" }, + { input: "Symbol", output: "$Symbol", status: "unchanged" }, + { input: "entry", output: "$entry", status: "unchanged" }, + { input: "__T04main3FooV", output: "main.Foo", status: "demangled" }, + { input: "s4main3FooV", output: "main.Foo", status: "demangled" }, + ]); + }); + it("parses mixed batches preserving ordering", () => { const inputs = ["$s4main4testyyF", "_printf", "$sInvalidMangle"]; const output = "main.test() -> ()\n_printf\n$sInvalidMangle\n"; diff --git a/src/native/parsers/demangle.ts b/src/native/parsers/demangle.ts index 3c036d37b7..154490543a 100644 --- a/src/native/parsers/demangle.ts +++ b/src/native/parsers/demangle.ts @@ -1,14 +1,19 @@ -import { z } from "zod"; +import type { z } from "zod"; import { isSwiftMangledSymbolName } from "../../domain/swiftSymbolClassification.js"; +import type { demangleSwiftSchema } from "../../domain/native/nativeInspection.js"; -const resultSchema = z.object({ - input: z.string(), - output: z.string(), - status: z.enum(["demangled", "unchanged", "invalid"]), -}); +type DemangledSymbol = z.infer["symbols"][number]; -export type DemangledSymbol = z.infer; +/** + * swift-demangle removes one underscore from a name starting with `__` before + * demangling, and prefixes `$` to an argument starting with S, s or e. A name + * it cannot decode is printed in that adjusted spelling. + */ +const linkageName = (input: string): string => + input.startsWith("__") ? input.slice(1) : input; +const undecodedSpelling = (input: string): string => + /^[Sse]/u.test(input) ? `$${input}` : linkageName(input); /** * Preserve input ordering while parsing `swift-demangle --compact` lines. @@ -26,19 +31,21 @@ export const parseDemangledSymbols = ( const lines = output.replace(/\n$/u, "").split("\n"); if (lines.length !== inputs.length) throw new TypeError("swift-demangle output count does not match input"); - return inputs.map((input, index) => { + return inputs.map((input, index) => { const value = lines[index]; if (value === undefined) throw new TypeError("swift-demangle omitted an output line"); - return resultSchema.parse({ + const decoded = value !== input && value !== undecodedSpelling(input); + return { input, + // Status describes decoding; retain the producer's exact spelling even + // when it adjusted an identifier that it could not decode. output: value, - status: - value === input - ? isSwiftMangledSymbolName(input) - ? "invalid" - : "unchanged" - : "demangled", - }); + status: decoded + ? "demangled" + : isSwiftMangledSymbolName(linkageName(input)) + ? "invalid" + : "unchanged", + }; }); }; diff --git a/tests/boundary/mcp/nativeDemangleMcp.test.ts b/tests/boundary/mcp/nativeDemangleMcp.test.ts index 3130d09bba..14c13e8d25 100644 --- a/tests/boundary/mcp/nativeDemangleMcp.test.ts +++ b/tests/boundary/mcp/nativeDemangleMcp.test.ts @@ -1,5 +1,7 @@ +import { execFile } from "node:child_process"; import { copyFile } from "node:fs/promises"; import { join } from "node:path"; +import { promisify } from "node:util"; import { describe, expect } from "vitest"; @@ -11,6 +13,7 @@ import { mcpTest } from "../../support/mcp/mcpFixture.js"; import { openProductionTarget } from "../../support/mcp/productionSession.js"; const demangledSchema = evidenceResultOf(demangleSwiftSchema); +const execute = promisify(execFile); describe.skipIf(process.platform !== "darwin")( "native Swift demangling through MCP", @@ -86,19 +89,40 @@ describe.skipIf(process.platform !== "darwin")( ...decodable.map((prefix) => `${prefix}4main3FooV`), ...prefixes.map((prefix) => `${prefix}Bogus`), "_Tprintf", + // Current swift-demangle prints these undecoded names as + // _T0Bogus, __stack_chk_fail and $strlen. + "__T0Bogus", + "___stack_chk_fail", + "strlen", ], }, }); expect(called.isError, JSON.stringify(called.content)).not.toBe(true); - expect( - demangledSchema - .parse(called.structuredContent) - .normalized_result.symbols.map(({ status }) => status), - ).toEqual([ + const result = demangledSchema.parse( + called.structuredContent, + ).normalized_result; + const { symbols } = result; + expect(symbols.map(({ status }) => status)).toEqual([ ...decodable.map(() => "demangled"), ...prefixes.map(() => "invalid"), "unchanged", + "invalid", + "unchanged", + "unchanged", ]); + // Toolchain versions can differ in their undecoded spelling. Compare + // retained Evidence with the actual producer instead of rewriting it + // to match the input or requiring one toolchain's adjustment. + const command = result.provenance.find( + ({ tool }) => tool === "swift-demangle", + )?.command; + const executable = command?.[0]; + if (executable === undefined || command === undefined) + throw new Error("Missing demangler command provenance"); + const { stdout } = await execute(executable, command.slice(1)); + expect(symbols.map(({ output }) => output)).toEqual( + stdout.replace(/\n$/u, "").split("\n"), + ); }, ); },