mandrel-platform 1.4.2 → 1.5.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.
@@ -39,6 +39,20 @@
39
39
  * job". Requires git history (run CI checkout with fetch-depth: 0); the
40
40
  * check degrades to a skipped NOTE when the pinned blob is unreachable.
41
41
  *
42
+ * 4. `runs-on: ${{ inputs.runner }}` — consuming a `runner` input RAW is
43
+ * PROHIBITED. GitHub does not parse a JSON-array *string* in that
44
+ * position: it takes the entire text as ONE label name, so the
45
+ * documented `'["self-hosted","my-runner"]'` form matches no runner and
46
+ * every job sits `queued` until the 24-hour timeout. This one is worse
47
+ * than the others because it is not even loud — no job starts, so there
48
+ * are no logs, and `gh pr checks` reports `pending 0`, indistinguishable
49
+ * from a busy fleet. Normalize to
50
+ * `fromJSON(startsWith(inputs.runner, '[') && inputs.runner ||
51
+ * format('"{0}"', inputs.runner))`, which accepts both documented
52
+ * shapes. (Caused #419 / v1.5.0; behaviour is covered by
53
+ * check-runner-runs-on.test.mjs, which evaluates the real expression
54
+ * rather than matching its spelling.)
55
+ *
42
56
  * What this lint deliberately does NOT flag: `${{ }}` in `runs.steps[].with`
43
57
  * (e.g. `dest: ${{ inputs['pnpm-dest'] || format('{0}/pnpm', runner.temp) }}`)
44
58
  * is a VALID runtime expression. The lint only inspects `description` and
@@ -266,6 +280,31 @@ export function checkWorkflowContent(content) {
266
280
  }
267
281
  }
268
282
 
283
+
284
+ // Rule 4: no `runs-on` that consumes a `runner` input raw. GitHub does not
285
+ // parse a JSON-array STRING in that position — it takes the whole text as one
286
+ // label name — so the documented `'["self-hosted","my-runner"]'` form matches
287
+ // no runner and every job sits `queued` until the 24-hour timeout. The failure
288
+ // is silent (no job starts, so no logs, and `gh pr checks` shows `pending 0`),
289
+ // which is why it needs a static tripwire as well as the behavioural guard in
290
+ // check-runner-runs-on.test.mjs.
291
+ content.split("\n").forEach((raw, idx) => {
292
+ const trimmed = raw.trim();
293
+ if (!trimmed.startsWith("runs-on:")) return;
294
+ const m = trimmed.match(/^runs-on:\s*\$\{\{(.+)\}\}\s*$/);
295
+ if (!m) return;
296
+ if (m[1].trim() !== "inputs.runner") return;
297
+ violations.push({
298
+ line: idx + 1,
299
+ message:
300
+ `\`runs-on: \${{ inputs.runner }}\` consumes the input raw — a ` +
301
+ `JSON-encoded label-array string resolves to ONE unmatchable label ` +
302
+ `name and the job queues until the 24-hour timeout, silently. ` +
303
+ `Normalize it: \`fromJSON(startsWith(inputs.runner, '[') && ` +
304
+ `inputs.runner || format('"{0}"', inputs.runner))\`.`,
305
+ });
306
+ });
307
+
269
308
  return violations;
270
309
  }
271
310
 
@@ -197,3 +197,69 @@ test("parseArgs: --no-pin-check, dir overrides, and --help are parsed", () => {
197
197
  help: true,
198
198
  });
199
199
  });
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Rule 4 — `runs-on` must not consume a `runner` input raw (Story #421)
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /** A minimal reusable workflow whose single job's `runs-on` is `value`. */
206
+ function runnerWorkflow(value) {
207
+ return [
208
+ "on:",
209
+ " workflow_call:",
210
+ " inputs:",
211
+ " runner:",
212
+ " required: false",
213
+ " type: string",
214
+ " default: 'ubuntu-latest'",
215
+ "jobs:",
216
+ " build:",
217
+ ` runs-on: ${value}`,
218
+ " steps:",
219
+ " - run: echo hi",
220
+ "",
221
+ ].join("\n");
222
+ }
223
+
224
+ const NORMALIZED =
225
+ "${{ fromJSON(startsWith(inputs.runner, '[') && inputs.runner " +
226
+ "|| format('\"{0}\"', inputs.runner)) }}";
227
+
228
+ test("Rule 4: a raw `runs-on: ${{ inputs.runner }}` is a violation", () => {
229
+ const violations = checkWorkflowContent(runnerWorkflow("${{ inputs.runner }}"));
230
+ assert.equal(violations.length, 1);
231
+ assert.equal(violations[0].line, 10);
232
+ assert.match(violations[0].message, /consumes the input raw/);
233
+ // The message must name the failure mode, not just the rule — a silent
234
+ // 24-hour queue is not something a reader infers from "normalize this".
235
+ assert.match(violations[0].message, /queues until the 24-hour timeout/);
236
+ });
237
+
238
+ test("Rule 4: the normalized form is clean", () => {
239
+ assert.deepEqual(checkWorkflowContent(runnerWorkflow(NORMALIZED)), []);
240
+ });
241
+
242
+ test("Rule 4: a hardcoded `runs-on` is untouched", () => {
243
+ assert.deepEqual(checkWorkflowContent(runnerWorkflow("ubuntu-latest")), []);
244
+ });
245
+
246
+ test("Rule 4: an unrelated expression in `runs-on` is untouched", () => {
247
+ // Only the exact raw-input read is flagged; a caller doing its own
248
+ // normalization or reading a different context is none of this rule's business.
249
+ assert.deepEqual(checkWorkflowContent(runnerWorkflow("${{ fromJSON(vars.CI_RUNNER) }}")), []);
250
+ assert.deepEqual(checkWorkflowContent(runnerWorkflow("${{ inputs.runner-label }}")), []);
251
+ });
252
+
253
+ test("Rule 4: only reusable workflows are checked", () => {
254
+ // A `push`-triggered workflow has no workflow_call interface to break.
255
+ const plain = [
256
+ "on:",
257
+ " push:",
258
+ " branches: [main]",
259
+ "jobs:",
260
+ " build:",
261
+ " runs-on: ${{ inputs.runner }}",
262
+ "",
263
+ ].join("\n");
264
+ assert.deepEqual(checkWorkflowContent(plain), []);
265
+ });
@@ -63,6 +63,8 @@ import { readFileSync, existsSync } from 'node:fs';
63
63
  import { resolve } from 'node:path';
64
64
  import { parseArgs } from 'node:util';
65
65
 
66
+ import { isDirectInvocation } from './lib/entry-guard.mjs';
67
+
66
68
  // ---------------------------------------------------------------------------
67
69
  // CLI argument parsing
68
70
  // ---------------------------------------------------------------------------
@@ -153,13 +155,114 @@ export function resolveConfigPath(explicitFile, cwd = process.cwd()) {
153
155
  // ---------------------------------------------------------------------------
154
156
 
155
157
  /**
156
- * Parse JSON tolerating `//` line comments and block comments (jsonc).
158
+ * Skip past a JSON string literal, honouring backslash escapes.
159
+ *
160
+ * @param {string} text Full document.
161
+ * @param {number} start Index of the opening quote.
162
+ * @returns {number} Index just past the closing quote (or `text.length`).
163
+ */
164
+ function skipString(text, start) {
165
+ let i = start + 1;
166
+ while (i < text.length) {
167
+ if (text[i] === '\\') {
168
+ i += 2;
169
+ continue;
170
+ }
171
+ if (text[i] === '"') return i + 1;
172
+ i += 1;
173
+ }
174
+ return i;
175
+ }
176
+
177
+ /**
178
+ * Blank out `//` line comments and block comments, leaving every other byte —
179
+ * string-literal contents included — untouched.
180
+ *
181
+ * Character-scanned rather than regex-replaced. A regex stripping `//` to
182
+ * end-of-line cannot tell a comment from the `//` inside a string: the
183
+ * previous `(^|[^:])//` guard special-cased the scheme colon in
184
+ * `"https://…"`, but `"a//b"` still lost its tail. Comment bytes are replaced
185
+ * with spaces (newlines preserved) rather than deleted, so every offset is
186
+ * unchanged and a `JSON.parse` error still reports the position it actually
187
+ * occupies in the file the operator is looking at.
188
+ *
189
+ * @param {string} text
190
+ * @returns {string} Same length as `text`, comments blanked.
191
+ */
192
+ function blankComments(text) {
193
+ const out = text.split('');
194
+ let i = 0;
195
+ while (i < text.length) {
196
+ if (text[i] === '"') {
197
+ i = skipString(text, i);
198
+ continue;
199
+ }
200
+ if (text[i] === '/' && text[i + 1] === '/') {
201
+ while (i < text.length && text[i] !== '\n') {
202
+ out[i] = ' ';
203
+ i += 1;
204
+ }
205
+ continue;
206
+ }
207
+ if (text[i] === '/' && text[i + 1] === '*') {
208
+ out[i] = ' ';
209
+ out[i + 1] = ' ';
210
+ i += 2;
211
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) {
212
+ if (text[i] !== '\n') out[i] = ' ';
213
+ i += 1;
214
+ }
215
+ if (i < text.length) {
216
+ out[i] = ' ';
217
+ out[i + 1] = ' ';
218
+ i += 2;
219
+ }
220
+ continue;
221
+ }
222
+ i += 1;
223
+ }
224
+ return out.join('');
225
+ }
226
+
227
+ /**
228
+ * Blank out trailing commas — a `,` whose next significant character closes
229
+ * its object or array. Legal JSONC, rejected by `JSON.parse`.
230
+ *
231
+ * Runs AFTER {@link blankComments} so a comment sitting between the comma and
232
+ * its closing brace (`[1, /* note *\/ ]`) cannot hide the trailing comma. Like
233
+ * that pass it substitutes spaces rather than deleting, preserving offsets.
234
+ *
235
+ * @param {string} text Comment-blanked document.
236
+ * @returns {string} Same length as `text`, trailing commas blanked.
237
+ */
238
+ function blankTrailingCommas(text) {
239
+ const out = text.split('');
240
+ let i = 0;
241
+ while (i < text.length) {
242
+ if (text[i] === '"') {
243
+ i = skipString(text, i);
244
+ continue;
245
+ }
246
+ if (text[i] === ',') {
247
+ let j = i + 1;
248
+ while (j < text.length && /\s/.test(text[j])) j += 1;
249
+ if (text[j] === '}' || text[j] === ']') out[i] = ' ';
250
+ }
251
+ i += 1;
252
+ }
253
+ return out.join('');
254
+ }
255
+
256
+ /**
257
+ * Parse JSON tolerating what the `.jsonc` extension names: `//` line comments,
258
+ * block comments, and trailing commas in objects and arrays. String-literal
259
+ * contents pass through byte-identical.
260
+ *
157
261
  * @param {string} text
158
262
  * @returns {any}
159
263
  */
160
264
  export function parseJsonc(text) {
161
- const stripped = text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
162
- return JSON.parse(stripped);
265
+ return JSON.parse(blankTrailingCommas(blankComments(text)));
163
266
  }
164
267
 
165
268
  /**
@@ -489,8 +592,21 @@ export function runCli({
489
592
  const text = readFileSync(configPath, 'utf-8');
490
593
  config = parseWranglerConfig(configPath, text);
491
594
  } catch (err) {
492
- stderr.write(`[wrangler-baseline] failed to parse ${configPath}: ${err instanceof Error ? err.message : String(err)}\n`);
493
- return 1;
595
+ // Advisory mode is a promise about EXIT CODES, not about which findings
596
+ // are reachable: under --warn-only nothing in the consumer's config may
597
+ // turn the gate red. A parse failure used to return 1 here, ahead of the
598
+ // warnOnly branch below, so an advisory-mode consumer with a legal
599
+ // trailing-comma .jsonc got a hard red on a gate that had opted out of
600
+ // blocking (Story #407). Route it through the same policy as violations.
601
+ const detail = err instanceof Error ? err.message : String(err);
602
+ const marker = warnOnly ? '⚠️ ' : '❌';
603
+ stderr.write(`[wrangler-baseline] ${marker} failed to parse ${configPath}: ${detail}\n`);
604
+ if (json) {
605
+ stdout.write(
606
+ `${JSON.stringify({ kind: 'wrangler-baseline-report', found: true, file: configPath, parseError: detail, violations: [] })}\n`,
607
+ );
608
+ }
609
+ return warnOnly ? 0 : 1;
494
610
  }
495
611
 
496
612
  const report = evaluateBaseline(config, maxAgeDays, now);
@@ -507,8 +623,10 @@ export function runCli({
507
623
  return 0;
508
624
  }
509
625
 
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) {
626
+ // Direct-invocation guard symlink-safe via the shared seam. The previous
627
+ // spelling compared a realpath-resolved `import.meta.url` against an
628
+ // unresolved `process.argv[1]`, which never matched under pnpm's symlinked
629
+ // node_modules: the CLI silently never ran (Story #407).
630
+ if (isDirectInvocation(import.meta.url)) {
513
631
  process.exit(runCli());
514
632
  }
@@ -11,9 +11,17 @@
11
11
  */
12
12
 
13
13
  import assert from 'node:assert/strict';
14
- import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
14
+ import {
15
+ mkdtempSync,
16
+ mkdirSync,
17
+ writeFileSync,
18
+ symlinkSync,
19
+ rmSync,
20
+ } from 'node:fs';
21
+ import { spawnSync } from 'node:child_process';
15
22
  import { tmpdir } from 'node:os';
16
23
  import { join } from 'node:path';
24
+ import { fileURLToPath, pathToFileURL } from 'node:url';
17
25
  import { afterEach, beforeEach, test } from 'node:test';
18
26
  import {
19
27
  parseArgv,
@@ -452,3 +460,252 @@ test('runCli --help prints usage and exits 0 without touching the filesystem', (
452
460
  assert.equal(exit, 0);
453
461
  assert.match(streams.out, /check-wrangler-baseline\.mjs/);
454
462
  });
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // JSONC tolerance — trailing commas, and string contents left alone (#407)
466
+ // ---------------------------------------------------------------------------
467
+
468
+ test('parseJsonc tolerates trailing commas in objects and arrays', () => {
469
+ const text = `{
470
+ "name": "web",
471
+ "analytics_engine_datasets": [
472
+ { "binding": "AE", "dataset": "events", },
473
+ ],
474
+ }`;
475
+ assert.deepEqual(parseJsonc(text), {
476
+ name: 'web',
477
+ analytics_engine_datasets: [{ binding: 'AE', dataset: 'events' }],
478
+ });
479
+ });
480
+
481
+ test('parseJsonc tolerates a trailing comma separated by a comment', () => {
482
+ const text = '{ "a": [1, 2, /* done */ ], }';
483
+ assert.deepEqual(parseJsonc(text), { a: [1, 2] });
484
+ });
485
+
486
+ test('parseJsonc leaves string contents byte-identical', () => {
487
+ // Each of these would be corrupted by a regex-based comment/comma stripper:
488
+ // `,}` and `,]` look like trailing commas, and `//` looks like a comment
489
+ // even when it is not preceded by a scheme colon.
490
+ const text = JSON.stringify({
491
+ braces: 'a,} b,] c',
492
+ slashes: 'a//b',
493
+ url: 'https://example.com/x',
494
+ block: 'a/*b*/c',
495
+ });
496
+ assert.deepEqual(parseJsonc(text), {
497
+ braces: 'a,} b,] c',
498
+ slashes: 'a//b',
499
+ url: 'https://example.com/x',
500
+ block: 'a/*b*/c',
501
+ });
502
+ });
503
+
504
+ test('parseJsonc still strips real comments outside strings', () => {
505
+ const text = `{
506
+ // line comment
507
+ "logpush": true, /* block */
508
+ "name": "x"
509
+ }`;
510
+ assert.deepEqual(parseJsonc(text), { logpush: true, name: 'x' });
511
+ });
512
+
513
+ test('parseJsonc reports parse-error positions against the original offsets', () => {
514
+ // Comments and trailing commas are blanked in place, never deleted, so a
515
+ // position in the error message still points at the operator's file.
516
+ const text = '{\n // a comment\n "a": 1\n "b": 2\n}';
517
+ assert.throws(
518
+ () => parseJsonc(text),
519
+ (err) => /position (2[0-9]|3[0-9])/.test(err.message),
520
+ );
521
+ });
522
+
523
+ // ---------------------------------------------------------------------------
524
+ // Advisory mode is a promise about exit codes, not about findings (#407)
525
+ // ---------------------------------------------------------------------------
526
+
527
+ test('runCli exits 0 on an unparseable config with --warn-only', () => {
528
+ writeFileSync(join(tmpDir, 'wrangler.jsonc'), '{ not valid json');
529
+ const streams = noopStreams();
530
+ const exit = runCli({
531
+ argv: ['--warn-only'],
532
+ cwd: tmpDir,
533
+ stdout: streams.stdout,
534
+ stderr: streams.stderr,
535
+ });
536
+ assert.equal(exit, 0, 'advisory mode must never hard-fail on config content');
537
+ assert.match(streams.err, /failed to parse/);
538
+ });
539
+
540
+ test('runCli --json emits a parseError envelope instead of dying silently', () => {
541
+ writeFileSync(join(tmpDir, 'wrangler.jsonc'), '{ not valid json');
542
+ const streams = noopStreams();
543
+ const exit = runCli({
544
+ argv: ['--json', '--warn-only'],
545
+ cwd: tmpDir,
546
+ stdout: streams.stdout,
547
+ stderr: streams.stderr,
548
+ });
549
+ assert.equal(exit, 0);
550
+ const envelope = JSON.parse(streams.out);
551
+ assert.equal(envelope.kind, 'wrangler-baseline-report');
552
+ assert.equal(envelope.found, true);
553
+ assert.ok(envelope.parseError, 'the envelope names why the config was unusable');
554
+ });
555
+
556
+ test('runCli reads a trailing-comma wrangler.jsonc end to end', () => {
557
+ writeFileSync(
558
+ join(tmpDir, 'wrangler.jsonc'),
559
+ `{
560
+ "compatibility_date": "2026-06-15",
561
+ "logpush": true,
562
+ "env": { "production": { "logpush": true, }, },
563
+ "analytics_engine_datasets": [{ "binding": "AE", "dataset": "events", },],
564
+ }`,
565
+ );
566
+ const streams = noopStreams();
567
+ const exit = runCli({
568
+ argv: [],
569
+ cwd: tmpDir,
570
+ stdout: streams.stdout,
571
+ stderr: streams.stderr,
572
+ now: new Date('2026-07-01T00:00:00Z'),
573
+ });
574
+ assert.equal(exit, 0, streams.out + streams.err);
575
+ });
576
+
577
+ // ---------------------------------------------------------------------------
578
+ // The consumer end state reported in #406 (Turborepo + declared exception)
579
+ // ---------------------------------------------------------------------------
580
+
581
+ test('runCli renders a declared exception as EXCEPTED while still reporting a real violation', () => {
582
+ // domio's shape: the Worker config lives outside the repo root, uses
583
+ // trailing commas throughout, declares an analytics-engine opt-out, and has
584
+ // a genuinely stale compatibility_date.
585
+ mkdirSync(join(tmpDir, 'apps', 'web'), { recursive: true });
586
+ writeFileSync(
587
+ join(tmpDir, 'apps', 'web', 'wrangler.jsonc'),
588
+ `{
589
+ // Worker config for the web app
590
+ "name": "web",
591
+ "compatibility_date": "2025-01-01",
592
+ "logpush": true,
593
+ "env": { "production": { "logpush": true, }, },
594
+ "mandrel": {
595
+ "wranglerBaselineExceptions": {
596
+ "analytics-engine": "no telemetry sink for this static-asset Worker",
597
+ },
598
+ },
599
+ }`,
600
+ );
601
+ const streams = noopStreams();
602
+ const exit = runCli({
603
+ argv: ['--file', 'apps/web/wrangler.jsonc', '--json'],
604
+ cwd: tmpDir,
605
+ stdout: streams.stdout,
606
+ stderr: streams.stderr,
607
+ now: new Date('2026-08-29T00:00:00Z'),
608
+ });
609
+
610
+ const envelope = JSON.parse(streams.out);
611
+ const analytics = envelope.findings.find((f) => f.id === 'analytics-engine');
612
+ assert.equal(analytics.excepted, true, 'the declared opt-out is honoured');
613
+ assert.ok(
614
+ !envelope.violations.some((v) => v.id === 'analytics-engine'),
615
+ 'an excepted rule is absent from violations',
616
+ );
617
+ assert.ok(
618
+ envelope.violations.some((v) => v.id === 'compat-date-stale'),
619
+ 'a genuine staleness violation is still reported',
620
+ );
621
+ assert.equal(exit, 1, 'a real un-excepted violation still fails in blocking mode');
622
+ });
623
+
624
+ // ---------------------------------------------------------------------------
625
+ // Direct-invocation guard — spawned as a SUBPROCESS through a symlink (#407)
626
+ //
627
+ // Every other test here imports runCli, which is exactly why the guard bug
628
+ // survived: the guard line only runs when the file is the process entry point.
629
+ // These cases spawn it the way a pnpm consumer's CI does.
630
+ // ---------------------------------------------------------------------------
631
+
632
+ const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url));
633
+
634
+ /**
635
+ * Spawn the CLI at `scriptPath` with `cwd`, returning the child result.
636
+ *
637
+ * @param {string} scriptPath Path to invoke (possibly through a symlink).
638
+ * @param {string[]} args CLI args.
639
+ * @param {string} cwd Working directory for the child.
640
+ */
641
+ function runScript(scriptPath, args, cwd) {
642
+ return spawnSync(process.execPath, [scriptPath, ...args], {
643
+ cwd,
644
+ encoding: 'utf-8',
645
+ });
646
+ }
647
+
648
+ test('the CLI runs when invoked through a pnpm-style symlinked node_modules', () => {
649
+ // node_modules/mandrel-platform -> <repo root>, the shape pnpm installs.
650
+ const nodeModules = join(tmpDir, 'node_modules');
651
+ mkdirSync(nodeModules, { recursive: true });
652
+ symlinkSync(REPO_ROOT, join(nodeModules, 'mandrel-platform'), 'dir');
653
+ writeFileSync(join(tmpDir, 'wrangler.json'), '{"compatibility_date": "2020-01-01"}');
654
+
655
+ const linked = join(
656
+ nodeModules,
657
+ 'mandrel-platform',
658
+ 'scripts',
659
+ 'check-wrangler-baseline.mjs',
660
+ );
661
+ const result = runScript(linked, ['--max-age-days', '90'], tmpDir);
662
+
663
+ assert.notEqual(
664
+ result.stdout.trim(),
665
+ '',
666
+ 'a symlinked invocation must produce output, not a silent pass',
667
+ );
668
+ assert.match(result.stdout, /wrangler-baseline/);
669
+ assert.equal(result.status, 1, 'a violating config exits non-zero in blocking mode');
670
+ });
671
+
672
+ test('a symlinked invocation honours --warn-only rather than exiting silently', () => {
673
+ const nodeModules = join(tmpDir, 'node_modules');
674
+ mkdirSync(nodeModules, { recursive: true });
675
+ symlinkSync(REPO_ROOT, join(nodeModules, 'mandrel-platform'), 'dir');
676
+ writeFileSync(join(tmpDir, 'wrangler.json'), '{"compatibility_date": "2020-01-01"}');
677
+
678
+ const linked = join(
679
+ nodeModules,
680
+ 'mandrel-platform',
681
+ 'scripts',
682
+ 'check-wrangler-baseline.mjs',
683
+ );
684
+ const result = runScript(linked, ['--max-age-days', '90', '--warn-only'], tmpDir);
685
+
686
+ assert.equal(result.status, 0);
687
+ assert.match(result.stdout, /violation/i, 'advisory mode still reports the findings');
688
+ });
689
+
690
+ test('the CLI still runs when invoked through its real path', () => {
691
+ writeFileSync(join(tmpDir, 'wrangler.json'), '{"compatibility_date": "2020-01-01"}');
692
+ const direct = join(REPO_ROOT, 'scripts', 'check-wrangler-baseline.mjs');
693
+ const result = runScript(direct, ['--max-age-days', '90'], tmpDir);
694
+ assert.match(result.stdout, /wrangler-baseline/);
695
+ assert.equal(result.status, 1);
696
+ });
697
+
698
+ test('importing the module does not execute the CLI', () => {
699
+ // The guard must be false when the entry point is some other script.
700
+ const probe = join(tmpDir, 'probe.mjs');
701
+ const target = join(REPO_ROOT, 'scripts', 'check-wrangler-baseline.mjs');
702
+ writeFileSync(
703
+ probe,
704
+ `await import(${JSON.stringify(pathToFileURL(target).href)});\nconsole.log('IMPORT-OK');\n`,
705
+ );
706
+ writeFileSync(join(tmpDir, 'wrangler.json'), '{"compatibility_date": "2020-01-01"}');
707
+ const result = runScript(probe, [], tmpDir);
708
+ assert.equal(result.status, 0, 'an import must not exit(1) via the CLI');
709
+ assert.match(result.stdout, /IMPORT-OK/);
710
+ assert.doesNotMatch(result.stdout, /wrangler-baseline/);
711
+ });
@@ -6,7 +6,7 @@
6
6
  * (Story #231). Extracted from the workflow's former ~140-line inline bash so
7
7
  * the probe is unit-testable and reviewed as code, not as a YAML diff. The
8
8
  * workflow sparse-checks this script out of dsj1984/mandrel-platform at
9
- * `github.job_workflow_sha` — the exact commit the caller's
9
+ * `job.workflow_sha` — the exact commit the caller's
10
10
  * `deploy-cloudflare.yml@<ref>` pin resolved to — so the script version
11
11
  * always travels in lockstep with the workflow pin (same model as
12
12
  * `uptime-apply.yml` → `apply-uptime-monitors.mjs`).
@@ -5,7 +5,7 @@
5
5
  * In-pipeline worker-secrets provisioning for the shared
6
6
  * `deploy-cloudflare.yml` workflow (Story #170; extracted from inline bash in
7
7
  * Story #231). The workflow sparse-checks this script out of
8
- * dsj1984/mandrel-platform at `github.job_workflow_sha` — the exact commit
8
+ * dsj1984/mandrel-platform at `job.workflow_sha` — the exact commit
9
9
  * the caller's `deploy-cloudflare.yml@<ref>` pin resolved to — so the script
10
10
  * version always travels in lockstep with the workflow pin (same model as
11
11
  * `uptime-apply.yml` → `apply-uptime-monitors.mjs`).