@redue/cli 0.1.0-alpha.18 → 0.1.0-alpha.19

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,54 @@
1
+ // pnpm's launchers. On macOS and Linux, pnpm writes each node_modules/.bin
2
+ // entry as a `#!/bin/sh` script that finds its own directory (with sed,
3
+ // dirname, readlink, uname or printf, depending on the pnpm release), sets
4
+ // NODE_PATH and execs Node on the package's bin file. A launcher is trusted only
5
+ // when its text is one of the templates pnpm releases write
6
+ // (pnpm-shim-templates.json, built by eval/pnpm-shim-templates.mjs), with only
7
+ // the NODE_PATH value, the bin file and the trailing target comment filled in.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ const templates=JSON.parse(fs.readFileSync(new URL('pnpm-shim-templates.json',import.meta.url),'utf8')).templates;
12
+ const known=new Set(templates.map(row=>row.template));
13
+ // A value with no quote, expansion, escape or line break: it stays literal in
14
+ // the shell.
15
+ const literal=/^[^"$`\\\n]*$/;
16
+
17
+ // The launcher's text as a template, with the values it fills in, or null
18
+ // when it does not have pnpm's form.
19
+ export function shimTemplate(text){
20
+ if(typeof text!=='string'||!text.startsWith('#!/bin/sh\n'))return null;
21
+ let template=text,nodePath=null;
22
+ // NODE_PATH, set alone or in front of the caller's.
23
+ const alone=/\n export NODE_PATH="([^"\n]*)"\n/.exec(text);
24
+ if(alone){
25
+ nodePath=alone[1];
26
+ // Each entry is literal, or relative to the launcher's own directory
27
+ // ($basedir_abs, which pnpm 12.6 and later write).
28
+ if(!nodePath.split(':').every(entry=>literal.test(entry.replace(/^\$basedir(?:_abs)?\//,'')))||
29
+ !text.includes(`\n export NODE_PATH="${nodePath}:$NODE_PATH"\n`))return null;
30
+ template=template.split(`\n export NODE_PATH="${nodePath}:$NODE_PATH"\n`).join('\n export NODE_PATH="@@NODE_PATH@@:$NODE_PATH"\n')
31
+ .split(`\n export NODE_PATH="${nodePath}"\n`).join('\n export NODE_PATH="@@NODE_PATH@@"\n');
32
+ }
33
+ // The bin file, relative to the launcher's directory, after $basedir,
34
+ // $basedir_abs or $basedir_win; `node` and `node.exe` there are Node itself.
35
+ const targets=new Set([...template.matchAll(/"\$basedir(?:_abs|_win)?\/([^"\n]+)"/g)].map(match=>match[1])
36
+ .filter(value=>value!=='node'&&value!=='node.exe'));
37
+ if(targets.size!==1)return null;
38
+ const [target]=targets;
39
+ if(!literal.test(target))return null;
40
+ for(const prefix of ['$basedir','$basedir_abs','$basedir_win'])
41
+ template=template.split(`"${prefix}/${target}"`).join(`"${prefix}/@@TARGET@@"`);
42
+ // pnpm 11 and later end with a comment naming the target.
43
+ template=template.replace(/\n# cmd-shim-target=[^\n]*\n?$/,'\n# cmd-shim-target=@@COMMENT@@\n');
44
+ return {template,nodePath,target};
45
+ }
46
+ // The bin file a pnpm launcher runs with Node, as an absolute path, or null
47
+ // when `file` is not a launcher pnpm writes.
48
+ export function pnpmShimTarget(file,text=null){
49
+ try{
50
+ text??=fs.readFileSync(file,'utf8');
51
+ const shim=shimTemplate(text);
52
+ return shim&&known.has(shim.template)?path.resolve(path.dirname(file),shim.target):null;
53
+ }catch{return null;}
54
+ }
@@ -1,4 +1,9 @@
1
1
  // Presentation consumes evidence; it never grants applicability.
2
+ // The recipe that made a receipt, by the qualification it names.
3
+ const recipes={'npm-typescript-direct-v1':'direct TypeScript recipe','npm-tests-direct-v1':'direct test recipe',
4
+ 'node-script-v1':'general recipe','pnpm-tsc-v1':'pnpm TypeScript recipe','yarn-workspace-tsc-v1':'Yarn workspace TypeScript recipe',
5
+ 'typescript-noemit-v1':'npm TypeScript recipe'};
6
+ export const recipeLabel=recipe=>recipes[recipe]??null;
2
7
  export function checkLabel(row) {
3
8
  return row.result==='FAIL'?'FAILED':row.freshness;
4
9
  }
@@ -21,7 +26,8 @@ export function renderStatus(value,{short=false}={}) {
21
26
  const label=checkLabel(row),symbol=label==='CURRENT'&&row.result==='PASS'?'✓':label==='FAILED'?'✗':'?';
22
27
  const mark=label==='STALE'?'⚠':symbol;
23
28
  const historical=row.result?` / ${row.result}`:'';
24
- lines.push(`${mark} ${row.name} ${label}${historical}`);
29
+ const recipe=label==='CURRENT'?recipeLabel(row.recipe):null;
30
+ lines.push(`${mark} ${row.name} ${label}${historical}${recipe?` (${recipe})`:''}`);
25
31
  if(label!=='CURRENT')lines.push(` ${row.result==='FAIL'?`Historical failure; applicability ${row.freshness}. `:''}${explanation(row)}`);
26
32
  }
27
33
  if(!lines.length)lines.push('UNVERIFIED — observation is initializing; no check state is available yet.');
@@ -34,6 +40,7 @@ export function renderExplain(value,name) {
34
40
  const lines=[`${row.name}: ${checkLabel(row)}${row.result?` / ${row.result}`:''}`];
35
41
  if(row.result==='FAIL')lines.push(`Historical failure; applicability: ${row.freshness}.`);
36
42
  if(row.verification_recipe==='npm-direct-typescript@1')lines.push('Recorded recipe: direct local TypeScript compiler; npm and lifecycle scripts were not executed.');
43
+ else if(recipeLabel(row.recipe))lines.push(`Recorded recipe: ${recipeLabel(row.recipe)}.`);
37
44
  lines.push(explanation(row));
38
45
  if(row.changed_inputs?.length)lines.push('Relevant inputs changed:',...row.changed_inputs.map(file=>` ${file}`));
39
46
  if(row.freshness==='STALE')lines.push(`Due again when verification is needed: redue run ${JSON.stringify(row.name)}`);
@@ -61,8 +61,12 @@ export function recordedEnvironment(guardFile,env=process.env) {
61
61
  // input (relative path) at the start checkpoint to its digest; `runner` is the
62
62
  // PID of the process REDUE started. Returns {issues, runFacts, environmentReads,
63
63
  // isOutput}: environmentReads maps each variable the run read directly to the
64
- // caller's value digest, or '<unset>'.
65
- export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=testEnvironment,coverage=null}) {
64
+ // caller's value digest, or '<unset>'. `programs` is the general recipe's
65
+ // account of processes (node-script.mjs): `judge(child, parentMain)` says
66
+ // whether a started program is accounted for and must load the recorder,
67
+ // `runner` describes the program REDUE started, and `trusted` names checkout
68
+ // paths (runner caches) whose use is not an input.
69
+ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=testEnvironment,coverage=null,programs=null}) {
66
70
  // The recipe's variables and any the project declares are compared, not guarded.
67
71
  const compared=name=>(environment.variables||[]).includes(name)||
68
72
  (environment.prefixes||[]).some(prefix=>name.toLowerCase().startsWith(prefix.toLowerCase()));
@@ -82,16 +86,28 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
82
86
  headers.set(`${header.pid}-${header.threadId}`,header);
83
87
  records.push({header,rows:rows.slice(1)});
84
88
  }
85
- if(!headers.has(`${runner}-0`))add('the test runner did not load the REDUE recorder');
89
+ let opaque=false;
90
+ if(!programs){if(!headers.has(`${runner}-0`))add('the test runner did not load the REDUE recorder');}
91
+ else{const judged=programs.judge(programs.runner,null);
92
+ if(judged.issue)add(judged.issue);
93
+ else if(judged.needsHeader&&!headers.has(`${runner}-0`))add('the package manager did not load the REDUE recorder');
94
+ opaque||=Boolean(judged.opaque);}
95
+ const trusted=programs?.trusted?patternMatcher(['**',...programs.trusted.map(pattern=>'!'+pattern)]):null;
96
+ // A cache directory itself, and everything in it.
97
+ const isTrusted=rel=>trusted!==null&&(!trusted(rel)||!trusted(rel+'/_'));
86
98
  // Answers per question; a question answered two ways changed during the run.
87
99
  const answers=new Map(),ask=(key,answer)=>{const set=answers.get(key)||new Set();set.add(JSON.stringify(answer));answers.set(key,set);};
88
- const reads=new Map(),produced=new Set(),created=new Set(),environmentReads={},spawned=new Map();
100
+ // Every child with a PID, accepted shells too: a header with one of these
101
+ // PIDs is that child, not an execSync child (which reports no PID).
102
+ const installedWrites=new Set();
103
+ const reads=new Map(),produced=new Set(),created=new Set(),environmentReads={},spawned=new Map(),childPids=new Map();
89
104
  for(const {header,rows} of records){
90
105
  for(const row of rows){
91
106
  const [kind,...value]=Array.isArray(row)?row:[];
92
107
  if(kind==='U')add(`recorder: ${value[0]}`);
93
108
  else if(kind==='R')reads.set(value[0],reads.get(value[0])||value[1]===true);
94
109
  else if(kind==='P')produced.add(path.join(root,value[0]));
110
+ else if(kind==='I')installedWrites.add(value[0]);
95
111
  else if(kind==='M')created.add(value[0]);
96
112
  else if(kind==='L')ask(`stat\0${value[0]}`,value[1]);
97
113
  else if(kind==='D')ask(`entries\0${value[0]}`,value[1]);
@@ -101,7 +117,15 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
101
117
  environmentReads[name]=typeof state==='string'?state:'<unset>';}
102
118
  else if(kind==='N')add(`network: ${value[0]}`);
103
119
  else if(kind==='C'||kind==='S'){
104
- const list=spawned.get(header.pid)||[];list.push({pid:kind==='C'?value[0]:null,file:value[kind==='C'?1:0]});
120
+ const [file,args,cwd,searchPath,env,script]=kind==='C'?value.slice(1):value;
121
+ if(kind==='C')childPids.set(header.pid,[...childPids.get(header.pid)||[],value[0]]);
122
+ // The general recipe accounts for each program; one that needs no
123
+ // recorder (a shell, the pnpm executable) is not matched to a header.
124
+ if(programs){const judged=programs.judge({file,args,cwd,searchPath,env,script},header.main??null);
125
+ if(judged.issue){add(judged.issue);continue;}
126
+ opaque||=Boolean(judged.opaque);
127
+ if(!judged.needsHeader)continue;}
128
+ const list=spawned.get(header.pid)||[];list.push({pid:kind==='C'?value[0]:null,file});
105
129
  spawned.set(header.pid,list);}
106
130
  else if(kind==='T'){if(!headers.has(`${header.pid}-${value[0]}`))add('a worker thread did not load the REDUE recorder');}
107
131
  else if(kind!=='H')add('a run record is unreadable');
@@ -114,9 +138,14 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
114
138
  const unknown=children.filter(child=>child.pid===null);
115
139
  for(const child of children)if(child.pid!==null&&!recorded.some(h=>h.pid===child.pid))
116
140
  add(`started ${path.basename(child.file)}, which REDUE does not record`);
117
- const matched=recorded.filter(h=>!children.some(child=>child.pid===h.pid)).length;
141
+ const matched=recorded.filter(h=>!(childPids.get(parent)||[]).includes(h.pid)).length;
118
142
  if(unknown.length>matched)add(`started ${path.basename(unknown[0].file)}, which REDUE does not record`);
119
143
  }
144
+ // What the pnpm executable starts is not seen. Its node-options setting
145
+ // replaces NODE_OPTIONS for everything it starts, so a run through it must
146
+ // show at least one recorded Node process the recorded ones did not start.
147
+ if(opaque&&![...headers.values()].some(h=>h.threadId===0&&h.pid!==runner&&!headers.has(`${h.ppid}-0`)))
148
+ add('no Node process the pnpm executable started loaded the REDUE recorder');
120
149
  // Inside a directory the run created. A file the run wrote excuses nothing:
121
150
  // another process may have read it before the write. Listings may differ
122
151
  // by such files (listingAfter).
@@ -128,8 +157,11 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
128
157
  // (coverageReadable); its contents do not decide pass or fail.
129
158
  const inCoverage=rel=>coverage!==null&&(rel===coverage||rel.startsWith(coverage+'/'));
130
159
  // File content in the checkout must be an observed input.
160
+ // A runner cache is git-ignored; a file in a cache path git keeps is source.
161
+ const trustedReads=[...reads].filter(([rel,found])=>found&&isTrusted(rel)&&!inCreated(checkout(rel))).map(([rel])=>rel);
162
+ const ignoredCaches=gitIgnored(root,trustedReads);
131
163
  for(const [rel,found] of [...reads].sort(([a],[b])=>a<b?-1:a>b?1:0)){
132
- if(inCreated(checkout(rel))||inCoverage(rel))continue;
164
+ if(inCreated(checkout(rel))||inCoverage(rel)||isTrusted(rel)&&(!found||ignoredCaches.has(rel)||produced.has(checkout(rel))))continue;
133
165
  if(!included(rel))add(`read ${rel}, which the check's inputs exclude`);
134
166
  else if(found&&!observed(rel))add(`read ${rel}, which REDUE does not observe (ignored by git)`);
135
167
  // A missing file may be ignored by git, so the index would not see it appear.
@@ -141,7 +173,7 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
141
173
  if(question==='stat'||question==='entries'){
142
174
  const full=checkout(where);
143
175
  // A path the run made answers differently afterwards; it is output, not input.
144
- if(madeByRun(full)||produced.has(full)||inCoverage(where))continue;
176
+ if(madeByRun(full)||produced.has(full)||inCoverage(where)||isTrusted(where))continue;
145
177
  if(set.size>1){add(`${where} changed during the run`);continue;}
146
178
  if(question==='stat'){
147
179
  // Observed files and directories: the index covers them. A missing
@@ -173,7 +205,8 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
173
205
  produced.has(checkout(rel))&&reads.get(rel)!==true);
174
206
  return {issues:issues.length>issueLimit?[...issues.slice(0,issueLimit),`${issues.length-issueLimit} more`]:issues,
175
207
  runFacts:runFacts.length>factLimit?[]:[...new Map(runFacts.map(f=>[JSON.stringify(f),f])).values()],
176
- environmentReads:Object.fromEntries(Object.entries(environmentReads).sort(([a],[b])=>a<b?-1:a>b?1:0)),isOutput};
208
+ environmentReads:Object.fromEntries(Object.entries(environmentReads).sort(([a],[b])=>a<b?-1:a>b?1:0)),isOutput,opaque,
209
+ wroteInstalled:rel=>installedWrites.has(rel)};
177
210
  }
178
211
  // The observed files that differ between two checkpoints' file maps.
179
212
  export function changedFiles(before,after){
@@ -23,11 +23,15 @@ function install(){
23
23
  const emit=row=>{try{const line=Buffer.from(JSON.stringify(row)+'\n');
24
24
  for(let at=0;at<line.length;)at+=original.writeSync(fd,line,at,line.length-at);}catch{}};
25
25
  const failed=reason=>emit(['U',reason]);
26
- emit(['H',{pid:process.pid,ppid:process.ppid,threadId}]);
26
+ // The main script tells the guard which program a process runs (a package
27
+ // manager may start helpers of its own).
28
+ emit(['H',{pid:process.pid,ppid:process.ppid,threadId,main:typeof process.argv[1]==='string'?process.argv[1]:null}]);
27
29
  // Every node_modules observed as installed: the root's and each workspace package's.
28
30
  const sep=path.sep,root=config.root,installed=Array.isArray(config.installed)?config.installed:[path.join(root,'node_modules')],
29
31
  tmp=config.tmp;
30
32
  const within=(file,dir)=>file===dir||file.startsWith(dir+sep);
33
+ let installedReal=null;
34
+ const realInstalled=()=>installedReal??=installed.map(dir=>realDir(dir));
31
35
  const digest=value=>createHash('sha256').update(value).digest('hex');
32
36
  // Directory realpaths, so a checkout path spelled through a link is still
33
37
  // classified as the checkout.
@@ -44,7 +48,8 @@ function install(){
44
48
  // The absolute path an argument names; undefined for a descriptor, whose
45
49
  // open was recorded; null for anything else.
46
50
  function located(file){
47
- if(typeof file==='number')return undefined;
51
+ // A descriptor or a FileHandle: its open was recorded.
52
+ if(typeof file==='number'||file&&typeof file==='object'&&typeof file.fd==='number'&&typeof file.close==='function')return undefined;
48
53
  if(Buffer.isBuffer(file))file=file.toString();
49
54
  else if(file instanceof URL){if(file.protocol!=='file:')return null;file=require('node:url').fileURLToPath(file);}
50
55
  if(typeof file!=='string')return null;
@@ -75,18 +80,45 @@ function install(){
75
80
  if(!produced.size)return false;
76
81
  for(let at=full;;at=path.dirname(at)){if(produced.has(at))return true;if(path.dirname(at)===at)return false;}
77
82
  }
78
- const runnerData=config.runnerData||[];
79
- const runnerCache=full=>tmp.some(dir=>within(full,dir)&&full.slice(dir.length+1).split(sep)[0].startsWith('jest_'))||
83
+ // Runner data and the toolchain's own files (a package manager's install
84
+ // and logs) are not inputs.
85
+ const runnerData=[...(config.runnerData||[]),...(Array.isArray(config.toolchain)?config.toolchain:[])];
86
+ // Runner caches in the temporary directory, keyed by content: Jest's,
87
+ // Yarn 1's compile cache, tsx's, jiti's, ts-node's and Babel's.
88
+ const tmpCache=/^(?:jest_|v8-compile-cache|tsx-\d+$|jiti$|node-jiti$|ts-node|babel)/;
89
+ const runnerCache=full=>tmp.some(dir=>within(full,dir)&&tmpCache.test(full.slice(dir.length+1).split(sep)[0]))||
80
90
  runnerData.some(dir=>within(full,dir));
91
+ // A read of the process's own executable through /proc (detect-libc reads
92
+ // its ELF header there), when the process runs the Node on PATH, which the
93
+ // environment identifies by its file.
94
+ const ownExePath=process.platform==='linux'?`/proc/${process.pid}/exe`:null;
95
+ let ownExe;
96
+ const ownExecutable=(role,full)=>{
97
+ if(role!=='read'||full!==ownExePath)return false;
98
+ if(ownExe===undefined)try{ownExe=typeof config.node==='string'&&original.real(full)===config.node;}catch{ownExe=false;}
99
+ return ownExe;
100
+ };
81
101
  // One filesystem access by its role: read (content), produce, touch (a write
82
102
  // that keeps earlier content), mkdir, mkdtemp, list, stat or realpath.
83
103
  function access(role,file,error=null,result=undefined,recursive=false){
84
104
  try{
85
- const full=located(file);
105
+ let full=located(file);
86
106
  if(full===undefined)return;
87
107
  if(full===null){if(!['produce','touch','mkdir'].includes(role))failed(`${role} of an unsupported path argument`);return;}
88
- // Installed files are observed by identity.
89
- if(installed.some(dir=>within(full,dir)))return;
108
+ // Installed files are observed by identity. A path under node_modules
109
+ // that a link leads out of the installed tree (a workspace package's
110
+ // link) is classified where it leads.
111
+ if(installed.some(dir=>within(full,dir))){
112
+ // A listing, lookup or realpath of the link itself follows it.
113
+ const real=['list','stat','realpath'].includes(role)?realDir(full):path.join(realDir(path.dirname(full)),path.basename(full));
114
+ // A write there is noted apart from checkout outputs: the guard does
115
+ // not see reads of installed files, so only named tool files use it.
116
+ if(real===full||realInstalled().some(dir=>within(real,dir))){
117
+ if(role==='produce'&&!error&&within(full,root))once(['I',rel(full)]);
118
+ return;
119
+ }
120
+ full=real;
121
+ }
90
122
  if(role==='produce'){if(!error)produced.add(full);if(!error&&within(full,root))once(['P',rel(full)]);return;}
91
123
  // A directory the run created held nothing before it, so everything in
92
124
  // it is the run's output, whichever process reads it.
@@ -105,7 +137,7 @@ function install(){
105
137
  else if(role==='list')emit(['D',rel(full),error?missing(error):entries(full)]);
106
138
  return;
107
139
  }
108
- if(runnerCache(full))return;
140
+ if(runnerCache(full)||ownExecutable(role,full))return;
109
141
  // A small file read outside the checkout is kept by its content, as
110
142
  // queryAnswer's readFile hashes it, so status can ask again.
111
143
  if(role==='read'&&!error){
@@ -212,7 +244,8 @@ function install(){
212
244
  envSeen.add(key);emit(['E',key,caller[key]]);
213
245
  }catch{failed('recorder failure');}
214
246
  }
215
- const recordingEnv=source=>new Proxy(source,{
247
+ const targets=new WeakMap();
248
+ const recordingEnv=source=>{const proxy=new Proxy(source,{
216
249
  ownKeys(t){const keys=Reflect.ownKeys(t),current={keys:new Set(keys),last:null,copy:nodeInternal()};enumeration=current;
217
250
  queueMicrotask(()=>{if(enumeration===current)enumeration=null;});return keys;},
218
251
  getOwnPropertyDescriptor(t,key){
@@ -225,7 +258,10 @@ function install(){
225
258
  return Reflect.get(t,key);},
226
259
  has(t,key){if(enumeration)enumeration.last=null;envRead(t,key);return Reflect.has(t,key);},
227
260
  set(t,key,value){t[key]=value;return true;},
228
- });
261
+ });targets.set(proxy,source);return proxy;};
262
+ // PATH as a child receives it, read without recording a read.
263
+ const pathOf=env=>{try{const value=Reflect.get(targets.get(env)??env??{},'PATH');return typeof value==='string'?value:null;}
264
+ catch{return null;}};
229
265
  function wrapEnv(holder){
230
266
  let current=recordingEnv(holder.env);
231
267
  // Code that replaces the environment object keeps being recorded.
@@ -289,17 +325,63 @@ function install(){
289
325
  }
290
326
  // Child processes and threads must load this recorder too; the guard checks
291
327
  // for their headers. Children of execSync/execFileSync report no PID, so
292
- // they are counted instead.
328
+ // they are counted instead. Each row names the program and its arguments
329
+ // as Node's spawn normalizes them (a `shell` option runs /bin/sh -c and the
330
+ // joined command), the working directory and PATH, so the guard can tell
331
+ // which program a name ran and read what a shell was asked to run.
332
+ // Arguments over 64 KB in all are left out (null).
333
+ const argsOf=args=>{try{const list=Array.isArray(args)?args.map(String):[];
334
+ return list.reduce((n,arg)=>n+arg.length,0)>65536?null:list;}catch{return null;}};
335
+ const normalized=(file,args,options,shell)=>{
336
+ if(!Array.isArray(args)){options=args;args=[];}
337
+ shell=options?.shell??shell;
338
+ if(!shell)return [String(file),argsOf(args)];
339
+ return [typeof shell==='string'?shell:'/bin/sh',['-c',[file,...args].join(' ')]];
340
+ };
341
+ // What the child's environment says: whether NODE_OPTIONS still loads this
342
+ // recorder for the same run (npm's and pnpm's `node-options` setting
343
+ // replaces it), and whether BASH_ENV is set (bash -c sources it).
344
+ const envFacts=lookupVar=>{
345
+ try{const options=lookupVar('NODE_OPTIONS');
346
+ return {preload:typeof options==='string'&&options.includes(__filename)&&lookupVar('REDUE_RUN_RECORD')===configFile,
347
+ bashEnv:Boolean(lookupVar('BASH_ENV'))};}
348
+ catch{return {preload:false,bashEnv:true};}
349
+ };
350
+ const envOf=env=>envFacts(name=>{const value=Reflect.get(targets.get(env)??env??{},name);return typeof value==='string'?value:undefined;});
351
+ const pairsOf=pairs=>envFacts(name=>{const pair=(pairs||[]).find(item=>typeof item==='string'&&item.startsWith(name+'='));
352
+ return pair?pair.slice(name.length+1):undefined;});
353
+ const where=options=>[path.resolve(typeof options?.cwd==='string'?options.cwd:options?.cwd instanceof URL?
354
+ require('node:url').fileURLToPath(options.cwd):'.'),pathOf(options?.env??process.env),envOf(options?.env??process.env)];
355
+ const optionsOf=(args,options)=>Array.isArray(args)?options:args;
356
+ // A #! script the child runs, as it is now: Yarn writes its bin wrappers
357
+ // into a temporary directory and removes them after the run.
358
+ function scriptOf(file,cwd,searchPath){
359
+ try{
360
+ const candidates=file.includes(sep)?[path.resolve(cwd,file)]:String(searchPath??'').split(path.delimiter).map(dir=>path.join(path.resolve(cwd,dir||'.'),file));
361
+ for(const candidate of candidates){
362
+ let st;try{st=original.statSync(candidate);}catch{continue;}
363
+ if(!st.isFile()||!(st.mode&0o111))continue;
364
+ if(st.size>16384)return null;
365
+ const text=original.readFileSync(candidate,'utf8');
366
+ return text.startsWith('#!')?{file:original.real(candidate),text}:null;
367
+ }
368
+ }catch{}
369
+ return null;
370
+ }
293
371
  const childProcess=require('node:child_process'),spawn=childProcess.ChildProcess.prototype.spawn;
294
372
  childProcess.ChildProcess.prototype.spawn=function(options){
295
373
  const result=spawn.call(this,options);
296
- try{if(this.pid)emit(['C',this.pid,String(options?.file)]);}catch{failed('recorder failure');}
374
+ try{if(this.pid){const pair=(options?.envPairs||[]).find(item=>typeof item==='string'&&item.startsWith('PATH='));
375
+ emit(['C',this.pid,String(options?.file),argsOf(options?.args?.slice(1)),where(options)[0],pair?pair.slice(5):null,
376
+ pairsOf(options?.envPairs),scriptOf(String(options?.file),where(options)[0],pair?pair.slice(5):null)]);}}
377
+ catch{failed('recorder failure');}
297
378
  return result;
298
379
  };
299
380
  const spawnSync=childProcess.spawnSync;
300
381
  childProcess.spawnSync=function(file,...rest){
301
382
  const result=spawnSync.call(this,file,...rest);
302
- try{if(result?.pid)emit(['C',result.pid,String(file)]);}catch{failed('recorder failure');}
383
+ try{if(result?.pid)emit(['C',result.pid,...normalized(file,rest[0],rest[1]),...where(optionsOf(rest[0],rest[1]))]);}
384
+ catch{failed('recorder failure');}
303
385
  return result;
304
386
  };
305
387
  for(const name of ['execSync','execFileSync']){
@@ -308,12 +390,17 @@ function install(){
308
390
  let started=true;
309
391
  try{return call.call(this,file,...rest);}
310
392
  catch(error){if(error?.code==='ENOENT'&&!error.pid)started=false;throw error;}
311
- finally{if(started)emit(['S',String(file)]);}
393
+ finally{if(started)try{emit(['S',...name==='execSync'?normalized(file,[],rest[0],true):normalized(file,rest[0],rest[1]),
394
+ ...where(name==='execSync'?rest[0]:optionsOf(rest[0],rest[1]))]);}
395
+ catch{failed('recorder failure');}}
312
396
  };
313
397
  }
314
398
  const workers=require('node:worker_threads'),Worker=workers.Worker;
315
399
  workers.Worker=class extends Worker{
316
400
  constructor(...args){super(...args);try{emit(['T',this.threadId]);}catch{failed('recorder failure');}}
317
401
  };
402
+ // Node reads an environment file natively; this is the one JavaScript entry to it.
403
+ if(typeof process.loadEnvFile==='function'){const load=process.loadEnvFile;
404
+ process.loadEnvFile=function(file='.env',...rest){access('read',file);return load.call(this,file,...rest);};}
318
405
  try{Module.syncBuiltinESMExports();}catch{failed('recorder failure');}
319
406
  }