@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
@@ -0,0 +1,252 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { collectFiles, walkDir, walkSourceFiles } from "./fileWalk.js";
5
+ /** Materialize a file tree under a fresh temp dir. Keys are relative paths;
6
+ * a value of `null` creates an (empty) directory instead of a file. */
7
+ function makeTree(files) {
8
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "filewalk-"));
9
+ for (const [rel, content] of Object.entries(files)) {
10
+ const abs = path.join(root, rel);
11
+ if (content === null) {
12
+ fs.mkdirSync(abs, { recursive: true });
13
+ }
14
+ else {
15
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
16
+ fs.writeFileSync(abs, content);
17
+ }
18
+ }
19
+ return root;
20
+ }
21
+ /** Sort + make paths relative to root for stable comparison. */
22
+ function rel(root, abs) {
23
+ return abs.map((p) => path.relative(root, p).replace(/\\/g, "/")).sort();
24
+ }
25
+ // `chmod 000` only makes a directory unreadable where the OS enforces the
26
+ // permission bits: not on Windows, and not for root (which bypasses them). Skip
27
+ // the best-effort-on-unreadable-dir tests where the setup can't actually fail a
28
+ // readdir, rather than let them false-fail. `it`/`it.skip` chosen at load time.
29
+ const permsEnforced = process.platform !== "win32" &&
30
+ (typeof process.getuid !== "function" || process.getuid() !== 0);
31
+ const itIfPerms = permsEnforced ? it : it.skip;
32
+ /** Collect the absolute file paths a walkDir generator yields. */
33
+ function walkPaths(root, opts) {
34
+ const seen = [];
35
+ for (const [, full] of walkDir(root, opts))
36
+ seen.push(full);
37
+ return seen;
38
+ }
39
+ describe("walkDir", () => {
40
+ it("yields every file depth-first as [entry, absolute path]", () => {
41
+ const root = makeTree({
42
+ "a.txt": "a",
43
+ "sub/b.txt": "b",
44
+ "sub/deep/c.txt": "c",
45
+ });
46
+ const seen = [];
47
+ for (const [entry, full] of walkDir(root)) {
48
+ expect(entry.isFile()).toBe(true);
49
+ expect(path.isAbsolute(full)).toBe(true);
50
+ seen.push(full);
51
+ }
52
+ expect(rel(root, seen)).toEqual(["a.txt", "sub/b.txt", "sub/deep/c.txt"]);
53
+ });
54
+ it("yields only files, never directory entries", () => {
55
+ const root = makeTree({ "sub/b.txt": "b", emptydir: null });
56
+ const names = [...walkDir(root)].map(([entry]) => entry.name);
57
+ expect(names).toEqual(["b.txt"]);
58
+ });
59
+ it("honors maxDepth = 0 (root files only, no descent)", () => {
60
+ const root = makeTree({ "a.txt": "a", "sub/b.txt": "b" });
61
+ expect(rel(root, walkPaths(root, { maxDepth: 0 }))).toEqual(["a.txt"]);
62
+ });
63
+ it("honors maxDepth = 1 (root + one level down)", () => {
64
+ const root = makeTree({
65
+ "a.txt": "a",
66
+ "sub/b.txt": "b",
67
+ "sub/deep/c.txt": "c",
68
+ });
69
+ expect(rel(root, walkPaths(root, { maxDepth: 1 }))).toEqual([
70
+ "a.txt",
71
+ "sub/b.txt",
72
+ ]);
73
+ });
74
+ it("skips directories for which shouldSkipDir returns true", () => {
75
+ const root = makeTree({
76
+ "keep/a.txt": "a",
77
+ "node_modules/x.txt": "x",
78
+ });
79
+ const out = walkPaths(root, {
80
+ shouldSkipDir: (entry) => entry.name === "node_modules",
81
+ });
82
+ expect(rel(root, out)).toEqual(["keep/a.txt"]);
83
+ });
84
+ it("passes the directory's absolute path to shouldSkipDir", () => {
85
+ const root = makeTree({ "sub/a.txt": "a" });
86
+ [
87
+ ...walkDir(root, {
88
+ shouldSkipDir: (entry, full) => {
89
+ expect(path.isAbsolute(full)).toBe(true);
90
+ expect(path.basename(full)).toBe(entry.name);
91
+ return false;
92
+ },
93
+ }),
94
+ ];
95
+ });
96
+ it("stops immediately when the consumer breaks (no further reads)", () => {
97
+ const root = makeTree({
98
+ "1.txt": "",
99
+ "2.txt": "",
100
+ "3.txt": "",
101
+ "sub/4.txt": "",
102
+ });
103
+ const seen = [];
104
+ for (const [, full] of walkDir(root)) {
105
+ seen.push(full);
106
+ break; // stop after the very first file
107
+ }
108
+ expect(seen.length).toBe(1);
109
+ });
110
+ it("yields all files when the consumer never breaks", () => {
111
+ const root = makeTree({ "1.txt": "", "2.txt": "" });
112
+ expect(walkPaths(root).length).toBe(2);
113
+ });
114
+ itIfPerms("is best-effort: an unreadable subdirectory is skipped, not fatal", () => {
115
+ const root = makeTree({ "a.txt": "a", "bad/b.txt": "b", "c.txt": "c" });
116
+ const badDir = path.join(root, "bad");
117
+ fs.chmodSync(badDir, 0o000);
118
+ try {
119
+ // Should not throw despite `bad` being unreadable, and should skip its
120
+ // contents while still visiting siblings.
121
+ expect(rel(root, walkPaths(root))).toEqual(["a.txt", "c.txt"]);
122
+ }
123
+ finally {
124
+ fs.chmodSync(badDir, 0o755);
125
+ }
126
+ });
127
+ it("does not throw when the root itself is unreadable/missing", () => {
128
+ expect(() => walkPaths(path.join(os.tmpdir(), "filewalk-does-not-exist-xyz"))).not.toThrow();
129
+ });
130
+ });
131
+ describe("walkSourceFiles (characterization — existing contract)", () => {
132
+ it("collects only files matching one of the extensions, as absolute paths", () => {
133
+ const root = makeTree({
134
+ "a.ts": "",
135
+ "b.tsx": "",
136
+ "c.js": "",
137
+ "d.py": "",
138
+ "sub/e.ts": "",
139
+ });
140
+ const out = walkSourceFiles(root, {
141
+ extensions: [".ts", ".tsx"],
142
+ skipDirs: new Set(),
143
+ });
144
+ expect(out.every((p) => path.isAbsolute(p))).toBe(true);
145
+ expect(rel(root, out)).toEqual(["a.ts", "b.tsx", "sub/e.ts"]);
146
+ });
147
+ it("skips directories named in skipDirs (matched on basename)", () => {
148
+ const root = makeTree({
149
+ "a.ts": "",
150
+ "node_modules/x.ts": "",
151
+ "src/b.ts": "",
152
+ });
153
+ const out = walkSourceFiles(root, {
154
+ extensions: [".ts"],
155
+ skipDirs: new Set(["node_modules"]),
156
+ });
157
+ expect(rel(root, out)).toEqual(["a.ts", "src/b.ts"]);
158
+ });
159
+ it("stops at exactly `limit` files when the cap is hit", () => {
160
+ const root = makeTree({
161
+ "a.ts": "",
162
+ "b.ts": "",
163
+ "c.ts": "",
164
+ "d.ts": "",
165
+ });
166
+ const out = walkSourceFiles(root, {
167
+ extensions: [".ts"],
168
+ skipDirs: new Set(),
169
+ limit: 2,
170
+ });
171
+ expect(out.length).toBe(2);
172
+ });
173
+ it("returns all matches when under the limit", () => {
174
+ const root = makeTree({ "a.ts": "", "b.ts": "" });
175
+ const out = walkSourceFiles(root, {
176
+ extensions: [".ts"],
177
+ skipDirs: new Set(),
178
+ limit: 100,
179
+ });
180
+ expect(out.length).toBe(2);
181
+ });
182
+ it("does NOT skip dotfile directories by default", () => {
183
+ const root = makeTree({ ".hidden/a.ts": "", "b.ts": "" });
184
+ const out = walkSourceFiles(root, {
185
+ extensions: [".ts"],
186
+ skipDirs: new Set(),
187
+ });
188
+ expect(rel(root, out)).toEqual([".hidden/a.ts", "b.ts"]);
189
+ });
190
+ itIfPerms("is best-effort on unreadable directories", () => {
191
+ const root = makeTree({ "a.ts": "", "bad/b.ts": "" });
192
+ const badDir = path.join(root, "bad");
193
+ fs.chmodSync(badDir, 0o000);
194
+ try {
195
+ const out = walkSourceFiles(root, {
196
+ extensions: [".ts"],
197
+ skipDirs: new Set(),
198
+ });
199
+ expect(rel(root, out)).toEqual(["a.ts"]);
200
+ }
201
+ finally {
202
+ fs.chmodSync(badDir, 0o755);
203
+ }
204
+ });
205
+ });
206
+ describe("collectFiles", () => {
207
+ it("collects files matching the predicate as absolute paths", () => {
208
+ const root = makeTree({ "a.dart": "", "b.txt": "", "sub/c.dart": "" });
209
+ const out = collectFiles(root, {
210
+ match: (entry) => entry.name.endsWith(".dart"),
211
+ });
212
+ expect(out.every((p) => path.isAbsolute(p))).toBe(true);
213
+ expect(rel(root, out)).toEqual(["a.dart", "sub/c.dart"]);
214
+ });
215
+ it("passes the absolute path to the matcher", () => {
216
+ const root = makeTree({ "keep/x.ts": "", "modules/y/index.ts": "" });
217
+ const out = collectFiles(root, {
218
+ match: (_entry, full) => full.includes(`${path.sep}modules${path.sep}`),
219
+ });
220
+ expect(rel(root, out)).toEqual(["modules/y/index.ts"]);
221
+ });
222
+ it("returns exactly `limit` files when the cap is hit", () => {
223
+ const root = makeTree({ "a.ts": "", "b.ts": "", "c.ts": "" });
224
+ const out = collectFiles(root, {
225
+ match: (entry) => entry.name.endsWith(".ts"),
226
+ limit: 2,
227
+ });
228
+ expect(out.length).toBe(2);
229
+ });
230
+ it("returns nothing for `limit: 0` (does not collect a single file)", () => {
231
+ const root = makeTree({ "a.ts": "", "b.ts": "" });
232
+ const out = collectFiles(root, {
233
+ match: () => true,
234
+ limit: 0,
235
+ });
236
+ expect(out).toEqual([]);
237
+ });
238
+ it("forwards maxDepth and shouldSkipDir to the walk", () => {
239
+ const root = makeTree({
240
+ "a.ts": "",
241
+ "skip/b.ts": "",
242
+ "sub/deep/c.ts": "",
243
+ });
244
+ const out = collectFiles(root, {
245
+ match: (entry) => entry.name.endsWith(".ts"),
246
+ maxDepth: 1,
247
+ shouldSkipDir: (entry) => entry.name === "skip",
248
+ });
249
+ // maxDepth: 1 excludes sub/deep/c.ts; shouldSkipDir excludes skip/b.ts.
250
+ expect(rel(root, out)).toEqual(["a.ts"]);
251
+ });
252
+ });
@@ -28,6 +28,7 @@ import * as fs from "fs";
28
28
  import * as path from "path";
29
29
  import { isTestFile } from "../prompts/test-recommendation/scopeAssessment.js";
30
30
  import { logger } from "./logger.js";
31
+ import { walkDir } from "./fileWalk.js";
31
32
  import { buildPathSignatures } from "./pathSignatures.js";
32
33
  import { IntegrationReason } from "../types/FrontendIntegration.js";
33
34
  const MAX_IMPORTERS = 10;
@@ -117,48 +118,31 @@ function manualWalkGrep(repositoryPath, signatures) {
117
118
  const results = [];
118
119
  let filesScanned = 0;
119
120
  let truncated = false;
120
- const stack = [repositoryPath];
121
- walk: while (stack.length > 0) {
121
+ const shouldSkipDir = (_entry, full) => {
122
+ // EXCLUDED_DIR_PATTERN matches on the "/"-normalized relative path.
123
+ const rel = path.relative(repositoryPath, full).replace(/\\/g, "/");
124
+ return EXCLUDED_DIR_PATTERN.test(rel + "/");
125
+ };
126
+ for (const [entry, abs] of walkDir(repositoryPath, { shouldSkipDir })) {
127
+ if (!FRONTEND_SOURCE_EXT_PATTERN.test(entry.name))
128
+ continue;
122
129
  if (filesScanned >= MAX_FILES_WALKED) {
123
130
  truncated = true;
124
- break;
131
+ break; // stop the entire walk
125
132
  }
126
- const dir = stack.pop();
127
- let entries;
133
+ filesScanned++;
134
+ // Normalize to forward slashes: on Windows, path.relative() returns
135
+ // "\\"-separated paths, but isTestFile assumes "/".
136
+ const rel = path.relative(repositoryPath, abs).replace(/\\/g, "/");
128
137
  try {
129
- entries = fs.readdirSync(dir, { withFileTypes: true });
138
+ if (fs.statSync(abs).size > MAX_FILE_SIZE_BYTES)
139
+ continue;
140
+ const content = fs.readFileSync(abs, "utf-8");
141
+ if (signatures.some((s) => content.includes(s)))
142
+ results.push(rel);
130
143
  }
131
144
  catch {
132
- continue;
133
- }
134
- for (const entry of entries) {
135
- const abs = path.join(dir, entry.name);
136
- // Normalize to forward slashes: on Windows, path.relative() returns
137
- // "\\"-separated paths, but EXCLUDED_DIR_PATTERN/isTestFile assume "/".
138
- const rel = path.relative(repositoryPath, abs).replace(/\\/g, "/");
139
- if (entry.isDirectory()) {
140
- if (EXCLUDED_DIR_PATTERN.test(rel + "/"))
141
- continue;
142
- stack.push(abs);
143
- continue;
144
- }
145
- if (!FRONTEND_SOURCE_EXT_PATTERN.test(entry.name))
146
- continue;
147
- if (filesScanned >= MAX_FILES_WALKED) {
148
- truncated = true;
149
- break walk;
150
- }
151
- filesScanned++;
152
- try {
153
- if (fs.statSync(abs).size > MAX_FILE_SIZE_BYTES)
154
- continue;
155
- const content = fs.readFileSync(abs, "utf-8");
156
- if (signatures.some((s) => content.includes(s)))
157
- results.push(rel);
158
- }
159
- catch {
160
- continue;
161
- }
145
+ // Unreadable/vanished file — skip it, keep scanning.
162
146
  }
163
147
  }
164
148
  if (truncated && results.length === 0) {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Frontend selector extraction for UI-test-maintenance discovery (Pass 3, "selector-edge").
3
+ *
4
+ * Playwright/Cypress page objects couple to a changed component by SELECTOR — a
5
+ * `data-testid` value or a CSS class token — not by filename or by `import`. So the
6
+ * filename-token (pass 1) and import-edge (pass 2) promotion signals in
7
+ * TestDiscoveryService Step 2 both miss them: a page object `table.component.ts` shares
8
+ * no name token with the changed `CascadeFields.tsx` and does not import it — it selects
9
+ * `.t--table-filter-columns-dropdown` at runtime.
10
+ *
11
+ * This module extracts the selector literals that a frontend diff added OR removed, so
12
+ * discovery can promote any existing test/page-object whose body still references one of
13
+ * them. Both sides of a rename are captured deliberately: a page object that has NOT been
14
+ * updated still contains the OLD value, so matching the removed literal is what surfaces
15
+ * the test that needs maintenance.
16
+ *
17
+ * This is the frontend analog of importerHop's `extractChangedSymbols` (backend type
18
+ * names) — kept in its own frontend-scoped module rather than in the backend-resource
19
+ * importerHop, per the codebase convention of keeping domain vocabularies local.
20
+ */
21
+ export declare const MAX_CHANGED_SELECTORS = 80;
22
+ /**
23
+ * Extract selector literals added or removed in a single file's diff slice.
24
+ *
25
+ * Two complementary passes over the changed (`+`/`-`) lines, excluding the `+++`/`---`
26
+ * file headers:
27
+ * 1. Explicit test-facing attribute/API values: `data-testid` / `data-test` /
28
+ * `data-test-id` / `data-cy` / `data-qa` attribute values and `getByTestId(...)`
29
+ * arguments (trusted — accepts camelCase or single-delimiter ids).
30
+ * 2. Distinctive kebab / BEM class tokens anywhere on the line (>= 2 hyphens or `--`),
31
+ * which catches CSS-class-based coupling (`t--table-filter-columns-dropdown`,
32
+ * `ads-button--primary`).
33
+ */
34
+ export declare function extractChangedSelectors(fileDiff: string): string[];
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Frontend selector extraction for UI-test-maintenance discovery (Pass 3, "selector-edge").
3
+ *
4
+ * Playwright/Cypress page objects couple to a changed component by SELECTOR — a
5
+ * `data-testid` value or a CSS class token — not by filename or by `import`. So the
6
+ * filename-token (pass 1) and import-edge (pass 2) promotion signals in
7
+ * TestDiscoveryService Step 2 both miss them: a page object `table.component.ts` shares
8
+ * no name token with the changed `CascadeFields.tsx` and does not import it — it selects
9
+ * `.t--table-filter-columns-dropdown` at runtime.
10
+ *
11
+ * This module extracts the selector literals that a frontend diff added OR removed, so
12
+ * discovery can promote any existing test/page-object whose body still references one of
13
+ * them. Both sides of a rename are captured deliberately: a page object that has NOT been
14
+ * updated still contains the OLD value, so matching the removed literal is what surfaces
15
+ * the test that needs maintenance.
16
+ *
17
+ * This is the frontend analog of importerHop's `extractChangedSymbols` (backend type
18
+ * names) — kept in its own frontend-scoped module rather than in the backend-resource
19
+ * importerHop, per the codebase convention of keeping domain vocabularies local.
20
+ */
21
+ // Bound the extracted set so a large diff can't produce an unbounded match list. The
22
+ // downstream promotion is separately capped by MAX_UI_PROMOTED, so this is a belt on the
23
+ // literal set itself. Exported so the caller can detect + log when a single file's
24
+ // extraction is truncated by this cap.
25
+ export const MAX_CHANGED_SELECTORS = 80;
26
+ // Utility / design-token CSS classes (Tailwind/Bootstrap-style) are ALSO multi-hyphen kebab
27
+ // tokens, but they are shared *styling* vocabulary, not component identity — matching them
28
+ // floods Pass 3, because snapshot files (`__snapshots__/*.snap`) serialize `className`
29
+ // verbatim, so an unrelated component's snapshot reuses the same class (e.g. `text-gray-500`).
30
+ // Reject the two dominant utility shapes on the untrusted path: a numeric scale suffix
31
+ // (`text-gray-500`, `bg-blue-600`, `grid-cols-3`) and common non-numeric utility roots
32
+ // (`flex-row-reverse`, `space-x-…`). BEM component classes (`ads-button--primary`, `t--…`)
33
+ // and semantic identifiers (`confirmation-modal-confirm-button`) are kept.
34
+ const COMMON_CSS_SELECTOR_TOKENS = /-\d+$|^(?:flex|grid|space|gap|order|col|row|inset|translate|scale|rotate|divide|place|self|content|justify|items|auto)-/;
35
+ /**
36
+ * True if a token is a distinctive selector literal worth matching against test bodies.
37
+ * `trusted` = extracted from an explicit test-facing attribute (data-testid / getByTestId),
38
+ * where a single delimiter or camelCase boundary is enough. Untrusted (a bare kebab/BEM token
39
+ * found anywhere on the line) requires a BEM `--`, or >= 2 hyphens that don't look like a
40
+ * utility/design-token class — otherwise shared styling classes flood promotion.
41
+ */
42
+ function isDistinctiveSelector(token, trusted) {
43
+ if (token.length < 6)
44
+ return false;
45
+ const hyphens = (token.match(/-/g) ?? []).length;
46
+ const hasBEM = token.includes("--");
47
+ if (trusted) {
48
+ // A test id: any structural signal (delimiter or camelCase) qualifies — single-word
49
+ // ids like "submit" (6 chars, so past the length gate) are dropped by the structural
50
+ // requirement alone: no hyphen, underscore, or camelCase boundary.
51
+ return hyphens > 0 || token.includes("_") || /[a-z][A-Z]/.test(token);
52
+ }
53
+ return hasBEM || (hyphens >= 2 && !COMMON_CSS_SELECTOR_TOKENS.test(token));
54
+ }
55
+ /**
56
+ * Extract selector literals added or removed in a single file's diff slice.
57
+ *
58
+ * Two complementary passes over the changed (`+`/`-`) lines, excluding the `+++`/`---`
59
+ * file headers:
60
+ * 1. Explicit test-facing attribute/API values: `data-testid` / `data-test` /
61
+ * `data-test-id` / `data-cy` / `data-qa` attribute values and `getByTestId(...)`
62
+ * arguments (trusted — accepts camelCase or single-delimiter ids).
63
+ * 2. Distinctive kebab / BEM class tokens anywhere on the line (>= 2 hyphens or `--`),
64
+ * which catches CSS-class-based coupling (`t--table-filter-columns-dropdown`,
65
+ * `ads-button--primary`).
66
+ */
67
+ export function extractChangedSelectors(fileDiff) {
68
+ const out = new Set();
69
+ // Require a quote immediately before the value so only string LITERALS are captured —
70
+ // `data-testid="x"`, `data-testid={'x'}`, `data-testid={`x`}`. This deliberately skips
71
+ // non-literal expressions like `data-testid={someVar}`, which would otherwise inject a
72
+ // variable name into the selector set and cause noisy promotion matches.
73
+ // Require a quote/backtick on BOTH sides so only FULLY-literal values are captured —
74
+ // `data-testid="x"`, `data-testid={'x'}`, `data-testid={`x`}`. The closing delimiter
75
+ // deliberately rejects interpolated template literals (`data-testid={`members-${id}`}`),
76
+ // which would otherwise leak a partial prefix (`members-`) into the selector set.
77
+ // eslint-disable-next-line no-useless-escape
78
+ const ATTR = /\bdata-(?:test-?id|test|cy|qa)\b\s*=\s*\{?\s*[`'"]([A-Za-z][\w:-]{2,})[`'"]/g;
79
+ const GET_BY_TEST_ID = /getByTestId\(\s*[`'"]([A-Za-z][\w:-]{2,})[`'"]/g;
80
+ const KEBAB = /[a-z][a-z0-9]*(?:-{1,2}[a-z0-9]+)+/g;
81
+ // BEM base whose modifier is composed DYNAMICALLY, so no concrete class literal exists
82
+ // in the diff to grep: `ads-button--${kind}`, "ads-button--" + kind, clsx(base && `x--${y}`).
83
+ // Match a kebab base + `--` followed by a NON-alphanumeric (interpolation `$`, quote,
84
+ // paren, whitespace) and capture the `<base>--` prefix. The page objects' concrete
85
+ // modifier classes (ads-button--primary, ads-button--secondary) all start with that
86
+ // prefix, so a substring match on it promotes them — the shared/design-system component
87
+ // case a static-literal scan (KEBAB) misses. (A STATIC modifier like `foo--bar` is left
88
+ // to KEBAB, which captures the whole class.)
89
+ const BEM_DYNAMIC = /([a-z][a-z0-9]*(?:-[a-z0-9]+)+--)(?![a-z0-9])/g;
90
+ for (const raw of fileDiff.split("\n")) {
91
+ if (!(raw.startsWith("+") || raw.startsWith("-")))
92
+ continue;
93
+ if (raw.startsWith("+++") || raw.startsWith("---"))
94
+ continue;
95
+ const line = raw.slice(1);
96
+ for (const re of [ATTR, GET_BY_TEST_ID]) {
97
+ re.lastIndex = 0;
98
+ let m;
99
+ while ((m = re.exec(line)) !== null) {
100
+ if (isDistinctiveSelector(m[1], true))
101
+ out.add(m[1]);
102
+ if (out.size >= MAX_CHANGED_SELECTORS)
103
+ return [...out];
104
+ }
105
+ }
106
+ KEBAB.lastIndex = 0;
107
+ let k;
108
+ while ((k = KEBAB.exec(line)) !== null) {
109
+ if (isDistinctiveSelector(k[0], false))
110
+ out.add(k[0]);
111
+ if (out.size >= MAX_CHANGED_SELECTORS)
112
+ return [...out];
113
+ }
114
+ BEM_DYNAMIC.lastIndex = 0;
115
+ let b;
116
+ while ((b = BEM_DYNAMIC.exec(line)) !== null) {
117
+ if (isDistinctiveSelector(b[1], false))
118
+ out.add(b[1]);
119
+ if (out.size >= MAX_CHANGED_SELECTORS)
120
+ return [...out];
121
+ }
122
+ }
123
+ return [...out];
124
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect } from "@jest/globals";
2
+ import { extractChangedSelectors } from "./frontendSelectors.js";
3
+ describe("extractChangedSelectors", () => {
4
+ it("captures both the old (removed) and new (added) value of a renamed data-testid", () => {
5
+ const diff = [
6
+ "@@ -175,7 +175,7 @@",
7
+ '- dataTestId="confirmation-modal-confirm-button"',
8
+ '+ dataTestId="confirmation-modal-primary-button"',
9
+ ].join("\n");
10
+ const sel = extractChangedSelectors(diff);
11
+ // dataTestId isn't a data-* attribute, but both are distinctive kebab class-ish tokens
12
+ expect(sel).toEqual(expect.arrayContaining([
13
+ "confirmation-modal-confirm-button",
14
+ "confirmation-modal-primary-button",
15
+ ]));
16
+ });
17
+ it("captures data-testid attribute values", () => {
18
+ const diff = '+ <input data-testid="members-search-field" />\n- <input data-testid="members-search-input" />';
19
+ const sel = extractChangedSelectors(diff);
20
+ expect(sel).toEqual(expect.arrayContaining(["members-search-field", "members-search-input"]));
21
+ });
22
+ it("captures CSS class rename with BEM `--` and multi-segment kebab", () => {
23
+ const diff = [
24
+ '- <div className="t--table-filter-columns-dropdown">',
25
+ '+ <div className="t--tbl-filter-column-select">',
26
+ '+ <button className="ads-button--primary">',
27
+ ].join("\n");
28
+ const sel = extractChangedSelectors(diff);
29
+ expect(sel).toEqual(expect.arrayContaining([
30
+ "t--table-filter-columns-dropdown",
31
+ "t--tbl-filter-column-select",
32
+ "ads-button--primary",
33
+ ]));
34
+ });
35
+ it("captures the BEM base prefix of a dynamically-composed modifier class", () => {
36
+ const diff = [
37
+ "- className={clsx(ButtonClassName, className)}",
38
+ "+ className={clsx(ButtonClassName, kind && `ads-button--${kind}`, className)}",
39
+ ].join("\n");
40
+ const sel = extractChangedSelectors(diff);
41
+ // no concrete `ads-button--primary` literal exists in the diff — capture the base prefix
42
+ expect(sel).toContain("ads-button--");
43
+ });
44
+ it("captures the BEM base from string-concatenation too, but leaves STATIC modifiers to the kebab pass", () => {
45
+ const concat = extractChangedSelectors('+ const cls = "ads-button--" + kind;');
46
+ expect(concat).toContain("ads-button--");
47
+ // a fully static modifier is captured whole (not as a bare `foo-bar--` prefix)
48
+ const staticMod = extractChangedSelectors('+ <div className="t--tbl-filter-column-select" />');
49
+ expect(staticMod).toContain("t--tbl-filter-column-select");
50
+ expect(staticMod).not.toContain("t--tbl-filter-column-select--");
51
+ });
52
+ it("captures getByTestId arguments", () => {
53
+ const diff = "+ const btn = page.getByTestId('workflow-settings-modal');";
54
+ expect(extractChangedSelectors(diff)).toContain("workflow-settings-modal");
55
+ });
56
+ it("drops single-hyphen styling utilities and short/generic tokens", () => {
57
+ const diff = [
58
+ '+ <div className="flex-start space-between text-align" />',
59
+ '+ const gap = "sans-serif";',
60
+ ].join("\n");
61
+ expect(extractChangedSelectors(diff)).toEqual([]);
62
+ });
63
+ it("drops multi-hyphen utility / design-token classes (numeric scale + utility roots)", () => {
64
+ const diff = [
65
+ '- <div className="text-gray-500 bg-blue-600 flex-row-reverse" />',
66
+ '+ <div className="text-gray-600 bg-blue-700 grid-cols-3" />',
67
+ ].join("\n");
68
+ const sel = extractChangedSelectors(diff);
69
+ for (const util of ["text-gray-500", "text-gray-600", "bg-blue-600", "bg-blue-700", "flex-row-reverse", "grid-cols-3"]) {
70
+ expect(sel).not.toContain(util);
71
+ }
72
+ });
73
+ it("keeps semantic multi-hyphen identifiers and BEM classes (not utility)", () => {
74
+ const diff = [
75
+ '+ <div className="confirmation-modal-confirm-button user-profile-header" />',
76
+ '+ <div className="t--tbl-filter-column-select" />',
77
+ ].join("\n");
78
+ const sel = extractChangedSelectors(diff);
79
+ expect(sel).toEqual(expect.arrayContaining([
80
+ "confirmation-modal-confirm-button",
81
+ "user-profile-header",
82
+ "t--tbl-filter-column-select",
83
+ ]));
84
+ });
85
+ it("ignores context (unchanged) lines and file headers", () => {
86
+ const diff = [
87
+ "--- a/src/Component.tsx",
88
+ "+++ b/src/Component.tsx",
89
+ ' <div className="t--unchanged-context-selector" />', // context line (leading space)
90
+ '+ <div className="t--added-real-selector" />',
91
+ ].join("\n");
92
+ const sel = extractChangedSelectors(diff);
93
+ expect(sel).toContain("t--added-real-selector");
94
+ expect(sel).not.toContain("t--unchanged-context-selector");
95
+ expect(sel).not.toContain("Component"); // +++/--- headers ignored
96
+ });
97
+ it("does not leak a partial prefix from an interpolated template-literal attribute value", () => {
98
+ const diff = [
99
+ "+ <input data-testid={`members-search-${id}`} />",
100
+ "+ <button data-testid={`confirmation-modal-${kind}`} />",
101
+ ].join("\n");
102
+ const sel = extractChangedSelectors(diff);
103
+ expect(sel).not.toContain("members-search-");
104
+ expect(sel).not.toContain("confirmation-modal-");
105
+ });
106
+ it("does not capture non-literal expression attribute values (data-testid={someVar})", () => {
107
+ const diff = [
108
+ "+ <div data-testid={someVariableName} />",
109
+ "+ <div data-testid={'members-search-field'} />",
110
+ ].join("\n");
111
+ const sel = extractChangedSelectors(diff);
112
+ expect(sel).not.toContain("someVariableName"); // variable name, not a literal
113
+ expect(sel).toContain("members-search-field"); // quoted literal still captured
114
+ });
115
+ it("returns [] for an empty diff", () => {
116
+ expect(extractChangedSelectors("")).toEqual([]);
117
+ });
118
+ });
@@ -31,6 +31,14 @@ export declare function deriveResourceToken(file: string): string;
31
31
  * `[deriveResourceToken(file)]` (or `[]` if that's generic/unknown) —
32
32
  * identical to today's filename-only behavior.
33
33
  */
34
+ /**
35
+ * Declared symbol names changed in a unified diff — from added-line declarations
36
+ * AND from the hunk-header enclosing type (so a field-only edit to an existing class,
37
+ * e.g. `DeploymentCreate.work_pool_name` → required, still yields `DeploymentCreate`).
38
+ * Returns raw names, used both for resource-token derivation (via `tokensFromSymbol`)
39
+ * and for content-grep matching against test bodies (SKYR-3924).
40
+ */
41
+ export declare function extractChangedSymbols(diffHunk: string): string[];
34
42
  export declare function deriveResourceTokensForFile(file: string, diffHunk?: string): string[];
35
43
  /**
36
44
  * All singular resource tokens implied by an endpoint path, used for affinity
@@ -114,6 +122,16 @@ export declare function extractFieldNamesFromAddedLines(lines: string[]): string
114
122
  * without re-implementing the diff-header walk.
115
123
  */
116
124
  export declare function sliceAddedLinesByFile(diffContent: string): Map<string, string[]>;
125
+ /**
126
+ * Splits a unified diff into per-file raw sections — every line after a file's
127
+ * `diff --git` header up to the next one — keyed by the `b/` (post-change) path.
128
+ * Unlike `sliceAddedLinesByFile` (added lines only), this preserves the `@@`
129
+ * hunk headers, so `extractChangedSymbols`' enclosing-type extraction (which
130
+ * reads `@@ ... class Foo`) still works when a single file's section is passed
131
+ * to `deriveResourceTokensForFile`. Use this for per-file token derivation that
132
+ * must not mix symbols across files in a multi-file diff (SKYR-3924).
133
+ */
134
+ export declare function sliceDiffByFile(diffContent: string): Map<string, string>;
117
135
  /**
118
136
  * Computes added-field names per file for the given files, from a full
119
137
  * unified diff. Files with no added-line matches are omitted from the result