@redue/cli 0.1.0-alpha.17 → 0.1.0-alpha.19

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.
@@ -1,7 +1,8 @@
1
1
  # Direct test recipe (Vitest and Jest)
2
2
 
3
- `redue init --recipe tests-direct --check test` records a project's unit test
4
- run and reports later whether that result still applies to the files you have.
3
+ `redue init` (or `redue init --recipe tests-direct --check test` for this recipe
4
+ alone) records a project's unit test run and reports later whether that result
5
+ still applies to the files you have.
5
6
  It supports a single-package project installed by npm, pnpm 9-12 (isolated or
6
7
  hoisted `node_modules`) or Yarn with the `node-modules` linker, whose test
7
8
  script runs Vitest 4 or 5, or Jest 29, directly. It runs on macOS and Linux with
@@ -35,7 +36,8 @@ runner directly, otherwise the only script that does. The script must be one
35
36
  runner invocation: no `&&`, quotes, globs or variable assignments. Watch mode,
36
37
  `--changed`, `related`, snapshot updates (`-u`), shuffling, sharding and
37
38
  Jest's `--onlyChanged` are refused. Vitest needs `run`, `--run` or
38
- `--watch=false`. Other scripts stay recording-only.
39
+ `--watch=false`. Other test scripts stay recording-only: the
40
+ [general recipe](node-script.md) covers typecheck and lint scripts, not tests.
39
41
 
40
42
  REDUE runs the local runner with Node directly. The package manager, its
41
43
  lifecycle scripts and `package.json` are not changed or executed.
@@ -79,6 +81,10 @@ kept but marked UNVERIFIED, with the reason, when the run:
79
81
  about a name other than localhost;
80
82
  - started a process that did not load the recorder: a shell, a binary, or a
81
83
  Node process with its own environment;
84
+ - had an installed file with another hard link, other than a link into the
85
+ pnpm store the install recorded in `node_modules/.modules.yaml` (pnpm's
86
+ default on Linux). A change through a link elsewhere, such as Yarn's
87
+ hardlinks modes, is not seen.
82
88
 
83
89
  Lookups that can be asked again become run facts, checked before every CURRENT
84
90
  answer: whether a path outside the checkout exists, where a link resolves, the
@@ -119,4 +125,8 @@ Tests that write files git does not ignore change the run's inputs.
119
125
  are not indexed at start), makes the result STALE.
120
126
  - A config written by an earlier build of this recipe is UNVERIFIED with "run
121
127
  redue init again"; move `redue.config.json` aside and run init.
128
+ - pnpm's store is trusted not to change a file in place, as pnpm never does.
129
+ Editing an installed file in another project that shares the same store
130
+ file changes it here too, unseen. Use `pnpm patch`, or a copying install
131
+ (`package-import-method=copy`), if you edit installed files.
122
132
  - Windows is not supported; the check stays recording-only there.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@redue/cli",
3
3
  "private": false,
4
- "version": "0.1.0-alpha.17",
4
+ "version": "0.1.0-alpha.19",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"
@@ -11,7 +11,7 @@
11
11
  "redue": "./bin/redue.mjs"
12
12
  },
13
13
  "scripts": {
14
- "test": "node --test --test-concurrency=1 test/*.test.mjs",
14
+ "test": "node test/run-suite.mjs",
15
15
  "test:package": "node test/acceptance/package-smoke.mjs",
16
16
  "demo": "node test/acceptance/package-smoke.mjs --demo",
17
17
  "pretest": "npm --prefix test/fixtures/typescript-5-5-2 ci --no-audit --no-fund && npm --prefix test/fixtures/typescript-6-0-2 ci --no-audit --no-fund && npm --prefix test/fixtures/typescript-6-0-3 ci --no-audit --no-fund"
@@ -26,7 +26,7 @@ const sameJson=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
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
28
  function hookFor(config,relative,scripts){
29
- const rules=[...new Set([...hookForms(config,relative).flatMap(row=>[...row.forms,...row.local]),
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
31
  return rules.length?{matcher:'Bash',hooks:rules.map(rule=>({type:'command',if:rule,command:hookCommand,timeout:15}))}:null;
32
32
  }
@@ -99,17 +99,23 @@ function saved(root,host){const text=read(root,marker(host));if(text===null)retu
99
99
  return value;
100
100
  }
101
101
  // The managed block is always in the agent's context, so it stays short. For
102
- // Claude Code it names the typecheck and test commands the hook matches, asks
102
+ // Claude Code it names the typecheck, test and lint commands the hook matches, asks
103
103
  // the agent to run them rather than judge for itself that a rerun is not
104
104
  // needed, and says what a skip means. The hook does the rest, so the block
105
105
  // does not point to the skill; the skill stays for agents that look for it.
106
106
  const quoted=forms=>forms.map(form=>`\`${form}\``).join(' and ');
107
- const commandsOf=rows=>[rows.filter(row=>row.kind!=='test').map(row=>row.form),rows.filter(row=>row.kind==='test').map(row=>row.form)];
107
+ const commandsOf=rows=>[rows.filter(row=>row.kind!=='test'&&row.kind!=='lint').map(row=>row.form),
108
+ rows.filter(row=>row.kind==='test').map(row=>row.form),rows.filter(row=>row.kind==='lint').map(row=>row.form)];
108
109
  const fence=(host,body)=>`\n\n<!-- REDUE:${host}:begin -->\n${body}<!-- REDUE:${host}:end -->\n`;
109
110
  const skillPointer=host=>`Before you run, skip or report a typecheck or other configured check, read\n${hosts[host].skill}.\n`;
110
111
  function blockFor(host,rows=[]){
111
- const [typechecks,tests]=commandsOf(rows);
112
+ const [typechecks,tests,lints]=commandsOf(rows);
112
113
  if(!rows.length)return fence(host,skillPointer(host));
114
+ if(lints.length)return fence(host,(typechecks.length?`Typecheck with ${quoted(typechecks)}.\n`:'')+
115
+ (tests.length?`Run the unit tests with ${quoted(tests)}.\n`:'')+`Lint with ${quoted(lints)}.\n`+
116
+ `Run a check before you say it passes, even after an edit you think cannot\n`+
117
+ `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`);
113
119
  if(!tests.length)return fence(host,`Typecheck with ${quoted(typechecks)}. Run it before you say it passes, even after an\n`+
114
120
  `edit you think cannot matter; don't skip it yourself. If its last pass still\n`+
115
121
  `applies to these exact files, a REDUE hook skips the run and says so. That\n`+
@@ -134,7 +140,7 @@ function earlierBlock(host,rows=[]){
134
140
  }
135
141
  // One form per check: the package manager's script form when there is one,
136
142
  // since that is what the project's own instructions usually name.
137
- const blockForms=(host,config,relative)=>host!=='claude'?[]:hookForms(config,relative).map(row=>({kind:row.kind,
143
+ const blockForms=(host,config,relative)=>host!=='claude'?[]:hookForms(config,relative).filter(row=>!row.part).map(row=>({kind:row.kind,
138
144
  form:row.forms.find(form=>/^(?:npm run|pnpm|yarn) /.test(form))??row.forms.find(form=>!form.startsWith('redue '))??row.forms[0]}));
139
145
  const priorBlocks=(host,relative)=>[false,true].map(legacy=>`\n\n<!-- REDUE:${host}:begin -->\n`+
140
146
  `Before deciding whether configured verification needs repeating, use REDUE.\n`+
@@ -157,7 +163,8 @@ function knownBlock(host,relative,block){
157
163
  const list='((?:`[A-Za-z0-9_./:=,+@ -]+`(?: and )?)+)';
158
164
  const forms=pattern=>{const named=new RegExp(pattern).exec(block);return named?[...named[1].matchAll(/`([^`]+)`/g)].map(m=>m[1]):[];};
159
165
  const rows=[...forms(`^\\n\\n<!-- REDUE:[a-z]+:begin -->\\nTypecheck with ${list}\\.`).map(form=>({kind:'typecheck',form})),
160
- ...forms(`Run the unit tests with ${list}\\.\\n`).map(form=>({kind:'test',form}))];
166
+ ...forms(`Run the unit tests with ${list}\\.\\n`).map(form=>({kind:'test',form})),
167
+ ...forms(`Lint with ${list}\\.\\n`).map(form=>({kind:'lint',form}))];
161
168
  return block===blockFor(host,rows)||block===earlierBlock(host,rows)||priorBlocks(host,relative).includes(block);
162
169
  }
163
170
  // The policy with the exact commands for this config, so an agent never builds
@@ -166,8 +173,9 @@ function knownBlock(host,relative,block){
166
173
  function skillFor(relative,host,config){
167
174
  // Double quotes work in POSIX shells, cmd and PowerShell for ordinary names.
168
175
  const redue=redueFor(relative)??`redue --config ${JSON.stringify(relative)}`,status=`${redue} status --sync --json`;
169
- const rows=host==='claude'?hookForms(config,relative):[];
176
+ const rows=host==='claude'?hookForms(config,relative).filter(row=>!row.part):[];
170
177
  const label=row=>row.kind==='test'?(row.check==='test'?'Unit tests':`Unit tests \`${row.check}\``):
178
+ row.kind==='lint'?(row.check==='lint'?'Lint':`Lint \`${row.check}\``):
171
179
  row.check==='typecheck'?'Typecheck':`Typecheck \`${row.check}\``;
172
180
  const commands=rows.map(row=>`- ${label(row)}: `+
173
181
  `${row.forms.map(form=>`\`${form}\``).join(' or ')}.\n`).join('')+
@@ -183,7 +191,7 @@ function skillFor(relative,host,config){
183
191
  // typecheck needs only the CLAUDE.md block, so the description must not
184
192
  // send the agent here for one.
185
193
  const description=rows.length?'Rules and exact commands for redue status, redue run and other redue commands. '+
186
- `A plain ${rows.some(row=>row.kind==='test')?'typecheck or test run':'typecheck'} needs only the REDUE section of CLAUDE.md.`:
194
+ `A plain ${rows.some(row=>row.kind==='lint')?'typecheck, test or lint run':rows.some(row=>row.kind==='test')?'typecheck or test run':'typecheck'} needs only the REDUE section of CLAUDE.md.`:
187
195
  'Use before running, skipping or reporting a typecheck or other configured check.';
188
196
  // Replacer functions, so `$` in a config name is never a replacement pattern.
189
197
  return policy.replace('{{DESCRIPTION}}',()=>description).replace('{{CONFIG}}',()=>JSON.stringify(relative)).replace('{{COMMANDS}}',()=>commands.trimEnd())
@@ -1,4 +1,4 @@
1
- // Claude Code PreToolUse hook: skip an exact typecheck or test rerun when REDUE
1
+ // Claude Code PreToolUse hook: skip an exact typecheck, test or lint rerun when REDUE
2
2
  // reports CURRENT/PASS with reuse_eligible on the current inputs. When the check is not
3
3
  // CURRENT, the command becomes `redue run <check>` so the run is recorded. Every
4
4
  // other outcome, including errors and timeouts, allows the command unchanged.
@@ -10,6 +10,8 @@ import {spawn} 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
+ import {scriptRunChecks} from './typescript-invocation.mjs';
14
+ import {recipeLabel} from './presentation.mjs';
13
15
 
14
16
  export const hookCommand='redue agent hook claude';
15
17
  // No shell metacharacters; `@` starts scoped package names.
@@ -18,7 +20,11 @@ const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
18
20
  // A direct test check: its command is the runner and the script's arguments.
19
21
  const tests=check=>check?.qualification==='npm-tests-direct-v1'&&Array.isArray(check.command)&&
20
22
  /^@test-runner:(?:vitest|jest)$/.test(check.command[1]||'');
21
- export const checkKind=check=>tests(check)?'test':'typecheck';
23
+ // A general recipe check: a root typecheck, test or lint script run through the package manager.
24
+ const general=check=>check?.qualification==='node-script-v1'&&['typecheck','test','lint'].includes(check.kind)&&
25
+ typeof check.script==='string';
26
+ const eligibleCheck=check=>typecheck(check)||tests(check)||general(check);
27
+ export const checkKind=check=>tests(check)?'test':general(check)?check.kind:'typecheck';
22
28
 
23
29
  export const defaultConfig='redue.config.json';
24
30
  // The `redue` prefix for a config beside the project root; null when the name
@@ -52,8 +58,12 @@ function workspaceForms(pm,name,script){
52
58
  `npm run ${script} --workspace=${name}`,`npm -w ${name} run ${script}`];
53
59
  return [];
54
60
  }
55
- // The tool a check runs, as typed: `tsc ARGS` or the test runner's command.
56
- function toolText(check){
61
+ // The tool a check runs, as typed: `tsc ARGS` or the test runner's command;
62
+ // for a general check, its script when the script is one plain command.
63
+ function toolText(check,scripts){
64
+ if(general(check)){const text=typeof scripts?.[check.script]==='string'?scripts[check.script].trim().replace(/\s+/g,' '):'';
65
+ return safe.test(text.replace(/ /g,''))&&!/^(?:npm|npx|pnpm|pnpx|yarn|node)(?: |$)/.test(text)&&
66
+ !scripts['pre'+check.script]&&!scripts['post'+check.script]?text:null;}
57
67
  const command=check.command;
58
68
  if(!Array.isArray(command)||command[0]!=='@node')return null;
59
69
  const words=/^@typescript-(?:compiler|bin):/.test(command[1]||'')?['tsc',...command.slice(2)]:
@@ -91,29 +101,65 @@ const checkScript=check=>typeof check.script==='string'?check.script:
91
101
  Array.isArray(check.command)&&/^@which:(?:npm|pnpm|yarn)$/.test(check.command[0])&&check.command.includes('run')?
92
102
  check.command[check.command.indexOf('run')+1]:null;
93
103
 
104
+ // A typecheck check whose command is the compiler: its arguments are one tsc
105
+ // run of its script (typescript-invocation.mjs).
106
+ function tscCheck(check){return typecheck(check)&&/^tsc(?: |$)/.test(toolText(check)||'');}
94
107
  // Exact commands that rerun each configured typecheck or direct test check.
95
108
  // `forms` work from the project root; `local` ones work in the check's own
96
109
  // directory `dir`, after a `cd` into it. Names that would need shell quoting
97
- // get no form, so the hook never fires for them.
98
- export function hookForms(config,configFile=defaultConfig){
99
- const rows=[],redue=redueFor(configFile),pm=config?.packageManager;
100
- for(const check of config?.checks||[]){
101
- if(!(typecheck(check)||tests(check))||typeof check.name!=='string'||!safe.test(check.name))continue;
102
- const forms=redue?[`${redue} run ${check.name}`]:[],local=[],dir=checkDir(check),script=checkScript(check);
110
+ // get no form, so the hook never fires for them. The script forms of compiler
111
+ // checks name the script's checks as {checks, script}: the hook acts on them
112
+ // only when the script's tsc runs are those checks' runs, one each. With one
113
+ // check they stay on its row (`scriptForms`); a chain's checks get a row for
114
+ // the script, and their own rows (`part`) keep only their own forms.
115
+ export function hookForms(config,configFile=defaultConfig,packageScripts=null){
116
+ const rows=[],redue=redueFor(configFile),pm=config?.packageManager,scripts=new Map();
117
+ const eligible=(config?.checks||[]).filter(check=>eligibleCheck(check)&&typeof check.name==='string'&&safe.test(check.name));
118
+ const groupOf=check=>tscCheck(check)&&typeof checkScript(check)==='string'&&checkDir(check)!==null?
119
+ JSON.stringify([checkDir(check),checkScript(check),workspaceName(check)]):null;
120
+ for(const check of eligible){const key=groupOf(check);if(key)scripts.set(key,[...scripts.get(key)||[],check.name]);}
121
+ const scriptRow=(dir,script,workspace)=>({forms:dir==='.'?scriptForms(pm,script):dir!==null&&safe.test(dir)?workspaceForms(pm,workspace,script):[],
122
+ local:dir!=='.'&&dir!==null&&safe.test(dir)?scriptForms(pm,script):[]});
123
+ for(const check of eligible){
124
+ const forms=redue?[`${redue} run ${check.name}`]:[],local=[],dir=checkDir(check),key=groupOf(check),
125
+ group=key?scripts.get(key):null,part=group?.length>1;
126
+ const own=group?null:checkScript(check);
103
127
  if(dir==='.'){
104
- forms.push(...scriptForms(pm,script),...toolForms(pm,toolText(check)));
128
+ forms.push(...scriptForms(pm,own),...toolForms(pm,toolText(check,packageScripts)));
105
129
  if(!tests(check)&&Array.isArray(check.command)&&check.command.length&&
106
130
  check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
107
131
  forms.push(check.command.join(' '));
108
132
  }else if(dir!==null&&safe.test(dir)){
109
- forms.push(...workspaceForms(pm,workspaceName(check),script));
110
- local.push(...scriptForms(pm,script),...(check.cwd===dir?toolForms(pm,toolText(check)):[]));
133
+ forms.push(...workspaceForms(pm,workspaceName(check),own));
134
+ local.push(...scriptForms(pm,own),...(check.cwd===dir?toolForms(pm,toolText(check)):[]));
111
135
  }
136
+ let extra={};
137
+ if(group&&!part){
138
+ const {forms:scriptOnly,local:scriptLocal}=scriptRow(dir,checkScript(check),workspaceName(check));
139
+ forms.splice(redue?1:0,0,...scriptOnly);local.unshift(...scriptLocal);
140
+ extra={checks:group,script:checkScript(check),scriptForms:[...scriptOnly,...scriptLocal]};
141
+ }
142
+ if(part)extra={part:true};
112
143
  if(forms.length||local.length)rows.push({check:check.name,kind:checkKind(check),dir,
113
- forms:[...new Set(forms)],local:[...new Set(local)]});
144
+ forms:[...new Set(forms)],local:[...new Set(local)],...extra});
145
+ }
146
+ for(const [key,checks] of scripts){
147
+ if(checks.length<2)continue;
148
+ const [dir,script,workspace]=JSON.parse(key),{forms,local}=scriptRow(dir,script,workspace);
149
+ if(forms.length||local.length)rows.push({check:checks[0].replace(/\.\d+$/,''),checks,script,
150
+ scriptForms:[...forms,...local],kind:'typecheck',dir,forms,local});
114
151
  }
115
152
  return rows;
116
153
  }
154
+ // The checks a compiler script row runs, in the order of the script's runs in
155
+ // the package's package.json now; null when they no longer correspond (the
156
+ // script changed since init), so the hook leaves the command alone.
157
+ export function scriptChecks(row,config,projectRoot){
158
+ let scripts;try{scripts=JSON.parse(fs.readFileSync(path.join(projectRoot,row.dir,'package.json'),'utf8')).scripts;}catch{return null;}
159
+ if(scripts?.['pre'+row.script]||scripts?.['post'+row.script])return null;
160
+ const args=name=>config.checks.find(check=>check.name===name)?.command?.slice(2);
161
+ return scriptRunChecks(scripts?.[row.script],row.checks.map(name=>({name,args:args(name)})));
162
+ }
117
163
 
118
164
  // A turbo or `pnpm -r` command that runs one task in every workspace package:
119
165
  // {tool, task}. No filters and no nx: both can add packages or tasks that only
@@ -130,7 +176,7 @@ export function fanout(text){
130
176
  // Bash `if` rules for fan-out commands of a task some workspace check runs,
131
177
  // and for root scripts that run one; other turbo or nx commands never start the hook.
132
178
  export function fanoutRules(config,scripts={}){
133
- const tasks=new Set((config?.checks||[]).filter(check=>(typecheck(check)||tests(check))&&typeof check.name==='string'&&
179
+ const tasks=new Set((config?.checks||[]).filter(check=>eligibleCheck(check)&&typeof check.name==='string'&&
134
180
  ![null,'.'].includes(checkDir(check))).map(checkScript).filter(task=>typeof task==='string'&&/^[A-Za-z0-9_:.-]+$/.test(task)));
135
181
  const rules=[];
136
182
  for(const task of tasks){
@@ -166,10 +212,17 @@ export async function fanoutChecks(fan,config,projectRoot){
166
212
  return null;
167
213
  const names=[];
168
214
  for(const item of packages){
169
- const check=config.checks.find(c=>(typecheck(c)||tests(c))&&typeof c.name==='string'&&
215
+ const own=config.checks.filter(c=>eligibleCheck(c)&&typeof c.name==='string'&&
170
216
  workspaceName(c)===item.name&&checkScript(c)===fan.task&&checkDir(c)===item.dir);
171
- if(!check)return null;
172
- names.push(check.name);
217
+ if(!own.length)return null;
218
+ // A compiler script's checks: one per tsc run it has now.
219
+ if(own.every(tscCheck)){
220
+ const ordered=scriptRunChecks(item.scripts[fan.task],own.map(c=>({name:c.name,args:c.command.slice(2)})));
221
+ if(!ordered)return null;
222
+ names.push(...ordered);continue;
223
+ }
224
+ if(own.length!==1)return null;
225
+ names.push(own[0].name);
173
226
  }
174
227
  return names;
175
228
  }
@@ -191,6 +244,10 @@ function split(command){
191
244
  function matched(command,config,configFile,dir='.',scripts={}){
192
245
  const parts=split(command);
193
246
  if(!parts)return null;
247
+ const row=hookForms(config,configFile,dir==='.'?scripts:null).find(row=>dir==='.'?row.forms.includes(parts.base):
248
+ row.dir===dir&&row.local.includes(parts.base));
249
+ // A root script that a general check runs is that check, even when the script fans out.
250
+ if(dir==='.'&&row&&general(config.checks.find(check=>check.name===row.check)))return {check:row.check,rest:parts.rest};
194
251
  if(dir==='.'){
195
252
  // A root script that runs a fan-out is that fan-out, unless pre/post scripts
196
253
  // run around it: then it matches nothing.
@@ -199,18 +256,21 @@ function matched(command,config,configFile,dir='.',scripts={}){
199
256
  return scripts['pre'+name]||scripts['post'+name]?null:{fanout:fanout(text.trim()),rest:parts.rest};
200
257
  if(fanout(parts.base))return {fanout:fanout(parts.base),rest:parts.rest};
201
258
  }
202
- const check=hookForms(config,configFile).find(row=>dir==='.'?row.forms.includes(parts.base):
203
- row.dir===dir&&row.local.includes(parts.base))?.check;
204
- return check?{check,rest:parts.rest}:null;
259
+ // A script form of compiler checks is checked against the script first.
260
+ return row?.scriptForms?.includes(parts.base)?{row,rest:parts.rest}:row?{check:row.check,rest:parts.rest}:null;
205
261
  }
206
- export function matchCheck(command,config,configFile,dir,scripts){return matched(command,config,configFile,dir,scripts)?.check||null;}
207
- // The same check through REDUE, keeping the output suffix; null if it already
208
- // is, or if more commands follow it.
262
+ export function matchCheck(command,config,configFile,dir,scripts){
263
+ const match=matched(command,config,configFile,dir,scripts);
264
+ return match?.row?match.row.checks.join(' && '):match?.check||null;
265
+ }
266
+ // The same check (or checks, in order) through REDUE, keeping the output
267
+ // suffix; null if it already is, or if more commands follow it.
209
268
  export function recordedCommand(command,name,configFile=defaultConfig){
269
+ const names=Array.isArray(name)?name:[name];
210
270
  const parts=split(command),redue=redueFor(configFile);
211
- if(!parts||parts.rest!==null||!safe.test(name)||!redue)return null;
212
- const run=`${redue} run ${name}`;
213
- return parts.base===run?null:run+parts.suffix;
271
+ if(!parts||parts.rest!==null||!names.length||!names.every(value=>safe.test(value))||!redue)return null;
272
+ const run=names.map(value=>`${redue} run ${value}`).join(' && ');
273
+ return parts.base===run?null:names.length>1&&parts.suffix?`(${run})${parts.suffix}`:run+parts.suffix;
214
274
  }
215
275
 
216
276
  // A leading `cd DIR;` or `cd DIR &&`, which agents often add before a check:
@@ -243,7 +303,8 @@ export function verdict(status,name,rest=null){
243
303
  const runId=row?.invocation?.runId;
244
304
  if(!row||row.freshness!=='CURRENT'||row.result!=='PASS'||row.reuse_eligible!==true||
245
305
  typeof runId!=='string'||!/^[0-9a-f-]{36}$/.test(runId))return null;
246
- return `REDUE: check ${JSON.stringify(name)} passed on these exact inputs (run ${runId}); `+
306
+ const recipe=recipeLabel(row.recipe);
307
+ return `REDUE: check ${JSON.stringify(name)} passed on these exact inputs (run ${runId}${recipe?`, ${recipe}`:''}); `+
247
308
  'rerun skipped. This counts as a pass for the current files; no status check is needed. '+
248
309
  'To run it anyway, prefix the command with REDUE_FORCE=1.'+
249
310
  (rest===null?'':` Nothing else in the command ran either; run the rest on its own: ${rest}`);
@@ -277,9 +338,11 @@ export function statusTimeout(status,name){
277
338
  Math.min(run.durationMs/2,10000):2000;
278
339
  }
279
340
  // From the observer's last published status, which is small, not the receipts.
280
- export function lastRunTimeout(config,parsed,name){
341
+ // The longest wait among `names`, read once.
342
+ export function lastRunTimeout(config,parsed,...names){
281
343
  try{const root=realObservedPath(path.resolve(path.dirname(config),parsed.root||'.'));
282
- return statusTimeout(JSON.parse(fs.readFileSync(path.join(projectState(root,config),'status.json'),'utf8')),name);}
344
+ const status=JSON.parse(fs.readFileSync(path.join(projectState(root,config),'status.json'),'utf8'));
345
+ return Math.max(...names.map(name=>statusTimeout(status,name)));}
283
346
  catch{return 2000;}
284
347
  }
285
348
 
@@ -329,10 +392,34 @@ export async function claudeHook(input,{entry,timeout}){
329
392
  if(match.fanout){
330
393
  const names=await fanoutChecks(match.fanout,parsed,project.projectRoot);
331
394
  if(!names)return null;
332
- const status=await synchronizedStatus(entry,config,root,timeout??Math.max(...names.map(name=>lastRunTimeout(config,parsed,name)))),
395
+ const status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,...names)),
333
396
  reason=fanoutVerdict(status,names,match.rest);
334
397
  return reason?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}:null;
335
398
  }
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
+ // A compiler script: its checks in the order of its runs, or nothing when
404
+ // they no longer correspond. One run is an ordinary check. A chain is
405
+ // skipped when every run is CURRENT, else the runs that are not run through
406
+ // redue run, in order.
407
+ if(match.row){
408
+ const names=scriptChecks(match.row,parsed,project.projectRoot);
409
+ if(!names)return null;
410
+ if(names.length===1)match.check=names[0];
411
+ else{
412
+ const status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,...names)),
413
+ reason=fanoutVerdict(status,names,match.rest);
414
+ if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}};
415
+ const pending=names.filter(name=>!verdict(status,name));
416
+ 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;
418
+ return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
419
+ 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
+ updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null;
421
+ }
422
+ }
336
423
  const name=match.check,status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,name)),
337
424
  reason=verdict(status,name,match.rest);
338
425
  if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',
@@ -342,9 +429,7 @@ export async function claudeHook(input,{entry,timeout}){
342
429
  // stays too: the rewrite returns "allow", which would also skip the prompt
343
430
  // for the rest of the chain.
344
431
  const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
345
- status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,
346
- // Both sides resolved alike: Windows can spell the same directory RUNNER~1 or by its long name.
347
- !entered.prefix?record.config:path.relative(path.join(project.projectRoot,entered.dir),realObservedPath(config)).split(path.sep).join('/')):null;
432
+ status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath):null;
348
433
  // updatedInput replaces the whole tool input. Deny and ask rules still apply
349
434
  // to the rewritten command; "allow" skips only the prompt.
350
435
  return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
package/src/cli.mjs CHANGED
@@ -3,8 +3,9 @@ import {builtInProbe,probeFloorMs,publishedDeadline} from './probe-deadline.mjs'
3
3
  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
- import {runFactChange} from './typescript-list.mjs';
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
9
  import path from 'node:path';
9
10
  import net from 'node:net';
10
11
  import os from 'node:os';
@@ -69,7 +70,8 @@ const readsFile=path.join(state,'environment-reads-v1.json');
69
70
  function environmentReads() {
70
71
  try{const value=JSON.parse(fs.readFileSync(readsFile,'utf8'));return value?.schema===1&&value.checks||{};}catch{return {};}
71
72
  }
72
- function contextHashes() {const reads=config.checks.some(check=>check.qualification===testsQualification)?environmentReads():{};
73
+ const recordedRecipes=[testsQualification,scriptQualification];
74
+ function contextHashes() {const reads=config.checks.some(check=>recordedRecipes.includes(check.qualification))?environmentReads():{};
73
75
  return Object.fromEntries(config.checks.map(check=>[
74
76
  check.name,Object.fromEntries([...(check.environment?.variables||[])
75
77
  .map(key=>[key,digest(process.env[key]??'<unset>')]),
@@ -271,20 +273,28 @@ async function execute() {
271
273
  const recorded=directRunRecordable(selected)&&!unobservedDirectRun;
272
274
  const launched=recorded?[command[0],'--require',path.join(here,'run-recorder.cjs'),
273
275
  ...command.slice(1)]:command;
274
- // A test run's processes record what the guard checks (test-recorder.cjs).
275
- const testRun=selected.qualification===testsQualification&&['darwin','linux'].includes(process.platform);
276
- let testRecord=null,launchEnv=process.env,testFacts={};
276
+ // A test run's processes record what the guard checks (test-recorder.cjs),
277
+ // and so do a general recipe run's, through the package manager.
278
+ const general=selected.qualification===scriptQualification;
279
+ const testRun=recordedRecipes.includes(selected.qualification)&&['darwin','linux'].includes(process.platform);
280
+ let testRecord=null,launchEnv=process.env,testFacts={},toolchain=[];
277
281
  if(testRun){
278
282
  // The package directory and installed node_modules, as the plan has them.
279
- try{testFacts=testRunFacts(root,selected);}
280
- catch(e){captureIssues.push(`test run facts unavailable (${e.code||e.message})`);}
283
+ try{testFacts=general?scriptRunFacts(root,selected):testRunFacts(root,selected);}
284
+ catch(e){captureIssues.push(`${general?'script':'test'} run facts unavailable (${e.code||e.message})`);}
281
285
  const environmentIssue=testEnvironmentIssue();
282
286
  if(environmentIssue)captureIssues.push(environmentIssue);
283
287
  testRecord=path.join(state,`test-record-${runId}`);
284
288
  fs.mkdirSync(testRecord,{mode:0o700});
285
- const guardFile=path.join(testRecord,'guard.json');
286
- fs.writeFileSync(guardFile,JSON.stringify(testGuardConfig(testRecord,root,process.env,testFacts)),{mode:0o600});
289
+ const guardFile=path.join(testRecord,'guard.json'),guardConfig=testGuardConfig(testRecord,root,process.env,testFacts);
290
+ if(general){toolchain=toolchainDirs();
291
+ // The Node on PATH, which the environment identifies by its file.
292
+ Object.assign(guardConfig,{installed:scriptInstalledPaths(root,testFacts.dirs||[]),toolchain,
293
+ node:lookup('node',process.env.PATH,process.cwd())});}
294
+ fs.writeFileSync(guardFile,JSON.stringify(guardConfig),{mode:0o600});
287
295
  launchEnv=recordedEnvironment(guardFile);
296
+ // npm's and pnpm's update checks read state outside the checkout and the network.
297
+ if(general)launchEnv.npm_config_update_notifier='false';
288
298
  }
289
299
  let runnerPid=null;
290
300
  const record=[];
@@ -357,12 +367,18 @@ async function execute() {
357
367
  }else if(!unobservedDirectRun&&!recording)probeAfter=probeHash(probePlan);
358
368
  let guard=null;
359
369
  if(testRecord){
360
- let coverage=null;try{coverage=coverageReadable(root,testFacts);}catch{}
370
+ let coverage=null;if(!general)try{coverage=coverageReadable(root,testFacts);}catch{}
371
+ const programs=general?{runner:{file:command[0],args:command.slice(1),cwd:plan.cwd,searchPath:process.env.PATH,
372
+ env:{preload:true,bashEnv:Boolean(process.env.BASH_ENV)}},
373
+ judge:(child,main)=>childProgram(child,{toolchain,parentMain:main}),trusted:scriptCaches}:null;
361
374
  guard=testRunGuard(testRecord,{root,sourcePatterns:plan.sourcePatterns||plan.patterns,files:snap?.files,
362
- runner:runnerPid,environment:selected.environment||{},coverage});
375
+ runner:runnerPid,environment:selected.environment||{},coverage,programs});
363
376
  runFacts=guard.runFacts;
364
- const shared=sharedInstalledFile(root,testFacts);
365
- if(shared)guard.issues.push(`installed file ${shared} has another hard link, likely into a package store; a change through it is not observed`);
377
+ // The pnpm executable reads its configuration natively: kept by content.
378
+ if(guard.opaque)for(const file of pnpmConfigFiles())try{runFacts.push(['readFile',file,queryAnswer('readFile',file)]);}
379
+ catch{guard.issues.push(`pnpm configuration ${file} cannot be read`);}
380
+ const shared=sharedInstalledFile(root,testFacts,path.join(state,'pnpm-store-links-v1.json'));
381
+ if(shared)guard.issues.push(`installed file ${shared.file} ${shared.reason}; a change through it is not observed`);
366
382
  fs.rmSync(testRecord,{recursive:true,force:true});
367
383
  }
368
384
  // The daemon reads the recording from owned state; control requests are small.
@@ -392,7 +408,8 @@ async function execute() {
392
408
  const directory=rel=>{try{return fs.lstatSync(path.join(root,rel)).isDirectory();}catch{return false;}};
393
409
  const changed=changedFiles(snap.files,end.files),events=(end.inputEventPaths||[]).filter(rel=>!directory(rel));
394
410
  const touched=[...new Set([...changed,...events])].sort();
395
- const ignored=touched.length&&touched.every(guard.isOutput)?gitIgnored(root,touched):new Set();
411
+ const output=rel=>guard.isOutput(rel)||general&&toolOutput(rel,guard);
412
+ const ignored=touched.length&&touched.every(output)?gitIgnored(root,touched):new Set();
396
413
  if(touched.every(file=>ignored.has(file))){eventsExplained=true;if(changed.length)outputs=changed;}
397
414
  }
398
415
  // The end certificate, recorded or probed, must cover what this compile received.
@@ -447,9 +464,10 @@ async function execute() {
447
464
  }
448
465
  // A recorded run's receipt carries its run facts, even none; observers that
449
466
  // predate run facts do not reuse version 2.
450
- const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,
467
+ // The recipe that made the receipt, which status and the hook name.
468
+ const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,recipe:selected.qualification||null,
451
469
  ...(recorded||testRun?{runFacts}:{}),
452
- ...(testRun?{verificationRecipe:testsInterpretation,guardIssues:guard?.issues||['the run record is unavailable'],
470
+ ...(testRun?{verificationRecipe:general?scriptInterpretation:testsInterpretation,guardIssues:guard?.issues||['the run record is unavailable'],
453
471
  environmentReads:guard?.environmentReads||{},...(nondeterministic?{nondeterministic}:{})}:{}),
454
472
  resolutionLinks:plan.resolutionLinks||[],
455
473
  coverage:{qualified:plan.unresolved.length===0,unresolved:plan.unresolved},
package/src/daemon.mjs CHANGED
@@ -20,7 +20,9 @@ import {inputKey,planGuard,toolchainKey} from './decision-validation.mjs';
20
20
  import {queriesHold,runFactReason as runFactChanged} from './typescript-list.mjs';
21
21
  import {nextProbeHint,probeDeadline,probeFloorMs} from './probe-deadline.mjs';
22
22
  import {statusSummary} from './cached-state.mjs';
23
- import {directProject,directProbeValue,reviewedTypeScriptVersions} from './direct-typescript.mjs';
23
+ import {directProject,directProbeValue} from './direct-typescript.mjs';
24
+ import {npmCacheDir} from './npm-integrity.mjs';
25
+ import {runFromArgv} from './typescript-invocation.mjs';
24
26
  import {buildInfoPath,recordedCertificate} from './direct-run-certificate.mjs';
25
27
  import {timing,mark,traceBase,traceContext,traceScope,traceEnvironment,traceId} from './decision-profile.mjs';
26
28
 
@@ -130,7 +132,7 @@ function continuityBarrier() {
130
132
  // The basis a full direct probe is checked against: plan guard, observation
131
133
  // continuity, and the compiler tree and Node identity taken before the probe.
132
134
  function certificateBasis(row) {
133
- const argv=row.plan.probes[0],compiler=directProject(argv[2],argv[3],{verify:false});
135
+ const argv=row.plan.probes[0],compiler=directProject(argv[2],runFromArgv(argv.slice(3)),{verify:false});
134
136
  return {guard:planGuard(index.bundle),gaps:metrics.gaps,
135
137
  compiler:JSON.stringify([compiler.tsRoot,compiler.entry,compiler.digest]),
136
138
  toolchain:toolchainKey(compiler.tsRoot,argv[0])};
@@ -238,8 +240,18 @@ function probe(row,allowReuse=false,{launch=true}={}) {
238
240
  .includes(row.plan.qualification)?
239
241
  /VSTATE_REASON:([a-z-]+)/.exec(out.stderr?.toString()||'')?.[1]:null;
240
242
  const explanations={
241
- 'direct-typescript-implementation-unreviewed':'installed TypeScript bytes or membership differ from the reviewed compiler',
242
- 'direct-typescript-version-unsupported':`direct compiler recipe supports reviewed TypeScript ${reviewedTypeScriptVersions.join(', ')} only`,
243
+ 'direct-typescript-implementation-unreviewed':'installed TypeScript files differ from the reviewed compiler or from the tarball package-lock.json names',
244
+ 'direct-typescript-version-unsupported':'direct compiler recipe supports TypeScript 5.x and 6.0',
245
+ 'direct-typescript-lockfile-source-unsupported':'package-lock.json installs TypeScript from somewhere other than the registry\'s typescript package (an alias, a file or a git source)',
246
+ 'npm-shrinkwrap-unsupported':'npm-shrinkwrap.json is not read; the reviewed TypeScript releases still qualify',
247
+ 'direct-typescript-lockfile-integrity-missing':'package-lock.json records no integrity for the installed TypeScript',
248
+ 'direct-typescript-lockfile-version-differs':'installed TypeScript version differs from package-lock.json; run npm ci',
249
+ 'npm-lockfile-unreadable':'package-lock.json cannot be read',
250
+ 'npm-lockfile-integrity-unsupported':'package-lock.json records TypeScript integrity with neither sha512 nor sha1',
251
+ 'npm-tarball-uncached':`the TypeScript tarball package-lock.json names is not in npm's cache (${npmCacheDir(root)}); run npm ci or npm cache add`,
252
+ 'npm-tarball-corrupt':"npm's cached TypeScript tarball does not match its integrity",
253
+ 'npm-tarball-invalid':"npm's cached TypeScript tarball cannot be read",
254
+ 'npm-tarball-link-unsupported':"npm's cached TypeScript tarball holds links",
243
255
  'direct-execution-environment-unsupported':'direct compiler rejects development helpers, Node cache/preloads, and unreviewed runtime overrides',
244
256
  'direct-node-version-unsupported':'direct compiler recipe supports Node 22 and 24',
245
257
  'npm-launcher-unobserved-inputs':'npm launcher uses unobserved notifier inputs; execution is recording-only',
@@ -340,6 +352,7 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
340
352
  plan_id:row.plan.id,observed_at:lastObservation,
341
353
  invocation:r?.invocation||null,target_provenance:r?.target||null,
342
354
  ...(r?.verificationRecipe?{verification_recipe:r.verificationRecipe}:{}),
355
+ ...(r?.recipe?{recipe:r.recipe}:{}),
343
356
  coverage_at_run:r?.coverage||null,declared_inputs_match:null,reuse_eligible:false,
344
357
  ...(r?.runFacts?.length?{run_facts:r.runFacts.length}:{})};
345
358
  if(receiptStateError){item.reason=receiptStateError;return item;}
@@ -492,7 +505,7 @@ function adopt(name,row,token) {
492
505
  return done('inputs or certificate changed during the run');
493
506
  const cache=pending.cache,argv=row.plan.probes[0];
494
507
  let result;
495
- try{result=recordedCertificate(text,cache,directProject(argv[2],argv[3],{verify:false}),buildInfoPath(row.plan));}
508
+ try{result=recordedCertificate(text,cache,directProject(argv[2],runFromArgv(argv.slice(3)),{verify:false}),buildInfoPath(row.plan));}
496
509
  catch{return done('recording unreadable');}
497
510
  if(result.issue)return done(result.issue);
498
511
  const identities=new Map(cache.identities);
@@ -782,8 +795,8 @@ function markInputEvent(name,rel,type,replay) {
782
795
  if(!hasHistory||historyDisabled||replay){
783
796
  const serial=(inputEventSerial.get(name)||0)+1;
784
797
  inputEventSerial.set(name,serial);
785
- // Kept only for test checks; trimmed in batches.
786
- if(index?.checks.get(name)?.plan.qualification==='npm-tests-direct-v1'){
798
+ // Kept only for recorded test and script checks; trimmed in batches.
799
+ if(['npm-tests-direct-v1','node-script-v1'].includes(index?.checks.get(name)?.plan.qualification)){
787
800
  const list=inputEventPaths.get(name)||[];list.push([serial,rel]);
788
801
  if(list.length>2*eventPathLimit)list.splice(0,list.length-eventPathLimit);inputEventPaths.set(name,list);
789
802
  }
@@ -1217,7 +1230,9 @@ const server=net.createServer(client=>{
1217
1230
  event('control_reply_unavailable',{classification:handled?'closed_without_reply':
1218
1231
  'closed_before_request',request_bytes:text.length});});
1219
1232
  client.on('data',d=>{
1220
- if(handled)return;text+=d;if(text.length>4096){event('control_request_rejected',
1233
+ // A sync request carries each check's context digests, about 1.5 KB per
1234
+ // check; 1 MiB leaves room for hundreds of checks.
1235
+ if(handled)return;text+=d;if(text.length>1024*1024){event('control_request_rejected',
1221
1236
  {classification:'request_too_large',request_bytes:text.length});client.destroy();return;}
1222
1237
  if(!text.includes('\n'))return;handled=true;
1223
1238
  try{const message=JSON.parse(text);