mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(adb): report unrecognized package rows from resolve-adb-packages (#5874)
resolve_adb_packages marked coverage partial when pm list packages printed unrecognized rows, but dropped the rows themselves and could still emit a limitation describing an unparsed_lines field the result never carried. ADB parsers now own unrecognized-line facts as a bounded sample (first ten) plus the exact total, and a single provider projection derives unparsed_lines, coverage, and the truncation limitation from those facts for every parsed listing and the package search. The resolver result schema now carries unparsed_lines, matching list_adb_packages through CLI and MCP. Fixes #5843 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ce1f4b1bba
commit
5b014a739e
12 files changed
+280
-127
No files matched your search
@@ -162,8 +162,9 @@ and during shutdown.
|
||||
APKs keep other names report the `unknown` role rather than guessing.
|
||||
- **Partial results keep facts.** Failed individual pulls return successful
|
||||
artifacts with `coverage: "partial"` and per-file `failures`. Listings
|
||||
that contain unrecognized lines report them as `unparsed_lines` and mark
|
||||
partial coverage instead of dropping rows.
|
||||
and package searches that contain unrecognized lines report the first ten
|
||||
as `unparsed_lines`, state the exact total in a limitation when more were
|
||||
seen, and mark partial coverage instead of dropping rows.
|
||||
- **Mutations are explicit.** `push_adb_file`, `install_adb_package`, and
|
||||
`uninstall_adb_package` declare `mutatesTarget` and never run implicitly:
|
||||
pushes check for existing device files before transfer and refuse them
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("parseAdbDevicesOutput", () => {
|
||||
].join("\n");
|
||||
const { devices, unparsedLines, daemonStarted } =
|
||||
parseAdbDevicesOutput(output);
|
||||
expect(unparsedLines).toEqual([]);
|
||||
expect(unparsedLines).toEqual({ sample: [], total: 0 });
|
||||
expect(daemonStarted).toBe(false);
|
||||
expect(devices).toEqual([
|
||||
{
|
||||
@@ -88,7 +88,7 @@ describe("ADB daemon startup notices", () => {
|
||||
stderr,
|
||||
);
|
||||
expect(daemonStarted).toBe(true);
|
||||
expect(unparsedLines).toEqual([]);
|
||||
expect(unparsedLines).toEqual({ sample: [], total: 0 });
|
||||
expect(devices).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("ADB daemon startup notices", () => {
|
||||
"* daemon started successfully *\r\n",
|
||||
);
|
||||
expect(daemonStarted).toBe(true);
|
||||
expect(unparsedLines).toEqual([]);
|
||||
expect(unparsedLines).toEqual({ sample: [], total: 0 });
|
||||
});
|
||||
|
||||
it("does not treat a daemon startup error containing the success phrase as success", () => {
|
||||
@@ -107,7 +107,7 @@ describe("ADB daemon startup notices", () => {
|
||||
"* daemon not started successfully\r\n",
|
||||
);
|
||||
expect(daemonStarted).toBe(false);
|
||||
expect(unparsedLines).toEqual([]);
|
||||
expect(unparsedLines).toEqual({ sample: [], total: 0 });
|
||||
});
|
||||
|
||||
it("ignores non-startup lines and device strings on stderr", () => {
|
||||
@@ -126,7 +126,7 @@ describe("ADB daemon startup notices", () => {
|
||||
stderr,
|
||||
);
|
||||
expect(daemonStarted).toBe(false);
|
||||
expect(unparsedLines).toEqual([]);
|
||||
expect(unparsedLines).toEqual({ sample: [], total: 0 });
|
||||
expect(devices).toHaveLength(1);
|
||||
expect(devices[0]?.serial).toBe("emulator-5554");
|
||||
});
|
||||
@@ -158,11 +158,14 @@ describe("ADB device states", () => {
|
||||
expect(devices.map(({ serial, state }) => [serial, state])).toEqual([
|
||||
["serial-no-permission", "no permissions"],
|
||||
]);
|
||||
expect(unparsedLines).toEqual([
|
||||
"serial-bad-device deviceXYZ ",
|
||||
"serial-bad-offline offline-ish",
|
||||
"serial-bad-auth unauthorizedXYZ",
|
||||
]);
|
||||
expect(unparsedLines).toEqual({
|
||||
sample: [
|
||||
"serial-bad-device deviceXYZ ",
|
||||
"serial-bad-offline offline-ish",
|
||||
"serial-bad-auth unauthorizedXYZ",
|
||||
],
|
||||
total: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns unrecognized lines instead of dropping them", () => {
|
||||
@@ -172,7 +175,10 @@ describe("ADB device states", () => {
|
||||
"",
|
||||
].join("\n");
|
||||
const { unparsedLines } = parseAdbDevicesOutput(output);
|
||||
expect(unparsedLines).toEqual(["this is not a device line"]);
|
||||
expect(unparsedLines).toEqual({
|
||||
sample: ["this is not a device line"],
|
||||
total: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a non-numeric transport_id as unknown", () => {
|
||||
@@ -259,7 +265,7 @@ describe("parsePackageListOutput", () => {
|
||||
"",
|
||||
].join("\n");
|
||||
const { packages, unparsedLines } = parsePackageListOutput(output);
|
||||
expect(unparsedLines).toEqual([]);
|
||||
expect(unparsedLines).toEqual({ sample: [], total: 0 });
|
||||
expect(packages).toEqual([
|
||||
{
|
||||
package_name: "com.example",
|
||||
@@ -278,7 +284,19 @@ describe("parsePackageListOutput", () => {
|
||||
"package:/data/app/x/base.apk=com.ok\n garbage line \n",
|
||||
);
|
||||
expect(packages).toHaveLength(1);
|
||||
expect(unparsedLines).toEqual([" garbage line "]);
|
||||
expect(unparsedLines).toEqual({ sample: [" garbage line "], total: 1 });
|
||||
});
|
||||
|
||||
it("samples the first ten unrecognized lines and counts every one", () => {
|
||||
const rows = Array.from(
|
||||
{ length: 11 },
|
||||
(_, index) => `row ${String(index + 1)}`,
|
||||
);
|
||||
const { packages, unparsedLines } = parsePackageListOutput(
|
||||
["package:/data/app/x/base.apk=com.ok", ...rows].join("\n"),
|
||||
);
|
||||
expect(packages).toHaveLength(1);
|
||||
expect(unparsedLines).toEqual({ sample: rows.slice(0, 10), total: 11 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -291,7 +309,7 @@ describe("parsePmPathOutput", () => {
|
||||
"",
|
||||
].join("\n");
|
||||
const { paths, unparsedLines } = parsePmPathOutput(output);
|
||||
expect(unparsedLines).toEqual([]);
|
||||
expect(unparsedLines).toEqual({ sample: [], total: 0 });
|
||||
expect(paths).toEqual([
|
||||
{
|
||||
device_path: "/data/app/~~a==/com.example.b==/base.apk",
|
||||
@@ -334,7 +352,7 @@ describe("parsePmPathOutput", () => {
|
||||
},
|
||||
]);
|
||||
expect(rejectedPaths).toEqual([]);
|
||||
expect(unparsedLines).toEqual([]);
|
||||
expect(unparsedLines).toEqual({ sample: [], total: 0 });
|
||||
});
|
||||
|
||||
it("rejects an empty final component without normalizing the source", () => {
|
||||
@@ -349,6 +367,6 @@ describe("parsePmPathOutput", () => {
|
||||
it("retains malformed lines including surrounding whitespace", () => {
|
||||
expect(
|
||||
parsePmPathOutput(" package:/data/app/pkg/base.apk \n").unparsedLines,
|
||||
).toEqual([" package:/data/app/pkg/base.apk "]);
|
||||
).toEqual({ sample: [" package:/data/app/pkg/base.apk "], total: 1 });
|
||||
});
|
||||
});
|
||||
+20
-16
@@ -2,14 +2,18 @@
|
||||
* Parsers for the exact adb and Package Manager output formats REA consumes.
|
||||
*
|
||||
* Each parser preserves what the tool actually printed: unparseable lines are
|
||||
* retained rather than silently dropped, and derived classifications carry
|
||||
* their evidence basis.
|
||||
* sampled and counted rather than silently dropped, and derived
|
||||
* classifications carry their evidence basis.
|
||||
*/
|
||||
|
||||
import {
|
||||
adbHostFileName,
|
||||
type AdbHostFileNameReason,
|
||||
} from "./AdbHostFileName.js";
|
||||
import {
|
||||
collectUnparsedLines,
|
||||
type AdbUnparsedLines,
|
||||
} from "./AdbUnparsedLines.js";
|
||||
import {
|
||||
ADB_DEVICE_STATES,
|
||||
type AdbDeviceState,
|
||||
@@ -44,18 +48,18 @@ const metadataValue = (remainder: string, key: string): string | null => {
|
||||
/**
|
||||
* Parse `adb devices -l` device lines. The banner, blank lines, and the
|
||||
* daemon startup notices adb prints on first use are recognized separately;
|
||||
* every other unrecognized line is returned for honest reporting.
|
||||
* every other unrecognized line is sampled and counted for honest reporting.
|
||||
*/
|
||||
export const parseAdbDevicesOutput = (
|
||||
stdout: string,
|
||||
stderr = "",
|
||||
): {
|
||||
devices: readonly AdbDeviceEntry[];
|
||||
unparsedLines: readonly string[];
|
||||
unparsedLines: AdbUnparsedLines;
|
||||
daemonStarted: boolean;
|
||||
} => {
|
||||
const devices: AdbDeviceEntry[] = [];
|
||||
const unparsedLines: string[] = [];
|
||||
const unparsed = collectUnparsedLines();
|
||||
let daemonStarted = false;
|
||||
for (const rawLine of stdout.split(/\r?\n/u)) {
|
||||
const line = normalizeAdbOutputLine(rawLine);
|
||||
@@ -74,7 +78,7 @@ export const parseAdbDevicesOutput = (
|
||||
return next === undefined || /\s/u.test(next);
|
||||
});
|
||||
if (match === null || state === undefined) {
|
||||
if (rawLine.trim() !== "") unparsedLines.push(rawLine);
|
||||
if (rawLine.trim() !== "") unparsed.add(rawLine);
|
||||
continue;
|
||||
}
|
||||
const serial = match[1]!;
|
||||
@@ -122,7 +126,7 @@ export const parseAdbDevicesOutput = (
|
||||
}
|
||||
}
|
||||
}
|
||||
return { devices, unparsedLines, daemonStarted };
|
||||
return { devices, unparsedLines: unparsed.facts(), daemonStarted };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -184,21 +188,21 @@ export interface AdbPackageEntry {
|
||||
|
||||
const PACKAGE_LINE = /^package:(.+)=([^\s=]+)$/u;
|
||||
|
||||
/** Parse `pm list packages -f` lines, retaining unrecognized lines. */
|
||||
/** Parse `pm list packages -f` lines, sampling and counting unrecognized lines. */
|
||||
export const parsePackageListOutput = (
|
||||
stdout: string,
|
||||
): {
|
||||
packages: readonly AdbPackageEntry[];
|
||||
unparsedLines: readonly string[];
|
||||
unparsedLines: AdbUnparsedLines;
|
||||
} => {
|
||||
const packages: AdbPackageEntry[] = [];
|
||||
const unparsedLines: string[] = [];
|
||||
const unparsed = collectUnparsedLines();
|
||||
for (const rawLine of stdout.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (line === "") continue;
|
||||
const match = PACKAGE_LINE.exec(line);
|
||||
if (match === null) {
|
||||
unparsedLines.push(rawLine);
|
||||
unparsed.add(rawLine);
|
||||
continue;
|
||||
}
|
||||
packages.push({
|
||||
@@ -206,7 +210,7 @@ export const parsePackageListOutput = (
|
||||
base_apk_device_path: match[1]!,
|
||||
});
|
||||
}
|
||||
return { packages, unparsedLines };
|
||||
return { packages, unparsedLines: unparsed.facts() };
|
||||
};
|
||||
|
||||
/** Quote raw device paths for prose without allowing bidi or invisible text. */
|
||||
@@ -248,19 +252,19 @@ export const parsePmPathOutput = (
|
||||
device_path: string;
|
||||
reason: AdbHostFileNameReason;
|
||||
}[];
|
||||
unparsedLines: readonly string[];
|
||||
unparsedLines: AdbUnparsedLines;
|
||||
} => {
|
||||
const paths: AdbPackagePathEntry[] = [];
|
||||
const rejectedPaths: {
|
||||
device_path: string;
|
||||
reason: AdbHostFileNameReason;
|
||||
}[] = [];
|
||||
const unparsedLines: string[] = [];
|
||||
const unparsed = collectUnparsedLines();
|
||||
for (const rawLine of stdout.split(/\r?\n/u)) {
|
||||
if (rawLine === "") continue;
|
||||
const match = /^package:(.*)$/u.exec(rawLine);
|
||||
if (match === null) {
|
||||
unparsedLines.push(rawLine);
|
||||
unparsed.add(rawLine);
|
||||
continue;
|
||||
}
|
||||
const devicePath = match[1]!;
|
||||
@@ -284,5 +288,5 @@ export const parsePmPathOutput = (
|
||||
: "unknown",
|
||||
});
|
||||
}
|
||||
return { paths, rejectedPaths, unparsedLines };
|
||||
return { paths, rejectedPaths, unparsedLines: unparsed.facts() };
|
||||
};
|
||||
@@ -1,5 +1,10 @@
|
||||
import { isUtf8 } from "node:buffer";
|
||||
|
||||
import {
|
||||
collectUnparsedLines,
|
||||
type AdbUnparsedLines,
|
||||
} from "./AdbUnparsedLines.js";
|
||||
|
||||
interface AdbDirectoryEntry {
|
||||
readonly name: string;
|
||||
readonly kind: "directory" | "file" | "symlink" | "other";
|
||||
@@ -71,35 +76,35 @@ export const parseAdbDirectoryListing = (
|
||||
stdout: Buffer,
|
||||
): {
|
||||
entries: readonly AdbDirectoryEntry[];
|
||||
unparsedLines: readonly string[];
|
||||
unparsedLines: AdbUnparsedLines;
|
||||
} => {
|
||||
const entries: AdbDirectoryEntry[] = [];
|
||||
const unparsedLines: string[] = [];
|
||||
const unparsed = collectUnparsedLines();
|
||||
let offset = 0;
|
||||
while (offset < stdout.length) {
|
||||
const metadataEnd = stdout.indexOf(0, offset);
|
||||
const nameEnd = metadataEnd < 0 ? -1 : stdout.indexOf(0, metadataEnd + 1);
|
||||
const targetEnd = nameEnd < 0 ? -1 : stdout.indexOf(0, nameEnd + 1);
|
||||
if (targetEnd < 0) {
|
||||
unparsedLines.push(describeUnparsedRecord(stdout.subarray(offset)));
|
||||
unparsed.add(describeUnparsedRecord(stdout.subarray(offset)));
|
||||
break;
|
||||
}
|
||||
const rawRecord = stdout.subarray(offset, targetEnd + 1);
|
||||
if (!isUtf8(rawRecord)) {
|
||||
unparsedLines.push(describeUnparsedRecord(rawRecord));
|
||||
unparsed.add(describeUnparsedRecord(rawRecord));
|
||||
} else {
|
||||
const entry = parseEntry(
|
||||
stdout.toString("utf8", offset, metadataEnd),
|
||||
stdout.toString("utf8", metadataEnd + 1, nameEnd),
|
||||
stdout.toString("utf8", nameEnd + 1, targetEnd),
|
||||
);
|
||||
if (entry === null) unparsedLines.push(rawRecord.toString("utf8"));
|
||||
if (entry === null) unparsed.add(rawRecord.toString("utf8"));
|
||||
else entries.push(entry);
|
||||
}
|
||||
offset = targetEnd + 1;
|
||||
}
|
||||
|
||||
return { entries, unparsedLines };
|
||||
return { entries, unparsedLines: unparsed.facts() };
|
||||
};
|
||||
|
||||
const describeUnparsedRecord = (record: Buffer): string =>
|
||||
|
||||
@@ -1617,6 +1617,7 @@ it("resolves packages by case-insensitive substring", async () => {
|
||||
},
|
||||
]);
|
||||
expect(result.exact_match).toBe(false);
|
||||
expect(result.unparsed_lines).toEqual([]);
|
||||
expect(result.coverage).toBe("complete");
|
||||
});
|
||||
|
||||
|
||||
+59
-72
@@ -121,6 +121,7 @@ import {
|
||||
parseWmDensityOutput,
|
||||
parseWmSizeOutput,
|
||||
} from "./AdbShellOutput.js";
|
||||
import type { AdbUnparsedLines } from "./AdbUnparsedLines.js";
|
||||
|
||||
/** Stable identity for the ADB device-acquisition provider. */
|
||||
export const ADB_PROVIDER_IDENTITY = {
|
||||
@@ -152,7 +153,6 @@ const SCREEN_TIMEOUT_MS = 60_000;
|
||||
const SCREEN_MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
|
||||
const BUGREPORT_TIMEOUT_MS = 600_000;
|
||||
const BUGREPORT_MAX_OUTPUT_BYTES = 1024 * 1024;
|
||||
const UNPARSED_LINES_REPORTED = 10;
|
||||
|
||||
/** Carry the exact digest owner through caller-specific partial-result mapping. */
|
||||
class AdbDigestFailure extends Error {
|
||||
@@ -381,23 +381,35 @@ const clientIdentityResult = (
|
||||
type ClientIdentity = ReturnType<typeof clientIdentityResult>;
|
||||
type ProviderIdentity = { id: string; name: string; version: string | null };
|
||||
|
||||
const reported = (
|
||||
unparsedLines: readonly string[],
|
||||
coverage: "complete" | "partial",
|
||||
) => ({
|
||||
unparsed_lines: unparsedLines.slice(0, UNPARSED_LINES_REPORTED),
|
||||
coverage:
|
||||
coverage === "partial" || unparsedLines.length > 0 ? "partial" : "complete",
|
||||
});
|
||||
|
||||
const unparsedOverflowLimitation = (
|
||||
unparsedLines: readonly string[],
|
||||
): string[] =>
|
||||
unparsedLines.length > UNPARSED_LINES_REPORTED
|
||||
? [
|
||||
`unparsed_lines reports the first ${String(UNPARSED_LINES_REPORTED)} of ${String(unparsedLines.length)} unrecognized lines`,
|
||||
]
|
||||
: [];
|
||||
/**
|
||||
* Build a parsed-listing execution whose `unparsed_lines`, coverage, and
|
||||
* truncation limitation all derive from the parser's unrecognized-line facts,
|
||||
* so the limitation can never describe a field the result does not carry.
|
||||
*/
|
||||
const parsedListingExecution = (
|
||||
result: Readonly<Record<string, unknown>>,
|
||||
unparsed: AdbUnparsedLines,
|
||||
provider: ProviderIdentity,
|
||||
limitations: readonly string[] = [],
|
||||
) =>
|
||||
createAnalysisExecution(
|
||||
{
|
||||
...result,
|
||||
unparsed_lines: unparsed.sample,
|
||||
coverage: unparsed.total > 0 ? "partial" : "complete",
|
||||
},
|
||||
provider,
|
||||
{
|
||||
limitations: [
|
||||
...limitations,
|
||||
...(unparsed.total > unparsed.sample.length
|
||||
? [
|
||||
`unparsed_lines reports the first ${String(unparsed.sample.length)} of ${String(unparsed.total)} unrecognized lines`,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
/** Materialize the caller's output directory, refusing existing files inside. */
|
||||
const prepareOutputDirectory = async (
|
||||
@@ -1355,10 +1367,10 @@ export class AdbProvider {
|
||||
ran.value.stdout,
|
||||
ran.value.stderr,
|
||||
);
|
||||
if (unparsedLines.length > 0)
|
||||
if (unparsedLines.total > 0)
|
||||
return err(
|
||||
new AnalysisProtocolError(
|
||||
`adb devices reported an unrecognized line: ${unparsedLines[0]}`,
|
||||
`adb devices reported an unrecognized line: ${unparsedLines.sample[0]!}`,
|
||||
),
|
||||
);
|
||||
return ok(
|
||||
@@ -1435,21 +1447,18 @@ export class AdbProvider {
|
||||
ran.value.stdout,
|
||||
);
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
parsedListingExecution(
|
||||
{
|
||||
client,
|
||||
serial: request.input.serial,
|
||||
scope: request.input.scope,
|
||||
packages,
|
||||
...reported(unparsedLines, "complete"),
|
||||
},
|
||||
unparsedLines,
|
||||
provider,
|
||||
{
|
||||
limitations: [
|
||||
"base_apk_device_path is a device filesystem path, not a host-local path",
|
||||
...unparsedOverflowLimitation(unparsedLines),
|
||||
],
|
||||
},
|
||||
[
|
||||
"base_apk_device_path is a device filesystem path, not a host-local path",
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1603,16 +1612,10 @@ export class AdbProvider {
|
||||
ran.value.stdout,
|
||||
);
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
client,
|
||||
serial: request.input.serial,
|
||||
columns,
|
||||
processes,
|
||||
...reported(unparsedLines, "complete"),
|
||||
},
|
||||
parsedListingExecution(
|
||||
{ client, serial: request.input.serial, columns, processes },
|
||||
unparsedLines,
|
||||
provider,
|
||||
{ limitations: unparsedOverflowLimitation(unparsedLines) },
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1642,21 +1645,18 @@ export class AdbProvider {
|
||||
ran.value.stdout,
|
||||
);
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
parsedListingExecution(
|
||||
{
|
||||
client,
|
||||
serial: request.input.serial,
|
||||
device_path: request.input.device_path,
|
||||
entries,
|
||||
...reported(unparsedLines, "complete"),
|
||||
},
|
||||
unparsedLines,
|
||||
provider,
|
||||
{
|
||||
limitations: [
|
||||
"Entry kinds derive from stat permission mode; directory contents and link targets may change during capture",
|
||||
...unparsedOverflowLimitation(unparsedLines),
|
||||
],
|
||||
},
|
||||
[
|
||||
"Entry kinds derive from stat permission mode; directory contents and link targets may change during capture",
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1670,15 +1670,10 @@ export class AdbProvider {
|
||||
ran.value.stdout,
|
||||
);
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
client,
|
||||
serial: request.input.serial,
|
||||
features,
|
||||
...reported(unparsedLines, "complete"),
|
||||
},
|
||||
parsedListingExecution(
|
||||
{ client, serial: request.input.serial, features },
|
||||
unparsedLines,
|
||||
provider,
|
||||
{ limitations: unparsedOverflowLimitation(unparsedLines) },
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1692,15 +1687,10 @@ export class AdbProvider {
|
||||
ran.value.stdout,
|
||||
);
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
client,
|
||||
serial: request.input.serial,
|
||||
services,
|
||||
...reported(unparsedLines, "complete"),
|
||||
},
|
||||
parsedListingExecution(
|
||||
{ client, serial: request.input.serial, services },
|
||||
unparsedLines,
|
||||
provider,
|
||||
{ limitations: unparsedOverflowLimitation(unparsedLines) },
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1830,7 +1820,7 @@ export class AdbProvider {
|
||||
entry.package_name.toLowerCase().includes(needle),
|
||||
);
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
parsedListingExecution(
|
||||
{
|
||||
client,
|
||||
serial: request.input.serial,
|
||||
@@ -1839,15 +1829,12 @@ export class AdbProvider {
|
||||
exact_match:
|
||||
matches.length === 1 &&
|
||||
matches[0]!.package_name.toLowerCase() === needle,
|
||||
coverage: unparsedLines.length > 0 ? "partial" : "complete",
|
||||
},
|
||||
unparsedLines,
|
||||
provider,
|
||||
{
|
||||
limitations: [
|
||||
"Matching is a case-insensitive substring over package names; application display labels are not part of pm output",
|
||||
...unparsedOverflowLimitation(unparsedLines),
|
||||
],
|
||||
},
|
||||
[
|
||||
"Matching is a case-insensitive substring over package names; application display labels are not part of pm output",
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1952,7 +1939,7 @@ export class AdbProvider {
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (parsed.paths.length === 0 && parsed.unparsedLines.length === 0)
|
||||
if (parsed.paths.length === 0 && parsed.unparsedLines.total === 0)
|
||||
return err(
|
||||
new AnalysisUnsupportedTargetError(
|
||||
operation,
|
||||
@@ -1960,10 +1947,10 @@ export class AdbProvider {
|
||||
`Package Manager reported no APK paths for ${packageName}; it may not be installed for user 0`,
|
||||
),
|
||||
);
|
||||
if (parsed.unparsedLines.length > 0)
|
||||
if (parsed.unparsedLines.total > 0)
|
||||
return err(
|
||||
new AnalysisProtocolError(
|
||||
`pm path reported an unrecognized line: ${parsed.unparsedLines[0]}`,
|
||||
`pm path reported an unrecognized line: ${parsed.unparsedLines.sample[0]!}`,
|
||||
{ capturedOutput },
|
||||
),
|
||||
);
|
||||
|
||||
+18
-14
@@ -2,13 +2,17 @@ import type {
|
||||
AdbWindowFocusContext,
|
||||
AdbWindowFocusObservation,
|
||||
} from "../domain/adb/adbDeviceAnalysis.js";
|
||||
import {
|
||||
collectUnparsedLines,
|
||||
type AdbUnparsedLines,
|
||||
} from "./AdbUnparsedLines.js";
|
||||
|
||||
/**
|
||||
* Parsers for fixed observation commands REA runs through `adb shell`.
|
||||
*
|
||||
* Formats follow toybox output on Android 8+ (verified against an Android 14
|
||||
* emulator). Unparseable lines are retained rather than dropped so callers
|
||||
* can see the format drift instead of silently losing rows.
|
||||
* emulator). Unparseable lines are sampled and counted rather than dropped so
|
||||
* callers can see the format drift instead of silently losing rows.
|
||||
*/
|
||||
|
||||
export interface AdbProcessEntry {
|
||||
@@ -29,10 +33,10 @@ export const parseProcessListOutput = (
|
||||
): {
|
||||
columns: string | null;
|
||||
processes: readonly AdbProcessEntry[];
|
||||
unparsedLines: readonly string[];
|
||||
unparsedLines: AdbUnparsedLines;
|
||||
} => {
|
||||
const processes: AdbProcessEntry[] = [];
|
||||
const unparsedLines: string[] = [];
|
||||
const unparsed = collectUnparsedLines();
|
||||
let columns: string | null = null;
|
||||
for (const rawLine of stdout.split(/\r?\n/u)) {
|
||||
const line = rawLine.trimEnd();
|
||||
@@ -43,7 +47,7 @@ export const parseProcessListOutput = (
|
||||
}
|
||||
const match = PROCESS_LINE.exec(line);
|
||||
if (match === null) {
|
||||
unparsedLines.push(line);
|
||||
unparsed.add(line);
|
||||
continue;
|
||||
}
|
||||
processes.push({
|
||||
@@ -55,7 +59,7 @@ export const parseProcessListOutput = (
|
||||
name: match[9]!.trim(),
|
||||
});
|
||||
}
|
||||
return { columns, processes, unparsedLines };
|
||||
return { columns, processes, unparsedLines: unparsed.facts() };
|
||||
};
|
||||
|
||||
export interface AdbFeatureEntry {
|
||||
@@ -73,16 +77,16 @@ export const parseFeatureListOutput = (
|
||||
stdout: string,
|
||||
): {
|
||||
features: readonly AdbFeatureEntry[];
|
||||
unparsedLines: readonly string[];
|
||||
unparsedLines: AdbUnparsedLines;
|
||||
} => {
|
||||
const features: AdbFeatureEntry[] = [];
|
||||
const unparsedLines: string[] = [];
|
||||
const unparsed = collectUnparsedLines();
|
||||
for (const rawLine of stdout.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (line === "") continue;
|
||||
const match = FEATURE_LINE.exec(line);
|
||||
if (match === null) {
|
||||
unparsedLines.push(line);
|
||||
unparsed.add(line);
|
||||
continue;
|
||||
}
|
||||
features.push({
|
||||
@@ -90,7 +94,7 @@ export const parseFeatureListOutput = (
|
||||
version: match[2] === undefined ? null : parseFeatureVersion(match[2]!),
|
||||
});
|
||||
}
|
||||
return { features, unparsedLines };
|
||||
return { features, unparsedLines: unparsed.facts() };
|
||||
};
|
||||
|
||||
export interface AdbServiceEntry {
|
||||
@@ -105,17 +109,17 @@ export const parseServiceListOutput = (
|
||||
stdout: string,
|
||||
): {
|
||||
services: readonly AdbServiceEntry[];
|
||||
unparsedLines: readonly string[];
|
||||
unparsedLines: AdbUnparsedLines;
|
||||
} => {
|
||||
const services: AdbServiceEntry[] = [];
|
||||
const unparsedLines: string[] = [];
|
||||
const unparsed = collectUnparsedLines();
|
||||
for (const rawLine of stdout.split(/\r?\n/u)) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (line.trim() === "" || /^Found \d+ services:/u.test(line.trim()))
|
||||
continue;
|
||||
const match = SERVICE_LINE.exec(line);
|
||||
if (match === null) {
|
||||
if (line.trim() !== "") unparsedLines.push(line.trim());
|
||||
if (line.trim() !== "") unparsed.add(line.trim());
|
||||
continue;
|
||||
}
|
||||
services.push({
|
||||
@@ -123,7 +127,7 @@ export const parseServiceListOutput = (
|
||||
interface: match[2] === "" ? null : match[2]!,
|
||||
});
|
||||
}
|
||||
return { services, unparsedLines };
|
||||
return { services, unparsedLines: unparsed.facts() };
|
||||
};
|
||||
|
||||
const SIZE_PAIR = /^Physical size:\s+(\d+)x(\d+)$/u;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Most unrecognized output lines a parser retains as source evidence. */
|
||||
const UNPARSED_LINE_SAMPLE_LIMIT = 10;
|
||||
|
||||
/**
|
||||
* Unrecognized output lines one parser observed: the first lines verbatim, up
|
||||
* to {@link UNPARSED_LINE_SAMPLE_LIMIT}, and the exact number observed.
|
||||
*/
|
||||
export interface AdbUnparsedLines {
|
||||
readonly sample: readonly string[];
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
/** Accumulate unrecognized lines without retaining more than the sample. */
|
||||
export const collectUnparsedLines = (): {
|
||||
add: (line: string) => void;
|
||||
facts: () => AdbUnparsedLines;
|
||||
} => {
|
||||
const sample: string[] = [];
|
||||
let total = 0;
|
||||
return {
|
||||
add: (line) => {
|
||||
total += 1;
|
||||
if (sample.length < UNPARSED_LINE_SAMPLE_LIMIT) sample.push(line);
|
||||
},
|
||||
facts: () => ({ sample, total }),
|
||||
};
|
||||
};
|
||||
@@ -731,6 +731,7 @@ export const adbResultSchemas = {
|
||||
}),
|
||||
),
|
||||
exact_match: z.boolean(),
|
||||
unparsed_lines: z.array(z.string().min(1)),
|
||||
coverage: z.enum(["complete", "partial"]),
|
||||
}),
|
||||
install_adb_package: z.strictObject({
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.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";
|
||||
|
||||
const PACKAGE_LINE = "package:/data/app/example/base.apk=com.example.app";
|
||||
const unrecognized = (count: number) =>
|
||||
Array.from(
|
||||
{ length: count },
|
||||
(_, index) => `unrecognized package row ${String(index + 1)}`,
|
||||
);
|
||||
|
||||
cliTest.skipIf(process.platform === "win32")(
|
||||
"reports unrecognized package rows through package search and listing via CLI and MCP",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-adb-package-coverage-");
|
||||
const binary = join(root, "adb");
|
||||
const inventory = join(root, "packages.txt");
|
||||
await writeFile(
|
||||
binary,
|
||||
`#!${process.execPath}
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === 'version') {
|
||||
console.log('Android Debug Bridge version 1.0.41\\nVersion 34.0.5-fixture\\nInstalled as ' + process.argv[1]);
|
||||
process.exit(0);
|
||||
}
|
||||
if (args.join(' ') === '-s emulator-5554 shell pm list packages -f') {
|
||||
process.stdout.write(require('node:fs').readFileSync(${JSON.stringify(inventory)}));
|
||||
process.exit(0);
|
||||
}
|
||||
process.exit(91);
|
||||
`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
const environment = {
|
||||
HOME: root,
|
||||
USERPROFILE: root,
|
||||
REA_ADB_PATH: binary,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
};
|
||||
const { call } = await connectLocalToolsMcp(environment);
|
||||
|
||||
for (const count of [0, 1, 11]) {
|
||||
const rows = unrecognized(count);
|
||||
await writeFile(inventory, [PACKAGE_LINE, ...rows, ""].join("\n"));
|
||||
const expected = {
|
||||
unparsed_lines: rows.slice(0, 10),
|
||||
coverage: count === 0 ? "complete" : "partial",
|
||||
};
|
||||
const truncation =
|
||||
"unparsed_lines reports the first 10 of 11 unrecognized lines";
|
||||
|
||||
for (const [command, operation, tail] of [
|
||||
["resolve-adb-packages", "resolve_adb_packages", ["example"]],
|
||||
["list-adb-packages", "list_adb_packages", []],
|
||||
] as const) {
|
||||
const output = await cli.run({
|
||||
arguments: [command, "emulator-5554", ...tail, "--json"],
|
||||
environment,
|
||||
});
|
||||
expect(output.exitCode, output.stderr).toBe(0);
|
||||
const evidence = parseEvidence(output.json);
|
||||
expect(
|
||||
evidence.normalized_result,
|
||||
`${command} ${String(count)}`,
|
||||
).toMatchObject(expected);
|
||||
if (count === 11) expect(evidence.limitations).toContain(truncation);
|
||||
else
|
||||
expect(evidence.limitations).not.toContain(
|
||||
expect.stringContaining("unparsed_lines"),
|
||||
);
|
||||
|
||||
if (operation !== "resolve_adb_packages") continue;
|
||||
expect(evidence.normalized_result).toMatchObject({
|
||||
matches: [
|
||||
{
|
||||
package_name: "com.example.app",
|
||||
base_apk_device_path: "/data/app/example/base.apk",
|
||||
},
|
||||
],
|
||||
exact_match: false,
|
||||
});
|
||||
const reply = await call(operation, {
|
||||
serial: "emulator-5554",
|
||||
query: "example",
|
||||
});
|
||||
expect(reply.isError).not.toBe(true);
|
||||
const structured = toolContract(operation).outputSchema.parse(
|
||||
reply.structuredContent,
|
||||
);
|
||||
expect(structured.normalized_result).toEqual(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
expect(structured.limitations).toEqual(evidence.limitations);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -242,6 +242,7 @@ const results: Readonly<Record<string, unknown>> = {
|
||||
},
|
||||
],
|
||||
exact_match: true,
|
||||
unparsed_lines: [],
|
||||
coverage: "complete",
|
||||
},
|
||||
install_adb_package: {
|
||||
|
||||
@@ -231,7 +231,7 @@ itPosix(
|
||||
});
|
||||
expect(parseAdbDirectoryListing(output)).toEqual({
|
||||
entries: [],
|
||||
unparsedLines: [],
|
||||
unparsedLines: { sample: [], total: 0 },
|
||||
});
|
||||
expect((await readFile(calls, "utf8")).trim()).toBe("3");
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user