@redue/cli 0.1.0-alpha.23 → 0.1.0-alpha.25

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 CHANGED
@@ -1,5 +1,63 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.25 — 2026-10-29
4
+
5
+ Running your tests no longer makes a typecheck STALE, and `status --sync`
6
+ answers about twice as fast.
7
+
8
+ - **Vitest and the direct TypeScript recipe.** A Vitest run writes its
9
+ results under `node_modules/.vite/vitest`, which the recipe counted as an
10
+ installed input, so the typecheck turned STALE after every test run. Those
11
+ files are no longer inputs. Everything else under `node_modules/.vite`
12
+ still is. A program that asks the compiler about anything under
13
+ `node_modules/.vite`, or lists `node_modules`, is UNVERIFIED instead. A
14
+ check set up by an earlier alpha reports STALE ("input plan changed") once
15
+ after the upgrade.
16
+ - **Faster status.** `status --sync`, `detail` and `explain` no longer start
17
+ a second Node process on macOS and Linux, the YAML parser loads only for
18
+ pnpm projects, and each call to the observer no longer waits on a fresh
19
+ read of its status file. On the 800-file bench, warm `status --sync` adds
20
+ 27 ms over cached status on a GitHub Linux runner (alpha.24: 120 ms) and
21
+ 36 ms on a macOS runner (alpha.24: 137 ms).
22
+ - **Releases are benched.** The release smoke now benches the candidate
23
+ against the last release on macOS and Linux runners; a slower candidate,
24
+ or one over the warm-sync or run-overhead budget, blocks the release.
25
+ - Known limit: on the maintainer's Mac, which runs Microsoft Defender,
26
+ `redue run` still adds about 15-20% to a 2.7 s typecheck (3% on a GitHub
27
+ Linux runner).
28
+
29
+ ## 0.1.0-alpha.24 — 2026-10-28
30
+
31
+ Checks keep working after a normal `npm install`, and agents that chain an
32
+ edit and a check get the same skip-or-record as a check run on its own.
33
+
34
+ - **Hard-linked installed files.** Without a pnpm store, any installed file
35
+ with a second hard link made a recorded run UNVERIFIED. esbuild's install
36
+ makes one (npm, not Yarn or Windows), so after an ordinary `npm install`
37
+ general-recipe and test checks in projects with esbuild stayed
38
+ UNVERIFIED. Such a file is now accepted when every one of its links is an
39
+ installed file the check observes; its link count is checked again before
40
+ every answer, so a link added later is noticed.
41
+ - **Checks inside a chain.** In a simple chain (`printf ... >> README.md &&
42
+ npm run lint`), the Claude Code hook rewrites each check to the new
43
+ `redue run CHECK --if-due`, which skips the check when its last pass still
44
+ applies at that moment and otherwise runs it through REDUE with the same
45
+ exit code. The hook changes the command without approving it, so your
46
+ permission rules still apply. Chains with subshells, substitutions,
47
+ here-documents, background jobs, shell keywords or builtins before a check
48
+ run untouched.
49
+ - The CLAUDE.md section asks agents to run each check as a command of its
50
+ own. Setups from earlier alphas are recognized and updated.
51
+ - The README's coverage numbers now come from a census that installs with
52
+ dependencies' install scripts on, as users do: of 120 public TypeScript
53
+ repositories, 86 installed and 30 have a qualifying check (typecheck 21,
54
+ lint 18, tests 2).
55
+ - Known limit, unchanged: a Vitest run writes under `node_modules/.vite`,
56
+ which makes a direct TypeScript typecheck STALE until it runs again.
57
+
58
+ - Contributors: census `--install-scripts`; the stranger test can install
59
+ from npm (`--npm`) and run a follow-up session that uses the hooks.
60
+
3
61
  ## 0.1.0-alpha.23 — 2026-10-26
4
62
 
5
63
  One command to set up REDUE with Claude Code, and one to undo it.
package/README.md CHANGED
@@ -62,25 +62,21 @@ 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 c9034e5, just after 0.1.0-alpha.22), 89 installed cleanly. A check
66
- counts when two recorded runs on the same code both came out CURRENT on the
67
- same inputs. 31 repositories had at least one such check:
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 | 22 | 16 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
+ | 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. Two you may
79
- meet right after setup: a Vitest run writes its results under
80
- `node_modules/.vite`, which the direct TypeScript recipe counts as an
81
- installed input, so the typecheck turns STALE until it runs again; and an
82
- installed file with a second hard link (esbuild's install makes one) keeps
83
- the test check UNVERIFIED.
79
+ the project uses Bun, or a test suite writes into its own inputs.
84
80
  [Supported projects](docs/user/supported-projects.md) has the details.
85
81
 
86
82
  ## Measured with Claude Code
package/bin/redue.mjs CHANGED
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import {mark,traceBase,traceContext,traceEnvironment,traceId} from '../src/decision-profile.mjs';
4
4
  traceBase({...traceContext(),request:traceId(),work:'client'});
5
5
  mark('client.loaded');
6
- import {directCompiler,directQualification,directEnvironment,directInterpretation} from '../src/direct-typescript.mjs';
6
+ import {directCompiler,directQualification,directEnvironment,directInstalledInputs,directInterpretation} from '../src/direct-typescript.mjs';
7
7
  import {runArgv,tscRun} from '../src/typescript-invocation.mjs';
8
8
  import {testEnvironment,testRunnerAt,testsInterpretation,testsQualification} from '../src/test-direct.mjs';
9
9
  import {scriptEnvironment,scriptInterpretation,scriptKinds,scriptQualification} from '../src/node-script.mjs';
@@ -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 Execute one configured check and record its outcome
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');
@@ -333,7 +337,7 @@ const checks=publicConfig.checks.map(check=>{
333
337
  typeof check.cwd==='string'&&check.cwd&&!path.isAbsolute(check.cwd)&&!check.cwd.split('/').includes('..')?check.cwd:'.'),
334
338
  cwd:check.cwd,inputs:check.inputs||[],generatedInputs:check.generatedInputs||[],
335
339
  installedInputs:check.installedInputs||(
336
- npmAutomatic||npmDirect||testsDirect?['node_modules/**']:[]),
340
+ npmDirect?[...directInstalledInputs]:npmAutomatic||testsDirect?['node_modules/**']:[]),
337
341
  allowedExternalRoots:check.allowedExternalRoots||[],
338
342
  probes:[...(check.probes||[]),...autoProbes]
339
343
  .map((p,i)=>argv(p,`${check.name}.probes[${i}]`)),
@@ -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);
@@ -406,6 +424,15 @@ if(command==='run'&&process.platform!=='win32'){
406
424
  process.off('SIGINT',interrupt);process.off('SIGTERM',terminate);
407
425
  if(outcome.error)error(outcome.error.message);
408
426
  process.exit(outcome.status??(outcome.signal?128+os.constants.signals[outcome.signal]:1));
427
+ }else if(internal==='sync'&&process.platform!=='win32'){
428
+ // As for run, the synchronized status engine runs in this process.
429
+ process.argv=[process.argv[0],engine,runtimeFile,internal,'','--value'];
430
+ mark('client.engine_submit',{inProcess:true});
431
+ const synchronized=await import(pathToFileURL(engine).href);
432
+ await synchronized.finished;
433
+ mark('client.engine_complete',{exit:process.exitCode??0});
434
+ if(synchronized.syncValue)showSynchronized(synchronized.syncValue);
435
+ await exit(process.exitCode??0);
409
436
  }else{
410
437
  mark('client.engine_submit');
411
438
  const result=spawnSync(process.execPath,[engine,runtimeFile,internal,...(check?[check]:[])],
@@ -416,10 +443,13 @@ if(command==='run'&&process.platform!=='win32'){
416
443
  if(result.stdout){
417
444
  if(['status','detail','explain'].includes(command)){
418
445
  let value;try{value=JSON.parse(result.stdout);}catch{error('status response could not be read; applicability is unknown');}
419
- if(details&&check)value={...value,checks:value.checks.filter(row=>row.name===check)};
420
- if(json)console.log(JSON.stringify(value));
421
- else console.log(details?renderExplain(value,check):renderStatus(value,{short}));
446
+ showSynchronized(value);
422
447
  }else printLifecycle(result.stdout,command,json);
423
448
  }
424
449
  await exit(result.status??1);
425
450
  }
451
+ function showSynchronized(value){
452
+ if(details&&check)value={...value,checks:value.checks.filter(row=>row.name===check)};
453
+ if(json)console.log(JSON.stringify(value));
454
+ else console.log(details?renderExplain(value,check):renderStatus(value,{short}));
455
+ }
@@ -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. Other settings in the file are kept,
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
 
@@ -141,6 +141,14 @@ changes invalidate or withdraw applicability normally. Installed files are obser
141
141
  by identity, so reinstalling identical packages or restoring an installed file's
142
142
  bytes also needs one rerun (see [configuration](configuration.md)).
143
143
 
144
+ Vitest writes its results under `node_modules/.vite/vitest` at every run.
145
+ They are not inputs, so a test run leaves the typecheck CURRENT. Everything
146
+ else under `node_modules/.vite` is still an input. A program that asks about
147
+ anything under `node_modules/.vite` (by any spelling or through a link that
148
+ resolves there), or lists `node_modules`, is UNVERIFIED: REDUE does not
149
+ observe what Vitest writes there. A check configured before 0.1.0-alpha.25 gets the new inputs on
150
+ its own and reports STALE once after the upgrade.
151
+
144
152
  A root tsconfig with `incremental: true` is supported. tsc reads and writes
145
153
  its build-info file (`tsBuildInfoFile`, or the default next to the config) on
146
154
  each run, so REDUE leaves that file out of the inputs and lets the run write
@@ -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). A change through a link elsewhere, such as Yarn's
94
- hardlinks modes, is not seen.
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@redue/cli",
3
3
  "private": false,
4
- "version": "0.1.0-alpha.23",
4
+ "version": "0.1.0-alpha.25",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"
@@ -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
- function blockFor(host,rows=[]){
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
- `skip counts as a pass.\n`);
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===earlierBlock(host,rows)||priorBlocks(host,relative).includes(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
@@ -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==='\\'&&quote==='"')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
- function synchronizedStatus(entry,config,root,timeout){
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','--sync','--json'],
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';
@@ -53,7 +55,17 @@ function publishedProbeDeadline() {
53
55
  try{return publishedDeadline(JSON.parse(fs.readFileSync(path.join(state,'status.json'),'utf8')).probe_deadline_ms);}
54
56
  catch{return 0;}
55
57
  }
56
- const decisionDeadline=()=>Math.max(decisionBaseMs,publishedProbeDeadline()+10000);
58
+ // The wait for a decision, read when it starts: the base at once, and the
59
+ // published deadline when status.json has been read. That file is rewritten
60
+ // at every decision, and a first read of a fresh file is slow where a scanner
61
+ // checks files as they are opened, so it is read off the main thread while
62
+ // the call runs. On Windows it is read before the call, as before: the
63
+ // observer replaces it during the call, and a replacement fails there while a
64
+ // reader holds the file open. An override is exact.
65
+ const decisionWait=override=>override?{timeout:Number(override),deadline:null}:
66
+ process.platform==='win32'?{timeout:Math.max(decisionBaseMs,publishedProbeDeadline()+10000),deadline:null}:{timeout:decisionBaseMs,
67
+ deadline:fs.promises.readFile(path.join(state,'status.json'),'utf8')
68
+ .then(text=>Math.max(decisionBaseMs,publishedDeadline(JSON.parse(text).probe_deadline_ms)+10000),()=>decisionBaseMs)};
57
69
  const digest=value=>createHash('sha256').update(value).digest('hex');
58
70
  function nodeIdentity() {const real=fs.realpathSync(process.execPath),st=fs.statSync(real,{bigint:true});
59
71
  return digest(JSON.stringify([real,process.version,...['dev','ino','mode','size','mtimeNs','ctimeNs']
@@ -118,20 +130,26 @@ function owner(){
118
130
  atomicJson(ownerFile,expected);
119
131
  }
120
132
  }
121
- function call(message,timeout=5000) {
133
+ // deadline, when given, resolves to the whole wait in ms, which replaces
134
+ // timeout when it is longer.
135
+ function call(message,timeout=5000,deadline=null) {
122
136
  return new Promise((resolve,reject)=>{
123
137
  let data='',done=false;mark('client.control_connect',{deadlineMs:timeout});const client=net.createConnection(socket);
124
- const timer=setTimeout(()=>{mark('client.control_timeout');client.destroy();reject(Error('observer synchronization timeout'));},timeout);
138
+ const started=Date.now(),expire=()=>{mark('client.control_timeout');client.destroy();reject(Error('observer synchronization timeout'));};
139
+ let timer=setTimeout(expire,timeout);
140
+ deadline?.then(total=>{if(done||total<=timeout)return;mark('client.control_deadline',{deadlineMs:total});
141
+ clearTimeout(timer);timer=setTimeout(expire,Math.max(0,total-(Date.now()-started)));});
125
142
  client.on('connect',()=>{mark('client.control_submit');client.write(JSON.stringify({...message,...(profiling?{diagnostic:traceContext()}: {})})+'\n');});
126
143
  client.on('data',chunk=>data+=chunk);
127
144
  client.on('end',()=>{mark('client.control_complete',{bytes:data.length});clearTimeout(timer);if(done)return;done=true;
128
145
  try{resolve(JSON.parse(data));}catch(e){reject(e);}});
129
- client.on('error',e=>{mark('client.control_error',{code:e.code});clearTimeout(timer);reject(e);});
146
+ client.on('error',e=>{mark('client.control_error',{code:e.code});clearTimeout(timer);done=true;reject(e);});
130
147
  });
131
148
  }
132
- async function decisionCall(message,timeout) {
133
- const started=Date.now(),remaining=()=>Math.max(1,timeout-(Date.now()-started));
134
- try{return await call(message,remaining());}
149
+ async function decisionCall(message,{timeout,deadline}) {
150
+ let total=timeout;deadline?.then(value=>{total=Math.max(total,value);});
151
+ const started=Date.now(),remaining=()=>Math.max(1,total-(Date.now()-started));
152
+ try{return await call(message,timeout,deadline);}
135
153
  catch(error){
136
154
  if(process.platform!=='win32'||!['ENOENT','ECONNREFUSED','ECONNRESET'].includes(error.code))throw error;
137
155
  const pidFile=path.join(state,'observer.lock','pid');
@@ -231,8 +249,7 @@ async function execute() {
231
249
  const environmentBefore=contextHashes()[name];
232
250
  const observedContext=selected.qualification===directQualification?{interpretation:directInterpretation,npmProxyDigest:observedNpmContext()}:null;
233
251
  timing('cli.context_before',contextStarted);
234
- const
235
- captureTimeout=Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||decisionDeadline());
252
+ const captureWait=decisionWait(process.env.VSTATE_CAPTURE_TIMEOUT_MS);
236
253
  let before=null,after=null;
237
254
  // Expensive state probes can take seconds. Keep the filesystem checkpoint
238
255
  // adjacent to process launch so a late notification from earlier work
@@ -242,7 +259,7 @@ async function execute() {
242
259
  let probeBefore=callerCertificate?null:probeHash(probePlan);
243
260
  const startSnapshotStarted=performance.now();
244
261
  try{before=await call({action:'snapshot',name,contextHashes:contextHashes(),
245
- ...(callerCertificate?{runCertificate:1}:{})},captureTimeout);}
262
+ ...(callerCertificate?{runCertificate:1}:{})},captureWait.timeout,captureWait.deadline);}
246
263
  catch(error){captureIssues.push('start observation unavailable'+
247
264
  (typeof error.code==='string'&&/^[A-Z0-9_]+$/.test(error.code)?` (${error.code})`:''));}
248
265
  timing('cli.start_checkpoint',startSnapshotStarted,{available:before?.snapshots?.[name]?1:0});
@@ -305,7 +322,7 @@ async function execute() {
305
322
  await new Promise(resolve=>setTimeout(resolve,100));
306
323
  const prelaunchStarted=performance.now();
307
324
  try{before=await call({action:'prelaunch',name,
308
- contextHashes:contextHashes()},Math.min(captureTimeout,5000));}
325
+ contextHashes:contextHashes()},Math.min(captureWait.timeout,5000));}
309
326
  catch{before=null;captureIssues.push('prelaunch observation unavailable');}
310
327
  timing('cli.prelaunch_checkpoint',prelaunchStarted,
311
328
  {available:before?.snapshots?.[name]?1:0});
@@ -377,8 +394,13 @@ async function execute() {
377
394
  // The pnpm executable reads its configuration natively: kept by content.
378
395
  if(guard.opaque)for(const file of pnpmConfigFiles())try{runFacts.push(['readFile',file,queryAnswer('readFile',file)]);}
379
396
  catch{guard.issues.push(`pnpm configuration ${file} cannot be read`);}
380
- const shared=sharedInstalledFile(root,testFacts,path.join(state,'pnpm-store-links-v1.json'));
397
+ // Hard links count only where the check observes them.
398
+ // As the index matches: source patterns, or additive (installed) ones.
399
+ const source=patternMatcher(plan.sourcePatterns||plan.patterns||[]),additive=patternMatcher(plan.additivePatterns||[]);
400
+ const linkFacts=[],observed=observedBy(root,rel=>source(rel)||additive(rel));
401
+ const shared=sharedInstalledFile(root,testFacts,path.join(state,'pnpm-store-links-v1.json'),{observed,facts:linkFacts});
381
402
  if(shared)guard.issues.push(`installed file ${shared.file} ${shared.reason}; a change through it is not observed`);
403
+ else runFacts.push(...linkFacts);
382
404
  fs.rmSync(testRecord,{recursive:true,force:true});
383
405
  }
384
406
  // The daemon reads the recording from owned state; control requests are small.
@@ -386,11 +408,12 @@ async function execute() {
386
408
  if(recordFile)try{fs.writeFileSync(recordFile,recordText,{mode:0o600});}catch{}
387
409
  const endSnapshotStarted=performance.now();
388
410
  if(!unobservedDirectRun){
411
+ // The start checkpoint's probe may have raised the deadline.
412
+ const endWait=decisionWait(process.env.VSTATE_CAPTURE_TIMEOUT_MS);
389
413
  try{after=await call({action:'snapshot',name,contextHashes:contextHashes(),
390
414
  ...(testRun&&Number.isSafeInteger(snap?.inputEventSerial)?{sinceSerial:snap.inputEventSerial}:{}),
391
415
  ...(recording?{runCertificate:1,adopt:recording}:recorded&&!certificate?{runCertificate:1,runFacts:1}:{})},
392
- // The start checkpoint's probe may have raised the deadline.
393
- Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||decisionDeadline()));}
416
+ endWait.timeout,endWait.deadline);}
394
417
  catch{captureIssues.push('end observation unavailable');}
395
418
  finally{if(recordFile)fs.rmSync(recordFile,{force:true});}
396
419
  timing('cli.end_checkpoint',endSnapshotStarted,{available:after?.snapshots?.[name]?1:0});
@@ -594,7 +617,7 @@ async function main(){
594
617
  observedNpmDigest:observedNpmContext(),proxyShapes});
595
618
  }
596
619
  let value;try{value=await decisionCall({action:'sync',contextHashes:contextHashes()},
597
- Number(process.env.VSTATE_SYNC_TIMEOUT_MS||decisionDeadline()));
620
+ decisionWait(process.env.VSTATE_SYNC_TIMEOUT_MS));
598
621
  // A client upgrade can encounter an older running observer. Its successful
599
622
  // control response does not establish that it evaluated the latest receipt.
600
623
  if(value.receipt_revision===null){
@@ -608,7 +631,9 @@ async function main(){
608
631
  }}
609
632
  catch(e){value=unavailableCached(state,e.message,config.checks);process.exitCode=2;}
610
633
  timing('cli.synchronized_status',started,{available:value.observation?.healthy?1:0});
611
- if(action==='sync')console.log(JSON.stringify(value));
634
+ // In the front's process (bin/redue.mjs), the value is handed back.
635
+ if(action==='sync'&&process.argv.includes('--value'))syncValue=value;
636
+ else if(action==='sync')console.log(JSON.stringify(value));
612
637
  else for(const row of value.checks)console.log(`${row.name}: ${row.freshness}/${row.result||'NO RESULT'} — ${row.reason}`);
613
638
  return;
614
639
  }
@@ -616,4 +641,5 @@ async function main(){
616
641
  if(action==='run'){await execute();return;}
617
642
  throw Error(`unknown action ${action}`);
618
643
  }
619
- main().catch(e=>{console.error(`redue: ${e.message}`);process.exitCode=2;});
644
+ export let syncValue=null;
645
+ export const finished=main().catch(e=>{console.error(`redue: ${e.message}`);process.exitCode=2;});
package/src/daemon.mjs CHANGED
@@ -269,6 +269,7 @@ function probe(row,allowReuse=false,{launch=true}={}) {
269
269
  'typescript-plugin-unsupported':'TypeScript compiler plugin needs explicit review',
270
270
  'typescript-input-list-unavailable':'TypeScript input listing did not complete',
271
271
  'typescript-input-outside-repository':'TypeScript consumes files outside the observed repository',
272
+ 'typescript-vite-cache-consumed':'TypeScript asks about node_modules/.vite or lists node_modules; Vitest rewrites files there and this recipe does not observe them',
272
273
  'npm-installation-layout-unsupported':'npm executable or installation layout is unsupported',
273
274
  'npm-installation-unobservable':'npm installation contains unobservable entries',
274
275
  'npm-configuration-unsupported':'npm script configuration needs review',
@@ -7,7 +7,7 @@ import {createHash} from 'node:crypto';
7
7
  import {inflateSync} from 'node:zlib';
8
8
  import {fileURLToPath} from 'node:url';
9
9
  import {timing} from './decision-profile.mjs';
10
- import {directCompiler,directContextHash,directEnvironmentIssue,directProbeOutput,directProbeValue} from './direct-typescript.mjs';
10
+ import {directCompiler,directContextHash,directEnvironmentIssue,directProbeOutput,directProbeValue,viteCacheQuestion} from './direct-typescript.mjs';
11
11
  import {withinPath} from './path-identity.mjs';
12
12
  const hash=value=>createHash('sha256').update(value).digest('hex');
13
13
  // Whether a direct run is recorded at all: on macOS and Linux, always, so its
@@ -158,6 +158,8 @@ export function runRecordIssue(record,certificate,runFacts=null) {
158
158
  if(kept.issue)return kept.issue;
159
159
  if(record.main!==compiler.entry||!Array.isArray(record.node)||
160
160
  directContextHash(compiler,record.node)!==certificate.context)return 'execution context changed';
161
+ const vite=viteCacheQuestion(compiler.root,kept.facts);
162
+ if(vite)return `the compiler asked about ${vite}, which Vitest rewrites and REDUE does not observe`;
161
163
  const inCompiler=withinCompiler(compiler);
162
164
  const facts=new Map();
163
165
  for(const query of queries){if(!facts.has(query[1]))facts.set(query[1],[]);facts.get(query[1]).push(query);}
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import {directProject,directInterpretation,directContextHash,directProbeOutput} from './direct-typescript.mjs';
2
+ import {directProject,directInterpretation,directContextHash,directProbeOutput,viteCacheQuestion} from './direct-typescript.mjs';
3
3
  import {typeScriptInputFacts} from './typescript-input-facts.mjs';
4
4
  import {contextOnly,probeResult} from './probe-result.mjs';
5
5
  import {realObservedPath} from './path-identity.mjs';
@@ -18,6 +18,9 @@ try{
18
18
  const contextHash=directContextHash(compiler,context.node);
19
19
  if(!contextOnly(contextHash)){
20
20
  const {queries,configs,files,buildInfo}=typeScriptInputFacts(root,compiler.tsRoot,compiler.entry,compiler.config);
21
+ // Captured questions include every config and program file read.
22
+ if(viteCacheQuestion(root,queries||[...configs,...files].map(([file])=>['readFile',file])))
23
+ throw Object.assign(Error('typescript-vite-cache-consumed'),{code:'typescript-vite-cache-consumed'});
21
24
  const layout={root,script,configs,files,context,...(buildInfo?{buildInfo}:{})};
22
25
  probeResult(directProbeOutput(layout),contextHash,queries,layout);
23
26
  }
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import {createHash} from 'node:crypto';
5
5
  import {createRequire} from 'node:module';
6
- import {racyUntil,realObservedPath,withinPath} from './path-identity.mjs';
6
+ import {normalizedPath,racyUntil,realObservedPath,withinPath} from './path-identity.mjs';
7
7
  import {cachedTarball,lockedPackage,matchTarball,npmCacheDir,registryPackage,tarballFiles} from './npm-integrity.mjs';
8
8
  import {runConfig,tscRun} from './typescript-invocation.mjs';
9
9
  export const directInterpretation='npm-direct-typescript@1';
@@ -79,7 +79,7 @@ export function directCompiler(root,{verify=true}={}){
79
79
  for(const [name,file] of files.sort(([a],[b])=>a<b?-1:a>b?1:0))digest.update(name+'\0'+hash(fs.readFileSync(file))+'\0');
80
80
  if(digest.digest('hex')!==spec.digest)fail('direct-typescript-implementation-unreviewed');
81
81
  }
82
- return {tsRoot,version,entry:path.join(tsRoot,spec.entry),digest:spec.digest};
82
+ return {root,tsRoot,version,entry:path.join(tsRoot,spec.entry),digest:spec.digest};
83
83
  }
84
84
  // A TypeScript 5.x or 6.0 the lockfile records: the installed package must be
85
85
  // the registry's `typescript` at that version, holding exactly the files of
@@ -115,7 +115,7 @@ function lockedCompiler(root,tsRoot,version,verify){
115
115
  }
116
116
  }
117
117
  const entry=fs.existsSync(path.join(tsRoot,'lib','_tsc.js'))?'lib/_tsc.js':'lib/tsc.js';
118
- return {tsRoot,version,entry:path.join(tsRoot,entry),digest:hash('npm-integrity\0'+locked.integrity)};
118
+ return {root,tsRoot,version,entry:path.join(tsRoot,entry),digest:hash('npm-integrity\0'+locked.integrity)};
119
119
  }
120
120
  // The direct probe's output: root, script, configuration and program files with
121
121
  // their byte digests, and the execution context. A recorded run rebuilds it from
@@ -162,6 +162,26 @@ export function directContextHash(compiler,node){
162
162
  return hash(JSON.stringify({interpretation:directInterpretation,compiler:compiler.digest,
163
163
  entry:compiler.entry,node}));
164
164
  }
165
+ // Vitest writes its results under node_modules/.vite/vitest at every run. The
166
+ // recipe does not observe them. A compiler that asks about anything under
167
+ // node_modules/.vite, is told a path there (realpath), or lists node_modules,
168
+ // could see them, so its result is UNVERIFIED.
169
+ export const directInstalledInputs=Object.freeze(['node_modules/**','!node_modules/.vite/vitest/**']);
170
+ const listings=new Set(['readdir','listing','directories','entries','readDirectory']);
171
+ // The first path of [kind, file, answer] questions that does, or null. Kinds
172
+ // are a recorded run's (readdir) or a probe's. Compared without case: a
173
+ // case-insensitive mount reaches .vite under any spelling.
174
+ export function viteCacheQuestion(root,questions){
175
+ const fold=value=>normalizedPath(value).toLowerCase();
176
+ const modules=fold(path.join(root,'node_modules')),cache=path.join(modules,'.vite');
177
+ for(const [kind,file,answer] of questions){
178
+ if(typeof file!=='string')continue;
179
+ const at=fold(file);
180
+ if(withinPath(at,cache)||at===modules&&listings.has(kind))return file;
181
+ if(kind==='realpath'&&typeof answer==='string'&&withinPath(fold(answer),cache))return answer;
182
+ }
183
+ return null;
184
+ }
165
185
  // The compiler for one check run {script, part, args}: script null for a
166
186
  // check init proposed without one. The run must pass --noEmit and name a
167
187
  // config inside the root.
@@ -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
+ }
@@ -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}/**`,`!${at}/.cache/**`,`!${at}/.vite/**`,`!${at}/.vite-temp/**`,
43
- `!${at}/.vitest/**`,`!${at}/.vitest-cache/**`,`!${at}/**/*.tsbuildinfo`]),...installState]};
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
@@ -8,7 +8,6 @@ import path from 'node:path';
8
8
  import {createRequire} from 'node:module';
9
9
  import {execFileSync,spawnSync} from 'node:child_process';
10
10
  import micromatch,{patternMatcher} from './glob.mjs';
11
- import YAML from 'yaml';
12
11
  import {resolveLinks} from './installed-inputs.mjs';
13
12
  import {withinPath as within,realObservedPath,samePath} from './path-identity.mjs';
14
13
  import {timing} from './decision-profile.mjs';
@@ -18,7 +17,11 @@ import {runConfig,tscRun,tscRuns} from './typescript-invocation.mjs';
18
17
  const rel=(root,file)=>path.relative(root,file).split(path.sep).join('/');
19
18
  const read=file=>JSON.parse(fs.readFileSync(file,'utf8'));
20
19
  const exists=file=>{try{fs.lstatSync(file);return true;}catch{return false;}};
20
+ // Loaded on first use: the parser takes about 40 ms to load, and every
21
+ // command imports this module.
22
+ let YAML=null;
21
23
  export function yaml(file){
24
+ YAML??=createRequire(import.meta.url)('yaml');
22
25
  const doc=YAML.parseDocument(fs.readFileSync(file,'utf8'),{uniqueKeys:true});
23
26
  if(doc.errors.length)throw Error(`${path.basename(file)} cannot be interpreted safely`);
24
27
  return doc.toJS()||{};
@@ -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
- export function sharedInstalledFile(root,project={},cache=null){
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)return shared(file,'has another hard link, and the install records no pnpm store');
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;
@@ -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;}