@skyramp/mcp 0.3.8 → 0.4.0-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 (229) hide show
  1. package/build/commands/commandLibrary.d.ts +1 -1
  2. package/build/commands/commandLibrary.js +3 -3
  3. package/build/commands/recommendTestsAndExecuteCommand.d.ts +1 -1
  4. package/build/commands/recommendTestsAndExecuteCommand.js +35 -20
  5. package/build/commands/testThisEndpointCommand.js +35 -19
  6. package/build/index.js +9 -3
  7. package/build/playwright/blueprintDigest.d.ts +15 -0
  8. package/build/playwright/blueprintDigest.js +152 -0
  9. package/build/playwright/blueprintDigestStore.d.ts +31 -0
  10. package/build/playwright/blueprintDigestStore.js +117 -0
  11. package/build/playwright/registerPlaywrightTools.js +60 -12
  12. package/build/playwright/traceRecordingPrompt.js +8 -7
  13. package/build/prompts/enhance-assertions/sharedAssertionRules.js +9 -8
  14. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +24 -2
  15. package/build/prompts/promptAssets.d.ts +20 -0
  16. package/build/prompts/promptAssets.js +55 -0
  17. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +19 -5
  18. package/build/prompts/test-maintenance/actionsInstructions.d.ts +4 -0
  19. package/build/prompts/test-maintenance/actionsInstructions.js +14 -2
  20. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +0 -10
  21. package/build/prompts/test-maintenance/drift-analysis-prompt.js +2 -11
  22. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +8 -4
  23. package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +5 -22
  24. package/build/prompts/test-recommendation/diffExecutionPlan.js +37 -465
  25. package/build/prompts/test-recommendation/recommendationSections.d.ts +7 -17
  26. package/build/prompts/test-recommendation/recommendationSections.js +67 -309
  27. package/build/prompts/test-recommendation/recommendationShared.d.ts +19 -47
  28. package/build/prompts/test-recommendation/recommendationShared.js +49 -155
  29. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.d.ts +0 -5
  30. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +10 -153
  31. package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +2 -29
  32. package/build/prompts/test-recommendation/test-recommendation-prompt.js +32 -457
  33. package/build/prompts/testbot/planDeclarations.d.ts +6 -0
  34. package/build/prompts/testbot/planDeclarations.js +9 -0
  35. package/build/prompts/testbot/testbot-prompts.d.ts +8 -0
  36. package/build/prompts/testbot/testbot-prompts.js +256 -381
  37. package/build/recommendation/answers.d.ts +35 -0
  38. package/build/recommendation/answers.js +96 -0
  39. package/build/recommendation/registerPlan.d.ts +49 -0
  40. package/build/recommendation/registerPlan.js +117 -0
  41. package/build/recommendation/runVerifiers.d.ts +10 -0
  42. package/build/recommendation/runVerifiers.js +49 -0
  43. package/build/recommendation/subjectStep.d.ts +42 -0
  44. package/build/recommendation/subjectStep.js +86 -0
  45. package/build/recommendation/types.d.ts +163 -0
  46. package/build/recommendation/types.js +20 -0
  47. package/build/recommendation/verifierContracts.d.ts +382 -0
  48. package/build/recommendation/verifierContracts.js +263 -0
  49. package/build/recommendation/verifiers/changedFile.d.ts +2 -0
  50. package/build/recommendation/verifiers/changedFile.js +82 -0
  51. package/build/recommendation/verifiers/citedPath.d.ts +12 -0
  52. package/build/recommendation/verifiers/citedPath.js +35 -0
  53. package/build/recommendation/verifiers/coverage.d.ts +7 -0
  54. package/build/recommendation/verifiers/coverage.js +617 -0
  55. package/build/recommendation/verifiers/deliveredMatchesPlan.d.ts +11 -0
  56. package/build/recommendation/verifiers/deliveredMatchesPlan.js +33 -0
  57. package/build/recommendation/verifiers/endpointGrounded.d.ts +17 -0
  58. package/build/recommendation/verifiers/endpointGrounded.js +128 -0
  59. package/build/recommendation/verifiers/existingCoverage.d.ts +6 -0
  60. package/build/recommendation/verifiers/existingCoverage.js +51 -0
  61. package/build/recommendation/verifiers/expectedOutcome.d.ts +31 -0
  62. package/build/recommendation/verifiers/expectedOutcome.js +105 -0
  63. package/build/recommendation/verifiers/removedElementGuarded.d.ts +2 -0
  64. package/build/recommendation/verifiers/removedElementGuarded.js +57 -0
  65. package/build/recommendation/verifiers/reportedCategory.d.ts +26 -0
  66. package/build/recommendation/verifiers/reportedCategory.js +84 -0
  67. package/build/recommendation/verifiers/screenRoute.d.ts +10 -0
  68. package/build/recommendation/verifiers/screenRoute.js +118 -0
  69. package/build/recommendation/verifiers/statedDifference.d.ts +6 -0
  70. package/build/recommendation/verifiers/statedDifference.js +140 -0
  71. package/build/recommendation/verifiers/uiElementGrounded.d.ts +7 -0
  72. package/build/recommendation/verifiers/uiElementGrounded.js +318 -0
  73. package/build/resources/analysisResources.js +1 -114
  74. package/build/resources/testbotResource.js +23 -13
  75. package/build/services/ModularizationService.js +2 -1
  76. package/build/services/TestDiscoveryService.d.ts +3 -72
  77. package/build/services/TestDiscoveryService.js +10 -303
  78. package/build/services/containerEnv.d.ts +1 -1
  79. package/build/services/containerEnv.js +12 -0
  80. package/build/skills/fixTestImportErrorsSkill.d.ts +13 -0
  81. package/build/skills/fixTestImportErrorsSkill.js +20 -0
  82. package/build/toolNames.d.ts +1 -0
  83. package/build/toolNames.js +1 -0
  84. package/build/tools/code-refactor/enhanceAssertionsTool.js +3 -3
  85. package/build/tools/code-refactor/modularizationTool.js +2 -1
  86. package/build/tools/executeSkyrampTestTool.d.ts +80 -0
  87. package/build/tools/executeSkyrampTestTool.js +246 -19
  88. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +6 -0
  89. package/build/tools/generate-tests/generateContractRestTool.js +3 -3
  90. package/build/tools/generate-tests/planGuard.d.ts +2 -2
  91. package/build/tools/generate-tests/planGuard.js +78 -18
  92. package/build/tools/one-click/oneClickTool.d.ts +0 -1
  93. package/build/tools/one-click/oneClickTool.js +0 -5
  94. package/build/tools/submitReportTool.d.ts +48 -42
  95. package/build/tools/submitReportTool.js +576 -193
  96. package/build/tools/test-management/actionsTool.js +72 -4
  97. package/build/tools/test-management/analyzeChangesTool.d.ts +144 -48
  98. package/build/tools/test-management/analyzeChangesTool.js +212 -1219
  99. package/build/tools/test-management/analyzeTestHealthTool.js +13 -24
  100. package/build/tools/test-management/index.d.ts +1 -0
  101. package/build/tools/test-management/index.js +1 -0
  102. package/build/tools/test-management/registerTestPlanTool.d.ts +795 -172
  103. package/build/tools/test-management/registerTestPlanTool.js +609 -542
  104. package/build/tools/test-management/resolveScreenTool.d.ts +75 -0
  105. package/build/tools/test-management/resolveScreenTool.js +289 -0
  106. package/build/types/BlueprintDigest.d.ts +34 -0
  107. package/build/types/BlueprintDigest.js +1 -0
  108. package/build/types/RepositoryAnalysis.d.ts +20 -1559
  109. package/build/types/RepositoryAnalysis.js +2 -58
  110. package/build/types/StepMethod.d.ts +40 -0
  111. package/build/types/StepMethod.js +77 -0
  112. package/build/types/TestAnalysis.d.ts +12 -0
  113. package/build/types/TestExecution.d.ts +4 -0
  114. package/build/types/TestRecommendation.d.ts +24 -24
  115. package/build/types/TestRecommendation.js +91 -89
  116. package/build/types/TestbotPromptOptions.d.ts +0 -4
  117. package/build/types/TestbotReport.d.ts +64 -2
  118. package/build/utils/AnalysisStateManager.d.ts +79 -113
  119. package/build/utils/AnalysisStateManager.js +147 -57
  120. package/build/utils/assertion-verify/api-shared-lints.js +1 -1
  121. package/build/utils/assertion-verify/metrics.js +85 -36
  122. package/build/utils/assertion-verify/ui-lints.d.ts +0 -5
  123. package/build/utils/assertion-verify/ui-lints.js +32 -0
  124. package/build/utils/branchDiff.d.ts +63 -31
  125. package/build/utils/branchDiff.js +242 -94
  126. package/build/utils/containedPath.d.ts +18 -0
  127. package/build/utils/containedPath.js +73 -0
  128. package/build/utils/dartRouteExtractor.d.ts +18 -34
  129. package/build/utils/dartRouteExtractor.js +101 -173
  130. package/build/utils/featureFlags.d.ts +12 -0
  131. package/build/utils/featureFlags.js +14 -0
  132. package/build/utils/frontendSelectors.d.ts +48 -27
  133. package/build/utils/frontendSelectors.js +241 -80
  134. package/build/utils/pathMatching.d.ts +2 -4
  135. package/build/utils/pathMatching.js +2 -4
  136. package/build/utils/planMatchKeys.d.ts +38 -47
  137. package/build/utils/planMatchKeys.js +143 -81
  138. package/build/utils/rebaselineSnapshots.d.ts +24 -0
  139. package/build/utils/rebaselineSnapshots.js +65 -0
  140. package/build/utils/removedUiElements.d.ts +22 -0
  141. package/build/utils/removedUiElements.js +106 -0
  142. package/build/utils/reportVerification.d.ts +2 -6
  143. package/build/utils/reportVerification.js +61 -2
  144. package/build/utils/screenRoutes.d.ts +66 -0
  145. package/build/utils/screenRoutes.js +727 -0
  146. package/build/utils/sourceRouteExtractor.js +320 -112
  147. package/build/utils/testFileClassification.d.ts +11 -2
  148. package/build/utils/testFileClassification.js +44 -2
  149. package/build/utils/testFixtures.d.ts +5 -0
  150. package/build/utils/testFixtures.js +13 -0
  151. package/build/utils/utils.d.ts +0 -1
  152. package/build/utils/utils.js +0 -11
  153. package/build/utils/versions.d.ts +3 -3
  154. package/build/utils/versions.js +1 -1
  155. package/build/workspace/workspace.d.ts +12 -12
  156. package/node_modules/playwright/lib/mcp/skyramp/assertHiddenTool.js +56 -0
  157. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +2 -1
  158. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +10 -0
  159. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +4 -1
  160. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +160 -1
  161. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -2
  162. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +1 -0
  163. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderSignalProcessor.js +2 -0
  164. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder.js +5 -1
  165. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.-Id052Lr.js → index.B7KbSQcC.js} +1 -1
  166. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  167. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  168. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  169. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderSignalProcessor.ts +7 -0
  170. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder.ts +6 -1
  171. package/node_modules/playwright/package.json +1 -1
  172. package/package.json +4 -3
  173. package/plugin/.claude-plugin/plugin.json +8 -0
  174. package/plugin/plugin.json +6 -0
  175. package/plugin/prompts/declaring-a-plan.md +20 -0
  176. package/plugin/prompts/generate-tests/context-fetching.md +4 -0
  177. package/plugin/prompts/generate-tests/execution-plan.md +63 -0
  178. package/plugin/prompts/generate-tests/generation.md +108 -0
  179. package/plugin/prompts/generate-tests/path-parameters.md +1 -0
  180. package/plugin/prompts/generate-tests/reasoning-protocol.md +17 -0
  181. package/plugin/prompts/generate-tests/tool-workflow-variants.md +61 -0
  182. package/plugin/prompts/generate-tests/tool-workflows.md +65 -0
  183. package/plugin/prompts/plan-tests.md +42 -0
  184. package/plugin/prompts/testbot-task1.md +82 -0
  185. package/plugin/skills/fix-test-import-errors/SKILL.md +98 -0
  186. package/build/prompts/test-recommendation/analysisOutputPrompt.d.ts +0 -84
  187. package/build/prompts/test-recommendation/analysisOutputPrompt.js +0 -369
  188. package/build/prompts/test-recommendation/fullRepoCatalog.d.ts +0 -7
  189. package/build/prompts/test-recommendation/fullRepoCatalog.js +0 -283
  190. package/build/prompts/test-recommendation/scopeAssessment.d.ts +0 -81
  191. package/build/prompts/test-recommendation/scopeAssessment.js +0 -359
  192. package/build/recommendation/budgeters/diversityBalancedBudgeter.d.ts +0 -7
  193. package/build/recommendation/budgeters/diversityBalancedBudgeter.js +0 -105
  194. package/build/recommendation/budgeters/fixedNBudgeter.d.ts +0 -7
  195. package/build/recommendation/budgeters/fixedNBudgeter.js +0 -11
  196. package/build/recommendation/budgeters/shared.d.ts +0 -32
  197. package/build/recommendation/budgeters/shared.js +0 -246
  198. package/build/recommendation/discriminators.d.ts +0 -37
  199. package/build/recommendation/discriminators.js +0 -379
  200. package/build/recommendation/diversity.d.ts +0 -47
  201. package/build/recommendation/diversity.js +0 -101
  202. package/build/recommendation/planRanker.d.ts +0 -65
  203. package/build/recommendation/planRanker.js +0 -83
  204. package/build/recommendation/testFixtures.d.ts +0 -25
  205. package/build/recommendation/testFixtures.js +0 -45
  206. package/build/types/FrontendIntegration.d.ts +0 -28
  207. package/build/types/FrontendIntegration.js +0 -22
  208. package/build/types/Recommendation.d.ts +0 -146
  209. package/build/types/Recommendation.js +0 -74
  210. package/build/utils/changedRoutes.d.ts +0 -29
  211. package/build/utils/changedRoutes.js +0 -87
  212. package/build/utils/frontendIntegration.d.ts +0 -9
  213. package/build/utils/frontendIntegration.js +0 -243
  214. package/build/utils/importerHop.d.ts +0 -135
  215. package/build/utils/importerHop.js +0 -489
  216. package/build/utils/pathAffinityClassification.d.ts +0 -49
  217. package/build/utils/pathAffinityClassification.js +0 -180
  218. package/build/utils/pythonMountPrefixes.d.ts +0 -25
  219. package/build/utils/pythonMountPrefixes.js +0 -347
  220. package/build/utils/repoScanner.d.ts +0 -34
  221. package/build/utils/repoScanner.js +0 -300
  222. package/build/utils/routeParsers.d.ts +0 -95
  223. package/build/utils/routeParsers.js +0 -951
  224. package/build/utils/scenarioDrafting.d.ts +0 -92
  225. package/build/utils/scenarioDrafting.js +0 -951
  226. package/build/utils/subjectEndpoints.d.ts +0 -19
  227. package/build/utils/subjectEndpoints.js +0 -98
  228. package/build/utils/uiPageEnumerator.d.ts +0 -172
  229. package/build/utils/uiPageEnumerator.js +0 -474
@@ -10,35 +10,67 @@ export interface BranchDiffData {
10
10
  /** Files deleted in this branch (+++ /dev/null in diff header). Relative paths. */
11
11
  deletedFiles: string[];
12
12
  }
13
- /**
14
- * Extract every file path mentioned in a unified-diff `diff --git` header.
15
- * Always uses the `b/` form so renames return the new path.
16
- */
17
- export declare function parseChangedFilesFromDiff(rawDiff: string): string[];
18
- /**
19
- * Splits a unified diff into per-file added-line arrays, keyed by the `b/`
20
- * (post-change) path. Exported so other file->resource-affinity callers
21
- * (e.g. `pathAffinityClassification.ts`'s fallback, SKYR-3857) can derive the
22
- * same per-file diff hunks this module uses, without re-implementing the
23
- * diff-header walk. Lives here rather than in `importerHop.ts`, its original
24
- * home, so callers that `importerHop.ts` itself imports can reach it without
25
- * an import cycle.
26
- */
27
- export declare function sliceAddedLinesByFile(diffContent: string): Map<string, string[]>;
28
- /**
29
- * Normalize and validate a caller-provided base branch/ref before passing it
30
- * to git. Reject option-like or ambiguous values so the ref is always treated
31
- * as a revision name, not as a command-line flag.
32
- */
13
+ /** Every path in a `diff --git` header, always the `b/` form so a rename
14
+ * returns the new name. */
15
+ /** One `diff --git` block, read once. */
16
+ export interface ChangedFileEntry {
17
+ /** The `b/` path, so a rename reports the new name. */
18
+ path: string;
19
+ /** `--- /dev/null`. A newly added BINARY file has no such line: git renders
20
+ * `Binary files /dev/null and b/… differ` instead. */
21
+ isNew: boolean;
22
+ /** `+++ /dev/null`, or a deleted binary's `Binary files a/… and /dev/null`. */
23
+ isDeleted: boolean;
24
+ /** A pure rename and a mode-only change carry no hunk. */
25
+ hasHunks: boolean;
26
+ /** A rename's `a/` path. The block is keyed by the NEW path, so without this
27
+ * "was this path removed" answers a false no. */
28
+ renamedFrom?: string;
29
+ }
30
+ /** Every `diff --git` block, in order. Per BLOCK, never per path: the text
31
+ * concatenates every repository, so one repository's deletion of a path would
32
+ * hide another's real change to it. */
33
+ export declare function parseDiffEntries(rawDiff: string): ChangedFileEntry[];
34
+ /** Each changed path's own slice, so a check can ask whether a line is in THIS
35
+ * file. Two repositories changing one path share a key, which errs toward
36
+ * saying a file holds a line it does not. */
37
+ export declare function diffBlocksByPath(rawDiff: string): Map<string, string>;
38
+ /** Git quotes a path only when it has to. An unquoted path is returned as it
39
+ * stands, because decoding escapes in one would corrupt a legal name that
40
+ * really contains a backslash. */
41
+ export declare function unquoteIfQuoted(value: string): string;
42
+ /** Undo git's C-style quoting. A non-ASCII character arrives as one `\NNN` escape
43
+ * per UTF-8 BYTE, so the bytes are decoded once at the end. */
44
+ export declare function unquoteGitPath(value: string): string;
45
+ /** Exported so `frontendSurface` drops a deleted file without a second walk. */
46
+ export declare function parseNewAndDeletedFiles(rawDiff: string): {
47
+ newFiles: string[];
48
+ deletedFiles: string[];
49
+ };
50
+ /** Reject an option-like or ambiguous ref, so git reads it as a revision name
51
+ * and never as a command-line flag. */
33
52
  export declare function normalizeProvidedBaseRef(providedBaseBranch?: string): string | undefined;
34
- /**
35
- * Compute the diff between a base reference and the current state.
36
- *
37
- * @param repositoryPath Absolute path to the git repository.
38
- * @param providedBaseBranch A branch name, tag, or commit SHA to diff against.
39
- * When omitted, the remote default branch is auto-detected.
40
- * @param includeUncommitted When true, diffs against the working tree so
41
- * uncommitted/unstaged changes are included. Defaults to false (CI mode
42
- * that only considers committed changes via the three-dot form).
43
- */
44
- export declare function computeBranchDiff(repositoryPath: string, providedBaseBranch?: string, includeUncommitted?: boolean): Promise<BranchDiffData>;
53
+ /** One changed path, read from `--name-status` rather than from diff text. */
54
+ export interface ChangedFileName {
55
+ /** Repository-relative path in git's own spelling, unquoted. A rename reports
56
+ * the new path; the old one arrives as a separate deleted entry. */
57
+ path: string;
58
+ /** Removed, but still a changed surface: a test can assert its route is gone. */
59
+ deleted: boolean;
60
+ }
61
+ /** Parse `git diff --name-status`. Exported for its own tests.
62
+ * A rename and a copy carry TWO paths: the new one is the changed surface, and
63
+ * a rename also removes the old, while a copy leaves the original. Git quotes a
64
+ * non-ASCII path here as it does in a diff header. */
65
+ export declare function parseNameStatus(raw: string): ChangedFileName[];
66
+ /** The ONE derivation of `{path, deleted}` from a names-only diff, so no caller
67
+ * re-derives the join. */
68
+ export declare function changedFileNames(diff: BranchDiffData): ChangedFileName[];
69
+ /** Compute the diff between a base reference and the current state.
70
+ * `providedBaseBranch` takes a branch, tag or SHA; omitted, the remote default
71
+ * is detected. `includeUncommitted` diffs the working tree (local dev); false
72
+ * compares two commits through the three-dot form, which is the CI shape. */
73
+ export declare function computeBranchDiff(repositoryPath: string, providedBaseBranch?: string, includeUncommitted?: boolean,
74
+ /** Names only: no diff text is computed, and `diffContent`/`diffStat` come back
75
+ * empty. The plan checks read which files changed, never what changed in them. */
76
+ namesOnly?: boolean): Promise<BranchDiffData>;
@@ -1,75 +1,141 @@
1
1
  import { simpleGit } from "simple-git";
2
2
  import { logger } from "./logger.js";
3
- /**
4
- * Extract every file path mentioned in a unified-diff `diff --git` header.
5
- * Always uses the `b/` form so renames return the new path.
6
- */
7
- export function parseChangedFilesFromDiff(rawDiff) {
8
- const out = [];
9
- const re = /^diff --git a\/\S+ b\/(\S+)/gm;
10
- let m;
11
- while ((m = re.exec(rawDiff)) !== null) {
12
- out.push(m[1]);
13
- }
14
- return out;
15
- }
16
- /**
17
- * Splits a unified diff into per-file added-line arrays, keyed by the `b/`
18
- * (post-change) path. Exported so other file->resource-affinity callers
19
- * (e.g. `pathAffinityClassification.ts`'s fallback, SKYR-3857) can derive the
20
- * same per-file diff hunks this module uses, without re-implementing the
21
- * diff-header walk. Lives here rather than in `importerHop.ts`, its original
22
- * home, so callers that `importerHop.ts` itself imports can reach it without
23
- * an import cycle.
24
- */
25
- export function sliceAddedLinesByFile(diffContent) {
26
- const sections = new Map();
27
- let currentFile = null;
28
- for (const line of diffContent.split("\n")) {
29
- const headerMatch = /^diff --git a\/.+ b\/(.+)$/.exec(line);
30
- if (headerMatch) {
31
- currentFile = headerMatch[1];
3
+ /** Every `diff --git` block, in order. Per BLOCK, never per path: the text
4
+ * concatenates every repository, so one repository's deletion of a path would
5
+ * hide another's real change to it. */
6
+ export function parseDiffEntries(rawDiff) {
7
+ const entries = [];
8
+ for (const line of rawDiff.split("\n")) {
9
+ if (line.startsWith("diff --git ")) {
10
+ const path = parseDiffHeaderPath(line);
11
+ // An unreadable header still ENDS the previous block, or its `/dev/null`
12
+ // and `@@` lines are credited to the entry before it.
13
+ if (path === undefined) {
14
+ entries.push({ path: "", isNew: false, isDeleted: false, hasHunks: false });
15
+ continue;
16
+ }
17
+ entries.push({ path, isNew: false, isDeleted: false, hasHunks: false });
32
18
  continue;
33
19
  }
34
- if (!currentFile)
20
+ const current = entries[entries.length - 1];
21
+ if (!current)
35
22
  continue;
36
- if (line.startsWith("+") && !line.startsWith("+++")) {
37
- const arr = sections.get(currentFile) ?? [];
38
- arr.push(line);
39
- sections.set(currentFile, arr);
23
+ if (line === "--- /dev/null")
24
+ current.isNew = true;
25
+ else if (line === "+++ /dev/null")
26
+ current.isDeleted = true;
27
+ else if (line.startsWith("@@ "))
28
+ current.hasHunks = true;
29
+ // A binary block has no `--- `/`+++ ` lines; git folds both into this one.
30
+ else if (line.startsWith("Binary files /dev/null and "))
31
+ current.isNew = true;
32
+ else if (line.startsWith("Binary files ") && line.endsWith(" and /dev/null differ"))
33
+ current.isDeleted = true;
34
+ // Unquoted like the new-path side, or one rename's two halves disagree.
35
+ else if (line.startsWith("rename from "))
36
+ current.renamedFrom = unquoteIfQuoted(line.slice("rename from ".length));
37
+ }
38
+ return entries;
39
+ }
40
+ /** Each changed path's own slice, so a check can ask whether a line is in THIS
41
+ * file. Two repositories changing one path share a key, which errs toward
42
+ * saying a file holds a line it does not. */
43
+ export function diffBlocksByPath(rawDiff) {
44
+ const blocks = new Map();
45
+ let current;
46
+ for (const line of rawDiff.split("\n")) {
47
+ // ANY `diff ` header ends a block, not just `diff --git`: a combined diff
48
+ // writes `diff --cc`, and its lines would otherwise append to the file before.
49
+ if (line.startsWith("diff ")) {
50
+ const path = line.startsWith("diff --git ") ? parseDiffHeaderPath(line) : undefined;
51
+ if (path === undefined) {
52
+ current = undefined;
53
+ continue;
54
+ }
55
+ current = blocks.get(path) ?? [];
56
+ blocks.set(path, current);
40
57
  }
58
+ current?.push(line);
41
59
  }
42
- return sections;
60
+ return new Map([...blocks].map(([path, lines]) => [path, lines.join("\n")]));
43
61
  }
44
- /** Parse diff headers to find newly created and deleted files. */
45
- function isShaLikeRef(ref) {
46
- return /^[0-9a-f]{7,40}$/i.test(ref);
62
+ /** The `b/` path of a header, unquoted. Git QUOTES a non-ASCII path and
63
+ * `core.quotePath` defaults to true, so a header can read
64
+ * `diff --git "a/src/caf\303\251.tsx" "b/…"`. */
65
+ function parseDiffHeaderPath(line) {
66
+ const rest = line.slice("diff --git ".length);
67
+ // Git quotes each side INDEPENDENTLY: a rename of a non-ASCII name emits one
68
+ // quoted side and one bare, which a single whole-line pattern reads neither of.
69
+ const quoted = /^"a\/((?:[^"\\]|\\.)*)" "b\/((?:[^"\\]|\\.)*)"$/.exec(rest);
70
+ if (quoted)
71
+ return unquoteGitPath(quoted[2]);
72
+ const quotedNew = /^a\/.+ "b\/((?:[^"\\]|\\.)*)"$/.exec(rest);
73
+ if (quotedNew)
74
+ return unquoteGitPath(quotedNew[1]);
75
+ const quotedOld = /^"a\/(?:[^"\\]|\\.)*" b\/(.+)$/.exec(rest);
76
+ if (quotedOld)
77
+ return quotedOld[1];
78
+ const plain = /^a\/.+ b\/(.+)$/.exec(rest);
79
+ if (plain)
80
+ return plain[1];
81
+ // `diff.noprefix` drops `a/` and `b/`; `diff.mnemonicPrefix` makes them
82
+ // `c/ w/ i/ o/`. Both are developer-local, so only the local-dev path sees them.
83
+ const mnemonic = /^[a-z]\/(.+) [a-z]\/(.+)$/.exec(rest);
84
+ if (mnemonic)
85
+ return mnemonic[2];
86
+ const noPrefix = /^(\S+) (\S+)$/.exec(rest);
87
+ return noPrefix ? noPrefix[2] : undefined;
88
+ }
89
+ /** Git quotes a path only when it has to. An unquoted path is returned as it
90
+ * stands, because decoding escapes in one would corrupt a legal name that
91
+ * really contains a backslash. */
92
+ export function unquoteIfQuoted(value) {
93
+ const trimmed = value.trim();
94
+ return trimmed.startsWith('"') && trimmed.endsWith('"') ? unquoteGitPath(trimmed) : trimmed;
47
95
  }
48
- function parseNewAndDeletedFiles(rawDiff) {
49
- const newFiles = [];
50
- const deletedFiles = [];
51
- const lines = rawDiff.split("\n");
52
- let currentFile = "";
53
- for (const line of lines) {
54
- const fileMatch = line.match(/^diff --git a\/.+ b\/(.+)$/);
55
- if (fileMatch) {
56
- currentFile = fileMatch[1];
96
+ /** Undo git's C-style quoting. A non-ASCII character arrives as one `\NNN` escape
97
+ * per UTF-8 BYTE, so the bytes are decoded once at the end. */
98
+ export function unquoteGitPath(value) {
99
+ const trimmed = value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
100
+ const simple = { n: "\n", t: "\t", r: "\r", '"': '"', "\\": "\\" };
101
+ const bytes = [];
102
+ for (let i = 0; i < trimmed.length; i++) {
103
+ if (trimmed[i] !== "\\") {
104
+ bytes.push(...Buffer.from(trimmed[i], "utf-8"));
57
105
  continue;
58
106
  }
59
- if (line === "--- /dev/null" && currentFile) {
60
- newFiles.push(currentFile);
61
- }
62
- else if (line === "+++ /dev/null" && currentFile) {
63
- deletedFiles.push(currentFile);
107
+ const next = trimmed[++i];
108
+ if (next !== undefined && /[0-7]/.test(next) && /^[0-7]{3}$/.test(trimmed.slice(i, i + 3))) {
109
+ bytes.push(parseInt(trimmed.slice(i, i + 3), 8));
110
+ i += 2;
111
+ continue;
64
112
  }
113
+ bytes.push(...Buffer.from(next === undefined ? "\\" : (simple[next] ?? next), "utf-8"));
65
114
  }
66
- return { newFiles, deletedFiles };
115
+ return Buffer.from(bytes).toString("utf-8");
116
+ }
117
+ /** Parse diff headers to find newly created and deleted files. */
118
+ function isShaLikeRef(ref) {
119
+ return /^[0-9a-f]{7,40}$/i.test(ref);
67
120
  }
68
- /**
69
- * Normalize and validate a caller-provided base branch/ref before passing it
70
- * to git. Reject option-like or ambiguous values so the ref is always treated
71
- * as a revision name, not as a command-line flag.
72
- */
121
+ /** Exported so `frontendSurface` drops a deleted file without a second walk. */
122
+ export function parseNewAndDeletedFiles(rawDiff) {
123
+ const entries = parseDiffEntries(rawDiff);
124
+ const renamedFromPaths = entries
125
+ .map((entry) => entry.renamedFrom)
126
+ .filter((path) => path !== undefined);
127
+ // An unreadable header yields `""`, which would read as a file nobody added.
128
+ return {
129
+ newFiles: entries.filter((entry) => entry.isNew && entry.path).map((entry) => entry.path),
130
+ // Keyed by the NEW path, so the OLD one is added here: it moved, not died.
131
+ deletedFiles: [
132
+ ...entries.filter((entry) => entry.isDeleted && entry.path).map((entry) => entry.path),
133
+ ...renamedFromPaths,
134
+ ].filter(Boolean),
135
+ };
136
+ }
137
+ /** Reject an option-like or ambiguous ref, so git reads it as a revision name
138
+ * and never as a command-line flag. */
73
139
  export function normalizeProvidedBaseRef(providedBaseBranch) {
74
140
  if (providedBaseBranch === undefined)
75
141
  return undefined;
@@ -87,21 +153,53 @@ export function normalizeProvidedBaseRef(providedBaseBranch) {
87
153
  }
88
154
  return trimmed;
89
155
  }
156
+ /** The paths `--name-status` marks `A`. A rename's new path is NOT one: git
157
+ * reports it as `R`, and `parseNameStatus` already lists it as changed. */
158
+ function addedPaths(raw) {
159
+ const added = [];
160
+ for (const line of (typeof raw === "string" ? raw : "").split("\n")) {
161
+ const parts = line.split("\t");
162
+ if ((parts[0] ?? "").trim().toUpperCase() !== "A")
163
+ continue;
164
+ const path = unquoteIfQuoted(parts[1] ?? "");
165
+ if (path)
166
+ added.push(path);
167
+ }
168
+ return added;
169
+ }
170
+ /** `--name-status` for one ref, split into the three lists `BranchDiffData` carries.
171
+ * Undefined when the ref does not resolve; an EMPTY listing is a result, not a
172
+ * failure. */
173
+ async function tryNameStatus(git, ref, includeUncommitted) {
174
+ try {
175
+ const raw = await git.diff([includeUncommitted ? ref : `${ref}...HEAD`, "--name-status"]);
176
+ const files = parseNameStatus(raw);
177
+ return {
178
+ changedFiles: files.filter((file) => !file.deleted).map((file) => file.path),
179
+ // `--name-status` marks an added path `A`; the header parser never runs here.
180
+ newFiles: addedPaths(raw),
181
+ deletedFiles: files.filter((file) => file.deleted).map((file) => file.path),
182
+ };
183
+ }
184
+ catch {
185
+ return undefined;
186
+ }
187
+ }
90
188
  async function tryDiff(git, ref, includeUncommitted = false) {
91
189
  try {
92
- // When includeUncommitted is true, diff the ref directly against the
93
- // working tree so uncommitted/unstaged local changes are captured.
190
+ // `includeUncommitted` diffs the ref against the working tree.
94
191
  const diffRange = includeUncommitted ? ref : `${ref}...HEAD`;
95
192
  const changedFilesRaw = await git.diff([diffRange, "--name-only"]);
96
193
  const changedFiles = changedFilesRaw
97
194
  .split("\n")
98
195
  .map((f) => f.trim())
99
- .filter((f) => f.length > 0);
196
+ .filter((f) => f.length > 0)
197
+ // `--name-only` quotes a non-ASCII path too.
198
+ .map((f) => unquoteGitPath(f));
100
199
  const diffStat = await git.diff([diffRange, "--stat"]);
101
200
  const fullDiff = await git.diff([diffRange]);
102
201
  const { newFiles, deletedFiles } = parseNewAndDeletedFiles(fullDiff);
103
- // git diff does not include untracked files. Include them as both changed
104
- // and new so endpoint classification treats newly created local files as new.
202
+ // git diff omits untracked files; list them as both changed and new.
105
203
  if (includeUncommitted) {
106
204
  const statusResult = await git.status();
107
205
  const untrackedFiles = statusResult.not_added ?? [];
@@ -135,31 +233,11 @@ async function tryDiff(git, ref, includeUncommitted = false) {
135
233
  return undefined;
136
234
  }
137
235
  }
138
- /**
139
- * Compute the diff between a base reference and the current state.
140
- *
141
- * @param repositoryPath Absolute path to the git repository.
142
- * @param providedBaseBranch A branch name, tag, or commit SHA to diff against.
143
- * When omitted, the remote default branch is auto-detected.
144
- * @param includeUncommitted When true, diffs against the working tree so
145
- * uncommitted/unstaged changes are included. Defaults to false (CI mode
146
- * that only considers committed changes via the three-dot form).
147
- */
148
- export async function computeBranchDiff(repositoryPath, providedBaseBranch, includeUncommitted = false) {
149
- const git = simpleGit(repositoryPath);
150
- const isRepo = await git.checkIsRepo();
151
- if (!isRepo) {
152
- throw new Error(`"${repositoryPath}" is not a Git repository.`);
153
- }
154
- const branchInfo = await git.branch();
155
- const currentBranch = branchInfo.current || "HEAD";
156
- // Build candidate base refs in priority order:
157
- // 1. Provided value used directly (supports SHAs, local branches, tags)
158
- // 2. Provided value with origin/ prefix (remote branch)
159
- // 3. Remote default branch auto-detected from available remotes
160
- // 4. FETCH_HEAD — present in base_commit repos (git fetch --depth 1 origin <sha>)
161
- // where no remote tracking branch is created
162
- // 5. HEAD~1 — last resort for repos with no remote at all
236
+ /** The base refs to try, in priority order. One ladder for both modes, or they
237
+ * would answer about different bases. */
238
+ async function baseRefCandidates(git, providedBaseBranch, includeUncommitted) {
239
+ // In order: the value as given, then with `origin/`, the detected remote
240
+ // default, FETCH_HEAD (a base_commit clone has no tracking branch), HEAD~1.
163
241
  const candidates = [];
164
242
  const normalizedBaseBranch = normalizeProvidedBaseRef(providedBaseBranch);
165
243
  if (normalizedBaseBranch) {
@@ -168,20 +246,17 @@ export async function computeBranchDiff(repositoryPath, providedBaseBranch, incl
168
246
  !isShaLikeRef(normalizedBaseBranch) &&
169
247
  !/[~^:@]/.test(normalizedBaseBranch);
170
248
  if (includeUncommitted && looksLikeSimpleBranch) {
171
- // Local-dev diffs should prefer the remote base branch when available;
172
- // a developer's local main may be ahead of origin/main.
249
+ // A developer's local main may be ahead of origin/main.
173
250
  candidates.push(`origin/${normalizedBaseBranch}`);
174
251
  candidates.push(normalizedBaseBranch);
175
252
  }
176
253
  else {
177
- // Try the raw value first — handles commit SHAs, tags, and explicit local refs.
178
254
  candidates.push(normalizedBaseBranch);
179
255
  if (!isRemoteQualified) {
180
256
  candidates.push(`origin/${normalizedBaseBranch}`);
181
257
  }
182
258
  }
183
259
  }
184
- // Auto-detect remote default branch
185
260
  try {
186
261
  const remoteBranches = await git.branch(["-r"]);
187
262
  if (remoteBranches.all.some((b) => b.endsWith("/main"))) {
@@ -196,10 +271,83 @@ export async function computeBranchDiff(repositoryPath, providedBaseBranch, incl
196
271
  catch {
197
272
  logger.debug("Could not list remote branches");
198
273
  }
199
- // FETCH_HEAD covers base_commit repos (appsmith, etc.)
200
274
  candidates.push("FETCH_HEAD");
201
- // Final fallback
202
275
  candidates.push("HEAD~1");
276
+ return candidates;
277
+ }
278
+ /** Parse `git diff --name-status`. Exported for its own tests.
279
+ * A rename and a copy carry TWO paths: the new one is the changed surface, and
280
+ * a rename also removes the old, while a copy leaves the original. Git quotes a
281
+ * non-ASCII path here as it does in a diff header. */
282
+ export function parseNameStatus(raw) {
283
+ const files = [];
284
+ const add = (value, deleted) => {
285
+ const path = unquoteIfQuoted(value ?? "");
286
+ if (path)
287
+ files.push({ path, deleted });
288
+ };
289
+ for (const line of (typeof raw === "string" ? raw : "").split("\n")) {
290
+ if (!line.trim())
291
+ continue;
292
+ const parts = line.split("\t");
293
+ // A similarity score follows the letter on a rename or a copy: `R100`, `C085`.
294
+ const status = (parts[0] ?? "").trim().toUpperCase();
295
+ if (!status)
296
+ continue;
297
+ if ((status.startsWith("R") || status.startsWith("C")) && parts[2]) {
298
+ add(parts[2], false);
299
+ if (status.startsWith("R"))
300
+ add(parts[1], true);
301
+ continue;
302
+ }
303
+ // A rename or copy with no second path is malformed; read its one path as an
304
+ // ordinary change rather than dropping the file.
305
+ add(parts[1], status === "D");
306
+ }
307
+ return files;
308
+ }
309
+ /** The ONE derivation of `{path, deleted}` from a names-only diff, so no caller
310
+ * re-derives the join. */
311
+ export function changedFileNames(diff) {
312
+ return [
313
+ ...(diff.changedFiles ?? []).map((path) => ({ path, deleted: false })),
314
+ ...(diff.deletedFiles ?? []).map((path) => ({ path, deleted: true })),
315
+ ];
316
+ }
317
+ /** Compute the diff between a base reference and the current state.
318
+ * `providedBaseBranch` takes a branch, tag or SHA; omitted, the remote default
319
+ * is detected. `includeUncommitted` diffs the working tree (local dev); false
320
+ * compares two commits through the three-dot form, which is the CI shape. */
321
+ export async function computeBranchDiff(repositoryPath, providedBaseBranch, includeUncommitted = false,
322
+ /** Names only: no diff text is computed, and `diffContent`/`diffStat` come back
323
+ * empty. The plan checks read which files changed, never what changed in them. */
324
+ namesOnly = false) {
325
+ const git = simpleGit(repositoryPath);
326
+ const isRepo = await git.checkIsRepo();
327
+ if (!isRepo) {
328
+ throw new Error(`"${repositoryPath}" is not a Git repository.`);
329
+ }
330
+ const branchInfo = await git.branch();
331
+ const currentBranch = branchInfo.current || "HEAD";
332
+ const candidates = await baseRefCandidates(git, providedBaseBranch, includeUncommitted);
333
+ if (namesOnly) {
334
+ for (const ref of candidates) {
335
+ const result = await tryNameStatus(git, ref, includeUncommitted);
336
+ // The FIRST ref that resolves wins, even when it lists nothing: walking past
337
+ // an empty listing reached `HEAD~1` and reported the previous commit's files
338
+ // as this pull request's.
339
+ if (result) {
340
+ logger.info("Changed-file names computed", {
341
+ currentBranch,
342
+ baseBranch: ref,
343
+ changedFiles: result.changedFiles.length,
344
+ });
345
+ return { currentBranch, baseBranch: ref, diffContent: "", diffStat: "", ...result };
346
+ }
347
+ logger.debug(`Name-status against ${ref} failed, trying next candidate`);
348
+ }
349
+ throw new Error(`Could not list changed file names: all candidates failed (${candidates.join(", ")})`);
350
+ }
203
351
  for (const ref of candidates) {
204
352
  const result = await tryDiff(git, ref, includeUncommitted);
205
353
  if (result) {
@@ -0,0 +1,18 @@
1
+ /** Resolve and read a path an AGENT wrote, without leaving the checkout. ONE
2
+ * implementation for every such reader: an escape closed in one and left open in
3
+ * another is the same hole. */
4
+ /** `candidate` resolved against `root`, or undefined when the real target is not
5
+ * inside `root`. `root` must already be a real path, or a symlinked checkout fails
6
+ * its own containment test. */
7
+ export declare function resolveWithinRoot(root: string, candidate: string): string | undefined;
8
+ /** Whether `cited` names a real FILE inside `root`. */
9
+ export declare function fileExistsWithinRoot(root: string, cited: string): boolean;
10
+ /** The text of a file inside `root`, or undefined for anything else — outside the
11
+ * checkout, absent, not a regular file, or past the cap. A caller cannot tell those
12
+ * apart ON PURPOSE. `size` is 0 for a FIFO, so the byte cap is not enough:
13
+ * `readFile` on one blocks until something writes and the tool never returns. */
14
+ export declare function readFileWithinRoot(root: string, candidate: string, maxBytes: number): string | undefined;
15
+ /** The real paths of a run's checkouts, deduped. Every containment test compares
16
+ * real paths, so a root has to be one: on macOS `os.tmpdir()` is a symlink, and
17
+ * a raw root failed its own containment test for files really inside it. */
18
+ export declare function realRootsOf(roots: Array<string | undefined>): string[];
@@ -0,0 +1,73 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ /** Resolve and read a path an AGENT wrote, without leaving the checkout. ONE
4
+ * implementation for every such reader: an escape closed in one and left open in
5
+ * another is the same hole. */
6
+ /** `candidate` resolved against `root`, or undefined when the real target is not
7
+ * inside `root`. `root` must already be a real path, or a symlinked checkout fails
8
+ * its own containment test. */
9
+ export function resolveWithinRoot(root, candidate) {
10
+ try {
11
+ // `path.resolve` returns an absolute candidate unchanged and a
12
+ // drive-relative one as written, so neither is silently joined to the root;
13
+ // the containment test below is what rejects them.
14
+ const real = fs.realpathSync(path.resolve(root, candidate));
15
+ if (real !== root && !real.startsWith(root + path.sep))
16
+ return undefined;
17
+ return real;
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
23
+ /** Whether `cited` names a real FILE inside `root`. */
24
+ export function fileExistsWithinRoot(root, cited) {
25
+ const real = resolveWithinRoot(root, cited);
26
+ if (!real)
27
+ return false;
28
+ try {
29
+ // A directory is not a file that defines a route, and `realpathSync` has
30
+ // already followed every symlink, so this is the real target's kind.
31
+ return fs.statSync(real).isFile();
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ /** The text of a file inside `root`, or undefined for anything else — outside the
38
+ * checkout, absent, not a regular file, or past the cap. A caller cannot tell those
39
+ * apart ON PURPOSE. `size` is 0 for a FIFO, so the byte cap is not enough:
40
+ * `readFile` on one blocks until something writes and the tool never returns. */
41
+ export function readFileWithinRoot(root, candidate, maxBytes) {
42
+ const real = resolveWithinRoot(root, candidate);
43
+ if (!real)
44
+ return undefined;
45
+ try {
46
+ const stat = fs.statSync(real);
47
+ if (!stat.isFile() || stat.size > maxBytes)
48
+ return undefined;
49
+ return fs.readFileSync(real, "utf-8");
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ }
55
+ /** The real paths of a run's checkouts, deduped. Every containment test compares
56
+ * real paths, so a root has to be one: on macOS `os.tmpdir()` is a symlink, and
57
+ * a raw root failed its own containment test for files really inside it. */
58
+ export function realRootsOf(roots) {
59
+ const real = [];
60
+ for (const root of roots) {
61
+ if (!root || root === "unknown")
62
+ continue;
63
+ try {
64
+ const resolved = fs.realpathSync(root);
65
+ if (!real.includes(resolved))
66
+ real.push(resolved);
67
+ }
68
+ catch {
69
+ // A checkout path that no longer resolves holds no readable file.
70
+ }
71
+ }
72
+ return real;
73
+ }