mandrel-platform 1.4.2 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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-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-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/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,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for check-workflow-platform-checkout.mjs (Story #415).
|
|
3
|
+
*
|
|
4
|
+
* The lint exists because the defect class it guards is invisible to every
|
|
5
|
+
* other gate: an empty `ref:` is valid YAML, valid actionlint, and green in CI
|
|
6
|
+
* — it goes wrong only at runtime, on a consumer. So these tests assert both
|
|
7
|
+
* directions: each violation shape IS caught, and each legitimate shape is
|
|
8
|
+
* NOT, because a lint that over-fires on a sound pattern gets suppressed and
|
|
9
|
+
* then guards nothing.
|
|
10
|
+
*/
|
|
11
|
+
import test from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
14
|
+
import { join, dirname } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
import { lintWorkflow, parseWorkflow, sparseEntries, stepGuard } from './check-workflow-platform-checkout.mjs';
|
|
18
|
+
|
|
19
|
+
const REPO = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
|
|
21
|
+
/** A fail-closed resolve step, as the real workflows carry it. */
|
|
22
|
+
const RESOLVE = (id, cond) =>
|
|
23
|
+
` - name: Resolve platform ref
|
|
24
|
+
${cond ? ` if: ${cond}\n` : ''} id: ${id}
|
|
25
|
+
shell: bash
|
|
26
|
+
env:
|
|
27
|
+
PLATFORM_SHA: \${{ job.workflow_sha }}
|
|
28
|
+
run: |
|
|
29
|
+
set -euo pipefail
|
|
30
|
+
if [[ ! "\${PLATFORM_SHA}" =~ ^[0-9a-f]{40}$ ]]; then
|
|
31
|
+
echo "::error::unresolved"
|
|
32
|
+
exit 1
|
|
33
|
+
fi
|
|
34
|
+
echo "sha=\${PLATFORM_SHA}" >> "$GITHUB_OUTPUT"
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
/** A platform checkout consuming a resolve step's output. */
|
|
38
|
+
const CHECKOUT = ({ ref = '${{ steps.platform-ref.outputs.sha }}', cond = null, sparse = ['scripts'] }) =>
|
|
39
|
+
` - name: Checkout platform
|
|
40
|
+
${cond ? ` if: ${cond}\n` : ''} uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
41
|
+
with:
|
|
42
|
+
repository: dsj1984/mandrel-platform
|
|
43
|
+
ref: ${ref}
|
|
44
|
+
sparse-checkout: |
|
|
45
|
+
${sparse.map((s) => ` ${s}`).join('\n')}
|
|
46
|
+
sparse-checkout-cone-mode: false
|
|
47
|
+
path: _platform
|
|
48
|
+
persist-credentials: false
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
const workflow = (steps) => `name: t
|
|
52
|
+
on:
|
|
53
|
+
workflow_call:
|
|
54
|
+
jobs:
|
|
55
|
+
build:
|
|
56
|
+
runs-on: ubuntu-latest
|
|
57
|
+
steps:
|
|
58
|
+
${steps}`;
|
|
59
|
+
|
|
60
|
+
const rules = (findings) => findings.map((f) => f.rule).sort();
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// The clean shape
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
test('a pinned, fail-closed, module-graph-complete checkout is clean', () => {
|
|
67
|
+
const src = workflow(RESOLVE('platform-ref', null) + '\n' + CHECKOUT({}));
|
|
68
|
+
assert.deepEqual(lintWorkflow('t.yml', src), []);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Rule 1 — dead-token
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
test('the dead job_workflow_sha token is flagged anywhere in the file', () => {
|
|
76
|
+
const src = workflow(
|
|
77
|
+
RESOLVE('platform-ref', null) + '\n' + CHECKOUT({}),
|
|
78
|
+
).replace('name: t', '# note: job_workflow_sha used to live here\nname: t');
|
|
79
|
+
const found = lintWorkflow('t.yml', src);
|
|
80
|
+
assert.deepEqual(rules(found), ['dead-token']);
|
|
81
|
+
assert.match(found[0].detail, /OIDC token claim/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('the dead token is flagged even inside a live ref expression', () => {
|
|
85
|
+
const src = workflow(CHECKOUT({ ref: '${{ fromJSON(toJSON(github)).job_workflow_sha }}' }));
|
|
86
|
+
assert.ok(rules(lintWorkflow('t.yml', src)).includes('dead-token'));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Rule 2 — resolved-ref
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
test('a raw context expression as ref: is rejected', () => {
|
|
94
|
+
// The exact shape that shipped the outage — note it is otherwise valid YAML.
|
|
95
|
+
const src = workflow(CHECKOUT({ ref: '${{ github.sha }}' }));
|
|
96
|
+
const found = lintWorkflow('t.yml', src);
|
|
97
|
+
assert.ok(rules(found).includes('resolved-ref'));
|
|
98
|
+
assert.match(found.find((f) => f.rule === 'resolved-ref').detail, /silently use the default branch/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('a platform checkout with no ref: at all is rejected', () => {
|
|
102
|
+
const src = workflow(
|
|
103
|
+
` - name: Checkout platform
|
|
104
|
+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
105
|
+
with:
|
|
106
|
+
repository: dsj1984/mandrel-platform
|
|
107
|
+
path: _platform
|
|
108
|
+
`,
|
|
109
|
+
);
|
|
110
|
+
const found = lintWorkflow('t.yml', src);
|
|
111
|
+
assert.ok(rules(found).includes('resolved-ref'));
|
|
112
|
+
assert.match(found[0].detail, /no `ref:` at all/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a checkout of some OTHER repository is not this lint’s business', () => {
|
|
116
|
+
const src = workflow(
|
|
117
|
+
` - name: Checkout elsewhere
|
|
118
|
+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
119
|
+
with:
|
|
120
|
+
repository: someone/else
|
|
121
|
+
ref: \${{ github.sha }}
|
|
122
|
+
`,
|
|
123
|
+
);
|
|
124
|
+
assert.deepEqual(lintWorkflow('t.yml', src), []);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Rule 3 — fail-closed
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
test('a resolve step that does not assert 40-hex is rejected', () => {
|
|
132
|
+
const weak = ` - name: Resolve platform ref
|
|
133
|
+
id: platform-ref
|
|
134
|
+
shell: bash
|
|
135
|
+
env:
|
|
136
|
+
PLATFORM_SHA: \${{ job.workflow_sha }}
|
|
137
|
+
run: echo "sha=\${PLATFORM_SHA}" >> "$GITHUB_OUTPUT"
|
|
138
|
+
`;
|
|
139
|
+
const found = lintWorkflow('t.yml', workflow(weak + '\n' + CHECKOUT({})));
|
|
140
|
+
assert.ok(rules(found).includes('fail-closed'));
|
|
141
|
+
assert.match(found.find((f) => f.rule === 'fail-closed').detail, /never fall back to the default branch/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a ref: pointing at a step id that resolves nothing is rejected', () => {
|
|
145
|
+
const src = workflow(CHECKOUT({ ref: '${{ steps.nope.outputs.sha }}' }));
|
|
146
|
+
const found = lintWorkflow('t.yml', src);
|
|
147
|
+
assert.ok(rules(found).includes('fail-closed'));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('a step reading job.workflow_sha for output but carrying no id is rejected', () => {
|
|
151
|
+
const noId = ` - name: Resolve platform ref
|
|
152
|
+
shell: bash
|
|
153
|
+
env:
|
|
154
|
+
PLATFORM_SHA: \${{ job.workflow_sha }}
|
|
155
|
+
run: |
|
|
156
|
+
if [[ ! "\${PLATFORM_SHA}" =~ ^[0-9a-f]{40}$ ]]; then exit 1; fi
|
|
157
|
+
echo "sha=\${PLATFORM_SHA}" >> "$GITHUB_OUTPUT"
|
|
158
|
+
`;
|
|
159
|
+
const found = lintWorkflow('t.yml', workflow(noId));
|
|
160
|
+
assert.ok(rules(found).includes('fail-closed'));
|
|
161
|
+
assert.match(found[0].detail, /no `id:`/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('reading job.workflow_sha for a job summary is not a resolve step', () => {
|
|
165
|
+
// deploy-summary echoes the resolved SHA without exporting it; requiring an
|
|
166
|
+
// `id:` there would be noise, so this must NOT fire.
|
|
167
|
+
const summary = ` - name: Emit resolved platform-ref summary
|
|
168
|
+
shell: bash
|
|
169
|
+
run: echo "| SHA | \\\`\${SHA}\\\` |" >> "$GITHUB_STEP_SUMMARY"
|
|
170
|
+
env:
|
|
171
|
+
SHA: \${{ job.workflow_sha }}
|
|
172
|
+
`;
|
|
173
|
+
assert.deepEqual(lintWorkflow('t.yml', workflow(summary)), []);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Rule 4 — guard-parity
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
test('a guarded resolve step paired with a differently-guarded checkout is rejected', () => {
|
|
181
|
+
const src = workflow(
|
|
182
|
+
RESOLVE('platform-ref', "${{ inputs.a }}") + '\n' + CHECKOUT({ cond: '${{ inputs.b }}' }),
|
|
183
|
+
);
|
|
184
|
+
const found = lintWorkflow('t.yml', src);
|
|
185
|
+
assert.ok(rules(found).includes('guard-parity'));
|
|
186
|
+
assert.match(found.find((f) => f.rule === 'guard-parity').detail, /or leave itself unguarded/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('matching guards on resolve and checkout are clean', () => {
|
|
190
|
+
const src = workflow(
|
|
191
|
+
RESOLVE('platform-ref', '${{ inputs.a }}') + '\n' + CHECKOUT({ cond: '${{ inputs.a }}' }),
|
|
192
|
+
);
|
|
193
|
+
assert.deepEqual(lintWorkflow('t.yml', src), []);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('an UNGUARDED resolve step covers any guarded checkout', () => {
|
|
197
|
+
// The security tier's real shape: one unconditional resolve serving three
|
|
198
|
+
// checkouts with three different `if:` guards. It always runs, so its output
|
|
199
|
+
// is always populated — flagging it would be a false positive.
|
|
200
|
+
const src = workflow(
|
|
201
|
+
RESOLVE('platform-ref', null) +
|
|
202
|
+
'\n' +
|
|
203
|
+
CHECKOUT({ cond: '${{ inputs.enable-sast }}' }) +
|
|
204
|
+
'\n' +
|
|
205
|
+
CHECKOUT({ cond: "${{ inputs.enable-sast && inputs.semgrep-config == 'vendored' }}" }),
|
|
206
|
+
);
|
|
207
|
+
assert.deepEqual(lintWorkflow('t.yml', src), []);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Rule 5 — module-graph
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
test('a sparse list naming a .mjs script but not scripts/lib/ is rejected', () => {
|
|
215
|
+
// Exactly the uptime-apply shape that took every consumer red.
|
|
216
|
+
const src = workflow(
|
|
217
|
+
RESOLVE('platform-ref', null) +
|
|
218
|
+
'\n' +
|
|
219
|
+
CHECKOUT({ sparse: ['scripts/apply-uptime-monitors.mjs', '.nvmrc'] }),
|
|
220
|
+
);
|
|
221
|
+
const found = lintWorkflow('t.yml', src);
|
|
222
|
+
assert.deepEqual(rules(found), ['module-graph']);
|
|
223
|
+
assert.match(found[0].detail, /scripts\/apply-uptime-monitors\.mjs/);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('adding scripts/lib/ to the same list clears it', () => {
|
|
227
|
+
const src = workflow(
|
|
228
|
+
RESOLVE('platform-ref', null) +
|
|
229
|
+
'\n' +
|
|
230
|
+
CHECKOUT({ sparse: ['scripts/apply-uptime-monitors.mjs', 'scripts/lib/', '.nvmrc'] }),
|
|
231
|
+
);
|
|
232
|
+
assert.deepEqual(lintWorkflow('t.yml', src), []);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('a bare `scripts` entry already covers the whole tree', () => {
|
|
236
|
+
const src = workflow(RESOLVE('platform-ref', null) + '\n' + CHECKOUT({ sparse: ['scripts'] }));
|
|
237
|
+
assert.deepEqual(lintWorkflow('t.yml', src), []);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('a non-.mjs sparse list needs no module graph', () => {
|
|
241
|
+
const src = workflow(
|
|
242
|
+
RESOLVE('platform-ref', null) + '\n' + CHECKOUT({ sparse: ['scripts/resolve-diff-range.sh'] }),
|
|
243
|
+
);
|
|
244
|
+
assert.deepEqual(lintWorkflow('t.yml', src), []);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Parsing seams
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
test('YAML aliases are resolved to the step they stand for', () => {
|
|
252
|
+
// pr-quality.yml reuses `&checkout-range` across five jobs; without anchor
|
|
253
|
+
// expansion the aliasing jobs would look like they had no checkout at all
|
|
254
|
+
// and the lint would pass them vacuously.
|
|
255
|
+
const src = `name: t
|
|
256
|
+
on:
|
|
257
|
+
workflow_call:
|
|
258
|
+
jobs:
|
|
259
|
+
a:
|
|
260
|
+
runs-on: ubuntu-latest
|
|
261
|
+
steps:
|
|
262
|
+
- &co
|
|
263
|
+
name: Checkout platform
|
|
264
|
+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
265
|
+
with:
|
|
266
|
+
repository: dsj1984/mandrel-platform
|
|
267
|
+
ref: \${{ github.sha }}
|
|
268
|
+
b:
|
|
269
|
+
runs-on: ubuntu-latest
|
|
270
|
+
steps:
|
|
271
|
+
- *co
|
|
272
|
+
`;
|
|
273
|
+
const { jobs } = parseWorkflow(src);
|
|
274
|
+
assert.equal(jobs.size, 2);
|
|
275
|
+
const found = lintWorkflow('t.yml', src);
|
|
276
|
+
// Both the anchor site and the alias site are reported, at their own lines.
|
|
277
|
+
assert.deepEqual(rules(found), ['resolved-ref', 'resolved-ref']);
|
|
278
|
+
assert.notEqual(found[0].line, found[1].line);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('sparseEntries reads a block scalar and stops at the next key', () => {
|
|
282
|
+
const text = ` - name: x
|
|
283
|
+
with:
|
|
284
|
+
sparse-checkout: |
|
|
285
|
+
scripts/a.mjs
|
|
286
|
+
scripts/lib/
|
|
287
|
+
sparse-checkout-cone-mode: false
|
|
288
|
+
path: _p
|
|
289
|
+
`;
|
|
290
|
+
assert.deepEqual(sparseEntries(text), ['scripts/a.mjs', 'scripts/lib/']);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('stepGuard ignores commented-out if: lines', () => {
|
|
294
|
+
assert.equal(stepGuard(' - name: x\n # if: ${{ never }}\n run: true\n'), null);
|
|
295
|
+
assert.equal(stepGuard(' - name: x\n if: ${{ inputs.a }}\n'), '${{ inputs.a }}');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// The real tree
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
test('every shipped workflow passes the lint', () => {
|
|
303
|
+
const findings = [];
|
|
304
|
+
for (const dir of ['.github/workflows', 'templates/workflows']) {
|
|
305
|
+
const abs = join(REPO, dir);
|
|
306
|
+
if (!existsSync(abs)) continue;
|
|
307
|
+
for (const name of readdirSync(abs)) {
|
|
308
|
+
if (!name.endsWith('.yml') && !name.endsWith('.yaml')) continue;
|
|
309
|
+
findings.push(...lintWorkflow(join(dir, name), readFileSync(join(abs, name), 'utf8')));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
assert.deepEqual(
|
|
313
|
+
findings,
|
|
314
|
+
[],
|
|
315
|
+
`platform-checkout violations:\n${findings.map((f) => `${f.path}:${f.line} [${f.rule}] ${f.detail}`).join('\n')}`,
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('every platform checkout in the shipped workflows is actually pinned', () => {
|
|
320
|
+
// Belt-and-braces over the rule engine: assert the resolved shape directly,
|
|
321
|
+
// so a future refactor of the lint cannot quietly stop covering the tree.
|
|
322
|
+
const dir = join(REPO, '.github/workflows');
|
|
323
|
+
let checkouts = 0;
|
|
324
|
+
for (const name of readdirSync(dir)) {
|
|
325
|
+
if (!name.endsWith('.yml')) continue;
|
|
326
|
+
const src = readFileSync(join(dir, name), 'utf8');
|
|
327
|
+
assert.ok(!src.includes('job_workflow_sha'), `${name} still names the dead OIDC claim`);
|
|
328
|
+
const { jobs } = parseWorkflow(src);
|
|
329
|
+
for (const job of jobs.values()) {
|
|
330
|
+
for (const step of job.steps) {
|
|
331
|
+
if (!step.text.includes('repository: dsj1984/mandrel-platform')) continue;
|
|
332
|
+
checkouts += 1;
|
|
333
|
+
assert.match(
|
|
334
|
+
step.text,
|
|
335
|
+
/ref: \$\{\{ steps\.[A-Za-z0-9_-]+\.outputs\.sha \}\}/,
|
|
336
|
+
`${name}:${step.line} platform checkout is not pinned to a resolve step`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
assert.ok(checkouts >= 10, `expected the known platform checkouts, saw ${checkouts}`);
|
|
342
|
+
});
|
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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
|
|
511
|
-
|
|
512
|
-
|
|
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
|
}
|