@redue/cli 0.1.0-alpha.22 → 0.1.0-alpha.24
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 +116 -200
- package/bin/redue.mjs +34 -2
- package/docs/user/agents.md +31 -4
- package/docs/user/direct-typescript.md +3 -1
- package/docs/user/getting-started.md +20 -1
- package/docs/user/node-script.md +4 -1
- package/docs/user/supported-projects.md +5 -1
- package/docs/user/tests-direct.md +6 -2
- package/package.json +1 -1
- package/src/agent-integration.mjs +100 -21
- package/src/claude-hook.mjs +113 -12
- package/src/cli.mjs +8 -1
- package/src/installed-observation.mjs +23 -0
- package/src/node-script.mjs +20 -2
- package/src/pnpm-inputs.mjs +4 -2
- package/src/setup.mjs +178 -0
- package/src/test-direct.mjs +19 -3
- package/src/typescript-list.mjs +11 -1
- package/src/yarn-workspace-inputs.mjs +3 -0
|
@@ -90,8 +90,12 @@ kept but marked UNVERIFIED, with the reason, when the run:
|
|
|
90
90
|
`--expose-internals`: the recorder cannot follow those;
|
|
91
91
|
- had an installed file with another hard link, other than a link into the
|
|
92
92
|
pnpm store the install recorded in `node_modules/.modules.yaml` (pnpm's
|
|
93
|
-
default on Linux)
|
|
94
|
-
|
|
93
|
+
default on Linux) or, without a pnpm store, links that are all installed
|
|
94
|
+
files the check observes (esbuild's install links its binary into its
|
|
95
|
+
platform package). The link count of each such file is checked again
|
|
96
|
+
before every answer, so a link added later is noticed. A change through a
|
|
97
|
+
link elsewhere, such as Yarn's hardlinks modes, is not seen. This applies
|
|
98
|
+
to the [general recipe](node-script.md) too.
|
|
95
99
|
|
|
96
100
|
Lookups that can be asked again become run facts, checked before every CURRENT
|
|
97
101
|
answer: whether a path outside the checkout exists, where a link resolves, the
|
package/package.json
CHANGED
|
@@ -11,7 +11,7 @@ import {readProject,projectState,canonicalConfig} from './project-location.mjs';
|
|
|
11
11
|
import {readCachedStatus} from './cached-state.mjs';
|
|
12
12
|
import {samePath,withinPath} from './path-identity.mjs';
|
|
13
13
|
import {processAlive} from './process-liveness.mjs';
|
|
14
|
-
import {fanoutRules,hookCommand,hookForms,redueFor} from './claude-hook.mjs';
|
|
14
|
+
import {fanoutRules,hookCommand,hookForms,redueFor,runsEntry,shellQuote as quote} from './claude-hook.mjs';
|
|
15
15
|
|
|
16
16
|
const policy=fs.readFileSync(new URL('./agent-policy.md',import.meta.url),'utf8');
|
|
17
17
|
const hosts={codex:{instruction:'AGENTS.md',skill:'.agents/skills/redue-verification/SKILL.md'},
|
|
@@ -25,17 +25,53 @@ const sameJson=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
|
|
|
25
25
|
// The hook waits for status up to 10 s; 15 s leaves room for starting Node.
|
|
26
26
|
// Fan-out commands (turbo, nx, `pnpm -r`) and root scripts that run one get
|
|
27
27
|
// prefix rules; the hook decides from the full command.
|
|
28
|
-
function hookFor(config,relative,scripts){
|
|
28
|
+
function hookFor(config,relative,scripts,command=hookCommand){
|
|
29
29
|
const rules=[...new Set([...hookForms(config,relative,scripts).flatMap(row=>[...row.forms,...row.local]),
|
|
30
30
|
...fanoutRules(config,scripts)])].map(form=>`Bash(${form}*)`);
|
|
31
|
-
return rules.length?{matcher:'Bash',hooks:rules.map(rule=>({type:'command',if:rule,command
|
|
31
|
+
return rules.length?{matcher:'Bash',hooks:rules.map(rule=>({type:'command',if:rule,command,timeout:15}))}:null;
|
|
32
32
|
}
|
|
33
33
|
// The same command on SessionStart starts the observer; it needs no check to match.
|
|
34
|
-
const
|
|
34
|
+
const sessionHookFor=(command=hookCommand)=>({hooks:[{type:'command',command,timeout:10}]});
|
|
35
35
|
const events=['PreToolUse','SessionStart'];
|
|
36
|
+
// The absolute form: Node and REDUE's entry (redue.mjs), each quoted as quote()
|
|
37
|
+
// writes it. [node, entry] or null.
|
|
38
|
+
function absoluteHook(command){
|
|
39
|
+
const word="'((?:[^']|'\\\\'')*)'";
|
|
40
|
+
const match=new RegExp(`^${word} ${word} agent hook claude$`).exec(command??'');
|
|
41
|
+
if(!match)return null;
|
|
42
|
+
const [node,entry]=[match[1],match[2]].map(value=>value.replaceAll("'\\''","'"));
|
|
43
|
+
// REDUE's entry file, or the bin link npm installs for it.
|
|
44
|
+
return /^redue(\.mjs)?$/.test(path.basename(entry))?[node,entry]:null;
|
|
45
|
+
}
|
|
46
|
+
export const isHookCommand=command=>command===hookCommand||Boolean(absoluteHook(command));
|
|
47
|
+
// The command Claude Code runs for the hook. Plain `redue` when this install
|
|
48
|
+
// is the one both this shell and a login shell find on PATH. Otherwise this
|
|
49
|
+
// install's Node and entry by absolute path, so a Claude Code started from
|
|
50
|
+
// another shell, an IDE or with a shorter PATH still runs it, and an older
|
|
51
|
+
// install on PATH does not answer for this one. The settings file is
|
|
52
|
+
// per-developer, so a machine path is fine there.
|
|
53
|
+
export function hookInvocation(entry,{env=process.env,platform=process.platform}={}){
|
|
54
|
+
if(platform==='win32')return {command:hookCommand,reason:'Windows: plain redue on PATH'};
|
|
55
|
+
let target;try{target=fs.realpathSync(entry);}catch{target=entry;}
|
|
56
|
+
// Paths that survive upgrades where they can: the entry as invoked (a
|
|
57
|
+
// global bin link, not the versioned file behind it) and the node on PATH
|
|
58
|
+
// (Homebrew's link, not its versioned cellar).
|
|
59
|
+
const real=file=>{try{return fs.realpathSync(file);}catch{return null;}};
|
|
60
|
+
const invoked=typeof process.argv[1]==='string'&&path.isAbsolute(process.argv[1])&&real(process.argv[1])===target?process.argv[1]:target;
|
|
61
|
+
const execReal=real(process.execPath);
|
|
62
|
+
const node=(env.PATH||'').split(path.delimiter).map(dir=>path.join(dir||'.','node')).find(file=>real(file)===execReal)??process.execPath;
|
|
63
|
+
const same=file=>runsEntry(file,target,env);
|
|
64
|
+
const here=findExecutable('redue',{env});
|
|
65
|
+
const login=spawnSync(env.SHELL||'/bin/sh',['-lc','command -v redue'],{env,encoding:'utf8',timeout:5000,stdio:['ignore','pipe','ignore']});
|
|
66
|
+
const loginPath=login.status===0?login.stdout.trim().split('\n').pop():null;
|
|
67
|
+
if(same(here)&&same(loginPath))return {command:hookCommand,reason:`redue on PATH is this install (${here})`};
|
|
68
|
+
return {command:`${quote(node)} ${quote(invoked)} agent hook claude`,
|
|
69
|
+
reason:!here&&!loginPath?'redue is not on PATH; the hook names this install by its absolute path':
|
|
70
|
+
`redue on PATH is ${here||loginPath||'missing'} in ${same(here)?'a login shell':'this shell'}, not this install; the hook names this install by its absolute path`};
|
|
71
|
+
}
|
|
36
72
|
// REDUE's entry is recognized by its command, not by a committed record: the
|
|
37
73
|
// local file differs between checkouts, and users may edit the entry.
|
|
38
|
-
const ownedHook=entry=>Array.isArray(entry?.hooks)&&entry.hooks.length>0&&entry.hooks.every(h=>h?.command
|
|
74
|
+
const ownedHook=entry=>Array.isArray(entry?.hooks)&&entry.hooks.length>0&&entry.hooks.every(h=>isHookCommand(h?.command));
|
|
39
75
|
function parseSettings(text){
|
|
40
76
|
if(text===null)return {};
|
|
41
77
|
let value;try{value=JSON.parse(text);}catch{throw Error(`${settingsPath} is not valid JSON; fix it before agent setup or removal`);}
|
|
@@ -65,6 +101,45 @@ function hookInstalled(root,event='PreToolUse'){
|
|
|
65
101
|
try{return (parseSettings(read(root,settingsPath)).hooks?.[event]||[]).some(ownedHook);}
|
|
66
102
|
catch{return false;}
|
|
67
103
|
}
|
|
104
|
+
// What removing a host's integration changes, from its setup record: the
|
|
105
|
+
// managed block, the skill, the hooks and the record.
|
|
106
|
+
function removalChanges(root,host,record){
|
|
107
|
+
const h=hosts[host],before=read(root,h.instruction),after=before===null?null:before.replace(record.block,'');
|
|
108
|
+
const changes=[{path:h.instruction,before,after:record.instructionCreated&&after===''?null:after},
|
|
109
|
+
{path:h.skill,before:read(root,h.skill),after:null}];
|
|
110
|
+
if(host==='claude'){const settings=read(root,settingsPath);
|
|
111
|
+
changes.push({path:settingsPath,before:settings,after:settingsWith(settings,null)});}
|
|
112
|
+
changes.push({path:marker(host),before:read(root,marker(host)),after:null});
|
|
113
|
+
return changes;
|
|
114
|
+
}
|
|
115
|
+
// Removes a host's integration without needing the project config (redue
|
|
116
|
+
// remove after the config is gone): the changed paths, each with its operation.
|
|
117
|
+
export function removeIntegration(root,host='claude'){
|
|
118
|
+
const record=saved(root,host);if(!record)return [];
|
|
119
|
+
const health=inspect(root,host,record);if(health.integrity==='conflict')throw Error(health.reason);
|
|
120
|
+
const changes=removalChanges(root,host,record).filter(c=>c.before!==c.after);
|
|
121
|
+
apply(root,changes);
|
|
122
|
+
return changes.map(c=>`${c.after===null?'remove':'update'} ${c.path}`);
|
|
123
|
+
}
|
|
124
|
+
// Whether REDUE's Claude Code hooks are in the local settings.
|
|
125
|
+
export const claudeHooksInstalled=root=>hookInstalled(root,'SessionStart')||hookInstalled(root);
|
|
126
|
+
// Removes REDUE's Claude Code hooks from the local settings and keeps the
|
|
127
|
+
// rest; the file's path when it changed, else null.
|
|
128
|
+
export function removeClaudeHooks(root){
|
|
129
|
+
const before=read(root,settingsPath);if(before===null)return null;
|
|
130
|
+
const after=settingsWith(before,null);if(after===before)return null;
|
|
131
|
+
apply(root,[{path:settingsPath,before,after}]);return settingsPath;
|
|
132
|
+
}
|
|
133
|
+
// Whether the installed hook's command can start: an absolute one by its
|
|
134
|
+
// files, plain redue by PATH.
|
|
135
|
+
function hookRunnable(root){
|
|
136
|
+
let command=null;
|
|
137
|
+
try{command=(parseSettings(read(root,settingsPath)).hooks?.SessionStart||[]).find(ownedHook)?.hooks[0]?.command??null;}catch{}
|
|
138
|
+
const absolute=absoluteHook(command);
|
|
139
|
+
if(absolute)return fs.existsSync(absolute[0])&&fs.existsSync(absolute[1])?'installed (absolute path)':
|
|
140
|
+
'installed, but its Node or REDUE path no longer exists; run redue agent setup claude again';
|
|
141
|
+
return findExecutable('redue')?'installed':'installed, but redue is not on PATH, so it never runs';
|
|
142
|
+
}
|
|
68
143
|
function safeFile(root,relative){
|
|
69
144
|
if(path.isAbsolute(relative)||relative.split(/[\\/]/).some(p=>p==='..'||p===''))throw Error('unsafe integration path');
|
|
70
145
|
const file=path.resolve(root,relative);
|
|
@@ -108,23 +183,28 @@ const commandsOf=rows=>[rows.filter(row=>row.kind!=='test'&&row.kind!=='lint').m
|
|
|
108
183
|
rows.filter(row=>row.kind==='test').map(row=>row.form),rows.filter(row=>row.kind==='lint').map(row=>row.form)];
|
|
109
184
|
const fence=(host,body)=>`\n\n<!-- REDUE:${host}:begin -->\n${body}<!-- REDUE:${host}:end -->\n`;
|
|
110
185
|
const skillPointer=host=>`Before you run, skip or report a typecheck or other configured check, read\n${hosts[host].skill}.\n`;
|
|
111
|
-
|
|
186
|
+
// `own`: the line asking for each check as a command of its own, added in
|
|
187
|
+
// alpha.24; the alpha.15-alpha.23 wording without it is still recognized.
|
|
188
|
+
function blockFor(host,rows=[],own=true){
|
|
112
189
|
const [typechecks,tests,lints]=commandsOf(rows);
|
|
113
190
|
if(!rows.length)return fence(host,skillPointer(host));
|
|
191
|
+
const ownLine=own?`Run each check as a command of its own, not chained after an edit or another\n`+
|
|
192
|
+
`command.\n`:'';
|
|
114
193
|
if(lints.length)return fence(host,(typechecks.length?`Typecheck with ${quoted(typechecks)}.\n`:'')+
|
|
115
194
|
(tests.length?`Run the unit tests with ${quoted(tests)}.\n`:'')+`Lint with ${quoted(lints)}.\n`+
|
|
116
195
|
`Run a check before you say it passes, even after an edit you think cannot\n`+
|
|
117
196
|
`matter; don't skip it yourself. If its last pass still applies to these exact\n`+
|
|
118
|
-
`files, a REDUE hook skips the run and says so. That skip counts as a pass.\n
|
|
197
|
+
`files, a REDUE hook skips the run and says so. That skip counts as a pass.\n`+ownLine);
|
|
119
198
|
if(!tests.length)return fence(host,`Typecheck with ${quoted(typechecks)}. Run it before you say it passes, even after an\n`+
|
|
120
199
|
`edit you think cannot matter; don't skip it yourself. If its last pass still\n`+
|
|
121
200
|
`applies to these exact files, a REDUE hook skips the run and says so. That\n`+
|
|
122
|
-
|
|
201
|
+
(own?`skip counts as a pass. Run it as a command of its own, not chained after an\n`+
|
|
202
|
+
`edit or another command.\n`:`skip counts as a pass.\n`));
|
|
123
203
|
return fence(host,(typechecks.length?`Typecheck with ${quoted(typechecks)}.\n`:'')+
|
|
124
204
|
`Run the unit tests with ${quoted(tests)}.\n`+
|
|
125
205
|
`Run a check before you say it passes, even after an edit you think cannot\n`+
|
|
126
206
|
`matter; don't skip it yourself. If its last pass still applies to these exact\n`+
|
|
127
|
-
`files, a REDUE hook skips the run and says so. That skip counts as a pass.\n
|
|
207
|
+
`files, a REDUE hook skips the run and says so. That skip counts as a pass.\n`+ownLine);
|
|
128
208
|
}
|
|
129
209
|
// The alpha.12-alpha.14 wording, recognized so setup can replace it.
|
|
130
210
|
function earlierBlock(host,rows=[]){
|
|
@@ -165,7 +245,8 @@ function knownBlock(host,relative,block){
|
|
|
165
245
|
const rows=[...forms(`^\\n\\n<!-- REDUE:[a-z]+:begin -->\\nTypecheck with ${list}\\.`).map(form=>({kind:'typecheck',form})),
|
|
166
246
|
...forms(`Run the unit tests with ${list}\\.\\n`).map(form=>({kind:'test',form})),
|
|
167
247
|
...forms(`Lint with ${list}\\.\\n`).map(form=>({kind:'lint',form}))];
|
|
168
|
-
return block===blockFor(host,rows)||block===
|
|
248
|
+
return block===blockFor(host,rows)||block===blockFor(host,rows,false)||block===earlierBlock(host,rows)||
|
|
249
|
+
priorBlocks(host,relative).includes(block);
|
|
169
250
|
}
|
|
170
251
|
// The policy with the exact commands for this config, so an agent never builds
|
|
171
252
|
// a --config form the hook would not recognize. Claude Code gets the typecheck
|
|
@@ -273,7 +354,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
273
354
|
const {root,relative,config}=project(configFile),h=hosts[host],record=saved(root,host),health=inspect(root,host,record);
|
|
274
355
|
if(health.integrity==='conflict')throw Error(health.reason);
|
|
275
356
|
const changes=[],notes=action==='setup'?warnings(root,host,cwd):[];
|
|
276
|
-
let hook=null,block=null;
|
|
357
|
+
let hook=null,sessionHook=null,block=null;
|
|
277
358
|
if(action==='setup'){
|
|
278
359
|
const before=read(root,h.instruction),skill=skillFor(relative,host,config);
|
|
279
360
|
block=blockFor(host,blockForms(host,config,relative));
|
|
@@ -281,7 +362,12 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
281
362
|
throw Error('unowned REDUE instruction or skill already exists; inspect it before setup');
|
|
282
363
|
const after=record?before.replace(record.block,block):(before||'')+block;
|
|
283
364
|
let scripts={};try{scripts=JSON.parse(read(root,'package.json')??'{}').scripts||{};}catch{}
|
|
284
|
-
|
|
365
|
+
const invocation=host==='claude'?hookInvocation(entry):null;
|
|
366
|
+
if(invocation)notes.push(`Hook command: ${invocation.command} (${invocation.reason})`);
|
|
367
|
+
if(invocation&&invocation.command!==hookCommand)
|
|
368
|
+
notes.push('The agent instructions say `redue ...`: put this install on PATH (npm install --global @redue/cli@alpha) so the agent\'s shell can run them. The hook runs the checks it rewrites through this install either way.');
|
|
369
|
+
hook=host==='claude'?hookFor(config,relative,scripts,invocation.command):null;
|
|
370
|
+
sessionHook=host==='claude'?sessionHookFor(invocation.command):null;
|
|
285
371
|
const next={schema:1,host,config:relative,instruction:h.instruction,skill:h.skill,
|
|
286
372
|
block,skillHash:hash(skill),instructionCreated:record?.instructionCreated??before===null};
|
|
287
373
|
changes.push({path:h.skill,before:read(root,h.skill),after:skill},
|
|
@@ -291,14 +377,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
291
377
|
content:JSON.stringify(hook?next:{SessionStart:sessionHook},null,2)+
|
|
292
378
|
(hook?'':'\nno PreToolUse hook: no typecheck or test check to match')});}
|
|
293
379
|
changes.push({path:marker(host),before:read(root,marker(host)),after:JSON.stringify(next,null,2)+'\n'});
|
|
294
|
-
}else if(record)
|
|
295
|
-
const before=read(root,h.instruction),after=before.replace(record.block,'');
|
|
296
|
-
changes.push({path:h.instruction,before,after:record.instructionCreated&&after===''?null:after},
|
|
297
|
-
{path:h.skill,before:read(root,h.skill),after:null});
|
|
298
|
-
if(host==='claude'){const settings=read(root,settingsPath);
|
|
299
|
-
changes.push({path:settingsPath,before:settings,after:settingsWith(settings,null)});}
|
|
300
|
-
changes.push({path:marker(host),before:read(root,marker(host)),after:null});
|
|
301
|
-
}
|
|
380
|
+
}else if(record)changes.push(...removalChanges(root,host,record));
|
|
302
381
|
const actual=changes.filter(c=>c.before!==c.after);
|
|
303
382
|
const summary={schema:1,action,host,config:configFile,applied:false,
|
|
304
383
|
changes:actual.map(c=>({path:c.path,operation:c.after===null?'remove':c.before===null?'create':'update',
|
|
@@ -355,7 +434,7 @@ export async function doctor({configFile,stateOverride,entry,cwd,host}){
|
|
|
355
434
|
const out=spawnSync(launch.file,launch.args,{...launch.options,encoding:'utf8',timeout:2000,maxBuffer:4096,stdio:['ignore','pipe','pipe']});
|
|
356
435
|
if(out.status===0)version=out.stdout.trim();}
|
|
357
436
|
let integration={installed:false,integrity:'unknown'},hook=name==='claude'?'absent':'none',sessionHook=hook;
|
|
358
|
-
const installed=()=>
|
|
437
|
+
const installed=()=>hookRunnable(p.root);
|
|
359
438
|
if(p)try{const record=saved(p.root,name);integration=inspect(p.root,name,record);
|
|
360
439
|
if(name==='claude'&&hookInstalled(p.root))hook=installed();
|
|
361
440
|
if(name==='claude'&&hookInstalled(p.root,'SessionStart'))sessionHook=installed();
|
package/src/claude-hook.mjs
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
// Never exit 2: Claude Code treats exit 2 as a block.
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import {spawn} from 'node:child_process';
|
|
9
|
+
import {spawn,spawnSync} from 'node:child_process';
|
|
10
10
|
import {projectState} from './project-location.mjs';
|
|
11
11
|
import {realObservedPath} from './path-identity.mjs';
|
|
12
12
|
import {processAlive} from './process-liveness.mjs';
|
|
13
13
|
import {scriptRunChecks} from './typescript-invocation.mjs';
|
|
14
14
|
import {recipeLabel} from './presentation.mjs';
|
|
15
|
+
import {findExecutable} from './executable-lookup.mjs';
|
|
15
16
|
|
|
16
17
|
export const hookCommand='redue agent hook claude';
|
|
17
18
|
// No shell metacharacters; `@` starts scoped package names.
|
|
@@ -31,6 +32,22 @@ export const defaultConfig='redue.config.json';
|
|
|
31
32
|
// would need shell quoting.
|
|
32
33
|
export const redueFor=(configFile=defaultConfig)=>configFile===defaultConfig?'redue':
|
|
33
34
|
safe.test(configFile)?`redue --config ${configFile}`:null;
|
|
35
|
+
export const shellQuote=value=>`'${String(value).replaceAll("'","'\\''")}'`;
|
|
36
|
+
// How a rewritten command starts REDUE: plain `redue` when the hook's PATH
|
|
37
|
+
// finds this install, else this install by absolute path. An agent shell
|
|
38
|
+
// without redue on PATH cannot run the plain form.
|
|
39
|
+
export function redueInvocation(entry,{env=process.env,platform=process.platform}={}){
|
|
40
|
+
if(platform==='win32'||runsEntry(findExecutable('redue',{env}),entry,env))return 'redue';
|
|
41
|
+
return `${shellQuote(process.execPath)} ${shellQuote(entry)}`;
|
|
42
|
+
}
|
|
43
|
+
// Whether the command at `file` runs this install's entry: the same file, or
|
|
44
|
+
// a shim or wrapper that reports it (redue --entry).
|
|
45
|
+
export function runsEntry(file,entry,env=process.env){
|
|
46
|
+
if(!file)return false;
|
|
47
|
+
try{const target=fs.realpathSync(entry);if(fs.realpathSync(file)===target)return true;
|
|
48
|
+
const out=spawnSync(file,['--entry'],{env,encoding:'utf8',timeout:5000,stdio:['ignore','pipe','ignore']});
|
|
49
|
+
return out.status===0&&fs.realpathSync(out.stdout.trim())===target;}catch{return false;}
|
|
50
|
+
}
|
|
34
51
|
|
|
35
52
|
// Package-manager commands that a script of the same name would not reach.
|
|
36
53
|
const builtins={pnpm:new Set(['add','audit','bin','config','create','dedupe','deploy','dlx','doctor','env','exec','fetch',
|
|
@@ -259,15 +276,83 @@ function matched(command,config,configFile,dir='.',scripts={}){
|
|
|
259
276
|
// A script form of compiler checks is checked against the script first.
|
|
260
277
|
return row?.scriptForms?.includes(parts.base)?{row,rest:parts.rest}:row?{check:row.check,rest:parts.rest}:null;
|
|
261
278
|
}
|
|
279
|
+
// A simple chain: commands joined by &&, || or ;, outside quotes, with no
|
|
280
|
+
// line break, subshell, command or process substitution, backtick,
|
|
281
|
+
// here-document or background job. [{separator, text}] with the separator
|
|
282
|
+
// before each part ('' for the first), or null.
|
|
283
|
+
export function chainParts(command){
|
|
284
|
+
if(typeof command!=='string'||/[\r\n`]|\$\(|[<>]\(|<<|[()]/.test(command))return null;
|
|
285
|
+
const parts=[];let quote=null,start=0,separator='';
|
|
286
|
+
for(let i=0;i<command.length;i++){
|
|
287
|
+
const c=command[i];
|
|
288
|
+
if(quote){if(c===quote)quote=null;else if(c==='\\'&"e==='"')i++;continue;}
|
|
289
|
+
if(c==='\\'){i++;continue;}
|
|
290
|
+
if(c==="'"||c==='"'){quote=c;continue;}
|
|
291
|
+
const two=command.slice(i,i+2);
|
|
292
|
+
if(two==='&&'||two==='||'||c===';'){
|
|
293
|
+
parts.push({separator,text:command.slice(start,i)});separator=c===';'?';':two;
|
|
294
|
+
start=i+(c===';'?1:2);if(c!==';')i++;continue;}
|
|
295
|
+
// A lone & runs a job in the background; >&, &> and |& redirect.
|
|
296
|
+
if(c==='&'&&command[i-1]!=='>'&&command[i+1]!=='>')return null;
|
|
297
|
+
if(two==='|&')return null;
|
|
298
|
+
}
|
|
299
|
+
if(quote)return null;
|
|
300
|
+
parts.push({separator,text:command.slice(start)});
|
|
301
|
+
return parts.every(part=>part.text.trim())?parts:null;
|
|
302
|
+
}
|
|
303
|
+
// A simple chain with each check in it run through REDUE when its turn comes:
|
|
304
|
+
// `redue run CHECK --if-due` decides then, after earlier parts may have
|
|
305
|
+
// changed files. Null when the command is not such a chain or runs no check.
|
|
306
|
+
// The rest of the chain is unchanged, and so is its output and exit status
|
|
307
|
+
// when a check runs.
|
|
308
|
+
// Shell builtins that change the shell for the parts after them.
|
|
309
|
+
const shellBuiltins=new Set(['cd','pushd','popd','export','unset','set','source','.','eval','exec','alias','unalias','shopt',
|
|
310
|
+
'ulimit','umask','trap','builtin','command','declare','typeset','local','readonly','hash','enable','let','read','wait']);
|
|
311
|
+
// A part that cannot change the directory or environment of the parts after
|
|
312
|
+
// it: a program found on PATH (or by path), not a builtin, function or bare
|
|
313
|
+
// assignment.
|
|
314
|
+
function plainProgram(body){
|
|
315
|
+
const words=body.split(/\s+/).filter(Boolean);
|
|
316
|
+
while(words.length&&/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))words.shift();
|
|
317
|
+
const word=words[0];
|
|
318
|
+
return Boolean(word)&&!shellBuiltins.has(word)&&/^[A-Za-z0-9_./+@-]+$/.test(word)&&(word.includes('/')||Boolean(findExecutable(word)));
|
|
319
|
+
}
|
|
320
|
+
// A simple chain with each check in it run through REDUE when its turn comes:
|
|
321
|
+
// `redue run CHECK --if-due` decides then, after earlier parts may have
|
|
322
|
+
// changed files. {command, names}, or null when the command is not such a
|
|
323
|
+
// chain or runs no check. The rest of the chain is unchanged, and so are its
|
|
324
|
+
// output and exit status when a check runs. Every part before a check must be
|
|
325
|
+
// a plain program, so the check runs where and as it would have.
|
|
326
|
+
export function chainCommand(command,config,configFile,dir='.',scripts={},invoke='redue',runConfig=configFile,projectRoot=null){
|
|
327
|
+
const parts=chainParts(command);
|
|
328
|
+
if(!parts||parts.length<2)return null;
|
|
329
|
+
const redue=redueFor(runConfig)?.replace(/^redue/,()=>invoke);
|
|
330
|
+
if(!redue)return null;
|
|
331
|
+
const names=[];let plain=true;
|
|
332
|
+
const text=parts.map(({separator,text})=>{
|
|
333
|
+
const lead=text.match(/^\s*/)[0],trail=text.match(/\s*$/)[0],body=text.trim();
|
|
334
|
+
const match=matched(body,config,configFile,dir,scripts),split_=split(body);
|
|
335
|
+
// A compiler script is its checks in the order of its runs.
|
|
336
|
+
const checks=match?.check?[match.check]:match?.row&&projectRoot?scriptChecks(match.row,config,projectRoot):null;
|
|
337
|
+
if(!checks?.length||match.rest!==null||!split_||!checks.every(name=>safe.test(name))){
|
|
338
|
+
plain&&=plainProgram(body);return separator+text;}
|
|
339
|
+
if(!plain)return null;
|
|
340
|
+
names.push(...checks);
|
|
341
|
+
// Grouped, so a separator before it applies to all its runs.
|
|
342
|
+
const runs=checks.map(name=>`${redue} run ${name} --if-due`).join(' && ');
|
|
343
|
+
return `${separator}${lead}${checks.length>1?`{ ${runs}; }`:runs}${split_.suffix}${trail}`;
|
|
344
|
+
});
|
|
345
|
+
return names.length&&!text.includes(null)?{command:text.join(''),names}:null;
|
|
346
|
+
}
|
|
262
347
|
export function matchCheck(command,config,configFile,dir,scripts){
|
|
263
348
|
const match=matched(command,config,configFile,dir,scripts);
|
|
264
349
|
return match?.row?match.row.checks.join(' && '):match?.check||null;
|
|
265
350
|
}
|
|
266
351
|
// The same check (or checks, in order) through REDUE, keeping the output
|
|
267
352
|
// suffix; null if it already is, or if more commands follow it.
|
|
268
|
-
export function recordedCommand(command,name,configFile=defaultConfig){
|
|
353
|
+
export function recordedCommand(command,name,configFile=defaultConfig,invoke='redue'){
|
|
269
354
|
const names=Array.isArray(name)?name:[name];
|
|
270
|
-
const parts=split(command),redue=redueFor(configFile);
|
|
355
|
+
const parts=split(command),redue=redueFor(configFile)?.replace(/^redue/,()=>invoke);
|
|
271
356
|
if(!parts||parts.rest!==null||!names.length||!names.every(value=>safe.test(value))||!redue)return null;
|
|
272
357
|
const run=names.map(value=>`${redue} run ${value}`).join(' && ');
|
|
273
358
|
return parts.base===run?null:names.length>1&&parts.suffix?`(${run})${parts.suffix}`:run+parts.suffix;
|
|
@@ -310,7 +395,8 @@ export function verdict(status,name,rest=null){
|
|
|
310
395
|
(rest===null?'':` Nothing else in the command ran either; run the rest on its own: ${rest}`);
|
|
311
396
|
}
|
|
312
397
|
|
|
313
|
-
|
|
398
|
+
// The status answer, synchronized unless `sync` is false (the cached one).
|
|
399
|
+
function synchronizedStatus(entry,config,root,timeout,sync=true){
|
|
314
400
|
return new Promise(resolve=>{
|
|
315
401
|
let child,out='',done=false;
|
|
316
402
|
const finish=value=>{if(done)return;done=true;clearTimeout(timer);process.off('SIGTERM',stop);
|
|
@@ -320,7 +406,7 @@ function synchronizedStatus(entry,config,root,timeout){
|
|
|
320
406
|
// Claude Code ends a hook that outlives its own timeout; end the status call with it.
|
|
321
407
|
const stop=()=>finish(null);process.once('SIGTERM',stop);
|
|
322
408
|
// Its own process group on POSIX, so a timeout also ends the engine it starts.
|
|
323
|
-
try{child=spawn(process.execPath,[entry,'--config',config,'status'
|
|
409
|
+
try{child=spawn(process.execPath,[entry,'--config',config,'status',...(sync?['--sync']:[]),'--json'],
|
|
324
410
|
{cwd:root,stdio:['ignore','pipe','ignore'],env:process.env,windowsHide:true,detached:process.platform!=='win32'});}
|
|
325
411
|
catch{return finish(null);}
|
|
326
412
|
child.on('error',()=>finish(null));
|
|
@@ -374,7 +460,7 @@ export function sessionStart(input,{entry}){
|
|
|
374
460
|
}
|
|
375
461
|
|
|
376
462
|
// Returns the hook's stdout object, or null to allow the command.
|
|
377
|
-
export async function claudeHook(input,{entry,timeout}){
|
|
463
|
+
export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
378
464
|
if(input?.tool_name!=='Bash')return null;
|
|
379
465
|
const command=input.tool_input?.command,root=input.cwd;
|
|
380
466
|
if(typeof command!=='string'||/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return null;
|
|
@@ -385,8 +471,27 @@ export async function claudeHook(input,{entry,timeout}){
|
|
|
385
471
|
if(!entered)return null;
|
|
386
472
|
let scripts={};
|
|
387
473
|
if(entered.dir==='.')try{scripts=JSON.parse(fs.readFileSync(path.join(project.projectRoot,'package.json'),'utf8')).scripts||{};}catch{}
|
|
474
|
+
// The configured config path as the command's directory sees it.
|
|
475
|
+
// Both sides resolved alike: Windows can spell the same directory RUNNER~1 or by its long name.
|
|
476
|
+
const configPath=!entered.prefix?record.config:
|
|
477
|
+
path.relative(path.join(project.projectRoot,entered.dir),realObservedPath(config)).split(path.sep).join('/');
|
|
478
|
+
// A simple chain: each check in it runs through REDUE when its turn comes,
|
|
479
|
+
// after the parts before it (an edit) have run. The command is changed,
|
|
480
|
+
// not approved: Claude Code's permission rules apply to it as rewritten.
|
|
481
|
+
// A chain with no check of its own (a leading fan-out) goes on below.
|
|
482
|
+
// Rewritten only on a healthy answer: without an observer, redue run
|
|
483
|
+
// cannot run the check, and the chain would not run as written.
|
|
484
|
+
// The observer's cached health is enough here: each check asks again when it runs.
|
|
485
|
+
const chained=chainCommand(entered.command,parsed,record.config,entered.dir,scripts,invoke??redueInvocation(entry),configPath,project.projectRoot);
|
|
486
|
+
if(chained){
|
|
487
|
+
const cached=await synchronizedStatus(entry,config,root,timeout??5000,false);
|
|
488
|
+
return cached?.observation?.healthy===true&&chained.names.every(name=>cached.checks?.some(row=>row?.name===name))?
|
|
489
|
+
{hookSpecificOutput:{hookEventName:'PreToolUse',updatedInput:{...input.tool_input,command:entered.prefix+chained.command}}}:null;
|
|
490
|
+
}
|
|
388
491
|
const match=matched(entered.command,parsed,record.config,entered.dir,scripts);
|
|
389
492
|
if(!match)return null;
|
|
493
|
+
// A check followed by more than a simple chain runs untouched.
|
|
494
|
+
if(match.rest!==null&&!match.fanout)return null;
|
|
390
495
|
// turbo, nx and `pnpm -r` cache their own results: skip only when every
|
|
391
496
|
// check the command runs is CURRENT, and otherwise leave it alone.
|
|
392
497
|
if(match.fanout){
|
|
@@ -396,10 +501,6 @@ export async function claudeHook(input,{entry,timeout}){
|
|
|
396
501
|
reason=fanoutVerdict(status,names,match.rest);
|
|
397
502
|
return reason?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}:null;
|
|
398
503
|
}
|
|
399
|
-
// The configured config path as the command's directory sees it.
|
|
400
|
-
// Both sides resolved alike: Windows can spell the same directory RUNNER~1 or by its long name.
|
|
401
|
-
const configPath=!entered.prefix?record.config:
|
|
402
|
-
path.relative(path.join(project.projectRoot,entered.dir),realObservedPath(config)).split(path.sep).join('/');
|
|
403
504
|
// A compiler script: its checks in the order of its runs, or nothing when
|
|
404
505
|
// they no longer correspond. One run is an ordinary check. A chain is
|
|
405
506
|
// skipped when every run is CURRENT, else the runs that are not run through
|
|
@@ -414,7 +515,7 @@ export async function claudeHook(input,{entry,timeout}){
|
|
|
414
515
|
if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}};
|
|
415
516
|
const pending=names.filter(name=>!verdict(status,name));
|
|
416
517
|
const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
|
|
417
|
-
names.every(name=>status.checks.some(item=>item?.name===name))?recordedCommand(entered.command,pending,configPath):null;
|
|
518
|
+
names.every(name=>status.checks.some(item=>item?.name===name))?recordedCommand(entered.command,pending,configPath,invoke??redueInvocation(entry)):null;
|
|
418
519
|
return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
|
|
419
520
|
permissionDecisionReason:`REDUE: ${pending.map(name=>JSON.stringify(name)).join(', ')} of this script's checks ${pending.length===1?'is':'are'} not CURRENT; running ${pending.length===1?'it':'them'} through redue run so the result is recorded.`,
|
|
420
521
|
updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null;
|
|
@@ -429,7 +530,7 @@ export async function claudeHook(input,{entry,timeout}){
|
|
|
429
530
|
// stays too: the rewrite returns "allow", which would also skip the prompt
|
|
430
531
|
// for the rest of the chain.
|
|
431
532
|
const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
|
|
432
|
-
status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath):null;
|
|
533
|
+
status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath,invoke??redueInvocation(entry)):null;
|
|
433
534
|
// updatedInput replaces the whole tool input. Deny and ask rules still apply
|
|
434
535
|
// to the rewritten command; "allow" skips only the prompt.
|
|
435
536
|
return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
|
package/src/cli.mjs
CHANGED
|
@@ -4,6 +4,8 @@ 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 {queryAnswer,runFactChange} from './typescript-list.mjs';
|
|
7
|
+
import {patternMatcher} from './glob.mjs';
|
|
8
|
+
import {observedBy} from './installed-observation.mjs';
|
|
7
9
|
import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
|
|
8
10
|
import {childProgram,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,pnpmNameless,toolOutput,toolchainDirs} from './node-script.mjs';
|
|
9
11
|
import path from 'node:path';
|
|
@@ -377,8 +379,13 @@ async function execute() {
|
|
|
377
379
|
// The pnpm executable reads its configuration natively: kept by content.
|
|
378
380
|
if(guard.opaque)for(const file of pnpmConfigFiles())try{runFacts.push(['readFile',file,queryAnswer('readFile',file)]);}
|
|
379
381
|
catch{guard.issues.push(`pnpm configuration ${file} cannot be read`);}
|
|
380
|
-
|
|
382
|
+
// Hard links count only where the check observes them.
|
|
383
|
+
// As the index matches: source patterns, or additive (installed) ones.
|
|
384
|
+
const source=patternMatcher(plan.sourcePatterns||plan.patterns||[]),additive=patternMatcher(plan.additivePatterns||[]);
|
|
385
|
+
const linkFacts=[],observed=observedBy(root,rel=>source(rel)||additive(rel));
|
|
386
|
+
const shared=sharedInstalledFile(root,testFacts,path.join(state,'pnpm-store-links-v1.json'),{observed,facts:linkFacts});
|
|
381
387
|
if(shared)guard.issues.push(`installed file ${shared.file} ${shared.reason}; a change through it is not observed`);
|
|
388
|
+
else runFacts.push(...linkFacts);
|
|
382
389
|
fs.rmSync(testRecord,{recursive:true,force:true});
|
|
383
390
|
}
|
|
384
391
|
// The daemon reads the recording from owned state; control requests are small.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Whether a check observes an installed file: the file is in the checkout
|
|
2
|
+
// and matched by the check's patterns, and a link it goes through that leads
|
|
3
|
+
// elsewhere in the checkout leads to a matched path too. A recorded run's
|
|
4
|
+
// hard-linked installed files are accepted only when every link is one the
|
|
5
|
+
// check observes, so a pattern that leaves a folder out (a runner cache in
|
|
6
|
+
// node_modules) cannot hide one.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import {observedRelative} from './path-identity.mjs';
|
|
10
|
+
|
|
11
|
+
// `matches(rel)`: the check's pattern matcher over checkout-relative paths.
|
|
12
|
+
// A link that leads outside the checkout is the plan's to resolve (a
|
|
13
|
+
// permitted root or a refusal).
|
|
14
|
+
export function observedBy(root,matches){
|
|
15
|
+
let realRoot=root;try{realRoot=fs.realpathSync(root);}catch{}
|
|
16
|
+
return file=>{
|
|
17
|
+
const logical=observedRelative(root,path.resolve(file));
|
|
18
|
+
if(logical===null||!matches(logical))return false;
|
|
19
|
+
let real;try{real=fs.realpathSync(file);}catch{return false;}
|
|
20
|
+
const physical=observedRelative(realRoot,real);
|
|
21
|
+
return physical===null||physical===logical||matches(physical);
|
|
22
|
+
};
|
|
23
|
+
}
|
package/src/node-script.mjs
CHANGED
|
@@ -35,12 +35,15 @@ const modulesDirs=dirs=>['node_modules',...dirs.map(dir=>`${dir}/node_modules`)]
|
|
|
35
35
|
// Inputs: the whole checkout but node_modules, .git and runner caches. Every
|
|
36
36
|
// node_modules of the workspace is observed as installed files, without the
|
|
37
37
|
// caches tools keep there.
|
|
38
|
+
// Runner caches at the top of a node_modules, which Vite, Vitest and build
|
|
39
|
+
// loaders rewrite as they run: not installed inputs.
|
|
40
|
+
export const installedRunnerCaches=['.cache','.vite','.vite-temp','.vitest','.vitest-cache'];
|
|
38
41
|
export function scriptCheckInputs(dirs=[]){
|
|
39
42
|
const modules=modulesDirs(dirs);
|
|
40
43
|
return {inputs:['**',...modules.map(at=>`!${at}/**`),'!.git/**',...installState.map(file=>`!${file}`),
|
|
41
44
|
...scriptCaches.map(pattern=>`!${pattern}`)],
|
|
42
|
-
installedInputs:[...modules.flatMap(at=>[`${at}
|
|
43
|
-
`!${at}
|
|
45
|
+
installedInputs:[...modules.flatMap(at=>[`${at}/**`,...installedRunnerCaches.map(dir=>`!${at}/${dir}/**`),
|
|
46
|
+
`!${at}/**/*.tsbuildinfo`]),...installState]};
|
|
44
47
|
}
|
|
45
48
|
// Installed files a tool rewrites during a run. pnpm writes its workspace
|
|
46
49
|
// state in the root node_modules at the first run after an install, through
|
|
@@ -236,6 +239,19 @@ export const scriptPath=(dir,callerPath)=>{
|
|
|
236
239
|
// Why a shell command line could start a program REDUE does not record, or
|
|
237
240
|
// null. Names are looked up along `searchPath` from `cwd`. A script the pnpm
|
|
238
241
|
// executable runs is read the same way, since the recorder cannot see it.
|
|
242
|
+
// TypeScript 7's Node launchers (tsc in typescript@7, tsgo in
|
|
243
|
+
// @typescript/native-preview) replace themselves with a native compiler REDUE
|
|
244
|
+
// does not record: the package and version, or null.
|
|
245
|
+
export function nativeTypeScriptLauncher(file){
|
|
246
|
+
let dir;try{dir=path.dirname(fs.realpathSync(file));}catch{return null;}
|
|
247
|
+
for(let n=0;n<3;n++,dir=path.dirname(dir)){
|
|
248
|
+
let pkg;try{pkg=JSON.parse(fs.readFileSync(path.join(dir,'package.json'),'utf8'));}catch{continue;}
|
|
249
|
+
if(pkg.name==='typescript'&&Number(String(pkg.version).split('.')[0])>=7||pkg.name==='@typescript/native-preview')
|
|
250
|
+
return `${pkg.name} ${pkg.version}`;
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
239
255
|
export function commandLineIssue(text,{cwd,searchPath,depth=0}={}){
|
|
240
256
|
if(depth>8)return 'the scripts run each other too deeply for REDUE to read';
|
|
241
257
|
const parsed=parseCommandLine(text);
|
|
@@ -270,6 +286,8 @@ export function commandLineIssue(text,{cwd,searchPath,depth=0}={}){
|
|
|
270
286
|
if(kind==='node'){
|
|
271
287
|
const flag=['node','nodejs'].includes(path.basename(file))&&args.find(arg=>nativeReads.test(arg.text));
|
|
272
288
|
if(flag)return `runs node ${flag.text.split('=')[0]}, which reads a file REDUE cannot see`;
|
|
289
|
+
const native=nativeTypeScriptLauncher(file);
|
|
290
|
+
if(native)return `runs ${path.basename(name)} from ${native}, a native compiler REDUE does not record`;
|
|
273
291
|
break;
|
|
274
292
|
}
|
|
275
293
|
if(kind==='pnpm'){const issue=pnpmRunIssue(args.map(arg=>arg.text),{cwd:dir,searchPath,depth});if(issue)return issue;break;}
|
package/src/pnpm-inputs.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// repository-relative input sets and resolution relationships, never pnpm
|
|
3
3
|
// virtual-store names as semantic identities.
|
|
4
4
|
import {probeTimeLeft} from './probe-deadline.mjs';
|
|
5
|
-
import {compilerFiles} from './typescript-list.mjs';
|
|
5
|
+
import {compilerApiIssue,compilerFiles} from './typescript-list.mjs';
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import {createRequire} from 'node:module';
|
|
@@ -298,7 +298,9 @@ export function pnpmTypecheckInputs(root,workspace,run,{captureQueries=false}={}
|
|
|
298
298
|
let tsPackage;try{tsPackage=realObservedPath(lookup.resolve('typescript/package.json'));}
|
|
299
299
|
catch{throw Error('installed TypeScript compiler cannot be resolved');}
|
|
300
300
|
if(!within(tsPackage,root))throw Error('TypeScript compiler is outside the observed checkout');
|
|
301
|
-
const tsRoot=path.dirname(tsPackage),
|
|
301
|
+
const tsRoot=path.dirname(tsPackage),apiIssue=compilerApiIssue(tsRoot);
|
|
302
|
+
if(apiIssue)throw Error(apiIssue);
|
|
303
|
+
const ts=lookup('typescript');
|
|
302
304
|
const tsc=path.join(tsRoot,'bin','tsc');
|
|
303
305
|
if(!exists(tsc))throw Error('installed TypeScript executable is missing');
|
|
304
306
|
const configStarted=performance.now();
|