From 9226ade5114f287eb8e1b00b08dd91da1cc66003 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:01:35 +0000 Subject: [PATCH 1/3] fix(apple): pair bridge hypotheses within one application root A ZIP or DMG holding two applications produced bridge candidates from scripts in one app to native code in the other. Candidates now pair only components owned by the same application root; components outside every root pair only with each other. A versioned framework with several real version directories now keeps its Info.plist unknown even when only one version has a plist, because Versions/Current, which is not inventoried, decides which applies. Co-Authored-By: Claude Opus 5.5 --- src/domain/appleApplication.test.ts | 45 +++++++++++++++++++++++++++++ src/domain/appleApplication.ts | 30 ++++++++++++------- src/domain/appleBundleAnatomy.ts | 6 +++- 3 files changed, 70 insertions(+), 11 deletions(-) diff --git a/src/domain/appleApplication.test.ts b/src/domain/appleApplication.test.ts index 8e61d8e2d..b9e1041b0 100644 --- a/src/domain/appleApplication.test.ts +++ b/src/domain/appleApplication.test.ts @@ -309,6 +309,17 @@ describe("Apple bundle layouts and containers", () => { expect( result.bundles.find(({ path }) => path === framework)?.info_plist_path, ).toBeNull(); + // Only A has a plist, but Versions/Current may select B. + const onlyA = project( + inventoryEvidence("zip", "Fixture.zip", [ + macho("Fixture.app/Contents/MacOS/Fixture"), + { path: `${framework}/Versions/A/Resources/Info.plist` }, + macho(`${framework}/Versions/B/Core`), + ]), + ); + expect( + onlyA.bundles.find(({ path }) => path === framework)?.info_plist_path, + ).toBeNull(); expect(result.limitations).toContainEqual( expect.stringContaining("Versions/Current selects is unknown"), ); @@ -438,3 +449,37 @@ describe("macOS application scope", () => { }); }); }); + +describe("Apple application bridge candidates", () => { + it("pairs scripts and native code only within one application root", () => { + const result = project( + inventoryEvidence("zip", "Suite.zip", [ + macho("First.app/Contents/MacOS/First"), + { + path: "First.app/Contents/Resources/first.js", + format: "javascript-bundle", + }, + macho("Second.app/Contents/MacOS/Second"), + { + path: "Second.app/Contents/Resources/second.js", + format: "javascript-bundle", + }, + ]), + ); + expect(result.application_roots).toEqual(["First.app", "Second.app"]); + expect( + result.bridge_candidates.map( + ({ source_path: source, native_path: target }) => [source, target], + ), + ).toEqual([ + [ + "First.app/Contents/Resources/first.js", + "First.app/Contents/MacOS/First", + ], + [ + "Second.app/Contents/Resources/second.js", + "Second.app/Contents/MacOS/Second", + ], + ]); + }); +}); diff --git a/src/domain/appleApplication.ts b/src/domain/appleApplication.ts index 4650fce72..42bc970b6 100644 --- a/src/domain/appleApplication.ts +++ b/src/domain/appleApplication.ts @@ -149,6 +149,7 @@ export const projectAppleApplication = ( ...classified.executables, ...classified.native_libraries, ]), + roots, ); const uninventoriedImage = rootFormat === "dmg" && inventory.occurrences.length === 1; @@ -305,18 +306,27 @@ const identifyRuntimeFamilies = (all: readonly Component[]) => { return [...families].sort(compare); }; +/** Pair scripts and native code only within the same application root. */ const identifyBridgeCandidates = ( scripts: readonly Component[], native: readonly Component[], -) => ({ - candidates: scripts.flatMap((script) => - native.map((item) => ({ - source_path: script.path, - native_path: item.path, - basis: bridgeBasis(item.path), - })), - ), -}); + roots: readonly string[], +) => { + const owner = (path: string): string | null => + roots.find((root) => isWithin(path, root)) ?? null; + return { + candidates: scripts.flatMap((script) => { + const root = owner(script.path); + return native + .filter(({ path }) => owner(path) === root) + .map((item) => ({ + source_path: script.path, + native_path: item.path, + basis: bridgeBasis(item.path), + })); + }), + }; +}; const bridgeBasis = ( path: string, @@ -360,7 +370,7 @@ const projectionLimitations = (facts: { layout === "versioned-framework" && plist === null, ) ? [ - "A versioned framework has zero or several Versions/*/Resources/Info.plist files; the one Versions/Current selects is unknown because symlink targets are not inventoried.", + "A versioned framework has several version directories or no Versions/*/Resources/Info.plist; the Info.plist that Versions/Current selects is unknown because symlink targets are not inventoried.", ] : []), ...(facts.symlinks diff --git a/src/domain/appleBundleAnatomy.ts b/src/domain/appleBundleAnatomy.ts index a70424908..766f1b515 100644 --- a/src/domain/appleBundleAnatomy.ts +++ b/src/domain/appleBundleAnatomy.ts @@ -234,7 +234,11 @@ const infoPlistPath = ( layout: Bundle["layout"], tree: Tree, ): string | null => { - const candidates = contentDirectories(bundle, layout, tree) + const directories = contentDirectories(bundle, layout, tree); + // With several real versions, Versions/Current (a symlink outside the + // inventory) decides which plist applies, so the plist stays unknown. + if (directories.length !== 1) return null; + const candidates = directories .map((directory) => joinPath( directory, From 2e81e867b24da8314f666f62d154de5803040cdf Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:46:53 +0000 Subject: [PATCH 2/3] fix(apple): count only real framework version directories A file such as Versions/.DS_Store beside the only real version made the framework look multi-versioned, so its Info.plist became unknown with a misleading limitation. Version directories are now limited to entries observed as directories or implied by deeper paths. Co-Authored-By: Claude Opus 5.5 --- src/domain/appleApplication.test.ts | 12 ++++++++++++ src/domain/appleBundleAnatomy.ts | 10 +++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/domain/appleApplication.test.ts b/src/domain/appleApplication.test.ts index b9e1041b0..47c45a746 100644 --- a/src/domain/appleApplication.test.ts +++ b/src/domain/appleApplication.test.ts @@ -309,6 +309,18 @@ describe("Apple bundle layouts and containers", () => { expect( result.bundles.find(({ path }) => path === framework)?.info_plist_path, ).toBeNull(); + // Files beside the only real version are not versions. + const withFiles = project( + inventoryEvidence("zip", "Fixture.zip", [ + macho("Fixture.app/Contents/MacOS/Fixture"), + { path: `${framework}/Versions/A/Resources/Info.plist` }, + { path: `${framework}/Versions/.DS_Store` }, + { path: `${framework}/Versions/Current`, kind: "symlink" }, + ]), + ); + expect( + withFiles.bundles.find(({ path }) => path === framework)?.info_plist_path, + ).toBe(`${framework}/Versions/A/Resources/Info.plist`); // Only A has a plist, but Versions/Current may select B. const onlyA = project( inventoryEvidence("zip", "Fixture.zip", [ diff --git a/src/domain/appleBundleAnatomy.ts b/src/domain/appleBundleAnatomy.ts index 766f1b515..f98def7c6 100644 --- a/src/domain/appleBundleAnatomy.ts +++ b/src/domain/appleBundleAnatomy.ts @@ -141,14 +141,18 @@ export const detectBundles = ( interface Tree { readonly children: ReadonlyMap>; readonly files: ReadonlyMap; + /** Paths observed as directories, or implied by a deeper path. */ + readonly directories: ReadonlySet; } const indexTree = (entries: readonly Entry[]): Tree => { const children = new Map>(); const files = new Map(); + const directories = new Set(); for (const { path, kind, component } of entries) { if (isSidecar(path)) continue; if (kind === "file" && component !== undefined) files.set(path, component); + if (kind === "directory") directories.add(path); const segments = path.split("/"); let parent = ""; for (const segment of segments) { @@ -156,9 +160,10 @@ const indexTree = (entries: readonly Entry[]): Tree => { names.add(segment); children.set(parent, names); parent = joinPath(parent, segment); + if (parent !== path) directories.add(parent); } } - return { children, files }; + return { children, files, directories }; }; const joinPath = (directory: string, name: string): string => @@ -223,9 +228,12 @@ const contentDirectories = ( if (layout === "macos-deep") return [joinPath(directory, "Contents")]; if (layout === "shallow") return [directory]; const versions = joinPath(directory, "Versions"); + // Only real version directories count: Current is a symlink, and files + // such as .DS_Store are not versions. return [...(tree.children.get(versions) ?? [])] .filter((version) => version !== "Current") .map((version) => joinPath(versions, version)) + .filter((version) => tree.directories.has(version)) .sort(compare); }; From 6d74992efed3271e8370c505afdde347609f8353 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:21:41 +0000 Subject: [PATCH 3/3] docs(apple): describe directory-based framework plist ambiguity Co-Authored-By: Claude Opus 5.5 --- docs/apple-application-analysis.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/apple-application-analysis.md b/docs/apple-application-analysis.md index 07ff1d293..bf606f34d 100644 --- a/docs/apple-application-analysis.md +++ b/docs/apple-application-analysis.md @@ -96,9 +96,11 @@ The components also list: Symlinks are reported by path in `symlinks`. Their targets are not inventoried, so a versioned framework is described through its real `Versions//` -directories rather than `Versions/Current`. When a framework has zero or several -`Versions/*/Resources/Info.plist` files, its `info_plist_path` is `null` and a -limitation explains why. +directories rather than `Versions/Current`. A framework's `info_plist_path` is +`null`, with a limitation explaining why, when it has more than one real +`Versions//` directory (even if only one of them holds a plist, +because `Versions/Current` decides which one applies) or when its single +version directory has no `Resources/Info.plist`. AppleDouble sidecar files (`._*` and `__MACOSX/`) are inventory facts, but they describe neighbouring files. They are excluded from roots, bundle roles, and the