import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import type { StructuredItem } from "./types.ts"; import { listValue, loadEnv, loadStructuredItems, scalar } from "./fs.ts"; export type LogSourceName = "backend" | "frontend" | "console"; export type FindingSeverity = | "fail" | "warning" | "matched_troubleshooting" | "env_issue" | "ignored_expected_issue" | "missing_input"; export type LogFinding = { source: LogSourceName; path: string; severity: FindingSeverity; kind: string; pattern: string; line?: number; excerpt?: string; troubleshooting_id?: string; troubleshooting_title?: string; related_to_case?: boolean; }; export type LogLine = { number: number; text: string; }; export type LogSuccessSignal = { source: LogSourceName; path: string; kind: "case_success_pattern"; pattern: string; line?: number; excerpt?: string; }; export type LogScanMode = | "whole-file" | "since" | "until" | "since+until" | "tail-lines" | "since+tail-lines" | "until+tail-lines" | "since+until+tail-lines"; export type LogScanConfig = { mode: LogScanMode; since?: string; since_epoch_ms?: number; until?: string; until_epoch_ms?: number; tail_lines?: number; warnings: string[]; }; export type LogSourceSummary = { source: LogSourceName; path: string; status: "scanned" | "missing" | "auto_not_found"; line_count: number; total_line_count?: number; start_line?: number; end_line?: number; timestamped_line_count?: number; auto_detected?: boolean; }; export type LogGuardPatternContext = { successPatterns?: string[]; failurePatterns?: string[]; expectedFailures?: string[]; relatedTroubleshootingIds?: string[]; }; export type LogGuardResult = { status: string; scan: LogScanConfig; sources: LogSourceSummary[]; success_signals: LogSuccessSignal[]; findings: LogFinding[]; }; export type AutomationResultEvidence = { status: "not_provided" | "missing" | "invalid" | "loaded"; path?: string; result?: string; reason?: string; duration_ms?: number; started_at?: string; started_at_local?: string; finished_at?: string; finished_at_local?: string; url?: string; prompt?: string; expected_text?: string; metrics_summary?: Record; thresholds_summary?: Record; artifacts?: Record; }; type MutableScanState = { findings: LogFinding[]; successSignals: LogSuccessSignal[]; seenFindings: Set; seenSuccessSignals: Set; }; const secretAssignmentRe = /\b(api[_-]?key|authorization|credential|jwt|oauth|password|secret|token)\s*[:=]\s*["']?([^"',\s]+)/gi; const bearerSecretRe = /\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/i; const openAiStyleSecretRe = /\bsk-[A-Za-z0-9_-]{6,}\b/i; const unexpectedPatterns: Array<{ kind: string; pattern: string; regex: RegExp; severity: FindingSeverity; sources?: LogSourceName[]; }> = [ { kind: "python_traceback", pattern: "Traceback", regex: /\bTraceback(?: \(most recent call last\))?/i, severity: "fail" }, { kind: "unretrieved_task_exception", pattern: "Task exception was never retrieved", regex: /Task exception was never retrieved/i, severity: "fail", }, { kind: "unawaited_coroutine", pattern: "RuntimeWarning: coroutine .* was never awaited", regex: /RuntimeWarning:\s+coroutine .* was never awaited/i, severity: "fail", }, { kind: "unclosed_client_session", pattern: "Unclosed client session", regex: /Unclosed client session/i, severity: "fail", }, { kind: "unclosed_connector", pattern: "Unclosed connector", regex: /Unclosed connector/i, severity: "fail" }, { kind: "key_error", pattern: "KeyError", regex: /(^|[^A-Za-z])KeyError(?:\b|:)/, severity: "fail" }, { kind: "type_error", pattern: "TypeError", regex: /(^|[^A-Za-z])TypeError(?:\b|:)/, severity: "fail" }, { kind: "attribute_error", pattern: "AttributeError", regex: /(^|[^A-Za-z])AttributeError(?:\b|:)/, severity: "fail", }, { kind: "frontend_uncaught_error", pattern: "Uncaught frontend error", regex: /\bUncaught (?:[A-Za-z]*Error|Exception)|Unhandled(?: promise rejection|Rejection)/i, severity: "fail", sources: ["console", "frontend"], }, { kind: "http_5xx", pattern: "HTTP 5xx resource failure", regex: /Failed to load resource: the server responded with a status of 5\d\d|HTTP\/\d(?:\.\d)?\s+5\d\d/i, severity: "fail", }, { kind: "error_log", pattern: "ERROR or CRITICAL log line", regex: /\b(?:ERROR|CRITICAL)\b/, severity: "warning" }, ]; export function logPatternContextFromStructuredItem(item: StructuredItem): LogGuardPatternContext { return { successPatterns: listValue(item.fields, "success_patterns"), failurePatterns: listValue(item.fields, "failure_patterns"), expectedFailures: listValue(item.fields, "expected_failures"), relatedTroubleshootingIds: listValue(item.fields, "troubleshooting"), }; } export function scanStructuredLogSources( root: string, item: StructuredItem, options: Record, ): LogGuardResult { return scanLogSources(root, options, logPatternContextFromStructuredItem(item)); } export function scanLogSources( root: string, options: Record, context: LogGuardPatternContext = {}, ): LogGuardResult { const env = loadEnv(root); const scan = parseScanConfig(optionsWithEvidenceWindow(options)); const configuredSources: Array<{ source: LogSourceName; option: string }> = [ { source: "backend", option: "backend-log" }, { source: "frontend", option: "frontend-log" }, { source: "console", option: "console-log" }, ]; const sources: LogSourceSummary[] = []; const state: MutableScanState = { findings: [], successSignals: [], seenFindings: new Set(), seenSuccessSignals: new Set(), }; for (const warning of scan.warnings) { addFinding(state, { source: "backend", path: "log-scan-options", severity: "missing_input", kind: "invalid_log_scan_option", pattern: warning, }); } for (const configured of configuredSources) { const explicitPath = options[configured.option]; const autoPath = configured.source === "backend" && options["no-auto-log"] !== true ? latestLangBotLogPath(env) : null; const rawPath = typeof explicitPath === "string" ? explicitPath : autoPath; const autoDetected = typeof explicitPath !== "string" && rawPath === autoPath; if (!rawPath) { if (configured.source === "backend" && options["no-auto-log"] !== true) { const logsDir = env.LANGBOT_REPO ? join(env.LANGBOT_REPO, "data", "logs") : "LANGBOT_REPO/data/logs"; sources.push({ source: "backend", path: join(logsDir, "langbot-*.log"), status: "auto_not_found", line_count: 0, auto_detected: true }); } continue; } const path = resolve(rawPath); if (!existsSync(path)) { sources.push({ source: configured.source, path, status: "missing", line_count: 0 }); if (!autoDetected) { addFinding(state, { source: configured.source, path, severity: "missing_input", kind: "missing_log_file", pattern: `${configured.option} path does not exist`, }); } continue; } const text = readFileSync(path, "utf8"); scanLogTextIntoState(root, configured.source, path, text, scan, context, sources, state); } finalizeMissingSuccessSignal(context, sources, state); return buildLogGuardResult(scan, sources, state); } export function scanLogText( root: string, source: LogSourceName, path: string, text: string, options: Record = {}, context: LogGuardPatternContext = {}, baseLineNumber = 0, includeMissingSuccessSignal = true, ): LogGuardResult { const scan = parseScanConfig(options); const sources: LogSourceSummary[] = []; const state: MutableScanState = { findings: [], successSignals: [], seenFindings: new Set(), seenSuccessSignals: new Set(), }; scanLogTextIntoState(root, source, resolve(path), text, scan, context, sources, state, baseLineNumber); if (includeMissingSuccessSignal) finalizeMissingSuccessSignal(context, sources, state); return buildLogGuardResult(scan, sources, state); } function scanLogTextIntoState( root: string, source: LogSourceName, path: string, text: string, scan: LogScanConfig, context: LogGuardPatternContext, sources: LogSourceSummary[], state: MutableScanState, baseLineNumber = 0, ): void { const allLines = text.split(/\r?\n/).map((line, index) => ({ number: baseLineNumber + index + 1, text: line })); const selected = selectLinesForScan(allLines, scan); sources.push({ source, path, status: "scanned", line_count: selected.lines.length, total_line_count: allLines.length, start_line: selected.lines[0]?.number, end_line: selected.lines[selected.lines.length - 1]?.number, timestamped_line_count: selected.timestampedLineCount, }); scanUnexpectedPatterns(state, source, path, selected.lines, context.expectedFailures ?? []); scanCaseDeclaredPatterns( state, source, path, selected.lines, context.successPatterns ?? [], context.failurePatterns ?? [], context.expectedFailures ?? [], ); scanTroubleshootingPatterns( state, source, path, selected.lines, loadStructuredItems(root, "troubleshooting"), new Set(context.relatedTroubleshootingIds ?? []), context.expectedFailures ?? [], ); } function buildLogGuardResult(scan: LogScanConfig, sources: LogSourceSummary[], state: MutableScanState): LogGuardResult { const scannedCount = sources.filter((source) => source.status === "scanned").length; const status = scannedCount === 0 && state.findings.length === 0 ? "not_run" : state.findings.some((finding) => finding.severity === "fail" || finding.severity === "missing_input") ? "fail" : state.findings.some((finding) => finding.severity === "matched_troubleshooting" && finding.related_to_case !== false) ? "fail" : state.findings.some((finding) => finding.severity === "env_issue") ? "env_issue" : state.findings.some((finding) => finding.severity === "warning") ? "warning" : "pass"; return { status, scan, sources, success_signals: state.successSignals, findings: state.findings }; } function finalizeMissingSuccessSignal( context: LogGuardPatternContext, sources: LogSourceSummary[], state: MutableScanState, ): void { const scannedCount = sources.filter((source) => source.status === "scanned").length; const successPatterns = context.successPatterns ?? []; if (scannedCount > 0 && successPatterns.length > 0 && state.successSignals.length === 0) { addFinding(state, { source: "backend", path: "case-success-patterns", severity: "warning", kind: "missing_success_signal", pattern: successPatterns.join(" | "), excerpt: "No declared success_patterns matched the scanned log window.", }); } } function shouldTreatAssignmentValueAsSecret(value: string): boolean { const normalized = value.trim().replace(/^["']|["']$/g, ""); const lower = normalized.toLowerCase(); if (!normalized) return false; if (["error", "invalid", "missing", "none", "null", "undefined", "redacted", "[redacted]"].includes(lower)) { return false; } if (/^(error|invalid|missing|none|null|undefined)\b/i.test(normalized)) return false; if (/^(your-|<|\$\{|example-|placeholder)/i.test(normalized)) return false; if (openAiStyleSecretRe.test(normalized)) return true; return normalized.length >= 8 && /[A-Za-z0-9]/.test(normalized); } function redactSecretAssignments(text: string): string { return text.replace(secretAssignmentRe, (match, key: string, value: string) => { if (!shouldTreatAssignmentValueAsSecret(value)) return match; return match.replace(value, "[redacted]"); }); } export function redactSecrets(text: string): string { return redactSecretAssignments(text .replace(/(\bauthorization\s*[:=]\s*bearer\s+)[A-Za-z0-9._~+/=-]+/gi, "$1[redacted]") .replace(/\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]") .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, "[redacted]")); } function hasSecretLeak(line: string): boolean { secretAssignmentRe.lastIndex = 0; const hasSecretAssignment = Array.from(line.matchAll(secretAssignmentRe)) .some((match) => shouldTreatAssignmentValueAsSecret(match[2] ?? "")); return hasSecretAssignment || bearerSecretRe.test(line) || openAiStyleSecretRe.test(line); } function findingKey(finding: LogFinding): string { return [ finding.source, finding.path, finding.kind, finding.pattern, finding.line ?? "", finding.troubleshooting_id ?? "", ].join("\0"); } function addFinding(state: MutableScanState, finding: LogFinding): void { const key = findingKey(finding); if (state.seenFindings.has(key)) return; state.seenFindings.add(key); state.findings.push(finding); } function successSignalKey(signal: LogSuccessSignal): string { return [signal.source, signal.path, signal.pattern, signal.line ?? ""].join("\0"); } function addSuccessSignal(state: MutableScanState, signal: LogSuccessSignal): void { const key = successSignalKey(signal); if (state.seenSuccessSignals.has(key)) return; state.seenSuccessSignals.add(key); state.successSignals.push(signal); } function isExpectedFinding(finding: LogFinding, expectedFailures: string[]): boolean { if (finding.kind === "secret_leak" || finding.severity === "missing_input") return false; const haystack = [ finding.kind, finding.pattern, finding.troubleshooting_id ?? "", finding.troubleshooting_title ?? "", finding.excerpt ?? "", ].join("\n").toLowerCase(); return expectedFailures.some((item) => item && haystack.includes(item.toLowerCase())); } function withExpectedSeverity(finding: LogFinding, expectedFailures: string[]): LogFinding { if (!isExpectedFinding(finding, expectedFailures)) return finding; return { ...finding, severity: "ignored_expected_issue" }; } function scanUnexpectedPatterns( state: MutableScanState, source: LogSourceName, path: string, lines: LogLine[], expectedFailures: string[], ): void { for (const line of lines) { for (const pattern of unexpectedPatterns) { if (pattern.sources && !pattern.sources.includes(source)) continue; if (!pattern.regex.test(line.text)) continue; addFinding(state, withExpectedSeverity({ source, path, severity: pattern.severity, kind: pattern.kind, pattern: pattern.pattern, line: line.number, excerpt: redactSecrets(line.text.trim()), }, expectedFailures)); } if (hasSecretLeak(line.text)) { addFinding(state, { source, path, severity: "fail", kind: "secret_leak", pattern: "secret-like value in logs", line: line.number, excerpt: redactSecrets(line.text.trim()), }); } } } function scanTroubleshootingPatterns( state: MutableScanState, source: LogSourceName, path: string, lines: LogLine[], troubles: StructuredItem[], relatedIds: Set, expectedFailures: string[], ): void { for (const entry of troubles) { const id = scalar(entry.fields, "id"); const title = scalar(entry.fields, "title"); const category = scalar(entry.fields, "category"); for (const pattern of listValue(entry.fields, "patterns")) { const needle = pattern.toLowerCase(); if (!needle) continue; let matchesForPattern = 0; for (const line of lines) { if (!line.text.toLowerCase().includes(needle)) continue; if (id === "plugin-runtime-timeout" && isModelRouteUnavailableText(line.text)) continue; addFinding(state, withExpectedSeverity({ source, path, severity: category === "env_issue" ? "env_issue" : "matched_troubleshooting", kind: "troubleshooting_pattern", pattern, line: line.number, excerpt: redactSecrets(line.text.trim()), troubleshooting_id: id, troubleshooting_title: title, related_to_case: relatedIds.has(id), }, expectedFailures)); matchesForPattern += 1; if (matchesForPattern >= 3) break; } } } } function isModelRouteUnavailableText(text: string): boolean { return /model_not_found|no available channel for model|invalid api key|当前分组上游负载已饱和/i.test(text); } function scanCaseDeclaredPatterns( state: MutableScanState, source: LogSourceName, path: string, lines: LogLine[], successPatterns: string[], failurePatterns: string[], expectedFailures: string[], ): void { for (const pattern of successPatterns) { const needle = pattern.toLowerCase(); if (!needle) continue; let matchesForPattern = 0; for (const line of lines) { if (!line.text.toLowerCase().includes(needle)) continue; addSuccessSignal(state, { source, path, kind: "case_success_pattern", pattern, line: line.number, excerpt: redactSecrets(line.text.trim()), }); matchesForPattern += 1; if (matchesForPattern >= 3) break; } } for (const pattern of failurePatterns) { const needle = pattern.toLowerCase(); if (!needle) continue; let matchesForPattern = 0; for (const line of lines) { if (!line.text.toLowerCase().includes(needle)) continue; addFinding(state, withExpectedSeverity({ source, path, severity: "fail", kind: "case_failure_pattern", pattern, line: line.number, excerpt: redactSecrets(line.text.trim()), }, expectedFailures)); matchesForPattern += 1; if (matchesForPattern >= 3) break; } } } function optionsWithEvidenceWindow(options: Record): Record { if (typeof options.since === "string" && typeof options.until === "string") { return options; } const evidenceDir = evidenceDirFromOptions(options); if (!evidenceDir) return options; const resultPath = automationResultPath(evidenceDir); if (!existsSync(resultPath)) return options; try { const result = JSON.parse(readFileSync(resultPath, "utf8")) as Record; const enriched = { ...options }; const startedAt = stringField(result, "started_at_local") ?? stringField(result, "started_at"); const finishedAt = stringField(result, "finished_at_local") ?? stringField(result, "finished_at"); if (typeof enriched.since !== "string" && startedAt) { enriched.since = startedAt; } if (typeof enriched.until !== "string" && finishedAt) { enriched.until = finishedAt; } return enriched; } catch { return options; } } function stringField(data: Record, key: string): string | undefined { const value = data[key]; return typeof value === "string" && value.trim() ? value : undefined; } function numberField(data: Record, key: string): number | undefined { const value = data[key]; return typeof value === "number" && Number.isFinite(value) ? value : undefined; } function objectField(data: Record, key: string): Record | undefined { const value = data[key]; return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; } function evidenceDirFromOptions(options: Record): string | undefined { const explicit = typeof options["evidence-dir"] === "string" ? options["evidence-dir"] : undefined; if (explicit) return resolve(explicit); const consoleLog = typeof options["console-log"] === "string" ? options["console-log"] : undefined; return consoleLog ? dirname(resolve(consoleLog)) : undefined; } function automationResultPath(evidenceDir: string): string { const primary = join(evidenceDir, "automation-result.json"); if (existsSync(primary)) return primary; return join(evidenceDir, "result.json"); } export function readAutomationResultEvidence(options: Record): AutomationResultEvidence { const evidenceDir = evidenceDirFromOptions(options); if (!evidenceDir) return { status: "not_provided" }; const resultPath = automationResultPath(evidenceDir); if (!existsSync(resultPath)) return { status: "missing", path: resultPath }; try { const result = JSON.parse(readFileSync(resultPath, "utf8")) as Record; if (result.source === "final") { return { status: "not_provided", path: resultPath, reason: "only final result.json is present; automation-result.json was not found", }; } return { status: "loaded", path: resultPath, result: stringField(result, "status"), reason: stringField(result, "reason"), duration_ms: numberField(result, "duration_ms"), started_at: stringField(result, "started_at"), started_at_local: stringField(result, "started_at_local"), finished_at: stringField(result, "finished_at"), finished_at_local: stringField(result, "finished_at_local"), url: stringField(result, "url"), prompt: redactSecrets(stringField(result, "prompt") ?? ""), expected_text: stringField(result, "expected_text"), metrics_summary: objectField(result, "metrics_summary"), thresholds_summary: objectField(result, "thresholds_summary"), artifacts: objectField(result, "artifacts"), }; } catch (error) { return { status: "invalid", path: resultPath, reason: String(error) }; } } export function latestLangBotLogPath(env: Record): string | null { const repo = env.LANGBOT_REPO; if (!repo) return null; const logsDir = join(repo, "data", "logs"); if (!existsSync(logsDir)) return null; const candidates = readdirSync(logsDir) .filter((name) => /^langbot-.*\.log$/.test(name)) .map((name) => join(logsDir, name)) .filter((path) => { try { return statSync(path).isFile(); } catch { return false; } }) .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs); return candidates[0] ?? null; } export function parseScanConfig(options: Record): LogScanConfig { const warnings: string[] = []; const sinceInput = typeof options.since === "string" ? options.since : undefined; const sinceMs = sinceInput ? Date.parse(sinceInput) : undefined; const untilInput = typeof options.until === "string" ? options.until : undefined; const untilMs = untilInput ? Date.parse(untilInput) : undefined; const tailInput = typeof options["tail-lines"] === "string" ? options["tail-lines"] : undefined; let tailLines: number | undefined; if (sinceInput && Number.isNaN(sinceMs)) { warnings.push(`--since is not a valid date/time: ${sinceInput}`); } if (untilInput && Number.isNaN(untilMs)) { warnings.push(`--until is not a valid date/time: ${untilInput}`); } if (tailInput) { const parsed = Number.parseInt(tailInput, 10); if (!/^\d+$/.test(tailInput) || parsed <= 0) { warnings.push(`--tail-lines must be a positive integer: ${tailInput}`); } else { tailLines = parsed; } } const hasSince = sinceInput !== undefined && sinceMs !== undefined && !Number.isNaN(sinceMs); const hasUntil = untilInput !== undefined && untilMs !== undefined && !Number.isNaN(untilMs); const hasTail = tailLines !== undefined; let mode: LogScanMode = "whole-file"; if (hasSince && hasUntil && hasTail) { mode = "since+until+tail-lines"; } else if (hasSince && hasUntil) { mode = "since+until"; } else if (hasSince && hasTail) { mode = "since+tail-lines"; } else if (hasUntil && hasTail) { mode = "until+tail-lines"; } else if (hasSince) { mode = "since"; } else if (hasUntil) { mode = "until"; } else if (hasTail) { mode = "tail-lines"; } return { mode, since: hasSince ? sinceInput : undefined, since_epoch_ms: hasSince ? sinceMs : undefined, until: hasUntil ? untilInput : undefined, until_epoch_ms: hasUntil ? untilMs : undefined, tail_lines: tailLines, warnings, }; } function selectLinesForScan(lines: LogLine[], scan: LogScanConfig): { lines: LogLine[]; timestampedLineCount: number } { let selected = lines; let timestampedLineCount = 0; if (scan.since_epoch_ms !== undefined || scan.until_epoch_ms !== undefined) { const offsetMinutes = timezoneOffsetMinutes(scan.since) ?? timezoneOffsetMinutes(scan.until) ?? -new Date(scan.since_epoch_ms ?? scan.until_epoch_ms ?? Date.now()).getTimezoneOffset(); const yearHint = new Date(scan.since_epoch_ms ?? scan.until_epoch_ms ?? Date.now()).getUTCFullYear(); let includeCurrentBlock = false; const filtered = lines.filter((line) => { const timestamp = parseLogLineTimestampMs(line.text, yearHint, offsetMinutes); if (timestamp !== null) { timestampedLineCount += 1; includeCurrentBlock = (scan.since_epoch_ms === undefined || timestamp >= scan.since_epoch_ms) && (scan.until_epoch_ms === undefined || timestamp <= scan.until_epoch_ms); return includeCurrentBlock; } return includeCurrentBlock; }); selected = timestampedLineCount === 0 ? lines : filtered; } else { timestampedLineCount = lines.reduce((count, line) => ( parseLogLineTimestampMs(line.text, new Date().getFullYear(), -new Date().getTimezoneOffset()) === null ? count : count + 1 ), 0); } if (scan.tail_lines !== undefined && selected.length > scan.tail_lines) { selected = selected.slice(-scan.tail_lines); } return { lines: selected, timestampedLineCount }; } function timezoneOffsetMinutes(input: string | undefined): number | null { if (!input) return null; if (/[zZ]$/.test(input)) return 0; const match = input.match(/([+-])(\d{2}):?(\d{2})$/); if (!match) return null; const sign = match[1] === "-" ? -1 : 1; return sign * (Number.parseInt(match[2], 10) * 60 + Number.parseInt(match[3], 10)); } function parseLogLineTimestampMs(line: string, yearHint: number, offsetMinutes: number): number | null { const fullIso = line.match(/^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:?\d{2})?)\]?/); if (fullIso) { const timestamp = Date.parse(fullIso[1].replace(" ", "T")); return Number.isNaN(timestamp) ? null : timestamp; } const langBot = line.match(/^\[(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})\.(\d{3})\]/); if (!langBot) return null; const [, month, day, hour, minute, second, millisecond] = langBot; return Date.UTC( yearHint, Number.parseInt(month, 10) - 1, Number.parseInt(day, 10), Number.parseInt(hour, 10), Number.parseInt(minute, 10), Number.parseInt(second, 10), Number.parseInt(millisecond, 10), ) - offsetMinutes * 60 * 1000; } export function renderLogFinding(finding: LogFinding): string { const location = finding.line ? `${finding.source}:${finding.line}` : finding.source; const trouble = finding.troubleshooting_id ? ` (${finding.troubleshooting_id})` : ""; const related = finding.related_to_case === true ? ", related" : ""; const excerpt = finding.excerpt ? ` - ${finding.excerpt}` : ""; return `- [${finding.severity}] ${location}: ${finding.kind}${trouble}${related}; pattern: ${finding.pattern}${excerpt}`; } export function renderLogSuccessSignal(signal: LogSuccessSignal): string { const location = signal.line ? `${signal.source}:${signal.line}` : signal.source; const excerpt = signal.excerpt ? ` - ${signal.excerpt}` : ""; return `- ${location}: ${signal.pattern}${excerpt}`; } export function strictLogGuardExitCode(result: LogGuardResult): number { return result.status === "fail" || result.status === "env_issue" ? 1 : 0; }