@redue/cli 0.1.0-alpha.3 → 0.1.0-alpha.30
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 +806 -0
- package/README.md +157 -99
- package/bin/redue.mjs +267 -61
- package/docs/README.md +3 -1
- package/docs/user/agents.md +333 -15
- package/docs/user/configuration.md +28 -2
- package/docs/user/direct-typescript.md +219 -0
- package/docs/user/getting-started.md +49 -7
- package/docs/user/json.md +4 -2
- package/docs/user/node-script.md +177 -0
- package/docs/user/npm-launcher-notice.md +42 -0
- package/docs/user/pilot.md +146 -0
- package/docs/user/sandbox.md +312 -0
- package/docs/user/shell.md +3 -3
- package/docs/user/supported-projects.md +69 -23
- package/docs/user/tests-direct.md +143 -0
- package/package.json +4 -3
- package/src/agent-integration.mjs +467 -0
- package/src/agent-policy.md +28 -0
- package/src/cached-state.mjs +24 -1
- package/src/claude-hook.mjs +616 -0
- package/src/cli.mjs +449 -54
- package/src/daemon.mjs +442 -93
- package/src/decision-profile.mjs +15 -1
- package/src/decision-validation.mjs +12 -1
- package/src/declared-plan.mjs +38 -6
- package/src/direct-run-certificate.mjs +254 -0
- package/src/direct-typescript-probe.mjs +27 -0
- package/src/direct-typescript.mjs +205 -0
- package/src/glob.mjs +8 -0
- package/src/history-worker.mjs +21 -13
- package/src/index.mjs +108 -21
- package/src/installed-inputs.mjs +7 -1
- package/src/installed-observation.mjs +23 -0
- package/src/landlock.py +527 -0
- package/src/linux-inotify.py +39 -10
- package/src/node-onboarding.mjs +203 -54
- package/src/node-script.mjs +406 -0
- package/src/npm-integrity.mjs +124 -0
- package/src/npm-typecheck-environment.mjs +36 -0
- package/src/npmrc.mjs +16 -0
- package/src/observation.mjs +34 -12
- package/src/path-identity.mjs +8 -0
- package/src/plan-worker.mjs +17 -17
- package/src/pnpm-inputs.mjs +122 -57
- package/src/pnpm-shim-templates.json +180 -0
- package/src/pnpm-shims.mjs +54 -0
- package/src/pnpm-typecheck-probe.mjs +11 -6
- package/src/presentation.mjs +13 -4
- package/src/probe-deadline.mjs +37 -0
- package/src/probe-result.mjs +7 -3
- package/src/project-location.mjs +34 -0
- package/src/python-onboarding.mjs +89 -0
- package/src/run-recorder.cjs +91 -0
- package/src/sandbox.mjs +653 -0
- package/src/setup.mjs +205 -0
- package/src/stats.mjs +72 -0
- package/src/test-direct.mjs +608 -0
- package/src/test-recorder.cjs +435 -0
- package/src/typescript-contract-probe.mjs +4 -187
- package/src/typescript-input-facts.mjs +92 -0
- package/src/typescript-invocation.mjs +92 -0
- package/src/typescript-list.mjs +118 -13
- package/src/typescript-qualification.mjs +29 -49
- package/src/workspace-manifests.mjs +24 -0
- package/src/yarn-workspace-inputs.mjs +3 -0
- package/src/yarn-workspace-typecheck-probe.mjs +2 -1
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// `redue setup claude` and `redue remove`: one command from a project with
|
|
2
|
+
// installed dependencies to a working Claude Code integration, and one to
|
|
3
|
+
// undo it. Setup runs init when the project has no config, agent setup
|
|
4
|
+
// claude, start, and one recorded run of each check init can qualify that is
|
|
5
|
+
// not CURRENT. It prints every file it writes. What it created is kept in a
|
|
6
|
+
// record in the clone's Git directory (never committed); a later setup adds
|
|
7
|
+
// to that account and never forgets it. Remove undoes exactly what the record
|
|
8
|
+
// says setup created: the agent files or just this machine's hooks, the
|
|
9
|
+
// observer state, the local ignore line, and the config if it is unchanged
|
|
10
|
+
// since setup wrote it.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import {createHash} from 'node:crypto';
|
|
14
|
+
import {spawnSync} from 'node:child_process';
|
|
15
|
+
import {testCounts} from './sandbox.mjs';
|
|
16
|
+
import {createInterface} from 'node:readline/promises';
|
|
17
|
+
import {projectState} from './project-location.mjs';
|
|
18
|
+
import {claudeHooksInstalled,removeClaudeHooks,removeIntegration} from './agent-integration.mjs';
|
|
19
|
+
|
|
20
|
+
const configName='redue.config.json',localSettings='.claude/settings.local.json',agentRecord='.redue/agents/claude.json';
|
|
21
|
+
const hash=text=>createHash('sha256').update(text).digest('hex');
|
|
22
|
+
const readText=file=>{try{return fs.readFileSync(file,'utf8');}catch{return null;}};
|
|
23
|
+
const git=(root,...args)=>spawnSync('git',args,{cwd:root,encoding:'utf8',stdio:['ignore','pipe','ignore']});
|
|
24
|
+
const ignoreLine=`/${localSettings}`;
|
|
25
|
+
|
|
26
|
+
async function confirm(question,yes,{defaultYes}){
|
|
27
|
+
if(yes)return true;
|
|
28
|
+
if(!process.stdin.isTTY||!process.stdout.isTTY)throw Error('not a terminal; run again with --yes to proceed without prompts');
|
|
29
|
+
const prompt=createInterface({input:process.stdin,output:process.stdout});
|
|
30
|
+
try{const answer=(await prompt.question(`${question} ${defaultYes?'[Y/n]':'[y/N]'} `)).trim();
|
|
31
|
+
return defaultYes?!/^n/i.test(answer):/^y/i.test(answer);}finally{prompt.close();}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function setupCommand(args,{entry,cwd=process.cwd()}){
|
|
35
|
+
const action=args.shift();
|
|
36
|
+
const yes=args.includes('--yes'),rest=args.filter(arg=>arg!=='--yes');
|
|
37
|
+
if(action==='setup'&&(rest.length!==1||rest[0]!=='claude'))throw Error('use redue setup claude [--yes]');
|
|
38
|
+
if(action==='remove'&&rest.length)throw Error('use redue remove [--yes]');
|
|
39
|
+
const top=git(cwd,'rev-parse','--show-toplevel');
|
|
40
|
+
if(top.status!==0)throw Error('run this inside a Git checkout');
|
|
41
|
+
const root=fs.realpathSync(top.stdout.trim());
|
|
42
|
+
if(!['package.json','pyproject.toml','setup.cfg','setup.py','requirements.txt'].some(file=>fs.existsSync(path.join(root,file))))
|
|
43
|
+
throw Error(`no package.json or Python project at the repository root (${root})`);
|
|
44
|
+
// The entry as invoked, so a hook written by a child names the install's
|
|
45
|
+
// stable bin link rather than the versioned file behind it.
|
|
46
|
+
const invoked=typeof process.argv[1]==='string'&&path.isAbsolute(process.argv[1])&&
|
|
47
|
+
(()=>{try{return fs.realpathSync(process.argv[1])===fs.realpathSync(entry);}catch{return false;}})()?process.argv[1]:entry;
|
|
48
|
+
// Every step is the ordinary command, so its own output says what it did.
|
|
49
|
+
const redue=(argv,options={})=>spawnSync(process.execPath,[invoked,...argv],{cwd:root,encoding:'utf8',
|
|
50
|
+
stdio:options.capture?['ignore','pipe','pipe']:'inherit',timeout:options.timeout});
|
|
51
|
+
// One record per checkout, beside the others in the shared Git directory:
|
|
52
|
+
// linked worktrees share .git/info/exclude.
|
|
53
|
+
const common=path.resolve(root,git(root,'rev-parse','--git-common-dir').stdout.trim());
|
|
54
|
+
const recordFile=path.join(common,`redue-setup-${hash(root).slice(0,16)}.json`);
|
|
55
|
+
// Other checkouts' records, for checkouts that still exist.
|
|
56
|
+
const others=()=>{try{return fs.readdirSync(common).filter(name=>/^redue-setup-[0-9a-f]{16}\.json$/.test(name)&&path.join(common,name)!==recordFile)
|
|
57
|
+
.map(name=>JSON.parse(readText(path.join(common,name))??'null')).filter(other=>other&&fs.existsSync(other.root));}catch{return [];}};
|
|
58
|
+
const context={root,redue,yes,record:JSON.parse(readText(recordFile)??'null'),recordFile,
|
|
59
|
+
exclude:path.join(common,'info','exclude'),others,save:next=>fs.writeFileSync(recordFile,JSON.stringify(next,null,2)+'\n')};
|
|
60
|
+
return action==='setup'?setup(context):remove(context);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function setup({root,redue,yes,record:previous,recordFile,exclude,save}){
|
|
64
|
+
// A Node project needs node_modules; a Python one its .venv.
|
|
65
|
+
const node=fs.existsSync(path.join(root,'package.json'));
|
|
66
|
+
if(node&&!fs.existsSync(path.join(root,'node_modules')))
|
|
67
|
+
throw Error('dependencies are not installed: run your package manager\'s install (npm install, pnpm install, yarn) first');
|
|
68
|
+
if(!node&&!fs.existsSync(path.join(root,'.venv','pyvenv.cfg')))
|
|
69
|
+
throw Error('no .venv in the project root: create it first (uv sync, or Poetry with virtualenvs.in-project)');
|
|
70
|
+
if(fs.existsSync(path.join(root,'vstate.config.json'))&&!fs.existsSync(path.join(root,configName)))
|
|
71
|
+
throw Error('a legacy vstate.config.json exists; rename it to redue.config.json or remove it first');
|
|
72
|
+
if(git(root,'ls-files','--error-unmatch',localSettings).status===0)
|
|
73
|
+
throw Error(`${localSettings} is committed; the hooks REDUE adds there name this machine's paths. Untrack it (git rm --cached) first`);
|
|
74
|
+
const configPath=path.join(root,configName),configExisted=fs.existsSync(configPath);
|
|
75
|
+
// init's account of the checks: which can qualify, also with a config.
|
|
76
|
+
const preview=redue(['init','--dry-run','--json'],{capture:true});
|
|
77
|
+
if(preview.status!==0)throw Error(preview.stderr.trim().replace(/^redue: /,'')||'init failed');
|
|
78
|
+
const found=JSON.parse(preview.stdout);
|
|
79
|
+
if(!configExisted&&!found.writable)throw Error(`nothing to set up: ${found.issue||(found.checks.length?found.checks.map(check=>`${check.name}: ${check.reason}`).join('; '):'no typecheck, lint or test script REDUE can use')}`);
|
|
80
|
+
const levels=new Map(found.checks.map(check=>[check.name,check.level]));
|
|
81
|
+
const names=configExisted?JSON.parse(readText(configPath)).checks.map(check=>check.name):found.checks.map(check=>check.name);
|
|
82
|
+
const ready=names.filter(name=>levels.get(name)==='ready');
|
|
83
|
+
console.log(`Setting up REDUE for Claude Code in ${root}`);
|
|
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
|
+
}
|
|
97
|
+
console.log(` write the Claude Code integration: CLAUDE.md section, .claude/skills/redue-verification/SKILL.md, ${localSettings} hooks, ${agentRecord}`);
|
|
98
|
+
console.log(` start the observer and record ${ready.length?ready.join(', '):'no check'} once, unless already CURRENT`);
|
|
99
|
+
if(!await confirm('Proceed?',yes,{defaultYes:true})){console.log('Nothing written.');return {applied:false};}
|
|
100
|
+
// What this run creates is added to what earlier runs created.
|
|
101
|
+
const state=projectState(root,configPath);
|
|
102
|
+
const own={schema:1,host:'claude',root,config:configName,state,
|
|
103
|
+
configCreated:Boolean(previous?.configCreated),configHash:previous?.configHash??null,
|
|
104
|
+
agentCreated:Boolean(previous?.agentCreated)||!fs.existsSync(path.join(root,agentRecord)),
|
|
105
|
+
hooksAdded:Boolean(previous?.hooksAdded)||!claudeHooksInstalled(root),
|
|
106
|
+
stateCreated:Boolean(previous?.stateCreated)||!fs.existsSync(state),
|
|
107
|
+
excludeAdded:Boolean(previous?.excludeAdded)};
|
|
108
|
+
if(!configExisted){
|
|
109
|
+
const init=redue(['init']);
|
|
110
|
+
if(init.status!==0||!fs.existsSync(configPath))throw Error(`init did not write ${configName}; nothing else was written`);
|
|
111
|
+
Object.assign(own,{configCreated:true,configHash:hash(readText(configPath))});
|
|
112
|
+
}
|
|
113
|
+
save(own);
|
|
114
|
+
console.log(`${previous?'update':'create'} ${recordFile}: what setup created, for redue remove`);
|
|
115
|
+
const agent=redue(['agent','setup','claude','--apply']);
|
|
116
|
+
if(agent.status!==0)throw Error('agent setup failed; see above. redue remove undoes what setup wrote');
|
|
117
|
+
// The hooks' settings hold machine paths: keep them out of commits in this
|
|
118
|
+
// clone when Git does not ignore them.
|
|
119
|
+
if(git(root,'check-ignore','-q',localSettings).status===1){
|
|
120
|
+
fs.mkdirSync(path.dirname(exclude),{recursive:true});
|
|
121
|
+
const text=readText(exclude)??'';
|
|
122
|
+
fs.writeFileSync(exclude,`${text}${text&&!text.endsWith('\n')?'\n':''}${ignoreLine}\n`);
|
|
123
|
+
console.log(`update ${exclude}: ignore ${ignoreLine} in this clone`);
|
|
124
|
+
own.excludeAdded=true;save(own);
|
|
125
|
+
}
|
|
126
|
+
// An observer that is already running is what start would give.
|
|
127
|
+
const started=redue(['start'],{capture:true});
|
|
128
|
+
process.stdout.write(started.stdout);
|
|
129
|
+
if(started.status!==0){
|
|
130
|
+
if(!/already running/.test(started.stderr))throw Error(`the observer did not start: ${started.stderr.trim().replace(/^redue: /,'')}`);
|
|
131
|
+
console.log('Observer already running.');
|
|
132
|
+
}
|
|
133
|
+
// The observer indexes the project first; a run before that cannot be
|
|
134
|
+
// recorded as reusable. Wait while it plans and indexes; once its plan is
|
|
135
|
+
// ready, an observer that is still unhealthy says why in the status below.
|
|
136
|
+
// Nothing to wait for when no check can qualify.
|
|
137
|
+
const observation=()=>{const out=redue(['status','--cached','--json'],{capture:true});try{return JSON.parse(out.stdout).observation??null;}catch{return null;}};
|
|
138
|
+
// An answer without a phase (a stopped observer) for 10 s in a row ends the
|
|
139
|
+
// wait too.
|
|
140
|
+
const begun=Date.now();let told=-1,unread=0,phaseless=0;
|
|
141
|
+
for(let state=observation();ready.length&&!(state?.phase==='ready'||state?.phase==='unavailable')&&phaseless<10;state=observation()){
|
|
142
|
+
const seconds=Math.round((Date.now()-begun)/1000);
|
|
143
|
+
unread=state===null?unread+1:0;phaseless=state&&!state.phase?phaseless+1:0;
|
|
144
|
+
if(unread>=30)throw Error('the observer\'s status cannot be read; run redue status to see why');
|
|
145
|
+
if(seconds>15*60)throw Error(`the observer is not ready after 15 minutes (${state?.reason||state?.phase||'unknown'}); run redue status, then redue setup claude again`);
|
|
146
|
+
if(Math.floor(seconds/15)>told){told=Math.floor(seconds/15);console.log(seconds?`Still indexing (${seconds} s)...`:'Waiting for the observer to index the project...');}
|
|
147
|
+
await new Promise(resolve=>setTimeout(resolve,1000));
|
|
148
|
+
}
|
|
149
|
+
// One recorded run of each check that can qualify and is not CURRENT.
|
|
150
|
+
const statusOf=()=>{const out=redue(['status','--sync','--json'],{capture:true,timeout:600_000});try{return JSON.parse(out.stdout).checks;}catch{return [];}};
|
|
151
|
+
const rows=statusOf(),due=ready.filter(name=>rows.find(row=>row.name===name)?.freshness!=='CURRENT');
|
|
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
|
+
}
|
|
161
|
+
if(ready.length&&!due.length)console.log(`Already CURRENT: ${ready.join(', ')}.`);
|
|
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}`);
|
|
164
|
+
console.log('Done. Start a new Claude Code session in this project. Undo with redue remove.');
|
|
165
|
+
return {applied:true};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function remove({root,redue,yes,record:own,recordFile,exclude,others}){
|
|
169
|
+
if(!own){console.log('No setup record for this checkout: nothing redue setup created to remove.');
|
|
170
|
+
console.log('To undo a setup made step by step: redue agent remove claude --apply, then redue remove-state.');return {removed:false};}
|
|
171
|
+
const configPath=path.join(root,configName),configText=readText(configPath);
|
|
172
|
+
const removeAgent=own.agentCreated&&fs.existsSync(path.join(root,agentRecord));
|
|
173
|
+
const removeHooks=!removeAgent&&own.hooksAdded&&claudeHooksInstalled(root);
|
|
174
|
+
const removeState=own.stateCreated&&fs.existsSync(own.state);
|
|
175
|
+
const deleteConfig=own.configCreated&&configText!==null&&hash(configText)===own.configHash;
|
|
176
|
+
// The ignore line goes only when no other checkout of this repository is
|
|
177
|
+
// set up: those rely on it too, whichever added it.
|
|
178
|
+
const removeIgnore=own.excludeAdded&&!others().length;
|
|
179
|
+
console.log(`Removing what redue setup created in ${root}`);
|
|
180
|
+
if(removeAgent)console.log(' remove the Claude Code integration');
|
|
181
|
+
if(removeHooks)console.log(` remove REDUE's hooks from ${localSettings} (the integration predates setup)`);
|
|
182
|
+
if(removeState)console.log(' stop the observer and remove its local state (receipts included)');
|
|
183
|
+
else if(!own.stateCreated)console.log(' keep the observer state (it predates setup)');
|
|
184
|
+
if(own.configCreated&&configText!==null)console.log(deleteConfig?` delete ${configName}`:` keep ${configName} (edited since setup)`);
|
|
185
|
+
if(removeIgnore)console.log(` remove ${ignoreLine} from ${exclude}`);
|
|
186
|
+
console.log(` delete ${recordFile}`);
|
|
187
|
+
if(!await confirm('Proceed?',yes,{defaultYes:false})){console.log('Nothing removed.');return {removed:false};}
|
|
188
|
+
if(removeAgent)for(const line of removeIntegration(root))console.log(line);
|
|
189
|
+
if(removeHooks){const changed=removeClaudeHooks(root);if(changed)console.log(`update ${changed}`);}
|
|
190
|
+
if(removeState&&redue(['--state-dir',own.state,'remove-state']).status!==0)
|
|
191
|
+
throw Error('the observer state was not removed; see above. The config and setup record are kept so you can retry');
|
|
192
|
+
if(deleteConfig){fs.unlinkSync(configPath);console.log(`remove ${configName}`);}
|
|
193
|
+
if(removeIgnore){
|
|
194
|
+
const text=readText(exclude);
|
|
195
|
+
if(text!==null&&text.split('\n').includes(ignoreLine)){
|
|
196
|
+
fs.writeFileSync(exclude,text.split('\n').filter(line=>line!==ignoreLine).join('\n'));
|
|
197
|
+
console.log(`update ${exclude}`);}
|
|
198
|
+
}
|
|
199
|
+
fs.unlinkSync(recordFile);console.log(`remove ${recordFile}`);
|
|
200
|
+
// Folders the integration lived in, when nothing else is left in them.
|
|
201
|
+
for(const dir of ['.redue/agents','.redue','.claude/skills/redue-verification','.claude/skills','.claude'])
|
|
202
|
+
try{fs.rmdirSync(path.join(root,dir));console.log(`remove ${dir}/`);}catch{}
|
|
203
|
+
console.log('Done.');
|
|
204
|
+
return {removed:true};
|
|
205
|
+
}
|
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
|
+
}
|