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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.24 — 2026-10-28
4
+
5
+ Checks keep working after a normal `npm install`, and agents that chain an
6
+ edit and a check get the same skip-or-record as a check run on its own.
7
+
8
+ - **Hard-linked installed files.** Without a pnpm store, any installed file
9
+ with a second hard link made a recorded run UNVERIFIED. esbuild's install
10
+ makes one (npm, not Yarn or Windows), so after an ordinary `npm install`
11
+ general-recipe and test checks in projects with esbuild stayed
12
+ UNVERIFIED. Such a file is now accepted when every one of its links is an
13
+ installed file the check observes; its link count is checked again before
14
+ every answer, so a link added later is noticed.
15
+ - **Checks inside a chain.** In a simple chain (`printf ... >> README.md &&
16
+ npm run lint`), the Claude Code hook rewrites each check to the new
17
+ `redue run CHECK --if-due`, which skips the check when its last pass still
18
+ applies at that moment and otherwise runs it through REDUE with the same
19
+ exit code. The hook changes the command without approving it, so your
20
+ permission rules still apply. Chains with subshells, substitutions,
21
+ here-documents, background jobs, shell keywords or builtins before a check
22
+ run untouched.
23
+ - The CLAUDE.md section asks agents to run each check as a command of its
24
+ own. Setups from earlier alphas are recognized and updated.
25
+ - The README's coverage numbers now come from a census that installs with
26
+ dependencies' install scripts on, as users do: of 120 public TypeScript
27
+ repositories, 86 installed and 30 have a qualifying check (typecheck 21,
28
+ lint 18, tests 2).
29
+ - Known limit, unchanged: a Vitest run writes under `node_modules/.vite`,
30
+ which makes a direct TypeScript typecheck STALE until it runs again.
31
+
32
+ - Contributors: census `--install-scripts`; the stranger test can install
33
+ from npm (`--npm`) and run a follow-up session that uses the hooks.
34
+
3
35
  ## 0.1.0-alpha.23 — 2026-10-26
4
36
 
5
37
  One command to set up REDUE with Claude Code, and one to undo it.
package/README.md CHANGED
@@ -62,25 +62,24 @@ To undo everything setup did: `redue remove`.
62
62
  ## What it covers today
63
63
 
64
64
  In a census of 120 active public TypeScript repositories (October 2026, REDUE
65
- at commit 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
+ the project uses Bun, or a test suite writes into its own inputs. One you may
79
80
  meet right after setup: a Vitest run writes its results under
80
81
  `node_modules/.vite`, which the direct TypeScript recipe counts as an
81
- installed input, so the typecheck turns STALE until it runs again; and an
82
- installed file with a second hard link (esbuild's install makes one) keeps
83
- the test check UNVERIFIED.
82
+ installed input, so the typecheck turns STALE until it runs again.
84
83
  [Supported projects](docs/user/supported-projects.md) has the details.
85
84
 
86
85
  ## Measured with Claude Code
package/bin/redue.mjs CHANGED
@@ -43,7 +43,8 @@ Commands:
43
43
  status --sync Reconcile inputs and caller context before answering
44
44
  explain [CHECK] [--json] Explain applicability (synchronized; may cost more than a rerun)
45
45
  detail [CHECK] [--json] Alias for explain
46
- run CHECK 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');
@@ -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);
@@ -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
 
@@ -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.24",
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';
@@ -377,8 +379,13 @@ async function execute() {
377
379
  // The pnpm executable reads its configuration natively: kept by content.
378
380
  if(guard.opaque)for(const file of pnpmConfigFiles())try{runFacts.push(['readFile',file,queryAnswer('readFile',file)]);}
379
381
  catch{guard.issues.push(`pnpm configuration ${file} cannot be read`);}
380
- const shared=sharedInstalledFile(root,testFacts,path.join(state,'pnpm-store-links-v1.json'));
382
+ // Hard links count only where the check observes them.
383
+ // As the index matches: source patterns, or additive (installed) ones.
384
+ const source=patternMatcher(plan.sourcePatterns||plan.patterns||[]),additive=patternMatcher(plan.additivePatterns||[]);
385
+ const linkFacts=[],observed=observedBy(root,rel=>source(rel)||additive(rel));
386
+ const shared=sharedInstalledFile(root,testFacts,path.join(state,'pnpm-store-links-v1.json'),{observed,facts:linkFacts});
381
387
  if(shared)guard.issues.push(`installed file ${shared.file} ${shared.reason}; a change through it is not observed`);
388
+ else runFacts.push(...linkFacts);
382
389
  fs.rmSync(testRecord,{recursive:true,force:true});
383
390
  }
384
391
  // The daemon reads the recording from owned state; control requests are small.
@@ -0,0 +1,23 @@
1
+ // Whether a check observes an installed file: the file is in the checkout
2
+ // and matched by the check's patterns, and a link it goes through that leads
3
+ // elsewhere in the checkout leads to a matched path too. A recorded run's
4
+ // hard-linked installed files are accepted only when every link is one the
5
+ // check observes, so a pattern that leaves a folder out (a runner cache in
6
+ // node_modules) cannot hide one.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import {observedRelative} from './path-identity.mjs';
10
+
11
+ // `matches(rel)`: the check's pattern matcher over checkout-relative paths.
12
+ // A link that leads outside the checkout is the plan's to resolve (a
13
+ // permitted root or a refusal).
14
+ export function observedBy(root,matches){
15
+ let realRoot=root;try{realRoot=fs.realpathSync(root);}catch{}
16
+ return file=>{
17
+ const logical=observedRelative(root,path.resolve(file));
18
+ if(logical===null||!matches(logical))return false;
19
+ let real;try{real=fs.realpathSync(file);}catch{return false;}
20
+ const physical=observedRelative(realRoot,real);
21
+ return physical===null||physical===logical||matches(physical);
22
+ };
23
+ }
@@ -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
@@ -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;}