@redue/cli 0.1.0-alpha.15 → 0.1.0-alpha.17
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 +68 -0
- package/README.md +1 -1
- package/bin/redue.mjs +17 -10
- package/docs/user/agents.md +46 -11
- package/docs/user/pilot.md +3 -3
- package/docs/user/supported-projects.md +16 -3
- package/docs/user/tests-direct.md +20 -4
- package/package.json +1 -1
- package/src/agent-integration.mjs +43 -28
- package/src/claude-hook.mjs +229 -40
- package/src/cli.mjs +9 -4
- package/src/daemon.mjs +1 -1
- package/src/declared-plan.mjs +8 -1
- package/src/index.mjs +29 -12
- package/src/installed-inputs.mjs +4 -0
- package/src/node-onboarding.mjs +21 -17
- package/src/pnpm-inputs.mjs +62 -31
- package/src/test-direct.mjs +145 -32
- package/src/test-recorder.cjs +3 -2
- package/src/workspace-manifests.mjs +23 -0
package/src/claude-hook.mjs
CHANGED
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
// reports CURRENT/PASS with reuse_eligible on the current inputs. When the check is not
|
|
3
3
|
// CURRENT, the command becomes `redue run <check>` so the run is recorded. Every
|
|
4
4
|
// other outcome, including errors and timeouts, allows the command unchanged.
|
|
5
|
+
// The same command handles SessionStart: it starts the project's observer.
|
|
5
6
|
// Never exit 2: Claude Code treats exit 2 as a block.
|
|
6
7
|
import fs from 'node:fs';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import {spawn} from 'node:child_process';
|
|
9
10
|
import {projectState} from './project-location.mjs';
|
|
10
|
-
import {realObservedPath
|
|
11
|
+
import {realObservedPath} from './path-identity.mjs';
|
|
12
|
+
import {processAlive} from './process-liveness.mjs';
|
|
11
13
|
|
|
12
14
|
export const hookCommand='redue agent hook claude';
|
|
13
|
-
|
|
15
|
+
// No shell metacharacters; `@` starts scoped package names.
|
|
16
|
+
const safe=/^[A-Za-z0-9_./:=,+@-]+$/;
|
|
14
17
|
const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
|
|
15
18
|
// A direct test check: its command is the runner and the script's arguments.
|
|
16
19
|
const tests=check=>check?.qualification==='npm-tests-direct-v1'&&Array.isArray(check.command)&&
|
|
@@ -22,32 +25,155 @@ export const defaultConfig='redue.config.json';
|
|
|
22
25
|
// would need shell quoting.
|
|
23
26
|
export const redueFor=(configFile=defaultConfig)=>configFile===defaultConfig?'redue':
|
|
24
27
|
safe.test(configFile)?`redue --config ${configFile}`:null;
|
|
28
|
+
|
|
29
|
+
// Package-manager commands that a script of the same name would not reach.
|
|
30
|
+
const builtins={pnpm:new Set(['add','audit','bin','config','create','dedupe','deploy','dlx','doctor','env','exec','fetch',
|
|
31
|
+
'i','import','init','install','link','list','ln','ls','outdated','pack','patch','prune','publish','rebuild','recursive',
|
|
32
|
+
'remove','rm','root','run','self-update','server','setup','store','uninstall','unlink','up','update','why','x']),
|
|
33
|
+
// Yarn 4 and Yarn 1 together.
|
|
34
|
+
yarn:new Set(['access','add','audit','autoclean','bin','cache','check','config','constraints','create','dedupe','dir','dlx',
|
|
35
|
+
'exec','explain','generate-lock-entry','global','help','import','info','init','install','licenses','link','list','login',
|
|
36
|
+
'logout','node','npm','outdated','owner','pack','patch','plugin','policies','publish','rebuild','remove','run','set',
|
|
37
|
+
'stage','tag','team','unlink','unplug','up','upgrade','upgrade-interactive','version','versions','why','workspace','workspaces'])};
|
|
38
|
+
// A package script run by the project's package manager, in the package's directory.
|
|
39
|
+
function scriptForms(pm,script){
|
|
40
|
+
if(typeof script!=='string'||!safe.test(script))return [];
|
|
41
|
+
if(pm==='npm')return [`npm run ${script}`,...(script==='test'?['npm test']:[])];
|
|
42
|
+
if(pm==='pnpm'||pm==='yarn')return [...(builtins[pm].has(script)?[]:[`${pm} ${script}`]),`${pm} run ${script}`];
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
// A workspace package's script, run from the project root.
|
|
46
|
+
function workspaceForms(pm,name,script){
|
|
47
|
+
if(typeof name!=='string'||!safe.test(name)||typeof script!=='string'||!safe.test(script))return [];
|
|
48
|
+
if(pm==='pnpm')return ['--filter ','--filter=','-F '].flatMap(flag=>
|
|
49
|
+
[...(builtins.pnpm.has(script)?[]:[`pnpm ${flag}${name} ${script}`]),`pnpm ${flag}${name} run ${script}`]);
|
|
50
|
+
if(pm==='yarn')return [...(builtins.yarn.has(script)?[]:[`yarn workspace ${name} ${script}`]),`yarn workspace ${name} run ${script}`];
|
|
51
|
+
if(pm==='npm')return [`npm run ${script} -w ${name}`,`npm run ${script} --workspace ${name}`,
|
|
52
|
+
`npm run ${script} --workspace=${name}`,`npm -w ${name} run ${script}`];
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
// The tool a check runs, as typed: `tsc ARGS` or the test runner's command.
|
|
56
|
+
function toolText(check){
|
|
57
|
+
const command=check.command;
|
|
58
|
+
if(!Array.isArray(command)||command[0]!=='@node')return null;
|
|
59
|
+
const words=/^@typescript-(?:compiler|bin):/.test(command[1]||'')?['tsc',...command.slice(2)]:
|
|
60
|
+
tests(check)?[command[1].slice('@test-runner:'.length),...command.slice(2)]:null;
|
|
61
|
+
return words?.every(word=>typeof word==='string'&&safe.test(word))?words.join(' '):null;
|
|
62
|
+
}
|
|
63
|
+
// A bare `tsc` is rarely on PATH; a test runner's own text is what the script says.
|
|
64
|
+
const toolForms=(pm,text)=>!text?[]:[...(/^tsc(?: |$)/.test(text)?[]:[text]),`npx ${text}`,...(pm==='pnpm'?[`pnpm exec ${text}`]:[])];
|
|
65
|
+
// A workspace check's package: its `workspace`, or the package its generated
|
|
66
|
+
// `npm run S --workspace W`, `yarn workspace W run S` or `pnpm --filter W run S`
|
|
67
|
+
// command names; null for any other check.
|
|
68
|
+
function workspaceName(check){
|
|
69
|
+
if(typeof check.workspace==='string')return check.workspace;
|
|
70
|
+
const command=check.command,flag={'@which:npm':'--workspace','@which:yarn':'workspace','@which:pnpm':'--filter'}[command?.[0]];
|
|
71
|
+
const at=flag&&Array.isArray(command)?command.indexOf(flag):-1;
|
|
72
|
+
return at>0&&typeof command[at+1]==='string'?command[at+1]:null;
|
|
73
|
+
}
|
|
74
|
+
// Where a check runs: '.' for a root check, the package directory for a
|
|
75
|
+
// workspace check, or null. A workspace check's directory is its `cwd`, or the
|
|
76
|
+
// one directory its inputs name as `DIR/package.json` or `DIR/**` with
|
|
77
|
+
// `!DIR/node_modules/**`, as init writes them.
|
|
78
|
+
export function checkDir(check){
|
|
79
|
+
if(!workspaceName(check))return check.cwd===undefined||check.cwd==='.'?'.':null;
|
|
80
|
+
if(typeof check.cwd==='string'&&check.cwd!=='.')return check.cwd;
|
|
81
|
+
const inputs=(check.inputs||[]).filter(value=>typeof value==='string');
|
|
82
|
+
const dirs=new Set(inputs.flatMap(value=>{
|
|
83
|
+
const manifest=/^([^!*]+)\/package\.json$/.exec(value)?.[1],tree=/^([^!*]+)\/\*\*$/.exec(value)?.[1];
|
|
84
|
+
return manifest?[manifest]:tree&&inputs.includes(`!${tree}/node_modules/**`)?[tree]:[];
|
|
85
|
+
}));
|
|
86
|
+
return dirs.size===1?[...dirs][0]:null;
|
|
87
|
+
}
|
|
88
|
+
// The package script a check runs: its `script`, or the last word of a
|
|
89
|
+
// generated `pnpm --filter NAME run SCRIPT`-style command.
|
|
90
|
+
const checkScript=check=>typeof check.script==='string'?check.script:
|
|
91
|
+
Array.isArray(check.command)&&/^@which:(?:npm|pnpm|yarn)$/.test(check.command[0])&&check.command.includes('run')?
|
|
92
|
+
check.command[check.command.indexOf('run')+1]:null;
|
|
93
|
+
|
|
25
94
|
// Exact commands that rerun each configured typecheck or direct test check.
|
|
26
|
-
//
|
|
95
|
+
// `forms` work from the project root; `local` ones work in the check's own
|
|
96
|
+
// directory `dir`, after a `cd` into it. Names that would need shell quoting
|
|
97
|
+
// get no form, so the hook never fires for them.
|
|
27
98
|
export function hookForms(config,configFile=defaultConfig){
|
|
28
|
-
const rows=[],redue=redueFor(configFile);
|
|
99
|
+
const rows=[],redue=redueFor(configFile),pm=config?.packageManager;
|
|
29
100
|
for(const check of config?.checks||[]){
|
|
30
101
|
if(!(typecheck(check)||tests(check))||typeof check.name!=='string'||!safe.test(check.name))continue;
|
|
31
|
-
const forms=redue?[`${redue} run ${check.name}`]:[];
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
// The script's own text, which runs the same runner with the same arguments.
|
|
41
|
-
if(tests(check)){
|
|
42
|
-
if(check.script==='test'&&config.packageManager==='npm')forms.push('npm test');
|
|
43
|
-
const text=[check.command[1].slice('@test-runner:'.length),...check.command.slice(2)];
|
|
44
|
-
if(text.every(v=>typeof v==='string'&&safe.test(v)))forms.push(text.join(' '),`npx ${text.join(' ')}`);
|
|
102
|
+
const forms=redue?[`${redue} run ${check.name}`]:[],local=[],dir=checkDir(check),script=checkScript(check);
|
|
103
|
+
if(dir==='.'){
|
|
104
|
+
forms.push(...scriptForms(pm,script),...toolForms(pm,toolText(check)));
|
|
105
|
+
if(!tests(check)&&Array.isArray(check.command)&&check.command.length&&
|
|
106
|
+
check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
|
|
107
|
+
forms.push(check.command.join(' '));
|
|
108
|
+
}else if(dir!==null&&safe.test(dir)){
|
|
109
|
+
forms.push(...workspaceForms(pm,workspaceName(check),script));
|
|
110
|
+
local.push(...scriptForms(pm,script),...(check.cwd===dir?toolForms(pm,toolText(check)):[]));
|
|
45
111
|
}
|
|
46
|
-
if(forms.length)rows.push({check:check.name,kind:checkKind(check),
|
|
112
|
+
if(forms.length||local.length)rows.push({check:check.name,kind:checkKind(check),dir,
|
|
113
|
+
forms:[...new Set(forms)],local:[...new Set(local)]});
|
|
47
114
|
}
|
|
48
115
|
return rows;
|
|
49
116
|
}
|
|
50
117
|
|
|
118
|
+
// A turbo or `pnpm -r` command that runs one task in every workspace package:
|
|
119
|
+
// {tool, task}. No filters and no nx: both can add packages or tasks that only
|
|
120
|
+
// a fuller model of the tool would see. `pnpm turbo` is left out because a root
|
|
121
|
+
// script named turbo would run instead.
|
|
122
|
+
const turboPrefix='(?:npx |pnpm exec )?',task='([A-Za-z0-9_:.-]+)';
|
|
123
|
+
const fanouts=[['turbo',new RegExp(`^${turboPrefix}turbo(?: run)? ${task}$`)],
|
|
124
|
+
['pnpm',new RegExp(`^pnpm (?:-r|--recursive)(?: run)? ${task}$`)]];
|
|
125
|
+
export function fanout(text){
|
|
126
|
+
for(const [tool,pattern] of fanouts){const match=pattern.exec(text);
|
|
127
|
+
if(match)return {tool,task:match[1]};}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
// Bash `if` rules for fan-out commands of a task some workspace check runs,
|
|
131
|
+
// and for root scripts that run one; other turbo or nx commands never start the hook.
|
|
132
|
+
export function fanoutRules(config,scripts={}){
|
|
133
|
+
const tasks=new Set((config?.checks||[]).filter(check=>(typecheck(check)||tests(check))&&typeof check.name==='string'&&
|
|
134
|
+
![null,'.'].includes(checkDir(check))).map(checkScript).filter(task=>typeof task==='string'&&/^[A-Za-z0-9_:.-]+$/.test(task)));
|
|
135
|
+
const rules=[];
|
|
136
|
+
for(const task of tasks){
|
|
137
|
+
for(const prefix of ['','npx ','pnpm exec '])rules.push(`${prefix}turbo run ${task}`,`${prefix}turbo ${task}`);
|
|
138
|
+
if(config.packageManager==='pnpm')rules.push(`pnpm -r ${task}`,`pnpm -r run ${task}`,`pnpm --recursive ${task}`,`pnpm --recursive run ${task}`);
|
|
139
|
+
}
|
|
140
|
+
for(const [name,text] of Object.entries(scripts||{})){
|
|
141
|
+
const fan=typeof text==='string'&&!scripts['pre'+name]&&!scripts['post'+name]?fanout(text.trim()):null;
|
|
142
|
+
if(fan&&tasks.has(fan.task))rules.push(...scriptForms(config.packageManager,name));
|
|
143
|
+
}
|
|
144
|
+
return rules;
|
|
145
|
+
}
|
|
146
|
+
// The checks a fan-out runs: one configured check for every workspace package
|
|
147
|
+
// with the task's script, or null when any package has none, or when the
|
|
148
|
+
// tool may run more: another task first (turbo `dependsOn` other than ^TASK,
|
|
149
|
+
// `with`, package-scoped keys, package turbo.json), a task turbo does not
|
|
150
|
+
// define, or pnpm's workspace root.
|
|
151
|
+
export async function fanoutChecks(fan,config,projectRoot){
|
|
152
|
+
const {workspacePackages}=await import('./node-onboarding.mjs');
|
|
153
|
+
const packages=workspacePackages(projectRoot).filter(item=>typeof item.scripts[fan.task]==='string');
|
|
154
|
+
if(!packages.length)return null;
|
|
155
|
+
const text=file=>{try{return fs.readFileSync(path.join(projectRoot,file),'utf8');}catch(e){return e.code==='ENOENT'?undefined:null;}};
|
|
156
|
+
if(fan.tool==='turbo'){
|
|
157
|
+
let tasks;try{const turbo=JSON.parse(text('turbo.json'));tasks=turbo?.tasks??turbo?.pipeline;}catch{return null;}
|
|
158
|
+
const own=tasks?.[fan.task];
|
|
159
|
+
if(!own||typeof own!=='object'||own.with!==undefined||
|
|
160
|
+
own.dependsOn!==undefined&&!(Array.isArray(own.dependsOn)&&own.dependsOn.every(item=>item===`^${fan.task}`))||
|
|
161
|
+
Object.keys(tasks).some(key=>key.endsWith(`#${fan.task}`))||
|
|
162
|
+
packages.some(item=>text(`${item.dir}/turbo.json`)!==undefined||text(`${item.dir}/turbo.jsonc`)!==undefined))return null;
|
|
163
|
+
}
|
|
164
|
+
if(fan.tool==='pnpm'&&(config.packageManager!=='pnpm'||
|
|
165
|
+
['.npmrc','pnpm-workspace.yaml'].some(file=>{const value=text(file);return value===null||/include-?workspace-?root/i.test(value??'');})))
|
|
166
|
+
return null;
|
|
167
|
+
const names=[];
|
|
168
|
+
for(const item of packages){
|
|
169
|
+
const check=config.checks.find(c=>(typecheck(c)||tests(c))&&typeof c.name==='string'&&
|
|
170
|
+
workspaceName(c)===item.name&&checkScript(c)===fan.task&&checkDir(c)===item.dir);
|
|
171
|
+
if(!check)return null;
|
|
172
|
+
names.push(check.name);
|
|
173
|
+
}
|
|
174
|
+
return names;
|
|
175
|
+
}
|
|
176
|
+
|
|
51
177
|
// Accept only an optional trailing `2>&1`, then an optional `| tail|head -n N`
|
|
52
178
|
// or `-N`, then optionally `;` or `&&` and the rest of the command. A form has
|
|
53
179
|
// no shell metacharacters, so the first separator after it ends it. Newlines
|
|
@@ -59,11 +185,25 @@ function split(command){
|
|
|
59
185
|
const one=text=>text.replace(/[ \t]+/g,' ');
|
|
60
186
|
return {base:one(base),suffix:one(suffix),rest};
|
|
61
187
|
}
|
|
62
|
-
|
|
63
|
-
|
|
188
|
+
// {check, rest}, or {fanout, rest} for a turbo/nx/`pnpm -r` command or a root
|
|
189
|
+
// script that runs one; null when nothing matches. `dir` is where the command
|
|
190
|
+
// runs, relative to the project root.
|
|
191
|
+
function matched(command,config,configFile,dir='.',scripts={}){
|
|
192
|
+
const parts=split(command);
|
|
193
|
+
if(!parts)return null;
|
|
194
|
+
if(dir==='.'){
|
|
195
|
+
// A root script that runs a fan-out is that fan-out, unless pre/post scripts
|
|
196
|
+
// run around it: then it matches nothing.
|
|
197
|
+
for(const [name,text] of Object.entries(scripts||{}))
|
|
198
|
+
if(typeof text==='string'&&scriptForms(config.packageManager,name).includes(parts.base)&&fanout(text.trim()))
|
|
199
|
+
return scripts['pre'+name]||scripts['post'+name]?null:{fanout:fanout(text.trim()),rest:parts.rest};
|
|
200
|
+
if(fanout(parts.base))return {fanout:fanout(parts.base),rest:parts.rest};
|
|
201
|
+
}
|
|
202
|
+
const check=hookForms(config,configFile).find(row=>dir==='.'?row.forms.includes(parts.base):
|
|
203
|
+
row.dir===dir&&row.local.includes(parts.base))?.check;
|
|
64
204
|
return check?{check,rest:parts.rest}:null;
|
|
65
205
|
}
|
|
66
|
-
export function matchCheck(command,config,configFile){return matched(command,config,configFile)?.check||null;}
|
|
206
|
+
export function matchCheck(command,config,configFile,dir,scripts){return matched(command,config,configFile,dir,scripts)?.check||null;}
|
|
67
207
|
// The same check through REDUE, keeping the output suffix; null if it already
|
|
68
208
|
// is, or if more commands follow it.
|
|
69
209
|
export function recordedCommand(command,name,configFile=defaultConfig){
|
|
@@ -73,21 +213,30 @@ export function recordedCommand(command,name,configFile=defaultConfig){
|
|
|
73
213
|
return parts.base===run?null:run+parts.suffix;
|
|
74
214
|
}
|
|
75
215
|
|
|
76
|
-
// A leading `cd DIR;` or `cd DIR
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
216
|
+
// A leading `cd DIR;` or `cd DIR &&`, which agents often add before a check:
|
|
217
|
+
// {prefix, command, dir} with the rest of the command and DIR relative to the
|
|
218
|
+
// project root ('.' for the root or no prefix), or null when DIR is outside
|
|
219
|
+
// the project or missing. DIR may be quoted; it may not use variables, a leading
|
|
80
220
|
// `~` or escapes. Inside double quotes a backslash before anything but
|
|
81
221
|
// $ ` " \ is literal, so a quoted Windows path matches; unquoted it is an escape.
|
|
82
222
|
const cdPrefix=/^[ \t]*cd[ \t]+(?:"((?:[^"$`\\]|\\(?![$`"\\\n]))*)"|'([^']*)'|((?!~)[^\s;&|'"$`\\]+))[ \t]*(?:;|&&)[ \t]*/;
|
|
83
|
-
export function
|
|
223
|
+
export function enteredDir(command,cwd,projectRoot){
|
|
84
224
|
const prefix=typeof command==='string'?cdPrefix.exec(command):null;
|
|
85
|
-
if(!prefix)return {prefix:'',command};
|
|
86
|
-
|
|
87
|
-
try{
|
|
88
|
-
|
|
225
|
+
if(!prefix)return {prefix:'',command,dir:'.'};
|
|
226
|
+
let relative;
|
|
227
|
+
try{relative=path.relative(projectRoot,realObservedPath(path.resolve(cwd,prefix[1]??prefix[2]??prefix[3])));}catch{return null;}
|
|
228
|
+
if(relative.split(path.sep)[0]==='..'||path.isAbsolute(relative))return null;
|
|
229
|
+
return {prefix:prefix[0],command:command.slice(prefix[0].length),dir:relative?relative.split(path.sep).join('/'):'.'};
|
|
89
230
|
}
|
|
90
231
|
|
|
232
|
+
// The fan-out's skip message when every check it runs is CURRENT/PASS, else null.
|
|
233
|
+
export function fanoutVerdict(status,names,rest=null){
|
|
234
|
+
if(!names.length||!names.every(name=>verdict(status,name)))return null;
|
|
235
|
+
return `REDUE: all ${names.length} checks this command runs (${names.join(', ')}) passed on these exact inputs; `+
|
|
236
|
+
'rerun skipped. This counts as a pass for the current files; no status check is needed. '+
|
|
237
|
+
'To run it anyway, prefix the command with REDUE_FORCE=1.'+
|
|
238
|
+
(rest===null?'':` Nothing else in the command ran either; run the rest on its own: ${rest}`);
|
|
239
|
+
}
|
|
91
240
|
export function verdict(status,name,rest=null){
|
|
92
241
|
const row=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)?
|
|
93
242
|
status.checks.find(item=>item?.name===name):null;
|
|
@@ -134,20 +283,56 @@ export function lastRunTimeout(config,parsed,name){
|
|
|
134
283
|
catch{return 2000;}
|
|
135
284
|
}
|
|
136
285
|
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const command=input.tool_input?.command,root=input.cwd;
|
|
141
|
-
if(typeof command!=='string'||/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return null;
|
|
286
|
+
// The project set up in the session's directory: its setup record, the config
|
|
287
|
+
// beside it and the project root. null when there is none; throws on bad files.
|
|
288
|
+
function hookProject(root){
|
|
142
289
|
if(typeof root!=='string'||!path.isAbsolute(root))return null;
|
|
143
290
|
const record=JSON.parse(fs.readFileSync(path.join(root,'.redue','agents','claude.json'),'utf8'));
|
|
144
291
|
if(record?.schema!==1||record.host!=='claude'||typeof record.config!=='string'||
|
|
145
292
|
!/^[^/\\]+\.json$/.test(record.config))return null;
|
|
146
293
|
const config=path.join(root,record.config);
|
|
147
294
|
const parsed=JSON.parse(fs.readFileSync(config,'utf8'));
|
|
148
|
-
|
|
149
|
-
|
|
295
|
+
return {record,config,parsed,projectRoot:realObservedPath(path.resolve(path.dirname(config),parsed.root||'.'))};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// SessionStart: start the observer when none is running, so the session's
|
|
299
|
+
// checks can be skipped or recorded without a manual `redue start`. It
|
|
300
|
+
// launches `redue start` detached and returns at once, and prints nothing:
|
|
301
|
+
// SessionStart output becomes agent context. Returns what it did.
|
|
302
|
+
export function sessionStart(input,{entry}){
|
|
303
|
+
const project=hookProject(input?.cwd);
|
|
304
|
+
if(!project)return null;
|
|
305
|
+
try{const pid=Number(fs.readFileSync(path.join(projectState(project.projectRoot,project.config),'observer.lock','pid'),'utf8'));
|
|
306
|
+
if(Number.isSafeInteger(pid)&&pid>0&&processAlive(pid))return 'running';}catch{}
|
|
307
|
+
const child=spawn(process.execPath,[entry,'--config',project.config,'start'],
|
|
308
|
+
{cwd:input.cwd,detached:true,stdio:'ignore',windowsHide:true,env:process.env});
|
|
309
|
+
child.on('error',()=>{});child.unref();
|
|
310
|
+
return 'started';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Returns the hook's stdout object, or null to allow the command.
|
|
314
|
+
export async function claudeHook(input,{entry,timeout}){
|
|
315
|
+
if(input?.tool_name!=='Bash')return null;
|
|
316
|
+
const command=input.tool_input?.command,root=input.cwd;
|
|
317
|
+
if(typeof command!=='string'||/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return null;
|
|
318
|
+
const project=hookProject(root);
|
|
319
|
+
if(!project)return null;
|
|
320
|
+
const {record,config,parsed}=project;
|
|
321
|
+
const entered=enteredDir(command,root,project.projectRoot);
|
|
322
|
+
if(!entered)return null;
|
|
323
|
+
let scripts={};
|
|
324
|
+
if(entered.dir==='.')try{scripts=JSON.parse(fs.readFileSync(path.join(project.projectRoot,'package.json'),'utf8')).scripts||{};}catch{}
|
|
325
|
+
const match=matched(entered.command,parsed,record.config,entered.dir,scripts);
|
|
150
326
|
if(!match)return null;
|
|
327
|
+
// turbo, nx and `pnpm -r` cache their own results: skip only when every
|
|
328
|
+
// check the command runs is CURRENT, and otherwise leave it alone.
|
|
329
|
+
if(match.fanout){
|
|
330
|
+
const names=await fanoutChecks(match.fanout,parsed,project.projectRoot);
|
|
331
|
+
if(!names)return null;
|
|
332
|
+
const status=await synchronizedStatus(entry,config,root,timeout??Math.max(...names.map(name=>lastRunTimeout(config,parsed,name)))),
|
|
333
|
+
reason=fanoutVerdict(status,names,match.rest);
|
|
334
|
+
return reason?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}:null;
|
|
335
|
+
}
|
|
151
336
|
const name=match.check,status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,name)),
|
|
152
337
|
reason=verdict(status,name,match.rest);
|
|
153
338
|
if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',
|
|
@@ -157,7 +342,9 @@ export async function claudeHook(input,{entry,timeout}){
|
|
|
157
342
|
// stays too: the rewrite returns "allow", which would also skip the prompt
|
|
158
343
|
// for the rest of the chain.
|
|
159
344
|
const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
|
|
160
|
-
status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,
|
|
345
|
+
status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,
|
|
346
|
+
// Both sides resolved alike: Windows can spell the same directory RUNNER~1 or by its long name.
|
|
347
|
+
!entered.prefix?record.config:path.relative(path.join(project.projectRoot,entered.dir),realObservedPath(config)).split(path.sep).join('/')):null;
|
|
161
348
|
// updatedInput replaces the whole tool input. Deny and ask rules still apply
|
|
162
349
|
// to the rewritten command; "allow" skips only the prompt.
|
|
163
350
|
return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
|
|
@@ -169,7 +356,9 @@ export async function runClaudeHook(entry){
|
|
|
169
356
|
try{
|
|
170
357
|
let text='';process.stdin.setEncoding('utf8');
|
|
171
358
|
for await(const chunk of process.stdin){text+=chunk;if(text.length>1024*1024)return;}
|
|
172
|
-
const
|
|
359
|
+
const input=JSON.parse(text);
|
|
360
|
+
if(input?.hook_event_name==='SessionStart'){sessionStart(input,{entry});return;}
|
|
361
|
+
const output=await claudeHook(input,{entry});
|
|
173
362
|
if(output)await new Promise(resolve=>process.stdout.write(JSON.stringify(output)+'\n',resolve));
|
|
174
363
|
}catch{}
|
|
175
364
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import fs from 'node:fs';
|
|
|
4
4
|
import {directQualification,directInterpretation,observedNpmContext} from './direct-typescript.mjs';
|
|
5
5
|
import {directRunCertificateAllowed,directRunCertificate,directRunRecordable,directRunRecording,probeQuestions,validateRunRecord} from './direct-run-certificate.mjs';
|
|
6
6
|
import {runFactChange} from './typescript-list.mjs';
|
|
7
|
-
import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,
|
|
7
|
+
import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import net from 'node:net';
|
|
10
10
|
import os from 'node:os';
|
|
@@ -273,14 +273,17 @@ async function execute() {
|
|
|
273
273
|
...command.slice(1)]:command;
|
|
274
274
|
// A test run's processes record what the guard checks (test-recorder.cjs).
|
|
275
275
|
const testRun=selected.qualification===testsQualification&&['darwin','linux'].includes(process.platform);
|
|
276
|
-
let testRecord=null,launchEnv=process.env;
|
|
276
|
+
let testRecord=null,launchEnv=process.env,testFacts={};
|
|
277
277
|
if(testRun){
|
|
278
|
+
// The package directory and installed node_modules, as the plan has them.
|
|
279
|
+
try{testFacts=testRunFacts(root,selected);}
|
|
280
|
+
catch(e){captureIssues.push(`test run facts unavailable (${e.code||e.message})`);}
|
|
278
281
|
const environmentIssue=testEnvironmentIssue();
|
|
279
282
|
if(environmentIssue)captureIssues.push(environmentIssue);
|
|
280
283
|
testRecord=path.join(state,`test-record-${runId}`);
|
|
281
284
|
fs.mkdirSync(testRecord,{mode:0o700});
|
|
282
285
|
const guardFile=path.join(testRecord,'guard.json');
|
|
283
|
-
fs.writeFileSync(guardFile,JSON.stringify(testGuardConfig(testRecord,root)),{mode:0o600});
|
|
286
|
+
fs.writeFileSync(guardFile,JSON.stringify(testGuardConfig(testRecord,root,process.env,testFacts)),{mode:0o600});
|
|
284
287
|
launchEnv=recordedEnvironment(guardFile);
|
|
285
288
|
}
|
|
286
289
|
let runnerPid=null;
|
|
@@ -354,10 +357,12 @@ async function execute() {
|
|
|
354
357
|
}else if(!unobservedDirectRun&&!recording)probeAfter=probeHash(probePlan);
|
|
355
358
|
let guard=null;
|
|
356
359
|
if(testRecord){
|
|
357
|
-
let coverage=null;try{coverage=coverageReadable(root,
|
|
360
|
+
let coverage=null;try{coverage=coverageReadable(root,testFacts);}catch{}
|
|
358
361
|
guard=testRunGuard(testRecord,{root,sourcePatterns:plan.sourcePatterns||plan.patterns,files:snap?.files,
|
|
359
362
|
runner:runnerPid,environment:selected.environment||{},coverage});
|
|
360
363
|
runFacts=guard.runFacts;
|
|
364
|
+
const shared=sharedInstalledFile(root,testFacts);
|
|
365
|
+
if(shared)guard.issues.push(`installed file ${shared} has another hard link, likely into a package store; a change through it is not observed`);
|
|
361
366
|
fs.rmSync(testRecord,{recursive:true,force:true});
|
|
362
367
|
}
|
|
363
368
|
// The daemon reads the recording from owned state; control requests are small.
|
package/src/daemon.mjs
CHANGED
|
@@ -271,7 +271,7 @@ function probe(row,allowReuse=false,{launch=true}={}) {
|
|
|
271
271
|
'workspace-typecheck-contract-unavailable':'workspace TypeScript applicability probe could not establish inputs'
|
|
272
272
|
,'pnpm-execution-environment-unsupported':'pnpm or Node launcher environment override needs review'
|
|
273
273
|
,'pnpm-distribution-unobservable':'pnpm executable contents cannot be observed'
|
|
274
|
-
,'pnpm-version-mismatch':'pnpm executable differs from the
|
|
274
|
+
,'pnpm-version-mismatch':'pnpm executable differs from the installed version'
|
|
275
275
|
,'typescript-input-outside-pnpm-contract':'TypeScript reads outside the selected workspace, linked outputs, or installed inputs'
|
|
276
276
|
,'pnpm-input-coverage-unavailable':'pnpm installed-input coverage became unavailable; inspect check details'
|
|
277
277
|
,'pnpm-typecheck-contract-unavailable':'pnpm TypeScript input contract could not be established'
|
package/src/declared-plan.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import {createHash} from 'node:crypto';
|
|
|
6
6
|
import {permittedRoots,resolveDeclaredInstalled} from './installed-inputs.mjs';
|
|
7
7
|
import {qualifyTypeScript} from './typescript-qualification.mjs';
|
|
8
8
|
import {directBuildInfo} from './direct-typescript.mjs';
|
|
9
|
-
import {qualifyTests,testsQualification} from './test-direct.mjs';
|
|
9
|
+
import {qualifyTests,testWorkspacePatterns,testsQualification} from './test-direct.mjs';
|
|
10
10
|
import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
|
|
11
11
|
import {pnpmTypecheckInputs} from './pnpm-inputs.mjs';
|
|
12
12
|
import {observedRelative,withinPath,realObservedPath} from './path-identity.mjs';
|
|
@@ -92,6 +92,13 @@ export function discoverDeclared(config) {
|
|
|
92
92
|
...(workspaceIssue?[workspaceIssue]:[])];
|
|
93
93
|
for(const item of workspace?.configurationInputs||[])configurationInputs.add(item);
|
|
94
94
|
for(const item of workspace?.workspacePatterns||[])workspacePatterns.add(item);
|
|
95
|
+
// A workspace test check is qualified against the current package list: a
|
|
96
|
+
// package manifest appearing or pnpm's package list changing re-plans.
|
|
97
|
+
if(selected.qualification===testsQualification&&selected.workspace)try{
|
|
98
|
+
for(const item of testWorkspacePatterns(root,JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'))))
|
|
99
|
+
workspacePatterns.add(item);
|
|
100
|
+
if(fs.existsSync(path.join(root,'pnpm-workspace.yaml')))configurationInputs.add('pnpm-workspace.yaml');
|
|
101
|
+
}catch{}
|
|
95
102
|
for(const item of workspace?.closure||[])workspaceManifests.add(item.manifest);
|
|
96
103
|
if(!automatic)for(const category of ['source','generated','installedDependencies',
|
|
97
104
|
'environment','toolchain','runtime'])if(!selected.coverage?.[category])
|
package/src/index.mjs
CHANGED
|
@@ -42,24 +42,33 @@ const pathPrefixes = rel => {
|
|
|
42
42
|
// tick at the same size; such files are tracked in `racy`, and the daemon waits
|
|
43
43
|
// the window out and records them again before it writes a run checkpoint.
|
|
44
44
|
const identity=st=>identityFields.map(key=>String(st[key])).join(':');
|
|
45
|
+
// An installed link to a directory (pnpm links every package this way) is
|
|
46
|
+
// observed by its link chain alone, when `observedDir` says the directory it
|
|
47
|
+
// resolves to is itself observed installed: its own entries carry its contents.
|
|
45
48
|
function fileHash(root, rel,installed=false,before=fs.lstatSync(path.join(root,rel),
|
|
46
|
-
{bigint:true})) {
|
|
49
|
+
{bigint:true}),observedDir=null) {
|
|
47
50
|
const file=path.join(root, rel);
|
|
48
51
|
let value,references=[],bytes=installed?0:Number(before.size),racy=0;
|
|
49
52
|
if(before.isSymbolicLink()){
|
|
50
53
|
const resolution=resolveLinks(file,root,[],true);
|
|
51
54
|
if(!resolution.physical)throw Error(`symlink input target observation unavailable: ${rel}: ${resolution.reason}`);
|
|
52
55
|
const target=fs.statSync(resolution.physical,{bigint:true});
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
const physical=path.relative(root,resolution.physical).split(path.sep).join('/');
|
|
57
|
+
const directory=target.isDirectory()&&installed&&observedDir?.(physical);
|
|
58
|
+
if(!target.isFile()&&!directory)throw Error(`directory input symlink unsupported: ${rel}`);
|
|
59
|
+
if(directory)value='dirlink:'+JSON.stringify(resolution.links)+':'+physical;
|
|
60
|
+
else{
|
|
61
|
+
if(installed)racy=racyUntil(target);else bytes=Number(target.size);
|
|
62
|
+
value='link:'+JSON.stringify(resolution.links)+':'+(installed?'stat:'+identity(target):
|
|
63
|
+
target.mode+':'+hashRegular(resolution.physical).hash);
|
|
64
|
+
}
|
|
57
65
|
references=[...new Set([...resolution.links.map(link=>path.relative(root,link.path)
|
|
58
66
|
.split(path.sep).join('/')),path.relative(root,resolution.physical)
|
|
59
67
|
.split(path.sep).join('/')])];
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
68
|
+
// A directory's own timestamps follow its entries, which are observed separately.
|
|
69
|
+
if(!directory){const targetAfter=fs.statSync(resolution.physical,{bigint:true});
|
|
70
|
+
for(const key of identityFields)
|
|
71
|
+
if(target[key]!==targetAfter[key])throw Error(`input changed during inspection: ${rel}`);}
|
|
63
72
|
const resolutionAfter=resolveLinks(file,root,[],true);
|
|
64
73
|
if(resolutionAfter.physical!==resolution.physical||
|
|
65
74
|
JSON.stringify(resolutionAfter.links)!==JSON.stringify(resolution.links))
|
|
@@ -181,7 +190,14 @@ export class InputIndex {
|
|
|
181
190
|
catch(e){if(e.code!=='ENOENT'){this.unavailable=String(e);return false;}}
|
|
182
191
|
if(stat&&(stat.isFile()||stat.isSymbolicLink())) {
|
|
183
192
|
const installed=this.installedPath(rel);
|
|
184
|
-
try{const started=performance.now();
|
|
193
|
+
try{const started=performance.now();
|
|
194
|
+
// A linked directory counts as observed only strictly inside an
|
|
195
|
+
// installed root, and only if every check matching the link also
|
|
196
|
+
// observes the directory's own files (its package.json stands for them).
|
|
197
|
+
const observedDir=installed?physical=>!this.includeInstalled&&
|
|
198
|
+
[...this.installedRootSet].some(root=>physical.startsWith(root+'/'))&&
|
|
199
|
+
matched.every(name=>this.matches(this.checks.get(name),physical+'/package.json')):null;
|
|
200
|
+
inspection=fileHash(this.root,rel,installed,stat,observedDir);
|
|
185
201
|
fresh=inspection.hash;
|
|
186
202
|
this.profile.hashingMs+=performance.now()-started;
|
|
187
203
|
this.rehashedFiles++;this.rehashedBytes+=inspection.bytes;}
|
|
@@ -302,9 +318,10 @@ export class InputIndex {
|
|
|
302
318
|
const full=path.join(this.root,rel);
|
|
303
319
|
let subtree=false;
|
|
304
320
|
try {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
if(fs.
|
|
321
|
+
// A link is an entry of its own: record() accepts an observed installed
|
|
322
|
+
// directory link and refuses any other.
|
|
323
|
+
if(fs.lstatSync(full).isSymbolicLink())this.record(rel);
|
|
324
|
+
else if(fs.statSync(full).isDirectory()) {this.scanSubtree(rel);subtree=true;}
|
|
308
325
|
else this.record(rel);
|
|
309
326
|
} catch(e) {
|
|
310
327
|
if(e.code==='ENOENT') {
|
package/src/installed-inputs.mjs
CHANGED
|
@@ -84,6 +84,10 @@ export function resolveDeclaredInstalled(root,patterns,allowed=[]) {
|
|
|
84
84
|
if(!resolved.physical){
|
|
85
85
|
if(resolved.reason==='resolved target missing'&&!resolved.links.length){
|
|
86
86
|
internal.add(pattern);continue;}
|
|
87
|
+
// An exclusion of a path that does not exist yet, through links (a runner
|
|
88
|
+
// cache inside a linked package): exclude it where the links lead.
|
|
89
|
+
if(negative&&resolved.reason==='resolved target missing'&&resolved.prospective&&within(resolved.prospective,root)){
|
|
90
|
+
internal.add('!'+posix(path.relative(root,resolved.prospective))+(suffix?'/'+suffix:''));continue;}
|
|
87
91
|
result.unresolved.push(`${prefix}: ${resolved.reason}`);continue;
|
|
88
92
|
}
|
|
89
93
|
const boundary=within(resolved.physical,root)?root:
|
package/src/node-onboarding.mjs
CHANGED
|
@@ -3,11 +3,11 @@ import {testProject,testRecipeScript,testCheckInputs,testsQualification} from '.
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import {spawnSync} from 'node:child_process';
|
|
6
|
-
import micromatch from './glob.mjs';
|
|
7
6
|
import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
|
|
8
7
|
import {pnpmInstallInfo,pnpmProjectInfo,pnpmTypecheckInputs} from './pnpm-inputs.mjs';
|
|
9
8
|
import {realObservedPath} from './path-identity.mjs';
|
|
10
9
|
import {findExecutable} from './executable-lookup.mjs';
|
|
10
|
+
import {workspaceManifests} from './workspace-manifests.mjs';
|
|
11
11
|
|
|
12
12
|
const exists=file=>{try{fs.accessSync(file);return true;}catch{return false;}};
|
|
13
13
|
const readJson=file=>JSON.parse(fs.readFileSync(file,'utf8'));
|
|
@@ -147,19 +147,14 @@ function selectScript(kind,names,scripts) {
|
|
|
147
147
|
return candidates.length===1?{script:candidates[0]}:
|
|
148
148
|
candidates.length>1?{ambiguous:candidates}:{};
|
|
149
149
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if(
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
return dir!=='.'&&!dir.split('/').includes('node_modules')&&
|
|
159
|
-
micromatch.isMatch(dir,patterns,{dot:true});
|
|
160
|
-
}).sort();
|
|
161
|
-
if(matched.length>50)throw Error('more than 50 workspace packages found; select checks explicitly');
|
|
162
|
-
return matched.map(file=>({file,pkg:readJson(path.join(root,file))}));
|
|
150
|
+
// Workspace packages as init finds them, with their scripts; [] without workspaces.
|
|
151
|
+
export function workspacePackages(root){
|
|
152
|
+
root=realObservedPath(root);
|
|
153
|
+
const pkg=readJson(path.join(root,'package.json')),pm=manager(root,pkg);
|
|
154
|
+
const patterns=pm.name==='pnpm'?pm.workspacePatterns:Array.isArray(pkg.workspaces)?pkg.workspaces:pkg.workspaces?.packages;
|
|
155
|
+
if(!Array.isArray(patterns)||!patterns.length)return [];
|
|
156
|
+
return workspaceManifests(root,patterns).map(({file,pkg:member})=>
|
|
157
|
+
({name:member.name,dir:path.posix.dirname(file),scripts:member.scripts&&typeof member.scripts==='object'?member.scripts:{}}));
|
|
163
158
|
}
|
|
164
159
|
export function discoverNodeProject(root,{recipe=null}={}) {
|
|
165
160
|
if(recipe&&!['typescript-direct','tests-direct'].includes(recipe))throw Error('unknown recipe; use typescript-direct or tests-direct');
|
|
@@ -200,7 +195,8 @@ export function discoverNodeProject(root,{recipe=null}={}) {
|
|
|
200
195
|
}
|
|
201
196
|
if(recipe==='tests-direct'&&kind==='test'){
|
|
202
197
|
try{
|
|
203
|
-
if(pm.name
|
|
198
|
+
if(!['npm','pnpm','yarn'].includes(pm.name)||workspaces||observationIssue||pm.issue)
|
|
199
|
+
throw Error('test recipe requires a supported single-package npm, pnpm or Yarn checkout');
|
|
204
200
|
const chosen=testRecipeScript(scripts);
|
|
205
201
|
if(chosen.ambiguous)throw Error(`several scripts run Vitest or Jest directly (${chosen.ambiguous.join(', ')})`);
|
|
206
202
|
if(!chosen.script)throw Error(chosen.issue||'no script runs Vitest or Jest directly');
|
|
@@ -221,7 +217,7 @@ export function discoverNodeProject(root,{recipe=null}={}) {
|
|
|
221
217
|
check.inputs=['package.json','pnpm-lock.yaml',
|
|
222
218
|
...(exists(path.join(root,'pnpm-workspace.yaml'))?['pnpm-workspace.yaml']:[]),
|
|
223
219
|
'tsconfig.json','src/**'];
|
|
224
|
-
classification={level:'ready',reason:'
|
|
220
|
+
classification={level:'ready',reason:'pnpm TypeScript inputs and installation; checked again before reuse',
|
|
225
221
|
qualification:'pnpm-tsc-v1'};}
|
|
226
222
|
catch(e){classification={level:'recording',reason:`pnpm TypeScript contract needs review: ${e.message}`};}
|
|
227
223
|
}
|
|
@@ -275,9 +271,17 @@ export function discoverNodeProject(root,{recipe=null}={}) {
|
|
|
275
271
|
...member.scripts[selected.script].trim().split(/\s+/).slice(1)];
|
|
276
272
|
check.inputs=['package.json','pnpm-lock.yaml','pnpm-workspace.yaml',file,
|
|
277
273
|
`${dir}/src/**`];
|
|
278
|
-
level='ready';reason='
|
|
274
|
+
level='ready';reason='pnpm workspace TypeScript inputs; checked again before reuse';
|
|
279
275
|
}catch(e){reason=`pnpm TypeScript contract needs review: ${e.message}`;}
|
|
280
276
|
}
|
|
277
|
+
if(recipe==='tests-direct'&&kind==='test'&&!pm.issue){
|
|
278
|
+
try{const project=testProject(root,selected.script,dir);
|
|
279
|
+
Object.assign(check,{workspace:name,script:selected.script,cwd:dir,
|
|
280
|
+
command:['@node',`@test-runner:${project.runner}`,...project.args],...testCheckInputs(project),
|
|
281
|
+
qualification:testsQualification});
|
|
282
|
+
level='ready';reason=`explicit local ${project.runner} ${project.version} run of ${name}'s script ${JSON.stringify(selected.script)}; the whole checkout is its input; the package manager and lifecycle scripts are not executed`;
|
|
283
|
+
}catch(e){reason=`Test recipe unavailable (${e.code||e.message}); original script retained`;}
|
|
284
|
+
}
|
|
281
285
|
checks.push({config:check,level,reason});
|
|
282
286
|
}
|
|
283
287
|
}
|