@redue/cli 0.1.0-alpha.27 → 0.1.0-alpha.29

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
@@ -29,9 +29,37 @@ export const sandboxInterpretation='sandbox@1';
29
29
  export const hiddenQualification='sandbox-v2';
30
30
  export const hiddenInterpretation='sandbox@2';
31
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'];
32
+ // And the project's documentation hidden too (docPatterns): an agent edits it
33
+ // often, and a typecheck, lint or test suite seldom reads it.
34
+ export const docsQualification='sandbox-v3';
35
+ export const docsInterpretation='sandbox@3';
36
+ // Under docs/, only documentation files: code there (examples a typecheck
37
+ // includes) stays visible.
38
+ const docsFolderTypes=['txt','adoc','png','jpg','jpeg','gif','svg','webp','pdf'];
39
+ export const docPatterns=['**/*.md','**/*.mdx','**/*.rst','**/LICENSE*','**/CHANGELOG*',...docsFolderTypes.map(type=>`docs/**/*.${type}`)];
40
+ export const hiddenDocsIssue='git cannot list the documentation';
41
+ // What macOS refuses by path pattern: the extensions, in any case (Seatbelt
42
+ // matches regexes without regard to case). Every other listed file
43
+ // (LICENSE*, CHANGELOG*) it refuses by its path; a pattern would refuse
44
+ // license-check.ts too.
45
+ const docsByPattern=rel=>/^(.+\/)?[^/]*\.(md|mdx|rst)$/i.test(rel)||new RegExp(`^docs/(.+/)?[^/]*\\.(${docsFolderTypes.join('|')})$`,'i').test(rel);
46
+ // One path each makes the profile larger and slower to compile (1,000 add
47
+ // about 190 ms to a run; a few thousand do not compile). Past this many,
48
+ // those files stay visible and count as inputs, on every platform.
49
+ export const namedDocsLimit=100;
50
+ export const isSandboxed=qualification=>[sandboxQualification,hiddenQualification,docsQualification].includes(qualification);
51
+ // Whether the recipe hides the checkout's git-ignored entries (v2 and v3).
52
+ export const hidesIgnored=qualification=>qualification===hiddenQualification||qualification===docsQualification;
53
+ // A macOS test run has had no TCP since alpha.29: its own interpretation, so a
54
+ // result recorded with loopback is not the same plan.
55
+ export const sandboxInterpretationOf=(qualification,kind=null,platform=process.platform)=>
56
+ (qualification===docsQualification?docsInterpretation:qualification===hiddenQualification?hiddenInterpretation:sandboxInterpretation)+
57
+ (kind==='test'&&platform==='darwin'?'+no-tcp':'');
58
+ export const sandboxKinds=['typecheck','lint','test'];
59
+ // A test run gets a private /tmp (sandboxArgv's `writes`) and, on Linux,
60
+ // supervised loopback networking (`network`); a typecheck or lint, and a
61
+ // test run on macOS, get no network but Unix sockets.
62
+ export const testRun=check=>check?.kind==='test';
35
63
  const fail=code=>{throw Object.assign(Error(code),{code});};
36
64
  const digest=value=>createHash('sha256').update(value).digest('hex');
37
65
  const real=file=>{try{return fs.realpathSync(file);}catch{return null;}};
@@ -62,10 +90,12 @@ export const pythonTool=check=>Array.isArray(check?.command)&&/^\.venv\/bin\/[A-
62
90
  // script check names its package manager first among the executables, as the
63
91
  // general recipe does.
64
92
  const managers=['npm','pnpm','yarn','corepack'];
65
- export function sandboxEnvironment(python=false,manager=null){
93
+ export function sandboxEnvironment(python=false,manager=null,kind=null){
66
94
  return {variables:['TZ','LANG','LANGUAGE','CI','USER','LOGNAME'],
67
95
  prefixes:['LC_','DYLD_','LD_',...python?['PYTHON','MYPY','RUFF_','PYRIGHT','TY_']:
68
- ['NODE_','npm_config_','PNPM_','YARN_','COREPACK_','TSGO_']],
96
+ ['NODE_','npm_config_','PNPM_','YARN_','COREPACK_','TSGO_'],
97
+ // A test runner's own variables.
98
+ ...kind==='test'?python?['PYTEST_']:['VITEST_','JEST_']:[]],
69
99
  ...manager?{pathExecutables:['node',manager,...managers.filter(name=>name!==manager)]}:{}};
70
100
  }
71
101
 
@@ -247,6 +277,24 @@ export function hiddenEntries(root){
247
277
  !runnerCache(rel)&&!runnerCache(rel.replace(/\/$/,'')+'/x'));
248
278
  }
249
279
 
280
+ // The documentation files docPatterns names among the files git lists and
281
+ // does not ignore (git-ignored ones are hidden as such), as regular files:
282
+ // a link is not hidden. Files, never folders, so a folder stays listable.
283
+ // Relative; null when git cannot tell. git filters by pathspec, so a large
284
+ // checkout is not listed whole.
285
+ const docMatch=picomatch(docPatterns,{dot:true});
286
+ export function hiddenDocs(root){
287
+ const r=spawnSync('git',[...projectGit,'ls-files','-z','--cached','--others','--exclude-standard','--',...docPatterns.map(pattern=>`:(glob)${pattern}`)],
288
+ {cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:60000,stdio:['ignore','pipe','ignore'],env:gitEnv()});
289
+ if(r.status!==0)return null;
290
+ const out=new Set();
291
+ for(const rel of r.stdout.split('\0'))if(rel&&!out.has(rel)&&docMatch(rel))
292
+ try{if(fs.lstatSync(path.join(root,rel)).isFile())out.add(rel);}catch{}
293
+ const named=[...out].filter(rel=>!docsByPattern(rel));
294
+ if(named.length>namedDocsLimit)for(const rel of named)out.delete(rel);
295
+ return [...out].sort();
296
+ }
297
+
250
298
  // What git lists now as ignored at or beneath `paths` (relative files, or
251
299
  // directories ending in /), as hiddenEntries would: nested repositories are
252
300
  // not entered, and an entry that is an installed tree is not ignored (one
@@ -273,6 +321,39 @@ export function trackedBeneath(root,entries){
273
321
  {cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:10000,stdio:['ignore','pipe','ignore'],env:gitEnv()});
274
322
  return r.status===0?new Set(r.stdout.split('\0').filter(Boolean)):null;
275
323
  }
324
+ // For a run that hid `hidden` (relative entries), the first path of `paths`
325
+ // that changes its inputs, between `before` and `current` ({rel: identity}),
326
+ // or null. A file the run saw always counts. A file beneath a hidden entry
327
+ // counts only once git tracks it, and a new file only when git does not list
328
+ // it as ignored now, as the next run's hidden entries would not. When git
329
+ // cannot tell, every change counts. `external:` paths always count. With
330
+ // `during` (changes while the run ran), a new file outside the hidden
331
+ // entries counts too: the run could read it.
332
+ // With `docs` (sandbox@3: the documentation files the run hid, relative), a
333
+ // later change to one of those files does not count. A new documentation
334
+ // file does, and so does any change while the run ran: the run is not shown
335
+ // to have been kept from it.
336
+ export function visibleChange(root,hidden,before,current,paths,{during=false,docs=null}={}){
337
+ const entries=hidden.map(entry=>entry.replace(/\/$/,''));
338
+ const beneath=file=>entries.some(entry=>file===entry||file.startsWith(entry+'/'));
339
+ const hiddenDoc=new Set(during?[]:docs||[]);
340
+ const changed=[...new Set(paths)].filter(file=>before[file]!==current[file]&&!hiddenDoc.has(file)).sort();
341
+ const seen=changed.filter(file=>file in before&&!beneath(file));
342
+ if(seen.length)return seen[0];
343
+ const covered=changed.filter(beneath),fresh=changed.filter(file=>!(file in before)&&!beneath(file));
344
+ if(covered.length){
345
+ const tracked=trackedBeneath(root,entries.filter(entry=>covered.some(file=>file===entry||file.startsWith(entry+'/'))));
346
+ const found=covered.find(file=>!tracked||tracked.has(file));
347
+ if(found)return found;
348
+ }
349
+ if(!fresh.length)return null;
350
+ if(during)return fresh[0];
351
+ // Asked once per directory the run had no file in, then the files outside one.
352
+ const dirs=new Set(Object.keys(before).flatMap(file=>file.split('/').slice(0,-1).map((_,i,parts)=>parts.slice(0,i+1).join('/'))));
353
+ 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;};
354
+ const ignored=ignoredNow(root,[...new Set(fresh.filter(file=>!file.startsWith('external:')).map(file=>newDir(file)??file))]);
355
+ return fresh.find(file=>file.startsWith('external:')||!ignored||!ignored.has(file))??null;
356
+ }
276
357
  // A refusal line that names a hidden entry (relative): a refusal by the
277
358
  // sandbox, or a missing file, which Linux tools report for an unreadable one.
278
359
  export function hiddenRefusal(text,hidden){
@@ -288,29 +369,78 @@ const ancestors=list=>[...new Set(list.flatMap(dir=>{const out=[];for(let at=pat
288
369
  // metadata of their parent directories, and no network but Unix sockets in
289
370
  // the run's own temporary directory (`own`), which is new for each run: a
290
371
  // daemon an earlier run left behind cannot answer a later one. `hidden`:
291
- // absolute paths it may not read at all, not even their metadata.
292
- export function seatbeltProfile(reads,own=null,hidden=[]){
372
+ // absolute paths whose contents it may not read or execute.
373
+ // A test run gets no TCP either: Seatbelt cannot refuse one loopback port
374
+ // while allowing the others (a per-port deny under a loopback allow is not
375
+ // applied; an allow list of 65,000 ports does not compile), so a suite could
376
+ // reach a database started outside the run. A suite that needs loopback
377
+ // fails init's trial and runs plainly. With `writes`, a test run writes
378
+ // nothing beneath /tmp and /var/tmp but beneath those places (its checkout,
379
+ // home and own temporary directory).
380
+ // `docs` ({root, entries}): the checkout's documentation it may not read,
381
+ // regular files only, so folders stay listable. Extensions go by path
382
+ // pattern, so the profile stays small however many files match: every file
383
+ // hiddenDocs lists, and more (one in node_modules, README.MD). Seatbelt
384
+ // matches a regex without regard to case, so LICENSE* and CHANGELOG* go by
385
+ // the files hiddenDocs listed: a pattern would hide license-check.ts too.
386
+ export function seatbeltProfile(reads,own=null,hidden=[],{writes=null,docs=null}={}){
293
387
  const quote=value=>JSON.stringify(value);
388
+ const both=list=>[...new Set(list.flatMap(dir=>[dir,real(dir)]).filter(Boolean))];
294
389
  const all=[...new Set(reads.flatMap(dir=>[dir,real(dir)]).filter(Boolean))];
295
390
  return ['(version 1)','(allow default)','(deny file-read* process-exec*)',
296
391
  `(allow file-read* process-exec* ${all.map(dir=>`(subpath ${quote(dir)})`).join(' ')} (literal "/") (literal "/etc") (literal "/var") (literal "/tmp"))`,
297
392
  `(allow file-read-metadata ${ancestors(all).map(dir=>`(literal ${quote(dir)})`).join(' ')})`,
298
393
  // The entry's own path, by its directory's real path: a hidden link's target is not hidden.
299
- ...hidden.length?[`(deny file-read* process-exec* ${[...new Set(hidden.flatMap(file=>[file,path.join(real(path.dirname(file))??path.dirname(file),path.basename(file))]))]
394
+ // Its contents only, as on Linux: a run may still see that it exists, and write beneath it.
395
+ ...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))]))]
300
396
  .map(file=>`(subpath ${quote(file)})`).join(' ')})`]:[],
301
- '(deny network*)',...own?[`(allow network* ${[own,real(own)].filter(Boolean).map(dir=>`(subpath ${quote(dir)})`).join(' ')})`]:[]].join('\n');
397
+ ...docs?[docsRule(docs,both([docs.root]))]:[],
398
+ '(deny network*)',...own?[`(allow network* ${[own,real(own)].filter(Boolean).map(dir=>`(subpath ${quote(dir)})`).join(' ')})`]:[],
399
+ ...writes?['(deny file-write* (subpath "/tmp") (subpath "/private/tmp") (subpath "/var/tmp") (subpath "/private/var/tmp"))',
400
+ `(allow file-write* ${both(writes).map(dir=>`(subpath ${quote(dir)})`).join(' ')})`]:[]].join('\n');
401
+ }
402
+ // docsByPattern's patterns for each root, and each listed file they do not cover by its path.
403
+ function docsRule(docs,roots){
404
+ const named=docs.entries.filter(rel=>!docsByPattern(rel));
405
+ const patterns=roots.map(dir=>escape(dir)).flatMap(dir=>[`^${dir}/(.+/)?[^/]*\\.(md|mdx|rst)$`,`^${dir}/docs/(.+/)?[^/]*\\.(${docsFolderTypes.join('|')})$`])
406
+ .map(pattern=>`(regex #"${pattern.replace(/"/g,'\\"')}")`);
407
+ const files=roots.flatMap(dir=>named.map(rel=>`(literal ${JSON.stringify(path.join(dir,rel))})`));
408
+ return `(deny file-read-data process-exec* (require-all (vnode-type REGULAR-FILE) (require-any ${[...patterns,...files].join(' ')})))`;
302
409
  }
303
410
  // The argv that runs `argv` sandboxed: reads beneath `reads`, no network but
304
411
  // Unix sockets beneath `tmp` on macOS, nothing beneath `hidden` (absolute).
305
412
  // On Linux a hidden directory can still be listed and its entries stat'ed:
306
413
  // Landlock restricts neither beneath a listable parent.
307
- export function sandboxArgv(mode,argv,{reads,tmp=null,python,hidden=[],stats=null}){
308
- if(mode==='seatbelt')return ['/usr/bin/sandbox-exec','-p',seatbeltProfile(reads,tmp,hidden),...argv];
414
+ // `network` "loopback" and `writes`: a test run's. Only Linux gives it a
415
+ // network (seatbeltProfile says why macOS does not), supervised: `stats`
416
+ // then says whether the supervisor served the whole run (supervisorIssue).
417
+ // `docs` ({root, entries}): the checkout's documentation, hidden too.
418
+ export function sandboxArgv(mode,argv,{reads,tmp=null,python,hidden=[],stats=null,network='none',writes=null,fault=null,docs=null}){
419
+ if(mode==='seatbelt')return ['/usr/bin/sandbox-exec','-p',seatbeltProfile(reads,tmp,hidden,{writes,docs}),...argv];
420
+ if(docs)hidden=[...hidden,...docs.entries.map(rel=>path.join(docs.root,rel.replace(/\/$/,'')))];
309
421
  // The launcher's own warnings would become the check's output.
310
422
  if(mode==='landlock')return [python,'-I','-W','ignore',landlockScript,...reads.flatMap(dir=>['--read',dir]),
311
- ...hidden.flatMap(file=>['--hide',file]),...stats?['--stats',stats]:[],'--no-tcp','--',...argv];
423
+ ...hidden.flatMap(file=>['--hide',file]),...stats?['--stats',stats]:[],
424
+ ...network==='loopback'?['--loopback']:['--no-tcp'],
425
+ ...writes?['--deny-write','/tmp','--deny-write','/var/tmp',...writes.flatMap(dir=>['--write',dir])]:[],
426
+ ...fault?['--test-fault',fault]:[],'--',...argv];
312
427
  throw Error(`unknown sandbox ${mode}`);
313
428
  }
429
+ // Why a supervised run's network was not supervised throughout, from the
430
+ // launcher's stats file, or null when it was.
431
+ export function supervisorIssue(stats){
432
+ let value=null;try{value=JSON.parse(fs.readFileSync(stats,'utf8'));}catch{}
433
+ return value?.supervisor==='ok'?null:`the network supervisor ${value?.supervisor?`reported: ${value.supervisor}`:'left no report'}`;
434
+ }
435
+ // The counts a test runner's summary gives, the last of each: pytest, Vitest,
436
+ // Jest, Mocha, node --test.
437
+ const ansi=/\u001b\[[0-9;]*m/g;
438
+ export function testCounts(text){
439
+ const plain=String(text).replace(ansi,'');
440
+ 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;};
441
+ return {passed:last('(\\d+) (?:tests? )?(?:passed|passing)\\b','^(?:#|ℹ) pass (\\d+)'),failed:last('(\\d+) (?:tests? )?(?:failed|failing)\\b','^(?:#|ℹ) fail (\\d+)'),
442
+ skipped:last('(\\d+) (?:tests? )?(?:skipped|pending|todo)\\b','^(?:#|ℹ) (?:skip|skipped) (\\d+)')};
443
+ }
314
444
  // A line of a failed run's output that looks like the sandbox refused
315
445
  // something: a refused read or write, or the network unreachable. Such a
316
446
  // failure is run again outside the sandbox to tell a denial from a real one.
@@ -454,6 +584,7 @@ export function runToEnd(argv,{cwd,env,timeout=0,echo=false,started=null}){
454
584
  });
455
585
  }
456
586
  // {same, plain, sandboxed, line} or {issue} when the sandbox cannot run here.
587
+ // With `hide` 'docs', the documentation (hiddenDocs) is hidden too.
457
588
  // With `hide`, the git-ignored entries hiddenEntries lists are kept from the
458
589
  // sandboxed run, and it also returns them, their count, the run times and, on
459
590
  // Linux, the launcher's rule count and setup time.
@@ -463,18 +594,27 @@ export async function sandboxTrial(root,check,{timeout=300000,hide=false,plain:e
463
594
  if(supported.issue)return {issue:supported.issue};
464
595
  const python=Boolean(pythonTool(check));
465
596
  const command=python?check.command:[lookup(scriptProject(root,check.script).manager,process.env.PATH,root)||'npm','run',check.script];
466
- const selected={...check,command,environment:sandboxEnvironment(python,python?null:scriptProject(root,check.script).manager)};
467
- const scratch=fs.mkdtempSync(path.join(os.tmpdir(),'redue-trial-')),home=path.join(scratch,'home'),tmp=path.join(scratch,'tmp');
468
- fs.mkdirSync(home);fs.mkdirSync(tmp);
597
+ const selected={...check,command,environment:sandboxEnvironment(python,python?null:scriptProject(root,check.script).manager,check.kind)};
598
+ const tests=testRun(check);
599
+ // A test run's home is beneath its temporary directory, as in cli.mjs: on
600
+ // macOS that is where its Unix sockets may be.
601
+ const scratch=fs.mkdtempSync(path.join(os.tmpdir(),'redue-trial-')),tmp=path.join(scratch,'tmp'),home=tests?path.join(tmp,'home'):path.join(scratch,'home');
602
+ fs.mkdirSync(tmp);fs.mkdirSync(home);
469
603
  try{
470
- const entries=hide?hiddenEntries(root):[];
471
- if(!entries)return {issue:hiddenListIssue};
472
- const hidden=entries.map(rel=>path.join(root,rel.replace(/\/$/,""))),stats=hide&&supported.mode==='landlock'?path.join(scratch,'stats.json'):null;
604
+ const ignored=hide?hiddenEntries(root):[],docEntries=hide==='docs'?hiddenDocs(root):[];
605
+ if(!ignored)return {issue:hiddenListIssue};
606
+ if(!docEntries)return {issue:hiddenDocsIssue};
607
+ const entries=[...ignored,...docEntries];
608
+ const hidden=ignored.map(rel=>path.join(root,rel.replace(/\/$/,""))),stats=(hide||tests)&&supported.mode==='landlock'?path.join(scratch,'stats.json'):null;
473
609
  const run=async(argv,env)=>{const started=Date.now(),r=await runToEnd(argv,{cwd:root,env,timeout});
474
610
  return {status:r.timedOut?'timeout':r.status??r.signal??r.error?.code,output:`${r.stdout}\n${r.stderr}`,ms:Date.now()-started};};
475
611
  const plain=earlier??await run(command,{...process.env,...python?{}:{npm_config_update_notifier:'false'}});
476
612
  const reads=[root,home,tmp,...sandboxPlaces(selected,process.env,root),...systemPlaces];
477
- const sandboxed=await run(sandboxArgv(supported.mode,command,{reads,tmp,python:supported.python,hidden,stats}),sandboxEnv(selected,process.env,{root,home,tmp}));
613
+ const sandboxed=await run(sandboxArgv(supported.mode,command,{reads,tmp,python:supported.python,hidden,stats,
614
+ ...hide==='docs'?{docs:{root,entries:docEntries}}:{},
615
+ ...tests?{...supported.mode==='landlock'?{network:'loopback'}:{},writes:[root,home,tmp]}:{}}),sandboxEnv(selected,process.env,{root,home,tmp}));
616
+ // A test run's network was supervised throughout, on Linux.
617
+ const supervisor=tests&&stats?supervisorIssue(stats):null;
478
618
  // A failure both ways counts as the same only without a sign of a refusal.
479
619
  const denied=sandboxed.status===0?null:denialLine(sandboxed.output,entries);
480
620
  const realOf=dir=>{try{return fs.realpathSync(dir);}catch{return null;}},plainTmp=process.env.TMPDIR||os.tmpdir();
@@ -486,8 +626,14 @@ export async function sandboxTrial(root,check,{timeout=300000,hide=false,plain:e
486
626
  if(at<0&&b.length>a.length)at=a.length;
487
627
  const output=at<0?null:{plain:(a[at]??'(no more output)').slice(0,160),sandboxed:(b[at]??'(no more output)').slice(0,160)};
488
628
  let launcher=null;if(stats)try{launcher=JSON.parse(fs.readFileSync(stats,'utf8'));}catch{}
489
- return {same:plain.status===sandboxed.status&&!denied&&!output,plain:plain.status,sandboxed:sandboxed.status,output,plainRun:plain,
490
- line:denied||(plain.status===sandboxed.status?null:sandboxed.output.trim().split('\n').at(-1)?.slice(0,200)||null),
491
- ...hide?{hidden:entries.length,entries,plainMs:plain.ms,sandboxedMs:sandboxed.ms,...launcher?{rules:launcher.rules,setupMs:launcher.ms}:{}}:{}};
629
+ // Tests print in an order their workers decide: the counts their summary
630
+ // gives are compared, not every line.
631
+ // When neither summary can be read, every line is compared, as for a typecheck.
632
+ const counts=tests?[plain,sandboxed].map(r=>testCounts(r.output)):null;
633
+ const read=counts?.some(c=>Object.values(c).some(value=>value!==null));
634
+ const differs=tests&&read?(JSON.stringify(counts[0])===JSON.stringify(counts[1])?null:{plain:JSON.stringify(counts[0]),sandboxed:JSON.stringify(counts[1])}):output;
635
+ return {same:plain.status===sandboxed.status&&!denied&&!differs&&!supervisor,plain:plain.status,sandboxed:sandboxed.status,output:differs,plainRun:plain,
636
+ line:supervisor||denied||(plain.status===sandboxed.status?null:sandboxed.output.trim().split('\n').at(-1)?.slice(0,200)||null),
637
+ ...hide?{hidden:entries.length,entries,docs:docEntries.length,plainMs:plain.ms,sandboxedMs:sandboxed.ms,...launcher?{rules:launcher.rules,setupMs:launcher.ms}:{}}:{}};
492
638
  }finally{fs.rmSync(scratch,{recursive:true,force:true});}
493
639
  }
package/src/setup.mjs CHANGED
@@ -12,6 +12,7 @@ import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import {createHash} from 'node:crypto';
14
14
  import {spawnSync} from 'node:child_process';
15
+ import {testCounts} from './sandbox.mjs';
15
16
  import {createInterface} from 'node:readline/promises';
16
17
  import {projectState} from './project-location.mjs';
17
18
  import {claudeHooksInstalled,removeClaudeHooks,removeIntegration} from './agent-integration.mjs';
@@ -80,7 +81,19 @@ async function setup({root,redue,yes,record:previous,recordFile,exclude,save}){
80
81
  const names=configExisted?JSON.parse(readText(configPath)).checks.map(check=>check.name):found.checks.map(check=>check.name);
81
82
  const ready=names.filter(name=>levels.get(name)==='ready');
82
83
  console.log(`Setting up REDUE for Claude Code in ${root}`);
83
- console.log(configExisted?` keep ${configName} (exists)`:` create ${configName}: ${names.map(name=>`${name} (${levels.get(name)==='ready'?'can qualify':'recording-only'})`).join(', ')}`);
84
+ console.log(configExisted?` keep ${configName} (exists)`:` create ${configName}: ${names.map(name=>`${name} (${levels.get(name)==='ready'?'can qualify':'recording only'})`).join(', ')}`);
85
+ // What the checks setup does not make reusable are, and why.
86
+ if(!configExisted){
87
+ const recording=found.checks.filter(check=>check.level!=='ready');
88
+ if(recording.length)console.log(' recording only: REDUE records these runs but never reports one CURRENT, because it cannot tell what the result depends on:');
89
+ for(const check of recording)console.log(` ${check.name}: ${check.reason}`);
90
+ const out=[...found.skipped||[]].filter(row=>row.reason);
91
+ const kinds=new Set([...found.checks.map(check=>check.kind??check.name.split(':')[0]),...out.map(row=>String(row.kind).split(':')[0])]);
92
+ for(const kind of ['typecheck','lint','test'])if(!kinds.has(kind)&&!found.checks.some(check=>check.name===kind))
93
+ out.push({kind,reason:`${kind}: none found (no script or tool REDUE recognizes)`});
94
+ if(out.length)console.log(' left out:');
95
+ for(const row of out)console.log(` ${row.reason.startsWith(String(row.kind))?row.reason:`${row.kind}: ${row.reason}`}`);
96
+ }
84
97
  console.log(` write the Claude Code integration: CLAUDE.md section, .claude/skills/redue-verification/SKILL.md, ${localSettings} hooks, ${agentRecord}`);
85
98
  console.log(` start the observer and record ${ready.length?ready.join(', '):'no check'} once, unless already CURRENT`);
86
99
  if(!await confirm('Proceed?',yes,{defaultYes:true})){console.log('Nothing written.');return {applied:false};}
@@ -121,7 +134,7 @@ async function setup({root,redue,yes,record:previous,recordFile,exclude,save}){
121
134
  // recorded as reusable. Wait while it plans and indexes; once its plan is
122
135
  // ready, an observer that is still unhealthy says why in the status below.
123
136
  // Nothing to wait for when no check can qualify.
124
- const observation=()=>{const out=redue(['status','--json'],{capture:true});try{return JSON.parse(out.stdout).observation??null;}catch{return null;}};
137
+ const observation=()=>{const out=redue(['status','--cached','--json'],{capture:true});try{return JSON.parse(out.stdout).observation??null;}catch{return null;}};
125
138
  // An answer without a phase (a stopped observer) for 10 s in a row ends the
126
139
  // wait too.
127
140
  const begun=Date.now();let told=-1,unread=0,phaseless=0;
@@ -136,9 +149,18 @@ async function setup({root,redue,yes,record:previous,recordFile,exclude,save}){
136
149
  // One recorded run of each check that can qualify and is not CURRENT.
137
150
  const statusOf=()=>{const out=redue(['status','--sync','--json'],{capture:true,timeout:600_000});try{return JSON.parse(out.stdout).checks;}catch{return [];}};
138
151
  const rows=statusOf(),due=ready.filter(name=>rows.find(row=>row.name===name)?.freshness!=='CURRENT');
139
- for(const name of due){console.log(`Recording ${name} once:`);redue(['run',name]);}
152
+ // One line per check; the run's own output only when it fails.
153
+ for(const name of due){
154
+ console.log(`Recording ${name} once (running it)...`);
155
+ const started=Date.now(),run=redue(['run',name],{capture:true});
156
+ const output=`${run.stdout??''}\n${run.stderr??''}`,counts=testCounts(output),seconds=((Date.now()-started)/1000).toFixed(1);
157
+ const tally=Object.entries(counts).filter(([,value])=>value!==null).map(([key,value])=>`${value} ${key}`).join(', ');
158
+ console.log(` ${name}: ${run.status===0?'PASS':`FAIL (exit ${run.status??run.signal})`} in ${seconds} s${tally?`, ${tally}`:''}`);
159
+ if(run.status!==0)console.log(output.trim().split('\n').slice(-15).map(line=>` ${line}`).join('\n'));
160
+ }
140
161
  if(ready.length&&!due.length)console.log(`Already CURRENT: ${ready.join(', ')}.`);
141
- for(const row of statusOf())console.log(` ${row.name}: ${row.freshness}${row.result?' / '+row.result:''}${row.freshness==='CURRENT'?'':' — '+row.reason}`);
162
+ for(const row of statusOf())console.log(` ${row.name}: ${row.freshness}${row.result?' / '+row.result:''}${row.freshness==='CURRENT'?'':
163
+ levels.get(row.name)!=='ready'?' — recording only':' — '+row.reason}`);
142
164
  console.log('Done. Start a new Claude Code session in this project. Undo with redue remove.');
143
165
  return {applied:true};
144
166
  }