@redue/cli 0.1.0-alpha.24 → 0.1.0-alpha.26
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 +70 -0
- package/README.md +19 -14
- package/bin/redue.mjs +73 -13
- package/docs/README.md +1 -0
- package/docs/user/direct-typescript.md +8 -0
- package/docs/user/node-script.md +10 -6
- package/docs/user/sandbox.md +169 -0
- package/docs/user/supported-projects.md +8 -5
- package/package.json +1 -1
- package/src/claude-hook.mjs +52 -14
- package/src/cli.mjs +120 -31
- package/src/daemon.mjs +15 -4
- package/src/decision-validation.mjs +2 -1
- package/src/declared-plan.mjs +10 -1
- package/src/direct-run-certificate.mjs +3 -1
- package/src/direct-typescript-probe.mjs +4 -1
- package/src/direct-typescript.mjs +23 -3
- package/src/index.mjs +36 -3
- package/src/landlock.py +183 -0
- package/src/node-onboarding.mjs +41 -20
- package/src/node-script.mjs +16 -1
- package/src/pnpm-inputs.mjs +4 -1
- package/src/presentation.mjs +1 -1
- package/src/python-onboarding.mjs +87 -0
- package/src/sandbox.mjs +396 -0
- package/src/setup.mjs +7 -2
package/src/claude-hook.mjs
CHANGED
|
@@ -22,10 +22,41 @@ const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
|
|
|
22
22
|
const tests=check=>check?.qualification==='npm-tests-direct-v1'&&Array.isArray(check.command)&&
|
|
23
23
|
/^@test-runner:(?:vitest|jest)$/.test(check.command[1]||'');
|
|
24
24
|
// A general recipe check: a root typecheck, test or lint script run through the package manager.
|
|
25
|
-
const general=check=>check?.qualification==='node-script-v1'&&['typecheck','test','lint'].includes(check.kind)
|
|
26
|
-
typeof check.script==='string';
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
const general=check=>(check?.qualification==='node-script-v1'&&['typecheck','test','lint'].includes(check.kind)||
|
|
26
|
+
sandboxed(check))&&typeof check.script==='string';
|
|
27
|
+
// A sandboxed check: a root package script, or a tool from the project's .venv.
|
|
28
|
+
const sandboxed=check=>check?.qualification==='sandbox-v1'&&['typecheck','lint'].includes(check.kind);
|
|
29
|
+
const python=check=>sandboxed(check)&&Array.isArray(check.command)&&/^\.venv\/bin\/[A-Za-z0-9_.-]+$/.test(check.command[0]||'')&&
|
|
30
|
+
check.command.every(word=>typeof word==='string'&&safe.test(word));
|
|
31
|
+
const eligibleCheck=check=>typecheck(check)||tests(check)||general(check)||python(check);
|
|
32
|
+
export const checkKind=check=>tests(check)?'test':general(check)||python(check)?check.kind:'typecheck';
|
|
33
|
+
// The ways an agent runs a Python check's tool: through uv or Poetry, with
|
|
34
|
+
// `python -m`, from .venv/bin, or by name. A form that starts with `python`
|
|
35
|
+
// or the tool's name depends on PATH, so the hook takes it only when PATH
|
|
36
|
+
// finds that program in the project's .venv/bin first (pathForm).
|
|
37
|
+
const wrappers=['uv run ','poetry run '];
|
|
38
|
+
function pythonForms(check){
|
|
39
|
+
const [file,...args]=check.command,tool=file.split('/').at(-1),rest=args.length?' '+args.join(' '):'';
|
|
40
|
+
return [`.venv/bin/${tool}${rest}`,`./.venv/bin/${tool}${rest}`,`.venv/bin/python -m ${tool}${rest}`,
|
|
41
|
+
...wrappers.flatMap(wrapper=>[`${wrapper}${tool}${rest}`,`${wrapper}python -m ${tool}${rest}`]),
|
|
42
|
+
`python -m ${tool}${rest}`,`python3 -m ${tool}${rest}`,`${tool}${rest}`];
|
|
43
|
+
}
|
|
44
|
+
// For a form that names a program PATH finds (`mypy .`, `python -m mypy .`),
|
|
45
|
+
// whether PATH finds it first in the project's .venv/bin.
|
|
46
|
+
export function pathForm(form,projectRoot,env=process.env){
|
|
47
|
+
const word=form.split(' ')[0];
|
|
48
|
+
if(word.includes('/')||wrappers.some(wrapper=>form.startsWith(wrapper)))return true;
|
|
49
|
+
if(!projectRoot)return false;
|
|
50
|
+
for(const dir of String(env.PATH??'').split(path.delimiter)){
|
|
51
|
+
if(!dir||!path.isAbsolute(dir))continue;
|
|
52
|
+
try{fs.accessSync(path.join(dir,word),fs.constants.X_OK);}catch{continue;}
|
|
53
|
+
try{return fs.realpathSync(dir)===fs.realpathSync(path.join(projectRoot,'.venv','bin'));}catch{return false;}
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
// The wrapper a matched form starts with, kept when the hook runs the check
|
|
58
|
+
// through REDUE: `uv run` and `poetry run` sync the environment first.
|
|
59
|
+
const wrapperOf=form=>wrappers.find(wrapper=>form.startsWith(wrapper))||'';
|
|
29
60
|
|
|
30
61
|
export const defaultConfig='redue.config.json';
|
|
31
62
|
// The `redue` prefix for a config beside the project root; null when the name
|
|
@@ -141,7 +172,8 @@ export function hookForms(config,configFile=defaultConfig,packageScripts=null){
|
|
|
141
172
|
const forms=redue?[`${redue} run ${check.name}`]:[],local=[],dir=checkDir(check),key=groupOf(check),
|
|
142
173
|
group=key?scripts.get(key):null,part=group?.length>1;
|
|
143
174
|
const own=group?null:checkScript(check);
|
|
144
|
-
if(dir==='.')
|
|
175
|
+
if(dir==='.'&&python(check))forms.push(...pythonForms(check));
|
|
176
|
+
else if(dir==='.'){
|
|
145
177
|
forms.push(...scriptForms(pm,own),...toolForms(pm,toolText(check,packageScripts)));
|
|
146
178
|
if(!tests(check)&&Array.isArray(check.command)&&check.command.length&&
|
|
147
179
|
check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
|
|
@@ -258,11 +290,16 @@ function split(command){
|
|
|
258
290
|
// {check, rest}, or {fanout, rest} for a turbo/nx/`pnpm -r` command or a root
|
|
259
291
|
// script that runs one; null when nothing matches. `dir` is where the command
|
|
260
292
|
// runs, relative to the project root.
|
|
261
|
-
function matched(command,config,configFile,dir='.',scripts={}){
|
|
293
|
+
function matched(command,config,configFile,dir='.',scripts={},projectRoot=null){
|
|
262
294
|
const parts=split(command);
|
|
263
295
|
if(!parts)return null;
|
|
264
296
|
const row=hookForms(config,configFile,dir==='.'?scripts:null).find(row=>dir==='.'?row.forms.includes(parts.base):
|
|
265
297
|
row.dir===dir&&row.local.includes(parts.base));
|
|
298
|
+
// A Python check's form that depends on PATH matches only where PATH finds
|
|
299
|
+
// the project's .venv/bin.
|
|
300
|
+
const check=row&&config.checks.find(item=>item.name===row.check);
|
|
301
|
+
if(python(check)&&!parts.base.startsWith('redue'))
|
|
302
|
+
return pathForm(parts.base,projectRoot)?{check:row.check,rest:parts.rest,wrapper:wrapperOf(parts.base)}:null;
|
|
266
303
|
// A root script that a general check runs is that check, even when the script fans out.
|
|
267
304
|
if(dir==='.'&&row&&general(config.checks.find(check=>check.name===row.check)))return {check:row.check,rest:parts.rest};
|
|
268
305
|
if(dir==='.'){
|
|
@@ -331,7 +368,7 @@ export function chainCommand(command,config,configFile,dir='.',scripts={},invoke
|
|
|
331
368
|
const names=[];let plain=true;
|
|
332
369
|
const text=parts.map(({separator,text})=>{
|
|
333
370
|
const lead=text.match(/^\s*/)[0],trail=text.match(/\s*$/)[0],body=text.trim();
|
|
334
|
-
const match=matched(body,config,configFile,dir,scripts),split_=split(body);
|
|
371
|
+
const match=matched(body,config,configFile,dir,scripts,projectRoot),split_=split(body);
|
|
335
372
|
// A compiler script is its checks in the order of its runs.
|
|
336
373
|
const checks=match?.check?[match.check]:match?.row&&projectRoot?scriptChecks(match.row,config,projectRoot):null;
|
|
337
374
|
if(!checks?.length||match.rest!==null||!split_||!checks.every(name=>safe.test(name))){
|
|
@@ -339,22 +376,23 @@ export function chainCommand(command,config,configFile,dir='.',scripts={},invoke
|
|
|
339
376
|
if(!plain)return null;
|
|
340
377
|
names.push(...checks);
|
|
341
378
|
// Grouped, so a separator before it applies to all its runs.
|
|
342
|
-
const runs=checks.map(name=>`${redue} run ${name} --if-due`).join(' && ');
|
|
379
|
+
const runs=checks.map(name=>`${match.wrapper||''}${redue} run ${name} --if-due`).join(' && ');
|
|
343
380
|
return `${separator}${lead}${checks.length>1?`{ ${runs}; }`:runs}${split_.suffix}${trail}`;
|
|
344
381
|
});
|
|
345
382
|
return names.length&&!text.includes(null)?{command:text.join(''),names}:null;
|
|
346
383
|
}
|
|
347
|
-
export function matchCheck(command,config,configFile,dir,scripts){
|
|
348
|
-
const match=matched(command,config,configFile,dir,scripts);
|
|
384
|
+
export function matchCheck(command,config,configFile,dir,scripts,projectRoot=null){
|
|
385
|
+
const match=matched(command,config,configFile,dir,scripts,projectRoot);
|
|
349
386
|
return match?.row?match.row.checks.join(' && '):match?.check||null;
|
|
350
387
|
}
|
|
351
388
|
// The same check (or checks, in order) through REDUE, keeping the output
|
|
352
389
|
// suffix; null if it already is, or if more commands follow it.
|
|
353
|
-
|
|
390
|
+
// A `uv run` or `poetry run` wrapper stays in front, so the environment syncs first.
|
|
391
|
+
export function recordedCommand(command,name,configFile=defaultConfig,invoke='redue',wrapper=''){
|
|
354
392
|
const names=Array.isArray(name)?name:[name];
|
|
355
393
|
const parts=split(command),redue=redueFor(configFile)?.replace(/^redue/,()=>invoke);
|
|
356
394
|
if(!parts||parts.rest!==null||!names.length||!names.every(value=>safe.test(value))||!redue)return null;
|
|
357
|
-
const run=names.map(value=>`${redue} run ${value}`).join(' && ');
|
|
395
|
+
const run=names.map(value=>`${wrapper}${redue} run ${value}`).join(' && ');
|
|
358
396
|
return parts.base===run?null:names.length>1&&parts.suffix?`(${run})${parts.suffix}`:run+parts.suffix;
|
|
359
397
|
}
|
|
360
398
|
|
|
@@ -488,7 +526,7 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
|
488
526
|
return cached?.observation?.healthy===true&&chained.names.every(name=>cached.checks?.some(row=>row?.name===name))?
|
|
489
527
|
{hookSpecificOutput:{hookEventName:'PreToolUse',updatedInput:{...input.tool_input,command:entered.prefix+chained.command}}}:null;
|
|
490
528
|
}
|
|
491
|
-
const match=matched(entered.command,parsed,record.config,entered.dir,scripts);
|
|
529
|
+
const match=matched(entered.command,parsed,record.config,entered.dir,scripts,project.projectRoot);
|
|
492
530
|
if(!match)return null;
|
|
493
531
|
// A check followed by more than a simple chain runs untouched.
|
|
494
532
|
if(match.rest!==null&&!match.fanout)return null;
|
|
@@ -530,7 +568,7 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
|
530
568
|
// stays too: the rewrite returns "allow", which would also skip the prompt
|
|
531
569
|
// for the rest of the chain.
|
|
532
570
|
const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
|
|
533
|
-
status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath,invoke??redueInvocation(entry)):null;
|
|
571
|
+
status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath,invoke??redueInvocation(entry),match.wrapper):null;
|
|
534
572
|
// updatedInput replaces the whole tool input. Deny and ask rules still apply
|
|
535
573
|
// to the rewritten command; "allow" skips only the prompt.
|
|
536
574
|
return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
|
package/src/cli.mjs
CHANGED
|
@@ -7,7 +7,9 @@ import {queryAnswer,runFactChange} from './typescript-list.mjs';
|
|
|
7
7
|
import {patternMatcher} from './glob.mjs';
|
|
8
8
|
import {observedBy} from './installed-observation.mjs';
|
|
9
9
|
import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
|
|
10
|
-
import {childProgram,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,pnpmNameless,toolOutput,toolchainDirs} from './node-script.mjs';
|
|
10
|
+
import {childProgram,generalIssue,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,pnpmNameless,toolOutput,toolchainDirs} from './node-script.mjs';
|
|
11
|
+
import {denialLine,endWithChild,runToEnd,sandboxArgv,sandboxContext,sandboxDirs,sandboxEnv,sandboxInterpretation,sandboxPlaces,
|
|
12
|
+
sandboxQualification,sandboxSupport,systemPlaces} from './sandbox.mjs';
|
|
11
13
|
import path from 'node:path';
|
|
12
14
|
import net from 'node:net';
|
|
13
15
|
import os from 'node:os';
|
|
@@ -55,7 +57,17 @@ function publishedProbeDeadline() {
|
|
|
55
57
|
try{return publishedDeadline(JSON.parse(fs.readFileSync(path.join(state,'status.json'),'utf8')).probe_deadline_ms);}
|
|
56
58
|
catch{return 0;}
|
|
57
59
|
}
|
|
58
|
-
|
|
60
|
+
// The wait for a decision, read when it starts: the base at once, and the
|
|
61
|
+
// published deadline when status.json has been read. That file is rewritten
|
|
62
|
+
// at every decision, and a first read of a fresh file is slow where a scanner
|
|
63
|
+
// checks files as they are opened, so it is read off the main thread while
|
|
64
|
+
// the call runs. On Windows it is read before the call, as before: the
|
|
65
|
+
// observer replaces it during the call, and a replacement fails there while a
|
|
66
|
+
// reader holds the file open. An override is exact.
|
|
67
|
+
const decisionWait=override=>override?{timeout:Number(override),deadline:null}:
|
|
68
|
+
process.platform==='win32'?{timeout:Math.max(decisionBaseMs,publishedProbeDeadline()+10000),deadline:null}:{timeout:decisionBaseMs,
|
|
69
|
+
deadline:fs.promises.readFile(path.join(state,'status.json'),'utf8')
|
|
70
|
+
.then(text=>Math.max(decisionBaseMs,publishedDeadline(JSON.parse(text).probe_deadline_ms)+10000),()=>decisionBaseMs)};
|
|
59
71
|
const digest=value=>createHash('sha256').update(value).digest('hex');
|
|
60
72
|
function nodeIdentity() {const real=fs.realpathSync(process.execPath),st=fs.statSync(real,{bigint:true});
|
|
61
73
|
return digest(JSON.stringify([real,process.version,...['dev','ino','mode','size','mtimeNs','ctimeNs']
|
|
@@ -73,7 +85,8 @@ function environmentReads() {
|
|
|
73
85
|
try{const value=JSON.parse(fs.readFileSync(readsFile,'utf8'));return value?.schema===1&&value.checks||{};}catch{return {};}
|
|
74
86
|
}
|
|
75
87
|
const recordedRecipes=[testsQualification,scriptQualification];
|
|
76
|
-
|
|
88
|
+
// A sandboxed check's run is recorded where its sandbox cannot start (execute).
|
|
89
|
+
function contextHashes() {const reads=config.checks.some(check=>[...recordedRecipes,sandboxQualification].includes(check.qualification))?environmentReads():{};
|
|
77
90
|
return Object.fromEntries(config.checks.map(check=>[
|
|
78
91
|
check.name,Object.fromEntries([...(check.environment?.variables||[])
|
|
79
92
|
.map(key=>[key,digest(process.env[key]??'<unset>')]),
|
|
@@ -85,6 +98,8 @@ function contextHashes() {const reads=config.checks.some(check=>recordedRecipes.
|
|
|
85
98
|
...(check.environment?.pathExecutables||[]).map(name=>[
|
|
86
99
|
`__vstate_path_executable_${name}__`,pathExecutableIdentity(name)]),
|
|
87
100
|
...(check.environment?.executableIdentity?[['__vstate_node_identity__',nodeIdentity()]]:[]),
|
|
101
|
+
// A sandboxed run's environment, toolchain and places (sandbox.mjs).
|
|
102
|
+
...(check.qualification===sandboxQualification?Object.entries(sandboxContext(check,process.env,root)):[]),
|
|
88
103
|
// The variables the last test run read directly, compared here: which
|
|
89
104
|
// list was compared, and the first variable whose value now differs.
|
|
90
105
|
// Control requests are small, so the values stay out of them.
|
|
@@ -120,20 +135,26 @@ function owner(){
|
|
|
120
135
|
atomicJson(ownerFile,expected);
|
|
121
136
|
}
|
|
122
137
|
}
|
|
123
|
-
|
|
138
|
+
// deadline, when given, resolves to the whole wait in ms, which replaces
|
|
139
|
+
// timeout when it is longer.
|
|
140
|
+
function call(message,timeout=5000,deadline=null) {
|
|
124
141
|
return new Promise((resolve,reject)=>{
|
|
125
142
|
let data='',done=false;mark('client.control_connect',{deadlineMs:timeout});const client=net.createConnection(socket);
|
|
126
|
-
const
|
|
143
|
+
const started=Date.now(),expire=()=>{mark('client.control_timeout');client.destroy();reject(Error('observer synchronization timeout'));};
|
|
144
|
+
let timer=setTimeout(expire,timeout);
|
|
145
|
+
deadline?.then(total=>{if(done||total<=timeout)return;mark('client.control_deadline',{deadlineMs:total});
|
|
146
|
+
clearTimeout(timer);timer=setTimeout(expire,Math.max(0,total-(Date.now()-started)));});
|
|
127
147
|
client.on('connect',()=>{mark('client.control_submit');client.write(JSON.stringify({...message,...(profiling?{diagnostic:traceContext()}: {})})+'\n');});
|
|
128
148
|
client.on('data',chunk=>data+=chunk);
|
|
129
149
|
client.on('end',()=>{mark('client.control_complete',{bytes:data.length});clearTimeout(timer);if(done)return;done=true;
|
|
130
150
|
try{resolve(JSON.parse(data));}catch(e){reject(e);}});
|
|
131
|
-
client.on('error',e=>{mark('client.control_error',{code:e.code});clearTimeout(timer);reject(e);});
|
|
151
|
+
client.on('error',e=>{mark('client.control_error',{code:e.code});clearTimeout(timer);done=true;reject(e);});
|
|
132
152
|
});
|
|
133
153
|
}
|
|
134
|
-
async function decisionCall(message,timeout) {
|
|
135
|
-
|
|
136
|
-
|
|
154
|
+
async function decisionCall(message,{timeout,deadline}) {
|
|
155
|
+
let total=timeout;deadline?.then(value=>{total=Math.max(total,value);});
|
|
156
|
+
const started=Date.now(),remaining=()=>Math.max(1,total-(Date.now()-started));
|
|
157
|
+
try{return await call(message,timeout,deadline);}
|
|
137
158
|
catch(error){
|
|
138
159
|
if(process.platform!=='win32'||!['ENOENT','ECONNREFUSED','ECONNRESET'].includes(error.code))throw error;
|
|
139
160
|
const pidFile=path.join(state,'observer.lock','pid');
|
|
@@ -195,10 +216,11 @@ async function execute() {
|
|
|
195
216
|
const lockStarted=performance.now(),runLock=acquireRunLock(state);
|
|
196
217
|
timing('cli.run_lock',lockStarted);
|
|
197
218
|
const captureIssues=[];
|
|
198
|
-
let activeChild=null,requestedSignal=null,treeStop=null,preserveRunLock=false;
|
|
219
|
+
let activeChild=null,requestedSignal=null,treeStop=null,preserveRunLock=false,ownGroup=false;
|
|
199
220
|
const forward=signal=>{requestedSignal=signal;
|
|
200
221
|
if(activeChild&&activeChild.exitCode===null){
|
|
201
|
-
|
|
222
|
+
// A Linux run, and a sandboxed one on macOS, has its own process group.
|
|
223
|
+
if((process.platform==='linux'||ownGroup)&&activeChild.pid){
|
|
202
224
|
try{process.kill(-activeChild.pid,signal);}
|
|
203
225
|
catch(e){if(e.code!=='ESRCH')throw e;}
|
|
204
226
|
}else if(process.platform==='win32'&&activeChild.pid){
|
|
@@ -233,8 +255,7 @@ async function execute() {
|
|
|
233
255
|
const environmentBefore=contextHashes()[name];
|
|
234
256
|
const observedContext=selected.qualification===directQualification?{interpretation:directInterpretation,npmProxyDigest:observedNpmContext()}:null;
|
|
235
257
|
timing('cli.context_before',contextStarted);
|
|
236
|
-
const
|
|
237
|
-
captureTimeout=Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||decisionDeadline());
|
|
258
|
+
const captureWait=decisionWait(process.env.VSTATE_CAPTURE_TIMEOUT_MS);
|
|
238
259
|
let before=null,after=null;
|
|
239
260
|
// Expensive state probes can take seconds. Keep the filesystem checkpoint
|
|
240
261
|
// adjacent to process launch so a late notification from earlier work
|
|
@@ -244,7 +265,7 @@ async function execute() {
|
|
|
244
265
|
let probeBefore=callerCertificate?null:probeHash(probePlan);
|
|
245
266
|
const startSnapshotStarted=performance.now();
|
|
246
267
|
try{before=await call({action:'snapshot',name,contextHashes:contextHashes(),
|
|
247
|
-
...(callerCertificate?{runCertificate:1}:{})},
|
|
268
|
+
...(callerCertificate?{runCertificate:1}:{})},captureWait.timeout,captureWait.deadline);}
|
|
248
269
|
catch(error){captureIssues.push('start observation unavailable'+
|
|
249
270
|
(typeof error.code==='string'&&/^[A-Z0-9_]+$/.test(error.code)?` (${error.code})`:''));}
|
|
250
271
|
timing('cli.start_checkpoint',startSnapshotStarted,{available:before?.snapshots?.[name]?1:0});
|
|
@@ -275,10 +296,15 @@ async function execute() {
|
|
|
275
296
|
const recorded=directRunRecordable(selected)&&!unobservedDirectRun;
|
|
276
297
|
const launched=recorded?[command[0],'--require',path.join(here,'run-recorder.cjs'),
|
|
277
298
|
...command.slice(1)]:command;
|
|
299
|
+
// A sandboxed check runs inside the OS sandbox. Where the sandbox cannot
|
|
300
|
+
// start, a script the general recipe takes is recorded as that recipe
|
|
301
|
+
// records it (`fallback`); any other runs as it is, and is not reused.
|
|
302
|
+
const sandboxed=selected.qualification===sandboxQualification,support=sandboxed?sandboxSupport():null;
|
|
303
|
+
const fallback=Boolean(support?.issue&&typeof selected.script==='string'&&!generalIssue(root,selected.script));
|
|
278
304
|
// A test run's processes record what the guard checks (test-recorder.cjs),
|
|
279
305
|
// and so do a general recipe run's, through the package manager.
|
|
280
|
-
const general=selected.qualification===scriptQualification;
|
|
281
|
-
const testRun=recordedRecipes.includes(selected.qualification)&&['darwin','linux'].includes(process.platform);
|
|
306
|
+
const general=selected.qualification===scriptQualification||fallback;
|
|
307
|
+
const testRun=(recordedRecipes.includes(selected.qualification)||fallback)&&['darwin','linux'].includes(process.platform);
|
|
282
308
|
let testRecord=null,launchEnv=process.env,testFacts={},toolchain=[];
|
|
283
309
|
if(testRun){
|
|
284
310
|
// The package directory and installed node_modules, as the plan has them.
|
|
@@ -298,6 +324,27 @@ async function execute() {
|
|
|
298
324
|
// npm's and pnpm's update checks read state outside the checkout and the network.
|
|
299
325
|
if(general)launchEnv.npm_config_update_notifier='false';
|
|
300
326
|
}
|
|
327
|
+
// The sandboxed run, with the environment REDUE gives it.
|
|
328
|
+
let sandbox=null;
|
|
329
|
+
if(sandboxed&&!fallback){
|
|
330
|
+
if(support.issue)captureIssues.push(`the run was not sandboxed: ${support.issue}`);
|
|
331
|
+
else{
|
|
332
|
+
const dirs=sandboxDirs(state,name);
|
|
333
|
+
const reads=[root,dirs.home,dirs.tmp,...sandboxPlaces(selected,process.env,root),...systemPlaces];
|
|
334
|
+
sandbox={mode:support.mode,dirs,argv:sandboxArgv(support.mode,command,{reads,tmp:dirs.tmp,python:support.python}),
|
|
335
|
+
env:sandboxEnv(selected,process.env,{root,...dirs})};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const plainEnv={...process.env,...selected.script?{npm_config_update_notifier:'false'}:{}};
|
|
339
|
+
// Output kept, and its own process group, for a run as this recipe makes it.
|
|
340
|
+
const own=sandboxed&&!fallback;
|
|
341
|
+
ownGroup=own&&process.platform!=='win32';
|
|
342
|
+
// A sandboxed run's output is passed through and its start and end kept,
|
|
343
|
+
// to tell a refusal by the sandbox from a real failure.
|
|
344
|
+
const output={head:'',tail:''};
|
|
345
|
+
const keep=chunk=>{const text=chunk.toString('utf8');
|
|
346
|
+
if(output.head.length<65536)output.head+=text.slice(0,65536-output.head.length);
|
|
347
|
+
output.tail=(output.tail+text).slice(-65536);};
|
|
301
348
|
let runnerPid=null;
|
|
302
349
|
const record=[];
|
|
303
350
|
// A Windows full reconciliation may finish while queued watcher callbacks
|
|
@@ -307,7 +354,7 @@ async function execute() {
|
|
|
307
354
|
await new Promise(resolve=>setTimeout(resolve,100));
|
|
308
355
|
const prelaunchStarted=performance.now();
|
|
309
356
|
try{before=await call({action:'prelaunch',name,
|
|
310
|
-
contextHashes:contextHashes()},Math.min(
|
|
357
|
+
contextHashes:contextHashes()},Math.min(captureWait.timeout,5000));}
|
|
311
358
|
catch{before=null;captureIssues.push('prelaunch observation unavailable');}
|
|
312
359
|
timing('cli.prelaunch_checkpoint',prelaunchStarted,
|
|
313
360
|
{available:before?.snapshots?.[name]?1:0});
|
|
@@ -320,11 +367,19 @@ async function execute() {
|
|
|
320
367
|
if(requestedSignal){resolve({status:'interrupted',exitCode:null,signal:requestedSignal});return;}
|
|
321
368
|
let child,spawnError=null;
|
|
322
369
|
try{runLock.phase('launching');
|
|
323
|
-
const launch=windowsLaunch(launched);
|
|
370
|
+
const launch=windowsLaunch(sandbox?.argv??launched);
|
|
324
371
|
child=spawn(launch.file,launch.args,{cwd:plan.cwd,
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
372
|
+
// A sandboxed run's own group gets no terminal input, which would stop it.
|
|
373
|
+
stdio:recorded?['inherit','inherit','inherit','pipe']:own?['ignore','pipe','pipe']:'inherit',
|
|
374
|
+
env:sandbox?.env??(own?plainEnv:launchEnv),
|
|
375
|
+
detached:process.platform==='linux'||ownGroup,...launch.options});
|
|
376
|
+
child.stdio[3]?.on('data',chunk=>record.push(chunk));
|
|
377
|
+
if(own){child.stdout.on('data',chunk=>{process.stdout.write(chunk);keep(chunk);});
|
|
378
|
+
child.stderr.on('data',chunk=>{process.stderr.write(chunk);keep(chunk);});
|
|
379
|
+
// What the check left running is stopped when it exits, as a CI
|
|
380
|
+
// job's end does (on Linux the launcher also stops daemons that
|
|
381
|
+
// left the group), and the run ends a second after its exit.
|
|
382
|
+
endWithChild(child);}}
|
|
328
383
|
catch(e){resolve({status:'start_failed',exitCode:null,signal:null,errorCode:e.code||e.name});return;}
|
|
329
384
|
activeChild=child;runnerPid=child.pid??null;runLock.phase('running',child.pid??null);
|
|
330
385
|
mark('cli.child_started',{childPid:child.pid});
|
|
@@ -332,7 +387,7 @@ async function execute() {
|
|
|
332
387
|
child.on('error',e=>{spawnError=e.code||e.name;});
|
|
333
388
|
child.on('close',async(exit,signal)=>{
|
|
334
389
|
if(treeStop)await treeStop;
|
|
335
|
-
if(process.platform==='linux'&&requestedSignal&&child.pid){
|
|
390
|
+
if((process.platform==='linux'||ownGroup)&&requestedSignal&&child.pid){
|
|
336
391
|
// A verification command may have forked children in its process
|
|
337
392
|
// group. Normal cancellation must reach them even if the leader has
|
|
338
393
|
// already exited. Commands that daemonize into a new session remain
|
|
@@ -354,7 +409,7 @@ async function execute() {
|
|
|
354
409
|
invocation.argc=command.length;
|
|
355
410
|
invocation.runId=runId;
|
|
356
411
|
invocation.reporting='normal';
|
|
357
|
-
|
|
412
|
+
let result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
|
|
358
413
|
const target={source:'direct-command',status:invocation.status==='exited'?'direct_executed':'unknown'};
|
|
359
414
|
const afterContextStarted=performance.now(),environmentAfter=contextHashes()[name];
|
|
360
415
|
timing('cli.context_after',afterContextStarted);
|
|
@@ -393,11 +448,12 @@ async function execute() {
|
|
|
393
448
|
if(recordFile)try{fs.writeFileSync(recordFile,recordText,{mode:0o600});}catch{}
|
|
394
449
|
const endSnapshotStarted=performance.now();
|
|
395
450
|
if(!unobservedDirectRun){
|
|
451
|
+
// The start checkpoint's probe may have raised the deadline.
|
|
452
|
+
const endWait=decisionWait(process.env.VSTATE_CAPTURE_TIMEOUT_MS);
|
|
396
453
|
try{after=await call({action:'snapshot',name,contextHashes:contextHashes(),
|
|
397
|
-
...(testRun&&Number.isSafeInteger(snap?.inputEventSerial)?{sinceSerial:snap.inputEventSerial}:{}),
|
|
454
|
+
...((testRun||sandboxed)&&Number.isSafeInteger(snap?.inputEventSerial)?{sinceSerial:snap.inputEventSerial}:{}),
|
|
398
455
|
...(recording?{runCertificate:1,adopt:recording}:recorded&&!certificate?{runCertificate:1,runFacts:1}:{})},
|
|
399
|
-
|
|
400
|
-
Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||decisionDeadline()));}
|
|
456
|
+
endWait.timeout,endWait.deadline);}
|
|
401
457
|
catch{captureIssues.push('end observation unavailable');}
|
|
402
458
|
finally{if(recordFile)fs.rmSync(recordFile,{force:true});}
|
|
403
459
|
timing('cli.end_checkpoint',endSnapshotStarted,{available:after?.snapshots?.[name]?1:0});
|
|
@@ -460,12 +516,40 @@ async function execute() {
|
|
|
460
516
|
// A recorded certificate changes only the probe part of the fingerprint.
|
|
461
517
|
if(!outputs&&(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
|
|
462
518
|
snap.revision!==end.revision))captureIssues.push('declared inputs changed or were not observed');
|
|
519
|
+
// A sandboxed run that failed with what looks like a refusal by the
|
|
520
|
+
// sandbox runs again outside it. When that run exits differently, the
|
|
521
|
+
// sandbox changed the result: the plain run's result is kept, not reused.
|
|
522
|
+
// An interrupt before or during it stops there: the run is interrupted.
|
|
523
|
+
let sandboxRerun=null;
|
|
524
|
+
if(sandbox&&result==='FAIL'&&!requestedSignal){
|
|
525
|
+
const line=denialLine(output.head+'\n'+output.tail);
|
|
526
|
+
if(line){
|
|
527
|
+
console.error(`redue: the sandboxed run failed with what looks like a refusal by the sandbox (${line}); running it again outside the sandbox`);
|
|
528
|
+
// Interrupting REDUE interrupts the rerun's group too (forward).
|
|
529
|
+
const plain=await runToEnd(command,{cwd:plan.cwd,env:plainEnv,echo:true,started:child=>{activeChild=child;}});
|
|
530
|
+
activeChild=null;
|
|
531
|
+
sandboxRerun={line,exitCode:plain.status,signal:plain.signal||null,...plain.error?{errorCode:plain.error.code||plain.error.name}:{}};
|
|
532
|
+
if(requestedSignal){
|
|
533
|
+
captureIssues.push('interrupted during the run outside the sandbox');
|
|
534
|
+
Object.assign(invocation,{status:'interrupted',exitCode:null,signal:requestedSignal});
|
|
535
|
+
}
|
|
536
|
+
// A rerun that did not start or finish tells nothing: the sandboxed result stands, not reused.
|
|
537
|
+
else if(plain.error||plain.status===null)
|
|
538
|
+
captureIssues.push(`the run outside the sandbox did not finish (${plain.error?.code||plain.signal}), so a refusal by the sandbox is not ruled out (${line})`);
|
|
539
|
+
else if(plain.status!==invocation.exitCode){
|
|
540
|
+
captureIssues.push(`the sandbox changed the result: exit ${invocation.exitCode} inside it, ${plain.status??plain.signal} outside it (${line})`);
|
|
541
|
+
invocation.sandboxedExitCode=invocation.exitCode;invocation.exitCode=plain.status;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if(sandbox)fs.rmSync(sandbox.dirs.tmp,{recursive:true,force:true});
|
|
463
546
|
const stable=captureIssues.length===0;
|
|
464
547
|
// Two runs on the same inputs, environment and plan that disagree make the
|
|
465
548
|
// check nondeterministic until its inputs change.
|
|
466
549
|
let nondeterministic=false;
|
|
467
550
|
const declared=hashes=>Object.entries(hashes||{}).filter(([key])=>!key.startsWith('__redue_env_'));
|
|
468
|
-
if(
|
|
551
|
+
if(sandboxRerun)result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
|
|
552
|
+
if((testRun||sandboxed)&&stable&&result){
|
|
469
553
|
const prior=readReceipts(state).checks[name];
|
|
470
554
|
// A difference explained by inputs REDUE did not observe proves nothing.
|
|
471
555
|
nondeterministic=Boolean(!guard?.issues.length&&prior?.stable&&!prior.guardIssues?.length&&prior.result&&prior.planId===(snap?.planId??plan.id)&&
|
|
@@ -478,7 +562,7 @@ async function execute() {
|
|
|
478
562
|
// A recorded run's receipt carries its run facts, even none; observers that
|
|
479
563
|
// predate run facts do not reuse version 2.
|
|
480
564
|
// The recipe that made the receipt, which status and the hook name.
|
|
481
|
-
const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,recipe:selected.qualification||null,
|
|
565
|
+
const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,recipe:(fallback?scriptQualification:selected.qualification)||null,
|
|
482
566
|
...(recorded||testRun?{runFacts}:{}),
|
|
483
567
|
...(testRun?{verificationRecipe:general?scriptInterpretation:testsInterpretation,guardIssues:guard?.issues||['the run record is unavailable'],
|
|
484
568
|
environmentReads:guard?.environmentReads||{},...(nondeterministic?{nondeterministic}:{})}:{}),
|
|
@@ -492,6 +576,8 @@ async function execute() {
|
|
|
492
576
|
planId:snap?.planId??plan.id,fingerprint:(recording||outputs?end:snap)?.fingerprint??null,
|
|
493
577
|
provider:plan.provider,environmentHashes:environmentBefore,
|
|
494
578
|
...(observedContext?{observedContext,verificationRecipe:directInterpretation}:{}),
|
|
579
|
+
...(own?{verificationRecipe:sandboxInterpretation,sandbox:{mode:sandbox?.mode??null,...sandboxRerun?{rerun:sandboxRerun}:{}}}:{}),
|
|
580
|
+
...(fallback?{sandbox:{mode:null,fallback:'node-script',issue:support.issue}}:{}),
|
|
495
581
|
files:(outputs?end:snap)?.files??null,observerGeneration:before?.observation?.generation??null,
|
|
496
582
|
startRevision:snap?.revision??null,endRevision:end?.revision??null};
|
|
497
583
|
const persistenceStarted=performance.now();
|
|
@@ -601,7 +687,7 @@ async function main(){
|
|
|
601
687
|
observedNpmDigest:observedNpmContext(),proxyShapes});
|
|
602
688
|
}
|
|
603
689
|
let value;try{value=await decisionCall({action:'sync',contextHashes:contextHashes()},
|
|
604
|
-
|
|
690
|
+
decisionWait(process.env.VSTATE_SYNC_TIMEOUT_MS));
|
|
605
691
|
// A client upgrade can encounter an older running observer. Its successful
|
|
606
692
|
// control response does not establish that it evaluated the latest receipt.
|
|
607
693
|
if(value.receipt_revision===null){
|
|
@@ -615,7 +701,9 @@ async function main(){
|
|
|
615
701
|
}}
|
|
616
702
|
catch(e){value=unavailableCached(state,e.message,config.checks);process.exitCode=2;}
|
|
617
703
|
timing('cli.synchronized_status',started,{available:value.observation?.healthy?1:0});
|
|
618
|
-
|
|
704
|
+
// In the front's process (bin/redue.mjs), the value is handed back.
|
|
705
|
+
if(action==='sync'&&process.argv.includes('--value'))syncValue=value;
|
|
706
|
+
else if(action==='sync')console.log(JSON.stringify(value));
|
|
619
707
|
else for(const row of value.checks)console.log(`${row.name}: ${row.freshness}/${row.result||'NO RESULT'} — ${row.reason}`);
|
|
620
708
|
return;
|
|
621
709
|
}
|
|
@@ -623,4 +711,5 @@ async function main(){
|
|
|
623
711
|
if(action==='run'){await execute();return;}
|
|
624
712
|
throw Error(`unknown action ${action}`);
|
|
625
713
|
}
|
|
626
|
-
|
|
714
|
+
export let syncValue=null;
|
|
715
|
+
export const finished=main().catch(e=>{console.error(`redue: ${e.message}`);process.exitCode=2;});
|
package/src/daemon.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import {npmCacheDir} from './npm-integrity.mjs';
|
|
|
25
25
|
import {runFromArgv} from './typescript-invocation.mjs';
|
|
26
26
|
import {buildInfoPath,recordedCertificate} from './direct-run-certificate.mjs';
|
|
27
27
|
import {timing,mark,traceBase,traceContext,traceScope,traceEnvironment,traceId} from './decision-profile.mjs';
|
|
28
|
+
import {sandboxChange} from './sandbox.mjs';
|
|
28
29
|
|
|
29
30
|
const configFile=path.resolve(process.argv[2]);
|
|
30
31
|
const config=JSON.parse(fs.readFileSync(configFile));
|
|
@@ -63,7 +64,9 @@ let identityChanged=false;
|
|
|
63
64
|
let identityGapKey=null;
|
|
64
65
|
let triggerHashes=new Map();
|
|
65
66
|
let candidateDescendants=new Map();
|
|
66
|
-
|
|
67
|
+
// A Python project may have no package.json.
|
|
68
|
+
let workspacePatterns=(()=>{let w;try{w=JSON.parse(fs.readFileSync(path.join(root,'package.json'))).workspaces;}
|
|
69
|
+
catch(e){if(e.code==='ENOENT')return [];throw e;}
|
|
67
70
|
return Array.isArray(w)?w:w?.packages||[];})();
|
|
68
71
|
let planningWorker=null;
|
|
69
72
|
let validatedPlan=null;
|
|
@@ -269,6 +272,7 @@ function probe(row,allowReuse=false,{launch=true}={}) {
|
|
|
269
272
|
'typescript-plugin-unsupported':'TypeScript compiler plugin needs explicit review',
|
|
270
273
|
'typescript-input-list-unavailable':'TypeScript input listing did not complete',
|
|
271
274
|
'typescript-input-outside-repository':'TypeScript consumes files outside the observed repository',
|
|
275
|
+
'typescript-vite-cache-consumed':'TypeScript asks about node_modules/.vite or lists node_modules; Vitest rewrites files there and this recipe does not observe them',
|
|
272
276
|
'npm-installation-layout-unsupported':'npm executable or installation layout is unsupported',
|
|
273
277
|
'npm-installation-unobservable':'npm installation contains unobservable entries',
|
|
274
278
|
'npm-configuration-unsupported':'npm script configuration needs review',
|
|
@@ -395,13 +399,19 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
395
399
|
if(r.target.status==='failed'&&result!=='FAIL'){
|
|
396
400
|
item.reason='target failure conflicts with outer invocation outcome';return item;}
|
|
397
401
|
if(!r.stable){const issues=r.checkpoint?.issues?.length?` (${r.checkpoint.issues.join('; ')})`:'';
|
|
398
|
-
|
|
402
|
+
// A sandboxed run that could not start, whose result the sandbox changed, or whose rerun outside it did not finish, says so alone.
|
|
403
|
+
const sandbox=r.checkpoint?.issues?.length&&r.checkpoint.issues.every(issue=>/^the (?:sandbox changed|run was not sandboxed|run outside the sandbox)/.test(issue));
|
|
404
|
+
item.reason=sandbox?r.checkpoint.issues.join('; '):`inputs changed during execution or observation gap${issues}`;return item;}
|
|
399
405
|
const names=[...(row.plan.environment.variables||[]),
|
|
400
406
|
...(row.plan.environment.prefixes||[]).map(prefix=>
|
|
401
407
|
`__vstate_env_prefix_${prefix.toLowerCase()}__`),
|
|
402
408
|
...(row.plan.environment.pathExecutables||[]).map(name=>
|
|
403
409
|
`__vstate_path_executable_${name}__`),
|
|
404
410
|
...(row.plan.environment.executableIdentity?['__vstate_node_identity__']:[])];
|
|
411
|
+
// A sandboxed run's variables, toolchain, PATH directories and places
|
|
412
|
+
// (sandbox.mjs): every entry either side has, and the first change named.
|
|
413
|
+
const sandboxChanged=row.plan.qualification==='sandbox-v1'&&contextHashes?.[name]?
|
|
414
|
+
sandboxChange(r?.environmentHashes,contextHashes[name]):null;
|
|
405
415
|
if(names.length&&!contextHashes?.[name])unconfirmed??='execution environment context not supplied';
|
|
406
416
|
item.freshness='STALE';
|
|
407
417
|
if(r.planId!==row.plan.id){
|
|
@@ -410,6 +420,7 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
410
420
|
item.reason=changed?`installed input link changed: ${changed.path}`:'input plan changed';
|
|
411
421
|
if(changed)item.changed_inputs=[changed.path];
|
|
412
422
|
}
|
|
423
|
+
else if(sandboxChanged)item.reason=sandboxChanged;
|
|
413
424
|
else if(contextHashes?.[name]&&names.some(key=>contextHashes[name][key]!==r.environmentHashes?.[key]))
|
|
414
425
|
item.reason='declared execution environment changed';
|
|
415
426
|
// Every variable a test run read directly must have the same value. The
|
|
@@ -795,8 +806,8 @@ function markInputEvent(name,rel,type,replay) {
|
|
|
795
806
|
if(!hasHistory||historyDisabled||replay){
|
|
796
807
|
const serial=(inputEventSerial.get(name)||0)+1;
|
|
797
808
|
inputEventSerial.set(name,serial);
|
|
798
|
-
// Kept only for recorded test and script checks; trimmed in batches.
|
|
799
|
-
if(['npm-tests-direct-v1','node-script-v1'].includes(index?.checks.get(name)?.plan.qualification)){
|
|
809
|
+
// Kept only for recorded test and script checks and sandboxed ones; trimmed in batches.
|
|
810
|
+
if(['npm-tests-direct-v1','node-script-v1','sandbox-v1'].includes(index?.checks.get(name)?.plan.qualification)){
|
|
800
811
|
const list=inputEventPaths.get(name)||[];list.push([serial,rel]);
|
|
801
812
|
if(list.length>2*eventPathLimit)list.splice(0,list.length-eventPathLimit);inputEventPaths.set(name,list);
|
|
802
813
|
}
|
|
@@ -17,7 +17,8 @@ export function planGuard(bundle) {
|
|
|
17
17
|
...bundle.workspaceManifests||[],...bundle.configurationInputs||[],
|
|
18
18
|
...Object.values(bundle.plans).flatMap(plan=>plan.resolutionTriggers||[])]);
|
|
19
19
|
const patterns=bundle.workspacePatterns?.length?bundle.workspacePatterns:
|
|
20
|
-
(()=>{
|
|
20
|
+
(()=>{let pkg;try{pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json')));}
|
|
21
|
+
catch(e){if(e.code==='ENOENT')return [];throw e;}
|
|
21
22
|
return Array.isArray(pkg.workspaces)?pkg.workspaces:pkg.workspaces?.packages||[];})();
|
|
22
23
|
if(patterns.length){
|
|
23
24
|
const files=execFileSync('git',['ls-files','--cached','--others','--exclude-standard',
|
package/src/declared-plan.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import {qualifyTypeScript} from './typescript-qualification.mjs';
|
|
|
8
8
|
import {directBuildInfo} from './direct-typescript.mjs';
|
|
9
9
|
import {qualifyTests,testWorkspacePatterns,testsQualification} from './test-direct.mjs';
|
|
10
10
|
import {qualifyNodeScript,scriptQualification} from './node-script.mjs';
|
|
11
|
+
import {qualifySandbox,sandboxInstalledExclusions,sandboxQualification} from './sandbox.mjs';
|
|
11
12
|
import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
|
|
12
13
|
import {pnpmTypecheckInputs} from './pnpm-inputs.mjs';
|
|
13
14
|
import {checkRun} from './typescript-invocation.mjs';
|
|
@@ -78,12 +79,17 @@ export function discoverDeclared(config) {
|
|
|
78
79
|
if(selected.qualification==='npm-typescript-direct-v1')
|
|
79
80
|
try{buildInfo=directBuildInfo(root,checkRun(selected));}catch{}
|
|
80
81
|
const excluded=buildInfo?['!'+buildInfo]:[];
|
|
82
|
+
// A sandboxed check leaves out the .venv's links that lead out of the
|
|
83
|
+
// checkout; its qualification names them.
|
|
84
|
+
const sandboxed=selected.qualification===sandboxQualification;
|
|
85
|
+
if(sandboxed)excluded.push(...sandboxInstalledExclusions(root));
|
|
81
86
|
const installedStarted=performance.now();
|
|
82
87
|
const installed=resolveDeclaredInstalled(root,
|
|
83
88
|
[...(selected.installedInputs||[]),...(workspace?.installed||[]),...excluded],allowedRoots);
|
|
84
89
|
timing('plan.installed_resolution',installedStarted,{mappings:installed.mappings.length});
|
|
85
90
|
const automatic=selected.qualification===testsQualification?qualifyTests(root,selected):
|
|
86
91
|
selected.qualification===scriptQualification?qualifyNodeScript(root,selected):
|
|
92
|
+
sandboxed?qualifySandbox(root,selected):
|
|
87
93
|
qualifyTypeScript(root,selected,{workspace,issue:workspaceIssue});
|
|
88
94
|
const sourcePatterns=[...new Set([...selected.inputs,...(workspace?.source||[]),
|
|
89
95
|
...(workspace?.generated||[]),...excluded])].sort();
|
|
@@ -97,7 +103,8 @@ export function discoverDeclared(config) {
|
|
|
97
103
|
// A workspace test check, and a general check in a workspace, is qualified
|
|
98
104
|
// against the current package list: a package manifest appearing or pnpm's
|
|
99
105
|
// package list changing re-plans.
|
|
100
|
-
if(selected.qualification===testsQualification&&selected.workspace||selected.qualification===scriptQualification
|
|
106
|
+
if(selected.qualification===testsQualification&&selected.workspace||selected.qualification===scriptQualification||
|
|
107
|
+
sandboxed&&typeof selected.script==='string')try{
|
|
101
108
|
for(const item of testWorkspacePatterns(root,JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'))))
|
|
102
109
|
workspacePatterns.add(item);
|
|
103
110
|
if(fs.existsSync(path.join(root,'pnpm-workspace.yaml')))configurationInputs.add('pnpm-workspace.yaml');
|
|
@@ -136,6 +143,8 @@ export function discoverDeclared(config) {
|
|
|
136
143
|
probes:selected.probes||[],environment:selected.environment||{},
|
|
137
144
|
qualification:selected.qualification||null,
|
|
138
145
|
...(buildInfo?{buildInfo}:{}),
|
|
146
|
+
// A sandboxed run's reads are not recorded: git-ignored files are inputs too.
|
|
147
|
+
...(sandboxed?{ignoredInputs:true}:{}),
|
|
139
148
|
executionProvenance:'declared-command',
|
|
140
149
|
unresolved:[...new Set(unresolved)].sort()};
|
|
141
150
|
plan.resolutionCandidates=workspace?.resolutionCandidates||[];
|
|
@@ -7,7 +7,7 @@ import {createHash} from 'node:crypto';
|
|
|
7
7
|
import {inflateSync} from 'node:zlib';
|
|
8
8
|
import {fileURLToPath} from 'node:url';
|
|
9
9
|
import {timing} from './decision-profile.mjs';
|
|
10
|
-
import {directCompiler,directContextHash,directEnvironmentIssue,directProbeOutput,directProbeValue} from './direct-typescript.mjs';
|
|
10
|
+
import {directCompiler,directContextHash,directEnvironmentIssue,directProbeOutput,directProbeValue,viteCacheQuestion} from './direct-typescript.mjs';
|
|
11
11
|
import {withinPath} from './path-identity.mjs';
|
|
12
12
|
const hash=value=>createHash('sha256').update(value).digest('hex');
|
|
13
13
|
// Whether a direct run is recorded at all: on macOS and Linux, always, so its
|
|
@@ -158,6 +158,8 @@ export function runRecordIssue(record,certificate,runFacts=null) {
|
|
|
158
158
|
if(kept.issue)return kept.issue;
|
|
159
159
|
if(record.main!==compiler.entry||!Array.isArray(record.node)||
|
|
160
160
|
directContextHash(compiler,record.node)!==certificate.context)return 'execution context changed';
|
|
161
|
+
const vite=viteCacheQuestion(compiler.root,kept.facts);
|
|
162
|
+
if(vite)return `the compiler asked about ${vite}, which Vitest rewrites and REDUE does not observe`;
|
|
161
163
|
const inCompiler=withinCompiler(compiler);
|
|
162
164
|
const facts=new Map();
|
|
163
165
|
for(const query of queries){if(!facts.has(query[1]))facts.set(query[1],[]);facts.get(query[1]).push(query);}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {directProject,directInterpretation,directContextHash,directProbeOutput} from './direct-typescript.mjs';
|
|
2
|
+
import {directProject,directInterpretation,directContextHash,directProbeOutput,viteCacheQuestion} from './direct-typescript.mjs';
|
|
3
3
|
import {typeScriptInputFacts} from './typescript-input-facts.mjs';
|
|
4
4
|
import {contextOnly,probeResult} from './probe-result.mjs';
|
|
5
5
|
import {realObservedPath} from './path-identity.mjs';
|
|
@@ -18,6 +18,9 @@ try{
|
|
|
18
18
|
const contextHash=directContextHash(compiler,context.node);
|
|
19
19
|
if(!contextOnly(contextHash)){
|
|
20
20
|
const {queries,configs,files,buildInfo}=typeScriptInputFacts(root,compiler.tsRoot,compiler.entry,compiler.config);
|
|
21
|
+
// Captured questions include every config and program file read.
|
|
22
|
+
if(viteCacheQuestion(root,queries||[...configs,...files].map(([file])=>['readFile',file])))
|
|
23
|
+
throw Object.assign(Error('typescript-vite-cache-consumed'),{code:'typescript-vite-cache-consumed'});
|
|
21
24
|
const layout={root,script,configs,files,context,...(buildInfo?{buildInfo}:{})};
|
|
22
25
|
probeResult(directProbeOutput(layout),contextHash,queries,layout);
|
|
23
26
|
}
|