@redue/cli 0.1.0-alpha.25 → 0.1.0-alpha.27
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 +84 -0
- package/README.md +27 -12
- package/bin/redue.mjs +77 -10
- package/docs/README.md +1 -0
- package/docs/user/node-script.md +10 -6
- package/docs/user/sandbox.md +221 -0
- package/docs/user/supported-projects.md +9 -5
- package/package.json +1 -1
- package/src/claude-hook.mjs +60 -14
- package/src/cli.mjs +136 -16
- package/src/daemon.mjs +51 -6
- package/src/decision-validation.mjs +2 -1
- package/src/declared-plan.mjs +10 -1
- package/src/index.mjs +37 -3
- package/src/landlock.py +228 -0
- package/src/node-onboarding.mjs +41 -20
- package/src/node-script.mjs +16 -1
- package/src/presentation.mjs +2 -1
- package/src/python-onboarding.mjs +87 -0
- package/src/sandbox.mjs +493 -0
- package/src/setup.mjs +7 -2
- package/src/stats.mjs +72 -0
package/src/claude-hook.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {processAlive} from './process-liveness.mjs';
|
|
|
13
13
|
import {scriptRunChecks} from './typescript-invocation.mjs';
|
|
14
14
|
import {recipeLabel} from './presentation.mjs';
|
|
15
15
|
import {findExecutable} from './executable-lookup.mjs';
|
|
16
|
+
import {note,skipNote,dueNote} from './stats.mjs';
|
|
16
17
|
|
|
17
18
|
export const hookCommand='redue agent hook claude';
|
|
18
19
|
// No shell metacharacters; `@` starts scoped package names.
|
|
@@ -22,10 +23,42 @@ const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
|
|
|
22
23
|
const tests=check=>check?.qualification==='npm-tests-direct-v1'&&Array.isArray(check.command)&&
|
|
23
24
|
/^@test-runner:(?:vitest|jest)$/.test(check.command[1]||'');
|
|
24
25
|
// 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
|
-
|
|
26
|
+
const general=check=>(check?.qualification==='node-script-v1'&&['typecheck','test','lint'].includes(check.kind)||
|
|
27
|
+
sandboxed(check))&&typeof check.script==='string';
|
|
28
|
+
// A sandboxed check: a root package script, or a tool from the project's .venv.
|
|
29
|
+
// sandbox.mjs's isSandboxed, written out: the hook does not load the sandbox module.
|
|
30
|
+
const sandboxed=check=>['sandbox-v1','sandbox-v2'].includes(check?.qualification)&&['typecheck','lint'].includes(check.kind);
|
|
31
|
+
const python=check=>sandboxed(check)&&Array.isArray(check.command)&&/^\.venv\/bin\/[A-Za-z0-9_.-]+$/.test(check.command[0]||'')&&
|
|
32
|
+
check.command.every(word=>typeof word==='string'&&safe.test(word));
|
|
33
|
+
const eligibleCheck=check=>typecheck(check)||tests(check)||general(check)||python(check);
|
|
34
|
+
export const checkKind=check=>tests(check)?'test':general(check)||python(check)?check.kind:'typecheck';
|
|
35
|
+
// The ways an agent runs a Python check's tool: through uv or Poetry, with
|
|
36
|
+
// `python -m`, from .venv/bin, or by name. A form that starts with `python`
|
|
37
|
+
// or the tool's name depends on PATH, so the hook takes it only when PATH
|
|
38
|
+
// finds that program in the project's .venv/bin first (pathForm).
|
|
39
|
+
const wrappers=['uv run ','poetry run '];
|
|
40
|
+
function pythonForms(check){
|
|
41
|
+
const [file,...args]=check.command,tool=file.split('/').at(-1),rest=args.length?' '+args.join(' '):'';
|
|
42
|
+
return [`.venv/bin/${tool}${rest}`,`./.venv/bin/${tool}${rest}`,`.venv/bin/python -m ${tool}${rest}`,
|
|
43
|
+
...wrappers.flatMap(wrapper=>[`${wrapper}${tool}${rest}`,`${wrapper}python -m ${tool}${rest}`]),
|
|
44
|
+
`python -m ${tool}${rest}`,`python3 -m ${tool}${rest}`,`${tool}${rest}`];
|
|
45
|
+
}
|
|
46
|
+
// For a form that names a program PATH finds (`mypy .`, `python -m mypy .`),
|
|
47
|
+
// whether PATH finds it first in the project's .venv/bin.
|
|
48
|
+
export function pathForm(form,projectRoot,env=process.env){
|
|
49
|
+
const word=form.split(' ')[0];
|
|
50
|
+
if(word.includes('/')||wrappers.some(wrapper=>form.startsWith(wrapper)))return true;
|
|
51
|
+
if(!projectRoot)return false;
|
|
52
|
+
for(const dir of String(env.PATH??'').split(path.delimiter)){
|
|
53
|
+
if(!dir||!path.isAbsolute(dir))continue;
|
|
54
|
+
try{fs.accessSync(path.join(dir,word),fs.constants.X_OK);}catch{continue;}
|
|
55
|
+
try{return fs.realpathSync(dir)===fs.realpathSync(path.join(projectRoot,'.venv','bin'));}catch{return false;}
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
// The wrapper a matched form starts with, kept when the hook runs the check
|
|
60
|
+
// through REDUE: `uv run` and `poetry run` sync the environment first.
|
|
61
|
+
const wrapperOf=form=>wrappers.find(wrapper=>form.startsWith(wrapper))||'';
|
|
29
62
|
|
|
30
63
|
export const defaultConfig='redue.config.json';
|
|
31
64
|
// The `redue` prefix for a config beside the project root; null when the name
|
|
@@ -141,7 +174,8 @@ export function hookForms(config,configFile=defaultConfig,packageScripts=null){
|
|
|
141
174
|
const forms=redue?[`${redue} run ${check.name}`]:[],local=[],dir=checkDir(check),key=groupOf(check),
|
|
142
175
|
group=key?scripts.get(key):null,part=group?.length>1;
|
|
143
176
|
const own=group?null:checkScript(check);
|
|
144
|
-
if(dir==='.')
|
|
177
|
+
if(dir==='.'&&python(check))forms.push(...pythonForms(check));
|
|
178
|
+
else if(dir==='.'){
|
|
145
179
|
forms.push(...scriptForms(pm,own),...toolForms(pm,toolText(check,packageScripts)));
|
|
146
180
|
if(!tests(check)&&Array.isArray(check.command)&&check.command.length&&
|
|
147
181
|
check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
|
|
@@ -258,11 +292,16 @@ function split(command){
|
|
|
258
292
|
// {check, rest}, or {fanout, rest} for a turbo/nx/`pnpm -r` command or a root
|
|
259
293
|
// script that runs one; null when nothing matches. `dir` is where the command
|
|
260
294
|
// runs, relative to the project root.
|
|
261
|
-
function matched(command,config,configFile,dir='.',scripts={}){
|
|
295
|
+
function matched(command,config,configFile,dir='.',scripts={},projectRoot=null){
|
|
262
296
|
const parts=split(command);
|
|
263
297
|
if(!parts)return null;
|
|
264
298
|
const row=hookForms(config,configFile,dir==='.'?scripts:null).find(row=>dir==='.'?row.forms.includes(parts.base):
|
|
265
299
|
row.dir===dir&&row.local.includes(parts.base));
|
|
300
|
+
// A Python check's form that depends on PATH matches only where PATH finds
|
|
301
|
+
// the project's .venv/bin.
|
|
302
|
+
const check=row&&config.checks.find(item=>item.name===row.check);
|
|
303
|
+
if(python(check)&&!parts.base.startsWith('redue'))
|
|
304
|
+
return pathForm(parts.base,projectRoot)?{check:row.check,rest:parts.rest,wrapper:wrapperOf(parts.base)}:null;
|
|
266
305
|
// A root script that a general check runs is that check, even when the script fans out.
|
|
267
306
|
if(dir==='.'&&row&&general(config.checks.find(check=>check.name===row.check)))return {check:row.check,rest:parts.rest};
|
|
268
307
|
if(dir==='.'){
|
|
@@ -331,7 +370,7 @@ export function chainCommand(command,config,configFile,dir='.',scripts={},invoke
|
|
|
331
370
|
const names=[];let plain=true;
|
|
332
371
|
const text=parts.map(({separator,text})=>{
|
|
333
372
|
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);
|
|
373
|
+
const match=matched(body,config,configFile,dir,scripts,projectRoot),split_=split(body);
|
|
335
374
|
// A compiler script is its checks in the order of its runs.
|
|
336
375
|
const checks=match?.check?[match.check]:match?.row&&projectRoot?scriptChecks(match.row,config,projectRoot):null;
|
|
337
376
|
if(!checks?.length||match.rest!==null||!split_||!checks.every(name=>safe.test(name))){
|
|
@@ -339,22 +378,23 @@ export function chainCommand(command,config,configFile,dir='.',scripts={},invoke
|
|
|
339
378
|
if(!plain)return null;
|
|
340
379
|
names.push(...checks);
|
|
341
380
|
// Grouped, so a separator before it applies to all its runs.
|
|
342
|
-
const runs=checks.map(name=>`${redue} run ${name} --if-due`).join(' && ');
|
|
381
|
+
const runs=checks.map(name=>`${match.wrapper||''}${redue} run ${name} --if-due`).join(' && ');
|
|
343
382
|
return `${separator}${lead}${checks.length>1?`{ ${runs}; }`:runs}${split_.suffix}${trail}`;
|
|
344
383
|
});
|
|
345
384
|
return names.length&&!text.includes(null)?{command:text.join(''),names}:null;
|
|
346
385
|
}
|
|
347
|
-
export function matchCheck(command,config,configFile,dir,scripts){
|
|
348
|
-
const match=matched(command,config,configFile,dir,scripts);
|
|
386
|
+
export function matchCheck(command,config,configFile,dir,scripts,projectRoot=null){
|
|
387
|
+
const match=matched(command,config,configFile,dir,scripts,projectRoot);
|
|
349
388
|
return match?.row?match.row.checks.join(' && '):match?.check||null;
|
|
350
389
|
}
|
|
351
390
|
// The same check (or checks, in order) through REDUE, keeping the output
|
|
352
391
|
// suffix; null if it already is, or if more commands follow it.
|
|
353
|
-
|
|
392
|
+
// A `uv run` or `poetry run` wrapper stays in front, so the environment syncs first.
|
|
393
|
+
export function recordedCommand(command,name,configFile=defaultConfig,invoke='redue',wrapper=''){
|
|
354
394
|
const names=Array.isArray(name)?name:[name];
|
|
355
395
|
const parts=split(command),redue=redueFor(configFile)?.replace(/^redue/,()=>invoke);
|
|
356
396
|
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(' && ');
|
|
397
|
+
const run=names.map(value=>`${wrapper}${redue} run ${value}`).join(' && ');
|
|
358
398
|
return parts.base===run?null:names.length>1&&parts.suffix?`(${run})${parts.suffix}`:run+parts.suffix;
|
|
359
399
|
}
|
|
360
400
|
|
|
@@ -467,6 +507,9 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
|
467
507
|
const project=hookProject(root);
|
|
468
508
|
if(!project)return null;
|
|
469
509
|
const {record,config,parsed}=project;
|
|
510
|
+
// For `redue stats`: the checks a decision skipped, or the ones that were due.
|
|
511
|
+
const tally=(status,names,skipped)=>{let state;try{state=projectState(project.projectRoot,config);}catch{return;}
|
|
512
|
+
for(const name of names){const entry=skipped?skipNote(status,name,'hook'):dueNote(status,name);if(entry)note(state,entry);}};
|
|
470
513
|
const entered=enteredDir(command,root,project.projectRoot);
|
|
471
514
|
if(!entered)return null;
|
|
472
515
|
let scripts={};
|
|
@@ -488,7 +531,7 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
|
488
531
|
return cached?.observation?.healthy===true&&chained.names.every(name=>cached.checks?.some(row=>row?.name===name))?
|
|
489
532
|
{hookSpecificOutput:{hookEventName:'PreToolUse',updatedInput:{...input.tool_input,command:entered.prefix+chained.command}}}:null;
|
|
490
533
|
}
|
|
491
|
-
const match=matched(entered.command,parsed,record.config,entered.dir,scripts);
|
|
534
|
+
const match=matched(entered.command,parsed,record.config,entered.dir,scripts,project.projectRoot);
|
|
492
535
|
if(!match)return null;
|
|
493
536
|
// A check followed by more than a simple chain runs untouched.
|
|
494
537
|
if(match.rest!==null&&!match.fanout)return null;
|
|
@@ -499,6 +542,7 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
|
499
542
|
if(!names)return null;
|
|
500
543
|
const status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,...names)),
|
|
501
544
|
reason=fanoutVerdict(status,names,match.rest);
|
|
545
|
+
tally(status,names,Boolean(reason));
|
|
502
546
|
return reason?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}:null;
|
|
503
547
|
}
|
|
504
548
|
// A compiler script: its checks in the order of its runs, or nothing when
|
|
@@ -512,6 +556,7 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
|
512
556
|
else{
|
|
513
557
|
const status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,...names)),
|
|
514
558
|
reason=fanoutVerdict(status,names,match.rest);
|
|
559
|
+
tally(status,names,Boolean(reason));
|
|
515
560
|
if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}};
|
|
516
561
|
const pending=names.filter(name=>!verdict(status,name));
|
|
517
562
|
const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
|
|
@@ -523,6 +568,7 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
|
523
568
|
}
|
|
524
569
|
const name=match.check,status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,name)),
|
|
525
570
|
reason=verdict(status,name,match.rest);
|
|
571
|
+
tally(status,[name],Boolean(reason));
|
|
526
572
|
if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',
|
|
527
573
|
permissionDecision:'deny',permissionDecisionReason:reason}};
|
|
528
574
|
// Rewrite only on a healthy answer for this check: without an observer,
|
|
@@ -530,7 +576,7 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
|
530
576
|
// stays too: the rewrite returns "allow", which would also skip the prompt
|
|
531
577
|
// for the rest of the chain.
|
|
532
578
|
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;
|
|
579
|
+
status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath,invoke??redueInvocation(entry),match.wrapper):null;
|
|
534
580
|
// updatedInput replaces the whole tool input. Deny and ask rules still apply
|
|
535
581
|
// to the rewritten command; "allow" skips only the prompt.
|
|
536
582
|
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,hiddenEntries,hiddenQualification,hiddenRefusal,isSandboxed,runToEnd,sandboxArgv,sandboxContext,sandboxDirs,sandboxEnv,
|
|
12
|
+
sandboxInterpretation,sandboxInterpretationOf,sandboxPlaces,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';
|
|
@@ -27,6 +29,7 @@ import {atomicJson,commitReceipt,readReceipts,receiptRevision} from './state-sto
|
|
|
27
29
|
import {timing,profiling,mark,traceContext} from './decision-profile.mjs';
|
|
28
30
|
import {readCachedStatus,unavailableCached} from './cached-state.mjs';
|
|
29
31
|
import {renderRun} from './presentation.mjs';
|
|
32
|
+
import {note} from './stats.mjs';
|
|
30
33
|
|
|
31
34
|
mark('cli.loaded');
|
|
32
35
|
const here=path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -83,7 +86,8 @@ function environmentReads() {
|
|
|
83
86
|
try{const value=JSON.parse(fs.readFileSync(readsFile,'utf8'));return value?.schema===1&&value.checks||{};}catch{return {};}
|
|
84
87
|
}
|
|
85
88
|
const recordedRecipes=[testsQualification,scriptQualification];
|
|
86
|
-
|
|
89
|
+
// A sandboxed check's run is recorded where its sandbox cannot start (execute).
|
|
90
|
+
function contextHashes() {const reads=config.checks.some(check=>recordedRecipes.includes(check.qualification)||isSandboxed(check.qualification))?environmentReads():{};
|
|
87
91
|
return Object.fromEntries(config.checks.map(check=>[
|
|
88
92
|
check.name,Object.fromEntries([...(check.environment?.variables||[])
|
|
89
93
|
.map(key=>[key,digest(process.env[key]??'<unset>')]),
|
|
@@ -95,6 +99,8 @@ function contextHashes() {const reads=config.checks.some(check=>recordedRecipes.
|
|
|
95
99
|
...(check.environment?.pathExecutables||[]).map(name=>[
|
|
96
100
|
`__vstate_path_executable_${name}__`,pathExecutableIdentity(name)]),
|
|
97
101
|
...(check.environment?.executableIdentity?[['__vstate_node_identity__',nodeIdentity()]]:[]),
|
|
102
|
+
// A sandboxed run's environment, toolchain and places (sandbox.mjs).
|
|
103
|
+
...(isSandboxed(check.qualification)?Object.entries(sandboxContext(check,process.env,root)):[]),
|
|
98
104
|
// The variables the last test run read directly, compared here: which
|
|
99
105
|
// list was compared, and the first variable whose value now differs.
|
|
100
106
|
// Control requests are small, so the values stay out of them.
|
|
@@ -211,10 +217,11 @@ async function execute() {
|
|
|
211
217
|
const lockStarted=performance.now(),runLock=acquireRunLock(state);
|
|
212
218
|
timing('cli.run_lock',lockStarted);
|
|
213
219
|
const captureIssues=[];
|
|
214
|
-
let activeChild=null,requestedSignal=null,treeStop=null,preserveRunLock=false;
|
|
220
|
+
let activeChild=null,requestedSignal=null,treeStop=null,preserveRunLock=false,ownGroup=false;
|
|
215
221
|
const forward=signal=>{requestedSignal=signal;
|
|
216
222
|
if(activeChild&&activeChild.exitCode===null){
|
|
217
|
-
|
|
223
|
+
// A Linux run, and a sandboxed one on macOS, has its own process group.
|
|
224
|
+
if((process.platform==='linux'||ownGroup)&&activeChild.pid){
|
|
218
225
|
try{process.kill(-activeChild.pid,signal);}
|
|
219
226
|
catch(e){if(e.code!=='ESRCH')throw e;}
|
|
220
227
|
}else if(process.platform==='win32'&&activeChild.pid){
|
|
@@ -290,10 +297,15 @@ async function execute() {
|
|
|
290
297
|
const recorded=directRunRecordable(selected)&&!unobservedDirectRun;
|
|
291
298
|
const launched=recorded?[command[0],'--require',path.join(here,'run-recorder.cjs'),
|
|
292
299
|
...command.slice(1)]:command;
|
|
300
|
+
// A sandboxed check runs inside the OS sandbox. Where the sandbox cannot
|
|
301
|
+
// start, a script the general recipe takes is recorded as that recipe
|
|
302
|
+
// records it (`fallback`); any other runs as it is, and is not reused.
|
|
303
|
+
const sandboxed=isSandboxed(selected.qualification),support=sandboxed?sandboxSupport():null;
|
|
304
|
+
const fallback=Boolean(support?.issue&&typeof selected.script==='string'&&!generalIssue(root,selected.script));
|
|
293
305
|
// A test run's processes record what the guard checks (test-recorder.cjs),
|
|
294
306
|
// and so do a general recipe run's, through the package manager.
|
|
295
|
-
const general=selected.qualification===scriptQualification;
|
|
296
|
-
const testRun=recordedRecipes.includes(selected.qualification)&&['darwin','linux'].includes(process.platform);
|
|
307
|
+
const general=selected.qualification===scriptQualification||fallback;
|
|
308
|
+
const testRun=(recordedRecipes.includes(selected.qualification)||fallback)&&['darwin','linux'].includes(process.platform);
|
|
297
309
|
let testRecord=null,launchEnv=process.env,testFacts={},toolchain=[];
|
|
298
310
|
if(testRun){
|
|
299
311
|
// The package directory and installed node_modules, as the plan has them.
|
|
@@ -313,6 +325,31 @@ async function execute() {
|
|
|
313
325
|
// npm's and pnpm's update checks read state outside the checkout and the network.
|
|
314
326
|
if(general)launchEnv.npm_config_update_notifier='false';
|
|
315
327
|
}
|
|
328
|
+
// The sandboxed run, with the environment REDUE gives it.
|
|
329
|
+
let sandbox=null;
|
|
330
|
+
if(sandboxed&&!fallback){
|
|
331
|
+
if(support.issue)captureIssues.push(`the run was not sandboxed: ${support.issue}`);
|
|
332
|
+
else{
|
|
333
|
+
const dirs=sandboxDirs(state,name);
|
|
334
|
+
const reads=[root,dirs.home,dirs.tmp,...sandboxPlaces(selected,process.env,root),...systemPlaces];
|
|
335
|
+
// sandbox-v2 hides the checkout's git-ignored entries. Where git
|
|
336
|
+
// cannot list them, nothing is hidden, and the receipt compares every file.
|
|
337
|
+
const hidden=selected.qualification===hiddenQualification?hiddenEntries(root):null;
|
|
338
|
+
sandbox={mode:support.mode,dirs,reads,hidden,argv:sandboxArgv(support.mode,command,{reads,tmp:dirs.tmp,python:support.python,
|
|
339
|
+
hidden:(hidden||[]).map(rel=>path.join(root,rel.replace(/\/$/,'')))}),
|
|
340
|
+
env:sandboxEnv(selected,process.env,{root,...dirs})};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const plainEnv={...process.env,...selected.script?{npm_config_update_notifier:'false'}:{}};
|
|
344
|
+
// Output kept, and its own process group, for a run as this recipe makes it.
|
|
345
|
+
const own=sandboxed&&!fallback;
|
|
346
|
+
ownGroup=own&&process.platform!=='win32';
|
|
347
|
+
// A sandboxed run's output is passed through and its start and end kept,
|
|
348
|
+
// to tell a refusal by the sandbox from a real failure.
|
|
349
|
+
const output={head:'',tail:''};
|
|
350
|
+
const keep=chunk=>{const text=chunk.toString('utf8');
|
|
351
|
+
if(output.head.length<65536)output.head+=text.slice(0,65536-output.head.length);
|
|
352
|
+
output.tail=(output.tail+text).slice(-65536);};
|
|
316
353
|
let runnerPid=null;
|
|
317
354
|
const record=[];
|
|
318
355
|
// A Windows full reconciliation may finish while queued watcher callbacks
|
|
@@ -335,11 +372,19 @@ async function execute() {
|
|
|
335
372
|
if(requestedSignal){resolve({status:'interrupted',exitCode:null,signal:requestedSignal});return;}
|
|
336
373
|
let child,spawnError=null;
|
|
337
374
|
try{runLock.phase('launching');
|
|
338
|
-
const launch=windowsLaunch(launched);
|
|
375
|
+
const launch=windowsLaunch(sandbox?.argv??launched);
|
|
339
376
|
child=spawn(launch.file,launch.args,{cwd:plan.cwd,
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
377
|
+
// A sandboxed run's own group gets no terminal input, which would stop it.
|
|
378
|
+
stdio:recorded?['inherit','inherit','inherit','pipe']:own?['ignore','pipe','pipe']:'inherit',
|
|
379
|
+
env:sandbox?.env??(own?plainEnv:launchEnv),
|
|
380
|
+
detached:process.platform==='linux'||ownGroup,...launch.options});
|
|
381
|
+
child.stdio[3]?.on('data',chunk=>record.push(chunk));
|
|
382
|
+
if(own){child.stdout.on('data',chunk=>{process.stdout.write(chunk);keep(chunk);});
|
|
383
|
+
child.stderr.on('data',chunk=>{process.stderr.write(chunk);keep(chunk);});
|
|
384
|
+
// What the check left running is stopped when it exits, as a CI
|
|
385
|
+
// job's end does (on Linux the launcher also stops daemons that
|
|
386
|
+
// left the group), and the run ends a second after its exit.
|
|
387
|
+
endWithChild(child);}}
|
|
343
388
|
catch(e){resolve({status:'start_failed',exitCode:null,signal:null,errorCode:e.code||e.name});return;}
|
|
344
389
|
activeChild=child;runnerPid=child.pid??null;runLock.phase('running',child.pid??null);
|
|
345
390
|
mark('cli.child_started',{childPid:child.pid});
|
|
@@ -347,7 +392,7 @@ async function execute() {
|
|
|
347
392
|
child.on('error',e=>{spawnError=e.code||e.name;});
|
|
348
393
|
child.on('close',async(exit,signal)=>{
|
|
349
394
|
if(treeStop)await treeStop;
|
|
350
|
-
if(process.platform==='linux'&&requestedSignal&&child.pid){
|
|
395
|
+
if((process.platform==='linux'||ownGroup)&&requestedSignal&&child.pid){
|
|
351
396
|
// A verification command may have forked children in its process
|
|
352
397
|
// group. Normal cancellation must reach them even if the leader has
|
|
353
398
|
// already exited. Commands that daemonize into a new session remain
|
|
@@ -369,7 +414,7 @@ async function execute() {
|
|
|
369
414
|
invocation.argc=command.length;
|
|
370
415
|
invocation.runId=runId;
|
|
371
416
|
invocation.reporting='normal';
|
|
372
|
-
|
|
417
|
+
let result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
|
|
373
418
|
const target={source:'direct-command',status:invocation.status==='exited'?'direct_executed':'unknown'};
|
|
374
419
|
const afterContextStarted=performance.now(),environmentAfter=contextHashes()[name];
|
|
375
420
|
timing('cli.context_after',afterContextStarted);
|
|
@@ -411,7 +456,7 @@ async function execute() {
|
|
|
411
456
|
// The start checkpoint's probe may have raised the deadline.
|
|
412
457
|
const endWait=decisionWait(process.env.VSTATE_CAPTURE_TIMEOUT_MS);
|
|
413
458
|
try{after=await call({action:'snapshot',name,contextHashes:contextHashes(),
|
|
414
|
-
...(testRun&&Number.isSafeInteger(snap?.inputEventSerial)?{sinceSerial:snap.inputEventSerial}:{}),
|
|
459
|
+
...((testRun||sandboxed)&&Number.isSafeInteger(snap?.inputEventSerial)?{sinceSerial:snap.inputEventSerial}:{}),
|
|
415
460
|
...(recording?{runCertificate:1,adopt:recording}:recorded&&!certificate?{runCertificate:1,runFacts:1}:{})},
|
|
416
461
|
endWait.timeout,endWait.deadline);}
|
|
417
462
|
catch{captureIssues.push('end observation unavailable');}
|
|
@@ -476,12 +521,79 @@ async function execute() {
|
|
|
476
521
|
// A recorded certificate changes only the probe part of the fingerprint.
|
|
477
522
|
if(!outputs&&(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
|
|
478
523
|
snap.revision!==end.revision))captureIssues.push('declared inputs changed or were not observed');
|
|
524
|
+
// A run that hid git-ignored files and failed with a refusal that names
|
|
525
|
+
// one runs again in the sandbox without hiding them: the check needs
|
|
526
|
+
// them. That run's result is kept, compared on every file, when the
|
|
527
|
+
// inputs held through it too. Its own refusal is handled as below.
|
|
528
|
+
let hiddenRerun=null;
|
|
529
|
+
if(sandbox?.hidden?.length&&result==='FAIL'&&!requestedSignal){
|
|
530
|
+
const line=hiddenRefusal(output.head+'\n'+output.tail,sandbox.hidden);
|
|
531
|
+
if(line){
|
|
532
|
+
console.error(`redue: the sandboxed run failed with what looks like a refusal of a file git ignores (${line}); running it again in the sandbox without hiding them`);
|
|
533
|
+
const again=await runToEnd(sandboxArgv(sandbox.mode,command,{reads:sandbox.reads,tmp:sandbox.dirs.tmp,python:support.python}),
|
|
534
|
+
{cwd:plan.cwd,env:sandbox.env,echo:true,started:child=>{activeChild=child;}});
|
|
535
|
+
activeChild=null;
|
|
536
|
+
hiddenRerun={line,exitCode:again.status,signal:again.signal||null,...again.error?{errorCode:again.error.code||again.error.name}:{}};
|
|
537
|
+
if(requestedSignal){
|
|
538
|
+
captureIssues.push('interrupted during the run without hidden files');
|
|
539
|
+
Object.assign(invocation,{status:'interrupted',exitCode:null,signal:requestedSignal});
|
|
540
|
+
}
|
|
541
|
+
else if(again.error||again.status===null)
|
|
542
|
+
captureIssues.push(`the run without hidden files did not finish (${again.error?.code||again.signal}), so a refusal of a file git ignores is not ruled out (${line})`);
|
|
543
|
+
else{
|
|
544
|
+
const wait=decisionWait(process.env.VSTATE_CAPTURE_TIMEOUT_MS);
|
|
545
|
+
// Inputs, plan, observer and environment held through it as through the first run.
|
|
546
|
+
let confirmed=null;const environmentNow=contextHashes()[name];
|
|
547
|
+
try{confirmed=await call({action:'snapshot',name,contextHashes:contextHashes(),
|
|
548
|
+
...Number.isSafeInteger(snap?.inputEventSerial)?{sinceSerial:snap.inputEventSerial}:{}},wait.timeout,wait.deadline);}catch{}
|
|
549
|
+
const last=confirmed?.snapshots?.[name];
|
|
550
|
+
if(!last?.observationHealthy||!snap||last.planId!==snap.planId||last.fingerprint!==snap.fingerprint||last.revision!==snap.revision||
|
|
551
|
+
last.inputEventSerial!==end?.inputEventSerial||last.planGeneration!==snap.planGeneration||
|
|
552
|
+
confirmed.observation?.generation!==before?.observation?.generation)
|
|
553
|
+
captureIssues.push('declared inputs changed or were not observed during the run without hidden files');
|
|
554
|
+
if(JSON.stringify(environmentNow)!==JSON.stringify(environmentBefore))
|
|
555
|
+
captureIssues.push('declared environment changed during the run without hidden files');
|
|
556
|
+
invocation.hiddenExitCode=invocation.exitCode;invocation.exitCode=again.status;
|
|
557
|
+
result=again.status===0?'PASS':'FAIL';
|
|
558
|
+
output.head=`${again.stdout}\n${again.stderr}`.slice(0,65536);output.tail='';
|
|
559
|
+
sandbox.hidden=null;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// A sandboxed run that failed with what looks like a refusal by the
|
|
564
|
+
// sandbox runs again outside it. When that run exits differently, the
|
|
565
|
+
// sandbox changed the result: the plain run's result is kept, not reused.
|
|
566
|
+
// An interrupt before or during it stops there: the run is interrupted.
|
|
567
|
+
let sandboxRerun=null;
|
|
568
|
+
if(sandbox&&result==='FAIL'&&!requestedSignal){
|
|
569
|
+
const line=denialLine(output.head+'\n'+output.tail);
|
|
570
|
+
if(line){
|
|
571
|
+
console.error(`redue: the sandboxed run failed with what looks like a refusal by the sandbox (${line}); running it again outside the sandbox`);
|
|
572
|
+
// Interrupting REDUE interrupts the rerun's group too (forward).
|
|
573
|
+
const plain=await runToEnd(command,{cwd:plan.cwd,env:plainEnv,echo:true,started:child=>{activeChild=child;}});
|
|
574
|
+
activeChild=null;
|
|
575
|
+
sandboxRerun={line,exitCode:plain.status,signal:plain.signal||null,...plain.error?{errorCode:plain.error.code||plain.error.name}:{}};
|
|
576
|
+
if(requestedSignal){
|
|
577
|
+
captureIssues.push('interrupted during the run outside the sandbox');
|
|
578
|
+
Object.assign(invocation,{status:'interrupted',exitCode:null,signal:requestedSignal});
|
|
579
|
+
}
|
|
580
|
+
// A rerun that did not start or finish tells nothing: the sandboxed result stands, not reused.
|
|
581
|
+
else if(plain.error||plain.status===null)
|
|
582
|
+
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})`);
|
|
583
|
+
else if(plain.status!==invocation.exitCode){
|
|
584
|
+
captureIssues.push(`the sandbox changed the result: exit ${invocation.exitCode} inside it, ${plain.status??plain.signal} outside it (${line})`);
|
|
585
|
+
invocation.sandboxedExitCode=invocation.exitCode;invocation.exitCode=plain.status;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if(sandbox)fs.rmSync(sandbox.dirs.tmp,{recursive:true,force:true});
|
|
479
590
|
const stable=captureIssues.length===0;
|
|
480
591
|
// Two runs on the same inputs, environment and plan that disagree make the
|
|
481
592
|
// check nondeterministic until its inputs change.
|
|
482
593
|
let nondeterministic=false;
|
|
483
594
|
const declared=hashes=>Object.entries(hashes||{}).filter(([key])=>!key.startsWith('__redue_env_'));
|
|
484
|
-
if(
|
|
595
|
+
if(sandboxRerun)result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
|
|
596
|
+
if((testRun||sandboxed)&&stable&&result){
|
|
485
597
|
const prior=readReceipts(state).checks[name];
|
|
486
598
|
// A difference explained by inputs REDUE did not observe proves nothing.
|
|
487
599
|
nondeterministic=Boolean(!guard?.issues.length&&prior?.stable&&!prior.guardIssues?.length&&prior.result&&prior.planId===(snap?.planId??plan.id)&&
|
|
@@ -494,7 +606,9 @@ async function execute() {
|
|
|
494
606
|
// A recorded run's receipt carries its run facts, even none; observers that
|
|
495
607
|
// predate run facts do not reuse version 2.
|
|
496
608
|
// The recipe that made the receipt, which status and the hook name.
|
|
497
|
-
|
|
609
|
+
// A sandboxed receipt names what the run did: hid the git-ignored files or not.
|
|
610
|
+
const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,
|
|
611
|
+
recipe:(fallback?scriptQualification:own?(sandbox?.hidden?hiddenQualification:sandboxQualification):selected.qualification)||null,
|
|
498
612
|
...(recorded||testRun?{runFacts}:{}),
|
|
499
613
|
...(testRun?{verificationRecipe:general?scriptInterpretation:testsInterpretation,guardIssues:guard?.issues||['the run record is unavailable'],
|
|
500
614
|
environmentReads:guard?.environmentReads||{},...(nondeterministic?{nondeterministic}:{})}:{}),
|
|
@@ -508,10 +622,15 @@ async function execute() {
|
|
|
508
622
|
planId:snap?.planId??plan.id,fingerprint:(recording||outputs?end:snap)?.fingerprint??null,
|
|
509
623
|
provider:plan.provider,environmentHashes:environmentBefore,
|
|
510
624
|
...(observedContext?{observedContext,verificationRecipe:directInterpretation}:{}),
|
|
625
|
+
// `hidden`: the git-ignored entries the run did not see, which the observer does not compare.
|
|
626
|
+
...(own?{verificationRecipe:sandbox?.hidden?sandboxInterpretationOf(hiddenQualification):sandboxInterpretation,
|
|
627
|
+
sandbox:{mode:sandbox?.mode??null,hidden:sandbox?.hidden??null,...hiddenRerun?{hiddenRerun}:{},...sandboxRerun?{rerun:sandboxRerun}:{}}}:{}),
|
|
628
|
+
...(fallback?{sandbox:{mode:null,fallback:'node-script',issue:support.issue}}:{}),
|
|
511
629
|
files:(outputs?end:snap)?.files??null,observerGeneration:before?.observation?.generation??null,
|
|
512
630
|
startRevision:snap?.revision??null,endRevision:end?.revision??null};
|
|
513
631
|
const persistenceStarted=performance.now();
|
|
514
632
|
commitReceipt(state,name,receipt);
|
|
633
|
+
note(state,{kind:'run',check:name,ms:invocation.durationMs??null,result,stable});
|
|
515
634
|
if(testRun){const reads=environmentReads();reads[name]=receipt.environmentReads;atomicJson(readsFile,{schema:1,checks:reads});}
|
|
516
635
|
timing('cli.receipt_persistence',persistenceStarted);
|
|
517
636
|
const reloadStarted=performance.now();
|
|
@@ -601,7 +720,8 @@ async function main(){
|
|
|
601
720
|
await new Promise(r=>setTimeout(r,100));
|
|
602
721
|
if(fs.existsSync(path.join(state,'observer.lock')))throw Error('observer did not stop');}
|
|
603
722
|
}
|
|
604
|
-
|
|
723
|
+
// A stopping observer can still be closing its files (Windows: ENOTEMPTY, EBUSY).
|
|
724
|
+
fs.rmSync(state,{recursive:true,force:true,maxRetries:10,retryDelay:200});
|
|
605
725
|
console.log(JSON.stringify({removed:state}));return;
|
|
606
726
|
}
|
|
607
727
|
if(action==='status'){console.log(JSON.stringify(cached()));return;}
|
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 {ignoredNow,isSandboxed,sandboxChange,trackedBeneath} 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;
|
|
@@ -345,6 +348,39 @@ function describeChangedInput(plan,file) {
|
|
|
345
348
|
.map(mapping=>mapping.logical).slice(0,2);
|
|
346
349
|
return file+' changed'+(matches.length?` (installed via ${matches.join(', ')})`:'');
|
|
347
350
|
}
|
|
351
|
+
// A run that hid the checkout's git-ignored entries (sandbox@2) did not see
|
|
352
|
+
// them. A changed file the run saw always counts. A file beneath a hidden
|
|
353
|
+
// entry counts only once git tracks it, and a new file only when git does
|
|
354
|
+
// not list it as ignored now, as the next run's hidden entries would not.
|
|
355
|
+
// Installed trees count whatever git says. The first changed input that
|
|
356
|
+
// counts, or null; once per receipt and pair of fingerprints. When git
|
|
357
|
+
// cannot tell, every change counts.
|
|
358
|
+
const hiddenRun=r=>Array.isArray(r?.sandbox?.hidden)&&Boolean(r.files);
|
|
359
|
+
function visibleChange(row,r,name) {
|
|
360
|
+
const key=(r.invocation?.runId||'')+'\0'+r.fingerprint+'\0'+combinedSummary(name).fingerprint;
|
|
361
|
+
if(row.visibleKey===key)return row.visibleChange;
|
|
362
|
+
const hidden=r.sandbox.hidden.map(entry=>entry.replace(/\/$/,''));
|
|
363
|
+
const beneath=file=>hidden.some(entry=>file===entry||file.startsWith(entry+'/'));
|
|
364
|
+
const current=combinedFiles(name);
|
|
365
|
+
const changed=[...new Set([...Object.keys(r.files),...Object.keys(current)])].filter(file=>r.files[file]!==current[file]).sort();
|
|
366
|
+
const seen=changed.filter(file=>file in r.files&&!beneath(file));
|
|
367
|
+
const covered=changed.filter(file=>beneath(file)),fresh=changed.filter(file=>!(file in r.files)&&!beneath(file));
|
|
368
|
+
let found=seen[0]??null;
|
|
369
|
+
if(!found&&covered.length){
|
|
370
|
+
const tracked=trackedBeneath(root,hidden.filter(entry=>covered.some(file=>file===entry||file.startsWith(entry+'/'))));
|
|
371
|
+
found=covered.find(file=>!tracked||tracked.has(file))??null;
|
|
372
|
+
}
|
|
373
|
+
if(!found&&fresh.length){
|
|
374
|
+
// Ask once per directory the run had no file in, then the files outside one.
|
|
375
|
+
const before=new Set(Object.keys(r.files).flatMap(file=>file.split('/').slice(0,-1).map((_,i,parts)=>parts.slice(0,i+1).join('/'))));
|
|
376
|
+
const newDir=file=>{const parts=file.split('/');for(let i=1;i<parts.length;i++){const dir=parts.slice(0,i).join('/');if(!before.has(dir))return dir+'/';}return null;};
|
|
377
|
+
const asked=[...new Set(fresh.filter(file=>!file.startsWith('external:')).map(file=>newDir(file)??file))];
|
|
378
|
+
const ignored=ignoredNow(root,asked);
|
|
379
|
+
found=fresh.find(file=>file.startsWith('external:')||!ignored||!ignored.has(file))??null;
|
|
380
|
+
}
|
|
381
|
+
row.visibleKey=key;row.visibleChange=found;
|
|
382
|
+
return found;
|
|
383
|
+
}
|
|
348
384
|
function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=false) {
|
|
349
385
|
if(!index)return [];
|
|
350
386
|
return [...index.checks].map(([name,row])=>{
|
|
@@ -365,7 +401,7 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
365
401
|
return item;}
|
|
366
402
|
if(pendingChecks.has(name)){item.reason='filesystem change pending content inspection';return item;}
|
|
367
403
|
if(r&&r.planId) item.declared_inputs_match=r.planId===row.plan.id&&
|
|
368
|
-
r.fingerprint===combinedSummary(name).fingerprint;
|
|
404
|
+
(r.fingerprint===combinedSummary(name).fingerprint||hiddenRun(r)&&visibleChange(row,r,name)===null);
|
|
369
405
|
if(r&&!(r.fingerprintVersion===1||r.fingerprintVersion===2&&Array.isArray(r.runFacts))){
|
|
370
406
|
item.reason='receipt uses earlier input-resolution format';return item;}
|
|
371
407
|
if(row.plan.unresolved.length){
|
|
@@ -396,13 +432,19 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
396
432
|
if(r.target.status==='failed'&&result!=='FAIL'){
|
|
397
433
|
item.reason='target failure conflicts with outer invocation outcome';return item;}
|
|
398
434
|
if(!r.stable){const issues=r.checkpoint?.issues?.length?` (${r.checkpoint.issues.join('; ')})`:'';
|
|
399
|
-
|
|
435
|
+
// A sandboxed run that could not start, whose result the sandbox changed, or whose rerun outside it did not finish, says so alone.
|
|
436
|
+
const sandbox=r.checkpoint?.issues?.length&&r.checkpoint.issues.every(issue=>/^the (?:sandbox changed|run was not sandboxed|run outside the sandbox)/.test(issue));
|
|
437
|
+
item.reason=sandbox?r.checkpoint.issues.join('; '):`inputs changed during execution or observation gap${issues}`;return item;}
|
|
400
438
|
const names=[...(row.plan.environment.variables||[]),
|
|
401
439
|
...(row.plan.environment.prefixes||[]).map(prefix=>
|
|
402
440
|
`__vstate_env_prefix_${prefix.toLowerCase()}__`),
|
|
403
441
|
...(row.plan.environment.pathExecutables||[]).map(name=>
|
|
404
442
|
`__vstate_path_executable_${name}__`),
|
|
405
443
|
...(row.plan.environment.executableIdentity?['__vstate_node_identity__']:[])];
|
|
444
|
+
// A sandboxed run's variables, toolchain, PATH directories and places
|
|
445
|
+
// (sandbox.mjs): every entry either side has, and the first change named.
|
|
446
|
+
const sandboxChanged=isSandboxed(row.plan.qualification)&&contextHashes?.[name]?
|
|
447
|
+
sandboxChange(r?.environmentHashes,contextHashes[name]):null;
|
|
406
448
|
if(names.length&&!contextHashes?.[name])unconfirmed??='execution environment context not supplied';
|
|
407
449
|
item.freshness='STALE';
|
|
408
450
|
if(r.planId!==row.plan.id){
|
|
@@ -411,6 +453,7 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
411
453
|
item.reason=changed?`installed input link changed: ${changed.path}`:'input plan changed';
|
|
412
454
|
if(changed)item.changed_inputs=[changed.path];
|
|
413
455
|
}
|
|
456
|
+
else if(sandboxChanged)item.reason=sandboxChanged;
|
|
414
457
|
else if(contextHashes?.[name]&&names.some(key=>contextHashes[name][key]!==r.environmentHashes?.[key]))
|
|
415
458
|
item.reason='declared execution environment changed';
|
|
416
459
|
// Every variable a test run read directly must have the same value. The
|
|
@@ -421,8 +464,10 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
421
464
|
item.freshness='UNVERIFIED';item.reason='environment variables the last run read were not compared';}
|
|
422
465
|
else item.reason=`environment variable ${contextHashes[name].__redue_env_changed__} differs from the last run`;
|
|
423
466
|
}
|
|
424
|
-
else if(r.fingerprint!==combinedSummary(name).fingerprint) {
|
|
467
|
+
else if(r.fingerprint!==combinedSummary(name).fingerprint&&!(hiddenRun(r)&&visibleChange(row,r,name)===null)) {
|
|
425
468
|
const key=r.fingerprint+'\0'+combinedSummary(name).fingerprint;
|
|
469
|
+
if(row.staleReasonKey!==key&&hiddenRun(r)){
|
|
470
|
+
row.staleInputs=[visibleChange(row,r,name)];row.staleReason=describeChangedInput(row.plan,row.staleInputs[0]);row.staleReasonKey=key;}
|
|
426
471
|
if(row.staleReasonKey!==key){
|
|
427
472
|
let changed=row.lastReason;row.staleInputs=[];
|
|
428
473
|
const candidate=changed?.endsWith(' changed')?changed.slice(0,-8):null;
|
|
@@ -796,8 +841,8 @@ function markInputEvent(name,rel,type,replay) {
|
|
|
796
841
|
if(!hasHistory||historyDisabled||replay){
|
|
797
842
|
const serial=(inputEventSerial.get(name)||0)+1;
|
|
798
843
|
inputEventSerial.set(name,serial);
|
|
799
|
-
// Kept only for recorded test and script checks; trimmed in batches.
|
|
800
|
-
if(['npm-tests-direct-v1','node-script-v1'].includes(index?.checks.get(name)?.plan.qualification)){
|
|
844
|
+
// Kept only for recorded test and script checks and sandboxed ones; trimmed in batches.
|
|
845
|
+
if(['npm-tests-direct-v1','node-script-v1'].includes(index?.checks.get(name)?.plan.qualification)||isSandboxed(index?.checks.get(name)?.plan.qualification)){
|
|
801
846
|
const list=inputEventPaths.get(name)||[];list.push([serial,rel]);
|
|
802
847
|
if(list.length>2*eventPathLimit)list.splice(0,list.length-eventPathLimit);inputEventPaths.set(name,list);
|
|
803
848
|
}
|
|
@@ -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 {isSandboxed,qualifySandbox,sandboxInstalledExclusions} 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=isSandboxed(selected.qualification);
|
|
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||[];
|