@skyramp/mcp 0.1.4 → 0.1.5

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