Skip to content

Commit 239fb5d

Browse files
authored
feat(amazonq): multi-root workspace support #4948
Problem Security scans don't work for multi-root workspaces. Solution - Gather all open workspace folders for security scan payload instead of from the active file - Since we no longer require the active file, remove the requirement of having a file open to run scans
1 parent 09bfb14 commit 239fb5d

File tree

12 files changed

+119
-141
lines changed

12 files changed

+119
-141
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Feature",
3+
"description": "Security Scan: Scans can now be run without an open editor"
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Feature",
3+
"description": "Security Scan: Multi-root workspace support"
4+
}

packages/core/src/codewhisperer/commands/basicCommands.ts

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import { FeatureConfigProvider } from '../service/featureConfigProvider'
3333
import { TelemetryHelper } from '../util/telemetryHelper'
3434
import { Auth, AwsConnection } from '../../auth'
3535
import { once } from '../../shared/utilities/functionUtils'
36-
import { isTextEditor } from '../../shared/utilities/editorUtilities'
3736
import { focusAmazonQPanel } from '../../codewhispererChat/commands/registerCommands'
3837
import { removeDiagnostic } from '../service/diagnosticsProvider'
3938
import { SecurityIssueHoverProvider } from '../service/securityIssueHoverProvider'
@@ -117,26 +116,16 @@ export const showSecurityScan = Commands.declare(
117116
if (AuthUtil.instance.isConnectionExpired()) {
118117
await AuthUtil.instance.notifyReauthenticate()
119118
}
120-
const editor = vscode.window.activeTextEditor
121-
if (editor && isTextEditor(editor)) {
122-
if (codeScanState.isNotStarted()) {
123-
// User intends to start as "Start Security Scan" is shown in the explorer tree
124-
codeScanState.setToRunning()
125-
void startSecurityScanWithProgress(
126-
securityPanelViewProvider,
127-
editor,
128-
client,
129-
context.extensionContext
130-
)
131-
} else if (codeScanState.isRunning()) {
132-
// User intends to stop as "Stop Security Scan" is shown in the explorer tree
133-
// Cancel only when the code scan state is "Running"
134-
await confirmStopSecurityScan()
135-
}
136-
vsCodeState.isFreeTierLimitReached = false
137-
} else {
138-
void vscode.window.showInformationMessage('Open a valid file to scan.')
119+
if (codeScanState.isNotStarted()) {
120+
// User intends to start as "Start Security Scan" is shown in the explorer tree
121+
codeScanState.setToRunning()
122+
void startSecurityScanWithProgress(securityPanelViewProvider, client, context.extensionContext)
123+
} else if (codeScanState.isRunning()) {
124+
// User intends to stop as "Stop Security Scan" is shown in the explorer tree
125+
// Cancel only when the code scan state is "Running"
126+
await confirmStopSecurityScan()
139127
}
128+
vsCodeState.isFreeTierLimitReached = false
140129
}
141130
)
142131

packages/core/src/codewhisperer/commands/startSecurityScan.ts

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import {
2929
CodeScanTelemetryEntry,
3030
} from '../models/model'
3131
import { cancel, ok } from '../../shared/localizedText'
32-
import { getFileExt } from '../util/commonUtil'
3332
import { getDirSize } from '../../shared/filesystemUtilities'
3433
import { telemetry } from '../../shared/telemetry/telemetry'
3534
import { isAwsError } from '../../shared/errors'
@@ -63,7 +62,6 @@ const getLogOutputChan = once(() => {
6362

6463
export function startSecurityScanWithProgress(
6564
securityPanelViewProvider: SecurityPanelViewProvider,
66-
editor: vscode.TextEditor,
6765
client: DefaultCodeWhispererClient,
6866
context: vscode.ExtensionContext
6967
) {
@@ -76,7 +74,7 @@ export function startSecurityScanWithProgress(
7674
async () => {
7775
await startSecurityScan(
7876
securityPanelViewProvider,
79-
editor,
77+
undefined,
8078
client,
8179
context,
8280
CodeWhispererConstants.CodeAnalysisScope.PROJECT
@@ -92,7 +90,7 @@ export const debounceStartSecurityScan = debounce(
9290

9391
export async function startSecurityScan(
9492
securityPanelViewProvider: SecurityPanelViewProvider,
95-
editor: vscode.TextEditor,
93+
editor: vscode.TextEditor | undefined,
9694
client: DefaultCodeWhispererClient,
9795
context: vscode.ExtensionContext,
9896
scope: CodeWhispererConstants.CodeAnalysisScope
@@ -107,10 +105,12 @@ export async function startSecurityScan(
107105
}
108106
let serviceInvocationStartTime = 0
109107
const codeScanTelemetryEntry: CodeScanTelemetryEntry = {
110-
codewhispererLanguage: runtimeLanguageContext.getLanguageContext(
111-
editor.document.languageId,
112-
path.extname(editor.document.fileName)
113-
).language,
108+
codewhispererLanguage: editor
109+
? runtimeLanguageContext.getLanguageContext(
110+
editor.document.languageId,
111+
path.extname(editor.document.fileName)
112+
).language
113+
: 'plaintext',
114114
codewhispererCodeScanSrcPayloadBytes: 0,
115115
codewhispererCodeScanSrcZipFileBytes: 0,
116116
codewhispererCodeScanLines: 0,
@@ -131,8 +131,8 @@ export async function startSecurityScan(
131131
*/
132132
throwIfCancelled(scope, codeScanStartTime)
133133
const zipUtil = new ZipUtil()
134-
const zipMetadata = await zipUtil.generateZip(editor.document.uri, scope)
135-
const projectPath = zipUtil.getProjectPath(editor.document.uri)
134+
const zipMetadata = await zipUtil.generateZip(editor?.document.uri, scope)
135+
const projectPaths = zipUtil.getProjectPaths()
136136

137137
const contextTruncationStartTime = performance.now()
138138
codeScanTelemetryEntry.contextTruncationDuration = performance.now() - contextTruncationStartTime
@@ -195,7 +195,7 @@ export async function startSecurityScan(
195195
client,
196196
scanJob.jobId,
197197
CodeWhispererConstants.codeScanFindingsSchema,
198-
projectPath,
198+
projectPaths,
199199
scope
200200
)
201201
const { total, withFixes } = securityRecommendationCollection.reduce(
@@ -250,14 +250,14 @@ export async function startSecurityScan(
250250
codeScanState.setToNotStarted()
251251
codeScanTelemetryEntry.duration = performance.now() - codeScanStartTime
252252
codeScanTelemetryEntry.codeScanServiceInvocationsDuration = performance.now() - serviceInvocationStartTime
253-
await emitCodeScanTelemetry(editor, codeScanTelemetryEntry)
253+
await emitCodeScanTelemetry(codeScanTelemetryEntry)
254254
}
255255
}
256256

257257
export function showSecurityScanResults(
258258
securityPanelViewProvider: SecurityPanelViewProvider,
259259
securityRecommendationCollection: AggregatedCodeScanIssue[],
260-
editor: vscode.TextEditor,
260+
editor: vscode.TextEditor | undefined,
261261
context: vscode.ExtensionContext,
262262
scope: CodeWhispererConstants.CodeAnalysisScope,
263263
zipMetadata: ZipMetadata,
@@ -278,18 +278,15 @@ export function showSecurityScanResults(
278278
}
279279
}
280280

281-
export async function emitCodeScanTelemetry(editor: vscode.TextEditor, codeScanTelemetryEntry: CodeScanTelemetryEntry) {
282-
const uri = editor.document.uri
283-
const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri)
284-
const fileExt = getFileExt(editor.document.languageId)
285-
if (workspaceFolder !== undefined && fileExt !== undefined) {
286-
const projectSize = await getDirSize(
287-
workspaceFolder.uri.fsPath,
288-
performance.now(),
289-
CodeWhispererConstants.projectSizeCalculateTimeoutSeconds * 1000,
290-
fileExt
281+
export async function emitCodeScanTelemetry(codeScanTelemetryEntry: CodeScanTelemetryEntry) {
282+
codeScanTelemetryEntry.codewhispererCodeScanProjectBytes = 0
283+
const now = performance.now()
284+
for (const folder of vscode.workspace.workspaceFolders ?? []) {
285+
codeScanTelemetryEntry.codewhispererCodeScanProjectBytes += await getDirSize(
286+
folder.uri.fsPath,
287+
now,
288+
CodeWhispererConstants.projectSizeCalculateTimeoutSeconds * 1000
291289
)
292-
codeScanTelemetryEntry.codewhispererCodeScanProjectBytes = projectSize
293290
}
294291
telemetry.codewhisperer_securityScan.emit({
295292
...codeScanTelemetryEntry,

packages/core/src/codewhisperer/service/diagnosticsProvider.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ export const securityScanRender: SecurityScanRender = {
2222
export function initSecurityScanRender(
2323
securityRecommendationList: AggregatedCodeScanIssue[],
2424
context: vscode.ExtensionContext,
25-
editor: vscode.TextEditor,
25+
editor: vscode.TextEditor | undefined,
2626
scope: CodeAnalysisScope
2727
) {
2828
securityScanRender.initialized = false
29-
if (scope === CodeAnalysisScope.FILE) {
29+
if (scope === CodeAnalysisScope.FILE && editor) {
3030
securityScanRender.securityDiagnosticCollection?.delete(editor.document.uri)
3131
} else if (scope === CodeAnalysisScope.PROJECT) {
3232
securityScanRender.securityDiagnosticCollection?.clear()

packages/core/src/codewhisperer/service/securityScanHandler.ts

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export async function listScanResults(
2929
client: DefaultCodeWhispererClient,
3030
jobId: string,
3131
codeScanFindingsSchema: string,
32-
projectPath: string,
32+
projectPaths: string[],
3333
scope: CodeWhispererConstants.CodeAnalysisScope
3434
) {
3535
const logger = getLoggerForScope(scope)
@@ -51,30 +51,32 @@ export async function listScanResults(
5151
mapToAggregatedList(codeScanIssueMap, issue)
5252
})
5353
codeScanIssueMap.forEach((issues, key) => {
54-
const filePath = path.join(projectPath, '..', key)
55-
if (existsSync(filePath) && statSync(filePath).isFile()) {
56-
const aggregatedCodeScanIssue: AggregatedCodeScanIssue = {
57-
filePath: filePath,
58-
issues: issues.map(issue => {
59-
return {
60-
startLine: issue.startLine - 1 >= 0 ? issue.startLine - 1 : 0,
61-
endLine: issue.endLine,
62-
comment: `${issue.title.trim()}: ${issue.description.text.trim()}`,
63-
title: issue.title,
64-
description: issue.description,
65-
detectorId: issue.detectorId,
66-
detectorName: issue.detectorName,
67-
findingId: issue.findingId,
68-
ruleId: issue.ruleId,
69-
relatedVulnerabilities: issue.relatedVulnerabilities,
70-
severity: issue.severity,
71-
recommendation: issue.remediation.recommendation,
72-
suggestedFixes: issue.remediation.suggestedFixes,
73-
}
74-
}),
54+
projectPaths.forEach(projectPath => {
55+
const filePath = path.join(projectPath, '..', key)
56+
if (existsSync(filePath) && statSync(filePath).isFile()) {
57+
const aggregatedCodeScanIssue: AggregatedCodeScanIssue = {
58+
filePath: filePath,
59+
issues: issues.map(issue => {
60+
return {
61+
startLine: issue.startLine - 1 >= 0 ? issue.startLine - 1 : 0,
62+
endLine: issue.endLine,
63+
comment: `${issue.title.trim()}: ${issue.description.text.trim()}`,
64+
title: issue.title,
65+
description: issue.description,
66+
detectorId: issue.detectorId,
67+
detectorName: issue.detectorName,
68+
findingId: issue.findingId,
69+
ruleId: issue.ruleId,
70+
relatedVulnerabilities: issue.relatedVulnerabilities,
71+
severity: issue.severity,
72+
recommendation: issue.remediation.recommendation,
73+
suggestedFixes: issue.remediation.suggestedFixes,
74+
}
75+
}),
76+
}
77+
aggregatedCodeScanIssueList.push(aggregatedCodeScanIssue)
7578
}
76-
aggregatedCodeScanIssueList.push(aggregatedCodeScanIssue)
77-
}
79+
})
7880
})
7981
return aggregatedCodeScanIssueList
8082
}

packages/core/src/codewhisperer/util/zipUtil.ts

Lines changed: 37 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { collectFiles } from '../../amazonqFeatureDev/util/files'
1414
import { getLoggerForScope } from '../service/securityScanHandler'
1515
import { runtimeLanguageContext } from './runtimeLanguageContext'
1616
import { CodewhispererLanguage } from '../../shared/telemetry/telemetry.gen'
17+
import { CurrentWsFolders } from '../../amazonqFeatureDev/types'
1718

1819
export interface ZipMetadata {
1920
rootDir: string
@@ -53,21 +54,12 @@ export class ZipUtil {
5354
return CodeWhispererConstants.projectScanPayloadSizeLimitBytes
5455
}
5556

56-
public getProjectName(uri: vscode.Uri) {
57-
const projectPath = this.getProjectPath(uri)
58-
return path.basename(projectPath)
59-
}
60-
61-
public getProjectPath(uri: vscode.Uri) {
62-
const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri)
63-
if (workspaceFolder === undefined) {
64-
return this.getBaseDirPath(uri)
57+
public getProjectPaths() {
58+
const workspaceFolders = vscode.workspace.workspaceFolders
59+
if (!workspaceFolders || workspaceFolders.length === 0) {
60+
throw Error('No workspace folders found')
6561
}
66-
return workspaceFolder.uri.fsPath
67-
}
68-
69-
protected getBaseDirPath(uri: vscode.Uri) {
70-
return path.dirname(uri.fsPath)
62+
return workspaceFolders.map(folder => folder.uri.fsPath)
7163
}
7264

7365
protected async getTextContent(uri: vscode.Uri) {
@@ -93,12 +85,22 @@ export class ZipUtil {
9385
return willReachLimit
9486
}
9587

96-
protected async zipFile(uri: vscode.Uri) {
88+
protected async zipFile(uri: vscode.Uri | undefined) {
89+
if (!uri) {
90+
throw Error('Uri is undefined')
91+
}
9792
const zip = new admZip()
9893

9994
const content = await this.getTextContent(uri)
10095

101-
zip.addFile(this.getZipPath(uri), Buffer.from(content, 'utf-8'))
96+
const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri)
97+
if (!workspaceFolder) {
98+
throw Error('No workspace folder found')
99+
}
100+
const projectName = workspaceFolder.name
101+
const relativePath = vscode.workspace.asRelativePath(uri)
102+
const zipEntryPath = this.getZipEntryPath(projectName, relativePath)
103+
zip.addFile(zipEntryPath, Buffer.from(content, 'utf-8'))
102104

103105
this._pickedSourceFiles.add(uri.fsPath)
104106
this._totalSize += (await fsCommon.stat(uri.fsPath)).size
@@ -113,16 +115,19 @@ export class ZipUtil {
113115
return zipFilePath
114116
}
115117

116-
protected async zipProject(uri: vscode.Uri) {
118+
protected getZipEntryPath(projectName: string, relativePath: string) {
119+
// Workspaces with multiple folders have the folder names as the root folder,
120+
// but workspaces with only a single folder don't. So prepend the workspace folder name
121+
// if it is not present.
122+
return relativePath.split('/').shift() === projectName ? relativePath : path.join(projectName, relativePath)
123+
}
124+
125+
protected async zipProject() {
117126
const zip = new admZip()
118127

119-
const projectPath = this.getProjectPath(uri)
120-
const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri)
121-
if (!workspaceFolder) {
122-
throw Error('No workspace folder found')
123-
}
128+
const projectPaths = this.getProjectPaths()
124129

125-
const files = await collectFiles([projectPath], [workspaceFolder])
130+
const files = await collectFiles(projectPaths, vscode.workspace.workspaceFolders as CurrentWsFolders)
126131
const languageCount = new Map<CodewhispererLanguage, number>()
127132
for (const file of files) {
128133
const isFileOpenAndDirty = this.isFileOpenAndDirty(file.fileUri)
@@ -151,10 +156,12 @@ export class ZipUtil {
151156
}
152157
}
153158

159+
const zipEntryPath = this.getZipEntryPath(file.workspaceFolder.name, file.zipFilePath)
160+
154161
if (isFileOpenAndDirty) {
155-
zip.addFile(this.getZipPath(file.fileUri), Buffer.from(fileContent, 'utf-8'))
162+
zip.addFile(zipEntryPath, Buffer.from(fileContent, 'utf-8'))
156163
} else {
157-
zip.addLocalFile(file.fileUri.fsPath, path.dirname(this.getZipPath(file.fileUri)))
164+
zip.addLocalFile(file.fileUri.fsPath, path.dirname(zipEntryPath))
158165
}
159166
}
160167

@@ -171,12 +178,6 @@ export class ZipUtil {
171178
return vscode.workspace.textDocuments.some(document => document.uri.fsPath === uri.fsPath && document.isDirty)
172179
}
173180

174-
protected getZipPath(uri: vscode.Uri) {
175-
const projectName = this.getProjectName(uri)
176-
const relativePath = vscode.workspace.asRelativePath(uri)
177-
return path.join(projectName, relativePath)
178-
}
179-
180181
protected getZipDirPath(): string {
181182
if (this._zipDir === '') {
182183
this._zipDir = path.join(
@@ -187,14 +188,17 @@ export class ZipUtil {
187188
return this._zipDir
188189
}
189190

190-
public async generateZip(uri: vscode.Uri, scope: CodeWhispererConstants.CodeAnalysisScope): Promise<ZipMetadata> {
191+
public async generateZip(
192+
uri: vscode.Uri | undefined,
193+
scope: CodeWhispererConstants.CodeAnalysisScope
194+
): Promise<ZipMetadata> {
191195
try {
192196
const zipDirPath = this.getZipDirPath()
193197
let zipFilePath: string
194198
if (scope === CodeWhispererConstants.CodeAnalysisScope.FILE) {
195199
zipFilePath = await this.zipFile(uri)
196200
} else if (scope === CodeWhispererConstants.CodeAnalysisScope.PROJECT) {
197-
zipFilePath = await this.zipProject(uri)
201+
zipFilePath = await this.zipProject()
198202
} else {
199203
throw new ToolkitError(`Unknown code analysis scope: ${scope}`)
200204
}

0 commit comments

Comments
 (0)