@skyramp/mcp 0.1.1 → 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.
- package/build/prompts/enhance-assertions/contractProviderAssertionsPrompt.js +28 -110
- package/build/prompts/enhance-assertions/integrationAssertionsPrompt.js +35 -128
- package/build/prompts/enhance-assertions/sharedAssertionRules.js +212 -0
- package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +217 -78
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +24 -19
- package/build/prompts/test-recommendation/recommendationSections.js +1 -1
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +48 -6
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +139 -0
- package/build/services/TestDiscoveryService.js +22 -7
- package/build/services/TestDiscoveryService.test.js +44 -0
- package/build/tools/code-refactor/enhanceAssertionsTool.js +8 -10
- package/build/tools/test-management/analyzeChangesTool.js +259 -140
- package/build/tools/test-management/analyzeChangesTool.test.js +3 -1
- package/build/tools/test-management/analyzeTestHealthTool.js +5 -0
- package/build/types/RepositoryAnalysis.js +8 -0
- package/build/utils/branchDiff.js +24 -8
- package/build/utils/docker.test.js +1 -1
- package/build/utils/repoScanner.js +16 -2
- package/build/utils/routeParsers.js +79 -79
- package/build/utils/routeParsers.test.js +192 -66
- package/build/utils/scenarioDrafting.js +10 -2
- package/build/utils/versions.js +1 -1
- package/package.json +2 -2
|
@@ -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 {
|
|
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(
|
|
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(
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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.
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
{
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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) });
|
|
@@ -54,7 +54,7 @@ describe("dockerImageExistsLocally", () => {
|
|
|
54
54
|
});
|
|
55
55
|
});
|
|
56
56
|
describe("pullDockerImage", () => {
|
|
57
|
-
const IMAGE = "skyramp/executor:v1.3.
|
|
57
|
+
const IMAGE = "skyramp/executor:v1.3.23";
|
|
58
58
|
beforeEach(() => jest.clearAllMocks());
|
|
59
59
|
describe("on amd64 host", () => {
|
|
60
60
|
const originalArch = process.arch;
|
|
@@ -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
|
-
|
|
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();
|