@skyramp/mcp 0.1.2 → 0.1.3

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,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,18 @@ 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
+ // Only new endpoints are passed as "diff-direct" — draftDiffDirectScenarios
577
+ // generates success-oriented scenarios (200/201/204) which are wrong for
578
+ // removed endpoints. Removal coverage (verify-404) is handled by the LLM
579
+ // from the diffContext.removedEndpoints signal in the recommendation prompt.
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 codeInferredScenarios = draftScenariosFromEndpoints(skeletonEndpoints, newEndpointsForDrafting);
549
587
  let allDraftedScenarios = codeInferredScenarios;
550
588
  if (traceResult && traceResult.userFlows.length > 0) {
551
589
  const traceScenarios = traceResult.userFlows
@@ -642,35 +680,37 @@ to produce a unified state file for the test health workflow.
642
680
  const relevantExternalTestPaths = discoveredRelevantExternalPaths.map(p => path.relative(params.repositoryPath, p));
643
681
  // Build the full RepositoryAnalysis object — same structure as analyzeRepositoryTool
644
682
  // 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,
683
+ // Build diffContext from classifiedEndpoints ScannedEndpoint already
684
+ // has full paths and concrete methods, so no grouping/MULTI handling needed.
685
+ const diffContext = classifiedEndpoints ? {
686
+ currentBranch: classifiedEndpoints.currentBranch,
687
+ baseBranch: classifiedEndpoints.baseBranch,
688
+ changedFiles: classifiedEndpoints.changedFiles,
689
+ newEndpoints: classifiedEndpoints.newEndpoints.map((ep) => ({
690
+ path: ep.path,
691
+ methods: ep.methods.map((m) => ({
692
+ method: m,
693
+ sourceFile: ep.sourceFile,
694
+ interactionCount: 0,
695
+ })),
696
+ })),
697
+ modifiedEndpoints: classifiedEndpoints.changedEndpoints.map((ep) => ({
698
+ path: ep.path,
699
+ methods: ep.methods.map((m) => ({
700
+ method: m,
701
+ sourceFile: ep.sourceFile,
702
+ changeType: "modified",
703
+ })),
704
+ })),
705
+ removedEndpoints: classifiedEndpoints.removedEndpoints.map((ep) => ({
706
+ path: ep.path,
707
+ methods: ep.methods.map((m) => ({
708
+ method: m,
709
+ sourceFile: ep.sourceFile,
710
+ changeType: "removed",
711
+ })),
712
+ })),
713
+ affectedServices: classifiedEndpoints.affectedServices,
674
714
  summary: "",
675
715
  } : undefined;
676
716
  const fullAnalysis = {
@@ -752,7 +792,7 @@ to produce a unified state file for the test health workflow.
752
792
  : undefined;
753
793
  const unifiedState = {
754
794
  existingTests,
755
- newEndpoints,
795
+ newEndpoints: newEndpointsForDrafting,
756
796
  analysisScope,
757
797
  repositoryAnalysis: {
758
798
  skeletonEndpoints,
@@ -764,7 +804,35 @@ to produce a unified state file for the test health workflow.
764
804
  wsSchemaPath,
765
805
  wsAuthMethod,
766
806
  scenarios: allDraftedScenarios,
767
- diff: parsedDiff,
807
+ diff: classifiedEndpoints
808
+ ? {
809
+ currentBranch: classifiedEndpoints.currentBranch,
810
+ baseBranch: classifiedEndpoints.baseBranch,
811
+ changedFiles: classifiedEndpoints.changedFiles,
812
+ newEndpoints: classifiedEndpoints.newEndpoints.map((ep) => ({
813
+ path: ep.path,
814
+ methods: ep.methods.map((m) => ({
815
+ method: m,
816
+ sourceFile: ep.sourceFile,
817
+ })),
818
+ })),
819
+ modifiedEndpoints: classifiedEndpoints.changedEndpoints.map((ep) => ({
820
+ path: ep.path,
821
+ methods: ep.methods.map((m) => ({
822
+ method: m,
823
+ sourceFile: ep.sourceFile,
824
+ })),
825
+ })),
826
+ removedEndpoints: classifiedEndpoints.removedEndpoints.map((ep) => ({
827
+ path: ep.path,
828
+ methods: ep.methods.map((m) => ({
829
+ method: m,
830
+ sourceFile: ep.sourceFile,
831
+ })),
832
+ })),
833
+ affectedServices: classifiedEndpoints.affectedServices,
834
+ }
835
+ : undefined,
768
836
  sessionId,
769
837
  routerMountContext,
770
838
  candidateRouteFiles,
@@ -780,6 +848,24 @@ to produce a unified state file for the test health workflow.
780
848
  catch (error) {
781
849
  logger.warning(`Failed to cleanup old state files: ${error.message}`);
782
850
  }
851
+ // Clean up old diff temp files (>24 hours) from previous invocations
852
+ try {
853
+ const tmpDir = os.tmpdir();
854
+ const entries = await fs.promises.readdir(tmpDir);
855
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
856
+ for (const entry of entries) {
857
+ if (!entry.startsWith("skyramp-diff-") || !entry.endsWith(".diff"))
858
+ continue;
859
+ const fullPath = path.join(tmpDir, entry);
860
+ const stat = await fs.promises.stat(fullPath).catch(() => null);
861
+ if (stat && stat.mtimeMs < cutoff) {
862
+ await fs.promises.unlink(fullPath).catch(() => { });
863
+ }
864
+ }
865
+ }
866
+ catch {
867
+ // Non-critical — temp cleanup failure should not block analysis
868
+ }
783
869
  const stateManager = new StateManager("analysis", sessionId, undefined, params.stateOutputFile);
784
870
  await stateManager.writeData(unifiedState, {
785
871
  repositoryPath: params.repositoryPath,
@@ -842,18 +928,51 @@ to produce a unified state file for the test health workflow.
842
928
  projectType: projectMeta.projectType,
843
929
  primaryFramework: projectMeta.primaryFramework,
844
930
  existingTestCount: existingTests.length,
845
- newEndpointCount: newEndpoints.length,
931
+ newEndpointCount: classifiedEndpoints?.newEndpoints.length ?? 0,
846
932
  endpointCount: skeletonEndpoints.reduce((acc, ep) => acc + ep.methods.length, 0),
847
933
  },
848
934
  stateFileSize: stateSize,
849
935
  nextStep: "Call skyramp_analyze_test_health with stateFile to run drift analysis and health scoring",
850
936
  }, null, 2);
937
+ // Build a DiffSummary for buildAnalysisOutputText from classified endpoints.
938
+ const parsedDiffShim = classifiedEndpoints
939
+ ? {
940
+ currentBranch: classifiedEndpoints.currentBranch,
941
+ baseBranch: classifiedEndpoints.baseBranch,
942
+ changedFiles: classifiedEndpoints.changedFiles,
943
+ diffStat: diffData?.diffStat ?? "",
944
+ newEndpoints: classifiedEndpoints.newEndpoints.flatMap((ep) => ep.methods.map((m) => ({
945
+ method: m,
946
+ path: ep.path,
947
+ sourceFile: ep.sourceFile,
948
+ }))),
949
+ modifiedEndpoints: classifiedEndpoints.changedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
950
+ method: m,
951
+ path: ep.path,
952
+ sourceFile: ep.sourceFile,
953
+ }))),
954
+ removedEndpoints: classifiedEndpoints.removedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
955
+ method: m,
956
+ path: ep.path,
957
+ sourceFile: ep.sourceFile,
958
+ }))),
959
+ affectedServices: classifiedEndpoints.affectedServices,
960
+ }
961
+ : undefined;
962
+ // Write the full diff to a temp file so the LLM can read it on demand
963
+ // rather than embedding potentially large content inline in the prompt.
964
+ let diffFilePath;
965
+ if (diffData?.diffContent) {
966
+ diffFilePath = path.join(os.tmpdir(), `skyramp-diff-${sessionId}.diff`);
967
+ await fs.promises.writeFile(diffFilePath, diffData.diffContent, { encoding: "utf-8", mode: 0o600 });
968
+ }
851
969
  const outputText = buildAnalysisOutputText({
852
970
  sessionId,
853
971
  stateFile,
854
972
  repositoryPath: params.repositoryPath,
855
973
  analysisScope,
856
- parsedDiff,
974
+ parsedDiff: parsedDiffShim,
975
+ diffFilePath,
857
976
  diffContent: diffData?.diffContent,
858
977
  candidateRouteFiles,
859
978
  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(),
@@ -94,6 +94,11 @@ exist only in the LLM's reasoning context and are acted on by \`skyramp_actions\
94
94
  .map((e) => e.methods ? e.methods.map((m) => `${m.method} ${e.path}`).join(", ") : `${e.method} ${e.path}`)
95
95
  .join(", ")}`);
96
96
  }
97
+ if (diff.removedEndpoints?.length > 0) {
98
+ lines.push(`**Removed Endpoints** (${diff.removedEndpoints.length}): ${diff.removedEndpoints
99
+ .map((e) => e.methods ? e.methods.map((m) => `${m.method} ${e.path}`).join(", ") : `${e.method} ${e.path}`)
100
+ .join(", ")}`);
101
+ }
97
102
  parsedDiffText = lines.join("\n");
98
103
  }
99
104
  const scannedEndpoints = stateData.repositoryAnalysis?.skeletonEndpoints || [];
@@ -128,6 +128,14 @@ export const branchDiffContextSchema = z.object({
128
128
  changeType: z.enum(["added", "modified", "removed"]),
129
129
  })),
130
130
  })),
131
+ removedEndpoints: z.array(z.object({
132
+ path: z.string(),
133
+ methods: z.array(z.object({
134
+ method: z.string(),
135
+ sourceFile: z.string(),
136
+ changeType: z.literal("removed"),
137
+ })),
138
+ })).optional(),
131
139
  affectedServices: z.array(z.string()),
132
140
  summary: z.string().optional(),
133
141
  });
@@ -1,10 +1,30 @@
1
1
  import { simpleGit } from "simple-git";
2
2
  import { logger } from "./logger.js";
3
- const MAX_DIFF_LENGTH = 50_000;
4
3
  /**
5
4
  * Try a git diff against the given ref. Returns undefined if the ref doesn't exist
6
5
  * or the diff fails, so the caller can try the next candidate.
7
6
  */
7
+ /** Parse diff headers to find newly created and deleted files. */
8
+ function parseNewAndDeletedFiles(rawDiff) {
9
+ const newFiles = [];
10
+ const deletedFiles = [];
11
+ const lines = rawDiff.split("\n");
12
+ let currentFile = "";
13
+ for (const line of lines) {
14
+ const fileMatch = line.match(/^diff --git a\/.+ b\/(.+)$/);
15
+ if (fileMatch) {
16
+ currentFile = fileMatch[1];
17
+ continue;
18
+ }
19
+ if (line === "--- /dev/null" && currentFile) {
20
+ newFiles.push(currentFile);
21
+ }
22
+ else if (line === "+++ /dev/null" && currentFile) {
23
+ deletedFiles.push(currentFile);
24
+ }
25
+ }
26
+ return { newFiles, deletedFiles };
27
+ }
8
28
  async function tryDiff(git, ref) {
9
29
  try {
10
30
  const changedFilesRaw = await git.diff([`${ref}...HEAD`, "--name-only"]);
@@ -13,13 +33,9 @@ async function tryDiff(git, ref) {
13
33
  .map((f) => f.trim())
14
34
  .filter((f) => f.length > 0);
15
35
  const diffStat = await git.diff([`${ref}...HEAD`, "--stat"]);
16
- let diffContent = await git.diff([`${ref}...HEAD`]);
17
- if (diffContent.length > MAX_DIFF_LENGTH) {
18
- diffContent =
19
- diffContent.substring(0, MAX_DIFF_LENGTH) +
20
- `\n\n... [diff truncated at ${MAX_DIFF_LENGTH} chars, ${changedFiles.length} files total] ...`;
21
- }
22
- return { changedFiles, diffContent, diffStat };
36
+ const fullDiff = await git.diff([`${ref}...HEAD`]);
37
+ const { newFiles, deletedFiles } = parseNewAndDeletedFiles(fullDiff);
38
+ return { changedFiles, diffContent: fullDiff, diffStat, newFiles, deletedFiles };
23
39
  }
24
40
  catch (err) {
25
41
  logger.debug(`tryDiff against ${ref} failed`, { error: err instanceof Error ? err.message : String(err) });
@@ -1,13 +1,15 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { nextjsFileToApiPath, parseFileEndpoints, } from "./routeParsers.js";
4
+ import { logger } from "./logger.js";
4
5
  function globRecursive(dir, extensions) {
5
6
  const results = [];
6
7
  let entries;
7
8
  try {
8
9
  entries = fs.readdirSync(dir, { withFileTypes: true });
9
10
  }
10
- catch {
11
+ catch (err) {
12
+ logger.debug("globRecursive: skipping directory", { dir, error: err instanceof Error ? err.message : String(err) });
11
13
  return results;
12
14
  }
13
15
  for (const entry of entries) {
@@ -201,7 +203,19 @@ export function findCandidateRouteFiles(repositoryPath) {
201
203
  }
202
204
  }
203
205
  }
204
- return [...byName, ...byContent].slice(0, MAX_CANDIDATE_FILES);
206
+ const combined = [...byName, ...byContent];
207
+ const contentPassSkipped = byName.length >= MAX_CANDIDATE_FILES;
208
+ const result = combined.slice(0, MAX_CANDIDATE_FILES);
209
+ if (combined.length > MAX_CANDIDATE_FILES) {
210
+ logger.warning("findCandidateRouteFiles: hit MAX_CANDIDATE_FILES cap — some route files may be excluded from LLM analysis", {
211
+ cap: MAX_CANDIDATE_FILES,
212
+ byName: byName.length,
213
+ byContent: byContent.length,
214
+ contentPassSkipped,
215
+ totalFound: combined.length,
216
+ });
217
+ }
218
+ return result;
205
219
  }
206
220
  export function scanAllRepoEndpoints(repositoryPath) {
207
221
  const endpointMap = new Map();