@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.
- package/CHANGELOG.md +806 -0
- package/README.md +157 -99
- package/bin/redue.mjs +267 -61
- package/docs/README.md +3 -1
- package/docs/user/agents.md +333 -15
- package/docs/user/configuration.md +28 -2
- package/docs/user/direct-typescript.md +219 -0
- package/docs/user/getting-started.md +49 -7
- package/docs/user/json.md +4 -2
- package/docs/user/node-script.md +177 -0
- package/docs/user/npm-launcher-notice.md +42 -0
- package/docs/user/pilot.md +146 -0
- package/docs/user/sandbox.md +312 -0
- package/docs/user/shell.md +3 -3
- package/docs/user/supported-projects.md +69 -23
- package/docs/user/tests-direct.md +143 -0
- package/package.json +4 -3
- package/src/agent-integration.mjs +467 -0
- package/src/agent-policy.md +28 -0
- package/src/cached-state.mjs +24 -1
- package/src/claude-hook.mjs +616 -0
- package/src/cli.mjs +449 -54
- package/src/daemon.mjs +442 -93
- package/src/decision-profile.mjs +15 -1
- package/src/decision-validation.mjs +12 -1
- package/src/declared-plan.mjs +38 -6
- package/src/direct-run-certificate.mjs +254 -0
- package/src/direct-typescript-probe.mjs +27 -0
- package/src/direct-typescript.mjs +205 -0
- package/src/glob.mjs +8 -0
- package/src/history-worker.mjs +21 -13
- package/src/index.mjs +108 -21
- package/src/installed-inputs.mjs +7 -1
- package/src/installed-observation.mjs +23 -0
- package/src/landlock.py +527 -0
- package/src/linux-inotify.py +39 -10
- package/src/node-onboarding.mjs +203 -54
- package/src/node-script.mjs +406 -0
- package/src/npm-integrity.mjs +124 -0
- package/src/npm-typecheck-environment.mjs +36 -0
- package/src/npmrc.mjs +16 -0
- package/src/observation.mjs +34 -12
- package/src/path-identity.mjs +8 -0
- package/src/plan-worker.mjs +17 -17
- package/src/pnpm-inputs.mjs +122 -57
- package/src/pnpm-shim-templates.json +180 -0
- package/src/pnpm-shims.mjs +54 -0
- package/src/pnpm-typecheck-probe.mjs +11 -6
- package/src/presentation.mjs +13 -4
- package/src/probe-deadline.mjs +37 -0
- package/src/probe-result.mjs +7 -3
- package/src/project-location.mjs +34 -0
- package/src/python-onboarding.mjs +89 -0
- package/src/run-recorder.cjs +91 -0
- package/src/sandbox.mjs +653 -0
- package/src/setup.mjs +205 -0
- package/src/stats.mjs +72 -0
- package/src/test-direct.mjs +608 -0
- package/src/test-recorder.cjs +435 -0
- package/src/typescript-contract-probe.mjs +4 -187
- package/src/typescript-input-facts.mjs +92 -0
- package/src/typescript-invocation.mjs +92 -0
- package/src/typescript-list.mjs +118 -13
- package/src/typescript-qualification.mjs +29 -49
- package/src/workspace-manifests.mjs +24 -0
- package/src/yarn-workspace-inputs.mjs +3 -0
- package/src/yarn-workspace-typecheck-probe.mjs +2 -1
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// Preload for a recorded test run: NODE_OPTIONS="--require <this>" and
|
|
2
|
+
// REDUE_RUN_RECORD naming the run's guard file. Every Node process and worker
|
|
3
|
+
// thread of the run loads it. It never changes an answer, an output or the
|
|
4
|
+
// exit code. It appends, one JSON line at a time, what the guard needs
|
|
5
|
+
// (test-direct.mjs): content reads and writes of checkout files outside
|
|
6
|
+
// node_modules, lookups outside the checkout and the temporary directory,
|
|
7
|
+
// direct reads of environment variables, network use, and the child
|
|
8
|
+
// processes and threads it started. A line is written when its event happens,
|
|
9
|
+
// so a terminated thread loses nothing. A failure here only makes the record
|
|
10
|
+
// unusable.
|
|
11
|
+
'use strict';
|
|
12
|
+
const fs=require('node:fs'),path=require('node:path'),{createHash}=require('node:crypto');
|
|
13
|
+
const configFile=process.env.REDUE_RUN_RECORD;
|
|
14
|
+
if(configFile)install();
|
|
15
|
+
function install(){
|
|
16
|
+
const original={openSync:fs.openSync,writeSync:fs.writeSync,readFileSync:fs.readFileSync,
|
|
17
|
+
statSync:fs.statSync,readdirSync:fs.readdirSync,real:fs.realpathSync.native};
|
|
18
|
+
const {threadId,isInternalThread}=require('node:worker_threads');
|
|
19
|
+
let config,fd;
|
|
20
|
+
try{config=JSON.parse(original.readFileSync(configFile,'utf8'));
|
|
21
|
+
fd=original.openSync(path.join(config.dir,`${process.pid}-${threadId}.jsonl`),'a',0o600);}
|
|
22
|
+
catch{return;}
|
|
23
|
+
const emit=row=>{try{const line=Buffer.from(JSON.stringify(row)+'\n');
|
|
24
|
+
for(let at=0;at<line.length;)at+=original.writeSync(fd,line,at,line.length-at);}catch{}};
|
|
25
|
+
const failed=reason=>emit(['U',reason]);
|
|
26
|
+
// The main script tells the guard which program a process runs (a package
|
|
27
|
+
// manager may start helpers of its own).
|
|
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}:{})}]);
|
|
32
|
+
// Every node_modules observed as installed: the root's and each workspace package's.
|
|
33
|
+
const sep=path.sep,root=config.root,installed=Array.isArray(config.installed)?config.installed:[path.join(root,'node_modules')],
|
|
34
|
+
tmp=config.tmp;
|
|
35
|
+
const within=(file,dir)=>file===dir||file.startsWith(dir+sep);
|
|
36
|
+
let installedReal=null;
|
|
37
|
+
const realInstalled=()=>installedReal??=installed.map(dir=>realDir(dir));
|
|
38
|
+
const digest=value=>createHash('sha256').update(value).digest('hex');
|
|
39
|
+
// Directory realpaths, so a checkout path spelled through a link is still
|
|
40
|
+
// classified as the checkout.
|
|
41
|
+
const realDirs=new Map();
|
|
42
|
+
function realDir(dir){
|
|
43
|
+
let value=realDirs.get(dir);
|
|
44
|
+
if(value===undefined){
|
|
45
|
+
try{value=original.real(dir);}
|
|
46
|
+
catch{const parent=path.dirname(dir);value=parent===dir?dir:path.join(realDir(parent),path.basename(dir));}
|
|
47
|
+
realDirs.set(dir,value);
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
// The absolute path an argument names; undefined for a descriptor, whose
|
|
52
|
+
// open was recorded; null for anything else.
|
|
53
|
+
function located(file){
|
|
54
|
+
// A descriptor or a FileHandle: its open was recorded.
|
|
55
|
+
if(typeof file==='number'||file&&typeof file==='object'&&typeof file.fd==='number'&&typeof file.close==='function')return undefined;
|
|
56
|
+
if(Buffer.isBuffer(file))file=file.toString();
|
|
57
|
+
else if(file instanceof URL){if(file.protocol!=='file:')return null;file=require('node:url').fileURLToPath(file);}
|
|
58
|
+
if(typeof file!=='string')return null;
|
|
59
|
+
const full=path.resolve(file);
|
|
60
|
+
return within(full,root)?full:path.join(realDir(path.dirname(full)),path.basename(full));
|
|
61
|
+
}
|
|
62
|
+
const seen=new Set();
|
|
63
|
+
const once=row=>{const key=JSON.stringify(row);if(seen.has(key))return;seen.add(key);emit(row);};
|
|
64
|
+
// One answer per question and process: asked first, answered once.
|
|
65
|
+
const asked=new Set(),first=key=>{if(asked.has(key))return false;asked.add(key);return true;};
|
|
66
|
+
const missing=error=>['ENOENT','ENOTDIR'].includes(error?.code)?null:'error';
|
|
67
|
+
const kindOf=st=>st.isFile()?'f':st.isDirectory()?'d':'o';
|
|
68
|
+
function statKind(full){try{return kindOf(original.statSync(full));}catch(e){return missing(e);}}
|
|
69
|
+
// A stat-like answer as a kind, following links as fileExists does.
|
|
70
|
+
const answerKind=(full,result)=>result===false?null:result&&typeof result.isFile==='function'&&!result.isSymbolicLink()?
|
|
71
|
+
kindOf(result):statKind(full);
|
|
72
|
+
// A listing as queryAnswer's `entries` computes it.
|
|
73
|
+
function entries(full){
|
|
74
|
+
try{return original.readdirSync(full,{withFileTypes:true})
|
|
75
|
+
.map(entry=>[entry.name,entry.isDirectory()?'d':entry.isSymbolicLink()?'l':'f']).sort();}
|
|
76
|
+
catch(e){return missing(e);}
|
|
77
|
+
}
|
|
78
|
+
// Paths this process created from nothing: files it truncated or created,
|
|
79
|
+
// directories it made. Reading them back reads the run's own output.
|
|
80
|
+
// Appends, renames and links keep earlier content, so they are not here.
|
|
81
|
+
const produced=new Set();
|
|
82
|
+
function isProduced(full){
|
|
83
|
+
if(!produced.size)return false;
|
|
84
|
+
for(let at=full;;at=path.dirname(at)){if(produced.has(at))return true;if(path.dirname(at)===at)return false;}
|
|
85
|
+
}
|
|
86
|
+
// Runner data and the toolchain's own files (a package manager's install
|
|
87
|
+
// and logs) are not inputs.
|
|
88
|
+
const runnerData=[...(config.runnerData||[]),...(Array.isArray(config.toolchain)?config.toolchain:[])];
|
|
89
|
+
// Runner caches in the temporary directory, keyed by content: Jest's,
|
|
90
|
+
// Yarn 1's compile cache, tsx's, jiti's, ts-node's and Babel's.
|
|
91
|
+
const tmpCache=/^(?:jest_|v8-compile-cache|tsx-\d+$|jiti$|node-jiti$|ts-node|babel)/;
|
|
92
|
+
const runnerCache=full=>tmp.some(dir=>within(full,dir)&&tmpCache.test(full.slice(dir.length+1).split(sep)[0]))||
|
|
93
|
+
runnerData.some(dir=>within(full,dir));
|
|
94
|
+
// A read of the process's own executable through /proc (detect-libc reads
|
|
95
|
+
// its ELF header there), when the process runs the Node on PATH, which the
|
|
96
|
+
// environment identifies by its file.
|
|
97
|
+
const ownExePath=process.platform==='linux'?`/proc/${process.pid}/exe`:null;
|
|
98
|
+
let ownExe;
|
|
99
|
+
const ownExecutable=(role,full)=>{
|
|
100
|
+
if(role!=='read'||full!==ownExePath)return false;
|
|
101
|
+
if(ownExe===undefined)try{ownExe=typeof config.node==='string'&&original.real(full)===config.node;}catch{ownExe=false;}
|
|
102
|
+
return ownExe;
|
|
103
|
+
};
|
|
104
|
+
// One filesystem access by its role: read (content), produce, touch (a write
|
|
105
|
+
// that keeps earlier content), mkdir, mkdtemp, list, stat or realpath.
|
|
106
|
+
function access(role,file,error=null,result=undefined,recursive=false){
|
|
107
|
+
try{
|
|
108
|
+
let full=located(file);
|
|
109
|
+
if(full===undefined)return;
|
|
110
|
+
if(full===null){if(!['produce','touch','mkdir'].includes(role))failed(`${role} of an unsupported path argument`);return;}
|
|
111
|
+
// Installed files are observed by identity. A path under node_modules
|
|
112
|
+
// that a link leads out of the installed tree (a workspace package's
|
|
113
|
+
// link) is classified where it leads.
|
|
114
|
+
if(installed.some(dir=>within(full,dir))){
|
|
115
|
+
// A listing, lookup or realpath of the link itself follows it.
|
|
116
|
+
const real=['list','stat','realpath'].includes(role)?realDir(full):path.join(realDir(path.dirname(full)),path.basename(full));
|
|
117
|
+
// A write there is noted apart from checkout outputs: the guard does
|
|
118
|
+
// not see reads of installed files, so only named tool files use it.
|
|
119
|
+
if(real===full||realInstalled().some(dir=>within(real,dir))){
|
|
120
|
+
if(role==='produce'&&!error&&within(full,root))once(['I',rel(full)]);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
full=real;
|
|
124
|
+
}
|
|
125
|
+
if(role==='produce'){if(!error)produced.add(full);if(!error&&within(full,root))once(['P',rel(full)]);return;}
|
|
126
|
+
// A directory the run created held nothing before it, so everything in
|
|
127
|
+
// it is the run's output, whichever process reads it.
|
|
128
|
+
if(role==='mkdir'||role==='mkdtemp'){
|
|
129
|
+
const made=error?null:typeof result==='string'?located(result):role==='mkdir'&&!recursive?full:null;
|
|
130
|
+
if(made){produced.add(made);once(['M',made]);}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if(role==='touch')return;
|
|
134
|
+
// This recorder is REDUE's own file; stack-trace printers read it.
|
|
135
|
+
if(full===__filename||isProduced(full))return;
|
|
136
|
+
if(!first(role+'\0'+full))return;
|
|
137
|
+
if(within(full,root)){
|
|
138
|
+
if(role==='read')emit(['R',rel(full),!error]);
|
|
139
|
+
else if(role==='stat')emit(['L',rel(full),error?missing(error):answerKind(full,result)]);
|
|
140
|
+
else if(role==='list')emit(['D',rel(full),error?missing(error):entries(full)]);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if(runnerCache(full)||ownExecutable(role,full))return;
|
|
144
|
+
// A small file read outside the checkout is kept by its content, as
|
|
145
|
+
// queryAnswer's readFile hashes it, so status can ask again.
|
|
146
|
+
if(role==='read'&&!error){
|
|
147
|
+
let answer='present';
|
|
148
|
+
try{if(original.statSync(full).size<=1024*1024)answer={content:digest(JSON.stringify(original.readFileSync(full,'utf8')))};}catch{}
|
|
149
|
+
once(['O','read',full,answer]);return;
|
|
150
|
+
}
|
|
151
|
+
if(role==='read'||role==='list'){once(['O',role,full,error?missing(error):'present']);return;}
|
|
152
|
+
if(role==='realpath'){once(['O','realpath',full,error?missing(error):String(result)]);return;}
|
|
153
|
+
if(error){once(['O','stat',full,missing(error)]);return;}
|
|
154
|
+
// A link outside the checkout is kept as its resolution.
|
|
155
|
+
if(result?.isSymbolicLink?.()){let real=null;try{real=original.real(full);}catch(e){real=missing(e);}
|
|
156
|
+
once(['O','realpath',full,real]);return;}
|
|
157
|
+
once(['O','stat',full,answerKind(full,result)]);
|
|
158
|
+
}catch{failed('recorder failure');}
|
|
159
|
+
}
|
|
160
|
+
const rel=full=>path.relative(root,full).split(sep).join('/');
|
|
161
|
+
// Roles of an open by its flags.
|
|
162
|
+
function openRoles(flags){
|
|
163
|
+
if(typeof flags==='number'){
|
|
164
|
+
const {O_RDONLY,O_RDWR,O_TRUNC,O_CREAT,O_EXCL}=fs.constants,access=flags&3;
|
|
165
|
+
if(access===O_RDONLY)return ['read'];
|
|
166
|
+
if(flags&O_TRUNC||flags&O_CREAT&&flags&O_EXCL)return ['produce'];
|
|
167
|
+
return access===O_RDWR?['read','touch']:['touch'];
|
|
168
|
+
}
|
|
169
|
+
if(flags===undefined||flags===null)return ['read'];
|
|
170
|
+
if(typeof flags!=='string')return ['read','touch'];
|
|
171
|
+
if(flags.startsWith('r'))return flags.includes('+')?['read','touch']:['read'];
|
|
172
|
+
if(flags.startsWith('w'))return ['produce'];
|
|
173
|
+
return flags.includes('+')?['read','touch']:['touch'];
|
|
174
|
+
}
|
|
175
|
+
const flagOf=options=>typeof options==='object'&&options!==null?options.flag??options.flags:undefined;
|
|
176
|
+
const opened=args=>openRoles(args[1]).map(role=>[role,args[0]]);
|
|
177
|
+
const written=args=>openRoles(flagOf(args[2])??'w').map(role=>[role,args[0]]);
|
|
178
|
+
const appended=args=>openRoles(flagOf(args[2])??'a').map(role=>[role,args[0]]);
|
|
179
|
+
const copied=args=>[['read',args[0]],['produce',args[1]]];
|
|
180
|
+
const moved=args=>[['touch',args[1]]];
|
|
181
|
+
// Wraps a synchronous, callback or promise API without changing what it does.
|
|
182
|
+
function wrap(holder,name,roles,style){
|
|
183
|
+
const call=holder[name];if(typeof call!=='function')return;
|
|
184
|
+
const pick=args=>typeof roles==='function'?roles(args):roles.map(role=>[role,args[0]]);
|
|
185
|
+
const recursive=args=>name.startsWith('mkdir')&&Boolean(args[1]?.recursive);
|
|
186
|
+
const note=(args,error,result)=>{for(const [role,file] of pick(args))access(role,file,error,result,recursive(args));};
|
|
187
|
+
let wrapped;
|
|
188
|
+
if(style==='sync')wrapped=function(...args){
|
|
189
|
+
let result;try{result=call.apply(this,args);}catch(error){note(args,error);throw error;}
|
|
190
|
+
note(args,null,result);return result;};
|
|
191
|
+
else if(style==='callback')wrapped=function(...args){
|
|
192
|
+
const at=args.findLastIndex(value=>typeof value==='function');
|
|
193
|
+
if(at<0)return call.apply(this,args);
|
|
194
|
+
const done=args[at],given=args.slice(0,at);
|
|
195
|
+
args[at]=name==='exists'?function(value){note(given,null,value);return done.apply(this,arguments);}:
|
|
196
|
+
function(error,result){note(given,error,result);return done.apply(this,arguments);};
|
|
197
|
+
return call.apply(this,args);};
|
|
198
|
+
else wrapped=function(...args){
|
|
199
|
+
return call.apply(this,args).then(result=>{note(args,null,result);return result;},error=>{note(args,error);throw error;});};
|
|
200
|
+
Object.defineProperties(wrapped,Object.getOwnPropertyDescriptors(call));
|
|
201
|
+
holder[name]=wrapped;
|
|
202
|
+
}
|
|
203
|
+
const apis=[
|
|
204
|
+
['readFile',['read']],['open',opened],['readlink',['read']],['copyFile',copied],['cp',copied],
|
|
205
|
+
['writeFile',written],['appendFile',appended],['mkdir',['mkdir']],['mkdtemp',['mkdtemp']],
|
|
206
|
+
['rename',moved],['symlink',moved],['link',moved],['truncate',['touch']],
|
|
207
|
+
['stat',['stat']],['lstat',['stat']],['access',['stat']],['readdir',['list']],['opendir',['list']],
|
|
208
|
+
['realpath',['realpath']],
|
|
209
|
+
];
|
|
210
|
+
for(const [name,roles] of apis){
|
|
211
|
+
wrap(fs,name+'Sync',roles,'sync');wrap(fs,name,roles,'callback');wrap(fs.promises,name,roles,'promise');
|
|
212
|
+
}
|
|
213
|
+
wrap(fs,'existsSync',['stat'],'sync');wrap(fs,'exists',['stat'],'callback');
|
|
214
|
+
wrap(fs.realpathSync,'native',['realpath'],'sync');wrap(fs.realpath,'native',['realpath'],'callback');
|
|
215
|
+
for(const [name,roles] of [['createReadStream',()=>['read']],['createWriteStream',options=>openRoles(flagOf(options)??'w')]]){
|
|
216
|
+
const call=fs[name];
|
|
217
|
+
fs[name]=Object.defineProperties(function(file,options,...rest){
|
|
218
|
+
for(const role of roles(options))access(role,file);return call.call(this,file,options,...rest);},
|
|
219
|
+
Object.getOwnPropertyDescriptors(call));
|
|
220
|
+
}
|
|
221
|
+
// Environment: direct reads, compared with the caller's environment by
|
|
222
|
+
// digest. A read that copies the whole environment (a spread, or a child
|
|
223
|
+
// process's environment) is an enumeration, not a read of each name.
|
|
224
|
+
const caller=config.env,envSeen=new Set();
|
|
225
|
+
// A copy lists the keys, then asks for each key's descriptor and value right
|
|
226
|
+
// after (a spread, Object.assign). Only that pair is not a read. Node's own
|
|
227
|
+
// modules (child_process, worker_threads, util) list the keys, then read
|
|
228
|
+
// every value; there each listed key's first read is part of the copy.
|
|
229
|
+
let enumeration=null;
|
|
230
|
+
function nodeInternal(){
|
|
231
|
+
const prepare=Error.prepareStackTrace,holder={};
|
|
232
|
+
try{Error.prepareStackTrace=(_,sites)=>sites;Error.captureStackTrace(holder,nodeInternal);
|
|
233
|
+
for(const site of holder.stack){const file=site.getFileName?.();
|
|
234
|
+
if(file&&file!==__filename)return file.startsWith('node:');}
|
|
235
|
+
return false;}
|
|
236
|
+
catch{return false;}
|
|
237
|
+
finally{Error.prepareStackTrace=prepare;}
|
|
238
|
+
}
|
|
239
|
+
function envRead(source,key){
|
|
240
|
+
if(typeof key!=='string'||envSeen.has(key))return;
|
|
241
|
+
try{
|
|
242
|
+
// The caller's value as a digest, or null when the caller left it unset.
|
|
243
|
+
if(!Object.hasOwn(caller,key)){envSeen.add(key);emit(['E',key,null]);return;}
|
|
244
|
+
const value=source[key];
|
|
245
|
+
// A value the run set itself is not the caller's.
|
|
246
|
+
if(typeof value!=='string'||digest(value)!==caller[key])return;
|
|
247
|
+
envSeen.add(key);emit(['E',key,caller[key]]);
|
|
248
|
+
}catch{failed('recorder failure');}
|
|
249
|
+
}
|
|
250
|
+
const targets=new WeakMap();
|
|
251
|
+
const recordingEnv=source=>{const proxy=new Proxy(source,{
|
|
252
|
+
ownKeys(t){const keys=Reflect.ownKeys(t),current={keys:new Set(keys),last:null,copy:nodeInternal()};enumeration=current;
|
|
253
|
+
queueMicrotask(()=>{if(enumeration===current)enumeration=null;});return keys;},
|
|
254
|
+
getOwnPropertyDescriptor(t,key){
|
|
255
|
+
if(enumeration?.copy&&enumeration.keys.has(key)){}
|
|
256
|
+
else if(enumeration?.keys.delete(key))enumeration.last=key;else{if(enumeration)enumeration.last=null;envRead(t,key);}
|
|
257
|
+
return Reflect.getOwnPropertyDescriptor(t,key);},
|
|
258
|
+
get(t,key){
|
|
259
|
+
if(enumeration?.copy&&enumeration.keys.delete(key)){}
|
|
260
|
+
else if(enumeration&&enumeration.last===key)enumeration.last=null;else{if(enumeration)enumeration.last=null;envRead(t,key);}
|
|
261
|
+
return Reflect.get(t,key);},
|
|
262
|
+
has(t,key){if(enumeration)enumeration.last=null;envRead(t,key);return Reflect.has(t,key);},
|
|
263
|
+
set(t,key,value){t[key]=value;return true;},
|
|
264
|
+
});targets.set(proxy,source);return proxy;};
|
|
265
|
+
// PATH as a child receives it, read without recording a read.
|
|
266
|
+
const pathOf=env=>{try{const value=Reflect.get(targets.get(env)??env??{},'PATH');return typeof value==='string'?value:null;}
|
|
267
|
+
catch{return null;}};
|
|
268
|
+
function wrapEnv(holder){
|
|
269
|
+
let current=recordingEnv(holder.env);
|
|
270
|
+
// Code that replaces the environment object keeps being recorded.
|
|
271
|
+
try{Object.defineProperty(holder,'env',{configurable:true,enumerable:true,get(){return current;},
|
|
272
|
+
set(value){current=value&&typeof value==='object'?recordingEnv(value):value;}});}
|
|
273
|
+
catch{failed('environment not recordable');}
|
|
274
|
+
}
|
|
275
|
+
wrapEnv(process);
|
|
276
|
+
// Jest 29 gives each test file a copy of process.env (jest-util's
|
|
277
|
+
// createProcessObject). Test code reads that copy, so it is recorded too.
|
|
278
|
+
const Module=require('node:module'),compile=Module.prototype._compile;
|
|
279
|
+
Module.prototype._compile=function(content,filename,...rest){
|
|
280
|
+
const result=compile.call(this,content,filename,...rest);
|
|
281
|
+
try{if(typeof filename==='string'&&filename.split(sep).join('/').endsWith('/jest-util/build/createProcessObject.js')&&
|
|
282
|
+
typeof this.exports?.default==='function'){
|
|
283
|
+
const create=this.exports.default;
|
|
284
|
+
this.exports.default=function(...args){const created=create.apply(this,args);
|
|
285
|
+
try{wrapEnv(created);}catch{failed('environment not recordable');}
|
|
286
|
+
return created;};
|
|
287
|
+
}}catch{failed('recorder failure');}
|
|
288
|
+
return result;
|
|
289
|
+
};
|
|
290
|
+
// Network: connections other than loopback or local sockets, and DNS
|
|
291
|
+
// questions about anything but localhost.
|
|
292
|
+
const loopback=host=>{const value=String(host).replace(/^\[|\]$/g,'').toLowerCase();
|
|
293
|
+
return value==='localhost'||value.endsWith('.localhost')||/^127\./.test(value)||value==='::1'||
|
|
294
|
+
value==='0.0.0.0'||value==='::'||/^::ffff:127\./.test(value);};
|
|
295
|
+
const localSocket=file=>{const full=located(file);
|
|
296
|
+
return typeof full==='string'&&(within(full,root)||tmp.some(dir=>within(full,dir)));};
|
|
297
|
+
const net=require('node:net'),connect=net.Socket.prototype.connect;
|
|
298
|
+
net.Socket.prototype.connect=function(...args){
|
|
299
|
+
try{
|
|
300
|
+
let options=args[0];
|
|
301
|
+
if(Array.isArray(options))options=options[0];
|
|
302
|
+
else if(typeof options==='number'||typeof options==='string'&&/^\d+$/.test(options))
|
|
303
|
+
options={port:options,host:typeof args[1]==='string'?args[1]:undefined};
|
|
304
|
+
else if(typeof options==='string')options={path:options};
|
|
305
|
+
if(options?.path!=null){if(!localSocket(options.path))emit(['N',`socket ${String(options.path)}`]);}
|
|
306
|
+
else if(options&&!loopback(options.host??'localhost'))emit(['N',`${options.host}:${options.port}`]);
|
|
307
|
+
}catch{failed('recorder failure');}
|
|
308
|
+
return connect.apply(this,args);
|
|
309
|
+
};
|
|
310
|
+
const dgram=require('node:dgram');
|
|
311
|
+
for(const name of ['send','connect']){
|
|
312
|
+
const call=dgram.Socket.prototype[name];
|
|
313
|
+
dgram.Socket.prototype[name]=function(...args){
|
|
314
|
+
try{const host=args.find((value,i)=>i>0&&typeof value==='string');
|
|
315
|
+
if(host===undefined?name==='connect':!loopback(host))emit(['N',`udp ${host??'default address'}`]);}
|
|
316
|
+
catch{failed('recorder failure');}
|
|
317
|
+
return call.apply(this,args);};
|
|
318
|
+
}
|
|
319
|
+
const dns=require('node:dns');
|
|
320
|
+
const questions=['lookup','lookupService','resolve','resolve4','resolve6','resolveAny','resolveCaa','resolveCname',
|
|
321
|
+
'resolveMx','resolveNaptr','resolveNs','resolvePtr','resolveSoa','resolveSrv','resolveTxt','reverse'];
|
|
322
|
+
for(const holder of [dns,dns.promises,dns.Resolver.prototype,dns.promises.Resolver.prototype])
|
|
323
|
+
for(const name of questions){
|
|
324
|
+
const call=holder[name];if(typeof call!=='function')continue;
|
|
325
|
+
holder[name]=function(host,...rest){
|
|
326
|
+
try{if(!loopback(host))emit(['N',`dns ${String(host)}`]);}catch{failed('recorder failure');}
|
|
327
|
+
return call.call(this,host,...rest);};
|
|
328
|
+
}
|
|
329
|
+
// Child processes and threads must load this recorder too; the guard checks
|
|
330
|
+
// for their headers. Children of execSync/execFileSync report no PID, so
|
|
331
|
+
// they are counted instead. Each row names the program and its arguments
|
|
332
|
+
// as Node's spawn normalizes them (a `shell` option runs /bin/sh -c and the
|
|
333
|
+
// joined command), the working directory and PATH, so the guard can tell
|
|
334
|
+
// which program a name ran and read what a shell was asked to run.
|
|
335
|
+
// Arguments over 64 KB in all are left out (null).
|
|
336
|
+
const argsOf=args=>{try{const list=Array.isArray(args)?args.map(String):[];
|
|
337
|
+
return list.reduce((n,arg)=>n+arg.length,0)>65536?null:list;}catch{return null;}};
|
|
338
|
+
const normalized=(file,args,options,shell)=>{
|
|
339
|
+
if(!Array.isArray(args)){options=args;args=[];}
|
|
340
|
+
shell=options?.shell??shell;
|
|
341
|
+
if(!shell)return [String(file),argsOf(args)];
|
|
342
|
+
return [typeof shell==='string'?shell:'/bin/sh',['-c',[file,...args].join(' ')]];
|
|
343
|
+
};
|
|
344
|
+
// What the child's environment says: whether NODE_OPTIONS still loads this
|
|
345
|
+
// recorder for the same run (npm's and pnpm's `node-options` setting
|
|
346
|
+
// replaces it), and whether BASH_ENV is set (bash -c sources it).
|
|
347
|
+
const envFacts=lookupVar=>{
|
|
348
|
+
try{const options=lookupVar('NODE_OPTIONS');
|
|
349
|
+
return {preload:typeof options==='string'&&options.includes(__filename)&&lookupVar('REDUE_RUN_RECORD')===configFile,
|
|
350
|
+
bashEnv:Boolean(lookupVar('BASH_ENV'))};}
|
|
351
|
+
catch{return {preload:false,bashEnv:true};}
|
|
352
|
+
};
|
|
353
|
+
const envOf=env=>envFacts(name=>{const value=Reflect.get(targets.get(env)??env??{},name);return typeof value==='string'?value:undefined;});
|
|
354
|
+
const pairsOf=pairs=>envFacts(name=>{const pair=(pairs||[]).find(item=>typeof item==='string'&&item.startsWith(name+'='));
|
|
355
|
+
return pair?pair.slice(name.length+1):undefined;});
|
|
356
|
+
const where=options=>[path.resolve(typeof options?.cwd==='string'?options.cwd:options?.cwd instanceof URL?
|
|
357
|
+
require('node:url').fileURLToPath(options.cwd):'.'),pathOf(options?.env??process.env),envOf(options?.env??process.env)];
|
|
358
|
+
const optionsOf=(args,options)=>Array.isArray(args)?options:args;
|
|
359
|
+
// A #! script the child runs, as it is now: Yarn writes its bin wrappers
|
|
360
|
+
// into a temporary directory and removes them after the run.
|
|
361
|
+
function scriptOf(file,cwd,searchPath){
|
|
362
|
+
try{
|
|
363
|
+
const candidates=file.includes(sep)?[path.resolve(cwd,file)]:String(searchPath??'').split(path.delimiter).map(dir=>path.join(path.resolve(cwd,dir||'.'),file));
|
|
364
|
+
for(const candidate of candidates){
|
|
365
|
+
let st;try{st=original.statSync(candidate);}catch{continue;}
|
|
366
|
+
if(!st.isFile()||!(st.mode&0o111))continue;
|
|
367
|
+
if(st.size>16384)return null;
|
|
368
|
+
const text=original.readFileSync(candidate,'utf8');
|
|
369
|
+
return text.startsWith('#!')?{file:original.real(candidate),text}:null;
|
|
370
|
+
}
|
|
371
|
+
}catch{}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
const childProcess=require('node:child_process'),spawn=childProcess.ChildProcess.prototype.spawn;
|
|
375
|
+
childProcess.ChildProcess.prototype.spawn=function(options){
|
|
376
|
+
const result=spawn.call(this,options);
|
|
377
|
+
try{if(this.pid){const pair=(options?.envPairs||[]).find(item=>typeof item==='string'&&item.startsWith('PATH='));
|
|
378
|
+
emit(['C',this.pid,String(options?.file),argsOf(options?.args?.slice(1)),where(options)[0],pair?pair.slice(5):null,
|
|
379
|
+
pairsOf(options?.envPairs),scriptOf(String(options?.file),where(options)[0],pair?pair.slice(5):null)]);}}
|
|
380
|
+
catch{failed('recorder failure');}
|
|
381
|
+
return result;
|
|
382
|
+
};
|
|
383
|
+
const spawnSync=childProcess.spawnSync;
|
|
384
|
+
childProcess.spawnSync=function(file,...rest){
|
|
385
|
+
const result=spawnSync.call(this,file,...rest);
|
|
386
|
+
try{if(result?.pid)emit(['C',result.pid,...normalized(file,rest[0],rest[1]),...where(optionsOf(rest[0],rest[1]))]);}
|
|
387
|
+
catch{failed('recorder failure');}
|
|
388
|
+
return result;
|
|
389
|
+
};
|
|
390
|
+
for(const name of ['execSync','execFileSync']){
|
|
391
|
+
const call=childProcess[name];
|
|
392
|
+
childProcess[name]=function(file,...rest){
|
|
393
|
+
let started=true;
|
|
394
|
+
try{return call.call(this,file,...rest);}
|
|
395
|
+
catch(error){if(error?.code==='ENOENT'&&!error.pid)started=false;throw error;}
|
|
396
|
+
finally{if(started)try{emit(['S',...name==='execSync'?normalized(file,[],rest[0],true):normalized(file,rest[0],rest[1]),
|
|
397
|
+
...where(name==='execSync'?rest[0]:optionsOf(rest[0],rest[1]))]);}
|
|
398
|
+
catch{failed('recorder failure');}}
|
|
399
|
+
};
|
|
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)');
|
|
427
|
+
const workers=require('node:worker_threads'),Worker=workers.Worker;
|
|
428
|
+
workers.Worker=class extends Worker{
|
|
429
|
+
constructor(...args){super(...args);try{emit(['T',this.threadId]);}catch{failed('recorder failure');}}
|
|
430
|
+
};
|
|
431
|
+
// Node reads an environment file natively; this is the one JavaScript entry to it.
|
|
432
|
+
if(typeof process.loadEnvFile==='function'){const load=process.loadEnvFile;
|
|
433
|
+
process.loadEnvFile=function(file='.env',...rest){access('read',file);return load.call(this,file,...rest);};}
|
|
434
|
+
try{Module.syncBuiltinESMExports();}catch{failed('recorder failure');}
|
|
435
|
+
}
|
|
@@ -1,188 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import fs from 'node:fs';
|
|
7
|
-
import path from 'node:path';
|
|
8
|
-
import os from 'node:os';
|
|
9
|
-
import {createHash} from 'node:crypto';
|
|
10
|
-
import {createRequire} from 'node:module';
|
|
11
|
-
import {spawnSync} from 'node:child_process';
|
|
12
|
-
import {withinPath as within,realObservedPath} from './path-identity.mjs';
|
|
13
|
-
import {timing} from './decision-profile.mjs';
|
|
14
|
-
|
|
15
|
-
const [rootArg,scriptName,npmArg]=process.argv.slice(2);
|
|
16
|
-
const hash=value=>createHash('sha256').update(value).digest('hex');
|
|
17
|
-
const fail=code=>{throw Object.assign(Error(code),{code});};
|
|
18
|
-
const digestFile=file=>hash(fs.readFileSync(file));
|
|
19
|
-
const require=createRequire(import.meta.url);
|
|
20
|
-
function treeHash(dir){
|
|
21
|
-
const digest=createHash('sha256');let count=0;
|
|
22
|
-
const visit=(folder,rel='')=>{
|
|
23
|
-
for(const entry of fs.readdirSync(folder,{withFileTypes:true}).sort((a,b)=>
|
|
24
|
-
a.name.localeCompare(b.name))){
|
|
25
|
-
const name=rel?rel+'/'+entry.name:entry.name,full=path.join(folder,entry.name);
|
|
26
|
-
if(entry.isDirectory())visit(full,name);
|
|
27
|
-
else if(entry.isFile()){digest.update(name+'\0'+digestFile(full)+'\0');count++;}
|
|
28
|
-
else fail('npm-installation-unobservable');
|
|
29
|
-
}
|
|
30
|
-
};
|
|
31
|
-
visit(dir);return [digest.digest('hex'),count];
|
|
32
|
-
}
|
|
33
|
-
function npmRcKeys(file){
|
|
34
|
-
if(!fs.existsSync(file))return null;
|
|
35
|
-
const keys=[];
|
|
36
|
-
for(const line of fs.readFileSync(file,'utf8').split(/\r?\n/)){
|
|
37
|
-
const trimmed=line.trim();if(!trimmed||/^[#;]/.test(trimmed))continue;
|
|
38
|
-
const at=trimmed.indexOf('=');if(at<0)fail('npm-configuration-unsupported');
|
|
39
|
-
const key=trimmed.slice(0,at).trim();
|
|
40
|
-
if(key!=='registry'&&!/^\/\/[^\s]+\/:_authToken$/.test(key))
|
|
41
|
-
fail('npm-configuration-unsupported');
|
|
42
|
-
keys.push(key);
|
|
43
|
-
}
|
|
44
|
-
return keys.sort();
|
|
45
|
-
}
|
|
46
|
-
try{
|
|
47
|
-
if(!rootArg||!scriptName||!npmArg)fail('probe-arguments');
|
|
48
|
-
const root=realObservedPath(rootArg),npm=realObservedPath(npmArg);
|
|
49
|
-
const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
|
|
50
|
-
if(pkg.scripts?.[scriptName]?.trim()!=='tsc --noEmit'||
|
|
51
|
-
pkg.scripts?.['pre'+scriptName]||pkg.scripts?.['post'+scriptName])
|
|
52
|
-
fail('typecheck-script-changed');
|
|
53
|
-
if(process.env.NODE_OPTIONS||process.env.NODE_PATH||process.env.BASH_ENV||
|
|
54
|
-
process.env.ENV||Object.entries(process.env).some(([key,value])=>{
|
|
55
|
-
if(/^npm_config_prefix$/i.test(key)&&process.platform==='win32'){
|
|
56
|
-
// The Windows runner supplies a global npm prefix independently of
|
|
57
|
-
// the selected local compiler. Its value is part of caller context.
|
|
58
|
-
return !path.isAbsolute(value);
|
|
59
|
-
}
|
|
60
|
-
return /^(npm_config_|DYLD_|TSGO_)/i.test(key);
|
|
61
|
-
}))fail('execution-environment-unsupported');
|
|
62
|
-
let npmRoot;
|
|
63
|
-
if(process.platform==='win32'){
|
|
64
|
-
if(path.basename(npm).toLowerCase()!=='npm.cmd')
|
|
65
|
-
fail('npm-installation-layout-unsupported');
|
|
66
|
-
npmRoot=path.join(path.dirname(npm),'node_modules','npm');
|
|
67
|
-
const body=fs.readFileSync(npm,'utf8');
|
|
68
|
-
if(!body.includes('node_modules\\npm\\bin\\npm-cli.js')||
|
|
69
|
-
!body.includes('node_modules\\npm\\bin\\npm-prefix.js')||
|
|
70
|
-
!body.includes('"%NODE_EXE%" "%NPM_CLI_JS%" %*'))
|
|
71
|
-
fail('npm-installation-layout-unsupported');
|
|
72
|
-
const prefix=spawnSync(process.execPath,[path.join(npmRoot,'bin','npm-prefix.js')],
|
|
73
|
-
{cwd:root,env:process.env,encoding:'utf8',timeout:4000,stdio:['ignore','pipe','ignore']});
|
|
74
|
-
const selectedPrefix=prefix.stdout?.trim();
|
|
75
|
-
if(prefix.error||prefix.status!==0||!selectedPrefix||
|
|
76
|
-
!path.isAbsolute(selectedPrefix))fail('npm-installation-layout-unsupported');
|
|
77
|
-
// npm.cmd selects the prefix's CLI only when that file exists. Otherwise
|
|
78
|
-
// it runs the bundled CLI beside npm.cmd; observe the same branch here.
|
|
79
|
-
const alternate=path.join(selectedPrefix,'node_modules','npm','bin','npm-cli.js');
|
|
80
|
-
if(fs.existsSync(alternate)&&
|
|
81
|
-
realObservedPath(alternate)!==realObservedPath(path.join(npmRoot,'bin','npm-cli.js')))
|
|
82
|
-
fail('npm-installation-layout-unsupported');
|
|
83
|
-
}else npmRoot=path.dirname(path.dirname(npm));
|
|
84
|
-
if(path.basename(npmRoot)!=='npm'||path.basename(path.dirname(npmRoot))!=='node_modules')
|
|
85
|
-
fail('npm-installation-layout-unsupported');
|
|
86
|
-
const home=process.platform==='win32'?os.homedir():process.env.HOME;
|
|
87
|
-
if(!home||!path.isAbsolute(home))fail('home-context-unavailable');
|
|
88
|
-
const npmConfig=[path.join(root,'.npmrc'),path.join(home,'.npmrc')]
|
|
89
|
-
.map(file=>[hash(file),npmRcKeys(file)]);
|
|
90
|
-
const contextFacts={
|
|
91
|
-
npmTree:treeHash(npmRoot),npmConfig,
|
|
92
|
-
node:hash(JSON.stringify([process.version,realObservedPath(process.execPath),
|
|
93
|
-
fs.statSync(process.execPath).size])),
|
|
94
|
-
shell:process.platform==='win32'?hash(JSON.stringify([
|
|
95
|
-
realObservedPath(process.env.ComSpec||process.env.COMSPEC||''),
|
|
96
|
-
digestFile(process.env.ComSpec||process.env.COMSPEC||''),
|
|
97
|
-
process.env.PATHEXT||'',digestFile(npm),
|
|
98
|
-
digestFile(path.join(root,'node_modules','.bin','tsc.cmd'))])):
|
|
99
|
-
digestFile('/bin/sh')};
|
|
100
|
-
const contextHash=hash(JSON.stringify(contextFacts));
|
|
101
|
-
if(contextOnly(contextHash))process.exit(0);
|
|
102
|
-
const tsRoot=path.join(root,'node_modules','typescript');
|
|
103
|
-
let tsc;
|
|
104
|
-
if(process.platform==='win32'){
|
|
105
|
-
const shim=path.join(root,'node_modules','.bin','tsc.cmd');
|
|
106
|
-
const body=fs.readFileSync(shim,'utf8').replace(/\r\n/g,'\n').trim();
|
|
107
|
-
const expected=`@ECHO off
|
|
108
|
-
GOTO start
|
|
109
|
-
:find_dp0
|
|
110
|
-
SET dp0=%~dp0
|
|
111
|
-
EXIT /b
|
|
112
|
-
:start
|
|
113
|
-
SETLOCAL
|
|
114
|
-
CALL :find_dp0
|
|
115
|
-
|
|
116
|
-
IF EXIST "%dp0%\\node.exe" (
|
|
117
|
-
SET "_prog=%dp0%\\node.exe"
|
|
118
|
-
) ELSE (
|
|
119
|
-
SET "_prog=node"
|
|
120
|
-
SET PATHEXT=%PATHEXT:;.JS;=;%
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\..\\typescript\\bin\\tsc" %*`;
|
|
124
|
-
if(body!==expected)fail('typescript-executable-changed');
|
|
125
|
-
tsc=realObservedPath(path.join(tsRoot,'bin','tsc'));
|
|
126
|
-
}else{
|
|
127
|
-
tsc=realObservedPath(path.join(root,'node_modules','.bin','tsc'));
|
|
128
|
-
if(tsc!==path.join(tsRoot,'bin','tsc'))fail('typescript-executable-changed');
|
|
129
|
-
}
|
|
130
|
-
const configReads=new Set(),visited=new Set();
|
|
131
|
-
let ts;try{ts=require(tsRoot);}catch{}
|
|
132
|
-
if(typeof ts?.getParsedCommandLineOfConfigFile==='function'){
|
|
133
|
-
const host={...ts.sys,readFile(file){configReads.add(path.resolve(file));
|
|
134
|
-
return ts.sys.readFile(file);},onUnRecoverableConfigFileDiagnostic(){}};
|
|
135
|
-
const parseConfig=file=>{
|
|
136
|
-
const absolute=path.resolve(file);
|
|
137
|
-
if(!within(absolute,root))fail('typescript-config-outside-repository');
|
|
138
|
-
if(visited.has(absolute))return;visited.add(absolute);
|
|
139
|
-
if(visited.size>64)fail('typescript-config-graph-too-large');
|
|
140
|
-
const parsed=ts.getParsedCommandLineOfConfigFile(absolute,{},host);
|
|
141
|
-
if(!parsed||parsed.errors?.length)fail('typescript-config-unavailable');
|
|
142
|
-
if(parsed.options?.plugins?.length)fail('typescript-plugin-unsupported');
|
|
143
|
-
if(parsed.options?.incremental||parsed.options?.composite)
|
|
144
|
-
fail('typescript-incremental-unsupported');
|
|
145
|
-
for(const reference of parsed.projectReferences||[])
|
|
146
|
-
parseConfig(reference.path.endsWith('.json')?reference.path:
|
|
147
|
-
path.join(reference.path,'tsconfig.json'));
|
|
148
|
-
};
|
|
149
|
-
parseConfig(path.join(root,'tsconfig.json'));
|
|
150
|
-
}else{
|
|
151
|
-
// TypeScript 7's native compiler does not expose the previous config API.
|
|
152
|
-
// Its CLI reports the effective config; restrict this path to one direct
|
|
153
|
-
// config rather than guessing about extends/reference provenance.
|
|
154
|
-
const config=path.join(root,'tsconfig.json'),raw=fs.readFileSync(config,'utf8');
|
|
155
|
-
if(/"(?:extends|references|plugins|incremental|composite)"\s*:/.test(raw))
|
|
156
|
-
fail('typescript-config-graph-unsupported');
|
|
157
|
-
const shown=spawnSync(process.execPath,[tsc,'--showConfig'],{cwd:root,
|
|
158
|
-
env:process.env,encoding:'utf8',timeout:4000,maxBuffer:16*1024*1024,
|
|
159
|
-
stdio:['ignore','pipe','pipe']});
|
|
160
|
-
if(shown.error||shown.signal||shown.status!==0)fail('typescript-config-unavailable');
|
|
161
|
-
const effective=JSON.parse(shown.stdout);
|
|
162
|
-
if(effective.compilerOptions?.plugins?.length||
|
|
163
|
-
effective.compilerOptions?.incremental||effective.compilerOptions?.composite)
|
|
164
|
-
fail('typescript-config-graph-unsupported');
|
|
165
|
-
configReads.add(config);
|
|
166
|
-
}
|
|
167
|
-
for(const file of configReads)if(!within(realObservedPath(file),root))
|
|
168
|
-
fail('typescript-config-outside-repository');
|
|
169
|
-
const listingStarted=performance.now();
|
|
170
|
-
const captured=process.argv.includes('--redue-checkpoint')?compilerFiles(tsRoot,root):null;
|
|
171
|
-
const listed=captured?{status:0,stdout:captured.files.join('\n')}:spawnSync(process.execPath,[tsc,'--noEmit','--listFilesOnly'],
|
|
172
|
-
{cwd:root,env:process.env,encoding:'utf8',timeout:process.platform==='win32'?10000:4000,
|
|
173
|
-
maxBuffer:16*1024*1024,
|
|
174
|
-
stdio:['ignore','pipe','pipe']});
|
|
175
|
-
timing('typescript_contract.list_files',listingStarted,{status:listed.status??null,
|
|
176
|
-
error:listed.error?.code||null});
|
|
177
|
-
if(listed.error||listed.signal||listed.status!==0)fail('typescript-input-list-unavailable');
|
|
178
|
-
const files=listed.stdout.trim().split(/\r?\n/).filter(Boolean)
|
|
179
|
-
.map(file=>path.resolve(file));
|
|
180
|
-
if(!files.length||files.some(file=>!within(realObservedPath(file),root)))
|
|
181
|
-
fail('typescript-input-outside-repository');
|
|
182
|
-
const facts={schema:1,root:hash(root),script:scriptName,
|
|
183
|
-
configs:[...configReads].sort().map(file=>[hash(file),digestFile(file)]),
|
|
184
|
-
files:[...new Set(files)].sort().map(file=>[hash(file),digestFile(file)]),
|
|
185
|
-
...contextFacts};
|
|
186
|
-
probeResult(hash(JSON.stringify(facts)),contextHash,captured?.queries);
|
|
187
|
-
}catch(e){console.error(`VSTATE_REASON:${e.code&&/^[a-z-]+$/.test(e.code)?e.code:
|
|
188
|
-
'typescript-contract-unavailable'}`);process.exitCode=2;}
|
|
2
|
+
// Retired npm-launcher interpretation. Do not return even a context-only digest:
|
|
3
|
+
// old receipts and cached compiler certificates cannot qualify this obligation.
|
|
4
|
+
console.error('VSTATE_REASON:npm-launcher-unobserved-inputs');
|
|
5
|
+
process.exitCode=2;
|