@orkestrel/scaffold 0.0.87 → 0.0.89
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 +483 -77
- 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 +596 -76
- 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/agents/orkestrel.md +2 -2
- package/dist/host/claude/rules/architecture.md +4 -0
- package/dist/host/claude/rules/tests.md +2 -0
- package/dist/host/claude/rules/workspace.md +1 -1
- package/dist/host/configs/policy.ts +13 -3
- package/dist/host/guides/browser.md +2626 -1195
- package/dist/host/guides/contract.md +1 -1
- package/dist/host/guides/scaffold.md +13 -3
- package/dist/host/manifest.json +16 -16
- package/dist/host/tests/config.test.ts +51 -1
- package/dist/src/core/index.cjs +138 -105
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +14 -14
- package/dist/src/core/index.d.ts +14 -14
- package/dist/src/core/index.js +138 -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,65 +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
|
-
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
},
|
|
369
|
+
)
|
|
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 []
|
|
114
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 })
|
|
115
420
|
}
|
|
116
|
-
return collected
|
|
117
421
|
}
|
|
118
422
|
|
|
119
423
|
function listTestFiles(directory: string): readonly string[] {
|
|
@@ -133,6 +437,185 @@ function countMarkers(file: string): Readonly<Record<string, number>> {
|
|
|
133
437
|
return counts
|
|
134
438
|
}
|
|
135
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
|
+
|
|
136
619
|
function main(argv: readonly string[]): number {
|
|
137
620
|
const missing = readMissingFlags(argv, ['--config', '--projects'])
|
|
138
621
|
if (missing.length > 0) {
|
|
@@ -148,39 +631,71 @@ function main(argv: readonly string[]): number {
|
|
|
148
631
|
console.error(`discovery: ${VITEST} is missing; run npm ci first`)
|
|
149
632
|
return 2
|
|
150
633
|
}
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
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) {
|
|
646
|
+
console.error('discovery: vitest list failed')
|
|
155
647
|
return 2
|
|
156
648
|
}
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
...collected.map((entry) => entry.projectName),
|
|
161
|
-
...named,
|
|
162
|
-
])
|
|
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)])
|
|
163
652
|
const projects: Project[] = [...names].sort().map((name) => {
|
|
164
653
|
const mine = collected.filter((entry) => entry.projectName === name)
|
|
165
|
-
const
|
|
166
|
-
.
|
|
167
|
-
|
|
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
|
|
168
668
|
return {
|
|
169
669
|
name,
|
|
170
|
-
gate
|
|
670
|
+
gate,
|
|
171
671
|
files: new Set(mine.map((entry) => entry.file)).size,
|
|
172
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
|
+
: {}),
|
|
173
689
|
}
|
|
174
690
|
})
|
|
175
691
|
const collectedFiles = new Set(collected.map((entry) => entry.file))
|
|
176
|
-
const undiscovered = listTestFiles('tests')
|
|
692
|
+
const undiscovered = (named.length === 0 ? listTestFiles('tests') : [])
|
|
177
693
|
.filter((file) => !collectedFiles.has(file) && !gates.files.has(file))
|
|
178
694
|
.sort()
|
|
179
695
|
const ungated = projects
|
|
180
696
|
.filter((project) => project.gate === undefined && project.tests > 0)
|
|
181
697
|
.map((project) => project.name)
|
|
182
|
-
//
|
|
183
|
-
// empty one is reported, never flagged.
|
|
698
|
+
// An empty project no chain containing ` > ` names is a workbench, reported without a flag.
|
|
184
699
|
const empty = projects
|
|
185
700
|
.filter((project) => project.tests === 0 && project.gate?.includes(' > ') === true)
|
|
186
701
|
.map((project) => project.name)
|
|
@@ -211,7 +726,7 @@ function main(argv: readonly string[]): number {
|
|
|
211
726
|
console.log(`discovery: ${name} collects tests but no root script chain reaches it`)
|
|
212
727
|
for (const name of empty) console.log(`discovery: ${name} collects nothing`)
|
|
213
728
|
for (const name of workbenches)
|
|
214
|
-
console.log(`discovery: ${name} is
|
|
729
|
+
console.log(`discovery: ${name} is an empty workbench; no chain containing " > " names it`)
|
|
215
730
|
for (const file of undiscovered) console.log(`discovery: ${file} is collected by no project`)
|
|
216
731
|
for (const entry of census) {
|
|
217
732
|
const marks = Object.entries(entry.markers)
|
|
@@ -225,4 +740,9 @@ function main(argv: readonly string[]): number {
|
|
|
225
740
|
return flagged ? 3 : 0
|
|
226
741
|
}
|
|
227
742
|
|
|
228
|
-
|
|
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
|
+
}
|