forgedock 1.29.1 → 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +5 -2
- package/bin/engine/phases.mjs +20 -3
- package/bin/engine/runlog.mjs +82 -12
- package/bin/engine/telemetry-seam.mjs +11 -4
- package/bin/engine/telemetry-seam.test.mjs +18 -0
- package/bin/engine-cli.mjs +453 -66
- package/bin/engine.mjs +50 -38
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/labels.json +5 -0
- package/bin/lean/apishape.mjs +170 -0
- package/bin/lean/apishape.test.mjs +187 -0
- package/bin/lean/batch.mjs +32 -0
- package/bin/lean/batch.test.mjs +356 -2
- package/bin/lean/budget.mjs +89 -0
- package/bin/lean/budget.test.mjs +106 -0
- package/bin/lean/checks.mjs +32 -9
- package/bin/lean/checks.test.mjs +78 -1
- package/bin/lean/conformance.e2e.test.mjs +27 -5
- package/bin/lean/conformance.mjs +278 -22
- package/bin/lean/conformance.test.mjs +251 -5
- package/bin/lean/context.mjs +133 -55
- package/bin/lean/context.test.mjs +175 -9
- package/bin/lean/decompose.mjs +90 -0
- package/bin/lean/decompose.test.mjs +105 -0
- package/bin/lean/depcache.mjs +11 -7
- package/bin/lean/depcache.test.mjs +81 -0
- package/bin/lean/e2e.mjs +102 -0
- package/bin/lean/e2e.test.mjs +86 -0
- package/bin/lean/effects.mjs +55 -19
- package/bin/lean/enumerating.mjs +189 -0
- package/bin/lean/enumerating.test.mjs +97 -0
- package/bin/lean/explore.mjs +47 -0
- package/bin/lean/explore.test.mjs +61 -0
- package/bin/lean/facts.mjs +84 -0
- package/bin/lean/facts.test.mjs +66 -0
- package/bin/lean/fixround.mjs +90 -0
- package/bin/lean/fixround.test.mjs +117 -0
- package/bin/lean/heavy.mjs +35 -2
- package/bin/lean/heavy.test.mjs +32 -2
- package/bin/lean/lean.mjs +799 -127
- package/bin/lean/lean.test.mjs +1749 -37
- package/bin/lean/ledger.mjs +5 -3
- package/bin/lean/ledger.test.mjs +18 -0
- package/bin/lean/light.mjs +135 -0
- package/bin/lean/light.test.mjs +173 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +341 -99
- package/bin/lean/pitfalls.mjs +136 -0
- package/bin/lean/pitfalls.test.mjs +117 -0
- package/bin/lean/postmerge.mjs +85 -0
- package/bin/lean/postmerge.test.mjs +79 -0
- package/bin/lean/precheck.mjs +180 -0
- package/bin/lean/precheck.test.mjs +205 -0
- package/bin/lean/prefilter.mjs +180 -9
- package/bin/lean/prefilter.test.mjs +175 -3
- package/bin/lean/preflight.mjs +238 -0
- package/bin/lean/preflight.test.mjs +108 -0
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +134 -12
- package/bin/lean/recovery.test.mjs +75 -0
- package/bin/lean/render.mjs +7 -3
- package/bin/lean/resume.mjs +67 -0
- package/bin/lean/resume.test.mjs +84 -0
- package/bin/lean/review-trigger.mjs +12 -0
- package/bin/lean/review-trigger.test.mjs +17 -1
- package/bin/lean/routing.mjs +74 -0
- package/bin/lean/routing.test.mjs +67 -0
- package/bin/lean/sched.test.mjs +127 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +149 -3
- package/bin/lean/selfreview.mjs +37 -0
- package/bin/lean/selfreview.test.mjs +51 -0
- package/bin/lean/seqlease.mjs +40 -0
- package/bin/lean/seqlease.test.mjs +101 -0
- package/bin/lean/sequence.mjs +385 -0
- package/bin/lean/sequence.test.mjs +373 -0
- package/bin/lean/testmap.mjs +43 -6
- package/bin/lean/testmap.test.mjs +38 -1
- package/bin/lean/toolchain.mjs +60 -15
- package/bin/lean/toolchain.test.mjs +120 -3
- package/bin/lean/toolstats.mjs +72 -0
- package/bin/lean/toolstats.test.mjs +47 -0
- package/bin/lean/verifier.mjs +232 -0
- package/bin/lean/verifier.test.mjs +163 -0
- package/bin/lean/verify.mjs +77 -10
- package/bin/lean/verify.test.mjs +74 -3
- package/bin/review-staging.mjs +533 -0
- package/bin/runner.mjs +82 -13
- package/bin/stats/aggregate.mjs +87 -2
- package/bin/stats/efficiency.mjs +319 -0
- package/bin/stats/efficiency.test.mjs +233 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +69 -6
- package/bin/stats/local-source.mjs +86 -3
- package/bin/stats/query.mjs +48 -0
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +224 -5
- package/commands/orchestrate.md +6 -2
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +84 -831
- package/commands/review-pr.md +1 -1
- package/commands/work-on/build/implement.md +1 -0
- package/commands/work-on/build/validate.md +11 -1
- package/docs/CONFIG.md +88 -6
- package/docs/OUTCOME-CODES.md +7 -4
- package/docs/RELEASE-NOTES.md +10 -0
- package/docs/STATS.md +53 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/acceptance-red-green.sh +13 -4
- package/scripts/acceptance-red-green.test.sh +14 -0
- package/scripts/ci-local/lib.mjs +20 -0
- package/scripts/ci-local/plan.mjs +2 -12
- package/scripts/ci-local/scoping.mjs +10 -1
- package/scripts/ci-local.test.sh +2 -0
- package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
- package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
- package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
- package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
- package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
- package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
- package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
- package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
- package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
- package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
- package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
- package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
- package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
- package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
- package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
- package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
- package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
- package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
- package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
- package/scripts/forge-root.test.sh +2 -2
- package/scripts/publish-staging-sync.test.sh +62 -4
- package/scripts/review-finalize.sh +17 -6
- package/scripts/review-finalize.test.sh +47 -0
- package/scripts/review-pr-arguments.test.sh +13 -1
- package/scripts/review-pr-delta-wiring.test.sh +2 -2
- package/scripts/review-pr-gate2.test.sh +6 -14
- package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
- package/scripts/review-pr-staging-scope.test.sh +39 -33
- package/scripts/review-prepare.sh +73 -6
- package/scripts/review-prepare.test.sh +85 -0
- package/scripts/review-staging-post.sh +293 -0
- package/scripts/review-staging-post.test.sh +143 -0
- package/scripts/review-staging-pre.sh +401 -0
- package/scripts/review-staging-pre.test.sh +150 -0
- package/scripts/symbol-index.mjs +26 -7
- package/scripts/symbol-index.py +55 -9
- package/scripts/test-map.sh +63 -17
- package/scripts/test-map.test.sh +55 -0
- package/scripts/trusted-comments.test.sh +1 -1
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
/**
|
|
5
|
+
* Public-API-shape change detector (forge#4449, light lane #4442). Deterministic, no LLM: for every file changed in
|
|
6
|
+
* <base>...<head> it indexes the base and head blobs with scripts/symbol-index.py / symbol-index.mjs `--shape` (the
|
|
7
|
+
* target repo's own Python `ast` / `typescript`) and compares the public symbols:
|
|
8
|
+
* signature | sync-async | generator-function | removed | renamed
|
|
9
|
+
* renamed = a vanished public symbol paired with a new public symbol of the same kind and shape in the same file;
|
|
10
|
+
* public→private counts as removed; additions never report. Code in a language without an indexer (go, rust, ...)
|
|
11
|
+
* or a blob the indexer refuses (parse error, no parser) is `unknown`, which the light lane treats as an upgrade.
|
|
12
|
+
* Docs/config/data files, test files and .d.ts are skipped.
|
|
13
|
+
*
|
|
14
|
+
* CLI: node bin/lean/apishape.mjs <base>...<head> | <base> <head> (run from the repo root)
|
|
15
|
+
* stdout {"status":"none|changed|unknown","changes":[{file,symbol,change,before,after}],"unknown":[{file,lang,reason}]}
|
|
16
|
+
* exit 0 none/changed/unknown | 2 usage or bad ref
|
|
17
|
+
*/
|
|
18
|
+
import { execFile } from "node:child_process";
|
|
19
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
const SCRIPTS = fileURLToPath(new URL("../../scripts/", import.meta.url));
|
|
25
|
+
export const SYMBOL_INDEX_PY = join(SCRIPTS, "symbol-index.py");
|
|
26
|
+
export const SYMBOL_INDEX_MJS = join(SCRIPTS, "symbol-index.mjs");
|
|
27
|
+
|
|
28
|
+
const INDEXED = { ".py": "python", ".ts": "ts", ".tsx": "ts", ".mts": "ts", ".cts": "ts", ".js": "js", ".jsx": "js", ".mjs": "js", ".cjs": "js" };
|
|
29
|
+
const UNINDEXED = {
|
|
30
|
+
".go": "go", ".rs": "rust", ".rb": "ruby", ".java": "java", ".kt": "kotlin", ".c": "c", ".h": "c", ".cc": "cpp",
|
|
31
|
+
".cpp": "cpp", ".hpp": "cpp", ".cs": "csharp", ".php": "php", ".swift": "swift", ".sh": "shell",
|
|
32
|
+
};
|
|
33
|
+
const TEST_FILE = /(^|\/)(test_[^/]*\.py|[^/]*_test\.py|conftest\.py|[^/]*\.(test|spec)\.[^/]+)$|(^|\/)(tests?|__tests__)\//;
|
|
34
|
+
|
|
35
|
+
/** argv runner without a shell; never throws for a non-zero exit. */
|
|
36
|
+
export function defaultRun(cmd, args, { cwd } = {}) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
execFile(cmd, args, { cwd, maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
39
|
+
const code = err ? (Number.isInteger(err.code) ? err.code : 127) : 0;
|
|
40
|
+
resolve({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? (err && !Number.isInteger(err.code) ? err.message : "")) });
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** "python" | "ts" | "js" for an indexed file, { unknown: lang } for code without an indexer, null when skipped. */
|
|
46
|
+
export function classifyPath(path) {
|
|
47
|
+
const p = String(path);
|
|
48
|
+
if (TEST_FILE.test(p) || /\.d\.[cm]?ts$/.test(p)) return null;
|
|
49
|
+
const ext = extname(p).toLowerCase();
|
|
50
|
+
if (INDEXED[ext]) return INDEXED[ext];
|
|
51
|
+
return UNINDEXED[ext] ? { unknown: UNINDEXED[ext] } : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `git diff -z --name-status -M` → [{ status, before, after }] (before/after null for added/deleted). */
|
|
55
|
+
export function parseNameStatus(out) {
|
|
56
|
+
const f = String(out).split("\0");
|
|
57
|
+
const rows = [];
|
|
58
|
+
for (let i = 0; i < f.length && f[i];) {
|
|
59
|
+
const st = f[i][0];
|
|
60
|
+
if (st === "R" || st === "C") { rows.push({ status: st, before: st === "R" ? f[i + 1] : null, after: f[i + 2] }); i += 3; continue; }
|
|
61
|
+
const p = f[i + 1];
|
|
62
|
+
rows.push({ status: st, before: st === "A" ? null : p, after: st === "D" ? null : p });
|
|
63
|
+
i += 2;
|
|
64
|
+
}
|
|
65
|
+
return rows;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const shapeOf = (s) => ({ async: !!s.async, generator: !!s.generator, sig: s.sig ?? null });
|
|
69
|
+
const shapeKey = (s) => JSON.stringify([s.kind, shapeOf(s)]);
|
|
70
|
+
|
|
71
|
+
/** Public-symbol shape changes between two `--shape` indexes of one file. */
|
|
72
|
+
export function compareIndexes(file, base, head) {
|
|
73
|
+
const pub = (idx) => new Map((idx?.symbols ?? []).filter((s) => s.public).map((s) => [s.name, s]));
|
|
74
|
+
const b = pub(base), h = pub(head), changes = [];
|
|
75
|
+
for (const [name, bs] of b) {
|
|
76
|
+
const hs = h.get(name);
|
|
77
|
+
if (!hs) continue;
|
|
78
|
+
const x = shapeOf(bs), y = shapeOf(hs);
|
|
79
|
+
if (x.async !== y.async) changes.push({ file, symbol: name, change: "sync-async", before: x.async ? "async" : "sync", after: y.async ? "async" : "sync" });
|
|
80
|
+
if (x.generator !== y.generator) changes.push({ file, symbol: name, change: "generator-function", before: x.generator ? "generator" : "function", after: y.generator ? "generator" : "function" });
|
|
81
|
+
if (x.sig !== y.sig) changes.push({ file, symbol: name, change: "signature", before: x.sig, after: y.sig });
|
|
82
|
+
else if (x.async === y.async && x.generator === y.generator && bs.kind !== hs.kind) {
|
|
83
|
+
changes.push({ file, symbol: name, change: "signature", before: bs.kind, after: hs.kind });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const added = [...h.keys()].filter((n) => !b.has(n)).sort();
|
|
87
|
+
for (const name of [...b.keys()].filter((n) => !h.has(n)).sort()) {
|
|
88
|
+
const key = shapeKey(b.get(name));
|
|
89
|
+
const i = added.findIndex((n) => shapeKey(h.get(n)) === key);
|
|
90
|
+
if (i >= 0) changes.push({ file, symbol: name, change: "renamed", before: name, after: added.splice(i, 1)[0] });
|
|
91
|
+
else changes.push({ file, symbol: name, change: "removed", before: name, after: null });
|
|
92
|
+
}
|
|
93
|
+
return changes;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function indexBlob({ cwd, run, dir, ref, path, lang }) {
|
|
97
|
+
const show = await run("git", ["show", `${ref}:${path}`], { cwd });
|
|
98
|
+
if (show.code !== 0) return { unknown: { lang, reason: `git-show-failed: ${ref}:${path}` } };
|
|
99
|
+
mkdirSync(dir, { recursive: true });
|
|
100
|
+
const file = join(dir, basename(path));
|
|
101
|
+
writeFileSync(file, show.stdout);
|
|
102
|
+
const r = lang === "python"
|
|
103
|
+
? await run("python3", [SYMBOL_INDEX_PY, "--shape", file], { cwd })
|
|
104
|
+
: await run(process.execPath, [SYMBOL_INDEX_MJS, "--shape", file], { cwd });
|
|
105
|
+
let out = null;
|
|
106
|
+
try { out = JSON.parse(r.stdout); } catch { /* reported below */ }
|
|
107
|
+
if (r.code !== 0 || !out) return { unknown: { lang, reason: r.code === 127 ? "no-indexer-runtime" : `index-failed: exit ${r.code}` } };
|
|
108
|
+
if (out.unsupported) return { unknown: { lang: out.unsupported, reason: out.reason ?? "unsupported" } };
|
|
109
|
+
return { index: out };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Compare base vs head for the changed files. Throws only for a bad ref or a failed `git diff` (CLI exit 2).
|
|
114
|
+
* @returns {Promise<{status:"none"|"changed"|"unknown", changes:object[], unknown:object[]}>}
|
|
115
|
+
*/
|
|
116
|
+
export async function detectApiShape({ cwd = process.cwd(), base, head, run = defaultRun } = {}) {
|
|
117
|
+
for (const ref of [base, head]) {
|
|
118
|
+
if (!ref || (await run("git", ["rev-parse", "--verify", "-q", `${ref}^{commit}`], { cwd })).code !== 0) throw new Error(`bad ref: ${ref}`);
|
|
119
|
+
}
|
|
120
|
+
const diff = await run("git", ["diff", "-z", "--name-status", "-M", `${base}...${head}`], { cwd });
|
|
121
|
+
if (diff.code !== 0) throw new Error(`git diff failed: ${diff.stderr.trim()}`);
|
|
122
|
+
const changes = [], unknown = [];
|
|
123
|
+
const tmp = mkdtempSync(join(tmpdir(), "apishape-"));
|
|
124
|
+
try {
|
|
125
|
+
let n = 0;
|
|
126
|
+
for (const row of parseNameStatus(diff.stdout)) {
|
|
127
|
+
if (!row.before) continue; // added (or copied) file: additions are not shape changes
|
|
128
|
+
const file = row.after ?? row.before;
|
|
129
|
+
const kind = classifyPath(row.before);
|
|
130
|
+
if (kind === null) continue;
|
|
131
|
+
if (typeof kind === "object") { unknown.push({ file, lang: kind.unknown, reason: "unsupported-language" }); continue; }
|
|
132
|
+
const dir = join(tmp, String(n++));
|
|
133
|
+
const b = await indexBlob({ cwd, run, dir: join(dir, "base"), ref: base, path: row.before, lang: kind });
|
|
134
|
+
if (b.unknown) { unknown.push({ file, ...b.unknown }); continue; }
|
|
135
|
+
let h = { index: { symbols: [] } };
|
|
136
|
+
if (row.after) {
|
|
137
|
+
const headKind = classifyPath(row.after);
|
|
138
|
+
if (headKind !== kind) { unknown.push({ file, lang: typeof headKind === "object" && headKind ? headKind.unknown : String(headKind), reason: "language-changed" }); continue; }
|
|
139
|
+
h = await indexBlob({ cwd, run, dir: join(dir, "head"), ref: head, path: row.after, lang: kind });
|
|
140
|
+
if (h.unknown) { unknown.push({ file, ...h.unknown }); continue; }
|
|
141
|
+
}
|
|
142
|
+
changes.push(...compareIndexes(file, b.index, h.index));
|
|
143
|
+
}
|
|
144
|
+
} finally {
|
|
145
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
148
|
+
changes.sort((a, b) => cmp(a.file, b.file) || cmp(a.symbol, b.symbol) || cmp(a.change, b.change));
|
|
149
|
+
unknown.sort((a, b) => cmp(a.file, b.file));
|
|
150
|
+
return { status: unknown.length ? "unknown" : changes.length ? "changed" : "none", changes, unknown };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function main(argv) {
|
|
154
|
+
let base, head;
|
|
155
|
+
if (argv.length === 1 && argv[0].includes("...")) [base, head] = argv[0].split("...");
|
|
156
|
+
else if (argv.length === 2) [base, head] = argv;
|
|
157
|
+
if (!base || !head || argv.includes("-h") || argv.includes("--help")) {
|
|
158
|
+
process.stderr.write("usage: apishape.mjs <base>...<head> | <base> <head>\n");
|
|
159
|
+
return 2;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
process.stdout.write(JSON.stringify(await detectApiShape({ base, head })) + "\n");
|
|
163
|
+
return 0;
|
|
164
|
+
} catch (err) {
|
|
165
|
+
process.stderr.write(`apishape: ${err.message}\n`);
|
|
166
|
+
return 2;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) process.exitCode = await main(process.argv.slice(2));
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// Public-API-shape detector (forge#4449): bin/lean/apishape.mjs against throwaway git repos built from
|
|
4
|
+
// scripts/fixtures/apishape/<lang>/<case>/{base,head}/ and the real symbol indexers.
|
|
5
|
+
// The JS/TS cases need the `typescript` (or `ts-morph`) package: APISHAPE_TS_NODE_MODULES=<node_modules holding it> is
|
|
6
|
+
// symlinked into each repo; without it they skip, and APISHAPE_REQUIRE_TS=1 turns the skip into a failure (CI).
|
|
7
|
+
import { describe, it } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
10
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
14
|
+
import { classifyPath, compareIndexes, detectApiShape, parseNameStatus } from "./apishape.mjs";
|
|
15
|
+
|
|
16
|
+
const FIX = fileURLToPath(new URL("../../scripts/fixtures/apishape/", import.meta.url));
|
|
17
|
+
const CLI = fileURLToPath(new URL("./apishape.mjs", import.meta.url));
|
|
18
|
+
const TS_MODULES = process.env.APISHAPE_TS_NODE_MODULES || "";
|
|
19
|
+
const HAS_TS = !!TS_MODULES && (existsSync(join(TS_MODULES, "typescript")) || existsSync(join(TS_MODULES, "ts-morph")));
|
|
20
|
+
const tsSkip = HAS_TS ? false : "TS-parser cases skipped (set APISHAPE_TS_NODE_MODULES)";
|
|
21
|
+
if (!HAS_TS && process.env.APISHAPE_REQUIRE_TS === "1") {
|
|
22
|
+
throw new Error("APISHAPE_REQUIRE_TS=1 but APISHAPE_TS_NODE_MODULES holds neither typescript nor ts-morph");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const git = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
26
|
+
|
|
27
|
+
function clearTracked(dir) {
|
|
28
|
+
for (const ent of readdirSync(dir)) if (ent !== ".git" && ent !== "node_modules") rmSync(join(dir, ent), { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A repo with commit `base` (fixture base/) and commit `head` (fixture head/), or explicit file maps. */
|
|
32
|
+
function repo({ fixture, base, head, ts = false }) {
|
|
33
|
+
const dir = tmpDir("apishape-");
|
|
34
|
+
git(dir, "init", "-q", "-b", "main");
|
|
35
|
+
git(dir, "config", "user.email", "t@example.com");
|
|
36
|
+
git(dir, "config", "user.name", "t");
|
|
37
|
+
if (ts) symlinkSync(TS_MODULES, join(dir, "node_modules"));
|
|
38
|
+
writeFileSync(join(dir, ".gitignore"), "node_modules\n");
|
|
39
|
+
const put = (side, files) => {
|
|
40
|
+
clearTracked(dir);
|
|
41
|
+
writeFileSync(join(dir, ".gitignore"), "node_modules\n");
|
|
42
|
+
if (fixture) cpSync(join(FIX, fixture, side), dir, { recursive: true });
|
|
43
|
+
for (const [p, body] of Object.entries(files ?? {})) { mkdirSync(join(dir, p, ".."), { recursive: true }); writeFileSync(join(dir, p), body); }
|
|
44
|
+
git(dir, "add", "-A");
|
|
45
|
+
git(dir, "commit", "-q", "--allow-empty", "-m", side);
|
|
46
|
+
git(dir, "tag", side);
|
|
47
|
+
};
|
|
48
|
+
put("base", base);
|
|
49
|
+
put("head", head);
|
|
50
|
+
return dir;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const detect = (cwd) => detectApiShape({ cwd, base: "base", head: "head" });
|
|
54
|
+
const brief = (r) => r.changes.map((c) => `${c.change}:${c.symbol}:${c.before}>${c.after}`);
|
|
55
|
+
|
|
56
|
+
describe("apishape: Python fixtures", () => {
|
|
57
|
+
it("signature: a new keyword-only parameter", async () => {
|
|
58
|
+
const r = await detect(repo({ fixture: "python/signature" }));
|
|
59
|
+
assert.equal(r.status, "changed");
|
|
60
|
+
assert.deepEqual(brief(r), ["signature:area:(width, height)>(width, height, *, scale=)"]);
|
|
61
|
+
assert.equal(r.changes[0].file, "shapes.py");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("sync-async: a top-level function and a method", async () => {
|
|
65
|
+
const r = await detect(repo({ fixture: "python/sync-async" }));
|
|
66
|
+
assert.deepEqual(brief(r), ["sync-async:Client.get:sync>async", "sync-async:fetch:sync>async"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("generator-function: a generator def becomes a list-returning def", async () => {
|
|
70
|
+
const r = await detect(repo({ fixture: "python/generator-function" }));
|
|
71
|
+
assert.deepEqual(brief(r), ["generator-function:pairs:generator>function"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("removed public symbol", async () => {
|
|
75
|
+
const r = await detect(repo({ fixture: "python/removed" }));
|
|
76
|
+
assert.deepEqual(brief(r), ["removed:sub:sub>null"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("renamed public symbol (same kind and shape)", async () => {
|
|
80
|
+
const r = await detect(repo({ fixture: "python/renamed" }));
|
|
81
|
+
assert.deepEqual(brief(r), ["renamed:slugify:slugify>to_slug"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("private/internal edit reports nothing", async () => {
|
|
85
|
+
const r = await detect(repo({ fixture: "python/private-edit" }));
|
|
86
|
+
assert.deepEqual(r, { status: "none", changes: [], unknown: [] });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("regression #4442: more-itertools ichunked generator → plain function is flagged", async () => {
|
|
90
|
+
const r = await detect(repo({ fixture: "python/ichunked" }));
|
|
91
|
+
assert.equal(r.status, "changed");
|
|
92
|
+
assert.deepEqual(brief(r), ["generator-function:ichunked:generator>function"]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("apishape: JS/TS fixtures", () => {
|
|
97
|
+
const ts = (fixture) => detect(repo({ fixture, ts: true }));
|
|
98
|
+
it("signature: a new defaulted parameter (.ts)", { skip: tsSkip }, async () => {
|
|
99
|
+
const r = await ts("ts/signature");
|
|
100
|
+
assert.deepEqual(brief(r), ["signature:area:(width: number, height: number) -> number>(width: number, height: number, scale=) -> number"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("sync-async: a function and an arrow const (.mjs)", { skip: tsSkip }, async () => {
|
|
104
|
+
const r = await ts("ts/sync-async");
|
|
105
|
+
assert.deepEqual(brief(r), ["sync-async:fetchUrl:sync>async", "sync-async:load:sync>async"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("generator-function: function* becomes a plain function (.js)", { skip: tsSkip }, async () => {
|
|
109
|
+
const r = await ts("ts/generator-function");
|
|
110
|
+
assert.deepEqual(brief(r), ["generator-function:pairs:generator>function"]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("removed exported symbol", { skip: tsSkip }, async () => {
|
|
114
|
+
const r = await ts("ts/removed");
|
|
115
|
+
assert.deepEqual(brief(r), ["removed:sub:sub>null"]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("renamed exported symbol", { skip: tsSkip }, async () => {
|
|
119
|
+
const r = await ts("ts/renamed");
|
|
120
|
+
assert.deepEqual(brief(r), ["renamed:slugify:slugify>toSlug"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("non-exported edit reports nothing", { skip: tsSkip }, async () => {
|
|
124
|
+
const r = await ts("ts/private-edit");
|
|
125
|
+
assert.deepEqual(r, { status: "none", changes: [], unknown: [] });
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("apishape: scope and unknown", () => {
|
|
130
|
+
it("a language without an indexer reports unknown", async () => {
|
|
131
|
+
const r = await detect(repo({ fixture: "other/go-change" }));
|
|
132
|
+
assert.equal(r.status, "unknown");
|
|
133
|
+
assert.deepEqual(r.unknown, [{ file: "x.go", lang: "go", reason: "unsupported-language" }]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("docs-only change, an added public file and a test-file signature change report nothing", async () => {
|
|
137
|
+
const r = await detect(repo({
|
|
138
|
+
base: { "README.md": "a\n", "tests/test_x.py": "def test_a(x):\n pass\n" },
|
|
139
|
+
head: { "README.md": "b\n", "tests/test_x.py": "def test_a(x, y):\n pass\n", "new.py": "def f():\n pass\n" },
|
|
140
|
+
}));
|
|
141
|
+
assert.deepEqual(r, { status: "none", changes: [], unknown: [] });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("a deleted file reports each public symbol as removed; a renamed file compares old vs new path", async () => {
|
|
145
|
+
const r = await detect(repo({
|
|
146
|
+
base: { "a.py": "def keep(x):\n pass\n\n\ndef _p():\n pass\n", "b.py": "def gone():\n pass\n" },
|
|
147
|
+
head: { "pkg/a.py": "def keep(x):\n pass\n\n\ndef _p():\n pass\n" },
|
|
148
|
+
}));
|
|
149
|
+
assert.deepEqual(r.changes.map((c) => `${c.file}:${c.change}:${c.symbol}`), ["b.py:removed:gone"]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("a head that does not parse reports unknown, public→private counts as removed", async () => {
|
|
153
|
+
const r = await detect(repo({ base: { "a.py": "def f():\n pass\n" }, head: { "a.py": "def f(:\n" } }));
|
|
154
|
+
assert.equal(r.status, "unknown");
|
|
155
|
+
assert.match(r.unknown[0].reason, /^parse-error/);
|
|
156
|
+
const p = compareIndexes("a.py", { symbols: [{ name: "f", kind: "function", public: true, sig: "()" }] },
|
|
157
|
+
{ symbols: [{ name: "f", kind: "function", public: false, sig: "()" }] });
|
|
158
|
+
assert.deepEqual(p.map((c) => c.change), ["removed"]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("classifyPath and parseNameStatus", () => {
|
|
162
|
+
assert.equal(classifyPath("src/a.py"), "python");
|
|
163
|
+
assert.equal(classifyPath("src/a.tsx"), "ts");
|
|
164
|
+
assert.equal(classifyPath("src/a.cjs"), "js");
|
|
165
|
+
assert.deepEqual(classifyPath("cmd/main.rs"), { unknown: "rust" });
|
|
166
|
+
for (const p of ["README.md", "x.json", "src/a.test.ts", "__tests__/b.js", "pkg/test_a.py", "a_test.py", "conftest.py", "types.d.ts"]) {
|
|
167
|
+
assert.equal(classifyPath(p), null, p);
|
|
168
|
+
}
|
|
169
|
+
assert.deepEqual(parseNameStatus("M\0a.py\0R100\0b.py\0c.py\0A\0d.py\0D\0e.py\0"), [
|
|
170
|
+
{ status: "M", before: "a.py", after: "a.py" },
|
|
171
|
+
{ status: "R", before: "b.py", after: "c.py" },
|
|
172
|
+
{ status: "A", before: null, after: "d.py" },
|
|
173
|
+
{ status: "D", before: "e.py", after: null },
|
|
174
|
+
]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("CLI: exit 2 on a bad ref, JSON status none on an empty diff", () => {
|
|
178
|
+
const dir = repo({ base: { "a.py": "def f():\n pass\n" }, head: { "a.py": "def f():\n pass\n" } });
|
|
179
|
+
const bad = spawnSync(process.execPath, [CLI, "nope...head"], { cwd: dir, encoding: "utf8" });
|
|
180
|
+
assert.equal(bad.status, 2);
|
|
181
|
+
assert.match(bad.stderr, /bad ref: nope/);
|
|
182
|
+
assert.equal(spawnSync(process.execPath, [CLI], { cwd: dir }).status, 2);
|
|
183
|
+
const ok = spawnSync(process.execPath, [CLI, "base", "head"], { cwd: dir, encoding: "utf8" });
|
|
184
|
+
assert.equal(ok.status, 0);
|
|
185
|
+
assert.deepEqual(JSON.parse(ok.stdout), { status: "none", changes: [], unknown: [] });
|
|
186
|
+
});
|
|
187
|
+
});
|
package/bin/lean/batch.mjs
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* A batch lives in `~/.forge/batches/<id>/` (or `FORGEDOCK_BATCH_DIR`): `plan.json` (the command), `state.json` (the
|
|
7
7
|
* scheduler state, rewritten after every transition), `log.jsonl` (one line per lane update), `parallel` (live K),
|
|
8
|
+
* `add` (issues added to the batch by `--add`, #4408: a cumulative JSON set the runner re-reads every pass),
|
|
9
|
+
* `cancel` (`{at, reason}` written by `--cancel`, #4376: the runner stops admitting and stops its local lanes; a resume clears it),
|
|
8
10
|
* `lanes/<issue>.{json,log}` (each lane's result and output) and `report.json` (the final rows and printed report, written
|
|
9
11
|
* when the scheduler loop returns; `forgedock orchestrate --resume` on a finished batch replays it, #4192). Every write is tmp + fsync + rename, and the root must be
|
|
10
12
|
* on disk, never tmpfs. A lane is its own session (`setsid` through `detached`), so killing the dispatcher leaves it
|
|
@@ -61,6 +63,11 @@ export function writeAtomic(file, data) {
|
|
|
61
63
|
|
|
62
64
|
const readJson = (file) => { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } };
|
|
63
65
|
|
|
66
|
+
const readAdded = (file) => {
|
|
67
|
+
const v = readJson(file);
|
|
68
|
+
return Array.isArray(v) ? [...new Set(v.filter((n) => Number.isSafeInteger(n) && n > 0))].sort((a, b) => a - b) : [];
|
|
69
|
+
};
|
|
70
|
+
|
|
64
71
|
export function newBatchId(now = Date.now(), pid = process.pid) {
|
|
65
72
|
return `b${new Date(now).toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15)}-${pid}`;
|
|
66
73
|
}
|
|
@@ -93,6 +100,31 @@ export function openBatch(root, id) {
|
|
|
93
100
|
if (!Number.isInteger(k) || k < 1) throw new Error("parallel must be a positive integer");
|
|
94
101
|
writeAtomic(f("parallel"), `${k}\n`);
|
|
95
102
|
},
|
|
103
|
+
/** Issues added by `--add` (#4408): the `add` file, sorted unique positive integers; [] when missing or invalid. */
|
|
104
|
+
readAdded: () => readAdded(f("add")),
|
|
105
|
+
/**
|
|
106
|
+
* Merge `list` into the `add` file. Not a plain positive integer: skipped `not an integer`; already in state.json
|
|
107
|
+
* `order`, in the file or earlier in `list`: skipped `already in batch`. @returns {{added:number[], skipped:Array<{issue, reason}>}}
|
|
108
|
+
*/
|
|
109
|
+
addIssues: (list) => {
|
|
110
|
+
const have = new Set([...(readJson(f("state.json"))?.order ?? []), ...readAdded(f("add"))]);
|
|
111
|
+
const added = [], skipped = [];
|
|
112
|
+
for (const x of Array.isArray(list) ? list : []) {
|
|
113
|
+
const n = typeof x === "number" ? x : typeof x === "string" && /^#?\d+$/.test(x.trim()) ? parseInt(x.trim().replace("#", ""), 10) : NaN;
|
|
114
|
+
if (!Number.isSafeInteger(n) || n < 1) { skipped.push({ issue: x, reason: "not an integer" }); continue; }
|
|
115
|
+
if (have.has(n)) { skipped.push({ issue: n, reason: "already in batch" }); continue; }
|
|
116
|
+
have.add(n); added.push(n);
|
|
117
|
+
}
|
|
118
|
+
if (added.length) writeAtomic(f("add"), JSON.stringify([...new Set([...readAdded(f("add")), ...added])].sort((a, b) => a - b)) + "\n");
|
|
119
|
+
return { added, skipped };
|
|
120
|
+
},
|
|
121
|
+
/** `--cancel` (#4376): `{at, reason}` in the `cancel` file; the runner re-reads it every pass. */
|
|
122
|
+
requestCancel: ({ reason = "operator" } = {}) => writeAtomic(f("cancel"), JSON.stringify({ at: new Date().toISOString(), reason: String(reason) }) + "\n"),
|
|
123
|
+
/** The cancel record, or null when missing or invalid. */
|
|
124
|
+
readCancel: () => { const v = readJson(f("cancel")); return v && typeof v === "object" && !Array.isArray(v) && typeof v.at === "string" ? v : null; },
|
|
125
|
+
clearCancel: () => rmSync(f("cancel"), { force: true }),
|
|
126
|
+
/** Added issues the batch never took into its `order` (a finished or unhosted batch with pending adds). */
|
|
127
|
+
unrunAdds: () => { const order = new Set(readJson(f("state.json"))?.order ?? []); return readAdded(f("add")).filter((n) => !order.has(n)); },
|
|
96
128
|
/** The lane updates in `log.jsonl`, in order (unparsable lines skipped). */
|
|
97
129
|
readLog: () => {
|
|
98
130
|
let text = "";
|