forgedock 1.26.0 → 1.27.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/bin/engine/outcome-codes.mjs +3 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +6 -0
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +78 -20
- package/bin/engine.mjs +5 -1
- package/bin/forgedock.mjs +94 -30
- 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/batch.mjs +2 -1
- package/bin/lean/batch.test.mjs +49 -1
- package/bin/lean/conformance.mjs +76 -10
- package/bin/lean/conformance.test.mjs +32 -3
- package/bin/lean/effects.mjs +96 -11
- package/bin/lean/lean.mjs +326 -33
- package/bin/lean/lean.test.mjs +618 -10
- package/bin/lean/ledger.mjs +67 -0
- package/bin/lean/ledger.test.mjs +65 -0
- package/bin/lean/orchestrate.mjs +9 -0
- package/bin/lean/prompts.mjs +42 -8
- package/bin/lean/render.mjs +1 -1
- package/bin/lean/verify.mjs +113 -0
- package/bin/lean/verify.test.mjs +95 -0
- package/commands/adopt.md +3 -0
- package/commands/audit.md +3 -8
- package/commands/forgedock-init.md +118 -6
- package/commands/review-pr.md +2 -2
- package/docs/CONFIG.md +52 -3
- package/docs/OUTCOME-CODES.md +3 -0
- package/package.json +1 -1
- package/scripts/bootstrap-validate.mjs +287 -0
- package/scripts/check-devdocs-freshness.sh +120 -0
- package/scripts/check-devdocs-freshness.test.sh +120 -0
- package/scripts/ci-local/run.mjs +13 -2
- package/scripts/ci-local.test.sh +13 -1
- package/scripts/northstar-docs.test.sh +68 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +150 -0
- package/scripts/structure-delta.test.sh +135 -0
|
@@ -0,0 +1,287 @@
|
|
|
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, "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
|
+
* `Depends on` lines are read with the engine's own parser (bin/engine/phases.mjs parseDependsOn) after placeholder
|
|
21
|
+
* resolution, so the plan and engine admission agree on what blocks what.
|
|
22
|
+
*
|
|
23
|
+
* Usage:
|
|
24
|
+
* node scripts/bootstrap-validate.mjs <plan.json>
|
|
25
|
+
* node scripts/bootstrap-validate.mjs - # read the plan from stdin
|
|
26
|
+
*
|
|
27
|
+
* Exit codes:
|
|
28
|
+
* 0 = plan valid
|
|
29
|
+
* 1 = plan invalid (one `<KEY>: [code] message` line per error on stderr)
|
|
30
|
+
* 2 = usage error, unreadable file or bad JSON
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { readFileSync } from "node:fs";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
import { parseDependsOn } from "../bin/engine/phases.mjs";
|
|
36
|
+
|
|
37
|
+
const NEEDS_HUMAN_LABEL = "needs-human";
|
|
38
|
+
const TYPE_TAG_RE = /\[type:(?:api|unit|e2e|manual)\]/;
|
|
39
|
+
const CHECKBOX_RE = /^[ \t]*[-*+][ \t]+\[[ xX]\]/;
|
|
40
|
+
const PLACEHOLDER_RE = /#\{\{([^{}]*)\}\}/g;
|
|
41
|
+
// Synthetic numbers for keys without a real number: far above any real issue number, so they never collide.
|
|
42
|
+
const SYNTHETIC_BASE = 1_000_000_000;
|
|
43
|
+
|
|
44
|
+
// Mandatory Pipeline Issue Template sections (commands/issue.md Phase 3D). Matched case-insensitively on `## ` headings,
|
|
45
|
+
// ignoring a trailing parenthetical such as "Root Cause (if known)".
|
|
46
|
+
const SECTIONS = ["Problem", "Root Cause", "Affected Files", "Acceptance Criteria", "Context", "Dependencies"];
|
|
47
|
+
|
|
48
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
49
|
+
const isNonEmptyString = (v) => typeof v === "string" && v.trim() !== "";
|
|
50
|
+
const normHeading = (h) => h.replace(/\([^)]*\)\s*$/, "").trim().toLowerCase();
|
|
51
|
+
|
|
52
|
+
/** True when the issue is a needs-human issue (credential, account or external input). */
|
|
53
|
+
export function isNeedsHuman(issue) {
|
|
54
|
+
return issue?.needsHuman === true || (Array.isArray(issue?.labels) && issue.labels.includes(NEEDS_HUMAN_LABEL));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Replace every `#{{KEY}}` whose KEY is in `keyToNumber` with `#N`. Unknown keys are left in place.
|
|
59
|
+
* @param {string} body
|
|
60
|
+
* @param {Record<string, number>|Map<string, number>} keyToNumber
|
|
61
|
+
*/
|
|
62
|
+
export function resolvePlaceholders(body, keyToNumber) {
|
|
63
|
+
const map = keyToNumber instanceof Map ? keyToNumber : new Map(Object.entries(keyToNumber ?? {}));
|
|
64
|
+
return String(body ?? "").replace(PLACEHOLDER_RE, (whole, key) => {
|
|
65
|
+
const n = map.get(key);
|
|
66
|
+
return Number.isSafeInteger(n) && n > 0 ? `#${n}` : whole;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `## ` sections of a markdown body (fenced code blocks ignored): normalized heading -> content lines. */
|
|
71
|
+
function sections(body) {
|
|
72
|
+
const out = new Map();
|
|
73
|
+
let current = null;
|
|
74
|
+
let fence = null;
|
|
75
|
+
for (const line of String(body).replace(/\r\n/g, "\n").split("\n")) {
|
|
76
|
+
const f = line.match(/^[ \t]*(`{3,}|~{3,})/);
|
|
77
|
+
if (f) {
|
|
78
|
+
if (!fence) fence = f[1][0];
|
|
79
|
+
else if (f[1][0] === fence) fence = null;
|
|
80
|
+
if (current) current.push(line);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const h = !fence && line.match(/^##(?!#)[ \t]+(.+?)[ \t]*#*[ \t]*$/);
|
|
84
|
+
if (h) {
|
|
85
|
+
current = [];
|
|
86
|
+
const name = normHeading(h[1]);
|
|
87
|
+
if (!out.has(name)) out.set(name, current);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (current) current.push(line);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function checkStructure(plan) {
|
|
96
|
+
const errors = [];
|
|
97
|
+
const bad = (message) => errors.push({ issue: null, code: "invalid-plan", message });
|
|
98
|
+
if (!isObj(plan)) return [{ issue: null, code: "invalid-plan", message: "plan must be a JSON object" }];
|
|
99
|
+
if (!Array.isArray(plan.milestones) || plan.milestones.length === 0) bad("`milestones` must be a non-empty array");
|
|
100
|
+
if (!Array.isArray(plan.issues) || plan.issues.length === 0) bad("`issues` must be a non-empty array");
|
|
101
|
+
if (plan.labels !== undefined && !(Array.isArray(plan.labels) && plan.labels.every(isNonEmptyString))) {
|
|
102
|
+
bad("`labels` must be an array of strings when given");
|
|
103
|
+
}
|
|
104
|
+
if (plan.numbers !== undefined && !isObj(plan.numbers)) bad("`numbers` must be an object of KEY -> issue number");
|
|
105
|
+
if (errors.length) return errors;
|
|
106
|
+
|
|
107
|
+
const milestoneKeys = new Set();
|
|
108
|
+
plan.milestones.forEach((m, i) => {
|
|
109
|
+
if (!isObj(m) || !isNonEmptyString(m.key)) return bad(`milestones[${i}] needs a string \`key\``);
|
|
110
|
+
if (milestoneKeys.has(m.key)) bad(`duplicate milestone key ${m.key}`);
|
|
111
|
+
milestoneKeys.add(m.key);
|
|
112
|
+
});
|
|
113
|
+
const issueKeys = new Set();
|
|
114
|
+
plan.issues.forEach((it, i) => {
|
|
115
|
+
if (!isObj(it) || !isNonEmptyString(it.key)) return bad(`issues[${i}] needs a string \`key\``);
|
|
116
|
+
const at = (message) => errors.push({ issue: it.key, code: "invalid-plan", message });
|
|
117
|
+
if (issueKeys.has(it.key)) at(`duplicate issue key ${it.key}`);
|
|
118
|
+
issueKeys.add(it.key);
|
|
119
|
+
if (!isNonEmptyString(it.title)) at("needs a non-empty `title`");
|
|
120
|
+
if (!milestoneKeys.has(it.milestone)) at(`unknown milestone ${JSON.stringify(it.milestone)}`);
|
|
121
|
+
if (it.deps !== undefined && !(Array.isArray(it.deps) && it.deps.every(isNonEmptyString))) {
|
|
122
|
+
at("`deps` must be an array of issue keys");
|
|
123
|
+
}
|
|
124
|
+
if (it.labels !== undefined && !(Array.isArray(it.labels) && it.labels.every((l) => typeof l === "string"))) {
|
|
125
|
+
at("`labels` must be an array of strings");
|
|
126
|
+
}
|
|
127
|
+
if (typeof it.body !== "string") at("needs a string `body`");
|
|
128
|
+
});
|
|
129
|
+
return errors;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Issue keys on dependency cycles, each with one cycle path through it (DFS over known deps). */
|
|
133
|
+
function findCycles(issues, byKey) {
|
|
134
|
+
const state = new Map(); // key -> 1 visiting | 2 done
|
|
135
|
+
const stack = [];
|
|
136
|
+
const onCycle = new Map();
|
|
137
|
+
const visit = (key) => {
|
|
138
|
+
state.set(key, 1);
|
|
139
|
+
stack.push(key);
|
|
140
|
+
for (const d of byKey.get(key).deps ?? []) {
|
|
141
|
+
if (!byKey.has(d)) continue;
|
|
142
|
+
if (state.get(d) === 1) {
|
|
143
|
+
const path = [...stack.slice(stack.indexOf(d)), d];
|
|
144
|
+
for (const k of path) if (!onCycle.has(k)) onCycle.set(k, path);
|
|
145
|
+
} else if (!state.has(d)) visit(d);
|
|
146
|
+
}
|
|
147
|
+
stack.pop();
|
|
148
|
+
state.set(key, 2);
|
|
149
|
+
};
|
|
150
|
+
for (const it of issues) if (!state.has(it.key)) visit(it.key);
|
|
151
|
+
return onCycle;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Validate a backlog plan. Never throws on bad input; structural problems come back as `invalid-plan` errors.
|
|
156
|
+
* @returns {{ ok: boolean, errors: Array<{ issue: string|null, code: string, message: string }> }}
|
|
157
|
+
*/
|
|
158
|
+
export function validatePlan(plan) {
|
|
159
|
+
const structural = checkStructure(plan);
|
|
160
|
+
if (structural.length) return { ok: false, errors: structural };
|
|
161
|
+
|
|
162
|
+
const errors = [];
|
|
163
|
+
const { issues, milestones } = plan;
|
|
164
|
+
const err = (issue, code, message) => errors.push({ issue, code, message });
|
|
165
|
+
const byKey = new Map(issues.map((it) => [it.key, it]));
|
|
166
|
+
const milestoneIndex = new Map(milestones.map((m, i) => [m.key, i]));
|
|
167
|
+
const declaredLabels = Array.isArray(plan.labels) ? new Set(plan.labels) : null;
|
|
168
|
+
|
|
169
|
+
// Every plan key resolves: to its real number when known, else to a unique synthetic one.
|
|
170
|
+
const keyToNumber = new Map();
|
|
171
|
+
issues.forEach((it, i) => {
|
|
172
|
+
const real = plan.numbers?.[it.key];
|
|
173
|
+
keyToNumber.set(it.key, Number.isSafeInteger(real) && real > 0 ? real : SYNTHETIC_BASE + i);
|
|
174
|
+
});
|
|
175
|
+
const numberToKey = new Map([...keyToNumber].map(([k, n]) => [n, k]));
|
|
176
|
+
|
|
177
|
+
const cycles = findCycles(issues, byKey);
|
|
178
|
+
|
|
179
|
+
for (const it of issues) {
|
|
180
|
+
const deps = it.deps ?? [];
|
|
181
|
+
const myMilestone = milestoneIndex.get(it.milestone);
|
|
182
|
+
|
|
183
|
+
for (const d of deps) {
|
|
184
|
+
const dep = byKey.get(d);
|
|
185
|
+
if (!dep) {
|
|
186
|
+
err(it.key, "unknown-dep", `depends on ${d}, which is not an issue in the plan`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const depMilestone = milestoneIndex.get(dep.milestone);
|
|
190
|
+
if (depMilestone > myMilestone) {
|
|
191
|
+
err(it.key, "forward-milestone-dep",
|
|
192
|
+
`in milestone ${it.milestone} but depends on ${d} in later milestone ${dep.milestone}`);
|
|
193
|
+
}
|
|
194
|
+
if (!isNeedsHuman(it) && isNeedsHuman(dep)) {
|
|
195
|
+
err(it.key, "code-depends-on-needs-human",
|
|
196
|
+
`code issue depends on needs-human issue ${d}; reference it for real credentials, but keep mocks so it never blocks`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (cycles.has(it.key)) err(it.key, "cycle", `dependency cycle: ${cycles.get(it.key).join(" -> ")}`);
|
|
200
|
+
|
|
201
|
+
const labels = it.labels ?? [];
|
|
202
|
+
if (labels.filter(isNonEmptyString).length === 0) err(it.key, "missing-labels", "issue has no labels");
|
|
203
|
+
if (declaredLabels) {
|
|
204
|
+
for (const l of labels) {
|
|
205
|
+
if (isNonEmptyString(l) && !declaredLabels.has(l)) err(it.key, "unknown-label", `label ${l} is not in the plan's labels`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const resolved = resolvePlaceholders(it.body, keyToNumber);
|
|
210
|
+
const unresolved = [...new Set([...resolved.matchAll(PLACEHOLDER_RE)].map((m) => m[0]))];
|
|
211
|
+
for (const p of unresolved) err(it.key, "unresolved-placeholder", `${p} does not match any issue key in the plan`);
|
|
212
|
+
|
|
213
|
+
const declared = new Set();
|
|
214
|
+
const strays = [];
|
|
215
|
+
for (const n of parseDependsOn(resolved)) {
|
|
216
|
+
if (numberToKey.has(n)) declared.add(numberToKey.get(n));
|
|
217
|
+
else strays.push(`#${n}`);
|
|
218
|
+
}
|
|
219
|
+
const planned = new Set(deps);
|
|
220
|
+
const missing = [...planned].filter((k) => !declared.has(k));
|
|
221
|
+
const extra = [...declared].filter((k) => !planned.has(k)).concat(strays);
|
|
222
|
+
if (missing.length || extra.length) {
|
|
223
|
+
const parts = [];
|
|
224
|
+
if (missing.length) parts.push(`planned but not declared: ${missing.join(", ")}`);
|
|
225
|
+
if (extra.length) parts.push(`declared but not planned: ${extra.join(", ")}`);
|
|
226
|
+
err(it.key, "depends-on-mismatch", `\`Depends on\` lines differ from planned deps (${parts.join("; ")})`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const secs = sections(it.body);
|
|
230
|
+
for (const name of SECTIONS) {
|
|
231
|
+
if (!secs.has(name.toLowerCase())) err(it.key, "missing-section", `missing \`## ${name}\` section`);
|
|
232
|
+
}
|
|
233
|
+
const ac = secs.get("acceptance criteria");
|
|
234
|
+
if (ac) {
|
|
235
|
+
const items = ac.filter((l) => CHECKBOX_RE.test(l));
|
|
236
|
+
if (items.length === 0) err(it.key, "missing-section", "`## Acceptance Criteria` has no `- [ ]` checklist items");
|
|
237
|
+
const untagged = items.filter((l) => !TYPE_TAG_RE.test(l));
|
|
238
|
+
if (untagged.length) {
|
|
239
|
+
err(it.key, "missing-type-tag",
|
|
240
|
+
`${untagged.length} acceptance criteria lack a [type:api|unit|e2e|manual] tag: ${untagged[0].trim()}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const ctx = secs.get("context");
|
|
244
|
+
if (ctx && !ctx.some((l) => l.includes("**Parent**"))) err(it.key, "missing-parent", "`## Context` has no `**Parent**` line");
|
|
245
|
+
}
|
|
246
|
+
return { ok: errors.length === 0, errors };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** One `<KEY>: [code] message` line per error; plan-level errors use `(plan)`. */
|
|
250
|
+
export function formatErrors(errors) {
|
|
251
|
+
return errors.map((e) => `${e.issue ?? "(plan)"}: [${e.code}] ${e.message}`).join("\n");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function main(argv) {
|
|
255
|
+
const arg = argv[0];
|
|
256
|
+
if (!arg || argv.length > 1) {
|
|
257
|
+
process.stderr.write("usage: node scripts/bootstrap-validate.mjs <plan.json|->\n");
|
|
258
|
+
return 2;
|
|
259
|
+
}
|
|
260
|
+
let raw;
|
|
261
|
+
try {
|
|
262
|
+
raw = readFileSync(arg === "-" ? 0 : arg, "utf8");
|
|
263
|
+
} catch (e) {
|
|
264
|
+
process.stderr.write(`bootstrap-validate: cannot read ${arg}: ${e.message}\n`);
|
|
265
|
+
return 2;
|
|
266
|
+
}
|
|
267
|
+
let plan;
|
|
268
|
+
try {
|
|
269
|
+
plan = JSON.parse(raw);
|
|
270
|
+
} catch (e) {
|
|
271
|
+
process.stderr.write(`bootstrap-validate: invalid JSON: ${e.message}\n`);
|
|
272
|
+
return 2;
|
|
273
|
+
}
|
|
274
|
+
const { ok, errors } = validatePlan(plan);
|
|
275
|
+
if (!ok) {
|
|
276
|
+
process.stderr.write(formatErrors(errors) + "\n");
|
|
277
|
+
process.stderr.write(`bootstrap-validate: ${errors.length} error(s)\n`);
|
|
278
|
+
return 1;
|
|
279
|
+
}
|
|
280
|
+
process.stdout.write(`bootstrap-validate: OK (${plan.issues.length} issues, ${plan.milestones.length} milestones)\n`);
|
|
281
|
+
return 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Run as CLI only when invoked directly, not when imported by tests.
|
|
285
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
286
|
+
process.exitCode = main(process.argv.slice(2));
|
|
287
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
# PR body: --body-file, else env PR_BODY (empty when unset). Diff: <base-ref>...HEAD, run from the repo root.
|
|
15
|
+
# Exit: 0 fresh / exempt / nothing to check | 1 docs missing | 2 bad usage or unknown base ref
|
|
16
|
+
|
|
17
|
+
set -u
|
|
18
|
+
BASE="${1:-}"
|
|
19
|
+
[ -n "$BASE" ] || { echo "usage: check-devdocs-freshness.sh <base-ref> [--body-file <file>]" >&2; exit 2; }
|
|
20
|
+
shift
|
|
21
|
+
BODY="${PR_BODY:-}"
|
|
22
|
+
while [ $# -gt 0 ]; do
|
|
23
|
+
case "$1" in
|
|
24
|
+
--body-file)
|
|
25
|
+
[ -f "${2:-}" ] || { echo "check-devdocs-freshness: body file not found: ${2:-}" >&2; exit 2; }
|
|
26
|
+
BODY=$(cat "$2"); shift 2 ;;
|
|
27
|
+
*) echo "check-devdocs-freshness: unknown argument: $1" >&2; exit 2 ;;
|
|
28
|
+
esac
|
|
29
|
+
done
|
|
30
|
+
git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null || { echo "check-devdocs-freshness: unknown base ref: $BASE" >&2; exit 2; }
|
|
31
|
+
|
|
32
|
+
CHANGES=$(git diff --no-renames --name-status "${BASE}...HEAD") || { echo "check-devdocs-freshness: git diff failed" >&2; exit 2; }
|
|
33
|
+
CHANGED=$(printf '%s\n' "$CHANGES" | awk -F'\t' 'NF>=2 {print $2}')
|
|
34
|
+
|
|
35
|
+
# Behaviour files: non-test files under bin/lean/ or scripts/.
|
|
36
|
+
BEHAVIOUR=$(printf '%s\n' "$CHANGED" | grep -E '^(bin/lean|scripts)/' \
|
|
37
|
+
| grep -vE '\.test\.[^/]*$|(^|/)fixtures/|^scripts/tests/' || true)
|
|
38
|
+
if [ -z "$BEHAVIOUR" ]; then
|
|
39
|
+
echo "OK: no behaviour change under bin/lean/ or scripts/ — devdocs freshness not required."
|
|
40
|
+
exit 0
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
if printf '%s\n' "$BODY" | tr -d '\r' | grep -Eq '^[[:space:]]*docs-not-needed:[[:space:]]*[^[:space:]]'; then
|
|
44
|
+
echo "OK: exempt via 'docs-not-needed:' line in the PR body."
|
|
45
|
+
exit 0
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# (c) a new decision record.
|
|
49
|
+
DECISION=$(printf '%s\n' "$CHANGES" | awk -F'\t' '$1=="A" && $2 ~ /^devdocs\/decisions\/[^\/]+\.md$/ {print $2}' | head -1)
|
|
50
|
+
|
|
51
|
+
# (b) changed lines inside NORTHSTAR.md § Runtime map (line range in HEAD vs the -U0 hunks' new-side lines).
|
|
52
|
+
RUNTIME_MAP=0
|
|
53
|
+
if printf '%s\n' "$CHANGED" | grep -qx 'NORTHSTAR.md' && [ -f NORTHSTAR.md ]; then
|
|
54
|
+
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)
|
|
55
|
+
if [ -n "$RANGE" ]; then
|
|
56
|
+
RS=${RANGE% *}; RE=${RANGE#* }
|
|
57
|
+
RUNTIME_MAP=$(git diff -U0 "${BASE}...HEAD" -- NORTHSTAR.md | awk -v s="$RS" -v e="$RE" '
|
|
58
|
+
/^@@/ {
|
|
59
|
+
match($0, /\+[0-9]+(,[0-9]+)?/)
|
|
60
|
+
spec = substr($0, RSTART + 1, RLENGTH - 1)
|
|
61
|
+
n = split(spec, p, ",")
|
|
62
|
+
start = p[1] + 0; cnt = (n > 1) ? p[2] + 0 : 1
|
|
63
|
+
if (cnt == 0) { lo = start; hi = start + 1 } else { lo = start; hi = start + cnt - 1 }
|
|
64
|
+
if (lo <= e && hi >= s) hit = 1
|
|
65
|
+
}
|
|
66
|
+
END { print hit ? 1 : 0 }')
|
|
67
|
+
fi
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
# (a) dossiers: glob/path pairs from devdocs/index.yaml `modules:`.
|
|
71
|
+
MODULES=""
|
|
72
|
+
if [ -f devdocs/index.yaml ]; then
|
|
73
|
+
MODULES=$(awk '
|
|
74
|
+
/^modules:/ { m = 1; next }
|
|
75
|
+
m && /^[^[:space:]#]/ { m = 0 }
|
|
76
|
+
m && /^[[:space:]]*-[[:space:]]/ { if (g != "" && p != "") print g "\t" p; g = ""; p = "" }
|
|
77
|
+
m && /^[[:space:]]*(-[[:space:]]+)?glob:/ { v = $0; sub(/^[^:]*:[[:space:]]*/, "", v); gsub(/["\047]/, "", v); g = v }
|
|
78
|
+
m && /^[[:space:]]*(-[[:space:]]+)?path:/ { v = $0; sub(/^[^:]*:[[:space:]]*/, "", v); gsub(/["\047]/, "", v); p = v }
|
|
79
|
+
END { if (g != "" && p != "") print g "\t" p }' devdocs/index.yaml)
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
MISSING=""
|
|
83
|
+
while IFS= read -r f; do
|
|
84
|
+
[ -n "$f" ] || continue
|
|
85
|
+
[ -n "$DECISION" ] && continue
|
|
86
|
+
[ "$RUNTIME_MAP" = "1" ] && continue
|
|
87
|
+
ok=0
|
|
88
|
+
if [ -n "$MODULES" ]; then
|
|
89
|
+
while IFS="$(printf '\t')" read -r glob dpath; do
|
|
90
|
+
[ -n "$glob" ] || continue
|
|
91
|
+
# shellcheck disable=SC2254
|
|
92
|
+
case "$f" in
|
|
93
|
+
$glob) printf '%s\n' "$CHANGED" | grep -qxF "devdocs/$dpath" && ok=1 ;;
|
|
94
|
+
esac
|
|
95
|
+
done <<EOF_MODULES
|
|
96
|
+
$MODULES
|
|
97
|
+
EOF_MODULES
|
|
98
|
+
fi
|
|
99
|
+
[ "$ok" = "1" ] || MISSING="${MISSING}${f}
|
|
100
|
+
"
|
|
101
|
+
done <<EOF_BEHAVIOUR
|
|
102
|
+
$BEHAVIOUR
|
|
103
|
+
EOF_BEHAVIOUR
|
|
104
|
+
|
|
105
|
+
if [ -z "$MISSING" ]; then
|
|
106
|
+
if [ -n "$DECISION" ]; then echo "OK: new decision record $DECISION covers the behaviour change."
|
|
107
|
+
elif [ "$RUNTIME_MAP" = "1" ]; then echo "OK: NORTHSTAR.md § Runtime map updated."
|
|
108
|
+
else echo "OK: every behaviour change has its module dossier updated."; fi
|
|
109
|
+
exit 0
|
|
110
|
+
fi
|
|
111
|
+
|
|
112
|
+
echo "ERROR: behaviour changed under bin/lean/ or scripts/ without a docs update:"
|
|
113
|
+
printf '%s' "$MISSING" | sed 's/^/ - /'
|
|
114
|
+
echo ""
|
|
115
|
+
echo "Fix with one of:"
|
|
116
|
+
echo " - edit the matching dossier (devdocs/index.yaml modules: glob -> devdocs/modules/*.md)"
|
|
117
|
+
echo " - update NORTHSTAR.md § Runtime map"
|
|
118
|
+
echo " - add devdocs/decisions/<issue>-<slug>.md"
|
|
119
|
+
echo " - or add a line 'docs-not-needed: <reason>' to the PR body"
|
|
120
|
+
exit 1
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# check-devdocs-freshness.test.sh — fixture PRs for scripts/check-devdocs-freshness.sh (forge#4185).
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
set -uo pipefail
|
|
6
|
+
S="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/check-devdocs-freshness.sh"
|
|
7
|
+
PASS=0; FAILN=0
|
|
8
|
+
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
9
|
+
bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
|
|
10
|
+
|
|
11
|
+
git init -q --bare "$TMP/origin.git"
|
|
12
|
+
git clone -q "$TMP/origin.git" "$TMP/w" 2>/dev/null
|
|
13
|
+
cd "$TMP/w" || exit 1
|
|
14
|
+
git config user.email t@example.com; git config user.name t
|
|
15
|
+
mkdir -p bin/lean scripts/fixtures devdocs/modules devdocs/decisions
|
|
16
|
+
printf 'export const x = 1;\n' > bin/lean/lean.mjs
|
|
17
|
+
printf 'test\n' > bin/lean/lean.test.mjs
|
|
18
|
+
printf '#!/bin/sh\necho foo\n' > scripts/foo.sh
|
|
19
|
+
printf 'fixture\n' > scripts/fixtures/f.txt
|
|
20
|
+
printf '# lean\n' > devdocs/modules/lean.md
|
|
21
|
+
printf '# runner\n' > devdocs/modules/runner.md
|
|
22
|
+
: > devdocs/decisions/.gitkeep
|
|
23
|
+
cat > devdocs/index.yaml <<'EOF'
|
|
24
|
+
version: 2
|
|
25
|
+
domains:
|
|
26
|
+
lean:
|
|
27
|
+
docs:
|
|
28
|
+
- path: modules/lean.md
|
|
29
|
+
modules:
|
|
30
|
+
- name: runner
|
|
31
|
+
glob: "bin/runner*"
|
|
32
|
+
path: modules/runner.md
|
|
33
|
+
authority: required
|
|
34
|
+
|
|
35
|
+
- name: lean
|
|
36
|
+
glob: "bin/lean/*"
|
|
37
|
+
path: modules/lean.md
|
|
38
|
+
authority: required
|
|
39
|
+
|
|
40
|
+
always_load: []
|
|
41
|
+
EOF
|
|
42
|
+
cat > NORTHSTAR.md <<'EOF'
|
|
43
|
+
# North Star
|
|
44
|
+
|
|
45
|
+
## Non-negotiables
|
|
46
|
+
|
|
47
|
+
1. Deterministic first.
|
|
48
|
+
|
|
49
|
+
## Runtime map
|
|
50
|
+
|
|
51
|
+
| Path | Role |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `bin/lean/*` | lean engine |
|
|
54
|
+
|
|
55
|
+
## Anti-goals
|
|
56
|
+
|
|
57
|
+
- More agents.
|
|
58
|
+
EOF
|
|
59
|
+
git add -A; git commit -qm base; git branch -M staging; git push -q origin staging 2>/dev/null
|
|
60
|
+
git checkout -q -b feat
|
|
61
|
+
|
|
62
|
+
reset() { git reset -q --hard origin/staging; git clean -qfd; }
|
|
63
|
+
commit() { git add -A; git commit -qm change; }
|
|
64
|
+
run() { # name want_rc [env...]
|
|
65
|
+
local name="$1" want="$2"; shift 2
|
|
66
|
+
env PR_BODY="" "$@" bash "$S" origin/staging >"$TMP/out" 2>&1
|
|
67
|
+
local rc=$?
|
|
68
|
+
if [ "$rc" = "$want" ]; then PASS=$((PASS+1)); else bad "$name: rc=$rc want=$want"; cat "$TMP/out"; fi
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
reset; printf 'export const x = 2;\n' > bin/lean/lean.mjs; commit
|
|
72
|
+
run "bin/lean/lean.mjs without docs fails" 1
|
|
73
|
+
grep -q 'bin/lean/lean.mjs' "$TMP/out" || bad "failure output names bin/lean/lean.mjs"
|
|
74
|
+
|
|
75
|
+
reset; printf 'export const x = 2;\n' > bin/lean/lean.mjs; printf '# lean\nnew entry\n' > devdocs/modules/lean.md; commit
|
|
76
|
+
run "bin/lean/lean.mjs + dossier passes" 0
|
|
77
|
+
|
|
78
|
+
reset; printf 'export const x = 2;\n' > bin/lean/lean.mjs; printf '# runner\nedit\n' > devdocs/modules/runner.md; commit
|
|
79
|
+
run "bin/lean/lean.mjs + unrelated dossier fails" 1
|
|
80
|
+
|
|
81
|
+
reset; printf 'export const x = 2;\n' > bin/lean/lean.mjs; commit
|
|
82
|
+
run "exemption line passes" 0 PR_BODY="Summary
|
|
83
|
+
docs-not-needed: refactor only, no behaviour change"
|
|
84
|
+
run "CRLF exemption line passes" 0 PR_BODY="$(printf 'Summary\r\ndocs-not-needed: typo fix\r\n')"
|
|
85
|
+
run "empty exemption reason fails" 1 PR_BODY="docs-not-needed: "
|
|
86
|
+
run "inline mention is not an exemption" 1 PR_BODY="we said docs-not-needed: no"
|
|
87
|
+
printf 'docs-not-needed: via file\n' > "$TMP/body"
|
|
88
|
+
env PR_BODY="" bash "$S" origin/staging --body-file "$TMP/body" >"$TMP/out" 2>&1 && PASS=$((PASS+1)) || bad "--body-file exemption passes"
|
|
89
|
+
|
|
90
|
+
reset; printf 'test2\n' > bin/lean/lean.test.mjs; printf 'fixture2\n' > scripts/fixtures/f.txt; printf 'x\n' > scripts/new.test.sh; commit
|
|
91
|
+
run "test-only and fixture changes pass" 0
|
|
92
|
+
|
|
93
|
+
reset; printf '#!/bin/sh\necho bar\n' > scripts/foo.sh; commit
|
|
94
|
+
run "scripts/foo.sh without docs fails" 1
|
|
95
|
+
|
|
96
|
+
reset; printf '#!/bin/sh\necho bar\n' > scripts/foo.sh; printf '# ADR\n' > devdocs/decisions/4185-x.md; commit
|
|
97
|
+
run "scripts/foo.sh + new decision passes" 0
|
|
98
|
+
|
|
99
|
+
reset; printf '# ADR\n' > devdocs/decisions/4185-x.md; git add -A; git commit -qm adr; git push -q origin HEAD:staging 2>/dev/null
|
|
100
|
+
git fetch -q origin; reset; printf '#!/bin/sh\necho bar\n' > scripts/foo.sh; printf '# ADR\nedited\n' > devdocs/decisions/4185-x.md; commit
|
|
101
|
+
run "editing an existing decision does not count" 1
|
|
102
|
+
|
|
103
|
+
reset; printf '#!/bin/sh\necho bar\n' > scripts/foo.sh; sed -i.bak 's/Deterministic first./Deterministic first, always./' NORTHSTAR.md; rm -f NORTHSTAR.md.bak; commit
|
|
104
|
+
run "NORTHSTAR edit outside Runtime map fails" 1
|
|
105
|
+
|
|
106
|
+
reset; printf '#!/bin/sh\necho bar\n' > scripts/foo.sh; sed -i.bak 's/| lean engine |/| lean engine (canonical) |/' NORTHSTAR.md; rm -f NORTHSTAR.md.bak; commit
|
|
107
|
+
run "NORTHSTAR edit inside Runtime map passes" 0
|
|
108
|
+
|
|
109
|
+
reset; printf '#!/bin/sh\necho bar\n' > scripts/foo.sh; sed -i.bak '/| `bin\/lean\/\*` | lean engine |/d' NORTHSTAR.md; rm -f NORTHSTAR.md.bak; commit
|
|
110
|
+
run "NORTHSTAR row deleted inside Runtime map passes" 0
|
|
111
|
+
|
|
112
|
+
reset; printf 'readme\n' > README.md; commit
|
|
113
|
+
run "no relevant changes pass" 0
|
|
114
|
+
|
|
115
|
+
reset
|
|
116
|
+
env PR_BODY="" bash "$S" no/such-ref >"$TMP/out" 2>&1; [ $? = 2 ] && PASS=$((PASS+1)) || bad "unknown base ref exits 2"
|
|
117
|
+
env bash "$S" >"$TMP/out" 2>&1; [ $? = 2 ] && PASS=$((PASS+1)) || bad "missing base ref exits 2"
|
|
118
|
+
|
|
119
|
+
echo "check-devdocs-freshness.test.sh: $PASS passed, $FAILN failed"
|
|
120
|
+
[ "$FAILN" = 0 ]
|
package/scripts/ci-local/run.mjs
CHANGED
|
@@ -117,6 +117,16 @@ class Tail {
|
|
|
117
117
|
async function portFree(port) { return new Promise((res) => { const s = net.createServer(); s.once('error', () => res(false)); s.listen(port, '127.0.0.1', () => s.close(() => res(true))); }); }
|
|
118
118
|
const HEALTH_FLAGS = new Set(['--health-cmd', '--health-interval', '--health-timeout', '--health-retries', '--health-start-period', '--shm-size']);
|
|
119
119
|
|
|
120
|
+
// forge#4181: ownership labels from the lane (FORGE_DOCKER_LABELS); a standalone run fills in defaults so it is labelled too.
|
|
121
|
+
const LABEL_KEYS = ['owner', 'batch', 'lane', 'issue', 'expires'];
|
|
122
|
+
export function dockerLabelArgs(env = process.env, now = Date.now()) {
|
|
123
|
+
const got = new Map();
|
|
124
|
+
for (const e of String(env.FORGE_DOCKER_LABELS || '').split(/\s+/)) { const m = /^forgedock\.([a-z]+)=([A-Za-z0-9_.-]+)$/.exec(e); if (m && !got.has(m[1])) got.set(m[1], m[2]); }
|
|
125
|
+
const dflt = { owner: '1', batch: 'adhoc', lane: `ci-local-${process.pid}`, issue: '0', expires: String(now + 6 * 3600 * 1000) };
|
|
126
|
+
for (const k of LABEL_KEYS) if (!got.has(k)) got.set(k, dflt[k]);
|
|
127
|
+
return [...got].flatMap(([k, v]) => ['--label', `forgedock.${k}=${v}`]);
|
|
128
|
+
}
|
|
129
|
+
|
|
120
130
|
async function startServices(job, log) {
|
|
121
131
|
const started = [];
|
|
122
132
|
const stop = () => { for (const n of started) runSync('docker', ['rm', '-f', n], { timeout: 30000 }); started.length = 0; };
|
|
@@ -128,11 +138,12 @@ async function startServices(job, log) {
|
|
|
128
138
|
if (!commandExists('docker') || runSync('docker', ['info'], { timeout: 20000 }).code !== 0) return { error: 'docker is not available', finish };
|
|
129
139
|
// forge#4040 (SEC-6): one docker network per job; nothing runs with --network host.
|
|
130
140
|
net = `forge-ci-net-${process.pid}-${job.id.replace(/[^A-Za-z0-9_.-]/g, '-')}`.slice(0, 120);
|
|
131
|
-
const
|
|
141
|
+
const labels = dockerLabelArgs();
|
|
142
|
+
const nr = runSync('docker', ['network', 'create', ...labels, net], { timeout: 30000 });
|
|
132
143
|
if (nr.code !== 0) { net = ''; return { error: `docker network create failed: ${(nr.err || nr.out).split('\n')[0]}`, finish }; }
|
|
133
144
|
for (const svc of job.services) {
|
|
134
145
|
const name = `forge-ci-${process.pid}-${job.id.replace(/[^A-Za-z0-9_.-]/g, '-')}-${svc.name}`.slice(0, 120);
|
|
135
|
-
const args = ['run', '-d', '--rm', '--name', name, '--network', net, '--network-alias', svc.name];
|
|
146
|
+
const args = ['run', '-d', '--rm', ...labels, '--name', name, '--network', net, '--network-alias', svc.name];
|
|
136
147
|
const hostPorts = [];
|
|
137
148
|
for (const p of svc.ports || []) {
|
|
138
149
|
const m = /^(?:(\d+):)?(\d+)(?:\/(tcp|udp))?$/.exec(String(p).trim());
|
package/scripts/ci-local.test.sh
CHANGED
|
@@ -442,6 +442,18 @@ has "health options are passed through" "$LOG" "--health-cmd"
|
|
|
442
442
|
hasnt "options off the safe list are dropped" "$LOG" "--privileged"
|
|
443
443
|
has "container is named per run" "$LOG" "forge-ci-"
|
|
444
444
|
has "container is torn down" "$LOG" "rm -f"
|
|
445
|
+
# forge#4181: the network and every container carry the forgedock.* ownership labels; standalone runs get defaults
|
|
446
|
+
RUNL=$(grep '^run -d' <<<"$LOG"); NETL=$(grep '^network create' <<<"$LOG")
|
|
447
|
+
for k in owner=1 batch=adhoc lane=ci-local- issue=0 expires=; do has "4181: container label forgedock.$k" "$RUNL" "--label forgedock.$k"; has "4181: network label forgedock.$k" "$NETL" "--label forgedock.$k"; done
|
|
448
|
+
has "4181: container keeps --rm" "$RUNL" "--rm"
|
|
449
|
+
: > "$FAKE_DOCKER_LOG"
|
|
450
|
+
OUT=$(FORGE_DOCKER_LABELS="forgedock.owner=1 forgedock.batch=b9 forgedock.lane=lean-42 forgedock.issue=42 forgedock.expires=1792434600394 other=x forgedock.bad=a;b" PATH="$T/bin:$PATH" bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat 2>&1); RC=$?
|
|
451
|
+
eq "4181: a labelled run passes" 0 "$RC"
|
|
452
|
+
RUNL=$(grep '^run -d' "$FAKE_DOCKER_LOG"); NETL=$(grep '^network create' "$FAKE_DOCKER_LOG")
|
|
453
|
+
for l in batch=b9 lane=lean-42 issue=42 expires=1792434600394; do has "4181: container gets lane label $l" "$RUNL" "--label forgedock.$l"; has "4181: network gets lane label $l" "$NETL" "--label forgedock.$l"; done
|
|
454
|
+
hasnt "4181: non-forgedock entries are dropped" "$RUNL" "other=x"
|
|
455
|
+
hasnt "4181: malformed entries are dropped" "$RUNL" "forgedock.bad"
|
|
456
|
+
hasnt "4181: env labels replace the defaults" "$RUNL" "batch=adhoc"
|
|
445
457
|
: > "$FAKE_DOCKER_LOG"
|
|
446
458
|
OUT=$(FAKE_DOCKER_INFO_RC=1 PATH="$T/bin:$PATH" bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat 2>&1); RC=$?
|
|
447
459
|
eq "docker absent defers the service job (exit 0)" 0 "$RC"
|
|
@@ -776,7 +788,7 @@ OUT=$(PATH="$T/bin:$PATH" bash "$S" run --repo "$r" --base main --base-branch ma
|
|
|
776
788
|
LOG=$(cat "$FAKE_DOCKER_LOG")
|
|
777
789
|
eq "SEC-6: the circleci service job runs" 0 "$RC"
|
|
778
790
|
hasnt "SEC-6: no --network host" "$LOG" "--network host"
|
|
779
|
-
has "SEC-6: a per-job docker network is created" "$
|
|
791
|
+
has "SEC-6: a per-job docker network is created" "$(grep '^network create' <<<"$LOG")" "forge-ci-net-"
|
|
780
792
|
has "SEC-6: the container joins it" "$LOG" "--network forge-ci-net-"
|
|
781
793
|
has "SEC-6: only the declared/default port is published, on 127.0.0.1" "$LOG" "-p 127.0.0.1:54331:5432"
|
|
782
794
|
has "SEC-6: the network is removed afterwards" "$LOG" "network rm forge-ci-net-"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# northstar-docs.test.sh — NORTHSTAR.md is present, small, pointed at, and devdocs selective loading still works (forge#4185).
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
set -uo pipefail
|
|
6
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
7
|
+
cd "$ROOT" || exit 1
|
|
8
|
+
PASS=0; FAILN=0
|
|
9
|
+
ok() { PASS=$((PASS+1)); }
|
|
10
|
+
bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
|
|
11
|
+
check() { if eval "$2"; then ok; else bad "$1"; fi; }
|
|
12
|
+
|
|
13
|
+
# NORTHSTAR.md: exists, under 8 KB, no unresolved ⟨confirm⟩ markers.
|
|
14
|
+
check "NORTHSTAR.md exists" '[ -f NORTHSTAR.md ]'
|
|
15
|
+
check "NORTHSTAR.md is under 8 KB" '[ "$(wc -c < NORTHSTAR.md | tr -d " ")" -lt 8192 ]'
|
|
16
|
+
check "NORTHSTAR.md has no ⟨confirm⟩ markers" '! grep -q "⟨confirm⟩" NORTHSTAR.md'
|
|
17
|
+
for s in "## Where a fix belongs" "## Runtime map" "## Non-negotiables" "## Anti-goals"; do
|
|
18
|
+
check "NORTHSTAR.md has section '$s'" 'grep -q "^$s" NORTHSTAR.md'
|
|
19
|
+
done
|
|
20
|
+
|
|
21
|
+
# CLAUDE.md and AGENTS.md point at it in their first line; CLAUDE.md is tracked (not gitignored).
|
|
22
|
+
check "CLAUDE.md first line points at NORTHSTAR.md" 'head -1 CLAUDE.md | grep -q "NORTHSTAR\.md"'
|
|
23
|
+
check "AGENTS.md first line points at NORTHSTAR.md" 'head -1 AGENTS.md | grep -q "NORTHSTAR\.md"'
|
|
24
|
+
check "AGENTS.md keeps its Codex section" 'grep -q "^# ForgeDock for Codex" AGENTS.md'
|
|
25
|
+
check "root CLAUDE.md is not gitignored" '! git check-ignore -q CLAUDE.md'
|
|
26
|
+
check "nested CLAUDE.md stays gitignored" 'git check-ignore -q sub/CLAUDE.md'
|
|
27
|
+
|
|
28
|
+
# "Pipeline Hardening Principles" resolves to NORTHSTAR.md only; audit.md points there, not at CLAUDE.md.
|
|
29
|
+
HITS=$(grep -rl "Pipeline Hardening Principles" --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=.forge-lean \
|
|
30
|
+
--exclude=northstar-docs.test.sh . | sed 's|^\./||')
|
|
31
|
+
check "'Pipeline Hardening Principles' resolves to NORTHSTAR.md only (got: $HITS)" '[ "$HITS" = "NORTHSTAR.md" ]'
|
|
32
|
+
check "commands/audit.md references NORTHSTAR.md" 'grep -q "NORTHSTAR\.md" commands/audit.md'
|
|
33
|
+
check "commands/audit.md has no CLAUDE.md reference" '! grep -q "CLAUDE\.md" commands/audit.md'
|
|
34
|
+
|
|
35
|
+
# lean dossier is indexed.
|
|
36
|
+
check "devdocs/modules/lean.md exists" '[ -f devdocs/modules/lean.md ]'
|
|
37
|
+
check "index.yaml modules has modules/lean.md" 'awk "/^modules:/{m=1;next} m&&/^[^[:space:]#]/{m=0} m" devdocs/index.yaml | grep -q "path: modules/lean.md"'
|
|
38
|
+
check "index.yaml has a lean domain" 'awk "/^domains:/{m=1;next} m&&/^[^[:space:]#]/{m=0} m" devdocs/index.yaml | grep -q "^ lean:"'
|
|
39
|
+
|
|
40
|
+
# Selective-loading regression: replay commands/work-on/build/context.md's always_load extraction (quote strip written portably).
|
|
41
|
+
INDEX_CONTENT=$(cat devdocs/index.yaml)
|
|
42
|
+
ALWAYS_LOAD_PATHS=$(echo "$INDEX_CONTENT" \
|
|
43
|
+
| awk '/^always_load:/,/^[a-z]/' \
|
|
44
|
+
| grep '^\s*-\s*path:' \
|
|
45
|
+
| sed 's/.*path:\s*//' \
|
|
46
|
+
| tr -d "\"'" \
|
|
47
|
+
| tr -d '[:space:]')
|
|
48
|
+
check "always_load set is empty (NORTHSTAR.md loads via CLAUDE.md/AGENTS.md)" '[ -z "$ALWAYS_LOAD_PATHS" ]'
|
|
49
|
+
check "always_load is still the empty list" 'grep -qx "always_load: \[\]" devdocs/index.yaml'
|
|
50
|
+
PARSE_LINE="awk '/^always_load:/,/^[a-z]/'"
|
|
51
|
+
check "context.md still parses always_load this way" 'grep -qF "$PARSE_LINE" commands/work-on/build/context.md'
|
|
52
|
+
for d in pipeline github architecture devdocs scripts bin infra decisions lean; do
|
|
53
|
+
check "domain '$d' present" 'grep -q "^ $d:" devdocs/index.yaml'
|
|
54
|
+
done
|
|
55
|
+
for m in runner orchestrate lean; do
|
|
56
|
+
check "module '$m' present" 'grep -q "^ - name: $m$" devdocs/index.yaml'
|
|
57
|
+
done
|
|
58
|
+
# Every concrete (non-glob) doc path the index names exists under devdocs/.
|
|
59
|
+
PATHS=$(grep -E '^[[:space:]]*-?[[:space:]]*path:' devdocs/index.yaml | sed 's/.*path:[[:space:]]*//' | tr -d "\"'" | grep -v '\*' | sort -u)
|
|
60
|
+
while IFS= read -r p; do
|
|
61
|
+
[ -n "$p" ] || continue
|
|
62
|
+
check "indexed doc exists: devdocs/$p" '[ -f "devdocs/$p" ]'
|
|
63
|
+
done <<EOF_PATHS
|
|
64
|
+
$PATHS
|
|
65
|
+
EOF_PATHS
|
|
66
|
+
|
|
67
|
+
echo "northstar-docs.test.sh: $PASS passed, $FAILN failed"
|
|
68
|
+
[ "$FAILN" = 0 ]
|