forgedock 1.29.1 → 1.31.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 +5 -2
- package/bin/engine/phases.mjs +20 -3
- package/bin/engine/runlog.mjs +82 -12
- package/bin/engine/telemetry-seam.mjs +11 -4
- package/bin/engine/telemetry-seam.test.mjs +18 -0
- package/bin/engine-cli.mjs +453 -66
- package/bin/engine.mjs +50 -38
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/labels.json +5 -0
- package/bin/lean/apishape.mjs +170 -0
- package/bin/lean/apishape.test.mjs +187 -0
- package/bin/lean/batch.mjs +32 -0
- package/bin/lean/batch.test.mjs +356 -2
- package/bin/lean/budget.mjs +89 -0
- package/bin/lean/budget.test.mjs +106 -0
- package/bin/lean/checks.mjs +32 -9
- package/bin/lean/checks.test.mjs +78 -1
- package/bin/lean/conformance.e2e.test.mjs +27 -5
- package/bin/lean/conformance.mjs +278 -22
- package/bin/lean/conformance.test.mjs +251 -5
- package/bin/lean/context.mjs +133 -55
- package/bin/lean/context.test.mjs +175 -9
- package/bin/lean/decompose.mjs +90 -0
- package/bin/lean/decompose.test.mjs +105 -0
- package/bin/lean/depcache.mjs +11 -7
- package/bin/lean/depcache.test.mjs +81 -0
- package/bin/lean/e2e.mjs +102 -0
- package/bin/lean/e2e.test.mjs +86 -0
- package/bin/lean/effects.mjs +55 -19
- package/bin/lean/enumerating.mjs +189 -0
- package/bin/lean/enumerating.test.mjs +97 -0
- package/bin/lean/explore.mjs +47 -0
- package/bin/lean/explore.test.mjs +61 -0
- package/bin/lean/facts.mjs +84 -0
- package/bin/lean/facts.test.mjs +66 -0
- package/bin/lean/fixround.mjs +90 -0
- package/bin/lean/fixround.test.mjs +117 -0
- package/bin/lean/heavy.mjs +35 -2
- package/bin/lean/heavy.test.mjs +32 -2
- package/bin/lean/lean.mjs +799 -127
- package/bin/lean/lean.test.mjs +1749 -37
- package/bin/lean/ledger.mjs +5 -3
- package/bin/lean/ledger.test.mjs +18 -0
- package/bin/lean/light.mjs +135 -0
- package/bin/lean/light.test.mjs +173 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +341 -99
- package/bin/lean/pitfalls.mjs +136 -0
- package/bin/lean/pitfalls.test.mjs +117 -0
- package/bin/lean/postmerge.mjs +85 -0
- package/bin/lean/postmerge.test.mjs +79 -0
- package/bin/lean/precheck.mjs +180 -0
- package/bin/lean/precheck.test.mjs +205 -0
- package/bin/lean/prefilter.mjs +180 -9
- package/bin/lean/prefilter.test.mjs +175 -3
- package/bin/lean/preflight.mjs +238 -0
- package/bin/lean/preflight.test.mjs +108 -0
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +134 -12
- package/bin/lean/recovery.test.mjs +75 -0
- package/bin/lean/render.mjs +7 -3
- package/bin/lean/resume.mjs +67 -0
- package/bin/lean/resume.test.mjs +84 -0
- package/bin/lean/review-trigger.mjs +12 -0
- package/bin/lean/review-trigger.test.mjs +17 -1
- package/bin/lean/routing.mjs +74 -0
- package/bin/lean/routing.test.mjs +67 -0
- package/bin/lean/sched.test.mjs +127 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +149 -3
- package/bin/lean/selfreview.mjs +37 -0
- package/bin/lean/selfreview.test.mjs +51 -0
- package/bin/lean/seqlease.mjs +40 -0
- package/bin/lean/seqlease.test.mjs +101 -0
- package/bin/lean/sequence.mjs +385 -0
- package/bin/lean/sequence.test.mjs +373 -0
- package/bin/lean/testmap.mjs +43 -6
- package/bin/lean/testmap.test.mjs +38 -1
- package/bin/lean/toolchain.mjs +60 -15
- package/bin/lean/toolchain.test.mjs +120 -3
- package/bin/lean/toolstats.mjs +72 -0
- package/bin/lean/toolstats.test.mjs +47 -0
- package/bin/lean/verifier.mjs +232 -0
- package/bin/lean/verifier.test.mjs +163 -0
- package/bin/lean/verify.mjs +77 -10
- package/bin/lean/verify.test.mjs +74 -3
- package/bin/review-staging.mjs +533 -0
- package/bin/runner.mjs +82 -13
- package/bin/stats/aggregate.mjs +87 -2
- package/bin/stats/efficiency.mjs +319 -0
- package/bin/stats/efficiency.test.mjs +233 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +69 -6
- package/bin/stats/local-source.mjs +86 -3
- package/bin/stats/query.mjs +48 -0
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +224 -5
- package/commands/orchestrate.md +6 -2
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +84 -831
- package/commands/review-pr.md +1 -1
- package/commands/work-on/build/implement.md +1 -0
- package/commands/work-on/build/validate.md +11 -1
- package/docs/CONFIG.md +88 -6
- package/docs/OUTCOME-CODES.md +7 -4
- package/docs/RELEASE-NOTES.md +10 -0
- package/docs/STATS.md +53 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/acceptance-red-green.sh +13 -4
- package/scripts/acceptance-red-green.test.sh +14 -0
- package/scripts/ci-local/lib.mjs +20 -0
- package/scripts/ci-local/plan.mjs +2 -12
- package/scripts/ci-local/scoping.mjs +10 -1
- package/scripts/ci-local.test.sh +2 -0
- package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
- package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
- package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
- package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
- package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
- package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
- package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
- package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
- package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
- package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
- package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
- package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
- package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
- package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
- package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
- package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
- package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
- package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
- package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
- package/scripts/forge-root.test.sh +2 -2
- package/scripts/publish-staging-sync.test.sh +62 -4
- package/scripts/review-finalize.sh +17 -6
- package/scripts/review-finalize.test.sh +47 -0
- package/scripts/review-pr-arguments.test.sh +13 -1
- package/scripts/review-pr-delta-wiring.test.sh +2 -2
- package/scripts/review-pr-gate2.test.sh +6 -14
- package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
- package/scripts/review-pr-staging-scope.test.sh +39 -33
- package/scripts/review-prepare.sh +73 -6
- package/scripts/review-prepare.test.sh +85 -0
- package/scripts/review-staging-post.sh +293 -0
- package/scripts/review-staging-post.test.sh +143 -0
- package/scripts/review-staging-pre.sh +401 -0
- package/scripts/review-staging-pre.test.sh +150 -0
- package/scripts/symbol-index.mjs +26 -7
- package/scripts/symbol-index.py +55 -9
- package/scripts/test-map.sh +63 -17
- package/scripts/test-map.test.sh +55 -0
- package/scripts/trusted-comments.test.sh +1 -1
|
@@ -2,9 +2,9 @@
|
|
|
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, flakyReason, makePrefilter, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, DEFAULT_PREFILTER_MODEL } from "./prefilter.mjs";
|
|
5
|
+
import { prefilterIssue, flakyReason, makePrefilter, postDeployGate, postDeployOnly, deferredFrom, formatParkedLine, lazyDefaultBranch, ownLogins, resolveOwnLogins, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, isTrustedIssueComment, DEFAULT_PREFILTER_MODEL } from "./prefilter.mjs";
|
|
6
6
|
import { parsePrefilterVerdict, prefilterPrompt } from "./prompts.mjs";
|
|
7
|
-
import { orchestrate, formatTable } from "./orchestrate.mjs";
|
|
7
|
+
import { orchestrate, formatTable, formatOutcomeLists } from "./orchestrate.mjs";
|
|
8
8
|
import { triageFromCli } from "../engine-cli.mjs";
|
|
9
9
|
|
|
10
10
|
const fence = (o) => "```json\n" + JSON.stringify(o) + "\n```";
|
|
@@ -13,7 +13,7 @@ const fence = (o) => "```json\n" + JSON.stringify(o) + "\n```";
|
|
|
13
13
|
function world(o = {}) {
|
|
14
14
|
const w = { comments: [], closed: [], labels: [], runner: [], lanes: [], gitCalls: [],
|
|
15
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
|
-
verdict: o.verdict ?? null };
|
|
16
|
+
verdict: o.verdict ?? null, mergedPrs: o.mergedPrs ?? [], ancestry: o.ancestry ?? {}, edits: [] };
|
|
17
17
|
w.io = {
|
|
18
18
|
gh: async (a) => {
|
|
19
19
|
const k = a.slice(0, 2).join(" ");
|
|
@@ -30,6 +30,8 @@ function world(o = {}) {
|
|
|
30
30
|
}
|
|
31
31
|
if (k === "issue comment") { w.comments.push([Number(a[2]), a[a.indexOf("--body") + 1]]); return ""; }
|
|
32
32
|
if (k === "issue close") { w.closed.push([Number(a[2]), a[a.indexOf("--reason") + 1]]); return ""; }
|
|
33
|
+
if (k === "pr list") return JSON.stringify(w.mergedPrs);
|
|
34
|
+
if (k === "issue edit") { w.edits.push([Number(a[2]), a[5], a[6]]); return ""; }
|
|
33
35
|
throw new Error(`unexpected gh ${a.join(" ")}`);
|
|
34
36
|
},
|
|
35
37
|
git: async (a) => {
|
|
@@ -37,6 +39,7 @@ function world(o = {}) {
|
|
|
37
39
|
if (a[0] === "log") return w.commits;
|
|
38
40
|
if (a[0] === "cat-file") { if (w.paths.has(a[2].replace(/^origin\/staging:/, ""))) return ""; throw new Error("missing"); }
|
|
39
41
|
if (a[0] === "fetch") return "";
|
|
42
|
+
if (a[0] === "merge-base") { const c = w.ancestry[a[2]] ?? 128; if (c === 0) return ""; throw Object.assign(new Error(`exit ${c}`), { code: c }); }
|
|
40
43
|
if (a[0] === "ls-remote") return a.at(-1) === "refs/heads/staging" ? "abc\trefs/heads/staging\n" : "";
|
|
41
44
|
throw new Error(`unexpected git ${a.join(" ")}`);
|
|
42
45
|
},
|
|
@@ -71,6 +74,20 @@ describe("pre-filter (#4123)", () => {
|
|
|
71
74
|
assert.match(w.comments[0][1], /FORGE:PREFILTER/); assert.match(w.comments[0][1], /src\/foo\.js:3 null guard/); assert.match(w.comments[0][1], /PR #90/);
|
|
72
75
|
});
|
|
73
76
|
|
|
77
|
+
it("#4399 an issue reopened by a failed acceptance probe is never re-closed as already-fixed; a pass is no skip", async () => {
|
|
78
|
+
const probe = (result, author = { login: "rapiercraft-forgedock" }, assoc = "CONTRIBUTOR") => ({ author, authorAssociation: assoc,
|
|
79
|
+
body: `<!-- FORGE:PROBE -->\n## Acceptance probe: ${result}\n${JSON.stringify({ fix: "abc1234", default_sha: "d", result, exit: result === "pass" ? 0 : 1, ms: 5 })}` });
|
|
80
|
+
assert.deepEqual(flakyReason({ comments: [probe("fail")] }), { reason: "probe-failed", why: "acceptance probe failed after promotion" });
|
|
81
|
+
assert.equal(flakyReason({ comments: [probe("fail"), probe("pass")] }), null);
|
|
82
|
+
assert.equal(flakyReason({ comments: [probe("fail", { login: "someone" }, "NONE")] }), null);
|
|
83
|
+
const w = world({ issues: { 12: { title: "foo breaks", body: "foo crashes on null", comments: [probe("fail")] } },
|
|
84
|
+
prs: [{ number: 90, title: "fix: guard foo", body: "Fixes #12", url: "https://github.com/o/r/pull/90" }],
|
|
85
|
+
verdict: { verdict: "ALREADY-FIXED", summary: "guard added", evidence: ["src/foo.js:3"] } });
|
|
86
|
+
const r = await pf(w, 12);
|
|
87
|
+
assert.equal(r.closed, false); assert.match(r.detail, /acceptance probe failed after promotion/);
|
|
88
|
+
assert.equal(w.runner.length, 0); assert.deepEqual(w.closed, []); assert.deepEqual(w.comments, []);
|
|
89
|
+
});
|
|
90
|
+
|
|
74
91
|
it("a candidate the validator CONFIRMS (or answers without evidence) is not closed", async () => {
|
|
75
92
|
const mk = (verdict) => world({ issues: { 12: { body: "x" } }, prs: [{ number: 90, title: "fix (#12)", body: "" }], verdict });
|
|
76
93
|
for (const v of [{ verdict: "CONFIRMED", evidence: ["src/foo.js:3 still derefs"] }, { verdict: "ALREADY-FIXED", evidence: [] }, null]) {
|
|
@@ -315,3 +332,158 @@ describe("orchestrate with the pre-filter (#4123)", () => {
|
|
|
315
332
|
assert.ok(logs.some((s) => /^#12 → lane-refused \(lane\.default_branch/.test(s)));
|
|
316
333
|
});
|
|
317
334
|
});
|
|
335
|
+
|
|
336
|
+
describe("#4318: the engine's own identity under an installation token or a differently named App", () => {
|
|
337
|
+
it("ownLogins falls back to the App slug from `gh api app` when `gh api user` fails", async () => {
|
|
338
|
+
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 ""; };
|
|
339
|
+
assert.deepEqual((await resolveOwnLogins(gh)), { logins: ["rapiercraft-forgedock", "other-forge"], resolved: true });
|
|
340
|
+
assert.deepEqual(await ownLogins(gh), ["rapiercraft-forgedock", "other-forge"]);
|
|
341
|
+
const both = async () => { throw new Error("401"); };
|
|
342
|
+
assert.deepEqual(await resolveOwnLogins(both), { logins: ["rapiercraft-forgedock"], resolved: false });
|
|
343
|
+
});
|
|
344
|
+
it("an unresolved lookup is retried once in the batch, then the App-slug-only set is kept", async () => {
|
|
345
|
+
const link = "Run: https://github.com/o/r/actions/runs/500";
|
|
346
|
+
const real = { author: { login: "Other-Forge" }, authorAssociation: "CONTRIBUTOR", body: "<!-- FORGE:PREFILTER -->\n## Pre-filter" };
|
|
347
|
+
const w = world({ issues: { 34: { title: "shell-compat failed on staging", body: `Workflow failed.\n${link}`, comments: [real] } },
|
|
348
|
+
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" } },
|
|
349
|
+
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" }] },
|
|
350
|
+
shaRuns: { "8d9f6ba0aaaaaaaa": [{ id: 600, conclusion: "success" }] } });
|
|
351
|
+
let lookups = 0; const g0 = w.io.gh;
|
|
352
|
+
w.io.gh = async (a) => (a[0] === "api" && (a[1] === "user" || a[1] === "app") ? (lookups++, (() => { throw new Error("401"); })()) : g0(a));
|
|
353
|
+
const run = makePrefilter({ repo: "o/r", cwd: "/repo", io: w.io });
|
|
354
|
+
await run(34, "staging"); await run(34, "staging"); await run(34, "staging");
|
|
355
|
+
assert.equal(lookups, 4, "user + app, twice: one retry, then cached");
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe("post-deploy precondition gate (#4389)", () => {
|
|
360
|
+
const MERGE = "a".repeat(40);
|
|
361
|
+
const item = (id, disp) => `- [ ] \`${id}: After the next scheduled run, a health row exists\` — ${disp}: only checkable in prod after promotion`;
|
|
362
|
+
const followup = (parent, items) => `## Problem\n\nDeferred from #${parent}: the items below were declared out of scope.\n\n## Acceptance Criteria\n\n${items.join("\n")}\n\n<!-- FORGE:DEFERRED_FROM: #${parent} -->\n`;
|
|
363
|
+
const parentPr = [{ number: 90, title: "fix(x): health row (#40)", body: "", mergeCommit: { oid: MERGE } }];
|
|
364
|
+
const gated = (w, n, extra = {}) => pf(w, n, { defaultBranch: "main", ...extra });
|
|
365
|
+
|
|
366
|
+
it("parses the marker and requires every checklist item to be post-deploy", () => {
|
|
367
|
+
const body = followup(40, [item("R2", "post-deploy")]);
|
|
368
|
+
assert.equal(deferredFrom(body), 40);
|
|
369
|
+
assert.equal(deferredFrom("no marker"), null);
|
|
370
|
+
assert.ok(postDeployOnly(body));
|
|
371
|
+
assert.ok(!postDeployOnly(followup(40, [item("R2", "post-deploy"), item("R3", "defer")])));
|
|
372
|
+
assert.ok(!postDeployOnly("no checklist"));
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("not an ancestor of the default branch: parked awaiting-promotion, labelled and explained once, no validation run, $0", async () => {
|
|
376
|
+
const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]) } }, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
|
|
377
|
+
const r = await gated(w, 41);
|
|
378
|
+
assert.equal(r.parked, true); assert.equal(r.kind, "awaiting-promotion"); assert.equal(r.closed, false); assert.equal(r.cost_usd, 0);
|
|
379
|
+
assert.match(r.detail, /of #40 is not on main/);
|
|
380
|
+
assert.deepEqual(w.edits, [[41, "--add-label", "awaiting-promotion"]]);
|
|
381
|
+
assert.equal(w.comments.length, 1); assert.match(w.comments[0][1], /FORGE:PRECONDITION/);
|
|
382
|
+
assert.equal(w.runner.length, 0); assert.deepEqual(w.closed, []);
|
|
383
|
+
// already labelled: no second comment
|
|
384
|
+
const w2 = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]), labels: [{ name: "awaiting-promotion" }] } }, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
|
|
385
|
+
assert.equal((await gated(w2, 41)).parked, true);
|
|
386
|
+
assert.equal(w2.comments.length, 0);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("an escalated (needs-human) promotion follow-up is parked too; a dry run changes nothing on GitHub", async () => {
|
|
390
|
+
const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]), labels: [{ name: "needs-human" }] } }, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
|
|
391
|
+
const r = await gated(w, 41, { dryRun: true });
|
|
392
|
+
assert.equal(r.parked, true);
|
|
393
|
+
assert.deepEqual(w.edits, []); assert.deepEqual(w.comments, []);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("an ancestor (after promotion): not parked, the label is removed, and the issue goes on as before", async () => {
|
|
397
|
+
const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]), labels: [{ name: "awaiting-promotion" }] } }, mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
|
|
398
|
+
const r = await gated(w, 41);
|
|
399
|
+
assert.ok(!r.parked); assert.equal(r.detail, "no already-fixed signal");
|
|
400
|
+
assert.deepEqual(w.edits, [[41, "--remove-label", "awaiting-promotion"]]);
|
|
401
|
+
const rows = await orchestrate({ issues: [41], readBody: async () => "", prefilter: makePrefilter({ repo: "o/r", cwd: "/repo", io: w.io, defaultBranch: "main" }),
|
|
402
|
+
runOne: async () => ({ terminalReason: "merged", pr: 7, cost_usd: 1 }) });
|
|
403
|
+
assert.deepEqual(rows.map((x) => x.outcome), ["merged"]);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("an unresolvable parent (no merged PR, no trajectory) or an unknown SHA falls through to the lane", async () => {
|
|
407
|
+
const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]) }, 40: { comments: [] } } });
|
|
408
|
+
const r = await gated(w, 41);
|
|
409
|
+
assert.ok(!r.parked); assert.deepEqual(w.edits, []);
|
|
410
|
+
const g = await postDeployGate({ iss: { number: 41, body: followup(40, [item("R2", "post-deploy")]) }, repo: "o/r", lane: "staging", defaultBranch: "main", cwd: "/repo",
|
|
411
|
+
io: world({ mergedPrs: parentPr, ancestry: { [MERGE]: 128 } }).io });
|
|
412
|
+
assert.equal(g.state, "unresolved");
|
|
413
|
+
assert.equal((await postDeployGate({ iss: { number: 41, body: followup(40, [item("R2", "post-deploy")]) }, repo: "o/r", defaultBranch: async () => null, io: world().io })).state, "unresolved");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("the trusted trajectory 'merged at <sha>' is the fallback when no merged PR names the parent", async () => {
|
|
417
|
+
const w = world({ issues: { 40: { comments: [{ author: { login: "rapiercraft-forgedock" }, authorAssociation: "CONTRIBUTOR", body: "<!-- FORGE:TRAJECTORY -->\n| Merge | merged at abcdef123456 |" },
|
|
418
|
+
{ author: { login: "mallory" }, authorAssociation: "NONE", body: "<!-- FORGE:TRAJECTORY --> merged at 0123456789ab" }] } }, ancestry: { abcdef123456: 1 } });
|
|
419
|
+
const g = await postDeployGate({ iss: { number: 41, body: followup(40, [item("R2", "post-deploy")]) }, repo: "o/r", lane: "staging", defaultBranch: "main", cwd: "/repo", io: w.io });
|
|
420
|
+
assert.equal(g.state, "awaiting-promotion"); assert.deepEqual(g.shas, ["abcdef123456"]);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("mixed post-deploy + code items, and issues without DEFERRED_FROM, are unaffected (no gate calls)", async () => {
|
|
424
|
+
for (const body of [followup(40, [item("R2", "post-deploy"), item("R3", "defer")]), item("R2", "post-deploy")]) {
|
|
425
|
+
const w = world({ issues: { 41: { body } }, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
|
|
426
|
+
const r = await gated(w, 41);
|
|
427
|
+
assert.ok(!r.parked); assert.ok(!w.gitCalls.some((a) => a[0] === "merge-base"));
|
|
428
|
+
}
|
|
429
|
+
const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]), labels: [{ name: "needs-human" }] } } });
|
|
430
|
+
assert.equal((await pf(w, 41)).detail, "labelled, left to the lane"); // no default branch: gate off, today's behaviour
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("post_deploy.precondition_cmd: exit 0 ready, exit 1 parked awaiting-precondition, other exits fail open", async () => {
|
|
434
|
+
const body = followup(40, [item("R2", "post-deploy")]);
|
|
435
|
+
for (const [code, state] of [[0, "ready"], [1, "awaiting-precondition"], [2, "unresolved"], [-1, "unresolved"]]) {
|
|
436
|
+
const w = world({ mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
|
|
437
|
+
const calls = [];
|
|
438
|
+
w.io.execCode = async (cmd, args, opts) => { calls.push([cmd, args, opts.env.FORGE_ISSUE]); return code; };
|
|
439
|
+
const g = await postDeployGate({ iss: { number: 41, body }, repo: "o/r", lane: "staging", defaultBranch: "main", cwd: "/repo", io: w.io, preconditionCmd: "check-rollout" });
|
|
440
|
+
assert.equal(g.state, state);
|
|
441
|
+
assert.deepEqual(calls, [["sh", ["-c", "check-rollout", "precondition", "41"], "41"]]);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("forgedock triage prints a parked issue, one awaiting-promotion line, and leaves it out of the lanes", async () => {
|
|
446
|
+
const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]) }, 13: { body: "y" } }, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
|
|
447
|
+
const logs = []; const l = console.log; console.log = (x) => logs.push(String(x));
|
|
448
|
+
let res;
|
|
449
|
+
try { res = await triageFromCli(["41", "13", "--repo", "o/r", "--lane", "staging", "--dry-run"], { leanIo: w.io }); } finally { console.log = l; }
|
|
450
|
+
assert.deepEqual(res.lanes, [13]);
|
|
451
|
+
assert.ok(logs.some((x) => /^#41 → parked \(awaiting-promotion: /.test(x)));
|
|
452
|
+
assert.ok(logs.includes("awaiting promotion: #41 (fixes on staging, not on main)"));
|
|
453
|
+
assert.deepEqual(w.edits, []);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("lazyDefaultBranch resolves GitHub's default and fetches it once, only when asked", async () => {
|
|
457
|
+
const w = world();
|
|
458
|
+
const d = lazyDefaultBranch({ io: w.io, repo: "o/r", cwd: "/repo" });
|
|
459
|
+
assert.equal(w.gitCalls.length, 0);
|
|
460
|
+
assert.equal(await d(), "main"); assert.equal(await d(), "main");
|
|
461
|
+
assert.deepEqual(w.gitCalls, [["fetch", "origin", "main"]]);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("orchestrate: parked issues start no lane and the report has one awaiting-promotion line, no escalations", async () => {
|
|
465
|
+
const issues = {}; for (const n of [41, 42, 43]) issues[n] = { body: followup(40, [item("R2", "post-deploy")]) };
|
|
466
|
+
const w = world({ issues, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
|
|
467
|
+
const lanes = [];
|
|
468
|
+
const rows = await orchestrate({ issues: [41, 42, 43], readBody: async () => "", prefilter: makePrefilter({ repo: "o/r", cwd: "/repo", io: w.io, defaultBranch: "main" }),
|
|
469
|
+
resolveLane: async () => ({ ok: true, lane: "staging" }),
|
|
470
|
+
runOne: async (n) => { lanes.push(n); return { terminalReason: "merged", pr: 1, cost_usd: 1 }; } });
|
|
471
|
+
assert.deepEqual(lanes, []);
|
|
472
|
+
assert.deepEqual(rows.map((r) => [r.outcome, r.cost_usd]), [["awaiting-promotion", 0], ["awaiting-promotion", 0], ["awaiting-promotion", 0]]);
|
|
473
|
+
const lists = formatOutcomeLists(rows);
|
|
474
|
+
assert.deepEqual(lists, ["awaiting promotion: #41 #42 #43 (fixes on staging, not on main)"]);
|
|
475
|
+
assert.match(formatTable(rows), /#41\s+awaiting-promotion \(pre-filter\)/);
|
|
476
|
+
assert.equal(formatParkedLine([{ issue: 1, outcome: "merged" }]), "");
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
describe("#4467: isTrustedIssueComment", () => {
|
|
481
|
+
it("trusts the App in gh issue view and REST shapes and trusted associations; not other contributors", () => {
|
|
482
|
+
assert.equal(isTrustedIssueComment({ author: { login: "rapiercraft-forgedock" }, authorAssociation: "CONTRIBUTOR" }), true);
|
|
483
|
+
assert.equal(isTrustedIssueComment({ user: { login: "rapiercraft-forgedock[bot]", type: "Bot" }, author_association: "NONE" }), true);
|
|
484
|
+
assert.equal(isTrustedIssueComment({ author: { login: "app/rapiercraft-forgedock" }, authorAssociation: "NONE" }), true);
|
|
485
|
+
assert.equal(isTrustedIssueComment({ author: { login: "owner" }, authorAssociation: "OWNER" }), true);
|
|
486
|
+
assert.equal(isTrustedIssueComment({ author: { login: "someone" }, authorAssociation: "CONTRIBUTOR" }), false);
|
|
487
|
+
assert.equal(isTrustedIssueComment(null), false);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
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
|
+
* #4393: text alone misses wrapped interpreters (`uv run python -m x`) and transitive imports, so `runPreflight` also runs
|
|
9
|
+
* each command once on the base worktree and classifies its output (`runProbeMiss`); `unsetEnvVars` flags `$NAME`s the lane
|
|
10
|
+
* env leaves empty (#4325).
|
|
11
|
+
*/
|
|
12
|
+
import { builtinModules } from "node:module";
|
|
13
|
+
import { missingTool } from "./conformance.mjs";
|
|
14
|
+
|
|
15
|
+
export const PREFLIGHT_CAP = 20;
|
|
16
|
+
const KIND_NAME = { command: "command", "python-module": "Python module", "node-module": "Node package", path: "path", "env-var": "env var" };
|
|
17
|
+
const PY_RE = /^python(\d+(\.\d+)?)?$/;
|
|
18
|
+
const PY_ARG_FLAGS = new Set(["-W", "-X", "-Q"]);
|
|
19
|
+
const sq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
20
|
+
|
|
21
|
+
/** Quote-aware split of a bash command into segments (on &&, ||, |, ;, newline) of unquoted words; `$(...)` stays one word. */
|
|
22
|
+
export function shellSegments(command) {
|
|
23
|
+
const segs = [];
|
|
24
|
+
let words = [], word = "", has = false, q = null, depth = 0;
|
|
25
|
+
const endWord = () => { if (has) words.push(word); word = ""; has = false; };
|
|
26
|
+
const endSeg = () => { endWord(); if (words.length) segs.push(words); words = []; };
|
|
27
|
+
const s = String(command ?? "");
|
|
28
|
+
for (let i = 0; i < s.length; i++) {
|
|
29
|
+
const ch = s[i];
|
|
30
|
+
if (q === "'") { if (ch === "'") q = null; else word += ch; continue; }
|
|
31
|
+
if (ch === "\\" && i + 1 < s.length) { word += s[++i]; has = true; continue; }
|
|
32
|
+
if (q === '"') { if (ch === '"') q = null; else word += ch; continue; }
|
|
33
|
+
if (depth) { word += ch; if (ch === "(") depth++; else if (ch === ")") depth--; continue; }
|
|
34
|
+
if (ch === "'" || ch === '"') { q = ch; has = true; continue; }
|
|
35
|
+
if (ch === "$" && s[i + 1] === "(") { word += "$("; i++; depth = 1; has = true; continue; }
|
|
36
|
+
if (ch === " " || ch === "\t") { endWord(); continue; }
|
|
37
|
+
if (ch === "\n" || ch === ";" || ch === "|" || ch === "&") { if (ch === "&" && s[i + 1] !== "&") { endWord(); continue; } endSeg(); if (s[i + 1] === ch) i++; continue; }
|
|
38
|
+
if ((ch === "(" || ch === ")" || ch === "{" || ch === "}") && !has) continue;
|
|
39
|
+
word += ch; has = true;
|
|
40
|
+
}
|
|
41
|
+
endSeg();
|
|
42
|
+
return segs;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Top-level module names a Python `-c` program imports (absolute imports only). */
|
|
46
|
+
export function pythonImports(code) {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const line of String(code ?? "").split(/[;\n]/)) {
|
|
49
|
+
const imp = /^\s*import\s+(.+)$/.exec(line), from = /^\s*from\s+([\w.]+)\s+import\b/.exec(line);
|
|
50
|
+
if (imp) for (const part of imp[1].split(",")) { const m = /^\s*([A-Za-z_]\w*)/.exec(part); if (m) out.push(m[1]); }
|
|
51
|
+
else if (from && !from[1].startsWith(".")) out.push(from[1].split(".")[0]);
|
|
52
|
+
}
|
|
53
|
+
for (const m of String(code ?? "").matchAll(/(?:__import__|import_module)\(\s*['"]([A-Za-z_][\w.]*)['"]/g)) out.push(m[1].split(".")[0]);
|
|
54
|
+
return [...new Set(out)].filter((x) => x !== "__future__");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Package names (bare specifiers, cut to the package) a `node -e` program imports; builtins, node:, relative and absolute are not packages. */
|
|
58
|
+
export function nodeImports(code) {
|
|
59
|
+
const specs = [], c = String(code ?? "");
|
|
60
|
+
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]);
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const spec of specs) {
|
|
63
|
+
if (/^(\.|\/|node:|file:|data:)/.test(spec)) continue;
|
|
64
|
+
const parts = spec.split("/"), pkg = spec.startsWith("@") ? (parts.length > 1 ? parts.slice(0, 2).join("/") : null) : parts[0];
|
|
65
|
+
if (pkg && !builtinModules.includes(pkg)) out.push(pkg);
|
|
66
|
+
}
|
|
67
|
+
return [...new Set(out)];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const PY_PROBE = "import importlib.util,sys;sys.exit(0 if importlib.util.find_spec(sys.argv[1]) else 3)";
|
|
71
|
+
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}";
|
|
72
|
+
|
|
73
|
+
/** The program text and modules of one python/node segment's arguments. */
|
|
74
|
+
function moduleNeeds(exe, args) {
|
|
75
|
+
if (PY_RE.test(exe)) {
|
|
76
|
+
for (let i = 0; i < args.length; i++) {
|
|
77
|
+
const a = args[i];
|
|
78
|
+
if (a === "-c") return pythonImports(args[i + 1]);
|
|
79
|
+
if (a.startsWith("-c") && a.length > 2) return pythonImports(a.slice(2));
|
|
80
|
+
if (a === "-m") return args[i + 1] ? [args[i + 1].split(".")[0]] : [];
|
|
81
|
+
if (a.startsWith("-m") && a.length > 2) return [a.slice(2).split(".")[0]];
|
|
82
|
+
if (PY_ARG_FLAGS.has(a)) { i++; continue; }
|
|
83
|
+
if (!a.startsWith("-")) return [];
|
|
84
|
+
}
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
if (exe === "node") {
|
|
88
|
+
for (let i = 0; i < args.length; i++) {
|
|
89
|
+
const a = args[i];
|
|
90
|
+
if (["-e", "--eval", "-p", "--print"].includes(a)) return nodeImports(args[i + 1]);
|
|
91
|
+
const eq = /^--(?:eval|print)=(.*)$/s.exec(a);
|
|
92
|
+
if (eq) return nodeImports(eq[1]);
|
|
93
|
+
if (!a.startsWith("-")) return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The probes for `contract.validate[]`: {probes: [{ids, tool, kind, interp, needs, probe}], dropped}. `needs` is the command
|
|
101
|
+
* probe (tool) a module probe depends on: skip the module probe when its interpreter is missing. A name `repoHas(name)` holds
|
|
102
|
+
* or a deliverable's change text names (the change adds it) is not probed; paths and `$...` words are never probed.
|
|
103
|
+
*/
|
|
104
|
+
export function preflightProbes(contract, { repoHas = () => false, cap = PREFLIGHT_CAP } = {}) {
|
|
105
|
+
const changes = (Array.isArray(contract?.deliverables) ? contract.deliverables : []).map((d) => String(d?.change ?? "")).join("\n");
|
|
106
|
+
const named = (name) => new RegExp(`(^|[^\\w@/.-])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w/-])`).test(changes);
|
|
107
|
+
const skip = (name) => !name || name.includes("/") && !name.startsWith("@") || name.startsWith("$") || repoHas(name) || named(name);
|
|
108
|
+
const byKey = new Map();
|
|
109
|
+
const add = (id, kind, tool, interp = null) => {
|
|
110
|
+
const key = `${kind}|${tool}|${interp ?? ""}`;
|
|
111
|
+
const p = byKey.get(key);
|
|
112
|
+
if (p) { if (!p.ids.includes(id)) p.ids.push(id); return; }
|
|
113
|
+
const probe = kind === "command" ? `command -v -- ${sq(tool)} >/dev/null 2>&1`
|
|
114
|
+
: kind === "python-module" ? `${sq(interp)} -c ${sq(PY_PROBE)} ${sq(tool)} >/dev/null 2>&1`
|
|
115
|
+
: `node -e ${sq(NODE_PROBE)} ${sq(tool)} >/dev/null 2>&1`;
|
|
116
|
+
byKey.set(key, { ids: [id], tool, kind, interp, needs: kind === "command" ? null : interp, probe });
|
|
117
|
+
};
|
|
118
|
+
for (const v of Array.isArray(contract?.validate) ? contract.validate : []) {
|
|
119
|
+
if (!v?.id || typeof v.command !== "string") continue;
|
|
120
|
+
for (const seg of shellSegments(v.command)) {
|
|
121
|
+
let i = 0;
|
|
122
|
+
while (i < seg.length && (/^[A-Za-z_]\w*=/.test(seg[i]) || ["!", "time", "exec", "command", "env"].includes(seg[i]))) i++;
|
|
123
|
+
const exe = seg[i];
|
|
124
|
+
if (!exe || exe.includes("/") || exe.startsWith("$") || exe.startsWith("-")) continue;
|
|
125
|
+
if (!skip(exe)) add(v.id, "command", exe);
|
|
126
|
+
const kind = exe === "node" ? "node-module" : "python-module";
|
|
127
|
+
for (const mod of moduleNeeds(exe, seg.slice(i + 1))) if (!skip(mod)) add(v.id, kind, mod, exe);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const all = [...byKey.values()];
|
|
131
|
+
return { probes: all.slice(0, cap), dropped: Math.max(0, all.length - cap) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const FIX_HOW = "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";
|
|
135
|
+
/** One contract problem per miss: names the validate ids and the tool (or path, or env var), and how to fix the plan (never a global install). */
|
|
136
|
+
export function preflightProblems(misses) {
|
|
137
|
+
return (misses ?? []).map((m) => {
|
|
138
|
+
const ids = m.ids.join(", ");
|
|
139
|
+
if (m.kind === "env-var") return `${ids}: uses $${m.tool}, which is unset or empty in the lane environment, so the validate command cannot run as written; set it inside the command or ${FIX_HOW}`;
|
|
140
|
+
if (m.source === "run") return `${ids}: running it on the base worktree stopped before any test ran: needs ${KIND_NAME[m.kind] ?? m.kind} '${m.tool}'${m.line ? ` (\`${m.line}\`)` : ""}, so the validate command cannot run here; ${m.kind === "path" ? "point it at a path the repo or a deliverable has, or " : ""}${FIX_HOW}`;
|
|
141
|
+
return `${ids}: 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; ${FIX_HOW}`;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** #4393: per-command timeout and total budget of the freeze-time runs; a command cut by either is no evidence. */
|
|
146
|
+
export const RUN_PROBE_MS = 30000, RUN_BUDGET_MS = 120000;
|
|
147
|
+
const TIMEOUT_RC = 124;
|
|
148
|
+
// bash sets these itself; they are never "missing from the lane env"
|
|
149
|
+
const SHELL_VARS = new Set(["RANDOM", "SECONDS", "LINENO", "PWD", "OLDPWD", "HOME", "UID", "EUID", "PPID", "BASHPID", "IFS", "HOSTNAME", "SHELL", "USER", "PATH", "BASH", "BASH_SOURCE", "BASH_VERSION", "BASH_REMATCH", "PIPESTATUS", "FUNCNAME", "EPOCHSECONDS", "EPOCHREALTIME", "SHLVL", "OPTARG", "OPTIND", "REPLY", "HOSTTYPE", "OSTYPE", "MACHTYPE", "GROUPS"]);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* `$NAME` / `${NAME}` references (outside single quotes, not escaped) that are unset or empty in `env` and that the command
|
|
153
|
+
* does not assign itself (`NAME=`, `export NAME=`, `for NAME in`). `${NAME:-x}`-style defaults, `${#NAME}` and specials are not flagged.
|
|
154
|
+
*/
|
|
155
|
+
export function unsetEnvVars(command, env = {}) {
|
|
156
|
+
const s = String(command ?? ""), refs = [];
|
|
157
|
+
let q = null;
|
|
158
|
+
for (let i = 0; i < s.length; i++) {
|
|
159
|
+
const ch = s[i];
|
|
160
|
+
if (q === "'") { if (ch === "'") q = null; continue; }
|
|
161
|
+
if (ch === "\\") { i++; continue; }
|
|
162
|
+
if (ch === '"') { q = q ? null : '"'; continue; }
|
|
163
|
+
if (ch === "'" && !q) { q = "'"; continue; }
|
|
164
|
+
if (ch !== "$") continue;
|
|
165
|
+
const m = /^\{([A-Za-z_]\w*)(\}|:?[-=+?])|^([A-Za-z_]\w*)/.exec(s.slice(i + 1));
|
|
166
|
+
if (m && (m[3] || m[2] === "}")) refs.push(m[1] ?? m[3]);
|
|
167
|
+
}
|
|
168
|
+
const assigned = (n) => new RegExp(`(^|[\\s;&|(])((export|local|readonly|declare)\\s+)?${n}=|\\bfor\\s+${n}\\s+in\\b`).test(s);
|
|
169
|
+
return [...new Set(refs)].filter((n) => !SHELL_VARS.has(n) && (env[n] === undefined || env[n] === null || env[n] === "") && !assigned(n));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// [rc, pattern]: bash could not exec a path (127), python could not open its script, pytest / node --test could not find a file
|
|
173
|
+
const PATH_MISSING_RES = [[127, /(?:^|\s)([^\s:'"`]+): No such file or directory/m], [null, /can't open file '([^']+)'/], [null, /file or directory not found: (\S+)/], [null, /Could not find '([^']+)'/]];
|
|
174
|
+
const TESTISH_RE = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[^/]+$|(^|\/)test_[^/]*\.py$|_test\.(py|go)$/;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* #4393: why one freeze-time run of a validate command shows it cannot run on this host, or null when it ran (passed, or
|
|
178
|
+
* failed on test content: RED on base is expected), timed out (no evidence) or gave no recognisable reason.
|
|
179
|
+
* A path counts as missing unless `knownPath(path)` (repo-tracked, a deliverable, the repro test) or it looks like a test the
|
|
180
|
+
* builder adds; a module goes through conformance.mjs `missingTool` with `repoHas`, as the post-build gate does.
|
|
181
|
+
* Returns {tool, kind: command|python-module|node-module|path, line} with the output line that showed it.
|
|
182
|
+
*/
|
|
183
|
+
export function runProbeMiss(r, { repoHas = () => false, command = "", knownPath = () => false } = {}) {
|
|
184
|
+
const rc = r?.rc, out = String(r?.output ?? "");
|
|
185
|
+
if (!Number.isInteger(rc) || rc === 0 || rc === TIMEOUT_RC) return null;
|
|
186
|
+
const lineOf = (needle) => (out.split("\n").find((l) => l.includes(needle)) ?? "").trim().slice(0, 160);
|
|
187
|
+
for (const [onlyRc, re] of PATH_MISSING_RES) {
|
|
188
|
+
const p = onlyRc === null || onlyRc === rc ? re.exec(out)?.[1] : null;
|
|
189
|
+
if (!p) continue;
|
|
190
|
+
if (TESTISH_RE.test(p) || knownPath(p)) return null;
|
|
191
|
+
return { tool: p, kind: "path", line: lineOf(p) };
|
|
192
|
+
}
|
|
193
|
+
const miss = missingTool(r, { repoHas, command });
|
|
194
|
+
return miss ? { ...miss, line: lineOf(miss.tool) } : null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* #4393: what a freeze-time run may legitimately not find yet: `repoHas` (a name the repo tracks or a deliverable's change
|
|
199
|
+
* text names) and `knownPath` (a tracked file, a deliverable or the repro test, matched as a suffix of an absolute path).
|
|
200
|
+
*/
|
|
201
|
+
export function knownToContract(contract, files = []) {
|
|
202
|
+
const changes = (Array.isArray(contract?.deliverables) ? contract.deliverables : []).map((d) => String(d?.change ?? "")).join("\n");
|
|
203
|
+
const paths = [...files, ...(contract?.deliverables ?? []).map((d) => d?.path), contract?.repro_test?.path].filter((x) => typeof x === "string" && x).map((x) => x.replace(/^\.\//, "").replace(/\/+$/, ""));
|
|
204
|
+
const segs = new Set(paths.flatMap((x) => x.split("/")));
|
|
205
|
+
const repoHas = (name) => segs.has(name) || segs.has(`${name}.py`) || new RegExp(`(^|[^\\w@/.-])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w/-])`).test(changes);
|
|
206
|
+
const knownPath = (p) => { const rel = String(p).replace(/^\.\//, ""); return paths.some((k) => rel === k || rel.endsWith(`/${k}`) || rel.startsWith(`${k}/`) || rel.includes(`/${k}/`)); };
|
|
207
|
+
return { repoHas, knownPath };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const porcelain = (out) => new Map(String(out ?? "").split("\n").filter((l) => l.length > 3).map((l) => [l.slice(3), l.slice(0, 2)]));
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* #4393: run each `contract.validate[]` command (ids in `skipIds` excepted) once through `run({command, timeoutMs, env})`,
|
|
214
|
+
* 30 s each inside a 120 s budget, and classify each with `runProbeMiss`. `git(args)` runs in the worktree: whatever the runs
|
|
215
|
+
* newly created or modified (`git status --porcelain`) is reverted; a path dirty before the runs is never touched.
|
|
216
|
+
* Returns {misses: [{ids, tool, kind, line, source: "run"}], skipped: ids not run (budget), reverted: paths}.
|
|
217
|
+
*/
|
|
218
|
+
export async function runPreflight({ contract, run, git, env = {}, repoHas = () => false, knownPath = () => false, skipIds = [], now = () => Date.now(), perCommandMs = RUN_PROBE_MS, budgetMs = RUN_BUDGET_MS }) {
|
|
219
|
+
const misses = [], skipped = [], reverted = [];
|
|
220
|
+
const todo = (Array.isArray(contract?.validate) ? contract.validate : []).filter((v) => v?.id && typeof v.command === "string" && !skipIds.includes(v.id));
|
|
221
|
+
if (!todo.length) return { misses, skipped, reverted };
|
|
222
|
+
const status = async () => porcelain(await git(["status", "--porcelain", "--untracked-files=all"]).catch(() => ""));
|
|
223
|
+
const before = await status(), deadline = now() + budgetMs;
|
|
224
|
+
for (const v of todo) {
|
|
225
|
+
const left = deadline - now();
|
|
226
|
+
if (left <= 0) { skipped.push(v.id); continue; }
|
|
227
|
+
let r;
|
|
228
|
+
try { r = await run({ command: v.command, timeoutMs: Math.min(perCommandMs, left), env }); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
|
|
229
|
+
const miss = runProbeMiss(r, { repoHas, command: v.command, knownPath });
|
|
230
|
+
if (miss) misses.push({ ids: [v.id], ...miss, source: "run" });
|
|
231
|
+
}
|
|
232
|
+
const after = await status();
|
|
233
|
+
for (const [path, xy] of after) {
|
|
234
|
+
if (before.has(path) || path.includes(" -> ")) continue;
|
|
235
|
+
try { await git(xy === "??" ? ["clean", "-fdq", "--", path] : ["checkout", "--", path]); reverted.push(path); } catch { /* left as is; the scope gate still sees it */ }
|
|
236
|
+
}
|
|
237
|
+
return { misses, skipped, reverted };
|
|
238
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
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, runProbeMiss, unsetEnvVars, runPreflight, knownToContract } 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
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("#4393 validate preflight: running the command on base", () => {
|
|
64
|
+
const known = knownToContract({ deliverables: [{ id: "D1", path: "tools/new_check.py", change: "add module newlib" }], repro_test: { path: "test/r.test.mjs" } }, ["shared/detection/__init__.py", "scripts/run.sh"]);
|
|
65
|
+
it("unrunnable: a missing module, command or path; a repo-tracked or deliverable-named module is not the host", () => {
|
|
66
|
+
assert.deepEqual(runProbeMiss({ rc: 1, output: "Traceback (most recent call last):\nModuleNotFoundError: No module named 'httpx'\n" }, known),
|
|
67
|
+
{ tool: "httpx", kind: "python-module", line: "ModuleNotFoundError: No module named 'httpx'" });
|
|
68
|
+
assert.equal(runProbeMiss({ rc: 1, output: "ModuleNotFoundError: No module named 'shared'" }, known), null);
|
|
69
|
+
assert.equal(runProbeMiss({ rc: 1, output: "ModuleNotFoundError: No module named 'newlib'" }, known), null);
|
|
70
|
+
assert.equal(runProbeMiss({ rc: 127, output: "bash: line 1: jq: command not found" }, known).tool, "jq");
|
|
71
|
+
assert.deepEqual(runProbeMiss({ rc: 127, output: "bash: line 1: ./scripts/gone.sh: No such file or directory" }, known), { tool: "./scripts/gone.sh", kind: "path", line: "bash: line 1: ./scripts/gone.sh: No such file or directory" });
|
|
72
|
+
assert.equal(runProbeMiss({ rc: 2, output: "python3: can't open file '/wt/x/tools/old.py': [Errno 2] No such file or directory" }, known).kind, "path");
|
|
73
|
+
});
|
|
74
|
+
it("runs: passes, RED test failures, timeouts and paths the change adds are no miss", () => {
|
|
75
|
+
for (const r of [{ rc: 0, output: "" }, { rc: 1, output: "AssertionError: expected 1 to equal 2" }, { rc: 124, output: "ModuleNotFoundError: No module named 'httpx'\n(timed out after 30s)" },
|
|
76
|
+
{ rc: 2, output: "python3: can't open file '/wt/x/tools/new_check.py': [Errno 2] No such file or directory" }, { rc: 4, output: "ERROR: file or directory not found: tests/test_new.py" },
|
|
77
|
+
{ rc: 1, output: "Could not find '/wt/x/test/r.test.mjs'" }, { rc: 1, output: "cat: data.json: No such file or directory" }]) assert.equal(runProbeMiss(r, known), null, r.output);
|
|
78
|
+
});
|
|
79
|
+
it("unset env vars: empty or unset, outside single quotes, not assigned or defaulted by the command", () => {
|
|
80
|
+
assert.deepEqual(unsetEnvVars(`cat "$TMPDIR/x" && echo \${OUT} '$SINGLE' \\$ESC \${D:-1} $? $HOME`, { TMPDIR: "", HOME: "/h" }), ["TMPDIR", "OUT"]);
|
|
81
|
+
assert.deepEqual(unsetEnvVars(`X=1; echo $X; export Y=2 && echo "$Y"; for F in a b; do echo $F; done; echo $SET`, { SET: "s" }), []);
|
|
82
|
+
});
|
|
83
|
+
it("runPreflight runs every command with the env and a capped timeout, skips ids, honours the budget and reverts only what the runs changed", async () => {
|
|
84
|
+
const ran = [], gits = [];
|
|
85
|
+
let clock = 0, st = " M kept.js\n";
|
|
86
|
+
const run = async (a) => { ran.push([a.command, a.timeoutMs, a.env.K]); clock += 70000; st += "?? new.log\n M src/a.js\n"; return { rc: 1, output: a.command === "c1" ? "ModuleNotFoundError: No module named 'httpx'" : "" }; };
|
|
87
|
+
const git = async (args) => { gits.push(args.join(" ")); return args[0] === "status" ? st : ""; };
|
|
88
|
+
const contract = { validate: ["c1", "c2", "c3", "c4"].map((command, i) => ({ id: `V${i + 1}`, command })) };
|
|
89
|
+
const r = await runPreflight({ contract, run, git, env: { K: "v" }, skipIds: ["V2"], now: () => clock });
|
|
90
|
+
assert.deepEqual(ran, [["c1", 30000, "v"], ["c3", 30000, "v"]]);
|
|
91
|
+
assert.deepEqual(r.misses.map((m) => [m.ids, m.tool, m.kind, m.source]), [[["V1"], "httpx", "python-module", "run"]]);
|
|
92
|
+
assert.deepEqual(r.skipped, ["V4"]);
|
|
93
|
+
assert.deepEqual(r.reverted, ["new.log", "src/a.js"]);
|
|
94
|
+
assert.deepEqual(gits.filter((g) => !g.startsWith("status")), ["clean -fdq -- new.log", "checkout -- src/a.js"], "kept.js was dirty before: untouched");
|
|
95
|
+
});
|
|
96
|
+
it("problem lines name the id, the tool and the observed line, or the env var", () => {
|
|
97
|
+
const [run, env] = preflightProblems([{ ids: ["V4"], tool: "httpx", kind: "python-module", source: "run", line: "ModuleNotFoundError: No module named 'httpx'" }, { ids: ["V2"], tool: "TMPDIR", kind: "env-var", source: "env" }]);
|
|
98
|
+
assert.match(run, /^V4: running it on the base worktree stopped before any test ran: needs Python module 'httpx' \(`ModuleNotFoundError[^`]*`\)[\s\S]*repo's own toolchain .*never a global install/);
|
|
99
|
+
assert.match(env, /^V2: uses \$TMPDIR, which is unset or empty in the lane environment/);
|
|
100
|
+
});
|
|
101
|
+
it("a real run: a missing module is a miss, a failing test is not", async () => {
|
|
102
|
+
const dir = tmpDir("preflight-run-");
|
|
103
|
+
const git = async () => "";
|
|
104
|
+
const contract = C(`node -e "require('absent-pkg-4393')"`, `node -e "process.exit(1)"`);
|
|
105
|
+
const r = await runPreflight({ contract, run: (a) => runCheckCommand({ ...a, cwd: dir }), git });
|
|
106
|
+
assert.deepEqual(r.misses.map((m) => [m.ids[0], m.tool]), [["V1", "absent-pkg-4393"]]);
|
|
107
|
+
});
|
|
108
|
+
});
|