@redue/cli 0.1.0-alpha.18 → 0.1.0-alpha.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +8 -4
- package/bin/redue.mjs +10 -3
- package/docs/user/configuration.md +2 -0
- package/docs/user/getting-started.md +7 -7
- package/docs/user/node-script.md +153 -0
- package/docs/user/supported-projects.md +12 -10
- package/docs/user/tests-direct.md +5 -3
- package/package.json +1 -1
- package/src/agent-integration.mjs +14 -6
- package/src/claude-hook.mjs +24 -12
- package/src/cli.mjs +32 -14
- package/src/daemon.mjs +3 -2
- package/src/declared-plan.mjs +6 -3
- package/src/index.mjs +10 -4
- package/src/node-onboarding.mjs +39 -7
- package/src/node-script.mjs +362 -0
- package/src/pnpm-shim-templates.json +180 -0
- package/src/pnpm-shims.mjs +54 -0
- package/src/presentation.mjs +8 -1
- package/src/test-direct.mjs +42 -9
- package/src/test-recorder.cjs +101 -14
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// pnpm's launchers. On macOS and Linux, pnpm writes each node_modules/.bin
|
|
2
|
+
// entry as a `#!/bin/sh` script that finds its own directory (with sed,
|
|
3
|
+
// dirname, readlink, uname or printf, depending on the pnpm release), sets
|
|
4
|
+
// NODE_PATH and execs Node on the package's bin file. A launcher is trusted only
|
|
5
|
+
// when its text is one of the templates pnpm releases write
|
|
6
|
+
// (pnpm-shim-templates.json, built by eval/pnpm-shim-templates.mjs), with only
|
|
7
|
+
// the NODE_PATH value, the bin file and the trailing target comment filled in.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
const templates=JSON.parse(fs.readFileSync(new URL('pnpm-shim-templates.json',import.meta.url),'utf8')).templates;
|
|
12
|
+
const known=new Set(templates.map(row=>row.template));
|
|
13
|
+
// A value with no quote, expansion, escape or line break: it stays literal in
|
|
14
|
+
// the shell.
|
|
15
|
+
const literal=/^[^"$`\\\n]*$/;
|
|
16
|
+
|
|
17
|
+
// The launcher's text as a template, with the values it fills in, or null
|
|
18
|
+
// when it does not have pnpm's form.
|
|
19
|
+
export function shimTemplate(text){
|
|
20
|
+
if(typeof text!=='string'||!text.startsWith('#!/bin/sh\n'))return null;
|
|
21
|
+
let template=text,nodePath=null;
|
|
22
|
+
// NODE_PATH, set alone or in front of the caller's.
|
|
23
|
+
const alone=/\n export NODE_PATH="([^"\n]*)"\n/.exec(text);
|
|
24
|
+
if(alone){
|
|
25
|
+
nodePath=alone[1];
|
|
26
|
+
// Each entry is literal, or relative to the launcher's own directory
|
|
27
|
+
// ($basedir_abs, which pnpm 12.6 and later write).
|
|
28
|
+
if(!nodePath.split(':').every(entry=>literal.test(entry.replace(/^\$basedir(?:_abs)?\//,'')))||
|
|
29
|
+
!text.includes(`\n export NODE_PATH="${nodePath}:$NODE_PATH"\n`))return null;
|
|
30
|
+
template=template.split(`\n export NODE_PATH="${nodePath}:$NODE_PATH"\n`).join('\n export NODE_PATH="@@NODE_PATH@@:$NODE_PATH"\n')
|
|
31
|
+
.split(`\n export NODE_PATH="${nodePath}"\n`).join('\n export NODE_PATH="@@NODE_PATH@@"\n');
|
|
32
|
+
}
|
|
33
|
+
// The bin file, relative to the launcher's directory, after $basedir,
|
|
34
|
+
// $basedir_abs or $basedir_win; `node` and `node.exe` there are Node itself.
|
|
35
|
+
const targets=new Set([...template.matchAll(/"\$basedir(?:_abs|_win)?\/([^"\n]+)"/g)].map(match=>match[1])
|
|
36
|
+
.filter(value=>value!=='node'&&value!=='node.exe'));
|
|
37
|
+
if(targets.size!==1)return null;
|
|
38
|
+
const [target]=targets;
|
|
39
|
+
if(!literal.test(target))return null;
|
|
40
|
+
for(const prefix of ['$basedir','$basedir_abs','$basedir_win'])
|
|
41
|
+
template=template.split(`"${prefix}/${target}"`).join(`"${prefix}/@@TARGET@@"`);
|
|
42
|
+
// pnpm 11 and later end with a comment naming the target.
|
|
43
|
+
template=template.replace(/\n# cmd-shim-target=[^\n]*\n?$/,'\n# cmd-shim-target=@@COMMENT@@\n');
|
|
44
|
+
return {template,nodePath,target};
|
|
45
|
+
}
|
|
46
|
+
// The bin file a pnpm launcher runs with Node, as an absolute path, or null
|
|
47
|
+
// when `file` is not a launcher pnpm writes.
|
|
48
|
+
export function pnpmShimTarget(file,text=null){
|
|
49
|
+
try{
|
|
50
|
+
text??=fs.readFileSync(file,'utf8');
|
|
51
|
+
const shim=shimTemplate(text);
|
|
52
|
+
return shim&&known.has(shim.template)?path.resolve(path.dirname(file),shim.target):null;
|
|
53
|
+
}catch{return null;}
|
|
54
|
+
}
|
package/src/presentation.mjs
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
// Presentation consumes evidence; it never grants applicability.
|
|
2
|
+
// The recipe that made a receipt, by the qualification it names.
|
|
3
|
+
const recipes={'npm-typescript-direct-v1':'direct TypeScript recipe','npm-tests-direct-v1':'direct test recipe',
|
|
4
|
+
'node-script-v1':'general recipe','pnpm-tsc-v1':'pnpm TypeScript recipe','yarn-workspace-tsc-v1':'Yarn workspace TypeScript recipe',
|
|
5
|
+
'typescript-noemit-v1':'npm TypeScript recipe'};
|
|
6
|
+
export const recipeLabel=recipe=>recipes[recipe]??null;
|
|
2
7
|
export function checkLabel(row) {
|
|
3
8
|
return row.result==='FAIL'?'FAILED':row.freshness;
|
|
4
9
|
}
|
|
@@ -21,7 +26,8 @@ export function renderStatus(value,{short=false}={}) {
|
|
|
21
26
|
const label=checkLabel(row),symbol=label==='CURRENT'&&row.result==='PASS'?'✓':label==='FAILED'?'✗':'?';
|
|
22
27
|
const mark=label==='STALE'?'⚠':symbol;
|
|
23
28
|
const historical=row.result?` / ${row.result}`:'';
|
|
24
|
-
|
|
29
|
+
const recipe=label==='CURRENT'?recipeLabel(row.recipe):null;
|
|
30
|
+
lines.push(`${mark} ${row.name} ${label}${historical}${recipe?` (${recipe})`:''}`);
|
|
25
31
|
if(label!=='CURRENT')lines.push(` ${row.result==='FAIL'?`Historical failure; applicability ${row.freshness}. `:''}${explanation(row)}`);
|
|
26
32
|
}
|
|
27
33
|
if(!lines.length)lines.push('UNVERIFIED — observation is initializing; no check state is available yet.');
|
|
@@ -34,6 +40,7 @@ export function renderExplain(value,name) {
|
|
|
34
40
|
const lines=[`${row.name}: ${checkLabel(row)}${row.result?` / ${row.result}`:''}`];
|
|
35
41
|
if(row.result==='FAIL')lines.push(`Historical failure; applicability: ${row.freshness}.`);
|
|
36
42
|
if(row.verification_recipe==='npm-direct-typescript@1')lines.push('Recorded recipe: direct local TypeScript compiler; npm and lifecycle scripts were not executed.');
|
|
43
|
+
else if(recipeLabel(row.recipe))lines.push(`Recorded recipe: ${recipeLabel(row.recipe)}.`);
|
|
37
44
|
lines.push(explanation(row));
|
|
38
45
|
if(row.changed_inputs?.length)lines.push('Relevant inputs changed:',...row.changed_inputs.map(file=>` ${file}`));
|
|
39
46
|
if(row.freshness==='STALE')lines.push(`Due again when verification is needed: redue run ${JSON.stringify(row.name)}`);
|
package/src/test-direct.mjs
CHANGED
|
@@ -61,8 +61,12 @@ export function recordedEnvironment(guardFile,env=process.env) {
|
|
|
61
61
|
// input (relative path) at the start checkpoint to its digest; `runner` is the
|
|
62
62
|
// PID of the process REDUE started. Returns {issues, runFacts, environmentReads,
|
|
63
63
|
// isOutput}: environmentReads maps each variable the run read directly to the
|
|
64
|
-
// caller's value digest, or '<unset>'.
|
|
65
|
-
|
|
64
|
+
// caller's value digest, or '<unset>'. `programs` is the general recipe's
|
|
65
|
+
// account of processes (node-script.mjs): `judge(child, parentMain)` says
|
|
66
|
+
// whether a started program is accounted for and must load the recorder,
|
|
67
|
+
// `runner` describes the program REDUE started, and `trusted` names checkout
|
|
68
|
+
// paths (runner caches) whose use is not an input.
|
|
69
|
+
export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=testEnvironment,coverage=null,programs=null}) {
|
|
66
70
|
// The recipe's variables and any the project declares are compared, not guarded.
|
|
67
71
|
const compared=name=>(environment.variables||[]).includes(name)||
|
|
68
72
|
(environment.prefixes||[]).some(prefix=>name.toLowerCase().startsWith(prefix.toLowerCase()));
|
|
@@ -82,16 +86,28 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
|
|
|
82
86
|
headers.set(`${header.pid}-${header.threadId}`,header);
|
|
83
87
|
records.push({header,rows:rows.slice(1)});
|
|
84
88
|
}
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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=>!
|
|
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){
|
package/src/test-recorder.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
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',
|
|
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
|
}
|