@skyramp/mcp 0.1.6 → 0.1.7

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,14 +1,15 @@
1
1
  import * as crypto from "crypto";
2
2
  import { AnalysisScope, isDiff, } from "../../types/RepositoryAnalysis.js";
3
- import { WorkspaceAuthType, getDefaultAuthHeader, AUTH_MIDDLEWARE_PATTERNS_STR } from "../../utils/workspaceAuth.js";
3
+ import { WorkspaceAuthType, getDefaultAuthHeader } from "../../utils/workspaceAuth.js";
4
4
  import { logger } from "../../utils/logger.js";
5
- import { extractResourceFromPath } from "../../utils/routeParsers.js";
6
- import { buildArchitectPreamble, buildContextFetchingGuidance, buildReasoningProtocol, buildToolWorkflows, buildTestPatternGuidelines, buildTestQualityCriteria, buildFewShotExamples, buildVerificationChecklist, buildGenerationRules, getAuthSnippets, MAX_TESTS_TO_GENERATE, MAX_RECOMMENDATIONS, MAX_CRITICAL_TESTS, } from "./recommendationSections.js";
7
- import { CATEGORY_PRIORITY, TEST_CATEGORIES } from "../../types/TestRecommendation.js";
5
+ import { buildArchitectPreamble, buildContextFetchingGuidance, buildReasoningProtocol, buildToolWorkflows, buildFewShotExamples, buildVerificationChecklist, getAuthSnippets, MAX_TESTS_TO_GENERATE, MAX_RECOMMENDATIONS, } from "./recommendationSections.js";
6
+ import { CATEGORY_PRIORITY } from "../../types/TestRecommendation.js";
8
7
  import { buildScopeAssessmentSection, isFrontendFile } from "./scopeAssessment.js";
9
- import { resolveServiceDetailsRef } from "../../utils/utils.js";
10
- // Cached at module-load flag is process-wide and cannot change per call.
11
- const SERVICE_REFS = resolveServiceDetailsRef();
8
+ import { buildExecutionPlan } from "./diffExecutionPlan.js";
9
+ import { buildFullRepoRecommendations } from "./fullRepoCatalog.js";
10
+ import { buildExternalCoverageSet, externalDedupKey, } from "./recommendationShared.js";
11
+ // Re-export for backward compatibility (tests and external callers import these from this module)
12
+ export { buildExternalCoverageSet, externalDedupKey };
12
13
  function formatTestLocations(locs) {
13
14
  const entries = Object.entries(locs || {});
14
15
  if (entries.length === 0)
@@ -54,640 +55,7 @@ function computeTiebreakerSeed(endpoints, diffFiles) {
54
55
  const canonical = [...endpoints].sort().join("|") + "::" + [...diffFiles].sort().join("|");
55
56
  return crypto.createHash("sha256").update(canonical).digest("hex").slice(0, 8);
56
57
  }
57
- // ── Helpers ──
58
- /** Resolve the primary step and inferred test type for a scenario. */
59
- function resolvePrimaryStep(scenario) {
60
- const testType = scenario.testType ?? (scenario.steps.length === 1 ? "contract" : "integration");
61
- const mutatingSteps = scenario.steps.filter(st => ["POST", "PUT", "PATCH", "DELETE"].includes(st.method));
62
- // Use the last mutating step — earlier steps are typically prerequisite setup
63
- // (e.g. POST /products before PATCH /orders), while the final mutation is the
64
- // primary action under test.
65
- const primaryStep = mutatingSteps[mutatingSteps.length - 1] ?? scenario.steps[scenario.steps.length - 1];
66
- return { primaryStep, testType };
67
- }
68
- function scenarioCoverageKey(scenario) {
69
- const { primaryStep, testType } = resolvePrimaryStep(scenario);
70
- const resource = extractResourceFromPath(primaryStep?.path ?? "");
71
- return `${resource}::${testType}`;
72
- }
73
- /**
74
- * Method-aware coverage key for external test dedup.
75
- * Unlike scenarioCoverageKey (resource::testType), this includes the HTTP method
76
- * so that e.g. an external test covering "GET /orders" doesn't block generating
77
- * a test for "PUT /orders" — a different operation on the same resource.
78
- */
79
- function externalDedupKey(scenario) {
80
- const { primaryStep, testType } = resolvePrimaryStep(scenario);
81
- const method = primaryStep?.method ?? "GET";
82
- const resource = extractResourceFromPath(primaryStep?.path ?? "");
83
- return `${method}::${resource}::${testType}`;
84
- }
85
- /**
86
- * Build a set of coverage keys from external (non-Skyramp) tests.
87
- * Parses `testLocations` entries tagged with `[external]` to extract the
88
- * method-aware `METHOD::resource::testType` keys they cover. This allows
89
- * programmatic filtering of scenarios that duplicate external test coverage
90
- * while preserving distinct operations on the same resource (for example,
91
- * `GET::orders::integration` vs `PUT::orders::integration`) — complementing
92
- * the prompt-level Step 0 dedup instructions with an algorithmic guarantee.
93
- *
94
- * Format of testLocations: Record<testType, "file1 [external] (covers: GET /api/v1/orders, POST /api/v1/orders), file2 (covers: ...)">
95
- */
96
- function buildExternalCoverageSet(testLocations) {
97
- const coverage = new Set();
98
- let externalWithoutCoverage = 0;
99
- for (const [testType, fileList] of Object.entries(testLocations)) {
100
- // Count external files with no covers clause — these fall back to prompt-level dedup only
101
- const externalCount = (fileList.match(/\[external\]/g) || []).length;
102
- const coveredCount = (fileList.match(/\[external\]\s*\(covers:/g) || []).length;
103
- externalWithoutCoverage += externalCount - coveredCount;
104
- // Match all "[external] (covers: ...)" segments in the file list string.
105
- // Each match captures the covers clause for one external test file.
106
- for (const m of fileList.matchAll(/\[external\]\s*\(covers:\s*([^)]+)\)/g)) {
107
- const endpoints = m[1].split(",").map(e => e.trim());
108
- for (const ep of endpoints) {
109
- // ep is "METHOD /path" e.g. "GET /api/v1/orders/{order_id}"
110
- const spaceIdx = ep.indexOf(" ");
111
- if (spaceIdx < 0)
112
- continue;
113
- const method = ep.slice(0, spaceIdx).toUpperCase();
114
- const epPath = ep.slice(spaceIdx + 1);
115
- const resource = extractResourceFromPath(epPath);
116
- if (resource !== "unknown") {
117
- // Method-aware key: "GET::orders::integration" — matches externalDedupKey() format.
118
- // When testType is "unknown" (heuristic failed), emit keys for both integration and
119
- // contract to avoid silent misses — conservative over-blocking is preferable.
120
- if (testType === "unknown") {
121
- coverage.add(`${method}::${resource}::integration`);
122
- coverage.add(`${method}::${resource}::contract`);
123
- }
124
- else {
125
- coverage.add(`${method}::${resource}::${testType}`);
126
- }
127
- }
128
- }
129
- }
130
- }
131
- if (externalWithoutCoverage > 0) {
132
- logger.info(`${externalWithoutCoverage} external test file(s) have no extractable endpoint coverage — ` +
133
- `programmatic dedup skipped for these; Step 0 semantic check is the fallback.`);
134
- }
135
- return coverage;
136
- }
137
58
  // ── Execution Plan (replaces pre-ranked + scenarios + heuristic sections) ──
138
- function buildFullRepoRecommendations(scored, topN, baseUrl, authHeaderValue, authSchemeSnippet, authTypeValue, isFrontendProject = false, isFrontendOnlyProject = false, externalCoverage = new Set()) {
139
- // Full-repo mode only — percentage-based UI/E2E slot targets (15% each, floor 1).
140
- const rawE2E = isFrontendProject ? Math.max(1, Math.round(topN * 0.15)) : 0;
141
- const rawUI = isFrontendProject ? Math.max(1, Math.round(topN * 0.15)) : 0;
142
- const slotsFloor = Math.floor(topN / 2);
143
- const minE2ESlots = Math.min(rawE2E, slotsFloor);
144
- const minUISlots = Math.min(rawUI, Math.max(0, topN - minE2ESlots));
145
- const authRef = authHeaderValue
146
- ? `, authHeader: "${authHeaderValue}"${authSchemeSnippet}`
147
- : `, authHeader: <check OpenAPI securitySchemes or auth middleware; "" if confirmed unauthenticated>`;
148
- const hasWorkspaceAuthType = !!authTypeValue && authTypeValue !== "none";
149
- const scenarioAuthRef = authRef;
150
- const authHeaderOnlyRef = hasWorkspaceAuthType
151
- ? ""
152
- : authHeaderValue
153
- ? `, authHeader: "${authHeaderValue}"`
154
- : `, authHeader: <check OpenAPI securitySchemes or auth middleware; "" if confirmed unauthenticated>`;
155
- // Supplement count for full-repo mode
156
- const supplementCount = topN - Math.min(scored.length, topN);
157
- const toTitle = (name) => name.replace(/-/g, " ").replace(/\b\w/g, c => c.toUpperCase());
158
- const TYPE_ORDER = ["e2e", "ui", "integration", "contract"];
159
- const TYPE_LABEL = {
160
- e2e: "E2E", ui: "UI", integration: "Integration", contract: "Contract",
161
- };
162
- // Filter out scenarios already covered by external tests before slicing.
163
- const scoredFiltered = externalCoverage.size > 0
164
- ? scored.filter(item => {
165
- const key = externalDedupKey(item.scenario);
166
- if (externalCoverage.has(key)) {
167
- logger.info(`External dedup (full-repo): skipping "${item.scenario.scenarioName}" (${key})`);
168
- return false;
169
- }
170
- return true;
171
- })
172
- : scored;
173
- // For full-stack repos, carve out E2E and UI slots before filling with backend tests.
174
- const backendSlotCount = isFrontendProject
175
- ? Math.max(0, topN - minE2ESlots - minUISlots)
176
- : topN;
177
- const allItems = scoredFiltered.slice(0, backendSlotCount);
178
- const byType = new Map();
179
- for (const t of TYPE_ORDER)
180
- byType.set(t, []);
181
- for (const item of allItems) {
182
- const t = item.scenario.testType ?? (item.scenario.steps.length === 1 ? "contract" : "integration");
183
- if (!byType.has(t))
184
- byType.set(t, []);
185
- byType.get(t).push(item);
186
- }
187
- const renderItem = (item, rank) => {
188
- const s = item.scenario;
189
- const testType = s.testType ?? (s.steps.length === 1 ? "contract" : "integration");
190
- const title = toTitle(s.scenarioName);
191
- if (testType === "contract") {
192
- const step = s.steps[0];
193
- const endpointURL = `${baseUrl}${step.path}`;
194
- const isBodyMethod = ["POST", "PUT", "PATCH"].includes(step.method);
195
- const dataParam = isBodyMethod
196
- ? `, requestData: <${step.method} ${step.path} required fields from source code>`
197
- : "";
198
- return [
199
- `**${rank}. ${title}**`,
200
- ` ${s.description}`,
201
- ` ${step.method} ${step.path} \u2192 ${step.expectedStatusCode}`,
202
- ` Tool: \`skyramp_contract_test_generation({ endpointURL: "${endpointURL}", method: "${step.method}"${authRef}${dataParam} })\``,
203
- ` From source: fill in requestData field names and the specific production boundary this validates`,
204
- ].join("\n");
205
- }
206
- else {
207
- const stepLines = s.steps.map(st => {
208
- const isBody = ["POST", "PUT", "PATCH"].includes(st.method);
209
- const bodyHint = isBody ? ` \u2014 body: <${st.method} ${st.path} required fields from source>` : "";
210
- return ` ${st.order}. ${st.method} ${st.path} \u2192 ${st.expectedStatusCode}: ${st.description}${bodyHint}`;
211
- }).join("\n");
212
- const isTraceBased = testType === "e2e" || testType === "ui";
213
- let toolCallsBlock;
214
- if (isTraceBased) {
215
- // E2E and UI need browser recording first, then generation
216
- const frontendUrl = "<frontend_url>";
217
- const zipPath = `<repositoryPath>/.skyramp/${s.scenarioName}_trace.zip`;
218
- if (testType === "ui") {
219
- toolCallsBlock = [
220
- ` 1. browser_navigate({ url: "${frontendUrl}" })`,
221
- ` 2. Interact with the changed components (browser_click, browser_type, browser_fill_form, etc.)`,
222
- ` 3. browser_snapshot() after each key interaction`,
223
- ` 4. skyramp_export_zip({ outputPath: "${zipPath}" }) — use absolute path`,
224
- ` 5. skyramp_ui_test_generation({ playwrightInput: "${zipPath}"${authHeaderOnlyRef} })`,
225
- ].join("\n");
226
- }
227
- else {
228
- toolCallsBlock = [
229
- ` 1. browser_navigate({ url: "${frontendUrl}" }) — record frontend trace`,
230
- ` 2. Interact with the user journey described above`,
231
- ` 3. skyramp_export_zip({ outputPath: "${zipPath}" }) — use absolute path`,
232
- ` 4. Capture backend trace JSON separately (skyramp_start_trace_collection / skyramp_stop_trace_collection)`,
233
- ` 5. skyramp_e2e_test_generation({ playwrightInput: "${zipPath}", trace: "<backend trace path>"${authHeaderOnlyRef} })`,
234
- ].join("\n");
235
- }
236
- }
237
- else {
238
- // Integration: use batch scenario tool (all steps in one call)
239
- let destinationHost = s.scenarioName;
240
- try {
241
- destinationHost = new URL(baseUrl).hostname;
242
- }
243
- catch { /* keep fallback */ }
244
- const batchSteps = s.steps.map(st => {
245
- const isBody = ["POST", "PUT", "PATCH"].includes(st.method);
246
- let dataParam = "";
247
- if (isBody) {
248
- if (st.requestBody && Object.keys(st.requestBody).length > 0) {
249
- const bodyJson = JSON.stringify(st.requestBody).replace(/"/g, '\\"');
250
- dataParam = `, requestBody: "${bodyJson}"`;
251
- }
252
- else {
253
- dataParam = `, requestBody: <${st.method} ${st.path} required fields from source code>`;
254
- }
255
- }
256
- return ` { method: "${st.method}", path: "${st.path}", statusCode: ${st.expectedStatusCode}${dataParam} }`;
257
- }).join(",\n");
258
- toolCallsBlock = [
259
- ` skyramp_batch_scenario_test_generation({ scenarioName: "${s.scenarioName}", destination: "${destinationHost}", baseURL: "${baseUrl}"${scenarioAuthRef}, steps: [\n${batchSteps}\n ] })`,
260
- ` skyramp_integration_test_generation({ scenarioFile: <filePath returned by skyramp_batch_scenario_test_generation above>${authHeaderOnlyRef} })`,
261
- ].join("\n");
262
- }
263
- return [
264
- `**${rank}. ${title}**`,
265
- ` ${s.description}`,
266
- ` Steps:`,
267
- stepLines,
268
- ` Tool calls:`,
269
- toolCallsBlock,
270
- ` From source: fill in requestBody field values and assert all computed response fields`,
271
- ].join("\n");
272
- }
273
- };
274
- const backendSections = TYPE_ORDER
275
- .filter(t => (byType.get(t) ?? []).length > 0)
276
- .map(t => {
277
- const items = byType.get(t);
278
- const label = TYPE_LABEL[t];
279
- let globalRank = 0;
280
- for (const prev of TYPE_ORDER) {
281
- if (prev === t)
282
- break;
283
- globalRank += (byType.get(prev) ?? []).length;
284
- }
285
- const entries = items.map((item, i) => renderItem(item, globalRank + i + 1)).join("\n\n");
286
- return `### ${label} (${items.length})\n\n${entries}`;
287
- });
288
- // Pre-allocate E2E and UI placeholder sections for full-stack repos.
289
- const e2eSectionParts = [];
290
- const uiSectionParts = [];
291
- if (isFrontendProject) {
292
- for (let i = 0; i < minE2ESlots; i++) {
293
- const rank = i + 1;
294
- e2eSectionParts.push(`**${rank}. E2E User Journey ${i + 1}**\n` +
295
- ` End-to-end test covering a complete user journey through the frontend and backend.\n` +
296
- ` To generate: record a browser trace, then call the generation tool.\n` +
297
- ` browser_navigate({ url: "${baseUrl}" }) \u2192 exercise key user flow \u2192 skyramp_export_zip({ outputPath: "<repo>/.skyramp/e2e_journey_${i + 1}.zip" })\n` +
298
- ` Tool: \`skyramp_e2e_test_generation({ playwrightInput: "<repo>/.skyramp/e2e_journey_${i + 1}.zip"${authHeaderOnlyRef} })\`\n` +
299
- ` From source: read frontend components and their API calls to identify the highest-value user journey`);
300
- }
301
- for (let i = 0; i < minUISlots; i++) {
302
- const rank = minE2ESlots + i + 1;
303
- uiSectionParts.push(`**${rank}. UI Component Test ${i + 1}**\n` +
304
- ` Test key UI component interactions and state changes.\n` +
305
- ` To generate: record a browser trace, then call the generation tool.\n` +
306
- ` browser_navigate({ url: "${baseUrl}" }) \u2192 interact with UI components \u2192 skyramp_export_zip({ outputPath: "<repo>/.skyramp/ui_component_${i + 1}.zip" })\n` +
307
- ` Tool: \`skyramp_ui_test_generation({ playwrightInput: "<repo>/.skyramp/ui_component_${i + 1}.zip"${authHeaderOnlyRef} })\`\n` +
308
- ` From source: read frontend component files to identify interactions, form submissions, and state transitions`);
309
- }
310
- // Offset backend section ranks by the number of E2E + UI placeholders
311
- const offset = minE2ESlots + minUISlots;
312
- backendSections.forEach((_, idx) => {
313
- const t = TYPE_ORDER.filter(t => (byType.get(t) ?? []).length > 0)[idx];
314
- if (!t)
315
- return;
316
- const items = byType.get(t);
317
- const label = TYPE_LABEL[t];
318
- let globalRank = offset;
319
- for (const prev of TYPE_ORDER) {
320
- if (prev === t)
321
- break;
322
- globalRank += (byType.get(prev) ?? []).length;
323
- }
324
- backendSections[idx] = `### ${label} (${items.length})\n\n${items.map((item, i) => renderItem(item, globalRank + i + 1)).join("\n\n")}`;
325
- });
326
- }
327
- const allSections = [
328
- ...(e2eSectionParts.length > 0 ? [`### E2E (${e2eSectionParts.length})\n\n${e2eSectionParts.join("\n\n")}`] : []),
329
- ...(uiSectionParts.length > 0 ? [`### UI (${uiSectionParts.length})\n\n${uiSectionParts.join("\n\n")}`] : []),
330
- ...backendSections,
331
- ];
332
- const sections = allSections.join("\n\n");
333
- const frontendTierNote = isFrontendOnlyProject
334
- ? `\n\n**Frontend repo:** supplement MUST include at least ${minE2ESlots} E2E test${minE2ESlots > 1 ? "s" : ""} (\`skyramp_e2e_test_generation\`) and at least ${minUISlots} UI test${minUISlots > 1 ? "s" : ""} (\`skyramp_ui_test_generation\`). Do NOT add integration or contract tests.`
335
- : isFrontendProject
336
- ? `\n\n**Full-stack repo:** supplement MUST include at least ${minE2ESlots} E2E test${minE2ESlots > 1 ? "s" : ""} (\`skyramp_e2e_test_generation\`) and at least ${minUISlots} UI test${minUISlots > 1 ? "s" : ""} (\`skyramp_ui_test_generation\`). Add these before exhausting backend tiers.`
337
- : "";
338
- const repoSupplementNote = supplementCount > 0
339
- ? `
340
- <supplement_guidance>
341
- **When to use:** The pre-ranked sections above contain fewer than ${topN} items. Add exactly ${supplementCount} more using the tiers below — exhaust each tier before moving to the next.
342
-
343
- **Tier 1 — Error paths for endpoints already in the list** (highest value, do first):
344
- • Auth boundary (no Authorization header → 403/401) → \`testType: contract, category: security_boundary\`
345
- • Invalid/non-existent IDs (→ 404) → \`testType: contract, category: error_handling\`
346
- • Missing required fields (→ 422) → \`testType: contract, category: data_validation\`
347
- • Boundary values for numeric fields → \`testType: integration, category: data_validation\`
348
- Note: DISCARD unique-constraint scenarios if the storage backend is Redis, MongoDB, or schema-less.
349
-
350
- **Tier 2 — Auth coverage for any endpoint not yet covered by Tier 1:**
351
- → \`testType: contract, category: security_boundary\`
352
-
353
- **Tier 3 — Cross-resource integration** (only when one resource's POST body contains another's \`_id\` field):
354
- → \`testType: integration, category: workflow\`
355
-
356
- **Tier 4 — CRUD lifecycle** for any resource not yet covered:
357
- → \`testType: integration, category: crud\`
358
-
359
- **How to fill each item:** Use path parameters in \`{param}\` format. Use real field names from the analysis or handler source — no generic placeholders. Describe behavior in API terms (HTTP method, path, status code), not storage internals.${frontendTierNote}
360
- </supplement_guidance>`
361
- : "";
362
- const typeMixText = isFrontendOnlyProject
363
- ? `This is a frontend repo. Focus on E2E and UI tests only. Include at least ${minE2ESlots} E2E test${minE2ESlots > 1 ? "s" : ""} (\`skyramp_e2e_test_generation\`) and at least ${minUISlots} UI test${minUISlots > 1 ? "s" : ""} (\`skyramp_ui_test_generation\`). Do NOT add integration or contract tests.`
364
- : isFrontendProject
365
- ? `This is a full-stack repo. Coverage ranking: E2E > UI > Integration > Contract. Include at least ${minE2ESlots} E2E test${minE2ESlots > 1 ? "s" : ""} (\`skyramp_e2e_test_generation\`) and at least ${minUISlots} UI test${minUISlots > 1 ? "s" : ""} (\`skyramp_ui_test_generation\`), in addition to backend integration and contract tests.`
366
- : `Focus on integration and contract tests for all API endpoints.`;
367
- return `## Test Recommendations — ${topN} total (grouped by test type)
368
-
369
- > Repo mode — no tests are executed. Ranked by risk within each type.
370
- > To generate any item: read the handler source, fill \`<…from source>\` placeholders with real values, then call the tool.
371
-
372
- ${sections}
373
-
374
- **Test type mix — MANDATORY. No smoke tests. No fuzz tests. Only: integration, contract, E2E, UI.**
375
- ${typeMixText}
376
-
377
- ${repoSupplementNote}
378
-
379
- **Present up to ${topN} recommendations.** Prioritize quality — only include a recommendation if it adds genuine new coverage. If fewer than ${topN} high-value tests exist for this codebase, stop at the last useful item rather than padding with trivial ones.
380
-
381
- ---
382
- <enrichment_notes>
383
- **Path resolution (do this before filling in any tool call):**
384
- Cross-check every endpoint path against the Router Mounting / Nesting section in the analysis above. Sub-routers may be mounted at nested prefixes — e.g. a reviews router with \`@router.get("/")\` may actually be \`GET /api/v1/products/{product_id}/reviews\` if mounted under that prefix. Always use the fully-qualified nested path in tool calls, not the path as it appears in the route file alone.
385
-
386
- **Existing test files (check before assigning output filenames):**
387
- See the Existing Tests section above. If a recommendation's primary resource already has a \`[skyramp]\` test file listed there, prefer passing an explicit \`output\` filename (e.g. \`output: "orders_integration_test.py"\`) to update the existing file rather than creating a duplicate. Do NOT update \`[external]\` test files — they are user-maintained.
388
-
389
- Before filling in tool call parameters for each item, use the analysis data already provided above (endpoint interactions, source context) first. Only read the route handler source code directly when the analysis data does not contain the specific value you need:
390
- - Required request body fields (POST/PUT/PATCH) — use field names from the analysis interactions; read source only if they show \`{}\` or are missing
391
- - Computed/derived response fields and their formulas — assert exact values; read source for formula details not captured in the analysis
392
- - Auth middleware — set authHeader/authScheme from the repository context above; FastAPI HTTPBearer → 403 not 401
393
- - Storage backend — if Redis or schema-less, discard unique-constraint and cascade-delete scenarios
394
- - Delete behavior — hard-delete → 204; soft-delete/cancel → 200
395
-
396
- ${buildTestQualityCriteria()}
397
-
398
- **5-dimension rubric — use to assign priority for supplement items:**
399
- | Dimension | What to assess |
400
- | Production Safety | Guards a critical boundary (auth, unique constraint, cascade delete, data integrity, breaking migration)? → HIGH |
401
- | Bug-Finding Potential | Targets a known failure mode (race condition, data consistency, state transition, cascade effect)? → HIGH |
402
- | User Journey Relevance | Reflects how real users interact (from traces, business flows, critical paths)? → HIGH or MEDIUM |
403
- | Coverage Gap | Addresses an area with zero existing test coverage? → bump up one tier |
404
- | Code Insight | Derived from actual implementation (spotted middleware pattern, N+1 risk, unique constraint)? → bump up one tier |
405
- </enrichment_notes>`;
406
- }
407
- function buildExecutionPlan(scored, maxGen, topN, baseUrl, authHeaderValue, authSchemeSnippet, authTypeValue, seed, endpointCount, isUIOnlyPR, hasFrontendChanges = false, hasTraces = false, externalCoverage = new Set(), relevantExternalTestPaths = []) {
408
- const frontendUrl = "<frontend_url>";
409
- // Slot allocation:
410
- // - UI-only PR: all GENERATE slots are UI placeholders (no pre-ranked backend scenarios)
411
- // - Mixed PR: last GENERATE slot is a UI placeholder; remaining slots are backend
412
- // - Backend-only PR: all GENERATE slots are backend scenarios
413
- const backendGenerateCount = isUIOnlyPR
414
- ? 0
415
- : hasFrontendChanges
416
- ? Math.max(0, maxGen - 1)
417
- : maxGen;
418
- // Filter out scenarios whose primary method + resource + test type is already covered by external tests.
419
- // Method-aware: an external test covering GET /orders won't block PUT /orders scenarios.
420
- // This is the programmatic complement to the prompt-level Step 0 dedup instructions.
421
- const scoredAfterExternalDedup = externalCoverage.size > 0
422
- ? scored.filter(item => {
423
- const key = externalDedupKey(item.scenario);
424
- if (externalCoverage.has(key)) {
425
- logger.info(`External dedup: skipping "${item.scenario.scenarioName}" (${key}) — covered by external test`);
426
- return false;
427
- }
428
- return true;
429
- })
430
- : scored;
431
- const generateItems = scoredAfterExternalDedup.slice(0, Math.min(backendGenerateCount, scoredAfterExternalDedup.length));
432
- const rawAdditionalItems = scoredAfterExternalDedup.slice(backendGenerateCount, topN);
433
- // Filter additional items whose primary resource + test type already appear in GENERATE
434
- const generatedCoverage = new Set(generateItems.map(item => scenarioCoverageKey(item.scenario)));
435
- const additionalItems = rawAdditionalItems.filter(item => !generatedCoverage.has(scenarioCoverageKey(item.scenario)));
436
- const hasWorkspaceAuthType = !!authTypeValue && authTypeValue !== "none";
437
- // For skyramp_integration_test_generation with scenarioFile:
438
- // - If workspace has authType set: omit auth entirely — workspace handles Bearer prefix.
439
- // - If no authType: pass authHeader only (no authScheme).
440
- const authHeaderOnlyRef = hasWorkspaceAuthType
441
- ? ""
442
- : authHeaderValue
443
- ? `, authHeader: "${authHeaderValue}"`
444
- : `, authHeader: <check OpenAPI securitySchemes or auth middleware; "" if confirmed unauthenticated>`;
445
- // UI-only: all GENERATE slots are UI test placeholders (one per changed component/flow)
446
- const uiGenerateBlocks = isUIOnlyPR
447
- ? Array.from({ length: maxGen }, (_, i) => {
448
- const rank = i + 1;
449
- const zipPath = `<repositoryPath>/.skyramp/ui_test_${rank}_trace.zip`;
450
- return hasTraces
451
- ? (`**#${rank} — GENERATE** | ui | workflow | new\n` +
452
- `Scenario: ui-test-from-trace-${rank} (rename from the actual changed component/flow)\n` +
453
- `Validates: UI interactions for a changed frontend component or flow.\n\n` +
454
- `**Tool**: \`skyramp_ui_test_generation({ playwrightInput: "<discovered_trace_file_path>", outputDir: "<frontend_output_dir>" })\` — set \`outputDir\` to ${SERVICE_REFS.frontendTestDirRef}`)
455
- : (`**#${rank} — GENERATE** | ui | workflow | new\n` +
456
- `Scenario: ui-test-for-changed-component-${rank} (rename from the actual changed component/flow)\n` +
457
- `Validates: UI interactions for changed frontend component/flow ${rank}.\n\n` +
458
- `**Tool workflow:**\n` +
459
- ` 1. \`browser_navigate({ url: "${frontendUrl}" })\`\n` +
460
- ` 2. Interact with the changed component (read the diff to identify which component changed and what interactions it supports)\n` +
461
- ` 3. \`browser_snapshot()\` after each key interaction\n` +
462
- ` 4. \`skyramp_export_zip({ outputPath: "${zipPath}" })\` — absolute path\n` +
463
- ` 5. \`skyramp_ui_test_generation({ playwrightInput: "${zipPath}", outputDir: "<frontend_output_dir>" })\` — set \`outputDir\` to ${SERVICE_REFS.frontendTestDirRef}\n\n` +
464
- `Each item must target a distinct changed component or user flow.`);
465
- }).join("\n\n")
466
- : "";
467
- // Mixed PR: reserve the last GENERATE slot for a UI test for the changed frontend components.
468
- // Guard: skip when maxGen=0 (caller explicitly requested no generation)
469
- const uiRank = generateItems.length + 1;
470
- const uiPlaceholderBlock = (hasFrontendChanges && !isUIOnlyPR && maxGen > 0)
471
- ? hasTraces
472
- ? (`**#${uiRank} — GENERATE** | ui | workflow | new\n` +
473
- `Scenario: ui-test-for-changed-components (rename from the actual changed component/flow)\n` +
474
- `Validates: UI interactions for the changed frontend components in this PR.\n\n` +
475
- `**Tool**: \`skyramp_ui_test_generation({ playwrightInput: "<discovered_trace_file_path>", outputDir: "<frontend_output_dir>" })\` — set \`outputDir\` to ${SERVICE_REFS.frontendTestDirRef}`)
476
- : (`**#${uiRank} — GENERATE** | ui | workflow | new\n` +
477
- `Scenario: ui-test-for-changed-components (rename from the actual changed component/flow)\n` +
478
- `Validates: UI interactions for the changed frontend components in this PR.\n\n` +
479
- `**Tool workflow:**\n` +
480
- ` 1. \`browser_navigate({ url: "${frontendUrl}" })\`\n` +
481
- ` 2. Interact with the changed component (read the diff to identify which component changed and what interactions it supports)\n` +
482
- ` 3. \`browser_snapshot()\` after each key interaction\n` +
483
- ` 4. \`skyramp_export_zip({ outputPath: "<repositoryPath>/.skyramp/ui_mixed_pr_trace.zip" })\` — absolute path\n` +
484
- ` 5. \`skyramp_ui_test_generation({ playwrightInput: "<repositoryPath>/.skyramp/ui_mixed_pr_trace.zip", outputDir: "<frontend_output_dir>" })\` — set \`outputDir\` to ${SERVICE_REFS.frontendTestDirRef}\n\n` +
485
- `Derive scenario name and steps from the actual changed frontend files.`)
486
- : "";
487
- const generateBlocks = generateItems.map((item, i) => {
488
- const rank = i + 1;
489
- const s = item.scenario;
490
- const testType = s.testType ?? (s.steps.length === 1 ? "contract" : "integration");
491
- if (testType === "contract") {
492
- const step = s.steps[0];
493
- const endpointURL = `${baseUrl}${step.path}`;
494
- const isBodyMethod = ["POST", "PUT", "PATCH"].includes(step.method);
495
- const requestBodyData = step.requestBody && Object.keys(step.requestBody).length > 0
496
- ? `\n Request body: ${JSON.stringify(step.requestBody)} (pass as JSON string in tool call, NOT as object)`
497
- : (isBodyMethod ? `\n Request body: <derive from source code schemas>` : "");
498
- const authContext = authHeaderValue
499
- ? `\n authHeader: "${authHeaderValue}"${authSchemeSnippet}`
500
- : `\n authHeader: <resolve from workspace or OpenAPI securitySchemes>; authScheme: <if Authorization>`;
501
- return (`**#${rank} — GENERATE** | ${testType} | ${s.category} | ${item.novelty}\n` +
502
- `${step.method} ${step.path} → ${step.expectedStatusCode}\n` +
503
- `Validates: ${s.description}\n\n` +
504
- `**Context for generation**:\n` +
505
- ` Endpoint URL: ${endpointURL}${requestBodyData}${authContext}\n\n` +
506
- `**Tool**: skyramp_contract_test_generation (see tool description for parameter structure)`);
507
- }
508
- else {
509
- // integration / e2e / ui — multi-step scenario pipeline
510
- const stepLines = s.steps.map((st) => {
511
- const chains = st.chainsFrom
512
- ? ` (chains: ${Array.isArray(st.chainsFrom)
513
- ? st.chainsFrom.map(c => `${c.sourceField} from step ${c.sourceStep}`).join(", ")
514
- : `${st.chainsFrom.sourceField} from step ${st.chainsFrom.sourceStep}`})`
515
- : "";
516
- const bodyHint = st.bodyMustInclude?.length
517
- ? ` [required fields: ${st.bodyMustInclude.join(", ")}]`
518
- : "";
519
- const responseHint = st.expectedResponseFields?.length
520
- ? ` [assert: ${st.expectedResponseFields.join(", ")}]`
521
- : "";
522
- const bodyData = st.requestBody && Object.keys(st.requestBody).length > 0
523
- ? ` [use requestBody: ${JSON.stringify(st.requestBody)} — pass as JSON string in tool call]`
524
- : "";
525
- return ` ${st.order}. ${st.method} ${st.path} → ${st.expectedStatusCode}: ${st.description}${chains}${bodyHint}${bodyData}${responseHint}`;
526
- }).join("\n");
527
- let destinationHost = "localhost";
528
- try {
529
- const parsed = new URL(baseUrl);
530
- destinationHost = parsed.hostname;
531
- }
532
- catch { /* use localhost as fallback */ }
533
- const authContext = authHeaderValue
534
- ? `authHeader: "${authHeaderValue}"${authSchemeSnippet}`
535
- : "authHeader: <resolve from workspace or OpenAPI securitySchemes>; authScheme: <if Authorization>";
536
- const prereqNote = s.category === "new_endpoint"
537
- ? `\n**Prerequisite discovery**: Check for FK fields (product_id, user_id, order_id) in the endpoint's request body. If found, prepend a step to create that prerequisite resource first, then chain its primary key field into the dependent step using template variable syntax. Check the actual field name from the response body (\`id\`, \`uuid\`, \`_id\`, etc.), response header (\`Location\`), or cookie — do not assume \`id\`.`
538
- : "";
539
- const bugLine = s.bugCatchingTarget
540
- ? `**Bug to catch**: ${s.bugCatchingTarget}\n`
541
- : "";
542
- const fromSource = s.source === "agent-enriched"
543
- ? "Auth: OpenAPI securitySchemes or auth middleware"
544
- : "Request/response shapes: source code schemas; Auth: OpenAPI securitySchemes or auth middleware";
545
- return (`**#${rank} — GENERATE** | ${testType} | ${s.category} | ${item.novelty}\n` +
546
- `Scenario: ${s.scenarioName} (${s.steps.length} steps)\n` +
547
- bugLine +
548
- `${stepLines}\n\n` +
549
- `**Context for generation**:\n` +
550
- ` - Destination: ${destinationHost}\n` +
551
- ` - Base URL: ${baseUrl}\n` +
552
- ` - ${authContext}\n` +
553
- ` - From source: ${fromSource}\n\n` +
554
- `**Tool pipeline**:\n` +
555
- ` 1. skyramp_batch_scenario_test_generation (see tool description for parameter structure)\n` +
556
- ` 2. skyramp_integration_test_generation with returned scenarioFile${authHeaderOnlyRef ? ` and ${authHeaderOnlyRef.replace(/^,\s*/, '')}` : ""}\n` +
557
- ` **Note**: requestBody/responseBody must be JSON strings (e.g. "{\\"field\\":\\"value\\"}"), not objects.` +
558
- prereqNote);
559
- }
560
- }).join("\n\n");
561
- // Pre-ranked backend additional candidates — the LLM picks from these per its Budget Plan.
562
- const additionalLines = additionalItems.map((item, i) => {
563
- const rank = maxGen + i + 1;
564
- const s = item.scenario;
565
- const testType = s.testType ?? (s.steps.length === 1 ? "contract" : "integration");
566
- const target = s.steps.length === 1
567
- ? `${s.steps[0].method} ${s.steps[0].path} → ${s.steps[0].expectedStatusCode}`
568
- : `Scenario: ${s.scenarioName} (${s.steps.map(st => `${st.method} ${st.path}`).join(" → ")})`;
569
- return `#${rank} [ADDITIONAL] | ${testType} | ${s.category} | ${item.novelty}\n ${target}\n Validates: ${s.description}`;
570
- }).join("\n\n");
571
- // UI/E2E guidance — the LLM adds as many as its Budget Plan calls for.
572
- // Note: if a UI test already occupies a GENERATE slot (uiPlaceholderBlock), that slot
573
- // satisfies the UI generate count — do not add it again in ADDITIONAL.
574
- const uiGuidance = !isUIOnlyPR ? `
575
- **UI/E2E tests (add per your Budget Plan):** If your Budget Plan requires UI/E2E items beyond what is already in your GENERATE list, append an [ADDITIONAL] entry for each. If a UI test already occupies a GENERATE slot above, that slot satisfies your UI/E2E generate count — do NOT add it again to ADDITIONAL. Tool workflow for each new item:
576
- - **E2E**: ${hasTraces ? "Use discovered trace/recording files with `skyramp_e2e_test_generation`." : "Add to additionalRecommendations with a note that both a backend API trace (`skyramp_start_trace_collection` / `skyramp_stop_trace_collection`) and a browser Playwright recording must be collected in a live environment first. Do NOT attempt `skyramp_e2e_test_generation` without both traces present."}
577
- - **UI**: ${hasTraces ? "Use an existing Playwright `.zip` trace with `skyramp_ui_test_generation`." : `Record a trace using \`browser_navigate\` + \`browser_snapshot\` + \`skyramp_export_zip\`, then call \`skyramp_ui_test_generation({ playwrightInput: "<zip_path>", outputDir: "<frontend_output_dir>" })\` — set \`outputDir\` to ${SERVICE_REFS.frontendTestDirRef}.`}
578
- Derive scenario names and steps from the actual changed frontend files. If your Budget Plan calls for 0% UI/E2E, omit this entirely.` : "";
579
- const supplementNote = `\n**If your Budget Plan total exceeds the pre-ranked items listed above:** draft additional tests from source-code enrichment (Step 1). For each new or changed endpoint, identify boundary or variation scenarios — formula parameters, search/filter constraints, required field validation. Only after exhausting PR-specific scenarios, add generic patterns (auth boundary → 401, non-existent ID → 404). Do NOT supplement with tests whose endpoint + test type match a GENERATE item.`;
580
- // ── PR / branch-diff mode: execution plan ────────────────────────────────
581
- const externalTestFilesList = relevantExternalTestPaths.length > 0
582
- ? `**Read these external test files first** (paths are relative to the \`repositoryPath\` you passed to \`skyramp_analyze_changes\` — prepend it to get the absolute path). Determine exactly which HTTP methods + paths each one covers. This is the definitive source of truth for external coverage:\n${relevantExternalTestPaths.map(p => `- \`${p}\``).join("\n")}\n\n`
583
- : "";
584
- return `## Execution Plan
585
- Seed: ${seed} | Endpoints: ${endpointCount} | Max: ${maxGen} generate + up to ${Math.max(topN - maxGen, 0)} additional (your Budget Plan determines the exact count)
586
-
587
- ${buildScopeAssessmentSection(topN, maxGen, isUIOnlyPR)}
588
-
589
- **Step 0 — External test coverage verification (before executing anything)**
590
- ${externalTestFilesList}For every GENERATE item below, check its endpoint path and test type against the Existing Tests list (further down in the prompt).
591
- - **\`[external]\` tests**: If the endpoint is already covered by an \`[external]\` test of the same type → skip the resource entirely (do NOT create or update). Backfill from ADDITIONAL using the priority order below:
592
- 1. **BUG-CATCHING TESTS FIRST (CRITICAL)**: If source code analysis revealed a bug, logic error, or incorrect formula (e.g. discount math adding instead of subtracting, off-by-one errors, missing validation), CREATE A TEST THAT EXPOSES IT. The test SHOULD FAIL — that's the point. Document the bug. Example: if discount formula is wrong, test with discount=20% and assert correct math. If no bug found, skip to #2.
593
- 2. **PR-endpoint edge cases**: Look for integration test candidates covering error paths, boundary values, or alternative scenarios for the SAME endpoints changed in the PR diff. If no suitable candidate exists in ADDITIONAL, derive one from your source-code enrichment findings.
594
- 3. **Same-resource other scenarios**: Other HTTP methods or flows on the same resource group touched by the PR.
595
- 4. **Cross-resource workflows involving the PR endpoint**: Integration scenarios that include the PR's changed endpoint as one of the steps.
596
- 5. **Unrelated endpoint coverage (last resort)**: Tests for endpoints with no connection to the PR diff, only when ALL options above have been exhausted.
597
- **Avoid backfilling with a test for a completely unrelated resource (e.g. \`POST /reviews\` when the PR only changes \`/orders\`) if any PR-endpoint edge-case integration test is feasible.**
598
- - **Contract tests (\`[skyramp]\`)**: If an existing \`[skyramp]\` contract test already covers that resource path → UPDATE the existing test file instead of creating a new one. A new test case is a new test even if the file already exists — count it toward \`newTestsCreated\`.
599
- - **Integration/scenario tests**: Always generate as a new file via the scenario pipeline, even if an existing integration test covers the same resource. A new multi-step scenario is a distinct test. Count it toward \`newTestsCreated\`.
600
- - **UI tests**: Always generate as a new file. Count toward \`newTestsCreated\`.
601
-
602
- **Step 1 — Source-Code Enrichment (before executing anything)**
603
- Read the source code for ALL changed files. Before generating each recommendation, quote the relevant source code in a <source_evidence> block — include the route handler signature, request body schema fields, response shape, and any computed field formulas. Use these quotes to derive tool call parameters. Look for:
604
- - **Auth middleware** — check for known signals (${AUTH_MIDDLEWARE_PATTERNS_STR}). If any match, override \`authHeader\` and \`authScheme\` even if workspace.yml says authType: none. **If no known signal matches but the diff shows security-adjacent code** (decorators like \`@requiresRole\`/\`@Protected\`, function names like \`validateToken\`/\`checkPermission\`/\`verifyHMAC\`, or imports from auth/security packages), read the relevant source file to determine the actual auth scheme before proceeding. Auth handling for \`skyramp_integration_test_generation\` with \`scenarioFile\` is covered in the Tool Workflows section below.
605
- - Business rules and formulas (e.g. total_cost = compute * rate + memory * rate)
606
- - State transitions and domain constraints (e.g. budget cannot drop below current spend)
607
- - Validation logic (field constraints, cross-field dependencies)
608
- - Security boundaries not covered by the structural candidates below
609
-
610
- For each one found, evaluate it against these 6 dimensions and assign priority:
611
- | Dimension | What to assess |
612
- | Production Safety | Guards a critical boundary (auth, unique constraint, cascade delete, data integrity, breaking migration)? → HIGH |
613
- | Bug-Finding Potential | Targets a known failure mode (race condition, data consistency, state transition, cascade effect)? → HIGH |
614
- | Mutation Side Effects | Does PUT/PATCH modify a collection of child items (line items, cart entries) and trigger recalculation (totals, counts, amounts)? → HIGH — this is the most common source of user-reported bugs |
615
- | User Journey Relevance | Reflects how real users interact (from traces, business flows, critical paths)? → HIGH or MEDIUM |
616
- | Coverage Gap | Addresses an area with zero existing test coverage? → bump up one tier |
617
- | Code Insight | Derived from actual implementation (spotted middleware pattern, N+1 risk, unique constraint)? → bump up one tier |
618
-
619
- Quality gate — ask all three questions:
620
- 1. "Would this test prevent a production incident?" → YES = HIGH priority regardless of other dimensions
621
- 2. "Does this test exercise a real workflow or catch a real bug?" → YES = at least MEDIUM
622
- 3. "Does this test cover a mutation that modifies child items and triggers total/amount recalculation?" → YES = HIGH priority, and prefer it for GENERATE over simple single-field update tests for the same endpoint
623
-
624
- Assign category: ${TEST_CATEGORIES.join(" | ")}
625
-
626
- ${buildTestPatternGuidelines()}
627
-
628
- INSERT a source-code-derived candidate into the ranked list **only if ALL three conditions are met**:
629
- 1. Priority is HIGH (it guards a critical boundary or would prevent a production incident)
630
- 2. It is specific to THIS codebase — derived from a concrete business rule, formula, or constraint found in the changed files (not a general pattern that applies to any API)
631
- 3. It is not already covered by a structural candidate in the list below
632
-
633
- If these conditions are not met, add it to ADDITIONAL only — do NOT displace a pre-ranked GENERATE item.
634
- **CRITICAL-tier items (category: new_endpoint) should never be displaced** — they test the actual endpoints introduced in this PR and must always occupy GENERATE slots.
635
-
636
- When a qualifying candidate is inserted: place it HIGH before MEDIUM before LOW; within the same priority, source-code-derived candidates go BEFORE structural ones. Re-number ranks after insertion. The top ${maxGen} ranked items become GENERATE candidates.
637
-
638
- **Source-code validation gates (apply during Step 1):**
639
- - **Cascade vs referential integrity**: If both a cascade-delete and a delete-blocked scenario appear for the same resource pair, keep only the one matching the source FK delete policy (ON DELETE CASCADE / cascade=True / onDelete: 'CASCADE' → keep cascade-delete; RESTRICT/PROTECT/no annotation → keep delete-blocked). Remove the inapplicable variant.
640
- - **Unique constraints**: Unique-constraint scenarios (duplicate POST → 409) are pre-drafted for all resources. Confirm enforcement before keeping: SQL UNIQUE index, Mongoose unique: true, Prisma @unique, or explicit duplicate-check code. If the backend is Redis, schema-less, or has no explicit constraint in the changed files, move to ADDITIONAL with a note — do NOT generate.
641
-
642
- **Step 2 — Diversity check (using enriched knowledge from Step 1)**
643
- Each GENERATE item must exercise a **distinct code path** — not just different input values on the same path.
644
-
645
- For each pair of GENERATE items, ask: same HTTP method + path + step sequence + expected status? → DUPLICATE. Keep the richer item; replace the other with a test from a different path below. Move the displaced item to ADDITIONAL.
646
-
647
- **Good diversity — aim for this mix across GENERATE slots:**
648
- - **Happy-path**: create prerequisites → call the new endpoint → verify computed fields and child collections
649
- - **Error-path**: trigger a distinct error status (404 for non-existent resource, 422 for invalid input, 400 for malformed request — whichever the source code handles)
650
- - **State-variation**: same endpoint, different logic branch (empty array, remove instead of add, boundary value that triggers a guard)
651
-
652
- Same step sequence with only payload differences (e.g. 10% vs 5% discount both returning 200) = same code path = duplicate. Different scenario names do not make duplicate tests distinct.
653
-
654
- **Step 3 — Execute merged plan in rank order**
655
- Replace any scenario that pairs unrelated resources with one reflecting actual FK relationships in the codebase.
656
- Use the field names and values from the \`<source_evidence>\` blocks you quoted in Step 1 to fill all tool call parameters. Prefer reusing Step 1 evidence when it already resolves a placeholder, but if a placeholder cannot be replaced with concrete values from files already read, you may read the specific schema, model, or handler file needed to resolve it. Assert response field values, not just status codes.
657
-
658
- ${buildTestQualityCriteria()}
659
-
660
- ${buildGenerationRules(isUIOnlyPR)}
661
-
662
- **ADDITIONAL recommendations** are submitted via \`skyramp_submit_report\`. Refer to its schema for required fields. Only include recommendations that add distinct coverage beyond what was generated.
663
-
664
- **Never mark a recommendation "blocked":** No OpenAPI spec → use source code for shapes. No traces → provide \`skyramp_start_trace_collection\` instructions. No backend trace → use the scenario pipeline.
665
-
666
- **Critical-category minimum:** At least ${Math.min(MAX_CRITICAL_TESTS, maxGen)} of the ${maxGen} GENERATE items should be from HIGH-priority categories (security_boundary, business_rule, data_integrity, breaking_change). The pre-ranked plan below already prioritises this — only override if source-code enrichment reveals a higher-value candidate.
667
-
668
- ### GENERATE (process these EXACTLY as listed, in order — after completing Steps 0–2 above; if Step 0 converts an item to UPDATE, backfill the ADD slot from ADDITIONAL following the priority order in Step 0)
669
-
670
- ${isUIOnlyPR
671
- ? (uiGenerateBlocks || " (no UI generate items — derive scenarios from changed frontend files)")
672
- : ([generateBlocks, uiPlaceholderBlock].filter(Boolean).join("\n\n") || " (no pre-ranked generate items — draft your own based on endpoint analysis)")}
673
-
674
- **COMPLIANCE CHECK**: Before proceeding, verify your generate list matches the items above. If you plan to generate a scenario with a different name than what is listed (e.g. you want to generate "order-update-discount-calculation" but the plan says "orders-patch-add-items-recalculate"), STOP — use the plan's scenario name and steps. Add your alternative to ADDITIONAL instead. One retry on failure then skip to next item.
675
-
676
- ### ADDITIONAL (list in additionalRecommendations in this order after Step 1 insertion)
677
-
678
- ${additionalLines || " (none pre-ranked)"}
679
- ${uiGuidance}
680
- ${supplementNote}
681
-
682
- **Honor your Budget Plan: produce exactly the total you committed to (GENERATE + ADDITIONAL). No fewer, no padding with low-value tests.**
683
-
684
- ## Recommendation Stability
685
- - **Carry forward** previous additionalRecommendations that still apply — match by scenarioName (multi-step) or endpoint (single-endpoint). Re-derive category and priority from test content.
686
- - **Only drop** a previous recommendation if its target endpoint was removed, its business logic changed, or it is now covered by a generated test.
687
- - **Only add** new recommendations for code paths introduced since the last run.`;
688
- }
689
- // Exported for testing — these are the core dedup primitives.
690
- export { buildExternalCoverageSet, externalDedupKey };
691
59
  export function buildRecommendationPrompt(analysis, analysisScope = AnalysisScope.FullRepo, topN = MAX_RECOMMENDATIONS, prContext, workspaceAuthHeader, workspaceAuthType, workspaceAuthScheme, maxGenerateOverride, sessionId) {
692
60
  const isDiffScope = isDiff(analysisScope);
693
61
  const diffContext = analysis.branchDiffContext;