@skyramp/mcp 0.2.9 → 0.2.10-rc.1

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 (76) hide show
  1. package/build/index.js +1 -1
  2. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +28 -1
  3. package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +1 -0
  4. package/build/prompts/personas.d.ts +2 -2
  5. package/build/prompts/personas.js +2 -2
  6. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +3 -0
  7. package/build/prompts/sut-setup/shared.js +1 -3
  8. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +1 -1
  9. package/build/prompts/test-maintenance/drift-analysis-prompt.js +10 -5
  10. package/build/prompts/test-maintenance/driftAnalysisSections.js +1 -4
  11. package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +7 -1
  12. package/build/prompts/test-maintenance/driftAnalysisShared.js +19 -1
  13. package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +0 -6
  14. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +0 -16
  15. package/build/prompts/test-recommendation/recommendationShared.js +1 -1
  16. package/build/prompts/testbot/testbot-prompts.js +8 -8
  17. package/build/resources/testbotResource.js +1 -1
  18. package/build/services/TestDiscoveryService.d.ts +0 -1
  19. package/build/services/TestDiscoveryService.js +13 -33
  20. package/build/services/TestDiscoveryService.test.js +136 -1
  21. package/build/services/TestExecutionService.d.ts +1 -1
  22. package/build/tools/executeSkyrampTestTool.js +40 -71
  23. package/build/tools/submitReportTool.d.ts +20 -20
  24. package/build/tools/submitReportTool.js +113 -31
  25. package/build/tools/submitReportTool.test.js +411 -94
  26. package/build/tools/test-management/actionsTool.js +135 -53
  27. package/build/tools/test-management/actionsTool.test.d.ts +1 -0
  28. package/build/tools/test-management/actionsTool.test.js +297 -0
  29. package/build/tools/test-management/analyzeChangesTool.d.ts +9 -0
  30. package/build/tools/test-management/analyzeChangesTool.js +63 -152
  31. package/build/tools/test-management/analyzeChangesTool.test.js +34 -3
  32. package/build/tools/test-management/analyzeTestHealthTool.js +24 -157
  33. package/build/tools/test-management/analyzeTestHealthTool.test.js +15 -215
  34. package/build/tools/workspace/initializeWorkspaceTool.js +35 -15
  35. package/build/types/FrontendIntegration.d.ts +25 -0
  36. package/build/types/FrontendIntegration.js +19 -0
  37. package/build/types/RepositoryAnalysis.d.ts +8 -8
  38. package/build/types/TestAnalysis.d.ts +16 -50
  39. package/build/types/TestAnalysis.js +0 -28
  40. package/build/types/TestTypes.d.ts +3 -1
  41. package/build/types/TestTypes.js +3 -1
  42. package/build/utils/AnalysisStateManager.d.ts +13 -1
  43. package/build/utils/docker.test.js +1 -1
  44. package/build/utils/featureFlags.d.ts +1 -1
  45. package/build/utils/featureFlags.js +1 -1
  46. package/build/utils/frontendIntegration.d.ts +9 -0
  47. package/build/utils/frontendIntegration.js +237 -0
  48. package/build/utils/frontendIntegration.test.d.ts +1 -0
  49. package/build/utils/frontendIntegration.test.js +229 -0
  50. package/build/utils/pr-comment-parser.d.ts +3 -3
  51. package/build/utils/pr-comment-parser.js +6 -6
  52. package/build/utils/pr-comment-parser.test.js +3 -3
  53. package/build/utils/repoScanner.d.ts +7 -0
  54. package/build/utils/repoScanner.js +14 -6
  55. package/build/utils/repoScanner.test.js +9 -9
  56. package/build/utils/routeParsers.d.ts +32 -0
  57. package/build/utils/routeParsers.js +205 -2
  58. package/build/utils/routeParsers.test.js +279 -29
  59. package/build/utils/sourceRouteExtractor.js +174 -5
  60. package/build/utils/sourceRouteExtractor.test.js +173 -0
  61. package/build/utils/utils.d.ts +11 -0
  62. package/build/utils/utils.js +19 -0
  63. package/build/utils/utils.test.js +23 -1
  64. package/build/utils/versions.d.ts +3 -3
  65. package/build/utils/versions.js +1 -1
  66. package/node_modules/playwright/lib/mcp/browser/tools/files.js +8 -3
  67. package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
  68. package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -1
  69. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +33 -9
  70. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +7 -1
  71. package/node_modules/playwright/lib/mcp/test/skyRampExport.test.js +44 -0
  72. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.B_7ywgmr.js → index.d4gkVSou.js} +1 -1
  73. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  74. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  75. package/node_modules/playwright/package.json +1 -1
  76. package/package.json +3 -3
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Server-side "frontend component integration check" — SKYR-3855.
3
+ *
4
+ * Deterministically answers the question the testbot prompt previously asked
5
+ * the agent to answer via a mid-run grep: "is this changed frontend file
6
+ * actually rendered anywhere in the running app, or is it dead/orphaned
7
+ * code?" Computed here so the skip decision for UI test generation consumes
8
+ * a server fact instead of an LLM judgment call made under budget pressure.
9
+ *
10
+ * Detection greps for the module's PATH-REFERENCE SIGNATURE — the basename
11
+ * preceded by a slash and followed by a quote (`/Foo'`, `/Foo"`, `/Foo\``) —
12
+ * rather than the bare component name. Imports always reference the module
13
+ * path in quotes (`from './Foo'`, `require("components/Foo")`,
14
+ * `import('./Foo')`), so the signature carries import-context precision
15
+ * lexically: no per-line verification, no file reads in Node, and `git grep
16
+ * -l -F` returns only matching paths (bounded output regardless of file
17
+ * sizes). Named imports that go through a barrel resolve one hop: the
18
+ * barrel's `export { Foo } from './Foo'` contains the signature, and the
19
+ * barrel counts as the importer. Known miss: extensionless root-level
20
+ * imports with no slash (`from 'Foo'`) — root files are route/entrypoint
21
+ * short-circuited in practice.
22
+ *
23
+ * Fail-open by design: any scan failure reports `integrated: true` so a bug
24
+ * in this heuristic can never wrongly suppress UI test generation.
25
+ */
26
+ import { execFileSync } from "child_process";
27
+ import * as fs from "fs";
28
+ import * as path from "path";
29
+ import { isTestFile } from "../prompts/test-recommendation/scopeAssessment.js";
30
+ import { logger } from "./logger.js";
31
+ import { IntegrationReason } from "../types/FrontendIntegration.js";
32
+ const MAX_IMPORTERS = 10;
33
+ const MAX_FILES_WALKED = 3000;
34
+ const MAX_FILE_SIZE_BYTES = 512 * 1024;
35
+ // Framework route/page/entrypoint conventions — mirrors the testbot prompt's
36
+ // "Never apply the unintegrated heuristic to framework route/entrypoint files"
37
+ // rule. These are reachable by convention, so the grep is skipped entirely.
38
+ // `app` is deliberately NOT in the dir pattern: it is a common generic
39
+ // monorepo directory (e.g. appsmith's top-level app/), and matching it made
40
+ // the check inert for every file in such repos (eval run 28618761653).
41
+ // Next.js App Router route files are still short-circuited via their
42
+ // basenames (page.*, layout.*); non-route components under app/ resolve
43
+ // through the importer grep like any other file.
44
+ const ROUTE_DIR_PATTERN = /(^|\/)(pages|routes)(\/|$)/i;
45
+ // Generic entrypoint basenames (index/main/app/_app) are only reachable-by-
46
+ // convention at the repo root or directly under `src/` — e.g. Vite's
47
+ // `src/main.tsx`, CRA's `src/index.tsx`, Pages Router's root `_app.tsx`.
48
+ // Deeper in the tree these are ordinary component filenames (folder-per-
49
+ // component layouts commonly name the component file `index.tsx`), so
50
+ // matching them anywhere would short-circuit real, checkable components —
51
+ // the same class of bug as the `app/` dir match (see module doc).
52
+ const ROOT_ENTRYPOINT_BASENAME_PATTERN = /^(src\/)?(index|main|app|_app)\.[^/]+$/i;
53
+ // `page`/`layout` are App Router-specific and only meaningful under an
54
+ // `app/` segment; outside of one, a file named `page.tsx` or `layout.tsx`
55
+ // has no special framework meaning and should go through the normal check.
56
+ const APP_ROUTER_BASENAME_PATTERN = /(^|\/)app\/(.*\/)?(page|layout)\.[^/]+$/i;
57
+ // Frontend-importer file types only. Deliberately NOT shared with
58
+ // analyzeChangesTool's SOURCE_EXTS (backend-inclusive: py|java|go|…) or
59
+ // repoScanner's ROUTE_FILE_PATTERN — those detect backend route handlers;
60
+ // this detects files that can render a frontend component. The domains must
61
+ // stay independently tunable (see the `app/` route-dir lesson above).
62
+ const FRONTEND_SOURCE_EXT_PATTERN = /\.(ts|tsx|js|jsx|mjs|cjs|vue|svelte)$/i;
63
+ const STORY_FILE_PATTERN = /\.stories\.[^/]+$/i;
64
+ const EXCLUDED_DIR_PATTERN = /(^|\/)(node_modules|dist|build|\.git)(\/|$)/;
65
+ function isRouteOrEntrypoint(file) {
66
+ return (ROUTE_DIR_PATTERN.test(file) ||
67
+ ROOT_ENTRYPOINT_BASENAME_PATTERN.test(file) ||
68
+ APP_ROUTER_BASENAME_PATTERN.test(file));
69
+ }
70
+ /**
71
+ * Fixed-string signatures that identify an import of `file` by module path.
72
+ * Extensionless (`/Foo'`) covers TS/JS convention; extension-included
73
+ * (`/Foo.vue'`) covers Vue/Svelte, where imports keep the extension.
74
+ */
75
+ function buildPathSignatures(file) {
76
+ // path.extname/basename treat ".d.ts" as ext ".ts" + basename "Foo.d",
77
+ // which would build a signature ("/Foo.d'") that never matches a real
78
+ // import of "./Foo" — special-case the compound extension.
79
+ const isDts = /\.d\.ts$/i.test(file);
80
+ const ext = isDts ? ".d.ts" : path.extname(file);
81
+ const basename = isDts ? path.basename(file, ".d.ts") : path.basename(file, ext);
82
+ const quotes = [`'`, `"`, "`"];
83
+ const stems = [`/${basename}`, `/${basename}${ext}`];
84
+ return stems.flatMap((stem) => quotes.map((q) => `${stem}${q}`));
85
+ }
86
+ function isExcludedImporterPath(relPath) {
87
+ return (EXCLUDED_DIR_PATTERN.test(relPath) ||
88
+ isTestFile(relPath) ||
89
+ STORY_FILE_PATTERN.test(relPath) ||
90
+ !FRONTEND_SOURCE_EXT_PATTERN.test(relPath));
91
+ }
92
+ /**
93
+ * Runs `git grep -l -F` for the given fixed-string signatures (OR'd
94
+ * together) scoped to the repository's tracked files. Returns file paths
95
+ * relative to `repositoryPath` — only paths, never matched lines, so output
96
+ * stays bounded no matter how large the matching files are. An exit code of
97
+ * 1 means "no matches" — a normal outcome, not an error. Throws on real
98
+ * failures (not a git repo, git not installed) so the caller can fall back
99
+ * to a manual walk.
100
+ */
101
+ function gitGrepFiles(repositoryPath, signatures) {
102
+ const args = ["grep", "-l", "-F", "-I"];
103
+ for (const signature of signatures)
104
+ args.push("-e", signature);
105
+ try {
106
+ const stdout = execFileSync("git", args, {
107
+ cwd: repositoryPath,
108
+ encoding: "utf8",
109
+ maxBuffer: 20 * 1024 * 1024,
110
+ });
111
+ return stdout.split("\n").filter(Boolean);
112
+ }
113
+ catch (err) {
114
+ if (err && err.status === 1)
115
+ return []; // no matches — not an error
116
+ throw err;
117
+ }
118
+ }
119
+ /**
120
+ * Bounded manual directory walk used only when `git grep` itself fails
121
+ * (e.g. not a git repository). Caps the number and size of files scanned so
122
+ * a huge repo can't turn a fallback path into a multi-minute scan or a
123
+ * large allocation.
124
+ *
125
+ * If the walk is cut off by `MAX_FILES_WALKED` before finding any importer,
126
+ * the "no importer" result can't be trusted — an importer may exist in the
127
+ * unscanned remainder. Per the module's fail-open contract, that ambiguity
128
+ * throws so the caller reports `scan-error` (`integrated: true`) instead of
129
+ * a false "no-importers".
130
+ */
131
+ function manualWalkGrep(repositoryPath, signatures) {
132
+ const results = [];
133
+ let filesScanned = 0;
134
+ let truncated = false;
135
+ const stack = [repositoryPath];
136
+ walk: while (stack.length > 0) {
137
+ if (filesScanned >= MAX_FILES_WALKED) {
138
+ truncated = true;
139
+ break;
140
+ }
141
+ const dir = stack.pop();
142
+ let entries;
143
+ try {
144
+ entries = fs.readdirSync(dir, { withFileTypes: true });
145
+ }
146
+ catch {
147
+ continue;
148
+ }
149
+ for (const entry of entries) {
150
+ const abs = path.join(dir, entry.name);
151
+ // Normalize to forward slashes: on Windows, path.relative() returns
152
+ // "\\"-separated paths, but EXCLUDED_DIR_PATTERN/isTestFile assume "/".
153
+ const rel = path.relative(repositoryPath, abs).replace(/\\/g, "/");
154
+ if (entry.isDirectory()) {
155
+ if (EXCLUDED_DIR_PATTERN.test(rel + "/"))
156
+ continue;
157
+ stack.push(abs);
158
+ continue;
159
+ }
160
+ if (!FRONTEND_SOURCE_EXT_PATTERN.test(entry.name))
161
+ continue;
162
+ if (filesScanned >= MAX_FILES_WALKED) {
163
+ truncated = true;
164
+ break walk;
165
+ }
166
+ filesScanned++;
167
+ try {
168
+ if (fs.statSync(abs).size > MAX_FILE_SIZE_BYTES)
169
+ continue;
170
+ const content = fs.readFileSync(abs, "utf-8");
171
+ if (signatures.some((s) => content.includes(s)))
172
+ results.push(rel);
173
+ }
174
+ catch {
175
+ continue;
176
+ }
177
+ }
178
+ }
179
+ if (truncated && results.length === 0) {
180
+ throw new Error(`manual-walk fallback truncated at ${MAX_FILES_WALKED} files with no importer found`);
181
+ }
182
+ return results;
183
+ }
184
+ function searchProductionImporters(repositoryPath, signatures) {
185
+ try {
186
+ return gitGrepFiles(repositoryPath, signatures);
187
+ }
188
+ catch (err) {
189
+ logger.warning("git grep failed for integration check — falling back to bounded manual walk", {
190
+ repositoryPath,
191
+ error: err instanceof Error ? err.message : String(err),
192
+ });
193
+ return manualWalkGrep(repositoryPath, signatures);
194
+ }
195
+ }
196
+ function checkOneFile(repositoryPath, file) {
197
+ if (isRouteOrEntrypoint(file)) {
198
+ return { file, integrated: true, importers: [], reason: IntegrationReason.RouteOrEntrypoint };
199
+ }
200
+ // A missing changed file (e.g. deleted, or an unexpected diff shape) means
201
+ // the classification can't be trusted — fail open rather than risk wrongly
202
+ // suppressing UI test generation.
203
+ if (!fs.existsSync(path.join(repositoryPath, file))) {
204
+ logger.warning("Integration check: changed file not found on disk — reporting integrated (fail-open)", {
205
+ file,
206
+ });
207
+ return { file, integrated: true, importers: [], reason: IntegrationReason.ScanError };
208
+ }
209
+ try {
210
+ const signatures = buildPathSignatures(file);
211
+ const importers = searchProductionImporters(repositoryPath, signatures)
212
+ .filter((candidate) => candidate !== file && !isExcludedImporterPath(candidate))
213
+ .slice(0, MAX_IMPORTERS);
214
+ return {
215
+ file,
216
+ integrated: importers.length > 0,
217
+ importers,
218
+ reason: importers.length > 0 ? IntegrationReason.Imported : IntegrationReason.NoImporters,
219
+ };
220
+ }
221
+ catch (err) {
222
+ logger.warning("Integration check scan failed — reporting integrated (fail-open)", {
223
+ file,
224
+ error: err instanceof Error ? err.message : String(err),
225
+ });
226
+ return { file, integrated: true, importers: [], reason: IntegrationReason.ScanError };
227
+ }
228
+ }
229
+ /**
230
+ * Checks each changed frontend file for a production importer — the
231
+ * deterministic version of the grep the testbot prompt used to ask the
232
+ * agent to run mid-task. See module doc for the signature-based approach
233
+ * and the fail-open rationale.
234
+ */
235
+ export function checkFrontendFileIntegration(repositoryPath, changedFrontendFiles) {
236
+ return changedFrontendFiles.map((file) => checkOneFile(repositoryPath, file));
237
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,229 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { execFileSync } from "child_process";
5
+ import { checkFrontendFileIntegration } from "./frontendIntegration.js";
6
+ /** Creates a temp git repo, writes the given files, and stages them so
7
+ * `git grep` (which only searches tracked files by default) can find them. */
8
+ function makeFixtureRepo(files) {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frontend-integration-"));
10
+ execFileSync("git", ["init", "-q"], { cwd: dir });
11
+ for (const [relPath, content] of Object.entries(files)) {
12
+ const abs = path.join(dir, relPath);
13
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
14
+ fs.writeFileSync(abs, content);
15
+ }
16
+ execFileSync("git", ["add", "-A"], { cwd: dir });
17
+ return dir;
18
+ }
19
+ /** Same as makeFixtureRepo but NOT a git repo, to exercise the manual-walk
20
+ * fallback (`git grep` fails outright when the directory isn't a repo). */
21
+ function makeNonGitFixture(files) {
22
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frontend-integration-nogit-"));
23
+ for (const [relPath, content] of Object.entries(files)) {
24
+ const abs = path.join(dir, relPath);
25
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
26
+ fs.writeFileSync(abs, content);
27
+ }
28
+ return dir;
29
+ }
30
+ describe("checkFrontendFileIntegration", () => {
31
+ it("marks a component imported by a production file as integrated, listing the importer", () => {
32
+ const repo = makeFixtureRepo({
33
+ "src/components/Foo.tsx": `export function Foo() { return null; }\n`,
34
+ "src/components/Bar.tsx": `import { Foo } from './Foo';\nexport function Bar() { return Foo(); }\n`,
35
+ });
36
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.tsx"]);
37
+ expect(result.integrated).toBe(true);
38
+ expect(result.reason).toBe("imported");
39
+ expect(result.importers).toEqual(["src/components/Bar.tsx"]);
40
+ });
41
+ it("marks a component imported ONLY by test files as unintegrated", () => {
42
+ const repo = makeFixtureRepo({
43
+ "src/components/Foo.tsx": `export function Foo() { return null; }\n`,
44
+ "src/components/Foo.test.tsx": `import { Foo } from './Foo';\ntest('renders', () => Foo());\n`,
45
+ "src/components/__tests__/Foo.spec.tsx": `import { Foo } from './Foo';\n`,
46
+ });
47
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.tsx"]);
48
+ expect(result.integrated).toBe(false);
49
+ expect(result.reason).toBe("no-importers");
50
+ expect(result.importers).toEqual([]);
51
+ });
52
+ it("short-circuits route/entrypoint files as integrated without grepping", () => {
53
+ const repo = makeFixtureRepo({
54
+ "src/pages/Dashboard.tsx": `export default function Dashboard() { return null; }\n`,
55
+ });
56
+ const [result] = checkFrontendFileIntegration(repo, ["src/pages/Dashboard.tsx"]);
57
+ expect(result).toEqual({
58
+ file: "src/pages/Dashboard.tsx",
59
+ integrated: true,
60
+ importers: [],
61
+ reason: "route-or-entrypoint",
62
+ });
63
+ });
64
+ it("counts a re-export barrel as an importer", () => {
65
+ const repo = makeFixtureRepo({
66
+ "src/components/Foo.tsx": `export function Foo() { return null; }\n`,
67
+ "src/components/index.ts": `export { Foo } from './Foo';\n`,
68
+ });
69
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.tsx"]);
70
+ expect(result.integrated).toBe(true);
71
+ expect(result.importers).toEqual(["src/components/index.ts"]);
72
+ });
73
+ it("counts an oversized single-line bundle referencing the module path as an importer, unread", () => {
74
+ // A minified >512KB one-line file. Node never opens candidates — git
75
+ // grep -l only returns the path — so file size cannot cause a large
76
+ // allocation or blow the subprocess buffer.
77
+ const hugeBundle = `var m=require("./components/Foo");` + "a".repeat(600 * 1024);
78
+ const repo = makeFixtureRepo({
79
+ "src/components/Foo.tsx": `export function Foo() { return null; }\n`,
80
+ "src/vendor/bundle.js": hugeBundle,
81
+ });
82
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.tsx"]);
83
+ expect(result.integrated).toBe(true);
84
+ expect(result.importers).toEqual(["src/vendor/bundle.js"]);
85
+ });
86
+ it("resolves importers of an oversized changed file (the changed file is never read)", () => {
87
+ const hugeComponent = `export function Foo() { return null; }\n// ` + "b".repeat(600 * 1024) + `\n`;
88
+ const repo = makeFixtureRepo({
89
+ "src/components/Foo.tsx": hugeComponent,
90
+ "src/components/Bar.tsx": `import Foo from './Foo';\nexport function Bar() { return Foo(); }\n`,
91
+ });
92
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.tsx"]);
93
+ expect(result.integrated).toBe(true);
94
+ expect(result.reason).toBe("imported");
95
+ expect(result.importers).toEqual(["src/components/Bar.tsx"]);
96
+ });
97
+ it("catches multiline imports (the from-line carries the path signature)", () => {
98
+ const repo = makeFixtureRepo({
99
+ "src/components/Foo.tsx": `export function Foo() { return null; }\n`,
100
+ "src/components/Bar.tsx": `import {\n Foo,\n} from "./Foo";\nexport function Bar() { return Foo(); }\n`,
101
+ });
102
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.tsx"]);
103
+ expect(result.integrated).toBe(true);
104
+ expect(result.importers).toEqual(["src/components/Bar.tsx"]);
105
+ });
106
+ it("does not count bare-name mentions (comments, strings) as importers", () => {
107
+ // The precision property of the path-signature design: a dead component
108
+ // whose NAME appears in prose elsewhere must still read as unintegrated.
109
+ const repo = makeFixtureRepo({
110
+ "src/components/Foo.tsx": `export function Foo() { return null; }\n`,
111
+ "src/components/Other.tsx": `// TODO: consolidate with Foo someday\nconst label = "Foo";\nexport function Other() { return label; }\n`,
112
+ });
113
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.tsx"]);
114
+ expect(result.integrated).toBe(false);
115
+ expect(result.reason).toBe("no-importers");
116
+ expect(result.importers).toEqual([]);
117
+ });
118
+ it("does not short-circuit components under a generic top-level app/ monorepo dir", () => {
119
+ // Regression: appsmith's repo root is `app/` — matching it as a framework
120
+ // route dir made the check inert for the whole repo (eval 28618761653).
121
+ const repo = makeFixtureRepo({
122
+ "app/client/src/components/Orphan.tsx": `export function Orphan() { return null; }\n`,
123
+ });
124
+ const [result] = checkFrontendFileIntegration(repo, ["app/client/src/components/Orphan.tsx"]);
125
+ expect(result.integrated).toBe(false);
126
+ expect(result.reason).toBe("no-importers");
127
+ });
128
+ it("still short-circuits Next.js App Router route files via their basenames", () => {
129
+ const repo = makeFixtureRepo({
130
+ "src/app/dashboard/page.tsx": `export default function Dashboard() { return null; }\n`,
131
+ });
132
+ const [result] = checkFrontendFileIntegration(repo, ["src/app/dashboard/page.tsx"]);
133
+ expect(result.integrated).toBe(true);
134
+ expect(result.reason).toBe("route-or-entrypoint");
135
+ });
136
+ it("fails open (scan-error, integrated: true) when the changed file does not exist on disk", () => {
137
+ const repo = makeFixtureRepo({
138
+ "src/components/Bar.tsx": `export function Bar() { return null; }\n`,
139
+ });
140
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Deleted.tsx"]);
141
+ expect(result).toEqual({
142
+ file: "src/components/Deleted.tsx",
143
+ integrated: true,
144
+ importers: [],
145
+ reason: "scan-error",
146
+ });
147
+ });
148
+ it("finds an importer of a .d.ts declaration file", () => {
149
+ // Regression: path.extname("Foo.d.ts") is ".ts", so a naive basename/ext
150
+ // split would build a signature for "Foo.d" instead of "Foo" and never
151
+ // match a real `from "./Foo"` import.
152
+ const repo = makeFixtureRepo({
153
+ "src/components/Foo.d.ts": `export declare function Foo(): null;\n`,
154
+ "src/components/Bar.tsx": `import { Foo } from './Foo';\nexport function Bar() { return Foo(); }\n`,
155
+ });
156
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.d.ts"]);
157
+ expect(result.integrated).toBe(true);
158
+ expect(result.reason).toBe("imported");
159
+ expect(result.importers).toEqual(["src/components/Bar.tsx"]);
160
+ });
161
+ describe("basename short-circuit precision", () => {
162
+ it("does NOT short-circuit a folder-per-component index.tsx as a route/entrypoint", () => {
163
+ // Regression: matching "index"/"main"/"app" basenames anywhere in the
164
+ // tree (not just at the root or src/) would short-circuit the common
165
+ // React folder-per-component convention (Button/index.tsx) as always
166
+ // integrated, defeating the check for exactly the files it targets.
167
+ const repo = makeFixtureRepo({
168
+ "src/components/Button/index.tsx": `export function Button() { return null; }\n`,
169
+ });
170
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Button/index.tsx"]);
171
+ expect(result.integrated).toBe(false);
172
+ expect(result.reason).toBe("no-importers");
173
+ });
174
+ it("still short-circuits a root-level src/index.tsx entrypoint", () => {
175
+ const repo = makeFixtureRepo({
176
+ "src/index.tsx": `export default function App() { return null; }\n`,
177
+ });
178
+ const [result] = checkFrontendFileIntegration(repo, ["src/index.tsx"]);
179
+ expect(result).toEqual({
180
+ file: "src/index.tsx",
181
+ integrated: true,
182
+ importers: [],
183
+ reason: "route-or-entrypoint",
184
+ });
185
+ });
186
+ it("does NOT short-circuit a page.tsx outside an app/ segment", () => {
187
+ // "page"/"layout" are App Router-specific; outside an app/ segment they
188
+ // are ordinary component filenames with no framework meaning.
189
+ const repo = makeFixtureRepo({
190
+ "src/components/page.tsx": `export function Page() { return null; }\n`,
191
+ });
192
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/page.tsx"]);
193
+ expect(result.integrated).toBe(false);
194
+ expect(result.reason).toBe("no-importers");
195
+ });
196
+ });
197
+ describe("manual-walk fallback (non-git repos)", () => {
198
+ it("falls back to a manual walk and finds an importer when the directory isn't a git repo", () => {
199
+ const repo = makeNonGitFixture({
200
+ "src/components/Foo.tsx": `export function Foo() { return null; }\n`,
201
+ "src/components/Bar.tsx": `import { Foo } from './Foo';\nexport function Bar() { return Foo(); }\n`,
202
+ });
203
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.tsx"]);
204
+ expect(result.integrated).toBe(true);
205
+ expect(result.reason).toBe("imported");
206
+ expect(result.importers).toEqual(["src/components/Bar.tsx"]);
207
+ });
208
+ it("fails open (scan-error) when the fallback walk is truncated before finding an importer", () => {
209
+ // Regression: the module doc promises fail-open on any scan error/
210
+ // uncertainty, but a truncated manual walk used to just return its
211
+ // partial (empty) result set, reporting a confident "no-importers"
212
+ // even though an importer could exist past the file cap.
213
+ const files = {
214
+ "src/components/Foo.tsx": `export function Foo() { return null; }\n`,
215
+ };
216
+ for (let i = 0; i < 3005; i++) {
217
+ files[`src/generated/File${i}.tsx`] = `export const noop${i} = 0;\n`;
218
+ }
219
+ const repo = makeNonGitFixture(files);
220
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.tsx"]);
221
+ expect(result).toEqual({
222
+ file: "src/components/Foo.tsx",
223
+ integrated: true,
224
+ importers: [],
225
+ reason: "scan-error",
226
+ });
227
+ });
228
+ });
229
+ });
@@ -1,7 +1,7 @@
1
1
  /**
2
- * PR Comment Parser — extracts TestBot history from GitHub PR comments.
2
+ * PR Comment Parser — extracts Testbot history from GitHub PR comments.
3
3
  *
4
- * Parses previous Skyramp TestBot comments on a PR to build context
4
+ * Parses previous Skyramp Testbot comments on a PR to build context
5
5
  * about what has already been recommended, implemented, and tested.
6
6
  * This prevents re-recommendations and enables contextual awareness
7
7
  * across the PR lifecycle.
@@ -23,7 +23,7 @@ export interface PRTestContext {
23
23
  }>;
24
24
  }
25
25
  /**
26
- * Fetch and parse PR comments to build TestBot history context.
26
+ * Fetch and parse PR comments to build Testbot history context.
27
27
  *
28
28
  * Uses the `gh` CLI (available in CI and local dev environments with
29
29
  * GitHub CLI installed). Falls back gracefully if `gh` is unavailable.
@@ -1,7 +1,7 @@
1
1
  /**
2
- * PR Comment Parser — extracts TestBot history from GitHub PR comments.
2
+ * PR Comment Parser — extracts Testbot history from GitHub PR comments.
3
3
  *
4
- * Parses previous Skyramp TestBot comments on a PR to build context
4
+ * Parses previous Skyramp Testbot comments on a PR to build context
5
5
  * about what has already been recommended, implemented, and tested.
6
6
  * This prevents re-recommendations and enables contextual awareness
7
7
  * across the PR lifecycle.
@@ -16,7 +16,7 @@ const TESTBOT_MARKERS = [
16
16
  const TEST_TYPE_PATTERN = /\b(Smoke|Contract|Integration|Fuzz|Load|E2E|UI)\b/gi;
17
17
  const TEST_FILE_PATTERN = /[\w/.-]+(?:_test|_smoke|_contract|_fuzz|_integration|_load|_e2e|_ui)\.\w+/gi;
18
18
  const STATUS_PATTERN = /\b(Pass|Fail|Skipped)\b/gi;
19
- function isTestBotComment(comment) {
19
+ function isTestbotComment(comment) {
20
20
  const hasMarker = TESTBOT_MARKERS.some((marker) => comment.body.includes(marker));
21
21
  if (!hasMarker)
22
22
  return false;
@@ -191,7 +191,7 @@ function extractImplementedFiles(body) {
191
191
  return [...new Set(matches.map((m) => m[0]))];
192
192
  }
193
193
  /**
194
- * Fetch and parse PR comments to build TestBot history context.
194
+ * Fetch and parse PR comments to build Testbot history context.
195
195
  *
196
196
  * Uses the `gh` CLI (available in CI and local dev environments with
197
197
  * GitHub CLI installed). Falls back gracefully if `gh` is unavailable.
@@ -228,11 +228,11 @@ export async function parsePRComments(repoOwner, repoName, prNumber, _token) {
228
228
  logger.warning("Failed to parse PR comments JSON");
229
229
  return empty;
230
230
  }
231
- const testBotComments = comments.filter((c) => isTestBotComment(c));
231
+ const testBotComments = comments.filter((c) => isTestbotComment(c));
232
232
  if (testBotComments.length === 0) {
233
233
  return empty;
234
234
  }
235
- // Use only the latest TestBot comment — older comments represent stale
235
+ // Use only the latest Testbot comment — older comments represent stale
236
236
  // state from previous bot runs. The latest comment has the most accurate
237
237
  // picture of what was recommended, generated, and executed.
238
238
  const latestComment = testBotComments[testBotComments.length - 1];
@@ -163,7 +163,7 @@ describe("parsePRComments — error handling", () => {
163
163
  expect(ctx.implementedTestFiles).toEqual([]);
164
164
  expect(ctx.executionResults).toEqual([]);
165
165
  });
166
- it("returns empty context when no TestBot comments exist", async () => {
166
+ it("returns empty context when no Testbot comments exist", async () => {
167
167
  mockedExecFileSync.mockReturnValue(ghResponse([
168
168
  { id: 1, body: "LGTM!", login: "reviewer" },
169
169
  { id: 2, body: "Please fix the typo", login: "reviewer" },
@@ -173,7 +173,7 @@ describe("parsePRComments — error handling", () => {
173
173
  expect(ctx.implementedTestFiles).toEqual([]);
174
174
  expect(ctx.executionResults).toEqual([]);
175
175
  });
176
- it("uses only the latest TestBot comment when multiple exist", async () => {
176
+ it("uses only the latest Testbot comment when multiple exist", async () => {
177
177
  const olderReport = progressWrapper(`### 📋 Business Case Analysis
178
178
  Old report.
179
179
 
@@ -347,7 +347,7 @@ describe("edge cases", () => {
347
347
  const count = ctx.implementedTestFiles.filter((f) => f === "test_items_integration.py").length;
348
348
  expect(count).toBe(1);
349
349
  });
350
- it("handles empty TestBot comment body", async () => {
350
+ it("handles empty Testbot comment body", async () => {
351
351
  mockedExecFileSync.mockReturnValue(ghResponse([{ id: 1, body: "Skyramp Testbot — empty run" }]));
352
352
  const ctx = await parsePRComments("owner", "repo", 1);
353
353
  expect(ctx.previousRecommendations).toEqual([]);
@@ -2,6 +2,13 @@ export interface ScannedEndpoint {
2
2
  path: string;
3
3
  methods: string[];
4
4
  sourceFile: string;
5
+ /**
6
+ * 1-based line number of the route's declaration in `sourceFile`, when known.
7
+ * Used by hunk-aware diff scoping to attribute a changed line to the single
8
+ * route that owns it (rather than flagging every route in a touched file).
9
+ * Optional — absence disables scoping and falls back to file-level matching.
10
+ */
11
+ line?: number;
5
12
  }
6
13
  /**
7
14
  * Returns the relative paths of entry-point files that define the routing
@@ -101,16 +101,22 @@ export function grepRouterMountingContext(repositoryPath) {
101
101
  }
102
102
  return found;
103
103
  }
104
- function addEndpointToMap(endpointMap, apiPath, method, sourceFile, repositoryPath) {
104
+ function addEndpointToMap(endpointMap, apiPath, method, sourceFile, repositoryPath, line) {
105
105
  const relative = sourceFile.startsWith(repositoryPath)
106
106
  ? sourceFile.slice(repositoryPath.length + 1) : sourceFile;
107
107
  const normalizedPath = apiPath.startsWith("/") ? apiPath : `/${apiPath}`;
108
108
  const key = `${relative}::${normalizedPath}`;
109
109
  const existing = endpointMap.get(key);
110
- if (existing)
110
+ if (existing) {
111
111
  existing.methods.add(method);
112
- else
113
- endpointMap.set(key, { path: normalizedPath, methods: new Set([method]), sourceFile: relative });
112
+ // Keep the earliest declaration line so ownership-range scoping starts there.
113
+ if (typeof line === "number" && (existing.line === undefined || line < existing.line)) {
114
+ existing.line = line;
115
+ }
116
+ }
117
+ else {
118
+ endpointMap.set(key, { path: normalizedPath, methods: new Set([method]), sourceFile: relative, line });
119
+ }
114
120
  }
115
121
  function scanNextjsFile(file, repositoryPath, endpointMap) {
116
122
  const relative = file.startsWith(repositoryPath)
@@ -239,13 +245,14 @@ export function scanAllRepoEndpoints(repositoryPath) {
239
245
  if (content === null)
240
246
  continue;
241
247
  for (const ep of parseFileEndpoints(content, relative)) {
242
- addEndpointToMap(endpointMap, ep.path, ep.method, file, repositoryPath);
248
+ addEndpointToMap(endpointMap, ep.path, ep.method, file, repositoryPath, ep.line);
243
249
  }
244
250
  }
245
251
  return Array.from(endpointMap.values()).map((data) => ({
246
252
  path: data.path,
247
253
  methods: Array.from(data.methods),
248
254
  sourceFile: data.sourceFile,
255
+ ...(data.line !== undefined ? { line: data.line } : {}),
249
256
  }));
250
257
  }
251
258
  export function scanRelatedEndpoints(repositoryPath, changedFiles) {
@@ -273,7 +280,7 @@ export function scanRelatedEndpoints(repositoryPath, changedFiles) {
273
280
  if (fileContent === null)
274
281
  continue;
275
282
  for (const ep of parseFileEndpoints(fileContent, relative)) {
276
- addEndpointToMap(endpointMap, ep.path, ep.method, file, repositoryPath);
283
+ addEndpointToMap(endpointMap, ep.path, ep.method, file, repositoryPath, ep.line);
277
284
  }
278
285
  }
279
286
  }
@@ -281,5 +288,6 @@ export function scanRelatedEndpoints(repositoryPath, changedFiles) {
281
288
  path: data.path,
282
289
  methods: Array.from(data.methods),
283
290
  sourceFile: data.sourceFile,
291
+ ...(data.line !== undefined ? { line: data.line } : {}),
284
292
  }));
285
293
  }