@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,560 @@
1
+ // The direct test recipe's guard. A recorded test run treats the whole
2
+ // checkout as its inputs (the plan), so the recorder (test-recorder.cjs) only
3
+ // has to show that the run used nothing else: no excluded or unobserved file
4
+ // content, nothing outside the project but lookups that can be asked again,
5
+ // no network, no environment variable outside the compared list, and no
6
+ // process that escaped the recorder.
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import {createHash} from 'node:crypto';
11
+ import {fileURLToPath} from 'node:url';
12
+ import {spawnSync} from 'node:child_process';
13
+ import {patternMatcher} from './glob.mjs';
14
+ import {runFact} from './direct-run-certificate.mjs';
15
+ import {queryAnswer} from './typescript-list.mjs';
16
+ import {pnpmInstallInfo,pnpmProjectInfo,yaml} from './pnpm-inputs.mjs';
17
+ import {workspaceManifests} from './workspace-manifests.mjs';
18
+ import {atomicJson} from './state-store.mjs';
19
+ import {racyUntil} from './path-identity.mjs';
20
+
21
+ const digest=value=>createHash('sha256').update(value).digest('hex');
22
+ export const testRecorder=fileURLToPath(new URL('test-recorder.cjs',import.meta.url));
23
+ // Variables the recorder cannot see being read, compared by the
24
+ // caller-context machinery: Node and libc read TZ, LANG, LC_*, NODE_*, DYLD_*
25
+ // and LD_* natively, and Vite picks VITE_* by listing the environment. Every
26
+ // other variable the run reads directly is compared by value (environmentReads).
27
+ export const testEnvironment={variables:['TZ','LANG'],
28
+ prefixes:['NODE_','LC_','VITE_','DYLD_','LD_'],executableIdentity:true};
29
+ // Display-only variables, not compared. `_` is the shell's last command;
30
+ // argument parsers (yargs) use it only for help text.
31
+ export const displayVariables=['_','TERM','TERM_PROGRAM','TERM_PROGRAM_VERSION','COLORTERM','FORCE_COLOR','NO_COLOR',
32
+ 'FORCE_TTY','COLUMNS','LINES','AI_AGENT','REDUE_RUN_RECORD'];
33
+ // The runners' own data directories, like their caches, are not inputs:
34
+ // Vitest keeps its API token in <user data>/vitest (env-paths' data
35
+ // directory, not the state directory REDUE uses). A relative XDG_DATA_HOME
36
+ // is ignored, as the XDG specification says.
37
+ export function runnerDataDirectories(env=process.env){
38
+ const data=process.platform==='darwin'?path.join(os.homedir(),'Library','Application Support'):
39
+ path.isAbsolute(env.XDG_DATA_HOME||'')?env.XDG_DATA_HOME:path.join(os.homedir(),'.local','share');
40
+ return [path.join(data,'vitest')];
41
+ }
42
+ const issueLimit=20,factLimit=10000;
43
+
44
+ // The guard file the recorder reads: where to write, what counts as the
45
+ // checkout and as temporary space, and digests of the caller's environment.
46
+ export function testGuardConfig(dir,root,env=process.env,project={}) {
47
+ const tmp=[...new Set([path.resolve(os.tmpdir()),fs.realpathSync(os.tmpdir())])];
48
+ const real=dir=>{try{return fs.realpathSync(dir);}catch{return dir;}};
49
+ return {schema:1,dir,root,tmp,installed:modulesDirs(project).map(at=>path.join(root,at)),runnerData:[...new Set(runnerDataDirectories(env).flatMap(dir=>[path.resolve(dir),real(dir)]))],
50
+ env:Object.fromEntries(Object.entries(env).filter(([,value])=>typeof value==='string').map(([key,value])=>[key,digest(value)]))};
51
+ }
52
+
53
+ // The environment a recorded run starts with: the caller's, plus the preload.
54
+ // Node splits NODE_OPTIONS on spaces and honours double quotes.
55
+ export function recordedEnvironment(guardFile,env=process.env) {
56
+ const preload=`--require "${testRecorder.replace(/["\\]/g,'\\$&')}"`;
57
+ return {...env,NODE_OPTIONS:env.NODE_OPTIONS?`${env.NODE_OPTIONS} ${preload}`:preload,REDUE_RUN_RECORD:guardFile};
58
+ }
59
+
60
+ // What the run used beyond its observed inputs. `files` maps each observed
61
+ // input (relative path) at the start checkpoint to its digest; `runner` is the
62
+ // PID of the process REDUE started. Returns {issues, runFacts, environmentReads,
63
+ // isOutput}: environmentReads maps each variable the run read directly to the
64
+ // caller's value digest, or '<unset>'. `programs` is the general recipe's
65
+ // account of processes (node-script.mjs): `judge(child, parentMain)` says
66
+ // whether a started program is accounted for and must load the recorder,
67
+ // `runner` describes the program REDUE started, and `trusted` names checkout
68
+ // paths (runner caches) whose use is not an input.
69
+ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=testEnvironment,coverage=null,programs=null}) {
70
+ // The recipe's variables and any the project declares are compared, not guarded.
71
+ const compared=name=>(environment.variables||[]).includes(name)||
72
+ (environment.prefixes||[]).some(prefix=>name.toLowerCase().startsWith(prefix.toLowerCase()));
73
+ const issues=[],add=issue=>{if(!issues.includes(issue))issues.push(issue);};
74
+ const included=patternMatcher(sourcePatterns);
75
+ const observed=rel=>Object.hasOwn(files||{},rel);
76
+ const headers=new Map(),records=[];
77
+ let names=[];
78
+ try{names=fs.readdirSync(dir).filter(name=>name.endsWith('.jsonl'));}
79
+ catch{return {issues:['the run record is unavailable'],runFacts:[],environmentReads:{},isOutput:()=>false};}
80
+ for(const name of names){
81
+ let rows;
82
+ try{rows=fs.readFileSync(path.join(dir,name),'utf8').split('\n').filter(Boolean).map(line=>JSON.parse(line));}
83
+ catch{add('a run record is unreadable');continue;}
84
+ const header=rows[0]?.[0]==='H'?rows[0][1]:null;
85
+ if(!header||!Number.isSafeInteger(header.pid)||!Number.isSafeInteger(header.threadId)){add('a run record is unreadable');continue;}
86
+ headers.set(`${header.pid}-${header.threadId}`,header);
87
+ records.push({header,rows:rows.slice(1)});
88
+ }
89
+ let opaque=false;
90
+ if(!programs){if(!headers.has(`${runner}-0`))add('the test runner did not load the REDUE recorder');}
91
+ else{const judged=programs.judge(programs.runner,null);
92
+ if(judged.issue)add(judged.issue);
93
+ else if(judged.needsHeader&&!headers.has(`${runner}-0`))add('the package manager did not load the REDUE recorder');
94
+ opaque||=Boolean(judged.opaque);}
95
+ const trusted=programs?.trusted?patternMatcher(['**',...programs.trusted.map(pattern=>'!'+pattern)]):null;
96
+ // A cache directory itself, and everything in it.
97
+ const isTrusted=rel=>trusted!==null&&(!trusted(rel)||!trusted(rel+'/_'));
98
+ // Answers per question; a question answered two ways changed during the run.
99
+ const answers=new Map(),ask=(key,answer)=>{const set=answers.get(key)||new Set();set.add(JSON.stringify(answer));answers.set(key,set);};
100
+ // Every child with a PID, accepted shells too: a header with one of these
101
+ // PIDs is that child, not an execSync child (which reports no PID).
102
+ const installedWrites=new Set();
103
+ const reads=new Map(),produced=new Set(),created=new Set(),environmentReads={},spawned=new Map(),childPids=new Map();
104
+ for(const {header,rows} of records){
105
+ for(const row of rows){
106
+ const [kind,...value]=Array.isArray(row)?row:[];
107
+ if(kind==='U')add(`recorder: ${value[0]}`);
108
+ else if(kind==='R')reads.set(value[0],reads.get(value[0])||value[1]===true);
109
+ else if(kind==='P')produced.add(path.join(root,value[0]));
110
+ else if(kind==='I')installedWrites.add(value[0]);
111
+ else if(kind==='M')created.add(value[0]);
112
+ else if(kind==='L')ask(`stat\0${value[0]}`,value[1]);
113
+ else if(kind==='D')ask(`entries\0${value[0]}`,value[1]);
114
+ else if(kind==='O')ask(`outside-${value[0]}\0${value[1]}`,value[2]);
115
+ else if(kind==='E'){const [name,state]=value;
116
+ if(compared(name)||displayVariables.includes(name)||typeof name!=='string')continue;
117
+ environmentReads[name]=typeof state==='string'?state:'<unset>';}
118
+ else if(kind==='N')add(`network: ${value[0]}`);
119
+ else if(kind==='C'||kind==='S'){
120
+ const [file,args,cwd,searchPath,env,script]=kind==='C'?value.slice(1):value;
121
+ if(kind==='C')childPids.set(header.pid,[...childPids.get(header.pid)||[],value[0]]);
122
+ // The general recipe accounts for each program; one that needs no
123
+ // recorder (a shell, the pnpm executable) is not matched to a header.
124
+ if(programs){const judged=programs.judge({file,args,cwd,searchPath,env,script},header.main??null);
125
+ if(judged.issue){add(judged.issue);continue;}
126
+ opaque||=Boolean(judged.opaque);
127
+ if(!judged.needsHeader)continue;}
128
+ const list=spawned.get(header.pid)||[];list.push({pid:kind==='C'?value[0]:null,file});
129
+ spawned.set(header.pid,list);}
130
+ else if(kind==='T'){if(!headers.has(`${header.pid}-${value[0]}`))add('a worker thread did not load the REDUE recorder');}
131
+ else if(kind!=='H')add('a run record is unreadable');
132
+ }
133
+ }
134
+ // Every child must be a recorded Node process: by PID when known, otherwise
135
+ // by the number of recorded processes whose parent started it.
136
+ for(const [parent,children] of spawned){
137
+ const recorded=[...headers.values()].filter(h=>h.threadId===0&&h.ppid===parent);
138
+ const unknown=children.filter(child=>child.pid===null);
139
+ for(const child of children)if(child.pid!==null&&!recorded.some(h=>h.pid===child.pid))
140
+ add(`started ${path.basename(child.file)}, which REDUE does not record`);
141
+ const matched=recorded.filter(h=>!(childPids.get(parent)||[]).includes(h.pid)).length;
142
+ if(unknown.length>matched)add(`started ${path.basename(unknown[0].file)}, which REDUE does not record`);
143
+ }
144
+ // What the pnpm executable starts is not seen. Its node-options setting
145
+ // replaces NODE_OPTIONS for everything it starts, so a run through it must
146
+ // show at least one recorded Node process the recorded ones did not start.
147
+ if(opaque&&![...headers.values()].some(h=>h.threadId===0&&h.pid!==runner&&!headers.has(`${h.ppid}-0`)))
148
+ add('no Node process the pnpm executable started loaded the REDUE recorder');
149
+ // Inside a directory the run created. A file the run wrote excuses nothing:
150
+ // another process may have read it before the write. Listings may differ
151
+ // by such files (listingAfter).
152
+ const inCreated=full=>{for(let at=path.dirname(full);;at=path.dirname(at)){if(created.has(at))return true;if(path.dirname(at)===at)return false;}};
153
+ const madeByRun=full=>created.has(full)||inCreated(full);
154
+ const runFacts=[];
155
+ const checkout=rel=>path.join(root,rel);
156
+ // The coverage report a --coverage run writes and may read back
157
+ // (coverageReadable); its contents do not decide pass or fail.
158
+ const inCoverage=rel=>coverage!==null&&(rel===coverage||rel.startsWith(coverage+'/'));
159
+ // File content in the checkout must be an observed input.
160
+ // A runner cache is git-ignored; a file in a cache path git keeps is source.
161
+ const trustedReads=[...reads].filter(([rel,found])=>found&&isTrusted(rel)&&!inCreated(checkout(rel))).map(([rel])=>rel);
162
+ const ignoredCaches=gitIgnored(root,trustedReads);
163
+ for(const [rel,found] of [...reads].sort(([a],[b])=>a<b?-1:a>b?1:0)){
164
+ if(inCreated(checkout(rel))||inCoverage(rel)||isTrusted(rel)&&(!found||ignoredCaches.has(rel)||produced.has(checkout(rel))))continue;
165
+ if(!included(rel))add(`read ${rel}, which the check's inputs exclude`);
166
+ else if(found&&!observed(rel))add(`read ${rel}, which REDUE does not observe (ignored by git)`);
167
+ // A missing file may be ignored by git, so the index would not see it appear.
168
+ else if(!found&&!produced.has(checkout(rel)))runFacts.push(['fileExists',checkout(rel),null]);
169
+ }
170
+ for(const [key,set] of [...answers].sort(([a],[b])=>a<b?-1:a>b?1:0)){
171
+ const [question,where]=key.split('\0');
172
+ const answer=JSON.parse([...set][0]);
173
+ if(question==='stat'||question==='entries'){
174
+ const full=checkout(where);
175
+ // A path the run made answers differently afterwards; it is output, not input.
176
+ if(madeByRun(full)||produced.has(full)||inCoverage(where)||isTrusted(where))continue;
177
+ if(set.size>1){add(`${where} changed during the run`);continue;}
178
+ if(question==='stat'){
179
+ // Observed files and directories: the index covers them. A missing
180
+ // path may be ignored by git, so it stays a fact.
181
+ if(included(where)&&(answer==='d'||observed(where)))continue;
182
+ const fact=answer==='error'||answer==='o'?null:runFact('stat',full,answer);
183
+ if(fact)runFacts.push(fact);else add(`lookup of ${where} gave ${answer}`);
184
+ continue;
185
+ }
186
+ // A listing the run itself added to or removed from is kept as it is now.
187
+ runFacts.push(['entries',full,answer==='error'?answer:listingAfter(full,answer,file=>madeByRun(file)||produced.has(file))]);
188
+ continue;
189
+ }
190
+ const kind=question.slice('outside-'.length),file=where;
191
+ if(madeByRun(file))continue;
192
+ if(set.size>1){add(`${file} changed during the run`);continue;}
193
+ const fact=answer==='error'?null:kind==='stat'||kind==='realpath'?runFact(kind,file,answer):
194
+ answer===null?['fileExists',file,null]:kind==='read'&&typeof answer?.content==='string'?['readFile',file,answer.content]:null;
195
+ if(fact)runFacts.push(fact);
196
+ else add(kind==='read'?`read ${file}, outside the project`:kind==='list'?`listed ${file}, outside the project`:
197
+ `${kind} of ${file} outside the project gave ${answer}`);
198
+ }
199
+ if(runFacts.length>factLimit)add('too many lookups during the run');
200
+ // A checkout path the run wrote without reading what was there before:
201
+ // inside a directory it created, or a file it created or truncated and no
202
+ // process read first.
203
+ // A read that found nothing read no leftover.
204
+ const isOutput=rel=>!rel.startsWith('external:')&&(inCreated(checkout(rel))||created.has(checkout(rel))||
205
+ produced.has(checkout(rel))&&reads.get(rel)!==true);
206
+ return {issues:issues.length>issueLimit?[...issues.slice(0,issueLimit),`${issues.length-issueLimit} more`]:issues,
207
+ runFacts:runFacts.length>factLimit?[]:[...new Map(runFacts.map(f=>[JSON.stringify(f),f])).values()],
208
+ environmentReads:Object.fromEntries(Object.entries(environmentReads).sort(([a],[b])=>a<b?-1:a>b?1:0)),isOutput,opaque,
209
+ wroteInstalled:rel=>installedWrites.has(rel)};
210
+ }
211
+ // The observed files that differ between two checkpoints' file maps.
212
+ export function changedFiles(before,after){
213
+ return [...new Set([...Object.keys(before||{}),...Object.keys(after||{})])].filter(file=>before?.[file]!==after?.[file]).sort();
214
+ }
215
+ // Files git ignores among `files`, in one call. A leading `./` keeps a name
216
+ // that starts with `:` from being read as pathspec magic. An error counts as
217
+ // not ignored.
218
+ export function gitIgnored(root,files){
219
+ if(!files.length)return new Set();
220
+ const out=spawnSync('git',['check-ignore','--stdin','-z'],{cwd:root,input:files.map(file=>'./'+file).join('\0')+'\0',
221
+ encoding:'utf8',maxBuffer:64*1024*1024,timeout:10000,env:{...process.env,GIT_OPTIONAL_LOCKS:'0'}});
222
+ return new Set(out.status===0?out.stdout.split('\0').filter(Boolean).map(file=>file.replace(/^\.\//,'')):[]);
223
+ }
224
+ // The listing now, when it differs from the run's only by entries the run made.
225
+ function listingAfter(full,during,madeByRun){
226
+ let now;try{now=queryAnswer('entries',full);}catch{return during;}
227
+ const own=entries=>Array.isArray(entries)?entries.filter(([name])=>!madeByRun(path.join(full,name))):entries;
228
+ return JSON.stringify(own(now))===JSON.stringify(own(during))?now:during;
229
+ }
230
+
231
+ // The recipe: a package.json script that runs Vitest or Jest directly, run by
232
+ // REDUE with the local runner and Node, never through npm.
233
+ export const testsQualification='npm-tests-direct-v1';
234
+ export const testsInterpretation='npm-direct-tests@1';
235
+ const fail=code=>{throw Object.assign(Error(code),{code});};
236
+ // Majors whose recorded runs were checked; Jest's per-file environment copy
237
+ // is recorded through jest-util 29.
238
+ const runners={vitest:{majors:[4,5]},jest:{majors:[29]}};
239
+ // Flags that keep the run a single, deterministic pass over the selected
240
+ // tests. Watch, changed-file, snapshot-update, shuffle and shard flags are not
241
+ // here. `value` flags take the next argument unless written with `=`.
242
+ const flags={
243
+ vitest:{plain:['--run','--no-watch','--coverage','--silent','--passWithNoTests','--typecheck','--globals',
244
+ '--no-file-parallelism','--allowOnly'],
245
+ value:['--config','-c','--root','-r','--dir','--project','--reporter','--environment','--pool','--maxWorkers',
246
+ '--testTimeout','--hookTimeout','--bail','--retry','--exclude']},
247
+ jest:{plain:['--coverage','--ci','--detectOpenHandles','--forceExit','--runInBand','-i','--silent','--verbose',
248
+ '--passWithNoTests','--colors','--no-cache','--logHeapUsage','--noStackTrace','--no-watchman','--errorOnDeprecated','--useStderr'],
249
+ value:['--config','-c','--testPathIgnorePatterns','--testPathPattern','--testPathPatterns','--maxWorkers','-w',
250
+ '--testTimeout','--selectProjects','--testNamePattern','-t','--bail']},
251
+ };
252
+ // A script's runner and arguments, or an error code. Shell syntax (quotes,
253
+ // globs, variables, chaining) would change what runs, so it is refused.
254
+ export function testScript(value){
255
+ const text=typeof value==='string'?value.trim():'';
256
+ if(!/^[A-Za-z0-9_./:=,@+-]+(?: [A-Za-z0-9_./:=,@+-]+)*$/.test(text))fail('test-script-unsupported');
257
+ const [runner,...args]=text.split(' ');
258
+ if(!runners[runner])fail('test-runner-unsupported');
259
+ const allowed=flags[runner];
260
+ let watchOff=false,positional=0;
261
+ for(let i=0;i<args.length;i++){
262
+ const arg=args[i];
263
+ if(!arg.startsWith('-')){
264
+ // Vitest takes its first positional argument as the command, after
265
+ // flags too. Its only command here is `run`; others are filters.
266
+ if(runner==='vitest'&&positional++===0){
267
+ if(['watch','dev','related','bench','init','list','typecheck'].includes(arg))fail('test-arguments-unsupported');
268
+ if(arg==='run')watchOff=true;
269
+ }
270
+ continue;
271
+ }
272
+ const [name,inline]=arg.split(/=(.*)/s);
273
+ if(runner==='vitest'&&(arg==='--watch=false'||arg==='--run'||arg==='--no-watch')){watchOff=true;continue;}
274
+ if(allowed.plain.includes(arg))continue;
275
+ if(allowed.value.includes(name)){
276
+ if(inline===undefined){if(i+1>=args.length||args[i+1].startsWith('-'))fail('test-arguments-unsupported');i++;}
277
+ else if(!inline)fail('test-arguments-unsupported');
278
+ continue;
279
+ }
280
+ fail('test-arguments-unsupported');
281
+ }
282
+ // Without `run`, Vitest watches when it has a terminal.
283
+ if(runner==='vitest'&&!watchOff)fail('test-watch-mode');
284
+ return {runner,args};
285
+ }
286
+ // The script `init` picks: a familiar name when it runs a runner directly,
287
+ // otherwise the only script that does.
288
+ export function testRecipeScript(scripts){
289
+ const issue=name=>{try{testScript(scripts[name]);return null;}catch(e){return e.code;}};
290
+ const runs=name=>typeof scripts[name]==='string'&&/^(?:vitest|jest)(?:\s|$)/.test(scripts[name].trim());
291
+ const preferred=['test','test:unit','test:check'].find(name=>typeof scripts[name]==='string'&&!issue(name));
292
+ if(preferred)return {script:preferred};
293
+ const candidates=Object.keys(scripts).filter(name=>runs(name)&&!issue(name));
294
+ if(candidates.length)return candidates.length===1?{script:candidates[0]}:{ambiguous:candidates};
295
+ // Why the nearest script does not qualify.
296
+ const near=Object.keys(scripts).find(runs);
297
+ return near?{issue:issue(near)}:{};
298
+ }
299
+ const majorOf=version=>Number(String(version).split('.')[0]);
300
+ // The runner's executable inside the checkout's node_modules. pnpm links
301
+ // node_modules/RUNNER into node_modules/.pnpm; the files stay in node_modules.
302
+ export function testRunnerEntry(root,runner){
303
+ if(!runners[runner])fail('test-runner-unsupported');
304
+ const dir=path.join(root,'node_modules',runner);
305
+ let pkg,real;
306
+ try{real=fs.realpathSync(dir);pkg=JSON.parse(fs.readFileSync(path.join(real,'package.json'),'utf8'));}
307
+ catch{fail('test-runner-missing');}
308
+ const modules=fs.realpathSync(path.join(root,'node_modules'));
309
+ if(!real.startsWith(modules+path.sep)||pkg.name!==runner)fail('test-runner-layout-unsupported');
310
+ const bin=typeof pkg.bin==='string'?pkg.bin:pkg.bin?.[runner];
311
+ const entry=typeof bin==='string'?path.resolve(real,bin):null;
312
+ if(!entry||!entry.startsWith(real+path.sep)||!fs.existsSync(entry))fail('test-runner-layout-unsupported');
313
+ return entry;
314
+ }
315
+ // The package manager of a checkout whose install the recipe observes in
316
+ // node_modules: npm, pnpm 9-12 (isolated or hoisted), or Yarn with the
317
+ // node-modules linker. One lockfile kind, agreeing with any declaration.
318
+ // Workspaces are refused unless `workspaces` is set.
319
+ export function testPackageManager(root,pkg,{workspaces=false}={}){
320
+ const has=file=>fs.existsSync(path.join(root,file));
321
+ const kinds=new Set([has('package-lock.json')||has('npm-shrinkwrap.json')?'npm':null,has('pnpm-lock.yaml')?'pnpm':null,
322
+ has('yarn.lock')?'yarn':null,has('bun.lock')||has('bun.lockb')?'bun':null].filter(Boolean));
323
+ const declared=/^(npm|pnpm|yarn|bun)@/.exec(pkg.packageManager||'')?.[1];
324
+ if(pkg.packageManager!==undefined&&!declared)fail('test-package-manager-unsupported');
325
+ if(declared)kinds.add(declared);
326
+ const name=kinds.size===1?[...kinds][0]:null;
327
+ if(!name||!has({npm:'package-lock.json',pnpm:'pnpm-lock.yaml',yarn:'yarn.lock',bun:'bun.lock'}[name])&&
328
+ !(name==='npm'&&has('npm-shrinkwrap.json')))fail('test-package-manager-unsupported');
329
+ if(pkg.workspaces&&!workspaces)fail('test-workspace-unsupported');
330
+ if(name==='pnpm'){
331
+ // pnpm 10+ keeps settings in pnpm-workspace.yaml; only `packages` makes a workspace.
332
+ let install;try{install=pnpmInstallInfo(root);}catch{fail('test-pnpm-install-unsupported');}
333
+ if(install.patterns.length&&!workspaces)fail('test-workspace-unsupported');
334
+ }
335
+ if(name==='yarn'){
336
+ // Yarn 2+ needs top-level `nodeLinker: node-modules` and the classic nmMode
337
+ // (no hard links); Yarn 1 always installs node_modules.
338
+ const rc=has('.yarnrc.yml')?fs.readFileSync(path.join(root,'.yarnrc.yml'),'utf8'):null;
339
+ const top=key=>rc?new RegExp(`^${key}\\s*:\\s*['"]?([\\w-]+)`,'m').exec(rc)?.[1]:undefined;
340
+ const version=/^yarn@(\d+)\./.exec(pkg.packageManager||'')?.[1];
341
+ if(!version||(version==='1'?rc!==null:top('nodeLinker')!=='node-modules'||![undefined,'classic'].includes(top('nmMode'))))
342
+ fail('test-yarn-install-unsupported');
343
+ }
344
+ if(name==='bun')fail('test-package-manager-unsupported');
345
+ return name;
346
+ }
347
+ // What a recorded run needs, from the check itself rather than the checkout:
348
+ // the package directory, the workspace node_modules its installed inputs
349
+ // list, and the runner and arguments of the package's script. A package
350
+ // added since init is then not treated as installed, so reads there are
351
+ // unobserved and make the run UNVERIFIED.
352
+ export function testRunFacts(root,check){
353
+ const dir=check.cwd&&check.cwd!=='.'?check.cwd:'.';
354
+ const dirs=(check.installedInputs||[]).map(p=>/^([^!*]+)\/node_modules\/\*\*$/.exec(p)?.[1]).filter(Boolean);
355
+ const own=JSON.parse(fs.readFileSync(path.join(root,dir,'package.json'),'utf8'));
356
+ if(typeof own.scripts?.[check.script]!=='string')fail('test-script-changed');
357
+ return {...testScript(own.scripts[check.script]),dir,dirs};
358
+ }
359
+ // The checkout's workspace patterns: pnpm's `packages`, else package.json's.
360
+ export function testWorkspacePatterns(root,pkg){
361
+ return fs.existsSync(path.join(root,'pnpm-lock.yaml'))?pnpmProjectInfo(root).patterns:
362
+ Array.isArray(pkg.workspaces)?pkg.workspaces:pkg.workspaces?.packages??[];
363
+ }
364
+ // The checkout's workspace package directories, [] for a single package.
365
+ export function testWorkspaceDirs(root,pkg){
366
+ const patterns=testWorkspacePatterns(root,pkg);
367
+ return patterns.length?workspaceManifests(root,patterns).map(({file})=>path.posix.dirname(file)):[];
368
+ }
369
+ // Each node_modules the recipe observes as installed: the root's and every
370
+ // workspace package's, relative to the root.
371
+ const modulesDirs=({dirs=[]})=>['node_modules',...dirs.map(dir=>`${dir}/node_modules`)];
372
+ // pnpm's content-addressed store, as the install recorded it in .modules.yaml:
373
+ // files/<first 2 hex>/<rest of the content's sha512 hex>, "-exec" for
374
+ // executables. pnpm writes new store files and never changes one in place.
375
+ function pnpmStoreFiles(root){
376
+ try{const {storeDir}=yaml(path.join(root,'node_modules','.modules.yaml'));
377
+ return typeof storeDir==='string'&&path.isAbsolute(storeDir)?path.join(storeDir,'files'):null;}
378
+ catch{return null;}
379
+ }
380
+ const identity=st=>`${st.size}:${st.mtimeNs}:${st.ctimeNs}`;
381
+ const sameFile=(file,st)=>{
382
+ try{const other=fs.statSync(file,{bigint:true});return other.dev===st.dev&&other.ino===st.ino;}
383
+ catch{return false;}};
384
+ // The file's store path relative to `files`, or null when the store does not
385
+ // hold this file at its content's address.
386
+ function storePath(files,file,st){
387
+ const hash=createHash('sha512'),buffer=Buffer.alloc(1<<20),fd=fs.openSync(file,'r');
388
+ try{for(let n;(n=fs.readSync(fd,buffer,0,buffer.length,null))>0;)hash.update(buffer.subarray(0,n));}
389
+ finally{fs.closeSync(fd);}
390
+ const hex=hash.digest('hex'),at=path.join(hex.slice(0,2),hex.slice(2));
391
+ return [at,at+'-exec'].find(candidate=>sameFile(path.join(files,candidate),st))??null;
392
+ }
393
+ // The first installed file with more than one link, as {file, reason}, or
394
+ // null. A package store outside the checkout (pnpm's default on Linux, Yarn's
395
+ // hardlinks modes) can change such a file with no event here. A link into
396
+ // pnpm's own store is accepted: the store's file is named by its content.
397
+ // Checked at every recorded run: a later install that adds links replaces the
398
+ // files, which is a change REDUE sees. `cache` (in owned state) keeps each
399
+ // file found in the store with its identity and store path, so an unchanged
400
+ // file is not hashed again; its store link is still checked.
401
+ export function sharedInstalledFile(root,project={},cache=null){
402
+ const stack=modulesDirs(project).map(dir=>path.join(root,dir));
403
+ const files=pnpmStoreFiles(root),now=Date.now();
404
+ let known={},changed=false;
405
+ if(files&&cache)try{const value=JSON.parse(fs.readFileSync(cache,'utf8'));
406
+ if(value?.files===files&&value.entries&&typeof value.entries==='object')known=value.entries;}catch{}
407
+ const found={};
408
+ const shared=(file,reason)=>({file:path.relative(root,file).split(path.sep).join('/'),reason});
409
+ while(stack.length){
410
+ const dir=stack.pop();let entries;
411
+ try{entries=fs.readdirSync(dir,{withFileTypes:true});}catch{continue;}
412
+ for(const entry of entries){
413
+ const file=path.join(dir,entry.name);
414
+ if(entry.isDirectory()){stack.push(file);continue;}
415
+ if(!entry.isFile())continue;
416
+ let st;try{st=fs.lstatSync(file,{bigint:true});}catch{continue;}
417
+ if(st.nlink<=1n)continue;
418
+ if(!files)return shared(file,'has another hard link, and the install records no pnpm store');
419
+ const key=`${st.dev}:${st.ino}`,cached=known[key];
420
+ let at=Array.isArray(cached)&&cached[0]===identity(st)&&typeof cached[1]==='string'&&
421
+ sameFile(path.join(files,cached[1]),st)?cached[1]:null;
422
+ if(!at){
423
+ try{at=storePath(files,file,st);}
424
+ catch(e){return shared(file,`could not be checked against pnpm's store (${e.code||e.message})`);}
425
+ if(!at)return shared(file,"has another hard link outside pnpm's store");
426
+ changed=true;
427
+ }
428
+ // A file changed within the racy window could change again unseen in
429
+ // the same timestamp tick; it is hashed again next time.
430
+ if(racyUntil(st)<=now)found[key]=[identity(st),at];
431
+ else changed=true;
432
+ }
433
+ }
434
+ if(files&&cache&&(changed||Object.keys(found).length!==Object.keys(known).length))
435
+ try{atomicJson(cache,{files,entries:found});}catch{}
436
+ return null;
437
+ }
438
+ // The runner a package's script reaches: Node's lookup from the package
439
+ // directory up to the root, skipping candidates that are not the runner. Its
440
+ // files must be in an observed node_modules (`dirs`: workspace packages).
441
+ // `logical` is where the lookup found it, before links, relative to the root.
442
+ export function testRunnerAt(root,dir,runner,dirs=[]){
443
+ if(dir==='.')return {entry:testRunnerEntry(root,runner),logical:`node_modules/${runner}`};
444
+ if(!runners[runner])fail('test-runner-unsupported');
445
+ const rootReal=fs.realpathSync(root),observed=modulesDirs({dirs}).map(at=>path.join(rootReal,at)+path.sep);
446
+ for(let at=dir;;at=path.posix.dirname(at)){
447
+ const logical=at==='.'?`node_modules/${runner}`:`${at}/node_modules/${runner}`;
448
+ let real,pkg;
449
+ try{real=fs.realpathSync(path.join(root,logical));pkg=JSON.parse(fs.readFileSync(path.join(real,'package.json'),'utf8'));}catch{}
450
+ if(pkg?.name===runner){
451
+ if(!observed.some(prefix=>real.startsWith(prefix)))fail('test-runner-layout-unsupported');
452
+ const bin=typeof pkg.bin==='string'?pkg.bin:pkg.bin?.[runner];
453
+ const entry=typeof bin==='string'?path.resolve(real,bin):null;
454
+ if(!entry||!entry.startsWith(real+path.sep)||!fs.existsSync(entry))fail('test-runner-layout-unsupported');
455
+ return {entry,logical};
456
+ }
457
+ if(at==='.')fail('test-runner-missing');
458
+ }
459
+ }
460
+ // The project facts the recipe needs, or an error code. `dir` is a workspace
461
+ // package's directory; there the recipe supports Vitest only.
462
+ export function testProject(root,script,dir='.'){
463
+ const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
464
+ testPackageManager(root,pkg,{workspaces:dir!=='.'});
465
+ const dirs=dir==='.'?[]:testWorkspaceDirs(root,pkg);
466
+ if(dir!=='.'&&!dirs.includes(dir))fail('test-workspace-unsupported');
467
+ const own=dir==='.'?pkg:JSON.parse(fs.readFileSync(path.join(root,dir,'package.json'),'utf8'));
468
+ if(typeof own.scripts?.[script]!=='string'||own.scripts['pre'+script]||own.scripts['post'+script])fail('test-script-changed');
469
+ const {runner,args}=testScript(own.scripts[script]);
470
+ if(dir!=='.'&&runner!=='vitest')fail('test-runner-unsupported');
471
+ const {entry,logical}=testRunnerAt(root,dir,runner,dirs);
472
+ const version=JSON.parse(fs.readFileSync(path.join(root,logical,'package.json'),'utf8')).version;
473
+ if(!runners[runner].majors.includes(majorOf(version)))fail('test-runner-version-unsupported');
474
+ if(runner==='jest'){
475
+ let util=null;try{util=JSON.parse(fs.readFileSync(path.join(root,'node_modules/jest-util/package.json'),'utf8')).version;}catch{}
476
+ if(majorOf(util)!==29||!fs.existsSync(path.join(root,'node_modules/jest-util/build/createProcessObject.js')))
477
+ fail('test-runner-layout-unsupported');
478
+ }
479
+ return {runner,version,args,entry,dir,dirs,logical};
480
+ }
481
+ // Inputs: the whole checkout except documentation and the coverage report a
482
+ // --coverage run writes. Git-ignored files the run creates are handled per
483
+ // run (cli.mjs). Installed files, in the root's node_modules and every
484
+ // workspace package's, are observed by identity; Vite's caches inside them
485
+ // are written by every Vitest run.
486
+ export function testCheckInputs({runner,args,dir='.',dirs=[],logical=`node_modules/${runner}`}){
487
+ const modules=modulesDirs({dirs});
488
+ return {inputs:['**',...modules.map(at=>`!${at}/**`),'!.git/**','!**/*.md','!**/*.mdx',
489
+ ...(args.includes('--coverage')?[`!${dir==='.'?'':dir+'/'}coverage/**`]:[])],
490
+ // Vitest's typecheck mode keeps tsc's build info in its own dist directory.
491
+ installedInputs:modules.flatMap(at=>[`${at}/**`,...(runner==='vitest'?[`!${at}/.vite/**`,`!${at}/.vite-temp/**`,`!${at}/.vitest/**`]:[])])
492
+ .concat(runner==='vitest'?[`!${logical}/dist/tsconfig.tmp.tsbuildinfo`]:[])};
493
+ }
494
+ // The coverage directory a --coverage run may read back: Jest reads every
495
+ // .js file under its roots, an earlier report's included, to map dependencies,
496
+ // and that does not decide pass or fail. Only the default directory, and only
497
+ // when no configuration names thresholds (which make the report decide the
498
+ // result) or another report directory. Configuration can be code, so this
499
+ // reads its text.
500
+ export function coverageReadable(checkout,{runner,args,dir='.'}){
501
+ if(!args.includes('--coverage'))return null;
502
+ // A workspace package's configuration and report live in its directory.
503
+ const root=path.join(checkout,dir);
504
+ const names=runner==='jest'?['jest.config.js','jest.config.ts','jest.config.mjs','jest.config.cjs','jest.config.json']:
505
+ ['vitest.config.ts','vitest.config.mts','vitest.config.cts','vitest.config.js','vitest.config.mjs','vitest.config.cjs',
506
+ 'vite.config.ts','vite.config.mts','vite.config.cts','vite.config.js','vite.config.mjs','vite.config.cjs',
507
+ 'vitest.workspace.ts','vitest.workspace.js','vitest.workspace.json'];
508
+ const at=args.findIndex(arg=>arg==='--config'||arg==='-c'),inline=args.find(arg=>/^(?:--config|-c)=/.test(arg));
509
+ const given=at>=0?args[at+1]:inline?.split(/=(.*)/s)[1];
510
+ // Vitest finds its configuration in the package directory or a parent.
511
+ const texts=[],folders=[];
512
+ for(let at=dir;;at=path.posix.dirname(at)){folders.push(path.join(checkout,at));if(at==='.')break;}
513
+ for(const name of [...names,...(given?[given]:[])])for(const folder of folders){
514
+ try{texts.push(fs.readFileSync(path.resolve(folder,name),'utf8'));}catch{}
515
+ }
516
+ try{const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
517
+ if(runner==='jest'&&pkg.jest)texts.push(JSON.stringify(pkg.jest));}catch{return null;}
518
+ return texts.some(text=>/coverageThreshold|thresholds|coverageDirectory|reportsDirectory/.test(text))?null:
519
+ dir==='.'?'coverage':`${dir}/coverage`;
520
+ }
521
+ // The caller's environment, when it would load code before the recorder or
522
+ // the Node version is not one the recipe supports. Compared variables are
523
+ // fingerprinted separately.
524
+ export function testEnvironmentIssue(env=process.env){
525
+ if(![22,24].includes(majorOf(process.versions.node)))return 'the test recipe supports Node 22 and 24';
526
+ const options=(env.NODE_OPTIONS||'').trim();
527
+ if(options&&!(/^[A-Za-z0-9=._ -]*$/.test(options)&&options.split(' ').filter(Boolean).every(flag=>
528
+ /^--max-(?:old|semi)-space-size=\d+$/.test(flag)||/^--use-(?:openssl|bundled|system)-ca$/.test(flag)||
529
+ /^--dns-result-order=(?:ipv4first|ipv6first|verbatim)$/.test(flag))))
530
+ return 'NODE_OPTIONS could load code before the REDUE recorder';
531
+ return null;
532
+ }
533
+ // Plan qualification: the check is exactly what `init` writes for the
534
+ // project's current script.
535
+ export function qualifyTests(root,selected){
536
+ const issues=[];
537
+ if(!['darwin','linux'].includes(process.platform))issues.push('the test recipe records runs on macOS and Linux only');
538
+ try{
539
+ const dir=selected.cwd&&selected.cwd!=='.'?selected.cwd:'.';
540
+ if(dir!=='.'&&!selected.workspace)throw Object.assign(Error(),{code:'a test check in a directory needs its workspace'});
541
+ const project=testProject(root,selected.script,dir);
542
+ if(JSON.stringify(selected.command)!==JSON.stringify([process.execPath,project.entry,...project.args]))
543
+ issues.push('test runner invocation changed; run redue init again');
544
+ // Every checkout file but installed ones is a content input; the recorder
545
+ // skips node_modules because it is observed as installed files.
546
+ if(dir!=='.'&&JSON.parse(fs.readFileSync(path.join(root,dir,'package.json'),'utf8')).name!==selected.workspace)
547
+ issues.push('the test check names another workspace package than its directory holds; run redue init again');
548
+ const expected=testCheckInputs(project),sorted=list=>JSON.stringify([...(list||[])].sort());
549
+ if(sorted(selected.inputs)!==sorted(expected.inputs)||
550
+ JSON.stringify([...(selected.installedInputs||[])].sort())!==JSON.stringify([...testCheckInputs(project).installedInputs].sort()))
551
+ issues.push('test inputs must be the whole checkout, with node_modules observed as installed files; run redue init again');
552
+ }catch(e){issues.push(e.code||'test runner prerequisites unavailable');}
553
+ if(!selected.environment?.executableIdentity||
554
+ !testEnvironment.variables.every(key=>selected.environment?.variables?.includes(key))||
555
+ !testEnvironment.prefixes.every(key=>selected.environment?.prefixes?.includes(key)))
556
+ issues.push('test execution context incomplete; run redue init again');
557
+ if(selected.probes?.length)issues.push('the test recipe takes no probes');
558
+ if(selected.generatedInputs?.length)issues.push('the test recipe takes no generated inputs');
559
+ return {qualified:issues.length===0,issues};
560
+ }