@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.
@@ -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
- const eligibleCheck=check=>typecheck(check)||tests(check)||general(check);
28
- export const checkKind=check=>tests(check)?'test':general(check)?check.kind:'typecheck';
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
- export function recordedCommand(command,name,configFile=defaultConfig,invoke='redue'){
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
- function contextHashes() {const reads=config.checks.some(check=>recordedRecipes.includes(check.qualification))?environmentReads():{};
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
- if(process.platform==='linux'&&activeChild.pid){
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
- stdio:recorded?['inherit','inherit','inherit','pipe']:'inherit',env:launchEnv,
341
- detached:process.platform==='linux',...launch.options});
342
- child.stdio[3]?.on('data',chunk=>record.push(chunk));}
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
- const result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
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(testRun&&stable&&result){
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
- const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,recipe:selected.qualification||null,
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
- fs.rmSync(state,{recursive:true,force:true});
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
- let workspacePatterns=(()=>{const w=JSON.parse(fs.readFileSync(path.join(root,'package.json'))).workspaces;
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
- item.reason=`inputs changed during execution or observation gap${issues}`;return item;}
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
- (()=>{const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json')));
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',
@@ -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)try{
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||[];