Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
320e17e602 | ||
|
|
a46d79c529 | ||
|
|
b97ed43352 | ||
|
|
426dd64805 |
No files matched your search
@@ -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: [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 `_`"),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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] };
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user