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.
@@ -0,0 +1,271 @@
1
+ /**
2
+ * actions-expression.mjs — a minimal GitHub Actions expression evaluator.
3
+ *
4
+ * Extracted from check-toolchain-cache-default.test.mjs (Story #364) when a
5
+ * second guard needed it (Story #421). It exists because **a `grep` is not an
6
+ * acceptance criterion for a workflow expression**: string-matching pins the
7
+ * spelling, not the behaviour, and both expression defects this repo has
8
+ * shipped read correctly and evaluated wrong. Extract the real expression from
9
+ * the YAML and RUN it.
10
+ *
11
+ * Supported surface — only what the guarded expressions use: string literals,
12
+ * `inputs.<name>` / `inputs['<name>']` reads, `==`/`!=`, `&&`/`||`,
13
+ * parentheses, and `contains()`, `startsWith()`, `format()`, `fromJSON()`.
14
+ *
15
+ * The semantics that matter, and that a hand-read gets wrong:
16
+ *
17
+ * - `a && b` yields `b` when `a` is truthy, else `a`.
18
+ * - `a || b` yields `a` when `a` is truthy, else `b`.
19
+ * Both yield OPERANDS, not booleans.
20
+ * - EVERY non-empty string is truthy — including the string `'false'`.
21
+ * - Arrays and objects are truthy. GitHub documents the falsy set as exactly
22
+ * `false`, `0`, `-0`, `''`, `null`; an array is not in it.
23
+ * - String comparison is case-insensitive.
24
+ *
25
+ * ## On short-circuiting
26
+ *
27
+ * This evaluator parses to an AST first and evaluates `&&`/`||` lazily, which
28
+ * is the standard semantic. GitHub does NOT document whether its own evaluator
29
+ * short-circuits, so a guarded expression **must not depend on it** — never
30
+ * place a throwing call (`fromJSON` on caller-controlled text) in a branch that
31
+ * is only conditionally reached. Wrap the conditional instead:
32
+ *
33
+ * fromJSON(cond && '<json>' || '<json>') ← safe under either semantic
34
+ * cond && fromJSON(x) || x ← safe ONLY if lazy
35
+ *
36
+ * Evaluating lazily here while requiring the safe shape in the workflows means
37
+ * the guard cannot bless an expression whose correctness rests on an
38
+ * unverified assumption about GitHub's evaluator.
39
+ *
40
+ * Errors are thrown as plain `Error`s; nothing here depends on a test framework.
41
+ */
42
+
43
+ /** Actions truthiness — NOT JavaScript's. */
44
+ export function truthy(v) {
45
+ if (typeof v === "boolean") return v;
46
+ if (typeof v === "number") return v !== 0;
47
+ if (typeof v === "string") return v !== "";
48
+ return v !== null && v !== undefined;
49
+ }
50
+
51
+ /** Actions comparison: case-insensitive for strings. */
52
+ export function looseEqual(a, b) {
53
+ if (typeof a === "string" && typeof b === "string") {
54
+ return a.toLowerCase() === b.toLowerCase();
55
+ }
56
+ return a === b;
57
+ }
58
+
59
+ function fail(message) {
60
+ throw new Error(`[actions-expression] ${message}`);
61
+ }
62
+
63
+ const FUNCTIONS = new Map([
64
+ ["contains", 2],
65
+ ["startsWith", 2],
66
+ ["endsWith", 2],
67
+ ["fromJSON", 1],
68
+ ]);
69
+
70
+ /** Parse `expr` into an AST. Exported for tests that assert on shape. */
71
+ export function parse(expr) {
72
+ let i = 0;
73
+
74
+ const ws = () => {
75
+ while (i < expr.length && /\s/.test(expr[i])) i++;
76
+ };
77
+ const eat = (token) => {
78
+ ws();
79
+ if (expr.startsWith(token, i)) {
80
+ i += token.length;
81
+ return true;
82
+ }
83
+ return false;
84
+ };
85
+
86
+ function parseOr() {
87
+ let left = parseAnd();
88
+ for (;;) {
89
+ ws();
90
+ if (!eat("||")) return left;
91
+ left = { kind: "or", left, right: parseAnd() };
92
+ }
93
+ }
94
+
95
+ function parseAnd() {
96
+ let left = parseCompare();
97
+ for (;;) {
98
+ ws();
99
+ if (!eat("&&")) return left;
100
+ left = { kind: "and", left, right: parseCompare() };
101
+ }
102
+ }
103
+
104
+ function parseCompare() {
105
+ const left = parsePrimary();
106
+ ws();
107
+ if (eat("!=")) return { kind: "ne", left, right: parsePrimary() };
108
+ if (eat("==")) return { kind: "eq", left, right: parsePrimary() };
109
+ return left;
110
+ }
111
+
112
+ function parsePrimary() {
113
+ ws();
114
+ if (eat("(")) {
115
+ const node = parseOr();
116
+ ws();
117
+ if (!eat(")")) fail(`unbalanced parenthesis at ${i} in: ${expr}`);
118
+ return node;
119
+ }
120
+ if (expr[i] === "'") {
121
+ i++;
122
+ let out = "";
123
+ while (i < expr.length) {
124
+ if (expr[i] === "'" && expr[i + 1] === "'") {
125
+ out += "'";
126
+ i += 2;
127
+ continue;
128
+ }
129
+ if (expr[i] === "'") {
130
+ i++;
131
+ return { kind: "literal", value: out };
132
+ }
133
+ out += expr[i++];
134
+ }
135
+ fail(`unterminated string literal in: ${expr}`);
136
+ }
137
+ for (const [name, arity] of FUNCTIONS) {
138
+ if (!expr.startsWith(`${name}(`, i)) continue;
139
+ i += `${name}(`.length;
140
+ const args = [parseOr()];
141
+ while (args.length < arity) {
142
+ ws();
143
+ if (!eat(",")) fail(`${name}() expects ${arity} arguments in: ${expr}`);
144
+ args.push(parseOr());
145
+ }
146
+ ws();
147
+ if (!eat(")")) fail(`unclosed ${name}() in: ${expr}`);
148
+ return { kind: "call", name, args };
149
+ }
150
+ // `format()` is variadic, so it is parsed separately from the fixed-arity table.
151
+ if (expr.startsWith("format(", i)) {
152
+ i += "format(".length;
153
+ const args = [parseOr()];
154
+ for (;;) {
155
+ ws();
156
+ if (!eat(",")) break;
157
+ args.push(parseOr());
158
+ }
159
+ ws();
160
+ if (!eat(")")) fail(`unclosed format() in: ${expr}`);
161
+ return { kind: "call", name: "format", args };
162
+ }
163
+ const ident = expr.slice(i).match(/^[A-Za-z_][A-Za-z0-9_.\-]*(\['[^']*'\])?/);
164
+ if (!ident) fail(`unparseable token at ${i} in: ${expr}`);
165
+ i += ident[0].length;
166
+ const raw = ident[0];
167
+ if (raw === "true") return { kind: "literal", value: true };
168
+ if (raw === "false") return { kind: "literal", value: false };
169
+ const bracket = raw.match(/^inputs\['([^']*)'\]$/);
170
+ const dotted = raw.match(/^inputs\.(.+)$/);
171
+ const key = bracket ? bracket[1] : dotted ? dotted[1] : null;
172
+ if (key === null) fail(`unsupported context read \`${raw}\` in: ${expr}`);
173
+ return { kind: "input", key };
174
+ }
175
+
176
+ const ast = parseOr();
177
+ ws();
178
+ if (i !== expr.length) fail(`trailing tokens at ${i} in: ${expr}`);
179
+ return ast;
180
+ }
181
+
182
+ function applyFormat(args) {
183
+ const template = String(args[0]);
184
+ const rest = args.slice(1);
185
+ let out = "";
186
+ for (let k = 0; k < template.length; k++) {
187
+ const ch = template[k];
188
+ if (ch === "{" && template[k + 1] === "{") {
189
+ out += "{";
190
+ k++;
191
+ } else if (ch === "}" && template[k + 1] === "}") {
192
+ out += "}";
193
+ k++;
194
+ } else if (ch === "{") {
195
+ const close = template.indexOf("}", k);
196
+ if (close === -1) fail(`unclosed format placeholder in: ${template}`);
197
+ const idx = Number(template.slice(k + 1, close));
198
+ if (!Number.isInteger(idx)) fail(`non-numeric format placeholder in: ${template}`);
199
+ out += rest[idx] === undefined ? "" : String(rest[idx]);
200
+ k = close;
201
+ } else {
202
+ out += ch;
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+
208
+ function evalNode(node, inputs) {
209
+ switch (node.kind) {
210
+ case "literal":
211
+ return node.value;
212
+ case "input":
213
+ if (!(node.key in inputs)) {
214
+ fail(`expression reads \`inputs.${node.key}\`, not provided by the caller`);
215
+ }
216
+ return inputs[node.key];
217
+ case "and": {
218
+ // Lazy on purpose — see the module header's note on short-circuiting.
219
+ const left = evalNode(node.left, inputs);
220
+ return truthy(left) ? evalNode(node.right, inputs) : left;
221
+ }
222
+ case "or": {
223
+ const left = evalNode(node.left, inputs);
224
+ return truthy(left) ? left : evalNode(node.right, inputs);
225
+ }
226
+ case "eq":
227
+ return looseEqual(evalNode(node.left, inputs), evalNode(node.right, inputs));
228
+ case "ne":
229
+ return !looseEqual(evalNode(node.left, inputs), evalNode(node.right, inputs));
230
+ case "call": {
231
+ const args = node.args.map((a) => evalNode(a, inputs));
232
+ if (node.name === "format") return applyFormat(args);
233
+ const [a, b] = args;
234
+ if (node.name === "contains") {
235
+ // Substring on a string haystack; exact-item match on an array one.
236
+ if (Array.isArray(a)) return a.some((item) => looseEqual(item, b));
237
+ return String(a).toLowerCase().includes(String(b).toLowerCase());
238
+ }
239
+ if (node.name === "startsWith") {
240
+ return String(a).toLowerCase().startsWith(String(b).toLowerCase());
241
+ }
242
+ if (node.name === "endsWith") {
243
+ return String(a).toLowerCase().endsWith(String(b).toLowerCase());
244
+ }
245
+ if (node.name === "fromJSON") {
246
+ try {
247
+ return JSON.parse(String(a));
248
+ } catch {
249
+ // A parse failure is a HARD expression error in Actions — the run
250
+ // fails rather than yielding null, which is what makes a malformed
251
+ // `runner` value loud instead of silently unmatchable.
252
+ fail(`fromJSON() could not parse ${JSON.stringify(String(a))} in this expression`);
253
+ }
254
+ }
255
+ return fail(`unsupported function \`${node.name}\``);
256
+ }
257
+ default:
258
+ return fail(`unsupported node kind \`${node.kind}\``);
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Evaluate `expr` (the body BETWEEN `${{` and `}}`) against `inputs`.
264
+ *
265
+ * @param {string} expr
266
+ * @param {Record<string, unknown>} inputs values for `inputs.*` reads
267
+ * @returns {unknown} the resulting operand — a string, boolean, or array
268
+ */
269
+ export function evaluate(expr, inputs) {
270
+ return evalNode(parse(expr), inputs);
271
+ }
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * actions-expression.test.mjs — the evaluator must be trustworthy before it
4
+ * can judge a workflow (Story #421).
5
+ *
6
+ * These assertions are the reason the evaluator exists: every one of them is a
7
+ * place where Actions semantics diverge from JavaScript's, and where a
8
+ * hand-read of an expression gets the wrong answer.
9
+ *
10
+ * Run: node --test scripts/lib/actions-expression.test.mjs
11
+ */
12
+
13
+ import assert from "node:assert/strict";
14
+ import { test } from "node:test";
15
+ import { evaluate, truthy } from "./actions-expression.mjs";
16
+
17
+ test("`&&` and `||` yield OPERANDS, not booleans", () => {
18
+ assert.equal(evaluate("true && 'kept'", {}), "kept");
19
+ assert.equal(evaluate("'' || 'fallback'", {}), "fallback");
20
+ assert.equal(evaluate("'a' || 'b'", {}), "a");
21
+ });
22
+
23
+ test("every non-empty string is truthy — including the string 'false'", () => {
24
+ // The trap the toolchain-cache expression depends on: an explicitly-pinned
25
+ // 'false' must survive the first arm of the ternary rather than falling
26
+ // through to the derivation.
27
+ assert.equal(evaluate("'false' && 'yes' || 'no'", {}), "yes");
28
+ assert.equal(truthy("false"), true);
29
+ assert.equal(truthy(""), false);
30
+ });
31
+
32
+ test("arrays are truthy — the property the runs-on normalization rests on", () => {
33
+ // GitHub documents the falsy set as exactly false, 0, -0, '', null. An array
34
+ // is not in it, so a parsed label array carries through `&&` / `||`.
35
+ assert.equal(truthy([]), true);
36
+ assert.equal(truthy(["self-hosted"]), true);
37
+ });
38
+
39
+ test("comparison is case-insensitive", () => {
40
+ assert.equal(evaluate("'AUTO' == 'auto'", {}), true);
41
+ assert.equal(evaluate("'AUTO' != 'auto'", {}), false);
42
+ });
43
+
44
+ test("contains() is substring on a string and exact-item on an array", () => {
45
+ assert.equal(evaluate("contains('[\"self-hosted\",\"x\"]', 'self-hosted')", {}), true);
46
+ assert.equal(evaluate("contains(inputs.r, 'self-hosted')", { r: ["self-hosted", "x"] }), true);
47
+ // Exact-item, NOT substring, once the haystack is an array.
48
+ assert.equal(evaluate("contains(inputs.r, 'self')", { r: ["self-hosted"] }), false);
49
+ });
50
+
51
+ test("startsWith() casts to string and is case-insensitive", () => {
52
+ assert.equal(evaluate("startsWith('[\"a\"]', '[')", {}), true);
53
+ assert.equal(evaluate("startsWith('ubuntu-latest', '[')", {}), false);
54
+ assert.equal(evaluate("startsWith('UBUNTU', 'ub')", {}), true);
55
+ });
56
+
57
+ test("format() substitutes positionally and unescapes doubled braces", () => {
58
+ assert.equal(evaluate("format('\"{0}\"', inputs.r)", { r: "my-runner" }), '"my-runner"');
59
+ assert.equal(evaluate("format('{{{0}}}', inputs.r)", { r: "x" }), "{x}");
60
+ });
61
+
62
+ test("fromJSON() parses arrays and scalars", () => {
63
+ assert.deepEqual(evaluate("fromJSON('[\"self-hosted\",\"x\"]')", {}), ["self-hosted", "x"]);
64
+ assert.equal(evaluate("fromJSON('\"bare-label\"')", {}), "bare-label");
65
+ });
66
+
67
+ test("fromJSON() on malformed input THROWS — it does not yield null", () => {
68
+ // Load-bearing: in Actions a fromJSON parse failure fails the run. If it
69
+ // degraded to null here, a malformed `runner` would look like a clean
70
+ // fallback in the guard while hanging forever in production.
71
+ assert.throws(() => evaluate("fromJSON('[\"unterminated')", {}), /could not parse/);
72
+ });
73
+
74
+ test("`&&` / `||` short-circuit, so an unreached branch is never evaluated", () => {
75
+ // The evaluator is lazy. Guarded workflow expressions must NOT rely on that
76
+ // (GitHub does not document its own behaviour here) — but modelling it
77
+ // faithfully keeps the guard honest about what it is asserting.
78
+ assert.equal(evaluate("false && fromJSON('nonsense') || 'safe'", {}), "safe");
79
+ });
80
+
81
+ test("an unknown input read is an error, not a silent undefined", () => {
82
+ assert.throws(() => evaluate("inputs.missing", {}), /not provided by the caller/);
83
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * scripts/lib/entry-guard.mjs
3
+ *
4
+ * The single direct-invocation ("am I the process entry point?") seam shared
5
+ * by the `scripts/*.mjs` CLIs. Each of those scripts had grown its own guard,
6
+ * and they had drifted into three mutually incompatible spellings:
7
+ *
8
+ * 1. an identity comparison of the resolved `argv[1]` against the resolved
9
+ * pathname of `import.meta.url`;
10
+ * 2. a string comparison of `import.meta.url` against a `file:` URL built by
11
+ * interpolating `argv[1]`;
12
+ * 3. a suffix test — the resolved `argv[1]` ending with the script basename.
13
+ *
14
+ * Spellings 1 and 2 are both broken under pnpm, and broken in the most
15
+ * dangerous possible way — silently. `import.meta.url` is **realpath-resolved**
16
+ * by the ESM loader, while `process.argv[1]` is not: it keeps whatever path the
17
+ * caller typed. pnpm installs every package as a symlink
18
+ * (`node_modules/<pkg>` → `node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>`),
19
+ * so a consumer invoking
20
+ *
21
+ * node node_modules/mandrel-platform/scripts/check-wrangler-baseline.mjs
22
+ *
23
+ * compares the symlinked path against the store realpath. They never match, the
24
+ * guard is false, the CLI never runs, and the process exits 0 having printed
25
+ * nothing. In a CI log that is indistinguishable from a clean pass — the gate
26
+ * reports success precisely because it did not run (Story #407, superseding the
27
+ * consumer report in #406).
28
+ *
29
+ * Spelling 2 fails a second way even without a symlink: it string-compares a
30
+ * URL against an unencoded path, so a relative `argv[1]` (`node scripts/x.mjs`)
31
+ * or any path needing percent-encoding (a space, a `#`) also misses.
32
+ *
33
+ * Spelling 3 is not broken — a path suffix survives realpath resolution — but
34
+ * it is loose (any file with that basename matches) and it duplicates the
35
+ * script's own name as a string literal that nothing keeps in sync with a
36
+ * rename. It is left in place where it already exists; new entry points should
37
+ * use this helper.
38
+ *
39
+ * `isDirectInvocation(importMetaUrl)` resolves BOTH sides through
40
+ * `realpathSync` so the comparison is symlink-agnostic in either direction. It
41
+ * is total: it never throws, and answers `false` for every shape that cannot be
42
+ * a direct invocation (no `argv[1]`, a deleted or unreadable entry path), so
43
+ * merely importing a module for its exports can never crash on the guard line
44
+ * and can never be mistaken for running it.
45
+ *
46
+ * This module performs no I/O beyond the two realpath probes and reads no
47
+ * environment, so the sibling `entry-guard.test.mjs` suite exercises it
48
+ * entirely offline.
49
+ */
50
+
51
+ import { realpathSync } from 'node:fs';
52
+ import { resolve } from 'node:path';
53
+ import { fileURLToPath } from 'node:url';
54
+
55
+ /**
56
+ * Resolve a filesystem path through `realpathSync`, falling back to a plain
57
+ * absolute resolution when the path cannot be realpath'd.
58
+ *
59
+ * A path that does not exist has no realpath — `realpathSync` throws ENOENT.
60
+ * That is a normal, non-exceptional case here (`process.argv[1]` can name a
61
+ * file that was deleted mid-run, and is absent entirely under `node -e`), so
62
+ * it degrades to `resolve()` rather than propagating. Two non-existent paths
63
+ * then still compare equal to themselves, which keeps the guard meaningful
64
+ * instead of collapsing to a blanket `false`.
65
+ *
66
+ * @param {string} candidate Path to canonicalize.
67
+ * @returns {string} The realpath when resolvable, else the absolute path.
68
+ */
69
+ function canonicalize(candidate) {
70
+ const absolute = resolve(candidate);
71
+ try {
72
+ return realpathSync(absolute);
73
+ } catch {
74
+ return absolute;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Report whether the module identified by `importMetaUrl` is the process entry
80
+ * point — i.e. whether it was run as `node <this-file>` rather than imported.
81
+ *
82
+ * Symlink-safe in both directions: the entry path and the module URL are each
83
+ * canonicalized through `realpathSync`, so a pnpm-style symlinked
84
+ * `node_modules` invocation matches its own store realpath.
85
+ *
86
+ * Total by construction — never throws. A `file:` URL that cannot be converted
87
+ * to a path (an `http:`/`data:` specifier, a malformed URL) answers `false`,
88
+ * because such a module cannot be a filesystem entry point.
89
+ *
90
+ * @param {string} importMetaUrl The calling module's `import.meta.url`.
91
+ * @returns {boolean} True when this module is the direct invocation target.
92
+ *
93
+ * @example
94
+ * if (isDirectInvocation(import.meta.url)) {
95
+ * process.exit(runCli());
96
+ * }
97
+ */
98
+ export function isDirectInvocation(importMetaUrl) {
99
+ const entry = process.argv[1];
100
+ if (typeof entry !== 'string' || entry === '') return false;
101
+ if (typeof importMetaUrl !== 'string' || importMetaUrl === '') return false;
102
+
103
+ let modulePath;
104
+ try {
105
+ modulePath = fileURLToPath(importMetaUrl);
106
+ } catch {
107
+ return false;
108
+ }
109
+
110
+ return canonicalize(entry) === canonicalize(modulePath);
111
+ }
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * entry-guard.test.mjs — node:test suite for the shared direct-invocation
4
+ * guard (`scripts/lib/entry-guard.mjs`, Story #407).
5
+ *
6
+ * The load-bearing case is the symlinked one: pnpm installs packages as
7
+ * symlinks, and the guard spelling this helper replaces compared a
8
+ * realpath-resolved `import.meta.url` against an unresolved `process.argv[1]`.
9
+ * Under pnpm those never matched, so the CLI never ran and the process exited
10
+ * 0 having printed nothing — a silent pass. Every assertion below that builds
11
+ * a symlink exists to keep that specific regression dead.
12
+ *
13
+ * Offline: the suite only creates files and symlinks under a temp dir and
14
+ * swaps `process.argv[1]`, which it restores after each case.
15
+ *
16
+ * Run: node --test scripts/lib/entry-guard.test.mjs
17
+ */
18
+
19
+ import assert from 'node:assert/strict';
20
+ import { test } from 'node:test';
21
+ import {
22
+ mkdtempSync,
23
+ mkdirSync,
24
+ writeFileSync,
25
+ symlinkSync,
26
+ rmSync,
27
+ } from 'node:fs';
28
+ import { tmpdir } from 'node:os';
29
+ import { join } from 'node:path';
30
+ import { pathToFileURL } from 'node:url';
31
+
32
+ import { isDirectInvocation } from './entry-guard.mjs';
33
+
34
+ /**
35
+ * Run `fn` with `process.argv[1]` swapped to `entry`, restoring it after.
36
+ *
37
+ * @param {string | undefined} entry Value to install at `process.argv[1]`.
38
+ * @param {() => void} fn Body to run under the swapped argv.
39
+ */
40
+ function withArgv(entry, fn) {
41
+ const original = process.argv[1];
42
+ if (entry === undefined) {
43
+ process.argv.splice(1, 1);
44
+ } else {
45
+ process.argv[1] = entry;
46
+ }
47
+ try {
48
+ fn();
49
+ } finally {
50
+ process.argv[1] = original;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Build a temp dir holding `store/pkg/scripts/cli.mjs` plus a `link` symlink
56
+ * pointing at `store/pkg` — the shape pnpm's `node_modules` produces.
57
+ *
58
+ * @returns {{ dir: string, realScript: string, linkedScript: string }}
59
+ */
60
+ function makeSymlinkedPackage() {
61
+ const dir = mkdtempSync(join(tmpdir(), 'entry-guard-test-'));
62
+ const pkg = join(dir, 'store', 'pkg');
63
+ mkdirSync(join(pkg, 'scripts'), { recursive: true });
64
+ const realScript = join(pkg, 'scripts', 'cli.mjs');
65
+ writeFileSync(realScript, '// fixture\n');
66
+ const link = join(dir, 'link');
67
+ symlinkSync(pkg, link, 'dir');
68
+ return { dir, realScript, linkedScript: join(link, 'scripts', 'cli.mjs') };
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // The regression this helper exists for
73
+ // ---------------------------------------------------------------------------
74
+
75
+ test('isDirectInvocation matches when the entry path traverses a symlink', () => {
76
+ const { dir, realScript, linkedScript } = makeSymlinkedPackage();
77
+ try {
78
+ // The module was loaded through its realpath (what the ESM loader does),
79
+ // while argv[1] kept the symlinked path (what the caller typed). This is
80
+ // exactly the pnpm shape that used to answer false.
81
+ withArgv(linkedScript, () => {
82
+ assert.equal(
83
+ isDirectInvocation(pathToFileURL(realScript).href),
84
+ true,
85
+ 'a symlinked invocation must still count as direct',
86
+ );
87
+ });
88
+ } finally {
89
+ rmSync(dir, { recursive: true, force: true });
90
+ }
91
+ });
92
+
93
+ test('isDirectInvocation matches with the symlink on the module side too', () => {
94
+ const { dir, realScript, linkedScript } = makeSymlinkedPackage();
95
+ try {
96
+ withArgv(realScript, () => {
97
+ assert.equal(isDirectInvocation(pathToFileURL(linkedScript).href), true);
98
+ });
99
+ } finally {
100
+ rmSync(dir, { recursive: true, force: true });
101
+ }
102
+ });
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Ordinary direct / imported cases
106
+ // ---------------------------------------------------------------------------
107
+
108
+ test('isDirectInvocation matches an unsymlinked direct invocation', () => {
109
+ const { dir, realScript } = makeSymlinkedPackage();
110
+ try {
111
+ withArgv(realScript, () => {
112
+ assert.equal(isDirectInvocation(pathToFileURL(realScript).href), true);
113
+ });
114
+ } finally {
115
+ rmSync(dir, { recursive: true, force: true });
116
+ }
117
+ });
118
+
119
+ test('isDirectInvocation resolves a relative entry path against cwd', () => {
120
+ withArgv('scripts/lib/entry-guard.test.mjs', () => {
121
+ const absolute = join(process.cwd(), 'scripts/lib/entry-guard.test.mjs');
122
+ assert.equal(isDirectInvocation(pathToFileURL(absolute).href), true);
123
+ });
124
+ });
125
+
126
+ test('isDirectInvocation answers false when another module is the entry', () => {
127
+ const { dir, realScript } = makeSymlinkedPackage();
128
+ try {
129
+ const sibling = join(dir, 'store', 'pkg', 'scripts', 'other.mjs');
130
+ writeFileSync(sibling, '// fixture\n');
131
+ withArgv(sibling, () => {
132
+ assert.equal(isDirectInvocation(pathToFileURL(realScript).href), false);
133
+ });
134
+ } finally {
135
+ rmSync(dir, { recursive: true, force: true });
136
+ }
137
+ });
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Totality — the guard must never throw on an import
141
+ // ---------------------------------------------------------------------------
142
+
143
+ test('isDirectInvocation answers false when argv[1] is absent', () => {
144
+ withArgv(undefined, () => {
145
+ assert.equal(isDirectInvocation(import.meta.url), false);
146
+ });
147
+ });
148
+
149
+ test('isDirectInvocation answers false for an empty argv[1]', () => {
150
+ withArgv('', () => {
151
+ assert.equal(isDirectInvocation(import.meta.url), false);
152
+ });
153
+ });
154
+
155
+ test('isDirectInvocation tolerates an entry path that does not exist', () => {
156
+ const missing = join(tmpdir(), 'entry-guard-does-not-exist-407', 'cli.mjs');
157
+ withArgv(missing, () => {
158
+ assert.doesNotThrow(() => isDirectInvocation(import.meta.url));
159
+ assert.equal(isDirectInvocation(import.meta.url), false);
160
+ // A non-existent path still compares equal to itself rather than
161
+ // collapsing to a blanket false.
162
+ assert.equal(isDirectInvocation(pathToFileURL(missing).href), true);
163
+ });
164
+ });
165
+
166
+ test('isDirectInvocation answers false for a non-file module URL', () => {
167
+ withArgv('/tmp/whatever.mjs', () => {
168
+ assert.equal(isDirectInvocation('https://example.com/cli.mjs'), false);
169
+ assert.equal(isDirectInvocation('data:text/javascript,0'), false);
170
+ });
171
+ });
172
+
173
+ test('isDirectInvocation answers false for a missing or malformed URL', () => {
174
+ withArgv('/tmp/whatever.mjs', () => {
175
+ assert.equal(isDirectInvocation(''), false);
176
+ assert.equal(isDirectInvocation(/** @type {any} */ (undefined)), false);
177
+ assert.equal(isDirectInvocation('not a url'), false);
178
+ });
179
+ });
@@ -77,6 +77,8 @@ import {
77
77
  import { defaultGhRunner } from "./lib/gh-json.mjs";
78
78
  import { parseSemver } from "./lib/semver-duration.mjs";
79
79
 
80
+ import { isDirectInvocation } from './lib/entry-guard.mjs';
81
+
80
82
  const __dirname = dirname(fileURLToPath(import.meta.url));
81
83
 
82
84
  // The stable head branch every repair PR is opened from. Keying idempotency off
@@ -782,9 +784,9 @@ export function runCli({
782
784
  return 0;
783
785
  }
784
786
 
785
- // Direct-invocation guard (matches the repo's other scripts/*.mjs entry style).
786
- const invokedDirectly =
787
- process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
788
- if (invokedDirectly) {
787
+ // Direct-invocation guard symlink-safe via the shared seam (Story #407):
788
+ // comparing an unresolved argv[1] against a realpath-resolved
789
+ // import.meta.url silently never matches under pnpm's symlinked node_modules.
790
+ if (isDirectInvocation(import.meta.url)) {
789
791
  process.exit(runCli());
790
792
  }