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,298 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
/**
|
|
5
|
+
* review-context.mjs — detect and refresh a stale forge.yaml `review.context` / `review.tech_stack` (#4666).
|
|
6
|
+
*
|
|
7
|
+
* The checks are deterministic (no LLM): manifests, the decision log and the context text are compared directly.
|
|
8
|
+
* Only `proposeRefresh` may use the enrichment backend, and only when the caller passes one in.
|
|
9
|
+
*
|
|
10
|
+
* node bin/review-context.mjs check --config <forge.yaml> [--root <dir>] [--repo owner/name] --json
|
|
11
|
+
*
|
|
12
|
+
* Exit 0 always for `check` (a failing check must never block a review); the JSON carries the result.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
|
15
|
+
import { dirname, join, resolve } from "path";
|
|
16
|
+
import { execFileSync } from "child_process";
|
|
17
|
+
import { fileURLToPath } from "url";
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_MAX_MERGED_PRS = 50;
|
|
20
|
+
export const DECISION_LOG_CANDIDATES = ["docs/decisions.md", "DECISIONS.md", "docs/decision-log.md", "devdocs/project/decisions.md"];
|
|
21
|
+
|
|
22
|
+
// label as written in tech_stack -> package name as written in a manifest
|
|
23
|
+
const FRAMEWORKS = [
|
|
24
|
+
["Next.js", "next"], ["React", "react"], ["Vue", "vue"], ["Nuxt", "nuxt"], ["Svelte", "svelte"], ["SvelteKit", "@sveltejs/kit"],
|
|
25
|
+
["Angular", "@angular/core"], ["Remix", "@remix-run/react"], ["Astro", "astro"], ["Express", "express"], ["Fastify", "fastify"],
|
|
26
|
+
["NestJS", "@nestjs/core"], ["Tailwind", "tailwindcss"], ["Prisma", "prisma"], ["Drizzle", "drizzle-orm"],
|
|
27
|
+
["FastAPI", "fastapi"], ["Django", "django"], ["Flask", "flask"], ["SQLAlchemy", "sqlalchemy"],
|
|
28
|
+
["Rails", "rails"], ["Laravel", "laravel/framework"], ["Gin", "gin-gonic/gin"], ["Axum", "axum"], ["Actix", "actix-web"],
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", ".venv", "venv", "vendor", "target", "__pycache__", ".claude", ".forge-lean"]);
|
|
32
|
+
const MANIFEST_RE = /^(package\.json|pyproject\.toml|requirements[\w.-]*\.txt|go\.mod|Cargo\.toml|Gemfile|composer\.json)$/;
|
|
33
|
+
|
|
34
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
35
|
+
const wordRe = (w, flags = "i") => new RegExp(`(^|[^A-Za-z0-9_.-])${esc(w)}($|[^A-Za-z0-9_.-])`, flags);
|
|
36
|
+
|
|
37
|
+
// ── forge.yaml review block (no YAML parser in this repo: a tolerant line scanner) ──────────────────────────────
|
|
38
|
+
|
|
39
|
+
function reviewRange(lines) {
|
|
40
|
+
const start = lines.findIndex((l) => /^review:\s*(#.*)?$/.test(l));
|
|
41
|
+
if (start === -1) return null;
|
|
42
|
+
let end = lines.length;
|
|
43
|
+
for (let i = start + 1; i < lines.length; i++) if (/^\S/.test(lines[i]) && !lines[i].startsWith("#")) { end = i; break; }
|
|
44
|
+
return { start, end };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function scalar(raw) {
|
|
48
|
+
const v = raw.trim();
|
|
49
|
+
if (v.startsWith('"')) { try { return JSON.parse(v.replace(/\s+#.*$/, "")); } catch { return v.slice(1, -1); } }
|
|
50
|
+
if (v.startsWith("'")) return v.slice(1, v.lastIndexOf("'")).replace(/''/g, "'");
|
|
51
|
+
return v.replace(/\s+#.*$/, "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reads the scalar keys of the top-level `review:` block (block scalars `|`/`>` included). Missing -> "". */
|
|
55
|
+
export function readReviewFields(yamlText) {
|
|
56
|
+
const lines = yamlText.split(/\r?\n/);
|
|
57
|
+
const r = reviewRange(lines);
|
|
58
|
+
const out = {};
|
|
59
|
+
if (!r) return out;
|
|
60
|
+
for (let i = r.start + 1; i < r.end; i++) {
|
|
61
|
+
const m = /^ ([A-Za-z_]+):\s*(.*)$/.exec(lines[i]);
|
|
62
|
+
if (!m) continue;
|
|
63
|
+
const [, key, rest] = m;
|
|
64
|
+
if (/^[|>][+-]?\s*(#.*)?$/.test(rest)) {
|
|
65
|
+
const body = [];
|
|
66
|
+
let j = i + 1;
|
|
67
|
+
while (j < r.end && (/^\s*$/.test(lines[j]) || /^ {3,}/.test(lines[j]))) body.push(lines[j++].replace(/^ {4}/, "").replace(/^ +$/, ""));
|
|
68
|
+
out[key] = body.join("\n").replace(/\n+$/, "");
|
|
69
|
+
i = j - 1;
|
|
70
|
+
} else if (rest.trim() !== "") out[key] = scalar(rest);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Returns yamlText with the given review keys replaced (or added). Every other line is left untouched. */
|
|
76
|
+
export function writeReviewFields(yamlText, fields) {
|
|
77
|
+
const lines = yamlText.split("\n");
|
|
78
|
+
let r = reviewRange(lines);
|
|
79
|
+
if (!r) {
|
|
80
|
+
if (lines[lines.length - 1] !== "") lines.push("");
|
|
81
|
+
lines.push("review:");
|
|
82
|
+
r = { start: lines.length - 1, end: lines.length };
|
|
83
|
+
}
|
|
84
|
+
const keys = Object.keys(fields);
|
|
85
|
+
const kept = [];
|
|
86
|
+
for (let i = r.start + 1; i < r.end; i++) {
|
|
87
|
+
const m = /^ ([A-Za-z_]+):/.exec(lines[i]);
|
|
88
|
+
if (m && keys.includes(m[1])) {
|
|
89
|
+
while (i + 1 < r.end && (/^ {3,}/.test(lines[i + 1]) || (/^\s*$/.test(lines[i + 1]) && /^ {3,}/.test(lines[i + 2] || "")))) i++;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
kept.push(lines[i]);
|
|
93
|
+
}
|
|
94
|
+
const added = [];
|
|
95
|
+
for (const k of keys) {
|
|
96
|
+
const v = String(fields[k]);
|
|
97
|
+
if (v.includes("\n")) added.push(` ${k}: |`, ...v.replace(/\n+$/, "").split("\n").map((l) => (l ? ` ${l}` : "")));
|
|
98
|
+
else added.push(` ${k}: ${JSON.stringify(v)}`);
|
|
99
|
+
}
|
|
100
|
+
return [...lines.slice(0, r.start + 1), ...added, ...kept, ...lines.slice(r.end)].join("\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── facts: manifests and the decision log ────────────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/** Dependency names and raw manifest text under `root` (depth <= 3, skipping vendored dirs). */
|
|
106
|
+
export function collectManifestFacts(root) {
|
|
107
|
+
const files = [];
|
|
108
|
+
const walk = (dir, depth) => {
|
|
109
|
+
let entries;
|
|
110
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
111
|
+
for (const name of entries) {
|
|
112
|
+
if (files.length >= 200) return;
|
|
113
|
+
const p = join(dir, name);
|
|
114
|
+
let st;
|
|
115
|
+
try { st = statSync(p); } catch { continue; }
|
|
116
|
+
if (st.isDirectory()) { if (depth < 3 && !SKIP_DIRS.has(name)) walk(p, depth + 1); }
|
|
117
|
+
else if (MANIFEST_RE.test(name) && st.size < 2_000_000) files.push(p);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
walk(root, 0);
|
|
121
|
+
const parts = [];
|
|
122
|
+
let hasSourceManifest = false;
|
|
123
|
+
for (const f of files) {
|
|
124
|
+
let text;
|
|
125
|
+
try { text = readFileSync(f, "utf8"); } catch { continue; }
|
|
126
|
+
if (f.endsWith("package.json")) {
|
|
127
|
+
try {
|
|
128
|
+
const j = JSON.parse(text);
|
|
129
|
+
const deps = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"].flatMap((k) => Object.keys(j[k] || {}));
|
|
130
|
+
if (deps.length) hasSourceManifest = true;
|
|
131
|
+
parts.push(deps.join("\n"));
|
|
132
|
+
} catch { /* unparsable manifest: ignore */ }
|
|
133
|
+
} else {
|
|
134
|
+
hasSourceManifest = true;
|
|
135
|
+
parts.push(text);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { files, hasSourceManifest, haystack: parts.join("\n").toLowerCase() };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function findDecisionLog(root, configured) {
|
|
142
|
+
for (const rel of [configured, ...DECISION_LOG_CANDIDATES].filter(Boolean)) {
|
|
143
|
+
const p = resolve(root, rel);
|
|
144
|
+
if (existsSync(p)) { try { return readFileSync(p, "utf8"); } catch { /* next */ } }
|
|
145
|
+
}
|
|
146
|
+
return "";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const ID_RE = /\b(?:[A-Z]{1,5}-?\d{1,4})\b/g;
|
|
150
|
+
|
|
151
|
+
/** IDs the decision log marks superseded: "D-12 ... superseded", "Status: superseded" under a "## D-12" heading, "D-15 supersedes D-12". */
|
|
152
|
+
export function supersededDecisionIds(logText) {
|
|
153
|
+
const out = new Set();
|
|
154
|
+
let lastId = [];
|
|
155
|
+
for (const line of logText.split(/\r?\n/)) {
|
|
156
|
+
const m = /supersed\w*/i.exec(line);
|
|
157
|
+
if (!m) {
|
|
158
|
+
const ids = line.match(ID_RE);
|
|
159
|
+
if (ids) lastId = ids;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (/^supersedes$/i.test(m[0])) (line.slice(m.index).match(ID_RE) || []).forEach((i) => out.add(i));
|
|
163
|
+
else {
|
|
164
|
+
const before = line.slice(0, m.index).match(ID_RE) || [];
|
|
165
|
+
(before.length ? before : lastId).forEach((i) => out.add(i));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── detection ────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/** Reasons the context is stale: [{code, detail}]. Empty = fresh (or nothing to compare against). */
|
|
174
|
+
export function detectStale({ techStack = "", context = "", manifests, decisionLogText = "" }) {
|
|
175
|
+
const reasons = [];
|
|
176
|
+
if (manifests?.hasSourceManifest) {
|
|
177
|
+
const absent = FRAMEWORKS.filter(([label, pkg]) => wordRe(label).test(techStack) && !wordRe(pkg).test(manifests.haystack)).map(([l]) => l);
|
|
178
|
+
if (absent.length) reasons.push({ code: "tech_stack_absent", detail: `tech_stack names ${absent.join(", ")} but no manifest lists it` });
|
|
179
|
+
if (/docs[- ]only|\bno (?:product |source |application )?code\b/i.test(context)) {
|
|
180
|
+
reasons.push({ code: "docs_only_phrasing", detail: "context says the repo is docs-only / has no code, but source manifests exist" });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (decisionLogText && context) {
|
|
184
|
+
const cited = [...supersededDecisionIds(decisionLogText)].filter((id) => wordRe(id, "").test(context));
|
|
185
|
+
if (cited.length) reasons.push({ code: "superseded_decision", detail: `context cites superseded decision(s) ${cited.join(", ")}` });
|
|
186
|
+
}
|
|
187
|
+
return reasons;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Advisory once more than `maxMergedPrs` PRs merged since the context was generated. Unknown count = not advisory. */
|
|
191
|
+
export function contextAge({ generatedAt = "", mergedSince = null, maxMergedPrs = DEFAULT_MAX_MERGED_PRS }) {
|
|
192
|
+
const max = Number.isFinite(maxMergedPrs) && maxMergedPrs > 0 ? maxMergedPrs : DEFAULT_MAX_MERGED_PRS;
|
|
193
|
+
return { generatedAt, mergedSince, maxMergedPrs: max, advisory: Boolean(generatedAt) && mergedSince != null && mergedSince > max };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Merged PR count since `generatedAt` via gh; null when it cannot be determined. */
|
|
197
|
+
export function countMergedSince(repo, generatedAt, run = execFileSync) {
|
|
198
|
+
if (!repo || !/^\d{4}-\d{2}-\d{2}/.test(generatedAt)) return null;
|
|
199
|
+
try {
|
|
200
|
+
const out = run("gh", ["pr", "list", "-R", repo, "--state", "merged", "--search", `merged:>=${generatedAt.slice(0, 10)}`, "--json", "number", "--limit", "200"], { encoding: "utf8", timeout: 20000, stdio: ["ignore", "pipe", "ignore"] });
|
|
201
|
+
const n = JSON.parse(out);
|
|
202
|
+
return Array.isArray(n) ? n.length : null;
|
|
203
|
+
} catch { return null; }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Full check of one forge.yaml. `mergedCount` is injectable for tests. */
|
|
207
|
+
export function checkContext({ configPath, root, repo, mergedCount = countMergedSince }) {
|
|
208
|
+
let text = "";
|
|
209
|
+
try { text = readFileSync(configPath, "utf8"); } catch { return { stale: false, reasons: [], generated_at: "", merged_since: null, advisory: false, max_merged_prs: DEFAULT_MAX_MERGED_PRS }; }
|
|
210
|
+
const f = readReviewFields(text);
|
|
211
|
+
const manifests = collectManifestFacts(root);
|
|
212
|
+
const reasons = detectStale({ techStack: f.tech_stack || "", context: f.context || "", manifests, decisionLogText: findDecisionLog(root, f.decision_log) });
|
|
213
|
+
const generatedAt = f.context_generated_at || "";
|
|
214
|
+
const age = contextAge({ generatedAt, mergedSince: generatedAt ? mergedCount(repo, generatedAt) : null, maxMergedPrs: parseInt(f.context_max_merged_prs, 10) });
|
|
215
|
+
return { stale: reasons.length > 0, reasons, generated_at: generatedAt, merged_since: age.mergedSince, advisory: age.advisory, max_merged_prs: age.maxMergedPrs };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── refresh ──────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
/** Tech stack from the frameworks the manifests actually list. */
|
|
221
|
+
export function techStackFromManifests(manifests) {
|
|
222
|
+
return FRAMEWORKS.filter(([, pkg]) => wordRe(pkg).test(manifests.haystack)).map(([l]) => l).join(", ");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Drops the sentences that made the context stale (docs-only phrasing, superseded decision IDs). */
|
|
226
|
+
export function pruneContext(context, { hasSourceManifest, superseded }) {
|
|
227
|
+
const stale = (s) => (hasSourceManifest && /docs[- ]only|\bno (?:product |source |application )?code\b/i.test(s)) || [...superseded].some((id) => wordRe(id, "").test(s));
|
|
228
|
+
return context.split("\n").map((line) => line.split(/(?<=[.!?])\s+/).filter((s) => !stale(s)).join(" ")).join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Proposes new review fields from the current manifests and decision log, deterministically: tech_stack is rebuilt from
|
|
233
|
+
* the manifests, stale sentences are pruned from the context. `enrichFn({tech_stack, context, decisionLog, manifestFiles})`
|
|
234
|
+
* (optional, async) may rewrite them further.
|
|
235
|
+
*/
|
|
236
|
+
export async function proposeRefresh({ yamlText, root, now = new Date(), enrichFn, mergedTotal }) {
|
|
237
|
+
const cur = readReviewFields(yamlText);
|
|
238
|
+
const manifests = collectManifestFacts(root);
|
|
239
|
+
const log = findDecisionLog(root, cur.decision_log);
|
|
240
|
+
const names = manifests.files.map((p) => p.slice(root.length + 1));
|
|
241
|
+
const stack = techStackFromManifests(manifests) || cur.tech_stack || "";
|
|
242
|
+
const fields = {
|
|
243
|
+
tech_stack: stack,
|
|
244
|
+
context: pruneContext(cur.context || "", { hasSourceManifest: manifests.hasSourceManifest, superseded: supersededDecisionIds(log) }),
|
|
245
|
+
};
|
|
246
|
+
if (!fields.context && names.length) fields.context = `Source manifests: ${names.join(", ")}.`;
|
|
247
|
+
if (enrichFn) {
|
|
248
|
+
const e = await enrichFn({ ...fields, decisionLog: log, manifestFiles: names });
|
|
249
|
+
if (e && typeof e === "object") { if (e.tech_stack) fields.tech_stack = e.tech_stack; if (e.context) fields.context = e.context; }
|
|
250
|
+
}
|
|
251
|
+
fields.context_generated_at = now.toISOString().slice(0, 10);
|
|
252
|
+
if (mergedTotal != null) fields.context_generated_pr_count = String(mergedTotal);
|
|
253
|
+
const next = writeReviewFields(yamlText, fields);
|
|
254
|
+
return { next, diff: simpleDiff(yamlText, next), changed: next !== yamlText };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* `forgedock init --refresh-context`: show the diff, write only on approval. `deps` = {confirm, backup, write, interactive}.
|
|
259
|
+
* Returns the exit code (0 written or nothing to do, 1 declined / non-interactive / no forge.yaml).
|
|
260
|
+
*/
|
|
261
|
+
export async function refreshContextFlow({ cwd, out, deps, enrichFn, mergedTotal }) {
|
|
262
|
+
const path = join(cwd, "forge.yaml");
|
|
263
|
+
if (!existsSync(path)) { out.write("\n No forge.yaml here — run: npx forgedock init\n"); return 1; }
|
|
264
|
+
const { next, diff, changed } = await proposeRefresh({ yamlText: readFileSync(path, "utf8"), root: cwd, enrichFn, mergedTotal });
|
|
265
|
+
out.write(`\n review.context / review.tech_stack refresh (from manifests + decision log):\n\n${diff.split("\n").map((l) => ` ${l}`).join("\n")}\n\n`);
|
|
266
|
+
if (!changed) { out.write(" Nothing to change.\n"); return 0; }
|
|
267
|
+
if (!deps.interactive) { out.write(" Non-interactive run — forge.yaml left untouched. Re-run in a terminal to approve.\n"); return 1; }
|
|
268
|
+
if (!(await deps.confirm("Apply this change to forge.yaml? A backup will be created.", false))) { out.write(" Refresh cancelled — forge.yaml left untouched.\n"); return 1; }
|
|
269
|
+
const backup = deps.backup(path);
|
|
270
|
+
if (backup) out.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
|
|
271
|
+
deps.write(path, next);
|
|
272
|
+
out.write(" forge.yaml updated.\n");
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Minimal line diff (removed `-`, added `+`) over the lines that differ; enough for an approval prompt. */
|
|
277
|
+
export function simpleDiff(a, b) {
|
|
278
|
+
const x = a.split("\n"), y = b.split("\n");
|
|
279
|
+
let s = 0;
|
|
280
|
+
while (s < x.length && s < y.length && x[s] === y[s]) s++;
|
|
281
|
+
let e = 0;
|
|
282
|
+
while (e < x.length - s && e < y.length - s && x[x.length - 1 - e] === y[y.length - 1 - e]) e++;
|
|
283
|
+
return [...x.slice(s, x.length - e).map((l) => `- ${l}`), ...y.slice(s, y.length - e).map((l) => `+ ${l}`)].join("\n");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
289
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
290
|
+
const opt = (n) => { const i = rest.indexOf(n); return i === -1 ? "" : rest[i + 1] || ""; };
|
|
291
|
+
if (cmd !== "check" || !opt("--config")) {
|
|
292
|
+
process.stderr.write("usage: review-context.mjs check --config <forge.yaml> [--root <dir>] [--repo owner/name] --json\n");
|
|
293
|
+
process.exit(2);
|
|
294
|
+
}
|
|
295
|
+
const configPath = resolve(opt("--config"));
|
|
296
|
+
const res = checkContext({ configPath, root: resolve(opt("--root") || dirname(configPath)), repo: opt("--repo") });
|
|
297
|
+
process.stdout.write(JSON.stringify(res) + "\n");
|
|
298
|
+
}
|
package/bin/runner.mjs
CHANGED
|
@@ -2001,6 +2001,17 @@ export function listDescendants(pid, { psFn = null, maxCmd = 200 } = {}) {
|
|
|
2001
2001
|
/** #4410: cap of the tool uses one stream session records. */
|
|
2002
2002
|
const TOOL_USES_MAX = 5000;
|
|
2003
2003
|
|
|
2004
|
+
/**
|
|
2005
|
+
* #4690: limit events of the systemd scope the process `pid` runs in: `tasks` (pids.events `max`: forks refused at TasksMax)
|
|
2006
|
+
* and `memory` (memory.events `oom_kill`). null when its cgroup or the counters are unreadable (no cgroup v2, no scope).
|
|
2007
|
+
*/
|
|
2008
|
+
export function readScopeLimitEvents(pid, { procRoot = "/proc", cgroupRoot = "/sys/fs/cgroup" } = {}) {
|
|
2009
|
+
const line = readFileSync(join(procRoot, String(pid), "cgroup"), "utf8").split("\n").find((l) => l.startsWith("0::"));
|
|
2010
|
+
const path = line?.slice(3).trim();
|
|
2011
|
+
if (!path || !path.endsWith(".scope")) return null;
|
|
2012
|
+
const count = (file, key) => { try { return Number((readFileSync(join(cgroupRoot, path, file), "utf8").match(new RegExp(`^${key} (\\d+)`, "m")) ?? [])[1]) || 0; } catch { return 0; } };
|
|
2013
|
+
return { tasks: count("pids.events", "max"), memory: count("memory.events", "oom_kill") };
|
|
2014
|
+
}
|
|
2004
2015
|
/**
|
|
2005
2016
|
* Async twin of runCliBackend (core work-on live comments): the CLI runs under child_process.spawn so the caller's event
|
|
2006
2017
|
* loop keeps turning while the session runs. It returns the SAME envelope (and throws the same errors) because the
|
|
@@ -2024,7 +2035,7 @@ const TOOL_USES_MAX = 5000;
|
|
|
2024
2035
|
* (deduped by tool_use id, capped at TOOL_USES_MAX), for bin/lean/toolstats.mjs; absent on the non-stream path.
|
|
2025
2036
|
*/
|
|
2026
2037
|
export async function runCliBackendAsync(opts) {
|
|
2027
|
-
const { spawnFn: _ignored, onSpawn = null, idleMs = 0, maxMs = 0, stream = false, wrap = null, control = null, contextMax = 0, turnMax = 0, busy = null, busyMs = 0, onIdleExtend = null, ...rest } = opts;
|
|
2038
|
+
const { spawnFn: _ignored, onSpawn = null, idleMs = 0, maxMs = 0, stream = false, wrap = null, control = null, contextMax = 0, turnMax = 0, busy = null, busyMs = 0, onIdleExtend = null, limitProbe = readScopeLimitEvents, ...rest } = opts;
|
|
2028
2039
|
let captured = null;
|
|
2029
2040
|
try {
|
|
2030
2041
|
runCliBackend({ ...rest, spawnFn: (bin, args, o) => {
|
|
@@ -2047,7 +2058,9 @@ export async function runCliBackendAsync(opts) {
|
|
|
2047
2058
|
const j = args.indexOf("--output-format");
|
|
2048
2059
|
if (j >= 0) { args[j + 1] = "stream-json"; if (!args.includes("--verbose")) args.splice(j + 2, 0, "--verbose"); }
|
|
2049
2060
|
}
|
|
2050
|
-
|
|
2061
|
+
let scopeUnit = null; // #4690: set when `wrap` put the child in a limited systemd scope
|
|
2062
|
+
if (typeof wrap === "function") { const w = wrap(bin, args); ({ bin, args } = w); scopeUnit = w.unit ?? null; }
|
|
2063
|
+
let limitInfo = null;
|
|
2051
2064
|
const ceiling = maxMs > 0 ? maxMs : o.timeout;
|
|
2052
2065
|
let collected, idleInfo = null, ctxInfo = null, turnInfo = null;
|
|
2053
2066
|
const msgUsage = new Map(); // #4390: assistant message id -> its usage (a message spans several stream lines)
|
|
@@ -2099,7 +2112,12 @@ export async function runCliBackendAsync(opts) {
|
|
|
2099
2112
|
}
|
|
2100
2113
|
}
|
|
2101
2114
|
// #4405: a separate check, so the absolute ceiling still applies while the idle kill is extended (busy) or settling.
|
|
2102
|
-
|
|
2115
|
+
// #4690: the scope's pids/memory events say the lane hit its own limit (the scope cgroup is gone once the child exits, so sample live)
|
|
2116
|
+
if (scopeUnit && !limitInfo && !timedOut && !idleInfo && !ctxInfo && !turnInfo) {
|
|
2117
|
+
let ev = null; try { ev = limitProbe(child.pid); } catch { ev = null; }
|
|
2118
|
+
if (ev && (ev.tasks > 0 || ev.memory > 0)) { limitInfo = { limit: ev.tasks > 0 ? "tasks" : "memory", unit: scopeUnit, events: ev }; kill(); }
|
|
2119
|
+
}
|
|
2120
|
+
if (!timedOut && !idleInfo && !ctxInfo && !turnInfo && !limitInfo && now - startedAt - pausedTotal - (paused ? now - pausedAt : 0) >= ceiling) { timedOut = true; kill(); }
|
|
2103
2121
|
}, tick);
|
|
2104
2122
|
timer.unref?.();
|
|
2105
2123
|
const noteLine = (line) => {
|
|
@@ -2143,6 +2161,11 @@ export async function runCliBackendAsync(opts) {
|
|
|
2143
2161
|
} finally { if (sysDir) rmSync(sysDir, { recursive: true, force: true }); }
|
|
2144
2162
|
const sum = (k) => [...msgUsage.values()].reduce((a, u) => a + toFiniteUsageNumber(u[k]), 0);
|
|
2145
2163
|
const streamUsage = () => (msgUsage.size ? { input_tokens: sum("input_tokens"), output_tokens: sum("output_tokens"), cache_creation_input_tokens: sum("cache_creation_input_tokens"), cache_read_input_tokens: sum("cache_read_input_tokens") } : null);
|
|
2164
|
+
if (limitInfo) {
|
|
2165
|
+
const err = new Error(`claude CLI lane hit its scope ${limitInfo.limit} limit (${limitInfo.unit}) and was stopped`);
|
|
2166
|
+
err.code = "CLI_LANE_LIMIT"; err.limit = limitInfo.limit; err.unit = limitInfo.unit; err.events = limitInfo.events; err.toolUses = [...toolUses.values()];
|
|
2167
|
+
throw err;
|
|
2168
|
+
}
|
|
2146
2169
|
if (turnInfo) {
|
|
2147
2170
|
const err = new Error(`claude CLI session reached its turn budget (${turnMax} turns) and was stopped`);
|
|
2148
2171
|
err.code = "CLI_TURNS"; err.turns = msgUsage.size; err.turnMax = turnMax; err.lastTool = turnInfo.lastTool; err.usage = streamUsage(); err.toolUses = [...toolUses.values()];
|
package/bin/stats/aggregate.mjs
CHANGED
|
@@ -86,7 +86,7 @@ function costSection(runs) {
|
|
|
86
86
|
n, cacheReadMean: mean(cr), cacheReadMedian: percentile(cr, 50), perPhase,
|
|
87
87
|
outputMean: n ? sum(runs.map((r) => sum(Object.values(r.phases).map((p) => p.out ?? 0)))) / n : null,
|
|
88
88
|
perMergedIssue: merged ? sum(cr) / merged : null,
|
|
89
|
-
usdN: usd.length, usdMean: mean(usd), usdTotal: usd.length ? sum(usd) : null,
|
|
89
|
+
usdN: usd.length, usdMean: mean(usd), usdMedian: percentile(usd, 50), usdTotal: usd.length ? sum(usd) : null,
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* #4696: compounding metrics for `forgedock stats --compounding`: does a repo get safer the longer ForgeDock works on it.
|
|
5
|
+
* Deterministic (0 LLM calls): guards (#4692 FORGE:GUARD comments), `reopened` issue events, git blame, and gate events
|
|
6
|
+
* from the FORGE:TELEMETRY records. The report core is pure; `gatherCompounding` supplies the GitHub and git inputs.
|
|
7
|
+
* reintroduction: a guarded fix whose issue was reopened after the lane closed it (a), or whose merge commit is the blame
|
|
8
|
+
* of the old-side lines of a later guarded fix within 90 days (b; squash and rebase merges, where merge_sha is the commit)
|
|
9
|
+
* reopen_after_close: lane-closed (merged) issues that were reopened afterwards
|
|
10
|
+
* guard / decision counts: gate events guard_run, guard_broken, supersedes_accepted, decision_superseded,
|
|
11
|
+
* decision_violation (#4693, #4695); null (n/a) until a repo has recorded any of that kind
|
|
12
|
+
* Grouped as a rolling window, per release and per lane-age bucket (lane rank = merge order of the repo's lanes).
|
|
13
|
+
*/
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { collectGuards } from "../lean/guards.mjs";
|
|
16
|
+
import { parsePages } from "./github-source.mjs";
|
|
17
|
+
|
|
18
|
+
export const REINTRO_DAYS = 90;
|
|
19
|
+
export const DEFAULT_BUCKETS = [100, 500];
|
|
20
|
+
export const DEFAULT_WINDOW_MS = 30 * 86400000;
|
|
21
|
+
export const COUNTED_EVENTS = ["guard_run", "guard_broken", "supersedes_accepted", "decision_superseded", "decision_violation"];
|
|
22
|
+
const DAY = 86400000;
|
|
23
|
+
const ratio = (a, b) => (b ? Math.round((a / b) * 1000) / 1000 : null);
|
|
24
|
+
const ms = (ts) => { const t = Date.parse(ts ?? ""); return Number.isFinite(t) ? t : null; };
|
|
25
|
+
|
|
26
|
+
export const bucketLabel = (rank, edges) => {
|
|
27
|
+
if (rank == null) return "unranked";
|
|
28
|
+
let lo = 1;
|
|
29
|
+
for (const e of edges) { if (rank <= e) return `${lo}-${e}`; lo = e + 1; }
|
|
30
|
+
return `${lo}+`;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Guards whose fixed lines a later guarded fix changed within 90 days: [{fix, by}] (fix = earlier guard's issue). Git probes may return null (unresolved). */
|
|
34
|
+
export function blameLineage(guards, git) {
|
|
35
|
+
const t = new Map(), hits = [];
|
|
36
|
+
const time = (g) => { if (!t.has(g.merge_sha)) t.set(g.merge_sha, git.time(g.merge_sha)); return t.get(g.merge_sha); };
|
|
37
|
+
const byCommit = new Map(guards.filter((g) => g.merge_sha).map((g) => [g.merge_sha, g]));
|
|
38
|
+
let unresolved = 0;
|
|
39
|
+
for (const g2 of guards) {
|
|
40
|
+
if (!g2.merge_sha) { unresolved++; continue; }
|
|
41
|
+
const ranges = git.oldRanges(g2.merge_sha);
|
|
42
|
+
if (!ranges) { unresolved++; continue; }
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
for (const r of ranges) for (const sha of git.blame(`${g2.merge_sha}^`, r.file, r.start, r.end) ?? []) {
|
|
45
|
+
const g1 = byCommit.get(sha);
|
|
46
|
+
if (!g1 || g1.issue === g2.issue || seen.has(g1.issue)) continue;
|
|
47
|
+
const a = time(g1), b = time(g2);
|
|
48
|
+
if (a == null || b == null || b < a || b - a > REINTRO_DAYS * 86400) continue;
|
|
49
|
+
seen.add(g1.issue); hits.push({ fix: g1.issue, by: g2.issue });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { hits, unresolved };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Metrics of one group of lane entries and the gate events falling in it. */
|
|
56
|
+
function summarize(entries, events, have, minN) {
|
|
57
|
+
const guarded = entries.filter((e) => e.guarded), lanes = entries.filter((e) => e.merged);
|
|
58
|
+
const count = (k) => (have[k] ? events.filter((e) => e.event === k).length : null);
|
|
59
|
+
return {
|
|
60
|
+
lanes: lanes.length,
|
|
61
|
+
reopen_after_close: { reopened: lanes.filter((e) => e.reopened).length, rate: ratio(lanes.filter((e) => e.reopened).length, lanes.length) },
|
|
62
|
+
reintroduction: { guarded: guarded.length, reintroduced: guarded.filter((e) => e.reintroduced).length, rate: ratio(guarded.filter((e) => e.reintroduced).length, guarded.length) },
|
|
63
|
+
guard: { tests_run: count("guard_run"), broken_caught: count("guard_broken"), supersedes_accepted: count("supersedes_accepted") },
|
|
64
|
+
decisions: { superseded: count("decision_superseded"), violations: count("decision_violation") },
|
|
65
|
+
low_n: Math.max(lanes.length, guarded.length) < minN,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Pure report. guards: parsed FORGE:GUARD objects; reopens: [{issue, ts}]; runs: toModel runs; events: toModel gate events;
|
|
71
|
+
* git: {time(sha)->epoch s|null, oldRanges(sha)->[{file,start,end}]|null, blame(rev,file,start,end)->[sha]|null}.
|
|
72
|
+
*/
|
|
73
|
+
export function compoundingReport({ guards = [], reopens = [], runs = [], events = [], git, windowMs = DEFAULT_WINDOW_MS, buckets = DEFAULT_BUCKETS, now = Date.now(), minN = 10 }) {
|
|
74
|
+
const merged = runs.filter((r) => r.reason === "merged").sort((a, b) => (ms(a.endTs) ?? 0) - (ms(b.endTs) ?? 0) || a.issue - b.issue);
|
|
75
|
+
const byIssue = new Map(merged.map((r, i) => [r.issue, { issue: r.issue, merged: true, rank: i + 1, version: r.version ?? null, at: ms(r.endTs), guarded: false, reopened: false, reintroduced: false }]));
|
|
76
|
+
const gset = new Map();
|
|
77
|
+
for (const g of guards) if (!gset.has(g.issue)) gset.set(g.issue, g);
|
|
78
|
+
const uniq = [...gset.values()];
|
|
79
|
+
for (const g of uniq) {
|
|
80
|
+
const e = byIssue.get(g.issue) ?? { issue: g.issue, merged: false, rank: null, version: null, at: null, guarded: false, reopened: false, reintroduced: false };
|
|
81
|
+
e.guarded = true;
|
|
82
|
+
const secs = git?.time && g.merge_sha ? git.time(g.merge_sha) : null;
|
|
83
|
+
if (e.at == null && secs != null) e.at = secs * 1000;
|
|
84
|
+
byIssue.set(g.issue, e);
|
|
85
|
+
}
|
|
86
|
+
for (const e of byIssue.values()) { // (a) and reopen-after-close: a reopen after the lane closed the issue
|
|
87
|
+
e.reopened = reopens.some((r) => r.issue === e.issue && (e.at == null || (ms(r.ts) ?? 0) > e.at));
|
|
88
|
+
if (e.guarded && e.reopened) e.reintroduced = true;
|
|
89
|
+
}
|
|
90
|
+
const lineage = git && uniq.length ? blameLineage(uniq, git) : { hits: [], unresolved: uniq.length };
|
|
91
|
+
for (const h of lineage.hits) if (byIssue.has(h.fix)) byIssue.get(h.fix).reintroduced = true;
|
|
92
|
+
const entries = [...byIssue.values()];
|
|
93
|
+
const have = Object.fromEntries(COUNTED_EVENTS.map((k) => [k, events.some((e) => e.event === k)]));
|
|
94
|
+
const evMs = (e) => ms(e.ts);
|
|
95
|
+
const out = { rolling: null, by_release: {}, by_lane_age: {}, sources: { guards: uniq.length, reopens: reopens.length, blame_hits: lineage.hits.length, blame_unresolved: lineage.unresolved }, buckets };
|
|
96
|
+
out.rolling = { window_days: Math.round(windowMs / DAY), ...summarize(entries.filter((e) => e.at != null && now - e.at <= windowMs && e.at <= now),
|
|
97
|
+
events.filter((e) => evMs(e) != null && now - evMs(e) <= windowMs && evMs(e) <= now), have, minN) };
|
|
98
|
+
const versions = [...new Set([...entries.map((e) => e.version), ...events.map((e) => e.version)].filter(Boolean))].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
99
|
+
for (const v of versions) out.by_release[v] = summarize(entries.filter((e) => e.version === v), events.filter((e) => e.version === v), have, minN);
|
|
100
|
+
const labels = [...new Set(entries.map((e) => bucketLabel(e.rank, buckets)))];
|
|
101
|
+
const order = (l) => (l === "unranked" ? Infinity : Number.parseInt(l, 10));
|
|
102
|
+
for (const l of labels.sort((a, b) => order(a) - order(b))) {
|
|
103
|
+
const inB = entries.filter((e) => bucketLabel(e.rank, buckets) === l);
|
|
104
|
+
const evs = events.filter((e) => { const r = byIssue.get(e.issue); return r && bucketLabel(r.rank, buckets) === l; });
|
|
105
|
+
out.by_lane_age[l] = summarize(inB, evs, have, minN);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const pct = (x) => (x == null ? "n/a" : `${Math.round(x * 1000) / 10}%`);
|
|
111
|
+
const n = (x) => (x == null ? "n/a" : String(x));
|
|
112
|
+
function line(label, s) {
|
|
113
|
+
return ` ${label}: ${s.lanes} lanes, reintroduction ${pct(s.reintroduction.rate)} (${s.reintroduction.reintroduced}/${s.reintroduction.guarded}), reopen-after-close ${pct(s.reopen_after_close.rate)}, `
|
|
114
|
+
+ `guard runs ${n(s.guard.tests_run)} broken ${n(s.guard.broken_caught)} supersedes ${n(s.guard.supersedes_accepted)}, decisions superseded ${n(s.decisions.superseded)} violations ${n(s.decisions.violations)}${s.low_n ? " (low n)" : ""}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function renderCompounding(r) {
|
|
118
|
+
const rows = [`compounding: ${r.sources.guards} guards, ${r.sources.reopens} reopen events, ${r.sources.blame_hits} blame hits (${r.sources.blame_unresolved} guards unresolved)`,
|
|
119
|
+
line(`rolling ${r.rolling.window_days}d`, r.rolling), "by lane age:", ...Object.entries(r.by_lane_age).map(([k, s]) => line(k, s))];
|
|
120
|
+
const rel = Object.entries(r.by_release);
|
|
121
|
+
return [...rows, "by release:", ...(rel.length ? rel.map(([k, s]) => line(k, s)) : [" n/a"])].join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const tryGit = (cwd, args) => { try { return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 }); } catch { return null; } };
|
|
125
|
+
|
|
126
|
+
/** git probes over a local clone; every miss (unknown sha, shallow clone) is null, never a thrown error. */
|
|
127
|
+
export function gitProbes(cwd) {
|
|
128
|
+
return {
|
|
129
|
+
time: (sha) => { const t = Number(tryGit(cwd, ["show", "-s", "--format=%ct", sha])?.trim()); return Number.isFinite(t) && t > 0 ? t : null; },
|
|
130
|
+
oldRanges: (sha) => {
|
|
131
|
+
const d = tryGit(cwd, ["diff", "-U0", "--no-renames", `${sha}^`, sha]);
|
|
132
|
+
if (d == null) return null;
|
|
133
|
+
const out = []; let file = null;
|
|
134
|
+
for (const l of d.split("\n")) {
|
|
135
|
+
if (l.startsWith("--- ")) { file = l === "--- /dev/null" ? null : l.replace(/^--- a\//, ""); continue; }
|
|
136
|
+
const m = /^@@ -(\d+)(?:,(\d+))? \+/.exec(l);
|
|
137
|
+
if (m && file && m[2] !== "0") out.push({ file, start: Number(m[1]), end: Number(m[1]) + (m[2] === undefined ? 1 : Number(m[2])) - 1 });
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
},
|
|
141
|
+
blame: (rev, file, start, end) => {
|
|
142
|
+
const o = tryGit(cwd, ["blame", "--porcelain", "-L", `${start},${end}`, rev, "--", file]);
|
|
143
|
+
return o == null ? null : [...new Set(o.split("\n").filter((l) => /^[0-9a-f]{40} \d+ \d+/.test(l)).map((l) => l.slice(0, 40)))];
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** GitHub + git inputs for compoundingReport: guards from FORGE:GUARD comments, reopen events from one paginated events call. */
|
|
149
|
+
export async function gatherCompounding({ repo, gh, cwd = process.cwd() }) {
|
|
150
|
+
const guards = await collectGuards({ gh, repo });
|
|
151
|
+
let reopens = [];
|
|
152
|
+
try {
|
|
153
|
+
reopens = parsePages(await gh(["api", "--paginate", `repos/${repo}/issues/events?per_page=100`]))
|
|
154
|
+
.filter((e) => e?.event === "reopened" && Number.isSafeInteger(e.issue?.number)).map((e) => ({ issue: e.issue.number, ts: e.created_at }));
|
|
155
|
+
} catch { /* events unreadable: reopen metrics read 0 reopens, shown with the count in sources */ }
|
|
156
|
+
return { guards, reopens, git: gitProbes(cwd) };
|
|
157
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
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 { compoundingReport, renderCompounding, bucketLabel, gitProbes } from "./compounding.mjs";
|
|
6
|
+
import { parseArgs } from "./index.mjs";
|
|
7
|
+
|
|
8
|
+
const NOW = Date.parse("2026-10-24T00:00:00Z"), DAY = 86400000, S = (d) => Math.floor((NOW - d * DAY) / 1000);
|
|
9
|
+
const run = (issue, daysAgo, version = "1.0.0") => ({ issue, reason: "merged", version, endTs: new Date(NOW - daysAgo * DAY).toISOString() });
|
|
10
|
+
const guard = (issue, sha) => ({ v: 1, issue, merge_sha: sha, tests: [{ path: "t.mjs", ids: [] }], hunks: [] });
|
|
11
|
+
// shas: a (issue 1, 40d ago), b (issue 2, 10d ago), c (issue 3, 1d ago)
|
|
12
|
+
const TIMES = { a: S(40), b: S(10), c: S(1) };
|
|
13
|
+
const fakeGit = (blames = {}) => ({ time: (s) => TIMES[s] ?? null, oldRanges: (s) => [{ file: "f.mjs", start: 1, end: 3 }], blame: (rev) => blames[rev] ?? [] });
|
|
14
|
+
const base = { runs: [run(1, 40), run(2, 10), run(3, 1)], guards: [guard(1, "a"), guard(2, "b"), guard(3, "c")], now: NOW, minN: 1 };
|
|
15
|
+
|
|
16
|
+
test("reintroduction (b): a later fix whose old lines blame to an earlier fix within 90 days", () => {
|
|
17
|
+
const r = compoundingReport({ ...base, git: fakeGit({ "b^": ["a"] }) });
|
|
18
|
+
assert.deepEqual(r.rolling.reintroduction, { guarded: 2, reintroduced: 0, rate: 0 }); // 30d window holds fixes 2, 3; the broken fix is #1 (40d ago)
|
|
19
|
+
assert.equal(r.sources.blame_hits, 1);
|
|
20
|
+
const all = Object.values(r.by_lane_age)[0];
|
|
21
|
+
assert.deepEqual(all.reintroduction, { guarded: 3, reintroduced: 1, rate: 0.333 });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("reintroduction (b): older than 90 days or unknown time does not count; unresolved is reported, not zeroed", () => {
|
|
25
|
+
const far = { ...base, guards: [guard(1, "a"), guard(2, "b")], runs: [run(1, 200), run(2, 10)] };
|
|
26
|
+
const r = compoundingReport({ ...far, git: { ...fakeGit({ "b^": ["a"] }), time: (s) => ({ a: S(200), b: S(10) })[s] ?? null } });
|
|
27
|
+
assert.equal(r.sources.blame_hits, 0);
|
|
28
|
+
const u = compoundingReport({ ...base, git: { time: () => null, oldRanges: () => null, blame: () => null } });
|
|
29
|
+
assert.equal(u.sources.blame_unresolved, 3);
|
|
30
|
+
assert.equal(u.sources.blame_hits, 0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("reintroduction (a) and reopen-after-close: reopen after the lane closed the issue", () => {
|
|
34
|
+
const reopens = [{ issue: 2, ts: new Date(NOW - 5 * DAY).toISOString() }, { issue: 3, ts: new Date(NOW - 3 * DAY).toISOString() /* before close? no: close was 1d ago */ }];
|
|
35
|
+
const r = compoundingReport({ ...base, reopens, git: fakeGit() });
|
|
36
|
+
assert.equal(r.rolling.reopen_after_close.reopened, 1); // issue 2 reopened after its close; issue 3's reopen predates its close
|
|
37
|
+
assert.equal(r.rolling.reintroduction.reintroduced, 1);
|
|
38
|
+
assert.equal(r.rolling.lanes, 2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("guard and decision counts are n/a until a kind is recorded, then counted per window", () => {
|
|
42
|
+
const none = compoundingReport({ ...base, git: fakeGit() });
|
|
43
|
+
assert.equal(none.rolling.guard.broken_caught, null);
|
|
44
|
+
assert.equal(none.rolling.decisions.violations, null);
|
|
45
|
+
const events = [{ event: "guard_broken", issue: 2, ts: new Date(NOW - DAY).toISOString(), version: "1.0.0" }, { event: "guard_broken", issue: 1, ts: new Date(NOW - 60 * DAY).toISOString(), version: "1.0.0" }];
|
|
46
|
+
const r = compoundingReport({ ...base, events, git: fakeGit() });
|
|
47
|
+
assert.equal(r.rolling.guard.broken_caught, 1);
|
|
48
|
+
assert.equal(r.rolling.guard.tests_run, null);
|
|
49
|
+
assert.equal(r.by_release["1.0.0"].guard.broken_caught, 2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("lane-age buckets by merge order and rendering", () => {
|
|
53
|
+
assert.deepEqual([1, 100, 101, 500, 501, null].map((n) => bucketLabel(n, [100, 500])), ["1-100", "1-100", "101-500", "101-500", "501+", "unranked"]);
|
|
54
|
+
const runs = Array.from({ length: 4 }, (_, i) => run(i + 1, 40 - i));
|
|
55
|
+
const r = compoundingReport({ runs, guards: [], buckets: [2], now: NOW, minN: 1, git: fakeGit() });
|
|
56
|
+
assert.deepEqual(Object.keys(r.by_lane_age), ["1-2", "3+"]);
|
|
57
|
+
assert.equal(r.by_lane_age["3+"].lanes, 2);
|
|
58
|
+
assert.match(renderCompounding(r), /by lane age:\n 1-2: 2 lanes, reintroduction n\/a \(0\/0\)/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("gitProbes degrades to null outside a repo or for an unknown sha", () => {
|
|
62
|
+
const g = gitProbes("/nonexistent-dir-for-compounding");
|
|
63
|
+
assert.equal(g.time("deadbeef"), null);
|
|
64
|
+
assert.equal(g.oldRanges("deadbeef"), null);
|
|
65
|
+
assert.equal(g.blame("deadbeef^", "x", 1, 2), null);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("parseArgs: --compounding takes --window and --bucket; --bucket alone is rejected", () => {
|
|
69
|
+
const o = parseArgs(["--compounding", "--window", "30d", "--bucket", "50,200"]);
|
|
70
|
+
assert.equal(o.compounding, true);
|
|
71
|
+
assert.deepEqual(o.bucket, [50, 200]);
|
|
72
|
+
assert.throws(() => parseArgs(["--bucket", "10"]), /needs --compounding/);
|
|
73
|
+
assert.throws(() => parseArgs(["--compounding", "--bucket", "9,5"]), /increasing/);
|
|
74
|
+
});
|