@skyramp/mcp 0.1.2 → 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.
Files changed (28) hide show
  1. package/build/prompts/test-maintenance/driftAnalysisSections.js +2 -2
  2. package/build/prompts/test-recommendation/analysisOutputPrompt.js +26 -21
  3. package/build/prompts/test-recommendation/recommendationSections.js +42 -10
  4. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +2 -5
  5. package/build/prompts/test-recommendation/test-recommendation-prompt.js +114 -157
  6. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +250 -18
  7. package/build/prompts/testbot/testbot-prompts.js +17 -9
  8. package/build/services/ScenarioGenerationService.js +2 -1
  9. package/build/services/TestDiscoveryService.js +22 -7
  10. package/build/services/TestDiscoveryService.test.js +44 -0
  11. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +3 -4
  12. package/build/tools/generate-tests/generateBatchScenarioRestTool.test.js +9 -0
  13. package/build/tools/submitReportTool.js +4 -3
  14. package/build/tools/submitReportTool.test.js +16 -2
  15. package/build/tools/test-management/analyzeChangesTool.js +264 -140
  16. package/build/tools/test-management/analyzeChangesTool.test.js +3 -1
  17. package/build/tools/test-management/analyzeTestHealthTool.js +5 -0
  18. package/build/types/RepositoryAnalysis.js +8 -0
  19. package/build/types/TestRecommendation.js +2 -0
  20. package/build/utils/branchDiff.js +24 -8
  21. package/build/utils/featureFlags.js +25 -0
  22. package/build/utils/httpDefaults.js +12 -0
  23. package/build/utils/repoScanner.js +16 -2
  24. package/build/utils/routeParsers.js +79 -79
  25. package/build/utils/routeParsers.test.js +192 -66
  26. package/build/utils/scenarioDrafting.js +116 -497
  27. package/build/utils/scenarioDrafting.test.js +260 -480
  28. package/package.json +1 -1
@@ -9,6 +9,7 @@ describe("stepSchema — requestBody validation", () => {
9
9
  const result = stepSchema.safeParse({
10
10
  method: "POST",
11
11
  path: "/api/v1/products",
12
+ statusCode: 201,
12
13
  requestBody: JSON.stringify({ name: "Widget-123", price: 9.99 }),
13
14
  });
14
15
  expect(result.success).toBe(true);
@@ -17,6 +18,7 @@ describe("stepSchema — requestBody validation", () => {
17
18
  const result = stepSchema.safeParse({
18
19
  method: "POST",
19
20
  path: "/api/v1/products",
21
+ statusCode: 201,
20
22
  requestBody: "{}",
21
23
  });
22
24
  expect(result.success).toBe(false);
@@ -28,6 +30,7 @@ describe("stepSchema — requestBody validation", () => {
28
30
  const result = stepSchema.safeParse({
29
31
  method: "PATCH",
30
32
  path: "/api/v1/orders/1",
33
+ statusCode: 200,
31
34
  requestBody: "{}",
32
35
  });
33
36
  expect(result.success).toBe(false);
@@ -37,6 +40,7 @@ describe("stepSchema — requestBody validation", () => {
37
40
  const result = stepSchema.safeParse({
38
41
  method: "PUT",
39
42
  path: "/api/v1/products/1",
43
+ statusCode: 200,
40
44
  requestBody: "{}",
41
45
  });
42
46
  expect(result.success).toBe(false);
@@ -49,6 +53,7 @@ describe("stepSchema — requestBody validation", () => {
49
53
  const result = stepSchema.safeParse({
50
54
  method: "POST",
51
55
  path: "/api/v1/products",
56
+ statusCode: 201,
52
57
  });
53
58
  expect(result.success).toBe(true);
54
59
  });
@@ -57,6 +62,7 @@ describe("stepSchema — requestBody validation", () => {
57
62
  const result = stepSchema.safeParse({
58
63
  method: "POST",
59
64
  path: "/api/v1/products",
65
+ statusCode: 201,
60
66
  requestBody: "null",
61
67
  });
62
68
  expect(result.success).toBe(true); // null is not an empty object — not rejected
@@ -65,6 +71,7 @@ describe("stepSchema — requestBody validation", () => {
65
71
  const result = stepSchema.safeParse({
66
72
  method: "GET",
67
73
  path: "/api/v1/products/1",
74
+ statusCode: 200,
68
75
  });
69
76
  expect(result.success).toBe(true);
70
77
  });
@@ -72,6 +79,7 @@ describe("stepSchema — requestBody validation", () => {
72
79
  const result = stepSchema.safeParse({
73
80
  method: "DELETE",
74
81
  path: "/api/v1/products/1",
82
+ statusCode: 204,
75
83
  });
76
84
  expect(result.success).toBe(true);
77
85
  });
@@ -81,6 +89,7 @@ describe("stepSchema — requestBody validation", () => {
81
89
  const result = stepSchema.safeParse({
82
90
  method: "GET",
83
91
  path: "/api/v1/products",
92
+ statusCode: 200,
84
93
  requestBody: "{}",
85
94
  });
86
95
  expect(result.success).toBe(true);
@@ -120,8 +120,9 @@ export function registerSubmitReportTool(server) {
120
120
  .string()
121
121
  .optional()
122
122
  .default(DEFAULT_COMMIT_MESSAGE)
123
- .describe("Succinct commit message (under 72 chars) summarizing what the testbot did, " +
124
- "e.g. 'add contract tests for /products endpoint' or 'update smoke tests for order API changes'"),
123
+ .describe("Succinct commit message (if possible, under 72 chars) summarizing what Testbot did, " +
124
+ "e.g. 'add contract tests for /products endpoint' or 'update smoke tests for order API changes'. " +
125
+ "Used as both the git commit subject and the side PR title — the consumer applies truncation as needed."),
125
126
  },
126
127
  _meta: {
127
128
  keywords: ["report", "summary", "testbot", "submit"],
@@ -161,7 +162,7 @@ export function registerSubmitReportTool(server) {
161
162
  testResults: params.testResults,
162
163
  issuesFound: params.issuesFound,
163
164
  nextSteps: params.nextSteps ?? [],
164
- commitMessage: (params.commitMessage ?? "").replace(/[\r\n]+/g, " ").trim().slice(0, 72) || DEFAULT_COMMIT_MESSAGE,
165
+ commitMessage: (params.commitMessage ?? "").replace(/[\r\n]+/g, " ").trim() || DEFAULT_COMMIT_MESSAGE,
165
166
  }, null, 2);
166
167
  logger.info("Submitting testbot report", {
167
168
  outputFile: params.summaryOutputFile,
@@ -128,7 +128,7 @@ describe("registerSubmitReportTool", () => {
128
128
  const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
129
129
  expect(written.commitMessage).toBe("Added recommendations by Skyramp Testbot.");
130
130
  });
131
- it("should sanitize commitMessage (newlines, length)", async () => {
131
+ it("should sanitize commitMessage (collapse newlines, trim whitespace)", async () => {
132
132
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
133
133
  tmpDirs.push(tmpDir);
134
134
  const outputFile = path.join(tmpDir, "report.json");
@@ -139,7 +139,21 @@ describe("registerSubmitReportTool", () => {
139
139
  expect(result.isError).toBeUndefined();
140
140
  const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
141
141
  expect(written.commitMessage).toBe("line one line two line three");
142
- expect(written.commitMessage.length).toBeLessThanOrEqual(72);
142
+ // No length cap — truncation is the consumer's responsibility (SKYR-3757).
143
+ });
144
+ it("should preserve commitMessage longer than 72 chars without truncation", async () => {
145
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
146
+ tmpDirs.push(tmpDir);
147
+ const outputFile = path.join(tmpDir, "report.json");
148
+ const longMessage = "add contract and integration tests for the new PATCH /orders/{order_id} endpoint including discount recalculation and line-item edits";
149
+ const result = await handler({
150
+ ...sampleReportParams(outputFile),
151
+ commitMessage: longMessage,
152
+ });
153
+ expect(result.isError).toBeUndefined();
154
+ const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
155
+ expect(written.commitMessage).toBe(longMessage);
156
+ expect(written.commitMessage.length).toBeGreaterThan(72);
143
157
  });
144
158
  it("should use default commitMessage when provided as empty string", async () => {
145
159
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import * as crypto from "crypto";
3
3
  import * as fs from "fs";
4
+ import * as os from "os";
4
5
  import * as path from "path";
5
6
  import { simpleGit } from "simple-git";
6
7
  import { logger } from "../../utils/logger.js";
@@ -12,7 +13,7 @@ import { MAX_RECOMMENDATIONS, MAX_TESTS_TO_GENERATE } from "../../prompts/test-r
12
13
  import { TestDiscoveryService } from "../../services/TestDiscoveryService.js";
13
14
  import { ScenarioSource, AnalysisScope } from "../../types/RepositoryAnalysis.js";
14
15
  import { computeBranchDiff } from "../../utils/branchDiff.js";
15
- import { parseEndpointsFromDiff, resolveEndpointPaths, extractResourceFromPath, } from "../../utils/routeParsers.js";
16
+ import { parseFileEndpoints, extractResourceFromPath, classifyEndpointsByChangedFiles, } from "../../utils/routeParsers.js";
16
17
  import { scanAllRepoEndpoints, scanRelatedEndpoints, grepRouterMountingContext, findCandidateRouteFiles, } from "../../utils/repoScanner.js";
17
18
  import { detectProjectMetadata } from "../../utils/projectMetadata.js";
18
19
  import { draftScenariosFromEndpoints } from "../../utils/scenarioDrafting.js";
@@ -151,6 +152,53 @@ const NON_APP_PATTERNS = [
151
152
  function isNonApplicationFile(filePath) {
152
153
  return NON_APP_PATTERNS.some((p) => p.test(filePath));
153
154
  }
155
+ const ROUTE_FILE_PATTERN = /route|controller|endpoint|handler|view|urls|api|router/i;
156
+ const SOURCE_EXTS = /\.(ts|tsx|js|jsx|py|java|kt|go|rb|php|rs|cs|ex|exs)$/;
157
+ /**
158
+ * Recover endpoints from files deleted in this branch by reading their
159
+ * content from the base branch via `git show`. Only checks files whose
160
+ * name matches route-file heuristics to keep I/O bounded.
161
+ */
162
+ async function recoverDeletedFileEndpoints(repositoryPath, baseBranch, deletedFiles) {
163
+ const candidates = deletedFiles.filter((f) => SOURCE_EXTS.test(f) && ROUTE_FILE_PATTERN.test(f));
164
+ if (candidates.length === 0)
165
+ return [];
166
+ const git = simpleGit(repositoryPath);
167
+ const results = [];
168
+ const endpointMap = new Map();
169
+ for (const file of candidates) {
170
+ try {
171
+ const content = await git.show([`${baseBranch}:${file}`]);
172
+ for (const ep of parseFileEndpoints(content, file)) {
173
+ const existing = endpointMap.get(ep.path);
174
+ if (existing) {
175
+ existing.methods.add(ep.method);
176
+ }
177
+ else {
178
+ endpointMap.set(ep.path, {
179
+ methods: new Set([ep.method]),
180
+ sourceFile: file,
181
+ });
182
+ }
183
+ }
184
+ }
185
+ catch (err) {
186
+ logger.debug("Failed to recover endpoints from deleted file", {
187
+ file,
188
+ baseBranch,
189
+ error: err instanceof Error ? err.message : String(err),
190
+ });
191
+ }
192
+ }
193
+ for (const [apiPath, data] of endpointMap) {
194
+ results.push({
195
+ path: apiPath,
196
+ methods: Array.from(data.methods),
197
+ sourceFile: data.sourceFile,
198
+ });
199
+ }
200
+ return results;
201
+ }
154
202
  export const analyzeChangesInputSchema = {
155
203
  repositoryPath: z
156
204
  .string()
@@ -267,17 +315,10 @@ to produce a unified state file for the test health workflow.
267
315
  };
268
316
  }
269
317
  }
270
- await sendProgress(25, 100, "Parsing endpoints from diff...");
271
- const parsedDiff = diffData
272
- ? parseEndpointsFromDiff(diffData)
273
- : undefined;
274
- const newEndpoints = parsedDiff
275
- ? parsedDiff.newEndpoints
276
- : [];
277
318
  // ── Step 2: Scan endpoints ──
278
319
  let scannedEndpoints = [];
279
320
  if (analysisScope !== AnalysisScope.CurrentBranchDiff) {
280
- await sendProgress(35, 100, "Scanning all repository endpoints...");
321
+ await sendProgress(25, 100, "Scanning all repository endpoints...");
281
322
  try {
282
323
  scannedEndpoints = scanAllRepoEndpoints(params.repositoryPath);
283
324
  logger.info("Pre-scanned repo endpoints", {
@@ -291,7 +332,7 @@ to produce a unified state file for the test health workflow.
291
332
  }
292
333
  }
293
334
  else if (diffData) {
294
- await sendProgress(35, 100, "Scanning related endpoints from diff...");
335
+ await sendProgress(25, 100, "Scanning related endpoints from diff...");
295
336
  try {
296
337
  scannedEndpoints = scanRelatedEndpoints(params.repositoryPath, diffData.changedFiles);
297
338
  logger.info("Scanned related endpoints", {
@@ -303,32 +344,68 @@ to produce a unified state file for the test health workflow.
303
344
  error: err instanceof Error ? err.message : String(err),
304
345
  });
305
346
  }
306
- if (scannedEndpoints.length === 0) {
307
- try {
308
- scannedEndpoints = scanAllRepoEndpoints(params.repositoryPath);
309
- logger.info("Fallback: scanned all repo endpoints", {
310
- count: scannedEndpoints.length,
311
- });
312
- }
313
- catch (err) {
314
- logger.warning("Full repo scan fallback also failed", {
315
- error: err instanceof Error ? err.message : String(err),
316
- });
317
- }
318
- }
347
+ // No fallback to scanAllRepoEndpoints in PR mode.
348
+ // If the scanner found 0 related endpoints, the PR likely touches
349
+ // non-route code (services, models, schemas, client SDK). Flooding
350
+ // all repo endpoints into the prompt causes the LLM to test
351
+ // irrelevant neighboring endpoints instead of focusing on the diff.
352
+ }
353
+ await sendProgress(40, 100, "Classifying changed endpoints...");
354
+ // ── Step 2.5: Classify endpoints by changed files ──
355
+ // Cross-reference changedFiles against scannedEndpoints[].sourceFile to
356
+ // identify new/modified/removed endpoints replaces fragile regex parsing
357
+ // of diff hunks. Scanned endpoints always have full paths and concrete
358
+ // HTTP methods, eliminating the need for path resolution and MULTI sentinels.
359
+ let classifiedEndpoints;
360
+ if (diffData) {
361
+ // Recover endpoints from deleted files by reading base-branch content
362
+ const deletedFileEndpoints = diffData.deletedFiles.length > 0
363
+ ? await recoverDeletedFileEndpoints(params.repositoryPath, diffData.baseBranch, diffData.deletedFiles)
364
+ : [];
365
+ classifiedEndpoints = classifyEndpointsByChangedFiles(diffData, scannedEndpoints, deletedFileEndpoints);
366
+ logger.info("Classified endpoints from changed files", {
367
+ changed: classifiedEndpoints.changedEndpoints.length,
368
+ new: classifiedEndpoints.newEndpoints.length,
369
+ removed: classifiedEndpoints.removedEndpoints.length,
370
+ unmatched: classifiedEndpoints.unmatchedFiles.length,
371
+ });
319
372
  }
320
373
  await sendProgress(50, 100, "Discovering existing tests...");
321
374
  // ── Step 3: Discover existing tests ──
322
- // In PR mode, extract resource names from the diff endpoints so that
323
- // TestDiscoveryService can limit full extraction to relevant files only.
324
- const changedResources = parsedDiff
325
- ? [
326
- ...parsedDiff.newEndpoints,
327
- ...parsedDiff.modifiedEndpoints,
328
- ]
329
- .map(ep => extractResourceFromPath(ep.path))
330
- .filter((r, i, arr) => r !== "unknown" && arr.indexOf(r) === i)
331
- : [];
375
+ // Compute changedResources from classified endpoints for test discovery filtering.
376
+ // undefined → full-repo mode (no diff context)
377
+ // [] → PR mode, no endpoints found → skip external tests
378
+ // [...names] → PR mode with resolved resource names → filter external by relevance
379
+ let changedResources;
380
+ if (!classifiedEndpoints) {
381
+ changedResources = undefined;
382
+ }
383
+ else {
384
+ const allClassified = [
385
+ ...classifiedEndpoints.changedEndpoints,
386
+ ...classifiedEndpoints.newEndpoints,
387
+ ...classifiedEndpoints.removedEndpoints,
388
+ ];
389
+ if (allClassified.length > 0) {
390
+ // Scanned endpoints always have full paths — extractResourceFromPath
391
+ // never returns "unknown" for properly resolved paths.
392
+ const resolved = allClassified
393
+ .map((ep) => extractResourceFromPath(ep.path))
394
+ .filter((r, i, arr) => r !== "unknown" && arr.indexOf(r) === i);
395
+ changedResources = resolved.length > 0 ? resolved : ["unknown"];
396
+ }
397
+ else if (classifiedEndpoints.unmatchedFiles.length > 0) {
398
+ // Changed files don't map to any endpoint (e.g. schema, model, or
399
+ // migration changes near route files). Use ["unknown"] so external
400
+ // tests get name-only entries — enough for the LLM to infer coverage
401
+ // from filenames without flooding context with full extraction of
402
+ // hundreds of irrelevant test files.
403
+ changedResources = ["unknown"];
404
+ }
405
+ else {
406
+ changedResources = [];
407
+ }
408
+ }
332
409
  let existingTests = [];
333
410
  let discoveredRelevantExternalPaths = [];
334
411
  try {
@@ -427,71 +504,30 @@ to produce a unified state file for the test health workflow.
427
504
  : method === "DELETE"
428
505
  ? { statusCode: 204, description: "No Content" }
429
506
  : { statusCode: 200, description: "OK" };
430
- const skeletonEndpoints = scannedEndpoints.length > 0
431
- ? scannedEndpoints.map((ep) => ({
432
- path: ep.path,
433
- resourceGroup: ep.path.split("/").filter(Boolean).pop() || "unknown",
434
- pathParams: (ep.path.match(/\{(\w+)\}/g) || []).map((p) => ({
435
- name: p.slice(1, -1),
436
- type: "string",
437
- required: true,
438
- })),
439
- methods: ep.methods.map((m) => ({
440
- method: m,
441
- description: "",
442
- queryParams: [],
443
- authRequired: true,
444
- sourceFile: ep.sourceFile,
445
- interactions: [
446
- {
447
- description: `${m} ${ep.path}`,
448
- type: "success",
449
- request: {},
450
- response: skeletonResponse(m),
451
- },
452
- ],
453
- })),
454
- }))
455
- : parsedDiff
456
- ? (() => {
457
- const grouped = new Map();
458
- for (const ep of [
459
- ...parsedDiff.newEndpoints,
460
- ...parsedDiff.modifiedEndpoints,
461
- ]) {
462
- let entry = grouped.get(ep.path);
463
- if (!entry) {
464
- entry = {
465
- path: ep.path,
466
- resourceGroup: ep.path.split("/").filter(Boolean).pop() || "unknown",
467
- pathParams: (ep.path.match(/\{(\w+)\}/g) || []).map((p) => ({
468
- name: p.slice(1, -1),
469
- type: "string",
470
- required: true,
471
- })),
472
- methods: [],
473
- };
474
- grouped.set(ep.path, entry);
475
- }
476
- entry.methods.push({
477
- method: ep.method,
478
- description: "",
479
- queryParams: [],
480
- authRequired: true,
481
- sourceFile: ep.sourceFile,
482
- interactions: [
483
- {
484
- description: `${ep.method} ${ep.path}`,
485
- type: "success",
486
- request: {},
487
- response: skeletonResponse(ep.method),
488
- },
489
- ],
490
- });
491
- }
492
- return Array.from(grouped.values());
493
- })()
494
- : [];
507
+ const skeletonEndpoints = scannedEndpoints.map((ep) => ({
508
+ path: ep.path,
509
+ resourceGroup: ep.path.split("/").filter(Boolean).pop() || "unknown",
510
+ pathParams: (ep.path.match(/\{(\w+)\}/g) || []).map((p) => ({
511
+ name: p.slice(1, -1),
512
+ type: "string",
513
+ required: true,
514
+ })),
515
+ methods: ep.methods.map((m) => ({
516
+ method: m,
517
+ description: "",
518
+ queryParams: [],
519
+ authRequired: true,
520
+ sourceFile: ep.sourceFile,
521
+ interactions: [
522
+ {
523
+ description: `${m} ${ep.path}`,
524
+ type: "success",
525
+ request: {},
526
+ response: skeletonResponse(m),
527
+ },
528
+ ],
529
+ })),
530
+ }));
495
531
  // ── Step 8: Merge trace interactions ──
496
532
  if (traceResult && traceResult.entries.length > 0) {
497
533
  for (const entry of traceResult.entries) {
@@ -536,16 +572,23 @@ to produce a unified state file for the test health workflow.
536
572
  }
537
573
  }
538
574
  }
539
- // ── Step 8.5: Resolve diff-parsed endpoint paths ──
540
- // The diff parser extracts route-decorator-relative paths (e.g. "/{order_id}")
541
- // because the router prefix is usually outside the diff hunk. Match against
542
- // the authoritative scanned endpoints to recover the full API path.
543
- if (parsedDiff && skeletonEndpoints.length > 0) {
544
- resolveEndpointPaths(parsedDiff.newEndpoints, skeletonEndpoints);
545
- resolveEndpointPaths(parsedDiff.modifiedEndpoints, skeletonEndpoints);
546
- }
547
575
  // ── Step 9: Draft scenarios ──
548
- const codeInferredScenarios = draftScenariosFromEndpoints(skeletonEndpoints, parsedDiff?.newEndpoints ?? []);
576
+ // New and changed endpoints get minimal intent-marker scenarios (contract + integration
577
+ // for mutating methods) via draftMinimalScenarios. The LLM enriches these with
578
+ // prerequisite steps and error paths during source-code analysis. Removed endpoints
579
+ // are handled by the LLM from the diffContext.removedEndpoints signal.
580
+ // Classified endpoints have full paths and concrete methods (no MULTI sentinels).
581
+ const newEndpointsForDrafting = classifiedEndpoints?.newEndpoints.flatMap((ep) => ep.methods.map((m) => ({
582
+ method: m,
583
+ path: ep.path,
584
+ sourceFile: ep.sourceFile,
585
+ }))) ?? [];
586
+ const changedEndpointsForDrafting = classifiedEndpoints?.changedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
587
+ method: m,
588
+ path: ep.path,
589
+ sourceFile: ep.sourceFile,
590
+ }))) ?? [];
591
+ const codeInferredScenarios = draftScenariosFromEndpoints(skeletonEndpoints, newEndpointsForDrafting, changedEndpointsForDrafting);
549
592
  let allDraftedScenarios = codeInferredScenarios;
550
593
  if (traceResult && traceResult.userFlows.length > 0) {
551
594
  const traceScenarios = traceResult.userFlows
@@ -642,35 +685,37 @@ to produce a unified state file for the test health workflow.
642
685
  const relevantExternalTestPaths = discoveredRelevantExternalPaths.map(p => path.relative(params.repositoryPath, p));
643
686
  // Build the full RepositoryAnalysis object — same structure as analyzeRepositoryTool
644
687
  // so buildRecommendationPrompt can reason over enriched endpoint + scenario data
645
- const diffContext = parsedDiff ? {
646
- currentBranch: parsedDiff.currentBranch,
647
- baseBranch: parsedDiff.baseBranch,
648
- changedFiles: parsedDiff.changedFiles,
649
- newEndpoints: (() => {
650
- const grouped = new Map();
651
- for (const ep of parsedDiff.newEndpoints) {
652
- let entry = grouped.get(ep.path);
653
- if (!entry) {
654
- entry = { path: ep.path, methods: [] };
655
- grouped.set(ep.path, entry);
656
- }
657
- entry.methods.push({ method: ep.method, sourceFile: ep.sourceFile, interactionCount: 0 });
658
- }
659
- return Array.from(grouped.values());
660
- })(),
661
- modifiedEndpoints: (() => {
662
- const grouped = new Map();
663
- for (const ep of parsedDiff.modifiedEndpoints) {
664
- let entry = grouped.get(ep.path);
665
- if (!entry) {
666
- entry = { path: ep.path, methods: [] };
667
- grouped.set(ep.path, entry);
668
- }
669
- entry.methods.push({ method: ep.method, sourceFile: ep.sourceFile, changeType: "modified" });
670
- }
671
- return Array.from(grouped.values());
672
- })(),
673
- affectedServices: parsedDiff.affectedServices,
688
+ // Build diffContext from classifiedEndpoints ScannedEndpoint already
689
+ // has full paths and concrete methods, so no grouping/MULTI handling needed.
690
+ const diffContext = classifiedEndpoints ? {
691
+ currentBranch: classifiedEndpoints.currentBranch,
692
+ baseBranch: classifiedEndpoints.baseBranch,
693
+ changedFiles: classifiedEndpoints.changedFiles,
694
+ newEndpoints: classifiedEndpoints.newEndpoints.map((ep) => ({
695
+ path: ep.path,
696
+ methods: ep.methods.map((m) => ({
697
+ method: m,
698
+ sourceFile: ep.sourceFile,
699
+ interactionCount: 0,
700
+ })),
701
+ })),
702
+ modifiedEndpoints: classifiedEndpoints.changedEndpoints.map((ep) => ({
703
+ path: ep.path,
704
+ methods: ep.methods.map((m) => ({
705
+ method: m,
706
+ sourceFile: ep.sourceFile,
707
+ changeType: "modified",
708
+ })),
709
+ })),
710
+ removedEndpoints: classifiedEndpoints.removedEndpoints.map((ep) => ({
711
+ path: ep.path,
712
+ methods: ep.methods.map((m) => ({
713
+ method: m,
714
+ sourceFile: ep.sourceFile,
715
+ changeType: "removed",
716
+ })),
717
+ })),
718
+ affectedServices: classifiedEndpoints.affectedServices,
674
719
  summary: "",
675
720
  } : undefined;
676
721
  const fullAnalysis = {
@@ -752,7 +797,7 @@ to produce a unified state file for the test health workflow.
752
797
  : undefined;
753
798
  const unifiedState = {
754
799
  existingTests,
755
- newEndpoints,
800
+ newEndpoints: newEndpointsForDrafting,
756
801
  analysisScope,
757
802
  repositoryAnalysis: {
758
803
  skeletonEndpoints,
@@ -764,7 +809,35 @@ to produce a unified state file for the test health workflow.
764
809
  wsSchemaPath,
765
810
  wsAuthMethod,
766
811
  scenarios: allDraftedScenarios,
767
- diff: parsedDiff,
812
+ diff: classifiedEndpoints
813
+ ? {
814
+ currentBranch: classifiedEndpoints.currentBranch,
815
+ baseBranch: classifiedEndpoints.baseBranch,
816
+ changedFiles: classifiedEndpoints.changedFiles,
817
+ newEndpoints: classifiedEndpoints.newEndpoints.map((ep) => ({
818
+ path: ep.path,
819
+ methods: ep.methods.map((m) => ({
820
+ method: m,
821
+ sourceFile: ep.sourceFile,
822
+ })),
823
+ })),
824
+ modifiedEndpoints: classifiedEndpoints.changedEndpoints.map((ep) => ({
825
+ path: ep.path,
826
+ methods: ep.methods.map((m) => ({
827
+ method: m,
828
+ sourceFile: ep.sourceFile,
829
+ })),
830
+ })),
831
+ removedEndpoints: classifiedEndpoints.removedEndpoints.map((ep) => ({
832
+ path: ep.path,
833
+ methods: ep.methods.map((m) => ({
834
+ method: m,
835
+ sourceFile: ep.sourceFile,
836
+ })),
837
+ })),
838
+ affectedServices: classifiedEndpoints.affectedServices,
839
+ }
840
+ : undefined,
768
841
  sessionId,
769
842
  routerMountContext,
770
843
  candidateRouteFiles,
@@ -780,6 +853,24 @@ to produce a unified state file for the test health workflow.
780
853
  catch (error) {
781
854
  logger.warning(`Failed to cleanup old state files: ${error.message}`);
782
855
  }
856
+ // Clean up old diff temp files (>24 hours) from previous invocations
857
+ try {
858
+ const tmpDir = os.tmpdir();
859
+ const entries = await fs.promises.readdir(tmpDir);
860
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
861
+ for (const entry of entries) {
862
+ if (!entry.startsWith("skyramp-diff-") || !entry.endsWith(".diff"))
863
+ continue;
864
+ const fullPath = path.join(tmpDir, entry);
865
+ const stat = await fs.promises.stat(fullPath).catch(() => null);
866
+ if (stat && stat.mtimeMs < cutoff) {
867
+ await fs.promises.unlink(fullPath).catch(() => { });
868
+ }
869
+ }
870
+ }
871
+ catch {
872
+ // Non-critical — temp cleanup failure should not block analysis
873
+ }
783
874
  const stateManager = new StateManager("analysis", sessionId, undefined, params.stateOutputFile);
784
875
  await stateManager.writeData(unifiedState, {
785
876
  repositoryPath: params.repositoryPath,
@@ -842,18 +933,51 @@ to produce a unified state file for the test health workflow.
842
933
  projectType: projectMeta.projectType,
843
934
  primaryFramework: projectMeta.primaryFramework,
844
935
  existingTestCount: existingTests.length,
845
- newEndpointCount: newEndpoints.length,
936
+ newEndpointCount: classifiedEndpoints?.newEndpoints.length ?? 0,
846
937
  endpointCount: skeletonEndpoints.reduce((acc, ep) => acc + ep.methods.length, 0),
847
938
  },
848
939
  stateFileSize: stateSize,
849
940
  nextStep: "Call skyramp_analyze_test_health with stateFile to run drift analysis and health scoring",
850
941
  }, null, 2);
942
+ // Build a DiffSummary for buildAnalysisOutputText from classified endpoints.
943
+ const parsedDiffShim = classifiedEndpoints
944
+ ? {
945
+ currentBranch: classifiedEndpoints.currentBranch,
946
+ baseBranch: classifiedEndpoints.baseBranch,
947
+ changedFiles: classifiedEndpoints.changedFiles,
948
+ diffStat: diffData?.diffStat ?? "",
949
+ newEndpoints: classifiedEndpoints.newEndpoints.flatMap((ep) => ep.methods.map((m) => ({
950
+ method: m,
951
+ path: ep.path,
952
+ sourceFile: ep.sourceFile,
953
+ }))),
954
+ modifiedEndpoints: classifiedEndpoints.changedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
955
+ method: m,
956
+ path: ep.path,
957
+ sourceFile: ep.sourceFile,
958
+ }))),
959
+ removedEndpoints: classifiedEndpoints.removedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
960
+ method: m,
961
+ path: ep.path,
962
+ sourceFile: ep.sourceFile,
963
+ }))),
964
+ affectedServices: classifiedEndpoints.affectedServices,
965
+ }
966
+ : undefined;
967
+ // Write the full diff to a temp file so the LLM can read it on demand
968
+ // rather than embedding potentially large content inline in the prompt.
969
+ let diffFilePath;
970
+ if (diffData?.diffContent) {
971
+ diffFilePath = path.join(os.tmpdir(), `skyramp-diff-${sessionId}.diff`);
972
+ await fs.promises.writeFile(diffFilePath, diffData.diffContent, { encoding: "utf-8", mode: 0o600 });
973
+ }
851
974
  const outputText = buildAnalysisOutputText({
852
975
  sessionId,
853
976
  stateFile,
854
977
  repositoryPath: params.repositoryPath,
855
978
  analysisScope,
856
- parsedDiff,
979
+ parsedDiff: parsedDiffShim,
980
+ diffFilePath,
857
981
  diffContent: diffData?.diffContent,
858
982
  candidateRouteFiles,
859
983
  scannedEndpoints,
@@ -21,7 +21,9 @@ jest.mock("../../utils/branchDiff.js", () => ({
21
21
  computeBranchDiff: jest.fn(),
22
22
  }));
23
23
  jest.mock("../../utils/routeParsers.js", () => ({
24
- parseEndpointsFromDiff: jest.fn(),
24
+ classifyEndpointsByChangedFiles: jest.fn(),
25
+ extractResourceFromPath: jest.fn(),
26
+ parseFileEndpoints: jest.fn(),
25
27
  }));
26
28
  jest.mock("../../utils/repoScanner.js", () => ({
27
29
  scanAllRepoEndpoints: jest.fn(),