fix(mobile): classify authenticated file occurrences as components

Select file and slice identity once before Android, Apple and Harmony
classification. Keep directory/link facts in source inventory and Apple
anatomy rather than deriving code roles, runtime hints or bridges from them.
Preserve occurrence order, multiplicity and existing unavailable facts.

Adapt @flyl1u's proposal and public CLI/stdio MCP controls from PR #3830,
extending the controls and structural fix to Apple and slice/link semantics.

Validation: 80 focused checks, build, check:fast and docs:check pass.
Four package controls fail before the fix. 72 tiny completed source cost
tasks preserve all regular-file control replies and projection IDs byte
for byte. No heavy profiling, target or real deep-provider execution;
full agent/public-task costs and published npm package unverified.

Fixes #3349
This commit is contained in:
morluto committed 2026-10-17 19:58:18 +08:00
1 parent 149a0fc06c
commit a425b3e528
9 files changed
+292 -11

No files matched your search

+4 -2
View File
@@ -233,8 +233,10 @@ rea project-android-application-graph '{"inventory_evidence":[<inventory_artifac
```
The projection reports exact component paths and hashes, runtime-family hints,
and path-based bridge hypotheses. It does not decode DEX or claim observed
runtime calls. Framework hints match complete native library names, such as
and path-based bridge hypotheses. File and slice occurrences supply component
endpoints; directory trees and symlinks retain their identities in the source
inventory without being classified as code files. It does not decode DEX or
claim observed runtime calls. Framework hints match complete native library names, such as
`libreactnativejni.so`, `libreactnative.so`, `libhermes.so`,
`libhermes_executor.so`, `libflutter.so`, and `libunity.so`; unrelated names
such as `libcommunity.so` and `libreactivity.so` do not identify those families.
+3
View File
@@ -77,6 +77,9 @@ including when empty, so Apple projection identifiers change.
| `.app` directory | `.`, the inventoried bundle itself |
| Directory, ZIP, or DMG containing apps | Each outermost `<Name>.app` followed by `Contents/` |
File and slice occurrences supply component endpoints. Directory tree identities
remain in source inventory and bundle anatomy, and links remain in `symlinks`.
Root and layout discovery use known directories and ancestors implied by deeper
inventory paths. A leaf file or symlink named `*.app`, `Contents`, or `Versions`
does not establish that directory. Directory and authenticated-file identity are
+4
View File
@@ -45,6 +45,10 @@ The MCP tool `project_harmony_application_graph` takes the same
- path-based N-API bridge hypotheses between bytecode and `libs/<abi>` native
libraries
File and slice occurrences supply component and child-package paths; directory
trees and symlinks retain their identities in the source inventory without
being classified as code files.
Every claim is path-presence only. Manifest semantics (module identity,
abilities, entry points, permissions, dependencies), Ark bytecode contents and
versions, resource resolution, signing verification, and observed native
+2 -3
View File
@@ -14,6 +14,7 @@ import {
projectedBridgeCandidateSchema,
projectedComponentSchema,
projectedInventoryEntries,
projectedFileComponents,
projectionCoverage,
projectionDigest,
projectionEvidenceIdSchema,
@@ -94,9 +95,7 @@ export const projectAndroidApplication = (
`Android application projection requires APK inventory Evidence; the inventory root format is ${inventory.manifest.root_format}. APK is chosen for a ZIP archive whose name ends in .apk.`,
);
const entries = projectedInventoryEntries(inventory);
const all = entries.flatMap(({ component }) =>
component === undefined ? [] : [component],
);
const all = projectedFileComponents(entries);
const unavailable = entries.flatMap(({ unavailable }) =>
unavailable === undefined ? [] : [unavailable],
);
+2 -3
View File
@@ -28,6 +28,7 @@ import {
import {
projectedComponentSchema,
projectedInventoryEntries,
projectedFileComponents,
projectionDigest,
unavailableComponentSchema,
type ProjectedInventoryEntry,
@@ -119,9 +120,7 @@ export const projectAppleApplication = (
);
const rootFormat = appleRootFormat(inventory.manifest.root_format);
const entries = projectedInventoryEntries(inventory);
const inventoried = entries.flatMap(({ component }) =>
component === undefined ? [] : [component],
);
const inventoried = projectedFileComponents(entries);
const { roots, bundles } = projectAppleBundleAnatomy(entries, {
name: evidence[0]?.subject?.name ?? "",
format: rootFormat,
+2 -3
View File
@@ -14,6 +14,7 @@ import {
projectedBridgeCandidateSchema,
projectedComponentSchema,
projectedInventoryEntries,
projectedFileComponents,
projectionCoverage,
projectionDigest,
projectionEvidenceIdSchema,
@@ -84,9 +85,7 @@ export const projectHarmonyApplication = (
);
const rootFormat = harmonyRootFormat(inventory.manifest.root_format);
const entries = projectedInventoryEntries(inventory);
const all = entries.flatMap(({ component }) =>
component === undefined ? [] : [component],
);
const all = projectedFileComponents(entries);
const unavailable = entries.flatMap(({ unavailable }) =>
unavailable === undefined ? [] : [unavailable],
);
@@ -0,0 +1,52 @@
import { expect, it } from "vitest";
import {
projectedFileComponents,
type ProjectedInventoryEntry,
} from "./mobileApplicationGraphProjection.js";
it("selects authenticated file and slice facts without reordering or deduplicating occurrences", () => {
const file: ProjectedInventoryEntry = {
path: "member",
kind: "file",
unavailable: undefined,
component: {
path: "member",
artifact_id: `art_${"a".repeat(64)}`,
sha256: "b".repeat(64),
format: "file",
},
};
const slice: ProjectedInventoryEntry = {
...file,
kind: "slice",
component: { ...file.component, path: "member/arm64", format: "mach-o" },
path: "member/arm64",
};
const unread: ProjectedInventoryEntry = {
path: "unread",
kind: "file",
component: undefined,
unavailable: {
path: "unread",
artifact_id: null,
sha256: null,
format: "file",
encrypted: false,
limitations: ["Content was not read."],
},
};
const entries = Object.freeze([
{ ...file, kind: "directory" as const },
file,
{ ...file, kind: "symlink" as const },
slice,
unread,
file,
]);
const result = projectedFileComponents(entries);
expect(result).toEqual([file.component, slice.component, file.component]);
expect(result[0]).toBe(file.component);
expect(result[1]).toBe(slice.component);
expect(entries[4]).toBe(unread);
});
@@ -130,6 +130,16 @@ export const projectedInventoryEntries = (
});
};
/** Directory trees and links retain inventory identity but are not file components. */
export const projectedFileComponents = (
entries: readonly ProjectedInventoryEntry[],
): ProjectedComponent[] =>
entries.flatMap(({ kind, component }) =>
(kind === "file" || kind === "slice") && component !== undefined
? [component]
: [],
);
export { bridgeCandidateCoverageSchema, projectCartesianCandidates };
/** Digest a canonical JSON projection payload for its stable identifier. */
@@ -0,0 +1,213 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
TextReader,
Uint8ArrayReader,
Uint8ArrayWriter,
ZipWriter,
} from "@zip.js/zip.js";
import { expect } from "vitest";
import { appleApplicationProjectionResultSchema } from "../../../src/domain/apple/appleApplication.js";
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { androidApplicationProjectionResultSchema } from "../../../src/domain/android/androidApplication.js";
import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js";
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { harmonyApplicationProjectionResultSchema } from "../../../src/domain/harmony/harmonyApplication.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const harmony = {
family: "HarmonyOS",
extension: "hap",
command: "project-harmony-application-graph",
operation: "project_harmony_application_graph",
paths: [
"module.json",
"ets/modules.abc",
"libs/arm64-v8a/libflutter.so",
"assets/main.js",
"resources.index",
"resources/base",
"META-INF/TEST.p7b",
],
nestedCode: "ets/modules.abc/actual.abc",
runtimeFamilies: ["ark", "flutter", "javascript", "native"],
nestedRuntime: "ark",
bridgeCount: 1,
};
const fixtures = [
{
family: "Android",
extension: "apk",
command: "project-android-application-graph",
operation: "project_android_application_graph",
paths: [
"AndroidManifest.xml",
"resources.arsc",
"classes.dex",
"Main.class",
"lib/arm64-v8a/libflutter.so",
"META-INF/TEST.RSA",
],
nestedCode: "classes.dex/actual.dex",
runtimeFamilies: ["dalvik-art", "flutter", "java-kotlin", "native"],
nestedRuntime: "dalvik-art",
bridgeCount: 2,
},
{
family: "Apple",
extension: "ipa",
command: "project-apple-application-graph",
operation: "project_apple_application_graph",
paths: [
"Payload/Fixture.app/Info.plist",
"Payload/Fixture.app/main.js",
"Payload/Fixture.app/libentry.dylib",
],
nestedCode: "Payload/Fixture.app/main.js/actual.js",
runtimeFamilies: ["javascript", "native"],
nestedRuntime: "javascript",
bridgeCount: 1,
},
harmony,
{
...harmony,
family: "HarmonyOS App Pack",
extension: "app",
paths: [...harmony.paths, "entry.hap", "feature.hsp"],
},
];
cliTest.for(fixtures)(
"preserves entry kinds in $family projections through CLI and stdio MCP",
{ timeout: 60_000 },
async (fixture, { cli }) => {
const root = await createTestTempDirectory("rea-mobile-directory-");
const environment = { HOME: root, USERPROFILE: root };
const { call } = await connectLocalToolsMcp(environment);
for (const mode of ["directories", "files", "nested"] as const) {
const writer = new ZipWriter(new Uint8ArrayWriter());
for (const path of fixture.paths) {
if (mode !== "files")
await writer.add(`${path}/`, undefined, { directory: true });
else
await writer.add(
path,
path.endsWith(".dylib")
? new Uint8ArrayReader(thinMach(0xfeedfacf, 0x0100000c))
: path.endsWith(".so")
? new Uint8ArrayReader(
Uint8Array.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]),
)
: new TextReader("opaque fixture content"),
);
}
if (mode === "nested")
await writer.add(fixture.nestedCode, new TextReader("opaque bytecode"));
const bytes = await writer.close();
const path = join(root, `${mode}.${fixture.extension}`);
await writeFile(path, bytes);
const inspected = await cli.run({
arguments: ["inspect-artifact", path, "--json"],
environment,
});
expect(inspected.exitCode, inspected.stderr).toBe(0);
const inventoryEvidence = parseEvidence(inspected.json);
const inspection = artifactInspectionResultSchema.parse(
inventoryEvidence.normalized_result,
);
const occurrences = inspection.substeps.flatMap(
({ evidence }) =>
artifactInventoryResultSchema.parse(
parseEvidence(evidence).normalized_result,
).occurrences,
);
for (const member of fixture.paths)
expect(occurrences, `${mode}: ${member}`).toContainEqual(
expect.objectContaining({
logical_path: member,
entry_kind: mode === "files" ? "file" : "directory",
...(mode === "files" ? {} : { artifact_format: "directory" }),
}),
);
const input = { inventory_evidence: [inventoryEvidence] };
const originalInput = JSON.stringify(input);
const projected = await cli.run({
arguments: [fixture.command, originalInput, "--json"],
environment,
});
expect(projected.exitCode, projected.stderr).toBe(0);
const evidence = parseEvidence(projected.json);
const mcp = await call(fixture.operation, input);
expect(mcp.isError, JSON.stringify(mcp)).not.toBe(true);
expect(parseEvidence(mcp.structuredContent)).toEqual(evidence);
expect(JSON.stringify(input)).toBe(originalInput);
const projection =
fixture.extension === "apk"
? androidApplicationProjectionResultSchema.parse(
evidence.normalized_result,
)
: fixture.extension === "ipa"
? appleApplicationProjectionResultSchema.parse(
evidence.normalized_result,
)
: harmonyApplicationProjectionResultSchema.parse(
evidence.normalized_result,
);
const childPackages = fixture.paths.filter((member) =>
/\.(?:hap|hsp)$/u.test(member),
);
const categories = Object.values(projection.components);
for (const category of categories)
expect(new Set(category.map(({ path }) => path)).size).toBe(
category.length,
);
const componentPaths = [
...new Set(categories.flat().map((component) => component.path)),
].sort();
expect(componentPaths, mode).toEqual(
mode === "files"
? fixture.paths
.filter((member) => !childPackages.includes(member))
.sort()
: mode === "nested"
? [fixture.nestedCode]
: [],
);
expect(projection.runtime_families, mode).toEqual(
mode === "files"
? fixture.runtimeFamilies
: mode === "nested"
? [fixture.nestedRuntime]
: [],
);
expect(projection.bridge_candidates, mode).toHaveLength(
mode === "files" ? fixture.bridgeCount : 0,
);
expect(projection.source_evidence_ids).toEqual([
inventoryEvidence.evidence_id,
]);
expect(projection.coverage).toEqual({
status: "complete-within-inventory",
inventory_complete: true,
});
if ("packaging_model" in projection) {
expect(projection.packaging_model, mode).toBe(
mode === "files" ? "stage" : "unknown",
);
expect(projection.app_pack_children.map((child) => child.path)).toEqual(
mode === "files" ? childPackages : [],
);
}
expect(await readFile(path)).toEqual(Buffer.from(bytes));
}
},
);