leerness 1.36.300 → 1.36.301

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,394 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // T-0359: split CI work across parallel jobs. Until 1.36.300 each Windows gate job ran e2e.js (167-226 min) and the
4
+ // command-surface leaves (43-52 min) one after the other, and the Windows fast job ran its leaves for 79 min, so a
5
+ // release waited about five hours for CI (run 38193803239). A shard runs part of that work. Shards 1..k of one job
6
+ // together run every unit exactly once, because every shard computes the same assignment from the same inputs:
7
+ // the units in source order, weighted by measured seconds (scripts/ci-shard-weights.json), placed largest first on
8
+ // the least-loaded shard, ties to the earlier unit and the lower shard.
9
+ //
10
+ // node ./scripts/ci-shard.js npm <script> <i>/<k> run the leaves of an npm script that shard i of k owns
11
+ // node ./scripts/ci-shard.js plan npm <script> <k> print the assignment
12
+ // node ./scripts/ci-shard.js plan e2e <k> print the assignment of the e2e.js cases
13
+ // node ./scripts/ci-shard.js weights e2e|npm <times.json>... replace weights with measured seconds, written by
14
+ // LEERNESS_E2E_TIMES=<file> (e2e.js) or LEERNESS_CI_SHARD_TIMES=<file>
15
+ //
16
+ // A leaf is `node <file> [args]`, or `npm run <name>`, which is expanded. Any other shape is refused rather than
17
+ // guessed: running it differently from npm would test something else. Leaves run with node directly, in the
18
+ // package root, in their order in the script. A shard runs all of its leaves and fails if any failed, so one CI
19
+ // run reports every failing leaf of the shard (a plain `&&` chain stops at the first).
20
+ // e2e.js uses e2eShard() below: each top-level case is `if (e2eShard.take('<key>')) {`, and finish() checks that the
21
+ // file has no unguarded top-level block and that every guard was reached exactly once.
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const cp = require('child_process');
25
+
26
+ const ROOT = path.resolve(__dirname, '..');
27
+ const WEIGHTS_FILE = path.join(__dirname, 'ci-shard-weights.json');
28
+
29
+ function parseShard(text) {
30
+ const m = /^([1-9]\d{0,2})\/([1-9]\d{0,2})$/.exec(String(text == null ? '' : text).trim());
31
+ if (!m || Number(m[1]) > Number(m[2])) throw new Error(`shard must be i/k with 1 <= i <= k, got "${text}"`);
32
+ return { index: Number(m[1]), count: Number(m[2]) };
33
+ }
34
+
35
+ // units: [{ key, weight }] in source order. Returns the shard (1..count) of each unit and each shard's load.
36
+ function assign(units, count) {
37
+ for (const u of units) {
38
+ if (!(typeof u.weight === 'number' && Number.isFinite(u.weight) && u.weight >= 0)) throw new Error(`bad weight for ${u.key}: ${u.weight}`);
39
+ }
40
+ const order = units.map((u, i) => i).sort((a, b) => (units[b].weight - units[a].weight) || (a - b));
41
+ const load = new Array(count).fill(0);
42
+ const shardOf = new Array(units.length);
43
+ for (const i of order) {
44
+ let best = 0;
45
+ for (let s = 1; s < count; s++) if (load[s] < load[best]) best = s;
46
+ shardOf[i] = best + 1;
47
+ load[best] += units[i].weight;
48
+ }
49
+ return { shardOf, load };
50
+ }
51
+
52
+ function loadWeights(file = WEIGHTS_FILE) {
53
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
54
+ for (const kind of ['e2e', 'npm']) {
55
+ const table = data[kind];
56
+ if (!table || typeof table !== 'object' || Array.isArray(table)) throw new Error(`${path.basename(file)}: "${kind}" table missing`);
57
+ for (const [key, value] of Object.entries(table)) {
58
+ if (!(typeof value === 'number' && Number.isFinite(value) && value >= 0)) throw new Error(`${path.basename(file)}: bad ${kind} weight for ${key}`);
59
+ }
60
+ }
61
+ return data;
62
+ }
63
+
64
+ // A unit without a measurement weighs the median of its table, so a new case or leaf lands like an ordinary one.
65
+ function weigh(keys, table) {
66
+ const values = Object.values(table).sort((a, b) => a - b);
67
+ const median = values.length ? values[Math.floor(values.length / 2)] : 1;
68
+ const plain = key => key.replace(/ #\d+$/, '');
69
+ return keys.map(key => ({ key, weight: Object.prototype.hasOwnProperty.call(table, key) ? table[key]
70
+ : Object.prototype.hasOwnProperty.call(table, plain(key)) ? table[plain(key)] : median }));
71
+ }
72
+
73
+ // Measured seconds (the LEERNESS_E2E_TIMES / LEERNESS_CI_SHARD_TIMES files) replace those units' weights; the
74
+ // table is kept sorted so a refresh diffs cleanly. A repeated leaf (`<text> #2`) is stored under its text.
75
+ function withMeasured(data, kind, measured) {
76
+ const table = { ...data[kind] };
77
+ let updated = 0;
78
+ for (const times of measured) {
79
+ for (const [key, seconds] of Object.entries(times)) {
80
+ if (!(typeof seconds === 'number' && Number.isFinite(seconds) && seconds >= 0)) throw new Error(`bad seconds for ${key}: ${seconds}`);
81
+ table[key.replace(/ #\d+$/, '')] = Math.round(seconds * 10) / 10; updated++;
82
+ }
83
+ }
84
+ const sorted = Object.fromEntries(Object.entries(table).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
85
+ return { data: { ...data, [kind]: sorted }, updated };
86
+ }
87
+
88
+ function uniqueKeys(texts) {
89
+ const seen = new Map();
90
+ return texts.map(text => { const n = (seen.get(text) || 0) + 1; seen.set(text, n); return n === 1 ? text : `${text} #${n}`; });
91
+ }
92
+
93
+ // ---- npm script leaves ----
94
+ const LEAF_TOKEN = /^[\w./:=@+-]+$/;
95
+
96
+ function npmLeaves(scripts, name, stack = []) {
97
+ if (!Object.prototype.hasOwnProperty.call(scripts, name)) throw new Error(`npm script not found: ${name}`);
98
+ if (stack.includes(name)) throw new Error(`npm script cycle: ${[...stack, name].join(' -> ')}`);
99
+ // `npm run <name>` also runs pre<name> and post<name> (review round 1). This runner does not run lifecycle
100
+ // scripts, so a script that has one is refused instead of losing it.
101
+ for (const hook of [`pre${name}`, `post${name}`]) {
102
+ if (Object.prototype.hasOwnProperty.call(scripts, hook)) {
103
+ throw new Error(`unsupported npm script ${name}: npm would also run its lifecycle script ${hook}, which ci-shard.js does not run`);
104
+ }
105
+ }
106
+ const out = [];
107
+ for (const part of String(scripts[name]).split('&&').map(p => p.trim())) {
108
+ const nested = /^npm run ([\w:.-]+)$/.exec(part);
109
+ if (nested) { out.push(...npmLeaves(scripts, nested[1], [...stack, name])); continue; }
110
+ const argv = part.split(/\s+/);
111
+ if (argv[0] !== 'node' || argv.length < 2 || !argv.every(t => LEAF_TOKEN.test(t)) || argv[1].startsWith('-')) {
112
+ throw new Error(`unsupported step in npm script ${name}: "${part}" (expected node <file> [args] or npm run <name>)`);
113
+ }
114
+ out.push({ text: argv.join(' '), file: argv[1], args: argv.slice(2), from: name });
115
+ }
116
+ return out;
117
+ }
118
+
119
+ function readScripts() {
120
+ return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8').replace(/^\uFEFF/, '')).scripts || {};
121
+ }
122
+
123
+ function planNpm(scripts, name, count, weights) {
124
+ const leaves = npmLeaves(scripts, name);
125
+ const keys = uniqueKeys(leaves.map(l => l.text));
126
+ const units = weigh(keys, weights.npm);
127
+ const { shardOf, load } = assign(units, count);
128
+ return { leaves: leaves.map((l, i) => ({ ...l, key: keys[i], weight: units[i].weight, shard: shardOf[i] })), load };
129
+ }
130
+
131
+ function runNpm(name, spec, opts = {}) {
132
+ const root = opts.root || ROOT;
133
+ const env = opts.env || process.env;
134
+ const scripts = opts.scripts || readScripts();
135
+ const weights = opts.weights || loadWeights();
136
+ const { leaves } = planNpm(scripts, name, spec.count, weights);
137
+ const mine = leaves.filter(l => l.shard === spec.index);
138
+ const log = opts.log || (line => process.stdout.write(line + '\n'));
139
+ const grouped = env.GITHUB_ACTIONS === 'true';
140
+ log(`ci-shard: ${name} shard ${spec.index}/${spec.count} runs ${mine.length} of ${leaves.length} leaves`);
141
+ const results = [];
142
+ for (const leaf of mine) {
143
+ if (grouped) log(`::group::${leaf.key}`);
144
+ const started = Date.now();
145
+ const r = cp.spawnSync(process.execPath, [path.resolve(root, leaf.file), ...leaf.args], { cwd: root, stdio: opts.stdio || 'inherit', env });
146
+ const seconds = (Date.now() - started) / 1000;
147
+ const ok = r.status === 0 && !r.signal && !r.error;
148
+ results.push({ key: leaf.key, ok, seconds, status: r.status, signal: r.signal || null, error: r.error ? String(r.error.code || r.error.message) : null });
149
+ if (grouped) log('::endgroup::');
150
+ log(`ci-shard: ${ok ? 'ok ' : 'FAIL'} ${seconds.toFixed(1).padStart(7)} s ${leaf.key}${ok ? '' : ` (exit ${r.status}${r.signal ? `, signal ${r.signal}` : ''}${r.error ? `, ${r.error.code || r.error.message}` : ''})`}`);
151
+ }
152
+ const failed = results.filter(r => !r.ok);
153
+ const total = results.reduce((s, r) => s + r.seconds, 0);
154
+ log(`ci-shard: ${name} shard ${spec.index}/${spec.count}: ${results.length - failed.length}/${results.length} leaves passed · ${total.toFixed(0)} s`);
155
+ for (const f of failed) log(`ci-shard: failed: ${f.key}`);
156
+ if (env.LEERNESS_CI_SHARD_TIMES) {
157
+ fs.writeFileSync(env.LEERNESS_CI_SHARD_TIMES, JSON.stringify(Object.fromEntries(results.map(r => [r.key, Math.round(r.seconds * 10) / 10])), null, 1) + '\n');
158
+ }
159
+ return { ran: results.length, of: leaves.length, failed: failed.map(f => f.key), results };
160
+ }
161
+
162
+ // ---- e2e.js cases ----
163
+ // A case is a top-level `if (e2eShard.take('<key>')) {` block. Its text, for the check below, runs to the next guard:
164
+ // a column-0 `}` can sit inside a template literal, and a short text could hide a mention.
165
+ // The helper is created by one line naming the file's shared top-level state (the fixture project and its helpers,
166
+ // whose state one case leaves for the next). Every case whose text mentions one of those names runs in the same
167
+ // shard as the others, in source order; the other cases are placed one by one.
168
+ const GUARD = /^if \(e2eShard\.take\('([^'\\\n]+)'\)\) \{$/;
169
+ const CREATE = /^const e2eShard = require\('\.\/ci-shard'\)\.e2eShard\(__filename, \[((?:'[\w$]+'(?:, )?)*)\]\);$/;
170
+ const SHARED_GROUP = '(cases using the shared fixture)';
171
+ // A case's block ends at its first column-0 `}`. The cases start after the line `// e2e-shard: cases start here`;
172
+ // from there, top-level code outside a case only prepares what later cases use (smoke `run()` calls, fixture files,
173
+ // environment switches). A block, a control-flow block, an immediately invoked function or a case counter there is
174
+ // a case without a guard: review round 1 found a block opened by `{ // comment`, round 2 an `if (false) {` in place
175
+ // of the first guard (the boundary was the first guard then). Whatever wraps a case, its `total++` stays outside
176
+ // a guard and is reported. The preamble above the line may use any statement except a bare block. The run-wide
177
+ // section, from a column-0 `setImmediate(` to its column-0 `});` (a comment may follow), ends the file; each of
178
+ // its own counters carries `// e2e-shard: run-wide check`.
179
+ const CASES_START = /^\/\/ e2e-shard: cases start here\b/;
180
+ const RUN_WIDE_CHECK = /\/\/ e2e-shard: run-wide check\s*$/;
181
+ const CASE_COUNTER = /(^|[^\w$.])total\s*(?:\+\+|\+=)/;
182
+ const OUTSIDE_A_CASE = [
183
+ [/^\s*\{/, 'a block'],
184
+ [/^\s*(?:if|else|for|while|do|try|switch)\b.*\{\s*(?:\/\/.*)?$/, 'a control-flow block'],
185
+ [/^\s*[(!;+~-]\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[\w$]+\s*=>)/, 'an immediately invoked function'],
186
+ [CASE_COUNTER, 'a case counter'],
187
+ ];
188
+
189
+ function e2eCases(source) {
190
+ const lines = String(source).replace(/\r\n/g, '\n').split('\n');
191
+ const keys = [], problems = [], at = new Map(), text = new Map();
192
+ let shared = null, current = null, inCase = false, casesStarted = false, runWide = false, runWideEnded = false;
193
+ lines.forEach((line, i) => {
194
+ const c = CREATE.exec(line);
195
+ if (c) {
196
+ if (shared) problems.push(`line ${i + 1}: a second e2eShard(...) line`);
197
+ shared = [...c[1].matchAll(/'([\w$]+)'/g)].map(m => m[1]);
198
+ return;
199
+ }
200
+ const m = GUARD.exec(line);
201
+ if (m) {
202
+ if (!casesStarted) problems.push(`line ${i + 1}: case "${m[1]}" before the line "// e2e-shard: cases start here"`);
203
+ if (runWide) problems.push(`line ${i + 1}: case "${m[1]}" in or after the run-wide section`);
204
+ if (inCase) problems.push(`line ${i + 1}: case "${m[1]}" starts inside case "${current}"`);
205
+ if (at.has(m[1])) { problems.push(`key "${m[1]}" repeats (lines ${at.get(m[1])} and ${i + 1})`); current = null; }
206
+ else { at.set(m[1], i + 1); keys.push(m[1]); current = m[1]; text.set(current, []); }
207
+ inCase = true;
208
+ return;
209
+ }
210
+ if (current) text.get(current).push(line);
211
+ if (/e2eShard\.take\(/.test(line) && !/^\s*\/\//.test(line)) {
212
+ problems.push(`line ${i + 1}: e2eShard.take outside the guard form "if (e2eShard.take('<key>')) {"`);
213
+ return;
214
+ }
215
+ if (inCase) { if (/^\}/.test(line)) inCase = false; return; }
216
+ if (CASES_START.test(line)) {
217
+ if (casesStarted) problems.push(`line ${i + 1}: a second "cases start here" line`);
218
+ casesStarted = true;
219
+ return;
220
+ }
221
+ if (runWideEnded) {
222
+ if (line.trim() && !/^\s*\/\//.test(line)) problems.push(`line ${i + 1}: code after the run-wide section`);
223
+ return;
224
+ }
225
+ if (runWide) {
226
+ // Review round 3: `}); // end` left the section open, so a case after it was exempt.
227
+ if (/^\}\)\s*;?\s*(?:\/\/.*|\/\*.*\*\/\s*)?$/.test(line)) { runWideEnded = true; return; }
228
+ // The section's own checks say so; any other counter there is a case without a guard.
229
+ if (CASE_COUNTER.test(line) && !RUN_WIDE_CHECK.test(line)) {
230
+ problems.push(`line ${i + 1}: a case counter in the run-wide section without "// e2e-shard: run-wide check"`);
231
+ }
232
+ return;
233
+ }
234
+ if (casesStarted && /^setImmediate\(/.test(line)) { runWide = true; return; }
235
+ if (/^\s*\/\//.test(line)) return;
236
+ for (const [re, what] of casesStarted ? OUTSIDE_A_CASE : OUTSIDE_A_CASE.slice(0, 1)) {
237
+ if (re.test(line)) { problems.push(`line ${i + 1}: ${what} outside a case, without an e2eShard.take guard`); break; }
238
+ }
239
+ });
240
+ if (!casesStarted) problems.push('no line "// e2e-shard: cases start here"');
241
+ if (runWide && !runWideEnded) problems.push('the run-wide section (column-0 "setImmediate(") never closes with a column-0 "});"');
242
+ if (!shared) { problems.push("no line \"const e2eShard = require('./ci-shard').e2eShard(__filename, [<shared names>]);\""); shared = []; }
243
+ const mentions = shared.map(n => new RegExp(`(^|[^\\w$.])${n.replace(/\$/g, '\\$')}([^\\w$]|$)`, 'm'));
244
+ const usesShared = new Set(keys.filter(k => { const t = text.get(k).join('\n'); return mentions.some(re => re.test(t)); }));
245
+ return { keys, problems, shared, usesShared };
246
+ }
247
+
248
+ function planE2e(source, count, weights) {
249
+ const { keys, problems, shared, usesShared } = e2eCases(source);
250
+ const weighed = weigh(keys, weights.e2e);
251
+ // One unit for the cases using shared state, at the position of the first of them; one unit per other case.
252
+ const units = [], unitOf = new Map();
253
+ weighed.forEach(u => {
254
+ if (!usesShared.has(u.key)) { unitOf.set(u.key, units.length); units.push({ key: u.key, weight: u.weight }); return; }
255
+ if (!unitOf.has(SHARED_GROUP)) { unitOf.set(SHARED_GROUP, units.length); units.push({ key: SHARED_GROUP, weight: 0 }); }
256
+ units[unitOf.get(SHARED_GROUP)].weight += u.weight;
257
+ unitOf.set(u.key, unitOf.get(SHARED_GROUP));
258
+ });
259
+ const { shardOf, load } = assign(units, count);
260
+ return { cases: weighed.map(u => ({ key: u.key, weight: u.weight, shared: usesShared.has(u.key), shard: shardOf[unitOf.get(u.key)] })),
261
+ load, problems, shared };
262
+ }
263
+
264
+ function e2eShard(file, shared, env = process.env, opts = {}) {
265
+ const raw = env.LEERNESS_E2E_SHARD;
266
+ const timesOut = env.LEERNESS_E2E_TIMES || null;
267
+ // Children see the environment of an unsharded run: a test that starts this file again runs all of it.
268
+ delete env.LEERNESS_E2E_SHARD; delete env.LEERNESS_E2E_TIMES;
269
+ const spec = raw === undefined || raw === '' ? null : parseShard(raw);
270
+ const source = fs.readFileSync(file, 'utf8');
271
+ const parsed = e2eCases(source);
272
+ const { keys, problems } = parsed;
273
+ if (JSON.stringify(shared) !== JSON.stringify(parsed.shared)) problems.push(`shared names passed (${JSON.stringify(shared)}) differ from the file's e2eShard line (${JSON.stringify(parsed.shared)})`);
274
+ const known = new Set(keys);
275
+ let mine = null;
276
+ if (spec) {
277
+ const { cases } = planE2e(source, spec.count, opts.weights || loadWeights());
278
+ mine = new Set(cases.filter(c => c.shard === spec.index).map(c => c.key));
279
+ }
280
+ const reached = new Map(), times = {};
281
+ let current = null, since = 0;
282
+ const stop = () => { if (current !== null) times[current] = Math.round((times[current] || 0) * 10 + (Date.now() - since) / 100) / 10; current = null; };
283
+ return {
284
+ spec,
285
+ take(key) {
286
+ stop();
287
+ reached.set(key, (reached.get(key) || 0) + 1);
288
+ // A key the parser did not see (a guard in another form) runs in every shard: finish() fails the run, but no
289
+ // shard drops the case.
290
+ const run = !mine || mine.has(key) || !known.has(key);
291
+ if (run) { current = key; since = Date.now(); }
292
+ return run;
293
+ },
294
+ finish() {
295
+ stop();
296
+ const missing = keys.filter(k => !reached.has(k));
297
+ const repeated = [...reached].filter(([, n]) => n > 1).map(([k]) => k);
298
+ const unknown = [...reached.keys()].filter(k => !known.has(k));
299
+ if (timesOut) { try { fs.writeFileSync(timesOut, JSON.stringify(times, null, 1) + '\n'); } catch (e) { problems.push(`times file not written: ${e.message}`); } }
300
+ const ok = !problems.length && !missing.length && !repeated.length && !unknown.length;
301
+ return { ok, spec, ran: mine ? keys.filter(k => mine.has(k)).length + unknown.length : keys.length, total: keys.length, problems, missing, repeated, unknown };
302
+ },
303
+ };
304
+ }
305
+
306
+ // ---- .github/workflows/ci.yml shard lists ----
307
+ // The jobs of a workflow written the way ci.yml is: two-space job keys under `jobs:`, matrix entries as one-line flow
308
+ // mappings (`- { os: windows-latest, node: 18, shard: 1, shards: 4 }`), steps with `run:` and `env:` lines.
309
+ // Returns { <job>: { entries: [{ key: value }], runs: [command], lines: [line] } }.
310
+ function workflowJobs(text) {
311
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n');
312
+ const jobs = {};
313
+ let job = null, inJobs = false;
314
+ for (const line of lines) {
315
+ if (/^\S/.test(line)) { inJobs = line === 'jobs:'; job = null; continue; }
316
+ if (!inJobs) continue;
317
+ const head = /^ {2}([\w-]+):\s*$/.exec(line);
318
+ if (head) { job = jobs[head[1]] = { entries: [], runs: [], lines: [] }; continue; }
319
+ if (!job) continue;
320
+ job.lines.push(line);
321
+ const entry = /^\s+- \{ (.+) \}\s*$/.exec(line);
322
+ if (entry) {
323
+ job.entries.push(Object.fromEntries(entry[1].split(/,\s*/).map(pair => {
324
+ const at = pair.indexOf(':');
325
+ return at < 0 ? [pair.trim(), ''] : [pair.slice(0, at).trim(), pair.slice(at + 1).trim()];
326
+ })));
327
+ continue;
328
+ }
329
+ const run = /^\s+(?:- )?run:\s*(.+?)\s*$/.exec(line);
330
+ if (run) job.runs.push(run[1]);
331
+ }
332
+ return jobs;
333
+ }
334
+
335
+ // The configurations of a job's entries (an entry without shard/shards), and for a sharded job the configurations
336
+ // whose entries are not exactly shards 1..k of one k.
337
+ function shardLists(entries) {
338
+ const groups = new Map();
339
+ for (const e of entries) {
340
+ const { shard, shards, ...config } = e;
341
+ const id = Object.entries(config).sort(([a], [b]) => (a < b ? -1 : 1)).map(([k, v]) => `${k}=${v}`).join(' ');
342
+ if (!groups.has(id)) groups.set(id, []);
343
+ groups.get(id).push({ shard, shards });
344
+ }
345
+ const problems = [];
346
+ for (const [id, list] of groups) {
347
+ const k = Number(list[0].shards);
348
+ const got = list.map(x => Number(x.shard)).sort((a, b) => a - b);
349
+ const sameK = list.every(x => x.shards === list[0].shards);
350
+ if (!sameK || !Number.isInteger(k) || k < 1 || got.length !== k || got.some((s, i) => s !== i + 1)) {
351
+ problems.push(`${id}: ${list.map(x => `${x.shard}/${x.shards}`).join(', ')}`);
352
+ }
353
+ }
354
+ return { configs: [...groups.keys()], problems };
355
+ }
356
+
357
+ function main(argv) {
358
+ const [cmd, ...rest] = argv;
359
+ if (cmd === 'npm' && rest.length === 2) {
360
+ const r = runNpm(rest[0], parseShard(rest[1]));
361
+ return r.failed.length ? 1 : 0;
362
+ }
363
+ if (cmd === 'plan' && ((rest[0] === 'npm' && rest.length === 3) || (rest[0] === 'e2e' && rest.length === 2))) {
364
+ const count = parseShard(`1/${rest[rest.length - 1]}`).count;
365
+ const weights = loadWeights();
366
+ const plan = rest[0] === 'npm' ? planNpm(readScripts(), rest[1], count, weights)
367
+ : planE2e(fs.readFileSync(path.join(__dirname, 'e2e.js'), 'utf8'), count, weights);
368
+ const units = plan.leaves || plan.cases;
369
+ for (let s = 1; s <= count; s++) {
370
+ const own = units.filter(u => u.shard === s);
371
+ console.log(`shard ${s}/${count}: ${own.length} units, ${(plan.load[s - 1] / 60).toFixed(1)} min measured`);
372
+ for (const u of own) console.log(` ${u.weight.toFixed(1).padStart(8)} s ${u.shared ? '[shared] ' : ''}${u.key}`);
373
+ }
374
+ if (plan.shared) console.log(`shared names: ${plan.shared.join(', ')}; cases using them: ${plan.cases.filter(c => c.shared).length}`);
375
+ for (const p of plan.problems || []) console.log(`problem: ${p}`);
376
+ return plan.problems && plan.problems.length ? 1 : 0;
377
+ }
378
+ if (cmd === 'weights' && ['e2e', 'npm'].includes(rest[0]) && rest.length >= 2) {
379
+ const measured = rest.slice(1).map(file => JSON.parse(fs.readFileSync(file, 'utf8')));
380
+ const { data, updated } = withMeasured(loadWeights(), rest[0], measured);
381
+ fs.writeFileSync(WEIGHTS_FILE, JSON.stringify(data, null, 1) + '\n');
382
+ console.log(`ci-shard: ${updated} ${rest[0]} weights updated in ${path.relative(ROOT, WEIGHTS_FILE)}`);
383
+ return 0;
384
+ }
385
+ console.error('usage: ci-shard.js npm <script> <i>/<k> | plan npm <script> <k> | plan e2e <k> | weights e2e|npm <times.json>...');
386
+ return 2;
387
+ }
388
+
389
+ module.exports = { parseShard, assign, loadWeights, weigh, withMeasured, uniqueKeys, npmLeaves, planNpm, runNpm, e2eCases, planE2e, e2eShard,
390
+ workflowJobs, shardLists, WEIGHTS_FILE };
391
+
392
+ if (require.main === module) {
393
+ try { process.exitCode = main(process.argv.slice(2)); } catch (e) { console.error(`ci-shard: ${e.message}`); process.exitCode = 2; }
394
+ }
@@ -34,9 +34,10 @@ const unique = marker => {
34
34
  const extract = (start, end, precedingCounter = false) => {
35
35
  let a = unique(start); const b = unique(end);
36
36
  if (precedingCounter) {
37
- const counter = source.lastIndexOf('total++;\n{', a);
38
- assert(counter >= 0 && /^total\+\+;\n\{\s*$/.test(source.slice(counter, a)), 'handoff counter boundary drift');
39
- a = counter;
37
+ // T-0359: a case opens with its shard guard, and the counter is the guard's first line.
38
+ const guard = source.lastIndexOf("if (e2eShard.take('", a);
39
+ assert(guard >= 0 && /^if \(e2eShard\.take\('[^'\\\n]+'\)\) \{\n total\+\+;\n\s*$/.test(source.slice(guard, a)), 'handoff counter boundary drift');
40
+ a = guard;
40
41
  }
41
42
  assert(b > a, 'source markers reversed');
42
43
  const block = source.slice(a, b);
@@ -100,6 +101,7 @@ try {
100
101
  const execute = (name, failingResult) => {
101
102
  const lines = [], calls = [];
102
103
  const context = { fs, path, os: { ...os, tmpdir: () => arena }, process: { execPath: process.execPath, env: { ...process.env } }, CLI, SELFTEST_SPAWN_MS, performance, childDiagnostics, total: 0, failed: 0,
104
+ e2eShard: { take: () => true },
103
105
  console: { log: (...args) => lines.push(args.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ')) },
104
106
  cp: { spawnSync: (executable, args, options) => {
105
107
  assert.equal(executable, process.execPath); assert.equal(args[0], CLI);