Compare commits

...
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 320e17e602 fix(native): never forward @ symbols to swift-demangle as response files
LLVM expands an argument starting with `@` as a response file even after
`--`, so `@/dev/null` produced no record and failed the whole batch, and
`@<path>` read a caller-named file instead of demangling the symbol.
`@__swiftmacro_` manglings are forwarded in their equivalent `$s`
spelling; other `@` inputs are reported unchanged with a limitation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 02:55:23 +00:00
Kaoru0822 a46d79c529 Merge remote-tracking branch 'origin/main' into fix/demangle-swift-omitted-record 2026-10-21 02:50:15 +00:00
Kaoru0822andClaude Opus 5.5 b97ed43352 Merge origin/main into fix/demangle-swift-omitted-record
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:06:06 -07:00
Kaoru0822andClaude Opus 5.5 426dd64805 fix(native): keep per-input demangle outcomes when swift-demangle skips _
swift-demangle skips an argument spelled exactly `_` with only a stderr
warning, so its output has one record fewer than its arguments. REA split
empty stdout into one empty record, reporting `_` as demangled to "", and
rejected mixed batches with unreadable_output.

The demangle parser now owns which symbols are forwarded: `_` is never
passed, is reported unchanged with a limitation, and every forwarded
argument keeps a one-to-one LF-terminated record. Empty output now holds
no record and an unterminated record fails closed.

Fixes #5552

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:50:39 +00:00
4 changed files with 272 additions and 45 deletions

No files matched your search

+8 -8
View File
@@ -101,7 +101,10 @@ import {
type SignatureTargetBinding,
} from "./SignatureTargetBinding.js";
import { parseCodeSignature } from "./parsers/codesign.js";
import { parseDemangledSymbols } from "./parsers/demangle.js";
import {
parseDemangledSymbols,
swiftDemangleArguments,
} from "./parsers/demangle.js";
import { parseLipoOutput } from "./parsers/lipo.js";
import {
parsePlistJson,
@@ -614,19 +617,16 @@ class NativeMacOSClient implements AnalysisClient {
"demangle_swift",
"swift-demangle",
// `--` keeps symbols that begin with `-` from selecting demangler options.
["--compact", "--", ...symbols.data],
["--compact", "--", ...swiftDemangleArguments(symbols.data)],
{ signal },
);
if (!capture.ok) return capture;
const provenance = [invocation(capture.value, this.target.path)];
const result = demangleSwiftSchema.parse({
symbols: parseDemangledSymbols(symbols.data, capture.value.stdout),
provenance,
limitations: [],
});
const parsed = parseDemangledSymbols(symbols.data, capture.value.stdout);
const result = demangleSwiftSchema.parse({ ...parsed, provenance });
return ok({
result: jsonValueSchema.parse(result),
limitations: [],
limitations: result.limitations,
locations: [],
});
}
+76 -12
View File
@@ -1,13 +1,13 @@
import { describe, expect, it } from "vitest";
import { parseDemangledSymbols } from "./demangle.js";
import { parseDemangledSymbols, swiftDemangleArguments } from "./demangle.js";
import { swiftSymbolsSchema } from "../../contracts/native/nativeToolContracts.js";
describe("parseDemangledSymbols", () => {
it("returns an empty array when given an empty input list", () => {
expect(swiftSymbolsSchema.safeParse([]).success).toBe(true);
expect(parseDemangledSymbols([], "")).toEqual([]);
expect(parseDemangledSymbols([], "\n")).toEqual([]);
expect(parseDemangledSymbols([], "").symbols).toEqual([]);
expect(parseDemangledSymbols([], "\n").symbols).toEqual([]);
});
it("rejects demangler output when the input batch is empty", () => {
@@ -19,7 +19,7 @@ describe("parseDemangledSymbols", () => {
it("parses demangled symbols successfully", () => {
const inputs = ["$s4main4testyyF"];
const output = "main.test() -> ()\n";
expect(parseDemangledSymbols(inputs, output)).toEqual([
expect(parseDemangledSymbols(inputs, output).symbols).toEqual([
{
input: "$s4main4testyyF",
output: "main.test() -> ()",
@@ -31,7 +31,7 @@ describe("parseDemangledSymbols", () => {
it("identifies unchanged non-Swift symbols", () => {
const inputs = ["_printf"];
const output = "_printf\n";
expect(parseDemangledSymbols(inputs, output)).toEqual([
expect(parseDemangledSymbols(inputs, output).symbols).toEqual([
{
input: "_printf",
output: "_printf",
@@ -52,11 +52,14 @@ describe("parseDemangledSymbols", () => {
"_$eBogus",
"_Tprintf",
];
expect(
parseDemangledSymbols(inputs, `${inputs.join("\n")}\n`).map(
({ status }) => status,
),
).toEqual([...Array<string>(8).fill("invalid"), "unchanged"]);
// The demangler echoes each forwarded argument it cannot decode.
const echoed = `${swiftDemangleArguments(inputs).join("\n")}\n`;
const symbols = parseDemangledSymbols(inputs, echoed).symbols;
expect(symbols.map(({ status }) => status)).toEqual([
...Array<string>(8).fill("invalid"),
"unchanged",
]);
expect(symbols.map(({ output }) => output)).toEqual(inputs);
});
it("keeps the demangler's adjusted spelling of an undecoded name out of demangled", () => {
@@ -80,7 +83,7 @@ describe("parseDemangledSymbols", () => {
"main.Foo",
"main.Foo",
].join("\n");
expect(parseDemangledSymbols(inputs, `${output}\n`)).toEqual([
expect(parseDemangledSymbols(inputs, `${output}\n`).symbols).toEqual([
{ input: "__T0Bogus", output: "_T0Bogus", status: "invalid" },
{ input: "__$sBogus", output: "_$sBogus", status: "invalid" },
{
@@ -99,7 +102,7 @@ describe("parseDemangledSymbols", () => {
it("parses mixed batches preserving ordering", () => {
const inputs = ["$s4main4testyyF", "_printf", "$sInvalidMangle"];
const output = "main.test() -> ()\n_printf\n$sInvalidMangle\n";
expect(parseDemangledSymbols(inputs, output)).toEqual([
expect(parseDemangledSymbols(inputs, output).symbols).toEqual([
{
input: "$s4main4testyyF",
output: "main.test() -> ()",
@@ -118,6 +121,15 @@ describe("parseDemangledSymbols", () => {
]);
});
it("rejects an omitted or unterminated record instead of reporting an empty decoding", () => {
expect(() => parseDemangledSymbols(["$s4main3FooV"], "")).toThrow(
"swift-demangle output count does not match input",
);
expect(() => parseDemangledSymbols(["$s4main3FooV"], "main.Foo")).toThrow(
"swift-demangle output ended inside a record",
);
});
it("throws TypeError when output count does not match input count", () => {
expect(() =>
parseDemangledSymbols(["sym1", "sym2"], "demangled1\n"),
@@ -127,3 +139,55 @@ describe("parseDemangledSymbols", () => {
).toThrow("swift-demangle output count does not match input");
});
});
describe("swift-demangle argument selection", () => {
it("reports `_`, which swift-demangle skips without a record, apart from the demangled batch", () => {
const inputs = ["_", "$s4main3FooV", "_", "_Sa"];
expect(swiftDemangleArguments(inputs)).toEqual(["$s4main3FooV", "_Sa"]);
expect(parseDemangledSymbols(inputs, "main.Foo\n_Sa\n")).toEqual({
symbols: [
{ input: "_", output: "_", status: "unchanged" },
{ input: "$s4main3FooV", output: "main.Foo", status: "demangled" },
{ input: "_", output: "_", status: "unchanged" },
{ input: "_Sa", output: "_Sa", status: "unchanged" },
],
limitations: [expect.stringContaining("skips the symbol `_`")],
});
expect(swiftDemangleArguments(["_"])).toEqual([]);
expect(parseDemangledSymbols(["_"], "")).toEqual({
symbols: [{ input: "_", output: "_", status: "unchanged" }],
limitations: [expect.stringContaining("skips the symbol `_`")],
});
expect(
parseDemangledSymbols(["$s4main3FooV"], "main.Foo\n").limitations,
).toEqual([]);
});
it("never forwards `@` symbols, which LLVM would expand as response files", () => {
const macro = "@__swiftmacro_1a13testStringifyAA1bySi_SitF9stringifyfMf_";
const inputs = ["@/dev/null", macro, "$s4main3FooV", "_", "@x", "a@b"];
expect(swiftDemangleArguments(inputs)).toEqual([
"$s1a13testStringifyAA1bySi_SitF9stringifyfMf_",
"$s4main3FooV",
"a@b",
]);
const expansion =
"freestanding macro expansion #1 of stringify in a.testStringify(a: Swift.Int, b: Swift.Int) -> ()";
expect(
parseDemangledSymbols(inputs, `${expansion}\nmain.Foo\na@b\n`),
).toEqual({
symbols: [
{ input: "@/dev/null", output: "@/dev/null", status: "unchanged" },
{ input: macro, output: expansion, status: "demangled" },
{ input: "$s4main3FooV", output: "main.Foo", status: "demangled" },
{ input: "_", output: "_", status: "unchanged" },
{ input: "@x", output: "@x", status: "unchanged" },
{ input: "a@b", output: "a@b", status: "unchanged" },
],
limitations: [
expect.stringContaining("as a response file"),
expect.stringContaining("skips the symbol `_`"),
],
});
});
});
+86 -24
View File
@@ -3,7 +3,8 @@ import type { z } from "zod";
import { isSwiftMangledSymbolName } from "../../domain/swiftSymbolClassification.js";
import type { demangleSwiftSchema } from "../../domain/native/nativeInspection.js";
type DemangledSymbol = z.infer<typeof demangleSwiftSchema>["symbols"][number];
type DemangleSwift = z.infer<typeof demangleSwiftSchema>;
type DemangledSymbol = DemangleSwift["symbols"][number];
/**
* swift-demangle removes one underscore from a name starting with `__` before
@@ -15,37 +16,98 @@ const linkageName = (input: string): string =>
const undecodedSpelling = (input: string): string =>
/^[Sse]/u.test(input) ? `$${input}` : linkageName(input);
const MACRO_PREFIX = "@__swiftmacro_";
/**
* Preserve input ordering while parsing `swift-demangle --compact` lines.
* The demangler ends each record with LF, so a carriage return belongs to the
* symbol text.
* The swift-demangle argument for one input, or why it is reported unchanged.
* - LLVM expands an argument starting with `@` as a response file, even after
* `--`, so it would read a file instead of the symbol. The macro prefix is
* an alternative spelling of `$s` and is forwarded in that spelling; no other
* Swift mangling starts with `@`.
* - Current swift-demangle skips an argument spelled exactly `_` (a likely
* shell expansion of `_$s…`) with only a stderr warning, while older
* toolchains print it unchanged.
*/
const demangleArgument = (
input: string,
): { argument: string } | { limitation: string } => {
if (input.startsWith(MACRO_PREFIX))
return { argument: `$s${input.slice(MACRO_PREFIX.length)}` };
if (input.startsWith("@"))
return {
limitation:
"swift-demangle reads an argument starting with `@` as a response file, so non-Swift symbols starting with `@` were not passed to the demangler and are reported unchanged.",
};
if (input === "_")
return {
limitation:
"swift-demangle skips the symbol `_` without output, so it was not passed to the demangler; it is reported unchanged because it is not a Swift mangling.",
};
return { argument: input };
};
/** Symbol arguments for `swift-demangle`; each yields one output record. */
export const swiftDemangleArguments = (inputs: readonly string[]): string[] =>
inputs.flatMap((input) => {
const selected = demangleArgument(input);
return "argument" in selected ? [selected.argument] : [];
});
/** Split LF-terminated records; empty output contains no record. */
const outputRecords = (output: string): string[] => {
if (output === "") return [];
if (!output.endsWith("\n"))
throw new TypeError("swift-demangle output ended inside a record");
return output.slice(0, -1).split("\n");
};
const decodedSymbol = (
input: string,
argument: string,
value: string,
): DemangledSymbol => {
const decoded = value !== argument && value !== undecodedSpelling(argument);
return {
input,
// Status describes decoding; retain the producer's exact spelling even
// when it adjusted an identifier that it could not decode, unless that
// spelling is only the forwarded rewrite of the caller's input.
output: !decoded && argument !== input ? input : value,
status: decoded
? "demangled"
: isSwiftMangledSymbolName(linkageName(input))
? "invalid"
: "unchanged",
};
};
/**
* Preserve input ordering while parsing `swift-demangle --compact` output for
* the arguments selected by `swiftDemangleArguments`. The demangler ends each
* record with LF, so a carriage return belongs to the symbol text.
*/
export const parseDemangledSymbols = (
inputs: readonly string[],
output: string,
): DemangledSymbol[] => {
if (inputs.length === 0) {
if (output === "" || output === "\n") return [];
): Pick<DemangleSwift, "symbols" | "limitations"> => {
const forwarded = swiftDemangleArguments(inputs);
if (forwarded.length === 0 && output !== "" && output !== "\n")
throw new TypeError("swift-demangle emitted output for an empty input");
}
const lines = output.replace(/\n$/u, "").split("\n");
if (lines.length !== inputs.length)
const records = forwarded.length === 0 ? [] : outputRecords(output);
if (records.length !== forwarded.length)
throw new TypeError("swift-demangle output count does not match input");
return inputs.map<DemangledSymbol>((input, index) => {
const value = lines[index];
let next = 0;
const limitations = new Set<string>();
const symbols = inputs.map<DemangledSymbol>((input) => {
const selected = demangleArgument(input);
if ("limitation" in selected) {
limitations.add(selected.limitation);
return { input, output: input, status: "unchanged" };
}
const value = records[next++];
if (value === undefined)
throw new TypeError("swift-demangle omitted an output line");
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: decoded
? "demangled"
: isSwiftMangledSymbolName(linkageName(input))
? "invalid"
: "unchanged",
};
return decodedSymbol(input, selected.argument, value);
});
return { symbols, limitations: [...limitations] };
};
+102 -1
View File
@@ -1,5 +1,5 @@
import { execFile } from "node:child_process";
import { copyFile } from "node:fs/promises";
import { copyFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
@@ -127,3 +127,104 @@ describe.skipIf(process.platform !== "darwin")(
);
},
);
describe.skipIf(process.platform !== "darwin")(
"swift-demangle arguments forwarded through MCP",
() => {
mcpTest(
"keeps one ordered outcome per input when swift-demangle skips `_`",
async ({ mcp, onTestFinished }) => {
const target = join(
await createTestTempDirectory("rea-demangle-mcp-"),
"tool",
);
await copyFile("/usr/bin/true", target);
const client = await openProductionTarget(mcp, onTestFinished, target);
const skipped = { input: "_", output: "_", status: "unchanged" };
for (const [symbols, expected] of [
[["_"], [skipped]],
[
["_", "$s4main3FooV", "_Sa"],
[
skipped,
{
input: "$s4main3FooV",
output: "main.Foo",
status: "demangled",
},
{ input: "_Sa", output: "_Sa", status: "unchanged" },
],
],
] as const) {
const called = await client.callTool({
name: "demangle_swift",
arguments: { symbols },
});
expect(called.isError, JSON.stringify(called.content)).not.toBe(true);
const result = demangledSchema.parse(
called.structuredContent,
).normalized_result;
expect(result.symbols).toEqual(expected);
expect(result.limitations).toEqual([
expect.stringContaining("skips the symbol `_`"),
]);
expect(
result.provenance.find(({ tool }) => tool === "swift-demangle")
?.command,
).not.toContain("_");
}
},
);
mcpTest(
"reports `@` symbols unchanged instead of reading them as response files",
async ({ mcp, onTestFinished }) => {
const directory = await createTestTempDirectory("rea-demangle-mcp-");
const target = join(directory, "tool");
await copyFile("/usr/bin/true", target);
// A real response file whose contents would demangle if it were read.
const responseFile = join(directory, "symbols.rsp");
await writeFile(responseFile, "$s4main3FooV\n");
const client = await openProductionTarget(mcp, onTestFinished, target);
const macro =
"@__swiftmacro_1a13testStringifyAA1bySi_SitF9stringifyfMf_";
const symbols = [
`@${responseFile}`,
"@/dev/null",
macro,
"$s4main3FooV",
];
const called = await client.callTool({
name: "demangle_swift",
arguments: { symbols },
});
expect(called.isError, JSON.stringify(called.content)).not.toBe(true);
const result = demangledSchema.parse(
called.structuredContent,
).normalized_result;
expect(result.symbols).toEqual([
{ input: symbols[0], output: symbols[0], status: "unchanged" },
{ input: "@/dev/null", output: "@/dev/null", status: "unchanged" },
{
input: macro,
output:
"freestanding macro expansion #1 of stringify in a.testStringify(a: Swift.Int, b: Swift.Int) -> ()",
status: "demangled",
},
{ input: "$s4main3FooV", output: "main.Foo", status: "demangled" },
]);
expect(result.limitations).toEqual([
expect.stringContaining("as a response file"),
]);
const command = result.provenance.find(
({ tool }) => tool === "swift-demangle",
)?.command;
expect(command?.some((argument) => argument.startsWith("@"))).toBe(
false,
);
},
);
},
);