@redue/cli 0.1.0-alpha.17 → 0.1.0-alpha.19

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.
@@ -7,8 +7,10 @@ import {permittedRoots,resolveDeclaredInstalled} from './installed-inputs.mjs';
7
7
  import {qualifyTypeScript} from './typescript-qualification.mjs';
8
8
  import {directBuildInfo} from './direct-typescript.mjs';
9
9
  import {qualifyTests,testWorkspacePatterns,testsQualification} from './test-direct.mjs';
10
+ import {qualifyNodeScript,scriptQualification} from './node-script.mjs';
10
11
  import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
11
12
  import {pnpmTypecheckInputs} from './pnpm-inputs.mjs';
13
+ import {checkRun} from './typescript-invocation.mjs';
12
14
  import {observedRelative,withinPath,realObservedPath} from './path-identity.mjs';
13
15
  import {timing} from './decision-profile.mjs';
14
16
 
@@ -64,8 +66,7 @@ export function discoverDeclared(config) {
64
66
  catch(e){workspaceIssue=e.message;}
65
67
  }
66
68
  if(selected.qualification==='pnpm-tsc-v1'){
67
- try{workspace=pnpmTypecheckInputs(root,selected.workspace||'.',
68
- selected.script||selected.command.at(-1));}
69
+ try{workspace=pnpmTypecheckInputs(root,selected.workspace||'.',checkRun(selected));}
69
70
  catch(e){workspaceIssue=e.message;}
70
71
  }
71
72
  timing('plan.workspace_inputs',workspaceStarted,{available:workspace?1:0});
@@ -75,13 +76,14 @@ export function discoverDeclared(config) {
75
76
  // until then the run reports the unexpected write and is not reusable.
76
77
  let buildInfo=null;
77
78
  if(selected.qualification==='npm-typescript-direct-v1')
78
- try{buildInfo=directBuildInfo(root,selected.script);}catch{}
79
+ try{buildInfo=directBuildInfo(root,checkRun(selected));}catch{}
79
80
  const excluded=buildInfo?['!'+buildInfo]:[];
80
81
  const installedStarted=performance.now();
81
82
  const installed=resolveDeclaredInstalled(root,
82
83
  [...(selected.installedInputs||[]),...(workspace?.installed||[]),...excluded],allowedRoots);
83
84
  timing('plan.installed_resolution',installedStarted,{mappings:installed.mappings.length});
84
85
  const automatic=selected.qualification===testsQualification?qualifyTests(root,selected):
86
+ selected.qualification===scriptQualification?qualifyNodeScript(root,selected):
85
87
  qualifyTypeScript(root,selected,{workspace,issue:workspaceIssue});
86
88
  const sourcePatterns=[...new Set([...selected.inputs,...(workspace?.source||[]),
87
89
  ...(workspace?.generated||[]),...excluded])].sort();
@@ -92,9 +94,10 @@ export function discoverDeclared(config) {
92
94
  ...(workspaceIssue?[workspaceIssue]:[])];
93
95
  for(const item of workspace?.configurationInputs||[])configurationInputs.add(item);
94
96
  for(const item of workspace?.workspacePatterns||[])workspacePatterns.add(item);
95
- // A workspace test check is qualified against the current package list: a
96
- // package manifest appearing or pnpm's package list changing re-plans.
97
- if(selected.qualification===testsQualification&&selected.workspace)try{
97
+ // A workspace test check, and a general check in a workspace, is qualified
98
+ // against the current package list: a package manifest appearing or pnpm's
99
+ // package list changing re-plans.
100
+ if(selected.qualification===testsQualification&&selected.workspace||selected.qualification===scriptQualification)try{
98
101
  for(const item of testWorkspacePatterns(root,JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'))))
99
102
  workspacePatterns.add(item);
100
103
  if(fs.existsSync(path.join(root,'pnpm-workspace.yaml')))configurationInputs.add('pnpm-workspace.yaml');
@@ -4,18 +4,20 @@ import {typeScriptInputFacts} from './typescript-input-facts.mjs';
4
4
  import {contextOnly,probeResult} from './probe-result.mjs';
5
5
  import {realObservedPath} from './path-identity.mjs';
6
6
  import {timing} from './decision-profile.mjs';
7
+ import {runArgv,runFromArgv} from './typescript-invocation.mjs';
7
8
  try{
8
- const [rootArg,script]=process.argv.slice(2),root=realObservedPath(rootArg);
9
+ const [rootArg,...rest]=process.argv.slice(2).filter(value=>!value.startsWith('--redue-')),root=realObservedPath(rootArg);
10
+ const run=runFromArgv(rest),script=runArgv(run).join(' ');
9
11
  // Verify all installed implementation bytes before loading compiler API/code,
10
12
  // including the optimized context-only path. A version label is insufficient.
11
13
  const implementationStarted=performance.now();
12
- const compiler=directProject(root,script);
14
+ const compiler=directProject(root,run);
13
15
  timing('direct_probe.implementation',implementationStarted,{version:compiler.version});
14
16
  const context={interpretation:directInterpretation,compiler:compiler.digest,
15
17
  entry:compiler.entry,node:[realObservedPath(process.execPath),process.version]};
16
18
  const contextHash=directContextHash(compiler,context.node);
17
19
  if(!contextOnly(contextHash)){
18
- const {queries,configs,files,buildInfo}=typeScriptInputFacts(root,compiler.tsRoot,compiler.entry);
20
+ const {queries,configs,files,buildInfo}=typeScriptInputFacts(root,compiler.tsRoot,compiler.entry,compiler.config);
19
21
  const layout={root,script,configs,files,context,...(buildInfo?{buildInfo}:{})};
20
22
  probeResult(directProbeOutput(layout),contextHash,queries,layout);
21
23
  }
@@ -3,7 +3,9 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import {createHash} from 'node:crypto';
5
5
  import {createRequire} from 'node:module';
6
- import {realObservedPath,withinPath} from './path-identity.mjs';
6
+ import {racyUntil,realObservedPath,withinPath} from './path-identity.mjs';
7
+ import {cachedTarball,lockedPackage,matchTarball,npmCacheDir,registryPackage,tarballFiles} from './npm-integrity.mjs';
8
+ import {runConfig,tscRun} from './typescript-invocation.mjs';
7
9
  export const directInterpretation='npm-direct-typescript@1';
8
10
  export const directQualification='npm-typescript-direct-v1';
9
11
  const reviewed={
@@ -60,8 +62,9 @@ export function directCompiler(root,{verify=true}={}){
60
62
  const tsRoot=path.dirname(realObservedPath(manifest));
61
63
  if(!withinPath(tsRoot,root)||tsRoot!==realObservedPath(path.join(root,'node_modules/typescript')))
62
64
  fail('direct-typescript-layout-unsupported');
63
- const version=JSON.parse(fs.readFileSync(manifest)).version,spec=reviewed[version];
64
- if(!spec)fail('direct-typescript-version-unsupported');
65
+ const version=JSON.parse(fs.readFileSync(manifest)).version;
66
+ if(!reviewed[version])return lockedCompiler(root,tsRoot,version,verify);
67
+ const spec=reviewed[version];
65
68
  if(verify){
66
69
  const files=[];
67
70
  const walk=(dir,relative='')=>{
@@ -78,6 +81,42 @@ export function directCompiler(root,{verify=true}={}){
78
81
  }
79
82
  return {tsRoot,version,entry:path.join(tsRoot,spec.entry),digest:spec.digest};
80
83
  }
84
+ // A TypeScript 5.x or 6.0 the lockfile records: the installed package must be
85
+ // the registry's `typescript` at that version, holding exactly the files of
86
+ // the tarball npm cached under the lockfile's integrity. Its identity is that
87
+ // integrity. The entry is lib/_tsc.js where the package has one (5.7 and
88
+ // later), as for the reviewed versions, else lib/tsc.js. A verified tree is
89
+ // remembered per process while its files keep their identity.
90
+ const verifiedTrees=new Map();
91
+ function treeIdentity(tsRoot){
92
+ const parts=[],now=Date.now();
93
+ const walk=folder=>{for(const entry of fs.readdirSync(folder,{withFileTypes:true})){
94
+ const full=path.join(folder,entry.name),st=fs.lstatSync(full,{bigint:true});
95
+ if(racyUntil(st)>now)return null;
96
+ parts.push([full,st.dev,st.ino,st.size,st.mtimeNs,st.ctimeNs].join(':'));
97
+ if(entry.isDirectory()&&walk(full)===null)return null;
98
+ }};
99
+ try{if(walk(tsRoot)===null)return null;}catch{return null;}
100
+ return hash(parts.sort().join('\n'));
101
+ }
102
+ function lockedCompiler(root,tsRoot,version,verify){
103
+ if(!/^(?:5\.\d+|6\.0)\.\d+$/.test(version))fail('direct-typescript-version-unsupported');
104
+ let locked;try{locked=lockedPackage(root,'typescript');}
105
+ catch(e){fail(['npm-lockfile-unreadable','npm-shrinkwrap-unsupported'].includes(e.code)?e.code:'direct-typescript-lockfile-integrity-missing');}
106
+ if(locked.version!==version)fail('direct-typescript-lockfile-version-differs');
107
+ if(!registryPackage(locked,'typescript',version)||
108
+ JSON.parse(fs.readFileSync(path.join(tsRoot,'package.json'),'utf8')).name!=='typescript')
109
+ fail('direct-typescript-lockfile-source-unsupported');
110
+ if(verify){
111
+ const tree=treeIdentity(tsRoot);
112
+ if(!tree||verifiedTrees.get(locked.integrity)!==tree){
113
+ matchTarball(tsRoot,tarballFiles(cachedTarball(npmCacheDir(root),locked.integrity)));
114
+ if(tree)verifiedTrees.set(locked.integrity,tree);
115
+ }
116
+ }
117
+ const entry=fs.existsSync(path.join(tsRoot,'lib','_tsc.js'))?'lib/_tsc.js':'lib/tsc.js';
118
+ return {tsRoot,version,entry:path.join(tsRoot,entry),digest:hash('npm-integrity\0'+locked.integrity)};
119
+ }
81
120
  // The direct probe's output: root, script, configuration and program files with
82
121
  // their byte digests, and the execution context. A recorded run rebuilds it from
83
122
  // the same layout (direct-run-certificate.mjs).
@@ -101,13 +140,16 @@ export function buildInfoFor(ts,options,root) {
101
140
  if(!relative.endsWith('.tsbuildinfo')||/[*?[\]{}()!+@,]/.test(relative))return {issue:'typescript-buildinfo-name-unsupported'};
102
141
  return {file:relative};
103
142
  }
143
+ // The config a run's arguments name, relative to the root, or null.
144
+ export const directConfig=(root,args)=>runConfig(root,args);
104
145
  // The plan's build-info file, or null. The compiler is loaded only when the
105
- // root config sets incremental or extends another config.
106
- export function directBuildInfo(root,script) {
107
- const config=path.join(root,'tsconfig.json');
146
+ // run's config sets incremental or extends another config.
147
+ export function directBuildInfo(root,run) {
148
+ const name=directConfig(root,run.args);if(!name)return null;
149
+ const config=path.join(root,name);
108
150
  let raw;try{raw=fs.readFileSync(config,'utf8');}catch{return null;}
109
151
  if(!/"(?:incremental|extends)"\s*:/.test(raw))return null;
110
- const compiler=directProject(root,script,{verify:false}),ts=createRequire(import.meta.url)(compiler.tsRoot);
152
+ const compiler=directProject(root,run,{verify:false}),ts=createRequire(import.meta.url)(compiler.tsRoot);
111
153
  const parsed=ts.getParsedCommandLineOfConfigFile(config,{},{...ts.sys,onUnRecoverableConfigFileDiagnostic(){}});
112
154
  return parsed?.options?.composite?null:buildInfoFor(ts,parsed?.options,root).file??null;
113
155
  }
@@ -120,16 +162,21 @@ export function directContextHash(compiler,node){
120
162
  return hash(JSON.stringify({interpretation:directInterpretation,compiler:compiler.digest,
121
163
  entry:compiler.entry,node}));
122
164
  }
123
- export function directProject(root,script,{verify=true}={}){
165
+ // The compiler for one check run {script, part, args}: script null for a
166
+ // check init proposed without one. The run must pass --noEmit and name a
167
+ // config inside the root.
168
+ export function directProject(root,run,{verify=true}={}){
124
169
  const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json')));
125
170
  if(pkg.workspaces||pkg.packageManager&&!/^npm@/.test(pkg.packageManager)||
126
171
  !['package-lock.json','npm-shrinkwrap.json'].some(file=>fs.existsSync(path.join(root,file)))||
127
172
  ['yarn.lock','pnpm-lock.yaml','bun.lock','bun.lockb'].some(file=>fs.existsSync(path.join(root,file))))
128
173
  fail('direct-npm-project-unsupported');
129
- if(pkg.scripts?.[script]?.trim()!=='tsc --noEmit'||pkg.scripts?.['pre'+script]||pkg.scripts?.['post'+script])
130
- fail('typecheck-script-changed');
174
+ const own=tscRun(pkg.scripts,run).run;
175
+ if(!own?.noEmit)fail('typecheck-script-changed');
176
+ const config=directConfig(root,run.args);
177
+ if(!config||!fs.existsSync(path.join(root,config)))fail('typescript-config-unavailable');
131
178
  const issue=directEnvironmentIssue();if(issue)fail(issue);
132
- return directCompiler(root,{verify});
179
+ return {...directCompiler(root,{verify}),config};
133
180
  }
134
181
  // Provenance only: retain a digest, never proxy URLs/credentials or a raw dump.
135
182
  // No npm process runs in this recipe; these keys are not compiler applicability.
package/src/glob.mjs CHANGED
@@ -7,3 +7,11 @@ export default {
7
7
  matcher(pattern,options){return picomatch(pattern,options);},
8
8
  isMatch(file,patterns,options){return picomatch(patterns,options)(file);}
9
9
  };
10
+ // A matcher for a pattern list with `!` exclusions: a path matches when a
11
+ // positive pattern does and no excluded one does. A plain list match would
12
+ // take any `!` pattern as matching everything else.
13
+ export function patternMatcher(patterns){
14
+ const positive=patterns.filter(p=>!p.startsWith('!')).map(p=>picomatch(p,{dot:true}));
15
+ const negative=patterns.filter(p=>p.startsWith('!')).map(p=>picomatch(p.slice(1),{dot:true}));
16
+ return file=>positive.some(fn=>fn(file))&&!negative.some(fn=>fn(file));
17
+ }
package/src/index.mjs CHANGED
@@ -55,7 +55,7 @@ function fileHash(root, rel,installed=false,before=fs.lstatSync(path.join(root,r
55
55
  const target=fs.statSync(resolution.physical,{bigint:true});
56
56
  const physical=path.relative(root,resolution.physical).split(path.sep).join('/');
57
57
  const directory=target.isDirectory()&&installed&&observedDir?.(physical);
58
- if(!target.isFile()&&!directory)throw Error(`directory input symlink unsupported: ${rel}`);
58
+ if(!target.isFile()&&!directory)throw Error(`directory input symlink unsupported: ${rel} -> ${physical}`);
59
59
  if(directory)value='dirlink:'+JSON.stringify(resolution.links)+':'+physical;
60
60
  else{
61
61
  if(installed)racy=racyUntil(target);else bytes=Number(target.size);
@@ -192,10 +192,16 @@ export class InputIndex {
192
192
  const installed=this.installedPath(rel);
193
193
  try{const started=performance.now();
194
194
  // A linked directory counts as observed only strictly inside an
195
- // installed root, and only if every check matching the link also
196
- // observes the directory's own files (its package.json stands for them).
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);
197
202
  const observedDir=installed?physical=>!this.includeInstalled&&
198
- [...this.installedRootSet].some(root=>physical.startsWith(root+'/'))&&
203
+ ([...this.installedRootSet].some(root=>physical.startsWith(root+'/'))||
204
+ !physical.split('/').includes('node_modules')&&!this.installedPath(physical+'/package.json')&&matched.every(recorded))&&
199
205
  matched.every(name=>this.matches(this.checks.get(name),physical+'/package.json')):null;
200
206
  inspection=fileHash(this.root,rel,installed,stat,observedDir);
201
207
  fresh=inspection.hash;
@@ -1,5 +1,9 @@
1
1
  import {directProject,directQualification} from './direct-typescript.mjs';
2
- import {testProject,testRecipeScript,testCheckInputs,testsQualification} from './test-direct.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'];
3
7
  import fs from 'node:fs';
4
8
  import path from 'node:path';
5
9
  import {spawnSync} from 'node:child_process';
@@ -8,11 +12,12 @@ import {pnpmInstallInfo,pnpmProjectInfo,pnpmTypecheckInputs} from './pnpm-inputs
8
12
  import {realObservedPath} from './path-identity.mjs';
9
13
  import {findExecutable} from './executable-lookup.mjs';
10
14
  import {workspaceManifests} from './workspace-manifests.mjs';
15
+ import {partName,tscRuns} from './typescript-invocation.mjs';
11
16
 
12
17
  const exists=file=>{try{fs.accessSync(file);return true;}catch{return false;}};
13
18
  const readJson=file=>JSON.parse(fs.readFileSync(file,'utf8'));
14
19
  const scriptNames={
15
- typecheck:['typecheck','type-check','check:types','types:check'],
20
+ typecheck:['typecheck','type-check','check:types','types:check','check-types'],
16
21
  test:['test','test:unit','test:check'],
17
22
  lint:['lint','lint:check'],
18
23
  build:['build'],
@@ -82,6 +87,28 @@ function manager(root,pkg) {
82
87
  return {name,version,layout:'node-modules/classic',issue:null,lockfiles:locks};
83
88
  }
84
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('!'))];
85
112
  function sourceLinkIssue(root) {
86
113
  const stack=likelyDirs.filter(name=>exists(path.join(root,name)));
87
114
  let inspected=0;
@@ -107,7 +134,8 @@ function sourceInputs(root,kind,lockfiles) {
107
134
  '!node_modules/**','!.git/**'])];
108
135
  const dirs=likelyDirs.filter(name=>exists(path.join(root,name)))
109
136
  .map(name=>`${name}/**`);
110
- 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'])])];
111
139
  }
112
140
  function classify(kind,scriptName,script,pkg,managerInfo,root) {
113
141
  const value=script.trim();
@@ -139,14 +167,26 @@ function selectScript(kind,names,scripts) {
139
167
  const candidates=Object.keys(scripts).filter(name=>{
140
168
  const value=scripts[name]?.trim();
141
169
  if(typeof value!=='string')return false;
142
- if(kind==='typecheck')return exactTypeScript(value);
170
+ if(kind==='typecheck')return noEmitRuns(value);
143
171
  if(kind==='test')return /^(?:vitest|jest)(?:\s|$)/.test(value);
144
172
  if(kind==='lint')return /^eslint(?:\s|$)/.test(value);
145
173
  return /^vite\s+build(?:\s|$)/.test(value);
146
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]};
147
179
  return candidates.length===1?{script:candidates[0]}:
148
180
  candidates.length>1?{ambiguous:candidates}:{};
149
181
  }
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)])]};
189
+ }
150
190
  // Workspace packages as init finds them, with their scripts; [] without workspaces.
151
191
  export function workspacePackages(root){
152
192
  root=realObservedPath(root);
@@ -156,8 +196,27 @@ export function workspacePackages(root){
156
196
  return workspaceManifests(root,patterns).map(({file,pkg:member})=>
157
197
  ({name:member.name,dir:path.posix.dirname(file),scripts:member.scripts&&typeof member.scripts==='object'?member.scripts:{}}));
158
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
+ }
159
215
  export function discoverNodeProject(root,{recipe=null}={}) {
160
- if(recipe&&!['typescript-direct','tests-direct'].includes(recipe))throw Error('unknown recipe; use typescript-direct or tests-direct');
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;
161
220
  root=realObservedPath(root);
162
221
  const packageFile=path.join(root,'package.json');
163
222
  if(!exists(packageFile))throw Error('run init from a repository containing package.json');
@@ -178,22 +237,30 @@ export function discoverNodeProject(root,{recipe=null}={}) {
178
237
  for(const [kind,names] of Object.entries(scriptNames)) {
179
238
  const selected=selectScript(kind,names,scripts),script=selected.script;
180
239
  if(selected.ambiguous)ambiguous.push({kind,scripts:selected.ambiguous});
181
- 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;
182
246
  let classification=observationIssue?{level:'unsupported',reason:observationIssue}:
183
- classify(kind,script,scripts[script],pkg,pm,root);
247
+ script?classify(kind,script,scripts[script],pkg,pm,root):{level:'recording',reason:'no typecheck script'};
248
+ let parts=null;
184
249
  if(workspaces&&classification.qualification==='typescript-noemit-v1')classification={level:'recording',
185
250
  reason:'workspace task and installed-input closure need explicit qualification'};
186
251
  const check={name:kind,script,kind,inputs:sourceInputs(root,kind,pm.lockfiles||[])};
187
- if(recipe==='typescript-direct'&&kind==='typecheck'){
252
+ if((recipe==='typescript-direct'||auto&&pm.name==='npm'&&!workspaces)&&kind==='typecheck'){
188
253
  try{
189
254
  if(pm.name!=='npm'||workspaces||observationIssue||pm.issue)throw Error('direct recipe requires a supported single-package npm checkout');
190
- const compiler=directProject(root,script);
191
- check.command=['@node','@typescript-compiler:.','--noEmit'];check.cwd='.';
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:'.'};});
192
259
  classification={level:'ready',qualification:directQualification,
193
- reason:`explicit local TypeScript ${compiler.version} compilation; npm launcher/lifecycle scripts are not executed; package.json stays unchanged`};
260
+ reason:`explicit local TypeScript ${version} compilation; npm launcher/lifecycle scripts are not executed; package.json stays unchanged`};
194
261
  }catch(e){classification={level:'recording',reason:`Direct recipe unavailable (${e.code||e.message}); original npm script retained, without bypassing required behavior`};}
195
262
  }
196
- if(recipe==='tests-direct'&&kind==='test'){
263
+ if((recipe==='tests-direct'||auto)&&kind==='test'){
197
264
  try{
198
265
  if(!['npm','pnpm','yarn'].includes(pm.name)||workspaces||observationIssue||pm.issue)
199
266
  throw Error('test recipe requires a supported single-package npm, pnpm or Yarn checkout');
@@ -207,20 +274,32 @@ export function discoverNodeProject(root,{recipe=null}={}) {
207
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`};
208
275
  }catch(e){classification={level:'recording',reason:`Test recipe unavailable (${e.code||e.message}); original npm script retained`};}
209
276
  }
210
- if(pm.name==='pnpm'&&kind==='typecheck'&&!workspaces&&!pm.issue&&
211
- /^(?:tsc --noEmit|tsc -p (?:\.|tsconfig\.json)(?: --noEmit)?)$/.test(scripts[script].trim())){
212
- try{const contract=pnpmTypecheckInputs(root,'.',script);
213
- if(contract.limitations.length)throw Error(contract.limitations.join('; '));
214
- check.command=['@node','@typescript-bin:.',
215
- ...scripts[script].trim().split(/\s+/).slice(1)];
216
- check.cwd='.';
217
- check.inputs=['package.json','pnpm-lock.yaml',
218
- ...(exists(path.join(root,'pnpm-workspace.yaml'))?['pnpm-workspace.yaml']:[]),
219
- 'tsconfig.json','src/**'];
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)])]};});
220
286
  classification={level:'ready',reason:'pnpm TypeScript inputs and installation; checked again before reuse',
221
287
  qualification:'pnpm-tsc-v1'};}
222
288
  catch(e){classification={level:'recording',reason:`pnpm TypeScript contract needs review: ${e.message}`};}
223
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
+ }
224
303
  if(classification.qualification)check.qualification=classification.qualification;
225
304
  checks.push({config:check,...classification});
226
305
  }
@@ -238,6 +317,16 @@ export function discoverNodeProject(root,{recipe=null}={}) {
238
317
  const selected=selectScript(kind,names,member.scripts||{});
239
318
  if(selected.ambiguous){ambiguous.push({kind:`${name}:${kind}`,
240
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
+ }
241
330
  if(!selected.script)continue;
242
331
  const command=pm.name==='npm'?['@which:npm','run',selected.script,
243
332
  '--workspace',name]:pm.name==='yarn'?
@@ -245,7 +334,7 @@ export function discoverNodeProject(root,{recipe=null}={}) {
245
334
  ['@which:pnpm','--filter',name,'run',selected.script];
246
335
  const check={name:`${name}:${kind}`,kind,command,
247
336
  inputs:['package.json',...(pm.lockfiles||[]),`${dir}/**`,
248
- `!${dir}/node_modules/**`]};
337
+ `!${dir}/node_modules/**`,`!${dir}/**/node_modules/**`]};
249
338
  let level=pm.issue&&pm.name!=='pnpm'?'unsupported':'recording';
250
339
  let reason=pm.issue||'workspace script found; shared configuration and installed inputs need review';
251
340
  if(!pm.issue&&pm.name==='yarn'&&pm.layout==='node-modules/classic'&&
@@ -258,20 +347,11 @@ export function discoverNodeProject(root,{recipe=null}={}) {
258
347
  level='ready';reason='pinned Yarn workspace tsc and installed inputs; checked again before reuse';
259
348
  }catch(e){reason=`workspace TypeScript contract needs review: ${e.message}`;}
260
349
  }
261
- if(!pm.issue&&pm.name==='pnpm'&&kind==='typecheck'&&
262
- /^(?:tsc --noEmit|tsc -p (?:\.|tsconfig\.json)(?: --noEmit)?)$/.test(
263
- (member.scripts||{})[selected.script]?.trim()||'')&&
264
- !member.scripts?.['pre'+selected.script]&&!member.scripts?.['post'+selected.script]){
265
- try{const contract=pnpmTypecheckInputs(root,name,selected.script);
266
- if(contract.limitations.length)throw Error(contract.limitations.join('; '));
267
- check.workspace=name;check.qualification='pnpm-tsc-v1';
268
- check.script=selected.script;
269
- check.cwd=dir;
270
- check.command=['@node',`@typescript-bin:${dir}`,
271
- ...member.scripts[selected.script].trim().split(/\s+/).slice(1)];
272
- check.inputs=['package.json','pnpm-lock.yaml','pnpm-workspace.yaml',file,
273
- `${dir}/src/**`];
274
- level='ready';reason='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;
275
355
  }catch(e){reason=`pnpm TypeScript contract needs review: ${e.message}`;}
276
356
  }
277
357
  if(recipe==='tests-direct'&&kind==='test'&&!pm.issue){