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.
- package/package.json +2 -2
- package/scripts/apply-uptime-monitors.mjs +6 -2
- package/scripts/check-affected-mode.test.mjs +54 -1
- package/scripts/check-coverage-threshold.test.mjs +1 -1
- package/scripts/check-destructive-migration.mjs +6 -2
- package/scripts/check-osv-scan-mode.test.mjs +2 -2
- package/scripts/check-pin-drift.mjs +7 -5
- package/scripts/check-repo-settings.mjs +6 -4
- package/scripts/check-ruleset.mjs +6 -4
- package/scripts/check-runner-health.mjs +6 -4
- package/scripts/check-runner-runs-on.test.mjs +153 -0
- package/scripts/check-toolchain-cache-default.test.mjs +6 -138
- package/scripts/check-workflow-gh-flags.mjs +6 -2
- package/scripts/check-workflow-platform-checkout.mjs +319 -0
- package/scripts/check-workflow-platform-checkout.test.mjs +342 -0
- package/scripts/check-workflow-portability.mjs +39 -0
- package/scripts/check-workflow-portability.test.mjs +66 -0
- package/scripts/check-wrangler-baseline.mjs +126 -8
- package/scripts/check-wrangler-baseline.test.mjs +258 -1
- package/scripts/deploy-boot-smoke.mjs +1 -1
- package/scripts/deploy-worker-secrets.mjs +1 -1
- package/scripts/lib/actions-expression.mjs +271 -0
- package/scripts/lib/actions-expression.test.mjs +83 -0
- package/scripts/lib/entry-guard.mjs +111 -0
- package/scripts/lib/entry-guard.test.mjs +179 -0
- package/scripts/platform-repair.mjs +6 -4
- package/scripts/track-issue.test.mjs +280 -0
- package/scripts/update-semgrep-rules.mjs +6 -4
|
@@ -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
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
if (
|
|
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
|
}
|