@orkestrel/scaffold 0.0.101 → 0.0.102

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.
Files changed (58) hide show
  1. package/dist/agents/skills/orkestrel-dispatch/scripts/brief.js +6 -14
  2. package/dist/agents/skills/orkestrel-dispatch/scripts/cite.js +41 -28
  3. package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +5 -12
  4. package/dist/agents/skills/orkestrel-dispatch/scripts/launch.js +25 -13
  5. package/dist/agents/skills/orkestrel-dispatch/scripts/login.js +18 -25
  6. package/dist/agents/skills/orkestrel-dispatch/scripts/result.js +11 -9
  7. package/dist/agents/skills/orkestrel-harden/scripts/discovery.js +233 -232
  8. package/dist/agents/skills/orkestrel-publish/scripts/compare.js +95 -89
  9. package/dist/agents/skills/orkestrel-publish/scripts/pins.js +5 -7
  10. package/dist/agents/skills/orkestrel-publish/scripts/swap.js +1273 -0
  11. package/dist/agents/skills/orkestrel-publish/scripts/wave.js +99 -89
  12. package/dist/agents/skills/orkestrel-publish/scripts/window.js +54 -52
  13. package/dist/agents/skills/orkestrel-scout/scripts/map.js +160 -161
  14. package/dist/bin/main.js +483 -444
  15. package/dist/bin/main.js.map +1 -1
  16. package/dist/host/AGENTS.md +1 -0
  17. package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +10 -10
  18. package/dist/host/agents/skills/orkestrel-dispatch/scripts/brief.ts +8 -11
  19. package/dist/host/agents/skills/orkestrel-dispatch/scripts/cite.ts +58 -28
  20. package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +5 -10
  21. package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +31 -12
  22. package/dist/host/agents/skills/orkestrel-dispatch/scripts/login.ts +28 -23
  23. package/dist/host/agents/skills/orkestrel-dispatch/scripts/result.ts +12 -10
  24. package/dist/host/agents/skills/orkestrel-harden/scripts/discovery.ts +318 -237
  25. package/dist/host/agents/skills/orkestrel-journey/references/statechart.md +2 -5
  26. package/dist/host/agents/skills/orkestrel-publish/SKILL.md +7 -6
  27. package/dist/host/agents/skills/orkestrel-publish/references/release.md +12 -10
  28. package/dist/host/agents/skills/orkestrel-publish/references/wave.md +12 -5
  29. package/dist/host/agents/skills/orkestrel-publish/scripts/compare.ts +124 -82
  30. package/dist/host/agents/skills/orkestrel-publish/scripts/pins.ts +5 -7
  31. package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +1655 -0
  32. package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +128 -97
  33. package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +70 -55
  34. package/dist/host/agents/skills/orkestrel-scout/scripts/map.ts +215 -155
  35. package/dist/host/claude/rules/architecture.md +2 -2
  36. package/dist/host/claude/rules/tests.md +8 -2
  37. package/dist/host/claude/rules/typescript.md +130 -0
  38. package/dist/host/claude/rules/workspace.md +30 -12
  39. package/dist/host/configs/helpers.ts +206 -133
  40. package/dist/host/configs/policy.ts +110 -105
  41. package/dist/host/guides/scaffold.md +244 -170
  42. package/dist/host/manifest.json +35 -29
  43. package/dist/host/tests/config.test.ts +1220 -633
  44. package/dist/host/tests/policy.test.ts +19 -0
  45. package/dist/host/tests/setupPolicy.ts +1669 -985
  46. package/dist/src/core/index.cjs +1595 -1686
  47. package/dist/src/core/index.cjs.map +1 -1
  48. package/dist/src/core/index.d.cts +202 -18
  49. package/dist/src/core/index.d.ts +202 -18
  50. package/dist/src/core/index.js +1590 -1687
  51. package/dist/src/core/index.js.map +1 -1
  52. package/dist/src/server/index.cjs +448 -392
  53. package/dist/src/server/index.cjs.map +1 -1
  54. package/dist/src/server/index.d.cts +1182 -1052
  55. package/dist/src/server/index.d.ts +1182 -1052
  56. package/dist/src/server/index.js +447 -395
  57. package/dist/src/server/index.js.map +1 -1
  58. package/package.json +2 -2
@@ -58,6 +58,12 @@ interface Collected {
58
58
  readonly gate?: string | undefined
59
59
  }
60
60
 
61
+ interface Selection {
62
+ readonly selected: readonly string[]
63
+ readonly skip: number
64
+ readonly closed: boolean
65
+ }
66
+
61
67
  interface Gates {
62
68
  readonly units: readonly Gate[]
63
69
  readonly files: ReadonlyMap<string, string>
@@ -82,6 +88,17 @@ interface Listing extends Unit {
82
88
  readonly empty: readonly string[]
83
89
  }
84
90
 
91
+ interface UnitReading {
92
+ readonly listing: Listing
93
+ readonly populated: readonly string[]
94
+ }
95
+
96
+ interface ProjectReading {
97
+ readonly project: string
98
+ readonly populated: boolean
99
+ readonly empty: boolean
100
+ }
101
+
85
102
  interface Invocation {
86
103
  readonly name: string
87
104
  readonly args: readonly string[]
@@ -108,46 +125,71 @@ function readCLIOptions(): ReadonlyMap<string, Option> {
108
125
  windowsHide: true,
109
126
  })
110
127
  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
128
+ // A later line that names the same flag replaces the earlier option at the earlier position.
129
+ return new Map(
130
+ help.stdout.split(/\r\n|\n/u).flatMap((line): Array<readonly [string, Option]> => {
131
+ const match = /^\s+(?:(-\w), )?--([\w.-]+)(?: ([<[][^\n]*?[>\]]))?\s{2}/u.exec(line)
132
+ const name = match?.[2]
133
+ if (name === undefined) return []
134
+ const option = { name: name.replace(/^no-/u, ''), argument: match?.[3] }
135
+ return [
136
+ [`--${name}`, option],
137
+ ...(name.startsWith('no-') ? [[`--${name.slice(3)}`, option] as const] : []),
138
+ [`--${name.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)}`, option],
139
+ ...(match?.[1] === undefined ? [] : [[match[1], option] as const]),
140
+ ]
141
+ }),
142
+ )
143
+ }
144
+
145
+ // An option that takes a value consumes the token after it, so that token is never read as a runner.
146
+ function skipRunnerOptions(command: readonly string[], index: number): number {
147
+ const skipped = [...command.entries()].reduce<{
148
+ readonly next: number
149
+ readonly found: number | undefined
150
+ }>(
151
+ (state, [position, option]) => {
152
+ if (state.found !== undefined || position < state.next) return state
153
+ if (!option.startsWith('-')) return { ...state, found: position }
154
+ if (['-e', '--eval', '-p', '--print', '-c', '--check'].includes(option)) {
155
+ return { ...state, found: -1 }
156
+ }
157
+ return {
158
+ ...state,
159
+ next:
160
+ position +
161
+ ([
162
+ '-r',
163
+ '--require',
164
+ '--import',
165
+ '--loader',
166
+ '--experimental-loader',
167
+ '--max-old-space-size',
168
+ '--max_old_space_size',
169
+ '-p',
170
+ '--package',
171
+ ].includes(option)
172
+ ? 2
173
+ : 1),
174
+ }
175
+ },
176
+ { next: index, found: undefined },
177
+ )
178
+ return skipped.found ?? skipped.next
123
179
  }
124
180
 
125
181
  function findRunner(command: readonly string[]): number {
126
- let index = 0
127
182
  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 ?? '')
183
+ const start =
184
+ executable === 'npm'
185
+ ? command[1] === 'exec'
147
186
  ? 2
148
- : 1
149
- }
150
- }
187
+ : -1
188
+ : executable === 'node' || executable === 'npx'
189
+ ? 1
190
+ : 0
191
+ const index = start > 0 ? skipRunnerOptions(command, start) : start
192
+ if (index < 0) return -1
151
193
  return ['vitest', 'vitest.mjs', 'vitest.js'].includes(
152
194
  basename(command[index] ?? '').replace(/\.cmd$/u, ''),
153
195
  )
@@ -155,55 +197,47 @@ function findRunner(command: readonly string[]): number {
155
197
  : -1
156
198
  }
157
199
 
200
+ // The first `--` ends the options, so the rest of the arguments pass verbatim. A runner option that
201
+ // takes a value consumes the argument after it, so a step below `skip` reads nothing.
158
202
  function readSelection(
159
203
  argv: readonly string[],
160
204
  options: ReadonlyMap<string, Option>,
161
205
  ): 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
206
+ return argv.reduce<Selection>(
207
+ (state, token, index) => {
208
+ if (state.closed || index < state.skip) return state
209
+ if (token === '--') {
210
+ return { ...state, selected: state.selected.concat(argv.slice(index)), closed: true }
211
+ }
212
+ if (!token.startsWith('-')) return { ...state, selected: [...state.selected, token] }
213
+ const equals = token.indexOf('=')
214
+ const flag = equals < 0 ? token : token.slice(0, equals)
215
+ const positive = flag.replace(/^--no-/u, '--')
216
+ const option = options.get(positive) ?? options.get(positive.split('.').slice(0, 1).join('.'))
217
+ if (option === undefined || !OMITTED.has(option.name.split('.')[0] ?? '')) {
218
+ return { ...state, selected: [...state.selected, token] }
219
+ }
220
+ const next = argv[index + 1]
221
+ const valued =
222
+ !flag.startsWith('--no-') &&
223
+ equals < 0 &&
224
+ next !== undefined &&
225
+ !next.startsWith('-') &&
226
+ (option.argument !== undefined || next === 'true' || next === 'false')
227
+ return { ...state, skip: index + (valued ? 2 : 1) }
228
+ },
229
+ { selected: [], skip: 0, closed: false },
230
+ ).selected
193
231
  }
194
232
 
195
233
  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
234
+ const end = argv.indexOf('--')
235
+ return (end < 0 ? argv : argv.slice(0, end)).flatMap((token, index) => {
201
236
  const equals = token.indexOf('=')
202
- if (!names.includes(equals < 0 ? token : token.slice(0, equals))) continue
237
+ if (!names.includes(equals < 0 ? token : token.slice(0, equals))) return []
203
238
  const value = equals < 0 ? argv[index + 1] : token.slice(equals + 1)
204
- if (value !== undefined && !value.startsWith('-')) values.push(value)
205
- }
206
- return values
239
+ return value !== undefined && !value.startsWith('-') ? [value] : []
240
+ })
207
241
  }
208
242
 
209
243
  function readScripts(): Readonly<Record<string, string>> {
@@ -211,37 +245,32 @@ function readScripts(): Readonly<Record<string, string>> {
211
245
  if (typeof parsed !== 'object' || parsed === null) return {}
212
246
  const scripts = Object.fromEntries(Object.entries(parsed)).scripts
213
247
  if (typeof scripts !== 'object' || scripts === null) return {}
214
- const record: Record<string, string> = {}
215
- for (const [key, value] of Object.entries(scripts))
216
- if (typeof value === 'string') record[key] = value
217
- return record
248
+ return Object.fromEntries(
249
+ Object.entries(scripts).flatMap(([key, value]) =>
250
+ typeof value === 'string' ? [[key, value] as const] : [],
251
+ ),
252
+ )
218
253
  }
219
254
 
220
255
  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
256
+ const tokens = [...text.matchAll(/(?:[^\s"';&|]+|"[^"]*"|'[^']*')+|[;&|]+/gu)].map(
257
+ (match) => match[0],
258
+ )
259
+ // A separator token ends a command, so the commands are the runs of tokens between the cuts.
260
+ const cuts = tokens.flatMap((token, index) => (/^[;&|]+$/u.test(token) ? [index] : []))
261
+ return [-1, ...cuts].map((cut, at) =>
262
+ tokens.slice(cut + 1, cuts[at] ?? tokens.length).map((token) => token.replace(/["']/gu, '')),
263
+ )
233
264
  }
234
265
 
235
266
  function listInvocations(commands: ReadonlyArray<readonly string[]>): readonly Invocation[] {
236
- const invoked: Invocation[] = []
237
- for (const command of commands) {
267
+ return commands.flatMap((command) => {
238
268
  const index = command.indexOf('npm')
239
269
  const name = command[index + 2]
240
- if (index < 0 || command[index + 1] !== 'run' || name === undefined) continue
270
+ if (index < 0 || command[index + 1] !== 'run' || name === undefined) return []
241
271
  const separator = command.indexOf('--', index + 3)
242
- invoked.push({ name, args: separator < 0 ? [] : command.slice(separator + 1), ancestors: [] })
243
- }
244
- return invoked
272
+ return [{ name, args: separator < 0 ? [] : command.slice(separator + 1), ancestors: [] }]
273
+ })
245
274
  }
246
275
 
247
276
  function readGates(
@@ -274,12 +303,9 @@ function readGates(
274
303
  const expanded = script.map((command, index) =>
275
304
  index === script.length - 1 ? [...command, ...args] : command,
276
305
  )
277
- pending.push(
278
- ...listInvocations(expanded).map((entry) => ({
279
- ...entry,
280
- ancestors: [...ancestors, name],
281
- })),
282
- )
306
+ for (const entry of listInvocations(expanded)) {
307
+ pending.push({ ...entry, ancestors: [...ancestors, name] })
308
+ }
283
309
  const gate = root === name ? name : `${root} > ${name}`
284
310
  for (const command of expanded) {
285
311
  const index = findRunner(command)
@@ -388,28 +414,30 @@ function listCollected(
388
414
  }
389
415
  const parsed: unknown = JSON.parse(readFileSync(listing, 'utf8'))
390
416
  if (!Array.isArray(parsed)) return undefined
391
- const collected: Collected[] = []
392
- for (const entry of parsed) {
393
- if (typeof entry !== 'object' || entry === null) continue
417
+ const collected = parsed.flatMap((entry): Collected[] => {
418
+ if (typeof entry !== 'object' || entry === null) return []
394
419
  const record = Object.fromEntries(Object.entries(entry))
395
420
  const location =
396
421
  typeof record.location === 'object' && record.location !== null
397
422
  ? Object.fromEntries(Object.entries(record.location))
398
423
  : {}
399
424
  if (
400
- typeof record.file === 'string' &&
401
- (record.projectName === undefined || typeof record.projectName === 'string') &&
402
- (!full || typeof record.name === 'string')
425
+ typeof record.file !== 'string' ||
426
+ (record.projectName !== undefined && typeof record.projectName !== 'string') ||
427
+ (full && typeof record.name !== 'string')
403
428
  ) {
404
- collected.push({
429
+ return []
430
+ }
431
+ return [
432
+ {
405
433
  file: relative(process.cwd(), record.file).split('\\').join('/'),
406
434
  projectName: record.projectName ?? '',
407
435
  name: typeof record.name === 'string' ? record.name : undefined,
408
436
  line: typeof location.line === 'number' ? location.line : undefined,
409
437
  column: typeof location.column === 'number' ? location.column : undefined,
410
- })
411
- }
412
- }
438
+ },
439
+ ]
440
+ })
413
441
  cache.set(key, collected)
414
442
  return collected
415
443
  } catch (error) {
@@ -429,12 +457,11 @@ function listTestFiles(directory: string): readonly string[] {
429
457
 
430
458
  function countMarkers(file: string): Readonly<Record<string, number>> {
431
459
  const text = readFileSync(file, 'utf8')
432
- const counts: Record<string, number> = {}
433
- for (const marker of MARKERS) {
434
- const count = text.split(marker).length - 1
435
- if (count > 0) counts[marker] = count
436
- }
437
- return counts
460
+ return Object.fromEntries(
461
+ MARKERS.map((marker) => [marker, text.split(marker).length - 1] as const).filter(
462
+ ([, count]) => count > 0,
463
+ ),
464
+ )
438
465
  }
439
466
 
440
467
  function collectListings(
@@ -442,93 +469,140 @@ function collectListings(
442
469
  named: readonly string[],
443
470
  gates: Gates,
444
471
  ): 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,
472
+ const base: Unit = { config, mode: undefined }
473
+ // A gate joins the units unless the base unit or an earlier gate already names the same unit.
474
+ const units: readonly Unit[] = [
475
+ base,
476
+ ...gates.units
477
+ .filter(
478
+ (gate, index) =>
479
+ ![base, ...gates.units.slice(0, index)].some((unit) => isSameUnit(unit, gate)),
454
480
  )
455
- ) {
456
- units.push({
481
+ .map((gate) => ({
457
482
  config: gate.config,
458
483
  mode: gate.mode,
459
484
  ...(gate.root === undefined ? {} : { root: gate.root }),
460
485
  ...(gate.dir === undefined ? {} : { dir: gate.dir }),
461
- })
462
- }
463
- }
464
- const listings: Listing[] = []
465
- const populated = new Set<string>()
486
+ })),
487
+ ]
488
+ // A listing is a memo keyed by its arguments: each key is written once, on its first miss.
466
489
  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
- ),
490
+ const readings = collectReadings(units, (unit) => readUnit(unit, named, gates, cache))
491
+ if (readings === undefined) return undefined
492
+ const listings = readings.map(({ listing }) => listing)
493
+ const populated = new Set(readings.flatMap((reading) => reading.populated))
494
+ // A project empty under one unit may still list tests under another, so each unit is asked for it.
495
+ const rescans = collectReadings(
496
+ [...new Set(listings.flatMap((listing) => listing.empty))]
497
+ .filter((name) => !populated.has(name))
498
+ .flatMap((name) => units.map((unit) => ({ name, unit }))),
499
+ ({ name, unit }) => {
500
+ const selection = listCollected([...readUnitArguments(unit), '--project', name], false, cache)
501
+ return selection === undefined ? undefined : { name, listed: selection.length > 0 }
502
+ },
503
+ )
504
+ if (rescans === undefined) return undefined
505
+ const resolved = new Set([
506
+ ...populated,
507
+ ...rescans.filter(({ listed }) => listed).map(({ name }) => name),
508
+ ])
509
+ return listings.map((listing) => ({
510
+ ...listing,
511
+ empty: listing.empty.filter((name) => !resolved.has(name)),
512
+ }))
513
+ }
514
+
515
+ // Runs one read per item in order and stops at the first refusal, so no later read spawns.
516
+ function collectReadings<Item, Reading>(
517
+ items: readonly Item[],
518
+ read: (item: Item) => Reading | undefined,
519
+ ): readonly Reading[] | undefined {
520
+ return items.reduce<readonly Reading[] | undefined>((readings, item) => {
521
+ if (readings === undefined) return undefined
522
+ const reading = read(item)
523
+ return reading === undefined ? undefined : [...readings, reading]
524
+ }, [])
525
+ }
526
+
527
+ function isSameUnit(left: Unit, right: Unit): boolean {
528
+ return (
529
+ left.config === right.config &&
530
+ left.mode === right.mode &&
531
+ left.root === right.root &&
532
+ left.dir === right.dir
533
+ )
534
+ }
535
+
536
+ function readUnit(
537
+ unit: Unit,
538
+ named: readonly string[],
539
+ gates: Gates,
540
+ cache: Map<string, readonly Collected[]>,
541
+ ): UnitReading | undefined {
542
+ const reached = gates.units.filter((gate) => isSameUnit(gate, unit))
543
+ const parameters = readUnitArguments(unit)
544
+ const collected = listCollected(parameters, true, cache)
545
+ if (collected === undefined) return undefined
546
+ const scope =
547
+ named.length === 0
548
+ ? undefined
549
+ : listCollected(
550
+ [...parameters, ...named.flatMap((name) => ['--project', name])],
551
+ false,
552
+ cache,
553
+ )
554
+ if (named.length > 0 && scope === undefined) return undefined
555
+ const selected = collectReadings(reached, (gate) => {
556
+ const selection =
557
+ JSON.stringify(gate.args) === JSON.stringify(parameters)
558
+ ? collected
559
+ : listCollected(gate.args, gate.full, cache)
560
+ if (selection === undefined) {
561
+ console.error(`discovery: gate ${gate.chain}: vitest list refused ${gate.args.join(' ')}`)
562
+ return undefined
563
+ }
564
+ return {
565
+ gate,
566
+ identities: new Set(
567
+ selection.map((entry) =>
568
+ JSON.stringify([
569
+ entry.file,
570
+ entry.projectName,
571
+ ...(gate.full ? [entry.name, entry.line, entry.column] : []),
572
+ ]),
507
573
  ),
508
- })
574
+ ),
509
575
  }
510
- const empty: string[] = []
511
- for (const project of new Set([...reached.flatMap((gate) => gate.projects), ...named])) {
512
- if (project.includes('*') || project.startsWith('!')) continue
576
+ })
577
+ if (selected === undefined) return undefined
578
+ const readings = collectReadings(
579
+ [...new Set([...reached.flatMap((gate) => gate.projects), ...named])].filter(
580
+ (project) => !project.includes('*') && !project.startsWith('!'),
581
+ ),
582
+ (project): ProjectReading | undefined => {
513
583
  if (
514
584
  selected.some(
515
585
  ({ gate, identities }) =>
516
586
  gate.projects.length === 1 && gate.projects[0] === project && identities.size > 0,
517
587
  )
518
588
  ) {
519
- populated.add(project)
520
- continue
589
+ return { project, populated: true, empty: false }
521
590
  }
522
591
  const selection = listCollected([...parameters, '--project', project], false, cache)
523
592
  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({
593
+ return {
594
+ project,
595
+ populated: selection.length > 0,
596
+ empty:
597
+ selection.length === 0 &&
598
+ (reached.some((gate) => gate.projects.includes(project)) || named.includes(project)) &&
599
+ (named.length === 0 || named.includes(project)),
600
+ }
601
+ },
602
+ )
603
+ if (readings === undefined) return undefined
604
+ return {
605
+ listing: {
532
606
  ...unit,
533
607
  collected: collected
534
608
  .filter(
@@ -550,22 +624,10 @@ function collectListings(
550
624
  ),
551
625
  )?.gate.chain,
552
626
  })),
553
- empty,
554
- })
627
+ empty: readings.filter(({ empty }) => empty).map(({ project }) => project),
628
+ },
629
+ populated: readings.filter(({ populated }) => populated).map(({ project }) => project),
555
630
  }
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
631
  }
570
632
 
571
633
  function readUnitArguments(unit: Unit): readonly string[] {
@@ -577,25 +639,35 @@ function readUnitArguments(unit: Unit): readonly string[] {
577
639
  ]
578
640
  }
579
641
 
642
+ function identifyCollected(entry: Collected): string {
643
+ return JSON.stringify([entry.file, entry.projectName, entry.name, entry.line, entry.column])
644
+ }
645
+
646
+ // The same test listed twice in one listing is two occurrences, and the same occurrence listed by
647
+ // two listings is one test: the last listing's fields win, and the gate is the last one any named.
580
648
  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()]
649
+ const keyed = listings.flatMap((listing) => {
650
+ const positions = new Map(
651
+ [...Map.groupBy(listing.collected, identifyCollected).values()].flatMap((group) =>
652
+ group.map((entry, position) => [entry, position + 1] as const),
653
+ ),
654
+ )
655
+ return listing.collected.map((entry) => ({
656
+ entry,
657
+ key: JSON.stringify([identifyCollected(entry), positions.get(entry)]),
658
+ }))
659
+ })
660
+ return [...Map.groupBy(keyed, ({ key }) => key).values()].flatMap((group) => {
661
+ const last = group.at(-1)
662
+ return last === undefined
663
+ ? []
664
+ : [
665
+ {
666
+ ...last.entry,
667
+ gate: group.findLast(({ entry }) => entry.gate !== undefined)?.entry.gate,
668
+ },
669
+ ]
670
+ })
599
671
  }
600
672
 
601
673
  function readSnapshot(gates: Gates): string {
@@ -606,16 +678,32 @@ function readSnapshot(gates: Gates): string {
606
678
  ),
607
679
  ]
608
680
  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
- }
681
+ // A later listing of the same path replaces the earlier modification time at the earlier position.
682
+ const files = new Map(
683
+ [...roots].flatMap((root) =>
684
+ existsSync(root)
685
+ ? listFiles(root)
686
+ .filter((file) => /\.test\.[cm]?[jt]sx?$/u.test(file.path))
687
+ .map((file) => [file.path, file.modified] as const)
688
+ : [],
689
+ ),
690
+ )
616
691
  return JSON.stringify([...files].sort(([left], [right]) => left.localeCompare(right)))
617
692
  }
618
693
 
694
+ function collectStableListings(
695
+ config: string,
696
+ named: readonly string[],
697
+ gates: Gates,
698
+ attempt = 0,
699
+ ): readonly Listing[] | undefined {
700
+ const before = readSnapshot(gates)
701
+ const listings = collectListings(config, named, gates)
702
+ if (before === readSnapshot(gates)) return listings
703
+ if (attempt === 1) throw new Error('Test files changed during both census attempts')
704
+ return collectStableListings(config, named, gates, attempt + 1)
705
+ }
706
+
619
707
  function main(argv: readonly string[]): number {
620
708
  const missing = readMissingFlags(argv, ['--config', '--projects'])
621
709
  if (missing.length > 0) {
@@ -634,14 +722,7 @@ function main(argv: readonly string[]): number {
634
722
  const canonical = relative(process.cwd(), resolve(config)).split('\\').join('/')
635
723
  const gates = readGates(readScripts(), canonical, readCLIOptions())
636
724
  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
- }
725
+ const listings = collectStableListings(canonical, named, gates)
645
726
  if (listings === undefined) {
646
727
  console.error('discovery: vitest list failed')
647
728
  return 2