testguard-cli 0.14.1 → 0.15.0

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.
@@ -0,0 +1,634 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, rmSync, statSync } from 'node:fs';
4
+ import { builtinModules, createRequire } from 'node:module';
5
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+ import { sha256 } from '../../util/hash.mjs';
8
+ import { globToRegExp, walk } from '../../util/glob.mjs';
9
+ import { parseModuleSource, tokenizeModule } from '../imports.mjs';
10
+ import { terminateProcessTree, TIMEOUT_CLEANUP_WARNING } from './shared.mjs';
11
+
12
+ export const DISCOVERY_TIMEOUT_MS = 60_000;
13
+ export const MAX_DISCOVERY_OUTPUT_BYTES = 16 * 1024 * 1024;
14
+ export const MAX_DISCOVERY_FILES = 100_000;
15
+ export const MAX_DISCOVERY_PATH_BYTES = 4096;
16
+ export const MAX_DISCOVERY_CONFIG_BYTES = 1024 * 1024;
17
+ export const MAX_DISCOVERY_CONFIG_TOTAL_BYTES = 8 * 1024 * 1024;
18
+ export const MAX_DISCOVERY_CONFIG_FILES = 4096;
19
+
20
+ const SCRIPT_CONFIG_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx'];
21
+ const RESOLVABLE_CONFIG_EXTENSIONS = [...SCRIPT_CONFIG_EXTENSIONS, '.json'];
22
+ const ABSENT_CONFIG_HASH = sha256('testguard:discovery-config:absent:v1');
23
+ const ALIAS_CONFIG_FILES = [
24
+ 'tsconfig.json',
25
+ 'jsconfig.json',
26
+ ...['js', 'mjs', 'cjs', 'ts', 'mts', 'cts'].flatMap((ext) => [`vite.config.${ext}`, `vitest.config.${ext}`]),
27
+ ];
28
+
29
+ const COMMON_CONFIG_FILES = [
30
+ 'package.json',
31
+ 'pnpm-workspace.yaml',
32
+ 'pnpm-workspace.yml',
33
+ 'lerna.json',
34
+ 'nx.json',
35
+ 'turbo.json',
36
+ 'rush.json',
37
+ 'workspace.json',
38
+ 'package-lock.json',
39
+ 'pnpm-lock.yaml',
40
+ 'yarn.lock',
41
+ 'bun.lock',
42
+ 'bun.lockb',
43
+ ...ALIAS_CONFIG_FILES,
44
+ ];
45
+
46
+ const CONVENTIONAL_CONFIG_BASENAMES = new Set([
47
+ 'package.json', 'pnpm-workspace.yaml', 'pnpm-workspace.yml', 'lerna.json', 'nx.json', 'turbo.json',
48
+ 'rush.json', 'workspace.json', 'pyproject.toml', 'pytest.ini', 'setup.cfg', 'tox.ini', 'conftest.py',
49
+ ]);
50
+
51
+ const isConventionalConfig = (path) => {
52
+ const name = basename(path);
53
+ return CONVENTIONAL_CONFIG_BASENAMES.has(name)
54
+ || /^(?:tsconfig|jsconfig)(?:\.[^/]+)?\.json$/.test(name)
55
+ || /^(?:vite|vitest|jest|playwright)\.config\.(?:[cm]?[jt]sx?|json)$/.test(name)
56
+ || /^vitest\.workspace\.(?:[cm]?[jt]s|json)$/.test(name);
57
+ };
58
+
59
+ const workspaceConfigFiles = (root, files, maxConfigBytes) => {
60
+ const declared = [];
61
+ const packageBytes = readBoundedFile(join(root, 'package.json'), maxConfigBytes, 'discovery config package.json', { optional: true });
62
+ if (packageBytes !== null) {
63
+ let pkg;
64
+ try {
65
+ pkg = JSON.parse(decodeUtf8(packageBytes, 'discovery config package.json'));
66
+ } catch (error) {
67
+ if (error instanceof DiscoveryError) throw error;
68
+ throw new DiscoveryError(`discovery config package.json is not valid JSON: ${error.message}`, { cause: error });
69
+ }
70
+ const workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces?.packages;
71
+ if (Array.isArray(workspaces)) declared.push(...workspaces);
72
+ }
73
+
74
+ for (const workspaceFile of files.filter((path) => !path.includes('/') && /^vitest\.workspace\./.test(path))) {
75
+ const bytes = readBoundedFile(join(root, workspaceFile), maxConfigBytes, `discovery config ${workspaceFile}`);
76
+ const references = staticConfigReferences(workspaceFile, bytes);
77
+ declared.push(...references.paths);
78
+ }
79
+ const patterns = declared.filter((pattern) => typeof pattern === 'string' && pattern.length > 0 && !pattern.startsWith('!'))
80
+ .map((pattern) => pattern.replace(/^\.\//, '').replace(/\/$/, ''))
81
+ .map((pattern) => globToRegExp(pattern));
82
+ return files.filter((path) => {
83
+ const directory = dirname(path).split(sep).join('/');
84
+ return patterns.some((pattern) => pattern.test(directory) || pattern.test(path));
85
+ });
86
+ };
87
+
88
+ export class DiscoveryError extends Error {
89
+ constructor(message, options) {
90
+ super(message, options);
91
+ this.name = 'DiscoveryError';
92
+ }
93
+ }
94
+
95
+ const positiveInteger = (name, value) => {
96
+ if (!Number.isSafeInteger(value) || value <= 0) throw new DiscoveryError(`${name} must be a positive integer`);
97
+ };
98
+
99
+ const validateArgv = (argv) => {
100
+ if (!Array.isArray(argv) || argv.length === 0 || argv.some((part) => typeof part !== 'string' || part.length === 0 || part.includes('\0'))) {
101
+ throw new DiscoveryError('native test discovery requires a non-empty argv of strings');
102
+ }
103
+ };
104
+
105
+ const diagnostic = (stderr, stdout) => {
106
+ const lines = `${stderr}\n${stdout}`.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
107
+ const informative = lines.find((line) => /^error\b|error:/i.test(line) && !/^at\s/.test(line))
108
+ ?? lines.find((line) => /failed|invalid|cannot|unexpected/i.test(line) && !/^at\s/.test(line));
109
+ return (informative ?? lines.at(-1))?.slice(0, 1000);
110
+ };
111
+
112
+ const decodeUtf8 = (bytes, label) => {
113
+ try {
114
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
115
+ } catch (error) {
116
+ throw new DiscoveryError(`${label} is not valid UTF-8`, { cause: error });
117
+ }
118
+ };
119
+
120
+ const readBoundedFile = (path, maxBytes, label, { optional = false } = {}) => {
121
+ let descriptor;
122
+ try {
123
+ descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
124
+ } catch (error) {
125
+ if (optional && error?.code === 'ENOENT') return null;
126
+ throw new DiscoveryError(`${label} is not readable: ${error.message}`, { cause: error });
127
+ }
128
+ try {
129
+ const stat = fstatSync(descriptor);
130
+ if (!stat.isFile()) throw new DiscoveryError(`${label} is not a regular file`);
131
+ if (stat.size > maxBytes) throw new DiscoveryError(`${label} exceeded ${maxBytes} bytes`);
132
+ const bytes = Buffer.alloc(maxBytes + 1);
133
+ let length = 0;
134
+ while (length < bytes.length) {
135
+ const read = readSync(descriptor, bytes, length, bytes.length - length, null);
136
+ if (read === 0) break;
137
+ length += read;
138
+ }
139
+ if (length > maxBytes) throw new DiscoveryError(`${label} exceeded ${maxBytes} bytes`);
140
+ return bytes.subarray(0, length);
141
+ } finally {
142
+ closeSync(descriptor);
143
+ }
144
+ };
145
+
146
+ /**
147
+ * Run a runner's native listing command behind a hard time and output bound.
148
+ * Any exceptional condition rejects discovery; callers must never substitute
149
+ * a hand-written glob and accidentally turn an unknown test universe into a
150
+ * successful one.
151
+ */
152
+ export function runDiscoveryProcess({
153
+ projectDir,
154
+ argv,
155
+ timeoutMs = DISCOVERY_TIMEOUT_MS,
156
+ maxOutputBytes = MAX_DISCOVERY_OUTPUT_BYTES,
157
+ env = {},
158
+ }) {
159
+ validateArgv(argv);
160
+ if (typeof projectDir !== 'string' || projectDir.length === 0 || projectDir.includes('\0')) throw new DiscoveryError('projectDir must be a non-empty path');
161
+ positiveInteger('timeoutMs', timeoutMs);
162
+ positiveInteger('maxOutputBytes', maxOutputBytes);
163
+ if (env == null || typeof env !== 'object' || Array.isArray(env)) throw new DiscoveryError('discovery env must be an object');
164
+
165
+ return new Promise((resolvePromise, rejectPromise) => {
166
+ const [command, ...args] = argv;
167
+ let child;
168
+ try {
169
+ child = spawn(command, args, {
170
+ cwd: projectDir,
171
+ stdio: ['ignore', 'pipe', 'pipe'],
172
+ detached: process.platform !== 'win32',
173
+ env: { ...process.env, CI: '1', FORCE_COLOR: '0', ...env },
174
+ });
175
+ } catch (error) {
176
+ rejectPromise(new DiscoveryError(`native test discovery could not start: ${error.message}`, { cause: error }));
177
+ return;
178
+ }
179
+
180
+ const started = Date.now();
181
+ const stdout = [];
182
+ const stderr = [];
183
+ let outputBytes = 0;
184
+ let terminalError;
185
+ let settled = false;
186
+ let timer;
187
+
188
+ const reject = (error) => {
189
+ if (settled) return;
190
+ settled = true;
191
+ clearTimeout(timer);
192
+ rejectPromise(error);
193
+ };
194
+ const terminate = () => {
195
+ terminateProcessTree(child);
196
+ };
197
+ const capture = (target) => (chunk) => {
198
+ if (terminalError) return;
199
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
200
+ outputBytes += bytes.length;
201
+ if (outputBytes > maxOutputBytes) {
202
+ terminalError = new DiscoveryError(`native test discovery output exceeded ${maxOutputBytes} bytes`);
203
+ reject(terminalError);
204
+ terminate();
205
+ return;
206
+ }
207
+ target.push(bytes);
208
+ };
209
+
210
+ child.stdout.on('data', capture(stdout));
211
+ child.stderr.on('data', capture(stderr));
212
+ child.on('error', (error) => {
213
+ terminalError ??= new DiscoveryError(`native test discovery could not start: ${error.message}`, { cause: error });
214
+ reject(terminalError);
215
+ });
216
+ timer = setTimeout(() => {
217
+ terminalError ??= new DiscoveryError(`native test discovery timeout of ${timeoutMs}ms exceeded; ${TIMEOUT_CLEANUP_WARNING}`);
218
+ reject(terminalError);
219
+ terminate();
220
+ }, timeoutMs);
221
+
222
+ child.on('close', (code, signal) => {
223
+ clearTimeout(timer);
224
+ if (settled) return;
225
+ if (terminalError) {
226
+ reject(terminalError);
227
+ return;
228
+ }
229
+ let stdoutText;
230
+ let stderrText;
231
+ try {
232
+ stdoutText = decodeUtf8(Buffer.concat(stdout), 'native test discovery stdout');
233
+ stderrText = decodeUtf8(Buffer.concat(stderr), 'native test discovery stderr');
234
+ } catch (error) {
235
+ reject(error);
236
+ return;
237
+ }
238
+ if (code !== 0) {
239
+ const detail = diagnostic(stderrText, stdoutText);
240
+ reject(new DiscoveryError(`native test discovery exited ${code ?? `on signal ${signal ?? 'unknown'}`}${detail ? `: ${detail}` : ''}`));
241
+ return;
242
+ }
243
+ settled = true;
244
+ resolvePromise({ stdout: stdoutText, stderr: stderrText, durationMs: Date.now() - started });
245
+ });
246
+ });
247
+ }
248
+
249
+ /** Read a runner-created JSON report only after enforcing a byte bound. */
250
+ export function readBoundedJsonFile(path, maxBytes = MAX_DISCOVERY_OUTPUT_BYTES) {
251
+ positiveInteger('maxBytes', maxBytes);
252
+ const bytes = readBoundedFile(path, maxBytes, 'native test discovery report');
253
+ let text;
254
+ try {
255
+ text = decodeUtf8(bytes, 'native test discovery report');
256
+ return JSON.parse(text);
257
+ } catch (error) {
258
+ if (error instanceof DiscoveryError) throw error;
259
+ throw new DiscoveryError(`native test discovery report is not valid JSON: ${error.message}`, { cause: error });
260
+ }
261
+ }
262
+
263
+ const configPath = (path) => typeof path === 'string'
264
+ && path.length > 0
265
+ && !path.includes('\0')
266
+ && !path.includes('\\')
267
+ && !isAbsolute(path)
268
+ && !path.split('/').some((part) => part === '' || part === '.' || part === '..');
269
+
270
+ const inside = (root, path) => {
271
+ const rel = relative(root, path);
272
+ return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
273
+ };
274
+
275
+ const relativeConfigPath = (root, path) => relative(root, path).split(sep).join('/');
276
+
277
+ const existingConfigCandidate = (root, path) => {
278
+ if (!inside(root, path)) throw new DiscoveryError(`discovery config dependency resolves outside the project: ${path}`);
279
+ let stat;
280
+ try {
281
+ stat = lstatSync(path);
282
+ } catch (error) {
283
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null;
284
+ throw new DiscoveryError(`discovery config dependency is not readable: ${error.message}`, { cause: error });
285
+ }
286
+ if (stat.isSymbolicLink()) throw new DiscoveryError(`discovery config dependency is not readable: symbolic links are not allowed`);
287
+ if (!stat.isFile()) return null;
288
+ return path;
289
+ };
290
+
291
+ const resolveConfigReference = (root, fromPath, rawReference) => {
292
+ if (typeof rawReference !== 'string' || rawReference.length === 0 || rawReference.includes('\0')) return [];
293
+ const reference = rawReference.replace(/[?#].*$/, '');
294
+ if (!reference || /[*{}[\]]/.test(reference)) return [];
295
+
296
+ let bases = [];
297
+ if (reference.startsWith('<rootDir>/')) bases = [resolve(root, reference.slice('<rootDir>/'.length))];
298
+ // Module specifiers are relative to their file, while runner options such
299
+ // as Vitest `setupFiles: ['./test/setup.mjs']` are relative to the project.
300
+ // Hash both existing interpretations; an extra dependency is conservative.
301
+ else if (reference.startsWith('.')) bases = [resolve(dirname(fromPath), reference), resolve(root, reference)];
302
+ else if (isAbsolute(reference)) bases = [resolve(reference)];
303
+ else if (reference.includes('/') || extname(reference)) bases = [resolve(root, reference), resolve(dirname(fromPath), reference)];
304
+ else return [];
305
+
306
+ const resolved = new Set();
307
+ for (const base of bases) {
308
+ if (!inside(root, base)) throw new DiscoveryError(`discovery config dependency resolves outside the project: ${rawReference}`);
309
+ const candidates = extname(base)
310
+ ? [base]
311
+ : [base, ...RESOLVABLE_CONFIG_EXTENSIONS.map((extension) => `${base}${extension}`),
312
+ ...RESOLVABLE_CONFIG_EXTENSIONS.map((extension) => join(base, `index${extension}`)),
313
+ join(base, 'tsconfig.json'), join(base, 'jsconfig.json')];
314
+ for (const candidate of candidates) {
315
+ const existing = existingConfigCandidate(root, candidate);
316
+ if (existing) resolved.add(existing);
317
+ }
318
+ }
319
+ return [...resolved];
320
+ };
321
+
322
+ const JSON_REFERENCE_KEYS = new Set([
323
+ 'extends', 'setupFiles', 'setupFilesAfterEnv', 'globalSetup', 'globalTeardown', 'testEnvironment',
324
+ 'testRunner', 'resolver', 'preset', 'projects', 'reporters', 'moduleNameMapper', 'transform',
325
+ 'snapshotResolver', 'watchPlugins',
326
+ ]);
327
+
328
+ const jsonConfigReferences = (value) => {
329
+ const references = new Set();
330
+ const collectStrings = (item) => {
331
+ if (typeof item === 'string') references.add(item);
332
+ else if (Array.isArray(item)) for (const child of item) collectStrings(child);
333
+ else if (item && typeof item === 'object') for (const child of Object.values(item)) collectStrings(child);
334
+ };
335
+ const visit = (item, parentKey) => {
336
+ if (Array.isArray(item)) {
337
+ for (const child of item) visit(child, parentKey);
338
+ return;
339
+ }
340
+ if (!item || typeof item !== 'object') return;
341
+ for (const [key, child] of Object.entries(item)) {
342
+ if (JSON_REFERENCE_KEYS.has(key) || (key === 'path' && parentKey === 'references')) collectStrings(child);
343
+ else visit(child, key);
344
+ }
345
+ };
346
+ visit(value);
347
+ return references;
348
+ };
349
+
350
+ const staticConfigReferences = (path, bytes) => {
351
+ const text = decodeUtf8(bytes, `discovery config ${path}`);
352
+ if (extname(path) === '.json') {
353
+ try {
354
+ return { paths: jsonConfigReferences(JSON.parse(text)), imports: new Set() };
355
+ } catch (error) {
356
+ throw new DiscoveryError(`discovery config ${path} is not valid JSON: ${error.message}`, { cause: error });
357
+ }
358
+ }
359
+ if (!SCRIPT_CONFIG_EXTENSIONS.includes(extname(path))) return { paths: new Set(), imports: new Set() };
360
+ let tokens;
361
+ try {
362
+ tokens = tokenizeModule(text);
363
+ } catch (error) {
364
+ throw new DiscoveryError(`discovery config ${path} cannot be scanned for static dependencies: ${error.message}`, { cause: error });
365
+ }
366
+ const paths = new Set(tokens.filter((token) => token.type === 'string').map((token) => token.value));
367
+ let parsed;
368
+ try {
369
+ parsed = parseModuleSource(text);
370
+ } catch (error) {
371
+ throw new DiscoveryError(`discovery config ${path} cannot be scanned for static dependencies: ${error.message}`, { cause: error });
372
+ }
373
+ const imports = new Set(parsed.runtime);
374
+ for (const specifier of imports) paths.delete(specifier);
375
+ return { paths, imports };
376
+ };
377
+
378
+ const BUILTIN_MODULES = new Set([...builtinModules, ...builtinModules.map((name) => `node:${name}`)]);
379
+
380
+ const modulePackageRoot = (resolved) => {
381
+ let current = dirname(resolved);
382
+ while (dirname(current) !== current) {
383
+ try {
384
+ if (statSync(join(current, 'package.json')).isFile()) return current;
385
+ } catch {}
386
+ current = dirname(current);
387
+ }
388
+ return dirname(resolved);
389
+ };
390
+
391
+ const resolveRelativeModuleFile = (fromPath, specifier) => {
392
+ const base = resolve(dirname(fromPath), specifier.replace(/[?#].*$/, ''));
393
+ const candidates = extname(base)
394
+ ? [base]
395
+ : [base, ...RESOLVABLE_CONFIG_EXTENSIONS.map((extension) => `${base}${extension}`),
396
+ ...RESOLVABLE_CONFIG_EXTENSIONS.map((extension) => join(base, `index${extension}`))];
397
+ return candidates.find((candidate) => {
398
+ try { return lstatSync(candidate).isFile(); } catch { return false; }
399
+ });
400
+ };
401
+
402
+ /** Hash a package config helper and the static relative module closure of its entry. */
403
+ const hashModuleConfigDependency = (fromPath, fromLabel, specifier, { maxConfigBytes, maxTotalBytes, maxConfigFiles }) => {
404
+ if (BUILTIN_MODULES.has(specifier)) return null;
405
+ let entry;
406
+ try {
407
+ entry = createRequire(fromPath).resolve(specifier);
408
+ } catch (error) {
409
+ throw new DiscoveryError(`discovery config ${fromLabel} imports unresolved module ${specifier}: ${error.message}`, { cause: error });
410
+ }
411
+ if (!isAbsolute(entry)) return null;
412
+ const packageRoot = modulePackageRoot(entry);
413
+ const queued = [entry];
414
+ const packageJson = join(packageRoot, 'package.json');
415
+ try { if (statSync(packageJson).isFile()) queued.push(packageJson); } catch {}
416
+ const seen = new Set();
417
+ const components = [];
418
+ let bytesRead = 0;
419
+ while (queued.length > 0) {
420
+ const path = queued.shift();
421
+ if (seen.has(path)) continue;
422
+ seen.add(path);
423
+ if (seen.size > maxConfigFiles) throw new DiscoveryError(`module config dependency ${specifier} exceeded ${maxConfigFiles} files`);
424
+ const bytes = readBoundedFile(path, maxConfigBytes, `module config dependency ${specifier}`);
425
+ bytesRead += bytes.length;
426
+ if (bytesRead > maxTotalBytes) throw new DiscoveryError(`module config dependency ${specifier} exceeded ${maxTotalBytes} bytes in total`);
427
+ components.push({ path: relative(packageRoot, path).split(sep).join('/'), sha256: sha256(bytes) });
428
+ if (!SCRIPT_CONFIG_EXTENSIONS.includes(extname(path))) continue;
429
+ const { imports } = staticConfigReferences(path, bytes);
430
+ for (const dependency of imports) {
431
+ if (!dependency.startsWith('.')) continue; // Root lockfiles bind transitive package versions.
432
+ const resolved = resolveRelativeModuleFile(path, dependency);
433
+ if (!resolved) throw new DiscoveryError(`module config dependency ${specifier} imports missing file ${dependency}`);
434
+ if (!inside(packageRoot, resolved)) throw new DiscoveryError(`module config dependency ${specifier} escapes its package: ${dependency}`);
435
+ queued.push(resolved);
436
+ }
437
+ }
438
+ components.sort((a, b) => a.path.localeCompare(b.path));
439
+ return {
440
+ entry: { path: `@module/${sha256(`${fromLabel}\0${specifier}`).slice(0, 20)}`, sha256: sha256(JSON.stringify(components)) },
441
+ bytesRead,
442
+ };
443
+ };
444
+
445
+ /**
446
+ * Hash the bounded conventional inputs that can change native discovery.
447
+ *
448
+ * Root candidates carry an explicit absent sentinel, so creating a config
449
+ * invalidates a prior universe even when the listed files happen to stay the
450
+ * same. Existing nested workspace configs and the static local dependency
451
+ * closure of every config are included as well. Dynamic dependencies are
452
+ * still evaluated by the runner, but they can never disappear from this hash
453
+ * merely because they live in an imported helper or setup file.
454
+ */
455
+ export function hashDiscoveryConfigs(projectDir, runnerConfigFiles = [], {
456
+ maxConfigBytes = MAX_DISCOVERY_CONFIG_BYTES,
457
+ maxTotalBytes = MAX_DISCOVERY_CONFIG_TOTAL_BYTES,
458
+ maxConfigFiles = MAX_DISCOVERY_CONFIG_FILES,
459
+ } = {}) {
460
+ positiveInteger('maxConfigBytes', maxConfigBytes);
461
+ positiveInteger('maxTotalBytes', maxTotalBytes);
462
+ positiveInteger('maxConfigFiles', maxConfigFiles);
463
+ if (typeof projectDir !== 'string' || projectDir.length === 0 || projectDir.includes('\0')) throw new DiscoveryError('projectDir must be a non-empty path');
464
+ if (!Array.isArray(runnerConfigFiles) || runnerConfigFiles.some((path) => !configPath(path))) {
465
+ throw new DiscoveryError('discovery config candidates must be normalized project-relative paths');
466
+ }
467
+ let root;
468
+ try {
469
+ root = realpathSync(resolve(projectDir));
470
+ } catch (error) {
471
+ throw new DiscoveryError(`cannot validate discovery project root: ${error.message}`, { cause: error });
472
+ }
473
+ const rootCandidates = [...new Set([...COMMON_CONFIG_FILES, ...runnerConfigFiles])].sort();
474
+ let discovered;
475
+ try {
476
+ const conventional = walk(root).filter(isConventionalConfig);
477
+ discovered = workspaceConfigFiles(root, conventional, maxConfigBytes);
478
+ if (runnerConfigFiles.includes('conftest.py')) {
479
+ discovered.push(...conventional.filter((path) => basename(path) === 'conftest.py'));
480
+ }
481
+ discovered = [...new Set(discovered)].sort();
482
+ } catch (error) {
483
+ throw new DiscoveryError(`cannot enumerate discovery configs: ${error.message}`, { cause: error });
484
+ }
485
+ const queued = [...new Set([...rootCandidates, ...discovered])].sort();
486
+ if (queued.length > maxConfigFiles) throw new DiscoveryError(`discovery config closure exceeded ${maxConfigFiles} files`);
487
+
488
+ const entries = new Map(rootCandidates.map((path) => [path, { path, sha256: ABSENT_CONFIG_HASH }]));
489
+ const seen = new Set();
490
+ const enqueue = (dependencyName) => {
491
+ if (!seen.has(dependencyName) && !queued.includes(dependencyName)) queued.push(dependencyName);
492
+ };
493
+ let totalBytes = 0;
494
+ while (queued.length > 0) {
495
+ const name = queued.shift();
496
+ if (seen.has(name)) continue;
497
+ seen.add(name);
498
+ const absolute = join(root, name);
499
+ const bytes = readBoundedFile(absolute, maxConfigBytes, `discovery config ${name}`, { optional: true });
500
+ if (bytes === null) continue; // Root candidates retain their absent sentinel.
501
+ totalBytes += bytes.length;
502
+ if (totalBytes > maxTotalBytes) throw new DiscoveryError(`discovery configs exceeded ${maxTotalBytes} bytes in total`);
503
+ entries.set(name, { path: name, sha256: sha256(bytes) });
504
+ const references = staticConfigReferences(name, bytes);
505
+ for (const reference of references.paths) {
506
+ for (const dependency of resolveConfigReference(root, absolute, reference)) {
507
+ const dependencyName = relativeConfigPath(root, dependency);
508
+ if (!configPath(dependencyName)) throw new DiscoveryError(`invalid discovery config dependency path: ${dependencyName}`);
509
+ enqueue(dependencyName);
510
+ }
511
+ }
512
+ for (const specifier of references.imports) {
513
+ if (specifier.startsWith('.')) {
514
+ for (const dependency of resolveConfigReference(root, absolute, specifier)) {
515
+ const dependencyName = relativeConfigPath(root, dependency);
516
+ if (!configPath(dependencyName)) throw new DiscoveryError(`invalid discovery config dependency path: ${dependencyName}`);
517
+ enqueue(dependencyName);
518
+ }
519
+ continue;
520
+ }
521
+ const moduleDependency = hashModuleConfigDependency(absolute, name, specifier, { maxConfigBytes, maxTotalBytes, maxConfigFiles });
522
+ if (!moduleDependency) continue;
523
+ totalBytes += moduleDependency.bytesRead;
524
+ if (totalBytes > maxTotalBytes) throw new DiscoveryError(`discovery configs exceeded ${maxTotalBytes} bytes in total`);
525
+ entries.set(moduleDependency.entry.path, moduleDependency.entry);
526
+ }
527
+ queued.sort();
528
+ if (seen.size + queued.length > maxConfigFiles) throw new DiscoveryError(`discovery config closure exceeded ${maxConfigFiles} files`);
529
+ }
530
+ return [...entries.values()].sort((a, b) => a.path.localeCompare(b.path));
531
+ }
532
+
533
+ /** Run a discovery command whose JSON reporter is directed to `{out}` in env. */
534
+ export async function runDiscoveryReportProcess(options) {
535
+ const outFile = join(tmpdir(), `testguard-discovery-${randomBytes(8).toString('hex')}.json`);
536
+ const env = Object.fromEntries(Object.entries(options.env ?? {}).map(([key, value]) => [key, String(value).replaceAll('{out}', outFile)]));
537
+ try {
538
+ const processResult = await runDiscoveryProcess({ ...options, env });
539
+ return { ...processResult, report: readBoundedJsonFile(outFile, options.maxOutputBytes) };
540
+ } finally {
541
+ rmSync(outFile, { force: true });
542
+ }
543
+ }
544
+
545
+ const outside = (root, path) => {
546
+ const rel = relative(root, path);
547
+ return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);
548
+ };
549
+
550
+ /**
551
+ * Validate runner-reported paths against both lexical and real project roots.
552
+ * Output is relative to the canonical project root. That both rejects symlink
553
+ * escapes and removes platform aliases such as macOS `/var` → `/private/var`
554
+ * from the identity being hashed.
555
+ */
556
+ export function normalizeDiscoveredFiles(projectDir, reportedFiles, {
557
+ maxFiles = MAX_DISCOVERY_FILES,
558
+ maxPathBytes = MAX_DISCOVERY_PATH_BYTES,
559
+ } = {}) {
560
+ positiveInteger('maxFiles', maxFiles);
561
+ positiveInteger('maxPathBytes', maxPathBytes);
562
+ if (typeof projectDir !== 'string' || projectDir.length === 0 || projectDir.includes('\0')) throw new DiscoveryError('projectDir must be a non-empty path');
563
+ if (!Array.isArray(reportedFiles)) throw new DiscoveryError('native test discovery must return an array of file paths');
564
+ if (reportedFiles.length > maxFiles) throw new DiscoveryError(`native test discovery returned more than ${maxFiles} files`);
565
+
566
+ const root = resolve(projectDir);
567
+ let realRoot;
568
+ try {
569
+ realRoot = realpathSync(root);
570
+ if (!statSync(realRoot).isDirectory()) throw new Error('project path is not a directory');
571
+ } catch (error) {
572
+ throw new DiscoveryError(`cannot validate discovery project root: ${error.message}`, { cause: error });
573
+ }
574
+
575
+ const normalized = new Set();
576
+ for (const reported of reportedFiles) {
577
+ if (typeof reported !== 'string' || reported.length === 0 || reported.includes('\0')) {
578
+ throw new DiscoveryError('native test discovery returned a malformed file path');
579
+ }
580
+ const absolute = isAbsolute(reported) ? resolve(reported) : resolve(root, reported);
581
+
582
+ let real;
583
+ let stat;
584
+ try {
585
+ real = realpathSync(absolute);
586
+ stat = statSync(real);
587
+ } catch (error) {
588
+ throw new DiscoveryError(`native test discovery path does not exist: ${reported}`, { cause: error });
589
+ }
590
+ if (outside(realRoot, real)) throw new DiscoveryError(`native test discovery path resolves outside the project: ${reported}`);
591
+ if (!stat.isFile()) throw new DiscoveryError(`native test discovery path is not a file: ${reported}`);
592
+
593
+ const rel = relative(realRoot, real).split(sep).join('/');
594
+ if (!rel || Buffer.byteLength(rel, 'utf8') > maxPathBytes) {
595
+ throw new DiscoveryError(`native test discovery path exceeds ${maxPathBytes} bytes: ${reported}`);
596
+ }
597
+ normalized.add(rel);
598
+ }
599
+ return [...normalized].sort();
600
+ }
601
+
602
+ const manifestPath = (file) => typeof file === 'string'
603
+ && file.length > 0
604
+ && !file.includes('\0')
605
+ && !file.includes('\\')
606
+ && !file.startsWith('/')
607
+ && !file.split('/').some((part) => part === '' || part === '.' || part === '..')
608
+ && Buffer.byteLength(file, 'utf8') <= MAX_DISCOVERY_PATH_BYTES;
609
+
610
+ /** A canonical, runner-bound snapshot of the exact native test universe. */
611
+ export function createDiscoveryManifest({ runner, version, files, configFiles = [], source = 'native' }) {
612
+ if (typeof runner !== 'string' || runner.length === 0 || typeof version !== 'string' || version.length === 0) {
613
+ throw new DiscoveryError('a discovery manifest requires non-empty runner and version strings');
614
+ }
615
+ if (!Array.isArray(files) || files.length > MAX_DISCOVERY_FILES || files.some((file) => !manifestPath(file))) {
616
+ throw new DiscoveryError('a discovery manifest requires normalized project-relative file paths');
617
+ }
618
+ if (!Array.isArray(configFiles) || configFiles.some((entry) => entry == null
619
+ || typeof entry !== 'object'
620
+ || !configPath(entry.path)
621
+ || !/^[0-9a-f]{64}$/.test(entry.sha256))) {
622
+ throw new DiscoveryError('a discovery manifest requires valid config dependency hashes');
623
+ }
624
+ if (typeof source !== 'string' || source.length === 0) throw new DiscoveryError('a discovery manifest requires a source');
625
+ const canonicalFiles = [...new Set(files)].sort();
626
+ const canonicalConfigs = configFiles
627
+ .map(({ path, sha256: contentHash }) => ({ path, sha256: contentHash }))
628
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
629
+ if (new Set(canonicalConfigs.map((entry) => entry.path)).size !== canonicalConfigs.length) {
630
+ throw new DiscoveryError('a discovery manifest cannot contain duplicate config dependency paths');
631
+ }
632
+ const preimage = { schemaVersion: 1, runner: { name: runner, version }, source, files: canonicalFiles, configFiles: canonicalConfigs };
633
+ return { ...preimage, testUniverseHash: sha256(JSON.stringify(preimage)) };
634
+ }