mandrel-platform 0.2.1

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 ADDED
@@ -0,0 +1,138 @@
1
+ # mandrel-platform
2
+
3
+ Shared CI/deploy workflows, composite toolchain action, npm config package,
4
+ Renovate preset, and operator runbook templates for the Mandrel platform.
5
+
6
+ ---
7
+
8
+ ## Shared Composite Actions
9
+
10
+ ### `setup-toolchain`
11
+
12
+ Installs pnpm (version sourced from the consuming repo's `packageManager` field), Node.js (version sourced from `.nvmrc`), and project dependencies via `pnpm install --frozen-lockfile`.
13
+
14
+ **Reference by SHA** to pin an exact version:
15
+
16
+ ```yaml
17
+ - name: Setup toolchain
18
+ uses: dsj1984/mandrel-platform/.github/actions/setup-toolchain@<sha>
19
+ with:
20
+ cache: 'true' # omit or pass 'false' on self-hosted runners with a warm pnpm store
21
+ ```
22
+
23
+ **Inputs:**
24
+
25
+ | Input | Required | Default | Description |
26
+ | ------- | -------- | ------- | -------------------------------------------------------------------------------------------- |
27
+ | `cache` | No | `true` | Enable pnpm store caching via `actions/setup-node`. Pass `false` on self-hosted runners. |
28
+
29
+ **When to pass `cache: 'false'`:** Self-hosted runners that maintain their own warm pnpm store do not need the `actions/setup-node` pnpm cache layer. Ubuntu runners on `ubuntu-latest` benefit from the default `cache: 'true'`.
30
+
31
+ ---
32
+
33
+ ## Published npm package
34
+
35
+ The `mandrel-platform` npm package exports shared configuration baselines and
36
+ utility scripts so all consumer repos extend the same SSOT instead of
37
+ hand-syncing copies.
38
+
39
+ ### `tsconfig.base.json`
40
+
41
+ A strict TypeScript base config — `strict`, `noUncheckedIndexedAccess`,
42
+ `noImplicitOverride`, `verbatimModuleSyntax`, `isolatedModules`,
43
+ `moduleResolution: Bundler`, `target: ES2022` — intended to be extended by
44
+ every consumer.
45
+
46
+ **Consumer usage (`tsconfig.json`):**
47
+
48
+ ```jsonc
49
+ {
50
+ "extends": "mandrel-platform/tsconfig.base.json",
51
+ "compilerOptions": {
52
+ // repo-specific overrides only
53
+ "outDir": "dist"
54
+ },
55
+ "include": ["src"]
56
+ }
57
+ ```
58
+
59
+ ### `biome.base.json`
60
+
61
+ A Biome base config with the recommended linter rule set, import organizer,
62
+ and standard formatter defaults (2-space indent, 100-char line width).
63
+
64
+ **Consumer usage (`biome.json`):**
65
+
66
+ ```jsonc
67
+ {
68
+ "extends": ["mandrel-platform/biome.base.json"],
69
+ "files": {
70
+ "ignore": ["dist/", ".wrangler/"]
71
+ }
72
+ }
73
+ ```
74
+
75
+ ### `scripts/audit-check.mjs`
76
+
77
+ CVE gate script. Runs `pnpm audit --prod` and blocks on any **unsuppressed**
78
+ High or Critical vulnerability in the production dependency graph. This is the
79
+ stricter athportal/swarm-os policy: all unsuppressed High/Critical are
80
+ blocking, not just fixable ones.
81
+
82
+ Known/accepted CVEs are suppressed via a **dated, self-expiring allowlist**
83
+ (`audit-allowlist.json` in the project root). Expired entries are treated as
84
+ un-suppressed and cause the script to exit non-zero — forcing teams to
85
+ periodically re-evaluate accepted risk.
86
+
87
+ **Consumer usage (`package.json`):**
88
+
89
+ ```jsonc
90
+ {
91
+ "scripts": {
92
+ "audit:check": "node node_modules/mandrel-platform/scripts/audit-check.mjs"
93
+ }
94
+ }
95
+ ```
96
+
97
+ Or copy the script into your repo's `scripts/` directory when you need
98
+ local customization (and pin a semver range on `mandrel-platform` so drift
99
+ is detected by Renovate).
100
+
101
+ **Allowlist format (`audit-allowlist.json`):**
102
+
103
+ ```jsonc
104
+ [
105
+ {
106
+ "id": "GHSA-xxxx-xxxx-xxxx",
107
+ "reason": "No fix available; mitigated by X",
108
+ "expires": "2026-12-31"
109
+ }
110
+ ]
111
+ ```
112
+
113
+ - `id` — GitHub Security Advisory ID (`GHSA-*`) or CVE ID (`CVE-*`).
114
+ - `reason` — Human-readable explanation of why this CVE is accepted.
115
+ - `expires` — ISO 8601 date (`YYYY-MM-DD`). **Required.** Entries whose
116
+ expiry date is in the past cause the script to exit non-zero.
117
+
118
+ ---
119
+
120
+ ## Development
121
+
122
+ ```bash
123
+ # Install dependencies
124
+ pnpm install
125
+
126
+ # Bootstrap agent scaffolding
127
+ pnpm run bootstrap
128
+ ```
129
+
130
+ ---
131
+
132
+ ## Package exports
133
+
134
+ | Export | Path |
135
+ | ------------------------------------- | --------------------------- |
136
+ | `mandrel-platform/tsconfig.base.json` | `config/tsconfig.base.json` |
137
+ | `mandrel-platform/biome.base.json` | `config/biome.base.json` |
138
+ | `mandrel-platform/scripts/*` | `scripts/*` |
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3
+ "organizeImports": {
4
+ "enabled": true
5
+ },
6
+ "linter": {
7
+ "enabled": true,
8
+ "rules": {
9
+ "recommended": true
10
+ }
11
+ },
12
+ "formatter": {
13
+ "enabled": true,
14
+ "indentStyle": "space",
15
+ "indentWidth": 2,
16
+ "lineWidth": 100
17
+ }
18
+ }
@@ -0,0 +1,63 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://github.com/dsj1984/mandrel-platform/blob/main/config/main-protection.schema.json",
4
+ "title": "Main Branch Protection Contract",
5
+ "description": "Defines the required status checks and branch protection settings for the main branch. Validated by scripts/check-required-contexts.mjs to prevent phantom-check drift.",
6
+ "type": "object",
7
+ "required": ["branch", "requiredStatusChecks"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "$schema": {
11
+ "type": "string",
12
+ "description": "JSON Schema reference."
13
+ },
14
+ "branch": {
15
+ "type": "string",
16
+ "description": "The protected branch name.",
17
+ "default": "main"
18
+ },
19
+ "requiredStatusChecks": {
20
+ "type": "array",
21
+ "description": "The exact check context names that must pass before a PR can merge. Keep this list as short as possible — ideally one aggregator context (e.g. ci-required).",
22
+ "items": {
23
+ "type": "string"
24
+ },
25
+ "minItems": 1
26
+ },
27
+ "aggregatorJob": {
28
+ "type": "string",
29
+ "description": "The workflow job whose result is the single required branch-protection context. Must match the first entry in requiredStatusChecks."
30
+ },
31
+ "upstreamJobs": {
32
+ "type": "array",
33
+ "description": "The workflow job names that the aggregatorJob depends on. scripts/check-required-contexts.mjs validates these names exist in the workflow files.",
34
+ "items": {
35
+ "type": "string"
36
+ }
37
+ },
38
+ "enforceAdmins": {
39
+ "type": "boolean",
40
+ "description": "Whether branch protection rules apply to repository administrators.",
41
+ "default": false
42
+ },
43
+ "requireLinearHistory": {
44
+ "type": "boolean",
45
+ "description": "Require a linear commit history (no merge commits).",
46
+ "default": false
47
+ },
48
+ "allowForcePushes": {
49
+ "type": "boolean",
50
+ "description": "Allow force pushes to the protected branch.",
51
+ "default": false
52
+ },
53
+ "allowDeletions": {
54
+ "type": "boolean",
55
+ "description": "Allow the protected branch to be deleted.",
56
+ "default": false
57
+ },
58
+ "_note": {
59
+ "type": "string",
60
+ "description": "Human-readable note for maintainers."
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
+ "description": "Alias — the canonical preset lives at default.json (repo root). Consumers use: github>dsj1984/mandrel-platform.",
4
+ "extends": ["github>dsj1984/mandrel-platform"]
5
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "compilerOptions": {
4
+ "strict": true,
5
+ "noUncheckedIndexedAccess": true,
6
+ "noImplicitOverride": true,
7
+ "verbatimModuleSyntax": true,
8
+ "isolatedModules": true,
9
+ "moduleResolution": "Bundler",
10
+ "target": "ES2022",
11
+ "lib": ["ES2022"],
12
+ "declaration": true,
13
+ "declarationMap": true,
14
+ "sourceMap": true,
15
+ "skipLibCheck": true
16
+ }
17
+ }
package/default.json ADDED
@@ -0,0 +1,106 @@
1
+ {
2
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
+ "description": "Shared Renovate preset for mandrel-platform consumers. Consume as: github>dsj1984/mandrel-platform.",
4
+ "schedule": ["before 9am on Monday"],
5
+ "timezone": "America/New_York",
6
+ "minimumReleaseAge": "3 days",
7
+ "platformAutomerge": true,
8
+ "dependencyDashboard": true,
9
+ "lockFileMaintenance": {
10
+ "enabled": true,
11
+ "schedule": ["before 5am on Monday"]
12
+ },
13
+ "packageRules": [
14
+ {
15
+ "description": "Automerge patch and minor updates",
16
+ "matchUpdateTypes": ["patch", "minor"],
17
+ "automerge": true,
18
+ "automergeType": "pr",
19
+ "platformAutomerge": true
20
+ },
21
+ {
22
+ "description": "Gate major updates behind dependency-dashboard approval",
23
+ "matchUpdateTypes": ["major"],
24
+ "dependencyDashboardApproval": true,
25
+ "automerge": false
26
+ },
27
+ {
28
+ "description": "Group Astro packages",
29
+ "matchPackagePatterns": ["^astro$", "^@astrojs/"],
30
+ "groupName": "Astro packages",
31
+ "groupSlug": "astro"
32
+ },
33
+ {
34
+ "description": "Group Sentry packages",
35
+ "matchPackagePatterns": ["^@sentry/"],
36
+ "groupName": "Sentry packages",
37
+ "groupSlug": "sentry"
38
+ },
39
+ {
40
+ "description": "Group ESLint packages",
41
+ "matchPackagePatterns": [
42
+ "^eslint$",
43
+ "^@eslint/",
44
+ "^eslint-config-",
45
+ "^eslint-plugin-",
46
+ "^@typescript-eslint/"
47
+ ],
48
+ "groupName": "ESLint packages",
49
+ "groupSlug": "eslint"
50
+ },
51
+ {
52
+ "description": "Group Vitest packages",
53
+ "matchPackagePatterns": ["^vitest$", "^@vitest/"],
54
+ "groupName": "Vitest packages",
55
+ "groupSlug": "vitest"
56
+ },
57
+ {
58
+ "description": "Group Playwright packages",
59
+ "matchPackagePatterns": ["^playwright$", "^@playwright/"],
60
+ "groupName": "Playwright packages",
61
+ "groupSlug": "playwright"
62
+ },
63
+ {
64
+ "description": "Group Cloudflare packages",
65
+ "matchPackagePatterns": ["^wrangler$", "^@cloudflare/", "^miniflare$"],
66
+ "groupName": "Cloudflare packages",
67
+ "groupSlug": "cloudflare"
68
+ },
69
+ {
70
+ "description": "Group Clerk packages",
71
+ "matchPackagePatterns": ["^@clerk/"],
72
+ "groupName": "Clerk packages",
73
+ "groupSlug": "clerk"
74
+ },
75
+ {
76
+ "description": "Keep .nvmrc and engines.node in lockstep — bump both when Node.js advances",
77
+ "matchManagers": ["nvm"],
78
+ "matchPackageNames": ["node"],
79
+ "postUpdateOptions": ["nodeToolchainFile"],
80
+ "postUpgradeTasks": {
81
+ "commands": [
82
+ "node -e \"const fs=require('fs'),path=require('path');const ver=fs.readFileSync('.nvmrc','utf8').trim().replace(/^v/,'');function walk(d){return fs.readdirSync(d,{withFileTypes:true}).flatMap(e=>{const p=path.join(d,e.name);return e.isDirectory()&&e.name!=='node_modules'?walk(p):e.isFile()&&e.name==='package.json'?[p]:[]});}walk('.').forEach(f=>{try{const pkg=JSON.parse(fs.readFileSync(f,'utf8'));if(pkg.engines&&pkg.engines.node){pkg.engines.node=ver;fs.writeFileSync(f,JSON.stringify(pkg,null,2)+'\\n','utf8');console.log('Updated engines.node to '+ver+' in '+f);}}catch(e){}});\""
83
+ ],
84
+ "fileFilters": ["**/.nvmrc", "**/package.json"]
85
+ },
86
+ "automerge": true,
87
+ "automergeType": "pr",
88
+ "platformAutomerge": true
89
+ },
90
+ {
91
+ "description": "Advance wrangler compatibility_date to today when wrangler is bumped",
92
+ "matchManagers": ["npm"],
93
+ "matchPackageNames": ["wrangler"],
94
+ "postUpgradeTasks": {
95
+ "commands": [
96
+ "node -e \"const fs=require('fs'),path=require('path');function walk(d){return fs.readdirSync(d,{withFileTypes:true}).flatMap(e=>{const p=path.join(d,e.name);return e.isDirectory()&&e.name!=='node_modules'?walk(p):e.isFile()&&/wrangler\\.(json|jsonc|toml)$/.test(e.name)?[p]:[]});}const today=new Date().toISOString().slice(0,10);walk('.').forEach(f=>{let c=fs.readFileSync(f,'utf8');const u=c.replace(/compatibility_date\\s*=\\s*\\\"[0-9-]+\\\"/g,'compatibility_date = \\\"'+today+'\\\"').replace(/\\\"compatibility_date\\\"\\s*:\\s*\\\"[0-9-]+\\\"/g,'\\\"compatibility_date\\\": \\\"'+today+'\\\"');if(u!==c){fs.writeFileSync(f,u,'utf8');console.log('Updated compatibility_date in '+f);}});\""
97
+ ],
98
+ "fileFilters": [
99
+ "**/wrangler.json",
100
+ "**/wrangler.jsonc",
101
+ "**/wrangler.toml"
102
+ ]
103
+ }
104
+ }
105
+ ]
106
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "mandrel-platform",
3
+ "version": "0.2.1",
4
+ "description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
5
+ "license": "MIT",
6
+ "engines": {
7
+ "node": "24.16.0",
8
+ "pnpm": ">=11.5.2"
9
+ },
10
+ "exports": {
11
+ "./tsconfig.base.json": "./config/tsconfig.base.json",
12
+ "./biome.base.json": "./config/biome.base.json",
13
+ "./scripts/*": "./scripts/*"
14
+ },
15
+ "files": [
16
+ "config/",
17
+ "default.json",
18
+ "scripts/",
19
+ "templates/"
20
+ ],
21
+ "dependencies": {
22
+ "mandrel": "^1.78.0"
23
+ },
24
+ "scripts": {
25
+ "typecheck": "node --input-type=module --eval 'process.exit(0)'",
26
+ "lint": "node --input-type=module --eval 'process.exit(0)'",
27
+ "test": "node --input-type=module --eval 'process.exit(0)'",
28
+ "sync:commands": "node .agents/scripts/sync-claude-commands.js",
29
+ "bootstrap": "node .agents/scripts/bootstrap.js",
30
+ "quality:preview": "node .agents/scripts/quality-preview.js --changed-since HEAD",
31
+ "quality:watch": "node .agents/scripts/quality-watch.js"
32
+ }
33
+ }
File without changes
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * audit-check.mjs
4
+ *
5
+ * CVE gate for the mandrel-platform npm package.
6
+ *
7
+ * Policy (athportal/swarm-os stricter variant):
8
+ * Block ALL unsuppressed High and Critical vulnerabilities in the
9
+ * production dependency graph. A self-expiring allowlist lets teams
10
+ * record known, accepted CVEs with a required expiry date — entries
11
+ * whose expiry has passed are treated as un-suppressed and will cause
12
+ * the script to exit non-zero.
13
+ *
14
+ * Usage:
15
+ * node scripts/audit-check.mjs
16
+ * node scripts/audit-check.mjs --allowlist path/to/allowlist.json
17
+ *
18
+ * Exit codes:
19
+ * 0 — no blocking vulnerabilities (all High/Critical suppressed with
20
+ * valid, non-expired allowlist entries, or none found)
21
+ * 1 — one or more unsuppressed High/Critical CVEs, or expired allowlist
22
+ * entries were encountered
23
+ *
24
+ * Allowlist format (JSON):
25
+ * [
26
+ * {
27
+ * "id": "GHSA-xxxx-xxxx-xxxx", // GitHub Advisory ID or CVE ID
28
+ * "reason": "No fix available; mitigated by X",
29
+ * "expires": "2026-12-31" // ISO 8601 date — REQUIRED
30
+ * }
31
+ * ]
32
+ *
33
+ * The allowlist file path defaults to `audit-allowlist.json` in the
34
+ * directory from which this script is invoked (i.e. the project root).
35
+ * Override with `--allowlist <path>`.
36
+ */
37
+
38
+ import { execSync } from "node:child_process";
39
+ import { existsSync, readFileSync } from "node:fs";
40
+ import { resolve } from "node:path";
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // CLI arg parsing
44
+ // ---------------------------------------------------------------------------
45
+
46
+ const args = process.argv.slice(2);
47
+ let allowlistPath = null;
48
+
49
+ for (let i = 0; i < args.length; i++) {
50
+ if (args[i] === "--allowlist" && args[i + 1]) {
51
+ allowlistPath = resolve(process.cwd(), args[i + 1]);
52
+ i++;
53
+ }
54
+ }
55
+
56
+ if (allowlistPath === null) {
57
+ allowlistPath = resolve(process.cwd(), "audit-allowlist.json");
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Allowlist loading and validation
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * @typedef {{ id: string; reason: string; expires: string }} AllowlistEntry
66
+ */
67
+
68
+ /** @type {AllowlistEntry[]} */
69
+ let allowlist = [];
70
+
71
+ if (existsSync(allowlistPath)) {
72
+ try {
73
+ const raw = readFileSync(allowlistPath, "utf8");
74
+ const parsed = JSON.parse(raw);
75
+
76
+ if (!Array.isArray(parsed)) {
77
+ console.error(
78
+ `[audit-check] ERROR: Allowlist at ${allowlistPath} must be a JSON array.`,
79
+ );
80
+ process.exit(1);
81
+ }
82
+
83
+ allowlist = parsed;
84
+ } catch (err) {
85
+ console.error(
86
+ `[audit-check] ERROR: Failed to parse allowlist at ${allowlistPath}: ${err instanceof Error ? err.message : String(err)}`,
87
+ );
88
+ process.exit(1);
89
+ }
90
+ }
91
+
92
+ const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
93
+
94
+ /** @type {Set<string>} Active (non-expired) suppressed advisory IDs */
95
+ const suppressed = new Set();
96
+ /** @type {AllowlistEntry[]} */
97
+ const expiredEntries = [];
98
+
99
+ for (const entry of allowlist) {
100
+ if (!entry.id || !entry.expires) {
101
+ console.error(
102
+ `[audit-check] ERROR: Allowlist entry missing required "id" or "expires" field: ${JSON.stringify(entry)}`,
103
+ );
104
+ process.exit(1);
105
+ }
106
+
107
+ if (entry.expires < today) {
108
+ expiredEntries.push(entry);
109
+ } else {
110
+ suppressed.add(entry.id);
111
+ }
112
+ }
113
+
114
+ if (expiredEntries.length > 0) {
115
+ console.error("[audit-check] EXPIRED allowlist entries detected:");
116
+ for (const entry of expiredEntries) {
117
+ console.error(
118
+ ` - ${entry.id} (expired ${entry.expires}): ${entry.reason ?? "no reason recorded"}`,
119
+ );
120
+ }
121
+ console.error(
122
+ "[audit-check] Renew or remove expired entries to proceed. Exit 1.",
123
+ );
124
+ process.exit(1);
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Run pnpm audit (production graph only)
129
+ // ---------------------------------------------------------------------------
130
+
131
+ console.log("[audit-check] Running pnpm audit --prod --json ...");
132
+
133
+ let auditOutput = "";
134
+ let auditExitCode = 0;
135
+
136
+ try {
137
+ auditOutput = execSync("pnpm audit --prod --json 2>/dev/null", {
138
+ encoding: "utf8",
139
+ });
140
+ } catch (err) {
141
+ // pnpm audit exits non-zero when vulnerabilities are found.
142
+ // We want the JSON regardless of the exit code.
143
+ const execError = /** @type {{ stdout?: string; status?: number }} */ (err);
144
+ auditOutput = execError.stdout ?? "";
145
+ auditExitCode = execError.status ?? 1;
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Parse audit JSON
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /** @type {unknown} */
153
+ let report;
154
+
155
+ try {
156
+ report = JSON.parse(auditOutput);
157
+ } catch {
158
+ if (auditExitCode === 0) {
159
+ // No JSON means nothing to audit — clean.
160
+ console.log("[audit-check] No vulnerabilities found. Exit 0.");
161
+ process.exit(0);
162
+ }
163
+ console.error(
164
+ "[audit-check] ERROR: pnpm audit produced non-JSON output (exit code " +
165
+ auditExitCode +
166
+ ").",
167
+ );
168
+ console.error(auditOutput.slice(0, 2000));
169
+ process.exit(1);
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Extract advisories
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /**
177
+ * pnpm audit --json shape:
178
+ * {
179
+ * "advisories": {
180
+ * "<id>": {
181
+ * "ghsa_id": "GHSA-xxxx",
182
+ * "cve": ["CVE-xxxx"],
183
+ * "severity": "high" | "critical" | "moderate" | "low" | "info",
184
+ * "title": "...",
185
+ * "url": "...",
186
+ * ...
187
+ * }
188
+ * },
189
+ * "metadata": { ... }
190
+ * }
191
+ */
192
+
193
+ const BLOCKING_SEVERITIES = new Set(["high", "critical"]);
194
+
195
+ /** @type {Array<{ id: string; severity: string; title: string; url: string }>} */
196
+ const blocking = [];
197
+
198
+ if (
199
+ report !== null &&
200
+ typeof report === "object" &&
201
+ "advisories" in report &&
202
+ report.advisories !== null &&
203
+ typeof report.advisories === "object"
204
+ ) {
205
+ const advisories = /** @type {Record<string, unknown>} */ (
206
+ report.advisories
207
+ );
208
+
209
+ for (const [, advisory] of Object.entries(advisories)) {
210
+ if (
211
+ advisory === null ||
212
+ typeof advisory !== "object" ||
213
+ !("severity" in advisory)
214
+ ) {
215
+ continue;
216
+ }
217
+
218
+ const adv = /** @type {Record<string, unknown>} */ (advisory);
219
+ const severity = String(adv["severity"] ?? "").toLowerCase();
220
+
221
+ if (!BLOCKING_SEVERITIES.has(severity)) {
222
+ continue;
223
+ }
224
+
225
+ // Collect all IDs this advisory is known by for allowlist matching.
226
+ const ghsaId = String(adv["ghsa_id"] ?? "");
227
+ const cveIds = Array.isArray(adv["cve"])
228
+ ? adv["cve"].map((c) => String(c))
229
+ : [];
230
+ const allIds = [ghsaId, ...cveIds].filter(Boolean);
231
+
232
+ const isSuppressed = allIds.some((id) => suppressed.has(id));
233
+
234
+ if (!isSuppressed) {
235
+ blocking.push({
236
+ id: ghsaId || cveIds[0] || "(unknown)",
237
+ severity,
238
+ title: String(adv["title"] ?? "(no title)"),
239
+ url: String(adv["url"] ?? ""),
240
+ });
241
+ }
242
+ }
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Report and exit
247
+ // ---------------------------------------------------------------------------
248
+
249
+ if (blocking.length === 0) {
250
+ console.log(
251
+ `[audit-check] No unsuppressed High/Critical vulnerabilities in the prod graph. Exit 0.`,
252
+ );
253
+ process.exit(0);
254
+ }
255
+
256
+ console.error(
257
+ `[audit-check] ${blocking.length} unsuppressed High/Critical CVE(s) found in prod dependency graph:`,
258
+ );
259
+
260
+ for (const vuln of blocking) {
261
+ console.error(` [${vuln.severity.toUpperCase()}] ${vuln.id}: ${vuln.title}`);
262
+ if (vuln.url) {
263
+ console.error(` → ${vuln.url}`);
264
+ }
265
+ }
266
+
267
+ console.error(
268
+ "\n[audit-check] To suppress a known/accepted CVE, add a dated entry to audit-allowlist.json:",
269
+ );
270
+ console.error(
271
+ JSON.stringify(
272
+ [
273
+ {
274
+ id: blocking[0]?.id ?? "GHSA-xxxx-xxxx-xxxx",
275
+ reason: "Describe why this is accepted and any mitigations in place",
276
+ expires: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)
277
+ .toISOString()
278
+ .slice(0, 10),
279
+ },
280
+ ],
281
+ null,
282
+ 2,
283
+ ),
284
+ );
285
+
286
+ console.error("\n[audit-check] Exit 1.");
287
+ process.exit(1);
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-docs-staleness.mjs
4
+ *
5
+ * Flags retired-product references and known staleness patterns in documentation.
6
+ *
7
+ * Designed to be run in mandrel-platform consumers as a CI lint step, or from
8
+ * the mandrel-platform repo itself against its own docs.
9
+ *
10
+ * Usage:
11
+ * node scripts/check-docs-staleness.mjs [options]
12
+ *
13
+ * Options:
14
+ * --dir <path> Directory to scan (default: docs/)
15
+ * --warn-only Exit 0 even when issues are found (print warnings, don't fail CI)
16
+ * --quiet Suppress per-file output; only print summary
17
+ * --help Print this help and exit
18
+ *
19
+ * Examples:
20
+ * node scripts/check-docs-staleness.mjs
21
+ * node scripts/check-docs-staleness.mjs --dir docs/ --warn-only
22
+ * node node_modules/mandrel-platform/scripts/check-docs-staleness.mjs --dir docs/
23
+ */
24
+
25
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
26
+ import { join, relative, extname } from 'node:path';
27
+ import { parseArgs } from 'node:util';
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // CLI argument parsing
31
+ // ---------------------------------------------------------------------------
32
+
33
+ const { values: argv } = parseArgs({
34
+ options: {
35
+ dir: { type: 'string', default: 'docs' },
36
+ 'warn-only': { type: 'boolean', default: false },
37
+ quiet: { type: 'boolean', default: false },
38
+ help: { type: 'boolean', default: false },
39
+ },
40
+ strict: false,
41
+ });
42
+
43
+ if (argv.help) {
44
+ console.log(`
45
+ check-docs-staleness.mjs — docs staleness lint for mandrel-platform consumers
46
+
47
+ Usage:
48
+ node scripts/check-docs-staleness.mjs [options]
49
+
50
+ Options:
51
+ --dir <path> Directory to scan (default: docs/)
52
+ --warn-only Exit 0 even when issues are found
53
+ --quiet Suppress per-file output; only print summary
54
+ --help Print this help and exit
55
+ `);
56
+ process.exit(0);
57
+ }
58
+
59
+ const SCAN_DIR = argv.dir ?? 'docs';
60
+ const WARN_ONLY = argv['warn-only'] ?? false;
61
+ const QUIET = argv.quiet ?? false;
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Staleness patterns
65
+ //
66
+ // Each rule has:
67
+ // id — unique identifier (used in suppression comments)
68
+ // description — human-readable explanation shown in lint output
69
+ // pattern — regex to search for in file content
70
+ // severity — 'error' | 'warning'
71
+ // fileGlob — optional: only apply to files matching this regex
72
+ //
73
+ // Suppression: add `<!-- staleness-ignore: <id> -->` on the line above the
74
+ // flagged text to suppress a specific rule for that occurrence.
75
+ // ---------------------------------------------------------------------------
76
+
77
+ const RULES = [
78
+ {
79
+ id: 'pages-deploy-command',
80
+ description: 'References `wrangler pages deploy` — may be stale if the project has migrated web to a Worker',
81
+ pattern: /wrangler pages deploy/g,
82
+ severity: 'error',
83
+ },
84
+ {
85
+ id: 'pages-dev-url',
86
+ description: 'References `*.pages.dev` URL — may be stale if the project has migrated off Cloudflare Pages',
87
+ pattern: /[a-z0-9-]+\.pages\.dev/g,
88
+ severity: 'error',
89
+ },
90
+ {
91
+ id: 'pages-dashboard-link',
92
+ description: 'References the Cloudflare Pages dashboard (`dash.cloudflare.com/pages`) — may be stale after Worker migration',
93
+ pattern: /dash\.cloudflare\.com\/[a-z0-9]+\/pages/g,
94
+ severity: 'error',
95
+ },
96
+ {
97
+ id: 'pages-rollback',
98
+ description: 'References `wrangler pages deployment rollback` — may be stale if the project has migrated web to a Worker',
99
+ pattern: /wrangler pages deployment rollback/g,
100
+ severity: 'warning',
101
+ },
102
+ {
103
+ id: 'hardcoded-worker-name',
104
+ description: 'Possible hardcoded project-specific worker name in a common runbook (expected only in project-local docs)',
105
+ // Detects patterns like `my-app-staging` or `my-app-production` but not generic `<worker-name>`
106
+ pattern: /--name\s+[a-z][a-z0-9-]+-(staging|production)\b(?!\s*>)/g,
107
+ severity: 'warning',
108
+ fileGlob: /docs\/runbooks\//,
109
+ },
110
+ {
111
+ id: 'hardcoded-url',
112
+ description: 'Possible hardcoded non-placeholder URL (use `<PLACEHOLDER>` style in common runbooks)',
113
+ // Detects https:// URLs that are not placeholders (< >) and not github.com/cloudflare docs links
114
+ pattern: /https:\/\/(?!github\.com|docs\.cloudflare\.com|api\.cloudflare\.com|uptime\.betterstack\.com)[a-z0-9][a-z0-9.-]+\.[a-z]{2,}\/[^\s)"'>]*/g,
115
+ severity: 'warning',
116
+ fileGlob: /docs\/runbooks\//,
117
+ },
118
+ {
119
+ id: 'stale-github-runbooks-ref',
120
+ description: 'References `.github/RUNBOOKS/` — this is the stale duplicate directory pattern; canonical runbooks live in `docs/runbooks/`',
121
+ pattern: /\.github\/RUNBOOKS\//g,
122
+ severity: 'error',
123
+ },
124
+ {
125
+ id: 'quality-yml-ref',
126
+ description: 'References `quality.yml` — verify this file exists in the project (swarm-os ships `ci.yml` instead)',
127
+ pattern: /quality\.yml/g,
128
+ severity: 'warning',
129
+ },
130
+ {
131
+ id: 'expired-placeholder',
132
+ description: 'Placeholder date that has passed (YYYY-MM-DD pattern in an expiry/todo context)',
133
+ // Matches explicit expiry dates like "expires: 2025-01-01" that are in the past
134
+ pattern: /expires[:\s]+202[0-4]-\d{2}-\d{2}/gi,
135
+ severity: 'error',
136
+ },
137
+ ];
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // File walker
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * Recursively collect all .md and .json files under dir.
145
+ * @param {string} dir
146
+ * @returns {string[]}
147
+ */
148
+ function walkDir(dir) {
149
+ const results = [];
150
+ let entries;
151
+ try {
152
+ entries = readdirSync(dir);
153
+ } catch {
154
+ return results;
155
+ }
156
+ for (const entry of entries) {
157
+ const fullPath = join(dir, entry);
158
+ const stat = statSync(fullPath);
159
+ if (stat.isDirectory()) {
160
+ if (entry === 'node_modules' || entry === '.git') continue;
161
+ results.push(...walkDir(fullPath));
162
+ } else if (['.md', '.json'].includes(extname(entry))) {
163
+ results.push(fullPath);
164
+ }
165
+ }
166
+ return results;
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Lint a single file
171
+ // ---------------------------------------------------------------------------
172
+
173
+ /**
174
+ * @typedef {{ file: string, line: number, rule: typeof RULES[0], match: string }} Finding
175
+ */
176
+
177
+ /**
178
+ * Lint a file against all rules.
179
+ * @param {string} filePath
180
+ * @returns {Finding[]}
181
+ */
182
+ function lintFile(filePath) {
183
+ const findings = [];
184
+ let content;
185
+ try {
186
+ content = readFileSync(filePath, 'utf8');
187
+ } catch {
188
+ return findings;
189
+ }
190
+
191
+ const lines = content.split('\n');
192
+
193
+ for (const rule of RULES) {
194
+ // Skip if this rule has a fileGlob and the file doesn't match
195
+ if (rule.fileGlob && !rule.fileGlob.test(filePath)) continue;
196
+
197
+ for (let i = 0; i < lines.length; i++) {
198
+ const line = lines[i];
199
+ const prevLine = i > 0 ? lines[i - 1] : '';
200
+
201
+ // Check for suppression comment on the preceding line
202
+ if (prevLine.includes(`staleness-ignore: ${rule.id}`)) continue;
203
+
204
+ // Reset regex state for global patterns
205
+ rule.pattern.lastIndex = 0;
206
+ let match;
207
+ while ((match = rule.pattern.exec(line)) !== null) {
208
+ findings.push({
209
+ file: filePath,
210
+ line: i + 1,
211
+ rule,
212
+ match: match[0],
213
+ });
214
+ }
215
+ }
216
+ }
217
+
218
+ return findings;
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Main
223
+ // ---------------------------------------------------------------------------
224
+
225
+ const files = walkDir(SCAN_DIR);
226
+
227
+ if (files.length === 0) {
228
+ console.log(`[docs-staleness] No files found under '${SCAN_DIR}' — nothing to check.`);
229
+ process.exit(0);
230
+ }
231
+
232
+ /** @type {Finding[]} */
233
+ const allFindings = [];
234
+
235
+ for (const file of files) {
236
+ const findings = lintFile(file);
237
+ allFindings.push(...findings);
238
+ }
239
+
240
+ // Group findings by file for readable output
241
+ const byFile = new Map();
242
+ for (const finding of allFindings) {
243
+ const key = finding.file;
244
+ if (!byFile.has(key)) byFile.set(key, []);
245
+ byFile.get(key).push(finding);
246
+ }
247
+
248
+ const errors = allFindings.filter((f) => f.rule.severity === 'error');
249
+ const warnings = allFindings.filter((f) => f.rule.severity === 'warning');
250
+
251
+ if (!QUIET) {
252
+ for (const [file, findings] of byFile.entries()) {
253
+ const relPath = relative(process.cwd(), file);
254
+ for (const f of findings) {
255
+ const sev = f.rule.severity === 'error' ? 'ERR ' : 'WARN';
256
+ console.log(`[${sev}] ${relPath}:${f.line} — ${f.rule.id}: ${f.rule.description}`);
257
+ console.log(` matched: ${JSON.stringify(f.match)}`);
258
+ }
259
+ }
260
+ }
261
+
262
+ console.log(
263
+ `\n[docs-staleness] Scanned ${files.length} file(s). ` +
264
+ `Found ${errors.length} error(s), ${warnings.length} warning(s).`,
265
+ );
266
+
267
+ if (allFindings.length > 0) {
268
+ console.log(`\nTo suppress a specific rule occurrence, add this comment on the line above:`);
269
+ console.log(` <!-- staleness-ignore: <rule-id> -->`);
270
+ console.log(`\nAvailable rule IDs: ${RULES.map((r) => r.id).join(', ')}`);
271
+ }
272
+
273
+ if (errors.length > 0 && !WARN_ONLY) {
274
+ process.exit(1);
275
+ }
276
+
277
+ process.exit(0);
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-required-contexts.mjs
4
+ *
5
+ * Branch-protection context lint for mandrel-platform consumers.
6
+ *
7
+ * Reads `docs/runbooks/main-protection.json` and asserts that every context
8
+ * listed in `requiredStatusChecks` — and every job listed in `upstreamJobs` —
9
+ * is actually emitted by a job in the `.github/workflows/` directory.
10
+ *
11
+ * This prevents the "phantom required check" failure mode where a check is
12
+ * registered in branch protection but no CI job ever reports it, leaving
13
+ * every PR blocked indefinitely on a `pending` status that never resolves.
14
+ *
15
+ * Usage:
16
+ * node scripts/check-required-contexts.mjs
17
+ * node scripts/check-required-contexts.mjs --contract path/to/main-protection.json
18
+ * node scripts/check-required-contexts.mjs --workflows-dir .github/workflows
19
+ *
20
+ * Exit codes:
21
+ * 0 — all required contexts are emitted by at least one workflow job
22
+ * 1 — one or more phantom contexts detected (named in stderr)
23
+ *
24
+ * Consumer adoption:
25
+ * Copy this script into your project's `scripts/` directory, then wire it
26
+ * into your PR-quality workflow:
27
+ *
28
+ * - name: Lint branch-protection contract
29
+ * run: node scripts/check-required-contexts.mjs
30
+ *
31
+ * Keep `docs/runbooks/main-protection.json` up to date whenever you add,
32
+ * rename, or remove workflow jobs so the lint stays accurate.
33
+ */
34
+
35
+ import { readFileSync, readdirSync } from "node:fs";
36
+ import { resolve, join, relative } from "node:path";
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Arg parsing
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const args = process.argv.slice(2);
43
+ let contractPath = null;
44
+ let workflowsDir = null;
45
+
46
+ for (let i = 0; i < args.length; i++) {
47
+ if ((args[i] === "--contract" || args[i] === "-c") && args[i + 1]) {
48
+ contractPath = args[++i];
49
+ } else if ((args[i] === "--workflows-dir" || args[i] === "-w") && args[i + 1]) {
50
+ workflowsDir = args[++i];
51
+ } else if (args[i] === "--help" || args[i] === "-h") {
52
+ process.stdout.write(
53
+ "Usage: node scripts/check-required-contexts.mjs [--contract <path>] [--workflows-dir <dir>]\n"
54
+ );
55
+ process.exit(0);
56
+ }
57
+ }
58
+
59
+ // Resolve paths relative to the repo root (cwd when invoked from CI or locally).
60
+ const repoRoot = process.cwd();
61
+ const resolvedContract = contractPath
62
+ ? resolve(contractPath)
63
+ : resolve(repoRoot, "docs/runbooks/main-protection.json");
64
+ const resolvedWorkflowsDir = workflowsDir
65
+ ? resolve(workflowsDir)
66
+ : resolve(repoRoot, ".github/workflows");
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Load contract
70
+ // ---------------------------------------------------------------------------
71
+
72
+ let contract;
73
+ try {
74
+ const raw = readFileSync(resolvedContract, "utf8");
75
+ contract = JSON.parse(raw);
76
+ } catch (err) {
77
+ process.stderr.write(
78
+ `[check-required-contexts] ERROR: Cannot read contract at ${relative(repoRoot, resolvedContract)}: ${err.message}\n`
79
+ );
80
+ process.exit(1);
81
+ }
82
+
83
+ const requiredContexts = Array.isArray(contract.requiredStatusChecks)
84
+ ? contract.requiredStatusChecks
85
+ : [];
86
+ const upstreamJobs = Array.isArray(contract.upstreamJobs)
87
+ ? contract.upstreamJobs
88
+ : [];
89
+
90
+ if (requiredContexts.length === 0) {
91
+ process.stderr.write(
92
+ "[check-required-contexts] ERROR: contract.requiredStatusChecks is empty — at least one context is required.\n"
93
+ );
94
+ process.exit(1);
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Collect job names from all workflow files
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * Extract all job IDs from a YAML workflow file using a simple line-by-line
103
+ * parser. We intentionally avoid a full YAML parser to keep this script
104
+ * dependency-free — the job ID pattern is stable enough to parse with a regex.
105
+ *
106
+ * GitHub Actions job IDs appear as top-level keys under the `jobs:` map:
107
+ *
108
+ * jobs:
109
+ * lint: ← job ID = "lint"
110
+ * name: Lint & format
111
+ * ...
112
+ * ci-required: ← job ID = "ci-required"
113
+ * ...
114
+ *
115
+ * The job ID line is indented by exactly 2 spaces and ends with a colon.
116
+ */
117
+ function extractJobIds(yamlContent) {
118
+ const ids = new Set();
119
+ let inJobsBlock = false;
120
+
121
+ for (const line of yamlContent.split("\n")) {
122
+ // Detect the `jobs:` top-level key (zero indentation).
123
+ if (/^jobs:\s*$/.test(line)) {
124
+ inJobsBlock = true;
125
+ continue;
126
+ }
127
+
128
+ if (!inJobsBlock) continue;
129
+
130
+ // A top-level key at zero indentation that is NOT `jobs:` ends the block.
131
+ if (/^[a-zA-Z0-9_-]/.test(line) && !/^jobs:\s*$/.test(line)) {
132
+ inJobsBlock = false;
133
+ continue;
134
+ }
135
+
136
+ // Job ID lines: exactly 2-space indent, identifier, colon, optional spaces.
137
+ const jobMatch = line.match(/^ ([a-zA-Z0-9_-]+):\s*$/);
138
+ if (jobMatch) {
139
+ ids.add(jobMatch[1]);
140
+ }
141
+ }
142
+
143
+ return ids;
144
+ }
145
+
146
+ let workflowFiles;
147
+ try {
148
+ workflowFiles = readdirSync(resolvedWorkflowsDir).filter(
149
+ (f) => f.endsWith(".yml") || f.endsWith(".yaml")
150
+ );
151
+ } catch (err) {
152
+ process.stderr.write(
153
+ `[check-required-contexts] ERROR: Cannot read workflows directory at ${relative(repoRoot, resolvedWorkflowsDir)}: ${err.message}\n`
154
+ );
155
+ process.exit(1);
156
+ }
157
+
158
+ if (workflowFiles.length === 0) {
159
+ process.stderr.write(
160
+ `[check-required-contexts] ERROR: No workflow files found in ${relative(repoRoot, resolvedWorkflowsDir)}\n`
161
+ );
162
+ process.exit(1);
163
+ }
164
+
165
+ /** Map from workflow filename → Set<jobId> */
166
+ const workflowJobMap = new Map();
167
+ /** Flat set of all job IDs across all workflows */
168
+ const allJobIds = new Set();
169
+
170
+ for (const file of workflowFiles) {
171
+ const filePath = join(resolvedWorkflowsDir, file);
172
+ let content;
173
+ try {
174
+ content = readFileSync(filePath, "utf8");
175
+ } catch (err) {
176
+ process.stderr.write(
177
+ `[check-required-contexts] WARN: Cannot read ${file}: ${err.message} — skipping.\n`
178
+ );
179
+ continue;
180
+ }
181
+ const ids = extractJobIds(content);
182
+ workflowJobMap.set(file, ids);
183
+ for (const id of ids) {
184
+ allJobIds.add(id);
185
+ }
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Validate required contexts
190
+ // ---------------------------------------------------------------------------
191
+
192
+ const phantomContexts = requiredContexts.filter((ctx) => !allJobIds.has(ctx));
193
+ const phantomUpstream = upstreamJobs.filter((job) => !allJobIds.has(job));
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Report
197
+ // ---------------------------------------------------------------------------
198
+
199
+ const contractRel = relative(repoRoot, resolvedContract);
200
+ const workflowsRel = relative(repoRoot, resolvedWorkflowsDir);
201
+
202
+ process.stdout.write(
203
+ `[check-required-contexts] Contract : ${contractRel}\n` +
204
+ `[check-required-contexts] Workflows: ${workflowsRel}/ (${workflowFiles.length} file${workflowFiles.length === 1 ? "" : "s"})\n` +
205
+ `[check-required-contexts] Emitted job IDs: ${[...allJobIds].sort().join(", ")}\n`
206
+ );
207
+
208
+ if (phantomContexts.length > 0) {
209
+ process.stderr.write(
210
+ `\n[check-required-contexts] ❌ PHANTOM required contexts detected!\n` +
211
+ ` These contexts are listed in requiredStatusChecks but no workflow job emits them:\n`
212
+ );
213
+ for (const ctx of phantomContexts) {
214
+ process.stderr.write(` • "${ctx}"\n`);
215
+ }
216
+ process.stderr.write(
217
+ `\n A phantom context will block every PR indefinitely on a "pending" status\n` +
218
+ ` that never resolves. Fix: either add a workflow job with this exact ID,\n` +
219
+ ` or remove the context from requiredStatusChecks in ${contractRel}.\n\n`
220
+ );
221
+ }
222
+
223
+ if (phantomUpstream.length > 0) {
224
+ process.stderr.write(
225
+ `\n[check-required-contexts] ❌ PHANTOM upstream jobs detected!\n` +
226
+ ` These jobs are listed in upstreamJobs but no workflow defines them:\n`
227
+ );
228
+ for (const job of phantomUpstream) {
229
+ process.stderr.write(` • "${job}"\n`);
230
+ }
231
+ process.stderr.write(
232
+ `\n Fix: either add the missing job to a workflow, or remove it from\n` +
233
+ ` upstreamJobs in ${contractRel}.\n\n`
234
+ );
235
+ }
236
+
237
+ const hasError = phantomContexts.length > 0 || phantomUpstream.length > 0;
238
+
239
+ if (!hasError) {
240
+ process.stdout.write(
241
+ `[check-required-contexts] ✅ All required contexts and upstream jobs are emitted by CI.\n` +
242
+ ` requiredStatusChecks : ${requiredContexts.join(", ")}\n` +
243
+ ` upstreamJobs : ${upstreamJobs.length > 0 ? upstreamJobs.join(", ") : "(none listed)"}\n`
244
+ );
245
+ }
246
+
247
+ process.exit(hasError ? 1 : 0);
File without changes