@orkestrel/scaffold 0.0.88 → 0.0.90

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,72 +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)
200
+ continue;
201
+ const { name, args, ancestors } = invocation;
202
+ const key = JSON.stringify([name, args]);
203
+ if (visited.has(key) || ancestors.includes(name))
44
204
  continue;
45
- visited.add(name);
46
- const text = scripts[name];
47
- if (text === undefined)
205
+ visited.add(key);
206
+ const script = commands[name];
207
+ if (script === undefined)
48
208
  continue;
49
- pending.push(...listInvocations(text));
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
- const start = result.stdout.indexOf('[');
72
- if (result.status !== 0 || start === -1) {
73
- const diagnostic = result.stderr || result.error?.message || result.stdout;
74
- console.error(diagnostic
75
- .split(/\r\n|\n/u)
76
- .filter((line) => line.trim() !== '')
77
- .slice(0, 12)
78
- .join('\n'));
79
- return undefined;
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 [];
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;
80
315
  }
81
- const parsed = JSON.parse(result.stdout.slice(start));
82
- if (!Array.isArray(parsed))
316
+ catch (error) {
317
+ console.error(error instanceof Error ? error.message : String(error));
83
318
  return undefined;
84
- const collected = [];
85
- for (const entry of parsed) {
86
- if (typeof entry !== 'object' || entry === null)
87
- continue;
88
- const record = Object.fromEntries(Object.entries(entry));
89
- if (typeof record.file === 'string' && typeof record.projectName === 'string') {
90
- collected.push({
91
- file: relative(process.cwd(), record.file).split('\\').join('/'),
92
- projectName: record.projectName,
93
- });
94
- }
95
319
  }
96
- return collected;
320
+ finally {
321
+ rmSync(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
322
+ }
97
323
  }
98
324
  function listTestFiles(directory) {
99
325
  if (!existsSync(directory))
@@ -112,6 +338,151 @@ function countMarkers(file) {
112
338
  }
113
339
  return counts;
114
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
+ }
115
486
  function main(argv) {
116
487
  const missing = readMissingFlags(argv, ['--config', '--projects']);
117
488
  if (missing.length > 0) {
@@ -127,39 +498,63 @@ function main(argv) {
127
498
  console.error(`discovery: ${VITEST} is missing; run npm ci first`);
128
499
  return 2;
129
500
  }
130
- const gates = readGates(readScripts());
131
- const collected = listCollected(config);
132
- if (collected === undefined) {
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) {
133
515
  console.error('discovery: vitest list failed');
134
516
  return 2;
135
517
  }
136
- const named = (readOption(argv, '--projects') ?? '').split(',').filter((name) => name !== '');
137
- const names = new Set([
138
- ...gates.projects.keys(),
139
- ...collected.map((entry) => entry.projectName),
140
- ...named,
141
- ]);
518
+ const collected = mergeCollected(listings);
519
+ const known = listings.flatMap((listing) => listing.empty);
520
+ const names = new Set([...known, ...collected.map((entry) => entry.projectName)]);
142
521
  const projects = [...names].sort().map((name) => {
143
522
  const mine = collected.filter((entry) => entry.projectName === name);
144
- const fileGate = mine
145
- .map((entry) => gates.files.get(entry.file))
146
- .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;
147
530
  return {
148
531
  name,
149
- gate: gates.projects.get(name) ?? fileGate,
532
+ gate,
150
533
  files: new Set(mine.map((entry) => entry.file)).size,
151
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
+ : {}),
152
548
  };
153
549
  });
154
550
  const collectedFiles = new Set(collected.map((entry) => entry.file));
155
- const undiscovered = listTestFiles('tests')
551
+ const undiscovered = (named.length === 0 ? listTestFiles('tests') : [])
156
552
  .filter((file) => !collectedFiles.has(file) && !gates.files.has(file))
157
553
  .sort();
158
554
  const ungated = projects
159
555
  .filter((project) => project.gate === undefined && project.tests > 0)
160
556
  .map((project) => project.name);
161
- // A project no chain reaches is a workbench (the `probe` project collects `tmp/probes/**`), so an
162
- // empty one is reported, never flagged.
557
+ // An empty project no chain containing ` > ` names is a workbench, reported without a flag.
163
558
  const empty = projects
164
559
  .filter((project) => project.tests === 0 && project.gate?.includes(' > ') === true)
165
560
  .map((project) => project.name);
@@ -186,7 +581,7 @@ function main(argv) {
186
581
  for (const name of empty)
187
582
  console.log(`discovery: ${name} collects nothing`);
188
583
  for (const name of workbenches)
189
- 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`);
190
585
  for (const file of undiscovered)
191
586
  console.log(`discovery: ${file} is collected by no project`);
192
587
  for (const entry of census) {
@@ -198,4 +593,10 @@ function main(argv) {
198
593
  }
199
594
  return flagged ? 3 : 0;
200
595
  }
201
- 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
+ }