diff --git a/docs/android-analysis.md b/docs/android-analysis.md index e6352544ad..493eef7d0b 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -236,7 +236,9 @@ The projection reports exact component paths and hashes, runtime-family hints, 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 +claim observed runtime calls. DEX/JVM runtime hints summarize the classified +bytecode component lists. 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. diff --git a/docs/harmony-analysis.md b/docs/harmony-analysis.md index c0963dad5f..c36bb4f917 100644 --- a/docs/harmony-analysis.md +++ b/docs/harmony-analysis.md @@ -47,7 +47,9 @@ The MCP tool `project_harmony_application_graph` takes the same 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. +being classified as code files. Ark and JavaScript runtime hints summarize the +classified bytecode and JavaScript lists; supported source extensions share the +same classification rule as their component rows. Every claim is path-presence only. Manifest semantics (module identity, abilities, entry points, permissions, dependencies), Ark bytecode contents and diff --git a/src/domain/android/androidApplication.ts b/src/domain/android/androidApplication.ts index ade60df39e..7276e627ef 100644 --- a/src/domain/android/androidApplication.ts +++ b/src/domain/android/androidApplication.ts @@ -139,7 +139,7 @@ export const projectAndroidApplication = ( .sort(compareUnicodeCodePoints), components, unavailable_components: unavailable, - runtime_families: runtimeFamilies(all), + runtime_families: runtimeFamilies(all, classified), bridge_candidates: bridgeProjection.candidates, bridge_candidate_coverage: bridgeProjection.coverage, coverage: projectionCoverage( @@ -172,14 +172,15 @@ const classify = (all: readonly Component[]) => ({ ), }); -const runtimeFamilies = (all: readonly Component[]) => { +const runtimeFamilies = ( + all: readonly Component[], + components: AndroidApplicationProjectionResult["components"], +) => { const families = new Set< AndroidApplicationProjectionResult["runtime_families"][number] >(); - if (all.some(({ path }) => path.toLowerCase().endsWith(".dex"))) - families.add("dalvik-art"); - if (all.some(({ path }) => path.toLowerCase().endsWith(".class"))) - families.add("java-kotlin"); + if (components.dex.length > 0) families.add("dalvik-art"); + if (components.jvm_classes.length > 0) families.add("java-kotlin"); if (all.some(({ format }) => format === "elf")) families.add("native"); if (all.some(({ format }) => format === "javascript-bundle")) families.add("javascript"); diff --git a/src/domain/harmony/harmonyApplication.ts b/src/domain/harmony/harmonyApplication.ts index 53585f378f..2055d8f99c 100644 --- a/src/domain/harmony/harmonyApplication.ts +++ b/src/domain/harmony/harmonyApplication.ts @@ -135,7 +135,7 @@ export const projectHarmonyApplication = ( rootFormat === "app-pack" ? all.filter(({ path }) => /\.(?:hap|hsp)$/iu.test(path)) : [], - runtime_families: runtimeFamilies(all), + runtime_families: runtimeFamilies(all, classified), bridge_candidates: bridgeProjection.candidates, bridge_candidate_coverage: bridgeProjection.coverage, coverage: projectionCoverage( @@ -195,16 +195,16 @@ const classify = (all: readonly Component[]) => ({ ), }); -const runtimeFamilies = (all: readonly Component[]) => { - const paths = all.map(({ path }) => path.toLowerCase()); +const runtimeFamilies = ( + all: readonly Component[], + components: HarmonyApplicationProjectionResult["components"], +) => { const families = new Set< HarmonyApplicationProjectionResult["runtime_families"][number] >(); - if (paths.some((path) => path.endsWith(".abc") || path.endsWith(".pa"))) - families.add("ark"); + if (components.bytecode.length > 0) families.add("ark"); if (all.some(({ format }) => format === "elf")) families.add("native"); - if (paths.some((path) => /\.(?:m?js|cjs)$/.test(path))) - families.add("javascript"); + if (components.javascript.length > 0) families.add("javascript"); for (const { path } of all) { const convention = nativeRuntimeConvention(path); if (convention !== null) families.add(convention); diff --git a/tests/acceptance/applications/androidRuntimeComponentHints.test.ts b/tests/acceptance/applications/androidRuntimeComponentHints.test.ts new file mode 100644 index 0000000000..7ff556e5b6 --- /dev/null +++ b/tests/acceptance/applications/androidRuntimeComponentHints.test.ts @@ -0,0 +1,63 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect } from "vitest"; + +import { androidApplicationProjectionResultSchema } from "../../../src/domain/android/androidApplication.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "Android runtime hints follow classified bytecode through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-android-runtime-hints-"); + const environment = { HOME: root, USERPROFILE: root }; + const { call } = await connectLocalToolsMcp(environment); + for (const prefix of ["", "named"]) { + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("AndroidManifest.xml", new TextReader("fixture")); + for (const extension of ["dex", "class"]) + await writer.add( + `assets/${prefix}.${extension}`, + new TextReader("ordinary text"), + ); + const path = join(root, `${prefix || "dot-names"}.apk`); + await writeFile(path, await writer.close()); + const inspected = await cli.run({ + arguments: ["inspect-artifact", path, "--json"], + environment, + }); + expect(inspected.exitCode, inspected.stderr).toBe(0); + const source = parseEvidence(inspected.json); + const input = { inventory_evidence: [source] }; + const projected = await cli.run({ + arguments: [ + "project-android-application-graph", + JSON.stringify(input), + "--json", + ], + environment, + }); + expect(projected.exitCode, projected.stderr).toBe(0); + const evidence = parseEvidence(projected.json); + const graph = androidApplicationProjectionResultSchema.parse( + evidence.normalized_result, + ); + expect(graph.components.dex.map(({ path }) => path)).toEqual( + prefix ? ["assets/named.dex"] : [], + ); + expect(graph.components.jvm_classes.map(({ path }) => path)).toEqual( + prefix ? ["assets/named.class"] : [], + ); + expect(graph.runtime_families).toEqual( + prefix ? ["dalvik-art", "java-kotlin"] : [], + ); + const mcp = await call("project_android_application_graph", input); + expect(mcp.isError).not.toBe(true); + expect(parseEvidence(mcp.structuredContent)).toEqual(evidence); + } + }, +); diff --git a/tests/acceptance/harmonyArtifactJourney.test.ts b/tests/acceptance/harmonyArtifactJourney.test.ts index 51178d744d..217ed65045 100644 --- a/tests/acceptance/harmonyArtifactJourney.test.ts +++ b/tests/acceptance/harmonyArtifactJourney.test.ts @@ -25,6 +25,11 @@ test("preserves Harmony archive formats through the public CLI/MCP lifecycle", a "ets/modules.abc", new TextReader("opaque bytecode fixture"), ); + const javascriptPaths = ["cts", "jsx", "mts", "ts", "tsx"].map( + (extension) => `assets/js/index.${extension}`, + ); + for (const path of javascriptPaths) + await writer.add(path, new TextReader("export const value = 1;")); const bytes = await writer.close(); const environment = { PATH: process.env.PATH ?? "", @@ -114,6 +119,30 @@ test("preserves Harmony archive formats through the public CLI/MCP lifecycle", a expect( graph.components.bytecode.map(({ path: member }) => member), ).toEqual(["ets/modules.abc"]); + expect( + graph.components.javascript.map(({ path: member }) => member), + ).toEqual(javascriptPaths); + expect(graph.runtime_families).toEqual(["ark", "javascript"]); + const projectionInput = workspace.path("projection-input.json"); + await writeFile( + projectionInput, + JSON.stringify({ inventory_evidence: [cliEvidence] }), + ); + const cliProjection = await cli.run({ + arguments: [ + "project-harmony-application-graph", + projectionInput, + "--format", + "json", + ], + environment, + }); + expect(cliProjection.exitCode, cliProjection.stderr).toBe(0); + expect( + harmonyApplicationProjectionResultSchema.parse( + parseEvidence(cliProjection.json).normalized_result, + ), + ).toEqual(graph); const closed = await client.callTool({ name: "close_binary", arguments: {}, diff --git a/tests/boundary/filesystem/harmony/application.test.ts b/tests/boundary/filesystem/harmony/application.test.ts index 76fddc3740..4e4d71d0be 100644 --- a/tests/boundary/filesystem/harmony/application.test.ts +++ b/tests/boundary/filesystem/harmony/application.test.ts @@ -37,6 +37,48 @@ const stageHapEntries = [ ] as const; describe("HarmonyOS application projection", () => { + it.each(["js", "mjs", "cjs", "jsx", "ts", "tsx", "mts", "cts"])( + "retains the JavaScript runtime hint for an authenticated .%s component", + async (extension) => { + const member = `assets/js/index.${extension}`; + const path = await writeApplicationZip( + "rea-harmony-javascript-", + "Fixture.hap", + [{ path: member, content: "export const value = 1;" }], + ); + const inventory = await inventoryApplicationPackage(path); + const projection = requireDeterministicProjection( + projectHarmonyApplicationEvidence, + inventory, + harmonyApplicationProjectionResultSchema, + ); + expect(projection.components.javascript).toEqual([ + expect.objectContaining({ path: member, format: "javascript-bundle" }), + ]); + expect(projection.runtime_families).toEqual(["javascript"]); + expect(projection.coverage.status).toBe("complete-within-inventory"); + }, + ); + + it("does not infer JavaScript from unrelated inventory content", async () => { + const path = await writeApplicationZip( + "rea-harmony-no-javascript-", + "Fixture.hap", + [ + { path: "module.json", content: "{}" }, + { path: "assets/js/notes.txt", content: "ordinary text" }, + ], + ); + const inventory = await inventoryApplicationPackage(path); + const projection = requireDeterministicProjection( + projectHarmonyApplicationEvidence, + inventory, + harmonyApplicationProjectionResultSchema, + ); + expect(projection.components.javascript).toEqual([]); + expect(projection.runtime_families).toEqual([]); + }); + it("projects deterministic Stage HAP components and explicit bridge hypotheses", async () => { const path = await writeApplicationZip( "rea-harmony-",