@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,17 +1,23 @@
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 {
16
22
  listFiles,
17
23
  readMissingFlags,
@@ -20,22 +26,74 @@ import {
20
26
 
21
27
  const VITEST = 'node_modules/vitest/vitest.mjs'
22
28
  const MARKERS: readonly string[] = ['.skip(', '.todo(', '.skipIf(', '.runIf(', 'retry:', 'timeout:']
29
+ const OMITTED = new Set([
30
+ 'reporter',
31
+ 'outputFile',
32
+ 'coverage',
33
+ 'color',
34
+ 'cache',
35
+ 'silent',
36
+ 'ui',
37
+ 'watch',
38
+ 'run',
39
+ 'inspect',
40
+ 'inspectBrk',
41
+ 'standalone',
42
+ ])
43
+ // Bounds one listing, so an option that waits for input exits 2 naming its gate instead of
44
+ // hanging the census.
45
+ const LISTING_TIMEOUT = 600_000
46
+
47
+ interface Option {
48
+ readonly name: string
49
+ readonly argument: string | undefined
50
+ }
23
51
 
24
52
  interface Collected {
25
53
  readonly file: string
26
54
  readonly projectName: string
55
+ readonly name: string | undefined
56
+ readonly line: number | undefined
57
+ readonly column: number | undefined
58
+ readonly gate?: string | undefined
27
59
  }
28
60
 
29
61
  interface Gates {
30
- readonly projects: ReadonlyMap<string, string>
62
+ readonly units: readonly Gate[]
31
63
  readonly files: ReadonlyMap<string, string>
32
64
  }
33
65
 
66
+ interface Unit {
67
+ readonly config: string | undefined
68
+ readonly mode: string | undefined
69
+ readonly root?: string | undefined
70
+ readonly dir?: string | undefined
71
+ }
72
+
73
+ interface Gate extends Unit {
74
+ readonly projects: readonly string[]
75
+ readonly chain: string
76
+ readonly args: readonly string[]
77
+ readonly full: boolean
78
+ }
79
+
80
+ interface Listing extends Unit {
81
+ readonly collected: readonly Collected[]
82
+ readonly empty: readonly string[]
83
+ }
84
+
85
+ interface Invocation {
86
+ readonly name: string
87
+ readonly args: readonly string[]
88
+ readonly ancestors: readonly string[]
89
+ }
90
+
34
91
  interface Project {
35
92
  readonly name: string
36
93
  readonly gate: string | undefined
37
94
  readonly files: number
38
95
  readonly tests: number
96
+ readonly units?: readonly Unit[]
39
97
  }
40
98
 
41
99
  interface Census {
@@ -44,6 +102,110 @@ interface Census {
44
102
  readonly markers: Readonly<Record<string, number>>
45
103
  }
46
104
 
105
+ function readCLIOptions(): ReadonlyMap<string, Option> {
106
+ const help = spawnSync(process.execPath, [VITEST, 'list', '--help', '--expand-help'], {
107
+ encoding: 'utf8',
108
+ windowsHide: true,
109
+ })
110
+ if (help.status !== 0) throw new Error(help.stderr || 'Cannot read Vitest CLI options')
111
+ const options = new Map<string, Option>()
112
+ for (const line of help.stdout.split(/\r\n|\n/u)) {
113
+ const match = /^\s+(?:(-\w), )?--([\w.-]+)(?: ([<[][^\n]*?[>\]]))?\s{2}/u.exec(line)
114
+ const name = match?.[2]
115
+ if (name === undefined) continue
116
+ const option = { name: name.replace(/^no-/u, ''), argument: match?.[3] }
117
+ options.set(`--${name}`, option)
118
+ if (name.startsWith('no-')) options.set(`--${name.slice(3)}`, option)
119
+ options.set(`--${name.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)}`, option)
120
+ if (match?.[1] !== undefined) options.set(match[1], option)
121
+ }
122
+ return options
123
+ }
124
+
125
+ function findRunner(command: readonly string[]): number {
126
+ let index = 0
127
+ const executable = basename(command[0] ?? '').replace(/\.(?:exe|cmd)$/u, '')
128
+ if (executable === 'npm') {
129
+ if (command[1] !== 'exec') return -1
130
+ index = 2
131
+ } else if (executable === 'node' || executable === 'npx') index = 1
132
+ if (index > 0) {
133
+ while (command[index]?.startsWith('-')) {
134
+ const option = command[index]
135
+ if (['-e', '--eval', '-p', '--print', '-c', '--check'].includes(option ?? '')) return -1
136
+ index += [
137
+ '-r',
138
+ '--require',
139
+ '--import',
140
+ '--loader',
141
+ '--experimental-loader',
142
+ '--max-old-space-size',
143
+ '--max_old_space_size',
144
+ '-p',
145
+ '--package',
146
+ ].includes(option ?? '')
147
+ ? 2
148
+ : 1
149
+ }
150
+ }
151
+ return ['vitest', 'vitest.mjs', 'vitest.js'].includes(
152
+ basename(command[index] ?? '').replace(/\.cmd$/u, ''),
153
+ )
154
+ ? index
155
+ : -1
156
+ }
157
+
158
+ function readSelection(
159
+ argv: readonly string[],
160
+ options: ReadonlyMap<string, Option>,
161
+ ): readonly string[] {
162
+ const selected: string[] = []
163
+ for (let index = 0; index < argv.length; index++) {
164
+ const token = argv[index]
165
+ if (token === undefined) continue
166
+ if (token === '--') {
167
+ selected.push(...argv.slice(index))
168
+ break
169
+ }
170
+ if (!token.startsWith('-')) {
171
+ selected.push(token)
172
+ continue
173
+ }
174
+ const equals = token.indexOf('=')
175
+ const flag = equals < 0 ? token : token.slice(0, equals)
176
+ const positive = flag.replace(/^--no-/u, '--')
177
+ const option = options.get(positive) ?? options.get(positive.split('.').slice(0, 1).join('.'))
178
+ if (option === undefined || !OMITTED.has(option.name.split('.')[0] ?? '')) {
179
+ selected.push(token)
180
+ continue
181
+ }
182
+ const next = argv[index + 1]
183
+ if (
184
+ !flag.startsWith('--no-') &&
185
+ equals < 0 &&
186
+ next !== undefined &&
187
+ !next.startsWith('-') &&
188
+ (option.argument !== undefined || next === 'true' || next === 'false')
189
+ )
190
+ index++
191
+ }
192
+ return selected
193
+ }
194
+
195
+ function readArguments(argv: readonly string[], names: readonly string[]): readonly string[] {
196
+ const values: string[] = []
197
+ for (let index = 0; index < argv.length; index++) {
198
+ const token = argv[index]
199
+ if (token === '--') break
200
+ if (token === undefined) continue
201
+ const equals = token.indexOf('=')
202
+ if (!names.includes(equals < 0 ? token : token.slice(0, equals))) continue
203
+ const value = equals < 0 ? argv[index + 1] : token.slice(equals + 1)
204
+ if (value !== undefined && !value.startsWith('-')) values.push(value)
205
+ }
206
+ return values
207
+ }
208
+
47
209
  function readScripts(): Readonly<Record<string, string>> {
48
210
  const parsed: unknown = JSON.parse(readFileSync('package.json', 'utf8'))
49
211
  if (typeof parsed !== 'object' || parsed === null) return {}
@@ -55,65 +217,207 @@ function readScripts(): Readonly<Record<string, string>> {
55
217
  return record
56
218
  }
57
219
 
58
- function listInvocations(text: string): readonly string[] {
59
- return [...text.matchAll(/npm run ([A-Za-z0-9:_-]+)/gu)].flatMap((match) =>
60
- match[1] === undefined ? [] : [match[1]],
61
- )
220
+ function readCommands(text: string): ReadonlyArray<readonly string[]> {
221
+ const commands: string[][] = []
222
+ let command: string[] = []
223
+ for (const match of text.matchAll(/(?:[^\s"';&|]+|"[^"]*"|'[^']*')+|[;&|]+/gu)) {
224
+ if (/^[;&|]+$/u.test(match[0])) {
225
+ commands.push(command)
226
+ command = []
227
+ } else {
228
+ command.push(match[0].replace(/["']/gu, ''))
229
+ }
230
+ }
231
+ commands.push(command)
232
+ return commands
233
+ }
234
+
235
+ function listInvocations(commands: ReadonlyArray<readonly string[]>): readonly Invocation[] {
236
+ const invoked: Invocation[] = []
237
+ for (const command of commands) {
238
+ const index = command.indexOf('npm')
239
+ const name = command[index + 2]
240
+ if (index < 0 || command[index + 1] !== 'run' || name === undefined) continue
241
+ const separator = command.indexOf('--', index + 3)
242
+ invoked.push({ name, args: separator < 0 ? [] : command.slice(separator + 1), ancestors: [] })
243
+ }
244
+ return invoked
62
245
  }
63
246
 
64
- function readGates(scripts: Readonly<Record<string, string>>): Gates {
65
- const invoked = new Set(Object.values(scripts).flatMap(listInvocations))
66
- const projects = new Map<string, string>()
247
+ function readGates(
248
+ scripts: Readonly<Record<string, string>>,
249
+ config: string,
250
+ options: ReadonlyMap<string, Option>,
251
+ ): Gates {
252
+ const commands = Object.fromEntries(
253
+ Object.entries(scripts).map(([name, text]) => [name, readCommands(text)]),
254
+ )
255
+ const invoked = new Set(
256
+ Object.values(commands)
257
+ .flatMap(listInvocations)
258
+ .map((entry) => entry.name),
259
+ )
260
+ const units: Gate[] = []
67
261
  const files = new Map<string, string>()
68
262
  for (const root of Object.keys(scripts).filter((name) => !invoked.has(name))) {
69
263
  const visited = new Set<string>()
70
- const pending = [root]
264
+ const pending: Invocation[] = [{ name: root, args: [], ancestors: [] }]
71
265
  while (pending.length > 0) {
72
- const name = pending.pop()
73
- if (name === undefined || visited.has(name)) continue
74
- visited.add(name)
75
- const text = scripts[name]
76
- if (text === undefined) continue
77
- pending.push(...listInvocations(text))
266
+ const invocation = pending.pop()
267
+ if (invocation === undefined) continue
268
+ const { name, args, ancestors } = invocation
269
+ const key = JSON.stringify([name, args])
270
+ if (visited.has(key) || ancestors.includes(name)) continue
271
+ visited.add(key)
272
+ const script = commands[name]
273
+ if (script === undefined) continue
274
+ const expanded = script.map((command, index) =>
275
+ index === script.length - 1 ? [...command, ...args] : command,
276
+ )
277
+ pending.push(
278
+ ...listInvocations(expanded).map((entry) => ({
279
+ ...entry,
280
+ ancestors: [...ancestors, name],
281
+ })),
282
+ )
78
283
  const gate = root === name ? name : `${root} > ${name}`
79
- if (/\bvitest\b/u.test(text)) {
80
- for (const match of text.matchAll(/--project[= ]([A-Za-z0-9:_-]+)/gu)) {
81
- if (match[1] !== undefined && !projects.has(match[1])) projects.set(match[1], gate)
284
+ for (const command of expanded) {
285
+ const index = findRunner(command)
286
+ if (index >= 0) {
287
+ const verb = command[index + 1]
288
+ if (verb === 'list' || verb === 'bench') continue
289
+ const tokens = command.slice(
290
+ index + (['run', 'watch', 'dev'].includes(verb ?? '') ? 2 : 1),
291
+ )
292
+ const separator = tokens.indexOf('--')
293
+ const before = separator < 0 ? tokens : tokens.slice(0, separator)
294
+ if (
295
+ before.some(
296
+ (token, position) =>
297
+ /^(?:--mergeReports|--merge-reports|--listTags|--list-tags|--clearCache|--clear-cache)(?:=|$)/u.test(
298
+ token,
299
+ ) &&
300
+ !token.endsWith('=false') &&
301
+ before[position + 1] !== 'false',
302
+ )
303
+ )
304
+ continue
305
+ if (verb === 'related')
306
+ throw new Error(`${gate}: Vitest list exposes no related command or --related option`)
307
+ const flags = readSelection(tokens, options)
308
+ const mode = readArguments(flags, ['--mode'])[0]
309
+ const directory = readArguments(flags, ['--root', '-r'])[0]
310
+ const dir = readArguments(flags, ['--dir'])[0]
311
+ const configured = readArguments(flags, ['--config', '-c'])[0]
312
+ const path =
313
+ configured ?? (directory === undefined && dir === undefined ? config : undefined)
314
+ units.push({
315
+ config:
316
+ path === undefined
317
+ ? undefined
318
+ : relative(process.cwd(), resolve(path)).split('\\').join('/'),
319
+ mode: mode === 'test' ? undefined : mode,
320
+ ...(directory === undefined ? {} : { root: directory }),
321
+ ...(dir === undefined ? {} : { dir }),
322
+ projects: readArguments(flags, ['--project']),
323
+ chain: gate,
324
+ args:
325
+ configured === undefined && path !== undefined ? ['--config', path, ...flags] : flags,
326
+ full: before.some(
327
+ (flag) =>
328
+ /^(?:-t|--testNamePattern|--test-name-pattern|--tagsFilter|--tags-filter|--shard|--(?:no-)?allowOnly|--(?:no-)?allow-only|--(?:no-)?strictTags|--(?:no-)?strict-tags)(?:=|$)/u.test(
329
+ flag,
330
+ ) || /:\d+(?:-\d+)?$/u.test(flag),
331
+ ),
332
+ })
333
+ continue
334
+ }
335
+ for (const argument of command) {
336
+ if (
337
+ /^(?:tests|src|app)\/[A-Za-z0-9_./-]+\.test\.[cm]?ts$/u.test(argument) &&
338
+ !files.has(argument)
339
+ )
340
+ files.set(argument, gate)
82
341
  }
83
- }
84
- for (const match of text.matchAll(
85
- /(?:^|\s)((?:tests|src|app)\/[A-Za-z0-9_./-]+\.test\.[cm]?ts)\b/gu,
86
- )) {
87
- if (match[1] !== undefined && !files.has(match[1])) files.set(match[1], gate)
88
342
  }
89
343
  }
90
344
  }
91
- return { projects, files }
345
+ return { units, files }
92
346
  }
93
347
 
94
- function listCollected(config: string): readonly Collected[] | undefined {
95
- const result = spawnSync(process.execPath, [VITEST, 'list', '--config', config, '--json'], {
96
- encoding: 'utf8',
97
- maxBuffer: 64 * 1024 * 1024,
98
- windowsHide: true,
99
- })
100
- if (result.status !== 0) return undefined
101
- const start = result.stdout.indexOf('[')
102
- if (start === -1) return undefined
103
- const parsed: unknown = JSON.parse(result.stdout.slice(start))
104
- if (!Array.isArray(parsed)) return undefined
105
- const collected: Collected[] = []
106
- for (const entry of parsed) {
107
- if (typeof entry !== 'object' || entry === null) continue
108
- const record = Object.fromEntries(Object.entries(entry))
109
- if (typeof record.file === 'string' && typeof record.projectName === 'string') {
110
- collected.push({
111
- file: relative(process.cwd(), record.file).split('\\').join('/'),
112
- projectName: record.projectName,
113
- })
348
+ function listCollected(
349
+ args: readonly string[],
350
+ full: boolean,
351
+ cache: Map<string, readonly Collected[]>,
352
+ ): readonly Collected[] | undefined {
353
+ const parameters = [...(full ? ['--includeTaskLocation'] : ['--filesOnly']), ...args]
354
+ const key = JSON.stringify(parameters)
355
+ const cached = cache.get(key)
356
+ if (cached !== undefined) return cached
357
+ const directory = mkdtempSync(join(tmpdir(), 'discovery-'))
358
+ const listing = join(directory, 'listing.json')
359
+ try {
360
+ const result = spawnSync(
361
+ process.execPath,
362
+ [VITEST, 'list', `--json=${listing}`, ...parameters],
363
+ {
364
+ encoding: 'utf8',
365
+ maxBuffer: 64 * 1024 * 1024,
366
+ timeout: LISTING_TIMEOUT,
367
+ windowsHide: true,
368
+ },
369
+ )
370
+ if (
371
+ args.includes('--project') &&
372
+ result.status === 1 &&
373
+ result.stderr.includes('No projects matched the filter "')
374
+ ) {
375
+ cache.set(key, [])
376
+ return []
114
377
  }
378
+ if (result.status !== 0 || !existsSync(listing)) {
379
+ const diagnostic = result.stderr || result.error?.message || result.stdout
380
+ console.error(
381
+ diagnostic
382
+ .split(/\r\n|\n/u)
383
+ .filter((line) => line.trim() !== '')
384
+ .slice(0, 12)
385
+ .join('\n'),
386
+ )
387
+ return undefined
388
+ }
389
+ const parsed: unknown = JSON.parse(readFileSync(listing, 'utf8'))
390
+ if (!Array.isArray(parsed)) return undefined
391
+ const collected: Collected[] = []
392
+ for (const entry of parsed) {
393
+ if (typeof entry !== 'object' || entry === null) continue
394
+ const record = Object.fromEntries(Object.entries(entry))
395
+ const location =
396
+ typeof record.location === 'object' && record.location !== null
397
+ ? Object.fromEntries(Object.entries(record.location))
398
+ : {}
399
+ if (
400
+ typeof record.file === 'string' &&
401
+ (record.projectName === undefined || typeof record.projectName === 'string') &&
402
+ (!full || typeof record.name === 'string')
403
+ ) {
404
+ collected.push({
405
+ file: relative(process.cwd(), record.file).split('\\').join('/'),
406
+ projectName: record.projectName ?? '',
407
+ name: typeof record.name === 'string' ? record.name : undefined,
408
+ line: typeof location.line === 'number' ? location.line : undefined,
409
+ column: typeof location.column === 'number' ? location.column : undefined,
410
+ })
411
+ }
412
+ }
413
+ cache.set(key, collected)
414
+ return collected
415
+ } catch (error) {
416
+ console.error(error instanceof Error ? error.message : String(error))
417
+ return undefined
418
+ } finally {
419
+ rmSync(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })
115
420
  }
116
- return collected
117
421
  }
118
422
 
119
423
  function listTestFiles(directory: string): readonly string[] {
@@ -133,6 +437,185 @@ function countMarkers(file: string): Readonly<Record<string, number>> {
133
437
  return counts
134
438
  }
135
439
 
440
+ function collectListings(
441
+ config: string,
442
+ named: readonly string[],
443
+ gates: Gates,
444
+ ): readonly Listing[] | undefined {
445
+ const units: Unit[] = [{ config, mode: undefined }]
446
+ for (const gate of gates.units) {
447
+ if (
448
+ !units.some(
449
+ (unit) =>
450
+ unit.config === gate.config &&
451
+ unit.mode === gate.mode &&
452
+ unit.root === gate.root &&
453
+ unit.dir === gate.dir,
454
+ )
455
+ ) {
456
+ units.push({
457
+ config: gate.config,
458
+ mode: gate.mode,
459
+ ...(gate.root === undefined ? {} : { root: gate.root }),
460
+ ...(gate.dir === undefined ? {} : { dir: gate.dir }),
461
+ })
462
+ }
463
+ }
464
+ const listings: Listing[] = []
465
+ const populated = new Set<string>()
466
+ const cache = new Map<string, readonly Collected[]>()
467
+ for (const unit of units) {
468
+ const reached = gates.units.filter(
469
+ (gate) =>
470
+ gate.config === unit.config &&
471
+ gate.mode === unit.mode &&
472
+ gate.root === unit.root &&
473
+ gate.dir === unit.dir,
474
+ )
475
+ const parameters = readUnitArguments(unit)
476
+ const collected = listCollected(parameters, true, cache)
477
+ if (collected === undefined) return undefined
478
+ const scope =
479
+ named.length === 0
480
+ ? undefined
481
+ : listCollected(
482
+ [...parameters, ...named.flatMap((name) => ['--project', name])],
483
+ false,
484
+ cache,
485
+ )
486
+ if (named.length > 0 && scope === undefined) return undefined
487
+ const selected: Array<{ readonly gate: Gate; readonly identities: ReadonlySet<string> }> = []
488
+ for (const gate of reached) {
489
+ const selection =
490
+ JSON.stringify(gate.args) === JSON.stringify(parameters)
491
+ ? collected
492
+ : listCollected(gate.args, gate.full, cache)
493
+ if (selection === undefined) {
494
+ console.error(`discovery: gate ${gate.chain}: vitest list refused ${gate.args.join(' ')}`)
495
+ return undefined
496
+ }
497
+ selected.push({
498
+ gate,
499
+ identities: new Set(
500
+ selection.map((entry) =>
501
+ JSON.stringify([
502
+ entry.file,
503
+ entry.projectName,
504
+ ...(gate.full ? [entry.name, entry.line, entry.column] : []),
505
+ ]),
506
+ ),
507
+ ),
508
+ })
509
+ }
510
+ const empty: string[] = []
511
+ for (const project of new Set([...reached.flatMap((gate) => gate.projects), ...named])) {
512
+ if (project.includes('*') || project.startsWith('!')) continue
513
+ if (
514
+ selected.some(
515
+ ({ gate, identities }) =>
516
+ gate.projects.length === 1 && gate.projects[0] === project && identities.size > 0,
517
+ )
518
+ ) {
519
+ populated.add(project)
520
+ continue
521
+ }
522
+ const selection = listCollected([...parameters, '--project', project], false, cache)
523
+ if (selection === undefined) return undefined
524
+ if (selection.length > 0) populated.add(project)
525
+ else if (
526
+ (reached.some((gate) => gate.projects.includes(project)) || named.includes(project)) &&
527
+ (named.length === 0 || named.includes(project))
528
+ )
529
+ empty.push(project)
530
+ }
531
+ listings.push({
532
+ ...unit,
533
+ collected: collected
534
+ .filter(
535
+ (entry) =>
536
+ scope === undefined ||
537
+ scope.some(
538
+ (chosen) => chosen.file === entry.file && chosen.projectName === entry.projectName,
539
+ ),
540
+ )
541
+ .map((entry) => ({
542
+ ...entry,
543
+ gate: selected.find(({ gate, identities }) =>
544
+ identities.has(
545
+ JSON.stringify([
546
+ entry.file,
547
+ entry.projectName,
548
+ ...(gate.full ? [entry.name, entry.line, entry.column] : []),
549
+ ]),
550
+ ),
551
+ )?.gate.chain,
552
+ })),
553
+ empty,
554
+ })
555
+ }
556
+ const candidates = new Set(listings.flatMap((listing) => listing.empty))
557
+ for (const name of candidates) {
558
+ if (populated.has(name)) continue
559
+ for (const unit of units) {
560
+ const selection = listCollected([...readUnitArguments(unit), '--project', name], false, cache)
561
+ if (selection === undefined) return undefined
562
+ if (selection.length > 0) populated.add(name)
563
+ }
564
+ }
565
+ return listings.map((listing) => ({
566
+ ...listing,
567
+ empty: listing.empty.filter((name) => !populated.has(name)),
568
+ }))
569
+ }
570
+
571
+ function readUnitArguments(unit: Unit): readonly string[] {
572
+ return [
573
+ ...(unit.config === undefined ? [] : ['--config', unit.config]),
574
+ ...(unit.mode === undefined ? [] : ['--mode', unit.mode]),
575
+ ...(unit.root === undefined ? [] : ['--root', unit.root]),
576
+ ...(unit.dir === undefined ? [] : ['--dir', unit.dir]),
577
+ ]
578
+ }
579
+
580
+ function mergeCollected(listings: readonly Listing[]): readonly Collected[] {
581
+ const collected = new Map<string, Collected>()
582
+ for (const listing of listings) {
583
+ const occurrences = new Map<string, number>()
584
+ for (const entry of listing.collected) {
585
+ const identity = JSON.stringify([
586
+ entry.file,
587
+ entry.projectName,
588
+ entry.name,
589
+ entry.line,
590
+ entry.column,
591
+ ])
592
+ const occurrence = (occurrences.get(identity) ?? 0) + 1
593
+ occurrences.set(identity, occurrence)
594
+ const key = JSON.stringify([identity, occurrence])
595
+ collected.set(key, { ...entry, gate: entry.gate ?? collected.get(key)?.gate })
596
+ }
597
+ }
598
+ return [...collected.values()]
599
+ }
600
+
601
+ function readSnapshot(gates: Gates): string {
602
+ const bases = [
603
+ process.cwd(),
604
+ ...gates.units.flatMap((gate) =>
605
+ [gate.root, gate.dir].filter((path) => path !== undefined).map((path) => resolve(path)),
606
+ ),
607
+ ]
608
+ const roots = new Set(bases.flatMap((base) => [base, join(base, 'tmp/probes')]))
609
+ const files = new Map<string, number>()
610
+ for (const root of roots) {
611
+ if (!existsSync(root)) continue
612
+ for (const file of listFiles(root)) {
613
+ if (/\.test\.[cm]?[jt]sx?$/u.test(file.path)) files.set(file.path, file.modified)
614
+ }
615
+ }
616
+ return JSON.stringify([...files].sort(([left], [right]) => left.localeCompare(right)))
617
+ }
618
+
136
619
  function main(argv: readonly string[]): number {
137
620
  const missing = readMissingFlags(argv, ['--config', '--projects'])
138
621
  if (missing.length > 0) {
@@ -148,39 +631,71 @@ function main(argv: readonly string[]): number {
148
631
  console.error(`discovery: ${VITEST} is missing; run npm ci first`)
149
632
  return 2
150
633
  }
151
- const gates = readGates(readScripts())
152
- const collected = listCollected(config)
153
- if (collected === undefined) {
154
- console.error('discovery: vitest list failed; run it bare to read the diagnostic')
634
+ const canonical = relative(process.cwd(), resolve(config)).split('\\').join('/')
635
+ const gates = readGates(readScripts(), canonical, readCLIOptions())
636
+ const named = (readOption(argv, '--projects') ?? '').split(',').filter((name) => name !== '')
637
+ let listings: readonly Listing[] | undefined
638
+ for (let attempt = 0; attempt < 2; attempt++) {
639
+ const before = readSnapshot(gates)
640
+ listings = collectListings(canonical, named, gates)
641
+ if (before === readSnapshot(gates)) break
642
+ listings = undefined
643
+ if (attempt === 1) throw new Error('Test files changed during both census attempts')
644
+ }
645
+ if (listings === undefined) {
646
+ console.error('discovery: vitest list failed')
155
647
  return 2
156
648
  }
157
- const named = (readOption(argv, '--projects') ?? '').split(',').filter((name) => name !== '')
158
- const names = new Set<string>([
159
- ...gates.projects.keys(),
160
- ...collected.map((entry) => entry.projectName),
161
- ...named,
162
- ])
649
+ const collected = mergeCollected(listings)
650
+ const known = listings.flatMap((listing) => listing.empty)
651
+ const names = new Set<string>([...known, ...collected.map((entry) => entry.projectName)])
163
652
  const projects: Project[] = [...names].sort().map((name) => {
164
653
  const mine = collected.filter((entry) => entry.projectName === name)
165
- const fileGate = mine
166
- .map((entry) => gates.files.get(entry.file))
167
- .find((gate) => gate !== undefined)
654
+ const units = listings.filter((listing) =>
655
+ listing.collected.some((entry) => entry.projectName === name),
656
+ )
657
+ const chains = mine.map((entry) => entry.gate ?? gates.files.get(entry.file))
658
+ const gate =
659
+ mine.length === 0
660
+ ? (
661
+ gates.units.find(
662
+ (unit) => unit.projects.includes(name) && unit.chain.includes(' > '),
663
+ ) ?? gates.units.find((unit) => unit.projects.includes(name))
664
+ )?.chain
665
+ : chains.every((chain) => chain !== undefined)
666
+ ? chains[0]
667
+ : undefined
168
668
  return {
169
669
  name,
170
- gate: gates.projects.get(name) ?? fileGate,
670
+ gate,
171
671
  files: new Set(mine.map((entry) => entry.file)).size,
172
672
  tests: mine.length,
673
+ ...(units.some(
674
+ (unit) =>
675
+ unit.config !== canonical ||
676
+ unit.mode !== undefined ||
677
+ unit.root !== undefined ||
678
+ unit.dir !== undefined,
679
+ )
680
+ ? {
681
+ units: units.map((unit) => ({
682
+ config: unit.config,
683
+ mode: unit.mode,
684
+ ...(unit.root === undefined ? {} : { root: unit.root }),
685
+ ...(unit.dir === undefined ? {} : { dir: unit.dir }),
686
+ })),
687
+ }
688
+ : {}),
173
689
  }
174
690
  })
175
691
  const collectedFiles = new Set(collected.map((entry) => entry.file))
176
- const undiscovered = listTestFiles('tests')
692
+ const undiscovered = (named.length === 0 ? listTestFiles('tests') : [])
177
693
  .filter((file) => !collectedFiles.has(file) && !gates.files.has(file))
178
694
  .sort()
179
695
  const ungated = projects
180
696
  .filter((project) => project.gate === undefined && project.tests > 0)
181
697
  .map((project) => project.name)
182
- // A project no chain reaches is a workbench (the `probe` project collects `tmp/probes/**`), so an
183
- // empty one is reported, never flagged.
698
+ // An empty project no chain containing ` > ` names is a workbench, reported without a flag.
184
699
  const empty = projects
185
700
  .filter((project) => project.tests === 0 && project.gate?.includes(' > ') === true)
186
701
  .map((project) => project.name)
@@ -211,7 +726,7 @@ function main(argv: readonly string[]): number {
211
726
  console.log(`discovery: ${name} collects tests but no root script chain reaches it`)
212
727
  for (const name of empty) console.log(`discovery: ${name} collects nothing`)
213
728
  for (const name of workbenches)
214
- console.log(`discovery: ${name} is a workbench no chain runs; it collects nothing`)
729
+ console.log(`discovery: ${name} is an empty workbench; no chain containing " > " names it`)
215
730
  for (const file of undiscovered) console.log(`discovery: ${file} is collected by no project`)
216
731
  for (const entry of census) {
217
732
  const marks = Object.entries(entry.markers)
@@ -225,4 +740,9 @@ function main(argv: readonly string[]): number {
225
740
  return flagged ? 3 : 0
226
741
  }
227
742
 
228
- process.exitCode = main(process.argv.slice(2))
743
+ try {
744
+ process.exitCode = main(process.argv.slice(2))
745
+ } catch (error) {
746
+ console.error(`discovery: ${error instanceof Error ? error.message : String(error)}`)
747
+ process.exitCode = 2
748
+ }