forgedock 1.30.0 → 1.32.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 +3 -2
- 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 +409 -38
- package/bin/engine.mjs +47 -37
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/hooks/reread-nudge.mjs +65 -0
- 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 +338 -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 +150 -15
- package/bin/lean/conformance.test.mjs +175 -3
- package/bin/lean/context.mjs +128 -44
- package/bin/lean/context.test.mjs +157 -5
- 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 +48 -15
- 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 +712 -134
- package/bin/lean/lean.test.mjs +1626 -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/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +303 -41
- 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 +138 -13
- package/bin/lean/precheck.test.mjs +118 -1
- package/bin/lean/prefilter.mjs +220 -5
- package/bin/lean/prefilter.test.mjs +260 -4
- package/bin/lean/preflight.mjs +108 -3
- package/bin/lean/preflight.test.mjs +48 -1
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +104 -13
- 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.mjs +16 -1
- package/bin/lean/sched.test.mjs +175 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +170 -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 +296 -13
- package/bin/lean/sequence.test.mjs +263 -2
- 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 +72 -10
- package/bin/lean/verify.test.mjs +68 -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 +335 -0
- package/bin/stats/efficiency.test.mjs +266 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +91 -6
- package/bin/stats/local-source.mjs +145 -8
- package/bin/stats/query.mjs +181 -3
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +403 -8
- package/commands/orchestrate.md +3 -1
- 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 +80 -5
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/STATS.md +66 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/eval/corpus/lightlane-v1.json +55 -0
- package/docs/eval/lightlane-bench.md +91 -0
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +286 -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/trusted-comments.test.sh +1 -1
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
import { describe, it } from "node:test";
|
|
6
6
|
import assert from "node:assert/strict";
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
11
|
-
import { sequenceCollisions, journalProblems, snapshotLane, verifyRenumber } from "./sequence.mjs";
|
|
11
|
+
import { sequenceCollisions, journalProblems, snapshotLane, verifyRenumber, numberOnlyChange, renumberLane, resolveSequenceConflicts } from "./sequence.mjs";
|
|
12
12
|
import { sequencePrompt, sequenceSyncNote } from "./prompts.mjs";
|
|
13
13
|
|
|
14
14
|
const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
|
|
@@ -109,4 +109,265 @@ describe("sequence-numbered migrations at hand-off (#4322)", () => {
|
|
|
109
109
|
assert.match(sequenceSyncNote(["db/drizzle/meta/_journal.json"]), /do not text-merge/);
|
|
110
110
|
assert.match(sequenceSyncNote(["db/drizzle/0007_usage.sql"]), /Renumber the lane's files/);
|
|
111
111
|
});
|
|
112
|
+
|
|
113
|
+
/** A base commit with `baseFiles`, a lane commit adding `laneFiles`, then `laterBase` on main; returns the repo on the lane. */
|
|
114
|
+
const twoCommits = (baseFiles, laneFiles, laterBase = null) => {
|
|
115
|
+
const d = tmpDir("seq-");
|
|
116
|
+
sh(d, "init", "-q", "-b", "main");
|
|
117
|
+
put(d, baseFiles); commit(d, "base");
|
|
118
|
+
sh(d, "checkout", "-qb", "lane");
|
|
119
|
+
put(d, laneFiles); commit(d, "lane");
|
|
120
|
+
if (laterBase) { sh(d, "checkout", "-q", "main"); put(d, laterBase); commit(d, "later base"); sh(d, "checkout", "-q", "lane"); }
|
|
121
|
+
return d;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
it("date and issue-number filename prefixes are not sequence numbers (#4343)", async () => {
|
|
125
|
+
const d = twoCommits(
|
|
126
|
+
{ "blog/2026-10-05-first.md": "a\n", "news/20261005-a.md": "a\n", "docs/4322-a.md": "a\n", "docs2/2447-a.md": "a\n", "docs2/4160-b.md": "b\n" },
|
|
127
|
+
{ "blog/2026-10-20-second.md": "b\n", "news/20261020-b.md": "b\n", "docs/4322-b.md": "b\n", "docs2/4160-c.md": "c\n" },
|
|
128
|
+
);
|
|
129
|
+
assert.deepEqual(await sequenceCollisions({ git: gitIn(d), base: "main" }), []);
|
|
130
|
+
assert.deepEqual(await snapshotLane({ git: gitIn(d), base: "main" }).then((s) => s.map((f) => f.path).sort()), ["docs/4322-b.md", "docs2/4160-c.md"]);
|
|
131
|
+
assert.equal(sequenceSyncNote(["blog/2026-10-05-first.md"]), "");
|
|
132
|
+
assert.equal(sequenceSyncNote(["news/20261005-a.md"]), "");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("real series still collide: SQL, Python, drizzle with a single base migration plus journal (#4343)", async () => {
|
|
136
|
+
const d = twoCommits(
|
|
137
|
+
{
|
|
138
|
+
"db/0006_a.sql": "a\n", "db/0007_other.sql": "o\n",
|
|
139
|
+
"app/migrations/0001_initial.py": "i\n", "app/migrations/0002_x.py": "x\n", "app/migrations/__init__.py": "",
|
|
140
|
+
"drizzle/0000_init.sql": "CREATE TABLE t (id int);\n", "drizzle/meta/_journal.json": journal("0000_init"),
|
|
141
|
+
},
|
|
142
|
+
{ "db/0007_usage.sql": "u\n", "app/migrations/0002_y.py": "y\n", "drizzle/0000_x.sql": "CREATE TABLE x (id int);\n", "drizzle/meta/0000_snapshot.json": '{"id":"lane0"}\n' },
|
|
143
|
+
{ "drizzle/meta/0000_snapshot.json": '{"id":"s0"}\n' },
|
|
144
|
+
);
|
|
145
|
+
const c = await sequenceCollisions({ git: gitIn(d), base: "main" });
|
|
146
|
+
assert.deepEqual(c.map((x) => [x.lane, x.base, x.next]).sort((a, b) => a[0].localeCompare(b[0])), [
|
|
147
|
+
["app/migrations/0002_y.py", ["app/migrations/0002_x.py"], "0003"],
|
|
148
|
+
["db/0007_usage.sql", ["db/0007_other.sql"], "0008"],
|
|
149
|
+
["drizzle/0000_x.sql", ["drizzle/0000_init.sql"], "0001"],
|
|
150
|
+
["drizzle/meta/0000_snapshot.json", ["drizzle/meta/0000_snapshot.json"], "0001"],
|
|
151
|
+
]);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("deterministic renumber (#4386)", () => {
|
|
156
|
+
/** main gets `baseFiles`, the lane (from main) adds `laneFiles`, then main gets `laterBase`; returns the repo on the lane. */
|
|
157
|
+
const repo = (baseFiles, laneFiles, laterBase) => {
|
|
158
|
+
const d = tmpDir("seq4386-");
|
|
159
|
+
sh(d, "init", "-q", "-b", "main");
|
|
160
|
+
put(d, baseFiles); commit(d, "base");
|
|
161
|
+
sh(d, "checkout", "-qb", "lane");
|
|
162
|
+
put(d, laneFiles); commit(d, "lane");
|
|
163
|
+
sh(d, "checkout", "-q", "main"); put(d, laterBase); commit(d, "later base");
|
|
164
|
+
sh(d, "checkout", "-q", "lane");
|
|
165
|
+
return d;
|
|
166
|
+
};
|
|
167
|
+
const show = (d, p) => sh(d, "show", `HEAD:${p}`);
|
|
168
|
+
const snap = (id, prevId, tables = { t: 1 }) => `${JSON.stringify({ version: "7", id, prevId, tables }, null, 2)}\n`;
|
|
169
|
+
const NCLT_SQL = "INSERT INTO adapters (name) VALUES ('nclt');\n";
|
|
170
|
+
/** base 0048; the lane adds a data-only 0049_nclt; then the base takes 0049-0051. */
|
|
171
|
+
const drizzle = ({ laneTables = { t: 1 } } = {}) => repo(
|
|
172
|
+
{ "db/0048_init.sql": "CREATE TABLE t (id int);\n", "db/meta/_journal.json": journal("0048_init"), "db/meta/0048_snapshot.json": snap("s48", "s47") },
|
|
173
|
+
{ "db/0049_nclt.sql": NCLT_SQL, "db/meta/_journal.json": journal("0048_init", "0049_nclt"), "db/meta/0049_snapshot.json": snap("lane49", "s48", laneTables) },
|
|
174
|
+
{
|
|
175
|
+
"db/0049_a.sql": "a;\n", "db/0050_b.sql": "b;\n", "db/0051_c.sql": "CREATE TABLE u (id int);\n",
|
|
176
|
+
"db/meta/_journal.json": journal("0048_init", "0049_a", "0050_b", "0051_c"),
|
|
177
|
+
"db/meta/0049_snapshot.json": snap("s49", "s48"), "db/meta/0050_snapshot.json": snap("s50", "s49"), "db/meta/0051_snapshot.json": snap("s51", "s50", { t: 1, u: 1 }),
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
it("verifyRenumber accepts an ADR whose only change is its own heading number, and an index row change (#4386)", async () => {
|
|
182
|
+
const adr = (body) => repo(
|
|
183
|
+
{ "docs/adr/0015-a.md": "# 0015. A\n", "docs/adr/0016-b.md": "# 0016. B\n", "docs/adr/README.md": "| 0015 | A |\n| 0016 | B |\n" },
|
|
184
|
+
{ "docs/adr/0017-nclt.md": "# 0017. NCLT adapter\n\nWe adopt 0017-style adapters.\n", "docs/adr/README.md": "| 0015 | A |\n| 0016 | B |\n| 0017 | [NCLT](0017-nclt.md) |\n" },
|
|
185
|
+
{ "docs/adr/0017-other.md": "# 0017. Other\n", "docs/adr/README.md": "| 0015 | A |\n| 0016 | B |\n| 0017 | Other |\n" },
|
|
186
|
+
);
|
|
187
|
+
for (const [body, ok] of [["We adopt 0017-style adapters.", true], ["We adopt other adapters.", false]]) {
|
|
188
|
+
const d = adr();
|
|
189
|
+
const before = await snapshotLane({ git: gitIn(d), base: "main" });
|
|
190
|
+
try { sh(d, "merge", "--no-edit", "main"); } catch { /* README conflict: resolved below */ }
|
|
191
|
+
sh(d, "mv", "-f", "docs/adr/0017-nclt.md", "docs/adr/0018-nclt.md");
|
|
192
|
+
put(d, { "docs/adr/0018-nclt.md": `# 0018. NCLT adapter\n\n${body}\n`, "docs/adr/README.md": "| 0015 | A |\n| 0016 | B |\n| 0017 | Other |\n| 0018 | [NCLT](0018-nclt.md) |\n" });
|
|
193
|
+
commit(d, "renumber");
|
|
194
|
+
const v = await verifyRenumber({ git: gitIn(d), base: "main", before });
|
|
195
|
+
assert.equal(v.ok, ok, v.problems.join("; "));
|
|
196
|
+
if (!ok) assert.match(v.problems.join("\n"), /docs\/adr\/0017-nclt\.md: content changed/);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("base takes 0049-0051 while the lane holds 0049: renumberLane moves it to 0052, clones the tip snapshot, appends the journal, and the lane merges", async () => {
|
|
201
|
+
const d = drizzle();
|
|
202
|
+
const before = await snapshotLane({ git: gitIn(d), base: "main" });
|
|
203
|
+
const r = await renumberLane({ git: gitIn(d), base: "main", cwd: d, uuid: () => "fresh-id" });
|
|
204
|
+
assert.deepEqual(r.moves, [{ from: "db/0049_nclt.sql", to: "db/0052_nclt.sql" }]);
|
|
205
|
+
assert.equal(r.commit, sh(d, "rev-parse", "HEAD").trim());
|
|
206
|
+
assert.equal(show(d, "db/0052_nclt.sql"), NCLT_SQL, "SQL byte-identical");
|
|
207
|
+
assert.ok(!existsSync(join(d, "db/0049_nclt.sql")));
|
|
208
|
+
assert.equal(show(d, "db/meta/0049_snapshot.json"), snap("s49", "s48"), "base snapshot restored at the shared path");
|
|
209
|
+
const base51 = JSON.parse(show(d, "db/meta/0051_snapshot.json")), mine = JSON.parse(show(d, "db/meta/0052_snapshot.json"));
|
|
210
|
+
assert.deepEqual(mine, { ...base51, id: "fresh-id", prevId: "s51" });
|
|
211
|
+
const j = JSON.parse(show(d, "db/meta/_journal.json"));
|
|
212
|
+
const baseJ = JSON.parse(sh(d, "show", "main:db/meta/_journal.json"));
|
|
213
|
+
assert.deepEqual(j.entries.slice(0, 4), baseJ.entries);
|
|
214
|
+
assert.deepEqual(j.entries[4], { idx: 4, tag: "0052_nclt", when: 1001 });
|
|
215
|
+
assert.deepEqual(j.entries.map((e) => e.idx), [0, 1, 2, 3, 4]);
|
|
216
|
+
assert.deepEqual(await journalProblems({ git: gitIn(d), dirs: ["db"] }), []);
|
|
217
|
+
assert.deepEqual(await sequenceCollisions({ git: gitIn(d), base: "main" }), []);
|
|
218
|
+
assert.deepEqual(await verifyRenumber({ git: gitIn(d), base: "main", before, moves: r.moves }), { ok: true, problems: [] });
|
|
219
|
+
sh(d, "checkout", "-q", "main");
|
|
220
|
+
sh(d, "merge", "--no-edit", "lane"); // the lane merges into its base without conflicts
|
|
221
|
+
assert.deepEqual(await journalProblems({ git: gitIn(d), dirs: ["db"] }), []);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("references to the old basename are rewritten repo-wide (a link from another ADR, a code comment) and the index keeps both rows", async () => {
|
|
225
|
+
const d = repo(
|
|
226
|
+
{ "docs/adr/0015-a.md": "# 0015. A\n", "docs/adr/0016-b.md": "# 0016. B\n", "docs/adr/0017-c.md": "# 0017. C\n", "docs/adr/0018-d.md": "# 0018. D\n", "docs/adr/README.md": "# ADRs\n\n- 0018 D\n", "src/x.ts": "export const x = 1;\n" },
|
|
227
|
+
{ "docs/adr/0019-nclt.md": "# 0019. NCLT\n\nSupersedes nothing.\n", "docs/adr/0015-a.md": "# 0015. A\n\nSee [NCLT](0019-nclt.md).\n", "docs/adr/README.md": "# ADRs\n\n- 0018 D\n- [0019 NCLT](0019-nclt.md)\n", "src/x.ts": "// docs/adr/0019-nclt.md\nexport const x = 1;\n" },
|
|
228
|
+
{ "docs/adr/0019-other.md": "# 0019. Other\n", "docs/adr/README.md": "# ADRs\n\n- 0018 D\n- 0019 Other\n" },
|
|
229
|
+
);
|
|
230
|
+
const before = await snapshotLane({ git: gitIn(d), base: "main" });
|
|
231
|
+
const r = await renumberLane({ git: gitIn(d), base: "main", cwd: d });
|
|
232
|
+
assert.deepEqual(r.moves, [{ from: "docs/adr/0019-nclt.md", to: "docs/adr/0020-nclt.md" }]);
|
|
233
|
+
assert.equal(show(d, "docs/adr/0020-nclt.md"), "# 0020. NCLT\n\nSupersedes nothing.\n");
|
|
234
|
+
assert.equal(show(d, "docs/adr/0015-a.md"), "# 0015. A\n\nSee [NCLT](0020-nclt.md).\n");
|
|
235
|
+
assert.equal(show(d, "src/x.ts"), "// docs/adr/0020-nclt.md\nexport const x = 1;\n");
|
|
236
|
+
const idx = show(d, "docs/adr/README.md");
|
|
237
|
+
assert.match(idx, /0019 Other/);
|
|
238
|
+
assert.match(idx, /\(0020-nclt\.md\)/);
|
|
239
|
+
assert.doesNotMatch(idx, /<<<<<<<|0019-nclt/);
|
|
240
|
+
assert.equal(sh(d, "status", "--porcelain").trim(), "");
|
|
241
|
+
assert.deepEqual(await verifyRenumber({ git: gitIn(d), base: "main", before, moves: r.moves }), { ok: true, problems: [] });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("falls back (unsupported, branch unchanged) for a schema-changing migration without regenerate or a .py migration; a regenerate command fills the snapshot", async () => {
|
|
245
|
+
let d = drizzle({ laneTables: { t: 1, nclt: 1 } });
|
|
246
|
+
let head = sh(d, "rev-parse", "HEAD");
|
|
247
|
+
let r = await renumberLane({ git: gitIn(d), base: "main", cwd: d });
|
|
248
|
+
assert.match(r.unsupported, /schema-changing migration db\/0049_nclt\.sql needs lean\.sequence\.regenerate/);
|
|
249
|
+
assert.equal(sh(d, "rev-parse", "HEAD"), head);
|
|
250
|
+
assert.equal(sh(d, "status", "--porcelain").trim(), "");
|
|
251
|
+
const calls = [];
|
|
252
|
+
r = await renumberLane({ git: gitIn(d), base: "main", cwd: d, regenerate: "pnpm db:generate", run: async (cmd, o) => { calls.push(cmd); writeFileSync(join(o.cwd, "db/meta/0052_snapshot.json"), snap("gen", "s51", { t: 1, u: 1, nclt: 1 })); } });
|
|
253
|
+
assert.ok(r.ok, r.unsupported);
|
|
254
|
+
assert.deepEqual(calls, ["pnpm db:generate"]);
|
|
255
|
+
assert.equal(JSON.parse(show(d, "db/meta/0052_snapshot.json")).id, "gen");
|
|
256
|
+
assert.deepEqual(await journalProblems({ git: gitIn(d), dirs: ["db"] }), []);
|
|
257
|
+
d = repo({ "app/migrations/0001_initial.py": "i\n", "app/migrations/0002_x.py": "x\n" }, { "app/migrations/0003_y.py": "y\n" }, { "app/migrations/0003_z.py": "z\n" });
|
|
258
|
+
head = sh(d, "rev-parse", "HEAD");
|
|
259
|
+
r = await renumberLane({ git: gitIn(d), base: "main", cwd: d });
|
|
260
|
+
assert.match(r.unsupported, /app\/migrations\/0003_y\.py: no deterministic renumber for \.py migrations/);
|
|
261
|
+
assert.equal(sh(d, "rev-parse", "HEAD"), head);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("resolveSequenceConflicts takes the base side for journal/snapshot conflicts, then renumberLane from the pre-merge head gives a valid chain; another conflicted file is unsupported", async () => {
|
|
265
|
+
let d = drizzle();
|
|
266
|
+
const pre = sh(d, "rev-parse", "HEAD").trim();
|
|
267
|
+
assert.throws(() => sh(d, "merge", "--no-edit", "main"));
|
|
268
|
+
const files = sh(d, "diff", "--name-only", "--diff-filter=U").trim().split("\n");
|
|
269
|
+
assert.deepEqual(files.sort(), ["db/meta/0049_snapshot.json", "db/meta/_journal.json"]);
|
|
270
|
+
assert.deepEqual(await resolveSequenceConflicts({ git: gitIn(d), files }), { ok: true });
|
|
271
|
+
assert.ok(!existsSync(join(d, ".git", "MERGE_HEAD")), "merge committed");
|
|
272
|
+
const r = await renumberLane({ git: gitIn(d), base: "main", cwd: d, laneRef: pre, uuid: () => "u52" });
|
|
273
|
+
assert.deepEqual(r.moves, [{ from: "db/0049_nclt.sql", to: "db/0052_nclt.sql" }]);
|
|
274
|
+
assert.deepEqual(JSON.parse(show(d, "db/meta/_journal.json")).entries.map((e) => e.tag), ["0048_init", "0049_a", "0050_b", "0051_c", "0052_nclt"]);
|
|
275
|
+
assert.equal(JSON.parse(show(d, "db/meta/0052_snapshot.json")).prevId, "s51");
|
|
276
|
+
assert.deepEqual(await journalProblems({ git: gitIn(d), dirs: ["db"] }), []);
|
|
277
|
+
d = repo({ "db/0001_a.sql": "a\n", "db/0002_b.sql": "b\n", "db/meta/_journal.json": journal("0001_a", "0002_b"), "src/a.ts": "1\n" },
|
|
278
|
+
{ "db/0003_c.sql": "c\n", "db/meta/_journal.json": journal("0001_a", "0002_b", "0003_c"), "src/a.ts": "2\n" },
|
|
279
|
+
{ "db/0003_d.sql": "d\n", "db/meta/_journal.json": journal("0001_a", "0002_b", "0003_d"), "src/a.ts": "3\n" });
|
|
280
|
+
assert.throws(() => sh(d, "merge", "--no-edit", "main"));
|
|
281
|
+
const f2 = sh(d, "diff", "--name-only", "--diff-filter=U").trim().split("\n");
|
|
282
|
+
assert.deepEqual(await resolveSequenceConflicts({ git: gitIn(d), files: f2 }), { unsupported: "non-sequence conflict: src/a.ts" });
|
|
283
|
+
assert.ok(readFileSync(join(d, "src/a.ts"), "utf8").includes("<<<<<<<"), "left for the builder");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("#4465: a numbered file both sides edited, or one outside a series, is not a sequence conflict: unsupported, the lane's edit kept for the builder", async () => {
|
|
287
|
+
const conflict = async (base, lane, later, file) => {
|
|
288
|
+
const d = repo(base, lane, later);
|
|
289
|
+
assert.throws(() => sh(d, "merge", "--no-edit", "main"));
|
|
290
|
+
const files = sh(d, "diff", "--name-only", "--diff-filter=U").trim().split("\n");
|
|
291
|
+
assert.deepEqual(files, [file]);
|
|
292
|
+
assert.deepEqual(await resolveSequenceConflicts({ git: gitIn(d), files }), { unsupported: `non-sequence conflict: ${file}` });
|
|
293
|
+
assert.ok(existsSync(join(d, ".git", "MERGE_HEAD")), "merge not committed");
|
|
294
|
+
assert.match(readFileSync(join(d, file), "utf8"), /<<<<<<<[\s\S]*LANE[\s\S]*BASE/, "lane side still present");
|
|
295
|
+
};
|
|
296
|
+
// both edit an existing NNN- source file (no series)
|
|
297
|
+
await conflict({ "src/404-page.tsx": "a\nb\nc\n" }, { "src/404-page.tsx": "a\nLANE\nc\n" }, { "src/404-page.tsx": "a\nBASE\nc\n" }, "src/404-page.tsx");
|
|
298
|
+
// both edit an ADR inside a real series: renumberLane only restores lane-added files
|
|
299
|
+
const adrs = { "docs/adr/0001-one.md": "# 1. One\n", "docs/adr/0002-two.md": "# 2. Two\nx\n", "docs/adr/0003-three.md": "# 3. Three\n" };
|
|
300
|
+
await conflict(adrs, { "docs/adr/0002-two.md": "# 2. Two\nLANE\n" }, { "docs/adr/0002-two.md": "# 2. Two\nBASE\n" }, "docs/adr/0002-two.md");
|
|
301
|
+
// both add the same numbered name in a directory without a series
|
|
302
|
+
await conflict({ "src/a.ts": "1\n" }, { "src/500_error.html": "LANE\n" }, { "src/500_error.html": "BASE\n" }, "src/500_error.html");
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe("sequence-numbered documents: two siblings each add an ADR (#4385)", () => {
|
|
307
|
+
const INDEX = "# ADRs\n\n- [0001](0001-one.md)\n- [0002](0002-two.md)\n- [0003](0003-three.md)\n";
|
|
308
|
+
/** main: docs/adr/0001..0003 + README index; the lane adds 0004-b.md; main then merges a sibling's 0004-a.md. */
|
|
309
|
+
function adrFixture() {
|
|
310
|
+
const d = tmpDir("seq-adr-");
|
|
311
|
+
sh(d, "init", "-q", "-b", "main");
|
|
312
|
+
put(d, { "docs/adr/0001-one.md": "# 1. One\n", "docs/adr/0002-two.md": "# 2. Two\n", "docs/adr/0003-three.md": "# 3. Three\n", "docs/adr/README.md": INDEX });
|
|
313
|
+
commit(d, "init");
|
|
314
|
+
sh(d, "checkout", "-qb", "lane");
|
|
315
|
+
put(d, { "docs/adr/0004-b.md": "# 4. Use B\n\nStatus: accepted (ADR 0004)\n", "docs/adr/README.md": `${INDEX}- [0004](0004-b.md)\n` });
|
|
316
|
+
commit(d, "lane adr");
|
|
317
|
+
sh(d, "checkout", "-q", "main");
|
|
318
|
+
put(d, { "docs/adr/0004-a.md": "# 4. Use A\n", "docs/adr/README.md": `${INDEX}- [0004](0004-a.md)\n` });
|
|
319
|
+
commit(d, "sibling adr");
|
|
320
|
+
sh(d, "checkout", "-q", "lane");
|
|
321
|
+
return d;
|
|
322
|
+
}
|
|
323
|
+
/** The builder: merge the base (the index conflicts), rename to 0005, fix its own title, write the index. */
|
|
324
|
+
function renumberAdr(d, { title = "# 5. Use B\n\nStatus: accepted (ADR 0005)\n", index = `${INDEX}- [0004](0004-a.md)\n- [0005](0005-b.md)\n` } = {}) {
|
|
325
|
+
try { sh(d, "merge", "--no-edit", "main"); } catch { /* README.md conflicts: written below */ }
|
|
326
|
+
sh(d, "mv", "docs/adr/0004-b.md", "docs/adr/0005-b.md");
|
|
327
|
+
put(d, { "docs/adr/0005-b.md": title, "docs/adr/README.md": index });
|
|
328
|
+
commit(d, "renumber adr");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
it("the lane's ADR collides with the sibling's 0004 and moves to 0005", async () => {
|
|
332
|
+
const d = adrFixture();
|
|
333
|
+
const c = await sequenceCollisions({ git: gitIn(d), base: "main" });
|
|
334
|
+
assert.deepEqual(c.map((x) => [x.lane, x.base, x.next]), [["docs/adr/0004-b.md", ["docs/adr/0004-a.md"], "0005"]]);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("a renumber that changes only the ADR's own number and lists both in the index verifies: distinct numbers, index correct", async () => {
|
|
338
|
+
const d = adrFixture();
|
|
339
|
+
const before = await snapshotLane({ git: gitIn(d), base: "main" });
|
|
340
|
+
renumberAdr(d);
|
|
341
|
+
const v = await verifyRenumber({ git: gitIn(d), base: "main", before });
|
|
342
|
+
assert.deepEqual(v, { ok: true, problems: [] });
|
|
343
|
+
assert.deepEqual(await sequenceCollisions({ git: gitIn(d), base: "main" }), []);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("an index still linking the old name, or a body change beyond the number, fails verification", async () => {
|
|
347
|
+
const d = adrFixture();
|
|
348
|
+
const before = await snapshotLane({ git: gitIn(d), base: "main" });
|
|
349
|
+
renumberAdr(d, { index: `${INDEX}- [0004](0004-a.md)\n- [0004](0004-b.md)\n` });
|
|
350
|
+
const v = await verifyRenumber({ git: gitIn(d), base: "main", before });
|
|
351
|
+
assert.equal(v.ok, false);
|
|
352
|
+
assert.ok(v.problems.some((p) => /docs\/adr\/README\.md: still references 0004-b/.test(p)), v.problems.join("\n"));
|
|
353
|
+
const d2 = adrFixture();
|
|
354
|
+
const before2 = await snapshotLane({ git: gitIn(d2), base: "main" });
|
|
355
|
+
renumberAdr(d2, { title: "# 5. Use C instead\n\nStatus: accepted (ADR 0005)\n" });
|
|
356
|
+
const v2 = await verifyRenumber({ git: gitIn(d2), base: "main", before: before2 });
|
|
357
|
+
assert.equal(v2.ok, false);
|
|
358
|
+
assert.ok(v2.problems.some((p) => /0004-b\.md: content changed/.test(p)), v2.problems.join("\n"));
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("numberOnlyChange accepts padded and unpadded own-number edits only", () => {
|
|
362
|
+
assert.equal(numberOnlyChange("# 16. X (0016)\nsee 160 and 16x\n", "# 17. X (0017)\nsee 160 and 16x\n", "0016", "0017"), true);
|
|
363
|
+
assert.equal(numberOnlyChange("# 16. X\n", "# 17. Y\n", "0016", "0017"), false);
|
|
364
|
+
assert.equal(numberOnlyChange("CREATE TABLE t;\n", "CREATE TABLE t;\n", "0007", "0008"), true);
|
|
365
|
+
assert.equal(numberOnlyChange("a\n", "b\n", null, "0008"), false);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("the renumber prompt names numbered documents and the series index", () => {
|
|
369
|
+
const p = sequencePrompt([{ dir: "docs/adr", number: "0004", lane: "docs/adr/0004-b.md", base: ["docs/adr/0004-a.md"], next: "0005" }], "staging");
|
|
370
|
+
assert.match(p, /ADR/);
|
|
371
|
+
assert.match(p, /series index/);
|
|
372
|
+
});
|
|
112
373
|
});
|
package/bin/lean/toolchain.mjs
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* A repo without pins gets nothing: no shim dir, no env, no warning.
|
|
8
8
|
*/
|
|
9
9
|
import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
|
|
10
|
-
import { accessSync, constants } from "node:fs";
|
|
10
|
+
import { accessSync, constants, readFileSync, statSync } from "node:fs";
|
|
11
11
|
import { execFileSync } from "node:child_process";
|
|
12
|
-
import { join, delimiter, resolve } from "node:path";
|
|
12
|
+
import { join, delimiter, resolve, dirname, isAbsolute } from "node:path";
|
|
13
13
|
|
|
14
14
|
const PMS = ["pnpm", "yarn", "npm"];
|
|
15
15
|
const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
@@ -145,16 +145,50 @@ export function which(name, pathStr = process.env.PATH ?? "", exclude = []) {
|
|
|
145
145
|
const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
146
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
147
|
|
|
148
|
+
/** npx package spec and package name for a pin: yarn >= 2 is published as @yarnpkg/cli-dist; the npm "yarn" package is 1.x only. */
|
|
149
|
+
function npxPackage(pm) {
|
|
150
|
+
const name = pm.name === "yarn" && Number(pm.version.split(".")[0]) >= 2 ? "@yarnpkg/cli-dist" : pm.name;
|
|
151
|
+
return { name, spec: `${name}@${pm.version}` };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Bin names the npx-launched shims exec for a pin (pnpx runs `pnpm dlx`, so it reuses pnpm). */
|
|
155
|
+
const pinBins = (pm) => (pm.name === "npm" ? ["npm", "npx"] : [pm.name]);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Resolve the pinned manager's real binaries once per lane (#4348): `npx -y -p <pkg>@<ver> -c 'command -v <bin>'`, which
|
|
159
|
+
* reuses npx's cache or fetches once. A path is kept only when it is an absolute executable file and its package's
|
|
160
|
+
* `package.json` (`<.bin>/../<pkg>/package.json`) has exactly the pinned version. Returns {bin: absPath}; a failed bin is
|
|
161
|
+
* omitted (its shim falls back to npx). Never throws.
|
|
162
|
+
*/
|
|
163
|
+
export function resolvePinnedBins(pm, { npx = null, path = process.env.PATH ?? "", run = (f, a, o) => execFileSync(f, a, o) } = {}) {
|
|
164
|
+
const out = {};
|
|
165
|
+
if (!npx || !pm || !PMS.includes(pm.name) || !VERSION_RE.test(pm.version)) return out;
|
|
166
|
+
const pkg = npxPackage(pm);
|
|
167
|
+
for (const bin of pinBins(pm)) {
|
|
168
|
+
try {
|
|
169
|
+
const p = String(run(npx, ["-y", "-p", pkg.spec, "-c", `command -v ${bin}`], { env: { ...process.env, PATH: path }, timeout: 120000, stdio: ["ignore", "pipe", "ignore"] }) ?? "").trim().split("\n").pop().trim();
|
|
170
|
+
if (!p || !isAbsolute(p) || !statSync(p).isFile()) continue;
|
|
171
|
+
accessSync(p, constants.X_OK);
|
|
172
|
+
const meta = JSON.parse(readFileSync(join(dirname(p), "..", ...pkg.name.split("/"), "package.json"), "utf8"));
|
|
173
|
+
if (meta?.version === pm.version) out[bin] = p;
|
|
174
|
+
} catch { /* unresolved: the shim falls back to npx */ }
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
148
179
|
/**
|
|
149
180
|
* Shim scripts {name: content} for the pinned manager: `corepack <pm>@<ver>` when corepack is on the host PATH, else
|
|
150
181
|
* `npx -y`. Absolute paths are resolved here, so a shim never re-enters the shim dir. Returns {} when neither exists.
|
|
182
|
+
* `bins` ({bin: absPath} from resolvePinnedBins) makes an npx shim exec that binary directly while it exists, keeping the
|
|
183
|
+
* npx line as the fallback (#4348).
|
|
151
184
|
*/
|
|
152
|
-
export function shimScripts(pm, { corepack = null, npx = null } = {}) {
|
|
185
|
+
export function shimScripts(pm, { corepack = null, npx = null, bins = {} } = {}) {
|
|
153
186
|
if (!pm || !PMS.includes(pm.name) || !VERSION_RE.test(pm.version)) return {};
|
|
154
187
|
const at = `${pm.name}@${pm.version}`;
|
|
155
188
|
const out = {};
|
|
189
|
+
const direct = (bin, extra = "") => (bins?.[bin] ? `if [ -x ${q(bins[bin])} ]; then exec ${q(bins[bin])}${extra} "$@"; fi\n` : "");
|
|
156
190
|
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`; }
|
|
191
|
+
if (npx) { out.npm = `${HEADER}${direct("npm")}exec ${q(npx)} -y ${q(at)} "$@"\n`; out.npx = `${HEADER}${direct("npx")}exec ${q(npx)} -y -p ${q(at)} npx "$@"\n`; }
|
|
158
192
|
return out;
|
|
159
193
|
}
|
|
160
194
|
if (corepack) {
|
|
@@ -163,10 +197,9 @@ export function shimScripts(pm, { corepack = null, npx = null } = {}) {
|
|
|
163
197
|
return out;
|
|
164
198
|
}
|
|
165
199
|
if (!npx) return out;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (pm.name === "pnpm") out.pnpx = `${HEADER}exec ${q(npx)} -y -p ${q(pkg)} pnpm dlx "$@"\n`;
|
|
200
|
+
const pkg = npxPackage(pm).spec;
|
|
201
|
+
out[pm.name] = `${HEADER}${direct(pm.name)}exec ${q(npx)} -y -p ${q(pkg)} ${pm.name} "$@"\n`;
|
|
202
|
+
if (pm.name === "pnpm") out.pnpx = `${HEADER}${direct("pnpm", " dlx")}exec ${q(npx)} -y -p ${q(pkg)} pnpm dlx "$@"\n`;
|
|
170
203
|
return out;
|
|
171
204
|
}
|
|
172
205
|
|
|
@@ -186,16 +219,26 @@ async function writeShimFile(p, content) { await writeFile(p, content); await ch
|
|
|
186
219
|
|
|
187
220
|
/**
|
|
188
221
|
* Write executable (0755) shims for the pinned manager into `binDir`; launchers are resolved from `path` with `binDir`
|
|
189
|
-
* excluded.
|
|
222
|
+
* excluded. When npx is the launcher, `resolveBins` runs once to find the pinned binaries (#4348). Returns
|
|
223
|
+
* {names, unresolved}: the shim names written ([] when neither corepack nor npx is on `path`) and the bins left on npx.
|
|
190
224
|
*/
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
225
|
+
async function writeShimSet(binDir, pm, { path = process.env.PATH ?? "", find = which, writeShim = writeShimFile, mkdirp = (d) => mkdir(d, { recursive: true }), resolveBins = resolvePinnedBins } = {}) {
|
|
226
|
+
const corepack = find("corepack", path, [binDir]), npx = find("npx", path, [binDir]);
|
|
227
|
+
let bins = {}, unresolved = [];
|
|
228
|
+
// npx launches the shims for an npm pin, and for pnpm/yarn only without corepack (shimScripts).
|
|
229
|
+
if (npx && pm && PMS.includes(pm.name) && VERSION_RE.test(pm.version) && (pm.name === "npm" || !corepack)) {
|
|
230
|
+
try { bins = (await resolveBins(pm, { npx, path })) ?? {}; } catch { bins = {}; }
|
|
231
|
+
unresolved = pinBins(pm).filter((b) => !bins[b]);
|
|
232
|
+
}
|
|
233
|
+
const scripts = shimScripts(pm, { corepack, npx, bins });
|
|
234
|
+
if (!Object.keys(scripts).length) return { names: [], unresolved: [] };
|
|
194
235
|
await mkdirp(binDir);
|
|
195
236
|
for (const [name, content] of Object.entries(scripts)) await writeShim(join(binDir, name), content);
|
|
196
|
-
return Object.keys(scripts);
|
|
237
|
+
return { names: Object.keys(scripts), unresolved };
|
|
197
238
|
}
|
|
198
239
|
|
|
240
|
+
export async function writeShims(binDir, pm, opts = {}) { return (await writeShimSet(binDir, pm, opts)).names; }
|
|
241
|
+
|
|
199
242
|
/** 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
243
|
export function summary(pins) {
|
|
201
244
|
const parts = [...(pins?.pm ? [`${pins.pm.name}@${pins.pm.version} (${pins.pm.source})`] : []), ...(pins?.node ? [`node ${pins.node.spec} (${pins.node.source})`] : [])];
|
|
@@ -208,7 +251,7 @@ export function summary(pins) {
|
|
|
208
251
|
* env is null and shims empty without a usable pm pin; summary is null when nothing is pinned: the caller changes nothing.
|
|
209
252
|
*/
|
|
210
253
|
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) } = {}) {
|
|
254
|
+
writeShim = writeShimFile, mkdirp = (d) => mkdir(d, { recursive: true }), find = which, resolveBins = resolvePinnedBins, hostNode = () => hostNodeVersion(basePath) } = {}) {
|
|
212
255
|
const pins = await detectPins(dir, { override, ...(read ? { read } : {}) });
|
|
213
256
|
const out = { pins, env: null, shims: [], summary: null, warning: null, notes: [...pins.notes] };
|
|
214
257
|
if (pins.node) {
|
|
@@ -219,7 +262,9 @@ export async function prepareToolchain({ dir, binDir, override = null, read, bas
|
|
|
219
262
|
const pm = pins.pm;
|
|
220
263
|
let effective = pins;
|
|
221
264
|
if (pm && binDir) {
|
|
222
|
-
|
|
265
|
+
const set = await writeShimSet(binDir, pm, { path: basePath, find, writeShim, mkdirp, resolveBins });
|
|
266
|
+
out.shims = set.names;
|
|
267
|
+
if (set.unresolved.length) out.notes.push(`Toolchain: could not resolve ${set.unresolved.join(", ")} for ${pm.name}@${pm.version} once; its shim launches through npx on every call`);
|
|
223
268
|
if (out.shims.length) out.env = toolchainEnv(binDir, basePath);
|
|
224
269
|
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
270
|
}
|
|
@@ -2,11 +2,12 @@
|
|
|
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 { writeFileSync, mkdirSync, chmodSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { writeFileSync, mkdirSync, chmodSync, existsSync, readFileSync, statSync, rmSync } from "node:fs";
|
|
6
6
|
import { join, delimiter } from "node:path";
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
8
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
|
-
import { detectPins, parsePackageManager, nodeSatisfies, nodeWarning, shimScripts, prepareToolchain, toolchainEnv, writeShims, summary } from "./toolchain.mjs";
|
|
9
|
+
import { detectPins, parsePackageManager, nodeSatisfies, nodeWarning, shimScripts, prepareToolchain, toolchainEnv, writeShims, summary, resolvePinnedBins, which } from "./toolchain.mjs";
|
|
10
|
+
import { writeHeavyShims } from "./heavy.mjs";
|
|
10
11
|
|
|
11
12
|
const tmp = (p) => tmpDir(p);
|
|
12
13
|
const repo = (files) => {
|
|
@@ -28,6 +29,8 @@ const fakeHost = ({ corepack = true } = {}) => {
|
|
|
28
29
|
return d;
|
|
29
30
|
};
|
|
30
31
|
const sysPath = process.env.PATH ?? "";
|
|
32
|
+
// Hosts whose node ships corepack next to npx (e.g. CI's Node 24) would take the corepack branch: npx-only lanes see npx alone.
|
|
33
|
+
const npxOnly = (n, p, ex) => (n === "corepack" ? null : which(n, p, ex));
|
|
31
34
|
|
|
32
35
|
describe("toolchain pins (#4163)", () => {
|
|
33
36
|
it("parsePackageManager accepts pm@x.y.z (with a +sha suffix) and nothing else", () => {
|
|
@@ -96,7 +99,7 @@ describe("toolchain pins (#4163)", () => {
|
|
|
96
99
|
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
100
|
const d = repo({ "package.json": { name: "x", packageManager: "pnpm@10.4.1+sha512.ff" } });
|
|
98
101
|
const binDir = join(tmp("tc-run-"), "toolchain", "bin");
|
|
99
|
-
const tc = await prepareToolchain({ dir: d, binDir, basePath, hostNode: () => "v20.0.0" });
|
|
102
|
+
const tc = await prepareToolchain({ dir: d, binDir, basePath, find: corepack ? which : npxOnly, hostNode: () => "v20.0.0" });
|
|
100
103
|
assert.ok(tc.env && tc.env.PATH.startsWith(binDir + delimiter), "shim dir first on PATH");
|
|
101
104
|
assert.equal(tc.summary, "pnpm@10.4.1 (package.json packageManager)");
|
|
102
105
|
const env = { ...process.env, ...tc.env };
|
|
@@ -151,4 +154,118 @@ describe("toolchain pins (#4163)", () => {
|
|
|
151
154
|
assert.equal(summary({ pm: null, node: null }), null);
|
|
152
155
|
assert.equal(nodeWarning({ spec: "1", source: ".nvmrc" }) !== null, true, "defaults to process.version");
|
|
153
156
|
});
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A host with only npx (no corepack) whose fake npx emulates the npx cache: `-y -p <pkg>@<v> -c 'command -v <bin>'`
|
|
160
|
+
* installs `<cache>/<pkg>@<v>/node_modules/<pkg>` (package.json version `installed`, default the requested one) with
|
|
161
|
+
* executable `.bin/<bin>` files printing that version, and prints the bin path; `-y [-p <pkg>@<v>] <bin|pkg@v> args`
|
|
162
|
+
* runs the bin. Every call is logged to `<host>/npx.log`. `fail` makes resolution exit 1.
|
|
163
|
+
*/
|
|
164
|
+
const npxHost = ({ installed = null, fail = false } = {}) => {
|
|
165
|
+
const d = tmp("tc-npx-");
|
|
166
|
+
exe(d, "npx", [
|
|
167
|
+
`echo "$*" >> '${join(d, "npx.log")}'`,
|
|
168
|
+
'shift',
|
|
169
|
+
'if [ "$1" = "-p" ]; then spec="$2"; shift 2; else spec="$1"; shift; set -- "${spec%@*}" "$@"; fi',
|
|
170
|
+
'name="${spec%@*}"; ver="${spec##*@}"',
|
|
171
|
+
installed ? `iv='${installed}'` : 'iv="$ver"',
|
|
172
|
+
`root='${join(d, "cache")}'/"$spec"/node_modules`,
|
|
173
|
+
'mkdir -p "$root/$name" "$root/.bin"',
|
|
174
|
+
'printf \'{"name":"%s","version":"%s"}\' "$name" "$iv" > "$root/$name/package.json"',
|
|
175
|
+
'for b in pnpm yarn npm npx; do printf \'#!/bin/sh\\necho %s\\n\' "$iv" > "$root/.bin/$b"; chmod 755 "$root/.bin/$b"; done',
|
|
176
|
+
`if [ "$1" = "-c" ]; then ${fail ? "exit 1" : 'echo "$root/.bin/${2#command -v }"; exit 0'}; fi`,
|
|
177
|
+
'b="$1"; shift; exec "$root/.bin/$b" "$@"',
|
|
178
|
+
].join("\n"));
|
|
179
|
+
return d;
|
|
180
|
+
};
|
|
181
|
+
const npxCalls = (host) => (existsSync(join(host, "npx.log")) ? readFileSync(join(host, "npx.log"), "utf8").trim().split("\n").filter(Boolean) : []);
|
|
182
|
+
const pinRepo = (pm) => repo({ "package.json": { name: "x", packageManager: pm } });
|
|
183
|
+
const shimRun = (tc, cmd) => execFileSync("sh", ["-c", cmd], { env: { ...process.env, ...tc.env } }).toString().trim();
|
|
184
|
+
|
|
185
|
+
it("#4348: a pnpm pin is resolved once at lane setup; the shim execs that binary with no npx call", async () => {
|
|
186
|
+
const host = npxHost();
|
|
187
|
+
const binDir = join(tmp("tc-run-"), "toolchain", "bin");
|
|
188
|
+
const tc = await prepareToolchain({ dir: pinRepo("pnpm@10.4.1"), binDir, basePath: `${host}${delimiter}${sysPath}`, find: npxOnly, hostNode: () => "v20.0.0" });
|
|
189
|
+
assert.deepEqual(tc.shims, ["pnpm", "pnpx"]);
|
|
190
|
+
assert.deepEqual(tc.notes, []);
|
|
191
|
+
assert.equal(npxCalls(host).length, 1, "one resolution call");
|
|
192
|
+
assert.match(npxCalls(host)[0], /^-y -p pnpm@10\.4\.1 -c command -v pnpm$/);
|
|
193
|
+
const shim = readFileSync(join(binDir, "pnpm"), "utf8");
|
|
194
|
+
assert.match(shim, /if \[ -x '[^']*pnpm@10\.4\.1\/node_modules\/\.bin\/pnpm' \]; then exec '[^']*\/\.bin\/pnpm' "\$@"; fi\nexec '[^']*npx' -y -p 'pnpm@10\.4\.1' pnpm "\$@"/);
|
|
195
|
+
assert.match(readFileSync(join(binDir, "pnpx"), "utf8"), /then exec '[^']*\/\.bin\/pnpm' dlx "\$@"; fi/);
|
|
196
|
+
for (let i = 0; i < 3; i++) assert.equal(shimRun(tc, "pnpm --version"), "10.4.1");
|
|
197
|
+
assert.equal(npxCalls(host).length, 1, "shim calls never go through npx");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("#4348: resolution failure keeps today's npx shim and the pinned version", async () => {
|
|
201
|
+
const host = npxHost({ fail: true });
|
|
202
|
+
const binDir = join(tmp("tc-run-"), "toolchain", "bin");
|
|
203
|
+
const tc = await prepareToolchain({ dir: pinRepo("pnpm@10.4.1"), binDir, basePath: `${host}${delimiter}${sysPath}`, find: npxOnly, hostNode: () => "v20.0.0" });
|
|
204
|
+
const npx = join(host, "npx");
|
|
205
|
+
assert.deepEqual(readFileSync(join(binDir, "pnpm"), "utf8"), shimScripts({ name: "pnpm", version: "10.4.1" }, { npx }).pnpm, "byte-identical to the unresolved form");
|
|
206
|
+
assert.match(tc.notes.join("\n"), /could not resolve pnpm for pnpm@10\.4\.1 once; its shim launches through npx/);
|
|
207
|
+
assert.equal(shimRun(tc, "pnpm --version"), "10.4.1");
|
|
208
|
+
// a launcher that does not exist fails fast and leaves the same fallback
|
|
209
|
+
assert.deepEqual(resolvePinnedBins({ name: "pnpm", version: "10.4.1" }, { npx: join(host, "missing-npx") }), {});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("#4348: a cached binary of another version is never used", async () => {
|
|
213
|
+
const host = npxHost({ installed: "10.0.0" });
|
|
214
|
+
assert.deepEqual(resolvePinnedBins({ name: "pnpm", version: "10.4.1" }, { npx: join(host, "npx"), path: `${host}${delimiter}${sysPath}` }), {});
|
|
215
|
+
const run = () => "/abs/.bin/pnpm\n";
|
|
216
|
+
assert.deepEqual(resolvePinnedBins({ name: "pnpm", version: "10.4.1" }, { npx: "/h/npx", run }), {}, "a path without a matching package.json is rejected");
|
|
217
|
+
assert.deepEqual(resolvePinnedBins({ name: "pnpm", version: "10.4.1" }, { npx: "/h/npx", run: () => "relative/pnpm" }), {});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("#4348: a resolved binary removed mid-lane falls back to npx", async () => {
|
|
221
|
+
const host = npxHost();
|
|
222
|
+
const binDir = join(tmp("tc-run-"), "toolchain", "bin");
|
|
223
|
+
const tc = await prepareToolchain({ dir: pinRepo("pnpm@10.4.1"), binDir, basePath: `${host}${delimiter}${sysPath}`, find: npxOnly, hostNode: () => "v20.0.0" });
|
|
224
|
+
const real = /then exec '([^']+)'/.exec(readFileSync(join(binDir, "pnpm"), "utf8"))[1];
|
|
225
|
+
rmSync(real);
|
|
226
|
+
assert.equal(shimRun(tc, "pnpm --version"), "10.4.1");
|
|
227
|
+
assert.equal(npxCalls(host).length, 2, "the fallback went through npx");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
for (const [pin, bins, pkg] of [["yarn@4.1.0", ["yarn"], "@yarnpkg/cli-dist@4.1.0"], ["yarn@1.22.19", ["yarn"], "yarn@1.22.19"], ["npm@10.2.0", ["npm", "npx"], "npm@10.2.0"]]) {
|
|
231
|
+
it(`#4348: ${pin} resolves ${bins.join(" and ")} from ${pkg}`, async () => {
|
|
232
|
+
const host = npxHost();
|
|
233
|
+
const binDir = join(tmp("tc-run-"), "toolchain", "bin");
|
|
234
|
+
const tc = await prepareToolchain({ dir: pinRepo(pin), binDir, basePath: `${host}${delimiter}${sysPath}`, find: npxOnly, hostNode: () => "v20.0.0" });
|
|
235
|
+
assert.deepEqual(tc.shims, bins);
|
|
236
|
+
assert.deepEqual(tc.notes, []);
|
|
237
|
+
assert.deepEqual(npxCalls(host), bins.map((b) => `-y -p ${pkg} -c command -v ${b}`));
|
|
238
|
+
const ver = pin.split("@")[1];
|
|
239
|
+
for (const b of bins) {
|
|
240
|
+
assert.match(readFileSync(join(binDir, b), "utf8"), new RegExp(`then exec '[^']*/node_modules/\\.bin/${b}' "\\$@"; fi\\n`));
|
|
241
|
+
assert.equal(shimRun(tc, `${b} --version`), ver);
|
|
242
|
+
}
|
|
243
|
+
assert.equal(npxCalls(host).length, bins.length, "no npx call per shim invocation");
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
it("#4348: an npm pin is resolved even when corepack is on the host (its shims always launch through npx)", async () => {
|
|
248
|
+
const host = npxHost();
|
|
249
|
+
const binDir = join(tmp("tc-run-"), "toolchain", "bin");
|
|
250
|
+
const find = (n, p, ex) => (n === "corepack" ? "/h/corepack" : which(n, p, ex));
|
|
251
|
+
const tc = await prepareToolchain({ dir: pinRepo("npm@10.2.0"), binDir, basePath: `${host}${delimiter}${sysPath}`, find, hostNode: () => "v20.0.0" });
|
|
252
|
+
assert.deepEqual(npxCalls(host), ["-y -p npm@10.2.0 -c command -v npm", "-y -p npm@10.2.0 -c command -v npx"]);
|
|
253
|
+
assert.match(readFileSync(join(binDir, "npm"), "utf8"), /then exec '[^']*\/node_modules\/\.bin\/npm' "\$@"; fi\n/);
|
|
254
|
+
assert.equal(shimRun(tc, "npm --version"), "10.2.0");
|
|
255
|
+
assert.equal(npxCalls(host).length, 2, "no npx call per shim invocation");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("#4348: corepack hosts are not resolved; the heavy shim still wraps the toolchain shim", async () => {
|
|
259
|
+
let calls = 0;
|
|
260
|
+
const resolveBins = () => { calls++; return {}; };
|
|
261
|
+
const cp = await prepareToolchain({ dir: pinRepo("pnpm@10.4.1"), binDir: join(tmp("tc-run-"), "bin"), basePath: fakeHost(), resolveBins, hostNode: () => "v20.0.0" });
|
|
262
|
+
assert.equal(calls, 0);
|
|
263
|
+
assert.deepEqual(cp.notes, []);
|
|
264
|
+
const host = npxHost();
|
|
265
|
+
const root = tmp("tc-run-"), binDir = join(root, "toolchain", "bin"), heavyBin = join(root, "heavy", "bin");
|
|
266
|
+
const tc = await prepareToolchain({ dir: pinRepo("pnpm@10.4.1"), binDir, basePath: `${host}${delimiter}${sysPath}`, find: npxOnly, hostNode: () => "v20.0.0" });
|
|
267
|
+
const names = await writeHeavyShims(heavyBin, { path: tc.env.PATH });
|
|
268
|
+
assert.ok(names.includes("pnpm"));
|
|
269
|
+
assert.match(readFileSync(join(heavyBin, "pnpm"), "utf8"), new RegExp(`run --name 'pnpm' -- '${join(binDir, "pnpm")}' "\\$@"`));
|
|
270
|
+
});
|
|
154
271
|
});
|