@redue/cli 0.1.0-alpha.21 → 0.1.0-alpha.23
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 +67 -0
- package/README.md +117 -200
- package/bin/redue.mjs +15 -1
- package/docs/user/agents.md +12 -4
- package/docs/user/direct-typescript.md +3 -1
- package/docs/user/getting-started.md +20 -1
- package/docs/user/node-script.md +23 -4
- package/docs/user/supported-projects.md +5 -1
- package/docs/user/tests-direct.md +8 -1
- package/package.json +1 -1
- package/src/agent-integration.mjs +89 -16
- package/src/claude-hook.mjs +23 -6
- package/src/cli.mjs +12 -6
- package/src/node-script.mjs +24 -0
- package/src/pnpm-inputs.mjs +4 -2
- package/src/setup.mjs +178 -0
- package/src/test-direct.mjs +39 -7
- package/src/test-recorder.cjs +31 -2
- package/src/typescript-list.mjs +8 -0
- package/src/yarn-workspace-inputs.mjs +3 -0
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);
|
|
@@ -273,7 +348,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
273
348
|
const {root,relative,config}=project(configFile),h=hosts[host],record=saved(root,host),health=inspect(root,host,record);
|
|
274
349
|
if(health.integrity==='conflict')throw Error(health.reason);
|
|
275
350
|
const changes=[],notes=action==='setup'?warnings(root,host,cwd):[];
|
|
276
|
-
let hook=null,block=null;
|
|
351
|
+
let hook=null,sessionHook=null,block=null;
|
|
277
352
|
if(action==='setup'){
|
|
278
353
|
const before=read(root,h.instruction),skill=skillFor(relative,host,config);
|
|
279
354
|
block=blockFor(host,blockForms(host,config,relative));
|
|
@@ -281,7 +356,12 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
281
356
|
throw Error('unowned REDUE instruction or skill already exists; inspect it before setup');
|
|
282
357
|
const after=record?before.replace(record.block,block):(before||'')+block;
|
|
283
358
|
let scripts={};try{scripts=JSON.parse(read(root,'package.json')??'{}').scripts||{};}catch{}
|
|
284
|
-
|
|
359
|
+
const invocation=host==='claude'?hookInvocation(entry):null;
|
|
360
|
+
if(invocation)notes.push(`Hook command: ${invocation.command} (${invocation.reason})`);
|
|
361
|
+
if(invocation&&invocation.command!==hookCommand)
|
|
362
|
+
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.');
|
|
363
|
+
hook=host==='claude'?hookFor(config,relative,scripts,invocation.command):null;
|
|
364
|
+
sessionHook=host==='claude'?sessionHookFor(invocation.command):null;
|
|
285
365
|
const next={schema:1,host,config:relative,instruction:h.instruction,skill:h.skill,
|
|
286
366
|
block,skillHash:hash(skill),instructionCreated:record?.instructionCreated??before===null};
|
|
287
367
|
changes.push({path:h.skill,before:read(root,h.skill),after:skill},
|
|
@@ -291,14 +371,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
291
371
|
content:JSON.stringify(hook?next:{SessionStart:sessionHook},null,2)+
|
|
292
372
|
(hook?'':'\nno PreToolUse hook: no typecheck or test check to match')});}
|
|
293
373
|
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
|
-
}
|
|
374
|
+
}else if(record)changes.push(...removalChanges(root,host,record));
|
|
302
375
|
const actual=changes.filter(c=>c.before!==c.after);
|
|
303
376
|
const summary={schema:1,action,host,config:configFile,applied:false,
|
|
304
377
|
changes:actual.map(c=>({path:c.path,operation:c.after===null?'remove':c.before===null?'create':'update',
|
|
@@ -355,7 +428,7 @@ export async function doctor({configFile,stateOverride,entry,cwd,host}){
|
|
|
355
428
|
const out=spawnSync(launch.file,launch.args,{...launch.options,encoding:'utf8',timeout:2000,maxBuffer:4096,stdio:['ignore','pipe','pipe']});
|
|
356
429
|
if(out.status===0)version=out.stdout.trim();}
|
|
357
430
|
let integration={installed:false,integrity:'unknown'},hook=name==='claude'?'absent':'none',sessionHook=hook;
|
|
358
|
-
const installed=()=>
|
|
431
|
+
const installed=()=>hookRunnable(p.root);
|
|
359
432
|
if(p)try{const record=saved(p.root,name);integration=inspect(p.root,name,record);
|
|
360
433
|
if(name==='claude'&&hookInstalled(p.root))hook=installed();
|
|
361
434
|
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',
|
|
@@ -265,9 +282,9 @@ export function matchCheck(command,config,configFile,dir,scripts){
|
|
|
265
282
|
}
|
|
266
283
|
// The same check (or checks, in order) through REDUE, keeping the output
|
|
267
284
|
// suffix; null if it already is, or if more commands follow it.
|
|
268
|
-
export function recordedCommand(command,name,configFile=defaultConfig){
|
|
285
|
+
export function recordedCommand(command,name,configFile=defaultConfig,invoke='redue'){
|
|
269
286
|
const names=Array.isArray(name)?name:[name];
|
|
270
|
-
const parts=split(command),redue=redueFor(configFile);
|
|
287
|
+
const parts=split(command),redue=redueFor(configFile)?.replace(/^redue/,()=>invoke);
|
|
271
288
|
if(!parts||parts.rest!==null||!names.length||!names.every(value=>safe.test(value))||!redue)return null;
|
|
272
289
|
const run=names.map(value=>`${redue} run ${value}`).join(' && ');
|
|
273
290
|
return parts.base===run?null:names.length>1&&parts.suffix?`(${run})${parts.suffix}`:run+parts.suffix;
|
|
@@ -374,7 +391,7 @@ export function sessionStart(input,{entry}){
|
|
|
374
391
|
}
|
|
375
392
|
|
|
376
393
|
// Returns the hook's stdout object, or null to allow the command.
|
|
377
|
-
export async function claudeHook(input,{entry,timeout}){
|
|
394
|
+
export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
378
395
|
if(input?.tool_name!=='Bash')return null;
|
|
379
396
|
const command=input.tool_input?.command,root=input.cwd;
|
|
380
397
|
if(typeof command!=='string'||/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return null;
|
|
@@ -414,7 +431,7 @@ export async function claudeHook(input,{entry,timeout}){
|
|
|
414
431
|
if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}};
|
|
415
432
|
const pending=names.filter(name=>!verdict(status,name));
|
|
416
433
|
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;
|
|
434
|
+
names.every(name=>status.checks.some(item=>item?.name===name))?recordedCommand(entered.command,pending,configPath,invoke??redueInvocation(entry)):null;
|
|
418
435
|
return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
|
|
419
436
|
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
437
|
updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null;
|
|
@@ -429,7 +446,7 @@ export async function claudeHook(input,{entry,timeout}){
|
|
|
429
446
|
// stays too: the rewrite returns "allow", which would also skip the prompt
|
|
430
447
|
// for the rest of the chain.
|
|
431
448
|
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;
|
|
449
|
+
status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath,invoke??redueInvocation(entry)):null;
|
|
433
450
|
// updatedInput replaces the whole tool input. Deny and ask rules still apply
|
|
434
451
|
// to the rewritten command; "allow" skips only the prompt.
|
|
435
452
|
return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
|
package/src/cli.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import {directQualification,directInterpretation,observedNpmContext} from './dir
|
|
|
5
5
|
import {directRunCertificateAllowed,directRunCertificate,directRunRecordable,directRunRecording,probeQuestions,validateRunRecord} from './direct-run-certificate.mjs';
|
|
6
6
|
import {queryAnswer,runFactChange} from './typescript-list.mjs';
|
|
7
7
|
import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
|
|
8
|
-
import {childProgram,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,toolOutput,toolchainDirs} from './node-script.mjs';
|
|
8
|
+
import {childProgram,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,pnpmNameless,toolOutput,toolchainDirs} from './node-script.mjs';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import net from 'node:net';
|
|
11
11
|
import os from 'node:os';
|
|
@@ -400,7 +400,7 @@ async function execute() {
|
|
|
400
400
|
// what was there (test/tmp). When they are the only observed change, the
|
|
401
401
|
// run is kept with the end checkpoint, which includes them.
|
|
402
402
|
let outputs=null;
|
|
403
|
-
let eventsExplained=false;
|
|
403
|
+
let eventsExplained=false,unexplained=end?.inputEventPaths||null;
|
|
404
404
|
if(guard&&snap&&end&&snap.planId===end.planId&&snap.probeHash===end.probeHash&&snap.observationHealthy&&end.observationHealthy&&
|
|
405
405
|
(snap.inputEventSerial===end.inputEventSerial||end.inputEventPathsComplete===true)){
|
|
406
406
|
// A directory event is not an input change; a change to its files shows
|
|
@@ -408,9 +408,10 @@ async function execute() {
|
|
|
408
408
|
const directory=rel=>{try{return fs.lstatSync(path.join(root,rel)).isDirectory();}catch{return false;}};
|
|
409
409
|
const changed=changedFiles(snap.files,end.files),events=(end.inputEventPaths||[]).filter(rel=>!directory(rel));
|
|
410
410
|
const touched=[...new Set([...changed,...events])].sort();
|
|
411
|
-
const output=rel=>guard.isOutput(rel)||general&&toolOutput(rel,guard);
|
|
411
|
+
const output=rel=>guard.isOutput(rel)||general&&(toolOutput(rel,guard)||pnpmNameless(rel,guard,root,[snap.files,end.files]));
|
|
412
412
|
const ignored=touched.length&&touched.every(output)?gitIgnored(root,touched):new Set();
|
|
413
|
-
|
|
413
|
+
unexplained=touched.filter(file=>!ignored.has(file));
|
|
414
|
+
if(!unexplained.length){eventsExplained=true;if(changed.length)outputs=changed;}
|
|
414
415
|
}
|
|
415
416
|
// The end certificate, recorded or probed, must cover what this compile received.
|
|
416
417
|
// With no input event, gap or content change in between, it also holds at the start.
|
|
@@ -442,8 +443,13 @@ async function execute() {
|
|
|
442
443
|
captureIssues.push('observer generation changed during execution');
|
|
443
444
|
if(snap?.planGeneration!==end?.planGeneration)
|
|
444
445
|
captureIssues.push('input plan rebuilt during execution');
|
|
445
|
-
|
|
446
|
-
|
|
446
|
+
// The reason names up to five files the run did not explain, and a count of the rest.
|
|
447
|
+
if(snap?.inputEventSerial!==end?.inputEventSerial&&!eventsExplained){
|
|
448
|
+
const named=[...new Set(unexplained||[])],more=named.length-5,
|
|
449
|
+
unrecorded=end?.inputEventPathsComplete===false?' (not every event path was kept)':'';
|
|
450
|
+
captureIssues.push('declared input event occurred during execution'+(named.length?
|
|
451
|
+
`: ${named.slice(0,5).join(', ')}${more>0?` and ${more} more`:''}`:'')+unrecorded);
|
|
452
|
+
}
|
|
447
453
|
// A recorded certificate changes only the probe part of the fingerprint.
|
|
448
454
|
if(!outputs&&(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
|
|
449
455
|
snap.revision!==end.revision))captureIssues.push('declared inputs changed or were not observed');
|
package/src/node-script.mjs
CHANGED
|
@@ -57,6 +57,15 @@ export const toolOutputPath=rel=>pnpmState(rel)||pnpmTemp(rel)||vueTypes(rel);
|
|
|
57
57
|
// The run wrote it: a recorded process did, or, for pnpm's files, the pnpm
|
|
58
58
|
// executable ran, which writes them natively.
|
|
59
59
|
export const toolOutput=(rel,guard)=>toolOutputPath(rel)&&(guard.wroteInstalled(rel)||(pnpmState(rel)||pnpmTemp(rel))&&guard.opaque);
|
|
60
|
+
// On Linux, pnpm 12 also writes a file with no name (O_TMPFILE) in the root
|
|
61
|
+
// node_modules, whose writes inotify reports as '#<inode>'. Such an event is
|
|
62
|
+
// the run's output when the pnpm executable ran and no file has that name, at
|
|
63
|
+
// either checkpoint or now. Limit: another program's file with no name there,
|
|
64
|
+
// during a run that started the pnpm executable, is taken as pnpm's too; once
|
|
65
|
+
// linked in under any name, its writes through the open file are missed.
|
|
66
|
+
export const pnpmNameless=(rel,guard,root,checkpoints,platform=process.platform)=>platform==='linux'&&/^node_modules\/#\d+$/.test(rel)&&
|
|
67
|
+
guard.opaque&&checkpoints.every(files=>files&&!Object.hasOwn(files,rel))&&
|
|
68
|
+
(()=>{try{fs.lstatSync(path.join(root,rel));return false;}catch(error){return error.code==='ENOENT';}})();
|
|
60
69
|
// The installed paths the recorder treats as observed by identity.
|
|
61
70
|
export const scriptInstalledPaths=(root,dirs=[])=>[...modulesDirs(dirs),...installState].map(at=>path.join(root,at));
|
|
62
71
|
// Variables a recorded run cannot be seen reading: Node's and libc's (as in
|
|
@@ -227,6 +236,19 @@ export const scriptPath=(dir,callerPath)=>{
|
|
|
227
236
|
// Why a shell command line could start a program REDUE does not record, or
|
|
228
237
|
// null. Names are looked up along `searchPath` from `cwd`. A script the pnpm
|
|
229
238
|
// executable runs is read the same way, since the recorder cannot see it.
|
|
239
|
+
// TypeScript 7's Node launchers (tsc in typescript@7, tsgo in
|
|
240
|
+
// @typescript/native-preview) replace themselves with a native compiler REDUE
|
|
241
|
+
// does not record: the package and version, or null.
|
|
242
|
+
export function nativeTypeScriptLauncher(file){
|
|
243
|
+
let dir;try{dir=path.dirname(fs.realpathSync(file));}catch{return null;}
|
|
244
|
+
for(let n=0;n<3;n++,dir=path.dirname(dir)){
|
|
245
|
+
let pkg;try{pkg=JSON.parse(fs.readFileSync(path.join(dir,'package.json'),'utf8'));}catch{continue;}
|
|
246
|
+
if(pkg.name==='typescript'&&Number(String(pkg.version).split('.')[0])>=7||pkg.name==='@typescript/native-preview')
|
|
247
|
+
return `${pkg.name} ${pkg.version}`;
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
230
252
|
export function commandLineIssue(text,{cwd,searchPath,depth=0}={}){
|
|
231
253
|
if(depth>8)return 'the scripts run each other too deeply for REDUE to read';
|
|
232
254
|
const parsed=parseCommandLine(text);
|
|
@@ -261,6 +283,8 @@ export function commandLineIssue(text,{cwd,searchPath,depth=0}={}){
|
|
|
261
283
|
if(kind==='node'){
|
|
262
284
|
const flag=['node','nodejs'].includes(path.basename(file))&&args.find(arg=>nativeReads.test(arg.text));
|
|
263
285
|
if(flag)return `runs node ${flag.text.split('=')[0]}, which reads a file REDUE cannot see`;
|
|
286
|
+
const native=nativeTypeScriptLauncher(file);
|
|
287
|
+
if(native)return `runs ${path.basename(name)} from ${native}, a native compiler REDUE does not record`;
|
|
264
288
|
break;
|
|
265
289
|
}
|
|
266
290
|
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();
|
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
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 {createInterface} from 'node:readline/promises';
|
|
16
|
+
import {projectState} from './project-location.mjs';
|
|
17
|
+
import {claudeHooksInstalled,removeClaudeHooks,removeIntegration} from './agent-integration.mjs';
|
|
18
|
+
|
|
19
|
+
const configName='redue.config.json',localSettings='.claude/settings.local.json',agentRecord='.redue/agents/claude.json';
|
|
20
|
+
const hash=text=>createHash('sha256').update(text).digest('hex');
|
|
21
|
+
const readText=file=>{try{return fs.readFileSync(file,'utf8');}catch{return null;}};
|
|
22
|
+
const git=(root,...args)=>spawnSync('git',args,{cwd:root,encoding:'utf8',stdio:['ignore','pipe','ignore']});
|
|
23
|
+
const ignoreLine=`/${localSettings}`;
|
|
24
|
+
|
|
25
|
+
async function confirm(question,yes,{defaultYes}){
|
|
26
|
+
if(yes)return true;
|
|
27
|
+
if(!process.stdin.isTTY||!process.stdout.isTTY)throw Error('not a terminal; run again with --yes to proceed without prompts');
|
|
28
|
+
const prompt=createInterface({input:process.stdin,output:process.stdout});
|
|
29
|
+
try{const answer=(await prompt.question(`${question} ${defaultYes?'[Y/n]':'[y/N]'} `)).trim();
|
|
30
|
+
return defaultYes?!/^n/i.test(answer):/^y/i.test(answer);}finally{prompt.close();}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function setupCommand(args,{entry,cwd=process.cwd()}){
|
|
34
|
+
const action=args.shift();
|
|
35
|
+
const yes=args.includes('--yes'),rest=args.filter(arg=>arg!=='--yes');
|
|
36
|
+
if(action==='setup'&&(rest.length!==1||rest[0]!=='claude'))throw Error('use redue setup claude [--yes]');
|
|
37
|
+
if(action==='remove'&&rest.length)throw Error('use redue remove [--yes]');
|
|
38
|
+
const top=git(cwd,'rev-parse','--show-toplevel');
|
|
39
|
+
if(top.status!==0)throw Error('run this inside a Git checkout');
|
|
40
|
+
const root=fs.realpathSync(top.stdout.trim());
|
|
41
|
+
if(!fs.existsSync(path.join(root,'package.json')))throw Error(`no package.json at the repository root (${root})`);
|
|
42
|
+
// The entry as invoked, so a hook written by a child names the install's
|
|
43
|
+
// stable bin link rather than the versioned file behind it.
|
|
44
|
+
const invoked=typeof process.argv[1]==='string'&&path.isAbsolute(process.argv[1])&&
|
|
45
|
+
(()=>{try{return fs.realpathSync(process.argv[1])===fs.realpathSync(entry);}catch{return false;}})()?process.argv[1]:entry;
|
|
46
|
+
// Every step is the ordinary command, so its own output says what it did.
|
|
47
|
+
const redue=(argv,options={})=>spawnSync(process.execPath,[invoked,...argv],{cwd:root,encoding:'utf8',
|
|
48
|
+
stdio:options.capture?['ignore','pipe','pipe']:'inherit',timeout:options.timeout});
|
|
49
|
+
// One record per checkout, beside the others in the shared Git directory:
|
|
50
|
+
// linked worktrees share .git/info/exclude.
|
|
51
|
+
const common=path.resolve(root,git(root,'rev-parse','--git-common-dir').stdout.trim());
|
|
52
|
+
const recordFile=path.join(common,`redue-setup-${hash(root).slice(0,16)}.json`);
|
|
53
|
+
// Other checkouts' records, for checkouts that still exist.
|
|
54
|
+
const others=()=>{try{return fs.readdirSync(common).filter(name=>/^redue-setup-[0-9a-f]{16}\.json$/.test(name)&&path.join(common,name)!==recordFile)
|
|
55
|
+
.map(name=>JSON.parse(readText(path.join(common,name))??'null')).filter(other=>other&&fs.existsSync(other.root));}catch{return [];}};
|
|
56
|
+
const context={root,redue,yes,record:JSON.parse(readText(recordFile)??'null'),recordFile,
|
|
57
|
+
exclude:path.join(common,'info','exclude'),others,save:next=>fs.writeFileSync(recordFile,JSON.stringify(next,null,2)+'\n')};
|
|
58
|
+
return action==='setup'?setup(context):remove(context);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function setup({root,redue,yes,record:previous,recordFile,exclude,save}){
|
|
62
|
+
if(!fs.existsSync(path.join(root,'node_modules')))
|
|
63
|
+
throw Error('dependencies are not installed: run your package manager\'s install (npm install, pnpm install, yarn) first');
|
|
64
|
+
if(fs.existsSync(path.join(root,'vstate.config.json'))&&!fs.existsSync(path.join(root,configName)))
|
|
65
|
+
throw Error('a legacy vstate.config.json exists; rename it to redue.config.json or remove it first');
|
|
66
|
+
if(git(root,'ls-files','--error-unmatch',localSettings).status===0)
|
|
67
|
+
throw Error(`${localSettings} is committed; the hooks REDUE adds there name this machine's paths. Untrack it (git rm --cached) first`);
|
|
68
|
+
const configPath=path.join(root,configName),configExisted=fs.existsSync(configPath);
|
|
69
|
+
// init's account of the checks: which can qualify, also with a config.
|
|
70
|
+
const preview=redue(['init','--dry-run','--json'],{capture:true});
|
|
71
|
+
if(preview.status!==0)throw Error(preview.stderr.trim().replace(/^redue: /,'')||'init failed');
|
|
72
|
+
const found=JSON.parse(preview.stdout);
|
|
73
|
+
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')}`);
|
|
74
|
+
const levels=new Map(found.checks.map(check=>[check.name,check.level]));
|
|
75
|
+
const names=configExisted?JSON.parse(readText(configPath)).checks.map(check=>check.name):found.checks.map(check=>check.name);
|
|
76
|
+
const ready=names.filter(name=>levels.get(name)==='ready');
|
|
77
|
+
console.log(`Setting up REDUE for Claude Code in ${root}`);
|
|
78
|
+
console.log(configExisted?` keep ${configName} (exists)`:` create ${configName}: ${names.map(name=>`${name} (${levels.get(name)==='ready'?'can qualify':'recording-only'})`).join(', ')}`);
|
|
79
|
+
console.log(` write the Claude Code integration: CLAUDE.md section, .claude/skills/redue-verification/SKILL.md, ${localSettings} hooks, ${agentRecord}`);
|
|
80
|
+
console.log(` start the observer and record ${ready.length?ready.join(', '):'no check'} once, unless already CURRENT`);
|
|
81
|
+
if(!await confirm('Proceed?',yes,{defaultYes:true})){console.log('Nothing written.');return {applied:false};}
|
|
82
|
+
// What this run creates is added to what earlier runs created.
|
|
83
|
+
const state=projectState(root,configPath);
|
|
84
|
+
const own={schema:1,host:'claude',root,config:configName,state,
|
|
85
|
+
configCreated:Boolean(previous?.configCreated),configHash:previous?.configHash??null,
|
|
86
|
+
agentCreated:Boolean(previous?.agentCreated)||!fs.existsSync(path.join(root,agentRecord)),
|
|
87
|
+
hooksAdded:Boolean(previous?.hooksAdded)||!claudeHooksInstalled(root),
|
|
88
|
+
stateCreated:Boolean(previous?.stateCreated)||!fs.existsSync(state),
|
|
89
|
+
excludeAdded:Boolean(previous?.excludeAdded)};
|
|
90
|
+
if(!configExisted){
|
|
91
|
+
const init=redue(['init']);
|
|
92
|
+
if(init.status!==0||!fs.existsSync(configPath))throw Error(`init did not write ${configName}; nothing else was written`);
|
|
93
|
+
Object.assign(own,{configCreated:true,configHash:hash(readText(configPath))});
|
|
94
|
+
}
|
|
95
|
+
save(own);
|
|
96
|
+
console.log(`${previous?'update':'create'} ${recordFile}: what setup created, for redue remove`);
|
|
97
|
+
const agent=redue(['agent','setup','claude','--apply']);
|
|
98
|
+
if(agent.status!==0)throw Error('agent setup failed; see above. redue remove undoes what setup wrote');
|
|
99
|
+
// The hooks' settings hold machine paths: keep them out of commits in this
|
|
100
|
+
// clone when Git does not ignore them.
|
|
101
|
+
if(git(root,'check-ignore','-q',localSettings).status===1){
|
|
102
|
+
fs.mkdirSync(path.dirname(exclude),{recursive:true});
|
|
103
|
+
const text=readText(exclude)??'';
|
|
104
|
+
fs.writeFileSync(exclude,`${text}${text&&!text.endsWith('\n')?'\n':''}${ignoreLine}\n`);
|
|
105
|
+
console.log(`update ${exclude}: ignore ${ignoreLine} in this clone`);
|
|
106
|
+
own.excludeAdded=true;save(own);
|
|
107
|
+
}
|
|
108
|
+
// An observer that is already running is what start would give.
|
|
109
|
+
const started=redue(['start'],{capture:true});
|
|
110
|
+
process.stdout.write(started.stdout);
|
|
111
|
+
if(started.status!==0){
|
|
112
|
+
if(!/already running/.test(started.stderr))throw Error(`the observer did not start: ${started.stderr.trim().replace(/^redue: /,'')}`);
|
|
113
|
+
console.log('Observer already running.');
|
|
114
|
+
}
|
|
115
|
+
// The observer indexes the project first; a run before that cannot be
|
|
116
|
+
// recorded as reusable. Wait while it plans and indexes; once its plan is
|
|
117
|
+
// ready, an observer that is still unhealthy says why in the status below.
|
|
118
|
+
// Nothing to wait for when no check can qualify.
|
|
119
|
+
const observation=()=>{const out=redue(['status','--json'],{capture:true});try{return JSON.parse(out.stdout).observation??null;}catch{return null;}};
|
|
120
|
+
// An answer without a phase (a stopped observer) for 10 s in a row ends the
|
|
121
|
+
// wait too.
|
|
122
|
+
const begun=Date.now();let told=-1,unread=0,phaseless=0;
|
|
123
|
+
for(let state=observation();ready.length&&!(state?.phase==='ready'||state?.phase==='unavailable')&&phaseless<10;state=observation()){
|
|
124
|
+
const seconds=Math.round((Date.now()-begun)/1000);
|
|
125
|
+
unread=state===null?unread+1:0;phaseless=state&&!state.phase?phaseless+1:0;
|
|
126
|
+
if(unread>=30)throw Error('the observer\'s status cannot be read; run redue status to see why');
|
|
127
|
+
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`);
|
|
128
|
+
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...');}
|
|
129
|
+
await new Promise(resolve=>setTimeout(resolve,1000));
|
|
130
|
+
}
|
|
131
|
+
// One recorded run of each check that can qualify and is not CURRENT.
|
|
132
|
+
const statusOf=()=>{const out=redue(['status','--sync','--json'],{capture:true,timeout:600_000});try{return JSON.parse(out.stdout).checks;}catch{return [];}};
|
|
133
|
+
const rows=statusOf(),due=ready.filter(name=>rows.find(row=>row.name===name)?.freshness!=='CURRENT');
|
|
134
|
+
for(const name of due){console.log(`Recording ${name} once:`);redue(['run',name]);}
|
|
135
|
+
if(ready.length&&!due.length)console.log(`Already CURRENT: ${ready.join(', ')}.`);
|
|
136
|
+
for(const row of statusOf())console.log(` ${row.name}: ${row.freshness}${row.result?' / '+row.result:''}${row.freshness==='CURRENT'?'':' — '+row.reason}`);
|
|
137
|
+
console.log('Done. Start a new Claude Code session in this project. Undo with redue remove.');
|
|
138
|
+
return {applied:true};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function remove({root,redue,yes,record:own,recordFile,exclude,others}){
|
|
142
|
+
if(!own){console.log('No setup record for this checkout: nothing redue setup created to remove.');
|
|
143
|
+
console.log('To undo a setup made step by step: redue agent remove claude --apply, then redue remove-state.');return {removed:false};}
|
|
144
|
+
const configPath=path.join(root,configName),configText=readText(configPath);
|
|
145
|
+
const removeAgent=own.agentCreated&&fs.existsSync(path.join(root,agentRecord));
|
|
146
|
+
const removeHooks=!removeAgent&&own.hooksAdded&&claudeHooksInstalled(root);
|
|
147
|
+
const removeState=own.stateCreated&&fs.existsSync(own.state);
|
|
148
|
+
const deleteConfig=own.configCreated&&configText!==null&&hash(configText)===own.configHash;
|
|
149
|
+
// The ignore line goes only when no other checkout of this repository is
|
|
150
|
+
// set up: those rely on it too, whichever added it.
|
|
151
|
+
const removeIgnore=own.excludeAdded&&!others().length;
|
|
152
|
+
console.log(`Removing what redue setup created in ${root}`);
|
|
153
|
+
if(removeAgent)console.log(' remove the Claude Code integration');
|
|
154
|
+
if(removeHooks)console.log(` remove REDUE's hooks from ${localSettings} (the integration predates setup)`);
|
|
155
|
+
if(removeState)console.log(' stop the observer and remove its local state (receipts included)');
|
|
156
|
+
else if(!own.stateCreated)console.log(' keep the observer state (it predates setup)');
|
|
157
|
+
if(own.configCreated&&configText!==null)console.log(deleteConfig?` delete ${configName}`:` keep ${configName} (edited since setup)`);
|
|
158
|
+
if(removeIgnore)console.log(` remove ${ignoreLine} from ${exclude}`);
|
|
159
|
+
console.log(` delete ${recordFile}`);
|
|
160
|
+
if(!await confirm('Proceed?',yes,{defaultYes:false})){console.log('Nothing removed.');return {removed:false};}
|
|
161
|
+
if(removeAgent)for(const line of removeIntegration(root))console.log(line);
|
|
162
|
+
if(removeHooks){const changed=removeClaudeHooks(root);if(changed)console.log(`update ${changed}`);}
|
|
163
|
+
if(removeState&&redue(['--state-dir',own.state,'remove-state']).status!==0)
|
|
164
|
+
throw Error('the observer state was not removed; see above. The config and setup record are kept so you can retry');
|
|
165
|
+
if(deleteConfig){fs.unlinkSync(configPath);console.log(`remove ${configName}`);}
|
|
166
|
+
if(removeIgnore){
|
|
167
|
+
const text=readText(exclude);
|
|
168
|
+
if(text!==null&&text.split('\n').includes(ignoreLine)){
|
|
169
|
+
fs.writeFileSync(exclude,text.split('\n').filter(line=>line!==ignoreLine).join('\n'));
|
|
170
|
+
console.log(`update ${exclude}`);}
|
|
171
|
+
}
|
|
172
|
+
fs.unlinkSync(recordFile);console.log(`remove ${recordFile}`);
|
|
173
|
+
// Folders the integration lived in, when nothing else is left in them.
|
|
174
|
+
for(const dir of ['.redue/agents','.redue','.claude/skills/redue-verification','.claude/skills','.claude'])
|
|
175
|
+
try{fs.rmdirSync(path.join(root,dir));console.log(`remove ${dir}/`);}catch{}
|
|
176
|
+
console.log('Done.');
|
|
177
|
+
return {removed:true};
|
|
178
|
+
}
|
package/src/test-direct.mjs
CHANGED
|
@@ -16,6 +16,8 @@ import {queryAnswer} from './typescript-list.mjs';
|
|
|
16
16
|
import {pnpmInstallInfo,pnpmProjectInfo,yaml} from './pnpm-inputs.mjs';
|
|
17
17
|
import {workspaceManifests} from './workspace-manifests.mjs';
|
|
18
18
|
import {atomicJson} from './state-store.mjs';
|
|
19
|
+
// node-script.mjs imports this module too; childProgram is only called at run time.
|
|
20
|
+
import {childProgram} from './node-script.mjs';
|
|
19
21
|
import {racyUntil} from './path-identity.mjs';
|
|
20
22
|
|
|
21
23
|
const digest=value=>createHash('sha256').update(value).digest('hex');
|
|
@@ -73,7 +75,7 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
|
|
|
73
75
|
const issues=[],add=issue=>{if(!issues.includes(issue))issues.push(issue);};
|
|
74
76
|
const included=patternMatcher(sourcePatterns);
|
|
75
77
|
const observed=rel=>Object.hasOwn(files||{},rel);
|
|
76
|
-
const headers=new Map(),records=[];
|
|
78
|
+
const headers=new Map(),records=[],headerCount=new Map(),threadsStarted=new Map();
|
|
77
79
|
let names=[];
|
|
78
80
|
try{names=fs.readdirSync(dir).filter(name=>name.endsWith('.jsonl'));}
|
|
79
81
|
catch{return {issues:['the run record is unavailable'],runFacts:[],environmentReads:{},isOutput:()=>false};}
|
|
@@ -84,6 +86,9 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
|
|
|
84
86
|
const header=rows[0]?.[0]==='H'?rows[0][1]:null;
|
|
85
87
|
if(!header||!Number.isSafeInteger(header.pid)||!Number.isSafeInteger(header.threadId)){add('a run record is unreadable');continue;}
|
|
86
88
|
headers.set(`${header.pid}-${header.threadId}`,header);
|
|
89
|
+
// After process.execve, the new program's threads append to the same
|
|
90
|
+
// records: each thread started needs its own header.
|
|
91
|
+
headerCount.set(`${header.pid}-${header.threadId}`,rows.filter(row=>row?.[0]==='H'&&!row[1]?.internal).length);
|
|
87
92
|
records.push({header,rows:rows.slice(1)});
|
|
88
93
|
}
|
|
89
94
|
let opaque=false;
|
|
@@ -101,7 +106,24 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
|
|
|
101
106
|
// PIDs is that child, not an execSync child (which reports no PID).
|
|
102
107
|
const installedWrites=new Set();
|
|
103
108
|
const reads=new Map(),produced=new Set(),created=new Set(),environmentReads={},spawned=new Map(),childPids=new Map();
|
|
109
|
+
// Whether a started program is accounted for: {issue} or {needsHeader}.
|
|
110
|
+
// The tests recipe wants every program to become a recorded process; a
|
|
111
|
+
// shell, which can run other programs first, is read by its command as
|
|
112
|
+
// the general recipe reads it.
|
|
113
|
+
const judge=(child,main)=>{
|
|
114
|
+
if(!programs){
|
|
115
|
+
const judged=/^(sh|bash|dash|zsh|ksh|fish|csh|tcsh)$/.test(path.basename(String(child.file)))?childProgram(child,{parentMain:main}):{};
|
|
116
|
+
return judged.issue?judged:{needsHeader:true};}
|
|
117
|
+
const judged=programs.judge(child,main);
|
|
118
|
+
if(!judged.issue)opaque||=Boolean(judged.opaque);
|
|
119
|
+
return judged;
|
|
120
|
+
};
|
|
104
121
|
for(const {header,rows} of records){
|
|
122
|
+
// A program this process replaced itself with (process.execve) that must
|
|
123
|
+
// load the recorder: its header follows in this record, from the same
|
|
124
|
+
// parent. Later rows come from the program now running. (A reused PID
|
|
125
|
+
// also appends a header to an old record.)
|
|
126
|
+
let replaced=null,main=header.main??null;
|
|
105
127
|
for(const row of rows){
|
|
106
128
|
const [kind,...value]=Array.isArray(row)?row:[];
|
|
107
129
|
if(kind==='U')add(`recorder: ${value[0]}`);
|
|
@@ -121,16 +143,26 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
|
|
|
121
143
|
if(kind==='C')childPids.set(header.pid,[...childPids.get(header.pid)||[],value[0]]);
|
|
122
144
|
// The general recipe accounts for each program; one that needs no
|
|
123
145
|
// recorder (a shell, the pnpm executable) is not matched to a header.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if(!judged.needsHeader)continue;}
|
|
146
|
+
const judged=judge({file,args,cwd,searchPath,env,script},main);
|
|
147
|
+
if(judged.issue){add(judged.issue);continue;}
|
|
148
|
+
if(!judged.needsHeader)continue;
|
|
128
149
|
const list=spawned.get(header.pid)||[];list.push({pid:kind==='C'?value[0]:null,file});
|
|
129
150
|
spawned.set(header.pid,list);}
|
|
130
|
-
else if(kind==='
|
|
131
|
-
|
|
151
|
+
else if(kind==='X'){
|
|
152
|
+
const [file,args,cwd,searchPath,env,script]=value;
|
|
153
|
+
const judged=judge({file,args,cwd,searchPath,env,script},main);
|
|
154
|
+
if(judged.issue){add(judged.issue);replaced=null;continue;}
|
|
155
|
+
replaced=judged.needsHeader?file:null;}
|
|
156
|
+
else if(kind==='Y')replaced=null;
|
|
157
|
+
else if(kind==='T'){const key=`${header.pid}-${value[0]}`;threadsStarted.set(key,(threadsStarted.get(key)||0)+1);}
|
|
158
|
+
else if(kind==='H'){
|
|
159
|
+
if(replaced!==null&&value[0]?.ppid!==header.ppid)add(`replaced itself with ${path.basename(String(replaced))}, which REDUE does not record`);
|
|
160
|
+
replaced=null;main=value[0]?.main??null;}
|
|
161
|
+
else add('a run record is unreadable');
|
|
132
162
|
}
|
|
163
|
+
if(replaced!==null)add(`replaced itself with ${path.basename(String(replaced))}, which REDUE does not record`);
|
|
133
164
|
}
|
|
165
|
+
for(const [key,count] of threadsStarted)if((headerCount.get(key)||0)<count)add('a worker thread did not load the REDUE recorder');
|
|
134
166
|
// Every child must be a recorded Node process: by PID when known, otherwise
|
|
135
167
|
// by the number of recorded processes whose parent started it.
|
|
136
168
|
for(const [parent,children] of spawned){
|