@redue/cli 0.1.0-alpha.21 → 0.1.0-alpha.22

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.22 — 2026-10-25
4
+
5
+ A correctness fix for recorded runs. Earlier alphas could report CURRENT for
6
+ a script that replaced itself with another program.
7
+
8
+ - **Fixed: a script that replaced itself with another program could be
9
+ CURRENT.** Node's `process.execve` (Node 22.15 and later) swaps a process
10
+ for another program under the same PID. The recorder did not see it, so
11
+ that program ran unseen. TypeScript 7's `tsc` does this to start its
12
+ native compiler, so alphas 19 to 21 could report a TypeScript 7 typecheck
13
+ as CURRENT while REDUE saw none of what the compiler did. The general
14
+ recipe and the direct test recipe now judge the new program like any
15
+ program a run starts: TypeScript 7's `tsc` makes the run UNVERIFIED and
16
+ names it. Support for TypeScript 7 is planned.
17
+ - Runs that use Node's bindings with file, network or program access
18
+ (`process.binding('fs')`, `'spawn_sync'` and others), or run Node with
19
+ `--expose-internals`, are UNVERIFIED: the recorder cannot follow them.
20
+ - Direct test recipe: a shell a test starts is read by its command, as in the
21
+ general recipe. One that runs another program before it becomes Node makes
22
+ the run UNVERIFIED.
23
+ - General recipe, on Linux: pnpm 12 writes a file with no name in
24
+ `node_modules` when it rewrites its workspace state, which left the first
25
+ run after an install UNVERIFIED. That file now counts as the run's output
26
+ when the pnpm executable ran and no file has its name. Known limit: another
27
+ program's file with no name there, during such a run, is taken as pnpm's.
28
+ - UNVERIFIED reasons for an input that changed during a run name up to five
29
+ files and a count of the rest.
30
+
31
+ - Contributors: the census traces each run on Linux and flags programs the
32
+ recorder missed.
33
+
3
34
  ## 0.1.0-alpha.21 — 2026-10-25
4
35
 
5
36
  `init` finds more typecheck scripts and no longer stops in large monorepos.
@@ -69,12 +69,16 @@ anywhere in it.
69
69
 
70
70
  Two installed files are rewritten by tools during a run. pnpm rewrites
71
71
  `node_modules/.pnpm-workspace-state-v1.json` at the first run after an
72
- install, through a temporary `node_modules/.tmpXXXXXX` file. vue-tsc writes
72
+ install, through a temporary `node_modules/.tmpXXXXXX` file, and on Linux
73
+ writes a file with no name there (`O_TMPFILE`), whose writes the file
74
+ watcher reports as `node_modules/#NUMBER`. vue-tsc writes
73
75
  `node_modules/.vue-global-types/*.d.ts` before it reads them. These files stay
74
76
  inputs. When they are the only files a run changed, and the run wrote them,
75
77
  the result is kept with the files as the run left them. A recorded process
76
78
  counts as writing a file it wrote; for pnpm's files, the pnpm executable must
77
- have run. A later change to them makes the result STALE.
79
+ have run. A `#NUMBER` event counts only directly in the root `node_modules`,
80
+ on Linux, when no file has that name. A later change to them makes the
81
+ result STALE.
78
82
 
79
83
  It does not mean a rerun would give the same result: a test that depends on
80
84
  the time or on randomness can pass now and fail later on the same inputs.
@@ -107,6 +111,10 @@ one REDUE can account for:
107
111
  content;
108
112
  - `ps -A -o ppid,pid`, which pnpm loaded into Corepack runs.
109
113
 
114
+ A process that replaces itself with another program through
115
+ `process.execve` (TypeScript 7's `tsc` does this to start its native
116
+ compiler) is judged the same way as a program it starts.
117
+
110
118
  Anything else makes the result UNVERIFIED and names the program: `git`,
111
119
  `sleep`, `find`, and native programs from installed packages such as esbuild,
112
120
  turbo, tsgo, tsgolint and biome, whose reads, writes and network use REDUE
@@ -142,11 +150,19 @@ inputs.
142
150
  run (pnpm 12 does this) can be reported by the file watcher as an event on
143
151
  the root itself, an observation gap that makes the run UNVERIFIED. Linux
144
152
  reports the file, which is not an input.
153
+ - On Linux, any program's file with no name in the root `node_modules`,
154
+ written during a run that started the pnpm executable, is taken as pnpm's.
155
+ If that program gives the file a real name (any name, `node_modules`
156
+ included) and then writes through the open file, REDUE misses those
157
+ writes. A real file named `node_modules/#NUMBER` that is created and
158
+ removed within such a run is treated the same way. Outside a pnpm run,
159
+ these writes leave the run UNVERIFIED.
145
160
  - The pnpm executable and the pnpm launchers' helpers are trusted as
146
161
  toolchain: what they read is not checked.
147
162
  - Code that copies the environment and reads the copy, and native code that
148
- reads files or variables (native addons such as Rust-based resolvers), is
149
- not seen.
163
+ reads files or variables or starts programs (native addons such as
164
+ Rust-based resolvers), is not seen. Neither are files opened by
165
+ `node:sqlite` or preopened for `node:wasi`.
150
166
  - A project whose `.npmrc` sets `node-options` stays UNVERIFIED: npm replaces
151
167
  `NODE_OPTIONS` with it, which drops REDUE's recorder.
152
168
  - A script that runs a build first (`npm run build && tsc --noEmit`) usually
@@ -80,7 +80,14 @@ kept but marked UNVERIFIED, with the reason, when the run:
80
80
  - connected to anything other than loopback or a local socket, or asked DNS
81
81
  about a name other than localhost;
82
82
  - started a process that did not load the recorder: a shell, a binary, or a
83
- Node process with its own environment;
83
+ Node process with its own environment. A process that replaces itself with
84
+ another program (`process.execve`) counts as starting it, and it must
85
+ become a recorded Node process. A shell is read by its command, as the
86
+ [general recipe](node-script.md#programs) reads it: one that runs another
87
+ program before it becomes Node counts too;
88
+ - used one of Node's bindings with file, network or program access
89
+ (`process.binding('fs')`, `'spawn_sync'` and others), or ran Node with
90
+ `--expose-internals`: the recorder cannot follow those;
84
91
  - had an installed file with another hard link, other than a link into the
85
92
  pnpm store the install recorded in `node_modules/.modules.yaml` (pnpm's
86
93
  default on Linux). A change through a link elsewhere, such as Yarn's
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@redue/cli",
3
3
  "private": false,
4
- "version": "0.1.0-alpha.21",
4
+ "version": "0.1.0-alpha.22",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"
package/src/cli.mjs CHANGED
@@ -5,7 +5,7 @@ import {directQualification,directInterpretation,observedNpmContext} from './dir
5
5
  import {directRunCertificateAllowed,directRunCertificate,directRunRecordable,directRunRecording,probeQuestions,validateRunRecord} from './direct-run-certificate.mjs';
6
6
  import {queryAnswer,runFactChange} from './typescript-list.mjs';
7
7
  import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
8
- import {childProgram,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,toolOutput,toolchainDirs} from './node-script.mjs';
8
+ import {childProgram,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,pnpmNameless,toolOutput,toolchainDirs} from './node-script.mjs';
9
9
  import path from 'node:path';
10
10
  import net from 'node:net';
11
11
  import os from 'node:os';
@@ -400,7 +400,7 @@ async function execute() {
400
400
  // what was there (test/tmp). When they are the only observed change, the
401
401
  // run is kept with the end checkpoint, which includes them.
402
402
  let outputs=null;
403
- let eventsExplained=false;
403
+ let eventsExplained=false,unexplained=end?.inputEventPaths||null;
404
404
  if(guard&&snap&&end&&snap.planId===end.planId&&snap.probeHash===end.probeHash&&snap.observationHealthy&&end.observationHealthy&&
405
405
  (snap.inputEventSerial===end.inputEventSerial||end.inputEventPathsComplete===true)){
406
406
  // A directory event is not an input change; a change to its files shows
@@ -408,9 +408,10 @@ async function execute() {
408
408
  const directory=rel=>{try{return fs.lstatSync(path.join(root,rel)).isDirectory();}catch{return false;}};
409
409
  const changed=changedFiles(snap.files,end.files),events=(end.inputEventPaths||[]).filter(rel=>!directory(rel));
410
410
  const touched=[...new Set([...changed,...events])].sort();
411
- const output=rel=>guard.isOutput(rel)||general&&toolOutput(rel,guard);
411
+ const output=rel=>guard.isOutput(rel)||general&&(toolOutput(rel,guard)||pnpmNameless(rel,guard,root,[snap.files,end.files]));
412
412
  const ignored=touched.length&&touched.every(output)?gitIgnored(root,touched):new Set();
413
- if(touched.every(file=>ignored.has(file))){eventsExplained=true;if(changed.length)outputs=changed;}
413
+ unexplained=touched.filter(file=>!ignored.has(file));
414
+ if(!unexplained.length){eventsExplained=true;if(changed.length)outputs=changed;}
414
415
  }
415
416
  // The end certificate, recorded or probed, must cover what this compile received.
416
417
  // With no input event, gap or content change in between, it also holds at the start.
@@ -442,8 +443,13 @@ async function execute() {
442
443
  captureIssues.push('observer generation changed during execution');
443
444
  if(snap?.planGeneration!==end?.planGeneration)
444
445
  captureIssues.push('input plan rebuilt during execution');
445
- if(snap?.inputEventSerial!==end?.inputEventSerial&&!eventsExplained)
446
- captureIssues.push('declared input event occurred during execution');
446
+ // The reason names up to five files the run did not explain, and a count of the rest.
447
+ if(snap?.inputEventSerial!==end?.inputEventSerial&&!eventsExplained){
448
+ const named=[...new Set(unexplained||[])],more=named.length-5,
449
+ unrecorded=end?.inputEventPathsComplete===false?' (not every event path was kept)':'';
450
+ captureIssues.push('declared input event occurred during execution'+(named.length?
451
+ `: ${named.slice(0,5).join(', ')}${more>0?` and ${more} more`:''}`:'')+unrecorded);
452
+ }
447
453
  // A recorded certificate changes only the probe part of the fingerprint.
448
454
  if(!outputs&&(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
449
455
  snap.revision!==end.revision))captureIssues.push('declared inputs changed or were not observed');
@@ -57,6 +57,15 @@ export const toolOutputPath=rel=>pnpmState(rel)||pnpmTemp(rel)||vueTypes(rel);
57
57
  // The run wrote it: a recorded process did, or, for pnpm's files, the pnpm
58
58
  // executable ran, which writes them natively.
59
59
  export const toolOutput=(rel,guard)=>toolOutputPath(rel)&&(guard.wroteInstalled(rel)||(pnpmState(rel)||pnpmTemp(rel))&&guard.opaque);
60
+ // On Linux, pnpm 12 also writes a file with no name (O_TMPFILE) in the root
61
+ // node_modules, whose writes inotify reports as '#<inode>'. Such an event is
62
+ // the run's output when the pnpm executable ran and no file has that name, at
63
+ // either checkpoint or now. Limit: another program's file with no name there,
64
+ // during a run that started the pnpm executable, is taken as pnpm's too; once
65
+ // linked in under any name, its writes through the open file are missed.
66
+ export const pnpmNameless=(rel,guard,root,checkpoints,platform=process.platform)=>platform==='linux'&&/^node_modules\/#\d+$/.test(rel)&&
67
+ guard.opaque&&checkpoints.every(files=>files&&!Object.hasOwn(files,rel))&&
68
+ (()=>{try{fs.lstatSync(path.join(root,rel));return false;}catch(error){return error.code==='ENOENT';}})();
60
69
  // The installed paths the recorder treats as observed by identity.
61
70
  export const scriptInstalledPaths=(root,dirs=[])=>[...modulesDirs(dirs),...installState].map(at=>path.join(root,at));
62
71
  // Variables a recorded run cannot be seen reading: Node's and libc's (as in
@@ -16,6 +16,8 @@ import {queryAnswer} from './typescript-list.mjs';
16
16
  import {pnpmInstallInfo,pnpmProjectInfo,yaml} from './pnpm-inputs.mjs';
17
17
  import {workspaceManifests} from './workspace-manifests.mjs';
18
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';
19
21
  import {racyUntil} from './path-identity.mjs';
20
22
 
21
23
  const digest=value=>createHash('sha256').update(value).digest('hex');
@@ -73,7 +75,7 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
73
75
  const issues=[],add=issue=>{if(!issues.includes(issue))issues.push(issue);};
74
76
  const included=patternMatcher(sourcePatterns);
75
77
  const observed=rel=>Object.hasOwn(files||{},rel);
76
- const headers=new Map(),records=[];
78
+ const headers=new Map(),records=[],headerCount=new Map(),threadsStarted=new Map();
77
79
  let names=[];
78
80
  try{names=fs.readdirSync(dir).filter(name=>name.endsWith('.jsonl'));}
79
81
  catch{return {issues:['the run record is unavailable'],runFacts:[],environmentReads:{},isOutput:()=>false};}
@@ -84,6 +86,9 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
84
86
  const header=rows[0]?.[0]==='H'?rows[0][1]:null;
85
87
  if(!header||!Number.isSafeInteger(header.pid)||!Number.isSafeInteger(header.threadId)){add('a run record is unreadable');continue;}
86
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);
87
92
  records.push({header,rows:rows.slice(1)});
88
93
  }
89
94
  let opaque=false;
@@ -101,7 +106,24 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
101
106
  // PIDs is that child, not an execSync child (which reports no PID).
102
107
  const installedWrites=new Set();
103
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
+ };
104
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;
105
127
  for(const row of rows){
106
128
  const [kind,...value]=Array.isArray(row)?row:[];
107
129
  if(kind==='U')add(`recorder: ${value[0]}`);
@@ -121,16 +143,26 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
121
143
  if(kind==='C')childPids.set(header.pid,[...childPids.get(header.pid)||[],value[0]]);
122
144
  // The general recipe accounts for each program; one that needs no
123
145
  // 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;}
146
+ const judged=judge({file,args,cwd,searchPath,env,script},main);
147
+ if(judged.issue){add(judged.issue);continue;}
148
+ if(!judged.needsHeader)continue;
128
149
  const list=spawned.get(header.pid)||[];list.push({pid:kind==='C'?value[0]:null,file});
129
150
  spawned.set(header.pid,list);}
130
- else if(kind==='T'){if(!headers.has(`${header.pid}-${value[0]}`))add('a worker thread did not load the REDUE recorder');}
131
- else if(kind!=='H')add('a run record is unreadable');
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');
132
162
  }
163
+ if(replaced!==null)add(`replaced itself with ${path.basename(String(replaced))}, which REDUE does not record`);
133
164
  }
165
+ for(const [key,count] of threadsStarted)if((headerCount.get(key)||0)<count)add('a worker thread did not load the REDUE recorder');
134
166
  // Every child must be a recorded Node process: by PID when known, otherwise
135
167
  // by the number of recorded processes whose parent started it.
136
168
  for(const [parent,children] of spawned){
@@ -15,7 +15,7 @@ if(configFile)install();
15
15
  function install(){
16
16
  const original={openSync:fs.openSync,writeSync:fs.writeSync,readFileSync:fs.readFileSync,
17
17
  statSync:fs.statSync,readdirSync:fs.readdirSync,real:fs.realpathSync.native};
18
- const {threadId}=require('node:worker_threads');
18
+ const {threadId,isInternalThread}=require('node:worker_threads');
19
19
  let config,fd;
20
20
  try{config=JSON.parse(original.readFileSync(configFile,'utf8'));
21
21
  fd=original.openSync(path.join(config.dir,`${process.pid}-${threadId}.jsonl`),'a',0o600);}
@@ -25,7 +25,10 @@ function install(){
25
25
  const failed=reason=>emit(['U',reason]);
26
26
  // The main script tells the guard which program a process runs (a package
27
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}]);
28
+ // Node's own threads (the ESM hooks thread) load it too; the guard does not
29
+ // count them as threads the run started.
30
+ emit(['H',{pid:process.pid,ppid:process.ppid,threadId,main:typeof process.argv[1]==='string'?process.argv[1]:null,
31
+ ...(isInternalThread===true?{internal:true}:{})}]);
29
32
  // Every node_modules observed as installed: the root's and each workspace package's.
30
33
  const sep=path.sep,root=config.root,installed=Array.isArray(config.installed)?config.installed:[path.join(root,'node_modules')],
31
34
  tmp=config.tmp;
@@ -395,6 +398,32 @@ function install(){
395
398
  catch{failed('recorder failure');}}
396
399
  };
397
400
  }
401
+ // process.execve replaces this process with another program, under the
402
+ // same PID: the row comes first, since a successful call does not return
403
+ // (a failed one aborts the process). Node refuses it in a worker and for
404
+ // bad arguments by throwing; the row is then withdrawn. The kernel does not
405
+ // search PATH, so the file is taken from the working directory. A Node
406
+ // program that replaces this one loads the recorder again and appends a
407
+ // header to the same record.
408
+ if(typeof process.execve==='function'){const execve=process.execve;
409
+ process.execve=function(file,args,env,...rest){
410
+ let row=false;
411
+ if(threadId===0)try{const options={env:env??process.env},full=path.resolve(String(file)),where_=where(options);
412
+ emit(['X',full,argsOf(Array.isArray(args)?args.slice(1):[]),...where_,scriptOf(full,where_[0],null)]);row=true;}
413
+ catch{failed('recorder failure');}
414
+ try{return execve.call(this,file,args,env,...rest);}
415
+ catch(error){if(row)emit(['Y']);throw error;}};}
416
+ // Bindings that read files, use the network or start programs out of the
417
+ // recorder's sight. Only bindings without such access are allowed.
418
+ const harmlessBindings=new Set(['async_wrap','buffer','config','constants','contextify','crypto','http_parser','icu',
419
+ 'js_stream','natives','os','signal_wrap','stream_wrap','tty_wrap','url','util','uv','v8','zlib']);
420
+ if(typeof process.binding==='function'){const binding=process.binding;
421
+ process.binding=function(name,...rest){
422
+ const result=binding.call(this,name,...rest);
423
+ if(!harmlessBindings.has(name))failed(`process.binding('${String(name)}') reads files, uses the network or starts programs out of REDUE's sight`);
424
+ return result;};}
425
+ // Node's internal modules include child_process's unhooked internals.
426
+ if(process.execArgv.some(arg=>/^--expose[-_]internals(=|$)/.test(arg)))failed('a Node process exposed its internal modules (--expose-internals)');
398
427
  const workers=require('node:worker_threads'),Worker=workers.Worker;
399
428
  workers.Worker=class extends Worker{
400
429
  constructor(...args){super(...args);try{emit(['T',this.threadId]);}catch{failed('recorder failure');}}