From ddb7241eaccf83b61cbcf5c3bbf66308b9497a07 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 11 Oct 2023 19:57:51 +0000 Subject: [PATCH 01/12] chore(release): 0.102.0 [skip ci] # [0.102.0](https://github.com/getappmap/vscode-appland/compare/v0.101.2...v0.102.0) (2023-10-11) ### Bug Fixes * AppMaps displayed chronologically for Java requests ([#813](https://github.com/getappmap/vscode-appland/issues/813)) ([4f8e730](https://github.com/getappmap/vscode-appland/commit/4f8e730be089000d8c2eb3e28b1226f39efaf0e4)) * Instructions page should be reactive to the Java agent file ([3bfbcb6](https://github.com/getappmap/vscode-appland/commit/3bfbcb602e53e19285e902a56e427c7f07fe2459)) ### Features * Accurately determine if Runtime Analysis step is complete ([#829](https://github.com/getappmap/vscode-appland/issues/829)) ([0dd1fa2](https://github.com/getappmap/vscode-appland/commit/0dd1fa2d61e5b7923c67bf0d1e051b2074df3120)) * The Code Objects view also lists External Service Calls ([d9b9c32](https://github.com/getappmap/vscode-appland/commit/d9b9c325edb40791d94353ccd3541baab1d241fa)) * Update [@appland](https://github.com/appland) dependencies ([d696a53](https://github.com/getappmap/vscode-appland/commit/d696a5388d158f827c97caf5db4242340d8b44eb)) * update @appland/components to 3.8.0 ([8b66898](https://github.com/getappmap/vscode-appland/commit/8b668983fe30602f5960836fe1d1120d6b28b31f)) * **deleteAppMaps.ts:** add closeEditorByUri function after deleting appmaps ([2a54b8c](https://github.com/getappmap/vscode-appland/commit/2a54b8cde5c5b0b5894185cfe63f78c0cd539e4e)) --- CHANGELOG.md | 17 +++++++++++++++++ package.json | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e61b5fea..b3574069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +# [0.102.0](https://github.com/getappmap/vscode-appland/compare/v0.101.2...v0.102.0) (2023-10-11) + + +### Bug Fixes + +* AppMaps displayed chronologically for Java requests ([#813](https://github.com/getappmap/vscode-appland/issues/813)) ([4f8e730](https://github.com/getappmap/vscode-appland/commit/4f8e730be089000d8c2eb3e28b1226f39efaf0e4)) +* Instructions page should be reactive to the Java agent file ([3bfbcb6](https://github.com/getappmap/vscode-appland/commit/3bfbcb602e53e19285e902a56e427c7f07fe2459)) + + +### Features + +* Accurately determine if Runtime Analysis step is complete ([#829](https://github.com/getappmap/vscode-appland/issues/829)) ([0dd1fa2](https://github.com/getappmap/vscode-appland/commit/0dd1fa2d61e5b7923c67bf0d1e051b2074df3120)) +* The Code Objects view also lists External Service Calls ([d9b9c32](https://github.com/getappmap/vscode-appland/commit/d9b9c325edb40791d94353ccd3541baab1d241fa)) +* Update [@appland](https://github.com/appland) dependencies ([d696a53](https://github.com/getappmap/vscode-appland/commit/d696a5388d158f827c97caf5db4242340d8b44eb)) +* update @appland/components to 3.8.0 ([8b66898](https://github.com/getappmap/vscode-appland/commit/8b668983fe30602f5960836fe1d1120d6b28b31f)) +* **deleteAppMaps.ts:** add closeEditorByUri function after deleting appmaps ([2a54b8c](https://github.com/getappmap/vscode-appland/commit/2a54b8cde5c5b0b5894185cfe63f78c0cd539e4e)) + ## [0.101.2](https://github.com/getappmap/vscode-appland/compare/v0.101.1...v0.101.2) (2023-09-21) diff --git a/package.json b/package.json index 2088242f..48997bd3 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "name": "appmap", "displayName": "AppMap", "description": "Interactive maps of runtime code behavior", - "version": "0.101.2", + "version": "0.102.0", "repository": { "type": "git", "url": "https://github.com/getappmap/vscode-appland" From 021e14851d073589e41935af3f17464a9d7626ef Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 31 Oct 2023 16:24:55 +0000 Subject: [PATCH 02/12] chore(release): 0.103.0 [skip ci] # [0.103.0](https://github.com/getappmap/vscode-appland/compare/v0.102.0...v0.103.0) (2023-10-31) ### Features * Update @appland/components to 3.9.0 ([fa2cc6e](https://github.com/getappmap/vscode-appland/commit/fa2cc6e56f96b13225d1a865a3972a4495eecf28)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3574069..be7c57f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [0.103.0](https://github.com/getappmap/vscode-appland/compare/v0.102.0...v0.103.0) (2023-10-31) + + +### Features + +* Update @appland/components to 3.9.0 ([fa2cc6e](https://github.com/getappmap/vscode-appland/commit/fa2cc6e56f96b13225d1a865a3972a4495eecf28)) + # [0.102.0](https://github.com/getappmap/vscode-appland/compare/v0.101.2...v0.102.0) (2023-10-11) diff --git a/package.json b/package.json index 48997bd3..92ceae67 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "name": "appmap", "displayName": "AppMap", "description": "Interactive maps of runtime code behavior", - "version": "0.102.0", + "version": "0.103.0", "repository": { "type": "git", "url": "https://github.com/getappmap/vscode-appland" From 7dc3de77026ad8d8e3a88b85f5f6ad74ed633f8e Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Wed, 28 Jun 2023 11:48:18 -0400 Subject: [PATCH 03/12] ci: Disable the IndexJanitor test, which is unrelated and blocking the build --- test/integration/lib/indexJanitor.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/integration/lib/indexJanitor.test.ts b/test/integration/lib/indexJanitor.test.ts index 9eadfadd..5eda19fc 100644 --- a/test/integration/lib/indexJanitor.test.ts +++ b/test/integration/lib/indexJanitor.test.ts @@ -20,6 +20,7 @@ describe('AppMapIndex', () => { beforeEach(async () => (extension = await waitForExtension())); afterEach(initializeWorkspace); + // TODO: Restore this test once the IndexJanitor has been fixed or this test has been fixed. xit('cleans up index directories', async () => { const appmapFiles = await findFiles(`tmp/appmap/**/*.appmap.json`); const indexDirs = appmapFiles.map(({ fsPath }) => fsPath.replace(/\.appmap\.json$/, '')); From 2b9fadaaf8a8225cd1b0189fae13835eb31ab0b6 Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Mon, 19 Jun 2023 18:03:11 -0400 Subject: [PATCH 04/12] feat: Fix findings and tests using GPT --- package.json | 9 ++ src/commands/fixFinding.ts | 167 +++++++++++++++++++++++++++++ src/commands/fixTest.ts | 191 ++++++++++++++++++++++++++++++++++ src/lib/buildOpenAIApi.ts | 17 +++ src/lib/filesModifiedInGit.ts | 11 ++ src/lib/filesUnknownToGit.ts | 11 ++ src/lib/snippet.ts | 58 +++++++++++ src/lib/suggestFix.ts | 57 ++++++++++ yarn.lock | 22 +++- 9 files changed, 542 insertions(+), 1 deletion(-) create mode 100644 src/commands/fixFinding.ts create mode 100644 src/commands/fixTest.ts create mode 100644 src/lib/buildOpenAIApi.ts create mode 100644 src/lib/filesModifiedInGit.ts create mode 100644 src/lib/filesUnknownToGit.ts create mode 100644 src/lib/snippet.ts create mode 100644 src/lib/suggestFix.ts diff --git a/package.json b/package.json index 92ceae67..f1bfafa8 100644 --- a/package.json +++ b/package.json @@ -131,6 +131,14 @@ "command": "appmap.openCodeObjectInAppMap", "title": "AppMap: Open Code Object in AppMap" }, + { + "command": "appmap.fixFinding", + "title": "AppMap: Suggest a Fix for an Analysis Finding" + }, + { + "command": "appmap.fixTest", + "title": "AppMap: Suggest a Fix for a Failing Test" + }, { "command": "appmap.touchOutOfDateTestFiles", "title": "AppMap: Touch Out-of-Date Test Files" @@ -498,6 +506,7 @@ "diff": "^5.1.0", "jquery": "^3.5.1", "js-yaml": "^4.1.0", + "openai": "^3.3.0", "popper.js": "^1.16.1", "proper-lockfile": "^4.1.2", "semver": "^7.3.5", diff --git a/src/commands/fixFinding.ts b/src/commands/fixFinding.ts new file mode 100644 index 00000000..70b65d28 --- /dev/null +++ b/src/commands/fixFinding.ts @@ -0,0 +1,167 @@ +import * as vscode from 'vscode'; +import { ResolvedFinding } from '../services/resolvedFinding'; +import AnalysisManager from '../services/analysisManager'; +import { + ChatCompletionRequestMessage, + ChatCompletionRequestMessageRoleEnum, + Configuration, + OpenAIApi, +} from 'openai'; +import assert from 'assert'; +import { Event, buildAppMap } from '@appland/models'; +import { readFile } from 'fs/promises'; +import loadSnippet, { DEFAULT_SPAN } from '../lib/snippet'; +import { suggestFix } from '../lib/suggestFix'; +import { debug } from 'console'; + +export class FindingPickItem implements vscode.QuickPickItem { + constructor(public finding: ResolvedFinding) {} + + get label(): string { + return [this.finding.finding.ruleTitle, `(${this.finding.finding.hash_v2.slice(0, 8)})`].join( + ' ' + ); + } +} + +export async function fixFinding(finding: ResolvedFinding, openAI: OpenAIApi) { + const appmap = buildAppMap() + .source(await readFile(finding.finding.appMapFile, 'utf-8')) + .build(); + const language = appmap.metadata?.language?.name; + + const scopeEvent = appmap.events[finding.finding.scope.id - 1]; + const event = appmap.events[finding.finding.event.id - 1]; + const stackFunctions: string[] = []; + { + let parent: Event | undefined = event; + while (parent) { + stackFunctions.push(parent.codeObject.fqid); + parent = parent.parent; + } + } + + let participatingEvents = Object.values(finding.finding.participatingEvents || {}); + if (participatingEvents.length === 0) participatingEvents = [finding.finding.event]; + + const locations = [ + ...participatingEvents.map((event) => [event.path, event.lineno].filter(Boolean).join(':')), + ...finding.finding.stack, + ]; + + const snippetLocations = new Set(); + const codeSnippets = new Array(); + for (const location of locations) { + if (snippetLocations.has(location)) continue; + + // TODO: Determine the function span more accurately + const snippet = await loadSnippet(finding.folder, location, 0, DEFAULT_SPAN * 2); + if (!snippet) continue; + + if (snippet) snippetLocations.add(location); + codeSnippets.push([`Source file: ${snippet.path}`, '', snippet.lines.join('\n')].join('\n')); + } + + const systemMessages: ChatCompletionRequestMessage[] = [ + { + content: `You are a software developer fixing problems in a codebase`, + role: 'system', + }, + ]; + if (language) + systemMessages.push({ content: `Programming language: ${language}`, role: 'system' }); + + let relatedReferences: string[] = []; + if (finding.rule.references && Object.keys(finding.rule.references).length > 0) + relatedReferences = Object.entries(finding.rule.references || {}) + .map(([key, value]) => [key, value].join(' ')) + .map((reference) => `Related reference: ${reference}`); + + const ancestors = [event]; + + const userMessages: ChatCompletionRequestMessage[] = [ + `Type: ${finding.rule.impactDomain}`, + `Name: ${finding.rule.title}`, + `Description: ${finding.finding.message}`, + ...relatedReferences, + `The problem occurs within ${scopeEvent.codeObject.fqid}`, + `Stack trace (file locations): ${finding.finding.stack.reverse().join(' -> ')}`, + `Stack trace (event names): ${ancestors.join(' -> ')}`, + ...codeSnippets, + ].map((message) => ({ + content: message, + role: 'user' as ChatCompletionRequestMessageRoleEnum, + })); + userMessages.push({ + content: `Decribe the problem and suggest how to fix it, using diff / patch format for code suggestions${ + language ? ' in ' + language : '' + }`, + role: 'user' as ChatCompletionRequestMessageRoleEnum, + }); + + await suggestFix(openAI, finding.rule.title, systemMessages, userMessages); +} + +export default function register(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.commands.registerCommand('appmap.fixFinding', async (findingHash?: string) => { + const findingsIndex = AnalysisManager.findingsIndex; + if (!findingsIndex) return; + + if (!findingHash) { + const uniqueFindingHashes = new Set(); + const findings = ( + findingsIndex + .findings() + .map((finding) => { + if (uniqueFindingHashes.has(finding.finding.hash_v2)) return; + + uniqueFindingHashes.add(finding.finding.hash_v2); + return finding; + }) + + .filter(Boolean) as ResolvedFinding[] + ).sort((a, b) => a.rule.title.localeCompare(b.rule.title)); + + const choices = findings.map((finding) => new FindingPickItem(finding)); + + const selection = await vscode.window.showQuickPick(choices); + if (!selection) return; + + findingHash = selection.finding.finding.hash_v2; + } + + const findings = findingsIndex.findingsByHash(findingHash); + if (!findings || findings.length === 0) return; + + const finding = findings[0]; + assert(finding); + + let gptKey = await context.secrets.get('openai.gptKey'); + if (!gptKey) { + gptKey = await vscode.window.showInputBox({ title: `Enter your OpenAI API key` }); + if (!gptKey) return; + + await context.secrets.store('openai.gptKey', gptKey); + } + + const openAI = new OpenAIApi(new Configuration({ apiKey: gptKey })); + + await vscode.window.withProgress( + { + title: `Analyzing: ${finding.rule.title}`, + location: vscode.ProgressLocation.Notification, + }, + async () => { + assert(findingHash); + try { + await fixFinding(finding, openAI); + } catch (e) { + debug((e as any).toString()); + vscode.window.showErrorMessage(`Failed to analyze finding: ${e}`); + } + } + ); + }) + ); +} diff --git a/src/commands/fixTest.ts b/src/commands/fixTest.ts new file mode 100644 index 00000000..2613a99e --- /dev/null +++ b/src/commands/fixTest.ts @@ -0,0 +1,191 @@ +import * as vscode from 'vscode'; +import AppMapCollection from '../services/appmapCollection'; +import AppMapLoader from '../services/appmapLoader'; +import buildOpenAIApi from '../lib/buildOpenAIApi'; +import assert from 'assert'; +import { + ChatCompletionRequestMessage, + ChatCompletionRequestMessageRoleEnum, + OpenAIApi, +} from 'openai'; +import loadSnippet, { DEFAULT_SPAN } from '../lib/snippet'; +import filesUnknownToGit from '../lib/filesUnknownToGit'; +import filesModifiedInGit from '../lib/filesModifiedInGit'; +import { suggestFix } from '../lib/suggestFix'; +import { debug } from 'console'; +import { isAbsolute } from 'path'; + +export class AppMapPickItem implements vscode.QuickPickItem { + constructor(public appmap: AppMapLoader) {} + + get label(): string { + return this.appmap.descriptor.metadata?.name || ''; + } +} + +async function fixFailedTest(appmapLoader: AppMapLoader, openAI: OpenAIApi) { + const appmap = await appmapLoader.loadAppMap(); + const language = appmap.metadata?.language?.name; + + const folder = vscode.workspace.workspaceFolders?.find((folder) => + appmapLoader.descriptor.resourceUri.fsPath.startsWith(folder.uri.fsPath) + ); + if (!folder) return; + + const systemMessages: ChatCompletionRequestMessage[] = [ + { + content: `You are a software developer fixing problems in a codebase`, + role: 'system', + }, + ]; + if (language) + systemMessages.push({ content: `Programming language: ${language}`, role: 'system' }); + + if (appmap.metadata.source_location) { + const snippet = await loadSnippet(folder, appmap.metadata.source_location, 0, DEFAULT_SPAN * 2); + if (snippet) { + const content = [ + `Test case${snippet.lineno ? ' begins at line ' + snippet.lineno : ''}: ${snippet.path}`, + '', + snippet.lines.join('\n'), + ].join('\n'); + systemMessages.push({ content, role: 'system' }); + } + } + + const userMessages: ChatCompletionRequestMessage[] = []; + if (appmap.metadata.test_failure?.message) { + userMessages.push({ + content: `Test failed: ${appmap.metadata.test_failure.message}`, + role: 'user', + }); + } + + if (appmap.metadata.test_failure?.location) { + const snippet = await loadSnippet(folder, appmap.metadata.test_failure.location); + if (snippet) { + const content = [ + `Test failure${snippet.lineno ? ' occurred at line' + snippet.lineno : ''}: ${ + snippet.path + }`, + '', + snippet.lines.join('\n'), + ].join('\n'); + userMessages.push({ content, role: 'user' }); + } + } + + for (const exceptionEvent of appmap.events.filter( + (event) => event.exceptions && event.exceptions.length + )) { + for (const exception of exceptionEvent.exceptions) { + const location = [exception.path, exception.lineno].filter(Boolean).join(':'); + const snippet = await loadSnippet(folder, location); + if (!snippet) continue; + + const content = [ + `Exception occurred at line ${snippet.lineno}: ${snippet.path}`, + '', + snippet.lines.join('\n'), + ].join('\n'); + userMessages.push({ content, role: 'user' }); + } + } + + const outOfDateFiles = new Set([ + ...(await filesUnknownToGit(folder.uri.fsPath)), + ...(await filesModifiedInGit(folder.uri.fsPath)), + ]); + const snippetLocations = new Set(); + const snippetMessages: string[] = []; + for (const event of appmap.events) { + if (!(event.path && event.lineno)) continue; + + let { path } = event; + if (isAbsolute(path) && path.startsWith(folder.uri.fsPath)) + path = path.slice(folder.uri.fsPath.length + 1); + + if (!outOfDateFiles.has(event.path)) continue; + + const location = [path, event.lineno].join(':'); + if (snippetLocations.has(location)) continue; + snippetLocations.add(location); + + const snippet = await loadSnippet(folder, location, 0, DEFAULT_SPAN); + if (!snippet) continue; + + const content = [ + `Code executed at line ${snippet.lineno}: ${snippet.path}`, + '', + snippet.lines.join('\n'), + ].join('\n'); + snippetMessages.push(content); + } + + userMessages.push( + ...snippetMessages + .slice(0, 5) // TODO: Limiting to 5 of these for now, to stay under the token limit + .map((content) => ({ content, role: 'user' as ChatCompletionRequestMessageRoleEnum })) + ); + + userMessages.push({ + content: `Decribe the problem and suggest how to fix it, using diff / patch format for code suggestions${ + language ? ' in ' + language : '' + }`, + role: 'user' as ChatCompletionRequestMessageRoleEnum, + }); + + const title = appmap.metadata.test_failure?.message || appmap.metadata.name || 'Test failure'; + await suggestFix(openAI, title, systemMessages, userMessages); +} + +export default function register( + context: vscode.ExtensionContext, + appmapCollection: AppMapCollection +): void { + context.subscriptions.push( + vscode.commands.registerCommand('appmap.fixTest', async (appmapUri?: vscode.Uri) => { + let appmap: AppMapLoader | undefined; + if (appmapUri) { + appmap = appmapCollection + .allAppMaps() + .find((appmap) => appmap.descriptor.resourceUri === appmapUri); + } else { + const choices = ( + appmapCollection + .allAppMaps() + .filter( + (appmap) => appmap.descriptor.metadata?.test_status === 'failed' + ) as AppMapLoader[] + ) + .map((appmap) => new AppMapPickItem(appmap)) + .sort((a, b) => a.label.localeCompare(b.label)); + + const selection = await vscode.window.showQuickPick(choices); + if (!selection) return; + + appmap = selection.appmap; + } + if (!appmap) return; + + const openAI = await buildOpenAIApi(context); + if (!openAI) return; + + await vscode.window.withProgress( + { + title: `Analyzing failed test`, + location: vscode.ProgressLocation.Notification, + }, + async () => { + assert(appmap); + try { + await fixFailedTest(appmap, openAI); + } catch (e) { + debug((e as any).toString()); + vscode.window.showErrorMessage(`Failed to analyze failed test: ${e}`); + } + } + ); + }) + ); +} diff --git a/src/lib/buildOpenAIApi.ts b/src/lib/buildOpenAIApi.ts new file mode 100644 index 00000000..5ce2ceaa --- /dev/null +++ b/src/lib/buildOpenAIApi.ts @@ -0,0 +1,17 @@ +import * as vscode from 'vscode'; + +import { Configuration, OpenAIApi } from 'openai'; + +export default async function buildOpenAIApi( + context: vscode.ExtensionContext +): Promise { + let gptKey = await context.secrets.get('openai.gptKey'); + if (!gptKey) { + gptKey = await vscode.window.showInputBox({ title: `Enter your OpenAI API key` }); + if (!gptKey) return; + + await context.secrets.store('openai.gptKey', gptKey); + } + + return new OpenAIApi(new Configuration({ apiKey: gptKey })); +} diff --git a/src/lib/filesModifiedInGit.ts b/src/lib/filesModifiedInGit.ts new file mode 100644 index 00000000..d49283a4 --- /dev/null +++ b/src/lib/filesModifiedInGit.ts @@ -0,0 +1,11 @@ +import { exec } from 'child_process'; + +export default async function filesModifiedInGit(cwd: string): Promise { + return new Promise((resolve, reject) => { + exec(`git ls-files -m`, { cwd }, (err, stdout) => { + if (err && err.code && err.code > 0) reject(err.code); + + resolve(stdout.trim().split('\n')); + }); + }); +} diff --git a/src/lib/filesUnknownToGit.ts b/src/lib/filesUnknownToGit.ts new file mode 100644 index 00000000..20543b7e --- /dev/null +++ b/src/lib/filesUnknownToGit.ts @@ -0,0 +1,11 @@ +import { exec } from 'child_process'; + +export default async function filesUnknownToGit(cwd: string): Promise { + return new Promise((resolve, reject) => { + exec(`git ls-files -o --exclude-standard`, { cwd }, (err, stdout) => { + if (err && err.code && err.code > 0) reject(err.code); + + resolve(stdout.trim().split('\n')); + }); + }); +} diff --git a/src/lib/snippet.ts b/src/lib/snippet.ts new file mode 100644 index 00000000..7902db7c --- /dev/null +++ b/src/lib/snippet.ts @@ -0,0 +1,58 @@ +import * as vscode from 'vscode'; + +import { isAbsolute } from 'path'; + +export const DEFAULT_SPAN = 10; + +export type Snippet = { + path: string; + lineno?: number; + lines: string[]; +}; + +export default async function loadSnippet( + folder: vscode.WorkspaceFolder, + location: string, + behindSpan = DEFAULT_SPAN, + aheadSpan = DEFAULT_SPAN +): Promise { + const [path, lineStr] = location.split(':'); + let lineno: number | undefined; + if (lineStr) lineno = parseInt(lineStr, 10); + + let filePath = path; + if (isAbsolute(filePath) && filePath.startsWith(folder.uri.fsPath)) + filePath = filePath.slice(folder.uri.fsPath.length + 1); + + if (isAbsolute(filePath) || filePath.startsWith('node_modules') || filePath.startsWith('vendor')) + return; + + const fileUri = vscode.Uri.joinPath(folder.uri, filePath); + let fileStat: vscode.FileStat; + let exists = false; + try { + fileStat = await vscode.workspace.fs.stat(fileUri); + exists = fileStat.type === vscode.FileType.File; + } catch (e) { + // File doesn't exist + } + + if (!exists) return; + + const fileContents = await vscode.workspace.fs.readFile(fileUri); + const lines = fileContents.toString().split('\n'); + let snippet: string[]; + if (lineno) { + snippet = lines + .slice(Math.max(lineno - behindSpan, 0), Math.min(lineno + aheadSpan, lines.length)) + .map((line, index) => `${index + (lineno || 0) + 1}: ${line}`); + } else { + snippet = lines; + } + + return { + path: filePath, + lineno, + lines: snippet, + }; +} diff --git a/src/lib/suggestFix.ts b/src/lib/suggestFix.ts new file mode 100644 index 00000000..47ea28bc --- /dev/null +++ b/src/lib/suggestFix.ts @@ -0,0 +1,57 @@ +import * as vscode from 'vscode'; +import assert from 'assert'; +import { ChatCompletionRequestMessage, CreateChatCompletionResponse, OpenAIApi } from 'openai'; + +const MAX_TITLE = 30; + +export async function suggestFix( + openAI: OpenAIApi, + title: string, + systemMessages: ChatCompletionRequestMessage[], + userMessages: ChatCompletionRequestMessage[] +) { + const messages = [...systemMessages, ...userMessages]; + + let response: CreateChatCompletionResponse; + try { + const result = await openAI.createChatCompletion({ + model: 'gpt-3.5-turbo', + messages, + n: 1, + max_tokens: + // 4096 is the maximum number of tokens allowed by gpt-3.5-turbo + 4096 - + // This is a conservative estimate of the number of tokens in the prompt, since + // a token can be more than one character. + (messages.map((msg) => msg.content?.length).filter(Boolean) as number[]).reduce( + (a, b) => a + b, + 0 + ), + }); + response = result.data; + } catch (e) { + vscode.window.showErrorMessage((e as any).toString()); + return; + } + + const promptContent = [ + '```', + ...messages.map((msg) => [msg.role, msg.content].join(': ')), + '```', + ].join('\n'); + const responseContent = response.choices + .filter((choice) => choice.message) + .map((choice) => (assert(choice.message), choice.message.content)) + .filter(Boolean) + .join('\n'); + + title = title.replaceAll('\n', ' '); + if (title.length > MAX_TITLE) title = title.slice(0, MAX_TITLE) + '...'; + + const newDocument = await vscode.workspace.openTextDocument({ + content: [`## Analysis of '${title}'`, responseContent, `## Prompt`, promptContent].join( + '\n\n' + ), + }); + vscode.window.showTextDocument(newDocument); +} diff --git a/yarn.lock b/yarn.lock index ce7b5bcf..25ecb464 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2829,6 +2829,7 @@ __metadata: mocha-suppress-logs: ^0.3.1 mockery: ^2.1.0 node-libs-browser: ^2.2.1 + openai: ^3.3.0 openapi-types: ^11.0.1 popper.js: ^1.16.1 prettier: ^2.8.4 @@ -3125,6 +3126,15 @@ __metadata: languageName: node linkType: hard +"axios@npm:^0.26.0": + version: 0.26.1 + resolution: "axios@npm:0.26.1" + dependencies: + follow-redirects: ^1.14.8 + checksum: d9eb58ff4bc0b36a04783fc9ff760e9245c829a5a1052ee7ca6013410d427036b1d10d04e7380c02f3508c5eaf3485b1ae67bd2adbfec3683704745c8d7a6e1a + languageName: node + linkType: hard + "axios@npm:^0.27.2": version: 0.27.2 resolution: "axios@npm:0.27.2" @@ -6332,7 +6342,7 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.14.4, follow-redirects@npm:^1.14.9": +"follow-redirects@npm:^1.14.4, follow-redirects@npm:^1.14.8, follow-redirects@npm:^1.14.9": version: 1.15.2 resolution: "follow-redirects@npm:1.15.2" peerDependenciesMeta: @@ -9853,6 +9863,16 @@ __metadata: languageName: node linkType: hard +"openai@npm:^3.3.0": + version: 3.3.0 + resolution: "openai@npm:3.3.0" + dependencies: + axios: ^0.26.0 + form-data: ^4.0.0 + checksum: 28ccff8c09b6f47828c9583bb3bafc38a8459c76ea10eb9e08ca880f65523c5a9cc6c5f3c7669dded6f4c93e7cf49dd5c4dbfd12732a0f958c923117740d677b + languageName: node + linkType: hard + "openapi-diff@npm:^0.23.5, openapi-diff@npm:^0.23.6": version: 0.23.6 resolution: "openapi-diff@npm:0.23.6" From 25852064f8e2deb403e4b9cdc582cce7902a2dbb Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Wed, 21 Jun 2023 13:52:13 -0400 Subject: [PATCH 05/12] fix: Fix reported snippet lines --- src/commands/fixFinding.ts | 13 ++++--------- src/commands/fixTest.ts | 16 +++++++++++----- src/lib/snippet.ts | 6 ++++-- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/commands/fixFinding.ts b/src/commands/fixFinding.ts index 70b65d28..e3412fc2 100644 --- a/src/commands/fixFinding.ts +++ b/src/commands/fixFinding.ts @@ -59,7 +59,7 @@ export async function fixFinding(finding: ResolvedFinding, openAI: OpenAIApi) { if (!snippet) continue; if (snippet) snippetLocations.add(location); - codeSnippets.push([`Source file: ${snippet.path}`, '', snippet.lines.join('\n')].join('\n')); + codeSnippets.push(['', `Source file: ${snippet.path}`, snippet.lines.join('\n')].join('\n')); } const systemMessages: ChatCompletionRequestMessage[] = [ @@ -77,16 +77,11 @@ export async function fixFinding(finding: ResolvedFinding, openAI: OpenAIApi) { .map(([key, value]) => [key, value].join(' ')) .map((reference) => `Related reference: ${reference}`); - const ancestors = [event]; - const userMessages: ChatCompletionRequestMessage[] = [ - `Type: ${finding.rule.impactDomain}`, - `Name: ${finding.rule.title}`, - `Description: ${finding.finding.message}`, + `The code contains a ${finding.rule.impactDomain} problem: ${finding.rule.title}`, + `Specifically: ${finding.finding.message}`, ...relatedReferences, - `The problem occurs within ${scopeEvent.codeObject.fqid}`, - `Stack trace (file locations): ${finding.finding.stack.reverse().join(' -> ')}`, - `Stack trace (event names): ${ancestors.join(' -> ')}`, + `The problem occurs within ${scopeEvent.codeObject.fqid}. It's related to the following code:`, ...codeSnippets, ].map((message) => ({ content: message, diff --git a/src/commands/fixTest.ts b/src/commands/fixTest.ts index 2613a99e..3f4d691b 100644 --- a/src/commands/fixTest.ts +++ b/src/commands/fixTest.ts @@ -65,27 +65,33 @@ async function fixFailedTest(appmapLoader: AppMapLoader, openAI: OpenAIApi) { const snippet = await loadSnippet(folder, appmap.metadata.test_failure.location); if (snippet) { const content = [ - `Test failure${snippet.lineno ? ' occurred at line' + snippet.lineno : ''}: ${ + '', + `Test failure${snippet.lineno ? ' occurred at line ' + snippet.lineno : ''}: ${ snippet.path }`, - '', snippet.lines.join('\n'), ].join('\n'); userMessages.push({ content, role: 'user' }); } } + const uniqueExceptions = new Set(); for (const exceptionEvent of appmap.events.filter( (event) => event.exceptions && event.exceptions.length )) { for (const exception of exceptionEvent.exceptions) { const location = [exception.path, exception.lineno].filter(Boolean).join(':'); + const exceptionId = [exception.class, location].filter(Boolean).join(':'); + if (uniqueExceptions.has(exceptionId)) continue; + + uniqueExceptions.add(exceptionId); + const snippet = await loadSnippet(folder, location); if (!snippet) continue; const content = [ - `Exception occurred at line ${snippet.lineno}: ${snippet.path}`, '', + `Exception ${snippet.lineno ? ' occurred at line ' + snippet.lineno : ''}: ${snippet.path}`, snippet.lines.join('\n'), ].join('\n'); userMessages.push({ content, role: 'user' }); @@ -115,8 +121,8 @@ async function fixFailedTest(appmapLoader: AppMapLoader, openAI: OpenAIApi) { if (!snippet) continue; const content = [ - `Code executed at line ${snippet.lineno}: ${snippet.path}`, '', + `Code executed ${snippet.lineno ? ' at line ' + snippet.lineno : ''}: ${snippet.path}`, snippet.lines.join('\n'), ].join('\n'); snippetMessages.push(content); @@ -124,7 +130,7 @@ async function fixFailedTest(appmapLoader: AppMapLoader, openAI: OpenAIApi) { userMessages.push( ...snippetMessages - .slice(0, 5) // TODO: Limiting to 5 of these for now, to stay under the token limit + .slice(0, 4) // TODO: Limiting to 4 of these for now, to stay under the token limit .map((content) => ({ content, role: 'user' as ChatCompletionRequestMessageRoleEnum })) ); diff --git a/src/lib/snippet.ts b/src/lib/snippet.ts index 7902db7c..b9da05a1 100644 --- a/src/lib/snippet.ts +++ b/src/lib/snippet.ts @@ -43,9 +43,11 @@ export default async function loadSnippet( const lines = fileContents.toString().split('\n'); let snippet: string[]; if (lineno) { + const startLine = Math.max(lineno - behindSpan - 1, 0); + const endLine = Math.min(lineno + aheadSpan - 1, lines.length); snippet = lines - .slice(Math.max(lineno - behindSpan, 0), Math.min(lineno + aheadSpan, lines.length)) - .map((line, index) => `${index + (lineno || 0) + 1}: ${line}`); + .slice(startLine, endLine) + .map((line, index) => `${index + startLine + 1}: ${line}`); } else { snippet = lines; } From d7eb4813710418da240cab8517b6eed347cb7f68 Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Tue, 20 Jun 2023 10:58:45 -0400 Subject: [PATCH 06/12] feat: Provide 'fix' via inline context action --- package.json | 24 ++++++++++++++++++++++-- src/tree/findingsTreeDataProvider.ts | 17 +++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index f1bfafa8..f7d7d688 100644 --- a/package.json +++ b/package.json @@ -153,14 +153,24 @@ }, { "command": "appmap.context.openAsJson", - "title": "AppMap View: Open as JSON", + "title": "Open as JSON", "icon": "$(bracket)" }, { "command": "appmap.context.deleteAppMap", - "title": "AppMap View: Delete AppMap", + "title": "Delete", "icon": "$(trash)" }, + { + "command": "appmap.context.fixTest", + "title": "Fix", + "icon": "$(wrench)" + }, + { + "command": "appmap.context.fixFinding", + "title": "Fix", + "icon": "$(wrench)" + }, { "command": "appmap.context.rename", "title": "AppMap View: Rename AppMap" @@ -384,6 +394,16 @@ "when": "view == appmap.views.appmaps && viewItem == appmap.views.appmaps.appMap", "group": "inline" }, + { + "command": "appmap.context.fixTest", + "when": "view == appmap.views.findings && viewItem == appmap.views.analysis.failedTest", + "group": "inline" + }, + { + "command": "appmap.context.fixFinding", + "when": "view == appmap.views.findings && viewItem == appmap.views.analysis.finding", + "group": "inline" + }, { "command": "appmap.context.openInFileExplorer", "when": "view == appmap.views.appmaps && viewItem == appmap.views.appmaps.appMap" diff --git a/src/tree/findingsTreeDataProvider.ts b/src/tree/findingsTreeDataProvider.ts index c5d52740..c6f1a6e3 100644 --- a/src/tree/findingsTreeDataProvider.ts +++ b/src/tree/findingsTreeDataProvider.ts @@ -153,6 +153,23 @@ export class FindingsTreeDataProvider ); appmaps.onUpdated(() => this._onDidChangeTreeData.fire(undefined)); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'appmap.context.fixTest', + async (item: FailedTestTreeItem) => { + vscode.commands.executeCommand('appmap.fixTest', item.appmap.descriptor.resourceUri); + } + ) + ); + context.subscriptions.push( + vscode.commands.registerCommand( + 'appmap.context.fixFinding', + async (item: FindingTreeItem) => { + vscode.commands.executeCommand('appmap.fixFinding', item.finding.finding.hash_v2); + } + ) + ); } public setFindingsIndex(findingsIndex?: FindingsIndex): void { From 865d8f9f41d5c4d6fcff4bd0074c7270efdd54bf Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Wed, 21 Jun 2023 13:53:12 -0400 Subject: [PATCH 07/12] feat: Report fixes as commit comment + code suggestion --- src/commands/fixFinding.ts | 6 +++--- src/commands/fixTest.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/commands/fixFinding.ts b/src/commands/fixFinding.ts index e3412fc2..52f01410 100644 --- a/src/commands/fixFinding.ts +++ b/src/commands/fixFinding.ts @@ -88,9 +88,9 @@ export async function fixFinding(finding: ResolvedFinding, openAI: OpenAIApi) { role: 'user' as ChatCompletionRequestMessageRoleEnum, })); userMessages.push({ - content: `Decribe the problem and suggest how to fix it, using diff / patch format for code suggestions${ - language ? ' in ' + language : '' - }`, + content: `Describe the problem in the style of a code review comment, then suggest a fixed version of the code by generating one or more complete ${ + language ? language : '' + } functions`, role: 'user' as ChatCompletionRequestMessageRoleEnum, }); diff --git a/src/commands/fixTest.ts b/src/commands/fixTest.ts index 3f4d691b..1918b791 100644 --- a/src/commands/fixTest.ts +++ b/src/commands/fixTest.ts @@ -135,9 +135,9 @@ async function fixFailedTest(appmapLoader: AppMapLoader, openAI: OpenAIApi) { ); userMessages.push({ - content: `Decribe the problem and suggest how to fix it, using diff / patch format for code suggestions${ - language ? ' in ' + language : '' - }`, + content: `Analyze the problem, then provide a fixed version of the code, by generating one or more complete ${ + language ? language : '' + } functions`, role: 'user' as ChatCompletionRequestMessageRoleEnum, }); From 71894e55f3acb37496f49e5c3201d6e116d10072 Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Wed, 28 Jun 2023 09:49:46 -0400 Subject: [PATCH 08/12] feat: Ask a question about selected code --- package.json | 4 + src/commands/ask.ts | 282 ++++++++++++++++++++++++++++++++++ src/extension.ts | 7 + src/lib/ask.ts | 115 ++++++++++++++ src/services/lineInfoIndex.ts | 2 +- 5 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 src/commands/ask.ts create mode 100644 src/lib/ask.ts diff --git a/package.json b/package.json index f7d7d688..b8ed59ea 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,10 @@ "command": "appmap.fixTest", "title": "AppMap: Suggest a Fix for a Failing Test" }, + { + "command": "appmap.ask", + "title": "AppMap: Ask a Question About the Current Code Selection" + }, { "command": "appmap.touchOutOfDateTestFiles", "title": "AppMap: Touch Out-of-Date Test Files" diff --git a/src/commands/ask.ts b/src/commands/ask.ts new file mode 100644 index 00000000..2cc74a50 --- /dev/null +++ b/src/commands/ask.ts @@ -0,0 +1,282 @@ +import * as vscode from 'vscode'; +import LineInfoIndex, { LineInfo } from '../services/lineInfoIndex'; +import assert from 'assert'; +import AppMapCollection from '../services/appmapCollection'; +import ClassMapIndex from '../services/classMapIndex'; +import { promptForAppMap } from '../lib/promptForAppMap'; +import { debug } from 'console'; +import { readFile, writeFile } from 'fs/promises'; +import { + AppMap, + AppMapFilter, + CodeObject, + Event, + EventNavigator, + buildAppMap, +} from '@appland/models'; +import buildOpenAIApi from '../lib/buildOpenAIApi'; +import ask from '../lib/ask'; +import { isAbsolute, join } from 'path'; +import { fileExists } from '../util'; +import { FormatType, Specification, buildDiagram, format } from '@appland/sequence-diagram'; + +class LineInfoQuickPickItem implements vscode.QuickPickItem { + constructor(public lineInfo: LineInfo) {} + + get label(): string { + return [ + `line ${this.lineInfo.line}`, + this.lineInfo.codeObjects?.map((co) => co.fqid).join(', '), + ].join(': '); + } +} + +export default function register( + context: vscode.ExtensionContext, + lineInfoIndex: LineInfoIndex, + classMapIndex: ClassMapIndex, + appmapCollection: AppMapCollection +): void { + context.subscriptions.push( + vscode.commands.registerCommand('appmap.ask', async () => { + const { activeTextEditor } = vscode.window; + if (!activeTextEditor) return; + + const { selection } = activeTextEditor; + if (!selection) return; + + const startLine = selection.anchor.line; + const endLine = selection.active.line; + let selectedCode = activeTextEditor.document.getText(selection).trim(); + if (selectedCode === '') + selectedCode = activeTextEditor.document.lineAt(startLine).text.trim(); + + const documentUri = activeTextEditor.document.uri; + const lineInfo = (await lineInfoIndex.lineInfo(documentUri)).filter( + (line) => line.codeObjects + ); + const workspaceFolder = vscode.workspace.getWorkspaceFolder(documentUri); + if (!workspaceFolder) return; + + const lineCodePrompt = + selectedCode.split('\n').length === 0 + ? selectedCode + : [selectedCode.split('\n')[0], '...'].join(''); + const question = await vscode.window.showInputBox({ + placeHolder: `Ask a question about: ${lineCodePrompt}`, + value: 'How does this code work?', + }); + if (!question) return; + + const distanceFromSelection = (item: LineInfoQuickPickItem): number => + Math.min(Math.abs(item.lineInfo.line - startLine), Math.abs(item.lineInfo.line - endLine)); + + const lineInfoItems = lineInfo.map((info) => new LineInfoQuickPickItem(info)); + const lineDistance = lineInfoItems.map(distanceFromSelection); + const preferredDistance = lineDistance.sort()[0]; + + const choiceItems: vscode.QuickPickItem[] = [ + ...lineInfoItems.filter( + (lineInfo) => distanceFromSelection(lineInfo) === preferredDistance + ), + { label: '---' }, + ...lineInfoItems.filter( + (lineInfo) => distanceFromSelection(lineInfo) !== preferredDistance + ), + ]; + + const lineAbout = await vscode.window.showQuickPick(choiceItems, { + placeHolder: `Verify the function that you want to ask about`, + }); + if (!lineAbout) return; + if (!(lineAbout instanceof LineInfoQuickPickItem)) return; + + const codeObjectEntry = ((lineAbout as LineInfoQuickPickItem).lineInfo.codeObjects || [])[0]; + assert(codeObjectEntry); + + if (codeObjectEntry.appMapFiles.length === 0) { + console.warn(`No AppMaps for ${codeObjectEntry.fqid}`); + return; + } + + let appMapFileName: string; + if (codeObjectEntry.appMapFiles.length === 1) { + appMapFileName = codeObjectEntry.appMapFiles[0]; + } else { + const appmapFiles = new Set(Object.keys(await codeObjectEntry.appMapMetadata())); + const appmaps = appmapCollection + .allAppMaps() + .filter((appmap) => appmapFiles.has(appmap.descriptor.resourceUri.fsPath)); + + const selectedAppMap = await promptForAppMap(appmaps); + if (!selectedAppMap) return; + + appMapFileName = selectedAppMap.fsPath; + } + + const appmapUri = vscode.Uri.file(appMapFileName); + let codeObject: CodeObject | undefined; + let scopeCodeObjects: CodeObject[]; + let returnValues: string[]; + let functions: string[]; + let sequenceDiagram: string; + { + const data = await readFile(appmapUri.fsPath, 'utf-8'); + const appmap = buildAppMap().source(data).build(); + + appmap.classMap.visit((co) => { + if (co.fqid === codeObjectEntry.fqid) codeObject = co; + }); + if (!codeObject) { + vscode.window.showInformationMessage( + `Could not find code object ${codeObjectEntry.fqid} in the AppMap. Maybe it's out of date?` + ); + return; + } + + const { events } = appmap; + let filterAppMap: AppMap; + { + const codeObjectEvents = events.filter( + (event) => event.codeObject.fqid === codeObjectEntry.fqid + ); + returnValues = [ + ...new Set( + codeObjectEvents + .map((e) => { + return e.returnValue + ? [e.returnValue.class, e.returnValue.value].join(': ') + : undefined; + }) + .filter(Boolean) + ), + ] as string[]; + const roots: Event[] = []; + const filterEvents: Event[] = [...codeObjectEvents]; + for (const event of codeObjectEvents) { + for (const ancestor of new EventNavigator(event).ancestors()) { + filterEvents.push(ancestor.event); + if (ancestor.event.httpServerRequest) { + roots.push(ancestor.event); + break; + } + if (!ancestor.event.parent) roots.push(ancestor.event); + } + for (const child of event.children) { + filterEvents.push(child); + for (const child2 of child.children) { + filterEvents.push(child2); + for (const child3 of child2.children) { + filterEvents.push(child3); + for (const child4 of child3.children) filterEvents.push(child4); + } + } + } + } + for (const root of roots) { + for (const child of root.children) { + filterEvents.push(child); + for (const child2 of child.children) { + filterEvents.push(child2); + for (const child3 of child2.children) { + filterEvents.push(child3); + for (const child4 of child3.children) filterEvents.push(child4); + } + } + } + } + + scopeCodeObjects = [...new Set(roots.map((e) => e.codeObject))]; + // const relatedCodeObjects = [...new Set(filterEvents.map((e) => e.codeObject))]; + // KEG: I want to avoid sending too much source code to the AI, as it places undue priority + // on the source code rather than the code flow. + functions = []; + for (const co of [codeObject] /* relatedCodeObjects */) { + const codeObjectEntry = await classMapIndex.lookupCodeObject(co.fqid); + if (!codeObjectEntry) continue; + if (!codeObjectEntry.path || !codeObjectEntry?.lineNo) continue; + + let { path } = codeObjectEntry; + const { lineNo } = codeObjectEntry; + if (!isAbsolute(path)) path = join(codeObjectEntry.folder.uri.fsPath, path); + if (!(await fileExists(path))) continue; + if (!path.startsWith(codeObjectEntry.folder.uri.fsPath)) continue; + if ( + path.slice(codeObjectEntry.folder.uri.fsPath.length).startsWith('/node_modules/') || + path.slice(codeObjectEntry.folder.uri.fsPath.length).startsWith('/vendor/') + ) + continue; + + const code = (await readFile(path, 'utf-8')).split('\n'); + // Sort lineInfo by distance from codeObject + const distanceFromCodeObject = (lineInfo: LineInfo) => lineInfo.line - lineNo; + const nextLineInfo = lineInfo + .sort((a, b) => distanceFromCodeObject(a) - distanceFromCodeObject(b)) + .filter((li) => distanceFromCodeObject(li) > 0) + .sort()[0]; + const lastLine = nextLineInfo ? nextLineInfo.line : codeObjectEntry.lineNo + 10; + const codeLines = code.slice(codeObjectEntry.lineNo - 1, lastLine - 1); + functions.push([codeObjectEntry.fqid, ...codeLines].join('\n')); + } + + const eventIds = new Set(filterEvents.map((e) => e.id)); + filterAppMap = buildAppMap({ + events: events.filter((e) => eventIds.has(e.callEvent.id)), + classMap: appmap.classMap.roots.map((c) => ({ ...c.data })), + metadata: appmap.metadata, + }).build(); + + const filterAppMapData = JSON.stringify(filterAppMap, null, 2); + let appmapName = question.replace(/[^a-zA-Z0-9]/g, '-'); + if (appmapName.endsWith('-')) appmapName = appmapName.slice(0, -1); + const filePath = join(workspaceFolder?.uri.fsPath, `${appmapName}.appmap.json`); + await writeFile(filePath, filterAppMapData); + const uri = vscode.Uri.file(filePath); + await vscode.commands.executeCommand('vscode.openWith', uri, 'appmap.views.appMapFile'); + } + + const specification = Specification.build(filterAppMap, { loops: true }); + + assert(appmapUri); + let sequenceDiagramAppMap: AppMap; + { + const sequenceDiagramFilter = new AppMapFilter(); + if (appmap.metadata.language?.name !== 'java') + sequenceDiagramFilter.declutter.hideExternalPaths.on = true; + sequenceDiagramAppMap = sequenceDiagramFilter.filter(filterAppMap, []); + } + const diagram = buildDiagram(appmapUri.fsPath, sequenceDiagramAppMap, specification); + sequenceDiagram = format(FormatType.PlantUML, diagram, appmapUri.fsPath).diagram; + } + + const openAI = await buildOpenAIApi(context); + if (!openAI) return; + + await vscode.window.withProgress( + { + title: `Thinking about your question...`, + location: vscode.ProgressLocation.Notification, + }, + async () => { + assert(codeObject); + + try { + await ask( + question, + selectedCode, + codeObject, + returnValues, + scopeCodeObjects, + functions, + sequenceDiagram, + openAI + ); + } catch (e) { + debug((e as any).toString()); + vscode.window.showErrorMessage(`Unable to process your question: ${e}`); + } + } + ); + }) + ); +} diff --git a/src/extension.ts b/src/extension.ts index 24bda976..d140e0f6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -58,6 +58,9 @@ import getAppmapDir from './commands/getAppmapDir'; import JavaAssets from './services/javaAssets'; import checkAndTriggerFirstAppMapNotification from './lib/firstAppMapNotification'; import Watcher from './services/watcher'; +import { default as fixFinding } from './commands/fixFinding'; +import { default as fixTest } from './commands/fixTest'; +import { default as ask } from './commands/ask'; export async function activate(context: vscode.ExtensionContext): Promise { Telemetry.register(context); @@ -192,6 +195,10 @@ export async function activate(context: vscode.ExtensionContext): Promise { + const systemMessages: ChatCompletionRequestMessage[] = [ + { + content: `You are a software developer explaining how code works.`, + role: 'system', + }, + { + content: `Be as assertive as possible. Make your best guess based on the information you have available.`, + role: 'system', + }, + { + content: `Try to include URL routes, SQL queries, function names, and code snippets.`, + role: 'system', + }, + { + content: `Use a list format for the answer.`, + role: 'system', + }, + { + content: `Do not discuss software development in general terms.`, + role: 'system', + }, + { + content: `Do not make statements like "However, without seeing the complete codebase...".`, + role: 'system', + }, + ]; + + const userMessages: ChatCompletionRequestMessage[] = [ + `A code snippet is: ${codeToDescribe}`, + `The code snippet part of a function called: ${codeObject.fqid}`, + `The code snippet returns ${returnValues.join(' or ')}`, + `It's used to implement higher-level functions such as ${scopeCodeObjects + .map((co) => co.fqid) + .join(', ')}`, + ...functions.map((fn) => `Related code includes: ${fn}`), + `A PlantUML sequence diagram about this code flow is: ${sequenceDiagram}`, + `Answer the following question about the code snippet: "${question}"`, + ].map((message) => ({ + content: message, + role: 'user' as ChatCompletionRequestMessageRoleEnum, + })); + + const messages = [...systemMessages, ...userMessages]; + + let response: CreateChatCompletionResponse; + try { + const result = await openAI.createChatCompletion({ + model: 'gpt-3.5-turbo-16k', + messages, + n: 1, + max_tokens: + // 16384 is the maximum number of tokens allowed by gpt-3.5-turbo-16k + 16384 - + // This is a conservative estimate of the number of tokens in the prompt, since + // a token can be more than one character. + (messages.map((msg) => msg.content?.length).filter(Boolean) as number[]).reduce( + (a, b) => a + b, + 0 + ), + }); + response = result.data; + } catch (e) { + vscode.window.showErrorMessage((e as any).toString()); + return; + } + + const promptContent = [ + '```', + ...messages.map((msg) => [msg.role, msg.content].join(': ')), + '```', + ].join('\n'); + const responseContent = response.choices + .filter((choice) => choice.message) + .map((choice) => (assert(choice.message), choice.message.content)) + .filter(Boolean) + .join('\n'); + + const newDocument = await vscode.workspace.openTextDocument({ + content: [ + `## ${codeToDescribe}`, + responseContent, + `## Prompt +
+ Click to expand + + ${promptContent} + +
+ `, + ].join('\n\n'), + }); + await vscode.window.showTextDocument(newDocument); + + return responseContent; +} diff --git a/src/services/lineInfoIndex.ts b/src/services/lineInfoIndex.ts index 6fc78105..ca2fdac7 100644 --- a/src/services/lineInfoIndex.ts +++ b/src/services/lineInfoIndex.ts @@ -3,7 +3,7 @@ import * as vscode from 'vscode'; import ClassMapIndex from './classMapIndex'; import { CodeObjectEntry } from '../lib/CodeObjectEntry'; -class LineInfo { +export class LineInfo { public codeObjects?: CodeObjectEntry[]; constructor(public line: number) {} From 909518b626c4f739e11ffbba10f1548571da6d55 Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Wed, 26 Jul 2023 09:26:57 -0400 Subject: [PATCH 09/12] feat: Generate code via AI --- extensions.json | 1 + package.json | 4 + src/commands/ask.ts | 169 +++++++++---------------- src/commands/generate.ts | 128 +++++++++++++++++++ src/extension.ts | 4 +- src/lib/ask.ts | 230 +++++++++++++++++++---------------- src/lib/ask/contextAppMap.ts | 47 +++++++ src/lib/ask/selectedCode.ts | 20 +++ 8 files changed, 384 insertions(+), 219 deletions(-) create mode 100644 extensions.json create mode 100644 src/commands/generate.ts create mode 100644 src/lib/ask/contextAppMap.ts create mode 100644 src/lib/ask/selectedCode.ts diff --git a/extensions.json b/extensions.json new file mode 100644 index 00000000..d54a2f2e --- /dev/null +++ b/extensions.json @@ -0,0 +1 @@ +[{"identifier":{"id":"undefined_publisher.undefined"},"location":{"$mid":1,"fsPath":"/Users/kgilpin/source/appland/vscode-appland/extern","path":"/Users/kgilpin/source/appland/vscode-appland/extern","scheme":"file"},"relativeLocation":"extern"},{"identifier":{"id":"appland.appmap","uuid":"41d86b02-68d3-4049-9422-95da6d11cc2e"},"version":"0.90.1","location":{"$mid":1,"fsPath":"/Users/kgilpin/source/appland/vscode-appland/out","path":"/Users/kgilpin/source/appland/vscode-appland/out","scheme":"file"},"relativeLocation":"out","metadata":{"id":"41d86b02-68d3-4049-9422-95da6d11cc2e","publisherDisplayName":"AppLand","publisherId":"f7f1004e-6038-49cd-a096-4e618fe53f77","isPreReleaseVersion":false}}] \ No newline at end of file diff --git a/package.json b/package.json index b8ed59ea..94026c71 100644 --- a/package.json +++ b/package.json @@ -143,6 +143,10 @@ "command": "appmap.ask", "title": "AppMap: Ask a Question About the Current Code Selection" }, + { + "command": "appmap.generate", + "title": "AppMap: Generate Code Based on the Current Code Selection" + }, { "command": "appmap.touchOutOfDateTestFiles", "title": "AppMap: Touch Out-of-Date Test Files" diff --git a/src/commands/ask.ts b/src/commands/ask.ts index 2cc74a50..65b53ff3 100644 --- a/src/commands/ask.ts +++ b/src/commands/ask.ts @@ -2,7 +2,6 @@ import * as vscode from 'vscode'; import LineInfoIndex, { LineInfo } from '../services/lineInfoIndex'; import assert from 'assert'; import AppMapCollection from '../services/appmapCollection'; -import ClassMapIndex from '../services/classMapIndex'; import { promptForAppMap } from '../lib/promptForAppMap'; import { debug } from 'console'; import { readFile, writeFile } from 'fs/promises'; @@ -15,10 +14,12 @@ import { buildAppMap, } from '@appland/models'; import buildOpenAIApi from '../lib/buildOpenAIApi'; -import ask from '../lib/ask'; -import { isAbsolute, join } from 'path'; -import { fileExists } from '../util'; +import { join } from 'path'; import { FormatType, Specification, buildDiagram, format } from '@appland/sequence-diagram'; +import { randomUUID } from 'crypto'; +import { Completion, Question } from '../lib/Ask'; +import selectedCode from '../lib/ask/selectedCode'; +import contextAppMap from '../lib/ask/contextAppMap'; class LineInfoQuickPickItem implements vscode.QuickPickItem { constructor(public lineInfo: LineInfo) {} @@ -34,7 +35,6 @@ class LineInfoQuickPickItem implements vscode.QuickPickItem { export default function register( context: vscode.ExtensionContext, lineInfoIndex: LineInfoIndex, - classMapIndex: ClassMapIndex, appmapCollection: AppMapCollection ): void { context.subscriptions.push( @@ -42,26 +42,27 @@ export default function register( const { activeTextEditor } = vscode.window; if (!activeTextEditor) return; - const { selection } = activeTextEditor; + const selection = selectedCode(activeTextEditor); if (!selection) return; - const startLine = selection.anchor.line; - const endLine = selection.active.line; - let selectedCode = activeTextEditor.document.getText(selection).trim(); - if (selectedCode === '') - selectedCode = activeTextEditor.document.lineAt(startLine).text.trim(); - const documentUri = activeTextEditor.document.uri; + const workspaceFolder = vscode.workspace.getWorkspaceFolder(documentUri); + if (!workspaceFolder) return; + const lineInfo = (await lineInfoIndex.lineInfo(documentUri)).filter( (line) => line.codeObjects ); - const workspaceFolder = vscode.workspace.getWorkspaceFolder(documentUri); - if (!workspaceFolder) return; + if (lineInfo.length === 0) { + vscode.window.showErrorMessage( + `I couldn't find any AppMaps related to ${documentUri.path}` + ); + return; + } const lineCodePrompt = - selectedCode.split('\n').length === 0 - ? selectedCode - : [selectedCode.split('\n')[0], '...'].join(''); + selection.text.split('\n').length === 0 + ? selection.text + : [selection.text.split('\n')[0], '...'].join(''); const question = await vscode.window.showInputBox({ placeHolder: `Ask a question about: ${lineCodePrompt}`, value: 'How does this code work?', @@ -69,7 +70,10 @@ export default function register( if (!question) return; const distanceFromSelection = (item: LineInfoQuickPickItem): number => - Math.min(Math.abs(item.lineInfo.line - startLine), Math.abs(item.lineInfo.line - endLine)); + Math.min( + Math.abs(item.lineInfo.line - selection.startLine), + Math.abs(item.lineInfo.line - selection.endLine) + ); const lineInfoItems = lineInfo.map((info) => new LineInfoQuickPickItem(info)); const lineDistance = lineInfoItems.map(distanceFromSelection); @@ -114,16 +118,14 @@ export default function register( appMapFileName = selectedAppMap.fsPath; } + const q = new Question(selection.text, question); + const appmapUri = vscode.Uri.file(appMapFileName); - let codeObject: CodeObject | undefined; - let scopeCodeObjects: CodeObject[]; - let returnValues: string[]; - let functions: string[]; - let sequenceDiagram: string; { const data = await readFile(appmapUri.fsPath, 'utf-8'); const appmap = buildAppMap().source(data).build(); + let codeObject: CodeObject | undefined; appmap.classMap.visit((co) => { if (co.fqid === codeObjectEntry.fqid) codeObject = co; }); @@ -133,6 +135,7 @@ export default function register( ); return; } + q.codeObject = codeObject; const { events } = appmap; let filterAppMap: AppMap; @@ -140,7 +143,7 @@ export default function register( const codeObjectEvents = events.filter( (event) => event.codeObject.fqid === codeObjectEntry.fqid ); - returnValues = [ + q.returnValues = [ ...new Set( codeObjectEvents .map((e) => { @@ -151,83 +154,14 @@ export default function register( .filter(Boolean) ), ] as string[]; - const roots: Event[] = []; - const filterEvents: Event[] = [...codeObjectEvents]; - for (const event of codeObjectEvents) { - for (const ancestor of new EventNavigator(event).ancestors()) { - filterEvents.push(ancestor.event); - if (ancestor.event.httpServerRequest) { - roots.push(ancestor.event); - break; - } - if (!ancestor.event.parent) roots.push(ancestor.event); - } - for (const child of event.children) { - filterEvents.push(child); - for (const child2 of child.children) { - filterEvents.push(child2); - for (const child3 of child2.children) { - filterEvents.push(child3); - for (const child4 of child3.children) filterEvents.push(child4); - } - } - } - } - for (const root of roots) { - for (const child of root.children) { - filterEvents.push(child); - for (const child2 of child.children) { - filterEvents.push(child2); - for (const child3 of child2.children) { - filterEvents.push(child3); - for (const child4 of child3.children) filterEvents.push(child4); - } - } - } - } - - scopeCodeObjects = [...new Set(roots.map((e) => e.codeObject))]; - // const relatedCodeObjects = [...new Set(filterEvents.map((e) => e.codeObject))]; - // KEG: I want to avoid sending too much source code to the AI, as it places undue priority - // on the source code rather than the code flow. - functions = []; - for (const co of [codeObject] /* relatedCodeObjects */) { - const codeObjectEntry = await classMapIndex.lookupCodeObject(co.fqid); - if (!codeObjectEntry) continue; - if (!codeObjectEntry.path || !codeObjectEntry?.lineNo) continue; - - let { path } = codeObjectEntry; - const { lineNo } = codeObjectEntry; - if (!isAbsolute(path)) path = join(codeObjectEntry.folder.uri.fsPath, path); - if (!(await fileExists(path))) continue; - if (!path.startsWith(codeObjectEntry.folder.uri.fsPath)) continue; - if ( - path.slice(codeObjectEntry.folder.uri.fsPath.length).startsWith('/node_modules/') || - path.slice(codeObjectEntry.folder.uri.fsPath.length).startsWith('/vendor/') - ) - continue; - - const code = (await readFile(path, 'utf-8')).split('\n'); - // Sort lineInfo by distance from codeObject - const distanceFromCodeObject = (lineInfo: LineInfo) => lineInfo.line - lineNo; - const nextLineInfo = lineInfo - .sort((a, b) => distanceFromCodeObject(a) - distanceFromCodeObject(b)) - .filter((li) => distanceFromCodeObject(li) > 0) - .sort()[0]; - const lastLine = nextLineInfo ? nextLineInfo.line : codeObjectEntry.lineNo + 10; - const codeLines = code.slice(codeObjectEntry.lineNo - 1, lastLine - 1); - functions.push([codeObjectEntry.fqid, ...codeLines].join('\n')); - } + filterAppMap = contextAppMap(appmap, codeObjectEvents); - const eventIds = new Set(filterEvents.map((e) => e.id)); - filterAppMap = buildAppMap({ - events: events.filter((e) => eventIds.has(e.callEvent.id)), - classMap: appmap.classMap.roots.map((c) => ({ ...c.data })), - metadata: appmap.metadata, - }).build(); + q.scopeCodeObjects = [...new Set(appmap.rootEvents().map((e) => e.codeObject))]; + } + { const filterAppMapData = JSON.stringify(filterAppMap, null, 2); - let appmapName = question.replace(/[^a-zA-Z0-9]/g, '-'); + let appmapName = ['ask_appmap', randomUUID()].join('-'); // question.replace(/[^a-zA-Z0-9]/g, '-'); KEG: Can result in a too-long file name. if (appmapName.endsWith('-')) appmapName = appmapName.slice(0, -1); const filePath = join(workspaceFolder?.uri.fsPath, `${appmapName}.appmap.json`); await writeFile(filePath, filterAppMapData); @@ -236,7 +170,6 @@ export default function register( } const specification = Specification.build(filterAppMap, { loops: true }); - assert(appmapUri); let sequenceDiagramAppMap: AppMap; { @@ -246,37 +179,49 @@ export default function register( sequenceDiagramAppMap = sequenceDiagramFilter.filter(filterAppMap, []); } const diagram = buildDiagram(appmapUri.fsPath, sequenceDiagramAppMap, specification); - sequenceDiagram = format(FormatType.PlantUML, diagram, appmapUri.fsPath).diagram; + q.sequenceDiagram = format(FormatType.PlantUML, diagram, appmapUri.fsPath).diagram; } const openAI = await buildOpenAIApi(context); if (!openAI) return; + let completion: Completion | undefined; await vscode.window.withProgress( { title: `Thinking about your question...`, location: vscode.ProgressLocation.Notification, }, async () => { - assert(codeObject); - try { - await ask( - question, - selectedCode, - codeObject, - returnValues, - scopeCodeObjects, - functions, - sequenceDiagram, - openAI - ); + completion = await q.complete(openAI); } catch (e) { debug((e as any).toString()); vscode.window.showErrorMessage(`Unable to process your question: ${e}`); } } ); + if (!completion) return; + + const fence = '```'; + const responseText = [ + `## ${lineCodePrompt}`, + `### You asked`, + `${fence}${question}${fence}`, + `### AI response:`, + completion.response, + `## Prompt +
+Click to expand + +${fence} +${completion.prompt} +${fence} +
`, + ].join('\n\n'); + const newDocument = await vscode.workspace.openTextDocument({ + content: responseText, + }); + await vscode.window.showTextDocument(newDocument); }) ); } diff --git a/src/commands/generate.ts b/src/commands/generate.ts new file mode 100644 index 00000000..42c3d639 --- /dev/null +++ b/src/commands/generate.ts @@ -0,0 +1,128 @@ +import * as vscode from 'vscode'; +import AppMapCollection from '../services/appmapCollection'; +import selectedCode from '../lib/ask/selectedCode'; +import { promptForAppMap } from '../lib/promptForAppMap'; +import { CodeGen, Completion } from '../lib/Ask'; +import assert from 'assert'; +import buildOpenAIApi from '../lib/buildOpenAIApi'; +import { FormatType, Specification, buildDiagram, format } from '@appland/sequence-diagram'; +import { AppMap, AppMapFilter, buildAppMap } from '@appland/models'; +import contextAppMap from '../lib/ask/contextAppMap'; +import { readFile, writeFile } from 'fs/promises'; +import { randomUUID } from 'crypto'; +import { join } from 'path'; +import { debug } from 'console'; + +export default function register( + context: vscode.ExtensionContext, + appmapCollection: AppMapCollection +): void { + context.subscriptions.push( + vscode.commands.registerCommand('appmap.generate', async () => { + const documentUri = vscode.window.activeTextEditor?.document.uri; + if (!documentUri) return; + + const workspaceFolder = vscode.workspace.getWorkspaceFolder(documentUri); + if (!workspaceFolder) return; + + const selection = selectedCode(vscode.window.activeTextEditor); + if (!selection) return; + + const lineCodePrompt = + selection.text.split('\n').length === 0 + ? selection.text + : [selection.text.split('\n')[0], '...'].join(''); + const question = await vscode.window.showInputBox({ + placeHolder: `Generated code related to: ${lineCodePrompt}`, + }); + if (!question) return; + + const appmaps = appmapCollection.allAppMaps(); + const appmapUri = await promptForAppMap(appmaps); + if (!appmapUri) return; + + const g = new CodeGen(selection.text, question); + + { + const appmapEntry = appmaps.find((appmap) => appmap.descriptor.resourceUri === appmapUri); + assert(appmapEntry); + + if (appmapEntry.descriptor.metadata?.language?.name) + g.language = appmapEntry.descriptor.metadata.language.name; + } + + let filterAppMap: AppMap; + { + const data = await readFile(appmapUri.fsPath, 'utf-8'); + const appmap = buildAppMap().source(data).build(); + + filterAppMap = contextAppMap(appmap, appmap.rootEvents()); + + g.scopeCodeObjects = [...new Set(appmap.rootEvents().map((e) => e.codeObject))]; + } + + { + const filterAppMapData = JSON.stringify(filterAppMap, null, 2); + let appmapName = ['ask_appmap', randomUUID()].join('-'); // question.replace(/[^a-zA-Z0-9]/g, '-'); KEG: Can result in a too-long file name. + if (appmapName.endsWith('-')) appmapName = appmapName.slice(0, -1); + const filePath = join(workspaceFolder.uri.fsPath, `${appmapName}.appmap.json`); + await writeFile(filePath, filterAppMapData); + const uri = vscode.Uri.file(filePath); + await vscode.commands.executeCommand('vscode.openWith', uri, 'appmap.views.appMapFile'); + } + + const specification = Specification.build(filterAppMap, { loops: true }); + assert(appmapUri); + let sequenceDiagramAppMap: AppMap; + { + const sequenceDiagramFilter = new AppMapFilter(); + if (filterAppMap.metadata.language?.name !== 'java') + sequenceDiagramFilter.declutter.hideExternalPaths.on = true; + sequenceDiagramAppMap = sequenceDiagramFilter.filter(filterAppMap, []); + } + const diagram = buildDiagram(appmapUri.fsPath, sequenceDiagramAppMap, specification); + g.sequenceDiagram = format(FormatType.PlantUML, diagram, appmapUri.fsPath).diagram; + + const openAI = await buildOpenAIApi(context); + if (!openAI) return; + + let completion: Completion | undefined; + await vscode.window.withProgress( + { + title: `Generating code...`, + location: vscode.ProgressLocation.Notification, + }, + async () => { + try { + completion = await g.complete(openAI); + } catch (e) { + debug((e as any).toString()); + vscode.window.showErrorMessage(`Unable to process your question: ${e}`); + } + } + ); + if (!completion) return; + + const fence = '```'; + const responseText = [ + `## ${lineCodePrompt}`, + `### You asked`, + `${fence}${question}${fence}`, + `### AI response:`, + completion.response, + `## Prompt +
+Click to expand + +${fence} +${completion.prompt} +${fence} +
`, + ].join('\n\n'); + const newDocument = await vscode.workspace.openTextDocument({ + content: responseText, + }); + await vscode.window.showTextDocument(newDocument); + }) + ); +} diff --git a/src/extension.ts b/src/extension.ts index d140e0f6..ff0ee764 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -61,6 +61,7 @@ import Watcher from './services/watcher'; import { default as fixFinding } from './commands/fixFinding'; import { default as fixTest } from './commands/fixTest'; import { default as ask } from './commands/ask'; +import { default as generate } from './commands/generate'; export async function activate(context: vscode.ExtensionContext): Promise { Telemetry.register(context); @@ -197,7 +198,8 @@ export async function activate(context: vscode.ExtensionContext): Promise { - const systemMessages: ChatCompletionRequestMessage[] = [ - { - content: `You are a software developer explaining how code works.`, - role: 'system', - }, - { - content: `Be as assertive as possible. Make your best guess based on the information you have available.`, - role: 'system', - }, - { - content: `Try to include URL routes, SQL queries, function names, and code snippets.`, - role: 'system', - }, - { - content: `Use a list format for the answer.`, - role: 'system', - }, - { - content: `Do not discuss software development in general terms.`, - role: 'system', - }, - { - content: `Do not make statements like "However, without seeing the complete codebase...".`, - role: 'system', - }, - ]; - - const userMessages: ChatCompletionRequestMessage[] = [ - `A code snippet is: ${codeToDescribe}`, - `The code snippet part of a function called: ${codeObject.fqid}`, - `The code snippet returns ${returnValues.join(' or ')}`, - `It's used to implement higher-level functions such as ${scopeCodeObjects - .map((co) => co.fqid) - .join(', ')}`, - ...functions.map((fn) => `Related code includes: ${fn}`), - `A PlantUML sequence diagram about this code flow is: ${sequenceDiagram}`, - `Answer the following question about the code snippet: "${question}"`, - ].map((message) => ({ - content: message, - role: 'user' as ChatCompletionRequestMessageRoleEnum, - })); - - const messages = [...systemMessages, ...userMessages]; - - let response: CreateChatCompletionResponse; - try { - const result = await openAI.createChatCompletion({ - model: 'gpt-3.5-turbo-16k', - messages, - n: 1, - max_tokens: - // 16384 is the maximum number of tokens allowed by gpt-3.5-turbo-16k - 16384 - - // This is a conservative estimate of the number of tokens in the prompt, since - // a token can be more than one character. - (messages.map((msg) => msg.content?.length).filter(Boolean) as number[]).reduce( - (a, b) => a + b, - 0 - ), - }); - response = result.data; - } catch (e) { - vscode.window.showErrorMessage((e as any).toString()); - return; +export type Completion = { + prompt: string; + response: string; +}; + +abstract class Ask { + public model = 'gpt-3.5-turbo-16k'; + + public language?: string; + public codeObject?: CodeObject; + public scopeCodeObjects?: CodeObject[]; + public returnValues?: string[]; + public sequenceDiagram?: string; + + public constructor(public readonly selectedCode: string, public readonly question: string) {} + + async complete(ai: OpenAIApi): Promise { + const propertyBasedUserMessages: string[] = [`A code snippet is: ${this.selectedCode}`]; + if (this.codeObject) + propertyBasedUserMessages.push( + `The code snippet belongs to a function called: ${this.codeObject.fqid}` + ); + if (this.returnValues && this.returnValues.length > 0) + propertyBasedUserMessages.push(`The code snippet returns ${this.returnValues.join(' or ')}`); + if (this.scopeCodeObjects && this.scopeCodeObjects.length > 0) + propertyBasedUserMessages.push( + `The code snippet is used by higher-level functions such as ${this.scopeCodeObjects + .map((co) => co.fqid) + .join(', ')}` + ); + if (this.sequenceDiagram) + propertyBasedUserMessages.push( + `A PlantUML sequence diagram about this code flow is: ${this.sequenceDiagram}` + ); + + const systemMessages: ChatCompletionRequestMessage[] = this.systemMessages.map((message) => ({ + content: message, + role: 'system' as ChatCompletionRequestMessageRoleEnum, + })); + + const userMessages: ChatCompletionRequestMessage[] = [ + ...propertyBasedUserMessages, + ...this.userMessages, + ].map((message) => ({ + content: message, + role: 'user' as ChatCompletionRequestMessageRoleEnum, + })); + + const messages = [...systemMessages, ...userMessages]; + try { + const result = await ai.createChatCompletion({ + model: this.model, + messages, + n: 1, + max_tokens: + // 16384 is the maximum number of tokens allowed by gpt-3.5-turbo-16k + 16384 - + // This is a conservative estimate of the number of tokens in the prompt, since + // a token can be more than one character. + (messages.map((msg) => msg.content?.length).filter(Boolean) as number[]).reduce( + (a, b) => a + b, + 0 + ), + }); + const response = result.data.choices + .map((choice) => (assert(choice.message), choice.message.content)) + .filter(Boolean) + .join('\n'); + return { + prompt: messages.map((msg) => [msg.role, msg.content].join(': ')).join('\n'), + response, + }; + } catch (e) { + warn((e as any).toString()); + return; + } } - const promptContent = [ - '```', - ...messages.map((msg) => [msg.role, msg.content].join(': ')), - '```', - ].join('\n'); - const responseContent = response.choices - .filter((choice) => choice.message) - .map((choice) => (assert(choice.message), choice.message.content)) - .filter(Boolean) - .join('\n'); - - const newDocument = await vscode.workspace.openTextDocument({ - content: [ - `## ${codeToDescribe}`, - responseContent, - `## Prompt -
- Click to expand - - ${promptContent} - -
- `, - ].join('\n\n'), - }); - await vscode.window.showTextDocument(newDocument); - - return responseContent; + protected abstract get systemMessages(): string[]; + + protected abstract get userMessages(): string[]; +} + +export class Question extends Ask { + public constructor(public readonly selectedCode: string, public readonly question: string) { + super(selectedCode, question); + } + + protected get userMessages(): string[] { + return [`Answer the following question about the code: "${this.question}"`]; + } + + protected get systemMessages(): string[] { + return [ + `You are a software developer explaining how code works.`, + `Be as assertive as possible. Make your best guess based on the information you have available.`, + `Try to include URL routes, SQL queries, function names, and code snippets.`, + `Use a list format for the answer.`, + `Do not discuss software development in general terms.`, + `Do not make statements like "However, without seeing the complete codebase...".`, + ]; + } +} + +export class CodeGen extends Ask { + public constructor(public readonly selectedCode: string, public readonly question: string) { + super(selectedCode, question); + } + + protected get userMessages(): string[] { + return [`Generate code to: "${this.question}"`]; + } + + protected get systemMessages(): string[] { + const messages = [ + `You are a software developer writing code.`, + `Be as assertive as possible. Make your best guess based on the information you have available.`, + `Respond with code that is syntactically correct.`, + ]; + if (this.language) messages.push(`Respond with code in ${this.language}.`); + + return messages; + } } diff --git a/src/lib/ask/contextAppMap.ts b/src/lib/ask/contextAppMap.ts new file mode 100644 index 00000000..79b6a07e --- /dev/null +++ b/src/lib/ask/contextAppMap.ts @@ -0,0 +1,47 @@ +import { AppMap, Event, EventNavigator, buildAppMap } from '@appland/models'; + +export default function contextAppMap(appmap: AppMap, scopeEvents: Event[]): AppMap { + const { events } = appmap; + + const roots: Event[] = []; + const filterEvents: Event[] = [...scopeEvents]; + for (const event of scopeEvents) { + for (const ancestor of new EventNavigator(event).ancestors()) { + filterEvents.push(ancestor.event); + if (ancestor.event.httpServerRequest) { + roots.push(ancestor.event); + break; + } + if (!ancestor.event.parent) roots.push(ancestor.event); + } + for (const child of event.children) { + filterEvents.push(child); + for (const child2 of child.children) { + filterEvents.push(child2); + for (const child3 of child2.children) { + filterEvents.push(child3); + for (const child4 of child3.children) filterEvents.push(child4); + } + } + } + } + for (const root of roots) { + for (const child of root.children) { + filterEvents.push(child); + for (const child2 of child.children) { + filterEvents.push(child2); + for (const child3 of child2.children) { + filterEvents.push(child3); + for (const child4 of child3.children) filterEvents.push(child4); + } + } + } + } + + const eventIds = new Set(filterEvents.map((e) => e.id)); + return buildAppMap({ + events: events.filter((e) => eventIds.has(e.callEvent.id)), + classMap: appmap.classMap.roots.map((c) => ({ ...c.data })), + metadata: appmap.metadata, + }).build(); +} diff --git a/src/lib/ask/selectedCode.ts b/src/lib/ask/selectedCode.ts new file mode 100644 index 00000000..d874029c --- /dev/null +++ b/src/lib/ask/selectedCode.ts @@ -0,0 +1,20 @@ +import * as vscode from 'vscode'; + +export type Selection = { + startLine: number; + endLine: number; + text: string; +}; + +export default function selectedCode(textEditor?: vscode.TextEditor): Selection | undefined { + if (!textEditor) return; + + const { selection } = textEditor; + if (!selection) return; + + const startLine = selection.anchor.line; + const endLine = selection.active.line; + let selectedCode = textEditor.document.getText(selection).trim(); + if (selectedCode === '') selectedCode = textEditor.document.lineAt(startLine).text.trim(); + return { startLine, endLine, text: selectedCode }; +} From 9efe3c250d6550cb718e77c89bcec7f0ab216ce6 Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Wed, 27 Sep 2023 14:13:13 -0400 Subject: [PATCH 10/12] fix: Fix ask.js imports --- src/commands/ask.ts | 2 +- src/commands/generate.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/ask.ts b/src/commands/ask.ts index 65b53ff3..ff5890cf 100644 --- a/src/commands/ask.ts +++ b/src/commands/ask.ts @@ -17,7 +17,7 @@ import buildOpenAIApi from '../lib/buildOpenAIApi'; import { join } from 'path'; import { FormatType, Specification, buildDiagram, format } from '@appland/sequence-diagram'; import { randomUUID } from 'crypto'; -import { Completion, Question } from '../lib/Ask'; +import { Completion, Question } from '../lib/ask'; import selectedCode from '../lib/ask/selectedCode'; import contextAppMap from '../lib/ask/contextAppMap'; diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 42c3d639..148c79a8 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import AppMapCollection from '../services/appmapCollection'; import selectedCode from '../lib/ask/selectedCode'; import { promptForAppMap } from '../lib/promptForAppMap'; -import { CodeGen, Completion } from '../lib/Ask'; +import { CodeGen, Completion } from '../lib/ask'; import assert from 'assert'; import buildOpenAIApi from '../lib/buildOpenAIApi'; import { FormatType, Specification, buildDiagram, format } from '@appland/sequence-diagram'; From 937cf152ea4819d5e3fb4c6d0d4501435335e164 Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Wed, 27 Sep 2023 14:25:52 -0400 Subject: [PATCH 11/12] fix: Linter warnings --- src/commands/ask.ts | 11 ++--------- src/commands/fixFinding.ts | 3 ++- src/commands/fixTest.ts | 2 +- src/commands/generate.ts | 2 +- src/lib/ask.ts | 2 +- src/lib/suggestFix.ts | 3 ++- 6 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/commands/ask.ts b/src/commands/ask.ts index ff5890cf..9493e231 100644 --- a/src/commands/ask.ts +++ b/src/commands/ask.ts @@ -5,14 +5,7 @@ import AppMapCollection from '../services/appmapCollection'; import { promptForAppMap } from '../lib/promptForAppMap'; import { debug } from 'console'; import { readFile, writeFile } from 'fs/promises'; -import { - AppMap, - AppMapFilter, - CodeObject, - Event, - EventNavigator, - buildAppMap, -} from '@appland/models'; +import { AppMap, AppMapFilter, CodeObject, buildAppMap } from '@appland/models'; import buildOpenAIApi from '../lib/buildOpenAIApi'; import { join } from 'path'; import { FormatType, Specification, buildDiagram, format } from '@appland/sequence-diagram'; @@ -195,7 +188,7 @@ export default function register( try { completion = await q.complete(openAI); } catch (e) { - debug((e as any).toString()); + debug(e); vscode.window.showErrorMessage(`Unable to process your question: ${e}`); } } diff --git a/src/commands/fixFinding.ts b/src/commands/fixFinding.ts index 52f01410..5903ac2d 100644 --- a/src/commands/fixFinding.ts +++ b/src/commands/fixFinding.ts @@ -125,6 +125,7 @@ export default function register(context: vscode.ExtensionContext): void { findingHash = selection.finding.finding.hash_v2; } + if (!findingHash) return; const findings = findingsIndex.findingsByHash(findingHash); if (!findings || findings.length === 0) return; @@ -152,7 +153,7 @@ export default function register(context: vscode.ExtensionContext): void { try { await fixFinding(finding, openAI); } catch (e) { - debug((e as any).toString()); + debug(e); vscode.window.showErrorMessage(`Failed to analyze finding: ${e}`); } } diff --git a/src/commands/fixTest.ts b/src/commands/fixTest.ts index 1918b791..08d94a3b 100644 --- a/src/commands/fixTest.ts +++ b/src/commands/fixTest.ts @@ -187,7 +187,7 @@ export default function register( try { await fixFailedTest(appmap, openAI); } catch (e) { - debug((e as any).toString()); + debug(e); vscode.window.showErrorMessage(`Failed to analyze failed test: ${e}`); } } diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 148c79a8..4f8c7835 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -96,7 +96,7 @@ export default function register( try { completion = await g.complete(openAI); } catch (e) { - debug((e as any).toString()); + debug(e.toString()); vscode.window.showErrorMessage(`Unable to process your question: ${e}`); } } diff --git a/src/lib/ask.ts b/src/lib/ask.ts index 5e15e221..0e45b8b1 100644 --- a/src/lib/ask.ts +++ b/src/lib/ask.ts @@ -80,7 +80,7 @@ abstract class Ask { response, }; } catch (e) { - warn((e as any).toString()); + warn(e); return; } } diff --git a/src/lib/suggestFix.ts b/src/lib/suggestFix.ts index 47ea28bc..1d5273b4 100644 --- a/src/lib/suggestFix.ts +++ b/src/lib/suggestFix.ts @@ -30,7 +30,8 @@ export async function suggestFix( }); response = result.data; } catch (e) { - vscode.window.showErrorMessage((e as any).toString()); + if (e instanceof Error) vscode.window.showErrorMessage(e.message); + else vscode.window.showErrorMessage(`Failed to analyze failed test: ${e}`); return; } From 2a47e09d253881c4d4a9760c29215864460fdfc1 Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Tue, 14 Nov 2023 12:44:48 -0500 Subject: [PATCH 12/12] fix: Fix lint errors --- src/commands/fixFinding.ts | 2 +- src/commands/generate.ts | 3 ++- src/services/findingLocation.ts | 2 +- src/services/findingsIndex.ts | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/commands/fixFinding.ts b/src/commands/fixFinding.ts index 5903ac2d..d1da3a49 100644 --- a/src/commands/fixFinding.ts +++ b/src/commands/fixFinding.ts @@ -41,7 +41,7 @@ export async function fixFinding(finding: ResolvedFinding, openAI: OpenAIApi) { } } - let participatingEvents = Object.values(finding.finding.participatingEvents || {}); + let participatingEvents: Event[] = Object.values(finding.finding.participatingEvents || {}); if (participatingEvents.length === 0) participatingEvents = [finding.finding.event]; const locations = [ diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 4f8c7835..6645635a 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -96,7 +96,8 @@ export default function register( try { completion = await g.complete(openAI); } catch (e) { - debug(e.toString()); + if (e instanceof Error) debug(e.toString()); + else debug(`${e}`); vscode.window.showErrorMessage(`Unable to process your question: ${e}`); } } diff --git a/src/services/findingLocation.ts b/src/services/findingLocation.ts index 4a3d48b5..520afcff 100644 --- a/src/services/findingLocation.ts +++ b/src/services/findingLocation.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { Finding } from '@appland/scanner/built/cli'; +import { Finding } from '@appland/scanner'; export type FindingLocation = { finding: Finding; diff --git a/src/services/findingsIndex.ts b/src/services/findingsIndex.ts index ff8ffb2f..5578cb8b 100644 --- a/src/services/findingsIndex.ts +++ b/src/services/findingsIndex.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { Finding } from '@appland/scanner/built/cli'; +import { Finding } from '@appland/scanner'; import { ResolvedFinding } from './resolvedFinding'; import { debuglog, promisify } from 'util'; import { readFile } from 'fs';