@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/CHANGELOG.md +80 -0
- package/README.md +39 -11
- package/bin/redue.mjs +35 -16
- package/docs/user/node-script.md +2 -2
- package/docs/user/sandbox.md +121 -20
- package/docs/user/supported-projects.md +9 -5
- package/package.json +1 -1
- package/src/claude-hook.mjs +9 -1
- package/src/cli.mjs +82 -10
- package/src/daemon.mjs +18 -6
- package/src/declared-plan.mjs +2 -2
- package/src/index.mjs +2 -1
- package/src/landlock.py +351 -7
- package/src/presentation.mjs +2 -1
- package/src/python-onboarding.mjs +9 -7
- package/src/sandbox.mjs +197 -27
- package/src/stats.mjs +72 -0
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
|
-
|
|
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
|
|
161
|
-
// and biome's ignore rules)
|
|
162
|
-
//
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
230
|
-
|
|
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]),
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
|
379
|
-
|
|
380
|
-
const
|
|
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
|
|
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
|
-
|
|
394
|
-
|
|
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
|
+
}
|