@orkestrel/scaffold 0.0.87 → 0.0.89

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.
@@ -1,20 +1,149 @@
1
- // Census of test discovery: which projects the gates reach, what each collects, and where a suite
2
- // skips. Run from the checkout root:
1
+ // Let Vitest select each gate's tests, then compare its identities with each config/mode universe.
2
+ // Run from the checkout root:
3
3
  // node .agents/skills/orkestrel-harden/scripts/discovery.ts [--config vite.config.ts] [--projects a,b] [--json]
4
- // The script follows every root script chain in package.json (a script no other script invokes) to
5
- // the `--project` names its Vitest scripts gate and the test files its `node` scripts run, runs
6
- // `vitest list --json` once through the local vitest entry to read what each project collects, and
7
- // reads every collected file for `.skip(`, `.todo(`, `.skipIf(`, `.runIf(`, `retry:`, and
8
- // `timeout:`. It flags a collected project no root chain reaches, a named project that collects
9
- // nothing, and a test file under tests/ that no project collects and no root script runs directly.
10
- // A project with no test file and no gate is outside the census. Exit 0 with no flag, 3 with one, 2 when Vitest cannot list, 64 on
11
- // usage.
4
+ // Follow root npm script chains and forwarded arguments. Collect an unfiltered full listing for
5
+ // each config/mode/root/dir, including the base config. Pass gate arguments verbatim except the
6
+ // reporter, output-file, coverage, color, cache, silent, UI, watch, run-mode, inspector, and
7
+ // standalone options;
8
+ // use filesOnly for file selection and full collection for name, tag, line, and shard selection.
9
+ // Compare file/project/name/line/column identities without renaming projects. With --projects,
10
+ // ask Vitest for the reporting scope. Preserve duplicate names within a listing and merge across units.
11
+ // Ignore bench, list, mergeReports, listTags, and clearCache invocations as gates. Flag partly ungated rows, empty chained projects,
12
+ // and undiscovered files under tests/. Report marker counts and empty standalone workbenches.
13
+ // Retry when test files change during collection; refuse another change during that retry.
14
+ // Exit 0 with no flag, 3 with a flag, 2 for missing Vitest, unsupported related commands,
15
+ // CLI/help/argument/listing/output failures, repeated test-file changes, or filesystem/JSON errors;
16
+ // exit 64 on usage.
12
17
  import { spawnSync } from 'node:child_process';
13
- import { existsSync, readFileSync } from 'node:fs';
14
- import { relative, resolve } from 'node:path';
18
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { basename, join, relative, resolve } from 'node:path';
15
21
  import { listFiles, readMissingFlags, readOption, } from "../../orkestrel-dispatch/scripts/helpers.js";
16
22
  const VITEST = 'node_modules/vitest/vitest.mjs';
17
23
  const MARKERS = ['.skip(', '.todo(', '.skipIf(', '.runIf(', 'retry:', 'timeout:'];
24
+ const OMITTED = new Set([
25
+ 'reporter',
26
+ 'outputFile',
27
+ 'coverage',
28
+ 'color',
29
+ 'cache',
30
+ 'silent',
31
+ 'ui',
32
+ 'watch',
33
+ 'run',
34
+ 'inspect',
35
+ 'inspectBrk',
36
+ 'standalone',
37
+ ]);
38
+ // Bounds one listing, so an option that waits for input exits 2 naming its gate instead of
39
+ // hanging the census.
40
+ const LISTING_TIMEOUT = 600_000;
41
+ function readCLIOptions() {
42
+ const help = spawnSync(process.execPath, [VITEST, 'list', '--help', '--expand-help'], {
43
+ encoding: 'utf8',
44
+ windowsHide: true,
45
+ });
46
+ if (help.status !== 0)
47
+ throw new Error(help.stderr || 'Cannot read Vitest CLI options');
48
+ const options = new Map();
49
+ for (const line of help.stdout.split(/\r\n|\n/u)) {
50
+ const match = /^\s+(?:(-\w), )?--([\w.-]+)(?: ([<[][^\n]*?[>\]]))?\s{2}/u.exec(line);
51
+ const name = match?.[2];
52
+ if (name === undefined)
53
+ continue;
54
+ const option = { name: name.replace(/^no-/u, ''), argument: match?.[3] };
55
+ options.set(`--${name}`, option);
56
+ if (name.startsWith('no-'))
57
+ options.set(`--${name.slice(3)}`, option);
58
+ options.set(`--${name.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)}`, option);
59
+ if (match?.[1] !== undefined)
60
+ options.set(match[1], option);
61
+ }
62
+ return options;
63
+ }
64
+ function findRunner(command) {
65
+ let index = 0;
66
+ const executable = basename(command[0] ?? '').replace(/\.(?:exe|cmd)$/u, '');
67
+ if (executable === 'npm') {
68
+ if (command[1] !== 'exec')
69
+ return -1;
70
+ index = 2;
71
+ }
72
+ else if (executable === 'node' || executable === 'npx')
73
+ index = 1;
74
+ if (index > 0) {
75
+ while (command[index]?.startsWith('-')) {
76
+ const option = command[index];
77
+ if (['-e', '--eval', '-p', '--print', '-c', '--check'].includes(option ?? ''))
78
+ return -1;
79
+ index += [
80
+ '-r',
81
+ '--require',
82
+ '--import',
83
+ '--loader',
84
+ '--experimental-loader',
85
+ '--max-old-space-size',
86
+ '--max_old_space_size',
87
+ '-p',
88
+ '--package',
89
+ ].includes(option ?? '')
90
+ ? 2
91
+ : 1;
92
+ }
93
+ }
94
+ return ['vitest', 'vitest.mjs', 'vitest.js'].includes(basename(command[index] ?? '').replace(/\.cmd$/u, ''))
95
+ ? index
96
+ : -1;
97
+ }
98
+ function readSelection(argv, options) {
99
+ const selected = [];
100
+ for (let index = 0; index < argv.length; index++) {
101
+ const token = argv[index];
102
+ if (token === undefined)
103
+ continue;
104
+ if (token === '--') {
105
+ selected.push(...argv.slice(index));
106
+ break;
107
+ }
108
+ if (!token.startsWith('-')) {
109
+ selected.push(token);
110
+ continue;
111
+ }
112
+ const equals = token.indexOf('=');
113
+ const flag = equals < 0 ? token : token.slice(0, equals);
114
+ const positive = flag.replace(/^--no-/u, '--');
115
+ const option = options.get(positive) ?? options.get(positive.split('.').slice(0, 1).join('.'));
116
+ if (option === undefined || !OMITTED.has(option.name.split('.')[0] ?? '')) {
117
+ selected.push(token);
118
+ continue;
119
+ }
120
+ const next = argv[index + 1];
121
+ if (!flag.startsWith('--no-') &&
122
+ equals < 0 &&
123
+ next !== undefined &&
124
+ !next.startsWith('-') &&
125
+ (option.argument !== undefined || next === 'true' || next === 'false'))
126
+ index++;
127
+ }
128
+ return selected;
129
+ }
130
+ function readArguments(argv, names) {
131
+ const values = [];
132
+ for (let index = 0; index < argv.length; index++) {
133
+ const token = argv[index];
134
+ if (token === '--')
135
+ break;
136
+ if (token === undefined)
137
+ continue;
138
+ const equals = token.indexOf('=');
139
+ if (!names.includes(equals < 0 ? token : token.slice(0, equals)))
140
+ continue;
141
+ const value = equals < 0 ? argv[index + 1] : token.slice(equals + 1);
142
+ if (value !== undefined && !value.startsWith('-'))
143
+ values.push(value);
144
+ }
145
+ return values;
146
+ }
18
147
  function readScripts() {
19
148
  const parsed = JSON.parse(readFileSync('package.json', 'utf8'));
20
149
  if (typeof parsed !== 'object' || parsed === null)
@@ -28,67 +157,169 @@ function readScripts() {
28
157
  record[key] = value;
29
158
  return record;
30
159
  }
31
- function listInvocations(text) {
32
- return [...text.matchAll(/npm run ([A-Za-z0-9:_-]+)/gu)].flatMap((match) => match[1] === undefined ? [] : [match[1]]);
160
+ function readCommands(text) {
161
+ const commands = [];
162
+ let command = [];
163
+ for (const match of text.matchAll(/(?:[^\s"';&|]+|"[^"]*"|'[^']*')+|[;&|]+/gu)) {
164
+ if (/^[;&|]+$/u.test(match[0])) {
165
+ commands.push(command);
166
+ command = [];
167
+ }
168
+ else {
169
+ command.push(match[0].replace(/["']/gu, ''));
170
+ }
171
+ }
172
+ commands.push(command);
173
+ return commands;
174
+ }
175
+ function listInvocations(commands) {
176
+ const invoked = [];
177
+ for (const command of commands) {
178
+ const index = command.indexOf('npm');
179
+ const name = command[index + 2];
180
+ if (index < 0 || command[index + 1] !== 'run' || name === undefined)
181
+ continue;
182
+ const separator = command.indexOf('--', index + 3);
183
+ invoked.push({ name, args: separator < 0 ? [] : command.slice(separator + 1), ancestors: [] });
184
+ }
185
+ return invoked;
33
186
  }
34
- function readGates(scripts) {
35
- const invoked = new Set(Object.values(scripts).flatMap(listInvocations));
36
- const projects = new Map();
187
+ function readGates(scripts, config, options) {
188
+ const commands = Object.fromEntries(Object.entries(scripts).map(([name, text]) => [name, readCommands(text)]));
189
+ const invoked = new Set(Object.values(commands)
190
+ .flatMap(listInvocations)
191
+ .map((entry) => entry.name));
192
+ const units = [];
37
193
  const files = new Map();
38
194
  for (const root of Object.keys(scripts).filter((name) => !invoked.has(name))) {
39
195
  const visited = new Set();
40
- const pending = [root];
196
+ const pending = [{ name: root, args: [], ancestors: [] }];
41
197
  while (pending.length > 0) {
42
- const name = pending.pop();
43
- if (name === undefined || visited.has(name))
198
+ const invocation = pending.pop();
199
+ if (invocation === undefined)
44
200
  continue;
45
- visited.add(name);
46
- const text = scripts[name];
47
- if (text === undefined)
201
+ const { name, args, ancestors } = invocation;
202
+ const key = JSON.stringify([name, args]);
203
+ if (visited.has(key) || ancestors.includes(name))
48
204
  continue;
49
- pending.push(...listInvocations(text));
205
+ visited.add(key);
206
+ const script = commands[name];
207
+ if (script === undefined)
208
+ continue;
209
+ const expanded = script.map((command, index) => index === script.length - 1 ? [...command, ...args] : command);
210
+ pending.push(...listInvocations(expanded).map((entry) => ({
211
+ ...entry,
212
+ ancestors: [...ancestors, name],
213
+ })));
50
214
  const gate = root === name ? name : `${root} > ${name}`;
51
- if (/\bvitest\b/u.test(text)) {
52
- for (const match of text.matchAll(/--project[= ]([A-Za-z0-9:_-]+)/gu)) {
53
- if (match[1] !== undefined && !projects.has(match[1]))
54
- projects.set(match[1], gate);
215
+ for (const command of expanded) {
216
+ const index = findRunner(command);
217
+ if (index >= 0) {
218
+ const verb = command[index + 1];
219
+ if (verb === 'list' || verb === 'bench')
220
+ continue;
221
+ const tokens = command.slice(index + (['run', 'watch', 'dev'].includes(verb ?? '') ? 2 : 1));
222
+ const separator = tokens.indexOf('--');
223
+ const before = separator < 0 ? tokens : tokens.slice(0, separator);
224
+ if (before.some((token, position) => /^(?:--mergeReports|--merge-reports|--listTags|--list-tags|--clearCache|--clear-cache)(?:=|$)/u.test(token) &&
225
+ !token.endsWith('=false') &&
226
+ before[position + 1] !== 'false'))
227
+ continue;
228
+ if (verb === 'related')
229
+ throw new Error(`${gate}: Vitest list exposes no related command or --related option`);
230
+ const flags = readSelection(tokens, options);
231
+ const mode = readArguments(flags, ['--mode'])[0];
232
+ const directory = readArguments(flags, ['--root', '-r'])[0];
233
+ const dir = readArguments(flags, ['--dir'])[0];
234
+ const configured = readArguments(flags, ['--config', '-c'])[0];
235
+ const path = configured ?? (directory === undefined && dir === undefined ? config : undefined);
236
+ units.push({
237
+ config: path === undefined
238
+ ? undefined
239
+ : relative(process.cwd(), resolve(path)).split('\\').join('/'),
240
+ mode: mode === 'test' ? undefined : mode,
241
+ ...(directory === undefined ? {} : { root: directory }),
242
+ ...(dir === undefined ? {} : { dir }),
243
+ projects: readArguments(flags, ['--project']),
244
+ chain: gate,
245
+ args: configured === undefined && path !== undefined ? ['--config', path, ...flags] : flags,
246
+ full: before.some((flag) => /^(?:-t|--testNamePattern|--test-name-pattern|--tagsFilter|--tags-filter|--shard|--(?:no-)?allowOnly|--(?:no-)?allow-only|--(?:no-)?strictTags|--(?:no-)?strict-tags)(?:=|$)/u.test(flag) || /:\d+(?:-\d+)?$/u.test(flag)),
247
+ });
248
+ continue;
249
+ }
250
+ for (const argument of command) {
251
+ if (/^(?:tests|src|app)\/[A-Za-z0-9_./-]+\.test\.[cm]?ts$/u.test(argument) &&
252
+ !files.has(argument))
253
+ files.set(argument, gate);
55
254
  }
56
- }
57
- for (const match of text.matchAll(/(?:^|\s)((?:tests|src|app)\/[A-Za-z0-9_./-]+\.test\.[cm]?ts)\b/gu)) {
58
- if (match[1] !== undefined && !files.has(match[1]))
59
- files.set(match[1], gate);
60
255
  }
61
256
  }
62
257
  }
63
- return { projects, files };
258
+ return { units, files };
64
259
  }
65
- function listCollected(config) {
66
- const result = spawnSync(process.execPath, [VITEST, 'list', '--config', config, '--json'], {
67
- encoding: 'utf8',
68
- maxBuffer: 64 * 1024 * 1024,
69
- windowsHide: true,
70
- });
71
- if (result.status !== 0)
72
- return undefined;
73
- const start = result.stdout.indexOf('[');
74
- if (start === -1)
75
- return undefined;
76
- const parsed = JSON.parse(result.stdout.slice(start));
77
- if (!Array.isArray(parsed))
78
- return undefined;
79
- const collected = [];
80
- for (const entry of parsed) {
81
- if (typeof entry !== 'object' || entry === null)
82
- continue;
83
- const record = Object.fromEntries(Object.entries(entry));
84
- if (typeof record.file === 'string' && typeof record.projectName === 'string') {
85
- collected.push({
86
- file: relative(process.cwd(), record.file).split('\\').join('/'),
87
- projectName: record.projectName,
88
- });
260
+ function listCollected(args, full, cache) {
261
+ const parameters = [...(full ? ['--includeTaskLocation'] : ['--filesOnly']), ...args];
262
+ const key = JSON.stringify(parameters);
263
+ const cached = cache.get(key);
264
+ if (cached !== undefined)
265
+ return cached;
266
+ const directory = mkdtempSync(join(tmpdir(), 'discovery-'));
267
+ const listing = join(directory, 'listing.json');
268
+ try {
269
+ const result = spawnSync(process.execPath, [VITEST, 'list', `--json=${listing}`, ...parameters], {
270
+ encoding: 'utf8',
271
+ maxBuffer: 64 * 1024 * 1024,
272
+ timeout: LISTING_TIMEOUT,
273
+ windowsHide: true,
274
+ });
275
+ if (args.includes('--project') &&
276
+ result.status === 1 &&
277
+ result.stderr.includes('No projects matched the filter "')) {
278
+ cache.set(key, []);
279
+ return [];
89
280
  }
281
+ if (result.status !== 0 || !existsSync(listing)) {
282
+ const diagnostic = result.stderr || result.error?.message || result.stdout;
283
+ console.error(diagnostic
284
+ .split(/\r\n|\n/u)
285
+ .filter((line) => line.trim() !== '')
286
+ .slice(0, 12)
287
+ .join('\n'));
288
+ return undefined;
289
+ }
290
+ const parsed = JSON.parse(readFileSync(listing, 'utf8'));
291
+ if (!Array.isArray(parsed))
292
+ return undefined;
293
+ const collected = [];
294
+ for (const entry of parsed) {
295
+ if (typeof entry !== 'object' || entry === null)
296
+ continue;
297
+ const record = Object.fromEntries(Object.entries(entry));
298
+ const location = typeof record.location === 'object' && record.location !== null
299
+ ? Object.fromEntries(Object.entries(record.location))
300
+ : {};
301
+ if (typeof record.file === 'string' &&
302
+ (record.projectName === undefined || typeof record.projectName === 'string') &&
303
+ (!full || typeof record.name === 'string')) {
304
+ collected.push({
305
+ file: relative(process.cwd(), record.file).split('\\').join('/'),
306
+ projectName: record.projectName ?? '',
307
+ name: typeof record.name === 'string' ? record.name : undefined,
308
+ line: typeof location.line === 'number' ? location.line : undefined,
309
+ column: typeof location.column === 'number' ? location.column : undefined,
310
+ });
311
+ }
312
+ }
313
+ cache.set(key, collected);
314
+ return collected;
315
+ }
316
+ catch (error) {
317
+ console.error(error instanceof Error ? error.message : String(error));
318
+ return undefined;
319
+ }
320
+ finally {
321
+ rmSync(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
90
322
  }
91
- return collected;
92
323
  }
93
324
  function listTestFiles(directory) {
94
325
  if (!existsSync(directory))
@@ -107,6 +338,151 @@ function countMarkers(file) {
107
338
  }
108
339
  return counts;
109
340
  }
341
+ function collectListings(config, named, gates) {
342
+ const units = [{ config, mode: undefined }];
343
+ for (const gate of gates.units) {
344
+ if (!units.some((unit) => unit.config === gate.config &&
345
+ unit.mode === gate.mode &&
346
+ unit.root === gate.root &&
347
+ unit.dir === gate.dir)) {
348
+ units.push({
349
+ config: gate.config,
350
+ mode: gate.mode,
351
+ ...(gate.root === undefined ? {} : { root: gate.root }),
352
+ ...(gate.dir === undefined ? {} : { dir: gate.dir }),
353
+ });
354
+ }
355
+ }
356
+ const listings = [];
357
+ const populated = new Set();
358
+ const cache = new Map();
359
+ for (const unit of units) {
360
+ const reached = gates.units.filter((gate) => gate.config === unit.config &&
361
+ gate.mode === unit.mode &&
362
+ gate.root === unit.root &&
363
+ gate.dir === unit.dir);
364
+ const parameters = readUnitArguments(unit);
365
+ const collected = listCollected(parameters, true, cache);
366
+ if (collected === undefined)
367
+ return undefined;
368
+ const scope = named.length === 0
369
+ ? undefined
370
+ : listCollected([...parameters, ...named.flatMap((name) => ['--project', name])], false, cache);
371
+ if (named.length > 0 && scope === undefined)
372
+ return undefined;
373
+ const selected = [];
374
+ for (const gate of reached) {
375
+ const selection = JSON.stringify(gate.args) === JSON.stringify(parameters)
376
+ ? collected
377
+ : listCollected(gate.args, gate.full, cache);
378
+ if (selection === undefined) {
379
+ console.error(`discovery: gate ${gate.chain}: vitest list refused ${gate.args.join(' ')}`);
380
+ return undefined;
381
+ }
382
+ selected.push({
383
+ gate,
384
+ identities: new Set(selection.map((entry) => JSON.stringify([
385
+ entry.file,
386
+ entry.projectName,
387
+ ...(gate.full ? [entry.name, entry.line, entry.column] : []),
388
+ ]))),
389
+ });
390
+ }
391
+ const empty = [];
392
+ for (const project of new Set([...reached.flatMap((gate) => gate.projects), ...named])) {
393
+ if (project.includes('*') || project.startsWith('!'))
394
+ continue;
395
+ if (selected.some(({ gate, identities }) => gate.projects.length === 1 && gate.projects[0] === project && identities.size > 0)) {
396
+ populated.add(project);
397
+ continue;
398
+ }
399
+ const selection = listCollected([...parameters, '--project', project], false, cache);
400
+ if (selection === undefined)
401
+ return undefined;
402
+ if (selection.length > 0)
403
+ populated.add(project);
404
+ else if ((reached.some((gate) => gate.projects.includes(project)) || named.includes(project)) &&
405
+ (named.length === 0 || named.includes(project)))
406
+ empty.push(project);
407
+ }
408
+ listings.push({
409
+ ...unit,
410
+ collected: collected
411
+ .filter((entry) => scope === undefined ||
412
+ scope.some((chosen) => chosen.file === entry.file && chosen.projectName === entry.projectName))
413
+ .map((entry) => ({
414
+ ...entry,
415
+ gate: selected.find(({ gate, identities }) => identities.has(JSON.stringify([
416
+ entry.file,
417
+ entry.projectName,
418
+ ...(gate.full ? [entry.name, entry.line, entry.column] : []),
419
+ ])))?.gate.chain,
420
+ })),
421
+ empty,
422
+ });
423
+ }
424
+ const candidates = new Set(listings.flatMap((listing) => listing.empty));
425
+ for (const name of candidates) {
426
+ if (populated.has(name))
427
+ continue;
428
+ for (const unit of units) {
429
+ const selection = listCollected([...readUnitArguments(unit), '--project', name], false, cache);
430
+ if (selection === undefined)
431
+ return undefined;
432
+ if (selection.length > 0)
433
+ populated.add(name);
434
+ }
435
+ }
436
+ return listings.map((listing) => ({
437
+ ...listing,
438
+ empty: listing.empty.filter((name) => !populated.has(name)),
439
+ }));
440
+ }
441
+ function readUnitArguments(unit) {
442
+ return [
443
+ ...(unit.config === undefined ? [] : ['--config', unit.config]),
444
+ ...(unit.mode === undefined ? [] : ['--mode', unit.mode]),
445
+ ...(unit.root === undefined ? [] : ['--root', unit.root]),
446
+ ...(unit.dir === undefined ? [] : ['--dir', unit.dir]),
447
+ ];
448
+ }
449
+ function mergeCollected(listings) {
450
+ const collected = new Map();
451
+ for (const listing of listings) {
452
+ const occurrences = new Map();
453
+ for (const entry of listing.collected) {
454
+ const identity = JSON.stringify([
455
+ entry.file,
456
+ entry.projectName,
457
+ entry.name,
458
+ entry.line,
459
+ entry.column,
460
+ ]);
461
+ const occurrence = (occurrences.get(identity) ?? 0) + 1;
462
+ occurrences.set(identity, occurrence);
463
+ const key = JSON.stringify([identity, occurrence]);
464
+ collected.set(key, { ...entry, gate: entry.gate ?? collected.get(key)?.gate });
465
+ }
466
+ }
467
+ return [...collected.values()];
468
+ }
469
+ function readSnapshot(gates) {
470
+ const bases = [
471
+ process.cwd(),
472
+ ...gates.units.flatMap((gate) => [gate.root, gate.dir].filter((path) => path !== undefined).map((path) => resolve(path))),
473
+ ];
474
+ const roots = new Set(bases.flatMap((base) => [base, join(base, 'tmp/probes')]));
475
+ const files = new Map();
476
+ for (const root of roots) {
477
+ if (!existsSync(root))
478
+ continue;
479
+ for (const file of listFiles(root)) {
480
+ if (/\.test\.[cm]?[jt]sx?$/u.test(file.path))
481
+ files.set(file.path, file.modified);
482
+ }
483
+ }
484
+ return JSON.stringify([...files].sort(([left], [right]) => left.localeCompare(right)));
485
+ }
110
486
  function main(argv) {
111
487
  const missing = readMissingFlags(argv, ['--config', '--projects']);
112
488
  if (missing.length > 0) {
@@ -122,39 +498,63 @@ function main(argv) {
122
498
  console.error(`discovery: ${VITEST} is missing; run npm ci first`);
123
499
  return 2;
124
500
  }
125
- const gates = readGates(readScripts());
126
- const collected = listCollected(config);
127
- if (collected === undefined) {
128
- console.error('discovery: vitest list failed; run it bare to read the diagnostic');
501
+ const canonical = relative(process.cwd(), resolve(config)).split('\\').join('/');
502
+ const gates = readGates(readScripts(), canonical, readCLIOptions());
503
+ const named = (readOption(argv, '--projects') ?? '').split(',').filter((name) => name !== '');
504
+ let listings;
505
+ for (let attempt = 0; attempt < 2; attempt++) {
506
+ const before = readSnapshot(gates);
507
+ listings = collectListings(canonical, named, gates);
508
+ if (before === readSnapshot(gates))
509
+ break;
510
+ listings = undefined;
511
+ if (attempt === 1)
512
+ throw new Error('Test files changed during both census attempts');
513
+ }
514
+ if (listings === undefined) {
515
+ console.error('discovery: vitest list failed');
129
516
  return 2;
130
517
  }
131
- const named = (readOption(argv, '--projects') ?? '').split(',').filter((name) => name !== '');
132
- const names = new Set([
133
- ...gates.projects.keys(),
134
- ...collected.map((entry) => entry.projectName),
135
- ...named,
136
- ]);
518
+ const collected = mergeCollected(listings);
519
+ const known = listings.flatMap((listing) => listing.empty);
520
+ const names = new Set([...known, ...collected.map((entry) => entry.projectName)]);
137
521
  const projects = [...names].sort().map((name) => {
138
522
  const mine = collected.filter((entry) => entry.projectName === name);
139
- const fileGate = mine
140
- .map((entry) => gates.files.get(entry.file))
141
- .find((gate) => gate !== undefined);
523
+ const units = listings.filter((listing) => listing.collected.some((entry) => entry.projectName === name));
524
+ const chains = mine.map((entry) => entry.gate ?? gates.files.get(entry.file));
525
+ const gate = mine.length === 0
526
+ ? (gates.units.find((unit) => unit.projects.includes(name) && unit.chain.includes(' > ')) ?? gates.units.find((unit) => unit.projects.includes(name)))?.chain
527
+ : chains.every((chain) => chain !== undefined)
528
+ ? chains[0]
529
+ : undefined;
142
530
  return {
143
531
  name,
144
- gate: gates.projects.get(name) ?? fileGate,
532
+ gate,
145
533
  files: new Set(mine.map((entry) => entry.file)).size,
146
534
  tests: mine.length,
535
+ ...(units.some((unit) => unit.config !== canonical ||
536
+ unit.mode !== undefined ||
537
+ unit.root !== undefined ||
538
+ unit.dir !== undefined)
539
+ ? {
540
+ units: units.map((unit) => ({
541
+ config: unit.config,
542
+ mode: unit.mode,
543
+ ...(unit.root === undefined ? {} : { root: unit.root }),
544
+ ...(unit.dir === undefined ? {} : { dir: unit.dir }),
545
+ })),
546
+ }
547
+ : {}),
147
548
  };
148
549
  });
149
550
  const collectedFiles = new Set(collected.map((entry) => entry.file));
150
- const undiscovered = listTestFiles('tests')
551
+ const undiscovered = (named.length === 0 ? listTestFiles('tests') : [])
151
552
  .filter((file) => !collectedFiles.has(file) && !gates.files.has(file))
152
553
  .sort();
153
554
  const ungated = projects
154
555
  .filter((project) => project.gate === undefined && project.tests > 0)
155
556
  .map((project) => project.name);
156
- // A project no chain reaches is a workbench (the `probe` project collects `tmp/probes/**`), so an
157
- // empty one is reported, never flagged.
557
+ // An empty project no chain containing ` > ` names is a workbench, reported without a flag.
158
558
  const empty = projects
159
559
  .filter((project) => project.tests === 0 && project.gate?.includes(' > ') === true)
160
560
  .map((project) => project.name);
@@ -181,7 +581,7 @@ function main(argv) {
181
581
  for (const name of empty)
182
582
  console.log(`discovery: ${name} collects nothing`);
183
583
  for (const name of workbenches)
184
- console.log(`discovery: ${name} is a workbench no chain runs; it collects nothing`);
584
+ console.log(`discovery: ${name} is an empty workbench; no chain containing " > " names it`);
185
585
  for (const file of undiscovered)
186
586
  console.log(`discovery: ${file} is collected by no project`);
187
587
  for (const entry of census) {
@@ -193,4 +593,10 @@ function main(argv) {
193
593
  }
194
594
  return flagged ? 3 : 0;
195
595
  }
196
- process.exitCode = main(process.argv.slice(2));
596
+ try {
597
+ process.exitCode = main(process.argv.slice(2));
598
+ }
599
+ catch (error) {
600
+ console.error(`discovery: ${error instanceof Error ? error.message : String(error)}`);
601
+ process.exitCode = 2;
602
+ }