fix(apple): explain present rejected dylib candidates

Derive unresolved findings from stored candidate paths and outcomes while preserving weak, lazy and conditional load consequences. Document not-loadable and platform-mismatch outcomes and later-path resolution.

Validation: 126 resolver/CLI/MCP tests, check:ci, docs:check, and seven compiled CLI plus SDK-validated stdio MCP fixture cases. Source-built and mutated inert Mach-O inputs retained identical hashes; no target execution or dyld runtime comparison.

Fixes #5747

Fixes #5694
This commit is contained in:
morluto committed 2026-10-21 12:34:05 +08:00
1 parent f1126ff7bf
commit 3b67b362aa
3 files changed
+63 -3

No files matched your search

+10 -2
View File
@@ -213,8 +213,16 @@ Resolution follows dyld:
Each edge lists every candidate path tried, with its outcome: `resolved`,
`absent`, `not-mach-o`, `malformed`, `unsupported` (recognized Mach-O outside
parser coverage, such as big-endian images), `architecture-missing`,
`outside-target`, `escapes-target`, or `undetermined`. Slices follow dyld's
graded architectures, so an `x86_64h` process also loads `x86_64`. The edge's `resolution` is one of:
`not-loadable`, `platform-mismatch`, `outside-target`, `escapes-target`, or
`undetermined`. `not-loadable` means a
matching-architecture Mach-O image was found but it is not a dylib and cannot
satisfy a dylib dependency. `platform-mismatch` means a matching-architecture
dylib was found but its observed platform is incompatible with the process.
These outcomes reject that candidate; dyld search may continue and resolve a
later candidate. Candidate outcomes describe individual search paths, while
`resolution.status` describes the final edge. Slices follow dyld's graded
architectures, so an `x86_64h` process also loads `x86_64`. The edge's
`resolution` is one of:
| Status | Meaning |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+3
View File
@@ -398,6 +398,9 @@ describe("dyld resolution outcomes: checking candidate format and compatibility"
({ outcome }) => outcome,
),
).toEqual(["not-mach-o", "malformed", "architecture-missing"]);
expect(trace.findings[0]?.explanation).toContain(
"No usable candidate for @rpath/lib.dylib exists in the analyzed root; found Contents/MacOS/a/lib.dylib (not a Mach-O image), Contents/MacOS/b/lib.dylib (malformed), Contents/MacOS/c/lib.dylib (missing the process architecture)",
);
expect(trace.coverage).toEqual({
status: "partial",
unparsed_images: ["Contents/MacOS/b/lib.dylib"],
+50 -1
View File
@@ -70,7 +70,10 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => {
basis: "derived",
} as const;
const lazy = isLazyLoadCommand(edge.command);
const missing = `No candidate for ${dependencyPhrase(edge, lazy)} exists in the analyzed root`;
const missing = missingCandidateExplanation(
edge,
dependencyPhrase(edge, lazy),
);
// Below a conditional fallback, every consequence applies only if it loads.
const when = edge.loader_conditional
? `. ${edge.loader} loads only conditionally; if it loads, `
@@ -94,6 +97,52 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => {
};
};
/** Use the resolver's physical-candidate facts without rechecking source paths. */
const missingCandidateExplanation = (
edge: Edge,
dependency: string,
): string => {
const present = edge.candidates.flatMap(({ outcome, resolved_path }) =>
resolved_path === null ? [] : [{ outcome, path: resolved_path }],
);
if (present.length === 0)
return `No candidate for ${dependency} exists in the analyzed root`;
const descriptions = present.map(
({ outcome, path }) => `${path} (${candidateRejection(outcome)})`,
);
return `No usable candidate for ${dependency} exists in the analyzed root; found ${descriptions.join(", ")}`;
};
/** Explain a found candidate from the resolver's outcome, not by reopening it. */
const candidateRejection = (
outcome: Edge["candidates"][number]["outcome"],
): string => {
switch (outcome) {
case "not-mach-o":
return "not a Mach-O image";
case "malformed":
return "malformed";
case "architecture-missing":
return "missing the process architecture";
case "not-loadable":
return "not a loadable dylib";
case "platform-mismatch":
return "incompatible with the process platform";
case "unsupported":
return "unsupported by the parser";
case "resolved":
return "loadable";
case "absent":
return "absent";
case "outside-target":
return "outside the analyzed root";
case "escapes-target":
return "escapes the analyzed root";
case "undetermined":
return "of undetermined loadability";
}
};
/** Name the dependency the way the finding's consequence treats it. */
const dependencyPhrase = (edge: Edge, lazy: boolean): string => {
if (lazy && edge.weak)