* Copyright (c) 2026 Huawei Device Co., Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Effect, Schema } from "effect"
import * as Tool from "./tool"
import { findDevEcoHome, hdcPath } from "./lib/env"
import DESCRIPTION from "./jscrash-report.txt"
type CrashReport = {
status: string
source: string
device: string
bundle: string
process: string
errorType: string
errorMessage: string
suspectedFile: string
keywords: string[]
topStack: string[]
excerpt: string[]
}
type ScoredLine = {
line: string
index: number
score: number
}
const ERROR_TYPE_RE = /(TypeError|ReferenceError|RangeError|SyntaxError|BusinessError|ParameterError|ResourceError|SystemError|EvalError|URIError)/i
const CRASH_SIGNAL_RE = /(jscrash|uncaught|exception|fatal|abort|crash|error)/i
const STRONG_CRASH_SIGNAL_RE =
/(jscrash|uncaught|fatal|abort|crash|TypeError|ReferenceError|RangeError|SyntaxError|BusinessError|ParameterError|ResourceError|SystemError|EvalError|URIError)/i
const FILE_RE = /([A-Za-z0-9_./\\-]+\.(ets|ts|js)(?::\d+:\d+)?)/i
function trim(input: string) {
return input.trim()
}
function target(device: string | undefined) {
return device ? ["-t", device] : []
}
async function run(cmd: string[]) {
const proc = Bun.spawn({
cmd,
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, exitCode] = await Promise.all([
proc.stdout ? Bun.readableStreamToText(proc.stdout) : Promise.resolve(""),
proc.stderr ? Bun.readableStreamToText(proc.stderr) : Promise.resolve(""),
proc.exited,
])
return { stdout, stderr, exitCode }
}
function cleanLines(input: string) {
return input
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter((line) => line.trim().length > 0)
}
function unique(items: string[]) {
return [...new Set(items)]
}
function containsIgnoreCase(input: string, token: string) {
return input.toLowerCase().includes(token.toLowerCase())
}
function firstMatch(input: string, patterns: RegExp[]) {
for (const pattern of patterns) {
const match = pattern.exec(input)
if (match?.[1]) {
return match[1].trim()
}
}
return ""
}
function detectErrorType(input: string) {
const matches = input.match(new RegExp(ERROR_TYPE_RE.source, "ig"))
if (!matches?.length) {
return "UnknownError"
}
return matches[matches.length - 1]
}
function detectBundle(input: string, bundleName: string) {
if (bundleName) {
return bundleName
}
return (
firstMatch(input, [
/bundleName\s*[:=]\s*([A-Za-z0-9._-]+)/i,
/bundle\s*[:=]\s*([A-Za-z0-9._-]+)/i,
/app\s*[:=]\s*([A-Za-z0-9._-]+)/i,
]) || "(unknown)"
)
}
function detectProcess(input: string, processHint: string) {
if (processHint) {
return processHint
}
return (
firstMatch(input, [
/(?:process(?:Name)?)\s*[:=]\s*([A-Za-z0-9._-]+)/i,
/pid\s*[:=]\s*([0-9]+)/i,
]) || "(unknown)"
)
}
function stackLike(line: string) {
return (
/at\s+.+:\d+:\d+/i.test(line) ||
/at\s+.+\(.+:\d+:\d+\)/i.test(line) ||
/([A-Za-z0-9_./\\-]+\.(ets|ts|js)):\d+:\d+/.test(line)
)
}
function applicationFrameScore(line: string, bundle: string) {
let score = 0
if (/entry[\\/].*\.ets/i.test(line)) {
score += 8
}
if (/src[\\/].*\.(ets|ts|js)/i.test(line)) {
score += 6
}
if (bundle !== "(unknown)" && containsIgnoreCase(line, bundle)) {
score += 4
}
if (/(pages|page|feature|component|viewmodel|store|model)[\\/]/i.test(line)) {
score += 3
}
if (/(framework|runtime|node_modules|oh_modules|libarkui|ets_runtime|foundation|system)[\\/]/i.test(line)) {
score -= 6
}
return score
}
function scoreCrashLine(line: string, bundle: string, processHint: string) {
let score = 0
if (CRASH_SIGNAL_RE.test(line)) {
score += 5
}
if (STRONG_CRASH_SIGNAL_RE.test(line)) {
score += 4
}
if (ERROR_TYPE_RE.test(line)) {
score += 4
}
if (stackLike(line)) {
score += 2
}
if (bundle && bundle !== "(unknown)" && containsIgnoreCase(line, bundle)) {
score += 2
}
if (processHint && processHint !== "(unknown)" && containsIgnoreCase(line, processHint)) {
score += 2
}
return score
}
function findCrashAnchor(lines: string[], bundle: string, processHint: string) {
for (let index = lines.length - 1; index >= 0; index -= 1) {
const score = scoreCrashLine(lines[index], bundle, processHint)
if (score >= 7) {
return index
}
}
for (let index = lines.length - 1; index >= 0; index -= 1) {
const score = scoreCrashLine(lines[index], bundle, processHint)
if (score >= 5) {
return index
}
}
return -1
}
function sliceWindow(lines: string[], anchor: number, before: number, after: number) {
if (anchor < 0) {
return []
}
const start = Math.max(0, anchor - before)
const end = Math.min(lines.length, anchor + after + 1)
return lines.slice(start, end)
}
function findCrashSignal(lines: string[], start: number, end: number, step: 1 | -1) {
for (let index = start; step > 0 ? index < end : index >= end; index += step) {
if (CRASH_SIGNAL_RE.test(lines[index])) {
return lines[index].trim()
}
}
return ""
}
function detectErrorMessage(lines: string[], anchor: number) {
if (anchor >= 0) {
const forwardMatch = findCrashSignal(lines, anchor, Math.min(lines.length, anchor + 6), 1)
if (forwardMatch) {
return forwardMatch
}
const backwardMatch = findCrashSignal(lines, anchor, Math.max(0, anchor - 3), -1)
if (backwardMatch) {
return backwardMatch
}
return lines[anchor].trim()
}
for (let index = lines.length - 1; index >= 0; index -= 1) {
if (CRASH_SIGNAL_RE.test(lines[index])) {
return lines[index].trim()
}
}
return lines[lines.length - 1] || "(not found)"
}
function detectTopStack(lines: string[], anchor: number) {
const around = anchor >= 0 ? sliceWindow(lines, anchor, 4, 18) : lines.slice(Math.max(0, lines.length - 24))
const frames = around.filter((line) => stackLike(line))
return unique(frames).slice(0, 8)
}
function detectSuspectedFile(topStack: string[], bundle: string) {
const candidates: ScoredLine[] = []
for (let index = 0; index < topStack.length; index += 1) {
const line = topStack[index]
const match = FILE_RE.exec(line)
if (!match?.[1]) {
continue
}
candidates.push({
line: match[1],
index,
score: applicationFrameScore(match[1], bundle) - index,
})
}
if (!candidates.length) {
return "(not found)"
}
candidates.sort((a, b) => b.score - a.score || a.index - b.index)
return candidates[0].line
}
function detectKeywords(input: string) {
const candidates = [
"jscrash",
"uncaught",
"exception",
"fatal",
"typeerror",
"referenceerror",
"rangeerror",
"syntaxerror",
"businesserror",
"parametererror",
"resourceerror",
"systemerror",
"abort",
"crash",
]
return candidates.filter((item) => containsIgnoreCase(input, item))
}
function looksLikeCrash(input: string, anchor: number, errorType: string) {
if (errorType !== "UnknownError") {
return true
}
if (anchor >= 0) {
return true
}
return /(jscrash|uncaught|fatal|abort|crash)/i.test(input)
}
function pickExcerpt(lines: string[], anchor: number, bundle: string, processHint: string, limit: number) {
if (anchor >= 0) {
const around = sliceWindow(lines, anchor, 5, 18)
if (around.length) {
return around.slice(0, limit)
}
}
const scored = lines
.map((line, index) => ({
line,
index,
score: scoreCrashLine(line, bundle, processHint),
}))
.filter((item) => item.score > 0)
if (!scored.length) {
return lines.slice(Math.max(0, lines.length - limit))
}
const out: string[] = []
for (const item of scored.slice(Math.max(0, scored.length - limit))) {
out.push(item.line)
}
return unique(out).slice(0, limit)
}
function buildNextAction(report: CrashReport) {
if (report.status === "no_crash_signature") {
return "Use hdc_log or provide a fuller crash log."
}
if (report.suspectedFile !== "(not found)") {
return `Inspect ${report.suspectedFile} first, load harmony-jscrash-fixes, and verify with compilation before runtime checks.`
}
return "Load harmony-jscrash-fixes, inspect the top stack frames, make a minimal fix, then verify with compilation and runtime evidence."
}
function formatReport(report: CrashReport) {
return [
report.status === "detected" ? "Crash signature detected." : "No clear crash signature detected.",
`source: ${report.source}`,
`device: ${report.device}`,
`bundle: ${report.bundle}`,
`process: ${report.process}`,
`error_type: ${report.errorType}`,
`error_message: ${report.errorMessage}`,
`suspected_file: ${report.suspectedFile}`,
`keywords: ${report.keywords.length ? report.keywords.join(", ") : "(none)"}`,
"",
"Top stack:",
...(report.topStack.length ? report.topStack : ["(empty)"]),
"",
"Evidence excerpt:",
...(report.excerpt.length ? report.excerpt : ["(empty)"]),
"",
`next_action: ${buildNextAction(report)}`,
].join("\n")
}
function buildReport(
input: string,
source: string,
device: string,
bundleName: string,
processHint: string,
): CrashReport {
const normalized = trim(input)
const lines = cleanLines(normalized)
const bundle = detectBundle(normalized, bundleName)
const process = detectProcess(normalized, processHint)
const anchor = findCrashAnchor(lines, bundle, process)
const focus = anchor >= 0 ? sliceWindow(lines, anchor, 5, 18).join("\n") : normalized
const errorType = detectErrorType(focus)
const topStack = detectTopStack(lines, anchor)
const excerpt = pickExcerpt(lines, anchor, bundle, process, 24)
return {
status: looksLikeCrash(normalized, anchor, errorType) ? "detected" : "no_crash_signature",
source,
device,
bundle,
process,
errorType,
errorMessage: detectErrorMessage(lines, anchor),
suspectedFile: detectSuspectedFile(topStack, bundle),
keywords: detectKeywords(focus || normalized),
topStack,
excerpt,
}
}
export const JscrashReportTool = Tool.define("jscrash_report", Effect.gen(function* () {
const Parameters = Schema.Struct({
crash_log: Schema.optional(Schema.String).annotate({
description: "Raw crash log text. If omitted, collect recent hilog content from the device.",
}),
device_id: Schema.optional(Schema.String).annotate({ description: "Optional hdc target id" }),
bundle_name: Schema.optional(Schema.String).annotate({ description: "Optional bundle name to help filter the report" }),
process_hint: Schema.optional(Schema.String).annotate({ description: "Optional process name hint to help filter the report" }),
lines: Schema.Int.pipe(
Schema.check(Schema.isGreaterThanOrEqualTo(200)),
Schema.check(Schema.isLessThanOrEqualTo(10000)),
Schema.optional,
Schema.withDecodingDefault(Effect.succeed(4000)),
).annotate({ description: "Number of log lines to analyze" }),
})
return {
description: DESCRIPTION,
parameters: Parameters,
execute: (args: Schema.Schema.Type<typeof Parameters>, _ctx: Tool.Context) =>
Effect.gen(function* () {
const rawLog = trim(String(args.crash_log || ""))
if (rawLog) {
const report = buildReport(rawLog, "provided_text", args.device_id || "default", args.bundle_name || "", args.process_hint || "")
return {
title: "Crash Report (from provided text)",
output: formatReport(report),
metadata: { status: report.status, errorType: report.errorType },
}
}
const home = yield* Effect.tryPromise(() => findDevEcoHome())
if (!home) {
return yield* Effect.fail(new Error("DevEco Studio path not found. Set DEVECO_HOME and retry, or pass crash_log directly."))
}
const hdc = hdcPath(home)
const hdcExists = yield* Effect.tryPromise(() => Bun.file(hdc).exists())
if (!hdcExists) {
return yield* Effect.fail(new Error(`hdc not found: ${hdc}`))
}
const out = yield* Effect.tryPromise(() => run([hdc, ...target(args.device_id), "shell", "hilog", "-x"]))
if (out.exitCode !== 0) {
return yield* Effect.fail(new Error(`hdc hilog -x failed (code=${out.exitCode}): ${out.stderr || out.stdout}`))
}
const logLines = cleanLines(out.stdout)
const lines = args.lines ?? 4000
const recent = logLines.slice(Math.max(0, logLines.length - lines)).join("\n")
const report = buildReport(recent, "device_hilog", args.device_id || "default", args.bundle_name || "", args.process_hint || "")
return {
title: "Crash Report (from device hilog)",
output: formatReport(report),
metadata: { status: report.status, errorType: report.errorType },
}
}).pipe(Effect.orDie),
}
}))