@pushpalsdev/cli 1.0.81 → 1.0.82
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/package.json +1 -1
- package/runtime/sandbox/.pushpals-remotebuddy-fallback.js +21 -0
- package/runtime/sandbox/apps/workerpals/Dockerfile.sandbox +5 -0
- package/runtime/sandbox/apps/workerpals/src/execute_job.ts +158 -0
- package/runtime/sandbox/packages/shared/src/index.ts +11 -0
- package/runtime/sandbox/packages/shared/src/toolchain.ts +509 -0
package/package.json
CHANGED
|
@@ -1742,6 +1742,27 @@ var KNOWN_TOOL_NAMES = new Set([
|
|
|
1742
1742
|
"python",
|
|
1743
1743
|
"shell"
|
|
1744
1744
|
]);
|
|
1745
|
+
// packages/shared/src/toolchain.ts
|
|
1746
|
+
var SHELL_CONTROL_TOKENS = new Set(["|", "||", "&", "&&", ";", ">", ">>", "<", "<<"]);
|
|
1747
|
+
var NODE_BACKED_CLI_NAMES = new Set([
|
|
1748
|
+
"astro",
|
|
1749
|
+
"babel",
|
|
1750
|
+
"cypress",
|
|
1751
|
+
"eslint",
|
|
1752
|
+
"expo",
|
|
1753
|
+
"jest",
|
|
1754
|
+
"metro",
|
|
1755
|
+
"next",
|
|
1756
|
+
"nuxt",
|
|
1757
|
+
"playwright",
|
|
1758
|
+
"react-native",
|
|
1759
|
+
"rollup",
|
|
1760
|
+
"tsc",
|
|
1761
|
+
"tsx",
|
|
1762
|
+
"vite",
|
|
1763
|
+
"vitest",
|
|
1764
|
+
"webpack"
|
|
1765
|
+
]);
|
|
1745
1766
|
// packages/shared/src/session_event_visibility.ts
|
|
1746
1767
|
var ALWAYS_VISIBLE_EVENT_TYPES = new Set(["question_asked"]);
|
|
1747
1768
|
// packages/shared/src/localbuddy_runtime.ts
|
|
@@ -11,14 +11,18 @@ import {
|
|
|
11
11
|
explicitSourceControlCommitIdentityFromEnv,
|
|
12
12
|
loadPromptTemplate,
|
|
13
13
|
loadPushPalsConfig,
|
|
14
|
+
buildToolchainPlan,
|
|
14
15
|
extractVisionKeyItems,
|
|
16
|
+
formatToolRequirement,
|
|
15
17
|
matchesGlob,
|
|
16
18
|
normalizeAutonomyComponentArea,
|
|
17
19
|
normalizeTargetPath,
|
|
20
|
+
requirementsForValidationCommand,
|
|
18
21
|
sanitizeSourceControlIdentityField,
|
|
19
22
|
validateScopeInvariants,
|
|
20
23
|
type AutonomyComponentArea,
|
|
21
24
|
type SourceControlCommitIdentity,
|
|
25
|
+
type ToolRequirement,
|
|
22
26
|
} from "shared";
|
|
23
27
|
import { resolveExecutor, type WorkerpalsRuntimeConfig } from "./common/executor_backend.js";
|
|
24
28
|
import type { JobPublishBlockedInfo, JobResult } from "./common/types.js";
|
|
@@ -586,6 +590,99 @@ async function runValidationCommand(
|
|
|
586
590
|
};
|
|
587
591
|
}
|
|
588
592
|
|
|
593
|
+
interface ToolAvailabilityResult {
|
|
594
|
+
requirement: ToolRequirement;
|
|
595
|
+
ok: boolean;
|
|
596
|
+
candidate: string | null;
|
|
597
|
+
detail: string;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function toolProbeArgv(candidate: string): string[] {
|
|
601
|
+
const normalized = candidate.toLowerCase();
|
|
602
|
+
if (normalized === "sh") {
|
|
603
|
+
return [candidate, "-c", "exit 0"];
|
|
604
|
+
}
|
|
605
|
+
if (normalized === "cmd") {
|
|
606
|
+
return [candidate, "/c", "exit 0"];
|
|
607
|
+
}
|
|
608
|
+
if (normalized === "bash") {
|
|
609
|
+
return [candidate, "-lc", "exit 0"];
|
|
610
|
+
}
|
|
611
|
+
if (normalized === "powershell" || normalized === "pwsh") {
|
|
612
|
+
return [candidate, "-NoProfile", "-Command", "exit 0"];
|
|
613
|
+
}
|
|
614
|
+
return [candidate, "--version"];
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function checkToolCandidate(candidate: string, timeoutMs = 5_000): Promise<boolean> {
|
|
618
|
+
try {
|
|
619
|
+
const proc = Bun.spawn(toolProbeArgv(candidate), {
|
|
620
|
+
stdout: "pipe",
|
|
621
|
+
stderr: "pipe",
|
|
622
|
+
});
|
|
623
|
+
let timedOut = false;
|
|
624
|
+
const timer = setTimeout(() => {
|
|
625
|
+
timedOut = true;
|
|
626
|
+
try {
|
|
627
|
+
proc.kill();
|
|
628
|
+
} catch {
|
|
629
|
+
// ignore
|
|
630
|
+
}
|
|
631
|
+
}, Math.max(1_000, timeoutMs));
|
|
632
|
+
try {
|
|
633
|
+
const [exitCode] = await Promise.all([
|
|
634
|
+
proc.exited,
|
|
635
|
+
new Response(proc.stdout).text().catch(() => ""),
|
|
636
|
+
new Response(proc.stderr).text().catch(() => ""),
|
|
637
|
+
]);
|
|
638
|
+
return !timedOut && exitCode === 0;
|
|
639
|
+
} finally {
|
|
640
|
+
clearTimeout(timer);
|
|
641
|
+
}
|
|
642
|
+
} catch {
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function checkToolAvailability(
|
|
648
|
+
requirements: ToolRequirement[],
|
|
649
|
+
): Promise<ToolAvailabilityResult[]> {
|
|
650
|
+
const cache = new Map<string, Promise<boolean>>();
|
|
651
|
+
const check = (candidate: string) => {
|
|
652
|
+
const key = candidate.toLowerCase();
|
|
653
|
+
let cached = cache.get(key);
|
|
654
|
+
if (!cached) {
|
|
655
|
+
cached = checkToolCandidate(candidate);
|
|
656
|
+
cache.set(key, cached);
|
|
657
|
+
}
|
|
658
|
+
return cached;
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const out: ToolAvailabilityResult[] = [];
|
|
662
|
+
for (const requirement of requirements) {
|
|
663
|
+
let availableCandidate: string | null = null;
|
|
664
|
+
for (const candidate of requirement.candidates) {
|
|
665
|
+
if (await check(candidate)) {
|
|
666
|
+
availableCandidate = candidate;
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
out.push({
|
|
671
|
+
requirement,
|
|
672
|
+
ok: Boolean(availableCandidate),
|
|
673
|
+
candidate: availableCandidate,
|
|
674
|
+
detail: availableCandidate
|
|
675
|
+
? `${availableCandidate} is available`
|
|
676
|
+
: `missing ${formatToolRequirement(requirement)}`,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
return out;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function formatMissingToolRequirements(requirements: ToolRequirement[]): string {
|
|
683
|
+
return requirements.map(formatToolRequirement).join(", ");
|
|
684
|
+
}
|
|
685
|
+
|
|
589
686
|
function extractPreparedMergeConflictPaths(params: Record<string, unknown>): string[] {
|
|
590
687
|
const reviewAgent =
|
|
591
688
|
params.reviewAgent && typeof params.reviewAgent === "object" && !Array.isArray(params.reviewAgent)
|
|
@@ -607,6 +704,20 @@ function detectValidationBlocker(runs: ValidationExecutionResult[]): ValidationB
|
|
|
607
704
|
.toLowerCase();
|
|
608
705
|
if (!combined) return null;
|
|
609
706
|
|
|
707
|
+
if (
|
|
708
|
+
combined.includes("validation skipped before execution because required tool") ||
|
|
709
|
+
combined.includes("missing required tool") ||
|
|
710
|
+
combined.includes("command not found") ||
|
|
711
|
+
combined.includes("executable not found") ||
|
|
712
|
+
combined.includes("not recognized as an internal or external command")
|
|
713
|
+
) {
|
|
714
|
+
return {
|
|
715
|
+
category: "environment",
|
|
716
|
+
detail:
|
|
717
|
+
"Validation is blocked by missing required toolchain executables in the worker environment. Install/provision the missing tools or declare a supported repo toolchain before retrying this job.",
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
610
721
|
if (
|
|
611
722
|
combined.includes("cannot find module") ||
|
|
612
723
|
combined.includes("module not found") ||
|
|
@@ -1086,7 +1197,54 @@ async function runDeterministicQualityGate(
|
|
|
1086
1197
|
`[QualityGate] No runnable planning.validationSteps found; using fallback validation command(s): ${commandsToRun.join(" | ")}`,
|
|
1087
1198
|
);
|
|
1088
1199
|
}
|
|
1200
|
+
const toolchainPlan = buildToolchainPlan({
|
|
1201
|
+
repoRoot: repo,
|
|
1202
|
+
validationCommands: commandsToRun,
|
|
1203
|
+
});
|
|
1204
|
+
if (toolchainPlan.requirements.length > 0) {
|
|
1205
|
+
onLog?.(
|
|
1206
|
+
"stdout",
|
|
1207
|
+
`[QualityGate] Toolchain preflight: source=${toolchainPlan.environmentSource}, required=${toolchainPlan.requirements
|
|
1208
|
+
.map((requirement) => requirement.tool)
|
|
1209
|
+
.join(", ")}`,
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
const toolAvailability = await checkToolAvailability(toolchainPlan.requirements);
|
|
1213
|
+
const missingToolRequirements = toolAvailability
|
|
1214
|
+
.filter((entry) => !entry.ok)
|
|
1215
|
+
.map((entry) => entry.requirement);
|
|
1216
|
+
if (missingToolRequirements.length > 0) {
|
|
1217
|
+
onLog?.(
|
|
1218
|
+
"stderr",
|
|
1219
|
+
`[QualityGate] Toolchain preflight blocked dependent validation command(s): ${formatMissingToolRequirements(
|
|
1220
|
+
missingToolRequirements,
|
|
1221
|
+
)}`,
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1089
1224
|
for (const command of commandsToRun) {
|
|
1225
|
+
const commandMissingTools = requirementsForValidationCommand(toolchainPlan, command).filter(
|
|
1226
|
+
(requirement) =>
|
|
1227
|
+
missingToolRequirements.some((missing) => missing.tool === requirement.tool),
|
|
1228
|
+
);
|
|
1229
|
+
if (commandMissingTools.length > 0) {
|
|
1230
|
+
const stderr = `Validation skipped before execution because required tool(s) are missing: ${formatMissingToolRequirements(
|
|
1231
|
+
commandMissingTools,
|
|
1232
|
+
)}.`;
|
|
1233
|
+
validationRuns.push({
|
|
1234
|
+
step: command,
|
|
1235
|
+
command,
|
|
1236
|
+
ok: false,
|
|
1237
|
+
exitCode: 127,
|
|
1238
|
+
stdout: "",
|
|
1239
|
+
stderr,
|
|
1240
|
+
elapsedMs: 1,
|
|
1241
|
+
});
|
|
1242
|
+
onLog?.(
|
|
1243
|
+
"stderr",
|
|
1244
|
+
`[QualityGate] Quality gate validation skipped (missing toolchain): ${command}`,
|
|
1245
|
+
);
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1090
1248
|
onLog?.("stdout", `[QualityGate] Quality gate validation: running "${command}"`);
|
|
1091
1249
|
const run = await runValidationCommand(
|
|
1092
1250
|
repo,
|
|
@@ -73,6 +73,17 @@ export {
|
|
|
73
73
|
type ToolRegistry,
|
|
74
74
|
type ToolRunRecord,
|
|
75
75
|
} from "./tooling.js";
|
|
76
|
+
export {
|
|
77
|
+
buildToolchainPlan,
|
|
78
|
+
formatToolRequirement,
|
|
79
|
+
inferToolRequirementsForValidationCommand,
|
|
80
|
+
requirementsForValidationCommand,
|
|
81
|
+
tokenizeToolchainCommand,
|
|
82
|
+
type BuildToolchainPlanOptions,
|
|
83
|
+
type ToolRequirement,
|
|
84
|
+
type ToolchainEnvironmentSource,
|
|
85
|
+
type ToolchainPlan,
|
|
86
|
+
} from "./toolchain.js";
|
|
76
87
|
export {
|
|
77
88
|
DEFAULT_WORKERPALS_EXECUTOR,
|
|
78
89
|
invalidatePushPalsConfigCache,
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
|
2
|
+
import { join, normalize } from "path";
|
|
3
|
+
|
|
4
|
+
export type ToolchainEnvironmentSource =
|
|
5
|
+
| "devcontainer"
|
|
6
|
+
| "dockerfile"
|
|
7
|
+
| "mise"
|
|
8
|
+
| "asdf"
|
|
9
|
+
| "nix"
|
|
10
|
+
| "pushpals-default-sandbox";
|
|
11
|
+
|
|
12
|
+
export interface ToolRequirement {
|
|
13
|
+
tool: string;
|
|
14
|
+
candidates: string[];
|
|
15
|
+
reason: string;
|
|
16
|
+
detectedFrom: string;
|
|
17
|
+
requiredFor: string[];
|
|
18
|
+
optional?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ToolchainPlan {
|
|
22
|
+
requirements: ToolRequirement[];
|
|
23
|
+
environmentSource: ToolchainEnvironmentSource;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface BuildToolchainPlanOptions {
|
|
27
|
+
repoRoot: string;
|
|
28
|
+
validationCommands: string[];
|
|
29
|
+
maxNativeScanEntries?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SHELL_CONTROL_TOKENS = new Set(["|", "||", "&", "&&", ";", ">", ">>", "<", "<<"]);
|
|
33
|
+
|
|
34
|
+
const NODE_BACKED_CLI_NAMES = new Set([
|
|
35
|
+
"astro",
|
|
36
|
+
"babel",
|
|
37
|
+
"cypress",
|
|
38
|
+
"eslint",
|
|
39
|
+
"expo",
|
|
40
|
+
"jest",
|
|
41
|
+
"metro",
|
|
42
|
+
"next",
|
|
43
|
+
"nuxt",
|
|
44
|
+
"playwright",
|
|
45
|
+
"react-native",
|
|
46
|
+
"rollup",
|
|
47
|
+
"tsc",
|
|
48
|
+
"tsx",
|
|
49
|
+
"vite",
|
|
50
|
+
"vitest",
|
|
51
|
+
"webpack",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const DIRECT_TOOL_CANDIDATES: Record<string, string[]> = {
|
|
55
|
+
bash: ["bash"],
|
|
56
|
+
bun: ["bun"],
|
|
57
|
+
bunx: ["bun"],
|
|
58
|
+
cargo: ["cargo"],
|
|
59
|
+
cc: ["cc"],
|
|
60
|
+
clang: ["clang"],
|
|
61
|
+
"clang++": ["clang++"],
|
|
62
|
+
cmake: ["cmake"],
|
|
63
|
+
cypress: ["cypress"],
|
|
64
|
+
docker: ["docker"],
|
|
65
|
+
eslint: ["eslint"],
|
|
66
|
+
expo: ["expo"],
|
|
67
|
+
gcc: ["gcc"],
|
|
68
|
+
"g++": ["g++"],
|
|
69
|
+
gh: ["gh"],
|
|
70
|
+
go: ["go"],
|
|
71
|
+
java: ["java"],
|
|
72
|
+
javac: ["javac"],
|
|
73
|
+
make: ["make"],
|
|
74
|
+
mvn: ["mvn"],
|
|
75
|
+
next: ["next"],
|
|
76
|
+
ninja: ["ninja"],
|
|
77
|
+
node: ["node"],
|
|
78
|
+
npm: ["npm"],
|
|
79
|
+
npx: ["npx"],
|
|
80
|
+
playwright: ["playwright"],
|
|
81
|
+
pnpm: ["pnpm"],
|
|
82
|
+
powershell: ["powershell"],
|
|
83
|
+
pwsh: ["pwsh"],
|
|
84
|
+
python: ["python3", "python", "py"],
|
|
85
|
+
python3: ["python3", "python"],
|
|
86
|
+
pytest: ["python3", "python", "py"],
|
|
87
|
+
rustc: ["rustc"],
|
|
88
|
+
sh: ["sh"],
|
|
89
|
+
tsc: ["tsc"],
|
|
90
|
+
vite: ["vite"],
|
|
91
|
+
vitest: ["vitest"],
|
|
92
|
+
yarn: ["yarn"],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
interface NativeSignals {
|
|
96
|
+
hasC: boolean;
|
|
97
|
+
hasCxx: boolean;
|
|
98
|
+
hasMakefile: boolean;
|
|
99
|
+
hasCMake: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function tokenizeToolchainCommand(command: string): string[] | null {
|
|
103
|
+
const input = command.trim();
|
|
104
|
+
if (!input) return null;
|
|
105
|
+
const out: string[] = [];
|
|
106
|
+
let current = "";
|
|
107
|
+
let quote: "'" | '"' | null = null;
|
|
108
|
+
|
|
109
|
+
const pushCurrent = () => {
|
|
110
|
+
const trimmed = current.trim();
|
|
111
|
+
if (trimmed) out.push(trimmed);
|
|
112
|
+
current = "";
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
116
|
+
const ch = input[index] ?? "";
|
|
117
|
+
if (quote) {
|
|
118
|
+
if (ch === quote) {
|
|
119
|
+
quote = null;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
current += ch;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (ch === "'" || ch === '"') {
|
|
126
|
+
quote = ch;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (/\s/.test(ch)) {
|
|
130
|
+
pushCurrent();
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
current += ch;
|
|
134
|
+
}
|
|
135
|
+
if (quote) return null;
|
|
136
|
+
pushCurrent();
|
|
137
|
+
if (out.length === 0) return null;
|
|
138
|
+
if (out.some((token) => SHELL_CONTROL_TOKENS.has(token))) return null;
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function buildToolchainPlan(options: BuildToolchainPlanOptions): ToolchainPlan {
|
|
143
|
+
const repoRoot = options.repoRoot;
|
|
144
|
+
const nativeSignals = detectNativeSignals(repoRoot, options.maxNativeScanEntries ?? 1_000);
|
|
145
|
+
const requirements: ToolRequirement[] = [];
|
|
146
|
+
for (const command of options.validationCommands) {
|
|
147
|
+
requirements.push(...inferToolRequirementsForValidationCommand(repoRoot, command, nativeSignals));
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
requirements: dedupeToolRequirements(requirements),
|
|
151
|
+
environmentSource: detectToolchainEnvironmentSource(repoRoot),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function inferToolRequirementsForValidationCommand(
|
|
156
|
+
repoRoot: string,
|
|
157
|
+
command: string,
|
|
158
|
+
nativeSignals: NativeSignals = detectNativeSignals(repoRoot),
|
|
159
|
+
): ToolRequirement[] {
|
|
160
|
+
const tokens = tokenizeToolchainCommand(command);
|
|
161
|
+
if (!tokens) return [];
|
|
162
|
+
const requirements: ToolRequirement[] = [];
|
|
163
|
+
const first = normalizeToolToken(tokens[0] ?? "");
|
|
164
|
+
|
|
165
|
+
addDirectExecutableRequirement(requirements, first, command);
|
|
166
|
+
addNodeBackedCliRequirement(requirements, first, `validation command "${command}"`, command);
|
|
167
|
+
|
|
168
|
+
const bunSubcommand = resolveBunSubcommand(tokens);
|
|
169
|
+
if (bunSubcommand?.kind === "x") {
|
|
170
|
+
addNodeBackedCliRequirement(
|
|
171
|
+
requirements,
|
|
172
|
+
normalizeToolToken(bunSubcommand.value),
|
|
173
|
+
`bun x package "${bunSubcommand.value}"`,
|
|
174
|
+
command,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const script = resolvePackageScript(repoRoot, tokens);
|
|
179
|
+
if (script) {
|
|
180
|
+
addScriptRequirements(requirements, script.script, script.detectedFrom, command);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (usesNativeBuildCommand(tokens)) {
|
|
184
|
+
if (nativeSignals.hasC) {
|
|
185
|
+
requirements.push({
|
|
186
|
+
tool: "c-compiler",
|
|
187
|
+
candidates: ["cc", "gcc", "clang"],
|
|
188
|
+
reason: "native C sources may be compiled by this validation command",
|
|
189
|
+
detectedFrom: nativeSignals.hasCMake
|
|
190
|
+
? "CMakeLists.txt/native source scan"
|
|
191
|
+
: "Makefile/native source scan",
|
|
192
|
+
requiredFor: [command],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (nativeSignals.hasCxx) {
|
|
196
|
+
requirements.push({
|
|
197
|
+
tool: "cxx-compiler",
|
|
198
|
+
candidates: ["c++", "g++", "clang++"],
|
|
199
|
+
reason: "native C++ sources may be compiled by this validation command",
|
|
200
|
+
detectedFrom: nativeSignals.hasCMake
|
|
201
|
+
? "CMakeLists.txt/native source scan"
|
|
202
|
+
: "Makefile/native source scan",
|
|
203
|
+
requiredFor: [command],
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return dedupeToolRequirements(requirements);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function requirementsForValidationCommand(
|
|
212
|
+
plan: ToolchainPlan,
|
|
213
|
+
command: string,
|
|
214
|
+
): ToolRequirement[] {
|
|
215
|
+
return plan.requirements.filter((requirement) => requirement.requiredFor.includes(command));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function formatToolRequirement(requirement: ToolRequirement): string {
|
|
219
|
+
const candidates =
|
|
220
|
+
requirement.candidates.length === 1
|
|
221
|
+
? requirement.candidates[0]
|
|
222
|
+
: `${requirement.tool} (${requirement.candidates.join(" or ")})`;
|
|
223
|
+
return `${candidates} from ${requirement.detectedFrom}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function addDirectExecutableRequirement(
|
|
227
|
+
requirements: ToolRequirement[],
|
|
228
|
+
tool: string,
|
|
229
|
+
command: string,
|
|
230
|
+
): void {
|
|
231
|
+
const candidates = DIRECT_TOOL_CANDIDATES[tool];
|
|
232
|
+
if (!candidates) return;
|
|
233
|
+
requirements.push({
|
|
234
|
+
tool: canonicalToolName(tool),
|
|
235
|
+
candidates,
|
|
236
|
+
reason: `validation command invokes ${tool}`,
|
|
237
|
+
detectedFrom: `validation command "${command}"`,
|
|
238
|
+
requiredFor: [command],
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function addNodeBackedCliRequirement(
|
|
243
|
+
requirements: ToolRequirement[],
|
|
244
|
+
cliName: string,
|
|
245
|
+
detectedFrom: string,
|
|
246
|
+
command: string,
|
|
247
|
+
): void {
|
|
248
|
+
if (!NODE_BACKED_CLI_NAMES.has(cliName)) return;
|
|
249
|
+
requirements.push({
|
|
250
|
+
tool: "node",
|
|
251
|
+
candidates: ["node"],
|
|
252
|
+
reason: `${cliName} is normally distributed as a Node.js CLI`,
|
|
253
|
+
detectedFrom,
|
|
254
|
+
requiredFor: [command],
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function addScriptRequirements(
|
|
259
|
+
requirements: ToolRequirement[],
|
|
260
|
+
script: string,
|
|
261
|
+
detectedFrom: string,
|
|
262
|
+
command: string,
|
|
263
|
+
): void {
|
|
264
|
+
const tokens = tokenizeToolchainCommand(script) ?? script.split(/\s+/).filter(Boolean);
|
|
265
|
+
const first = normalizeToolToken(tokens[0] ?? "");
|
|
266
|
+
addDirectExecutableRequirement(requirements, first, command);
|
|
267
|
+
addNodeBackedCliRequirement(requirements, first, detectedFrom, command);
|
|
268
|
+
for (const token of tokens) {
|
|
269
|
+
addNodeBackedCliRequirement(requirements, normalizeToolToken(token), detectedFrom, command);
|
|
270
|
+
}
|
|
271
|
+
if (/\bnode\b/.test(script)) {
|
|
272
|
+
requirements.push({
|
|
273
|
+
tool: "node",
|
|
274
|
+
candidates: ["node"],
|
|
275
|
+
reason: "package script invokes node directly",
|
|
276
|
+
detectedFrom,
|
|
277
|
+
requiredFor: [command],
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
if (/\bbun\b/.test(script)) {
|
|
281
|
+
requirements.push({
|
|
282
|
+
tool: "bun",
|
|
283
|
+
candidates: ["bun"],
|
|
284
|
+
reason: "package script invokes bun",
|
|
285
|
+
detectedFrom,
|
|
286
|
+
requiredFor: [command],
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function resolveBunSubcommand(tokens: string[]): { kind: "run" | "x"; value: string } | null {
|
|
292
|
+
if (normalizeToolToken(tokens[0] ?? "") !== "bun") return null;
|
|
293
|
+
let index = 1;
|
|
294
|
+
while (index < tokens.length) {
|
|
295
|
+
const token = tokens[index] ?? "";
|
|
296
|
+
if (token === "--cwd" || token === "-C") {
|
|
297
|
+
index += 2;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (token.startsWith("--")) {
|
|
301
|
+
index += 1;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
const subcommand = normalizeToolToken(tokens[index] ?? "");
|
|
307
|
+
if ((subcommand === "run" || subcommand === "x") && tokens[index + 1]) {
|
|
308
|
+
return { kind: subcommand, value: tokens[index + 1] ?? "" };
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function resolvePackageScript(
|
|
314
|
+
repoRoot: string,
|
|
315
|
+
tokens: string[],
|
|
316
|
+
): { script: string; detectedFrom: string } | null {
|
|
317
|
+
const first = normalizeToolToken(tokens[0] ?? "");
|
|
318
|
+
let cwd = repoRoot;
|
|
319
|
+
let scriptName = "";
|
|
320
|
+
if (first === "bun") {
|
|
321
|
+
let index = 1;
|
|
322
|
+
while (index < tokens.length) {
|
|
323
|
+
const token = tokens[index] ?? "";
|
|
324
|
+
if ((token === "--cwd" || token === "-C") && tokens[index + 1]) {
|
|
325
|
+
cwd = join(repoRoot, tokens[index + 1] ?? "");
|
|
326
|
+
index += 2;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (token.startsWith("--")) {
|
|
330
|
+
index += 1;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
if (normalizeToolToken(tokens[index] ?? "") === "run") {
|
|
336
|
+
scriptName = tokens[index + 1] ?? "";
|
|
337
|
+
} else {
|
|
338
|
+
const candidate = tokens[index] ?? "";
|
|
339
|
+
if (candidate && !["install", "test", "x"].includes(normalizeToolToken(candidate))) {
|
|
340
|
+
scriptName = candidate;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
} else if (first === "npm" || first === "pnpm" || first === "yarn") {
|
|
344
|
+
let index = 1;
|
|
345
|
+
while (index < tokens.length) {
|
|
346
|
+
const token = tokens[index] ?? "";
|
|
347
|
+
const normalized = normalizeToolToken(token);
|
|
348
|
+
if (
|
|
349
|
+
(token === "--prefix" ||
|
|
350
|
+
token === "--dir" ||
|
|
351
|
+
token === "--cwd" ||
|
|
352
|
+
token === "-C") &&
|
|
353
|
+
tokens[index + 1]
|
|
354
|
+
) {
|
|
355
|
+
cwd = join(repoRoot, tokens[index + 1] ?? "");
|
|
356
|
+
index += 2;
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (normalized === "run") {
|
|
360
|
+
scriptName = tokens[index + 1] ?? "";
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
if (!token.startsWith("-")) {
|
|
364
|
+
scriptName = normalized;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
index += 1;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (!scriptName) return null;
|
|
371
|
+
|
|
372
|
+
const packagePath = join(cwd, "package.json");
|
|
373
|
+
if (!existsSync(packagePath)) return null;
|
|
374
|
+
try {
|
|
375
|
+
const parsed = JSON.parse(readFileSync(packagePath, "utf8")) as {
|
|
376
|
+
scripts?: Record<string, unknown>;
|
|
377
|
+
};
|
|
378
|
+
const script = parsed.scripts?.[scriptName];
|
|
379
|
+
if (typeof script !== "string" || !script.trim()) return null;
|
|
380
|
+
return {
|
|
381
|
+
script,
|
|
382
|
+
detectedFrom: `${repoRelativePath(repoRoot, packagePath)} script "${scriptName}"`,
|
|
383
|
+
};
|
|
384
|
+
} catch {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function detectToolchainEnvironmentSource(repoRoot: string): ToolchainEnvironmentSource {
|
|
390
|
+
if (existsSync(join(repoRoot, ".devcontainer", "devcontainer.json"))) return "devcontainer";
|
|
391
|
+
if (existsSync(join(repoRoot, "devcontainer.json"))) return "devcontainer";
|
|
392
|
+
if (existsSync(join(repoRoot, "Dockerfile"))) return "dockerfile";
|
|
393
|
+
if (existsSync(join(repoRoot, "mise.toml")) || existsSync(join(repoRoot, ".mise.toml"))) {
|
|
394
|
+
return "mise";
|
|
395
|
+
}
|
|
396
|
+
if (existsSync(join(repoRoot, ".tool-versions"))) return "asdf";
|
|
397
|
+
if (existsSync(join(repoRoot, "flake.nix")) || existsSync(join(repoRoot, "shell.nix"))) {
|
|
398
|
+
return "nix";
|
|
399
|
+
}
|
|
400
|
+
return "pushpals-default-sandbox";
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function detectNativeSignals(repoRoot: string, maxEntries = 1_000): NativeSignals {
|
|
404
|
+
const signals: NativeSignals = {
|
|
405
|
+
hasC: false,
|
|
406
|
+
hasCxx: false,
|
|
407
|
+
hasMakefile:
|
|
408
|
+
existsSync(join(repoRoot, "Makefile")) ||
|
|
409
|
+
existsSync(join(repoRoot, "makefile")) ||
|
|
410
|
+
existsSync(join(repoRoot, "GNUmakefile")),
|
|
411
|
+
hasCMake: existsSync(join(repoRoot, "CMakeLists.txt")),
|
|
412
|
+
};
|
|
413
|
+
const ignored = new Set([
|
|
414
|
+
".git",
|
|
415
|
+
".worktrees",
|
|
416
|
+
"node_modules",
|
|
417
|
+
"outputs",
|
|
418
|
+
"dist",
|
|
419
|
+
"build",
|
|
420
|
+
".next",
|
|
421
|
+
".expo",
|
|
422
|
+
]);
|
|
423
|
+
let visited = 0;
|
|
424
|
+
const scan = (dir: string, depth: number) => {
|
|
425
|
+
if (visited >= maxEntries || depth > 4 || (signals.hasC && signals.hasCxx)) return;
|
|
426
|
+
let entries: string[] = [];
|
|
427
|
+
try {
|
|
428
|
+
entries = readdirSync(dir);
|
|
429
|
+
} catch {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
for (const entry of entries) {
|
|
433
|
+
if (visited >= maxEntries) return;
|
|
434
|
+
if (ignored.has(entry)) continue;
|
|
435
|
+
const fullPath = join(dir, entry);
|
|
436
|
+
visited += 1;
|
|
437
|
+
let stats;
|
|
438
|
+
try {
|
|
439
|
+
stats = statSync(fullPath);
|
|
440
|
+
} catch {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (stats.isDirectory()) {
|
|
444
|
+
scan(fullPath, depth + 1);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
const lower = entry.toLowerCase();
|
|
448
|
+
if (/\.(c|h)$/.test(lower)) signals.hasC = true;
|
|
449
|
+
if (/\.(cc|cpp|cxx|hpp|hh|hxx)$/.test(lower)) signals.hasCxx = true;
|
|
450
|
+
if (lower === "cmakelists.txt") signals.hasCMake = true;
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
scan(repoRoot, 0);
|
|
454
|
+
return signals;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function usesNativeBuildCommand(tokens: string[]): boolean {
|
|
458
|
+
return tokens.some((token) => {
|
|
459
|
+
const normalized = normalizeToolToken(token);
|
|
460
|
+
return normalized === "make" || normalized === "cmake" || normalized === "ninja";
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function dedupeToolRequirements(requirements: ToolRequirement[]): ToolRequirement[] {
|
|
465
|
+
const merged = new Map<string, ToolRequirement>();
|
|
466
|
+
for (const requirement of requirements) {
|
|
467
|
+
const key = requirement.tool;
|
|
468
|
+
const existing = merged.get(key);
|
|
469
|
+
if (!existing) {
|
|
470
|
+
merged.set(key, {
|
|
471
|
+
...requirement,
|
|
472
|
+
candidates: Array.from(new Set(requirement.candidates)),
|
|
473
|
+
requiredFor: Array.from(new Set(requirement.requiredFor)),
|
|
474
|
+
});
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
for (const candidate of requirement.candidates) {
|
|
478
|
+
if (!existing.candidates.includes(candidate)) existing.candidates.push(candidate);
|
|
479
|
+
}
|
|
480
|
+
for (const command of requirement.requiredFor) {
|
|
481
|
+
if (!existing.requiredFor.includes(command)) existing.requiredFor.push(command);
|
|
482
|
+
}
|
|
483
|
+
if (!existing.detectedFrom.includes(requirement.detectedFrom)) {
|
|
484
|
+
existing.detectedFrom = `${existing.detectedFrom}; ${requirement.detectedFrom}`;
|
|
485
|
+
}
|
|
486
|
+
if (!existing.reason.includes(requirement.reason)) {
|
|
487
|
+
existing.reason = `${existing.reason}; ${requirement.reason}`;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return Array.from(merged.values()).sort((a, b) => a.tool.localeCompare(b.tool));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function canonicalToolName(tool: string): string {
|
|
494
|
+
if (tool === "bunx") return "bun";
|
|
495
|
+
if (tool === "python3" || tool === "pytest") return "python";
|
|
496
|
+
return tool;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function normalizeToolToken(token: string): string {
|
|
500
|
+
const normalizedToken = token.trim().replace(/\\/g, "/").split("/").pop() ?? token;
|
|
501
|
+
return normalizedToken.toLowerCase().replace(/\.(cmd|exe|ps1)$/i, "");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function repoRelativePath(repoRoot: string, pathValue: string): string {
|
|
505
|
+
const root = normalize(repoRoot).replace(/\\/g, "/").replace(/\/+$/, "");
|
|
506
|
+
const path = normalize(pathValue).replace(/\\/g, "/");
|
|
507
|
+
if (path.startsWith(`${root}/`)) return path.slice(root.length + 1);
|
|
508
|
+
return path;
|
|
509
|
+
}
|