@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.
- package/CHANGELOG.md +101 -0
- package/README.md +9 -5
- package/bin/redue.mjs +26 -9
- package/docs/user/agents.md +11 -0
- package/docs/user/configuration.md +6 -0
- package/docs/user/direct-typescript.md +62 -5
- package/docs/user/getting-started.md +7 -7
- package/docs/user/node-script.md +153 -0
- package/docs/user/pilot.md +6 -5
- package/docs/user/supported-projects.md +26 -15
- package/docs/user/tests-direct.md +13 -3
- package/package.json +2 -2
- package/src/agent-integration.mjs +16 -8
- package/src/claude-hook.mjs +119 -34
- package/src/cli.mjs +34 -16
- package/src/daemon.mjs +23 -8
- package/src/declared-plan.mjs +9 -6
- package/src/direct-typescript-probe.mjs +5 -3
- package/src/direct-typescript.mjs +58 -11
- package/src/glob.mjs +8 -0
- package/src/index.mjs +10 -4
- package/src/node-onboarding.mjs +117 -37
- package/src/node-script.mjs +362 -0
- package/src/npm-integrity.mjs +124 -0
- package/src/npmrc.mjs +16 -0
- package/src/plan-worker.mjs +4 -1
- package/src/pnpm-inputs.mjs +63 -35
- package/src/pnpm-shim-templates.json +180 -0
- package/src/pnpm-shims.mjs +54 -0
- package/src/pnpm-typecheck-probe.mjs +11 -6
- package/src/presentation.mjs +8 -1
- package/src/test-direct.mjs +103 -21
- package/src/test-recorder.cjs +101 -14
- package/src/typescript-input-facts.mjs +7 -7
- package/src/typescript-invocation.mjs +92 -0
- package/src/typescript-list.mjs +2 -2
- package/src/typescript-qualification.mjs +8 -9
- package/src/workspace-manifests.mjs +3 -4
package/src/declared-plan.mjs
CHANGED
|
@@ -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
|
|
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
|
|
96
|
-
// package manifest appearing or pnpm's
|
|
97
|
-
|
|
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
|
|
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,
|
|
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
|
|
64
|
-
if(!
|
|
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
|
-
//
|
|
106
|
-
export function directBuildInfo(root,
|
|
107
|
-
const
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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,
|
|
196
|
-
//
|
|
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;
|
package/src/node-onboarding.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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 ${
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
263
|
-
(
|
|
264
|
-
|
|
265
|
-
|
|
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){
|