@skyramp/mcp 0.1.4 → 0.1.6

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 (33) hide show
  1. package/build/index.js +6 -5
  2. package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +11 -7
  3. package/build/prompts/personas.js +2 -1
  4. package/build/prompts/test-maintenance/drift-analysis-prompt.js +2 -1
  5. package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +28 -0
  6. package/build/prompts/test-maintenance/driftAnalysisSections.js +2 -2
  7. package/build/prompts/test-recommendation/analysisOutputPrompt.js +74 -16
  8. package/build/prompts/test-recommendation/analysisOutputPrompt.test.js +154 -0
  9. package/build/prompts/test-recommendation/recommendationSections.js +13 -43
  10. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +19 -0
  11. package/build/prompts/test-recommendation/test-recommendation-prompt.js +158 -70
  12. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +24 -117
  13. package/build/prompts/testbot/testbot-prompts.js +12 -18
  14. package/build/prompts/testbot/testbot-prompts.test.js +2 -2
  15. package/build/resources/analysisResources.js +1 -0
  16. package/build/tools/code-refactor/enhanceAssertionsTool.js +2 -1
  17. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +127 -4
  18. package/build/tools/generate-tests/generateBatchScenarioRestTool.test.js +205 -18
  19. package/build/tools/generate-tests/generateContractRestTool.js +19 -19
  20. package/build/tools/generate-tests/generateIntegrationRestTool.js +9 -2
  21. package/build/tools/generate-tests/generateUIRestTool.js +23 -8
  22. package/build/tools/test-management/analyzeChangesTool.js +222 -11
  23. package/build/tools/test-management/analyzeChangesTool.test.js +233 -1
  24. package/build/types/TestRecommendation.js +0 -2
  25. package/build/utils/featureFlags.js +4 -22
  26. package/build/utils/featureFlags.test.js +81 -0
  27. package/build/utils/httpDefaults.js +6 -1
  28. package/build/utils/httpDefaults.test.js +21 -0
  29. package/build/utils/scenarioDrafting.js +511 -100
  30. package/build/utils/scenarioDrafting.test.js +545 -259
  31. package/build/utils/telemetry.js +2 -1
  32. package/build/utils/utils.js +23 -0
  33. package/package.json +1 -1
@@ -1,44 +1,134 @@
1
1
  import { ScenarioSource } from "../types/RepositoryAnalysis.js";
2
2
  import { TestType } from "../types/TestTypes.js";
3
- import { CATEGORY_PRIORITY, PRIORITY_TIER_ORDER } from "../types/TestRecommendation.js";
3
+ import { CATEGORY_PRIORITY } from "../types/TestRecommendation.js";
4
4
  import { inferExpectedStatus } from "./httpDefaults.js";
5
+ 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|unknown)$/i;
6
+ const ACTION_VERB_HYPHEN = /^(forgot-|reset-|verify-|confirm-|send-|check-|get-|set-|update-|delete-|create-|trigger-|start-|stop-)/i;
7
+ /**
8
+ * Returns true when `a` starts with `b` at a path-segment boundary.
9
+ * Plain `startsWith` is not sufficient — "/orders-archive".startsWith("/orders") is true
10
+ * even though they are different resources. Requiring the next character to be "/" or
11
+ * end-of-string ensures only genuine path prefixes are matched.
12
+ */
13
+ const isSegmentPrefix = (a, b) => a.startsWith(b) && (a[b.length] === "/" || a[b.length] === undefined);
14
+ export function isRealResource(r) {
15
+ return !ACTION_PATTERN.test(r) && !ACTION_VERB_HYPHEN.test(r);
16
+ }
5
17
  const SKIP_SEGMENTS = new Set(["api", "v1", "v2", "v3", "public"]);
18
+ /**
19
+ * Convert a plural resource name to its singular form for use in field names
20
+ * and step descriptions.
21
+ *
22
+ * Handles the most common irregular plurals found in REST API paths:
23
+ * -ies → -y (categories → category, companies → company)
24
+ * -ses → -s (statuses → status, classes → class)
25
+ * Falls back to removing the trailing "s" for regular plurals.
26
+ */
27
+ function singularize(word) {
28
+ if (word.endsWith("ies") && word.length > 3) {
29
+ return word.slice(0, -3) + "y";
30
+ }
31
+ if (word.endsWith("ses") && word.length > 4) {
32
+ return word.slice(0, -2); // statuses → status
33
+ }
34
+ return word.endsWith("s") && word.length > 1 ? word.slice(0, -1) : word;
35
+ }
6
36
  /**
7
37
  * Extract the primary resource name from an endpoint path.
8
- * Returns the last non-param, non-skip segment.
9
38
  * E.g. "/api/v1/flow-costs/{cost_id}" → "flow-costs"
10
39
  * "/collections/{id}/links" → "links"
11
- * "/api/orders/search" → "search"
12
- *
13
- * Action sub-paths (search, archive, etc.) are NOT filtered here.
14
- * The LLM resolves the full path from source code during enrichment.
15
40
  */
16
41
  function extractResourceName(path) {
17
42
  const segments = path.split("/").filter(Boolean);
18
43
  const nonParam = segments.filter(s => !s.startsWith("{") && !SKIP_SEGMENTS.has(s));
19
- return nonParam[nonParam.length - 1] ?? null;
44
+ const name = nonParam[nonParam.length - 1];
45
+ return name && isRealResource(name) ? name : null;
20
46
  }
21
47
  /**
22
- * Derive a scenario-name slug from a resolved path.
23
- * Uses the last 2 meaningful (non-param, non-skip) segments joined with "-".
24
- * Appends "-by-id" when the path ends with a path parameter to disambiguate
25
- * collection endpoints from item endpoints:
26
- * /api/v1/orders "orders"
27
- * /api/v1/orders/{id} → "orders-by-id"
28
- * /api/orders/search → "orders-search"
29
- * /api/products/search"products-search"
48
+ * Infer parent→child resource relationships from two signals already present
49
+ * in the endpoint data no source code scanning required.
50
+ *
51
+ * Signal 1 path nesting (always available):
52
+ * /collections/{id}/links links depends on collections
53
+ *
54
+ * Signal 2 — request body FK fields (available when trace data is merged):
55
+ * POST /orders body: { product_id: "..." } orders depends on products
56
+ *
57
+ * Returns a map of `dependent → Set<parent>`. Only confirmed, real-resource
58
+ * pairs are included. When no signal is found the map is empty, which tells
59
+ * the caller to fall back to heuristic pairing.
30
60
  */
31
- function pathSlug(resolvedPath) {
32
- const segments = resolvedPath.split("/").filter(Boolean);
33
- const meaningful = segments.filter(s => !s.startsWith("{") && !SKIP_SEGMENTS.has(s));
34
- const base = meaningful.slice(-2).join("-") || "unknown";
35
- // Append "-by-id" if path ends with a param segment to distinguish from collection
36
- const lastSeg = segments[segments.length - 1];
37
- if (lastSeg && lastSeg.startsWith("{"))
38
- return `${base}-by-id`;
39
- return base;
61
+ export function inferResourceRelationships(endpoints) {
62
+ const relationships = new Map();
63
+ const addRelationship = (dependent, parent) => {
64
+ if (dependent === parent)
65
+ return;
66
+ if (!isRealResource(dependent) || !isRealResource(parent))
67
+ return;
68
+ const deps = relationships.get(dependent) ?? new Set();
69
+ deps.add(parent);
70
+ relationships.set(dependent, deps);
71
+ };
72
+ // ── Signal 1: path nesting ──
73
+ // Pattern: /…/parentResource/{param}/childResource
74
+ for (const ep of endpoints) {
75
+ const segments = ep.path.split("/").filter(Boolean);
76
+ for (let i = 0; i + 2 < segments.length; i++) {
77
+ const seg = segments[i];
78
+ const paramSeg = segments[i + 1];
79
+ const childSeg = segments[i + 2];
80
+ if (!seg.startsWith("{") &&
81
+ paramSeg.startsWith("{") &&
82
+ !childSeg.startsWith("{") &&
83
+ !SKIP_SEGMENTS.has(seg) &&
84
+ !SKIP_SEGMENTS.has(childSeg)) {
85
+ addRelationship(childSeg, seg);
86
+ }
87
+ }
88
+ }
89
+ // ── Signal 2: FK fields in request body interactions ──
90
+ // Collect all known resource names first so we can validate FK targets.
91
+ const knownResources = new Set(endpoints.map(ep => extractResourceName(ep.path)).filter(Boolean));
92
+ // Map singular form → actual resource name to handle irregular plurals.
93
+ // e.g. singularize("companies") = "company", so "company_id" resolves to "companies".
94
+ const singularToResource = new Map([...knownResources].map(r => [singularize(r), r]));
95
+ for (const ep of endpoints) {
96
+ const resource = extractResourceName(ep.path);
97
+ if (!resource)
98
+ continue;
99
+ for (const m of ep.methods) {
100
+ if (typeof m === "string")
101
+ continue;
102
+ if (!["POST", "PUT", "PATCH"].includes(m.method))
103
+ continue;
104
+ for (const interaction of m.interactions ?? []) {
105
+ const body = interaction.request?.body;
106
+ if (!body || typeof body !== "object")
107
+ continue;
108
+ for (const key of Object.keys(body)) {
109
+ if (!key.endsWith("_id"))
110
+ continue;
111
+ const depSingular = key.slice(0, -3); // "product_id" → "product"
112
+ const depPlural = depSingular + "s"; // naive plural, fine for regular nouns
113
+ // Only create relationship when the dependency actually exists as an endpoint.
114
+ // Check naive plural first (products), then exact singular (product),
115
+ // then reverse-singularize to catch irregular plurals (company → companies).
116
+ if (knownResources.has(depPlural)) {
117
+ addRelationship(resource, depPlural);
118
+ }
119
+ else if (knownResources.has(depSingular)) {
120
+ addRelationship(resource, depSingular);
121
+ }
122
+ else if (singularToResource.has(depSingular)) {
123
+ addRelationship(resource, singularToResource.get(depSingular));
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ return relationships;
40
130
  }
41
- export function draftScenariosFromEndpoints(endpoints, newEndpoints = [], changedEndpoints = []) {
131
+ export function draftScenariosFromEndpoints(endpoints, newEndpoints = []) {
42
132
  const scenarios = [];
43
133
  const resourceGroups = new Map();
44
134
  for (const ep of endpoints) {
@@ -54,8 +144,8 @@ export function draftScenariosFromEndpoints(endpoints, newEndpoints = [], change
54
144
  }
55
145
  const existing = resourceGroups.get(resource);
56
146
  if (existing) {
57
- // Only merge if paths are related (one is a prefix of the other).
58
- const related = basePath.startsWith(existing.basePath) || existing.basePath.startsWith(basePath);
147
+ // Only merge if the paths are related at a segment boundary.
148
+ const related = isSegmentPrefix(basePath, existing.basePath) || isSegmentPrefix(existing.basePath, basePath);
59
149
  if (related) {
60
150
  for (const m of ep.methods)
61
151
  existing.methods.add(typeof m === "string" ? m : m.method);
@@ -67,9 +157,9 @@ export function draftScenariosFromEndpoints(endpoints, newEndpoints = [], change
67
157
  }
68
158
  }
69
159
  else {
70
- // Unrelated paths sharing a last segment (e.g. /products/search vs /orders/search).
71
- // Store under a disambiguated key for resource group tracking.
72
- const disambiguatedKey = `${resource}__${basePath.replace(/\//g, "_")}`;
160
+ // Use the raw basePath as the disambiguating suffix — replacing "/" with "_" could
161
+ // produce collisions between e.g. /orders/search and /orders_search.
162
+ const disambiguatedKey = `${resource}::${basePath}`;
73
163
  resourceGroups.set(disambiguatedKey, {
74
164
  basePath,
75
165
  methods: new Set(ep.methods.map((m) => typeof m === "string" ? m : m.method)),
@@ -85,17 +175,11 @@ export function draftScenariosFromEndpoints(endpoints, newEndpoints = [], change
85
175
  });
86
176
  }
87
177
  }
88
- scenarios.push(...draftMinimalScenarios(newEndpoints, resourceGroups, "new_endpoint"));
89
- // Filter out changed endpoints that overlap with new endpoints (new takes priority)
90
- const newEpKeys = new Set(newEndpoints.map(ep => `${ep.method.toUpperCase()} ${ep.path}`));
91
- const uniqueChanged = changedEndpoints.filter(ep => !newEpKeys.has(`${ep.method.toUpperCase()} ${ep.path}`));
92
- if (uniqueChanged.length > 0) {
93
- scenarios.push(...draftMinimalScenarios(uniqueChanged, resourceGroups, "business_rule"));
94
- }
178
+ scenarios.push(...draftDiffDirectScenarios(newEndpoints, resourceGroups));
95
179
  return capScenarios(scenarios);
96
180
  }
97
181
  const MAX_TOTAL_SCENARIOS = 30;
98
- const TIER_ORDER = PRIORITY_TIER_ORDER;
182
+ const TIER_ORDER = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
99
183
  /**
100
184
  * Enforce a global cap on drafted scenarios while preserving category diversity.
101
185
  *
@@ -130,84 +214,411 @@ function capScenarios(scenarios) {
130
214
  // Enforce the hard cap even if critical set alone exceeds it
131
215
  return combined.slice(0, MAX_TOTAL_SCENARIOS);
132
216
  }
217
+ // ── Diff-direct scenario drafting ──
218
+ // Generates targeted scenarios for each new endpoint in the branch diff.
219
+ // These get category "new_endpoint" which maps to the CRITICAL priority tier
220
+ // in the scorer, so they always fill the GENERATE slots before any structural
221
+ // scenario — aligning the plan with what the LLM naturally wants to do anyway.
222
+ /**
223
+ * Build the minimum steps for a diff-direct integration scenario.
224
+ * Prerequisite resources (e.g. POST /products before POST /orders) are NOT
225
+ * computed here — the execution plan instructs the LLM to discover them from
226
+ * source code (FK fields in request bodies) and prepend the steps itself.
227
+ */
228
+ function diffDirectIntegration(method, resource, singular, group) {
229
+ const steps = [];
230
+ if (method === "POST") {
231
+ steps.push({
232
+ order: 1, method: "POST", path: group.basePath,
233
+ description: `Create ${singular} and verify response`,
234
+ interactionType: "success", expectedStatusCode: 201,
235
+ });
236
+ if (group.paramPath && group.methods.has("GET")) {
237
+ steps.push({
238
+ order: 2, method: "GET", path: group.paramPath,
239
+ description: `Retrieve created ${singular} and verify fields`,
240
+ interactionType: "success", expectedStatusCode: 200,
241
+ chainsFrom: { sourceStep: 1, sourceField: "id", sourceLocation: "body", targetParam: `${singular}_id`, targetLocation: "path" },
242
+ });
243
+ }
244
+ }
245
+ else if (method === "DELETE") {
246
+ if (group.methods.has("POST")) {
247
+ steps.push({
248
+ order: 1, method: "POST", path: group.basePath,
249
+ description: `Create ${singular} to delete`,
250
+ interactionType: "success", expectedStatusCode: 201,
251
+ });
252
+ }
253
+ const targetPath = group.paramPath ?? group.basePath;
254
+ steps.push({
255
+ order: steps.length + 1, method: "DELETE", path: targetPath,
256
+ description: `Delete ${singular}`,
257
+ interactionType: "success", expectedStatusCode: 204,
258
+ ...(steps.length > 0 ? { chainsFrom: { sourceStep: 1, sourceField: "id", sourceLocation: "body", targetParam: `${singular}_id`, targetLocation: "path" } } : {}),
259
+ });
260
+ if (group.paramPath && group.methods.has("GET")) {
261
+ steps.push({
262
+ order: steps.length + 1, method: "GET", path: group.paramPath,
263
+ description: `Verify ${singular} is deleted (404)`,
264
+ interactionType: "error", expectedStatusCode: 404,
265
+ });
266
+ }
267
+ }
268
+ const suffix = method === "DELETE" ? "delete" : method.toLowerCase();
269
+ return {
270
+ scenarioName: `${resource}-${suffix}-lifecycle`,
271
+ description: `Lifecycle test: ${method} ${group.basePath} — verify ${method === "DELETE" ? "delete and subsequent 404" : "create and read-back"} flow`,
272
+ category: "new_endpoint",
273
+ priority: "high",
274
+ steps,
275
+ chainingKeys: ["id", `${singular}_id`],
276
+ requiresAuth: true,
277
+ estimatedComplexity: "moderate",
278
+ source: ScenarioSource.CodeInferred,
279
+ testType: TestType.INTEGRATION,
280
+ };
281
+ }
282
+ function diffDirectContract(method, path, resource) {
283
+ const expectedStatus = inferExpectedStatus(method);
284
+ return {
285
+ scenarioName: `${resource}-${method.toLowerCase()}-new-endpoint-contract`,
286
+ description: `Contract: ${method} ${path} returns a schema-conformant response`,
287
+ category: "new_endpoint",
288
+ priority: "high",
289
+ steps: [{ order: 1, method, path, description: `${method} ${path} with valid input — verify response schema`, interactionType: "success", expectedStatusCode: expectedStatus }],
290
+ chainingKeys: [],
291
+ requiresAuth: true,
292
+ estimatedComplexity: "simple",
293
+ source: ScenarioSource.CodeInferred,
294
+ testType: TestType.CONTRACT,
295
+ };
296
+ }
297
+ function diffDirectNotFound(method, path, resource, singular) {
298
+ return {
299
+ scenarioName: `${resource}-${method.toLowerCase()}-new-endpoint-not-found`,
300
+ description: `Edge case: ${method} ${path} with a non-existent ${singular} ID returns 404`,
301
+ category: "new_endpoint",
302
+ priority: "high",
303
+ steps: [{ order: 1, method, path, description: `${method} ${path} with non-existent ${singular} ID — expect 404 Not Found`, interactionType: "error", expectedStatusCode: 404 }],
304
+ chainingKeys: [],
305
+ requiresAuth: true,
306
+ estimatedComplexity: "simple",
307
+ source: ScenarioSource.CodeInferred,
308
+ testType: TestType.CONTRACT,
309
+ };
310
+ }
311
+ function diffDirectValidation(method, path, resource) {
312
+ return {
313
+ scenarioName: `${resource}-${method.toLowerCase()}-new-endpoint-validation`,
314
+ description: `Validation: ${method} ${path} with missing required fields returns 422`,
315
+ category: "new_endpoint",
316
+ priority: "high",
317
+ steps: [{ order: 1, method, path, description: `${method} ${path} with empty/missing required fields — expect 422`, interactionType: "error", expectedStatusCode: 422 }],
318
+ chainingKeys: [],
319
+ requiresAuth: true,
320
+ estimatedComplexity: "simple",
321
+ source: ScenarioSource.CodeInferred,
322
+ testType: TestType.CONTRACT,
323
+ };
324
+ }
325
+ /**
326
+ * Draft a "mutation with collection modification" scenario for PUT/PATCH endpoints.
327
+ * This tests adding/removing child items (e.g., order line items) and verifying that
328
+ * derived totals (total_amount, item_count, subtotal) are recalculated.
329
+ * This pattern catches the most common class of user-reported bugs.
330
+ */
331
+ function diffDirectBoundaryValues(method, resource, singular, group) {
332
+ const steps = [];
333
+ const targetPath = group.paramPath ?? group.basePath;
334
+ const pathParamName = group.paramPath?.match(/\{([^}]+)\}/)?.[1] ?? `${singular}_id`;
335
+ if (group.methods.has("POST")) {
336
+ steps.push({
337
+ order: 1,
338
+ method: "POST",
339
+ path: group.basePath,
340
+ description: `Create a ${singular} for boundary testing`,
341
+ interactionType: "success",
342
+ expectedStatusCode: 201,
343
+ });
344
+ }
345
+ steps.push({
346
+ order: steps.length + 1,
347
+ method,
348
+ path: targetPath,
349
+ description: `${method} with valid boundary values (e.g. 0, maximum allowed, empty collection) — expect 200`,
350
+ interactionType: "success",
351
+ expectedStatusCode: 200,
352
+ // Signal the execution plan that a body is required. The agent reads source
353
+ // to discover the actual numeric/percentage field names to test at boundaries.
354
+ bodyMustInclude: ["numeric_or_percentage_field"],
355
+ ...(steps.length > 0 && group.paramPath
356
+ ? { chainsFrom: { sourceStep: 1, sourceField: "id", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
357
+ : {}),
358
+ });
359
+ return {
360
+ scenarioName: `${resource}-${method.toLowerCase()}-boundary-values`,
361
+ description: `Boundary test: ${method} ${targetPath} — test valid boundary values (0%, 100%, minimum, maximum allowed by schema). Read source to identify numeric/percentage fields and their constraints; assert the response reflects the boundary value correctly.`,
362
+ category: "new_endpoint",
363
+ priority: "high",
364
+ steps,
365
+ chainingKeys: ["id", pathParamName],
366
+ requiresAuth: true,
367
+ estimatedComplexity: "moderate",
368
+ source: ScenarioSource.CodeInferred,
369
+ testType: TestType.INTEGRATION,
370
+ };
371
+ }
372
+ function diffDirectMutationRecalc(method, resource, singular, group) {
373
+ const steps = [];
374
+ if (group.methods.has("POST")) {
375
+ steps.push({
376
+ order: 1,
377
+ method: "POST",
378
+ path: group.basePath,
379
+ description: `Create a ${singular} for testing`,
380
+ interactionType: "success",
381
+ expectedStatusCode: 201,
382
+ });
383
+ }
384
+ const targetPath = group.paramPath ?? group.basePath;
385
+ const pathParamName = group.paramPath?.match(/\{([^}]+)\}/)?.[1] ?? `${singular}_id`;
386
+ const sourceStep = steps.length;
387
+ steps.push({
388
+ order: steps.length + 1,
389
+ method,
390
+ path: targetPath,
391
+ description: `${method} the ${singular} with valid changes — read source to find mutable fields`,
392
+ interactionType: "success",
393
+ expectedStatusCode: 200,
394
+ ...(sourceStep > 0 && group.paramPath
395
+ ? { chainsFrom: { sourceStep, sourceField: "id", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
396
+ : {}),
397
+ });
398
+ if (group.paramPath && group.methods.has("GET")) {
399
+ steps.push({
400
+ order: steps.length + 1,
401
+ method: "GET",
402
+ path: group.paramPath,
403
+ description: `Verify the ${singular} reflects changes — check any derived/calculated fields`,
404
+ interactionType: "success",
405
+ expectedStatusCode: 200,
406
+ ...(sourceStep > 0 && group.paramPath
407
+ ? { chainsFrom: { sourceStep, sourceField: "id", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
408
+ : {}),
409
+ });
410
+ }
411
+ return {
412
+ scenarioName: `${resource}-${method.toLowerCase()}-mutation-verify`,
413
+ description: `Mutation test: ${method} ${targetPath} — update fields and verify derived calculations (totals, discounts, sums, quantities) are recomputed correctly. Test at least one calculation-affecting change (e.g. discount percentage, quantity update, item addition/removal).`,
414
+ category: "new_endpoint",
415
+ priority: "high",
416
+ steps,
417
+ chainingKeys: ["id", pathParamName],
418
+ requiresAuth: true,
419
+ estimatedComplexity: "complex",
420
+ source: ScenarioSource.CodeInferred,
421
+ testType: TestType.INTEGRATION,
422
+ };
423
+ }
424
+ function diffDirectAuthBoundary(method, path, resource) {
425
+ return {
426
+ scenarioName: `${resource}-${method.toLowerCase()}-auth-boundary`,
427
+ description: `Auth boundary: ${method} ${path} without authentication returns 401 Unauthorized`,
428
+ category: "security_boundary",
429
+ priority: "high",
430
+ steps: [{ order: 1, method, path, description: `${method} ${path} without Authorization header — expect 401 Unauthorized`, interactionType: "error", expectedStatusCode: 401 }],
431
+ chainingKeys: [],
432
+ requiresAuth: false,
433
+ estimatedComplexity: "simple",
434
+ source: ScenarioSource.CodeInferred,
435
+ testType: TestType.CONTRACT,
436
+ };
437
+ }
438
+ /**
439
+ * Draft a unified scenario for GET endpoints without path params (collection or search).
440
+ * Rather than hardcoding keyword detection, the description instructs the LLM to read
441
+ * the diff source and determine whether query params are involved, then draft variations
442
+ * accordingly.
443
+ */
444
+ function diffDirectGetCollection(path, resource, singular, group) {
445
+ const steps = [];
446
+ if (group.methods.has("POST")) {
447
+ steps.push({
448
+ order: 1,
449
+ method: "POST",
450
+ path: group.basePath,
451
+ description: `Create a ${singular} for query verification`,
452
+ interactionType: "success",
453
+ expectedStatusCode: 201,
454
+ });
455
+ steps.push({
456
+ order: 2,
457
+ method: "GET",
458
+ path,
459
+ description: `Query ${resource} and verify results include the created item`,
460
+ interactionType: "success",
461
+ expectedStatusCode: 200,
462
+ });
463
+ }
464
+ else {
465
+ steps.push({
466
+ order: 1,
467
+ method: "GET",
468
+ path,
469
+ description: `Query ${resource} and verify response structure`,
470
+ interactionType: "success",
471
+ expectedStatusCode: 200,
472
+ });
473
+ }
474
+ // Include a path-specific suffix in scenarioName to avoid collision when multiple GET
475
+ // collection endpoints exist for the same resource (e.g. /products and /products/featured).
476
+ // Strip API prefix segments (api, v1, ...) and param segments, take the last 1-2 meaningful ones.
477
+ const meaningfulSegs = path.split("/").filter(s => s && !s.startsWith("{") && !SKIP_SEGMENTS.has(s));
478
+ const pathSlug = meaningfulSegs.slice(-2).join("-") || resource;
479
+ const uniqueName = pathSlug === resource ? `${resource}-list-query` : `${pathSlug}-list-query`;
480
+ return {
481
+ scenarioName: uniqueName,
482
+ description: `List/query test: GET ${path} — read source to find supported query params (filters, pagination, search), then test with and without them`,
483
+ category: "new_endpoint",
484
+ priority: "high",
485
+ steps,
486
+ chainingKeys: ["id", `${singular}_id`],
487
+ requiresAuth: true,
488
+ estimatedComplexity: group.methods.has("POST") ? "moderate" : "simple",
489
+ source: ScenarioSource.CodeInferred,
490
+ testType: group.methods.has("POST") ? TestType.INTEGRATION : TestType.CONTRACT,
491
+ };
492
+ }
133
493
  /**
134
- * Draft minimal seed scenarios for a set of endpoints.
494
+ * Draft scenarios that directly test each new endpoint in the branch diff.
135
495
  *
136
- * Per endpoint-method:
137
- * GET 1 contract
138
- * POST 1 contract + 1 integration
139
- * PUT/PATCH 1 contract + 1 integration
140
- * DELETE 1 contract + 1 integration
496
+ * For each new endpoint, method-specific scenarios are produced:
497
+ * PUT/PATCH mutation-recalc integration, boundary-values integration, contract, not-found
498
+ * POST lifecycle integration (create + optional GET), contract, validation error
499
+ * DELETE lifecycle integration (create + delete + 404), contract
500
+ * GET /{id} → contract, not-found
501
+ * GET /coll → contract + collection-list integration (create-then-GET) or contract-only
141
502
  *
142
- * Each scenario is a single-step seed. The LLM enriches integration
143
- * scenarios with prerequisite steps, chaining, and read-back verification
144
- * during source-code analysis.
503
+ * Additionally, PUT/PATCH/DELETE methods get an auth-boundary scenario
504
+ * (category "security_boundary" HIGH priority, not CRITICAL) so it
505
+ * lands in ADDITIONAL deterministically instead of depending on LLM
506
+ * supplement. GET is excluded (often public); POST is excluded because
507
+ * the security-boundary supplement tier covers it.
145
508
  */
146
- export function draftMinimalScenarios(endpoints, _resourceGroups, category) {
147
- if (endpoints.length === 0)
509
+ export function draftDiffDirectScenarios(newEndpoints, resourceGroups) {
510
+ if (newEndpoints.length === 0)
148
511
  return [];
149
512
  const scenarios = [];
150
- // Dedup by the true coverage identity (method + path + testType), not just scenarioName.
151
- // This prevents silent loss when different paths produce the same resource-based name.
152
513
  const seen = new Set();
153
- const add = (key, s) => {
154
- if (!seen.has(key)) {
155
- seen.add(key);
156
- scenarios.push(s);
157
- }
158
- };
159
514
  // Group flat list by path
160
515
  const grouped = new Map();
161
- for (const ep of endpoints) {
516
+ for (const ep of newEndpoints) {
162
517
  const methods = grouped.get(ep.path) ?? new Set();
163
518
  methods.add(ep.method.toUpperCase());
164
519
  grouped.set(ep.path, methods);
165
520
  }
166
- const suffix = category === "new_endpoint" ? "new" : "changed";
521
+ // Segment-boundary suffix match: avoids "/orders" matching "/preorders".
522
+ const pathSuffixMatches = (fullPath, suffix) => {
523
+ if (fullPath === suffix)
524
+ return true;
525
+ const fullSegs = fullPath.split("/").filter(Boolean);
526
+ const suffixSegs = suffix.split("/").filter(Boolean);
527
+ if (suffixSegs.length === 0 || suffixSegs.length > fullSegs.length)
528
+ return false;
529
+ const offset = fullSegs.length - suffixSegs.length;
530
+ for (let i = 0; i < suffixSegs.length; i++) {
531
+ if (fullSegs[offset + i] !== suffixSegs[i])
532
+ return false;
533
+ }
534
+ return true;
535
+ };
167
536
  for (const [epPath, methods] of grouped) {
168
- const slug = pathSlug(epPath);
537
+ let resource = extractResourceName(epPath);
538
+ let resolvedPath = epPath;
539
+ // The map key to use for the final resourceGroups lookup — may differ from `resource`
540
+ // when the entry was stored under a disambiguated "resource::basePath" key.
541
+ let resourceGroupKey = resource;
542
+ // When resource was found but epPath may be router-relative (e.g. "/orders"
543
+ // instead of "/api/v1/orders"), try to upgrade resolvedPath to the full path.
544
+ if (resource) {
545
+ const existingGroup = resourceGroups.get(resource);
546
+ if (existingGroup) {
547
+ if (existingGroup.paramPath && existingGroup.paramPath !== epPath && pathSuffixMatches(existingGroup.paramPath, epPath)) {
548
+ resolvedPath = existingGroup.paramPath;
549
+ }
550
+ else if (existingGroup.basePath !== epPath && pathSuffixMatches(existingGroup.basePath, epPath)) {
551
+ resolvedPath = existingGroup.basePath;
552
+ }
553
+ }
554
+ }
555
+ // When extractResourceName returns null OR the resource isn't in resourceGroups,
556
+ // fall back to finding a matching group via prefix or suffix matching.
557
+ if (!resource || !resourceGroups.has(resource)) {
558
+ let bestMatch = null;
559
+ for (const [rName, rGroup] of resourceGroups) {
560
+ // Prefix match: epPath starts with group's basePath (e.g. /products/search → /products)
561
+ if (epPath.startsWith(rGroup.basePath + "/") || epPath === rGroup.basePath) {
562
+ if (!bestMatch || rGroup.basePath.length > bestMatch.length) {
563
+ bestMatch = { name: rName, path: rGroup.basePath, length: rGroup.basePath.length, isPrefixMatch: true };
564
+ }
565
+ }
566
+ // Suffix match for router-relative paths (e.g. /{order_id} → /api/v1/orders/{order_id})
567
+ if (rGroup.paramPath && pathSuffixMatches(rGroup.paramPath, epPath)) {
568
+ if (!bestMatch || rGroup.paramPath.length > bestMatch.length) {
569
+ bestMatch = { name: rName, path: rGroup.paramPath, length: rGroup.paramPath.length, isPrefixMatch: false };
570
+ }
571
+ }
572
+ }
573
+ if (!bestMatch)
574
+ continue;
575
+ // Keep the full map key for lookup — disambiguated keys use "resource::basePath" format.
576
+ // Use only the prefix before "::" as the resource name for scenario naming/singularization.
577
+ resourceGroupKey = bestMatch.name;
578
+ resource = resourceGroupKey.includes("::") ? resourceGroupKey.split("::")[0] : resourceGroupKey;
579
+ // For prefix matches (action sub-paths), keep original path; for suffix matches, use resolved path
580
+ resolvedPath = bestMatch.isPrefixMatch ? epPath : bestMatch.path;
581
+ }
582
+ const group = resourceGroups.get(resourceGroupKey);
583
+ if (!group)
584
+ continue;
585
+ const singular = singularize(resource);
586
+ const hasPathParam = resolvedPath.includes("{");
587
+ const add = (s) => {
588
+ if (!seen.has(s.scenarioName)) {
589
+ seen.add(s.scenarioName);
590
+ scenarios.push(s);
591
+ }
592
+ };
169
593
  for (const method of methods) {
170
- const isMutating = method !== "GET";
171
- // Contract test for every endpoint-method
172
- const contractKey = `${method}|${epPath}|contract`;
173
- add(contractKey, {
174
- scenarioName: `${slug}-${method.toLowerCase()}-${suffix}-contract`,
175
- description: `Contract: ${method} ${epPath} returns a schema-conformant response`,
176
- category,
177
- priority: "high",
178
- steps: [{
179
- order: 1, method, path: epPath,
180
- description: `${method} ${epPath} with valid input — verify response schema`,
181
- interactionType: "success",
182
- expectedStatusCode: inferExpectedStatus(method),
183
- }],
184
- chainingKeys: [],
185
- requiresAuth: true,
186
- estimatedComplexity: "simple",
187
- source: ScenarioSource.CodeInferred,
188
- testType: TestType.CONTRACT,
189
- });
190
- // Integration test for mutating methods
191
- if (isMutating) {
192
- const integrationKey = `${method}|${epPath}|integration`;
193
- add(integrationKey, {
194
- scenarioName: `${slug}-${method.toLowerCase()}-${suffix}-integration`,
195
- description: `Integration: ${method} ${epPath} — read source to discover prerequisites, then verify the full lifecycle`,
196
- category,
197
- priority: "high",
198
- steps: [{
199
- order: 1, method, path: epPath,
200
- description: `${method} ${epPath} — verify end-to-end flow (LLM adds prerequisite and verification steps from source)`,
201
- interactionType: "success",
202
- expectedStatusCode: inferExpectedStatus(method),
203
- }],
204
- chainingKeys: [],
205
- requiresAuth: true,
206
- estimatedComplexity: "moderate",
207
- source: ScenarioSource.CodeInferred,
208
- testType: TestType.INTEGRATION,
209
- });
594
+ if (method === "PUT" || method === "PATCH") {
595
+ // mutation-recalc tests the primary calculation flow
596
+ add(diffDirectMutationRecalc(method, resource, singular, group));
597
+ // boundary-values tests edge cases (0, max, empty)
598
+ add(diffDirectBoundaryValues(method, resource, singular, group));
599
+ add(diffDirectContract(method, resolvedPath, resource));
600
+ if (hasPathParam)
601
+ add(diffDirectNotFound(method, resolvedPath, resource, singular));
602
+ }
603
+ else if (method === "POST") {
604
+ add(diffDirectIntegration(method, resource, singular, group));
605
+ add(diffDirectContract(method, resolvedPath, resource));
606
+ add(diffDirectValidation(method, resolvedPath, resource));
607
+ }
608
+ else if (method === "DELETE") {
609
+ add(diffDirectIntegration(method, resource, singular, group));
610
+ add(diffDirectContract(method, resolvedPath, resource));
611
+ }
612
+ else if (method === "GET" && hasPathParam) {
613
+ add(diffDirectContract(method, resolvedPath, resource));
614
+ add(diffDirectNotFound(method, resolvedPath, resource, singular));
615
+ }
616
+ else if (method === "GET" && !hasPathParam) {
617
+ add(diffDirectContract(method, resolvedPath, resource));
618
+ add(diffDirectGetCollection(resolvedPath, resource, singular, group));
210
619
  }
620
+ if (method === "PUT" || method === "PATCH" || method === "DELETE")
621
+ add(diffDirectAuthBoundary(method, resolvedPath, resource));
211
622
  }
212
623
  }
213
624
  return scenarios;