@skyramp/mcp 0.1.3 → 0.1.4

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,132 +1,44 @@
1
1
  import { ScenarioSource } from "../types/RepositoryAnalysis.js";
2
2
  import { TestType } from "../types/TestTypes.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
- }
3
+ import { CATEGORY_PRIORITY, PRIORITY_TIER_ORDER } from "../types/TestRecommendation.js";
4
+ import { inferExpectedStatus } from "./httpDefaults.js";
15
5
  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
- }
34
6
  /**
35
7
  * Extract the primary resource name from an endpoint path.
8
+ * Returns the last non-param, non-skip segment.
36
9
  * E.g. "/api/v1/flow-costs/{cost_id}" → "flow-costs"
37
10
  * "/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.
38
15
  */
39
16
  function extractResourceName(path) {
40
17
  const segments = path.split("/").filter(Boolean);
41
18
  const nonParam = segments.filter(s => !s.startsWith("{") && !SKIP_SEGMENTS.has(s));
42
- const name = nonParam[nonParam.length - 1];
43
- return name && isRealResource(name) ? name : null;
19
+ return nonParam[nonParam.length - 1] ?? null;
44
20
  }
45
21
  /**
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.
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"
58
30
  */
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;
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;
128
40
  }
129
- export function draftScenariosFromEndpoints(endpoints, newEndpoints = []) {
41
+ export function draftScenariosFromEndpoints(endpoints, newEndpoints = [], changedEndpoints = []) {
130
42
  const scenarios = [];
131
43
  const resourceGroups = new Map();
132
44
  for (const ep of endpoints) {
@@ -142,13 +54,27 @@ export function draftScenariosFromEndpoints(endpoints, newEndpoints = []) {
142
54
  }
143
55
  const existing = resourceGroups.get(resource);
144
56
  if (existing) {
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;
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
+ }
149
68
  }
150
- if (paramPath && (!existing.paramPath || paramPath.split("/").length < existing.paramPath.split("/").length)) {
151
- existing.paramPath = paramPath;
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
+ });
152
78
  }
153
79
  }
154
80
  else {
@@ -159,11 +85,17 @@ export function draftScenariosFromEndpoints(endpoints, newEndpoints = []) {
159
85
  });
160
86
  }
161
87
  }
162
- scenarios.push(...draftDiffDirectScenarios(newEndpoints, resourceGroups));
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
+ }
163
95
  return capScenarios(scenarios);
164
96
  }
165
97
  const MAX_TOTAL_SCENARIOS = 30;
166
- const TIER_ORDER = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
98
+ const TIER_ORDER = PRIORITY_TIER_ORDER;
167
99
  /**
168
100
  * Enforce a global cap on drafted scenarios while preserving category diversity.
169
101
  *
@@ -198,405 +130,84 @@ function capScenarios(scenarios) {
198
130
  // Enforce the hard cap even if critical set alone exceeds it
199
131
  return combined.slice(0, MAX_TOTAL_SCENARIOS);
200
132
  }
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.
206
133
  /**
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.
134
+ * Draft minimal seed scenarios for a set of endpoints.
479
135
  *
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
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
486
141
  *
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.
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.
492
145
  */
493
- export function draftDiffDirectScenarios(newEndpoints, resourceGroups) {
494
- if (newEndpoints.length === 0)
146
+ export function draftMinimalScenarios(endpoints, _resourceGroups, category) {
147
+ if (endpoints.length === 0)
495
148
  return [];
496
149
  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.
497
152
  const seen = new Set();
153
+ const add = (key, s) => {
154
+ if (!seen.has(key)) {
155
+ seen.add(key);
156
+ scenarios.push(s);
157
+ }
158
+ };
498
159
  // Group flat list by path
499
160
  const grouped = new Map();
500
- for (const ep of newEndpoints) {
161
+ for (const ep of endpoints) {
501
162
  const methods = grouped.get(ep.path) ?? new Set();
502
163
  methods.add(ep.method.toUpperCase());
503
164
  grouped.set(ep.path, methods);
504
165
  }
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
- };
166
+ const suffix = category === "new_endpoint" ? "new" : "changed";
520
167
  for (const [epPath, methods] of grouped) {
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
- };
168
+ const slug = pathSlug(epPath);
571
169
  for (const method of methods) {
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));
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
+ });
597
210
  }
598
- if (method === "PUT" || method === "PATCH" || method === "DELETE")
599
- add(diffDirectAuthBoundary(method, resolvedPath, resource));
600
211
  }
601
212
  }
602
213
  return scenarios;