@redue/cli 0.1.0-alpha.17 → 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.
@@ -10,11 +10,13 @@ import path from 'node:path';
10
10
  import {createHash} from 'node:crypto';
11
11
  import {fileURLToPath} from 'node:url';
12
12
  import {spawnSync} from 'node:child_process';
13
- import micromatch from './glob.mjs';
13
+ import {patternMatcher} from './glob.mjs';
14
14
  import {runFact} from './direct-run-certificate.mjs';
15
15
  import {queryAnswer} from './typescript-list.mjs';
16
- import {pnpmInstallInfo,pnpmProjectInfo} from './pnpm-inputs.mjs';
16
+ import {pnpmInstallInfo,pnpmProjectInfo,yaml} from './pnpm-inputs.mjs';
17
17
  import {workspaceManifests} from './workspace-manifests.mjs';
18
+ import {atomicJson} from './state-store.mjs';
19
+ import {racyUntil} from './path-identity.mjs';
18
20
 
19
21
  const digest=value=>createHash('sha256').update(value).digest('hex');
20
22
  export const testRecorder=fileURLToPath(new URL('test-recorder.cjs',import.meta.url));
@@ -59,15 +61,17 @@ export function recordedEnvironment(guardFile,env=process.env) {
59
61
  // input (relative path) at the start checkpoint to its digest; `runner` is the
60
62
  // PID of the process REDUE started. Returns {issues, runFacts, environmentReads,
61
63
  // isOutput}: environmentReads maps each variable the run read directly to the
62
- // caller's value digest, or '<unset>'.
63
- 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}) {
64
70
  // The recipe's variables and any the project declares are compared, not guarded.
65
71
  const compared=name=>(environment.variables||[]).includes(name)||
66
72
  (environment.prefixes||[]).some(prefix=>name.toLowerCase().startsWith(prefix.toLowerCase()));
67
73
  const issues=[],add=issue=>{if(!issues.includes(issue))issues.push(issue);};
68
- const positive=sourcePatterns.filter(p=>!p.startsWith('!')).map(p=>micromatch.matcher(p,{dot:true}));
69
- const negative=sourcePatterns.filter(p=>p.startsWith('!')).map(p=>micromatch.matcher(p.slice(1),{dot:true}));
70
- const included=rel=>positive.some(fn=>fn(rel))&&!negative.some(fn=>fn(rel));
74
+ const included=patternMatcher(sourcePatterns);
71
75
  const observed=rel=>Object.hasOwn(files||{},rel);
72
76
  const headers=new Map(),records=[];
73
77
  let names=[];
@@ -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){
@@ -336,21 +369,70 @@ export function testWorkspaceDirs(root,pkg){
336
369
  // Each node_modules the recipe observes as installed: the root's and every
337
370
  // workspace package's, relative to the root.
338
371
  const modulesDirs=({dirs=[]})=>['node_modules',...dirs.map(dir=>`${dir}/node_modules`)];
339
- // The first installed file with more than one link, or null. A package store
340
- // outside the checkout (pnpm's default on Linux, Yarn's hardlinks modes) can
341
- // change such a file with no event here. Checked at every recorded run: a later
342
- // install that adds links replaces the files, which is a change REDUE sees.
343
- export function sharedInstalledFile(root,project={}){
372
+ // pnpm's content-addressed store, as the install recorded it in .modules.yaml:
373
+ // files/<first 2 hex>/<rest of the content's sha512 hex>, "-exec" for
374
+ // executables. pnpm writes new store files and never changes one in place.
375
+ function pnpmStoreFiles(root){
376
+ try{const {storeDir}=yaml(path.join(root,'node_modules','.modules.yaml'));
377
+ return typeof storeDir==='string'&&path.isAbsolute(storeDir)?path.join(storeDir,'files'):null;}
378
+ catch{return null;}
379
+ }
380
+ const identity=st=>`${st.size}:${st.mtimeNs}:${st.ctimeNs}`;
381
+ const sameFile=(file,st)=>{
382
+ try{const other=fs.statSync(file,{bigint:true});return other.dev===st.dev&&other.ino===st.ino;}
383
+ catch{return false;}};
384
+ // The file's store path relative to `files`, or null when the store does not
385
+ // hold this file at its content's address.
386
+ function storePath(files,file,st){
387
+ const hash=createHash('sha512'),buffer=Buffer.alloc(1<<20),fd=fs.openSync(file,'r');
388
+ try{for(let n;(n=fs.readSync(fd,buffer,0,buffer.length,null))>0;)hash.update(buffer.subarray(0,n));}
389
+ finally{fs.closeSync(fd);}
390
+ const hex=hash.digest('hex'),at=path.join(hex.slice(0,2),hex.slice(2));
391
+ return [at,at+'-exec'].find(candidate=>sameFile(path.join(files,candidate),st))??null;
392
+ }
393
+ // The first installed file with more than one link, as {file, reason}, or
394
+ // null. A package store outside the checkout (pnpm's default on Linux, Yarn's
395
+ // hardlinks modes) can change such a file with no event here. A link into
396
+ // pnpm's own store is accepted: the store's file is named by its content.
397
+ // Checked at every recorded run: a later install that adds links replaces the
398
+ // files, which is a change REDUE sees. `cache` (in owned state) keeps each
399
+ // file found in the store with its identity and store path, so an unchanged
400
+ // file is not hashed again; its store link is still checked.
401
+ export function sharedInstalledFile(root,project={},cache=null){
344
402
  const stack=modulesDirs(project).map(dir=>path.join(root,dir));
403
+ const files=pnpmStoreFiles(root),now=Date.now();
404
+ let known={},changed=false;
405
+ if(files&&cache)try{const value=JSON.parse(fs.readFileSync(cache,'utf8'));
406
+ if(value?.files===files&&value.entries&&typeof value.entries==='object')known=value.entries;}catch{}
407
+ const found={};
408
+ const shared=(file,reason)=>({file:path.relative(root,file).split(path.sep).join('/'),reason});
345
409
  while(stack.length){
346
410
  const dir=stack.pop();let entries;
347
411
  try{entries=fs.readdirSync(dir,{withFileTypes:true});}catch{continue;}
348
412
  for(const entry of entries){
349
413
  const file=path.join(dir,entry.name);
350
- if(entry.isDirectory())stack.push(file);
351
- else if(entry.isFile())try{if(fs.lstatSync(file).nlink>1)return path.relative(root,file).split(path.sep).join('/');}catch{}
414
+ if(entry.isDirectory()){stack.push(file);continue;}
415
+ if(!entry.isFile())continue;
416
+ let st;try{st=fs.lstatSync(file,{bigint:true});}catch{continue;}
417
+ if(st.nlink<=1n)continue;
418
+ if(!files)return shared(file,'has another hard link, and the install records no pnpm store');
419
+ const key=`${st.dev}:${st.ino}`,cached=known[key];
420
+ let at=Array.isArray(cached)&&cached[0]===identity(st)&&typeof cached[1]==='string'&&
421
+ sameFile(path.join(files,cached[1]),st)?cached[1]:null;
422
+ if(!at){
423
+ try{at=storePath(files,file,st);}
424
+ catch(e){return shared(file,`could not be checked against pnpm's store (${e.code||e.message})`);}
425
+ if(!at)return shared(file,"has another hard link outside pnpm's store");
426
+ changed=true;
427
+ }
428
+ // A file changed within the racy window could change again unseen in
429
+ // the same timestamp tick; it is hashed again next time.
430
+ if(racyUntil(st)<=now)found[key]=[identity(st),at];
431
+ else changed=true;
352
432
  }
353
433
  }
434
+ if(files&&cache&&(changed||Object.keys(found).length!==Object.keys(known).length))
435
+ try{atomicJson(cache,{files,entries:found});}catch{}
354
436
  return null;
355
437
  }
356
438
  // The runner a package's script reaches: Node's lookup from the package
@@ -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
  }
@@ -9,7 +9,7 @@ import {timing} from './decision-profile.mjs';
9
9
  const require=createRequire(import.meta.url),hash=value=>createHash('sha256').update(value).digest('hex');
10
10
  const digestFile=file=>hash(fs.readFileSync(file));
11
11
  const fail=code=>{throw Object.assign(Error(code),{code});};
12
- export function typeScriptInputFacts(root,tsRoot,tsc){
12
+ export function typeScriptInputFacts(root,tsRoot,tsc,config='tsconfig.json'){
13
13
  const configReads=new Set(),visited=new Set(),referenced=[];
14
14
  let buildInfo=null;
15
15
  let ts;try{ts=require(tsRoot);}catch{}
@@ -37,15 +37,15 @@ export function typeScriptInputFacts(root,tsRoot,tsc){
37
37
  parseConfig(reference.path.endsWith('.json')?reference.path:
38
38
  path.join(reference.path,'tsconfig.json'));
39
39
  };
40
- parseConfig(path.join(root,'tsconfig.json'),true);
40
+ parseConfig(path.join(root,config),true);
41
41
  }else{
42
42
  // TypeScript 7's native compiler does not expose the previous config API.
43
43
  // Its CLI reports the effective config; restrict this path to one direct
44
44
  // config rather than guessing about extends/reference provenance.
45
- const config=path.join(root,'tsconfig.json'),raw=fs.readFileSync(config,'utf8');
45
+ const file=path.join(root,config),raw=fs.readFileSync(file,'utf8');
46
46
  if(/"(?:extends|references|plugins|incremental|composite)"\s*:/.test(raw))
47
47
  fail('typescript-config-graph-unsupported');
48
- const shown=spawnSync(process.execPath,[tsc,'--showConfig'],{cwd:root,
48
+ const shown=spawnSync(process.execPath,[tsc,'--showConfig','-p',config],{cwd:root,
49
49
  env:process.env,encoding:'utf8',timeout:probeTimeLeft(4000),maxBuffer:16*1024*1024,
50
50
  stdio:['ignore','pipe','pipe']});
51
51
  if(shown.error||shown.signal||shown.status!==0)fail('typescript-config-unavailable');
@@ -53,13 +53,13 @@ export function typeScriptInputFacts(root,tsRoot,tsc){
53
53
  if(effective.compilerOptions?.plugins?.length||
54
54
  effective.compilerOptions?.incremental||effective.compilerOptions?.composite)
55
55
  fail('typescript-config-graph-unsupported');
56
- configReads.add(config);
56
+ configReads.add(file);
57
57
  }
58
58
  for(const file of configReads)if(!within(realObservedPath(file),root))
59
59
  fail('typescript-config-outside-repository');
60
60
  const listingStarted=performance.now();
61
- const captured=process.argv.includes('--redue-checkpoint')?compilerFiles(tsRoot,root):null;
62
- const listed=captured?{status:0,stdout:captured.files.join('\n')}:spawnSync(process.execPath,[tsc,'--noEmit','--listFilesOnly'],
61
+ const captured=process.argv.includes('--redue-checkpoint')?compilerFiles(tsRoot,root,config):null;
62
+ const listed=captured?{status:0,stdout:captured.files.join('\n')}:spawnSync(process.execPath,[tsc,'--noEmit','--listFilesOnly','-p',config],
63
63
  {cwd:root,env:process.env,encoding:'utf8',timeout:probeTimeLeft(process.platform==='win32'?10000:4000),
64
64
  maxBuffer:16*1024*1024,
65
65
  stdio:['ignore','pipe','pipe']});
@@ -0,0 +1,92 @@
1
+ // The package-script forms of a standalone TypeScript check: one `tsc` run, or
2
+ // several joined by `&&`, each with these arguments in any order:
3
+ // --noEmit, -p/--project PATH, --skipLibCheck, --pretty [true|false].
4
+ // Anything else (tsc -b, --watch, other options, other commands) is not one.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ // No shell metacharacters; a leading @ would read as a REDUE token.
9
+ const word=/^[A-Za-z0-9_./+-][A-Za-z0-9_./@+-]*$/;
10
+ // More runs than this is not a script REDUE splits into checks.
11
+ const maxRuns=16;
12
+ function parseRun(words){
13
+ if(words[0]!=='tsc'||!words.every(value=>word.test(value)))return null;
14
+ let project=null,noEmit=false;
15
+ for(let i=1;i<words.length;i++){
16
+ const value=words[i];
17
+ if(value==='-p'||value==='--project'){
18
+ if(project!==null||!words[i+1]||words[i+1].startsWith('-'))return null;
19
+ project=words[++i];
20
+ }else if(value==='--noEmit')noEmit=true;
21
+ else if(value==='--pretty'){if(['true','false'].includes(words[i+1]))i++;}
22
+ else if(value!=='--skipLibCheck')return null;
23
+ }
24
+ return {args:words.slice(1),project,noEmit};
25
+ }
26
+ // The runs of a script, as [{args, project, noEmit}], or null.
27
+ export function tscRuns(text){
28
+ if(typeof text!=='string'||!text.trim())return null;
29
+ const parts=text.trim().split(/[ \t]+&&[ \t]+/);
30
+ if(parts.length>maxRuns)return null;
31
+ const runs=parts.map(part=>parseRun(part.split(/[ \t]+/)));
32
+ return runs.every(Boolean)?runs:null;
33
+ }
34
+ // The run a check makes: its `script` (null for a check init proposed without
35
+ // one), the tsc arguments after the compiler in its command, and `count`, the
36
+ // number of configured checks for that script (more than one for a chain).
37
+ // Returns {run} with the parsed run, or {issue} when the arguments are outside
38
+ // the forms, or the script no longer has exactly `count` runs, one of them
39
+ // with exactly these arguments.
40
+ export function tscRun(scripts,{script=null,args,count=1}){
41
+ if(!Array.isArray(args)||!args.every(value=>typeof value==='string'))return {issue:'TypeScript arguments are invalid'};
42
+ const own=parseRun(['tsc',...args]);
43
+ if(!own)return {issue:'TypeScript arguments are outside the supported forms'};
44
+ if(script===null)return {run:own};
45
+ if(typeof scripts?.[script]!=='string')return {issue:'typecheck script was removed'};
46
+ if(scripts['pre'+script]||scripts['post'+script])return {issue:'typecheck script has pre or post scripts'};
47
+ const runs=tscRuns(scripts[script]);
48
+ if(runs?.length!==count||!runs.some(run=>JSON.stringify(run.args)===JSON.stringify(args)))
49
+ return {issue:'typecheck script changed'};
50
+ return {run:own};
51
+ }
52
+ // The config file a run names, relative to the directory `dir` it runs in
53
+ // (posix): tsconfig.json without -p, a directory's tsconfig.json, or null
54
+ // when it leaves that directory or the arguments are not a run.
55
+ export function runConfig(dir,args){
56
+ const run=parseRun(['tsc',...args]);
57
+ if(!run)return null;
58
+ let file=path.posix.normalize((run.project??'tsconfig.json').split(path.sep).join('/'));
59
+ let directory=false;try{directory=fs.statSync(path.join(dir,file)).isDirectory();}catch{}
60
+ if(!file.endsWith('.json')||directory)file=path.posix.join(file,'tsconfig.json');
61
+ if(path.posix.isAbsolute(file)||file==='..'||file.startsWith('../'))return null;
62
+ return file;
63
+ }
64
+ // The check names for a script's runs: the kind's name for one run, and
65
+ // NAME.1 ... NAME.n for a chain (not #: the hook refuses it and zsh may glob it).
66
+ export const partName=(name,index,count)=>count===1?name:`${name}.${index}`;
67
+ // Probe arguments for a run, and back: SCRIPT (or -), COUNT, then the tsc
68
+ // arguments.
69
+ export const runArgv=({script=null,args,count=1})=>[script??'-',String(count),...args];
70
+ export function runFromArgv(values){
71
+ const [script,count,...args]=values;
72
+ if(typeof script!=='string'||!script||!/^[1-9]\d?$/.test(count||''))throw Object.assign(Error('probe-arguments'),{code:'probe-arguments'});
73
+ return {script:script==='-'?null:script,args,count:Number(count)};
74
+ }
75
+ // The run a configured check makes: its script, the arguments after the
76
+ // compiler in its command, and how many checks the config has for the script
77
+ // (`runCount`, set when the config is loaded).
78
+ export const checkRun=check=>({script:check.script??null,
79
+ args:Array.isArray(check.command)?check.command.slice(2):[],count:check.runCount??1});
80
+ // A script's checks in the order of its runs: one check per run with the same
81
+ // arguments ({name, args} each), as names; null when they do not correspond.
82
+ export function scriptRunChecks(text,checks){
83
+ const runs=tscRuns(text);
84
+ if(!runs||runs.length!==checks.length)return null;
85
+ const left=[...checks],names=[];
86
+ for(const run of runs){
87
+ const at=left.findIndex(check=>JSON.stringify(check.args)===JSON.stringify(run.args));
88
+ if(at<0)return null;
89
+ names.push(left.splice(at,1)[0].name);
90
+ }
91
+ return names;
92
+ }
@@ -63,7 +63,7 @@ export function queriesMatch(queries) {
63
63
  return queries.every(([kind,file,answer,options])=>
64
64
  JSON.stringify(queryAnswer(kind,file,options))===JSON.stringify(answer));
65
65
  }
66
- export function compilerFiles(tsRoot,cwd) {
66
+ export function compilerFiles(tsRoot,cwd,config='tsconfig.json') {
67
67
  const require=createRequire(import.meta.url),ts=require(tsRoot);
68
68
  if(typeof ts.createProgram!=='function'||typeof ts.createCompilerHost!=='function')return null;
69
69
  const queries=new Map();
@@ -114,7 +114,7 @@ export function compilerFiles(tsRoot,cwd) {
114
114
  };
115
115
  const realpath=file=>{remember('realpath',file);return ts.sys.realpath(file);};
116
116
  // `extends` of a package config resolves through realpath too.
117
- const parsed=ts.getParsedCommandLineOfConfigFile(path.join(cwd,'tsconfig.json'),{},
117
+ const parsed=ts.getParsedCommandLineOfConfigFile(path.join(cwd,config),{},
118
118
  {...ts.sys,readFile,fileExists,directoryExists,getDirectories,readDirectory,realpath,
119
119
  onUnRecoverableConfigFileDiagnostic(){}});
120
120
  if(!parsed||parsed.errors?.length)throw Error('TypeScript configuration unavailable');
@@ -2,6 +2,7 @@ import {directProject,directQualification,directEnvironment} from './direct-type
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import {realObservedPath} from './path-identity.mjs';
5
+ import {checkRun,runArgv} from './typescript-invocation.mjs';
5
6
 
6
7
  // This is a machine-enforced contract, not a claim inferred from a script name.
7
8
  // Runtime validation checks effective compiler membership, configuration and
@@ -11,10 +12,9 @@ export function qualifyTypeScript(root,selected,discovered={}) {
11
12
  if(selected.qualification===directQualification){
12
13
  const issues=[];
13
14
  try{
14
- const compiler=directProject(root,selected.script);
15
- if(selected.command?.length!==3||selected.command[0]!==process.execPath||
16
- selected.command[1]!==compiler.entry||selected.command[2]!=='--noEmit'||
17
- selected.cwd&&selected.cwd!=='.')issues.push('direct compiler invocation changed');
15
+ const compiler=directProject(root,checkRun(selected));
16
+ if(selected.command?.[0]!==process.execPath||
17
+ selected.command[1]!==compiler.entry||selected.cwd&&selected.cwd!=='.')issues.push('direct compiler invocation changed');
18
18
  }catch(e){issues.push(e.code||'direct compiler prerequisites unavailable');}
19
19
  if(!selected.inputs?.includes('**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json,jsonc}')||
20
20
  !selected.inputs?.includes('package.json')||!selected.inputs?.includes('tsconfig.json')||
@@ -24,23 +24,22 @@ export function qualifyTypeScript(root,selected,discovered={}) {
24
24
  !directEnvironment.prefixes.every(key=>selected.environment?.prefixes?.includes(key)))
25
25
  issues.push('direct compiler execution context incomplete');
26
26
  if(selected.probes?.length!==1||!selected.probes?.some(argv=>path.basename(argv[1]||'')==='direct-typescript-probe.mjs'&&
27
- argv[0]===process.execPath&&argv[2]===root&&argv[3]===selected.script))issues.push('direct compiler probe missing');
27
+ argv[0]===process.execPath&&argv[2]===root&&
28
+ JSON.stringify(argv.slice(3))===JSON.stringify(runArgv(checkRun(selected)))))issues.push('direct compiler probe missing');
28
29
  return {qualified:issues.length===0,issues};
29
30
  }
30
31
  if(selected.qualification==='pnpm-tsc-v1'){
31
32
  const issues=[];
32
33
  const workspace=selected.workspace||'.';
33
- const script=selected.script||selected.command?.at(-1);
34
34
  try{
35
35
  if(discovered.issue)throw Error(discovered.issue);
36
36
  const contract=discovered.workspace;
37
37
  if(!contract)throw Error('pnpm TypeScript input discovery unavailable');
38
38
  issues.push(...contract.limitations);
39
- const declared=contract.workspace.pkg.scripts[script].trim().split(/\s+/).slice(1);
39
+ // The arguments are checked against the script by the contract itself.
40
40
  if(path.resolve(root,selected.cwd||'.')!==path.join(root,contract.workspace.dir)||
41
41
  selected.command?.[0]!==process.execPath||
42
- selected.command?.[1]!==contract.tsc||
43
- JSON.stringify(selected.command.slice(2))!==JSON.stringify(declared))
42
+ selected.command?.[1]!==contract.tsc)
44
43
  issues.push('standalone TypeScript invocation changed');
45
44
  }catch(e){issues.push(e.message);}
46
45
  if(!selected.probes?.some(argv=>path.basename(argv[1]||'')==='pnpm-typecheck-probe.mjs'))
@@ -3,7 +3,7 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import {spawnSync} from 'node:child_process';
6
- import micromatch from './glob.mjs';
6
+ import {patternMatcher} from './glob.mjs';
7
7
 
8
8
  export function workspaceManifests(root,patterns) {
9
9
  const listing=spawnSync('git',['ls-files','--cached','--others','--exclude-standard',
@@ -12,11 +12,10 @@ export function workspaceManifests(root,patterns) {
12
12
  env:{...process.env,GIT_OPTIONAL_LOCKS:'0'}});
13
13
  if(listing.status!==0||listing.error)throw Error('workspace package manifests cannot be inspected');
14
14
  // `!pattern` entries exclude, as in pnpm-workspace.yaml.
15
- const positive=patterns.filter(p=>!p.startsWith('!')),negative=patterns.filter(p=>p.startsWith('!')).map(p=>p.slice(1));
15
+ const included=patternMatcher(patterns);
16
16
  const matched=listing.stdout.split('\0').filter(Boolean).filter(file=>{
17
17
  const dir=path.posix.dirname(file);
18
- return dir!=='.'&&!dir.split('/').includes('node_modules')&&
19
- micromatch.isMatch(dir,positive,{dot:true})&&!(negative.length&&micromatch.isMatch(dir,negative,{dot:true}));
18
+ return dir!=='.'&&!dir.split('/').includes('node_modules')&&included(dir);
20
19
  }).sort();
21
20
  if(matched.length>50)throw Error('more than 50 workspace packages found; select checks explicitly');
22
21
  return matched.map(file=>({file,pkg:JSON.parse(fs.readFileSync(path.join(root,file),'utf8'))}));