forgedock 1.26.0 → 1.28.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.
Files changed (137) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +13 -0
  4. package/bin/engine/phases.mjs +38 -2
  5. package/bin/engine/telemetry-seam.mjs +41 -6
  6. package/bin/engine/telemetry-seam.test.mjs +35 -0
  7. package/bin/engine-cli.mjs +616 -54
  8. package/bin/engine.mjs +13 -5
  9. package/bin/forgedock.mjs +169 -39
  10. package/bin/gc.mjs +580 -0
  11. package/bin/init-detect.mjs +68 -3
  12. package/bin/init-enrich.mjs +3 -0
  13. package/bin/init-repo.mjs +213 -0
  14. package/bin/journey.mjs +278 -43
  15. package/bin/lean/admission.mjs +33 -3
  16. package/bin/lean/batch.mjs +24 -5
  17. package/bin/lean/batch.test.mjs +54 -4
  18. package/bin/lean/checks.mjs +199 -0
  19. package/bin/lean/checks.test.mjs +183 -0
  20. package/bin/lean/conformance.mjs +235 -37
  21. package/bin/lean/conformance.test.mjs +143 -5
  22. package/bin/lean/credentials.mjs +29 -0
  23. package/bin/lean/depcache.mjs +312 -0
  24. package/bin/lean/depcache.test.mjs +298 -0
  25. package/bin/lean/drift.mjs +198 -0
  26. package/bin/lean/drift.test.mjs +223 -0
  27. package/bin/lean/effects.mjs +178 -16
  28. package/bin/lean/execution.e2e.test.mjs +81 -0
  29. package/bin/lean/execution.mjs +431 -0
  30. package/bin/lean/execution.test.mjs +583 -0
  31. package/bin/lean/heavy.mjs +289 -0
  32. package/bin/lean/heavy.test.mjs +207 -0
  33. package/bin/lean/lean.mjs +877 -151
  34. package/bin/lean/lean.test.mjs +1798 -28
  35. package/bin/lean/ledger.mjs +92 -0
  36. package/bin/lean/ledger.test.mjs +100 -0
  37. package/bin/lean/limits.mjs +58 -0
  38. package/bin/lean/limits.test.mjs +150 -0
  39. package/bin/lean/orchestrate.mjs +321 -19
  40. package/bin/lean/prompts.mjs +125 -17
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +25 -6
  44. package/bin/lean/review-trigger.mjs +134 -0
  45. package/bin/lean/review-trigger.test.mjs +104 -0
  46. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  47. package/bin/lean/sandbox-remote.mjs +142 -0
  48. package/bin/lean/sched.mjs +221 -0
  49. package/bin/lean/sched.test.mjs +310 -0
  50. package/bin/lean/schedd.mjs +247 -0
  51. package/bin/lean/schedd.test.mjs +180 -0
  52. package/bin/lean/toolchain.mjs +228 -0
  53. package/bin/lean/toolchain.test.mjs +154 -0
  54. package/bin/lean/verify.mjs +124 -0
  55. package/bin/lean/verify.test.mjs +118 -0
  56. package/bin/lean/worktree.mjs +69 -0
  57. package/bin/lean/worktree.test.mjs +141 -0
  58. package/bin/runner.mjs +53 -10
  59. package/bin/stats/github-source.mjs +1 -1
  60. package/bin/stats/index.mjs +14 -2
  61. package/bin/stats/query.mjs +72 -0
  62. package/bin/stats/stats.test.mjs +54 -0
  63. package/commands/adopt.md +3 -0
  64. package/commands/audit.md +5 -8
  65. package/commands/autopilot.md +2 -0
  66. package/commands/bootstrap.md +35 -0
  67. package/commands/cleanup.md +7 -22
  68. package/commands/forge-stats.md +2 -1
  69. package/commands/forgedock-init.md +125 -6
  70. package/commands/issue.md +89 -830
  71. package/commands/milestone.md +55 -6
  72. package/commands/orchestrate.md +10 -2
  73. package/commands/review-pr.md +2 -2
  74. package/commands/upgrade-deps.md +3 -3
  75. package/commands/work-on/build/validate.md +4 -0
  76. package/commands/work-on/close.md +2 -0
  77. package/commands/work-on/decompose.md +24 -6
  78. package/docs/CI.md +3 -3
  79. package/docs/CONFIG.md +227 -3
  80. package/docs/OUTCOME-CODES.md +11 -0
  81. package/docs/TELEMETRY.md +1 -1
  82. package/docs/site/command-reference.md +22 -0
  83. package/docs/site/getting-started.md +2 -0
  84. package/docs/site/start-a-new-project.md +327 -0
  85. package/package.json +1 -1
  86. package/scripts/affected-files-check.sh +91 -0
  87. package/scripts/affected-files-check.test.sh +80 -0
  88. package/scripts/bootstrap-create.mjs +393 -0
  89. package/scripts/bootstrap-validate.mjs +321 -0
  90. package/scripts/calibration.mjs +116 -0
  91. package/scripts/check-devdocs-freshness.sh +125 -0
  92. package/scripts/check-devdocs-freshness.test.sh +130 -0
  93. package/scripts/check-spec-markers.sh +3 -0
  94. package/scripts/check-spec-size.sh +29 -11
  95. package/scripts/check-spec-size.test.sh +27 -4
  96. package/scripts/ci-local/lib.mjs +8 -1
  97. package/scripts/ci-local/parallelism.test.mjs +27 -0
  98. package/scripts/ci-local/run.mjs +16 -5
  99. package/scripts/ci-local/scoping.mjs +82 -0
  100. package/scripts/ci-local.test.sh +14 -2
  101. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  102. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  103. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  106. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  107. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  108. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  109. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  110. package/scripts/forge-root.test.sh +1 -1
  111. package/scripts/hotspots.sh +100 -0
  112. package/scripts/hotspots.test.sh +101 -0
  113. package/scripts/issue-create.sh +197 -0
  114. package/scripts/issue-create.test.sh +166 -0
  115. package/scripts/maintenance-scan.sh +185 -0
  116. package/scripts/maintenance-scan.test.sh +89 -0
  117. package/scripts/milestone-drift.sh +206 -0
  118. package/scripts/milestone-drift.test.sh +120 -0
  119. package/scripts/northstar-docs.test.sh +68 -0
  120. package/scripts/refactor-verify.sh +368 -0
  121. package/scripts/refactor-verify.test.sh +202 -0
  122. package/scripts/review-prepare.sh +11 -9
  123. package/scripts/review-prepare.test.sh +14 -0
  124. package/scripts/structure-delta.sh +155 -0
  125. package/scripts/structure-delta.test.sh +136 -0
  126. package/scripts/symbol-index.mjs +253 -0
  127. package/scripts/symbol-index.py +354 -0
  128. package/scripts/symbol-index.test.sh +119 -0
  129. package/scripts/symbol-move-ts.test.sh +161 -0
  130. package/scripts/symbol-move.mjs +379 -0
  131. package/scripts/symbol-move.py +383 -0
  132. package/scripts/symbol-move.test.sh +168 -0
  133. package/templates/bootstrap/interview.md +34 -0
  134. package/templates/bootstrap/issue-body.md +42 -0
  135. package/templates/bootstrap/needs-human-issue.md +45 -0
  136. package/templates/bootstrap/stack-profiles.md +34 -0
  137. package/templates/bootstrap/technical-spike.md +75 -0
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/bootstrap-validate.mjs — mechanical backlog plan validator for /bootstrap (#4171, part of #4155)
4
+ *
5
+ * Validates a JSON backlog plan BEFORE any issue is created: milestones in build order, issues with plan keys,
6
+ * `#{{KEY}}` placeholders standing in for not-yet-created issue numbers, planned dependencies and bodies.
7
+ * Codebase-agnostic: it reads only the plan, never the repo, so planned (not-yet-existing) paths are fine.
8
+ *
9
+ * Plan shape:
10
+ * {
11
+ * "labels": ["backend", ...], // optional: when given, every issue label must be in it
12
+ * "milestones": [{ "key": "M1", "title": "..." }, ...], // build order: foundation first
13
+ * "issues": [{ "key": "API", "title": "...", "milestone": "M1", "labels": ["backend"],
14
+ * "deps": ["DB"], "needsHuman": false, "ui": false, "hero": false, "body": "## Problem\n..." }],
15
+ * "numbers": { "DB": 12 } // optional: real issue numbers once created
16
+ * }
17
+ *
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,
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).
22
+ * `Depends on` lines are read with the engine's own parser (bin/engine/phases.mjs parseDependsOn) after placeholder
23
+ * resolution, so the plan and engine admission agree on what blocks what.
24
+ *
25
+ * Usage:
26
+ * node scripts/bootstrap-validate.mjs <plan.json>
27
+ * node scripts/bootstrap-validate.mjs - # read the plan from stdin
28
+ *
29
+ * Exit codes:
30
+ * 0 = plan valid
31
+ * 1 = plan invalid (one `<KEY>: [code] message` line per error on stderr)
32
+ * 2 = usage error, unreadable file or bad JSON
33
+ */
34
+
35
+ import { readFileSync } from "node:fs";
36
+ import { fileURLToPath } from "node:url";
37
+ import { parseDependsOn } from "../bin/engine/phases.mjs";
38
+
39
+ const NEEDS_HUMAN_LABEL = "needs-human";
40
+ const TYPE_TAG_RE = /\[type:(?:api|unit|e2e|manual)\]/;
41
+ const CHECKBOX_RE = /^[ \t]*[-*+][ \t]+\[[ xX]\]/;
42
+ const PLACEHOLDER_RE = /#\{\{([^{}]*)\}\}/g;
43
+ // Synthetic numbers for keys without a real number: far above any real issue number, so they never collide.
44
+ const SYNTHETIC_BASE = 1_000_000_000;
45
+
46
+ // Mandatory Pipeline Issue Template sections (commands/issue.md Phase 3D). Matched case-insensitively on `## ` headings,
47
+ // ignoring a trailing parenthetical such as "Root Cause (if known)".
48
+ const SECTIONS = ["Problem", "Root Cause", "Affected Files", "Acceptance Criteria", "Context", "Dependencies"];
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
+
69
+ const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
70
+ const isNonEmptyString = (v) => typeof v === "string" && v.trim() !== "";
71
+ const normHeading = (h) => h.replace(/\([^)]*\)\s*$/, "").trim().toLowerCase();
72
+
73
+ /** True when the issue is a needs-human issue (credential, account or external input). */
74
+ export function isNeedsHuman(issue) {
75
+ return issue?.needsHuman === true || (Array.isArray(issue?.labels) && issue.labels.includes(NEEDS_HUMAN_LABEL));
76
+ }
77
+
78
+ /**
79
+ * Replace every `#{{KEY}}` whose KEY is in `keyToNumber` with `#N`. Unknown keys are left in place.
80
+ * @param {string} body
81
+ * @param {Record<string, number>|Map<string, number>} keyToNumber
82
+ */
83
+ export function resolvePlaceholders(body, keyToNumber) {
84
+ const map = keyToNumber instanceof Map ? keyToNumber : new Map(Object.entries(keyToNumber ?? {}));
85
+ return String(body ?? "").replace(PLACEHOLDER_RE, (whole, key) => {
86
+ const n = map.get(key);
87
+ return Number.isSafeInteger(n) && n > 0 ? `#${n}` : whole;
88
+ });
89
+ }
90
+
91
+ /** `## ` sections of a markdown body (fenced code blocks ignored): normalized heading -> content lines. */
92
+ function sections(body) {
93
+ const out = new Map();
94
+ let current = null;
95
+ let fence = null;
96
+ for (const line of String(body).replace(/\r\n/g, "\n").split("\n")) {
97
+ const f = line.match(/^[ \t]*(`{3,}|~{3,})/);
98
+ if (f) {
99
+ if (!fence) fence = f[1][0];
100
+ else if (f[1][0] === fence) fence = null;
101
+ if (current) current.push(line);
102
+ continue;
103
+ }
104
+ const h = !fence && line.match(/^##(?!#)[ \t]+(.+?)[ \t]*#*[ \t]*$/);
105
+ if (h) {
106
+ current = [];
107
+ const name = normHeading(h[1]);
108
+ if (!out.has(name)) out.set(name, current);
109
+ continue;
110
+ }
111
+ if (current) current.push(line);
112
+ }
113
+ return out;
114
+ }
115
+
116
+ function checkStructure(plan) {
117
+ const errors = [];
118
+ const bad = (message) => errors.push({ issue: null, code: "invalid-plan", message });
119
+ if (!isObj(plan)) return [{ issue: null, code: "invalid-plan", message: "plan must be a JSON object" }];
120
+ if (!Array.isArray(plan.milestones) || plan.milestones.length === 0) bad("`milestones` must be a non-empty array");
121
+ if (!Array.isArray(plan.issues) || plan.issues.length === 0) bad("`issues` must be a non-empty array");
122
+ if (plan.labels !== undefined && !(Array.isArray(plan.labels) && plan.labels.every(isNonEmptyString))) {
123
+ bad("`labels` must be an array of strings when given");
124
+ }
125
+ if (plan.numbers !== undefined && !isObj(plan.numbers)) bad("`numbers` must be an object of KEY -> issue number");
126
+ if (errors.length) return errors;
127
+
128
+ const milestoneKeys = new Set();
129
+ plan.milestones.forEach((m, i) => {
130
+ if (!isObj(m) || !isNonEmptyString(m.key)) return bad(`milestones[${i}] needs a string \`key\``);
131
+ if (milestoneKeys.has(m.key)) bad(`duplicate milestone key ${m.key}`);
132
+ milestoneKeys.add(m.key);
133
+ });
134
+ const issueKeys = new Set();
135
+ plan.issues.forEach((it, i) => {
136
+ if (!isObj(it) || !isNonEmptyString(it.key)) return bad(`issues[${i}] needs a string \`key\``);
137
+ const at = (message) => errors.push({ issue: it.key, code: "invalid-plan", message });
138
+ if (issueKeys.has(it.key)) at(`duplicate issue key ${it.key}`);
139
+ issueKeys.add(it.key);
140
+ if (!isNonEmptyString(it.title)) at("needs a non-empty `title`");
141
+ if (!milestoneKeys.has(it.milestone)) at(`unknown milestone ${JSON.stringify(it.milestone)}`);
142
+ if (it.deps !== undefined && !(Array.isArray(it.deps) && it.deps.every(isNonEmptyString))) {
143
+ at("`deps` must be an array of issue keys");
144
+ }
145
+ if (it.labels !== undefined && !(Array.isArray(it.labels) && it.labels.every((l) => typeof l === "string"))) {
146
+ at("`labels` must be an array of strings");
147
+ }
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`);
150
+ });
151
+ return errors;
152
+ }
153
+
154
+ /** Issue keys on dependency cycles, each with one cycle path through it (DFS over known deps). */
155
+ function findCycles(issues, byKey) {
156
+ const state = new Map(); // key -> 1 visiting | 2 done
157
+ const stack = [];
158
+ const onCycle = new Map();
159
+ const visit = (key) => {
160
+ state.set(key, 1);
161
+ stack.push(key);
162
+ for (const d of byKey.get(key).deps ?? []) {
163
+ if (!byKey.has(d)) continue;
164
+ if (state.get(d) === 1) {
165
+ const path = [...stack.slice(stack.indexOf(d)), d];
166
+ for (const k of path) if (!onCycle.has(k)) onCycle.set(k, path);
167
+ } else if (!state.has(d)) visit(d);
168
+ }
169
+ stack.pop();
170
+ state.set(key, 2);
171
+ };
172
+ for (const it of issues) if (!state.has(it.key)) visit(it.key);
173
+ return onCycle;
174
+ }
175
+
176
+ /**
177
+ * Validate a backlog plan. Never throws on bad input; structural problems come back as `invalid-plan` errors.
178
+ * @returns {{ ok: boolean, errors: Array<{ issue: string|null, code: string, message: string }> }}
179
+ */
180
+ export function validatePlan(plan) {
181
+ const structural = checkStructure(plan);
182
+ if (structural.length) return { ok: false, errors: structural };
183
+
184
+ const errors = [];
185
+ const { issues, milestones } = plan;
186
+ const err = (issue, code, message) => errors.push({ issue, code, message });
187
+ const byKey = new Map(issues.map((it) => [it.key, it]));
188
+ const milestoneIndex = new Map(milestones.map((m, i) => [m.key, i]));
189
+ const declaredLabels = Array.isArray(plan.labels) ? new Set(plan.labels) : null;
190
+
191
+ // Every plan key resolves: to its real number when known, else to a unique synthetic one.
192
+ const keyToNumber = new Map();
193
+ issues.forEach((it, i) => {
194
+ const real = plan.numbers?.[it.key];
195
+ keyToNumber.set(it.key, Number.isSafeInteger(real) && real > 0 ? real : SYNTHETIC_BASE + i);
196
+ });
197
+ const numberToKey = new Map([...keyToNumber].map(([k, n]) => [n, k]));
198
+
199
+ const cycles = findCycles(issues, byKey);
200
+
201
+ for (const it of issues) {
202
+ const deps = it.deps ?? [];
203
+ const myMilestone = milestoneIndex.get(it.milestone);
204
+
205
+ for (const d of deps) {
206
+ const dep = byKey.get(d);
207
+ if (!dep) {
208
+ err(it.key, "unknown-dep", `depends on ${d}, which is not an issue in the plan`);
209
+ continue;
210
+ }
211
+ const depMilestone = milestoneIndex.get(dep.milestone);
212
+ if (depMilestone > myMilestone) {
213
+ err(it.key, "forward-milestone-dep",
214
+ `in milestone ${it.milestone} but depends on ${d} in later milestone ${dep.milestone}`);
215
+ }
216
+ if (!isNeedsHuman(it) && isNeedsHuman(dep)) {
217
+ err(it.key, "code-depends-on-needs-human",
218
+ `code issue depends on needs-human issue ${d}; reference it for real credentials, but keep mocks so it never blocks`);
219
+ }
220
+ }
221
+ if (cycles.has(it.key)) err(it.key, "cycle", `dependency cycle: ${cycles.get(it.key).join(" -> ")}`);
222
+
223
+ const labels = it.labels ?? [];
224
+ if (labels.filter(isNonEmptyString).length === 0) err(it.key, "missing-labels", "issue has no labels");
225
+ if (declaredLabels) {
226
+ for (const l of labels) {
227
+ if (isNonEmptyString(l) && !declaredLabels.has(l)) err(it.key, "unknown-label", `label ${l} is not in the plan's labels`);
228
+ }
229
+ }
230
+
231
+ const resolved = resolvePlaceholders(it.body, keyToNumber);
232
+ const unresolved = [...new Set([...resolved.matchAll(PLACEHOLDER_RE)].map((m) => m[0]))];
233
+ for (const p of unresolved) err(it.key, "unresolved-placeholder", `${p} does not match any issue key in the plan`);
234
+
235
+ const declared = new Set();
236
+ const strays = [];
237
+ for (const n of parseDependsOn(resolved)) {
238
+ if (numberToKey.has(n)) declared.add(numberToKey.get(n));
239
+ else strays.push(`#${n}`);
240
+ }
241
+ const planned = new Set(deps);
242
+ const missing = [...planned].filter((k) => !declared.has(k));
243
+ const extra = [...declared].filter((k) => !planned.has(k)).concat(strays);
244
+ if (missing.length || extra.length) {
245
+ const parts = [];
246
+ if (missing.length) parts.push(`planned but not declared: ${missing.join(", ")}`);
247
+ if (extra.length) parts.push(`declared but not planned: ${extra.join(", ")}`);
248
+ err(it.key, "depends-on-mismatch", `\`Depends on\` lines differ from planned deps (${parts.join("; ")})`);
249
+ }
250
+
251
+ const secs = sections(it.body);
252
+ for (const name of SECTIONS) {
253
+ if (!secs.has(name.toLowerCase())) err(it.key, "missing-section", `missing \`## ${name}\` section`);
254
+ }
255
+ const ac = secs.get("acceptance criteria");
256
+ if (ac) {
257
+ const items = ac.filter((l) => CHECKBOX_RE.test(l));
258
+ if (items.length === 0) err(it.key, "missing-section", "`## Acceptance Criteria` has no `- [ ]` checklist items");
259
+ const untagged = items.filter((l) => !TYPE_TAG_RE.test(l));
260
+ if (untagged.length) {
261
+ err(it.key, "missing-type-tag",
262
+ `${untagged.length} acceptance criteria lack a [type:api|unit|e2e|manual] tag: ${untagged[0].trim()}`);
263
+ }
264
+ }
265
+ const ctx = secs.get("context");
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
+ }
279
+ }
280
+ return { ok: errors.length === 0, errors };
281
+ }
282
+
283
+ /** One `<KEY>: [code] message` line per error; plan-level errors use `(plan)`. */
284
+ export function formatErrors(errors) {
285
+ return errors.map((e) => `${e.issue ?? "(plan)"}: [${e.code}] ${e.message}`).join("\n");
286
+ }
287
+
288
+ function main(argv) {
289
+ const arg = argv[0];
290
+ if (!arg || argv.length > 1) {
291
+ process.stderr.write("usage: node scripts/bootstrap-validate.mjs <plan.json|->\n");
292
+ return 2;
293
+ }
294
+ let raw;
295
+ try {
296
+ raw = readFileSync(arg === "-" ? 0 : arg, "utf8");
297
+ } catch (e) {
298
+ process.stderr.write(`bootstrap-validate: cannot read ${arg}: ${e.message}\n`);
299
+ return 2;
300
+ }
301
+ let plan;
302
+ try {
303
+ plan = JSON.parse(raw);
304
+ } catch (e) {
305
+ process.stderr.write(`bootstrap-validate: invalid JSON: ${e.message}\n`);
306
+ return 2;
307
+ }
308
+ const { ok, errors } = validatePlan(plan);
309
+ if (!ok) {
310
+ process.stderr.write(formatErrors(errors) + "\n");
311
+ process.stderr.write(`bootstrap-validate: ${errors.length} error(s)\n`);
312
+ return 1;
313
+ }
314
+ process.stdout.write(`bootstrap-validate: OK (${plan.issues.length} issues, ${plan.milestones.length} milestones)\n`);
315
+ return 0;
316
+ }
317
+
318
+ // Run as CLI only when invoked directly, not when imported by tests.
319
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
320
+ process.exitCode = main(process.argv.slice(2));
321
+ }
@@ -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;
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # check-devdocs-freshness.sh — docs must move with runtime behaviour (forge#4185).
6
+ # A PR that changes non-test files under bin/lean/ or scripts/ must, for every such file, also:
7
+ # (a) touch a dossier whose devdocs/index.yaml `modules:` glob matches the file, or
8
+ # (b) change lines inside the `## Runtime map` section of NORTHSTAR.md, or
9
+ # (c) add a new devdocs/decisions/*.md.
10
+ # Exempt: a `docs-not-needed: <reason>` line (non-empty reason) in the PR body.
11
+ # Deterministic, no LLM. bash 3.2 portable (no bash-4 builtins, no PCRE grep).
12
+ #
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).
15
+ # PR body: --body-file, else env PR_BODY (empty when unset). Diff: <base-ref>...HEAD, run from the repo root.
16
+ # Exit: 0 fresh / exempt / nothing to check | 1 docs missing | 2 bad usage or unknown base ref
17
+
18
+ set -u
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; }
26
+ BODY="${PR_BODY:-}"
27
+ while [ $# -gt 0 ]; do
28
+ case "$1" in
29
+ --body-file)
30
+ [ -f "${2:-}" ] || { echo "check-devdocs-freshness: body file not found: ${2:-}" >&2; exit 2; }
31
+ BODY=$(cat "$2"); shift 2 ;;
32
+ *) echo "check-devdocs-freshness: unknown argument: $1" >&2; exit 2 ;;
33
+ esac
34
+ done
35
+ git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null || { echo "check-devdocs-freshness: unknown base ref: $BASE" >&2; exit 2; }
36
+
37
+ CHANGES=$(git diff --no-renames --name-status "${BASE}...HEAD") || { echo "check-devdocs-freshness: git diff failed" >&2; exit 2; }
38
+ CHANGED=$(printf '%s\n' "$CHANGES" | awk -F'\t' 'NF>=2 {print $2}')
39
+
40
+ # Behaviour files: non-test files under bin/lean/ or scripts/.
41
+ BEHAVIOUR=$(printf '%s\n' "$CHANGED" | grep -E '^(bin/lean|scripts)/' \
42
+ | grep -vE '\.test\.[^/]*$|(^|/)fixtures/|^scripts/tests/' || true)
43
+ if [ -z "$BEHAVIOUR" ]; then
44
+ echo "OK: no behaviour change under bin/lean/ or scripts/ — devdocs freshness not required."
45
+ exit 0
46
+ fi
47
+
48
+ if printf '%s\n' "$BODY" | tr -d '\r' | grep -Eq '^[[:space:]]*docs-not-needed:[[:space:]]*[^[:space:]]'; then
49
+ echo "OK: exempt via 'docs-not-needed:' line in the PR body."
50
+ exit 0
51
+ fi
52
+
53
+ # (c) a new decision record.
54
+ DECISION=$(printf '%s\n' "$CHANGES" | awk -F'\t' '$1=="A" && $2 ~ /^devdocs\/decisions\/[^\/]+\.md$/ {print $2}' | head -1)
55
+
56
+ # (b) changed lines inside NORTHSTAR.md § Runtime map (line range in HEAD vs the -U0 hunks' new-side lines).
57
+ RUNTIME_MAP=0
58
+ if printf '%s\n' "$CHANGED" | grep -qx 'NORTHSTAR.md' && [ -f NORTHSTAR.md ]; then
59
+ RANGE=$(awk '/^## /{ if (s && !e) e = NR - 1; if ($0 ~ /^## Runtime map[[:space:]]*$/) s = NR } END { if (s) { if (!e) e = NR; print s, e } }' NORTHSTAR.md)
60
+ if [ -n "$RANGE" ]; then
61
+ RS=${RANGE% *}; RE=${RANGE#* }
62
+ RUNTIME_MAP=$(git diff -U0 "${BASE}...HEAD" -- NORTHSTAR.md | awk -v s="$RS" -v e="$RE" '
63
+ /^@@/ {
64
+ match($0, /\+[0-9]+(,[0-9]+)?/)
65
+ spec = substr($0, RSTART + 1, RLENGTH - 1)
66
+ n = split(spec, p, ",")
67
+ start = p[1] + 0; cnt = (n > 1) ? p[2] + 0 : 1
68
+ if (cnt == 0) { lo = start; hi = start + 1 } else { lo = start; hi = start + cnt - 1 }
69
+ if (lo <= e && hi >= s) hit = 1
70
+ }
71
+ END { print hit ? 1 : 0 }')
72
+ fi
73
+ fi
74
+
75
+ # (a) dossiers: glob/path pairs from devdocs/index.yaml `modules:`.
76
+ MODULES=""
77
+ if [ -f devdocs/index.yaml ]; then
78
+ MODULES=$(awk '
79
+ /^modules:/ { m = 1; next }
80
+ m && /^[^[:space:]#]/ { m = 0 }
81
+ m && /^[[:space:]]*-[[:space:]]/ { if (g != "" && p != "") print g "\t" p; g = ""; p = "" }
82
+ m && /^[[:space:]]*(-[[:space:]]+)?glob:/ { v = $0; sub(/^[^:]*:[[:space:]]*/, "", v); gsub(/["\047]/, "", v); g = v }
83
+ m && /^[[:space:]]*(-[[:space:]]+)?path:/ { v = $0; sub(/^[^:]*:[[:space:]]*/, "", v); gsub(/["\047]/, "", v); p = v }
84
+ END { if (g != "" && p != "") print g "\t" p }' devdocs/index.yaml)
85
+ fi
86
+
87
+ MISSING=""
88
+ while IFS= read -r f; do
89
+ [ -n "$f" ] || continue
90
+ [ -n "$DECISION" ] && continue
91
+ [ "$RUNTIME_MAP" = "1" ] && continue
92
+ ok=0
93
+ if [ -n "$MODULES" ]; then
94
+ while IFS="$(printf '\t')" read -r glob dpath; do
95
+ [ -n "$glob" ] || continue
96
+ # shellcheck disable=SC2254
97
+ case "$f" in
98
+ $glob) printf '%s\n' "$CHANGED" | grep -qxF "devdocs/$dpath" && ok=1 ;;
99
+ esac
100
+ done <<EOF_MODULES
101
+ $MODULES
102
+ EOF_MODULES
103
+ fi
104
+ [ "$ok" = "1" ] || MISSING="${MISSING}${f}
105
+ "
106
+ done <<EOF_BEHAVIOUR
107
+ $BEHAVIOUR
108
+ EOF_BEHAVIOUR
109
+
110
+ if [ -z "$MISSING" ]; then
111
+ if [ -n "$DECISION" ]; then echo "OK: new decision record $DECISION covers the behaviour change."
112
+ elif [ "$RUNTIME_MAP" = "1" ]; then echo "OK: NORTHSTAR.md § Runtime map updated."
113
+ else echo "OK: every behaviour change has its module dossier updated."; fi
114
+ exit 0
115
+ fi
116
+
117
+ echo "ERROR: behaviour changed under bin/lean/ or scripts/ without a docs update:"
118
+ printf '%s' "$MISSING" | sed 's/^/ - /'
119
+ echo ""
120
+ echo "Fix with one of:"
121
+ echo " - edit the matching dossier (devdocs/index.yaml modules: glob -> devdocs/modules/*.md)"
122
+ echo " - update NORTHSTAR.md § Runtime map"
123
+ echo " - add devdocs/decisions/<issue>-<slug>.md"
124
+ echo " - or add a line 'docs-not-needed: <reason>' to the PR body"
125
+ exit 1