mandrel-platform 0.17.2 → 0.18.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 +220 -14
- package/config/commitlint.base.mjs +36 -0
- package/config/repo-settings.schema.json +78 -0
- package/package.json +2 -1
- package/scripts/apply-uptime-monitors.mjs +378 -0
- package/scripts/apply-uptime-monitors.test.mjs +372 -0
- package/scripts/check-repo-settings.mjs +363 -0
- package/scripts/check-repo-settings.test.mjs +320 -0
- package/scripts/check-required-contexts.mjs +247 -129
- package/scripts/check-required-contexts.test.mjs +137 -0
- package/scripts/check-ruleset.mjs +435 -0
- package/scripts/check-ruleset.test.mjs +439 -0
- package/scripts/check-wrangler-baseline.mjs +514 -0
- package/scripts/check-wrangler-baseline.test.mjs +454 -0
- package/scripts/platform-sync.mjs +533 -5
- package/scripts/platform-sync.test.mjs +477 -0
- package/templates/workflows/deploy-staging.yml +86 -0
- package/templates/workflows/uptime-apply.yml +54 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-wrangler-baseline.mjs
|
|
4
|
+
*
|
|
5
|
+
* Wrangler configuration baseline gate (Story #177, roadmap §2a.3).
|
|
6
|
+
*
|
|
7
|
+
* Four wrangler invariants were "kept by convention" across the fleet with no
|
|
8
|
+
* automated enforcement (repo-ops consumers matrix §2/§7): the `env.*`
|
|
9
|
+
* named-Environment split, `logpush: true` per Worker, an Analytics Engine
|
|
10
|
+
* binding, and a `compatibility_date` staleness policy (Renovate's preset
|
|
11
|
+
* bumps the date whenever `wrangler` itself is bumped, but nothing flags a
|
|
12
|
+
* `compatibility_date` that has gone stale on its own). This script asserts
|
|
13
|
+
* all four against a consumer's own `wrangler.toml` / `wrangler.jsonc`.
|
|
14
|
+
*
|
|
15
|
+
* Designed to run in mandrel-platform consumers as a CI lint step (wired into
|
|
16
|
+
* the `pr-quality` reusable workflow's `lint` tier — see
|
|
17
|
+
* `.github/workflows/pr-quality.yml`), or standalone from this repo against a
|
|
18
|
+
* given file. Mirrors `check-docs-staleness.mjs`'s consumer-runnable framing:
|
|
19
|
+
* a project with no wrangler config at all is a no-op pass (not every
|
|
20
|
+
* consumer is a Cloudflare Worker).
|
|
21
|
+
*
|
|
22
|
+
* Exceptions: a Worker that legitimately opts out of one invariant declares
|
|
23
|
+
* it explicitly via a top-level `mandrel` block (see `docs/reusable-workflows.md`)
|
|
24
|
+
* rather than silently failing to match — e.g.:
|
|
25
|
+
*
|
|
26
|
+
* // wrangler.jsonc
|
|
27
|
+
* "mandrel": {
|
|
28
|
+
* "wranglerBaselineExceptions": {
|
|
29
|
+
* "analytics-engine": "no telemetry sink for this static-asset Worker"
|
|
30
|
+
* }
|
|
31
|
+
* }
|
|
32
|
+
*
|
|
33
|
+
* or, in wrangler.toml:
|
|
34
|
+
*
|
|
35
|
+
* [mandrel.wranglerBaselineExceptions]
|
|
36
|
+
* analytics-engine = "no telemetry sink for this static-asset Worker"
|
|
37
|
+
*
|
|
38
|
+
* A declared exception suppresses that one rule's finding but is echoed back
|
|
39
|
+
* in the report so it stays visible (declared, not silent).
|
|
40
|
+
*
|
|
41
|
+
* Usage:
|
|
42
|
+
* node scripts/check-wrangler-baseline.mjs [options]
|
|
43
|
+
*
|
|
44
|
+
* Options:
|
|
45
|
+
* --file <path> Path to the wrangler config (default: auto-detect
|
|
46
|
+
* wrangler.jsonc then wrangler.toml at repo root).
|
|
47
|
+
* --max-age-days <n> Staleness window for compatibility_date (default: 90).
|
|
48
|
+
* --warn-only Exit 0 even when violations are found (advisory).
|
|
49
|
+
* --json Emit a machine-readable envelope instead of text.
|
|
50
|
+
* --help Print this help and exit.
|
|
51
|
+
*
|
|
52
|
+
* Exit codes:
|
|
53
|
+
* 0 — no config found (no-op pass), all rules pass, or --warn-only.
|
|
54
|
+
* 1 — at least one un-excepted rule violation (without --warn-only).
|
|
55
|
+
*
|
|
56
|
+
* Advisory rollout (acceptance criteria): the `pr-quality` wiring defaults
|
|
57
|
+
* `wrangler-baseline-fail-on-violation: false` (the tier reports but never
|
|
58
|
+
* blocks) until the fleet is clean; flip the caller default to `true` once
|
|
59
|
+
* every consumer is green.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
63
|
+
import { resolve } from 'node:path';
|
|
64
|
+
import { parseArgs } from 'node:util';
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// CLI argument parsing
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {string[]} argv
|
|
72
|
+
* @returns {{ file: string | null, maxAgeDays: number, warnOnly: boolean, json: boolean, help: boolean }}
|
|
73
|
+
*/
|
|
74
|
+
export function parseArgv(argv = []) {
|
|
75
|
+
const { values } = parseArgs({
|
|
76
|
+
args: argv,
|
|
77
|
+
options: {
|
|
78
|
+
file: { type: 'string' },
|
|
79
|
+
'max-age-days': { type: 'string', default: '90' },
|
|
80
|
+
'warn-only': { type: 'boolean', default: false },
|
|
81
|
+
json: { type: 'boolean', default: false },
|
|
82
|
+
help: { type: 'boolean', default: false },
|
|
83
|
+
},
|
|
84
|
+
strict: false,
|
|
85
|
+
});
|
|
86
|
+
const maxAgeDays = Number.parseInt(values['max-age-days'], 10);
|
|
87
|
+
return {
|
|
88
|
+
file: values.file ?? null,
|
|
89
|
+
maxAgeDays: Number.isFinite(maxAgeDays) && maxAgeDays > 0 ? maxAgeDays : 90,
|
|
90
|
+
warnOnly: values['warn-only'] === true,
|
|
91
|
+
json: values.json === true,
|
|
92
|
+
help: values.help === true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const HELP_TEXT = `
|
|
97
|
+
check-wrangler-baseline.mjs — wrangler config baseline gate for mandrel-platform consumers
|
|
98
|
+
|
|
99
|
+
Usage:
|
|
100
|
+
node scripts/check-wrangler-baseline.mjs [options]
|
|
101
|
+
|
|
102
|
+
Options:
|
|
103
|
+
--file <path> Path to the wrangler config (default: auto-detect
|
|
104
|
+
wrangler.jsonc then wrangler.toml at repo root)
|
|
105
|
+
--max-age-days <n> Staleness window for compatibility_date (default: 90)
|
|
106
|
+
--warn-only Exit 0 even when violations are found
|
|
107
|
+
--json Emit a machine-readable envelope
|
|
108
|
+
--help Print this help and exit
|
|
109
|
+
|
|
110
|
+
Rules:
|
|
111
|
+
env-split At least one named [env.*] / "env": { ... } block exists.
|
|
112
|
+
logpush Top-level (or per-audited-env) logpush = true.
|
|
113
|
+
analytics-engine At least one Analytics Engine binding
|
|
114
|
+
([[analytics_engine_datasets]] / "analytics_engine_datasets").
|
|
115
|
+
compat-date-stale compatibility_date is within --max-age-days of today.
|
|
116
|
+
|
|
117
|
+
Exceptions: declare a rule opt-out in a top-level "mandrel.wranglerBaselineExceptions"
|
|
118
|
+
block (see docs/reusable-workflows.md) rather than silently failing to match.
|
|
119
|
+
`;
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Config discovery
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
const DEFAULT_CANDIDATES = ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml'];
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Locate the wrangler config file to check. Returns null when none exists
|
|
129
|
+
* (a consumer with no wrangler config at all is a legitimate no-op, not
|
|
130
|
+
* every mandrel-platform consumer is a Cloudflare Worker).
|
|
131
|
+
*
|
|
132
|
+
* @param {string | null} explicitFile
|
|
133
|
+
* @param {string} cwd
|
|
134
|
+
* @returns {string | null} absolute path, or null.
|
|
135
|
+
*/
|
|
136
|
+
export function resolveConfigPath(explicitFile, cwd = process.cwd()) {
|
|
137
|
+
if (explicitFile) {
|
|
138
|
+
const abs = resolve(cwd, explicitFile);
|
|
139
|
+
return existsSync(abs) ? abs : null;
|
|
140
|
+
}
|
|
141
|
+
for (const candidate of DEFAULT_CANDIDATES) {
|
|
142
|
+
const abs = resolve(cwd, candidate);
|
|
143
|
+
if (existsSync(abs)) return abs;
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Parsers — no TOML/JSONC dependency in this package, so both parsers are
|
|
150
|
+
// hand-rolled and scoped to exactly what this gate needs (flat/nested key
|
|
151
|
+
// lookups and named-table detection), matching the existing repo convention
|
|
152
|
+
// (see platform-sync.mjs's parseJsonc for the same tolerant-JSON approach).
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Parse JSON tolerating `//` line comments and block comments (jsonc).
|
|
157
|
+
* @param {string} text
|
|
158
|
+
* @returns {any}
|
|
159
|
+
*/
|
|
160
|
+
export function parseJsonc(text) {
|
|
161
|
+
const stripped = text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
162
|
+
return JSON.parse(stripped);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Minimal TOML parser scoped to wrangler.toml's shape: `key = value` pairs,
|
|
167
|
+
* `[section]` / `[section.sub]` tables, and `[[array.of.tables]]`. Produces a
|
|
168
|
+
* plain object mirroring wrangler's JSON config shape closely enough for this
|
|
169
|
+
* gate's rules (env.* detection, logpush booleans, analytics_engine_datasets
|
|
170
|
+
* array-of-tables, compatibility_date string). Does NOT aim to be a general
|
|
171
|
+
* TOML parser — inline arrays/tables and multi-line strings are out of scope
|
|
172
|
+
* (wrangler.toml in the wild does not use them for these fields).
|
|
173
|
+
*
|
|
174
|
+
* @param {string} text
|
|
175
|
+
* @returns {Record<string, any>}
|
|
176
|
+
*/
|
|
177
|
+
export function parseWranglerToml(text) {
|
|
178
|
+
const root = {};
|
|
179
|
+
let current = root;
|
|
180
|
+
let currentArrayTableKey = null;
|
|
181
|
+
|
|
182
|
+
const getOrCreatePath = (obj, path) => {
|
|
183
|
+
let node = obj;
|
|
184
|
+
for (const key of path) {
|
|
185
|
+
if (typeof node[key] !== 'object' || node[key] === null || Array.isArray(node[key])) {
|
|
186
|
+
node[key] = node[key] && typeof node[key] === 'object' ? node[key] : {};
|
|
187
|
+
}
|
|
188
|
+
node = node[key];
|
|
189
|
+
}
|
|
190
|
+
return node;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const coerceValue = (raw) => {
|
|
194
|
+
const v = raw.trim();
|
|
195
|
+
if (v === 'true') return true;
|
|
196
|
+
if (v === 'false') return false;
|
|
197
|
+
if (/^-?\d+$/.test(v)) return Number.parseInt(v, 10);
|
|
198
|
+
if (/^-?\d+\.\d+$/.test(v)) return Number.parseFloat(v);
|
|
199
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
200
|
+
return v.slice(1, -1);
|
|
201
|
+
}
|
|
202
|
+
return v;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const lines = text.split(/\r?\n/);
|
|
206
|
+
for (const rawLine of lines) {
|
|
207
|
+
// Strip trailing comments (naive: a `#` outside quotes). Good enough for
|
|
208
|
+
// wrangler.toml's simple key/value + table-header shape.
|
|
209
|
+
let line = rawLine;
|
|
210
|
+
const commentIdx = line.indexOf('#');
|
|
211
|
+
if (commentIdx !== -1) {
|
|
212
|
+
const before = line.slice(0, commentIdx);
|
|
213
|
+
// Only strip when the `#` isn't inside a quoted string.
|
|
214
|
+
const quoteCount = (before.match(/"/g) || []).length + (before.match(/'/g) || []).length;
|
|
215
|
+
if (quoteCount % 2 === 0) line = before;
|
|
216
|
+
}
|
|
217
|
+
const trimmed = line.trim();
|
|
218
|
+
if (!trimmed) continue;
|
|
219
|
+
|
|
220
|
+
const arrayTableMatch = /^\[\[([^\]]+)\]\]$/.exec(trimmed);
|
|
221
|
+
if (arrayTableMatch) {
|
|
222
|
+
const path = arrayTableMatch[1].split('.').map((p) => p.trim());
|
|
223
|
+
const parentPath = path.slice(0, -1);
|
|
224
|
+
const leafKey = path[path.length - 1];
|
|
225
|
+
const parent = getOrCreatePath(root, parentPath);
|
|
226
|
+
if (!Array.isArray(parent[leafKey])) parent[leafKey] = [];
|
|
227
|
+
const entry = {};
|
|
228
|
+
parent[leafKey].push(entry);
|
|
229
|
+
current = entry;
|
|
230
|
+
currentArrayTableKey = arrayTableMatch[1];
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const tableMatch = /^\[([^\]]+)\]$/.exec(trimmed);
|
|
235
|
+
if (tableMatch) {
|
|
236
|
+
const path = tableMatch[1].split('.').map((p) => p.trim());
|
|
237
|
+
current = getOrCreatePath(root, path);
|
|
238
|
+
currentArrayTableKey = null;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const kvMatch = /^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/.exec(trimmed);
|
|
243
|
+
if (kvMatch) {
|
|
244
|
+
const [, key, rawValue] = kvMatch;
|
|
245
|
+
current[key] = coerceValue(rawValue);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
void currentArrayTableKey; // retained for readability of the state machine
|
|
249
|
+
return root;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Parse a wrangler config file by extension.
|
|
254
|
+
* @param {string} filePath
|
|
255
|
+
* @param {string} text
|
|
256
|
+
* @returns {Record<string, any>}
|
|
257
|
+
*/
|
|
258
|
+
export function parseWranglerConfig(filePath, text) {
|
|
259
|
+
return filePath.endsWith('.toml') ? parseWranglerToml(text) : parseJsonc(text);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Exceptions
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Read declared per-consumer exceptions from `mandrel.wranglerBaselineExceptions`.
|
|
268
|
+
* @param {Record<string, any>} config
|
|
269
|
+
* @returns {Record<string, string>} ruleId -> reason.
|
|
270
|
+
*/
|
|
271
|
+
export function readExceptions(config) {
|
|
272
|
+
const exceptions = config?.mandrel?.wranglerBaselineExceptions;
|
|
273
|
+
if (!exceptions || typeof exceptions !== 'object') return {};
|
|
274
|
+
/** @type {Record<string, string>} */
|
|
275
|
+
const out = {};
|
|
276
|
+
for (const [k, v] of Object.entries(exceptions)) {
|
|
277
|
+
if (typeof v === 'string' && v.trim().length > 0) out[k] = v;
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Rules — each returns { id, pass, message } (pass ignores exceptions; the
|
|
284
|
+
// caller reconciles pass/exception into the final verdict).
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Does the config declare at least one named Environment (`[env.<name>]` in
|
|
289
|
+
* TOML, or `"env": { "<name>": {...} }` in JSON)?
|
|
290
|
+
* @param {Record<string, any>} config
|
|
291
|
+
* @returns {{ id: string, pass: boolean, message: string }}
|
|
292
|
+
*/
|
|
293
|
+
export function checkEnvSplit(config) {
|
|
294
|
+
const env = config.env;
|
|
295
|
+
const names = env && typeof env === 'object' ? Object.keys(env) : [];
|
|
296
|
+
const pass = names.length > 0;
|
|
297
|
+
return {
|
|
298
|
+
id: 'env-split',
|
|
299
|
+
pass,
|
|
300
|
+
message: pass
|
|
301
|
+
? `named Environment split present: ${names.join(', ')}`
|
|
302
|
+
: 'no [env.*] / "env" named-Environment split found',
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Is `logpush` enabled, either at top level or on every named environment
|
|
308
|
+
* present in the config? A Worker with named environments and a top-level
|
|
309
|
+
* `logpush = true` inherits it per wrangler's env-inheritance rules, so a
|
|
310
|
+
* top-level `true` satisfies the rule regardless of per-env overrides that
|
|
311
|
+
* don't explicitly disable it.
|
|
312
|
+
* @param {Record<string, any>} config
|
|
313
|
+
* @returns {{ id: string, pass: boolean, message: string }}
|
|
314
|
+
*/
|
|
315
|
+
export function checkLogpush(config) {
|
|
316
|
+
if (config.logpush === true) {
|
|
317
|
+
return { id: 'logpush', pass: true, message: 'logpush = true at top level' };
|
|
318
|
+
}
|
|
319
|
+
const env = config.env && typeof config.env === 'object' ? config.env : {};
|
|
320
|
+
const envNames = Object.keys(env);
|
|
321
|
+
if (envNames.length > 0 && envNames.every((name) => env[name]?.logpush === true)) {
|
|
322
|
+
return {
|
|
323
|
+
id: 'logpush',
|
|
324
|
+
pass: true,
|
|
325
|
+
message: `logpush = true on every named environment (${envNames.join(', ')})`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
id: 'logpush',
|
|
330
|
+
pass: false,
|
|
331
|
+
message: 'logpush is not enabled at top level or on every named environment',
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Is at least one Analytics Engine binding declared, at top level or on any
|
|
337
|
+
* named environment?
|
|
338
|
+
* @param {Record<string, any>} config
|
|
339
|
+
* @returns {{ id: string, pass: boolean, message: string }}
|
|
340
|
+
*/
|
|
341
|
+
export function checkAnalyticsEngine(config) {
|
|
342
|
+
const hasBinding = (obj) => Array.isArray(obj?.analytics_engine_datasets) && obj.analytics_engine_datasets.length > 0;
|
|
343
|
+
if (hasBinding(config)) {
|
|
344
|
+
return { id: 'analytics-engine', pass: true, message: 'analytics_engine_datasets binding present at top level' };
|
|
345
|
+
}
|
|
346
|
+
const env = config.env && typeof config.env === 'object' ? config.env : {};
|
|
347
|
+
const envWithBinding = Object.keys(env).find((name) => hasBinding(env[name]));
|
|
348
|
+
if (envWithBinding) {
|
|
349
|
+
return {
|
|
350
|
+
id: 'analytics-engine',
|
|
351
|
+
pass: true,
|
|
352
|
+
message: `analytics_engine_datasets binding present on env.${envWithBinding}`,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
id: 'analytics-engine',
|
|
357
|
+
pass: false,
|
|
358
|
+
message: 'no analytics_engine_datasets binding found (top level or any named environment)',
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Is `compatibility_date` present and within the staleness policy window?
|
|
364
|
+
* @param {Record<string, any>} config
|
|
365
|
+
* @param {number} maxAgeDays
|
|
366
|
+
* @param {Date} [now]
|
|
367
|
+
* @returns {{ id: string, pass: boolean, message: string }}
|
|
368
|
+
*/
|
|
369
|
+
export function checkCompatibilityDate(config, maxAgeDays, now = new Date()) {
|
|
370
|
+
const raw = config.compatibility_date;
|
|
371
|
+
if (typeof raw !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
|
372
|
+
return { id: 'compat-date-stale', pass: false, message: 'compatibility_date is missing or not in YYYY-MM-DD form' };
|
|
373
|
+
}
|
|
374
|
+
const parsed = new Date(`${raw}T00:00:00Z`);
|
|
375
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
376
|
+
return { id: 'compat-date-stale', pass: false, message: `compatibility_date "${raw}" is not a valid calendar date` };
|
|
377
|
+
}
|
|
378
|
+
const ageMs = now.getTime() - parsed.getTime();
|
|
379
|
+
const ageDays = Math.floor(ageMs / (24 * 60 * 60 * 1000));
|
|
380
|
+
const pass = ageDays <= maxAgeDays;
|
|
381
|
+
return {
|
|
382
|
+
id: 'compat-date-stale',
|
|
383
|
+
pass,
|
|
384
|
+
message: pass
|
|
385
|
+
? `compatibility_date ${raw} is ${ageDays} day(s) old (within the ${maxAgeDays}-day policy window)`
|
|
386
|
+
: `compatibility_date ${raw} is ${ageDays} day(s) old, exceeding the ${maxAgeDays}-day policy window`,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const ALL_RULES = [checkEnvSplit, checkLogpush, checkAnalyticsEngine, checkCompatibilityDate];
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Run every rule against the config and reconcile with declared exceptions.
|
|
394
|
+
* @param {Record<string, any>} config
|
|
395
|
+
* @param {number} maxAgeDays
|
|
396
|
+
* @param {Date} [now]
|
|
397
|
+
* @returns {{
|
|
398
|
+
* findings: Array<{ id: string, pass: boolean, message: string, excepted: boolean, exceptionReason: string | null }>,
|
|
399
|
+
* violations: Array<{ id: string, message: string }>,
|
|
400
|
+
* exceptions: Array<{ id: string, reason: string }>,
|
|
401
|
+
* }}
|
|
402
|
+
*/
|
|
403
|
+
export function evaluateBaseline(config, maxAgeDays, now = new Date()) {
|
|
404
|
+
const exceptions = readExceptions(config);
|
|
405
|
+
const findings = ALL_RULES.map((rule) => {
|
|
406
|
+
const result = rule === checkCompatibilityDate ? rule(config, maxAgeDays, now) : rule(config);
|
|
407
|
+
const exceptionReason = exceptions[result.id] ?? null;
|
|
408
|
+
return { ...result, excepted: !result.pass && exceptionReason !== null, exceptionReason };
|
|
409
|
+
});
|
|
410
|
+
const violations = findings.filter((f) => !f.pass && !f.excepted).map((f) => ({ id: f.id, message: f.message }));
|
|
411
|
+
const declaredExceptions = findings
|
|
412
|
+
.filter((f) => f.excepted)
|
|
413
|
+
.map((f) => ({ id: f.id, reason: /** @type {string} */ (f.exceptionReason) }));
|
|
414
|
+
return { findings, violations, exceptions: declaredExceptions };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
// Rendering
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* @param {ReturnType<typeof evaluateBaseline>} report
|
|
423
|
+
* @param {string} configLabel
|
|
424
|
+
* @returns {string}
|
|
425
|
+
*/
|
|
426
|
+
export function renderReport(report, configLabel) {
|
|
427
|
+
const lines = [`[wrangler-baseline] Checked ${configLabel}`];
|
|
428
|
+
for (const f of report.findings) {
|
|
429
|
+
if (f.pass) {
|
|
430
|
+
lines.push(` ✅ ${f.id}: ${f.message}`);
|
|
431
|
+
} else if (f.excepted) {
|
|
432
|
+
lines.push(` ⚠️ ${f.id}: EXCEPTED — ${f.exceptionReason} (would otherwise fail: ${f.message})`);
|
|
433
|
+
} else {
|
|
434
|
+
lines.push(` ❌ ${f.id}: ${f.message}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
lines.push('');
|
|
438
|
+
if (report.violations.length === 0) {
|
|
439
|
+
lines.push('[wrangler-baseline] ✅ All baseline rules satisfied (or explicitly excepted).');
|
|
440
|
+
} else {
|
|
441
|
+
lines.push(
|
|
442
|
+
`[wrangler-baseline] ❌ ${report.violations.length} violation(s). Declare a legitimate opt-out via ` +
|
|
443
|
+
'"mandrel.wranglerBaselineExceptions" (see docs/reusable-workflows.md) rather than leaving it unmet silently.',
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
return lines.join('\n');
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
// CLI entry
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* @param {{
|
|
455
|
+
* argv?: string[],
|
|
456
|
+
* cwd?: string,
|
|
457
|
+
* stdout?: { write: (s: string) => void },
|
|
458
|
+
* stderr?: { write: (s: string) => void },
|
|
459
|
+
* now?: Date,
|
|
460
|
+
* }} [opts]
|
|
461
|
+
* @returns {number} exit code
|
|
462
|
+
*/
|
|
463
|
+
export function runCli({
|
|
464
|
+
argv = process.argv.slice(2),
|
|
465
|
+
cwd = process.cwd(),
|
|
466
|
+
stdout = process.stdout,
|
|
467
|
+
stderr = process.stderr,
|
|
468
|
+
now = new Date(),
|
|
469
|
+
} = {}) {
|
|
470
|
+
const { file, maxAgeDays, warnOnly, json, help } = parseArgv(argv);
|
|
471
|
+
|
|
472
|
+
if (help) {
|
|
473
|
+
stdout.write(HELP_TEXT);
|
|
474
|
+
return 0;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const configPath = resolveConfigPath(file, cwd);
|
|
478
|
+
if (!configPath) {
|
|
479
|
+
if (json) {
|
|
480
|
+
stdout.write(`${JSON.stringify({ kind: 'wrangler-baseline-report', found: false, violations: [] })}\n`);
|
|
481
|
+
} else {
|
|
482
|
+
stdout.write('[wrangler-baseline] No wrangler.toml / wrangler.jsonc found — nothing to check.\n');
|
|
483
|
+
}
|
|
484
|
+
return 0;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
let config;
|
|
488
|
+
try {
|
|
489
|
+
const text = readFileSync(configPath, 'utf-8');
|
|
490
|
+
config = parseWranglerConfig(configPath, text);
|
|
491
|
+
} catch (err) {
|
|
492
|
+
stderr.write(`[wrangler-baseline] ❌ failed to parse ${configPath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
493
|
+
return 1;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const report = evaluateBaseline(config, maxAgeDays, now);
|
|
497
|
+
|
|
498
|
+
if (json) {
|
|
499
|
+
stdout.write(`${JSON.stringify({ kind: 'wrangler-baseline-report', found: true, file: configPath, ...report }, null, 2)}\n`);
|
|
500
|
+
} else {
|
|
501
|
+
stdout.write(`${renderReport(report, configPath)}\n`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (report.violations.length > 0 && !warnOnly) {
|
|
505
|
+
return 1;
|
|
506
|
+
}
|
|
507
|
+
return 0;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Direct-invocation guard (matches the repo's other scripts/*.mjs entry style).
|
|
511
|
+
const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
|
512
|
+
if (invokedDirectly) {
|
|
513
|
+
process.exit(runCli());
|
|
514
|
+
}
|