@orkestrel/scaffold 0.0.107 → 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 (164) 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 +368 -27
  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 +26 -20
  25. package/dist/host/agents/orchestration.md +98 -58
  26. package/dist/host/agents/platforms/posix.md +15 -0
  27. package/dist/host/agents/platforms/windows.md +37 -0
  28. package/dist/host/agents/skills/orkestrel-align/SKILL.md +10 -13
  29. package/dist/host/agents/skills/orkestrel-align/references/fleet.md +4 -10
  30. package/dist/host/agents/skills/orkestrel-align/references/integration.md +1 -1
  31. package/dist/host/agents/skills/orkestrel-build/SKILL.md +16 -48
  32. package/dist/host/agents/skills/orkestrel-build/agents/openai.yaml +1 -1
  33. package/dist/host/agents/skills/orkestrel-debrief/SKILL.md +19 -16
  34. package/dist/host/agents/skills/orkestrel-debrief/agents/openai.yaml +1 -1
  35. package/dist/host/agents/skills/orkestrel-debrief/references/field-testing.md +15 -28
  36. package/dist/host/agents/skills/orkestrel-debrief/references/instruction-audit.md +5 -6
  37. package/dist/host/agents/skills/orkestrel-debrief/references/retention.md +12 -14
  38. package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +36 -24
  39. package/dist/host/agents/skills/orkestrel-dispatch/references/launch.md +14 -11
  40. package/dist/host/agents/skills/orkestrel-dispatch/scripts/bench.ts +366 -75
  41. package/dist/host/agents/skills/orkestrel-dispatch/scripts/brief.ts +126 -16
  42. package/dist/host/agents/skills/orkestrel-dispatch/scripts/chain.ts +323 -0
  43. package/dist/host/agents/skills/orkestrel-dispatch/scripts/cite.ts +220 -31
  44. package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +176 -33
  45. package/dist/host/agents/skills/orkestrel-dispatch/scripts/login.ts +294 -59
  46. package/dist/host/agents/skills/orkestrel-dispatch/scripts/patch.ts +401 -0
  47. package/dist/host/agents/skills/orkestrel-dispatch/scripts/result.ts +199 -34
  48. package/dist/host/agents/skills/orkestrel-dispatch/scripts/sweep.ts +428 -27
  49. package/dist/host/agents/skills/orkestrel-falsify/SKILL.md +10 -8
  50. package/dist/host/agents/skills/orkestrel-falsify/references/brief.md +1 -5
  51. package/dist/host/agents/skills/orkestrel-falsify/references/reconcile.md +1 -1
  52. package/dist/host/agents/skills/orkestrel-falsify/scripts/mutate.ts +701 -0
  53. package/dist/host/agents/skills/orkestrel-harden/SKILL.md +16 -12
  54. package/dist/host/agents/skills/orkestrel-harden/references/bindings.md +23 -81
  55. package/dist/host/agents/skills/orkestrel-harden/references/centralization.md +4 -5
  56. package/dist/host/agents/skills/orkestrel-harden/references/contract.md +2 -0
  57. package/dist/host/agents/skills/orkestrel-harden/references/document.md +33 -0
  58. package/dist/host/agents/skills/orkestrel-harden/references/equivalence.md +6 -3
  59. package/dist/host/agents/skills/orkestrel-harden/references/hardening.md +8 -4
  60. package/dist/host/agents/skills/orkestrel-harden/references/research.md +2 -0
  61. package/dist/host/agents/skills/orkestrel-harden/scripts/discovery.ts +1272 -279
  62. package/dist/host/agents/skills/orkestrel-harden/scripts/measure.ts +425 -0
  63. package/dist/host/agents/skills/orkestrel-harden/scripts/titles.ts +603 -98
  64. package/dist/host/agents/skills/orkestrel-journey/SKILL.md +75 -98
  65. package/dist/host/agents/skills/orkestrel-journey/references/captures.md +54 -17
  66. package/dist/host/agents/skills/orkestrel-journey/references/decide.md +1 -20
  67. package/dist/host/agents/skills/orkestrel-journey/references/layer.md +21 -31
  68. package/dist/host/agents/skills/orkestrel-journey/references/styles.md +1 -1
  69. package/dist/host/agents/skills/orkestrel-parallel/SKILL.md +91 -30
  70. package/dist/host/agents/skills/orkestrel-parallel/agents/openai.yaml +2 -2
  71. package/dist/host/agents/skills/orkestrel-parallel/references/claude.md +34 -0
  72. package/dist/host/agents/skills/orkestrel-parallel/references/codex.md +29 -0
  73. package/dist/host/agents/skills/orkestrel-parallel/references/cursor.md +29 -0
  74. package/dist/host/agents/skills/orkestrel-parallel/references/t3.md +31 -0
  75. package/dist/host/agents/skills/orkestrel-parallel/scripts/cases.ts +389 -0
  76. package/dist/host/agents/skills/orkestrel-polish/SKILL.md +16 -20
  77. package/dist/host/agents/skills/orkestrel-polish/references/capture-harness.md +16 -26
  78. package/dist/host/agents/skills/orkestrel-publish/SKILL.md +12 -26
  79. package/dist/host/agents/skills/orkestrel-publish/agents/openai.yaml +2 -2
  80. package/dist/host/agents/skills/orkestrel-publish/references/adopt.md +23 -0
  81. package/dist/host/agents/skills/orkestrel-publish/references/release.md +4 -4
  82. package/dist/host/agents/skills/orkestrel-publish/references/wave.md +12 -10
  83. package/dist/host/agents/skills/orkestrel-publish/references/window.md +20 -25
  84. package/dist/host/agents/skills/orkestrel-publish/scripts/compare.ts +348 -59
  85. package/dist/host/agents/skills/orkestrel-publish/scripts/pins.ts +202 -22
  86. package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +1061 -191
  87. package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +812 -140
  88. package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +280 -38
  89. package/dist/host/agents/skills/orkestrel-scout/SKILL.md +3 -3
  90. package/dist/host/agents/skills/orkestrel-scout/scripts/map.ts +428 -70
  91. package/dist/host/agents/templates/brief.md +1 -1
  92. package/dist/host/agents/transports/claude-cli.md +3 -3
  93. package/dist/host/agents/transports/codex.md +3 -8
  94. package/dist/host/agents/transports/cursor.md +2 -3
  95. package/dist/host/claude/AGENTS.md +7 -10
  96. package/dist/host/claude/agents/analyst.md +3 -3
  97. package/dist/host/claude/agents/astra.md +3 -3
  98. package/dist/host/claude/agents/builder.md +1 -1
  99. package/dist/host/claude/agents/checker.md +1 -1
  100. package/dist/host/claude/agents/distiller.md +1 -1
  101. package/dist/host/claude/agents/grok.md +1 -1
  102. package/dist/host/claude/agents/opus.md +2 -2
  103. package/dist/host/claude/agents/orkestrel.md +57 -90
  104. package/dist/host/claude/agents/researcher.md +1 -1
  105. package/dist/host/claude/agents/reviewer.md +4 -0
  106. package/dist/host/claude/agents/scout.md +1 -1
  107. package/dist/host/claude/agents/verifier.md +1 -1
  108. package/dist/host/claude/rules/application.md +14 -21
  109. package/dist/host/claude/rules/architecture.md +34 -44
  110. package/dist/host/claude/rules/browser.md +3 -4
  111. package/dist/host/claude/rules/campaign.md +55 -0
  112. package/dist/host/claude/rules/documentation.md +26 -11
  113. package/dist/host/claude/rules/names.md +9 -4
  114. package/dist/host/claude/rules/patterns.md +6 -7
  115. package/dist/host/claude/rules/portability.md +2 -3
  116. package/dist/host/claude/rules/quality.md +4 -5
  117. package/dist/host/claude/rules/styles.md +2 -2
  118. package/dist/host/claude/rules/tests.md +57 -114
  119. package/dist/host/claude/rules/typescript.md +8 -10
  120. package/dist/host/claude/rules/workspace.md +21 -19
  121. package/dist/host/claude/rules/writing.md +3 -3
  122. package/dist/host/claude/skills/orkestrel-dispatch/SKILL.md +1 -1
  123. package/dist/host/claude/skills/orkestrel-harden/SKILL.md +1 -1
  124. package/dist/host/claude/skills/orkestrel-parallel/SKILL.md +1 -1
  125. package/dist/host/claude/skills/orkestrel-publish/SKILL.md +1 -1
  126. package/dist/host/claude/skills/orkestrel-scout/SKILL.md +1 -1
  127. package/dist/host/codex/agents/analyst.toml +9 -3
  128. package/dist/host/codex/agents/astra.toml +3 -4
  129. package/dist/host/codex/agents/builder.toml +1 -1
  130. package/dist/host/codex/agents/opus.toml +3 -4
  131. package/dist/host/codex/agents/orkestrel.toml +1 -1
  132. package/dist/host/codex/agents/researcher.toml +2 -2
  133. package/dist/host/codex/agents/verifier.toml +1 -1
  134. package/dist/host/codex/config.toml +3 -3
  135. package/dist/host/configs/helpers.ts +21 -1
  136. package/dist/host/configs/policy.ts +405 -16
  137. package/dist/host/cursor/rules/orchestration.mdc +1 -0
  138. package/dist/host/dotfiles/oxlintrc.json +141 -5
  139. package/dist/host/guides/agent.md +111 -134
  140. package/dist/host/guides/capture.md +801 -0
  141. package/dist/host/guides/probe.md +27 -8
  142. package/dist/host/guides/scaffold.md +152 -19
  143. package/dist/host/guides/study.md +534 -440
  144. package/dist/host/guides/test.md +111 -44
  145. package/dist/host/manifest.json +199 -242
  146. package/dist/host/tests/config.test.ts +1558 -219
  147. package/dist/host/tests/policy.test.ts +127 -1
  148. package/dist/host/tests/setupPolicy.ts +1389 -39
  149. package/dist/src/core/index.cjs +239 -87
  150. package/dist/src/core/index.cjs.map +1 -1
  151. package/dist/src/core/index.d.cts +123 -39
  152. package/dist/src/core/index.d.ts +123 -39
  153. package/dist/src/core/index.js +234 -86
  154. package/dist/src/core/index.js.map +1 -1
  155. package/dist/src/server/index.cjs +20 -27
  156. package/dist/src/server/index.cjs.map +1 -1
  157. package/dist/src/server/index.js +21 -28
  158. package/dist/src/server/index.js.map +1 -1
  159. package/package.json +11 -11
  160. package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +0 -283
  161. package/dist/host/agents/skills/orkestrel-dispatch/references/bench.md +0 -25
  162. package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +0 -333
  163. package/dist/host/agents/skills/orkestrel-parallel/references/harnesses.md +0 -19
  164. package/dist/host/guides/supervisor.md +0 -4890
@@ -1,27 +1,65 @@
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 { existsSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, } from 'node:fs';
19
36
  import { tmpdir } from 'node:os';
20
37
  import { basename, join, relative, resolve } from 'node:path';
21
- import { listFiles, readMissingFlags, readOption, } from "../../orkestrel-dispatch/scripts/helpers.js";
22
- const VITEST = 'node_modules/vitest/vitest.mjs';
23
- const MARKERS = ['.skip(', '.todo(', '.skipIf(', '.runIf(', 'retry:', 'timeout:'];
24
- const OMITTED = new Set([
38
+ /** Names the Vitest entry the census runs. */
39
+ export const VITEST = 'node_modules/vitest/vitest.mjs';
40
+ /** Lists the marker names the census counts, in report order. */
41
+ export const MARKERS = [
42
+ '.todo',
43
+ '.skipIf',
44
+ '.runIf',
45
+ '.fails',
46
+ 'retry',
47
+ 'timeout',
48
+ ];
49
+ /** Lists the options the scraped Vitest help table must hold before a listing runs. */
50
+ export const REQUIRED_OPTIONS = [
51
+ '--reporter',
52
+ '--project',
53
+ '--config',
54
+ '--filesOnly',
55
+ '--staticParse',
56
+ ];
57
+ /** Lists the flags this script reads, value flags and switches alike. */
58
+ export const FLAGS = ['--config', '--projects', '--json'];
59
+ /** Holds the usage line printed on a usage refusal. */
60
+ export const USAGE = 'usage: discovery.ts [--config vite.config.ts] [--projects a,b] [--json]';
61
+ /** Names the options a gate listing drops, because they change the run and not the selection. */
62
+ export const OMITTED = new Set([
25
63
  'reporter',
26
64
  'outputFile',
27
65
  'coverage',
@@ -35,10 +73,171 @@ const OMITTED = new Set([
35
73
  'inspectBrk',
36
74
  'standalone',
37
75
  ]);
38
- // Bounds one listing, so an option that waits for input exits 2 naming its gate instead of
39
- // hanging the census.
40
- const LISTING_TIMEOUT = 600_000;
41
- function readCLIOptions() {
76
+ /** Bounds one listing, so an option that waits for input exits 2 naming its gate. */
77
+ export const LISTING_TIMEOUT = 600_000;
78
+ /** Caps the characters of a name an error message echoes. */
79
+ export const NAME_LIMIT = 80;
80
+ /** Names the directories a tree walk skips by default, so build output and notes stay unread. */
81
+ export const SKIPPED_DIRECTORIES = new Set([
82
+ 'node_modules',
83
+ '.git',
84
+ 'dist',
85
+ 'tmp',
86
+ '.orkestrel',
87
+ '.idea',
88
+ ]);
89
+ /** Names the only directories the undiscovered scan skips. */
90
+ export const UNDISCOVERED_SKIPS = new Set(['node_modules', '.git']);
91
+ /** Matches the test file names the modification snapshot watches. */
92
+ export const TEST_FILE_PATTERN = /\.test\.[cm]?[jt]sx?$/u;
93
+ /** Matches the test and spec file names the undiscovered scan reports. */
94
+ export const DISCOVERABLE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
95
+ /** Matches a test file path a `node` command can run as an argument. */
96
+ export const TEST_ARGUMENT_PATTERN = /^(?:tests|src|app)\/[A-Za-z0-9_./-]+\.test\.[cm]?ts$/u;
97
+ /** Matches one command token, keeping a quoted run, an `&`-form redirection, or a separator whole. */
98
+ export const COMMAND_TOKEN_PATTERN = /\d*[<>]&[\d-]+|&>>?|(?:[^\s"';&|]+|"[^"]*"|'[^']*')+|[;&|]+/gu;
99
+ /** Matches a redirection operator that takes the next token as its target. */
100
+ export const REDIRECT_OPERATOR_PATTERN = /^(?:\d*>>?|\d*<|&>>?)$/u;
101
+ /** Matches a redirection that carries its target or duplicates a descriptor. */
102
+ export const REDIRECT_TARGET_PATTERN = /^(?:(?:\d*>>?|\d*<|&>>?)[^\s<>&]\S*|\d*[<>]&[\d-]+)$/u;
103
+ /** Matches a leading `NAME=value` environment assignment. */
104
+ export const ENVIRONMENT_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=/u;
105
+ /** Matches a `--watch` switch that is not turned off in its own token. */
106
+ export const WATCH_PATTERN = /^(?:--watch|--watch=(?!false$).*|-w)$/u;
107
+ /** Matches the reporting and cache-maintenance switches that make an invocation no gate. */
108
+ export const MAINTENANCE_PATTERN = /^(?:--mergeReports|--merge-reports|--listTags|--list-tags|--clearCache|--clear-cache)(?:=|$)/u;
109
+ /** Matches the options and filters that make a gate select tests by name, tag, line, or shard. */
110
+ export const FULL_PATTERN = /^(?:-t|--testNamePattern|--test-name-pattern|--tagsFilter|--tags-filter|--shard|--(?:no-)?allowOnly|--(?:no-)?allow-only|--(?:no-)?strictTags|--(?:no-)?strict-tags)(?:=|$)/u;
111
+ /** Matches a numeric literal token such as a timeout argument. */
112
+ export const NUMBER_PATTERN = /^\d[\d_]*(?:\.\d+)?(?:e\d+)?$/iu;
113
+ /** Matches the quoted string literals the scanner keeps whole. */
114
+ export const STRING_PATTERN = /^(?:'(?:\\[\s\S]|[^'\\\r\n])*'|"(?:\\[\s\S]|[^"\\\r\n])*")/u;
115
+ /** Matches a regular expression literal opening at a slash. */
116
+ export const REGEX_PATTERN = /^\/(?:\\.|\[(?:\\.|[^\]\\\r\n])*\]|[^/\\[\r\n])+\/[a-z]*/u;
117
+ /** Matches an identifier or a number as one scanner word. */
118
+ export const WORD_PATTERN = /^(?:[\p{ID_Start}_$][\p{ID_Continue}$]*|\d[\w.]*)/u;
119
+ /** Names the keywords after which a slash opens a regular expression. */
120
+ export const REGEX_KEYWORDS = new Set([
121
+ 'return',
122
+ 'typeof',
123
+ 'case',
124
+ 'throw',
125
+ 'in',
126
+ 'of',
127
+ 'void',
128
+ 'delete',
129
+ 'yield',
130
+ 'await',
131
+ 'else',
132
+ 'do',
133
+ 'instanceof',
134
+ 'new',
135
+ ]);
136
+ /** Lists the tokens that open a bracket group. */
137
+ export const OPENERS = ['(', '[', '{'];
138
+ /** Lists the tokens that close a bracket group. */
139
+ export const CLOSERS = [')', ']', '}'];
140
+ /** Names the calls whose first argument list precedes the registration call. */
141
+ export const ARGUMENT_MODIFIERS = new Set(['each', 'skipIf', 'runIf', 'for']);
142
+ /** Names the registration modifiers that need a ruling when a test file carries them. */
143
+ export const CHAIN_MARKERS = new Set(['todo', 'skipIf', 'runIf', 'fails']);
144
+ /** Names the identifiers that open a test registration. */
145
+ export const REGISTRARS = new Set(['it', 'test', 'describe', 'suite']);
146
+ /**
147
+ * Normalizes a host path to forward slashes.
148
+ *
149
+ * @param path - A host path.
150
+ * @returns The same path with every backslash replaced by a slash.
151
+ */
152
+ export function normalizeSlashes(path) {
153
+ return path.split('\\').join('/');
154
+ }
155
+ /**
156
+ * Reads the value that follows a flag.
157
+ *
158
+ * @remarks
159
+ * The first occurrence of a flag wins, a following value that opens with `--` is a missing value,
160
+ * and the `--flag=value` form is not read.
161
+ *
162
+ * @param argv - The arguments after the script path.
163
+ * @param name - The flag, such as `--config`.
164
+ * @returns The value after the flag, or undefined when the flag is absent, last, or followed by
165
+ * another flag.
166
+ */
167
+ export function readOption(argv, name) {
168
+ const index = argv.indexOf(name);
169
+ const value = index === -1 ? undefined : argv[index + 1];
170
+ return value === undefined || value.startsWith('--') ? undefined : value;
171
+ }
172
+ /**
173
+ * Names the value flags a caller gave with no value.
174
+ *
175
+ * @param argv - The arguments after the script path.
176
+ * @param names - The flags that take a value.
177
+ * @returns Each named flag that appears last or is followed by another flag, in argument order.
178
+ */
179
+ export function readMissingFlags(argv, names) {
180
+ return names.filter((name) => argv.includes(name) && readOption(argv, name) === undefined);
181
+ }
182
+ /**
183
+ * Names the flags a caller gave that the script does not read.
184
+ *
185
+ * @remarks
186
+ * Reads the arguments before a bare `--`. A `--flag=value` argument is unknown, because the script
187
+ * reads `--flag value` pairs only.
188
+ *
189
+ * @param argv - The arguments after the script path.
190
+ * @param flags - Every flag the script reads, value flags and switches alike.
191
+ * @returns Each argument that opens with `--` and is not a read flag, in argument order.
192
+ */
193
+ export function readUnknownFlags(argv, flags) {
194
+ const end = argv.indexOf('--');
195
+ return (end === -1 ? argv : argv.slice(0, end)).filter((argument) => argument.startsWith('--') && !flags.includes(argument));
196
+ }
197
+ /**
198
+ * Lists every regular file beneath a directory, skipping the named directories at any depth.
199
+ *
200
+ * @param directory - The directory to walk.
201
+ * @param skipped - The directory names to skip. Default: {@link SKIPPED_DIRECTORIES}.
202
+ * @returns Each file's forward-slash path and modification time, in walk order.
203
+ * @throws Thrown when `directory` does not exist or cannot be read; check `existsSync` first.
204
+ */
205
+ export function listFiles(directory, skipped = SKIPPED_DIRECTORIES) {
206
+ const files = [];
207
+ const pending = [directory];
208
+ while (pending.length > 0) {
209
+ const current = pending.pop();
210
+ if (current === undefined)
211
+ break;
212
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
213
+ if (skipped.has(entry.name))
214
+ continue;
215
+ const path = join(current, entry.name);
216
+ if (entry.isDirectory())
217
+ pending.push(path);
218
+ else if (entry.isFile()) {
219
+ files.push({ path: normalizeSlashes(path), modified: statSync(path).mtimeMs });
220
+ }
221
+ }
222
+ }
223
+ return files;
224
+ }
225
+ /**
226
+ * Describes a name for an error message, JSON-encoded and length-capped.
227
+ *
228
+ * @param name - The untrusted name to echo.
229
+ * @returns The encoded name, cut where it exceeds the cap.
230
+ */
231
+ export function describeName(name) {
232
+ return JSON.stringify(name.length > NAME_LIMIT ? `${name.slice(0, NAME_LIMIT)}…` : name);
233
+ }
234
+ /**
235
+ * Reads the Vitest option table the installed CLI prints for its list command.
236
+ *
237
+ * @returns Each option keyed by its long, kebab, negated, and short spellings.
238
+ * @throws Thrown when the help command fails.
239
+ */
240
+ export function readCLIOptions() {
42
241
  const help = spawnSync(process.execPath, [VITEST, 'list', '--help', '--expand-help'], {
43
242
  encoding: 'utf8',
44
243
  windowsHide: true,
@@ -60,8 +259,40 @@ function readCLIOptions() {
60
259
  ];
61
260
  }));
62
261
  }
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) {
262
+ /**
263
+ * Names the required options an option table lacks.
264
+ *
265
+ * @param options - The table `readCLIOptions` returned.
266
+ * @returns Each required option the table does not hold, in required order.
267
+ */
268
+ export function readMissingOptions(options) {
269
+ return REQUIRED_OPTIONS.filter((name) => !options.has(name));
270
+ }
271
+ /**
272
+ * Reads the installed Vitest version.
273
+ *
274
+ * @returns The version string, or `unknown` when the manifest names none.
275
+ */
276
+ export function readVitestVersion() {
277
+ const parsed = JSON.parse(readFileSync('node_modules/vitest/package.json', 'utf8'));
278
+ return typeof parsed === 'object' &&
279
+ parsed !== null &&
280
+ 'version' in parsed &&
281
+ typeof parsed.version === 'string'
282
+ ? parsed.version
283
+ : 'unknown';
284
+ }
285
+ /**
286
+ * Skips the options of a `node` command so the first non-option token is the runner.
287
+ *
288
+ * @remarks
289
+ * An option that takes a value consumes the token after it, so that token is never read as a runner.
290
+ *
291
+ * @param command - The command tokens.
292
+ * @param index - The index to start reading options at.
293
+ * @returns The index of the first non-option token, or -1 for an eval, print, or check command.
294
+ */
295
+ export function skipRunnerOptions(command, index) {
65
296
  const skipped = [...command.entries()].reduce((state, [position, option]) => {
66
297
  if (state.found !== undefined || position < state.next)
67
298
  return state;
@@ -90,7 +321,13 @@ function skipRunnerOptions(command, index) {
90
321
  }, { next: index, found: undefined });
91
322
  return skipped.found ?? skipped.next;
92
323
  }
93
- function findRunner(command) {
324
+ /**
325
+ * Finds the Vitest runner in a command.
326
+ *
327
+ * @param command - The command tokens, past any environment prefix.
328
+ * @returns The runner's index, or -1 when the command does not run Vitest.
329
+ */
330
+ export function findRunner(command) {
94
331
  const executable = basename(command[0] ?? '').replace(/\.(?:exe|cmd)$/u, '');
95
332
  const start = executable === 'npm'
96
333
  ? command[1] === 'exec'
@@ -106,9 +343,18 @@ function findRunner(command) {
106
343
  ? index
107
344
  : -1;
108
345
  }
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.
111
- function readSelection(argv, options) {
346
+ /**
347
+ * Keeps the tokens of a gate command that select tests and drops the run-mode options.
348
+ *
349
+ * @remarks
350
+ * The first `--` ends the options, so the rest of the arguments pass verbatim. A runner option that
351
+ * takes a value consumes the argument after it, so a step below `skip` reads nothing.
352
+ *
353
+ * @param argv - The tokens after the Vitest runner and verb.
354
+ * @param options - The option table `readCLIOptions` returned.
355
+ * @returns The tokens to pass to a listing.
356
+ */
357
+ export function readSelection(argv, options) {
112
358
  return argv.reduce((state, token, index) => {
113
359
  if (state.closed || index < state.skip)
114
360
  return state;
@@ -133,7 +379,14 @@ function readSelection(argv, options) {
133
379
  return { ...state, skip: index + (valued ? 2 : 1) };
134
380
  }, { selected: [], skip: 0, closed: false }).selected;
135
381
  }
136
- function readArguments(argv, names) {
382
+ /**
383
+ * Reads every value a named flag carries before the first `--`.
384
+ *
385
+ * @param argv - The tokens to read.
386
+ * @param names - The flag spellings to read.
387
+ * @returns Each value of the flag in either the `--flag value` or the `--flag=value` form.
388
+ */
389
+ export function readArguments(argv, names) {
137
390
  const end = argv.indexOf('--');
138
391
  return (end < 0 ? argv : argv.slice(0, end)).flatMap((token, index) => {
139
392
  const equals = token.indexOf('=');
@@ -143,7 +396,12 @@ function readArguments(argv, names) {
143
396
  return value !== undefined && !value.startsWith('-') ? [value] : [];
144
397
  });
145
398
  }
146
- function readScripts() {
399
+ /**
400
+ * Reads the script table of the package manifest in the working directory.
401
+ *
402
+ * @returns Each script name with its command text.
403
+ */
404
+ export function readScripts() {
147
405
  const parsed = JSON.parse(readFileSync('package.json', 'utf8'));
148
406
  if (typeof parsed !== 'object' || parsed === null)
149
407
  return {};
@@ -152,96 +410,311 @@ function readScripts() {
152
410
  return {};
153
411
  return Object.fromEntries(Object.entries(scripts).flatMap(([key, value]) => typeof value === 'string' ? [[key, value]] : []));
154
412
  }
155
- function readCommands(text) {
156
- const tokens = [...text.matchAll(/(?:[^\s"';&|]+|"[^"]*"|'[^']*')+|[;&|]+/gu)].map((match) => match[0]);
413
+ /**
414
+ * Drops the redirections from a command's tokens.
415
+ *
416
+ * @param tokens - The raw command tokens, quotes still attached.
417
+ * @returns The tokens without a redirection operator, its target, or a descriptor duplication.
418
+ */
419
+ export function stripRedirections(tokens) {
420
+ return tokens.filter((token, index) => {
421
+ const previous = tokens[index - 1];
422
+ return (!(previous !== undefined && REDIRECT_OPERATOR_PATTERN.test(previous)) &&
423
+ !REDIRECT_OPERATOR_PATTERN.test(token) &&
424
+ !REDIRECT_TARGET_PATTERN.test(token));
425
+ });
426
+ }
427
+ /**
428
+ * Drops the leading `cross-env` word and `NAME=value` assignments from a command's tokens.
429
+ *
430
+ * @param tokens - The command tokens.
431
+ * @returns The tokens from the first word that is neither, empty when the command is only a prefix.
432
+ */
433
+ export function stripEnvironment(tokens) {
434
+ const start = tokens.findIndex((token) => token !== 'cross-env' && !ENVIRONMENT_PATTERN.test(token));
435
+ return start < 0 ? [] : tokens.slice(start);
436
+ }
437
+ /**
438
+ * Splits a script's text into its commands.
439
+ *
440
+ * @param text - The script text, with `;`, `&`, and `|` separating commands.
441
+ * @returns The commands as tokens, past redirections and environment prefixes, quotes removed.
442
+ */
443
+ export function readCommands(text) {
444
+ const tokens = [...text.matchAll(COMMAND_TOKEN_PATTERN)].map((match) => match[0]);
157
445
  // A separator token ends a command, so the commands are the runs of tokens between the cuts.
158
446
  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, '')));
447
+ return [-1, ...cuts].map((cut, at) => stripEnvironment(stripRedirections(tokens.slice(cut + 1, cuts[at] ?? tokens.length))).map((token) => token.replace(/["']/gu, '')));
160
448
  }
161
- function listInvocations(commands) {
449
+ /**
450
+ * Lists the script calls the commands make.
451
+ *
452
+ * @remarks
453
+ * Reads `npm run [-s|--silent] NAME`, `npm run-script NAME`, `npm test`, and `npm t`; the last two
454
+ * are shorthand, which runs a script a person also starts directly. Arguments after `--` forward.
455
+ *
456
+ * @param commands - The commands to read.
457
+ * @returns One invocation per script call.
458
+ */
459
+ export function listInvocations(commands) {
162
460
  return commands.flatMap((command) => {
163
461
  const index = command.indexOf('npm');
164
- const name = command[index + 2];
165
- if (index < 0 || command[index + 1] !== 'run' || name === undefined)
462
+ if (index < 0)
166
463
  return [];
167
- const separator = command.indexOf('--', index + 3);
168
- return [{ name, args: separator < 0 ? [] : command.slice(separator + 1), ancestors: [] }];
464
+ const verb = command[index + 1];
465
+ const separator = command.indexOf('--', index + 2);
466
+ const args = separator < 0 ? [] : command.slice(separator + 1);
467
+ if (verb === 'test' || verb === 't') {
468
+ return [{ name: 'test', args, ancestors: [], shorthand: true }];
469
+ }
470
+ if (verb !== 'run' && verb !== 'run-script')
471
+ return [];
472
+ const name = (separator < 0 ? command.slice(index + 2) : command.slice(index + 2, separator)).find((token) => !token.startsWith('-'));
473
+ return name === undefined ? [] : [{ name, args, ancestors: [], shorthand: false }];
169
474
  });
170
475
  }
171
- function readGates(scripts, config, options) {
476
+ /**
477
+ * Walks the scripts a root reaches, last-pushed first, each script once per forwarded arguments.
478
+ *
479
+ * @param commands - The commands of every script, by name.
480
+ * @param root - The root script the chain starts at.
481
+ * @param pending - The invocations left to visit.
482
+ * @param visited - The name and argument keys already visited.
483
+ * @returns One visit per script reached, in visit order.
484
+ */
485
+ export function walkInvocations(commands, root, pending, visited) {
486
+ const invocation = pending.at(-1);
487
+ if (invocation === undefined)
488
+ return [];
489
+ const rest = pending.slice(0, -1);
490
+ const { name, args, ancestors } = invocation;
491
+ const key = JSON.stringify([name, args]);
492
+ if (visited.has(key) || ancestors.includes(name)) {
493
+ return walkInvocations(commands, root, rest, visited);
494
+ }
495
+ const seen = new Set([...visited, key]);
496
+ const script = commands[name];
497
+ if (script === undefined)
498
+ return walkInvocations(commands, root, rest, seen);
499
+ const expanded = script.map((command, index) => index === script.length - 1 ? [...command, ...args] : command);
500
+ const children = listInvocations(expanded).map((entry) => ({
501
+ ...entry,
502
+ ancestors: [...ancestors, name],
503
+ }));
504
+ return [
505
+ { chain: root === name ? name : `${root} > ${name}`, commands: expanded },
506
+ ...walkInvocations(commands, root, [...rest, ...children], seen),
507
+ ];
508
+ }
509
+ /**
510
+ * Checks whether a command's options name a reporting or cache-maintenance invocation.
511
+ *
512
+ * @param before - The tokens before the first `--`.
513
+ * @returns True if a maintenance switch is present and not turned off.
514
+ */
515
+ export function isMaintenance(before) {
516
+ return before.some((token, position) => MAINTENANCE_PATTERN.test(token) &&
517
+ !token.endsWith('=false') &&
518
+ before[position + 1] !== 'false');
519
+ }
520
+ /**
521
+ * Checks whether a command's options start watch mode.
522
+ *
523
+ * @param before - The tokens before the first `--`.
524
+ * @returns True if `--watch` or `-w` is present and not turned off.
525
+ */
526
+ export function isWatching(before) {
527
+ return before.some((token, position) => WATCH_PATTERN.test(token) && before[position + 1] !== 'false');
528
+ }
529
+ /**
530
+ * Reads the gate a command makes, or none when the command does not run a test selection.
531
+ *
532
+ * @param command - The command tokens.
533
+ * @param chain - The root chain label.
534
+ * @param config - The base config path, forward-slashed and relative.
535
+ * @param options - The option table `readCLIOptions` returned.
536
+ * @returns The gate, or undefined for a command that is no Vitest run or no gate.
537
+ * @throws Thrown for a `related` command, which `vitest list` cannot select.
538
+ */
539
+ export function readGate(command, chain, config, options) {
540
+ const index = findRunner(command);
541
+ if (index < 0)
542
+ return undefined;
543
+ const verb = command[index + 1];
544
+ if (verb === 'list' || verb === 'bench' || verb === 'watch' || verb === 'dev')
545
+ return undefined;
546
+ const tokens = command.slice(index + (verb === 'run' ? 2 : 1));
547
+ const separator = tokens.indexOf('--');
548
+ const before = separator < 0 ? tokens : tokens.slice(0, separator);
549
+ if (isMaintenance(before) || isWatching(before))
550
+ return undefined;
551
+ if (verb === 'related') {
552
+ throw new Error(`${chain}: Vitest list exposes no related command or --related option`);
553
+ }
554
+ const flags = readSelection(tokens, options);
555
+ const mode = readArguments(flags, ['--mode'])[0];
556
+ const directory = readArguments(flags, ['--root', '-r'])[0];
557
+ const dir = readArguments(flags, ['--dir'])[0];
558
+ const configured = readArguments(flags, ['--config', '-c'])[0];
559
+ const path = configured ?? (directory === undefined && dir === undefined ? config : undefined);
560
+ return {
561
+ config: path === undefined ? undefined : normalizeSlashes(relative(process.cwd(), resolve(path))),
562
+ mode: mode === 'test' ? undefined : mode,
563
+ ...(directory === undefined ? {} : { root: directory }),
564
+ ...(dir === undefined ? {} : { dir }),
565
+ projects: readArguments(flags, ['--project']),
566
+ chain,
567
+ args: configured === undefined && path !== undefined ? ['--config', path, ...flags] : flags,
568
+ full: before.some((flag) => FULL_PATTERN.test(flag) || /:\d+(?:-\d+)?$/u.test(flag)),
569
+ };
570
+ }
571
+ /**
572
+ * Reads the test files a `node` command runs as its arguments.
573
+ *
574
+ * @param command - The command tokens.
575
+ * @returns The test file arguments of `node FILE` or `node --test FILE...`; none for any other runner.
576
+ */
577
+ export function readFileCredits(command) {
578
+ if (basename(command[0] ?? '').replace(/\.exe$/u, '') !== 'node')
579
+ return [];
580
+ const first = skipRunnerOptions(command, 1);
581
+ if (first < 0)
582
+ return [];
583
+ const candidates = command.includes('--test')
584
+ ? command.slice(first)
585
+ : command.slice(first, first + 1);
586
+ return candidates.filter((argument) => TEST_ARGUMENT_PATTERN.test(argument));
587
+ }
588
+ /**
589
+ * Reads the gates and node-run test files every root script chain reaches.
590
+ *
591
+ * @param scripts - The package scripts by name.
592
+ * @param config - The base config path, forward-slashed and relative.
593
+ * @param options - The option table `readCLIOptions` returned.
594
+ * @returns The gates in visit order, and the earliest chain that runs each test file with `node`.
595
+ */
596
+ export function readGates(scripts, config, options) {
172
597
  const commands = Object.fromEntries(Object.entries(scripts).map(([name, text]) => [name, readCommands(text)]));
598
+ // A shorthand call (`npm test`) runs a script a person also starts directly, so it stays a root.
173
599
  const invoked = new Set(Object.values(commands)
174
600
  .flatMap(listInvocations)
601
+ .filter((entry) => !entry.shorthand)
175
602
  .map((entry) => entry.name));
176
- const units = [];
177
- const files = new Map();
178
- for (const root of Object.keys(scripts).filter((name) => !invoked.has(name))) {
179
- const visited = new Set();
180
- const pending = [{ name: root, args: [], ancestors: [] }];
181
- while (pending.length > 0) {
182
- const invocation = pending.pop();
183
- if (invocation === undefined)
184
- continue;
185
- const { name, args, ancestors } = invocation;
186
- const key = JSON.stringify([name, args]);
187
- if (visited.has(key) || ancestors.includes(name))
188
- continue;
189
- visited.add(key);
190
- const script = commands[name];
191
- if (script === undefined)
192
- continue;
193
- const expanded = script.map((command, index) => index === script.length - 1 ? [...command, ...args] : command);
194
- for (const entry of listInvocations(expanded)) {
195
- pending.push({ ...entry, ancestors: [...ancestors, name] });
196
- }
197
- const gate = root === name ? name : `${root} > ${name}`;
198
- for (const command of expanded) {
199
- const index = findRunner(command);
200
- if (index >= 0) {
201
- const verb = command[index + 1];
202
- if (verb === 'list' || verb === 'bench')
203
- continue;
204
- const tokens = command.slice(index + (['run', 'watch', 'dev'].includes(verb ?? '') ? 2 : 1));
205
- const separator = tokens.indexOf('--');
206
- const before = separator < 0 ? tokens : tokens.slice(0, separator);
207
- if (before.some((token, position) => /^(?:--mergeReports|--merge-reports|--listTags|--list-tags|--clearCache|--clear-cache)(?:=|$)/u.test(token) &&
208
- !token.endsWith('=false') &&
209
- before[position + 1] !== 'false'))
210
- continue;
211
- if (verb === 'related')
212
- throw new Error(`${gate}: Vitest list exposes no related command or --related option`);
213
- const flags = readSelection(tokens, options);
214
- const mode = readArguments(flags, ['--mode'])[0];
215
- const directory = readArguments(flags, ['--root', '-r'])[0];
216
- const dir = readArguments(flags, ['--dir'])[0];
217
- const configured = readArguments(flags, ['--config', '-c'])[0];
218
- const path = configured ?? (directory === undefined && dir === undefined ? config : undefined);
219
- units.push({
220
- config: path === undefined
221
- ? undefined
222
- : relative(process.cwd(), resolve(path)).split('\\').join('/'),
223
- mode: mode === 'test' ? undefined : mode,
224
- ...(directory === undefined ? {} : { root: directory }),
225
- ...(dir === undefined ? {} : { dir }),
226
- projects: readArguments(flags, ['--project']),
227
- chain: gate,
228
- args: configured === undefined && path !== undefined ? ['--config', path, ...flags] : flags,
229
- full: before.some((flag) => /^(?:-t|--testNamePattern|--test-name-pattern|--tagsFilter|--tags-filter|--shard|--(?:no-)?allowOnly|--(?:no-)?allow-only|--(?:no-)?strictTags|--(?:no-)?strict-tags)(?:=|$)/u.test(flag) || /:\d+(?:-\d+)?$/u.test(flag)),
230
- });
231
- continue;
232
- }
233
- for (const argument of command) {
234
- if (/^(?:tests|src|app)\/[A-Za-z0-9_./-]+\.test\.[cm]?ts$/u.test(argument) &&
235
- !files.has(argument))
236
- files.set(argument, gate);
237
- }
238
- }
239
- }
240
- }
603
+ const visits = Object.keys(scripts)
604
+ .filter((name) => !invoked.has(name))
605
+ .flatMap((root) => walkInvocations(commands, root, [{ name: root, args: [], ancestors: [], shorthand: false }], new Set()));
606
+ const units = visits.flatMap((visit) => visit.commands.flatMap((command) => {
607
+ const gate = readGate(command, visit.chain, config, options);
608
+ return gate === undefined ? [] : [gate];
609
+ }));
610
+ // A Map keeps the last entry of a key, so the reversed credits leave the earliest chain.
611
+ const files = new Map(visits
612
+ .flatMap((visit) => visit.commands.flatMap((command) => findRunner(command) >= 0
613
+ ? []
614
+ : readFileCredits(command).map((file) => [file, visit.chain])))
615
+ .toReversed());
241
616
  return { units, files };
242
617
  }
243
- function listCollected(args, full, cache) {
244
- const parameters = [...(full ? ['--includeTaskLocation'] : ['--filesOnly']), ...args];
618
+ /**
619
+ * Checks whether a project name is a literal name and not a Vitest pattern.
620
+ *
621
+ * @param name - The project name or pattern.
622
+ * @returns True if the name holds no `*` and does not open with `!`.
623
+ */
624
+ export function isConcreteName(name) {
625
+ return !name.includes('*') && !name.startsWith('!');
626
+ }
627
+ /**
628
+ * Checks whether a name matches a wildcard pattern the way Vitest matches a project filter.
629
+ *
630
+ * @param pattern - The pattern, where `*` matches any run of characters.
631
+ * @param name - The name to test.
632
+ * @returns True if the whole name matches, ignoring case.
633
+ */
634
+ export function matchesWildcard(pattern, name) {
635
+ const body = pattern
636
+ .split('*')
637
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'))
638
+ .join('.*');
639
+ return new RegExp(`^${body}$`, 'iu').test(name);
640
+ }
641
+ /**
642
+ * Checks whether a gate's project filters reach a project name.
643
+ *
644
+ * @remarks
645
+ * A browser instance name such as `web (chromium)` is also tested by its base name, because Vitest
646
+ * keeps every instance of a project whose base name a positive filter matches and drops them all
647
+ * when a negative filter matches it.
648
+ *
649
+ * @param filters - The gate's `--project` values, where a leading `!` negates.
650
+ * @param name - The concrete project name.
651
+ * @returns True if no negative filter matches and some positive filter does, or none is positive.
652
+ */
653
+ export function matchesProjectFilter(filters, name) {
654
+ const names = [name, name.replace(/ \([^)]*\)$/u, '')];
655
+ const negative = filters
656
+ .filter((filter) => filter.startsWith('!'))
657
+ .map((filter) => filter.slice(1));
658
+ const positive = filters.filter((filter) => !filter.startsWith('!'));
659
+ if (negative.some((pattern) => names.some((candidate) => matchesWildcard(pattern, candidate)))) {
660
+ return false;
661
+ }
662
+ return (positive.length === 0 ||
663
+ positive.some((pattern) => names.some((candidate) => matchesWildcard(pattern, candidate))));
664
+ }
665
+ /**
666
+ * Replaces the `--project` flags of a gate's arguments.
667
+ *
668
+ * @param args - The gate arguments.
669
+ * @param projects - The project names to pass in place of the existing flags.
670
+ * @returns The arguments with one `--project NAME` pair per name, before any bare `--`.
671
+ */
672
+ export function replaceProjects(args, projects) {
673
+ const end = args.indexOf('--');
674
+ const head = end < 0 ? args : args.slice(0, end);
675
+ const tail = end < 0 ? [] : args.slice(end);
676
+ const kept = head.filter((token, index) => token !== '--project' && !token.startsWith('--project=') && head[index - 1] !== '--project');
677
+ return [...kept, ...projects.flatMap((project) => ['--project', project]), ...tail];
678
+ }
679
+ /**
680
+ * Narrows the gates to the named projects, so no listing runs for a project outside the names.
681
+ *
682
+ * @remarks
683
+ * A gate with no project filter lists the named projects alone. A gate that names projects lists the
684
+ * named ones its filters reach and is dropped when it reaches none. A pattern among the names
685
+ * (`*`, or a leading `!`) cannot be matched beforehand, so a gate that names projects keeps them.
686
+ *
687
+ * @param gates - The gates the root chains reach.
688
+ * @param named - The `--projects` names, empty for no narrowing.
689
+ * @returns The gates to list, each with its narrowed arguments.
690
+ */
691
+ export function narrowGates(gates, named) {
692
+ if (named.length === 0)
693
+ return gates;
694
+ const concrete = named.filter(isConcreteName);
695
+ const patterned = concrete.length < named.length;
696
+ return gates.flatMap((gate) => {
697
+ if (gate.projects.length === 0)
698
+ return [{ ...gate, args: replaceProjects(gate.args, named) }];
699
+ if (patterned)
700
+ return [gate];
701
+ const reached = concrete.filter((name) => matchesProjectFilter(gate.projects, name));
702
+ return reached.length === 0 ? [] : [{ ...gate, args: replaceProjects(gate.args, reached) }];
703
+ });
704
+ }
705
+ /**
706
+ * Lists the tests one Vitest listing collects.
707
+ *
708
+ * @param args - The arguments to pass after `vitest list`.
709
+ * @param full - True to collect each test with its name and location; false for files only.
710
+ * @param cache - The listings already read, keyed by their arguments.
711
+ * @returns The tests and whether the project filter matched, or undefined when Vitest refuses.
712
+ */
713
+ export function listCollected(args, full, cache) {
714
+ const parameters = [
715
+ ...(full ? ['--includeTaskLocation', '--staticParse=false'] : ['--filesOnly']),
716
+ ...args,
717
+ ];
245
718
  const key = JSON.stringify(parameters);
246
719
  const cached = cache.get(key);
247
720
  if (cached !== undefined)
@@ -257,9 +730,11 @@ function listCollected(args, full, cache) {
257
730
  });
258
731
  if (args.includes('--project') &&
259
732
  result.status === 1 &&
260
- result.stderr.includes('No projects matched the filter "')) {
261
- cache.set(key, []);
262
- return [];
733
+ (result.stderr.includes('No projects matched the filter "') ||
734
+ result.stderr.includes('The filter matched no projects:'))) {
735
+ const unmatched = { entries: [], matched: false };
736
+ cache.set(key, unmatched);
737
+ return unmatched;
263
738
  }
264
739
  if (result.status !== 0 || !existsSync(listing)) {
265
740
  const diagnostic = result.stderr || result.error?.message || result.stdout;
@@ -287,7 +762,7 @@ function listCollected(args, full, cache) {
287
762
  }
288
763
  return [
289
764
  {
290
- file: relative(process.cwd(), record.file).split('\\').join('/'),
765
+ file: normalizeSlashes(relative(process.cwd(), record.file)),
291
766
  projectName: record.projectName ?? '',
292
767
  name: typeof record.name === 'string' ? record.name : undefined,
293
768
  line: typeof location.line === 'number' ? location.line : undefined,
@@ -295,8 +770,9 @@ function listCollected(args, full, cache) {
295
770
  },
296
771
  ];
297
772
  });
298
- cache.set(key, collected);
299
- return collected;
773
+ const listed = { entries: collected, matched: true };
774
+ cache.set(key, listed);
775
+ return listed;
300
776
  }
301
777
  catch (error) {
302
778
  console.error(error instanceof Error ? error.message : String(error));
@@ -306,86 +782,340 @@ function listCollected(args, full, cache) {
306
782
  rmSync(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
307
783
  }
308
784
  }
309
- function listTestFiles(directory) {
785
+ /**
786
+ * Lists the test and spec files beneath a directory.
787
+ *
788
+ * @param directory - The directory to walk; a missing directory holds none.
789
+ * @returns The forward-slash paths of every test or spec file, skipping only `node_modules` and `.git`.
790
+ */
791
+ export function listTestFiles(directory) {
310
792
  if (!existsSync(directory))
311
793
  return [];
312
- return listFiles(directory)
794
+ return listFiles(directory, UNDISCOVERED_SKIPS)
313
795
  .map((file) => file.path)
314
- .filter((path) => /\.test\.[cm]?ts$/u.test(path));
796
+ .filter((path) => DISCOVERABLE_PATTERN.test(path));
315
797
  }
316
- function countMarkers(file) {
317
- const text = readFileSync(file, 'utf8');
318
- return Object.fromEntries(MARKERS.map((marker) => [marker, text.split(marker).length - 1]).filter(([, count]) => count > 0));
798
+ /**
799
+ * Scans source text into tokens that tell code from comments and strings.
800
+ *
801
+ * @remarks
802
+ * Comments are dropped. A string, a template (whole, with its expressions), and a regular expression
803
+ * literal each become one token. A slash opens a regular expression after an operator, an opening
804
+ * bracket, or a keyword, and divides after a word, a literal, or a closing bracket. The scanner
805
+ * pairs each opening bracket with its closing bracket as it reads.
806
+ */
807
+ export class CodeScanner {
808
+ #text;
809
+ #tokens = [];
810
+ #closes = new Map();
811
+ #opened = [];
812
+ #position = 0;
813
+ #last = undefined;
814
+ constructor(text) {
815
+ this.#text = text;
816
+ }
817
+ scan() {
818
+ while (this.#position < this.#text.length) {
819
+ const token = this.#read();
820
+ if (token === undefined)
821
+ continue;
822
+ if (token.kind === 'punct' && OPENERS.includes(token.text)) {
823
+ this.#opened.push(this.#tokens.length);
824
+ }
825
+ if (token.kind === 'punct' && CLOSERS.includes(token.text)) {
826
+ const open = this.#opened.pop();
827
+ if (open !== undefined)
828
+ this.#closes.set(open, this.#tokens.length);
829
+ }
830
+ this.#tokens.push(token);
831
+ }
832
+ return { tokens: this.#tokens, closes: this.#closes };
833
+ }
834
+ #read() {
835
+ const rest = this.#text.slice(this.#position);
836
+ const skipped = /^(?:\s+|\/\/[^\r\n]*|\/\*[\s\S]*?(?:\*\/|$))/u.exec(rest);
837
+ if (skipped !== null) {
838
+ this.#position += skipped[0].length;
839
+ return undefined;
840
+ }
841
+ if (rest.startsWith('`'))
842
+ return this.#take('template', this.#readTemplate());
843
+ const quoted = STRING_PATTERN.exec(rest);
844
+ if (quoted !== null)
845
+ return this.#take('string', quoted[0]);
846
+ if (rest.startsWith('/') && this.#expectsRegex()) {
847
+ const regex = REGEX_PATTERN.exec(rest);
848
+ if (regex !== null)
849
+ return this.#take('regex', regex[0]);
850
+ }
851
+ const word = WORD_PATTERN.exec(rest);
852
+ if (word !== null)
853
+ return this.#take('word', word[0]);
854
+ return this.#take('punct', rest.slice(0, 1));
855
+ }
856
+ #take(kind, text) {
857
+ if (kind !== 'template')
858
+ this.#position += text.length;
859
+ const token = { kind, text };
860
+ this.#last = token;
861
+ return token;
862
+ }
863
+ #expectsRegex() {
864
+ const last = this.#last;
865
+ if (last === undefined)
866
+ return true;
867
+ if (last.kind === 'punct')
868
+ return !CLOSERS.includes(last.text);
869
+ if (last.kind === 'word')
870
+ return REGEX_KEYWORDS.has(last.text);
871
+ return false;
872
+ }
873
+ #readTemplate() {
874
+ const start = this.#position;
875
+ this.#position += 1;
876
+ while (this.#position < this.#text.length) {
877
+ const character = this.#text[this.#position];
878
+ if (character === '\\') {
879
+ this.#position += 2;
880
+ }
881
+ else if (character === '`') {
882
+ this.#position += 1;
883
+ return this.#text.slice(start, this.#position);
884
+ }
885
+ else if (this.#text.startsWith('${', this.#position)) {
886
+ this.#position += 2;
887
+ this.#skipExpression();
888
+ }
889
+ else {
890
+ this.#position += 1;
891
+ }
892
+ }
893
+ return this.#text.slice(start, this.#position);
894
+ }
895
+ #skipExpression() {
896
+ while (this.#position < this.#text.length) {
897
+ const token = this.#read();
898
+ if (token?.kind !== 'punct')
899
+ continue;
900
+ if (token.text === '}')
901
+ return;
902
+ if (token.text === '{')
903
+ this.#skipExpression();
904
+ }
905
+ }
319
906
  }
320
- function collectListings(config, named, gates) {
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.
335
- const cache = new Map();
336
- const readings = collectReadings(units, (unit) => readUnit(unit, named, gates, cache));
337
- if (readings === undefined)
907
+ /**
908
+ * Checks whether a token is a punctuation mark with the given text.
909
+ *
910
+ * @param token - The token, or undefined past the end.
911
+ * @param text - The punctuation text.
912
+ * @returns True if the token is that punctuation mark.
913
+ */
914
+ export function isPunct(token, text) {
915
+ return token?.kind === 'punct' && token.text === text;
916
+ }
917
+ /**
918
+ * Reads the modifier chain after a registrar, up to its registration call.
919
+ *
920
+ * @param scan - The scanned source.
921
+ * @param at - The index after the registrar or the last chain element.
922
+ * @param names - The modifier names read so far; an empty name marks a consumed argument group.
923
+ * @returns The names and the index of the registration call's opening parenthesis, or undefined when
924
+ * the tokens are no registration call.
925
+ */
926
+ export function readChain(scan, at, names) {
927
+ const token = scan.tokens[at];
928
+ if (token === undefined)
338
929
  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)
930
+ const last = names.at(-1) ?? '';
931
+ if (isPunct(token, '.')) {
932
+ const name = scan.tokens[at + 1];
933
+ return name?.kind === 'word' ? readChain(scan, at + 2, [...names, name.text]) : undefined;
934
+ }
935
+ if (token.kind === 'template' && ARGUMENT_MODIFIERS.has(last)) {
936
+ return readChain(scan, at + 1, [...names, '']);
937
+ }
938
+ if (!isPunct(token, '('))
349
939
  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
- }));
940
+ if (!ARGUMENT_MODIFIERS.has(last))
941
+ return { names, call: at };
942
+ const close = scan.closes.get(at);
943
+ return close === undefined ? undefined : readChain(scan, close + 1, [...names, '']);
944
+ }
945
+ /**
946
+ * Splits a token range into its top-level arguments, jumping over bracket groups.
947
+ *
948
+ * @param scan - The scanned source.
949
+ * @param start - The index the current argument starts at.
950
+ * @param at - The index being read.
951
+ * @param end - The index the range ends at, excluded.
952
+ * @returns One span per non-empty argument.
953
+ */
954
+ export function splitArguments(scan, start, at, end) {
955
+ if (at >= end)
956
+ return start < end ? [{ start, end }] : [];
957
+ const token = scan.tokens[at];
958
+ if (token?.kind === 'punct' && OPENERS.includes(token.text)) {
959
+ return splitArguments(scan, start, (scan.closes.get(at) ?? at) + 1, end);
960
+ }
961
+ if (isPunct(token, ',')) {
962
+ return [{ start, end: at }, ...splitArguments(scan, at + 1, at + 1, end)];
963
+ }
964
+ return splitArguments(scan, start, at + 1, end);
965
+ }
966
+ /**
967
+ * Reads the property keys at the top level of an object literal's token range.
968
+ *
969
+ * @param scan - The scanned source.
970
+ * @param at - The index being read.
971
+ * @param end - The index the range ends at, excluded.
972
+ * @returns Each key that follows `{` or `,` and precedes `:`, `,`, or `}`, quotes removed.
973
+ */
974
+ export function readKeys(scan, at, end) {
975
+ const token = scan.tokens[at];
976
+ if (token === undefined || at >= end)
977
+ return [];
978
+ if (token.kind === 'punct' && OPENERS.includes(token.text)) {
979
+ return readKeys(scan, (scan.closes.get(at) ?? at) + 1, end);
980
+ }
981
+ const previous = scan.tokens[at - 1];
982
+ const following = scan.tokens[at + 1];
983
+ const spelled = token.kind === 'word'
984
+ ? token.text
985
+ : token.kind === 'string'
986
+ ? token.text.slice(1, -1)
987
+ : undefined;
988
+ const isKey = spelled !== undefined &&
989
+ (isPunct(previous, '{') || isPunct(previous, ',')) &&
990
+ (isPunct(following, ':') || isPunct(following, ',') || isPunct(following, '}'));
991
+ return [...(isKey ? [spelled] : []), ...readKeys(scan, at + 1, end)];
992
+ }
993
+ /**
994
+ * Reads the retry and timeout markers a registration's arguments carry.
995
+ *
996
+ * @param scan - The scanned source.
997
+ * @param spans - The registration call's top-level arguments.
998
+ * @returns A `timeout` entry for a numeric last argument after the title, and an entry for each
999
+ * `retry` or `timeout` key of an object-literal argument.
1000
+ */
1001
+ export function readArgumentMarkers(scan, spans) {
1002
+ const last = spans.at(-1);
1003
+ const lastToken = last === undefined ? undefined : scan.tokens[last.start];
1004
+ const numeric = spans.length >= 2 &&
1005
+ last !== undefined &&
1006
+ last.end - last.start === 1 &&
1007
+ lastToken?.kind === 'word' &&
1008
+ NUMBER_PATTERN.test(lastToken.text);
1009
+ const keys = spans.flatMap((span) => isPunct(scan.tokens[span.start], '{') && scan.closes.get(span.start) === span.end - 1
1010
+ ? readKeys(scan, span.start + 1, span.end - 1)
1011
+ : []);
1012
+ return [
1013
+ ...(numeric ? ['timeout'] : []),
1014
+ ...keys.filter((key) => key === 'retry' || key === 'timeout'),
1015
+ ];
1016
+ }
1017
+ /**
1018
+ * Reads the markers one registration carries.
1019
+ *
1020
+ * @param scan - The scanned source.
1021
+ * @param index - The index of the registrar identifier.
1022
+ * @returns The chain markers (`.todo`, `.skipIf`, `.runIf`, `.fails`) and the argument markers.
1023
+ */
1024
+ export function readHits(scan, index) {
1025
+ const chain = readChain(scan, index + 1, []);
1026
+ if (chain === undefined)
1027
+ return [];
1028
+ const close = scan.closes.get(chain.call);
1029
+ const spans = close === undefined ? [] : splitArguments(scan, chain.call + 1, chain.call + 1, close);
1030
+ return [
1031
+ ...chain.names.filter((name) => CHAIN_MARKERS.has(name)).map((name) => `.${name}`),
1032
+ ...readArgumentMarkers(scan, spans),
1033
+ ];
358
1034
  }
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)
1035
+ /**
1036
+ * Counts the markers a scanned test file carries, as call forms outside comments and strings.
1037
+ *
1038
+ * @param scan - The scanned source.
1039
+ * @returns Each marker with a nonzero count, in marker order.
1040
+ */
1041
+ export function readMarkers(scan) {
1042
+ const hits = scan.tokens.flatMap((token, index) => token.kind === 'word' && REGISTRARS.has(token.text) && !isPunct(scan.tokens[index - 1], '.')
1043
+ ? readHits(scan, index)
1044
+ : []);
1045
+ return Object.fromEntries(MARKERS.map((marker) => [marker, hits.filter((hit) => hit === marker).length]).filter(([, count]) => count > 0));
1046
+ }
1047
+ /**
1048
+ * Counts the markers of a test file on disk.
1049
+ *
1050
+ * @param file - The test file path.
1051
+ * @returns Each marker with a nonzero count.
1052
+ */
1053
+ export function countMarkers(file) {
1054
+ return readMarkers(new CodeScanner(readFileSync(file, 'utf8')).scan());
1055
+ }
1056
+ /**
1057
+ * Runs one read per item in order and stops at the first refusal, so no later read spawns.
1058
+ *
1059
+ * @param items - The items to read.
1060
+ * @param read - The read for one item; undefined refuses.
1061
+ * @returns The readings in order, or undefined once any read refuses.
1062
+ */
1063
+ export function collectResults(items, read) {
1064
+ return items.reduce((results, item) => {
1065
+ if (results === undefined)
363
1066
  return undefined;
364
- const reading = read(item);
365
- return reading === undefined ? undefined : [...readings, reading];
1067
+ const result = read(item);
1068
+ return result === undefined ? undefined : [...results, result];
366
1069
  }, []);
367
1070
  }
368
- function isSameUnit(left, right) {
1071
+ /**
1072
+ * Checks whether two units run under the same config, mode, root, and dir.
1073
+ *
1074
+ * @param left - The first unit.
1075
+ * @param right - The second unit.
1076
+ * @returns True if every field is equal.
1077
+ */
1078
+ export function isSameUnit(left, right) {
369
1079
  return (left.config === right.config &&
370
1080
  left.mode === right.mode &&
371
1081
  left.root === right.root &&
372
1082
  left.dir === right.dir);
373
1083
  }
374
- function readUnit(unit, named, gates, cache) {
1084
+ /**
1085
+ * Builds the arguments that select a unit's config, mode, root, and dir.
1086
+ *
1087
+ * @param unit - The unit.
1088
+ * @returns The arguments to pass to `vitest list`.
1089
+ */
1090
+ export function readUnitArguments(unit) {
1091
+ return [
1092
+ ...(unit.config === undefined ? [] : ['--config', unit.config]),
1093
+ ...(unit.mode === undefined ? [] : ['--mode', unit.mode]),
1094
+ ...(unit.root === undefined ? [] : ['--root', unit.root]),
1095
+ ...(unit.dir === undefined ? [] : ['--dir', unit.dir]),
1096
+ ];
1097
+ }
1098
+ /**
1099
+ * Reads one unit's universe, gate selections, and project readings.
1100
+ *
1101
+ * @param unit - The unit to read.
1102
+ * @param named - The `--projects` names; the universe and every listing carry them when present.
1103
+ * @param gates - The narrowed gates.
1104
+ * @param cache - The listings already read.
1105
+ * @returns The unit's reading, or undefined when a listing refuses.
1106
+ */
1107
+ export function readUnit(unit, named, gates, cache) {
375
1108
  const reached = gates.units.filter((gate) => isSameUnit(gate, unit));
376
1109
  const parameters = readUnitArguments(unit);
377
- const collected = listCollected(parameters, true, cache);
378
- if (collected === undefined)
1110
+ const universeArguments = [...parameters, ...named.flatMap((name) => ['--project', name])];
1111
+ const universe = listCollected(universeArguments, true, cache);
1112
+ if (universe === undefined)
379
1113
  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)
1114
+ const collected = universe.entries;
1115
+ const selected = collectResults(reached, (gate) => {
1116
+ const selection = JSON.stringify(gate.args) === JSON.stringify(universeArguments)
387
1117
  ? collected
388
- : listCollected(gate.args, gate.full, cache);
1118
+ : listCollected(gate.args, gate.full, cache)?.entries;
389
1119
  if (selection === undefined) {
390
1120
  console.error(`discovery: gate ${gate.chain}: vitest list refused ${gate.args.join(' ')}`);
391
1121
  return undefined;
@@ -401,7 +1131,8 @@ function readUnit(unit, named, gates, cache) {
401
1131
  });
402
1132
  if (selected === undefined)
403
1133
  return undefined;
404
- const readings = collectReadings([...new Set([...reached.flatMap((gate) => gate.projects), ...named])].filter((project) => !project.includes('*') && !project.startsWith('!')), (project) => {
1134
+ const candidates = named.length === 0 ? reached.flatMap((gate) => gate.projects) : named;
1135
+ const projects = collectResults([...new Set(candidates)].filter(isConcreteName), (project) => {
405
1136
  if (selected.some(({ gate, identities }) => gate.projects.length === 1 && gate.projects[0] === project && identities.size > 0)) {
406
1137
  return { project, populated: true, empty: false };
407
1138
  }
@@ -410,21 +1141,18 @@ function readUnit(unit, named, gates, cache) {
410
1141
  return undefined;
411
1142
  return {
412
1143
  project,
413
- populated: selection.length > 0,
414
- empty: selection.length === 0 &&
1144
+ populated: selection.entries.length > 0,
1145
+ empty: selection.entries.length === 0 &&
415
1146
  (reached.some((gate) => gate.projects.includes(project)) || named.includes(project)) &&
416
1147
  (named.length === 0 || named.includes(project)),
417
1148
  };
418
1149
  });
419
- if (readings === undefined)
1150
+ if (projects === undefined)
420
1151
  return undefined;
421
1152
  return {
422
1153
  listing: {
423
1154
  ...unit,
424
- collected: collected
425
- .filter((entry) => scope === undefined ||
426
- scope.some((chosen) => chosen.file === entry.file && chosen.projectName === entry.projectName))
427
- .map((entry) => ({
1155
+ collected: collected.map((entry) => ({
428
1156
  ...entry,
429
1157
  gate: selected.find(({ gate, identities }) => identities.has(JSON.stringify([
430
1158
  entry.file,
@@ -432,25 +1160,93 @@ function readUnit(unit, named, gates, cache) {
432
1160
  ...(gate.full ? [entry.name, entry.line, entry.column] : []),
433
1161
  ])))?.gate.chain,
434
1162
  })),
435
- empty: readings.filter(({ empty }) => empty).map(({ project }) => project),
1163
+ empty: projects.filter(({ empty }) => empty).map(({ project }) => project),
436
1164
  },
437
- populated: readings.filter(({ populated }) => populated).map(({ project }) => project),
1165
+ populated: projects.filter(({ populated }) => populated).map(({ project }) => project),
438
1166
  };
439
1167
  }
440
- function readUnitArguments(unit) {
441
- return [
442
- ...(unit.config === undefined ? [] : ['--config', unit.config]),
443
- ...(unit.mode === undefined ? [] : ['--mode', unit.mode]),
444
- ...(unit.root === undefined ? [] : ['--root', unit.root]),
445
- ...(unit.dir === undefined ? [] : ['--dir', unit.dir]),
1168
+ /**
1169
+ * Reads the listing of every unit the narrowed gates reach.
1170
+ *
1171
+ * @param config - The base config path.
1172
+ * @param named - The `--projects` names.
1173
+ * @param gates - The narrowed gates.
1174
+ * @returns One listing per unit, or undefined when a listing refuses.
1175
+ */
1176
+ export function collectListings(config, named, gates) {
1177
+ const base = { config, mode: undefined };
1178
+ // A gate joins the units unless the base unit or an earlier gate already names the same unit.
1179
+ const units = [
1180
+ base,
1181
+ ...gates.units
1182
+ .filter((gate, index) => ![base, ...gates.units.slice(0, index)].some((unit) => isSameUnit(unit, gate)))
1183
+ .map((gate) => ({
1184
+ config: gate.config,
1185
+ mode: gate.mode,
1186
+ ...(gate.root === undefined ? {} : { root: gate.root }),
1187
+ ...(gate.dir === undefined ? {} : { dir: gate.dir }),
1188
+ })),
446
1189
  ];
1190
+ // A listing is a memo keyed by its arguments: each key is written once, on its first miss.
1191
+ const cache = new Map();
1192
+ const results = collectResults(units, (unit) => readUnit(unit, named, gates, cache));
1193
+ if (results === undefined)
1194
+ return undefined;
1195
+ const listings = results.map(({ listing }) => listing);
1196
+ const populated = new Set(results.flatMap((result) => result.populated));
1197
+ // A project empty under one unit may still list tests under another, so each unit is asked for it.
1198
+ const rescans = collectResults([...new Set(listings.flatMap((listing) => listing.empty))]
1199
+ .filter((name) => !populated.has(name))
1200
+ .flatMap((name) => units.map((unit) => ({ name, unit }))), ({ name, unit }) => {
1201
+ const selection = listCollected([...readUnitArguments(unit), '--project', name], false, cache);
1202
+ return selection === undefined ? undefined : { name, listed: selection.entries.length > 0 };
1203
+ });
1204
+ if (rescans === undefined)
1205
+ return undefined;
1206
+ const resolved = new Set([
1207
+ ...populated,
1208
+ ...rescans.filter(({ listed }) => listed).map(({ name }) => name),
1209
+ ]);
1210
+ return listings.map((listing) => ({
1211
+ ...listing,
1212
+ empty: listing.empty.filter((name) => !resolved.has(name)),
1213
+ }));
1214
+ }
1215
+ /**
1216
+ * Names the `--projects` entries the base config's projects do not match.
1217
+ *
1218
+ * @param unit - The base unit.
1219
+ * @param named - The `--projects` names.
1220
+ * @param cache - The listings already read.
1221
+ * @returns The names Vitest matched to no project, or undefined when a listing refuses.
1222
+ */
1223
+ export function readUnmatched(unit, named, cache) {
1224
+ const matches = collectResults(named, (name) => {
1225
+ const listed = listCollected([...readUnitArguments(unit), '--project', name], false, cache);
1226
+ return listed === undefined ? undefined : { name, matched: listed.matched };
1227
+ });
1228
+ return matches?.filter(({ matched }) => !matched).map(({ name }) => name);
447
1229
  }
448
- function identifyCollected(entry) {
1230
+ /**
1231
+ * Identifies a collected test by its file, project, name, and location.
1232
+ *
1233
+ * @param entry - The collected test.
1234
+ * @returns A key two listings share for the same test.
1235
+ */
1236
+ export function identifyCollected(entry) {
449
1237
  return JSON.stringify([entry.file, entry.projectName, entry.name, entry.line, entry.column]);
450
1238
  }
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.
453
- function mergeCollected(listings) {
1239
+ /**
1240
+ * Merges the tests of several listings into one list.
1241
+ *
1242
+ * @remarks
1243
+ * The same test listed twice in one listing is two occurrences, and the same occurrence listed by
1244
+ * two listings is one test: the last listing's fields win, and the gate is the last one any named.
1245
+ *
1246
+ * @param listings - The listings to merge.
1247
+ * @returns The merged tests.
1248
+ */
1249
+ export function mergeCollected(listings) {
454
1250
  const keyed = listings.flatMap((listing) => {
455
1251
  const positions = new Map([...Map.groupBy(listing.collected, identifyCollected).values()].flatMap((group) => group.map((entry, position) => [entry, position + 1])));
456
1252
  return listing.collected.map((entry) => ({
@@ -470,7 +1266,13 @@ function mergeCollected(listings) {
470
1266
  ];
471
1267
  });
472
1268
  }
473
- function readSnapshot(gates) {
1269
+ /**
1270
+ * Reads the modification times of every test file the census can collect.
1271
+ *
1272
+ * @param gates - The narrowed gates, whose roots and dirs add watched directories.
1273
+ * @returns A JSON text two reads share when no test file changed.
1274
+ */
1275
+ export function readSnapshot(gates) {
474
1276
  const bases = [
475
1277
  process.cwd(),
476
1278
  ...gates.units.flatMap((gate) => [gate.root, gate.dir].filter((path) => path !== undefined).map((path) => resolve(path))),
@@ -479,12 +1281,22 @@ function readSnapshot(gates) {
479
1281
  // A later listing of the same path replaces the earlier modification time at the earlier position.
480
1282
  const files = new Map([...roots].flatMap((root) => existsSync(root)
481
1283
  ? listFiles(root)
482
- .filter((file) => /\.test\.[cm]?[jt]sx?$/u.test(file.path))
1284
+ .filter((file) => TEST_FILE_PATTERN.test(file.path))
483
1285
  .map((file) => [file.path, file.modified])
484
1286
  : []));
485
1287
  return JSON.stringify([...files].sort(([left], [right]) => left.localeCompare(right)));
486
1288
  }
487
- function collectStableListings(config, named, gates, attempt = 0) {
1289
+ /**
1290
+ * Reads the listings, repeating once when a test file changed during the first read.
1291
+ *
1292
+ * @param config - The base config path.
1293
+ * @param named - The `--projects` names.
1294
+ * @param gates - The narrowed gates.
1295
+ * @param attempt - The attempt number, zero for the first.
1296
+ * @returns One listing per unit, or undefined when a listing refuses.
1297
+ * @throws Thrown when test files changed during both attempts.
1298
+ */
1299
+ export function collectStableListings(config, named, gates, attempt = 0) {
488
1300
  const before = readSnapshot(gates);
489
1301
  const listings = collectListings(config, named, gates);
490
1302
  if (before === readSnapshot(gates))
@@ -493,7 +1305,18 @@ function collectStableListings(config, named, gates, attempt = 0) {
493
1305
  throw new Error('Test files changed during both census attempts');
494
1306
  return collectStableListings(config, named, gates, attempt + 1);
495
1307
  }
496
- function main(argv) {
1308
+ /**
1309
+ * Runs the census and prints its report.
1310
+ *
1311
+ * @param argv - The arguments after the script path.
1312
+ * @returns The exit code: 0 no flag, 3 a flag, 2 a tool failure, 64 usage.
1313
+ */
1314
+ export function main(argv) {
1315
+ const unknown = readUnknownFlags(argv, FLAGS);
1316
+ if (unknown.length > 0) {
1317
+ console.error(`discovery: ${unknown.map(describeName).join(', ')} is not a flag this script reads; give each value as its own argument. ${USAGE}`);
1318
+ return 64;
1319
+ }
497
1320
  const missing = readMissingFlags(argv, ['--config', '--projects']);
498
1321
  if (missing.length > 0) {
499
1322
  console.error(`discovery: ${missing.join(', ')} given with no value`);
@@ -501,16 +1324,38 @@ function main(argv) {
501
1324
  }
502
1325
  const config = readOption(argv, '--config') ?? 'vite.config.ts';
503
1326
  if (!existsSync(config) || !existsSync('package.json')) {
504
- console.error('usage: discovery.ts [--config vite.config.ts] [--projects a,b] [--json]');
1327
+ console.error(USAGE);
505
1328
  return 64;
506
1329
  }
507
1330
  if (!existsSync(VITEST)) {
508
1331
  console.error(`discovery: ${VITEST} is missing; run npm ci first`);
509
1332
  return 2;
510
1333
  }
511
- const canonical = relative(process.cwd(), resolve(config)).split('\\').join('/');
512
- const gates = readGates(readScripts(), canonical, readCLIOptions());
513
- const named = (readOption(argv, '--projects') ?? '').split(',').filter((name) => name !== '');
1334
+ const canonical = normalizeSlashes(relative(process.cwd(), resolve(config)));
1335
+ const options = readCLIOptions();
1336
+ const absent = readMissingOptions(options);
1337
+ if (absent.length > 0) {
1338
+ console.error(`discovery: Vitest ${readVitestVersion()} lists no ${absent.join(', ')} option; the help layout this script reads has changed`);
1339
+ return 2;
1340
+ }
1341
+ const named = [
1342
+ ...new Set((readOption(argv, '--projects') ?? '').split(',').filter((name) => name !== '')),
1343
+ ];
1344
+ const read = readGates(readScripts(), canonical, options);
1345
+ if (named.length > 0) {
1346
+ const base = { config: canonical, mode: undefined };
1347
+ const cache = new Map();
1348
+ const unmatched = readUnmatched(base, named, cache);
1349
+ if (unmatched === undefined) {
1350
+ console.error('discovery: vitest list failed');
1351
+ return 2;
1352
+ }
1353
+ if (unmatched.length > 0) {
1354
+ console.error(`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`);
1355
+ return 64;
1356
+ }
1357
+ }
1358
+ const gates = { ...read, units: narrowGates(read.units, named) };
514
1359
  const listings = collectStableListings(canonical, named, gates);
515
1360
  if (listings === undefined) {
516
1361
  console.error('discovery: vitest list failed');
@@ -594,10 +1439,12 @@ function main(argv) {
594
1439
  }
595
1440
  return flagged ? 3 : 0;
596
1441
  }
597
- try {
598
- process.exitCode = main(process.argv.slice(2));
599
- }
600
- catch (error) {
601
- console.error(`discovery: ${error instanceof Error ? error.message : String(error)}`);
602
- process.exitCode = 2;
1442
+ if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === import.meta.filename) {
1443
+ try {
1444
+ process.exitCode = main(process.argv.slice(2));
1445
+ }
1446
+ catch (error) {
1447
+ console.error(`discovery: ${error instanceof Error ? error.message : String(error)}`);
1448
+ process.exitCode = 2;
1449
+ }
603
1450
  }