@redue/cli 0.1.0-alpha.3 → 0.1.0-alpha.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +806 -0
  2. package/README.md +157 -99
  3. package/bin/redue.mjs +267 -61
  4. package/docs/README.md +3 -1
  5. package/docs/user/agents.md +333 -15
  6. package/docs/user/configuration.md +28 -2
  7. package/docs/user/direct-typescript.md +219 -0
  8. package/docs/user/getting-started.md +49 -7
  9. package/docs/user/json.md +4 -2
  10. package/docs/user/node-script.md +177 -0
  11. package/docs/user/npm-launcher-notice.md +42 -0
  12. package/docs/user/pilot.md +146 -0
  13. package/docs/user/sandbox.md +312 -0
  14. package/docs/user/shell.md +3 -3
  15. package/docs/user/supported-projects.md +69 -23
  16. package/docs/user/tests-direct.md +143 -0
  17. package/package.json +4 -3
  18. package/src/agent-integration.mjs +467 -0
  19. package/src/agent-policy.md +28 -0
  20. package/src/cached-state.mjs +24 -1
  21. package/src/claude-hook.mjs +616 -0
  22. package/src/cli.mjs +449 -54
  23. package/src/daemon.mjs +442 -93
  24. package/src/decision-profile.mjs +15 -1
  25. package/src/decision-validation.mjs +12 -1
  26. package/src/declared-plan.mjs +38 -6
  27. package/src/direct-run-certificate.mjs +254 -0
  28. package/src/direct-typescript-probe.mjs +27 -0
  29. package/src/direct-typescript.mjs +205 -0
  30. package/src/glob.mjs +8 -0
  31. package/src/history-worker.mjs +21 -13
  32. package/src/index.mjs +108 -21
  33. package/src/installed-inputs.mjs +7 -1
  34. package/src/installed-observation.mjs +23 -0
  35. package/src/landlock.py +527 -0
  36. package/src/linux-inotify.py +39 -10
  37. package/src/node-onboarding.mjs +203 -54
  38. package/src/node-script.mjs +406 -0
  39. package/src/npm-integrity.mjs +124 -0
  40. package/src/npm-typecheck-environment.mjs +36 -0
  41. package/src/npmrc.mjs +16 -0
  42. package/src/observation.mjs +34 -12
  43. package/src/path-identity.mjs +8 -0
  44. package/src/plan-worker.mjs +17 -17
  45. package/src/pnpm-inputs.mjs +122 -57
  46. package/src/pnpm-shim-templates.json +180 -0
  47. package/src/pnpm-shims.mjs +54 -0
  48. package/src/pnpm-typecheck-probe.mjs +11 -6
  49. package/src/presentation.mjs +13 -4
  50. package/src/probe-deadline.mjs +37 -0
  51. package/src/probe-result.mjs +7 -3
  52. package/src/project-location.mjs +34 -0
  53. package/src/python-onboarding.mjs +89 -0
  54. package/src/run-recorder.cjs +91 -0
  55. package/src/sandbox.mjs +653 -0
  56. package/src/setup.mjs +205 -0
  57. package/src/stats.mjs +72 -0
  58. package/src/test-direct.mjs +608 -0
  59. package/src/test-recorder.cjs +435 -0
  60. package/src/typescript-contract-probe.mjs +4 -187
  61. package/src/typescript-input-facts.mjs +92 -0
  62. package/src/typescript-invocation.mjs +92 -0
  63. package/src/typescript-list.mjs +118 -13
  64. package/src/typescript-qualification.mjs +29 -49
  65. package/src/workspace-manifests.mjs +24 -0
  66. package/src/yarn-workspace-inputs.mjs +3 -0
  67. package/src/yarn-workspace-typecheck-probe.mjs +2 -1
@@ -0,0 +1,608 @@
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 {patternMatcher} from './glob.mjs';
14
+ import {runFact} from './direct-run-certificate.mjs';
15
+ import {queryAnswer} from './typescript-list.mjs';
16
+ import {pnpmInstallInfo,pnpmProjectInfo,yaml} from './pnpm-inputs.mjs';
17
+ import {workspaceManifests} from './workspace-manifests.mjs';
18
+ import {atomicJson} from './state-store.mjs';
19
+ // node-script.mjs imports this module too; childProgram is only called at run time.
20
+ import {childProgram} from './node-script.mjs';
21
+ import {racyUntil} from './path-identity.mjs';
22
+
23
+ const digest=value=>createHash('sha256').update(value).digest('hex');
24
+ export const testRecorder=fileURLToPath(new URL('test-recorder.cjs',import.meta.url));
25
+ // Variables the recorder cannot see being read, compared by the
26
+ // caller-context machinery: Node and libc read TZ, LANG, LC_*, NODE_*, DYLD_*
27
+ // and LD_* natively, and Vite picks VITE_* by listing the environment. Every
28
+ // other variable the run reads directly is compared by value (environmentReads).
29
+ export const testEnvironment={variables:['TZ','LANG'],
30
+ prefixes:['NODE_','LC_','VITE_','DYLD_','LD_'],executableIdentity:true};
31
+ // Display-only variables, not compared. `_` is the shell's last command;
32
+ // argument parsers (yargs) use it only for help text.
33
+ export const displayVariables=['_','TERM','TERM_PROGRAM','TERM_PROGRAM_VERSION','COLORTERM','FORCE_COLOR','NO_COLOR',
34
+ 'FORCE_TTY','COLUMNS','LINES','AI_AGENT','REDUE_RUN_RECORD'];
35
+ // The runners' own data directories, like their caches, are not inputs:
36
+ // Vitest keeps its API token in <user data>/vitest (env-paths' data
37
+ // directory, not the state directory REDUE uses). A relative XDG_DATA_HOME
38
+ // is ignored, as the XDG specification says.
39
+ export function runnerDataDirectories(env=process.env){
40
+ const data=process.platform==='darwin'?path.join(os.homedir(),'Library','Application Support'):
41
+ path.isAbsolute(env.XDG_DATA_HOME||'')?env.XDG_DATA_HOME:path.join(os.homedir(),'.local','share');
42
+ return [path.join(data,'vitest')];
43
+ }
44
+ const issueLimit=20,factLimit=10000;
45
+
46
+ // The guard file the recorder reads: where to write, what counts as the
47
+ // checkout and as temporary space, and digests of the caller's environment.
48
+ export function testGuardConfig(dir,root,env=process.env,project={}) {
49
+ const tmp=[...new Set([path.resolve(os.tmpdir()),fs.realpathSync(os.tmpdir())])];
50
+ const real=dir=>{try{return fs.realpathSync(dir);}catch{return dir;}};
51
+ return {schema:1,dir,root,tmp,installed:modulesDirs(project).map(at=>path.join(root,at)),runnerData:[...new Set(runnerDataDirectories(env).flatMap(dir=>[path.resolve(dir),real(dir)]))],
52
+ env:Object.fromEntries(Object.entries(env).filter(([,value])=>typeof value==='string').map(([key,value])=>[key,digest(value)]))};
53
+ }
54
+
55
+ // The environment a recorded run starts with: the caller's, plus the preload.
56
+ // Node splits NODE_OPTIONS on spaces and honours double quotes.
57
+ export function recordedEnvironment(guardFile,env=process.env) {
58
+ const preload=`--require "${testRecorder.replace(/["\\]/g,'\\$&')}"`;
59
+ return {...env,NODE_OPTIONS:env.NODE_OPTIONS?`${env.NODE_OPTIONS} ${preload}`:preload,REDUE_RUN_RECORD:guardFile};
60
+ }
61
+
62
+ // What the run used beyond its observed inputs. `files` maps each observed
63
+ // input (relative path) at the start checkpoint to its digest; `runner` is the
64
+ // PID of the process REDUE started. Returns {issues, runFacts, environmentReads,
65
+ // isOutput}: environmentReads maps each variable the run read directly to the
66
+ // caller's value digest, or '<unset>'. `programs` is the general recipe's
67
+ // account of processes (node-script.mjs): `judge(child, parentMain)` says
68
+ // whether a started program is accounted for and must load the recorder,
69
+ // `runner` describes the program REDUE started, and `trusted` names checkout
70
+ // paths (runner caches) whose use is not an input.
71
+ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=testEnvironment,coverage=null,programs=null}) {
72
+ // The recipe's variables and any the project declares are compared, not guarded.
73
+ const compared=name=>(environment.variables||[]).includes(name)||
74
+ (environment.prefixes||[]).some(prefix=>name.toLowerCase().startsWith(prefix.toLowerCase()));
75
+ const issues=[],add=issue=>{if(!issues.includes(issue))issues.push(issue);};
76
+ const included=patternMatcher(sourcePatterns);
77
+ const observed=rel=>Object.hasOwn(files||{},rel);
78
+ const headers=new Map(),records=[],headerCount=new Map(),threadsStarted=new Map();
79
+ let names=[];
80
+ try{names=fs.readdirSync(dir).filter(name=>name.endsWith('.jsonl'));}
81
+ catch{return {issues:['the run record is unavailable'],runFacts:[],environmentReads:{},isOutput:()=>false};}
82
+ for(const name of names){
83
+ let rows;
84
+ try{rows=fs.readFileSync(path.join(dir,name),'utf8').split('\n').filter(Boolean).map(line=>JSON.parse(line));}
85
+ catch{add('a run record is unreadable');continue;}
86
+ const header=rows[0]?.[0]==='H'?rows[0][1]:null;
87
+ if(!header||!Number.isSafeInteger(header.pid)||!Number.isSafeInteger(header.threadId)){add('a run record is unreadable');continue;}
88
+ headers.set(`${header.pid}-${header.threadId}`,header);
89
+ // After process.execve, the new program's threads append to the same
90
+ // records: each thread started needs its own header.
91
+ headerCount.set(`${header.pid}-${header.threadId}`,rows.filter(row=>row?.[0]==='H'&&!row[1]?.internal).length);
92
+ records.push({header,rows:rows.slice(1)});
93
+ }
94
+ let opaque=false;
95
+ if(!programs){if(!headers.has(`${runner}-0`))add('the test runner did not load the REDUE recorder');}
96
+ else{const judged=programs.judge(programs.runner,null);
97
+ if(judged.issue)add(judged.issue);
98
+ else if(judged.needsHeader&&!headers.has(`${runner}-0`))add('the package manager did not load the REDUE recorder');
99
+ opaque||=Boolean(judged.opaque);}
100
+ const trusted=programs?.trusted?patternMatcher(['**',...programs.trusted.map(pattern=>'!'+pattern)]):null;
101
+ // A cache directory itself, and everything in it.
102
+ const isTrusted=rel=>trusted!==null&&(!trusted(rel)||!trusted(rel+'/_'));
103
+ // Answers per question; a question answered two ways changed during the run.
104
+ const answers=new Map(),ask=(key,answer)=>{const set=answers.get(key)||new Set();set.add(JSON.stringify(answer));answers.set(key,set);};
105
+ // Every child with a PID, accepted shells too: a header with one of these
106
+ // PIDs is that child, not an execSync child (which reports no PID).
107
+ const installedWrites=new Set();
108
+ const reads=new Map(),produced=new Set(),created=new Set(),environmentReads={},spawned=new Map(),childPids=new Map();
109
+ // Whether a started program is accounted for: {issue} or {needsHeader}.
110
+ // The tests recipe wants every program to become a recorded process; a
111
+ // shell, which can run other programs first, is read by its command as
112
+ // the general recipe reads it.
113
+ const judge=(child,main)=>{
114
+ if(!programs){
115
+ const judged=/^(sh|bash|dash|zsh|ksh|fish|csh|tcsh)$/.test(path.basename(String(child.file)))?childProgram(child,{parentMain:main}):{};
116
+ return judged.issue?judged:{needsHeader:true};}
117
+ const judged=programs.judge(child,main);
118
+ if(!judged.issue)opaque||=Boolean(judged.opaque);
119
+ return judged;
120
+ };
121
+ for(const {header,rows} of records){
122
+ // A program this process replaced itself with (process.execve) that must
123
+ // load the recorder: its header follows in this record, from the same
124
+ // parent. Later rows come from the program now running. (A reused PID
125
+ // also appends a header to an old record.)
126
+ let replaced=null,main=header.main??null;
127
+ for(const row of rows){
128
+ const [kind,...value]=Array.isArray(row)?row:[];
129
+ if(kind==='U')add(`recorder: ${value[0]}`);
130
+ else if(kind==='R')reads.set(value[0],reads.get(value[0])||value[1]===true);
131
+ else if(kind==='P')produced.add(path.join(root,value[0]));
132
+ else if(kind==='I')installedWrites.add(value[0]);
133
+ else if(kind==='M')created.add(value[0]);
134
+ else if(kind==='L')ask(`stat\0${value[0]}`,value[1]);
135
+ else if(kind==='D')ask(`entries\0${value[0]}`,value[1]);
136
+ else if(kind==='O')ask(`outside-${value[0]}\0${value[1]}`,value[2]);
137
+ else if(kind==='E'){const [name,state]=value;
138
+ if(compared(name)||displayVariables.includes(name)||typeof name!=='string')continue;
139
+ environmentReads[name]=typeof state==='string'?state:'<unset>';}
140
+ else if(kind==='N')add(`network: ${value[0]}`);
141
+ else if(kind==='C'||kind==='S'){
142
+ const [file,args,cwd,searchPath,env,script]=kind==='C'?value.slice(1):value;
143
+ if(kind==='C')childPids.set(header.pid,[...childPids.get(header.pid)||[],value[0]]);
144
+ // The general recipe accounts for each program; one that needs no
145
+ // recorder (a shell, the pnpm executable) is not matched to a header.
146
+ const judged=judge({file,args,cwd,searchPath,env,script},main);
147
+ if(judged.issue){add(judged.issue);continue;}
148
+ if(!judged.needsHeader)continue;
149
+ const list=spawned.get(header.pid)||[];list.push({pid:kind==='C'?value[0]:null,file});
150
+ spawned.set(header.pid,list);}
151
+ else if(kind==='X'){
152
+ const [file,args,cwd,searchPath,env,script]=value;
153
+ const judged=judge({file,args,cwd,searchPath,env,script},main);
154
+ if(judged.issue){add(judged.issue);replaced=null;continue;}
155
+ replaced=judged.needsHeader?file:null;}
156
+ else if(kind==='Y')replaced=null;
157
+ else if(kind==='T'){const key=`${header.pid}-${value[0]}`;threadsStarted.set(key,(threadsStarted.get(key)||0)+1);}
158
+ else if(kind==='H'){
159
+ if(replaced!==null&&value[0]?.ppid!==header.ppid)add(`replaced itself with ${path.basename(String(replaced))}, which REDUE does not record`);
160
+ replaced=null;main=value[0]?.main??null;}
161
+ else add('a run record is unreadable');
162
+ }
163
+ if(replaced!==null)add(`replaced itself with ${path.basename(String(replaced))}, which REDUE does not record`);
164
+ }
165
+ for(const [key,count] of threadsStarted)if((headerCount.get(key)||0)<count)add('a worker thread did not load the REDUE recorder');
166
+ // Every child must be a recorded Node process: by PID when known, otherwise
167
+ // by the number of recorded processes whose parent started it.
168
+ for(const [parent,children] of spawned){
169
+ const recorded=[...headers.values()].filter(h=>h.threadId===0&&h.ppid===parent);
170
+ const unknown=children.filter(child=>child.pid===null);
171
+ for(const child of children)if(child.pid!==null&&!recorded.some(h=>h.pid===child.pid))
172
+ add(`started ${path.basename(child.file)}, which REDUE does not record`);
173
+ const matched=recorded.filter(h=>!(childPids.get(parent)||[]).includes(h.pid)).length;
174
+ if(unknown.length>matched)add(`started ${path.basename(unknown[0].file)}, which REDUE does not record`);
175
+ }
176
+ // What the pnpm executable starts is not seen. Its node-options setting
177
+ // replaces NODE_OPTIONS for everything it starts, so a run through it must
178
+ // show at least one recorded Node process the recorded ones did not start.
179
+ if(opaque&&![...headers.values()].some(h=>h.threadId===0&&h.pid!==runner&&!headers.has(`${h.ppid}-0`)))
180
+ add('no Node process the pnpm executable started loaded the REDUE recorder');
181
+ // Inside a directory the run created. A file the run wrote excuses nothing:
182
+ // another process may have read it before the write. Listings may differ
183
+ // by such files (listingAfter).
184
+ 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;}};
185
+ const madeByRun=full=>created.has(full)||inCreated(full);
186
+ const runFacts=[];
187
+ const checkout=rel=>path.join(root,rel);
188
+ // The coverage report a --coverage run writes and may read back
189
+ // (coverageReadable); its contents do not decide pass or fail.
190
+ const inCoverage=rel=>coverage!==null&&(rel===coverage||rel.startsWith(coverage+'/'));
191
+ // File content in the checkout must be an observed input.
192
+ // A runner cache is git-ignored; a file in a cache path git keeps is source.
193
+ const trustedReads=[...reads].filter(([rel,found])=>found&&isTrusted(rel)&&!inCreated(checkout(rel))).map(([rel])=>rel);
194
+ const ignoredCaches=gitIgnored(root,trustedReads);
195
+ for(const [rel,found] of [...reads].sort(([a],[b])=>a<b?-1:a>b?1:0)){
196
+ if(inCreated(checkout(rel))||inCoverage(rel)||isTrusted(rel)&&(!found||ignoredCaches.has(rel)||produced.has(checkout(rel))))continue;
197
+ if(!included(rel))add(`read ${rel}, which the check's inputs exclude`);
198
+ else if(found&&!observed(rel))add(`read ${rel}, which REDUE does not observe (ignored by git)`);
199
+ // A missing file may be ignored by git, so the index would not see it appear.
200
+ else if(!found&&!produced.has(checkout(rel)))runFacts.push(['fileExists',checkout(rel),null]);
201
+ }
202
+ for(const [key,set] of [...answers].sort(([a],[b])=>a<b?-1:a>b?1:0)){
203
+ const [question,where]=key.split('\0');
204
+ const answer=JSON.parse([...set][0]);
205
+ if(question==='stat'||question==='entries'){
206
+ const full=checkout(where);
207
+ // A path the run made answers differently afterwards; it is output, not input.
208
+ if(madeByRun(full)||produced.has(full)||inCoverage(where)||isTrusted(where))continue;
209
+ if(set.size>1){add(`${where} changed during the run`);continue;}
210
+ if(question==='stat'){
211
+ // Observed files and directories: the index covers them. A missing
212
+ // path may be ignored by git, so it stays a fact.
213
+ if(included(where)&&(answer==='d'||observed(where)))continue;
214
+ const fact=answer==='error'||answer==='o'?null:runFact('stat',full,answer);
215
+ if(fact)runFacts.push(fact);else add(`lookup of ${where} gave ${answer}`);
216
+ continue;
217
+ }
218
+ // A listing the run itself added to or removed from is kept as it is now.
219
+ runFacts.push(['entries',full,answer==='error'?answer:listingAfter(full,answer,file=>madeByRun(file)||produced.has(file))]);
220
+ continue;
221
+ }
222
+ const kind=question.slice('outside-'.length),file=where;
223
+ if(madeByRun(file))continue;
224
+ if(set.size>1){add(`${file} changed during the run`);continue;}
225
+ const fact=answer==='error'?null:kind==='stat'||kind==='realpath'?runFact(kind,file,answer):
226
+ answer===null?['fileExists',file,null]:kind==='read'&&typeof answer?.content==='string'?['readFile',file,answer.content]:null;
227
+ if(fact)runFacts.push(fact);
228
+ else add(kind==='read'?`read ${file}, outside the project`:kind==='list'?`listed ${file}, outside the project`:
229
+ `${kind} of ${file} outside the project gave ${answer}`);
230
+ }
231
+ if(runFacts.length>factLimit)add('too many lookups during the run');
232
+ // A checkout path the run wrote without reading what was there before:
233
+ // inside a directory it created, or a file it created or truncated and no
234
+ // process read first.
235
+ // A read that found nothing read no leftover.
236
+ const isOutput=rel=>!rel.startsWith('external:')&&(inCreated(checkout(rel))||created.has(checkout(rel))||
237
+ produced.has(checkout(rel))&&reads.get(rel)!==true);
238
+ return {issues:issues.length>issueLimit?[...issues.slice(0,issueLimit),`${issues.length-issueLimit} more`]:issues,
239
+ runFacts:runFacts.length>factLimit?[]:[...new Map(runFacts.map(f=>[JSON.stringify(f),f])).values()],
240
+ environmentReads:Object.fromEntries(Object.entries(environmentReads).sort(([a],[b])=>a<b?-1:a>b?1:0)),isOutput,opaque,
241
+ wroteInstalled:rel=>installedWrites.has(rel)};
242
+ }
243
+ // The observed files that differ between two checkpoints' file maps.
244
+ export function changedFiles(before,after){
245
+ return [...new Set([...Object.keys(before||{}),...Object.keys(after||{})])].filter(file=>before?.[file]!==after?.[file]).sort();
246
+ }
247
+ // Files git ignores among `files`, in one call. A leading `./` keeps a name
248
+ // that starts with `:` from being read as pathspec magic. An error counts as
249
+ // not ignored.
250
+ export function gitIgnored(root,files){
251
+ if(!files.length)return new Set();
252
+ const out=spawnSync('git',['check-ignore','--stdin','-z'],{cwd:root,input:files.map(file=>'./'+file).join('\0')+'\0',
253
+ encoding:'utf8',maxBuffer:64*1024*1024,timeout:10000,env:{...process.env,GIT_OPTIONAL_LOCKS:'0'}});
254
+ return new Set(out.status===0?out.stdout.split('\0').filter(Boolean).map(file=>file.replace(/^\.\//,'')):[]);
255
+ }
256
+ // The listing now, when it differs from the run's only by entries the run made.
257
+ function listingAfter(full,during,madeByRun){
258
+ let now;try{now=queryAnswer('entries',full);}catch{return during;}
259
+ const own=entries=>Array.isArray(entries)?entries.filter(([name])=>!madeByRun(path.join(full,name))):entries;
260
+ return JSON.stringify(own(now))===JSON.stringify(own(during))?now:during;
261
+ }
262
+
263
+ // The recipe: a package.json script that runs Vitest or Jest directly, run by
264
+ // REDUE with the local runner and Node, never through npm.
265
+ export const testsQualification='npm-tests-direct-v1';
266
+ export const testsInterpretation='npm-direct-tests@1';
267
+ const fail=code=>{throw Object.assign(Error(code),{code});};
268
+ // Majors whose recorded runs were checked; Jest's per-file environment copy
269
+ // is recorded through jest-util 29.
270
+ const runners={vitest:{majors:[4,5]},jest:{majors:[29]}};
271
+ // Flags that keep the run a single, deterministic pass over the selected
272
+ // tests. Watch, changed-file, snapshot-update, shuffle and shard flags are not
273
+ // here. `value` flags take the next argument unless written with `=`.
274
+ const flags={
275
+ vitest:{plain:['--run','--no-watch','--coverage','--silent','--passWithNoTests','--typecheck','--globals',
276
+ '--no-file-parallelism','--allowOnly'],
277
+ value:['--config','-c','--root','-r','--dir','--project','--reporter','--environment','--pool','--maxWorkers',
278
+ '--testTimeout','--hookTimeout','--bail','--retry','--exclude']},
279
+ jest:{plain:['--coverage','--ci','--detectOpenHandles','--forceExit','--runInBand','-i','--silent','--verbose',
280
+ '--passWithNoTests','--colors','--no-cache','--logHeapUsage','--noStackTrace','--no-watchman','--errorOnDeprecated','--useStderr'],
281
+ value:['--config','-c','--testPathIgnorePatterns','--testPathPattern','--testPathPatterns','--maxWorkers','-w',
282
+ '--testTimeout','--selectProjects','--testNamePattern','-t','--bail']},
283
+ };
284
+ // A script's runner and arguments, or an error code. Shell syntax (quotes,
285
+ // globs, variables, chaining) would change what runs, so it is refused.
286
+ export function testScript(value){
287
+ const text=typeof value==='string'?value.trim():'';
288
+ if(!/^[A-Za-z0-9_./:=,@+-]+(?: [A-Za-z0-9_./:=,@+-]+)*$/.test(text))fail('test-script-unsupported');
289
+ const [runner,...args]=text.split(' ');
290
+ if(!runners[runner])fail('test-runner-unsupported');
291
+ const allowed=flags[runner];
292
+ let watchOff=false,positional=0;
293
+ for(let i=0;i<args.length;i++){
294
+ const arg=args[i];
295
+ if(!arg.startsWith('-')){
296
+ // Vitest takes its first positional argument as the command, after
297
+ // flags too. Its only command here is `run`; others are filters.
298
+ if(runner==='vitest'&&positional++===0){
299
+ if(['watch','dev','related','bench','init','list','typecheck'].includes(arg))fail('test-arguments-unsupported');
300
+ if(arg==='run')watchOff=true;
301
+ }
302
+ continue;
303
+ }
304
+ const [name,inline]=arg.split(/=(.*)/s);
305
+ if(runner==='vitest'&&(arg==='--watch=false'||arg==='--run'||arg==='--no-watch')){watchOff=true;continue;}
306
+ if(allowed.plain.includes(arg))continue;
307
+ if(allowed.value.includes(name)){
308
+ if(inline===undefined){if(i+1>=args.length||args[i+1].startsWith('-'))fail('test-arguments-unsupported');i++;}
309
+ else if(!inline)fail('test-arguments-unsupported');
310
+ continue;
311
+ }
312
+ fail('test-arguments-unsupported');
313
+ }
314
+ // Without `run`, Vitest watches when it has a terminal.
315
+ if(runner==='vitest'&&!watchOff)fail('test-watch-mode');
316
+ return {runner,args};
317
+ }
318
+ // The script `init` picks: a familiar name when it runs a runner directly,
319
+ // otherwise the only script that does.
320
+ export function testRecipeScript(scripts){
321
+ const issue=name=>{try{testScript(scripts[name]);return null;}catch(e){return e.code;}};
322
+ const runs=name=>typeof scripts[name]==='string'&&/^(?:vitest|jest)(?:\s|$)/.test(scripts[name].trim());
323
+ const preferred=['test','test:unit','test:check'].find(name=>typeof scripts[name]==='string'&&!issue(name));
324
+ if(preferred)return {script:preferred};
325
+ const candidates=Object.keys(scripts).filter(name=>runs(name)&&!issue(name));
326
+ if(candidates.length)return candidates.length===1?{script:candidates[0]}:{ambiguous:candidates};
327
+ // Why the nearest script does not qualify.
328
+ const near=Object.keys(scripts).find(runs);
329
+ return near?{issue:issue(near)}:{};
330
+ }
331
+ const majorOf=version=>Number(String(version).split('.')[0]);
332
+ // The runner's executable inside the checkout's node_modules. pnpm links
333
+ // node_modules/RUNNER into node_modules/.pnpm; the files stay in node_modules.
334
+ export function testRunnerEntry(root,runner){
335
+ if(!runners[runner])fail('test-runner-unsupported');
336
+ const dir=path.join(root,'node_modules',runner);
337
+ let pkg,real;
338
+ try{real=fs.realpathSync(dir);pkg=JSON.parse(fs.readFileSync(path.join(real,'package.json'),'utf8'));}
339
+ catch{fail('test-runner-missing');}
340
+ const modules=fs.realpathSync(path.join(root,'node_modules'));
341
+ if(!real.startsWith(modules+path.sep)||pkg.name!==runner)fail('test-runner-layout-unsupported');
342
+ const bin=typeof pkg.bin==='string'?pkg.bin:pkg.bin?.[runner];
343
+ const entry=typeof bin==='string'?path.resolve(real,bin):null;
344
+ if(!entry||!entry.startsWith(real+path.sep)||!fs.existsSync(entry))fail('test-runner-layout-unsupported');
345
+ return entry;
346
+ }
347
+ // The package manager of a checkout whose install the recipe observes in
348
+ // node_modules: npm, pnpm 9-12 (isolated or hoisted), or Yarn with the
349
+ // node-modules linker. One lockfile kind, agreeing with any declaration.
350
+ // Workspaces are refused unless `workspaces` is set.
351
+ export function testPackageManager(root,pkg,{workspaces=false}={}){
352
+ const has=file=>fs.existsSync(path.join(root,file));
353
+ const kinds=new Set([has('package-lock.json')||has('npm-shrinkwrap.json')?'npm':null,has('pnpm-lock.yaml')?'pnpm':null,
354
+ has('yarn.lock')?'yarn':null,has('bun.lock')||has('bun.lockb')?'bun':null].filter(Boolean));
355
+ const declared=/^(npm|pnpm|yarn|bun)@/.exec(pkg.packageManager||'')?.[1];
356
+ if(pkg.packageManager!==undefined&&!declared)fail('test-package-manager-unsupported');
357
+ if(declared)kinds.add(declared);
358
+ const name=kinds.size===1?[...kinds][0]:null;
359
+ if(!name||!has({npm:'package-lock.json',pnpm:'pnpm-lock.yaml',yarn:'yarn.lock',bun:'bun.lock'}[name])&&
360
+ !(name==='npm'&&has('npm-shrinkwrap.json')))fail('test-package-manager-unsupported');
361
+ if(pkg.workspaces&&!workspaces)fail('test-workspace-unsupported');
362
+ if(name==='pnpm'){
363
+ // pnpm 10+ keeps settings in pnpm-workspace.yaml; only `packages` makes a workspace.
364
+ let install;try{install=pnpmInstallInfo(root);}catch{fail('test-pnpm-install-unsupported');}
365
+ if(install.patterns.length&&!workspaces)fail('test-workspace-unsupported');
366
+ }
367
+ if(name==='yarn'){
368
+ // Yarn 2+ needs top-level `nodeLinker: node-modules` and the classic nmMode
369
+ // (no hard links); Yarn 1 always installs node_modules.
370
+ const rc=has('.yarnrc.yml')?fs.readFileSync(path.join(root,'.yarnrc.yml'),'utf8'):null;
371
+ const top=key=>rc?new RegExp(`^${key}\\s*:\\s*['"]?([\\w-]+)`,'m').exec(rc)?.[1]:undefined;
372
+ const version=/^yarn@(\d+)\./.exec(pkg.packageManager||'')?.[1];
373
+ if(!version||(version==='1'?rc!==null:top('nodeLinker')!=='node-modules'||![undefined,'classic'].includes(top('nmMode'))))
374
+ fail('test-yarn-install-unsupported');
375
+ }
376
+ if(name==='bun')fail('test-package-manager-unsupported');
377
+ return name;
378
+ }
379
+ // What a recorded run needs, from the check itself rather than the checkout:
380
+ // the package directory, the workspace node_modules its installed inputs
381
+ // list, and the runner and arguments of the package's script. A package
382
+ // added since init is then not treated as installed, so reads there are
383
+ // unobserved and make the run UNVERIFIED.
384
+ export function testRunFacts(root,check){
385
+ const dir=check.cwd&&check.cwd!=='.'?check.cwd:'.';
386
+ const dirs=(check.installedInputs||[]).map(p=>/^([^!*]+)\/node_modules\/\*\*$/.exec(p)?.[1]).filter(Boolean);
387
+ const own=JSON.parse(fs.readFileSync(path.join(root,dir,'package.json'),'utf8'));
388
+ if(typeof own.scripts?.[check.script]!=='string')fail('test-script-changed');
389
+ return {...testScript(own.scripts[check.script]),dir,dirs};
390
+ }
391
+ // The checkout's workspace patterns: pnpm's `packages`, else package.json's.
392
+ export function testWorkspacePatterns(root,pkg){
393
+ return fs.existsSync(path.join(root,'pnpm-lock.yaml'))?pnpmProjectInfo(root).patterns:
394
+ Array.isArray(pkg.workspaces)?pkg.workspaces:pkg.workspaces?.packages??[];
395
+ }
396
+ // The checkout's workspace package directories, [] for a single package.
397
+ export function testWorkspaceDirs(root,pkg){
398
+ const patterns=testWorkspacePatterns(root,pkg);
399
+ return patterns.length?workspaceManifests(root,patterns).map(({file})=>path.posix.dirname(file)):[];
400
+ }
401
+ // Each node_modules the recipe observes as installed: the root's and every
402
+ // workspace package's, relative to the root.
403
+ const modulesDirs=({dirs=[]})=>['node_modules',...dirs.map(dir=>`${dir}/node_modules`)];
404
+ // pnpm's content-addressed store, as the install recorded it in .modules.yaml:
405
+ // files/<first 2 hex>/<rest of the content's sha512 hex>, "-exec" for
406
+ // executables. pnpm writes new store files and never changes one in place.
407
+ function pnpmStoreFiles(root){
408
+ try{const {storeDir}=yaml(path.join(root,'node_modules','.modules.yaml'));
409
+ return typeof storeDir==='string'&&path.isAbsolute(storeDir)?path.join(storeDir,'files'):null;}
410
+ catch{return null;}
411
+ }
412
+ const identity=st=>`${st.size}:${st.mtimeNs}:${st.ctimeNs}`;
413
+ const sameFile=(file,st)=>{
414
+ try{const other=fs.statSync(file,{bigint:true});return other.dev===st.dev&&other.ino===st.ino;}
415
+ catch{return false;}};
416
+ // The file's store path relative to `files`, or null when the store does not
417
+ // hold this file at its content's address.
418
+ function storePath(files,file,st){
419
+ const hash=createHash('sha512'),buffer=Buffer.alloc(1<<20),fd=fs.openSync(file,'r');
420
+ try{for(let n;(n=fs.readSync(fd,buffer,0,buffer.length,null))>0;)hash.update(buffer.subarray(0,n));}
421
+ finally{fs.closeSync(fd);}
422
+ const hex=hash.digest('hex'),at=path.join(hex.slice(0,2),hex.slice(2));
423
+ return [at,at+'-exec'].find(candidate=>sameFile(path.join(files,candidate),st))??null;
424
+ }
425
+ // The first installed file with more than one link, as {file, reason}, or
426
+ // null. A package store outside the checkout (pnpm's default on Linux, Yarn's
427
+ // hardlinks modes) can change such a file with no event here. A link into
428
+ // pnpm's own store is accepted: the store's file is named by its content.
429
+ // Checked at every recorded run: a later install that adds links replaces the
430
+ // files, which is a change REDUE sees. `cache` (in owned state) keeps each
431
+ // file found in the store with its identity and store path, so an unchanged
432
+ // file is not hashed again; its store link is still checked.
433
+ // Without a pnpm store, a hard-linked installed file is accepted only when
434
+ // every one of its links is an installed file the check observes (`observed`,
435
+ // installed-observation.mjs): esbuild links its binary into its platform
436
+ // package. Each accepted file's link count becomes a run fact in `facts`, so
437
+ // a link added later is noticed before the next answer.
438
+ export function sharedInstalledFile(root,project={},cache=null,{observed=()=>false,facts=[]}={}){
439
+ const stack=modulesDirs(project).map(dir=>path.join(root,dir));
440
+ const files=pnpmStoreFiles(root),now=Date.now();
441
+ let known={},changed=false;
442
+ if(files&&cache)try{const value=JSON.parse(fs.readFileSync(cache,'utf8'));
443
+ if(value?.files===files&&value.entries&&typeof value.entries==='object')known=value.entries;}catch{}
444
+ const found={},links=new Map();
445
+ const shared=(file,reason)=>({file:path.relative(root,file).split(path.sep).join('/'),reason});
446
+ while(stack.length){
447
+ const dir=stack.pop();let entries;
448
+ try{entries=fs.readdirSync(dir,{withFileTypes:true});}catch{continue;}
449
+ for(const entry of entries){
450
+ const file=path.join(dir,entry.name);
451
+ if(entry.isDirectory()){stack.push(file);continue;}
452
+ if(!entry.isFile())continue;
453
+ let st;try{st=fs.lstatSync(file,{bigint:true});}catch{continue;}
454
+ if(st.nlink<=1n)continue;
455
+ if(!files){
456
+ // Distinct observed links by real path: a directory walked twice
457
+ // through a link counts once.
458
+ const key=`${st.dev}:${st.ino}`,group=links.get(key)??{file,nlink:st.nlink,seen:new Set()};
459
+ if(group.nlink!==st.nlink)return shared(file,'had its hard links change while REDUE checked them');
460
+ if(observed(file))try{group.seen.add(fs.realpathSync(file));}catch{}
461
+ links.set(key,group);continue;
462
+ }
463
+ const key=`${st.dev}:${st.ino}`,cached=known[key];
464
+ let at=Array.isArray(cached)&&cached[0]===identity(st)&&typeof cached[1]==='string'&&
465
+ sameFile(path.join(files,cached[1]),st)?cached[1]:null;
466
+ if(!at){
467
+ try{at=storePath(files,file,st);}
468
+ catch(e){return shared(file,`could not be checked against pnpm's store (${e.code||e.message})`);}
469
+ if(!at)return shared(file,"has another hard link outside pnpm's store");
470
+ changed=true;
471
+ }
472
+ // A file changed within the racy window could change again unseen in
473
+ // the same timestamp tick; it is hashed again next time.
474
+ if(racyUntil(st)<=now)found[key]=[identity(st),at];
475
+ else changed=true;
476
+ }
477
+ }
478
+ for(const group of links.values()){
479
+ if(BigInt(group.seen.size)<group.nlink)return shared(group.file,'has another hard link that is not an installed file this check observes, and the install records no pnpm store');
480
+ facts.push(['linkCount',group.file,Number(group.nlink)]);
481
+ }
482
+ if(files&&cache&&(changed||Object.keys(found).length!==Object.keys(known).length))
483
+ try{atomicJson(cache,{files,entries:found});}catch{}
484
+ return null;
485
+ }
486
+ // The runner a package's script reaches: Node's lookup from the package
487
+ // directory up to the root, skipping candidates that are not the runner. Its
488
+ // files must be in an observed node_modules (`dirs`: workspace packages).
489
+ // `logical` is where the lookup found it, before links, relative to the root.
490
+ export function testRunnerAt(root,dir,runner,dirs=[]){
491
+ if(dir==='.')return {entry:testRunnerEntry(root,runner),logical:`node_modules/${runner}`};
492
+ if(!runners[runner])fail('test-runner-unsupported');
493
+ const rootReal=fs.realpathSync(root),observed=modulesDirs({dirs}).map(at=>path.join(rootReal,at)+path.sep);
494
+ for(let at=dir;;at=path.posix.dirname(at)){
495
+ const logical=at==='.'?`node_modules/${runner}`:`${at}/node_modules/${runner}`;
496
+ let real,pkg;
497
+ try{real=fs.realpathSync(path.join(root,logical));pkg=JSON.parse(fs.readFileSync(path.join(real,'package.json'),'utf8'));}catch{}
498
+ if(pkg?.name===runner){
499
+ if(!observed.some(prefix=>real.startsWith(prefix)))fail('test-runner-layout-unsupported');
500
+ const bin=typeof pkg.bin==='string'?pkg.bin:pkg.bin?.[runner];
501
+ const entry=typeof bin==='string'?path.resolve(real,bin):null;
502
+ if(!entry||!entry.startsWith(real+path.sep)||!fs.existsSync(entry))fail('test-runner-layout-unsupported');
503
+ return {entry,logical};
504
+ }
505
+ if(at==='.')fail('test-runner-missing');
506
+ }
507
+ }
508
+ // The project facts the recipe needs, or an error code. `dir` is a workspace
509
+ // package's directory; there the recipe supports Vitest only.
510
+ export function testProject(root,script,dir='.'){
511
+ const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
512
+ testPackageManager(root,pkg,{workspaces:dir!=='.'});
513
+ const dirs=dir==='.'?[]:testWorkspaceDirs(root,pkg);
514
+ if(dir!=='.'&&!dirs.includes(dir))fail('test-workspace-unsupported');
515
+ const own=dir==='.'?pkg:JSON.parse(fs.readFileSync(path.join(root,dir,'package.json'),'utf8'));
516
+ if(typeof own.scripts?.[script]!=='string'||own.scripts['pre'+script]||own.scripts['post'+script])fail('test-script-changed');
517
+ const {runner,args}=testScript(own.scripts[script]);
518
+ if(dir!=='.'&&runner!=='vitest')fail('test-runner-unsupported');
519
+ const {entry,logical}=testRunnerAt(root,dir,runner,dirs);
520
+ const version=JSON.parse(fs.readFileSync(path.join(root,logical,'package.json'),'utf8')).version;
521
+ if(!runners[runner].majors.includes(majorOf(version)))fail('test-runner-version-unsupported');
522
+ if(runner==='jest'){
523
+ let util=null;try{util=JSON.parse(fs.readFileSync(path.join(root,'node_modules/jest-util/package.json'),'utf8')).version;}catch{}
524
+ if(majorOf(util)!==29||!fs.existsSync(path.join(root,'node_modules/jest-util/build/createProcessObject.js')))
525
+ fail('test-runner-layout-unsupported');
526
+ }
527
+ return {runner,version,args,entry,dir,dirs,logical};
528
+ }
529
+ // Inputs: the whole checkout except documentation and the coverage report a
530
+ // --coverage run writes. Git-ignored files the run creates are handled per
531
+ // run (cli.mjs). Installed files, in the root's node_modules and every
532
+ // workspace package's, are observed by identity; Vite's caches inside them
533
+ // are written by every Vitest run.
534
+ export function testCheckInputs({runner,args,dir='.',dirs=[],logical=`node_modules/${runner}`}){
535
+ const modules=modulesDirs({dirs});
536
+ return {inputs:['**',...modules.map(at=>`!${at}/**`),'!.git/**','!**/*.md','!**/*.mdx',
537
+ ...(args.includes('--coverage')?[`!${dir==='.'?'':dir+'/'}coverage/**`]:[])],
538
+ // Vitest's typecheck mode keeps tsc's build info in its own dist directory.
539
+ installedInputs:modules.flatMap(at=>[`${at}/**`,...(runner==='vitest'?[`!${at}/.vite/**`,`!${at}/.vite-temp/**`,`!${at}/.vitest/**`]:[])])
540
+ .concat(runner==='vitest'?[`!${logical}/dist/tsconfig.tmp.tsbuildinfo`]:[])};
541
+ }
542
+ // The coverage directory a --coverage run may read back: Jest reads every
543
+ // .js file under its roots, an earlier report's included, to map dependencies,
544
+ // and that does not decide pass or fail. Only the default directory, and only
545
+ // when no configuration names thresholds (which make the report decide the
546
+ // result) or another report directory. Configuration can be code, so this
547
+ // reads its text.
548
+ export function coverageReadable(checkout,{runner,args,dir='.'}){
549
+ if(!args.includes('--coverage'))return null;
550
+ // A workspace package's configuration and report live in its directory.
551
+ const root=path.join(checkout,dir);
552
+ const names=runner==='jest'?['jest.config.js','jest.config.ts','jest.config.mjs','jest.config.cjs','jest.config.json']:
553
+ ['vitest.config.ts','vitest.config.mts','vitest.config.cts','vitest.config.js','vitest.config.mjs','vitest.config.cjs',
554
+ 'vite.config.ts','vite.config.mts','vite.config.cts','vite.config.js','vite.config.mjs','vite.config.cjs',
555
+ 'vitest.workspace.ts','vitest.workspace.js','vitest.workspace.json'];
556
+ const at=args.findIndex(arg=>arg==='--config'||arg==='-c'),inline=args.find(arg=>/^(?:--config|-c)=/.test(arg));
557
+ const given=at>=0?args[at+1]:inline?.split(/=(.*)/s)[1];
558
+ // Vitest finds its configuration in the package directory or a parent.
559
+ const texts=[],folders=[];
560
+ for(let at=dir;;at=path.posix.dirname(at)){folders.push(path.join(checkout,at));if(at==='.')break;}
561
+ for(const name of [...names,...(given?[given]:[])])for(const folder of folders){
562
+ try{texts.push(fs.readFileSync(path.resolve(folder,name),'utf8'));}catch{}
563
+ }
564
+ try{const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
565
+ if(runner==='jest'&&pkg.jest)texts.push(JSON.stringify(pkg.jest));}catch{return null;}
566
+ return texts.some(text=>/coverageThreshold|thresholds|coverageDirectory|reportsDirectory/.test(text))?null:
567
+ dir==='.'?'coverage':`${dir}/coverage`;
568
+ }
569
+ // The caller's environment, when it would load code before the recorder or
570
+ // the Node version is not one the recipe supports. Compared variables are
571
+ // fingerprinted separately.
572
+ export function testEnvironmentIssue(env=process.env){
573
+ if(![22,24].includes(majorOf(process.versions.node)))return 'the test recipe supports Node 22 and 24';
574
+ const options=(env.NODE_OPTIONS||'').trim();
575
+ if(options&&!(/^[A-Za-z0-9=._ -]*$/.test(options)&&options.split(' ').filter(Boolean).every(flag=>
576
+ /^--max-(?:old|semi)-space-size=\d+$/.test(flag)||/^--use-(?:openssl|bundled|system)-ca$/.test(flag)||
577
+ /^--dns-result-order=(?:ipv4first|ipv6first|verbatim)$/.test(flag))))
578
+ return 'NODE_OPTIONS could load code before the REDUE recorder';
579
+ return null;
580
+ }
581
+ // Plan qualification: the check is exactly what `init` writes for the
582
+ // project's current script.
583
+ export function qualifyTests(root,selected){
584
+ const issues=[];
585
+ if(!['darwin','linux'].includes(process.platform))issues.push('the test recipe records runs on macOS and Linux only');
586
+ try{
587
+ const dir=selected.cwd&&selected.cwd!=='.'?selected.cwd:'.';
588
+ if(dir!=='.'&&!selected.workspace)throw Object.assign(Error(),{code:'a test check in a directory needs its workspace'});
589
+ const project=testProject(root,selected.script,dir);
590
+ if(JSON.stringify(selected.command)!==JSON.stringify([process.execPath,project.entry,...project.args]))
591
+ issues.push('test runner invocation changed; run redue init again');
592
+ // Every checkout file but installed ones is a content input; the recorder
593
+ // skips node_modules because it is observed as installed files.
594
+ if(dir!=='.'&&JSON.parse(fs.readFileSync(path.join(root,dir,'package.json'),'utf8')).name!==selected.workspace)
595
+ issues.push('the test check names another workspace package than its directory holds; run redue init again');
596
+ const expected=testCheckInputs(project),sorted=list=>JSON.stringify([...(list||[])].sort());
597
+ if(sorted(selected.inputs)!==sorted(expected.inputs)||
598
+ JSON.stringify([...(selected.installedInputs||[])].sort())!==JSON.stringify([...testCheckInputs(project).installedInputs].sort()))
599
+ issues.push('test inputs must be the whole checkout, with node_modules observed as installed files; run redue init again');
600
+ }catch(e){issues.push(e.code||'test runner prerequisites unavailable');}
601
+ if(!selected.environment?.executableIdentity||
602
+ !testEnvironment.variables.every(key=>selected.environment?.variables?.includes(key))||
603
+ !testEnvironment.prefixes.every(key=>selected.environment?.prefixes?.includes(key)))
604
+ issues.push('test execution context incomplete; run redue init again');
605
+ if(selected.probes?.length)issues.push('the test recipe takes no probes');
606
+ if(selected.generatedInputs?.length)issues.push('the test recipe takes no generated inputs');
607
+ return {qualified:issues.length===0,issues};
608
+ }