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.
- package/README.md +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +13 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +41 -6
- package/bin/engine/telemetry-seam.test.mjs +35 -0
- package/bin/engine-cli.mjs +616 -54
- package/bin/engine.mjs +13 -5
- package/bin/forgedock.mjs +169 -39
- package/bin/gc.mjs +580 -0
- package/bin/init-detect.mjs +68 -3
- package/bin/init-enrich.mjs +3 -0
- package/bin/init-repo.mjs +213 -0
- package/bin/journey.mjs +278 -43
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +24 -5
- package/bin/lean/batch.test.mjs +54 -4
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +235 -37
- package/bin/lean/conformance.test.mjs +143 -5
- 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 +178 -16
- 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 +877 -151
- package/bin/lean/lean.test.mjs +1798 -28
- package/bin/lean/ledger.mjs +92 -0
- package/bin/lean/ledger.test.mjs +100 -0
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +321 -19
- package/bin/lean/prompts.mjs +125 -17
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +25 -6
- 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/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +124 -0
- package/bin/lean/verify.test.mjs +118 -0
- 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 +14 -2
- package/bin/stats/query.mjs +72 -0
- package/bin/stats/stats.test.mjs +54 -0
- package/commands/adopt.md +3 -0
- package/commands/audit.md +5 -8
- 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 +125 -6
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +10 -2
- package/commands/review-pr.md +2 -2
- 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 +227 -3
- package/docs/OUTCOME-CODES.md +11 -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 +321 -0
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +125 -0
- package/scripts/check-devdocs-freshness.test.sh +130 -0
- package/scripts/check-spec-markers.sh +3 -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 +16 -5
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +14 -2
- 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/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/northstar-docs.test.sh +68 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +155 -0
- package/scripts/structure-delta.test.sh +136 -0
- 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/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,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
|
+
}
|
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;
|
|
@@ -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
|