@orkestrel/scaffold 0.0.108 → 0.0.109

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 (157) hide show
  1. package/dist/agents/skills/orkestrel-dispatch/scripts/bench.js +326 -91
  2. package/dist/agents/skills/orkestrel-dispatch/scripts/brief.js +110 -14
  3. package/dist/agents/skills/orkestrel-dispatch/scripts/chain.js +280 -0
  4. package/dist/agents/skills/orkestrel-dispatch/scripts/cite.js +188 -26
  5. package/dist/agents/skills/orkestrel-dispatch/scripts/launch.js +142 -43
  6. package/dist/agents/skills/orkestrel-dispatch/scripts/login.js +240 -62
  7. package/dist/agents/skills/orkestrel-dispatch/scripts/patch.js +323 -0
  8. package/dist/agents/skills/orkestrel-dispatch/scripts/result.js +169 -32
  9. package/dist/agents/skills/orkestrel-dispatch/scripts/sweep.js +290 -69
  10. package/dist/agents/skills/orkestrel-falsify/scripts/mutate.js +560 -0
  11. package/dist/agents/skills/orkestrel-harden/scripts/discovery.js +1052 -205
  12. package/dist/agents/skills/orkestrel-harden/scripts/measure.js +361 -0
  13. package/dist/agents/skills/orkestrel-harden/scripts/titles.js +455 -80
  14. package/dist/agents/skills/orkestrel-parallel/scripts/cases.js +327 -0
  15. package/dist/agents/skills/orkestrel-publish/scripts/compare.js +298 -51
  16. package/dist/agents/skills/orkestrel-publish/scripts/pins.js +170 -13
  17. package/dist/agents/skills/orkestrel-publish/scripts/swap.js +956 -191
  18. package/dist/agents/skills/orkestrel-publish/scripts/wave.js +707 -112
  19. package/dist/agents/skills/orkestrel-publish/scripts/window.js +235 -41
  20. package/dist/agents/skills/orkestrel-scout/scripts/map.js +341 -54
  21. package/dist/agents/templates/brief.md +1 -1
  22. package/dist/bin/main.js +51 -46
  23. package/dist/bin/main.js.map +1 -1
  24. package/dist/host/AGENTS.md +23 -21
  25. package/dist/host/agents/orchestration.md +43 -18
  26. package/dist/host/agents/platforms/windows.md +3 -3
  27. package/dist/host/agents/skills/orkestrel-align/SKILL.md +10 -13
  28. package/dist/host/agents/skills/orkestrel-align/references/fleet.md +4 -10
  29. package/dist/host/agents/skills/orkestrel-align/references/integration.md +1 -1
  30. package/dist/host/agents/skills/orkestrel-build/SKILL.md +16 -48
  31. package/dist/host/agents/skills/orkestrel-build/agents/openai.yaml +1 -1
  32. package/dist/host/agents/skills/orkestrel-debrief/SKILL.md +19 -16
  33. package/dist/host/agents/skills/orkestrel-debrief/agents/openai.yaml +1 -1
  34. package/dist/host/agents/skills/orkestrel-debrief/references/field-testing.md +15 -28
  35. package/dist/host/agents/skills/orkestrel-debrief/references/instruction-audit.md +5 -6
  36. package/dist/host/agents/skills/orkestrel-debrief/references/retention.md +4 -6
  37. package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +31 -20
  38. package/dist/host/agents/skills/orkestrel-dispatch/references/launch.md +3 -4
  39. package/dist/host/agents/skills/orkestrel-dispatch/scripts/bench.ts +366 -75
  40. package/dist/host/agents/skills/orkestrel-dispatch/scripts/brief.ts +126 -16
  41. package/dist/host/agents/skills/orkestrel-dispatch/scripts/chain.ts +323 -0
  42. package/dist/host/agents/skills/orkestrel-dispatch/scripts/cite.ts +220 -31
  43. package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +176 -33
  44. package/dist/host/agents/skills/orkestrel-dispatch/scripts/login.ts +294 -59
  45. package/dist/host/agents/skills/orkestrel-dispatch/scripts/patch.ts +401 -0
  46. package/dist/host/agents/skills/orkestrel-dispatch/scripts/result.ts +199 -34
  47. package/dist/host/agents/skills/orkestrel-dispatch/scripts/sweep.ts +330 -69
  48. package/dist/host/agents/skills/orkestrel-falsify/SKILL.md +10 -8
  49. package/dist/host/agents/skills/orkestrel-falsify/references/brief.md +1 -5
  50. package/dist/host/agents/skills/orkestrel-falsify/references/reconcile.md +1 -1
  51. package/dist/host/agents/skills/orkestrel-falsify/scripts/mutate.ts +701 -0
  52. package/dist/host/agents/skills/orkestrel-harden/SKILL.md +16 -12
  53. package/dist/host/agents/skills/orkestrel-harden/references/bindings.md +23 -81
  54. package/dist/host/agents/skills/orkestrel-harden/references/centralization.md +4 -5
  55. package/dist/host/agents/skills/orkestrel-harden/references/contract.md +2 -0
  56. package/dist/host/agents/skills/orkestrel-harden/references/document.md +33 -0
  57. package/dist/host/agents/skills/orkestrel-harden/references/equivalence.md +6 -3
  58. package/dist/host/agents/skills/orkestrel-harden/references/hardening.md +8 -4
  59. package/dist/host/agents/skills/orkestrel-harden/references/research.md +2 -0
  60. package/dist/host/agents/skills/orkestrel-harden/scripts/discovery.ts +1272 -279
  61. package/dist/host/agents/skills/orkestrel-harden/scripts/measure.ts +425 -0
  62. package/dist/host/agents/skills/orkestrel-harden/scripts/titles.ts +603 -98
  63. package/dist/host/agents/skills/orkestrel-journey/SKILL.md +75 -98
  64. package/dist/host/agents/skills/orkestrel-journey/references/captures.md +54 -17
  65. package/dist/host/agents/skills/orkestrel-journey/references/decide.md +1 -20
  66. package/dist/host/agents/skills/orkestrel-journey/references/layer.md +21 -31
  67. package/dist/host/agents/skills/orkestrel-journey/references/styles.md +1 -1
  68. package/dist/host/agents/skills/orkestrel-parallel/SKILL.md +28 -24
  69. package/dist/host/agents/skills/orkestrel-parallel/references/claude.md +15 -13
  70. package/dist/host/agents/skills/orkestrel-parallel/references/codex.md +1 -0
  71. package/dist/host/agents/skills/orkestrel-parallel/references/cursor.md +13 -13
  72. package/dist/host/agents/skills/orkestrel-parallel/scripts/cases.ts +389 -0
  73. package/dist/host/agents/skills/orkestrel-polish/SKILL.md +16 -20
  74. package/dist/host/agents/skills/orkestrel-polish/references/capture-harness.md +16 -26
  75. package/dist/host/agents/skills/orkestrel-publish/SKILL.md +12 -26
  76. package/dist/host/agents/skills/orkestrel-publish/agents/openai.yaml +2 -2
  77. package/dist/host/agents/skills/orkestrel-publish/references/adopt.md +23 -0
  78. package/dist/host/agents/skills/orkestrel-publish/references/release.md +4 -4
  79. package/dist/host/agents/skills/orkestrel-publish/references/wave.md +10 -9
  80. package/dist/host/agents/skills/orkestrel-publish/references/window.md +20 -25
  81. package/dist/host/agents/skills/orkestrel-publish/scripts/compare.ts +348 -59
  82. package/dist/host/agents/skills/orkestrel-publish/scripts/pins.ts +202 -22
  83. package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +1061 -191
  84. package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +812 -140
  85. package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +280 -38
  86. package/dist/host/agents/skills/orkestrel-scout/SKILL.md +3 -3
  87. package/dist/host/agents/skills/orkestrel-scout/scripts/map.ts +428 -70
  88. package/dist/host/agents/templates/brief.md +1 -1
  89. package/dist/host/agents/transports/claude-cli.md +3 -3
  90. package/dist/host/agents/transports/codex.md +2 -7
  91. package/dist/host/agents/transports/cursor.md +2 -3
  92. package/dist/host/claude/AGENTS.md +2 -2
  93. package/dist/host/claude/agents/analyst.md +3 -3
  94. package/dist/host/claude/agents/astra.md +3 -3
  95. package/dist/host/claude/agents/builder.md +1 -1
  96. package/dist/host/claude/agents/checker.md +1 -1
  97. package/dist/host/claude/agents/distiller.md +1 -1
  98. package/dist/host/claude/agents/grok.md +1 -1
  99. package/dist/host/claude/agents/opus.md +2 -2
  100. package/dist/host/claude/agents/orkestrel.md +57 -90
  101. package/dist/host/claude/agents/researcher.md +1 -1
  102. package/dist/host/claude/agents/reviewer.md +4 -0
  103. package/dist/host/claude/agents/scout.md +1 -1
  104. package/dist/host/claude/agents/verifier.md +1 -1
  105. package/dist/host/claude/rules/application.md +14 -21
  106. package/dist/host/claude/rules/architecture.md +34 -44
  107. package/dist/host/claude/rules/browser.md +3 -4
  108. package/dist/host/claude/rules/campaign.md +2 -2
  109. package/dist/host/claude/rules/documentation.md +25 -12
  110. package/dist/host/claude/rules/names.md +9 -4
  111. package/dist/host/claude/rules/patterns.md +6 -7
  112. package/dist/host/claude/rules/portability.md +1 -1
  113. package/dist/host/claude/rules/quality.md +4 -5
  114. package/dist/host/claude/rules/styles.md +2 -2
  115. package/dist/host/claude/rules/tests.md +57 -114
  116. package/dist/host/claude/rules/typescript.md +7 -9
  117. package/dist/host/claude/rules/workspace.md +21 -19
  118. package/dist/host/claude/rules/writing.md +2 -5
  119. package/dist/host/claude/skills/orkestrel-dispatch/SKILL.md +1 -1
  120. package/dist/host/claude/skills/orkestrel-harden/SKILL.md +1 -1
  121. package/dist/host/claude/skills/orkestrel-publish/SKILL.md +1 -1
  122. package/dist/host/claude/skills/orkestrel-scout/SKILL.md +1 -1
  123. package/dist/host/codex/agents/analyst.toml +9 -3
  124. package/dist/host/codex/agents/astra.toml +3 -4
  125. package/dist/host/codex/agents/builder.toml +1 -1
  126. package/dist/host/codex/agents/opus.toml +3 -4
  127. package/dist/host/codex/agents/orkestrel.toml +1 -1
  128. package/dist/host/codex/agents/researcher.toml +2 -2
  129. package/dist/host/codex/agents/verifier.toml +1 -1
  130. package/dist/host/configs/helpers.ts +21 -1
  131. package/dist/host/configs/policy.ts +299 -15
  132. package/dist/host/dotfiles/oxlintrc.json +137 -3
  133. package/dist/host/guides/agent.md +111 -134
  134. package/dist/host/guides/capture.md +801 -0
  135. package/dist/host/guides/probe.md +27 -8
  136. package/dist/host/guides/scaffold.md +96 -18
  137. package/dist/host/guides/study.md +534 -440
  138. package/dist/host/guides/test.md +111 -44
  139. package/dist/host/manifest.json +160 -240
  140. package/dist/host/tests/config.test.ts +971 -187
  141. package/dist/host/tests/policy.test.ts +127 -1
  142. package/dist/host/tests/setupPolicy.ts +893 -33
  143. package/dist/src/core/index.cjs +214 -86
  144. package/dist/src/core/index.cjs.map +1 -1
  145. package/dist/src/core/index.d.cts +122 -38
  146. package/dist/src/core/index.d.ts +122 -38
  147. package/dist/src/core/index.js +209 -85
  148. package/dist/src/core/index.js.map +1 -1
  149. package/dist/src/server/index.cjs +20 -27
  150. package/dist/src/server/index.cjs.map +1 -1
  151. package/dist/src/server/index.js +21 -28
  152. package/dist/src/server/index.js.map +1 -1
  153. package/package.json +11 -11
  154. package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +0 -283
  155. package/dist/host/agents/skills/orkestrel-dispatch/references/bench.md +0 -25
  156. package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +0 -333
  157. package/dist/host/guides/supervisor.md +0 -4890
@@ -1,32 +1,79 @@
1
1
  // Let Vitest select each gate's tests, then compare its identities with each config/mode universe.
2
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
- // 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.
4
+ // Each value is a separate argument: `--flag=value` and any flag the script does not read exit 64.
5
+ // Roots are the package scripts no `npm run` invokes; `npm test` runs the root `test`, which stays a
6
+ // root. Follow root chains and forwarded arguments through `npm run [-s|--silent] NAME`,
7
+ // `npm run-script`, `npm test`, and `npm t`. Read a command past an environment prefix, `cross-env`,
8
+ // and a redirection. A gate is a Vitest run; list, bench, watch, dev, `--watch` without `=false`, and
9
+ // the mergeReports, listTags, and clearCache invocations are not gates. A test file counts as gated
10
+ // by a command only when `node` runs it as an argument (`node FILE`, `node --test FILE`).
11
+ // Collect an unfiltered full listing for each config/mode/root/dir, including the base config. Pass
12
+ // gate arguments verbatim except the reporter, output-file, coverage, color, cache, silent, UI,
13
+ // watch, run-mode, inspector, and standalone options; use filesOnly for file selection and full
14
+ // collection with --staticParse=false for name, tag, line, and shard selection, so runtime titles,
15
+ // setup modules, and the worker sequencer participate. Compare file/project/name/line/column
16
+ // identities without renaming projects. Preserve duplicate names within a listing and merge across
17
+ // units. Exit 2 naming the Vitest version when the option table lacks a required option.
18
+ // With --projects, list only the named projects: the universe, each gate listing, each project
19
+ // reading, the units enumerated, and the empty-project rescan carry the names, and a gate that
20
+ // reaches none of them is not listed. An exact name never lists a project outside the set, and the
21
+ // refusal for a name that matches none lists no other project either. A name holding `*` or opening
22
+ // with `!` is a Vitest pattern the script cannot match beforehand, so a gate that names projects
23
+ // keeps its own and a listing for such a name can reach projects beyond the names. Exit 64 for a
24
+ // name no project in the config matches, and skip the undiscovered scan.
25
+ // Flag ungated projects, empty chained projects, and undiscovered files under tests/ (the test
26
+ // and spec names, any extension). Report marker counts for the forms that need a ruling (`.todo`,
27
+ // `.skipIf`, `.runIf`, `.fails`, a `retry` option, a timeout option or a numeric last argument),
28
+ // read as call forms outside comments and strings; `vitest/no-focused-tests` and `vitest/no-disabled-tests` refuse `.only` and `.skip`. Report empty
29
+ // standalone workbenches without a flag: an empty project no chain containing " > " names.
13
30
  // 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.
31
+ // Exit 0 with no flag, 3 with a flag, 2 for missing Vitest, an option table without a required
32
+ // option, unsupported related commands, CLI/help/argument/listing/output failures, repeated
33
+ // test-file changes, or filesystem/JSON errors; exit 64 on usage.
17
34
  import { spawnSync } from 'node:child_process'
18
- import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
35
+ import {
36
+ existsSync,
37
+ mkdtempSync,
38
+ readdirSync,
39
+ readFileSync,
40
+ realpathSync,
41
+ rmSync,
42
+ statSync,
43
+ } from 'node:fs'
19
44
  import { tmpdir } from 'node:os'
20
45
  import { basename, join, relative, resolve } from 'node:path'
21
- import {
22
- listFiles,
23
- readMissingFlags,
24
- readOption,
25
- } from '../../orkestrel-dispatch/scripts/helpers.ts'
26
-
27
- const VITEST = 'node_modules/vitest/vitest.mjs'
28
- const MARKERS: readonly string[] = ['.skip(', '.todo(', '.skipIf(', '.runIf(', 'retry:', 'timeout:']
29
- const OMITTED = new Set([
46
+
47
+ /** Names the Vitest entry the census runs. */
48
+ export const VITEST = 'node_modules/vitest/vitest.mjs'
49
+
50
+ /** Lists the marker names the census counts, in report order. */
51
+ export const MARKERS: readonly string[] = [
52
+ '.todo',
53
+ '.skipIf',
54
+ '.runIf',
55
+ '.fails',
56
+ 'retry',
57
+ 'timeout',
58
+ ]
59
+
60
+ /** Lists the options the scraped Vitest help table must hold before a listing runs. */
61
+ export const REQUIRED_OPTIONS: readonly string[] = [
62
+ '--reporter',
63
+ '--project',
64
+ '--config',
65
+ '--filesOnly',
66
+ '--staticParse',
67
+ ]
68
+
69
+ /** Lists the flags this script reads, value flags and switches alike. */
70
+ export const FLAGS: readonly string[] = ['--config', '--projects', '--json']
71
+
72
+ /** Holds the usage line printed on a usage refusal. */
73
+ export const USAGE = 'usage: discovery.ts [--config vite.config.ts] [--projects a,b] [--json]'
74
+
75
+ /** Names the options a gate listing drops, because they change the run and not the selection. */
76
+ export const OMITTED = new Set([
30
77
  'reporter',
31
78
  'outputFile',
32
79
  'coverage',
@@ -40,16 +87,111 @@ const OMITTED = new Set([
40
87
  'inspectBrk',
41
88
  'standalone',
42
89
  ])
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
90
 
47
- interface Option {
91
+ /** Bounds one listing, so an option that waits for input exits 2 naming its gate. */
92
+ export const LISTING_TIMEOUT = 600_000
93
+
94
+ /** Caps the characters of a name an error message echoes. */
95
+ export const NAME_LIMIT = 80
96
+
97
+ /** Names the directories a tree walk skips by default, so build output and notes stay unread. */
98
+ export const SKIPPED_DIRECTORIES: ReadonlySet<string> = new Set([
99
+ 'node_modules',
100
+ '.git',
101
+ 'dist',
102
+ 'tmp',
103
+ '.orkestrel',
104
+ '.idea',
105
+ ])
106
+
107
+ /** Names the only directories the undiscovered scan skips. */
108
+ export const UNDISCOVERED_SKIPS: ReadonlySet<string> = new Set(['node_modules', '.git'])
109
+
110
+ /** Matches the test file names the modification snapshot watches. */
111
+ export const TEST_FILE_PATTERN = /\.test\.[cm]?[jt]sx?$/u
112
+
113
+ /** Matches the test and spec file names the undiscovered scan reports. */
114
+ export const DISCOVERABLE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u
115
+
116
+ /** Matches a test file path a `node` command can run as an argument. */
117
+ export const TEST_ARGUMENT_PATTERN = /^(?:tests|src|app)\/[A-Za-z0-9_./-]+\.test\.[cm]?ts$/u
118
+
119
+ /** Matches one command token, keeping a quoted run, an `&`-form redirection, or a separator whole. */
120
+ export const COMMAND_TOKEN_PATTERN = /\d*[<>]&[\d-]+|&>>?|(?:[^\s"';&|]+|"[^"]*"|'[^']*')+|[;&|]+/gu
121
+
122
+ /** Matches a redirection operator that takes the next token as its target. */
123
+ export const REDIRECT_OPERATOR_PATTERN = /^(?:\d*>>?|\d*<|&>>?)$/u
124
+
125
+ /** Matches a redirection that carries its target or duplicates a descriptor. */
126
+ export const REDIRECT_TARGET_PATTERN = /^(?:(?:\d*>>?|\d*<|&>>?)[^\s<>&]\S*|\d*[<>]&[\d-]+)$/u
127
+
128
+ /** Matches a leading `NAME=value` environment assignment. */
129
+ export const ENVIRONMENT_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=/u
130
+
131
+ /** Matches a `--watch` switch that is not turned off in its own token. */
132
+ export const WATCH_PATTERN = /^(?:--watch|--watch=(?!false$).*|-w)$/u
133
+
134
+ /** Matches the reporting and cache-maintenance switches that make an invocation no gate. */
135
+ export const MAINTENANCE_PATTERN =
136
+ /^(?:--mergeReports|--merge-reports|--listTags|--list-tags|--clearCache|--clear-cache)(?:=|$)/u
137
+
138
+ /** Matches the options and filters that make a gate select tests by name, tag, line, or shard. */
139
+ export const FULL_PATTERN =
140
+ /^(?:-t|--testNamePattern|--test-name-pattern|--tagsFilter|--tags-filter|--shard|--(?:no-)?allowOnly|--(?:no-)?allow-only|--(?:no-)?strictTags|--(?:no-)?strict-tags)(?:=|$)/u
141
+
142
+ /** Matches a numeric literal token such as a timeout argument. */
143
+ export const NUMBER_PATTERN = /^\d[\d_]*(?:\.\d+)?(?:e\d+)?$/iu
144
+
145
+ /** Matches the quoted string literals the scanner keeps whole. */
146
+ export const STRING_PATTERN = /^(?:'(?:\\[\s\S]|[^'\\\r\n])*'|"(?:\\[\s\S]|[^"\\\r\n])*")/u
147
+
148
+ /** Matches a regular expression literal opening at a slash. */
149
+ export const REGEX_PATTERN = /^\/(?:\\.|\[(?:\\.|[^\]\\\r\n])*\]|[^/\\[\r\n])+\/[a-z]*/u
150
+
151
+ /** Matches an identifier or a number as one scanner word. */
152
+ export const WORD_PATTERN = /^(?:[\p{ID_Start}_$][\p{ID_Continue}$]*|\d[\w.]*)/u
153
+
154
+ /** Names the keywords after which a slash opens a regular expression. */
155
+ export const REGEX_KEYWORDS: ReadonlySet<string> = new Set([
156
+ 'return',
157
+ 'typeof',
158
+ 'case',
159
+ 'throw',
160
+ 'in',
161
+ 'of',
162
+ 'void',
163
+ 'delete',
164
+ 'yield',
165
+ 'await',
166
+ 'else',
167
+ 'do',
168
+ 'instanceof',
169
+ 'new',
170
+ ])
171
+
172
+ /** Lists the tokens that open a bracket group. */
173
+ export const OPENERS: readonly string[] = ['(', '[', '{']
174
+
175
+ /** Lists the tokens that close a bracket group. */
176
+ export const CLOSERS: readonly string[] = [')', ']', '}']
177
+
178
+ /** Names the calls whose first argument list precedes the registration call. */
179
+ export const ARGUMENT_MODIFIERS: ReadonlySet<string> = new Set(['each', 'skipIf', 'runIf', 'for'])
180
+
181
+ /** Names the registration modifiers that need a ruling when a test file carries them. */
182
+ export const CHAIN_MARKERS: ReadonlySet<string> = new Set(['todo', 'skipIf', 'runIf', 'fails'])
183
+
184
+ /** Names the identifiers that open a test registration. */
185
+ export const REGISTRARS: ReadonlySet<string> = new Set(['it', 'test', 'describe', 'suite'])
186
+
187
+ /** Describes one Vitest command-line option read from the help table. */
188
+ export interface Option {
48
189
  readonly name: string
49
190
  readonly argument: string | undefined
50
191
  }
51
192
 
52
- interface Collected {
193
+ /** Describes one test a listing collected, with the gate that names it. */
194
+ export interface Collected {
53
195
  readonly file: string
54
196
  readonly projectName: string
55
197
  readonly name: string | undefined
@@ -58,54 +200,76 @@ interface Collected {
58
200
  readonly gate?: string | undefined
59
201
  }
60
202
 
61
- interface Selection {
203
+ /** Holds one listing's tests and whether Vitest matched the project filter it was given. */
204
+ export interface Listed {
205
+ readonly entries: readonly Collected[]
206
+ readonly matched: boolean
207
+ }
208
+
209
+ /** Holds the tokens a gate command keeps and the position the option reader has reached. */
210
+ export interface Selection {
62
211
  readonly selected: readonly string[]
63
212
  readonly skip: number
64
213
  readonly closed: boolean
65
214
  }
66
215
 
67
- interface Gates {
216
+ /** Holds the gates the root scripts reach and the files `node` commands run. */
217
+ export interface Gates {
68
218
  readonly units: readonly Gate[]
69
219
  readonly files: ReadonlyMap<string, string>
70
220
  }
71
221
 
72
- interface Unit {
222
+ /** Names one config, mode, root, and dir combination a listing runs under. */
223
+ export interface Unit {
73
224
  readonly config: string | undefined
74
225
  readonly mode: string | undefined
75
226
  readonly root?: string | undefined
76
227
  readonly dir?: string | undefined
77
228
  }
78
229
 
79
- interface Gate extends Unit {
230
+ /** Describes one Vitest run a root script chain reaches. */
231
+ export interface Gate extends Unit {
80
232
  readonly projects: readonly string[]
81
233
  readonly chain: string
82
234
  readonly args: readonly string[]
83
235
  readonly full: boolean
84
236
  }
85
237
 
86
- interface Listing extends Unit {
238
+ /** Holds one unit's tests with the gate that names each and the projects it lists empty. */
239
+ export interface Listing extends Unit {
87
240
  readonly collected: readonly Collected[]
88
241
  readonly empty: readonly string[]
89
242
  }
90
243
 
91
- interface UnitReading {
244
+ /** Pairs one unit's listing with the projects that collected a test in it. */
245
+ export interface UnitResult {
92
246
  readonly listing: Listing
93
247
  readonly populated: readonly string[]
94
248
  }
95
249
 
96
- interface ProjectReading {
250
+ /** States whether one project collected a test and whether the report calls it empty. */
251
+ export interface ProjectResult {
97
252
  readonly project: string
98
253
  readonly populated: boolean
99
254
  readonly empty: boolean
100
255
  }
101
256
 
102
- interface Invocation {
257
+ /** Describes one `npm` script call a command makes. */
258
+ export interface Invocation {
103
259
  readonly name: string
104
260
  readonly args: readonly string[]
105
261
  readonly ancestors: readonly string[]
262
+ readonly shorthand: boolean
263
+ }
264
+
265
+ /** Describes one script a root chain visits, with the commands its forwarded arguments extend. */
266
+ export interface Visit {
267
+ readonly chain: string
268
+ readonly commands: ReadonlyArray<readonly string[]>
106
269
  }
107
270
 
108
- interface Project {
271
+ /** Describes one project row the report prints. */
272
+ export interface Project {
109
273
  readonly name: string
110
274
  readonly gate: string | undefined
111
275
  readonly files: number
@@ -113,13 +277,152 @@ interface Project {
113
277
  readonly units?: readonly Unit[]
114
278
  }
115
279
 
116
- interface Census {
280
+ /** Holds one collected file with its projects and marker counts. */
281
+ export interface Census {
117
282
  readonly file: string
118
283
  readonly projects: readonly string[]
119
284
  readonly markers: Readonly<Record<string, number>>
120
285
  }
121
286
 
122
- function readCLIOptions(): ReadonlyMap<string, Option> {
287
+ /** Describes one scanner token: a word, a punctuation mark, or a whole literal. */
288
+ export interface CodeToken {
289
+ readonly kind: 'word' | 'punct' | 'string' | 'template' | 'regex'
290
+ readonly text: string
291
+ }
292
+
293
+ /** Holds the tokens of one source text and the index each opening bracket closes at. */
294
+ export interface CodeScan {
295
+ readonly tokens: readonly CodeToken[]
296
+ readonly closes: ReadonlyMap<number, number>
297
+ }
298
+
299
+ /** Holds the modifier names of a registration chain and the index of its registration call. */
300
+ export interface Chain {
301
+ readonly names: readonly string[]
302
+ readonly call: number
303
+ }
304
+
305
+ /** Names a run of tokens from a start index up to, and excluding, an end index. */
306
+ export interface Span {
307
+ readonly start: number
308
+ readonly end: number
309
+ }
310
+
311
+ /**
312
+ * Normalizes a host path to forward slashes.
313
+ *
314
+ * @param path - A host path.
315
+ * @returns The same path with every backslash replaced by a slash.
316
+ */
317
+ export function normalizeSlashes(path: string): string {
318
+ return path.split('\\').join('/')
319
+ }
320
+
321
+ /**
322
+ * Reads the value that follows a flag.
323
+ *
324
+ * @remarks
325
+ * The first occurrence of a flag wins, a following value that opens with `--` is a missing value,
326
+ * and the `--flag=value` form is not read.
327
+ *
328
+ * @param argv - The arguments after the script path.
329
+ * @param name - The flag, such as `--config`.
330
+ * @returns The value after the flag, or undefined when the flag is absent, last, or followed by
331
+ * another flag.
332
+ */
333
+ export function readOption(argv: readonly string[], name: string): string | undefined {
334
+ const index = argv.indexOf(name)
335
+ const value = index === -1 ? undefined : argv[index + 1]
336
+ return value === undefined || value.startsWith('--') ? undefined : value
337
+ }
338
+
339
+ /**
340
+ * Names the value flags a caller gave with no value.
341
+ *
342
+ * @param argv - The arguments after the script path.
343
+ * @param names - The flags that take a value.
344
+ * @returns Each named flag that appears last or is followed by another flag, in argument order.
345
+ */
346
+ export function readMissingFlags(
347
+ argv: readonly string[],
348
+ names: readonly string[],
349
+ ): readonly string[] {
350
+ return names.filter((name) => argv.includes(name) && readOption(argv, name) === undefined)
351
+ }
352
+
353
+ /**
354
+ * Names the flags a caller gave that the script does not read.
355
+ *
356
+ * @remarks
357
+ * Reads the arguments before a bare `--`. A `--flag=value` argument is unknown, because the script
358
+ * reads `--flag value` pairs only.
359
+ *
360
+ * @param argv - The arguments after the script path.
361
+ * @param flags - Every flag the script reads, value flags and switches alike.
362
+ * @returns Each argument that opens with `--` and is not a read flag, in argument order.
363
+ */
364
+ export function readUnknownFlags(
365
+ argv: readonly string[],
366
+ flags: readonly string[],
367
+ ): readonly string[] {
368
+ const end = argv.indexOf('--')
369
+ return (end === -1 ? argv : argv.slice(0, end)).filter(
370
+ (argument) => argument.startsWith('--') && !flags.includes(argument),
371
+ )
372
+ }
373
+
374
+ /** Describes one file a tree walk found, with the modification time the snapshot reads. */
375
+ export interface WalkedFile {
376
+ readonly path: string
377
+ readonly modified: number
378
+ }
379
+
380
+ /**
381
+ * Lists every regular file beneath a directory, skipping the named directories at any depth.
382
+ *
383
+ * @param directory - The directory to walk.
384
+ * @param skipped - The directory names to skip. Default: {@link SKIPPED_DIRECTORIES}.
385
+ * @returns Each file's forward-slash path and modification time, in walk order.
386
+ * @throws Thrown when `directory` does not exist or cannot be read; check `existsSync` first.
387
+ */
388
+ export function listFiles(
389
+ directory: string,
390
+ skipped: ReadonlySet<string> = SKIPPED_DIRECTORIES,
391
+ ): readonly WalkedFile[] {
392
+ const files: WalkedFile[] = []
393
+ const pending = [directory]
394
+ while (pending.length > 0) {
395
+ const current = pending.pop()
396
+ if (current === undefined) break
397
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
398
+ if (skipped.has(entry.name)) continue
399
+ const path = join(current, entry.name)
400
+ if (entry.isDirectory()) pending.push(path)
401
+ else if (entry.isFile()) {
402
+ files.push({ path: normalizeSlashes(path), modified: statSync(path).mtimeMs })
403
+ }
404
+ }
405
+ }
406
+ return files
407
+ }
408
+
409
+ /**
410
+ * Describes a name for an error message, JSON-encoded and length-capped.
411
+ *
412
+ * @param name - The untrusted name to echo.
413
+ * @returns The encoded name, cut where it exceeds the cap.
414
+ */
415
+ export function describeName(name: string): string {
416
+ return JSON.stringify(name.length > NAME_LIMIT ? `${name.slice(0, NAME_LIMIT)}…` : name)
417
+ }
418
+
419
+ /**
420
+ * Reads the Vitest option table the installed CLI prints for its list command.
421
+ *
422
+ * @returns Each option keyed by its long, kebab, negated, and short spellings.
423
+ * @throws Thrown when the help command fails.
424
+ */
425
+ export function readCLIOptions(): ReadonlyMap<string, Option> {
123
426
  const help = spawnSync(process.execPath, [VITEST, 'list', '--help', '--expand-help'], {
124
427
  encoding: 'utf8',
125
428
  windowsHide: true,
@@ -142,8 +445,42 @@ function readCLIOptions(): ReadonlyMap<string, Option> {
142
445
  )
143
446
  }
144
447
 
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 {
448
+ /**
449
+ * Names the required options an option table lacks.
450
+ *
451
+ * @param options - The table `readCLIOptions` returned.
452
+ * @returns Each required option the table does not hold, in required order.
453
+ */
454
+ export function readMissingOptions(options: ReadonlyMap<string, Option>): readonly string[] {
455
+ return REQUIRED_OPTIONS.filter((name) => !options.has(name))
456
+ }
457
+
458
+ /**
459
+ * Reads the installed Vitest version.
460
+ *
461
+ * @returns The version string, or `unknown` when the manifest names none.
462
+ */
463
+ export function readVitestVersion(): string {
464
+ const parsed: unknown = JSON.parse(readFileSync('node_modules/vitest/package.json', 'utf8'))
465
+ return typeof parsed === 'object' &&
466
+ parsed !== null &&
467
+ 'version' in parsed &&
468
+ typeof parsed.version === 'string'
469
+ ? parsed.version
470
+ : 'unknown'
471
+ }
472
+
473
+ /**
474
+ * Skips the options of a `node` command so the first non-option token is the runner.
475
+ *
476
+ * @remarks
477
+ * An option that takes a value consumes the token after it, so that token is never read as a runner.
478
+ *
479
+ * @param command - The command tokens.
480
+ * @param index - The index to start reading options at.
481
+ * @returns The index of the first non-option token, or -1 for an eval, print, or check command.
482
+ */
483
+ export function skipRunnerOptions(command: readonly string[], index: number): number {
147
484
  const skipped = [...command.entries()].reduce<{
148
485
  readonly next: number
149
486
  readonly found: number | undefined
@@ -178,7 +515,13 @@ function skipRunnerOptions(command: readonly string[], index: number): number {
178
515
  return skipped.found ?? skipped.next
179
516
  }
180
517
 
181
- function findRunner(command: readonly string[]): number {
518
+ /**
519
+ * Finds the Vitest runner in a command.
520
+ *
521
+ * @param command - The command tokens, past any environment prefix.
522
+ * @returns The runner's index, or -1 when the command does not run Vitest.
523
+ */
524
+ export function findRunner(command: readonly string[]): number {
182
525
  const executable = basename(command[0] ?? '').replace(/\.(?:exe|cmd)$/u, '')
183
526
  const start =
184
527
  executable === 'npm'
@@ -197,9 +540,18 @@ function findRunner(command: readonly string[]): number {
197
540
  : -1
198
541
  }
199
542
 
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.
202
- function readSelection(
543
+ /**
544
+ * Keeps the tokens of a gate command that select tests and drops the run-mode options.
545
+ *
546
+ * @remarks
547
+ * The first `--` ends the options, so the rest of the arguments pass verbatim. A runner option that
548
+ * takes a value consumes the argument after it, so a step below `skip` reads nothing.
549
+ *
550
+ * @param argv - The tokens after the Vitest runner and verb.
551
+ * @param options - The option table `readCLIOptions` returned.
552
+ * @returns The tokens to pass to a listing.
553
+ */
554
+ export function readSelection(
203
555
  argv: readonly string[],
204
556
  options: ReadonlyMap<string, Option>,
205
557
  ): readonly string[] {
@@ -230,7 +582,17 @@ function readSelection(
230
582
  ).selected
231
583
  }
232
584
 
233
- function readArguments(argv: readonly string[], names: readonly string[]): readonly string[] {
585
+ /**
586
+ * Reads every value a named flag carries before the first `--`.
587
+ *
588
+ * @param argv - The tokens to read.
589
+ * @param names - The flag spellings to read.
590
+ * @returns Each value of the flag in either the `--flag value` or the `--flag=value` form.
591
+ */
592
+ export function readArguments(
593
+ argv: readonly string[],
594
+ names: readonly string[],
595
+ ): readonly string[] {
234
596
  const end = argv.indexOf('--')
235
597
  return (end < 0 ? argv : argv.slice(0, end)).flatMap((token, index) => {
236
598
  const equals = token.indexOf('=')
@@ -240,7 +602,12 @@ function readArguments(argv: readonly string[], names: readonly string[]): reado
240
602
  })
241
603
  }
242
604
 
243
- function readScripts(): Readonly<Record<string, string>> {
605
+ /**
606
+ * Reads the script table of the package manifest in the working directory.
607
+ *
608
+ * @returns Each script name with its command text.
609
+ */
610
+ export function readScripts(): Readonly<Record<string, string>> {
244
611
  const parsed: unknown = JSON.parse(readFileSync('package.json', 'utf8'))
245
612
  if (typeof parsed !== 'object' || parsed === null) return {}
246
613
  const scripts = Object.fromEntries(Object.entries(parsed)).scripts
@@ -252,28 +619,218 @@ function readScripts(): Readonly<Record<string, string>> {
252
619
  )
253
620
  }
254
621
 
255
- function readCommands(text: string): ReadonlyArray<readonly string[]> {
256
- const tokens = [...text.matchAll(/(?:[^\s"';&|]+|"[^"]*"|'[^']*')+|[;&|]+/gu)].map(
257
- (match) => match[0],
622
+ /**
623
+ * Drops the redirections from a command's tokens.
624
+ *
625
+ * @param tokens - The raw command tokens, quotes still attached.
626
+ * @returns The tokens without a redirection operator, its target, or a descriptor duplication.
627
+ */
628
+ export function stripRedirections(tokens: readonly string[]): readonly string[] {
629
+ return tokens.filter((token, index) => {
630
+ const previous = tokens[index - 1]
631
+ return (
632
+ !(previous !== undefined && REDIRECT_OPERATOR_PATTERN.test(previous)) &&
633
+ !REDIRECT_OPERATOR_PATTERN.test(token) &&
634
+ !REDIRECT_TARGET_PATTERN.test(token)
635
+ )
636
+ })
637
+ }
638
+
639
+ /**
640
+ * Drops the leading `cross-env` word and `NAME=value` assignments from a command's tokens.
641
+ *
642
+ * @param tokens - The command tokens.
643
+ * @returns The tokens from the first word that is neither, empty when the command is only a prefix.
644
+ */
645
+ export function stripEnvironment(tokens: readonly string[]): readonly string[] {
646
+ const start = tokens.findIndex(
647
+ (token) => token !== 'cross-env' && !ENVIRONMENT_PATTERN.test(token),
258
648
  )
649
+ return start < 0 ? [] : tokens.slice(start)
650
+ }
651
+
652
+ /**
653
+ * Splits a script's text into its commands.
654
+ *
655
+ * @param text - The script text, with `;`, `&`, and `|` separating commands.
656
+ * @returns The commands as tokens, past redirections and environment prefixes, quotes removed.
657
+ */
658
+ export function readCommands(text: string): ReadonlyArray<readonly string[]> {
659
+ const tokens = [...text.matchAll(COMMAND_TOKEN_PATTERN)].map((match) => match[0])
259
660
  // A separator token ends a command, so the commands are the runs of tokens between the cuts.
260
661
  const cuts = tokens.flatMap((token, index) => (/^[;&|]+$/u.test(token) ? [index] : []))
261
662
  return [-1, ...cuts].map((cut, at) =>
262
- tokens.slice(cut + 1, cuts[at] ?? tokens.length).map((token) => token.replace(/["']/gu, '')),
663
+ stripEnvironment(stripRedirections(tokens.slice(cut + 1, cuts[at] ?? tokens.length))).map(
664
+ (token) => token.replace(/["']/gu, ''),
665
+ ),
263
666
  )
264
667
  }
265
668
 
266
- function listInvocations(commands: ReadonlyArray<readonly string[]>): readonly Invocation[] {
267
- return commands.flatMap((command) => {
669
+ /**
670
+ * Lists the script calls the commands make.
671
+ *
672
+ * @remarks
673
+ * Reads `npm run [-s|--silent] NAME`, `npm run-script NAME`, `npm test`, and `npm t`; the last two
674
+ * are shorthand, which runs a script a person also starts directly. Arguments after `--` forward.
675
+ *
676
+ * @param commands - The commands to read.
677
+ * @returns One invocation per script call.
678
+ */
679
+ export function listInvocations(commands: ReadonlyArray<readonly string[]>): readonly Invocation[] {
680
+ return commands.flatMap((command): Invocation[] => {
268
681
  const index = command.indexOf('npm')
269
- const name = command[index + 2]
270
- if (index < 0 || command[index + 1] !== 'run' || name === undefined) return []
271
- const separator = command.indexOf('--', index + 3)
272
- return [{ name, args: separator < 0 ? [] : command.slice(separator + 1), ancestors: [] }]
682
+ if (index < 0) return []
683
+ const verb = command[index + 1]
684
+ const separator = command.indexOf('--', index + 2)
685
+ const args = separator < 0 ? [] : command.slice(separator + 1)
686
+ if (verb === 'test' || verb === 't') {
687
+ return [{ name: 'test', args, ancestors: [], shorthand: true }]
688
+ }
689
+ if (verb !== 'run' && verb !== 'run-script') return []
690
+ const name = (
691
+ separator < 0 ? command.slice(index + 2) : command.slice(index + 2, separator)
692
+ ).find((token) => !token.startsWith('-'))
693
+ return name === undefined ? [] : [{ name, args, ancestors: [], shorthand: false }]
273
694
  })
274
695
  }
275
696
 
276
- function readGates(
697
+ /**
698
+ * Walks the scripts a root reaches, last-pushed first, each script once per forwarded arguments.
699
+ *
700
+ * @param commands - The commands of every script, by name.
701
+ * @param root - The root script the chain starts at.
702
+ * @param pending - The invocations left to visit.
703
+ * @param visited - The name and argument keys already visited.
704
+ * @returns One visit per script reached, in visit order.
705
+ */
706
+ export function walkInvocations(
707
+ commands: Readonly<Record<string, ReadonlyArray<readonly string[]>>>,
708
+ root: string,
709
+ pending: readonly Invocation[],
710
+ visited: ReadonlySet<string>,
711
+ ): readonly Visit[] {
712
+ const invocation = pending.at(-1)
713
+ if (invocation === undefined) return []
714
+ const rest = pending.slice(0, -1)
715
+ const { name, args, ancestors } = invocation
716
+ const key = JSON.stringify([name, args])
717
+ if (visited.has(key) || ancestors.includes(name)) {
718
+ return walkInvocations(commands, root, rest, visited)
719
+ }
720
+ const seen = new Set([...visited, key])
721
+ const script = commands[name]
722
+ if (script === undefined) return walkInvocations(commands, root, rest, seen)
723
+ const expanded = script.map((command, index) =>
724
+ index === script.length - 1 ? [...command, ...args] : command,
725
+ )
726
+ const children = listInvocations(expanded).map((entry) => ({
727
+ ...entry,
728
+ ancestors: [...ancestors, name],
729
+ }))
730
+ return [
731
+ { chain: root === name ? name : `${root} > ${name}`, commands: expanded },
732
+ ...walkInvocations(commands, root, [...rest, ...children], seen),
733
+ ]
734
+ }
735
+
736
+ /**
737
+ * Checks whether a command's options name a reporting or cache-maintenance invocation.
738
+ *
739
+ * @param before - The tokens before the first `--`.
740
+ * @returns True if a maintenance switch is present and not turned off.
741
+ */
742
+ export function isMaintenance(before: readonly string[]): boolean {
743
+ return before.some(
744
+ (token, position) =>
745
+ MAINTENANCE_PATTERN.test(token) &&
746
+ !token.endsWith('=false') &&
747
+ before[position + 1] !== 'false',
748
+ )
749
+ }
750
+
751
+ /**
752
+ * Checks whether a command's options start watch mode.
753
+ *
754
+ * @param before - The tokens before the first `--`.
755
+ * @returns True if `--watch` or `-w` is present and not turned off.
756
+ */
757
+ export function isWatching(before: readonly string[]): boolean {
758
+ return before.some(
759
+ (token, position) => WATCH_PATTERN.test(token) && before[position + 1] !== 'false',
760
+ )
761
+ }
762
+
763
+ /**
764
+ * Reads the gate a command makes, or none when the command does not run a test selection.
765
+ *
766
+ * @param command - The command tokens.
767
+ * @param chain - The root chain label.
768
+ * @param config - The base config path, forward-slashed and relative.
769
+ * @param options - The option table `readCLIOptions` returned.
770
+ * @returns The gate, or undefined for a command that is no Vitest run or no gate.
771
+ * @throws Thrown for a `related` command, which `vitest list` cannot select.
772
+ */
773
+ export function readGate(
774
+ command: readonly string[],
775
+ chain: string,
776
+ config: string,
777
+ options: ReadonlyMap<string, Option>,
778
+ ): Gate | undefined {
779
+ const index = findRunner(command)
780
+ if (index < 0) return undefined
781
+ const verb = command[index + 1]
782
+ if (verb === 'list' || verb === 'bench' || verb === 'watch' || verb === 'dev') return undefined
783
+ const tokens = command.slice(index + (verb === 'run' ? 2 : 1))
784
+ const separator = tokens.indexOf('--')
785
+ const before = separator < 0 ? tokens : tokens.slice(0, separator)
786
+ if (isMaintenance(before) || isWatching(before)) return undefined
787
+ if (verb === 'related') {
788
+ throw new Error(`${chain}: Vitest list exposes no related command or --related option`)
789
+ }
790
+ const flags = readSelection(tokens, options)
791
+ const mode = readArguments(flags, ['--mode'])[0]
792
+ const directory = readArguments(flags, ['--root', '-r'])[0]
793
+ const dir = readArguments(flags, ['--dir'])[0]
794
+ const configured = readArguments(flags, ['--config', '-c'])[0]
795
+ const path = configured ?? (directory === undefined && dir === undefined ? config : undefined)
796
+ return {
797
+ config:
798
+ path === undefined ? undefined : normalizeSlashes(relative(process.cwd(), resolve(path))),
799
+ mode: mode === 'test' ? undefined : mode,
800
+ ...(directory === undefined ? {} : { root: directory }),
801
+ ...(dir === undefined ? {} : { dir }),
802
+ projects: readArguments(flags, ['--project']),
803
+ chain,
804
+ args: configured === undefined && path !== undefined ? ['--config', path, ...flags] : flags,
805
+ full: before.some((flag) => FULL_PATTERN.test(flag) || /:\d+(?:-\d+)?$/u.test(flag)),
806
+ }
807
+ }
808
+
809
+ /**
810
+ * Reads the test files a `node` command runs as its arguments.
811
+ *
812
+ * @param command - The command tokens.
813
+ * @returns The test file arguments of `node FILE` or `node --test FILE...`; none for any other runner.
814
+ */
815
+ export function readFileCredits(command: readonly string[]): readonly string[] {
816
+ if (basename(command[0] ?? '').replace(/\.exe$/u, '') !== 'node') return []
817
+ const first = skipRunnerOptions(command, 1)
818
+ if (first < 0) return []
819
+ const candidates = command.includes('--test')
820
+ ? command.slice(first)
821
+ : command.slice(first, first + 1)
822
+ return candidates.filter((argument) => TEST_ARGUMENT_PATTERN.test(argument))
823
+ }
824
+
825
+ /**
826
+ * Reads the gates and node-run test files every root script chain reaches.
827
+ *
828
+ * @param scripts - The package scripts by name.
829
+ * @param config - The base config path, forward-slashed and relative.
830
+ * @param options - The option table `readCLIOptions` returned.
831
+ * @returns The gates in visit order, and the earliest chain that runs each test file with `node`.
832
+ */
833
+ export function readGates(
277
834
  scripts: Readonly<Record<string, string>>,
278
835
  config: string,
279
836
  options: ReadonlyMap<string, Option>,
@@ -281,102 +838,158 @@ function readGates(
281
838
  const commands = Object.fromEntries(
282
839
  Object.entries(scripts).map(([name, text]) => [name, readCommands(text)]),
283
840
  )
841
+ // A shorthand call (`npm test`) runs a script a person also starts directly, so it stays a root.
284
842
  const invoked = new Set(
285
843
  Object.values(commands)
286
844
  .flatMap(listInvocations)
845
+ .filter((entry) => !entry.shorthand)
287
846
  .map((entry) => entry.name),
288
847
  )
289
- const units: Gate[] = []
290
- const files = new Map<string, string>()
291
- for (const root of Object.keys(scripts).filter((name) => !invoked.has(name))) {
292
- const visited = new Set<string>()
293
- const pending: Invocation[] = [{ name: root, args: [], ancestors: [] }]
294
- while (pending.length > 0) {
295
- const invocation = pending.pop()
296
- if (invocation === undefined) continue
297
- const { name, args, ancestors } = invocation
298
- const key = JSON.stringify([name, args])
299
- if (visited.has(key) || ancestors.includes(name)) continue
300
- visited.add(key)
301
- const script = commands[name]
302
- if (script === undefined) continue
303
- const expanded = script.map((command, index) =>
304
- index === script.length - 1 ? [...command, ...args] : command,
848
+ const visits = Object.keys(scripts)
849
+ .filter((name) => !invoked.has(name))
850
+ .flatMap((root) =>
851
+ walkInvocations(
852
+ commands,
853
+ root,
854
+ [{ name: root, args: [], ancestors: [], shorthand: false }],
855
+ new Set(),
856
+ ),
857
+ )
858
+ const units = visits.flatMap((visit) =>
859
+ visit.commands.flatMap((command) => {
860
+ const gate = readGate(command, visit.chain, config, options)
861
+ return gate === undefined ? [] : [gate]
862
+ }),
863
+ )
864
+ // A Map keeps the last entry of a key, so the reversed credits leave the earliest chain.
865
+ const files = new Map(
866
+ visits
867
+ .flatMap((visit) =>
868
+ visit.commands.flatMap((command) =>
869
+ findRunner(command) >= 0
870
+ ? []
871
+ : readFileCredits(command).map((file): [string, string] => [file, visit.chain]),
872
+ ),
305
873
  )
306
- for (const entry of listInvocations(expanded)) {
307
- pending.push({ ...entry, ancestors: [...ancestors, name] })
308
- }
309
- const gate = root === name ? name : `${root} > ${name}`
310
- for (const command of expanded) {
311
- const index = findRunner(command)
312
- if (index >= 0) {
313
- const verb = command[index + 1]
314
- if (verb === 'list' || verb === 'bench') continue
315
- const tokens = command.slice(
316
- index + (['run', 'watch', 'dev'].includes(verb ?? '') ? 2 : 1),
317
- )
318
- const separator = tokens.indexOf('--')
319
- const before = separator < 0 ? tokens : tokens.slice(0, separator)
320
- if (
321
- before.some(
322
- (token, position) =>
323
- /^(?:--mergeReports|--merge-reports|--listTags|--list-tags|--clearCache|--clear-cache)(?:=|$)/u.test(
324
- token,
325
- ) &&
326
- !token.endsWith('=false') &&
327
- before[position + 1] !== 'false',
328
- )
329
- )
330
- continue
331
- if (verb === 'related')
332
- throw new Error(`${gate}: Vitest list exposes no related command or --related option`)
333
- const flags = readSelection(tokens, options)
334
- const mode = readArguments(flags, ['--mode'])[0]
335
- const directory = readArguments(flags, ['--root', '-r'])[0]
336
- const dir = readArguments(flags, ['--dir'])[0]
337
- const configured = readArguments(flags, ['--config', '-c'])[0]
338
- const path =
339
- configured ?? (directory === undefined && dir === undefined ? config : undefined)
340
- units.push({
341
- config:
342
- path === undefined
343
- ? undefined
344
- : relative(process.cwd(), resolve(path)).split('\\').join('/'),
345
- mode: mode === 'test' ? undefined : mode,
346
- ...(directory === undefined ? {} : { root: directory }),
347
- ...(dir === undefined ? {} : { dir }),
348
- projects: readArguments(flags, ['--project']),
349
- chain: gate,
350
- args:
351
- configured === undefined && path !== undefined ? ['--config', path, ...flags] : flags,
352
- full: before.some(
353
- (flag) =>
354
- /^(?:-t|--testNamePattern|--test-name-pattern|--tagsFilter|--tags-filter|--shard|--(?:no-)?allowOnly|--(?:no-)?allow-only|--(?:no-)?strictTags|--(?:no-)?strict-tags)(?:=|$)/u.test(
355
- flag,
356
- ) || /:\d+(?:-\d+)?$/u.test(flag),
357
- ),
358
- })
359
- continue
360
- }
361
- for (const argument of command) {
362
- if (
363
- /^(?:tests|src|app)\/[A-Za-z0-9_./-]+\.test\.[cm]?ts$/u.test(argument) &&
364
- !files.has(argument)
365
- )
366
- files.set(argument, gate)
367
- }
368
- }
369
- }
370
- }
874
+ .toReversed(),
875
+ )
371
876
  return { units, files }
372
877
  }
373
878
 
374
- function listCollected(
879
+ /**
880
+ * Checks whether a project name is a literal name and not a Vitest pattern.
881
+ *
882
+ * @param name - The project name or pattern.
883
+ * @returns True if the name holds no `*` and does not open with `!`.
884
+ */
885
+ export function isConcreteName(name: string): boolean {
886
+ return !name.includes('*') && !name.startsWith('!')
887
+ }
888
+
889
+ /**
890
+ * Checks whether a name matches a wildcard pattern the way Vitest matches a project filter.
891
+ *
892
+ * @param pattern - The pattern, where `*` matches any run of characters.
893
+ * @param name - The name to test.
894
+ * @returns True if the whole name matches, ignoring case.
895
+ */
896
+ export function matchesWildcard(pattern: string, name: string): boolean {
897
+ const body = pattern
898
+ .split('*')
899
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'))
900
+ .join('.*')
901
+ return new RegExp(`^${body}$`, 'iu').test(name)
902
+ }
903
+
904
+ /**
905
+ * Checks whether a gate's project filters reach a project name.
906
+ *
907
+ * @remarks
908
+ * A browser instance name such as `web (chromium)` is also tested by its base name, because Vitest
909
+ * keeps every instance of a project whose base name a positive filter matches and drops them all
910
+ * when a negative filter matches it.
911
+ *
912
+ * @param filters - The gate's `--project` values, where a leading `!` negates.
913
+ * @param name - The concrete project name.
914
+ * @returns True if no negative filter matches and some positive filter does, or none is positive.
915
+ */
916
+ export function matchesProjectFilter(filters: readonly string[], name: string): boolean {
917
+ const names = [name, name.replace(/ \([^)]*\)$/u, '')]
918
+ const negative = filters
919
+ .filter((filter) => filter.startsWith('!'))
920
+ .map((filter) => filter.slice(1))
921
+ const positive = filters.filter((filter) => !filter.startsWith('!'))
922
+ if (negative.some((pattern) => names.some((candidate) => matchesWildcard(pattern, candidate)))) {
923
+ return false
924
+ }
925
+ return (
926
+ positive.length === 0 ||
927
+ positive.some((pattern) => names.some((candidate) => matchesWildcard(pattern, candidate)))
928
+ )
929
+ }
930
+
931
+ /**
932
+ * Replaces the `--project` flags of a gate's arguments.
933
+ *
934
+ * @param args - The gate arguments.
935
+ * @param projects - The project names to pass in place of the existing flags.
936
+ * @returns The arguments with one `--project NAME` pair per name, before any bare `--`.
937
+ */
938
+ export function replaceProjects(
939
+ args: readonly string[],
940
+ projects: readonly string[],
941
+ ): readonly string[] {
942
+ const end = args.indexOf('--')
943
+ const head = end < 0 ? args : args.slice(0, end)
944
+ const tail = end < 0 ? [] : args.slice(end)
945
+ const kept = head.filter(
946
+ (token, index) =>
947
+ token !== '--project' && !token.startsWith('--project=') && head[index - 1] !== '--project',
948
+ )
949
+ return [...kept, ...projects.flatMap((project) => ['--project', project]), ...tail]
950
+ }
951
+
952
+ /**
953
+ * Narrows the gates to the named projects, so no listing runs for a project outside the names.
954
+ *
955
+ * @remarks
956
+ * A gate with no project filter lists the named projects alone. A gate that names projects lists the
957
+ * named ones its filters reach and is dropped when it reaches none. A pattern among the names
958
+ * (`*`, or a leading `!`) cannot be matched beforehand, so a gate that names projects keeps them.
959
+ *
960
+ * @param gates - The gates the root chains reach.
961
+ * @param named - The `--projects` names, empty for no narrowing.
962
+ * @returns The gates to list, each with its narrowed arguments.
963
+ */
964
+ export function narrowGates(gates: readonly Gate[], named: readonly string[]): readonly Gate[] {
965
+ if (named.length === 0) return gates
966
+ const concrete = named.filter(isConcreteName)
967
+ const patterned = concrete.length < named.length
968
+ return gates.flatMap((gate): Gate[] => {
969
+ if (gate.projects.length === 0) return [{ ...gate, args: replaceProjects(gate.args, named) }]
970
+ if (patterned) return [gate]
971
+ const reached = concrete.filter((name) => matchesProjectFilter(gate.projects, name))
972
+ return reached.length === 0 ? [] : [{ ...gate, args: replaceProjects(gate.args, reached) }]
973
+ })
974
+ }
975
+
976
+ /**
977
+ * Lists the tests one Vitest listing collects.
978
+ *
979
+ * @param args - The arguments to pass after `vitest list`.
980
+ * @param full - True to collect each test with its name and location; false for files only.
981
+ * @param cache - The listings already read, keyed by their arguments.
982
+ * @returns The tests and whether the project filter matched, or undefined when Vitest refuses.
983
+ */
984
+ export function listCollected(
375
985
  args: readonly string[],
376
986
  full: boolean,
377
- cache: Map<string, readonly Collected[]>,
378
- ): readonly Collected[] | undefined {
379
- const parameters = [...(full ? ['--includeTaskLocation'] : ['--filesOnly']), ...args]
987
+ cache: Map<string, Listed>,
988
+ ): Listed | undefined {
989
+ const parameters = [
990
+ ...(full ? ['--includeTaskLocation', '--staticParse=false'] : ['--filesOnly']),
991
+ ...args,
992
+ ]
380
993
  const key = JSON.stringify(parameters)
381
994
  const cached = cache.get(key)
382
995
  if (cached !== undefined) return cached
@@ -396,10 +1009,12 @@ function listCollected(
396
1009
  if (
397
1010
  args.includes('--project') &&
398
1011
  result.status === 1 &&
399
- result.stderr.includes('No projects matched the filter "')
1012
+ (result.stderr.includes('No projects matched the filter "') ||
1013
+ result.stderr.includes('The filter matched no projects:'))
400
1014
  ) {
401
- cache.set(key, [])
402
- return []
1015
+ const unmatched = { entries: [], matched: false }
1016
+ cache.set(key, unmatched)
1017
+ return unmatched
403
1018
  }
404
1019
  if (result.status !== 0 || !existsSync(listing)) {
405
1020
  const diagnostic = result.stderr || result.error?.message || result.stdout
@@ -430,7 +1045,7 @@ function listCollected(
430
1045
  }
431
1046
  return [
432
1047
  {
433
- file: relative(process.cwd(), record.file).split('\\').join('/'),
1048
+ file: normalizeSlashes(relative(process.cwd(), record.file)),
434
1049
  projectName: record.projectName ?? '',
435
1050
  name: typeof record.name === 'string' ? record.name : undefined,
436
1051
  line: typeof location.line === 'number' ? location.line : undefined,
@@ -438,8 +1053,9 @@ function listCollected(
438
1053
  },
439
1054
  ]
440
1055
  })
441
- cache.set(key, collected)
442
- return collected
1056
+ const listed = { entries: collected, matched: true }
1057
+ cache.set(key, listed)
1058
+ return listed
443
1059
  } catch (error) {
444
1060
  console.error(error instanceof Error ? error.message : String(error))
445
1061
  return undefined
@@ -448,83 +1064,314 @@ function listCollected(
448
1064
  }
449
1065
  }
450
1066
 
451
- function listTestFiles(directory: string): readonly string[] {
1067
+ /**
1068
+ * Lists the test and spec files beneath a directory.
1069
+ *
1070
+ * @param directory - The directory to walk; a missing directory holds none.
1071
+ * @returns The forward-slash paths of every test or spec file, skipping only `node_modules` and `.git`.
1072
+ */
1073
+ export function listTestFiles(directory: string): readonly string[] {
452
1074
  if (!existsSync(directory)) return []
453
- return listFiles(directory)
1075
+ return listFiles(directory, UNDISCOVERED_SKIPS)
454
1076
  .map((file) => file.path)
455
- .filter((path) => /\.test\.[cm]?ts$/u.test(path))
1077
+ .filter((path) => DISCOVERABLE_PATTERN.test(path))
456
1078
  }
457
1079
 
458
- function countMarkers(file: string): Readonly<Record<string, number>> {
459
- const text = readFileSync(file, 'utf8')
1080
+ /**
1081
+ * Scans source text into tokens that tell code from comments and strings.
1082
+ *
1083
+ * @remarks
1084
+ * Comments are dropped. A string, a template (whole, with its expressions), and a regular expression
1085
+ * literal each become one token. A slash opens a regular expression after an operator, an opening
1086
+ * bracket, or a keyword, and divides after a word, a literal, or a closing bracket. The scanner
1087
+ * pairs each opening bracket with its closing bracket as it reads.
1088
+ */
1089
+ export class CodeScanner {
1090
+ readonly #text: string
1091
+ readonly #tokens: CodeToken[] = []
1092
+ readonly #closes = new Map<number, number>()
1093
+ readonly #opened: number[] = []
1094
+ #position = 0
1095
+ #last: CodeToken | undefined = undefined
1096
+
1097
+ constructor(text: string) {
1098
+ this.#text = text
1099
+ }
1100
+
1101
+ scan(): CodeScan {
1102
+ while (this.#position < this.#text.length) {
1103
+ const token = this.#read()
1104
+ if (token === undefined) continue
1105
+ if (token.kind === 'punct' && OPENERS.includes(token.text)) {
1106
+ this.#opened.push(this.#tokens.length)
1107
+ }
1108
+ if (token.kind === 'punct' && CLOSERS.includes(token.text)) {
1109
+ const open = this.#opened.pop()
1110
+ if (open !== undefined) this.#closes.set(open, this.#tokens.length)
1111
+ }
1112
+ this.#tokens.push(token)
1113
+ }
1114
+ return { tokens: this.#tokens, closes: this.#closes }
1115
+ }
1116
+
1117
+ #read(): CodeToken | undefined {
1118
+ const rest = this.#text.slice(this.#position)
1119
+ const skipped = /^(?:\s+|\/\/[^\r\n]*|\/\*[\s\S]*?(?:\*\/|$))/u.exec(rest)
1120
+ if (skipped !== null) {
1121
+ this.#position += skipped[0].length
1122
+ return undefined
1123
+ }
1124
+ if (rest.startsWith('`')) return this.#take('template', this.#readTemplate())
1125
+ const quoted = STRING_PATTERN.exec(rest)
1126
+ if (quoted !== null) return this.#take('string', quoted[0])
1127
+ if (rest.startsWith('/') && this.#expectsRegex()) {
1128
+ const regex = REGEX_PATTERN.exec(rest)
1129
+ if (regex !== null) return this.#take('regex', regex[0])
1130
+ }
1131
+ const word = WORD_PATTERN.exec(rest)
1132
+ if (word !== null) return this.#take('word', word[0])
1133
+ return this.#take('punct', rest.slice(0, 1))
1134
+ }
1135
+
1136
+ #take(kind: CodeToken['kind'], text: string): CodeToken {
1137
+ if (kind !== 'template') this.#position += text.length
1138
+ const token = { kind, text }
1139
+ this.#last = token
1140
+ return token
1141
+ }
1142
+
1143
+ #expectsRegex(): boolean {
1144
+ const last = this.#last
1145
+ if (last === undefined) return true
1146
+ if (last.kind === 'punct') return !CLOSERS.includes(last.text)
1147
+ if (last.kind === 'word') return REGEX_KEYWORDS.has(last.text)
1148
+ return false
1149
+ }
1150
+
1151
+ #readTemplate(): string {
1152
+ const start = this.#position
1153
+ this.#position += 1
1154
+ while (this.#position < this.#text.length) {
1155
+ const character = this.#text[this.#position]
1156
+ if (character === '\\') {
1157
+ this.#position += 2
1158
+ } else if (character === '`') {
1159
+ this.#position += 1
1160
+ return this.#text.slice(start, this.#position)
1161
+ } else if (this.#text.startsWith('${', this.#position)) {
1162
+ this.#position += 2
1163
+ this.#skipExpression()
1164
+ } else {
1165
+ this.#position += 1
1166
+ }
1167
+ }
1168
+ return this.#text.slice(start, this.#position)
1169
+ }
1170
+
1171
+ #skipExpression(): void {
1172
+ while (this.#position < this.#text.length) {
1173
+ const token = this.#read()
1174
+ if (token?.kind !== 'punct') continue
1175
+ if (token.text === '}') return
1176
+ if (token.text === '{') this.#skipExpression()
1177
+ }
1178
+ }
1179
+ }
1180
+
1181
+ /**
1182
+ * Checks whether a token is a punctuation mark with the given text.
1183
+ *
1184
+ * @param token - The token, or undefined past the end.
1185
+ * @param text - The punctuation text.
1186
+ * @returns True if the token is that punctuation mark.
1187
+ */
1188
+ export function isPunct(token: CodeToken | undefined, text: string): boolean {
1189
+ return token?.kind === 'punct' && token.text === text
1190
+ }
1191
+
1192
+ /**
1193
+ * Reads the modifier chain after a registrar, up to its registration call.
1194
+ *
1195
+ * @param scan - The scanned source.
1196
+ * @param at - The index after the registrar or the last chain element.
1197
+ * @param names - The modifier names read so far; an empty name marks a consumed argument group.
1198
+ * @returns The names and the index of the registration call's opening parenthesis, or undefined when
1199
+ * the tokens are no registration call.
1200
+ */
1201
+ export function readChain(scan: CodeScan, at: number, names: readonly string[]): Chain | undefined {
1202
+ const token = scan.tokens[at]
1203
+ if (token === undefined) return undefined
1204
+ const last = names.at(-1) ?? ''
1205
+ if (isPunct(token, '.')) {
1206
+ const name = scan.tokens[at + 1]
1207
+ return name?.kind === 'word' ? readChain(scan, at + 2, [...names, name.text]) : undefined
1208
+ }
1209
+ if (token.kind === 'template' && ARGUMENT_MODIFIERS.has(last)) {
1210
+ return readChain(scan, at + 1, [...names, ''])
1211
+ }
1212
+ if (!isPunct(token, '(')) return undefined
1213
+ if (!ARGUMENT_MODIFIERS.has(last)) return { names, call: at }
1214
+ const close = scan.closes.get(at)
1215
+ return close === undefined ? undefined : readChain(scan, close + 1, [...names, ''])
1216
+ }
1217
+
1218
+ /**
1219
+ * Splits a token range into its top-level arguments, jumping over bracket groups.
1220
+ *
1221
+ * @param scan - The scanned source.
1222
+ * @param start - The index the current argument starts at.
1223
+ * @param at - The index being read.
1224
+ * @param end - The index the range ends at, excluded.
1225
+ * @returns One span per non-empty argument.
1226
+ */
1227
+ export function splitArguments(
1228
+ scan: CodeScan,
1229
+ start: number,
1230
+ at: number,
1231
+ end: number,
1232
+ ): readonly Span[] {
1233
+ if (at >= end) return start < end ? [{ start, end }] : []
1234
+ const token = scan.tokens[at]
1235
+ if (token?.kind === 'punct' && OPENERS.includes(token.text)) {
1236
+ return splitArguments(scan, start, (scan.closes.get(at) ?? at) + 1, end)
1237
+ }
1238
+ if (isPunct(token, ',')) {
1239
+ return [{ start, end: at }, ...splitArguments(scan, at + 1, at + 1, end)]
1240
+ }
1241
+ return splitArguments(scan, start, at + 1, end)
1242
+ }
1243
+
1244
+ /**
1245
+ * Reads the property keys at the top level of an object literal's token range.
1246
+ *
1247
+ * @param scan - The scanned source.
1248
+ * @param at - The index being read.
1249
+ * @param end - The index the range ends at, excluded.
1250
+ * @returns Each key that follows `{` or `,` and precedes `:`, `,`, or `}`, quotes removed.
1251
+ */
1252
+ export function readKeys(scan: CodeScan, at: number, end: number): readonly string[] {
1253
+ const token = scan.tokens[at]
1254
+ if (token === undefined || at >= end) return []
1255
+ if (token.kind === 'punct' && OPENERS.includes(token.text)) {
1256
+ return readKeys(scan, (scan.closes.get(at) ?? at) + 1, end)
1257
+ }
1258
+ const previous = scan.tokens[at - 1]
1259
+ const following = scan.tokens[at + 1]
1260
+ const spelled =
1261
+ token.kind === 'word'
1262
+ ? token.text
1263
+ : token.kind === 'string'
1264
+ ? token.text.slice(1, -1)
1265
+ : undefined
1266
+ const isKey =
1267
+ spelled !== undefined &&
1268
+ (isPunct(previous, '{') || isPunct(previous, ',')) &&
1269
+ (isPunct(following, ':') || isPunct(following, ',') || isPunct(following, '}'))
1270
+ return [...(isKey ? [spelled] : []), ...readKeys(scan, at + 1, end)]
1271
+ }
1272
+
1273
+ /**
1274
+ * Reads the retry and timeout markers a registration's arguments carry.
1275
+ *
1276
+ * @param scan - The scanned source.
1277
+ * @param spans - The registration call's top-level arguments.
1278
+ * @returns A `timeout` entry for a numeric last argument after the title, and an entry for each
1279
+ * `retry` or `timeout` key of an object-literal argument.
1280
+ */
1281
+ export function readArgumentMarkers(scan: CodeScan, spans: readonly Span[]): readonly string[] {
1282
+ const last = spans.at(-1)
1283
+ const lastToken = last === undefined ? undefined : scan.tokens[last.start]
1284
+ const numeric =
1285
+ spans.length >= 2 &&
1286
+ last !== undefined &&
1287
+ last.end - last.start === 1 &&
1288
+ lastToken?.kind === 'word' &&
1289
+ NUMBER_PATTERN.test(lastToken.text)
1290
+ const keys = spans.flatMap((span) =>
1291
+ isPunct(scan.tokens[span.start], '{') && scan.closes.get(span.start) === span.end - 1
1292
+ ? readKeys(scan, span.start + 1, span.end - 1)
1293
+ : [],
1294
+ )
1295
+ return [
1296
+ ...(numeric ? ['timeout'] : []),
1297
+ ...keys.filter((key) => key === 'retry' || key === 'timeout'),
1298
+ ]
1299
+ }
1300
+
1301
+ /**
1302
+ * Reads the markers one registration carries.
1303
+ *
1304
+ * @param scan - The scanned source.
1305
+ * @param index - The index of the registrar identifier.
1306
+ * @returns The chain markers (`.todo`, `.skipIf`, `.runIf`, `.fails`) and the argument markers.
1307
+ */
1308
+ export function readHits(scan: CodeScan, index: number): readonly string[] {
1309
+ const chain = readChain(scan, index + 1, [])
1310
+ if (chain === undefined) return []
1311
+ const close = scan.closes.get(chain.call)
1312
+ const spans =
1313
+ close === undefined ? [] : splitArguments(scan, chain.call + 1, chain.call + 1, close)
1314
+ return [
1315
+ ...chain.names.filter((name) => CHAIN_MARKERS.has(name)).map((name) => `.${name}`),
1316
+ ...readArgumentMarkers(scan, spans),
1317
+ ]
1318
+ }
1319
+
1320
+ /**
1321
+ * Counts the markers a scanned test file carries, as call forms outside comments and strings.
1322
+ *
1323
+ * @param scan - The scanned source.
1324
+ * @returns Each marker with a nonzero count, in marker order.
1325
+ */
1326
+ export function readMarkers(scan: CodeScan): Readonly<Record<string, number>> {
1327
+ const hits = scan.tokens.flatMap((token, index) =>
1328
+ token.kind === 'word' && REGISTRARS.has(token.text) && !isPunct(scan.tokens[index - 1], '.')
1329
+ ? readHits(scan, index)
1330
+ : [],
1331
+ )
460
1332
  return Object.fromEntries(
461
- MARKERS.map((marker) => [marker, text.split(marker).length - 1] as const).filter(
1333
+ MARKERS.map((marker) => [marker, hits.filter((hit) => hit === marker).length] as const).filter(
462
1334
  ([, count]) => count > 0,
463
1335
  ),
464
1336
  )
465
1337
  }
466
1338
 
467
- function collectListings(
468
- config: string,
469
- named: readonly string[],
470
- gates: Gates,
471
- ): readonly Listing[] | undefined {
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)),
480
- )
481
- .map((gate) => ({
482
- config: gate.config,
483
- mode: gate.mode,
484
- ...(gate.root === undefined ? {} : { root: gate.root }),
485
- ...(gate.dir === undefined ? {} : { dir: gate.dir }),
486
- })),
487
- ]
488
- // A listing is a memo keyed by its arguments: each key is written once, on its first miss.
489
- const cache = new Map<string, readonly Collected[]>()
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
- }))
1339
+ /**
1340
+ * Counts the markers of a test file on disk.
1341
+ *
1342
+ * @param file - The test file path.
1343
+ * @returns Each marker with a nonzero count.
1344
+ */
1345
+ export function countMarkers(file: string): Readonly<Record<string, number>> {
1346
+ return readMarkers(new CodeScanner(readFileSync(file, 'utf8')).scan())
513
1347
  }
514
1348
 
515
- // Runs one read per item in order and stops at the first refusal, so no later read spawns.
516
- function collectReadings<Item, Reading>(
1349
+ /**
1350
+ * Runs one read per item in order and stops at the first refusal, so no later read spawns.
1351
+ *
1352
+ * @param items - The items to read.
1353
+ * @param read - The read for one item; undefined refuses.
1354
+ * @returns The readings in order, or undefined once any read refuses.
1355
+ */
1356
+ export function collectResults<Item, Value>(
517
1357
  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]
1358
+ read: (item: Item) => Value | undefined,
1359
+ ): readonly Value[] | undefined {
1360
+ return items.reduce<readonly Value[] | undefined>((results, item) => {
1361
+ if (results === undefined) return undefined
1362
+ const result = read(item)
1363
+ return result === undefined ? undefined : [...results, result]
524
1364
  }, [])
525
1365
  }
526
1366
 
527
- function isSameUnit(left: Unit, right: Unit): boolean {
1367
+ /**
1368
+ * Checks whether two units run under the same config, mode, root, and dir.
1369
+ *
1370
+ * @param left - The first unit.
1371
+ * @param right - The second unit.
1372
+ * @returns True if every field is equal.
1373
+ */
1374
+ export function isSameUnit(left: Unit, right: Unit): boolean {
528
1375
  return (
529
1376
  left.config === right.config &&
530
1377
  left.mode === right.mode &&
@@ -533,30 +1380,47 @@ function isSameUnit(left: Unit, right: Unit): boolean {
533
1380
  )
534
1381
  }
535
1382
 
536
- function readUnit(
1383
+ /**
1384
+ * Builds the arguments that select a unit's config, mode, root, and dir.
1385
+ *
1386
+ * @param unit - The unit.
1387
+ * @returns The arguments to pass to `vitest list`.
1388
+ */
1389
+ export function readUnitArguments(unit: Unit): readonly string[] {
1390
+ return [
1391
+ ...(unit.config === undefined ? [] : ['--config', unit.config]),
1392
+ ...(unit.mode === undefined ? [] : ['--mode', unit.mode]),
1393
+ ...(unit.root === undefined ? [] : ['--root', unit.root]),
1394
+ ...(unit.dir === undefined ? [] : ['--dir', unit.dir]),
1395
+ ]
1396
+ }
1397
+
1398
+ /**
1399
+ * Reads one unit's universe, gate selections, and project readings.
1400
+ *
1401
+ * @param unit - The unit to read.
1402
+ * @param named - The `--projects` names; the universe and every listing carry them when present.
1403
+ * @param gates - The narrowed gates.
1404
+ * @param cache - The listings already read.
1405
+ * @returns The unit's reading, or undefined when a listing refuses.
1406
+ */
1407
+ export function readUnit(
537
1408
  unit: Unit,
538
1409
  named: readonly string[],
539
1410
  gates: Gates,
540
- cache: Map<string, readonly Collected[]>,
541
- ): UnitReading | undefined {
1411
+ cache: Map<string, Listed>,
1412
+ ): UnitResult | undefined {
542
1413
  const reached = gates.units.filter((gate) => isSameUnit(gate, unit))
543
1414
  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) => {
1415
+ const universeArguments = [...parameters, ...named.flatMap((name) => ['--project', name])]
1416
+ const universe = listCollected(universeArguments, true, cache)
1417
+ if (universe === undefined) return undefined
1418
+ const collected = universe.entries
1419
+ const selected = collectResults(reached, (gate) => {
556
1420
  const selection =
557
- JSON.stringify(gate.args) === JSON.stringify(parameters)
1421
+ JSON.stringify(gate.args) === JSON.stringify(universeArguments)
558
1422
  ? collected
559
- : listCollected(gate.args, gate.full, cache)
1423
+ : listCollected(gate.args, gate.full, cache)?.entries
560
1424
  if (selection === undefined) {
561
1425
  console.error(`discovery: gate ${gate.chain}: vitest list refused ${gate.args.join(' ')}`)
562
1426
  return undefined
@@ -575,11 +1439,10 @@ function readUnit(
575
1439
  }
576
1440
  })
577
1441
  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 => {
1442
+ const candidates = named.length === 0 ? reached.flatMap((gate) => gate.projects) : named
1443
+ const projects = collectResults(
1444
+ [...new Set(candidates)].filter(isConcreteName),
1445
+ (project): ProjectResult | undefined => {
583
1446
  if (
584
1447
  selected.some(
585
1448
  ({ gate, identities }) =>
@@ -592,60 +1455,133 @@ function readUnit(
592
1455
  if (selection === undefined) return undefined
593
1456
  return {
594
1457
  project,
595
- populated: selection.length > 0,
1458
+ populated: selection.entries.length > 0,
596
1459
  empty:
597
- selection.length === 0 &&
1460
+ selection.entries.length === 0 &&
598
1461
  (reached.some((gate) => gate.projects.includes(project)) || named.includes(project)) &&
599
1462
  (named.length === 0 || named.includes(project)),
600
1463
  }
601
1464
  },
602
1465
  )
603
- if (readings === undefined) return undefined
1466
+ if (projects === undefined) return undefined
604
1467
  return {
605
1468
  listing: {
606
1469
  ...unit,
607
- collected: collected
608
- .filter(
609
- (entry) =>
610
- scope === undefined ||
611
- scope.some(
612
- (chosen) => chosen.file === entry.file && chosen.projectName === entry.projectName,
613
- ),
614
- )
615
- .map((entry) => ({
616
- ...entry,
617
- gate: selected.find(({ gate, identities }) =>
618
- identities.has(
619
- JSON.stringify([
620
- entry.file,
621
- entry.projectName,
622
- ...(gate.full ? [entry.name, entry.line, entry.column] : []),
623
- ]),
624
- ),
625
- )?.gate.chain,
626
- })),
627
- empty: readings.filter(({ empty }) => empty).map(({ project }) => project),
1470
+ collected: collected.map((entry) => ({
1471
+ ...entry,
1472
+ gate: selected.find(({ gate, identities }) =>
1473
+ identities.has(
1474
+ JSON.stringify([
1475
+ entry.file,
1476
+ entry.projectName,
1477
+ ...(gate.full ? [entry.name, entry.line, entry.column] : []),
1478
+ ]),
1479
+ ),
1480
+ )?.gate.chain,
1481
+ })),
1482
+ empty: projects.filter(({ empty }) => empty).map(({ project }) => project),
628
1483
  },
629
- populated: readings.filter(({ populated }) => populated).map(({ project }) => project),
1484
+ populated: projects.filter(({ populated }) => populated).map(({ project }) => project),
630
1485
  }
631
1486
  }
632
1487
 
633
- function readUnitArguments(unit: Unit): readonly string[] {
634
- return [
635
- ...(unit.config === undefined ? [] : ['--config', unit.config]),
636
- ...(unit.mode === undefined ? [] : ['--mode', unit.mode]),
637
- ...(unit.root === undefined ? [] : ['--root', unit.root]),
638
- ...(unit.dir === undefined ? [] : ['--dir', unit.dir]),
1488
+ /**
1489
+ * Reads the listing of every unit the narrowed gates reach.
1490
+ *
1491
+ * @param config - The base config path.
1492
+ * @param named - The `--projects` names.
1493
+ * @param gates - The narrowed gates.
1494
+ * @returns One listing per unit, or undefined when a listing refuses.
1495
+ */
1496
+ export function collectListings(
1497
+ config: string,
1498
+ named: readonly string[],
1499
+ gates: Gates,
1500
+ ): readonly Listing[] | undefined {
1501
+ const base: Unit = { config, mode: undefined }
1502
+ // A gate joins the units unless the base unit or an earlier gate already names the same unit.
1503
+ const units: readonly Unit[] = [
1504
+ base,
1505
+ ...gates.units
1506
+ .filter(
1507
+ (gate, index) =>
1508
+ ![base, ...gates.units.slice(0, index)].some((unit) => isSameUnit(unit, gate)),
1509
+ )
1510
+ .map((gate) => ({
1511
+ config: gate.config,
1512
+ mode: gate.mode,
1513
+ ...(gate.root === undefined ? {} : { root: gate.root }),
1514
+ ...(gate.dir === undefined ? {} : { dir: gate.dir }),
1515
+ })),
639
1516
  ]
1517
+ // A listing is a memo keyed by its arguments: each key is written once, on its first miss.
1518
+ const cache = new Map<string, Listed>()
1519
+ const results = collectResults(units, (unit) => readUnit(unit, named, gates, cache))
1520
+ if (results === undefined) return undefined
1521
+ const listings = results.map(({ listing }) => listing)
1522
+ const populated = new Set(results.flatMap((result) => result.populated))
1523
+ // A project empty under one unit may still list tests under another, so each unit is asked for it.
1524
+ const rescans = collectResults(
1525
+ [...new Set(listings.flatMap((listing) => listing.empty))]
1526
+ .filter((name) => !populated.has(name))
1527
+ .flatMap((name) => units.map((unit) => ({ name, unit }))),
1528
+ ({ name, unit }) => {
1529
+ const selection = listCollected([...readUnitArguments(unit), '--project', name], false, cache)
1530
+ return selection === undefined ? undefined : { name, listed: selection.entries.length > 0 }
1531
+ },
1532
+ )
1533
+ if (rescans === undefined) return undefined
1534
+ const resolved = new Set([
1535
+ ...populated,
1536
+ ...rescans.filter(({ listed }) => listed).map(({ name }) => name),
1537
+ ])
1538
+ return listings.map((listing) => ({
1539
+ ...listing,
1540
+ empty: listing.empty.filter((name) => !resolved.has(name)),
1541
+ }))
1542
+ }
1543
+
1544
+ /**
1545
+ * Names the `--projects` entries the base config's projects do not match.
1546
+ *
1547
+ * @param unit - The base unit.
1548
+ * @param named - The `--projects` names.
1549
+ * @param cache - The listings already read.
1550
+ * @returns The names Vitest matched to no project, or undefined when a listing refuses.
1551
+ */
1552
+ export function readUnmatched(
1553
+ unit: Unit,
1554
+ named: readonly string[],
1555
+ cache: Map<string, Listed>,
1556
+ ): readonly string[] | undefined {
1557
+ const matches = collectResults(named, (name) => {
1558
+ const listed = listCollected([...readUnitArguments(unit), '--project', name], false, cache)
1559
+ return listed === undefined ? undefined : { name, matched: listed.matched }
1560
+ })
1561
+ return matches?.filter(({ matched }) => !matched).map(({ name }) => name)
640
1562
  }
641
1563
 
642
- function identifyCollected(entry: Collected): string {
1564
+ /**
1565
+ * Identifies a collected test by its file, project, name, and location.
1566
+ *
1567
+ * @param entry - The collected test.
1568
+ * @returns A key two listings share for the same test.
1569
+ */
1570
+ export function identifyCollected(entry: Collected): string {
643
1571
  return JSON.stringify([entry.file, entry.projectName, entry.name, entry.line, entry.column])
644
1572
  }
645
1573
 
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.
648
- function mergeCollected(listings: readonly Listing[]): readonly Collected[] {
1574
+ /**
1575
+ * Merges the tests of several listings into one list.
1576
+ *
1577
+ * @remarks
1578
+ * The same test listed twice in one listing is two occurrences, and the same occurrence listed by
1579
+ * two listings is one test: the last listing's fields win, and the gate is the last one any named.
1580
+ *
1581
+ * @param listings - The listings to merge.
1582
+ * @returns The merged tests.
1583
+ */
1584
+ export function mergeCollected(listings: readonly Listing[]): readonly Collected[] {
649
1585
  const keyed = listings.flatMap((listing) => {
650
1586
  const positions = new Map(
651
1587
  [...Map.groupBy(listing.collected, identifyCollected).values()].flatMap((group) =>
@@ -670,7 +1606,13 @@ function mergeCollected(listings: readonly Listing[]): readonly Collected[] {
670
1606
  })
671
1607
  }
672
1608
 
673
- function readSnapshot(gates: Gates): string {
1609
+ /**
1610
+ * Reads the modification times of every test file the census can collect.
1611
+ *
1612
+ * @param gates - The narrowed gates, whose roots and dirs add watched directories.
1613
+ * @returns A JSON text two reads share when no test file changed.
1614
+ */
1615
+ export function readSnapshot(gates: Gates): string {
674
1616
  const bases = [
675
1617
  process.cwd(),
676
1618
  ...gates.units.flatMap((gate) =>
@@ -683,7 +1625,7 @@ function readSnapshot(gates: Gates): string {
683
1625
  [...roots].flatMap((root) =>
684
1626
  existsSync(root)
685
1627
  ? listFiles(root)
686
- .filter((file) => /\.test\.[cm]?[jt]sx?$/u.test(file.path))
1628
+ .filter((file) => TEST_FILE_PATTERN.test(file.path))
687
1629
  .map((file) => [file.path, file.modified] as const)
688
1630
  : [],
689
1631
  ),
@@ -691,7 +1633,17 @@ function readSnapshot(gates: Gates): string {
691
1633
  return JSON.stringify([...files].sort(([left], [right]) => left.localeCompare(right)))
692
1634
  }
693
1635
 
694
- function collectStableListings(
1636
+ /**
1637
+ * Reads the listings, repeating once when a test file changed during the first read.
1638
+ *
1639
+ * @param config - The base config path.
1640
+ * @param named - The `--projects` names.
1641
+ * @param gates - The narrowed gates.
1642
+ * @param attempt - The attempt number, zero for the first.
1643
+ * @returns One listing per unit, or undefined when a listing refuses.
1644
+ * @throws Thrown when test files changed during both attempts.
1645
+ */
1646
+ export function collectStableListings(
695
1647
  config: string,
696
1648
  named: readonly string[],
697
1649
  gates: Gates,
@@ -704,7 +1656,20 @@ function collectStableListings(
704
1656
  return collectStableListings(config, named, gates, attempt + 1)
705
1657
  }
706
1658
 
707
- function main(argv: readonly string[]): number {
1659
+ /**
1660
+ * Runs the census and prints its report.
1661
+ *
1662
+ * @param argv - The arguments after the script path.
1663
+ * @returns The exit code: 0 no flag, 3 a flag, 2 a tool failure, 64 usage.
1664
+ */
1665
+ export function main(argv: readonly string[]): number {
1666
+ const unknown = readUnknownFlags(argv, FLAGS)
1667
+ if (unknown.length > 0) {
1668
+ console.error(
1669
+ `discovery: ${unknown.map(describeName).join(', ')} is not a flag this script reads; give each value as its own argument. ${USAGE}`,
1670
+ )
1671
+ return 64
1672
+ }
708
1673
  const missing = readMissingFlags(argv, ['--config', '--projects'])
709
1674
  if (missing.length > 0) {
710
1675
  console.error(`discovery: ${missing.join(', ')} given with no value`)
@@ -712,16 +1677,42 @@ function main(argv: readonly string[]): number {
712
1677
  }
713
1678
  const config = readOption(argv, '--config') ?? 'vite.config.ts'
714
1679
  if (!existsSync(config) || !existsSync('package.json')) {
715
- console.error('usage: discovery.ts [--config vite.config.ts] [--projects a,b] [--json]')
1680
+ console.error(USAGE)
716
1681
  return 64
717
1682
  }
718
1683
  if (!existsSync(VITEST)) {
719
1684
  console.error(`discovery: ${VITEST} is missing; run npm ci first`)
720
1685
  return 2
721
1686
  }
722
- const canonical = relative(process.cwd(), resolve(config)).split('\\').join('/')
723
- const gates = readGates(readScripts(), canonical, readCLIOptions())
724
- const named = (readOption(argv, '--projects') ?? '').split(',').filter((name) => name !== '')
1687
+ const canonical = normalizeSlashes(relative(process.cwd(), resolve(config)))
1688
+ const options = readCLIOptions()
1689
+ const absent = readMissingOptions(options)
1690
+ if (absent.length > 0) {
1691
+ console.error(
1692
+ `discovery: Vitest ${readVitestVersion()} lists no ${absent.join(', ')} option; the help layout this script reads has changed`,
1693
+ )
1694
+ return 2
1695
+ }
1696
+ const named = [
1697
+ ...new Set((readOption(argv, '--projects') ?? '').split(',').filter((name) => name !== '')),
1698
+ ]
1699
+ const read = readGates(readScripts(), canonical, options)
1700
+ if (named.length > 0) {
1701
+ const base: Unit = { config: canonical, mode: undefined }
1702
+ const cache = new Map<string, Listed>()
1703
+ const unmatched = readUnmatched(base, named, cache)
1704
+ if (unmatched === undefined) {
1705
+ console.error('discovery: vitest list failed')
1706
+ return 2
1707
+ }
1708
+ if (unmatched.length > 0) {
1709
+ console.error(
1710
+ `discovery: --projects ${unmatched.map(describeName).join(', ')} matches no project in ${describeName(canonical)}; run without --projects to list every project that collects tests, since naming them here would collect the projects outside the set`,
1711
+ )
1712
+ return 64
1713
+ }
1714
+ }
1715
+ const gates: Gates = { ...read, units: narrowGates(read.units, named) }
725
1716
  const listings = collectStableListings(canonical, named, gates)
726
1717
  if (listings === undefined) {
727
1718
  console.error('discovery: vitest list failed')
@@ -821,9 +1812,11 @@ function main(argv: readonly string[]): number {
821
1812
  return flagged ? 3 : 0
822
1813
  }
823
1814
 
824
- try {
825
- process.exitCode = main(process.argv.slice(2))
826
- } catch (error) {
827
- console.error(`discovery: ${error instanceof Error ? error.message : String(error)}`)
828
- process.exitCode = 2
1815
+ if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === import.meta.filename) {
1816
+ try {
1817
+ process.exitCode = main(process.argv.slice(2))
1818
+ } catch (error) {
1819
+ console.error(`discovery: ${error instanceof Error ? error.message : String(error)}`)
1820
+ process.exitCode = 2
1821
+ }
829
1822
  }