Skip to content

Commit be8bbe1

Browse files
kgilpinclaude
andcommitted
feat: Add markdown export for findings in Runtime Analysis
Add inline markdown icon to finding tree items that opens a comprehensive finding report as a markdown preview with clickable file links. Features: - Inline markdown icon on finding rows in Runtime Analysis sidebar - Opens finding synopsis as markdown preview (not text editor) - Workspace-relative file paths as clickable links with line numbers - Word-wrapped messages (~100 chars) in code blocks - Complete rule information (ID, description, documentation URL) - Stack trace with clickable links (filters out invalid line numbers) - Participating events with full SQL queries and method signatures - Impact domain, occurrence count, and references - Automatic line number validation (strips -1, 0, null values) Implementation: - New command: appmap.openFindingAsMarkdown - Command handler: src/commands/openFindingAsMarkdown.ts - Type definitions: src/types/rawEvent.ts for snake_case event fields - Updated resolvedFinding.ts to cast events to raw types - Updated findingsTreeDataProvider.ts to export FindingTreeItem class Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 626830c commit be8bbe1

6 files changed

Lines changed: 354 additions & 3 deletions

File tree

package.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,11 @@
169169
"command": "appmap.copyOutOfDateTestsToClipboard",
170170
"title": "AppMap: Copy Out-of-Date Tests to Clipboard"
171171
},
172+
{
173+
"command": "appmap.openFindingAsMarkdown",
174+
"title": "AppMap: Open Finding as Markdown",
175+
"icon": "$(markdown)"
176+
},
172177
{
173178
"command": "appmap.context.openInFileExplorer",
174179
"title": "AppMap View: Open in File Explorer"
@@ -568,6 +573,10 @@
568573
"command": "appmap.generateSequenceDiagramFiles",
569574
"when": "false"
570575
},
576+
{
577+
"command": "appmap.openFindingAsMarkdown",
578+
"when": "false"
579+
},
571580
{
572581
"command": "appmap.context.deleteAppMaps",
573582
"when": "false"
@@ -673,6 +682,11 @@
673682
{
674683
"command": "appmap.context.inspectCodeObject",
675684
"when": "config.appMap.inspectEnabled == true && ( viewItem == appmap.views.codeObjects.package || viewItem == appmap.views.codeObjects.class || viewItem == appmap.views.codeObjects.function || viewItem == appmap.views.codeObjects.query || viewItem == appmap.views.codeObjects.route )"
685+
},
686+
{
687+
"command": "appmap.openFindingAsMarkdown",
688+
"when": "view == appmap.views.findings && viewItem == appmap.views.analysis.finding",
689+
"group": "inline"
676690
}
677691
]
678692
}
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import * as vscode from 'vscode';
2+
import * as path from 'path';
3+
import { FindingTreeItem } from '../tree/findingsTreeDataProvider';
4+
import { RawEventData } from '../types/rawEvent';
5+
6+
const MAX_STACK_FRAMES = 5;
7+
const MESSAGE_WIDTH = 100;
8+
9+
function wordWrap(text: string, maxWidth: number): string {
10+
const lines: string[] = [];
11+
const paragraphs = text.split('\n');
12+
13+
for (const paragraph of paragraphs) {
14+
if (paragraph.trim().length === 0) {
15+
lines.push('');
16+
continue;
17+
}
18+
19+
const words = paragraph.split(/\s+/);
20+
let currentLine = '';
21+
22+
for (const word of words) {
23+
const testLine = currentLine.length === 0 ? word : `${currentLine} ${word}`;
24+
25+
if (testLine.length <= maxWidth) {
26+
currentLine = testLine;
27+
} else {
28+
if (currentLine.length > 0) {
29+
lines.push(currentLine);
30+
}
31+
currentLine = word;
32+
}
33+
}
34+
35+
if (currentLine.length > 0) {
36+
lines.push(currentLine);
37+
}
38+
}
39+
40+
return lines.join('\n');
41+
}
42+
43+
function createFileLink(stackFrame: string, workspaceFolder: vscode.WorkspaceFolder): string {
44+
// Parse format: "path/to/file.ts:42" or "path/to/file.ts" or "path/to/file.ts:-1"
45+
const match = stackFrame.match(/^(.+?)(?::(-?\d+))?$/);
46+
if (!match) return stackFrame;
47+
48+
const [, filePath, lineStr] = match;
49+
let lineNumber: number | null = null;
50+
51+
// Parse and validate line number
52+
if (lineStr) {
53+
const parsedLine = parseInt(lineStr, 10);
54+
// Only use line number if it's a positive integer (>= 1)
55+
if (parsedLine >= 1) {
56+
lineNumber = parsedLine;
57+
}
58+
}
59+
60+
// Resolve to absolute path first
61+
const absolutePath = path.isAbsolute(filePath)
62+
? filePath
63+
: path.join(workspaceFolder.uri.fsPath, filePath);
64+
65+
// Convert to workspace-relative path for both display and link
66+
const relativePath = vscode.workspace.asRelativePath(absolutePath);
67+
68+
// Create link URL (workspace-relative with line number fragment if present)
69+
const linkUrl = lineNumber ? `${relativePath}#L${lineNumber}` : relativePath;
70+
71+
// Create display text (with line number after colon if present)
72+
const displayText = lineNumber ? `${relativePath}:${lineNumber}` : relativePath;
73+
74+
return `[${displayText}](${linkUrl})`;
75+
}
76+
77+
function formatFindingSynopsis(
78+
findingTreeItem: FindingTreeItem,
79+
workspaceFolder: vscode.WorkspaceFolder
80+
): string {
81+
const resolvedFinding = findingTreeItem.finding;
82+
const finding = resolvedFinding.finding;
83+
84+
// Build synopsis in markdown format
85+
const parts: string[] = [];
86+
87+
// Header with rule title
88+
parts.push(`# ${finding.ruleTitle}`);
89+
parts.push('');
90+
91+
// Rule ID
92+
parts.push(`This AppMap matches the following analysis rule: \`${finding.ruleId}\``);
93+
parts.push('');
94+
95+
// Rule description
96+
if (resolvedFinding.rule.description) {
97+
parts.push(resolvedFinding.rule.description);
98+
parts.push('');
99+
}
100+
101+
// Impact domain and occurrence count
102+
const metadata: string[] = [];
103+
if (resolvedFinding.impactDomain) {
104+
metadata.push(`**Impact Domain**: ${resolvedFinding.impactDomain}`);
105+
}
106+
if (finding.occurranceCount && finding.occurranceCount > 1) {
107+
metadata.push(`**Occurrences**: ${finding.occurranceCount}`);
108+
}
109+
if (metadata.length > 0) {
110+
parts.push(metadata.join(' | '));
111+
parts.push('');
112+
}
113+
114+
// Message (word-wrapped in code block)
115+
parts.push(`## Finding Details`);
116+
parts.push('```');
117+
parts.push(wordWrap(finding.message, MESSAGE_WIDTH));
118+
parts.push('```');
119+
parts.push('');
120+
121+
// Group message if different from main message
122+
if (finding.groupMessage && finding.groupMessage !== finding.message) {
123+
parts.push(`## Group Summary`);
124+
parts.push('```');
125+
parts.push(wordWrap(finding.groupMessage, MESSAGE_WIDTH));
126+
parts.push('```');
127+
parts.push('');
128+
}
129+
130+
// Source location
131+
if (resolvedFinding.locationLabel) {
132+
parts.push(`## Source Location`);
133+
parts.push(createFileLink(resolvedFinding.locationLabel, workspaceFolder));
134+
parts.push('');
135+
}
136+
137+
// Stack trace (top N frames)
138+
if (finding.stack && finding.stack.length > 0) {
139+
parts.push(`## Stack Trace`);
140+
const stackFrames = finding.stack.slice(0, MAX_STACK_FRAMES);
141+
stackFrames.forEach((frame) => {
142+
parts.push(`- ${createFileLink(frame, workspaceFolder)}`);
143+
});
144+
if (finding.stack.length > MAX_STACK_FRAMES) {
145+
parts.push(`- ... (${finding.stack.length - MAX_STACK_FRAMES} more frames)`);
146+
}
147+
parts.push('');
148+
}
149+
150+
// Participating events if available
151+
// Note: participatingEvents is incorrectly typed as Event in @appland/scanner,
152+
// but actually contains raw JSON data with snake_case properties
153+
if (finding.participatingEvents && Object.keys(finding.participatingEvents).length > 0) {
154+
const rawEvents = finding.participatingEvents as unknown as Record<string, RawEventData>;
155+
parts.push(`## Code Event Context`);
156+
for (const [name, rawEvent] of Object.entries(rawEvents)) {
157+
const event: RawEventData = rawEvent;
158+
// Add event type/method if available
159+
if (event.http_server_request) {
160+
const req = event.http_server_request;
161+
const normalizedPath =
162+
req.normalized_path_info && req.normalized_path_info !== req.path_info
163+
? ` (${req.normalized_path_info})`
164+
: '';
165+
parts.push(`- **${name}**: ${req.request_method} ${req.path_info}${normalizedPath}`);
166+
} else if (event.sql_query) {
167+
parts.push(`- **${name}** (SQL):`);
168+
parts.push(' ```sql');
169+
parts.push(` ${event.sql_query.sql || 'N/A'}`);
170+
parts.push(' ```');
171+
if (event.sql_query.database_type) {
172+
parts.push(` Database: ${event.sql_query.database_type}`);
173+
}
174+
} else {
175+
const location = [event.defined_class, event.static ? '.' : '#', event.method_id]
176+
.filter(Boolean)
177+
.join('');
178+
parts.push(`- **${name}**: ${location}`);
179+
}
180+
}
181+
parts.push('');
182+
}
183+
184+
// AppMap file (workspace-relative path as clickable link)
185+
const workspaceRelativePath = vscode.workspace.asRelativePath(resolvedFinding.appMapUri.fsPath);
186+
parts.push(`## AppMap`);
187+
parts.push(`[${workspaceRelativePath}](${workspaceRelativePath})`);
188+
parts.push('');
189+
190+
// References
191+
parts.push(`## References`);
192+
if (resolvedFinding.rule.references && Object.keys(resolvedFinding.rule.references).length > 0) {
193+
parts.push(`- ${resolvedFinding.rule.url}`);
194+
for (const [name, url] of Object.entries(resolvedFinding.rule.references)) {
195+
parts.push(`- [${name}](${url.toString()})`);
196+
}
197+
parts.push('');
198+
}
199+
200+
return parts.join('\n');
201+
}
202+
203+
export default function registerCommand(context: vscode.ExtensionContext): void {
204+
context.subscriptions.push(
205+
vscode.commands.registerCommand(
206+
'appmap.openFindingAsMarkdown',
207+
async (findingTreeItem: FindingTreeItem) => {
208+
try {
209+
// Get workspace folder for resolving relative paths
210+
const workspaceFolder = findingTreeItem.finding.folder;
211+
const synopsis = formatFindingSynopsis(findingTreeItem, workspaceFolder);
212+
213+
// Create untitled markdown document
214+
const document = await vscode.workspace.openTextDocument({
215+
content: synopsis,
216+
language: 'markdown',
217+
});
218+
219+
// Show the document first (required for preview command to work)
220+
await vscode.window.showTextDocument(document);
221+
222+
// Then open the markdown preview
223+
await vscode.commands.executeCommand('markdown.showPreview', document.uri);
224+
} catch (error) {
225+
const errorMessage = error instanceof Error ? error.message : String(error);
226+
vscode.window.showErrorMessage(`Failed to open finding as markdown: ${errorMessage}`);
227+
console.error('Open finding as markdown error:', error);
228+
}
229+
}
230+
)
231+
);
232+
}

src/extension.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import FindingInfoWebview from './webviews/findingInfoWebview';
4747
import { AppMapRecommenderService } from './services/appmapRecommenderService';
4848
import openCodeObjectInSource from './commands/openCodeObjectInSource';
4949
import learnMoreRuntimeAnalysis from './commands/learnMoreRuntimeAnalysis';
50+
import openFindingAsMarkdown from './commands/openFindingAsMarkdown';
5051
import ReviewWebview from './webviews/reviewWebview';
5152
import SignInViewProvider from './webviews/signInWebview';
5253
import SignInManager from './services/signInManager';
@@ -237,6 +238,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<AppMap
237238

238239
FindingsOverviewWebview.register(context);
239240
FindingInfoWebview.register(context);
241+
openFindingAsMarkdown(context);
240242

241243
const processService = new NodeProcessService(context);
242244

src/services/resolvedFinding.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import present from '../lib/present';
55
import ValueCache from '../lib/ValueCache';
66
import assert from 'assert';
77
import { warn } from 'console';
8+
import { RawEventData } from '../types/rawEvent';
89

910
class StackFrameIndex {
1011
locationByFrame = new Map<string, vscode.Location>();
@@ -187,13 +188,17 @@ export class ResolvedFinding {
187188
const filePath = filePaths.find(Boolean);
188189
if (!filePath) return;
189190

191+
// Note: finding.event and finding.relatedEvents are incorrectly typed as Event
192+
// in @appland/scanner, but actually contain raw JSON data with snake_case properties
193+
const rawEvent = finding.event as unknown as RawEventData;
190194
const state = {
191195
currentView: 'viewFlow',
192-
selectedObject: `event:${finding.event.id}`,
196+
selectedObject: `event:${rawEvent.id}`,
193197
} as any; // eslint-disable-line @typescript-eslint/no-explicit-any
194198

195199
if (finding.relatedEvents) {
196-
state.traceFilter = finding.relatedEvents.map((evt) => ['id', evt.id].join(':')).join(' ');
200+
const rawRelatedEvents = finding.relatedEvents as unknown as RawEventData[];
201+
state.traceFilter = rawRelatedEvents.map((evt) => ['id', evt.id].join(':')).join(' ');
197202
}
198203

199204
const uri = vscode.Uri.file(filePath);

src/tree/findingsTreeDataProvider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ class RuleTreeItem extends vscode.TreeItem {
109109
}
110110
}
111111

112-
class FindingTreeItem extends vscode.TreeItem {
112+
export class FindingTreeItem extends vscode.TreeItem {
113113
constructor(public ruleTreeItem: RuleTreeItem, public finding: ResolvedFinding) {
114114
super(FindingTreeItem.label(finding));
115115

0 commit comments

Comments
 (0)