@skyramp/mcp 0.0.44 → 0.0.45

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 (40) hide show
  1. package/build/index.js +15 -0
  2. package/build/prompts/code-reuse.js +1 -1
  3. package/build/prompts/driftAnalysisPrompt.js +159 -0
  4. package/build/prompts/modularization/ui-test-modularization.js +2 -0
  5. package/build/prompts/testGenerationPrompt.js +2 -2
  6. package/build/prompts/testHealthPrompt.js +82 -0
  7. package/build/services/DriftAnalysisService.js +924 -0
  8. package/build/services/ModularizationService.js +16 -1
  9. package/build/services/TestDiscoveryService.js +237 -0
  10. package/build/services/TestExecutionService.js +311 -0
  11. package/build/services/TestGenerationService.js +16 -2
  12. package/build/services/TestHealthService.js +653 -0
  13. package/build/tools/auth/loginTool.js +1 -1
  14. package/build/tools/auth/logoutTool.js +1 -1
  15. package/build/tools/code-refactor/codeReuseTool.js +5 -3
  16. package/build/tools/code-refactor/modularizationTool.js +8 -2
  17. package/build/tools/executeSkyrampTestTool.js +12 -122
  18. package/build/tools/fixErrorTool.js +1 -1
  19. package/build/tools/generate-tests/generateE2ERestTool.js +1 -1
  20. package/build/tools/generate-tests/generateFuzzRestTool.js +1 -1
  21. package/build/tools/generate-tests/generateLoadRestTool.js +1 -1
  22. package/build/tools/generate-tests/generateSmokeRestTool.js +1 -1
  23. package/build/tools/generate-tests/generateUIRestTool.js +1 -1
  24. package/build/tools/test-maintenance/actionsTool.js +202 -0
  25. package/build/tools/test-maintenance/analyzeTestDriftTool.js +188 -0
  26. package/build/tools/test-maintenance/calculateHealthScoresTool.js +248 -0
  27. package/build/tools/test-maintenance/discoverTestsTool.js +135 -0
  28. package/build/tools/test-maintenance/executeBatchTestsTool.js +188 -0
  29. package/build/tools/test-maintenance/stateCleanupTool.js +145 -0
  30. package/build/tools/test-recommendation/analyzeRepositoryTool.js +16 -1
  31. package/build/tools/test-recommendation/recommendTestsTool.js +6 -2
  32. package/build/tools/trace/startTraceCollectionTool.js +1 -1
  33. package/build/tools/trace/stopTraceCollectionTool.js +1 -1
  34. package/build/types/TestAnalysis.js +1 -0
  35. package/build/types/TestDriftAnalysis.js +1 -0
  36. package/build/types/TestExecution.js +6 -0
  37. package/build/types/TestHealth.js +4 -0
  38. package/build/utils/AnalysisStateManager.js +238 -0
  39. package/build/utils/utils.test.js +25 -9
  40. package/package.json +6 -3
@@ -0,0 +1,924 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { simpleGit } from "simple-git";
4
+ import { logger } from "../utils/logger.js";
5
+ export class EnhancedDriftAnalysisService {
6
+ git;
7
+ repositoryPath;
8
+ constructor() { }
9
+ /**
10
+ * Analyze drift for multiple tests in batch
11
+ */
12
+ async analyzeBatchDrift(testFiles, repositoryPath, options) {
13
+ logger.info(`Starting batch drift analysis for ${testFiles.length} tests`);
14
+ this.git = simpleGit(repositoryPath);
15
+ const results = [];
16
+ for (const testInfo of testFiles) {
17
+ try {
18
+ const result = await this.analyzeTestDrift({
19
+ testFile: testInfo.testFile,
20
+ repositoryPath,
21
+ testType: testInfo.testType,
22
+ includeApiSchema: options?.includeApiSchema ?? true,
23
+ includeDependencies: options?.includeDependencies ?? true,
24
+ });
25
+ results.push(result);
26
+ }
27
+ catch (error) {
28
+ logger.error(`Failed to analyze drift for ${testInfo.testFile}: ${error.message}`);
29
+ }
30
+ }
31
+ return results;
32
+ }
33
+ /**
34
+ * Analyze drift for a specific test file
35
+ */
36
+ async analyzeTestDrift(options) {
37
+ const { testFile, repositoryPath, baselineCommit } = options;
38
+ logger.info(`Analyzing drift for test: ${testFile}`);
39
+ // Step 1: Initialize git and get baseline
40
+ this.repositoryPath = repositoryPath;
41
+ this.git = simpleGit(repositoryPath);
42
+ if (!(await this.git.checkIsRepo())) {
43
+ throw new Error(`Not a git repository: ${repositoryPath}`);
44
+ }
45
+ let baseline = baselineCommit ||
46
+ (await this.getTestBaselineCommit(testFile, repositoryPath));
47
+ // Handle no git history case
48
+ if (!baseline) {
49
+ return await this.analyzeCurrentState(testFile, repositoryPath, options);
50
+ }
51
+ const currentCommit = await this.getCurrentCommit();
52
+ // No changes if baseline equals current
53
+ if (baseline === currentCommit) {
54
+ return this.createNoChangeResult(testFile, baseline, currentCommit);
55
+ }
56
+ // Step 2: Extract dependencies and determine test type
57
+ // Use test type from options if provided (from discovery), otherwise calculate it
58
+ const testType = options.testType || "";
59
+ const dependencies = await this.extractDependenciesWithTransitive(testFile);
60
+ // Step 3: Get changes between commits
61
+ const allChanges = await this.getChangesBetweenCommits(baseline, currentCommit);
62
+ const affectedFiles = this.filterAffectedFiles(allChanges, Array.from(dependencies), testType);
63
+ const fileChanges = await this.getFileChanges(affectedFiles, baseline, currentCommit);
64
+ // Step 4: Analyze API and UI changes
65
+ const apiSchemaChanges = options.includeApiSchema
66
+ ? await this.analyzeApiSchemaChanges(testFile, baseline, currentCommit)
67
+ : undefined;
68
+ const uiComponentChanges = this.analyzeUiComponentChanges(affectedFiles, fileChanges);
69
+ const { apiDependencyUpdates, uiDependencyUpdates } = await this.categorizeDependencyChanges(affectedFiles, fileChanges, baseline, currentCommit);
70
+ // Step 5: Get file contents with diffs for LLM analysis (if there are changes)
71
+ let fileContentsWithDiffs = [];
72
+ if (fileChanges.length > 0) {
73
+ fileContentsWithDiffs = await this.getFileContentsWithDiffs(affectedFiles, baseline, currentCommit);
74
+ logger.debug(`Collected ${fileContentsWithDiffs.length} files with diffs for LLM analysis`);
75
+ }
76
+ // Step 6: Collect changes and calculate drift
77
+ const changes = await this.collectChangesWithContext(apiSchemaChanges, uiComponentChanges, apiDependencyUpdates, uiDependencyUpdates);
78
+ const driftScore = await this.calculateDriftScore(testFile, changes, fileChanges, apiSchemaChanges, uiComponentChanges, testType, fileContentsWithDiffs);
79
+ // Step 6: Generate recommendations
80
+ const recommendations = this.generateRecommendations(driftScore, changes, apiSchemaChanges);
81
+ return {
82
+ testFile,
83
+ lastCommit: baseline,
84
+ currentCommit,
85
+ driftScore,
86
+ changes,
87
+ affectedFiles: {
88
+ files: affectedFiles,
89
+ apiDependencyUpdates: apiDependencyUpdates.length > 0 ? apiDependencyUpdates : undefined,
90
+ uiDependencyUpdates: uiDependencyUpdates.length > 0 ? uiDependencyUpdates : undefined,
91
+ },
92
+ apiSchemaChanges,
93
+ uiComponentChanges,
94
+ analysisTimestamp: new Date().toISOString(),
95
+ recommendations,
96
+ };
97
+ }
98
+ /**
99
+ * Get the commit hash when the test file was created/last modified
100
+ */
101
+ async getTestBaselineCommit(testFile, repositoryPath) {
102
+ try {
103
+ if (!fs.existsSync(testFile)) {
104
+ throw new Error(`Test file does not exist: ${testFile}`);
105
+ }
106
+ const relativePath = path.relative(repositoryPath, testFile);
107
+ if (relativePath.startsWith("..")) {
108
+ throw new Error(`Test file is outside repository: ${testFile}`);
109
+ }
110
+ const log = await this.git.log({ file: relativePath, maxCount: 1 });
111
+ if (log.latest?.hash) {
112
+ return log.latest.hash;
113
+ }
114
+ // Fallback: raw git command
115
+ const result = await this.git.raw([
116
+ "log",
117
+ "--format=%H",
118
+ "-n",
119
+ "1",
120
+ "--",
121
+ relativePath,
122
+ ]);
123
+ if (result && result.trim()) {
124
+ return result.trim();
125
+ }
126
+ logger.debug(`No git history found for file: ${relativePath}`);
127
+ return "";
128
+ }
129
+ catch (error) {
130
+ throw new Error(`Failed to get baseline commit: ${error.message}`);
131
+ }
132
+ }
133
+ /**
134
+ * Get current HEAD commit
135
+ */
136
+ async getCurrentCommit() {
137
+ const log = await this.git.log({ maxCount: 1 });
138
+ if (!log.latest?.hash) {
139
+ throw new Error("Could not get current commit");
140
+ }
141
+ return log.latest.hash;
142
+ }
143
+ /**
144
+ * Get list of changed files between two commits
145
+ */
146
+ async getChangesBetweenCommits(fromCommit, toCommit) {
147
+ try {
148
+ const diff = await this.git.diff(["--name-only", fromCommit, toCommit]);
149
+ return diff
150
+ .split("\n")
151
+ .map((f) => f.trim())
152
+ .filter((f) => f.length > 0);
153
+ }
154
+ catch (error) {
155
+ logger.error(`Failed to get changes between commits: ${error}`);
156
+ return [];
157
+ }
158
+ }
159
+ /**
160
+ * Get detailed change information for files
161
+ */
162
+ async getFileChanges(affectedFiles, fromCommit, toCommit) {
163
+ const fileChanges = [];
164
+ for (const file of affectedFiles) {
165
+ try {
166
+ const diff = await this.git.diff([
167
+ `${fromCommit}..${toCommit}`,
168
+ "--",
169
+ file,
170
+ ]);
171
+ const diffLines = diff.split("\n");
172
+ let linesAdded = 0;
173
+ let linesRemoved = 0;
174
+ diffLines.forEach((line) => {
175
+ if (line.startsWith("+") && !line.startsWith("+++"))
176
+ linesAdded++;
177
+ else if (line.startsWith("-") && !line.startsWith("---"))
178
+ linesRemoved++;
179
+ });
180
+ fileChanges.push({ file, linesAdded, linesRemoved });
181
+ logger.debug(`File changed: ${file}: +${linesAdded}/-${linesRemoved} lines`);
182
+ }
183
+ catch (error) {
184
+ logger.debug(`Could not get change info for ${file}: ${error}`);
185
+ }
186
+ }
187
+ return fileChanges;
188
+ }
189
+ /**
190
+ * Extract all dependencies (including transitive) from a test file
191
+ */
192
+ async extractDependenciesWithTransitive(testFile, maxDepth = 3) {
193
+ const allDependencies = new Set();
194
+ const visited = new Set();
195
+ const queue = [
196
+ { file: testFile, depth: 0 },
197
+ ];
198
+ while (queue.length > 0) {
199
+ const { file, depth } = queue.shift();
200
+ if (visited.has(file) || depth > maxDepth)
201
+ continue;
202
+ visited.add(file);
203
+ const directDeps = await this.extractDependencies(file);
204
+ for (const dep of directDeps) {
205
+ allDependencies.add(dep);
206
+ const depPath = await this.resolveDependencyPath(dep, file, this.repositoryPath);
207
+ if (depPath && !visited.has(depPath)) {
208
+ queue.push({ file: depPath, depth: depth + 1 });
209
+ }
210
+ }
211
+ }
212
+ logger.debug(`Extracted ${allDependencies.size} dependencies (max depth: ${maxDepth})`);
213
+ return allDependencies;
214
+ }
215
+ /**
216
+ * Extract direct imports/requires from a file
217
+ */
218
+ async extractDependencies(testFile) {
219
+ try {
220
+ const content = fs.readFileSync(testFile, "utf-8");
221
+ const dependencies = new Set();
222
+ // Python imports: from X import Y, import X
223
+ const pythonImports = content.match(/^(?:from|import)\s+([^\s]+)/gm) || [];
224
+ pythonImports.forEach((imp) => {
225
+ const match = imp.match(/(?:from|import)\s+([^\s]+)/);
226
+ if (match) {
227
+ const module = match[1].replace(/\./g, "/");
228
+ dependencies.add(`${module}.py`);
229
+ }
230
+ });
231
+ // JavaScript/TypeScript imports
232
+ const jsImports = content.match(/^import\s+.*?from\s+['"]([^'"]+)['"]/gm) || [];
233
+ jsImports.forEach((imp) => {
234
+ const match = imp.match(/from\s+['"]([^'"]+)['"]/);
235
+ if (match) {
236
+ let depPath = match[1];
237
+ if (depPath.startsWith(".")) {
238
+ depPath = path.resolve(path.dirname(testFile), depPath);
239
+ depPath = path.relative(this.repositoryPath, depPath);
240
+ }
241
+ dependencies.add(depPath);
242
+ }
243
+ });
244
+ // Require statements
245
+ const requireImports = content.match(/require\(['"]([^'"]+)['"]\)/g) || [];
246
+ requireImports.forEach((req) => {
247
+ const match = req.match(/require\(['"]([^'"]+)['"]\)/);
248
+ if (match) {
249
+ let depPath = match[1];
250
+ if (depPath.startsWith(".")) {
251
+ depPath = path.resolve(path.dirname(testFile), depPath);
252
+ depPath = path.relative(this.repositoryPath, depPath);
253
+ }
254
+ dependencies.add(depPath);
255
+ }
256
+ });
257
+ return Array.from(dependencies);
258
+ }
259
+ catch (error) {
260
+ logger.debug(`Could not extract dependencies: ${error}`);
261
+ return [];
262
+ }
263
+ }
264
+ /**
265
+ * Resolve a dependency string to an actual file path
266
+ */
267
+ async resolveDependencyPath(dependency, fromFile, repositoryPath) {
268
+ try {
269
+ const extensions = [
270
+ "",
271
+ ".ts",
272
+ ".tsx",
273
+ ".js",
274
+ ".jsx",
275
+ ".py",
276
+ "/index.ts",
277
+ "/index.js",
278
+ ];
279
+ // Check if it's a relative import (starts with . or ..)
280
+ if (dependency.startsWith(".")) {
281
+ // Resolve relative to the importing file's directory
282
+ const fromDir = path.dirname(fromFile);
283
+ const resolvedPath = path.resolve(fromDir, dependency);
284
+ for (const ext of extensions) {
285
+ const withExt = resolvedPath + ext;
286
+ if (fs.existsSync(withExt))
287
+ return withExt;
288
+ }
289
+ return null;
290
+ }
291
+ // Otherwise, resolve relative to repository root
292
+ const fromRoot = path.join(repositoryPath, dependency);
293
+ for (const ext of extensions) {
294
+ const withExt = fromRoot + ext;
295
+ if (fs.existsSync(withExt))
296
+ return withExt;
297
+ }
298
+ return null;
299
+ }
300
+ catch (error) {
301
+ logger.debug(`Could not resolve dependency ${dependency}: ${error}`);
302
+ return null;
303
+ }
304
+ }
305
+ /**
306
+ * Filter changed files to only those that are dependencies of the test
307
+ */
308
+ filterAffectedFiles(allChanges, dependencies, testType) {
309
+ const affected = new Set();
310
+ const isApiTest = [
311
+ "smoke",
312
+ "contract",
313
+ "integration",
314
+ "fuzz",
315
+ "load",
316
+ ].includes(testType);
317
+ const isUiTest = ["ui", "e2e"].includes(testType?.toLowerCase());
318
+ logger.debug(`Filter mode - isApiTest: ${isApiTest}, isUiTest: ${isUiTest}, testType: ${testType}`);
319
+ // UI component patterns
320
+ const uiPatterns = [
321
+ /component/i,
322
+ /page/i,
323
+ /view/i,
324
+ /screen/i,
325
+ /template/i,
326
+ /\.tsx?$/,
327
+ /\.jsx?$/,
328
+ /\.vue$/,
329
+ /src.*ui/i,
330
+ /src.*frontend/i,
331
+ /\.css$/i,
332
+ /\.scss$/i,
333
+ /\.less$/i,
334
+ ];
335
+ allChanges.forEach((file) => {
336
+ // Check if file is a dependency
337
+ const isDependency = dependencies.some((dep) => file === dep ||
338
+ file.includes(dep) ||
339
+ dep.includes(file) ||
340
+ file.endsWith(dep) ||
341
+ dep.endsWith(file));
342
+ if (isDependency) {
343
+ affected.add(file);
344
+ }
345
+ // For API tests: also include model/schema files
346
+ if (isApiTest && (file.includes("/model") || file.includes("/schema"))) {
347
+ affected.add(file);
348
+ }
349
+ // For UI tests: also include UI component files even if not directly imported
350
+ // UI tests use selectors and don't import components, so we need special handling
351
+ if (isUiTest) {
352
+ const isUiFile = uiPatterns.some((pattern) => pattern.test(file));
353
+ if (isUiFile) {
354
+ affected.add(file);
355
+ logger.debug(`Including UI file for UI test: ${file}`);
356
+ }
357
+ }
358
+ });
359
+ return Array.from(affected);
360
+ }
361
+ /**
362
+ * Analyze API schema changes (if OpenAPI/Swagger spec exists)
363
+ */
364
+ async analyzeApiSchemaChanges(testFile, fromCommit, toCommit) {
365
+ const schemaPath = await this.extractApiSchemaPath(testFile);
366
+ if (!schemaPath)
367
+ return undefined;
368
+ try {
369
+ const oldSchema = await this.git.show([`${fromCommit}:${schemaPath}`]);
370
+ const newSchema = await this.git.show([`${toCommit}:${schemaPath}`]);
371
+ if (!oldSchema || !newSchema)
372
+ return undefined;
373
+ const oldParsed = JSON.parse(oldSchema);
374
+ const newParsed = JSON.parse(newSchema);
375
+ const changes = {
376
+ endpointsRemoved: [],
377
+ endpointsModified: [],
378
+ authenticationChanged: false,
379
+ };
380
+ const oldPaths = oldParsed.paths || {};
381
+ const newPaths = newParsed.paths || {};
382
+ // Find removed endpoints
383
+ for (const path in oldPaths) {
384
+ if (!newPaths[path]) {
385
+ for (const method in oldPaths[path]) {
386
+ changes.endpointsRemoved.push({ path, method });
387
+ }
388
+ }
389
+ }
390
+ // Find modified endpoints and removed methods from existing paths
391
+ for (const path in oldPaths) {
392
+ if (newPaths[path]) {
393
+ for (const method in oldPaths[path]) {
394
+ if (newPaths[path][method]) {
395
+ const oldEndpoint = JSON.stringify(oldPaths[path][method]);
396
+ const newEndpoint = JSON.stringify(newPaths[path][method]);
397
+ if (oldEndpoint !== newEndpoint) {
398
+ changes.endpointsModified.push({
399
+ path,
400
+ method,
401
+ changes: ["Parameters or response modified"],
402
+ });
403
+ }
404
+ }
405
+ else {
406
+ // Method exists in old schema but not in new schema
407
+ changes.endpointsRemoved.push({ path, method });
408
+ }
409
+ }
410
+ }
411
+ }
412
+ // Check authentication changes
413
+ // OpenAPI 3.x uses components.securitySchemes, Swagger 2.x uses securityDefinitions
414
+ const oldAuth = JSON.stringify(oldParsed.components?.securitySchemes || oldParsed.securityDefinitions || {});
415
+ const newAuth = JSON.stringify(newParsed.components?.securitySchemes || newParsed.securityDefinitions || {});
416
+ if (oldAuth !== newAuth) {
417
+ changes.authenticationChanged = true;
418
+ changes.authenticationDetails = "Security schemes have been modified";
419
+ }
420
+ return changes;
421
+ }
422
+ catch (error) {
423
+ logger.debug(`Could not analyze API schema changes: ${error}`);
424
+ return undefined;
425
+ }
426
+ }
427
+ /**
428
+ * Extract API schema path from test file comments/metadata
429
+ */
430
+ async extractApiSchemaPath(testFile) {
431
+ try {
432
+ const content = fs.readFileSync(testFile, "utf-8");
433
+ const patterns = [
434
+ /apiSchema["\s:=]+["']([^"']+)["']/i,
435
+ /api_schema["\s:=]+["']([^"']+)["']/i,
436
+ /API Schema:\s*([^\s\n]+)/i,
437
+ ];
438
+ for (const pattern of patterns) {
439
+ const match = content.match(pattern);
440
+ if (match && match[1])
441
+ return match[1];
442
+ }
443
+ return null;
444
+ }
445
+ catch (error) {
446
+ logger.debug(`Could not extract API schema path: ${error}`);
447
+ return null;
448
+ }
449
+ }
450
+ /**
451
+ * Analyze UI component changes from affected files
452
+ */
453
+ analyzeUiComponentChanges(affectedFiles, fileChanges) {
454
+ const uiPatterns = [
455
+ /component/i,
456
+ /page/i,
457
+ /view/i,
458
+ /screen/i,
459
+ /template/i,
460
+ /\.tsx?$/,
461
+ /\.jsx?$/,
462
+ /\.vue$/,
463
+ /src.*ui/i,
464
+ /src.*frontend/i,
465
+ ];
466
+ const componentFiles = [];
467
+ const routeFiles = [];
468
+ let hasSelectorsChanges = false;
469
+ let hasStylingChanges = false;
470
+ affectedFiles.forEach((file) => {
471
+ const isUiFile = uiPatterns.some((pattern) => pattern.test(file));
472
+ if (!isUiFile)
473
+ return;
474
+ if (/route|router|navigation/i.test(file)) {
475
+ routeFiles.push(file);
476
+ }
477
+ else if (/component|page|view|screen/i.test(file)) {
478
+ componentFiles.push(file);
479
+ }
480
+ const changeInfo = fileChanges.find((c) => c.file === file);
481
+ if (changeInfo &&
482
+ (changeInfo.linesAdded > 5 || changeInfo.linesRemoved > 5)) {
483
+ if (/\.css|\.scss|\.less|styled|style/.test(file)) {
484
+ hasStylingChanges = true;
485
+ }
486
+ else {
487
+ hasSelectorsChanges = true;
488
+ }
489
+ }
490
+ });
491
+ if (componentFiles.length === 0 &&
492
+ routeFiles.length === 0 &&
493
+ !hasSelectorsChanges &&
494
+ !hasStylingChanges) {
495
+ return undefined;
496
+ }
497
+ return {
498
+ componentFiles,
499
+ routeFiles,
500
+ hasSelectorsChanges,
501
+ hasStylingChanges,
502
+ };
503
+ }
504
+ /**
505
+ * Categorize file changes into API and UI dependency changes
506
+ */
507
+ async categorizeDependencyChanges(affectedFiles, fileChanges, baseline, currentCommit) {
508
+ const apiDependencyUpdates = [];
509
+ const uiDependencyUpdates = [];
510
+ const apiPatterns = [
511
+ /api/i,
512
+ /endpoint/i,
513
+ /route/i,
514
+ /controller/i,
515
+ /handler/i,
516
+ /service/i,
517
+ /model/i,
518
+ /schema/i,
519
+ /\.(yaml|yml|json)$/i,
520
+ ];
521
+ const uiPatterns = [
522
+ /component/i,
523
+ /page/i,
524
+ /view/i,
525
+ /screen/i,
526
+ /template/i,
527
+ /\.tsx?$/,
528
+ /\.jsx?$/,
529
+ /\.vue$/,
530
+ /src.*ui/i,
531
+ /src.*frontend/i,
532
+ /\.css$/i,
533
+ /\.scss$/i,
534
+ /\.less$/i,
535
+ ];
536
+ for (const file of affectedFiles) {
537
+ const changeInfo = fileChanges.find((c) => c.file === file);
538
+ if (!changeInfo)
539
+ continue;
540
+ const isApiFile = apiPatterns.some((pattern) => pattern.test(file));
541
+ const isUiFile = uiPatterns.some((pattern) => pattern.test(file));
542
+ try {
543
+ const diff = await this.git.diff([
544
+ `${baseline}..${currentCommit}`,
545
+ "--",
546
+ file,
547
+ ]);
548
+ const change = {
549
+ file,
550
+ linesAdded: changeInfo.linesAdded,
551
+ linesRemoved: changeInfo.linesRemoved,
552
+ diff,
553
+ };
554
+ if (isApiFile)
555
+ apiDependencyUpdates.push(change);
556
+ else if (isUiFile)
557
+ uiDependencyUpdates.push(change);
558
+ }
559
+ catch (error) {
560
+ logger.debug(`Could not get diff for ${file}: ${error}`);
561
+ }
562
+ }
563
+ return { apiDependencyUpdates, uiDependencyUpdates };
564
+ }
565
+ /**
566
+ * Get file contents with diffs for LLM analysis
567
+ * This provides the actual code changes that the LLM can analyze
568
+ */
569
+ async getFileContentsWithDiffs(affectedFiles, fromCommit, toCommit) {
570
+ const fileContents = [];
571
+ for (const file of affectedFiles) {
572
+ try {
573
+ // Get git diff
574
+ const diff = await this.git.diff([
575
+ `${fromCommit}..${toCommit}`,
576
+ "--",
577
+ file,
578
+ ]);
579
+ // Get current file content
580
+ const filePath = path.join(this.repositoryPath, file);
581
+ const currentContent = fs.existsSync(filePath)
582
+ ? fs.readFileSync(filePath, "utf-8")
583
+ : "";
584
+ if (diff || currentContent) {
585
+ fileContents.push({
586
+ file,
587
+ diff,
588
+ currentContent: currentContent.slice(0, 5000), // Limit to 5000 chars
589
+ });
590
+ }
591
+ }
592
+ catch (error) {
593
+ logger.debug(`Could not get content/diff for ${file}: ${error}`);
594
+ }
595
+ }
596
+ return fileContents;
597
+ }
598
+ /**
599
+ * Collect all changes with context and detect breaking changes
600
+ */
601
+ async collectChangesWithContext(apiSchemaChanges, uiComponentChanges, apiDependencyUpdates, uiDependencyUpdates) {
602
+ const changes = [];
603
+ // API schema changes
604
+ if (apiSchemaChanges) {
605
+ if (apiSchemaChanges.endpointsRemoved.length > 0) {
606
+ changes.push({
607
+ type: "endpoint_removed",
608
+ file: "API Schema",
609
+ description: `${apiSchemaChanges.endpointsRemoved.length} endpoint(s) removed`,
610
+ severity: "high",
611
+ });
612
+ }
613
+ if (apiSchemaChanges.endpointsModified.length > 0) {
614
+ changes.push({
615
+ type: "endpoint_modified",
616
+ file: "API Schema",
617
+ description: `${apiSchemaChanges.endpointsModified.length} endpoint(s) modified`,
618
+ severity: "medium",
619
+ });
620
+ }
621
+ if (apiSchemaChanges.authenticationChanged) {
622
+ changes.push({
623
+ type: "authentication_changed",
624
+ file: "API Schema",
625
+ description: "Authentication mechanism changed",
626
+ severity: "critical",
627
+ });
628
+ }
629
+ }
630
+ // API dependency changes
631
+ for (const apiChange of apiDependencyUpdates) {
632
+ if (apiChange.diff) {
633
+ changes.push(...this.detectTypeMismatches(apiChange.diff, apiChange.file));
634
+ const linesAdded = apiChange.linesAdded || 0;
635
+ const linesRemoved = apiChange.linesRemoved || 0;
636
+ if (linesAdded > 5 || linesRemoved > 5) {
637
+ changes.push({
638
+ type: "endpoint_modified",
639
+ file: apiChange.file,
640
+ description: `API file modified: +${linesAdded}/-${linesRemoved} lines`,
641
+ severity: linesRemoved > 10 ? "high" : "medium",
642
+ });
643
+ }
644
+ }
645
+ }
646
+ // UI dependency changes
647
+ for (const uiChange of uiDependencyUpdates) {
648
+ if (uiChange.diff) {
649
+ changes.push(...this.detectSelectorChanges(uiChange.diff, uiChange.file));
650
+ const linesAdded = uiChange.linesAdded || 0;
651
+ const linesRemoved = uiChange.linesRemoved || 0;
652
+ if (linesAdded > 5 || linesRemoved > 5) {
653
+ changes.push({
654
+ type: "ui_component_modified",
655
+ file: uiChange.file,
656
+ description: `UI component modified: +${linesAdded}/-${linesRemoved} lines`,
657
+ severity: "medium",
658
+ });
659
+ }
660
+ }
661
+ }
662
+ // UI component changes
663
+ if (uiComponentChanges) {
664
+ if (uiComponentChanges.componentFiles.length > 0) {
665
+ changes.push({
666
+ type: "ui_component_modified",
667
+ file: "UI Components",
668
+ description: `${uiComponentChanges.componentFiles.length} component file(s) modified`,
669
+ severity: "medium",
670
+ });
671
+ }
672
+ if (uiComponentChanges.routeFiles.length > 0) {
673
+ changes.push({
674
+ type: "route_changed",
675
+ file: "Route Definitions",
676
+ description: `${uiComponentChanges.routeFiles.length} route file(s) changed`,
677
+ severity: "high",
678
+ });
679
+ }
680
+ }
681
+ return changes;
682
+ }
683
+ /**
684
+ * Detect type mismatches in diffs (e.g., field: int -> field: string)
685
+ */
686
+ detectTypeMismatches(diff, file) {
687
+ const changes = [];
688
+ const lines = diff.split("\n");
689
+ const fieldTypes = new Map();
690
+ const patterns = [
691
+ /^[-+]\s*(\w+)\s*:\s*(\w+)(?:\s*=|$)/,
692
+ /^[-+]\s*(\w+)\??\s*:\s*(\w+)/,
693
+ /^[-+]\s*(\w+)\s*:\s*(?:Optional|List|Dict|Union)\[(\w+)\]/,
694
+ ];
695
+ for (const line of lines) {
696
+ for (const pattern of patterns) {
697
+ const match = line.match(pattern);
698
+ if (match) {
699
+ const fieldName = match[1];
700
+ const fieldType = match[2];
701
+ if (!fieldTypes.has(fieldName)) {
702
+ fieldTypes.set(fieldName, {});
703
+ }
704
+ const fieldInfo = fieldTypes.get(fieldName);
705
+ if (line.startsWith("-"))
706
+ fieldInfo.oldType = fieldType;
707
+ if (line.startsWith("+"))
708
+ fieldInfo.newType = fieldType;
709
+ }
710
+ }
711
+ }
712
+ for (const [fieldName, types] of fieldTypes.entries()) {
713
+ if (types.oldType && types.newType && types.oldType !== types.newType) {
714
+ changes.push({
715
+ type: "breaking_change",
716
+ file,
717
+ description: `Field "${fieldName}" type changed from ${types.oldType} to ${types.newType}`,
718
+ severity: "critical",
719
+ details: `Critical: API now expects ${types.newType} but test may send ${types.oldType}`,
720
+ });
721
+ }
722
+ }
723
+ return changes;
724
+ }
725
+ /**
726
+ * Detect selector changes in UI diffs
727
+ */
728
+ detectSelectorChanges(diff, file) {
729
+ const changes = [];
730
+ const lines = diff.split("\n");
731
+ const selectorChanges = new Map();
732
+ const selectorPatterns = [
733
+ /(?:className|class)\s*[:=]\s*["']([^"']+)["']/,
734
+ /id\s*[:=]\s*["']([^"']+)["']/,
735
+ /data-testid\s*[:=]\s*["']([^"']+)["']/,
736
+ ];
737
+ for (const line of lines) {
738
+ for (const pattern of selectorPatterns) {
739
+ const match = line.match(pattern);
740
+ if (match) {
741
+ const selectorValue = match[1];
742
+ if (!selectorChanges.has(selectorValue)) {
743
+ selectorChanges.set(selectorValue, {});
744
+ }
745
+ const selectorInfo = selectorChanges.get(selectorValue);
746
+ if (line.startsWith("-"))
747
+ selectorInfo.removed = selectorValue;
748
+ if (line.startsWith("+"))
749
+ selectorInfo.added = selectorValue;
750
+ }
751
+ }
752
+ }
753
+ for (const [selectorValue, info] of selectorChanges.entries()) {
754
+ if (info.removed && !info.added) {
755
+ changes.push({
756
+ type: "ui_component_modified",
757
+ file,
758
+ description: `Selector removed: "${selectorValue}"`,
759
+ severity: "high",
760
+ details: "Test may use this selector which no longer exists",
761
+ });
762
+ }
763
+ }
764
+ return changes;
765
+ }
766
+ /**
767
+ * Calculate drift score (0-100)
768
+ *
769
+ * SCORING:
770
+ * - 0-20: Minimal impact
771
+ * - 21-40: Low impact
772
+ * - 41-60: Medium impact
773
+ * - 61-80: High impact (breaking changes likely)
774
+ * - 81-100: Critical impact
775
+ *
776
+ * STRATEGY:
777
+ * 1. If there are file changes with diffs -> Use LLM to analyze affected files
778
+ * 2. Otherwise -> Use heuristic scoring based on detected changes
779
+ */
780
+ async calculateDriftScore(testFile, changes, fileChanges, apiSchemaChanges, uiComponentChanges, testType, fileContentsWithDiffs) {
781
+ if (changes.length === 0 && !apiSchemaChanges && fileChanges.length === 0) {
782
+ return 0;
783
+ }
784
+ // Use heuristic scoring
785
+ let score = 0;
786
+ // Count by severity
787
+ changes.forEach((change) => {
788
+ switch (change.severity) {
789
+ case "critical":
790
+ score += 20;
791
+ break;
792
+ case "high":
793
+ score += 15;
794
+ break;
795
+ case "medium":
796
+ score += 10;
797
+ break;
798
+ case "low":
799
+ score += 5;
800
+ break;
801
+ }
802
+ });
803
+ // API schema changes
804
+ if (apiSchemaChanges) {
805
+ score += apiSchemaChanges.endpointsRemoved.length * 15;
806
+ score += apiSchemaChanges.endpointsModified.length * 10;
807
+ if (apiSchemaChanges.authenticationChanged)
808
+ score += 25;
809
+ }
810
+ // Large file changes indicate potential breaking changes
811
+ fileChanges.forEach((change) => {
812
+ if (change.linesAdded > 10 || change.linesRemoved > 10) {
813
+ score += 5;
814
+ }
815
+ });
816
+ return Math.min(Math.round(score), 100);
817
+ }
818
+ /**
819
+ * Generate actionable recommendations based on drift score
820
+ */
821
+ generateRecommendations(driftScore, changes, apiSchemaChanges) {
822
+ const recommendations = [];
823
+ if (driftScore === 0) {
824
+ recommendations.push("✅ Test is up-to-date with current codebase");
825
+ return recommendations;
826
+ }
827
+ if (driftScore > 80) {
828
+ recommendations.push("CRITICAL: Test requires immediate update due to significant breaking changes");
829
+ recommendations.push("Consider rewriting the test to match current implementation");
830
+ }
831
+ else if (driftScore > 60) {
832
+ recommendations.push("⚠️ HIGH: Test should be reviewed and updated soon");
833
+ recommendations.push("Review breaking changes and update test assertions");
834
+ }
835
+ else if (driftScore > 40) {
836
+ recommendations.push("⚡ MEDIUM: Test may need updates for related code changes");
837
+ recommendations.push("Review changes and update if necessary");
838
+ }
839
+ else if (driftScore > 20) {
840
+ recommendations.push("💡 LOW: Minor changes detected, test likely still valid");
841
+ recommendations.push("Monitor for potential issues");
842
+ }
843
+ else {
844
+ recommendations.push("✨ MINIMAL: Changes have minimal impact on test");
845
+ }
846
+ // Specific recommendations
847
+ if (apiSchemaChanges) {
848
+ if (apiSchemaChanges.endpointsRemoved.length > 0) {
849
+ recommendations.push(`⚠️ ${apiSchemaChanges.endpointsRemoved.length} API endpoint(s) removed - update test`);
850
+ }
851
+ if (apiSchemaChanges.authenticationChanged) {
852
+ recommendations.push("🔐 Authentication mechanism changed - update test authentication");
853
+ }
854
+ }
855
+ const highSeverityChanges = changes.filter((c) => c.severity === "critical" || c.severity === "high");
856
+ if (highSeverityChanges.length > 0) {
857
+ recommendations.push(`${highSeverityChanges.length} high-severity change(s) require attention`);
858
+ }
859
+ return recommendations;
860
+ }
861
+ /**
862
+ * Analyze current state when no git history is available
863
+ */
864
+ async analyzeCurrentState(testFile, repositoryPath, options) {
865
+ logger.info(`Analyzing current state for test (no git history): ${testFile}`);
866
+ // Use test type from options if provided (from discovery), otherwise calculate it
867
+ const testType = options.testType;
868
+ if (options.testType) {
869
+ logger.debug(`Using cached test type '${testType}' from discovery (avoiding recalculation)`);
870
+ }
871
+ const changes = [];
872
+ const recommendations = [];
873
+ let driftScore = 0;
874
+ // Check if dependencies exist
875
+ const dependencies = await this.extractDependenciesWithTransitive(testFile, 2);
876
+ const missingDependencies = [];
877
+ for (const dep of dependencies) {
878
+ const depPath = await this.resolveDependencyPath(dep, testFile, repositoryPath);
879
+ if (!depPath || !fs.existsSync(depPath)) {
880
+ missingDependencies.push(dep);
881
+ changes.push({
882
+ type: "dependency_changed",
883
+ file: dep,
884
+ description: `Missing dependency: ${dep}`,
885
+ severity: "high",
886
+ });
887
+ driftScore += 15;
888
+ }
889
+ }
890
+ if (missingDependencies.length === 0) {
891
+ recommendations.push("✓ All dependencies are present in the current codebase");
892
+ recommendations.push("Note: Git history enables tracking changes over time.");
893
+ }
894
+ else {
895
+ recommendations.push(`⚠️ ${missingDependencies.length} missing dependencies detected`);
896
+ recommendations.push("These imports may be broken or files may have been moved/deleted");
897
+ }
898
+ return {
899
+ testFile,
900
+ lastCommit: "",
901
+ currentCommit: "",
902
+ driftScore: Math.min(driftScore, 100),
903
+ changes,
904
+ affectedFiles: { files: missingDependencies },
905
+ analysisTimestamp: new Date().toISOString(),
906
+ recommendations,
907
+ };
908
+ }
909
+ /**
910
+ * Create a result for when there are no changes
911
+ */
912
+ createNoChangeResult(testFile, baseline, currentCommit) {
913
+ return {
914
+ testFile,
915
+ lastCommit: baseline,
916
+ currentCommit,
917
+ driftScore: 0,
918
+ changes: [],
919
+ affectedFiles: { files: [] },
920
+ analysisTimestamp: new Date().toISOString(),
921
+ recommendations: ["Test is up-to-date with current codebase"],
922
+ };
923
+ }
924
+ }