@redue/cli 0.1.0-alpha.17 → 0.1.0-alpha.18
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 +58 -0
- package/README.md +1 -1
- package/bin/redue.mjs +16 -6
- package/docs/user/agents.md +11 -0
- package/docs/user/configuration.md +4 -0
- package/docs/user/direct-typescript.md +62 -5
- package/docs/user/pilot.md +6 -5
- package/docs/user/supported-projects.md +14 -5
- package/docs/user/tests-direct.md +8 -0
- package/package.json +2 -2
- package/src/agent-integration.mjs +2 -2
- package/src/claude-hook.mjs +100 -27
- package/src/cli.mjs +2 -2
- package/src/daemon.mjs +20 -6
- package/src/declared-plan.mjs +3 -3
- package/src/direct-typescript-probe.mjs +5 -3
- package/src/direct-typescript.mjs +58 -11
- package/src/glob.mjs +8 -0
- package/src/node-onboarding.mjs +79 -31
- 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-typecheck-probe.mjs +11 -6
- package/src/test-direct.mjs +61 -12
- 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/daemon.mjs
CHANGED
|
@@ -20,7 +20,9 @@ import {inputKey,planGuard,toolchainKey} from './decision-validation.mjs';
|
|
|
20
20
|
import {queriesHold,runFactReason as runFactChanged} from './typescript-list.mjs';
|
|
21
21
|
import {nextProbeHint,probeDeadline,probeFloorMs} from './probe-deadline.mjs';
|
|
22
22
|
import {statusSummary} from './cached-state.mjs';
|
|
23
|
-
import {directProject,directProbeValue
|
|
23
|
+
import {directProject,directProbeValue} from './direct-typescript.mjs';
|
|
24
|
+
import {npmCacheDir} from './npm-integrity.mjs';
|
|
25
|
+
import {runFromArgv} from './typescript-invocation.mjs';
|
|
24
26
|
import {buildInfoPath,recordedCertificate} from './direct-run-certificate.mjs';
|
|
25
27
|
import {timing,mark,traceBase,traceContext,traceScope,traceEnvironment,traceId} from './decision-profile.mjs';
|
|
26
28
|
|
|
@@ -130,7 +132,7 @@ function continuityBarrier() {
|
|
|
130
132
|
// The basis a full direct probe is checked against: plan guard, observation
|
|
131
133
|
// continuity, and the compiler tree and Node identity taken before the probe.
|
|
132
134
|
function certificateBasis(row) {
|
|
133
|
-
const argv=row.plan.probes[0],compiler=directProject(argv[2],argv
|
|
135
|
+
const argv=row.plan.probes[0],compiler=directProject(argv[2],runFromArgv(argv.slice(3)),{verify:false});
|
|
134
136
|
return {guard:planGuard(index.bundle),gaps:metrics.gaps,
|
|
135
137
|
compiler:JSON.stringify([compiler.tsRoot,compiler.entry,compiler.digest]),
|
|
136
138
|
toolchain:toolchainKey(compiler.tsRoot,argv[0])};
|
|
@@ -238,8 +240,18 @@ function probe(row,allowReuse=false,{launch=true}={}) {
|
|
|
238
240
|
.includes(row.plan.qualification)?
|
|
239
241
|
/VSTATE_REASON:([a-z-]+)/.exec(out.stderr?.toString()||'')?.[1]:null;
|
|
240
242
|
const explanations={
|
|
241
|
-
'direct-typescript-implementation-unreviewed':'installed TypeScript
|
|
242
|
-
'direct-typescript-version-unsupported'
|
|
243
|
+
'direct-typescript-implementation-unreviewed':'installed TypeScript files differ from the reviewed compiler or from the tarball package-lock.json names',
|
|
244
|
+
'direct-typescript-version-unsupported':'direct compiler recipe supports TypeScript 5.x and 6.0',
|
|
245
|
+
'direct-typescript-lockfile-source-unsupported':'package-lock.json installs TypeScript from somewhere other than the registry\'s typescript package (an alias, a file or a git source)',
|
|
246
|
+
'npm-shrinkwrap-unsupported':'npm-shrinkwrap.json is not read; the reviewed TypeScript releases still qualify',
|
|
247
|
+
'direct-typescript-lockfile-integrity-missing':'package-lock.json records no integrity for the installed TypeScript',
|
|
248
|
+
'direct-typescript-lockfile-version-differs':'installed TypeScript version differs from package-lock.json; run npm ci',
|
|
249
|
+
'npm-lockfile-unreadable':'package-lock.json cannot be read',
|
|
250
|
+
'npm-lockfile-integrity-unsupported':'package-lock.json records TypeScript integrity with neither sha512 nor sha1',
|
|
251
|
+
'npm-tarball-uncached':`the TypeScript tarball package-lock.json names is not in npm's cache (${npmCacheDir(root)}); run npm ci or npm cache add`,
|
|
252
|
+
'npm-tarball-corrupt':"npm's cached TypeScript tarball does not match its integrity",
|
|
253
|
+
'npm-tarball-invalid':"npm's cached TypeScript tarball cannot be read",
|
|
254
|
+
'npm-tarball-link-unsupported':"npm's cached TypeScript tarball holds links",
|
|
243
255
|
'direct-execution-environment-unsupported':'direct compiler rejects development helpers, Node cache/preloads, and unreviewed runtime overrides',
|
|
244
256
|
'direct-node-version-unsupported':'direct compiler recipe supports Node 22 and 24',
|
|
245
257
|
'npm-launcher-unobserved-inputs':'npm launcher uses unobserved notifier inputs; execution is recording-only',
|
|
@@ -492,7 +504,7 @@ function adopt(name,row,token) {
|
|
|
492
504
|
return done('inputs or certificate changed during the run');
|
|
493
505
|
const cache=pending.cache,argv=row.plan.probes[0];
|
|
494
506
|
let result;
|
|
495
|
-
try{result=recordedCertificate(text,cache,directProject(argv[2],argv
|
|
507
|
+
try{result=recordedCertificate(text,cache,directProject(argv[2],runFromArgv(argv.slice(3)),{verify:false}),buildInfoPath(row.plan));}
|
|
496
508
|
catch{return done('recording unreadable');}
|
|
497
509
|
if(result.issue)return done(result.issue);
|
|
498
510
|
const identities=new Map(cache.identities);
|
|
@@ -1217,7 +1229,9 @@ const server=net.createServer(client=>{
|
|
|
1217
1229
|
event('control_reply_unavailable',{classification:handled?'closed_without_reply':
|
|
1218
1230
|
'closed_before_request',request_bytes:text.length});});
|
|
1219
1231
|
client.on('data',d=>{
|
|
1220
|
-
|
|
1232
|
+
// A sync request carries each check's context digests, about 1.5 KB per
|
|
1233
|
+
// check; 1 MiB leaves room for hundreds of checks.
|
|
1234
|
+
if(handled)return;text+=d;if(text.length>1024*1024){event('control_request_rejected',
|
|
1221
1235
|
{classification:'request_too_large',request_bytes:text.length});client.destroy();return;}
|
|
1222
1236
|
if(!text.includes('\n'))return;handled=true;
|
|
1223
1237
|
try{const message=JSON.parse(text);
|
package/src/declared-plan.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import {directBuildInfo} from './direct-typescript.mjs';
|
|
|
9
9
|
import {qualifyTests,testWorkspacePatterns,testsQualification} from './test-direct.mjs';
|
|
10
10
|
import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
|
|
11
11
|
import {pnpmTypecheckInputs} from './pnpm-inputs.mjs';
|
|
12
|
+
import {checkRun} from './typescript-invocation.mjs';
|
|
12
13
|
import {observedRelative,withinPath,realObservedPath} from './path-identity.mjs';
|
|
13
14
|
import {timing} from './decision-profile.mjs';
|
|
14
15
|
|
|
@@ -64,8 +65,7 @@ export function discoverDeclared(config) {
|
|
|
64
65
|
catch(e){workspaceIssue=e.message;}
|
|
65
66
|
}
|
|
66
67
|
if(selected.qualification==='pnpm-tsc-v1'){
|
|
67
|
-
try{workspace=pnpmTypecheckInputs(root,selected.workspace||'.',
|
|
68
|
-
selected.script||selected.command.at(-1));}
|
|
68
|
+
try{workspace=pnpmTypecheckInputs(root,selected.workspace||'.',checkRun(selected));}
|
|
69
69
|
catch(e){workspaceIssue=e.message;}
|
|
70
70
|
}
|
|
71
71
|
timing('plan.workspace_inputs',workspaceStarted,{available:workspace?1:0});
|
|
@@ -75,7 +75,7 @@ export function discoverDeclared(config) {
|
|
|
75
75
|
// until then the run reports the unexpected write and is not reusable.
|
|
76
76
|
let buildInfo=null;
|
|
77
77
|
if(selected.qualification==='npm-typescript-direct-v1')
|
|
78
|
-
try{buildInfo=directBuildInfo(root,selected
|
|
78
|
+
try{buildInfo=directBuildInfo(root,checkRun(selected));}catch{}
|
|
79
79
|
const excluded=buildInfo?['!'+buildInfo]:[];
|
|
80
80
|
const installedStarted=performance.now();
|
|
81
81
|
const installed=resolveDeclaredInstalled(root,
|
|
@@ -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/node-onboarding.mjs
CHANGED
|
@@ -8,11 +8,12 @@ import {pnpmInstallInfo,pnpmProjectInfo,pnpmTypecheckInputs} from './pnpm-inputs
|
|
|
8
8
|
import {realObservedPath} from './path-identity.mjs';
|
|
9
9
|
import {findExecutable} from './executable-lookup.mjs';
|
|
10
10
|
import {workspaceManifests} from './workspace-manifests.mjs';
|
|
11
|
+
import {partName,tscRuns} from './typescript-invocation.mjs';
|
|
11
12
|
|
|
12
13
|
const exists=file=>{try{fs.accessSync(file);return true;}catch{return false;}};
|
|
13
14
|
const readJson=file=>JSON.parse(fs.readFileSync(file,'utf8'));
|
|
14
15
|
const scriptNames={
|
|
15
|
-
typecheck:['typecheck','type-check','check:types','types:check'],
|
|
16
|
+
typecheck:['typecheck','type-check','check:types','types:check','check-types'],
|
|
16
17
|
test:['test','test:unit','test:check'],
|
|
17
18
|
lint:['lint','lint:check'],
|
|
18
19
|
build:['build'],
|
|
@@ -82,6 +83,28 @@ function manager(root,pkg) {
|
|
|
82
83
|
return {name,version,layout:'node-modules/classic',issue:null,lockfiles:locks};
|
|
83
84
|
}
|
|
84
85
|
const exactTypeScript=script=>script.trim()==='tsc --noEmit';
|
|
86
|
+
// A script of tsc runs that each pass --noEmit (typescript-invocation.mjs).
|
|
87
|
+
const noEmitRuns=script=>Boolean(tscRuns(script)?.every(run=>run.noEmit));
|
|
88
|
+
// Ready checks for a typecheck, one per tsc run: the script's runs (a chain
|
|
89
|
+
// gives NAME.1 ... NAME.n), or with no script one proposed `tsc --noEmit`.
|
|
90
|
+
// `make(run)` returns a run's command, cwd and inputs, or throws why its
|
|
91
|
+
// contract does not hold.
|
|
92
|
+
function tscChecks(name,script,scripts,make){
|
|
93
|
+
const runs=script===null?[{args:['--noEmit']}]:tscRuns(scripts[script]);
|
|
94
|
+
if(!runs)throw Error('the script is not tsc runs in a supported form');
|
|
95
|
+
return runs.map(({args},i)=>({name:partName(name,i+1,runs.length),
|
|
96
|
+
...(script===null?{}:{script}),...make({script,args,count:runs.length})}));
|
|
97
|
+
}
|
|
98
|
+
// A proposed `tsc --noEmit` only where nothing typechecks already: a script
|
|
99
|
+
// that runs any TypeScript checker in another form (tsc -b, vue-tsc,
|
|
100
|
+
// svelte-check, tsgo, also quoted or wrapped by concurrently or run-p) is the
|
|
101
|
+
// project's own typecheck, which plain tsc would not match.
|
|
102
|
+
const typeChecker=/(?:^|[^\w-])(?:tsc|vue-tsc|tsgo|svelte-check|tsc-watch)(?=$|[^\w-])|\bastro check\b|\bnuxi typecheck\b/;
|
|
103
|
+
const typechecks=scripts=>Object.values(scripts||{}).some(text=>typeof text==='string'&&typeChecker.test(text));
|
|
104
|
+
const proposable=(dir,scripts)=>exists(path.join(dir,'tsconfig.json'))&&!typechecks(scripts);
|
|
105
|
+
// The inputs a TypeScript contract adds to its check's config: no exclusions,
|
|
106
|
+
// which would outlive the tsconfig they came from; the plan adds the current ones.
|
|
107
|
+
const contractInputs=contract=>[...contract.configs,...contract.sourcePatterns.filter(p=>!p.startsWith('!'))];
|
|
85
108
|
function sourceLinkIssue(root) {
|
|
86
109
|
const stack=likelyDirs.filter(name=>exists(path.join(root,name)));
|
|
87
110
|
let inspected=0;
|
|
@@ -139,14 +162,26 @@ function selectScript(kind,names,scripts) {
|
|
|
139
162
|
const candidates=Object.keys(scripts).filter(name=>{
|
|
140
163
|
const value=scripts[name]?.trim();
|
|
141
164
|
if(typeof value!=='string')return false;
|
|
142
|
-
if(kind==='typecheck')return
|
|
165
|
+
if(kind==='typecheck')return noEmitRuns(value);
|
|
143
166
|
if(kind==='test')return /^(?:vitest|jest)(?:\s|$)/.test(value);
|
|
144
167
|
if(kind==='lint')return /^eslint(?:\s|$)/.test(value);
|
|
145
168
|
return /^vite\s+build(?:\s|$)/.test(value);
|
|
146
169
|
});
|
|
170
|
+
// A lone plain `tsc --noEmit` wins over other tsc shapes beside it (a
|
|
171
|
+
// project's `typescript` next to `typescript:tests:raw`).
|
|
172
|
+
const exact=kind==='typecheck'?candidates.filter(name=>exactTypeScript(scripts[name])):[];
|
|
173
|
+
if(exact.length===1)return {script:exact[0]};
|
|
147
174
|
return candidates.length===1?{script:candidates[0]}:
|
|
148
175
|
candidates.length>1?{ambiguous:candidates}:{};
|
|
149
176
|
}
|
|
177
|
+
// A pnpm workspace package's TypeScript run, as its check's command, cwd and inputs.
|
|
178
|
+
function pnpmCheck(root,name,dir,manifest,run){
|
|
179
|
+
const contract=pnpmTypecheckInputs(root,name,run);
|
|
180
|
+
if(contract.limitations.length)throw Error(contract.limitations.join('; '));
|
|
181
|
+
return {cwd:dir,command:['@node',`@typescript-bin:${dir}`,...run.args],
|
|
182
|
+
inputs:[...new Set(['package.json','pnpm-lock.yaml','pnpm-workspace.yaml',manifest,
|
|
183
|
+
...contractInputs(contract)])]};
|
|
184
|
+
}
|
|
150
185
|
// Workspace packages as init finds them, with their scripts; [] without workspaces.
|
|
151
186
|
export function workspacePackages(root){
|
|
152
187
|
root=realObservedPath(root);
|
|
@@ -178,19 +213,27 @@ export function discoverNodeProject(root,{recipe=null}={}) {
|
|
|
178
213
|
for(const [kind,names] of Object.entries(scriptNames)) {
|
|
179
214
|
const selected=selectScript(kind,names,scripts),script=selected.script;
|
|
180
215
|
if(selected.ambiguous)ambiguous.push({kind,scripts:selected.ambiguous});
|
|
181
|
-
|
|
216
|
+
// With no typecheck script, a root tsconfig.json gets a proposed
|
|
217
|
+
// `tsc --noEmit` check; package.json stays unchanged. It is kept only
|
|
218
|
+
// when a contract makes it ready.
|
|
219
|
+
const proposed=!script&&!selected.ambiguous&&kind==='typecheck'&&!observationIssue&&
|
|
220
|
+
proposable(root,scripts);
|
|
221
|
+
if(!script&&!proposed)continue;
|
|
182
222
|
let classification=observationIssue?{level:'unsupported',reason:observationIssue}:
|
|
183
|
-
classify(kind,script,scripts[script],pkg,pm,root);
|
|
223
|
+
script?classify(kind,script,scripts[script],pkg,pm,root):{level:'recording',reason:'no typecheck script'};
|
|
224
|
+
let parts=null;
|
|
184
225
|
if(workspaces&&classification.qualification==='typescript-noemit-v1')classification={level:'recording',
|
|
185
226
|
reason:'workspace task and installed-input closure need explicit qualification'};
|
|
186
227
|
const check={name:kind,script,kind,inputs:sourceInputs(root,kind,pm.lockfiles||[])};
|
|
187
228
|
if(recipe==='typescript-direct'&&kind==='typecheck'){
|
|
188
229
|
try{
|
|
189
230
|
if(pm.name!=='npm'||workspaces||observationIssue||pm.issue)throw Error('direct recipe requires a supported single-package npm checkout');
|
|
190
|
-
|
|
191
|
-
|
|
231
|
+
let version;
|
|
232
|
+
parts=tscChecks('typecheck',script??null,scripts,run=>{
|
|
233
|
+
version=directProject(root,run).version;
|
|
234
|
+
return {command:['@node','@typescript-compiler:.',...run.args],cwd:'.'};});
|
|
192
235
|
classification={level:'ready',qualification:directQualification,
|
|
193
|
-
reason:`explicit local TypeScript ${
|
|
236
|
+
reason:`explicit local TypeScript ${version} compilation; npm launcher/lifecycle scripts are not executed; package.json stays unchanged`};
|
|
194
237
|
}catch(e){classification={level:'recording',reason:`Direct recipe unavailable (${e.code||e.message}); original npm script retained, without bypassing required behavior`};}
|
|
195
238
|
}
|
|
196
239
|
if(recipe==='tests-direct'&&kind==='test'){
|
|
@@ -207,20 +250,24 @@ export function discoverNodeProject(root,{recipe=null}={}) {
|
|
|
207
250
|
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
251
|
}catch(e){classification={level:'recording',reason:`Test recipe unavailable (${e.code||e.message}); original npm script retained`};}
|
|
209
252
|
}
|
|
210
|
-
if(pm.name==='pnpm'&&kind==='typecheck'&&!workspaces&&!pm.issue&&
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
'tsconfig.json','src/**'];
|
|
253
|
+
if(pm.name==='pnpm'&&kind==='typecheck'&&!workspaces&&!pm.issue&&(script===undefined||tscRuns(scripts[script]))){
|
|
254
|
+
try{
|
|
255
|
+
parts=tscChecks('typecheck',script??null,scripts,run=>{
|
|
256
|
+
const contract=pnpmTypecheckInputs(root,'.',run);
|
|
257
|
+
if(contract.limitations.length)throw Error(contract.limitations.join('; '));
|
|
258
|
+
return {command:['@node','@typescript-bin:.',...run.args],cwd:'.',
|
|
259
|
+
inputs:[...new Set(['package.json','pnpm-lock.yaml',
|
|
260
|
+
...(exists(path.join(root,'pnpm-workspace.yaml'))?['pnpm-workspace.yaml']:[]),
|
|
261
|
+
...contractInputs(contract)])]};});
|
|
220
262
|
classification={level:'ready',reason:'pnpm TypeScript inputs and installation; checked again before reuse',
|
|
221
263
|
qualification:'pnpm-tsc-v1'};}
|
|
222
264
|
catch(e){classification={level:'recording',reason:`pnpm TypeScript contract needs review: ${e.message}`};}
|
|
223
265
|
}
|
|
266
|
+
if(!script&&classification.level!=='ready')continue;
|
|
267
|
+
if(parts&&classification.level==='ready'){
|
|
268
|
+
for(const part of parts)checks.push({config:{...check,...part,qualification:classification.qualification},...classification});
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
224
271
|
if(classification.qualification)check.qualification=classification.qualification;
|
|
225
272
|
checks.push({config:check,...classification});
|
|
226
273
|
}
|
|
@@ -238,6 +285,16 @@ export function discoverNodeProject(root,{recipe=null}={}) {
|
|
|
238
285
|
const selected=selectScript(kind,names,member.scripts||{});
|
|
239
286
|
if(selected.ambiguous){ambiguous.push({kind:`${name}:${kind}`,
|
|
240
287
|
scripts:selected.ambiguous});continue;}
|
|
288
|
+
// A pnpm package with a tsconfig.json and no typecheck script gets a
|
|
289
|
+
// proposed `tsc --noEmit` check when the pnpm contract holds.
|
|
290
|
+
if(!selected.script&&kind==='typecheck'&&pm.name==='pnpm'&&!pm.issue&&
|
|
291
|
+
proposable(path.join(root,dir),member.scripts)&&!typechecks(scripts)){
|
|
292
|
+
try{for(const part of tscChecks(`${name}:typecheck`,null,{},run=>pnpmCheck(root,name,dir,file,run)))
|
|
293
|
+
checks.push({config:{kind,workspace:name,qualification:'pnpm-tsc-v1',...part},level:'ready',
|
|
294
|
+
reason:'proposed tsc --noEmit for the package\'s tsconfig.json; pnpm workspace TypeScript inputs; checked again before reuse'});}
|
|
295
|
+
catch{}
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
241
298
|
if(!selected.script)continue;
|
|
242
299
|
const command=pm.name==='npm'?['@which:npm','run',selected.script,
|
|
243
300
|
'--workspace',name]:pm.name==='yarn'?
|
|
@@ -258,20 +315,11 @@ export function discoverNodeProject(root,{recipe=null}={}) {
|
|
|
258
315
|
level='ready';reason='pinned Yarn workspace tsc and installed inputs; checked again before reuse';
|
|
259
316
|
}catch(e){reason=`workspace TypeScript contract needs review: ${e.message}`;}
|
|
260
317
|
}
|
|
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';
|
|
318
|
+
if(!pm.issue&&pm.name==='pnpm'&&kind==='typecheck'&&tscRuns((member.scripts||{})[selected.script])){
|
|
319
|
+
try{const parts=tscChecks(`${name}:typecheck`,selected.script,member.scripts,run=>pnpmCheck(root,name,dir,file,run));
|
|
320
|
+
for(const part of parts)checks.push({config:{kind,workspace:name,qualification:'pnpm-tsc-v1',...part},level:'ready',
|
|
321
|
+
reason:'pnpm workspace TypeScript inputs; checked again before reuse'});
|
|
322
|
+
continue;
|
|
275
323
|
}catch(e){reason=`pnpm TypeScript contract needs review: ${e.message}`;}
|
|
276
324
|
}
|
|
277
325
|
if(recipe==='tests-direct'&&kind==='test'&&!pm.issue){
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// An installed npm package checked against what the lockfile recorded: the
|
|
2
|
+
// tarball npm cached under the lockfile's integrity (sha512, else sha1),
|
|
3
|
+
// unpacked in memory and compared file by file. Nothing is fetched.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import zlib from 'node:zlib';
|
|
8
|
+
import {createHash} from 'node:crypto';
|
|
9
|
+
import {npmrc} from './npmrc.mjs';
|
|
10
|
+
import {racyUntil} from './path-identity.mjs';
|
|
11
|
+
|
|
12
|
+
const fail=code=>{throw Object.assign(Error(code),{code});};
|
|
13
|
+
// npm's cache directory, as npm picks it: npm_config_cache, else `cache` in
|
|
14
|
+
// the project's .npmrc, then the user's (npm_config_userconfig or
|
|
15
|
+
// ~/.npmrc), else npm's default. A leading ~ is the home directory. The
|
|
16
|
+
// global npmrc is not read.
|
|
17
|
+
export function npmCacheDir(root=null,env=process.env,platform=process.platform,home=os.homedir()){
|
|
18
|
+
const variable=name=>Object.entries(env).find(([key])=>key.toLowerCase()===name)?.[1];
|
|
19
|
+
const expand=value=>path.resolve(value.replace(/^~(?=$|[\/])/,home));
|
|
20
|
+
const configured=variable('npm_config_cache');
|
|
21
|
+
if(configured)return expand(configured);
|
|
22
|
+
for(const file of [root&&path.join(root,'.npmrc'),variable('npm_config_userconfig')||path.join(home,'.npmrc')].filter(Boolean)){
|
|
23
|
+
let cache;try{cache=npmrc(file).cache;}catch{}
|
|
24
|
+
if(typeof cache==='string'&&cache)return expand(cache);
|
|
25
|
+
}
|
|
26
|
+
return platform==='win32'?path.join(env.LOCALAPPDATA||path.join(home,'AppData','Local'),'npm-cache'):path.join(home,'.npm');
|
|
27
|
+
}
|
|
28
|
+
// The package-lock.json entry for an installed top-level package:
|
|
29
|
+
// {version, integrity, resolved, alias}. npm-shrinkwrap.json, which npm reads
|
|
30
|
+
// instead, is not modeled. Parsed entries are kept per process by the
|
|
31
|
+
// lockfile's identity: the daemon asks on every reuse check.
|
|
32
|
+
const lockfiles=new Map();
|
|
33
|
+
export function lockedPackage(root,name){
|
|
34
|
+
if(fs.existsSync(path.join(root,'npm-shrinkwrap.json')))fail('npm-shrinkwrap-unsupported');
|
|
35
|
+
const file=path.join(root,'package-lock.json');
|
|
36
|
+
let st;try{st=fs.statSync(file,{bigint:true});}catch{fail('npm-lockfile-integrity-missing');}
|
|
37
|
+
const identity=[st.dev,st.ino,st.size,st.mtimeNs,st.ctimeNs].join(':');
|
|
38
|
+
let packages=lockfiles.get(file)?.identity===identity?lockfiles.get(file).packages:null;
|
|
39
|
+
if(!packages){
|
|
40
|
+
try{packages=JSON.parse(fs.readFileSync(file,'utf8'))?.packages;}catch{fail('npm-lockfile-unreadable');}
|
|
41
|
+
// A lockfile changed within the racy window is read again next time.
|
|
42
|
+
if(racyUntil(st)<=Date.now())lockfiles.set(file,{identity,packages});
|
|
43
|
+
}
|
|
44
|
+
const entry=packages?.[`node_modules/${name}`];
|
|
45
|
+
if(!entry||typeof entry.version!=='string'||typeof entry.integrity!=='string')fail('npm-lockfile-integrity-missing');
|
|
46
|
+
return {version:entry.version,integrity:entry.integrity,resolved:entry.resolved??null,alias:entry.name??null};
|
|
47
|
+
}
|
|
48
|
+
// Whether a lockfile entry is the registry's own package NAME at VERSION:
|
|
49
|
+
// no alias, and a tarball URL .../NAME/-/NAME-VERSION.tgz over https.
|
|
50
|
+
export const registryPackage=(entry,name,version)=>entry.alias===null&&typeof entry.resolved==='string'&&
|
|
51
|
+
/^https:\/\//.test(entry.resolved)&&entry.resolved.endsWith(`/${name}/-/${name}-${version}.tgz`);
|
|
52
|
+
// The digest an SRI string names, as {algorithm, hex}: sha512, else sha1
|
|
53
|
+
// (some registries record only sha1; a collision needs a malicious party,
|
|
54
|
+
// which is outside REDUE's threat model). null for anything else.
|
|
55
|
+
export function sriDigest(integrity){
|
|
56
|
+
const items=String(integrity).split(/\s+/);
|
|
57
|
+
for(const [algorithm,length] of [['sha512',64],['sha1',20]]){
|
|
58
|
+
const value=items.map(item=>new RegExp(`^${algorithm}-([A-Za-z0-9+/]+={0,2})$`).exec(item)?.[1]).find(Boolean);
|
|
59
|
+
const bytes=value&&Buffer.from(value,'base64');
|
|
60
|
+
if(bytes?.length===length)return {algorithm,hex:bytes.toString('hex')};
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
// The cached tarball for an integrity, checked against it.
|
|
65
|
+
export function cachedTarball(cache,integrity){
|
|
66
|
+
const digest=sriDigest(integrity);
|
|
67
|
+
if(!digest)fail('npm-lockfile-integrity-unsupported');
|
|
68
|
+
const {algorithm,hex}=digest;
|
|
69
|
+
let bytes;
|
|
70
|
+
try{bytes=fs.readFileSync(path.join(cache,'_cacache','content-v2',algorithm,hex.slice(0,2),hex.slice(2,4),hex.slice(4)));}
|
|
71
|
+
catch{fail('npm-tarball-uncached');}
|
|
72
|
+
if(createHash(algorithm).update(bytes).digest('hex')!==hex)fail('npm-tarball-corrupt');
|
|
73
|
+
return bytes;
|
|
74
|
+
}
|
|
75
|
+
// The regular files of a gzipped npm tarball, as Map(path below the top
|
|
76
|
+
// directory -> bytes). Links and special files are refused.
|
|
77
|
+
export function tarballFiles(gzipped){
|
|
78
|
+
let tar;try{tar=zlib.gunzipSync(gzipped,{maxOutputLength:512*1024*1024});}catch{fail('npm-tarball-invalid');}
|
|
79
|
+
const files=new Map();
|
|
80
|
+
const text=(block,from,to)=>block.subarray(from,to).toString('utf8').replace(/\0[\s\S]*$/,'');
|
|
81
|
+
let offset=0,longName=null,paxPath=null;
|
|
82
|
+
while(offset+512<=tar.length){
|
|
83
|
+
const header=tar.subarray(offset,offset+512);
|
|
84
|
+
if(header.every(byte=>byte===0))break;
|
|
85
|
+
const size=parseInt(text(header,124,136).trim()||'0',8),type=String.fromCharCode(header[156]||48);
|
|
86
|
+
if(!Number.isSafeInteger(size)||size<0)fail('npm-tarball-invalid');
|
|
87
|
+
const body=tar.subarray(offset+512,offset+512+size);
|
|
88
|
+
offset+=512+Math.ceil(size/512)*512;
|
|
89
|
+
if(type==='x'){
|
|
90
|
+
// pax records: "LEN key=value\n"
|
|
91
|
+
for(const record of body.toString('utf8').split('\n')){const match=/^\d+ path=(.*)$/.exec(record);if(match)paxPath=match[1];}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if(type==='g')continue;
|
|
95
|
+
if(type==='L'){longName=body.toString('utf8').replace(/\0[\s\S]*$/,'');continue;}
|
|
96
|
+
const prefix=text(header,345,500);
|
|
97
|
+
const name=paxPath??longName??(prefix?`${prefix}/${text(header,0,100)}`:text(header,0,100));
|
|
98
|
+
paxPath=null;longName=null;
|
|
99
|
+
if(type==='5')continue;
|
|
100
|
+
if(!['0','7'].includes(type))fail('npm-tarball-link-unsupported');
|
|
101
|
+
// npm strips the top directory (usually package/).
|
|
102
|
+
const relative=name.split('/').slice(1).join('/');
|
|
103
|
+
if(!relative||relative.split('/').some(part=>part===''||part==='.'||part==='..'))fail('npm-tarball-invalid');
|
|
104
|
+
files.set(relative,body);
|
|
105
|
+
}
|
|
106
|
+
return files;
|
|
107
|
+
}
|
|
108
|
+
// Whether `dir` holds exactly the tarball's files, byte for byte; throws
|
|
109
|
+
// otherwise. About 120 ms for TypeScript 6 (140 files, 24 MB).
|
|
110
|
+
export function matchTarball(dir,files){
|
|
111
|
+
let count=0;
|
|
112
|
+
const walk=(folder,relative='')=>{
|
|
113
|
+
let entries;try{entries=fs.readdirSync(folder,{withFileTypes:true});}catch{fail('direct-typescript-implementation-unreviewed');}
|
|
114
|
+
for(const entry of entries){
|
|
115
|
+
const name=relative?`${relative}/${entry.name}`:entry.name,full=path.join(folder,entry.name);
|
|
116
|
+
if(entry.isDirectory()){walk(full,name);continue;}
|
|
117
|
+
const expected=files.get(name);
|
|
118
|
+
let same=false;try{same=entry.isFile()&&Boolean(expected)&&fs.readFileSync(full).equals(expected);}catch{}
|
|
119
|
+
if(!same)fail('direct-typescript-implementation-unreviewed');
|
|
120
|
+
count++;
|
|
121
|
+
}
|
|
122
|
+
};walk(dir);
|
|
123
|
+
if(count!==files.size)fail('direct-typescript-implementation-unreviewed');
|
|
124
|
+
}
|
package/src/npmrc.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// An .npmrc file read as npm reads ini: comments, a BOM, `key = value`, a bare
|
|
2
|
+
// key as true, quotes stripped, true/false as booleans. {} when it is absent.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
export function npmrc(file){
|
|
6
|
+
const settings={};
|
|
7
|
+
if(!fs.existsSync(file))return settings;
|
|
8
|
+
for(const line of fs.readFileSync(file,'utf8').replace(/^\uFEFF/,'').split(/\r?\n/)){
|
|
9
|
+
const trimmed=line.trim();if(!trimmed||/^[#;]/.test(trimmed))continue;
|
|
10
|
+
const at=trimmed.indexOf('='),key=(at<0?trimmed:trimmed.slice(0,at)).trim();
|
|
11
|
+
let value=at<0?true:trimmed.slice(at+1).trim();
|
|
12
|
+
if(typeof value==='string'&&/^(["']).*\1$/.test(value))value=value.slice(1,-1);
|
|
13
|
+
settings[key]=value==='true'?true:value==='false'?false:value;
|
|
14
|
+
}
|
|
15
|
+
return settings;
|
|
16
|
+
}
|
package/src/plan-worker.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import {createHash} from 'node:crypto';
|
|
|
5
5
|
import {discover} from './plan.mjs';
|
|
6
6
|
import {InputIndex,dehydrateIndex} from './index.mjs';
|
|
7
7
|
import {directProject,directQualification} from './direct-typescript.mjs';
|
|
8
|
+
import {checkRun} from './typescript-invocation.mjs';
|
|
8
9
|
import {queriesMatch} from './typescript-list.mjs';
|
|
9
10
|
import {planGuard,inputKey} from './decision-validation.mjs';
|
|
10
11
|
import {timing,traceBase} from './decision-profile.mjs';
|
|
@@ -25,8 +26,10 @@ try {
|
|
|
25
26
|
const queryStarted=performance.now();
|
|
26
27
|
// Cached membership queries load the compiler API; establish its reviewed
|
|
27
28
|
// implementation before that optimization can execute project code.
|
|
29
|
+
// Every direct check (a chain has one per run) uses the root's compiler:
|
|
30
|
+
// its bytes are verified once.
|
|
28
31
|
const directRoots=new Set(config.checks.filter(check=>check.qualification===directQualification)
|
|
29
|
-
.map(check=>directProject(config.root,check
|
|
32
|
+
.map((check,i)=>directProject(config.root,checkRun(check),{verify:i===0}).tsRoot));
|
|
30
33
|
if(directRoots.size&&(workerData.reuse.queries||[]).some(([kind,,,options])=>
|
|
31
34
|
kind==='readDirectory'&&!directRoots.has(options?.tsRoot)))throw Error('retained compiler implementation changed');
|
|
32
35
|
const queriesValid=guard===workerData.reuse.guard&&
|