@orkestrel/scaffold 0.0.100 → 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 (60) 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 +36 -12
  39. package/dist/host/configs/helpers.ts +214 -133
  40. package/dist/host/configs/policy.ts +110 -105
  41. package/dist/host/dotfiles/gitignore +1 -0
  42. package/dist/host/dotfiles/oxlintrc.json +10 -0
  43. package/dist/host/guides/scaffold.md +244 -170
  44. package/dist/host/manifest.json +37 -31
  45. package/dist/host/tests/config.test.ts +1305 -619
  46. package/dist/host/tests/policy.test.ts +19 -0
  47. package/dist/host/tests/setupPolicy.ts +1710 -995
  48. package/dist/src/core/index.cjs +1595 -1686
  49. package/dist/src/core/index.cjs.map +1 -1
  50. package/dist/src/core/index.d.cts +202 -18
  51. package/dist/src/core/index.d.ts +202 -18
  52. package/dist/src/core/index.js +1590 -1687
  53. package/dist/src/core/index.js.map +1 -1
  54. package/dist/src/server/index.cjs +448 -392
  55. package/dist/src/server/index.cjs.map +1 -1
  56. package/dist/src/server/index.d.cts +1182 -1052
  57. package/dist/src/server/index.d.ts +1182 -1052
  58. package/dist/src/server/index.js +447 -395
  59. package/dist/src/server/index.js.map +1 -1
  60. package/package.json +2 -2
@@ -45,104 +45,103 @@ function readCLIOptions() {
45
45
  });
46
46
  if (help.status !== 0)
47
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)) {
48
+ // A later line that names the same flag replaces the earlier option at the earlier position.
49
+ return new Map(help.stdout.split(/\r\n|\n/u).flatMap((line) => {
50
50
  const match = /^\s+(?:(-\w), )?--([\w.-]+)(?: ([<[][^\n]*?[>\]]))?\s{2}/u.exec(line);
51
51
  const name = match?.[2];
52
52
  if (name === undefined)
53
- continue;
53
+ return [];
54
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;
55
+ return [
56
+ [`--${name}`, option],
57
+ ...(name.startsWith('no-') ? [[`--${name.slice(3)}`, option]] : []),
58
+ [`--${name.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)}`, option],
59
+ ...(match?.[1] === undefined ? [] : [[match[1], option]]),
60
+ ];
61
+ }));
62
+ }
63
+ // An option that takes a value consumes the token after it, so that token is never read as a runner.
64
+ function skipRunnerOptions(command, index) {
65
+ const skipped = [...command.entries()].reduce((state, [position, option]) => {
66
+ if (state.found !== undefined || position < state.next)
67
+ return state;
68
+ if (!option.startsWith('-'))
69
+ return { ...state, found: position };
70
+ if (['-e', '--eval', '-p', '--print', '-c', '--check'].includes(option)) {
71
+ return { ...state, found: -1 };
72
+ }
73
+ return {
74
+ ...state,
75
+ next: position +
76
+ ([
77
+ '-r',
78
+ '--require',
79
+ '--import',
80
+ '--loader',
81
+ '--experimental-loader',
82
+ '--max-old-space-size',
83
+ '--max_old_space_size',
84
+ '-p',
85
+ '--package',
86
+ ].includes(option)
87
+ ? 2
88
+ : 1),
89
+ };
90
+ }, { next: index, found: undefined });
91
+ return skipped.found ?? skipped.next;
63
92
  }
64
93
  function findRunner(command) {
65
- let index = 0;
66
94
  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
- }
95
+ const start = executable === 'npm'
96
+ ? command[1] === 'exec'
97
+ ? 2
98
+ : -1
99
+ : executable === 'node' || executable === 'npx'
100
+ ? 1
101
+ : 0;
102
+ const index = start > 0 ? skipRunnerOptions(command, start) : start;
103
+ if (index < 0)
104
+ return -1;
94
105
  return ['vitest', 'vitest.mjs', 'vitest.js'].includes(basename(command[index] ?? '').replace(/\.cmd$/u, ''))
95
106
  ? index
96
107
  : -1;
97
108
  }
109
+ // The first `--` ends the options, so the rest of the arguments pass verbatim. A runner option that
110
+ // takes a value consumes the argument after it, so a step below `skip` reads nothing.
98
111
  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;
112
+ return argv.reduce((state, token, index) => {
113
+ if (state.closed || index < state.skip)
114
+ return state;
104
115
  if (token === '--') {
105
- selected.push(...argv.slice(index));
106
- break;
107
- }
108
- if (!token.startsWith('-')) {
109
- selected.push(token);
110
- continue;
116
+ return { ...state, selected: state.selected.concat(argv.slice(index)), closed: true };
111
117
  }
118
+ if (!token.startsWith('-'))
119
+ return { ...state, selected: [...state.selected, token] };
112
120
  const equals = token.indexOf('=');
113
121
  const flag = equals < 0 ? token : token.slice(0, equals);
114
122
  const positive = flag.replace(/^--no-/u, '--');
115
123
  const option = options.get(positive) ?? options.get(positive.split('.').slice(0, 1).join('.'));
116
124
  if (option === undefined || !OMITTED.has(option.name.split('.')[0] ?? '')) {
117
- selected.push(token);
118
- continue;
125
+ return { ...state, selected: [...state.selected, token] };
119
126
  }
120
127
  const next = argv[index + 1];
121
- if (!flag.startsWith('--no-') &&
128
+ const valued = !flag.startsWith('--no-') &&
122
129
  equals < 0 &&
123
130
  next !== undefined &&
124
131
  !next.startsWith('-') &&
125
- (option.argument !== undefined || next === 'true' || next === 'false'))
126
- index++;
127
- }
128
- return selected;
132
+ (option.argument !== undefined || next === 'true' || next === 'false');
133
+ return { ...state, skip: index + (valued ? 2 : 1) };
134
+ }, { selected: [], skip: 0, closed: false }).selected;
129
135
  }
130
136
  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;
137
+ const end = argv.indexOf('--');
138
+ return (end < 0 ? argv : argv.slice(0, end)).flatMap((token, index) => {
138
139
  const equals = token.indexOf('=');
139
140
  if (!names.includes(equals < 0 ? token : token.slice(0, equals)))
140
- continue;
141
+ return [];
141
142
  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;
143
+ return value !== undefined && !value.startsWith('-') ? [value] : [];
144
+ });
146
145
  }
147
146
  function readScripts() {
148
147
  const parsed = JSON.parse(readFileSync('package.json', 'utf8'));
@@ -151,38 +150,23 @@ function readScripts() {
151
150
  const scripts = Object.fromEntries(Object.entries(parsed)).scripts;
152
151
  if (typeof scripts !== 'object' || scripts === null)
153
152
  return {};
154
- const record = {};
155
- for (const [key, value] of Object.entries(scripts))
156
- if (typeof value === 'string')
157
- record[key] = value;
158
- return record;
153
+ return Object.fromEntries(Object.entries(scripts).flatMap(([key, value]) => typeof value === 'string' ? [[key, value]] : []));
159
154
  }
160
155
  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;
156
+ const tokens = [...text.matchAll(/(?:[^\s"';&|]+|"[^"]*"|'[^']*')+|[;&|]+/gu)].map((match) => match[0]);
157
+ // A separator token ends a command, so the commands are the runs of tokens between the cuts.
158
+ const cuts = tokens.flatMap((token, index) => (/^[;&|]+$/u.test(token) ? [index] : []));
159
+ return [-1, ...cuts].map((cut, at) => tokens.slice(cut + 1, cuts[at] ?? tokens.length).map((token) => token.replace(/["']/gu, '')));
174
160
  }
175
161
  function listInvocations(commands) {
176
- const invoked = [];
177
- for (const command of commands) {
162
+ return commands.flatMap((command) => {
178
163
  const index = command.indexOf('npm');
179
164
  const name = command[index + 2];
180
165
  if (index < 0 || command[index + 1] !== 'run' || name === undefined)
181
- continue;
166
+ return [];
182
167
  const separator = command.indexOf('--', index + 3);
183
- invoked.push({ name, args: separator < 0 ? [] : command.slice(separator + 1), ancestors: [] });
184
- }
185
- return invoked;
168
+ return [{ name, args: separator < 0 ? [] : command.slice(separator + 1), ancestors: [] }];
169
+ });
186
170
  }
187
171
  function readGates(scripts, config, options) {
188
172
  const commands = Object.fromEntries(Object.entries(scripts).map(([name, text]) => [name, readCommands(text)]));
@@ -207,10 +191,9 @@ function readGates(scripts, config, options) {
207
191
  if (script === undefined)
208
192
  continue;
209
193
  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
- })));
194
+ for (const entry of listInvocations(expanded)) {
195
+ pending.push({ ...entry, ancestors: [...ancestors, name] });
196
+ }
214
197
  const gate = root === name ? name : `${root} > ${name}`;
215
198
  for (const command of expanded) {
216
199
  const index = findRunner(command);
@@ -290,26 +273,28 @@ function listCollected(args, full, cache) {
290
273
  const parsed = JSON.parse(readFileSync(listing, 'utf8'));
291
274
  if (!Array.isArray(parsed))
292
275
  return undefined;
293
- const collected = [];
294
- for (const entry of parsed) {
276
+ const collected = parsed.flatMap((entry) => {
295
277
  if (typeof entry !== 'object' || entry === null)
296
- continue;
278
+ return [];
297
279
  const record = Object.fromEntries(Object.entries(entry));
298
280
  const location = typeof record.location === 'object' && record.location !== null
299
281
  ? Object.fromEntries(Object.entries(record.location))
300
282
  : {};
301
- if (typeof record.file === 'string' &&
302
- (record.projectName === undefined || typeof record.projectName === 'string') &&
303
- (!full || typeof record.name === 'string')) {
304
- collected.push({
283
+ if (typeof record.file !== 'string' ||
284
+ (record.projectName !== undefined && typeof record.projectName !== 'string') ||
285
+ (full && typeof record.name !== 'string')) {
286
+ return [];
287
+ }
288
+ return [
289
+ {
305
290
  file: relative(process.cwd(), record.file).split('\\').join('/'),
306
291
  projectName: record.projectName ?? '',
307
292
  name: typeof record.name === 'string' ? record.name : undefined,
308
293
  line: typeof location.line === 'number' ? location.line : undefined,
309
294
  column: typeof location.column === 'number' ? location.column : undefined,
310
- });
311
- }
312
- }
295
+ },
296
+ ];
297
+ });
313
298
  cache.set(key, collected);
314
299
  return collected;
315
300
  }
@@ -330,82 +315,111 @@ function listTestFiles(directory) {
330
315
  }
331
316
  function countMarkers(file) {
332
317
  const text = readFileSync(file, 'utf8');
333
- const counts = {};
334
- for (const marker of MARKERS) {
335
- const count = text.split(marker).length - 1;
336
- if (count > 0)
337
- counts[marker] = count;
338
- }
339
- return counts;
318
+ return Object.fromEntries(MARKERS.map((marker) => [marker, text.split(marker).length - 1]).filter(([, count]) => count > 0));
340
319
  }
341
320
  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();
321
+ const base = { config, mode: undefined };
322
+ // A gate joins the units unless the base unit or an earlier gate already names the same unit.
323
+ const units = [
324
+ base,
325
+ ...gates.units
326
+ .filter((gate, index) => ![base, ...gates.units.slice(0, index)].some((unit) => isSameUnit(unit, gate)))
327
+ .map((gate) => ({
328
+ config: gate.config,
329
+ mode: gate.mode,
330
+ ...(gate.root === undefined ? {} : { root: gate.root }),
331
+ ...(gate.dir === undefined ? {} : { dir: gate.dir }),
332
+ })),
333
+ ];
334
+ // A listing is a memo keyed by its arguments: each key is written once, on its first miss.
358
335
  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)
336
+ const readings = collectReadings(units, (unit) => readUnit(unit, named, gates, cache));
337
+ if (readings === undefined)
338
+ return undefined;
339
+ const listings = readings.map(({ listing }) => listing);
340
+ const populated = new Set(readings.flatMap((reading) => reading.populated));
341
+ // A project empty under one unit may still list tests under another, so each unit is asked for it.
342
+ const rescans = collectReadings([...new Set(listings.flatMap((listing) => listing.empty))]
343
+ .filter((name) => !populated.has(name))
344
+ .flatMap((name) => units.map((unit) => ({ name, unit }))), ({ name, unit }) => {
345
+ const selection = listCollected([...readUnitArguments(unit), '--project', name], false, cache);
346
+ return selection === undefined ? undefined : { name, listed: selection.length > 0 };
347
+ });
348
+ if (rescans === undefined)
349
+ return undefined;
350
+ const resolved = new Set([
351
+ ...populated,
352
+ ...rescans.filter(({ listed }) => listed).map(({ name }) => name),
353
+ ]);
354
+ return listings.map((listing) => ({
355
+ ...listing,
356
+ empty: listing.empty.filter((name) => !resolved.has(name)),
357
+ }));
358
+ }
359
+ // Runs one read per item in order and stops at the first refusal, so no later read spawns.
360
+ function collectReadings(items, read) {
361
+ return items.reduce((readings, item) => {
362
+ if (readings === undefined)
367
363
  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)
364
+ const reading = read(item);
365
+ return reading === undefined ? undefined : [...readings, reading];
366
+ }, []);
367
+ }
368
+ function isSameUnit(left, right) {
369
+ return (left.config === right.config &&
370
+ left.mode === right.mode &&
371
+ left.root === right.root &&
372
+ left.dir === right.dir);
373
+ }
374
+ function readUnit(unit, named, gates, cache) {
375
+ const reached = gates.units.filter((gate) => isSameUnit(gate, unit));
376
+ const parameters = readUnitArguments(unit);
377
+ const collected = listCollected(parameters, true, cache);
378
+ if (collected === undefined)
379
+ return undefined;
380
+ const scope = named.length === 0
381
+ ? undefined
382
+ : listCollected([...parameters, ...named.flatMap((name) => ['--project', name])], false, cache);
383
+ if (named.length > 0 && scope === undefined)
384
+ return undefined;
385
+ const selected = collectReadings(reached, (gate) => {
386
+ const selection = JSON.stringify(gate.args) === JSON.stringify(parameters)
387
+ ? collected
388
+ : listCollected(gate.args, gate.full, cache);
389
+ if (selection === undefined) {
390
+ console.error(`discovery: gate ${gate.chain}: vitest list refused ${gate.args.join(' ')}`);
372
391
  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
392
  }
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);
393
+ return {
394
+ gate,
395
+ identities: new Set(selection.map((entry) => JSON.stringify([
396
+ entry.file,
397
+ entry.projectName,
398
+ ...(gate.full ? [entry.name, entry.line, entry.column] : []),
399
+ ]))),
400
+ };
401
+ });
402
+ if (selected === undefined)
403
+ return undefined;
404
+ const readings = collectReadings([...new Set([...reached.flatMap((gate) => gate.projects), ...named])].filter((project) => !project.includes('*') && !project.startsWith('!')), (project) => {
405
+ if (selected.some(({ gate, identities }) => gate.projects.length === 1 && gate.projects[0] === project && identities.size > 0)) {
406
+ return { project, populated: true, empty: false };
407
407
  }
408
- listings.push({
408
+ const selection = listCollected([...parameters, '--project', project], false, cache);
409
+ if (selection === undefined)
410
+ return undefined;
411
+ return {
412
+ project,
413
+ populated: selection.length > 0,
414
+ empty: selection.length === 0 &&
415
+ (reached.some((gate) => gate.projects.includes(project)) || named.includes(project)) &&
416
+ (named.length === 0 || named.includes(project)),
417
+ };
418
+ });
419
+ if (readings === undefined)
420
+ return undefined;
421
+ return {
422
+ listing: {
409
423
  ...unit,
410
424
  collected: collected
411
425
  .filter((entry) => scope === undefined ||
@@ -418,25 +432,10 @@ function collectListings(config, named, gates) {
418
432
  ...(gate.full ? [entry.name, entry.line, entry.column] : []),
419
433
  ])))?.gate.chain,
420
434
  })),
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
- }));
435
+ empty: readings.filter(({ empty }) => empty).map(({ project }) => project),
436
+ },
437
+ populated: readings.filter(({ populated }) => populated).map(({ project }) => project),
438
+ };
440
439
  }
441
440
  function readUnitArguments(unit) {
442
441
  return [
@@ -446,25 +445,30 @@ function readUnitArguments(unit) {
446
445
  ...(unit.dir === undefined ? [] : ['--dir', unit.dir]),
447
446
  ];
448
447
  }
448
+ function identifyCollected(entry) {
449
+ return JSON.stringify([entry.file, entry.projectName, entry.name, entry.line, entry.column]);
450
+ }
451
+ // The same test listed twice in one listing is two occurrences, and the same occurrence listed by
452
+ // two listings is one test: the last listing's fields win, and the gate is the last one any named.
449
453
  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()];
454
+ const keyed = listings.flatMap((listing) => {
455
+ const positions = new Map([...Map.groupBy(listing.collected, identifyCollected).values()].flatMap((group) => group.map((entry, position) => [entry, position + 1])));
456
+ return listing.collected.map((entry) => ({
457
+ entry,
458
+ key: JSON.stringify([identifyCollected(entry), positions.get(entry)]),
459
+ }));
460
+ });
461
+ return [...Map.groupBy(keyed, ({ key }) => key).values()].flatMap((group) => {
462
+ const last = group.at(-1);
463
+ return last === undefined
464
+ ? []
465
+ : [
466
+ {
467
+ ...last.entry,
468
+ gate: group.findLast(({ entry }) => entry.gate !== undefined)?.entry.gate,
469
+ },
470
+ ];
471
+ });
468
472
  }
469
473
  function readSnapshot(gates) {
470
474
  const bases = [
@@ -472,17 +476,23 @@ function readSnapshot(gates) {
472
476
  ...gates.units.flatMap((gate) => [gate.root, gate.dir].filter((path) => path !== undefined).map((path) => resolve(path))),
473
477
  ];
474
478
  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
- }
479
+ // A later listing of the same path replaces the earlier modification time at the earlier position.
480
+ const files = new Map([...roots].flatMap((root) => existsSync(root)
481
+ ? listFiles(root)
482
+ .filter((file) => /\.test\.[cm]?[jt]sx?$/u.test(file.path))
483
+ .map((file) => [file.path, file.modified])
484
+ : []));
484
485
  return JSON.stringify([...files].sort(([left], [right]) => left.localeCompare(right)));
485
486
  }
487
+ function collectStableListings(config, named, gates, attempt = 0) {
488
+ const before = readSnapshot(gates);
489
+ const listings = collectListings(config, named, gates);
490
+ if (before === readSnapshot(gates))
491
+ return listings;
492
+ if (attempt === 1)
493
+ throw new Error('Test files changed during both census attempts');
494
+ return collectStableListings(config, named, gates, attempt + 1);
495
+ }
486
496
  function main(argv) {
487
497
  const missing = readMissingFlags(argv, ['--config', '--projects']);
488
498
  if (missing.length > 0) {
@@ -501,16 +511,7 @@ function main(argv) {
501
511
  const canonical = relative(process.cwd(), resolve(config)).split('\\').join('/');
502
512
  const gates = readGates(readScripts(), canonical, readCLIOptions());
503
513
  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
+ const listings = collectStableListings(canonical, named, gates);
514
515
  if (listings === undefined) {
515
516
  console.error('discovery: vitest list failed');
516
517
  return 2;