@skyramp/mcp 0.3.0-rc.1 → 0.3.0

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 (136) hide show
  1. package/build/prompts/test-maintenance/actionsInstructions.js +4 -2
  2. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +9 -0
  3. package/build/prompts/test-maintenance/drift-analysis-prompt.js +8 -7
  4. package/build/prompts/test-maintenance/driftAnalysisSections.js +4 -5
  5. package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +11 -0
  6. package/build/prompts/test-maintenance/driftAnalysisShared.js +22 -6
  7. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +23 -31
  8. package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +7 -1
  9. package/build/prompts/test-recommendation/diffExecutionPlan.js +20 -6
  10. package/build/prompts/test-recommendation/scopeAssessment.d.ts +10 -1
  11. package/build/prompts/test-recommendation/scopeAssessment.js +27 -2
  12. package/build/prompts/test-recommendation/scopeAssessment.test.js +35 -0
  13. package/build/prompts/test-recommendation/test-recommendation-prompt.js +5 -1
  14. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +61 -0
  15. package/build/prompts/testbot/testbot-prompts.js +7 -5
  16. package/build/prompts/testbot/testbot-prompts.test.js +1 -1
  17. package/build/services/AnalyticsService.js +37 -2
  18. package/build/services/TestDiscoveryService.d.ts +38 -4
  19. package/build/services/TestDiscoveryService.js +187 -16
  20. package/build/services/TestDiscoveryService.test.js +232 -10
  21. package/build/services/TestExecutionService.d.ts +12 -1
  22. package/build/services/TestExecutionService.js +20 -41
  23. package/build/services/TestGenerationService.d.ts +8 -0
  24. package/build/services/TestGenerationService.js +62 -17
  25. package/build/services/TestGenerationService.test.js +67 -1
  26. package/build/tools/auth/loginTool.js +1 -1
  27. package/build/tools/auth/logoutTool.js +1 -1
  28. package/build/tools/code-refactor/codeReuseTool.js +1 -1
  29. package/build/tools/code-refactor/modularizationTool.js +1 -1
  30. package/build/tools/executeSkyrampTestTool.js +30 -1
  31. package/build/tools/fixErrorTool.js +1 -1
  32. package/build/tools/one-click/oneClickTool.js +1 -1
  33. package/build/tools/test-management/actionsTool.js +76 -41
  34. package/build/tools/test-management/actionsTool.test.js +104 -38
  35. package/build/tools/test-management/analyzeChangesTool.js +102 -4
  36. package/build/tools/test-management/analyzeChangesTool.test.js +3 -1
  37. package/build/tools/test-management/analyzeTestHealthTool.js +9 -1
  38. package/build/tools/test-management/analyzeTestHealthTool.test.js +1 -1
  39. package/build/tools/trace/startTraceCollectionTool.js +1 -1
  40. package/build/tools/trace/stopTraceCollectionTool.js +1 -1
  41. package/build/tools/workspace/initScanWorkspaceTool.js +1 -1
  42. package/build/tools/workspace/initializeWorkspaceTool.js +1 -1
  43. package/build/tools/workspace/initializeWorkspaceTool.test.js +1 -1
  44. package/build/types/RepositoryAnalysis.d.ts +6 -6
  45. package/build/types/TestAnalysis.d.ts +13 -0
  46. package/build/utils/dartRouteExtractor.js +8 -30
  47. package/build/utils/docker.test.js +1 -1
  48. package/build/utils/fileWalk.d.ts +71 -0
  49. package/build/utils/fileWalk.js +79 -0
  50. package/build/utils/fileWalk.test.d.ts +1 -0
  51. package/build/utils/fileWalk.test.js +252 -0
  52. package/build/utils/frontendIntegration.js +20 -36
  53. package/build/utils/frontendSelectors.d.ts +34 -0
  54. package/build/utils/frontendSelectors.js +124 -0
  55. package/build/utils/frontendSelectors.test.d.ts +1 -0
  56. package/build/utils/frontendSelectors.test.js +118 -0
  57. package/build/utils/importerHop.d.ts +18 -0
  58. package/build/utils/importerHop.js +48 -3
  59. package/build/utils/importerHop.test.js +71 -1
  60. package/build/utils/pythonMountPrefixes.d.ts +25 -0
  61. package/build/utils/pythonMountPrefixes.js +347 -0
  62. package/build/utils/pythonMountPrefixes.test.d.ts +1 -0
  63. package/build/utils/pythonMountPrefixes.test.js +113 -0
  64. package/build/utils/repoScanner.js +30 -23
  65. package/build/utils/repoScanner.test.js +98 -0
  66. package/build/utils/routeParsers.d.ts +7 -1
  67. package/build/utils/routeParsers.js +32 -18
  68. package/build/utils/sourceRouteExtractor.js +17 -40
  69. package/build/utils/trace-parser.js +7 -19
  70. package/build/utils/versions.d.ts +3 -3
  71. package/build/utils/versions.js +1 -1
  72. package/build/utils/walkerCharacterization.test.d.ts +1 -0
  73. package/build/utils/walkerCharacterization.test.js +233 -0
  74. package/node_modules/playwright/lib/common/config.js +1 -1
  75. package/node_modules/playwright/lib/common/configLoader.js +1 -1
  76. package/node_modules/playwright/lib/common/fixtures.js +1 -1
  77. package/node_modules/playwright/lib/common/testType.js +1 -1
  78. package/node_modules/playwright/lib/index.js +2 -2
  79. package/node_modules/playwright/lib/isomorphic/testTree.js +1 -1
  80. package/node_modules/playwright/lib/matchers/expect.js +1 -1
  81. package/node_modules/playwright/lib/mcp/browser/tab.js +1 -1
  82. package/node_modules/playwright/lib/mcp/browser/tools/navigate.js +1 -1
  83. package/node_modules/playwright/lib/mcp/browser/tools/tracing.js +1 -1
  84. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +7 -0
  85. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +2 -1
  86. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +3 -1
  87. package/node_modules/playwright/lib/reporters/base.js +2 -2
  88. package/node_modules/playwright/lib/reporters/list.js +1 -1
  89. package/node_modules/playwright/lib/reporters/teleEmitter.js +1 -1
  90. package/node_modules/playwright/lib/worker/testInfo.js +2 -2
  91. package/node_modules/playwright/node_modules/playwright-core/ThirdPartyNotices.txt +23 -126
  92. package/node_modules/playwright/node_modules/playwright-core/lib/generated/injectedScriptSource.js +1 -1
  93. package/node_modules/playwright/node_modules/playwright-core/lib/generated/pollingRecorderSource.js +1 -1
  94. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +2 -1
  95. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-Cqp3cwEJ.js → codeMirrorModule-aszq5EdG.js} +1 -1
  96. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-CYf9adZh.js → defaultSettingsView-BxS7Jm4s.js} +94 -94
  97. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.d4gkVSou.js → index.D4JTTy4R.js} +1 -1
  98. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  99. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.u4_8VnCV.js → uiMode.DaRMQKOI.js} +1 -1
  100. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  101. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  102. package/node_modules/playwright/node_modules/playwright-core/src/generated/injectedScriptSource.ts +1 -1
  103. package/node_modules/playwright/node_modules/playwright-core/src/generated/pollingRecorderSource.ts +1 -1
  104. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  105. package/node_modules/playwright/package.json +1 -1
  106. package/package.json +3 -3
  107. package/node_modules/playwright/._ThirdPartyNotices.txt +0 -0
  108. package/node_modules/playwright/._cli.js +0 -0
  109. package/node_modules/playwright/._index.d.ts +0 -0
  110. package/node_modules/playwright/._index.js +0 -0
  111. package/node_modules/playwright/._index.mjs +0 -0
  112. package/node_modules/playwright/._jsx-runtime.js +0 -0
  113. package/node_modules/playwright/._jsx-runtime.mjs +0 -0
  114. package/node_modules/playwright/._lib +0 -0
  115. package/node_modules/playwright/._node_modules +0 -0
  116. package/node_modules/playwright/._package.json +0 -0
  117. package/node_modules/playwright/._test.d.ts +0 -0
  118. package/node_modules/playwright/._test.js +0 -0
  119. package/node_modules/playwright/._test.mjs +0 -0
  120. package/node_modules/playwright/._types +0 -0
  121. package/node_modules/playwright/._vitest.config.ts +0 -0
  122. package/node_modules/playwright/node_modules/playwright-core/._.DS_Store +0 -0
  123. package/node_modules/playwright/node_modules/playwright-core/._.npmignore +0 -0
  124. package/node_modules/playwright/node_modules/playwright-core/._README.md +0 -0
  125. package/node_modules/playwright/node_modules/playwright-core/._ThirdPartyNotices.txt +0 -0
  126. package/node_modules/playwright/node_modules/playwright-core/._bin +0 -0
  127. package/node_modules/playwright/node_modules/playwright-core/._browsers.json +0 -0
  128. package/node_modules/playwright/node_modules/playwright-core/._bundles +0 -0
  129. package/node_modules/playwright/node_modules/playwright-core/._cli.js +0 -0
  130. package/node_modules/playwright/node_modules/playwright-core/._index.d.ts +0 -0
  131. package/node_modules/playwright/node_modules/playwright-core/._index.js +0 -0
  132. package/node_modules/playwright/node_modules/playwright-core/._index.mjs +0 -0
  133. package/node_modules/playwright/node_modules/playwright-core/._lib +0 -0
  134. package/node_modules/playwright/node_modules/playwright-core/._package.json +0 -0
  135. package/node_modules/playwright/node_modules/playwright-core/._src +0 -0
  136. package/node_modules/playwright/node_modules/playwright-core/._types +0 -0
@@ -23,7 +23,8 @@ import { classifyUnmatchedFilesByPathAffinity } from "../../utils/pathAffinityCl
23
23
  import { scanAllRepoEndpoints, scanRelatedEndpoints, grepRouterMountingContext, findCandidateRouteFiles, } from "../../utils/repoScanner.js";
24
24
  import { detectProjectMetadata } from "../../utils/projectMetadata.js";
25
25
  import { draftScenariosFromEndpoints, draftResponseShapeScenarios, capScenarios } from "../../utils/scenarioDrafting.js";
26
- import { mapUnmatchedFilesToImporterEndpoints, buildHopResponseShapeInputs } from "../../utils/importerHop.js";
26
+ import { mapUnmatchedFilesToImporterEndpoints, buildHopResponseShapeInputs, deriveResourceTokensForFile, deriveResourceToken, extractChangedSymbols, sliceDiffByFile } from "../../utils/importerHop.js";
27
+ import { extractChangedSelectors, MAX_CHANGED_SELECTORS } from "../../utils/frontendSelectors.js";
27
28
  import { buildAnalysisOutputText } from "../../prompts/test-recommendation/analysisOutputPrompt.js";
28
29
  import { parseTraceFile, discoverTraceFiles, discoverPlaywrightZips, } from "../../utils/trace-parser.js";
29
30
  import { TestSource } from "../../types/TestAnalysis.js";
@@ -613,19 +614,114 @@ export function registerAnalyzeChangesTool(server) {
613
614
  });
614
615
  // PR mode → [] excludes external tests from pre-loading (agent discovers via grep).
615
616
  // No diff context → undefined triggers full-repo mode in TestDiscoveryService.
616
- const changedResources = classifiedEndpoints ? [] : undefined;
617
+ // SKYR-3924: a diff that changed NO endpoints but did change schema/model/DTO files
618
+ // yields no endpoint signal, so external tests would be skipped entirely and the
619
+ // breaking existing tests never enter the maintenance scope. Derive resource tokens
620
+ // from the changed files via the shared importer-hop derivation and use them, so
621
+ // discoverTests partitions and PRE-LOADS the relevant existing tests.
622
+ // Per-file diff slices — lazily computed once and shared by the changedSymbols
623
+ // (SKYR-3924) and changedSelectors (UI promotion pass 3) derivations below, so a diff
624
+ // that triggers both isn't sliced twice.
625
+ let _diffByFile = null;
626
+ const getDiffByFile = () => (_diffByFile ??= diffData?.diffContent ? sliceDiffByFile(diffData.diffContent) : new Map());
627
+ let changedResources = classifiedEndpoints ? [] : undefined;
628
+ let changedSymbols;
629
+ if (classifiedEndpoints &&
630
+ classifiedEndpoints.changedEndpoints.length === 0 &&
631
+ classifiedEndpoints.newEndpoints.length === 0 &&
632
+ classifiedEndpoints.removedEndpoints.length === 0 &&
633
+ diffData?.diffContent) {
634
+ // Only changed application-source files carry a resource signal — skip test
635
+ // files, configs, workflows, and docs (they'd contribute filename noise like
636
+ // "workspace" or "skyramp-testbot").
637
+ const SOURCE_EXT = /\.(ts|tsx|js|jsx|py|java|kt|go|rb|php|rs|cs|ex|exs)$/;
638
+ const files = (classifiedEndpoints.changedFiles ?? diffData.changedFiles ?? [])
639
+ .filter((f) => SOURCE_EXT.test(f) && !isTestFile(f));
640
+ // A schema/DTO/model file is named for its module, not the REST resource — its
641
+ // class names are the real signal (DeploymentCreate → "deployment", not the
642
+ // "actions" filename). For those files prefer the symbol-derived tokens and drop
643
+ // the generic filename token; otherwise use all tokens.
644
+ const SCHEMA_FILE = /\/(schemas?|models?|dtos?|entities|serializers?|types)\/|\.(dto|schema|model|entity|serializer)\.[^/]+$/i;
645
+ // Per-file diff sections (with @@ headers) so each file's tokens derive
646
+ // only from its own hunk — passing the full multi-file diff would mix
647
+ // symbols across files. Falls back to the full diff if a path doesn't
648
+ // match (no worse than the prior behavior). SKYR-3924.
649
+ const diffByFile = getDiffByFile();
650
+ const derived = new Set();
651
+ const symbols = new Set();
652
+ for (const f of files) {
653
+ const fileDiff = diffByFile.get(f) ?? diffData.diffContent;
654
+ const all = deriveResourceTokensForFile(f, fileDiff);
655
+ const filenameToken = deriveResourceToken(f);
656
+ const symbolTokens = all.filter((t) => t !== filenameToken);
657
+ // deriveResourceTokensForFile already drops generic tokens (via importerHop's
658
+ // GENERIC_RESOURCE_TOKENS / GENERIC_SYMBOL_TOKENS), so no extra filter is needed here.
659
+ const chosen = SCHEMA_FILE.test(f) && symbolTokens.length > 0 ? symbolTokens : all;
660
+ for (const t of chosen)
661
+ derived.add(t);
662
+ // Raw changed symbol names, derived from THIS file's diff slice only (not the full
663
+ // multi-file diff) so an unrelated changed file — a test or doc — can't inject its
664
+ // symbols. Uses the per-file slice from diffByFile.get(f).
665
+ for (const s of extractChangedSymbols(diffByFile.get(f) ?? ""))
666
+ symbols.add(s);
667
+ }
668
+ if (derived.size > 0) {
669
+ changedResources = [...derived];
670
+ // Raw changed symbol names (e.g. "DeploymentCreate") for a content grep against
671
+ // test bodies — catches tests that reference the changed type by name but whose
672
+ // path/URL doesn't token-match the resource (e.g. a schema unit test).
673
+ changedSymbols = [...symbols];
674
+ logger.info("Schema-only diff: derived changedResources from changed files (SKYR-3924)", {
675
+ changedResources,
676
+ changedSymbols,
677
+ });
678
+ }
679
+ }
617
680
  // Compute changed frontend files once — reused for both discoverTests (component
618
681
  // test promotion) and uiContext below (page enumeration). Uses classifiedEndpoints
619
682
  // changedFiles so it reflects the same diff scope as the rest of the analysis.
620
683
  const flutterSdkDep = hasFlutterSdkDep(params.repositoryPath);
621
684
  const changedFrontendFiles = (classifiedEndpoints?.changedFiles ?? diffData?.changedFiles ?? [])
622
685
  .filter(f => isFrontendFile(f, { hasFlutterSdkDep: flutterSdkDep }) && !isTestFile(f));
686
+ // Selector literals (data-testid values, CSS class tokens) added/removed in the
687
+ // frontend diff — drives discovery pass 3, which promotes page objects/specs that
688
+ // couple to a changed component by selector rather than by filename or import
689
+ // (see TestDiscoveryService Step 2). Derived from each changed frontend file's own
690
+ // diff slice so an unrelated file can't inject selectors.
691
+ let changedSelectors;
692
+ if (changedFrontendFiles.length > 0 && diffData?.diffContent) {
693
+ const diffByFile = getDiffByFile();
694
+ // Total cap across all changed files (each file is separately capped inside
695
+ // extractChangedSelectors). Bounds pass 3's O(externalFiles × selectors) scan so a
696
+ // large frontend PR can't produce a pathologically long selector set.
697
+ const MAX_TOTAL_SELECTORS = 200;
698
+ const selectors = new Set();
699
+ outer: for (const f of changedFrontendFiles) {
700
+ const fileSelectors = extractChangedSelectors(diffByFile.get(f) ?? "");
701
+ if (fileSelectors.length >= MAX_CHANGED_SELECTORS) {
702
+ logger.warning(`changedSelectors: file '${f}' hit the per-file cap (${MAX_CHANGED_SELECTORS}); its selector extraction may be truncated`);
703
+ }
704
+ for (const s of fileSelectors) {
705
+ selectors.add(s);
706
+ if (selectors.size >= MAX_TOTAL_SELECTORS) {
707
+ logger.warning(`changedSelectors: hit the total cap (${MAX_TOTAL_SELECTORS}) across ${changedFrontendFiles.length} changed frontend file(s); remaining selectors ignored for UI promotion pass 3`);
708
+ break outer;
709
+ }
710
+ }
711
+ }
712
+ if (selectors.size > 0) {
713
+ changedSelectors = [...selectors];
714
+ logger.info("Derived changedSelectors from frontend diff (UI promotion pass 3)", {
715
+ count: changedSelectors.length,
716
+ });
717
+ }
718
+ }
623
719
  let existingTests = [];
624
720
  let discoveredRelevantExternalPaths = [];
625
721
  try {
626
722
  const testDiscoveryService = new TestDiscoveryService();
627
723
  setTestsRepoDir(params.testsRepoDir);
628
- const discoveryResult = await testDiscoveryService.discoverTests(testDir ?? params.repositoryPath, { changedResources, hasFrontendChanges: changedFrontendFiles.length > 0 });
724
+ const discoveryResult = await testDiscoveryService.discoverTests(testDir ?? params.repositoryPath, { changedResources, changedSymbols, preciseResources: (changedSymbols?.length ?? 0) > 0, changedFrontendFiles, changedSelectors });
629
725
  existingTests = discoveryResult.tests.map((test) => ({
630
726
  testFile: test.testFile,
631
727
  testType: test.testType,
@@ -1374,6 +1470,8 @@ export function registerAnalyzeChangesTool(server) {
1374
1470
  primaryFramework: projectMeta.primaryFramework,
1375
1471
  existingTestCount: existingTests.length,
1376
1472
  newEndpointCount: classifiedEndpoints?.newEndpoints.length ?? 0,
1473
+ modifiedEndpointCount: classifiedEndpoints?.changedEndpoints.length ?? 0,
1474
+ removedEndpointCount: classifiedEndpoints?.removedEndpoints.length ?? 0,
1377
1475
  endpointCount: skeletonEndpoints.reduce((acc, ep) => acc + ep.methods.length, 0),
1378
1476
  },
1379
1477
  // Surface uiContext inline so the testbot prompt can iterate
@@ -1454,7 +1552,7 @@ export function registerAnalyzeChangesTool(server) {
1454
1552
  finally {
1455
1553
  AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, {
1456
1554
  repositoryPath: params.repositoryPath,
1457
- });
1555
+ }).catch((err) => { logger.warning("Analytics event failed", { error: String(err) }); });
1458
1556
  }
1459
1557
  });
1460
1558
  }
@@ -5,7 +5,7 @@ import { jest } from "@jest/globals";
5
5
  // graph touches must use unstable_mockModule before the dynamic import below.
6
6
  jest.unstable_mockModule("simple-git", () => ({ simpleGit: jest.fn() }));
7
7
  jest.unstable_mockModule("../../services/AnalyticsService.js", () => ({
8
- AnalyticsService: { pushMCPToolEvent: jest.fn() },
8
+ AnalyticsService: { pushMCPToolEvent: jest.fn().mockResolvedValue(undefined) },
9
9
  }));
10
10
  jest.unstable_mockModule("../../prompts/test-recommendation/test-recommendation-prompt.js", () => ({
11
11
  buildRecommendationPrompt: jest.fn(),
@@ -49,6 +49,8 @@ jest.unstable_mockModule("../../utils/importerHop.js", () => ({
49
49
  deriveResourceTokensForFile: jest.fn(() => []),
50
50
  endpointResourceTokens: jest.fn(() => new Set()),
51
51
  sliceAddedLinesByFile: jest.fn(() => new Map()),
52
+ sliceDiffByFile: jest.fn(() => new Map()),
53
+ extractChangedSymbols: jest.fn(() => []),
52
54
  }));
53
55
  jest.unstable_mockModule("../../utils/repoScanner.js", () => ({
54
56
  scanAllRepoEndpoints: jest.fn(),
@@ -95,10 +95,18 @@ export function registerAnalyzeTestHealthTool(server) {
95
95
  let uiDriftParams;
96
96
  if (hasUiFrontend) {
97
97
  logger.info(`Including UI drift analysis for ${uiTests.length} UI test(s), ${apiTests.length} API test(s)`);
98
+ // Shared components = changed frontend files imported by 2+ production files
99
+ // (server-computed importer graph, SKYR-3855). Surfacing the concrete importer
100
+ // list makes the "shared component → cannot IGNORE unread" drift rule enforceable
101
+ // against real data instead of the agent's qualitative guess.
102
+ const sharedComponents = (uiContext.frontendFileIntegration ?? [])
103
+ .filter((f) => f.importers.length >= 2)
104
+ .map((f) => ({ file: f.file, importers: f.importers }));
98
105
  uiDriftParams = {
99
106
  tests: uiTests.map(t => ({ testFile: t.testFile, source: t.source })),
100
107
  changedFrontendFiles: uiContext.changedFrontendFiles,
101
108
  blueprintCaptured: args.blueprintCaptured,
109
+ sharedComponents,
102
110
  };
103
111
  }
104
112
  // Collect all checked-out repo paths so the grep instruction covers
@@ -124,7 +132,7 @@ export function registerAnalyzeTestHealthTool(server) {
124
132
  return errorResult;
125
133
  }
126
134
  finally {
127
- AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, {});
135
+ AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, {}).catch((err) => { logger.warning("Analytics event failed", { error: String(err) }); });
128
136
  }
129
137
  });
130
138
  }
@@ -15,7 +15,7 @@ jest.unstable_mockModule("../../utils/logger.js", () => ({
15
15
  },
16
16
  }));
17
17
  jest.unstable_mockModule("../../services/AnalyticsService.js", () => ({
18
- AnalyticsService: { pushMCPToolEvent: jest.fn() },
18
+ AnalyticsService: { pushMCPToolEvent: jest.fn().mockResolvedValue(undefined) },
19
19
  }));
20
20
  const mockBuildDriftAnalysisPrompt = jest.fn().mockImplementation((_stateFile, apiTests, ui) => {
21
21
  const parts = [];
@@ -251,7 +251,7 @@ For detailed documentation visit: https://www.skyramp.dev/docs/load-test/advance
251
251
  prompt: params.prompt,
252
252
  playwright: params.playwright.toString(),
253
253
  };
254
- AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, recordParams);
254
+ AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, recordParams).catch((err) => { logger.warning("Analytics event failed", { error: String(err) }); });
255
255
  }
256
256
  });
257
257
  }
@@ -140,7 +140,7 @@ For detailed documentation visit: https://www.skyramp.dev/docs/load-test/advance
140
140
  prompt: params.prompt,
141
141
  playwright: params.playwrightEnabled.toString(),
142
142
  };
143
- AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, recordParams);
143
+ AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, recordParams).catch((err) => { logger.warning("Analytics event failed", { error: String(err) }); });
144
144
  }
145
145
  });
146
146
  }
@@ -70,7 +70,7 @@ export function registerInitScanWorkspaceTool(server) {
70
70
  return errorResult;
71
71
  }
72
72
  finally {
73
- AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, recordParams);
73
+ AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, recordParams).catch((err) => { logger.warning("Analytics event failed", { error: String(err) }); });
74
74
  }
75
75
  });
76
76
  }
@@ -201,7 +201,7 @@ export function registerInitializeWorkspaceTool(server) {
201
201
  return errorResult;
202
202
  }
203
203
  finally {
204
- AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, recordParams);
204
+ AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, recordParams).catch((err) => { logger.warning("Analytics event failed", { error: String(err) }); });
205
205
  }
206
206
  });
207
207
  }
@@ -18,7 +18,7 @@ jest.unstable_mockModule("../../utils/logger.js", () => ({
18
18
  logger: { info: jest.fn(), error: jest.fn() },
19
19
  }));
20
20
  jest.unstable_mockModule("../../services/AnalyticsService.js", () => ({
21
- AnalyticsService: { pushMCPToolEvent: jest.fn() },
21
+ AnalyticsService: { pushMCPToolEvent: jest.fn().mockResolvedValue(undefined) },
22
22
  }));
23
23
  jest.unstable_mockModule("./initScanWorkspaceTool.js", () => ({
24
24
  validateAndConsumeScanToken: jest.fn(),
@@ -1431,12 +1431,12 @@ export declare const analysisMetadataSchema: z.ZodObject<{
1431
1431
  }, "strip", z.ZodTypeAny, {
1432
1432
  repositoryName: string;
1433
1433
  analysisDate: string;
1434
- scanDepth: "quick" | "full";
1434
+ scanDepth: "full" | "quick";
1435
1435
  analysisScope?: AnalysisScope | undefined;
1436
1436
  }, {
1437
1437
  repositoryName: string;
1438
1438
  analysisDate: string;
1439
- scanDepth: "quick" | "full";
1439
+ scanDepth: "full" | "quick";
1440
1440
  analysisScope?: AnalysisScope | undefined;
1441
1441
  }>;
1442
1442
  export declare const projectClassificationSchema: z.ZodObject<{
@@ -1464,12 +1464,12 @@ export declare const repositoryAnalysisSchema: z.ZodObject<{
1464
1464
  }, "strip", z.ZodTypeAny, {
1465
1465
  repositoryName: string;
1466
1466
  analysisDate: string;
1467
- scanDepth: "quick" | "full";
1467
+ scanDepth: "full" | "quick";
1468
1468
  analysisScope?: AnalysisScope | undefined;
1469
1469
  }, {
1470
1470
  repositoryName: string;
1471
1471
  analysisDate: string;
1472
- scanDepth: "quick" | "full";
1472
+ scanDepth: "full" | "quick";
1473
1473
  analysisScope?: AnalysisScope | undefined;
1474
1474
  }>;
1475
1475
  projectClassification: z.ZodObject<{
@@ -2601,7 +2601,7 @@ export declare const repositoryAnalysisSchema: z.ZodObject<{
2601
2601
  metadata: {
2602
2602
  repositoryName: string;
2603
2603
  analysisDate: string;
2604
- scanDepth: "quick" | "full";
2604
+ scanDepth: "full" | "quick";
2605
2605
  analysisScope?: AnalysisScope | undefined;
2606
2606
  };
2607
2607
  projectClassification: {
@@ -2803,7 +2803,7 @@ export declare const repositoryAnalysisSchema: z.ZodObject<{
2803
2803
  metadata: {
2804
2804
  repositoryName: string;
2805
2805
  analysisDate: string;
2806
- scanDepth: "quick" | "full";
2806
+ scanDepth: "full" | "quick";
2807
2807
  analysisScope?: AnalysisScope | undefined;
2808
2808
  };
2809
2809
  projectClassification: {
@@ -48,8 +48,21 @@ export interface DriftRecommendation extends MaintenanceActionCore {
48
48
  newPath: string;
49
49
  method: string;
50
50
  }>;
51
+ /** Line-anchored edit sites the assessment located while reading a large UPDATE file —
52
+ * surfaced in update_context as a checklist so the apply step edits each site directly
53
+ * instead of re-scanning the file. */
54
+ editSites?: Array<{
55
+ line: number;
56
+ anchor: string;
57
+ change?: string;
58
+ }>;
51
59
  /** Computed during instruction building — suggested renamed file path if applicable. */
52
60
  _suggestedNewFile?: string;
61
+ /** REGENERATE/DELETE on an external (user-authored) test is report-only: the verdict is
62
+ * recorded and rendered in the report so the developer sees the recommendation, but the
63
+ * file is never touched (the apply loops skip reportOnly entries). Lets the report keep
64
+ * the real DELETE/REGENERATE action instead of flattening it to VERIFY. */
65
+ reportOnly?: boolean;
53
66
  }
54
67
  /**
55
68
  * Complete test analysis result that combines discovery, drift, execution, and health data
@@ -31,6 +31,7 @@
31
31
  */
32
32
  import * as fs from "fs";
33
33
  import * as path from "path";
34
+ import { collectFiles } from "./fileWalk.js";
34
35
  const MAX_CANDIDATE_FILES = 200;
35
36
  const MAX_WALK_DEPTH = 10;
36
37
  const SKIP_DIRS = new Set([
@@ -283,37 +284,14 @@ function collectImports(source, filePath, repoPath) {
283
284
  function snakeToCamel(s) {
284
285
  return s
285
286
  .split("_")
286
- .map((p, i) => p.charAt(0).toUpperCase() + p.slice(1))
287
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
287
288
  .join("");
288
289
  }
289
290
  function findDartFiles(repoPath) {
290
- const matches = [];
291
- function walk(dir, depth) {
292
- if (depth > MAX_WALK_DEPTH)
293
- return;
294
- if (matches.length >= MAX_CANDIDATE_FILES)
295
- return;
296
- let entries;
297
- try {
298
- entries = fs.readdirSync(dir, { withFileTypes: true });
299
- }
300
- catch {
301
- return;
302
- }
303
- for (const entry of entries) {
304
- if (matches.length >= MAX_CANDIDATE_FILES)
305
- return;
306
- const full = path.join(dir, entry.name);
307
- if (entry.isDirectory()) {
308
- if (SKIP_DIRS.has(entry.name) || entry.name.startsWith("."))
309
- continue;
310
- walk(full, depth + 1);
311
- }
312
- else if (entry.isFile() && entry.name.endsWith(".dart")) {
313
- matches.push(full);
314
- }
315
- }
316
- }
317
- walk(repoPath, 0);
318
- return matches;
291
+ return collectFiles(repoPath, {
292
+ match: (entry) => entry.name.endsWith(".dart"),
293
+ limit: MAX_CANDIDATE_FILES,
294
+ maxDepth: MAX_WALK_DEPTH,
295
+ shouldSkipDir: (entry) => SKIP_DIRS.has(entry.name) || entry.name.startsWith("."),
296
+ });
319
297
  }
@@ -55,7 +55,7 @@ describe("dockerImageExistsLocally", () => {
55
55
  });
56
56
  });
57
57
  describe("pullDockerImage", () => {
58
- const IMAGE = "skyramp/executor:v1.3.33";
58
+ const IMAGE = "skyramp/executor:v1.3.34";
59
59
  beforeEach(() => jest.clearAllMocks());
60
60
  describe("on amd64 host", () => {
61
61
  const originalArch = process.arch;
@@ -0,0 +1,71 @@
1
+ import * as fs from "fs";
2
+ export interface WalkDirOptions {
3
+ /**
4
+ * Return `true` to skip descending into a directory entirely. Receives the
5
+ * directory's `Dirent` and its absolute path. Omit to descend into every
6
+ * directory.
7
+ */
8
+ shouldSkipDir?: (entry: fs.Dirent, fullPath: string) => boolean;
9
+ /**
10
+ * Maximum directory depth to descend, with the walk root at depth 0. A file
11
+ * is visited only if its parent directory's depth is `<= maxDepth`. Omit for
12
+ * unlimited depth. (`maxDepth: 0` visits only files directly under the root.)
13
+ */
14
+ maxDepth?: number;
15
+ }
16
+ /**
17
+ * Core depth-first directory traversal shared by every repo walker.
18
+ *
19
+ * Yields `[entry, fullPath]` for each regular file encountered (directories are
20
+ * never yielded). Callers early-exit simply by `break`ing out of the `for…of` —
21
+ * abandoning the generator stops the walk immediately, before any further
22
+ * `readdirSync`.
23
+ *
24
+ * Best-effort: an unreadable directory is skipped rather than throwing, matching
25
+ * the scanners' "one bad directory shouldn't abort discovery" contract.
26
+ */
27
+ export declare function walkDir(rootDir: string, opts?: WalkDirOptions): Generator<[fs.Dirent, string]>;
28
+ export interface CollectFilesOptions {
29
+ /** Return `true` for files to include. Receives the file's `Dirent` and its absolute path. */
30
+ match: (entry: fs.Dirent, fullPath: string) => boolean;
31
+ /**
32
+ * Stop once this many files have been collected — a backstop against
33
+ * pathological repos. Omit for no cap. When the cap is hit exactly `limit`
34
+ * files are returned, so callers can detect truncation via
35
+ * `result.length >= limit`.
36
+ */
37
+ limit?: number;
38
+ /** Skip descending into a directory (see {@link WalkDirOptions.shouldSkipDir}). */
39
+ shouldSkipDir?: (entry: fs.Dirent, fullPath: string) => boolean;
40
+ /** Maximum descent depth, root = 0 (see {@link WalkDirOptions.maxDepth}). */
41
+ maxDepth?: number;
42
+ }
43
+ /**
44
+ * Collect the absolute paths of files matching `match`, up to `limit`.
45
+ *
46
+ * The cap-aware layer over {@link walkDir}: it owns the "accumulate matches and
47
+ * abort at the count cap" idiom once, so the repo's file collectors don't each
48
+ * re-derive it. Best-effort, matching `walkDir`.
49
+ */
50
+ export declare function collectFiles(rootDir: string, opts: CollectFilesOptions): string[];
51
+ export interface WalkOptions {
52
+ /** Only files whose name ends with one of these extensions are returned (e.g. [".py"], [".ts", ".tsx"]). */
53
+ extensions: string[];
54
+ /** Directory names to skip entirely (matched on basename, not full path). */
55
+ skipDirs: Set<string>;
56
+ /**
57
+ * Stop once this many files have been collected — a backstop against
58
+ * pathological repos. Omit for no cap. When the cap is hit the walk returns
59
+ * exactly `limit` files, so callers can detect truncation via
60
+ * `result.length >= limit`.
61
+ */
62
+ limit?: number;
63
+ }
64
+ /**
65
+ * Recursively collect source files under `rootDir`, returning absolute paths.
66
+ *
67
+ * Shared walker used by the repo scanners (endpoint catalog, Python mount-prefix
68
+ * resolver) so they don't each carry a near-identical `readdirSync` recursion.
69
+ * Thin wrapper over {@link collectFiles} that matches by extension suffix.
70
+ */
71
+ export declare function walkSourceFiles(rootDir: string, opts: WalkOptions): string[];
@@ -0,0 +1,79 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ /**
4
+ * Core depth-first directory traversal shared by every repo walker.
5
+ *
6
+ * Yields `[entry, fullPath]` for each regular file encountered (directories are
7
+ * never yielded). Callers early-exit simply by `break`ing out of the `for…of` —
8
+ * abandoning the generator stops the walk immediately, before any further
9
+ * `readdirSync`.
10
+ *
11
+ * Best-effort: an unreadable directory is skipped rather than throwing, matching
12
+ * the scanners' "one bad directory shouldn't abort discovery" contract.
13
+ */
14
+ export function* walkDir(rootDir, opts = {}) {
15
+ const { shouldSkipDir, maxDepth } = opts;
16
+ function* walk(dir, depth) {
17
+ let entries;
18
+ try {
19
+ entries = fs.readdirSync(dir, { withFileTypes: true });
20
+ }
21
+ catch {
22
+ return; // best-effort: skip unreadable directory, keep going
23
+ }
24
+ for (const entry of entries) {
25
+ const full = path.join(dir, entry.name);
26
+ if (entry.isDirectory()) {
27
+ // Cheap integer bound before the (possibly non-trivial) skip callback.
28
+ if (maxDepth !== undefined && depth + 1 > maxDepth)
29
+ continue;
30
+ if (shouldSkipDir?.(entry, full))
31
+ continue;
32
+ yield* walk(full, depth + 1);
33
+ }
34
+ else if (entry.isFile()) {
35
+ yield [entry, full];
36
+ }
37
+ }
38
+ }
39
+ yield* walk(rootDir, 0);
40
+ }
41
+ /**
42
+ * Collect the absolute paths of files matching `match`, up to `limit`.
43
+ *
44
+ * The cap-aware layer over {@link walkDir}: it owns the "accumulate matches and
45
+ * abort at the count cap" idiom once, so the repo's file collectors don't each
46
+ * re-derive it. Best-effort, matching `walkDir`.
47
+ */
48
+ export function collectFiles(rootDir, opts) {
49
+ const { match, limit, shouldSkipDir, maxDepth } = opts;
50
+ if (limit !== undefined && limit <= 0)
51
+ return [];
52
+ const results = [];
53
+ for (const [entry, full] of walkDir(rootDir, { shouldSkipDir, maxDepth })) {
54
+ if (match(entry, full)) {
55
+ results.push(full);
56
+ // Stop the instant the cap is reached — breaking abandons the generator
57
+ // before it descends into (and readdirs) any further directories, so we
58
+ // return exactly `limit` files.
59
+ if (limit !== undefined && results.length >= limit)
60
+ break;
61
+ }
62
+ }
63
+ return results;
64
+ }
65
+ /**
66
+ * Recursively collect source files under `rootDir`, returning absolute paths.
67
+ *
68
+ * Shared walker used by the repo scanners (endpoint catalog, Python mount-prefix
69
+ * resolver) so they don't each carry a near-identical `readdirSync` recursion.
70
+ * Thin wrapper over {@link collectFiles} that matches by extension suffix.
71
+ */
72
+ export function walkSourceFiles(rootDir, opts) {
73
+ const { extensions, skipDirs, limit } = opts;
74
+ return collectFiles(rootDir, {
75
+ match: (entry) => extensions.some((ext) => entry.name.endsWith(ext)),
76
+ limit,
77
+ shouldSkipDir: (entry) => skipDirs.has(entry.name),
78
+ });
79
+ }
@@ -0,0 +1 @@
1
+ export {};