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.
- package/.patchdrill.yml +33 -0
- package/CHANGELOG.md +150 -0
- package/CONTRIBUTING.md +59 -0
- package/LICENSE +21 -0
- package/README.md +601 -0
- package/SECURITY.md +28 -0
- package/action.yml +338 -0
- package/dist/baseline.d.ts +9 -0
- package/dist/baseline.js +38 -0
- package/dist/baseline.js.map +1 -0
- package/dist/cli.d.ts +19 -0
- package/dist/cli.js +662 -0
- package/dist/cli.js.map +1 -0
- package/dist/codeowners.d.ts +14 -0
- package/dist/codeowners.js +104 -0
- package/dist/codeowners.js.map +1 -0
- package/dist/command-plan.d.ts +3 -0
- package/dist/command-plan.js +26 -0
- package/dist/command-plan.js.map +1 -0
- package/dist/demo.d.ts +5 -0
- package/dist/demo.js +525 -0
- package/dist/demo.js.map +1 -0
- package/dist/dependency.d.ts +4 -0
- package/dist/dependency.js +1424 -0
- package/dist/dependency.js.map +1 -0
- package/dist/doctor.d.ts +26 -0
- package/dist/doctor.js +183 -0
- package/dist/doctor.js.map +1 -0
- package/dist/evidence.d.ts +64 -0
- package/dist/evidence.js +352 -0
- package/dist/evidence.js.map +1 -0
- package/dist/git.d.ts +16 -0
- package/dist/git.js +349 -0
- package/dist/git.js.map +1 -0
- package/dist/i18n-catalog.d.ts +8 -0
- package/dist/i18n-catalog.js +446 -0
- package/dist/i18n-catalog.js.map +1 -0
- package/dist/i18n.d.ts +20 -0
- package/dist/i18n.js +67 -0
- package/dist/i18n.js.map +1 -0
- package/dist/init.d.ts +13 -0
- package/dist/init.js +312 -0
- package/dist/init.js.map +1 -0
- package/dist/markdown-links.d.ts +18 -0
- package/dist/markdown-links.js +180 -0
- package/dist/markdown-links.js.map +1 -0
- package/dist/package-scripts.d.ts +3 -0
- package/dist/package-scripts.js +55 -0
- package/dist/package-scripts.js.map +1 -0
- package/dist/planner.d.ts +8 -0
- package/dist/planner.js +2351 -0
- package/dist/planner.js.map +1 -0
- package/dist/policy.d.ts +12 -0
- package/dist/policy.js +255 -0
- package/dist/policy.js.map +1 -0
- package/dist/project.d.ts +2 -0
- package/dist/project.js +1085 -0
- package/dist/project.js.map +1 -0
- package/dist/release-readiness.d.ts +25 -0
- package/dist/release-readiness.js +426 -0
- package/dist/release-readiness.js.map +1 -0
- package/dist/report-annotations.d.ts +3 -0
- package/dist/report-annotations.js +28 -0
- package/dist/report-annotations.js.map +1 -0
- package/dist/report-contract.d.ts +2 -0
- package/dist/report-contract.js +82 -0
- package/dist/report-contract.js.map +1 -0
- package/dist/report-html.d.ts +7 -0
- package/dist/report-html.js +706 -0
- package/dist/report-html.js.map +1 -0
- package/dist/report-sarif.d.ts +2 -0
- package/dist/report-sarif.js +90 -0
- package/dist/report-sarif.js.map +1 -0
- package/dist/report.d.ts +14 -0
- package/dist/report.js +310 -0
- package/dist/report.js.map +1 -0
- package/dist/risk.d.ts +19 -0
- package/dist/risk.js +1226 -0
- package/dist/risk.js.map +1 -0
- package/dist/runner.d.ts +8 -0
- package/dist/runner.js +113 -0
- package/dist/runner.js.map +1 -0
- package/dist/scan.d.ts +2 -0
- package/dist/scan.js +195 -0
- package/dist/scan.js.map +1 -0
- package/dist/schema.d.ts +12 -0
- package/dist/schema.js +30 -0
- package/dist/schema.js.map +1 -0
- package/dist/stack-coverage.d.ts +8 -0
- package/dist/stack-coverage.js +94 -0
- package/dist/stack-coverage.js.map +1 -0
- package/dist/types.d.ts +206 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/verification.d.ts +11 -0
- package/dist/verification.js +108 -0
- package/dist/verification.js.map +1 -0
- package/docs/ANNOTATIONS.md +34 -0
- package/docs/ARCHITECTURE.md +79 -0
- package/docs/BASELINES.md +32 -0
- package/docs/CASE_STUDIES.md +106 -0
- package/docs/CODEOWNERS.md +23 -0
- package/docs/DASHBOARD.md +87 -0
- package/docs/EVIDENCE.md +55 -0
- package/docs/LAUNCH_PLAYBOOK.md +103 -0
- package/docs/MONOREPOS.md +74 -0
- package/docs/POLICY.md +98 -0
- package/docs/PROOF_PACKS.md +57 -0
- package/docs/PR_COMMENTS.md +56 -0
- package/docs/RELEASE.md +35 -0
- package/docs/ROADMAP.md +152 -0
- package/docs/RULE_CATALOG.md +90 -0
- package/docs/SARIF.md +74 -0
- package/docs/SCHEMAS.md +49 -0
- package/docs/SECURITY_POSTURE.md +32 -0
- package/docs/STACK_COVERAGE.md +20 -0
- package/docs/assets/patchdrill-demo.svg +21 -0
- package/docs/media/patchdrill-dashboard.png +0 -0
- package/docs/media/patchdrill-demo.gif +0 -0
- package/examples/case-studies/README.md +20 -0
- package/examples/demo/README.md +21 -0
- package/examples/demo/patchdrill-demo-summary.md +35 -0
- package/examples/demo/patchdrill-demo.html +623 -0
- package/examples/demo/patchdrill-demo.json +355 -0
- package/examples/demo/patchdrill-demo.md +120 -0
- package/examples/demo/patchdrill-demo.sarif +195 -0
- package/examples/report.md +128 -0
- package/examples/risky-agent-pr/README.md +15 -0
- package/examples/risky-agent-pr/patchdrill-demo-summary.md +41 -0
- package/examples/risky-agent-pr/patchdrill-demo.html +681 -0
- package/examples/risky-agent-pr/patchdrill-demo.json +483 -0
- package/examples/risky-agent-pr/patchdrill-demo.md +140 -0
- package/examples/risky-agent-pr/patchdrill-demo.sarif +398 -0
- package/fixtures/stacks/README.md +4 -0
- package/fixtures/stacks/android-gradle/fixture.json +33 -0
- package/fixtures/stacks/aspnet-core-service/fixture.json +36 -0
- package/fixtures/stacks/bazel-workspace/fixture.json +30 -0
- package/fixtures/stacks/buck2-workspace/fixture.json +30 -0
- package/fixtures/stacks/cargo-workspace/fixture.json +48 -0
- package/fixtures/stacks/django-app/fixture.json +25 -0
- package/fixtures/stacks/docker-compose/fixture.json +17 -0
- package/fixtures/stacks/dockerfile-service/fixture.json +17 -0
- package/fixtures/stacks/dotnet-service/fixture.json +36 -0
- package/fixtures/stacks/dotnet-solution-filter/fixture.json +62 -0
- package/fixtures/stacks/fastapi-app/fixture.json +29 -0
- package/fixtures/stacks/go-workspace/fixture.json +48 -0
- package/fixtures/stacks/java-gradle/fixture.json +29 -0
- package/fixtures/stacks/java-maven/fixture.json +32 -0
- package/fixtures/stacks/kubernetes-helm/fixture.json +25 -0
- package/fixtures/stacks/kubernetes-kustomize/fixture.json +21 -0
- package/fixtures/stacks/nested-go-workspace/fixture.json +51 -0
- package/fixtures/stacks/nextjs-app/fixture.json +34 -0
- package/fixtures/stacks/node-turbo-workspace/fixture.json +39 -0
- package/fixtures/stacks/pants-python/fixture.json +33 -0
- package/fixtures/stacks/php-composer/fixture.json +31 -0
- package/fixtures/stacks/python-service/fixture.json +21 -0
- package/fixtures/stacks/rails-app/fixture.json +25 -0
- package/fixtures/stacks/spring-boot-gradle/fixture.json +29 -0
- package/fixtures/stacks/spring-boot-maven/fixture.json +43 -0
- package/fixtures/stacks/swift-package/fixture.json +21 -0
- package/fixtures/stacks/terraform-module/fixture.json +17 -0
- package/fixtures/stacks/uv-python-service/fixture.json +47 -0
- package/fixtures/stacks/xcode-app/fixture.json +72 -0
- package/package.json +80 -0
- package/schemas/patchdrill-doctor.schema.json +171 -0
- package/schemas/patchdrill-evidence.schema.json +239 -0
- package/schemas/patchdrill-policy.schema.json +170 -0
- package/schemas/patchdrill-release-check.schema.json +78 -0
- package/schemas/patchdrill-report.schema.json +647 -0
package/dist/planner.js
ADDED
|
@@ -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
|