@skyramp/mcp 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -886,6 +886,145 @@ describe("buildRecommendationPrompt — Tool Contract Framing", () => {
886
886
  });
887
887
  });
888
888
  // ---------------------------------------------------------------------------
889
+ // Tests — Multi-method endpoint partitioning
890
+ // ---------------------------------------------------------------------------
891
+ describe("buildRecommendationPrompt — multi-method endpoint partitioning", () => {
892
+ it("classifies all methods of a changed endpoint as changed", () => {
893
+ // When classifyEndpointsByChangedFiles identifies a file as changed,
894
+ // all methods from that endpoint's scanned catalog entry are included
895
+ // with concrete methods (no MULTI sentinels).
896
+ const analysis = minimalAnalysis({
897
+ apiEndpoints: {
898
+ totalCount: 2,
899
+ baseUrl: "http://localhost:3000",
900
+ endpoints: [
901
+ {
902
+ path: "/api/products",
903
+ resourceGroup: "products",
904
+ pathParams: [],
905
+ methods: [
906
+ { method: "GET", description: "List products", queryParams: [], authRequired: false, sourceFile: "app/api/products/route.ts", interactions: [] },
907
+ { method: "POST", description: "Create product", queryParams: [], authRequired: false, sourceFile: "app/api/products/route.ts", interactions: [] },
908
+ ],
909
+ },
910
+ {
911
+ path: "/api/items",
912
+ resourceGroup: "items",
913
+ pathParams: [],
914
+ methods: [
915
+ { method: "GET", description: "List items", queryParams: [], authRequired: false, sourceFile: "routes/items.ts", interactions: [] },
916
+ ],
917
+ },
918
+ ],
919
+ },
920
+ branchDiffContext: {
921
+ baseBranch: "main",
922
+ currentBranch: "feature/products",
923
+ changedFiles: ["app/api/products/route.ts"],
924
+ newEndpoints: [{
925
+ path: "/api/products",
926
+ methods: [
927
+ { method: "GET", sourceFile: "app/api/products/route.ts", interactionCount: 0 },
928
+ { method: "POST", sourceFile: "app/api/products/route.ts", interactionCount: 0 },
929
+ ],
930
+ }],
931
+ modifiedEndpoints: [],
932
+ affectedServices: [],
933
+ },
934
+ });
935
+ const prompt = buildRecommendationPrompt(analysis, AnalysisScope.CurrentBranchDiff, 10);
936
+ // Both GET and POST for /api/products should be in "Changed in this PR"
937
+ expect(prompt).toContain("Changed in this PR");
938
+ expect(prompt).toMatch(/Changed in this PR:[\s\S]*GET \/api\/products/);
939
+ expect(prompt).toMatch(/Changed in this PR:[\s\S]*POST \/api\/products/);
940
+ // /api/items should NOT be in changed section
941
+ expect(prompt).toMatch(/Other endpoints[\s\S]*GET \/api\/items/);
942
+ });
943
+ it("handles mix of new and modified endpoints with concrete methods", () => {
944
+ const analysis = minimalAnalysis({
945
+ apiEndpoints: {
946
+ totalCount: 2,
947
+ baseUrl: "http://localhost:3000",
948
+ endpoints: [
949
+ {
950
+ path: "/api/products",
951
+ resourceGroup: "products",
952
+ pathParams: [],
953
+ methods: [
954
+ { method: "GET", description: "List", queryParams: [], authRequired: false, sourceFile: "routes.ts", interactions: [] },
955
+ { method: "POST", description: "Create", queryParams: [], authRequired: false, sourceFile: "routes.ts", interactions: [] },
956
+ ],
957
+ },
958
+ {
959
+ path: "/api/orders",
960
+ resourceGroup: "orders",
961
+ pathParams: [],
962
+ methods: [
963
+ { method: "POST", description: "Create order", queryParams: [], authRequired: false, sourceFile: "routes.ts", interactions: [] },
964
+ ],
965
+ },
966
+ ],
967
+ },
968
+ branchDiffContext: {
969
+ baseBranch: "main",
970
+ currentBranch: "feature/mix",
971
+ changedFiles: ["routes.ts"],
972
+ newEndpoints: [
973
+ { path: "/api/products", methods: [
974
+ { method: "GET", sourceFile: "routes.ts", interactionCount: 0 },
975
+ { method: "POST", sourceFile: "routes.ts", interactionCount: 0 },
976
+ ] },
977
+ ],
978
+ modifiedEndpoints: [
979
+ { path: "/api/orders", methods: [{ method: "POST", sourceFile: "routes.ts", changeType: "modified" }] },
980
+ ],
981
+ affectedServices: [],
982
+ },
983
+ });
984
+ const prompt = buildRecommendationPrompt(analysis, AnalysisScope.CurrentBranchDiff, 10);
985
+ // Both products and orders should be in changed section
986
+ expect(prompt).toMatch(/Changed in this PR:[\s\S]*GET \/api\/products/);
987
+ expect(prompt).toMatch(/Changed in this PR:[\s\S]*POST \/api\/orders/);
988
+ });
989
+ });
990
+ // ---------------------------------------------------------------------------
991
+ // Tests — Removed endpoint [removed] marker in prompt (Fix 3 verification)
992
+ // ---------------------------------------------------------------------------
993
+ describe("buildRecommendationPrompt — removed endpoint listing", () => {
994
+ it("appends [removed] marker for removed endpoints not in current catalog", () => {
995
+ const analysis = minimalAnalysis({
996
+ apiEndpoints: {
997
+ totalCount: 1,
998
+ baseUrl: "http://localhost:3000",
999
+ endpoints: [{
1000
+ path: "/api/items",
1001
+ resourceGroup: "items",
1002
+ pathParams: [],
1003
+ methods: [{
1004
+ method: "GET", description: "List items", queryParams: [],
1005
+ authRequired: false, sourceFile: "routes.ts", interactions: [],
1006
+ }],
1007
+ }],
1008
+ },
1009
+ branchDiffContext: {
1010
+ baseBranch: "main",
1011
+ currentBranch: "feature/remove",
1012
+ changedFiles: ["routes.ts"],
1013
+ newEndpoints: [],
1014
+ modifiedEndpoints: [],
1015
+ removedEndpoints: [{
1016
+ path: "/api/legacy",
1017
+ methods: [{ method: "DELETE", sourceFile: "routes.ts", changeType: "removed" }],
1018
+ }],
1019
+ affectedServices: [],
1020
+ },
1021
+ });
1022
+ const prompt = buildRecommendationPrompt(analysis, AnalysisScope.CurrentBranchDiff, 10);
1023
+ expect(prompt).toContain("DELETE /api/legacy [removed]");
1024
+ expect(prompt).toContain("Changed in this PR");
1025
+ });
1026
+ });
1027
+ // ---------------------------------------------------------------------------
889
1028
  // Tests — Long-context best practices: XML tags structure
890
1029
  // ---------------------------------------------------------------------------
891
1030
  describe("buildRecommendationPrompt — XML tag structure (long-context best practice)", () => {
@@ -62,10 +62,11 @@ export class TestDiscoveryService {
62
62
  * Uses fast-glob for cross-platform file scanning, then classifies discovered files
63
63
  * as Skyramp-generated tests, external tests, or not-a-test during processing.
64
64
  *
65
- * When `options.changedResources` is provided (PR mode), external files are partitioned
66
- * by relevance: files whose path/name overlaps with the changed resource names get full
67
- * endpoint extraction; low-relevance files are returned as name-only entries (no reads).
68
- * This eliminates the old hard cap while keeping state file size bounded.
65
+ * External test handling depends on `options.changedResources`:
66
+ * - `string[]` with entries (PR mode, endpoints detected): partition by relevance.
67
+ * - `[]` empty array (PR mode, scanner found no endpoints): skip external tests entirely
68
+ * rather than flooding context with irrelevant files.
69
+ * - `undefined` (full-repo mode, no diff): cap at MAX_EXTERNAL_FULL_REPO.
69
70
  */
70
71
  async discoverTests(repositoryPath, options = {}) {
71
72
  logger.info(`Starting test discovery in: ${repositoryPath}`);
@@ -86,23 +87,37 @@ export class TestDiscoveryService {
86
87
  skyrampTests.forEach(t => { t.source = TestSource.Skyramp; });
87
88
  // Partition external tests into relevant (full extraction) and low-relevance (name-only).
88
89
  //
89
- // PR mode (changedResources provided):
90
+ // PR mode + endpoints detected (changedResources is non-empty array):
90
91
  // Files whose path/name token-overlaps with the changed resource names are "relevant".
91
92
  // Only they get full endpoint extraction. Low-relevance files get name-only entries.
92
93
  // No hard cap — the relevance filter naturally bounds the read set to PR scope.
94
+ // The sentinel ["unknown"] falls into this branch — most files score 0 (low-relevance)
95
+ // and get name-only entries, so external coverage is preserved without context flood.
93
96
  //
94
- // Full-repo mode (no changedResources):
97
+ // PR mode + truly no endpoints (changedResources is empty array []):
98
+ // Diff contained no endpoints at all (new, modified, or removed) — skip external
99
+ // tests entirely rather than flooding the prompt with hundreds of irrelevant files.
100
+ //
101
+ // Full-repo mode (changedResources is undefined):
95
102
  // No diff context — all external files treated as potentially relevant.
96
103
  // Cap at MAX_EXTERNAL_FULL_REPO to avoid reading hundreds of files.
97
104
  const { changedResources } = options;
98
105
  let relevantExternal;
99
106
  let otherExternal;
100
107
  if (changedResources?.length) {
108
+ // PR mode with detected endpoints — partition by relevance
101
109
  ({ relevant: relevantExternal, other: otherExternal } =
102
110
  this.partitionByRelevance(classified.external, changedResources));
103
111
  }
112
+ else if (changedResources !== undefined) {
113
+ // PR mode with an explicit empty endpoint list from diff parsing — don't flood
114
+ // context with irrelevant external tests. The LLM will work from Skyramp tests
115
+ // and scanned endpoints only.
116
+ relevantExternal = [];
117
+ otherExternal = [];
118
+ }
104
119
  else {
105
- // Full-repo mode: cap full-extraction set, remaining become name-only
120
+ // Full-repo mode (no diff context): cap full-extraction set, remaining become name-only
106
121
  relevantExternal = classified.external.slice(0, this.MAX_EXTERNAL_FULL_REPO);
107
122
  otherExternal = classified.external.slice(this.MAX_EXTERNAL_FULL_REPO);
108
123
  }
@@ -348,6 +348,50 @@ describe("TestDiscoveryService", () => {
348
348
  const withEndpoints = externalTests.filter(t => t.apiEndpoint !== "");
349
349
  expect(withEndpoints.length).toBe(12);
350
350
  });
351
+ it("returns zero external tests when changedResources is empty array (PR mode, no endpoints)", async () => {
352
+ // Simulate PR mode where a parsed diff produced no detected endpoints:
353
+ // newEndpoints=[], modifiedEndpoints=[], and removedEndpoints=[] → changedResources = []
354
+ writeFile("test_orders_api.py", 'import requests\nrequests.get("/api/orders")');
355
+ writeFile("test_products_api.py", 'import requests\nrequests.get("/api/products")');
356
+ const result = await service.discoverTests(tmpDir, { changedResources: [] });
357
+ const externalTests = result.tests.filter(t => t.source === TestSource.External);
358
+ // Empty changedResources = PR mode with no detected endpoints → zero external tests
359
+ expect(externalTests.length).toBe(0);
360
+ expect(result.relevantExternalTestPaths.length).toBe(0);
361
+ });
362
+ it("still returns external tests in full-repo mode (changedResources undefined)", async () => {
363
+ // Full-repo mode: changedResources not provided → should use capped full-repo behavior
364
+ writeFile("test_orders_api.py", 'import requests\nrequests.get("/api/orders")');
365
+ writeFile("test_products_api.py", 'import requests\nrequests.get("/api/products")');
366
+ const result = await service.discoverTests(tmpDir); // no options → undefined
367
+ const externalTests = result.tests.filter(t => t.source === TestSource.External);
368
+ expect(externalTests.length).toBe(2);
369
+ expect(result.relevantExternalTestPaths.length).toBe(2);
370
+ });
371
+ it("Skyramp tests are unaffected by empty changedResources", async () => {
372
+ writeFile("tests/test_orders_smoke.py", '# Generated by Skyramp\nskyramp generate smoke rest');
373
+ writeFile("test_external.py", 'import pytest\ndef test(): pass');
374
+ const result = await service.discoverTests(tmpDir, { changedResources: [] });
375
+ const skyrampTests = result.tests.filter(t => t.source === TestSource.Skyramp);
376
+ const externalTests = result.tests.filter(t => t.source === TestSource.External);
377
+ // Skyramp tests always discovered regardless of changedResources
378
+ expect(skyrampTests.length).toBe(1);
379
+ // External tests suppressed in PR-mode-no-endpoints
380
+ expect(externalTests.length).toBe(0);
381
+ });
382
+ it("returns external tests as name-only with ['unknown'] sentinel (unresolvable resources)", async () => {
383
+ // When diff endpoints exist but all paths resolve to "unknown" (e.g. decorator-relative
384
+ // paths like "/{order_id}"), changedResources = ["unknown"]. External tests should be
385
+ // discovered (not skipped) but scored as low-relevance since "unknown" won't match filenames.
386
+ writeFile("test_orders_api.py", 'import requests\nrequests.get("/api/orders")');
387
+ writeFile("test_products_api.py", 'import requests\nrequests.get("/api/products")');
388
+ const result = await service.discoverTests(tmpDir, { changedResources: ["unknown"] });
389
+ const externalTests = result.tests.filter(t => t.source === TestSource.External);
390
+ // External tests discovered (not skipped like empty array)
391
+ expect(externalTests.length).toBe(2);
392
+ // But all are low-relevance (name-only) since "unknown" doesn't match any filename tokens
393
+ expect(result.relevantExternalTestPaths.length).toBe(0);
394
+ });
351
395
  it("low-relevance files have empty apiEndpoint and empty framework in PR mode", async () => {
352
396
  writeFile("test_orders_api.py", 'import requests\nrequests.get("/api/orders")');
353
397
  writeFile("test_products_api.py", 'import requests\nrequests.get("/api/products")');
@@ -1,12 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { TestType } from "../../types/TestTypes.js";
3
3
  import { AnalyticsService } from "../../services/AnalyticsService.js";
4
- import { CONTRACT_PROVIDER_ASSERTIONS_PROMPT } from "../../prompts/enhance-assertions/contractProviderAssertionsPrompt.js";
5
- import { INTEGRATION_ASSERTIONS_PROMPT } from "../../prompts/enhance-assertions/integrationAssertionsPrompt.js";
6
- import { UI_ASSERTIONS_PROMPT } from "../../prompts/enhance-assertions/uiAssertionsPrompt.js";
4
+ import { getContractProviderAssertionsPrompt } from "../../prompts/enhance-assertions/contractProviderAssertionsPrompt.js";
5
+ import { getIntegrationAssertionsPrompt } from "../../prompts/enhance-assertions/integrationAssertionsPrompt.js";
6
+ import { getUIAssertionsPrompt } from "../../prompts/enhance-assertions/uiAssertionsPrompt.js";
7
7
  const TOOL_NAME = "skyramp_enhance_assertions";
8
- const SCOPE_GENERATION = "Apply to every test function in the generated file.";
9
- const SCOPE_MAINTENANCE = "Apply to **new test functions you are adding** and **existing functions that cover endpoints changed in the diff** only. Do NOT touch existing functions for endpoints unrelated to the diff.";
10
8
  const TESTBOT_UI_CHECKS = `
11
9
  ### Additional Testbot-Specific Checks
12
10
  - If no suitable selector exists in the generated file for an assertion you need to add, go back and call \`browser_assert\` on the live page to record it with a valid selector, then re-export and regenerate.
@@ -35,28 +33,28 @@ export function registerEnhanceAssertionsTool(server) {
35
33
  inputSchema: enhanceAssertionsSchema,
36
34
  }, async (params) => {
37
35
  const { testFile, testType, enhanceType } = params;
36
+ const enhanceCtx = enhanceType;
38
37
  let instructions;
39
38
  if (testType === TestType.UI) {
40
- instructions = UI_ASSERTIONS_PROMPT;
39
+ instructions = getUIAssertionsPrompt(testFile, enhanceCtx);
41
40
  if (process.env.SKYRAMP_FEATURE_TESTBOT === "1") {
42
41
  instructions += TESTBOT_UI_CHECKS;
43
42
  }
44
43
  }
45
44
  else if (testType === TestType.CONTRACT) {
46
- instructions = CONTRACT_PROVIDER_ASSERTIONS_PROMPT;
45
+ instructions = getContractProviderAssertionsPrompt(testFile, enhanceCtx);
47
46
  }
48
47
  else if (testType === TestType.INTEGRATION) {
49
- instructions = INTEGRATION_ASSERTIONS_PROMPT;
48
+ instructions = getIntegrationAssertionsPrompt(testFile, enhanceCtx);
50
49
  }
51
50
  else {
52
51
  throw new Error(`Unsupported testType for ${TOOL_NAME}: ${testType}`);
53
52
  }
54
- const scope = enhanceType === "maintenance" ? SCOPE_MAINTENANCE : SCOPE_GENERATION;
55
53
  const result = {
56
54
  content: [
57
55
  {
58
56
  type: "text",
59
- text: `**You MUST execute the following assertion enhancement instructions to modify the test file.**\n\n**Target file:** ${testFile}\n\n**Scope:** ${scope}\n\n${instructions}`,
57
+ text: instructions,
60
58
  },
61
59
  ],
62
60
  isError: false,