patchdrill 0.1.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 (169) hide show
  1. package/.patchdrill.yml +33 -0
  2. package/CHANGELOG.md +150 -0
  3. package/CONTRIBUTING.md +59 -0
  4. package/LICENSE +21 -0
  5. package/README.md +601 -0
  6. package/SECURITY.md +28 -0
  7. package/action.yml +338 -0
  8. package/dist/baseline.d.ts +9 -0
  9. package/dist/baseline.js +38 -0
  10. package/dist/baseline.js.map +1 -0
  11. package/dist/cli.d.ts +19 -0
  12. package/dist/cli.js +662 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/codeowners.d.ts +14 -0
  15. package/dist/codeowners.js +104 -0
  16. package/dist/codeowners.js.map +1 -0
  17. package/dist/command-plan.d.ts +3 -0
  18. package/dist/command-plan.js +26 -0
  19. package/dist/command-plan.js.map +1 -0
  20. package/dist/demo.d.ts +5 -0
  21. package/dist/demo.js +525 -0
  22. package/dist/demo.js.map +1 -0
  23. package/dist/dependency.d.ts +4 -0
  24. package/dist/dependency.js +1424 -0
  25. package/dist/dependency.js.map +1 -0
  26. package/dist/doctor.d.ts +26 -0
  27. package/dist/doctor.js +183 -0
  28. package/dist/doctor.js.map +1 -0
  29. package/dist/evidence.d.ts +64 -0
  30. package/dist/evidence.js +352 -0
  31. package/dist/evidence.js.map +1 -0
  32. package/dist/git.d.ts +16 -0
  33. package/dist/git.js +349 -0
  34. package/dist/git.js.map +1 -0
  35. package/dist/i18n-catalog.d.ts +8 -0
  36. package/dist/i18n-catalog.js +446 -0
  37. package/dist/i18n-catalog.js.map +1 -0
  38. package/dist/i18n.d.ts +20 -0
  39. package/dist/i18n.js +67 -0
  40. package/dist/i18n.js.map +1 -0
  41. package/dist/init.d.ts +13 -0
  42. package/dist/init.js +312 -0
  43. package/dist/init.js.map +1 -0
  44. package/dist/markdown-links.d.ts +18 -0
  45. package/dist/markdown-links.js +180 -0
  46. package/dist/markdown-links.js.map +1 -0
  47. package/dist/package-scripts.d.ts +3 -0
  48. package/dist/package-scripts.js +55 -0
  49. package/dist/package-scripts.js.map +1 -0
  50. package/dist/planner.d.ts +8 -0
  51. package/dist/planner.js +2351 -0
  52. package/dist/planner.js.map +1 -0
  53. package/dist/policy.d.ts +12 -0
  54. package/dist/policy.js +255 -0
  55. package/dist/policy.js.map +1 -0
  56. package/dist/project.d.ts +2 -0
  57. package/dist/project.js +1085 -0
  58. package/dist/project.js.map +1 -0
  59. package/dist/release-readiness.d.ts +25 -0
  60. package/dist/release-readiness.js +426 -0
  61. package/dist/release-readiness.js.map +1 -0
  62. package/dist/report-annotations.d.ts +3 -0
  63. package/dist/report-annotations.js +28 -0
  64. package/dist/report-annotations.js.map +1 -0
  65. package/dist/report-contract.d.ts +2 -0
  66. package/dist/report-contract.js +82 -0
  67. package/dist/report-contract.js.map +1 -0
  68. package/dist/report-html.d.ts +7 -0
  69. package/dist/report-html.js +706 -0
  70. package/dist/report-html.js.map +1 -0
  71. package/dist/report-sarif.d.ts +2 -0
  72. package/dist/report-sarif.js +90 -0
  73. package/dist/report-sarif.js.map +1 -0
  74. package/dist/report.d.ts +14 -0
  75. package/dist/report.js +310 -0
  76. package/dist/report.js.map +1 -0
  77. package/dist/risk.d.ts +19 -0
  78. package/dist/risk.js +1226 -0
  79. package/dist/risk.js.map +1 -0
  80. package/dist/runner.d.ts +8 -0
  81. package/dist/runner.js +113 -0
  82. package/dist/runner.js.map +1 -0
  83. package/dist/scan.d.ts +2 -0
  84. package/dist/scan.js +195 -0
  85. package/dist/scan.js.map +1 -0
  86. package/dist/schema.d.ts +12 -0
  87. package/dist/schema.js +30 -0
  88. package/dist/schema.js.map +1 -0
  89. package/dist/stack-coverage.d.ts +8 -0
  90. package/dist/stack-coverage.js +94 -0
  91. package/dist/stack-coverage.js.map +1 -0
  92. package/dist/types.d.ts +206 -0
  93. package/dist/types.js +2 -0
  94. package/dist/types.js.map +1 -0
  95. package/dist/verification.d.ts +11 -0
  96. package/dist/verification.js +108 -0
  97. package/dist/verification.js.map +1 -0
  98. package/docs/ANNOTATIONS.md +34 -0
  99. package/docs/ARCHITECTURE.md +79 -0
  100. package/docs/BASELINES.md +32 -0
  101. package/docs/CASE_STUDIES.md +106 -0
  102. package/docs/CODEOWNERS.md +23 -0
  103. package/docs/DASHBOARD.md +87 -0
  104. package/docs/EVIDENCE.md +55 -0
  105. package/docs/LAUNCH_PLAYBOOK.md +103 -0
  106. package/docs/MONOREPOS.md +74 -0
  107. package/docs/POLICY.md +98 -0
  108. package/docs/PROOF_PACKS.md +57 -0
  109. package/docs/PR_COMMENTS.md +56 -0
  110. package/docs/RELEASE.md +35 -0
  111. package/docs/ROADMAP.md +152 -0
  112. package/docs/RULE_CATALOG.md +90 -0
  113. package/docs/SARIF.md +74 -0
  114. package/docs/SCHEMAS.md +49 -0
  115. package/docs/SECURITY_POSTURE.md +32 -0
  116. package/docs/STACK_COVERAGE.md +20 -0
  117. package/docs/assets/patchdrill-demo.svg +21 -0
  118. package/docs/media/patchdrill-dashboard.png +0 -0
  119. package/docs/media/patchdrill-demo.gif +0 -0
  120. package/examples/case-studies/README.md +20 -0
  121. package/examples/demo/README.md +21 -0
  122. package/examples/demo/patchdrill-demo-summary.md +35 -0
  123. package/examples/demo/patchdrill-demo.html +623 -0
  124. package/examples/demo/patchdrill-demo.json +355 -0
  125. package/examples/demo/patchdrill-demo.md +120 -0
  126. package/examples/demo/patchdrill-demo.sarif +195 -0
  127. package/examples/report.md +128 -0
  128. package/examples/risky-agent-pr/README.md +15 -0
  129. package/examples/risky-agent-pr/patchdrill-demo-summary.md +41 -0
  130. package/examples/risky-agent-pr/patchdrill-demo.html +681 -0
  131. package/examples/risky-agent-pr/patchdrill-demo.json +483 -0
  132. package/examples/risky-agent-pr/patchdrill-demo.md +140 -0
  133. package/examples/risky-agent-pr/patchdrill-demo.sarif +398 -0
  134. package/fixtures/stacks/README.md +4 -0
  135. package/fixtures/stacks/android-gradle/fixture.json +33 -0
  136. package/fixtures/stacks/aspnet-core-service/fixture.json +36 -0
  137. package/fixtures/stacks/bazel-workspace/fixture.json +30 -0
  138. package/fixtures/stacks/buck2-workspace/fixture.json +30 -0
  139. package/fixtures/stacks/cargo-workspace/fixture.json +48 -0
  140. package/fixtures/stacks/django-app/fixture.json +25 -0
  141. package/fixtures/stacks/docker-compose/fixture.json +17 -0
  142. package/fixtures/stacks/dockerfile-service/fixture.json +17 -0
  143. package/fixtures/stacks/dotnet-service/fixture.json +36 -0
  144. package/fixtures/stacks/dotnet-solution-filter/fixture.json +62 -0
  145. package/fixtures/stacks/fastapi-app/fixture.json +29 -0
  146. package/fixtures/stacks/go-workspace/fixture.json +48 -0
  147. package/fixtures/stacks/java-gradle/fixture.json +29 -0
  148. package/fixtures/stacks/java-maven/fixture.json +32 -0
  149. package/fixtures/stacks/kubernetes-helm/fixture.json +25 -0
  150. package/fixtures/stacks/kubernetes-kustomize/fixture.json +21 -0
  151. package/fixtures/stacks/nested-go-workspace/fixture.json +51 -0
  152. package/fixtures/stacks/nextjs-app/fixture.json +34 -0
  153. package/fixtures/stacks/node-turbo-workspace/fixture.json +39 -0
  154. package/fixtures/stacks/pants-python/fixture.json +33 -0
  155. package/fixtures/stacks/php-composer/fixture.json +31 -0
  156. package/fixtures/stacks/python-service/fixture.json +21 -0
  157. package/fixtures/stacks/rails-app/fixture.json +25 -0
  158. package/fixtures/stacks/spring-boot-gradle/fixture.json +29 -0
  159. package/fixtures/stacks/spring-boot-maven/fixture.json +43 -0
  160. package/fixtures/stacks/swift-package/fixture.json +21 -0
  161. package/fixtures/stacks/terraform-module/fixture.json +17 -0
  162. package/fixtures/stacks/uv-python-service/fixture.json +47 -0
  163. package/fixtures/stacks/xcode-app/fixture.json +72 -0
  164. package/package.json +80 -0
  165. package/schemas/patchdrill-doctor.schema.json +171 -0
  166. package/schemas/patchdrill-evidence.schema.json +239 -0
  167. package/schemas/patchdrill-policy.schema.json +170 -0
  168. package/schemas/patchdrill-release-check.schema.json +78 -0
  169. package/schemas/patchdrill-report.schema.json +647 -0
@@ -0,0 +1,2351 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { basename, dirname, join, normalize, relative } from "node:path";
3
+ import { addCommandPlan } from "./command-plan.js";
4
+ const signalPlanners = [
5
+ {
6
+ ecosystem: "node",
7
+ applies: (_signal, context) => touchesNode(context.paths),
8
+ addPlans: (plans, signal, context) => {
9
+ const workspacePlanCount = addNodeWorkspacePlans(plans, context.paths, signal);
10
+ if (workspacePlanCount === 0)
11
+ addNodePlans(plans, signal);
12
+ }
13
+ },
14
+ {
15
+ ecosystem: "python",
16
+ applies: (signal, context) => touchesPython(context.paths, context.root, signal),
17
+ addPlans: addPythonSignalPlans
18
+ },
19
+ {
20
+ ecosystem: "rust",
21
+ applies: (signal, context) => touchesRust(context.paths, signal),
22
+ addPlans: addRustSignalPlans
23
+ },
24
+ {
25
+ ecosystem: "go",
26
+ applies: (signal, context) => touchesGo(context.paths, signal),
27
+ addPlans: (plans, signal, context) => {
28
+ const workspacePlanCount = addGoWorkspacePlans(plans, context.paths, signal);
29
+ if (workspacePlanCount === 0)
30
+ addGoPlans(plans, signal);
31
+ }
32
+ },
33
+ {
34
+ ecosystem: "java",
35
+ applies: (_signal, context) => touchesJava(context.paths, context.root),
36
+ addPlans: (plans, signal, context) => addJavaPlans(plans, context.root, signal)
37
+ },
38
+ {
39
+ ecosystem: "android",
40
+ applies: (_signal, context) => touchesAndroid(context.paths),
41
+ addPlans: (plans, _signal, context) => addAndroidPlans(plans, context.root, context.paths)
42
+ },
43
+ {
44
+ ecosystem: "ruby",
45
+ applies: (_signal, context) => touches(context.paths, [".rb", "Gemfile", "Gemfile.lock"]),
46
+ addPlans: (plans, signal, context) => addRubyPlans(plans, context.root, signal)
47
+ },
48
+ {
49
+ ecosystem: "php",
50
+ applies: (_signal, context) => touches(context.paths, [".php", "composer.json", "composer.lock"]),
51
+ addPlans: (plans, signal, context) => addPhpPlans(plans, context.root, context.paths, signal)
52
+ },
53
+ {
54
+ ecosystem: "dotnet",
55
+ applies: (_signal, context) => touchesDotnet(context.paths),
56
+ addPlans: (plans, signal, context) => addDotnetPlans(plans, context.root, context.paths, signal)
57
+ },
58
+ {
59
+ ecosystem: "swift",
60
+ applies: (_signal, context) => touches(context.paths, [".swift", "Package.swift", "Package.resolved"]),
61
+ addPlans: addSwiftPlans
62
+ },
63
+ {
64
+ ecosystem: "xcode",
65
+ applies: (_signal, context) => touchesXcode(context.paths),
66
+ addPlans: (plans, signal, context) => addXcodePlans(plans, context.root, context.paths, signal)
67
+ },
68
+ {
69
+ ecosystem: "terraform",
70
+ applies: (_signal, context) => context.paths.some((path) => path.endsWith(".tf") || path.endsWith(".tfvars")),
71
+ addPlans: addTerraformPlans
72
+ },
73
+ {
74
+ ecosystem: "docker",
75
+ applies: (_signal, context) => context.paths.some((path) => /(^|\/)(Dockerfile|compose\.ya?ml|docker-compose\.ya?ml)$/.test(path)),
76
+ addPlans: (plans, _signal, context) => addDockerPlans(plans, context.paths)
77
+ },
78
+ {
79
+ ecosystem: "kubernetes",
80
+ applies: (_signal, context) => touchesKubernetes(context.paths),
81
+ addPlans: (plans, _signal, context) => addKubernetesPlans(plans, context.root, context.paths)
82
+ },
83
+ {
84
+ ecosystem: "bazel",
85
+ applies: (_signal, context) => touchesBazel(context.paths),
86
+ addPlans: (plans, _signal, context) => addBazelPlans(plans, context.root, context.paths)
87
+ },
88
+ {
89
+ ecosystem: "buck",
90
+ applies: (_signal, context) => touchesBuck(context.paths),
91
+ addPlans: (plans, _signal, context) => addBuckPlans(plans, context.root, context.paths)
92
+ },
93
+ {
94
+ ecosystem: "pants",
95
+ applies: (_signal, context) => touchesPants(context.paths),
96
+ addPlans: (plans, _signal, context) => addPantsPlans(plans, context.root, context.options.changedSince ?? "HEAD")
97
+ }
98
+ ];
99
+ export function planCommands(root, changedFiles, signals, options = {}) {
100
+ const plans = [];
101
+ const paths = changedFiles.map((file) => file.path);
102
+ const context = { root, paths, options };
103
+ for (const signal of signals) {
104
+ for (const planner of signalPlanners) {
105
+ if (signal.ecosystem !== planner.ecosystem || !planner.applies(signal, context))
106
+ continue;
107
+ planner.addPlans(plans, signal, context);
108
+ }
109
+ }
110
+ if (paths.some((path) => path.startsWith(".github/workflows/"))) {
111
+ pushUnique(plans, {
112
+ id: "workflow-review",
113
+ label: "GitHub Actions review",
114
+ command: "git diff -- .github/workflows",
115
+ reason: "Workflow changes affect CI permissions and release behavior.",
116
+ ecosystem: "github-actions",
117
+ required: false
118
+ });
119
+ }
120
+ return plans;
121
+ }
122
+ export function supportedPlannerEcosystems() {
123
+ return [...new Set(signalPlanners.map((planner) => planner.ecosystem))].sort();
124
+ }
125
+ function addPythonSignalPlans(plans, signal, context) {
126
+ const signalRoot = signalRootPath(context.root, signal);
127
+ const scopedPaths = pathsForSignal(context.paths, signal);
128
+ if (isDjangoProject(signalRoot, signal)) {
129
+ addDjangoPlans(plans, signal);
130
+ return;
131
+ }
132
+ addPythonPlans(plans, context.root, context.paths, signal);
133
+ if (signal.framework === "fastapi" && signal.entrypoint)
134
+ addFastApiPlans(plans, scopedPaths, signal.entrypoint, signal);
135
+ }
136
+ function addRustSignalPlans(plans, signal, context) {
137
+ const workspacePlanCount = addCargoWorkspacePlans(plans, context.paths, signal);
138
+ if (workspacePlanCount > 0)
139
+ return;
140
+ pushUnique(plans, {
141
+ id: scopedPlanId("rust-tests", signal),
142
+ label: scopedPlanLabel("Rust tests", signal),
143
+ command: cargoCommand(signal, "test", "--all-targets"),
144
+ reason: "Rust source or Cargo metadata changed.",
145
+ ecosystem: "rust",
146
+ required: true,
147
+ ...scopedPackagePath(signal)
148
+ });
149
+ pushUnique(plans, {
150
+ id: scopedPlanId("rust-clippy", signal),
151
+ label: scopedPlanLabel("Rust clippy", signal),
152
+ command: cargoCommand(signal, "clippy", "--all-targets -- -D warnings"),
153
+ reason: "Rust changes should pass linting before merge.",
154
+ ecosystem: "rust",
155
+ required: false,
156
+ ...scopedPackagePath(signal)
157
+ });
158
+ }
159
+ function addSwiftPlans(plans) {
160
+ pushUnique(plans, {
161
+ id: "swift-tests",
162
+ label: "Swift tests",
163
+ command: "swift test",
164
+ reason: "Swift package source or package metadata changed.",
165
+ ecosystem: "swift",
166
+ required: true
167
+ });
168
+ pushUnique(plans, {
169
+ id: "swift-build",
170
+ label: "Swift build",
171
+ command: "swift build",
172
+ reason: "Swift packages should still build after source or dependency changes.",
173
+ ecosystem: "swift",
174
+ required: false
175
+ });
176
+ }
177
+ function addTerraformPlans(plans) {
178
+ pushUnique(plans, {
179
+ id: "terraform-validate",
180
+ label: "Terraform validate",
181
+ command: "terraform fmt -check && terraform validate",
182
+ reason: "Terraform configuration changed.",
183
+ ecosystem: "terraform",
184
+ required: true
185
+ });
186
+ }
187
+ function addDockerPlans(plans, paths) {
188
+ const dockerBuildContexts = new Set(paths.filter(isDockerfilePath).map((path) => parentPath(path) || "."));
189
+ for (const contextPath of dockerBuildContexts) {
190
+ pushUnique(plans, {
191
+ id: contextPath === "." ? "docker-build-check" : `docker-build-check-${slug(contextPath)}`,
192
+ label: contextPath === "." ? "Docker build check" : `${contextPath} Docker build check`,
193
+ command: `docker build ${quoteShell(contextPath)}`,
194
+ reason: contextPath === "."
195
+ ? "Dockerfile changed, so the root container build should still work."
196
+ : `Dockerfile changed under ${contextPath}, so that container build context should still work.`,
197
+ ecosystem: "docker",
198
+ required: false
199
+ });
200
+ }
201
+ for (const composePath of paths.filter(isDockerComposePath)) {
202
+ pushUnique(plans, {
203
+ id: `docker-compose-config-${slug(composePath)}`,
204
+ label: `${composePath} Compose config`,
205
+ command: `docker compose -f ${quoteShell(composePath)} config`,
206
+ reason: `${composePath} changed, so the composed service graph should render before merge.`,
207
+ ecosystem: "docker",
208
+ required: false
209
+ });
210
+ }
211
+ }
212
+ const NODE_TASKS = [
213
+ { id: "typecheck", label: "typecheck", aliases: ["typecheck", "check:types", "types", "tsc"], required: true },
214
+ { id: "lint", label: "lint", aliases: ["lint"], required: false },
215
+ { id: "test", label: "test", aliases: ["test", "test:unit", "unit"], required: true },
216
+ { id: "build", label: "build", aliases: ["build"], required: true },
217
+ { id: "e2e", label: "e2e", aliases: ["test:e2e", "e2e", "playwright", "cypress", "cy:run"], required: false }
218
+ ];
219
+ // Exposed so doctor's PASS/WARN script detection mirrors exactly what scan plans,
220
+ // rather than diverging via its own heuristics.
221
+ export function nodeTaskAliases(id) {
222
+ return NODE_TASKS.find((task) => task.id === id)?.aliases ?? [];
223
+ }
224
+ function addDotnetPlans(plans, root, paths, signal) {
225
+ const solutionFilterPlanCount = addDotnetSolutionFilterPlans(plans, root, paths);
226
+ if (solutionFilterPlanCount > 0)
227
+ return;
228
+ const targetedPlanCount = addDotnetProjectPlans(plans, root, paths, signal);
229
+ if (targetedPlanCount > 0)
230
+ return;
231
+ addRootDotnetPlans(plans, signal);
232
+ }
233
+ function addRootDotnetPlans(plans, signal) {
234
+ const solutionFilter = dotnetSolutionFilterTarget(signal);
235
+ pushUnique(plans, {
236
+ id: solutionFilter ? "dotnet-solution-filter-tests" : "dotnet-tests",
237
+ label: solutionFilter ? ".NET solution filter tests" : ".NET tests",
238
+ command: `dotnet test${solutionFilter ? ` ${quoteShell(solutionFilter)}` : ""}`,
239
+ reason: solutionFilter
240
+ ? `.NET solution filter ${solutionFilter} changed, so tests should run against the filtered solution.`
241
+ : ".NET source or project metadata changed.",
242
+ ecosystem: "dotnet",
243
+ required: true
244
+ });
245
+ pushUnique(plans, {
246
+ id: solutionFilter ? "dotnet-solution-filter-build" : "dotnet-build",
247
+ label: solutionFilter ? ".NET solution filter build" : ".NET build",
248
+ command: `dotnet build${solutionFilter ? ` ${quoteShell(solutionFilter)}` : ""} --no-restore`,
249
+ reason: solutionFilter
250
+ ? `.NET solution filter ${solutionFilter} changed, so the filtered solution should still compile.`
251
+ : ".NET projects should still compile after source or project metadata changes.",
252
+ ecosystem: "dotnet",
253
+ required: false
254
+ });
255
+ if (signal.framework === "aspnet-core" && !solutionFilter) {
256
+ pushUnique(plans, {
257
+ id: "aspnet-core-publish",
258
+ label: "ASP.NET Core publish",
259
+ command: "dotnet publish --no-restore",
260
+ reason: "ASP.NET Core services should still produce a publishable deployment artifact.",
261
+ ecosystem: "dotnet",
262
+ required: false
263
+ });
264
+ }
265
+ }
266
+ function dotnetSolutionFilterTarget(signal) {
267
+ return signal.manifestPath.endsWith(".slnf") ? signal.manifestPath : undefined;
268
+ }
269
+ function addDotnetSolutionFilterPlans(plans, root, paths) {
270
+ if (touchesDotnetRootMetadata(paths))
271
+ return 0;
272
+ const projects = discoverDotnetProjects(root);
273
+ if (projects.length === 0)
274
+ return 0;
275
+ const changedProjects = dotnetChangedProjects(projects, paths);
276
+ if (changedProjects.length === 0)
277
+ return 0;
278
+ const affectedProjects = includeDownstreamDotnetProjects(changedProjects, projects);
279
+ const affectedProjectPaths = new Set(affectedProjects.map((project) => project.path));
280
+ const affectedTestProjectPaths = new Set(affectedProjects.filter((project) => project.isTestProject).map((project) => project.path));
281
+ if (affectedTestProjectPaths.size === 0)
282
+ return 0;
283
+ const filters = selectDotnetSolutionFilters(root, affectedProjectPaths, affectedTestProjectPaths);
284
+ let added = 0;
285
+ for (const filter of filters) {
286
+ const before = plans.length;
287
+ pushUnique(plans, {
288
+ id: `dotnet-solution-filter-${slug(filter.path)}-tests`,
289
+ label: `${filter.path} .NET solution filter tests`,
290
+ command: `dotnet test ${quoteShell(filter.path)}`,
291
+ reason: dotnetSolutionFilterReason(filter, changedProjects, "test"),
292
+ ecosystem: "dotnet",
293
+ required: true,
294
+ packagePath: filter.path
295
+ });
296
+ pushUnique(plans, {
297
+ id: `dotnet-solution-filter-${slug(filter.path)}-build`,
298
+ label: `${filter.path} .NET solution filter build`,
299
+ command: `dotnet build ${quoteShell(filter.path)} --no-restore`,
300
+ reason: dotnetSolutionFilterReason(filter, changedProjects, "build"),
301
+ ecosystem: "dotnet",
302
+ required: false,
303
+ packagePath: filter.path
304
+ });
305
+ if (plans.length > before)
306
+ added += plans.length - before;
307
+ }
308
+ return added;
309
+ }
310
+ function selectDotnetSolutionFilters(root, affectedProjectPaths, affectedTestProjectPaths) {
311
+ const filters = discoverDotnetSolutionFilters(root)
312
+ .filter((filter) => filter.projects.some((project) => affectedTestProjectPaths.has(project)))
313
+ .sort((a, b) => a.projects.length - b.projects.length || a.path.localeCompare(b.path));
314
+ const selected = [];
315
+ const coveredTestProjects = new Set();
316
+ for (const filter of filters) {
317
+ const uncoveredTests = filter.projects.filter((project) => affectedTestProjectPaths.has(project) && !coveredTestProjects.has(project));
318
+ if (uncoveredTests.length === 0)
319
+ continue;
320
+ selected.push(filter);
321
+ for (const project of filter.projects) {
322
+ if (affectedProjectPaths.has(project) && affectedTestProjectPaths.has(project))
323
+ coveredTestProjects.add(project);
324
+ }
325
+ }
326
+ return selected;
327
+ }
328
+ function discoverDotnetSolutionFilters(root) {
329
+ return findFilesWithExtension(root, ".slnf", 4)
330
+ .map((path) => ({ path, projects: dotnetSolutionFilterProjects(root, path) }))
331
+ .filter((filter) => filter.projects.length > 0)
332
+ .sort((a, b) => a.path.localeCompare(b.path));
333
+ }
334
+ function dotnetSolutionFilterProjects(root, filterPath) {
335
+ let parsed;
336
+ try {
337
+ parsed = JSON.parse(readText(root, filterPath));
338
+ }
339
+ catch {
340
+ return [];
341
+ }
342
+ const projects = dotnetSolutionFilterProjectList(parsed);
343
+ if (!projects)
344
+ return [];
345
+ const filterDirectory = dirname(filterPath);
346
+ return uniqueStrings(projects
347
+ .map((project) => toRepoPath(relative(root, join(root, filterDirectory, normalize(project.replaceAll("\\", "/"))))))
348
+ .filter((project) => project.endsWith(".csproj") || project.endsWith(".fsproj") || project.endsWith(".vbproj"))).sort();
349
+ }
350
+ function dotnetSolutionFilterProjectList(value) {
351
+ if (!value || typeof value !== "object")
352
+ return undefined;
353
+ const solution = value.solution;
354
+ if (!solution || typeof solution !== "object")
355
+ return undefined;
356
+ const projects = solution.projects;
357
+ return Array.isArray(projects) && projects.every((project) => typeof project === "string") ? projects : undefined;
358
+ }
359
+ function dotnetSolutionFilterReason(filter, changedProjects, command) {
360
+ const examples = changedProjects.slice(0, 3).map((project) => project.path).join(", ");
361
+ const suffix = changedProjects.length > 3 ? ", ..." : "";
362
+ if (command === "test") {
363
+ return `${filter.path} covers affected .NET test projects for changed ${examples}${suffix}, so tests should run through that solution filter.`;
364
+ }
365
+ return `${filter.path} covers affected .NET projects for changed ${examples}${suffix}, so the filtered solution should still compile.`;
366
+ }
367
+ function addDotnetProjectPlans(plans, root, paths, signal) {
368
+ if (touchesDotnetRootMetadata(paths))
369
+ return 0;
370
+ const projects = discoverDotnetProjects(root);
371
+ if (projects.length === 0)
372
+ return 0;
373
+ const changedProjects = dotnetChangedProjects(projects, paths);
374
+ if (changedProjects.length === 0)
375
+ return 0;
376
+ const affectedProjects = includeDownstreamDotnetProjects(changedProjects, projects);
377
+ const affectedProjectPaths = new Set(affectedProjects.map((project) => project.path));
378
+ const testProjects = affectedProjects.filter((project) => project.isTestProject);
379
+ if (testProjects.length === 0)
380
+ return 0;
381
+ let added = 0;
382
+ for (const project of testProjects) {
383
+ const before = plans.length;
384
+ pushUnique(plans, {
385
+ id: `dotnet-project-${slug(project.name)}-tests`,
386
+ label: `${project.name} .NET tests`,
387
+ command: `dotnet test ${quoteShell(project.path)}`,
388
+ reason: dotnetProjectReason(project, changedProjects, affectedProjectPaths, "test"),
389
+ ecosystem: "dotnet",
390
+ required: true,
391
+ packageName: project.name,
392
+ packagePath: project.directory
393
+ });
394
+ if (plans.length > before)
395
+ added += 1;
396
+ }
397
+ for (const project of affectedProjects.filter((candidate) => !candidate.isTestProject)) {
398
+ const before = plans.length;
399
+ pushUnique(plans, {
400
+ id: `dotnet-project-${slug(project.name)}-build`,
401
+ label: `${project.name} .NET build`,
402
+ command: `dotnet build ${quoteShell(project.path)} --no-restore`,
403
+ reason: dotnetProjectReason(project, changedProjects, affectedProjectPaths, "build"),
404
+ ecosystem: "dotnet",
405
+ required: false,
406
+ packageName: project.name,
407
+ packagePath: project.directory
408
+ });
409
+ if (plans.length > before)
410
+ added += 1;
411
+ }
412
+ if (signal.framework === "aspnet-core") {
413
+ for (const project of affectedProjects.filter((candidate) => candidate.isAspNetCoreProject && !candidate.isTestProject)) {
414
+ const before = plans.length;
415
+ pushUnique(plans, {
416
+ id: `aspnet-core-project-${slug(project.name)}-publish`,
417
+ label: `${project.name} ASP.NET Core publish`,
418
+ command: `dotnet publish ${quoteShell(project.path)} --no-restore`,
419
+ reason: "Changed ASP.NET Core projects should still produce publishable deployment artifacts.",
420
+ ecosystem: "dotnet",
421
+ required: false,
422
+ packageName: project.name,
423
+ packagePath: project.directory
424
+ });
425
+ if (plans.length > before)
426
+ added += 1;
427
+ }
428
+ }
429
+ return added;
430
+ }
431
+ function discoverDotnetProjects(root) {
432
+ return findFilesWithExtension(root, ".csproj", 5).map((path) => {
433
+ const content = readText(root, path);
434
+ const explicitName = firstXmlValue(content, "AssemblyName") ?? firstXmlValue(content, "RootNamespace");
435
+ const name = explicitName ?? basename(path, ".csproj");
436
+ return {
437
+ name,
438
+ path,
439
+ directory: parentPath(path) || ".",
440
+ references: dotnetProjectReferences(root, path, content),
441
+ isTestProject: isDotnetTestProject(path, content),
442
+ isAspNetCoreProject: isDotnetAspNetCoreProject(content)
443
+ };
444
+ });
445
+ }
446
+ function dotnetChangedProjects(projects, paths) {
447
+ return projects.filter((project) => paths.some((path) => path === project.path ||
448
+ // A root project (directory ".") must only claim root-level files, not the
449
+ // whole tree — an empty startsWith() prefix matches every path.
450
+ (project.directory === "." ? !path.includes("/") : path.startsWith(`${project.directory}/`))));
451
+ }
452
+ function includeDownstreamDotnetProjects(changedProjects, projects) {
453
+ const affected = new Map();
454
+ const queue = [...changedProjects];
455
+ for (const project of changedProjects)
456
+ affected.set(project.path, project);
457
+ // for-of over an array visits elements pushed during iteration, so this drains
458
+ // the BFS queue including newly enqueued downstream projects.
459
+ for (const changedProject of queue) {
460
+ for (const candidate of projects) {
461
+ if (affected.has(candidate.path))
462
+ continue;
463
+ if (!candidate.references.includes(changedProject.path))
464
+ continue;
465
+ affected.set(candidate.path, candidate);
466
+ queue.push(candidate);
467
+ }
468
+ }
469
+ return [...affected.values()].sort((a, b) => a.path.localeCompare(b.path));
470
+ }
471
+ function dotnetProjectReason(project, changedProjects, affectedProjectPaths, command) {
472
+ if (changedProjects.some((changedProject) => changedProject.path === project.path)) {
473
+ return `${project.name} changed under ${project.directory}, so its .NET ${command} should run.`;
474
+ }
475
+ const upstream = project.references.find((reference) => affectedProjectPaths.has(reference));
476
+ return `${project.name} references ${upstream ?? "a changed project"}, so its .NET ${command} should run.`;
477
+ }
478
+ function dotnetProjectReferences(root, projectPath, content) {
479
+ const references = [];
480
+ const projectDir = dirname(projectPath);
481
+ const pattern = /<ProjectReference\b[^>]*\bInclude=["']([^"']+)["'][^>]*>/gi;
482
+ for (const match of content.matchAll(pattern)) {
483
+ const includePath = match[1];
484
+ if (!includePath)
485
+ continue;
486
+ const normalizedIncludePath = normalize(includePath.replaceAll("\\", "/"));
487
+ const normalizedPath = toRepoPath(relative(root, join(root, projectDir, normalizedIncludePath)));
488
+ if (normalizedPath.endsWith(".csproj"))
489
+ references.push(normalizedPath);
490
+ }
491
+ return [...new Set(references)].sort();
492
+ }
493
+ function isDotnetTestProject(path, content) {
494
+ return (/(^|[./_-])tests?([./_-]|$)/i.test(path) ||
495
+ /Microsoft\.NET\.Test\.Sdk|xunit|NUnit|MSTest\.TestFramework/i.test(content));
496
+ }
497
+ function isDotnetAspNetCoreProject(content) {
498
+ return /Sdk=["']Microsoft\.NET\.Sdk\.Web["']|Microsoft\.AspNetCore/i.test(content);
499
+ }
500
+ function firstXmlValue(content, tagName) {
501
+ const value = new RegExp(`<${tagName}>\\s*([^<]+?)\\s*</${tagName}>`, "i").exec(content)?.[1]?.trim();
502
+ if (!value)
503
+ return undefined;
504
+ return value;
505
+ }
506
+ function readText(root, path) {
507
+ try {
508
+ return readFileSync(join(root, path), "utf8");
509
+ }
510
+ catch {
511
+ return "";
512
+ }
513
+ }
514
+ function signalRootPath(root, signal) {
515
+ const projectRoot = signalProjectRoot(signal);
516
+ return projectRoot === "." ? root : join(root, projectRoot);
517
+ }
518
+ function signalProjectRoot(signal) {
519
+ if (!signal?.manifestPath.includes("/"))
520
+ return ".";
521
+ return parentPath(signal.manifestPath) || ".";
522
+ }
523
+ function isRootSignal(signal) {
524
+ return signalProjectRoot(signal) === ".";
525
+ }
526
+ function pathsForSignal(paths, signal) {
527
+ const projectRoot = signalProjectRoot(signal);
528
+ if (projectRoot === ".")
529
+ return paths;
530
+ return paths
531
+ .filter((path) => path === projectRoot || path.startsWith(`${projectRoot}/`))
532
+ .map((path) => (path === projectRoot ? "." : path.slice(projectRoot.length + 1)));
533
+ }
534
+ function stripPathPrefix(path, prefix) {
535
+ if (prefix === "." || !prefix)
536
+ return path;
537
+ if (path === prefix)
538
+ return ".";
539
+ return path.startsWith(`${prefix}/`) ? path.slice(prefix.length + 1) : path;
540
+ }
541
+ function scopedCommand(signal, command) {
542
+ const projectRoot = signalProjectRoot(signal);
543
+ return projectRoot === "." ? command : `cd ${quoteShell(projectRoot)} && ${command}`;
544
+ }
545
+ function scopedPlanId(baseId, signal) {
546
+ const projectRoot = signalProjectRoot(signal);
547
+ return projectRoot === "." ? baseId : `${baseId}-${slug(projectRoot)}`;
548
+ }
549
+ function scopedPlanLabel(label, signal) {
550
+ const projectRoot = signalProjectRoot(signal);
551
+ return projectRoot === "." ? label : `${projectRoot} ${label}`;
552
+ }
553
+ function scopedPackagePath(signal) {
554
+ const projectRoot = signalProjectRoot(signal);
555
+ return projectRoot === "." ? {} : { packagePath: projectRoot };
556
+ }
557
+ function addPythonPlans(plans, root, paths, signal) {
558
+ const signalRoot = signalRootPath(root, signal);
559
+ const scopedPaths = pathsForSignal(paths, signal);
560
+ const testTargets = pythonChangedTestTargets(signalRoot, scopedPaths, signal);
561
+ const python = pythonToolRunner(signalRoot);
562
+ pushUnique(plans, {
563
+ id: scopedPlanId(testTargets.length > 0 ? "python-targeted-tests" : "python-tests", signal),
564
+ label: scopedPlanLabel(testTargets.length > 0 ? "Python targeted tests" : "Python tests", signal),
565
+ command: scopedCommand(signal, testTargets.length > 0 ? `${pythonCommand(python, "pytest")} ${testTargets.map(quoteShell).join(" ")}` : pythonCommand(python, "pytest")),
566
+ reason: testTargets.length > 0
567
+ ? "Python source changes have matching changed-test or FastAPI dependency override targets on disk."
568
+ : "Python files or Python project metadata changed.",
569
+ ecosystem: "python",
570
+ required: true,
571
+ ...scopedPackagePath(signal)
572
+ });
573
+ pushUnique(plans, {
574
+ id: scopedPlanId("python-compile", signal),
575
+ label: scopedPlanLabel("Python syntax compile", signal),
576
+ command: scopedCommand(signal, "python -m compileall ."),
577
+ reason: "Compile Python files to catch syntax errors without needing project-specific tooling.",
578
+ ecosystem: "python",
579
+ required: true,
580
+ ...scopedPackagePath(signal)
581
+ });
582
+ addPythonStaticAnalysisPlans(plans, signalRoot, python, signal);
583
+ }
584
+ function addRubyPlans(plans, root, signal) {
585
+ if (rubyUsesRSpec(root)) {
586
+ pushUnique(plans, {
587
+ id: "ruby-rspec",
588
+ label: "Ruby RSpec tests",
589
+ command: "bundle exec rspec",
590
+ reason: "Ruby source, specs, or dependency metadata changed and the project declares RSpec.",
591
+ ecosystem: "ruby",
592
+ required: true
593
+ });
594
+ return;
595
+ }
596
+ if (signal.framework === "rails") {
597
+ pushUnique(plans, {
598
+ id: "rails-tests",
599
+ label: "Rails tests",
600
+ command: `${railsCommand(root)} test`,
601
+ reason: "Rails source, tests, or dependency metadata changed, so the Rails test runner should execute.",
602
+ ecosystem: "ruby",
603
+ required: true
604
+ });
605
+ return;
606
+ }
607
+ pushUnique(plans, {
608
+ id: "ruby-tests",
609
+ label: "Ruby tests",
610
+ command: "bundle exec rake test",
611
+ reason: "Ruby source or dependency metadata changed.",
612
+ ecosystem: "ruby",
613
+ required: true
614
+ });
615
+ }
616
+ function rubyUsesRSpec(root) {
617
+ if (existsSync(join(root, "spec")))
618
+ return true;
619
+ const manifest = `${readText(root, "Gemfile")}\n${readText(root, "Gemfile.lock")}`.toLowerCase();
620
+ return /\brspec(?:-rails)?\b/.test(manifest);
621
+ }
622
+ function railsCommand(root) {
623
+ return existsSync(join(root, "bin", "rails")) ? "bin/rails" : "bundle exec rails";
624
+ }
625
+ function pythonToolRunner(root) {
626
+ if (existsSync(join(root, "uv.lock")))
627
+ return "uv";
628
+ if (existsSync(join(root, "poetry.lock")))
629
+ return "poetry";
630
+ if (existsSync(join(root, "Pipfile")) || existsSync(join(root, "Pipfile.lock")))
631
+ return "pipenv";
632
+ return "python";
633
+ }
634
+ function pythonCommand(runner, tool, args = "") {
635
+ const suffix = args ? ` ${args}` : "";
636
+ if (runner === "uv")
637
+ return `uv run ${tool}${suffix}`;
638
+ if (runner === "poetry")
639
+ return `poetry run ${tool}${suffix}`;
640
+ if (runner === "pipenv")
641
+ return `pipenv run ${tool}${suffix}`;
642
+ return `python -m ${tool}${suffix}`;
643
+ }
644
+ function addPythonStaticAnalysisPlans(plans, root, runner, signal) {
645
+ if (pythonUsesRuff(root)) {
646
+ pushUnique(plans, {
647
+ id: scopedPlanId("python-ruff", signal),
648
+ label: scopedPlanLabel("Python Ruff lint", signal),
649
+ command: scopedCommand(signal, pythonCommand(runner, "ruff", "check .")),
650
+ reason: "Python Ruff configuration or dependency metadata was detected.",
651
+ ecosystem: "python",
652
+ required: false,
653
+ ...scopedPackagePath(signal)
654
+ });
655
+ }
656
+ if (pythonUsesMypy(root)) {
657
+ pushUnique(plans, {
658
+ id: scopedPlanId("python-mypy", signal),
659
+ label: scopedPlanLabel("Python mypy types", signal),
660
+ command: scopedCommand(signal, pythonCommand(runner, "mypy", ".")),
661
+ reason: "Python mypy configuration or dependency metadata was detected.",
662
+ ecosystem: "python",
663
+ required: false,
664
+ ...scopedPackagePath(signal)
665
+ });
666
+ }
667
+ if (pythonUsesPyright(root)) {
668
+ pushUnique(plans, {
669
+ id: scopedPlanId("python-pyright", signal),
670
+ label: scopedPlanLabel("Python Pyright types", signal),
671
+ command: scopedCommand(signal, pythonCommand(runner, "pyright")),
672
+ reason: "Python Pyright configuration or dependency metadata was detected.",
673
+ ecosystem: "python",
674
+ required: false,
675
+ ...scopedPackagePath(signal)
676
+ });
677
+ }
678
+ }
679
+ function pythonUsesRuff(root) {
680
+ return (existsSync(join(root, "ruff.toml")) ||
681
+ existsSync(join(root, ".ruff.toml")) ||
682
+ pythonConfigMentions(root, /\[tool\.ruff(?:\.|\])/i) ||
683
+ pythonManifestMentions(root, "ruff"));
684
+ }
685
+ function pythonUsesMypy(root) {
686
+ return (existsSync(join(root, "mypy.ini")) ||
687
+ existsSync(join(root, ".mypy.ini")) ||
688
+ pythonConfigMentions(root, /\[(?:tool\.mypy|mypy(?:-[^\]]+)?)\]/i) ||
689
+ pythonManifestMentions(root, "mypy"));
690
+ }
691
+ function pythonUsesPyright(root) {
692
+ return existsSync(join(root, "pyrightconfig.json")) || pythonConfigMentions(root, /\[tool\.pyright\]/i) || pythonManifestMentions(root, "pyright");
693
+ }
694
+ function pythonConfigMentions(root, pattern) {
695
+ return ["pyproject.toml", "setup.cfg"].some((path) => pattern.test(readText(root, path)));
696
+ }
697
+ function pythonManifestMentions(root, tool) {
698
+ const pattern = new RegExp(`(^|[^a-z0-9_.-])${escapeRegExp(tool.toLowerCase())}([^a-z0-9_.-]|$)`, "i");
699
+ return ["pyproject.toml", "requirements.txt", "setup.py", "setup.cfg"].some((path) => pattern.test(readText(root, path).toLowerCase().replaceAll("_", "-")));
700
+ }
701
+ function addPhpPlans(plans, root, paths, signal) {
702
+ if (touchesComposerMetadata(paths)) {
703
+ pushUnique(plans, {
704
+ id: "php-composer-validate",
705
+ label: "Composer validate",
706
+ command: "composer validate --strict",
707
+ reason: "Composer dependency metadata changed, so composer.json and composer.lock should validate before tests run.",
708
+ ecosystem: "php",
709
+ required: true
710
+ });
711
+ }
712
+ const composerScript = phpComposerTestScript(signal);
713
+ if (composerScript) {
714
+ pushUnique(plans, {
715
+ id: `php-composer-${slug(composerScript)}`,
716
+ label: `Composer ${composerScript}`,
717
+ command: `composer ${quoteShell(composerScript)}`,
718
+ reason: `composer.json defines the "${composerScript}" script for PHP verification.`,
719
+ ecosystem: "php",
720
+ required: true
721
+ });
722
+ return;
723
+ }
724
+ if (signal.framework === "laravel") {
725
+ pushUnique(plans, {
726
+ id: "laravel-tests",
727
+ label: "Laravel tests",
728
+ command: "php artisan test",
729
+ reason: "Laravel source or Composer metadata changed, so Laravel's test runner should execute.",
730
+ ecosystem: "php",
731
+ required: true
732
+ });
733
+ return;
734
+ }
735
+ if (phpUsesPhpUnit(root)) {
736
+ pushUnique(plans, {
737
+ id: "phpunit-tests",
738
+ label: "PHPUnit tests",
739
+ command: "vendor/bin/phpunit",
740
+ reason: "PHP source or Composer metadata changed and the project declares PHPUnit.",
741
+ ecosystem: "php",
742
+ required: true
743
+ });
744
+ return;
745
+ }
746
+ pushUnique(plans, {
747
+ id: "php-syntax-check",
748
+ label: "PHP syntax check",
749
+ command: "find . -name '*.php' -not -path './vendor/*' -exec php -l {} \\;",
750
+ reason: "PHP source changed but no test script, Laravel runner, or PHPUnit configuration was detected.",
751
+ ecosystem: "php",
752
+ required: true
753
+ });
754
+ }
755
+ function touchesComposerMetadata(paths) {
756
+ return paths.some((path) => path === "composer.json" || path === "composer.lock");
757
+ }
758
+ function phpComposerTestScript(signal) {
759
+ const scripts = signal.scripts ?? {};
760
+ for (const name of ["test", "tests", "phpunit"]) {
761
+ if (scripts[name])
762
+ return name;
763
+ }
764
+ return undefined;
765
+ }
766
+ function phpUsesPhpUnit(root) {
767
+ if (existsSync(join(root, "phpunit.xml")) || existsSync(join(root, "phpunit.xml.dist")))
768
+ return true;
769
+ return readText(root, "composer.json").toLowerCase().includes("phpunit/phpunit");
770
+ }
771
+ function pythonChangedTestTargets(root, paths, signal) {
772
+ const targets = new Set();
773
+ for (const path of paths) {
774
+ if (!path.endsWith(".py"))
775
+ continue;
776
+ if (isPythonTestPath(path) && existsSync(join(root, path))) {
777
+ targets.add(path);
778
+ continue;
779
+ }
780
+ for (const candidate of pythonTestCandidates(path)) {
781
+ if (existsSync(join(root, candidate)))
782
+ targets.add(candidate);
783
+ }
784
+ }
785
+ if (signal?.framework === "fastapi") {
786
+ for (const target of fastApiDependencyOverrideTestTargets(root, paths))
787
+ targets.add(target);
788
+ }
789
+ return [...targets].sort();
790
+ }
791
+ function fastApiDependencyOverrideTestTargets(root, paths) {
792
+ const dependencyModules = paths
793
+ .filter((path) => isFastApiDependencyPath(path))
794
+ .map((path) => ({
795
+ module: pythonImportModuleName(path),
796
+ functionNames: pythonDefinedFunctionNames(root, path)
797
+ }))
798
+ .filter((target) => Boolean(target.module) && target.functionNames.length > 0);
799
+ if (dependencyModules.length === 0)
800
+ return [];
801
+ return findFilesWithExtension(root, ".py", 7)
802
+ .filter((path) => isPythonTestPath(path))
803
+ .filter((path) => testOverridesFastApiDependency(readText(root, path), dependencyModules))
804
+ .sort();
805
+ }
806
+ function isFastApiDependencyPath(path) {
807
+ if (!path.endsWith(".py"))
808
+ return false;
809
+ return /(^|\/)(dependencies|deps)\.py$/.test(path) || /(^|\/)(dependencies|deps)\//.test(path);
810
+ }
811
+ function pythonDefinedFunctionNames(root, path) {
812
+ const names = new Set();
813
+ const content = readText(root, path);
814
+ for (const match of content.matchAll(/^\s*(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/gm)) {
815
+ if (match[1])
816
+ names.add(match[1]);
817
+ }
818
+ return [...names].sort();
819
+ }
820
+ function testOverridesFastApiDependency(content, dependencyModules) {
821
+ if (!content.includes("dependency_overrides"))
822
+ return false;
823
+ const overrideRefs = fastApiDependencyOverrideRefs(content);
824
+ if (overrideRefs.length === 0)
825
+ return false;
826
+ return dependencyModules.some((dependencyModule) => {
827
+ const imports = pythonImportsForModule(content, dependencyModule.module);
828
+ for (const ref of overrideRefs) {
829
+ const [qualifier, name] = splitPythonAttributeRef(ref);
830
+ if (name && imports.moduleAliases.has(qualifier) && dependencyModule.functionNames.includes(name))
831
+ return true;
832
+ if (!name && imports.directNames.has(qualifier) && dependencyModule.functionNames.includes(qualifier))
833
+ return true;
834
+ }
835
+ return false;
836
+ });
837
+ }
838
+ function fastApiDependencyOverrideRefs(content) {
839
+ const refs = new Set();
840
+ for (const match of content.matchAll(/dependency_overrides\s*\[\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\s*\]/g)) {
841
+ if (match[1])
842
+ refs.add(match[1]);
843
+ }
844
+ return [...refs].sort();
845
+ }
846
+ function pythonImportsForModule(content, moduleName) {
847
+ const directNames = new Set();
848
+ const moduleAliases = new Set();
849
+ const escapedModule = escapeRegExp(moduleName);
850
+ for (const match of content.matchAll(new RegExp(`^\\s*from\\s+${escapedModule}\\s+import\\s+(.+)$`, "gm"))) {
851
+ const imports = match[1] ?? "";
852
+ for (const imported of imports.split(",")) {
853
+ const name = imported.trim().replace(/[()]/g, "").split(/\s+as\s+/i).at(-1)?.trim();
854
+ if (name && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
855
+ directNames.add(name);
856
+ }
857
+ }
858
+ for (const match of content.matchAll(new RegExp(`^\\s*import\\s+${escapedModule}\\s+as\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*$`, "gm"))) {
859
+ if (match[1])
860
+ moduleAliases.add(match[1]);
861
+ }
862
+ return { directNames, moduleAliases };
863
+ }
864
+ function splitPythonAttributeRef(value) {
865
+ const dotIndex = value.lastIndexOf(".");
866
+ if (dotIndex < 0)
867
+ return [value, undefined];
868
+ return [value.slice(0, dotIndex), value.slice(dotIndex + 1)];
869
+ }
870
+ function pythonTestCandidates(path) {
871
+ const parsed = parsePath(path);
872
+ if (!parsed)
873
+ return [];
874
+ const testNames = [`test_${parsed.name}${parsed.extension}`, `${parsed.name}_test${parsed.extension}`];
875
+ const candidates = new Set();
876
+ const directories = [parsed.directory];
877
+ if (parsed.directory.startsWith("src/"))
878
+ directories.push(parsed.directory.slice("src/".length));
879
+ if (parsed.directory.startsWith("app/"))
880
+ directories.push(parsed.directory.slice("app/".length));
881
+ for (const directory of directories.filter((value, index, values) => values.indexOf(value) === index)) {
882
+ for (const testName of testNames) {
883
+ candidates.add(joinPath(directory, testName));
884
+ candidates.add(joinPath(directory, "tests", testName));
885
+ candidates.add(joinPath("tests", directory, testName));
886
+ candidates.add(joinPath("test", directory, testName));
887
+ candidates.add(joinPath("tests", testName));
888
+ candidates.add(joinPath("test", testName));
889
+ }
890
+ }
891
+ return [...candidates];
892
+ }
893
+ function isPythonTestPath(path) {
894
+ return /(^|\/)(tests?|spec)\//i.test(path) || /(^|\/)test_[^/]+\.py$/i.test(path) || /_test\.py$/i.test(path);
895
+ }
896
+ function parsePath(path) {
897
+ const slash = path.lastIndexOf("/");
898
+ const directory = slash >= 0 ? path.slice(0, slash) : "";
899
+ const fileName = slash >= 0 ? path.slice(slash + 1) : path;
900
+ const dot = fileName.lastIndexOf(".");
901
+ if (dot <= 0)
902
+ return undefined;
903
+ return {
904
+ directory,
905
+ name: fileName.slice(0, dot),
906
+ extension: fileName.slice(dot)
907
+ };
908
+ }
909
+ function joinPath(...parts) {
910
+ return parts.filter(Boolean).join("/");
911
+ }
912
+ function addDjangoPlans(plans, signal) {
913
+ pushUnique(plans, {
914
+ id: scopedPlanId("django-tests", signal),
915
+ label: scopedPlanLabel("Django tests", signal),
916
+ command: scopedCommand(signal, "python manage.py test"),
917
+ reason: "Django app code or framework metadata changed, so the Django test runner should load settings, apps, migrations, and tests.",
918
+ ecosystem: "python",
919
+ required: true,
920
+ ...scopedPackagePath(signal)
921
+ });
922
+ pushUnique(plans, {
923
+ id: scopedPlanId("django-check", signal),
924
+ label: scopedPlanLabel("Django system check", signal),
925
+ command: scopedCommand(signal, "python manage.py check"),
926
+ reason: "Django system checks catch model, settings, URL, and app registry issues before deployment.",
927
+ ecosystem: "python",
928
+ required: false,
929
+ ...scopedPackagePath(signal)
930
+ });
931
+ pushUnique(plans, {
932
+ id: scopedPlanId("python-compile", signal),
933
+ label: scopedPlanLabel("Python syntax compile", signal),
934
+ command: scopedCommand(signal, "python -m compileall ."),
935
+ reason: "Compile Python files to catch syntax errors in modules not imported by Django tests.",
936
+ ecosystem: "python",
937
+ required: true,
938
+ ...scopedPackagePath(signal)
939
+ });
940
+ }
941
+ function addFastApiPlans(plans, paths, entrypoint, signal) {
942
+ if (!isPythonEntrypoint(entrypoint))
943
+ return;
944
+ pushUnique(plans, {
945
+ id: scopedPlanId("fastapi-import-smoke", signal),
946
+ label: scopedPlanLabel("FastAPI import smoke", signal),
947
+ command: scopedCommand(signal, `python -c "import importlib, sys; sys.path[:0] = ['src', '.']; target = '${entrypoint}'; module, attr = target.split(':', 1); getattr(importlib.import_module(module), attr)"`),
948
+ reason: "FastAPI app entrypoints should import cleanly so route modules, startup wiring, and dependency setup are not obviously broken.",
949
+ ecosystem: "python",
950
+ required: false,
951
+ ...scopedPackagePath(signal)
952
+ });
953
+ const modules = fastApiChangedImportModules(paths);
954
+ if (modules.length === 0)
955
+ return;
956
+ pushUnique(plans, {
957
+ id: scopedPlanId("fastapi-module-import-smoke", signal),
958
+ label: scopedPlanLabel("FastAPI changed module import smoke", signal),
959
+ command: scopedCommand(signal, `python -c "import importlib, sys; sys.path[:0] = ['src', '.']; targets = [${modules.map((module) => `'${module}'`).join(", ")}]; [importlib.import_module(target) for target in targets]"`),
960
+ reason: "Changed FastAPI router or dependency modules should import cleanly before the full app startup path is trusted.",
961
+ ecosystem: "python",
962
+ required: false,
963
+ ...scopedPackagePath(signal)
964
+ });
965
+ }
966
+ function isPythonEntrypoint(value) {
967
+ return /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*:[A-Za-z_][A-Za-z0-9_]*$/.test(value);
968
+ }
969
+ function addXcodePlans(plans, root, paths, signal) {
970
+ const schemes = xcodeTargetSchemes(root, paths, signal.manifestPath);
971
+ const subject = xcodeBuildSubject(signal.manifestPath);
972
+ if (schemes.length === 0) {
973
+ pushUnique(plans, {
974
+ id: "xcode-list-schemes",
975
+ label: "Xcode scheme listing",
976
+ command: `xcodebuild ${subject} -list`,
977
+ reason: "Xcode project files changed, but no shared scheme was found in the repository. List schemes before choosing a build or test command.",
978
+ ecosystem: "xcode",
979
+ required: false,
980
+ packagePath: signal.manifestPath
981
+ });
982
+ return;
983
+ }
984
+ for (const scheme of schemes) {
985
+ const testPlanArg = scheme.testPlan ? ` -testPlan ${quoteShell(scheme.testPlan)}` : "";
986
+ const testDestinationArg = xcodeDestinationArg(scheme.destination, "test");
987
+ const buildDestinationArg = xcodeDestinationArg(scheme.destination, "build");
988
+ pushUnique(plans, {
989
+ id: `xcode-${slug(scheme.name)}-tests`,
990
+ label: `${scheme.name} Xcode tests`,
991
+ command: `xcodebuild ${subject} -scheme ${quoteShell(scheme.name)}${testPlanArg}${testDestinationArg} test`,
992
+ reason: scheme.testPlan
993
+ ? `${scheme.name} is an Xcode shared scheme for ${signal.manifestPath} with test plan ${scheme.testPlan}, so changed app or test files should run through that xcodebuild test plan${testDestinationArg ? ` on ${scheme.destination?.label}` : ""}.`
994
+ : `${scheme.name} is an Xcode shared scheme for ${signal.manifestPath}, so changed app or test files should run through xcodebuild test${testDestinationArg ? ` on ${scheme.destination?.label}` : ""}.`,
995
+ ecosystem: "xcode",
996
+ required: true,
997
+ packageName: scheme.name,
998
+ packagePath: signal.manifestPath
999
+ });
1000
+ if (scheme.destination && !scheme.destination.testSpecifier) {
1001
+ pushUnique(plans, {
1002
+ id: `xcode-${slug(scheme.name)}-destinations`,
1003
+ label: `${scheme.name} Xcode destinations`,
1004
+ command: `xcodebuild ${subject} -scheme ${quoteShell(scheme.name)} -showdestinations`,
1005
+ reason: `${scheme.name} targets ${scheme.destination.label}; xcodebuild test needs a concrete simulator or device name, so list valid destinations before pinning CI to one.`,
1006
+ ecosystem: "xcode",
1007
+ required: false,
1008
+ packageName: scheme.name,
1009
+ packagePath: signal.manifestPath
1010
+ });
1011
+ }
1012
+ pushUnique(plans, {
1013
+ id: `xcode-${slug(scheme.name)}-build`,
1014
+ label: `${scheme.name} Xcode build`,
1015
+ command: `xcodebuild ${subject} -scheme ${quoteShell(scheme.name)}${buildDestinationArg} build`,
1016
+ reason: `${scheme.name} should still compile through Xcode after project, source, resource, or signing metadata changes${buildDestinationArg ? ` using the ${scheme.destination?.label} destination.` : "."}`,
1017
+ ecosystem: "xcode",
1018
+ required: false,
1019
+ packageName: scheme.name,
1020
+ packagePath: signal.manifestPath
1021
+ });
1022
+ }
1023
+ }
1024
+ function xcodeBuildSubject(manifestPath) {
1025
+ if (manifestPath.endsWith(".xcworkspace"))
1026
+ return `-workspace ${quoteShell(manifestPath)}`;
1027
+ return `-project ${quoteShell(manifestPath)}`;
1028
+ }
1029
+ function xcodeTargetSchemes(root, paths, manifestPath) {
1030
+ const changedSchemes = paths.filter((path) => path.endsWith(".xcscheme")).map((path) => xcodeScheme(root, path, manifestPath));
1031
+ if (changedSchemes.length > 0)
1032
+ return uniqueXcodeSchemes(changedSchemes);
1033
+ const manifestSchemes = xcodeSharedSchemes(root, manifestPath);
1034
+ if (manifestSchemes.length > 0)
1035
+ return manifestSchemes;
1036
+ return uniqueXcodeSchemes(findFilesWithExtension(root, ".xcscheme", 7)
1037
+ .filter((path) => path.includes("/xcshareddata/xcschemes/"))
1038
+ .map((path) => xcodeScheme(root, path, manifestPath)));
1039
+ }
1040
+ function xcodeSharedSchemes(root, manifestPath) {
1041
+ const schemeRoot = joinPath(manifestPath, "xcshareddata", "xcschemes");
1042
+ return uniqueXcodeSchemes(findFilesWithExtension(root, ".xcscheme", 7)
1043
+ .filter((path) => path.startsWith(`${schemeRoot}/`))
1044
+ .map((path) => xcodeScheme(root, path, manifestPath)));
1045
+ }
1046
+ function xcodeScheme(root, path, manifestPath) {
1047
+ const testPlan = xcodeSchemeTestPlan(root, path);
1048
+ const destination = xcodeSchemeDestination(root, path, manifestPath);
1049
+ return {
1050
+ name: basename(path, ".xcscheme"),
1051
+ ...(testPlan ? { testPlan } : {}),
1052
+ ...(destination ? { destination } : {})
1053
+ };
1054
+ }
1055
+ function xcodeSchemeTestPlan(root, path) {
1056
+ const content = readText(root, path);
1057
+ const references = [...content.matchAll(/<TestPlanReference\b[^>]*\breference\s*=\s*"([^"]+)"[^>]*>/gi)];
1058
+ const defaultReference = references.find((match) => /\bdefault\s*=\s*"YES"/i.test(match[0]));
1059
+ return xcodeTestPlanName(defaultReference?.[1] ?? references[0]?.[1]);
1060
+ }
1061
+ function xcodeTestPlanName(reference) {
1062
+ if (!reference)
1063
+ return undefined;
1064
+ const cleanReference = reference.replace(/^container:/, "");
1065
+ if (!cleanReference.endsWith(".xctestplan"))
1066
+ return undefined;
1067
+ return basename(cleanReference, ".xctestplan");
1068
+ }
1069
+ function xcodeSchemeDestination(root, path, manifestPath) {
1070
+ const content = readText(root, path);
1071
+ const platforms = uniqueStrings([...content.matchAll(/<BuildableReference\b[^>]*>/gi)]
1072
+ .map((match) => xcodeBuildableReferencePlatform(root, path, manifestPath, match[0]))
1073
+ .filter((platform) => Boolean(platform)));
1074
+ if (platforms.length !== 1)
1075
+ return undefined;
1076
+ return xcodeDestinationForPlatform(platforms[0]);
1077
+ }
1078
+ function xcodeBuildableReferencePlatform(root, schemePath, manifestPath, tag) {
1079
+ const targetId = xmlAttribute(tag, "BlueprintIdentifier");
1080
+ if (!targetId)
1081
+ return undefined;
1082
+ const projectPath = xcodeReferencedProjectPath(root, schemePath, manifestPath, xmlAttribute(tag, "ReferencedContainer"));
1083
+ if (!projectPath)
1084
+ return undefined;
1085
+ return xcodeProjectTargetPlatform(root, projectPath, targetId);
1086
+ }
1087
+ function xcodeReferencedProjectPath(root, schemePath, manifestPath, reference) {
1088
+ const rawReference = reference?.replace(/^container:/, "").trim();
1089
+ if (!rawReference)
1090
+ return manifestPath.endsWith(".xcodeproj") ? manifestPath : undefined;
1091
+ const directPath = normalizeRepoPath(rawReference);
1092
+ if (directPath?.endsWith(".xcodeproj") && existsSync(join(root, directPath, "project.pbxproj")))
1093
+ return directPath;
1094
+ const schemeContainer = xcodeSchemeContainerPath(schemePath);
1095
+ const containerParent = schemeContainer ? dirname(schemeContainer).replaceAll("\\", "/") : "";
1096
+ const relativePath = normalizeRepoPath(joinPath(containerParent === "." ? "" : containerParent, rawReference));
1097
+ if (relativePath?.endsWith(".xcodeproj") && existsSync(join(root, relativePath, "project.pbxproj")))
1098
+ return relativePath;
1099
+ if (manifestPath.endsWith(".xcodeproj"))
1100
+ return manifestPath;
1101
+ return directPath?.endsWith(".xcodeproj") ? directPath : undefined;
1102
+ }
1103
+ function xcodeProjectTargetPlatform(root, projectPath, targetId) {
1104
+ const content = readText(root, joinPath(projectPath, "project.pbxproj"));
1105
+ const targetBlock = xcodeObjectBlock(content, targetId);
1106
+ if (!targetBlock)
1107
+ return undefined;
1108
+ const buildConfigurationIds = xcodeTargetBuildConfigurationIds(content, targetBlock);
1109
+ for (const configurationId of buildConfigurationIds) {
1110
+ const platform = xcodeBuildConfigurationPlatform(content, configurationId);
1111
+ if (platform)
1112
+ return platform;
1113
+ }
1114
+ return xcodeProductTypePlatform(firstPbxValue(targetBlock, "productType"));
1115
+ }
1116
+ function xcodeTargetBuildConfigurationIds(content, targetBlock) {
1117
+ const configurationListId = firstPbxValue(targetBlock, "buildConfigurationList");
1118
+ if (!configurationListId)
1119
+ return [];
1120
+ const configurationListBlock = xcodeObjectBlock(content, configurationListId);
1121
+ const configurations = /buildConfigurations\s*=\s*\(([\s\S]*?)\);/.exec(configurationListBlock ?? "");
1122
+ const configurationIds = configurations?.[1];
1123
+ if (!configurationIds)
1124
+ return [];
1125
+ return [...configurationIds.matchAll(/\b([A-Za-z0-9_]+)\b\s*(?:\/\*[\s\S]*?\*\/)?\s*,/g)]
1126
+ .map((match) => match[1])
1127
+ .filter((id) => Boolean(id));
1128
+ }
1129
+ function xcodeBuildConfigurationPlatform(content, configurationId) {
1130
+ const block = xcodeObjectBlock(content, configurationId);
1131
+ if (!block)
1132
+ return undefined;
1133
+ return (xcodePlatformFromTokens(xcodeBuildSettingValues(block, "SDKROOT")) ??
1134
+ xcodePlatformFromTokens(xcodeBuildSettingValues(block, "SUPPORTED_PLATFORMS")));
1135
+ }
1136
+ function xcodeBuildSettingValues(content, key) {
1137
+ return [...content.matchAll(new RegExp(`\\b${escapeRegExp(key)}\\s*=\\s*([^;]+);`, "g"))]
1138
+ .flatMap((match) => (match[1] ?? "").split(/[\s"',()]+/))
1139
+ .map((value) => value.trim())
1140
+ .filter(Boolean);
1141
+ }
1142
+ function xcodePlatformFromTokens(tokens) {
1143
+ const value = tokens.join(" ").toLowerCase();
1144
+ if (/\bmacosx\b/.test(value))
1145
+ return "macos";
1146
+ if (/\b(xros|xrsimulator|visionos)\b/.test(value))
1147
+ return "visionos";
1148
+ if (/\b(watchos|watchsimulator)\b/.test(value))
1149
+ return "watchos";
1150
+ if (/\b(appletvos|appletvsimulator)\b/.test(value))
1151
+ return "tvos";
1152
+ if (/\b(iphoneos|iphonesimulator)\b/.test(value))
1153
+ return "ios";
1154
+ return undefined;
1155
+ }
1156
+ function xcodeProductTypePlatform(productType) {
1157
+ if (!productType)
1158
+ return undefined;
1159
+ const value = productType.toLowerCase();
1160
+ if (value.includes("watch"))
1161
+ return "watchos";
1162
+ if (value.includes("tv"))
1163
+ return "tvos";
1164
+ return undefined;
1165
+ }
1166
+ function xcodeDestinationForPlatform(platform) {
1167
+ switch (platform) {
1168
+ case "macos":
1169
+ return { label: "macOS", buildSpecifier: "platform=macOS", testSpecifier: "platform=macOS" };
1170
+ case "visionos":
1171
+ return { label: "visionOS", buildSpecifier: "generic/platform=visionOS" };
1172
+ case "watchos":
1173
+ return { label: "watchOS", buildSpecifier: "generic/platform=watchOS" };
1174
+ case "tvos":
1175
+ return { label: "tvOS", buildSpecifier: "generic/platform=tvOS" };
1176
+ case "ios":
1177
+ return { label: "iOS", buildSpecifier: "generic/platform=iOS" };
1178
+ }
1179
+ }
1180
+ function xcodeDestinationArg(destination, action) {
1181
+ const specifier = action === "test" ? destination?.testSpecifier : destination?.buildSpecifier;
1182
+ return specifier ? ` -destination ${quoteShell(specifier)}` : "";
1183
+ }
1184
+ function xcodeObjectBlock(content, id) {
1185
+ const match = new RegExp(`\\b${escapeRegExp(id)}\\b\\s*(?:\\/\\*[\\s\\S]*?\\*\\/\\s*)?=\\s*\\{`).exec(content);
1186
+ if (!match)
1187
+ return undefined;
1188
+ const start = match.index + match[0].lastIndexOf("{");
1189
+ let depth = 0;
1190
+ for (let index = start; index < content.length; index += 1) {
1191
+ const char = content[index];
1192
+ if (char === "{")
1193
+ depth += 1;
1194
+ if (char === "}") {
1195
+ depth -= 1;
1196
+ if (depth === 0)
1197
+ return content.slice(start + 1, index);
1198
+ }
1199
+ }
1200
+ return undefined;
1201
+ }
1202
+ function firstPbxValue(content, key) {
1203
+ return new RegExp(`\\b${escapeRegExp(key)}\\s*=\\s*([^;]+);`).exec(content)?.[1]?.replace(/\/\*[\s\S]*?\*\//g, "").replaceAll('"', "").trim();
1204
+ }
1205
+ function xmlAttribute(tag, name) {
1206
+ return new RegExp(`\\b${escapeRegExp(name)}\\s*=\\s*["']([^"']+)["']`, "i").exec(tag)?.[1];
1207
+ }
1208
+ function xcodeSchemeContainerPath(path) {
1209
+ const parts = path.split("/");
1210
+ const index = parts.findIndex((part) => part.endsWith(".xcodeproj") || part.endsWith(".xcworkspace"));
1211
+ return index >= 0 ? parts.slice(0, index + 1).join("/") : undefined;
1212
+ }
1213
+ function normalizeRepoPath(path) {
1214
+ const normalized = normalize(path).replaceAll("\\", "/").replace(/^\.\//, "");
1215
+ if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/"))
1216
+ return undefined;
1217
+ return normalized;
1218
+ }
1219
+ function uniqueXcodeSchemes(schemes) {
1220
+ const byName = new Map();
1221
+ for (const scheme of schemes) {
1222
+ if (!scheme.name || byName.has(scheme.name))
1223
+ continue;
1224
+ byName.set(scheme.name, scheme);
1225
+ }
1226
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
1227
+ }
1228
+ function fastApiChangedImportModules(paths) {
1229
+ const modules = new Set();
1230
+ for (const path of paths) {
1231
+ if (!isFastApiImportSmokePath(path))
1232
+ continue;
1233
+ const module = pythonImportModuleName(path);
1234
+ if (module)
1235
+ modules.add(module);
1236
+ }
1237
+ return [...modules].sort();
1238
+ }
1239
+ function isFastApiImportSmokePath(path) {
1240
+ if (!path.endsWith(".py"))
1241
+ return false;
1242
+ return /(^|\/)routers?\//.test(path) || /(^|\/)(dependencies|deps)\.py$/.test(path) || /(^|\/)(dependencies|deps)\//.test(path);
1243
+ }
1244
+ function pythonImportModuleName(path) {
1245
+ const withoutSourceRoot = path.startsWith("src/") ? path.slice("src/".length) : path;
1246
+ const withoutExtension = withoutSourceRoot.replace(/\.py$/, "");
1247
+ if (withoutExtension.endsWith(".__init__"))
1248
+ return withoutExtension.slice(0, -"__init__".length - 1);
1249
+ const moduleName = withoutExtension.replaceAll("/", ".");
1250
+ return moduleName.split(".").every((part) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(part)) ? moduleName : undefined;
1251
+ }
1252
+ function addJavaPlans(plans, root, signal) {
1253
+ const buildTool = javaBuildTool(root, signal);
1254
+ pushUnique(plans, {
1255
+ id: "java-tests",
1256
+ label: "Java tests",
1257
+ command: javaTestCommand(root, buildTool),
1258
+ reason: "Java/Kotlin source or build metadata changed.",
1259
+ ecosystem: "java",
1260
+ required: true
1261
+ });
1262
+ if (signal.framework === "spring-boot") {
1263
+ pushUnique(plans, {
1264
+ id: "spring-boot-package",
1265
+ label: "Spring Boot package",
1266
+ command: springBootPackageCommand(root, buildTool),
1267
+ reason: "Spring Boot applications should still produce an executable application artifact after source or build changes.",
1268
+ ecosystem: "java",
1269
+ required: false
1270
+ });
1271
+ }
1272
+ }
1273
+ function addAndroidPlans(plans, root, paths) {
1274
+ const gradle = gradleCommand(root);
1275
+ const variant = androidVariantFromPaths(root, paths) ?? "Debug";
1276
+ const variantSlug = slug(androidVariantSlug(variant));
1277
+ pushUnique(plans, {
1278
+ id: `android-${variantSlug}-unit-tests`,
1279
+ label: `Android ${variant} unit tests`,
1280
+ command: `${gradle} test${variant}UnitTest`,
1281
+ reason: `Android source, resources, manifest, or Gradle metadata changed, so ${variant} JVM unit tests should run through the Android Gradle plugin.`,
1282
+ ecosystem: "android",
1283
+ required: true
1284
+ });
1285
+ pushUnique(plans, {
1286
+ id: `android-${variantSlug}-assemble`,
1287
+ label: `Android ${variant} assemble`,
1288
+ command: `${gradle} assemble${variant}`,
1289
+ reason: `Android changes should still compile resources, manifests, generated code, and the ${variant} artifact.`,
1290
+ ecosystem: "android",
1291
+ required: false
1292
+ });
1293
+ pushUnique(plans, {
1294
+ id: `android-${variantSlug}-lint`,
1295
+ label: `Android ${variant} lint`,
1296
+ command: `${gradle} lint${variant}`,
1297
+ reason: `Android ${variant} lint catches manifest, resource, API, and lifecycle issues that normal JVM tests can miss.`,
1298
+ ecosystem: "android",
1299
+ required: false
1300
+ });
1301
+ }
1302
+ function androidVariantFromPaths(root, paths) {
1303
+ const variants = new Set();
1304
+ for (const path of paths) {
1305
+ const generatedVariant = androidGeneratedVariantFromPath(root, path);
1306
+ if (generatedVariant) {
1307
+ variants.add(androidEnabledVariant(root, path, generatedVariant));
1308
+ continue;
1309
+ }
1310
+ const sourceSet = androidSourceSet(path);
1311
+ if (!sourceSet)
1312
+ continue;
1313
+ const variant = androidVariantFromSourceSet(sourceSet) ?? androidVariantFromFlavorSourceSet(root, path, sourceSet);
1314
+ if (variant)
1315
+ variants.add(androidEnabledVariant(root, path, variant));
1316
+ }
1317
+ return variants.size === 1 ? [...variants][0] : undefined;
1318
+ }
1319
+ function androidGeneratedVariantFromPath(root, path) {
1320
+ const segments = path.split("/");
1321
+ const buildIndex = segments.findIndex((segment, index) => segment === "build" && segments[index + 1] === "generated");
1322
+ if (buildIndex < 0)
1323
+ return undefined;
1324
+ for (const segment of segments.slice(buildIndex + 2)) {
1325
+ const variant = androidVariantFromSourceSet(segment) ?? androidVariantFromFlavorSourceSet(root, path, segment);
1326
+ if (variant)
1327
+ return variant;
1328
+ }
1329
+ return undefined;
1330
+ }
1331
+ function androidSourceSet(path) {
1332
+ return /(^|\/)src\/([^/]+)\//.exec(path)?.[2];
1333
+ }
1334
+ function androidVariantFromSourceSet(sourceSet) {
1335
+ if (sourceSet === "main")
1336
+ return undefined;
1337
+ if (sourceSet === "debug" || sourceSet === "testDebug" || sourceSet === "androidTestDebug")
1338
+ return "Debug";
1339
+ if (sourceSet === "release" || sourceSet === "testRelease" || sourceSet === "androidTestRelease")
1340
+ return "Release";
1341
+ const match = /^(?:test|androidTest)?([A-Za-z][A-Za-z0-9]*?)(Debug|Release)$/.exec(sourceSet);
1342
+ if (!match?.[1] || !match[2])
1343
+ return undefined;
1344
+ return `${pascalCase(match[1])}${match[2]}`;
1345
+ }
1346
+ function androidVariantFromFlavorSourceSet(root, path, sourceSet) {
1347
+ const normalizedSourceSet = sourceSet.replace(/^(?:test|androidTest)(?=[A-Z])/, (prefix) => (prefix === "test" || prefix === "androidTest" ? "" : prefix));
1348
+ const model = readAndroidGradleModel(root, path);
1349
+ if (model.productFlavors.length === 0)
1350
+ return undefined;
1351
+ if (!androidSourceSetMatchesFlavors(normalizedSourceSet, model.productFlavors))
1352
+ return undefined;
1353
+ const buildType = model.buildTypes.find((candidate) => candidate.toLowerCase() === "debug") ?? model.buildTypes[0] ?? "debug";
1354
+ return `${pascalCase(normalizedSourceSet)}${pascalCase(buildType)}`;
1355
+ }
1356
+ function androidEnabledVariant(root, path, variant) {
1357
+ const model = readAndroidGradleModel(root, path);
1358
+ if (!model.disabledVariants.includes(variant))
1359
+ return variant;
1360
+ return androidFallbackEnabledVariant(variant, model) ?? variant;
1361
+ }
1362
+ function readAndroidGradleModel(root, path) {
1363
+ const moduleRoot = nearestManifestRoot(root, path, ["build.gradle", "build.gradle.kts"]) ?? ".";
1364
+ const content = ["build.gradle", "build.gradle.kts"]
1365
+ .map((fileName) => readText(root, joinPath(moduleRoot === "." ? "" : moduleRoot, fileName)))
1366
+ .join("\n");
1367
+ const productFlavors = gradleNamedBlockChildren(content, "productFlavors");
1368
+ const explicitBuildTypes = gradleNamedBlockChildren(content, "buildTypes");
1369
+ const buildTypes = uniqueStrings([...explicitBuildTypes, "debug", "release"]);
1370
+ const disabledVariants = androidDisabledVariants(content, productFlavors, buildTypes);
1371
+ return { productFlavors, buildTypes, disabledVariants };
1372
+ }
1373
+ function androidDisabledVariants(content, productFlavors, buildTypes) {
1374
+ const variants = new Set();
1375
+ for (const snippet of androidDisabledVariantSnippets(content)) {
1376
+ const mentionedBuildTypes = androidMentionedBuildTypes(snippet, buildTypes);
1377
+ const mentionedFlavors = androidMentionedFlavors(snippet, productFlavors);
1378
+ const targetBuildTypes = mentionedBuildTypes.length > 0 ? mentionedBuildTypes : buildTypes;
1379
+ const targetFlavorSets = mentionedFlavors.length > 0 ? [mentionedFlavors] : [[]];
1380
+ for (const flavorSet of targetFlavorSets) {
1381
+ for (const buildType of targetBuildTypes) {
1382
+ variants.add(androidVariantName(flavorSet, buildType));
1383
+ }
1384
+ }
1385
+ }
1386
+ return [...variants].sort();
1387
+ }
1388
+ function androidDisabledVariantSnippets(content) {
1389
+ const snippets = [];
1390
+ const variantFilter = gradleBlockBody(content, "variantFilter");
1391
+ if (variantFilter)
1392
+ snippets.push(...androidVariantFilterDisabledSnippets(variantFilter));
1393
+ for (const match of content.matchAll(/beforeVariants\s*\(\s*selector\(\)([\s\S]*?)\)\s*\{([\s\S]*?)\}/g)) {
1394
+ const selector = match[1] ?? "";
1395
+ const body = match[2] ?? "";
1396
+ if (/\benable\s*=\s*false\b/.test(body))
1397
+ snippets.push(`${selector}\n${body}`);
1398
+ }
1399
+ return snippets;
1400
+ }
1401
+ function androidVariantFilterDisabledSnippets(content) {
1402
+ const snippets = [];
1403
+ for (const match of content.matchAll(/if\s*\(([\s\S]*?)\)\s*\{([\s\S]*?)\}/g)) {
1404
+ const condition = match[1] ?? "";
1405
+ const body = match[2] ?? "";
1406
+ if (/(setIgnore\s*\(\s*true\s*\)|ignore\s*=\s*true)/.test(body))
1407
+ snippets.push(`${condition}\n${body}`);
1408
+ }
1409
+ if (snippets.length === 0 && /(setIgnore\s*\(\s*true\s*\)|ignore\s*=\s*true)/.test(content))
1410
+ snippets.push(content);
1411
+ return snippets;
1412
+ }
1413
+ function androidMentionedBuildTypes(content, buildTypes) {
1414
+ const mentioned = new Set();
1415
+ for (const match of content.matchAll(/buildType\.name\s*(?:==|=)\s*["']([^"']+)["']/g)) {
1416
+ if (match[1])
1417
+ mentioned.add(match[1]);
1418
+ }
1419
+ for (const match of content.matchAll(/withBuildType\s*\(\s*["']([^"']+)["']\s*\)/g)) {
1420
+ if (match[1])
1421
+ mentioned.add(match[1]);
1422
+ }
1423
+ return filterKnownGradleNames([...mentioned], buildTypes);
1424
+ }
1425
+ function androidMentionedFlavors(content, productFlavors) {
1426
+ const mentioned = new Set();
1427
+ for (const match of content.matchAll(/flavors\*\.\s*name\.contains\s*\(\s*["']([^"']+)["']\s*\)/g)) {
1428
+ if (match[1])
1429
+ mentioned.add(match[1]);
1430
+ }
1431
+ for (const match of content.matchAll(/withFlavor\s*\(\s*["'][^"']+["']\s*(?:to|,)\s*["']([^"']+)["']\s*\)/g)) {
1432
+ if (match[1])
1433
+ mentioned.add(match[1]);
1434
+ }
1435
+ return filterKnownGradleNames([...mentioned], productFlavors);
1436
+ }
1437
+ function filterKnownGradleNames(values, knownValues) {
1438
+ if (knownValues.length === 0)
1439
+ return values;
1440
+ const known = new Map(knownValues.map((value) => [value.toLowerCase(), value]));
1441
+ return values.map((value) => known.get(value.toLowerCase())).filter((value) => Boolean(value));
1442
+ }
1443
+ function androidVariantName(flavors, buildType) {
1444
+ return `${flavors.map(pascalCase).join("")}${pascalCase(buildType)}`;
1445
+ }
1446
+ function androidFallbackEnabledVariant(variant, model) {
1447
+ const buildType = androidVariantBuildType(variant, model.buildTypes);
1448
+ if (!buildType)
1449
+ return undefined;
1450
+ const flavorPrefix = variant.slice(0, -pascalCase(buildType).length);
1451
+ const preferredBuildTypes = uniqueStrings([...model.buildTypes.filter((candidate) => candidate.toLowerCase() === "debug"), ...model.buildTypes]);
1452
+ for (const candidateBuildType of preferredBuildTypes) {
1453
+ const candidate = `${flavorPrefix}${pascalCase(candidateBuildType)}`;
1454
+ if (!model.disabledVariants.includes(candidate))
1455
+ return candidate;
1456
+ }
1457
+ return undefined;
1458
+ }
1459
+ function androidVariantBuildType(variant, buildTypes) {
1460
+ return buildTypes
1461
+ .map((buildType) => ({ raw: buildType, pascal: pascalCase(buildType) }))
1462
+ .sort((a, b) => b.pascal.length - a.pascal.length)
1463
+ .find((buildType) => variant.endsWith(buildType.pascal))?.raw;
1464
+ }
1465
+ function androidSourceSetMatchesFlavors(sourceSet, productFlavors) {
1466
+ const flavorNames = productFlavors.map((flavor) => pascalCase(flavor)).sort((a, b) => b.length - a.length);
1467
+ let remaining = pascalCase(sourceSet);
1468
+ if (remaining.length === 0)
1469
+ return false;
1470
+ while (remaining.length > 0) {
1471
+ const match = flavorNames.find((flavor) => remaining.startsWith(flavor));
1472
+ if (!match)
1473
+ return false;
1474
+ remaining = remaining.slice(match.length);
1475
+ }
1476
+ return true;
1477
+ }
1478
+ function gradleNamedBlockChildren(content, blockName) {
1479
+ const body = gradleBlockBody(content, blockName);
1480
+ if (!body)
1481
+ return [];
1482
+ const names = [];
1483
+ let depth = 0;
1484
+ for (const rawLine of body.split(/\r?\n/)) {
1485
+ const line = rawLine.replace(/\/\/.*$/, "");
1486
+ if (depth === 0) {
1487
+ const directBlock = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\{/.exec(line);
1488
+ const factoryBlock = /^\s*(?:create|maybeCreate|register)\s*\(\s*["']([^"']+)["']\s*\)/.exec(line);
1489
+ const name = directBlock?.[1] ?? factoryBlock?.[1];
1490
+ if (name)
1491
+ names.push(name);
1492
+ }
1493
+ depth += countChar(line, "{") - countChar(line, "}");
1494
+ if (depth < 0)
1495
+ depth = 0;
1496
+ }
1497
+ return uniqueStrings(names);
1498
+ }
1499
+ function gradleBlockBody(content, blockName) {
1500
+ const match = new RegExp(`\\b${escapeRegExp(blockName)}\\s*\\{`, "m").exec(content);
1501
+ if (!match)
1502
+ return undefined;
1503
+ const start = match.index + match[0].length;
1504
+ let depth = 1;
1505
+ for (let index = start; index < content.length; index += 1) {
1506
+ const char = content[index];
1507
+ if (char === "{")
1508
+ depth += 1;
1509
+ if (char === "}")
1510
+ depth -= 1;
1511
+ if (depth === 0)
1512
+ return content.slice(start, index);
1513
+ }
1514
+ return undefined;
1515
+ }
1516
+ function countChar(value, char) {
1517
+ return [...value].filter((candidate) => candidate === char).length;
1518
+ }
1519
+ function uniqueStrings(values) {
1520
+ return [...new Set(values.filter(Boolean))];
1521
+ }
1522
+ function escapeRegExp(value) {
1523
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1524
+ }
1525
+ function androidVariantSlug(variant) {
1526
+ return variant.replace(/([a-z0-9])([A-Z])/g, "$1-$2");
1527
+ }
1528
+ function javaBuildTool(root, signal) {
1529
+ if (signal.manifestPath === "pom.xml" || existsSync(join(root, "pom.xml")) || existsSync(join(root, "mvnw")))
1530
+ return "maven";
1531
+ if (signal.manifestPath.includes("gradle") || existsSync(join(root, "build.gradle")) || existsSync(join(root, "build.gradle.kts")) || existsSync(join(root, "gradlew"))) {
1532
+ return "gradle";
1533
+ }
1534
+ return "maven";
1535
+ }
1536
+ function javaTestCommand(root, buildTool) {
1537
+ if (buildTool === "gradle")
1538
+ return `${gradleCommand(root)} test`;
1539
+ return `${mavenCommand(root)} test`;
1540
+ }
1541
+ function springBootPackageCommand(root, buildTool) {
1542
+ if (buildTool === "gradle")
1543
+ return `${gradleCommand(root)} bootJar`;
1544
+ return `${mavenCommand(root)} package -DskipTests`;
1545
+ }
1546
+ function gradleCommand(root) {
1547
+ return existsSync(join(root, "gradlew")) ? "./gradlew" : "gradle";
1548
+ }
1549
+ function mavenCommand(root) {
1550
+ return existsSync(join(root, "mvnw")) ? "./mvnw" : "mvn";
1551
+ }
1552
+ function addPantsPlans(plans, root, changedSince) {
1553
+ const pants = existsSync(join(root, "pants")) ? "./pants" : "pants";
1554
+ const changedArgs = `--changed-since=${quoteShell(changedSince)} --changed-dependents=transitive`;
1555
+ pushUnique(plans, {
1556
+ id: "pants-changed-tests",
1557
+ label: "Pants changed tests",
1558
+ command: `${pants} ${changedArgs} test`,
1559
+ reason: "pants.toml is present, so Pants can select changed targets and transitive dependents from Git.",
1560
+ ecosystem: "pants",
1561
+ required: true
1562
+ });
1563
+ pushUnique(plans, {
1564
+ id: "pants-changed-lint",
1565
+ label: "Pants changed lint",
1566
+ command: `${pants} ${changedArgs} lint`,
1567
+ reason: "Pants can lint changed targets and their transitive dependents with native target selection.",
1568
+ ecosystem: "pants",
1569
+ required: false
1570
+ });
1571
+ pushUnique(plans, {
1572
+ id: "pants-changed-check",
1573
+ label: "Pants changed check",
1574
+ command: `${pants} ${changedArgs} check`,
1575
+ reason: "Pants can run configured typecheck and static analysis goals over changed targets.",
1576
+ ecosystem: "pants",
1577
+ required: false
1578
+ });
1579
+ }
1580
+ function addKubernetesPlans(plans, root, paths) {
1581
+ const helmRoots = new Set();
1582
+ const kustomizeRoots = new Set();
1583
+ const manifestRoots = new Set();
1584
+ for (const path of paths) {
1585
+ if (!isKubernetesPath(path))
1586
+ continue;
1587
+ const helmRoot = nearestManifestRoot(root, path, ["Chart.yaml"]);
1588
+ if (helmRoot) {
1589
+ helmRoots.add(helmRoot);
1590
+ continue;
1591
+ }
1592
+ const kustomizeRoot = nearestManifestRoot(root, path, ["kustomization.yaml", "kustomization.yml"]);
1593
+ if (kustomizeRoot) {
1594
+ kustomizeRoots.add(kustomizeRoot);
1595
+ continue;
1596
+ }
1597
+ manifestRoots.add(kubernetesManifestRoot(path));
1598
+ }
1599
+ for (const chartRoot of [...helmRoots].sort()) {
1600
+ pushUnique(plans, {
1601
+ id: `kubernetes-helm-lint-${slug(chartRoot)}`,
1602
+ label: "Helm lint",
1603
+ command: `helm lint ${quoteShell(chartRoot)}`,
1604
+ reason: "Helm chart files changed, so chart templates and values should lint before merge.",
1605
+ ecosystem: "kubernetes",
1606
+ required: true
1607
+ });
1608
+ }
1609
+ for (const kustomizeRoot of [...kustomizeRoots].sort()) {
1610
+ pushUnique(plans, {
1611
+ id: `kubernetes-kustomize-${slug(kustomizeRoot)}`,
1612
+ label: "Kustomize render",
1613
+ command: `kubectl kustomize ${quoteShell(kustomizeRoot)}`,
1614
+ reason: "Kustomize files changed, so rendered manifests should be generated before merge.",
1615
+ ecosystem: "kubernetes",
1616
+ required: true
1617
+ });
1618
+ }
1619
+ for (const manifestRoot of [...manifestRoots].sort()) {
1620
+ pushUnique(plans, {
1621
+ id: `kubernetes-dry-run-${slug(manifestRoot)}`,
1622
+ label: "Kubernetes manifest dry-run",
1623
+ command: `kubectl apply --dry-run=client -f ${quoteShell(manifestRoot)}`,
1624
+ reason: "Kubernetes manifests changed, so client-side apply should parse them before merge.",
1625
+ ecosystem: "kubernetes",
1626
+ required: true
1627
+ });
1628
+ }
1629
+ }
1630
+ function addBazelPlans(plans, root, paths) {
1631
+ const bazel = existsSync(join(root, "bazelisk")) ? "./bazelisk" : existsSync(join(root, "bazel")) ? "./bazel" : "bazel";
1632
+ const targets = bazelChangedTargetPatterns(root, paths);
1633
+ const targetArgs = targets.join(" ");
1634
+ const narrowed = targetArgs !== "//...";
1635
+ pushUnique(plans, {
1636
+ id: narrowed ? "bazel-changed-tests" : "bazel-tests",
1637
+ label: narrowed ? "Bazel changed-package tests" : "Bazel tests",
1638
+ command: `${bazel} test ${targetArgs}`,
1639
+ reason: narrowed
1640
+ ? "Bazel source or package files changed, so the nearest recursive target patterns should run through Bazel."
1641
+ : "Bazel workspace metadata changed or no nearest package was found, so all test targets should run through Bazel's target graph.",
1642
+ ecosystem: "bazel",
1643
+ required: true
1644
+ });
1645
+ pushUnique(plans, {
1646
+ id: narrowed ? "bazel-changed-build" : "bazel-build",
1647
+ label: narrowed ? "Bazel changed-package build" : "Bazel build",
1648
+ command: `${bazel} build ${targetArgs}`,
1649
+ reason: narrowed
1650
+ ? "Bazel changed packages should still analyze and build with their recursive target patterns."
1651
+ : "Bazel build graph should still analyze and build after workspace or source changes.",
1652
+ ecosystem: "bazel",
1653
+ required: false
1654
+ });
1655
+ if (narrowed) {
1656
+ const downstreamQuery = `rdeps(//..., set(${targetArgs}))`;
1657
+ pushUnique(plans, {
1658
+ id: "bazel-downstream-query",
1659
+ label: "Bazel downstream reverse-dependency query",
1660
+ command: `${bazel} query ${quoteShell(downstreamQuery)}`,
1661
+ reason: "Bazel changed-package patterns can miss downstream owners; rdeps shows graph-wide reverse dependencies for review before expanding tests.",
1662
+ ecosystem: "bazel",
1663
+ required: false
1664
+ });
1665
+ pushUnique(plans, {
1666
+ id: "bazel-downstream-tests",
1667
+ label: "Bazel downstream test targets",
1668
+ command: downstreamTargetsCommand(bazel, "query", `tests(${downstreamQuery})`, "test", "No downstream Bazel tests found"),
1669
+ reason: "Bazel rdeps can be promoted through tests(...) into executable downstream test targets after review.",
1670
+ ecosystem: "bazel",
1671
+ required: false
1672
+ });
1673
+ }
1674
+ }
1675
+ function addBuckPlans(plans, root, paths) {
1676
+ const buck = existsSync(join(root, "buck2")) ? "./buck2" : "buck2";
1677
+ const targets = buckChangedTargetPatterns(root, paths);
1678
+ const targetArgs = targets.join(" ");
1679
+ const narrowed = targetArgs !== "//...";
1680
+ pushUnique(plans, {
1681
+ id: narrowed ? "buck-changed-tests" : "buck-tests",
1682
+ label: narrowed ? "Buck changed-package tests" : "Buck tests",
1683
+ command: `${buck} test ${targetArgs}`,
1684
+ reason: narrowed
1685
+ ? "Buck source or package files changed, so the nearest recursive target patterns should run through Buck."
1686
+ : "Buck workspace metadata changed or no nearest package was found, so test targets should run through Buck's target graph.",
1687
+ ecosystem: "buck",
1688
+ required: true
1689
+ });
1690
+ pushUnique(plans, {
1691
+ id: narrowed ? "buck-changed-build" : "buck-build",
1692
+ label: narrowed ? "Buck changed-package build" : "Buck build",
1693
+ command: `${buck} build ${targetArgs}`,
1694
+ reason: narrowed
1695
+ ? "Buck changed packages should still analyze and build with their recursive target patterns."
1696
+ : "Buck build graph should still analyze and build after target or source changes.",
1697
+ ecosystem: "buck",
1698
+ required: false
1699
+ });
1700
+ if (narrowed) {
1701
+ const downstreamQuery = `rdeps(//..., set(${targetArgs}))`;
1702
+ pushUnique(plans, {
1703
+ id: "buck-downstream-uquery",
1704
+ label: "Buck downstream reverse-dependency query",
1705
+ command: `${buck} uquery ${quoteShell(downstreamQuery)}`,
1706
+ reason: "Buck changed-package patterns can miss downstream owners; uquery rdeps shows graph-wide reverse dependencies for review before expanding tests.",
1707
+ ecosystem: "buck",
1708
+ required: false
1709
+ });
1710
+ pushUnique(plans, {
1711
+ id: "buck-downstream-tests",
1712
+ label: "Buck downstream test targets",
1713
+ command: downstreamTargetsCommand(buck, "uquery", `testsof(${downstreamQuery})`, "test", "No downstream Buck tests found"),
1714
+ reason: "Buck uquery rdeps can be promoted through testsof(...) into executable downstream test targets after review.",
1715
+ ecosystem: "buck",
1716
+ required: false
1717
+ });
1718
+ }
1719
+ }
1720
+ function downstreamTargetsCommand(tool, querySubcommand, query, runSubcommand, emptyMessage) {
1721
+ return `targets="$(${tool} ${querySubcommand} ${quoteShell(query)})" && if [ -n "$targets" ]; then ${tool} ${runSubcommand} $targets; else echo ${quoteShell(emptyMessage)}; fi`;
1722
+ }
1723
+ function bazelChangedTargetPatterns(root, paths) {
1724
+ if (touchesBazelRootMetadata(paths))
1725
+ return ["//..."];
1726
+ const patterns = new Set();
1727
+ for (const path of paths) {
1728
+ if (!touchesBazel([path]))
1729
+ continue;
1730
+ const packageRoot = nearestManifestRoot(root, path, ["BUILD.bazel", "BUILD"]);
1731
+ if (!packageRoot)
1732
+ return ["//..."];
1733
+ const pattern = bazelTargetPattern(packageRoot);
1734
+ if (!pattern)
1735
+ return ["//..."];
1736
+ patterns.add(pattern);
1737
+ }
1738
+ return patterns.size > 0 ? [...patterns].sort() : ["//..."];
1739
+ }
1740
+ function buckChangedTargetPatterns(root, paths) {
1741
+ if (touchesBuckRootMetadata(paths))
1742
+ return ["//..."];
1743
+ const patterns = new Set();
1744
+ for (const path of paths) {
1745
+ if (!touchesBuck([path]))
1746
+ continue;
1747
+ const packageRoot = nearestManifestRoot(root, path, ["BUCK", "BUCK.v2"]);
1748
+ if (!packageRoot)
1749
+ return ["//..."];
1750
+ const pattern = buckTargetPattern(packageRoot);
1751
+ if (!pattern)
1752
+ return ["//..."];
1753
+ patterns.add(pattern);
1754
+ }
1755
+ return patterns.size > 0 ? [...patterns].sort() : ["//..."];
1756
+ }
1757
+ function bazelTargetPattern(packageRoot) {
1758
+ if (packageRoot === ".")
1759
+ return "//:all";
1760
+ if (!isBuildTargetPackagePath(packageRoot))
1761
+ return undefined;
1762
+ return `//${packageRoot}/...`;
1763
+ }
1764
+ function buckTargetPattern(packageRoot) {
1765
+ if (packageRoot === ".")
1766
+ return "//:";
1767
+ if (!isBuildTargetPackagePath(packageRoot))
1768
+ return undefined;
1769
+ return `//${packageRoot}/...`;
1770
+ }
1771
+ function isBuildTargetPackagePath(path) {
1772
+ return /^[A-Za-z0-9_./+=,@~-]+$/.test(path) && !path.includes("//") && !path.split("/").includes("..");
1773
+ }
1774
+ export function findAffectedWorkspacePackages(changedFiles, signals) {
1775
+ const affected = new Map();
1776
+ const paths = changedFiles.map((file) => file.path);
1777
+ for (const signal of signals) {
1778
+ if (!signal.workspacePackages || signal.workspacePackages.length === 0)
1779
+ continue;
1780
+ for (const workspacePackage of affectedPackagesForSignal(paths, signal, rootWideMetadataChange(paths, signal))) {
1781
+ affected.set(workspacePackage.path, workspacePackage);
1782
+ }
1783
+ }
1784
+ return [...affected.values()];
1785
+ }
1786
+ function addNodePlans(plans, signal) {
1787
+ const scripts = signal.scripts ?? {};
1788
+ for (const task of NODE_TASKS) {
1789
+ const script = nodeTaskScript(scripts, task);
1790
+ if (!script)
1791
+ continue;
1792
+ pushUnique(plans, {
1793
+ id: `node-${task.id}`,
1794
+ label: `Node ${task.label}`,
1795
+ command: nodeRun(signal.packageManager ?? "npm", script),
1796
+ reason: `package.json defines "${script}", and Node-related files changed.`,
1797
+ ecosystem: "node",
1798
+ required: task.required
1799
+ });
1800
+ }
1801
+ }
1802
+ function addGoPlans(plans, signal) {
1803
+ pushUnique(plans, {
1804
+ id: scopedPlanId("go-tests", signal),
1805
+ label: scopedPlanLabel("Go tests", signal),
1806
+ command: scopedCommand(signal, "go test ./..."),
1807
+ reason: "Go source or module metadata changed.",
1808
+ ecosystem: "go",
1809
+ required: true,
1810
+ ...scopedPackagePath(signal)
1811
+ });
1812
+ pushUnique(plans, {
1813
+ id: scopedPlanId("go-vet", signal),
1814
+ label: scopedPlanLabel("Go vet", signal),
1815
+ command: scopedCommand(signal, "go vet ./..."),
1816
+ reason: "Static checks catch common Go regressions.",
1817
+ ecosystem: "go",
1818
+ required: false,
1819
+ ...scopedPackagePath(signal)
1820
+ });
1821
+ }
1822
+ function addNodeWorkspacePlans(plans, paths, signal) {
1823
+ const affectedPackages = affectedPackagesForSignal(paths, signal, touchesRootWorkspaceMetadata(paths));
1824
+ const directlyAffected = new Set(directlyAffectedPackagesForSignal(paths, signal).map((workspacePackage) => workspacePackage.path));
1825
+ const affectedNames = new Set(affectedPackages.map((workspacePackage) => workspacePackage.name));
1826
+ const rootWideChange = touchesRootWorkspaceMetadata(paths);
1827
+ const taskRunnerPlanCount = addNodeTaskRunnerPlans(plans, affectedPackages, signal, directlyAffected, affectedNames, rootWideChange);
1828
+ if (taskRunnerPlanCount > 0)
1829
+ return taskRunnerPlanCount;
1830
+ let added = 0;
1831
+ for (const workspacePackage of affectedPackages) {
1832
+ for (const task of NODE_TASKS) {
1833
+ const script = nodeTaskScript(workspacePackage.scripts, task);
1834
+ if (!script)
1835
+ continue;
1836
+ const plan = {
1837
+ id: `node-workspace-${slug(workspacePackage.name)}-${task.id}`,
1838
+ label: `${workspacePackage.name} ${task.label}`,
1839
+ command: workspaceRun(signal.packageManager ?? "npm", workspacePackage.name, script),
1840
+ reason: workspaceReason(workspacePackage, script, directlyAffected, affectedNames, rootWideChange),
1841
+ ecosystem: "node",
1842
+ required: task.required,
1843
+ packageName: workspacePackage.name,
1844
+ packagePath: workspacePackage.path
1845
+ };
1846
+ const before = plans.length;
1847
+ pushUnique(plans, plan);
1848
+ if (plans.length > before)
1849
+ added += 1;
1850
+ }
1851
+ }
1852
+ return added;
1853
+ }
1854
+ function addCargoWorkspacePlans(plans, paths, signal) {
1855
+ const affectedPackages = affectedPackagesForSignal(paths, signal, touchesCargoMetadata(paths, signal));
1856
+ const directlyAffected = new Set(directlyAffectedPackagesForSignal(paths, signal).map((workspacePackage) => workspacePackage.path));
1857
+ const affectedNames = new Set(affectedPackages.map((workspacePackage) => workspacePackage.name));
1858
+ const rootWideChange = touchesCargoMetadata(paths, signal);
1859
+ let added = 0;
1860
+ for (const workspacePackage of affectedPackages) {
1861
+ for (const command of rustWorkspaceCommands(workspacePackage, directlyAffected, affectedNames, rootWideChange, signal)) {
1862
+ const before = plans.length;
1863
+ pushUnique(plans, command);
1864
+ if (plans.length > before)
1865
+ added += 1;
1866
+ }
1867
+ }
1868
+ return added;
1869
+ }
1870
+ function addGoWorkspacePlans(plans, paths, signal) {
1871
+ const affectedPackages = affectedPackagesForSignal(paths, signal, touchesGoMetadata(paths, signal));
1872
+ const directlyAffected = new Set(directlyAffectedPackagesForSignal(paths, signal).map((workspacePackage) => workspacePackage.path));
1873
+ const affectedNames = new Set(affectedPackages.map((workspacePackage) => workspacePackage.name));
1874
+ const rootWideChange = touchesGoMetadata(paths, signal);
1875
+ let added = 0;
1876
+ for (const workspacePackage of affectedPackages) {
1877
+ for (const command of goWorkspaceCommands(workspacePackage, directlyAffected, affectedNames, rootWideChange, signal)) {
1878
+ const before = plans.length;
1879
+ pushUnique(plans, command);
1880
+ if (plans.length > before)
1881
+ added += 1;
1882
+ }
1883
+ }
1884
+ return added;
1885
+ }
1886
+ function goWorkspaceCommands(workspacePackage, directlyAffected, affectedNames, rootWideChange, signal) {
1887
+ const pattern = goWorkspacePattern(workspacePackage.path, signal);
1888
+ const reason = goWorkspaceReason(workspacePackage, directlyAffected, affectedNames, rootWideChange);
1889
+ return [
1890
+ {
1891
+ id: scopedPlanId(`go-workspace-${slug(workspacePackage.name)}-tests`, signal),
1892
+ label: `${workspacePackage.name} Go tests`,
1893
+ command: scopedCommand(signal, `go test ${pattern}`),
1894
+ reason,
1895
+ ecosystem: "go",
1896
+ required: true,
1897
+ packageName: workspacePackage.name,
1898
+ packagePath: workspacePackage.path
1899
+ },
1900
+ {
1901
+ id: scopedPlanId(`go-workspace-${slug(workspacePackage.name)}-vet`, signal),
1902
+ label: `${workspacePackage.name} Go vet`,
1903
+ command: scopedCommand(signal, `go vet ${pattern}`),
1904
+ reason: `${reason} Go workspace changes should pass static checks before merge.`,
1905
+ ecosystem: "go",
1906
+ required: false,
1907
+ packageName: workspacePackage.name,
1908
+ packagePath: workspacePackage.path
1909
+ }
1910
+ ];
1911
+ }
1912
+ function rustWorkspaceCommands(workspacePackage, directlyAffected, affectedNames, rootWideChange, signal) {
1913
+ const packageName = quoteShell(workspacePackage.name);
1914
+ const reason = cargoWorkspaceReason(workspacePackage, directlyAffected, affectedNames, rootWideChange);
1915
+ return [
1916
+ {
1917
+ id: scopedPlanId(`rust-workspace-${slug(workspacePackage.name)}-tests`, signal),
1918
+ label: `${workspacePackage.name} Rust tests`,
1919
+ command: cargoCommand(signal, "test", `-p ${packageName} --all-targets`),
1920
+ reason,
1921
+ ecosystem: "rust",
1922
+ required: true,
1923
+ packageName: workspacePackage.name,
1924
+ packagePath: workspacePackage.path
1925
+ },
1926
+ {
1927
+ id: scopedPlanId(`rust-workspace-${slug(workspacePackage.name)}-clippy`, signal),
1928
+ label: `${workspacePackage.name} Rust clippy`,
1929
+ command: cargoCommand(signal, "clippy", `-p ${packageName} --all-targets -- -D warnings`),
1930
+ reason: `${reason} Rust workspace changes should pass linting before merge.`,
1931
+ ecosystem: "rust",
1932
+ required: false,
1933
+ packageName: workspacePackage.name,
1934
+ packagePath: workspacePackage.path
1935
+ }
1936
+ ];
1937
+ }
1938
+ function addNodeTaskRunnerPlans(plans, affectedPackages, signal, directlyAffected, affectedNames, rootWideChange) {
1939
+ if (!signal.taskRunner || affectedPackages.length === 0)
1940
+ return 0;
1941
+ let added = 0;
1942
+ for (const workspacePackage of affectedPackages) {
1943
+ for (const task of NODE_TASKS) {
1944
+ const script = workspaceTaskName(workspacePackage, task, signal.taskRunner);
1945
+ if (!script)
1946
+ continue;
1947
+ const projectName = workspacePackage.projectName ?? workspacePackage.name;
1948
+ const plan = {
1949
+ id: `node-${signal.taskRunner}-${slug(projectName)}-${task.id}`,
1950
+ label: `${workspacePackage.name} ${task.label}`,
1951
+ command: taskRunnerRun(signal.packageManager ?? "npm", signal.taskRunner, workspacePackage, script),
1952
+ reason: `${workspaceTaskRunnerReason(workspacePackage, script, directlyAffected, affectedNames, rootWideChange)} PatchDrill detected ${signal.taskRunner} and will use its task graph.`,
1953
+ ecosystem: "node",
1954
+ required: task.required,
1955
+ packageName: workspacePackage.name,
1956
+ packagePath: workspacePackage.path
1957
+ };
1958
+ const before = plans.length;
1959
+ pushUnique(plans, plan);
1960
+ if (plans.length > before)
1961
+ added += 1;
1962
+ }
1963
+ }
1964
+ return added;
1965
+ }
1966
+ function nodeTaskScript(scripts, task) {
1967
+ return task.aliases.find((script) => scripts[script]);
1968
+ }
1969
+ function workspaceTaskName(workspacePackage, task, taskRunner) {
1970
+ const script = nodeTaskScript(workspacePackage.scripts, task);
1971
+ if (script)
1972
+ return script;
1973
+ if (taskRunner !== "nx")
1974
+ return undefined;
1975
+ return task.aliases.find((alias) => workspacePackage.targets?.includes(alias));
1976
+ }
1977
+ function affectedPackagesForSignal(paths, signal, rootWideChange) {
1978
+ const workspacePackages = signal.workspacePackages ?? [];
1979
+ if (workspacePackages.length === 0)
1980
+ return [];
1981
+ if (rootWideChange)
1982
+ return workspacePackages;
1983
+ return includeDownstreamDependents(directlyAffectedPackagesForSignal(paths, signal), workspacePackages);
1984
+ }
1985
+ function directlyAffectedPackagesForSignal(paths, signal) {
1986
+ const workspacePackages = signal.workspacePackages ?? [];
1987
+ const affected = new Set();
1988
+ for (const path of paths) {
1989
+ // Attribute each changed path to its single most-specific (longest matching)
1990
+ // package so a file confined to a nested child package does not also mark the
1991
+ // enclosing parent as directly changed. Downstream dependents are added
1992
+ // separately via declared dependencies in includeDownstreamDependents.
1993
+ let best;
1994
+ for (const workspacePackage of workspacePackages) {
1995
+ if (path === workspacePackage.path || path.startsWith(`${workspacePackage.path}/`)) {
1996
+ if (!best || workspacePackage.path.length > best.path.length)
1997
+ best = workspacePackage;
1998
+ }
1999
+ }
2000
+ if (best)
2001
+ affected.add(best.path);
2002
+ }
2003
+ return workspacePackages.filter((workspacePackage) => affected.has(workspacePackage.path));
2004
+ }
2005
+ function includeDownstreamDependents(directlyAffected, workspacePackages) {
2006
+ const affected = new Map();
2007
+ const queue = [...directlyAffected];
2008
+ for (const workspacePackage of directlyAffected)
2009
+ affected.set(workspacePackage.path, workspacePackage);
2010
+ for (const changedPackage of queue) {
2011
+ for (const candidate of workspacePackages) {
2012
+ if (affected.has(candidate.path))
2013
+ continue;
2014
+ if (!candidate.dependencies?.includes(changedPackage.name))
2015
+ continue;
2016
+ affected.set(candidate.path, candidate);
2017
+ queue.push(candidate);
2018
+ }
2019
+ }
2020
+ return [...affected.values()];
2021
+ }
2022
+ function workspaceReason(workspacePackage, script, directlyAffected, affectedNames, rootWideChange) {
2023
+ if (rootWideChange) {
2024
+ return `Root workspace metadata changed, and ${workspacePackage.name} defines "${script}".`;
2025
+ }
2026
+ if (directlyAffected.has(workspacePackage.path)) {
2027
+ return `${workspacePackage.name} changed under ${workspacePackage.path}, and its package.json defines "${script}".`;
2028
+ }
2029
+ const upstream = workspacePackage.dependencies?.find((dependency) => affectedNames.has(dependency));
2030
+ return `${workspacePackage.name} depends on ${upstream ?? "an affected workspace package"}, and its package.json defines "${script}".`;
2031
+ }
2032
+ function workspaceTaskRunnerReason(workspacePackage, script, directlyAffected, affectedNames, rootWideChange) {
2033
+ const taskDefinition = workspacePackage.scripts[script] ? `package.json defines "${script}"` : `project.json defines target "${script}"`;
2034
+ if (rootWideChange) {
2035
+ return `Root workspace metadata changed, and ${taskDefinition} for ${workspacePackage.name}.`;
2036
+ }
2037
+ if (directlyAffected.has(workspacePackage.path)) {
2038
+ return `${workspacePackage.name} changed under ${workspacePackage.path}, and ${taskDefinition}.`;
2039
+ }
2040
+ const upstream = workspacePackage.dependencies?.find((dependency) => affectedNames.has(dependency));
2041
+ return `${workspacePackage.name} depends on ${upstream ?? "an affected workspace package"}, and ${taskDefinition}.`;
2042
+ }
2043
+ function cargoWorkspaceReason(workspacePackage, directlyAffected, affectedNames, rootWideChange) {
2044
+ if (rootWideChange) {
2045
+ return `Cargo workspace metadata changed, and ${workspacePackage.name} is a workspace member.`;
2046
+ }
2047
+ if (directlyAffected.has(workspacePackage.path)) {
2048
+ return `${workspacePackage.name} changed under ${workspacePackage.path}.`;
2049
+ }
2050
+ const upstream = workspacePackage.dependencies?.find((dependency) => affectedNames.has(dependency));
2051
+ return `${workspacePackage.name} depends on ${upstream ?? "an affected workspace crate"}.`;
2052
+ }
2053
+ function goWorkspaceReason(workspacePackage, directlyAffected, affectedNames, rootWideChange) {
2054
+ if (rootWideChange) {
2055
+ return `Go workspace metadata changed, and ${workspacePackage.name} is a workspace module.`;
2056
+ }
2057
+ if (directlyAffected.has(workspacePackage.path)) {
2058
+ return `${workspacePackage.name} changed under ${workspacePackage.path}.`;
2059
+ }
2060
+ const upstream = workspacePackage.dependencies?.find((dependency) => affectedNames.has(dependency));
2061
+ return `${workspacePackage.name} depends on ${upstream ?? "an affected workspace module"}.`;
2062
+ }
2063
+ function touchesRootWorkspaceMetadata(paths) {
2064
+ return paths.some((path) => ["package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb", "pnpm-workspace.yaml", "turbo.json", "nx.json"].includes(path));
2065
+ }
2066
+ function touchesCargoMetadata(paths, signal) {
2067
+ return pathsForSignal(paths, signal).some((path) => path === "Cargo.toml" || path === "Cargo.lock");
2068
+ }
2069
+ function touchesGoMetadata(paths, signal) {
2070
+ return pathsForSignal(paths, signal).some((path) => path === "go.work" || path === "go.work.sum");
2071
+ }
2072
+ function rootWideMetadataChange(paths, signal) {
2073
+ if (signal.ecosystem === "rust")
2074
+ return touchesCargoMetadata(paths, signal);
2075
+ if (signal.ecosystem === "go")
2076
+ return touchesGoMetadata(paths, signal);
2077
+ return touchesRootWorkspaceMetadata(paths);
2078
+ }
2079
+ function nodeRun(packageManager, script) {
2080
+ if (packageManager === "npm")
2081
+ return `npm run ${script}`;
2082
+ if (packageManager === "yarn")
2083
+ return `yarn ${script}`;
2084
+ if (packageManager === "pnpm")
2085
+ return `pnpm ${script}`;
2086
+ if (packageManager === "bun")
2087
+ return `bun run ${script}`;
2088
+ return `${packageManager} run ${script}`;
2089
+ }
2090
+ function workspaceRun(packageManager, packageName, script) {
2091
+ const quotedName = quoteShell(packageName);
2092
+ if (packageManager === "pnpm")
2093
+ return `pnpm --filter ${quotedName} run ${script}`;
2094
+ if (packageManager === "yarn")
2095
+ return `yarn workspace ${quotedName} ${script}`;
2096
+ if (packageManager === "bun")
2097
+ return `bun --filter ${quotedName} run ${script}`;
2098
+ return `npm --workspace ${quotedName} run ${script}`;
2099
+ }
2100
+ function taskRunnerRun(packageManager, taskRunner, workspacePackage, script) {
2101
+ const runner = packageManagerExec(packageManager, taskRunner);
2102
+ if (taskRunner === "turbo")
2103
+ return `${runner} run ${script} ${quoteShell(`--filter=${workspacePackage.name}`)}`;
2104
+ return `${runner} run ${quoteShell(`${workspacePackage.projectName ?? workspacePackage.name}:${script}`)}`;
2105
+ }
2106
+ function packageManagerExec(packageManager, binary) {
2107
+ if (packageManager === "pnpm")
2108
+ return `pnpm exec ${binary}`;
2109
+ if (packageManager === "yarn")
2110
+ return `yarn ${binary}`;
2111
+ if (packageManager === "bun")
2112
+ return `bunx ${binary}`;
2113
+ return `npx ${binary}`;
2114
+ }
2115
+ function goWorkspacePattern(packagePath, signal) {
2116
+ const projectRoot = signalProjectRoot(signal);
2117
+ const scopedPackagePath = projectRoot === "." ? packagePath : stripPathPrefix(packagePath, projectRoot);
2118
+ return scopedPackagePath === "." ? "./..." : `./${scopedPackagePath}/...`;
2119
+ }
2120
+ function cargoCommand(signal, cargoSubcommand, args) {
2121
+ const manifestArg = isRootSignal(signal) ? "" : ` --manifest-path ${quoteShell(signal.manifestPath)}`;
2122
+ return `cargo ${cargoSubcommand}${manifestArg}${args ? ` ${args}` : ""}`;
2123
+ }
2124
+ function touchesNode(paths) {
2125
+ return touches(paths, [
2126
+ ".js",
2127
+ ".jsx",
2128
+ ".ts",
2129
+ ".tsx",
2130
+ ".mjs",
2131
+ ".cjs",
2132
+ "package.json",
2133
+ "package-lock.json",
2134
+ "pnpm-lock.yaml",
2135
+ "yarn.lock",
2136
+ "bun.lock",
2137
+ "bun.lockb",
2138
+ "pnpm-workspace.yaml",
2139
+ "turbo.json",
2140
+ "nx.json",
2141
+ "tsconfig.json",
2142
+ "vite.config.ts",
2143
+ "next.config.js",
2144
+ "next.config.mjs"
2145
+ ]);
2146
+ }
2147
+ function touchesPython(paths, root, signal) {
2148
+ const signalRoot = signalRootPath(root, signal);
2149
+ const scopedPaths = pathsForSignal(paths, signal);
2150
+ if (scopedPaths.length === 0)
2151
+ return false;
2152
+ if (isDjangoProject(signalRoot, signal) && scopedPaths.some(isDjangoRelevantPath))
2153
+ return true;
2154
+ return (touches(scopedPaths, [
2155
+ ".py",
2156
+ "pyproject.toml",
2157
+ "requirements.txt",
2158
+ "setup.py",
2159
+ "setup.cfg",
2160
+ "manage.py",
2161
+ "pytest.ini",
2162
+ "uv.lock",
2163
+ "poetry.lock",
2164
+ "Pipfile",
2165
+ "Pipfile.lock",
2166
+ "ruff.toml",
2167
+ ".ruff.toml",
2168
+ "mypy.ini",
2169
+ ".mypy.ini",
2170
+ "pyrightconfig.json"
2171
+ ]) || existsSync(join(signalRoot, "pytest.ini")));
2172
+ }
2173
+ function touchesGo(paths, signal) {
2174
+ const scopedPaths = pathsForSignal(paths, signal);
2175
+ if (scopedPaths.length === 0)
2176
+ return false;
2177
+ return touches(scopedPaths, [".go", "go.mod", "go.sum", "go.work", "go.work.sum"]);
2178
+ }
2179
+ function touchesRust(paths, signal) {
2180
+ const scopedPaths = pathsForSignal(paths, signal);
2181
+ if (scopedPaths.length === 0)
2182
+ return false;
2183
+ return touches(scopedPaths, [".rs", "Cargo.toml", "Cargo.lock"]);
2184
+ }
2185
+ function isDjangoProject(root, signal) {
2186
+ return signal?.framework === "django" || existsSync(join(root, "manage.py"));
2187
+ }
2188
+ function isDjangoRelevantPath(path) {
2189
+ return (path === "manage.py" ||
2190
+ path.endsWith(".py") ||
2191
+ path.endsWith("requirements.txt") ||
2192
+ path.endsWith("pyproject.toml") ||
2193
+ path.endsWith("setup.py") ||
2194
+ path.endsWith("setup.cfg") ||
2195
+ /(^|\/)(templates|static)\//.test(path) ||
2196
+ /(^|\/)(settings|urls|asgi|wsgi)\.py$/.test(path));
2197
+ }
2198
+ function touchesJava(paths, root) {
2199
+ return (touches(paths, [".java", ".kt", ".kts", "pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", "gradle.properties"]) ||
2200
+ paths.some((path) => path === "gradlew" || path === "mvnw" || path.startsWith("gradle/")) ||
2201
+ existsSync(join(root, "mvnw")));
2202
+ }
2203
+ function touchesDotnet(paths) {
2204
+ return touches(paths, [".cs", ".fs", ".vb", ".csproj", ".fsproj", ".vbproj", ".sln", ".slnf", ".props", ".targets", "global.json", "Directory.Build.props", "Directory.Build.targets"]);
2205
+ }
2206
+ function touchesDotnetRootMetadata(paths) {
2207
+ return paths.some((path) => path === "global.json" || path.endsWith(".sln") || path.endsWith(".slnf") || path === "Directory.Build.props" || path === "Directory.Build.targets");
2208
+ }
2209
+ function touchesAndroid(paths) {
2210
+ return paths.some((path) => touches([path], [".java", ".kt", ".kts", ".xml", ".gradle", ".gradle.kts", "AndroidManifest.xml", "gradle.properties", "settings.gradle", "settings.gradle.kts"]) ||
2211
+ path === "gradlew" ||
2212
+ path.startsWith("gradle/") ||
2213
+ /(^|\/)(res|assets|aidl|jni|cpp)\//.test(path));
2214
+ }
2215
+ function touchesXcode(paths) {
2216
+ return paths.some((path) => touches([path], [
2217
+ ".swift",
2218
+ ".m",
2219
+ ".mm",
2220
+ ".h",
2221
+ ".hpp",
2222
+ ".storyboard",
2223
+ ".xib",
2224
+ ".plist",
2225
+ ".xcconfig",
2226
+ ".entitlements",
2227
+ ".xcscheme",
2228
+ ".xctestplan",
2229
+ "Package.resolved",
2230
+ "project.pbxproj"
2231
+ ]) ||
2232
+ path.includes(".xcodeproj/") ||
2233
+ path.includes(".xcworkspace/"));
2234
+ }
2235
+ function touchesPants(paths) {
2236
+ return paths.some((path) => path === "pants.toml" || path === "pants" || path === "BUILD" || path.endsWith("/BUILD") || path.endsWith("/BUILD.pants") || isSourceLikePath(path));
2237
+ }
2238
+ function touchesKubernetes(paths) {
2239
+ return paths.some(isKubernetesPath);
2240
+ }
2241
+ function isDockerfilePath(path) {
2242
+ return /(^|\/)Dockerfile$/.test(path);
2243
+ }
2244
+ function isDockerComposePath(path) {
2245
+ return /(^|\/)(compose\.ya?ml|docker-compose\.ya?ml)$/.test(path);
2246
+ }
2247
+ function touchesBazel(paths) {
2248
+ return paths.some((path) => path === "MODULE.bazel" ||
2249
+ path === "WORKSPACE" ||
2250
+ path === "WORKSPACE.bazel" ||
2251
+ path === ".bazelrc" ||
2252
+ path.endsWith("/BUILD") ||
2253
+ path.endsWith("/BUILD.bazel") ||
2254
+ isSourceLikePath(path));
2255
+ }
2256
+ function touchesBazelRootMetadata(paths) {
2257
+ return paths.some((path) => path === "MODULE.bazel" || path === "WORKSPACE" || path === "WORKSPACE.bazel" || path === ".bazelrc");
2258
+ }
2259
+ function touchesBuck(paths) {
2260
+ return paths.some((path) => path === ".buckconfig" || path === "BUCK" || path === "BUCK.v2" || path.endsWith("/BUCK") || path.endsWith("/BUCK.v2") || isSourceLikePath(path));
2261
+ }
2262
+ function touchesBuckRootMetadata(paths) {
2263
+ return paths.some((path) => path === ".buckconfig");
2264
+ }
2265
+ function isKubernetesPath(path) {
2266
+ const lower = path.toLowerCase();
2267
+ return (/(^|\/)(chart\.yaml|values\.ya?ml|kustomization\.ya?ml)$/.test(lower) ||
2268
+ /(^|\/)templates\/.+\.(ya?ml|tpl)$/.test(lower) ||
2269
+ (/(^|\/)(k8s|kubernetes|manifests|charts|helm)\//.test(lower) && /\.(ya?ml|tpl)$/.test(lower)));
2270
+ }
2271
+ function nearestManifestRoot(root, path, manifestNames) {
2272
+ let current = parentPath(path);
2273
+ for (;;) {
2274
+ if (manifestNames.some((manifestName) => existsSync(join(root, current, manifestName))))
2275
+ return current || ".";
2276
+ const next = parentPath(current);
2277
+ if (next === current)
2278
+ return undefined;
2279
+ current = next;
2280
+ }
2281
+ }
2282
+ function findFilesWithExtension(root, extension, maxDepth) {
2283
+ const results = [];
2284
+ const ignoredDirs = new Set([".git", "node_modules", "dist", "coverage", ".next", "bin", "obj"]);
2285
+ const walk = (relativeDir, depth) => {
2286
+ if (depth > maxDepth)
2287
+ return;
2288
+ let entries;
2289
+ try {
2290
+ entries = readdirSync(join(root, relativeDir), { withFileTypes: true });
2291
+ }
2292
+ catch {
2293
+ return;
2294
+ }
2295
+ for (const entry of entries) {
2296
+ const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
2297
+ if (entry.isDirectory()) {
2298
+ if (!ignoredDirs.has(entry.name))
2299
+ walk(relativePath, depth + 1);
2300
+ }
2301
+ else if (entry.isFile() && entry.name.endsWith(extension)) {
2302
+ results.push(relativePath);
2303
+ }
2304
+ }
2305
+ };
2306
+ walk("", 0);
2307
+ return results.sort();
2308
+ }
2309
+ function kubernetesManifestRoot(path) {
2310
+ const segments = path.split("/");
2311
+ const anchorIndex = segments.findIndex((segment) => ["k8s", "kubernetes", "manifests"].includes(segment.toLowerCase()));
2312
+ if (anchorIndex >= 0)
2313
+ return segments.slice(0, anchorIndex + 1).join("/");
2314
+ return parentPath(path) || ".";
2315
+ }
2316
+ function parentPath(path) {
2317
+ const slash = path.lastIndexOf("/");
2318
+ return slash >= 0 ? path.slice(0, slash) : "";
2319
+ }
2320
+ function toRepoPath(path) {
2321
+ return path.replaceAll("\\", "/");
2322
+ }
2323
+ function isSourceLikePath(path) {
2324
+ return /\.(ts|tsx|js|jsx|mjs|cjs|py|rs|go|java|kt|kts|rb|php|cs|fs|swift|scala)$/.test(path);
2325
+ }
2326
+ function touches(paths, tokens) {
2327
+ return paths.some((path) => tokens.some((token) => matchesToken(path, token)));
2328
+ }
2329
+ function matchesToken(path, token) {
2330
+ // Extension tokens (".ts") match by suffix; filename tokens ("package.json")
2331
+ // must match the whole path or a complete path segment, so "mypackage.json"
2332
+ // or "x.go.mod" no longer falsely activate an ecosystem.
2333
+ if (token.startsWith("."))
2334
+ return path.endsWith(token);
2335
+ return path === token || path.endsWith(`/${token}`);
2336
+ }
2337
+ function pushUnique(plans, plan) {
2338
+ addCommandPlan(plans, plan);
2339
+ }
2340
+ function quoteShell(value) {
2341
+ if (/^[A-Za-z0-9_./@=:-]+$/.test(value))
2342
+ return value;
2343
+ return `'${value.replaceAll("'", "'\\''")}'`;
2344
+ }
2345
+ function pascalCase(value) {
2346
+ return value ? `${value[0]?.toUpperCase()}${value.slice(1)}` : value;
2347
+ }
2348
+ function slug(value) {
2349
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "package";
2350
+ }
2351
+ //# sourceMappingURL=planner.js.map