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
|
@@ -2,7 +2,8 @@
|
|
|
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 {
|
|
5
|
+
import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
|
|
6
|
+
import { gatherContextFacts, compileContextPack, contextPackFor, contextArm, contextPackCacheKey, linkStateStamp, LINK_STAMP_MAX, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments } from "./context.mjs";
|
|
6
7
|
|
|
7
8
|
const REPO = "o/r";
|
|
8
9
|
const BOT = { author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
|
|
@@ -33,7 +34,7 @@ function fakes(o = {}) {
|
|
|
33
34
|
101: [{ id: 2, body: "<!-- FORGE:DECISION -->\nKeep `compileContextPack` pure; I/O lives in gather.", ...BOT }],
|
|
34
35
|
102: [],
|
|
35
36
|
103: [{ id: 3, body: "<!-- FORGE:DECISION -->\nUnrelated decision about logging.", ...BOT }],
|
|
36
|
-
33: [{ id: 4, body: "<!-- FORGE:CONTRACT -->\nx", ...BOT }],
|
|
37
|
+
33: [{ id: 4, body: "<!-- FORGE:CONTRACT -->\nx", ...BOT }, ...(o.facts33 ?? [])],
|
|
37
38
|
};
|
|
38
39
|
const prs = { 101: { title: "feat: ranking", body: "Closes #33", head: "feat/issue-33-lean" }, 102: { title: "feat: speedup", body: "", head: "feat/issue-34-lean" }, 103: { title: "chore: logs", body: "", head: "x" } };
|
|
39
40
|
const log = [
|
|
@@ -109,6 +110,21 @@ describe("context pack (forge#4275)", () => {
|
|
|
109
110
|
assert.notEqual(contextPackCacheKey(50, "abc", cfg), contextPackCacheKey(51, "abc", cfg));
|
|
110
111
|
});
|
|
111
112
|
|
|
113
|
+
it("#4432: the cache key and the prune prefix are repo-scoped, so another repo's pack for the same number survives", async () => {
|
|
114
|
+
const store = new Map();
|
|
115
|
+
const fs = { read: async (p) => { if (!store.has(p)) throw new Error("ENOENT"); return store.get(p); }, write: async (p, t) => { store.set(p, t); },
|
|
116
|
+
list: async () => [...store.keys()].map((p) => p.slice("/runs/".length)), rm: async (p) => { store.delete(p); } };
|
|
117
|
+
const cfg = parseContextPackConfig({});
|
|
118
|
+
const g = async () => gather(fakes());
|
|
119
|
+
assert.match(contextPackCacheKey(50, "abc", cfg, {}, "acme/one"), /^context-acme__one__50-abc-/);
|
|
120
|
+
assert.equal(contextPackCacheKey(50, "abc", cfg, {}), contextPackCacheKey(50, "abc", cfg), "no repo: today's key");
|
|
121
|
+
await contextPackFor({ cfg, issue: 50, repo: "acme/one", headSha: "abc", cacheDir: "/runs", fs, gather: g });
|
|
122
|
+
await contextPackFor({ cfg, issue: 50, repo: "acme/two", headSha: "def", cacheDir: "/runs", fs, gather: g });
|
|
123
|
+
assert.deepEqual([...store.keys()].map((k) => k.split("-").slice(0, 2).join("-")).sort(), ["/runs/context-acme__one__50", "/runs/context-acme__two__50"]);
|
|
124
|
+
const again = await contextPackFor({ cfg, issue: 50, repo: "acme/one", headSha: "abc", cacheDir: "/runs", fs, gather: g });
|
|
125
|
+
assert.equal(again.cached, true);
|
|
126
|
+
});
|
|
127
|
+
|
|
112
128
|
it("drops whole items past the byte cap, falls back to one line for a parent body, counts truncation", async () => {
|
|
113
129
|
const facts = await gather(fakes());
|
|
114
130
|
const p = compileContextPack(facts, { budgetBytes: 130 });
|
|
@@ -121,7 +137,10 @@ describe("context pack (forge#4275)", () => {
|
|
|
121
137
|
const f = fakes();
|
|
122
138
|
const facts = await gather(f, { sources: ["graph", "facts"] });
|
|
123
139
|
assert.equal(facts.ledger.length + facts.attempts.length, 0);
|
|
124
|
-
assert.ok(!f.calls.some((c) => c[1] === "
|
|
140
|
+
assert.ok(!f.calls.some((c) => c[1] === "for-each-ref"), "a disabled source makes no calls");
|
|
141
|
+
const g = fakes();
|
|
142
|
+
await gather(g, { sources: ["graph"] });
|
|
143
|
+
assert.ok(!g.calls.some((c) => c[1] === "log" || c[1] === "for-each-ref"), "no ledger walk without the ledger or facts source (#4401)");
|
|
125
144
|
const p = compileContextPack(facts, { sources: ["graph"] });
|
|
126
145
|
assert.ok(p.text.includes("#40") && !p.text.includes("test-map"));
|
|
127
146
|
assert.deepEqual(parseContextPackConfig({ sources: "ledger, bogus,graph" }).sources, ["graph", "ledger"]);
|
|
@@ -166,7 +185,9 @@ describe("context pack (forge#4275)", () => {
|
|
|
166
185
|
});
|
|
167
186
|
|
|
168
187
|
it("config, lane type and parsers", () => {
|
|
169
|
-
assert.deepEqual(parseContextPackConfig({}), { mode: "shadow", sources: ["graph", "attempts", "ledger", "facts"], budgetBytes: 6144, days: 30 });
|
|
188
|
+
assert.deepEqual(parseContextPackConfig({}), { mode: "shadow", sources: ["graph", "attempts", "pitfalls", "ledger", "facts"], budgetBytes: 6144, days: 30, pitfallLanes: 50 });
|
|
189
|
+
assert.equal(parseContextPackConfig({ pitfallLanes: 12 }).pitfallLanes, 12);
|
|
190
|
+
assert.equal(parseContextPackConfig({ pitfallLanes: -3 }).pitfallLanes, 50);
|
|
170
191
|
assert.equal(parseContextPackConfig({ mode: "ON" }).mode, "on");
|
|
171
192
|
assert.equal(parseContextPackConfig({ mode: "nope" }).mode, "shadow");
|
|
172
193
|
assert.equal(laneType({ body: "x\n**Parent**: #3" }), "child");
|
|
@@ -183,7 +204,7 @@ describe("context pack (forge#4275)", () => {
|
|
|
183
204
|
const p = compileContextPack(await gather(fakes()));
|
|
184
205
|
const r = renderContextRecord(p, { mode: "shadow", laneType: "rerun" });
|
|
185
206
|
assert.match(r, /^<details><summary>Context pack: \d+ item\(s\), \d+ bytes, 0 dropped \(mode shadow, lane rerun, hash [0-9a-f]{12}\)<\/summary>/);
|
|
186
|
-
assert.match(r, /Sources \(included\/found\): graph 5\/5, attempts 3\/3, ledger 3\/3, facts 1\/1/);
|
|
207
|
+
assert.match(r, /Sources \(included\/found\): graph 5\/5, attempts 3\/3, pitfalls 0\/0, ledger 3\/3, facts 1\/1/);
|
|
187
208
|
assert.ok(r.includes(p.text));
|
|
188
209
|
});
|
|
189
210
|
});
|
|
@@ -334,5 +355,136 @@ describe("#4298 context-pack cache and shared names", () => {
|
|
|
334
355
|
writeFileSync(`${d}/forge.yaml`, "engine:\n context_pack: on\n");
|
|
335
356
|
assert.deepEqual(readContextPack(d, (m) => warns.push(m)), { mode: "on" });
|
|
336
357
|
assert.equal(warns.length, 1, "a valid mode does not warn");
|
|
358
|
+
writeFileSync(`${d}/forge.yaml`, "engine:\n context_pack_pitfall_lanes: 20\n");
|
|
359
|
+
assert.deepEqual(readContextPack(d, (m) => warns.push(m)), { pitfallLanes: 20 }, "#4375: the pitfall aging window");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("#4444: ab is a valid mode, and contextArm gives each issue a stable on|shadow arm", async () => {
|
|
363
|
+
const { readContextPack } = await import("../engine-cli.mjs");
|
|
364
|
+
const { tmpDir } = await import("../tests/_tmp.mjs");
|
|
365
|
+
const { writeFileSync } = await import("node:fs");
|
|
366
|
+
const d = tmpDir("ctxab-"), warns = [];
|
|
367
|
+
writeFileSync(`${d}/forge.yaml`, "engine:\n context_pack: ab\n");
|
|
368
|
+
assert.deepEqual(readContextPack(d, (m) => warns.push(m)), { mode: "ab" });
|
|
369
|
+
assert.equal(warns.length, 0);
|
|
370
|
+
assert.equal(parseContextPackConfig({ mode: "AB" }).mode, "ab");
|
|
371
|
+
assert.equal(parseContextPackConfig({}).mode, "shadow", "the default stays shadow");
|
|
372
|
+
for (const mode of ["shadow", "on", "off"]) assert.deepEqual(contextArm({ mode, repo: "o/r", issue: 1 }), { mode, ab: false });
|
|
373
|
+
const arms = Array.from({ length: 40 }, (_, i) => contextArm({ mode: "ab", repo: "o/r", issue: i + 1 }));
|
|
374
|
+
assert.ok(arms.every((a) => a.ab && ["on", "shadow"].includes(a.mode)));
|
|
375
|
+
assert.ok(arms.some((a) => a.mode === "on") && arms.some((a) => a.mode === "shadow"), "both arms are populated");
|
|
376
|
+
assert.deepEqual(contextArm({ mode: "ab", repo: "O/R", issue: 7 }), contextArm({ mode: "ab", repo: "o/r", issue: 7 }), "stable per issue");
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe("#4375 recurring pitfalls in the context pack", () => {
|
|
381
|
+
const PITS = [
|
|
382
|
+
{ sig: "precheck:aaaaaaaaaaaa", trigger: "precheck", lesson: "precheck: src/a.ts:12:5 error 'x' is defined but never used no-unused-vars", globs: ["src/*.ts"], count: 4, match: "files" },
|
|
383
|
+
{ sig: "docs:bbbbbbbbbbbb", trigger: "docs", lesson: "docs: devdocs/modules/lean.md is stale for bin/lean/lean.mjs", globs: ["devdocs/modules/*.md"], count: 2, match: "repo" },
|
|
384
|
+
];
|
|
385
|
+
const facts = (pitfalls) => gatherContextFacts({ io: { gh: async () => { throw new Error("no gh"); }, git: async () => "", exists: async () => false }, repo: REPO, issue: 50, iss: { number: 50, body: "plain" }, comments: [], cwd: "/wt", base: "origin/staging", sources: ["pitfalls"], pitfalls });
|
|
386
|
+
|
|
387
|
+
it("compiles the given pitfalls in order, inside the byte budget, and lists the kept signatures", async () => {
|
|
388
|
+
const p = compileContextPack(await facts(PITS));
|
|
389
|
+
assert.ok(p.text.includes("Recurring pitfalls in this repo (past rework):"), p.text);
|
|
390
|
+
const a = p.text.indexOf("[precheck]"), b = p.text.indexOf("[docs]");
|
|
391
|
+
assert.ok(a > 0 && b > a, p.text);
|
|
392
|
+
assert.ok(p.text.includes("(src/*.ts); seen 4×"));
|
|
393
|
+
assert.deepEqual(p.pitfalls, [{ sig: PITS[0].sig, match: "files" }, { sig: PITS[1].sig, match: "repo" }]);
|
|
394
|
+
assert.equal(p.sources.pitfalls, 2);
|
|
395
|
+
const small = compileContextPack(await facts(PITS), { budgetBytes: 200 });
|
|
396
|
+
assert.ok(small.bytes <= 200 && small.truncated >= 1, `${small.bytes} ${small.truncated}`);
|
|
397
|
+
assert.deepEqual(small.pitfalls, small.text.includes("[docs]") ? p.pitfalls : [p.pitfalls[0]]);
|
|
398
|
+
assert.equal(compileContextPack(await facts(PITS), { sources: ["graph"] }).pitfalls.length, 0, "source off: no pitfalls");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("the posted FORGE:CONTEXT record never carries pitfall text, only the count", async () => {
|
|
402
|
+
const f = await gather(fakes());
|
|
403
|
+
const p = compileContextPack({ ...f, pitfalls: (await facts(PITS)).pitfalls });
|
|
404
|
+
assert.ok(p.text.includes("no-unused-vars"));
|
|
405
|
+
const r = renderContextRecord(p, { mode: "on", laneType: "first" });
|
|
406
|
+
for (const x of ["no-unused-vars", "Recurring pitfalls", "src/*.ts", "devdocs/modules/lean.md is stale"]) assert.ok(!r.includes(x), x);
|
|
407
|
+
assert.match(r, /pitfalls 2\/2/);
|
|
408
|
+
assert.ok(r.includes("Prior attempts at this issue:") && r.includes("Merged PRs on the same symbols"), "the other sections stay");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("the cache key changes when the selected pitfall signatures change", () => {
|
|
412
|
+
const cfg = parseContextPackConfig({});
|
|
413
|
+
const k0 = contextPackCacheKey(50, "abc", cfg, { scope: "s", attempts: 0 });
|
|
414
|
+
assert.equal(contextPackCacheKey(50, "abc", cfg, { scope: "s", attempts: 0, pitfalls: [] }), k0, "no pitfalls: today's key");
|
|
415
|
+
const k1 = contextPackCacheKey(50, "abc", cfg, { scope: "s", attempts: 0, pitfalls: ["precheck:aaaaaaaaaaaa"] });
|
|
416
|
+
assert.notEqual(k1, k0);
|
|
417
|
+
assert.notEqual(contextPackCacheKey(50, "abc", cfg, { scope: "s", attempts: 0, pitfalls: ["precheck:aaaaaaaaaaaa", "docs:bbbbbbbbbbbb"] }), k1);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("without pitfalls the pack text and hash are unchanged", async () => {
|
|
421
|
+
const f = await gather(fakes());
|
|
422
|
+
const a = compileContextPack(f), b = compileContextPack({ ...f, pitfalls: [] });
|
|
423
|
+
assert.equal(a.text, b.text); assert.equal(a.hash, b.hash);
|
|
424
|
+
assert.ok(!a.text.includes("Recurring pitfalls"));
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
describe("FORGE:FACTS in the context pack (#4401)", () => {
|
|
429
|
+
const rec = (files, approach, why = "it only hid the symptom") => `<!-- ${FACTS_MARKER} -->\n${renderFactsRecord(normaliseFacts({ facts: { rejected: [{ approach, why }], hotspots: [{ at: `${files[0]}:7`, why: "ranking loop" }] } }, { files }))}`;
|
|
430
|
+
const STRANGER = { author_association: "NONE", user: { type: "User", login: "rando" } };
|
|
431
|
+
|
|
432
|
+
it("a later lane on overlapping files receives the prior rejected approaches; the facts source is non-zero", async () => {
|
|
433
|
+
const f = fakes({ facts33: [
|
|
434
|
+
{ id: 5, body: rec(["bin/lean/context.mjs"], "detection-only check"), ...BOT },
|
|
435
|
+
{ id: 6, body: rec(["docs/other.md"], "unrelated file approach"), ...BOT },
|
|
436
|
+
{ id: 7, body: rec(["bin/lean/context.mjs"], "forged by a stranger"), ...STRANGER },
|
|
437
|
+
] });
|
|
438
|
+
const own = { id: "IC_9", body: rec(["bin/lean/context.mjs"], "own earlier attempt approach"), author: { login: "forge[bot]" }, authorAssociation: "NONE" };
|
|
439
|
+
const facts = await gather(f, { comments: [...COMMENTS, own] });
|
|
440
|
+
const p = compileContextPack(facts);
|
|
441
|
+
const t = p.text;
|
|
442
|
+
const at = (s) => { const i = t.indexOf(s); assert.ok(i >= 0, `missing ${s}\n${t}`); return i; };
|
|
443
|
+
const ownAt = at("- rejected: own earlier attempt approach — it only hid the symptom (#50 earlier attempt)");
|
|
444
|
+
const prior = at("- rejected: detection-only check — it only hid the symptom (#33, PR #101)");
|
|
445
|
+
const hot = at("- hot spot bin/lean/context.mjs:7: ranking loop (#33, PR #101)");
|
|
446
|
+
const ptr = at("- .forge/test-map.json");
|
|
447
|
+
assert.ok(ownAt < prior && prior < hot && hot < ptr, t);
|
|
448
|
+
assert.ok(t.includes("Facts from earlier lanes and repo facts:"));
|
|
449
|
+
assert.ok(!t.includes("unrelated file approach"), "a record on non-overlapping files is left out");
|
|
450
|
+
assert.ok(!t.includes("forged by a stranger"), "untrusted records are never read");
|
|
451
|
+
assert.equal(p.sources.facts, 5);
|
|
452
|
+
assert.equal(f.calls.filter((c) => c[0] === "gh" && c.some((x) => String(x).includes("pulls/101"))).length, 1, "ledger and facts share one read per PR");
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("works with the ledger source off and contributes nothing when no record exists", async () => {
|
|
456
|
+
const f = fakes({ facts33: [{ id: 5, body: rec(["bin/lean/context.mjs"], "detection-only check"), ...BOT }] });
|
|
457
|
+
const facts = await gather(f, { sources: ["facts"] });
|
|
458
|
+
assert.equal(facts.ledger.length, 0);
|
|
459
|
+
assert.ok(compileContextPack(facts, { sources: ["facts"] }).text.includes("detection-only check"));
|
|
460
|
+
const none = await gather(fakes(), { sources: ["facts"] });
|
|
461
|
+
assert.deepEqual([none.lanes, none.facts], [[], [".forge/test-map.json"]]);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
describe("populated pack sources (#4400)", () => {
|
|
466
|
+
it("repo-fact files that exist only in the main checkout are listed; the worktree alone (the old lookup) lists none", async () => {
|
|
467
|
+
const io = (dir) => ({ ...fakes().io, exists: async (p) => p.startsWith(`${dir}/`) && (p.endsWith(".forge/repo-facts.json") || p.endsWith(".forge/test-map.json")) });
|
|
468
|
+
const inCheckout = await gather({ io: io("/main") }, { sources: ["facts"], checkout: "/main" });
|
|
469
|
+
assert.deepEqual(inCheckout.facts, [".forge/repo-facts.json", ".forge/test-map.json"]);
|
|
470
|
+
const worktreeOnly = await gather({ io: io("/main") }, { sources: ["facts"] });
|
|
471
|
+
assert.deepEqual(worktreeOnly.facts, []);
|
|
472
|
+
const both = await gather({ io: { ...fakes().io, exists: async (p) => p.endsWith(".forge/repo-facts.json") } }, { sources: ["facts"], checkout: "/main" });
|
|
473
|
+
assert.deepEqual(both.facts, [".forge/repo-facts.json"], "a file in both dirs is listed once");
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("an issue with a prior run, a ledger PR on its file and a FORGE:FACTS record has non-zero attempts, ledger and facts", async () => {
|
|
477
|
+
const rec = `<!-- ${FACTS_MARKER} -->\n${renderFactsRecord(normaliseFacts({ facts: { rejected: [{ approach: "retry only", why: "hid the race" }] } }, { files: ["bin/lean/context.mjs"] }))}`;
|
|
478
|
+
const f = fakes({ facts33: [{ id: 8, body: rec, ...BOT }] });
|
|
479
|
+
const p = compileContextPack(await gather(f, { checkout: "/main" }));
|
|
480
|
+
for (const s of ["attempts", "ledger", "facts"]) assert.ok(p.sources[s] > 0, `${s} is zero: ${JSON.stringify(p.sources)}`);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
describe("#4467: priorAttempts reads App comments in gh issue view shape", () => {
|
|
485
|
+
const ghShape = (login) => ({ author: { login }, authorAssociation: "CONTRIBUTOR", body: "<!-- FORGE:NEEDS_HUMAN -->\nstuck" });
|
|
486
|
+
it("counts the App's NEEDS_HUMAN, not a stranger's", () => {
|
|
487
|
+
assert.equal(priorAttempts("", [ghShape("rapiercraft-forgedock")]), 1);
|
|
488
|
+
assert.equal(priorAttempts("", [ghShape("someone")]), 0);
|
|
337
489
|
});
|
|
338
490
|
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Decomposition gate (#4396): a builder `split-required` block is admitted only when its sub-issues touch disjoint
|
|
5
|
+
* areas (each declares `paths`) or the block's `size` estimate exceeds the large-lane budget; otherwise the issue stays
|
|
6
|
+
* one lane with an ordered checklist. Also the batch report's decomposed vs single-lane $/parent line. Pure: no I/O.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The large-lane size budget: a split of overlapping parts is admitted only above it. */
|
|
10
|
+
export const SPLIT_MIN_FILES = 25;
|
|
11
|
+
export const SPLIT_MIN_LINES = 1500;
|
|
12
|
+
|
|
13
|
+
const norm = (p) => String(p).trim().replace(/^(\.?\/+)+/, "").replace(/\/+$/, "");
|
|
14
|
+
const overlaps = (a, b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
15
|
+
const count = (v) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null);
|
|
16
|
+
|
|
17
|
+
/** The block's `size` as {files, lines} (null fields when not a finite non-negative number), or null when absent. */
|
|
18
|
+
function sizeOf(block) {
|
|
19
|
+
const s = block?.size;
|
|
20
|
+
if (!s || typeof s !== "object" || Array.isArray(s)) return null;
|
|
21
|
+
const files = count(s.files), lines = count(s.lines);
|
|
22
|
+
return files == null && lines == null ? null : { files, lines };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} block result.json `block` of kind split-required (sub-issues already checked for title and body)
|
|
27
|
+
* @returns {{ok:true, rationale:{independent:boolean, oversize:boolean, overlaps:Array<[number,number,string,string]>, missing:number[], size:?{files:?number, lines:?number}}} | {ok:false, problem:string}}
|
|
28
|
+
*/
|
|
29
|
+
export function splitGate(block) {
|
|
30
|
+
const subs = Array.isArray(block?.subissues) ? block.subissues : [];
|
|
31
|
+
// `paths` or the #4385 `files` (the files each sub-issue edits): either declares the area a part touches.
|
|
32
|
+
const declared = (s) => [...(Array.isArray(s?.paths) ? s.paths : []), ...(Array.isArray(s?.files) ? s.files : [])];
|
|
33
|
+
const paths = subs.map((s) => [...new Set(declared(s).filter((p) => typeof p === "string" && norm(p)).map(norm))]);
|
|
34
|
+
const missing = paths.flatMap((p, i) => (p.length ? [] : [i]));
|
|
35
|
+
const found = [];
|
|
36
|
+
for (let i = 0; i < paths.length; i++) {
|
|
37
|
+
for (let j = i + 1; j < paths.length; j++) {
|
|
38
|
+
for (const a of paths[i]) for (const b of paths[j]) if (overlaps(a, b)) found.push([i, j, a, b]);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const independent = subs.length > 0 && !missing.length && !found.length;
|
|
42
|
+
const size = sizeOf(block);
|
|
43
|
+
const oversize = !!size && ((size.files ?? 0) > SPLIT_MIN_FILES || (size.lines ?? 0) > SPLIT_MIN_LINES);
|
|
44
|
+
if (independent || oversize) return { ok: true, rationale: { independent, oversize, overlaps: found, missing, size } };
|
|
45
|
+
const why = [];
|
|
46
|
+
if (missing.length) why.push(`sub-issue(s) ${missing.join(", ")} declare no "paths" or "files"`);
|
|
47
|
+
if (found.length) why.push(`the parts overlap (${found.slice(0, 5).map(([i, j, a, b]) => `${i}:${a} ~ ${j}:${b}`).join("; ")})`);
|
|
48
|
+
why.push(size ? `the size estimate (${size.files ?? "?"} files, ${size.lines ?? "?"} lines) is within the ${SPLIT_MIN_FILES}-file / ${SPLIT_MIN_LINES}-line budget` : "the block has no \"size\" estimate");
|
|
49
|
+
return { ok: false, problem: `the split is not justified: ${why.join("; ")}. Split only when every sub-issue lists "paths" (or "files") disjoint from the others, or when "size" exceeds ${SPLIT_MIN_FILES} files or ${SPLIT_MIN_LINES} lines. Otherwise keep one lane: write the contract as an ordered checklist, one requirement or deliverable per item, each with its own validate command` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One line for the FORGE:DECOMPOSED comment and the decision log. */
|
|
53
|
+
export function rationaleLine(r) {
|
|
54
|
+
if (!r) return "";
|
|
55
|
+
const ov = r.overlaps?.length ? ` (overlaps ${r.overlaps.slice(0, 3).map(([i, j, a, b]) => `${i}:${a} ~ ${j}:${b}`).join("; ")})` : r.missing?.length ? ` (no paths: ${r.missing.join(", ")})` : "";
|
|
56
|
+
const sz = r.size ? `${r.size.files ?? "?"} files, ${r.size.lines ?? "?"} lines` : "no estimate";
|
|
57
|
+
return `independent: ${r.independent ? "yes" : "no"}${ov}; size: ${sz} vs budget ${SPLIT_MIN_FILES} files / ${SPLIT_MIN_LINES} lines -> oversize ${r.oversize ? "yes" : "no"}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Batch-report line: cost per decomposed parent (its row plus its sub-issue rows, recursively) vs per single-lane
|
|
62
|
+
* parent (a merged row that is no row's sub-issue). Null when no row decomposed.
|
|
63
|
+
*/
|
|
64
|
+
export function formatDecompositionSummary(rows) {
|
|
65
|
+
const list = (rows ?? []).filter((r) => r && Number.isSafeInteger(r.issue));
|
|
66
|
+
const subsOf = (r) => (r?.outcome === "decomposed" && Array.isArray(r.subissues) ? r.subissues.filter((n) => Number.isSafeInteger(n) && n !== r.issue) : []);
|
|
67
|
+
const byIssue = new Map(list.map((r) => [r.issue, r]));
|
|
68
|
+
const isSub = new Set(list.flatMap(subsOf));
|
|
69
|
+
const parents = list.filter((r) => subsOf(r).length && !isSub.has(r.issue));
|
|
70
|
+
if (!list.some((r) => subsOf(r).length)) return null;
|
|
71
|
+
let lanes = 0, outside = 0;
|
|
72
|
+
const total = (r, seen) => {
|
|
73
|
+
let c = Number(r.cost_usd) || 0;
|
|
74
|
+
for (const n of subsOf(r)) {
|
|
75
|
+
if (seen.has(n)) continue;
|
|
76
|
+
seen.add(n);
|
|
77
|
+
const s = byIssue.get(n);
|
|
78
|
+
if (!s) { outside++; continue; }
|
|
79
|
+
lanes++;
|
|
80
|
+
c += total(s, seen);
|
|
81
|
+
}
|
|
82
|
+
return c;
|
|
83
|
+
};
|
|
84
|
+
const dCost = parents.reduce((a, r) => a + total(r, new Set([r.issue])), 0);
|
|
85
|
+
const single = list.filter((r) => r.outcome === "merged" && !isSub.has(r.issue));
|
|
86
|
+
const sCost = single.reduce((a, r) => a + (Number(r.cost_usd) || 0), 0);
|
|
87
|
+
const per = (c, n) => `$${(n ? c / n : 0).toFixed(2)}/parent`;
|
|
88
|
+
return `decomposition: ${parents.length} decomposed parent${parents.length === 1 ? "" : "s"} ${per(dCost, parents.length)} (${lanes} sub-lane${lanes === 1 ? "" : "s"}${outside ? `, ${outside} outside this batch` : ""}); `
|
|
89
|
+
+ (single.length ? `${single.length} single-lane ${per(sCost, single.length)}` : "single-lane none");
|
|
90
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// #4396: decomposition gate (independence and size) and the batch report's decomposed vs single-lane $/parent line.
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { splitGate, rationaleLine, formatDecompositionSummary, SPLIT_MIN_FILES, SPLIT_MIN_LINES } from "./decompose.mjs";
|
|
7
|
+
import { classifyBlock } from "./lean.mjs";
|
|
8
|
+
import { orchestrate } from "./orchestrate.mjs";
|
|
9
|
+
|
|
10
|
+
const sub = (paths) => ({ title: "t", body: "b", ...(paths ? { paths } : {}) });
|
|
11
|
+
|
|
12
|
+
describe("splitGate (#4396)", () => {
|
|
13
|
+
it("admits parts with disjoint paths and records independent", () => {
|
|
14
|
+
const g = splitGate({ subissues: [sub(["src/a", "docs/a.md"]), sub(["./src/b/"]), sub(["src/ab"])] });
|
|
15
|
+
assert.equal(g.ok, true);
|
|
16
|
+
assert.deepEqual(g.rationale, { independent: true, oversize: false, overlaps: [], missing: [], size: null });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("rejects overlapping parts (same file, or a file under another part's directory) within the size budget", () => {
|
|
20
|
+
for (const [a, b] of [["src/alerts", "src/alerts/routing.ts"], ["src/x.ts", "./src/x.ts"]]) {
|
|
21
|
+
const g = splitGate({ subissues: [sub([a]), sub([b])], size: { files: 3, lines: 200 } });
|
|
22
|
+
assert.equal(g.ok, false, `${a} ~ ${b}`);
|
|
23
|
+
assert.match(g.problem, /parts overlap/);
|
|
24
|
+
assert.match(g.problem, /ordered checklist/);
|
|
25
|
+
assert.match(g.problem, /within the 25-file \/ 1500-line budget/);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("reads the #4385 per-sub-issue files as the declared area, alone or with paths", () => {
|
|
30
|
+
const f = (files, paths) => ({ title: "t", body: "b", files, ...(paths ? { paths } : {}) });
|
|
31
|
+
assert.equal(splitGate({ subissues: [f(["src/a.ts"]), f(["/src/b.ts"])] }).ok, true);
|
|
32
|
+
const g = splitGate({ subissues: [f(["src/a.ts"]), f(["src/b.ts"], ["src/a.ts"])] });
|
|
33
|
+
assert.equal(g.ok, false);
|
|
34
|
+
assert.match(g.problem, /0:src\/a\.ts ~ 1:src\/a\.ts/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("rejects a split whose parts declare no paths and that has no size estimate", () => {
|
|
38
|
+
const g = splitGate({ subissues: [sub(), sub(["src/b"])] });
|
|
39
|
+
assert.equal(g.ok, false);
|
|
40
|
+
assert.match(g.problem, /sub-issue\(s\) 0 declare no "paths"/);
|
|
41
|
+
assert.match(g.problem, /no "size" estimate/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("admits overlapping parts when the size estimate exceeds the budget (files or lines); ignores a non-numeric size", () => {
|
|
45
|
+
const parts = [sub(["src/a"]), sub(["src/a/x.ts"])];
|
|
46
|
+
assert.equal(splitGate({ subissues: parts, size: { files: SPLIT_MIN_FILES + 1 } }).rationale.oversize, true);
|
|
47
|
+
const g = splitGate({ subissues: parts, size: { lines: SPLIT_MIN_LINES + 1 } });
|
|
48
|
+
assert.deepEqual({ ok: g.ok, independent: g.rationale.independent, oversize: g.rationale.oversize }, { ok: true, independent: false, oversize: true });
|
|
49
|
+
assert.equal(splitGate({ subissues: parts, size: { files: SPLIT_MIN_FILES, lines: SPLIT_MIN_LINES } }).ok, false, "at the budget is not over it");
|
|
50
|
+
assert.equal(splitGate({ subissues: parts, size: { files: "99" } }).ok, false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rationaleLine names independence, overlaps and size against the budget", () => {
|
|
54
|
+
const g = splitGate({ subissues: [sub(["src/a"]), sub(["src/a/x.ts"])], size: { files: 40, lines: 3000 } });
|
|
55
|
+
assert.equal(rationaleLine(g.rationale), "independent: no (overlaps 0:src/a ~ 1:src/a/x.ts); size: 40 files, 3000 lines vs budget 25 files / 1500 lines -> oversize yes");
|
|
56
|
+
assert.equal(rationaleLine(null), "");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("classifyBlock turns an unjustified split into an untyped block and carries the rationale of an admitted one", () => {
|
|
60
|
+
const bad = classifyBlock({ block: { kind: "split-required", subissues: [sub(["src/a"]), sub(["src/a"])] } }, 7);
|
|
61
|
+
assert.equal(bad.kind, "untyped");
|
|
62
|
+
assert.match(bad.problem, /split is not justified/);
|
|
63
|
+
const ok = classifyBlock({ block: { kind: "split-required", subissues: [sub(["src/a"]), { ...sub(["src/b"]), depends_on: [0] }] } }, 7);
|
|
64
|
+
assert.equal(ok.kind, "split");
|
|
65
|
+
assert.equal(ok.rationale.independent, true);
|
|
66
|
+
assert.deepEqual(ok.subissues.map((s) => s.dependsOn), [[], [0]]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("formatDecompositionSummary (#4396)", () => {
|
|
71
|
+
it("is null when no row decomposed", () => {
|
|
72
|
+
assert.equal(formatDecompositionSummary([{ issue: 1, outcome: "merged", cost_usd: 3 }]), null);
|
|
73
|
+
assert.equal(formatDecompositionSummary(undefined), null);
|
|
74
|
+
assert.equal(formatDecompositionSummary([{ issue: 1, outcome: "decomposed", subissues: [] }]), null);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("sums a parent with its sub-lanes (recursively) and compares with merged single-lane parents", () => {
|
|
78
|
+
const rows = [
|
|
79
|
+
{ issue: 1, outcome: "decomposed", cost_usd: 2, subissues: [11, 12, 13] },
|
|
80
|
+
{ issue: 11, outcome: "merged", cost_usd: 4 },
|
|
81
|
+
{ issue: 12, outcome: "decomposed", cost_usd: 1, subissues: [121] },
|
|
82
|
+
{ issue: 121, outcome: "merged", cost_usd: 3 },
|
|
83
|
+
{ issue: 2, outcome: "merged", cost_usd: 5 },
|
|
84
|
+
{ issue: 3, outcome: "merged", cost_usd: 7 },
|
|
85
|
+
{ issue: 4, outcome: "needs-human", cost_usd: 9 },
|
|
86
|
+
];
|
|
87
|
+
assert.equal(formatDecompositionSummary(rows), "decomposition: 1 decomposed parent $10.00/parent (3 sub-lanes, 1 outside this batch); 2 single-lane $6.00/parent");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("reports no single-lane parents as none and guards a cycle", () => {
|
|
91
|
+
const rows = [{ issue: 1, outcome: "decomposed", cost_usd: 1, subissues: [2] }, { issue: 2, outcome: "decomposed", cost_usd: 1, subissues: [1] }];
|
|
92
|
+
assert.match(formatDecompositionSummary([{ issue: 1, outcome: "decomposed", cost_usd: 2, subissues: [5] }]), /^decomposition: 1 decomposed parent \$2\.00\/parent \(0 sub-lanes, 1 outside this batch\); single-lane none$/);
|
|
93
|
+
assert.ok(formatDecompositionSummary(rows));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("the batch row keeps the lane's subissues and decomposition, so the report links a parent to its sub-lanes", async () => {
|
|
97
|
+
const rationale = { independent: true, oversize: false, overlaps: [], missing: [], size: null };
|
|
98
|
+
const script = { 1: { terminalReason: "decomposed", cost_usd: 2, subissues: [8], decomposition: rationale }, 8: { terminalReason: "merged", cost_usd: 4 }, 2: { terminalReason: "merged", cost_usd: 1 } };
|
|
99
|
+
const rows = await orchestrate({ issues: [1, 2], parallel: 1, readBody: async () => "", runOne: async (n) => script[n] });
|
|
100
|
+
const r1 = rows.find((r) => r.issue === 1);
|
|
101
|
+
assert.deepEqual(r1.subissues, [8]);
|
|
102
|
+
assert.deepEqual(r1.decomposition, rationale);
|
|
103
|
+
assert.equal(formatDecompositionSummary(rows), "decomposition: 1 decomposed parent $6.00/parent (1 sub-lane); 1 single-lane $1.00/parent");
|
|
104
|
+
});
|
|
105
|
+
});
|
package/bin/lean/depcache.mjs
CHANGED
|
@@ -80,7 +80,8 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
|
|
|
80
80
|
return { npm_config_store_dir: store("pnpm-store"), npm_config_cache: store("npm-cache"), UV_CACHE_DIR: store("uv-cache"), UV_LINK_MODE: "hardlink",
|
|
81
81
|
POETRY_CACHE_DIR: store("poetry-cache"), PIP_CACHE_DIR: store("pip-cache"), POETRY_VIRTUALENVS_IN_PROJECT: "true" };
|
|
82
82
|
}
|
|
83
|
-
|
|
83
|
+
/** Install/repair command; `laneEnv` (the lane's toolchain env, #4347) sits under the store env, so store keys win. */
|
|
84
|
+
const run = (cmd, args, cwd, laneEnv) => exec(cmd, args, { cwd, env: laneEnv ? { ...laneEnv, ...env() } : env(), timeoutMs: installTimeoutMs });
|
|
84
85
|
const runtime = async (cmd) => { try { return String(await exec(cmd, ["--version"], { timeoutMs: 30000 })).trim(); } catch { return "unknown"; } };
|
|
85
86
|
|
|
86
87
|
/** Tracked package.json files (workspace members), relative; root only when git cannot list them. */
|
|
@@ -190,7 +191,7 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
|
|
|
190
191
|
}
|
|
191
192
|
}
|
|
192
193
|
|
|
193
|
-
async function prepareOne(wt, keyRoot, d) {
|
|
194
|
+
async function prepareOne(wt, keyRoot, d, laneEnv) {
|
|
194
195
|
const t0 = now();
|
|
195
196
|
const done = (status, extra = {}) => ({ status, eco: d.eco, key: extra.key ?? null, ms: now() - t0, reason: extra.reason ?? null, ...(extra.published === undefined ? {} : { published: extra.published }) });
|
|
196
197
|
if (d.skip) return done("skipped", { reason: d.skip });
|
|
@@ -209,7 +210,7 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
|
|
|
209
210
|
const created = [];
|
|
210
211
|
try {
|
|
211
212
|
for (const t of man.trees ?? []) { created.push(t); linkTree(join(snap, t), join(wt, t), { from: man.prefix, to: wt, binDir: binDirFor(d) }); }
|
|
212
|
-
for (const [c, a] of cmds.repair ?? []) await run(c, a, wt);
|
|
213
|
+
for (const [c, a] of cmds.repair ?? []) await run(c, a, wt, laneEnv);
|
|
213
214
|
try { fs.writeFileSync(join(snap, "last-used"), String(now())); } catch { /* best effort */ }
|
|
214
215
|
return done("hit", { key });
|
|
215
216
|
} catch (e) {
|
|
@@ -218,7 +219,7 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
|
|
|
218
219
|
}
|
|
219
220
|
};
|
|
220
221
|
const install = async () => {
|
|
221
|
-
try { for (const [c, a] of cmds.install) await run(c, a, wt); } catch (e) {
|
|
222
|
+
try { for (const [c, a] of cmds.install) await run(c, a, wt, laneEnv); } catch (e) {
|
|
222
223
|
rmTrees(wt, expected);
|
|
223
224
|
return { failed: done("failed", { key, reason: `${e?.code === "ENOENT" ? "tool not found" : "install failed"}: ${msg(e)}` }) };
|
|
224
225
|
}
|
|
@@ -240,8 +241,11 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
|
|
|
240
241
|
return ins.failed ?? done("miss-installed", { key, published: false, reason: "cache lock busy" });
|
|
241
242
|
}
|
|
242
243
|
|
|
243
|
-
/**
|
|
244
|
-
|
|
244
|
+
/**
|
|
245
|
+
* Materialize or build node_modules/.venv for `wt`; one result per detected ecosystem. Never throws.
|
|
246
|
+
* `env` (#4347): the lane's toolchain env (PATH with the pinned pm shims) for install/repair; absent means the host PATH.
|
|
247
|
+
*/
|
|
248
|
+
async function prepare({ wt, repoKey, env: laneEnv } = {}) {
|
|
245
249
|
if (disabled()) return [{ status: "skipped", eco: null, key: null, ms: 0, reason: "FORGE_DEPCACHE=0" }];
|
|
246
250
|
let found;
|
|
247
251
|
try { found = detect(fs, wt); } catch (e) { return [{ status: "failed", eco: null, key: null, ms: 0, reason: msg(e) }]; }
|
|
@@ -249,7 +253,7 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
|
|
|
249
253
|
const keyRoot = join(root, safeSegment(repoKey ?? "repo"));
|
|
250
254
|
const out = [];
|
|
251
255
|
for (const d of found) {
|
|
252
|
-
try { out.push(await prepareOne(wt, keyRoot, d)); } catch (e) { out.push({ status: "failed", eco: d.eco, key: null, ms: 0, reason: msg(e) }); }
|
|
256
|
+
try { out.push(await prepareOne(wt, keyRoot, d, laneEnv && typeof laneEnv === "object" ? laneEnv : undefined)); } catch (e) { out.push({ status: "failed", eco: d.eco, key: null, ms: 0, reason: msg(e) }); }
|
|
253
257
|
}
|
|
254
258
|
return out;
|
|
255
259
|
}
|
|
@@ -8,6 +8,7 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync, lstatSync
|
|
|
8
8
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
9
|
import { join, dirname, relative } from "node:path";
|
|
10
10
|
import { makeDepCache } from "./depcache.mjs";
|
|
11
|
+
import { prepareToolchain } from "./toolchain.mjs";
|
|
11
12
|
|
|
12
13
|
const scratch = () => tmpDir("fd-depcache-");
|
|
13
14
|
const put = (p, text, mode) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, text, mode ? { mode } : undefined); };
|
|
@@ -81,6 +82,10 @@ function fakeExec({ failInstall = false, installDelayMs = 0 } = {}) {
|
|
|
81
82
|
symlinkSync("../left-pad/cli.js", join(cwd, "node_modules/.bin/left-pad"));
|
|
82
83
|
return "";
|
|
83
84
|
}
|
|
85
|
+
if (cmd === "yarn") {
|
|
86
|
+
put(join(cwd, "node_modules/left-pad/index.js"), `// ${readFileSync(join(cwd, "yarn.lock"), "utf8").length}\n`);
|
|
87
|
+
return "";
|
|
88
|
+
}
|
|
84
89
|
if (cmd === "uv" && args[0] === "venv") { put(join(cwd, ".venv/pyvenv.cfg"), "home = /usr/bin\nrelocatable = true\n"); return ""; }
|
|
85
90
|
if (cmd === "uv" || cmd === "poetry") { venv(cwd, readFileSync(join(cwd, cmd === "uv" ? "uv.lock" : "poetry.lock"), "utf8")); return ""; }
|
|
86
91
|
throw Object.assign(new Error(`spawn ${cmd} ENOENT`), { code: "ENOENT" });
|
|
@@ -296,3 +301,79 @@ describe("depcache (#4220): shared dependency installs keyed by lock hash", () =
|
|
|
296
301
|
assert.ok(!existsSync(fresh));
|
|
297
302
|
});
|
|
298
303
|
});
|
|
304
|
+
|
|
305
|
+
describe("depcache (#4347): installs and repairs run with the lane toolchain env", () => {
|
|
306
|
+
const PMS = new Set(["pnpm", "pnpx", "yarn", "npm", "npx"]);
|
|
307
|
+
/** A host with no global package manager: pm commands resolve only when PATH starts with the toolchain shim dir. */
|
|
308
|
+
const shimOnlyExec = (f, binDir) => {
|
|
309
|
+
const seen = [];
|
|
310
|
+
const exec = async (cmd, args, o = {}) => {
|
|
311
|
+
if (PMS.has(cmd)) {
|
|
312
|
+
seen.push({ cmd, args: [...args], env: o.env });
|
|
313
|
+
if (!String(o.env?.PATH ?? "").startsWith(binDir)) throw Object.assign(new Error(`spawn ${cmd} ENOENT`), { code: "ENOENT" });
|
|
314
|
+
}
|
|
315
|
+
return f.exec(cmd, args, o);
|
|
316
|
+
};
|
|
317
|
+
return { exec, seen };
|
|
318
|
+
};
|
|
319
|
+
/** The real toolchain step: corepack on the host PATH, no global pm; shims recorded, not written. */
|
|
320
|
+
const laneToolchain = async (wt, binDir) => {
|
|
321
|
+
const tc = await prepareToolchain({ dir: wt, binDir, basePath: "/host/bin", find: (n) => (n === "corepack" ? "/host/bin/corepack" : null),
|
|
322
|
+
writeShim: async () => {}, mkdirp: async () => {}, hostNode: () => "v22.1.0" });
|
|
323
|
+
assert.ok(tc.env, "a packageManager pin yields a toolchain env");
|
|
324
|
+
return tc.env;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
it("#4347 a packageManager-pinned pnpm repo with no host pnpm installs and repairs through the lane toolchain env", async () => {
|
|
328
|
+
const base = scratch(), root = join(base, "cache"), binDir = join(base, "toolchain", "bin");
|
|
329
|
+
const f = fakeExec(), x = shimOnlyExec(f, binDir);
|
|
330
|
+
const cache = makeDepCache({ root, exec: x.exec, env: {}, pollMs: 5 });
|
|
331
|
+
const lane = (name) => { const wt = join(base, "wt", name); FIXTURES.pnpm(wt); put(join(wt, "package.json"), '{"name":"web","private":true,"packageManager":"pnpm@9.12.0"}'); return wt; };
|
|
332
|
+
const a = lane("a");
|
|
333
|
+
const env = await laneToolchain(a, binDir);
|
|
334
|
+
const [r1] = await cache.prepare({ wt: a, repoKey: "r", env });
|
|
335
|
+
assert.equal(r1.status, "miss-installed", r1.reason);
|
|
336
|
+
assert.ok(existsSync(join(a, "node_modules/lodash/index.js")));
|
|
337
|
+
const [r2] = await cache.prepare({ wt: lane("b"), repoKey: "r", env });
|
|
338
|
+
assert.equal(r2.status, "hit", r2.reason);
|
|
339
|
+
assert.deepEqual(x.seen.map((c) => [c.cmd, c.args.includes("--offline")]), [["pnpm", false], ["pnpm", true]], "install then repair");
|
|
340
|
+
for (const c of x.seen) {
|
|
341
|
+
assert.ok(c.env.PATH.startsWith(`${binDir}:`), "the shim dir leads PATH");
|
|
342
|
+
assert.equal(c.env.COREPACK_ENABLE_DOWNLOAD_PROMPT, "0");
|
|
343
|
+
assert.equal(c.env.npm_config_store_dir, join(root, "stores", "pnpm-store"), "the store env stays");
|
|
344
|
+
}
|
|
345
|
+
// Without the toolchain env (the toolchain step failed or wrote no shim) it is the host PATH, as before.
|
|
346
|
+
const [r3] = await makeDepCache({ root: join(base, "cache2"), exec: x.exec, env: {} }).prepare({ wt: lane("c"), repoKey: "r" });
|
|
347
|
+
assert.equal(r3.status, "failed");
|
|
348
|
+
assert.match(r3.reason, /tool not found: spawn pnpm ENOENT/);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("#4347 a pinned yarn (node-modules linker) repo installs through the shim env", async () => {
|
|
352
|
+
const base = scratch(), binDir = join(base, "toolchain", "bin");
|
|
353
|
+
const x = shimOnlyExec(fakeExec(), binDir);
|
|
354
|
+
const wt = join(base, "wt", "y");
|
|
355
|
+
put(join(wt, "package.json"), '{"name":"app","packageManager":"yarn@4.5.0"}');
|
|
356
|
+
put(join(wt, "yarn.lock"), "__metadata:\n version: 8\n");
|
|
357
|
+
put(join(wt, ".yarnrc.yml"), "nodeLinker: node-modules\n");
|
|
358
|
+
const env = await laneToolchain(wt, binDir);
|
|
359
|
+
const [r] = await makeDepCache({ root: join(base, "cache"), exec: x.exec, env: {} }).prepare({ wt, repoKey: "r", env });
|
|
360
|
+
assert.equal(r.status, "miss-installed", r.reason);
|
|
361
|
+
assert.deepEqual(x.seen.map((c) => [c.cmd, c.args]), [["yarn", ["install", "--immutable"]]]);
|
|
362
|
+
assert.ok(x.seen[0].env.PATH.startsWith(`${binDir}:`));
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("#4347 a plain npm repo without pins passes exactly the store env, as before; store keys win over the lane env", async () => {
|
|
366
|
+
const s = setup("npm");
|
|
367
|
+
const [r] = await s.cache.prepare({ wt: s.lane("plain"), repoKey: "r" });
|
|
368
|
+
assert.equal(r.status, "miss-installed", r.reason);
|
|
369
|
+
const inst = s.f.calls.filter((c) => c.cmd === "npm");
|
|
370
|
+
assert.equal(inst.length, 1);
|
|
371
|
+
const seen = [];
|
|
372
|
+
const cache = makeDepCache({ root: join(s.base, "cache2"), exec: async (cmd, args, o = {}) => { if (cmd === "npm") seen.push(o); return s.f.exec(cmd, args, o); }, env: {} });
|
|
373
|
+
await cache.prepare({ wt: s.lane("plain2"), repoKey: "r" });
|
|
374
|
+
assert.deepEqual(seen[0].env, cache.env(), "no lane env: only the store env");
|
|
375
|
+
await cache.prepare({ wt: s.lane("pinned"), repoKey: "r2", env: { PATH: "/shim:/host", npm_config_cache: "/lane/should-not-win" } });
|
|
376
|
+
assert.equal(seen[1].env.PATH, "/shim:/host");
|
|
377
|
+
assert.equal(seen[1].env.npm_config_cache, cache.env().npm_config_cache);
|
|
378
|
+
});
|
|
379
|
+
});
|