From 7c5801a63f8e072571382f14e7a681d418552f85 Mon Sep 17 00:00:00 2001 From: Bastian Doetsch Date: Sun, 7 Jun 2026 11:47:54 +0000 Subject: [PATCH] feat(codeaction): expose stable findingId and code action kind [IDE-2052] Add FindingId to ScanIssue (Diagnostic.data) sourced from issue.GetFindingId(). Unlike the existing Id field (per-result-set key), FindingId is stable across separate scan invocations, enabling clients to correlate the same underlying finding over time without relying on mutable string matching. Add Kind (LSP CodeActionKind) to the CodeAction interface and domain struct. The converter derives kind from action.GetKind(), falling back to QuickFix for all existing actions (zero-value Kind field). Add RemediationAgentQuickFix constant ("quickfix.snyk.remediationAgent") so clients can machine-match on this kind rather than the localised action title. IaC issues emit empty FindingId until the IaC scanner is updated to set it; this gap is documented by a dedicated test. --- domain/ide/converter/converter.go | 10 +- domain/ide/converter/converter_test.go | 172 +++++++++++++++++++++++++ domain/snyk/codeaction.go | 9 ++ internal/types/issues.go | 1 + internal/types/lsp.go | 10 +- 5 files changed, 200 insertions(+), 2 deletions(-) diff --git a/domain/ide/converter/converter.go b/domain/ide/converter/converter.go index 96e30aa26..3d22760a3 100644 --- a/domain/ide/converter/converter.go +++ b/domain/ide/converter/converter.go @@ -72,9 +72,13 @@ func ToCodeAction(issue types.Issue, action types.CodeAction) types.LSPCodeActio i := types.CodeActionData(*action.GetUuid()) id = &i } + kind := action.GetKind() + if kind == types.Empty { + kind = types.QuickFix + } return types.LSPCodeAction{ Title: action.GetTitle(), - Kind: types.QuickFix, + Kind: kind, Diagnostics: ToDiagnostics([]types.Issue{issue}), IsPreferred: action.GetIsPreferred(), Edit: ToWorkspaceEdit(action.GetEdit()), @@ -236,6 +240,7 @@ func getOssIssue(issue types.Issue) types.ScanIssue { scanIssue := types.ScanIssue{ Id: additionalData.Key, + FindingId: issue.GetFindingId(), Title: additionalData.Title, Severity: issue.GetSeverity().String(), FilePath: issue.GetAffectedFilePath(), @@ -287,6 +292,7 @@ func getIacIssue(issue types.Issue) types.ScanIssue { scanIssue := types.ScanIssue{ Id: additionalData.Key, + FindingId: issue.GetFindingId(), Title: additionalData.Title, Severity: issue.GetSeverity().String(), FilePath: issue.GetAffectedFilePath(), @@ -348,6 +354,7 @@ func getCodeIssue(issue types.Issue) types.ScanIssue { scanIssue := types.ScanIssue{ Id: additionalData.Key, + FindingId: issue.GetFindingId(), Title: issue.GetMessage(), Severity: issue.GetSeverity().String(), FilePath: issue.GetAffectedFilePath(), @@ -396,6 +403,7 @@ func getSecretIssue(issue types.Issue) types.ScanIssue { scanIssue := types.ScanIssue{ Id: additionalData.Key, + FindingId: issue.GetFindingId(), Title: additionalData.Title, Severity: issue.GetSeverity().String(), FilePath: issue.GetAffectedFilePath(), diff --git a/domain/ide/converter/converter_test.go b/domain/ide/converter/converter_test.go index 6e924526c..f7e1e2908 100644 --- a/domain/ide/converter/converter_test.go +++ b/domain/ide/converter/converter_test.go @@ -288,3 +288,175 @@ func TestGetCvssCalculatorUrl(t *testing.T) { assert.Equal(t, expected, url) }) } + +// TestToDiagnostics_FindingId verifies that ScanIssue.FindingId is populated from issue.GetFindingId() +// for each product. A stable FindingId allows clients to correlate the same underlying finding across +// separate scan invocations without relying on the per-result-set Id field. +func TestToDiagnostics_FindingId_Code(t *testing.T) { + testutil.UnitTest(t) + + const expectedFindingId = "snyk-asset-finding-v1-abc123" + testIssue := &snyk.Issue{ + ID: "code-rule-id", + Severity: types.High, + Product: product.ProductCode, + FindingId: expectedFindingId, + AdditionalData: snyk.CodeIssueData{ + Key: "code-key-1", + }, + } + + diagnostics := ToDiagnostics([]types.Issue{testIssue}) + + require.Len(t, diagnostics, 1) + assert.Equal(t, expectedFindingId, diagnostics[0].Data.FindingId, + "ScanIssue.FindingId must equal issue.GetFindingId() for Code issues") +} + +func TestToDiagnostics_FindingId_OSS(t *testing.T) { + testutil.UnitTest(t) + + const expectedFindingId = "oss-introducing-finding-id-xyz" + testIssue := &snyk.Issue{ + ID: "oss-vuln-id", + Severity: types.Medium, + Product: product.ProductOpenSource, + FindingId: expectedFindingId, + AdditionalData: snyk.OssIssueData{ + Key: "oss-key-1", + }, + } + + diagnostics := ToDiagnostics([]types.Issue{testIssue}) + + require.Len(t, diagnostics, 1) + assert.Equal(t, expectedFindingId, diagnostics[0].Data.FindingId, + "ScanIssue.FindingId must equal issue.GetFindingId() for OSS issues") +} + +func TestToDiagnostics_FindingId_Secrets(t *testing.T) { + testutil.UnitTest(t) + + const expectedFindingId = "secret-attrs-key-99" + testIssue := &snyk.Issue{ + ID: "secret-rule-id", + Severity: types.High, + Product: product.ProductSecrets, + FindingId: expectedFindingId, + AdditionalData: snyk.SecretsIssueData{ + Key: "secret-key-99", + Title: "Hardcoded Secret", + }, + } + + diagnostics := ToDiagnostics([]types.Issue{testIssue}) + + require.Len(t, diagnostics, 1) + assert.Equal(t, expectedFindingId, diagnostics[0].Data.FindingId, + "ScanIssue.FindingId must equal issue.GetFindingId() for Secrets issues") +} + +// TestToDiagnostics_FindingId_IaC documents that IaC findings currently emit an empty FindingId. +// The IaC scanner does not yet set FindingId on snyk.Issue, so ScanIssue.FindingId is always "". +// When the IaC scanner is updated to set FindingId, this test should be updated to assert the +// expected non-empty value. +func TestToDiagnostics_FindingId_IaC(t *testing.T) { + testutil.UnitTest(t) + + testIssue := &snyk.Issue{ + ID: "iac-rule-id", + Severity: types.High, + Product: product.ProductInfrastructureAsCode, + // FindingId is intentionally not set: the IaC scanner does not yet populate it. + AdditionalData: snyk.IaCIssueData{ + Key: "iac-key-1", + Title: "IaC misconfiguration", + }, + } + + diagnostics := ToDiagnostics([]types.Issue{testIssue}) + + require.Len(t, diagnostics, 1) + assert.Empty(t, diagnostics[0].Data.FindingId, + "IaC issues emit empty FindingId until the IaC scanner populates it") +} + +// TestToDiagnostics_FindingId_DeterministicConversion verifies that ToDiagnostics is a pure +// function: two calls on the same issue struct produce identical FindingId values. +func TestToDiagnostics_FindingId_DeterministicConversion(t *testing.T) { + testutil.UnitTest(t) + + const stableFindingId = "stable-code-fingerprint-v1" + testIssue := &snyk.Issue{ + ID: "code-rule-id", + Severity: types.High, + Product: product.ProductCode, + FindingId: stableFindingId, + AdditionalData: snyk.CodeIssueData{ + Key: "code-key-stable", + }, + } + + diagnostics1 := ToDiagnostics([]types.Issue{testIssue}) + diagnostics2 := ToDiagnostics([]types.Issue{testIssue}) + + require.Len(t, diagnostics1, 1) + require.Len(t, diagnostics2, 1) + assert.Equal(t, diagnostics1[0].Data.FindingId, diagnostics2[0].Data.FindingId, + "FindingId must be identical across two separate conversions of the same finding") + assert.Equal(t, stableFindingId, diagnostics1[0].Data.FindingId, + "FindingId must match the value from issue.GetFindingId()") +} + +func TestToCodeAction_KindDerived_RemediationAgent(t *testing.T) { + testutil.UnitTest(t) + + issue := &snyk.Issue{} + action := &snyk.CodeAction{ + Title: "Fix with Snyk Remediation Agent", + OriginalTitle: "Fix with Snyk Remediation Agent", + Kind: types.RemediationAgentQuickFix, + Command: nil, + Edit: &types.WorkspaceEdit{Changes: map[string][]types.TextEdit{}}, + } + issue.CodeActions = []types.CodeAction{action} + + result := ToCodeAction(issue, action) + + assert.Equal(t, types.RemediationAgentQuickFix, result.Kind) +} + +func TestToCodeAction_KindDerived_Empty_FallsBackToQuickFix(t *testing.T) { + testutil.UnitTest(t) + + issue := &snyk.Issue{} + action := &snyk.CodeAction{ + Title: "Some action", + OriginalTitle: "Some action", + Kind: types.Empty, + Command: nil, + Edit: &types.WorkspaceEdit{Changes: map[string][]types.TextEdit{}}, + } + issue.CodeActions = []types.CodeAction{action} + + result := ToCodeAction(issue, action) + + assert.Equal(t, types.QuickFix, result.Kind) +} + +func TestToCodeAction_KindDerived_ExistingQuickfix_NoRegression(t *testing.T) { + testutil.UnitTest(t) + + issue := &snyk.Issue{} + action := &snyk.CodeAction{ + Title: "Fix this", + OriginalTitle: "Fix this", + // Kind is zero-value (empty string) — existing path + Edit: &types.WorkspaceEdit{Changes: map[string][]types.TextEdit{}}, + } + issue.CodeActions = []types.CodeAction{action} + + result := ToCodeAction(issue, action) + + assert.Equal(t, types.QuickFix, result.Kind, "existing actions without explicit Kind must fall back to QuickFix") +} diff --git a/domain/snyk/codeaction.go b/domain/snyk/codeaction.go index 94f40bf7f..d151f4ec0 100644 --- a/domain/snyk/codeaction.go +++ b/domain/snyk/codeaction.go @@ -73,6 +73,9 @@ type CodeAction struct { // The type of grouping to determine the grouping function to be used GroupingType types.GroupingType + + // Kind is the LSP code action kind. When empty the converter falls back to QuickFix. + Kind types.CodeActionKind } func (c *CodeAction) SetEdit(edit *types.WorkspaceEdit) { @@ -127,6 +130,12 @@ func (c *CodeAction) GetGroupingType() types.GroupingType { return c.GroupingType } +// GetKind returns the LSP CodeActionKind. Kind is set at construction time via the Kind field; +// there is no SetKind because kind is an immutable property of the action type. +func (c *CodeAction) GetKind() types.CodeActionKind { + return c.Kind +} + func NewCodeAction(title string, edit *types.WorkspaceEdit, command *types.CommandData) (*CodeAction, error) { if edit == nil && command == nil { return nil, errors.New("a non-deferred action must have either an edit or a command") diff --git a/internal/types/issues.go b/internal/types/issues.go index fca90ce8a..073c60e50 100644 --- a/internal/types/issues.go +++ b/internal/types/issues.go @@ -79,6 +79,7 @@ type CodeAction interface { GetCommand() *CommandData GetDeferredCommand() *func() *CommandData GetUuid() *uuid.UUID + GetKind() CodeActionKind SetTitle(title string) SetEdit(edit *WorkspaceEdit) } diff --git a/internal/types/lsp.go b/internal/types/lsp.go index 1e20356f2..2fa1de31d 100644 --- a/internal/types/lsp.go +++ b/internal/types/lsp.go @@ -865,6 +865,10 @@ const SourceOrganizeImports CodeActionKind = "source.organizeImports" */ const SourceFixAll CodeActionKind = "source.fixAll" +// RemediationAgentQuickFix is the stable, machine-readable kind clients match on to identify +// the Snyk Remediation Agent fix action. Clients must match on this kind, never on the title. +const RemediationAgentQuickFix CodeActionKind = "quickfix.snyk.remediationAgent" + type CodeActionParams struct { /** * The document in which the command was invoked. @@ -1122,7 +1126,11 @@ type SnykScanParams struct { type ScanIssue struct { // TODO - convert this to a generic type // Unique key identifying an issue in the whole result set. Not the same as the Snyk issue ID. - Id string `json:"id"` + Id string `json:"id"` + // FindingId is stable across scans for the same underlying finding; sourced from Issue.GetFindingId(). + // Unlike Id (which is per-result-set), this value is consistent between separate scan invocations + // and allows clients to correlate or deduplicate findings over time. + FindingId string `json:"findingId"` Title string `json:"title"` Severity string `json:"severity"` FilePath FilePath `json:"filePath"`