Skip to content

Commit cbcaee2

Browse files
committed
TDD improvements for ql-mcp-client
1 parent e4b9239 commit cbcaee2

File tree

13 files changed

+125
-134
lines changed

13 files changed

+125
-134
lines changed

client/cmd/root_test.go

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,31 @@ import (
55
"testing"
66
)
77

8-
func TestRootCommand_Help(t *testing.T) {
9-
cmd := rootCmd
8+
// executeRootCmd executes rootCmd with the given args and returns stdout.
9+
// It resets the command's output and args before and after each call
10+
// to avoid leaking state between tests.
11+
func executeRootCmd(args []string) (string, error) {
1012
buf := new(bytes.Buffer)
11-
cmd.SetOut(buf)
12-
cmd.SetArgs([]string{"--help"})
13+
rootCmd.SetOut(buf)
14+
rootCmd.SetErr(buf)
15+
rootCmd.SetArgs(args)
16+
17+
err := rootCmd.Execute()
18+
19+
// Reset to default so subsequent tests are not affected
20+
rootCmd.SetOut(nil)
21+
rootCmd.SetErr(nil)
22+
rootCmd.SetArgs(nil)
23+
24+
return buf.String(), err
25+
}
1326

14-
err := cmd.Execute()
27+
func TestRootCommand_Help(t *testing.T) {
28+
output, err := executeRootCmd([]string{"--help"})
1529
if err != nil {
1630
t.Fatalf("root --help failed: %v", err)
1731
}
1832

19-
output := buf.String()
2033
if output == "" {
2134
t.Fatal("expected help output, got empty string")
2235
}
@@ -55,9 +68,8 @@ func TestRootCommand_Version(t *testing.T) {
5568
}
5669

5770
func TestRootCommand_DefaultFlags(t *testing.T) {
58-
// Reset flags to defaults
59-
rootCmd.SetArgs([]string{})
60-
_ = rootCmd.Execute()
71+
// Reset flags to defaults by executing with no args
72+
_, _ = executeRootCmd([]string{})
6173

6274
if MCPMode() != "http" {
6375
t.Errorf("expected default mode %q, got %q", "http", MCPMode())
@@ -74,27 +86,15 @@ func TestRootCommand_DefaultFlags(t *testing.T) {
7486
}
7587

7688
func TestCodeScanningCommand_InHelp(t *testing.T) {
77-
cmd := rootCmd
78-
buf := new(bytes.Buffer)
79-
cmd.SetOut(buf)
80-
cmd.SetArgs([]string{"--help"})
81-
82-
_ = cmd.Execute()
83-
output := buf.String()
89+
output, _ := executeRootCmd([]string{"--help"})
8490

8591
if !bytes.Contains([]byte(output), []byte("code-scanning")) {
8692
t.Error("root help should list code-scanning subcommand")
8793
}
8894
}
8995

9096
func TestSarifCommand_InHelp(t *testing.T) {
91-
cmd := rootCmd
92-
buf := new(bytes.Buffer)
93-
cmd.SetOut(buf)
94-
cmd.SetArgs([]string{"--help"})
95-
96-
_ = cmd.Execute()
97-
output := buf.String()
97+
output, _ := executeRootCmd([]string{"--help"})
9898

9999
if !bytes.Contains([]byte(output), []byte("sarif")) {
100100
t.Error("root help should list sarif subcommand")

client/internal/github/client.go

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import (
1313

1414
// Client wraps the go-gh REST client for Code Scanning API calls.
1515
type Client struct {
16-
rest *ghapi.RESTClient
16+
rest *ghapi.RESTClient
17+
sarifRest *ghapi.RESTClient // lazily initialized; uses Accept: application/sarif+json
1718
}
1819

1920
// NewClient creates a new GitHub API client using gh auth credentials.
@@ -30,6 +31,25 @@ func NewClient() (*Client, error) {
3031
return &Client{rest: rest}, nil
3132
}
3233

34+
// sarifClient returns or lazily creates a REST client with Accept: application/sarif+json.
35+
func (c *Client) sarifClient() (*ghapi.RESTClient, error) {
36+
if c.sarifRest != nil {
37+
return c.sarifRest, nil
38+
}
39+
sarifOpts := ghapi.ClientOptions{
40+
Headers: map[string]string{
41+
"Accept": "application/sarif+json",
42+
"X-GitHub-Api-Version": "2026-03-10",
43+
},
44+
}
45+
client, err := ghapi.NewRESTClient(sarifOpts)
46+
if err != nil {
47+
return nil, fmt.Errorf("create SARIF client: %w", err)
48+
}
49+
c.sarifRest = client
50+
return c.sarifRest, nil
51+
}
52+
3353
// ListAnalysesOptions configures the list analyses request.
3454
type ListAnalysesOptions struct {
3555
Owner string
@@ -71,20 +91,13 @@ func (c *Client) ListAnalyses(opts ListAnalysesOptions) ([]Analysis, error) {
7191
func (c *Client) GetAnalysisSARIF(owner, repo string, analysisID int) ([]byte, error) {
7292
path := fmt.Sprintf("repos/%s/%s/code-scanning/analyses/%d", owner, repo, analysisID)
7393

74-
// Need a separate client with SARIF Accept header
75-
sarifOpts := ghapi.ClientOptions{
76-
Headers: map[string]string{
77-
"Accept": "application/sarif+json",
78-
"X-GitHub-Api-Version": "2026-03-10",
79-
},
80-
}
81-
sarifClient, err := ghapi.NewRESTClient(sarifOpts)
94+
sc, err := c.sarifClient()
8295
if err != nil {
83-
return nil, fmt.Errorf("create SARIF client: %w", err)
96+
return nil, err
8497
}
8598

8699
var sarif json.RawMessage
87-
if err := sarifClient.Do("GET", path, nil, &sarif); err != nil {
100+
if err := sc.Do("GET", path, nil, &sarif); err != nil {
88101
return nil, fmt.Errorf("get analysis SARIF: %w", err)
89102
}
90103
return sarif, nil

client/internal/github/client_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,13 @@ func TestIntToStr(t *testing.T) {
176176
t.Errorf("intToStr(42) = %q, want %q", intToStr(42), "42")
177177
}
178178
}
179+
180+
func TestClient_SarifClientCached(t *testing.T) {
181+
// Verify that the sarifRest field starts nil and would be set by sarifClient().
182+
// We cannot call sarifClient() without real gh auth, so we verify the struct
183+
// field starts nil (no eager init) and would be reused once set.
184+
c := &Client{rest: nil}
185+
if c.sarifRest != nil {
186+
t.Error("sarifRest should be nil on a freshly constructed Client")
187+
}
188+
}

package-lock.json

Lines changed: 0 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/dist/codeql-development-mcp-server.js

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -200402,7 +200402,8 @@ function registerSarifTools(server) {
200402200402
registerSarifStoreTool(server);
200403200403
logger.info("Registered SARIF analysis tools");
200404200404
}
200405-
function loadSarif(sarifPath, cacheKey2, inlineContent) {
200405+
function loadSarif(opts) {
200406+
const { cacheKey: cacheKey2, inlineContent, sarifPath } = opts;
200406200407
if (!sarifPath && !cacheKey2 && !inlineContent) {
200407200408
return { error: "Either sarifPath or cacheKey is required." };
200408200409
}
@@ -200453,7 +200454,7 @@ function registerSarifExtractRuleTool(server) {
200453200454
sarifPath: external_exports.string().optional().describe("Path to the SARIF file.")
200454200455
},
200455200456
async ({ sarifPath, cacheKey: cacheKey2, ruleId }) => {
200456-
const loaded = loadSarif(sarifPath, cacheKey2);
200457+
const loaded = loadSarif({ sarifPath, cacheKey: cacheKey2 });
200457200458
if (loaded.error) {
200458200459
return { content: [{ type: "text", text: loaded.error }] };
200459200460
}
@@ -200490,7 +200491,7 @@ function registerSarifListRulesTool(server) {
200490200491
sarifPath: external_exports.string().optional().describe("Path to the SARIF file.")
200491200492
},
200492200493
async ({ sarifPath, cacheKey: cacheKey2 }) => {
200493-
const loaded = loadSarif(sarifPath, cacheKey2);
200494+
const loaded = loadSarif({ sarifPath, cacheKey: cacheKey2 });
200494200495
if (loaded.error) {
200495200496
return { content: [{ type: "text", text: loaded.error }] };
200496200497
}
@@ -200518,7 +200519,7 @@ function registerSarifRuleToMarkdownTool(server) {
200518200519
sarifPath: external_exports.string().optional().describe("Path to the SARIF file.")
200519200520
},
200520200521
async ({ sarifPath, cacheKey: cacheKey2, ruleId }) => {
200521-
const loaded = loadSarif(sarifPath, cacheKey2);
200522+
const loaded = loadSarif({ sarifPath, cacheKey: cacheKey2 });
200522200523
if (loaded.error) {
200523200524
return { content: [{ type: "text", text: loaded.error }] };
200524200525
}
@@ -200556,11 +200557,11 @@ function registerSarifCompareAlertsTool(server) {
200556200557
overlapMode: external_exports.enum(["sink", "source", "any-location", "full-path", "fingerprint"]).optional().default("sink").describe('Comparison mode: "sink" (primary locations), "source" (first dataflow step), "any-location" (all locations), "full-path" (structural path similarity), "fingerprint" (partialFingerprints match, falls back to full-path).')
200557200558
},
200558200559
async ({ alertA, alertB, overlapMode }) => {
200559-
const loadedA = loadSarif(alertA.sarifPath, alertA.cacheKey);
200560+
const loadedA = loadSarif({ sarifPath: alertA.sarifPath, cacheKey: alertA.cacheKey });
200560200561
if (loadedA.error) {
200561200562
return { content: [{ type: "text", text: `Alert A: ${loadedA.error}` }] };
200562200563
}
200563-
const loadedB = loadSarif(alertB.sarifPath, alertB.cacheKey);
200564+
const loadedB = loadSarif({ sarifPath: alertB.sarifPath, cacheKey: alertB.cacheKey });
200564200565
if (loadedB.error) {
200565200566
return { content: [{ type: "text", text: `Alert B: ${loadedB.error}` }] };
200566200567
}
@@ -200621,11 +200622,11 @@ function registerSarifDiffRunsTool(server) {
200621200622
sarifPathB: external_exports.string().optional().describe("Path to the second (comparison) SARIF file.")
200622200623
},
200623200624
async ({ sarifPathA, sarifPathB, cacheKeyA, cacheKeyB, labelA, labelB }) => {
200624-
const loadedA = loadSarif(sarifPathA, cacheKeyA);
200625+
const loadedA = loadSarif({ sarifPath: sarifPathA, cacheKey: cacheKeyA });
200625200626
if (loadedA.error) {
200626200627
return { content: [{ type: "text", text: `Run A: ${loadedA.error}` }] };
200627200628
}
200628-
const loadedB = loadSarif(sarifPathB, cacheKeyB);
200629+
const loadedB = loadSarif({ sarifPath: sarifPathB, cacheKey: cacheKeyB });
200629200630
if (loadedB.error) {
200630200631
return { content: [{ type: "text", text: `Run B: ${loadedB.error}` }] };
200631200632
}
@@ -200666,7 +200667,7 @@ function registerSarifStoreTool(server) {
200666200667
} else {
200667200668
content = sarifContent;
200668200669
}
200669-
const loaded = loadSarif(void 0, void 0, content);
200670+
const loaded = loadSarif({ inlineContent: content });
200670200671
if (loaded.error) {
200671200672
return { content: [{ type: "text", text: loaded.error }] };
200672200673
}
@@ -200717,11 +200718,11 @@ function registerSarifDeduplicateRulesTool(server) {
200717200718
sarifPathB: external_exports.string().optional().describe("Path to the second SARIF file.")
200718200719
},
200719200720
async ({ sarifPathA, sarifPathB, cacheKeyA, cacheKeyB, overlapThreshold }) => {
200720-
const loadedA = loadSarif(sarifPathA, cacheKeyA);
200721+
const loadedA = loadSarif({ sarifPath: sarifPathA, cacheKey: cacheKeyA });
200721200722
if (loadedA.error) {
200722200723
return { content: [{ type: "text", text: `SARIF A: ${loadedA.error}` }] };
200723200724
}
200724-
const loadedB = loadSarif(sarifPathB, cacheKeyB);
200725+
const loadedB = loadSarif({ sarifPath: sarifPathB, cacheKey: cacheKeyB });
200725200726
if (loadedB.error) {
200726200727
return { content: [{ type: "text", text: `SARIF B: ${loadedB.error}` }] };
200727200728
}
@@ -200738,28 +200739,31 @@ function registerSarifDeduplicateRulesTool(server) {
200738200739
const ruleObjA = extractedA.runs[0]?.tool.driver.rules?.[0] ?? { id: rA.ruleId };
200739200740
const ruleObjB = extractedB.runs[0]?.tool.driver.rules?.[0] ?? { id: rB.ruleId };
200740200741
const overlaps = findOverlappingAlerts(resultsA, ruleObjA, resultsB, ruleObjB, "full-path");
200741-
let fingerprintMatches = 0;
200742-
for (const rResultA of resultsA) {
200742+
const matchedAIndices = /* @__PURE__ */ new Set();
200743+
for (let ai = 0; ai < resultsA.length; ai++) {
200743200744
for (const rResultB of resultsB) {
200744-
const fpResult = computeFingerprintOverlap(rResultA, rResultB);
200745+
const fpResult = computeFingerprintOverlap(resultsA[ai], rResultB);
200745200746
if (fpResult.fingerprintMatch) {
200746-
fingerprintMatches++;
200747+
matchedAIndices.add(ai);
200748+
break;
200747200749
}
200748200750
}
200749200751
}
200750-
const matchedAlerts = Math.max(overlaps.length, fingerprintMatches);
200751-
const totalUnique = resultsA.length + resultsB.length - matchedAlerts;
200752-
const overlapScore = totalUnique > 0 ? matchedAlerts / totalUnique : 0;
200752+
const matchedAlerts = Math.max(overlaps.length, matchedAIndices.size);
200753+
const minResults = Math.min(resultsA.length, resultsB.length);
200754+
const cappedMatched = Math.min(matchedAlerts, minResults);
200755+
const totalUnique = resultsA.length + resultsB.length - cappedMatched;
200756+
const overlapScore = totalUnique > 0 ? cappedMatched / totalUnique : 0;
200753200757
if (overlapScore >= (overlapThreshold ?? 0.8)) {
200754200758
duplicateGroups.push({
200755-
matchedAlerts,
200759+
matchedAlerts: cappedMatched,
200756200760
overlapScore: Math.round(overlapScore * 1e3) / 1e3,
200757200761
ruleIdA: rA.ruleId,
200758200762
ruleIdB: rB.ruleId,
200759200763
totalA: resultsA.length,
200760200764
totalB: resultsB.length,
200761-
unmatchedA: resultsA.length - matchedAlerts,
200762-
unmatchedB: resultsB.length - matchedAlerts
200765+
unmatchedA: Math.max(0, resultsA.length - cappedMatched),
200766+
unmatchedB: Math.max(0, resultsB.length - cappedMatched)
200763200767
});
200764200768
}
200765200769
}

server/dist/codeql-development-mcp-server.js.map

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/ql/rust/tools/test/CallGraphFromTo/Cargo.lock

Lines changed: 0 additions & 7 deletions
This file was deleted.

server/ql/rust/tools/test/CallGraphTo/Cargo.lock

Lines changed: 0 additions & 7 deletions
This file was deleted.

server/ql/rust/tools/test/PrintCFG/Cargo.lock

Lines changed: 0 additions & 7 deletions
This file was deleted.

server/ql/rust/tools/test/ext/Cargo.lock

Lines changed: 0 additions & 7 deletions
This file was deleted.

0 commit comments

Comments
 (0)