@redue/cli 0.1.0-alpha.2 → 0.1.0-alpha.21

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +491 -0
  2. package/README.md +107 -2
  3. package/bin/redue.mjs +132 -50
  4. package/docs/README.md +2 -1
  5. package/docs/user/agents.md +299 -15
  6. package/docs/user/configuration.md +28 -2
  7. package/docs/user/direct-typescript.md +209 -0
  8. package/docs/user/getting-started.md +23 -2
  9. package/docs/user/json.md +2 -0
  10. package/docs/user/node-script.md +154 -0
  11. package/docs/user/npm-launcher-notice.md +42 -0
  12. package/docs/user/pilot.md +146 -0
  13. package/docs/user/receipt-selection-notice.md +60 -0
  14. package/docs/user/supported-projects.md +56 -23
  15. package/docs/user/tests-direct.md +132 -0
  16. package/package.json +4 -3
  17. package/src/agent-integration.mjs +388 -0
  18. package/src/agent-policy.md +28 -0
  19. package/src/cached-state.mjs +46 -8
  20. package/src/claude-hook.mjs +449 -0
  21. package/src/cli.mjs +263 -40
  22. package/src/daemon.mjs +433 -94
  23. package/src/decision-profile.mjs +15 -1
  24. package/src/decision-validation.mjs +10 -0
  25. package/src/declared-plan.mjs +29 -6
  26. package/src/direct-run-certificate.mjs +252 -0
  27. package/src/direct-typescript-probe.mjs +24 -0
  28. package/src/direct-typescript.mjs +185 -0
  29. package/src/glob.mjs +8 -0
  30. package/src/history-worker.mjs +21 -13
  31. package/src/index.mjs +72 -19
  32. package/src/installed-inputs.mjs +7 -1
  33. package/src/linux-inotify.py +39 -10
  34. package/src/node-onboarding.mjs +172 -53
  35. package/src/node-script.mjs +364 -0
  36. package/src/npm-integrity.mjs +124 -0
  37. package/src/npm-typecheck-environment.mjs +36 -0
  38. package/src/npmrc.mjs +16 -0
  39. package/src/observation.mjs +34 -12
  40. package/src/path-identity.mjs +8 -0
  41. package/src/plan-worker.mjs +17 -17
  42. package/src/pnpm-inputs.mjs +114 -54
  43. package/src/pnpm-shim-templates.json +180 -0
  44. package/src/pnpm-shims.mjs +54 -0
  45. package/src/pnpm-typecheck-probe.mjs +11 -6
  46. package/src/presentation.mjs +9 -1
  47. package/src/probe-deadline.mjs +37 -0
  48. package/src/probe-result.mjs +7 -3
  49. package/src/project-location.mjs +34 -0
  50. package/src/run-recorder.cjs +91 -0
  51. package/src/state-store.mjs +37 -1
  52. package/src/test-direct.mjs +560 -0
  53. package/src/test-recorder.cjs +406 -0
  54. package/src/typescript-contract-probe.mjs +4 -187
  55. package/src/typescript-input-facts.mjs +92 -0
  56. package/src/typescript-invocation.mjs +92 -0
  57. package/src/typescript-list.mjs +108 -13
  58. package/src/typescript-qualification.mjs +29 -49
  59. package/src/workspace-manifests.mjs +24 -0
  60. package/src/yarn-workspace-typecheck-probe.mjs +2 -1
@@ -0,0 +1,364 @@
1
+ // The general recipe: a typecheck, test or lint script run through the
2
+ // project's package manager, recorded like the direct test recipe
3
+ // (test-direct.mjs). Its inputs are the whole checkout except installed files
4
+ // (observed by identity), runner caches and what the run itself writes, plus
5
+ // the package manager, Node, the exact script and the environment the run
6
+ // read. The recorder sees only Node processes, so every other program a run
7
+ // starts must be one REDUE can account for: a shell whose command, read as
8
+ // text, starts only Node programs, pnpm's launchers (pnpm-shims.mjs), or the
9
+ // pnpm executable. Anything else makes the result UNVERIFIED and names the
10
+ // program.
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import {pnpmShimTarget} from './pnpm-shims.mjs';
15
+ import {testPackageManager,testWorkspaceDirs,testEnvironment} from './test-direct.mjs';
16
+
17
+ export const scriptQualification='node-script-v1';
18
+ export const scriptInterpretation='node-script@1';
19
+ export const scriptKinds=['typecheck','test','lint'];
20
+ const fail=code=>{throw Object.assign(Error(code),{code});};
21
+
22
+ // Runner caches in the checkout. They are not inputs, and a run may read and
23
+ // write them: ESLint's and Prettier's caches, TypeScript build info, the
24
+ // cache directories of Vite, Vitest, Jest, SWC, Parcel, Next.js, Angular,
25
+ // Turborepo and Nx, and the temporary files pnpm writes and removes in the
26
+ // project at every run (_tmp_<pid>_<random>).
27
+ export const scriptCaches=['**/_tmp_+([0-9])_+([0-9a-f])','**/.cache/**','**/.vite/**','**/.vite-temp/**','**/.vitest/**','**/.vitest-cache/**',
28
+ '**/.turbo/**','**/.nx/**','**/.swc/**','**/.jest-cache/**','**/.parcel-cache/**','**/.angular/cache/**',
29
+ '**/.next/cache/**','**/.tmp/**','**/*.tsbuildinfo','**/.eslintcache','**/.stylelintcache','**/.prettiercache'];
30
+ // Installed state a package manager reads at every run: pnpm's and Yarn's
31
+ // records inside node_modules are installed files already; Yarn's install
32
+ // state is kept beside them.
33
+ const installState=['.yarn/install-state.gz'];
34
+ const modulesDirs=dirs=>['node_modules',...dirs.map(dir=>`${dir}/node_modules`)];
35
+ // Inputs: the whole checkout but node_modules, .git and runner caches. Every
36
+ // node_modules of the workspace is observed as installed files, without the
37
+ // caches tools keep there.
38
+ export function scriptCheckInputs(dirs=[]){
39
+ const modules=modulesDirs(dirs);
40
+ return {inputs:['**',...modules.map(at=>`!${at}/**`),'!.git/**',...installState.map(file=>`!${file}`),
41
+ ...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]};
44
+ }
45
+ // Installed files a tool rewrites during a run. pnpm writes its workspace
46
+ // state in the root node_modules at the first run after an install, through
47
+ // a temporary .tmpXXXXXX file it renames. vue-tsc writes its global types in
48
+ // a workspace node_modules before it reads them. They stay inputs. When they
49
+ // are a run's only change and the run wrote them, the run is kept with the
50
+ // files as the run left them.
51
+ const pnpmState=rel=>/^node_modules\/\.pnpm-workspace-state(?:-v\d+)?\.json$/.test(rel);
52
+ const pnpmTemp=rel=>/^node_modules\/\.tmp[A-Za-z0-9]{6}$/.test(rel);
53
+ const vueTypes=rel=>{const parts=rel.split('/');
54
+ return /^[^/]+\.d\.ts$/.test(parts.at(-1))&&parts.at(-2)==='.vue-global-types'&&parts.at(-3)==='node_modules'&&
55
+ !parts.slice(0,-3).includes('node_modules')&&!rel.startsWith('external:');};
56
+ export const toolOutputPath=rel=>pnpmState(rel)||pnpmTemp(rel)||vueTypes(rel);
57
+ // The run wrote it: a recorded process did, or, for pnpm's files, the pnpm
58
+ // executable ran, which writes them natively.
59
+ export const toolOutput=(rel,guard)=>toolOutputPath(rel)&&(guard.wroteInstalled(rel)||(pnpmState(rel)||pnpmTemp(rel))&&guard.opaque);
60
+ // The installed paths the recorder treats as observed by identity.
61
+ export const scriptInstalledPaths=(root,dirs=[])=>[...modulesDirs(dirs),...installState].map(at=>path.join(root,at));
62
+ // Variables a recorded run cannot be seen reading: Node's and libc's (as in
63
+ // the test recipe), and the package managers' own, which npm reads by listing
64
+ // the environment and pnpm's executable reads natively. Every package
65
+ // manager's executable on PATH is identified, the project's first: a script
66
+ // may run another one.
67
+ const managers=['npm','pnpm','yarn','corepack'];
68
+ export function scriptEnvironment(manager){
69
+ return {variables:[...testEnvironment.variables,'PATH','HOME','CI','XDG_CONFIG_HOME'],
70
+ prefixes:[...testEnvironment.prefixes,'npm_config_','pnpm_config_','PNPM_','COREPACK_','YARN_'],
71
+ pathExecutables:['node',manager,...managers.filter(name=>name!==manager)],executableIdentity:true};
72
+ }
73
+ // The configuration files the pnpm executable reads natively, outside the
74
+ // checkout: kept by content as run facts when it ran.
75
+ export function pnpmConfigFiles(env=process.env){
76
+ const home=os.homedir(),config=path.isAbsolute(env.XDG_CONFIG_HOME||'')?env.XDG_CONFIG_HOME:
77
+ process.platform==='darwin'?path.join(home,'Library','Preferences'):path.join(home,'.config');
78
+ const prefix=path.dirname(path.dirname(process.execPath));
79
+ return [...new Set([path.join(config,'pnpm','rc'),path.join(config,'pnpm','config.yaml'),path.join(home,'.config','pnpm','rc'),
80
+ path.join(home,'.npmrc'),path.join(prefix,'etc','npmrc'),...(path.isAbsolute(env.npm_config_userconfig||'')?[env.npm_config_userconfig]:[])])];
81
+ }
82
+ // The project facts the recipe needs, or an error code: the package manager
83
+ // and the workspace package directories whose node_modules are installed.
84
+ export function scriptProject(root,script){
85
+ const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
86
+ const manager=testPackageManager(root,pkg,{workspaces:true});
87
+ if(typeof pkg.scripts?.[script]!=='string'||!pkg.scripts[script].trim())fail('script-missing');
88
+ return {manager,dirs:testWorkspaceDirs(root,pkg),text:pkg.scripts[script],scripts:pkg.scripts};
89
+ }
90
+
91
+ // The package managers' own installations, Corepack's downloads and npm's
92
+ // logs. They are toolchain, identified by the package manager's executable
93
+ // (pathExecutables), not inputs.
94
+ export function toolchainDirs(env=process.env){
95
+ const dirs=new Set();
96
+ for(const name of ['npm','npx','pnpm','pnpx','yarn','yarnpkg','corepack']){
97
+ const file=lookup(name,env.PATH,process.cwd());
98
+ const dir=file&&packageDir(file);
99
+ if(dir)dirs.add(dir);
100
+ }
101
+ const home=os.homedir(),cache=path.isAbsolute(env.XDG_CACHE_HOME||'')?env.XDG_CACHE_HOME:path.join(home,'.cache');
102
+ // Corepack's downloads, by version; lastKnownGood.json, which picks a
103
+ // version, stays a file read outside the checkout.
104
+ dirs.add(path.join(path.isAbsolute(env.COREPACK_HOME||'')?env.COREPACK_HOME:path.join(cache,'node','corepack'),'v1'));
105
+ dirs.add(path.join(path.isAbsolute(env.npm_config_cache||'')?env.npm_config_cache:path.join(home,'.npm'),'_logs'));
106
+ const real=dir=>{try{return fs.realpathSync(dir);}catch{return dir;}};
107
+ return [...new Set([...dirs].flatMap(dir=>[path.resolve(dir),real(dir)]))];
108
+ }
109
+ // The package directory of an installed package manager's file: the nearest
110
+ // package.json named npm, pnpm, @pnpm/exe*, yarn or corepack.
111
+ function packageDir(file){
112
+ for(let dir=path.dirname(file),i=0;i<5;i++,dir=path.dirname(dir)){
113
+ try{const name=JSON.parse(fs.readFileSync(path.join(dir,'package.json'),'utf8')).name;
114
+ if(['npm','pnpm','yarn','corepack','@pnpm/exe'].includes(name)||/^@pnpm\/exe\./.test(name))return dir;
115
+ return null;}
116
+ catch{}
117
+ }
118
+ return null;
119
+ }
120
+ // A command name looked up along PATH as a shell does: the first executable
121
+ // file. Returns the real path, or null.
122
+ export function lookup(name,searchPath,cwd){
123
+ const candidates=name.includes('/')?[path.resolve(cwd,name)]:
124
+ String(searchPath??'').split(path.delimiter).map(dir=>path.join(path.resolve(cwd,dir||'.'),name));
125
+ for(const file of candidates){
126
+ try{const st=fs.statSync(file);if(st.isFile()&&(st.mode&0o111))return fs.realpathSync(file);}catch{}
127
+ if(name.includes('/'))return null;
128
+ }
129
+ return null;
130
+ }
131
+ const head=file=>{
132
+ let fd;try{fd=fs.openSync(file,'r');const buffer=Buffer.alloc(512);return buffer.subarray(0,fs.readSync(fd,buffer,0,512,0));}
133
+ catch{return null;}finally{if(fd!==undefined)fs.closeSync(fd);}
134
+ };
135
+ const nativeMagic=buffer=>buffer?.length>=4&&(buffer[0]===0x7f&&buffer.toString('latin1',1,4)==='ELF'||
136
+ [0xfeedface,0xfeedfacf,0xcefaedfe,0xcffaedfe,0xcafebabe].includes(buffer.readUInt32BE(0)));
137
+ // The pnpm executable: a native file named pnpm, pn or pnpm-native in the pnpm
138
+ // package (where Corepack downloads it beside pnpm's Node entry) or in an
139
+ // @pnpm/exe package. Returns its version, or null.
140
+ export function pnpmExecutable(file){
141
+ if(!['pnpm','pn','pnpm-native'].includes(path.basename(file))||!nativeMagic(head(file)))return null;
142
+ const dir=packageDir(file);
143
+ try{const pkg=JSON.parse(fs.readFileSync(path.join(dir,'package.json'),'utf8'));
144
+ return pkg.name!=='npm'&&pkg.name!=='yarn'&&pkg.name!=='corepack'&&typeof pkg.version==='string'?pkg.version:null;}
145
+ catch{return null;}
146
+ }
147
+ // Yarn 2 and later run a package's bin through a wrapper they write into a
148
+ // temporary directory: `exec "NODE" 'BIN' "$@"`.
149
+ const yarnWrapper=/^#!\/bin\/sh\nexec "([^"$`\\\n]+)" ((?:'[^'\n]*'(?: '[^'\n]*')*)?) "\$@"\n$/;
150
+ export const yarnWrapperNode=text=>{const match=yarnWrapper.exec(text);return match?match[1]:null;};
151
+ // What a file run as a program is: 'node' for Node itself, a script Node runs
152
+ // by its #! line, a pnpm launcher or a Yarn bin wrapper; 'pnpm' for the pnpm
153
+ // executable; else null. `text` is the script as the run saw it, when known.
154
+ export function programKind(file,known=null){
155
+ const base=path.basename(file),bytes=known!==null?Buffer.from(known):head(file);
156
+ if(!bytes)return null;
157
+ // Node itself is a native executable; a shim named node (asdf, nodenv) is a script.
158
+ if(base==='node'||base==='nodejs')return nativeMagic(bytes)?'node':null;
159
+ const text=bytes.toString('latin1');
160
+ if(text.startsWith('#!')){
161
+ const line=text.slice(2).split('\n')[0].trim().split(/\s+/);
162
+ // #!/usr/bin/env node, #!/usr/bin/env -S node --flag, or a path to node.
163
+ const words=path.basename(line[0]||'')==='env'?line.slice(1).filter(word=>word!=='-S'):line;
164
+ if(['node','nodejs'].includes(path.basename(words[0]||'')))return 'node';
165
+ if(!text.startsWith('#!/bin/sh\n'))return null;
166
+ let script=known;if(script===null)try{script=fs.readFileSync(file,'utf8');}catch{return null;}
167
+ if(pnpmShimTarget(file,script))return 'node';
168
+ const node=yarnWrapperNode(script),real=node&&lookup(node,'','/');
169
+ return real&&['node','nodejs'].includes(path.basename(node))&&programKind(real)==='node'?'node':null;
170
+ }
171
+ return pnpmExecutable(file)?'pnpm':null;
172
+ }
173
+
174
+ // A shell command line as simple commands of words, or {issue}. It accepts
175
+ // words, single and double quotes without expansions, &&, ||, ;, | and
176
+ // redirections to /dev/null or between descriptors. Anything that could
177
+ // change which program runs or read a file unseen (variables, command
178
+ // substitution, input redirection, subshells, here-documents) is refused.
179
+ export function parseCommandLine(text){
180
+ const unsupported={issue:'the script uses shell syntax REDUE cannot read'};
181
+ const commands=[];let words=[],word=null,glob=false;
182
+ const end=()=>{if(word!==null){words.push({text:word,glob});word=null;glob=false;}};
183
+ const separate=()=>{end();if(words.length)commands.push(words);words=[];};
184
+ for(let i=0;i<text.length;i++){
185
+ const c=text[i],rest=text.slice(i);
186
+ if(c===' '||c==='\t'){end();continue;}
187
+ if(c==='\n'||c===';'){separate();continue;}
188
+ if(rest.startsWith('&&')||rest.startsWith('||')){separate();i++;continue;}
189
+ if(c==='|'){separate();continue;}
190
+ if(word===null){
191
+ const redirect=/^(?:\d?>&\d|&?\d?>>? *\/dev\/null)(?=[\s;&|]|$)/.exec(rest);
192
+ if(redirect){i+=redirect[0].length-1;continue;}
193
+ if(c==='#'||c==='~'||c==='!')return unsupported;
194
+ }
195
+ if(c==="'"){const close=text.indexOf("'",i+1);if(close<0)return unsupported;word=(word??'')+text.slice(i+1,close);i=close;continue;}
196
+ if(c==='"'){
197
+ const close=text.indexOf('"',i+1);
198
+ if(close<0||/[$`\\]/.test(text.slice(i+1,close)))return unsupported;
199
+ word=(word??'')+text.slice(i+1,close);i=close;continue;
200
+ }
201
+ if('$`<>(){}\\&'.includes(c))return unsupported;
202
+ if('*?['.includes(c))glob=true;
203
+ word=(word??'')+c;
204
+ }
205
+ separate();
206
+ return {commands};
207
+ }
208
+ // Variables a script may not set or clear: they would stop Node loading the
209
+ // recorder (npm and pnpm set NODE_OPTIONS from their node-options setting),
210
+ // or change which program a name runs.
211
+ const guarded=new Set(['NODE_OPTIONS','REDUE_RUN_RECORD','PATH','NPM_CONFIG_NODE_OPTIONS','PNPM_CONFIG_NODE_OPTIONS',
212
+ 'NPM_CONFIG_SCRIPT_SHELL','PNPM_CONFIG_SCRIPT_SHELL','BASH_ENV','ENV']);
213
+ const isGuarded=name=>guarded.has(name.toUpperCase().replace(/-/g,'_'));
214
+ // Node options that make Node read a file natively, where the recorder cannot see it.
215
+ const nativeReads=/^--(?:env-file|env-file-if-exists|experimental-config-file|experimental-default-config-file)(?:=|$)/;
216
+ const builtins=new Set(['cd','echo','true','false',':','exit','printf','set','export','unset','exec','command','wait','shift']);
217
+ // pnpm commands that are not scripts.
218
+ const pnpmCommands=new Set(['add','audit','bin','config','create','dedupe','deploy','dlx','doctor','env','fetch','i','import',
219
+ 'init','install','install-test','it','link','list','ln','ls','outdated','pack','patch','prune','publish','rebuild',
220
+ 'recursive','remove','rm','root','self-update','server','setup','store','uninstall','unlink','up','update','why','x']);
221
+ // The PATH a package manager gives a script in `dir`: every node_modules/.bin
222
+ // from the directory up, then the caller's.
223
+ export const scriptPath=(dir,callerPath)=>{
224
+ const bins=[];for(let at=path.resolve(dir);;at=path.dirname(at)){bins.push(path.join(at,'node_modules','.bin'));if(path.dirname(at)===at)break;}
225
+ return [...bins,callerPath??''].join(path.delimiter);
226
+ };
227
+ // Why a shell command line could start a program REDUE does not record, or
228
+ // null. Names are looked up along `searchPath` from `cwd`. A script the pnpm
229
+ // executable runs is read the same way, since the recorder cannot see it.
230
+ export function commandLineIssue(text,{cwd,searchPath,depth=0}={}){
231
+ if(depth>8)return 'the scripts run each other too deeply for REDUE to read';
232
+ const parsed=parseCommandLine(text);
233
+ if(parsed.issue)return parsed.issue;
234
+ let dir=cwd;
235
+ for(let words of parsed.commands){
236
+ words=[...words];
237
+ while(words.length&&/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0].text)){
238
+ const name=words.shift().text.split('=')[0];
239
+ if(isGuarded(name))return `the script sets ${name}`;
240
+ }
241
+ for(;;){
242
+ if(!words.length)break;
243
+ const [first,...args]=words,name=first.text;
244
+ if(first.glob)return 'the script uses shell syntax REDUE cannot read';
245
+ if(builtins.has(name)){
246
+ if(name==='export'||name==='unset'){
247
+ const names=args.map(arg=>arg.text.split('=')[0]);
248
+ const set=names.find(isGuarded);
249
+ if(set)return `the script ${name==='unset'?'clears':'sets'} ${set}`;
250
+ }
251
+ if(name==='cd'){if(args.length===1&&!args[0].glob)dir=path.resolve(dir,args[0].text);else return 'the script uses shell syntax REDUE cannot read';}
252
+ // `exec X` and `command X` run X; `command -v X` only looks it up;
253
+ // `command -p X` runs X from a default PATH.
254
+ if(name==='command'&&args[0]?.text==='-p')return 'the script uses shell syntax REDUE cannot read';
255
+ if(name==='exec'||name==='command'&&args.length&&!args[0].text.startsWith('-')){words=args;continue;}
256
+ break;
257
+ }
258
+ const file=lookup(name,searchPath,dir);
259
+ if(!file)return `runs ${name}, which REDUE cannot find`;
260
+ const kind=programKind(file);
261
+ if(kind==='node'){
262
+ const flag=['node','nodejs'].includes(path.basename(file))&&args.find(arg=>nativeReads.test(arg.text));
263
+ if(flag)return `runs node ${flag.text.split('=')[0]}, which reads a file REDUE cannot see`;
264
+ break;
265
+ }
266
+ if(kind==='pnpm'){const issue=pnpmRunIssue(args.map(arg=>arg.text),{cwd:dir,searchPath,depth});if(issue)return issue;break;}
267
+ return `runs ${path.basename(name)}, which is not a Node program`;
268
+ }
269
+ }
270
+ return null;
271
+ }
272
+ // What the pnpm executable runs for `args` in `cwd`: a package script, or a
273
+ // bin with `exec`. Each script it runs is read as text; pnpm's own commands,
274
+ // filters and recursive runs are refused.
275
+ export function pnpmRunIssue(args,{cwd,searchPath,depth=0}){
276
+ const words=args.filter(arg=>!['--silent','-s','--reporter=silent'].includes(arg));
277
+ if(words[0]&&words[0].startsWith('-'))return `runs pnpm ${words[0]}, which REDUE cannot follow`;
278
+ let command=words[0]==='t'?'test':words[0],rest=words.slice(1);
279
+ if(command==='run'){command=rest[0];rest=rest.slice(1);if(!command)return 'runs pnpm run without a script';}
280
+ else if(command==='exec'){
281
+ if(!rest[0]||rest[0].startsWith('-'))return 'runs pnpm exec in a form REDUE cannot follow';
282
+ return commandLineIssue(rest.map(quote).join(' '),{cwd,searchPath:scriptPath(cwd,searchPath),depth:depth+1});
283
+ }else if(!command||pnpmCommands.has(command))return `runs pnpm ${command||''}, which REDUE cannot follow`.replace(' ,',',');
284
+ let scripts;
285
+ try{scripts=JSON.parse(fs.readFileSync(path.join(cwd,'package.json'),'utf8')).scripts||{};}
286
+ catch{return `runs pnpm ${command} where REDUE cannot read package.json`;}
287
+ const inner={cwd,searchPath:scriptPath(cwd,searchPath),depth:depth+1};
288
+ if(typeof scripts[command]!=='string'){
289
+ // `pnpm NAME` with no such script runs the bin NAME.
290
+ if(words[0]==='run')return `runs pnpm run ${command}, a script package.json does not define`;
291
+ return commandLineIssue([command,...rest].map(quote).join(' '),inner);
292
+ }
293
+ for(const name of ['pre'+command,command,'post'+command]){
294
+ if(typeof scripts[name]!=='string')continue;
295
+ const issue=commandLineIssue(name===command&&rest.length?`${scripts[name]} ${rest.map(quote).join(' ')}`:scripts[name],inner);
296
+ if(issue)return issue;
297
+ }
298
+ return null;
299
+ }
300
+ const quote=word=>/^[A-Za-z0-9_./:=,@+%^-]+$/.test(word)?word:`'${word.replace(/'/g,'')}'`;
301
+ const shells=new Set(['sh','bash','dash','zsh','ksh','mksh','fish','csh','tcsh']);
302
+ // Why a program a recorded process started (a C or S row) is not accounted
303
+ // for, or null; `needsHeader` says whether it must have loaded the recorder,
304
+ // and `opaque` that REDUE cannot see what it starts (the pnpm executable).
305
+ // `env` is what the child's environment says (test-recorder.cjs): a program
306
+ // that needs no recorder itself must pass it on. `toolchain` holds the
307
+ // package managers' directories; `parentMain` is the starting process's main
308
+ // script.
309
+ export function childProgram({file,args,cwd,searchPath,env=null,script=null},{toolchain=[],parentMain=null}={}){
310
+ const resolved=script?.file??lookup(file,searchPath,cwd||process.cwd());
311
+ const name=path.basename(file);
312
+ if(!resolved)return {issue:`ran ${name}, which REDUE cannot find`};
313
+ const passesRecorder=()=>env?.preload===true?null:
314
+ {issue:`ran ${name} without REDUE's recorder in NODE_OPTIONS (a node-options setting replaces it)`};
315
+ if(shells.has(path.basename(resolved))||shells.has(name)){
316
+ // A non-interactive sh or dash reads no startup file; bash -c sources
317
+ // BASH_ENV; zsh and the others always read their own files.
318
+ if(!['sh','dash','bash'].includes(name)||name==='bash'&&env?.bashEnv!==false)
319
+ return {issue:`ran ${name}, a shell that reads startup files REDUE cannot see`};
320
+ if(!Array.isArray(args)||args[0]!=='-c'||typeof args[1]!=='string')return {issue:`ran ${name} in a form REDUE cannot read`};
321
+ const issue=commandLineIssue(args[1],{cwd,searchPath});
322
+ return issue?{issue}:passesRecorder()??{needsHeader:false};
323
+ }
324
+ const kind=programKind(resolved,typeof script?.text==='string'?script.text:null);
325
+ if(kind==='node')return {needsHeader:true};
326
+ if(kind==='pnpm'){const issue=pnpmRunIssue(args||[],{cwd,searchPath});return issue?{issue}:passesRecorder()??{needsHeader:false,opaque:true};}
327
+ // pnpm, loaded into Corepack, lists processes to stop a script's children.
328
+ const within=(item,dir)=>item===dir||item.startsWith(dir+path.sep);
329
+ let main=null;try{main=parentMain&&fs.realpathSync(parentMain);}catch{}
330
+ if(name==='ps'&&JSON.stringify(args)===JSON.stringify(['-A','-o','ppid,pid'])&&main&&toolchain.some(dir=>within(main,dir)))
331
+ return {needsHeader:false};
332
+ return {issue:`ran ${name}, a program REDUE does not record`};
333
+ }
334
+ // What a recorded run needs from the check: the workspace package directories
335
+ // whose node_modules its installed inputs list. The script must still exist.
336
+ export function scriptRunFacts(root,check){
337
+ const dirs=(check.installedInputs||[]).map(p=>/^([^!*]+)\/node_modules\/\*\*$/.exec(p)?.[1]).filter(Boolean);
338
+ const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
339
+ if(typeof pkg.scripts?.[check.script]!=='string')fail('script-changed');
340
+ return {dirs};
341
+ }
342
+ // Plan qualification: the check is what `init` writes for the project now.
343
+ export function qualifyNodeScript(root,selected){
344
+ const issues=[];
345
+ if(!['darwin','linux'].includes(process.platform))issues.push('the general recipe records runs on macOS and Linux only');
346
+ try{
347
+ if(selected.cwd&&selected.cwd!=='.')fail('the general recipe runs scripts at the project root');
348
+ const project=scriptProject(root,selected.script);
349
+ if(JSON.stringify(selected.command?.slice(1))!==JSON.stringify(['run',selected.script])||
350
+ selected.environment?.pathExecutables?.[1]!==project.manager)
351
+ issues.push('the package manager or script changed; run redue init again');
352
+ const expected=scriptCheckInputs(project.dirs),sorted=list=>JSON.stringify([...(list||[])].sort());
353
+ if(sorted(selected.inputs)!==sorted(expected.inputs)||sorted(selected.installedInputs)!==sorted(expected.installedInputs))
354
+ issues.push('inputs must be the whole checkout, with every node_modules observed as installed files; run redue init again');
355
+ const environment=scriptEnvironment(project.manager);
356
+ if(!selected.environment?.executableIdentity||
357
+ !environment.variables.every(key=>selected.environment?.variables?.includes(key))||
358
+ !environment.prefixes.every(key=>selected.environment?.prefixes?.includes(key)))
359
+ issues.push('execution context incomplete; run redue init again');
360
+ }catch(e){issues.push(e.code||'script prerequisites unavailable');}
361
+ if(selected.probes?.length)issues.push('the general recipe takes no probes');
362
+ if(selected.generatedInputs?.length)issues.push('the general recipe takes no generated inputs');
363
+ return {qualified:issues.length===0,issues};
364
+ }
@@ -0,0 +1,124 @@
1
+ // An installed npm package checked against what the lockfile recorded: the
2
+ // tarball npm cached under the lockfile's integrity (sha512, else sha1),
3
+ // unpacked in memory and compared file by file. Nothing is fetched.
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import zlib from 'node:zlib';
8
+ import {createHash} from 'node:crypto';
9
+ import {npmrc} from './npmrc.mjs';
10
+ import {racyUntil} from './path-identity.mjs';
11
+
12
+ const fail=code=>{throw Object.assign(Error(code),{code});};
13
+ // npm's cache directory, as npm picks it: npm_config_cache, else `cache` in
14
+ // the project's .npmrc, then the user's (npm_config_userconfig or
15
+ // ~/.npmrc), else npm's default. A leading ~ is the home directory. The
16
+ // global npmrc is not read.
17
+ export function npmCacheDir(root=null,env=process.env,platform=process.platform,home=os.homedir()){
18
+ const variable=name=>Object.entries(env).find(([key])=>key.toLowerCase()===name)?.[1];
19
+ const expand=value=>path.resolve(value.replace(/^~(?=$|[\/])/,home));
20
+ const configured=variable('npm_config_cache');
21
+ if(configured)return expand(configured);
22
+ for(const file of [root&&path.join(root,'.npmrc'),variable('npm_config_userconfig')||path.join(home,'.npmrc')].filter(Boolean)){
23
+ let cache;try{cache=npmrc(file).cache;}catch{}
24
+ if(typeof cache==='string'&&cache)return expand(cache);
25
+ }
26
+ return platform==='win32'?path.join(env.LOCALAPPDATA||path.join(home,'AppData','Local'),'npm-cache'):path.join(home,'.npm');
27
+ }
28
+ // The package-lock.json entry for an installed top-level package:
29
+ // {version, integrity, resolved, alias}. npm-shrinkwrap.json, which npm reads
30
+ // instead, is not modeled. Parsed entries are kept per process by the
31
+ // lockfile's identity: the daemon asks on every reuse check.
32
+ const lockfiles=new Map();
33
+ export function lockedPackage(root,name){
34
+ if(fs.existsSync(path.join(root,'npm-shrinkwrap.json')))fail('npm-shrinkwrap-unsupported');
35
+ const file=path.join(root,'package-lock.json');
36
+ let st;try{st=fs.statSync(file,{bigint:true});}catch{fail('npm-lockfile-integrity-missing');}
37
+ const identity=[st.dev,st.ino,st.size,st.mtimeNs,st.ctimeNs].join(':');
38
+ let packages=lockfiles.get(file)?.identity===identity?lockfiles.get(file).packages:null;
39
+ if(!packages){
40
+ try{packages=JSON.parse(fs.readFileSync(file,'utf8'))?.packages;}catch{fail('npm-lockfile-unreadable');}
41
+ // A lockfile changed within the racy window is read again next time.
42
+ if(racyUntil(st)<=Date.now())lockfiles.set(file,{identity,packages});
43
+ }
44
+ const entry=packages?.[`node_modules/${name}`];
45
+ if(!entry||typeof entry.version!=='string'||typeof entry.integrity!=='string')fail('npm-lockfile-integrity-missing');
46
+ return {version:entry.version,integrity:entry.integrity,resolved:entry.resolved??null,alias:entry.name??null};
47
+ }
48
+ // Whether a lockfile entry is the registry's own package NAME at VERSION:
49
+ // no alias, and a tarball URL .../NAME/-/NAME-VERSION.tgz over https.
50
+ export const registryPackage=(entry,name,version)=>entry.alias===null&&typeof entry.resolved==='string'&&
51
+ /^https:\/\//.test(entry.resolved)&&entry.resolved.endsWith(`/${name}/-/${name}-${version}.tgz`);
52
+ // The digest an SRI string names, as {algorithm, hex}: sha512, else sha1
53
+ // (some registries record only sha1; a collision needs a malicious party,
54
+ // which is outside REDUE's threat model). null for anything else.
55
+ export function sriDigest(integrity){
56
+ const items=String(integrity).split(/\s+/);
57
+ for(const [algorithm,length] of [['sha512',64],['sha1',20]]){
58
+ const value=items.map(item=>new RegExp(`^${algorithm}-([A-Za-z0-9+/]+={0,2})$`).exec(item)?.[1]).find(Boolean);
59
+ const bytes=value&&Buffer.from(value,'base64');
60
+ if(bytes?.length===length)return {algorithm,hex:bytes.toString('hex')};
61
+ }
62
+ return null;
63
+ }
64
+ // The cached tarball for an integrity, checked against it.
65
+ export function cachedTarball(cache,integrity){
66
+ const digest=sriDigest(integrity);
67
+ if(!digest)fail('npm-lockfile-integrity-unsupported');
68
+ const {algorithm,hex}=digest;
69
+ let bytes;
70
+ try{bytes=fs.readFileSync(path.join(cache,'_cacache','content-v2',algorithm,hex.slice(0,2),hex.slice(2,4),hex.slice(4)));}
71
+ catch{fail('npm-tarball-uncached');}
72
+ if(createHash(algorithm).update(bytes).digest('hex')!==hex)fail('npm-tarball-corrupt');
73
+ return bytes;
74
+ }
75
+ // The regular files of a gzipped npm tarball, as Map(path below the top
76
+ // directory -> bytes). Links and special files are refused.
77
+ export function tarballFiles(gzipped){
78
+ let tar;try{tar=zlib.gunzipSync(gzipped,{maxOutputLength:512*1024*1024});}catch{fail('npm-tarball-invalid');}
79
+ const files=new Map();
80
+ const text=(block,from,to)=>block.subarray(from,to).toString('utf8').replace(/\0[\s\S]*$/,'');
81
+ let offset=0,longName=null,paxPath=null;
82
+ while(offset+512<=tar.length){
83
+ const header=tar.subarray(offset,offset+512);
84
+ if(header.every(byte=>byte===0))break;
85
+ const size=parseInt(text(header,124,136).trim()||'0',8),type=String.fromCharCode(header[156]||48);
86
+ if(!Number.isSafeInteger(size)||size<0)fail('npm-tarball-invalid');
87
+ const body=tar.subarray(offset+512,offset+512+size);
88
+ offset+=512+Math.ceil(size/512)*512;
89
+ if(type==='x'){
90
+ // pax records: "LEN key=value\n"
91
+ for(const record of body.toString('utf8').split('\n')){const match=/^\d+ path=(.*)$/.exec(record);if(match)paxPath=match[1];}
92
+ continue;
93
+ }
94
+ if(type==='g')continue;
95
+ if(type==='L'){longName=body.toString('utf8').replace(/\0[\s\S]*$/,'');continue;}
96
+ const prefix=text(header,345,500);
97
+ const name=paxPath??longName??(prefix?`${prefix}/${text(header,0,100)}`:text(header,0,100));
98
+ paxPath=null;longName=null;
99
+ if(type==='5')continue;
100
+ if(!['0','7'].includes(type))fail('npm-tarball-link-unsupported');
101
+ // npm strips the top directory (usually package/).
102
+ const relative=name.split('/').slice(1).join('/');
103
+ if(!relative||relative.split('/').some(part=>part===''||part==='.'||part==='..'))fail('npm-tarball-invalid');
104
+ files.set(relative,body);
105
+ }
106
+ return files;
107
+ }
108
+ // Whether `dir` holds exactly the tarball's files, byte for byte; throws
109
+ // otherwise. About 120 ms for TypeScript 6 (140 files, 24 MB).
110
+ export function matchTarball(dir,files){
111
+ let count=0;
112
+ const walk=(folder,relative='')=>{
113
+ let entries;try{entries=fs.readdirSync(folder,{withFileTypes:true});}catch{fail('direct-typescript-implementation-unreviewed');}
114
+ for(const entry of entries){
115
+ const name=relative?`${relative}/${entry.name}`:entry.name,full=path.join(folder,entry.name);
116
+ if(entry.isDirectory()){walk(full,name);continue;}
117
+ const expected=files.get(name);
118
+ let same=false;try{same=entry.isFile()&&Boolean(expected)&&fs.readFileSync(full).equals(expected);}catch{}
119
+ if(!same)fail('direct-typescript-implementation-unreviewed');
120
+ count++;
121
+ }
122
+ };walk(dir);
123
+ if(count!==files.size)fail('direct-typescript-implementation-unreviewed');
124
+ }
@@ -0,0 +1,36 @@
1
+ // Admission for the standalone npm 10.9.2 -> tsc --noEmit contract only.
2
+ // Values are never returned: the caller separately fingerprints every original
3
+ // npm_config_* key/value (including case and empty values).
4
+ import path from 'node:path';
5
+ export const npmEnvironmentInterpretation = 'npm-tsc-environment@2';
6
+ const proxyKeys = new Set(['proxy', 'https_proxy', 'http_proxy', 'noproxy']);
7
+ export function npmTypecheckEnvironmentIssue(env, {npmVersion, platform = process.platform} = {}) {
8
+ if (env.NODE_OPTIONS || env.NODE_PATH || env.BASH_ENV || env.ENV)
9
+ return 'execution-environment-unsupported';
10
+ const seen = new Map();
11
+ for (const [key, value] of Object.entries(env)) {
12
+ if (/^(DYLD_|TSGO_)/i.test(key)) return 'execution-environment-unsupported';
13
+ if (!/^npm_config_/i.test(key)) continue;
14
+ if (/^npm_config_prefix$/i.test(key) && platform === 'win32' && path.win32.isAbsolute(value)) continue;
15
+ const suffix = key.slice('npm_config_'.length).toLowerCase();
16
+ if (!proxyKeys.has(suffix)) return 'execution-environment-unsupported';
17
+ // http_proxy is an unknown, inert pass-through key in this inspected npm
18
+ // version, not an alias for proxy. Other npm versions require source review.
19
+ if (npmVersion !== '10.9.2') return 'npm-proxy-version-unsupported';
20
+ if (seen.has(suffix) && seen.get(suffix) !== value)
21
+ return 'npm-proxy-context-ambiguous';
22
+ seen.set(suffix, value);
23
+ if (typeof value !== 'string' || /[\x00-\x1f\x7f]/.test(value))
24
+ return 'npm-proxy-value-unsupported';
25
+ // npm ignores empty environment config, but passes the original environment
26
+ // to the child. Empty and absent remain distinct caller identities.
27
+ if (value === '' || (suffix === 'proxy' && value === 'false')) continue;
28
+ if (suffix === 'noproxy') continue;
29
+ try {
30
+ const url = new URL(value);
31
+ if (!['http:', 'https:'].includes(url.protocol) || !url.hostname)
32
+ return 'npm-proxy-value-unsupported';
33
+ } catch { return 'npm-proxy-value-unsupported'; }
34
+ }
35
+ return null;
36
+ }
package/src/npmrc.mjs ADDED
@@ -0,0 +1,16 @@
1
+ // An .npmrc file read as npm reads ini: comments, a BOM, `key = value`, a bare
2
+ // key as true, quotes stripped, true/false as booleans. {} when it is absent.
3
+ import fs from 'node:fs';
4
+
5
+ export function npmrc(file){
6
+ const settings={};
7
+ if(!fs.existsSync(file))return settings;
8
+ for(const line of fs.readFileSync(file,'utf8').replace(/^\uFEFF/,'').split(/\r?\n/)){
9
+ const trimmed=line.trim();if(!trimmed||/^[#;]/.test(trimmed))continue;
10
+ const at=trimmed.indexOf('='),key=(at<0?trimmed:trimmed.slice(0,at)).trim();
11
+ let value=at<0?true:trimmed.slice(at+1).trim();
12
+ if(typeof value==='string'&&/^(["']).*\1$/.test(value))value=value.slice(1,-1);
13
+ settings[key]=value==='true'?true:value==='false'?false:value;
14
+ }
15
+ return settings;
16
+ }
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import {mark,traceEnvironment} from './decision-profile.mjs';
2
3
  import path from 'node:path';
3
4
  import {randomUUID} from 'node:crypto';
4
5
  import {fork} from 'node:child_process';
@@ -8,16 +9,39 @@ import {fileURLToPath} from 'node:url';
8
9
  // subscription is healthy; history may accelerate catch-up; a fresh scan is
9
10
  // the deterministic fallback. A failed history operation never updates cursor.
10
11
  const workerFile=path.join(path.dirname(fileURLToPath(import.meta.url)),'history-worker.mjs');
12
+ // Each query runs in a fresh helper: a reused native history session can take
13
+ // ten times longer. The next helper is forked once a query settles, so its
14
+ // startup is off the decision path. A failed or late helper is killed.
15
+ let spare=null,queue=Promise.resolve(),closed=false;
16
+ // After the observer's last query no helper is kept or started.
17
+ export function closeHistory() {closed=true;spare?.kill('SIGKILL');spare=null;}
18
+ function startHelper() {
19
+ const child=fork(workerFile,[],{stdio:['ignore','ignore','ignore','ipc'],env:traceEnvironment()});
20
+ child.abandoned=new Set();child.on('error',()=>{});
21
+ child.once('exit',()=>{if(spare===child)spare=null;
22
+ child.reply?.({ok:false,lost:Error(`history helper exited ${child.exitCode??child.signalCode}`)});
23
+ for(const next of child.abandoned)try{fs.unlinkSync(next);}catch{}});
24
+ return child;
25
+ }
11
26
  export function history(root,cursor,backend,deadlineMs,faultFile) {
27
+ const run=()=>query(root,cursor,backend,deadlineMs,faultFile);
28
+ const result=queue.then(run,run);queue=result.catch(()=>{});return result;
29
+ }
30
+ function query(root,cursor,backend,deadlineMs,faultFile) {
12
31
  const next=cursor+'.'+randomUUID()+'.next';
13
32
  return new Promise((resolve,reject)=>{
14
- const worker=fork(workerFile,[root,fs.existsSync(cursor)?cursor:'',next,backend,
15
- faultFile||''],{stdio:['ignore','ignore','ignore','ipc']});
16
- let settled=false,abandoned=false;
33
+ mark('history.submit',{deadlineMs});
34
+ const worker=spare??startHelper();spare=null;
35
+ worker.ref();worker.channel?.ref();
36
+ let settled=false;
17
37
  const finish=(error,value)=>{
18
- if(settled)return;settled=true;clearTimeout(timer);
38
+ if(settled)return;settled=true;worker.reply=null;clearTimeout(timer);
39
+ mark('history.complete',{childPid:worker.pid,outcome:error?.code||error?.name||'ok',events:value?.length});
40
+ if(error)worker.abandoned.add(next);
19
41
  worker.kill('SIGKILL');
20
- if(error){abandoned=true;try{fs.unlinkSync(next);}catch{}
42
+ // A failed query disables history for this observer; keep no helper.
43
+ if(!closed&&!error){spare=startHelper();spare.unref();spare.channel?.unref();}
44
+ if(error){try{fs.unlinkSync(next);}catch{}
21
45
  reject(error);return;}
22
46
  try{fs.renameSync(next,cursor);resolve(value);}
23
47
  catch(e){try{fs.unlinkSync(next);}catch{}reject(e);}
@@ -26,12 +50,10 @@ export function history(root,cursor,backend,deadlineMs,faultFile) {
26
50
  const e=Error(`history query did not settle within ${deadlineMs} ms`);
27
51
  e.code='ETIMEDOUT';finish(e);
28
52
  },deadlineMs);
29
- worker.on('message',message=>message.ok?finish(null,message.changes):
30
- finish(Object.assign(Error(message.error),{code:message.code})));
31
- worker.once('error',e=>finish(e));
32
- worker.once('exit',(code,signal)=>{
33
- if(abandoned)try{fs.unlinkSync(next);}catch{}
34
- if(!settled)finish(Error(`history helper exited ${code??signal}`));
35
- });
53
+ worker.reply=message=>message.lost?finish(message.lost):message.ok?finish(null,message.changes):
54
+ finish(Object.assign(Error(message.error),{code:message.code}));
55
+ worker.on('message',message=>worker.reply?.(message));
56
+ try{worker.send({root,previous:fs.existsSync(cursor)?cursor:'',next,backend,faultFile:faultFile||''});}
57
+ catch(e){finish(e);}
36
58
  });
37
59
  }
@@ -41,3 +41,11 @@ export function validInputRelative(rel,platform=process.platform) {
41
41
  rel.includes('\\')))return false;
42
42
  return true;
43
43
  }
44
+
45
+ // File identity fields. Any write changes ctime, but timestamps tick in
46
+ // milliseconds: a file changed within the racy window could be rewritten in the
47
+ // same tick at the same size without changing them (git's racy rule).
48
+ export const identityFields=['dev','ino','mode','nlink','size','mtimeNs','ctimeNs'];
49
+ export const racyWindowMs=2000;
50
+ // ctime, which no ordinary tool can set ahead, ends the window.
51
+ export const racyUntil=st=>Number(st.ctimeNs/1000000n)+racyWindowMs;