forgedock 1.29.0 → 1.30.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/bin/engine/outcome-codes.mjs +4 -2
- package/bin/engine/phases.mjs +20 -3
- package/bin/engine-cli.mjs +102 -38
- package/bin/engine.mjs +3 -1
- package/bin/lean/batch.test.mjs +62 -1
- package/bin/lean/conformance.mjs +129 -8
- package/bin/lean/conformance.test.mjs +77 -3
- package/bin/lean/context.mjs +32 -4
- package/bin/lean/context.test.mjs +71 -12
- package/bin/lean/effects.mjs +20 -6
- package/bin/lean/enumerating.mjs +189 -0
- package/bin/lean/enumerating.test.mjs +97 -0
- package/bin/lean/lean.mjs +207 -56
- package/bin/lean/lean.test.mjs +336 -9
- package/bin/lean/orchestrate.mjs +132 -83
- package/bin/lean/precheck.mjs +55 -0
- package/bin/lean/precheck.test.mjs +93 -0
- package/bin/lean/prefilter.mjs +96 -14
- package/bin/lean/prefilter.test.mjs +111 -2
- package/bin/lean/preflight.mjs +133 -0
- package/bin/lean/preflight.test.mjs +61 -0
- package/bin/lean/prompts.mjs +37 -3
- package/bin/lean/recovery.test.mjs +271 -7
- package/bin/lean/render.mjs +13 -0
- package/bin/lean/sequence.mjs +102 -0
- package/bin/lean/sequence.test.mjs +112 -0
- package/bin/lean/testmap.mjs +57 -3
- package/bin/lean/testmap.test.mjs +53 -1
- package/bin/lean/verify.mjs +7 -2
- package/bin/lean/verify.test.mjs +8 -2
- package/commands/deploy-pr.md +5 -4
- package/commands/orchestrate.md +7 -4
- package/commands/review-pr-staging.md +6 -1
- package/docs/CONFIG.md +8 -1
- package/docs/OUTCOME-CODES.md +6 -4
- package/docs/RELEASE-NOTES.md +10 -0
- package/package.json +1 -1
- package/scripts/acceptance-red-green.sh +13 -4
- package/scripts/acceptance-red-green.test.sh +14 -0
- package/scripts/code-index.sh +33 -33
- package/scripts/code-index.test.sh +21 -2
- package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +2 -0
- package/scripts/repo-facts.test.sh +5 -5
- package/scripts/review-prepare.sh +28 -6
- package/scripts/review-prepare.test.sh +22 -2
- package/scripts/test-map.sh +186 -34
- package/scripts/test-map.test.sh +250 -7
- package/scripts/verify-phase-trail.test.sh +44 -44
- package/scripts/wait-ci-green.sh +1 -1
- package/scripts/wait-ci-green.test.sh +4 -0
package/bin/lean/prefilter.mjs
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* Already-fixed pre-filter (#4123): cheap checks before any lane starts, so an issue that a merged PR or a later green
|
|
5
5
|
* CI run already resolved does not cost a worktree, a lease and a builder session.
|
|
6
6
|
* 1. merged PRs (and lane commits) that claim to fix #N -> candidate
|
|
7
|
-
* 2. auto-filed CI-failure issue whose workflow is green again on the failing branch -> closed with the run link
|
|
7
|
+
* 2. auto-filed CI-failure issue whose workflow is green again on the failing branch -> closed with the run link,
|
|
8
|
+
* unless it is flaky (#4305): flaky wording or label, a reopen after an earlier pre-filter close, or a failed run
|
|
9
|
+
* of the workflow at the green run's SHA. Then the shortcut is skipped and the issue goes on as normal.
|
|
8
10
|
* 3. every repo path the issue names is gone from origin/<lane> -> candidate
|
|
9
11
|
* A candidate gets ONE read-only, single-turn validation on a small model in the main checkout (no worktree). Only an
|
|
10
12
|
* ALREADY-FIXED/INVALID verdict with evidence closes the issue; everything else, and every error, goes to the normal lane.
|
|
@@ -12,6 +14,8 @@
|
|
|
12
14
|
*/
|
|
13
15
|
import { capComment, sanitizeMd, noComments } from "./render.mjs";
|
|
14
16
|
import { PREFILTER_SYSTEM, prefilterPrompt, parsePrefilterVerdict } from "./prompts.mjs";
|
|
17
|
+
import { isTrustedComment } from "../engine/phases.mjs";
|
|
18
|
+
import { APP_SLUG } from "../init-repo.mjs";
|
|
15
19
|
|
|
16
20
|
export const DEFAULT_PREFILTER_MODEL = "claude-haiku-5-5";
|
|
17
21
|
export const PREFILTER_MARKER = "FORGE:PREFILTER";
|
|
@@ -35,6 +39,51 @@ export function ciFailureRun(iss) {
|
|
|
35
39
|
return m && CI_FAIL_RE.test(text) ? { repo: m[1], id: Number(m[2]) } : null;
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
// #4316: a bare "sometimes" is ordinary prose; only "fails sometimes" / "sometimes fails" says the failure is intermittent
|
|
43
|
+
const FLAKY_RE = /\b(?:intermittent\w*|flaky|flak(?:e|es|iness)|fail(?:s|ed|ing)?\s+sometimes|sometimes\s+fail(?:s|ed|ing)?|non-?deterministic\w*)\b/i;
|
|
44
|
+
const loginOf = (c) => String(c?.author?.login ?? c?.user?.login ?? "");
|
|
45
|
+
const trusted = (c) => {
|
|
46
|
+
const login = loginOf(c);
|
|
47
|
+
return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
|
|
48
|
+
};
|
|
49
|
+
/** #4315: a login compared as the engine posts it: lowercased, without `app/` or a `[bot]` suffix (`gh issue view` drops both). */
|
|
50
|
+
const bareLogin = (l) => String(l ?? "").trim().toLowerCase().replace(/^app\//, "").replace(/\[bot\]$/, "");
|
|
51
|
+
const isMarked = (c) => String(c?.body ?? "").trimStart().startsWith(`<!-- ${PREFILTER_MARKER} -->`);
|
|
52
|
+
/**
|
|
53
|
+
* #4315: the logins the engine posts as: the ForgeDock App (`gh issue view` reports it as `rapiercraft-forgedock` with
|
|
54
|
+
* CONTRIBUTOR, never `[bot]`) plus the token's own user (`gh api user`; an App installation token cannot read it).
|
|
55
|
+
*/
|
|
56
|
+
export async function ownLogins(gh) {
|
|
57
|
+
return (await resolveOwnLogins(gh)).logins;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* #4318: {logins, resolved}. When `gh api user` fails (an App installation token), the App's own slug from `gh api app`
|
|
61
|
+
* (a differently named App); `resolved` is false when neither lookup answered, so the batch memo may ask once more.
|
|
62
|
+
*/
|
|
63
|
+
export async function resolveOwnLogins(gh) {
|
|
64
|
+
const out = new Set([bareLogin(APP_SLUG)]);
|
|
65
|
+
let resolved = false;
|
|
66
|
+
try { const me = bareLogin(await gh(["api", "user", "--jq", ".login"])); if (me) { out.add(me); resolved = true; } } catch { /* installation token */ }
|
|
67
|
+
if (!resolved) {
|
|
68
|
+
try { const slug = bareLogin(await gh(["api", "app", "--jq", ".slug"])); if (slug) { out.add(slug); resolved = true; } } catch { /* the App slug only */ }
|
|
69
|
+
}
|
|
70
|
+
return { logins: [...out], resolved };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* #4305: why one green run is no proof for this issue, without an API call: flaky wording or a `flaky` label, or an
|
|
74
|
+
* earlier pre-filter close by the pipeline (the issue is open again, so an operator reopened it). {reason, why} or null.
|
|
75
|
+
* #4315: a marker comment is the pipeline's own when it is trusted or written by one of `own` (bare logins).
|
|
76
|
+
*/
|
|
77
|
+
export function flakyReason(iss, own = [bareLogin(APP_SLUG)]) {
|
|
78
|
+
if (FLAKY_RE.test(`${iss.title ?? ""}\n${noComments(String(iss.body ?? ""), " ")}`)) return { reason: "flaky", why: "issue says the failure is intermittent" };
|
|
79
|
+
if ((iss.labels ?? []).some((l) => String(l?.name ?? l).toLowerCase() === "flaky")) return { reason: "flaky", why: "flaky label" };
|
|
80
|
+
const mine = new Set((own ?? []).map(bareLogin));
|
|
81
|
+
if ((iss.comments ?? []).some((c) => isMarked(c) && (trusted(c) || mine.has(bareLogin(loginOf(c)))))) return { reason: "reopened", why: "reopened after a pre-filter close" };
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
/** The own logins, looked up only when an otherwise-untrusted marker comment needs them (no call for most issues). */
|
|
85
|
+
const ownFor = async (iss, resolve) => ((iss.comments ?? []).some((c) => isMarked(c) && !trusted(c)) ? resolve() : undefined);
|
|
86
|
+
|
|
38
87
|
const PATH_RE = /(?:^|[\s`'"(\[])((?:\.{0,2}\/)?(?:[\w@.-]+\/)+[\w@.-]+\.[A-Za-z0-9]{1,8})(?::\d+(?:-\d+)?)?(?=$|[\s`'"),.:;\]])/gm;
|
|
39
88
|
/** Repo-relative file paths the issue names (`dir/file.ext`, optionally `:line`); URLs and HTML comments are ignored. */
|
|
40
89
|
export function namedPaths(text) {
|
|
@@ -43,9 +92,9 @@ export function namedPaths(text) {
|
|
|
43
92
|
}
|
|
44
93
|
|
|
45
94
|
/**
|
|
46
|
-
* @returns {Promise<{prs:object[], commits:object[], ci:?object, missingPaths:string[], namedPaths:string[]}>}
|
|
95
|
+
* @returns {Promise<{prs:object[], commits:object[], ci:?object, ciSkip?:{reason:string, why:string}, missingPaths:string[], namedPaths:string[]}>}
|
|
47
96
|
*/
|
|
48
|
-
export async function collectSignals({ iss, repo, lane, io, cwd }) {
|
|
97
|
+
export async function collectSignals({ iss, repo, lane, io, cwd, selfLogins = null }) {
|
|
49
98
|
const n = iss.number;
|
|
50
99
|
const sig = { prs: [], commits: [], ci: null, missingPaths: [], namedPaths: [] };
|
|
51
100
|
try {
|
|
@@ -63,7 +112,19 @@ export async function collectSignals({ iss, repo, lane, io, cwd }) {
|
|
|
63
112
|
const branch = failed.head_branch || lane;
|
|
64
113
|
const latest = JSON.parse(String(await io.gh(["api", `repos/${repo}/actions/workflows/${failed.workflow_id}/runs?branch=${encodeURIComponent(branch)}&status=completed&per_page=1`])))?.workflow_runs?.[0];
|
|
65
114
|
if (latest && latest.id !== failed.id && latest.conclusion === "success" && Date.parse(latest.created_at) > Date.parse(failed.created_at)) {
|
|
66
|
-
|
|
115
|
+
const sha = latest.head_sha ?? "";
|
|
116
|
+
// #4305: a workflow that failed and passed at the same SHA is flaky; the lookup is any branch (PR run vs push)
|
|
117
|
+
// and only made when the issue itself gave no reason. An unreadable lookup throws: no CI signal.
|
|
118
|
+
const own = await ownFor(iss, typeof selfLogins === "function" ? selfLogins : selfLogins ? async () => selfLogins : () => ownLogins(io.gh));
|
|
119
|
+
let skip = flakyReason(iss, own);
|
|
120
|
+
const same = { reason: "same-sha", why: `failed and passed at ${sha.slice(0, 12)}` };
|
|
121
|
+
if (!skip && sha && failed.head_sha === sha) skip = same;
|
|
122
|
+
if (!skip && sha) {
|
|
123
|
+
const atSha = JSON.parse(String(await io.gh(["api", `repos/${repo}/actions/workflows/${failed.workflow_id}/runs?head_sha=${sha}&status=completed&per_page=100`])))?.workflow_runs ?? [];
|
|
124
|
+
if (atSha.some((r) => r?.conclusion === "failure")) skip = same;
|
|
125
|
+
}
|
|
126
|
+
if (skip) sig.ciSkip = skip;
|
|
127
|
+
else sig.ci = { workflow: failed.name ?? latest.name ?? String(failed.workflow_id), branch, failedUrl: failed.html_url, greenUrl: latest.html_url, greenSha: sha };
|
|
67
128
|
}
|
|
68
129
|
} catch { /* run unreadable: no CI signal */ }
|
|
69
130
|
}
|
|
@@ -82,10 +143,10 @@ export async function collectSignals({ iss, repo, lane, io, cwd }) {
|
|
|
82
143
|
* Pre-filter one issue. Never throws.
|
|
83
144
|
* @returns {Promise<{issue:number, closed:boolean, kind:?string, candidate:boolean, verdict:?string, cost_usd:number, detail:string}>}
|
|
84
145
|
*/
|
|
85
|
-
export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5 }) {
|
|
146
|
+
export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5, selfLogins = null }) {
|
|
86
147
|
const out = { issue, closed: false, kind: null, candidate: false, verdict: null, cost_usd: 0, detail: "" };
|
|
87
148
|
try {
|
|
88
|
-
const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels"])));
|
|
149
|
+
const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels,comments"])));
|
|
89
150
|
if (String(iss.state).toUpperCase() !== "OPEN") return { ...out, detail: "not open" };
|
|
90
151
|
if ((iss.labels ?? []).some((l) => ["needs-human", "workflow:merged"].includes(l?.name ?? l))) return { ...out, detail: "labelled, left to the lane" };
|
|
91
152
|
if (io.projector) {
|
|
@@ -93,35 +154,48 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
|
|
|
93
154
|
try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed */ }
|
|
94
155
|
if (st?.lease && st.lease.until > Date.now() && st.lease.by !== agentId) return { ...out, detail: `leased by ${st.lease.by}` };
|
|
95
156
|
}
|
|
96
|
-
const sig = await collectSignals({ iss, repo, lane, io, cwd });
|
|
157
|
+
const sig = await collectSignals({ iss, repo, lane, io, cwd, selfLogins });
|
|
97
158
|
const close = async (kind, body, detail) => {
|
|
159
|
+
let note = "";
|
|
98
160
|
if (!dryRun) {
|
|
99
|
-
|
|
161
|
+
// #4315: close first: the marker is posted only on an issue that was closed, so it always means "reopened" later
|
|
100
162
|
await io.gh(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
|
|
163
|
+
// #4314: the issue IS closed from here on: the result is closed:true whatever the comment does, so no lane starts on it.
|
|
164
|
+
// The comment is retried once, then a marker-only comment (a second, tiny payload) keeps the #4305 reopen guard working.
|
|
165
|
+
const comment = (text) => io.gh(["issue", "comment", String(issue), "-R", repo, "--body", text]);
|
|
166
|
+
const full = capComment(`<!-- ${PREFILTER_MARKER} -->\n<!-- FORGE:VALIDATE -->\n${body}`);
|
|
167
|
+
const minimal = `<!-- ${PREFILTER_MARKER} -->\nClosed by the pre-filter (${kind}); the explanation comment could not be posted. Reopen if this is wrong.`;
|
|
168
|
+
let posted = false;
|
|
169
|
+
for (const text of [full, full, minimal]) {
|
|
170
|
+
try { await comment(text); posted = true; break; } catch { /* next attempt */ }
|
|
171
|
+
}
|
|
172
|
+
if (!posted) note = " (closed, but no marker comment could be posted: a reopen is not recognised)";
|
|
101
173
|
try { await io.transitionLabel?.(issue, "invalid"); } catch { /* labels are best effort */ }
|
|
102
174
|
}
|
|
103
|
-
return { ...out, closed: !dryRun, kind, detail };
|
|
175
|
+
return { ...out, closed: !dryRun, kind, detail: `${detail}${note}` };
|
|
104
176
|
};
|
|
105
177
|
|
|
178
|
+
// #4305: a skipped CI-green shortcut is named in every non-closing detail line that follows (#4316: with its reason).
|
|
179
|
+
const keep = (detail) => ({ ...out, detail: sig.ciSkip ? `skipped CI-green shortcut: ${sig.ciSkip.reason} (${sig.ciSkip.why}); ${detail}` : detail });
|
|
106
180
|
if (sig.ci) {
|
|
107
181
|
out.candidate = true;
|
|
108
182
|
const c = sig.ci;
|
|
109
183
|
return await close("ci-green", `## Pre-filter: CI failure no longer reproduces\n**Workflow:** ${c.workflow} on \`${c.branch}\`\n**Failing run:** ${c.failedUrl}\n**Latest run (green):** ${c.greenUrl}${c.greenSha ? ` at \`${c.greenSha.slice(0, 12)}\`` : ""}\n\nClosed without a lane. Reopen if the failure comes back.`,
|
|
110
184
|
`CI green again: ${c.greenUrl}`);
|
|
111
185
|
}
|
|
112
|
-
if (!sig.prs.length && !sig.commits.length && !sig.missingPaths.length) return
|
|
186
|
+
if (!sig.prs.length && !sig.commits.length && !sig.missingPaths.length) return keep("no already-fixed signal");
|
|
113
187
|
out.candidate = true;
|
|
114
188
|
const why = [...sig.prs.map((p) => `PR #${p.number}`), ...sig.commits.map((c) => c.sha.slice(0, 12)), ...(sig.missingPaths.length ? ["named paths missing"] : [])].join(", ");
|
|
115
|
-
if (dryRun) return
|
|
189
|
+
if (dryRun) return keep(`candidate: ${why}`);
|
|
116
190
|
let r;
|
|
117
191
|
try {
|
|
118
192
|
r = await io.runner({ role: "prefilter", issue, cwd, model, resume: null, system: PREFILTER_SYSTEM, readOnly: true,
|
|
119
193
|
prompt: prefilterPrompt({ issue: iss, lane, signals: sig }), maxMs: Math.round(maxMinutes * 60000) });
|
|
120
|
-
} catch (e) { return
|
|
194
|
+
} catch (e) { return keep(`validation failed (${String(e?.message ?? e).slice(0, 120)}): lane`); }
|
|
121
195
|
out.cost_usd = Number.isFinite(r?.costUsd) ? r.costUsd : 0;
|
|
122
196
|
const v = parsePrefilterVerdict(r?.text);
|
|
123
197
|
out.verdict = v?.verdict ?? null;
|
|
124
|
-
if (!v || v.verdict === "CONFIRMED" || !v.evidence.length) return
|
|
198
|
+
if (!v || v.verdict === "CONFIRMED" || !v.evidence.length) return keep(`candidate (${why}) ${v ? (v.verdict === "CONFIRMED" ? "confirmed" : "without evidence") : "unparseable verdict"}: lane`);
|
|
125
199
|
const ev = v.evidence.map((x) => `- ${sanitizeMd(x).replace(/\s+/g, " ").slice(0, 400)}`).join("\n");
|
|
126
200
|
return await close("validated", `## Pre-filter: ${v.verdict}\n**Signal:** ${why}\n**Summary:** ${sanitizeMd(v.summary).replace(/\s+/g, " ").slice(0, 600) || "not reported"}\n\n**Evidence:**\n${ev}\n\nValidated by a single read-only check (${model}), without a lane. Reopen if this is wrong.`,
|
|
127
201
|
`${v.verdict.toLowerCase()} (${why})`);
|
|
@@ -132,7 +206,15 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
|
|
|
132
206
|
|
|
133
207
|
/** Per-issue hook for `orchestrate({ prefilter })`; `lane` is the issue's own resolved lane (#4125). */
|
|
134
208
|
export function makePrefilter(opts) {
|
|
135
|
-
|
|
209
|
+
// #4315: one identity lookup per batch, and only when a marker comment needs it; #4318: an unresolved one is retried once
|
|
210
|
+
let own = null, tries = 0;
|
|
211
|
+
const selfLogins = opts.selfLogins ?? (() => (own ??= (async () => {
|
|
212
|
+
tries++;
|
|
213
|
+
const r = await resolveOwnLogins(opts.io.gh);
|
|
214
|
+
if (!r.resolved && tries < 2) own = null;
|
|
215
|
+
return r.logins;
|
|
216
|
+
})()));
|
|
217
|
+
return (issue, lane) => prefilterIssue({ ...opts, selfLogins, issue, ...(lane ? { lane } : {}) });
|
|
136
218
|
}
|
|
137
219
|
|
|
138
220
|
/** One report line: how many the pre-filter checked, flagged and closed, what validation cost, and the spend avoided. */
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
|
-
import { prefilterIssue, makePrefilter, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, DEFAULT_PREFILTER_MODEL } from "./prefilter.mjs";
|
|
5
|
+
import { prefilterIssue, flakyReason, makePrefilter, ownLogins, resolveOwnLogins, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, DEFAULT_PREFILTER_MODEL } from "./prefilter.mjs";
|
|
6
6
|
import { parsePrefilterVerdict, prefilterPrompt } from "./prompts.mjs";
|
|
7
7
|
import { orchestrate, formatTable } from "./orchestrate.mjs";
|
|
8
8
|
import { triageFromCli } from "../engine-cli.mjs";
|
|
@@ -12,7 +12,7 @@ const fence = (o) => "```json\n" + JSON.stringify(o) + "\n```";
|
|
|
12
12
|
/** Fake GitHub + git for one repo `o/r`, lane `staging`. */
|
|
13
13
|
function world(o = {}) {
|
|
14
14
|
const w = { comments: [], closed: [], labels: [], runner: [], lanes: [], gitCalls: [],
|
|
15
|
-
issues: o.issues ?? {}, prs: o.prs ?? [], commits: o.commits ?? "", paths: new Set(o.paths ?? []), runs: o.runs ?? {}, workflowRuns: o.workflowRuns ?? {},
|
|
15
|
+
issues: o.issues ?? {}, prs: o.prs ?? [], commits: o.commits ?? "", paths: new Set(o.paths ?? []), runs: o.runs ?? {}, workflowRuns: o.workflowRuns ?? {}, shaRuns: o.shaRuns ?? {},
|
|
16
16
|
verdict: o.verdict ?? null };
|
|
17
17
|
w.io = {
|
|
18
18
|
gh: async (a) => {
|
|
@@ -25,6 +25,8 @@ function world(o = {}) {
|
|
|
25
25
|
if (m) return JSON.stringify(w.runs[m[1]]);
|
|
26
26
|
const wf = /actions\/workflows\/(\d+)\/runs\?branch=([^&]+)/.exec(a[1]);
|
|
27
27
|
if (wf) return JSON.stringify({ workflow_runs: w.workflowRuns[`${wf[1]}:${decodeURIComponent(wf[2])}`] ?? [] });
|
|
28
|
+
const at = /actions\/workflows\/\d+\/runs\?head_sha=([^&]+)/.exec(a[1]);
|
|
29
|
+
if (at) { w.shaQueries = (w.shaQueries ?? 0) + 1; return JSON.stringify({ workflow_runs: w.shaRuns[at[1]] ?? [] }); }
|
|
28
30
|
}
|
|
29
31
|
if (k === "issue comment") { w.comments.push([Number(a[2]), a[a.indexOf("--body") + 1]]); return ""; }
|
|
30
32
|
if (k === "issue close") { w.closed.push([Number(a[2]), a[a.indexOf("--reason") + 1]]); return ""; }
|
|
@@ -100,6 +102,90 @@ describe("pre-filter (#4123)", () => {
|
|
|
100
102
|
assert.deepEqual(w.closed, [[34, "not planned"]]);
|
|
101
103
|
});
|
|
102
104
|
|
|
105
|
+
it("#4305 the CI-green shortcut is skipped for intermittent, reopened and same-SHA-flaky CI failures", async () => {
|
|
106
|
+
const link = "Run: https://github.com/o/r/actions/runs/500";
|
|
107
|
+
const green = (issue, extra = {}) => world({ issues: { 34: { title: "shell-compat failed on staging", body: `Workflow failed.\n${link}`, ...issue } },
|
|
108
|
+
runs: { 500: { id: 500, workflow_id: 7, name: "shell-compat", head_branch: "staging", head_sha: "1111111111111111", created_at: "2026-10-10T00:00:00Z", html_url: "https://github.com/o/r/actions/runs/500" } },
|
|
109
|
+
workflowRuns: { "7:staging": [{ id: 600, conclusion: "success", created_at: "2026-10-12T00:00:00Z", html_url: "https://github.com/o/r/actions/runs/600", head_sha: "8d9f6ba0aaaaaaaa" }] }, ...extra });
|
|
110
|
+
const bot = { author: { login: "rapiercraft-forgedock[bot]" }, authorAssociation: "NONE", body: "<!-- FORGE:PREFILTER -->\n<!-- FORGE:VALIDATE -->\n## Pre-filter: CI failure no longer reproduces" };
|
|
111
|
+
const stranger = { author: { login: "someone" }, authorAssociation: "NONE", body: bot.body };
|
|
112
|
+
const cases = [
|
|
113
|
+
["intermittent wording", green({ body: `\`a.test.sh\` fails intermittently on macOS.\n${link}` }), /issue says the failure is intermittent/, "flaky"],
|
|
114
|
+
["flaky label", green({ labels: [{ name: "flaky" }] }), /flaky label/, "flaky"],
|
|
115
|
+
["reopened after a pre-filter close", green({ comments: [bot] }), /reopened after a pre-filter close/, "reopened"],
|
|
116
|
+
["failed and passed at the same SHA", green({}, { shaRuns: { "8d9f6ba0aaaaaaaa": [{ id: 601, conclusion: "failure" }, { id: 600, conclusion: "success" }] } }), /failed and passed at 8d9f6ba0aaaa/, "same-sha"],
|
|
117
|
+
];
|
|
118
|
+
for (const [name, w, why, reason] of cases) {
|
|
119
|
+
const r = await pf(w, 34);
|
|
120
|
+
assert.equal(r.closed, false, name); assert.notEqual(r.kind, "ci-green", name);
|
|
121
|
+
assert.deepEqual(w.closed, [], name); assert.deepEqual(w.comments, [], name); assert.equal(w.runner.length, 0, name);
|
|
122
|
+
assert.ok(r.detail.startsWith(`skipped CI-green shortcut: ${reason} (`), `${name}: ${r.detail}`); assert.match(r.detail, why, name);
|
|
123
|
+
assert.match(r.detail, /no already-fixed signal$/, name);
|
|
124
|
+
}
|
|
125
|
+
// wording and the reopen marker need no runs lookup; an untrusted marker comment is no reopen, and a green-only SHA still closes
|
|
126
|
+
const quiet = green({ body: `fails intermittently ${link}` });
|
|
127
|
+
await pf(quiet, 34); assert.equal(quiet.shaQueries ?? 0, 0);
|
|
128
|
+
const forged = green({ comments: [stranger] }, { shaRuns: { "8d9f6ba0aaaaaaaa": [{ id: 600, conclusion: "success" }] } });
|
|
129
|
+
const r = await pf(forged, 34);
|
|
130
|
+
assert.equal(r.kind, "ci-green"); assert.equal(forged.shaQueries, 1);
|
|
131
|
+
// an unreadable same-SHA lookup is no proof either: no shortcut
|
|
132
|
+
const down = green();
|
|
133
|
+
const gh0 = down.io.gh; down.io.gh = async (a) => (/head_sha=/.test(a[1] ?? "") ? (() => { throw new Error("HTTP 502"); })() : gh0(a));
|
|
134
|
+
assert.equal((await pf(down, 34)).closed, false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("#4316 a bare \"sometimes\" is prose, not flaky wording; \"fails sometimes\" and \"sometimes fails\" are", () => {
|
|
138
|
+
assert.equal(flakyReason({ title: "deploy check failed", body: "This sometimes happens after a deploy." }), null);
|
|
139
|
+
assert.equal(flakyReason({ title: "x", body: "Sometimes we rerun it by hand." }), null);
|
|
140
|
+
for (const body of ["the job fails sometimes on macOS", "it sometimes fails on macOS", "shell-compat failed sometimes", "intermittent timeout", "a flaky test"]) {
|
|
141
|
+
assert.equal(flakyReason({ title: "ci failed", body })?.reason, "flaky", body);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
it("#4315 a comment shaped like #4235's real pre-filter comment is the pipeline's own: a reopened CI-failure issue is kept for a lane", async () => {
|
|
145
|
+
const link = "Run: https://github.com/o/r/actions/runs/500";
|
|
146
|
+
const green = (issue, extra = {}) => world({ issues: { 34: { title: "shell-compat failed on staging", body: `Workflow failed.\n${link}`, ...issue } },
|
|
147
|
+
runs: { 500: { id: 500, workflow_id: 7, name: "shell-compat", head_branch: "staging", head_sha: "1111111111111111", created_at: "2026-10-10T00:00:00Z", html_url: "https://github.com/o/r/actions/runs/500" } },
|
|
148
|
+
workflowRuns: { "7:staging": [{ id: 600, conclusion: "success", created_at: "2026-10-12T00:00:00Z", html_url: "https://github.com/o/r/actions/runs/600", head_sha: "8d9f6ba0aaaaaaaa" }] },
|
|
149
|
+
shaRuns: { "8d9f6ba0aaaaaaaa": [{ id: 600, conclusion: "success" }] }, ...extra });
|
|
150
|
+
// the shape `gh issue view --json comments` returns for the ForgeDock App on #4235: no `[bot]`, CONTRIBUTOR, marker first
|
|
151
|
+
const body = "<!-- FORGE:PREFILTER -->\n<!-- FORGE:VALIDATE -->\n## Pre-filter: CI failure no longer reproduces\n**Workflow:** shell-compat on `staging`";
|
|
152
|
+
const real = { author: { login: "rapiercraft-forgedock" }, authorAssociation: "CONTRIBUTOR", body };
|
|
153
|
+
const w = green({ comments: [real] });
|
|
154
|
+
const r = await pf(w, 34);
|
|
155
|
+
assert.equal(r.closed, false); assert.notEqual(r.kind, "ci-green");
|
|
156
|
+
assert.match(r.detail, /reopened after a pre-filter close/);
|
|
157
|
+
assert.deepEqual(w.closed, []); assert.deepEqual(w.comments, []);
|
|
158
|
+
// the token's own user (`gh api user`) counts too, cached once per makePrefilter batch; a CONTRIBUTOR stranger does not
|
|
159
|
+
const mine = green({ comments: [{ ...real, author: { login: "Op-User" } }] });
|
|
160
|
+
let userCalls = 0; const gh0 = mine.io.gh;
|
|
161
|
+
mine.io.gh = async (a) => (a[0] === "api" && a[1] === "user" ? (userCalls++, "op-user\n") : gh0(a));
|
|
162
|
+
const run = makePrefilter({ repo: "o/r", cwd: "/repo", io: mine.io });
|
|
163
|
+
assert.match((await run(34, "staging")).detail, /reopened after a pre-filter close/);
|
|
164
|
+
await run(34, "staging"); assert.equal(userCalls, 1);
|
|
165
|
+
const stranger = green({ comments: [{ ...real, author: { login: "someone" } }] });
|
|
166
|
+
assert.equal((await pf(stranger, 34)).kind, "ci-green");
|
|
167
|
+
// BUG-4: the marker is posted only after a successful close; a failed close posts no marker
|
|
168
|
+
const order = [];
|
|
169
|
+
const ok = green(); const g2 = ok.io.gh;
|
|
170
|
+
ok.io.gh = async (a) => { if (["issue close", "issue comment"].includes(a.slice(0, 2).join(" "))) order.push(a[1]); return g2(a); };
|
|
171
|
+
assert.equal((await pf(ok, 34)).closed, true); assert.deepEqual(order, ["close", "comment"]);
|
|
172
|
+
const fails = green(); const g3 = fails.io.gh;
|
|
173
|
+
fails.io.gh = async (a) => (a.slice(0, 2).join(" ") === "issue close" ? (() => { throw new Error("HTTP 403"); })() : g3(a));
|
|
174
|
+
const f = await pf(fails, 34);
|
|
175
|
+
assert.equal(f.closed, false); assert.deepEqual(fails.comments, []); assert.match(f.detail, /pre-filter error/);
|
|
176
|
+
// #4314: a failing comment after a successful close still counts as closed (no lane), via retry, then a marker-only comment
|
|
177
|
+
const flaky = (n, big) => { const x = green(); const g = x.io.gh; let calls = 0;
|
|
178
|
+
x.io.gh = async (a) => { if (a.slice(0, 2).join(" ") === "issue comment") { calls++; if (calls <= n || (big && a[a.indexOf("--body") + 1].includes("## Pre-filter"))) throw new Error("HTTP 502"); } return g(a); };
|
|
179
|
+
return x; };
|
|
180
|
+
const once = flaky(1); const r1 = await pf(once, 34);
|
|
181
|
+
assert.equal(r1.closed, true); assert.equal(once.comments.length, 1); assert.doesNotMatch(r1.detail, /no marker/);
|
|
182
|
+
const tiny = flaky(0, true); const r2 = await pf(tiny, 34);
|
|
183
|
+
assert.equal(r2.closed, true); assert.equal(tiny.comments.length, 1); assert.match(tiny.comments[0][1], /^<!-- FORGE:PREFILTER -->/);
|
|
184
|
+
assert.match(flakyReason({ comments: [{ body: tiny.comments[0][1], author: { login: "forgedock-app[bot]" }, authorAssociation: "NONE" }] }, ["forgedock-app"])?.reason ?? "", /reopened/);
|
|
185
|
+
const dead = flaky(99); const r3 = await pf(dead, 34);
|
|
186
|
+
assert.equal(r3.closed, true); assert.equal(dead.runner.length, 0); assert.deepEqual(dead.closed, [[34, "not planned"]]); assert.match(r3.detail, /no marker comment/);
|
|
187
|
+
});
|
|
188
|
+
|
|
103
189
|
it("a CI-failure issue whose workflow is still red (or whose latest run is the failing one) goes to a lane", async () => {
|
|
104
190
|
const base = { issues: { 34: { title: "ci failed", body: "https://github.com/o/r/actions/runs/500" } },
|
|
105
191
|
runs: { 500: { id: 500, workflow_id: 7, head_branch: "staging", created_at: "2026-10-10T00:00:00Z" } } };
|
|
@@ -229,3 +315,26 @@ describe("orchestrate with the pre-filter (#4123)", () => {
|
|
|
229
315
|
assert.ok(logs.some((s) => /^#12 → lane-refused \(lane\.default_branch/.test(s)));
|
|
230
316
|
});
|
|
231
317
|
});
|
|
318
|
+
|
|
319
|
+
describe("#4318: the engine's own identity under an installation token or a differently named App", () => {
|
|
320
|
+
it("ownLogins falls back to the App slug from `gh api app` when `gh api user` fails", async () => {
|
|
321
|
+
const gh = async (a) => { if (a[1] === "user") throw new Error("Resource not accessible by integration"); if (a[1] === "app") return "Other-Forge\n"; return ""; };
|
|
322
|
+
assert.deepEqual((await resolveOwnLogins(gh)), { logins: ["rapiercraft-forgedock", "other-forge"], resolved: true });
|
|
323
|
+
assert.deepEqual(await ownLogins(gh), ["rapiercraft-forgedock", "other-forge"]);
|
|
324
|
+
const both = async () => { throw new Error("401"); };
|
|
325
|
+
assert.deepEqual(await resolveOwnLogins(both), { logins: ["rapiercraft-forgedock"], resolved: false });
|
|
326
|
+
});
|
|
327
|
+
it("an unresolved lookup is retried once in the batch, then the App-slug-only set is kept", async () => {
|
|
328
|
+
const link = "Run: https://github.com/o/r/actions/runs/500";
|
|
329
|
+
const real = { author: { login: "Other-Forge" }, authorAssociation: "CONTRIBUTOR", body: "<!-- FORGE:PREFILTER -->\n## Pre-filter" };
|
|
330
|
+
const w = world({ issues: { 34: { title: "shell-compat failed on staging", body: `Workflow failed.\n${link}`, comments: [real] } },
|
|
331
|
+
runs: { 500: { id: 500, workflow_id: 7, name: "shell-compat", head_branch: "staging", head_sha: "1111111111111111", created_at: "2026-10-10T00:00:00Z", html_url: "https://github.com/o/r/actions/runs/500" } },
|
|
332
|
+
workflowRuns: { "7:staging": [{ id: 600, conclusion: "success", created_at: "2026-10-12T00:00:00Z", html_url: "https://github.com/o/r/actions/runs/600", head_sha: "8d9f6ba0aaaaaaaa" }] },
|
|
333
|
+
shaRuns: { "8d9f6ba0aaaaaaaa": [{ id: 600, conclusion: "success" }] } });
|
|
334
|
+
let lookups = 0; const g0 = w.io.gh;
|
|
335
|
+
w.io.gh = async (a) => (a[0] === "api" && (a[1] === "user" || a[1] === "app") ? (lookups++, (() => { throw new Error("401"); })()) : g0(a));
|
|
336
|
+
const run = makePrefilter({ repo: "o/r", cwd: "/repo", io: w.io });
|
|
337
|
+
await run(34, "staging"); await run(34, "staging"); await run(34, "staging");
|
|
338
|
+
assert.equal(lookups, 4, "user + app, twice: one retry, then cached");
|
|
339
|
+
});
|
|
340
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* forge#4328: freeze-time preflight of a contract's validate commands. Pure: derives the host tools a command needs
|
|
5
|
+
* (each segment's bare executable, Python modules a bare python imports via -c/-m, Node packages `node -e` code imports)
|
|
6
|
+
* and a read-only bash probe for each. The engine runs the probes (lean.mjs) before the implement session, so a validate
|
|
7
|
+
* command the host cannot run is corrected in the plan instead of found after the build (#4323 env.tool_missing).
|
|
8
|
+
*/
|
|
9
|
+
import { builtinModules } from "node:module";
|
|
10
|
+
|
|
11
|
+
export const PREFLIGHT_CAP = 20;
|
|
12
|
+
const KIND_NAME = { command: "command", "python-module": "Python module", "node-module": "Node package" };
|
|
13
|
+
const PY_RE = /^python(\d+(\.\d+)?)?$/;
|
|
14
|
+
const PY_ARG_FLAGS = new Set(["-W", "-X", "-Q"]);
|
|
15
|
+
const sq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
16
|
+
|
|
17
|
+
/** Quote-aware split of a bash command into segments (on &&, ||, |, ;, newline) of unquoted words; `$(...)` stays one word. */
|
|
18
|
+
export function shellSegments(command) {
|
|
19
|
+
const segs = [];
|
|
20
|
+
let words = [], word = "", has = false, q = null, depth = 0;
|
|
21
|
+
const endWord = () => { if (has) words.push(word); word = ""; has = false; };
|
|
22
|
+
const endSeg = () => { endWord(); if (words.length) segs.push(words); words = []; };
|
|
23
|
+
const s = String(command ?? "");
|
|
24
|
+
for (let i = 0; i < s.length; i++) {
|
|
25
|
+
const ch = s[i];
|
|
26
|
+
if (q === "'") { if (ch === "'") q = null; else word += ch; continue; }
|
|
27
|
+
if (ch === "\\" && i + 1 < s.length) { word += s[++i]; has = true; continue; }
|
|
28
|
+
if (q === '"') { if (ch === '"') q = null; else word += ch; continue; }
|
|
29
|
+
if (depth) { word += ch; if (ch === "(") depth++; else if (ch === ")") depth--; continue; }
|
|
30
|
+
if (ch === "'" || ch === '"') { q = ch; has = true; continue; }
|
|
31
|
+
if (ch === "$" && s[i + 1] === "(") { word += "$("; i++; depth = 1; has = true; continue; }
|
|
32
|
+
if (ch === " " || ch === "\t") { endWord(); continue; }
|
|
33
|
+
if (ch === "\n" || ch === ";" || ch === "|" || ch === "&") { if (ch === "&" && s[i + 1] !== "&") { endWord(); continue; } endSeg(); if (s[i + 1] === ch) i++; continue; }
|
|
34
|
+
if ((ch === "(" || ch === ")" || ch === "{" || ch === "}") && !has) continue;
|
|
35
|
+
word += ch; has = true;
|
|
36
|
+
}
|
|
37
|
+
endSeg();
|
|
38
|
+
return segs;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Top-level module names a Python `-c` program imports (absolute imports only). */
|
|
42
|
+
export function pythonImports(code) {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const line of String(code ?? "").split(/[;\n]/)) {
|
|
45
|
+
const imp = /^\s*import\s+(.+)$/.exec(line), from = /^\s*from\s+([\w.]+)\s+import\b/.exec(line);
|
|
46
|
+
if (imp) for (const part of imp[1].split(",")) { const m = /^\s*([A-Za-z_]\w*)/.exec(part); if (m) out.push(m[1]); }
|
|
47
|
+
else if (from && !from[1].startsWith(".")) out.push(from[1].split(".")[0]);
|
|
48
|
+
}
|
|
49
|
+
for (const m of String(code ?? "").matchAll(/(?:__import__|import_module)\(\s*['"]([A-Za-z_][\w.]*)['"]/g)) out.push(m[1].split(".")[0]);
|
|
50
|
+
return [...new Set(out)].filter((x) => x !== "__future__");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Package names (bare specifiers, cut to the package) a `node -e` program imports; builtins, node:, relative and absolute are not packages. */
|
|
54
|
+
export function nodeImports(code) {
|
|
55
|
+
const specs = [], c = String(code ?? "");
|
|
56
|
+
for (const re of [/\brequire\(\s*['"]([^'"]+)['"]\s*\)/g, /\bimport\s*\(\s*['"]([^'"]+)['"]/g, /\bfrom\s*['"]([^'"]+)['"]/g, /(?:^|[;\n])\s*import\s*['"]([^'"]+)['"]/g]) for (const m of c.matchAll(re)) specs.push(m[1]);
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const spec of specs) {
|
|
59
|
+
if (/^(\.|\/|node:|file:|data:)/.test(spec)) continue;
|
|
60
|
+
const parts = spec.split("/"), pkg = spec.startsWith("@") ? (parts.length > 1 ? parts.slice(0, 2).join("/") : null) : parts[0];
|
|
61
|
+
if (pkg && !builtinModules.includes(pkg)) out.push(pkg);
|
|
62
|
+
}
|
|
63
|
+
return [...new Set(out)];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const PY_PROBE = "import importlib.util,sys;sys.exit(0 if importlib.util.find_spec(sys.argv[1]) else 3)";
|
|
67
|
+
const NODE_PROBE = "const m=require('module'),p=require('path'),f=require('fs'),n=process.argv[1];if(m.builtinModules.includes(n))process.exit(0);for(let d=process.cwd();;){if(f.existsSync(p.join(d,'node_modules',n,'package.json')))process.exit(0);const u=p.dirname(d);if(u===d)process.exit(3);d=u}";
|
|
68
|
+
|
|
69
|
+
/** The program text and modules of one python/node segment's arguments. */
|
|
70
|
+
function moduleNeeds(exe, args) {
|
|
71
|
+
if (PY_RE.test(exe)) {
|
|
72
|
+
for (let i = 0; i < args.length; i++) {
|
|
73
|
+
const a = args[i];
|
|
74
|
+
if (a === "-c") return pythonImports(args[i + 1]);
|
|
75
|
+
if (a.startsWith("-c") && a.length > 2) return pythonImports(a.slice(2));
|
|
76
|
+
if (a === "-m") return args[i + 1] ? [args[i + 1].split(".")[0]] : [];
|
|
77
|
+
if (a.startsWith("-m") && a.length > 2) return [a.slice(2).split(".")[0]];
|
|
78
|
+
if (PY_ARG_FLAGS.has(a)) { i++; continue; }
|
|
79
|
+
if (!a.startsWith("-")) return [];
|
|
80
|
+
}
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
if (exe === "node") {
|
|
84
|
+
for (let i = 0; i < args.length; i++) {
|
|
85
|
+
const a = args[i];
|
|
86
|
+
if (["-e", "--eval", "-p", "--print"].includes(a)) return nodeImports(args[i + 1]);
|
|
87
|
+
const eq = /^--(?:eval|print)=(.*)$/s.exec(a);
|
|
88
|
+
if (eq) return nodeImports(eq[1]);
|
|
89
|
+
if (!a.startsWith("-")) return [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The probes for `contract.validate[]`: {probes: [{ids, tool, kind, interp, needs, probe}], dropped}. `needs` is the command
|
|
97
|
+
* probe (tool) a module probe depends on: skip the module probe when its interpreter is missing. A name `repoHas(name)` holds
|
|
98
|
+
* or a deliverable's change text names (the change adds it) is not probed; paths and `$...` words are never probed.
|
|
99
|
+
*/
|
|
100
|
+
export function preflightProbes(contract, { repoHas = () => false, cap = PREFLIGHT_CAP } = {}) {
|
|
101
|
+
const changes = (Array.isArray(contract?.deliverables) ? contract.deliverables : []).map((d) => String(d?.change ?? "")).join("\n");
|
|
102
|
+
const named = (name) => new RegExp(`(^|[^\\w@/.-])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w/-])`).test(changes);
|
|
103
|
+
const skip = (name) => !name || name.includes("/") && !name.startsWith("@") || name.startsWith("$") || repoHas(name) || named(name);
|
|
104
|
+
const byKey = new Map();
|
|
105
|
+
const add = (id, kind, tool, interp = null) => {
|
|
106
|
+
const key = `${kind}|${tool}|${interp ?? ""}`;
|
|
107
|
+
const p = byKey.get(key);
|
|
108
|
+
if (p) { if (!p.ids.includes(id)) p.ids.push(id); return; }
|
|
109
|
+
const probe = kind === "command" ? `command -v -- ${sq(tool)} >/dev/null 2>&1`
|
|
110
|
+
: kind === "python-module" ? `${sq(interp)} -c ${sq(PY_PROBE)} ${sq(tool)} >/dev/null 2>&1`
|
|
111
|
+
: `node -e ${sq(NODE_PROBE)} ${sq(tool)} >/dev/null 2>&1`;
|
|
112
|
+
byKey.set(key, { ids: [id], tool, kind, interp, needs: kind === "command" ? null : interp, probe });
|
|
113
|
+
};
|
|
114
|
+
for (const v of Array.isArray(contract?.validate) ? contract.validate : []) {
|
|
115
|
+
if (!v?.id || typeof v.command !== "string") continue;
|
|
116
|
+
for (const seg of shellSegments(v.command)) {
|
|
117
|
+
let i = 0;
|
|
118
|
+
while (i < seg.length && (/^[A-Za-z_]\w*=/.test(seg[i]) || ["!", "time", "exec", "command", "env"].includes(seg[i]))) i++;
|
|
119
|
+
const exe = seg[i];
|
|
120
|
+
if (!exe || exe.includes("/") || exe.startsWith("$") || exe.startsWith("-")) continue;
|
|
121
|
+
if (!skip(exe)) add(v.id, "command", exe);
|
|
122
|
+
const kind = exe === "node" ? "node-module" : "python-module";
|
|
123
|
+
for (const mod of moduleNeeds(exe, seg.slice(i + 1))) if (!skip(mod)) add(v.id, kind, mod, exe);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const all = [...byKey.values()];
|
|
127
|
+
return { probes: all.slice(0, cap), dropped: Math.max(0, all.length - cap) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** One contract problem per missed probe: names the validate ids and the tool, and how to fix the plan (never a global install). */
|
|
131
|
+
export function preflightProblems(misses) {
|
|
132
|
+
return (misses ?? []).map((m) => `${m.ids.join(", ")}: needs ${KIND_NAME[m.kind] ?? m.kind} '${m.tool}'${m.interp ? ` (for ${m.interp})` : ""}, which this host lacks, so the validate command cannot run here; run the check through the repo's own toolchain (its virtualenv, lockfile-pinned runner or package scripts) or use a check that does not need it, never a global install`);
|
|
133
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { shellSegments, pythonImports, nodeImports, preflightProbes, preflightProblems } from "./preflight.mjs";
|
|
8
|
+
import { runCheckCommand } from "./effects.mjs";
|
|
9
|
+
import { repoTracks } from "./conformance.mjs";
|
|
10
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
11
|
+
|
|
12
|
+
const C = (...commands) => ({ deliverables: [], validate: commands.map((command, i) => ({ id: `V${i + 1}`, command })) });
|
|
13
|
+
const needs = (c, o) => preflightProbes(c, o).probes.map((p) => [p.kind, p.tool, p.interp ?? null, p.ids.join(",")]);
|
|
14
|
+
|
|
15
|
+
describe("#4328 validate preflight: what a command needs", () => {
|
|
16
|
+
it("splits on && || | ; and newlines, keeps quotes and $(...) whole", () => {
|
|
17
|
+
assert.deepEqual(shellSegments(`cd a && python3 -c "import yaml; print('x | y')" | tee "$TMPDIR/o" ; git diff $(git merge-base HEAD origin/x) -- a || true`),
|
|
18
|
+
[["cd", "a"], ["python3", "-c", "import yaml; print('x | y')"], ["tee", "$TMPDIR/o"], ["git", "diff", "$(git merge-base HEAD origin/x)", "--", "a"], ["true"]]);
|
|
19
|
+
});
|
|
20
|
+
it("python -c imports and -m modules (top-level, absolute only); node -e bare packages", () => {
|
|
21
|
+
assert.deepEqual(pythonImports("import yaml, os.path as p\nfrom jsonschema.validators import x; from . import y; from __future__ import annotations; __import__('toml')"), ["yaml", "os", "jsonschema", "toml"]);
|
|
22
|
+
assert.deepEqual(nodeImports(`const z=require("zod"); import("@scope/pkg/sub"); import fs from "node:fs"; require("./x"); require("path")`), ["zod", "@scope/pkg"]);
|
|
23
|
+
});
|
|
24
|
+
it("probes each bare executable, a bare python's modules and node -e packages, deduped across validate ids", () => {
|
|
25
|
+
assert.deepEqual(needs(C(`FOO=1 python3 -c "import yaml,json"`, "python3 -m pytest -q tests/x.py && jq . a.json", `node -e "require('zod')"`, "python3 -c 'import yaml'")), [
|
|
26
|
+
["command", "python3", null, "V1,V2,V4"], ["python-module", "yaml", "python3", "V1,V4"], ["python-module", "json", "python3", "V1"],
|
|
27
|
+
["python-module", "pytest", "python3", "V2"], ["command", "jq", null, "V2"], ["command", "node", null, "V3"], ["node-module", "zod", "node", "V3"]]);
|
|
28
|
+
});
|
|
29
|
+
it("wrappers and paths: only the wrapper is probed; ./script and .venv/bin/python and $VAR never are", () => {
|
|
30
|
+
assert.deepEqual(needs(C(`uv run python -c "import yaml"`, "./scripts/check.sh", `.venv/bin/python -c "import yaml"`, "$PY -m x", "npx vitest run a.test.ts")),
|
|
31
|
+
[["command", "uv", null, "V1"], ["command", "npx", null, "V5"]]);
|
|
32
|
+
});
|
|
33
|
+
it("skips names the repo tracks and names a deliverable's change adds", () => {
|
|
34
|
+
const c = { ...C(`python3 -c "import app, yaml"`, `node -e "require('left-pad')"`), deliverables: [{ id: "D1", path: "package.json", change: "add devDependency left-pad for the check" }] };
|
|
35
|
+
assert.deepEqual(needs(c, { repoHas: repoTracks(["app/__init__.py"]) }).map((x) => x[1]), ["python3", "yaml", "node"]);
|
|
36
|
+
});
|
|
37
|
+
it("caps the probes and reports the dropped count", () => {
|
|
38
|
+
const r = preflightProbes(C(Array.from({ length: 25 }, (_, i) => `tool${i}`).join(" && ")), { cap: 20 });
|
|
39
|
+
assert.equal(r.probes.length, 20); assert.equal(r.dropped, 5);
|
|
40
|
+
});
|
|
41
|
+
it("a problem line names the ids, the kind and the tool and forbids a global install", () => {
|
|
42
|
+
const [p] = preflightProblems([{ ids: ["V5"], tool: "yaml", kind: "python-module", interp: "python3" }]);
|
|
43
|
+
assert.match(p, /^V5: needs Python module 'yaml' \(for python3\), which this host lacks/);
|
|
44
|
+
assert.match(p, /repo's own toolchain .*never a global install/);
|
|
45
|
+
});
|
|
46
|
+
it("the probes are real read-only shell checks: present tools pass, absent ones fail", async () => {
|
|
47
|
+
const dir = tmpDir("preflight-");
|
|
48
|
+
mkdirSync(join(dir, "node_modules", "here"), { recursive: true }); writeFileSync(join(dir, "node_modules", "here", "package.json"), "{}");
|
|
49
|
+
mkdirSync(join(dir, "sub"));
|
|
50
|
+
const probes = preflightProbes(C("bash -c true", "no-such-tool-4328 --x", `node -e "require('here'); require('fs'); require('absent-pkg-4328')"`)).probes;
|
|
51
|
+
const rc = {};
|
|
52
|
+
for (const p of probes) rc[`${p.kind}:${p.tool}`] = (await runCheckCommand({ command: p.probe, cwd: join(dir, "sub"), timeoutMs: 15000 })).rc;
|
|
53
|
+
assert.equal(rc["command:bash"], 0); assert.notEqual(rc["command:no-such-tool-4328"], 0);
|
|
54
|
+
assert.equal(rc["command:node"], 0); assert.equal(rc["node-module:here"], 0, "found in an ancestor node_modules");
|
|
55
|
+
assert.notEqual(rc["node-module:absent-pkg-4328"], 0);
|
|
56
|
+
const py = preflightProbes(C(`python3 -c "import json, no_such_mod_4328"`)).probes.filter((p) => p.kind === "python-module");
|
|
57
|
+
if ((await runCheckCommand({ command: "command -v python3", cwd: dir })).rc === 0) {
|
|
58
|
+
assert.deepEqual(await Promise.all(py.map(async (p) => (await runCheckCommand({ command: p.probe, cwd: dir })).rc !== 0)), [false, true]);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|