From 3f2fdb0cf567079ac4f55aed32c705842e620f07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 13:51:44 +0200 Subject: [PATCH 1/4] refactor: split debug symbols workflow modules --- src/debug-symbols.ts | 920 +--------------------------- src/debug-symbols/crash-artifact.ts | 252 ++++++++ src/debug-symbols/dsym.ts | 159 +++++ src/debug-symbols/report.ts | 236 +++++++ src/debug-symbols/symbolication.ts | 125 ++++ src/debug-symbols/types.ts | 118 ++++ src/debug-symbols/utils.ts | 94 +++ 7 files changed, 997 insertions(+), 907 deletions(-) create mode 100644 src/debug-symbols/crash-artifact.ts create mode 100644 src/debug-symbols/dsym.ts create mode 100644 src/debug-symbols/report.ts create mode 100644 src/debug-symbols/symbolication.ts create mode 100644 src/debug-symbols/types.ts create mode 100644 src/debug-symbols/utils.ts diff --git a/src/debug-symbols.ts b/src/debug-symbols.ts index ab1f78259..a725ce4d0 100644 --- a/src/debug-symbols.ts +++ b/src/debug-symbols.ts @@ -1,115 +1,21 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { runCmd } from './utils/exec.ts'; +import { readAppleCrashArtifact } from './debug-symbols/crash-artifact.ts'; +import { matchImagesToDsyms, readDsymPaths, readDsymSlices } from './debug-symbols/dsym.ts'; +import { summarizeCrashArtifact } from './debug-symbols/report.ts'; +import { resolveAppleTools, symbolicateAddresses } from './debug-symbols/symbolication.ts'; +import type { DebugSymbolsOptions, DebugSymbolsResult } from './debug-symbols/types.ts'; import { AppError } from './utils/errors.ts'; -export type DebugSymbolsOptions = { - action?: 'symbols'; - artifact: string; - dsym?: string; - searchPath?: string; - out?: string; - cwd?: string; -}; +export type { + DebugSymbolsCrashFrame, + DebugSymbolsCrashSummary, + DebugSymbolsImage, + DebugSymbolsOptions, + DebugSymbolsResult, +} from './debug-symbols/types.ts'; -export type DebugSymbolsImage = { - name: string; - uuid: string; - arch?: string; - dsymPath: string; - binaryPath: string; -}; - -export type DebugSymbolsCrashFrame = { - index: number; - image: string; - address: string; - symbol?: string; -}; - -export type DebugSymbolsCrashSummary = { - format: 'ips' | 'text'; - appName?: string; - bundleId?: string; - version?: string; - incident?: string; - timestamp?: string; - exceptionType?: string; - exceptionCodes?: string; - terminationReason?: string; - crashedThread?: number; - topFrames: DebugSymbolsCrashFrame[]; - findings: string[]; -}; - -export type DebugSymbolsResult = { - kind: 'debugSymbols'; - platform: 'apple'; - artifactPath: string; - outPath: string; - crash: DebugSymbolsCrashSummary; - matchedImages: DebugSymbolsImage[]; - symbolicatedFrames: number; - skippedImages: number; - warnings?: string[]; - message: string; -}; - -type AppleImage = { - index?: number; - name: string; - uuid: string; - arch?: string; - base: bigint; - end?: bigint; - path?: string; -}; - -type DsymSlice = { - dsymPath: string; - uuid: string; - arch?: string; - binaryPath: string; -}; - -type SymbolicatedAddress = { - image: AppleImage; - address: bigint; - text?: string; -}; - -type CrashArtifact = { - images: AppleImage[]; - addresses: SymbolicatedAddress[]; - summary: (addressMap: Map) => DebugSymbolsCrashSummary; - write: (addressMap: Map) => string; -}; - -type IpsFrameMatch = SymbolicatedAddress & { - frame: Record; - frameIndex: number; - threadIndex: number; -}; - -type IpsDocument = { - header?: string; - payload: Record; -}; - -type SymbolicationGroup = { - image: AppleImage; - dsym: DsymSlice; - addresses: bigint[]; -}; - -const MAX_SEARCH_ENTRIES = 10_000; -const MAX_DSYM_CANDIDATES = 200; -const MAX_CRASH_SUMMARY_FRAMES = 5; -const MAX_CRASH_FINDINGS = 3; const MAX_CRASH_ARTIFACT_BYTES = 64 * 1024 * 1024; -const UUID_DETAIL_SAMPLE_LIMIT = 5; -const UUID_RE = /^[0-9a-fA-F-]{32,36}$/; -const TEXT_IMAGE_ARCH_RE = /^(?:arm64e?|arm64_32|x86_64|armv7[sk]?|i386)$/; export async function symbolicateCrashArtifact( options: DebugSymbolsOptions, @@ -167,7 +73,7 @@ export async function symbolicateCrashArtifact( platform: 'apple', artifactPath, outPath, - crash: crash.summary(addressMap), + crash: summarizeCrashArtifact(crash, addressMap), matchedImages, symbolicatedFrames, skippedImages, @@ -176,715 +82,6 @@ export async function symbolicateCrashArtifact( }; } -function readAppleCrashArtifact(text: string): CrashArtifact | null { - return readIpsArtifact(text) ?? readTextCrashArtifact(text); -} - -function readIpsArtifact(text: string): CrashArtifact | null { - const document = readIpsDocument(text); - if (!document) return null; - const rawImages = Array.isArray(document.payload.usedImages) ? document.payload.usedImages : []; - const images = rawImages.flatMap((entry, index) => readIpsImage(entry, index)); - if (images.length === 0) return null; - - const rawThreads = Array.isArray(document.payload.threads) ? document.payload.threads : []; - const frameMatches = readIpsFrameMatches(rawThreads, images); - - return { - images, - addresses: frameMatches.map(({ frame: _frame, ...address }) => address), - summary: (addressMap) => summarizeIpsCrash(document, frameMatches, addressMap), - write: (addressMap) => writeIpsArtifact(document, frameMatches, addressMap), - }; -} - -function readIpsDocument(text: string): IpsDocument | null { - const wholeDocument = readJsonRecord(text); - if (wholeDocument) return { payload: wholeDocument }; - const newlineIndex = text.indexOf('\n'); - if (newlineIndex === -1) return null; - const header = text.slice(0, newlineIndex); - const payload = readJsonRecord(text.slice(newlineIndex + 1)); - return payload ? { header, payload } : null; -} - -function readJsonRecord(text: string): Record | null { - try { - const value = JSON.parse(text); - return value && typeof value === 'object' ? (value as Record) : null; - } catch { - return null; - } -} - -function readIpsFrameMatches(rawThreads: unknown[], images: AppleImage[]): IpsFrameMatch[] { - const imageByIndex = new Map(images.map((image) => [image.index, image])); - return rawThreads.flatMap((thread, threadIndex) => - readIpsFrameRecords(thread).flatMap((frame, frameIndex) => - readIpsFrameMatch(frame, imageByIndex, threadIndex, frameIndex), - ), - ); -} - -function readIpsFrameRecords(thread: unknown): Record[] { - if (!thread || typeof thread !== 'object') return []; - const frames = (thread as Record).frames; - return Array.isArray(frames) - ? frames.filter((frame): frame is Record => isRecord(frame)) - : []; -} - -function readIpsFrameMatch( - frame: Record, - imageByIndex: Map, - threadIndex: number, - frameIndex: number, -): IpsFrameMatch[] { - const imageIndex = readIntegerNumberField(frame, 'imageIndex', 'IPS frame'); - const imageOffset = readBigIntField(frame, 'imageOffset', 'IPS frame'); - if (imageIndex === undefined || imageOffset === undefined) return []; - const image = imageByIndex.get(imageIndex); - return image - ? [{ frame, frameIndex, threadIndex, image, address: image.base + imageOffset }] - : []; -} - -function writeIpsArtifact( - document: IpsDocument, - frameMatches: IpsFrameMatch[], - addressMap: Map, -): string { - for (const match of frameMatches) { - const symbol = addressMap.get(addressKey(match.image, match.address))?.text; - if (symbol) writeIpsFrameSymbol(match.frame, symbol); - } - document.payload.agentDeviceSymbolication = { - tool: 'agent-device debug symbols', - symbolicatedFrames: [...addressMap.values()].filter((entry) => entry.text).length, - }; - const payload = `${JSON.stringify(document.payload, null, 2)}\n`; - return document.header ? `${document.header}\n${payload}` : payload; -} - -function writeIpsFrameSymbol(frame: Record, symbol: string): void { - const parsed = parseAtosSymbol(symbol); - frame.symbol = parsed.symbol; - if (parsed.location !== undefined) frame.symbolLocation = parsed.location; -} - -function summarizeIpsCrash( - document: IpsDocument, - frameMatches: IpsFrameMatch[], - addressMap: Map, -): DebugSymbolsCrashSummary { - const crashedThread = readIpsCrashedThread(document.payload); - const summary: DebugSymbolsCrashSummary = { - format: 'ips', - ...readIpsCrashMetadata(document), - crashedThread, - topFrames: summarizeIpsFrames(frameMatches, crashedThread, addressMap), - findings: [], - }; - return { ...summary, findings: crashFindings(summary) }; -} - -function readIpsCrashMetadata( - document: IpsDocument, -): Omit { - const payload = document.payload; - const header = readIpsHeader(document.header); - return { - appName: readIpsAppName(payload, header), - bundleId: readIpsBundleId(payload, header), - version: readIpsVersion(payload, header), - incident: readIpsIncident(payload, header), - timestamp: readIpsTimestamp(payload, header), - exceptionType: readIpsExceptionType(payload.exception), - exceptionCodes: readIpsExceptionCodes(payload.exception), - terminationReason: readIpsTerminationReason(payload.termination), - }; -} - -function readIpsAppName( - payload: Record, - header: Record | null, -): string | undefined { - return firstString(payload.procName, header?.app_name, header?.name); -} - -function readIpsBundleId( - payload: Record, - header: Record | null, -): string | undefined { - return firstString(readRecord(payload.bundleInfo)?.CFBundleIdentifier, header?.bundleID); -} - -function readIpsVersion( - payload: Record, - header: Record | null, -): string | undefined { - return firstString( - readRecord(payload.bundleInfo)?.CFBundleShortVersionString, - header?.app_version, - ); -} - -function readIpsIncident( - payload: Record, - header: Record | null, -): string | undefined { - return firstString(payload.incident, header?.incident_id); -} - -function readIpsTimestamp( - payload: Record, - header: Record | null, -): string | undefined { - return firstString(payload.captureTime, header?.timestamp); -} - -function readIpsExceptionType(exception: unknown): string | undefined { - return readString(readRecord(exception)?.type); -} - -function readIpsHeader(header: string | undefined): Record | null { - return header ? readJsonRecord(header) : null; -} - -function readIpsCrashedThread(payload: Record): number | undefined { - const faultingThread = readNumber(payload.faultingThread); - if (faultingThread !== undefined) return faultingThread; - const threads = Array.isArray(payload.threads) ? payload.threads : []; - const triggeredIndex = threads.findIndex( - (thread) => isRecord(thread) && thread.triggered === true, - ); - return triggeredIndex === -1 ? undefined : triggeredIndex; -} - -function readIpsExceptionCodes(exception: unknown): string | undefined { - const record = readRecord(exception); - if (!record) return undefined; - return firstString(record.codes, record.rawCodes); -} - -function readIpsTerminationReason(termination: unknown): string | undefined { - const record = readRecord(termination); - if (!record) return undefined; - return compactJoin([ - readString(record.namespace), - readString(record.code), - readString(record.reason), - ]); -} - -function summarizeIpsFrames( - frameMatches: IpsFrameMatch[], - crashedThread: number | undefined, - addressMap: Map, -): DebugSymbolsCrashFrame[] { - return frameMatches - .filter((match) => crashedThread === undefined || match.threadIndex === crashedThread) - .slice(0, MAX_CRASH_SUMMARY_FRAMES) - .map((match) => crashFrameSummary(match.frameIndex, match.image, match.address, addressMap)); -} - -function readIpsImage(value: unknown, index: number): AppleImage[] { - if (!value || typeof value !== 'object') return []; - const record = value as Record; - const uuid = normalizeUuid(readString(record.uuid)); - const base = readBigIntField(record, 'base', 'IPS usedImages'); - if (!uuid || base === undefined) return []; - const pathValue = readString(record.path); - return [ - { - index, - name: readString(record.name) ?? (pathValue ? path.basename(pathValue) : `image-${index}`), - uuid, - arch: readString(record.arch), - base, - path: pathValue, - }, - ]; -} - -function readTextCrashArtifact(text: string): CrashArtifact | null { - const lines = text.split('\n'); - const images = readTextImages(lines); - if (images.length === 0) return null; - const addresses = readTextFrameAddresses(lines, images); - return { - images, - addresses, - summary: (addressMap) => summarizeTextCrash(lines, images, addressMap), - write(addressMap) { - return lines - .map((line) => { - const frame = readTextFrameLine(line, images); - if (!frame) return line; - const symbol = addressMap.get(addressKey(frame.image, frame.address))?.text; - if (!symbol || line.includes(symbol)) return line; - return `${line} // ${symbol}`; - }) - .join('\n'); - }, - }; -} - -function summarizeTextCrash( - lines: string[], - images: AppleImage[], - addressMap: Map, -): DebugSymbolsCrashSummary { - const crashedThread = readTextCrashedThread(lines); - const summary: DebugSymbolsCrashSummary = { - format: 'text', - appName: readTextProcessName(lines), - bundleId: readTextField(lines, 'Identifier'), - version: readTextField(lines, 'Version'), - incident: readTextField(lines, 'Incident Identifier'), - timestamp: readTextField(lines, 'Date/Time'), - exceptionType: readTextField(lines, 'Exception Type'), - exceptionCodes: readTextField(lines, 'Exception Codes'), - terminationReason: readTextField(lines, 'Termination Reason'), - crashedThread, - topFrames: summarizeTextFrames(lines, images, crashedThread, addressMap), - findings: [], - }; - return { ...summary, findings: crashFindings(summary) }; -} - -function readTextProcessName(lines: string[]): string | undefined { - const process = readTextField(lines, 'Process'); - return process?.replace(/\s+\[\d+\]$/, ''); -} - -function readTextField(lines: string[], label: string): string | undefined { - const prefix = `${label}:`; - const line = lines.find((candidate) => candidate.trimStart().startsWith(prefix)); - return line ? line.slice(line.indexOf(':') + 1).trim() || undefined : undefined; -} - -function readTextCrashedThread(lines: string[]): number | undefined { - const triggered = readTextField(lines, 'Triggered by Thread'); - const triggeredThread = triggered ? Number.parseInt(triggered, 10) : Number.NaN; - if (Number.isSafeInteger(triggeredThread)) return triggeredThread; - for (const line of lines) { - const match = line.match(/^Thread\s+(\d+)\s+Crashed:/); - if (match) return Number(match[1]); - } - return undefined; -} - -function summarizeTextFrames( - lines: string[], - images: AppleImage[], - crashedThread: number | undefined, - addressMap: Map, -): DebugSymbolsCrashFrame[] { - return textCrashedThreadFrameLines(lines, crashedThread) - .flatMap((line) => readTextCrashFrameSummary(line, images, addressMap)) - .slice(0, MAX_CRASH_SUMMARY_FRAMES); -} - -function textCrashedThreadFrameLines(lines: string[], crashedThread: number | undefined): string[] { - const headingIndex = lines.findIndex((line) => - crashedThread === undefined - ? /^Thread\s+\d+\s+Crashed:/.test(line) - : new RegExp(`^Thread\\s+${crashedThread}\\s+Crashed:`).test(line), - ); - if (headingIndex === -1) return []; - const frames: string[] = []; - for (const line of lines.slice(headingIndex + 1)) { - if (/^Thread\s+\d+/.test(line) || line.trim().length === 0) break; - if (/^\s*\d+\s+/.test(line)) frames.push(line); - } - return frames; -} - -function readTextCrashFrameSummary( - line: string, - images: AppleImage[], - addressMap: Map, -): DebugSymbolsCrashFrame[] { - const frame = readTextFrameLine(line, images); - const indexMatch = line.match(/^\s*(\d+)/); - return frame && indexMatch - ? [crashFrameSummary(Number(indexMatch[1]), frame.image, frame.address, addressMap)] - : []; -} - -function crashFrameSummary( - index: number, - image: AppleImage, - address: bigint, - addressMap: Map, -): DebugSymbolsCrashFrame { - return { - index, - image: image.name, - address: hex(address), - symbol: addressMap.get(addressKey(image, address))?.text, - }; -} - -function crashFindings(summary: DebugSymbolsCrashSummary): string[] { - return [ - firstSymbolicatedFrameFinding(summary), - summary.exceptionType ? `Exception: ${summary.exceptionType}` : undefined, - summary.terminationReason ? `Termination: ${summary.terminationReason}` : undefined, - ] - .filter((finding): finding is string => Boolean(finding)) - .slice(0, MAX_CRASH_FINDINGS); -} - -function firstSymbolicatedFrameFinding(summary: DebugSymbolsCrashSummary): string | undefined { - const frame = summary.topFrames.find((candidate) => candidate.symbol); - if (frame) { - return `Start with ${frame.symbol} in ${frame.image}; it is the first symbolicated frame captured on the crashed thread.`; - } - return summary.topFrames.length > 0 - ? 'No symbolicated frame was found on the crashed thread; verify matching dSYMs for the top images.' - : undefined; -} - -function readTextImages(lines: string[]): AppleImage[] { - const images: AppleImage[] = []; - const binaryImagesIndex = lines.findIndex((line) => /^Binary Images:/i.test(line.trim())); - if (binaryImagesIndex === -1) return images; - for (const line of lines.slice(binaryImagesIndex + 1)) { - const image = readTextImageLine(line); - if (image) images.push(image); - } - return images; -} - -function readTextImageLine(line: string): AppleImage | null { - const match = line.match( - /^\s*(0x[0-9a-fA-F]+)\s*-\s*(0x[0-9a-fA-F]+)\s+\+?(.+?)\s+<([0-9a-fA-F-]{32,36})>\s+(.+)$/, - ); - if (!match) return null; - const uuid = normalizeUuid(match[4]); - if (!uuid) return null; - const parsedName = readTextImageNameAndArch(match[3]!.trim(), match[5]!.trim()); - return { - name: parsedName.name, - arch: parsedName.arch, - uuid, - base: BigInt(match[1]!), - end: BigInt(match[2]!), - path: match[5]!.trim(), - }; -} - -function readTextImageNameAndArch( - rawName: string, - imagePath: string, -): { name: string; arch?: string } { - const tokens = rawName.split(/\s+/); - const maybeArch = tokens.at(-1); - if (maybeArch && TEXT_IMAGE_ARCH_RE.test(maybeArch)) { - return { name: tokens.slice(0, -1).join(' ').trim(), arch: maybeArch }; - } - const executableName = path.basename(imagePath); - return { name: rawName.startsWith(executableName) ? executableName : rawName }; -} - -function readTextFrameAddresses(lines: string[], images: AppleImage[]): SymbolicatedAddress[] { - return lines.flatMap((line) => { - const frame = readTextFrameLine(line, images); - return frame ? [frame] : []; - }); -} - -function readTextFrameLine(line: string, images: AppleImage[]): SymbolicatedAddress | null { - const match = line.match(/^\s*\d+\s+(.+?)\s+(0x[0-9a-fA-F]+)\b/); - if (!match) return null; - const imageName = match[1]!.trim(); - const address = BigInt(match[2]!); - const image = findTextFrameImage(images, imageName, address); - if (!image) return null; - return { image, address }; -} - -function findTextFrameImage( - images: AppleImage[], - imageName: string, - address: bigint, -): AppleImage | undefined { - const matches = images.filter((candidate) => candidate.name === imageName); - return matches.find((candidate) => imageContainsAddress(candidate, address)) ?? single(matches); -} - -function imageContainsAddress(image: AppleImage, address: bigint): boolean { - return image.end !== undefined && image.base <= address && address <= image.end; -} - -async function readDsymPaths(options: { - cwd: string; - dsym?: string; - searchPath?: string; -}): Promise { - if (options.dsym && options.searchPath) { - return [ - resolvePath(options.cwd, options.dsym), - ...(await findDsymBundles(resolvePath(options.cwd, options.searchPath))), - ]; - } - if (options.dsym) return [resolvePath(options.cwd, options.dsym)]; - if (options.searchPath) - return await findDsymBundles(resolvePath(options.cwd, options.searchPath)); - return []; -} - -async function findDsymBundles(root: string): Promise { - const found: string[] = []; - let visited = 0; - async function walk(current: string): Promise { - if (found.length >= MAX_DSYM_CANDIDATES) return; - visited += 1; - if (visited > MAX_SEARCH_ENTRIES) { - throw new AppError('COMMAND_FAILED', 'debug symbols search-path scan exceeded bounds.', { - searchPath: root, - maxEntries: MAX_SEARCH_ENTRIES, - hint: 'Pass --dsym directly or narrow --search-path to the build products directory.', - }); - } - const stat = await readSearchPathStat(current, root); - if (!stat.isDirectory()) { - if (current === root) throwInvalidSearchPathDirectory(root); - return; - } - if (current.endsWith('.dSYM')) { - found.push(current); - return; - } - const entries = await fs.readdir(current, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - await walk(path.join(current, entry.name)); - } - } - await walk(root); - return found; -} - -async function readSearchPathStat( - current: string, - root: string, -): Promise>> { - try { - return await fs.stat(current); - } catch { - throw new AppError('INVALID_ARGS', `debug symbols search path does not exist: ${root}`, { - hint: 'Pass an existing build products directory to --search-path, or pass --dsym directly.', - }); - } -} - -function throwInvalidSearchPathDirectory(root: string): never { - throw new AppError('INVALID_ARGS', `debug symbols search path is not a directory: ${root}`, { - hint: 'Pass an existing build products directory to --search-path, or pass --dsym directly.', - }); -} - -async function readDsymSlices(dsymPaths: string[], dwarfdump: string): Promise { - const sliceGroups = await Promise.all( - unique(dsymPaths).map((dsymPath) => readDsymBundleSlices(dsymPath, dwarfdump)), - ); - const slices = sliceGroups.flat(); - if (slices.length === 0) { - throw new AppError('COMMAND_FAILED', 'No UUIDs found in dSYM bundle.', { - hint: 'Verify the path points to a built .dSYM bundle with DWARF contents.', - }); - } - return slices; -} - -async function readDsymBundleSlices(dsymPath: string, dwarfdump: string): Promise { - await assertDsymBundlePath(dsymPath); - const result = await runCmd(dwarfdump, ['--uuid', dsymPath], { - timeoutMs: 15_000, - allowFailure: true, - }); - if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', `Failed to inspect dSYM UUIDs: ${dsymPath}`, { - stderr: result.stderr, - hint: 'Verify the dSYM bundle is valid and readable.', - }); - } - return parseDwarfdumpUuidOutput(dsymPath, result.stdout); -} - -async function assertDsymBundlePath(dsymPath: string): Promise { - const stat = await fs.stat(dsymPath).catch(() => null); - if (stat?.isDirectory() && dsymPath.endsWith('.dSYM')) return; - throw new AppError('INVALID_ARGS', `Not a .dSYM bundle: ${dsymPath}`, { - hint: 'Pass the .dSYM bundle path, not the DWARF executable inside it.', - }); -} - -function parseDwarfdumpUuidOutput(dsymPath: string, output: string): DsymSlice[] { - return output.split('\n').flatMap((line) => { - const match = line.match(/^UUID:\s+([0-9a-fA-F-]{32,36})\s+\(([^)]+)\)\s+(.+)$/); - const uuid = normalizeUuid(match?.[1]); - return match && uuid ? [{ dsymPath, uuid, arch: match[2], binaryPath: match[3]!.trim() }] : []; - }); -} - -function matchImagesToDsyms( - images: AppleImage[], - dsymSlices: DsymSlice[], - explicitDsym: boolean, -): Map { - const matched = new Map(); - for (const image of images) { - const dsym = dsymSlices.find( - (candidate) => - candidate.uuid === image.uuid && - (image.arch === undefined || candidate.arch === undefined || candidate.arch === image.arch), - ); - if (dsym) matched.set(image.uuid, { image, dsym }); - } - if (matched.size > 0) return matched; - - const artifactUuids = unique(images.map((image) => image.uuid)); - const dsymUuids = unique(dsymSlices.map((slice) => slice.uuid)); - throw new AppError( - 'COMMAND_FAILED', - explicitDsym - ? 'dSYM UUID does not match any Apple image in the crash artifact.' - : 'No matching dSYM UUID found under search path.', - { - artifactUuidCount: artifactUuids.length, - artifactUuidSample: artifactUuids.slice(0, UUID_DETAIL_SAMPLE_LIMIT), - dsymUuidCount: dsymUuids.length, - dsymUuidSample: dsymUuids.slice(0, UUID_DETAIL_SAMPLE_LIMIT), - hint: 'Use dwarfdump --uuid and compare it with the crash Binary Images or usedImages UUID, then pass the matching dSYM/search path.', - }, - ); -} - -async function symbolicateAddresses( - addresses: SymbolicatedAddress[], - matched: Map, - atos: string, -): Promise> { - const addressMap = new Map(); - for (const group of groupSymbolicationAddresses(addresses, matched).values()) { - for (const entry of await runAtosForGroup(atos, group)) { - addressMap.set(addressKey(entry.image, entry.address), entry); - } - } - return addressMap; -} - -function groupSymbolicationAddresses( - addresses: SymbolicatedAddress[], - matched: Map, -): Map { - const groups = new Map(); - for (const frame of addresses) { - const match = matched.get(frame.image.uuid); - if (!match) continue; - const key = `${frame.image.uuid}:${match.dsym.binaryPath}`; - const group = groups.get(key) ?? { ...match, addresses: [] }; - group.addresses.push(frame.address); - groups.set(key, group); - } - return groups; -} - -async function runAtosForGroup( - atos: string, - group: SymbolicationGroup, -): Promise { - const addresses = unique(group.addresses.map(hex)); - const result = await runCmd(atos, atosArgs(group, addresses), { - timeoutMs: 30_000, - allowFailure: true, - }); - if (result.exitCode !== 0) throwAtosFailure(result.stderr); - return mapAtosOutputToAddresses(group.image, addresses, result.stdout); -} - -function atosArgs(group: SymbolicationGroup, addresses: string[]): string[] { - return [ - '-arch', - group.image.arch ?? group.dsym.arch ?? 'arm64', - '-o', - group.dsym.binaryPath, - '-l', - hex(group.image.base), - ...addresses, - ]; -} - -function mapAtosOutputToAddresses( - image: AppleImage, - addresses: string[], - output: string, -): SymbolicatedAddress[] { - const symbols = splitAtosOutput(output); - return addresses.map((rawAddress, index) => { - const text = symbols[index]?.trim(); - return { - image, - address: BigInt(rawAddress), - text: isSymbolicatedAtosOutput(text, rawAddress) ? text : undefined, - }; - }); -} - -function splitAtosOutput(output: string): string[] { - const lines = output.split(/\r?\n/); - if (lines.at(-1) === '') lines.pop(); - return lines; -} - -function isSymbolicatedAtosOutput(text: string | undefined, rawAddress: string): text is string { - if (!text) return false; - const normalized = text.trim().toLowerCase(); - if (!normalized || normalized === '??') return false; - if (normalized === rawAddress.toLowerCase()) return false; - return !normalized.startsWith('0x'); -} - -function throwAtosFailure(stderr: string): never { - throw new AppError('COMMAND_FAILED', 'atos failed while symbolicating crash frames.', { - stderr, - hint: 'Verify the crash artifact and dSYM were produced from the same build and architecture.', - }); -} - -async function resolveAppleTools(): Promise<{ dwarfdump: string; atos: string }> { - return { - dwarfdump: await resolveAppleTool('dwarfdump'), - atos: await resolveAppleTool('atos'), - }; -} - -async function resolveAppleTool(name: 'dwarfdump' | 'atos'): Promise { - try { - const result = await runCmd('xcrun', ['--find', name], { - timeoutMs: 5_000, - allowFailure: true, - }); - const toolPath = result.stdout.trim(); - if (result.exitCode === 0 && toolPath.length > 0) return toolPath; - } catch { - // Fall through to the normalized TOOL_MISSING error below. - } - throw new AppError('TOOL_MISSING', `Apple symbolication tool not found: ${name}`, { - hint: 'Install Xcode Command Line Tools and verify xcrun --find dwarfdump and xcrun --find atos succeed.', - }); -} - -function parseAtosSymbol(value: string): { symbol: string; location?: number } { - const match = value.match(/^(.*) \+ (\d+)$/); - if (!match) return { symbol: value }; - return { symbol: match[1]!, location: Number(match[2]) }; -} - function throwUnsupportedArtifact(): never { throw new AppError( 'UNSUPPORTED_OPERATION', @@ -930,94 +127,3 @@ function defaultOutPath(artifactPath: string): string { const base = extension ? artifactPath.slice(0, -extension.length) : artifactPath; return `${base}-symbolicated${extension || '.log'}`; } - -function normalizeUuid(value: string | undefined): string | undefined { - if (!value || !UUID_RE.test(value)) return undefined; - return value.replaceAll('-', '').toUpperCase(); -} - -function addressKey(image: AppleImage, address: bigint): string { - return `${image.uuid}:${hex(address)}`; -} - -function hex(value: bigint): string { - return `0x${value.toString(16)}`; -} - -function readString(value: unknown): string | undefined { - return typeof value === 'string' && value.length > 0 ? value : undefined; -} - -function firstString(...values: unknown[]): string | undefined { - for (const value of values) { - const stringValue = readString(value); - if (stringValue) return stringValue; - } - return undefined; -} - -function compactJoin(values: (string | undefined)[]): string | undefined { - const compact = values.filter((value): value is string => Boolean(value)); - return compact.length > 0 ? compact.join(' ') : undefined; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function readRecord(value: unknown): Record | undefined { - return isRecord(value) ? value : undefined; -} - -function readNumber(value: unknown): number | undefined { - if (typeof value === 'number' && Number.isSafeInteger(value)) return value; - if (typeof value === 'string') { - const parsed = value.startsWith('0x') ? Number.parseInt(value, 16) : Number(value); - return Number.isSafeInteger(parsed) ? parsed : undefined; - } - return undefined; -} - -function readIntegerNumberField( - record: Record, - key: string, - context: string, -): number | undefined { - if (!Object.hasOwn(record, key)) return undefined; - const value = readNumber(record[key]); - if (value !== undefined) return value; - throwInvalidNumericField(context, key); -} - -function readBigIntField( - record: Record, - key: string, - context: string, -): bigint | undefined { - if (!Object.hasOwn(record, key)) return undefined; - const value = readBigInt(record[key]); - if (value !== undefined) return value; - throwInvalidNumericField(context, key); -} - -function readBigInt(value: unknown): bigint | undefined { - if (typeof value === 'bigint') return value; - if (typeof value === 'number' && Number.isSafeInteger(value)) return BigInt(value); - if (typeof value !== 'string') return undefined; - if (!/^(?:0x[0-9a-fA-F]+|\d+)$/.test(value)) return undefined; - return BigInt(value); -} - -function throwInvalidNumericField(context: string, key: string): never { - throw new AppError('INVALID_ARGS', `Invalid ${context} numeric field: ${key}`, { - hint: 'Crash artifact numeric fields must be integer numbers or integer numeric strings.', - }); -} - -function single(values: T[]): T | undefined { - return values.length === 1 ? values[0] : undefined; -} - -function unique(values: T[]): T[] { - return [...new Set(values)]; -} diff --git a/src/debug-symbols/crash-artifact.ts b/src/debug-symbols/crash-artifact.ts new file mode 100644 index 000000000..b9a6c6856 --- /dev/null +++ b/src/debug-symbols/crash-artifact.ts @@ -0,0 +1,252 @@ +import path from 'node:path'; +import type { + AppleImage, + CrashArtifact, + IpsDocument, + IpsFrameMatch, + SymbolicatedAddress, + TextFrameMatch, +} from './types.ts'; +import { + addressKey, + isRecord, + normalizeUuid, + readBigIntField, + readIntegerNumberField, + readString, + single, +} from './utils.ts'; +import { parseAtosSymbol } from './symbolication.ts'; + +const TEXT_IMAGE_ARCH_RE = /^(?:arm64e?|arm64_32|x86_64|armv7[sk]?|i386)$/; + +export function readAppleCrashArtifact(text: string): CrashArtifact | null { + return readIpsArtifact(text) ?? readTextCrashArtifact(text); +} + +function readIpsArtifact(text: string): CrashArtifact | null { + const document = readIpsDocument(text); + if (!document) return null; + const rawImages = Array.isArray(document.payload.usedImages) ? document.payload.usedImages : []; + const images = rawImages.flatMap((entry, index) => readIpsImage(entry, index)); + if (images.length === 0) return null; + + const rawThreads = Array.isArray(document.payload.threads) ? document.payload.threads : []; + const frameMatches = readIpsFrameMatches(rawThreads, images); + + return { + format: 'ips', + images, + addresses: frameMatches.map(({ frame: _frame, ...address }) => address), + document, + frameMatches, + write: (addressMap) => writeIpsArtifact(document, frameMatches, addressMap), + }; +} + +function readIpsDocument(text: string): IpsDocument | null { + const wholeDocument = readJsonRecord(text); + if (wholeDocument) return { payload: wholeDocument }; + const newlineIndex = text.indexOf('\n'); + if (newlineIndex === -1) return null; + const header = text.slice(0, newlineIndex); + const payload = readJsonRecord(text.slice(newlineIndex + 1)); + return payload ? { header, payload } : null; +} + +export function readJsonRecord(text: string): Record | null { + try { + const value = JSON.parse(text); + return value && typeof value === 'object' ? (value as Record) : null; + } catch { + return null; + } +} + +function readIpsFrameMatches(rawThreads: unknown[], images: AppleImage[]): IpsFrameMatch[] { + const imageByIndex = new Map(images.map((image) => [image.index, image])); + return rawThreads.flatMap((thread, threadIndex) => + readIpsFrameRecords(thread).flatMap((frame, frameIndex) => + readIpsFrameMatch(frame, imageByIndex, threadIndex, frameIndex), + ), + ); +} + +function readIpsFrameRecords(thread: unknown): Record[] { + if (!thread || typeof thread !== 'object') return []; + const frames = (thread as Record).frames; + return Array.isArray(frames) + ? frames.filter((frame): frame is Record => isRecord(frame)) + : []; +} + +function readIpsFrameMatch( + frame: Record, + imageByIndex: Map, + threadIndex: number, + frameIndex: number, +): IpsFrameMatch[] { + const imageIndex = readIntegerNumberField(frame, 'imageIndex', 'IPS frame'); + const imageOffset = readBigIntField(frame, 'imageOffset', 'IPS frame'); + if (imageIndex === undefined || imageOffset === undefined) return []; + const image = imageByIndex.get(imageIndex); + return image + ? [{ frame, frameIndex, threadIndex, image, address: image.base + imageOffset }] + : []; +} + +function writeIpsArtifact( + document: IpsDocument, + frameMatches: IpsFrameMatch[], + addressMap: Map, +): string { + for (const match of frameMatches) { + const symbol = addressMap.get(addressKey(match.image, match.address))?.text; + if (symbol) writeIpsFrameSymbol(match.frame, symbol); + } + document.payload.agentDeviceSymbolication = { + tool: 'agent-device debug symbols', + symbolicatedFrames: [...addressMap.values()].filter((entry) => entry.text).length, + }; + const payload = `${JSON.stringify(document.payload, null, 2)}\n`; + return document.header ? `${document.header}\n${payload}` : payload; +} + +function writeIpsFrameSymbol(frame: Record, symbol: string): void { + const parsed = parseAtosSymbol(symbol); + frame.symbol = parsed.symbol; + if (parsed.location !== undefined) frame.symbolLocation = parsed.location; +} + +function readIpsImage(value: unknown, index: number): AppleImage[] { + if (!value || typeof value !== 'object') return []; + const record = value as Record; + const uuid = normalizeUuid(readString(record.uuid)); + const base = readBigIntField(record, 'base', 'IPS usedImages'); + if (!uuid || base === undefined) return []; + const pathValue = readString(record.path); + return [ + { + index, + name: readString(record.name) ?? (pathValue ? path.basename(pathValue) : `image-${index}`), + uuid, + arch: readString(record.arch), + base, + path: pathValue, + }, + ]; +} + +function readTextCrashArtifact(text: string): CrashArtifact | null { + const lines = text.split('\n'); + const images = readTextImages(lines); + if (images.length === 0) return null; + const frameMatches = readTextFrameMatches(lines, images); + return { + format: 'text', + images, + addresses: frameMatches, + lines, + frameMatches, + write(addressMap) { + const framesByLine = new Map(frameMatches.map((frame) => [frame.lineIndex, frame])); + return lines + .map((line, lineIndex) => { + const frame = framesByLine.get(lineIndex); + if (!frame) return line; + const symbol = addressMap.get(addressKey(frame.image, frame.address))?.text; + if (!symbol || line.includes(symbol)) return line; + return `${line} // ${symbol}`; + }) + .join('\n'); + }, + }; +} + +function readTextImages(lines: string[]): AppleImage[] { + const images: AppleImage[] = []; + const binaryImagesIndex = lines.findIndex((line) => /^Binary Images:/i.test(line.trim())); + if (binaryImagesIndex === -1) return images; + for (const line of lines.slice(binaryImagesIndex + 1)) { + const image = readTextImageLine(line); + if (image) images.push(image); + } + return images; +} + +function readTextImageLine(line: string): AppleImage | null { + const match = line.match( + /^\s*(0x[0-9a-fA-F]+)\s*-\s*(0x[0-9a-fA-F]+)\s+\+?(.+?)\s+<([0-9a-fA-F-]{32,36})>\s+(.+)$/, + ); + if (!match) return null; + const uuid = normalizeUuid(match[4]); + if (!uuid) return null; + const parsedName = readTextImageNameAndArch(match[3]!.trim(), match[5]!.trim()); + return { + name: parsedName.name, + arch: parsedName.arch, + uuid, + base: BigInt(match[1]!), + end: BigInt(match[2]!), + path: match[5]!.trim(), + }; +} + +function readTextImageNameAndArch( + rawName: string, + imagePath: string, +): { name: string; arch?: string } { + const tokens = rawName.split(/\s+/); + const maybeArch = tokens.at(-1); + if (maybeArch && TEXT_IMAGE_ARCH_RE.test(maybeArch)) { + return { name: tokens.slice(0, -1).join(' ').trim(), arch: maybeArch }; + } + const executableName = path.basename(imagePath); + return { name: rawName.startsWith(executableName) ? executableName : rawName }; +} + +function readTextFrameMatches(lines: string[], images: AppleImage[]): TextFrameMatch[] { + const matches: TextFrameMatch[] = []; + let crashedStackThreadIndex: number | undefined; + for (const [lineIndex, line] of lines.entries()) { + const heading = line.match(/^Thread\s+(\d+)\s+Crashed:/); + if (heading) crashedStackThreadIndex = Number(heading[1]); + else if (line.trim().length === 0 || /^Thread\s+\d+/.test(line)) { + crashedStackThreadIndex = undefined; + } + const frame = readTextFrameLine(line, images); + const indexMatch = line.match(/^\s*(\d+)/); + if (frame && indexMatch) { + matches.push({ + ...frame, + frameIndex: Number(indexMatch[1]), + lineIndex, + threadIndex: crashedStackThreadIndex, + }); + } + } + return matches; +} + +function readTextFrameLine(line: string, images: AppleImage[]): SymbolicatedAddress | null { + const match = line.match(/^\s*\d+\s+(.+?)\s+(0x[0-9a-fA-F]+)\b/); + if (!match) return null; + const imageName = match[1]!.trim(); + const address = BigInt(match[2]!); + const image = findTextFrameImage(images, imageName, address); + if (!image) return null; + return { image, address }; +} + +function findTextFrameImage( + images: AppleImage[], + imageName: string, + address: bigint, +): AppleImage | undefined { + const matches = images.filter((candidate) => candidate.name === imageName); + return matches.find((candidate) => imageContainsAddress(candidate, address)) ?? single(matches); +} + +function imageContainsAddress(image: AppleImage, address: bigint): boolean { + return image.end !== undefined && image.base <= address && address <= image.end; +} diff --git a/src/debug-symbols/dsym.ts b/src/debug-symbols/dsym.ts new file mode 100644 index 000000000..26dd9c7d1 --- /dev/null +++ b/src/debug-symbols/dsym.ts @@ -0,0 +1,159 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { AppleImage, DsymMatch, DsymSlice } from './types.ts'; +import { normalizeUuid, unique } from './utils.ts'; +import { runCmd } from '../utils/exec.ts'; +import { AppError } from '../utils/errors.ts'; + +const MAX_SEARCH_ENTRIES = 10_000; +const MAX_DSYM_CANDIDATES = 200; +const UUID_DETAIL_SAMPLE_LIMIT = 5; + +export async function readDsymPaths(options: { + cwd: string; + dsym?: string; + searchPath?: string; +}): Promise { + if (options.dsym && options.searchPath) { + return [ + resolvePath(options.cwd, options.dsym), + ...(await findDsymBundles(resolvePath(options.cwd, options.searchPath))), + ]; + } + if (options.dsym) return [resolvePath(options.cwd, options.dsym)]; + if (options.searchPath) + return await findDsymBundles(resolvePath(options.cwd, options.searchPath)); + return []; +} + +async function findDsymBundles(root: string): Promise { + const found: string[] = []; + let visited = 0; + async function walk(current: string): Promise { + if (found.length >= MAX_DSYM_CANDIDATES) return; + visited += 1; + if (visited > MAX_SEARCH_ENTRIES) { + throw new AppError('COMMAND_FAILED', 'debug symbols search-path scan exceeded bounds.', { + searchPath: root, + maxEntries: MAX_SEARCH_ENTRIES, + hint: 'Pass --dsym directly or narrow --search-path to the build products directory.', + }); + } + const stat = await readSearchPathStat(current, root); + if (!stat.isDirectory()) { + if (current === root) throwInvalidSearchPathDirectory(root); + return; + } + if (current.endsWith('.dSYM')) { + found.push(current); + return; + } + const entries = await fs.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + await walk(path.join(current, entry.name)); + } + } + await walk(root); + return found; +} + +async function readSearchPathStat( + current: string, + root: string, +): Promise>> { + try { + return await fs.stat(current); + } catch { + throw new AppError('INVALID_ARGS', `debug symbols search path does not exist: ${root}`, { + hint: 'Pass an existing build products directory to --search-path, or pass --dsym directly.', + }); + } +} + +function throwInvalidSearchPathDirectory(root: string): never { + throw new AppError('INVALID_ARGS', `debug symbols search path is not a directory: ${root}`, { + hint: 'Pass an existing build products directory to --search-path, or pass --dsym directly.', + }); +} + +export async function readDsymSlices(dsymPaths: string[], dwarfdump: string): Promise { + const sliceGroups = await Promise.all( + unique(dsymPaths).map((dsymPath) => readDsymBundleSlices(dsymPath, dwarfdump)), + ); + const slices = sliceGroups.flat(); + if (slices.length === 0) { + throw new AppError('COMMAND_FAILED', 'No UUIDs found in dSYM bundle.', { + hint: 'Verify the path points to a built .dSYM bundle with DWARF contents.', + }); + } + return slices; +} + +async function readDsymBundleSlices(dsymPath: string, dwarfdump: string): Promise { + await assertDsymBundlePath(dsymPath); + const result = await runCmd(dwarfdump, ['--uuid', dsymPath], { + timeoutMs: 15_000, + allowFailure: true, + }); + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', `Failed to inspect dSYM UUIDs: ${dsymPath}`, { + stderr: result.stderr, + hint: 'Verify the dSYM bundle is valid and readable.', + }); + } + return parseDwarfdumpUuidOutput(dsymPath, result.stdout); +} + +async function assertDsymBundlePath(dsymPath: string): Promise { + const stat = await fs.stat(dsymPath).catch(() => null); + if (stat?.isDirectory() && dsymPath.endsWith('.dSYM')) return; + throw new AppError('INVALID_ARGS', `Not a .dSYM bundle: ${dsymPath}`, { + hint: 'Pass the .dSYM bundle path, not the DWARF executable inside it.', + }); +} + +function parseDwarfdumpUuidOutput(dsymPath: string, output: string): DsymSlice[] { + return output.split('\n').flatMap((line) => { + const match = line.match(/^UUID:\s+([0-9a-fA-F-]{32,36})\s+\(([^)]+)\)\s+(.+)$/); + const uuid = normalizeUuid(match?.[1]); + return match && uuid ? [{ dsymPath, uuid, arch: match[2], binaryPath: match[3]!.trim() }] : []; + }); +} + +export function matchImagesToDsyms( + images: AppleImage[], + dsymSlices: DsymSlice[], + explicitDsym: boolean, +): Map { + const matched = new Map(); + for (const image of images) { + const dsym = dsymSlices.find( + (candidate) => + candidate.uuid === image.uuid && + (image.arch === undefined || candidate.arch === undefined || candidate.arch === image.arch), + ); + if (dsym) matched.set(image.uuid, { image, dsym }); + } + if (matched.size > 0) return matched; + + const artifactUuids = unique(images.map((image) => image.uuid)); + const dsymUuids = unique(dsymSlices.map((slice) => slice.uuid)); + throw new AppError( + 'COMMAND_FAILED', + explicitDsym + ? 'dSYM UUID does not match any Apple image in the crash artifact.' + : 'No matching dSYM UUID found under search path.', + { + artifactUuidCount: artifactUuids.length, + artifactUuidSample: artifactUuids.slice(0, UUID_DETAIL_SAMPLE_LIMIT), + dsymUuidCount: dsymUuids.length, + dsymUuidSample: dsymUuids.slice(0, UUID_DETAIL_SAMPLE_LIMIT), + hint: 'Use dwarfdump --uuid and compare it with the crash Binary Images or usedImages UUID, then pass the matching dSYM/search path.', + }, + ); +} + +function resolvePath(cwd: string, value: string): string { + return path.resolve(cwd, value); +} diff --git a/src/debug-symbols/report.ts b/src/debug-symbols/report.ts new file mode 100644 index 000000000..2f6b81931 --- /dev/null +++ b/src/debug-symbols/report.ts @@ -0,0 +1,236 @@ +import type { + CrashArtifact, + DebugSymbolsCrashFrame, + DebugSymbolsCrashSummary, + IpsDocument, + IpsFrameMatch, + SymbolicatedAddress, + TextFrameMatch, +} from './types.ts'; +import { + addressKey, + compactJoin, + firstString, + hex, + readNumber, + readRecord, + readString, +} from './utils.ts'; +import { readJsonRecord } from './crash-artifact.ts'; + +const MAX_CRASH_SUMMARY_FRAMES = 5; +const MAX_CRASH_FINDINGS = 3; + +export function summarizeCrashArtifact( + artifact: CrashArtifact, + addressMap: Map, +): DebugSymbolsCrashSummary { + return artifact.format === 'ips' + ? summarizeIpsCrash(artifact.document, artifact.frameMatches, addressMap) + : summarizeTextCrash(artifact.lines, artifact.frameMatches, addressMap); +} + +function summarizeIpsCrash( + document: IpsDocument, + frameMatches: IpsFrameMatch[], + addressMap: Map, +): DebugSymbolsCrashSummary { + const crashedThread = readIpsCrashedThread(document.payload); + const summary: DebugSymbolsCrashSummary = { + format: 'ips', + ...readIpsCrashMetadata(document), + crashedThread, + topFrames: summarizeIpsFrames(frameMatches, crashedThread, addressMap), + findings: [], + }; + return { ...summary, findings: crashFindings(summary) }; +} + +function readIpsCrashMetadata( + document: IpsDocument, +): Omit { + const payload = document.payload; + const header = readIpsHeader(document.header); + return { + appName: readIpsAppName(payload, header), + bundleId: readIpsBundleId(payload, header), + version: readIpsVersion(payload, header), + incident: readIpsIncident(payload, header), + timestamp: readIpsTimestamp(payload, header), + exceptionType: readIpsExceptionType(payload.exception), + exceptionCodes: readIpsExceptionCodes(payload.exception), + terminationReason: readIpsTerminationReason(payload.termination), + }; +} + +function readIpsAppName( + payload: Record, + header: Record | null, +): string | undefined { + return firstString(payload.procName, header?.app_name, header?.name); +} + +function readIpsBundleId( + payload: Record, + header: Record | null, +): string | undefined { + return firstString(readRecord(payload.bundleInfo)?.CFBundleIdentifier, header?.bundleID); +} + +function readIpsVersion( + payload: Record, + header: Record | null, +): string | undefined { + return firstString( + readRecord(payload.bundleInfo)?.CFBundleShortVersionString, + header?.app_version, + ); +} + +function readIpsIncident( + payload: Record, + header: Record | null, +): string | undefined { + return firstString(payload.incident, header?.incident_id); +} + +function readIpsTimestamp( + payload: Record, + header: Record | null, +): string | undefined { + return firstString(payload.captureTime, header?.timestamp); +} + +function readIpsExceptionType(exception: unknown): string | undefined { + return readString(readRecord(exception)?.type); +} + +function readIpsHeader(header: string | undefined): Record | null { + return header ? readJsonRecord(header) : null; +} + +function readIpsCrashedThread(payload: Record): number | undefined { + const faultingThread = readNumber(payload.faultingThread); + if (faultingThread !== undefined) return faultingThread; + const threads = Array.isArray(payload.threads) ? payload.threads : []; + const triggeredIndex = threads.findIndex((thread) => readRecord(thread)?.triggered === true); + return triggeredIndex === -1 ? undefined : triggeredIndex; +} + +function readIpsExceptionCodes(exception: unknown): string | undefined { + const record = readRecord(exception); + if (!record) return undefined; + return firstString(record.codes, record.rawCodes); +} + +function readIpsTerminationReason(termination: unknown): string | undefined { + const record = readRecord(termination); + if (!record) return undefined; + return compactJoin([ + readString(record.namespace), + readString(record.code), + readString(record.reason), + ]); +} + +function summarizeIpsFrames( + frameMatches: IpsFrameMatch[], + crashedThread: number | undefined, + addressMap: Map, +): DebugSymbolsCrashFrame[] { + return frameMatches + .filter((match) => crashedThread === undefined || match.threadIndex === crashedThread) + .slice(0, MAX_CRASH_SUMMARY_FRAMES) + .map((match) => crashFrameSummary(match.frameIndex, match.image, match.address, addressMap)); +} + +function summarizeTextCrash( + lines: string[], + frameMatches: TextFrameMatch[], + addressMap: Map, +): DebugSymbolsCrashSummary { + const crashedThread = readTextCrashedThread(lines); + const summary: DebugSymbolsCrashSummary = { + format: 'text', + appName: readTextProcessName(lines), + bundleId: readTextField(lines, 'Identifier'), + version: readTextField(lines, 'Version'), + incident: readTextField(lines, 'Incident Identifier'), + timestamp: readTextField(lines, 'Date/Time'), + exceptionType: readTextField(lines, 'Exception Type'), + exceptionCodes: readTextField(lines, 'Exception Codes'), + terminationReason: readTextField(lines, 'Termination Reason'), + crashedThread, + topFrames: summarizeTextFrames(frameMatches, crashedThread, addressMap), + findings: [], + }; + return { ...summary, findings: crashFindings(summary) }; +} + +function readTextProcessName(lines: string[]): string | undefined { + const process = readTextField(lines, 'Process'); + return process?.replace(/\s+\[\d+\]$/, ''); +} + +function readTextField(lines: string[], label: string): string | undefined { + const prefix = `${label}:`; + const line = lines.find((candidate) => candidate.trimStart().startsWith(prefix)); + return line ? line.slice(line.indexOf(':') + 1).trim() || undefined : undefined; +} + +function readTextCrashedThread(lines: string[]): number | undefined { + const triggered = readTextField(lines, 'Triggered by Thread'); + const triggeredThread = triggered ? Number.parseInt(triggered, 10) : Number.NaN; + if (Number.isSafeInteger(triggeredThread)) return triggeredThread; + for (const line of lines) { + const match = line.match(/^Thread\s+(\d+)\s+Crashed:/); + if (match) return Number(match[1]); + } + return undefined; +} + +function summarizeTextFrames( + frameMatches: TextFrameMatch[], + crashedThread: number | undefined, + addressMap: Map, +): DebugSymbolsCrashFrame[] { + if (crashedThread === undefined) return []; + return frameMatches + .filter((match) => match.threadIndex === crashedThread) + .slice(0, MAX_CRASH_SUMMARY_FRAMES) + .map((match) => crashFrameSummary(match.frameIndex, match.image, match.address, addressMap)); +} + +function crashFrameSummary( + index: number, + image: { name: string; uuid: string }, + address: bigint, + addressMap: Map, +): DebugSymbolsCrashFrame { + return { + index, + image: image.name, + address: hex(address), + symbol: addressMap.get(addressKey(image, address))?.text, + }; +} + +function crashFindings(summary: DebugSymbolsCrashSummary): string[] { + return [ + firstSymbolicatedFrameFinding(summary), + summary.exceptionType ? `Exception: ${summary.exceptionType}` : undefined, + summary.terminationReason ? `Termination: ${summary.terminationReason}` : undefined, + ] + .filter((finding): finding is string => Boolean(finding)) + .slice(0, MAX_CRASH_FINDINGS); +} + +function firstSymbolicatedFrameFinding(summary: DebugSymbolsCrashSummary): string | undefined { + const frame = summary.topFrames.find((candidate) => candidate.symbol); + if (frame) { + return `Start with ${frame.symbol} in ${frame.image}; it is the first symbolicated frame captured on the crashed thread.`; + } + return summary.topFrames.length > 0 + ? 'No symbolicated frame was found on the crashed thread; verify matching dSYMs for the top images.' + : undefined; +} diff --git a/src/debug-symbols/symbolication.ts b/src/debug-symbols/symbolication.ts new file mode 100644 index 000000000..e41161067 --- /dev/null +++ b/src/debug-symbols/symbolication.ts @@ -0,0 +1,125 @@ +import type { DsymMatch, SymbolicatedAddress, SymbolicationGroup } from './types.ts'; +import { addressKey, hex, unique } from './utils.ts'; +import { runCmd } from '../utils/exec.ts'; +import { AppError } from '../utils/errors.ts'; + +export async function symbolicateAddresses( + addresses: SymbolicatedAddress[], + matched: Map, + atos: string, +): Promise> { + const addressMap = new Map(); + for (const group of groupSymbolicationAddresses(addresses, matched).values()) { + for (const entry of await runAtosForGroup(atos, group)) { + addressMap.set(addressKey(entry.image, entry.address), entry); + } + } + return addressMap; +} + +function groupSymbolicationAddresses( + addresses: SymbolicatedAddress[], + matched: Map, +): Map { + const groups = new Map(); + for (const frame of addresses) { + const match = matched.get(frame.image.uuid); + if (!match) continue; + const key = `${frame.image.uuid}:${match.dsym.binaryPath}`; + const group = groups.get(key) ?? { ...match, addresses: [] }; + group.addresses.push(frame.address); + groups.set(key, group); + } + return groups; +} + +async function runAtosForGroup( + atos: string, + group: SymbolicationGroup, +): Promise { + const addresses = unique(group.addresses.map(hex)); + const result = await runCmd(atos, atosArgs(group, addresses), { + timeoutMs: 30_000, + allowFailure: true, + }); + if (result.exitCode !== 0) throwAtosFailure(result.stderr); + return mapAtosOutputToAddresses(group.image, addresses, result.stdout); +} + +function atosArgs(group: SymbolicationGroup, addresses: string[]): string[] { + return [ + '-arch', + group.image.arch ?? group.dsym.arch ?? 'arm64', + '-o', + group.dsym.binaryPath, + '-l', + hex(group.image.base), + ...addresses, + ]; +} + +function mapAtosOutputToAddresses( + image: SymbolicatedAddress['image'], + addresses: string[], + output: string, +): SymbolicatedAddress[] { + const symbols = splitAtosOutput(output); + return addresses.map((rawAddress, index) => { + const text = symbols[index]?.trim(); + return { + image, + address: BigInt(rawAddress), + text: isSymbolicatedAtosOutput(text, rawAddress) ? text : undefined, + }; + }); +} + +function splitAtosOutput(output: string): string[] { + const lines = output.split(/\r?\n/); + if (lines.at(-1) === '') lines.pop(); + return lines; +} + +function isSymbolicatedAtosOutput(text: string | undefined, rawAddress: string): text is string { + if (!text) return false; + const normalized = text.trim().toLowerCase(); + if (!normalized || normalized === '??') return false; + if (normalized === rawAddress.toLowerCase()) return false; + return !normalized.startsWith('0x'); +} + +function throwAtosFailure(stderr: string): never { + throw new AppError('COMMAND_FAILED', 'atos failed while symbolicating crash frames.', { + stderr, + hint: 'Verify the crash artifact and dSYM were produced from the same build and architecture.', + }); +} + +export async function resolveAppleTools(): Promise<{ dwarfdump: string; atos: string }> { + return { + dwarfdump: await resolveAppleTool('dwarfdump'), + atos: await resolveAppleTool('atos'), + }; +} + +async function resolveAppleTool(name: 'dwarfdump' | 'atos'): Promise { + try { + const result = await runCmd('xcrun', ['--find', name], { + timeoutMs: 5_000, + allowFailure: true, + }); + const toolPath = result.stdout.trim(); + if (result.exitCode === 0 && toolPath.length > 0) return toolPath; + } catch { + // Fall through to the normalized TOOL_MISSING error below. + } + throw new AppError('TOOL_MISSING', `Apple symbolication tool not found: ${name}`, { + hint: 'Install Xcode Command Line Tools and verify xcrun --find dwarfdump and xcrun --find atos succeed.', + }); +} + +export function parseAtosSymbol(value: string): { symbol: string; location?: number } { + const match = value.match(/^(.*) \+ (\d+)$/); + if (!match) return { symbol: value }; + return { symbol: match[1]!, location: Number(match[2]) }; +} diff --git a/src/debug-symbols/types.ts b/src/debug-symbols/types.ts new file mode 100644 index 000000000..672478937 --- /dev/null +++ b/src/debug-symbols/types.ts @@ -0,0 +1,118 @@ +export type DebugSymbolsOptions = { + action?: 'symbols'; + artifact: string; + dsym?: string; + searchPath?: string; + out?: string; + cwd?: string; +}; + +export type DebugSymbolsImage = { + name: string; + uuid: string; + arch?: string; + dsymPath: string; + binaryPath: string; +}; + +export type DebugSymbolsCrashFrame = { + index: number; + image: string; + address: string; + symbol?: string; +}; + +export type DebugSymbolsCrashSummary = { + format: 'ips' | 'text'; + appName?: string; + bundleId?: string; + version?: string; + incident?: string; + timestamp?: string; + exceptionType?: string; + exceptionCodes?: string; + terminationReason?: string; + crashedThread?: number; + topFrames: DebugSymbolsCrashFrame[]; + findings: string[]; +}; + +export type DebugSymbolsResult = { + kind: 'debugSymbols'; + platform: 'apple'; + artifactPath: string; + outPath: string; + crash: DebugSymbolsCrashSummary; + matchedImages: DebugSymbolsImage[]; + symbolicatedFrames: number; + skippedImages: number; + warnings?: string[]; + message: string; +}; + +export type AppleImage = { + index?: number; + name: string; + uuid: string; + arch?: string; + base: bigint; + end?: bigint; + path?: string; +}; + +export type DsymSlice = { + dsymPath: string; + uuid: string; + arch?: string; + binaryPath: string; +}; + +export type SymbolicatedAddress = { + image: AppleImage; + address: bigint; + text?: string; +}; + +export type IpsDocument = { + header?: string; + payload: Record; +}; + +export type IpsFrameMatch = SymbolicatedAddress & { + frame: Record; + frameIndex: number; + threadIndex: number; +}; + +export type TextFrameMatch = SymbolicatedAddress & { + frameIndex: number; + lineIndex: number; + threadIndex?: number; +}; + +export type CrashArtifact = + | { + format: 'ips'; + images: AppleImage[]; + addresses: SymbolicatedAddress[]; + document: IpsDocument; + frameMatches: IpsFrameMatch[]; + write: (addressMap: Map) => string; + } + | { + format: 'text'; + images: AppleImage[]; + addresses: SymbolicatedAddress[]; + lines: string[]; + frameMatches: TextFrameMatch[]; + write: (addressMap: Map) => string; + }; + +export type DsymMatch = { + image: AppleImage; + dsym: DsymSlice; +}; + +export type SymbolicationGroup = DsymMatch & { + addresses: bigint[]; +}; diff --git a/src/debug-symbols/utils.ts b/src/debug-symbols/utils.ts new file mode 100644 index 000000000..066c6662c --- /dev/null +++ b/src/debug-symbols/utils.ts @@ -0,0 +1,94 @@ +import { AppError } from '../utils/errors.ts'; + +const UUID_RE = /^[0-9a-fA-F-]{32,36}$/; + +export function normalizeUuid(value: string | undefined): string | undefined { + if (!value || !UUID_RE.test(value)) return undefined; + return value.replaceAll('-', '').toUpperCase(); +} + +export function addressKey(image: { uuid: string }, address: bigint): string { + return `${image.uuid}:${hex(address)}`; +} + +export function hex(value: bigint): string { + return `0x${value.toString(16)}`; +} + +export function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +export function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + const stringValue = readString(value); + if (stringValue) return stringValue; + } + return undefined; +} + +export function compactJoin(values: (string | undefined)[]): string | undefined { + const compact = values.filter((value): value is string => Boolean(value)); + return compact.length > 0 ? compact.join(' ') : undefined; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function readRecord(value: unknown): Record | undefined { + return isRecord(value) ? value : undefined; +} + +export function readNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isSafeInteger(value)) return value; + if (typeof value === 'string') { + const parsed = value.startsWith('0x') ? Number.parseInt(value, 16) : Number(value); + return Number.isSafeInteger(parsed) ? parsed : undefined; + } + return undefined; +} + +export function readIntegerNumberField( + record: Record, + key: string, + context: string, +): number | undefined { + if (!Object.hasOwn(record, key)) return undefined; + const value = readNumber(record[key]); + if (value !== undefined) return value; + throwInvalidNumericField(context, key); +} + +export function readBigIntField( + record: Record, + key: string, + context: string, +): bigint | undefined { + if (!Object.hasOwn(record, key)) return undefined; + const value = readBigInt(record[key]); + if (value !== undefined) return value; + throwInvalidNumericField(context, key); +} + +function readBigInt(value: unknown): bigint | undefined { + if (typeof value === 'bigint') return value; + if (typeof value === 'number' && Number.isSafeInteger(value)) return BigInt(value); + if (typeof value !== 'string') return undefined; + if (!/^(?:0x[0-9a-fA-F]+|\d+)$/.test(value)) return undefined; + return BigInt(value); +} + +function throwInvalidNumericField(context: string, key: string): never { + throw new AppError('INVALID_ARGS', `Invalid ${context} numeric field: ${key}`, { + hint: 'Crash artifact numeric fields must be integer numbers or integer numeric strings.', + }); +} + +export function single(values: T[]): T | undefined { + return values.length === 1 ? values[0] : undefined; +} + +export function unique(values: T[]): T[] { + return [...new Set(values)]; +} From b663421d0a715e7158fb37af248d5f901aaf2b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 14:00:31 +0200 Subject: [PATCH 2/4] refactor: simplify debug symbols module dependencies --- src/debug-symbols/crash-artifact.ts | 12 ++---------- src/debug-symbols/report.ts | 2 +- src/debug-symbols/symbolication.ts | 6 ------ src/debug-symbols/utils.ts | 15 +++++++++++++++ 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/debug-symbols/crash-artifact.ts b/src/debug-symbols/crash-artifact.ts index b9a6c6856..7b4b6f2f5 100644 --- a/src/debug-symbols/crash-artifact.ts +++ b/src/debug-symbols/crash-artifact.ts @@ -11,12 +11,13 @@ import { addressKey, isRecord, normalizeUuid, + parseAtosSymbol, + readJsonRecord, readBigIntField, readIntegerNumberField, readString, single, } from './utils.ts'; -import { parseAtosSymbol } from './symbolication.ts'; const TEXT_IMAGE_ARCH_RE = /^(?:arm64e?|arm64_32|x86_64|armv7[sk]?|i386)$/; @@ -54,15 +55,6 @@ function readIpsDocument(text: string): IpsDocument | null { return payload ? { header, payload } : null; } -export function readJsonRecord(text: string): Record | null { - try { - const value = JSON.parse(text); - return value && typeof value === 'object' ? (value as Record) : null; - } catch { - return null; - } -} - function readIpsFrameMatches(rawThreads: unknown[], images: AppleImage[]): IpsFrameMatch[] { const imageByIndex = new Map(images.map((image) => [image.index, image])); return rawThreads.flatMap((thread, threadIndex) => diff --git a/src/debug-symbols/report.ts b/src/debug-symbols/report.ts index 2f6b81931..1827552e9 100644 --- a/src/debug-symbols/report.ts +++ b/src/debug-symbols/report.ts @@ -13,10 +13,10 @@ import { firstString, hex, readNumber, + readJsonRecord, readRecord, readString, } from './utils.ts'; -import { readJsonRecord } from './crash-artifact.ts'; const MAX_CRASH_SUMMARY_FRAMES = 5; const MAX_CRASH_FINDINGS = 3; diff --git a/src/debug-symbols/symbolication.ts b/src/debug-symbols/symbolication.ts index e41161067..d3b2ab330 100644 --- a/src/debug-symbols/symbolication.ts +++ b/src/debug-symbols/symbolication.ts @@ -117,9 +117,3 @@ async function resolveAppleTool(name: 'dwarfdump' | 'atos'): Promise { hint: 'Install Xcode Command Line Tools and verify xcrun --find dwarfdump and xcrun --find atos succeed.', }); } - -export function parseAtosSymbol(value: string): { symbol: string; location?: number } { - const match = value.match(/^(.*) \+ (\d+)$/); - if (!match) return { symbol: value }; - return { symbol: match[1]!, location: Number(match[2]) }; -} diff --git a/src/debug-symbols/utils.ts b/src/debug-symbols/utils.ts index 066c6662c..21e4de8b9 100644 --- a/src/debug-symbols/utils.ts +++ b/src/debug-symbols/utils.ts @@ -40,6 +40,15 @@ export function readRecord(value: unknown): Record | undefined return isRecord(value) ? value : undefined; } +export function readJsonRecord(text: string): Record | null { + try { + const value = JSON.parse(text); + return isRecord(value) ? value : null; + } catch { + return null; + } +} + export function readNumber(value: unknown): number | undefined { if (typeof value === 'number' && Number.isSafeInteger(value)) return value; if (typeof value === 'string') { @@ -92,3 +101,9 @@ export function single(values: T[]): T | undefined { export function unique(values: T[]): T[] { return [...new Set(values)]; } + +export function parseAtosSymbol(value: string): { symbol: string; location?: number } { + const match = value.match(/^(.*) \+ (\d+)$/); + if (!match) return { symbol: value }; + return { symbol: match[1]!, location: Number(match[2]) }; +} From d997d5a68476198b04d9522e02529343aa7a5326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 14:14:16 +0200 Subject: [PATCH 3/4] refactor: align debug symbols with command families --- src/__tests__/debug-symbols.test.ts | 2 +- src/client-types.ts | 10 +- src/client.ts | 2 +- src/commands/cli-grammar/registry.ts | 4 + src/commands/cli-output.ts | 4 + src/commands/client-command-facets.ts | 10 + src/commands/command-projection.ts | 2 + src/commands/debugging/index.test.ts | 35 +++ src/commands/debugging/index.ts | 69 +++++ src/commands/debugging/output.ts | 36 +++ .../debugging/runtime}/debug-symbols.ts | 2 +- .../runtime}/debug-symbols/crash-artifact.ts | 0 .../debugging/runtime}/debug-symbols/dsym.ts | 4 +- .../runtime}/debug-symbols/report.ts | 0 .../runtime}/debug-symbols/symbolication.ts | 4 +- .../debugging/runtime}/debug-symbols/types.ts | 0 .../debugging/runtime}/debug-symbols/utils.ts | 2 +- src/commands/observability/index.test.ts | 35 +-- src/commands/observability/index.ts | 241 +-------------- src/commands/observability/output.ts | 281 +----------------- src/commands/perf/index.test.ts | 56 ++++ src/commands/perf/index.ts | 196 ++++++++++++ src/commands/perf/output.ts | 253 ++++++++++++++++ .../perf-command-contract.ts | 0 src/utils/cli-command-overrides.ts | 4 + 25 files changed, 691 insertions(+), 561 deletions(-) create mode 100644 src/commands/debugging/index.test.ts create mode 100644 src/commands/debugging/index.ts create mode 100644 src/commands/debugging/output.ts rename src/{ => commands/debugging/runtime}/debug-symbols.ts (98%) rename src/{ => commands/debugging/runtime}/debug-symbols/crash-artifact.ts (100%) rename src/{ => commands/debugging/runtime}/debug-symbols/dsym.ts (98%) rename src/{ => commands/debugging/runtime}/debug-symbols/report.ts (100%) rename src/{ => commands/debugging/runtime}/debug-symbols/symbolication.ts (97%) rename src/{ => commands/debugging/runtime}/debug-symbols/types.ts (100%) rename src/{ => commands/debugging/runtime}/debug-symbols/utils.ts (98%) create mode 100644 src/commands/perf/index.test.ts create mode 100644 src/commands/perf/index.ts create mode 100644 src/commands/perf/output.ts rename src/commands/{observability => perf}/perf-command-contract.ts (100%) diff --git a/src/__tests__/debug-symbols.test.ts b/src/__tests__/debug-symbols.test.ts index eadf91f6a..24322b3f8 100644 --- a/src/__tests__/debug-symbols.test.ts +++ b/src/__tests__/debug-symbols.test.ts @@ -3,7 +3,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { test } from 'vitest'; -import { symbolicateCrashArtifact } from '../debug-symbols.ts'; +import { symbolicateCrashArtifact } from '../commands/debugging/runtime/debug-symbols.ts'; import { AppError } from '../utils/errors.ts'; import { withCommandExecutorOverride } from '../utils/exec.ts'; diff --git a/src/client-types.ts b/src/client-types.ts index e6fd9cfd1..ec1463d15 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -43,13 +43,19 @@ import type { ScreenshotRequestFlags } from './contracts/screenshot.ts'; import type { PerfAction, PerfArea, PerfKind, PerfSubject } from './contracts/perf.ts'; import type { DaemonBatchStep } from './core/batch.ts'; import type { AlertAction, AlertInfo } from './alert-contract.ts'; -import type { DebugSymbolsOptions, DebugSymbolsResult } from './debug-symbols.ts'; +import type { + DebugSymbolsOptions, + DebugSymbolsResult, +} from './commands/debugging/runtime/debug-symbols.ts'; export type { FindLocator } from './utils/finders.ts'; export type { CompanionTunnelScope, MetroBridgeScope } from './client-companion-tunnel-contract.ts'; export type { AppsFilter } from './contracts/app-inventory.ts'; export type { AlertAction, AlertInfo, AlertPlatform, AlertSource } from './alert-contract.ts'; -export type { DebugSymbolsOptions, DebugSymbolsResult } from './debug-symbols.ts'; +export type { + DebugSymbolsOptions, + DebugSymbolsResult, +} from './commands/debugging/runtime/debug-symbols.ts'; export type AgentDeviceDaemonTransport = ( req: Omit, diff --git a/src/client.ts b/src/client.ts index 759cc4204..7ffb2100e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,7 +1,7 @@ import { sendToDaemon } from './daemon-client.ts'; import { prepareMetroRuntime, reloadMetro } from './client-metro.ts'; import { resolveDaemonPaths } from './daemon/config.ts'; -import { symbolicateCrashArtifact } from './debug-symbols.ts'; +import { symbolicateCrashArtifact } from './commands/debugging/runtime/debug-symbols.ts'; import { INTERNAL_COMMANDS } from './command-catalog.ts'; import { prepareDaemonCommandRequest, diff --git a/src/commands/cli-grammar/registry.ts b/src/commands/cli-grammar/registry.ts index 553daf656..7ff476ef2 100644 --- a/src/commands/cli-grammar/registry.ts +++ b/src/commands/cli-grammar/registry.ts @@ -1,6 +1,7 @@ import type { CliFlags } from '../../utils/cli-flags.ts'; import { batchCliReaders } from '../batch/index.ts'; import { captureCliReaders } from '../capture/index.ts'; +import { debuggingCliReaders } from '../debugging/index.ts'; import type { CliReader } from './types.ts'; import type { CommandName } from '../command-metadata.ts'; import { @@ -11,6 +12,7 @@ import { import { appCliReaders } from '../management/index.ts'; import { metroCliReaders } from '../metro/index.ts'; import { observabilityCliReaders } from '../observability/index.ts'; +import { perfCliReaders } from '../perf/index.ts'; import { reactNativeCliReaders } from '../react-native/index.ts'; import { recordingCliReaders } from '../recording/index.ts'; import { replayCliReaders } from '../replay/index.ts'; @@ -23,6 +25,8 @@ const cliReaders = { ...gestureCliReaders, ...interactionSelectorCliReaders, ...observabilityCliReaders, + ...perfCliReaders, + ...debuggingCliReaders, ...reactNativeCliReaders, ...recordingCliReaders, ...replayCliReaders, diff --git a/src/commands/cli-output.ts b/src/commands/cli-output.ts index 1ea87be1c..a4aa58a31 100644 --- a/src/commands/cli-output.ts +++ b/src/commands/cli-output.ts @@ -1,10 +1,12 @@ import { batchCliOutputFormatters } from './batch/output.ts'; import { captureCliOutputFormatters } from './capture/output.ts'; import type { CliOutput } from './command-contract.ts'; +import { debuggingCliOutputFormatters } from './debugging/output.ts'; import { interactionCliOutputFormatters } from './interaction/output.ts'; import { managementCliOutputFormatters } from './management/output.ts'; import { metroCliOutputFormatters } from './metro/output.ts'; import { observabilityCliOutputFormatters } from './observability/output.ts'; +import { perfCliOutputFormatters } from './perf/output.ts'; import type { CliOutputFormatter } from './output-common.ts'; import { recordingCliOutputFormatters } from './recording/output.ts'; import { systemCliOutputFormatters } from './system/output.ts'; @@ -16,6 +18,8 @@ const cliOutputFormatters: Partial> = { ...systemCliOutputFormatters, ...interactionCliOutputFormatters, ...observabilityCliOutputFormatters, + ...perfCliOutputFormatters, + ...debuggingCliOutputFormatters, ...batchCliOutputFormatters, ...recordingCliOutputFormatters, ...metroCliOutputFormatters, diff --git a/src/commands/client-command-facets.ts b/src/commands/client-command-facets.ts index a9f3a217f..6dcd05b93 100644 --- a/src/commands/client-command-facets.ts +++ b/src/commands/client-command-facets.ts @@ -1,3 +1,4 @@ +import { debuggingCommandDefinitions, debuggingCommandMetadata } from './debugging/index.ts'; import { captureCommandDefinitions, captureCommandMetadata } from './capture/index.ts'; import { managementCommandDefinitions, managementCommandMetadata } from './management/index.ts'; import { metroCommandDefinition, metroCommandMetadata } from './metro/index.ts'; @@ -5,6 +6,7 @@ import { observabilityCommandDefinitions, observabilityCommandMetadata, } from './observability/index.ts'; +import { perfCommandDefinitions, perfCommandMetadataList } from './perf/index.ts'; import { reactNativeCommandDefinition, reactNativeCommandMetadata } from './react-native/index.ts'; import { recordingCommandDefinitions, recordingCommandMetadata } from './recording/index.ts'; import { replayCommandDefinitions, replayCommandMetadataList } from './replay/index.ts'; @@ -35,6 +37,14 @@ const clientCommandFamilyFacets = [ metadata: observabilityCommandMetadata, definitions: observabilityCommandDefinitions, }, + { + metadata: perfCommandMetadataList, + definitions: perfCommandDefinitions, + }, + { + metadata: debuggingCommandMetadata, + definitions: debuggingCommandDefinitions, + }, { metadata: recordingCommandMetadata, definitions: recordingCommandDefinitions, diff --git a/src/commands/command-projection.ts b/src/commands/command-projection.ts index 391680f95..fc8ce9613 100644 --- a/src/commands/command-projection.ts +++ b/src/commands/command-projection.ts @@ -8,6 +8,7 @@ import { } from './interaction/index.ts'; import { appDaemonWriters } from './management/index.ts'; import { observabilityDaemonWriters } from './observability/index.ts'; +import { perfDaemonWriters } from './perf/index.ts'; import { reactNativeDaemonWriters } from './react-native/index.ts'; import { recordingDaemonWriters } from './recording/index.ts'; import { replayDaemonWriters } from './replay/index.ts'; @@ -20,6 +21,7 @@ const daemonWriters = { ...gestureDaemonWriters, ...selectorDaemonWriters, ...observabilityDaemonWriters, + ...perfDaemonWriters, ...reactNativeDaemonWriters, ...recordingDaemonWriters, ...replayDaemonWriters, diff --git a/src/commands/debugging/index.test.ts b/src/commands/debugging/index.test.ts new file mode 100644 index 000000000..14bdfb1b1 --- /dev/null +++ b/src/commands/debugging/index.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'vitest'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import { debugCliReader, debugCommandDefinition, debugCommandMetadata } from './index.ts'; + +describe('debugging command interface', () => { + test('owns debug public metadata', () => { + expect(debugCommandMetadata.name).toBe('debug'); + expect(debugCommandDefinition.name).toBe('debug'); + }); + + test('reads debug symbols crash artifact inputs', () => { + expect( + debugCliReader(['symbols'], { + artifact: 'crash.ips', + dsym: 'Demo.app.dSYM', + out: 'crash-symbolicated.ips', + } as CliFlags), + ).toEqual({ + action: 'symbols', + artifact: 'crash.ips', + dsym: 'Demo.app.dSYM', + searchPath: undefined, + out: 'crash-symbolicated.ips', + }); + }); + + test('rejects unsupported debug actions', () => { + expect(() => debugCliReader(['live'], {} as CliFlags)).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining('debug supports only symbols'), + }), + ); + }); +}); diff --git a/src/commands/debugging/index.ts b/src/commands/debugging/index.ts new file mode 100644 index 000000000..c5d2e4171 --- /dev/null +++ b/src/commands/debugging/index.ts @@ -0,0 +1,69 @@ +import { AppError } from '../../utils/errors.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { enumField, requiredField, stringField } from '../command-input.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { commonInputFromFlags } from '../cli-grammar/common.ts'; +import type { CliReader } from '../cli-grammar/types.ts'; + +const DEBUG_COMMAND_NAME = 'debug'; +const DEBUG_ACTION_VALUES = ['symbols'] as const; + +const debugCommandDescription = 'Symbolicate crash artifacts with matching debug symbols.'; + +export const debugCommandMetadata = defineFieldCommandMetadata( + DEBUG_COMMAND_NAME, + debugCommandDescription, + { + action: requiredField(enumField(DEBUG_ACTION_VALUES)), + artifact: requiredField(stringField('Apple crash artifact path (.ips, .crash, or .log).')), + dsym: stringField('Path to a matching .dSYM bundle.'), + searchPath: stringField('Directory to scan for matching .dSYM bundles.'), + out: stringField('Output path for the symbolicated artifact.'), + }, +); + +export const debuggingCommandMetadata = [debugCommandMetadata] as const; + +export const debugCommandDefinition = defineExecutableCommand( + debugCommandMetadata, + (client, input) => client.debug.symbols(input), +); + +export const debuggingCommandDefinitions = [debugCommandDefinition] as const; + +const debugCliSchema = { + usageOverride: + 'debug symbols --artifact (--dsym | --search-path ) [--out ]', + listUsageOverride: 'debug symbols --artifact --dsym ', + helpDescription: + 'Symbolicate Apple crash artifacts with matching dSYM UUIDs. This debug namespace is intentionally narrow: use logs for app logs, network for HTTP evidence, perf for performance samples, record/trace for media and traces, and react-devtools for React Native profiles.', + summary: 'Symbolicate Apple crash artifacts', + positionalArgs: ['symbols'], + allowedFlags: ['artifact', 'dsym', 'searchPath', 'out'], +} as const satisfies CommandSchemaOverride; + +export const debuggingCliSchemas = { + [DEBUG_COMMAND_NAME]: debugCliSchema, +} as const satisfies Record; + +export const debugCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readDebugAction(positionals[0]), + artifact: flags.artifact, + dsym: flags.dsym, + searchPath: flags.searchPath, + out: flags.out, +}); + +export const debuggingCliReaders = { + debug: debugCliReader, +} satisfies Record; + +function readDebugAction(value: string | undefined): 'symbols' { + if (value === 'symbols') return value; + throw new AppError( + 'INVALID_ARGS', + 'debug supports only symbols; use logs, network, perf, record, trace, or react-devtools for other diagnostics', + ); +} diff --git a/src/commands/debugging/output.ts b/src/commands/debugging/output.ts new file mode 100644 index 000000000..a51e608a1 --- /dev/null +++ b/src/commands/debugging/output.ts @@ -0,0 +1,36 @@ +import type { DebugSymbolsResult } from '../../client-types.ts'; +import type { CliOutput } from '../command-contract.ts'; +import { resultOutput, type CliOutputFormatter } from '../output-common.ts'; + +function debugSymbolsCliOutput(result: DebugSymbolsResult): CliOutput { + const lines = [result.outPath, result.message]; + lines.push(...formatDebugCrashSummary(result)); + for (const image of result.matchedImages) { + lines.push(`Matched: ${image.name} ${image.uuid}${image.arch ? ` ${image.arch}` : ''}`); + } + for (const warning of result.warnings ?? []) { + lines.push(`Warning: ${warning}`); + } + return { data: result, text: lines.join('\n') }; +} + +export const debuggingCliOutputFormatters = { + debug: resultOutput(debugSymbolsCliOutput), +} as const satisfies Record; + +function formatDebugCrashSummary(result: DebugSymbolsResult): string[] { + const crash = result.crash; + const lines = [ + `Crash: ${crash.appName ?? 'unknown app'}${crash.crashedThread === undefined ? '' : ` thread ${crash.crashedThread}`}`, + ]; + if (crash.bundleId) lines.push(`Bundle: ${crash.bundleId}`); + if (crash.exceptionType) lines.push(`Exception: ${crash.exceptionType}`); + if (crash.terminationReason) lines.push(`Termination: ${crash.terminationReason}`); + for (const frame of crash.topFrames) { + lines.push(`Frame ${frame.index}: ${frame.image} ${frame.symbol ?? frame.address}`); + } + for (const finding of crash.findings) { + lines.push(`Finding: ${finding}`); + } + return lines; +} diff --git a/src/debug-symbols.ts b/src/commands/debugging/runtime/debug-symbols.ts similarity index 98% rename from src/debug-symbols.ts rename to src/commands/debugging/runtime/debug-symbols.ts index a725ce4d0..940176db7 100644 --- a/src/debug-symbols.ts +++ b/src/commands/debugging/runtime/debug-symbols.ts @@ -5,7 +5,7 @@ import { matchImagesToDsyms, readDsymPaths, readDsymSlices } from './debug-symbo import { summarizeCrashArtifact } from './debug-symbols/report.ts'; import { resolveAppleTools, symbolicateAddresses } from './debug-symbols/symbolication.ts'; import type { DebugSymbolsOptions, DebugSymbolsResult } from './debug-symbols/types.ts'; -import { AppError } from './utils/errors.ts'; +import { AppError } from '../../../utils/errors.ts'; export type { DebugSymbolsCrashFrame, diff --git a/src/debug-symbols/crash-artifact.ts b/src/commands/debugging/runtime/debug-symbols/crash-artifact.ts similarity index 100% rename from src/debug-symbols/crash-artifact.ts rename to src/commands/debugging/runtime/debug-symbols/crash-artifact.ts diff --git a/src/debug-symbols/dsym.ts b/src/commands/debugging/runtime/debug-symbols/dsym.ts similarity index 98% rename from src/debug-symbols/dsym.ts rename to src/commands/debugging/runtime/debug-symbols/dsym.ts index 26dd9c7d1..ee40291d0 100644 --- a/src/debug-symbols/dsym.ts +++ b/src/commands/debugging/runtime/debug-symbols/dsym.ts @@ -2,8 +2,8 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import type { AppleImage, DsymMatch, DsymSlice } from './types.ts'; import { normalizeUuid, unique } from './utils.ts'; -import { runCmd } from '../utils/exec.ts'; -import { AppError } from '../utils/errors.ts'; +import { runCmd } from '../../../../utils/exec.ts'; +import { AppError } from '../../../../utils/errors.ts'; const MAX_SEARCH_ENTRIES = 10_000; const MAX_DSYM_CANDIDATES = 200; diff --git a/src/debug-symbols/report.ts b/src/commands/debugging/runtime/debug-symbols/report.ts similarity index 100% rename from src/debug-symbols/report.ts rename to src/commands/debugging/runtime/debug-symbols/report.ts diff --git a/src/debug-symbols/symbolication.ts b/src/commands/debugging/runtime/debug-symbols/symbolication.ts similarity index 97% rename from src/debug-symbols/symbolication.ts rename to src/commands/debugging/runtime/debug-symbols/symbolication.ts index d3b2ab330..eb94d701b 100644 --- a/src/debug-symbols/symbolication.ts +++ b/src/commands/debugging/runtime/debug-symbols/symbolication.ts @@ -1,7 +1,7 @@ import type { DsymMatch, SymbolicatedAddress, SymbolicationGroup } from './types.ts'; import { addressKey, hex, unique } from './utils.ts'; -import { runCmd } from '../utils/exec.ts'; -import { AppError } from '../utils/errors.ts'; +import { runCmd } from '../../../../utils/exec.ts'; +import { AppError } from '../../../../utils/errors.ts'; export async function symbolicateAddresses( addresses: SymbolicatedAddress[], diff --git a/src/debug-symbols/types.ts b/src/commands/debugging/runtime/debug-symbols/types.ts similarity index 100% rename from src/debug-symbols/types.ts rename to src/commands/debugging/runtime/debug-symbols/types.ts diff --git a/src/debug-symbols/utils.ts b/src/commands/debugging/runtime/debug-symbols/utils.ts similarity index 98% rename from src/debug-symbols/utils.ts rename to src/commands/debugging/runtime/debug-symbols/utils.ts index 21e4de8b9..d71103d2d 100644 --- a/src/debug-symbols/utils.ts +++ b/src/commands/debugging/runtime/debug-symbols/utils.ts @@ -1,4 +1,4 @@ -import { AppError } from '../utils/errors.ts'; +import { AppError } from '../../../../utils/errors.ts'; const UUID_RE = /^[0-9a-fA-F-]{32,36}$/; diff --git a/src/commands/observability/index.test.ts b/src/commands/observability/index.test.ts index c01479431..b23682b5d 100644 --- a/src/commands/observability/index.test.ts +++ b/src/commands/observability/index.test.ts @@ -9,10 +9,6 @@ import { networkCommandDefinition, networkCommandMetadata, networkDaemonWriter, - perfCliReader, - perfCommandDefinition, - perfCommandMetadata, - perfDaemonWriter, } from './index.ts'; const NO_FLAGS = {} as CliFlags; @@ -27,41 +23,13 @@ function expectInvalidArgs(fn: () => unknown, messageFragment: string) { } describe('observability command interface', () => { - test('owns perf, logs, and network public metadata', () => { - expect(perfCommandMetadata.name).toBe('perf'); - expect(perfCommandDefinition.name).toBe('perf'); + test('owns logs and network public metadata', () => { expect(logsCommandMetadata.name).toBe('logs'); expect(logsCommandDefinition.name).toBe('logs'); expect(networkCommandMetadata.name).toBe('network'); expect(networkCommandDefinition.name).toBe('network'); }); - test('reads perf area, action, kind, and out flags', () => { - expect( - perfCliReader(['memory', 'snapshot'], { - kind: 'android-hprof', - out: './heap.hprof', - } as CliFlags), - ).toEqual({ - area: 'memory', - action: 'snapshot', - kind: 'android-hprof', - out: './heap.hprof', - }); - }); - - test('treats a single perf action as metrics action', () => { - expect(perfCliReader(['sample'], NO_FLAGS)).toEqual({ - action: 'sample', - kind: undefined, - out: undefined, - }); - expect(perfDaemonWriter({ action: 'sample' })).toMatchObject({ - command: 'perf', - positionals: ['metrics', 'sample'], - }); - }); - test('reads logs action and message', () => { expect(logsCliReader(['mark', 'checkout', 'started'], NO_FLAGS)).toEqual({ action: 'mark', @@ -96,7 +64,6 @@ describe('observability command interface', () => { }); test('rejects invalid observability positionals', () => { - expectInvalidArgs(() => perfCliReader(['memory', 'explode'], NO_FLAGS), 'perf action'); expectInvalidArgs(() => logsCliReader(['explode'], NO_FLAGS), 'logs requires'); expectInvalidArgs(() => networkCliReader(['explode'], NO_FLAGS), 'network requires'); expectInvalidArgs( diff --git a/src/commands/observability/index.ts b/src/commands/observability/index.ts index 0b15c278d..844a3e077 100644 --- a/src/commands/observability/index.ts +++ b/src/commands/observability/index.ts @@ -1,36 +1,12 @@ -import type { LogsOptions, NetworkOptions, PerfOptions } from '../../client-types.ts'; +import type { LogsOptions, NetworkOptions } from '../../client-types.ts'; import { NETWORK_INCLUDE_MODES, type NetworkIncludeMode } from '../../contracts.ts'; import { AppError } from '../../utils/errors.ts'; import { parseStringMember } from '../../utils/string-enum.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; -import { - booleanField, - enumField, - integerField, - requiredField, - stringField, -} from '../command-input.ts'; +import { booleanField, enumField, integerField, stringField } from '../command-input.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import { LOG_ACTION_VALUES, type LogAction } from './log-command-contract.ts'; -import { - isPerfAction, - isPerfArea, - isPerfKind, - isPerfSubject, - PERF_ACTION_ERROR_MESSAGE, - PERF_ACTION_VALUES, - PERF_AREA_ERROR_MESSAGE, - PERF_AREA_VALUES, - PERF_KIND_ERROR_MESSAGE, - PERF_KIND_VALUES, - PERF_SUBJECT_ERROR_MESSAGE, - PERF_SUBJECT_VALUES, - type PerfAction, - type PerfArea, - type PerfKind, - type PerfSubject, -} from './perf-command-contract.ts'; import { commonInputFromFlags, direct, @@ -41,31 +17,12 @@ import { } from '../cli-grammar/common.ts'; import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; -const PERF_COMMAND_NAME = 'perf'; const LOGS_COMMAND_NAME = 'logs'; const NETWORK_COMMAND_NAME = 'network'; -const DEBUG_COMMAND_NAME = 'debug'; const NETWORK_ACTION_VALUES = ['dump', 'log'] as const; -const DEBUG_ACTION_VALUES = ['symbols'] as const; -const perfCommandDescription = 'Show session performance, frame health, and memory diagnostics.'; const logsCommandDescription = 'Manage session app logs.'; const networkCommandDescription = 'Show recent HTTP traffic.'; -const debugCommandDescription = 'Symbolicate crash artifacts with matching debug symbols.'; - -export const perfCommandMetadata = defineFieldCommandMetadata( - PERF_COMMAND_NAME, - perfCommandDescription, - { - area: enumField(PERF_AREA_VALUES), - subject: enumField(PERF_SUBJECT_VALUES), - action: enumField(PERF_ACTION_VALUES), - kind: enumField(PERF_KIND_VALUES), - template: stringField('xctrace template name, for example Time Profiler.'), - out: stringField('Output artifact path.'), - tracePath: stringField('Existing .trace path to report, defaults to the latest session trace.'), - }, -); export const logsCommandMetadata = defineFieldCommandMetadata( LOGS_COMMAND_NAME, @@ -87,28 +44,7 @@ export const networkCommandMetadata = defineFieldCommandMetadata( }, ); -const debugCommandMetadata = defineFieldCommandMetadata( - DEBUG_COMMAND_NAME, - debugCommandDescription, - { - action: requiredField(enumField(DEBUG_ACTION_VALUES)), - artifact: requiredField(stringField('Apple crash artifact path (.ips, .crash, or .log).')), - dsym: stringField('Path to a matching .dSYM bundle.'), - searchPath: stringField('Directory to scan for matching .dSYM bundles.'), - out: stringField('Output path for the symbolicated artifact.'), - }, -); - -export const observabilityCommandMetadata = [ - perfCommandMetadata, - logsCommandMetadata, - networkCommandMetadata, - debugCommandMetadata, -] as const; - -export const perfCommandDefinition = defineExecutableCommand(perfCommandMetadata, (client, input) => - client.observability.perf(input), -); +export const observabilityCommandMetadata = [logsCommandMetadata, networkCommandMetadata] as const; export const logsCommandDefinition = defineExecutableCommand(logsCommandMetadata, (client, input) => client.observability.logs(input), @@ -119,29 +55,11 @@ export const networkCommandDefinition = defineExecutableCommand( (client, input) => client.observability.network(input), ); -const debugCommandDefinition = defineExecutableCommand(debugCommandMetadata, (client, input) => - client.debug.symbols(input), -); - export const observabilityCommandDefinitions = [ - perfCommandDefinition, logsCommandDefinition, networkCommandDefinition, - debugCommandDefinition, ] as const; -const perfCliSchema = { - usageOverride: - 'perf [metrics|frames|memory] [sample|snapshot]\n agent-device perf memory sample --json\n agent-device perf memory snapshot [--kind android-hprof|memgraph] [--out ]\n agent-device perf cpu profile start|stop|report --kind xctrace [--template ] --out \n agent-device perf trace start|stop --kind xctrace [--template ] --out \n agent-device perf cpu profile start|stop|report --kind simpleperf [--out ]\n agent-device perf trace start|stop --kind perfetto [--out ]', - listUsageOverride: - 'perf [metrics|frames|memory] | perf cpu profile start|stop|report | perf trace start|stop', - helpDescription: - 'Show session performance metrics, focused frame/jank health, memory diagnostics artifacts, Apple xctrace artifacts, or Android native Simpleperf/Perfetto artifacts. Bare perf and metrics are aliases for perf metrics. Native perf output is agent evidence: compact state, artifact path, and size only; raw profiles/traces stay on disk.', - summary: 'Show performance metrics or collect native perf artifacts', - positionalArgs: ['area?', 'subjectOrAction?', 'action?'], - allowedFlags: ['kind', 'perfTemplate', 'out'], -} as const satisfies CommandSchemaOverride; - const logsCliSchema = { usageOverride: 'logs path | logs start | logs stop | logs clear [--restart] | logs doctor | logs mark [message...]', @@ -161,33 +79,11 @@ const networkCliSchema = { allowedFlags: ['networkInclude'], } as const satisfies CommandSchemaOverride; -const debugCliSchema = { - usageOverride: - 'debug symbols --artifact (--dsym | --search-path ) [--out ]', - listUsageOverride: 'debug symbols --artifact --dsym ', - helpDescription: - 'Symbolicate Apple crash artifacts with matching dSYM UUIDs. This debug namespace is intentionally narrow: use logs for app logs, network for HTTP evidence, perf for performance samples, record/trace for media and traces, and react-devtools for React Native profiles.', - summary: 'Symbolicate Apple crash artifacts', - positionalArgs: ['symbols'], - allowedFlags: ['artifact', 'dsym', 'searchPath', 'out'], -} as const satisfies CommandSchemaOverride; - export const observabilityCliSchemas = { - [PERF_COMMAND_NAME]: perfCliSchema, [LOGS_COMMAND_NAME]: logsCliSchema, [NETWORK_COMMAND_NAME]: networkCliSchema, - [DEBUG_COMMAND_NAME]: debugCliSchema, } as const satisfies Record; -export const perfCliReader: CliReader = (positionals, flags) => ({ - ...commonInputFromFlags(flags), - ...readPerfPositionals(positionals, { - kind: readPerfKindFlag(flags.kind), - template: flags.perfTemplate, - out: flags.out, - }), -}); - export const logsCliReader: CliReader = (positionals, flags) => ({ ...commonInputFromFlags(flags), action: readLogsAction(positionals[0]), @@ -202,26 +98,11 @@ export const networkCliReader: CliReader = (positionals, flags) => ({ include: flags.networkInclude ?? readNetworkInclude(positionals[2]), }); -const debugCliReader: CliReader = (positionals, flags) => ({ - ...commonInputFromFlags(flags), - action: readDebugAction(positionals[0]), - artifact: flags.artifact, - dsym: flags.dsym, - searchPath: flags.searchPath, - out: flags.out, -}); - export const observabilityCliReaders = { - perf: perfCliReader, logs: logsCliReader, network: networkCliReader, - debug: debugCliReader, } satisfies Record; -export const perfDaemonWriter: DaemonWriter = direct(PERF_COMMAND_NAME, (input) => - perfPositionals(input as PerfOptions), -); - export const logsDaemonWriter: DaemonWriter = direct(LOGS_COMMAND_NAME, (input) => logsPositionals(input as LogsOptions), ); @@ -233,83 +114,10 @@ export const networkDaemonWriter: DaemonWriter = (input) => }); export const observabilityDaemonWriters = { - perf: perfDaemonWriter, logs: logsDaemonWriter, network: networkDaemonWriter, } satisfies Record; -function perfPositionals(input: PerfOptions): string[] { - const area = input.area ?? (input.action ? 'metrics' : undefined); - if (area === 'cpu') { - return nativePerfPositionals( - [ - ...optionalString(area), - ...optionalString(input.subject), - ...optionalString(input.action), - ...optionalString(input.kind), - ], - input, - ); - } - if (area === 'trace') { - return nativePerfPositionals( - [...optionalString(area), ...optionalString(input.action), ...optionalString(input.kind)], - input, - ); - } - return [...optionalString(area), ...optionalString(input.action)]; -} - -function nativePerfPositionals(base: string[], input: PerfOptions): string[] { - const positionals = [...base]; - if (input.template || input.out || input.tracePath) { - positionals.push(input.template ?? ''); - } - if (input.out || input.tracePath) { - positionals.push(input.out ?? ''); - } - if (input.tracePath) { - positionals.push(input.tracePath); - } - return positionals; -} - -function readPerfPositionals( - positionals: string[], - flags: Pick = {}, -): Pick { - if (positionals[0] !== undefined && positionals[1] === undefined) { - const action = readPerfAction(positionals[0], { allowUndefined: true }); - if (action) return { action, kind: readPerfKind(flags.kind), out: flags.out }; - } - const area = readPerfArea(positionals[0]); - if (area === 'cpu') { - return { - area, - subject: readPerfSubject(positionals[1]), - action: readPerfAction(positionals[2]), - kind: readPerfKind(flags.kind), - template: flags.template, - out: flags.out, - }; - } - if (area === 'trace') { - return { - area, - action: readPerfAction(positionals[1]), - kind: readPerfKind(flags.kind), - template: flags.template, - out: flags.out, - }; - } - return { - area, - action: readPerfAction(positionals[1]), - kind: readPerfKind(flags.kind), - out: flags.out, - }; -} - function logsPositionals(input: { action?: string; message?: string }): string[] { return [input.action ?? 'path', ...optionalString(input.message)]; } @@ -318,41 +126,6 @@ function networkPositionals(input: NetworkOptions): string[] { return [...(input.action ? [input.action] : []), ...optionalNumber(input.limit)]; } -function readPerfArea(value: string | undefined): PerfArea | undefined { - if (value === undefined) return undefined; - const normalized = value.toLowerCase(); - if (isPerfArea(normalized)) return normalized; - throw new AppError('INVALID_ARGS', PERF_AREA_ERROR_MESSAGE); -} - -function readPerfAction( - value: string | undefined, - options: { allowUndefined?: boolean } = {}, -): PerfAction | undefined { - if (value === undefined) return undefined; - const normalized = value.toLowerCase(); - if (isPerfAction(normalized)) return normalized; - if (options.allowUndefined) return undefined; - throw new AppError('INVALID_ARGS', PERF_ACTION_ERROR_MESSAGE); -} - -function readPerfSubject(value: string | undefined): PerfSubject { - const normalized = value?.toLowerCase(); - if (normalized !== undefined && isPerfSubject(normalized)) return normalized; - throw new AppError('INVALID_ARGS', PERF_SUBJECT_ERROR_MESSAGE); -} - -function readPerfKind(value: string | undefined): PerfKind | undefined { - if (value === undefined) return undefined; - const normalized = value.toLowerCase(); - if (isPerfKind(normalized)) return normalized; - throw new AppError('INVALID_ARGS', PERF_KIND_ERROR_MESSAGE); -} - -function readPerfKindFlag(value: unknown): PerfKind | undefined { - return typeof value === 'string' ? readPerfKind(value) : undefined; -} - function readLogsAction(value: string | undefined): LogAction | undefined { if (value === undefined) return undefined; return parseStringMember(LOG_ACTION_VALUES, value, { @@ -372,11 +145,3 @@ function readNetworkInclude(value: string | undefined): NetworkIncludeMode | und message: 'network include must be summary, headers, body, or all', }); } - -function readDebugAction(value: string | undefined): 'symbols' { - if (value === 'symbols') return value; - throw new AppError( - 'INVALID_ARGS', - 'debug supports only symbols; use logs, network, perf, record, trace, or react-devtools for other diagnostics', - ); -} diff --git a/src/commands/observability/output.ts b/src/commands/observability/output.ts index 3e880a518..56437d41e 100644 --- a/src/commands/observability/output.ts +++ b/src/commands/observability/output.ts @@ -1,11 +1,6 @@ -import type { CommandRequestResult, DebugSymbolsResult } from '../../client-types.ts'; +import type { CommandRequestResult } from '../../client-types.ts'; import type { CliOutput } from '../command-contract.ts'; -import { - readRecord, - readRecordArray, - resultOutput, - type CliOutputFormatter, -} from '../output-common.ts'; +import { readRecord, resultOutput, type CliOutputFormatter } from '../output-common.ts'; function logsCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; @@ -52,47 +47,11 @@ function networkCliOutput(result: CommandRequestResult): CliOutput { }; } -function perfCliOutput(result: CommandRequestResult): CliOutput { - const data = result as Record; - return { data, text: formatPerfCliOutput(data) }; -} - -function debugSymbolsCliOutput(result: DebugSymbolsResult): CliOutput { - const lines = [result.outPath, result.message]; - lines.push(...formatDebugCrashSummary(result)); - for (const image of result.matchedImages) { - lines.push(`Matched: ${image.name} ${image.uuid}${image.arch ? ` ${image.arch}` : ''}`); - } - for (const warning of result.warnings ?? []) { - lines.push(`Warning: ${warning}`); - } - return { data: result, text: lines.join('\n') }; -} - export const observabilityCliOutputFormatters = { - perf: resultOutput(perfCliOutput), logs: resultOutput(logsCliOutput), network: resultOutput(networkCliOutput), - debug: resultOutput(debugSymbolsCliOutput), } as const satisfies Record; -function formatDebugCrashSummary(result: DebugSymbolsResult): string[] { - const crash = result.crash; - const lines = [ - `Crash: ${crash.appName ?? 'unknown app'}${crash.crashedThread === undefined ? '' : ` thread ${crash.crashedThread}`}`, - ]; - if (crash.bundleId) lines.push(`Bundle: ${crash.bundleId}`); - if (crash.exceptionType) lines.push(`Exception: ${crash.exceptionType}`); - if (crash.terminationReason) lines.push(`Termination: ${crash.terminationReason}`); - for (const frame of crash.topFrames) { - lines.push(`Frame ${frame.index}: ${frame.image} ${frame.symbol ?? frame.address}`); - } - for (const finding of crash.findings) { - lines.push(`Finding: ${finding}`); - } - return lines; -} - function formatActionFields(data: Record): string | undefined { return ( ['started', 'stopped', 'marked', 'cleared', 'restarted', 'removedRotatedFiles'] @@ -144,239 +103,3 @@ function joinDefinedLines(lines: Array): string | undefined const joined = lines.filter((line): line is string => Boolean(line)).join('\n'); return joined || undefined; } - -function formatPerfCliOutput(data: Record): string { - const nativeOutput = formatNativePerfOutput(data); - if (nativeOutput) return nativeOutput; - const artifact = readRecord(data.artifact); - if (artifact) { - return formatMemoryArtifactSummary(artifact); - } - const metrics = readRecord(data.metrics); - const fps = readRecord(metrics?.fps); - const resourceSummary = buildResourcePerfSummary(metrics); - if (!fps) { - return formatPerfUnavailable(resourceSummary, 'missing frame metric'); - } - - if (fps.available === false) { - return formatPerfUnavailable(resourceSummary, readUnavailableReason(fps)); - } - - const frameSummary = formatFrameHealthSummary(fps); - if (!frameSummary) return formatPerfUnavailable(resourceSummary, 'missing dropped-frame summary'); - - const lines = [`Frame health: ${frameSummary}`]; - const worstWindows = formatWorstFrameWindows(fps); - if (worstWindows.length > 0) { - lines.push('Worst windows:', ...worstWindows); - } - return lines.join('\n'); -} - -function formatMemoryArtifactSummary(artifact: Record): string { - const kind = typeof artifact.kind === 'string' ? artifact.kind : 'memory'; - if (artifact.available === false) { - const reason = - typeof artifact.reason === 'string' && artifact.reason.length > 0 - ? artifact.reason - : 'not available'; - return `Memory artifact (${kind}): unavailable - ${reason}`; - } - const artifactPath = typeof artifact.path === 'string' ? artifact.path : undefined; - const sizeBytes = readFiniteNumber(artifact.sizeBytes); - const sizeText = sizeBytes === undefined ? '' : ` (${formatBytes(sizeBytes)})`; - return artifactPath - ? `Memory artifact (${kind}): ${artifactPath}${sizeText}` - : `Memory artifact (${kind}): captured${sizeText}`; -} - -function formatNativePerfOutput(data: Record): string | undefined { - if (data.kind === 'xctrace') return formatAppleNativePerfOutput(data); - return formatAndroidNativePerfOutput(data); -} - -function formatAppleNativePerfOutput(data: Record): string | undefined { - const state = typeof data.perf === 'string' ? data.perf : undefined; - const outPath = readNativePerfArtifactPath(data); - if (!state || !outPath || data.kind !== 'xctrace') return undefined; - const mode = typeof data.mode === 'string' ? data.mode : 'capture'; - return formatNativePerfLines(outPath, mode, state, data.template); -} - -function readNativePerfArtifactPath(data: Record): string | undefined { - if (typeof data.outPath === 'string') return data.outPath; - return typeof data.reportPath === 'string' ? data.reportPath : undefined; -} - -function formatNativePerfLines( - outPath: string, - mode: string, - state: string, - template: unknown, -): string { - const lines = [outPath, `Perf ${mode}: ${state}`]; - if (typeof template === 'string') lines.push(`Template: ${template}`); - return lines.join('\n'); -} - -function formatAndroidNativePerfOutput(data: Record): string | undefined { - const summary = readNativePerfSummary(data); - if (!summary) return undefined; - return `Perf ${summary.action}: ${summary.kind} ${summary.type}${formatNativePerfState( - data, - )}${formatNativePerfArtifact(data)}${formatNativePerfFrameHealth(data)}`; -} - -function readNativePerfSummary( - data: Record, -): { action: string; kind: string; type: string } | undefined { - const action = readString(data.action); - const kind = readString(data.kind); - const type = readString(data.type); - return action && kind && type ? { action, kind, type } : undefined; -} - -function formatNativePerfState(data: Record): string { - const state = readString(data.state); - return state ? ` state=${state}` : ''; -} - -function formatNativePerfArtifact(data: Record): string { - const outPath = readString(data.outPath); - if (!outPath) return ''; - const sizeBytes = readFiniteNumber(data.sizeBytes); - return `\n${outPath}${sizeBytes !== undefined ? ` (${formatBytes(sizeBytes)})` : ''}`; -} - -function readString(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined; -} - -function formatNativePerfFrameHealth(data: Record): string { - const summary = readRecord(data.summary); - const frameHealth = readRecord(summary?.frameHealth); - if (!frameHealth || frameHealth.available !== true) return ''; - const droppedFramePercent = readFiniteNumber(frameHealth.droppedFramePercent); - const droppedFrameCount = readFiniteNumber(frameHealth.droppedFrameCount); - const totalFrameCount = readFiniteNumber(frameHealth.totalFrameCount); - if ( - droppedFramePercent === undefined || - droppedFrameCount === undefined || - totalFrameCount === undefined - ) { - return ''; - } - return `\nTrace frame health: dropped ${formatPercent(droppedFramePercent)} (${Math.round( - droppedFrameCount, - )}/${Math.round(totalFrameCount)} frames)`; -} - -function formatPerfUnavailable(resourceSummary: string | undefined, reason: string): string { - return resourceSummary - ? `Performance: ${resourceSummary}` - : `Frame health: unavailable - ${reason}`; -} - -function readUnavailableReason(fps: Record): string { - return typeof fps.reason === 'string' && fps.reason.length > 0 ? fps.reason : 'not available'; -} - -function formatFrameHealthSummary(fps: Record): string | undefined { - const droppedFramePercent = readFiniteNumber(fps.droppedFramePercent); - const droppedFrameCount = readFiniteNumber(fps.droppedFrameCount); - if (droppedFramePercent === undefined || droppedFrameCount === undefined) return undefined; - return [ - `dropped ${formatPercent(droppedFramePercent)}`, - formatDroppedFrameCount(droppedFrameCount, readFiniteNumber(fps.totalFrameCount)), - formatSampleWindow(readFiniteNumber(fps.sampleWindowMs)), - ] - .filter(Boolean) - .join(' '); -} - -function formatDroppedFrameCount(droppedFrameCount: number, totalFrameCount?: number): string { - return totalFrameCount !== undefined - ? `(${Math.round(droppedFrameCount)}/${Math.round(totalFrameCount)} frames)` - : `(${Math.round(droppedFrameCount)} dropped frames)`; -} - -function formatSampleWindow(sampleWindowMs: number | undefined): string { - return sampleWindowMs !== undefined ? `window ${formatDurationMs(sampleWindowMs)}` : ''; -} - -function formatWorstFrameWindows(fps: Record): string[] { - return readRecordArray(fps.worstWindows).flatMap((window) => { - const line = formatWorstFrameWindow(window); - return line ? [line] : []; - }); -} - -function formatWorstFrameWindow(window: Record): string | undefined { - const startOffsetMs = readFiniteNumber(window.startOffsetMs); - const endOffsetMs = readFiniteNumber(window.endOffsetMs); - const count = readFiniteNumber(window.missedDeadlineFrameCount); - if (startOffsetMs === undefined || endOffsetMs === undefined || count === undefined) { - return undefined; - } - const worstFrameMs = readFiniteNumber(window.worstFrameMs); - const worstFrameText = - worstFrameMs === undefined ? '' : `, worst ${formatDurationMs(worstFrameMs)}`; - return `- +${formatDurationMs(startOffsetMs)}-+${formatDurationMs(endOffsetMs)}: ${Math.round(count)} missed-deadline frames${worstFrameText}`; -} - -function buildResourcePerfSummary( - metrics: Record | undefined, -): string | undefined { - const parts = [ - formatCpuPerfSummary(readRecord(metrics?.cpu)), - formatMemoryPerfSummary(readRecord(metrics?.memory)), - ].filter((part): part is string => Boolean(part)); - return parts.length > 0 ? parts.join(', ') : undefined; -} - -function formatCpuPerfSummary(cpu: Record | undefined): string | undefined { - if (cpu?.available !== true) return undefined; - const usagePercent = readFiniteNumber(cpu.usagePercent); - return usagePercent !== undefined ? `CPU ${formatPercent(usagePercent)}` : undefined; -} - -function formatMemoryPerfSummary(memory: Record | undefined): string | undefined { - if (memory?.available !== true) return undefined; - const memoryKb = - readFiniteNumber(memory.residentMemoryKb) ?? - readFiniteNumber(memory.totalPssKb) ?? - readFiniteNumber(memory.totalRssKb); - return memoryKb !== undefined ? `memory ${formatMemoryKb(memoryKb)}` : undefined; -} - -function readFiniteNumber(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) ? value : undefined; -} - -function formatPercent(value: number): string { - return `${Number.isInteger(value) ? value : value.toFixed(1)}%`; -} - -function formatDurationMs(value: number): string { - const roundedMs = Math.max(0, Math.round(value)); - if (roundedMs < 1000) return `${roundedMs}ms`; - const seconds = Math.round(roundedMs / 1000); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; -} - -function formatMemoryKb(value: number): string { - const megabytes = value / 1024; - return `${megabytes >= 10 ? Math.round(megabytes) : megabytes.toFixed(1)}MB`; -} - -function formatBytes(value: number): string { - if (value < 1024) return `${Math.round(value)}B`; - const kib = value / 1024; - if (kib < 1024) return `${kib >= 10 ? Math.round(kib) : kib.toFixed(1)}KB`; - const mib = kib / 1024; - return `${mib >= 10 ? Math.round(mib) : mib.toFixed(1)}MB`; -} diff --git a/src/commands/perf/index.test.ts b/src/commands/perf/index.test.ts new file mode 100644 index 000000000..26557a931 --- /dev/null +++ b/src/commands/perf/index.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'vitest'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import { + perfCliReader, + perfCommandDefinition, + perfCommandMetadata, + perfDaemonWriter, +} from './index.ts'; + +const NO_FLAGS = {} as CliFlags; + +function expectInvalidArgs(fn: () => unknown, messageFragment: string) { + expect(fn).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining(messageFragment), + }), + ); +} + +describe('perf command interface', () => { + test('owns perf public metadata', () => { + expect(perfCommandMetadata.name).toBe('perf'); + expect(perfCommandDefinition.name).toBe('perf'); + }); + + test('reads perf area, action, kind, and out flags', () => { + expect( + perfCliReader(['memory', 'snapshot'], { + kind: 'android-hprof', + out: './heap.hprof', + } as CliFlags), + ).toEqual({ + area: 'memory', + action: 'snapshot', + kind: 'android-hprof', + out: './heap.hprof', + }); + }); + + test('treats a single perf action as metrics action', () => { + expect(perfCliReader(['sample'], NO_FLAGS)).toEqual({ + action: 'sample', + kind: undefined, + out: undefined, + }); + expect(perfDaemonWriter({ action: 'sample' })).toMatchObject({ + command: 'perf', + positionals: ['metrics', 'sample'], + }); + }); + + test('rejects invalid perf positionals', () => { + expectInvalidArgs(() => perfCliReader(['memory', 'explode'], NO_FLAGS), 'perf action'); + }); +}); diff --git a/src/commands/perf/index.ts b/src/commands/perf/index.ts new file mode 100644 index 000000000..b60a9104f --- /dev/null +++ b/src/commands/perf/index.ts @@ -0,0 +1,196 @@ +import type { PerfOptions } from '../../client-types.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { enumField, stringField } from '../command-input.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { + isPerfAction, + isPerfArea, + isPerfKind, + isPerfSubject, + PERF_ACTION_ERROR_MESSAGE, + PERF_ACTION_VALUES, + PERF_AREA_ERROR_MESSAGE, + PERF_AREA_VALUES, + PERF_KIND_ERROR_MESSAGE, + PERF_KIND_VALUES, + PERF_SUBJECT_ERROR_MESSAGE, + PERF_SUBJECT_VALUES, + type PerfAction, + type PerfArea, + type PerfKind, + type PerfSubject, +} from './perf-command-contract.ts'; +import { commonInputFromFlags, direct, optionalString } from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; + +const PERF_COMMAND_NAME = 'perf'; + +const perfCommandDescription = 'Show session performance, frame health, and memory diagnostics.'; + +export const perfCommandMetadata = defineFieldCommandMetadata( + PERF_COMMAND_NAME, + perfCommandDescription, + { + area: enumField(PERF_AREA_VALUES), + subject: enumField(PERF_SUBJECT_VALUES), + action: enumField(PERF_ACTION_VALUES), + kind: enumField(PERF_KIND_VALUES), + template: stringField('xctrace template name, for example Time Profiler.'), + out: stringField('Output artifact path.'), + tracePath: stringField('Existing .trace path to report, defaults to the latest session trace.'), + }, +); + +export const perfCommandMetadataList = [perfCommandMetadata] as const; + +export const perfCommandDefinition = defineExecutableCommand(perfCommandMetadata, (client, input) => + client.observability.perf(input), +); + +export const perfCommandDefinitions = [perfCommandDefinition] as const; + +const perfCliSchema = { + usageOverride: + 'perf [metrics|frames|memory] [sample|snapshot]\n agent-device perf memory sample --json\n agent-device perf memory snapshot [--kind android-hprof|memgraph] [--out ]\n agent-device perf cpu profile start|stop|report --kind xctrace [--template ] --out \n agent-device perf trace start|stop --kind xctrace [--template ] --out \n agent-device perf cpu profile start|stop|report --kind simpleperf [--out ]\n agent-device perf trace start|stop --kind perfetto [--out ]', + listUsageOverride: + 'perf [metrics|frames|memory] | perf cpu profile start|stop|report | perf trace start|stop', + helpDescription: + 'Show session performance metrics, focused frame/jank health, memory diagnostics artifacts, Apple xctrace artifacts, or Android native Simpleperf/Perfetto artifacts. Bare perf and metrics are aliases for perf metrics. Native perf output is agent evidence: compact state, artifact path, and size only; raw profiles/traces stay on disk.', + summary: 'Show performance metrics or collect native perf artifacts', + positionalArgs: ['area?', 'subjectOrAction?', 'action?'], + allowedFlags: ['kind', 'perfTemplate', 'out'], +} as const satisfies CommandSchemaOverride; + +export const perfCliSchemas = { + [PERF_COMMAND_NAME]: perfCliSchema, +} as const satisfies Record; + +export const perfCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + ...readPerfPositionals(positionals, { + kind: readPerfKindFlag(flags.kind), + template: flags.perfTemplate, + out: flags.out, + }), +}); + +export const perfCliReaders = { + perf: perfCliReader, +} satisfies Record; + +export const perfDaemonWriter: DaemonWriter = direct(PERF_COMMAND_NAME, (input) => + perfPositionals(input as PerfOptions), +); + +export const perfDaemonWriters = { + perf: perfDaemonWriter, +} satisfies Record; + +function perfPositionals(input: PerfOptions): string[] { + const area = input.area ?? (input.action ? 'metrics' : undefined); + if (area === 'cpu') { + return nativePerfPositionals( + [ + ...optionalString(area), + ...optionalString(input.subject), + ...optionalString(input.action), + ...optionalString(input.kind), + ], + input, + ); + } + if (area === 'trace') { + return nativePerfPositionals( + [...optionalString(area), ...optionalString(input.action), ...optionalString(input.kind)], + input, + ); + } + return [...optionalString(area), ...optionalString(input.action)]; +} + +function nativePerfPositionals(base: string[], input: PerfOptions): string[] { + const positionals = [...base]; + if (input.template || input.out || input.tracePath) { + positionals.push(input.template ?? ''); + } + if (input.out || input.tracePath) { + positionals.push(input.out ?? ''); + } + if (input.tracePath) { + positionals.push(input.tracePath); + } + return positionals; +} + +function readPerfPositionals( + positionals: string[], + flags: Pick = {}, +): Pick { + if (positionals[0] !== undefined && positionals[1] === undefined) { + const action = readPerfAction(positionals[0], { allowUndefined: true }); + if (action) return { action, kind: readPerfKind(flags.kind), out: flags.out }; + } + const area = readPerfArea(positionals[0]); + if (area === 'cpu') { + return { + area, + subject: readPerfSubject(positionals[1]), + action: readPerfAction(positionals[2]), + kind: readPerfKind(flags.kind), + template: flags.template, + out: flags.out, + }; + } + if (area === 'trace') { + return { + area, + action: readPerfAction(positionals[1]), + kind: readPerfKind(flags.kind), + template: flags.template, + out: flags.out, + }; + } + return { + area, + action: readPerfAction(positionals[1]), + kind: readPerfKind(flags.kind), + out: flags.out, + }; +} + +function readPerfArea(value: string | undefined): PerfArea | undefined { + if (value === undefined) return undefined; + const normalized = value.toLowerCase(); + if (isPerfArea(normalized)) return normalized; + throw new AppError('INVALID_ARGS', PERF_AREA_ERROR_MESSAGE); +} + +function readPerfAction( + value: string | undefined, + options: { allowUndefined?: boolean } = {}, +): PerfAction | undefined { + if (value === undefined) return undefined; + const normalized = value.toLowerCase(); + if (isPerfAction(normalized)) return normalized; + if (options.allowUndefined) return undefined; + throw new AppError('INVALID_ARGS', PERF_ACTION_ERROR_MESSAGE); +} + +function readPerfSubject(value: string | undefined): PerfSubject { + const normalized = value?.toLowerCase(); + if (normalized !== undefined && isPerfSubject(normalized)) return normalized; + throw new AppError('INVALID_ARGS', PERF_SUBJECT_ERROR_MESSAGE); +} + +function readPerfKind(value: string | undefined): PerfKind | undefined { + if (value === undefined) return undefined; + const normalized = value.toLowerCase(); + if (isPerfKind(normalized)) return normalized; + throw new AppError('INVALID_ARGS', PERF_KIND_ERROR_MESSAGE); +} + +function readPerfKindFlag(value: unknown): PerfKind | undefined { + return typeof value === 'string' ? readPerfKind(value) : undefined; +} diff --git a/src/commands/perf/output.ts b/src/commands/perf/output.ts new file mode 100644 index 000000000..bb944da16 --- /dev/null +++ b/src/commands/perf/output.ts @@ -0,0 +1,253 @@ +import type { CommandRequestResult } from '../../client-types.ts'; +import type { CliOutput } from '../command-contract.ts'; +import { + readRecord, + readRecordArray, + resultOutput, + type CliOutputFormatter, +} from '../output-common.ts'; + +function perfCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + return { data, text: formatPerfCliOutput(data) }; +} + +export const perfCliOutputFormatters = { + perf: resultOutput(perfCliOutput), +} as const satisfies Record; + +function formatPerfCliOutput(data: Record): string { + const nativeOutput = formatNativePerfOutput(data); + if (nativeOutput) return nativeOutput; + const artifact = readRecord(data.artifact); + if (artifact) { + return formatMemoryArtifactSummary(artifact); + } + const metrics = readRecord(data.metrics); + const fps = readRecord(metrics?.fps); + const resourceSummary = buildResourcePerfSummary(metrics); + if (!fps) { + return formatPerfUnavailable(resourceSummary, 'missing frame metric'); + } + + if (fps.available === false) { + return formatPerfUnavailable(resourceSummary, readUnavailableReason(fps)); + } + + const frameSummary = formatFrameHealthSummary(fps); + if (!frameSummary) return formatPerfUnavailable(resourceSummary, 'missing dropped-frame summary'); + + const lines = [`Frame health: ${frameSummary}`]; + const worstWindows = formatWorstFrameWindows(fps); + if (worstWindows.length > 0) { + lines.push('Worst windows:', ...worstWindows); + } + return lines.join('\n'); +} + +function formatMemoryArtifactSummary(artifact: Record): string { + const kind = typeof artifact.kind === 'string' ? artifact.kind : 'memory'; + if (artifact.available === false) { + const reason = + typeof artifact.reason === 'string' && artifact.reason.length > 0 + ? artifact.reason + : 'not available'; + return `Memory artifact (${kind}): unavailable - ${reason}`; + } + const artifactPath = typeof artifact.path === 'string' ? artifact.path : undefined; + const sizeBytes = readFiniteNumber(artifact.sizeBytes); + const sizeText = sizeBytes === undefined ? '' : ` (${formatBytes(sizeBytes)})`; + return artifactPath + ? `Memory artifact (${kind}): ${artifactPath}${sizeText}` + : `Memory artifact (${kind}): captured${sizeText}`; +} + +function formatNativePerfOutput(data: Record): string | undefined { + if (data.kind === 'xctrace') return formatAppleNativePerfOutput(data); + return formatAndroidNativePerfOutput(data); +} + +function formatAppleNativePerfOutput(data: Record): string | undefined { + const state = typeof data.perf === 'string' ? data.perf : undefined; + const outPath = readNativePerfArtifactPath(data); + if (!state || !outPath || data.kind !== 'xctrace') return undefined; + const mode = typeof data.mode === 'string' ? data.mode : 'capture'; + return formatNativePerfLines(outPath, mode, state, data.template); +} + +function readNativePerfArtifactPath(data: Record): string | undefined { + if (typeof data.outPath === 'string') return data.outPath; + return typeof data.reportPath === 'string' ? data.reportPath : undefined; +} + +function formatNativePerfLines( + outPath: string, + mode: string, + state: string, + template: unknown, +): string { + const lines = [outPath, `Perf ${mode}: ${state}`]; + if (typeof template === 'string') lines.push(`Template: ${template}`); + return lines.join('\n'); +} + +function formatAndroidNativePerfOutput(data: Record): string | undefined { + const summary = readNativePerfSummary(data); + if (!summary) return undefined; + return `Perf ${summary.action}: ${summary.kind} ${summary.type}${formatNativePerfState( + data, + )}${formatNativePerfArtifact(data)}${formatNativePerfFrameHealth(data)}`; +} + +function readNativePerfSummary( + data: Record, +): { action: string; kind: string; type: string } | undefined { + const action = readString(data.action); + const kind = readString(data.kind); + const type = readString(data.type); + return action && kind && type ? { action, kind, type } : undefined; +} + +function formatNativePerfState(data: Record): string { + const state = readString(data.state); + return state ? ` state=${state}` : ''; +} + +function formatNativePerfArtifact(data: Record): string { + const outPath = readString(data.outPath); + if (!outPath) return ''; + const sizeBytes = readFiniteNumber(data.sizeBytes); + return `\n${outPath}${sizeBytes !== undefined ? ` (${formatBytes(sizeBytes)})` : ''}`; +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function formatNativePerfFrameHealth(data: Record): string { + const summary = readRecord(data.summary); + const frameHealth = readRecord(summary?.frameHealth); + if (!frameHealth || frameHealth.available !== true) return ''; + const droppedFramePercent = readFiniteNumber(frameHealth.droppedFramePercent); + const droppedFrameCount = readFiniteNumber(frameHealth.droppedFrameCount); + const totalFrameCount = readFiniteNumber(frameHealth.totalFrameCount); + if ( + droppedFramePercent === undefined || + droppedFrameCount === undefined || + totalFrameCount === undefined + ) { + return ''; + } + return `\nTrace frame health: dropped ${formatPercent(droppedFramePercent)} (${Math.round( + droppedFrameCount, + )}/${Math.round(totalFrameCount)} frames)`; +} + +function formatPerfUnavailable(resourceSummary: string | undefined, reason: string): string { + return resourceSummary + ? `Performance: ${resourceSummary}` + : `Frame health: unavailable - ${reason}`; +} + +function readUnavailableReason(fps: Record): string { + return typeof fps.reason === 'string' && fps.reason.length > 0 ? fps.reason : 'not available'; +} + +function formatFrameHealthSummary(fps: Record): string | undefined { + const droppedFramePercent = readFiniteNumber(fps.droppedFramePercent); + const droppedFrameCount = readFiniteNumber(fps.droppedFrameCount); + if (droppedFramePercent === undefined || droppedFrameCount === undefined) return undefined; + return [ + `dropped ${formatPercent(droppedFramePercent)}`, + formatDroppedFrameCount(droppedFrameCount, readFiniteNumber(fps.totalFrameCount)), + formatSampleWindow(readFiniteNumber(fps.sampleWindowMs)), + ] + .filter(Boolean) + .join(' '); +} + +function formatDroppedFrameCount(droppedFrameCount: number, totalFrameCount?: number): string { + return totalFrameCount !== undefined + ? `(${Math.round(droppedFrameCount)}/${Math.round(totalFrameCount)} frames)` + : `(${Math.round(droppedFrameCount)} dropped frames)`; +} + +function formatSampleWindow(sampleWindowMs: number | undefined): string { + return sampleWindowMs !== undefined ? `window ${formatDurationMs(sampleWindowMs)}` : ''; +} + +function formatWorstFrameWindows(fps: Record): string[] { + return readRecordArray(fps.worstWindows).flatMap((window) => { + const line = formatWorstFrameWindow(window); + return line ? [line] : []; + }); +} + +function formatWorstFrameWindow(window: Record): string | undefined { + const startOffsetMs = readFiniteNumber(window.startOffsetMs); + const endOffsetMs = readFiniteNumber(window.endOffsetMs); + const count = readFiniteNumber(window.missedDeadlineFrameCount); + if (startOffsetMs === undefined || endOffsetMs === undefined || count === undefined) { + return undefined; + } + const worstFrameMs = readFiniteNumber(window.worstFrameMs); + const worstFrameText = + worstFrameMs === undefined ? '' : `, worst ${formatDurationMs(worstFrameMs)}`; + return `- +${formatDurationMs(startOffsetMs)}-+${formatDurationMs(endOffsetMs)}: ${Math.round(count)} missed-deadline frames${worstFrameText}`; +} + +function buildResourcePerfSummary( + metrics: Record | undefined, +): string | undefined { + const parts = [ + formatCpuPerfSummary(readRecord(metrics?.cpu)), + formatMemoryPerfSummary(readRecord(metrics?.memory)), + ].filter((part): part is string => Boolean(part)); + return parts.length > 0 ? parts.join(', ') : undefined; +} + +function formatCpuPerfSummary(cpu: Record | undefined): string | undefined { + if (cpu?.available !== true) return undefined; + const usagePercent = readFiniteNumber(cpu.usagePercent); + return usagePercent !== undefined ? `CPU ${formatPercent(usagePercent)}` : undefined; +} + +function formatMemoryPerfSummary(memory: Record | undefined): string | undefined { + if (memory?.available !== true) return undefined; + const memoryKb = + readFiniteNumber(memory.residentMemoryKb) ?? + readFiniteNumber(memory.totalPssKb) ?? + readFiniteNumber(memory.totalRssKb); + return memoryKb !== undefined ? `memory ${formatMemoryKb(memoryKb)}` : undefined; +} + +function readFiniteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function formatPercent(value: number): string { + return `${Number.isInteger(value) ? value : value.toFixed(1)}%`; +} + +function formatDurationMs(value: number): string { + const roundedMs = Math.max(0, Math.round(value)); + if (roundedMs < 1000) return `${roundedMs}ms`; + const seconds = Math.round(roundedMs / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; +} + +function formatMemoryKb(value: number): string { + const megabytes = value / 1024; + return `${megabytes >= 10 ? Math.round(megabytes) : megabytes.toFixed(1)}MB`; +} + +function formatBytes(value: number): string { + if (value < 1024) return `${Math.round(value)}B`; + const kib = value / 1024; + if (kib < 1024) return `${kib >= 10 ? Math.round(kib) : kib.toFixed(1)}KB`; + const mib = kib / 1024; + return `${mib >= 10 ? Math.round(mib) : mib.toFixed(1)}MB`; +} diff --git a/src/commands/observability/perf-command-contract.ts b/src/commands/perf/perf-command-contract.ts similarity index 100% rename from src/commands/observability/perf-command-contract.ts rename to src/commands/perf/perf-command-contract.ts diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index f255d4509..9f5251823 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -1,10 +1,12 @@ import type { CommandName } from '../commands/command-metadata.ts'; import { batchCliSchemas } from '../commands/batch/index.ts'; import { captureCliSchemas } from '../commands/capture/index.ts'; +import { debuggingCliSchemas } from '../commands/debugging/index.ts'; import { interactionCliSchemas } from '../commands/interaction/index.ts'; import { managementCliSchemas } from '../commands/management/index.ts'; import { metroCliSchemas } from '../commands/metro/index.ts'; import { observabilityCliSchemas } from '../commands/observability/index.ts'; +import { perfCliSchemas } from '../commands/perf/index.ts'; import { reactNativeCliSchemas } from '../commands/react-native/index.ts'; import { recordingCliSchemas } from '../commands/recording/index.ts'; import { replayCliSchemas } from '../commands/replay/index.ts'; @@ -66,6 +68,8 @@ const CLI_COMMAND_OVERRIDES = { ...systemCliSchemas, ...interactionCliSchemas, ...observabilityCliSchemas, + ...perfCliSchemas, + ...debuggingCliSchemas, ...metroCliSchemas, ...replayCliSchemas, ...batchCliSchemas, From cbe7918524d925cbe3ae767d8c7e89eb59d39703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 14:36:05 +0200 Subject: [PATCH 4/4] refactor: move Apple debug symbols under ios platform --- src/client-types.ts | 10 +--- src/client.ts | 2 +- src/contracts.ts | 7 +++ src/contracts/debug-symbols.ts | 51 ++++++++++++++++ .../ios}/__tests__/debug-symbols.test.ts | 6 +- .../ios}/debug-symbols.ts | 4 +- .../ios}/debug-symbols/crash-artifact.ts | 0 .../ios}/debug-symbols/dsym.ts | 4 +- .../ios}/debug-symbols/report.ts | 0 .../ios}/debug-symbols/symbolication.ts | 4 +- .../ios}/debug-symbols/types.ts | 58 +++---------------- .../ios}/debug-symbols/utils.ts | 2 +- 12 files changed, 78 insertions(+), 70 deletions(-) create mode 100644 src/contracts/debug-symbols.ts rename src/{ => platforms/ios}/__tests__/debug-symbols.test.ts (98%) rename src/{commands/debugging/runtime => platforms/ios}/debug-symbols.ts (97%) rename src/{commands/debugging/runtime => platforms/ios}/debug-symbols/crash-artifact.ts (100%) rename src/{commands/debugging/runtime => platforms/ios}/debug-symbols/dsym.ts (98%) rename src/{commands/debugging/runtime => platforms/ios}/debug-symbols/report.ts (100%) rename src/{commands/debugging/runtime => platforms/ios}/debug-symbols/symbolication.ts (97%) rename src/{commands/debugging/runtime => platforms/ios}/debug-symbols/types.ts (56%) rename src/{commands/debugging/runtime => platforms/ios}/debug-symbols/utils.ts (98%) diff --git a/src/client-types.ts b/src/client-types.ts index ec1463d15..ba57c9e1e 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -43,19 +43,13 @@ import type { ScreenshotRequestFlags } from './contracts/screenshot.ts'; import type { PerfAction, PerfArea, PerfKind, PerfSubject } from './contracts/perf.ts'; import type { DaemonBatchStep } from './core/batch.ts'; import type { AlertAction, AlertInfo } from './alert-contract.ts'; -import type { - DebugSymbolsOptions, - DebugSymbolsResult, -} from './commands/debugging/runtime/debug-symbols.ts'; +import type { DebugSymbolsOptions, DebugSymbolsResult } from './contracts/debug-symbols.ts'; export type { FindLocator } from './utils/finders.ts'; export type { CompanionTunnelScope, MetroBridgeScope } from './client-companion-tunnel-contract.ts'; export type { AppsFilter } from './contracts/app-inventory.ts'; export type { AlertAction, AlertInfo, AlertPlatform, AlertSource } from './alert-contract.ts'; -export type { - DebugSymbolsOptions, - DebugSymbolsResult, -} from './commands/debugging/runtime/debug-symbols.ts'; +export type { DebugSymbolsOptions, DebugSymbolsResult } from './contracts/debug-symbols.ts'; export type AgentDeviceDaemonTransport = ( req: Omit, diff --git a/src/client.ts b/src/client.ts index 7ffb2100e..784579a02 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,7 +1,7 @@ import { sendToDaemon } from './daemon-client.ts'; import { prepareMetroRuntime, reloadMetro } from './client-metro.ts'; import { resolveDaemonPaths } from './daemon/config.ts'; -import { symbolicateCrashArtifact } from './commands/debugging/runtime/debug-symbols.ts'; +import { symbolicateCrashArtifact } from './platforms/ios/debug-symbols.ts'; import { INTERNAL_COMMANDS } from './command-catalog.ts'; import { prepareDaemonCommandRequest, diff --git a/src/contracts.ts b/src/contracts.ts index 69e0a9782..c2a3a514c 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -1,5 +1,12 @@ export type { AppErrorCode } from './utils/errors.ts'; export { defaultHintForCode, normalizeError } from './utils/errors.ts'; +export type { + DebugSymbolsCrashFrame, + DebugSymbolsCrashSummary, + DebugSymbolsImage, + DebugSymbolsOptions, + DebugSymbolsResult, +} from './contracts/debug-symbols.ts'; import type { PlatformSelector } from './utils/device.ts'; import { PLATFORM_SELECTORS } from './utils/device.ts'; diff --git a/src/contracts/debug-symbols.ts b/src/contracts/debug-symbols.ts new file mode 100644 index 000000000..9fdfe2a10 --- /dev/null +++ b/src/contracts/debug-symbols.ts @@ -0,0 +1,51 @@ +export type DebugSymbolsOptions = { + action?: 'symbols'; + artifact: string; + dsym?: string; + searchPath?: string; + out?: string; + cwd?: string; +}; + +export type DebugSymbolsImage = { + name: string; + uuid: string; + arch?: string; + dsymPath: string; + binaryPath: string; +}; + +export type DebugSymbolsCrashFrame = { + index: number; + image: string; + address: string; + symbol?: string; +}; + +export type DebugSymbolsCrashSummary = { + format: 'ips' | 'text'; + appName?: string; + bundleId?: string; + version?: string; + incident?: string; + timestamp?: string; + exceptionType?: string; + exceptionCodes?: string; + terminationReason?: string; + crashedThread?: number; + topFrames: DebugSymbolsCrashFrame[]; + findings: string[]; +}; + +export type DebugSymbolsResult = { + kind: 'debugSymbols'; + platform: 'apple'; + artifactPath: string; + outPath: string; + crash: DebugSymbolsCrashSummary; + matchedImages: DebugSymbolsImage[]; + symbolicatedFrames: number; + skippedImages: number; + warnings?: string[]; + message: string; +}; diff --git a/src/__tests__/debug-symbols.test.ts b/src/platforms/ios/__tests__/debug-symbols.test.ts similarity index 98% rename from src/__tests__/debug-symbols.test.ts rename to src/platforms/ios/__tests__/debug-symbols.test.ts index 24322b3f8..23224131a 100644 --- a/src/__tests__/debug-symbols.test.ts +++ b/src/platforms/ios/__tests__/debug-symbols.test.ts @@ -3,9 +3,9 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { test } from 'vitest'; -import { symbolicateCrashArtifact } from '../commands/debugging/runtime/debug-symbols.ts'; -import { AppError } from '../utils/errors.ts'; -import { withCommandExecutorOverride } from '../utils/exec.ts'; +import { symbolicateCrashArtifact } from '../debug-symbols.ts'; +import { AppError } from '../../../utils/errors.ts'; +import { withCommandExecutorOverride } from '../../../utils/exec.ts'; const UUID = 'ABCDEFAB-CDEF-ABCD-EFAB-CDEFABCDEFAB'; const NORMALIZED_UUID = 'ABCDEFABCDEFABCDEFABCDEFABCDEFAB'; diff --git a/src/commands/debugging/runtime/debug-symbols.ts b/src/platforms/ios/debug-symbols.ts similarity index 97% rename from src/commands/debugging/runtime/debug-symbols.ts rename to src/platforms/ios/debug-symbols.ts index 940176db7..2b495d09b 100644 --- a/src/commands/debugging/runtime/debug-symbols.ts +++ b/src/platforms/ios/debug-symbols.ts @@ -4,8 +4,8 @@ import { readAppleCrashArtifact } from './debug-symbols/crash-artifact.ts'; import { matchImagesToDsyms, readDsymPaths, readDsymSlices } from './debug-symbols/dsym.ts'; import { summarizeCrashArtifact } from './debug-symbols/report.ts'; import { resolveAppleTools, symbolicateAddresses } from './debug-symbols/symbolication.ts'; -import type { DebugSymbolsOptions, DebugSymbolsResult } from './debug-symbols/types.ts'; -import { AppError } from '../../../utils/errors.ts'; +import type { DebugSymbolsOptions, DebugSymbolsResult } from '../../contracts/debug-symbols.ts'; +import { AppError } from '../../utils/errors.ts'; export type { DebugSymbolsCrashFrame, diff --git a/src/commands/debugging/runtime/debug-symbols/crash-artifact.ts b/src/platforms/ios/debug-symbols/crash-artifact.ts similarity index 100% rename from src/commands/debugging/runtime/debug-symbols/crash-artifact.ts rename to src/platforms/ios/debug-symbols/crash-artifact.ts diff --git a/src/commands/debugging/runtime/debug-symbols/dsym.ts b/src/platforms/ios/debug-symbols/dsym.ts similarity index 98% rename from src/commands/debugging/runtime/debug-symbols/dsym.ts rename to src/platforms/ios/debug-symbols/dsym.ts index ee40291d0..f1ba5f5e5 100644 --- a/src/commands/debugging/runtime/debug-symbols/dsym.ts +++ b/src/platforms/ios/debug-symbols/dsym.ts @@ -2,8 +2,8 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import type { AppleImage, DsymMatch, DsymSlice } from './types.ts'; import { normalizeUuid, unique } from './utils.ts'; -import { runCmd } from '../../../../utils/exec.ts'; -import { AppError } from '../../../../utils/errors.ts'; +import { runCmd } from '../../../utils/exec.ts'; +import { AppError } from '../../../utils/errors.ts'; const MAX_SEARCH_ENTRIES = 10_000; const MAX_DSYM_CANDIDATES = 200; diff --git a/src/commands/debugging/runtime/debug-symbols/report.ts b/src/platforms/ios/debug-symbols/report.ts similarity index 100% rename from src/commands/debugging/runtime/debug-symbols/report.ts rename to src/platforms/ios/debug-symbols/report.ts diff --git a/src/commands/debugging/runtime/debug-symbols/symbolication.ts b/src/platforms/ios/debug-symbols/symbolication.ts similarity index 97% rename from src/commands/debugging/runtime/debug-symbols/symbolication.ts rename to src/platforms/ios/debug-symbols/symbolication.ts index eb94d701b..8c96e68a5 100644 --- a/src/commands/debugging/runtime/debug-symbols/symbolication.ts +++ b/src/platforms/ios/debug-symbols/symbolication.ts @@ -1,7 +1,7 @@ import type { DsymMatch, SymbolicatedAddress, SymbolicationGroup } from './types.ts'; import { addressKey, hex, unique } from './utils.ts'; -import { runCmd } from '../../../../utils/exec.ts'; -import { AppError } from '../../../../utils/errors.ts'; +import { runCmd } from '../../../utils/exec.ts'; +import { AppError } from '../../../utils/errors.ts'; export async function symbolicateAddresses( addresses: SymbolicatedAddress[], diff --git a/src/commands/debugging/runtime/debug-symbols/types.ts b/src/platforms/ios/debug-symbols/types.ts similarity index 56% rename from src/commands/debugging/runtime/debug-symbols/types.ts rename to src/platforms/ios/debug-symbols/types.ts index 672478937..976defda8 100644 --- a/src/commands/debugging/runtime/debug-symbols/types.ts +++ b/src/platforms/ios/debug-symbols/types.ts @@ -1,54 +1,10 @@ -export type DebugSymbolsOptions = { - action?: 'symbols'; - artifact: string; - dsym?: string; - searchPath?: string; - out?: string; - cwd?: string; -}; - -export type DebugSymbolsImage = { - name: string; - uuid: string; - arch?: string; - dsymPath: string; - binaryPath: string; -}; - -export type DebugSymbolsCrashFrame = { - index: number; - image: string; - address: string; - symbol?: string; -}; - -export type DebugSymbolsCrashSummary = { - format: 'ips' | 'text'; - appName?: string; - bundleId?: string; - version?: string; - incident?: string; - timestamp?: string; - exceptionType?: string; - exceptionCodes?: string; - terminationReason?: string; - crashedThread?: number; - topFrames: DebugSymbolsCrashFrame[]; - findings: string[]; -}; - -export type DebugSymbolsResult = { - kind: 'debugSymbols'; - platform: 'apple'; - artifactPath: string; - outPath: string; - crash: DebugSymbolsCrashSummary; - matchedImages: DebugSymbolsImage[]; - symbolicatedFrames: number; - skippedImages: number; - warnings?: string[]; - message: string; -}; +export type { + DebugSymbolsCrashFrame, + DebugSymbolsCrashSummary, + DebugSymbolsImage, + DebugSymbolsOptions, + DebugSymbolsResult, +} from '../../../contracts/debug-symbols.ts'; export type AppleImage = { index?: number; diff --git a/src/commands/debugging/runtime/debug-symbols/utils.ts b/src/platforms/ios/debug-symbols/utils.ts similarity index 98% rename from src/commands/debugging/runtime/debug-symbols/utils.ts rename to src/platforms/ios/debug-symbols/utils.ts index d71103d2d..c6d927422 100644 --- a/src/commands/debugging/runtime/debug-symbols/utils.ts +++ b/src/platforms/ios/debug-symbols/utils.ts @@ -1,4 +1,4 @@ -import { AppError } from '../../../../utils/errors.ts'; +import { AppError } from '../../../utils/errors.ts'; const UUID_RE = /^[0-9a-fA-F-]{32,36}$/;