@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,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,74 +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
- const start = result.stdout.indexOf('[')
101
- if (result.status !== 0 || start === -1) {
102
- const diagnostic = result.stderr || result.error?.message || result.stdout
103
- console.error(
104
- diagnostic
105
- .split(/\r\n|\n/u)
106
- .filter((line) => line.trim() !== '')
107
- .slice(0, 12)
108
- .join('\n'),
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
+ },
109
369
  )
110
- return undefined
111
- }
112
- const parsed: unknown = JSON.parse(result.stdout.slice(start))
113
- if (!Array.isArray(parsed)) return undefined
114
- const collected: Collected[] = []
115
- for (const entry of parsed) {
116
- if (typeof entry !== 'object' || entry === null) continue
117
- const record = Object.fromEntries(Object.entries(entry))
118
- if (typeof record.file === 'string' && typeof record.projectName === 'string') {
119
- collected.push({
120
- file: relative(process.cwd(), record.file).split('\\').join('/'),
121
- projectName: record.projectName,
122
- })
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 []
123
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 })
124
420
  }
125
- return collected
126
421
  }
127
422
 
128
423
  function listTestFiles(directory: string): readonly string[] {
@@ -142,6 +437,185 @@ function countMarkers(file: string): Readonly<Record<string, number>> {
142
437
  return counts
143
438
  }
144
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
+
145
619
  function main(argv: readonly string[]): number {
146
620
  const missing = readMissingFlags(argv, ['--config', '--projects'])
147
621
  if (missing.length > 0) {
@@ -157,39 +631,71 @@ function main(argv: readonly string[]): number {
157
631
  console.error(`discovery: ${VITEST} is missing; run npm ci first`)
158
632
  return 2
159
633
  }
160
- const gates = readGates(readScripts())
161
- const collected = listCollected(config)
162
- if (collected === undefined) {
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) {
163
646
  console.error('discovery: vitest list failed')
164
647
  return 2
165
648
  }
166
- const named = (readOption(argv, '--projects') ?? '').split(',').filter((name) => name !== '')
167
- const names = new Set<string>([
168
- ...gates.projects.keys(),
169
- ...collected.map((entry) => entry.projectName),
170
- ...named,
171
- ])
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)])
172
652
  const projects: Project[] = [...names].sort().map((name) => {
173
653
  const mine = collected.filter((entry) => entry.projectName === name)
174
- const fileGate = mine
175
- .map((entry) => gates.files.get(entry.file))
176
- .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
177
668
  return {
178
669
  name,
179
- gate: gates.projects.get(name) ?? fileGate,
670
+ gate,
180
671
  files: new Set(mine.map((entry) => entry.file)).size,
181
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
+ : {}),
182
689
  }
183
690
  })
184
691
  const collectedFiles = new Set(collected.map((entry) => entry.file))
185
- const undiscovered = listTestFiles('tests')
692
+ const undiscovered = (named.length === 0 ? listTestFiles('tests') : [])
186
693
  .filter((file) => !collectedFiles.has(file) && !gates.files.has(file))
187
694
  .sort()
188
695
  const ungated = projects
189
696
  .filter((project) => project.gate === undefined && project.tests > 0)
190
697
  .map((project) => project.name)
191
- // A project no chain reaches is a workbench (the `probe` project collects `tmp/probes/**`), so an
192
- // empty one is reported, never flagged.
698
+ // An empty project no chain containing ` > ` names is a workbench, reported without a flag.
193
699
  const empty = projects
194
700
  .filter((project) => project.tests === 0 && project.gate?.includes(' > ') === true)
195
701
  .map((project) => project.name)
@@ -220,7 +726,7 @@ function main(argv: readonly string[]): number {
220
726
  console.log(`discovery: ${name} collects tests but no root script chain reaches it`)
221
727
  for (const name of empty) console.log(`discovery: ${name} collects nothing`)
222
728
  for (const name of workbenches)
223
- 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`)
224
730
  for (const file of undiscovered) console.log(`discovery: ${file} is collected by no project`)
225
731
  for (const entry of census) {
226
732
  const marks = Object.entries(entry.markers)
@@ -234,4 +740,9 @@ function main(argv: readonly string[]): number {
234
740
  return flagged ? 3 : 0
235
741
  }
236
742
 
237
- 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
+ }