@skyramp/mcp 0.0.59 → 0.0.60-rc.2

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.
Files changed (35) hide show
  1. package/build/index.js +32 -5
  2. package/build/prompts/test-recommendation/analysisOutputPrompt.js +98 -0
  3. package/build/prompts/test-recommendation/recommendationSections.js +226 -0
  4. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +71 -0
  5. package/build/prompts/test-recommendation/test-recommendation-prompt.js +166 -104
  6. package/build/prompts/testGenerationPrompt.js +2 -3
  7. package/build/prompts/testbot/testbot-prompts.js +96 -93
  8. package/build/resources/analysisResources.js +254 -0
  9. package/build/services/ScenarioGenerationService.js +70 -26
  10. package/build/tools/generate-tests/generateIntegrationRestTool.js +54 -1
  11. package/build/tools/generate-tests/generateScenarioRestTool.js +8 -5
  12. package/build/tools/submitReportTool.js +28 -0
  13. package/build/tools/test-maintenance/stateCleanupTool.js +8 -0
  14. package/build/tools/test-recommendation/analyzeRepositoryTool.js +349 -217
  15. package/build/tools/test-recommendation/recommendTestsTool.js +163 -159
  16. package/build/tools/workspace/initializeWorkspaceTool.js +1 -1
  17. package/build/types/RepositoryAnalysis.js +99 -12
  18. package/build/utils/AnalysisStateManager.js +40 -23
  19. package/build/utils/branchDiff.js +47 -0
  20. package/build/utils/pr-comment-parser.js +124 -0
  21. package/build/utils/projectMetadata.js +188 -0
  22. package/build/utils/projectMetadata.test.js +81 -0
  23. package/build/utils/repoScanner.js +378 -0
  24. package/build/utils/routeParsers.js +213 -0
  25. package/build/utils/routeParsers.test.js +87 -0
  26. package/build/utils/scenarioDrafting.js +119 -0
  27. package/build/utils/scenarioDrafting.test.js +66 -0
  28. package/build/utils/trace-parser.js +166 -0
  29. package/build/utils/workspaceAuth.js +16 -0
  30. package/package.json +1 -1
  31. package/build/prompts/test-recommendation/repository-analysis-prompt.js +0 -326
  32. package/build/prompts/test-recommendation/test-mapping-prompt.js +0 -266
  33. package/build/tools/test-recommendation/mapTestsTool.js +0 -243
  34. package/build/types/TestMapping.js +0 -173
  35. package/build/utils/scoring-engine.js +0 -380
@@ -0,0 +1,119 @@
1
+ const ACTION_PATTERN = /^(me|merge|archive|search|login|logout|verify|forgot|reset|config|dashboard|webhook|migration|favicon|payment|health|status|ping|metrics|callback|confirm|activate|deactivate|subscribe|unsubscribe)$/i;
2
+ const ACTION_VERB_HYPHEN = /^(forgot-|reset-|verify-|confirm-|send-|check-|get-|set-|update-|delete-|create-|trigger-|start-|stop-)/i;
3
+ export function isRealResource(r) {
4
+ return !ACTION_PATTERN.test(r) && !ACTION_VERB_HYPHEN.test(r);
5
+ }
6
+ export function draftScenariosFromEndpoints(endpoints) {
7
+ const scenarios = [];
8
+ const resourceGroups = new Map();
9
+ for (const ep of endpoints) {
10
+ const segments = ep.path.split("/").filter(Boolean);
11
+ const skipSet = new Set(["api", "v1", "v2", "v3", "public"]);
12
+ const nonParamSegs = segments.filter(s => !s.startsWith("{") && !skipSet.has(s));
13
+ const resource = nonParamSegs[nonParamSegs.length - 1] || "unknown";
14
+ const hasParam = /\{/.test(ep.path);
15
+ const resourceSegIdx = segments.lastIndexOf(resource);
16
+ const basePath = "/" + segments.slice(0, resourceSegIdx + 1).join("/");
17
+ let paramPath;
18
+ if (hasParam && resourceSegIdx + 1 < segments.length && segments[resourceSegIdx + 1].startsWith("{")) {
19
+ paramPath = basePath + "/" + segments[resourceSegIdx + 1];
20
+ }
21
+ const existing = resourceGroups.get(resource);
22
+ if (existing) {
23
+ for (const m of ep.methods)
24
+ existing.methods.add(typeof m === "string" ? m : m.method);
25
+ if (basePath.split("/").length < existing.basePath.split("/").length) {
26
+ existing.basePath = basePath;
27
+ }
28
+ if (paramPath && (!existing.paramPath || paramPath.split("/").length < existing.paramPath.split("/").length)) {
29
+ existing.paramPath = paramPath;
30
+ }
31
+ }
32
+ else {
33
+ resourceGroups.set(resource, {
34
+ basePath,
35
+ methods: new Set(ep.methods.map((m) => typeof m === "string" ? m : m.method)),
36
+ paramPath,
37
+ });
38
+ }
39
+ }
40
+ const creatableResources = Array.from(resourceGroups.keys()).filter(r => isRealResource(r) && resourceGroups.get(r).methods.has("POST"));
41
+ const MAX_CROSS_RESOURCE = Math.min(Math.max(3, Math.floor(creatableResources.length / 2)), 8);
42
+ let crossCount = 0;
43
+ for (let i = 0; i < creatableResources.length && crossCount < MAX_CROSS_RESOURCE; i++) {
44
+ for (let j = i + 1; j < creatableResources.length && crossCount < MAX_CROSS_RESOURCE; j++) {
45
+ const r1 = creatableResources[i];
46
+ const r2 = creatableResources[j];
47
+ const g1 = resourceGroups.get(r1);
48
+ const g2 = resourceGroups.get(r2);
49
+ const steps = [];
50
+ let order = 1;
51
+ const singular1 = r1.replace(/s$/, "");
52
+ const singular2 = r2.replace(/s$/, "");
53
+ steps.push({
54
+ order: order++, method: "POST", path: g1.basePath,
55
+ description: `Create a ${singular1}`,
56
+ interactionType: "success", expectedStatusCode: 201,
57
+ });
58
+ steps.push({
59
+ order: order++, method: "POST", path: g2.basePath,
60
+ description: `Create a ${singular2} referencing the ${singular1}`,
61
+ interactionType: "success", expectedStatusCode: 201,
62
+ chainsFrom: { sourceStep: 1, sourceField: `${singular1}_id`, sourceLocation: "body", targetParam: `${singular1}_id`, targetLocation: "body" },
63
+ });
64
+ if (g2.paramPath && g2.methods.has("GET")) {
65
+ steps.push({
66
+ order: order++, method: "GET", path: g2.paramPath,
67
+ description: `Verify the ${singular2} references the correct ${singular1}`,
68
+ interactionType: "success", expectedStatusCode: 200,
69
+ });
70
+ }
71
+ if (g2.paramPath && g2.methods.has("DELETE")) {
72
+ steps.push({
73
+ order: order++, method: "DELETE", path: g2.paramPath,
74
+ description: `Clean up: delete the ${singular2}`,
75
+ interactionType: "success", expectedStatusCode: 204,
76
+ });
77
+ }
78
+ scenarios.push({
79
+ scenarioName: `${r1}-${r2}-integration`,
80
+ description: `Multi-resource integration: create ${singular1}, create ${singular2} linked to it, verify, clean up`,
81
+ category: "workflow",
82
+ priority: "high",
83
+ steps,
84
+ chainingKeys: [`${singular1}_id`, `${singular2}_id`, "id"],
85
+ requiresAuth: true,
86
+ estimatedComplexity: "complex",
87
+ source: "code-inferred",
88
+ });
89
+ crossCount++;
90
+ }
91
+ }
92
+ for (const [resource, group] of resourceGroups) {
93
+ const m = group.methods;
94
+ if (!m.has("POST") || !m.has("GET") || !isRealResource(resource))
95
+ continue;
96
+ const steps = [];
97
+ let order = 1;
98
+ const singular = resource.replace(/s$/, "");
99
+ steps.push({ order: order++, method: "POST", path: group.basePath, description: `Create a new ${singular}`, interactionType: "success", expectedStatusCode: 201 });
100
+ if (group.paramPath)
101
+ steps.push({ order: order++, method: "GET", path: group.paramPath, description: `Get the created ${singular} by ID`, interactionType: "success", expectedStatusCode: 200 });
102
+ if ((m.has("PUT") || m.has("PATCH")) && group.paramPath)
103
+ steps.push({ order: order++, method: m.has("PUT") ? "PUT" : "PATCH", path: group.paramPath, description: `Update the ${singular}`, interactionType: "success", expectedStatusCode: 200 });
104
+ if (m.has("DELETE") && group.paramPath)
105
+ steps.push({ order: order++, method: "DELETE", path: group.paramPath, description: `Delete the ${singular}`, interactionType: "success", expectedStatusCode: 204 });
106
+ scenarios.push({
107
+ scenarioName: `${resource}-crud-lifecycle`,
108
+ description: `CRUD lifecycle for ${resource}: create, read, update, delete`,
109
+ category: "crud",
110
+ priority: "medium",
111
+ steps,
112
+ chainingKeys: ["id", `${singular}_id`],
113
+ requiresAuth: true,
114
+ estimatedComplexity: "moderate",
115
+ source: "code-inferred",
116
+ });
117
+ }
118
+ return scenarios;
119
+ }
@@ -0,0 +1,66 @@
1
+ // @ts-ignore
2
+ import { isRealResource, draftScenariosFromEndpoints } from "./scenarioDrafting.js";
3
+ describe("isRealResource", () => {
4
+ it("accepts normal resource names", () => {
5
+ expect(isRealResource("users")).toBe(true);
6
+ expect(isRealResource("products")).toBe(true);
7
+ expect(isRealResource("orders")).toBe(true);
8
+ });
9
+ it("accepts hyphenated resource names (not action verbs)", () => {
10
+ expect(isRealResource("saved-searches")).toBe(true);
11
+ expect(isRealResource("order-items")).toBe(true);
12
+ expect(isRealResource("user-profiles")).toBe(true);
13
+ expect(isRealResource("api-keys")).toBe(true);
14
+ });
15
+ it("rejects action-like single words", () => {
16
+ expect(isRealResource("login")).toBe(false);
17
+ expect(isRealResource("logout")).toBe(false);
18
+ expect(isRealResource("verify")).toBe(false);
19
+ expect(isRealResource("health")).toBe(false);
20
+ expect(isRealResource("ping")).toBe(false);
21
+ expect(isRealResource("dashboard")).toBe(false);
22
+ expect(isRealResource("webhook")).toBe(false);
23
+ });
24
+ it("rejects action-verb-hyphenated patterns", () => {
25
+ expect(isRealResource("forgot-password")).toBe(false);
26
+ expect(isRealResource("reset-password")).toBe(false);
27
+ expect(isRealResource("verify-email")).toBe(false);
28
+ expect(isRealResource("confirm-order")).toBe(false);
29
+ expect(isRealResource("send-notification")).toBe(false);
30
+ expect(isRealResource("create-session")).toBe(false);
31
+ expect(isRealResource("delete-cache")).toBe(false);
32
+ });
33
+ it("is case-insensitive", () => {
34
+ expect(isRealResource("Login")).toBe(false);
35
+ expect(isRealResource("VERIFY")).toBe(false);
36
+ expect(isRealResource("Forgot-Password")).toBe(false);
37
+ });
38
+ });
39
+ describe("draftScenariosFromEndpoints", () => {
40
+ it("returns empty array for no endpoints", () => {
41
+ expect(draftScenariosFromEndpoints([])).toEqual([]);
42
+ });
43
+ it("generates CRUD scenario for resource with multiple methods", () => {
44
+ const endpoints = [
45
+ { path: "/api/products", methods: [{ method: "GET" }, { method: "POST" }] },
46
+ { path: "/api/products/{id}", methods: [{ method: "GET" }, { method: "PUT" }, { method: "DELETE" }] },
47
+ ];
48
+ const scenarios = draftScenariosFromEndpoints(endpoints);
49
+ expect(scenarios.length).toBeGreaterThan(0);
50
+ const crudScenario = scenarios.find((s) => /crud/i.test(s.scenarioName) || /products/i.test(s.scenarioName));
51
+ expect(crudScenario).toBeDefined();
52
+ });
53
+ it("generates cross-resource scenario when multiple resources exist", () => {
54
+ const endpoints = [
55
+ { path: "/api/users", methods: [{ method: "GET" }, { method: "POST" }] },
56
+ { path: "/api/users/{id}", methods: [{ method: "GET" }] },
57
+ { path: "/api/products", methods: [{ method: "GET" }, { method: "POST" }] },
58
+ { path: "/api/products/{id}", methods: [{ method: "GET" }] },
59
+ { path: "/api/orders", methods: [{ method: "GET" }, { method: "POST" }] },
60
+ { path: "/api/orders/{id}", methods: [{ method: "GET" }] },
61
+ ];
62
+ const scenarios = draftScenariosFromEndpoints(endpoints);
63
+ const crossResource = scenarios.find((s) => /integration/i.test(s.scenarioName) && s.category === "workflow");
64
+ expect(crossResource).toBeDefined();
65
+ });
66
+ });
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Trace Parser — parses Skyramp trace format and HAR files into
3
+ * structured entries for analysis enrichment.
4
+ */
5
+ import * as fs from "fs";
6
+ const FLOW_GAP_MS = 30_000;
7
+ const SENSITIVE_HEADER = /^(authorization|cookie|x-api-key|x-auth-token|set-cookie)/i;
8
+ function groupIntoFlows(entries) {
9
+ if (entries.length === 0)
10
+ return [];
11
+ const sorted = [...entries].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
12
+ const flows = [];
13
+ let current = [sorted[0]];
14
+ let flowIdx = 1;
15
+ for (let i = 1; i < sorted.length; i++) {
16
+ const prev = new Date(sorted[i - 1].timestamp).getTime();
17
+ const curr = new Date(sorted[i].timestamp).getTime();
18
+ if (curr - prev > FLOW_GAP_MS) {
19
+ const first = new Date(current[0].timestamp).getTime();
20
+ const last = new Date(current[current.length - 1].timestamp).getTime();
21
+ flows.push({ flowId: `flow-${flowIdx++}`, entries: current, durationMs: last - first });
22
+ current = [];
23
+ }
24
+ current.push(sorted[i]);
25
+ }
26
+ if (current.length > 0) {
27
+ const first = new Date(current[0].timestamp).getTime();
28
+ const last = new Date(current[current.length - 1].timestamp).getTime();
29
+ flows.push({ flowId: `flow-${flowIdx}`, entries: current, durationMs: last - first });
30
+ }
31
+ return flows;
32
+ }
33
+ function sanitizeHeaders(headers) {
34
+ const result = {};
35
+ for (const [k, v] of Object.entries(headers)) {
36
+ result[k] = SENSITIVE_HEADER.test(k) ? "<redacted>" : v;
37
+ }
38
+ return result;
39
+ }
40
+ function parseSkyrampTrace(data) {
41
+ return data.map((entry) => ({
42
+ method: (entry.method || entry.request?.method || "GET").toUpperCase(),
43
+ path: entry.path || entry.url || entry.request?.url || "/",
44
+ statusCode: entry.statusCode || entry.status || entry.response?.status || 0,
45
+ requestHeaders: entry.request?.headers ? sanitizeHeaders(entry.request.headers) : undefined,
46
+ requestBody: entry.request?.body || entry.requestBody,
47
+ responseHeaders: entry.response?.headers ? sanitizeHeaders(entry.response.headers) : undefined,
48
+ responseBody: summarizeBody(entry.response?.body || entry.responseBody),
49
+ timestamp: entry.timestamp || entry.request?.timestamp || "",
50
+ }));
51
+ }
52
+ function parseHarTrace(harData) {
53
+ const entries = harData?.log?.entries || [];
54
+ return entries.map((entry) => {
55
+ const req = entry.request || {};
56
+ const res = entry.response || {};
57
+ const url = new URL(req.url || "http://localhost/");
58
+ const reqHeaders = {};
59
+ for (const h of req.headers || [])
60
+ reqHeaders[h.name] = h.value;
61
+ const resHeaders = {};
62
+ for (const h of res.headers || [])
63
+ resHeaders[h.name] = h.value;
64
+ let reqBody;
65
+ if (req.postData?.text) {
66
+ try {
67
+ reqBody = JSON.parse(req.postData.text);
68
+ }
69
+ catch {
70
+ reqBody = req.postData.text;
71
+ }
72
+ }
73
+ let resBody;
74
+ if (res.content?.text) {
75
+ try {
76
+ resBody = JSON.parse(res.content.text);
77
+ }
78
+ catch {
79
+ resBody = res.content.text;
80
+ }
81
+ }
82
+ return {
83
+ method: (req.method || "GET").toUpperCase(),
84
+ path: url.pathname + (url.search || ""),
85
+ statusCode: res.status || 0,
86
+ requestHeaders: sanitizeHeaders(reqHeaders),
87
+ requestBody: reqBody,
88
+ responseHeaders: sanitizeHeaders(resHeaders),
89
+ responseBody: summarizeBody(resBody),
90
+ timestamp: entry.startedDateTime || "",
91
+ };
92
+ });
93
+ }
94
+ function summarizeBody(body) {
95
+ if (!body)
96
+ return undefined;
97
+ const str = typeof body === "string" ? body : JSON.stringify(body);
98
+ if (str.length > 2000) {
99
+ if (typeof body === "object" && body !== null) {
100
+ const keys = Object.keys(body);
101
+ return { _summarized: true, keys, _truncatedAt: 2000 };
102
+ }
103
+ return str.slice(0, 2000) + "...<truncated>";
104
+ }
105
+ return body;
106
+ }
107
+ export async function parseTraceFile(filePath) {
108
+ const raw = fs.readFileSync(filePath, "utf-8");
109
+ let data;
110
+ try {
111
+ data = JSON.parse(raw);
112
+ }
113
+ catch {
114
+ return { entries: [], userFlows: [], format: "unknown" };
115
+ }
116
+ let entries;
117
+ let format;
118
+ if (data?.log?.entries) {
119
+ entries = parseHarTrace(data);
120
+ format = "har";
121
+ }
122
+ else if (Array.isArray(data)) {
123
+ entries = parseSkyrampTrace(data);
124
+ format = "skyramp";
125
+ }
126
+ else if (data?.traces && Array.isArray(data.traces)) {
127
+ entries = parseSkyrampTrace(data.traces);
128
+ format = "skyramp";
129
+ }
130
+ else {
131
+ return { entries: [], userFlows: [], format: "unknown" };
132
+ }
133
+ const userFlows = groupIntoFlows(entries);
134
+ return { entries, userFlows, format };
135
+ }
136
+ /**
137
+ * Discover trace files in a repository path.
138
+ */
139
+ export function discoverTraceFiles(repositoryPath) {
140
+ const candidates = [
141
+ "skyramp_traces.json",
142
+ "traces.json",
143
+ "trace.json",
144
+ "traces.har",
145
+ "trace.har",
146
+ ];
147
+ const found = [];
148
+ for (const name of candidates) {
149
+ const full = `${repositoryPath}/${name}`;
150
+ if (fs.existsSync(full))
151
+ found.push(full);
152
+ }
153
+ const skyrampDir = `${repositoryPath}/.skyramp`;
154
+ if (fs.existsSync(skyrampDir)) {
155
+ try {
156
+ const files = fs.readdirSync(skyrampDir);
157
+ for (const f of files) {
158
+ if (/\.(json|har)$/i.test(f) && /trace/i.test(f)) {
159
+ found.push(`${skyrampDir}/${f}`);
160
+ }
161
+ }
162
+ }
163
+ catch { /* dir not readable */ }
164
+ }
165
+ return found;
166
+ }
@@ -0,0 +1,16 @@
1
+ import { WorkspaceConfigManager } from "@skyramp/skyramp";
2
+ export async function getWorkspaceAuthHeader(repositoryPath) {
3
+ try {
4
+ const wsMgr = new WorkspaceConfigManager(repositoryPath);
5
+ if (await wsMgr.exists()) {
6
+ const wsConfig = await wsMgr.read();
7
+ const firstServiceAuth = wsConfig.services?.find((s) => s.api?.authHeader)?.api?.authHeader;
8
+ if (firstServiceAuth)
9
+ return firstServiceAuth;
10
+ }
11
+ }
12
+ catch {
13
+ // Workspace config not available
14
+ }
15
+ return undefined;
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyramp/mcp",
3
- "version": "0.0.59",
3
+ "version": "0.0.60-rc.2",
4
4
  "main": "build/index.js",
5
5
  "type": "module",
6
6
  "bin": {