@redue/cli 0.1.0-alpha.26 → 0.1.0-alpha.28

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/src/sandbox.mjs CHANGED
@@ -19,10 +19,22 @@ import {spawn,spawnSync} from 'node:child_process';
19
19
  import {fileURLToPath} from 'node:url';
20
20
  import {lookup,scriptCaches,scriptCheckInputs,scriptProject,toolchainDirs} from './node-script.mjs';
21
21
  import {displayVariables} from './test-direct.mjs';
22
+ import picomatch from 'picomatch';
22
23
 
23
24
  export const sandboxQualification='sandbox-v1';
24
25
  export const sandboxInterpretation='sandbox@1';
25
- export const sandboxKinds=['typecheck','lint'];
26
+ // The same recipe with the checkout's git-ignored files hidden from the run
27
+ // (hiddenEntries): they are not its inputs, so build and test outputs do not
28
+ // make it STALE. init gives it when the run gives the same result that way.
29
+ export const hiddenQualification='sandbox-v2';
30
+ export const hiddenInterpretation='sandbox@2';
31
+ export const hiddenListIssue='git cannot list the ignored files';
32
+ export const isSandboxed=qualification=>qualification===sandboxQualification||qualification===hiddenQualification;
33
+ export const sandboxInterpretationOf=qualification=>qualification===hiddenQualification?hiddenInterpretation:sandboxInterpretation;
34
+ export const sandboxKinds=['typecheck','lint','test'];
35
+ // A test run gets loopback networking and a private /tmp (sandboxArgv's
36
+ // `network` and `writes`); a typecheck or lint gets no network.
37
+ export const testRun=check=>check?.kind==='test';
26
38
  const fail=code=>{throw Object.assign(Error(code),{code});};
27
39
  const digest=value=>createHash('sha256').update(value).digest('hex');
28
40
  const real=file=>{try{return fs.realpathSync(file);}catch{return null;}};
@@ -53,10 +65,12 @@ export const pythonTool=check=>Array.isArray(check?.command)&&/^\.venv\/bin\/[A-
53
65
  // script check names its package manager first among the executables, as the
54
66
  // general recipe does.
55
67
  const managers=['npm','pnpm','yarn','corepack'];
56
- export function sandboxEnvironment(python=false,manager=null){
68
+ export function sandboxEnvironment(python=false,manager=null,kind=null){
57
69
  return {variables:['TZ','LANG','LANGUAGE','CI','USER','LOGNAME'],
58
70
  prefixes:['LC_','DYLD_','LD_',...python?['PYTHON','MYPY','RUFF_','PYRIGHT','TY_']:
59
- ['NODE_','npm_config_','PNPM_','YARN_','COREPACK_','TSGO_']],
71
+ ['NODE_','npm_config_','PNPM_','YARN_','COREPACK_','TSGO_'],
72
+ // A test runner's own variables.
73
+ ...kind==='test'?python?['PYTEST_']:['VITEST_','JEST_']:[]],
60
74
  ...manager?{pathExecutables:['node',manager,...managers.filter(name=>name!==manager)]}:{}};
61
75
  }
62
76
 
@@ -157,16 +171,26 @@ export function sandboxEnv(check,env,{root,home,tmp}){
157
171
  }
158
172
  return out;
159
173
  }
160
- // The files in the checkout's .git that tools read to choose files (ruff's
161
- // and biome's ignore rules): the repository's exclude file and config. The
162
- // observer does not watch .git, so they are compared as context.
174
+ // The files in the repository's .git that tools read to choose files (ruff's
175
+ // and biome's ignore rules), and that decide what sandbox-v2 hides: its
176
+ // exclude file and config. For a project in a subdirectory of the
177
+ // repository, also the .gitignore files above it. The observer watches
178
+ // neither, so they are compared as context.
163
179
  function gitIgnoreSources(root){
164
- let dir=path.join(root,'.git');
180
+ let top=root;
181
+ while(!fs.existsSync(path.join(top,'.git'))){const up=path.dirname(top);if(up===top)return [];top=up;}
182
+ let dir=path.join(top,'.git');
165
183
  try{if(fs.statSync(dir).isFile()){
166
- const gitdir=path.resolve(root,/^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dir,'utf8'))?.[1].trim()??'');
184
+ const gitdir=path.resolve(top,/^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dir,'utf8'))?.[1].trim()??'');
167
185
  let common=gitdir;try{common=path.resolve(gitdir,fs.readFileSync(path.join(gitdir,'commondir'),'utf8').trim());}catch{}
168
186
  dir=common;}}catch{return [];}
169
- return ['info/exclude','config'].map(rel=>{try{return [rel,digest(fs.readFileSync(path.join(dir,rel)))];}catch{return [rel,null];}});
187
+ const read=file=>{try{return digest(fs.readFileSync(file));}catch{return null;}};
188
+ const above=[];
189
+ for(let at=path.dirname(root);top!==root&&(at===top||at.startsWith(top+path.sep));at=path.dirname(at)){
190
+ above.push([path.relative(top,path.join(at,'.gitignore')),read(path.join(at,'.gitignore'))]);
191
+ if(at===top)break;
192
+ }
193
+ return [...['info/exclude','config'].map(rel=>[rel,read(path.join(dir,rel))]),...above];
170
194
  }
171
195
  // What a sandboxed result depends on outside the observed files, compared
172
196
  // before reuse, one entry each so a change can be named (sandboxChange): every
@@ -211,33 +235,160 @@ export function sandboxDirs(state,name){
211
235
  return {home,tmp:fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(),'redue-run-')))};
212
236
  }
213
237
 
238
+ // The checkout's git-ignored entries a run may be kept from reading: what
239
+ // `git ls-files --others --ignored --exclude-standard --directory` lists (a
240
+ // wholly ignored directory as one entry, ending in /), but the installed
241
+ // trees (node_modules, .venv) and runner caches. Relative to root. null when
242
+ // git cannot list them. Only the project's rules count (.gitignore files and
243
+ // .git/info/exclude), not a user's global excludes file, as on CI.
244
+ const installedTree=/(?:^|\/)(?:node_modules|\.venv)(?:\/|$)/;
245
+ const runnerCache=picomatch(sandboxCaches,{dot:true});
246
+ const projectGit=['-c','core.excludesFile=/dev/null'],gitEnv=()=>({...process.env,GIT_OPTIONAL_LOCKS:'0'});
247
+ export function hiddenEntries(root){
248
+ const r=spawnSync('git',[...projectGit,'ls-files','-z','--others','--ignored','--exclude-standard','--directory'],
249
+ {cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:60000,stdio:['ignore','pipe','ignore'],env:gitEnv()});
250
+ if(r.status!==0)return null;
251
+ return r.stdout.split('\0').filter(Boolean).filter(rel=>!installedTree.test(rel)&&
252
+ !runnerCache(rel)&&!runnerCache(rel.replace(/\/$/,'')+'/x'));
253
+ }
254
+
255
+ // What git lists now as ignored at or beneath `paths` (relative files, or
256
+ // directories ending in /), as hiddenEntries would: nested repositories are
257
+ // not entered, and an entry that is an installed tree is not ignored (one
258
+ // inside an ignored build directory is). {has(rel)} for a file, or null when
259
+ // git cannot tell. A directory git ignores holds no file it does not, so
260
+ // asking a new directory once covers its files.
261
+ export function ignoredNow(root,paths){
262
+ const asked=paths;
263
+ if(!asked.length)return {has:()=>false};
264
+ const listed=new Set();
265
+ for(let at=0;at<asked.length;at+=500){
266
+ const r=spawnSync('git',[...projectGit,'ls-files','-z','--others','--ignored','--exclude-standard','--directory','--',...asked.slice(at,at+500)],
267
+ {cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:10000,stdio:['ignore','pipe','ignore'],env:gitEnv()});
268
+ if(r.status!==0)return null;
269
+ for(const entry of r.stdout.split('\0'))if(entry)listed.add(entry);
270
+ }
271
+ const entries=[...listed].filter(entry=>!installedTree.test(entry));
272
+ return {has:rel=>entries.some(entry=>rel===entry||entry.endsWith('/')&&rel.startsWith(entry))};
273
+ }
274
+ // The files git tracks now beneath `entries` (relative). null when git cannot tell.
275
+ export function trackedBeneath(root,entries){
276
+ if(!entries.length)return new Set();
277
+ const r=spawnSync('git',['ls-files','-z','--cached','--',...entries],
278
+ {cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:10000,stdio:['ignore','pipe','ignore'],env:gitEnv()});
279
+ return r.status===0?new Set(r.stdout.split('\0').filter(Boolean)):null;
280
+ }
281
+ // For a run that hid `hidden` (relative entries), the first path of `paths`
282
+ // that changes its inputs, between `before` and `current` ({rel: identity}),
283
+ // or null. A file the run saw always counts. A file beneath a hidden entry
284
+ // counts only once git tracks it, and a new file only when git does not list
285
+ // it as ignored now, as the next run's hidden entries would not. When git
286
+ // cannot tell, every change counts. `external:` paths always count. With
287
+ // `during` (changes while the run ran), a new file outside the hidden
288
+ // entries counts too: the run could read it.
289
+ export function visibleChange(root,hidden,before,current,paths,{during=false}={}){
290
+ const entries=hidden.map(entry=>entry.replace(/\/$/,''));
291
+ const beneath=file=>entries.some(entry=>file===entry||file.startsWith(entry+'/'));
292
+ const changed=[...new Set(paths)].filter(file=>before[file]!==current[file]).sort();
293
+ const seen=changed.filter(file=>file in before&&!beneath(file));
294
+ if(seen.length)return seen[0];
295
+ const covered=changed.filter(beneath),fresh=changed.filter(file=>!(file in before)&&!beneath(file));
296
+ if(covered.length){
297
+ const tracked=trackedBeneath(root,entries.filter(entry=>covered.some(file=>file===entry||file.startsWith(entry+'/'))));
298
+ const found=covered.find(file=>!tracked||tracked.has(file));
299
+ if(found)return found;
300
+ }
301
+ if(!fresh.length)return null;
302
+ if(during)return fresh[0];
303
+ // Asked once per directory the run had no file in, then the files outside one.
304
+ const dirs=new Set(Object.keys(before).flatMap(file=>file.split('/').slice(0,-1).map((_,i,parts)=>parts.slice(0,i+1).join('/'))));
305
+ const newDir=file=>{const parts=file.split('/');for(let i=1;i<parts.length;i++){const dir=parts.slice(0,i).join('/');if(!dirs.has(dir))return dir+'/';}return null;};
306
+ const ignored=ignoredNow(root,[...new Set(fresh.filter(file=>!file.startsWith('external:')).map(file=>newDir(file)??file))]);
307
+ return fresh.find(file=>file.startsWith('external:')||!ignored||!ignored.has(file))??null;
308
+ }
309
+ // A refusal line that names a hidden entry (relative): a refusal by the
310
+ // sandbox, or a missing file, which Linux tools report for an unreadable one.
311
+ export function hiddenRefusal(text,hidden){
312
+ const names=hidden.map(file=>file.replace(/\/$/,'')).filter(Boolean)
313
+ .map(name=>new RegExp(`(?:^|[\\s'"\`(=/])${escape(name)}(?:[/\\s'"\`):,]|$)`));
314
+ for(const line of String(text).split(/\r?\n/))
315
+ if((denial.test(line)||missing.test(line))&&names.some(name=>name.test(line)))return line.trim().slice(0,200);
316
+ return null;
317
+ }
318
+
214
319
  const ancestors=list=>[...new Set(list.flatMap(dir=>{const out=[];for(let at=path.dirname(dir);at!=='/'&&at!=='.';at=path.dirname(at))out.push(at);return out;}))];
215
320
  // The Seatbelt profile: reads and executes only beneath the places, the
216
321
  // metadata of their parent directories, and no network but Unix sockets in
217
322
  // the run's own temporary directory (`own`), which is new for each run: a
218
- // daemon an earlier run left behind cannot answer a later one.
219
- export function seatbeltProfile(reads,own=null){
323
+ // daemon an earlier run left behind cannot answer a later one. `hidden`:
324
+ // absolute paths whose contents it may not read or execute.
325
+ // A test run (`network` "loopback") may bind and accept anywhere and
326
+ // connect to loopback only; with `writes`, it writes nothing beneath /tmp
327
+ // and /var/tmp but beneath those places (its checkout, home and own
328
+ // temporary directory).
329
+ export function seatbeltProfile(reads,own=null,hidden=[],{network='none',writes=null}={}){
220
330
  const quote=value=>JSON.stringify(value);
331
+ const both=list=>[...new Set(list.flatMap(dir=>[dir,real(dir)]).filter(Boolean))];
221
332
  const all=[...new Set(reads.flatMap(dir=>[dir,real(dir)]).filter(Boolean))];
222
333
  return ['(version 1)','(allow default)','(deny file-read* process-exec*)',
223
334
  `(allow file-read* process-exec* ${all.map(dir=>`(subpath ${quote(dir)})`).join(' ')} (literal "/") (literal "/etc") (literal "/var") (literal "/tmp"))`,
224
335
  `(allow file-read-metadata ${ancestors(all).map(dir=>`(literal ${quote(dir)})`).join(' ')})`,
225
- '(deny network*)',...own?[`(allow network* ${[own,real(own)].filter(Boolean).map(dir=>`(subpath ${quote(dir)})`).join(' ')})`]:[]].join('\n');
336
+ // The entry's own path, by its directory's real path: a hidden link's target is not hidden.
337
+ // Its contents only, as on Linux: a run may still see that it exists, and write beneath it.
338
+ ...hidden.length?[`(deny file-read-data process-exec* ${[...new Set(hidden.flatMap(file=>[file,path.join(real(path.dirname(file))??path.dirname(file),path.basename(file))]))]
339
+ .map(file=>`(subpath ${quote(file)})`).join(' ')})`]:[],
340
+ '(deny network*)',...own?[`(allow network* ${[own,real(own)].filter(Boolean).map(dir=>`(subpath ${quote(dir)})`).join(' ')})`]:[],
341
+ ...network==='loopback'?['(allow network-bind)','(allow network-inbound)','(allow network-outbound (remote ip "localhost:*"))']:[],
342
+ ...writes?['(deny file-write* (subpath "/tmp") (subpath "/private/tmp") (subpath "/var/tmp") (subpath "/private/var/tmp"))',
343
+ `(allow file-write* ${both(writes).map(dir=>`(subpath ${quote(dir)})`).join(' ')})`]:[]].join('\n');
226
344
  }
227
345
  // The argv that runs `argv` sandboxed: reads beneath `reads`, no network but
228
- // Unix sockets beneath `tmp` on macOS.
229
- export function sandboxArgv(mode,argv,{reads,tmp=null,python}){
230
- if(mode==='seatbelt')return ['/usr/bin/sandbox-exec','-p',seatbeltProfile(reads,tmp),...argv];
346
+ // Unix sockets beneath `tmp` on macOS, nothing beneath `hidden` (absolute).
347
+ // On Linux a hidden directory can still be listed and its entries stat'ed:
348
+ // Landlock restricts neither beneath a listable parent.
349
+ // `network` "loopback" and `writes`: a test run's (seatbeltProfile). On Linux
350
+ // its network is supervised: `stats` then says whether the supervisor served
351
+ // the whole run (supervisorIssue).
352
+ export function sandboxArgv(mode,argv,{reads,tmp=null,python,hidden=[],stats=null,network='none',writes=null,fault=null}){
353
+ if(mode==='seatbelt')return ['/usr/bin/sandbox-exec','-p',seatbeltProfile(reads,tmp,hidden,{network,writes}),...argv];
231
354
  // The launcher's own warnings would become the check's output.
232
- if(mode==='landlock')return [python,'-I','-W','ignore',landlockScript,...reads.flatMap(dir=>['--read',dir]),'--no-tcp','--',...argv];
355
+ if(mode==='landlock')return [python,'-I','-W','ignore',landlockScript,...reads.flatMap(dir=>['--read',dir]),
356
+ ...hidden.flatMap(file=>['--hide',file]),...stats?['--stats',stats]:[],
357
+ ...network==='loopback'?['--loopback']:['--no-tcp'],
358
+ ...writes?['--deny-write','/tmp','--deny-write','/var/tmp',...writes.flatMap(dir=>['--write',dir])]:[],
359
+ ...fault?['--test-fault',fault]:[],'--',...argv];
233
360
  throw Error(`unknown sandbox ${mode}`);
234
361
  }
362
+ // Why a supervised run's network was not supervised throughout, from the
363
+ // launcher's stats file, or null when it was.
364
+ export function supervisorIssue(stats){
365
+ let value=null;try{value=JSON.parse(fs.readFileSync(stats,'utf8'));}catch{}
366
+ return value?.supervisor==='ok'?null:`the network supervisor ${value?.supervisor?`reported: ${value.supervisor}`:'left no report'}`;
367
+ }
368
+ // The counts a test runner's summary gives, the last of each: pytest, Vitest,
369
+ // Jest, Mocha, node --test.
370
+ const ansi=/\u001b\[[0-9;]*m/g;
371
+ export function testCounts(text){
372
+ const plain=String(text).replace(ansi,'');
373
+ const last=(...patterns)=>{for(const re of patterns){let m,v=null;const g=new RegExp(re,'gim');while((m=g.exec(plain)))v=Number(m[1]);if(v!==null)return v;}return null;};
374
+ return {passed:last('(\\d+) (?:tests? )?(?:passed|passing)\\b','^(?:#|ℹ) pass (\\d+)'),failed:last('(\\d+) (?:tests? )?(?:failed|failing)\\b','^(?:#|ℹ) fail (\\d+)'),
375
+ skipped:last('(\\d+) (?:tests? )?(?:skipped|pending|todo)\\b','^(?:#|ℹ) (?:skip|skipped) (\\d+)')};
376
+ }
235
377
  // A line of a failed run's output that looks like the sandbox refused
236
378
  // something: a refused read or write, or the network unreachable. Such a
237
379
  // failure is run again outside the sandbox to tell a denial from a real one.
238
380
  const denial=/operation not permitted|permission denied|read-only file system|\bE(?:PERM|ACCES|ROFS|NOTFOUND|AI_AGAIN|CONNREFUSED|NETUNREACH|HOSTUNREACH)\b|sandbox-exec:|landlock\.py:|network is unreachable|could not resolve host|name or service not known|nodename nor servname|temporary failure in name resolution|getaddrinfo|connection refused/i;
239
- export function denialLine(text){
240
- for(const line of String(text).split(/\r?\n/))if(denial.test(line))return line.trim().slice(0,200);
381
+ // A missing file counts only when the line names a path the run was kept
382
+ // from (`hidden`, relative to the checkout or absolute): Linux tools report
383
+ // an unreadable file as not found.
384
+ const missing=/not found|no such file|cannot find|can't find|could not find|cannot read|\bENOENT\b/i;
385
+ const escape=text=>text.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
386
+ export function denialLine(text,hidden=[]){
387
+ // A name as a whole path or path component, not inside a longer word.
388
+ const names=hidden.map(file=>file.replace(/\/$/,'')).filter(Boolean)
389
+ .map(name=>new RegExp(`(?:^|[\\s'"\`(=/])${escape(name)}(?:[/\\s'"\`):,]|$)`));
390
+ for(const line of String(text).split(/\r?\n/))
391
+ if(denial.test(line)||missing.test(line)&&names.some(name=>name.test(line)))return line.trim().slice(0,200);
241
392
  return null;
242
393
  }
243
394
 
@@ -366,22 +517,33 @@ export function runToEnd(argv,{cwd,env,timeout=0,echo=false,started=null}){
366
517
  });
367
518
  }
368
519
  // {same, plain, sandboxed, line} or {issue} when the sandbox cannot run here.
369
- export async function sandboxTrial(root,check,{timeout=300000}={}){
520
+ // With `hide`, the git-ignored entries hiddenEntries lists are kept from the
521
+ // sandboxed run, and it also returns them, their count, the run times and, on
522
+ // Linux, the launcher's rule count and setup time.
523
+ // `plain`: the plain run of an earlier trial of the same check, reused.
524
+ export async function sandboxTrial(root,check,{timeout=300000,hide=false,plain:earlier=null}={}){
370
525
  const supported=sandboxSupport();
371
526
  if(supported.issue)return {issue:supported.issue};
372
527
  const python=Boolean(pythonTool(check));
373
528
  const command=python?check.command:[lookup(scriptProject(root,check.script).manager,process.env.PATH,root)||'npm','run',check.script];
374
- const selected={...check,command,environment:sandboxEnvironment(python,python?null:scriptProject(root,check.script).manager)};
529
+ const selected={...check,command,environment:sandboxEnvironment(python,python?null:scriptProject(root,check.script).manager,check.kind)};
530
+ const tests=testRun(check);
375
531
  const scratch=fs.mkdtempSync(path.join(os.tmpdir(),'redue-trial-')),home=path.join(scratch,'home'),tmp=path.join(scratch,'tmp');
376
532
  fs.mkdirSync(home);fs.mkdirSync(tmp);
377
533
  try{
378
- const run=async(argv,env)=>{const r=await runToEnd(argv,{cwd:root,env,timeout});
379
- return {status:r.timedOut?'timeout':r.status??r.signal??r.error?.code,output:`${r.stdout}\n${r.stderr}`};};
380
- const plain=await run(command,{...process.env,...python?{}:{npm_config_update_notifier:'false'}});
534
+ const entries=hide?hiddenEntries(root):[];
535
+ if(!entries)return {issue:hiddenListIssue};
536
+ const hidden=entries.map(rel=>path.join(root,rel.replace(/\/$/,""))),stats=(hide||tests)&&supported.mode==='landlock'?path.join(scratch,'stats.json'):null;
537
+ const run=async(argv,env)=>{const started=Date.now(),r=await runToEnd(argv,{cwd:root,env,timeout});
538
+ return {status:r.timedOut?'timeout':r.status??r.signal??r.error?.code,output:`${r.stdout}\n${r.stderr}`,ms:Date.now()-started};};
539
+ const plain=earlier??await run(command,{...process.env,...python?{}:{npm_config_update_notifier:'false'}});
381
540
  const reads=[root,home,tmp,...sandboxPlaces(selected,process.env,root),...systemPlaces];
382
- const sandboxed=await run(sandboxArgv(supported.mode,command,{reads,tmp,python:supported.python}),sandboxEnv(selected,process.env,{root,home,tmp}));
541
+ const sandboxed=await run(sandboxArgv(supported.mode,command,{reads,tmp,python:supported.python,hidden,stats,
542
+ ...tests?{network:'loopback',writes:[root,home,tmp]}:{}}),sandboxEnv(selected,process.env,{root,home,tmp}));
543
+ // A test run's network was supervised throughout, on Linux.
544
+ const supervisor=tests&&stats?supervisorIssue(stats):null;
383
545
  // A failure both ways counts as the same only without a sign of a refusal.
384
- const denied=sandboxed.status===0?null:denialLine(sandboxed.output);
546
+ const denied=sandboxed.status===0?null:denialLine(sandboxed.output,entries);
385
547
  const realOf=dir=>{try{return fs.realpathSync(dir);}catch{return null;}},plainTmp=process.env.TMPDIR||os.tmpdir();
386
548
  const paths=[[home,'<home>'],[realOf(home),'<home>'],[tmp,'<tmp>'],[realOf(tmp),'<tmp>'],[plainTmp,'<tmp>'],[realOf(plainTmp),'<tmp>'],
387
549
  [os.homedir(),'<home>'],[realOf(os.homedir()),'<home>']];
@@ -390,7 +552,15 @@ export async function sandboxTrial(root,check,{timeout=300000}={}){
390
552
  let at=a.findIndex((line,i)=>line!==b[i]);
391
553
  if(at<0&&b.length>a.length)at=a.length;
392
554
  const output=at<0?null:{plain:(a[at]??'(no more output)').slice(0,160),sandboxed:(b[at]??'(no more output)').slice(0,160)};
393
- return {same:plain.status===sandboxed.status&&!denied&&!output,plain:plain.status,sandboxed:sandboxed.status,output,
394
- line:denied||(plain.status===sandboxed.status?null:sandboxed.output.trim().split('\n').at(-1)?.slice(0,200)||null)};
555
+ let launcher=null;if(stats)try{launcher=JSON.parse(fs.readFileSync(stats,'utf8'));}catch{}
556
+ // Tests print in an order their workers decide: the counts their summary
557
+ // gives are compared, not every line.
558
+ // When neither summary can be read, every line is compared, as for a typecheck.
559
+ const counts=tests?[plain,sandboxed].map(r=>testCounts(r.output)):null;
560
+ const read=counts?.some(c=>Object.values(c).some(value=>value!==null));
561
+ const differs=tests&&read?(JSON.stringify(counts[0])===JSON.stringify(counts[1])?null:{plain:JSON.stringify(counts[0]),sandboxed:JSON.stringify(counts[1])}):output;
562
+ return {same:plain.status===sandboxed.status&&!denied&&!differs&&!supervisor,plain:plain.status,sandboxed:sandboxed.status,output:differs,plainRun:plain,
563
+ line:supervisor||denied||(plain.status===sandboxed.status?null:sandboxed.output.trim().split('\n').at(-1)?.slice(0,200)||null),
564
+ ...hide?{hidden:entries.length,entries,plainMs:plain.ms,sandboxedMs:sandboxed.ms,...launcher?{rules:launcher.rules,setupMs:launcher.ms}:{}}:{}};
395
565
  }finally{fs.rmSync(scratch,{recursive:true,force:true});}
396
566
  }
package/src/stats.mjs ADDED
@@ -0,0 +1,72 @@
1
+ // `redue stats`: what REDUE did for each check, from a local log. The hook,
2
+ // `run --if-due` and recorded runs append one line each to stats-v1.jsonl in
3
+ // the project's state: a skip (with the skipped run's duration), a recorded
4
+ // run, or a check that was due (STALE or UNVERIFIED, with the reason). The log
5
+ // stays on this machine and goes away with `redue remove-state`.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ export const statsFile=state=>path.join(state,'stats-v1.jsonl');
10
+
11
+ // Never fails the command it is called from.
12
+ export function note(state,entry){
13
+ try{fs.appendFileSync(statsFile(state),JSON.stringify({at:Date.now(),...entry})+'\n',{mode:0o600});}catch{}
14
+ }
15
+
16
+ // A skip of `name`, from a status row that allowed it.
17
+ export const skipNote=(status,name,via)=>{
18
+ const ms=status?.checks?.find(row=>row?.name===name)?.invocation?.durationMs;
19
+ return {kind:'skip',check:name,via,savedMs:Number.isFinite(ms)&&ms>=0?ms:null};
20
+ };
21
+ // A check about to run because its last result does not apply, or null.
22
+ export const dueNote=(status,name)=>{
23
+ const row=status?.observation?.healthy===true?status.checks?.find(item=>item?.name===name):null;
24
+ return row&&row.freshness!=='CURRENT'?{kind:'due',check:name,freshness:row.freshness,reason:row.reason??null}:null;
25
+ };
26
+
27
+ // Run ids, hashes and digits in a reason say nothing about its kind.
28
+ const reasonKind=reason=>String(reason??'no reason').replace(/[0-9a-f]{8}(?:-?[0-9a-f]{4,})+|[0-9a-f]{12,}/g,'…').slice(0,160);
29
+
30
+ export function readStats(state,names=[]){
31
+ let text='';try{text=fs.readFileSync(statsFile(state),'utf8');}catch{}
32
+ // Configured checks first, in config order, then any the log still names.
33
+ const checks=new Map();
34
+ const row=name=>{let value=checks.get(name);
35
+ if(!value)checks.set(name,value={name,skips:0,savedMs:0,runs:0,passed:0,failed:0,runMs:0,stale:0,unverified:0,reasons:{}});
36
+ return value;};
37
+ for(const name of names)row(name);
38
+ let since=null;
39
+ for(const line of text.split('\n')){
40
+ let entry;try{entry=JSON.parse(line);}catch{continue;}
41
+ if(typeof entry?.check!=='string')continue;
42
+ since??=entry.at;
43
+ const value=row(entry.check);
44
+ if(entry.kind==='skip'){value.skips++;value.savedMs+=entry.savedMs??0;}
45
+ else if(entry.kind==='run'){value.runs++;value.runMs+=entry.ms??0;
46
+ if(entry.result==='PASS')value.passed++;else if(entry.result==='FAIL')value.failed++;}
47
+ else if(entry.kind==='due'&&entry.freshness==='STALE')value.stale++;
48
+ else if(entry.kind==='due'&&entry.freshness==='UNVERIFIED'){value.unverified++;
49
+ const kind=reasonKind(entry.reason);value.reasons[kind]=(value.reasons[kind]??0)+1;}
50
+ }
51
+ const list=[...checks.values()].map(({reasons,...value})=>({...value,
52
+ topUnverified:Object.entries(reasons).sort((a,b)=>b[1]-a[1]).slice(0,3).map(([reason,count])=>({reason,count}))}));
53
+ return {schema:1,since:since===null?null:new Date(since).toISOString(),checks:list};
54
+ }
55
+
56
+ export const duration=ms=>!ms?'0s':ms<60_000?`${(ms/1000).toFixed(ms<10_000?1:0)}s`:
57
+ ms<3_600_000?`${Math.floor(ms/60_000)}m ${Math.round(ms%60_000/1000)}s`:`${Math.floor(ms/3_600_000)}h ${Math.round(ms%3_600_000/60_000)}m`;
58
+
59
+ export function renderStats(value){
60
+ if(!value.since)return 'No skips or recorded runs yet.';
61
+ const head=['check','skips','saved','runs','pass','fail','stale','unverified'];
62
+ const rows=value.checks.map(c=>[c.name,c.skips,duration(c.savedMs),c.runs,c.passed,c.failed,c.stale,c.unverified].map(String));
63
+ const total=value.checks.reduce((sum,c)=>({skips:sum.skips+c.skips,savedMs:sum.savedMs+c.savedMs}),{skips:0,savedMs:0});
64
+ const widths=head.map((h,i)=>Math.max(h.length,...rows.map(r=>r[i].length)));
65
+ const line=r=>r.map((cell,i)=>i?cell.padStart(widths[i]):cell.padEnd(widths[i])).join(' ');
66
+ const lines=[`Since ${value.since.slice(0,10)}: ${total.skips} skips saved ${duration(total.savedMs)}.`,'',line(head),...rows.map(line)];
67
+ const reasons=value.checks.filter(c=>c.topUnverified.length);
68
+ if(reasons.length){lines.push('','Why checks were UNVERIFIED when they were due:');
69
+ for(const c of reasons)for(const r of c.topUnverified)lines.push(` ${c.name} ${r.count}× ${r.reason}`);}
70
+ lines.push('','Saved is the duration of the run each skip reused.');
71
+ return lines.join('\n');
72
+ }