@redue/cli 0.1.0-alpha.23 → 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 +32 -0
- package/README.md +7 -8
- package/bin/redue.mjs +19 -1
- package/docs/user/agents.md +20 -1
- package/docs/user/tests-direct.md +6 -2
- package/package.json +1 -1
- package/src/agent-integration.mjs +11 -5
- package/src/claude-hook.mjs +90 -6
- package/src/cli.mjs +8 -1
- package/src/installed-observation.mjs +23 -0
- package/src/node-script.mjs +5 -2
- package/src/test-direct.mjs +19 -3
- package/src/typescript-list.mjs +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.24 — 2026-10-28
|
|
4
|
+
|
|
5
|
+
Checks keep working after a normal `npm install`, and agents that chain an
|
|
6
|
+
edit and a check get the same skip-or-record as a check run on its own.
|
|
7
|
+
|
|
8
|
+
- **Hard-linked installed files.** Without a pnpm store, any installed file
|
|
9
|
+
with a second hard link made a recorded run UNVERIFIED. esbuild's install
|
|
10
|
+
makes one (npm, not Yarn or Windows), so after an ordinary `npm install`
|
|
11
|
+
general-recipe and test checks in projects with esbuild stayed
|
|
12
|
+
UNVERIFIED. Such a file is now accepted when every one of its links is an
|
|
13
|
+
installed file the check observes; its link count is checked again before
|
|
14
|
+
every answer, so a link added later is noticed.
|
|
15
|
+
- **Checks inside a chain.** In a simple chain (`printf ... >> README.md &&
|
|
16
|
+
npm run lint`), the Claude Code hook rewrites each check to the new
|
|
17
|
+
`redue run CHECK --if-due`, which skips the check when its last pass still
|
|
18
|
+
applies at that moment and otherwise runs it through REDUE with the same
|
|
19
|
+
exit code. The hook changes the command without approving it, so your
|
|
20
|
+
permission rules still apply. Chains with subshells, substitutions,
|
|
21
|
+
here-documents, background jobs, shell keywords or builtins before a check
|
|
22
|
+
run untouched.
|
|
23
|
+
- The CLAUDE.md section asks agents to run each check as a command of its
|
|
24
|
+
own. Setups from earlier alphas are recognized and updated.
|
|
25
|
+
- The README's coverage numbers now come from a census that installs with
|
|
26
|
+
dependencies' install scripts on, as users do: of 120 public TypeScript
|
|
27
|
+
repositories, 86 installed and 30 have a qualifying check (typecheck 21,
|
|
28
|
+
lint 18, tests 2).
|
|
29
|
+
- Known limit, unchanged: a Vitest run writes under `node_modules/.vite`,
|
|
30
|
+
which makes a direct TypeScript typecheck STALE until it runs again.
|
|
31
|
+
|
|
32
|
+
- Contributors: census `--install-scripts`; the stranger test can install
|
|
33
|
+
from npm (`--npm`) and run a follow-up session that uses the hooks.
|
|
34
|
+
|
|
3
35
|
## 0.1.0-alpha.23 — 2026-10-26
|
|
4
36
|
|
|
5
37
|
One command to set up REDUE with Claude Code, and one to undo it.
|
package/README.md
CHANGED
|
@@ -62,25 +62,24 @@ To undo everything setup did: `redue remove`.
|
|
|
62
62
|
## What it covers today
|
|
63
63
|
|
|
64
64
|
In a census of 120 active public TypeScript repositories (October 2026, REDUE
|
|
65
|
-
at commit
|
|
66
|
-
|
|
67
|
-
same
|
|
65
|
+
at commit 0fcd19b, after 0.1.0-alpha.23), each installed as its users would,
|
|
66
|
+
with dependencies' install scripts on, 86 installed cleanly. A check counts
|
|
67
|
+
when two recorded runs on the same code both came out CURRENT on the same
|
|
68
|
+
inputs. 30 repositories had at least one such check:
|
|
68
69
|
|
|
69
70
|
| Check | Repositories | How |
|
|
70
71
|
|---|---|---|
|
|
71
|
-
| Typecheck |
|
|
72
|
+
| Typecheck | 21 | 15 by running the project's script with every Node process recorded ([general recipe](docs/user/node-script.md)); 6 by running the local `tsc` directly ([direct recipe](docs/user/direct-typescript.md)) |
|
|
72
73
|
| Lint | 18 | general recipe |
|
|
73
74
|
| Tests | 2 | Vitest run directly ([direct test recipe](docs/user/tests-direct.md)) |
|
|
74
75
|
|
|
75
76
|
Everything else stays UNVERIFIED, with a reason you can read in
|
|
76
77
|
`redue explain`. The usual ones: a script starts a program REDUE cannot see
|
|
77
78
|
into (esbuild, turbo, nx, oxlint's tsgolint, TypeScript 7's native compiler),
|
|
78
|
-
the project uses Bun, or a test suite writes into its own inputs.
|
|
79
|
+
the project uses Bun, or a test suite writes into its own inputs. One you may
|
|
79
80
|
meet right after setup: a Vitest run writes its results under
|
|
80
81
|
`node_modules/.vite`, which the direct TypeScript recipe counts as an
|
|
81
|
-
installed input, so the typecheck turns STALE until it runs again
|
|
82
|
-
installed file with a second hard link (esbuild's install makes one) keeps
|
|
83
|
-
the test check UNVERIFIED.
|
|
82
|
+
installed input, so the typecheck turns STALE until it runs again.
|
|
84
83
|
[Supported projects](docs/user/supported-projects.md) has the details.
|
|
85
84
|
|
|
86
85
|
## Measured with Claude Code
|
package/bin/redue.mjs
CHANGED
|
@@ -43,7 +43,8 @@ Commands:
|
|
|
43
43
|
status --sync Reconcile inputs and caller context before answering
|
|
44
44
|
explain [CHECK] [--json] Explain applicability (synchronized; may cost more than a rerun)
|
|
45
45
|
detail [CHECK] [--json] Alias for explain
|
|
46
|
-
run CHECK
|
|
46
|
+
run CHECK [--if-due] Execute one configured check and record its outcome; with --if-due,
|
|
47
|
+
only when its last pass does not apply to the files as they are now
|
|
47
48
|
remove-state Stop and remove only this configuration's owned local state
|
|
48
49
|
|
|
49
50
|
No checks run in the background. Direct commands outside redue create no receipts.`;
|
|
@@ -96,6 +97,9 @@ if(command==='agent'){
|
|
|
96
97
|
if(!['init','start','stop','status','detail','explain','run','remove-state'].includes(command))
|
|
97
98
|
error(`unknown command ${command||'<none>'}; use --help`);
|
|
98
99
|
const json=args.includes('--json');
|
|
100
|
+
// run --if-due: run the check only when its last pass does not apply now.
|
|
101
|
+
const ifDue=command==='run'&&args.includes('--if-due');
|
|
102
|
+
if(ifDue)args.splice(args.indexOf('--if-due'),1);
|
|
99
103
|
const sync=args.includes('--sync');
|
|
100
104
|
const dryRun=args.includes('--dry-run');
|
|
101
105
|
const short=args.includes('--short');
|
|
@@ -388,6 +392,20 @@ const internal=command==='remove-state'?'uninstall':
|
|
|
388
392
|
// On macOS and Linux the engine runs in this process, which saves a Node startup
|
|
389
393
|
// on every run; its own handlers receive SIGINT and SIGTERM. Windows keeps a
|
|
390
394
|
// child engine because cancellation reaches it over IPC.
|
|
395
|
+
// Asked when this part of a command runs, after earlier parts may have
|
|
396
|
+
// changed files. REDUE_FORCE=1 runs it anyway.
|
|
397
|
+
if(ifDue&&process.env.REDUE_FORCE!=='1'){
|
|
398
|
+
const {verdict}=await import('../src/claude-hook.mjs');
|
|
399
|
+
const asked=spawnSync(process.execPath,[fileURLToPath(import.meta.url),'--config',configFile,...(stateOverride?['--state-dir',stateOverride]:[]),
|
|
400
|
+
'status','--sync','--json'],{encoding:'utf8',stdio:['ignore','pipe','ignore'],env:process.env,timeout:600_000});
|
|
401
|
+
let status=null;try{status=JSON.parse(asked.stdout);}catch{}
|
|
402
|
+
if(verdict(status,check)){
|
|
403
|
+
const row=status.checks.find(item=>item.name===check);
|
|
404
|
+
if(json)console.log(JSON.stringify({check,skipped:true,result:'PASS',run_id:row.invocation.runId}));
|
|
405
|
+
else console.error(`REDUE: ${check} passed on these exact inputs (run ${row.invocation.runId}); not run again. REDUE_FORCE=1 runs it anyway.`);
|
|
406
|
+
await exit(0);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
391
409
|
if(command==='run'&&process.platform!=='win32'){
|
|
392
410
|
process.argv=[process.argv[0],engine,runtimeFile,internal,check,...(!json?['--human']:[])];
|
|
393
411
|
await import(pathToFileURL(engine).href);
|
package/docs/user/agents.md
CHANGED
|
@@ -85,7 +85,26 @@ desktop app or another shell still runs the right REDUE; run setup again
|
|
|
85
85
|
after moving or upgrading Node. `redue agent doctor` reports a hook whose
|
|
86
86
|
paths no longer exist. When the hook runs a check through REDUE for the
|
|
87
87
|
agent, it likewise writes plain `redue run` only if its own PATH finds this
|
|
88
|
-
install, else the absolute form.
|
|
88
|
+
install, else the absolute form.
|
|
89
|
+
|
|
90
|
+
Agents often chain an edit and a check (`printf … >> README.md && npm run
|
|
91
|
+
lint`). In a simple chain (commands joined by `&&`, `||` or `;`, with no
|
|
92
|
+
subshell, here-document, command substitution, background job or shell
|
|
93
|
+
keyword) where every part before a check is a program on PATH (not a
|
|
94
|
+
builtin such as `cd` or `export`, a shell function such as `nvm`, or a bare
|
|
95
|
+
assignment), the hook rewrites each check to `redue run CHECK --if-due`. That
|
|
96
|
+
command decides when its turn comes, after the earlier parts may have
|
|
97
|
+
changed files: it skips the check (exit 0, and says so) when its last pass
|
|
98
|
+
still applies, and otherwise runs it through REDUE with the exit code a plain
|
|
99
|
+
run gives. A script that runs several checks becomes `{ redue run a --if-due
|
|
100
|
+
&& redue run b --if-due; }`. The rest of the chain is unchanged. The hook
|
|
101
|
+
rewrites only while the observer is healthy and knows each check. It changes
|
|
102
|
+
such a command without approving it, so your permission rules apply to the
|
|
103
|
+
rewritten command; a rule that allows only `npm run lint` will ask about
|
|
104
|
+
`redue run lint --if-due`. Anything more complex runs untouched. Known
|
|
105
|
+
limit: if REDUE cannot start the check when its turn comes (another
|
|
106
|
+
`redue run` holds the project's run lock), that part exits with REDUE's
|
|
107
|
+
error instead of running the check. Other settings in the file are kept,
|
|
89
108
|
but the file is rewritten as 2-space JSON. `.claude/settings.json` is not touched.
|
|
90
109
|
`redue agent remove claude --apply` removes only the entries REDUE added.
|
|
91
110
|
|
|
@@ -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
|
@@ -183,23 +183,28 @@ const commandsOf=rows=>[rows.filter(row=>row.kind!=='test'&&row.kind!=='lint').m
|
|
|
183
183
|
rows.filter(row=>row.kind==='test').map(row=>row.form),rows.filter(row=>row.kind==='lint').map(row=>row.form)];
|
|
184
184
|
const fence=(host,body)=>`\n\n<!-- REDUE:${host}:begin -->\n${body}<!-- REDUE:${host}:end -->\n`;
|
|
185
185
|
const skillPointer=host=>`Before you run, skip or report a typecheck or other configured check, read\n${hosts[host].skill}.\n`;
|
|
186
|
-
|
|
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){
|
|
187
189
|
const [typechecks,tests,lints]=commandsOf(rows);
|
|
188
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`:'';
|
|
189
193
|
if(lints.length)return fence(host,(typechecks.length?`Typecheck with ${quoted(typechecks)}.\n`:'')+
|
|
190
194
|
(tests.length?`Run the unit tests with ${quoted(tests)}.\n`:'')+`Lint with ${quoted(lints)}.\n`+
|
|
191
195
|
`Run a check before you say it passes, even after an edit you think cannot\n`+
|
|
192
196
|
`matter; don't skip it yourself. If its last pass still applies to these exact\n`+
|
|
193
|
-
`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);
|
|
194
198
|
if(!tests.length)return fence(host,`Typecheck with ${quoted(typechecks)}. Run it before you say it passes, even after an\n`+
|
|
195
199
|
`edit you think cannot matter; don't skip it yourself. If its last pass still\n`+
|
|
196
200
|
`applies to these exact files, a REDUE hook skips the run and says so. That\n`+
|
|
197
|
-
|
|
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`));
|
|
198
203
|
return fence(host,(typechecks.length?`Typecheck with ${quoted(typechecks)}.\n`:'')+
|
|
199
204
|
`Run the unit tests with ${quoted(tests)}.\n`+
|
|
200
205
|
`Run a check before you say it passes, even after an edit you think cannot\n`+
|
|
201
206
|
`matter; don't skip it yourself. If its last pass still applies to these exact\n`+
|
|
202
|
-
`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);
|
|
203
208
|
}
|
|
204
209
|
// The alpha.12-alpha.14 wording, recognized so setup can replace it.
|
|
205
210
|
function earlierBlock(host,rows=[]){
|
|
@@ -240,7 +245,8 @@ function knownBlock(host,relative,block){
|
|
|
240
245
|
const rows=[...forms(`^\\n\\n<!-- REDUE:[a-z]+:begin -->\\nTypecheck with ${list}\\.`).map(form=>({kind:'typecheck',form})),
|
|
241
246
|
...forms(`Run the unit tests with ${list}\\.\\n`).map(form=>({kind:'test',form})),
|
|
242
247
|
...forms(`Lint with ${list}\\.\\n`).map(form=>({kind:'lint',form}))];
|
|
243
|
-
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);
|
|
244
250
|
}
|
|
245
251
|
// The policy with the exact commands for this config, so an agent never builds
|
|
246
252
|
// a --config form the hook would not recognize. Claude Code gets the typecheck
|
package/src/claude-hook.mjs
CHANGED
|
@@ -276,6 +276,74 @@ function matched(command,config,configFile,dir='.',scripts={}){
|
|
|
276
276
|
// A script form of compiler checks is checked against the script first.
|
|
277
277
|
return row?.scriptForms?.includes(parts.base)?{row,rest:parts.rest}:row?{check:row.check,rest:parts.rest}:null;
|
|
278
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
|
+
}
|
|
279
347
|
export function matchCheck(command,config,configFile,dir,scripts){
|
|
280
348
|
const match=matched(command,config,configFile,dir,scripts);
|
|
281
349
|
return match?.row?match.row.checks.join(' && '):match?.check||null;
|
|
@@ -327,7 +395,8 @@ export function verdict(status,name,rest=null){
|
|
|
327
395
|
(rest===null?'':` Nothing else in the command ran either; run the rest on its own: ${rest}`);
|
|
328
396
|
}
|
|
329
397
|
|
|
330
|
-
|
|
398
|
+
// The status answer, synchronized unless `sync` is false (the cached one).
|
|
399
|
+
function synchronizedStatus(entry,config,root,timeout,sync=true){
|
|
331
400
|
return new Promise(resolve=>{
|
|
332
401
|
let child,out='',done=false;
|
|
333
402
|
const finish=value=>{if(done)return;done=true;clearTimeout(timer);process.off('SIGTERM',stop);
|
|
@@ -337,7 +406,7 @@ function synchronizedStatus(entry,config,root,timeout){
|
|
|
337
406
|
// Claude Code ends a hook that outlives its own timeout; end the status call with it.
|
|
338
407
|
const stop=()=>finish(null);process.once('SIGTERM',stop);
|
|
339
408
|
// Its own process group on POSIX, so a timeout also ends the engine it starts.
|
|
340
|
-
try{child=spawn(process.execPath,[entry,'--config',config,'status'
|
|
409
|
+
try{child=spawn(process.execPath,[entry,'--config',config,'status',...(sync?['--sync']:[]),'--json'],
|
|
341
410
|
{cwd:root,stdio:['ignore','pipe','ignore'],env:process.env,windowsHide:true,detached:process.platform!=='win32'});}
|
|
342
411
|
catch{return finish(null);}
|
|
343
412
|
child.on('error',()=>finish(null));
|
|
@@ -402,8 +471,27 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
|
402
471
|
if(!entered)return null;
|
|
403
472
|
let scripts={};
|
|
404
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
|
+
}
|
|
405
491
|
const match=matched(entered.command,parsed,record.config,entered.dir,scripts);
|
|
406
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;
|
|
407
495
|
// turbo, nx and `pnpm -r` cache their own results: skip only when every
|
|
408
496
|
// check the command runs is CURRENT, and otherwise leave it alone.
|
|
409
497
|
if(match.fanout){
|
|
@@ -413,10 +501,6 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
|
413
501
|
reason=fanoutVerdict(status,names,match.rest);
|
|
414
502
|
return reason?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}:null;
|
|
415
503
|
}
|
|
416
|
-
// The configured config path as the command's directory sees it.
|
|
417
|
-
// Both sides resolved alike: Windows can spell the same directory RUNNER~1 or by its long name.
|
|
418
|
-
const configPath=!entered.prefix?record.config:
|
|
419
|
-
path.relative(path.join(project.projectRoot,entered.dir),realObservedPath(config)).split(path.sep).join('/');
|
|
420
504
|
// A compiler script: its checks in the order of its runs, or nothing when
|
|
421
505
|
// they no longer correspond. One run is an ordinary check. A chain is
|
|
422
506
|
// skipped when every run is CURRENT, else the runs that are not run through
|
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
|
package/src/test-direct.mjs
CHANGED
|
@@ -430,13 +430,18 @@ function storePath(files,file,st){
|
|
|
430
430
|
// files, which is a change REDUE sees. `cache` (in owned state) keeps each
|
|
431
431
|
// file found in the store with its identity and store path, so an unchanged
|
|
432
432
|
// file is not hashed again; its store link is still checked.
|
|
433
|
-
|
|
433
|
+
// Without a pnpm store, a hard-linked installed file is accepted only when
|
|
434
|
+
// every one of its links is an installed file the check observes (`observed`,
|
|
435
|
+
// installed-observation.mjs): esbuild links its binary into its platform
|
|
436
|
+
// package. Each accepted file's link count becomes a run fact in `facts`, so
|
|
437
|
+
// a link added later is noticed before the next answer.
|
|
438
|
+
export function sharedInstalledFile(root,project={},cache=null,{observed=()=>false,facts=[]}={}){
|
|
434
439
|
const stack=modulesDirs(project).map(dir=>path.join(root,dir));
|
|
435
440
|
const files=pnpmStoreFiles(root),now=Date.now();
|
|
436
441
|
let known={},changed=false;
|
|
437
442
|
if(files&&cache)try{const value=JSON.parse(fs.readFileSync(cache,'utf8'));
|
|
438
443
|
if(value?.files===files&&value.entries&&typeof value.entries==='object')known=value.entries;}catch{}
|
|
439
|
-
const found={};
|
|
444
|
+
const found={},links=new Map();
|
|
440
445
|
const shared=(file,reason)=>({file:path.relative(root,file).split(path.sep).join('/'),reason});
|
|
441
446
|
while(stack.length){
|
|
442
447
|
const dir=stack.pop();let entries;
|
|
@@ -447,7 +452,14 @@ export function sharedInstalledFile(root,project={},cache=null){
|
|
|
447
452
|
if(!entry.isFile())continue;
|
|
448
453
|
let st;try{st=fs.lstatSync(file,{bigint:true});}catch{continue;}
|
|
449
454
|
if(st.nlink<=1n)continue;
|
|
450
|
-
if(!files)
|
|
455
|
+
if(!files){
|
|
456
|
+
// Distinct observed links by real path: a directory walked twice
|
|
457
|
+
// through a link counts once.
|
|
458
|
+
const key=`${st.dev}:${st.ino}`,group=links.get(key)??{file,nlink:st.nlink,seen:new Set()};
|
|
459
|
+
if(group.nlink!==st.nlink)return shared(file,'had its hard links change while REDUE checked them');
|
|
460
|
+
if(observed(file))try{group.seen.add(fs.realpathSync(file));}catch{}
|
|
461
|
+
links.set(key,group);continue;
|
|
462
|
+
}
|
|
451
463
|
const key=`${st.dev}:${st.ino}`,cached=known[key];
|
|
452
464
|
let at=Array.isArray(cached)&&cached[0]===identity(st)&&typeof cached[1]==='string'&&
|
|
453
465
|
sameFile(path.join(files,cached[1]),st)?cached[1]:null;
|
|
@@ -463,6 +475,10 @@ export function sharedInstalledFile(root,project={},cache=null){
|
|
|
463
475
|
else changed=true;
|
|
464
476
|
}
|
|
465
477
|
}
|
|
478
|
+
for(const group of links.values()){
|
|
479
|
+
if(BigInt(group.seen.size)<group.nlink)return shared(group.file,'has another hard link that is not an installed file this check observes, and the install records no pnpm store');
|
|
480
|
+
facts.push(['linkCount',group.file,Number(group.nlink)]);
|
|
481
|
+
}
|
|
466
482
|
if(files&&cache&&(changed||Object.keys(found).length!==Object.keys(known).length))
|
|
467
483
|
try{atomicJson(cache,{files,entries:found});}catch{}
|
|
468
484
|
return null;
|
package/src/typescript-list.mjs
CHANGED
|
@@ -15,6 +15,8 @@ export function queryAnswer(kind,file,options) {
|
|
|
15
15
|
if(kind==='readFile')return hash(fs.readFileSync(file,'utf8'));
|
|
16
16
|
if(kind==='fileExists')return fs.statSync(file).isFile();
|
|
17
17
|
if(kind==='directoryExists')return fs.statSync(file).isDirectory();
|
|
18
|
+
// How many hard links a file has: a link added elsewhere changes it.
|
|
19
|
+
if(kind==='linkCount')return fs.lstatSync(file).nlink;
|
|
18
20
|
// The compiler resolves through realpathSync.native outside Windows.
|
|
19
21
|
if(kind==='realpath')return process.platform==='win32'?fs.realpathSync(file):fs.realpathSync.native(file);
|
|
20
22
|
if(kind==='directories')return fs.readdirSync(file,{withFileTypes:true})
|
|
@@ -44,7 +46,7 @@ export function queryAnswer(kind,file,options) {
|
|
|
44
46
|
export function runFactChange(facts) {
|
|
45
47
|
if(!Array.isArray(facts))return 'an unreadable run fact';
|
|
46
48
|
for(const fact of facts){
|
|
47
|
-
if(!Array.isArray(fact)||!['fileExists','directoryExists','realpath','readFile','entries'].includes(fact[0])||
|
|
49
|
+
if(!Array.isArray(fact)||!['fileExists','directoryExists','realpath','readFile','entries','linkCount'].includes(fact[0])||
|
|
48
50
|
typeof fact[1]!=='string')return 'an unreadable run fact';
|
|
49
51
|
const [kind,file,answer]=fact;
|
|
50
52
|
let now;try{now=queryAnswer(kind,file);}catch{return file;}
|