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.
@@ -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
+ }