forgedock 1.27.0 → 1.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +39 -0
- package/bin/engine/telemetry-seam.mjs +37 -8
- package/bin/engine/telemetry-seam.test.mjs +31 -0
- package/bin/engine-cli.mjs +601 -42
- package/bin/engine.mjs +8 -4
- package/bin/forgedock.mjs +75 -9
- package/bin/gc.mjs +580 -0
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +22 -4
- package/bin/lean/batch.test.mjs +5 -3
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +234 -57
- package/bin/lean/conformance.test.mjs +168 -7
- package/bin/lean/context.mjs +333 -0
- package/bin/lean/context.test.mjs +279 -0
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +104 -9
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +723 -154
- package/bin/lean/lean.test.mjs +1603 -35
- package/bin/lean/ledger.mjs +27 -2
- package/bin/lean/ledger.test.mjs +36 -1
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +389 -19
- package/bin/lean/prefilter.mjs +2 -2
- package/bin/lean/prefilter.test.mjs +3 -0
- package/bin/lean/prompts.mjs +106 -17
- package/bin/lean/recovery.test.mjs +230 -0
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +49 -10
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/testmap.mjs +84 -0
- package/bin/lean/testmap.test.mjs +58 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +66 -10
- package/bin/lean/verify.test.mjs +104 -3
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +24 -2
- package/bin/stats/local-source.mjs +13 -0
- package/bin/stats/query.mjs +99 -0
- package/bin/stats/stats.test.mjs +94 -1
- package/commands/audit.md +2 -0
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +7 -0
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +62 -8
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +192 -2
- package/docs/OUTCOME-CODES.md +22 -0
- package/docs/RELEASE-NOTES.md +9 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +36 -2
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +9 -4
- package/scripts/check-devdocs-freshness.test.sh +11 -1
- package/scripts/check-spec-markers.sh +4 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +3 -3
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +1 -1
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
- package/scripts/fixtures/test-map/go/Makefile +5 -0
- package/scripts/fixtures/test-map/go/README.md +1 -0
- package/scripts/fixtures/test-map/go/go.mod +3 -0
- package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
- package/scripts/fixtures/test-map/go/main.go +3 -0
- package/scripts/fixtures/test-map/node/package.json +10 -0
- package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
- package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
- package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
- package/scripts/fixtures/test-map/node/src/a.ts +1 -0
- package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
- package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
- package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
- package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
- package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
- package/scripts/fixtures/test-map/python/uv.lock +1 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/repo-facts.sh +146 -0
- package/scripts/repo-facts.test.sh +106 -0
- package/scripts/structure-delta.sh +10 -5
- package/scripts/structure-delta.test.sh +3 -2
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/scripts/test-map.sh +211 -0
- package/scripts/test-map.test.sh +165 -0
- package/scripts/wait-ci-green.sh +10 -4
- package/scripts/wait-ci-green.test.sh +9 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- package/templates/bootstrap/technical-spike.md +75 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
/**
|
|
5
|
+
* scripts/bootstrap-create.mjs — idempotent, resumable backlog creation for /bootstrap (#4176, part of #4155)
|
|
6
|
+
*
|
|
7
|
+
* Takes a plan that passes scripts/bootstrap-validate.mjs and makes it real on GitHub: the spike parent issue,
|
|
8
|
+
* milestones in build order with their `milestone/<slug>` branches, every sub-issue (guardrails and domain rules
|
|
9
|
+
* injected into its Context, `#{{KEY}}` placeholders resolved), board items with Status=Todo, Priority and Component,
|
|
10
|
+
* a FORGE:BOOTSTRAP_PLAN and a FORGE:DECOMPOSED comment on the spike, and `workflow:decomposed` via
|
|
11
|
+
* scripts/transition-label.sh. Nothing is written when the plan or the spike doc is invalid.
|
|
12
|
+
*
|
|
13
|
+
* Re-running is safe: the spike is reused by exact title, milestones by title, branches when present, sub-issues by
|
|
14
|
+
* exact title plus a `**Parent**: #<spike>` line, comments when a trusted marker comment already exists.
|
|
15
|
+
*
|
|
16
|
+
* Plan fields on top of the validator's shape (all optional):
|
|
17
|
+
* "spike": "SPIKE" plan key of the spike parent issue (default "SPIKE")
|
|
18
|
+
* "spikeDoc": "docs/engineering/technical-spike.md" spike doc, checked for its sections (checkSpikeDoc)
|
|
19
|
+
* "guardrails": ["no auto-send"], "domainRules": ["India-only data residency"]
|
|
20
|
+
* issues[].priority: "p0".."p3" (label priority:P<n> + board Priority), issues[].component: board Component label,
|
|
21
|
+
* issues[].estimatedDiffLines: number (FORGE:BOOTSTRAP_PLAN estimate)
|
|
22
|
+
* "costPerIssueUsd": 3 dry-run cost estimate per build issue (#4177)
|
|
23
|
+
*
|
|
24
|
+
* Usage: node scripts/bootstrap-create.mjs <plan.json> [--dry-run] [--json] [--repo OWNER/NAME] [--config forge.yaml]
|
|
25
|
+
* [--base <branch>] [--cost-per-issue <usd>]
|
|
26
|
+
* Cost: plan.costPerIssueUsd, else --cost-per-issue, else DEFAULT_ISSUE_COST_USD, times the build sub-issues.
|
|
27
|
+
* Repo: --repo, else forge.yaml project.owner/project.repo. Base: --base, else branches.staging, else "staging".
|
|
28
|
+
* Exit: 0 created (or dry-run preview); 1 plan or spike doc invalid (nothing written); 2 usage; 3 GitHub error.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { execFileSync } from "node:child_process";
|
|
32
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
import { validatePlan, formatErrors, resolvePlaceholders, isNeedsHuman } from "./bootstrap-validate.mjs";
|
|
35
|
+
import { milestoneSlug } from "../bin/lean/lane.mjs";
|
|
36
|
+
|
|
37
|
+
const SELF_DIR = fileURLToPath(new URL(".", import.meta.url));
|
|
38
|
+
const DEFAULT_SPIKE_DOC = "docs/engineering/technical-spike.md";
|
|
39
|
+
const PLACEHOLDER_RE = /#\{\{[^{}]*\}\}/;
|
|
40
|
+
/** Planning estimate of agent spend per build issue (one lean lane) for the dry-run preview; not a measured figure. */
|
|
41
|
+
export const DEFAULT_ISSUE_COST_USD = 3;
|
|
42
|
+
|
|
43
|
+
/** Spike-doc sections (templates/bootstrap/technical-spike.md), matched as a prefix of a `## ` heading. */
|
|
44
|
+
export const SPIKE_SECTIONS = [
|
|
45
|
+
"Stack", "Repo layout", "Runtime topology", "Data model", "Security and privacy",
|
|
46
|
+
"Design system and quality gates", "Route/API map", "Credentials", "Milestone plan",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** `## ` sections of a markdown text, fenced code ignored: [{ heading, lines }]. */
|
|
50
|
+
function h2Sections(text) {
|
|
51
|
+
const out = [];
|
|
52
|
+
let cur = null, fence = null;
|
|
53
|
+
for (const line of String(text ?? "").replace(/\r\n/g, "\n").split("\n")) {
|
|
54
|
+
const f = line.match(/^[ \t]*(`{3,}|~{3,})/);
|
|
55
|
+
if (f) fence = fence ? (f[1][0] === fence ? null : fence) : f[1][0];
|
|
56
|
+
const h = !fence && !f && line.match(/^##(?!#)[ \t]+(.+?)[ \t]*$/);
|
|
57
|
+
if (h) { cur = { heading: h[1], lines: [] }; out.push(cur); continue; }
|
|
58
|
+
if (cur) cur.lines.push(line);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check a spike doc: every SPIKE_SECTIONS section present, each with at least one ADR-style `**Rationale**:` line.
|
|
65
|
+
* @returns {{ ok: boolean, missing: string[], noRationale: string[] }}
|
|
66
|
+
*/
|
|
67
|
+
export function checkSpikeDoc(text) {
|
|
68
|
+
const secs = h2Sections(text);
|
|
69
|
+
const missing = [], noRationale = [];
|
|
70
|
+
for (const name of SPIKE_SECTIONS) {
|
|
71
|
+
const s = secs.find((x) => x.heading.toLowerCase().startsWith(name.toLowerCase()));
|
|
72
|
+
if (!s) missing.push(name);
|
|
73
|
+
else if (!s.lines.some((l) => /\*\*Rationale\*\*:/.test(l))) noRationale.push(name);
|
|
74
|
+
}
|
|
75
|
+
return { ok: missing.length === 0 && noRationale.length === 0, missing, noRationale };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Append the plan's guardrails and domain rules to the end of a body's `## Context` section (idempotent).
|
|
80
|
+
* Bodies without a Context section are returned unchanged (the validator rejects those).
|
|
81
|
+
*/
|
|
82
|
+
export function injectGuardrails(body, guardrails = [], domainRules = []) {
|
|
83
|
+
const g = (guardrails ?? []).filter((x) => typeof x === "string" && x.trim());
|
|
84
|
+
const d = (domainRules ?? []).filter((x) => typeof x === "string" && x.trim());
|
|
85
|
+
if ((!g.length && !d.length) || /^\*\*Guardrails\*\*:/m.test(body)) return body;
|
|
86
|
+
const lines = String(body).split("\n");
|
|
87
|
+
let start = -1, end = lines.length, fence = null;
|
|
88
|
+
for (let i = 0; i < lines.length; i++) {
|
|
89
|
+
const f = lines[i].match(/^[ \t]*(`{3,}|~{3,})/);
|
|
90
|
+
if (f) { fence = fence ? (f[1][0] === fence ? null : fence) : f[1][0]; continue; }
|
|
91
|
+
if (fence || !/^##(?!#)[ \t]+/.test(lines[i])) continue;
|
|
92
|
+
if (start < 0 && /^##[ \t]+context\b/i.test(lines[i])) start = i;
|
|
93
|
+
else if (start >= 0) { end = i; break; }
|
|
94
|
+
}
|
|
95
|
+
if (start < 0) return body;
|
|
96
|
+
while (end > start + 1 && lines[end - 1].trim() === "") end--;
|
|
97
|
+
const block = [""];
|
|
98
|
+
if (g.length) block.push("**Guardrails**:", ...g.map((x) => `- ${x}`));
|
|
99
|
+
if (d.length) block.push(...(g.length ? [""] : []), "**Domain rules**:", ...d.map((x) => `- ${x}`));
|
|
100
|
+
lines.splice(end, 0, ...block);
|
|
101
|
+
return lines.join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Sub-issues (every plan issue but the spike) in dependency order: milestone order, then a stable topo sort. */
|
|
105
|
+
export function subIssueOrder(plan) {
|
|
106
|
+
const spikeKey = plan.spike ?? "SPIKE";
|
|
107
|
+
const mIdx = new Map(plan.milestones.map((m, i) => [m.key, i]));
|
|
108
|
+
const pending = plan.issues.filter((it) => it.key !== spikeKey)
|
|
109
|
+
.map((it, i) => ({ it, i })).sort((a, b) => mIdx.get(a.it.milestone) - mIdx.get(b.it.milestone) || a.i - b.i)
|
|
110
|
+
.map((x) => x.it);
|
|
111
|
+
const keys = new Set(pending.map((it) => it.key));
|
|
112
|
+
const done = new Set(), out = [];
|
|
113
|
+
while (pending.length) {
|
|
114
|
+
const at = pending.findIndex((it) => (it.deps ?? []).every((d) => !keys.has(d) || done.has(d)));
|
|
115
|
+
const [next] = pending.splice(at < 0 ? 0 : at, 1);
|
|
116
|
+
done.add(next.key);
|
|
117
|
+
out.push(next);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The priority label for an issue (`p1` -> `priority:P1`), or null. */
|
|
123
|
+
export function priorityLabel(issue) {
|
|
124
|
+
const m = String(issue?.priority ?? "").match(/^p([0-3])$/i);
|
|
125
|
+
return m ? `priority:P${m[1]}` : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Plan preview for --dry-run: milestones, titles, DAG summary, estimated agent cost. No GitHub access. */
|
|
129
|
+
export function planPreview(plan, { costPerIssueUsd } = {}) {
|
|
130
|
+
const spikeKey = plan.spike ?? "SPIKE";
|
|
131
|
+
const spike = plan.issues.find((it) => it.key === spikeKey);
|
|
132
|
+
const subs = subIssueOrder(plan);
|
|
133
|
+
const edges = subs.reduce((n, it) => n + (it.deps ?? []).filter((d) => d !== spikeKey).length, 0);
|
|
134
|
+
const human = subs.filter(isNeedsHuman);
|
|
135
|
+
const lines = ["## Bootstrap plan preview (dry run: nothing written to GitHub)", "",
|
|
136
|
+
`Spike: ${spike ? spike.title : "(missing)"} — doc \`${plan.spikeDoc ?? DEFAULT_SPIKE_DOC}\``, ""];
|
|
137
|
+
plan.milestones.forEach((m, i) => {
|
|
138
|
+
const its = subs.filter((it) => it.milestone === m.key);
|
|
139
|
+
lines.push(`### ${i + 1}. ${m.title} (\`milestone/${milestoneSlug(m.title)}\`, ${its.length} issues)`);
|
|
140
|
+
for (const it of its) {
|
|
141
|
+
const deps = (it.deps ?? []).filter((d) => d !== spikeKey);
|
|
142
|
+
lines.push(`- ${it.title}${isNeedsHuman(it) ? " [needs-human]" : ""}${deps.length ? ` — depends on ${deps.join(", ")}` : ""}`);
|
|
143
|
+
}
|
|
144
|
+
lines.push("");
|
|
145
|
+
});
|
|
146
|
+
lines.push(`DAG: ${subs.length} sub-issues (${subs.length - human.length} build, ${human.length} needs-human), ${edges} dependency edges, ${plan.milestones.length} milestones.`);
|
|
147
|
+
const rate = [plan.costPerIssueUsd, costPerIssueUsd].find((r) => Number.isFinite(r) && r > 0) ?? DEFAULT_ISSUE_COST_USD;
|
|
148
|
+
const build = subs.length - human.length;
|
|
149
|
+
lines.push(`Estimated agent cost: ~$${(build * rate).toFixed(2)} (${build} build issues × $${rate}/issue; needs-human and spike excluded).`);
|
|
150
|
+
return lines.join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `<!-- FORGE:BOOTSTRAP_PLAN -->` body, the shape commands/work-on/decompose.md Phase D0 reads. */
|
|
154
|
+
export function bootstrapPlanComment(plan) {
|
|
155
|
+
const subs = subIssueOrder(plan);
|
|
156
|
+
const pos = new Map(subs.map((it, i) => [it.key, i + 1]));
|
|
157
|
+
const items = subs.map((it, i) => {
|
|
158
|
+
const deps = (it.deps ?? []).filter((d) => pos.has(d)).map((d) => pos.get(d));
|
|
159
|
+
const est = Number.isFinite(it.estimatedDiffLines) ? ` — ESTIMATED_DIFF_LINES: ${it.estimatedDiffLines}` : "";
|
|
160
|
+
return `${i + 1}. ${it.title}${est}${deps.length ? ` _(depends on ${deps.join(", ")})_` : ""}`;
|
|
161
|
+
});
|
|
162
|
+
return ["<!-- FORGE:BOOTSTRAP_PLAN -->", "## Bootstrap Plan", "", `Spike: \`${plan.spikeDoc ?? DEFAULT_SPIKE_DOC}\``, "",
|
|
163
|
+
"### Decomposition Assessment", "", "**YES**", "", ...items, ""].join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** `<!-- FORGE:DECOMPOSED -->` body in the decompose Phase D5 format (`- #N: TITLE _(depends on #M)_`). */
|
|
167
|
+
export function decomposedComment(plan, numbers, { attribution = true } = {}) {
|
|
168
|
+
const spikeKey = plan.spike ?? "SPIKE";
|
|
169
|
+
const subs = subIssueOrder(plan);
|
|
170
|
+
const items = subs.map((it) => {
|
|
171
|
+
const deps = (it.deps ?? []).filter((d) => d !== spikeKey && numbers.has(d)).map((d) => `#${numbers.get(d)}`);
|
|
172
|
+
return `- #${numbers.get(it.key)}: ${it.title}${deps.length ? ` _(depends on ${deps.join(", ")})_` : ""}${isNeedsHuman(it) ? " — needs-human" : ""}`;
|
|
173
|
+
});
|
|
174
|
+
return ["<!-- FORGE:DECOMPOSED -->", "## Decomposition Complete", "",
|
|
175
|
+
"This spike has been broken into sub-issues by /bootstrap. Each sub-issue runs through its own /work-on pipeline independently.", "",
|
|
176
|
+
"**Plan source**: bootstrap", "", "### Sub-Issues Created", "", ...items, "", "### Decomposition Rationale", "",
|
|
177
|
+
`Milestones in build order: ${plan.milestones.map((m) => m.title).join(" → ")}. Needs-human issues are referenced by code issues but never block them; mocks keep dev and CI green.`,
|
|
178
|
+
...(attribution ? ["", "> Pipeline powered by [ForgeDock](https://github.com/RapierCraftStudios/ForgeDock)"] : []),
|
|
179
|
+
"<!-- FORGE:DECOMPOSED:COMPLETE -->"].join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Human hand-off: created links, needs-human links, `/orchestrate` per milestone in build order. */
|
|
183
|
+
export function handoffText(result) {
|
|
184
|
+
const link = (x) => `- #${x.number} ${x.title} — ${x.url}${x.reused ? " (reused)" : ""}`;
|
|
185
|
+
const lines = ["## Bootstrap hand-off", "", `Spike: #${result.spike.number} ${result.spike.title} — ${result.spike.url}`, "", "### Created"];
|
|
186
|
+
lines.push(...result.issues.filter((x) => !x.needsHuman).map(link));
|
|
187
|
+
lines.push("", "### Needs a human");
|
|
188
|
+
const human = result.issues.filter((x) => x.needsHuman);
|
|
189
|
+
lines.push(...(human.length ? human.map(link) : ["- none"]));
|
|
190
|
+
lines.push("", "### Next commands (in build order)");
|
|
191
|
+
result.milestones.forEach((m, i) => lines.push(`${i + 1}. ${m.orchestrate}`));
|
|
192
|
+
if (result.notes.length) lines.push("", "### Notes", ...result.notes.map((n) => `- ${n}`));
|
|
193
|
+
return lines.join("\n");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const parseIssueNumber = (out) => {
|
|
197
|
+
const m = String(out).match(/\/issues\/(\d+)/);
|
|
198
|
+
if (!m) throw new Error(`could not read the created issue number from: ${String(out).trim().slice(0, 200)}`);
|
|
199
|
+
return Number(m[1]);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Create (or reuse) the whole backlog. All GitHub access goes through `io`:
|
|
204
|
+
* io.gh(args, input?) -> stdout string (throws on failure)
|
|
205
|
+
* io.sh(cmd, args, input?) -> stdout string (bash helpers: transition-label.sh, trusted-comments.sh)
|
|
206
|
+
* @returns {object} result for handoffText (spike, issues, milestones, notes)
|
|
207
|
+
*/
|
|
208
|
+
export function createBacklog(plan, { io, repo, config = {}, base }) {
|
|
209
|
+
const spikeKey = plan.spike ?? "SPIKE";
|
|
210
|
+
const spikePlan = plan.issues.find((it) => it.key === spikeKey);
|
|
211
|
+
if (!spikePlan) throw new Error(`plan has no spike issue with key ${spikeKey}`);
|
|
212
|
+
const notes = [];
|
|
213
|
+
const board = config.project_board ?? {};
|
|
214
|
+
const pattern = config.branches?.feature_pattern || "milestone/{slug}";
|
|
215
|
+
const baseBranch = base || config.branches?.staging || "staging";
|
|
216
|
+
const gh = (args, input) => io.gh(args, input);
|
|
217
|
+
const json = (args) => JSON.parse(gh(args) || "null");
|
|
218
|
+
|
|
219
|
+
// Labels: create only the missing ones (never overwrite an existing label's colour or description).
|
|
220
|
+
const haveLabels = new Set((json(["label", "list", "-R", repo, "--limit", "1000", "--json", "name"]) ?? []).map((l) => l.name));
|
|
221
|
+
const wanted = new Set(plan.issues.flatMap((it) => [...(it.labels ?? []), priorityLabel(it)]).filter(Boolean));
|
|
222
|
+
for (const l of wanted) if (!haveLabels.has(l)) gh(["label", "create", l, "-R", repo]);
|
|
223
|
+
|
|
224
|
+
// Milestones in build order, each with its lane branch.
|
|
225
|
+
const ghMilestones = json(["api", `repos/${repo}/milestones?state=all&per_page=100`]) ?? [];
|
|
226
|
+
let baseSha = null;
|
|
227
|
+
const milestones = plan.milestones.map((m) => {
|
|
228
|
+
let reused = ghMilestones.some((g) => g.title === m.title);
|
|
229
|
+
if (!reused) gh(["api", `repos/${repo}/milestones`, "--method", "POST", "-f", `title=${m.title}`, "-f", `description=${m.description ?? ""}`, "-f", "state=open"]);
|
|
230
|
+
const slug = milestoneSlug(m.title);
|
|
231
|
+
if (!slug) throw new Error(`milestone "${m.title}" gives an empty branch slug`);
|
|
232
|
+
const branch = pattern.replace("{slug}", slug);
|
|
233
|
+
let exists = true;
|
|
234
|
+
try { gh(["api", `repos/${repo}/git/ref/heads/${branch}`]); } catch { exists = false; }
|
|
235
|
+
if (!exists) {
|
|
236
|
+
baseSha ??= gh(["api", `repos/${repo}/git/ref/heads/${baseBranch}`, "--jq", ".object.sha"]).trim();
|
|
237
|
+
gh(["api", `repos/${repo}/git/refs`, "--method", "POST", "-f", `ref=refs/heads/${branch}`, "-f", `sha=${baseSha}`]);
|
|
238
|
+
}
|
|
239
|
+
return { key: m.key, title: m.title, branch, reused, orchestrate: `/orchestrate milestone "${m.title}"` };
|
|
240
|
+
});
|
|
241
|
+
const milestoneTitle = new Map(plan.milestones.map((m) => [m.key, m.title]));
|
|
242
|
+
|
|
243
|
+
// Existing issues, read once (no search-index lag on a resume right after a create).
|
|
244
|
+
const existing = json(["issue", "list", "-R", repo, "--state", "all", "--limit", "2000", "--json", "number,title,body,url,labels"]) ?? [];
|
|
245
|
+
const numbers = new Map(Object.entries(plan.numbers ?? {}).filter(([, n]) => Number.isSafeInteger(n) && n > 0));
|
|
246
|
+
const finalBody = (it) => injectGuardrails(resolvePlaceholders(it.body, numbers), plan.guardrails, plan.domainRules);
|
|
247
|
+
const records = new Map();
|
|
248
|
+
const ensure = (it, match) => {
|
|
249
|
+
const found = existing.find((x) => x.title === it.title && match(x));
|
|
250
|
+
if (found) {
|
|
251
|
+
numbers.set(it.key, found.number);
|
|
252
|
+
records.set(it.key, { number: found.number, url: found.url, body: found.body ?? "", labels: (found.labels ?? []).map((l) => l.name), reused: true });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const args = ["issue", "create", "-R", repo, "--title", it.title, "--body-file", "-", "--milestone", milestoneTitle.get(it.milestone)];
|
|
256
|
+
for (const l of [...(it.labels ?? []), priorityLabel(it)].filter(Boolean)) args.push("--label", l);
|
|
257
|
+
const body = finalBody(it);
|
|
258
|
+
const out = gh(args, body);
|
|
259
|
+
const number = parseIssueNumber(out);
|
|
260
|
+
numbers.set(it.key, number);
|
|
261
|
+
records.set(it.key, { number, url: String(out).trim().split("\n").pop(), body, labels: [], reused: false });
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
ensure(spikePlan, () => true);
|
|
265
|
+
const spikeNo = numbers.get(spikeKey);
|
|
266
|
+
const parentRe = new RegExp(`\\*\\*Parent\\*\\*:?\\s*#${spikeNo}(?!\\d)`);
|
|
267
|
+
const subs = subIssueOrder(plan);
|
|
268
|
+
for (const it of subs) ensure(it, (x) => parentRe.test(x.body ?? ""));
|
|
269
|
+
|
|
270
|
+
// Edit pass: resolve forward references left as placeholders at create time (also finishes an interrupted run).
|
|
271
|
+
for (const it of [spikePlan, ...subs]) {
|
|
272
|
+
const rec = records.get(it.key);
|
|
273
|
+
if (!PLACEHOLDER_RE.test(rec.body)) continue;
|
|
274
|
+
const body = finalBody(it);
|
|
275
|
+
if (body === rec.body) continue;
|
|
276
|
+
gh(["issue", "edit", String(rec.number), "-R", repo, "--body-file", "-"], body);
|
|
277
|
+
rec.body = body;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Board: Status=Todo, Priority, Component (best effort per field; skipped when the board is not configured).
|
|
281
|
+
if (board.project_number && board.project_id) {
|
|
282
|
+
const owner = board.owner || config.project?.owner || repo.split("/")[0];
|
|
283
|
+
const fields = board.field_ids ?? {}, options = board.option_ids ?? {};
|
|
284
|
+
const components = Array.isArray(board.components) ? board.components : [];
|
|
285
|
+
for (const it of [spikePlan, ...subs]) {
|
|
286
|
+
const rec = records.get(it.key);
|
|
287
|
+
const itemId = gh(["project", "item-add", String(board.project_number), "--owner", owner, "--url", rec.url, "--format", "json", "--jq", ".id"]).trim();
|
|
288
|
+
if (!itemId) { notes.push(`#${rec.number}: board item id not returned; fields not set`); continue; }
|
|
289
|
+
const prio = String(it.priority ?? "").toLowerCase();
|
|
290
|
+
const comp = components.find((c) => it.component && String(c.label ?? "").toLowerCase() === String(it.component).toLowerCase())
|
|
291
|
+
?? components.find((c) => c.repo === repo);
|
|
292
|
+
const sets = [[fields.status, options.status?.todo], [fields.priority, options.priority?.[prio]], [fields.component, comp?.option_id]];
|
|
293
|
+
for (const [field, option] of sets) {
|
|
294
|
+
if (!field || !option) continue;
|
|
295
|
+
try {
|
|
296
|
+
gh(["project", "item-edit", "--project-id", board.project_id, "--id", itemId, "--field-id", field, "--single-select-option-id", option]);
|
|
297
|
+
} catch (e) { notes.push(`#${rec.number}: board field ${field} not set (${String(e?.message ?? e).slice(0, 120)})`); }
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
notes.push("project_board not configured in forge.yaml (project_number/project_id): board sync skipped");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Spike comments, posted once: a trusted marker comment already present is reused.
|
|
305
|
+
const comments = gh(["api", "--paginate", `repos/${repo}/issues/${spikeNo}/comments`]);
|
|
306
|
+
const trusted = (re) => io.sh("bash", [`${SELF_DIR}trusted-comments.sh`, "count", re], comments).trim() !== "0";
|
|
307
|
+
const attribution = String(config.branding?.show_attribution ?? "true") !== "false";
|
|
308
|
+
if (!trusted("^<!-- FORGE:BOOTSTRAP_PLAN -->")) gh(["issue", "comment", String(spikeNo), "-R", repo, "--body-file", "-"], bootstrapPlanComment(plan));
|
|
309
|
+
if (!trusted("^<!-- FORGE:DECOMPOSED -->")) gh(["issue", "comment", String(spikeNo), "-R", repo, "--body-file", "-"], decomposedComment(plan, numbers, { attribution }));
|
|
310
|
+
if (!records.get(spikeKey).labels.includes("workflow:decomposed")) {
|
|
311
|
+
io.sh("bash", [`${SELF_DIR}transition-label.sh`, String(spikeNo), "-R", repo, "decomposed"]);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const spikeRec = records.get(spikeKey);
|
|
315
|
+
return {
|
|
316
|
+
repo,
|
|
317
|
+
spike: { key: spikeKey, number: spikeNo, title: spikePlan.title, url: spikeRec.url, reused: spikeRec.reused },
|
|
318
|
+
issues: subs.map((it) => ({ key: it.key, number: numbers.get(it.key), title: it.title, url: records.get(it.key).url,
|
|
319
|
+
needsHuman: isNeedsHuman(it), milestone: milestoneTitle.get(it.milestone), reused: records.get(it.key).reused })),
|
|
320
|
+
milestones,
|
|
321
|
+
notes,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Plan + spike-doc preflight shared by the CLI and tests: [] when creation may proceed. */
|
|
326
|
+
export function preflight(plan, spikeText) {
|
|
327
|
+
const errors = [];
|
|
328
|
+
const v = validatePlan(plan);
|
|
329
|
+
if (!v.ok) errors.push(formatErrors(v.errors));
|
|
330
|
+
else if (!plan.issues.some((it) => it.key === (plan.spike ?? "SPIKE"))) errors.push(`(plan): [invalid-plan] no spike issue with key ${plan.spike ?? "SPIKE"}`);
|
|
331
|
+
if (spikeText == null) errors.push(`(spike): [missing-spike-doc] ${plan?.spikeDoc ?? DEFAULT_SPIKE_DOC} not found`);
|
|
332
|
+
else {
|
|
333
|
+
const s = checkSpikeDoc(spikeText);
|
|
334
|
+
if (s.missing.length) errors.push(`(spike): [missing-section] ${s.missing.join(", ")}`);
|
|
335
|
+
if (s.noRationale.length) errors.push(`(spike): [missing-rationale] no **Rationale**: line in ${s.noRationale.join(", ")}`);
|
|
336
|
+
}
|
|
337
|
+
return errors;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const defaultIo = {
|
|
341
|
+
gh: (args, input) => execFileSync("gh", args, { encoding: "utf8", input, stdio: ["pipe", "pipe", "pipe"], maxBuffer: 64 << 20 }),
|
|
342
|
+
sh: (cmd, args, input) => execFileSync(cmd, args, { encoding: "utf8", input, stdio: ["pipe", "pipe", "inherit"] }),
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
function readConfig(path) {
|
|
346
|
+
if (!existsSync(path)) return {};
|
|
347
|
+
try { return JSON.parse(execFileSync("yq", ["-o=json", ".", path], { encoding: "utf8" })) ?? {}; } catch { return {}; }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function main(argv, { io = defaultIo, out = process.stdout, err = process.stderr } = {}) {
|
|
351
|
+
let planPath = null, dryRun = false, asJson = false, repo = "", configPath = process.env.FORGE_CONFIG || "forge.yaml", base = "", costPerIssueUsd;
|
|
352
|
+
for (let i = 0; i < argv.length; i++) {
|
|
353
|
+
const a = argv[i];
|
|
354
|
+
const val = () => { if (i + 1 >= argv.length) throw new Error(`${a} requires a value`); return argv[++i]; };
|
|
355
|
+
try {
|
|
356
|
+
if (a === "--dry-run") dryRun = true;
|
|
357
|
+
else if (a === "--json") asJson = true;
|
|
358
|
+
else if (a === "--repo") repo = val();
|
|
359
|
+
else if (a === "--config") configPath = val();
|
|
360
|
+
else if (a === "--base") base = val();
|
|
361
|
+
else if (a === "--cost-per-issue") {
|
|
362
|
+
costPerIssueUsd = Number(val());
|
|
363
|
+
if (!Number.isFinite(costPerIssueUsd) || costPerIssueUsd <= 0) throw new Error("--cost-per-issue must be a positive number");
|
|
364
|
+
}
|
|
365
|
+
else if (!planPath && !a.startsWith("--")) planPath = a;
|
|
366
|
+
else throw new Error(`unknown argument ${a}`);
|
|
367
|
+
} catch (e) { err.write(`bootstrap-create: ${e.message}\n`); return 2; }
|
|
368
|
+
}
|
|
369
|
+
if (!planPath) { err.write("usage: node scripts/bootstrap-create.mjs <plan.json> [--dry-run] [--json] [--repo OWNER/NAME] [--config forge.yaml] [--base <branch>] [--cost-per-issue <usd>]\n"); return 2; }
|
|
370
|
+
let plan;
|
|
371
|
+
try { plan = JSON.parse(readFileSync(planPath, "utf8")); } catch (e) { err.write(`bootstrap-create: cannot read plan: ${e.message}\n`); return 2; }
|
|
372
|
+
const spikePath = plan?.spikeDoc ?? DEFAULT_SPIKE_DOC;
|
|
373
|
+
const spikeText = existsSync(spikePath) ? readFileSync(spikePath, "utf8") : null;
|
|
374
|
+
const errors = preflight(plan, spikeText);
|
|
375
|
+
if (errors.length) { err.write(errors.join("\n") + "\nbootstrap-create: not creating anything; fix the plan or spike doc\n"); return 1; }
|
|
376
|
+
if (dryRun) { out.write(planPreview(plan, { costPerIssueUsd }) + "\n"); return 0; }
|
|
377
|
+
|
|
378
|
+
const config = readConfig(configPath);
|
|
379
|
+
repo ||= config.project?.owner && config.project?.repo ? `${config.project.owner}/${config.project.repo}` : "";
|
|
380
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(repo)) { err.write("bootstrap-create: no target repository: pass --repo OWNER/NAME or a forge.yaml with project.owner/project.repo\n"); return 2; }
|
|
381
|
+
let result;
|
|
382
|
+
try { result = createBacklog(plan, { io, repo, config, base }); } catch (e) {
|
|
383
|
+
err.write(`bootstrap-create: GitHub error: ${String(e?.stderr || e?.message || e).trim().slice(0, 500)}\nRe-run the same command to resume; existing issues are reused.\n`);
|
|
384
|
+
return 3;
|
|
385
|
+
}
|
|
386
|
+
out.write((asJson ? JSON.stringify(result, null, 2) : handoffText(result)) + "\n");
|
|
387
|
+
return 0;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Run as CLI only when invoked directly, not when imported by tests.
|
|
391
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
392
|
+
process.exitCode = main(process.argv.slice(2));
|
|
393
|
+
}
|
|
@@ -11,12 +11,14 @@
|
|
|
11
11
|
* "labels": ["backend", ...], // optional: when given, every issue label must be in it
|
|
12
12
|
* "milestones": [{ "key": "M1", "title": "..." }, ...], // build order: foundation first
|
|
13
13
|
* "issues": [{ "key": "API", "title": "...", "milestone": "M1", "labels": ["backend"],
|
|
14
|
-
* "deps": ["DB"], "needsHuman": false, "body": "## Problem\n..." }],
|
|
14
|
+
* "deps": ["DB"], "needsHuman": false, "ui": false, "hero": false, "body": "## Problem\n..." }],
|
|
15
15
|
* "numbers": { "DB": 12 } // optional: real issue numbers once created
|
|
16
16
|
* }
|
|
17
17
|
*
|
|
18
18
|
* Rejects: invalid-plan, unknown-dep, cycle, forward-milestone-dep, depends-on-mismatch, unresolved-placeholder,
|
|
19
|
-
* missing-section, missing-type-tag, missing-parent, missing-labels, unknown-label, code-depends-on-needs-human
|
|
19
|
+
* missing-section, missing-type-tag, missing-parent, missing-labels, unknown-label, code-depends-on-needs-human,
|
|
20
|
+
* missing-design-spec (a `ui` issue lacks a design-spec item), hero-not-routed (a `hero` issue never names `/design`),
|
|
21
|
+
* missing-needs-human-section (a needs-human issue lacks setup steps, env var names or secret handling) (#4176).
|
|
20
22
|
* `Depends on` lines are read with the engine's own parser (bin/engine/phases.mjs parseDependsOn) after placeholder
|
|
21
23
|
* resolution, so the plan and engine admission agree on what blocks what.
|
|
22
24
|
*
|
|
@@ -45,6 +47,25 @@ const SYNTHETIC_BASE = 1_000_000_000;
|
|
|
45
47
|
// ignoring a trailing parenthetical such as "Root Cause (if known)".
|
|
46
48
|
const SECTIONS = ["Problem", "Root Cause", "Affected Files", "Acceptance Criteria", "Context", "Dependencies"];
|
|
47
49
|
|
|
50
|
+
// UI issues carry a full design spec (#4176): each item must be named somewhere in the body.
|
|
51
|
+
const DESIGN_SPEC = [
|
|
52
|
+
["breakpoints", /breakpoint/i],
|
|
53
|
+
["empty state", /\bempty\b/i],
|
|
54
|
+
["loading state", /\bloading\b/i],
|
|
55
|
+
["error state", /\berror\b/i],
|
|
56
|
+
["offline state", /\boffline\b/i],
|
|
57
|
+
["motion", /\bmotion\b/i],
|
|
58
|
+
["accessibility", /\b(?:accessibility|a11y)\b/i],
|
|
59
|
+
["dark mode", /\bdark[ -]mode\b/i],
|
|
60
|
+
];
|
|
61
|
+
// Needs-human issues (#4176): headings for setup steps, env vars and secret handling, plus at least one env var name.
|
|
62
|
+
const NEEDS_HUMAN_PARTS = [
|
|
63
|
+
["setup steps heading", /^#{2,6}[ \t]+.*\bsetup steps\b/im],
|
|
64
|
+
["environment variables heading", /^#{2,6}[ \t]+.*\b(?:environment variables|env vars)\b/im],
|
|
65
|
+
["secret handling heading", /^#{2,6}[ \t]+.*\bsecrets?\b/im],
|
|
66
|
+
["env var name (e.g. `API_KEY`)", /`[A-Z][A-Z0-9]*_[A-Z0-9_]+`/],
|
|
67
|
+
];
|
|
68
|
+
|
|
48
69
|
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
49
70
|
const isNonEmptyString = (v) => typeof v === "string" && v.trim() !== "";
|
|
50
71
|
const normHeading = (h) => h.replace(/\([^)]*\)\s*$/, "").trim().toLowerCase();
|
|
@@ -125,6 +146,7 @@ function checkStructure(plan) {
|
|
|
125
146
|
at("`labels` must be an array of strings");
|
|
126
147
|
}
|
|
127
148
|
if (typeof it.body !== "string") at("needs a string `body`");
|
|
149
|
+
for (const f of ["ui", "hero"]) if (it[f] !== undefined && typeof it[f] !== "boolean") at(`\`${f}\` must be a boolean`);
|
|
128
150
|
});
|
|
129
151
|
return errors;
|
|
130
152
|
}
|
|
@@ -242,6 +264,18 @@ export function validatePlan(plan) {
|
|
|
242
264
|
}
|
|
243
265
|
const ctx = secs.get("context");
|
|
244
266
|
if (ctx && !ctx.some((l) => l.includes("**Parent**"))) err(it.key, "missing-parent", "`## Context` has no `**Parent**` line");
|
|
267
|
+
|
|
268
|
+
if (it.ui === true) {
|
|
269
|
+
const lacking = DESIGN_SPEC.filter(([, re]) => !re.test(it.body)).map(([name]) => name);
|
|
270
|
+
if (lacking.length) err(it.key, "missing-design-spec", `UI issue design spec lacks: ${lacking.join(", ")}`);
|
|
271
|
+
}
|
|
272
|
+
if (it.hero === true && !/(^|[^\w/])\/design\b/.test(it.body)) {
|
|
273
|
+
err(it.key, "hero-not-routed", "hero surface issue does not route to `/design`");
|
|
274
|
+
}
|
|
275
|
+
if (isNeedsHuman(it)) {
|
|
276
|
+
const lacking = NEEDS_HUMAN_PARTS.filter(([, re]) => !re.test(it.body)).map(([name]) => name);
|
|
277
|
+
if (lacking.length) err(it.key, "missing-needs-human-section", `needs-human issue lacks: ${lacking.join(", ")}`);
|
|
278
|
+
}
|
|
245
279
|
}
|
|
246
280
|
return { ok: errors.length === 0, errors };
|
|
247
281
|
}
|
package/scripts/calibration.mjs
CHANGED
|
@@ -36,6 +36,12 @@
|
|
|
36
36
|
* --dry-run Compute table(s) but do not write any files
|
|
37
37
|
* --verbose Verbose logging
|
|
38
38
|
* --issue <number> Compute outcome for a single issue only (debugging)
|
|
39
|
+
* --shadow Instead: the per-repo conformance-gate shadow verdict distribution (forge#4160),
|
|
40
|
+
* read from FORGE:REVIEW comments, and whether to switch engine.conformance to enforce
|
|
41
|
+
* (--repo accepts a comma-separated list)
|
|
42
|
+
* --threshold <share> --shadow: recommend enforce while would-block-on-incomplete stays under this share (default 0.10)
|
|
43
|
+
* --min-runs <n> --shadow: minimum shadow reviews before recommending enforce (default 20)
|
|
44
|
+
* --json --shadow: print JSON instead of the text table
|
|
39
45
|
*
|
|
40
46
|
* Exit codes:
|
|
41
47
|
* 0 — success
|
|
@@ -75,6 +81,11 @@ const SCHEMA_VERSION = 1;
|
|
|
75
81
|
// larger and cells accumulate samples more slowly.
|
|
76
82
|
const DEFAULT_PROVENANCE_MIN_SAMPLES = 5;
|
|
77
83
|
|
|
84
|
+
// forge#4160: the conformance gate graduates from shadow to enforce once incomplete reviews are rare.
|
|
85
|
+
const DEFAULT_SHADOW_THRESHOLD = 0.10;
|
|
86
|
+
const DEFAULT_SHADOW_MIN_RUNS = 20;
|
|
87
|
+
const SHADOW_VERDICTS = ['pass', 'nonconforming', 'review.incomplete', 'review.inconsistent', 'review.unparseable'];
|
|
88
|
+
|
|
78
89
|
// Survival rate threshold above which a cell is classified as PROVEN (eligible
|
|
79
90
|
// for optional-agent de-escalation).
|
|
80
91
|
const PROVENANCE_PROVEN_THRESHOLD = 0.90;
|
|
@@ -105,6 +116,10 @@ export function parseArgs(argv) {
|
|
|
105
116
|
dryRun: false,
|
|
106
117
|
verbose: false,
|
|
107
118
|
issue: null,
|
|
119
|
+
shadow: false,
|
|
120
|
+
threshold: DEFAULT_SHADOW_THRESHOLD,
|
|
121
|
+
minRuns: DEFAULT_SHADOW_MIN_RUNS,
|
|
122
|
+
json: false,
|
|
108
123
|
};
|
|
109
124
|
|
|
110
125
|
for (let i = 2; i < argv.length; i++) {
|
|
@@ -120,6 +135,10 @@ export function parseArgs(argv) {
|
|
|
120
135
|
else if (arg === '--dry-run') { args.dryRun = true; }
|
|
121
136
|
else if (arg === '--verbose') { args.verbose = true; }
|
|
122
137
|
else if (arg === '--issue' && argv[i + 1]) { args.issue = parseInt(argv[++i], 10); }
|
|
138
|
+
else if (arg === '--shadow') { args.shadow = true; }
|
|
139
|
+
else if (arg === '--threshold' && argv[i + 1]) { args.threshold = parseFloat(argv[++i]); }
|
|
140
|
+
else if (arg === '--min-runs' && argv[i + 1]) { args.minRuns = parseInt(argv[++i], 10); }
|
|
141
|
+
else if (arg === '--json') { args.json = true; }
|
|
123
142
|
}
|
|
124
143
|
|
|
125
144
|
return args;
|
|
@@ -812,6 +831,98 @@ export function publishTable(table, repoPath, dryRun = false) {
|
|
|
812
831
|
// Main pipeline
|
|
813
832
|
// ---------------------------------------------------------------------------
|
|
814
833
|
|
|
834
|
+
// ---------------------------------------------------------------------------
|
|
835
|
+
// Shadow verdict distribution (forge#4160)
|
|
836
|
+
// ---------------------------------------------------------------------------
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* The shadow gate verdict in one FORGE:REVIEW comment body: "pass", or the would-block state
|
|
840
|
+
* (nonconforming | review.incomplete | review.inconsistent | review.unparseable). Null for anything else,
|
|
841
|
+
* including an enforced review.
|
|
842
|
+
* @param {string} body
|
|
843
|
+
* @returns {string|null}
|
|
844
|
+
*/
|
|
845
|
+
export function parseShadowVerdict(body) {
|
|
846
|
+
const b = String(body ?? '');
|
|
847
|
+
if (!b.includes('FORGE:REVIEW')) return null;
|
|
848
|
+
const m = /\*\*Gate verdict:\*\*\s*would (PASS|BLOCK \(([^)]+)\))/.exec(b);
|
|
849
|
+
if (!m) return null;
|
|
850
|
+
return m[1] === 'PASS' ? 'pass' : m[2].trim();
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Per-repo counts and shares of shadow verdicts.
|
|
855
|
+
* @param {{repo: string, verdict: string}[]} records
|
|
856
|
+
* @returns {Record<string, {runs: number, counts: Record<string, number>, shares: Record<string, number>}>}
|
|
857
|
+
*/
|
|
858
|
+
export function shadowDistribution(records) {
|
|
859
|
+
const out = {};
|
|
860
|
+
for (const r of records ?? []) {
|
|
861
|
+
if (!r?.repo || !r?.verdict) continue;
|
|
862
|
+
const e = out[r.repo] ??= { runs: 0, counts: Object.fromEntries(SHADOW_VERDICTS.map((v) => [v, 0])), shares: {} };
|
|
863
|
+
e.runs++;
|
|
864
|
+
e.counts[r.verdict] = (e.counts[r.verdict] ?? 0) + 1;
|
|
865
|
+
}
|
|
866
|
+
for (const e of Object.values(out)) e.shares = Object.fromEntries(Object.entries(e.counts).map(([k, n]) => [k, e.runs ? Math.round((n / e.runs) * 1000) / 1000 : 0]));
|
|
867
|
+
return out;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* enforce once there are at least minRuns shadow reviews and the would-block-on-incomplete share is under threshold.
|
|
872
|
+
* @param {{runs: number, counts: Record<string, number>}} entry
|
|
873
|
+
* @param {{threshold?: number, minRuns?: number}} [o]
|
|
874
|
+
* @returns {{mode: 'enforce'|'shadow', incompleteShare: number, reason: string}}
|
|
875
|
+
*/
|
|
876
|
+
export function recommendEnforce(entry, { threshold = DEFAULT_SHADOW_THRESHOLD, minRuns = DEFAULT_SHADOW_MIN_RUNS } = {}) {
|
|
877
|
+
const runs = entry?.runs ?? 0;
|
|
878
|
+
const share = runs ? (entry.counts?.['review.incomplete'] ?? 0) / runs : 0;
|
|
879
|
+
const pct = (x) => `${Math.round(x * 1000) / 10}%`;
|
|
880
|
+
if (runs < minRuns) return { mode: 'shadow', incompleteShare: share, reason: `only ${runs} shadow review(s); need ${minRuns}` };
|
|
881
|
+
if (share >= threshold) return { mode: 'shadow', incompleteShare: share, reason: `would-block-on-incomplete ${pct(share)} is not under ${pct(threshold)}` };
|
|
882
|
+
return { mode: 'enforce', incompleteShare: share, reason: `would-block-on-incomplete ${pct(share)} < ${pct(threshold)} over ${runs} runs` };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Shadow FORGE:REVIEW verdicts for one repo, from its issue comments.
|
|
887
|
+
* @param {string} repo
|
|
888
|
+
* @param {(path: string, flags: string[]) => string} [gh]
|
|
889
|
+
* @returns {{repo: string, issue: number|null, verdict: string}[]}
|
|
890
|
+
*/
|
|
891
|
+
export function fetchShadowRecords(repo, gh = ghApi) {
|
|
892
|
+
const out = gh(`repos/${repo}/issues/comments?per_page=100`, ['--paginate', '--jq', '.[] | select(.body | contains("<!-- FORGE:REVIEW -->")) | {issue_url, body}']);
|
|
893
|
+
const records = [];
|
|
894
|
+
for (const l of String(out).split('\n')) {
|
|
895
|
+
let c; try { c = JSON.parse(l); } catch { continue; }
|
|
896
|
+
const verdict = parseShadowVerdict(c?.body);
|
|
897
|
+
if (verdict) records.push({ repo, issue: Number(/\/issues\/(\d+)$/.exec(String(c.issue_url ?? ''))?.[1]) || null, verdict });
|
|
898
|
+
}
|
|
899
|
+
return records;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** Text table: one row per repo with the verdict mix and the recommendation. */
|
|
903
|
+
export function formatShadowReport(report) {
|
|
904
|
+
const lines = ['| Repo | Runs | ' + SHADOW_VERDICTS.join(' | ') + ' | Recommend |', '| --- | --- | ' + SHADOW_VERDICTS.map(() => '---').join(' | ') + ' | --- |'];
|
|
905
|
+
for (const [repo, e] of Object.entries(report.repos)) {
|
|
906
|
+
lines.push(`| ${repo} | ${e.runs} | ${SHADOW_VERDICTS.map((v) => `${e.counts[v] ?? 0} (${Math.round((e.shares[v] ?? 0) * 100)}%)`).join(' | ')} | ${e.recommendation.mode}: ${e.recommendation.reason} |`);
|
|
907
|
+
}
|
|
908
|
+
return lines.join('\n');
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* --shadow entry: per-repo distribution plus an enforce recommendation each.
|
|
913
|
+
* @returns {{shadow: true, repos: object}}
|
|
914
|
+
*/
|
|
915
|
+
export function runShadow(args, repoPath, gh = ghApi) {
|
|
916
|
+
const repos = String(args.repo || readForgeYaml(repoPath) || '').split(',').map((r) => r.trim()).filter(Boolean);
|
|
917
|
+
if (!repos.length) throw new Error('Cannot determine repo: no --repo flag and forge.yaml not found or missing project.owner/repo');
|
|
918
|
+
const records = repos.flatMap((r) => fetchShadowRecords(r, gh));
|
|
919
|
+
const dist = shadowDistribution(records);
|
|
920
|
+
const zero = () => Object.fromEntries(SHADOW_VERDICTS.map((v) => [v, 0]));
|
|
921
|
+
for (const r of repos) dist[r] ??= { runs: 0, counts: zero(), shares: zero() };
|
|
922
|
+
for (const e of Object.values(dist)) e.recommendation = recommendEnforce(e, { threshold: args.threshold, minRuns: args.minRuns });
|
|
923
|
+
return { shadow: true, repos: dist };
|
|
924
|
+
}
|
|
925
|
+
|
|
815
926
|
/**
|
|
816
927
|
* Main entry point (exported for testing).
|
|
817
928
|
*
|
|
@@ -821,6 +932,7 @@ export function publishTable(table, repoPath, dryRun = false) {
|
|
|
821
932
|
*/
|
|
822
933
|
export async function run(args, repoPath) {
|
|
823
934
|
_verbose = args.verbose;
|
|
935
|
+
if (args.shadow) return runShadow(args, repoPath);
|
|
824
936
|
|
|
825
937
|
// Resolve repo name
|
|
826
938
|
const repo = args.repo || readForgeYaml(repoPath);
|
|
@@ -965,6 +1077,10 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
|
965
1077
|
|
|
966
1078
|
run(args, repoPath)
|
|
967
1079
|
.then(result => {
|
|
1080
|
+
if (result?.shadow) {
|
|
1081
|
+
process.stdout.write((args.json ? JSON.stringify(result, null, 2) : formatShadowReport(result)) + '\n');
|
|
1082
|
+
process.exit(0);
|
|
1083
|
+
}
|
|
968
1084
|
const table = result.table || result;
|
|
969
1085
|
const provenanceTable = result.provenanceTable || null;
|
|
970
1086
|
const errorCount = result.errorCount || 0;
|
|
@@ -10,14 +10,19 @@
|
|
|
10
10
|
# Exempt: a `docs-not-needed: <reason>` line (non-empty reason) in the PR body.
|
|
11
11
|
# Deterministic, no LLM. bash 3.2 portable (no bash-4 builtins, no PCRE grep).
|
|
12
12
|
#
|
|
13
|
-
# Usage: bash scripts/check-devdocs-freshness.sh <base-ref> [--body-file <file>]
|
|
13
|
+
# Usage: bash scripts/check-devdocs-freshness.sh [<base-ref>] [--body-file <file>]
|
|
14
|
+
# No base-ref: the current branch's upstream (@{upstream}), else origin/HEAD (the remote default branch).
|
|
14
15
|
# PR body: --body-file, else env PR_BODY (empty when unset). Diff: <base-ref>...HEAD, run from the repo root.
|
|
15
16
|
# Exit: 0 fresh / exempt / nothing to check | 1 docs missing | 2 bad usage or unknown base ref
|
|
16
17
|
|
|
17
18
|
set -u
|
|
18
|
-
BASE="
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
BASE=""
|
|
20
|
+
case "${1:-}" in
|
|
21
|
+
""|--*) BASE=$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null) \
|
|
22
|
+
|| BASE=$(git rev-parse --abbrev-ref origin/HEAD 2>/dev/null) || BASE="" ;;
|
|
23
|
+
*) BASE="$1"; shift ;;
|
|
24
|
+
esac
|
|
25
|
+
[ -n "$BASE" ] || { echo "usage: check-devdocs-freshness.sh [<base-ref>] [--body-file <file>] (no upstream or origin/HEAD to default to)" >&2; exit 2; }
|
|
21
26
|
BODY="${PR_BODY:-}"
|
|
22
27
|
while [ $# -gt 0 ]; do
|
|
23
28
|
case "$1" in
|