From ada272e154dbe7f2c87c9d1769de1498582ea5f5 Mon Sep 17 00:00:00 2001 From: Bastian Doetsch Date: Fri, 6 Mar 2026 15:48:26 +0100 Subject: [PATCH 01/11] feat(lsp): update protocol version to 25 and handle configuration [IDE-1639] Made-with: Cursor --- .../SnykApplicationSettingsStateService.kt | 2 +- .../snyk/common/lsp/LanguageServerWrapper.kt | 178 ++++++++++--- .../snyk/common/lsp/SnykLanguageClient.kt | 246 ++++++++++++++++-- src/main/kotlin/snyk/common/lsp/Types.kt | 4 - .../lsp/settings/LanguageServerSettings.kt | 35 +++ ...SnykApplicationSettingsStateServiceTest.kt | 6 + .../plugin/ui/ReferenceChooserDialogTest.kt | 25 +- .../common/lsp/LanguageServerWrapperTest.kt | 24 +- .../snyk/common/lsp/SnykLanguageClientTest.kt | 56 +++- test-output.txt | 10 + tests.json | 51 ++++ 11 files changed, 548 insertions(+), 89 deletions(-) create mode 100644 test-output.txt create mode 100644 tests.json diff --git a/src/main/kotlin/io/snyk/plugin/services/SnykApplicationSettingsStateService.kt b/src/main/kotlin/io/snyk/plugin/services/SnykApplicationSettingsStateService.kt index 69f223575..f36a28cd3 100644 --- a/src/main/kotlin/io/snyk/plugin/services/SnykApplicationSettingsStateService.kt +++ b/src/main/kotlin/io/snyk/plugin/services/SnykApplicationSettingsStateService.kt @@ -25,7 +25,7 @@ class SnykApplicationSettingsStateService : // events var pluginInstalledSent: Boolean = false - val requiredLsProtocolVersion = 24 + val requiredLsProtocolVersion = 25 @Deprecated("left for old users migration only") var useTokenAuthentication = false var authenticationType = AuthenticationType.OAUTH2 diff --git a/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt b/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt index e33586b15..8a15845ac 100644 --- a/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt +++ b/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt @@ -33,6 +33,7 @@ import java.util.concurrent.TimeoutException import java.util.concurrent.locks.ReentrantLock import java.util.logging.Level import java.util.logging.Logger.getLogger +import org.apache.commons.lang3.SystemUtils import org.eclipse.lsp4j.ClientCapabilities import org.eclipse.lsp4j.ClientInfo import org.eclipse.lsp4j.CodeActionCapabilities @@ -76,10 +77,11 @@ import snyk.common.lsp.commands.COMMAND_WORKSPACE_CONFIGURATION import snyk.common.lsp.commands.COMMAND_WORKSPACE_FOLDER_SCAN import snyk.common.lsp.commands.SNYK_GENERATE_ISSUE_DESCRIPTION import snyk.common.lsp.progress.ProgressManager +import snyk.common.lsp.settings.ConfigSetting import snyk.common.lsp.settings.FolderConfigSettings -import snyk.common.lsp.settings.IssueViewOptions -import snyk.common.lsp.settings.LanguageServerSettings -import snyk.common.lsp.settings.SeverityFilter +import snyk.common.lsp.settings.InitializationOptions +import snyk.common.lsp.settings.LspConfigurationParam +import snyk.common.lsp.settings.LspFolderConfig import snyk.common.removeSuffix import snyk.pluginInfo import snyk.trust.WorkspaceTrustService @@ -325,7 +327,7 @@ class LanguageServerWrapper(private val project: Project) : Disposable { params.processId = ProcessHandle.current().pid().toInt() params.clientInfo = ClientInfo(pluginInfo.integrationEnvironment, pluginInfo.integrationEnvironmentVersion) - params.initializationOptions = getSettings() + params.initializationOptions = getInitializationOptions() params.capabilities = getCapabilities() initializeResult = @@ -532,13 +534,82 @@ class LanguageServerWrapper(private val project: Project) : Disposable { } } - fun getSettings(): LanguageServerSettings { + fun getSettings(): LspConfigurationParam { val ps = pluginSettings() + val trustService = service() + val trustedFolders = trustService.settings.getTrustedPaths() + + val settingsMap = mutableMapOf() + + // Global settings mapped to canonical pflag names + settingsMap["snyk_code_enabled"] = + ConfigSetting(value = ps.snykCodeSecurityIssuesScanEnable, changed = true) + settingsMap["snyk_oss_enabled"] = ConfigSetting(value = ps.ossScanEnable, changed = true) + settingsMap["snyk_iac_enabled"] = ConfigSetting(value = ps.iacScanEnabled, changed = true) + settingsMap["snyk_secrets_enabled"] = ConfigSetting(value = ps.secretsEnabled, changed = true) + settingsMap["proxy_insecure"] = ConfigSetting(value = ps.ignoreUnknownCA, changed = true) + + val endpoint = getEndpointUrl() + if (!endpoint.isNullOrBlank()) { + settingsMap["api_endpoint"] = ConfigSetting(value = endpoint, changed = true) + } + + if (ps.organization != null) { + settingsMap["organization"] = ConfigSetting(value = ps.organization!!, changed = true) + } + + settingsMap["send_error_reports"] = ConfigSetting(value = true, changed = true) + settingsMap["automatic_download"] = + ConfigSetting(value = ps.manageBinariesAutomatically, changed = true) + + val cliPath = getCliFile().absolutePath + if (cliPath.isNotBlank()) { + settingsMap["cli_path"] = ConfigSetting(value = cliPath, changed = true) + } + + if (!ps.cliBaseDownloadURL.isNullOrBlank()) { + settingsMap["binary_base_url"] = + ConfigSetting(value = ps.cliBaseDownloadURL!!, changed = true) + } + + if (!ps.token.isNullOrBlank()) { + settingsMap["token"] = ConfigSetting(value = ps.token!!, changed = true) + } + + settingsMap["automatic_authentication"] = ConfigSetting(value = false, changed = true) - // only send folderConfig after having received the folderConfigs from LS - // IntelliJ only has in-memory storage, so that storage should not overwrite - // the folderConfigs in language server - val folderConfigs = + // filters + val severityFilter = + mapOf( + "critical" to ps.criticalSeverityEnabled, + "high" to ps.highSeverityEnabled, + "medium" to ps.mediumSeverityEnabled, + "low" to ps.lowSeverityEnabled, + ) + settingsMap["enabled_severities"] = ConfigSetting(value = severityFilter, changed = true) + + if (ps.riskScoreThreshold != null) { + settingsMap["risk_score_threshold"] = + ConfigSetting(value = ps.riskScoreThreshold!!, changed = true) + } + + val issueViewOptions = + mapOf("openIssues" to ps.openIssuesEnabled, "ignoredIssues" to ps.ignoredIssuesEnabled) + // Actually mapped via canonical names, but issue_view_open_issues and issue_view_ignored_issues + settingsMap["issue_view_open_issues"] = + ConfigSetting(value = ps.openIssuesEnabled, changed = true) + settingsMap["issue_view_ignored_issues"] = + ConfigSetting(value = ps.ignoredIssuesEnabled, changed = true) + + settingsMap["trust_enabled"] = ConfigSetting(value = false, changed = true) + settingsMap["scan_automatic"] = ConfigSetting(value = ps.scanOnSave, changed = true) + settingsMap["authentication_method"] = + ConfigSetting(value = ps.authenticationType.languageServerSettingsName, changed = true) + settingsMap["enable_snyk_oss_quick_fix_code_actions"] = + ConfigSetting(value = true, changed = true) + settingsMap["scan_net_new"] = ConfigSetting(value = ps.isDeltaFindingsEnabled(), changed = true) + + val folderConfigsList = configuredWorkspaceFolders .filter { val folderPath = it.uri.fromUriToPath().toString() @@ -546,46 +617,73 @@ class LanguageServerWrapper(private val project: Project) : Disposable { } .map { val folderPath = it.uri.fromUriToPath().toString() - service().getFolderConfig(folderPath) + val fc = service().getFolderConfig(folderPath) + + val fcSettingsMap = mutableMapOf() + if (fc.baseBranch != null) { + fcSettingsMap["base_branch"] = ConfigSetting(value = fc.baseBranch!!, changed = true) + } + if (fc.additionalEnv != null) { + fcSettingsMap["additional_environment"] = + ConfigSetting(value = fc.additionalEnv!!, changed = true) + } + if (fc.additionalParameters != null) { + fcSettingsMap["additional_parameters"] = + ConfigSetting(value = fc.additionalParameters!!, changed = true) + } + if (fc.localBranches != null) { + fcSettingsMap["local_branches"] = + ConfigSetting(value = fc.localBranches!!, changed = true) + } + if (fc.referenceFolderPath != null) { + fcSettingsMap["reference_folder"] = + ConfigSetting(value = fc.referenceFolderPath!!, changed = true) + } + if (fc.preferredOrg != null) { + fcSettingsMap["preferred_org"] = + ConfigSetting(value = fc.preferredOrg!!, changed = true) + } + if (fc.autoDeterminedOrg != null) { + fcSettingsMap["auto_determined_org"] = + ConfigSetting(value = fc.autoDeterminedOrg!!, changed = true) + } + if (fc.orgSetByUser != null) { + fcSettingsMap["org_set_by_user"] = + ConfigSetting(value = fc.orgSetByUser!!, changed = true) + } + if (fc.scanCommandConfig != null) { + fcSettingsMap["scan_command_config"] = + ConfigSetting(value = fc.scanCommandConfig!!, changed = true) + } + LspFolderConfig(folderPath = folderPath, settings = fcSettingsMap) } .toList() + return LspConfigurationParam(settings = settingsMap, folderConfigs = folderConfigsList) + } + + fun getInitializationOptions(): InitializationOptions { + val ps = pluginSettings() val trustService = service() val trustedFolders = trustService.settings.getTrustedPaths() - return LanguageServerSettings( - activateSnykOpenSource = ps.ossScanEnable.toString(), - activateSnykCodeSecurity = ps.snykCodeSecurityIssuesScanEnable.toString(), - activateSnykIac = ps.iacScanEnabled.toString(), - activateSnykSecrets = ps.secretsEnabled.toString(), - organization = ps.organization ?: "", - insecure = ps.ignoreUnknownCA.toString(), - endpoint = getEndpointUrl(), - cliPath = getCliFile().absolutePath, - cliBaseDownloadURL = ps.cliBaseDownloadURL, - manageBinariesAutomatically = ps.manageBinariesAutomatically.toString(), - token = ps.token, - filterSeverity = - SeverityFilter( - critical = ps.criticalSeverityEnabled, - high = ps.highSeverityEnabled, - medium = ps.mediumSeverityEnabled, - low = ps.lowSeverityEnabled, - ), - issueViewOptions = - IssueViewOptions( - openIssues = ps.openIssuesEnabled, - ignoredIssues = ps.ignoredIssuesEnabled, - ), - enableTrustedFoldersFeature = "false", - scanningMode = if (!ps.scanOnSave) "manual" else "auto", + val param = getSettings() + + return InitializationOptions( + settings = param.settings, + folderConfigs = param.folderConfigs, + requiredProtocolVersion = ps.requiredLsProtocolVersion.toString(), + deviceId = ps.userAnonymousId, integrationName = pluginInfo.integrationName, integrationVersion = pluginInfo.integrationVersion, - authenticationMethod = ps.authenticationType.languageServerSettingsName, - enableSnykOSSQuickFixCodeActions = "true", - folderConfigs = folderConfigs, + osPlatform = SystemUtils.OS_NAME, + osArch = SystemUtils.OS_ARCH, + runtimeVersion = SystemUtils.JAVA_VERSION, + runtimeName = SystemUtils.JAVA_RUNTIME_NAME, + hoverVerbosity = 0, + outputFormat = "html", + path = null, trustedFolders = trustedFolders, - riskScoreThreshold = ps.riskScoreThreshold, ) } diff --git a/src/main/kotlin/snyk/common/lsp/SnykLanguageClient.kt b/src/main/kotlin/snyk/common/lsp/SnykLanguageClient.kt index 8de0a4338..64b2f8f30 100644 --- a/src/main/kotlin/snyk/common/lsp/SnykLanguageClient.kt +++ b/src/main/kotlin/snyk/common/lsp/SnykLanguageClient.kt @@ -63,6 +63,7 @@ import snyk.common.ProductType import snyk.common.editor.DocumentChanger import snyk.common.lsp.progress.ProgressManager import snyk.common.lsp.settings.FolderConfigSettings +import snyk.common.lsp.settings.LspConfigurationParam import snyk.sdk.SdkHelper import snyk.trust.WorkspaceTrustService @@ -213,27 +214,240 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog return completedFuture } - @JsonNotification(value = "$/snyk.folderConfigs") - fun folderConfig(folderConfigParam: FolderConfigsParam?) { - if (disposed) return - val folderConfigs = folderConfigParam?.folderConfigs ?: emptyList() + @JsonNotification(value = "$/snyk.configuration") + fun snykConfiguration(configurationParam: LspConfigurationParam?) { + if (disposed || configurationParam == null) return runAsync { - val service = service() - val languageServerWrapper = LanguageServerWrapper.getInstance(project) + try { + val ps = pluginSettings() + var settingsChanged = false + + configurationParam.settings?.let { settings -> + settings["snyk_code_enabled"]?.value?.let { + (it as? Boolean)?.let { boolVal -> + if (ps.snykCodeSecurityIssuesScanEnable != boolVal) { + ps.snykCodeSecurityIssuesScanEnable = boolVal + settingsChanged = true + } + } + } + settings["snyk_oss_enabled"]?.value?.let { + (it as? Boolean)?.let { boolVal -> + if (ps.ossScanEnable != boolVal) { + ps.ossScanEnable = boolVal + settingsChanged = true + } + } + } + settings["snyk_iac_enabled"]?.value?.let { + (it as? Boolean)?.let { boolVal -> + if (ps.iacScanEnabled != boolVal) { + ps.iacScanEnabled = boolVal + settingsChanged = true + } + } + } + settings["snyk_secrets_enabled"]?.value?.let { + (it as? Boolean)?.let { boolVal -> + if (ps.secretsEnabled != boolVal) { + ps.secretsEnabled = boolVal + settingsChanged = true + } + } + } + settings["proxy_insecure"]?.value?.let { + (it as? Boolean)?.let { boolVal -> + if (ps.ignoreUnknownCA != boolVal) { + ps.ignoreUnknownCA = boolVal + settingsChanged = true + } + } + } + settings["api_endpoint"]?.value?.let { + (it as? String)?.let { strVal -> + if (ps.customEndpointUrl != strVal) { + ps.customEndpointUrl = strVal + settingsChanged = true + } + } + } + settings["organization"]?.value?.let { + (it as? String)?.let { strVal -> + if (ps.organization != strVal) { + ps.organization = strVal + settingsChanged = true + } + } + } + settings["automatic_download"]?.value?.let { + (it as? Boolean)?.let { boolVal -> + if (ps.manageBinariesAutomatically != boolVal) { + ps.manageBinariesAutomatically = boolVal + settingsChanged = true + } + } + } + settings["cli_path"]?.value?.let { + (it as? String)?.let { strVal -> + if (ps.cliPath != strVal) { + ps.cliPath = strVal + settingsChanged = true + } + } + } + settings["binary_base_url"]?.value?.let { + (it as? String)?.let { strVal -> + if (ps.cliBaseDownloadURL != strVal) { + ps.cliBaseDownloadURL = strVal + settingsChanged = true + } + } + } + settings["token"]?.value?.let { + (it as? String)?.let { strVal -> + if (ps.token != strVal) { + ps.token = strVal + settingsChanged = true + } + } + } + settings["enabled_severities"]?.value?.let { + if (it is Map<*, *>) { + (it["critical"] as? Boolean)?.let { critical -> + if (ps.criticalSeverityEnabled != critical) { + ps.criticalSeverityEnabled = critical + settingsChanged = true + } + } + (it["high"] as? Boolean)?.let { high -> + if (ps.highSeverityEnabled != high) { + ps.highSeverityEnabled = high + settingsChanged = true + } + } + (it["medium"] as? Boolean)?.let { medium -> + if (ps.mediumSeverityEnabled != medium) { + ps.mediumSeverityEnabled = medium + settingsChanged = true + } + } + (it["low"] as? Boolean)?.let { low -> + if (ps.lowSeverityEnabled != low) { + ps.lowSeverityEnabled = low + settingsChanged = true + } + } + } + } + settings["risk_score_threshold"]?.value?.let { + if (it is Number && ps.riskScoreThreshold != it.toInt()) { + ps.riskScoreThreshold = it.toInt() + settingsChanged = true + } + } + settings["issue_view_open_issues"]?.value?.let { + (it as? Boolean)?.let { boolVal -> + if (ps.openIssuesEnabled != boolVal) { + ps.openIssuesEnabled = boolVal + settingsChanged = true + } + } + } + settings["issue_view_ignored_issues"]?.value?.let { + (it as? Boolean)?.let { boolVal -> + if (ps.ignoredIssuesEnabled != boolVal) { + ps.ignoredIssuesEnabled = boolVal + settingsChanged = true + } + } + } + settings["scan_automatic"]?.value?.let { + (it as? Boolean)?.let { boolVal -> + if (ps.scanOnSave != boolVal) { + ps.scanOnSave = boolVal + settingsChanged = true + } + } + } + settings["scan_net_new"]?.value?.let { + (it as? Boolean)?.let { boolVal -> + if (ps.isDeltaFindingsEnabled() != boolVal) { + ps.setDeltaEnabled(boolVal) + settingsChanged = true + } + } + } + } - service.addAll(folderConfigs) - folderConfigs.forEach { languageServerWrapper.updateFolderConfigRefresh(it.folderPath, true) } + if (settingsChanged) { + StoreUtil.saveSettings(ApplicationManager.getApplication(), true) + logger.debug("force-saved settings from Language Server configuration") - // Migrate any nested folder configs that may have been created by earlier plugin versions - // Only workspace folder paths (non-nested) should have folder configs - service.migrateNestedFolderConfigs(project) + publishAsync(project, SnykSettingsListener.SNYK_SETTINGS_TOPIC) { settingsChanged() } + } - try { - // Already in runAsync, so just use sync publisher here - getSyncPublisher(project, SnykFolderConfigListener.SNYK_FOLDER_CONFIG_TOPIC) - ?.folderConfigsChanged(folderConfigs.isNotEmpty()) + configurationParam.folderConfigs?.let { folderConfigs -> + val service = service() + val languageServerWrapper = LanguageServerWrapper.getInstance(project) + + // convert to FolderConfig + val convertedConfigs = + folderConfigs.map { lspFolderConfig -> + val settings = lspFolderConfig.settings ?: emptyMap() + val baseBranch = settings["base_branch"]?.value as? String ?: "" + val additionalEnv = settings["additional_environment"]?.value as? String + + // local_branches and additional_parameters are sent as arrays/lists of strings + val localBranches = + (settings["local_branches"]?.value as? List<*>)?.filterIsInstance() + ?: emptyList() + val additionalParameters = + (settings["additional_parameters"]?.value as? List<*>)?.filterIsInstance() + ?: emptyList() + + val referenceFolderPath = settings["reference_folder"]?.value as? String + val preferredOrg = settings["preferred_org"]?.value as? String ?: "" + val autoDeterminedOrg = settings["auto_determined_org"]?.value as? String ?: "" + val orgSetByUser = settings["org_set_by_user"]?.value as? Boolean ?: false + + @Suppress("UNCHECKED_CAST") + val scanCommandConfig = + settings["scan_command_config"]?.value + as? Map + + FolderConfig( + folderPath = lspFolderConfig.folderPath, + baseBranch = baseBranch, + additionalEnv = additionalEnv, + localBranches = localBranches, + additionalParameters = additionalParameters, + referenceFolderPath = referenceFolderPath, + preferredOrg = preferredOrg, + autoDeterminedOrg = autoDeterminedOrg, + orgSetByUser = orgSetByUser, + scanCommandConfig = scanCommandConfig, + ) + } + + service.addAll(convertedConfigs) + convertedConfigs.forEach { + languageServerWrapper.updateFolderConfigRefresh(it.folderPath, true) + } + + // Migrate any nested folder configs that may have been created by earlier plugin versions + // Only workspace folder paths (non-nested) should have folder configs + service.migrateNestedFolderConfigs(project) + + try { + // Already in runAsync, so just use sync publisher here + getSyncPublisher(project, SnykFolderConfigListener.SNYK_FOLDER_CONFIG_TOPIC) + ?.folderConfigsChanged(convertedConfigs.isNotEmpty()) + } catch (e: Exception) { + logger.error("Error processing snyk folder configs", e) + } + } } catch (e: Exception) { - logger.error("Error processing snyk folder configs", e) + logger.error("Error processing snyk configuration", e) } } } diff --git a/src/main/kotlin/snyk/common/lsp/Types.kt b/src/main/kotlin/snyk/common/lsp/Types.kt index 108e55c73..a83e64afd 100644 --- a/src/main/kotlin/snyk/common/lsp/Types.kt +++ b/src/main/kotlin/snyk/common/lsp/Types.kt @@ -514,10 +514,6 @@ data class OssIdentifiers( } } -data class FolderConfigsParam( - @SerializedName("folderConfigs") val folderConfigs: List? -) - /** * FolderConfig stores the configuration for a workspace folder * diff --git a/src/main/kotlin/snyk/common/lsp/settings/LanguageServerSettings.kt b/src/main/kotlin/snyk/common/lsp/settings/LanguageServerSettings.kt index ce8363e3e..397867cab 100644 --- a/src/main/kotlin/snyk/common/lsp/settings/LanguageServerSettings.kt +++ b/src/main/kotlin/snyk/common/lsp/settings/LanguageServerSettings.kt @@ -68,3 +68,38 @@ data class IssueViewOptions( @SerializedName("openIssues") val openIssues: Boolean?, @SerializedName("ignoredIssues") val ignoredIssues: Boolean?, ) + +data class ConfigSetting( + @SerializedName("value") val value: Any, + @SerializedName("changed") val changed: Boolean? = null, + @SerializedName("source") val source: String? = null, + @SerializedName("originScope") val originScope: String? = null, + @SerializedName("isLocked") val isLocked: Boolean? = null, +) + +data class LspFolderConfig( + @SerializedName("folderPath") val folderPath: String, + @SerializedName("settings") val settings: Map? = null, +) + +data class LspConfigurationParam( + @SerializedName("settings") val settings: Map? = null, + @SerializedName("folderConfigs") val folderConfigs: List? = null, +) + +data class InitializationOptions( + @SerializedName("settings") val settings: Map? = null, + @SerializedName("folderConfigs") val folderConfigs: List? = null, + @SerializedName("requiredProtocolVersion") val requiredProtocolVersion: String? = null, + @SerializedName("deviceId") val deviceId: String? = null, + @SerializedName("integrationName") val integrationName: String? = null, + @SerializedName("integrationVersion") val integrationVersion: String? = null, + @SerializedName("osPlatform") val osPlatform: String? = null, + @SerializedName("osArch") val osArch: String? = null, + @SerializedName("runtimeVersion") val runtimeVersion: String? = null, + @SerializedName("runtimeName") val runtimeName: String? = null, + @SerializedName("hoverVerbosity") val hoverVerbosity: Int? = null, + @SerializedName("outputFormat") val outputFormat: String? = null, + @SerializedName("path") val path: String? = null, + @SerializedName("trustedFolders") val trustedFolders: List? = emptyList(), +) diff --git a/src/test/kotlin/io/snyk/plugin/services/SnykApplicationSettingsStateServiceTest.kt b/src/test/kotlin/io/snyk/plugin/services/SnykApplicationSettingsStateServiceTest.kt index 968013730..1ba08c73e 100644 --- a/src/test/kotlin/io/snyk/plugin/services/SnykApplicationSettingsStateServiceTest.kt +++ b/src/test/kotlin/io/snyk/plugin/services/SnykApplicationSettingsStateServiceTest.kt @@ -26,4 +26,10 @@ class SnykApplicationSettingsStateServiceTest { assertFalse(target.hasSeverityEnabled(Severity.CRITICAL)) assertFalse(target.hasSeverityEnabled(Severity.LOW)) } + + @Test + fun requiredLsProtocolVersion_shouldBe25() { + val target = SnykApplicationSettingsStateService() + junit.framework.TestCase.assertEquals(25, target.requiredLsProtocolVersion) + } } diff --git a/src/test/kotlin/io/snyk/plugin/ui/ReferenceChooserDialogTest.kt b/src/test/kotlin/io/snyk/plugin/ui/ReferenceChooserDialogTest.kt index 1e48fe6ac..0ee6af984 100644 --- a/src/test/kotlin/io/snyk/plugin/ui/ReferenceChooserDialogTest.kt +++ b/src/test/kotlin/io/snyk/plugin/ui/ReferenceChooserDialogTest.kt @@ -22,7 +22,7 @@ import org.junit.Test import snyk.common.lsp.FolderConfig import snyk.common.lsp.LanguageServerWrapper import snyk.common.lsp.settings.FolderConfigSettings -import snyk.common.lsp.settings.LanguageServerSettings +import snyk.common.lsp.settings.LspConfigurationParam import snyk.trust.WorkspaceTrustSettings class ReferenceChooserDialogTest : LightPlatform4TestCase() { @@ -116,11 +116,17 @@ class ReferenceChooserDialogTest : LightPlatform4TestCase() { val capturedParam = CapturingSlot() verify { lsMock.workspaceService.didChangeConfiguration(capture(capturedParam)) } - val transmittedSettings = capturedParam.captured.settings as LanguageServerSettings + val transmittedSettings = capturedParam.captured.settings as LspConfigurationParam // we expect the selected item - assertEquals("main", transmittedSettings.folderConfigs[0].baseBranch) + assertEquals( + "main", + transmittedSettings.folderConfigs?.get(0)?.settings?.get("base_branch")?.value, + ) // we also expect the reference folder to be transmitted - assertEquals("/some/reference/path", transmittedSettings.folderConfigs[0].referenceFolderPath) + assertEquals( + "/some/reference/path", + transmittedSettings.folderConfigs?.get(0)?.settings?.get("reference_folder")?.value, + ) } @Test @@ -221,13 +227,16 @@ class ReferenceChooserDialogTest : LightPlatform4TestCase() { val capturedParam = CapturingSlot() verify { lsMock.workspaceService.didChangeConfiguration(capture(capturedParam)) } - val transmittedSettings = capturedParam.captured.settings as LanguageServerSettings + val transmittedSettings = capturedParam.captured.settings as LspConfigurationParam val transmittedConfig = - transmittedSettings.folderConfigs.find { it.folderPath == configNoBranches.folderPath } + transmittedSettings.folderConfigs?.find { it.folderPath == configNoBranches.folderPath } assertNotNull(transmittedConfig) - assertEquals("", transmittedConfig!!.baseBranch) // Should be empty string - assertEquals("/some/reference/path", transmittedConfig.referenceFolderPath) + assertEquals( + "", + transmittedConfig!!.settings?.get("base_branch")?.value, + ) // Should be empty string + assertEquals("/some/reference/path", transmittedConfig.settings?.get("reference_folder")?.value) } @Test diff --git a/src/test/kotlin/snyk/common/lsp/LanguageServerWrapperTest.kt b/src/test/kotlin/snyk/common/lsp/LanguageServerWrapperTest.kt index 4fb737fa7..b94e38255 100644 --- a/src/test/kotlin/snyk/common/lsp/LanguageServerWrapperTest.kt +++ b/src/test/kotlin/snyk/common/lsp/LanguageServerWrapperTest.kt @@ -439,19 +439,19 @@ class LanguageServerWrapperTest { val expectedTrustedFolders = listOf("/path/to/trusted1", "/path/to/trusted2") every { trustServiceMock.settings.getTrustedPaths() } returns expectedTrustedFolders - val actual = cut.getSettings() + val actual = cut.getInitializationOptions() assertEquals( - settings.snykCodeSecurityIssuesScanEnable.toString(), - actual.activateSnykCodeSecurity, + settings.snykCodeSecurityIssuesScanEnable, + actual.settings?.get("snyk_code_enabled")?.value, ) - assertEquals(settings.iacScanEnabled.toString(), actual.activateSnykIac) - assertEquals(settings.ossScanEnable.toString(), actual.activateSnykOpenSource) - assertEquals(settings.token, actual.token) - assertEquals("${settings.ignoreUnknownCA}", actual.insecure) - assertEquals(getCliFile().absolutePath, actual.cliPath) - assertEquals(settings.organization, actual.organization) - assertEquals(settings.isDeltaFindingsEnabled().toString(), actual.enableDeltaFindings) + assertEquals(settings.iacScanEnabled, actual.settings?.get("snyk_iac_enabled")?.value) + assertEquals(settings.ossScanEnable, actual.settings?.get("snyk_oss_enabled")?.value) + assertEquals(settings.token, actual.settings?.get("token")?.value) + assertEquals(settings.ignoreUnknownCA, actual.settings?.get("proxy_insecure")?.value) + assertEquals(getCliFile().absolutePath, actual.settings?.get("cli_path")?.value) + assertEquals(settings.organization, actual.settings?.get("organization")?.value) + assertEquals(settings.isDeltaFindingsEnabled(), actual.settings?.get("scan_net_new")?.value) assertEquals(expectedTrustedFolders, actual.trustedFolders) } @@ -461,7 +461,7 @@ class LanguageServerWrapperTest { val actual = cut.getSettings() - assertEquals("true", actual.manageBinariesAutomatically) + assertEquals(true, actual.settings?.get("automatic_download")?.value) } @Test @@ -470,7 +470,7 @@ class LanguageServerWrapperTest { val actual = cut.getSettings() - assertEquals("false", actual.manageBinariesAutomatically) + assertEquals(false, actual.settings?.get("automatic_download")?.value) } @Test diff --git a/src/test/kotlin/snyk/common/lsp/SnykLanguageClientTest.kt b/src/test/kotlin/snyk/common/lsp/SnykLanguageClientTest.kt index 5715bd712..30a1aae4b 100644 --- a/src/test/kotlin/snyk/common/lsp/SnykLanguageClientTest.kt +++ b/src/test/kotlin/snyk/common/lsp/SnykLanguageClientTest.kt @@ -47,7 +47,10 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import snyk.common.lsp.progress.ProgressManager +import snyk.common.lsp.settings.ConfigSetting import snyk.common.lsp.settings.FolderConfigSettings +import snyk.common.lsp.settings.LspConfigurationParam +import snyk.common.lsp.settings.LspFolderConfig import snyk.pluginInfo import snyk.trust.WorkspaceTrustService @@ -335,7 +338,7 @@ class SnykLanguageClientTest { } @Test - fun `folderConfig should call migrateNestedFolderConfigs after adding configs`() { + fun `snykConfiguration should call migrateNestedFolderConfigs after adding configs`() { val folderConfigSettingsMock = mockk(relaxed = true) val lsWrapperMock = mockk(relaxed = true) @@ -349,29 +352,66 @@ class SnykLanguageClientTest { messageBusMock.syncPublisher(SnykFolderConfigListener.SNYK_FOLDER_CONFIG_TOPIC) } returns folderConfigListener - val folderConfig = FolderConfig(folderPath = "/test/project", baseBranch = "main") - val param = FolderConfigsParam(listOf(folderConfig)) + val lspFolderConfig = + LspFolderConfig( + folderPath = "/test/project", + settings = mapOf("base_branch" to ConfigSetting(value = "main")), + ) + val param = LspConfigurationParam(folderConfigs = listOf(lspFolderConfig)) - cut.folderConfig(param) + cut.snykConfiguration(param) - verify(timeout = 5000) { folderConfigSettingsMock.addAll(listOf(folderConfig)) } + verify(timeout = 5000) { folderConfigSettingsMock.addAll(any()) } verify(timeout = 5000) { folderConfigSettingsMock.migrateNestedFolderConfigs(projectMock) } } @Test - fun `folderConfig should not run when disposed`() { + fun `snykConfiguration should not run when disposed`() { every { projectMock.isDisposed } returns true val folderConfigSettingsMock = mockk(relaxed = true) every { applicationMock.getService(FolderConfigSettings::class.java) } returns folderConfigSettingsMock - val param = FolderConfigsParam(listOf(FolderConfig(folderPath = "/test", baseBranch = "main"))) - cut.folderConfig(param) + val param = + LspConfigurationParam( + folderConfigs = + listOf( + LspFolderConfig( + folderPath = "/test", + settings = mapOf("base_branch" to ConfigSetting(value = "main")), + ) + ) + ) + cut.snykConfiguration(param) verify(exactly = 0) { folderConfigSettingsMock.addAll(any()) } } + @Test + fun `snykConfiguration should update plugin settings when received`() { + // initial state + settings.snykCodeSecurityIssuesScanEnable = false + settings.ossScanEnable = false + + val param = + LspConfigurationParam( + settings = + mapOf( + "snyk_code_enabled" to ConfigSetting(value = true, isLocked = true), + "snyk_oss_enabled" to ConfigSetting(value = true, isLocked = false), + ) + ) + + cut.snykConfiguration(param) + + // Give it a small amount of time to process async task + Thread.sleep(200) + + assertTrue(settings.snykCodeSecurityIssuesScanEnable) + assertTrue(settings.ossScanEnable) + } + @Test fun `showDocument should navigate to file URI with selection`() { val fileUri = "file:///tmp/test-file.kt" diff --git a/test-output.txt b/test-output.txt new file mode 100644 index 000000000..ac52d8618 --- /dev/null +++ b/test-output.txt @@ -0,0 +1,10 @@ +> Task :checkKotlinGradlePluginConfigurationErrors SKIPPED +> Task :initializeIntellijPlatformPlugin +> Task :patchPluginXml UP-TO-DATE +> Task :processResources UP-TO-DATE +> Task :generateManifest UP-TO-DATE +> Task :processTestResources UP-TO-DATE +> Task :koverFindJar UP-TO-DATE + +> Task :compileKotlin FAILED +7 actionable tasks: 2 executed, 5 up-to-date diff --git a/tests.json b/tests.json new file mode 100644 index 000000000..2902b85d8 --- /dev/null +++ b/tests.json @@ -0,0 +1,51 @@ +{ + "ticket": "IDE-1639", + "description": "Identify the changes in the LSP configuration communication and write an implementation plan to support it. use LS_PROTOCOL_VERSION 25.", + "lastUpdated": "2026-03-06", + "lastSession": { + "date": "2026-03-06", + "sessionNumber": 1, + "completedSteps": [], + "currentStep": "1.1 Requirements Analysis", + "nextStep": "1.2 Schema Design" + }, + "testSuites": { + "unit": { + "configuration": { + "status": "pending", + "scenarios": [ + { + "id": "CFG-001", + "name": "Should serialize DidChangeConfigurationParams using LspConfigurationParam structure", + "description": "Verify that getSettings returns the new LspConfigurationParam structure", + "status": "passed" + }, + { + "id": "CFG-002", + "name": "requiredLsProtocolVersion should be 25", + "description": "Verify that SnykApplicationSettingsStateService.requiredLsProtocolVersion is updated to 25", + "status": "passed" + }, + { + "id": "CFG-003", + "name": "Should handle $/snyk.configuration notification", + "description": "Verify that SnykLanguageClient.snykConfiguration updates the local SnykApplicationSettingsStateService properties according to the canonical pflag names", + "status": "passed" + }, + { + "id": "CFG-004", + "name": "Should update FolderConfigSettings via $/snyk.configuration", + "description": "Verify that SnykLanguageClient.snykConfiguration updates the FolderConfigSettings with the passed folderConfigs instead of the old $/snyk.folderConfigs endpoint", + "status": "passed" + } + ] + } + }, + "integration": { + "scenarios": [] + }, + "regression": { + "scenarios": [] + } + } +} From 1b25918e2495be764d013fa4f702ecb0925140cd Mon Sep 17 00:00:00 2001 From: Bastian Doetsch Date: Tue, 10 Mar 2026 09:18:33 +0100 Subject: [PATCH 02/11] refactor(lsp): use constant settings keys for LSP [IDE-1639] --- .../snyk/common/lsp/LanguageServerWrapper.kt | 210 ++++++++++++------ .../snyk/common/lsp/SnykLanguageClient.kt | 65 +++--- .../settings/LanguageServerSettingsKeys.kt | 49 ++++ 3 files changed, 231 insertions(+), 93 deletions(-) create mode 100644 src/main/kotlin/snyk/common/lsp/settings/LanguageServerSettingsKeys.kt diff --git a/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt b/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt index 8a15845ac..466658939 100644 --- a/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt +++ b/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt @@ -19,6 +19,7 @@ import io.snyk.plugin.getSnykTaskQueueService import io.snyk.plugin.getWaitForResultsTimeout import io.snyk.plugin.pluginSettings import io.snyk.plugin.runInBackground +import io.snyk.plugin.services.SnykApplicationSettingsStateService import io.snyk.plugin.toLanguageServerURI import io.snyk.plugin.ui.SnykBalloonNotificationHelper import io.snyk.plugin.ui.toolwindow.SnykPluginDisposable @@ -80,6 +81,8 @@ import snyk.common.lsp.progress.ProgressManager import snyk.common.lsp.settings.ConfigSetting import snyk.common.lsp.settings.FolderConfigSettings import snyk.common.lsp.settings.InitializationOptions +import snyk.common.lsp.settings.LsFolderSettingsKeys +import snyk.common.lsp.settings.LsSettingsKeys import snyk.common.lsp.settings.LspConfigurationParam import snyk.common.lsp.settings.LspFolderConfig import snyk.common.removeSuffix @@ -536,78 +539,155 @@ class LanguageServerWrapper(private val project: Project) : Disposable { fun getSettings(): LspConfigurationParam { val ps = pluginSettings() + val defaultSettings = SnykApplicationSettingsStateService() val trustService = service() val trustedFolders = trustService.settings.getTrustedPaths() val settingsMap = mutableMapOf() // Global settings mapped to canonical pflag names - settingsMap["snyk_code_enabled"] = - ConfigSetting(value = ps.snykCodeSecurityIssuesScanEnable, changed = true) - settingsMap["snyk_oss_enabled"] = ConfigSetting(value = ps.ossScanEnable, changed = true) - settingsMap["snyk_iac_enabled"] = ConfigSetting(value = ps.iacScanEnabled, changed = true) - settingsMap["snyk_secrets_enabled"] = ConfigSetting(value = ps.secretsEnabled, changed = true) - settingsMap["proxy_insecure"] = ConfigSetting(value = ps.ignoreUnknownCA, changed = true) + settingsMap[LsSettingsKeys.SNYK_CODE_ENABLED] = + ConfigSetting( + value = ps.snykCodeSecurityIssuesScanEnable, + changed = + ps.snykCodeSecurityIssuesScanEnable != defaultSettings.snykCodeSecurityIssuesScanEnable, + ) + settingsMap[LsSettingsKeys.SNYK_OSS_ENABLED] = + ConfigSetting( + value = ps.ossScanEnable, + changed = ps.ossScanEnable != defaultSettings.ossScanEnable, + ) + settingsMap[LsSettingsKeys.SNYK_IAC_ENABLED] = + ConfigSetting( + value = ps.iacScanEnabled, + changed = ps.iacScanEnabled != defaultSettings.iacScanEnabled, + ) + settingsMap[LsSettingsKeys.SNYK_SECRETS_ENABLED] = + ConfigSetting( + value = ps.secretsEnabled, + changed = ps.secretsEnabled != defaultSettings.secretsEnabled, + ) + settingsMap[LsSettingsKeys.PROXY_INSECURE] = + ConfigSetting( + value = ps.ignoreUnknownCA, + changed = ps.ignoreUnknownCA != defaultSettings.ignoreUnknownCA, + ) val endpoint = getEndpointUrl() if (!endpoint.isNullOrBlank()) { - settingsMap["api_endpoint"] = ConfigSetting(value = endpoint, changed = true) + settingsMap[LsSettingsKeys.API_ENDPOINT] = + ConfigSetting(value = endpoint, changed = endpoint != defaultSettings.customEndpointUrl) } if (ps.organization != null) { - settingsMap["organization"] = ConfigSetting(value = ps.organization!!, changed = true) + settingsMap[LsSettingsKeys.ORGANIZATION] = + ConfigSetting( + value = ps.organization!!, + changed = ps.organization != defaultSettings.organization, + ) } - settingsMap["send_error_reports"] = ConfigSetting(value = true, changed = true) - settingsMap["automatic_download"] = - ConfigSetting(value = ps.manageBinariesAutomatically, changed = true) + settingsMap[LsSettingsKeys.SEND_ERROR_REPORTS] = ConfigSetting(value = true, changed = false) + settingsMap[LsSettingsKeys.AUTOMATIC_DOWNLOAD] = + ConfigSetting( + value = ps.manageBinariesAutomatically, + changed = ps.manageBinariesAutomatically != defaultSettings.manageBinariesAutomatically, + ) - val cliPath = getCliFile().absolutePath - if (cliPath.isNotBlank()) { - settingsMap["cli_path"] = ConfigSetting(value = cliPath, changed = true) + val currentCliPath = getCliFile().absolutePath + if (currentCliPath.isNotBlank()) { + settingsMap[LsSettingsKeys.CLI_PATH] = + ConfigSetting(value = currentCliPath, changed = currentCliPath != defaultSettings.cliPath) } if (!ps.cliBaseDownloadURL.isNullOrBlank()) { - settingsMap["binary_base_url"] = - ConfigSetting(value = ps.cliBaseDownloadURL!!, changed = true) + settingsMap[LsSettingsKeys.BINARY_BASE_URL] = + ConfigSetting( + value = ps.cliBaseDownloadURL, + changed = ps.cliBaseDownloadURL != defaultSettings.cliBaseDownloadURL, + ) } if (!ps.token.isNullOrBlank()) { - settingsMap["token"] = ConfigSetting(value = ps.token!!, changed = true) + settingsMap[LsSettingsKeys.TOKEN] = + ConfigSetting(value = ps.token!!, changed = ps.token != defaultSettings.token) } - settingsMap["automatic_authentication"] = ConfigSetting(value = false, changed = true) + settingsMap[LsSettingsKeys.AUTOMATIC_AUTHENTICATION] = + ConfigSetting(value = false, changed = false) // filters val severityFilter = - mapOf( + mapOf( "critical" to ps.criticalSeverityEnabled, "high" to ps.highSeverityEnabled, "medium" to ps.mediumSeverityEnabled, "low" to ps.lowSeverityEnabled, ) - settingsMap["enabled_severities"] = ConfigSetting(value = severityFilter, changed = true) + val defaultSeverityFilter = + mapOf( + "critical" to defaultSettings.criticalSeverityEnabled, + "high" to defaultSettings.highSeverityEnabled, + "medium" to defaultSettings.mediumSeverityEnabled, + "low" to defaultSettings.lowSeverityEnabled, + ) + settingsMap[LsSettingsKeys.ENABLED_SEVERITIES] = + ConfigSetting(value = severityFilter, changed = severityFilter != defaultSeverityFilter) if (ps.riskScoreThreshold != null) { - settingsMap["risk_score_threshold"] = - ConfigSetting(value = ps.riskScoreThreshold!!, changed = true) - } - - val issueViewOptions = - mapOf("openIssues" to ps.openIssuesEnabled, "ignoredIssues" to ps.ignoredIssuesEnabled) - // Actually mapped via canonical names, but issue_view_open_issues and issue_view_ignored_issues - settingsMap["issue_view_open_issues"] = - ConfigSetting(value = ps.openIssuesEnabled, changed = true) - settingsMap["issue_view_ignored_issues"] = - ConfigSetting(value = ps.ignoredIssuesEnabled, changed = true) - - settingsMap["trust_enabled"] = ConfigSetting(value = false, changed = true) - settingsMap["scan_automatic"] = ConfigSetting(value = ps.scanOnSave, changed = true) - settingsMap["authentication_method"] = - ConfigSetting(value = ps.authenticationType.languageServerSettingsName, changed = true) - settingsMap["enable_snyk_oss_quick_fix_code_actions"] = - ConfigSetting(value = true, changed = true) - settingsMap["scan_net_new"] = ConfigSetting(value = ps.isDeltaFindingsEnabled(), changed = true) + settingsMap[LsSettingsKeys.RISK_SCORE_THRESHOLD] = + ConfigSetting( + value = ps.riskScoreThreshold!!, + changed = ps.riskScoreThreshold != defaultSettings.riskScoreThreshold, + ) + } + + settingsMap[LsSettingsKeys.ISSUE_VIEW_OPEN_ISSUES] = + ConfigSetting( + value = ps.openIssuesEnabled, + changed = ps.openIssuesEnabled != defaultSettings.openIssuesEnabled, + ) + settingsMap[LsSettingsKeys.ISSUE_VIEW_IGNORED_ISSUES] = + ConfigSetting( + value = ps.ignoredIssuesEnabled, + changed = ps.ignoredIssuesEnabled != defaultSettings.ignoredIssuesEnabled, + ) + + settingsMap[LsSettingsKeys.TRUST_ENABLED] = ConfigSetting(value = false, changed = false) + settingsMap[LsSettingsKeys.SCAN_AUTOMATIC] = + ConfigSetting(value = ps.scanOnSave, changed = ps.scanOnSave != defaultSettings.scanOnSave) + settingsMap[LsSettingsKeys.AUTHENTICATION_METHOD] = + ConfigSetting( + value = ps.authenticationType.languageServerSettingsName, + changed = ps.authenticationType != defaultSettings.authenticationType, + ) + settingsMap[LsSettingsKeys.ENABLE_SNYK_OSS_QUICK_FIX_CODE_ACTIONS] = + ConfigSetting(value = true, changed = false) + settingsMap[LsSettingsKeys.SCAN_NET_NEW] = + ConfigSetting( + value = ps.isDeltaFindingsEnabled(), + changed = ps.isDeltaFindingsEnabled() != defaultSettings.isDeltaFindingsEnabled(), + ) + + // Pass environment information in settings + settingsMap[LsSettingsKeys.INTEGRATION_NAME] = + ConfigSetting(value = pluginInfo.integrationName, changed = false) + settingsMap[LsSettingsKeys.INTEGRATION_VERSION] = + ConfigSetting(value = pluginInfo.integrationVersion, changed = false) + settingsMap[LsSettingsKeys.INTEGRATION_ENVIRONMENT] = + ConfigSetting(value = pluginInfo.integrationEnvironment, changed = false) + settingsMap[LsSettingsKeys.INTEGRATION_ENVIRONMENT_VERSION] = + ConfigSetting(value = pluginInfo.integrationEnvironmentVersion, changed = false) + settingsMap[LsSettingsKeys.DEVICE_ID] = + ConfigSetting(value = ps.userAnonymousId, changed = false) + settingsMap[LsSettingsKeys.OS_PLATFORM] = + ConfigSetting(value = SystemUtils.OS_NAME, changed = false) + settingsMap[LsSettingsKeys.OS_ARCH] = + ConfigSetting(value = SystemUtils.OS_ARCH, changed = false) + settingsMap[LsSettingsKeys.RUNTIME_NAME] = + ConfigSetting(value = SystemUtils.JAVA_RUNTIME_NAME, changed = false) + settingsMap[LsSettingsKeys.RUNTIME_VERSION] = + ConfigSetting(value = SystemUtils.JAVA_VERSION, changed = false) val folderConfigsList = configuredWorkspaceFolders @@ -620,40 +700,42 @@ class LanguageServerWrapper(private val project: Project) : Disposable { val fc = service().getFolderConfig(folderPath) val fcSettingsMap = mutableMapOf() - if (fc.baseBranch != null) { - fcSettingsMap["base_branch"] = ConfigSetting(value = fc.baseBranch!!, changed = true) - } + fcSettingsMap[LsFolderSettingsKeys.BASE_BRANCH] = + ConfigSetting(value = fc.baseBranch, changed = fc.baseBranch != "") if (fc.additionalEnv != null) { - fcSettingsMap["additional_environment"] = - ConfigSetting(value = fc.additionalEnv!!, changed = true) + fcSettingsMap[LsFolderSettingsKeys.ADDITIONAL_ENVIRONMENT] = + ConfigSetting(value = fc.additionalEnv!!, changed = fc.additionalEnv != "") } if (fc.additionalParameters != null) { - fcSettingsMap["additional_parameters"] = - ConfigSetting(value = fc.additionalParameters!!, changed = true) + fcSettingsMap[LsFolderSettingsKeys.ADDITIONAL_PARAMETERS] = + ConfigSetting( + value = fc.additionalParameters!!, + changed = fc.additionalParameters!!.isNotEmpty(), + ) } if (fc.localBranches != null) { - fcSettingsMap["local_branches"] = - ConfigSetting(value = fc.localBranches!!, changed = true) + fcSettingsMap[LsFolderSettingsKeys.LOCAL_BRANCHES] = + ConfigSetting(value = fc.localBranches!!, changed = fc.localBranches!!.isNotEmpty()) } if (fc.referenceFolderPath != null) { - fcSettingsMap["reference_folder"] = - ConfigSetting(value = fc.referenceFolderPath!!, changed = true) - } - if (fc.preferredOrg != null) { - fcSettingsMap["preferred_org"] = - ConfigSetting(value = fc.preferredOrg!!, changed = true) - } - if (fc.autoDeterminedOrg != null) { - fcSettingsMap["auto_determined_org"] = - ConfigSetting(value = fc.autoDeterminedOrg!!, changed = true) - } - if (fc.orgSetByUser != null) { - fcSettingsMap["org_set_by_user"] = - ConfigSetting(value = fc.orgSetByUser!!, changed = true) + fcSettingsMap[LsFolderSettingsKeys.REFERENCE_FOLDER] = + ConfigSetting( + value = fc.referenceFolderPath!!, + changed = fc.referenceFolderPath != "", + ) } + fcSettingsMap[LsFolderSettingsKeys.PREFERRED_ORG] = + ConfigSetting(value = fc.preferredOrg, changed = fc.preferredOrg != "") + fcSettingsMap[LsFolderSettingsKeys.AUTO_DETERMINED_ORG] = + ConfigSetting(value = fc.autoDeterminedOrg, changed = false) + fcSettingsMap[LsFolderSettingsKeys.ORG_SET_BY_USER] = + ConfigSetting(value = fc.orgSetByUser, changed = false) if (fc.scanCommandConfig != null) { - fcSettingsMap["scan_command_config"] = - ConfigSetting(value = fc.scanCommandConfig!!, changed = true) + fcSettingsMap[LsFolderSettingsKeys.SCAN_COMMAND_CONFIG] = + ConfigSetting( + value = fc.scanCommandConfig!!, + changed = fc.scanCommandConfig!!.isNotEmpty(), + ) } LspFolderConfig(folderPath = folderPath, settings = fcSettingsMap) } diff --git a/src/main/kotlin/snyk/common/lsp/SnykLanguageClient.kt b/src/main/kotlin/snyk/common/lsp/SnykLanguageClient.kt index 64b2f8f30..4bd64fe33 100644 --- a/src/main/kotlin/snyk/common/lsp/SnykLanguageClient.kt +++ b/src/main/kotlin/snyk/common/lsp/SnykLanguageClient.kt @@ -63,6 +63,8 @@ import snyk.common.ProductType import snyk.common.editor.DocumentChanger import snyk.common.lsp.progress.ProgressManager import snyk.common.lsp.settings.FolderConfigSettings +import snyk.common.lsp.settings.LsFolderSettingsKeys +import snyk.common.lsp.settings.LsSettingsKeys import snyk.common.lsp.settings.LspConfigurationParam import snyk.sdk.SdkHelper import snyk.trust.WorkspaceTrustService @@ -223,7 +225,7 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog var settingsChanged = false configurationParam.settings?.let { settings -> - settings["snyk_code_enabled"]?.value?.let { + settings[LsSettingsKeys.SNYK_CODE_ENABLED]?.value?.let { (it as? Boolean)?.let { boolVal -> if (ps.snykCodeSecurityIssuesScanEnable != boolVal) { ps.snykCodeSecurityIssuesScanEnable = boolVal @@ -231,7 +233,7 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog } } } - settings["snyk_oss_enabled"]?.value?.let { + settings[LsSettingsKeys.SNYK_OSS_ENABLED]?.value?.let { (it as? Boolean)?.let { boolVal -> if (ps.ossScanEnable != boolVal) { ps.ossScanEnable = boolVal @@ -239,7 +241,7 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog } } } - settings["snyk_iac_enabled"]?.value?.let { + settings[LsSettingsKeys.SNYK_IAC_ENABLED]?.value?.let { (it as? Boolean)?.let { boolVal -> if (ps.iacScanEnabled != boolVal) { ps.iacScanEnabled = boolVal @@ -247,7 +249,7 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog } } } - settings["snyk_secrets_enabled"]?.value?.let { + settings[LsSettingsKeys.SNYK_SECRETS_ENABLED]?.value?.let { (it as? Boolean)?.let { boolVal -> if (ps.secretsEnabled != boolVal) { ps.secretsEnabled = boolVal @@ -255,7 +257,7 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog } } } - settings["proxy_insecure"]?.value?.let { + settings[LsSettingsKeys.PROXY_INSECURE]?.value?.let { (it as? Boolean)?.let { boolVal -> if (ps.ignoreUnknownCA != boolVal) { ps.ignoreUnknownCA = boolVal @@ -263,7 +265,7 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog } } } - settings["api_endpoint"]?.value?.let { + settings[LsSettingsKeys.API_ENDPOINT]?.value?.let { (it as? String)?.let { strVal -> if (ps.customEndpointUrl != strVal) { ps.customEndpointUrl = strVal @@ -271,7 +273,7 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog } } } - settings["organization"]?.value?.let { + settings[LsSettingsKeys.ORGANIZATION]?.value?.let { (it as? String)?.let { strVal -> if (ps.organization != strVal) { ps.organization = strVal @@ -279,7 +281,7 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog } } } - settings["automatic_download"]?.value?.let { + settings[LsSettingsKeys.AUTOMATIC_DOWNLOAD]?.value?.let { (it as? Boolean)?.let { boolVal -> if (ps.manageBinariesAutomatically != boolVal) { ps.manageBinariesAutomatically = boolVal @@ -287,7 +289,7 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog } } } - settings["cli_path"]?.value?.let { + settings[LsSettingsKeys.CLI_PATH]?.value?.let { (it as? String)?.let { strVal -> if (ps.cliPath != strVal) { ps.cliPath = strVal @@ -295,7 +297,7 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog } } } - settings["binary_base_url"]?.value?.let { + settings[LsSettingsKeys.BINARY_BASE_URL]?.value?.let { (it as? String)?.let { strVal -> if (ps.cliBaseDownloadURL != strVal) { ps.cliBaseDownloadURL = strVal @@ -303,7 +305,7 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog } } } - settings["token"]?.value?.let { + settings[LsSettingsKeys.TOKEN]?.value?.let { (it as? String)?.let { strVal -> if (ps.token != strVal) { ps.token = strVal @@ -311,7 +313,7 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog } } } - settings["enabled_severities"]?.value?.let { + settings[LsSettingsKeys.ENABLED_SEVERITIES]?.value?.let { if (it is Map<*, *>) { (it["critical"] as? Boolean)?.let { critical -> if (ps.criticalSeverityEnabled != critical) { @@ -339,13 +341,13 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog } } } - settings["risk_score_threshold"]?.value?.let { + settings[LsSettingsKeys.RISK_SCORE_THRESHOLD]?.value?.let { if (it is Number && ps.riskScoreThreshold != it.toInt()) { ps.riskScoreThreshold = it.toInt() settingsChanged = true } } - settings["issue_view_open_issues"]?.value?.let { + settings[LsSettingsKeys.ISSUE_VIEW_OPEN_ISSUES]?.value?.let { (it as? Boolean)?.let { boolVal -> if (ps.openIssuesEnabled != boolVal) { ps.openIssuesEnabled = boolVal @@ -353,7 +355,7 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog } } } - settings["issue_view_ignored_issues"]?.value?.let { + settings[LsSettingsKeys.ISSUE_VIEW_IGNORED_ISSUES]?.value?.let { (it as? Boolean)?.let { boolVal -> if (ps.ignoredIssuesEnabled != boolVal) { ps.ignoredIssuesEnabled = boolVal @@ -361,7 +363,7 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog } } } - settings["scan_automatic"]?.value?.let { + settings[LsSettingsKeys.SCAN_AUTOMATIC]?.value?.let { (it as? Boolean)?.let { boolVal -> if (ps.scanOnSave != boolVal) { ps.scanOnSave = boolVal @@ -369,7 +371,7 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog } } } - settings["scan_net_new"]?.value?.let { + settings[LsSettingsKeys.SCAN_NET_NEW]?.value?.let { (it as? Boolean)?.let { boolVal -> if (ps.isDeltaFindingsEnabled() != boolVal) { ps.setDeltaEnabled(boolVal) @@ -394,25 +396,30 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog val convertedConfigs = folderConfigs.map { lspFolderConfig -> val settings = lspFolderConfig.settings ?: emptyMap() - val baseBranch = settings["base_branch"]?.value as? String ?: "" - val additionalEnv = settings["additional_environment"]?.value as? String + val baseBranch = settings[LsFolderSettingsKeys.BASE_BRANCH]?.value as? String ?: "" + val additionalEnv = + settings[LsFolderSettingsKeys.ADDITIONAL_ENVIRONMENT]?.value as? String // local_branches and additional_parameters are sent as arrays/lists of strings val localBranches = - (settings["local_branches"]?.value as? List<*>)?.filterIsInstance() - ?: emptyList() + (settings[LsFolderSettingsKeys.LOCAL_BRANCHES]?.value as? List<*>) + ?.filterIsInstance() ?: emptyList() val additionalParameters = - (settings["additional_parameters"]?.value as? List<*>)?.filterIsInstance() - ?: emptyList() - - val referenceFolderPath = settings["reference_folder"]?.value as? String - val preferredOrg = settings["preferred_org"]?.value as? String ?: "" - val autoDeterminedOrg = settings["auto_determined_org"]?.value as? String ?: "" - val orgSetByUser = settings["org_set_by_user"]?.value as? Boolean ?: false + (settings[LsFolderSettingsKeys.ADDITIONAL_PARAMETERS]?.value as? List<*>) + ?.filterIsInstance() ?: emptyList() + + val referenceFolderPath = + settings[LsFolderSettingsKeys.REFERENCE_FOLDER]?.value as? String + val preferredOrg = + settings[LsFolderSettingsKeys.PREFERRED_ORG]?.value as? String ?: "" + val autoDeterminedOrg = + settings[LsFolderSettingsKeys.AUTO_DETERMINED_ORG]?.value as? String ?: "" + val orgSetByUser = + settings[LsFolderSettingsKeys.ORG_SET_BY_USER]?.value as? Boolean ?: false @Suppress("UNCHECKED_CAST") val scanCommandConfig = - settings["scan_command_config"]?.value + settings[LsFolderSettingsKeys.SCAN_COMMAND_CONFIG]?.value as? Map FolderConfig( diff --git a/src/main/kotlin/snyk/common/lsp/settings/LanguageServerSettingsKeys.kt b/src/main/kotlin/snyk/common/lsp/settings/LanguageServerSettingsKeys.kt new file mode 100644 index 000000000..5285ef863 --- /dev/null +++ b/src/main/kotlin/snyk/common/lsp/settings/LanguageServerSettingsKeys.kt @@ -0,0 +1,49 @@ +package snyk.common.lsp.settings + +object LsSettingsKeys { + const val SNYK_CODE_ENABLED = "snyk_code_enabled" + const val SNYK_OSS_ENABLED = "snyk_oss_enabled" + const val SNYK_IAC_ENABLED = "snyk_iac_enabled" + const val SNYK_SECRETS_ENABLED = "snyk_secrets_enabled" + const val PROXY_INSECURE = "proxy_insecure" + const val API_ENDPOINT = "api_endpoint" + const val ORGANIZATION = "organization" + const val SEND_ERROR_REPORTS = "send_error_reports" + const val AUTOMATIC_DOWNLOAD = "automatic_download" + const val CLI_PATH = "cli_path" + const val BINARY_BASE_URL = "binary_base_url" + const val TOKEN = "token" + const val AUTOMATIC_AUTHENTICATION = "automatic_authentication" + const val ENABLED_SEVERITIES = "enabled_severities" + const val RISK_SCORE_THRESHOLD = "risk_score_threshold" + const val ISSUE_VIEW_OPEN_ISSUES = "issue_view_open_issues" + const val ISSUE_VIEW_IGNORED_ISSUES = "issue_view_ignored_issues" + const val TRUST_ENABLED = "trust_enabled" + const val SCAN_AUTOMATIC = "scan_automatic" + const val AUTHENTICATION_METHOD = "authentication_method" + const val ENABLE_SNYK_OSS_QUICK_FIX_CODE_ACTIONS = "enable_snyk_oss_quick_fix_code_actions" + const val SCAN_NET_NEW = "scan_net_new" + + // Environment Information + const val INTEGRATION_NAME = "integration_name" + const val INTEGRATION_VERSION = "integration_version" + const val INTEGRATION_ENVIRONMENT = "integration_environment" + const val INTEGRATION_ENVIRONMENT_VERSION = "integration_environment_version" + const val DEVICE_ID = "device_id" + const val OS_PLATFORM = "os_platform" + const val OS_ARCH = "os_arch" + const val RUNTIME_NAME = "runtime_name" + const val RUNTIME_VERSION = "runtime_version" +} + +object LsFolderSettingsKeys { + const val BASE_BRANCH = "base_branch" + const val ADDITIONAL_ENVIRONMENT = "additional_environment" + const val ADDITIONAL_PARAMETERS = "additional_parameters" + const val LOCAL_BRANCHES = "local_branches" + const val REFERENCE_FOLDER = "reference_folder" + const val PREFERRED_ORG = "preferred_org" + const val AUTO_DETERMINED_ORG = "auto_determined_org" + const val ORG_SET_BY_USER = "org_set_by_user" + const val SCAN_COMMAND_CONFIG = "scan_command_config" +} From 7ca4ee8dd4f713e26494642effaef421a89797cb Mon Sep 17 00:00:00 2001 From: Bastian Doetsch Date: Tue, 10 Mar 2026 09:37:54 +0100 Subject: [PATCH 03/11] refactor(lsp): use explicit change tracking for settings [IDE-1639] --- .../SnykApplicationSettingsStateService.kt | 8 ++ .../snyk/plugin/ui/jcef/SaveConfigHandler.kt | 87 +++++++++++++++---- .../snyk/common/lsp/LanguageServerWrapper.kt | 87 +++++++++++-------- src/main/kotlin/snyk/common/lsp/Types.kt | 20 +++++ 4 files changed, 152 insertions(+), 50 deletions(-) diff --git a/src/main/kotlin/io/snyk/plugin/services/SnykApplicationSettingsStateService.kt b/src/main/kotlin/io/snyk/plugin/services/SnykApplicationSettingsStateService.kt index f36a28cd3..ae8771a18 100644 --- a/src/main/kotlin/io/snyk/plugin/services/SnykApplicationSettingsStateService.kt +++ b/src/main/kotlin/io/snyk/plugin/services/SnykApplicationSettingsStateService.kt @@ -40,6 +40,14 @@ class SnykApplicationSettingsStateService : // testing flag var fileListenerEnabled: Boolean = true + var explicitChanges: MutableSet = mutableSetOf() + + fun markExplicitlyChanged(settingKey: String) { + explicitChanges.add(settingKey) + } + + fun isExplicitlyChanged(settingKey: String): Boolean = explicitChanges.contains(settingKey) + // TODO migrate to // https://plugins.jetbrains.com/docs/intellij/persisting-sensitive-data.html?from=jetbrains.org var token: String? = null diff --git a/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt b/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt index 8314330f2..b6013f096 100644 --- a/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt +++ b/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt @@ -21,6 +21,7 @@ import org.cef.browser.CefFrame import org.cef.handler.CefLoadHandlerAdapter import snyk.common.lsp.LanguageServerWrapper import snyk.common.lsp.settings.FolderConfigSettings +import snyk.common.lsp.settings.LsSettingsKeys import snyk.trust.WorkspaceTrustService class SaveConfigHandler( @@ -199,28 +200,56 @@ class SaveConfigHandler( ) { val isFallback = config.isFallbackForm == true - config.manageBinariesAutomatically?.let { settings.manageBinariesAutomatically = it } + config.manageBinariesAutomatically?.let { + settings.manageBinariesAutomatically = it + settings.markExplicitlyChanged(LsSettingsKeys.AUTOMATIC_DOWNLOAD) + } // Use the provided cliPath from the config if present, or the default CLI path if not. - config.cliPath?.let { path -> settings.cliPath = path.ifEmpty { getDefaultCliPath() } } + config.cliPath?.let { path -> + settings.cliPath = path.ifEmpty { getDefaultCliPath() } + settings.markExplicitlyChanged(LsSettingsKeys.CLI_PATH) + } - config.cliBaseDownloadURL?.let { settings.cliBaseDownloadURL = it } + config.cliBaseDownloadURL?.let { + settings.cliBaseDownloadURL = it + settings.markExplicitlyChanged(LsSettingsKeys.BINARY_BASE_URL) + } config.cliReleaseChannel?.let { settings.cliReleaseChannel = it } - config.insecure?.let { settings.ignoreUnknownCA = it } + config.insecure?.let { + settings.ignoreUnknownCA = it + settings.markExplicitlyChanged(LsSettingsKeys.PROXY_INSECURE) + } if (!isFallback) { settings.ossScanEnable = config.activateSnykOpenSource ?: false + settings.markExplicitlyChanged(LsSettingsKeys.SNYK_OSS_ENABLED) settings.snykCodeSecurityIssuesScanEnable = config.activateSnykCode ?: false + settings.markExplicitlyChanged(LsSettingsKeys.SNYK_CODE_ENABLED) settings.iacScanEnabled = config.activateSnykIac ?: false + settings.markExplicitlyChanged(LsSettingsKeys.SNYK_IAC_ENABLED) settings.secretsEnabled = config.activateSnykSecrets ?: false + settings.markExplicitlyChanged(LsSettingsKeys.SNYK_SECRETS_ENABLED) // Scanning mode - config.scanningMode?.let { settings.scanOnSave = (it == "auto") } + config.scanningMode?.let { + settings.scanOnSave = (it == "auto") + settings.markExplicitlyChanged(LsSettingsKeys.SCAN_AUTOMATIC) + } // Connection settings - config.organization?.let { settings.organization = it } - config.endpoint?.let { settings.customEndpointUrl = it } - config.token?.let { settings.token = it } + config.organization?.let { + settings.organization = it + settings.markExplicitlyChanged(LsSettingsKeys.ORGANIZATION) + } + config.endpoint?.let { + settings.customEndpointUrl = it + settings.markExplicitlyChanged(LsSettingsKeys.API_ENDPOINT) + } + config.token?.let { + settings.token = it + settings.markExplicitlyChanged(LsSettingsKeys.TOKEN) + } // Authentication method config.authenticationMethod?.let { method -> @@ -231,27 +260,52 @@ class SaveConfigHandler( "pat" -> AuthenticationType.PAT else -> AuthenticationType.OAUTH2 } + settings.markExplicitlyChanged(LsSettingsKeys.AUTHENTICATION_METHOD) } // Severity filters config.filterSeverity?.let { severity -> - severity.critical?.let { settings.criticalSeverityEnabled = it } - severity.high?.let { settings.highSeverityEnabled = it } - severity.medium?.let { settings.mediumSeverityEnabled = it } - severity.low?.let { settings.lowSeverityEnabled = it } + severity.critical?.let { + settings.criticalSeverityEnabled = it + settings.markExplicitlyChanged(LsSettingsKeys.ENABLED_SEVERITIES) + } + severity.high?.let { + settings.highSeverityEnabled = it + settings.markExplicitlyChanged(LsSettingsKeys.ENABLED_SEVERITIES) + } + severity.medium?.let { + settings.mediumSeverityEnabled = it + settings.markExplicitlyChanged(LsSettingsKeys.ENABLED_SEVERITIES) + } + severity.low?.let { + settings.lowSeverityEnabled = it + settings.markExplicitlyChanged(LsSettingsKeys.ENABLED_SEVERITIES) + } } // Issue view options config.issueViewOptions?.let { options -> - options.openIssues?.let { settings.openIssuesEnabled = it } - options.ignoredIssues?.let { settings.ignoredIssuesEnabled = it } + options.openIssues?.let { + settings.openIssuesEnabled = it + settings.markExplicitlyChanged(LsSettingsKeys.ISSUE_VIEW_OPEN_ISSUES) + } + options.ignoredIssues?.let { + settings.ignoredIssuesEnabled = it + settings.markExplicitlyChanged(LsSettingsKeys.ISSUE_VIEW_IGNORED_ISSUES) + } } // Delta findings - config.enableDeltaFindings?.let { settings.setDeltaEnabled(it) } + config.enableDeltaFindings?.let { + settings.setDeltaEnabled(it) + settings.markExplicitlyChanged(LsSettingsKeys.SCAN_NET_NEW) + } // Risk score threshold - config.riskScoreThreshold?.let { settings.riskScoreThreshold = it } + config.riskScoreThreshold?.let { + settings.riskScoreThreshold = it + settings.markExplicitlyChanged(LsSettingsKeys.RISK_SCORE_THRESHOLD) + } // Trusted folders - sync the list (add new, remove missing) config.trustedFolders?.let { folders -> @@ -322,6 +376,7 @@ class SaveConfigHandler( folderConfig.scanCommandConfig?.let { parseScanCommandConfig(it) } ?: existingConfig.scanCommandConfig, ) + updatedConfig.migrateExplicitChanges() fcs.addFolderConfig(updatedConfig) } } diff --git a/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt b/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt index 466658939..b2d350318 100644 --- a/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt +++ b/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt @@ -549,41 +549,43 @@ class LanguageServerWrapper(private val project: Project) : Disposable { settingsMap[LsSettingsKeys.SNYK_CODE_ENABLED] = ConfigSetting( value = ps.snykCodeSecurityIssuesScanEnable, - changed = - ps.snykCodeSecurityIssuesScanEnable != defaultSettings.snykCodeSecurityIssuesScanEnable, + changed = ps.isExplicitlyChanged(LsSettingsKeys.SNYK_CODE_ENABLED), ) settingsMap[LsSettingsKeys.SNYK_OSS_ENABLED] = ConfigSetting( value = ps.ossScanEnable, - changed = ps.ossScanEnable != defaultSettings.ossScanEnable, + changed = ps.isExplicitlyChanged(LsSettingsKeys.SNYK_OSS_ENABLED), ) settingsMap[LsSettingsKeys.SNYK_IAC_ENABLED] = ConfigSetting( value = ps.iacScanEnabled, - changed = ps.iacScanEnabled != defaultSettings.iacScanEnabled, + changed = ps.isExplicitlyChanged(LsSettingsKeys.SNYK_IAC_ENABLED), ) settingsMap[LsSettingsKeys.SNYK_SECRETS_ENABLED] = ConfigSetting( value = ps.secretsEnabled, - changed = ps.secretsEnabled != defaultSettings.secretsEnabled, + changed = ps.isExplicitlyChanged(LsSettingsKeys.SNYK_SECRETS_ENABLED), ) settingsMap[LsSettingsKeys.PROXY_INSECURE] = ConfigSetting( value = ps.ignoreUnknownCA, - changed = ps.ignoreUnknownCA != defaultSettings.ignoreUnknownCA, + changed = ps.isExplicitlyChanged(LsSettingsKeys.PROXY_INSECURE), ) val endpoint = getEndpointUrl() if (!endpoint.isNullOrBlank()) { settingsMap[LsSettingsKeys.API_ENDPOINT] = - ConfigSetting(value = endpoint, changed = endpoint != defaultSettings.customEndpointUrl) + ConfigSetting( + value = endpoint, + changed = ps.isExplicitlyChanged(LsSettingsKeys.API_ENDPOINT), + ) } if (ps.organization != null) { settingsMap[LsSettingsKeys.ORGANIZATION] = ConfigSetting( value = ps.organization!!, - changed = ps.organization != defaultSettings.organization, + changed = ps.isExplicitlyChanged(LsSettingsKeys.ORGANIZATION), ) } @@ -591,26 +593,29 @@ class LanguageServerWrapper(private val project: Project) : Disposable { settingsMap[LsSettingsKeys.AUTOMATIC_DOWNLOAD] = ConfigSetting( value = ps.manageBinariesAutomatically, - changed = ps.manageBinariesAutomatically != defaultSettings.manageBinariesAutomatically, + changed = ps.isExplicitlyChanged(LsSettingsKeys.AUTOMATIC_DOWNLOAD), ) val currentCliPath = getCliFile().absolutePath if (currentCliPath.isNotBlank()) { settingsMap[LsSettingsKeys.CLI_PATH] = - ConfigSetting(value = currentCliPath, changed = currentCliPath != defaultSettings.cliPath) + ConfigSetting( + value = currentCliPath, + changed = ps.isExplicitlyChanged(LsSettingsKeys.CLI_PATH), + ) } if (!ps.cliBaseDownloadURL.isNullOrBlank()) { settingsMap[LsSettingsKeys.BINARY_BASE_URL] = ConfigSetting( value = ps.cliBaseDownloadURL, - changed = ps.cliBaseDownloadURL != defaultSettings.cliBaseDownloadURL, + changed = ps.isExplicitlyChanged(LsSettingsKeys.BINARY_BASE_URL), ) } if (!ps.token.isNullOrBlank()) { settingsMap[LsSettingsKeys.TOKEN] = - ConfigSetting(value = ps.token!!, changed = ps.token != defaultSettings.token) + ConfigSetting(value = ps.token!!, changed = ps.isExplicitlyChanged(LsSettingsKeys.TOKEN)) } settingsMap[LsSettingsKeys.AUTOMATIC_AUTHENTICATION] = @@ -624,49 +629,48 @@ class LanguageServerWrapper(private val project: Project) : Disposable { "medium" to ps.mediumSeverityEnabled, "low" to ps.lowSeverityEnabled, ) - val defaultSeverityFilter = - mapOf( - "critical" to defaultSettings.criticalSeverityEnabled, - "high" to defaultSettings.highSeverityEnabled, - "medium" to defaultSettings.mediumSeverityEnabled, - "low" to defaultSettings.lowSeverityEnabled, - ) settingsMap[LsSettingsKeys.ENABLED_SEVERITIES] = - ConfigSetting(value = severityFilter, changed = severityFilter != defaultSeverityFilter) + ConfigSetting( + value = severityFilter, + changed = ps.isExplicitlyChanged(LsSettingsKeys.ENABLED_SEVERITIES), + ) if (ps.riskScoreThreshold != null) { settingsMap[LsSettingsKeys.RISK_SCORE_THRESHOLD] = ConfigSetting( value = ps.riskScoreThreshold!!, - changed = ps.riskScoreThreshold != defaultSettings.riskScoreThreshold, + changed = ps.isExplicitlyChanged(LsSettingsKeys.RISK_SCORE_THRESHOLD), ) } settingsMap[LsSettingsKeys.ISSUE_VIEW_OPEN_ISSUES] = ConfigSetting( value = ps.openIssuesEnabled, - changed = ps.openIssuesEnabled != defaultSettings.openIssuesEnabled, + changed = ps.isExplicitlyChanged(LsSettingsKeys.ISSUE_VIEW_OPEN_ISSUES), ) settingsMap[LsSettingsKeys.ISSUE_VIEW_IGNORED_ISSUES] = ConfigSetting( value = ps.ignoredIssuesEnabled, - changed = ps.ignoredIssuesEnabled != defaultSettings.ignoredIssuesEnabled, + changed = ps.isExplicitlyChanged(LsSettingsKeys.ISSUE_VIEW_IGNORED_ISSUES), ) settingsMap[LsSettingsKeys.TRUST_ENABLED] = ConfigSetting(value = false, changed = false) settingsMap[LsSettingsKeys.SCAN_AUTOMATIC] = - ConfigSetting(value = ps.scanOnSave, changed = ps.scanOnSave != defaultSettings.scanOnSave) + ConfigSetting( + value = ps.scanOnSave, + changed = ps.isExplicitlyChanged(LsSettingsKeys.SCAN_AUTOMATIC), + ) settingsMap[LsSettingsKeys.AUTHENTICATION_METHOD] = ConfigSetting( value = ps.authenticationType.languageServerSettingsName, - changed = ps.authenticationType != defaultSettings.authenticationType, + changed = ps.isExplicitlyChanged(LsSettingsKeys.AUTHENTICATION_METHOD), ) settingsMap[LsSettingsKeys.ENABLE_SNYK_OSS_QUICK_FIX_CODE_ACTIONS] = ConfigSetting(value = true, changed = false) settingsMap[LsSettingsKeys.SCAN_NET_NEW] = ConfigSetting( value = ps.isDeltaFindingsEnabled(), - changed = ps.isDeltaFindingsEnabled() != defaultSettings.isDeltaFindingsEnabled(), + changed = ps.isExplicitlyChanged(LsSettingsKeys.SCAN_NET_NEW), ) // Pass environment information in settings @@ -701,40 +705,55 @@ class LanguageServerWrapper(private val project: Project) : Disposable { val fcSettingsMap = mutableMapOf() fcSettingsMap[LsFolderSettingsKeys.BASE_BRANCH] = - ConfigSetting(value = fc.baseBranch, changed = fc.baseBranch != "") + ConfigSetting( + value = fc.baseBranch, + changed = fc.isExplicitlyChanged(LsFolderSettingsKeys.BASE_BRANCH), + ) if (fc.additionalEnv != null) { fcSettingsMap[LsFolderSettingsKeys.ADDITIONAL_ENVIRONMENT] = - ConfigSetting(value = fc.additionalEnv!!, changed = fc.additionalEnv != "") + ConfigSetting( + value = fc.additionalEnv!!, + changed = fc.isExplicitlyChanged(LsFolderSettingsKeys.ADDITIONAL_ENVIRONMENT), + ) } if (fc.additionalParameters != null) { fcSettingsMap[LsFolderSettingsKeys.ADDITIONAL_PARAMETERS] = ConfigSetting( value = fc.additionalParameters!!, - changed = fc.additionalParameters!!.isNotEmpty(), + changed = fc.isExplicitlyChanged(LsFolderSettingsKeys.ADDITIONAL_PARAMETERS), ) } if (fc.localBranches != null) { fcSettingsMap[LsFolderSettingsKeys.LOCAL_BRANCHES] = - ConfigSetting(value = fc.localBranches!!, changed = fc.localBranches!!.isNotEmpty()) + ConfigSetting( + value = fc.localBranches!!, + changed = fc.isExplicitlyChanged(LsFolderSettingsKeys.LOCAL_BRANCHES), + ) } if (fc.referenceFolderPath != null) { fcSettingsMap[LsFolderSettingsKeys.REFERENCE_FOLDER] = ConfigSetting( value = fc.referenceFolderPath!!, - changed = fc.referenceFolderPath != "", + changed = fc.isExplicitlyChanged(LsFolderSettingsKeys.REFERENCE_FOLDER), ) } fcSettingsMap[LsFolderSettingsKeys.PREFERRED_ORG] = - ConfigSetting(value = fc.preferredOrg, changed = fc.preferredOrg != "") + ConfigSetting( + value = fc.preferredOrg, + changed = fc.isExplicitlyChanged(LsFolderSettingsKeys.PREFERRED_ORG), + ) fcSettingsMap[LsFolderSettingsKeys.AUTO_DETERMINED_ORG] = ConfigSetting(value = fc.autoDeterminedOrg, changed = false) fcSettingsMap[LsFolderSettingsKeys.ORG_SET_BY_USER] = - ConfigSetting(value = fc.orgSetByUser, changed = false) + ConfigSetting( + value = fc.orgSetByUser, + changed = fc.isExplicitlyChanged(LsFolderSettingsKeys.ORG_SET_BY_USER), + ) if (fc.scanCommandConfig != null) { fcSettingsMap[LsFolderSettingsKeys.SCAN_COMMAND_CONFIG] = ConfigSetting( value = fc.scanCommandConfig!!, - changed = fc.scanCommandConfig!!.isNotEmpty(), + changed = fc.isExplicitlyChanged(LsFolderSettingsKeys.SCAN_COMMAND_CONFIG), ) } LspFolderConfig(folderPath = folderPath, settings = fcSettingsMap) diff --git a/src/main/kotlin/snyk/common/lsp/Types.kt b/src/main/kotlin/snyk/common/lsp/Types.kt index a83e64afd..672679887 100644 --- a/src/main/kotlin/snyk/common/lsp/Types.kt +++ b/src/main/kotlin/snyk/common/lsp/Types.kt @@ -538,8 +538,28 @@ data class FolderConfig( @SerializedName("scanCommandConfig") val scanCommandConfig: Map? = emptyMap(), @SerializedName("orgSetByUser") val orgSetByUser: Boolean = false, + @Transient val explicitChanges: MutableSet = mutableSetOf(), ) : Comparable { override fun compareTo(other: FolderConfig): Int = this.folderPath.compareTo(other.folderPath) + + fun markExplicitlyChanged(settingKey: String) { + explicitChanges.add(settingKey) + } + + fun isExplicitlyChanged(settingKey: String): Boolean = explicitChanges.contains(settingKey) + + fun migrateExplicitChanges() { + if (explicitChanges.isEmpty()) { + if (baseBranch.isNotEmpty()) markExplicitlyChanged("base_branch") + if (!additionalEnv.isNullOrEmpty()) markExplicitlyChanged("additional_environment") + if (!additionalParameters.isNullOrEmpty()) markExplicitlyChanged("additional_parameters") + if (!localBranches.isNullOrEmpty()) markExplicitlyChanged("local_branches") + if (!referenceFolderPath.isNullOrEmpty()) markExplicitlyChanged("reference_folder") + if (preferredOrg.isNotEmpty()) markExplicitlyChanged("preferred_org") + if (orgSetByUser) markExplicitlyChanged("org_set_by_user") + if (!scanCommandConfig.isNullOrEmpty()) markExplicitlyChanged("scan_command_config") + } + } } data class ScanCommandConfig( From 6e9d9c936e4308b9065b48209589844117e9181f Mon Sep 17 00:00:00 2001 From: Bastian Doetsch Date: Tue, 10 Mar 2026 09:44:31 +0100 Subject: [PATCH 04/11] refactor(lsp): use LspFolderConfig to pass changes explicitly [IDE-1639] --- .../ui/jcef/ConfigurationDataClasses.kt | 23 +-------- .../snyk/plugin/ui/jcef/SaveConfigHandler.kt | 50 ++++--------------- 2 files changed, 13 insertions(+), 60 deletions(-) diff --git a/src/main/kotlin/io/snyk/plugin/ui/jcef/ConfigurationDataClasses.kt b/src/main/kotlin/io/snyk/plugin/ui/jcef/ConfigurationDataClasses.kt index 20e0bc8a2..f1d57c77c 100644 --- a/src/main/kotlin/io/snyk/plugin/ui/jcef/ConfigurationDataClasses.kt +++ b/src/main/kotlin/io/snyk/plugin/ui/jcef/ConfigurationDataClasses.kt @@ -84,7 +84,8 @@ data class SaveConfigRequest( @SerializedName("trustedFolders") val trustedFolders: List? = null, // Folder Configs - @SerializedName("folderConfigs") val folderConfigs: List? = null, + @SerializedName("folderConfigs") + val folderConfigs: List? = null, // Form Type Indicator @SerializedName("isFallbackForm") val isFallbackForm: Boolean? = null, @@ -101,23 +102,3 @@ data class IssueViewOptionsConfig( @SerializedName("openIssues") val openIssues: Boolean? = null, @SerializedName("ignoredIssues") val ignoredIssues: Boolean? = null, ) - -data class FolderConfigData( - @SerializedName("folderPath") val folderPath: String, - @SerializedName("additionalParameters") - @com.google.gson.annotations.JsonAdapter(StringOrListTypeAdapter::class) - val additionalParameters: List? = null, - @SerializedName("additionalEnv") val additionalEnv: String? = null, - @SerializedName("preferredOrg") val preferredOrg: String? = null, - @SerializedName("autoDeterminedOrg") val autoDeterminedOrg: String? = null, - @SerializedName("orgSetByUser") val orgSetByUser: Boolean? = null, - @SerializedName("scanCommandConfig") - val scanCommandConfig: Map? = null, -) - -data class ScanCommandConfigData( - @SerializedName("preScanCommand") val preScanCommand: String? = null, - @SerializedName("preScanOnlyReferenceFolder") val preScanOnlyReferenceFolder: Boolean? = null, - @SerializedName("postScanCommand") val postScanCommand: String? = null, - @SerializedName("postScanOnlyReferenceFolder") val postScanOnlyReferenceFolder: Boolean? = null, -) diff --git a/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt b/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt index b6013f096..098246625 100644 --- a/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt +++ b/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt @@ -20,7 +20,6 @@ import org.cef.browser.CefBrowser import org.cef.browser.CefFrame import org.cef.handler.CefLoadHandlerAdapter import snyk.common.lsp.LanguageServerWrapper -import snyk.common.lsp.settings.FolderConfigSettings import snyk.common.lsp.settings.LsSettingsKeys import snyk.trust.WorkspaceTrustService @@ -358,44 +357,17 @@ class SaveConfigHandler( } } - private fun applyFolderConfigs(folderConfigs: List) { - val fcs = service() - - for (folderConfig in folderConfigs) { - val existingConfig = fcs.getFolderConfig(folderConfig.folderPath) - - // Build updated config, writing values directly from config (use defaults if null) - val updatedConfig = - existingConfig.copy( - additionalParameters = folderConfig.additionalParameters, - additionalEnv = folderConfig.additionalEnv, - preferredOrg = folderConfig.preferredOrg ?: "", - autoDeterminedOrg = folderConfig.autoDeterminedOrg ?: "", - orgSetByUser = folderConfig.orgSetByUser ?: false, - scanCommandConfig = - folderConfig.scanCommandConfig?.let { parseScanCommandConfig(it) } - ?: existingConfig.scanCommandConfig, - ) - updatedConfig.migrateExplicitChanges() - fcs.addFolderConfig(updatedConfig) + private fun applyFolderConfigs(folderConfigs: List) { + val snykLanguageClient = + snyk.common.lsp.LanguageServerWrapper.getInstance(project).languageClient + + if (snykLanguageClient != null) { + // Create a dummy notification param with just the folder configs + val param = snyk.common.lsp.settings.LspConfigurationParam(folderConfigs = folderConfigs) + // Call SnykLanguageClient internal function directly + snykLanguageClient.snykConfiguration(param) + } else { + logger.warn("Could not apply folder configs as SnykLanguageClient is null") } } - - private fun parseScanCommandConfig( - scanConfig: Map - ): Map { - val result = mutableMapOf() - - for ((product, config) in scanConfig) { - result[product] = - snyk.common.lsp.ScanCommandConfig( - preScanCommand = config.preScanCommand ?: "", - preScanOnlyReferenceFolder = config.preScanOnlyReferenceFolder ?: false, - postScanCommand = config.postScanCommand ?: "", - postScanOnlyReferenceFolder = config.postScanOnlyReferenceFolder ?: false, - ) - } - - return result - } } From 7901418f82ce2f188c0f409d5e19ee4ed8e630d5 Mon Sep 17 00:00:00 2001 From: Bastian Doetsch Date: Tue, 10 Mar 2026 09:50:40 +0100 Subject: [PATCH 05/11] Revert refactoring to use LspFolderConfig --- .../ui/jcef/ConfigurationDataClasses.kt | 23 ++++++++- .../snyk/plugin/ui/jcef/SaveConfigHandler.kt | 50 +++++++++++++++---- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/io/snyk/plugin/ui/jcef/ConfigurationDataClasses.kt b/src/main/kotlin/io/snyk/plugin/ui/jcef/ConfigurationDataClasses.kt index f1d57c77c..20e0bc8a2 100644 --- a/src/main/kotlin/io/snyk/plugin/ui/jcef/ConfigurationDataClasses.kt +++ b/src/main/kotlin/io/snyk/plugin/ui/jcef/ConfigurationDataClasses.kt @@ -84,8 +84,7 @@ data class SaveConfigRequest( @SerializedName("trustedFolders") val trustedFolders: List? = null, // Folder Configs - @SerializedName("folderConfigs") - val folderConfigs: List? = null, + @SerializedName("folderConfigs") val folderConfigs: List? = null, // Form Type Indicator @SerializedName("isFallbackForm") val isFallbackForm: Boolean? = null, @@ -102,3 +101,23 @@ data class IssueViewOptionsConfig( @SerializedName("openIssues") val openIssues: Boolean? = null, @SerializedName("ignoredIssues") val ignoredIssues: Boolean? = null, ) + +data class FolderConfigData( + @SerializedName("folderPath") val folderPath: String, + @SerializedName("additionalParameters") + @com.google.gson.annotations.JsonAdapter(StringOrListTypeAdapter::class) + val additionalParameters: List? = null, + @SerializedName("additionalEnv") val additionalEnv: String? = null, + @SerializedName("preferredOrg") val preferredOrg: String? = null, + @SerializedName("autoDeterminedOrg") val autoDeterminedOrg: String? = null, + @SerializedName("orgSetByUser") val orgSetByUser: Boolean? = null, + @SerializedName("scanCommandConfig") + val scanCommandConfig: Map? = null, +) + +data class ScanCommandConfigData( + @SerializedName("preScanCommand") val preScanCommand: String? = null, + @SerializedName("preScanOnlyReferenceFolder") val preScanOnlyReferenceFolder: Boolean? = null, + @SerializedName("postScanCommand") val postScanCommand: String? = null, + @SerializedName("postScanOnlyReferenceFolder") val postScanOnlyReferenceFolder: Boolean? = null, +) diff --git a/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt b/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt index 098246625..b6013f096 100644 --- a/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt +++ b/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt @@ -20,6 +20,7 @@ import org.cef.browser.CefBrowser import org.cef.browser.CefFrame import org.cef.handler.CefLoadHandlerAdapter import snyk.common.lsp.LanguageServerWrapper +import snyk.common.lsp.settings.FolderConfigSettings import snyk.common.lsp.settings.LsSettingsKeys import snyk.trust.WorkspaceTrustService @@ -357,17 +358,44 @@ class SaveConfigHandler( } } - private fun applyFolderConfigs(folderConfigs: List) { - val snykLanguageClient = - snyk.common.lsp.LanguageServerWrapper.getInstance(project).languageClient - - if (snykLanguageClient != null) { - // Create a dummy notification param with just the folder configs - val param = snyk.common.lsp.settings.LspConfigurationParam(folderConfigs = folderConfigs) - // Call SnykLanguageClient internal function directly - snykLanguageClient.snykConfiguration(param) - } else { - logger.warn("Could not apply folder configs as SnykLanguageClient is null") + private fun applyFolderConfigs(folderConfigs: List) { + val fcs = service() + + for (folderConfig in folderConfigs) { + val existingConfig = fcs.getFolderConfig(folderConfig.folderPath) + + // Build updated config, writing values directly from config (use defaults if null) + val updatedConfig = + existingConfig.copy( + additionalParameters = folderConfig.additionalParameters, + additionalEnv = folderConfig.additionalEnv, + preferredOrg = folderConfig.preferredOrg ?: "", + autoDeterminedOrg = folderConfig.autoDeterminedOrg ?: "", + orgSetByUser = folderConfig.orgSetByUser ?: false, + scanCommandConfig = + folderConfig.scanCommandConfig?.let { parseScanCommandConfig(it) } + ?: existingConfig.scanCommandConfig, + ) + updatedConfig.migrateExplicitChanges() + fcs.addFolderConfig(updatedConfig) } } + + private fun parseScanCommandConfig( + scanConfig: Map + ): Map { + val result = mutableMapOf() + + for ((product, config) in scanConfig) { + result[product] = + snyk.common.lsp.ScanCommandConfig( + preScanCommand = config.preScanCommand ?: "", + preScanOnlyReferenceFolder = config.preScanOnlyReferenceFolder ?: false, + postScanCommand = config.postScanCommand ?: "", + postScanOnlyReferenceFolder = config.postScanOnlyReferenceFolder ?: false, + ) + } + + return result + } } From 6b25fb0ca8d3f977525c12995fd2c1c79ef42b6a Mon Sep 17 00:00:00 2001 From: Bastian Doetsch Date: Tue, 10 Mar 2026 09:54:02 +0100 Subject: [PATCH 06/11] fix: track explicit changes for folder configs from UI [IDE-1639] --- .../snyk/plugin/ui/jcef/SaveConfigHandler.kt | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt b/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt index b6013f096..d76e2071a 100644 --- a/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt +++ b/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt @@ -21,6 +21,7 @@ import org.cef.browser.CefFrame import org.cef.handler.CefLoadHandlerAdapter import snyk.common.lsp.LanguageServerWrapper import snyk.common.lsp.settings.FolderConfigSettings +import snyk.common.lsp.settings.LsFolderSettingsKeys import snyk.common.lsp.settings.LsSettingsKeys import snyk.trust.WorkspaceTrustService @@ -377,6 +378,39 @@ class SaveConfigHandler( ?: existingConfig.scanCommandConfig, ) updatedConfig.migrateExplicitChanges() + + // Track explicit changes made by the user in the UI form + if ( + folderConfig.additionalParameters != null && + folderConfig.additionalParameters != existingConfig.additionalParameters + ) { + updatedConfig.markExplicitlyChanged(LsFolderSettingsKeys.ADDITIONAL_PARAMETERS) + } + if ( + folderConfig.additionalEnv != null && + folderConfig.additionalEnv != existingConfig.additionalEnv + ) { + updatedConfig.markExplicitlyChanged(LsFolderSettingsKeys.ADDITIONAL_ENVIRONMENT) + } + if ( + folderConfig.preferredOrg != null && + folderConfig.preferredOrg != existingConfig.preferredOrg + ) { + updatedConfig.markExplicitlyChanged(LsFolderSettingsKeys.PREFERRED_ORG) + } + if ( + folderConfig.orgSetByUser != null && + folderConfig.orgSetByUser != existingConfig.orgSetByUser + ) { + updatedConfig.markExplicitlyChanged(LsFolderSettingsKeys.ORG_SET_BY_USER) + } + if (folderConfig.scanCommandConfig != null) { + val parsedScanCommandConfig = parseScanCommandConfig(folderConfig.scanCommandConfig) + if (parsedScanCommandConfig != existingConfig.scanCommandConfig) { + updatedConfig.markExplicitlyChanged(LsFolderSettingsKeys.SCAN_COMMAND_CONFIG) + } + } + fcs.addFolderConfig(updatedConfig) } } From ca4feadd37787ffc0c8ad7f6c3ce91f521ef4fb6 Mon Sep 17 00:00:00 2001 From: Bastian Doetsch Date: Tue, 10 Mar 2026 09:58:40 +0100 Subject: [PATCH 07/11] fix: add org-scope overrides to folder config and track them explicitly [IDE-1639] --- .../ui/jcef/ConfigurationDataClasses.kt | 10 +++ .../snyk/common/lsp/LanguageServerWrapper.kt | 72 +++++++++++++++++++ src/main/kotlin/snyk/common/lsp/Types.kt | 11 +++ 3 files changed, 93 insertions(+) diff --git a/src/main/kotlin/io/snyk/plugin/ui/jcef/ConfigurationDataClasses.kt b/src/main/kotlin/io/snyk/plugin/ui/jcef/ConfigurationDataClasses.kt index 20e0bc8a2..8265eb6c5 100644 --- a/src/main/kotlin/io/snyk/plugin/ui/jcef/ConfigurationDataClasses.kt +++ b/src/main/kotlin/io/snyk/plugin/ui/jcef/ConfigurationDataClasses.kt @@ -113,6 +113,16 @@ data class FolderConfigData( @SerializedName("orgSetByUser") val orgSetByUser: Boolean? = null, @SerializedName("scanCommandConfig") val scanCommandConfig: Map? = null, + // Org-scope override fields + @SerializedName("scanAutomatic") val scanAutomatic: Boolean? = null, + @SerializedName("scanNetNew") val scanNetNew: Boolean? = null, + @SerializedName("enabledSeverities") val enabledSeverities: SeverityFilterConfig? = null, + @SerializedName("snykOssEnabled") val snykOssEnabled: Boolean? = null, + @SerializedName("snykCodeEnabled") val snykCodeEnabled: Boolean? = null, + @SerializedName("snykIacEnabled") val snykIacEnabled: Boolean? = null, + @SerializedName("issueViewOpenIssues") val issueViewOpenIssues: Boolean? = null, + @SerializedName("issueViewIgnoredIssues") val issueViewIgnoredIssues: Boolean? = null, + @SerializedName("riskScoreThreshold") val riskScoreThreshold: Int? = null, ) data class ScanCommandConfigData( diff --git a/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt b/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt index b2d350318..a9cfe07dd 100644 --- a/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt +++ b/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt @@ -756,6 +756,78 @@ class LanguageServerWrapper(private val project: Project) : Disposable { changed = fc.isExplicitlyChanged(LsFolderSettingsKeys.SCAN_COMMAND_CONFIG), ) } + + // Org-scope overrides + if (fc.scanAutomatic != null) { + fcSettingsMap[LsSettingsKeys.SCAN_AUTOMATIC] = + ConfigSetting( + value = fc.scanAutomatic, + changed = fc.isExplicitlyChanged(LsSettingsKeys.SCAN_AUTOMATIC), + ) + } + if (fc.scanNetNew != null) { + fcSettingsMap[LsSettingsKeys.SCAN_NET_NEW] = + ConfigSetting( + value = fc.scanNetNew, + changed = fc.isExplicitlyChanged(LsSettingsKeys.SCAN_NET_NEW), + ) + } + if (fc.enabledSeverities != null) { + val severityFilter = + mapOf( + "critical" to fc.enabledSeverities.critical, + "high" to fc.enabledSeverities.high, + "medium" to fc.enabledSeverities.medium, + "low" to fc.enabledSeverities.low, + ) + fcSettingsMap[LsSettingsKeys.ENABLED_SEVERITIES] = + ConfigSetting( + value = severityFilter, + changed = fc.isExplicitlyChanged(LsSettingsKeys.ENABLED_SEVERITIES), + ) + } + if (fc.snykOssEnabled != null) { + fcSettingsMap[LsSettingsKeys.SNYK_OSS_ENABLED] = + ConfigSetting( + value = fc.snykOssEnabled, + changed = fc.isExplicitlyChanged(LsSettingsKeys.SNYK_OSS_ENABLED), + ) + } + if (fc.snykCodeEnabled != null) { + fcSettingsMap[LsSettingsKeys.SNYK_CODE_ENABLED] = + ConfigSetting( + value = fc.snykCodeEnabled, + changed = fc.isExplicitlyChanged(LsSettingsKeys.SNYK_CODE_ENABLED), + ) + } + if (fc.snykIacEnabled != null) { + fcSettingsMap[LsSettingsKeys.SNYK_IAC_ENABLED] = + ConfigSetting( + value = fc.snykIacEnabled, + changed = fc.isExplicitlyChanged(LsSettingsKeys.SNYK_IAC_ENABLED), + ) + } + if (fc.issueViewOpenIssues != null) { + fcSettingsMap[LsSettingsKeys.ISSUE_VIEW_OPEN_ISSUES] = + ConfigSetting( + value = fc.issueViewOpenIssues, + changed = fc.isExplicitlyChanged(LsSettingsKeys.ISSUE_VIEW_OPEN_ISSUES), + ) + } + if (fc.issueViewIgnoredIssues != null) { + fcSettingsMap[LsSettingsKeys.ISSUE_VIEW_IGNORED_ISSUES] = + ConfigSetting( + value = fc.issueViewIgnoredIssues, + changed = fc.isExplicitlyChanged(LsSettingsKeys.ISSUE_VIEW_IGNORED_ISSUES), + ) + } + if (fc.riskScoreThreshold != null) { + fcSettingsMap[LsSettingsKeys.RISK_SCORE_THRESHOLD] = + ConfigSetting( + value = fc.riskScoreThreshold, + changed = fc.isExplicitlyChanged(LsSettingsKeys.RISK_SCORE_THRESHOLD), + ) + } LspFolderConfig(folderPath = folderPath, settings = fcSettingsMap) } .toList() diff --git a/src/main/kotlin/snyk/common/lsp/Types.kt b/src/main/kotlin/snyk/common/lsp/Types.kt index 672679887..9815f6baa 100644 --- a/src/main/kotlin/snyk/common/lsp/Types.kt +++ b/src/main/kotlin/snyk/common/lsp/Types.kt @@ -539,6 +539,17 @@ data class FolderConfig( val scanCommandConfig: Map? = emptyMap(), @SerializedName("orgSetByUser") val orgSetByUser: Boolean = false, @Transient val explicitChanges: MutableSet = mutableSetOf(), + // Org-scope overrides + @SerializedName("scanAutomatic") val scanAutomatic: Boolean? = null, + @SerializedName("scanNetNew") val scanNetNew: Boolean? = null, + @SerializedName("enabledSeverities") + val enabledSeverities: snyk.common.lsp.settings.SeverityFilter? = null, + @SerializedName("snykOssEnabled") val snykOssEnabled: Boolean? = null, + @SerializedName("snykCodeEnabled") val snykCodeEnabled: Boolean? = null, + @SerializedName("snykIacEnabled") val snykIacEnabled: Boolean? = null, + @SerializedName("issueViewOpenIssues") val issueViewOpenIssues: Boolean? = null, + @SerializedName("issueViewIgnoredIssues") val issueViewIgnoredIssues: Boolean? = null, + @SerializedName("riskScoreThreshold") val riskScoreThreshold: Int? = null, ) : Comparable { override fun compareTo(other: FolderConfig): Int = this.folderPath.compareTo(other.folderPath) From a2b9036d0fe73fc61e3a4e5be72ed7614b488b80 Mon Sep 17 00:00:00 2001 From: Bastian Doetsch Date: Tue, 10 Mar 2026 15:16:44 +0100 Subject: [PATCH 08/11] refactor(lsp): replace FolderConfig with LspFolderConfig using direct map access [IDE-1639] Remove custom FolderConfig data class and LspFolderConfigExtensions.kt. Store LspFolderConfig directly in FolderConfigSettings, eliminating conversion boilerplate between FolderConfig and LspFolderConfig. All UI consumers and tests now access settings via the LspFolderConfig settings map using LsFolderSettingsKeys constants. The withSetting extension function is preserved in LanguageServerSettings.kt for immutable config updates. --- .../SnykProjectSettingsConfigurable.kt | 20 +- .../snyk/plugin/ui/ReferenceChooserDialog.kt | 34 +- .../io/snyk/plugin/ui/SnykSettingsDialog.kt | 19 +- .../snyk/plugin/ui/jcef/SaveConfigHandler.kt | 104 +++--- .../ui/toolwindow/SnykToolWindowPanel.kt | 15 +- .../snyk/common/lsp/LanguageServerWrapper.kt | 131 +------- .../snyk/common/lsp/SnykLanguageClient.kt | 51 +-- src/main/kotlin/snyk/common/lsp/Types.kt | 59 ---- .../lsp/settings/FolderConfigSettings.kt | 155 +++++---- .../lsp/settings/LanguageServerSettings.kt | 21 +- .../SnykProjectSettingsConfigurableTest.kt | 53 +++- .../plugin/ui/ReferenceChooserDialogTest.kt | 23 +- .../SnykToolWindowSnykScanListenerTest.kt | 4 +- .../lsp/settings/FolderConfigSettingsTest.kt | 297 +++++++++++------- .../lsp/settings/TestFolderConfigHelper.kt | 37 +++ 15 files changed, 518 insertions(+), 505 deletions(-) create mode 100644 src/test/kotlin/snyk/common/lsp/settings/TestFolderConfigHelper.kt diff --git a/src/main/kotlin/io/snyk/plugin/settings/SnykProjectSettingsConfigurable.kt b/src/main/kotlin/io/snyk/plugin/settings/SnykProjectSettingsConfigurable.kt index 16b1b086f..20a3bb28c 100644 --- a/src/main/kotlin/io/snyk/plugin/settings/SnykProjectSettingsConfigurable.kt +++ b/src/main/kotlin/io/snyk/plugin/settings/SnykProjectSettingsConfigurable.kt @@ -27,6 +27,8 @@ import io.snyk.plugin.ui.settings.HTMLSettingsPanel import javax.swing.JComponent import snyk.common.lsp.LanguageServerWrapper import snyk.common.lsp.settings.FolderConfigSettings +import snyk.common.lsp.settings.LsFolderSettingsKeys +import snyk.common.lsp.settings.withSetting class SnykProjectSettingsConfigurable(val project: Project) : SearchableConfigurable { private val settingsStateService @@ -233,12 +235,18 @@ fun applyFolderConfigChanges( val existingConfig = fcs.getFolderConfig(folderPath) val updatedConfig = - existingConfig.copy( - additionalParameters = ParametersListUtil.parse(additionalParameters), - // Clear the preferredOrg field if the auto org selection is enabled. - preferredOrg = if (autoSelectOrgEnabled) "" else preferredOrgText.trim(), - orgSetByUser = !autoSelectOrgEnabled, - ) + existingConfig + .withSetting( + LsFolderSettingsKeys.ADDITIONAL_PARAMETERS, + ParametersListUtil.parse(additionalParameters), + changed = true, + ) + .withSetting( + LsFolderSettingsKeys.PREFERRED_ORG, + if (autoSelectOrgEnabled) "" else preferredOrgText.trim(), + changed = true, + ) + .withSetting(LsFolderSettingsKeys.ORG_SET_BY_USER, !autoSelectOrgEnabled, changed = true) fcs.addFolderConfig(updatedConfig) } diff --git a/src/main/kotlin/io/snyk/plugin/ui/ReferenceChooserDialog.kt b/src/main/kotlin/io/snyk/plugin/ui/ReferenceChooserDialog.kt index 0d1697a6f..bc9283af8 100644 --- a/src/main/kotlin/io/snyk/plugin/ui/ReferenceChooserDialog.kt +++ b/src/main/kotlin/io/snyk/plugin/ui/ReferenceChooserDialog.kt @@ -16,13 +16,15 @@ import java.awt.GridBagLayout import javax.swing.JComponent import javax.swing.JLabel import javax.swing.JPanel -import snyk.common.lsp.FolderConfig import snyk.common.lsp.LanguageServerWrapper import snyk.common.lsp.settings.FolderConfigSettings +import snyk.common.lsp.settings.LsFolderSettingsKeys +import snyk.common.lsp.settings.LspFolderConfig +import snyk.common.lsp.settings.withSetting class ReferenceChooserDialog(val project: Project) : DialogWrapper(true) { - var baseBranches: MutableMap> = mutableMapOf() - internal var referenceFolders: MutableMap = + var baseBranches: MutableMap> = mutableMapOf() + internal var referenceFolders: MutableMap = mutableMapOf() internal var hasChanges = false @@ -46,11 +48,13 @@ class ReferenceChooserDialog(val project: Project) : DialogWrapper(true) { folderConfigs.forEach { folderConfig -> // Only create combo box if there are local branches - folderConfig.localBranches + (folderConfig.settings?.get(LsFolderSettingsKeys.LOCAL_BRANCHES)?.value as? List<*>) + ?.filterIsInstance() ?.takeIf { it.isNotEmpty() } ?.let { localBranches -> val comboBox = ComboBox(localBranches.sorted().toTypedArray()) - comboBox.selectedItem = folderConfig.baseBranch + comboBox.selectedItem = + folderConfig.settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value as? String ?: "" comboBox.name = folderConfig.folderPath // Add change listener to track modifications @@ -81,9 +85,10 @@ class ReferenceChooserDialog(val project: Project) : DialogWrapper(true) { return scrollPane } - private fun configureReferenceFolder(folderConfig: FolderConfig): TextFieldWithBrowseButton { + private fun configureReferenceFolder(folderConfig: LspFolderConfig): TextFieldWithBrowseButton { val referenceFolder = TextFieldWithBrowseButton() - referenceFolder.text = folderConfig.referenceFolderPath ?: "" + referenceFolder.text = + folderConfig.settings?.get(LsFolderSettingsKeys.REFERENCE_FOLDER)?.value as? String ?: "" referenceFolder.name = folderConfig.folderPath referenceFolder.toolTipText = @@ -141,10 +146,13 @@ class ReferenceChooserDialog(val project: Project) : DialogWrapper(true) { // Update if either base branch or reference folder is provided if (baseBranch.isNotBlank() || referenceFolderControl?.text?.isNotBlank() == true) { folderConfigSettings.addFolderConfig( - folderConfig.copy( - baseBranch = baseBranch, - referenceFolderPath = referenceFolderControl?.text ?: "", - ) + folderConfig + .withSetting(LsFolderSettingsKeys.BASE_BRANCH, baseBranch, changed = true) + .withSetting( + LsFolderSettingsKeys.REFERENCE_FOLDER, + referenceFolderControl?.text ?: "", + changed = true, + ) ) } } @@ -156,7 +164,9 @@ class ReferenceChooserDialog(val project: Project) : DialogWrapper(true) { referenceFolders[folderConfig]?.text?.let { referenceFolder -> if (referenceFolder.isNotBlank()) { folderConfigSettings.addFolderConfig( - folderConfig.copy(baseBranch = "", referenceFolderPath = referenceFolder) + folderConfig + .withSetting(LsFolderSettingsKeys.BASE_BRANCH, "", changed = true) + .withSetting(LsFolderSettingsKeys.REFERENCE_FOLDER, referenceFolder, changed = true) ) } } diff --git a/src/main/kotlin/io/snyk/plugin/ui/SnykSettingsDialog.kt b/src/main/kotlin/io/snyk/plugin/ui/SnykSettingsDialog.kt index 732586a16..5ea86cb90 100644 --- a/src/main/kotlin/io/snyk/plugin/ui/SnykSettingsDialog.kt +++ b/src/main/kotlin/io/snyk/plugin/ui/SnykSettingsDialog.kt @@ -71,9 +71,10 @@ import javax.swing.event.DocumentEvent import javax.swing.text.BadLocationException import org.jetbrains.concurrency.runAsync import snyk.SnykBundle -import snyk.common.lsp.FolderConfig import snyk.common.lsp.LanguageServerWrapper import snyk.common.lsp.settings.FolderConfigSettings +import snyk.common.lsp.settings.LsFolderSettingsKeys +import snyk.common.lsp.settings.LspFolderConfig class SnykSettingsDialog( private val project: Project, @@ -211,7 +212,10 @@ class SnykSettingsDialog( cliReleaseChannelDropDown.selectedItem = applicationSettings.cliReleaseChannel baseBranchInfoLabel.text = service().getAll().values.joinToString("\n") { - "${it.folderPath}: Reference branch: ${it.baseBranch}, Reference directory: ${it.referenceFolderPath}" + val branch = it.settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value as? String ?: "" + val refDir = + it.settings?.get(LsFolderSettingsKeys.REFERENCE_FOLDER)?.value as? String ?: "" + "${it.folderPath}: Reference branch: $branch, Reference directory: $refDir" } netNewIssuesDropDown.selectedItem = applicationSettings.issuesToDisplay } @@ -258,7 +262,9 @@ class SnykSettingsDialog( cliReleaseChannelDropDown.selectedItem = settings.cliReleaseChannel baseBranchInfoLabel.text = service().getAll().values.joinToString("\n") { - "${it.folderPath}: Reference branch: ${it.baseBranch}, Reference directory: ${it.referenceFolderPath}" + val branch = it.settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value as? String ?: "" + val refDir = it.settings?.get(LsFolderSettingsKeys.REFERENCE_FOLDER)?.value as? String ?: "" + "${it.folderPath}: Reference branch: $branch, Reference directory: $refDir" } netNewIssuesDropDown.selectedItem = settings.issuesToDisplay @@ -955,7 +961,7 @@ class SnykSettingsDialog( fun isAutoSelectOrgEnabled(): Boolean = autoDetectOrgCheckbox.isSelected - private fun getFolderConfig(): FolderConfig? { + private fun getFolderConfig(): LspFolderConfig? { val folderConfigSettings = service() val languageServerWrapper = LanguageServerWrapper.getInstance(project) return languageServerWrapper @@ -973,10 +979,11 @@ class SnykSettingsDialog( val organization = if (autoDetectOrgSelected) { // Checkbox checked = auto-detect enabled = use autoDeterminedOrg only - folderConfig?.autoDeterminedOrg ?: "" + folderConfig?.settings?.get(LsFolderSettingsKeys.AUTO_DETERMINED_ORG)?.value as? String + ?: "" } else { // Checkbox unchecked = manual selection = clear textbox for user input - folderConfig?.preferredOrg ?: "" + folderConfig?.settings?.get(LsFolderSettingsKeys.PREFERRED_ORG)?.value as? String ?: "" } preferredOrgTextField.text = organization diff --git a/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt b/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt index d76e2071a..17c20474f 100644 --- a/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt +++ b/src/main/kotlin/io/snyk/plugin/ui/jcef/SaveConfigHandler.kt @@ -23,6 +23,7 @@ import snyk.common.lsp.LanguageServerWrapper import snyk.common.lsp.settings.FolderConfigSettings import snyk.common.lsp.settings.LsFolderSettingsKeys import snyk.common.lsp.settings.LsSettingsKeys +import snyk.common.lsp.settings.withSetting import snyk.trust.WorkspaceTrustService class SaveConfigHandler( @@ -363,55 +364,72 @@ class SaveConfigHandler( val fcs = service() for (folderConfig in folderConfigs) { - val existingConfig = fcs.getFolderConfig(folderConfig.folderPath) - - // Build updated config, writing values directly from config (use defaults if null) - val updatedConfig = - existingConfig.copy( - additionalParameters = folderConfig.additionalParameters, - additionalEnv = folderConfig.additionalEnv, - preferredOrg = folderConfig.preferredOrg ?: "", - autoDeterminedOrg = folderConfig.autoDeterminedOrg ?: "", - orgSetByUser = folderConfig.orgSetByUser ?: false, - scanCommandConfig = - folderConfig.scanCommandConfig?.let { parseScanCommandConfig(it) } - ?: existingConfig.scanCommandConfig, - ) - updatedConfig.migrateExplicitChanges() - - // Track explicit changes made by the user in the UI form - if ( - folderConfig.additionalParameters != null && - folderConfig.additionalParameters != existingConfig.additionalParameters - ) { - updatedConfig.markExplicitlyChanged(LsFolderSettingsKeys.ADDITIONAL_PARAMETERS) + var updated = fcs.getFolderConfig(folderConfig.folderPath) + + // Apply each field from the UI form, marking as changed + folderConfig.additionalParameters?.let { + updated = + updated.withSetting(LsFolderSettingsKeys.ADDITIONAL_PARAMETERS, it, changed = true) } - if ( - folderConfig.additionalEnv != null && - folderConfig.additionalEnv != existingConfig.additionalEnv - ) { - updatedConfig.markExplicitlyChanged(LsFolderSettingsKeys.ADDITIONAL_ENVIRONMENT) + folderConfig.additionalEnv?.let { + updated = + updated.withSetting(LsFolderSettingsKeys.ADDITIONAL_ENVIRONMENT, it, changed = true) } - if ( - folderConfig.preferredOrg != null && - folderConfig.preferredOrg != existingConfig.preferredOrg - ) { - updatedConfig.markExplicitlyChanged(LsFolderSettingsKeys.PREFERRED_ORG) + folderConfig.preferredOrg?.let { + updated = updated.withSetting(LsFolderSettingsKeys.PREFERRED_ORG, it, changed = true) } - if ( - folderConfig.orgSetByUser != null && - folderConfig.orgSetByUser != existingConfig.orgSetByUser - ) { - updatedConfig.markExplicitlyChanged(LsFolderSettingsKeys.ORG_SET_BY_USER) + folderConfig.autoDeterminedOrg?.let { + updated = updated.withSetting(LsFolderSettingsKeys.AUTO_DETERMINED_ORG, it) } - if (folderConfig.scanCommandConfig != null) { - val parsedScanCommandConfig = parseScanCommandConfig(folderConfig.scanCommandConfig) - if (parsedScanCommandConfig != existingConfig.scanCommandConfig) { - updatedConfig.markExplicitlyChanged(LsFolderSettingsKeys.SCAN_COMMAND_CONFIG) - } + folderConfig.orgSetByUser?.let { + updated = updated.withSetting(LsFolderSettingsKeys.ORG_SET_BY_USER, it, changed = true) + } + folderConfig.scanCommandConfig?.let { + updated = + updated.withSetting( + LsFolderSettingsKeys.SCAN_COMMAND_CONFIG, + parseScanCommandConfig(it), + changed = true, + ) + } + + // Org-scope overrides from processFolderOverrides() in JS + folderConfig.scanAutomatic?.let { + updated = updated.withSetting(LsSettingsKeys.SCAN_AUTOMATIC, it, changed = true) + } + folderConfig.scanNetNew?.let { + updated = updated.withSetting(LsSettingsKeys.SCAN_NET_NEW, it, changed = true) + } + folderConfig.enabledSeverities?.let { + val sev = + snyk.common.lsp.settings.SeverityFilter( + critical = it.critical, + high = it.high, + medium = it.medium, + low = it.low, + ) + updated = updated.withSetting(LsSettingsKeys.ENABLED_SEVERITIES, sev, changed = true) + } + folderConfig.snykOssEnabled?.let { + updated = updated.withSetting(LsSettingsKeys.SNYK_OSS_ENABLED, it, changed = true) + } + folderConfig.snykCodeEnabled?.let { + updated = updated.withSetting(LsSettingsKeys.SNYK_CODE_ENABLED, it, changed = true) + } + folderConfig.snykIacEnabled?.let { + updated = updated.withSetting(LsSettingsKeys.SNYK_IAC_ENABLED, it, changed = true) + } + folderConfig.issueViewOpenIssues?.let { + updated = updated.withSetting(LsSettingsKeys.ISSUE_VIEW_OPEN_ISSUES, it, changed = true) + } + folderConfig.issueViewIgnoredIssues?.let { + updated = updated.withSetting(LsSettingsKeys.ISSUE_VIEW_IGNORED_ISSUES, it, changed = true) + } + folderConfig.riskScoreThreshold?.let { + updated = updated.withSetting(LsSettingsKeys.RISK_SCORE_THRESHOLD, it, changed = true) } - fcs.addFolderConfig(updatedConfig) + fcs.addFolderConfig(updated) } } diff --git a/src/main/kotlin/io/snyk/plugin/ui/toolwindow/SnykToolWindowPanel.kt b/src/main/kotlin/io/snyk/plugin/ui/toolwindow/SnykToolWindowPanel.kt index cb0954d4e..3debfe2d1 100644 --- a/src/main/kotlin/io/snyk/plugin/ui/toolwindow/SnykToolWindowPanel.kt +++ b/src/main/kotlin/io/snyk/plugin/ui/toolwindow/SnykToolWindowPanel.kt @@ -72,12 +72,13 @@ import org.jetbrains.concurrency.runAsync import snyk.common.ProductType import snyk.common.SnykError import snyk.common.lsp.AiFixParams -import snyk.common.lsp.FolderConfig import snyk.common.lsp.LanguageServerWrapper import snyk.common.lsp.LsProduct import snyk.common.lsp.ScanIssue import snyk.common.lsp.SnykScanParams import snyk.common.lsp.settings.FolderConfigSettings +import snyk.common.lsp.settings.LsFolderSettingsKeys +import snyk.common.lsp.settings.LspFolderConfig /** Main panel for Snyk tool window. */ @Service(Service.Level.PROJECT) @@ -100,12 +101,16 @@ class SnykToolWindowPanel(val project: Project) : JPanel(), Disposable { Tree(rootTreeNode).apply { this.isRootVisible = pluginSettings().isDeltaFindingsEnabled() } } - private fun getRootNodeText(folderConfig: FolderConfig): String { + private fun getRootNodeText(folderConfig: LspFolderConfig): String { + val refPath = + folderConfig.settings?.get(LsFolderSettingsKeys.REFERENCE_FOLDER)?.value as? String ?: "" + val branch = + folderConfig.settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value as? String ?: "" val detail = - if (folderConfig.referenceFolderPath.isNullOrBlank()) { - folderConfig.baseBranch + if (refPath.isBlank()) { + branch } else { - folderConfig.referenceFolderPath + refPath } val path = folderConfig.folderPath.toNioPathOrNull() return "Click to choose base branch or reference folder for ${path?.fileName ?: path.toString()}: [ current: $detail ]" diff --git a/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt b/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt index a9cfe07dd..539046cd7 100644 --- a/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt +++ b/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt @@ -81,10 +81,8 @@ import snyk.common.lsp.progress.ProgressManager import snyk.common.lsp.settings.ConfigSetting import snyk.common.lsp.settings.FolderConfigSettings import snyk.common.lsp.settings.InitializationOptions -import snyk.common.lsp.settings.LsFolderSettingsKeys import snyk.common.lsp.settings.LsSettingsKeys import snyk.common.lsp.settings.LspConfigurationParam -import snyk.common.lsp.settings.LspFolderConfig import snyk.common.removeSuffix import snyk.pluginInfo import snyk.trust.WorkspaceTrustService @@ -701,134 +699,7 @@ class LanguageServerWrapper(private val project: Project) : Disposable { } .map { val folderPath = it.uri.fromUriToPath().toString() - val fc = service().getFolderConfig(folderPath) - - val fcSettingsMap = mutableMapOf() - fcSettingsMap[LsFolderSettingsKeys.BASE_BRANCH] = - ConfigSetting( - value = fc.baseBranch, - changed = fc.isExplicitlyChanged(LsFolderSettingsKeys.BASE_BRANCH), - ) - if (fc.additionalEnv != null) { - fcSettingsMap[LsFolderSettingsKeys.ADDITIONAL_ENVIRONMENT] = - ConfigSetting( - value = fc.additionalEnv!!, - changed = fc.isExplicitlyChanged(LsFolderSettingsKeys.ADDITIONAL_ENVIRONMENT), - ) - } - if (fc.additionalParameters != null) { - fcSettingsMap[LsFolderSettingsKeys.ADDITIONAL_PARAMETERS] = - ConfigSetting( - value = fc.additionalParameters!!, - changed = fc.isExplicitlyChanged(LsFolderSettingsKeys.ADDITIONAL_PARAMETERS), - ) - } - if (fc.localBranches != null) { - fcSettingsMap[LsFolderSettingsKeys.LOCAL_BRANCHES] = - ConfigSetting( - value = fc.localBranches!!, - changed = fc.isExplicitlyChanged(LsFolderSettingsKeys.LOCAL_BRANCHES), - ) - } - if (fc.referenceFolderPath != null) { - fcSettingsMap[LsFolderSettingsKeys.REFERENCE_FOLDER] = - ConfigSetting( - value = fc.referenceFolderPath!!, - changed = fc.isExplicitlyChanged(LsFolderSettingsKeys.REFERENCE_FOLDER), - ) - } - fcSettingsMap[LsFolderSettingsKeys.PREFERRED_ORG] = - ConfigSetting( - value = fc.preferredOrg, - changed = fc.isExplicitlyChanged(LsFolderSettingsKeys.PREFERRED_ORG), - ) - fcSettingsMap[LsFolderSettingsKeys.AUTO_DETERMINED_ORG] = - ConfigSetting(value = fc.autoDeterminedOrg, changed = false) - fcSettingsMap[LsFolderSettingsKeys.ORG_SET_BY_USER] = - ConfigSetting( - value = fc.orgSetByUser, - changed = fc.isExplicitlyChanged(LsFolderSettingsKeys.ORG_SET_BY_USER), - ) - if (fc.scanCommandConfig != null) { - fcSettingsMap[LsFolderSettingsKeys.SCAN_COMMAND_CONFIG] = - ConfigSetting( - value = fc.scanCommandConfig!!, - changed = fc.isExplicitlyChanged(LsFolderSettingsKeys.SCAN_COMMAND_CONFIG), - ) - } - - // Org-scope overrides - if (fc.scanAutomatic != null) { - fcSettingsMap[LsSettingsKeys.SCAN_AUTOMATIC] = - ConfigSetting( - value = fc.scanAutomatic, - changed = fc.isExplicitlyChanged(LsSettingsKeys.SCAN_AUTOMATIC), - ) - } - if (fc.scanNetNew != null) { - fcSettingsMap[LsSettingsKeys.SCAN_NET_NEW] = - ConfigSetting( - value = fc.scanNetNew, - changed = fc.isExplicitlyChanged(LsSettingsKeys.SCAN_NET_NEW), - ) - } - if (fc.enabledSeverities != null) { - val severityFilter = - mapOf( - "critical" to fc.enabledSeverities.critical, - "high" to fc.enabledSeverities.high, - "medium" to fc.enabledSeverities.medium, - "low" to fc.enabledSeverities.low, - ) - fcSettingsMap[LsSettingsKeys.ENABLED_SEVERITIES] = - ConfigSetting( - value = severityFilter, - changed = fc.isExplicitlyChanged(LsSettingsKeys.ENABLED_SEVERITIES), - ) - } - if (fc.snykOssEnabled != null) { - fcSettingsMap[LsSettingsKeys.SNYK_OSS_ENABLED] = - ConfigSetting( - value = fc.snykOssEnabled, - changed = fc.isExplicitlyChanged(LsSettingsKeys.SNYK_OSS_ENABLED), - ) - } - if (fc.snykCodeEnabled != null) { - fcSettingsMap[LsSettingsKeys.SNYK_CODE_ENABLED] = - ConfigSetting( - value = fc.snykCodeEnabled, - changed = fc.isExplicitlyChanged(LsSettingsKeys.SNYK_CODE_ENABLED), - ) - } - if (fc.snykIacEnabled != null) { - fcSettingsMap[LsSettingsKeys.SNYK_IAC_ENABLED] = - ConfigSetting( - value = fc.snykIacEnabled, - changed = fc.isExplicitlyChanged(LsSettingsKeys.SNYK_IAC_ENABLED), - ) - } - if (fc.issueViewOpenIssues != null) { - fcSettingsMap[LsSettingsKeys.ISSUE_VIEW_OPEN_ISSUES] = - ConfigSetting( - value = fc.issueViewOpenIssues, - changed = fc.isExplicitlyChanged(LsSettingsKeys.ISSUE_VIEW_OPEN_ISSUES), - ) - } - if (fc.issueViewIgnoredIssues != null) { - fcSettingsMap[LsSettingsKeys.ISSUE_VIEW_IGNORED_ISSUES] = - ConfigSetting( - value = fc.issueViewIgnoredIssues, - changed = fc.isExplicitlyChanged(LsSettingsKeys.ISSUE_VIEW_IGNORED_ISSUES), - ) - } - if (fc.riskScoreThreshold != null) { - fcSettingsMap[LsSettingsKeys.RISK_SCORE_THRESHOLD] = - ConfigSetting( - value = fc.riskScoreThreshold, - changed = fc.isExplicitlyChanged(LsSettingsKeys.RISK_SCORE_THRESHOLD), - ) - } - LspFolderConfig(folderPath = folderPath, settings = fcSettingsMap) + service().getFolderConfig(folderPath) } .toList() diff --git a/src/main/kotlin/snyk/common/lsp/SnykLanguageClient.kt b/src/main/kotlin/snyk/common/lsp/SnykLanguageClient.kt index 4bd64fe33..ff801e7eb 100644 --- a/src/main/kotlin/snyk/common/lsp/SnykLanguageClient.kt +++ b/src/main/kotlin/snyk/common/lsp/SnykLanguageClient.kt @@ -63,7 +63,6 @@ import snyk.common.ProductType import snyk.common.editor.DocumentChanger import snyk.common.lsp.progress.ProgressManager import snyk.common.lsp.settings.FolderConfigSettings -import snyk.common.lsp.settings.LsFolderSettingsKeys import snyk.common.lsp.settings.LsSettingsKeys import snyk.common.lsp.settings.LspConfigurationParam import snyk.sdk.SdkHelper @@ -392,52 +391,8 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog val service = service() val languageServerWrapper = LanguageServerWrapper.getInstance(project) - // convert to FolderConfig - val convertedConfigs = - folderConfigs.map { lspFolderConfig -> - val settings = lspFolderConfig.settings ?: emptyMap() - val baseBranch = settings[LsFolderSettingsKeys.BASE_BRANCH]?.value as? String ?: "" - val additionalEnv = - settings[LsFolderSettingsKeys.ADDITIONAL_ENVIRONMENT]?.value as? String - - // local_branches and additional_parameters are sent as arrays/lists of strings - val localBranches = - (settings[LsFolderSettingsKeys.LOCAL_BRANCHES]?.value as? List<*>) - ?.filterIsInstance() ?: emptyList() - val additionalParameters = - (settings[LsFolderSettingsKeys.ADDITIONAL_PARAMETERS]?.value as? List<*>) - ?.filterIsInstance() ?: emptyList() - - val referenceFolderPath = - settings[LsFolderSettingsKeys.REFERENCE_FOLDER]?.value as? String - val preferredOrg = - settings[LsFolderSettingsKeys.PREFERRED_ORG]?.value as? String ?: "" - val autoDeterminedOrg = - settings[LsFolderSettingsKeys.AUTO_DETERMINED_ORG]?.value as? String ?: "" - val orgSetByUser = - settings[LsFolderSettingsKeys.ORG_SET_BY_USER]?.value as? Boolean ?: false - - @Suppress("UNCHECKED_CAST") - val scanCommandConfig = - settings[LsFolderSettingsKeys.SCAN_COMMAND_CONFIG]?.value - as? Map - - FolderConfig( - folderPath = lspFolderConfig.folderPath, - baseBranch = baseBranch, - additionalEnv = additionalEnv, - localBranches = localBranches, - additionalParameters = additionalParameters, - referenceFolderPath = referenceFolderPath, - preferredOrg = preferredOrg, - autoDeterminedOrg = autoDeterminedOrg, - orgSetByUser = orgSetByUser, - scanCommandConfig = scanCommandConfig, - ) - } - - service.addAll(convertedConfigs) - convertedConfigs.forEach { + service.addAll(folderConfigs) + folderConfigs.forEach { languageServerWrapper.updateFolderConfigRefresh(it.folderPath, true) } @@ -448,7 +403,7 @@ class SnykLanguageClient(private val project: Project, val progressManager: Prog try { // Already in runAsync, so just use sync publisher here getSyncPublisher(project, SnykFolderConfigListener.SNYK_FOLDER_CONFIG_TOPIC) - ?.folderConfigsChanged(convertedConfigs.isNotEmpty()) + ?.folderConfigsChanged(folderConfigs.isNotEmpty()) } catch (e: Exception) { logger.error("Error processing snyk folder configs", e) } diff --git a/src/main/kotlin/snyk/common/lsp/Types.kt b/src/main/kotlin/snyk/common/lsp/Types.kt index 9815f6baa..6acb51aa2 100644 --- a/src/main/kotlin/snyk/common/lsp/Types.kt +++ b/src/main/kotlin/snyk/common/lsp/Types.kt @@ -514,65 +514,6 @@ data class OssIdentifiers( } } -/** - * FolderConfig stores the configuration for a workspace folder - * - * @param folderPath the path of the folder - * @param baseBranch the base branch to compare against (if git repository) - * @param localBranches the local branches in the git repository - * @param additionalParameters additional parameters to pass to the scan command - * @param additionalEnv additional environment variables to set for the scan command - * @param referenceFolderPath the reference folder to scan, if not a git repository - * @param scanCommandConfig the scan command configuration to specify a command to be executed - * before and/or after the scan - */ -data class FolderConfig( - @SerializedName("folderPath") val folderPath: String, - @SerializedName("preferredOrg") val preferredOrg: String = "", - @SerializedName("autoDeterminedOrg") val autoDeterminedOrg: String = "", - @SerializedName("baseBranch") val baseBranch: String, - @SerializedName("localBranches") val localBranches: List? = emptyList(), - @SerializedName("additionalParameters") val additionalParameters: List? = emptyList(), - @SerializedName("additionalEnv") val additionalEnv: String? = "", - @SerializedName("referenceFolderPath") val referenceFolderPath: String? = "", - @SerializedName("scanCommandConfig") - val scanCommandConfig: Map? = emptyMap(), - @SerializedName("orgSetByUser") val orgSetByUser: Boolean = false, - @Transient val explicitChanges: MutableSet = mutableSetOf(), - // Org-scope overrides - @SerializedName("scanAutomatic") val scanAutomatic: Boolean? = null, - @SerializedName("scanNetNew") val scanNetNew: Boolean? = null, - @SerializedName("enabledSeverities") - val enabledSeverities: snyk.common.lsp.settings.SeverityFilter? = null, - @SerializedName("snykOssEnabled") val snykOssEnabled: Boolean? = null, - @SerializedName("snykCodeEnabled") val snykCodeEnabled: Boolean? = null, - @SerializedName("snykIacEnabled") val snykIacEnabled: Boolean? = null, - @SerializedName("issueViewOpenIssues") val issueViewOpenIssues: Boolean? = null, - @SerializedName("issueViewIgnoredIssues") val issueViewIgnoredIssues: Boolean? = null, - @SerializedName("riskScoreThreshold") val riskScoreThreshold: Int? = null, -) : Comparable { - override fun compareTo(other: FolderConfig): Int = this.folderPath.compareTo(other.folderPath) - - fun markExplicitlyChanged(settingKey: String) { - explicitChanges.add(settingKey) - } - - fun isExplicitlyChanged(settingKey: String): Boolean = explicitChanges.contains(settingKey) - - fun migrateExplicitChanges() { - if (explicitChanges.isEmpty()) { - if (baseBranch.isNotEmpty()) markExplicitlyChanged("base_branch") - if (!additionalEnv.isNullOrEmpty()) markExplicitlyChanged("additional_environment") - if (!additionalParameters.isNullOrEmpty()) markExplicitlyChanged("additional_parameters") - if (!localBranches.isNullOrEmpty()) markExplicitlyChanged("local_branches") - if (!referenceFolderPath.isNullOrEmpty()) markExplicitlyChanged("reference_folder") - if (preferredOrg.isNotEmpty()) markExplicitlyChanged("preferred_org") - if (orgSetByUser) markExplicitlyChanged("org_set_by_user") - if (!scanCommandConfig.isNullOrEmpty()) markExplicitlyChanged("scan_command_config") - } - } -} - data class ScanCommandConfig( val preScanCommand: String = "", val preScanOnlyReferenceFolder: Boolean = true, diff --git a/src/main/kotlin/snyk/common/lsp/settings/FolderConfigSettings.kt b/src/main/kotlin/snyk/common/lsp/settings/FolderConfigSettings.kt index f3d959ce7..c02400fda 100644 --- a/src/main/kotlin/snyk/common/lsp/settings/FolderConfigSettings.kt +++ b/src/main/kotlin/snyk/common/lsp/settings/FolderConfigSettings.kt @@ -8,17 +8,16 @@ import com.intellij.openapi.ui.Messages import io.snyk.plugin.fromUriToPath import java.nio.file.Paths import java.util.concurrent.ConcurrentHashMap -import java.util.stream.Collectors import org.jetbrains.annotations.NotNull import snyk.SnykBundle -import snyk.common.lsp.FolderConfig import snyk.common.lsp.LanguageServerWrapper @Suppress("UselessCallOnCollection") @Service class FolderConfigSettings { private val logger = Logger.getInstance(FolderConfigSettings::class.java) - private val configs: MutableMap = ConcurrentHashMap() + private val configs: MutableMap = + ConcurrentHashMap() @Suppress( "UselessCallOnNotNull", @@ -26,22 +25,10 @@ class FolderConfigSettings { "UNNECESSARY_SAFE_CALL", "RedundantSuppression", ) - fun addFolderConfig(@NotNull folderConfig: FolderConfig) { + fun addFolderConfig(@NotNull folderConfig: LspFolderConfig) { if (folderConfig.folderPath.isNullOrBlank()) return val normalizedAbsolutePath = normalizePath(folderConfig.folderPath) - - // Handle null values from Language Server by providing defaults - val configToStore = - folderConfig.copy( - folderPath = normalizedAbsolutePath, - preferredOrg = folderConfig.preferredOrg ?: "", - autoDeterminedOrg = folderConfig.autoDeterminedOrg ?: "", - baseBranch = folderConfig.baseBranch ?: "", - referenceFolderPath = folderConfig.referenceFolderPath ?: "", - additionalParameters = folderConfig.additionalParameters ?: emptyList(), - additionalEnv = folderConfig.additionalEnv ?: "", - ) - configs[normalizedAbsolutePath] = configToStore + configs[normalizedAbsolutePath] = folderConfig.copy(folderPath = normalizedAbsolutePath) } private fun normalizePath(folderPath: String): String { @@ -49,25 +36,41 @@ class FolderConfigSettings { return normalizedAbsolutePath } - internal fun getFolderConfig(folderPath: String): FolderConfig { + internal fun getFolderConfig(folderPath: String): LspFolderConfig { val normalizedPath = normalizePath(folderPath) val folderConfig = configs[normalizedPath] ?: createEmpty(normalizedPath) return folderConfig } - private fun createEmpty(normalizedAbsolutePath: String): FolderConfig { - val newConfig = FolderConfig(folderPath = normalizedAbsolutePath, baseBranch = "main") - // Directly add to map, as addFolderConfig would re-normalize and copy, which is redundant here - // since normalizedAbsolutePath is already what we want for the key and the object's path. + private fun createEmpty(normalizedAbsolutePath: String): LspFolderConfig { + val newConfig = + LspFolderConfig( + folderPath = normalizedAbsolutePath, + settings = + mapOf( + LsFolderSettingsKeys.BASE_BRANCH to ConfigSetting(value = "main"), + LsFolderSettingsKeys.LOCAL_BRANCHES to ConfigSetting(value = emptyList()), + LsFolderSettingsKeys.ADDITIONAL_PARAMETERS to + ConfigSetting(value = emptyList()), + LsFolderSettingsKeys.ADDITIONAL_ENVIRONMENT to ConfigSetting(value = ""), + LsFolderSettingsKeys.REFERENCE_FOLDER to ConfigSetting(value = ""), + LsFolderSettingsKeys.PREFERRED_ORG to ConfigSetting(value = ""), + LsFolderSettingsKeys.AUTO_DETERMINED_ORG to ConfigSetting(value = ""), + LsFolderSettingsKeys.ORG_SET_BY_USER to ConfigSetting(value = false), + LsFolderSettingsKeys.SCAN_COMMAND_CONFIG to + ConfigSetting(value = emptyMap()), + ), + ) configs[normalizedAbsolutePath] = newConfig return newConfig } - fun getAll(): Map = HashMap(configs) + fun getAll(): Map = HashMap(configs) fun clear() = configs.clear() - fun addAll(folderConfigs: List) = folderConfigs.mapNotNull { addFolderConfig(it) } + fun addAll(folderConfigs: List) = + folderConfigs.mapNotNull { addFolderConfig(it) } /** * Gets all folder configs for a project. This method delegates to getFolderConfigs() to ensure @@ -76,8 +79,8 @@ class FolderConfigSettings { * @param project the project to get the folder configs for * @return the folder configs for workspace folders only (no nested paths) */ - fun getAllForProject(project: Project): List = - getFolderConfigs(project).stream().sorted().collect(Collectors.toList()).toList() + fun getAllForProject(project: Project): List = + getFolderConfigs(project).sortedBy { it.folderPath }.toList() /** * Gets the additional parameters for the given project by aggregating the folder configs with @@ -90,9 +93,12 @@ class FolderConfigSettings { // only use folder config with workspace folder path val additionalParameters = getFolderConfigs(project) - .filter { it.additionalParameters?.isNotEmpty() ?: false } - .mapNotNull { it.additionalParameters?.joinToString(" ") } - .joinToString(" ") + .map { config -> + (config.settings?.get(LsFolderSettingsKeys.ADDITIONAL_PARAMETERS)?.value as? List<*>) + ?.filterIsInstance() ?: emptyList() + } + .filter { it.isNotEmpty() } + .joinToString(" ") { it.joinToString(" ") } return additionalParameters } @@ -103,7 +109,7 @@ class FolderConfigSettings { * @param project the project to get the folder configs for * @return the folder configs for the project */ - fun getFolderConfigs(project: Project): List { + fun getFolderConfigs(project: Project): List { val languageServerWrapper = LanguageServerWrapper.getInstance(project) return languageServerWrapper .getWorkspaceFoldersFromRoots(project, promptForTrust = false) @@ -123,7 +129,11 @@ class FolderConfigSettings { fun getPreferredOrg(project: Project): String { // Note - this will not work for projects with extra content roots outside of the the main // workspace folder. - return getFolderConfigs(project).map { it.preferredOrg }.firstOrNull() ?: "" + return getFolderConfigs(project) + .firstOrNull() + ?.settings + ?.get(LsFolderSettingsKeys.PREFERRED_ORG) + ?.value as? String ?: "" } /** @@ -134,7 +144,9 @@ class FolderConfigSettings { * @return true if auto-organization is enabled */ fun isAutoOrganizationEnabled(project: Project): Boolean = - getFolderConfigs(project).firstOrNull()?.orgSetByUser != true + getFolderConfigs(project).firstOrNull()?.let { + !(it.settings?.get(LsFolderSettingsKeys.ORG_SET_BY_USER)?.value as? Boolean ?: false) + } ?: true /** * Sets the auto-organization setting for the given project. @@ -144,7 +156,12 @@ class FolderConfigSettings { */ fun setAutoOrganization(project: Project, autoOrganization: Boolean) { getFolderConfigs(project).forEach { folderConfig -> - val updatedConfig = folderConfig.copy(orgSetByUser = !autoOrganization) + val updatedConfig = + folderConfig.withSetting( + LsFolderSettingsKeys.ORG_SET_BY_USER, + !autoOrganization, + changed = true, + ) addFolderConfig(updatedConfig) } } @@ -157,7 +174,12 @@ class FolderConfigSettings { */ fun setOrganization(project: Project, organization: String?) { getFolderConfigs(project).forEach { folderConfig -> - val updatedConfig = folderConfig.copy(preferredOrg = organization ?: "") + val updatedConfig = + folderConfig.withSetting( + LsFolderSettingsKeys.PREFERRED_ORG, + organization ?: "", + changed = true, + ) addFolderConfig(updatedConfig) } } @@ -251,9 +273,9 @@ class FolderConfigSettings { private fun handleSingleCustomSubConfig( project: Project, parentPath: String, - parentConfig: FolderConfig, + parentConfig: LspFolderConfig, subPath: String, - subConfig: FolderConfig, + subConfig: LspFolderConfig, ): Int { val choice = promptForSingleSubConfigMigration(project, parentPath, subPath) @@ -285,7 +307,7 @@ class FolderConfigSettings { private fun handleMultipleConflictingConfigs( project: Project, parentPath: String, - customNestedConfigs: Map, + customNestedConfigs: Map, ): Int { val choice = promptForMultipleConflictingMigration(project, parentPath, customNestedConfigs.keys.toList()) @@ -394,28 +416,23 @@ class FolderConfigSettings { * user-configurable fields: baseBranch, referenceFolderPath, additionalParameters, additionalEnv, * preferredOrg, orgSetByUser, scanCommandConfig. */ - internal fun hasNonDefaultValues(config: FolderConfig, parentConfig: FolderConfig): Boolean = - config.baseBranch != parentConfig.baseBranch || - config.referenceFolderPath != parentConfig.referenceFolderPath || - config.additionalParameters != parentConfig.additionalParameters || - config.additionalEnv != parentConfig.additionalEnv || - config.preferredOrg != parentConfig.preferredOrg || - config.orgSetByUser != parentConfig.orgSetByUser || - config.scanCommandConfig != parentConfig.scanCommandConfig + internal fun hasNonDefaultValues( + config: LspFolderConfig, + parentConfig: LspFolderConfig, + ): Boolean = + COMPARABLE_SETTING_KEYS.any { key -> + config.settings?.get(key)?.value != parentConfig.settings?.get(key)?.value + } /** Checks if multiple configs have conflicting values between each other. */ - internal fun hasConflictingConfigs(configs: List): Boolean { + internal fun hasConflictingConfigs(configs: List): Boolean { if (configs.size < 2) return false val first = configs.first() return configs.drop(1).any { config -> - config.baseBranch != first.baseBranch || - config.referenceFolderPath != first.referenceFolderPath || - config.additionalParameters != first.additionalParameters || - config.additionalEnv != first.additionalEnv || - config.preferredOrg != first.preferredOrg || - config.orgSetByUser != first.orgSetByUser || - config.scanCommandConfig != first.scanCommandConfig + COMPARABLE_SETTING_KEYS.any { key -> + config.settings?.get(key)?.value != first.settings?.get(key)?.value + } } } @@ -423,16 +440,15 @@ class FolderConfigSettings { * Merges sub-config values into parent config. Sub-config values take precedence over parent * values. */ - internal fun mergeConfigs(parentConfig: FolderConfig, subConfig: FolderConfig): FolderConfig = - parentConfig.copy( - baseBranch = subConfig.baseBranch, - referenceFolderPath = subConfig.referenceFolderPath, - additionalParameters = subConfig.additionalParameters, - additionalEnv = subConfig.additionalEnv, - preferredOrg = subConfig.preferredOrg, - orgSetByUser = subConfig.orgSetByUser, - scanCommandConfig = subConfig.scanCommandConfig, - ) + internal fun mergeConfigs( + parentConfig: LspFolderConfig, + subConfig: LspFolderConfig, + ): LspFolderConfig { + // Merge sub-config settings into parent, sub-config values take precedence + val mergedSettings = (parentConfig.settings ?: emptyMap()).toMutableMap() + subConfig.settings?.forEach { (key, value) -> mergedSettings[key] = value } + return parentConfig.copy(settings = mergedSettings) + } /** * Checks if a path is nested under another path. @@ -457,4 +473,17 @@ class FolderConfigSettings { REMOVE_PARENT, KEEP_ALL } + + companion object { + private val COMPARABLE_SETTING_KEYS = + listOf( + LsFolderSettingsKeys.BASE_BRANCH, + LsFolderSettingsKeys.REFERENCE_FOLDER, + LsFolderSettingsKeys.ADDITIONAL_PARAMETERS, + LsFolderSettingsKeys.ADDITIONAL_ENVIRONMENT, + LsFolderSettingsKeys.PREFERRED_ORG, + LsFolderSettingsKeys.ORG_SET_BY_USER, + LsFolderSettingsKeys.SCAN_COMMAND_CONFIG, + ) + } } diff --git a/src/main/kotlin/snyk/common/lsp/settings/LanguageServerSettings.kt b/src/main/kotlin/snyk/common/lsp/settings/LanguageServerSettings.kt index 397867cab..e19e1e548 100644 --- a/src/main/kotlin/snyk/common/lsp/settings/LanguageServerSettings.kt +++ b/src/main/kotlin/snyk/common/lsp/settings/LanguageServerSettings.kt @@ -6,7 +6,6 @@ import com.google.gson.annotations.SerializedName import io.snyk.plugin.pluginSettings import io.snyk.plugin.services.AuthenticationType import org.apache.commons.lang3.SystemUtils -import snyk.common.lsp.FolderConfig import snyk.pluginInfo data class LanguageServerSettings( @@ -53,7 +52,7 @@ data class LanguageServerSettings( @SerializedName("outputFormat") val outputFormat: String = "html", @SerializedName("enableDeltaFindings") val enableDeltaFindings: String = pluginSettings().isDeltaFindingsEnabled().toString(), - @SerializedName("folderConfigs") val folderConfigs: List = emptyList(), + @SerializedName("folderConfigs") val folderConfigs: List = emptyList(), @SerializedName("riskScoreThreshold") val riskScoreThreshold: Int? = null, ) @@ -82,6 +81,24 @@ data class LspFolderConfig( @SerializedName("settings") val settings: Map? = null, ) +fun LspFolderConfig.withSetting( + key: String, + value: Any, + changed: Boolean? = null, +): LspFolderConfig { + val newSettings = (settings ?: emptyMap()).toMutableMap() + val existing = newSettings[key] + newSettings[key] = + ConfigSetting( + value = value, + changed = changed ?: existing?.changed, + source = existing?.source, + originScope = existing?.originScope, + isLocked = existing?.isLocked, + ) + return copy(settings = newSettings) +} + data class LspConfigurationParam( @SerializedName("settings") val settings: Map? = null, @SerializedName("folderConfigs") val folderConfigs: List? = null, diff --git a/src/test/kotlin/io/snyk/plugin/settings/SnykProjectSettingsConfigurableTest.kt b/src/test/kotlin/io/snyk/plugin/settings/SnykProjectSettingsConfigurableTest.kt index 2320116da..0f142654d 100644 --- a/src/test/kotlin/io/snyk/plugin/settings/SnykProjectSettingsConfigurableTest.kt +++ b/src/test/kotlin/io/snyk/plugin/settings/SnykProjectSettingsConfigurableTest.kt @@ -14,9 +14,10 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -import snyk.common.lsp.FolderConfig import snyk.common.lsp.LanguageServerWrapper import snyk.common.lsp.settings.FolderConfigSettings +import snyk.common.lsp.settings.LsFolderSettingsKeys +import snyk.common.lsp.settings.folderConfig class SnykProjectSettingsConfigurableTest { @@ -53,7 +54,7 @@ class SnykProjectSettingsConfigurableTest { // Setup initial config with orgSetByUser = true val initialConfig = - FolderConfig( + folderConfig( folderPath = path, baseBranch = "main", preferredOrg = "some-org", @@ -84,8 +85,15 @@ class SnykProjectSettingsConfigurableTest { // Verify the result val resultConfig = folderConfigSettings.getFolderConfig(path) - assertEquals("preferredOrg should be empty", "", resultConfig.preferredOrg) - assertFalse("orgSetByUser should be false (auto-detect enabled)", resultConfig.orgSetByUser) + assertEquals( + "preferredOrg should be empty", + "", + resultConfig.settings?.get(LsFolderSettingsKeys.PREFERRED_ORG)?.value, + ) + assertFalse( + "orgSetByUser should be false (auto-detect enabled)", + resultConfig.settings?.get(LsFolderSettingsKeys.ORG_SET_BY_USER)?.value as? Boolean ?: false, + ) assertTrue( "isAutoOrganizationEnabled should return true", folderConfigSettings.isAutoOrganizationEnabled(projectMock), @@ -103,7 +111,7 @@ class SnykProjectSettingsConfigurableTest { // Setup initial config with orgSetByUser = true val initialConfig = - FolderConfig( + folderConfig( folderPath = path, baseBranch = "main", preferredOrg = "some-org", @@ -138,11 +146,11 @@ class SnykProjectSettingsConfigurableTest { assertEquals( "preferredOrg should be empty to enable global org fallback", "", - resultConfig.preferredOrg, + resultConfig.settings?.get(LsFolderSettingsKeys.PREFERRED_ORG)?.value, ) assertTrue( "orgSetByUser should be true (manual mode, empty preferredOrg falls back to global)", - resultConfig.orgSetByUser, + resultConfig.settings?.get(LsFolderSettingsKeys.ORG_SET_BY_USER)?.value as? Boolean ?: false, ) assertFalse( "isAutoOrganizationEnabled should return false", @@ -161,7 +169,7 @@ class SnykProjectSettingsConfigurableTest { // Setup initial config with auto-detect enabled val initialConfig = - FolderConfig(folderPath = path, baseBranch = "main", preferredOrg = "", orgSetByUser = false) + folderConfig(folderPath = path, baseBranch = "main", preferredOrg = "", orgSetByUser = false) folderConfigSettings.addFolderConfig(initialConfig) // Mock the dialog to return a specific org @@ -187,8 +195,15 @@ class SnykProjectSettingsConfigurableTest { // Verify the result val resultConfig = folderConfigSettings.getFolderConfig(path) - assertEquals("preferredOrg should be set", "my-specific-org", resultConfig.preferredOrg) - assertTrue("orgSetByUser should be true (manual org)", resultConfig.orgSetByUser) + assertEquals( + "preferredOrg should be set", + "my-specific-org", + resultConfig.settings?.get(LsFolderSettingsKeys.PREFERRED_ORG)?.value, + ) + assertTrue( + "orgSetByUser should be true (manual org)", + resultConfig.settings?.get(LsFolderSettingsKeys.ORG_SET_BY_USER)?.value as? Boolean ?: false, + ) assertFalse( "isAutoOrganizationEnabled should return false", folderConfigSettings.isAutoOrganizationEnabled(projectMock), @@ -252,7 +267,7 @@ class SnykProjectSettingsConfigurableTest { for (case in cases) { fcs.clear() - fcs.addFolderConfig(FolderConfig(folderPath = path, baseBranch = "main")) + fcs.addFolderConfig(folderConfig(folderPath = path, baseBranch = "main")) applyFolderConfigChanges( fcs = fcs, @@ -262,7 +277,8 @@ class SnykProjectSettingsConfigurableTest { additionalParameters = case.input, ) - val result = fcs.getFolderConfig(path).additionalParameters + val result = + fcs.getFolderConfig(path).settings?.get(LsFolderSettingsKeys.ADDITIONAL_PARAMETERS)?.value assertEquals("Case '${case.description}': unexpected parsed tokens", case.expected, result) } } @@ -278,7 +294,7 @@ class SnykProjectSettingsConfigurableTest { // Setup initial config val initialConfig = - FolderConfig( + folderConfig( folderPath = path, baseBranch = "main", preferredOrg = "old-org", @@ -309,8 +325,15 @@ class SnykProjectSettingsConfigurableTest { // Verify the result - should use auto-detect since checkbox is checked val resultConfig = folderConfigSettings.getFolderConfig(path) - assertEquals("preferredOrg should be empty (auto-detect)", "", resultConfig.preferredOrg) - assertFalse("orgSetByUser should be false (auto-detect enabled)", resultConfig.orgSetByUser) + assertEquals( + "preferredOrg should be empty (auto-detect)", + "", + resultConfig.settings?.get(LsFolderSettingsKeys.PREFERRED_ORG)?.value, + ) + assertFalse( + "orgSetByUser should be false (auto-detect enabled)", + resultConfig.settings?.get(LsFolderSettingsKeys.ORG_SET_BY_USER)?.value as? Boolean ?: false, + ) assertTrue( "isAutoOrganizationEnabled should return true", folderConfigSettings.isAutoOrganizationEnabled(projectMock), diff --git a/src/test/kotlin/io/snyk/plugin/ui/ReferenceChooserDialogTest.kt b/src/test/kotlin/io/snyk/plugin/ui/ReferenceChooserDialogTest.kt index 0ee6af984..8dd832f48 100644 --- a/src/test/kotlin/io/snyk/plugin/ui/ReferenceChooserDialogTest.kt +++ b/src/test/kotlin/io/snyk/plugin/ui/ReferenceChooserDialogTest.kt @@ -19,15 +19,18 @@ import org.eclipse.lsp4j.DidChangeConfigurationParams import org.eclipse.lsp4j.WorkspaceFolder import org.eclipse.lsp4j.services.LanguageServer import org.junit.Test -import snyk.common.lsp.FolderConfig import snyk.common.lsp.LanguageServerWrapper import snyk.common.lsp.settings.FolderConfigSettings +import snyk.common.lsp.settings.LsFolderSettingsKeys import snyk.common.lsp.settings.LspConfigurationParam +import snyk.common.lsp.settings.LspFolderConfig +import snyk.common.lsp.settings.folderConfig +import snyk.common.lsp.settings.withSetting import snyk.trust.WorkspaceTrustSettings class ReferenceChooserDialogTest : LightPlatform4TestCase() { private val lsMock: LanguageServer = mockk(relaxed = true) - private lateinit var folderConfig: FolderConfig + private lateinit var folderConfig: LspFolderConfig private lateinit var cut: ReferenceChooserDialog private lateinit var workspaceFolder: WorkspaceFolder private lateinit var languageServerWrapper: LanguageServerWrapper @@ -48,7 +51,7 @@ class ReferenceChooserDialogTest : LightPlatform4TestCase() { // Create a folder config with local branches for the original tests folderConfig = - FolderConfig( + folderConfig( absolutePathString, baseBranch = "testBranch", localBranches = listOf("main", "dev"), @@ -84,10 +87,11 @@ class ReferenceChooserDialogTest : LightPlatform4TestCase() { } /** Helper method to create a folder config with no local branches for testing */ - private fun createFolderConfigWithNoBranches(): FolderConfig { + private fun createFolderConfigWithNoBranches(): LspFolderConfig { val folderConfigSettings = service() val existingConfig = folderConfigSettings.getFolderConfig(folderConfig.folderPath) - val modifiedConfig = existingConfig.copy(localBranches = emptyList()) + val modifiedConfig = + existingConfig.withSetting(LsFolderSettingsKeys.LOCAL_BRANCHES, emptyList()) folderConfigSettings.addFolderConfig(modifiedConfig) return modifiedConfig } @@ -135,13 +139,15 @@ class ReferenceChooserDialogTest : LightPlatform4TestCase() { val comboBox = ComboBox(arrayOf("main", "dev")).apply { name = folderConfig.folderPath - selectedItem = folderConfig.baseBranch // Use original value, not "main" + selectedItem = + folderConfig.settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value as? String ?: "" } // Create a reference folder control with original value (no changes) val referenceFolder = JTextField().apply { - text = folderConfig.referenceFolderPath ?: "" // Use original value + text = + folderConfig.settings?.get(LsFolderSettingsKeys.REFERENCE_FOLDER)?.value as? String ?: "" } val referenceFolderControl = TextFieldWithBrowseButton(referenceFolder) @@ -179,7 +185,8 @@ class ReferenceChooserDialogTest : LightPlatform4TestCase() { // Create a folder config with null local branches val folderConfigSettings = service() val existingConfig = folderConfigSettings.getFolderConfig(folderConfig.folderPath) - val configNullBranches = existingConfig.copy(localBranches = null) + val configNullBranches = + existingConfig.withSetting(LsFolderSettingsKeys.LOCAL_BRANCHES, emptyList()) folderConfigSettings.addFolderConfig(configNullBranches) // Create new dialog instance diff --git a/src/test/kotlin/io/snyk/plugin/ui/toolwindow/SnykToolWindowSnykScanListenerTest.kt b/src/test/kotlin/io/snyk/plugin/ui/toolwindow/SnykToolWindowSnykScanListenerTest.kt index 178617eeb..1888f024b 100644 --- a/src/test/kotlin/io/snyk/plugin/ui/toolwindow/SnykToolWindowSnykScanListenerTest.kt +++ b/src/test/kotlin/io/snyk/plugin/ui/toolwindow/SnykToolWindowSnykScanListenerTest.kt @@ -26,10 +26,10 @@ import org.eclipse.lsp4j.Position import org.eclipse.lsp4j.Range import snyk.common.annotator.SnykCodeAnnotator import snyk.common.lsp.DataFlow -import snyk.common.lsp.FolderConfig import snyk.common.lsp.IssueData import snyk.common.lsp.ScanIssue import snyk.common.lsp.settings.FolderConfigSettings +import snyk.common.lsp.settings.folderConfig import snyk.trust.WorkspaceTrustSettings class SnykToolWindowSnykScanListenerTest : BasePlatformTestCase() { @@ -61,7 +61,7 @@ class SnykToolWindowSnykScanListenerTest : BasePlatformTestCase() { val contentRootPaths = project.getContentRootPaths() service() .addFolderConfig( - FolderConfig(contentRootPaths.first().toAbsolutePath().toString(), baseBranch = "main") + folderConfig(contentRootPaths.first().toAbsolutePath().toString(), baseBranch = "main") ) snykToolWindowPanel = SnykToolWindowPanel(project) rootOssIssuesTreeNode = RootOssTreeNode(project) diff --git a/src/test/kotlin/snyk/common/lsp/settings/FolderConfigSettingsTest.kt b/src/test/kotlin/snyk/common/lsp/settings/FolderConfigSettingsTest.kt index a29574977..f5cc92f0f 100644 --- a/src/test/kotlin/snyk/common/lsp/settings/FolderConfigSettingsTest.kt +++ b/src/test/kotlin/snyk/common/lsp/settings/FolderConfigSettingsTest.kt @@ -16,7 +16,6 @@ import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -import snyk.common.lsp.FolderConfig import snyk.common.lsp.LanguageServerWrapper class FolderConfigSettingsTest { @@ -39,7 +38,7 @@ class FolderConfigSettingsTest { val path = "/test/projectA" val normalizedPath = Paths.get(path).normalize().toAbsolutePath().toString() val config = - FolderConfig( + folderConfig( folderPath = path, baseBranch = "main", additionalParameters = listOf("--scan-all-unmanaged"), @@ -50,11 +49,15 @@ class FolderConfigSettingsTest { val retrievedConfig = settings.getFolderConfig(path) assertNotNull("Retrieved config should not be null", retrievedConfig) assertEquals("Normalized path should match", normalizedPath, retrievedConfig.folderPath) - assertEquals("Base branch should match", "main", retrievedConfig.baseBranch) + assertEquals( + "Base branch should match", + "main", + retrievedConfig.settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value, + ) assertEquals( "Additional parameters should match", listOf("--scan-all-unmanaged"), - retrievedConfig.additionalParameters, + retrievedConfig.settings?.get(LsFolderSettingsKeys.ADDITIONAL_PARAMETERS)?.value, ) assertEquals("Settings map size should be 1", 1, settings.getAll().size) @@ -69,13 +72,17 @@ class FolderConfigSettingsTest { val rawPath = "/test/projectB/./subfolder/../othersubfolder" val expectedNormalizedPath = Paths.get(rawPath).normalize().toAbsolutePath().toString() - val config = FolderConfig(folderPath = rawPath, baseBranch = "develop") + val config = folderConfig(folderPath = rawPath, baseBranch = "develop") settings.addFolderConfig(config) val retrievedConfig = settings.getFolderConfig(rawPath) assertNotNull("Retrieved config should not be null", retrievedConfig) assertEquals("Normalized path should match", expectedNormalizedPath, retrievedConfig.folderPath) - assertEquals("Base branch should match", "develop", retrievedConfig.baseBranch) + assertEquals( + "Base branch should match", + "develop", + retrievedConfig.settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value, + ) val retrievedAgain = settings.getFolderConfig(expectedNormalizedPath) assertNotNull("Retrieved again config should not be null", retrievedAgain) @@ -93,60 +100,72 @@ class FolderConfigSettingsTest { val path3 = "/my/project/../project/folder" val normalizedPath1 = Paths.get(path1).normalize().toAbsolutePath().toString() - val config = FolderConfig(folderPath = path1, baseBranch = "feature-branch") + val config = folderConfig(folderPath = path1, baseBranch = "feature-branch") settings.addFolderConfig(config) val retrievedConfig1 = settings.getFolderConfig(path1) assertEquals("Path1 normalized path should match", normalizedPath1, retrievedConfig1.folderPath) - assertEquals("Path1 base branch should match", "feature-branch", retrievedConfig1.baseBranch) + assertEquals( + "Path1 base branch should match", + "feature-branch", + retrievedConfig1.settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value, + ) val retrievedConfig2 = settings.getFolderConfig(path2) assertEquals("Path2 normalized path should match", normalizedPath1, retrievedConfig2.folderPath) - assertEquals("Path2 base branch should match", "feature-branch", retrievedConfig2.baseBranch) + assertEquals( + "Path2 base branch should match", + "feature-branch", + retrievedConfig2.settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value, + ) val retrievedConfig3 = settings.getFolderConfig(path3) assertEquals("Path3 normalized path should match", normalizedPath1, retrievedConfig3.folderPath) - assertEquals("Path3 base branch should match", "feature-branch", retrievedConfig3.baseBranch) + assertEquals( + "Path3 base branch should match", + "feature-branch", + retrievedConfig3.settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value, + ) } @Test fun `addFolderConfig ignores empty or blank folderPaths`() { - settings.addFolderConfig(FolderConfig(folderPath = "", baseBranch = "main")) + settings.addFolderConfig(folderConfig(folderPath = "", baseBranch = "main")) assertEquals("Config with empty path should be ignored", 0, settings.getAll().size) - settings.addFolderConfig(FolderConfig(folderPath = " ", baseBranch = "main")) + settings.addFolderConfig(folderConfig(folderPath = " ", baseBranch = "main")) assertEquals("Config with blank path should be ignored", 0, settings.getAll().size) } @Test - fun `addFolderConfig handles null additionalParameters by defaulting to emptyList`() { + fun `addFolderConfig handles null additionalParameters by not setting key`() { val path = "/test/project" - val config = FolderConfig(folderPath = path, baseBranch = "main", additionalParameters = null) + val config = folderConfig(folderPath = path, baseBranch = "main", additionalParameters = null) settings.addFolderConfig(config) val retrievedConfig = settings.getFolderConfig(path) assertNotNull("Retrieved config should not be null", retrievedConfig) assertEquals( - "additionalParameters should be emptyList when null", - emptyList(), - retrievedConfig.additionalParameters, + "additionalParameters should be null when not set", + null, + retrievedConfig.settings?.get(LsFolderSettingsKeys.ADDITIONAL_PARAMETERS)?.value, ) } @Test - fun `addFolderConfig handles null additionalEnv by defaulting to empty string`() { + fun `addFolderConfig handles null additionalEnv by not setting key`() { val path = "/test/project" - val config = FolderConfig(folderPath = path, baseBranch = "main", additionalEnv = null) + val config = folderConfig(folderPath = path, baseBranch = "main", additionalEnv = null) settings.addFolderConfig(config) val retrievedConfig = settings.getFolderConfig(path) assertNotNull("Retrieved config should not be null", retrievedConfig) assertEquals( - "additionalEnv should be empty string when null", - "", - retrievedConfig.additionalEnv, + "additionalEnv should be null when not set", + null, + retrievedConfig.settings?.get(LsFolderSettingsKeys.ADDITIONAL_ENVIRONMENT)?.value, ) } @@ -164,26 +183,30 @@ class FolderConfigSettingsTest { expectedNormalizedPath, newConfig.folderPath, ) - assertEquals("New config should have default baseBranch", "main", newConfig.baseBranch) + assertEquals( + "New config should have default baseBranch", + "main", + newConfig.settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value, + ) assertEquals( "New config additionalParameters should be emptyList", emptyList(), - newConfig.additionalParameters, + newConfig.settings?.get(LsFolderSettingsKeys.ADDITIONAL_PARAMETERS)?.value, ) assertEquals( "New config localBranches should be emptyList", emptyList(), - newConfig.localBranches, + newConfig.settings?.get(LsFolderSettingsKeys.LOCAL_BRANCHES)?.value, ) assertEquals( "New config referenceFolderPath should be empty string", "", - newConfig.referenceFolderPath, + newConfig.settings?.get(LsFolderSettingsKeys.REFERENCE_FOLDER)?.value, ) assertEquals( "New config scanCommandConfig should be emptyMap", emptyMap(), - newConfig.scanCommandConfig, + newConfig.settings?.get(LsFolderSettingsKeys.SCAN_COMMAND_CONFIG)?.value, ) val allConfigs = settings.getAll() @@ -206,20 +229,24 @@ class FolderConfigSettingsTest { val normalizedPath = Paths.get(path).normalize().toAbsolutePath().toString() val config1 = - FolderConfig(folderPath = path, baseBranch = "v1", additionalParameters = listOf("param1")) + folderConfig(folderPath = path, baseBranch = "v1", additionalParameters = listOf("param1")) settings.addFolderConfig(config1) var retrieved = settings.getFolderConfig(path) - assertEquals("Retrieved v1 baseBranch", "v1", retrieved.baseBranch) + assertEquals( + "Retrieved v1 baseBranch", + "v1", + retrieved.settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value, + ) assertEquals( "Retrieved v1 additionalParameters", listOf("param1"), - retrieved.additionalParameters, + retrieved.settings?.get(LsFolderSettingsKeys.ADDITIONAL_PARAMETERS)?.value, ) assertEquals("Retrieved v1 normalizedPath", normalizedPath, retrieved.folderPath) val config2 = - FolderConfig( + folderConfig( folderPath = equivalentPath, baseBranch = "v2", additionalParameters = listOf("param2"), @@ -227,11 +254,15 @@ class FolderConfigSettingsTest { settings.addFolderConfig(config2) retrieved = settings.getFolderConfig(path) - assertEquals("BaseBranch should be from the overriding config", "v2", retrieved.baseBranch) + assertEquals( + "BaseBranch should be from the overriding config", + "v2", + retrieved.settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value, + ) assertEquals( "AdditionalParameters should be from the overriding config", listOf("param2"), - retrieved.additionalParameters, + retrieved.settings?.get(LsFolderSettingsKeys.ADDITIONAL_PARAMETERS)?.value, ) assertEquals( "NormalizedPath should remain the same after overwrite", @@ -250,10 +281,10 @@ class FolderConfigSettingsTest { val normalizedUpper = Paths.get(pathUpper).normalize().toAbsolutePath().toString() val normalizedLower = Paths.get(pathLower).normalize().toAbsolutePath().toString() - val configUpper = FolderConfig(folderPath = pathUpper, baseBranch = "upper") + val configUpper = folderConfig(folderPath = pathUpper, baseBranch = "upper") settings.addFolderConfig(configUpper) - val configLower = FolderConfig(folderPath = pathLower, baseBranch = "lower") + val configLower = folderConfig(folderPath = pathLower, baseBranch = "lower") settings.addFolderConfig(configLower) if ( @@ -268,12 +299,12 @@ class FolderConfigSettingsTest { assertEquals( "BaseBranch for upper case path", "upper", - settings.getFolderConfig(pathUpper).baseBranch, + settings.getFolderConfig(pathUpper).settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value, ) assertEquals( "BaseBranch for lower case path", "lower", - settings.getFolderConfig(pathLower).baseBranch, + settings.getFolderConfig(pathLower).settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value, ) } else if (normalizedUpper == normalizedLower) { assertEquals( @@ -284,12 +315,12 @@ class FolderConfigSettingsTest { assertEquals( "Lower should overwrite if normalized paths are identical (upper retrieval)", "lower", - settings.getFolderConfig(pathUpper).baseBranch, + settings.getFolderConfig(pathUpper).settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value, ) assertEquals( "Lower should overwrite if normalized paths are identical (lower retrieval)", "lower", - settings.getFolderConfig(pathLower).baseBranch, + settings.getFolderConfig(pathLower).settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value, ) } else { assertEquals( @@ -300,12 +331,12 @@ class FolderConfigSettingsTest { assertEquals( "BaseBranch for upper case path (distinct)", "upper", - settings.getFolderConfig(pathUpper).baseBranch, + settings.getFolderConfig(pathUpper).settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value, ) assertEquals( "BaseBranch for lower case path (distinct)", "lower", - settings.getFolderConfig(pathLower).baseBranch, + settings.getFolderConfig(pathLower).settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value, ) } } @@ -318,7 +349,7 @@ class FolderConfigSettingsTest { val expectedNormalizedPath = Paths.get(pathWithoutSlash).normalize().toAbsolutePath().toString() // Add with slash - val config1 = FolderConfig(folderPath = pathWithSlash, baseBranch = "main") + val config1 = folderConfig(folderPath = pathWithSlash, baseBranch = "main") settings.addFolderConfig(config1) // Retrieve with and without slash @@ -350,7 +381,7 @@ class FolderConfigSettingsTest { // Clear and test adding without slash first settings.clear() - val config2 = FolderConfig(folderPath = pathWithoutSlash, baseBranch = "develop") + val config2 = folderConfig(folderPath = pathWithoutSlash, baseBranch = "develop") settings.addFolderConfig(config2) val retrieved2With = settings.getFolderConfig(pathWithSlash) @@ -362,7 +393,10 @@ class FolderConfigSettingsTest { expectedNormalizedPath, retrieved2With.folderPath, ) - assertEquals("develop", retrieved2With.baseBranch) // Ensure correct config is retrieved + assertEquals( + "develop", + retrieved2With.settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value, + ) // Ensure correct config is retrieved assertNotNull( "Config (added without slash) should be retrievable without slash", retrieved2Without, @@ -372,7 +406,10 @@ class FolderConfigSettingsTest { expectedNormalizedPath, retrieved2Without.folderPath, ) - assertEquals("develop", retrieved2Without.baseBranch) + assertEquals( + "develop", + retrieved2Without.settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value, + ) assertEquals( "Both retrievals should yield the same object instance", retrieved2With, @@ -393,7 +430,7 @@ class FolderConfigSettingsTest { val rootPathWithSlash = "/" val rootPathNormalized = Paths.get(rootPathWithSlash).normalize().toAbsolutePath().toString() - val config = FolderConfig(folderPath = rootPathWithSlash, baseBranch = "rootBranch") + val config = folderConfig(folderPath = rootPathWithSlash, baseBranch = "rootBranch") settings.addFolderConfig(config) val retrieved = settings.getFolderConfig(rootPathWithSlash) assertNotNull("Retrieved config for root path should not be null", retrieved) @@ -422,19 +459,23 @@ class FolderConfigSettingsTest { @Test fun `addFolderConfig stores and retrieves preferredOrg`() { val path = "/test/project" - val config = FolderConfig(folderPath = path, baseBranch = "main", preferredOrg = "my-org-uuid") + val config = folderConfig(folderPath = path, baseBranch = "main", preferredOrg = "my-org-uuid") settings.addFolderConfig(config) val retrievedConfig = settings.getFolderConfig(path) assertNotNull("Retrieved config should not be null", retrievedConfig) - assertEquals("Preferred org should match", "my-org-uuid", retrievedConfig.preferredOrg) + assertEquals( + "Preferred org should match", + "my-org-uuid", + retrievedConfig.settings?.get(LsFolderSettingsKeys.PREFERRED_ORG)?.value, + ) } @Test fun `addFolderConfig with empty preferredOrg uses default`() { val path = "/test/project" - val config = FolderConfig(folderPath = path, baseBranch = "main") + val config = folderConfig(folderPath = path, baseBranch = "main") settings.addFolderConfig(config) @@ -443,7 +484,7 @@ class FolderConfigSettingsTest { assertEquals( "Preferred org should be empty string by default", "", - retrievedConfig.preferredOrg, + retrievedConfig.settings?.get(LsFolderSettingsKeys.PREFERRED_ORG)?.value, ) } @@ -453,23 +494,35 @@ class FolderConfigSettingsTest { val newConfig = settings.getFolderConfig(path) assertNotNull("New config should not be null", newConfig) - assertEquals("New config preferredOrg should be empty string", "", newConfig.preferredOrg) + assertEquals( + "New config preferredOrg should be empty string", + "", + newConfig.settings?.get(LsFolderSettingsKeys.PREFERRED_ORG)?.value, + ) } @Test fun `addFolderConfig overwrites preferredOrg when config is updated`() { val path = "/test/project" - val config1 = FolderConfig(folderPath = path, baseBranch = "main", preferredOrg = "first-org") + val config1 = folderConfig(folderPath = path, baseBranch = "main", preferredOrg = "first-org") settings.addFolderConfig(config1) val retrieved1 = settings.getFolderConfig(path) - assertEquals("First preferredOrg should match", "first-org", retrieved1.preferredOrg) + assertEquals( + "First preferredOrg should match", + "first-org", + retrieved1.settings?.get(LsFolderSettingsKeys.PREFERRED_ORG)?.value, + ) - val config2 = FolderConfig(folderPath = path, baseBranch = "main", preferredOrg = "second-org") + val config2 = folderConfig(folderPath = path, baseBranch = "main", preferredOrg = "second-org") settings.addFolderConfig(config2) val retrieved2 = settings.getFolderConfig(path) - assertEquals("PreferredOrg should be updated", "second-org", retrieved2.preferredOrg) + assertEquals( + "PreferredOrg should be updated", + "second-org", + retrieved2.settings?.get(LsFolderSettingsKeys.PREFERRED_ORG)?.value, + ) } @Test @@ -499,8 +552,8 @@ class FolderConfigSettingsTest { val normalizedPath2 = Paths.get(path2).normalize().toAbsolutePath().toString() // Add configs with preferredOrg - val config1 = FolderConfig(folderPath = path1, baseBranch = "main", preferredOrg = "org-uuid-1") - val config2 = FolderConfig(folderPath = path2, baseBranch = "main", preferredOrg = "org-uuid-2") + val config1 = folderConfig(folderPath = path1, baseBranch = "main", preferredOrg = "org-uuid-1") + val config2 = folderConfig(folderPath = path2, baseBranch = "main", preferredOrg = "org-uuid-2") settings.addFolderConfig(config1) settings.addFolderConfig(config2) @@ -535,7 +588,7 @@ class FolderConfigSettingsTest { val path1 = "/test/project1" val normalizedPath1 = Paths.get(path1).normalize().toAbsolutePath().toString() - val config1 = FolderConfig(folderPath = path1, baseBranch = "main", preferredOrg = "") + val config1 = folderConfig(folderPath = path1, baseBranch = "main", preferredOrg = "") settings.addFolderConfig(config1) val workspaceFolder1 = @@ -565,8 +618,8 @@ class FolderConfigSettingsTest { val normalizedPath1 = Paths.get(path1).normalize().toAbsolutePath().toString() val normalizedPath2 = Paths.get(path2).normalize().toAbsolutePath().toString() - val config1 = FolderConfig(folderPath = path1, baseBranch = "main", preferredOrg = "org-uuid-1") - val config2 = FolderConfig(folderPath = path2, baseBranch = "main", preferredOrg = "org-uuid-2") + val config1 = folderConfig(folderPath = path1, baseBranch = "main", preferredOrg = "org-uuid-1") + val config2 = folderConfig(folderPath = path2, baseBranch = "main", preferredOrg = "org-uuid-2") settings.addFolderConfig(config1) settings.addFolderConfig(config2) @@ -596,41 +649,57 @@ class FolderConfigSettingsTest { @Test fun `addFolderConfig stores and retrieves orgSetByUser with default false`() { val path = "/test/project" - val config = FolderConfig(folderPath = path, baseBranch = "main") + val config = folderConfig(folderPath = path, baseBranch = "main") settings.addFolderConfig(config) val retrievedConfig = settings.getFolderConfig(path) assertNotNull("Retrieved config should not be null", retrievedConfig) - assertEquals("orgSetByUser should default to false", false, retrievedConfig.orgSetByUser) + assertEquals( + "orgSetByUser should default to false", + false, + retrievedConfig.settings?.get(LsFolderSettingsKeys.ORG_SET_BY_USER)?.value, + ) } @Test fun `addFolderConfig stores and retrieves orgSetByUser set to true`() { val path = "/test/project" - val config = FolderConfig(folderPath = path, baseBranch = "main", orgSetByUser = true) + val config = folderConfig(folderPath = path, baseBranch = "main", orgSetByUser = true) settings.addFolderConfig(config) val retrievedConfig = settings.getFolderConfig(path) assertNotNull("Retrieved config should not be null", retrievedConfig) - assertEquals("orgSetByUser should be true", true, retrievedConfig.orgSetByUser) + assertEquals( + "orgSetByUser should be true", + true, + retrievedConfig.settings?.get(LsFolderSettingsKeys.ORG_SET_BY_USER)?.value, + ) } @Test fun `addFolderConfig overwrites orgSetByUser when config is updated`() { val path = "/test/project" - val config1 = FolderConfig(folderPath = path, baseBranch = "main", orgSetByUser = false) + val config1 = folderConfig(folderPath = path, baseBranch = "main", orgSetByUser = false) settings.addFolderConfig(config1) val retrieved1 = settings.getFolderConfig(path) - assertEquals("First orgSetByUser should be false", false, retrieved1.orgSetByUser) + assertEquals( + "First orgSetByUser should be false", + false, + retrieved1.settings?.get(LsFolderSettingsKeys.ORG_SET_BY_USER)?.value, + ) - val config2 = FolderConfig(folderPath = path, baseBranch = "main", orgSetByUser = true) + val config2 = folderConfig(folderPath = path, baseBranch = "main", orgSetByUser = true) settings.addFolderConfig(config2) val retrieved2 = settings.getFolderConfig(path) - assertEquals("orgSetByUser should be updated to true", true, retrieved2.orgSetByUser) + assertEquals( + "orgSetByUser should be updated to true", + true, + retrieved2.settings?.get(LsFolderSettingsKeys.ORG_SET_BY_USER)?.value, + ) } @Test @@ -639,7 +708,11 @@ class FolderConfigSettingsTest { val newConfig = settings.getFolderConfig(path) assertNotNull("New config should not be null", newConfig) - assertEquals("New config orgSetByUser should be false", false, newConfig.orgSetByUser) + assertEquals( + "New config orgSetByUser should be false", + false, + newConfig.settings?.get(LsFolderSettingsKeys.ORG_SET_BY_USER)?.value, + ) } @Test @@ -650,7 +723,7 @@ class FolderConfigSettingsTest { val path = "/test/project" val workspaceFolder = WorkspaceFolder().apply { uri = path.fromPathToUriString() } - val config = FolderConfig(folderPath = path, baseBranch = "main", orgSetByUser = false) + val config = folderConfig(folderPath = path, baseBranch = "main", orgSetByUser = false) settings.addFolderConfig(config) @@ -673,7 +746,7 @@ class FolderConfigSettingsTest { val path = "/test/project" val workspaceFolder = WorkspaceFolder().apply { uri = path.fromPathToUriString() } - val config = FolderConfig(folderPath = path, baseBranch = "main", orgSetByUser = true) + val config = folderConfig(folderPath = path, baseBranch = "main", orgSetByUser = true) settings.addFolderConfig(config) @@ -696,7 +769,7 @@ class FolderConfigSettingsTest { val path = "/test/project" val workspaceFolder = WorkspaceFolder().apply { uri = path.fromPathToUriString() } - val config = FolderConfig(folderPath = path, baseBranch = "main", orgSetByUser = true) + val config = folderConfig(folderPath = path, baseBranch = "main", orgSetByUser = true) settings.addFolderConfig(config) @@ -728,7 +801,7 @@ class FolderConfigSettingsTest { val path = "/test/project" val workspaceFolder = WorkspaceFolder().apply { uri = path.fromPathToUriString() } - val config = FolderConfig(folderPath = path, baseBranch = "main", preferredOrg = "old-org") + val config = folderConfig(folderPath = path, baseBranch = "main", preferredOrg = "old-org") settings.addFolderConfig(config) @@ -764,8 +837,8 @@ class FolderConfigSettingsTest { val normalizedNestedPath = Paths.get(nestedPath).normalize().toAbsolutePath().toString() // Add both configs with SAME values (nested has default/same values as parent) - val workspaceConfig = FolderConfig(folderPath = workspacePath, baseBranch = "main") - val nestedConfig = FolderConfig(folderPath = nestedPath, baseBranch = "main") // Same as parent + val workspaceConfig = folderConfig(folderPath = workspacePath, baseBranch = "main") + val nestedConfig = folderConfig(folderPath = nestedPath, baseBranch = "main") // Same as parent settings.addFolderConfig(workspaceConfig) settings.addFolderConfig(nestedConfig) @@ -809,8 +882,8 @@ class FolderConfigSettingsTest { val normalizedPath1 = Paths.get(path1).normalize().toAbsolutePath().toString() val normalizedPath2 = Paths.get(path2).normalize().toAbsolutePath().toString() - val config1 = FolderConfig(folderPath = path1, baseBranch = "main") - val config2 = FolderConfig(folderPath = path2, baseBranch = "develop") + val config1 = folderConfig(folderPath = path1, baseBranch = "main") + val config2 = folderConfig(folderPath = path2, baseBranch = "develop") settings.addFolderConfig(config1) settings.addFolderConfig(config2) @@ -846,7 +919,7 @@ class FolderConfigSettingsTest { val lsWrapperMock = mockk(relaxed = true) val path = "/test/project" - settings.addFolderConfig(FolderConfig(folderPath = path, baseBranch = "main")) + settings.addFolderConfig(folderConfig(folderPath = path, baseBranch = "main")) assertEquals("Should have 1 config before migration", 1, settings.getAll().size) @@ -872,8 +945,8 @@ class FolderConfigSettingsTest { val normalizedWorkspacePath = Paths.get(workspacePath).normalize().toAbsolutePath().toString() // Add both configs - settings.addFolderConfig(FolderConfig(folderPath = workspacePath, baseBranch = "main")) - settings.addFolderConfig(FolderConfig(folderPath = nestedPath, baseBranch = "develop")) + settings.addFolderConfig(folderConfig(folderPath = workspacePath, baseBranch = "main")) + settings.addFolderConfig(folderConfig(folderPath = nestedPath, baseBranch = "develop")) val workspaceFolder = WorkspaceFolder().apply { @@ -901,8 +974,8 @@ class FolderConfigSettingsTest { @Test fun `hasNonDefaultValues returns true when config differs from parent`() { - val parentConfig = FolderConfig(folderPath = "/parent", baseBranch = "main") - val childConfig = FolderConfig(folderPath = "/child", baseBranch = "develop") + val parentConfig = folderConfig(folderPath = "/parent", baseBranch = "main") + val childConfig = folderConfig(folderPath = "/child", baseBranch = "develop") assertTrue( "Should detect different baseBranch", @@ -912,8 +985,8 @@ class FolderConfigSettingsTest { @Test fun `hasNonDefaultValues returns false when config matches parent`() { - val parentConfig = FolderConfig(folderPath = "/parent", baseBranch = "main") - val childConfig = FolderConfig(folderPath = "/child", baseBranch = "main") + val parentConfig = folderConfig(folderPath = "/parent", baseBranch = "main") + val childConfig = folderConfig(folderPath = "/child", baseBranch = "main") assertFalse( "Should not detect differences when configs match", @@ -923,8 +996,8 @@ class FolderConfigSettingsTest { @Test fun `hasConflictingConfigs returns true when configs differ`() { - val config1 = FolderConfig(folderPath = "/path1", baseBranch = "main") - val config2 = FolderConfig(folderPath = "/path2", baseBranch = "develop") + val config1 = folderConfig(folderPath = "/path1", baseBranch = "main") + val config2 = folderConfig(folderPath = "/path2", baseBranch = "develop") assertTrue( "Should detect conflicting configs", @@ -934,8 +1007,8 @@ class FolderConfigSettingsTest { @Test fun `hasConflictingConfigs returns false when configs match`() { - val config1 = FolderConfig(folderPath = "/path1", baseBranch = "main") - val config2 = FolderConfig(folderPath = "/path2", baseBranch = "main") + val config1 = folderConfig(folderPath = "/path1", baseBranch = "main") + val config2 = folderConfig(folderPath = "/path2", baseBranch = "main") assertFalse( "Should not detect conflicts when configs match", @@ -946,14 +1019,14 @@ class FolderConfigSettingsTest { @Test fun `mergeConfigs copies sub-config values into parent`() { val parentConfig = - FolderConfig( + folderConfig( folderPath = "/parent", baseBranch = "main", referenceFolderPath = "/old/ref", additionalParameters = listOf("--old"), ) val subConfig = - FolderConfig( + folderConfig( folderPath = "/child", baseBranch = "develop", referenceFolderPath = "/new/ref", @@ -963,16 +1036,20 @@ class FolderConfigSettingsTest { val merged = settings.mergeConfigs(parentConfig, subConfig) assertEquals("Should keep parent folderPath", "/parent", merged.folderPath) - assertEquals("Should use sub-config baseBranch", "develop", merged.baseBranch) + assertEquals( + "Should use sub-config baseBranch", + "develop", + merged.settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value, + ) assertEquals( "Should use sub-config referenceFolderPath", "/new/ref", - merged.referenceFolderPath, + merged.settings?.get(LsFolderSettingsKeys.REFERENCE_FOLDER)?.value, ) assertEquals( "Should use sub-config additionalParameters", listOf("--new"), - merged.additionalParameters, + merged.settings?.get(LsFolderSettingsKeys.ADDITIONAL_PARAMETERS)?.value, ) } @@ -996,9 +1073,9 @@ class FolderConfigSettingsTest { val normalizedNestedPath = Paths.get(nestedPath).normalize().toAbsolutePath().toString() // Parent and nested have DIFFERENT values - settings.addFolderConfig(FolderConfig(folderPath = workspacePath, baseBranch = "main")) + settings.addFolderConfig(folderConfig(folderPath = workspacePath, baseBranch = "main")) settings.addFolderConfig( - FolderConfig( + folderConfig( folderPath = nestedPath, baseBranch = "develop", referenceFolderPath = "/custom/ref", @@ -1029,11 +1106,15 @@ class FolderConfigSettingsTest { // Verify parent was updated with sub-config values val parentConfig = settingsSpy.getAll()[normalizedWorkspacePath] - assertEquals("Parent should have merged baseBranch", "develop", parentConfig?.baseBranch) + assertEquals( + "Parent should have merged baseBranch", + "develop", + parentConfig?.settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value, + ) assertEquals( "Parent should have merged referenceFolderPath", "/custom/ref", - parentConfig?.referenceFolderPath, + parentConfig?.settings?.get(LsFolderSettingsKeys.REFERENCE_FOLDER)?.value, ) } @@ -1048,8 +1129,8 @@ class FolderConfigSettingsTest { val normalizedNestedPath = Paths.get(nestedPath).normalize().toAbsolutePath().toString() // Parent and nested have DIFFERENT values - settings.addFolderConfig(FolderConfig(folderPath = workspacePath, baseBranch = "main")) - settings.addFolderConfig(FolderConfig(folderPath = nestedPath, baseBranch = "develop")) + settings.addFolderConfig(folderConfig(folderPath = workspacePath, baseBranch = "main")) + settings.addFolderConfig(folderConfig(folderPath = nestedPath, baseBranch = "develop")) val workspaceFolder = WorkspaceFolder().apply { @@ -1075,7 +1156,11 @@ class FolderConfigSettingsTest { // Verify parent was NOT updated val parentConfig = settingsSpy.getAll()[normalizedWorkspacePath] - assertEquals("Parent should keep original baseBranch", "main", parentConfig?.baseBranch) + assertEquals( + "Parent should keep original baseBranch", + "main", + parentConfig?.settings?.get(LsFolderSettingsKeys.BASE_BRANCH)?.value, + ) } @Test @@ -1091,9 +1176,9 @@ class FolderConfigSettingsTest { val normalizedNestedPath2 = Paths.get(nestedPath2).normalize().toAbsolutePath().toString() // Parent and two nested configs with DIFFERENT values from each other - settings.addFolderConfig(FolderConfig(folderPath = workspacePath, baseBranch = "main")) - settings.addFolderConfig(FolderConfig(folderPath = nestedPath1, baseBranch = "develop")) - settings.addFolderConfig(FolderConfig(folderPath = nestedPath2, baseBranch = "feature")) + settings.addFolderConfig(folderConfig(folderPath = workspacePath, baseBranch = "main")) + settings.addFolderConfig(folderConfig(folderPath = nestedPath1, baseBranch = "develop")) + settings.addFolderConfig(folderConfig(folderPath = nestedPath2, baseBranch = "feature")) val workspaceFolder = WorkspaceFolder().apply { @@ -1147,9 +1232,9 @@ class FolderConfigSettingsTest { val normalizedNestedPath2 = Paths.get(nestedPath2).normalize().toAbsolutePath().toString() // Parent and two nested configs with DIFFERENT values from each other - settings.addFolderConfig(FolderConfig(folderPath = workspacePath, baseBranch = "main")) - settings.addFolderConfig(FolderConfig(folderPath = nestedPath1, baseBranch = "develop")) - settings.addFolderConfig(FolderConfig(folderPath = nestedPath2, baseBranch = "feature")) + settings.addFolderConfig(folderConfig(folderPath = workspacePath, baseBranch = "main")) + settings.addFolderConfig(folderConfig(folderPath = nestedPath1, baseBranch = "develop")) + settings.addFolderConfig(folderConfig(folderPath = nestedPath2, baseBranch = "feature")) val workspaceFolder = WorkspaceFolder().apply { diff --git a/src/test/kotlin/snyk/common/lsp/settings/TestFolderConfigHelper.kt b/src/test/kotlin/snyk/common/lsp/settings/TestFolderConfigHelper.kt new file mode 100644 index 000000000..c891ca744 --- /dev/null +++ b/src/test/kotlin/snyk/common/lsp/settings/TestFolderConfigHelper.kt @@ -0,0 +1,37 @@ +package snyk.common.lsp.settings + +import snyk.common.lsp.ScanCommandConfig + +@Suppress("LongParameterList") +fun folderConfig( + folderPath: String, + baseBranch: String = "main", + localBranches: List? = emptyList(), + additionalParameters: List? = emptyList(), + additionalEnv: String? = "", + referenceFolderPath: String? = "", + preferredOrg: String = "", + autoDeterminedOrg: String = "", + orgSetByUser: Boolean = false, + scanCommandConfig: Map? = emptyMap(), +): LspFolderConfig { + val settings = mutableMapOf() + settings[LsFolderSettingsKeys.BASE_BRANCH] = ConfigSetting(value = baseBranch) + localBranches?.let { settings[LsFolderSettingsKeys.LOCAL_BRANCHES] = ConfigSetting(value = it) } + additionalParameters?.let { + settings[LsFolderSettingsKeys.ADDITIONAL_PARAMETERS] = ConfigSetting(value = it) + } + additionalEnv?.let { + settings[LsFolderSettingsKeys.ADDITIONAL_ENVIRONMENT] = ConfigSetting(value = it) + } + referenceFolderPath?.let { + settings[LsFolderSettingsKeys.REFERENCE_FOLDER] = ConfigSetting(value = it) + } + settings[LsFolderSettingsKeys.PREFERRED_ORG] = ConfigSetting(value = preferredOrg) + settings[LsFolderSettingsKeys.AUTO_DETERMINED_ORG] = ConfigSetting(value = autoDeterminedOrg) + settings[LsFolderSettingsKeys.ORG_SET_BY_USER] = ConfigSetting(value = orgSetByUser) + scanCommandConfig?.let { + settings[LsFolderSettingsKeys.SCAN_COMMAND_CONFIG] = ConfigSetting(value = it) + } + return LspFolderConfig(folderPath = folderPath, settings = settings) +} From ffb520ad1a6a531b5e1c9081745e1ea47b4d93f0 Mon Sep 17 00:00:00 2001 From: Bastian Doetsch Date: Wed, 11 Mar 2026 08:13:22 +0100 Subject: [PATCH 09/11] refactor(lsp): remove dead code from LSP settings [IDE-1639] - Remove unused LanguageServerSettings data class (replaced by the LspConfigurationParam/getSettings() approach with LsSettingsKeys) - Remove unused defaultSettings and trustedFolders variables in getSettings() that were computed but never referenced - Remove unused SnykApplicationSettingsStateService import --- .../snyk/common/lsp/LanguageServerWrapper.kt | 4 -- .../lsp/settings/LanguageServerSettings.kt | 54 ------------------- 2 files changed, 58 deletions(-) diff --git a/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt b/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt index 539046cd7..4897e205c 100644 --- a/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt +++ b/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt @@ -19,7 +19,6 @@ import io.snyk.plugin.getSnykTaskQueueService import io.snyk.plugin.getWaitForResultsTimeout import io.snyk.plugin.pluginSettings import io.snyk.plugin.runInBackground -import io.snyk.plugin.services.SnykApplicationSettingsStateService import io.snyk.plugin.toLanguageServerURI import io.snyk.plugin.ui.SnykBalloonNotificationHelper import io.snyk.plugin.ui.toolwindow.SnykPluginDisposable @@ -537,9 +536,6 @@ class LanguageServerWrapper(private val project: Project) : Disposable { fun getSettings(): LspConfigurationParam { val ps = pluginSettings() - val defaultSettings = SnykApplicationSettingsStateService() - val trustService = service() - val trustedFolders = trustService.settings.getTrustedPaths() val settingsMap = mutableMapOf() diff --git a/src/main/kotlin/snyk/common/lsp/settings/LanguageServerSettings.kt b/src/main/kotlin/snyk/common/lsp/settings/LanguageServerSettings.kt index e19e1e548..ec43e9922 100644 --- a/src/main/kotlin/snyk/common/lsp/settings/LanguageServerSettings.kt +++ b/src/main/kotlin/snyk/common/lsp/settings/LanguageServerSettings.kt @@ -1,60 +1,6 @@ -@file:Suppress("unused") - package snyk.common.lsp.settings import com.google.gson.annotations.SerializedName -import io.snyk.plugin.pluginSettings -import io.snyk.plugin.services.AuthenticationType -import org.apache.commons.lang3.SystemUtils -import snyk.pluginInfo - -data class LanguageServerSettings( - @SerializedName("activateSnykOpenSource") val activateSnykOpenSource: String? = "false", - @SerializedName("activateSnykCode") val activateSnykCode: String? = "false", - @SerializedName("activateSnykIac") val activateSnykIac: String? = "false", - @SerializedName("activateSnykSecrets") val activateSnykSecrets: String? = "false", - @SerializedName("insecure") val insecure: String?, - @SerializedName("endpoint") val endpoint: String?, - @SerializedName("additionalParams") val additionalParams: String? = null, - @SerializedName("additionalEnv") val additionalEnv: String? = null, - @SerializedName("path") val path: String? = null, - @SerializedName("sendErrorReports") val sendErrorReports: String? = "true", - @SerializedName("organization") val organization: String? = null, - @SerializedName("enableTelemetry") val enableTelemetry: String? = "false", - @SerializedName("manageBinariesAutomatically") val manageBinariesAutomatically: String? = "false", - @SerializedName("cliPath") val cliPath: String?, - @SerializedName("cliBaseDownloadURL") val cliBaseDownloadURL: String? = null, - @SerializedName("token") val token: String?, - @SerializedName("integrationName") val integrationName: String? = pluginInfo.integrationName, - @SerializedName("integrationVersion") - val integrationVersion: String? = pluginInfo.integrationVersion, - @SerializedName("automaticAuthentication") val automaticAuthentication: String? = "false", - @SerializedName("deviceId") val deviceId: String? = pluginSettings().userAnonymousId, - @SerializedName("filterSeverity") val filterSeverity: SeverityFilter? = null, - @SerializedName("issueViewOptions") val issueViewOptions: IssueViewOptions? = null, - @SerializedName("enableTrustedFoldersFeature") val enableTrustedFoldersFeature: String? = "false", - @SerializedName("trustedFolders") val trustedFolders: List? = emptyList(), - @SerializedName("activateSnykCodeSecurity") val activateSnykCodeSecurity: String? = "false", - @SerializedName("osPlatform") val osPlatform: String? = SystemUtils.OS_NAME, - @SerializedName("osArch") val osArch: String? = SystemUtils.OS_ARCH, - @SerializedName("runtimeVersion") val runtimeVersion: String? = SystemUtils.JAVA_VERSION, - @SerializedName("runtimeName") val runtimeName: String? = SystemUtils.JAVA_RUNTIME_NAME, - @SerializedName("scanningMode") val scanningMode: String? = null, - @SerializedName("authenticationMethod") - val authenticationMethod: String = AuthenticationType.OAUTH2.languageServerSettingsName, - @SerializedName("snykCodeApi") val snykCodeApi: String? = null, - @SerializedName("enableSnykLearnCodeActions") val enableSnykLearnCodeActions: String? = null, - @SerializedName("enableSnykOSSQuickFixCodeActions") - val enableSnykOSSQuickFixCodeActions: String? = null, - @SerializedName("requiredProtocolVersion") - val requiredProtocolVersion: String = pluginSettings().requiredLsProtocolVersion.toString(), - @SerializedName("hoverVerbosity") val hoverVerbosity: Int = 0, - @SerializedName("outputFormat") val outputFormat: String = "html", - @SerializedName("enableDeltaFindings") - val enableDeltaFindings: String = pluginSettings().isDeltaFindingsEnabled().toString(), - @SerializedName("folderConfigs") val folderConfigs: List = emptyList(), - @SerializedName("riskScoreThreshold") val riskScoreThreshold: Int? = null, -) data class SeverityFilter( @SerializedName("critical") val critical: Boolean?, From e5db7553165a9e10886acb98a5e4d725a67b49e9 Mon Sep 17 00:00:00 2001 From: Bastian Doetsch Date: Wed, 11 Mar 2026 08:14:07 +0100 Subject: [PATCH 10/11] chore: add Claude Code project configuration [IDE-1639] Adapt .cursor rules and skills to Claude Code equivalents: - CLAUDE.md: project rules (adapted from .cursor/rules/general.mdc) - .claude/commands/commit.md: commit workflow skill - .claude/commands/create-implementation-plan.md: planning skill - .claude/commands/verification.md: code verification skill - .claude/commands/implementation.md: implementation workflow skill - .gitignore: exclude .claude/settings.local.json (machine-specific) --- .claude/commands/commit.md | 221 ++++++++++++++++ .../commands/create-implementation-plan.md | 110 ++++++++ .claude/commands/implementation.md | 180 +++++++++++++ .claude/commands/verification.md | 239 ++++++++++++++++++ .gitignore | 3 + CLAUDE.md | 107 ++++++++ 6 files changed, 860 insertions(+) create mode 100644 .claude/commands/commit.md create mode 100644 .claude/commands/create-implementation-plan.md create mode 100644 .claude/commands/implementation.md create mode 100644 .claude/commands/verification.md create mode 100644 CLAUDE.md diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md new file mode 100644 index 000000000..8c58345eb --- /dev/null +++ b/.claude/commands/commit.md @@ -0,0 +1,221 @@ +# Commit Workflow + +Prepare, verify, and commit code changes following project standards. + +## Workflow Overview + +``` +Verify → Fix Issues (TDD) → Pre-commit Checks → Commit → Tests → [Push] +``` + +## Quick Start Checklist + +Copy and track progress: + +``` +Commit Progress: +- [ ] Step 1: Run /verification +- [ ] Step 2: Fix issues using TDD +- [ ] Step 3: Run pre-commit checks +- [ ] Step 4: Create atomic commit +- [ ] Step 5: Run ALL test suites (3 REQUIRED): + - [ ] ./gradlew ktlintCheck spotlessApply + - [ ] ./gradlew koverXMLReport + - [ ] ./gradlew verifyPlugin +- [ ] Step 6: Push (optional, ask first) +``` + +**CRITICAL: Step 5 has THREE mandatory test suites. Skipping any is FORBIDDEN.** + +--- + +## Step 1: Run Verification + +Execute the `/verification` command to analyze code changes: + +``` +Verification Progress: +- [ ] Load project rules and standards +- [ ] Trace code paths for modified files +- [ ] Check for semantic changes +- [ ] Identify code smells +- [ ] Run security scans +- [ ] Review PR feedback (if PR exists) +``` + +**Output**: List of issues found, categorized by severity. + +--- + +## Step 2: Fix Issues Using TDD + +For each issue identified by verification: + +### TDD Gate (MANDATORY) + +Before ANY fix: + +- [ ] Write failing test first +- [ ] Confirm test fails without fix +- [ ] Implement minimal fix +- [ ] Confirm test passes + +**Never skip TDD. Use `/implementation` for complex fixes.** + +### Issue Priority + +1. **Security vulnerabilities** - Fix immediately (except test data) +2. **Breaking changes** - Confirm intentional, add tests +3. **Code smells** - Fix immediately +4. **PR feedback** - Address blockers first + +--- + +## Step 3: Pre-commit Checks + +Run: + +```bash +./gradlew ktlintCheck spotlessApply +./gradlew koverXMLReport +./gradlew verifyPlugin +``` + +### Security Scans + +Get absolute path first: + +```bash +pwd +``` + +Then run: + +1. `snyk_sca_scan` with absolute project path +2. `snyk_code_scan` with absolute project path + +**Fix any security issues** (skip test data false positives). + +--- + +## Step 4: Create Atomic Commit + +### Pre-commit Verification + +``` +- [ ] Linting clean (./gradlew ktlintCheck spotlessApply) +- [ ] Security scans clean +- [ ] No implementation plan files staged +- [ ] Documentation updated (if needed) +``` + +### Commit Format + +``` +type(scope): description [XXX-XXXX] + +Body explaining what and why. +``` + +**Types**: feat, fix, refactor, test, docs, chore, perf + +**Extract issue ID from branch:** + +```bash +git branch --show-current +``` + +### Staged Files Check + +```bash +git status +git diff --staged +``` + +**Never commit**: + +- Implementation plan files (`*_implementation_plan/`) +- Secrets or credentials +- Generated diagram source (commit PNGs only if needed) + +### Execute Commit + +```bash +git add +git commit -m "$(cat <<'EOF' +type(scope): description [XXX-XXXX] + +Body explaining what and why. +EOF +)" +``` + +**NEVER use --no-verify. NEVER amend commits.** + +--- + +## Step 5: Run All Test Suites (After Commit, Before Push) + +**CRITICAL: ALL three test suites MUST be executed after commit but before push. Skipping ANY is FORBIDDEN.** + +Run in order: + +```bash +./gradlew ktlintCheck spotlessApply +./gradlew koverXMLReport +./gradlew verifyPlugin +``` + +If ANY test suite fails: + +1. Do not proceed to push +2. Identify root cause +3. Apply TDD fix (test first, then implementation) +4. Create new commit with fix +5. Re-run ALL test suites + +--- + +## Step 6: Push (Optional) + +**Always ask before pushing.** + +If approved: + +```bash +git push --set-upstream origin $(git branch --show-current) +``` + +### After Push + +Offer to: + +1. Create draft PR (if none exists) +2. Update PR description (if PR exists) +3. Check snyk-pr-review-bot comments + +--- + +## Command Reference + +| Task | Command | +| ------------------- |----------------------------------------------------| +| Format & lint | `./gradlew ktlintCheck spotlessApply` | +| Unit tests | `./gradlew test` | +| Integration tests | `./gradlew verifyPlugin` | +| SCA scan | `snyk_sca_scan` with absolute path | +| Code scan | `snyk_code_scan` with absolute path | +| Current branch | `git branch --show-current` | +| Push | `git push --set-upstream origin $(git branch ...)` | + +--- + +## Red Flags (STOP) + +- [ ] Tests failing +- [ ] **Any test suite skipped** +- [ ] Security vulnerabilities unfixed +- [ ] Implementation plan files staged +- [ ] Unresolved PR blockers +- [ ] TDD not followed for fixes +- [ ] --no-verify being considered diff --git a/.claude/commands/create-implementation-plan.md b/.claude/commands/create-implementation-plan.md new file mode 100644 index 000000000..7fb8c6e2a --- /dev/null +++ b/.claude/commands/create-implementation-plan.md @@ -0,0 +1,110 @@ +# Create Implementation Plan + +Creates implementation plans using the official project template with session hand-off support, TDD workflow, and progress tracking. + +## Quick Start + +1. Extract issue ID from branch: `git branch --show-current` (format: `XXX-XXXX`) +2. Read Jira issue for context and acceptance criteria +3. Create plan file: `${issueID}_implementation_plan.md` (root directory) +4. Create `tests.json` for test scenario tracking +5. Create mermaid diagrams in `docs/diagrams/` +6. **STOP and wait for user confirmation** + +## Template Location + +Use template from: `.github/IMPLEMENTATION_PLAN_TEMPLATE.md` + +Replace all `{{TICKET_ID}}` and `{{TICKET_TITLE}}` placeholders with actual values. + +## Files to Create + +| File | Location | Purpose | +| ------------------- | ----------------------------------- | ------------------------------------ | +| Implementation plan | `${issueID}_implementation_plan.md` | Main plan document | +| Test tracking | `tests.json` | Track test scenarios across sessions | +| Flow diagrams | `docs/diagrams/${issueID}_*.mmd` | Mermaid source files | + +**All these files are gitignored - NEVER commit them.** + +## Key Sections to Complete + +### 1. SESSION RESUME (Critical for hand-off) + +- Update Quick Context with ticket info and branch +- Fill Current State table +- List Next Actions +- Update Current Working Files table + +### 2. Phase 1: Planning + +- **1.1 Requirements Analysis**: List changes, error handling, files to modify/create +- **1.2 Schema/Architecture Design**: Add schemas, data structures +- **1.3 Flow Diagrams**: Create mermaid files, generate PNGs + +### 3. Phase 2: Implementation (Outside-in TDD) + +- **CRITICAL: use outside-in TDD** +- Enforce strict test order: + 1. Smoke tests (E2E behavior) + 2. Integration tests (cross operating system behaviour, integrative behaviour) + 3. Unit tests +- Break into checkpoint steps (completable in one session) +- Each step: tasks, tests to write FIRST, commit message +- Reference test IDs from `tests.json` +- Add a plan self-check: integration tests must appear before unit tests + +### 4. Phase 3: Review + +- Code review prep checklist +- Documentation updates +- Pre-commit checks + +### 5. Progress Tracking + +- Update status table at end of each session +- Add entry to Session Log + +## tests.json Structure + +```json +{ + "ticket": "IDE-XXXX", + "description": "Ticket title", + "lastUpdated": "YYYY-MM-DD", + "lastSession": { + "date": "YYYY-MM-DD", + "sessionNumber": 1, + "completedSteps": [], + "currentStep": "1.1 Requirements Analysis", + "nextStep": "1.2 Schema Design" + }, + "testSuites": { + "unit": {}, + "integration": { "scenarios": [] }, + "regression": { "scenarios": [] } + } +} +``` + +## Diagram Creation + +1. Create: `docs/diagrams/${issueID}_description.mmd` +2. Reference PNG in plan: `![Name](docs/diagrams/${issueID}_description.png)` + +## Critical Rules + +- **NEVER commit** implementation plan, tests.json, or plan diagrams +- **WAIT for confirmation** after creating the plan before implementing +- **Use Outside-in TDD** - write tests FIRST +- **Update progress** at end of EVERY session (hand-off support) +- **Update Jira** with progress comments +- **Sync** plan changes to Jira ticket description and Confluence (if applicable) + +## Workflow Integration + +This command is called by `/implementation` when no plan exists. After creating the plan: + +1. Present plan summary to user +2. Wait for confirmation +3. `/implementation` continues with Phase 2 (Implementation) diff --git a/.claude/commands/implementation.md b/.claude/commands/implementation.md new file mode 100644 index 000000000..5752e4f79 --- /dev/null +++ b/.claude/commands/implementation.md @@ -0,0 +1,180 @@ +# Start Implementation Task + +## Workflow Overview + +``` +Check Plan → [Create if missing] → TEST FIRST → Implement → Test & Lint → Commit → Session Hand-off +``` + +**TDD is NON-NEGOTIABLE**: Every code change requires a failing test BEFORE implementation. + +## Phase 1: Initialize + +### 1.1 Get Issue Context + +```bash +git branch --show-current +``` + +The `issueID` follows format `XXX-XXXX` (e.g., `IDE-1718`). + +### 1.2 Check for Implementation Plan + +Look for: `${issueID}_implementation_plan/${issueID}_implementation_plan.md` + +**If plan exists:** Read it, note current progress, continue from last checkpoint. + +**If no plan:** Use `/create-implementation-plan` to create one. + +--- + +## Phase 2: Planning + +### Analysis + +- **Files to modify:** [list files] +- **Files to create:** [list files] +- **Packages affected:** [list packages] + +### Flow Diagrams + +See: `docs/diagrams/${issueID}_*.png` + +--- + +## Phase 3: Implementation (TDD) + +### CRITICAL: TDD is MANDATORY + +**NEVER write production code before writing a failing test.** + +This applies to: +- New features +- Bug fixes +- Security fixes +- Refactoring +- ANY code change + +### TDD Gate Check + +Before writing ANY production code, verify: + +- [ ] **Test exists?** Have I written a test for this change? +- [ ] **Test fails?** Does the test fail without my change? +- [ ] **Test is specific?** Does the test target the exact behavior I'm changing? + +**If ANY answer is NO → STOP and write the test first.** + +### TDD Cycle + +For each feature/change: + +1. **STOP** - Do not touch production code yet +2. **Write failing test first** (outside-in: start with integration/smoke tests, then unit tests) +3. **Run test** - confirm it fails for the right reason +4. **Write minimal code** to make `./gradlew test` pass +5. **Run test** - confirm it passes +6. **Refactor** if needed (tests must still pass) + +### Test Order (Outside-In) + +1. Smoke tests (E2E behavior) +2. Integration tests +3. Unit tests + +### Commands + +```bash +# Run unit tests +./gradlew test + +# Run smoke tests / integration tests +./gradlew verifyPlugin + +# Format and lint +./gradlew ktlintCheck spotlessApply +``` + +### Progress Updates + +Before each step: + +```markdown +| Step 1 | **in-progress** | Started [time] | +``` + +After each step: + +```markdown +| Step 1 | **completed** | Finished [time] | +``` + +--- + +## Phase 4: Finalize + +### 4.1 Run All Tests + +```bash +./gradlew test +./gradlew verifyPlugin +``` + +All tests must pass. Fix any failures before proceeding. + +### 4.2 Lint + +```bash +./gradlew ktlintCheck spotlessApply +``` + +Zero linting errors required. + +### 4.3 Security Scan + +Run before commit: + +- `snyk_code_scan` with absolute project path +- `snyk_sca_scan` with absolute project path + +Fix any security issues (except in test data). + +### 4.4 Generate & Update Docs + +Update documentation in `./docs` as needed. + +### 4.5 Commit + +Pre-commit checklist: + +- [ ] Tests pass (pre-existing issues MUST be fixed) +- [ ] Linting clean +- [ ] Security scans clean +- [ ] Docs updated + +Commit format: + +``` +type(scope): description [XXX-XXXX] + +Body explaining what and why. +``` + +**Never skip hooks. Never use --no-verify.** + +--- + +## Phase 5: Session Hand-off + +Update implementation plan with session summary according to the plan session handoff section. + +--- + +## Quick Reference + +| Action | Command | +| ----------------- | ------------------------------------- | +| Unit tests | `./gradlew test` | +| Smoke tests | `./gradlew verifyPlugin` | +| Format & lint | `./gradlew ktlintCheck spotlessApply` | +| Coverage | `./gradlew koverXmlReport` | diff --git a/.claude/commands/verification.md b/.claude/commands/verification.md new file mode 100644 index 000000000..7b6c8c0d6 --- /dev/null +++ b/.claude/commands/verification.md @@ -0,0 +1,239 @@ +# Code Verification + +Verify generated code in depth before committing. This command complements the pre-commit checklist by adding semantic analysis. + +## When to Use + +- Before committing implementation changes +- After completing implementation steps +- When PR review feedback needs to be addressed +- When explicitly asked to verify code +- When starting a new session of an implementation plan + +## Verification Workflow + +Copy this checklist and track progress: + +``` +Verification Progress: +- [ ] Step 1: Load project rules and standards +- [ ] Step 2: Trace code paths for modified files +- [ ] Step 3: Check for semantic changes +- [ ] Step 4: Identify code smells +- [ ] Step 5: Run security scans +- [ ] Step 6: Review PR feedback (if PR exists) +- [ ] Step 7: Get check results from github with gh cli +- [ ] Step 8: Update implementation plan with findings +- [ ] Step 9: Fix issues (TDD REQUIRED - test first, then fix) +- [ ] Step 10: Check coverage of changed files > 80% +- [ ] Step 11: Add tests if coverage not sufficient +- [ ] Step 12: Commit changes +``` + +--- + +## Step 1: Load Project Rules + +Read and apply these project standards: + +1. `CLAUDE.md` - critical rules and workflow +2. `.github/CONTRIBUTING.md` - coding standards + +Key rules to verify against: + +- Outside-in TDD followed +- Minimum necessary changes +- No workarounds or commented-out code +- mockk used for mocking (no custom mocks) + +--- + +## Step 2: Trace Code Paths + +For each modified file, trace the execution flow: + +1. **Identify entry points**: API handlers, public functions, exported methods +2. **Follow the call chain**: Map function calls through the codebase +3. **Verify dependencies**: Check that all called functions exist and have correct signatures +4. **Check return paths**: Ensure all code paths return appropriate values/errors + +### Verification Questions + +- Does the new code integrate correctly with existing callers? +- Are all error cases handled? +- Do interface implementations satisfy their contracts? +- Are there unreachable code paths? + +--- + +## Step 3: Check for Semantic Changes + +Detect unintended behavioral changes: + +### Breaking Changes + +- Modified function signatures +- Changed return types or error conditions +- Altered class/data class field types +- Modified interface definitions + +### Behavioral Changes + +- Different error messages (may break client parsing) +- Changed response structure +- Modified validation logic +- Altered default values + +### API Impact + +- API contract changes requiring versioning +- Behavior changes requiring documentation updates + +**Action**: Flag any semantic changes and ask if they are intentional. + +--- + +## Step 4: Identify Code Smells + +Check for these patterns: + +### Structural Smells + +- [ ] Functions longer than 50 lines +- [ ] Deeply nested conditionals (>3 levels) +- [ ] Duplicate code blocks +- [ ] God objects/functions doing too much +- [ ] Long parameter lists (>5 params) + +### Kotlin-Specific Smells + +- [ ] Ignored exceptions (empty catch blocks) +- [ ] Non-null assertions (`!!`) without justification +- [ ] Mutable state shared across threads without synchronization +- [ ] Memory leaks (listeners not unregistered, resources not closed) + +### Design Smells + +- [ ] Circular dependencies between packages +- [ ] Leaky abstractions (implementation details exposed) +- [ ] Inappropriate intimacy (packages knowing too much about each other) +- [ ] Feature envy (functions using other package's data excessively) + +### Copy-Paste Code (Refactoring Candidates) + +Identify duplicated code that should be extracted: + +- [ ] Similar code blocks across multiple files +- [ ] Repeated struct/data class transformations +- [ ] Duplicated validation logic +- [ ] Repeated error handling patterns +- [ ] Similar test setup code + +**Action**: For each smell found, propose a specific fix or flag for discussion. + +--- + +## Step 5: Run Security Scans + +Execute security checks using Snyk: + +```bash +pwd +``` + +Then run: + +1. `snyk_sca_scan` - dependency vulnerabilities +2. `snyk_code_scan` - code security issues + +### Manual Security Checklist + +- [ ] No hardcoded secrets, tokens, or credentials +- [ ] Input validation on all external data +- [ ] No path traversal vulnerabilities +- [ ] Proper authentication/authorization checks +- [ ] Sensitive data not logged +- [ ] HTTPS/TLS used for external calls + +**Action**: Fix security issues. If in test data, note but don't fix. + +--- + +## Step 6: Review PR Feedback + +If a GitHub PR exists for the current branch: + +```bash +gh pr view --json number,reviews,comments,url 2>/dev/null +``` + +For each review comment: + +1. **Categorize**: Bug | Enhancement | Style | Question | Blocker +2. **Assess**: Is this actionable? Does it require a decision? +3. **Prioritize**: Critical (must fix) | Should fix | Nice to have + +--- + +## Step 7: Update Implementation Plan + +Add verification findings to the implementation plan: + +```markdown +## Verification Results + +### Code Path Analysis + +- [List traced paths and any issues found] + +### Semantic Changes + +- [List any behavioral changes detected] + +### Code Smells + +- [List smells found with proposed fixes] + +### Security Findings + +- [List security issues and resolutions] + +### PR Feedback Items + +- [List items requiring decisions] +``` + +--- + +## Step 9: Fix Issues (TDD Required) + +**CRITICAL: ALL fixes MUST follow TDD. NEVER implement a fix without writing a failing test first.** + +When verification identifies issues to fix: + +1. Write a test that exposes the issue +2. Run the test - confirm it FAILS +3. Apply the minimum change to make the test pass +4. Run the test - confirm it PASSES +5. Run all test suites to verify no regressions + +--- + +## Quick Reference + +### Commands + +| Task | Command | +| --------- | ------------------------------------------- | +| Check PR | `gh pr view --json number,reviews,comments` | +| SCA scan | `snyk_sca_scan` with absolute path | +| Code scan | `snyk_code_scan` with absolute path | +| Run tests | `./gradlew test` | + +### Red Flags (Stop and Discuss) + +- Breaking API changes without versioning +- Security vulnerabilities in non-test code +- Significant behavioral changes +- Unresolved PR blockers +- Significant code duplication (>20 lines copied) diff --git a/.gitignore b/.gitignore index 63823cb45..722c9af99 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ Thumbs.db .cursor/rules/snyk_rules.mdc docs/performance-analysis.md IDE-* + +# Claude Code local settings (machine-specific, not shared) +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..1346ff560 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,107 @@ +# Project Rules + +## General + +- Always be concise, direct and don't try to appease me. +- Use .github/CONTRIBUTING.md and the links in there to find standards and contributing guidelines. +- DOUBLE CHECK THAT YOUR CHANGES ARE REALLY NEEDED. ALWAYS STICK TO THE GIVEN GOAL, NOT MORE. +- Don't optimize, don't refactor if not needed. +- Adhere to the rules, fix linting & test issues that are newly introduced. +- The `issueID` is usually specified in the current branch in the format `XXX-XXXX`. +- Read the issue description and acceptance criteria from jira (the manually given prompt takes precedence). + +## Process + +- Always create an implementation plan and save it to the directory under `${issueID}_implementation_plan` but never commit it. +- You will find a template for an implementation plan in `.github`. +- The implementation plan should have the phases: planning, implementation (including testing through TDD), review. +- Get confirmation that the implementation plan is ok. Wait until you get it. +- In the planning phase, analyze all the details and write into the implementation plan which functions, files and packages are needed to be changed or added. +- Be detailed: add steps to the phases and prepare a tracking section with checkboxes for progress tracking of each detailed step. +- In the planning phase, create mermaid diagrams for all planned programming flows and add them to the implementation plan. +- Use the same name for the diagrams as the implementation plan, but with the right extension (.mmd), so that they are ignored via .gitignore. +- Generate the implementation plan diagrams by putting the mermaid files into docs/diagrams and generate the pngs via mmdc with `-w 2048px` and add the flows to the implementation plan. +- Never commit the diagrams generated for the implementation plan. + +## Coding Guidelines + +- Follow the implementation plan step-by-step, phase-by-phase. Take it as a reference for each step and how to proceed. +- Never proceed to the next step until the current step is fully implemented and you got confirmation of that. +- Never jump a step. Always follow the plan. +- Use atomic commits. +- Update progress of the step before starting with a step and when ending. +- Update the jira ticket with the current status & progress (comment). +- USE TDD. +- I REPEAT: USE TDD. +- Always write and update test cases before writing the implementation (Test Driven Development). Iterate until they pass. +- After changing .kt or .java files, run `./gradlew spotlessCheck ktlintCheck` to check formatting and lint. Only continue once they pass. +- Always verify if fixes worked by running `./gradlew test`. +- Do atomic commits, see committing section for details. Ask before committing an atomic commit. +- Update current status in the implementation plan (in progress work, finished work, next steps). +- Maintain existing code patterns and conventions. +- Use mockk to mock. Writing your own mocks is forbidden if mockk can be used. +- Re-use mocks. +- Don't change code that does not need to be changed. Only do the minimum changes. +- Don't comment what is done, instead comment why something is done if the code is not clear. +- Use `./gradlew test` to run tests. +- Achieve 80% of test coverage. Use `./gradlew koverXmlReport`. +- If files are not used or needed anymore, delete them instead of deprecating them. +- Ask the human whether to maintain backwards compatibility or not. +- If a tool call fails, analyze why it failed and correct your approach. Don't prompt the user for help. +- If you don't know something, read the code instead of assuming it. +- Commenting out code to fix errors is NEVER a solution. Fix the error. +- Disabling or removing tests IS NOT ALLOWED. This can only be done manually by a human. +- Disabling linters is not allowed unless the human EXPLICITLY allows it for that single instance. +- Don't do workarounds. +- ALWAYS create production-ready code. We don't want examples, we want working, production-ready code. + +## Security + +- Determine the absolute path of the project directory (e.g., by executing `pwd`). +- Pass the absolute path of the project directory as a parameter to snyk_sca_scan and snyk_code_scan. +- Run snyk_sca_scan after updating gradle.build.kts. +- Run snyk_sca_scan and snyk_code_scan before committing. If not test data, fix issues before committing. +- Fix security issues if they are fixable. Take the snyk scan results and the test results as input. +- Don't fix test data. + +## Committing + +- NEVER commit implementation plan and implementation plan diagrams. +- NEVER amend commits, keep a history so we can revert atomic commits. +- NEVER NEVER NEVER skip the commit hooks. +- I REPEAT: NEVER USE --no-verify. DO NOT DO IT. NEVER. THIS IS CRITICAL, DO NOT DO IT. +- Run `./gradlew test` before committing and fix the issues. Don't run targeted tests, run the full suite (which may take >10min). +- Test failures prevent committing, regardless if caused by our changes. They MUST be fixed, even if they existed before. +- Deactivating tests is NEVER ALLOWED. +- Check with Kover (`./gradlew koverXmlReport`) that coverage of changed files is 80%+. +- Update the documentation before committing. +- When asked to commit, always use conventional commit messages (Conventional Commit Style: Subject + Body). Be descriptive in the body. If you find a JIRA issue (XXX-XXXX) in the branch name, use it as a postfix to the subject line in the format `[XXX-XXXX]`. +- Consider all commits in the current branch when committing, to have the context of the current changes. + +## Pushing + +- Before pushing, run `./gradlew verifyPlugin`. +- Never push without asking every single time. +- Never force push. +- When asked to push, always use `git push --set-upstream origin `. +- Regularly fetch main branch and offer to merge it into the current branch. +- After pushing offer to create a PR on github if no PR already exists. Analyze the changes by comparing the current branch with origin/main, and craft a PR description and title. +- Use the github template in `.github/PULL_REQUEST_TEMPLATE.md`. + +## PR Creation + +- Use `gh` command line util for PR creation. +- Use the template in `.github`. +- Always create draft PRs. +- Update the github PR description with the current status using `gh` command line util. +- Use the diff between the current branch and main to generate the description and title. +- Respect the PR template. +- Get the PR review comments, analyse them and propose fixes for them. Check before each commit. + +## Documenting + +- Always keep the documentation up-to-date in `./docs`. +- Don't create summary mds unless asked. +- Create mermaid syntax for all programming flows and add it to the documentation in `./docs`. +- Create png files from the mermaid diagrams using mmdc with `-w 2048px` for high resolution. +- Document the tested scenarios for all testing stages (unit, integration, e2e) in `./docs`. From c9a3d42fafee30d61c549455ee31ba91e1d69910 Mon Sep 17 00:00:00 2001 From: Bastian Doetsch Date: Wed, 11 Mar 2026 16:27:42 +0100 Subject: [PATCH 11/11] fix: mark automaticAuthentication, trustEnabled, and token as user-overrides; remove dead env info settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set changed=true for automaticAuthentication and trustEnabled so they are always transmitted as user-overrides to the language server - Set changed=true for token (always a user-override when present) - Remove environment info entries from settings map (integration_name, integration_version, device_id, os_platform, os_arch, runtime_name, runtime_version) — snyk-ls reads these from top-level InitializationOptions fields, not from the settings map --- .../snyk/common/lsp/LanguageServerWrapper.kt | 27 +++---------------- .../common/lsp/LanguageServerWrapperTest.kt | 13 +++++++++ 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt b/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt index 4897e205c..957e451b2 100644 --- a/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt +++ b/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt @@ -608,12 +608,11 @@ class LanguageServerWrapper(private val project: Project) : Disposable { } if (!ps.token.isNullOrBlank()) { - settingsMap[LsSettingsKeys.TOKEN] = - ConfigSetting(value = ps.token!!, changed = ps.isExplicitlyChanged(LsSettingsKeys.TOKEN)) + settingsMap[LsSettingsKeys.TOKEN] = ConfigSetting(value = ps.token!!, changed = true) } settingsMap[LsSettingsKeys.AUTOMATIC_AUTHENTICATION] = - ConfigSetting(value = false, changed = false) + ConfigSetting(value = false, changed = true) // filters val severityFilter = @@ -648,7 +647,7 @@ class LanguageServerWrapper(private val project: Project) : Disposable { changed = ps.isExplicitlyChanged(LsSettingsKeys.ISSUE_VIEW_IGNORED_ISSUES), ) - settingsMap[LsSettingsKeys.TRUST_ENABLED] = ConfigSetting(value = false, changed = false) + settingsMap[LsSettingsKeys.TRUST_ENABLED] = ConfigSetting(value = false, changed = true) settingsMap[LsSettingsKeys.SCAN_AUTOMATIC] = ConfigSetting( value = ps.scanOnSave, @@ -667,26 +666,6 @@ class LanguageServerWrapper(private val project: Project) : Disposable { changed = ps.isExplicitlyChanged(LsSettingsKeys.SCAN_NET_NEW), ) - // Pass environment information in settings - settingsMap[LsSettingsKeys.INTEGRATION_NAME] = - ConfigSetting(value = pluginInfo.integrationName, changed = false) - settingsMap[LsSettingsKeys.INTEGRATION_VERSION] = - ConfigSetting(value = pluginInfo.integrationVersion, changed = false) - settingsMap[LsSettingsKeys.INTEGRATION_ENVIRONMENT] = - ConfigSetting(value = pluginInfo.integrationEnvironment, changed = false) - settingsMap[LsSettingsKeys.INTEGRATION_ENVIRONMENT_VERSION] = - ConfigSetting(value = pluginInfo.integrationEnvironmentVersion, changed = false) - settingsMap[LsSettingsKeys.DEVICE_ID] = - ConfigSetting(value = ps.userAnonymousId, changed = false) - settingsMap[LsSettingsKeys.OS_PLATFORM] = - ConfigSetting(value = SystemUtils.OS_NAME, changed = false) - settingsMap[LsSettingsKeys.OS_ARCH] = - ConfigSetting(value = SystemUtils.OS_ARCH, changed = false) - settingsMap[LsSettingsKeys.RUNTIME_NAME] = - ConfigSetting(value = SystemUtils.JAVA_RUNTIME_NAME, changed = false) - settingsMap[LsSettingsKeys.RUNTIME_VERSION] = - ConfigSetting(value = SystemUtils.JAVA_VERSION, changed = false) - val folderConfigsList = configuredWorkspaceFolders .filter { diff --git a/src/test/kotlin/snyk/common/lsp/LanguageServerWrapperTest.kt b/src/test/kotlin/snyk/common/lsp/LanguageServerWrapperTest.kt index b94e38255..7b2003cd8 100644 --- a/src/test/kotlin/snyk/common/lsp/LanguageServerWrapperTest.kt +++ b/src/test/kotlin/snyk/common/lsp/LanguageServerWrapperTest.kt @@ -448,6 +448,7 @@ class LanguageServerWrapperTest { assertEquals(settings.iacScanEnabled, actual.settings?.get("snyk_iac_enabled")?.value) assertEquals(settings.ossScanEnable, actual.settings?.get("snyk_oss_enabled")?.value) assertEquals(settings.token, actual.settings?.get("token")?.value) + assertEquals(true, actual.settings?.get("token")?.changed) assertEquals(settings.ignoreUnknownCA, actual.settings?.get("proxy_insecure")?.value) assertEquals(getCliFile().absolutePath, actual.settings?.get("cli_path")?.value) assertEquals(settings.organization, actual.settings?.get("organization")?.value) @@ -455,6 +456,18 @@ class LanguageServerWrapperTest { assertEquals(expectedTrustedFolders, actual.trustedFolders) } + @Test + fun `getSettings should always mark token as changed`() { + settings.token = "persistedToken" + // token is NOT explicitly changed - simulates loading from persisted settings + assertFalse(settings.isExplicitlyChanged("token")) + + val actual = cut.getSettings() + + assertEquals("persistedToken", actual.settings?.get("token")?.value) + assertEquals(true, actual.settings?.get("token")?.changed) + } + @Test fun `getSettings should include manageBinariesAutomatically when true`() { settings.manageBinariesAutomatically = true