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

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 +467 -0
  2. package/README.md +107 -2
  3. package/bin/redue.mjs +130 -49
  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 +153 -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 +49 -21
  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 +157 -48
  35. package/src/node-script.mjs +362 -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 +22 -0
  60. package/src/yarn-workspace-typecheck-probe.mjs +2 -1
package/src/index.mjs CHANGED
@@ -5,7 +5,7 @@ import {createHash} from 'node:crypto';
5
5
  import micromatch from './glob.mjs';
6
6
  import {sha} from './plan.mjs';
7
7
  import {resolveLinks} from './installed-inputs.mjs';
8
- import {validInputRelative} from './path-identity.mjs';
8
+ import {validInputRelative,identityFields,racyUntil} from './path-identity.mjs';
9
9
 
10
10
  const tracked = root => [...new Set(execFileSync('git',
11
11
  ['ls-files','--cached','--others','--exclude-standard','-z'],
@@ -35,37 +35,52 @@ const pathPrefixes = rel => {
35
35
  return prefixes;
36
36
  };
37
37
 
38
+ // An installed file is observed by identity, not content: every write changes
39
+ // ctime, so its entry changes without reading the whole installation. The
40
+ // compiler's own reads are content-checked by its probe and certificate. A file
41
+ // changed within the racy window could still be rewritten in the same timestamp
42
+ // tick at the same size; such files are tracked in `racy`, and the daemon waits
43
+ // the window out and records them again before it writes a run checkpoint.
44
+ const identity=st=>identityFields.map(key=>String(st[key])).join(':');
45
+ // An installed link to a directory (pnpm links every package this way) is
46
+ // observed by its link chain alone, when `observedDir` says the directory it
47
+ // resolves to is itself observed installed: its own entries carry its contents.
38
48
  function fileHash(root, rel,installed=false,before=fs.lstatSync(path.join(root,rel),
39
- {bigint:true})) {
49
+ {bigint:true}),observedDir=null) {
40
50
  const file=path.join(root, rel);
41
- let value,references=[],bytes=Number(before.size);
51
+ let value,references=[],bytes=installed?0:Number(before.size),racy=0;
42
52
  if(before.isSymbolicLink()){
43
53
  const resolution=resolveLinks(file,root,[],true);
44
54
  if(!resolution.physical)throw Error(`symlink input target observation unavailable: ${rel}: ${resolution.reason}`);
45
55
  const target=fs.statSync(resolution.physical,{bigint:true});
46
- if(!target.isFile())throw Error(`directory input symlink unsupported: ${rel}`);
47
- value='link:'+JSON.stringify(resolution.links)+':'+target.mode+':'+
48
- (installed&&target.nlink>1?target.nlink+':':'')+
49
- hashRegular(resolution.physical).hash;
56
+ const physical=path.relative(root,resolution.physical).split(path.sep).join('/');
57
+ const directory=target.isDirectory()&&installed&&observedDir?.(physical);
58
+ if(!target.isFile()&&!directory)throw Error(`directory input symlink unsupported: ${rel} -> ${physical}`);
59
+ if(directory)value='dirlink:'+JSON.stringify(resolution.links)+':'+physical;
60
+ else{
61
+ if(installed)racy=racyUntil(target);else bytes=Number(target.size);
62
+ value='link:'+JSON.stringify(resolution.links)+':'+(installed?'stat:'+identity(target):
63
+ target.mode+':'+hashRegular(resolution.physical).hash);
64
+ }
50
65
  references=[...new Set([...resolution.links.map(link=>path.relative(root,link.path)
51
66
  .split(path.sep).join('/')),path.relative(root,resolution.physical)
52
67
  .split(path.sep).join('/')])];
53
- bytes=Number(target.size);
54
- const targetAfter=fs.statSync(resolution.physical,{bigint:true});
55
- for(const key of ['dev','ino','mode','nlink','size','mtimeNs','ctimeNs'])
56
- if(target[key]!==targetAfter[key])throw Error(`input changed during inspection: ${rel}`);
68
+ // A directory's own timestamps follow its entries, which are observed separately.
69
+ if(!directory){const targetAfter=fs.statSync(resolution.physical,{bigint:true});
70
+ for(const key of identityFields)
71
+ if(target[key]!==targetAfter[key])throw Error(`input changed during inspection: ${rel}`);}
57
72
  const resolutionAfter=resolveLinks(file,root,[],true);
58
73
  if(resolutionAfter.physical!==resolution.physical||
59
74
  JSON.stringify(resolutionAfter.links)!==JSON.stringify(resolution.links))
60
75
  throw Error(`input link changed during inspection: ${rel}`);
61
76
  }
62
- else if(before.isFile()) value='file:'+before.mode+':'+
63
- (installed&&before.nlink>1?before.nlink+':':'')+hashRegular(file).hash;
77
+ else if(before.isFile()&&installed){value='stat:'+identity(before);racy=racyUntil(before);}
78
+ else if(before.isFile()) value='file:'+before.mode+':'+hashRegular(file).hash;
64
79
  else throw Error(`non-file input: ${rel}`);
65
80
  const after=fs.lstatSync(file,{bigint:true});
66
- for(const key of ['dev','ino','mode','nlink','size','mtimeNs','ctimeNs'])
81
+ for(const key of identityFields)
67
82
  if(before[key]!==after[key]) throw Error(`input changed during inspection: ${rel}`);
68
- return {hash:sha(value),references,bytes};
83
+ return {hash:sha(value),references,bytes,racy};
69
84
  }
70
85
  const readBuffer=Buffer.allocUnsafe(65536);
71
86
  function hashRegular(file) {
@@ -81,6 +96,7 @@ export class InputIndex {
81
96
  const matcherStarted=performance.now();
82
97
  this.bundle=bundle; this.root=bundle.root; this.files=new Map(); this.members=new Map();
83
98
  this.symlinkRefs=new Map();this.aliasDependencies=new Map();
99
+ this.racy=new Map();
84
100
  this.trackGit=options.trackGit!==false;
85
101
  this.installedRoots=[...new Set(Object.values(bundle.plans).flatMap(plan=>
86
102
  plan.installedPhysicalRoots||[]))];
@@ -149,6 +165,17 @@ export class InputIndex {
149
165
  }
150
166
  return {files,bytes,durationMs:performance.now()-started};
151
167
  }
168
+ // When the racy window of the named checks' installed members ends (0: none).
169
+ racyUntil(names=null) {
170
+ let until=0;
171
+ for(const [rel,time] of this.racy)
172
+ if(!names||[...this.members.get(rel)||[]].some(name=>names.includes(name)))until=Math.max(until,time);
173
+ return until;
174
+ }
175
+ // Records again every installed file whose racy window has passed.
176
+ settleRacy() {
177
+ for(const [rel,time] of [...this.racy])if(time<=Date.now())this.record(rel,rel+' changed');
178
+ }
152
179
  summary(name) { const row=this.checks.get(name); return {fingerprint:this.fingerprint(name),
153
180
  revision:row.revision,planId:row.plan.id,files:row.files.size,probeAt:row.probeAt}; }
154
181
  record(rel, changeReason=rel+' changed', observedStat=null) {
@@ -163,7 +190,20 @@ export class InputIndex {
163
190
  catch(e){if(e.code!=='ENOENT'){this.unavailable=String(e);return false;}}
164
191
  if(stat&&(stat.isFile()||stat.isSymbolicLink())) {
165
192
  const installed=this.installedPath(rel);
166
- try{const started=performance.now();inspection=fileHash(this.root,rel,installed,stat);
193
+ try{const started=performance.now();
194
+ // A linked directory counts as observed only strictly inside an
195
+ // installed root, or as a checkout directory outside node_modules (a
196
+ // workspace package the package manager links), and only if every
197
+ // check matching the link also observes the directory's own files
198
+ // (its package.json stands for them).
199
+ // The second form is for recorded checks only: their recorder
200
+ // classifies a read through the link where it leads.
201
+ const recorded=name=>['node-script-v1','npm-tests-direct-v1'].includes(this.checks.get(name).plan.qualification);
202
+ const observedDir=installed?physical=>!this.includeInstalled&&
203
+ ([...this.installedRootSet].some(root=>physical.startsWith(root+'/'))||
204
+ !physical.split('/').includes('node_modules')&&!this.installedPath(physical+'/package.json')&&matched.every(recorded))&&
205
+ matched.every(name=>this.matches(this.checks.get(name),physical+'/package.json')):null;
206
+ inspection=fileHash(this.root,rel,installed,stat,observedDir);
167
207
  fresh=inspection.hash;
168
208
  this.profile.hashingMs+=performance.now()-started;
169
209
  this.rehashedFiles++;this.rehashedBytes+=inspection.bytes;}
@@ -182,6 +222,7 @@ export class InputIndex {
182
222
  }
183
223
  }
184
224
  const oldNames=this.members.get(rel)||new Set(), newNames=new Set(fresh===undefined?[]:matched);
225
+ if(inspection?.racy>Date.now()&&newNames.size)this.racy.set(rel,inspection.racy);else this.racy.delete(rel);
185
226
  if(old===fresh && oldNames.size===newNames.size && [...oldNames].every(n=>newNames.has(n))) return false;
186
227
  for(const name of new Set([...oldNames,...newNames])) {
187
228
  const row=this.checks.get(name);
@@ -283,9 +324,10 @@ export class InputIndex {
283
324
  const full=path.join(this.root,rel);
284
325
  let subtree=false;
285
326
  try {
286
- if(fs.lstatSync(full).isSymbolicLink()&&fs.statSync(full).isDirectory()){
287
- this.unavailable=`directory symlink input unsupported: ${rel}`;return;}
288
- if(fs.statSync(full).isDirectory()) {this.scanSubtree(rel);subtree=true;}
327
+ // A link is an entry of its own: record() accepts an observed installed
328
+ // directory link and refuses any other.
329
+ if(fs.lstatSync(full).isSymbolicLink())this.record(rel);
330
+ else if(fs.statSync(full).isDirectory()) {this.scanSubtree(rel);subtree=true;}
289
331
  else this.record(rel);
290
332
  } catch(e) {
291
333
  if(e.code==='ENOENT') {
@@ -304,3 +346,14 @@ export class InputIndex {
304
346
  return out;
305
347
  }
306
348
  }
349
+
350
+ // The index state a worker sends and a restart retains; hydrate() in daemon.mjs
351
+ // reads it back.
352
+ export function dehydrateIndex(value) {
353
+ return {files:[...value.files],
354
+ aliasDependencies:[...value.aliasDependencies].map(([name,refs])=>[name,[...refs]]),
355
+ rows:[...value.checks].map(([name,row])=>({name,files:[...row.files],
356
+ sum:row.sum.toString('hex'),revision:row.revision,lastReason:row.lastReason})),
357
+ eventCount:value.eventCount,rehashedFiles:value.rehashedFiles,
358
+ rehashedBytes:value.rehashedBytes,profile:value.profile,racy:[...value.racy]};
359
+ }
@@ -84,6 +84,10 @@ export function resolveDeclaredInstalled(root,patterns,allowed=[]) {
84
84
  if(!resolved.physical){
85
85
  if(resolved.reason==='resolved target missing'&&!resolved.links.length){
86
86
  internal.add(pattern);continue;}
87
+ // An exclusion of a path that does not exist yet, through links (a runner
88
+ // cache inside a linked package): exclude it where the links lead.
89
+ if(negative&&resolved.reason==='resolved target missing'&&resolved.prospective&&within(resolved.prospective,root)){
90
+ internal.add('!'+posix(path.relative(root,resolved.prospective))+(suffix?'/'+suffix:''));continue;}
87
91
  result.unresolved.push(`${prefix}: ${resolved.reason}`);continue;
88
92
  }
89
93
  const boundary=within(resolved.physical,root)?root:
@@ -99,7 +103,9 @@ export function resolveDeclaredInstalled(root,patterns,allowed=[]) {
99
103
  else {if(!external.has(boundary))external.set(boundary,new Set());
100
104
  external.get(boundary).add(mapped);}
101
105
  if(!negative)roots.add(resolved.physical);
102
- result.mappings.push({logical:pattern,physical:resolved.physical,root:boundary,
106
+ // An exclusion maps to the same pattern whether or not its path exists, so
107
+ // the plan stays the same when a run creates it (a runner cache).
108
+ if(!negative)result.mappings.push({logical:pattern,physical:resolved.physical,root:boundary,
103
109
  links:resolved.links.map(link=>link.path)});
104
110
  }
105
111
  result.internalPatterns=[...internal].sort();
@@ -73,18 +73,35 @@ def main(root):
73
73
  file_paths = {}
74
74
  intentional_removals = set()
75
75
 
76
+ # No input lies in a .git directory (config refuses such patterns and the
77
+ # index skips them), so none is watched: git creates object directories
78
+ # there on most commits, and each would be an observation gap.
79
+ def in_git(target):
80
+ return '.git' in os.path.relpath(target, root).split(os.sep)
81
+
76
82
  def add_tree(folder):
77
83
  stack = [folder]
78
84
  while stack:
79
85
  current = stack.pop()
86
+ if in_git(current):
87
+ continue
80
88
  wd = libc.inotify_add_watch(fd, os.fsencode(current), MASK)
81
89
  if wd < 0:
82
- raise OSError(ctypes.get_errno(), 'inotify_add_watch failed')
90
+ error = ctypes.get_errno()
91
+ # A new directory deleted before its watch: nothing to watch,
92
+ # and its parent reports the deletion.
93
+ if current != root and error in (errno.ENOENT, errno.ENOTDIR):
94
+ continue
95
+ raise OSError(error, 'inotify_add_watch failed')
83
96
  watches[wd] = current
84
- with os.scandir(current) as entries:
85
- for entry in entries:
86
- if entry.is_dir(follow_symlinks=False):
87
- stack.append(entry.path)
97
+ try:
98
+ with os.scandir(current) as entries:
99
+ for entry in entries:
100
+ if entry.is_dir(follow_symlinks=False):
101
+ stack.append(entry.path)
102
+ except (FileNotFoundError, NotADirectoryError):
103
+ if current == root:
104
+ raise
88
105
 
89
106
  def drain():
90
107
  while True:
@@ -119,6 +136,15 @@ def main(root):
119
136
  emit('gap', reason='inotify_watch_unknown')
120
137
  return False
121
138
  target = os.path.join(parent, os.fsdecode(raw)) if raw else parent
139
+ if in_git(target):
140
+ continue
141
+ if mask & IN_DELETE_SELF and parent != root:
142
+ # A watched directory was deleted: its IN_IGNORED follows
143
+ # and is expected. The engine rescans the path.
144
+ del watches[wd]
145
+ intentional_removals.add(wd)
146
+ emit('event', type='delete', path=parent)
147
+ continue
122
148
  if mask & (IN_UNMOUNT | IN_DELETE_SELF | IN_MOVE_SELF | IN_IGNORED):
123
149
  emit('gap', reason='inotify_watch_lost')
124
150
  return False
@@ -128,11 +154,14 @@ def main(root):
128
154
  except OSError as error:
129
155
  emit('gap', reason='inotify_watch_add_failed', errno=error.errno)
130
156
  return False
131
- # Contents can appear before their new directory receives
132
- # a watch. The engine reconciles instead of guessing.
133
- emit('gap', reason='inotify_new_directory')
134
- return False
135
- if (mask & IN_ISDIR) and (mask & (IN_DELETE | IN_MOVED_FROM)):
157
+ # Contents can appear before the new directory has its
158
+ # watch. The watches exist now, so later changes raise
159
+ # events; the engine rescans the directory on this event
160
+ # and finds what came before.
161
+ emit('event', type='create', path=target)
162
+ continue
163
+ # A moved-away directory keeps watches under its old paths.
164
+ if (mask & IN_ISDIR) and (mask & IN_MOVED_FROM):
136
165
  emit('gap', reason='inotify_directory_removed')
137
166
  return False
138
167
  event = ('delete' if mask & (IN_DELETE | IN_MOVED_FROM) else
@@ -1,16 +1,23 @@
1
+ import {directProject,directQualification} from './direct-typescript.mjs';
2
+ import {testProject,testRecipeScript,testCheckInputs,testEnvironmentIssue,testsQualification} from './test-direct.mjs';
3
+ import {commandLineIssue,scriptCheckInputs,scriptPath,scriptProject,scriptQualification} from './node-script.mjs';
4
+ // Init proposes the general recipe for typecheck and lint scripts; test
5
+ // scripts use the direct test recipe (Vitest, Jest).
6
+ const generalKinds=['typecheck','lint'];
1
7
  import fs from 'node:fs';
2
8
  import path from 'node:path';
3
9
  import {spawnSync} from 'node:child_process';
4
- import micromatch from './glob.mjs';
5
10
  import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
6
11
  import {pnpmInstallInfo,pnpmProjectInfo,pnpmTypecheckInputs} from './pnpm-inputs.mjs';
7
12
  import {realObservedPath} from './path-identity.mjs';
8
13
  import {findExecutable} from './executable-lookup.mjs';
14
+ import {workspaceManifests} from './workspace-manifests.mjs';
15
+ import {partName,tscRuns} from './typescript-invocation.mjs';
9
16
 
10
17
  const exists=file=>{try{fs.accessSync(file);return true;}catch{return false;}};
11
18
  const readJson=file=>JSON.parse(fs.readFileSync(file,'utf8'));
12
19
  const scriptNames={
13
- typecheck:['typecheck','type-check','check:types','types:check'],
20
+ typecheck:['typecheck','type-check','check:types','types:check','check-types'],
14
21
  test:['test','test:unit','test:check'],
15
22
  lint:['lint','lint:check'],
16
23
  build:['build'],
@@ -80,6 +87,28 @@ function manager(root,pkg) {
80
87
  return {name,version,layout:'node-modules/classic',issue:null,lockfiles:locks};
81
88
  }
82
89
  const exactTypeScript=script=>script.trim()==='tsc --noEmit';
90
+ // A script of tsc runs that each pass --noEmit (typescript-invocation.mjs).
91
+ const noEmitRuns=script=>Boolean(tscRuns(script)?.every(run=>run.noEmit));
92
+ // Ready checks for a typecheck, one per tsc run: the script's runs (a chain
93
+ // gives NAME.1 ... NAME.n), or with no script one proposed `tsc --noEmit`.
94
+ // `make(run)` returns a run's command, cwd and inputs, or throws why its
95
+ // contract does not hold.
96
+ function tscChecks(name,script,scripts,make){
97
+ const runs=script===null?[{args:['--noEmit']}]:tscRuns(scripts[script]);
98
+ if(!runs)throw Error('the script is not tsc runs in a supported form');
99
+ return runs.map(({args},i)=>({name:partName(name,i+1,runs.length),
100
+ ...(script===null?{}:{script}),...make({script,args,count:runs.length})}));
101
+ }
102
+ // A proposed `tsc --noEmit` only where nothing typechecks already: a script
103
+ // that runs any TypeScript checker in another form (tsc -b, vue-tsc,
104
+ // svelte-check, tsgo, also quoted or wrapped by concurrently or run-p) is the
105
+ // project's own typecheck, which plain tsc would not match.
106
+ const typeChecker=/(?:^|[^\w-])(?:tsc|vue-tsc|tsgo|svelte-check|tsc-watch)(?=$|[^\w-])|\bastro check\b|\bnuxi typecheck\b/;
107
+ const typechecks=scripts=>Object.values(scripts||{}).some(text=>typeof text==='string'&&typeChecker.test(text));
108
+ const proposable=(dir,scripts)=>exists(path.join(dir,'tsconfig.json'))&&!typechecks(scripts);
109
+ // The inputs a TypeScript contract adds to its check's config: no exclusions,
110
+ // which would outlive the tsconfig they came from; the plan adds the current ones.
111
+ const contractInputs=contract=>[...contract.configs,...contract.sourcePatterns.filter(p=>!p.startsWith('!'))];
83
112
  function sourceLinkIssue(root) {
84
113
  const stack=likelyDirs.filter(name=>exists(path.join(root,name)));
85
114
  let inspected=0;
@@ -105,7 +134,8 @@ function sourceInputs(root,kind,lockfiles) {
105
134
  '!node_modules/**','!.git/**'])];
106
135
  const dirs=likelyDirs.filter(name=>exists(path.join(root,name)))
107
136
  .map(name=>`${name}/**`);
108
- return [...new Set([...files,...(dirs.length?dirs:['package.json'])])];
137
+ // A workspace package's node_modules under them is installed, not source.
138
+ return [...new Set([...files,...(dirs.length?[...dirs,'!**/node_modules/**']:['package.json'])])];
109
139
  }
110
140
  function classify(kind,scriptName,script,pkg,managerInfo,root) {
111
141
  const value=script.trim();
@@ -116,7 +146,7 @@ function classify(kind,scriptName,script,pkg,managerInfo,root) {
116
146
  managerInfo.name==='npm'&&
117
147
  exists(path.join(root,'tsconfig.json'))&&
118
148
  exists(path.join(root,'node_modules/typescript/package.json')))
119
- return {level:'ready',reason:'canonical local tsc --noEmit; checked again before reuse',
149
+ return {level:'recording',reason:'npm launcher uses unobserved remote/cache/time inputs; execution can be recorded, but cannot be reused',
120
150
  qualification:'typescript-noemit-v1'};
121
151
  if(kind==='typecheck')return {level:'recording',reason:
122
152
  'TypeScript invocation, installation, or configuration is outside the automatic noEmit contract'};
@@ -137,29 +167,56 @@ function selectScript(kind,names,scripts) {
137
167
  const candidates=Object.keys(scripts).filter(name=>{
138
168
  const value=scripts[name]?.trim();
139
169
  if(typeof value!=='string')return false;
140
- if(kind==='typecheck')return exactTypeScript(value);
170
+ if(kind==='typecheck')return noEmitRuns(value);
141
171
  if(kind==='test')return /^(?:vitest|jest)(?:\s|$)/.test(value);
142
172
  if(kind==='lint')return /^eslint(?:\s|$)/.test(value);
143
173
  return /^vite\s+build(?:\s|$)/.test(value);
144
174
  });
175
+ // A lone plain `tsc --noEmit` wins over other tsc shapes beside it (a
176
+ // project's `typescript` next to `typescript:tests:raw`).
177
+ const exact=kind==='typecheck'?candidates.filter(name=>exactTypeScript(scripts[name])):[];
178
+ if(exact.length===1)return {script:exact[0]};
145
179
  return candidates.length===1?{script:candidates[0]}:
146
180
  candidates.length>1?{ambiguous:candidates}:{};
147
181
  }
148
- function workspaceManifests(root,patterns) {
149
- const listing=spawnSync('git',['ls-files','--cached','--others','--exclude-standard',
150
- '-z','--',':(glob)**/package.json'],{cwd:root,encoding:'utf8',timeout:5000,
151
- maxBuffer:32*1024*1024,stdio:['ignore','pipe','ignore'],
152
- env:{...process.env,GIT_OPTIONAL_LOCKS:'0'}});
153
- if(listing.status!==0||listing.error)throw Error('workspace package manifests cannot be inspected');
154
- const matched=listing.stdout.split('\0').filter(Boolean).filter(file=>{
155
- const dir=path.posix.dirname(file);
156
- return dir!=='.'&&!dir.split('/').includes('node_modules')&&
157
- micromatch.isMatch(dir,patterns,{dot:true});
158
- }).sort();
159
- if(matched.length>50)throw Error('more than 50 workspace packages found; select checks explicitly');
160
- return matched.map(file=>({file,pkg:readJson(path.join(root,file))}));
182
+ // A pnpm workspace package's TypeScript run, as its check's command, cwd and inputs.
183
+ function pnpmCheck(root,name,dir,manifest,run){
184
+ const contract=pnpmTypecheckInputs(root,name,run);
185
+ if(contract.limitations.length)throw Error(contract.limitations.join('; '));
186
+ return {cwd:dir,command:['@node',`@typescript-bin:${dir}`,...run.args],
187
+ inputs:[...new Set(['package.json','pnpm-lock.yaml','pnpm-workspace.yaml',manifest,
188
+ ...contractInputs(contract)])]};
161
189
  }
162
- export function discoverNodeProject(root) {
190
+ // Workspace packages as init finds them, with their scripts; [] without workspaces.
191
+ export function workspacePackages(root){
192
+ root=realObservedPath(root);
193
+ const pkg=readJson(path.join(root,'package.json')),pm=manager(root,pkg);
194
+ const patterns=pm.name==='pnpm'?pm.workspacePatterns:Array.isArray(pkg.workspaces)?pkg.workspaces:pkg.workspaces?.packages;
195
+ if(!Array.isArray(patterns)||!patterns.length)return [];
196
+ return workspaceManifests(root,patterns).map(({file,pkg:member})=>
197
+ ({name:member.name,dir:path.posix.dirname(file),scripts:member.scripts&&typeof member.scripts==='object'?member.scripts:{}}));
198
+ }
199
+ // The general recipe for a root script, or why it does not apply: a
200
+ // supported package manager and platform, and a script (with its pre and post
201
+ // scripts) that, read as text, starts only Node programs.
202
+ function generalCheck(root,kind,script){
203
+ if(!['darwin','linux'].includes(process.platform))throw Error('the general recipe records runs on macOS and Linux only');
204
+ const environmentIssue=testEnvironmentIssue();
205
+ if(environmentIssue)throw Error(environmentIssue);
206
+ let project;
207
+ try{project=scriptProject(root,script);}catch(e){throw Error(e.code?.replace(/^test-/,'')||e.message);}
208
+ for(const name of ['pre'+script,script,'post'+script]){
209
+ if(typeof project.scripts[name]!=='string')continue;
210
+ const issue=commandLineIssue(project.scripts[name],{cwd:root,searchPath:scriptPath(root,process.env.PATH)});
211
+ if(issue)throw Error(name===script?issue:`${name}: ${issue}`);
212
+ }
213
+ return {config:{name:kind,script,kind,qualification:scriptQualification,...scriptCheckInputs(project.dirs)},manager:project.manager};
214
+ }
215
+ export function discoverNodeProject(root,{recipe=null}={}) {
216
+ if(recipe&&!['typescript-direct','tests-direct','node-script'].includes(recipe))
217
+ throw Error('unknown recipe; use typescript-direct, tests-direct or node-script');
218
+ // Without --recipe, init tries the strict recipes first, then the general one.
219
+ const auto=recipe===null;
163
220
  root=realObservedPath(root);
164
221
  const packageFile=path.join(root,'package.json');
165
222
  if(!exists(packageFile))throw Error('run init from a repository containing package.json');
@@ -180,26 +237,69 @@ export function discoverNodeProject(root) {
180
237
  for(const [kind,names] of Object.entries(scriptNames)) {
181
238
  const selected=selectScript(kind,names,scripts),script=selected.script;
182
239
  if(selected.ambiguous)ambiguous.push({kind,scripts:selected.ambiguous});
183
- if(!script)continue;
240
+ // With no typecheck script, a root tsconfig.json gets a proposed
241
+ // `tsc --noEmit` check; package.json stays unchanged. It is kept only
242
+ // when a contract makes it ready.
243
+ const proposed=!script&&!selected.ambiguous&&kind==='typecheck'&&!observationIssue&&
244
+ proposable(root,scripts);
245
+ if(!script&&!proposed)continue;
184
246
  let classification=observationIssue?{level:'unsupported',reason:observationIssue}:
185
- classify(kind,script,scripts[script],pkg,pm,root);
186
- if(workspaces&&classification.level==='ready')classification={level:'recording',
247
+ script?classify(kind,script,scripts[script],pkg,pm,root):{level:'recording',reason:'no typecheck script'};
248
+ let parts=null;
249
+ if(workspaces&&classification.qualification==='typescript-noemit-v1')classification={level:'recording',
187
250
  reason:'workspace task and installed-input closure need explicit qualification'};
188
251
  const check={name:kind,script,kind,inputs:sourceInputs(root,kind,pm.lockfiles||[])};
189
- if(pm.name==='pnpm'&&kind==='typecheck'&&!workspaces&&!pm.issue&&
190
- /^(?:tsc --noEmit|tsc -p (?:\.|tsconfig\.json)(?: --noEmit)?)$/.test(scripts[script].trim())){
191
- try{const contract=pnpmTypecheckInputs(root,'.',script);
192
- if(contract.limitations.length)throw Error(contract.limitations.join('; '));
193
- check.command=['@node','@typescript-bin:.',
194
- ...scripts[script].trim().split(/\s+/).slice(1)];
195
- check.cwd='.';
196
- check.inputs=['package.json','pnpm-lock.yaml',
197
- ...(exists(path.join(root,'pnpm-workspace.yaml'))?['pnpm-workspace.yaml']:[]),
198
- 'tsconfig.json','src/**'];
199
- classification={level:'ready',reason:'pinned pnpm TypeScript inputs and installation; checked again before reuse',
252
+ if((recipe==='typescript-direct'||auto&&pm.name==='npm'&&!workspaces)&&kind==='typecheck'){
253
+ try{
254
+ if(pm.name!=='npm'||workspaces||observationIssue||pm.issue)throw Error('direct recipe requires a supported single-package npm checkout');
255
+ let version;
256
+ parts=tscChecks('typecheck',script??null,scripts,run=>{
257
+ version=directProject(root,run).version;
258
+ return {command:['@node','@typescript-compiler:.',...run.args],cwd:'.'};});
259
+ classification={level:'ready',qualification:directQualification,
260
+ reason:`explicit local TypeScript ${version} compilation; npm launcher/lifecycle scripts are not executed; package.json stays unchanged`};
261
+ }catch(e){classification={level:'recording',reason:`Direct recipe unavailable (${e.code||e.message}); original npm script retained, without bypassing required behavior`};}
262
+ }
263
+ if((recipe==='tests-direct'||auto)&&kind==='test'){
264
+ try{
265
+ if(!['npm','pnpm','yarn'].includes(pm.name)||workspaces||observationIssue||pm.issue)
266
+ throw Error('test recipe requires a supported single-package npm, pnpm or Yarn checkout');
267
+ const chosen=testRecipeScript(scripts);
268
+ if(chosen.ambiguous)throw Error(`several scripts run Vitest or Jest directly (${chosen.ambiguous.join(', ')})`);
269
+ if(!chosen.script)throw Error(chosen.issue||'no script runs Vitest or Jest directly');
270
+ const project=testProject(root,chosen.script);
271
+ Object.assign(check,{script:chosen.script,command:['@node',`@test-runner:${project.runner}`,...project.args],cwd:'.',
272
+ ...testCheckInputs(project)});
273
+ classification={level:'ready',qualification:testsQualification,
274
+ reason:`explicit local ${project.runner} ${project.version} run of script ${JSON.stringify(chosen.script)}; the whole checkout is its input; npm and lifecycle scripts are not executed`};
275
+ }catch(e){classification={level:'recording',reason:`Test recipe unavailable (${e.code||e.message}); original npm script retained`};}
276
+ }
277
+ if(recipe!=='node-script'&&pm.name==='pnpm'&&kind==='typecheck'&&!workspaces&&!pm.issue&&(script===undefined||tscRuns(scripts[script]))){
278
+ try{
279
+ parts=tscChecks('typecheck',script??null,scripts,run=>{
280
+ const contract=pnpmTypecheckInputs(root,'.',run);
281
+ if(contract.limitations.length)throw Error(contract.limitations.join('; '));
282
+ return {command:['@node','@typescript-bin:.',...run.args],cwd:'.',
283
+ inputs:[...new Set(['package.json','pnpm-lock.yaml',
284
+ ...(exists(path.join(root,'pnpm-workspace.yaml'))?['pnpm-workspace.yaml']:[]),
285
+ ...contractInputs(contract)])]};});
286
+ classification={level:'ready',reason:'pnpm TypeScript inputs and installation; checked again before reuse',
200
287
  qualification:'pnpm-tsc-v1'};}
201
288
  catch(e){classification={level:'recording',reason:`pnpm TypeScript contract needs review: ${e.message}`};}
202
289
  }
290
+ if((auto||recipe==='node-script')&&script&&generalKinds.includes(kind)&&classification.level!=='ready'&&!observationIssue){
291
+ try{
292
+ const general=generalCheck(root,kind,script);
293
+ checks.push({config:general.config,level:'ready',qualification:scriptQualification,
294
+ reason:`general recipe: ${general.manager} runs script ${JSON.stringify(script)} with REDUE's recorder in every Node process; the whole checkout is its input`});
295
+ continue;
296
+ }catch(e){classification={...classification,reason:`General recipe unavailable (${e.message}); ${classification.reason}`};}
297
+ }
298
+ if(!script&&classification.level!=='ready')continue;
299
+ if(parts&&classification.level==='ready'){
300
+ for(const part of parts)checks.push({config:{...check,...part,qualification:classification.qualification},...classification});
301
+ continue;
302
+ }
203
303
  if(classification.qualification)check.qualification=classification.qualification;
204
304
  checks.push({config:check,...classification});
205
305
  }
@@ -217,6 +317,16 @@ export function discoverNodeProject(root) {
217
317
  const selected=selectScript(kind,names,member.scripts||{});
218
318
  if(selected.ambiguous){ambiguous.push({kind:`${name}:${kind}`,
219
319
  scripts:selected.ambiguous});continue;}
320
+ // A pnpm package with a tsconfig.json and no typecheck script gets a
321
+ // proposed `tsc --noEmit` check when the pnpm contract holds.
322
+ if(!selected.script&&kind==='typecheck'&&pm.name==='pnpm'&&!pm.issue&&
323
+ proposable(path.join(root,dir),member.scripts)&&!typechecks(scripts)){
324
+ try{for(const part of tscChecks(`${name}:typecheck`,null,{},run=>pnpmCheck(root,name,dir,file,run)))
325
+ checks.push({config:{kind,workspace:name,qualification:'pnpm-tsc-v1',...part},level:'ready',
326
+ reason:'proposed tsc --noEmit for the package\'s tsconfig.json; pnpm workspace TypeScript inputs; checked again before reuse'});}
327
+ catch{}
328
+ continue;
329
+ }
220
330
  if(!selected.script)continue;
221
331
  const command=pm.name==='npm'?['@which:npm','run',selected.script,
222
332
  '--workspace',name]:pm.name==='yarn'?
@@ -224,7 +334,7 @@ export function discoverNodeProject(root) {
224
334
  ['@which:pnpm','--filter',name,'run',selected.script];
225
335
  const check={name:`${name}:${kind}`,kind,command,
226
336
  inputs:['package.json',...(pm.lockfiles||[]),`${dir}/**`,
227
- `!${dir}/node_modules/**`]};
337
+ `!${dir}/node_modules/**`,`!${dir}/**/node_modules/**`]};
228
338
  let level=pm.issue&&pm.name!=='pnpm'?'unsupported':'recording';
229
339
  let reason=pm.issue||'workspace script found; shared configuration and installed inputs need review';
230
340
  if(!pm.issue&&pm.name==='yarn'&&pm.layout==='node-modules/classic'&&
@@ -237,22 +347,21 @@ export function discoverNodeProject(root) {
237
347
  level='ready';reason='pinned Yarn workspace tsc and installed inputs; checked again before reuse';
238
348
  }catch(e){reason=`workspace TypeScript contract needs review: ${e.message}`;}
239
349
  }
240
- if(!pm.issue&&pm.name==='pnpm'&&kind==='typecheck'&&
241
- /^(?:tsc --noEmit|tsc -p (?:\.|tsconfig\.json)(?: --noEmit)?)$/.test(
242
- (member.scripts||{})[selected.script]?.trim()||'')&&
243
- !member.scripts?.['pre'+selected.script]&&!member.scripts?.['post'+selected.script]){
244
- try{const contract=pnpmTypecheckInputs(root,name,selected.script);
245
- if(contract.limitations.length)throw Error(contract.limitations.join('; '));
246
- check.workspace=name;check.qualification='pnpm-tsc-v1';
247
- check.script=selected.script;
248
- check.cwd=dir;
249
- check.command=['@node',`@typescript-bin:${dir}`,
250
- ...member.scripts[selected.script].trim().split(/\s+/).slice(1)];
251
- check.inputs=['package.json','pnpm-lock.yaml','pnpm-workspace.yaml',file,
252
- `${dir}/src/**`];
253
- level='ready';reason='pinned pnpm workspace TypeScript inputs; checked again before reuse';
350
+ if(!pm.issue&&pm.name==='pnpm'&&kind==='typecheck'&&tscRuns((member.scripts||{})[selected.script])){
351
+ try{const parts=tscChecks(`${name}:typecheck`,selected.script,member.scripts,run=>pnpmCheck(root,name,dir,file,run));
352
+ for(const part of parts)checks.push({config:{kind,workspace:name,qualification:'pnpm-tsc-v1',...part},level:'ready',
353
+ reason:'pnpm workspace TypeScript inputs; checked again before reuse'});
354
+ continue;
254
355
  }catch(e){reason=`pnpm TypeScript contract needs review: ${e.message}`;}
255
356
  }
357
+ if(recipe==='tests-direct'&&kind==='test'&&!pm.issue){
358
+ try{const project=testProject(root,selected.script,dir);
359
+ Object.assign(check,{workspace:name,script:selected.script,cwd:dir,
360
+ command:['@node',`@test-runner:${project.runner}`,...project.args],...testCheckInputs(project),
361
+ qualification:testsQualification});
362
+ level='ready';reason=`explicit local ${project.runner} ${project.version} run of ${name}'s script ${JSON.stringify(selected.script)}; the whole checkout is its input; the package manager and lifecycle scripts are not executed`;
363
+ }catch(e){reason=`Test recipe unavailable (${e.code||e.message}); original script retained`;}
364
+ }
256
365
  checks.push({config:check,level,reason});
257
366
  }
258
367
  }