forgedock 1.27.0 → 1.29.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/README.md +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +39 -0
- package/bin/engine/telemetry-seam.mjs +37 -8
- package/bin/engine/telemetry-seam.test.mjs +31 -0
- package/bin/engine-cli.mjs +601 -42
- package/bin/engine.mjs +8 -4
- package/bin/forgedock.mjs +75 -9
- package/bin/gc.mjs +580 -0
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +22 -4
- package/bin/lean/batch.test.mjs +5 -3
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +234 -57
- package/bin/lean/conformance.test.mjs +168 -7
- package/bin/lean/context.mjs +333 -0
- package/bin/lean/context.test.mjs +279 -0
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +104 -9
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +723 -154
- package/bin/lean/lean.test.mjs +1603 -35
- package/bin/lean/ledger.mjs +27 -2
- package/bin/lean/ledger.test.mjs +36 -1
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +389 -19
- package/bin/lean/prefilter.mjs +2 -2
- package/bin/lean/prefilter.test.mjs +3 -0
- package/bin/lean/prompts.mjs +106 -17
- package/bin/lean/recovery.test.mjs +230 -0
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +49 -10
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/testmap.mjs +84 -0
- package/bin/lean/testmap.test.mjs +58 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +66 -10
- package/bin/lean/verify.test.mjs +104 -3
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +24 -2
- package/bin/stats/local-source.mjs +13 -0
- package/bin/stats/query.mjs +99 -0
- package/bin/stats/stats.test.mjs +94 -1
- package/commands/audit.md +2 -0
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +7 -0
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +62 -8
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +192 -2
- package/docs/OUTCOME-CODES.md +22 -0
- package/docs/RELEASE-NOTES.md +9 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +36 -2
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +9 -4
- package/scripts/check-devdocs-freshness.test.sh +11 -1
- package/scripts/check-spec-markers.sh +4 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +3 -3
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +1 -1
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
- package/scripts/fixtures/test-map/go/Makefile +5 -0
- package/scripts/fixtures/test-map/go/README.md +1 -0
- package/scripts/fixtures/test-map/go/go.mod +3 -0
- package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
- package/scripts/fixtures/test-map/go/main.go +3 -0
- package/scripts/fixtures/test-map/node/package.json +10 -0
- package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
- package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
- package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
- package/scripts/fixtures/test-map/node/src/a.ts +1 -0
- package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
- package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
- package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
- package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
- package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
- package/scripts/fixtures/test-map/python/uv.lock +1 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/repo-facts.sh +146 -0
- package/scripts/repo-facts.test.sh +106 -0
- package/scripts/structure-delta.sh +10 -5
- package/scripts/structure-delta.test.sh +3 -2
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/scripts/test-map.sh +211 -0
- package/scripts/test-map.test.sh +165 -0
- package/scripts/wait-ci-green.sh +10 -4
- package/scripts/wait-ci-green.test.sh +9 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- package/templates/bootstrap/technical-spike.md +75 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Toolchain pins for a lane (#4163). Reads the target repo's own pins (package.json `packageManager` / `engines.node`,
|
|
5
|
+
* `.nvmrc`, `.node-version`, `.tool-versions`) and writes a per-run shim dir for the pinned package manager, prepended
|
|
6
|
+
* to the runner's PATH so the builder, its validate commands and git hooks all resolve the pinned version. Zero LLM.
|
|
7
|
+
* A repo without pins gets nothing: no shim dir, no env, no warning.
|
|
8
|
+
*/
|
|
9
|
+
import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
|
|
10
|
+
import { accessSync, constants } from "node:fs";
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { join, delimiter, resolve } from "node:path";
|
|
13
|
+
|
|
14
|
+
const PMS = ["pnpm", "yarn", "npm"];
|
|
15
|
+
const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
16
|
+
|
|
17
|
+
/** "pnpm@10.4.1+sha512.ab" -> {name, version}; null for anything not a known manager at an exact version. */
|
|
18
|
+
export function parsePackageManager(s) {
|
|
19
|
+
const m = /^(pnpm|yarn|npm)@([^+\s]+)(?:\+\S*)?$/.exec(String(s ?? "").trim());
|
|
20
|
+
return m && VERSION_RE.test(m[2]) ? { name: m[1], version: m[2] } : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `.tool-versions` lines -> {tool: firstVersion}. */
|
|
24
|
+
function parseToolVersions(text) {
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const l of String(text ?? "").split("\n")) {
|
|
27
|
+
const [tool, ver] = l.replace(/#.*/, "").trim().split(/\s+/);
|
|
28
|
+
if (tool && ver && !(tool in out)) out[tool] = ver;
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The repo's pins. `override` is forge.yaml `env.toolchain`: "<pm>@<version>" replaces the package-manager pin, "off"
|
|
35
|
+
* disables detection. Returns {pm:{name,version,source}|null, node:{spec,source}|null, notes:[]}.
|
|
36
|
+
*/
|
|
37
|
+
export async function detectPins(dir, { override = null, read = (p) => readFile(p, "utf8") } = {}) {
|
|
38
|
+
const res = { pm: null, node: null, notes: [] };
|
|
39
|
+
const ov = typeof override === "string" ? override.trim() : "";
|
|
40
|
+
if (ov.toLowerCase() === "off") return res;
|
|
41
|
+
const tryRead = async (f) => { try { return String(await read(join(dir, f))); } catch { return null; } };
|
|
42
|
+
let pkg = null;
|
|
43
|
+
const pkgText = await tryRead("package.json");
|
|
44
|
+
if (pkgText !== null) { try { pkg = JSON.parse(pkgText); } catch { res.notes.push("package.json is not valid JSON; its pins are ignored"); } }
|
|
45
|
+
const tv = parseToolVersions(await tryRead(".tool-versions"));
|
|
46
|
+
if (ov) {
|
|
47
|
+
const pm = parsePackageManager(ov);
|
|
48
|
+
if (pm) res.pm = { ...pm, source: "forge.yaml env.toolchain" };
|
|
49
|
+
else res.notes.push(`forge.yaml env.toolchain "${ov.slice(0, 60)}" is not <pnpm|yarn|npm>@<x.y.z> or "off"; ignored`);
|
|
50
|
+
}
|
|
51
|
+
if (!res.pm && typeof pkg?.packageManager === "string") {
|
|
52
|
+
const pm = parsePackageManager(pkg.packageManager);
|
|
53
|
+
if (pm) res.pm = { ...pm, source: "package.json packageManager" };
|
|
54
|
+
else res.notes.push(`package.json packageManager "${pkg.packageManager.slice(0, 60)}" is not <pnpm|yarn|npm>@<x.y.z>; not pinned`);
|
|
55
|
+
}
|
|
56
|
+
if (!res.pm) {
|
|
57
|
+
const name = PMS.find((n) => VERSION_RE.test(tv[n] ?? ""));
|
|
58
|
+
if (name) res.pm = { name, version: tv[name], source: ".tool-versions" };
|
|
59
|
+
}
|
|
60
|
+
for (const [f, get] of [[".nvmrc", (t) => t], [".node-version", (t) => t]]) {
|
|
61
|
+
if (res.node) break;
|
|
62
|
+
const t = await tryRead(f);
|
|
63
|
+
const spec = t === null ? "" : get(t).split("\n")[0].trim();
|
|
64
|
+
if (spec) res.node = { spec, source: f };
|
|
65
|
+
}
|
|
66
|
+
if (!res.node && (tv.nodejs || tv.node)) res.node = { spec: tv.nodejs || tv.node, source: ".tool-versions" };
|
|
67
|
+
if (!res.node && typeof pkg?.engines?.node === "string" && pkg.engines.node.trim()) res.node = { spec: pkg.engines.node.trim(), source: "package.json engines.node" };
|
|
68
|
+
return res;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- minimal semver satisfaction for node pins ----
|
|
72
|
+
const parseV = (s) => {
|
|
73
|
+
const m = /^v?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?$/i.exec(String(s).trim());
|
|
74
|
+
if (!m) return null;
|
|
75
|
+
const n = (x) => (x === undefined || /^[x*]$/i.test(x) ? null : Number(x));
|
|
76
|
+
return [Number(m[1]), n(m[2]), n(m[3])];
|
|
77
|
+
};
|
|
78
|
+
const cmp = (a, b) => { for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; } return 0; };
|
|
79
|
+
const fill = (p, v = 0) => p.map((x) => (x === null ? v : x));
|
|
80
|
+
/** host [M,m,p] within partial p (20 -> 20.x.x, 20.11 -> 20.11.x)? */
|
|
81
|
+
const within = (h, p) => p.every((x, i) => x === null || x === h[i]);
|
|
82
|
+
const upper = (p) => (p[1] === null ? [p[0] + 1, 0, 0] : p[2] === null ? [p[0], p[1] + 1, 0] : null);
|
|
83
|
+
|
|
84
|
+
/** true/false, or null when the comparator can't be evaluated. */
|
|
85
|
+
function satisfiesOne(h, c) {
|
|
86
|
+
const m = /^(\^|~|>=|<=|>|<|=)?\s*(\S+)$/.exec(c.trim());
|
|
87
|
+
if (!m) return null;
|
|
88
|
+
const p = parseV(m[2]);
|
|
89
|
+
if (!p) return null;
|
|
90
|
+
const lo = fill(p);
|
|
91
|
+
switch (m[1] ?? "") {
|
|
92
|
+
case "": case "=": return within(h, p);
|
|
93
|
+
case ">=": return cmp(h, lo) >= 0;
|
|
94
|
+
case ">": return p[1] === null || p[2] === null ? cmp(h, upper(p)) >= 0 : cmp(h, lo) > 0;
|
|
95
|
+
case "<": return cmp(h, lo) < 0;
|
|
96
|
+
case "<=": return p[1] === null || p[2] === null ? cmp(h, upper(p)) < 0 : cmp(h, lo) <= 0;
|
|
97
|
+
case "~": return cmp(h, lo) >= 0 && cmp(h, p[1] === null ? [p[0] + 1, 0, 0] : [p[0], p[1] + 1, 0]) < 0;
|
|
98
|
+
case "^": return cmp(h, lo) >= 0 && cmp(h, lo[0] > 0 ? [lo[0] + 1, 0, 0] : lo[1] > 0 ? [0, lo[1] + 1, 0] : [0, 0, lo[2] + 1]) < 0;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Does host version `v` satisfy node spec? true/false, or null when the spec can't be evaluated (aliases, unknown syntax). */
|
|
104
|
+
export function nodeSatisfies(spec, v) {
|
|
105
|
+
const h = parseV(v);
|
|
106
|
+
if (!h || h.includes(null)) return null;
|
|
107
|
+
const alts = String(spec).split("||").map((s) => s.trim()).filter(Boolean);
|
|
108
|
+
if (!alts.length) return null;
|
|
109
|
+
let unknown = false;
|
|
110
|
+
for (const alt of alts) {
|
|
111
|
+
const hy = /^(\S+)\s+-\s+(\S+)$/.exec(alt); // hyphen range "18 - 20" = ">=18 <=20"
|
|
112
|
+
const parts = hy ? [`>=${hy[1]}`, `<=${hy[2]}`] : alt.replace(/(\^|~|>=|<=|>|<|=)\s+/g, "$1").split(/\s+/);
|
|
113
|
+
let ok = true;
|
|
114
|
+
for (const c of parts) {
|
|
115
|
+
const r = satisfiesOne(h, c);
|
|
116
|
+
if (r === null) { unknown = true; ok = false; break; }
|
|
117
|
+
if (!r) { ok = false; break; }
|
|
118
|
+
}
|
|
119
|
+
if (ok) return true;
|
|
120
|
+
}
|
|
121
|
+
return unknown ? null : false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** One-line trajectory warning when the host node does not satisfy the pin; null when it does or nothing is pinned. */
|
|
125
|
+
export function nodeWarning(pin, hostVersion = process.version) {
|
|
126
|
+
if (!pin?.spec) return null;
|
|
127
|
+
if (/^(lts\/|node$|stable$|latest$|current$|system$)/i.test(pin.spec)) return null;
|
|
128
|
+
const ok = nodeSatisfies(pin.spec, hostVersion);
|
|
129
|
+
if (ok === true) return null;
|
|
130
|
+
if (ok === null) return `Toolchain: cannot evaluate node pin "${pin.spec}" (${pin.source}) against host node ${hostVersion}; check it manually`;
|
|
131
|
+
return `host node ${hostVersion} does not satisfy the ${pin.source} pin "${pin.spec}"; installs, builds and tests may behave differently from the repo's CI`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** First executable `name` on `pathStr`, skipping `exclude` dirs (the shim dir itself, so a shim never calls itself). */
|
|
135
|
+
export function which(name, pathStr = process.env.PATH ?? "", exclude = []) {
|
|
136
|
+
const skip = new Set(exclude.map((d) => resolve(d)));
|
|
137
|
+
for (const d of String(pathStr).split(delimiter)) {
|
|
138
|
+
if (!d || skip.has(resolve(d))) continue;
|
|
139
|
+
const p = join(d, name);
|
|
140
|
+
try { accessSync(p, constants.X_OK); return p; } catch { /* next */ }
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
146
|
+
const HEADER = "#!/bin/sh\n# forgedock lane toolchain shim (#4163): pinned by the target repo; do not edit.\nexport COREPACK_ENABLE_DOWNLOAD_PROMPT=0 COREPACK_ENABLE_STRICT=0\n";
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Shim scripts {name: content} for the pinned manager: `corepack <pm>@<ver>` when corepack is on the host PATH, else
|
|
150
|
+
* `npx -y`. Absolute paths are resolved here, so a shim never re-enters the shim dir. Returns {} when neither exists.
|
|
151
|
+
*/
|
|
152
|
+
export function shimScripts(pm, { corepack = null, npx = null } = {}) {
|
|
153
|
+
if (!pm || !PMS.includes(pm.name) || !VERSION_RE.test(pm.version)) return {};
|
|
154
|
+
const at = `${pm.name}@${pm.version}`;
|
|
155
|
+
const out = {};
|
|
156
|
+
if (pm.name === "npm") {
|
|
157
|
+
if (npx) { out.npm = `${HEADER}exec ${q(npx)} -y ${q(at)} "$@"\n`; out.npx = `${HEADER}exec ${q(npx)} -y -p ${q(at)} npx "$@"\n`; }
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
if (corepack) {
|
|
161
|
+
out[pm.name] = `${HEADER}exec ${q(corepack)} ${q(at)} "$@"\n`;
|
|
162
|
+
if (pm.name === "pnpm") out.pnpx = `${HEADER}exec ${q(corepack)} ${q(at)} dlx "$@"\n`;
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
if (!npx) return out;
|
|
166
|
+
// yarn >= 2 is published as @yarnpkg/cli-dist; the npm "yarn" package is 1.x only.
|
|
167
|
+
const pkg = pm.name === "yarn" && Number(pm.version.split(".")[0]) >= 2 ? `@yarnpkg/cli-dist@${pm.version}` : at;
|
|
168
|
+
out[pm.name] = `${HEADER}exec ${q(npx)} -y -p ${q(pkg)} ${pm.name} "$@"\n`;
|
|
169
|
+
if (pm.name === "pnpm") out.pnpx = `${HEADER}exec ${q(npx)} -y -p ${q(pkg)} pnpm dlx "$@"\n`;
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Runner env for a shim dir: PATH with the shim dir first, corepack prompts off. */
|
|
174
|
+
export function toolchainEnv(binDir, basePath = process.env.PATH ?? "") {
|
|
175
|
+
return { PATH: basePath ? `${binDir}${delimiter}${basePath}` : binDir, COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", COREPACK_ENABLE_STRICT: "0" };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Version of the host `node` the builder's shells resolve (PATH), falling back to this process. */
|
|
179
|
+
export function hostNodeVersion(pathStr = process.env.PATH ?? "") {
|
|
180
|
+
const n = which("node", pathStr);
|
|
181
|
+
if (n) { try { return String(execFileSync(n, ["--version"], { timeout: 10000 })).trim(); } catch { /* fall through */ } }
|
|
182
|
+
return process.version;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function writeShimFile(p, content) { await writeFile(p, content); await chmod(p, 0o755); }
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Write executable (0755) shims for the pinned manager into `binDir`; launchers are resolved from `path` with `binDir`
|
|
189
|
+
* excluded. Returns the shim names written ([] when neither corepack nor npx is on `path`).
|
|
190
|
+
*/
|
|
191
|
+
export async function writeShims(binDir, pm, { path = process.env.PATH ?? "", find = which, writeShim = writeShimFile, mkdirp = (d) => mkdir(d, { recursive: true }) } = {}) {
|
|
192
|
+
const scripts = shimScripts(pm, { corepack: find("corepack", path, [binDir]), npx: find("npx", path, [binDir]) });
|
|
193
|
+
if (!Object.keys(scripts).length) return [];
|
|
194
|
+
await mkdirp(binDir);
|
|
195
|
+
for (const [name, content] of Object.entries(scripts)) await writeShim(join(binDir, name), content);
|
|
196
|
+
return Object.keys(scripts);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Short pin text for prompts and decisions, e.g. "pnpm@10.4.1 (package.json packageManager), node 20 (.nvmrc)"; null when nothing is pinned. */
|
|
200
|
+
export function summary(pins) {
|
|
201
|
+
const parts = [...(pins?.pm ? [`${pins.pm.name}@${pins.pm.version} (${pins.pm.source})`] : []), ...(pins?.node ? [`node ${pins.node.spec} (${pins.node.source})`] : [])];
|
|
202
|
+
return parts.length ? parts.join(", ") : null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Detect pins in `dir` and, for a pinned package manager, write shims to `binDir`.
|
|
207
|
+
* @returns {Promise<{pins, env:?object, shims:string[], summary:?string, warning:?string, notes:string[]}>}
|
|
208
|
+
* env is null and shims empty without a usable pm pin; summary is null when nothing is pinned: the caller changes nothing.
|
|
209
|
+
*/
|
|
210
|
+
export async function prepareToolchain({ dir, binDir, override = null, read, basePath = process.env.PATH ?? "",
|
|
211
|
+
writeShim = writeShimFile, mkdirp = (d) => mkdir(d, { recursive: true }), find = which, hostNode = () => hostNodeVersion(basePath) } = {}) {
|
|
212
|
+
const pins = await detectPins(dir, { override, ...(read ? { read } : {}) });
|
|
213
|
+
const out = { pins, env: null, shims: [], summary: null, warning: null, notes: [...pins.notes] };
|
|
214
|
+
if (pins.node) {
|
|
215
|
+
let v = null;
|
|
216
|
+
try { v = hostNode(); } catch { v = null; }
|
|
217
|
+
out.warning = v ? nodeWarning(pins.node, v) : null;
|
|
218
|
+
}
|
|
219
|
+
const pm = pins.pm;
|
|
220
|
+
let effective = pins;
|
|
221
|
+
if (pm && binDir) {
|
|
222
|
+
out.shims = await writeShims(binDir, pm, { path: basePath, find, writeShim, mkdirp });
|
|
223
|
+
if (out.shims.length) out.env = toolchainEnv(binDir, basePath);
|
|
224
|
+
else { out.notes.push(`Toolchain: ${pm.name}@${pm.version} is pinned but neither corepack nor npx is on PATH; the host ${pm.name} is used`); effective = { ...pins, pm: null }; }
|
|
225
|
+
}
|
|
226
|
+
out.summary = summary(effective);
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
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 { writeFileSync, mkdirSync, chmodSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { join, delimiter } from "node:path";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
|
+
import { detectPins, parsePackageManager, nodeSatisfies, nodeWarning, shimScripts, prepareToolchain, toolchainEnv, writeShims, summary } from "./toolchain.mjs";
|
|
10
|
+
|
|
11
|
+
const tmp = (p) => tmpDir(p);
|
|
12
|
+
const repo = (files) => {
|
|
13
|
+
const d = tmp("tc-repo-");
|
|
14
|
+
for (const [f, t] of Object.entries(files)) writeFileSync(join(d, f), typeof t === "string" ? t : JSON.stringify(t));
|
|
15
|
+
return d;
|
|
16
|
+
};
|
|
17
|
+
const exe = (dir, name, body) => { mkdirSync(dir, { recursive: true }); const p = join(dir, name); writeFileSync(p, `#!/bin/sh\n${body}\n`); chmodSync(p, 0o755); return p; };
|
|
18
|
+
/**
|
|
19
|
+
* A host whose PATH has pnpm 11.0.0 and a fake corepack/npx that print the requested version for `--version`
|
|
20
|
+
* (`corepack pnpm@X --version`, `npx -y -p pnpm@X pnpm --version`) without any network.
|
|
21
|
+
*/
|
|
22
|
+
const fakeHost = ({ corepack = true } = {}) => {
|
|
23
|
+
const d = tmp("tc-host-");
|
|
24
|
+
exe(d, "pnpm", 'echo 11.0.0');
|
|
25
|
+
const pick = 'for a in "$@"; do case "$a" in pnpm@*) v="${a#pnpm@}";; esac; done; for a in "$@"; do [ "$a" = "--version" ] && { echo "$v"; exit 0; }; done; exit 0';
|
|
26
|
+
if (corepack) exe(d, "corepack", pick);
|
|
27
|
+
exe(d, "npx", pick);
|
|
28
|
+
return d;
|
|
29
|
+
};
|
|
30
|
+
const sysPath = process.env.PATH ?? "";
|
|
31
|
+
|
|
32
|
+
describe("toolchain pins (#4163)", () => {
|
|
33
|
+
it("parsePackageManager accepts pm@x.y.z (with a +sha suffix) and nothing else", () => {
|
|
34
|
+
assert.deepEqual(parsePackageManager("pnpm@10.4.1+sha512.abc"), { name: "pnpm", version: "10.4.1" });
|
|
35
|
+
assert.deepEqual(parsePackageManager("yarn@4.1.0"), { name: "yarn", version: "4.1.0" });
|
|
36
|
+
for (const bad of ["pnpm@10", "bun@1.0.0", "pnpm@latest", "pnpm@1.0.0;rm -rf /", "", null]) assert.equal(parsePackageManager(bad), null, String(bad));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("detects packageManager and node pins in priority order", async () => {
|
|
40
|
+
const d = repo({ "package.json": { packageManager: "pnpm@10.4.1", engines: { node: ">=18" } }, ".nvmrc": "20.11.0\n", ".node-version": "22\n" });
|
|
41
|
+
const p = await detectPins(d);
|
|
42
|
+
assert.deepEqual(p.pm, { name: "pnpm", version: "10.4.1", source: "package.json packageManager" });
|
|
43
|
+
assert.deepEqual(p.node, { spec: "20.11.0", source: ".nvmrc" });
|
|
44
|
+
const e = await detectPins(repo({ "package.json": { engines: { node: "^20" } } }));
|
|
45
|
+
assert.deepEqual(e.node, { spec: "^20", source: "package.json engines.node" });
|
|
46
|
+
assert.equal(e.pm, null);
|
|
47
|
+
const tv = await detectPins(repo({ ".tool-versions": "# pins\nnodejs 20.1.0\npnpm 9.1.0\n" }));
|
|
48
|
+
assert.deepEqual(tv.pm, { name: "pnpm", version: "9.1.0", source: ".tool-versions" });
|
|
49
|
+
assert.deepEqual(tv.node, { spec: "20.1.0", source: ".tool-versions" });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("forge.yaml env.toolchain overrides the pm pin; off disables detection; junk is a note", async () => {
|
|
53
|
+
const d = repo({ "package.json": { packageManager: "pnpm@10.4.1" }, ".nvmrc": "20" });
|
|
54
|
+
assert.deepEqual((await detectPins(d, { override: "pnpm@9.0.0" })).pm, { name: "pnpm", version: "9.0.0", source: "forge.yaml env.toolchain" });
|
|
55
|
+
const off = await detectPins(d, { override: "off" });
|
|
56
|
+
assert.equal(off.pm, null); assert.equal(off.node, null);
|
|
57
|
+
const junk = await detectPins(d, { override: "pnpm" });
|
|
58
|
+
assert.equal(junk.pm.version, "10.4.1");
|
|
59
|
+
assert.match(junk.notes[0], /env\.toolchain "pnpm" is not/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("nodeSatisfies covers exact, partial, x, caret, tilde, comparators and ||", () => {
|
|
63
|
+
const t = [["20", "v20.11.0", true], ["20", "v22.1.0", false], ["20.11", "v20.11.4", true], ["20.x", "v20.0.0", true], ["^20.5", "v20.4.0", false],
|
|
64
|
+
["^20.5", "v20.9.0", true], ["~20.5.1", "v20.6.0", false], [">=18", "v26.7.0", true], [">=18 <21", "v22.0.0", false], ["18 || 20", "v20.1.0", true],
|
|
65
|
+
["< 20", "v20.0.0", false], ["<=20", "v20.9.9", true], [">20", "v20.9.9", false], [">20", "v21.0.0", true], ["v20.11.0", "v20.11.0", true]];
|
|
66
|
+
for (const [spec, v, want] of t) assert.equal(nodeSatisfies(spec, v), want, `${spec} vs ${v}`);
|
|
67
|
+
assert.equal(nodeSatisfies("18 - 20", "v19.0.0"), true);
|
|
68
|
+
assert.equal(nodeSatisfies("18 - 20", "v21.0.0"), false);
|
|
69
|
+
assert.equal(nodeSatisfies("hydrogen", "v19.0.0"), null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("an unsatisfied node pin yields a clear warning; a satisfied or alias pin yields none", () => {
|
|
73
|
+
assert.match(nodeWarning({ spec: "18.19.0", source: ".nvmrc" }, "v26.7.0"), /host node v26\.7\.0 does not satisfy the \.nvmrc pin "18\.19\.0"/);
|
|
74
|
+
assert.equal(nodeWarning({ spec: ">=18", source: "package.json engines.node" }, "v26.7.0"), null);
|
|
75
|
+
assert.equal(nodeWarning({ spec: "lts/iron", source: ".nvmrc" }, "v26.7.0"), null);
|
|
76
|
+
assert.match(nodeWarning({ spec: "iron-ish", source: ".nvmrc" }, "v26.7.0"), /cannot evaluate/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("shims: corepack first, npx fallback, yarn berry via @yarnpkg/cli-dist, values quoted", () => {
|
|
80
|
+
const pm = { name: "pnpm", version: "10.4.1" };
|
|
81
|
+
assert.match(shimScripts(pm, { corepack: "/h/corepack", npx: "/h/npx" }).pnpm, /exec '\/h\/corepack' 'pnpm@10\.4\.1' "\$@"/);
|
|
82
|
+
assert.match(shimScripts(pm, { corepack: "/h/corepack" }).pnpx, /'pnpm@10\.4\.1' dlx "\$@"/);
|
|
83
|
+
assert.match(shimScripts(pm, { npx: "/h/npx" }).pnpm, /exec '\/h\/npx' -y -p 'pnpm@10\.4\.1' pnpm "\$@"/);
|
|
84
|
+
assert.match(shimScripts({ name: "yarn", version: "4.1.0" }, { npx: "/h/npx" }).yarn, /-p '@yarnpkg\/cli-dist@4\.1\.0' yarn/);
|
|
85
|
+
assert.match(shimScripts({ name: "yarn", version: "1.22.19" }, { npx: "/h/npx" }).yarn, /-p 'yarn@1\.22\.19' yarn/);
|
|
86
|
+
const npm = shimScripts({ name: "npm", version: "10.2.0" }, { corepack: "/h/c", npx: "/h/npx" });
|
|
87
|
+
assert.deepEqual(Object.keys(npm), ["npm", "npx"]);
|
|
88
|
+
assert.match(npm.npx, /exec '\/h\/npx' -y -p 'npm@10\.2\.0' npx "\$@"/);
|
|
89
|
+
assert.deepEqual(shimScripts(pm, {}), {});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
for (const corepack of [true, false]) {
|
|
93
|
+
it(`R1: with packageManager pnpm@10.4.1 and pnpm 11 on PATH, runner/validate/hook shells resolve pnpm --version to 10.4.1 (${corepack ? "corepack" : "npx"})`, async () => {
|
|
94
|
+
const host = fakeHost({ corepack });
|
|
95
|
+
const basePath = `${host}${delimiter}${sysPath}`;
|
|
96
|
+
assert.equal(execFileSync("sh", ["-c", "pnpm --version"], { env: { ...process.env, PATH: basePath } }).toString().trim(), "11.0.0", "the host pnpm is the wrong one");
|
|
97
|
+
const d = repo({ "package.json": { name: "x", packageManager: "pnpm@10.4.1+sha512.ff" } });
|
|
98
|
+
const binDir = join(tmp("tc-run-"), "toolchain", "bin");
|
|
99
|
+
const tc = await prepareToolchain({ dir: d, binDir, basePath, hostNode: () => "v20.0.0" });
|
|
100
|
+
assert.ok(tc.env && tc.env.PATH.startsWith(binDir + delimiter), "shim dir first on PATH");
|
|
101
|
+
assert.equal(tc.summary, "pnpm@10.4.1 (package.json packageManager)");
|
|
102
|
+
const env = { ...process.env, ...tc.env };
|
|
103
|
+
// runner: the claude CLI child gets this env (bin/runner.mjs applies `env` last); its Bash tool shells inherit it.
|
|
104
|
+
assert.equal(execFileSync("bash", ["-lc", "pnpm --version"], { env, cwd: d }).toString().trim(), "10.4.1", "runner shell");
|
|
105
|
+
// validate: a validate command is a shell command in the worktree.
|
|
106
|
+
assert.equal(execFileSync("sh", ["-c", "cd . && pnpm --version"], { env, cwd: d }).toString().trim(), "10.4.1", "validate command");
|
|
107
|
+
// hooks: git runs pre-commit / commit-msg hooks with the committing shell's PATH.
|
|
108
|
+
const git = (a) => execFileSync("git", a, { env: { ...env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" }, cwd: d }).toString();
|
|
109
|
+
git(["init", "-q"]);
|
|
110
|
+
exe(join(d, ".git", "hooks"), "pre-commit", `pnpm --version > "${join(d, "pre.out")}"`);
|
|
111
|
+
exe(join(d, ".git", "hooks"), "commit-msg", `pnpm --version > "${join(d, "msg.out")}"`);
|
|
112
|
+
git(["add", "package.json"]);
|
|
113
|
+
git(["commit", "-q", "-m", "x"]);
|
|
114
|
+
assert.equal(readFileSync(join(d, "pre.out"), "utf8").trim(), "10.4.1", "pre-commit hook");
|
|
115
|
+
assert.equal(readFileSync(join(d, "msg.out"), "utf8").trim(), "10.4.1", "commit-msg hook");
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
it("R2: a node pin the host does not satisfy produces a warning (and no shim without a pm pin)", async () => {
|
|
120
|
+
const binDir = join(tmp("tc-run-"), "bin");
|
|
121
|
+
const tc = await prepareToolchain({ dir: repo({ ".nvmrc": "18.19.0" }), binDir, hostNode: () => "v26.7.0" });
|
|
122
|
+
assert.match(tc.warning, /^host node v26\.7\.0 does not satisfy the \.nvmrc pin "18\.19\.0"/);
|
|
123
|
+
assert.equal(tc.summary, "node 18.19.0 (.nvmrc)");
|
|
124
|
+
assert.equal(tc.env, null); assert.equal(existsSync(binDir), false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("R4: a repo without pins gets nothing: no env, no shim dir, no warning, no notes", async () => {
|
|
128
|
+
const binDir = join(tmp("tc-run-"), "bin");
|
|
129
|
+
for (const files of [{}, { "package.json": { name: "x", scripts: { test: "node --test" } } }]) {
|
|
130
|
+
const tc = await prepareToolchain({ dir: repo(files), binDir, hostNode: () => { throw new Error("must not probe node"); } });
|
|
131
|
+
assert.equal(tc.env, null); assert.equal(tc.summary, null); assert.equal(tc.warning, null);
|
|
132
|
+
assert.deepEqual(tc.shims, []); assert.deepEqual(tc.notes, []);
|
|
133
|
+
assert.equal(existsSync(binDir), false);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("toolchainEnv prepends the shim dir", () => {
|
|
138
|
+
assert.deepEqual(toolchainEnv("/s", "/a:/b"), { PATH: `/s${delimiter}/a:/b`, COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", COREPACK_ENABLE_STRICT: "0" });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("writeShims writes 0755 shims that never resolve the launcher from the shim dir; summary names the pins", async () => {
|
|
142
|
+
const host = fakeHost();
|
|
143
|
+
const binDir = join(tmp("tc-ws-"), "bin");
|
|
144
|
+
exe(binDir, "corepack", "echo self");
|
|
145
|
+
const names = await writeShims(binDir, { name: "pnpm", version: "10.4.1" }, { path: `${binDir}${delimiter}${host}` });
|
|
146
|
+
assert.deepEqual(names, ["pnpm", "pnpx"]);
|
|
147
|
+
for (const n of names) assert.equal(statSync(join(binDir, n)).mode & 0o777, 0o755);
|
|
148
|
+
assert.match(readFileSync(join(binDir, "pnpm"), "utf8"), new RegExp(`exec '${join(host, "corepack")}' 'pnpm@10\\.4\\.1'`));
|
|
149
|
+
assert.deepEqual(await writeShims(join(tmp("tc-ws-"), "bin"), { name: "yarn", version: "4.1.0" }, { path: "/nonexistent" }), []);
|
|
150
|
+
assert.equal(summary({ pm: { name: "pnpm", version: "10.4.1", source: "package.json packageManager" }, node: { spec: "20", source: ".nvmrc" } }), "pnpm@10.4.1 (package.json packageManager), node 20 (.nvmrc)");
|
|
151
|
+
assert.equal(summary({ pm: null, node: null }), null);
|
|
152
|
+
assert.equal(nodeWarning({ spec: "1", source: ".nvmrc" }) !== null, true, "defaults to process.version");
|
|
153
|
+
});
|
|
154
|
+
});
|
package/bin/lean/verify.mjs
CHANGED
|
@@ -11,16 +11,23 @@ export const VERIFY_MODES = Object.freeze(["local", "ci", "hybrid"]);
|
|
|
11
11
|
/** Changed files that never need the CI matrix (docs-only lanes skip CI verification). */
|
|
12
12
|
export const DEFAULT_SKIP_PATHS = Object.freeze(["*.md", "*.mdx", "*.rst", "*.txt", "docs/**", "devdocs/**"]);
|
|
13
13
|
export const DEFAULT_MAX_ROUNDS = 2;
|
|
14
|
+
/** Per-command timeout of the pre-PR declared check commands (#4240, `verify.precheck_timeout_seconds`). */
|
|
15
|
+
export const DEFAULT_PRECHECK_TIMEOUT_SECONDS = 300;
|
|
14
16
|
/** Total characters of failed-job log handed to the builder. */
|
|
15
17
|
export const LOG_CAP = 12000;
|
|
16
18
|
/** No run or check for the head after this long means CI is unavailable (local fallback). */
|
|
17
19
|
export const DEFAULT_GRACE_MS = 5 * 60000;
|
|
18
20
|
const LEAN_PREFIX = ".forge-lean/";
|
|
21
|
+
/** The lane's own report-only check run (#4270, `verify.check_run`): never part of ForgeDock's own CI waits. */
|
|
22
|
+
export const VERIFY_CHECK_NAME = "forgedock/verify";
|
|
23
|
+
/** Checks API output.text limit is 65535 characters; stay well under it. */
|
|
24
|
+
export const VERIFY_TEXT_CAP = 60000;
|
|
19
25
|
const OK = ["success", "neutral", "skipped"];
|
|
20
26
|
|
|
21
27
|
/**
|
|
22
28
|
* forge.yaml `verify:` section (block style; parseForgeYaml gives strings) → {mode, workflow, pathsInput, skipPaths,
|
|
23
|
-
* maxRounds}. Invalid values fall back to the defaults; mode defaults to local (today's
|
|
29
|
+
* maxRounds, precheckTimeoutSeconds, checkRun}. Invalid values fall back to the defaults; mode defaults to local (today's
|
|
30
|
+
* behaviour); checkRun (`check_run`, #4270) is true only for the literal `true`.
|
|
24
31
|
*/
|
|
25
32
|
export function parseVerify(sec = {}) {
|
|
26
33
|
const s = sec && typeof sec === "object" ? sec : {};
|
|
@@ -32,7 +39,10 @@ export function parseVerify(sec = {}) {
|
|
|
32
39
|
const skipPaths = rawSkip ? rawSkip.map((x) => String(x).trim()).filter(Boolean) : [...DEFAULT_SKIP_PATHS];
|
|
33
40
|
const n = Number(str(s.max_rounds));
|
|
34
41
|
const maxRounds = str(s.max_rounds) !== "" && Number.isInteger(n) && n >= 0 && n <= 10 ? n : DEFAULT_MAX_ROUNDS;
|
|
35
|
-
|
|
42
|
+
const t = Number(str(s.precheck_timeout_seconds));
|
|
43
|
+
const precheckTimeoutSeconds = str(s.precheck_timeout_seconds) !== "" && Number.isInteger(t) && t >= 1 && t <= 3600 ? t : DEFAULT_PRECHECK_TIMEOUT_SECONDS;
|
|
44
|
+
const checkRun = str(s.check_run).toLowerCase() === "true";
|
|
45
|
+
return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, checkRun };
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
const globRe = (g) => new RegExp(`^${g.split("**").map((p) => p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*")}$`);
|
|
@@ -55,6 +65,19 @@ export function capLogs(failures, cap = LOG_CAP) {
|
|
|
55
65
|
return failures.map((f) => { const l = String(f.log ?? ""); return { ...f, log: l.length > per ? `…${l.slice(-per)}` : l }; });
|
|
56
66
|
}
|
|
57
67
|
const runIdOf = (url) => /\/actions\/runs\/(\d+)/.exec(String(url ?? ""))?.[1] ?? null;
|
|
68
|
+
/**
|
|
69
|
+
* Failed checks [{name, url}] → capped [{job, url, log}]: one `failedLogs` fetch per Actions run id, the log attached to
|
|
70
|
+
* the first check of that run only (#4240: shared by runCiVerify and the merge-time fix rounds).
|
|
71
|
+
*/
|
|
72
|
+
export async function collectFailedLogs(io, failedChecks) {
|
|
73
|
+
const logs = {}, out = [];
|
|
74
|
+
for (const c of failedChecks ?? []) {
|
|
75
|
+
const id = runIdOf(c.url);
|
|
76
|
+
if (id && !(id in logs)) { try { logs[id] = String((await io.failedLogs?.(id)) ?? ""); } catch { logs[id] = ""; } }
|
|
77
|
+
out.push({ job: c.name, url: c.url ?? null, log: id && !out.some((x) => runIdOf(x.url) === id) ? logs[id] : "" });
|
|
78
|
+
}
|
|
79
|
+
return capLogs(out);
|
|
80
|
+
}
|
|
58
81
|
const sameSha = (a, b) => Boolean(a && b) && (String(a).startsWith(b) || String(b).startsWith(a));
|
|
59
82
|
|
|
60
83
|
/**
|
|
@@ -92,18 +115,12 @@ export async function runCiVerify({ io, branch, head, changedFiles, verify, ciMa
|
|
|
92
115
|
if (!run && now() - startedAt >= graceMs) return unavailable(`no ${v.workflow} run for ${String(head).slice(0, 12)} appeared within ${Math.round(graceMs / 60000)} min`);
|
|
93
116
|
} else {
|
|
94
117
|
let checks = [];
|
|
95
|
-
try { checks = (await io.commitChecks(head)) ?? []; } catch { checks = []; }
|
|
118
|
+
try { checks = ((await io.commitChecks(head)) ?? []).filter((c) => c?.name !== VERIFY_CHECK_NAME); } catch { checks = []; }
|
|
96
119
|
if (!checks.length && now() - startedAt >= graceMs) return unavailable(`no checks reported for ${String(head).slice(0, 12)} within ${Math.round(graceMs / 60000)} min`);
|
|
97
120
|
if (checks.length && checks.every((c) => c.state !== "pending")) {
|
|
98
121
|
const failed = checks.filter((c) => c.state === "failure");
|
|
99
122
|
if (!failed.length) return { result: "pass", failures: [], runUrl: null, detail: `${checks.length} check(s) green` };
|
|
100
|
-
|
|
101
|
-
for (const c of failed) {
|
|
102
|
-
const id = runIdOf(c.url);
|
|
103
|
-
if (id && !(id in logs)) { try { logs[id] = String((await io.failedLogs?.(id)) ?? ""); } catch { logs[id] = ""; } }
|
|
104
|
-
out.push({ job: c.name, url: c.url ?? null, log: id && !out.some((x) => runIdOf(x.url) === id) ? logs[id] : "" });
|
|
105
|
-
}
|
|
106
|
-
return { result: "fail", failures: capLogs(out), runUrl: failed[0].url ?? null, detail: `${failed.length} check(s) failed: ${failed.map((c) => c.name).join(", ")}` };
|
|
123
|
+
return { result: "fail", failures: await collectFailedLogs(io, failed), runUrl: failed[0].url ?? null, detail: `${failed.length} check(s) failed: ${failed.map((c) => c.name).join(", ")}` };
|
|
107
124
|
}
|
|
108
125
|
}
|
|
109
126
|
if (now() - startedAt >= ciMaxMinutes * 60000) return { result: "timeout", failures: [], runUrl: null, detail: `CI still pending after ${ciMaxMinutes} min` };
|
|
@@ -111,3 +128,42 @@ export async function runCiVerify({ io, branch, head, changedFiles, verify, ciMa
|
|
|
111
128
|
await sleep(ciPollDelayMs(i, random));
|
|
112
129
|
}
|
|
113
130
|
}
|
|
131
|
+
|
|
132
|
+
const VALIDATE_KINDS = ["validate_missing", "validate_failed"], REQUIREMENT_KINDS = ["requirement_unimplemented", "untracked_deferral"];
|
|
133
|
+
/**
|
|
134
|
+
* #4270: the `forgedock/verify` check-run payload from the results the lane already computed (no gate logic of its own).
|
|
135
|
+
* commands: [{command, rc}] of the last declared-check round, or null (none configured); structure: the structure delta or
|
|
136
|
+
* null; undeclaredEdges: package edges the contract does not name; docs: io.docsFreshness result or null; precheck: the
|
|
137
|
+
* conformance precheck failures of the last gate() run (null = never ran); amendments: its amendment problems; hotspots:
|
|
138
|
+
* whether hotspot facts were available. Returns {conclusion, gates: [{id, ok, ran, evidence}], title, summary, text}. A gate
|
|
139
|
+
* counts only when it ran (#4297): failure if a gate that ran failed, neutral ("no gates ran") when none ran, else success.
|
|
140
|
+
*/
|
|
141
|
+
export function verifyReport({ head = null, commands = null, structure = null, undeclaredEdges = [], docs = null, precheck = null, amendments = [], hotspots = false, contract = null, result = null } = {}) {
|
|
142
|
+
const NOT_RUN = "not run";
|
|
143
|
+
const pre = Array.isArray(precheck) ? precheck : [];
|
|
144
|
+
const details = (kinds) => pre.filter((f) => kinds.includes(f?.kind)).map((f) => String(f.detail ?? f.code ?? ""));
|
|
145
|
+
const runs = Array.isArray(result?.validation) ? result.validation : [];
|
|
146
|
+
const reqs = Array.isArray(result?.requirements) ? result.requirements : [];
|
|
147
|
+
const edges = (xs) => (xs ?? []).map((e) => `${e?.from}→${e?.to}${e?.manifest ? ` (${e.manifest})` : ""}`);
|
|
148
|
+
const gate = (id, ran, failures, okEvidence) => ({ id, ok: !failures.length, ran: Boolean(ran), evidence: !ran ? NOT_RUN : failures.length ? failures : okEvidence });
|
|
149
|
+
const gates = [
|
|
150
|
+
gate("precheck.commands", Array.isArray(commands), (commands ?? []).filter((c) => c.rc !== 0).map((c) => `${c.command} (exit ${c.rc})`),
|
|
151
|
+
(commands ?? []).map((c) => ({ command: c.command, rc: c.rc }))),
|
|
152
|
+
gate("structure.edges", Boolean(structure), edges(undeclaredEdges).map((e) => `undeclared ${e}`), { added: edges(structure?.package_edges_added) }),
|
|
153
|
+
gate("docs.freshness", Boolean(docs), docs && !docs.ok ? [String(docs.output ?? "stale docs").slice(0, 2000)] : [], "ok"),
|
|
154
|
+
gate("conformance.scope", precheck !== null, details(["scope_creep"]), "every changed file is inside the contract's deliverables"),
|
|
155
|
+
gate("conformance.validate", precheck !== null, details(VALIDATE_KINDS),
|
|
156
|
+
(contract?.validate ?? []).map((v) => { const r = runs.find((x) => x?.id === v.id); return { id: v.id, command: v.command, ...(r?.exit !== undefined ? { exit: r.exit } : r?.ci ? { ci: true } : {}) }; })),
|
|
157
|
+
gate("conformance.requirements", precheck !== null, details(REQUIREMENT_KINDS), reqs.map((r) => ({ id: r?.id, status: r?.status }))),
|
|
158
|
+
gate("maintenance.hotspot_growth", precheck !== null && Boolean(hotspots), details(["hotspot_growth"]), "no over-threshold file grew past the limit"),
|
|
159
|
+
gate("conformance.amendments", precheck !== null, (amendments ?? []).map((a) => String(a?.detail ?? a)), "ok"),
|
|
160
|
+
];
|
|
161
|
+
const ran = gates.filter((g) => g.ran), failed = ran.filter((g) => !g.ok);
|
|
162
|
+
const conclusion = failed.length ? "failure" : ran.length ? "success" : "neutral";
|
|
163
|
+
const title = failed.length ? `${failed.length} of ${ran.length} deterministic gate(s) failed` : ran.length ? `${ran.length} deterministic gate(s) passed` : "no gates ran";
|
|
164
|
+
const summary = [`ForgeDock deterministic gates${head ? ` at \`${String(head).slice(0, 12)}\`` : ""}: report only (requiring it is the repository's branch-protection choice).`, "",
|
|
165
|
+
...(ran.length ? gates.map((g) => `- ${!g.ran ? "⏭️" : g.ok ? "✅" : "❌"} \`${g.id}\`${g.ran ? "" : " (not run)"}`) : ["no gates ran"])].join("\n");
|
|
166
|
+
let json = JSON.stringify({ head, conclusion, gates }, null, 2);
|
|
167
|
+
if (json.length > VERIFY_TEXT_CAP) json = JSON.stringify({ head, conclusion, truncated: true, gates: gates.map((g) => ({ id: g.id, ok: g.ok, ran: g.ran, evidence: JSON.stringify(g.evidence).slice(0, Math.floor(VERIFY_TEXT_CAP / (gates.length + 1))) })) }, null, 2);
|
|
168
|
+
return { conclusion, gates, title, summary, text: "```json\n" + json + "\n```" };
|
|
169
|
+
}
|
package/bin/lean/verify.test.mjs
CHANGED
|
@@ -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 { parseVerify, matchesGlob, skipsCi, capLogs, runCiVerify, DEFAULT_SKIP_PATHS, LOG_CAP } from "./verify.mjs";
|
|
5
|
+
import { parseVerify, matchesGlob, skipsCi, capLogs, runCiVerify, collectFailedLogs, verifyReport, DEFAULT_SKIP_PATHS, LOG_CAP, VERIFY_CHECK_NAME, VERIFY_TEXT_CAP } from "./verify.mjs";
|
|
6
6
|
import { precheck } from "./conformance.mjs";
|
|
7
7
|
|
|
8
8
|
const HEAD = "abc123abc123abc123";
|
|
@@ -22,9 +22,9 @@ function fake(o = {}) {
|
|
|
22
22
|
|
|
23
23
|
describe("verify-by-CI config (#4167)", () => {
|
|
24
24
|
it("parseVerify defaults to local and rejects invalid values", () => {
|
|
25
|
-
assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2 });
|
|
25
|
+
assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2, precheckTimeoutSeconds: 300, checkRun: false });
|
|
26
26
|
const v = parseVerify({ mode: "ci", workflow: ".github/workflows/verify.yml", paths_input: "changed_paths", skip_paths: "*.md, docs/** ,", max_rounds: "3" });
|
|
27
|
-
assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3 });
|
|
27
|
+
assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 300, checkRun: false });
|
|
28
28
|
assert.equal(parseVerify({ mode: "remote" }).mode, "local");
|
|
29
29
|
assert.equal(parseVerify({ workflow: "x; rm -rf /" }).workflow, null);
|
|
30
30
|
assert.equal(parseVerify({ max_rounds: "-1" }).maxRounds, 2);
|
|
@@ -93,3 +93,104 @@ describe("runCiVerify (#4167)", () => {
|
|
|
93
93
|
assert.equal((await runCiVerify(g.args({ verify: parseVerify({ mode: "ci" }) }))).result, "unavailable");
|
|
94
94
|
});
|
|
95
95
|
});
|
|
96
|
+
|
|
97
|
+
describe("merge-time fix rounds and declared check commands (#4240)", () => {
|
|
98
|
+
it("collectFailedLogs fetches one log per Actions run and caps the total", async () => {
|
|
99
|
+
const fetched = [];
|
|
100
|
+
const io = { failedLogs: async (id) => { fetched.push(id); return "x".repeat(LOG_CAP * 2); } };
|
|
101
|
+
const out = await collectFailedLogs(io, [
|
|
102
|
+
{ name: "Unit tests", url: "https://github.com/o/r/actions/runs/9/job/1" },
|
|
103
|
+
{ name: "Shell tests", url: "https://github.com/o/r/actions/runs/9/job/2" },
|
|
104
|
+
{ name: "status", url: null },
|
|
105
|
+
]);
|
|
106
|
+
assert.deepEqual(fetched, ["9"]);
|
|
107
|
+
assert.deepEqual(out.map((f) => f.job), ["Unit tests", "Shell tests", "status"]);
|
|
108
|
+
assert.ok(out[0].log.length > 0 && out[1].log === "" && out[2].log === "");
|
|
109
|
+
assert.ok(out.reduce((n, f) => n + f.log.length, 0) <= LOG_CAP + 3);
|
|
110
|
+
assert.deepEqual(await collectFailedLogs({ failedLogs: async () => { throw new Error("gone"); } }, [{ name: "a", url: "https://x/actions/runs/1" }]), [{ job: "a", url: "https://x/actions/runs/1", log: "" }]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("parseVerify precheck_timeout_seconds: default 300, integer 1-3600 accepted, others fall back", () => {
|
|
114
|
+
assert.equal(parseVerify({}).precheckTimeoutSeconds, 300);
|
|
115
|
+
assert.equal(parseVerify({ precheck_timeout_seconds: "60" }).precheckTimeoutSeconds, 60);
|
|
116
|
+
for (const bad of ["0", "-5", "1.5", "abc", "3601"]) assert.equal(parseVerify({ precheck_timeout_seconds: bad }).precheckTimeoutSeconds, 300, bad);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("forgedock/verify check run (#4270)", () => {
|
|
121
|
+
const contract = { requirements: [{ id: "R1", disposition: "deliver" }], deliverables: [{ id: "D1", path: "src/a.ts", change: "x", requirements: ["R1"] }], validate: [{ id: "V1", command: "node --test t.mjs" }, { id: "V2", command: "tsc" }] };
|
|
122
|
+
const result = { validation: [{ id: "V1", exit: 0 }, { id: "V2", ci: true }], requirements: [{ id: "R1", status: "implemented" }] };
|
|
123
|
+
const out = (r) => JSON.parse(/```json\n([\s\S]*)\n```/.exec(r.text)[1]);
|
|
124
|
+
|
|
125
|
+
it("parseVerify: check_run is true only for the literal true; default false", () => {
|
|
126
|
+
assert.equal(parseVerify({}).checkRun, false);
|
|
127
|
+
assert.equal(parseVerify({ check_run: "true" }).checkRun, true);
|
|
128
|
+
assert.equal(parseVerify({ check_run: true }).checkRun, true);
|
|
129
|
+
for (const v of ["false", "yes", "1", ""]) assert.equal(parseVerify({ check_run: v }).checkRun, false, v);
|
|
130
|
+
assert.equal(VERIFY_CHECK_NAME, "forgedock/verify");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("success iff every gate passed; per-gate typed evidence from the computed results", () => {
|
|
134
|
+
const r = verifyReport({ head: "abc", commands: [{ command: "npm run lint", rc: 0 }], structure: { package_edges_added: [{ from: "@s/a", to: "@s/b", manifest: "packages/a/package.json" }] },
|
|
135
|
+
docs: { ok: true }, precheck: [], amendments: [], hotspots: true, contract, result });
|
|
136
|
+
assert.equal(r.conclusion, "success");
|
|
137
|
+
const g = Object.fromEntries(out(r).gates.map((x) => [x.id, x]));
|
|
138
|
+
assert.deepEqual(g["precheck.commands"].evidence, [{ command: "npm run lint", rc: 0 }]);
|
|
139
|
+
assert.deepEqual(g["structure.edges"].evidence, { added: ["@s/a→@s/b (packages/a/package.json)"] });
|
|
140
|
+
assert.deepEqual(g["conformance.validate"].evidence, [{ id: "V1", command: "node --test t.mjs", exit: 0 }, { id: "V2", command: "tsc", ci: true }]);
|
|
141
|
+
assert.deepEqual(g["conformance.requirements"].evidence, [{ id: "R1", status: "implemented" }]);
|
|
142
|
+
assert.ok(Object.values(g).every((x) => x.ok));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("each failing gate is failure with its evidence; gates without input are ok and marked not run", () => {
|
|
146
|
+
const pre = [
|
|
147
|
+
{ kind: "scope_creep", detail: "changed outside the contract's deliverables: src/x.ts" },
|
|
148
|
+
{ kind: "validate_failed", detail: "validate V1 failed (exit 1)" },
|
|
149
|
+
{ kind: "requirement_unimplemented", detail: "R1 reported \"partial\"" },
|
|
150
|
+
{ kind: "hotspot_growth", detail: "src/big.ts grew by 300 lines" },
|
|
151
|
+
];
|
|
152
|
+
const r = verifyReport({ head: "abc", commands: [{ command: "lint", rc: 2 }], structure: { package_edges_added: [] }, undeclaredEdges: [{ from: "@s/a", to: "@s/b" }],
|
|
153
|
+
docs: { ok: false, output: "stale devdocs/x.md" }, precheck: pre, amendments: [{ detail: "add-deliverable without why" }], hotspots: true, contract, result });
|
|
154
|
+
assert.equal(r.conclusion, "failure");
|
|
155
|
+
const g = Object.fromEntries(r.gates.map((x) => [x.id, x]));
|
|
156
|
+
assert.ok(r.gates.every((x) => !x.ok), "every gate failed");
|
|
157
|
+
assert.deepEqual(g["precheck.commands"].evidence, ["lint (exit 2)"]);
|
|
158
|
+
assert.deepEqual(g["structure.edges"].evidence, ["undeclared @s/a→@s/b"]);
|
|
159
|
+
assert.match(g["conformance.scope"].evidence[0], /src\/x\.ts/);
|
|
160
|
+
assert.match(g["maintenance.hotspot_growth"].evidence[0], /src\/big\.ts/);
|
|
161
|
+
assert.match(r.title, /8 of 8/);
|
|
162
|
+
|
|
163
|
+
const none = verifyReport({});
|
|
164
|
+
assert.ok(none.gates.every((x) => x.ok && !x.ran && x.evidence === "not run"));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("verifyReport with no gate results is neutral with \"no gates ran\" (#4297)", () => {
|
|
168
|
+
for (const r of [verifyReport({}), verifyReport({ head: "abc" })]) {
|
|
169
|
+
assert.equal(r.conclusion, "neutral");
|
|
170
|
+
assert.equal(r.title, "no gates ran");
|
|
171
|
+
assert.match(r.summary, /no gates ran/);
|
|
172
|
+
assert.doesNotMatch(`${r.title}\n${r.summary}`, /passed|✅/);
|
|
173
|
+
}
|
|
174
|
+
// Only gates that ran are counted: one command round, nothing else.
|
|
175
|
+
const one = verifyReport({ head: "abc", commands: [{ command: "lint", rc: 0 }] });
|
|
176
|
+
assert.equal(one.conclusion, "success");
|
|
177
|
+
assert.equal(one.title, "1 deterministic gate(s) passed");
|
|
178
|
+
assert.match(one.summary, /`structure\.edges` \(not run\)/);
|
|
179
|
+
const bad = verifyReport({ head: "abc", commands: [{ command: "lint", rc: 1 }] });
|
|
180
|
+
assert.equal(bad.conclusion, "failure"); assert.equal(bad.title, "1 of 1 deterministic gate(s) failed");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("the output text stays under the Checks API limit", () => {
|
|
184
|
+
const r = verifyReport({ precheck: Array.from({ length: 2000 }, (_, i) => ({ kind: "scope_creep", detail: `changed outside: src/${"x".repeat(80)}${i}.ts` })), contract, result });
|
|
185
|
+
assert.ok(r.text.length < 65535); assert.ok(r.text.length <= VERIFY_TEXT_CAP + 100);
|
|
186
|
+
assert.equal(r.conclusion, "failure"); assert.equal(out(r).truncated, true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("runCiVerify never waits on the lane's own forgedock/verify check run", async () => {
|
|
190
|
+
const f = fake({ checks: [[{ name: "ci", state: "success" }, { name: VERIFY_CHECK_NAME, state: "failure" }]] });
|
|
191
|
+
const r = await runCiVerify(f.args({ verify: parseVerify({ mode: "ci" }) }));
|
|
192
|
+
assert.equal(r.result, "pass");
|
|
193
|
+
const g = fake({ checks: [[{ name: VERIFY_CHECK_NAME, state: "success" }], [{ name: "ci", state: "success" }]] });
|
|
194
|
+
assert.equal((await runCiVerify(g.args({ verify: parseVerify({ mode: "ci" }) }))).detail, "1 check(s) green", "its own check alone is not CI");
|
|
195
|
+
});
|
|
196
|
+
});
|