@orkestrel/scaffold 0.0.88 → 0.0.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/skills/orkestrel-harden/scripts/discovery.js +481 -80
- package/dist/agents/skills/orkestrel-publish/scripts/window.js +32 -5
- package/dist/bin/main.js +4 -3
- package/dist/bin/main.js.map +1 -1
- package/dist/host/agents/skills/orkestrel-harden/SKILL.md +3 -3
- package/dist/host/agents/skills/orkestrel-harden/scripts/discovery.ts +594 -83
- package/dist/host/agents/skills/orkestrel-publish/SKILL.md +1 -1
- package/dist/host/agents/skills/orkestrel-publish/references/wave.md +2 -0
- package/dist/host/agents/skills/orkestrel-publish/references/window.md +2 -0
- package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +34 -5
- package/dist/host/claude/rules/tests.md +2 -0
- package/dist/host/claude/rules/workspace.md +1 -1
- package/dist/host/guides/scaffold.md +13 -3
- package/dist/host/manifest.json +11 -11
- package/dist/host/scripts/codex.sh +0 -0
- package/dist/host/scripts/cursor.sh +0 -0
- package/dist/host/scripts/deps.sh +0 -0
- package/dist/host/scripts/ollama.sh +0 -0
- package/dist/host/tests/config.test.ts +17 -1
- package/dist/src/core/index.cjs +131 -105
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +7 -7
- package/dist/src/core/index.d.ts +7 -7
- package/dist/src/core/index.js +131 -105
- package/dist/src/core/index.js.map +1 -1
- package/package.json +4 -3
|
@@ -1,17 +1,23 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Let Vitest select each gate's tests, then compare its identities with each config/mode universe.
|
|
2
|
+
// Run from the checkout root:
|
|
3
3
|
// node .agents/skills/orkestrel-harden/scripts/discovery.ts [--config vite.config.ts] [--projects a,b] [--json]
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
4
|
+
// Follow root npm script chains and forwarded arguments. Collect an unfiltered full listing for
|
|
5
|
+
// each config/mode/root/dir, including the base config. Pass gate arguments verbatim except the
|
|
6
|
+
// reporter, output-file, coverage, color, cache, silent, UI, watch, run-mode, inspector, and
|
|
7
|
+
// standalone options;
|
|
8
|
+
// use filesOnly for file selection and full collection for name, tag, line, and shard selection.
|
|
9
|
+
// Compare file/project/name/line/column identities without renaming projects. With --projects,
|
|
10
|
+
// ask Vitest for the reporting scope. Preserve duplicate names within a listing and merge across units.
|
|
11
|
+
// Ignore bench, list, mergeReports, listTags, and clearCache invocations as gates. Flag partly ungated rows, empty chained projects,
|
|
12
|
+
// and undiscovered files under tests/. Report marker counts and empty standalone workbenches.
|
|
13
|
+
// Retry when test files change during collection; refuse another change during that retry.
|
|
14
|
+
// Exit 0 with no flag, 3 with a flag, 2 for missing Vitest, unsupported related commands,
|
|
15
|
+
// CLI/help/argument/listing/output failures, repeated test-file changes, or filesystem/JSON errors;
|
|
16
|
+
// exit 64 on usage.
|
|
12
17
|
import { spawnSync } from 'node:child_process'
|
|
13
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
14
|
-
import {
|
|
18
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
19
|
+
import { tmpdir } from 'node:os'
|
|
20
|
+
import { basename, join, relative, resolve } from 'node:path'
|
|
15
21
|
import {
|
|
16
22
|
listFiles,
|
|
17
23
|
readMissingFlags,
|
|
@@ -20,22 +26,74 @@ import {
|
|
|
20
26
|
|
|
21
27
|
const VITEST = 'node_modules/vitest/vitest.mjs'
|
|
22
28
|
const MARKERS: readonly string[] = ['.skip(', '.todo(', '.skipIf(', '.runIf(', 'retry:', 'timeout:']
|
|
29
|
+
const OMITTED = new Set([
|
|
30
|
+
'reporter',
|
|
31
|
+
'outputFile',
|
|
32
|
+
'coverage',
|
|
33
|
+
'color',
|
|
34
|
+
'cache',
|
|
35
|
+
'silent',
|
|
36
|
+
'ui',
|
|
37
|
+
'watch',
|
|
38
|
+
'run',
|
|
39
|
+
'inspect',
|
|
40
|
+
'inspectBrk',
|
|
41
|
+
'standalone',
|
|
42
|
+
])
|
|
43
|
+
// Bounds one listing, so an option that waits for input exits 2 naming its gate instead of
|
|
44
|
+
// hanging the census.
|
|
45
|
+
const LISTING_TIMEOUT = 600_000
|
|
46
|
+
|
|
47
|
+
interface Option {
|
|
48
|
+
readonly name: string
|
|
49
|
+
readonly argument: string | undefined
|
|
50
|
+
}
|
|
23
51
|
|
|
24
52
|
interface Collected {
|
|
25
53
|
readonly file: string
|
|
26
54
|
readonly projectName: string
|
|
55
|
+
readonly name: string | undefined
|
|
56
|
+
readonly line: number | undefined
|
|
57
|
+
readonly column: number | undefined
|
|
58
|
+
readonly gate?: string | undefined
|
|
27
59
|
}
|
|
28
60
|
|
|
29
61
|
interface Gates {
|
|
30
|
-
readonly
|
|
62
|
+
readonly units: readonly Gate[]
|
|
31
63
|
readonly files: ReadonlyMap<string, string>
|
|
32
64
|
}
|
|
33
65
|
|
|
66
|
+
interface Unit {
|
|
67
|
+
readonly config: string | undefined
|
|
68
|
+
readonly mode: string | undefined
|
|
69
|
+
readonly root?: string | undefined
|
|
70
|
+
readonly dir?: string | undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface Gate extends Unit {
|
|
74
|
+
readonly projects: readonly string[]
|
|
75
|
+
readonly chain: string
|
|
76
|
+
readonly args: readonly string[]
|
|
77
|
+
readonly full: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface Listing extends Unit {
|
|
81
|
+
readonly collected: readonly Collected[]
|
|
82
|
+
readonly empty: readonly string[]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface Invocation {
|
|
86
|
+
readonly name: string
|
|
87
|
+
readonly args: readonly string[]
|
|
88
|
+
readonly ancestors: readonly string[]
|
|
89
|
+
}
|
|
90
|
+
|
|
34
91
|
interface Project {
|
|
35
92
|
readonly name: string
|
|
36
93
|
readonly gate: string | undefined
|
|
37
94
|
readonly files: number
|
|
38
95
|
readonly tests: number
|
|
96
|
+
readonly units?: readonly Unit[]
|
|
39
97
|
}
|
|
40
98
|
|
|
41
99
|
interface Census {
|
|
@@ -44,6 +102,110 @@ interface Census {
|
|
|
44
102
|
readonly markers: Readonly<Record<string, number>>
|
|
45
103
|
}
|
|
46
104
|
|
|
105
|
+
function readCLIOptions(): ReadonlyMap<string, Option> {
|
|
106
|
+
const help = spawnSync(process.execPath, [VITEST, 'list', '--help', '--expand-help'], {
|
|
107
|
+
encoding: 'utf8',
|
|
108
|
+
windowsHide: true,
|
|
109
|
+
})
|
|
110
|
+
if (help.status !== 0) throw new Error(help.stderr || 'Cannot read Vitest CLI options')
|
|
111
|
+
const options = new Map<string, Option>()
|
|
112
|
+
for (const line of help.stdout.split(/\r\n|\n/u)) {
|
|
113
|
+
const match = /^\s+(?:(-\w), )?--([\w.-]+)(?: ([<[][^\n]*?[>\]]))?\s{2}/u.exec(line)
|
|
114
|
+
const name = match?.[2]
|
|
115
|
+
if (name === undefined) continue
|
|
116
|
+
const option = { name: name.replace(/^no-/u, ''), argument: match?.[3] }
|
|
117
|
+
options.set(`--${name}`, option)
|
|
118
|
+
if (name.startsWith('no-')) options.set(`--${name.slice(3)}`, option)
|
|
119
|
+
options.set(`--${name.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)}`, option)
|
|
120
|
+
if (match?.[1] !== undefined) options.set(match[1], option)
|
|
121
|
+
}
|
|
122
|
+
return options
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function findRunner(command: readonly string[]): number {
|
|
126
|
+
let index = 0
|
|
127
|
+
const executable = basename(command[0] ?? '').replace(/\.(?:exe|cmd)$/u, '')
|
|
128
|
+
if (executable === 'npm') {
|
|
129
|
+
if (command[1] !== 'exec') return -1
|
|
130
|
+
index = 2
|
|
131
|
+
} else if (executable === 'node' || executable === 'npx') index = 1
|
|
132
|
+
if (index > 0) {
|
|
133
|
+
while (command[index]?.startsWith('-')) {
|
|
134
|
+
const option = command[index]
|
|
135
|
+
if (['-e', '--eval', '-p', '--print', '-c', '--check'].includes(option ?? '')) return -1
|
|
136
|
+
index += [
|
|
137
|
+
'-r',
|
|
138
|
+
'--require',
|
|
139
|
+
'--import',
|
|
140
|
+
'--loader',
|
|
141
|
+
'--experimental-loader',
|
|
142
|
+
'--max-old-space-size',
|
|
143
|
+
'--max_old_space_size',
|
|
144
|
+
'-p',
|
|
145
|
+
'--package',
|
|
146
|
+
].includes(option ?? '')
|
|
147
|
+
? 2
|
|
148
|
+
: 1
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return ['vitest', 'vitest.mjs', 'vitest.js'].includes(
|
|
152
|
+
basename(command[index] ?? '').replace(/\.cmd$/u, ''),
|
|
153
|
+
)
|
|
154
|
+
? index
|
|
155
|
+
: -1
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readSelection(
|
|
159
|
+
argv: readonly string[],
|
|
160
|
+
options: ReadonlyMap<string, Option>,
|
|
161
|
+
): readonly string[] {
|
|
162
|
+
const selected: string[] = []
|
|
163
|
+
for (let index = 0; index < argv.length; index++) {
|
|
164
|
+
const token = argv[index]
|
|
165
|
+
if (token === undefined) continue
|
|
166
|
+
if (token === '--') {
|
|
167
|
+
selected.push(...argv.slice(index))
|
|
168
|
+
break
|
|
169
|
+
}
|
|
170
|
+
if (!token.startsWith('-')) {
|
|
171
|
+
selected.push(token)
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
const equals = token.indexOf('=')
|
|
175
|
+
const flag = equals < 0 ? token : token.slice(0, equals)
|
|
176
|
+
const positive = flag.replace(/^--no-/u, '--')
|
|
177
|
+
const option = options.get(positive) ?? options.get(positive.split('.').slice(0, 1).join('.'))
|
|
178
|
+
if (option === undefined || !OMITTED.has(option.name.split('.')[0] ?? '')) {
|
|
179
|
+
selected.push(token)
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
const next = argv[index + 1]
|
|
183
|
+
if (
|
|
184
|
+
!flag.startsWith('--no-') &&
|
|
185
|
+
equals < 0 &&
|
|
186
|
+
next !== undefined &&
|
|
187
|
+
!next.startsWith('-') &&
|
|
188
|
+
(option.argument !== undefined || next === 'true' || next === 'false')
|
|
189
|
+
)
|
|
190
|
+
index++
|
|
191
|
+
}
|
|
192
|
+
return selected
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function readArguments(argv: readonly string[], names: readonly string[]): readonly string[] {
|
|
196
|
+
const values: string[] = []
|
|
197
|
+
for (let index = 0; index < argv.length; index++) {
|
|
198
|
+
const token = argv[index]
|
|
199
|
+
if (token === '--') break
|
|
200
|
+
if (token === undefined) continue
|
|
201
|
+
const equals = token.indexOf('=')
|
|
202
|
+
if (!names.includes(equals < 0 ? token : token.slice(0, equals))) continue
|
|
203
|
+
const value = equals < 0 ? argv[index + 1] : token.slice(equals + 1)
|
|
204
|
+
if (value !== undefined && !value.startsWith('-')) values.push(value)
|
|
205
|
+
}
|
|
206
|
+
return values
|
|
207
|
+
}
|
|
208
|
+
|
|
47
209
|
function readScripts(): Readonly<Record<string, string>> {
|
|
48
210
|
const parsed: unknown = JSON.parse(readFileSync('package.json', 'utf8'))
|
|
49
211
|
if (typeof parsed !== 'object' || parsed === null) return {}
|
|
@@ -55,74 +217,207 @@ function readScripts(): Readonly<Record<string, string>> {
|
|
|
55
217
|
return record
|
|
56
218
|
}
|
|
57
219
|
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
220
|
+
function readCommands(text: string): ReadonlyArray<readonly string[]> {
|
|
221
|
+
const commands: string[][] = []
|
|
222
|
+
let command: string[] = []
|
|
223
|
+
for (const match of text.matchAll(/(?:[^\s"';&|]+|"[^"]*"|'[^']*')+|[;&|]+/gu)) {
|
|
224
|
+
if (/^[;&|]+$/u.test(match[0])) {
|
|
225
|
+
commands.push(command)
|
|
226
|
+
command = []
|
|
227
|
+
} else {
|
|
228
|
+
command.push(match[0].replace(/["']/gu, ''))
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
commands.push(command)
|
|
232
|
+
return commands
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function listInvocations(commands: ReadonlyArray<readonly string[]>): readonly Invocation[] {
|
|
236
|
+
const invoked: Invocation[] = []
|
|
237
|
+
for (const command of commands) {
|
|
238
|
+
const index = command.indexOf('npm')
|
|
239
|
+
const name = command[index + 2]
|
|
240
|
+
if (index < 0 || command[index + 1] !== 'run' || name === undefined) continue
|
|
241
|
+
const separator = command.indexOf('--', index + 3)
|
|
242
|
+
invoked.push({ name, args: separator < 0 ? [] : command.slice(separator + 1), ancestors: [] })
|
|
243
|
+
}
|
|
244
|
+
return invoked
|
|
62
245
|
}
|
|
63
246
|
|
|
64
|
-
function readGates(
|
|
65
|
-
|
|
66
|
-
|
|
247
|
+
function readGates(
|
|
248
|
+
scripts: Readonly<Record<string, string>>,
|
|
249
|
+
config: string,
|
|
250
|
+
options: ReadonlyMap<string, Option>,
|
|
251
|
+
): Gates {
|
|
252
|
+
const commands = Object.fromEntries(
|
|
253
|
+
Object.entries(scripts).map(([name, text]) => [name, readCommands(text)]),
|
|
254
|
+
)
|
|
255
|
+
const invoked = new Set(
|
|
256
|
+
Object.values(commands)
|
|
257
|
+
.flatMap(listInvocations)
|
|
258
|
+
.map((entry) => entry.name),
|
|
259
|
+
)
|
|
260
|
+
const units: Gate[] = []
|
|
67
261
|
const files = new Map<string, string>()
|
|
68
262
|
for (const root of Object.keys(scripts).filter((name) => !invoked.has(name))) {
|
|
69
263
|
const visited = new Set<string>()
|
|
70
|
-
const pending = [root]
|
|
264
|
+
const pending: Invocation[] = [{ name: root, args: [], ancestors: [] }]
|
|
71
265
|
while (pending.length > 0) {
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
|
|
266
|
+
const invocation = pending.pop()
|
|
267
|
+
if (invocation === undefined) continue
|
|
268
|
+
const { name, args, ancestors } = invocation
|
|
269
|
+
const key = JSON.stringify([name, args])
|
|
270
|
+
if (visited.has(key) || ancestors.includes(name)) continue
|
|
271
|
+
visited.add(key)
|
|
272
|
+
const script = commands[name]
|
|
273
|
+
if (script === undefined) continue
|
|
274
|
+
const expanded = script.map((command, index) =>
|
|
275
|
+
index === script.length - 1 ? [...command, ...args] : command,
|
|
276
|
+
)
|
|
277
|
+
pending.push(
|
|
278
|
+
...listInvocations(expanded).map((entry) => ({
|
|
279
|
+
...entry,
|
|
280
|
+
ancestors: [...ancestors, name],
|
|
281
|
+
})),
|
|
282
|
+
)
|
|
78
283
|
const gate = root === name ? name : `${root} > ${name}`
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
284
|
+
for (const command of expanded) {
|
|
285
|
+
const index = findRunner(command)
|
|
286
|
+
if (index >= 0) {
|
|
287
|
+
const verb = command[index + 1]
|
|
288
|
+
if (verb === 'list' || verb === 'bench') continue
|
|
289
|
+
const tokens = command.slice(
|
|
290
|
+
index + (['run', 'watch', 'dev'].includes(verb ?? '') ? 2 : 1),
|
|
291
|
+
)
|
|
292
|
+
const separator = tokens.indexOf('--')
|
|
293
|
+
const before = separator < 0 ? tokens : tokens.slice(0, separator)
|
|
294
|
+
if (
|
|
295
|
+
before.some(
|
|
296
|
+
(token, position) =>
|
|
297
|
+
/^(?:--mergeReports|--merge-reports|--listTags|--list-tags|--clearCache|--clear-cache)(?:=|$)/u.test(
|
|
298
|
+
token,
|
|
299
|
+
) &&
|
|
300
|
+
!token.endsWith('=false') &&
|
|
301
|
+
before[position + 1] !== 'false',
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
continue
|
|
305
|
+
if (verb === 'related')
|
|
306
|
+
throw new Error(`${gate}: Vitest list exposes no related command or --related option`)
|
|
307
|
+
const flags = readSelection(tokens, options)
|
|
308
|
+
const mode = readArguments(flags, ['--mode'])[0]
|
|
309
|
+
const directory = readArguments(flags, ['--root', '-r'])[0]
|
|
310
|
+
const dir = readArguments(flags, ['--dir'])[0]
|
|
311
|
+
const configured = readArguments(flags, ['--config', '-c'])[0]
|
|
312
|
+
const path =
|
|
313
|
+
configured ?? (directory === undefined && dir === undefined ? config : undefined)
|
|
314
|
+
units.push({
|
|
315
|
+
config:
|
|
316
|
+
path === undefined
|
|
317
|
+
? undefined
|
|
318
|
+
: relative(process.cwd(), resolve(path)).split('\\').join('/'),
|
|
319
|
+
mode: mode === 'test' ? undefined : mode,
|
|
320
|
+
...(directory === undefined ? {} : { root: directory }),
|
|
321
|
+
...(dir === undefined ? {} : { dir }),
|
|
322
|
+
projects: readArguments(flags, ['--project']),
|
|
323
|
+
chain: gate,
|
|
324
|
+
args:
|
|
325
|
+
configured === undefined && path !== undefined ? ['--config', path, ...flags] : flags,
|
|
326
|
+
full: before.some(
|
|
327
|
+
(flag) =>
|
|
328
|
+
/^(?:-t|--testNamePattern|--test-name-pattern|--tagsFilter|--tags-filter|--shard|--(?:no-)?allowOnly|--(?:no-)?allow-only|--(?:no-)?strictTags|--(?:no-)?strict-tags)(?:=|$)/u.test(
|
|
329
|
+
flag,
|
|
330
|
+
) || /:\d+(?:-\d+)?$/u.test(flag),
|
|
331
|
+
),
|
|
332
|
+
})
|
|
333
|
+
continue
|
|
334
|
+
}
|
|
335
|
+
for (const argument of command) {
|
|
336
|
+
if (
|
|
337
|
+
/^(?:tests|src|app)\/[A-Za-z0-9_./-]+\.test\.[cm]?ts$/u.test(argument) &&
|
|
338
|
+
!files.has(argument)
|
|
339
|
+
)
|
|
340
|
+
files.set(argument, gate)
|
|
82
341
|
}
|
|
83
|
-
}
|
|
84
|
-
for (const match of text.matchAll(
|
|
85
|
-
/(?:^|\s)((?:tests|src|app)\/[A-Za-z0-9_./-]+\.test\.[cm]?ts)\b/gu,
|
|
86
|
-
)) {
|
|
87
|
-
if (match[1] !== undefined && !files.has(match[1])) files.set(match[1], gate)
|
|
88
342
|
}
|
|
89
343
|
}
|
|
90
344
|
}
|
|
91
|
-
return {
|
|
345
|
+
return { units, files }
|
|
92
346
|
}
|
|
93
347
|
|
|
94
|
-
function listCollected(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
348
|
+
function listCollected(
|
|
349
|
+
args: readonly string[],
|
|
350
|
+
full: boolean,
|
|
351
|
+
cache: Map<string, readonly Collected[]>,
|
|
352
|
+
): readonly Collected[] | undefined {
|
|
353
|
+
const parameters = [...(full ? ['--includeTaskLocation'] : ['--filesOnly']), ...args]
|
|
354
|
+
const key = JSON.stringify(parameters)
|
|
355
|
+
const cached = cache.get(key)
|
|
356
|
+
if (cached !== undefined) return cached
|
|
357
|
+
const directory = mkdtempSync(join(tmpdir(), 'discovery-'))
|
|
358
|
+
const listing = join(directory, 'listing.json')
|
|
359
|
+
try {
|
|
360
|
+
const result = spawnSync(
|
|
361
|
+
process.execPath,
|
|
362
|
+
[VITEST, 'list', `--json=${listing}`, ...parameters],
|
|
363
|
+
{
|
|
364
|
+
encoding: 'utf8',
|
|
365
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
366
|
+
timeout: LISTING_TIMEOUT,
|
|
367
|
+
windowsHide: true,
|
|
368
|
+
},
|
|
109
369
|
)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const record = Object.fromEntries(Object.entries(entry))
|
|
118
|
-
if (typeof record.file === 'string' && typeof record.projectName === 'string') {
|
|
119
|
-
collected.push({
|
|
120
|
-
file: relative(process.cwd(), record.file).split('\\').join('/'),
|
|
121
|
-
projectName: record.projectName,
|
|
122
|
-
})
|
|
370
|
+
if (
|
|
371
|
+
args.includes('--project') &&
|
|
372
|
+
result.status === 1 &&
|
|
373
|
+
result.stderr.includes('No projects matched the filter "')
|
|
374
|
+
) {
|
|
375
|
+
cache.set(key, [])
|
|
376
|
+
return []
|
|
123
377
|
}
|
|
378
|
+
if (result.status !== 0 || !existsSync(listing)) {
|
|
379
|
+
const diagnostic = result.stderr || result.error?.message || result.stdout
|
|
380
|
+
console.error(
|
|
381
|
+
diagnostic
|
|
382
|
+
.split(/\r\n|\n/u)
|
|
383
|
+
.filter((line) => line.trim() !== '')
|
|
384
|
+
.slice(0, 12)
|
|
385
|
+
.join('\n'),
|
|
386
|
+
)
|
|
387
|
+
return undefined
|
|
388
|
+
}
|
|
389
|
+
const parsed: unknown = JSON.parse(readFileSync(listing, 'utf8'))
|
|
390
|
+
if (!Array.isArray(parsed)) return undefined
|
|
391
|
+
const collected: Collected[] = []
|
|
392
|
+
for (const entry of parsed) {
|
|
393
|
+
if (typeof entry !== 'object' || entry === null) continue
|
|
394
|
+
const record = Object.fromEntries(Object.entries(entry))
|
|
395
|
+
const location =
|
|
396
|
+
typeof record.location === 'object' && record.location !== null
|
|
397
|
+
? Object.fromEntries(Object.entries(record.location))
|
|
398
|
+
: {}
|
|
399
|
+
if (
|
|
400
|
+
typeof record.file === 'string' &&
|
|
401
|
+
(record.projectName === undefined || typeof record.projectName === 'string') &&
|
|
402
|
+
(!full || typeof record.name === 'string')
|
|
403
|
+
) {
|
|
404
|
+
collected.push({
|
|
405
|
+
file: relative(process.cwd(), record.file).split('\\').join('/'),
|
|
406
|
+
projectName: record.projectName ?? '',
|
|
407
|
+
name: typeof record.name === 'string' ? record.name : undefined,
|
|
408
|
+
line: typeof location.line === 'number' ? location.line : undefined,
|
|
409
|
+
column: typeof location.column === 'number' ? location.column : undefined,
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
cache.set(key, collected)
|
|
414
|
+
return collected
|
|
415
|
+
} catch (error) {
|
|
416
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
417
|
+
return undefined
|
|
418
|
+
} finally {
|
|
419
|
+
rmSync(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })
|
|
124
420
|
}
|
|
125
|
-
return collected
|
|
126
421
|
}
|
|
127
422
|
|
|
128
423
|
function listTestFiles(directory: string): readonly string[] {
|
|
@@ -142,6 +437,185 @@ function countMarkers(file: string): Readonly<Record<string, number>> {
|
|
|
142
437
|
return counts
|
|
143
438
|
}
|
|
144
439
|
|
|
440
|
+
function collectListings(
|
|
441
|
+
config: string,
|
|
442
|
+
named: readonly string[],
|
|
443
|
+
gates: Gates,
|
|
444
|
+
): readonly Listing[] | undefined {
|
|
445
|
+
const units: Unit[] = [{ config, mode: undefined }]
|
|
446
|
+
for (const gate of gates.units) {
|
|
447
|
+
if (
|
|
448
|
+
!units.some(
|
|
449
|
+
(unit) =>
|
|
450
|
+
unit.config === gate.config &&
|
|
451
|
+
unit.mode === gate.mode &&
|
|
452
|
+
unit.root === gate.root &&
|
|
453
|
+
unit.dir === gate.dir,
|
|
454
|
+
)
|
|
455
|
+
) {
|
|
456
|
+
units.push({
|
|
457
|
+
config: gate.config,
|
|
458
|
+
mode: gate.mode,
|
|
459
|
+
...(gate.root === undefined ? {} : { root: gate.root }),
|
|
460
|
+
...(gate.dir === undefined ? {} : { dir: gate.dir }),
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const listings: Listing[] = []
|
|
465
|
+
const populated = new Set<string>()
|
|
466
|
+
const cache = new Map<string, readonly Collected[]>()
|
|
467
|
+
for (const unit of units) {
|
|
468
|
+
const reached = gates.units.filter(
|
|
469
|
+
(gate) =>
|
|
470
|
+
gate.config === unit.config &&
|
|
471
|
+
gate.mode === unit.mode &&
|
|
472
|
+
gate.root === unit.root &&
|
|
473
|
+
gate.dir === unit.dir,
|
|
474
|
+
)
|
|
475
|
+
const parameters = readUnitArguments(unit)
|
|
476
|
+
const collected = listCollected(parameters, true, cache)
|
|
477
|
+
if (collected === undefined) return undefined
|
|
478
|
+
const scope =
|
|
479
|
+
named.length === 0
|
|
480
|
+
? undefined
|
|
481
|
+
: listCollected(
|
|
482
|
+
[...parameters, ...named.flatMap((name) => ['--project', name])],
|
|
483
|
+
false,
|
|
484
|
+
cache,
|
|
485
|
+
)
|
|
486
|
+
if (named.length > 0 && scope === undefined) return undefined
|
|
487
|
+
const selected: Array<{ readonly gate: Gate; readonly identities: ReadonlySet<string> }> = []
|
|
488
|
+
for (const gate of reached) {
|
|
489
|
+
const selection =
|
|
490
|
+
JSON.stringify(gate.args) === JSON.stringify(parameters)
|
|
491
|
+
? collected
|
|
492
|
+
: listCollected(gate.args, gate.full, cache)
|
|
493
|
+
if (selection === undefined) {
|
|
494
|
+
console.error(`discovery: gate ${gate.chain}: vitest list refused ${gate.args.join(' ')}`)
|
|
495
|
+
return undefined
|
|
496
|
+
}
|
|
497
|
+
selected.push({
|
|
498
|
+
gate,
|
|
499
|
+
identities: new Set(
|
|
500
|
+
selection.map((entry) =>
|
|
501
|
+
JSON.stringify([
|
|
502
|
+
entry.file,
|
|
503
|
+
entry.projectName,
|
|
504
|
+
...(gate.full ? [entry.name, entry.line, entry.column] : []),
|
|
505
|
+
]),
|
|
506
|
+
),
|
|
507
|
+
),
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
const empty: string[] = []
|
|
511
|
+
for (const project of new Set([...reached.flatMap((gate) => gate.projects), ...named])) {
|
|
512
|
+
if (project.includes('*') || project.startsWith('!')) continue
|
|
513
|
+
if (
|
|
514
|
+
selected.some(
|
|
515
|
+
({ gate, identities }) =>
|
|
516
|
+
gate.projects.length === 1 && gate.projects[0] === project && identities.size > 0,
|
|
517
|
+
)
|
|
518
|
+
) {
|
|
519
|
+
populated.add(project)
|
|
520
|
+
continue
|
|
521
|
+
}
|
|
522
|
+
const selection = listCollected([...parameters, '--project', project], false, cache)
|
|
523
|
+
if (selection === undefined) return undefined
|
|
524
|
+
if (selection.length > 0) populated.add(project)
|
|
525
|
+
else if (
|
|
526
|
+
(reached.some((gate) => gate.projects.includes(project)) || named.includes(project)) &&
|
|
527
|
+
(named.length === 0 || named.includes(project))
|
|
528
|
+
)
|
|
529
|
+
empty.push(project)
|
|
530
|
+
}
|
|
531
|
+
listings.push({
|
|
532
|
+
...unit,
|
|
533
|
+
collected: collected
|
|
534
|
+
.filter(
|
|
535
|
+
(entry) =>
|
|
536
|
+
scope === undefined ||
|
|
537
|
+
scope.some(
|
|
538
|
+
(chosen) => chosen.file === entry.file && chosen.projectName === entry.projectName,
|
|
539
|
+
),
|
|
540
|
+
)
|
|
541
|
+
.map((entry) => ({
|
|
542
|
+
...entry,
|
|
543
|
+
gate: selected.find(({ gate, identities }) =>
|
|
544
|
+
identities.has(
|
|
545
|
+
JSON.stringify([
|
|
546
|
+
entry.file,
|
|
547
|
+
entry.projectName,
|
|
548
|
+
...(gate.full ? [entry.name, entry.line, entry.column] : []),
|
|
549
|
+
]),
|
|
550
|
+
),
|
|
551
|
+
)?.gate.chain,
|
|
552
|
+
})),
|
|
553
|
+
empty,
|
|
554
|
+
})
|
|
555
|
+
}
|
|
556
|
+
const candidates = new Set(listings.flatMap((listing) => listing.empty))
|
|
557
|
+
for (const name of candidates) {
|
|
558
|
+
if (populated.has(name)) continue
|
|
559
|
+
for (const unit of units) {
|
|
560
|
+
const selection = listCollected([...readUnitArguments(unit), '--project', name], false, cache)
|
|
561
|
+
if (selection === undefined) return undefined
|
|
562
|
+
if (selection.length > 0) populated.add(name)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return listings.map((listing) => ({
|
|
566
|
+
...listing,
|
|
567
|
+
empty: listing.empty.filter((name) => !populated.has(name)),
|
|
568
|
+
}))
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function readUnitArguments(unit: Unit): readonly string[] {
|
|
572
|
+
return [
|
|
573
|
+
...(unit.config === undefined ? [] : ['--config', unit.config]),
|
|
574
|
+
...(unit.mode === undefined ? [] : ['--mode', unit.mode]),
|
|
575
|
+
...(unit.root === undefined ? [] : ['--root', unit.root]),
|
|
576
|
+
...(unit.dir === undefined ? [] : ['--dir', unit.dir]),
|
|
577
|
+
]
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function mergeCollected(listings: readonly Listing[]): readonly Collected[] {
|
|
581
|
+
const collected = new Map<string, Collected>()
|
|
582
|
+
for (const listing of listings) {
|
|
583
|
+
const occurrences = new Map<string, number>()
|
|
584
|
+
for (const entry of listing.collected) {
|
|
585
|
+
const identity = JSON.stringify([
|
|
586
|
+
entry.file,
|
|
587
|
+
entry.projectName,
|
|
588
|
+
entry.name,
|
|
589
|
+
entry.line,
|
|
590
|
+
entry.column,
|
|
591
|
+
])
|
|
592
|
+
const occurrence = (occurrences.get(identity) ?? 0) + 1
|
|
593
|
+
occurrences.set(identity, occurrence)
|
|
594
|
+
const key = JSON.stringify([identity, occurrence])
|
|
595
|
+
collected.set(key, { ...entry, gate: entry.gate ?? collected.get(key)?.gate })
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return [...collected.values()]
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function readSnapshot(gates: Gates): string {
|
|
602
|
+
const bases = [
|
|
603
|
+
process.cwd(),
|
|
604
|
+
...gates.units.flatMap((gate) =>
|
|
605
|
+
[gate.root, gate.dir].filter((path) => path !== undefined).map((path) => resolve(path)),
|
|
606
|
+
),
|
|
607
|
+
]
|
|
608
|
+
const roots = new Set(bases.flatMap((base) => [base, join(base, 'tmp/probes')]))
|
|
609
|
+
const files = new Map<string, number>()
|
|
610
|
+
for (const root of roots) {
|
|
611
|
+
if (!existsSync(root)) continue
|
|
612
|
+
for (const file of listFiles(root)) {
|
|
613
|
+
if (/\.test\.[cm]?[jt]sx?$/u.test(file.path)) files.set(file.path, file.modified)
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return JSON.stringify([...files].sort(([left], [right]) => left.localeCompare(right)))
|
|
617
|
+
}
|
|
618
|
+
|
|
145
619
|
function main(argv: readonly string[]): number {
|
|
146
620
|
const missing = readMissingFlags(argv, ['--config', '--projects'])
|
|
147
621
|
if (missing.length > 0) {
|
|
@@ -157,39 +631,71 @@ function main(argv: readonly string[]): number {
|
|
|
157
631
|
console.error(`discovery: ${VITEST} is missing; run npm ci first`)
|
|
158
632
|
return 2
|
|
159
633
|
}
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
|
|
634
|
+
const canonical = relative(process.cwd(), resolve(config)).split('\\').join('/')
|
|
635
|
+
const gates = readGates(readScripts(), canonical, readCLIOptions())
|
|
636
|
+
const named = (readOption(argv, '--projects') ?? '').split(',').filter((name) => name !== '')
|
|
637
|
+
let listings: readonly Listing[] | undefined
|
|
638
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
639
|
+
const before = readSnapshot(gates)
|
|
640
|
+
listings = collectListings(canonical, named, gates)
|
|
641
|
+
if (before === readSnapshot(gates)) break
|
|
642
|
+
listings = undefined
|
|
643
|
+
if (attempt === 1) throw new Error('Test files changed during both census attempts')
|
|
644
|
+
}
|
|
645
|
+
if (listings === undefined) {
|
|
163
646
|
console.error('discovery: vitest list failed')
|
|
164
647
|
return 2
|
|
165
648
|
}
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
...collected.map((entry) => entry.projectName),
|
|
170
|
-
...named,
|
|
171
|
-
])
|
|
649
|
+
const collected = mergeCollected(listings)
|
|
650
|
+
const known = listings.flatMap((listing) => listing.empty)
|
|
651
|
+
const names = new Set<string>([...known, ...collected.map((entry) => entry.projectName)])
|
|
172
652
|
const projects: Project[] = [...names].sort().map((name) => {
|
|
173
653
|
const mine = collected.filter((entry) => entry.projectName === name)
|
|
174
|
-
const
|
|
175
|
-
.
|
|
176
|
-
|
|
654
|
+
const units = listings.filter((listing) =>
|
|
655
|
+
listing.collected.some((entry) => entry.projectName === name),
|
|
656
|
+
)
|
|
657
|
+
const chains = mine.map((entry) => entry.gate ?? gates.files.get(entry.file))
|
|
658
|
+
const gate =
|
|
659
|
+
mine.length === 0
|
|
660
|
+
? (
|
|
661
|
+
gates.units.find(
|
|
662
|
+
(unit) => unit.projects.includes(name) && unit.chain.includes(' > '),
|
|
663
|
+
) ?? gates.units.find((unit) => unit.projects.includes(name))
|
|
664
|
+
)?.chain
|
|
665
|
+
: chains.every((chain) => chain !== undefined)
|
|
666
|
+
? chains[0]
|
|
667
|
+
: undefined
|
|
177
668
|
return {
|
|
178
669
|
name,
|
|
179
|
-
gate
|
|
670
|
+
gate,
|
|
180
671
|
files: new Set(mine.map((entry) => entry.file)).size,
|
|
181
672
|
tests: mine.length,
|
|
673
|
+
...(units.some(
|
|
674
|
+
(unit) =>
|
|
675
|
+
unit.config !== canonical ||
|
|
676
|
+
unit.mode !== undefined ||
|
|
677
|
+
unit.root !== undefined ||
|
|
678
|
+
unit.dir !== undefined,
|
|
679
|
+
)
|
|
680
|
+
? {
|
|
681
|
+
units: units.map((unit) => ({
|
|
682
|
+
config: unit.config,
|
|
683
|
+
mode: unit.mode,
|
|
684
|
+
...(unit.root === undefined ? {} : { root: unit.root }),
|
|
685
|
+
...(unit.dir === undefined ? {} : { dir: unit.dir }),
|
|
686
|
+
})),
|
|
687
|
+
}
|
|
688
|
+
: {}),
|
|
182
689
|
}
|
|
183
690
|
})
|
|
184
691
|
const collectedFiles = new Set(collected.map((entry) => entry.file))
|
|
185
|
-
const undiscovered = listTestFiles('tests')
|
|
692
|
+
const undiscovered = (named.length === 0 ? listTestFiles('tests') : [])
|
|
186
693
|
.filter((file) => !collectedFiles.has(file) && !gates.files.has(file))
|
|
187
694
|
.sort()
|
|
188
695
|
const ungated = projects
|
|
189
696
|
.filter((project) => project.gate === undefined && project.tests > 0)
|
|
190
697
|
.map((project) => project.name)
|
|
191
|
-
//
|
|
192
|
-
// empty one is reported, never flagged.
|
|
698
|
+
// An empty project no chain containing ` > ` names is a workbench, reported without a flag.
|
|
193
699
|
const empty = projects
|
|
194
700
|
.filter((project) => project.tests === 0 && project.gate?.includes(' > ') === true)
|
|
195
701
|
.map((project) => project.name)
|
|
@@ -220,7 +726,7 @@ function main(argv: readonly string[]): number {
|
|
|
220
726
|
console.log(`discovery: ${name} collects tests but no root script chain reaches it`)
|
|
221
727
|
for (const name of empty) console.log(`discovery: ${name} collects nothing`)
|
|
222
728
|
for (const name of workbenches)
|
|
223
|
-
console.log(`discovery: ${name} is
|
|
729
|
+
console.log(`discovery: ${name} is an empty workbench; no chain containing " > " names it`)
|
|
224
730
|
for (const file of undiscovered) console.log(`discovery: ${file} is collected by no project`)
|
|
225
731
|
for (const entry of census) {
|
|
226
732
|
const marks = Object.entries(entry.markers)
|
|
@@ -234,4 +740,9 @@ function main(argv: readonly string[]): number {
|
|
|
234
740
|
return flagged ? 3 : 0
|
|
235
741
|
}
|
|
236
742
|
|
|
237
|
-
|
|
743
|
+
try {
|
|
744
|
+
process.exitCode = main(process.argv.slice(2))
|
|
745
|
+
} catch (error) {
|
|
746
|
+
console.error(`discovery: ${error instanceof Error ? error.message : String(error)}`)
|
|
747
|
+
process.exitCode = 2
|
|
748
|
+
}
|