forgedock 1.27.0 → 1.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +39 -0
- package/bin/engine/telemetry-seam.mjs +37 -8
- package/bin/engine/telemetry-seam.test.mjs +31 -0
- package/bin/engine-cli.mjs +601 -42
- package/bin/engine.mjs +8 -4
- package/bin/forgedock.mjs +75 -9
- package/bin/gc.mjs +580 -0
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +22 -4
- package/bin/lean/batch.test.mjs +5 -3
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +234 -57
- package/bin/lean/conformance.test.mjs +168 -7
- package/bin/lean/context.mjs +333 -0
- package/bin/lean/context.test.mjs +279 -0
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +104 -9
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +723 -154
- package/bin/lean/lean.test.mjs +1603 -35
- package/bin/lean/ledger.mjs +27 -2
- package/bin/lean/ledger.test.mjs +36 -1
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +389 -19
- package/bin/lean/prefilter.mjs +2 -2
- package/bin/lean/prefilter.test.mjs +3 -0
- package/bin/lean/prompts.mjs +106 -17
- package/bin/lean/recovery.test.mjs +230 -0
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +49 -10
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/testmap.mjs +84 -0
- package/bin/lean/testmap.test.mjs +58 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +66 -10
- package/bin/lean/verify.test.mjs +104 -3
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +24 -2
- package/bin/stats/local-source.mjs +13 -0
- package/bin/stats/query.mjs +99 -0
- package/bin/stats/stats.test.mjs +94 -1
- package/commands/audit.md +2 -0
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +7 -0
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +62 -8
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +192 -2
- package/docs/OUTCOME-CODES.md +22 -0
- package/docs/RELEASE-NOTES.md +9 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +36 -2
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +9 -4
- package/scripts/check-devdocs-freshness.test.sh +11 -1
- package/scripts/check-spec-markers.sh +4 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +3 -3
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +1 -1
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
- package/scripts/fixtures/test-map/go/Makefile +5 -0
- package/scripts/fixtures/test-map/go/README.md +1 -0
- package/scripts/fixtures/test-map/go/go.mod +3 -0
- package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
- package/scripts/fixtures/test-map/go/main.go +3 -0
- package/scripts/fixtures/test-map/node/package.json +10 -0
- package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
- package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
- package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
- package/scripts/fixtures/test-map/node/src/a.ts +1 -0
- package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
- package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
- package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
- package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
- package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
- package/scripts/fixtures/test-map/python/uv.lock +1 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/repo-facts.sh +146 -0
- package/scripts/repo-facts.test.sh +106 -0
- package/scripts/structure-delta.sh +10 -5
- package/scripts/structure-delta.test.sh +3 -2
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/scripts/test-map.sh +211 -0
- package/scripts/test-map.test.sh +165 -0
- package/scripts/wait-ci-green.sh +10 -4
- package/scripts/wait-ci-green.test.sh +9 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- package/templates/bootstrap/technical-spike.md +75 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Sparse lane worktrees (#4221). A lane checks out every tracked file except regular blobs at or above
|
|
5
|
+
* `maxBlobBytes` (forge.yaml `env.sparse_max_kb`, default 1024; 0 = off). Lockfiles, manifests, toolchain pins and
|
|
6
|
+
* git/forge config are always checked out (the #4220 dep cache and #4163 toolchain pins read them). The patterns are
|
|
7
|
+
* non-cone (`/*` then `!/<path>`), kept in the worktree's own `info/sparse-checkout`; `core.sparseCheckout` is per-worktree
|
|
8
|
+
* config (git sets `extensions.worktreeConfig` in the shared config), so the main checkout is untouched. A repo with no
|
|
9
|
+
* such blob gets today's plain `git worktree add`. A failed sparse step falls back to a full checkout; never fails a lane.
|
|
10
|
+
*/
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_SPARSE_MAX_KB = 1024;
|
|
14
|
+
|
|
15
|
+
const KEEP_RE = /^(package\.json|package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|yarn\.lock|\.yarnrc\.yml|uv\.lock|poetry\.lock|pyproject\.toml|requirements.*\.txt|setup\.py|setup\.cfg|\.nvmrc|\.node-version|\.tool-versions|tsconfig.*\.json|forge\.yaml|\.gitattributes|\.gitignore)$/;
|
|
16
|
+
|
|
17
|
+
/** Blobs to leave out, from `git ls-tree -r -l -z <ref>` output: {excludes, bytes}. Symlinks, gitlinks and kept files never. */
|
|
18
|
+
export function sparsePlan(lsTree, { maxBlobBytes = DEFAULT_SPARSE_MAX_KB * 1024 } = {}) {
|
|
19
|
+
const excludes = [];
|
|
20
|
+
let bytes = 0;
|
|
21
|
+
if (!(maxBlobBytes > 0)) return { excludes, bytes };
|
|
22
|
+
for (const rec of String(lsTree ?? "").split("\0")) {
|
|
23
|
+
const m = /^(\d{6}) blob [0-9a-f]+ +(\d+)\t([\s\S]+)$/.exec(rec.replace(/^\n+/, ""));
|
|
24
|
+
if (!m || !["100644", "100755"].includes(m[1])) continue;
|
|
25
|
+
const size = Number(m[2]), path = m[3];
|
|
26
|
+
if (size < maxBlobBytes || /[\n\r]/.test(path) || KEEP_RE.test(path.split("/").at(-1))) continue;
|
|
27
|
+
excludes.push(path);
|
|
28
|
+
bytes += size;
|
|
29
|
+
}
|
|
30
|
+
return { excludes, bytes };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** gitignore-syntax escape for a literal path: \ * ? [ anywhere, trailing spaces. */
|
|
34
|
+
const esc = (p) => p.replace(/[\\*?[]| (?= *$)/g, (c) => `\\${c}`);
|
|
35
|
+
|
|
36
|
+
/** Non-cone sparse patterns that include everything except `excludes`. */
|
|
37
|
+
export function sparsePatterns(excludes) {
|
|
38
|
+
return ["/*", ...excludes.map((p) => `!/${esc(p)}`)];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Add a lane worktree, sparse when the plan is non-empty. `git(args, {cwd})` is the injected io.git; `write(path, text)`
|
|
43
|
+
* writes the pattern file (argv-safe for any number of patterns). `addArgs` is the plain `["worktree","add",...]` argv.
|
|
44
|
+
* A failing `worktree add` is rethrown unchanged (the caller retries lock races). Returns {sparse, excluded, bytes, fallback?}.
|
|
45
|
+
*/
|
|
46
|
+
export async function addLaneWorktree(git, write, { cwd, wt, addArgs, ref, maxBlobBytes = DEFAULT_SPARSE_MAX_KB * 1024 }) {
|
|
47
|
+
let plan = { excludes: [], bytes: 0 };
|
|
48
|
+
if (maxBlobBytes > 0 && typeof write === "function") {
|
|
49
|
+
try { plan = sparsePlan(await git(["ls-tree", "-r", "-l", "-z", ref], { cwd }), { maxBlobBytes }); } catch { /* no plan: full checkout */ }
|
|
50
|
+
}
|
|
51
|
+
if (!plan.excludes.length) {
|
|
52
|
+
await git(addArgs, { cwd });
|
|
53
|
+
return { sparse: false, excluded: 0, bytes: 0 };
|
|
54
|
+
}
|
|
55
|
+
await git([...addArgs.slice(0, 2), "--no-checkout", ...addArgs.slice(2)], { cwd });
|
|
56
|
+
const g = (a) => git(a, { cwd: wt });
|
|
57
|
+
try {
|
|
58
|
+
await g(["sparse-checkout", "set", "--no-cone", "/*"]);
|
|
59
|
+
const file = String(await g(["rev-parse", "--git-path", "info/sparse-checkout"])).trim();
|
|
60
|
+
await write(file.startsWith("/") ? file : join(wt, file), `${sparsePatterns(plan.excludes).join("\n")}\n`);
|
|
61
|
+
await g(["read-tree", "-mu", "HEAD"]);
|
|
62
|
+
return { sparse: true, excluded: plan.excludes.length, bytes: plan.bytes };
|
|
63
|
+
} catch (e) {
|
|
64
|
+
const reason = String(e?.stderr || e?.message || e).trim().split("\n").slice(-2).join(" ").slice(0, 300);
|
|
65
|
+
try { await g(["sparse-checkout", "disable"]); } catch { /* full read-tree below */ }
|
|
66
|
+
await g(["read-tree", "-mu", "HEAD"]);
|
|
67
|
+
return { sparse: false, excluded: 0, bytes: 0, fallback: reason || "sparse checkout failed" };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { mkdirSync, writeFileSync, readdirSync, statSync, existsSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
+
import { join, dirname } from "node:path";
|
|
9
|
+
import { sparsePlan, sparsePatterns, addLaneWorktree } from "./worktree.mjs";
|
|
10
|
+
|
|
11
|
+
const MiB = 1024 * 1024;
|
|
12
|
+
const gitSync = (args, cwd) => execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * MiB, stdio: ["ignore", "pipe", "pipe"] });
|
|
13
|
+
const git = async (args, { cwd } = {}) => gitSync(args, cwd);
|
|
14
|
+
const write = async (p, t) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, t); };
|
|
15
|
+
const blob = (n, ch = "x") => ch.repeat(n);
|
|
16
|
+
|
|
17
|
+
/** A fixture repo: {path: content}. Returns its dir. */
|
|
18
|
+
function repo(files) {
|
|
19
|
+
const dir = tmpDir("wt4221-");
|
|
20
|
+
const r = join(dir, "r");
|
|
21
|
+
mkdirSync(r);
|
|
22
|
+
gitSync(["init", "-q", "-b", "main"], r);
|
|
23
|
+
for (const [p, c] of Object.entries(files)) { mkdirSync(dirname(join(r, p)), { recursive: true }); writeFileSync(join(r, p), c); }
|
|
24
|
+
gitSync(["add", "-A"], r);
|
|
25
|
+
gitSync(["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "init"], r);
|
|
26
|
+
return { dir, r, wt: join(dir, "wt") };
|
|
27
|
+
}
|
|
28
|
+
/** Bytes of regular files on disk under `d`, skipping .git. */
|
|
29
|
+
function diskBytes(d) {
|
|
30
|
+
let n = 0;
|
|
31
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
32
|
+
if (e.name === ".git") continue;
|
|
33
|
+
const p = join(d, e.name);
|
|
34
|
+
if (e.isDirectory()) n += diskBytes(p); else if (e.isFile()) n += statSync(p).size;
|
|
35
|
+
}
|
|
36
|
+
return n;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const FIXTURES = {
|
|
40
|
+
"npm JS/TS": {
|
|
41
|
+
files: { "package.json": '{"name":"a","version":"1.0.0"}\n', "package-lock.json": `{"lockfileVersion":3,"pad":"${blob(MiB + 10)}"}\n`,
|
|
42
|
+
"tsconfig.json": "{}\n", "src/index.ts": "export const x = 1;\n", "public/asset.json": blob(2 * MiB) },
|
|
43
|
+
big: "public/asset.json", kept: ["package.json", "package-lock.json", "src/index.ts", "tsconfig.json"],
|
|
44
|
+
},
|
|
45
|
+
"uv Python": {
|
|
46
|
+
files: { "pyproject.toml": '[project]\nname = "a"\nversion = "0.1.0"\n', "uv.lock": `version = 1\n# ${blob(MiB + 10)}\n`,
|
|
47
|
+
"app/main.py": "print(1)\n", "data/blob.bin": blob(2 * MiB, "\u0001") },
|
|
48
|
+
big: "data/blob.bin", kept: ["pyproject.toml", "uv.lock", "app/main.py"],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
describe("sparse lane worktrees (#4221)", () => {
|
|
53
|
+
for (const [name, fx] of Object.entries(FIXTURES)) {
|
|
54
|
+
it(`${name}: large blobs stay out, lockfiles and manifests stay in, git sees no change`, async () => {
|
|
55
|
+
const { dir, r, wt } = repo(fx.files);
|
|
56
|
+
try {
|
|
57
|
+
const res = await addLaneWorktree(git, write, { cwd: r, wt, addArgs: ["worktree", "add", "-B", "feat/issue-1-lean", wt, "main"], ref: "main", maxBlobBytes: MiB });
|
|
58
|
+
assert.deepEqual(res, { sparse: true, excluded: 1, bytes: 2 * MiB });
|
|
59
|
+
assert.equal(existsSync(join(wt, fx.big)), false);
|
|
60
|
+
assert.match(gitSync(["ls-files", "-t", fx.big], wt), /^S /);
|
|
61
|
+
for (const k of fx.kept) assert.ok(existsSync(join(wt, k)), `${k} checked out`);
|
|
62
|
+
assert.equal(gitSync(["status", "--porcelain"], wt), "");
|
|
63
|
+
gitSync(["add", "-A"], wt);
|
|
64
|
+
assert.equal(gitSync(["diff", "--cached", "--name-only"], wt), "", "git add -A stages no deletion");
|
|
65
|
+
// R5: on-disk checkout is no more than the included blobs (+1 MiB slack): the left-out blob costs nothing.
|
|
66
|
+
const included = Object.entries(fx.files).filter(([p]) => p !== fx.big).reduce((n, [, c]) => n + Buffer.byteLength(c), 0);
|
|
67
|
+
assert.ok(diskBytes(wt) <= included + MiB, `disk ${diskBytes(wt)} <= ${included + MiB}`);
|
|
68
|
+
assert.throws(() => gitSync(["config", "--get", "core.sparseCheckout"], r), "main checkout has no core.sparseCheckout");
|
|
69
|
+
assert.equal(Object.keys(fx.files).every((p) => existsSync(join(r, p))), true);
|
|
70
|
+
mkdirSync(dirname(join(wt, fx.big)), { recursive: true });
|
|
71
|
+
writeFileSync(join(wt, fx.big), "regenerated\n");
|
|
72
|
+
assert.match(gitSync(["status", "--porcelain"], wt), new RegExp(` M ${fx.big}`));
|
|
73
|
+
gitSync(["checkout", "--", fx.big], wt);
|
|
74
|
+
gitSync(["sparse-checkout", "add", `/${fx.big}`], wt);
|
|
75
|
+
assert.equal(statSync(join(wt, fx.big)).size, 2 * MiB);
|
|
76
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
it("a repo with no large blob gets today's plain worktree add", async () => {
|
|
81
|
+
const { dir, r, wt } = repo({ "package.json": "{}\n", "src/a.js": "1\n" });
|
|
82
|
+
try {
|
|
83
|
+
const calls = [];
|
|
84
|
+
const spy = async (a, o) => { calls.push(a); return git(a, o); };
|
|
85
|
+
const addArgs = ["worktree", "add", "-B", "feat/issue-1-lean", wt, "main"];
|
|
86
|
+
assert.deepEqual(await addLaneWorktree(spy, write, { cwd: r, wt, addArgs, ref: "main" }), { sparse: false, excluded: 0, bytes: 0 });
|
|
87
|
+
assert.deepEqual(calls.filter((a) => a[0] === "worktree"), [addArgs]);
|
|
88
|
+
assert.equal(calls.some((a) => a[0] === "sparse-checkout"), false);
|
|
89
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("maxBlobBytes 0 disables it; a failing sparse step falls back to a full checkout", async () => {
|
|
93
|
+
const { dir, r, wt } = repo({ "a.bin": blob(2 * MiB), "b.txt": "1\n" });
|
|
94
|
+
try {
|
|
95
|
+
const calls = [];
|
|
96
|
+
const spy = async (a, o) => { calls.push(a); return git(a, o); };
|
|
97
|
+
await addLaneWorktree(spy, write, { cwd: r, wt, addArgs: ["worktree", "add", "-B", "lane", wt, "main"], ref: "main", maxBlobBytes: 0 });
|
|
98
|
+
assert.equal(calls.some((a) => a[0] === "ls-tree" || a.includes("--no-checkout")), false);
|
|
99
|
+
assert.ok(existsSync(join(wt, "a.bin")));
|
|
100
|
+
gitSync(["worktree", "remove", "--force", wt], r);
|
|
101
|
+
const failing = async (a, o) => { if (a[0] === "read-tree" && !failing.done) { failing.done = true; throw Object.assign(new Error("boom"), { stderr: "fatal: boom" }); } return git(a, o); };
|
|
102
|
+
const res = await addLaneWorktree(failing, write, { cwd: r, wt, addArgs: ["worktree", "add", "-B", "lane", wt, "main"], ref: "main", maxBlobBytes: MiB });
|
|
103
|
+
assert.equal(res.sparse, false);
|
|
104
|
+
assert.match(res.fallback, /boom/);
|
|
105
|
+
assert.ok(existsSync(join(wt, "a.bin")) && existsSync(join(wt, "b.txt")));
|
|
106
|
+
assert.equal(gitSync(["status", "--porcelain"], wt), "");
|
|
107
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("a failing worktree add is rethrown unchanged (the caller retries lock races)", async () => {
|
|
111
|
+
const e = Object.assign(new Error("lock"), { stderr: "could not lock config file" });
|
|
112
|
+
const g = async (a) => { if (a[0] === "worktree") throw e; return "100644 blob " + "a".repeat(40) + " 2000000\tbig.bin\0"; };
|
|
113
|
+
await assert.rejects(addLaneWorktree(g, write, { cwd: "/x", wt: "/x/wt", addArgs: ["worktree", "add", "/x/wt", "main"], ref: "main" }), (x) => x === e);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("sparsePlan: threshold, keep-list, symlinks and gitlinks; patterns escape glob characters", () => {
|
|
117
|
+
const h = "a".repeat(40);
|
|
118
|
+
const out = [
|
|
119
|
+
`100644 blob ${h} 2000000\tweb/openapi.json`, `100755 blob ${h} 1048576\tbin/tool`, `100644 blob ${h} 1048575\tsmall.txt`,
|
|
120
|
+
`120000 blob ${h} 40\tlink`, `160000 commit ${h} -\tsub`, `100644 blob ${h} 3000000\tpnpm-lock.yaml`,
|
|
121
|
+
`100644 blob ${h} 3000000\tsvc/requirements-dev.txt`, `100644 blob ${h} 3000000\tpkg/tsconfig.base.json`, `100644 blob ${h} 5000000\ta [x]*?.bin `,
|
|
122
|
+
].join("\0") + "\0";
|
|
123
|
+
const p = sparsePlan(out, { maxBlobBytes: MiB });
|
|
124
|
+
assert.deepEqual(p.excludes, ["web/openapi.json", "bin/tool", "a [x]*?.bin "]);
|
|
125
|
+
assert.equal(p.bytes, 2000000 + 1048576 + 5000000);
|
|
126
|
+
assert.deepEqual(sparsePlan(out, { maxBlobBytes: 0 }).excludes, []);
|
|
127
|
+
assert.deepEqual(sparsePatterns(p.excludes), ["/*", "!/web/openapi.json", "!/bin/tool", "!/a \\[x]\\*\\?.bin\\ "]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("an escaped pattern really matches its odd path", async () => {
|
|
131
|
+
const odd = "d/a [x]*?.bin";
|
|
132
|
+
const { dir, r, wt } = repo({ [odd]: blob(2 * MiB), "d/a x.bin": "keep\n", "f.txt": "1\n" });
|
|
133
|
+
try {
|
|
134
|
+
const res = await addLaneWorktree(git, write, { cwd: r, wt, addArgs: ["worktree", "add", "-B", "lane", wt, "main"], ref: "main", maxBlobBytes: MiB });
|
|
135
|
+
assert.equal(res.sparse, true);
|
|
136
|
+
assert.equal(existsSync(join(wt, odd)), false);
|
|
137
|
+
assert.ok(existsSync(join(wt, "d/a x.bin")));
|
|
138
|
+
assert.equal(gitSync(["status", "--porcelain"], wt), "");
|
|
139
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
140
|
+
});
|
|
141
|
+
});
|
package/bin/runner.mjs
CHANGED
|
@@ -359,6 +359,37 @@ export function resolveConfiguredDefaultModel(cwd) {
|
|
|
359
359
|
}
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Whether the owner approved the paid API backend as the `auto` fallback (#4257).
|
|
364
|
+
* Needs both forge.yaml `execution.agent_backend: api` in `cwd` (repo owner) and
|
|
365
|
+
* env `FORGEDOCK_ALLOW_API_BACKEND=1` (host owner), so a committed config edit
|
|
366
|
+
* alone cannot start billing an API key. Never throws.
|
|
367
|
+
*
|
|
368
|
+
* @param {{cwd?: string, env?: object}} [opts]
|
|
369
|
+
* @returns {boolean}
|
|
370
|
+
*/
|
|
371
|
+
export function isApiBackendApproved({ cwd = process.cwd(), env = process.env } = {}) {
|
|
372
|
+
if (String(env?.FORGEDOCK_ALLOW_API_BACKEND ?? "").trim() !== "1") return false;
|
|
373
|
+
try {
|
|
374
|
+
const forgeYamlPath = join(cwd, "forge.yaml");
|
|
375
|
+
if (!existsSync(forgeYamlPath)) return false;
|
|
376
|
+
return parseForgeYaml(readFileSync(forgeYamlPath, "utf-8"))?.execution?.agent_backend === "api";
|
|
377
|
+
} catch {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** #4257: typed error when `auto` finds no usable CLI and the API backend is not approved. */
|
|
383
|
+
function cliUnavailableError(detail = "The claude CLI is not available") {
|
|
384
|
+
const err = new Error(
|
|
385
|
+
`${detail}, and the API backend is never used implicitly. Install and authenticate the \`claude\` CLI, ` +
|
|
386
|
+
"or opt in to the API backend (forge.yaml `execution.agent_backend: api` plus FORGEDOCK_ALLOW_API_BACKEND=1), " +
|
|
387
|
+
"or pass --backend api.",
|
|
388
|
+
);
|
|
389
|
+
err.code = "CLI_BACKEND_UNAVAILABLE";
|
|
390
|
+
return err;
|
|
391
|
+
}
|
|
392
|
+
|
|
362
393
|
/**
|
|
363
394
|
* Resolve the CLI backend timeout from forge.yaml's
|
|
364
395
|
* `pipeline.cli_timeout_minutes` field, if it is a positive integer.
|
|
@@ -854,6 +885,7 @@ export function checkExecutionBackend({
|
|
|
854
885
|
apiKey = env.ANTHROPIC_API_KEY,
|
|
855
886
|
resolveCliFn = resolveClaudeCliBinary,
|
|
856
887
|
spawnImpl = spawnSync,
|
|
888
|
+
apiApproved = isApiBackendApproved({ cwd, env }),
|
|
857
889
|
sdkAvailableFn = () => {
|
|
858
890
|
try {
|
|
859
891
|
createRequire(import.meta.url).resolve("@anthropic-ai/sdk");
|
|
@@ -884,7 +916,8 @@ export function checkExecutionBackend({
|
|
|
884
916
|
return { ready: true, backend: "cli", reason: "resolved-cli" };
|
|
885
917
|
}
|
|
886
918
|
}
|
|
887
|
-
|
|
919
|
+
// #4257: auto never crosses to the paid API backend without the owner's opt-in.
|
|
920
|
+
if (requested === "cli" || !apiApproved) {
|
|
888
921
|
return { ready: false, backend: "cli", reason: "cli-unavailable" };
|
|
889
922
|
}
|
|
890
923
|
}
|
|
@@ -905,7 +938,8 @@ export function checkExecutionBackend({
|
|
|
905
938
|
* CLI and prefer it when present — but diverge on (a) which override
|
|
906
939
|
* values are valid and whether an invalid one throws or is silently
|
|
907
940
|
* ignored, and (b) what to return when the CLI probe fails (`resolveBackend`
|
|
908
|
-
*
|
|
941
|
+
* falls back to `"api"` only with the owner's opt-in, else `"cli"` (#4257);
|
|
942
|
+
* `resolveEnrichBackend` falls back to `"api"`
|
|
909
943
|
* only if `ANTHROPIC_API_KEY` is set, else `"none"`). Rather than force
|
|
910
944
|
* those into one shape, this shared helper implements exactly the common
|
|
911
945
|
* two steps and defers to a `cliFallback` callback for the diverging tail,
|
|
@@ -954,24 +988,27 @@ export function resolveBackendLadder({
|
|
|
954
988
|
* 2. `"auto"` (the default) probes `isClaudeCliAvailable()`: prefers the
|
|
955
989
|
* CLI backend when a working `claude` install is detected (reuses
|
|
956
990
|
* whatever credentials the CLI already has — Pro/Max OAuth or a
|
|
957
|
-
* CLI-managed key — with no ANTHROPIC_API_KEY required)
|
|
958
|
-
* falls back to the API backend
|
|
959
|
-
*
|
|
991
|
+
* CLI-managed key — with no ANTHROPIC_API_KEY required). Without a CLI it
|
|
992
|
+
* falls back to the API backend ONLY when the owner opted in
|
|
993
|
+
* (`isApiBackendApproved()`, #4257); otherwise it returns `"cli"` and
|
|
994
|
+
* `runCommand()` fails with a typed `CLI_BACKEND_UNAVAILABLE`. The API
|
|
995
|
+
* backend is never the implicit default.
|
|
960
996
|
*
|
|
961
997
|
* Delegates its shared "override-or-probe-CLI" shape to
|
|
962
998
|
* `resolveBackendLadder()` (issue #2026) — this function retains its own
|
|
963
999
|
* `VALID_BACKENDS` throw-on-invalid check (NOT shared; see that helper's
|
|
964
|
-
* doc comment) and its own
|
|
965
|
-
*
|
|
1000
|
+
* doc comment) and its own opt-in-gated fallback (`"api"` only when
|
|
1001
|
+
* approved — the API key itself is validated lazily downstream, only
|
|
966
1002
|
* if/when the api backend is actually selected).
|
|
967
1003
|
*
|
|
968
1004
|
* @param {object} [opts]
|
|
969
1005
|
* @param {string} [opts.requested] - "cli" | "api" | "auto" (default "auto").
|
|
970
1006
|
* @param {string} [opts.cwd] - Working directory for the CLI probe.
|
|
971
1007
|
* @param {object} [opts.env] - Runtime environment used for capability checks.
|
|
1008
|
+
* @param {Function} [opts.isCliAvailableFn] - Injectable CLI-presence probe (test seam).
|
|
972
1009
|
* @returns {"cli"|"api"}
|
|
973
1010
|
*/
|
|
974
|
-
export function resolveBackend({ requested = "auto", cwd = process.cwd(), env = process.env } = {}) {
|
|
1011
|
+
export function resolveBackend({ requested = "auto", cwd = process.cwd(), env = process.env, isCliAvailableFn = isClaudeCliAvailable } = {}) {
|
|
975
1012
|
if (!VALID_BACKENDS.has(requested)) {
|
|
976
1013
|
throw new Error(
|
|
977
1014
|
`Invalid backend "${requested}". Must be one of: ${[...VALID_BACKENDS].join(", ")}.`,
|
|
@@ -982,7 +1019,8 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd(), env =
|
|
|
982
1019
|
override: requested === "auto" ? undefined : requested,
|
|
983
1020
|
validOverrides: new Set(["cli", "api"]),
|
|
984
1021
|
cwd,
|
|
985
|
-
|
|
1022
|
+
isCliAvailableFn,
|
|
1023
|
+
cliFallback: () => (isApiBackendApproved({ cwd, env }) ? "api" : "cli"),
|
|
986
1024
|
});
|
|
987
1025
|
}
|
|
988
1026
|
|
|
@@ -2789,7 +2827,9 @@ export function renderSummaryCard(ctx) {
|
|
|
2789
2827
|
* @param {string} [opts.backend] - "cli" | "api" | "auto" (default
|
|
2790
2828
|
* "auto"). Resolution order when omitted: $FORGEDOCK_BACKEND env > "auto".
|
|
2791
2829
|
* "auto" prefers the local Claude Code CLI when detected (no
|
|
2792
|
-
* ANTHROPIC_API_KEY needed)
|
|
2830
|
+
* ANTHROPIC_API_KEY needed); without a CLI it uses the API backend only
|
|
2831
|
+
* with the owner's opt-in (`isApiBackendApproved()`, #4257), else it fails
|
|
2832
|
+
* with a typed CLI_BACKEND_UNAVAILABLE — api is never the default. This
|
|
2793
2833
|
* precedence is intentional/shipped (issue #2003's accepted acceptance
|
|
2794
2834
|
* criteria) and is NOT changed by issue #2020 — that issue only adds a
|
|
2795
2835
|
* discoverability notice (below) for the case where the ladder silently
|
|
@@ -2840,6 +2880,7 @@ export async function runCommand(opts = {}) {
|
|
|
2840
2880
|
|
|
2841
2881
|
if (resolvedBackend === "cli" && backend === "auto") {
|
|
2842
2882
|
const readiness = checkExecutionBackend({ requested: "auto", cwd, apiKey, env: runtimeEnv });
|
|
2883
|
+
// #4257: readiness only reports api when the owner approved it.
|
|
2843
2884
|
if (readiness.backend === "api") resolvedBackend = "api";
|
|
2844
2885
|
}
|
|
2845
2886
|
|
|
@@ -2938,6 +2979,8 @@ export async function runCommand(opts = {}) {
|
|
|
2938
2979
|
sessionFlags = buildMinimalSessionFlags(commandName, caps);
|
|
2939
2980
|
}
|
|
2940
2981
|
if (!resolvedBin && backend === "auto") {
|
|
2982
|
+
// #4257: never cross to the paid API backend without the owner's opt-in.
|
|
2983
|
+
if (!isApiBackendApproved({ cwd, env: runtimeEnv })) throw cliUnavailableError();
|
|
2941
2984
|
resolvedBackend = "api";
|
|
2942
2985
|
} else if (!resolvedBin) {
|
|
2943
2986
|
const err = new Error("The configured claude CLI path disappeared before invocation.");
|
|
@@ -163,7 +163,7 @@ async function* listIssuePages(gh, repo, startSince) {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
const meta = (it) => ({ number: it.number, isPr: Boolean(it.pull_request), createdAt: it.created_at ?? null, closedAt: it.closed_at ?? null,
|
|
166
|
-
updatedAt: it.updated_at ?? null, labels: (it.labels ?? []).map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean), authorType: it.user?.type ?? null });
|
|
166
|
+
updatedAt: it.updated_at ?? null, milestone: it.milestone?.title ?? null, labels: (it.labels ?? []).map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean), authorType: it.user?.type ?? null });
|
|
167
167
|
|
|
168
168
|
const CHECKPOINT_EVERY = 1000;
|
|
169
169
|
const RATE_CHECK_EVERY = 50;
|
package/bin/stats/index.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { aggregate, DEFAULT_MIN_N } from "./aggregate.mjs";
|
|
|
13
13
|
import { fetchGithubRecords, isValidRepo, toModel } from "./github-source.mjs";
|
|
14
14
|
import { readLocalRuns, runsDir } from "./local-source.mjs";
|
|
15
15
|
import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
|
|
16
|
+
import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb } from "./query.mjs";
|
|
16
17
|
|
|
17
18
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
19
|
|
|
@@ -34,10 +35,18 @@ delta against the previous version and a low-n flag for samples under 10. Missin
|
|
|
34
35
|
--min-n N low-n threshold (default ${DEFAULT_MIN_N})
|
|
35
36
|
--release-times FILE "<version> <epoch-seconds>" lines used to estimate versions of unversioned records
|
|
36
37
|
(default: git tags v* of the ForgeDock checkout)
|
|
38
|
+
|
|
39
|
+
Audit queries (forge#4165; JSON, from structure_delta / run_terminal telemetry, no comment bodies read):
|
|
40
|
+
--edge FROM:TO merged PRs that added the dependency or import edge FROM -> TO (e.g. @s/web:@s/db)
|
|
41
|
+
--milestone M conformance verdict mix and per-day structure series for milestone M
|
|
42
|
+
|
|
43
|
+
Context-pack A/B (forge#4275; local run logs only, no GitHub crawl):
|
|
44
|
+
--context-ab first-pass yield, builder turns, cache tokens and cost per lane type (rerun, child,
|
|
45
|
+
first) and context_pack mode (on, off, shadow); --json for JSON
|
|
37
46
|
`;
|
|
38
47
|
|
|
39
48
|
export function parseArgs(argv) {
|
|
40
|
-
const o = { since: null, repo: null, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
|
|
49
|
+
const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
|
|
41
50
|
const val = (i, name) => {
|
|
42
51
|
const v = argv[i + 1];
|
|
43
52
|
if (v == null || v.startsWith("--")) throw new Error(`${name} needs a value`);
|
|
@@ -51,6 +60,9 @@ export function parseArgs(argv) {
|
|
|
51
60
|
if (name === "--since") o.since = take();
|
|
52
61
|
else if (name === "--repo") o.repo = take();
|
|
53
62
|
else if (name === "--release-times") o.releaseTimes = take();
|
|
63
|
+
else if (name === "--edge") { const v = take(); if (!parseEdge(v)) throw new Error("--edge must be FROM:TO"); o.edge = v; }
|
|
64
|
+
else if (name === "--milestone") o.milestone = take();
|
|
65
|
+
else if (name === "--context-ab") o.contextAb = true;
|
|
54
66
|
else if (name === "--min-n") { const v = take(); if (!/^\d+$/.test(v)) throw new Error("--min-n must be a non-negative integer"); o.minN = Number(v); }
|
|
55
67
|
else if (name === "--json") o.json = true;
|
|
56
68
|
else if (name === "--refresh") o.refresh = true;
|
|
@@ -149,10 +161,19 @@ export async function buildReport(opts, deps = {}) {
|
|
|
149
161
|
if (opts.releaseTimes) releaseTimes = parseReleaseTimes(readFileSync(opts.releaseTimes, "utf8"));
|
|
150
162
|
else if (deps.releaseTimes) releaseTimes = deps.releaseTimes;
|
|
151
163
|
else releaseTimes = gitTagTimes();
|
|
164
|
+
if (opts.contextAb) { // forge#4275: run logs only
|
|
165
|
+
const runs = (deps.localRuns ?? readLocalRuns(runsDir(env))).filter((r) => r.repo == null || r.repo === repo);
|
|
166
|
+
return { repo, sources: { local: { runs: runs.length } }, query: { context_ab: contextPackAb(runs) } };
|
|
167
|
+
}
|
|
152
168
|
const bound = sinceToTimestamp(opts.since, releaseTimes);
|
|
153
169
|
if (bound.note) (deps.warn ?? ((m) => process.stderr.write(`forgedock stats: ${m}\n`)))(bound.note);
|
|
154
170
|
const gh = await fetchGithubRecords({ repo, since: bound.since, refresh: opts.refresh, useCache: opts.cache, gh: deps.gh, env,
|
|
155
171
|
onProgress: deps.onProgress, sleep: deps.sleep, now: deps.now });
|
|
172
|
+
if (opts.edge || opts.milestone) { // forge#4165: audit query instead of the version report
|
|
173
|
+
const e = opts.edge ? parseEdge(opts.edge) : null;
|
|
174
|
+
const query = { ...(e ? { edge: { ...e, prs: edgeQuery(gh.records, e.from, e.to) } } : {}), ...(opts.milestone ? { mix: verdictMix(gh.records, gh.items, opts.milestone) } : {}) };
|
|
175
|
+
return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache } }, query };
|
|
176
|
+
}
|
|
156
177
|
const model = toModel(repo, gh);
|
|
157
178
|
const ghKeys = new Set(model.runs.map((r) => `${r.repo}#${r.issue}`));
|
|
158
179
|
let local = [];
|
|
@@ -178,7 +199,8 @@ export async function runStats(argv, deps = {}) {
|
|
|
178
199
|
}
|
|
179
200
|
try {
|
|
180
201
|
const report = await buildReport(opts, { onProgress: makeProgressPrinter(), ...deps });
|
|
181
|
-
|
|
202
|
+
if (report.query?.context_ab && !opts.json) { process.stdout.write(`${renderContextAb(report.query.context_ab)}\n`); return 0; }
|
|
203
|
+
process.stdout.write(`${opts.json || report.query ? renderJson(report) : opts.compact ? renderCompact(report) : renderMarkdown(report)}\n`);
|
|
182
204
|
return 0;
|
|
183
205
|
} catch (err) {
|
|
184
206
|
process.stderr.write(`forgedock stats: ${err.message}\n`);
|
|
@@ -50,6 +50,13 @@ export function summariseLog(events, fallbackIssue = null) {
|
|
|
50
50
|
if (attempt && Number.isFinite(e.cost_usd)) p.usd = (p.usd ?? 0) + e.cost_usd;
|
|
51
51
|
}
|
|
52
52
|
const finished = t >= 0 && (r < 0 || t > r);
|
|
53
|
+
// forge#4275: the context-pack A/B inputs. Builder calls log as phase "implement" (the plan call is the first).
|
|
54
|
+
const term = finished ? ev[t] : null;
|
|
55
|
+
const cp = [...ev].reverse().find((e) => e.event === "CONTEXT_PACK");
|
|
56
|
+
const usage = ev.filter((e) => e.event === "PHASE_ATTEMPT_USAGE");
|
|
57
|
+
const builderCalls = usage.filter((e) => e.phase === "implement");
|
|
58
|
+
const reviewCalls = usage.filter((e) => e.phase === "review").length;
|
|
59
|
+
const contextPack = { mode: term?.context_pack ?? cp?.mode ?? "off", laneType: term?.lane_type ?? cp?.lane_type ?? null };
|
|
53
60
|
const startTs = start.ts ?? null;
|
|
54
61
|
const endTs = finished ? ev[t].ts ?? null : null;
|
|
55
62
|
const a = Date.parse(startTs ?? ""); const b = Date.parse(endTs ?? "");
|
|
@@ -60,6 +67,12 @@ export function summariseLog(events, fallbackIssue = null) {
|
|
|
60
67
|
remediated: ev.some((e) => e.event === "PHASE_COMMIT" && e.phase === "remediate"),
|
|
61
68
|
startTs, endTs, wallMs: finished && !Number.isNaN(a) && !Number.isNaN(b) ? Math.max(0, b - a) : null,
|
|
62
69
|
phases,
|
|
70
|
+
contextPack,
|
|
71
|
+
builderTurns: builderCalls.reduce((x, e) => x + n(e.turns), 0),
|
|
72
|
+
cacheTokens: usage.reduce((x, e) => x + n(e.usage?.cache_read_input_tokens) + n(e.usage?.cache_creation_input_tokens), 0),
|
|
73
|
+
costUsd: usage.reduce((x, e) => x + n(e.cost_usd), 0),
|
|
74
|
+
// first pass: merged off one plan + one implement call, at most one review, no CI fix round
|
|
75
|
+
firstPass: finished && term?.reason === "merged" && builderCalls.length <= 2 && reviewCalls <= 1 && !ev.some((e) => e.event === "CI_FIX_ROUND"),
|
|
63
76
|
};
|
|
64
77
|
}
|
|
65
78
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* forge#4165: deterministic audit queries over FORGE:TELEMETRY records, so `/audit` answers "which PRs added A→B?" and
|
|
5
|
+
* "what is the conformance verdict mix for milestone M?" from structured events instead of comment archaeology.
|
|
6
|
+
* Inputs are the records and issue metadata `fetchGithubRecords` already returns; no extra API call, no model.
|
|
7
|
+
* structure_delta (lean, merge-time): deps_added / new_edges / removed_edges as "from:to" comma lists, pr, milestone
|
|
8
|
+
* run_terminal: reason, pr, milestone, conformance_mode, shadow_would
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const list = (v) => (typeof v === "string" && v !== "none" ? v.split(",").filter(Boolean) : []);
|
|
12
|
+
const day = (ts) => (typeof ts === "string" ? ts.slice(0, 10) : "unknown");
|
|
13
|
+
|
|
14
|
+
/** "from:to" -> {from, to}; package names hold no ":" so the last ":" splits (a scoped name keeps its "/"). */
|
|
15
|
+
export function parseEdge(s) {
|
|
16
|
+
const i = String(s ?? "").lastIndexOf(":");
|
|
17
|
+
if (i <= 0 || i === String(s).length - 1) return null;
|
|
18
|
+
return { from: String(s).slice(0, i), to: String(s).slice(i + 1) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Milestone of a record: its issue's milestone from the crawled metadata (the exact title), else the record's own field
|
|
23
|
+
* (the emitter replaces characters outside [A-Za-z0-9 _.:/#@+=,-] with "_", so it may differ from the title).
|
|
24
|
+
*/
|
|
25
|
+
function milestoneOf(r, byIssue) {
|
|
26
|
+
return byIssue.get(r.issue)?.milestone ?? (typeof r.milestone === "string" && r.milestone ? r.milestone : null);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* PRs (and issues) whose merged change added the edge from→to: `kind` "package" (a package.json dependency) or
|
|
31
|
+
* "import" (value imports). Oldest first; one row per issue and kind (the latest record wins).
|
|
32
|
+
*/
|
|
33
|
+
export function edgeQuery(records, from, to) {
|
|
34
|
+
const key = `${from}:${to}`;
|
|
35
|
+
const rows = new Map();
|
|
36
|
+
for (const r of records) {
|
|
37
|
+
if (r?.event !== "structure_delta") continue;
|
|
38
|
+
for (const [field, kind] of [["deps_added", "package"], ["new_edges", "import"]]) {
|
|
39
|
+
if (!list(r[field]).includes(key)) continue;
|
|
40
|
+
const k = `${r.issue}\u0000${kind}`;
|
|
41
|
+
const prev = rows.get(k);
|
|
42
|
+
if (!prev || String(r.ts ?? "") >= String(prev.ts ?? "")) rows.set(k, { issue: r.issue ?? null, pr: r.pr ?? null, ts: r.ts ?? null, kind, milestone: r.milestone ?? null });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return [...rows.values()].sort((a, b) => String(a.ts ?? "").localeCompare(String(b.ts ?? "")) || (a.issue ?? 0) - (b.issue ?? 0));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Conformance verdict mix of finished runs (run_terminal) and the per-day structure series (structure_delta), for one
|
|
50
|
+
* milestone or, with `milestone` null, for the whole repo. `items` is the crawled issue metadata ({number, milestone}).
|
|
51
|
+
*/
|
|
52
|
+
export function verdictMix(records, items = [], milestone = null) {
|
|
53
|
+
const byIssue = new Map(items.map((i) => [i.number, i]));
|
|
54
|
+
const sel = (r) => milestone == null || milestoneOf(r, byIssue) === milestone;
|
|
55
|
+
const terms = records.filter((r) => r?.event === "run_terminal" && sel(r));
|
|
56
|
+
const count = (xs, f) => xs.reduce((m, x) => { const k = f(x); if (k != null) m[k] = (m[k] ?? 0) + 1; return m; }, {});
|
|
57
|
+
const series = new Map();
|
|
58
|
+
for (const r of records) {
|
|
59
|
+
if (r?.event !== "structure_delta" || r.phase !== "merge" || !sel(r)) continue;
|
|
60
|
+
const d = day(r.ts);
|
|
61
|
+
const s = series.get(d) ?? { date: d, merged: 0, deps_added: 0, new_edges: 0, removed_edges: 0 };
|
|
62
|
+
s.merged++; s.deps_added += list(r.deps_added).length; s.new_edges += list(r.new_edges).length; s.removed_edges += list(r.removed_edges).length;
|
|
63
|
+
series.set(d, s);
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
milestone, runs: terms.length,
|
|
67
|
+
verdicts: count(terms, (r) => (typeof r.shadow_would === "string" ? r.shadow_would : null)),
|
|
68
|
+
modes: count(terms, (r) => (typeof r.conformance_mode === "string" ? r.conformance_mode : null)),
|
|
69
|
+
reasons: count(terms, (r) => (typeof r.reason === "string" ? r.reason : null)),
|
|
70
|
+
series: [...series.values()].sort((a, b) => a.date.localeCompare(b.date)),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* forge#4275: the context-pack A/B over local run summaries (local-source.mjs summariseLog), per lane type (rerun,
|
|
76
|
+
* child, first) and mode (on, off, shadow), never one blended average. First-pass yield: merged with one plan + one
|
|
77
|
+
* implement builder call and at most one review. Runs without a recorded lane type (older logs) are left out.
|
|
78
|
+
*/
|
|
79
|
+
export const CONTEXT_LANE_TYPES = Object.freeze(["rerun", "child", "first"]);
|
|
80
|
+
export function contextPackAb(runs) {
|
|
81
|
+
const out = {};
|
|
82
|
+
const mean = (xs) => (xs.length ? Math.round((xs.reduce((a, b) => a + b, 0) / xs.length) * 1e4) / 1e4 : null);
|
|
83
|
+
for (const lt of CONTEXT_LANE_TYPES) {
|
|
84
|
+
out[lt] = {};
|
|
85
|
+
for (const mode of ["on", "off", "shadow"]) {
|
|
86
|
+
const rs = runs.filter((r) => r?.finished && r.contextPack?.laneType === lt && r.contextPack?.mode === mode);
|
|
87
|
+
out[lt][mode] = { n: rs.length, first_pass_yield: mean(rs.map((r) => (r.firstPass ? 1 : 0))), builder_turns: mean(rs.map((r) => r.builderTurns ?? 0)),
|
|
88
|
+
cache_tokens: mean(rs.map((r) => r.cacheTokens ?? 0)), cost_usd: mean(rs.map((r) => r.costUsd ?? 0)) };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Markdown table for contextPackAb. */
|
|
95
|
+
export function renderContextAb(ab) {
|
|
96
|
+
const f = (v) => (v == null ? "n/a" : String(v));
|
|
97
|
+
const rows = CONTEXT_LANE_TYPES.flatMap((lt) => ["on", "off", "shadow"].map((m) => { const g = ab[lt][m]; return `| ${lt} | ${m} | ${g.n} | ${f(g.first_pass_yield)} | ${f(g.builder_turns)} | ${f(g.cache_tokens)} | ${f(g.cost_usd)} |`; }));
|
|
98
|
+
return `| Lane type | Mode | n | First-pass yield | Builder turns | Cache tokens | Cost USD |\n| --- | --- | --- | --- | --- | --- | --- |\n${rows.join("\n")}`;
|
|
99
|
+
}
|