forgedock 1.35.0 → 1.37.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 +78 -324
- package/bin/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +161 -0
- package/bin/engine/events.test.mjs +142 -0
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +5 -4
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +372 -46
- package/bin/engine.mjs +3 -1
- package/bin/experiment.mjs +55 -0
- package/bin/forgedock.mjs +108 -5
- package/bin/hooks/reread-nudge.mjs +15 -4
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +52 -6
- package/bin/lean/batch.test.mjs +176 -2
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +136 -11
- package/bin/lean/conformance.test.mjs +148 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +32 -14
- package/bin/lean/execution.mjs +62 -16
- package/bin/lean/execution.test.mjs +59 -2
- package/bin/lean/gatewatch.mjs +31 -0
- package/bin/lean/gatewatch.test.mjs +59 -0
- package/bin/lean/guardgate.mjs +170 -0
- package/bin/lean/guardgate.test.mjs +153 -0
- package/bin/lean/guards.mjs +243 -0
- package/bin/lean/guards.test.mjs +177 -0
- package/bin/lean/heavy.mjs +54 -8
- package/bin/lean/heavy.test.mjs +74 -1
- package/bin/lean/hostkill.mjs +1 -1
- package/bin/lean/i18n.mjs +65 -0
- package/bin/lean/i18n.test.mjs +47 -0
- package/bin/lean/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lane.mjs +5 -2
- package/bin/lean/lean.mjs +169 -30
- package/bin/lean/lean.test.mjs +360 -24
- package/bin/lean/lifecycle.mjs +129 -0
- package/bin/lean/lifecycle.test.mjs +118 -0
- package/bin/lean/light.mjs +1 -1
- package/bin/lean/milestonesync.mjs +130 -0
- package/bin/lean/milestonesync.test.mjs +133 -0
- package/bin/lean/orchestrate.mjs +132 -20
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +181 -0
- package/bin/lean/postdeploy-check.test.mjs +206 -0
- package/bin/lean/postdeploy-monitor.mjs +97 -0
- package/bin/lean/postdeploy-monitor.test.mjs +147 -0
- package/bin/lean/precheck.mjs +6 -4
- package/bin/lean/precheck.test.mjs +17 -2
- package/bin/lean/prefilter.mjs +106 -21
- package/bin/lean/prefilter.test.mjs +86 -6
- package/bin/lean/probe.mjs +4 -3
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +23 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
- package/bin/lean/sandbox-remote.mjs +11 -4
- package/bin/lean/schedd.mjs +77 -13
- package/bin/lean/schedd.test.mjs +131 -5
- package/bin/lean/sequence.mjs +24 -0
- package/bin/lean/sequence.test.mjs +31 -1
- package/bin/lean/signoff.mjs +66 -0
- package/bin/lean/signoff.test.mjs +102 -0
- package/bin/lean/train.mjs +177 -0
- package/bin/lean/train.test.mjs +113 -0
- package/bin/pool/cli.mjs +2 -0
- package/bin/pool/health.mjs +30 -0
- package/bin/review-context.mjs +298 -0
- package/bin/runner.mjs +26 -3
- package/bin/stats/aggregate.mjs +1 -1
- package/bin/stats/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/experiment.mjs +44 -0
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/index.mjs +62 -3
- package/bin/stats/release.mjs +75 -0
- package/bin/stats/release.test.mjs +68 -0
- package/commands/bootstrap.md +10 -8
- package/commands/deploy-info.md +3 -0
- package/commands/issue.md +2 -0
- package/commands/milestone.md +43 -54
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/orchestrate.md +15 -3
- package/commands/pipeline-status.md +20 -0
- package/commands/review-pr.md +1 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build.md +4 -2
- package/commands/work-on/investigate.md +3 -1
- package/commands/work-on/remediate.md +1 -1
- package/docs/CONFIG.md +120 -2
- package/docs/EVENTS.md +65 -0
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +51 -0
- package/docs/TELEMETRY.md +3 -1
- package/docs/site/install.md +89 -0
- package/docs/site/start-a-new-project.md +1 -1
- package/docs/spec/label-state-machine.md +6 -0
- package/package.json +1 -1
- package/pool/install/install-agent.sh +4 -2
- package/pool/install/install-router.sh +4 -2
- package/pool/install/install-schedd.sh +28 -0
- package/pool/install/unit-hardening.sh +34 -0
- package/scripts/acceptance-red-green.sh +19 -2
- package/scripts/acceptance-red-green.test.sh +2 -0
- package/scripts/bench-lightlane.mjs +2 -1
- package/scripts/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/eval-gate-scorecard.mjs +7 -0
- package/scripts/file-deferred-followups.sh +286 -77
- package/scripts/file-deferred-followups.test.sh +117 -2
- package/scripts/hotspots.sh +23 -2
- package/scripts/hotspots.test.sh +9 -0
- package/scripts/plan-validate-live.mjs +111 -0
- package/scripts/readiness.mjs +141 -0
- package/scripts/review-prepare.sh +17 -1
- package/scripts/review-prepare.test.sh +17 -0
- package/scripts/settle-scope.mjs +74 -0
- package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
- package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
- package/scripts/wait-ci-green.sh +14 -3
- package/scripts/wait-ci-green.test.sh +3 -1
- package/templates/bootstrap/needs-human-issue.md +4 -1
- package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { oldRanges, selectGuards, runGuards, commandFor, basePathOf, guardFailures, supersessionComment, supersededIds, loadGuards } from "./guardgate.mjs";
|
|
9
|
+
import { applyAmendments, precheck } from "./conformance.mjs";
|
|
10
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
11
|
+
|
|
12
|
+
const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
|
|
13
|
+
const gitIn = (cwd) => async (args) => sh(cwd, ...args);
|
|
14
|
+
const { NODE_TEST_CONTEXT: _ctx, ...CLEAN_ENV } = process.env; // the fixture runs node --test inside a test
|
|
15
|
+
const runCommand = async ({ command, cwd }) => { const r = spawnSync("sh", ["-c", command], { cwd, encoding: "utf8", env: CLEAN_ENV }); return { rc: r.status ?? 1, output: `${r.stdout}${r.stderr}` }; };
|
|
16
|
+
const put = (cwd, p, body) => { mkdirSync(join(cwd, p, ".."), { recursive: true }); writeFileSync(join(cwd, p), body); };
|
|
17
|
+
|
|
18
|
+
const TEST_SRC = (v) => `import test from "node:test";\nimport assert from "node:assert/strict";\nimport { price } from "./a.mjs";\ntest("price is ${v}", () => assert.equal(price(), ${v}));\n`;
|
|
19
|
+
|
|
20
|
+
/** A repo whose fix commit (merge_sha) made price() return 2, guarded by src/a.test.mjs; the lane starts from it. */
|
|
21
|
+
function fixture() {
|
|
22
|
+
const d = tmpDir("guardgate-");
|
|
23
|
+
sh(d, "init", "-q", "-b", "main");
|
|
24
|
+
put(d, "src/a.mjs", "export function price() {\n return 1;\n}\n");
|
|
25
|
+
sh(d, "add", "-A"); sh(d, "commit", "-qm", "init");
|
|
26
|
+
put(d, "src/a.mjs", "export function price() {\n return 2;\n}\n");
|
|
27
|
+
put(d, "src/a.test.mjs", TEST_SRC(2));
|
|
28
|
+
sh(d, "add", "-A"); sh(d, "commit", "-qm", "fix: price");
|
|
29
|
+
const sha = sh(d, "rev-parse", "HEAD").trim();
|
|
30
|
+
put(d, "src/other.mjs", "export const x = 1;\n");
|
|
31
|
+
sh(d, "add", "-A"); sh(d, "commit", "-qm", "later");
|
|
32
|
+
sh(d, "update-ref", "refs/remotes/origin/main", "HEAD");
|
|
33
|
+
sh(d, "checkout", "-qb", "lane");
|
|
34
|
+
const guard = { v: 1, issue: 7, pr: 9, merge_sha: sha, tests: [{ path: "src/a.test.mjs", ids: [] }], hunks: [{ file: "src/a.mjs", start: 2, end: 2, symbol: "price" }], why: "price must be 2" };
|
|
35
|
+
return { d, guard };
|
|
36
|
+
}
|
|
37
|
+
const commit = (d, m) => { sh(d, "add", "-A"); sh(d, "commit", "-qm", m); };
|
|
38
|
+
const files = (d) => sh(d, "diff", "--name-only", "origin/main...HEAD").split("\n").filter(Boolean);
|
|
39
|
+
const gate = async (d, guard, contract = {}) => {
|
|
40
|
+
const git = gitIn(d);
|
|
41
|
+
const { selected, mergeBase } = await selectGuards({ guards: [guard], files: files(d), git, base: "origin/main" });
|
|
42
|
+
const r = selected.length ? await runGuards({ selected, mergeBase, contract, git, runCommand, cwd: d, env: process.env }) : null;
|
|
43
|
+
return { selected, r };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
test("a lane that reverts a guarded fix fails guard_broken even when it also edits the guard's test to pass", async () => {
|
|
47
|
+
const { d, guard } = fixture();
|
|
48
|
+
put(d, "src/a.mjs", "export function price() {\n return 1;\n}\n"); // reverts the fix
|
|
49
|
+
put(d, "src/a.test.mjs", TEST_SRC(1)); // and weakens the test so the lane's own copy passes
|
|
50
|
+
commit(d, "revert");
|
|
51
|
+
assert.equal((await runCommand({ command: "node --test src/a.test.mjs", cwd: d })).rc, 0, "the lane's own edited test passes");
|
|
52
|
+
const { selected, r } = await gate(d, guard);
|
|
53
|
+
assert.equal(selected.length, 1);
|
|
54
|
+
assert.match(selected[0].reason, /src\/a\.mjs/);
|
|
55
|
+
assert.equal(r.failures.length, 1, JSON.stringify({ runs: r.runs, skipped: r.skipped }));
|
|
56
|
+
assert.equal(r.failures[0].code, "conformance.guard_broken");
|
|
57
|
+
assert.equal(r.failures[0].guard, "#7");
|
|
58
|
+
assert.match(r.failures[0].detail, /price must be 2/);
|
|
59
|
+
assert.match(r.failures[0].detail, /src\/a\.test\.mjs/);
|
|
60
|
+
assert.deepEqual(readdirSync(join(d, "src")).filter((f) => f.includes("__guardbase")), [], "base copies are removed");
|
|
61
|
+
const pc = precheck({ contract: { requirements: [], deliverables: [{ id: "D1", path: "src/a.mjs" }, { id: "D2", path: "src/a.test.mjs" }], validate: [] }, result: {}, changedFiles: files(d), guardFailures: r.failures });
|
|
62
|
+
assert.deepEqual(pc.failures.filter((f) => f.kind === "guard_broken").map((f) => f.guard), ["#7"]);
|
|
63
|
+
assert.equal(pc.ok, false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("an accepted supersedes amendment passes the precheck and names the superseded guard for the issue comment", async () => {
|
|
67
|
+
const { d, guard } = fixture();
|
|
68
|
+
put(d, "src/a.mjs", "export function price() {\n return 3;\n}\n");
|
|
69
|
+
commit(d, "change");
|
|
70
|
+
const { selected, r } = await gate(d, guard);
|
|
71
|
+
assert.equal(r.failures.length, 1);
|
|
72
|
+
const frozen = { requirements: [], deliverables: [{ id: "D1", path: "src/a.mjs" }], validate: [] };
|
|
73
|
+
const a = applyAmendments(frozen, [{ kind: "supersedes", guard: "#7", why: "price is now 3 by product decision" }]);
|
|
74
|
+
assert.deepEqual(a.problems, []);
|
|
75
|
+
assert.deepEqual(a.applied, [{ kind: "supersedes", target: "#7", why: "price is now 3 by product decision" }]);
|
|
76
|
+
const pc = precheck({ contract: a.contract, result: {}, changedFiles: ["src/a.mjs"], guardFailures: r.failures });
|
|
77
|
+
assert.equal(pc.ok, true, JSON.stringify(pc.failures));
|
|
78
|
+
const sup = supersededIds(a.contract, selected);
|
|
79
|
+
assert.deepEqual(sup.map((s) => s.guard), ["#7"]);
|
|
80
|
+
assert.match(supersessionComment({ guard: "#7", pr: 12, why: sup[0].why }), /#7 was superseded by PR #12: price is now 3/);
|
|
81
|
+
assert.deepEqual(supersededIds(a.contract, []), [], "only selected guards are retired");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a supersedes amendment without a guard id is rejected", () => {
|
|
85
|
+
const a = applyAmendments({ requirements: [], deliverables: [] }, [{ kind: "supersedes", why: "x" }, { kind: "supersedes", guard: "abc", why: "x" }]);
|
|
86
|
+
assert.equal(a.problems.length, 2);
|
|
87
|
+
assert.match(a.problems[0], /needs guard/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a lane touching no guarded hunk selects nothing and runs no git or test", async () => {
|
|
91
|
+
const { d, guard } = fixture();
|
|
92
|
+
put(d, "src/other.mjs", "export const x = 2;\n");
|
|
93
|
+
commit(d, "unrelated");
|
|
94
|
+
const calls = [];
|
|
95
|
+
const git = async (a) => { calls.push(a[0]); return gitIn(d)(a); };
|
|
96
|
+
const r = await selectGuards({ guards: [guard], files: files(d), git, base: "origin/main" });
|
|
97
|
+
assert.deepEqual(r.selected, []);
|
|
98
|
+
assert.deepEqual(calls, [], "no git call at all");
|
|
99
|
+
// same file as the guard but lines far from the hunk, with the guard's commit not the blamed one
|
|
100
|
+
const big = { ...guard, hunks: [{ file: "src/a.mjs", start: 200, end: 210 }] };
|
|
101
|
+
assert.deepEqual((await selectGuards({ guards: [big], files: files(d).concat("src/a.mjs"), git: gitIn(d), base: "origin/main" })).selected, []);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("deleting or editing a guard's test selects it even when no guarded line changed", async () => {
|
|
105
|
+
const { d, guard } = fixture();
|
|
106
|
+
sh(d, "rm", "-q", "src/a.test.mjs");
|
|
107
|
+
commit(d, "drop test");
|
|
108
|
+
const { selected, r } = await gate(d, guard);
|
|
109
|
+
assert.equal(selected.length, 1);
|
|
110
|
+
assert.match(selected[0].reason, /edits or deletes its test/);
|
|
111
|
+
assert.deepEqual(r.failures, [], "the base test still passes against the unchanged code");
|
|
112
|
+
assert.equal(r.runs.length, 1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a base test that cannot run in the cap, or has no known runner, is skipped and never blocks", async () => {
|
|
116
|
+
const { d, guard } = fixture();
|
|
117
|
+
put(d, "src/a.mjs", "export function price() {\n return 1;\n}\n");
|
|
118
|
+
commit(d, "revert");
|
|
119
|
+
const git = gitIn(d);
|
|
120
|
+
const { selected, mergeBase } = await selectGuards({ guards: [guard], files: files(d), git, base: "origin/main" });
|
|
121
|
+
const slow = await runGuards({ selected, mergeBase, contract: {}, git, runCommand: async () => ({ rc: 124, output: "" }), cwd: d, env: {} });
|
|
122
|
+
assert.deepEqual(slow.failures, []);
|
|
123
|
+
assert.match(slow.skipped[0], /guard cap/);
|
|
124
|
+
const none = await runGuards({ selected: [{ guard: { ...guard, tests: [{ path: "src/a.test.rb" }] }, reason: "" }], mergeBase, contract: {}, git, runCommand, cwd: d, env: {} });
|
|
125
|
+
assert.match(none.skipped[0], /no runner/);
|
|
126
|
+
assert.equal(existsSync(join(d, basePathOf("src/a.test.mjs"))), false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("pytest-style guards run through the contract's own validate command with the path substituted", () => {
|
|
130
|
+
assert.equal(commandFor("tests/test_a__guardbase.py", { validate: [{ command: "pytest -q tests/test_a.py" }] }), "pytest -q tests/test_a__guardbase.py");
|
|
131
|
+
assert.equal(commandFor("tests/test_a__guardbase.py", {}), "python -m pytest -q tests/test_a__guardbase.py");
|
|
132
|
+
assert.equal(commandFor("src/a.test__guardbase.mjs", { validate: [{ command: "node --test src/a.test.mjs -t x" }] }), "node --test src/a.test__guardbase.mjs");
|
|
133
|
+
assert.equal(commandFor("a.rb", {}), null);
|
|
134
|
+
assert.equal(basePathOf("src/a.test.mjs"), "src/a.test__guardbase.mjs");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("oldRanges reads old-side lines of non-test files and skips pure insertions", () => {
|
|
138
|
+
const diff = "--- a/src/a.mjs\n+++ b/src/a.mjs\n@@ -3,2 +3 @@\n-a\n-b\n+c\n@@ -9,0 +9,2 @@\n+x\n+y\n@@ -20 +19,0 @@\n-z\n--- a/src/a.test.mjs\n+++ b/src/a.test.mjs\n@@ -1 +1 @@\n";
|
|
139
|
+
assert.deepEqual(oldRanges(diff), { "src/a.mjs": [[3, 4], [20, 20]] });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("guardFailures keeps only failing runs and loadGuards falls back to a stale index when GitHub is down", async () => {
|
|
143
|
+
assert.deepEqual(guardFailures([{ issue: 1, rc: 0 }]), []);
|
|
144
|
+
const root = tmpDir("guardgate-idx-");
|
|
145
|
+
const fs = await import("node:fs");
|
|
146
|
+
const { writeIndex } = await import("./guards.mjs");
|
|
147
|
+
writeIndex("o/r", [{ issue: 3 }], { root });
|
|
148
|
+
const old = new Date(Date.now() - 48 * 3600000);
|
|
149
|
+
fs.utimesSync(join(root, "o_r.json"), old, old);
|
|
150
|
+
const down = await loadGuards({ repo: "o/r", gh: async () => { throw new Error("offline"); }, root });
|
|
151
|
+
assert.deepEqual(down, [{ issue: 3 }]);
|
|
152
|
+
assert.deepEqual(await loadGuards({ repo: "o/none", gh: async () => "[]", root }), []);
|
|
153
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Fix guards (#4692): a merged lane whose frozen contract names a regression/repro test records a guard on its issue as
|
|
5
|
+
* `<!-- FORGE:GUARD {json} -->`: {v, issue, pr, merge_sha, tests:[{path, ids}], hunks:[{file, start, end, symbol}], why}.
|
|
6
|
+
* GitHub is the register; `~/.forge/guards/<repo>.json` is a cache rebuilt from those comments. Codebase-agnostic and
|
|
7
|
+
* deterministic (0 LLM calls): test identity comes from the contract's repro_test and validate commands, never from a
|
|
8
|
+
* language parser; without per-test ids a guard is file-granular (ids: []). Protected hunks come from the merged diff
|
|
9
|
+
* against the merge commit's first parent, which is the base for both merge-commit and squash merges.
|
|
10
|
+
*/
|
|
11
|
+
import * as nodeFs from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { isTestPath } from "./enumerating.mjs";
|
|
15
|
+
import { parseContractComment } from "./render.mjs";
|
|
16
|
+
|
|
17
|
+
export const GUARD_MARKER = "FORGE:GUARD";
|
|
18
|
+
const GUARD_RE = /<!-- FORGE:GUARD (\{[\s\S]*?\}) -->/;
|
|
19
|
+
const PATHISH = /^[\w@.+/-]+\.[A-Za-z0-9]+$/;
|
|
20
|
+
const hasMarker = (b) => GUARD_RE.test(String(b ?? ""));
|
|
21
|
+
|
|
22
|
+
export const guardsIndexPath = (repo, root = join(homedir(), ".forge", "guards")) => join(root, `${String(repo).replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
|
|
23
|
+
|
|
24
|
+
/** Test ids a validate/repro command names: `-k expr`, `--test-name-pattern x`, `-t x`, `--grep x`, and `file::name` pytest ids. */
|
|
25
|
+
function idsOf(command, path) {
|
|
26
|
+
const toks = String(command ?? "").match(/(?:[^\s"']|"[^"]*"|'[^']*')+/g) ?? [];
|
|
27
|
+
const unq = (t) => t.replace(/^(["'])(.*)\1$/, "$2");
|
|
28
|
+
const ids = [];
|
|
29
|
+
toks.forEach((t, i) => {
|
|
30
|
+
const v = unq(t);
|
|
31
|
+
if (/^(?:-k|-t|--grep|--test-name-pattern|--testNamePattern)$/.test(v) && toks[i + 1]) ids.push(unq(toks[i + 1]));
|
|
32
|
+
else if (/^--(?:k|grep|test-name-pattern|testNamePattern)=/.test(v)) ids.push(unq(v.split("=").slice(1).join("=")));
|
|
33
|
+
else if (v.startsWith(`${path}::`)) ids.push(v.slice(path.length + 2));
|
|
34
|
+
});
|
|
35
|
+
return ids;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Test files (and ids) from the contract's repro_test and validate commands. [] when the contract names no test. */
|
|
39
|
+
export function guardTests(contract) {
|
|
40
|
+
const by = new Map();
|
|
41
|
+
const add = (path, ids) => { const e = by.get(path) ?? new Set(); ids.filter(Boolean).forEach((i) => e.add(i)); by.set(path, e); };
|
|
42
|
+
const t = contract?.repro_test;
|
|
43
|
+
const cmds = [];
|
|
44
|
+
if (t && typeof t === "object") {
|
|
45
|
+
if (t.path) add(String(t.path), t.id ? [String(t.id)] : []);
|
|
46
|
+
cmds.push(t.command);
|
|
47
|
+
}
|
|
48
|
+
for (const v of contract?.validate ?? []) cmds.push(v?.command);
|
|
49
|
+
for (const c of cmds) {
|
|
50
|
+
for (const tok of String(c ?? "").match(/[^\s"']+/g) ?? []) {
|
|
51
|
+
const p = tok.split("::")[0];
|
|
52
|
+
if (PATHISH.test(p) && isTestPath(p)) add(p, idsOf(c, p));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (t && typeof t === "object" && t.path) add(String(t.path), []);
|
|
56
|
+
return [...by].map(([path, ids]) => ({ path, ids: [...ids] }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Hunks of a `git diff -U0` text for non-test files: [{file, start, end, symbol}] in post-image lines (deletions anchor at the line after). */
|
|
60
|
+
export function parseHunks(diff) {
|
|
61
|
+
const out = [];
|
|
62
|
+
let file = null;
|
|
63
|
+
for (const line of String(diff ?? "").split("\n")) {
|
|
64
|
+
if (line.startsWith("+++ ")) { file = line === "+++ /dev/null" ? null : line.replace(/^\+\+\+ b\//, ""); continue; }
|
|
65
|
+
if (line.startsWith("--- ")) { if (line === "--- /dev/null") file = null; continue; }
|
|
66
|
+
const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/.exec(line);
|
|
67
|
+
if (!m || !file || isTestPath(file)) continue;
|
|
68
|
+
const delOnly = m[4] === "0", start = Number(m[3]) + (delOnly ? 1 : 0), len = m[4] === undefined ? 1 : Number(m[4]);
|
|
69
|
+
out.push({ file, start, end: delOnly ? start : start + len - 1, symbol: m[5].trim().slice(0, 120) || null });
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The guard for a merged lane, or {skipped, reason} when its contract names no regression/repro test. */
|
|
75
|
+
export function buildGuard({ issue, title = "", pr = null, mergeSha = null, contract, diff = "" }) {
|
|
76
|
+
const tests = guardTests(contract);
|
|
77
|
+
if (!tests.length) {
|
|
78
|
+
const why = contract?.repro_test === "none" ? `repro_test none (${String(contract.repro_test_reason ?? "").slice(0, 120)})` : "contract names no test file";
|
|
79
|
+
return { skipped: true, reason: `no guard for #${issue}: ${why}` };
|
|
80
|
+
}
|
|
81
|
+
return { guard: { v: 1, issue, pr, merge_sha: mergeSha, tests, hunks: parseHunks(diff), why: String(title).replace(/\s+/g, " ").trim().slice(0, 200) } };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const renderGuard = (g) => `<!-- ${GUARD_MARKER} ${JSON.stringify(g).replace(/</g, "\\u003c").replace(/>/g, "\\u003e")} -->`;
|
|
85
|
+
export function parseGuard(body) {
|
|
86
|
+
const m = GUARD_RE.exec(String(body ?? ""));
|
|
87
|
+
if (!m) return null;
|
|
88
|
+
try { const g = JSON.parse(m[1]); return g && g.v === 1 && Number.isInteger(g.issue) ? g : null; } catch { return null; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const json = (s, d) => { try { return JSON.parse(String(s) || "null") ?? d; } catch { return d; } };
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Build the guard for one merged issue from GitHub alone (its FORGE:CONTRACT comment, merged PR and merge commit) and post it
|
|
95
|
+
* once. Idempotent: an issue that already has a FORGE:GUARD comment is left alone. Returns {posted|exists|skipped, ...}.
|
|
96
|
+
*/
|
|
97
|
+
export async function guardIssue({ gh, git, repo, issue, pr = null, post = null, dryRun = false }) {
|
|
98
|
+
const iss = json(await gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,comments"]), null);
|
|
99
|
+
if (!iss) return { skipped: true, reason: `#${issue}: issue not readable` };
|
|
100
|
+
const bodies = (iss.comments ?? []).map((c) => c?.body ?? "");
|
|
101
|
+
const existing = bodies.map(parseGuard).find(Boolean);
|
|
102
|
+
if (existing) return { exists: true, guard: existing };
|
|
103
|
+
const cb = [...bodies].reverse().find((b) => b.startsWith("<!-- FORGE:CONTRACT -->") && parseContractComment(b));
|
|
104
|
+
if (!cb) return { skipped: true, reason: `no guard for #${issue}: no frozen FORGE:CONTRACT comment` };
|
|
105
|
+
const contract = parseContractComment(cb).contract;
|
|
106
|
+
let prn = pr, sha = null;
|
|
107
|
+
const view = prn
|
|
108
|
+
? json(await gh(["pr", "view", String(prn), "-R", repo, "--json", "number,state,mergeCommit"]), null)
|
|
109
|
+
: json(await gh(["pr", "list", "-R", repo, "--state", "merged", "--search", `#${issue} in:body`, "--limit", "5", "--json", "number,mergeCommit,body"]), []).find((p) => new RegExp(`(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s+#${issue}\\b`, "i").test(p.body ?? "")) ?? null;
|
|
110
|
+
if (view) { prn = view.number ?? prn; sha = view.mergeCommit?.oid ?? null; }
|
|
111
|
+
if (!sha) return { skipped: true, reason: `no guard for #${issue}: no merged PR with a merge commit` };
|
|
112
|
+
let diff = "";
|
|
113
|
+
const getDiff = async () => String(await git(["diff", "-U0", "--no-color", "--no-renames", `${sha}^1`, sha]));
|
|
114
|
+
try {
|
|
115
|
+
try { diff = await getDiff(); } catch { await git(["fetch", "origin", sha]); diff = await getDiff(); } // merge commit not local yet: fetch it by sha
|
|
116
|
+
} catch (e) {
|
|
117
|
+
return { skipped: true, reason: `no guard for #${issue}: merge commit ${sha.slice(0, 10)} not available locally (${String(e?.message ?? e).split("\n")[0].slice(0, 100)})` };
|
|
118
|
+
}
|
|
119
|
+
const built = buildGuard({ issue, title: iss.title, pr: prn, mergeSha: sha, contract, diff });
|
|
120
|
+
if (built.skipped) return built;
|
|
121
|
+
if (!dryRun) await (post ?? ((b) => gh(["issue", "comment", String(issue), "-R", repo, "--body", b])))(renderGuard(built.guard));
|
|
122
|
+
return { posted: true, guard: built.guard };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Guards read from a repo's issue comments (the register of record). */
|
|
126
|
+
export async function collectGuards({ gh, repo, limit = 500 }) {
|
|
127
|
+
const rows = json(await gh(["issue", "list", "-R", repo, "--state", "all", "--search", `${GUARD_MARKER} in:comments`, "--limit", String(limit), "--json", "number,comments"]), []);
|
|
128
|
+
const guards = [];
|
|
129
|
+
for (const r of rows) for (const c of r.comments ?? []) { const g = hasMarker(c?.body) ? parseGuard(c.body) : null; if (g) { guards.push(g); break; } }
|
|
130
|
+
return guards;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Rewrite the local cache from the guards found on GitHub. */
|
|
134
|
+
export function writeIndex(repo, guards, { root, fs = nodeFs } = {}) {
|
|
135
|
+
const p = guardsIndexPath(repo, root);
|
|
136
|
+
fs.mkdirSync(dirname(p), { recursive: true });
|
|
137
|
+
fs.writeFileSync(p, JSON.stringify({ v: 1, repo, guards }, null, 1));
|
|
138
|
+
return p;
|
|
139
|
+
}
|
|
140
|
+
export function readIndex(repo, { root, fs = nodeFs } = {}) {
|
|
141
|
+
try { const j = JSON.parse(fs.readFileSync(guardsIndexPath(repo, root), "utf8")); return Array.isArray(j?.guards) ? j.guards : null; } catch { return null; }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Guards covering `path` (a protected hunk file or a guarding test file; a directory prefix matches too). No path: all. */
|
|
145
|
+
export function listGuards(guards, { path = null } = {}) {
|
|
146
|
+
if (!path) return guards;
|
|
147
|
+
const p = String(path).replace(/^\.\//, "").replace(/\/$/, "");
|
|
148
|
+
const hit = (f) => f === p || f.startsWith(`${p}/`);
|
|
149
|
+
return guards.filter((g) => (g.hunks ?? []).some((h) => hit(h.file)) || (g.tests ?? []).some((t) => hit(t.path)));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const H_ROW = /^H\d+$/;
|
|
153
|
+
const dirOf = (f) => String(f).split("/").slice(0, -1).join("/");
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* History preserve rows (#4694): at most `cap` `{id:"H<n>", what, source:"guard"}` rows for the guards covering the deliverable
|
|
157
|
+
* paths. Pure and deterministic: score 3 = a protected hunk file is a deliverable, 2 = a guarding test file is, 1 = either sits
|
|
158
|
+
* under a deliverable's directory; ties go to the newest issue. The lane's own issue is skipped.
|
|
159
|
+
*/
|
|
160
|
+
export function historyPreserve({ guards, deliverables, issue = null, cap = 5 }) {
|
|
161
|
+
const paths = [...new Set((deliverables ?? []).map((d) => String(d?.path ?? d ?? "").replace(/^\.\//, "")).filter(Boolean))];
|
|
162
|
+
if (!paths.length || !Array.isArray(guards) || !guards.length) return [];
|
|
163
|
+
const dirs = paths.map(dirOf).filter(Boolean);
|
|
164
|
+
const under = (f) => dirs.some((d) => f.startsWith(`${d}/`));
|
|
165
|
+
const scored = [];
|
|
166
|
+
for (const g of guards) {
|
|
167
|
+
if (!g || g.issue == null || String(g.issue) === String(issue)) continue;
|
|
168
|
+
const hunks = (g.hunks ?? []).map((h) => String(h.file)), tests = (g.tests ?? []).map((t) => String(t.path));
|
|
169
|
+
const score = hunks.some((f) => paths.includes(f)) ? 3 : tests.some((f) => paths.includes(f)) ? 2 : [...hunks, ...tests].some(under) ? 1 : 0;
|
|
170
|
+
if (score) scored.push({ g, score });
|
|
171
|
+
}
|
|
172
|
+
scored.sort((a, b) => b.score - a.score || Number(b.g.issue) - Number(a.g.issue) || (Number(a.g.pr) || 0) - (Number(b.g.pr) || 0));
|
|
173
|
+
const seen = new Set();
|
|
174
|
+
return scored.filter(({ g }) => !seen.has(String(g.issue)) && seen.add(String(g.issue))).slice(0, cap).map(({ g }, i) => ({
|
|
175
|
+
id: `H${i + 1}`,
|
|
176
|
+
what: `${String(g.why ?? "").replace(/\s+/g, " ").trim().slice(0, 160) || "guarded fix"} (#${g.issue}${g.pr ? `, PR #${g.pr}` : ""})`,
|
|
177
|
+
source: "guard",
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The contract with H-rows from the local guards index appended to preserve; the same object when nothing applies. Never throws. */
|
|
182
|
+
export function withHistoryRows(contract, { repo, issue, root, fs = nodeFs } = {}) {
|
|
183
|
+
try {
|
|
184
|
+
const guards = readIndex(repo, { root, fs });
|
|
185
|
+
if (!guards?.length) return contract;
|
|
186
|
+
const rows = historyPreserve({ guards, deliverables: contract?.deliverables, issue });
|
|
187
|
+
if (!rows.length) return contract;
|
|
188
|
+
return { ...contract, preserve: [...(contract.preserve ?? []).filter((p) => !(H_ROW.test(p?.id) && p?.source === "guard")), ...rows] };
|
|
189
|
+
} catch { return contract; }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Backfill guards for issues closed since `since` (ISO date) that ran through the lean lane (a FORGE:CONTRACT comment and a
|
|
194
|
+
* merged PR). Lanes without a test yield no guard; the reason is reported in `logs`. Idempotent.
|
|
195
|
+
*/
|
|
196
|
+
export async function backfill({ gh, git, repo, since, dryRun = false, log = () => {} }) {
|
|
197
|
+
const rows = json(await gh(["issue", "list", "-R", repo, "--state", "closed", "--search", `closed:>=${since}`, "--limit", "500", "--json", "number"]), []);
|
|
198
|
+
const res = { posted: [], exists: [], skipped: [] };
|
|
199
|
+
for (const { number } of rows) {
|
|
200
|
+
let r;
|
|
201
|
+
try { r = await guardIssue({ gh, git, repo, issue: number, dryRun }); } catch (e) { r = { skipped: true, reason: `#${number}: ${String(e?.message ?? e).split("\n")[0].slice(0, 160)}` }; }
|
|
202
|
+
if (r.posted) res.posted.push(number); else if (r.exists) res.exists.push(number);
|
|
203
|
+
else { res.skipped.push({ issue: number, reason: r.reason }); log(r.reason); }
|
|
204
|
+
}
|
|
205
|
+
return res;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Lane hook: after a merge, fetch the base so the merge commit is local, then guard the issue. Best effort: never throws. */
|
|
209
|
+
export async function guardAfterMerge({ io, repo, issue, pr, cwd, base = null, root }) {
|
|
210
|
+
try {
|
|
211
|
+
const branch = base ? String(base).replace(/^(?:refs\/remotes\/)?origin\//, "") : null; // lean.mjs passes `origin/<lane>`
|
|
212
|
+
if (branch) { try { await io.git(["fetch", "origin", branch], { cwd }); } catch { /* offline: use the local ref */ } }
|
|
213
|
+
const r = await guardIssue({ gh: (a) => io.gh(a), git: (a) => io.git(a, { cwd }), repo, issue, pr });
|
|
214
|
+
if (r.posted) { // #4694: keep the local cache current so the next lane's freeze sees this guard; only extend an existing index
|
|
215
|
+
try { const cur = readIndex(repo, { root }); if (cur) writeIndex(repo, [...cur.filter((g) => g.issue !== r.guard.issue), r.guard], { root }); } catch { /* cache only */ }
|
|
216
|
+
}
|
|
217
|
+
return r;
|
|
218
|
+
} catch (e) { return { skipped: true, reason: `guard failed: ${String(e?.message ?? e).split("\n")[0].slice(0, 160)}` }; }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** `forgedock guards list [--path p] [--json] | backfill --since <date> [--dry-run]`. Returns the exit code. */
|
|
222
|
+
export async function guardsCli(argv, { gh, git, repo, print = console.log, root, fs = nodeFs } = {}) {
|
|
223
|
+
const sub = argv[0];
|
|
224
|
+
const flag = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : null; };
|
|
225
|
+
if (sub === "list") {
|
|
226
|
+
let guards = null;
|
|
227
|
+
try { guards = await collectGuards({ gh, repo }); writeIndex(repo, guards, { root, fs }); } catch { guards = readIndex(repo, { root, fs }); }
|
|
228
|
+
if (!guards) throw new Error("guards list: GitHub is unreachable and there is no local index");
|
|
229
|
+
const hits = listGuards(guards, { path: flag("--path") });
|
|
230
|
+
if (argv.includes("--json")) print(JSON.stringify(hits, null, 1));
|
|
231
|
+
else if (!hits.length) print("No guards" + (flag("--path") ? ` cover ${flag("--path")}` : "") + ".");
|
|
232
|
+
else for (const g of hits) print(`#${g.issue} (PR ${g.pr ?? "?"}, ${String(g.merge_sha ?? "").slice(0, 10)}): ${g.why}\n tests: ${g.tests.map((t) => t.path + (t.ids.length ? ` [${t.ids.join(", ")}]` : "")).join(", ")}\n hunks: ${(g.hunks ?? []).map((h) => `${h.file}:${h.start}-${h.end}${h.symbol ? ` (${h.symbol})` : ""}`).join(", ") || "none"}`);
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
if (sub === "backfill") {
|
|
236
|
+
const since = flag("--since");
|
|
237
|
+
if (!since || Number.isNaN(Date.parse(since))) throw new Error("usage: forgedock guards backfill --since <YYYY-MM-DD> [--dry-run]");
|
|
238
|
+
const r = await backfill({ gh, git, repo, since, dryRun: argv.includes("--dry-run"), log: (m) => print(m) });
|
|
239
|
+
print(`guards backfill: ${r.posted.length} ${argv.includes("--dry-run") ? "would be posted" : "posted"}, ${r.exists.length} already guarded, ${r.skipped.length} skipped`);
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
throw new Error("usage: forgedock guards list [--path p] [--json] | backfill --since <date> [--dry-run]");
|
|
243
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { buildGuard, guardTests, parseHunks, renderGuard, parseGuard, guardIssue, listGuards, backfill, guardsCli, readIndex, guardAfterMerge, historyPreserve, withHistoryRows, writeIndex } from "./guards.mjs";
|
|
6
|
+
import { renderContract } from "./render.mjs";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
+
|
|
9
|
+
const DIFF = `diff --git a/src/a.mjs b/src/a.mjs
|
|
10
|
+
--- a/src/a.mjs
|
|
11
|
+
+++ b/src/a.mjs
|
|
12
|
+
@@ -10,2 +10,3 @@ export function fixed() {
|
|
13
|
+
-x
|
|
14
|
+
+y
|
|
15
|
+
+z
|
|
16
|
+
@@ -30 +31,0 @@ function other() {
|
|
17
|
+
-gone
|
|
18
|
+
diff --git a/test/a.test.mjs b/test/a.test.mjs
|
|
19
|
+
--- a/test/a.test.mjs
|
|
20
|
+
+++ b/test/a.test.mjs
|
|
21
|
+
@@ -1,0 +2,3 @@
|
|
22
|
+
+t
|
|
23
|
+
`;
|
|
24
|
+
const nodeContract = { repro_test: { path: "test/a.test.mjs", id: "fixed keeps x", command: "node --test test/a.test.mjs" }, validate: [{ id: "V1", command: "node --test test/a.test.mjs --test-name-pattern=\"fixed keeps x\"" }] };
|
|
25
|
+
const pyContract = { repro_test: { path: "tests/test_a.py", command: "pytest -q tests/test_a.py::test_fixed" }, validate: [{ id: "V1", command: "pytest -q tests/test_a.py -k fixed" }, { id: "V2", command: "pytest -q tests/test_b.py" }] };
|
|
26
|
+
|
|
27
|
+
test("node --test fixture: file and id, hunks exclude test files with symbol and deletion anchor", () => {
|
|
28
|
+
const { guard } = buildGuard({ issue: 7, title: "Fix x\nbug", pr: 9, mergeSha: "abc", contract: nodeContract, diff: DIFF });
|
|
29
|
+
assert.deepEqual(guard.tests, [{ path: "test/a.test.mjs", ids: ["fixed keeps x"] }]);
|
|
30
|
+
assert.deepEqual(guard.hunks, [{ file: "src/a.mjs", start: 10, end: 12, symbol: "export function fixed() {" }, { file: "src/a.mjs", start: 32, end: 32, symbol: "function other() {" }]);
|
|
31
|
+
assert.equal(guard.why, "Fix x bug");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("pytest fixture: ::ids and -k, every test file named", () => {
|
|
35
|
+
const t = guardTests(pyContract);
|
|
36
|
+
assert.deepEqual(t.find((x) => x.path === "tests/test_a.py").ids.sort(), ["fixed", "test_fixed"]);
|
|
37
|
+
assert.deepEqual(t.find((x) => x.path === "tests/test_b.py").ids, []); // file granularity
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a contract without a test yields no guard and says why", () => {
|
|
41
|
+
assert.match(buildGuard({ issue: 1, contract: { repro_test: "none", repro_test_reason: "docs only", validate: [{ command: "npm run lint" }] } }).reason, /repro_test none \(docs only\)/);
|
|
42
|
+
assert.match(buildGuard({ issue: 1, contract: { validate: [] } }).reason, /names no test/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("render/parse round trip survives '-->', '--!>' and '<x>' in a field", () => {
|
|
46
|
+
const g = { v: 1, issue: 3, why: "a --> b --!> c <x>", tests: [{ path: "t --!> <y>", ids: ["--!>"] }], hunks: [] };
|
|
47
|
+
const body = renderGuard(g);
|
|
48
|
+
const inner = body.slice(0, -" -->".length);
|
|
49
|
+
assert.ok(body.endsWith(" -->"));
|
|
50
|
+
assert.equal(inner.includes(">"), false); // no terminator variant can appear before the closing -->
|
|
51
|
+
assert.deepEqual(parseGuard(body), g);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function fakeGh({ contract, comments = [], title = "Fix the thing" }) {
|
|
55
|
+
const posted = [];
|
|
56
|
+
const body = `<!-- FORGE:CONTRACT -->\n${renderContract({ requirements: [], deliverables: [], preserve: [], validate: [], ...contract }, "abcdef123456")}`;
|
|
57
|
+
const gh = async (a) => {
|
|
58
|
+
if (a[0] === "issue" && a[1] === "view") return JSON.stringify({ number: 7, title, comments: [{ body }, ...comments, ...posted.map((b) => ({ body: b }))] });
|
|
59
|
+
if (a[0] === "pr" && a[1] === "view") return JSON.stringify({ number: 9, mergeCommit: { oid: "deadbeefcafe" } });
|
|
60
|
+
if (a[0] === "issue" && a[1] === "comment") { posted.push(a[a.indexOf("--body") + 1]); return ""; }
|
|
61
|
+
throw new Error(`unexpected gh ${a.join(" ")}`);
|
|
62
|
+
};
|
|
63
|
+
return { gh, posted };
|
|
64
|
+
}
|
|
65
|
+
const git = async (a) => { assert.deepEqual(a.slice(0, 2), ["diff", "-U0"]); assert.ok(a.includes("deadbeefcafe^1")); return DIFF; };
|
|
66
|
+
|
|
67
|
+
test("guardIssue posts exactly one FORGE:GUARD and is idempotent on rerun", async () => {
|
|
68
|
+
const f = fakeGh({ contract: nodeContract });
|
|
69
|
+
const r1 = await guardIssue({ gh: f.gh, git, repo: "o/r", issue: 7, pr: 9 });
|
|
70
|
+
assert.ok(r1.posted);
|
|
71
|
+
const r2 = await guardIssue({ gh: f.gh, git, repo: "o/r", issue: 7, pr: 9 });
|
|
72
|
+
assert.ok(r2.exists);
|
|
73
|
+
assert.equal(f.posted.length, 1);
|
|
74
|
+
assert.equal(parseGuard(f.posted[0]).merge_sha, "deadbeefcafe");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("guardIssue: no test posts nothing; unavailable merge commit is a logged skip", async () => {
|
|
78
|
+
const f = fakeGh({ contract: { repro_test: "none", repro_test_reason: "n/a" } });
|
|
79
|
+
assert.ok((await guardIssue({ gh: f.gh, git, repo: "o/r", issue: 7, pr: 9 })).skipped);
|
|
80
|
+
assert.equal(f.posted.length, 0);
|
|
81
|
+
const g = fakeGh({ contract: nodeContract });
|
|
82
|
+
const r = await guardIssue({ gh: g.gh, git: async () => { throw new Error("bad object"); }, repo: "o/r", issue: 7, pr: 9 });
|
|
83
|
+
assert.match(r.reason, /not available locally/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("listGuards filters by hunk file, test file or directory", () => {
|
|
87
|
+
const gs = [buildGuard({ issue: 7, contract: nodeContract, diff: DIFF }).guard, buildGuard({ issue: 8, contract: pyContract, diff: "" }).guard];
|
|
88
|
+
assert.deepEqual(listGuards(gs, { path: "src/a.mjs" }).map((g) => g.issue), [7]);
|
|
89
|
+
assert.deepEqual(listGuards(gs, { path: "tests" }).map((g) => g.issue), [8]);
|
|
90
|
+
assert.deepEqual(listGuards(gs, { path: "nope.js" }), []);
|
|
91
|
+
assert.equal(listGuards(gs).length, 2);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("backfill guards merged lanes and logs why a lane without a test yields none", async () => {
|
|
95
|
+
const withTest = fakeGh({ contract: nodeContract }), noTest = fakeGh({ contract: { validate: [] } });
|
|
96
|
+
const by = { 1: withTest, 2: noTest };
|
|
97
|
+
const gh = async (a) => {
|
|
98
|
+
if (a[0] === "issue" && a[1] === "list") return JSON.stringify([{ number: 1 }, { number: 2 }]);
|
|
99
|
+
if (a[0] === "pr" && a[1] === "list") return JSON.stringify([{ number: 9, body: `Closes #${a.join(" ").match(/#(\d+) in:body/)[1]}`, mergeCommit: { oid: "deadbeefcafe" } }]);
|
|
100
|
+
return by[a[2]].gh(a);
|
|
101
|
+
};
|
|
102
|
+
const logs = [];
|
|
103
|
+
const r = await backfill({ gh, git, repo: "o/r", since: "2026-01-01", log: (m) => logs.push(m) });
|
|
104
|
+
assert.deepEqual(r.posted, [1]);
|
|
105
|
+
assert.equal(r.skipped[0].issue, 2);
|
|
106
|
+
assert.match(logs[0], /names no test/);
|
|
107
|
+
assert.equal(withTest.posted.length, 1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("guards list prints guards for a path and caches the index; falls back to it offline", async () => {
|
|
111
|
+
const root = tmpDir("guards-");
|
|
112
|
+
{
|
|
113
|
+
const g = buildGuard({ issue: 7, title: "t", contract: nodeContract, diff: DIFF }).guard;
|
|
114
|
+
const gh = async () => JSON.stringify([{ number: 7, comments: [{ body: renderGuard(g) }] }]);
|
|
115
|
+
const out = [];
|
|
116
|
+
assert.equal(await guardsCli(["list", "--path", "src/a.mjs"], { gh, repo: "o/r", print: (m) => out.push(m), root }), 0);
|
|
117
|
+
assert.match(out[0], /#7/);
|
|
118
|
+
assert.equal(readIndex("o/r", { root }).length, 1);
|
|
119
|
+
const off = [];
|
|
120
|
+
await guardsCli(["list", "--path", "test/a.test.mjs"], { gh: async () => { throw new Error("offline"); }, repo: "o/r", print: (m) => off.push(m), root });
|
|
121
|
+
assert.match(off[0], /#7/);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
test("parseHunks ignores deleted files", () => {
|
|
125
|
+
assert.deepEqual(parseHunks("--- a/x.js\n+++ /dev/null\n@@ -1,2 +0,0 @@\n"), []);
|
|
126
|
+
});
|
|
127
|
+
test("guardAfterMerge fetches the bare branch (not origin/<lane>) and the merge sha when absent locally", async () => {
|
|
128
|
+
const calls = [];
|
|
129
|
+
let have = false;
|
|
130
|
+
const body = `<!-- FORGE:CONTRACT -->\n${renderContract({ requirements: [], deliverables: [], preserve: [], ...nodeContract }, "abcdef123456")}`;
|
|
131
|
+
const io = {
|
|
132
|
+
gh: async (a) => a[0] === "issue" ? JSON.stringify({ number: 9, title: "t", comments: [{ body }] }) : a[0] === "pr" ? JSON.stringify({ number: 3, mergeCommit: { oid: "abc123" } }) : "",
|
|
133
|
+
git: async (a) => { calls.push(a.join(" ")); if (a[0] === "diff") { if (!have) throw new Error("bad object"); return DIFF; } if (a.join(" ") === "fetch origin abc123") have = true; return ""; },
|
|
134
|
+
};
|
|
135
|
+
const r = await guardAfterMerge({ io, repo: "o/r", issue: 9, pr: 3, cwd: "/x", base: "origin/staging" });
|
|
136
|
+
assert.ok(calls.includes("fetch origin staging"));
|
|
137
|
+
assert.ok(!calls.some((c) => c.includes("origin/staging")));
|
|
138
|
+
assert.ok(r.posted, JSON.stringify(r));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const G = (issue, file, over = {}) => ({ v: 1, issue, pr: issue + 100, merge_sha: "s", tests: [{ path: "test/x.test.mjs", ids: [] }], hunks: [{ file, start: 1, end: 2, symbol: null }], why: `fix ${issue}`, ...over });
|
|
142
|
+
test("historyPreserve: guarded deliverable yields rows, unguarded none, own issue skipped", () => {
|
|
143
|
+
const guards = [G(5, "src/a.mjs"), G(6, "lib/other.mjs"), G(7, "src/a.mjs")];
|
|
144
|
+
const rows = historyPreserve({ guards, deliverables: [{ path: "src/a.mjs" }], issue: 7 });
|
|
145
|
+
assert.deepEqual(rows, [{ id: "H1", what: "fix 5 (#5, PR #105)", source: "guard" }]);
|
|
146
|
+
assert.deepEqual(historyPreserve({ guards, deliverables: [{ path: "src/zzz/q.mjs" }], issue: 1 }), []);
|
|
147
|
+
});
|
|
148
|
+
test("historyPreserve: deterministic under input order, capped at 5, proximity then recency", () => {
|
|
149
|
+
const guards = [G(1, "lib/z.mjs"), G(2, "src/a.mjs"), G(3, "src/b.mjs"), G(4, "src/c.mjs"), G(5, "src/d.mjs"), G(6, "src/e.mjs"), G(8, "x/y.mjs", { tests: [{ path: "src/a.mjs", ids: [] }] }), G(9, "src/a.mjs")];
|
|
150
|
+
const d = [{ path: "src/a.mjs" }];
|
|
151
|
+
const a = historyPreserve({ guards, deliverables: d, issue: 99 });
|
|
152
|
+
const b = historyPreserve({ guards: [...guards].reverse(), deliverables: d, issue: 99 });
|
|
153
|
+
assert.deepEqual(a, b);
|
|
154
|
+
assert.equal(a.length, 5);
|
|
155
|
+
assert.deepEqual(a.map((r) => r.what.match(/#(\d+),/)[1]), ["9", "2", "8", "6", "5"], "hunk match first, then test match, then same directory by recency");
|
|
156
|
+
assert.deepEqual(a.map((r) => r.id), ["H1", "H2", "H3", "H4", "H5"]);
|
|
157
|
+
});
|
|
158
|
+
test("withHistoryRows reads the local index; no index or no match returns the same contract", () => {
|
|
159
|
+
const root = tmpDir("guards-hist-");
|
|
160
|
+
const c = { requirements: [], deliverables: [{ id: "D1", path: "src/a.mjs" }], preserve: [{ id: "P1", what: "keep" }, { id: "H9", what: "old", source: "guard" }], validate: [] };
|
|
161
|
+
assert.equal(withHistoryRows(c, { repo: "o/r", issue: 1, root }), c, "no index");
|
|
162
|
+
writeIndex("o/r", [G(5, "src/a.mjs")], { root });
|
|
163
|
+
const w = withHistoryRows(c, { repo: "o/r", issue: 1, root });
|
|
164
|
+
assert.deepEqual(w.preserve.map((p) => p.id), ["P1", "H1"]);
|
|
165
|
+
const none = { ...c, deliverables: [{ id: "D1", path: "docs/q.md" }] };
|
|
166
|
+
assert.equal(withHistoryRows(none, { repo: "o/r", issue: 1, root }), none);
|
|
167
|
+
});
|
|
168
|
+
test("guardAfterMerge adds the posted guard to an existing local index only", async () => {
|
|
169
|
+
const root = tmpDir("guards-idx-");
|
|
170
|
+
const body = `<!-- FORGE:CONTRACT -->\n${renderContract({ requirements: [], deliverables: [], preserve: [], ...nodeContract }, "abcdef123456")}`;
|
|
171
|
+
const io = { gh: async (a) => a[0] === "issue" ? JSON.stringify({ number: 9, title: "t", comments: [{ body }] }) : a[0] === "pr" ? JSON.stringify({ number: 3, mergeCommit: { oid: "abc123" } }) : "", git: async (a) => (a[0] === "diff" ? DIFF : "") };
|
|
172
|
+
assert.ok((await guardAfterMerge({ io, repo: "o/r", issue: 9, pr: 3, cwd: "/x", root })).posted);
|
|
173
|
+
assert.equal(readIndex("o/r", { root }), null, "no index is not created from one guard");
|
|
174
|
+
writeIndex("o/r", [G(5, "src/a.mjs")], { root });
|
|
175
|
+
await guardAfterMerge({ io, repo: "o/r", issue: 9, pr: 3, cwd: "/x", root });
|
|
176
|
+
assert.deepEqual(readIndex("o/r", { root }).map((g) => g.issue), [5, 9]);
|
|
177
|
+
});
|