@redue/cli 0.1.0-alpha.12 → 0.1.0-alpha.14

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,365 @@
1
+ // The direct test recipe's guard. A recorded test run treats the whole
2
+ // checkout as its inputs (the plan), so the recorder (test-recorder.cjs) only
3
+ // has to show that the run used nothing else: no excluded or unobserved file
4
+ // content, nothing outside the project but lookups that can be asked again,
5
+ // no network, no environment variable outside the compared list, and no
6
+ // process that escaped the recorder.
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import {createHash} from 'node:crypto';
11
+ import {fileURLToPath} from 'node:url';
12
+ import {spawnSync} from 'node:child_process';
13
+ import micromatch from './glob.mjs';
14
+ import {runFact} from './direct-run-certificate.mjs';
15
+ import {queryAnswer} from './typescript-list.mjs';
16
+
17
+ const digest=value=>createHash('sha256').update(value).digest('hex');
18
+ export const testRecorder=fileURLToPath(new URL('test-recorder.cjs',import.meta.url));
19
+ // Variables the recorder cannot see being read, compared by the
20
+ // caller-context machinery: Node and libc read TZ, LANG, LC_*, NODE_*, DYLD_*
21
+ // and LD_* natively, and Vite picks VITE_* by listing the environment. Every
22
+ // other variable the run reads directly is compared by value (environmentReads).
23
+ export const testEnvironment={variables:['TZ','LANG'],
24
+ prefixes:['NODE_','LC_','VITE_','DYLD_','LD_'],executableIdentity:true};
25
+ // Display-only variables, not compared. `_` is the shell's last command;
26
+ // argument parsers (yargs) use it only for help text.
27
+ export const displayVariables=['_','TERM','TERM_PROGRAM','TERM_PROGRAM_VERSION','COLORTERM','FORCE_COLOR','NO_COLOR',
28
+ 'FORCE_TTY','COLUMNS','LINES','AI_AGENT','REDUE_RUN_RECORD'];
29
+ // The runners' own data directories, like their caches, are not inputs:
30
+ // Vitest keeps its API token in <user data>/vitest (env-paths' data
31
+ // directory, not the state directory REDUE uses). A relative XDG_DATA_HOME
32
+ // is ignored, as the XDG specification says.
33
+ export function runnerDataDirectories(env=process.env){
34
+ const data=process.platform==='darwin'?path.join(os.homedir(),'Library','Application Support'):
35
+ path.isAbsolute(env.XDG_DATA_HOME||'')?env.XDG_DATA_HOME:path.join(os.homedir(),'.local','share');
36
+ return [path.join(data,'vitest')];
37
+ }
38
+ const issueLimit=20,factLimit=10000;
39
+
40
+ // The guard file the recorder reads: where to write, what counts as the
41
+ // checkout and as temporary space, and digests of the caller's environment.
42
+ export function testGuardConfig(dir,root,env=process.env) {
43
+ const tmp=[...new Set([path.resolve(os.tmpdir()),fs.realpathSync(os.tmpdir())])];
44
+ const real=dir=>{try{return fs.realpathSync(dir);}catch{return dir;}};
45
+ return {schema:1,dir,root,tmp,runnerData:[...new Set(runnerDataDirectories(env).flatMap(dir=>[path.resolve(dir),real(dir)]))],
46
+ env:Object.fromEntries(Object.entries(env).filter(([,value])=>typeof value==='string').map(([key,value])=>[key,digest(value)]))};
47
+ }
48
+
49
+ // The environment a recorded run starts with: the caller's, plus the preload.
50
+ // Node splits NODE_OPTIONS on spaces and honours double quotes.
51
+ export function recordedEnvironment(guardFile,env=process.env) {
52
+ const preload=`--require "${testRecorder.replace(/["\\]/g,'\\$&')}"`;
53
+ return {...env,NODE_OPTIONS:env.NODE_OPTIONS?`${env.NODE_OPTIONS} ${preload}`:preload,REDUE_RUN_RECORD:guardFile};
54
+ }
55
+
56
+ // What the run used beyond its observed inputs. `files` maps each observed
57
+ // input (relative path) at the start checkpoint to its digest; `runner` is the
58
+ // PID of the process REDUE started. Returns {issues, runFacts, environmentReads,
59
+ // isOutput}: environmentReads maps each variable the run read directly to the
60
+ // caller's value digest, or '<unset>'.
61
+ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=testEnvironment,coverage=null}) {
62
+ // The recipe's variables and any the project declares are compared, not guarded.
63
+ const compared=name=>(environment.variables||[]).includes(name)||
64
+ (environment.prefixes||[]).some(prefix=>name.toLowerCase().startsWith(prefix.toLowerCase()));
65
+ const issues=[],add=issue=>{if(!issues.includes(issue))issues.push(issue);};
66
+ const positive=sourcePatterns.filter(p=>!p.startsWith('!')).map(p=>micromatch.matcher(p,{dot:true}));
67
+ const negative=sourcePatterns.filter(p=>p.startsWith('!')).map(p=>micromatch.matcher(p.slice(1),{dot:true}));
68
+ const included=rel=>positive.some(fn=>fn(rel))&&!negative.some(fn=>fn(rel));
69
+ const observed=rel=>Object.hasOwn(files||{},rel);
70
+ const headers=new Map(),records=[];
71
+ let names=[];
72
+ try{names=fs.readdirSync(dir).filter(name=>name.endsWith('.jsonl'));}
73
+ catch{return {issues:['the run record is unavailable'],runFacts:[],environmentReads:{},isOutput:()=>false};}
74
+ for(const name of names){
75
+ let rows;
76
+ try{rows=fs.readFileSync(path.join(dir,name),'utf8').split('\n').filter(Boolean).map(line=>JSON.parse(line));}
77
+ catch{add('a run record is unreadable');continue;}
78
+ const header=rows[0]?.[0]==='H'?rows[0][1]:null;
79
+ if(!header||!Number.isSafeInteger(header.pid)||!Number.isSafeInteger(header.threadId)){add('a run record is unreadable');continue;}
80
+ headers.set(`${header.pid}-${header.threadId}`,header);
81
+ records.push({header,rows:rows.slice(1)});
82
+ }
83
+ if(!headers.has(`${runner}-0`))add('the test runner did not load the REDUE recorder');
84
+ // Answers per question; a question answered two ways changed during the run.
85
+ const answers=new Map(),ask=(key,answer)=>{const set=answers.get(key)||new Set();set.add(JSON.stringify(answer));answers.set(key,set);};
86
+ const reads=new Map(),produced=new Set(),created=new Set(),environmentReads={},spawned=new Map();
87
+ for(const {header,rows} of records){
88
+ for(const row of rows){
89
+ const [kind,...value]=Array.isArray(row)?row:[];
90
+ if(kind==='U')add(`recorder: ${value[0]}`);
91
+ else if(kind==='R')reads.set(value[0],reads.get(value[0])||value[1]===true);
92
+ else if(kind==='P')produced.add(path.join(root,value[0]));
93
+ else if(kind==='M')created.add(value[0]);
94
+ else if(kind==='L')ask(`stat\0${value[0]}`,value[1]);
95
+ else if(kind==='D')ask(`entries\0${value[0]}`,value[1]);
96
+ else if(kind==='O')ask(`outside-${value[0]}\0${value[1]}`,value[2]);
97
+ else if(kind==='E'){const [name,state]=value;
98
+ if(compared(name)||displayVariables.includes(name)||typeof name!=='string')continue;
99
+ environmentReads[name]=typeof state==='string'?state:'<unset>';}
100
+ else if(kind==='N')add(`network: ${value[0]}`);
101
+ else if(kind==='C'||kind==='S'){
102
+ const list=spawned.get(header.pid)||[];list.push({pid:kind==='C'?value[0]:null,file:value[kind==='C'?1:0]});
103
+ spawned.set(header.pid,list);}
104
+ else if(kind==='T'){if(!headers.has(`${header.pid}-${value[0]}`))add('a worker thread did not load the REDUE recorder');}
105
+ else if(kind!=='H')add('a run record is unreadable');
106
+ }
107
+ }
108
+ // Every child must be a recorded Node process: by PID when known, otherwise
109
+ // by the number of recorded processes whose parent started it.
110
+ for(const [parent,children] of spawned){
111
+ const recorded=[...headers.values()].filter(h=>h.threadId===0&&h.ppid===parent);
112
+ const unknown=children.filter(child=>child.pid===null);
113
+ for(const child of children)if(child.pid!==null&&!recorded.some(h=>h.pid===child.pid))
114
+ add(`started ${path.basename(child.file)}, which REDUE does not record`);
115
+ const matched=recorded.filter(h=>!children.some(child=>child.pid===h.pid)).length;
116
+ if(unknown.length>matched)add(`started ${path.basename(unknown[0].file)}, which REDUE does not record`);
117
+ }
118
+ // Inside a directory the run created. A file the run wrote excuses nothing:
119
+ // another process may have read it before the write. Listings may differ
120
+ // by such files (listingAfter).
121
+ const inCreated=full=>{for(let at=path.dirname(full);;at=path.dirname(at)){if(created.has(at))return true;if(path.dirname(at)===at)return false;}};
122
+ const madeByRun=full=>created.has(full)||inCreated(full);
123
+ const runFacts=[];
124
+ const checkout=rel=>path.join(root,rel);
125
+ // The coverage report a --coverage run writes and may read back
126
+ // (coverageReadable); its contents do not decide pass or fail.
127
+ const inCoverage=rel=>coverage!==null&&(rel===coverage||rel.startsWith(coverage+'/'));
128
+ // File content in the checkout must be an observed input.
129
+ for(const [rel,found] of [...reads].sort(([a],[b])=>a<b?-1:a>b?1:0)){
130
+ if(inCreated(checkout(rel))||inCoverage(rel))continue;
131
+ if(!included(rel))add(`read ${rel}, which the check's inputs exclude`);
132
+ else if(found&&!observed(rel))add(`read ${rel}, which REDUE does not observe (ignored by git)`);
133
+ // A missing file may be ignored by git, so the index would not see it appear.
134
+ else if(!found&&!produced.has(checkout(rel)))runFacts.push(['fileExists',checkout(rel),null]);
135
+ }
136
+ for(const [key,set] of [...answers].sort(([a],[b])=>a<b?-1:a>b?1:0)){
137
+ const [question,where]=key.split('\0');
138
+ const answer=JSON.parse([...set][0]);
139
+ if(question==='stat'||question==='entries'){
140
+ const full=checkout(where);
141
+ // A path the run made answers differently afterwards; it is output, not input.
142
+ if(madeByRun(full)||produced.has(full)||inCoverage(where))continue;
143
+ if(set.size>1){add(`${where} changed during the run`);continue;}
144
+ if(question==='stat'){
145
+ // Observed files and directories: the index covers them. A missing
146
+ // path may be ignored by git, so it stays a fact.
147
+ if(included(where)&&(answer==='d'||observed(where)))continue;
148
+ const fact=answer==='error'||answer==='o'?null:runFact('stat',full,answer);
149
+ if(fact)runFacts.push(fact);else add(`lookup of ${where} gave ${answer}`);
150
+ continue;
151
+ }
152
+ // A listing the run itself added to or removed from is kept as it is now.
153
+ runFacts.push(['entries',full,answer==='error'?answer:listingAfter(full,answer,file=>madeByRun(file)||produced.has(file))]);
154
+ continue;
155
+ }
156
+ const kind=question.slice('outside-'.length),file=where;
157
+ if(madeByRun(file))continue;
158
+ if(set.size>1){add(`${file} changed during the run`);continue;}
159
+ const fact=answer==='error'?null:kind==='stat'||kind==='realpath'?runFact(kind,file,answer):
160
+ answer===null?['fileExists',file,null]:kind==='read'&&typeof answer?.content==='string'?['readFile',file,answer.content]:null;
161
+ if(fact)runFacts.push(fact);
162
+ else add(kind==='read'?`read ${file}, outside the project`:kind==='list'?`listed ${file}, outside the project`:
163
+ `${kind} of ${file} outside the project gave ${answer}`);
164
+ }
165
+ if(runFacts.length>factLimit)add('too many lookups during the run');
166
+ // A checkout path the run wrote without reading what was there before:
167
+ // inside a directory it created, or a file it created or truncated and no
168
+ // process read first.
169
+ // A read that found nothing read no leftover.
170
+ const isOutput=rel=>!rel.startsWith('external:')&&(inCreated(checkout(rel))||created.has(checkout(rel))||
171
+ produced.has(checkout(rel))&&reads.get(rel)!==true);
172
+ return {issues:issues.length>issueLimit?[...issues.slice(0,issueLimit),`${issues.length-issueLimit} more`]:issues,
173
+ runFacts:runFacts.length>factLimit?[]:[...new Map(runFacts.map(f=>[JSON.stringify(f),f])).values()],
174
+ environmentReads:Object.fromEntries(Object.entries(environmentReads).sort(([a],[b])=>a<b?-1:a>b?1:0)),isOutput};
175
+ }
176
+ // The observed files that differ between two checkpoints' file maps.
177
+ export function changedFiles(before,after){
178
+ return [...new Set([...Object.keys(before||{}),...Object.keys(after||{})])].filter(file=>before?.[file]!==after?.[file]).sort();
179
+ }
180
+ // Files git ignores among `files`, in one call. A leading `./` keeps a name
181
+ // that starts with `:` from being read as pathspec magic. An error counts as
182
+ // not ignored.
183
+ export function gitIgnored(root,files){
184
+ if(!files.length)return new Set();
185
+ const out=spawnSync('git',['check-ignore','--stdin','-z'],{cwd:root,input:files.map(file=>'./'+file).join('\0')+'\0',
186
+ encoding:'utf8',maxBuffer:64*1024*1024,timeout:10000,env:{...process.env,GIT_OPTIONAL_LOCKS:'0'}});
187
+ return new Set(out.status===0?out.stdout.split('\0').filter(Boolean).map(file=>file.replace(/^\.\//,'')):[]);
188
+ }
189
+ // The listing now, when it differs from the run's only by entries the run made.
190
+ function listingAfter(full,during,madeByRun){
191
+ let now;try{now=queryAnswer('entries',full);}catch{return during;}
192
+ const own=entries=>Array.isArray(entries)?entries.filter(([name])=>!madeByRun(path.join(full,name))):entries;
193
+ return JSON.stringify(own(now))===JSON.stringify(own(during))?now:during;
194
+ }
195
+
196
+ // The recipe: a package.json script that runs Vitest or Jest directly, run by
197
+ // REDUE with the local runner and Node, never through npm.
198
+ export const testsQualification='npm-tests-direct-v1';
199
+ export const testsInterpretation='npm-direct-tests@1';
200
+ const fail=code=>{throw Object.assign(Error(code),{code});};
201
+ // Majors whose recorded runs were checked; Jest's per-file environment copy
202
+ // is recorded through jest-util 29.
203
+ const runners={vitest:{majors:[4,5]},jest:{majors:[29]}};
204
+ // Flags that keep the run a single, deterministic pass over the selected
205
+ // tests. Watch, changed-file, snapshot-update, shuffle and shard flags are not
206
+ // here. `value` flags take the next argument unless written with `=`.
207
+ const flags={
208
+ vitest:{plain:['--run','--no-watch','--coverage','--silent','--passWithNoTests','--typecheck','--globals',
209
+ '--no-file-parallelism','--allowOnly'],
210
+ value:['--config','-c','--root','-r','--dir','--project','--reporter','--environment','--pool','--maxWorkers',
211
+ '--testTimeout','--hookTimeout','--bail','--retry','--exclude']},
212
+ jest:{plain:['--coverage','--ci','--detectOpenHandles','--forceExit','--runInBand','-i','--silent','--verbose',
213
+ '--passWithNoTests','--colors','--no-cache','--logHeapUsage','--noStackTrace','--no-watchman','--errorOnDeprecated','--useStderr'],
214
+ value:['--config','-c','--testPathIgnorePatterns','--testPathPattern','--testPathPatterns','--maxWorkers','-w',
215
+ '--testTimeout','--selectProjects','--testNamePattern','-t','--bail']},
216
+ };
217
+ // A script's runner and arguments, or an error code. Shell syntax (quotes,
218
+ // globs, variables, chaining) would change what runs, so it is refused.
219
+ export function testScript(value){
220
+ const text=typeof value==='string'?value.trim():'';
221
+ if(!/^[A-Za-z0-9_./:=,@+-]+(?: [A-Za-z0-9_./:=,@+-]+)*$/.test(text))fail('test-script-unsupported');
222
+ const [runner,...args]=text.split(' ');
223
+ if(!runners[runner])fail('test-runner-unsupported');
224
+ const allowed=flags[runner];
225
+ let watchOff=false,positional=0;
226
+ for(let i=0;i<args.length;i++){
227
+ const arg=args[i];
228
+ if(!arg.startsWith('-')){
229
+ // Vitest takes its first positional argument as the command, after
230
+ // flags too. Its only command here is `run`; others are filters.
231
+ if(runner==='vitest'&&positional++===0){
232
+ if(['watch','dev','related','bench','init','list','typecheck'].includes(arg))fail('test-arguments-unsupported');
233
+ if(arg==='run')watchOff=true;
234
+ }
235
+ continue;
236
+ }
237
+ const [name,inline]=arg.split(/=(.*)/s);
238
+ if(runner==='vitest'&&(arg==='--watch=false'||arg==='--run'||arg==='--no-watch')){watchOff=true;continue;}
239
+ if(allowed.plain.includes(arg))continue;
240
+ if(allowed.value.includes(name)){
241
+ if(inline===undefined){if(i+1>=args.length||args[i+1].startsWith('-'))fail('test-arguments-unsupported');i++;}
242
+ else if(!inline)fail('test-arguments-unsupported');
243
+ continue;
244
+ }
245
+ fail('test-arguments-unsupported');
246
+ }
247
+ // Without `run`, Vitest watches when it has a terminal.
248
+ if(runner==='vitest'&&!watchOff)fail('test-watch-mode');
249
+ return {runner,args};
250
+ }
251
+ // The script `init` picks: a familiar name when it runs a runner directly,
252
+ // otherwise the only script that does.
253
+ export function testRecipeScript(scripts){
254
+ const issue=name=>{try{testScript(scripts[name]);return null;}catch(e){return e.code;}};
255
+ const runs=name=>typeof scripts[name]==='string'&&/^(?:vitest|jest)(?:\s|$)/.test(scripts[name].trim());
256
+ const preferred=['test','test:unit','test:check'].find(name=>typeof scripts[name]==='string'&&!issue(name));
257
+ if(preferred)return {script:preferred};
258
+ const candidates=Object.keys(scripts).filter(name=>runs(name)&&!issue(name));
259
+ if(candidates.length)return candidates.length===1?{script:candidates[0]}:{ambiguous:candidates};
260
+ // Why the nearest script does not qualify.
261
+ const near=Object.keys(scripts).find(runs);
262
+ return near?{issue:issue(near)}:{};
263
+ }
264
+ const majorOf=version=>Number(String(version).split('.')[0]);
265
+ // The runner's executable inside the checkout's node_modules.
266
+ export function testRunnerEntry(root,runner){
267
+ if(!runners[runner])fail('test-runner-unsupported');
268
+ const dir=path.join(root,'node_modules',runner);
269
+ let pkg,real;
270
+ try{real=fs.realpathSync(dir);pkg=JSON.parse(fs.readFileSync(path.join(real,'package.json'),'utf8'));}
271
+ catch{fail('test-runner-missing');}
272
+ if(real!==dir||pkg.name!==runner)fail('test-runner-layout-unsupported');
273
+ const bin=typeof pkg.bin==='string'?pkg.bin:pkg.bin?.[runner];
274
+ const entry=typeof bin==='string'?path.resolve(real,bin):null;
275
+ if(!entry||!entry.startsWith(real+path.sep)||!fs.existsSync(entry))fail('test-runner-layout-unsupported');
276
+ return entry;
277
+ }
278
+ // The project facts the recipe needs, or an error code.
279
+ export function testProject(root,script){
280
+ const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
281
+ if(pkg.workspaces||pkg.packageManager&&!/^npm@/.test(pkg.packageManager)||
282
+ !['package-lock.json','npm-shrinkwrap.json'].some(file=>fs.existsSync(path.join(root,file)))||
283
+ ['yarn.lock','pnpm-lock.yaml','bun.lock','bun.lockb'].some(file=>fs.existsSync(path.join(root,file))))
284
+ fail('test-npm-project-unsupported');
285
+ if(typeof pkg.scripts?.[script]!=='string'||pkg.scripts['pre'+script]||pkg.scripts['post'+script])fail('test-script-changed');
286
+ const {runner,args}=testScript(pkg.scripts[script]);
287
+ const entry=testRunnerEntry(root,runner);
288
+ const version=JSON.parse(fs.readFileSync(path.join(root,'node_modules',runner,'package.json'),'utf8')).version;
289
+ if(!runners[runner].majors.includes(majorOf(version)))fail('test-runner-version-unsupported');
290
+ if(runner==='jest'){
291
+ let util=null;try{util=JSON.parse(fs.readFileSync(path.join(root,'node_modules/jest-util/package.json'),'utf8')).version;}catch{}
292
+ if(majorOf(util)!==29||!fs.existsSync(path.join(root,'node_modules/jest-util/build/createProcessObject.js')))
293
+ fail('test-runner-layout-unsupported');
294
+ }
295
+ return {runner,version,args,entry};
296
+ }
297
+ // Inputs: the whole checkout except documentation and the coverage report a
298
+ // --coverage run writes. Git-ignored files the run creates are handled per
299
+ // run (cli.mjs). Installed files are observed by identity; Vite's
300
+ // caches inside node_modules are written by every Vitest run.
301
+ export function testCheckInputs({runner,args}){
302
+ return {inputs:['**','!node_modules/**','!.git/**','!**/*.md','!**/*.mdx',
303
+ ...(args.includes('--coverage')?['!coverage/**']:[])],
304
+ // Vitest's typecheck mode keeps tsc's build info in its own dist directory.
305
+ installedInputs:['node_modules/**',...(runner==='vitest'?['!node_modules/.vite/**','!node_modules/.vite-temp/**','!node_modules/.vitest/**',
306
+ '!node_modules/vitest/dist/tsconfig.tmp.tsbuildinfo']:[])]};
307
+ }
308
+ // The coverage directory a --coverage run may read back: Jest reads every
309
+ // .js file under its roots, an earlier report's included, to map dependencies,
310
+ // and that does not decide pass or fail. Only the default directory, and only
311
+ // when no configuration names thresholds (which make the report decide the
312
+ // result) or another report directory. Configuration can be code, so this
313
+ // reads its text.
314
+ export function coverageReadable(root,{runner,args}){
315
+ if(!args.includes('--coverage'))return null;
316
+ const names=runner==='jest'?['jest.config.js','jest.config.ts','jest.config.mjs','jest.config.cjs','jest.config.json']:
317
+ ['vitest.config.ts','vitest.config.mts','vitest.config.cts','vitest.config.js','vitest.config.mjs','vitest.config.cjs',
318
+ 'vite.config.ts','vite.config.mts','vite.config.cts','vite.config.js','vite.config.mjs','vite.config.cjs',
319
+ 'vitest.workspace.ts','vitest.workspace.js','vitest.workspace.json'];
320
+ const at=args.findIndex(arg=>arg==='--config'||arg==='-c'),inline=args.find(arg=>/^(?:--config|-c)=/.test(arg));
321
+ const given=at>=0?args[at+1]:inline?.split(/=(.*)/s)[1];
322
+ const texts=[];
323
+ for(const name of [...names,...(given?[given]:[])]){
324
+ try{texts.push(fs.readFileSync(path.resolve(root,name),'utf8'));}catch{}
325
+ }
326
+ try{const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
327
+ if(runner==='jest'&&pkg.jest)texts.push(JSON.stringify(pkg.jest));}catch{return null;}
328
+ return texts.some(text=>/coverageThreshold|thresholds|coverageDirectory|reportsDirectory/.test(text))?null:'coverage';
329
+ }
330
+ // The caller's environment, when it would load code before the recorder or
331
+ // the Node version is not one the recipe supports. Compared variables are
332
+ // fingerprinted separately.
333
+ export function testEnvironmentIssue(env=process.env){
334
+ if(![22,24].includes(majorOf(process.versions.node)))return 'the test recipe supports Node 22 and 24';
335
+ const options=(env.NODE_OPTIONS||'').trim();
336
+ if(options&&!(/^[A-Za-z0-9=._ -]*$/.test(options)&&options.split(' ').filter(Boolean).every(flag=>
337
+ /^--max-(?:old|semi)-space-size=\d+$/.test(flag)||/^--use-(?:openssl|bundled|system)-ca$/.test(flag)||
338
+ /^--dns-result-order=(?:ipv4first|ipv6first|verbatim)$/.test(flag))))
339
+ return 'NODE_OPTIONS could load code before the REDUE recorder';
340
+ return null;
341
+ }
342
+ // Plan qualification: the check is exactly what `init` writes for the
343
+ // project's current script.
344
+ export function qualifyTests(root,selected){
345
+ const issues=[];
346
+ if(!['darwin','linux'].includes(process.platform))issues.push('the test recipe records runs on macOS and Linux only');
347
+ try{
348
+ const project=testProject(root,selected.script);
349
+ if(JSON.stringify(selected.command)!==JSON.stringify([process.execPath,project.entry,...project.args])||
350
+ selected.cwd&&selected.cwd!=='.')issues.push('test runner invocation changed; run redue init again');
351
+ // Every checkout file but installed ones is a content input; the recorder
352
+ // skips node_modules because it is observed as installed files.
353
+ const positives=(selected.inputs||[]).filter(p=>!p.startsWith('!'));
354
+ if(positives.length!==1||positives[0]!=='**'||!selected.inputs.includes('!node_modules/**')||
355
+ JSON.stringify([...(selected.installedInputs||[])].sort())!==JSON.stringify([...testCheckInputs(project).installedInputs].sort()))
356
+ issues.push('test inputs must be the whole checkout, with node_modules observed as installed files; run redue init again');
357
+ }catch(e){issues.push(e.code||'test runner prerequisites unavailable');}
358
+ if(!selected.environment?.executableIdentity||
359
+ !testEnvironment.variables.every(key=>selected.environment?.variables?.includes(key))||
360
+ !testEnvironment.prefixes.every(key=>selected.environment?.prefixes?.includes(key)))
361
+ issues.push('test execution context incomplete; run redue init again');
362
+ if(selected.probes?.length)issues.push('the test recipe takes no probes');
363
+ if(selected.generatedInputs?.length)issues.push('the test recipe takes no generated inputs');
364
+ return {qualified:issues.length===0,issues};
365
+ }